@jaato/web-coder-ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -0
- package/bin/jaato-web-coder-ui.d.ts +53 -0
- package/bin/jaato-web-coder-ui.js +328 -0
- package/dist/assets/index-BbhvTZYy.js +23 -0
- package/dist/assets/index-DYQXAFDQ.css +1 -0
- package/dist/build-info.json +7 -0
- package/dist/index.html +13 -0
- package/package.json +79 -0
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# jaato-web-coder-ui
|
|
2
|
+
|
|
3
|
+
Browser client for a jaato daemon — the web counterpart of `jaato-tui`.
|
|
4
|
+
It connects to `python -m server --web-socket …` over the daemon's
|
|
5
|
+
WebSocket transport through the TypeScript SDK (`@jaato/sdk`, in
|
|
6
|
+
`../jaato-sdk-ts`), and renders the same session a TUI can be attached to.
|
|
7
|
+
|
|
8
|
+
Design notes, stack rationale and the input model live in
|
|
9
|
+
[`docs/web-client-design.md`](../docs/web-client-design.md).
|
|
10
|
+
|
|
11
|
+
## Stack
|
|
12
|
+
|
|
13
|
+
| Layer | Choice | Why |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| UI | React 19 + Vite 7 | largest widget ecosystem for the pieces a client like this needs (virtualised lists, docking, terminals) |
|
|
16
|
+
| State | Zustand, one event-sourced reducer | the protocol is a stream of typed events; the store folds them, components subscribe to slices |
|
|
17
|
+
| Protocol | `@jaato/sdk` (workspace `file:` dep, source-aliased) | codegen'd event types stay in lockstep with `events.py`; no hand-written event mirror |
|
|
18
|
+
| Styling | Tailwind v4 + CSS variables | the eleven base colours of the TUI's `themes/*.json` become CSS custom properties, so both clients share one palette definition |
|
|
19
|
+
| Rendering | own `<j-*>` + markdown parsers, no `innerHTML` | the server emits neutral markup (`<j-code>`, `<j-table>`, Pygments token classes); the client only maps classes to theme colours |
|
|
20
|
+
| Tests | Vitest (protocol, store) + Playwright (UI against a scripted mock daemon) | |
|
|
21
|
+
|
|
22
|
+
## Commands are words, not `/verbs`
|
|
23
|
+
|
|
24
|
+
jaato commands are typed as bare words: `model gpt-4o`, `tools enable cli`,
|
|
25
|
+
`permissions status`. The composer proposes matching commands while the
|
|
26
|
+
first word is typed; **Esc on the proposal means "I mean this word
|
|
27
|
+
verbatim"** and the line is sent to the model as text even though it starts
|
|
28
|
+
with a command word. A hint under the box always states what Enter will do.
|
|
29
|
+
`@path`, `@@path`, `%prompt` and `/name` (workspace commands under
|
|
30
|
+
`.jaato/commands/`) pass through untouched and disable command proposals,
|
|
31
|
+
as in the TUI. See `src/protocol/commands.ts` for the routing rules, which
|
|
32
|
+
are a port of `jaato-tui/client_commands.py`.
|
|
33
|
+
|
|
34
|
+
## Run it
|
|
35
|
+
|
|
36
|
+
The client is published on npm as `@jaato/web-coder-ui`; the package is the built
|
|
37
|
+
bundle plus a dependency-free launcher, so `npx` fetches it in one go:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
.venv/bin/python -m server --web-socket :8080 --daemon # the daemon, on this machine
|
|
41
|
+
npx @jaato/web-coder-ui # serves the client, opens the browser
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The launcher serves `dist/` on `http://127.0.0.1:5180/`, hands the page the
|
|
45
|
+
daemon URL and — for a local daemon — the token from `~/.jaato/ws.token`, and
|
|
46
|
+
the page connects on its own. Against a daemon elsewhere:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx @jaato/web-coder-ui --daemon ws://build-box:8080 --token-file ./ws.token
|
|
50
|
+
npx @jaato/web-coder-ui --daemon wss://jaato.example.org --no-token # type the token in the form
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| Flag | Default | Meaning |
|
|
54
|
+
|---|---|---|
|
|
55
|
+
| `--daemon URL` | `ws://127.0.0.1:8080` | the daemon's `--web-socket` address |
|
|
56
|
+
| `--token TOKEN` / `--token-file PATH` | `~/.jaato/ws.token` when the daemon is loopback and the file exists | bearer token handed to the page |
|
|
57
|
+
| `--no-token` | | hand out no token; the form asks for it (or leave it empty for `--ws-unsafe-no-auth`) |
|
|
58
|
+
| `--host HOST` / `--port PORT` | `127.0.0.1` / `5180` (`0` = any free port) | where the client is served |
|
|
59
|
+
| `--no-open` | | do not open a browser |
|
|
60
|
+
| `--root DIR` | the package's `dist/` | serve another build |
|
|
61
|
+
|
|
62
|
+
The token is only ever handed to the page on a loopback bind: with
|
|
63
|
+
`--host 0.0.0.0` the launcher serves the client to the network and the
|
|
64
|
+
person at the browser types the token. On loopback the `Host` header must
|
|
65
|
+
name the bound address, so a DNS-rebinding page cannot fetch it either.
|
|
66
|
+
|
|
67
|
+
### Hosting the bundle yourself
|
|
68
|
+
|
|
69
|
+
`dist/` is static (assets are referenced relatively, so it can sit under any
|
|
70
|
+
path) and connects to whatever daemon URL the form is given. Put it behind
|
|
71
|
+
nginx, a CDN or a file server, and optionally publish a `config.json` next to
|
|
72
|
+
`index.html` to pre-fill the form:
|
|
73
|
+
|
|
74
|
+
```json
|
|
75
|
+
{"daemon": "wss://jaato.example.org", "autoConnect": true}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`token` is also accepted there, but a token in a file every visitor can read
|
|
79
|
+
is only right when the file server is as private as the daemon. Every
|
|
80
|
+
publish run of the npm workflow attaches `jaato-web-coder-ui-dist-<version>.tar.gz`
|
|
81
|
+
(the contents of `dist/`) to the GitHub Release `web-coder-ui-v<version>` for
|
|
82
|
+
this use; `npm run build` produces the same directory from a checkout.
|
|
83
|
+
|
|
84
|
+
### Knowing what you deployed
|
|
85
|
+
|
|
86
|
+
The SDK is compiled into the bundle, so the build stamps what it speaks:
|
|
87
|
+
`dist/build-info.json` carries the UI version, the `@jaato/sdk` revision,
|
|
88
|
+
its protocol floor and the commit. The connect screen and the status bar
|
|
89
|
+
show the same line, and the launcher prints it:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npx @jaato/web-coder-ui --version
|
|
93
|
+
# 0.1.0
|
|
94
|
+
# @jaato/sdk 0.6.0 · protocol ≥ 1.0 · cb65e5e · built 2026-09-15T20:40:00.000Z
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Develop
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
# once: build the SDK declarations the type-check uses
|
|
101
|
+
npm --prefix ../jaato-sdk-ts install && npm --prefix ../jaato-sdk-ts run build
|
|
102
|
+
|
|
103
|
+
npm install
|
|
104
|
+
npm run dev # http://localhost:5173, proxies /ws → ws://127.0.0.1:8080
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Against a real daemon:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
.venv/bin/python -m server --web-socket :8080 --workspace-root /srv/workspaces # or without --workspace-root
|
|
111
|
+
cat ~/.jaato/ws.token # paste into the connect form
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Against the scripted mock (no provider needed):
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm run mock-daemon # ws://127.0.0.1:8090 — try: code, tool, permit, ask, fail, subagent
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Connect the form to `ws://127.0.0.1:8090`. `MOCK_WORKSPACES=1` turns on the
|
|
121
|
+
workspace-first flow, `MOCK_TOKEN=x` enforces bearer auth.
|
|
122
|
+
|
|
123
|
+
## Verify
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
npm run typecheck # builds the SDK, then tsc
|
|
127
|
+
npm test # vitest (protocol, store) + node --test (launcher)
|
|
128
|
+
npm run build # production bundle in dist/
|
|
129
|
+
npm start -- --no-open # the launcher, serving that dist/
|
|
130
|
+
npm run e2e # Playwright, starts the mock daemon and Vite itself
|
|
131
|
+
npm pack # the npm tarball (refuses without a dist/)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Publishing is manual: the *Publish @jaato/web-coder-ui to npm* workflow
|
|
135
|
+
(`.github/workflows/publish-npm-web-coder-ui.yml`) runs the same gates as CI,
|
|
136
|
+
refuses a version already on the registry, builds, and **stages** the version
|
|
137
|
+
with the `@jaato` org's stage-only token; a maintainer with 2FA promotes it
|
|
138
|
+
(`npm stage list @jaato/web-coder-ui`, then `npm stage approve <stage-id>
|
|
139
|
+
--otp <code>`). It needs `@jaato/sdk` published first only at build time, from
|
|
140
|
+
the sibling checkout. Bump `version` in `package.json` first.
|
|
141
|
+
|
|
142
|
+
Set `PLAYWRIGHT_CHROMIUM=/path/to/chrome` to use a pre-installed browser
|
|
143
|
+
instead of Playwright's download.
|
|
144
|
+
|
|
145
|
+
## Layout
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
bin/ jaato-web-coder-ui.js — the launcher shipped as the package's `bin` (static server + config.json + browser open), tested with node --test
|
|
149
|
+
src/
|
|
150
|
+
app/ actions.ts (what a submitted line does), launcherConfig.ts (the page's side of config.json)
|
|
151
|
+
protocol/ commands.ts (routing + completion), jmarkup.ts, markdown.ts, pygments.ts — pure, unit-tested
|
|
152
|
+
store/ types.ts, store.ts (reduce(state, event)) — event-sourced client state
|
|
153
|
+
sdk/ connection.ts — JaatoClient lifecycle, frame-batched dispatch, workspace verbs
|
|
154
|
+
app/ actions.ts — what a submitted line does (prompt answers, client/server commands, messages)
|
|
155
|
+
components/ output/ (JMarkup, ToolBlockView, OutputPane, ToolOutputPopup, MediaView)
|
|
156
|
+
input/Composer.tsx prompts/ (Permission, Clarification, ReferenceSelection)
|
|
157
|
+
panels/ (Plan, Budget, Workspace, AgentTabs) layout/StatusBar.tsx
|
|
158
|
+
screens/ ConnectScreen, WorkspaceScreen, SessionScreen
|
|
159
|
+
theme/ themes.ts (imports ../jaato-tui/themes/*.json), theme.css
|
|
160
|
+
mock/daemon.ts scripted daemon speaking the wire protocol, for dev + e2e
|
|
161
|
+
e2e/ Playwright smoke suite
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Keys
|
|
165
|
+
|
|
166
|
+
`Enter` send · `Shift+Enter` newline · `Tab` complete / re-arm proposals ·
|
|
167
|
+
`Esc` dismiss proposal (send verbatim) · `Ctrl+P` plan · `Ctrl+B` budget ·
|
|
168
|
+
`Alt+W` files · `Ctrl+T` expand/collapse tools · `Ctrl+A` next agent ·
|
|
169
|
+
`Ctrl+O` next running tool in the popup · `Ctrl+C` (nothing selected) stop.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type declarations for the package's `exports` entry (`bin/jaato-web-coder-ui.js`).
|
|
3
|
+
* Hand-written: the launcher is plain JavaScript so that `npx @jaato/web-coder-ui`
|
|
4
|
+
* installs nothing but the bundle, and this file is what lets a TypeScript host
|
|
5
|
+
* (`jaato-web-coder-server`) import it.
|
|
6
|
+
*/
|
|
7
|
+
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
|
8
|
+
|
|
9
|
+
/** The bundle this package ships (`<package>/dist`). */
|
|
10
|
+
export declare const DIST_DIR: string;
|
|
11
|
+
|
|
12
|
+
/** What `GET /config.json` answers; see `src/app/launcherConfig.ts` for the page's reading of it. */
|
|
13
|
+
export interface StaticConfig {
|
|
14
|
+
daemon?: string;
|
|
15
|
+
token?: string;
|
|
16
|
+
ticketUrl?: string;
|
|
17
|
+
loginUrl?: string;
|
|
18
|
+
autoConnect?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface StaticOptions {
|
|
22
|
+
/** Directory holding `index.html` and `assets/`. */
|
|
23
|
+
root: string;
|
|
24
|
+
config: StaticConfig;
|
|
25
|
+
/** `Host` header values accepted (lower-case `host:port`); `null` accepts any. */
|
|
26
|
+
allowedHosts: Set<string> | null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type StaticHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
30
|
+
|
|
31
|
+
export declare function createStaticHandler(opts: StaticOptions): StaticHandler;
|
|
32
|
+
export declare function createStaticServer(opts: StaticOptions): Server;
|
|
33
|
+
|
|
34
|
+
export interface LauncherOptions {
|
|
35
|
+
daemon: string;
|
|
36
|
+
token?: string;
|
|
37
|
+
tokenFile?: string;
|
|
38
|
+
noToken: boolean;
|
|
39
|
+
host: string;
|
|
40
|
+
port: number;
|
|
41
|
+
open: boolean;
|
|
42
|
+
root: string;
|
|
43
|
+
help: boolean;
|
|
44
|
+
version: boolean;
|
|
45
|
+
}
|
|
46
|
+
export declare function parseArgs(argv: string[]): LauncherOptions;
|
|
47
|
+
export declare function isLoopbackHost(host: string): boolean;
|
|
48
|
+
export declare function resolveToken(opts: Partial<LauncherOptions>, io?: { readFile?: (p: string) => string; exists?: (p: string) => boolean }): { token: string | undefined; source: string };
|
|
49
|
+
|
|
50
|
+
export interface BuildInfo { ui: string; sdk: string; protocolMin: string; commit: string; builtAt: string }
|
|
51
|
+
export declare function readBuildInfo(root?: string): BuildInfo | null;
|
|
52
|
+
|
|
53
|
+
export declare function main(opts: LauncherOptions): Promise<Server | null>;
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ``jaato-web-coder-ui`` — the launcher that ships in the ``@jaato/web-coder-ui`` package.
|
|
4
|
+
*
|
|
5
|
+
* The browser client is a static bundle (``dist/``, produced by
|
|
6
|
+
* ``vite build``); all this script does is serve that bundle from a
|
|
7
|
+
* local port, tell the page which daemon to talk to, and open a browser
|
|
8
|
+
* on it. It is deliberately dependency-free (Node's ``http`` and ``fs``
|
|
9
|
+
* only) so ``npx @jaato/web-coder-ui`` installs nothing but the bundle itself.
|
|
10
|
+
*
|
|
11
|
+
* npx @jaato/web-coder-ui # ws://127.0.0.1:8080, token from ~/.jaato/ws.token
|
|
12
|
+
* npx @jaato/web-coder-ui --daemon ws://build-box:8080 --token-file ./ws.token
|
|
13
|
+
* npx @jaato/web-coder-ui --port 9000 --no-open
|
|
14
|
+
*
|
|
15
|
+
* How the page learns about the daemon
|
|
16
|
+
* ------------------------------------
|
|
17
|
+
* The bundle knows nothing about this launcher; it fetches ``./config.json``
|
|
18
|
+
* from wherever it was served and, when that answers with
|
|
19
|
+
* ``{"daemon": ..., "token": ..., "autoConnect": true}``, pre-fills the
|
|
20
|
+
* connect form and connects. Anyone hosting ``dist/`` behind nginx or
|
|
21
|
+
* similar can publish the same file by hand, or none at all, in which
|
|
22
|
+
* case the form starts empty. See ``src/app/launcherConfig.ts``.
|
|
23
|
+
*
|
|
24
|
+
* Importing this module
|
|
25
|
+
* ---------------------
|
|
26
|
+
* It is also the package's ``exports`` entry: ``createStaticServer``,
|
|
27
|
+
* ``createStaticHandler``, ``parseArgs``, ``resolveToken``, ``readBuildInfo``
|
|
28
|
+
* and ``DIST_DIR`` are
|
|
29
|
+
* what a host that serves the bundle itself (``jaato-web-coder-server``)
|
|
30
|
+
* reuses, and importing has no side effects — the CLI runs only when this
|
|
31
|
+
* file is the entry point.
|
|
32
|
+
*
|
|
33
|
+
* Where the token goes
|
|
34
|
+
* --------------------
|
|
35
|
+
* The daemon's bearer token grants full control of the agent, so the
|
|
36
|
+
* launcher only ever hands it to the page on a **loopback** bind: with
|
|
37
|
+
* ``--host 0.0.0.0`` the token is left out of ``config.json`` and the
|
|
38
|
+
* person at the browser types it. Two more guards apply even on
|
|
39
|
+
* loopback: ``config.json`` is served as JSON with ``nosniff`` (a page on
|
|
40
|
+
* another origin cannot read it — a cross-origin ``fetch`` has no CORS
|
|
41
|
+
* grant and a ``<script src>`` of a JSON document does not execute), and
|
|
42
|
+
* every request must carry a ``Host`` header naming the address we bound,
|
|
43
|
+
* which closes the DNS-rebinding route to the same file.
|
|
44
|
+
*/
|
|
45
|
+
import { createServer } from "node:http";
|
|
46
|
+
import { promises as fs, existsSync, readFileSync, realpathSync } from "node:fs";
|
|
47
|
+
import { homedir } from "node:os";
|
|
48
|
+
import { dirname, extname, join, resolve, sep } from "node:path";
|
|
49
|
+
import { fileURLToPath } from "node:url";
|
|
50
|
+
import { spawn } from "node:child_process";
|
|
51
|
+
|
|
52
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
53
|
+
/** The bundle this package ships; importable by a host that serves it itself (jaato-web-coder-server). */
|
|
54
|
+
export const DIST_DIR = join(HERE, "..", "dist");
|
|
55
|
+
const DEFAULT_ROOT = DIST_DIR;
|
|
56
|
+
const DEFAULT_DAEMON = "ws://127.0.0.1:8080";
|
|
57
|
+
const DEFAULT_TOKEN_FILE = join(homedir(), ".jaato", "ws.token");
|
|
58
|
+
|
|
59
|
+
const MIME = {
|
|
60
|
+
".html": "text/html; charset=utf-8",
|
|
61
|
+
".js": "text/javascript; charset=utf-8",
|
|
62
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
63
|
+
".css": "text/css; charset=utf-8",
|
|
64
|
+
".json": "application/json; charset=utf-8",
|
|
65
|
+
".map": "application/json; charset=utf-8",
|
|
66
|
+
".svg": "image/svg+xml",
|
|
67
|
+
".png": "image/png",
|
|
68
|
+
".ico": "image/x-icon",
|
|
69
|
+
".woff": "font/woff",
|
|
70
|
+
".woff2": "font/woff2",
|
|
71
|
+
".txt": "text/plain; charset=utf-8",
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const USAGE = `Usage: jaato-web-coder-ui [options]
|
|
75
|
+
|
|
76
|
+
Serve the jaato browser client and open it against a daemon started with
|
|
77
|
+
\`python -m server --web-socket [HOST:]PORT\`.
|
|
78
|
+
|
|
79
|
+
Options:
|
|
80
|
+
--daemon URL WebSocket URL of the daemon (default ${DEFAULT_DAEMON})
|
|
81
|
+
--token TOKEN Bearer token to hand to the page
|
|
82
|
+
--token-file PATH Read the token from PATH (default ~/.jaato/ws.token when
|
|
83
|
+
the daemon URL is a loopback address and the file exists)
|
|
84
|
+
--no-token Do not pass any token; type it in the connect form
|
|
85
|
+
--host HOST Address to serve on (default 127.0.0.1). The token is
|
|
86
|
+
only handed out on a loopback bind.
|
|
87
|
+
--port PORT Port to serve on (default 5180; 0 picks a free port)
|
|
88
|
+
--no-open Do not open a browser
|
|
89
|
+
--root DIR Serve this directory instead of the bundled dist/
|
|
90
|
+
-h, --help Show this help
|
|
91
|
+
-v, --version Print the package version
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
/** Parse ``argv`` into launcher options; throws on an unknown flag. */
|
|
95
|
+
export function parseArgs(argv) {
|
|
96
|
+
const opts = {
|
|
97
|
+
daemon: DEFAULT_DAEMON,
|
|
98
|
+
token: undefined,
|
|
99
|
+
tokenFile: undefined,
|
|
100
|
+
noToken: false,
|
|
101
|
+
host: "127.0.0.1",
|
|
102
|
+
port: 5180,
|
|
103
|
+
open: true,
|
|
104
|
+
root: DEFAULT_ROOT,
|
|
105
|
+
help: false,
|
|
106
|
+
version: false,
|
|
107
|
+
};
|
|
108
|
+
const takeValue = (i, flag) => {
|
|
109
|
+
const v = argv[i + 1];
|
|
110
|
+
if (v === undefined) throw new Error(`${flag} needs a value`);
|
|
111
|
+
return v;
|
|
112
|
+
};
|
|
113
|
+
for (let i = 0; i < argv.length; i++) {
|
|
114
|
+
const a = argv[i];
|
|
115
|
+
const eq = a.indexOf("=");
|
|
116
|
+
const flag = eq > 0 && a.startsWith("--") ? a.slice(0, eq) : a;
|
|
117
|
+
const inline = eq > 0 && a.startsWith("--") ? a.slice(eq + 1) : undefined;
|
|
118
|
+
const value = () => (inline !== undefined ? inline : (i++, takeValue(i - 1, flag)));
|
|
119
|
+
switch (flag) {
|
|
120
|
+
case "--daemon": opts.daemon = value(); break;
|
|
121
|
+
case "--token": opts.token = value(); break;
|
|
122
|
+
case "--token-file": opts.tokenFile = value(); break;
|
|
123
|
+
case "--no-token": opts.noToken = true; break;
|
|
124
|
+
case "--host": opts.host = value(); break;
|
|
125
|
+
case "--port": {
|
|
126
|
+
const n = Number(value());
|
|
127
|
+
if (!Number.isInteger(n) || n < 0 || n > 65535) throw new Error("--port must be an integer between 0 and 65535");
|
|
128
|
+
opts.port = n;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
case "--open": opts.open = true; break;
|
|
132
|
+
case "--no-open": opts.open = false; break;
|
|
133
|
+
case "--root": opts.root = resolve(value()); break;
|
|
134
|
+
case "-h": case "--help": opts.help = true; break;
|
|
135
|
+
case "-v": case "--version": opts.version = true; break;
|
|
136
|
+
default: throw new Error(`unknown option ${a}\n\n${USAGE}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return opts;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** ``true`` for the hosts a loopback bind answers to. */
|
|
143
|
+
export function isLoopbackHost(host) {
|
|
144
|
+
const h = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
145
|
+
return h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function daemonHost(url) {
|
|
149
|
+
try { return new URL(url).hostname; } catch { return ""; }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Decide which token (if any) reaches the page.
|
|
154
|
+
*
|
|
155
|
+
* Explicit ``--token`` / ``--token-file`` win; otherwise the default
|
|
156
|
+
* token file is used only for a loopback daemon, because that file
|
|
157
|
+
* belongs to the daemon on *this* machine and would be the wrong secret
|
|
158
|
+
* for any other.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveToken(opts, { readFile = (p) => readFileSync(p, "utf8"), exists = existsSync } = {}) {
|
|
161
|
+
if (opts.noToken) return { token: undefined, source: "disabled" };
|
|
162
|
+
if (opts.token !== undefined) return { token: opts.token, source: "flag" };
|
|
163
|
+
if (opts.tokenFile !== undefined) return { token: readFile(opts.tokenFile).trim(), source: opts.tokenFile };
|
|
164
|
+
if (isLoopbackHost(daemonHost(opts.daemon)) && exists(DEFAULT_TOKEN_FILE)) {
|
|
165
|
+
return { token: readFile(DEFAULT_TOKEN_FILE).trim(), source: DEFAULT_TOKEN_FILE };
|
|
166
|
+
}
|
|
167
|
+
return { token: undefined, source: "none" };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function send(res, status, body, headers = {}) {
|
|
171
|
+
res.writeHead(status, { "X-Content-Type-Options": "nosniff", ...headers });
|
|
172
|
+
res.end(body);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Build (but do not start) the static server.
|
|
177
|
+
*
|
|
178
|
+
* ``config`` is what ``GET /config.json`` answers; the caller decides
|
|
179
|
+
* whether it carries a token. Everything under ``root`` is served
|
|
180
|
+
* read-only, with the SPA fallback (any path without a file behind it
|
|
181
|
+
* gets ``index.html``) and immutable caching for Vite's hashed assets.
|
|
182
|
+
* ``allowedHosts`` is the set of ``Host`` header values accepted (read
|
|
183
|
+
* on every request, so it may be filled after ``listen``); ``null``
|
|
184
|
+
* accepts any host. A request naming another host is refused with 421.
|
|
185
|
+
*/
|
|
186
|
+
export function createStaticServer({ root, config, allowedHosts }) {
|
|
187
|
+
return createServer(createStaticHandler({ root, config, allowedHosts }));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The request listener behind :func:`createStaticServer`, for a host that
|
|
192
|
+
* answers its own routes first and falls through to the bundle
|
|
193
|
+
* (jaato-web-coder-server mounts it after ``/auth/*`` and ``/api/*``).
|
|
194
|
+
* Same contract: ``config`` answers ``GET /config.json``, ``allowedHosts``
|
|
195
|
+
* ``null`` accepts any ``Host``.
|
|
196
|
+
*/
|
|
197
|
+
export function createStaticHandler({ root, config, allowedHosts }) {
|
|
198
|
+
const rootAbs = resolve(root);
|
|
199
|
+
const configBody = JSON.stringify(config);
|
|
200
|
+
const hostOk = (req) => {
|
|
201
|
+
if (!allowedHosts) return true;
|
|
202
|
+
const h = (req.headers.host ?? "").toLowerCase();
|
|
203
|
+
return allowedHosts.has(h);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return async (req, res) => {
|
|
207
|
+
if (!hostOk(req)) return send(res, 421, "Misdirected Request\n", { "Content-Type": "text/plain" });
|
|
208
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
209
|
+
return send(res, 405, "Method Not Allowed\n", { "Content-Type": "text/plain", Allow: "GET, HEAD" });
|
|
210
|
+
}
|
|
211
|
+
let pathname;
|
|
212
|
+
try { pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname); }
|
|
213
|
+
catch { return send(res, 400, "Bad Request\n", { "Content-Type": "text/plain" }); }
|
|
214
|
+
|
|
215
|
+
if (pathname === "/config.json") {
|
|
216
|
+
return send(res, 200, configBody, { "Content-Type": MIME[".json"], "Cache-Control": "no-store" });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let file = resolve(rootAbs, "." + pathname);
|
|
220
|
+
if (file !== rootAbs && !file.startsWith(rootAbs + sep)) {
|
|
221
|
+
return send(res, 403, "Forbidden\n", { "Content-Type": "text/plain" });
|
|
222
|
+
}
|
|
223
|
+
let stat = await fs.stat(file).catch(() => null);
|
|
224
|
+
if (stat?.isDirectory()) { file = join(file, "index.html"); stat = await fs.stat(file).catch(() => null); }
|
|
225
|
+
if (!stat?.isFile()) { file = join(rootAbs, "index.html"); stat = await fs.stat(file).catch(() => null); }
|
|
226
|
+
if (!stat?.isFile()) return send(res, 404, "Not Found\n", { "Content-Type": "text/plain" });
|
|
227
|
+
|
|
228
|
+
const ext = extname(file).toLowerCase();
|
|
229
|
+
const hashed = file.startsWith(join(rootAbs, "assets") + sep);
|
|
230
|
+
const headers = {
|
|
231
|
+
"Content-Type": MIME[ext] ?? "application/octet-stream",
|
|
232
|
+
"Content-Length": String(stat.size),
|
|
233
|
+
"Cache-Control": hashed ? "public, max-age=31536000, immutable" : "no-cache",
|
|
234
|
+
};
|
|
235
|
+
if (req.method === "HEAD") return send(res, 200, undefined, headers);
|
|
236
|
+
send(res, 200, await fs.readFile(file), headers);
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function openBrowser(url) {
|
|
241
|
+
const [cmd, args] =
|
|
242
|
+
process.platform === "darwin" ? ["open", [url]]
|
|
243
|
+
: process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
|
|
244
|
+
: ["xdg-open", [url]];
|
|
245
|
+
try {
|
|
246
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {}).unref();
|
|
247
|
+
} catch { /* no opener on this box; the URL is printed anyway */ }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function packageVersion() {
|
|
251
|
+
try { return JSON.parse(readFileSync(join(HERE, "..", "package.json"), "utf8")).version; }
|
|
252
|
+
catch { return "unknown"; }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The build stamp vite writes beside the bundle (``dist/build-info.json``):
|
|
257
|
+
* UI version, the @jaato/sdk revision compiled in, its protocol floor and
|
|
258
|
+
* the commit. ``null`` when the root carries none (a foreign ``--root``).
|
|
259
|
+
*/
|
|
260
|
+
export function readBuildInfo(root = DEFAULT_ROOT) {
|
|
261
|
+
try { return JSON.parse(readFileSync(join(root, "build-info.json"), "utf8")); }
|
|
262
|
+
catch { return null; }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function versionLines(root) {
|
|
266
|
+
const lines = [packageVersion()];
|
|
267
|
+
const b = readBuildInfo(root);
|
|
268
|
+
if (b) lines.push(`@jaato/sdk ${b.sdk} · protocol ≥ ${b.protocolMin} · ${b.commit} · built ${b.builtAt}`);
|
|
269
|
+
return lines.join("\n") + "\n";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Start serving per ``opts`` (from ``parseArgs``); resolves to the listening server. */
|
|
273
|
+
export async function main(opts) {
|
|
274
|
+
if (opts.help) { process.stdout.write(USAGE); return null; }
|
|
275
|
+
if (opts.version) { process.stdout.write(versionLines(opts.root)); return null; }
|
|
276
|
+
if (!existsSync(join(opts.root, "index.html"))) {
|
|
277
|
+
throw new Error(`no index.html under ${opts.root} — run \`npm run build\` first, or pass --root`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const loopback = isLoopbackHost(opts.host);
|
|
281
|
+
const { token, source } = resolveToken(opts);
|
|
282
|
+
const config = { daemon: opts.daemon, autoConnect: true };
|
|
283
|
+
if (token && loopback) config.token = token;
|
|
284
|
+
|
|
285
|
+
// On a loopback bind only the loopback names are accepted in ``Host``,
|
|
286
|
+
// so a request that names any other host (a rebinding attack) never
|
|
287
|
+
// sees the token. A non-loopback bind serves no token, and the names
|
|
288
|
+
// people reach it by are not ours to know, so it takes any ``Host``.
|
|
289
|
+
const allowed = loopback ? new Set() : null;
|
|
290
|
+
const server = createStaticServer({ root: opts.root, config, allowedHosts: allowed });
|
|
291
|
+
await new Promise((ok, fail) => server.once("error", fail).listen(opts.port, opts.host, ok));
|
|
292
|
+
const { port } = server.address();
|
|
293
|
+
if (allowed) for (const h of ["localhost", "127.0.0.1", "[::1]", opts.host]) allowed.add(`${h}:${port}`.toLowerCase());
|
|
294
|
+
|
|
295
|
+
const wildcard = opts.host === "0.0.0.0" || opts.host === "::";
|
|
296
|
+
const hostForUrl = wildcard ? "localhost" : opts.host.includes(":") ? `[${opts.host}]` : opts.host;
|
|
297
|
+
const url = `http://${hostForUrl}:${port}/`;
|
|
298
|
+
const lines = [`jaato-web-coder-ui ${packageVersion()} serving ${opts.root}`, ` open ${url}${wildcard ? " (bound to all interfaces)" : ""}`, ` daemon ${opts.daemon}`];
|
|
299
|
+
if (config.token) lines.push(` token from ${source}`);
|
|
300
|
+
else if (token && !loopback) lines.push(` token NOT handed to the page: ${opts.host} is not a loopback bind; type it in the form`);
|
|
301
|
+
else lines.push(` token none (${source}); type it in the form, or leave empty for --ws-unsafe-no-auth`);
|
|
302
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
303
|
+
if (opts.open) openBrowser(url);
|
|
304
|
+
return server;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// npm installs the bin as a symlink under node_modules/.bin, so argv[1]
|
|
308
|
+
// must be resolved through the link before it can equal this module.
|
|
309
|
+
function invokedDirectly() {
|
|
310
|
+
if (!process.argv[1]) return false;
|
|
311
|
+
try { return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
|
|
312
|
+
catch { return false; }
|
|
313
|
+
}
|
|
314
|
+
const isMain = invokedDirectly();
|
|
315
|
+
if (isMain) {
|
|
316
|
+
let opts;
|
|
317
|
+
try { opts = parseArgs(process.argv.slice(2)); }
|
|
318
|
+
catch (err) { process.stderr.write(`jaato-web-coder-ui: ${err.message}\n`); process.exit(2); }
|
|
319
|
+
main(opts).then((server) => {
|
|
320
|
+
if (!server) return;
|
|
321
|
+
const stop = () => server.close(() => process.exit(0));
|
|
322
|
+
process.on("SIGINT", stop);
|
|
323
|
+
process.on("SIGTERM", stop);
|
|
324
|
+
}).catch((err) => {
|
|
325
|
+
process.stderr.write(`jaato-web-coder-ui: ${err.message}\n`);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
});
|
|
328
|
+
}
|