@omochi-ui/cli 0.0.0-stage → 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/LICENSE +202 -0
- package/README.md +278 -2
- package/bin/omochi-ui.js +2 -0
- package/dist/catalog-codegen.d.ts +9 -0
- package/dist/catalog-codegen.js +185 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +454 -0
- package/dist/help.d.ts +3 -0
- package/dist/help.js +187 -0
- package/dist/hub/dashboards.d.ts +17 -0
- package/dist/hub/dashboards.js +230 -0
- package/dist/hub/gateway-supervisor.d.ts +19 -0
- package/dist/hub/gateway-supervisor.js +75 -0
- package/dist/hub/hub.d.ts +41 -0
- package/dist/hub/hub.js +353 -0
- package/dist/hub/ssh-gateway.d.ts +16 -0
- package/dist/hub/ssh-gateway.js +110 -0
- package/dist/hub/static-ui.d.ts +2 -0
- package/dist/hub/static-ui.js +30 -0
- package/dist/stdio-websocket-bridge.d.ts +17 -0
- package/dist/stdio-websocket-bridge.js +78 -0
- package/dist/up.d.ts +21 -0
- package/dist/up.js +229 -0
- package/package.json +37 -4
package/dist/help.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
const overview = `omochi-ui — attach a declarative UI to your program
|
|
2
|
+
|
|
3
|
+
Usage: omochi-ui <command> [options]
|
|
4
|
+
|
|
5
|
+
Develop an app:
|
|
6
|
+
dev -- <command> [args...] Run an app behind a stdio/WebSocket bridge
|
|
7
|
+
generate catalog Generate typed bindings from a local catalog
|
|
8
|
+
|
|
9
|
+
Compose dashboards:
|
|
10
|
+
up [FILE] Start a composition and its dashboard preset
|
|
11
|
+
hub Serve the dashboard UI; auto-start a local gateway
|
|
12
|
+
gateway Publish running apps on this machine
|
|
13
|
+
|
|
14
|
+
Connect machines:
|
|
15
|
+
id Show or name this machine's identity
|
|
16
|
+
pair HOST:PORT Pair with a gateway using its one-time code
|
|
17
|
+
trust list List paired peers
|
|
18
|
+
trust forget <nodeId> Remove a paired peer
|
|
19
|
+
|
|
20
|
+
Start here:
|
|
21
|
+
omochi-ui dev -- <command> Preview one app (requires a separate UI server)
|
|
22
|
+
omochi-ui up --open Start ./omochi.toml and open its preset
|
|
23
|
+
omochi-ui hub Open dashboards at http://127.0.0.1:5555/
|
|
24
|
+
|
|
25
|
+
Use omochi-ui <command> --help or omochi-ui help <command> for options.
|
|
26
|
+
`;
|
|
27
|
+
const topics = {
|
|
28
|
+
up: `Start services and a read-only dashboard preset from a TOML composition.
|
|
29
|
+
|
|
30
|
+
Usage: omochi-ui up [options] [FILE]
|
|
31
|
+
|
|
32
|
+
Options:
|
|
33
|
+
--host HOST Hub bind address (default: 127.0.0.1)
|
|
34
|
+
--port PORT Hub port (default: 5555; 0 selects a port)
|
|
35
|
+
--allow-origin ORIGIN Allow an additional exact browser origin; repeatable
|
|
36
|
+
--open Open the selected preset in the desktop browser
|
|
37
|
+
|
|
38
|
+
FILE defaults to ./omochi.toml. Commands run without an implicit shell and
|
|
39
|
+
receive OMOCHI_SERVICE. Utility entries invoke the matching omochi-utils mode.
|
|
40
|
+
The preset is temporary; use Save as dashboard in the hub to keep and edit it.
|
|
41
|
+
If a child service exits, its view becomes unavailable while the hub continues.
|
|
42
|
+
`,
|
|
43
|
+
dev: `Run an application and bridge its JSONL stdin/stdout to WebSocket.
|
|
44
|
+
|
|
45
|
+
Usage: omochi-ui dev [options] -- <command> [args...]
|
|
46
|
+
|
|
47
|
+
Options:
|
|
48
|
+
--host HOST Bind address (default: 127.0.0.1)
|
|
49
|
+
--port PORT WebSocket port (default: 5555; 0 selects an available port)
|
|
50
|
+
|
|
51
|
+
This command starts the app and bridge. Start the browser preview separately:
|
|
52
|
+
bun run --filter @omochi-ui/dev-server dev
|
|
53
|
+
omochi-ui dev -- uv run --project examples/python examples/python/app.py
|
|
54
|
+
|
|
55
|
+
Open http://127.0.0.1:5173/ with the default bridge port. For another port,
|
|
56
|
+
use the ?ws= URL printed at startup. All arguments after -- go to the app.
|
|
57
|
+
The bridge has no authentication or TLS; keep the loopback default.
|
|
58
|
+
`,
|
|
59
|
+
hub: `Serve dashboards combining surfaces from running applications.
|
|
60
|
+
|
|
61
|
+
Usage: omochi-ui hub [options]
|
|
62
|
+
|
|
63
|
+
Options:
|
|
64
|
+
--host HOST HTTP/WebSocket bind address (default: 127.0.0.1)
|
|
65
|
+
--port PORT HTTP/WebSocket port (default: 5555; 0 selects a port)
|
|
66
|
+
--gateway TARGET Gateway socket path or <nodeId>@HOST:PORT; repeatable
|
|
67
|
+
--ssh HOST Reach a remote gateway through SSH (one target)
|
|
68
|
+
--ssh-socket PATH Override the remote socket; requires --ssh
|
|
69
|
+
--no-auto-gateway Do not start a missing local gateway
|
|
70
|
+
--discover-lan Also discover paired gateways through mDNS
|
|
71
|
+
--allow-origin ORIGIN Allow an additional exact browser origin; repeatable
|
|
72
|
+
|
|
73
|
+
With no target, the hub reuses or starts the local gateway. --gateway and
|
|
74
|
+
--ssh replace that default; add --gateway with the local socket to include it.
|
|
75
|
+
The hub serves its own UI at http://127.0.0.1:5555/; no Vite server is needed.
|
|
76
|
+
Apps must already be running with UnixSocketServerTransport.
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
omochi-ui hub
|
|
80
|
+
omochi-ui hub --ssh user@remote
|
|
81
|
+
omochi-ui hub --gateway <nodeId>@192.168.1.42:5556
|
|
82
|
+
|
|
83
|
+
SSH requires a running remote gateway. TCP requires pairing first.
|
|
84
|
+
The hub has no authentication or TLS; keep the loopback default.
|
|
85
|
+
`,
|
|
86
|
+
gateway: `Discover and publish the current user's running apps on one machine.
|
|
87
|
+
|
|
88
|
+
Usage: omochi-ui gateway [options]
|
|
89
|
+
|
|
90
|
+
Options:
|
|
91
|
+
--root DIR App registry directory (default: $XDG_RUNTIME_DIR/omochi)
|
|
92
|
+
--socket PATH Gateway socket (default: <root>/gateway.sock)
|
|
93
|
+
--node-name NAME Set this machine's display name
|
|
94
|
+
--listen HOST:PORT Enable the certificate-authenticated mTLS listener
|
|
95
|
+
--advertise Advertise with mDNS; requires --listen or --lan
|
|
96
|
+
--pair Open a one-time pairing window; requires --listen or --lan
|
|
97
|
+
--lan Shorthand for --listen 0.0.0.0:5556 --advertise
|
|
98
|
+
|
|
99
|
+
The gateway discovers apps; it does not launch them or serve a browser UI.
|
|
100
|
+
Without network flags, it listens only on a Unix socket. If XDG_RUNTIME_DIR
|
|
101
|
+
is unset, the registry is <temp>/omochi-<uid>/omochi.
|
|
102
|
+
|
|
103
|
+
Examples:
|
|
104
|
+
omochi-ui gateway
|
|
105
|
+
omochi-ui gateway --lan --pair
|
|
106
|
+
`,
|
|
107
|
+
id: `Show this machine's persistent node ID and display name.
|
|
108
|
+
|
|
109
|
+
Usage: omochi-ui id [--node-name NAME]
|
|
110
|
+
|
|
111
|
+
Creates an identity on first use. --node-name sets its display name.
|
|
112
|
+
Configuration: $XDG_CONFIG_HOME/omochi (default: ~/.config/omochi).
|
|
113
|
+
`,
|
|
114
|
+
pair: `Pair this machine with a gateway accepting a one-time code.
|
|
115
|
+
|
|
116
|
+
Usage: omochi-ui pair HOST:PORT [--code CODE]
|
|
117
|
+
|
|
118
|
+
Start omochi-ui gateway --lan --pair on the machine running the apps first.
|
|
119
|
+
Without --code, read the code from stdin to keep it out of shell history.
|
|
120
|
+
Use the printed random pairing port; it differs from the normal mTLS port.
|
|
121
|
+
After pairing, use the printed hub --gateway command to connect.
|
|
122
|
+
|
|
123
|
+
Example:
|
|
124
|
+
omochi-ui pair 192.168.1.42:42317 # use the printed random pairing port
|
|
125
|
+
`,
|
|
126
|
+
trust: `Manage paired peers stored in $XDG_CONFIG_HOME/omochi/peers.json.
|
|
127
|
+
|
|
128
|
+
Usage:
|
|
129
|
+
omochi-ui trust list
|
|
130
|
+
omochi-ui trust forget <nodeId>
|
|
131
|
+
|
|
132
|
+
list shows peer IDs, roles, names, and pairing times.
|
|
133
|
+
forget removes the local entry and waits for running processes to close revoked connections.
|
|
134
|
+
The machines must pair again to reconnect.
|
|
135
|
+
`,
|
|
136
|
+
'trust list': `List this machine's paired peers.
|
|
137
|
+
|
|
138
|
+
Usage: omochi-ui trust list
|
|
139
|
+
|
|
140
|
+
Prints peer IDs, roles, names, pairing times, and last-seen times when known.
|
|
141
|
+
`,
|
|
142
|
+
'trust forget': `Remove a peer from this machine's trust store.
|
|
143
|
+
|
|
144
|
+
Usage: omochi-ui trust forget <nodeId>
|
|
145
|
+
|
|
146
|
+
Use omochi-ui trust list to find its ID. Pair again to reconnect.
|
|
147
|
+
Returns exit code 1 if the peer was not found.
|
|
148
|
+
`,
|
|
149
|
+
generate: `Generate SDK bindings from local schema files.
|
|
150
|
+
|
|
151
|
+
Usage: omochi-ui generate catalog [options]
|
|
152
|
+
|
|
153
|
+
Use omochi-ui generate catalog --help for required inputs and output flags.
|
|
154
|
+
`,
|
|
155
|
+
'generate catalog': `Generate typed SDK bindings from a local A2UI v0.9 catalog.
|
|
156
|
+
|
|
157
|
+
Usage: omochi-ui generate catalog --catalog FILE [options]
|
|
158
|
+
|
|
159
|
+
Options:
|
|
160
|
+
--catalog FILE Required local catalog JSON file
|
|
161
|
+
--name NAME Type name (default: catalog title or GeneratedCatalog)
|
|
162
|
+
--typescript FILE Write TypeScript bindings
|
|
163
|
+
--python FILE Write Python bindings
|
|
164
|
+
--rust FILE Write Rust bindings
|
|
165
|
+
--check Fail if output files are missing or stale; do not write
|
|
166
|
+
|
|
167
|
+
Choose at least one output language. Output directories must already exist.
|
|
168
|
+
Schema resolution is local-only; no network fetches are performed.
|
|
169
|
+
|
|
170
|
+
Example:
|
|
171
|
+
omochi-ui generate catalog --catalog ./catalog.json --typescript ./catalog.ts
|
|
172
|
+
`,
|
|
173
|
+
};
|
|
174
|
+
export function helpText(topic = '') {
|
|
175
|
+
if (!topic)
|
|
176
|
+
return overview;
|
|
177
|
+
const body = Object.hasOwn(topics, topic) ? topics[topic] : undefined;
|
|
178
|
+
return body === undefined ? undefined : `${body}\n -h, --help Show this help\n`;
|
|
179
|
+
}
|
|
180
|
+
/** Only command words select a topic; options and child arguments never do. */
|
|
181
|
+
export function helpTopic(args) {
|
|
182
|
+
const command = args[0] ?? '';
|
|
183
|
+
const subcommand = args[1];
|
|
184
|
+
return (command === 'trust' || command === 'generate') && subcommand && !subcommand.startsWith('-')
|
|
185
|
+
? `${command} ${subcommand}`
|
|
186
|
+
: command;
|
|
187
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { type Dashboard, type DashboardInput, type DashboardPreset } from '@omochi-ui/hub-ui/contracts';
|
|
3
|
+
export declare function validateDashboard(value: unknown): DashboardInput;
|
|
4
|
+
/** Re-read under the mutation queue and cross-process lock; failed writes never replace the last good file. */
|
|
5
|
+
export declare class DashboardStore {
|
|
6
|
+
private readonly root;
|
|
7
|
+
private queue;
|
|
8
|
+
private readonly file;
|
|
9
|
+
constructor(root?: string);
|
|
10
|
+
private read;
|
|
11
|
+
list(): Promise<Dashboard[]>;
|
|
12
|
+
mutate(id: string | null, input: unknown, remove?: boolean): Promise<Dashboard | null>;
|
|
13
|
+
}
|
|
14
|
+
export declare function dashboardEndpoints(store: DashboardStore, allowed: (origin: string | undefined) => boolean): (request: IncomingMessage, response: ServerResponse) => boolean;
|
|
15
|
+
export declare function validatePresets(values: readonly DashboardPreset[]): DashboardPreset[];
|
|
16
|
+
/** Read-only presets live only for the lifetime of this hub process. */
|
|
17
|
+
export declare function presetEndpoints(presets: readonly DashboardPreset[], allowed: (origin: string | undefined) => boolean): (request: IncomingMessage, response: ServerResponse) => boolean;
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { readFile, writeFile, rename, unlink } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isValidNodeId, isValidServiceId } from '@omochi-ui/core';
|
|
5
|
+
import { ensureConfigRoot, omochiConfigRoot, withStateLock } from '@omochi-ui/discovery';
|
|
6
|
+
import { overlaps, referenceId } from '@omochi-ui/hub-ui/contracts';
|
|
7
|
+
class HttpError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
constructor(status, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.status = status;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const record = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
const text = (value, max = 200) => typeof value === 'string' && value.trim().length > 0 && value.length <= max;
|
|
16
|
+
const integer = (value, min, max) => Number.isSafeInteger(value) && Number(value) >= min && Number(value) <= max;
|
|
17
|
+
export function validateDashboard(value) {
|
|
18
|
+
if (!record(value) || !text(value.name) || !Array.isArray(value.panels) || value.panels.length > 200) {
|
|
19
|
+
throw new HttpError(400, 'A dashboard needs a name and at most 200 panels');
|
|
20
|
+
}
|
|
21
|
+
const panels = [];
|
|
22
|
+
const ids = new Set();
|
|
23
|
+
const references = new Set();
|
|
24
|
+
for (const p of value.panels) {
|
|
25
|
+
if (!record(p) || !text(p.id, 100) || !text(p.title) || !record(p.reference)
|
|
26
|
+
|| typeof p.reference.nodeId !== 'string' || !isValidNodeId(p.reference.nodeId)
|
|
27
|
+
|| typeof p.reference.serviceId !== 'string' || !isValidServiceId(p.reference.serviceId)
|
|
28
|
+
|| !text(p.reference.surfaceId, 4096)
|
|
29
|
+
|| !integer(p.x, 0, 11) || !integer(p.y, 0, 100000) || !integer(p.w, 1, 12) || !integer(p.h, 1, 1000)
|
|
30
|
+
|| p.x + p.w > 12)
|
|
31
|
+
throw new HttpError(400, 'Invalid panel reference or grid position');
|
|
32
|
+
const panel = {
|
|
33
|
+
id: p.id, title: p.title, reference: { nodeId: p.reference.nodeId, serviceId: p.reference.serviceId, surfaceId: p.reference.surfaceId },
|
|
34
|
+
x: p.x, y: p.y, w: p.w, h: p.h,
|
|
35
|
+
};
|
|
36
|
+
const ref = referenceId(panel.reference);
|
|
37
|
+
if (ids.has(panel.id) || references.has(ref))
|
|
38
|
+
throw new HttpError(400, 'Duplicate panel or surface');
|
|
39
|
+
if (panels.some(other => overlaps(panel, other)))
|
|
40
|
+
throw new HttpError(400, 'Panels must not overlap');
|
|
41
|
+
ids.add(panel.id);
|
|
42
|
+
references.add(ref);
|
|
43
|
+
panels.push(panel);
|
|
44
|
+
}
|
|
45
|
+
return { name: value.name.trim(), panels };
|
|
46
|
+
}
|
|
47
|
+
/** Re-read under the mutation queue and cross-process lock; failed writes never replace the last good file. */
|
|
48
|
+
export class DashboardStore {
|
|
49
|
+
root;
|
|
50
|
+
queue = Promise.resolve();
|
|
51
|
+
file;
|
|
52
|
+
constructor(root = omochiConfigRoot()) {
|
|
53
|
+
this.root = root;
|
|
54
|
+
this.file = path.join(root, 'dashboards.json');
|
|
55
|
+
}
|
|
56
|
+
async read() {
|
|
57
|
+
let raw;
|
|
58
|
+
try {
|
|
59
|
+
raw = await readFile(this.file, 'utf8');
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.code === 'ENOENT')
|
|
63
|
+
return { version: 1, dashboards: [] };
|
|
64
|
+
throw new HttpError(503, 'Could not read dashboard storage');
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const data = JSON.parse(raw);
|
|
68
|
+
if (!record(data) || data.version !== 1 || !Array.isArray(data.dashboards))
|
|
69
|
+
throw new Error();
|
|
70
|
+
const ids = new Set();
|
|
71
|
+
const dashboards = data.dashboards.map((item) => {
|
|
72
|
+
if (!record(item) || !text(item.id, 100) || !integer(item.revision, 1, Number.MAX_SAFE_INTEGER) || ids.has(item.id))
|
|
73
|
+
throw new Error();
|
|
74
|
+
ids.add(item.id);
|
|
75
|
+
return { ...validateDashboard(item), id: item.id, revision: item.revision };
|
|
76
|
+
});
|
|
77
|
+
return { version: 1, dashboards };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new HttpError(503, 'Dashboard storage is invalid; repair dashboards.json before saving');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async list() { await this.queue; return (await this.read()).dashboards; }
|
|
84
|
+
mutate(id, input, remove = false) {
|
|
85
|
+
const next = this.queue.then(async () => {
|
|
86
|
+
try {
|
|
87
|
+
await ensureConfigRoot(this.root);
|
|
88
|
+
return await withStateLock(this.file, async () => {
|
|
89
|
+
const file = await this.read();
|
|
90
|
+
const current = id === null ? undefined : file.dashboards.find(d => d.id === id);
|
|
91
|
+
if (id !== null && !current)
|
|
92
|
+
throw new HttpError(404, 'Dashboard not found');
|
|
93
|
+
if (current && (!record(input) || input.revision !== current.revision))
|
|
94
|
+
throw new HttpError(409, 'This dashboard changed elsewhere. Reload before saving.');
|
|
95
|
+
const dashboard = remove ? null : {
|
|
96
|
+
...validateDashboard(input), id: current?.id ?? randomUUID(), revision: (current?.revision ?? 0) + 1,
|
|
97
|
+
};
|
|
98
|
+
file.dashboards = file.dashboards.filter(d => d.id !== id);
|
|
99
|
+
if (dashboard)
|
|
100
|
+
file.dashboards.push(dashboard);
|
|
101
|
+
const temporary = path.join(this.root, `.dashboards-${randomUUID()}.tmp`);
|
|
102
|
+
try {
|
|
103
|
+
await writeFile(temporary, JSON.stringify(file, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
104
|
+
await rename(temporary, this.file);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
await unlink(temporary).catch(() => { });
|
|
108
|
+
}
|
|
109
|
+
return dashboard;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof HttpError)
|
|
114
|
+
throw error;
|
|
115
|
+
throw new HttpError(503, 'Could not save dashboard storage');
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
this.queue = next.catch(() => { });
|
|
119
|
+
return next;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function body(request) {
|
|
123
|
+
if (request.headers['content-type']?.split(';')[0]?.trim() !== 'application/json')
|
|
124
|
+
throw new HttpError(415, 'Use application/json');
|
|
125
|
+
const chunks = [];
|
|
126
|
+
let size = 0;
|
|
127
|
+
for await (const chunk of request) {
|
|
128
|
+
size += Buffer.byteLength(chunk);
|
|
129
|
+
if (size > 1024 * 1024)
|
|
130
|
+
throw new HttpError(413, 'Dashboard request is too large');
|
|
131
|
+
chunks.push(Buffer.from(chunk));
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
throw new HttpError(400, 'Invalid JSON');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function dashboardEndpoints(store, allowed) {
|
|
141
|
+
return (request, response) => {
|
|
142
|
+
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
143
|
+
if (pathname !== '/api/dashboards' && !pathname.startsWith('/api/dashboards/'))
|
|
144
|
+
return false;
|
|
145
|
+
const send = (status, value) => {
|
|
146
|
+
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
147
|
+
response.end(JSON.stringify(value));
|
|
148
|
+
};
|
|
149
|
+
if (!allowed(request.headers.origin)) {
|
|
150
|
+
send(403, { error: 'Forbidden origin' });
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
response.setHeader('Vary', 'Origin');
|
|
154
|
+
if (request.headers.origin)
|
|
155
|
+
response.setHeader('Access-Control-Allow-Origin', request.headers.origin);
|
|
156
|
+
if (request.method === 'OPTIONS') {
|
|
157
|
+
response.writeHead(204, { 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' });
|
|
158
|
+
response.end();
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
void (async () => {
|
|
162
|
+
const id = pathname === '/api/dashboards' ? null : decodeURIComponent(pathname.slice('/api/dashboards/'.length));
|
|
163
|
+
if (id !== null && (!id || id.includes('/')))
|
|
164
|
+
throw new HttpError(404, 'Dashboard not found');
|
|
165
|
+
if (request.method === 'GET') {
|
|
166
|
+
const dashboards = await store.list();
|
|
167
|
+
if (id === null)
|
|
168
|
+
send(200, dashboards);
|
|
169
|
+
else {
|
|
170
|
+
const dashboard = dashboards.find(d => d.id === id);
|
|
171
|
+
if (!dashboard)
|
|
172
|
+
throw new HttpError(404, 'Dashboard not found');
|
|
173
|
+
send(200, dashboard);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else if (request.method === 'POST' && id === null)
|
|
177
|
+
send(201, await store.mutate(null, await body(request)));
|
|
178
|
+
else if (request.method === 'PUT' && id !== null)
|
|
179
|
+
send(200, await store.mutate(id, await body(request)));
|
|
180
|
+
else if (request.method === 'DELETE' && id !== null) {
|
|
181
|
+
await store.mutate(id, await body(request), true);
|
|
182
|
+
send(200, { deleted: true });
|
|
183
|
+
}
|
|
184
|
+
else
|
|
185
|
+
throw new HttpError(405, 'Method not allowed');
|
|
186
|
+
})().catch(error => send(error instanceof HttpError ? error.status : error instanceof URIError ? 400 : 503, { error: error instanceof HttpError ? error.message : 'Could not save dashboard' }));
|
|
187
|
+
return true;
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
export function validatePresets(values) {
|
|
191
|
+
const ids = new Set();
|
|
192
|
+
return values.map((value) => {
|
|
193
|
+
if (!text(value.id, 100) || ids.has(value.id))
|
|
194
|
+
throw new Error(`Invalid or duplicate dashboard preset id: ${JSON.stringify(value.id)}`);
|
|
195
|
+
ids.add(value.id);
|
|
196
|
+
return { id: value.id, ...validateDashboard(value) };
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/** Read-only presets live only for the lifetime of this hub process. */
|
|
200
|
+
export function presetEndpoints(presets, allowed) {
|
|
201
|
+
const validated = validatePresets(presets);
|
|
202
|
+
return (request, response) => {
|
|
203
|
+
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
204
|
+
if (pathname !== '/api/presets')
|
|
205
|
+
return false;
|
|
206
|
+
const send = (status, value) => {
|
|
207
|
+
const headers = { 'Cache-Control': 'no-store' };
|
|
208
|
+
if (value !== undefined)
|
|
209
|
+
headers['Content-Type'] = 'application/json; charset=utf-8';
|
|
210
|
+
response.writeHead(status, headers);
|
|
211
|
+
response.end(value === undefined ? undefined : JSON.stringify(value));
|
|
212
|
+
};
|
|
213
|
+
if (!allowed(request.headers.origin)) {
|
|
214
|
+
send(403, { error: 'Forbidden origin' });
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
217
|
+
response.setHeader('Vary', 'Origin');
|
|
218
|
+
if (request.headers.origin)
|
|
219
|
+
response.setHeader('Access-Control-Allow-Origin', request.headers.origin);
|
|
220
|
+
if (request.method === 'OPTIONS') {
|
|
221
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
222
|
+
send(204);
|
|
223
|
+
}
|
|
224
|
+
else if (request.method === 'GET')
|
|
225
|
+
send(200, validated);
|
|
226
|
+
else
|
|
227
|
+
send(405, { error: 'Method not allowed' });
|
|
228
|
+
return true;
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface GatewaySupervisor {
|
|
2
|
+
/** True when this hub started the gateway and is therefore responsible for it. */
|
|
3
|
+
readonly spawned: boolean;
|
|
4
|
+
close(): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export interface SuperviseOptions {
|
|
7
|
+
/** Overridden in tests; defaults to this CLI's own `gateway` command. */
|
|
8
|
+
command?: string[];
|
|
9
|
+
startTimeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Makes sure a gateway is listening on this machine.
|
|
13
|
+
*
|
|
14
|
+
* A gateway already there is reused and left running, because it is not ours to stop and
|
|
15
|
+
* other hubs may be attached to it. Only a socket that refuses a connection is removed,
|
|
16
|
+
* and only then is a gateway started — a live socket is never unlinked, so two hubs
|
|
17
|
+
* racing to start end up sharing the winner's gateway instead of destroying it.
|
|
18
|
+
*/
|
|
19
|
+
export declare function superviseLocalGateway(socketPath: string, options?: SuperviseOptions): Promise<GatewaySupervisor>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { rm } from 'node:fs/promises';
|
|
4
|
+
import process from 'node:process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { probeGateway } from '@omochi-ui/discovery';
|
|
7
|
+
const START_TIMEOUT_MS = 10000;
|
|
8
|
+
const POLL_MS = 50;
|
|
9
|
+
/**
|
|
10
|
+
* How long to let a child report that it lost the race.
|
|
11
|
+
*
|
|
12
|
+
* The winner starts listening before the loser's bind fails, so a probe can succeed
|
|
13
|
+
* while our own child is still on its way out. Waiting for that exit is what makes
|
|
14
|
+
* `spawned` mean "this hub owns the gateway" rather than "this hub tried".
|
|
15
|
+
*/
|
|
16
|
+
const EXIT_GRACE_MS = 250;
|
|
17
|
+
const idle = { spawned: false, close: async () => { } };
|
|
18
|
+
function gatewayCommand() {
|
|
19
|
+
// `dist/hub/gateway-supervisor.js` sits one directory below `dist/cli.js`.
|
|
20
|
+
return [process.execPath, fileURLToPath(new URL('../cli.js', import.meta.url)), 'gateway'];
|
|
21
|
+
}
|
|
22
|
+
async function waitForGateway(socketPath, deadline, child) {
|
|
23
|
+
while (Date.now() < deadline) {
|
|
24
|
+
if (await probeGateway({ kind: 'unix', socketPath }))
|
|
25
|
+
return true;
|
|
26
|
+
// A child that exited lost the race for the socket, or failed outright; either way
|
|
27
|
+
// the next probe decides, because the winner is already listening.
|
|
28
|
+
if (child && child.exitCode !== null)
|
|
29
|
+
return (await probeGateway({ kind: 'unix', socketPath })) !== null;
|
|
30
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, POLL_MS));
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Makes sure a gateway is listening on this machine.
|
|
36
|
+
*
|
|
37
|
+
* A gateway already there is reused and left running, because it is not ours to stop and
|
|
38
|
+
* other hubs may be attached to it. Only a socket that refuses a connection is removed,
|
|
39
|
+
* and only then is a gateway started — a live socket is never unlinked, so two hubs
|
|
40
|
+
* racing to start end up sharing the winner's gateway instead of destroying it.
|
|
41
|
+
*/
|
|
42
|
+
export async function superviseLocalGateway(socketPath, options = {}) {
|
|
43
|
+
if (await probeGateway({ kind: 'unix', socketPath }))
|
|
44
|
+
return idle;
|
|
45
|
+
// Nothing answered, so any socket file here is left over from a gateway that died.
|
|
46
|
+
await rm(socketPath, { force: true });
|
|
47
|
+
const command = options.command ?? gatewayCommand();
|
|
48
|
+
const child = spawn(command[0], command.slice(1), { stdio: ['ignore', 'ignore', 'inherit'] });
|
|
49
|
+
child.unref();
|
|
50
|
+
const deadline = Date.now() + (options.startTimeoutMs ?? START_TIMEOUT_MS);
|
|
51
|
+
if (!(await waitForGateway(socketPath, deadline, child))) {
|
|
52
|
+
if (child.exitCode === null)
|
|
53
|
+
child.kill('SIGTERM');
|
|
54
|
+
throw new Error(`the gateway did not start on ${socketPath}`);
|
|
55
|
+
}
|
|
56
|
+
if (child.exitCode === null) {
|
|
57
|
+
await Promise.race([
|
|
58
|
+
once(child, 'exit'),
|
|
59
|
+
new Promise((resolveGrace) => setTimeout(resolveGrace, EXIT_GRACE_MS)),
|
|
60
|
+
]);
|
|
61
|
+
}
|
|
62
|
+
// Losing the race is normal: another hub bound the socket first and owns that process.
|
|
63
|
+
if (child.exitCode !== null)
|
|
64
|
+
return idle;
|
|
65
|
+
return {
|
|
66
|
+
// Reported live, so a gateway that dies later is never mistaken for ours to stop.
|
|
67
|
+
get spawned() {
|
|
68
|
+
return child.exitCode === null;
|
|
69
|
+
},
|
|
70
|
+
async close() {
|
|
71
|
+
if (child.exitCode === null)
|
|
72
|
+
child.kill('SIGTERM');
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type GatewayLocator, type NodeIdentity, type TrustStore } from '@omochi-ui/discovery';
|
|
2
|
+
import type { DashboardPreset } from '@omochi-ui/hub-ui/contracts';
|
|
3
|
+
export interface HubOptions {
|
|
4
|
+
host?: string;
|
|
5
|
+
port?: number;
|
|
6
|
+
allowOrigins?: string[];
|
|
7
|
+
/** Where to look for gateways. Defaults to the gateway on this machine. */
|
|
8
|
+
gateways?: GatewayLocator[];
|
|
9
|
+
/** The local gateway's socket; defaults to `<runtime root>/gateway.sock`. */
|
|
10
|
+
gatewaySocket?: string;
|
|
11
|
+
/** Start a local gateway when none is listening. Defaults to true. */
|
|
12
|
+
autoGateway?: boolean;
|
|
13
|
+
/** Where the persisted identity and pinned peers live; defaults to the config root. */
|
|
14
|
+
configRoot?: string;
|
|
15
|
+
/** Overrides this hub's identity and trust store; mainly useful in tests. */
|
|
16
|
+
identity?: NodeIdentity;
|
|
17
|
+
trust?: TrustStore;
|
|
18
|
+
/** Also look for gateways on the local link. Off by default. */
|
|
19
|
+
discoverLan?: boolean;
|
|
20
|
+
/** Read-only dashboard compositions exposed for this hub process only. */
|
|
21
|
+
presets?: DashboardPreset[];
|
|
22
|
+
}
|
|
23
|
+
export interface HubHandle {
|
|
24
|
+
readonly url: string;
|
|
25
|
+
readonly httpUrl: string;
|
|
26
|
+
/** How many gateways are currently connected. */
|
|
27
|
+
readonly nodeCount: number;
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Aggregates every reachable gateway into one A2UI stream.
|
|
32
|
+
*
|
|
33
|
+
* The hub knows nothing about manifests, runtime directories, or application sockets:
|
|
34
|
+
* a gateway owns all of that, and the hub only ever speaks the gateway contract. That is
|
|
35
|
+
* what lets a gateway on another machine join on exactly the same terms as the local one.
|
|
36
|
+
*
|
|
37
|
+
* Surfaces arrive namespaced by service id and are re-namespaced as
|
|
38
|
+
* `<nodeId>~<serviceId>/<surfaceId>`, so two machines running the same service stay
|
|
39
|
+
* distinct all the way out to the renderer.
|
|
40
|
+
*/
|
|
41
|
+
export declare function startHub(options?: HubOptions): Promise<HubHandle>;
|