@omochi-ui/cli 0.0.0-stage → 0.1.1
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/hub/hub.js
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { DashboardStore, dashboardEndpoints, presetEndpoints } from './dashboards.js';
|
|
2
|
+
import { serveHubUi } from './static-ui.js';
|
|
3
|
+
import process from 'node:process';
|
|
4
|
+
import { A2uiSnapshot, FanOut, closeSocket, createServicesEndpoints, joinRouteId, parseClientFrame, prefixClientMessage, readdressServerMessage, routeClientMessage, splitRouteId, startWsEndpoint, webSocketClient, } from '@omochi-ui/core';
|
|
5
|
+
import { connectGateway, createLocalLocator, createMdnsLocator, gatewaySocketPath, loadNodeIdentity, omochiRuntimeRoot, openTrustStore, mtlsClientOptions, } from '@omochi-ui/discovery';
|
|
6
|
+
import { superviseLocalGateway } from './gateway-supervisor.js';
|
|
7
|
+
const RECONNECT_MIN_MS = 100;
|
|
8
|
+
const RECONNECT_MAX_MS = 5000;
|
|
9
|
+
function surfaceIdsOf(snapshot) {
|
|
10
|
+
const ids = [];
|
|
11
|
+
for (const message of snapshot.messages()) {
|
|
12
|
+
if ('createSurface' in message)
|
|
13
|
+
ids.push(message.createSurface.surfaceId);
|
|
14
|
+
}
|
|
15
|
+
return ids;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Aggregates every reachable gateway into one A2UI stream.
|
|
19
|
+
*
|
|
20
|
+
* The hub knows nothing about manifests, runtime directories, or application sockets:
|
|
21
|
+
* a gateway owns all of that, and the hub only ever speaks the gateway contract. That is
|
|
22
|
+
* what lets a gateway on another machine join on exactly the same terms as the local one.
|
|
23
|
+
*
|
|
24
|
+
* Surfaces arrive namespaced by service id and are re-namespaced as
|
|
25
|
+
* `<nodeId>~<serviceId>/<surfaceId>`, so two machines running the same service stay
|
|
26
|
+
* distinct all the way out to the renderer.
|
|
27
|
+
*/
|
|
28
|
+
export async function startHub(options = {}) {
|
|
29
|
+
const host = options.host ?? '127.0.0.1';
|
|
30
|
+
const port = options.port ?? 5555;
|
|
31
|
+
const gatewaySocket = options.gatewaySocket ?? gatewaySocketPath(omochiRuntimeRoot());
|
|
32
|
+
const locators = [...(options.gateways ?? [createLocalLocator(gatewaySocket)])];
|
|
33
|
+
const nodes = new Map();
|
|
34
|
+
const byNodeId = new Map();
|
|
35
|
+
let supervisor = null;
|
|
36
|
+
let closed = false;
|
|
37
|
+
// Only a remote gateway needs either of these, so neither is created until one appears.
|
|
38
|
+
let identity = options.identity ?? null;
|
|
39
|
+
let trust = options.trust ?? null;
|
|
40
|
+
const trustSubscription = { close: null };
|
|
41
|
+
let subscribingTrust = null;
|
|
42
|
+
const remoteCredentials = async () => {
|
|
43
|
+
identity ??= await loadNodeIdentity({ root: options.configRoot });
|
|
44
|
+
trust ??= await openTrustStore(options.configRoot);
|
|
45
|
+
subscribingTrust ??= trust.subscribe(() => {
|
|
46
|
+
for (const node of nodes.values()) {
|
|
47
|
+
if (node.candidate.endpoint.kind !== 'tcp')
|
|
48
|
+
continue;
|
|
49
|
+
// Re-authorize on every trust change, including replacement of a certificate.
|
|
50
|
+
if (node.retry)
|
|
51
|
+
clearTimeout(node.retry);
|
|
52
|
+
node.retry = null;
|
|
53
|
+
node.authorization += 1;
|
|
54
|
+
const client = node.client;
|
|
55
|
+
node.client = null;
|
|
56
|
+
client?.close();
|
|
57
|
+
retractSurfaces(node);
|
|
58
|
+
node.status = 'disconnected';
|
|
59
|
+
node.services = [];
|
|
60
|
+
if (trust.get(node.candidate.nodeId ?? '')?.role === 'gateway')
|
|
61
|
+
scheduleReconnect(node);
|
|
62
|
+
}
|
|
63
|
+
publish();
|
|
64
|
+
}).then(unsubscribe => { trustSubscription.close = unsubscribe; });
|
|
65
|
+
await subscribingTrust;
|
|
66
|
+
return { identity, trust };
|
|
67
|
+
};
|
|
68
|
+
const fanOut = new FanOut(function* () {
|
|
69
|
+
for (const node of nodes.values()) {
|
|
70
|
+
for (const message of node.snapshot.messages())
|
|
71
|
+
yield message;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
const listServices = () => {
|
|
75
|
+
const rows = [];
|
|
76
|
+
for (const node of nodes.values()) {
|
|
77
|
+
if (node.nodeId === null)
|
|
78
|
+
continue;
|
|
79
|
+
const nodeView = {
|
|
80
|
+
id: node.nodeId,
|
|
81
|
+
name: node.nodeName ?? node.nodeId,
|
|
82
|
+
local: node.candidate.source === 'local',
|
|
83
|
+
};
|
|
84
|
+
for (const service of node.services) {
|
|
85
|
+
// A gateway that is down takes its whole list down with it, so the reason the
|
|
86
|
+
// node dropped is what the renderer shows rather than a stale per-service one.
|
|
87
|
+
const view = {
|
|
88
|
+
id: joinRouteId(node.nodeId, service.id),
|
|
89
|
+
name: service.name,
|
|
90
|
+
status: node.status === 'connected' ? service.status : node.status,
|
|
91
|
+
node: nodeView,
|
|
92
|
+
};
|
|
93
|
+
if (service.icon !== undefined)
|
|
94
|
+
view.icon = service.icon;
|
|
95
|
+
const detail = node.status === 'connected' ? service.detail : node.detail;
|
|
96
|
+
if (detail !== undefined)
|
|
97
|
+
view.detail = detail;
|
|
98
|
+
rows.push(view);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return rows;
|
|
102
|
+
};
|
|
103
|
+
const endpoints = createServicesEndpoints({ allowOrigins: options.allowOrigins, list: listServices });
|
|
104
|
+
const dashboards = dashboardEndpoints(new DashboardStore(options.configRoot), endpoints.isAllowedOrigin);
|
|
105
|
+
const presets = presetEndpoints(options.presets ?? [], endpoints.isAllowedOrigin);
|
|
106
|
+
const publish = () => endpoints.publish(listServices());
|
|
107
|
+
/** Retracts a node's surfaces so no renderer keeps a zombie surface around. */
|
|
108
|
+
const retractSurfaces = (node) => {
|
|
109
|
+
for (const surfaceId of surfaceIdsOf(node.snapshot)) {
|
|
110
|
+
fanOut.broadcast(JSON.stringify({ version: 'v0.9', deleteSurface: { surfaceId } }));
|
|
111
|
+
}
|
|
112
|
+
node.snapshot = new A2uiSnapshot();
|
|
113
|
+
};
|
|
114
|
+
const scheduleReconnect = (node) => {
|
|
115
|
+
if (closed || !nodes.has(node.candidate.key))
|
|
116
|
+
return;
|
|
117
|
+
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** node.attempt);
|
|
118
|
+
node.attempt += 1;
|
|
119
|
+
node.retry = setTimeout(() => {
|
|
120
|
+
node.retry = null;
|
|
121
|
+
connect(node);
|
|
122
|
+
}, delay + Math.random() * delay);
|
|
123
|
+
node.retry.unref();
|
|
124
|
+
};
|
|
125
|
+
const dropConnection = (node, status, detail) => {
|
|
126
|
+
const client = node.client;
|
|
127
|
+
if (!client)
|
|
128
|
+
return;
|
|
129
|
+
// Clearing the slot first makes the client's own close callback a no-op, so the
|
|
130
|
+
// reason recorded here survives instead of being overwritten by the teardown.
|
|
131
|
+
node.client = null;
|
|
132
|
+
client.close();
|
|
133
|
+
retractSurfaces(node);
|
|
134
|
+
node.status = status;
|
|
135
|
+
node.detail = detail;
|
|
136
|
+
publish();
|
|
137
|
+
scheduleReconnect(node);
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Resolves the certificate pin that authenticates one remote gateway.
|
|
141
|
+
*
|
|
142
|
+
* The hub must know which node it is calling to select its certificate pin
|
|
143
|
+
* before it connects. An unpaired peer is reported and left alone rather than reached
|
|
144
|
+
* for without authentication.
|
|
145
|
+
*/
|
|
146
|
+
const remoteTls = async (node) => {
|
|
147
|
+
const { identity: self, trust: store } = await remoteCredentials();
|
|
148
|
+
const expected = node.candidate.nodeId ?? null;
|
|
149
|
+
if (expected === null) {
|
|
150
|
+
throw new Error('name the node to reach it, as --gateway <nodeId>@host:port');
|
|
151
|
+
}
|
|
152
|
+
const peer = store.get(expected);
|
|
153
|
+
if (!peer)
|
|
154
|
+
throw new Error(`not paired with ${expected}; pair with it first`);
|
|
155
|
+
if (peer.role !== 'gateway')
|
|
156
|
+
throw new Error(`${expected} is paired as a ${peer.role}, not a gateway`);
|
|
157
|
+
return mtlsClientOptions(self, peer);
|
|
158
|
+
};
|
|
159
|
+
const attach = (node, tls) => {
|
|
160
|
+
if (closed)
|
|
161
|
+
return;
|
|
162
|
+
node.client = connectGateway(node.candidate.endpoint, {
|
|
163
|
+
onOpen(info) {
|
|
164
|
+
node.attempt = 0;
|
|
165
|
+
node.nodeId = info.nodeId;
|
|
166
|
+
node.nodeName = info.name;
|
|
167
|
+
node.status = 'connected';
|
|
168
|
+
delete node.detail;
|
|
169
|
+
byNodeId.set(info.nodeId, node);
|
|
170
|
+
publish();
|
|
171
|
+
},
|
|
172
|
+
onServices(services) {
|
|
173
|
+
node.services = services;
|
|
174
|
+
publish();
|
|
175
|
+
},
|
|
176
|
+
onMessage(message) {
|
|
177
|
+
if (node.nodeId === null)
|
|
178
|
+
return;
|
|
179
|
+
const readdressed = readdressServerMessage(node.nodeId, message);
|
|
180
|
+
if (readdressed === null) {
|
|
181
|
+
const detail = 'the gateway sent a surface id it had not namespaced';
|
|
182
|
+
process.stderr.write(`[omochi-ui] node ${node.nodeId}: ${detail}\n`);
|
|
183
|
+
dropConnection(node, 'error', detail);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
node.snapshot.apply(readdressed);
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
191
|
+
process.stderr.write(`[omochi-ui] node ${node.nodeId}: invalid A2UI message: ${detail}\n`);
|
|
192
|
+
dropConnection(node, 'error', detail);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
fanOut.broadcast(JSON.stringify(readdressed));
|
|
196
|
+
},
|
|
197
|
+
onClose(detail) {
|
|
198
|
+
dropConnection(node, 'disconnected', detail);
|
|
199
|
+
},
|
|
200
|
+
}, tls ? { tls, expectedNodeId: node.candidate.nodeId } : {});
|
|
201
|
+
};
|
|
202
|
+
const connect = (node) => {
|
|
203
|
+
if (closed)
|
|
204
|
+
return;
|
|
205
|
+
if (node.candidate.endpoint.kind === 'unix') {
|
|
206
|
+
attach(node);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const authorization = node.authorization;
|
|
210
|
+
void remoteTls(node).then((tls) => {
|
|
211
|
+
if (authorization !== node.authorization || closed || !nodes.has(node.candidate.key))
|
|
212
|
+
return;
|
|
213
|
+
const peer = trust?.get(node.candidate.nodeId ?? '');
|
|
214
|
+
if (!peer || peer.role !== 'gateway' || peer.certificate !== tls.ca?.[0])
|
|
215
|
+
return;
|
|
216
|
+
attach(node, tls);
|
|
217
|
+
}, (error) => {
|
|
218
|
+
if (closed || authorization !== node.authorization || !nodes.has(node.candidate.key))
|
|
219
|
+
return;
|
|
220
|
+
node.status = 'error';
|
|
221
|
+
node.detail = error.message;
|
|
222
|
+
process.stderr.write(`[omochi-ui] ${node.candidate.key}: ${error.message}\n`);
|
|
223
|
+
publish();
|
|
224
|
+
if (trust?.get(node.candidate.nodeId ?? '')?.role === 'gateway')
|
|
225
|
+
scheduleReconnect(node);
|
|
226
|
+
});
|
|
227
|
+
};
|
|
228
|
+
const teardown = (node) => {
|
|
229
|
+
if (node.retry)
|
|
230
|
+
clearTimeout(node.retry);
|
|
231
|
+
node.retry = null;
|
|
232
|
+
node.authorization += 1;
|
|
233
|
+
const client = node.client;
|
|
234
|
+
node.client = null;
|
|
235
|
+
client?.close();
|
|
236
|
+
retractSurfaces(node);
|
|
237
|
+
if (node.nodeId !== null && byNodeId.get(node.nodeId) === node)
|
|
238
|
+
byNodeId.delete(node.nodeId);
|
|
239
|
+
};
|
|
240
|
+
const sink = {
|
|
241
|
+
found(candidate) {
|
|
242
|
+
if (closed || nodes.has(candidate.key))
|
|
243
|
+
return;
|
|
244
|
+
const node = {
|
|
245
|
+
candidate,
|
|
246
|
+
client: null,
|
|
247
|
+
nodeId: null,
|
|
248
|
+
nodeName: null,
|
|
249
|
+
snapshot: new A2uiSnapshot(),
|
|
250
|
+
services: [],
|
|
251
|
+
status: 'disconnected',
|
|
252
|
+
attempt: 0,
|
|
253
|
+
retry: null,
|
|
254
|
+
authorization: 0,
|
|
255
|
+
};
|
|
256
|
+
nodes.set(candidate.key, node);
|
|
257
|
+
connect(node);
|
|
258
|
+
},
|
|
259
|
+
lost(key) {
|
|
260
|
+
const node = nodes.get(key);
|
|
261
|
+
if (!node)
|
|
262
|
+
return;
|
|
263
|
+
teardown(node);
|
|
264
|
+
nodes.delete(key);
|
|
265
|
+
publish();
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
if (options.autoGateway !== false)
|
|
269
|
+
supervisor = await superviseLocalGateway(gatewaySocket);
|
|
270
|
+
if (options.discoverLan) {
|
|
271
|
+
// Knowing our own id keeps this machine's gateway from arriving twice: once through
|
|
272
|
+
// its unix socket, and once over the network as if it were somebody else.
|
|
273
|
+
const { identity: self } = await remoteCredentials();
|
|
274
|
+
locators.push(createMdnsLocator({ selfNodeId: self.nodeId }));
|
|
275
|
+
}
|
|
276
|
+
for (const locator of locators)
|
|
277
|
+
await locator.start(sink);
|
|
278
|
+
let endpoint;
|
|
279
|
+
try {
|
|
280
|
+
endpoint = await startWsEndpoint({
|
|
281
|
+
host,
|
|
282
|
+
port,
|
|
283
|
+
onRequest: (request, response) => {
|
|
284
|
+
if (!endpoints.isAllowedOrigin(request.headers.origin)) {
|
|
285
|
+
response.writeHead(403).end('Forbidden origin');
|
|
286
|
+
return true;
|
|
287
|
+
}
|
|
288
|
+
return endpoints.handleRequest(request, response) || dashboards(request, response) || presets(request, response) || serveHubUi(request, response);
|
|
289
|
+
},
|
|
290
|
+
// WebSocket upgrades are exempt from CORS, so any page the user has open could
|
|
291
|
+
// otherwise drive every discovered service. Restrict them the same way.
|
|
292
|
+
onUpgrade: (request) => endpoints.isAllowedOrigin(request.headers.origin)
|
|
293
|
+
? { ok: true }
|
|
294
|
+
: { ok: false, status: 403, reason: 'Forbidden' },
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
catch (error) {
|
|
298
|
+
await trustSubscription.close?.();
|
|
299
|
+
endpoints.close();
|
|
300
|
+
for (const locator of locators)
|
|
301
|
+
await locator.close();
|
|
302
|
+
await supervisor?.close();
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
endpoint.onConnection((socket) => {
|
|
306
|
+
const client = webSocketClient(socket);
|
|
307
|
+
fanOut.add(client);
|
|
308
|
+
socket.on('message', (data, isBinary) => {
|
|
309
|
+
const frame = parseClientFrame(data, isBinary);
|
|
310
|
+
if (!frame.ok) {
|
|
311
|
+
closeSocket(socket, frame.code, frame.reason);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
const routed = routeClientMessage(frame.value);
|
|
315
|
+
const route = routed ? splitRouteId(routed.channelId) : null;
|
|
316
|
+
if (!routed || !route) {
|
|
317
|
+
process.stderr.write('[omochi-ui] dropped a client message with no routable surface id\n');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const node = byNodeId.get(route.nodeId);
|
|
321
|
+
if (!node?.client) {
|
|
322
|
+
process.stderr.write(`[omochi-ui] dropped a client message for unavailable node ${route.nodeId}\n`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
// The gateway namespaces by bare service id, so hand it what a renderer attached
|
|
326
|
+
// straight to it would have sent.
|
|
327
|
+
node.client.send(prefixClientMessage(route.serviceId, routed.message));
|
|
328
|
+
});
|
|
329
|
+
socket.on('close', () => fanOut.remove(client));
|
|
330
|
+
});
|
|
331
|
+
return {
|
|
332
|
+
url: endpoint.url,
|
|
333
|
+
httpUrl: endpoint.httpUrl,
|
|
334
|
+
get nodeCount() {
|
|
335
|
+
return [...nodes.values()].filter((node) => node.status === 'connected').length;
|
|
336
|
+
},
|
|
337
|
+
async close() {
|
|
338
|
+
closed = true;
|
|
339
|
+
await subscribingTrust?.catch(() => { });
|
|
340
|
+
for (const node of nodes.values())
|
|
341
|
+
teardown(node);
|
|
342
|
+
await trustSubscription.close?.();
|
|
343
|
+
for (const locator of locators)
|
|
344
|
+
await locator.close();
|
|
345
|
+
nodes.clear();
|
|
346
|
+
byNodeId.clear();
|
|
347
|
+
fanOut.closeAll(1001, 'hub shutting down');
|
|
348
|
+
endpoints.close();
|
|
349
|
+
await endpoint.close();
|
|
350
|
+
await supervisor?.close();
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare function validateSshTarget(host: string, socket?: string): void;
|
|
2
|
+
export interface SshGateway {
|
|
3
|
+
readonly socketPath: string;
|
|
4
|
+
/** Resolves even if SSH exits before the caller starts watching it. */
|
|
5
|
+
readonly exited: Promise<Error>;
|
|
6
|
+
close(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export interface SshGatewayOptions {
|
|
9
|
+
host: string;
|
|
10
|
+
remoteSocket?: string;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
/** Injectable executable for tests. */
|
|
13
|
+
command?: string[];
|
|
14
|
+
startTimeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
export declare function startSshGateway(options: SshGatewayOptions): Promise<SshGateway>;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdtemp, rm, stat } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
6
|
+
import { probeGateway } from '@omochi-ui/discovery';
|
|
7
|
+
// Fixed shell program: no user input is interpolated into a remote command.
|
|
8
|
+
const RESOLVE_SOCKET = 'if [ -n "$XDG_RUNTIME_DIR" ]; then printf "%s/omochi/gateway.sock\\n" "${XDG_RUNTIME_DIR%/}"; else printf "%s/omochi-%s/omochi/gateway.sock\\n" "${TMPDIR:-${TMP:-${TEMP:-/tmp}}}" "$(id -u)"; fi';
|
|
9
|
+
export function validateSshTarget(host, socket) {
|
|
10
|
+
if (!host || host.startsWith('-') || /[\s\x00-\x1f\x7f]/.test(host)) {
|
|
11
|
+
throw new Error('SSH target must be a host alias or user@host, not SSH options');
|
|
12
|
+
}
|
|
13
|
+
if (socket !== undefined && (!socket.startsWith('/') || /[:\x00-\x1f\x7f]/.test(socket))) {
|
|
14
|
+
throw new Error('SSH socket must be an absolute path without colons or control characters');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function startSshGateway(options) {
|
|
18
|
+
validateSshTarget(options.host, options.remoteSocket);
|
|
19
|
+
options.signal?.throwIfAborted();
|
|
20
|
+
const directory = await mkdtemp(path.join(tmpdir(), 'omochi-ssh-'));
|
|
21
|
+
const socketPath = path.join(directory, 'gateway.sock');
|
|
22
|
+
const command = options.command ?? ['ssh'];
|
|
23
|
+
let child;
|
|
24
|
+
let finished = Promise.resolve(new Error('SSH not started'));
|
|
25
|
+
let failure;
|
|
26
|
+
let closing;
|
|
27
|
+
const close = () => closing ??= (async () => {
|
|
28
|
+
options.signal?.removeEventListener('abort', abort);
|
|
29
|
+
if (child && child.exitCode === null && child.signalCode === null) {
|
|
30
|
+
child.kill('SIGTERM');
|
|
31
|
+
const timer = setTimeout(() => child?.kill('SIGKILL'), 1000);
|
|
32
|
+
await finished;
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
}
|
|
35
|
+
await rm(directory, { recursive: true, force: true });
|
|
36
|
+
})();
|
|
37
|
+
const abort = () => { void close(); };
|
|
38
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
39
|
+
const launch = (args, capture) => {
|
|
40
|
+
options.signal?.throwIfAborted();
|
|
41
|
+
failure = undefined;
|
|
42
|
+
child = spawn(command[0], [...command.slice(1), ...args], {
|
|
43
|
+
stdio: ['inherit', capture ? 'pipe' : 'ignore', 'inherit'],
|
|
44
|
+
});
|
|
45
|
+
finished = new Promise((resolve) => {
|
|
46
|
+
child.once('error', (error) => { failure = error; resolve(error); });
|
|
47
|
+
child.once('close', (code, signal) => {
|
|
48
|
+
const error = new Error(`SSH to ${options.host} exited (${signal ?? code})`);
|
|
49
|
+
failure = error;
|
|
50
|
+
resolve(error);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
return child;
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
let remoteSocket = options.remoteSocket;
|
|
57
|
+
if (remoteSocket === undefined) {
|
|
58
|
+
const resolver = launch(['-T', '-o', 'ControlMaster=no', options.host, `sh -c '${RESOLVE_SOCKET}'`], true);
|
|
59
|
+
let output = '';
|
|
60
|
+
resolver.stdout.setEncoding('utf8').on('data', (data) => {
|
|
61
|
+
output += data;
|
|
62
|
+
if (output.length > 16384)
|
|
63
|
+
resolver.kill('SIGTERM');
|
|
64
|
+
});
|
|
65
|
+
const error = await finished;
|
|
66
|
+
options.signal?.throwIfAborted();
|
|
67
|
+
if (resolver.exitCode !== 0)
|
|
68
|
+
throw error;
|
|
69
|
+
remoteSocket = output.trimEnd();
|
|
70
|
+
validateSshTarget(options.host, remoteSocket);
|
|
71
|
+
}
|
|
72
|
+
launch([
|
|
73
|
+
'-N', '-T', '-o', 'ControlMaster=no', '-o', 'ControlPath=none',
|
|
74
|
+
'-o', 'ExitOnForwardFailure=yes', '-o', 'ServerAliveInterval=15',
|
|
75
|
+
'-o', 'ServerAliveCountMax=3', '-L', `${socketPath}:${remoteSocket}`, options.host,
|
|
76
|
+
], false);
|
|
77
|
+
// OpenSSH creates the listener after authentication; do not time out a prompt.
|
|
78
|
+
while (!(await stat(socketPath).catch(() => null))?.isSocket()) {
|
|
79
|
+
options.signal?.throwIfAborted();
|
|
80
|
+
if (failure)
|
|
81
|
+
throw failure;
|
|
82
|
+
await delay(50);
|
|
83
|
+
}
|
|
84
|
+
const deadline = Date.now() + (options.startTimeoutMs ?? 10000);
|
|
85
|
+
while (true) {
|
|
86
|
+
options.signal?.throwIfAborted();
|
|
87
|
+
if (failure)
|
|
88
|
+
throw failure;
|
|
89
|
+
const remaining = deadline - Date.now();
|
|
90
|
+
if (remaining <= 0)
|
|
91
|
+
throw new Error(`gateway at ${options.host}:${remoteSocket} did not answer within the connection timeout`);
|
|
92
|
+
let timer;
|
|
93
|
+
const ready = await Promise.race([
|
|
94
|
+
probeGateway({ kind: 'unix', socketPath }),
|
|
95
|
+
new Promise((resolve) => { timer = setTimeout(() => resolve(null), remaining); }),
|
|
96
|
+
]).finally(() => clearTimeout(timer));
|
|
97
|
+
if (ready)
|
|
98
|
+
break;
|
|
99
|
+
await delay(50);
|
|
100
|
+
}
|
|
101
|
+
options.signal?.throwIfAborted();
|
|
102
|
+
if (failure)
|
|
103
|
+
throw failure;
|
|
104
|
+
return { socketPath, exited: finished, close };
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
await close();
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
const root = path.dirname(fileURLToPath(import.meta.resolve('@omochi-ui/hub-ui/assets')));
|
|
5
|
+
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' };
|
|
6
|
+
export function serveHubUi(request, response) {
|
|
7
|
+
const pathname = new URL(request.url ?? '/', 'http://localhost').pathname;
|
|
8
|
+
if (pathname !== '/' && pathname !== '/index.html' && !pathname.startsWith('/assets/'))
|
|
9
|
+
return false;
|
|
10
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
11
|
+
response.writeHead(405).end();
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
let filename;
|
|
15
|
+
try {
|
|
16
|
+
filename = path.resolve(root, '.' + (pathname === '/' ? '/index.html' : decodeURIComponent(pathname)));
|
|
17
|
+
if (!filename.startsWith(root + path.sep))
|
|
18
|
+
throw new Error();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
response.writeHead(400).end();
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
void readFile(filename).then(data => {
|
|
25
|
+
response.writeHead(200, { 'Content-Type': types[path.extname(filename)] ?? 'application/octet-stream',
|
|
26
|
+
'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'no-cache' });
|
|
27
|
+
response.end(request.method === 'HEAD' ? undefined : data);
|
|
28
|
+
}).catch(() => response.writeHead(404).end('Hub UI not found; run bun run build.'));
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
2
|
+
export interface StdioWebSocketBridgeOptions {
|
|
3
|
+
command: string[];
|
|
4
|
+
host?: string;
|
|
5
|
+
port?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface StdioWebSocketBridgeHandle {
|
|
8
|
+
readonly url: string;
|
|
9
|
+
readonly child: ChildProcessWithoutNullStreams;
|
|
10
|
+
readonly exited: Promise<number>;
|
|
11
|
+
close(): Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Hosts one child application for every connected renderer. The hub generalizes this
|
|
15
|
+
* to many backends; this entry point stays single-session and never rewrites surface ids.
|
|
16
|
+
*/
|
|
17
|
+
export declare function startStdioWebSocketBridge(options: StdioWebSocketBridgeOptions): Promise<StdioWebSocketBridgeHandle>;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import process from 'node:process';
|
|
2
|
+
import { A2uiSnapshot, FanOut, closeSocket, parseClientFrame, readJsonlMessages, spawnChildSession, startWsEndpoint, webSocketClient, } from '@omochi-ui/core';
|
|
3
|
+
/**
|
|
4
|
+
* Hosts one child application for every connected renderer. The hub generalizes this
|
|
5
|
+
* to many backends; this entry point stays single-session and never rewrites surface ids.
|
|
6
|
+
*/
|
|
7
|
+
export async function startStdioWebSocketBridge(options) {
|
|
8
|
+
if (!options.command.length)
|
|
9
|
+
throw new Error('a child command is required after --');
|
|
10
|
+
const host = options.host ?? '127.0.0.1';
|
|
11
|
+
const port = options.port ?? 5555;
|
|
12
|
+
const snapshot = new A2uiSnapshot();
|
|
13
|
+
const fanOut = new FanOut(() => snapshot.messages());
|
|
14
|
+
let endpoint = null;
|
|
15
|
+
let childProtocolFailed = false;
|
|
16
|
+
const closeEndpoint = () => {
|
|
17
|
+
if (endpoint?.httpServer.listening)
|
|
18
|
+
endpoint.httpServer.close();
|
|
19
|
+
};
|
|
20
|
+
const session = spawnChildSession(options.command, {
|
|
21
|
+
onExit(exitCode) {
|
|
22
|
+
if (!childProtocolFailed) {
|
|
23
|
+
fanOut.closeAll(exitCode === 0 ? 1000 : 1011, `child process exited with code ${exitCode}`);
|
|
24
|
+
}
|
|
25
|
+
closeEndpoint();
|
|
26
|
+
},
|
|
27
|
+
onError(error) {
|
|
28
|
+
fanOut.closeAll(1011, `failed to start child: ${error.message}`);
|
|
29
|
+
closeEndpoint();
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
readJsonlMessages({
|
|
33
|
+
input: session.child.stdout,
|
|
34
|
+
onMessage(value, raw) {
|
|
35
|
+
snapshot.apply(value);
|
|
36
|
+
fanOut.broadcast(raw);
|
|
37
|
+
},
|
|
38
|
+
onInvalid(detail, lineNumber) {
|
|
39
|
+
childProtocolFailed = true;
|
|
40
|
+
const message = `child stdout line ${lineNumber}: invalid A2UI JSON: ${detail}`;
|
|
41
|
+
process.stderr.write(`[omochi-ui] ${message}\n`);
|
|
42
|
+
fanOut.closeAll(1002, message);
|
|
43
|
+
session.kill('SIGTERM');
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
try {
|
|
47
|
+
endpoint = await startWsEndpoint({ host, port });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
session.kill('SIGTERM');
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
endpoint.onConnection((socket) => {
|
|
54
|
+
const client = webSocketClient(socket);
|
|
55
|
+
fanOut.add(client);
|
|
56
|
+
socket.on('message', (data, isBinary) => {
|
|
57
|
+
const frame = parseClientFrame(data, isBinary);
|
|
58
|
+
if (!frame.ok) {
|
|
59
|
+
closeSocket(socket, frame.code, frame.reason);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
session.writeLine(frame.value);
|
|
63
|
+
});
|
|
64
|
+
socket.on('close', () => fanOut.remove(client));
|
|
65
|
+
});
|
|
66
|
+
if (session.finished)
|
|
67
|
+
closeEndpoint();
|
|
68
|
+
return {
|
|
69
|
+
url: endpoint.url,
|
|
70
|
+
child: session.child,
|
|
71
|
+
exited: session.exited,
|
|
72
|
+
async close() {
|
|
73
|
+
fanOut.closeAll(1001, 'bridge shutting down');
|
|
74
|
+
session.kill('SIGTERM');
|
|
75
|
+
await endpoint.close();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
package/dist/up.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DashboardPreset } from '@omochi-ui/hub-ui/contracts';
|
|
2
|
+
export interface CompositionService {
|
|
3
|
+
id: string;
|
|
4
|
+
command: string[];
|
|
5
|
+
cwd: string;
|
|
6
|
+
env: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export interface Composition {
|
|
9
|
+
id: string;
|
|
10
|
+
services: CompositionService[];
|
|
11
|
+
preset: DashboardPreset;
|
|
12
|
+
}
|
|
13
|
+
/** Parse a composition and resolve its semantic rows onto the hub's twelve-column grid. */
|
|
14
|
+
export declare function parseComposition(source: string, file: string, nodeId: string): Composition;
|
|
15
|
+
export declare function loadComposition(file: string, nodeId: string): Promise<Composition>;
|
|
16
|
+
export interface ServiceSupervisor {
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
export declare function startCompositionServices(services: readonly CompositionService[]): ServiceSupervisor;
|
|
20
|
+
/** Best-effort desktop opener; the hub remains running when no desktop is available. */
|
|
21
|
+
export declare function openBrowser(url: string): void;
|