babavoss 0.0.1 → 0.12.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/NOTICE +7 -0
- package/bin/voss.ts +48 -0
- package/gui/babavoss-web.js +234 -0
- package/gui/chunk-5hpp1ypv.js +11710 -0
- package/gui/chunk-9rq662fd.js +8627 -0
- package/gui/chunk-k3cm8j6r.js +433 -0
- package/gui/chunk-pk09y98y.js +50 -0
- package/gui/chunk-smz02qa6.js +186 -0
- package/gui/chunk-wwqypxre.js +49 -0
- package/gui/gui.js +2015 -0
- package/gui/react-compiler-runtime.js +39 -0
- package/gui/react-dom-client.js +21 -0
- package/gui/react-dom.js +44 -0
- package/gui/react-jsx-runtime.js +19 -0
- package/gui/react.js +105 -0
- package/gui/theme.css +3053 -0
- package/index.ts +15 -0
- package/package.json +50 -4
- package/src/baba/check.ts +90 -0
- package/src/baba/config.ts +261 -0
- package/src/baba/find.ts +12 -0
- package/src/baba/init.ts +176 -0
- package/src/baba/node.ts +483 -0
- package/src/baba/project.ts +63 -0
- package/src/baba/registry.ts +35 -0
- package/src/baba/worker.ts +55 -0
- package/src/bench/index.ts +6 -0
- package/src/bench/measure.ts +132 -0
- package/src/bench/scenarios.ts +136 -0
- package/src/build/builder.ts +74 -0
- package/src/build/failure.ts +78 -0
- package/src/build/guard.ts +85 -0
- package/src/build/mdx-register.ts +3 -0
- package/src/build/mdx.ts +38 -0
- package/src/build/project.ts +38 -0
- package/src/build/views.ts +146 -0
- package/src/builder/main.ts +29 -0
- package/src/desktop/bob.ts +76 -0
- package/src/desktop/desktop.css +111 -0
- package/src/desktop/icons.ts +50 -0
- package/src/desktop/index.ts +323 -0
- package/src/desktop/routes.ts +98 -0
- package/src/desktop/view.tsx +673 -0
- package/src/door/core.ts +384 -0
- package/src/ecs/baba.ts +431 -0
- package/src/ecs/codec.ts +334 -0
- package/src/ecs/handles.ts +91 -0
- package/src/ecs/replica.ts +150 -0
- package/src/ecs/runtime.ts +603 -0
- package/src/ecs/scheduler.ts +75 -0
- package/src/ecs/snapshot.ts +102 -0
- package/src/ecs/state.ts +759 -0
- package/src/ecs/system.ts +256 -0
- package/src/ecs/table.ts +420 -0
- package/src/ecs/testbed.ts +97 -0
- package/src/exec/host.ts +177 -0
- package/src/exec/main.ts +98 -0
- package/src/exec/watch.ts +7 -0
- package/src/exec/wire.ts +29 -0
- package/src/generated/build.ts +4 -0
- package/src/gui/css.d.ts +1 -0
- package/src/gui/gui.tsx +245 -0
- package/src/gui/index.ts +51 -0
- package/src/gui/inspector.tsx +47 -0
- package/src/gui/levels.tsx +73 -0
- package/src/gui/promptware.tsx +68 -0
- package/src/gui/runner.tsx +118 -0
- package/src/gui/theme.css +498 -0
- package/src/gui/theme.ts +25 -0
- package/src/gui/wizard.tsx +227 -0
- package/src/guide/add-a-desktop.mdx +100 -0
- package/src/guide/compose-an-interface.mdx +84 -0
- package/src/guide/index.ts +13 -0
- package/src/guide/reach-outside.mdx +112 -0
- package/src/guide/spec-a-system.mdx +93 -0
- package/src/guide/systems-together.mdx +72 -0
- package/src/guide/write-a-system.mdx +183 -0
- package/src/guide/write-promptware.mdx +90 -0
- package/src/http/server.ts +310 -0
- package/src/kernel/build.ts +21 -0
- package/src/kernel/builder.ts +105 -0
- package/src/kernel/children.ts +117 -0
- package/src/kernel/context.ts +90 -0
- package/src/kernel/lock.ts +46 -0
- package/src/kernel/names.ts +14 -0
- package/src/kernel/schema.ts +130 -0
- package/src/kernel/where.ts +12 -0
- package/src/kit/index.ts +232 -0
- package/src/maker/system.ts +213 -0
- package/src/mcp/daemon.ts +61 -0
- package/src/mcp/main.ts +208 -0
- package/src/mcp/rpc.ts +64 -0
- package/src/mcp/tools.ts +125 -0
- package/src/prompt/evals.ts +42 -0
- package/src/prompt/index.ts +242 -0
- package/src/prompt/jsx-dev-runtime.ts +1 -0
- package/src/prompt/jsx-runtime.ts +49 -0
- package/src/prompt/mdx.d.ts +1 -0
- package/src/promptware/compile.ts +183 -0
- package/src/promptware/define.ts +16 -0
- package/src/promptware/disk.ts +72 -0
- package/src/promptware/markdown.d.ts +6 -0
- package/src/promptware/sync.ts +437 -0
- package/src/promptware/system.ts +215 -0
- package/src/runtime/bridge.ts +85 -0
- package/src/runtime/connect.ts +54 -0
- package/src/runtime/env.ts +35 -0
- package/src/runtime/harness.ts +80 -0
- package/src/runtime/main.ts +119 -0
- package/src/runtime/worker.ts +33 -0
- package/src/server/edge.ts +332 -0
- package/src/server/main.ts +45 -0
- package/src/server/messages.ts +97 -0
- package/src/server/protocol.ts +37 -0
- package/src/services/args.ts +45 -0
- package/src/services/exec.ts +69 -0
- package/src/services/fs.ts +139 -0
- package/src/services/http.ts +30 -0
- package/src/services/index.ts +113 -0
- package/src/services/secrets.ts +18 -0
- package/src/shell/address.ts +21 -0
- package/src/shell/args.ts +219 -0
- package/src/shell/client.ts +107 -0
- package/src/shell/codes.ts +26 -0
- package/src/shell/positional.ts +20 -0
- package/src/shell/run.ts +470 -0
- package/src/shell/service.ts +167 -0
- package/src/shell/state.ts +204 -0
- package/src/spec/adapters.ts +72 -0
- package/src/spec/diff.ts +26 -0
- package/src/spec/files.ts +17 -0
- package/src/spec/index.ts +155 -0
- package/src/spec/run.ts +97 -0
- package/src/spec/take.ts +54 -0
- package/src/test/index.ts +8 -0
- package/src/test/prove.ts +56 -0
- package/src/test/records.ts +23 -0
- package/src/test/specs.ts +56 -0
- package/src/test/steps.ts +100 -0
- package/src/test/voss-dir.ts +17 -0
- package/src/transport/messages.ts +110 -0
- package/src/transport/transport.ts +62 -0
- package/src/wall/probe.ts +67 -0
- package/src/wall/profile.ts +103 -0
- package/src/wall/spawn.ts +59 -0
- package/src/web/app.tsx +53 -0
- package/src/web/core.tsx +140 -0
- package/src/web/form.ts +155 -0
- package/src/web/hooks.ts +135 -0
- package/src/web/index.tsx +17 -0
- package/src/web/list.ts +19 -0
- package/src/web/maker.tsx +766 -0
- package/src/web/objects.tsx +213 -0
- package/src/web/socket.ts +84 -0
- package/src/web/state.tsx +69 -0
- package/src/web/store.ts +221 -0
- package/src/web/ui.tsx +135 -0
- package/README.md +0 -5
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// The GUI server's routes, answered from what the kernel sent and by asking
|
|
2
|
+
// the kernel; and the windows' sockets, each following one baba: a frame
|
|
3
|
+
// per round pushed by the kernel and cut to what the window asks for, and
|
|
4
|
+
// the window's fires, reads and raw writes forwarded and answered on the
|
|
5
|
+
// same socket, in order. No file, no project, no Node here: only memory and IPC.
|
|
6
|
+
import { isIP } from "node:net";
|
|
7
|
+
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
|
8
|
+
import type { Serve, Described, Failure, Describe, DescribeAnswer, View, ViewAnswer, Call, CallAnswer, Refresh, RefreshAnswer, Restart, RestartAnswer, Browse, BrowseAnswer, Add, AddAnswer } from "./messages.ts";
|
|
9
|
+
import type { Raw } from "../ecs/runtime.ts";
|
|
10
|
+
import type { Json } from "../kernel/schema.ts";
|
|
11
|
+
import { frameHeader, resolveInterest, sliceFrame, type Resolved } from "../ecs/codec.ts";
|
|
12
|
+
import { CLOSE, SOCKET_PROTOCOL, type Down, type Interest, type Up } from "./protocol.ts";
|
|
13
|
+
|
|
14
|
+
interface Pairs { describe: [Describe, DescribeAnswer]; view: [View, ViewAnswer]; call: [Call, CallAnswer]; refresh: [Refresh, RefreshAnswer]; restart: [Restart, RestartAnswer]; browse: [Browse, BrowseAnswer]; add: [Add, AddAnswer] }
|
|
15
|
+
type Unnumbered<T> = T extends unknown ? Omit<T, "id"> : never;
|
|
16
|
+
/** Sends a request to the kernel, numbered by the sender, and resolves with its answer. */
|
|
17
|
+
export type Ask = <K extends keyof Pairs>(m: Unnumbered<Pairs[K][0]>) => Promise<Pairs[K][1]>;
|
|
18
|
+
|
|
19
|
+
/** Hosts no DNS answer can rebind to: IP addresses and localhost names. */
|
|
20
|
+
export function localHost(host: string): boolean {
|
|
21
|
+
const name = host.startsWith("[") ? host.slice(1, host.indexOf("]")) : host.replace(/:\d+$/, "");
|
|
22
|
+
const lower = name.toLowerCase();
|
|
23
|
+
return isIP(name) !== 0 || lower === "localhost" || lower.endsWith(".localhost");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function same(a: string, b: string): boolean {
|
|
27
|
+
if (a.length !== b.length) return false;
|
|
28
|
+
let d = 0;
|
|
29
|
+
for (let i = 0; i < a.length; i++) d |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
30
|
+
return d === 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function tokenOf(req: globalThis.Request): string | null {
|
|
34
|
+
const auth = req.headers.get("authorization");
|
|
35
|
+
if (auth?.startsWith("Bearer ")) return auth.slice(7).trim();
|
|
36
|
+
return req.headers.get("x-voss-token");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const json = (v: unknown, status = 200) => Response.json(v, { status, headers: { "cache-control": "no-store" } });
|
|
40
|
+
const text = (s: string, status: number) => new Response(s, { status, headers: { "cache-control": "no-store" } });
|
|
41
|
+
const html = (s: string) => new Response(s, { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
42
|
+
|
|
43
|
+
function status(code: string): number {
|
|
44
|
+
return code === "nokey" || code === "noaction" || code === "noquery" || code === "noview" || code === "entity" || code === "name" ? 404 : code === "args" || code === "owner" ? 400 : 500;
|
|
45
|
+
}
|
|
46
|
+
const failed = (f: Failure) => json({ error: { code: f.code, message: f.message } }, status(f.code));
|
|
47
|
+
|
|
48
|
+
const RAW = new Set(["spawn", "add", "remove", "despawn", "set"]);
|
|
49
|
+
|
|
50
|
+
// ---- the sockets ------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/** A window's socket: which baba it follows, where it stands, and what it asks for. */
|
|
53
|
+
export interface Peer {
|
|
54
|
+
name: string;
|
|
55
|
+
authed: boolean;
|
|
56
|
+
/** The round the window holds: what the next frame is since. */
|
|
57
|
+
since: number;
|
|
58
|
+
/** A whole or a grown interest's parts are on their way: frames pushed meanwhile wait behind them. */
|
|
59
|
+
ready: boolean;
|
|
60
|
+
buffer: { round: number; frame: Uint8Array }[];
|
|
61
|
+
/** Everything that re-bases the stream, in turn: a hello, a resync, a grown interest. */
|
|
62
|
+
queue: Promise<void>;
|
|
63
|
+
/** The frame's layout, from the manifest at hello: what interest names resolve against. */
|
|
64
|
+
cid: Map<string, number>;
|
|
65
|
+
rid: Map<string, number>;
|
|
66
|
+
/** What the window asks for, by name and resolved: every frame is cut to it. */
|
|
67
|
+
interest: Interest;
|
|
68
|
+
want: Resolved;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Bytes a window may have unread before it is dropped to resync, as a source stream that overflows is. */
|
|
72
|
+
const BEHIND = 8 * 1024 * 1024;
|
|
73
|
+
|
|
74
|
+
export interface Edge {
|
|
75
|
+
fetch(req: globalThis.Request, server: Server<Peer>): Promise<Response | undefined>;
|
|
76
|
+
websocket: WebSocketHandler<Peer>;
|
|
77
|
+
/** A round of `name`: its frame to every window following it. */
|
|
78
|
+
push(name: string, round: number, frame: Uint8Array): void;
|
|
79
|
+
/** `name`'s generation advanced: its windows are told, and hello again. */
|
|
80
|
+
generation(name: string, gen: number): void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The names an interest newly asks the values or presence of, over the one before; null when none. */
|
|
84
|
+
export function added(before: Interest, after: Interest): Interest | null {
|
|
85
|
+
if (after.all) return before.all ? null : { all: true };
|
|
86
|
+
const had = (xs?: string[]) => new Set(xs ?? []);
|
|
87
|
+
const bp = had(before.presence), bv = had(before.values), br = had(before.resources);
|
|
88
|
+
const values = (after.values ?? []).filter((n) => !bv.has(n));
|
|
89
|
+
const presence = (after.presence ?? []).filter((n) => !bp.has(n) && !bv.has(n) && !values.includes(n));
|
|
90
|
+
const resources = (after.resources ?? []).filter((n) => !br.has(n));
|
|
91
|
+
const parts: Record<string, number[]> = {};
|
|
92
|
+
for (const [name, es] of Object.entries(after.parts ?? {})) {
|
|
93
|
+
if (bv.has(name) || values.includes(name)) continue;
|
|
94
|
+
const was = new Set(before.parts?.[name] ?? []);
|
|
95
|
+
const fresh = es.filter((e) => !was.has(e));
|
|
96
|
+
if (fresh.length) parts[name] = fresh;
|
|
97
|
+
}
|
|
98
|
+
if (!values.length && !presence.length && !resources.length && !Object.keys(parts).length) return null;
|
|
99
|
+
return { presence, values, parts, resources };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function edge(state: Serve, ask: Ask): Edge {
|
|
103
|
+
// The kernel replaces the projects on a refresh; the names follow.
|
|
104
|
+
const names = { has: (n: string) => state.projects.some((p) => p.name === n) };
|
|
105
|
+
const peers = new Map<string, Set<ServerWebSocket<Peer>>>();
|
|
106
|
+
const join = (ws: ServerWebSocket<Peer>) => { let s = peers.get(ws.data.name); if (!s) { s = new Set(); peers.set(ws.data.name, s); } s.add(ws); };
|
|
107
|
+
const leave = (ws: ServerWebSocket<Peer>) => { peers.get(ws.data.name)?.delete(ws); };
|
|
108
|
+
const tell = (ws: ServerWebSocket<Peer>, m: Down) => { ws.send(JSON.stringify(m)); };
|
|
109
|
+
const open = (ws: ServerWebSocket<Peer>) => ws.readyState === 1;
|
|
110
|
+
|
|
111
|
+
/** The frame cut to the window's interest and sent, if anything is left of it; where the window stands moves with what it was sent. A whole always goes: it is the window's state from here. */
|
|
112
|
+
const give = (ws: ServerWebSocket<Peer>, frame: Uint8Array, whole = false) => {
|
|
113
|
+
const d = ws.data;
|
|
114
|
+
const { round } = frameHeader(frame);
|
|
115
|
+
const out = d.want.all ? frame : sliceFrame(frame, d.want, d.since) ?? (whole ? frame : null);
|
|
116
|
+
if (!out) return;
|
|
117
|
+
ws.send(out);
|
|
118
|
+
d.since = round;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** A frame from the kernel: the state since where the window stands, or the parts it began to ask for. */
|
|
122
|
+
const fetchFrame = async (ws: ServerWebSocket<Peer>, m: { since: number; parts?: Interest }): Promise<Uint8Array | null> => {
|
|
123
|
+
const a = await ask<"call">({ type: "call", name: ws.data.name, kind: "state", since: m.since, frame: true, wait: false, ...(m.parts ? { parts: m.parts } : {}) });
|
|
124
|
+
if (!a.ok) { ws.close(CLOSE.down, a.message); return null; }
|
|
125
|
+
return open(ws) ? a.value as Uint8Array : null;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Re-bases the stream: frames pushed while `fn` fetches wait, then go in
|
|
130
|
+
* order with what it fetched, nothing lost between; one at a time per
|
|
131
|
+
* window, so a hello, a resync and a grown interest never cross.
|
|
132
|
+
*/
|
|
133
|
+
const rebase = (ws: ServerWebSocket<Peer>, fn: () => Promise<void>) => {
|
|
134
|
+
const d = ws.data;
|
|
135
|
+
d.queue = d.queue.then(async () => {
|
|
136
|
+
d.ready = false; d.buffer = [];
|
|
137
|
+
try { await fn(); } finally {
|
|
138
|
+
d.ready = true;
|
|
139
|
+
for (const b of d.buffer) if (b.round > d.since) give(ws, b.frame);
|
|
140
|
+
d.buffer = [];
|
|
141
|
+
}
|
|
142
|
+
}).catch(() => {});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** The window's state from where it stands: a welcome first when it just said hello. */
|
|
146
|
+
const sync = (ws: ServerWebSocket<Peer>, welcome: boolean) => rebase(ws, async () => {
|
|
147
|
+
const d = ws.data;
|
|
148
|
+
join(ws);
|
|
149
|
+
if (welcome) {
|
|
150
|
+
const a = await ask<"describe">({ type: "describe", name: d.name, since: 0 });
|
|
151
|
+
if (!a.ok) { ws.close(CLOSE.unknown, a.message); return; }
|
|
152
|
+
const m = a.value.manifest;
|
|
153
|
+
if (!m) { ws.close(CLOSE.down, `${d.name} is not running`); return; }
|
|
154
|
+
const declared = (x: Record<string, { schema: Json }>) => Object.entries(x).map(([name, v]) => ({ name, json: v.schema }));
|
|
155
|
+
d.cid = new Map(Object.keys(m.components).map((n, i) => [n, i])); d.rid = new Map(Object.keys(m.resources).map((n, i) => [n, i]));
|
|
156
|
+
d.want = resolveInterest(d.interest, d.cid, d.rid);
|
|
157
|
+
tell(ws, { type: "welcome", protocol: SOCKET_PROTOCOL, gen: a.value.gen, components: declared(m.components), resources: declared(m.resources) });
|
|
158
|
+
}
|
|
159
|
+
const frame = await fetchFrame(ws, { since: d.since });
|
|
160
|
+
if (frame) give(ws, frame, frameHeader(frame).whole);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/** The window began to ask for more: those parts as they are, in order with the rounds. */
|
|
164
|
+
const grow = (ws: ServerWebSocket<Peer>, more: Interest) => {
|
|
165
|
+
if (more.all) { ws.data.since = 0; sync(ws, false); return; }
|
|
166
|
+
rebase(ws, async () => {
|
|
167
|
+
const d = ws.data;
|
|
168
|
+
const frame = await fetchFrame(ws, { since: 0, parts: more });
|
|
169
|
+
if (!frame) return;
|
|
170
|
+
const { round } = frameHeader(frame);
|
|
171
|
+
for (const b of d.buffer) if (b.round <= round) give(ws, b.frame);
|
|
172
|
+
d.buffer = d.buffer.filter((b) => b.round > round);
|
|
173
|
+
give(ws, frame);
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
async function call(ws: ServerWebSocket<Peer>, m: Extract<Up, { id: number }>): Promise<void> {
|
|
178
|
+
const d = ws.data;
|
|
179
|
+
let c: Unnumbered<Call>;
|
|
180
|
+
if (m.type === "fire") c = { type: "call", name: d.name, kind: "fire", target: m.name, args: m.args ?? {}, ...(m.key ? { key: m.key } : {}) };
|
|
181
|
+
else if (m.type === "read") c = { type: "call", name: d.name, kind: "read", target: m.name, args: m.args ?? {} };
|
|
182
|
+
else {
|
|
183
|
+
const { type: _t, id: _i, op, ...rest } = m;
|
|
184
|
+
if (!RAW.has(op)) { tell(ws, { type: "result", id: m.id, ok: false, code: "notfound", message: `no raw op ${op}` }); return; }
|
|
185
|
+
c = { type: "call", name: d.name, kind: "raw", raw: { op, ...rest } as Raw };
|
|
186
|
+
}
|
|
187
|
+
const a = await ask<"call">(c);
|
|
188
|
+
if (!open(ws)) return;
|
|
189
|
+
if (a.ok) tell(ws, { type: "result", id: m.id, ok: true, value: a.value, ...(a.round !== undefined ? { round: a.round } : {}) });
|
|
190
|
+
else tell(ws, { type: "result", id: m.id, ok: false, code: a.code, message: a.message });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const websocket: WebSocketHandler<Peer> = {
|
|
194
|
+
async message(ws, raw) {
|
|
195
|
+
if (typeof raw !== "string") return;
|
|
196
|
+
let m: Up;
|
|
197
|
+
try { m = JSON.parse(raw) as Up; } catch { ws.close(4000, "not JSON"); return; }
|
|
198
|
+
const d = ws.data;
|
|
199
|
+
if (m.type === "hello") {
|
|
200
|
+
if (m.protocol !== SOCKET_PROTOCOL) { ws.close(CLOSE.badProtocol, `protocol ${SOCKET_PROTOCOL}`); return; }
|
|
201
|
+
if (typeof m.token !== "string" || !same(m.token, state.token)) { ws.close(CLOSE.unauthorized, "the page token is missing or wrong"); return; }
|
|
202
|
+
d.authed = true;
|
|
203
|
+
d.since = Math.max(0, Number(m.since) || 0);
|
|
204
|
+
d.interest = m.interest ?? { all: true };
|
|
205
|
+
sync(ws, true);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!d.authed) { ws.close(CLOSE.unauthorized, "hello first"); return; }
|
|
209
|
+
if (m.type === "subscribe") {
|
|
210
|
+
const before = d.interest;
|
|
211
|
+
d.interest = m.interest ?? {};
|
|
212
|
+
d.want = resolveInterest(d.interest, d.cid, d.rid);
|
|
213
|
+
const more = added(before, d.interest);
|
|
214
|
+
if (more) grow(ws, more);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (m.type === "fire" || m.type === "read" || m.type === "raw") { await call(ws, m); return; }
|
|
218
|
+
if (m.type === "resync") { d.since = 0; sync(ws, false); return; }
|
|
219
|
+
},
|
|
220
|
+
close(ws) { leave(ws); },
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
function push(name: string, round: number, frame: Uint8Array): void {
|
|
224
|
+
for (const ws of peers.get(name) ?? []) {
|
|
225
|
+
const d = ws.data;
|
|
226
|
+
if (!d.authed) continue;
|
|
227
|
+
if (!d.ready) { d.buffer.push({ round, frame }); continue; }
|
|
228
|
+
if (ws.getBufferedAmount() > BEHIND) { ws.close(CLOSE.behind, "behind"); continue; }
|
|
229
|
+
give(ws, frame);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function generation(name: string, gen: number): void {
|
|
234
|
+
for (const ws of peers.get(name) ?? []) tell(ws, { type: "generation", gen });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const fetch = async (req: globalThis.Request, server: Server<Peer>): Promise<Response | undefined> => {
|
|
238
|
+
const host = req.headers.get("host") ?? "";
|
|
239
|
+
if (!localHost(host)) return text("host", 403);
|
|
240
|
+
const origin = req.headers.get("origin");
|
|
241
|
+
const upgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
242
|
+
if ((req.method !== "GET" && req.method !== "HEAD" || upgrade) && origin && origin !== `http://${host}`) return text("origin", 403);
|
|
243
|
+
|
|
244
|
+
const url = new URL(req.url);
|
|
245
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
246
|
+
if (parts.length === 0) return html(state.pages[""]!);
|
|
247
|
+
|
|
248
|
+
// /ws/NAME: a window's socket to the baba; the token comes in its hello.
|
|
249
|
+
if (parts[0] === "ws" && parts[1] && parts.length === 2) {
|
|
250
|
+
if (!names.has(parts[1])) return text("notfound", 404);
|
|
251
|
+
const data: Peer = { name: parts[1], authed: false, since: 0, ready: false, buffer: [], queue: Promise.resolve(), cid: new Map(), rid: new Map(), interest: { all: true }, want: resolveInterest({ all: true }, new Map(), new Map()) };
|
|
252
|
+
if (server.upgrade(req, { data })) return undefined;
|
|
253
|
+
return text("upgrade", 400);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (parts[0] === "assets" && parts[1]) {
|
|
257
|
+
const a = state.assets[parts.slice(1).join("/")];
|
|
258
|
+
return a ? new Response(a.bytes, { headers: { "content-type": a.mime, "cache-control": "no-store" } }) : text("notfound", 404);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (parts[0] === "api") {
|
|
262
|
+
const t = tokenOf(req);
|
|
263
|
+
if (!t || !same(t, state.token)) return json({ error: { code: "unauthorized", message: "the page token is missing or wrong" } }, 401);
|
|
264
|
+
if (parts[1] === "projects" && parts[2] === "refresh") {
|
|
265
|
+
if (req.method !== "POST") return json({ error: { code: "notfound", message: "no such route" } }, 404);
|
|
266
|
+
const a = await ask<"refresh">({ type: "refresh" });
|
|
267
|
+
return a.ok ? json(a.value) : failed(a);
|
|
268
|
+
}
|
|
269
|
+
// /api/projects/add: remember a directory's baba, or make one there first; the kernel re-reads the registry and the new one opens.
|
|
270
|
+
if (parts[1] === "projects" && parts[2] === "add") {
|
|
271
|
+
if (req.method !== "POST") return json({ error: { code: "notfound", message: "no such route" } }, 404);
|
|
272
|
+
let body: { dir?: unknown; init?: unknown; name?: unknown; title?: unknown } = {};
|
|
273
|
+
try { body = await req.json() as typeof body; } catch { return json({ error: { code: "args", message: "the body is not JSON" } }, 400); }
|
|
274
|
+
if (typeof body.dir !== "string" || !body.dir) return json({ error: { code: "args", message: "dir: the directory, absolute" } }, 400);
|
|
275
|
+
const a = await ask<"add">({ type: "add", dir: body.dir, init: body.init === true, ...(typeof body.name === "string" && body.name ? { name: body.name } : {}), ...(typeof body.title === "string" && body.title ? { title: body.title } : {}) });
|
|
276
|
+
return a.ok ? json(a.value) : failed(a);
|
|
277
|
+
}
|
|
278
|
+
if (parts[1] === "projects") return json({ projects: state.projects, voss: state.voss, home: state.home });
|
|
279
|
+
// /api/dirs?path=: the directories under one, for picking where a baba is or goes.
|
|
280
|
+
if (parts[1] === "dirs" && parts.length === 2) {
|
|
281
|
+
const a = await ask<"browse">({ type: "browse", path: url.searchParams.get("path") ?? "" });
|
|
282
|
+
return a.ok ? json(a.value) : failed(a);
|
|
283
|
+
}
|
|
284
|
+
// /api/voss: which voss runs and whether a newer one is installed; /api/voss/restart stops it to come back as that one.
|
|
285
|
+
if (parts[1] === "voss" && parts.length === 2) return json(state.voss);
|
|
286
|
+
if (parts[1] === "voss" && parts[2] === "restart" && parts.length === 3) {
|
|
287
|
+
if (req.method !== "POST") return json({ error: { code: "notfound", message: "no such route" } }, 404);
|
|
288
|
+
const a = await ask<"restart">({ type: "restart" });
|
|
289
|
+
return a.ok ? json(a.value) : failed(a);
|
|
290
|
+
}
|
|
291
|
+
const name = parts[2];
|
|
292
|
+
if (!name || !names.has(name)) return json({ error: { code: "nokey", message: `no project ${name ?? ""}` } }, 404);
|
|
293
|
+
if (parts[1] === "describe") {
|
|
294
|
+
const a = await ask<"describe">({ type: "describe", name, since: Number(url.searchParams.get("since")) || 0 });
|
|
295
|
+
return a.ok ? json(a.value satisfies Described) : failed(a);
|
|
296
|
+
}
|
|
297
|
+
if (req.method !== "POST") return json({ error: { code: "notfound", message: "no such route" } }, 404);
|
|
298
|
+
let body: unknown = {};
|
|
299
|
+
try { body = await req.json(); } catch { return json({ error: { code: "args", message: "the body is not JSON" } }, 400); }
|
|
300
|
+
let m: Unnumbered<Call> | null = null;
|
|
301
|
+
// ?coerce reads shell strings as the schema's numbers and booleans: the CLI's way in. ?key=K makes a retry answer the same.
|
|
302
|
+
const key = url.searchParams.get("key");
|
|
303
|
+
if ((parts[1] === "fire" || parts[1] === "read") && parts[3]) m = { type: "call", name, kind: parts[1], target: parts[3], args: body, ...(url.searchParams.has("coerce") ? { coerce: true } : {}), ...(key ? { key } : {}) };
|
|
304
|
+
else if (parts[1] === "state") m = { type: "call", name, kind: "state", since: Number((body as { since?: unknown }).since) || 0 };
|
|
305
|
+
else if (parts[1] === "raw" && parts[3] && RAW.has(parts[3])) m = { type: "call", name, kind: "raw", raw: { op: parts[3], ...(body as object) } as Raw };
|
|
306
|
+
if (!m) return json({ error: { code: "notfound", message: "no such route" } }, 404);
|
|
307
|
+
const a = await ask<"call">(m);
|
|
308
|
+
return a.ok ? json({ value: a.value }) : failed(a);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// /view/NAME/GEN/ is the view's index at a generation, /view/NAME/GEN/PATH one of its outputs.
|
|
312
|
+
// A generation's bundle never changes, so it may be cached for good; an older generation's URL gets the current one, uncached.
|
|
313
|
+
if (parts[0] === "view" && parts[1] && parts[2]) {
|
|
314
|
+
if (!names.has(parts[1])) return text("notfound", 404);
|
|
315
|
+
const path = parts.slice(3).join("/");
|
|
316
|
+
const a = await ask<"view">({ type: "view", name: parts[1], path: path || undefined });
|
|
317
|
+
if (!a.ok) return json({ error: { code: a.code, message: a.message } }, status(a.code) === 404 || a.code === "notfound" ? 404 : 500);
|
|
318
|
+
const cache = parts[2] === String(a.gen) ? "public, max-age=31536000, immutable" : "no-store";
|
|
319
|
+
if ("index" in a) return Response.json({ gen: a.gen, ...a.index }, { headers: { "cache-control": cache } });
|
|
320
|
+
return new Response(a.file.bytes as Uint8Array<ArrayBuffer>, { headers: { "content-type": a.file.type, "cache-control": cache } });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// /NAME/ is the baba's launcher, /NAME/APP/PATH one of its apps at a path; the one page serves them all, and the desktop reads the address.
|
|
324
|
+
if (names.has(parts[0]!)) {
|
|
325
|
+
if (parts.length === 1 && !url.pathname.endsWith("/")) return Response.redirect(`/${parts[0]}/${url.search}`, 302);
|
|
326
|
+
return html(state.pages[parts[0]!]!);
|
|
327
|
+
}
|
|
328
|
+
return text("notfound", 404);
|
|
329
|
+
};
|
|
330
|
+
|
|
331
|
+
return { fetch, websocket, push, generation };
|
|
332
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// The GUI server: a child of the kernel with nothing of its own. It
|
|
2
|
+
// listens on loopback when the kernel says `serve`, answers from the
|
|
3
|
+
// memory that message filled, forwards describe, view and call, and fans
|
|
4
|
+
// each round's frame out to the windows on their sockets.
|
|
5
|
+
import { ParentTransport } from "../transport/transport.ts";
|
|
6
|
+
import type { FromServer, ToServer, Serve } from "./messages.ts";
|
|
7
|
+
import { edge, type Ask, type Edge, type Peer } from "./edge.ts";
|
|
8
|
+
|
|
9
|
+
const kernel = new ParentTransport<FromServer, ToServer>();
|
|
10
|
+
const pending = new Map<number, (m: ToServer) => void>();
|
|
11
|
+
let nextId = 1;
|
|
12
|
+
let server: import("bun").Server<Peer> | null = null;
|
|
13
|
+
let served: Serve | null = null;
|
|
14
|
+
let live: Edge | null = null;
|
|
15
|
+
|
|
16
|
+
const ask: Ask = (m) => new Promise((resolve) => {
|
|
17
|
+
const id = nextId++;
|
|
18
|
+
pending.set(id, resolve as (m: ToServer) => void);
|
|
19
|
+
kernel.send({ ...m, id } as unknown as FromServer);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
kernel.onMessage((m) => {
|
|
23
|
+
if (m.type === "serve") {
|
|
24
|
+
if (server) return;
|
|
25
|
+
try {
|
|
26
|
+
// A describe long poll is silent for up to 25 s; Bun closes a silent connection after 10 s unless told otherwise.
|
|
27
|
+
served = m;
|
|
28
|
+
live = edge(m, ask);
|
|
29
|
+
server = Bun.serve<Peer>({ hostname: m.hostname, port: m.port, idleTimeout: 60, fetch: live.fetch, websocket: live.websocket });
|
|
30
|
+
kernel.send({ type: "listening", hostname: server.hostname ?? m.hostname, port: server.port ?? m.port });
|
|
31
|
+
} catch (err) {
|
|
32
|
+
kernel.send({ type: "failed", error: err instanceof Error ? err.message : String(err) });
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// The edge reads the projects and pages from the served state on every request.
|
|
37
|
+
if (m.type === "projects") { if (served) { served.projects = m.projects; served.pages = m.pages; } return; }
|
|
38
|
+
if (m.type === "voss") { if (served) served.voss = m.voss; return; }
|
|
39
|
+
if (m.type === "round") { live?.push(m.name, m.round, m.frame); return; }
|
|
40
|
+
if (m.type === "generation") { live?.generation(m.name, m.gen); return; }
|
|
41
|
+
const resolve = pending.get(m.id);
|
|
42
|
+
pending.delete(m.id);
|
|
43
|
+
resolve?.(m);
|
|
44
|
+
});
|
|
45
|
+
kernel.send({ type: "ready" });
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// What crosses between the kernel and the GUI server. The server has no
|
|
2
|
+
// files and no projects of its own: the kernel fills its memory with
|
|
3
|
+
// `serve`, then answers every request it forwards, matched by id.
|
|
4
|
+
import type { Described } from "../baba/node.ts";
|
|
5
|
+
import type { Raw } from "../ecs/runtime.ts";
|
|
6
|
+
import type { Interest } from "./protocol.ts";
|
|
7
|
+
|
|
8
|
+
// ---- kernel → server ---------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/** Which voss runs, and whether a newer one is installed: then this one is stale, serving what it has until the restart. */
|
|
11
|
+
export interface Voss {
|
|
12
|
+
version: string; sha: string; at: string; dev: boolean; pid: number;
|
|
13
|
+
/** Under the service: a clean exit is a restart. Otherwise the restart re-runs the same command. */
|
|
14
|
+
kept: boolean;
|
|
15
|
+
stale: boolean;
|
|
16
|
+
installed: { version: string; sha: string; at: string } | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Everything the server holds, sent once: it listens when this arrives. */
|
|
20
|
+
export interface Serve {
|
|
21
|
+
type: "serve";
|
|
22
|
+
hostname: string;
|
|
23
|
+
port: number;
|
|
24
|
+
token: string;
|
|
25
|
+
voss: Voss;
|
|
26
|
+
projects: ProjectEntry[];
|
|
27
|
+
/** The user's home directory: the picker writes paths under it as ~/… and starts browsing there. */
|
|
28
|
+
home: string;
|
|
29
|
+
/** The page per scope: "" is the picker's, a project's name its own. The token meta is already in. */
|
|
30
|
+
pages: Record<string, string>;
|
|
31
|
+
/** The GUI's build, by path under /assets/. */
|
|
32
|
+
assets: Record<string, Asset>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A registered project; `parent` is the nearest registered project whose directory holds it, for nesting. */
|
|
36
|
+
export interface ProjectEntry { name: string; title: string; dir: string; parent: string | null }
|
|
37
|
+
export interface Asset { mime: string; bytes: Uint8Array<ArrayBuffer> }
|
|
38
|
+
|
|
39
|
+
export type { Described };
|
|
40
|
+
|
|
41
|
+
/** A failed request: `code` is the node's (noaction, noquery, args, noview, bundle, down, …). */
|
|
42
|
+
export interface Failure { ok: false; code: string; message: string }
|
|
43
|
+
|
|
44
|
+
export type DescribeAnswer = { type: "describe-answer"; id: number } & ({ ok: true; value: Described } | Failure);
|
|
45
|
+
/** What the view's build produced, by path under /view/NAME/GEN/: the entry to import, the stylesheets to attach. */
|
|
46
|
+
export interface ViewIndex { entry: string; css: string[]; paths: string[] }
|
|
47
|
+
export type ViewAnswer = { type: "view-answer"; id: number } & ({ ok: true; gen: number; index: ViewIndex } | { ok: true; gen: number; file: { type: string; bytes: Uint8Array } } | Failure);
|
|
48
|
+
export type CallAnswer = { type: "call-answer"; id: number } & ({ ok: true; value: unknown; /** The round a fire or a raw write ran in. */ round?: number } | Failure);
|
|
49
|
+
|
|
50
|
+
/** The registry changed: the projects and their pages, replacing what `serve` sent. */
|
|
51
|
+
export interface Projects { type: "projects"; projects: ProjectEntry[]; pages: Record<string, string> }
|
|
52
|
+
/** What a refresh did: the projects now open, and the directories that did not load, with why. */
|
|
53
|
+
export interface Refreshed { projects: ProjectEntry[]; errors: { dir: string; error: string }[] }
|
|
54
|
+
export type RefreshAnswer = { type: "refresh-answer"; id: number } & ({ ok: true; value: Refreshed } | Failure);
|
|
55
|
+
|
|
56
|
+
/** A round of a project's live generation, as the frame every window of it gets. */
|
|
57
|
+
export interface RoundPush { type: "round"; name: string; round: number; frame: Uint8Array }
|
|
58
|
+
/** A project's generation advanced: its windows re-read the contract and resync. */
|
|
59
|
+
export interface Generation { type: "generation"; name: string; gen: number }
|
|
60
|
+
|
|
61
|
+
/** Voss's own standing changed: a newer one is installed. */
|
|
62
|
+
export interface VossPush { type: "voss"; voss: Voss }
|
|
63
|
+
export type RestartAnswer = { type: "restart-answer"; id: number } & ({ ok: true; value: { restarting: true; kept: boolean } } | Failure);
|
|
64
|
+
/** A directory and what is under it: each child, whether it holds a baba, and whether it is already remembered. */
|
|
65
|
+
export interface Browsed { path: string; home: string; parent: string | null; baba: boolean; known: boolean; dirs: { name: string; path: string; baba: boolean; known: boolean }[] }
|
|
66
|
+
export type BrowseAnswer = { type: "browse-answer"; id: number } & ({ ok: true; value: Browsed } | Failure);
|
|
67
|
+
/** What an add did: the project made or remembered, and the registry re-read. */
|
|
68
|
+
export type AddAnswer = { type: "add-answer"; id: number } & ({ ok: true; value: Refreshed & { name: string; dir: string } } | Failure);
|
|
69
|
+
|
|
70
|
+
export type ToServer = Serve | Projects | VossPush | DescribeAnswer | ViewAnswer | CallAnswer | RefreshAnswer | RestartAnswer | BrowseAnswer | AddAnswer | RoundPush | Generation;
|
|
71
|
+
|
|
72
|
+
// ---- server → kernel ---------------------------------------------------
|
|
73
|
+
|
|
74
|
+
export interface Ready { type: "ready" }
|
|
75
|
+
export interface Listening { type: "listening"; hostname: string; port: number }
|
|
76
|
+
export interface Failed { type: "failed"; error: string }
|
|
77
|
+
|
|
78
|
+
/** `since` > 0 asks the kernel to wait until the project passes that generation, or a timeout. */
|
|
79
|
+
export interface Describe { type: "describe"; id: number; name: string; since: number }
|
|
80
|
+
/** The view's index when `path` is absent, else that output of its current bundle. */
|
|
81
|
+
export interface View { type: "view"; id: number; name: string; path?: string }
|
|
82
|
+
/** The baba's contract and state: fire an action, read a query, the state past a round, a raw write. */
|
|
83
|
+
export type Call =
|
|
84
|
+
| { type: "call"; id: number; name: string; kind: "fire" | "read"; target: string; args: unknown; coerce?: boolean; key?: string }
|
|
85
|
+
| { type: "call"; id: number; name: string; kind: "state"; since: number; frame?: true; wait?: boolean; parts?: Interest }
|
|
86
|
+
| { type: "call"; id: number; name: string; kind: "raw"; raw: Raw };
|
|
87
|
+
|
|
88
|
+
/** Re-read the registry: open the projects it gained, retire the ones it lost. */
|
|
89
|
+
export interface Refresh { type: "refresh"; id: number }
|
|
90
|
+
/** Stop and start again, as the newer installed voss. */
|
|
91
|
+
export interface Restart { type: "restart"; id: number }
|
|
92
|
+
/** The directories under `path`, the user's home when empty: for picking where a baba is, or goes. */
|
|
93
|
+
export interface Browse { type: "browse"; id: number; path: string }
|
|
94
|
+
/** Remember a project at `dir`; with `init`, make its .baba first, named and titled as given or after the directory. */
|
|
95
|
+
export interface Add { type: "add"; id: number; dir: string; init: boolean; name?: string; title?: string }
|
|
96
|
+
|
|
97
|
+
export type FromServer = Ready | Listening | Failed | Describe | View | Call | Refresh | Restart | Browse | Add;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The socket between a window and the GUI server: what each side says.
|
|
2
|
+
// Browser-safe; both ends import it, so the two vocabularies are one.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a window asks to follow, by name: everything; the presence of some
|
|
6
|
+
* components (which entities have them, for lists); the values of some
|
|
7
|
+
* (for predicates, orders and whole columns); the values of some parts, an
|
|
8
|
+
* entity's component (for rows); some resources. The edge cuts every frame
|
|
9
|
+
* to it. Empty means nothing.
|
|
10
|
+
*/
|
|
11
|
+
export interface Interest { all?: boolean; presence?: string[]; values?: string[]; parts?: Record<string, number[]>; resources?: string[] }
|
|
12
|
+
|
|
13
|
+
/** A component or resource as the frame lays it out: its name and its schema's JSON. */
|
|
14
|
+
export interface Declared { name: string; json: { [k: string]: unknown } }
|
|
15
|
+
|
|
16
|
+
export const SOCKET_PROTOCOL = 1;
|
|
17
|
+
|
|
18
|
+
/** What a window sends: its hello, what it follows, its calls, a resync. */
|
|
19
|
+
export type Up =
|
|
20
|
+
| { type: "hello"; protocol: number; token: string | null; since: number; interest: Interest }
|
|
21
|
+
| { type: "subscribe"; interest: Interest }
|
|
22
|
+
| { type: "fire"; id: number; name: string; args: unknown; key?: string }
|
|
23
|
+
| { type: "read"; id: number; name: string; args: unknown }
|
|
24
|
+
| { type: "raw"; id: number; op: string; [k: string]: unknown }
|
|
25
|
+
| { type: "resync" };
|
|
26
|
+
|
|
27
|
+
/** What the server sends as text; frames come as bytes. */
|
|
28
|
+
export type Down =
|
|
29
|
+
| { type: "welcome"; protocol: number; gen: number; components: Declared[]; resources: Declared[] }
|
|
30
|
+
| { type: "result"; id: number; ok: true; value: unknown; round?: number }
|
|
31
|
+
| { type: "result"; id: number; ok: false; code: string; message: string }
|
|
32
|
+
| { type: "generation"; gen: number };
|
|
33
|
+
|
|
34
|
+
export type Welcome = Extract<Down, { type: "welcome" }>;
|
|
35
|
+
|
|
36
|
+
/** The close codes the server uses; a window stops trying on the first three. */
|
|
37
|
+
export const CLOSE = { badProtocol: 4002, unauthorized: 4001, unknown: 4004, down: 4005, behind: 4008 } as const;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Checks at the kernel's edge: a runtime sends whatever it likes, so every
|
|
2
|
+
// argument is a shape to verify before a service acts on it.
|
|
3
|
+
import { ServiceError, type Who } from "../kernel/context.ts";
|
|
4
|
+
|
|
5
|
+
export function string(v: unknown, what: string): string {
|
|
6
|
+
if (typeof v !== "string") throw new ServiceError("args", `${what}: expected a string`);
|
|
7
|
+
return v;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function optional<T>(v: unknown, what: string, check: (v: unknown, what: string) => T): T | undefined {
|
|
11
|
+
return v === undefined || v === null ? undefined : check(v, what);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function bytes(v: unknown, what: string): string | Uint8Array {
|
|
15
|
+
if (typeof v === "string" || v instanceof Uint8Array) return v;
|
|
16
|
+
throw new ServiceError("args", `${what}: expected a string or bytes`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function strings(v: unknown, what: string): string[] {
|
|
20
|
+
if (!Array.isArray(v)) throw new ServiceError("args", `${what}: expected an array of strings`);
|
|
21
|
+
return v.map((s, i) => string(s, `${what}[${i}]`));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function object(v: unknown, what: string): Record<string, unknown> {
|
|
25
|
+
if (typeof v !== "object" || v === null || Array.isArray(v)) throw new ServiceError("args", `${what}: expected an object`);
|
|
26
|
+
return v as Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function record(v: unknown, what: string): Record<string, string> {
|
|
30
|
+
const o = object(v, what);
|
|
31
|
+
for (const [k, x] of Object.entries(o)) string(x, `${what}.${k}`);
|
|
32
|
+
return o as Record<string, string>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function params(v: unknown, what: string): (string | number | bigint | boolean | null | Uint8Array)[] {
|
|
36
|
+
if (!Array.isArray(v)) throw new ServiceError("args", `${what}: expected an array`);
|
|
37
|
+
return v.map((p, i) => {
|
|
38
|
+
if (p === null || p === undefined) return null;
|
|
39
|
+
if (typeof p === "string" || typeof p === "number" || typeof p === "bigint" || typeof p === "boolean" || p instanceof Uint8Array) return p;
|
|
40
|
+
throw new ServiceError("args", `${what}[${i}]: expected a string, number, boolean, bytes or null`);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** What a service module exports: one handler per operation of its interface, over unchecked arguments. */
|
|
45
|
+
export type Ops<S> = { [K in keyof S]: (who: Who, args: unknown[], signal: AbortSignal) => Promise<unknown> | AsyncIterable<unknown> };
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Programs on the machine, resolved on PATH and run by
|
|
2
|
+
// the kernel in the project, with a small environment and an owned lifetime.
|
|
3
|
+
import { stat } from "node:fs/promises";
|
|
4
|
+
import { ServiceError, type Exec, type Who } from "../kernel/context.ts";
|
|
5
|
+
import { string, optional, strings, object, record, type Ops } from "./args.ts";
|
|
6
|
+
import { inside } from "./fs.ts";
|
|
7
|
+
import { ExecutionHost } from "../exec/host.ts";
|
|
8
|
+
import type { Command } from "../exec/wire.ts";
|
|
9
|
+
|
|
10
|
+
export const Cap = 10 * 60_000;
|
|
11
|
+
|
|
12
|
+
/** What a program inherits from the kernel: enough to find things and write temp files, nothing of ours. */
|
|
13
|
+
const Inherited = ["PATH", "HOME", "LANG", "TMPDIR"];
|
|
14
|
+
|
|
15
|
+
/** The kernel's PATH, then where a person's tools usually are, voss's own bin first: a daemon under launchd has the system's PATH only. */
|
|
16
|
+
function lookupPath(): string {
|
|
17
|
+
const home = process.env.HOME ?? "";
|
|
18
|
+
const extra = [home && `${home}/.voss/bin`, home && `${home}/.bun/bin`, home && `${home}/.local/bin`, "/opt/homebrew/bin", "/usr/local/bin"].filter(Boolean) as string[];
|
|
19
|
+
const have = (process.env.PATH ?? "").split(":").filter(Boolean);
|
|
20
|
+
return [...have, ...extra.filter((d) => !have.includes(d))].join(":");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One host per kernel services instance, never a global process registry. */
|
|
24
|
+
export function execution(): Ops<Exec> {
|
|
25
|
+
const host = new ExecutionHost();
|
|
26
|
+
const command = async (who: Who, [p, a, o]: unknown[]): Promise<Command> => {
|
|
27
|
+
const program = string(p, "program");
|
|
28
|
+
const args = optional(a, "args", strings) ?? [];
|
|
29
|
+
const options = optional(o, "options", object) ?? {};
|
|
30
|
+
const cwd = optional(options.cwd, "options.cwd", string);
|
|
31
|
+
const input = optional(options.input, "options.input", string);
|
|
32
|
+
const env = optional(options.env, "options.env", record) ?? {};
|
|
33
|
+
const timeout = options.timeout === undefined ? Cap : options.timeout;
|
|
34
|
+
if (timeout !== null && (typeof timeout !== "number" || !Number.isSafeInteger(timeout) || timeout <= 0)) throw new ServiceError("args", "timeout must be positive whole milliseconds or null");
|
|
35
|
+
if (options.interactive !== undefined && typeof options.interactive !== "boolean") throw new ServiceError("args", "interactive must be boolean");
|
|
36
|
+
const PATH = lookupPath();
|
|
37
|
+
const path = Bun.which(program, { PATH });
|
|
38
|
+
if (!path) throw new ServiceError("notfound", `${program} is not on PATH of this machine`);
|
|
39
|
+
const dir = cwd === undefined ? who.project : await inside(who, cwd);
|
|
40
|
+
if (!(await stat(dir).catch(() => null))?.isDirectory()) throw new ServiceError("notfound", `options.cwd: ${cwd} is not a directory`);
|
|
41
|
+
const base = { ...Object.fromEntries(Inherited.filter((k) => process.env[k] !== undefined).map((k) => [k, process.env[k]!])), PATH };
|
|
42
|
+
return { program: path, args, cwd: dir, env: { ...base, ...env }, input, timeout, interactive: options.interactive === true };
|
|
43
|
+
};
|
|
44
|
+
return {
|
|
45
|
+
async run(who, args, signal) {
|
|
46
|
+
if (signal.aborted) throw new ServiceError("failed", "execution cancelled");
|
|
47
|
+
const c = await command(who, args);
|
|
48
|
+
if (c.interactive) throw new ServiceError("args", "interactive stdin requires exec.stream");
|
|
49
|
+
let stdout = "", stderr = "", code = 0, size = 0;
|
|
50
|
+
const out = new TextDecoder(), err = new TextDecoder();
|
|
51
|
+
for await (const e of host.stream(who, c, signal)) {
|
|
52
|
+
if (e.type === "exit") code = e.code;
|
|
53
|
+
else if (e.type === "stdout" || e.type === "stderr") {
|
|
54
|
+
size += e.data.byteLength;
|
|
55
|
+
if (size > 8 * 1024 * 1024) throw new ServiceError("failed", "execution output exceeds 8 MiB; use exec.stream");
|
|
56
|
+
if (e.type === "stdout") stdout += out.decode(e.data, { stream: true }); else stderr += err.decode(e.data, { stream: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { code, stdout: stdout + out.decode(), stderr: stderr + err.decode() };
|
|
60
|
+
},
|
|
61
|
+
async *stream(who, args, signal) {
|
|
62
|
+
if (signal.aborted) throw new ServiceError("failed", "execution cancelled");
|
|
63
|
+
yield* host.stream(who, await command(who, args), signal);
|
|
64
|
+
},
|
|
65
|
+
async write(who, [ref, input], signal) {
|
|
66
|
+
await host.write(who, string(ref, "process"), input === null ? null : string(input, "input"), signal);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|