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.
Files changed (158) hide show
  1. package/NOTICE +7 -0
  2. package/bin/voss.ts +48 -0
  3. package/gui/babavoss-web.js +234 -0
  4. package/gui/chunk-5hpp1ypv.js +11710 -0
  5. package/gui/chunk-9rq662fd.js +8627 -0
  6. package/gui/chunk-k3cm8j6r.js +433 -0
  7. package/gui/chunk-pk09y98y.js +50 -0
  8. package/gui/chunk-smz02qa6.js +186 -0
  9. package/gui/chunk-wwqypxre.js +49 -0
  10. package/gui/gui.js +2015 -0
  11. package/gui/react-compiler-runtime.js +39 -0
  12. package/gui/react-dom-client.js +21 -0
  13. package/gui/react-dom.js +44 -0
  14. package/gui/react-jsx-runtime.js +19 -0
  15. package/gui/react.js +105 -0
  16. package/gui/theme.css +3053 -0
  17. package/index.ts +15 -0
  18. package/package.json +50 -4
  19. package/src/baba/check.ts +90 -0
  20. package/src/baba/config.ts +261 -0
  21. package/src/baba/find.ts +12 -0
  22. package/src/baba/init.ts +176 -0
  23. package/src/baba/node.ts +483 -0
  24. package/src/baba/project.ts +63 -0
  25. package/src/baba/registry.ts +35 -0
  26. package/src/baba/worker.ts +55 -0
  27. package/src/bench/index.ts +6 -0
  28. package/src/bench/measure.ts +132 -0
  29. package/src/bench/scenarios.ts +136 -0
  30. package/src/build/builder.ts +74 -0
  31. package/src/build/failure.ts +78 -0
  32. package/src/build/guard.ts +85 -0
  33. package/src/build/mdx-register.ts +3 -0
  34. package/src/build/mdx.ts +38 -0
  35. package/src/build/project.ts +38 -0
  36. package/src/build/views.ts +146 -0
  37. package/src/builder/main.ts +29 -0
  38. package/src/desktop/bob.ts +76 -0
  39. package/src/desktop/desktop.css +111 -0
  40. package/src/desktop/icons.ts +50 -0
  41. package/src/desktop/index.ts +323 -0
  42. package/src/desktop/routes.ts +98 -0
  43. package/src/desktop/view.tsx +673 -0
  44. package/src/door/core.ts +384 -0
  45. package/src/ecs/baba.ts +431 -0
  46. package/src/ecs/codec.ts +334 -0
  47. package/src/ecs/handles.ts +91 -0
  48. package/src/ecs/replica.ts +150 -0
  49. package/src/ecs/runtime.ts +603 -0
  50. package/src/ecs/scheduler.ts +75 -0
  51. package/src/ecs/snapshot.ts +102 -0
  52. package/src/ecs/state.ts +759 -0
  53. package/src/ecs/system.ts +256 -0
  54. package/src/ecs/table.ts +420 -0
  55. package/src/ecs/testbed.ts +97 -0
  56. package/src/exec/host.ts +177 -0
  57. package/src/exec/main.ts +98 -0
  58. package/src/exec/watch.ts +7 -0
  59. package/src/exec/wire.ts +29 -0
  60. package/src/generated/build.ts +4 -0
  61. package/src/gui/css.d.ts +1 -0
  62. package/src/gui/gui.tsx +245 -0
  63. package/src/gui/index.ts +51 -0
  64. package/src/gui/inspector.tsx +47 -0
  65. package/src/gui/levels.tsx +73 -0
  66. package/src/gui/promptware.tsx +68 -0
  67. package/src/gui/runner.tsx +118 -0
  68. package/src/gui/theme.css +498 -0
  69. package/src/gui/theme.ts +25 -0
  70. package/src/gui/wizard.tsx +227 -0
  71. package/src/guide/add-a-desktop.mdx +100 -0
  72. package/src/guide/compose-an-interface.mdx +84 -0
  73. package/src/guide/index.ts +13 -0
  74. package/src/guide/reach-outside.mdx +112 -0
  75. package/src/guide/spec-a-system.mdx +93 -0
  76. package/src/guide/systems-together.mdx +72 -0
  77. package/src/guide/write-a-system.mdx +183 -0
  78. package/src/guide/write-promptware.mdx +90 -0
  79. package/src/http/server.ts +310 -0
  80. package/src/kernel/build.ts +21 -0
  81. package/src/kernel/builder.ts +105 -0
  82. package/src/kernel/children.ts +117 -0
  83. package/src/kernel/context.ts +90 -0
  84. package/src/kernel/lock.ts +46 -0
  85. package/src/kernel/names.ts +14 -0
  86. package/src/kernel/schema.ts +130 -0
  87. package/src/kernel/where.ts +12 -0
  88. package/src/kit/index.ts +232 -0
  89. package/src/maker/system.ts +213 -0
  90. package/src/mcp/daemon.ts +61 -0
  91. package/src/mcp/main.ts +208 -0
  92. package/src/mcp/rpc.ts +64 -0
  93. package/src/mcp/tools.ts +125 -0
  94. package/src/prompt/evals.ts +42 -0
  95. package/src/prompt/index.ts +242 -0
  96. package/src/prompt/jsx-dev-runtime.ts +1 -0
  97. package/src/prompt/jsx-runtime.ts +49 -0
  98. package/src/prompt/mdx.d.ts +1 -0
  99. package/src/promptware/compile.ts +183 -0
  100. package/src/promptware/define.ts +16 -0
  101. package/src/promptware/disk.ts +72 -0
  102. package/src/promptware/markdown.d.ts +6 -0
  103. package/src/promptware/sync.ts +437 -0
  104. package/src/promptware/system.ts +215 -0
  105. package/src/runtime/bridge.ts +85 -0
  106. package/src/runtime/connect.ts +54 -0
  107. package/src/runtime/env.ts +35 -0
  108. package/src/runtime/harness.ts +80 -0
  109. package/src/runtime/main.ts +119 -0
  110. package/src/runtime/worker.ts +33 -0
  111. package/src/server/edge.ts +332 -0
  112. package/src/server/main.ts +45 -0
  113. package/src/server/messages.ts +97 -0
  114. package/src/server/protocol.ts +37 -0
  115. package/src/services/args.ts +45 -0
  116. package/src/services/exec.ts +69 -0
  117. package/src/services/fs.ts +139 -0
  118. package/src/services/http.ts +30 -0
  119. package/src/services/index.ts +113 -0
  120. package/src/services/secrets.ts +18 -0
  121. package/src/shell/address.ts +21 -0
  122. package/src/shell/args.ts +219 -0
  123. package/src/shell/client.ts +107 -0
  124. package/src/shell/codes.ts +26 -0
  125. package/src/shell/positional.ts +20 -0
  126. package/src/shell/run.ts +470 -0
  127. package/src/shell/service.ts +167 -0
  128. package/src/shell/state.ts +204 -0
  129. package/src/spec/adapters.ts +72 -0
  130. package/src/spec/diff.ts +26 -0
  131. package/src/spec/files.ts +17 -0
  132. package/src/spec/index.ts +155 -0
  133. package/src/spec/run.ts +97 -0
  134. package/src/spec/take.ts +54 -0
  135. package/src/test/index.ts +8 -0
  136. package/src/test/prove.ts +56 -0
  137. package/src/test/records.ts +23 -0
  138. package/src/test/specs.ts +56 -0
  139. package/src/test/steps.ts +100 -0
  140. package/src/test/voss-dir.ts +17 -0
  141. package/src/transport/messages.ts +110 -0
  142. package/src/transport/transport.ts +62 -0
  143. package/src/wall/probe.ts +67 -0
  144. package/src/wall/profile.ts +103 -0
  145. package/src/wall/spawn.ts +59 -0
  146. package/src/web/app.tsx +53 -0
  147. package/src/web/core.tsx +140 -0
  148. package/src/web/form.ts +155 -0
  149. package/src/web/hooks.ts +135 -0
  150. package/src/web/index.tsx +17 -0
  151. package/src/web/list.ts +19 -0
  152. package/src/web/maker.tsx +766 -0
  153. package/src/web/objects.tsx +213 -0
  154. package/src/web/socket.ts +84 -0
  155. package/src/web/state.tsx +69 -0
  156. package/src/web/store.ts +221 -0
  157. package/src/web/ui.tsx +135 -0
  158. package/README.md +0 -5
@@ -0,0 +1,67 @@
1
+ // A probe the tests run behind the wall, sent as text through `bun -e`, so
2
+ // it needs no file of its own. It tries what the wall must deny and reports
3
+ // each attempt as allowed or denied, through IPC when spawned with it, else
4
+ // as one line of JSON on stdout. Targets come from the environment:
5
+ // PROBE_READ (a file to read), PROBE_WRITE (a file to create), PROBE_EXEC (a
6
+ // program to run), PROBE_URL (a URL to fetch).
7
+ // Kept free of imports: it runs as a single script.
8
+
9
+ type Outcome = { allowed: boolean; detail: string };
10
+
11
+ async function attempt(f: () => Promise<unknown> | unknown): Promise<Outcome> {
12
+ try {
13
+ const v = await f();
14
+ return { allowed: true, detail: String(v).slice(0, 80) };
15
+ } catch (e) {
16
+ return { allowed: false, detail: e instanceof Error ? `${(e as { code?: string }).code ?? e.name}: ${e.message}`.slice(0, 120) : String(e) };
17
+ }
18
+ }
19
+
20
+ const env = process.env;
21
+ const results: Record<string, Outcome> = {};
22
+ if (env.PROBE_READ) results.read = await attempt(() => Bun.file(env.PROBE_READ!).text());
23
+ results.readEtc = await attempt(() => Bun.file("/private/etc/hosts").text());
24
+ results.list = await attempt(async () => (await import("node:fs")).readdirSync("/").length);
25
+ if (env.PROBE_WRITE) results.write = await attempt(() => Bun.write(env.PROBE_WRITE!, "written from behind the wall"));
26
+ if (env.PROBE_EXEC) results.exec = await attempt(async () => {
27
+ const p = Bun.spawn([env.PROBE_EXEC!, "-e", "1"], { stdout: "ignore", stderr: "ignore", env: {} });
28
+ return `exit ${await p.exited}`;
29
+ });
30
+ results.execSelf = await attempt(async () => {
31
+ const p = Bun.spawn([process.execPath, "-e", "1"], { stdout: "ignore", stderr: "ignore", env: {} });
32
+ return `exit ${await p.exited}`;
33
+ });
34
+ if (env.PROBE_URL) results.fetch = await attempt(async () => (await fetch(env.PROBE_URL!, { signal: AbortSignal.timeout(3000) })).status);
35
+ results.fetchIp = await attempt(async () => (await fetch("http://1.1.1.1/", { signal: AbortSignal.timeout(3000) })).status);
36
+
37
+ // What the wall must keep working: a worker from text, the local time zone.
38
+ results.worker = await attempt(async () => {
39
+ const url = URL.createObjectURL(new Blob(["self.onmessage = (e) => postMessage(e.data * 2)"], { type: "application/javascript" }));
40
+ const w = new Worker(url);
41
+ const doubled = await new Promise((resolve) => { w.onmessage = (e) => resolve(e.data); w.postMessage(21); });
42
+ w.terminate();
43
+ return doubled;
44
+ });
45
+ results.timezone = await attempt(() => Intl.DateTimeFormat().resolvedOptions().timeZone);
46
+
47
+ // Loopback: serve, then fetch ourselves. The server stays up until told to stop,
48
+ // so the parent can reach it from outside the wall.
49
+ let server: ReturnType<typeof Bun.serve> | null = null;
50
+ results.serve = await attempt(() => {
51
+ server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response("inside") });
52
+ return server.port;
53
+ });
54
+ results.loopback = await attempt(async () => {
55
+ if (!server) throw new Error("no server");
56
+ return await (await fetch(`http://127.0.0.1:${server.port}/`, { signal: AbortSignal.timeout(3000) })).text();
57
+ });
58
+
59
+ const report = { type: "probes", results, port: (server as { port?: number } | null)?.port ?? 0, env: Object.keys(env).sort() };
60
+ if (typeof process.send === "function") {
61
+ process.send(report);
62
+ process.on("message", (m) => { if ((m as { type?: string }).type === "stop") process.exit(0); });
63
+ process.on("disconnect", () => process.exit(0));
64
+ } else {
65
+ console.log(JSON.stringify(report));
66
+ process.exit(0);
67
+ }
@@ -0,0 +1,103 @@
1
+ // The Seatbelt profile a walled child runs under on macOS. It starts from
2
+ // (deny default) and allows only what Bun needs to start and to run code it
3
+ // is sent as text, in workers too:
4
+ //
5
+ // - process-exec of the Bun binary itself, by the path given and its real
6
+ // path, so sandbox-exec can start it and nothing else can be started.
7
+ // There is no process-fork, so the child cannot spawn even itself.
8
+ // - file-read of the binary's directory, to map the executable, and
9
+ // file-read-metadata of the directories above it, for the realpath walk.
10
+ // - file-read of /usr/lib, /System/Library and /private/var/db/dyld: the
11
+ // system libraries and the dyld shared cache every process links against.
12
+ // - file-read of /dev/null, /dev/random, /dev/urandom, and file-write of
13
+ // /dev/null: stdio redirection and the entropy Bun seeds its hashing and
14
+ // crypto with.
15
+ // - file-read of /usr/share/icu, /usr/share/zoneinfo, /private/var/db/timezone
16
+ // and /private/etc/localtime, with file-read-metadata of the links on the
17
+ // way (/etc, /var, /private...): Intl needs ICU data, Date the local zone.
18
+ // Without them Date quietly runs in UTC.
19
+ // - file-read of "/" itself, the directory and not what is under it: the
20
+ // child starts in "/" and Bun opens its working directory at startup. It
21
+ // lets the child list the top-level names, nothing below.
22
+ // - sysctl-read: CPU count, page size, OS version; Bun aborts without it.
23
+ //
24
+ // No mach-lookup at all: Bun starts, runs workers, serves loopback and
25
+ // speaks IPC without any. The one thing lost is os.homedir() with no HOME,
26
+ // which would need opendirectoryd; a walled child has no home anyway.
27
+ //
28
+ // With `loopback`, the child may bind, accept and connect on localhost only;
29
+ // the GUI server needs that, a runtime does not. `readable` and
30
+ // `writable` add subpaths an integrator may grant later; by default none.
31
+ // Everything else is denied: other files, writes, exec, fork, mach services,
32
+ // outbound network including DNS.
33
+ import { realpathSync } from "node:fs";
34
+ import { dirname } from "node:path";
35
+
36
+ /** Directories a path passes through, "/" excluded: /a/b/c gives /a, /a/b. */
37
+ function ancestors(path: string): string[] {
38
+ const out: string[] = [];
39
+ for (let d = dirname(path); d !== "/" && d !== "."; d = dirname(d)) out.unshift(d);
40
+ return out;
41
+ }
42
+
43
+ export interface ProfileOptions {
44
+ /** The program the wall may run: the Bun binary. */
45
+ binary: string;
46
+ /** Allow listening and connecting on localhost. */
47
+ loopback?: boolean;
48
+ /** Extra subpaths the child may read. */
49
+ readable?: string[];
50
+ /** Extra subpaths the child may read and write. */
51
+ writable?: string[];
52
+ /** Subpaths taken back out of `readable`: a later deny beats an earlier allow. */
53
+ unreadable?: string[];
54
+ /** Symbolic links on the way to a readable target, granted as the paths they are, not their targets. */
55
+ links?: string[];
56
+ }
57
+
58
+ /** Seatbelt strings are double-quoted; a path with a quote or backslash is refused rather than escaped wrong. */
59
+ function quote(path: string): string {
60
+ if (!path.startsWith("/")) throw new Error(`the wall needs absolute paths, got ${JSON.stringify(path)}`);
61
+ if (/["\\\n\0]/.test(path)) throw new Error(`the wall refuses a path with quotes, backslashes or newlines: ${JSON.stringify(path)}`);
62
+ return `"${path}"`;
63
+ }
64
+
65
+ /** The real path, so a symlinked /tmp or binary matches what the kernel sees. */
66
+ function real(path: string): string {
67
+ try { return realpathSync(path); } catch { return path; }
68
+ }
69
+
70
+ export function seatbeltProfile(o: ProfileOptions): string {
71
+ const binary = real(o.binary);
72
+ const execs = [...new Set([o.binary, binary])];
73
+ const readable = (o.readable ?? []).map(real);
74
+ const writable = (o.writable ?? []).map(real);
75
+ const unreadable = (o.unreadable ?? []).map(real);
76
+ const binaryDir = dirname(binary);
77
+ const lines = [
78
+ "(version 1)",
79
+ "(deny default)",
80
+ `(allow process-exec ${execs.map((p) => `(literal ${quote(p)})`).join(" ")})`,
81
+ `(allow file-read* (subpath ${quote(binaryDir)}))`,
82
+ ...ancestors(binary).map((p) => `(allow file-read-metadata (literal ${quote(p)}))`),
83
+ '(allow file-read* (subpath "/usr/lib") (subpath "/System/Library") (subpath "/private/var/db/dyld"))',
84
+ '(allow file-read* (literal "/dev/null") (literal "/dev/random") (literal "/dev/urandom"))',
85
+ '(allow file-write* (literal "/dev/null"))',
86
+ '(allow file-read* (subpath "/usr/share/icu") (subpath "/usr/share/zoneinfo") (subpath "/private/var/db/timezone") (literal "/private/etc/localtime"))',
87
+ '(allow file-read-metadata (literal "/etc") (literal "/var") (literal "/private") (literal "/private/etc") (literal "/private/var") (literal "/private/var/db") (literal "/usr") (literal "/usr/share"))',
88
+ '(allow file-read* (literal "/"))',
89
+ "(allow sysctl-read)",
90
+ ];
91
+ if (o.loopback) {
92
+ lines.push(
93
+ '(allow network-bind (local ip "localhost:*"))',
94
+ '(allow network-inbound (local ip "localhost:*"))',
95
+ '(allow network-outbound (remote ip "localhost:*"))',
96
+ );
97
+ }
98
+ for (const p of readable) lines.push(`(allow file-read* (subpath ${quote(p)}))`);
99
+ for (const p of o.links ?? []) lines.push(`(allow file-read* (literal ${quote(p)}))`, ...ancestors(p).map((a) => `(allow file-read-metadata (literal ${quote(a)}))`));
100
+ for (const p of writable) lines.push(`(allow file-read* file-write* (subpath ${quote(p)}))`);
101
+ for (const p of unreadable) lines.push(`(deny file-read* (subpath ${quote(p)}))`);
102
+ return lines.join("\n") + "\n";
103
+ }
@@ -0,0 +1,59 @@
1
+ // Starting a child behind the wall. On macOS the child runs under
2
+ // sandbox-exec with a Seatbelt profile, in "/", with exactly the
3
+ // environment given. Elsewhere it refuses, so nothing runs unwalled by accident.
4
+ import { mkdirSync, writeFileSync } from "node:fs";
5
+ import { join, isAbsolute } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { ProcessTransport } from "../transport/transport.ts";
8
+ import type { Message } from "../transport/messages.ts";
9
+
10
+ const SANDBOX = "/usr/bin/sandbox-exec";
11
+
12
+ export interface WallOptions {
13
+ /** The profile text, from seatbeltProfile. */
14
+ profile: string;
15
+ /** The child's whole environment; nothing is inherited from the parent. */
16
+ env: Record<string, string>;
17
+ /** Where the profile file is written: the kernel's state directory. Defaults to the system temp directory. */
18
+ dir?: string;
19
+ /** The child's working directory: "/" unless the profile lets it read somewhere, since Bun opens it at startup. */
20
+ cwd?: string;
21
+ stdout?: "inherit" | "pipe" | "ignore";
22
+ stderr?: "inherit" | "pipe" | "ignore";
23
+ }
24
+
25
+ export interface Walled {
26
+ cmd: string[];
27
+ cwd: string;
28
+ env: Record<string, string>;
29
+ stdout?: "inherit" | "pipe" | "ignore";
30
+ stderr?: "inherit" | "pipe" | "ignore";
31
+ }
32
+
33
+ function assertSupported() {
34
+ if (process.platform !== "darwin") throw new Error(`the wall is not implemented on ${process.platform} yet`);
35
+ }
36
+
37
+ /** Writes the profile to a file named by its content, so equal profiles share one file. */
38
+ function profileFile(profile: string, dir: string): string {
39
+ mkdirSync(dir, { recursive: true });
40
+ const name = `wall-${Bun.hash(profile).toString(16)}.sb`;
41
+ const path = join(dir, name);
42
+ writeFileSync(path, profile, { mode: 0o600 });
43
+ return path;
44
+ }
45
+
46
+ /** The spawn options that run `cmd` behind the wall. `cmd[0]` must be absolute: the wall grants exec of one path. */
47
+ export function walled(cmd: string[], o: WallOptions): Walled {
48
+ assertSupported();
49
+ const program = cmd[0];
50
+ if (!program || !isAbsolute(program)) throw new Error(`the wall runs an absolute program path, got ${JSON.stringify(program)}`);
51
+ const file = profileFile(o.profile, o.dir ?? join(tmpdir(), "babavoss-wall"));
52
+ return { cmd: [SANDBOX, "-f", file, ...cmd], cwd: o.cwd ?? "/", env: { ...o.env }, stdout: o.stdout, stderr: o.stderr };
53
+ }
54
+
55
+ /** Spawns `cmd` behind the wall, with IPC as its transport. */
56
+ export function spawnWalled<Out extends { type: string } = Message, In extends { type: string } = Message>(cmd: string[], o: WallOptions): ProcessTransport<Out, In> {
57
+ const w = walled(cmd, o);
58
+ return new ProcessTransport<Out, In>(w.cmd, { cwd: w.cwd, env: w.env, stdout: w.stdout, stderr: w.stderr });
59
+ }
@@ -0,0 +1,53 @@
1
+ // The state a component reads, provided. The hooks, useQuery, useComponent,
2
+ // useColumn and the rest, read whatever state the component is shown over:
3
+ // live, the page's store, following the rounds; in the Maker, a simulated
4
+ // state; in a test, a fixed frame. An app written with hooks draws the same
5
+ // in all three, and its hand, from useHand, is there only when it may act.
6
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
7
+ import type { Replica } from "../ecs/replica.ts";
8
+ import type { Probe } from "./store.ts";
9
+
10
+ /** What an app may do to the state it draws. */
11
+ export interface Hand { fire(name: string, args?: unknown): void }
12
+
13
+ /** What the hooks need of a state: a replica to read, and a way to hear it change. The page's live store is one; a fixed replica is another. */
14
+ export interface HookStore {
15
+ replica: Replica<any>;
16
+ probe: Probe;
17
+ subscribe(fn: () => void): () => void;
18
+ retain(): () => void;
19
+ committed(): void;
20
+ wantAll(): () => void;
21
+ wantPresence(c: { name: string }): () => void;
22
+ wantValues(c: { name: string }): () => void;
23
+ wantPart(e: number, c: { name: string }): () => void;
24
+ wantResource(r: { name: string }): () => void;
25
+ wantSelect(sel: unknown): () => void;
26
+ }
27
+
28
+ const none = () => () => {};
29
+ /** A state that does not move: a frame, a sketch. Its interests ask nothing of a socket; a new frame is a new store. */
30
+ export function fixedStore(replica: Replica<any>): HookStore {
31
+ return { replica, probe: { samples: [] }, subscribe: none, retain: none, committed: () => {}, wantAll: none, wantPresence: none, wantValues: none, wantPart: none, wantResource: none, wantSelect: none };
32
+ }
33
+
34
+ /** The state the hooks read when a component is shown over a fixed one; null means the live store: a LiveStoreContext's, else the page's own. */
35
+ export const AppStoreContext = createContext<HookStore | null>(null);
36
+ /** No hand was given here: useHand finds the live store's. */
37
+ export const UNSET: unique symbol = Symbol("no hand given");
38
+ /** The hand given to a subtree, or `UNSET` where none was. */
39
+ export const HandContext = createContext<Hand | undefined | typeof UNSET>(UNSET);
40
+
41
+ /**
42
+ * Shows its children over a state: every hook inside reads `w`, and useHand answers `hand`, none when it is not given.
43
+ * Without `w`, the hooks read the state they read outside, and only the hand is given.
44
+ */
45
+ export function AppState({ w, hand, children }: { w?: Replica<any>; hand?: Hand; children: ReactNode }) {
46
+ const store = useMemo(() => (w ? fixedStore(w) : null), [w]);
47
+ const outer = useContext(AppStoreContext);
48
+ return (
49
+ <AppStoreContext.Provider value={store ?? outer}>
50
+ <HandContext.Provider value={hand}>{children}</HandContext.Provider>
51
+ </AppStoreContext.Provider>
52
+ );
53
+ }
@@ -0,0 +1,140 @@
1
+ // The core of babavoss/web: the interface, the scope, the API, the manifest.
2
+ import { createContext, createElement, useContext, useEffect, useState, type ComponentType } from "react";
3
+ import type { Manifest } from "../ecs/baba.ts";
4
+ import type { AppProps } from "../desktop/view.tsx";
5
+ import type { VossApp } from "../desktop/index.ts";
6
+ import { matchRoute } from "../desktop/routes.ts";
7
+
8
+ /**
9
+ * The interface: a component for each app the baba declares, shown by voss's shell, one window at a
10
+ * time. An app's component is given `AppProps` and reads the state through the hooks; the Maker draws
11
+ * the same components over a simulated state.
12
+ */
13
+ export interface AppsDef { apps: Record<string, ComponentType<AppProps>> }
14
+ /** The interface before the shell was voss's: one root component, full page. Read for one release. */
15
+ export interface InterfaceDef { shell: ComponentType }
16
+ /** The old spelling. */
17
+ export type ViewDef = InterfaceDef;
18
+ /** What `interface.tsx` exports: a component per app, or, the old way, a shell. */
19
+ export type Interface = AppsDef | InterfaceDef;
20
+ export const isApps = (d: Interface): d is AppsDef => typeof (d as InterfaceDef).shell !== "function" && typeof (d as AppsDef).apps === "object" && (d as AppsDef).apps !== null;
21
+
22
+ /** A component per route of an app that declares routes: the one whose route takes the window's path draws it. */
23
+ export type RouteTable<P extends string = string> = { [K in P]: ComponentType<AppProps> };
24
+ type RoutesFor<V, K> = V extends { $routes: infer R } ? K extends keyof R ? R[K] : never : never;
25
+ /** What compose takes for an app: a component, or, for an app with routes, a component per route. */
26
+ type EntryFor<P> = [P] extends [never] ? ComponentType<AppProps> : ComponentType<AppProps> | RouteTable<P & string>;
27
+
28
+ const isTable = (v: unknown): v is RouteTable => typeof v === "object" && v !== null && !("$$typeof" in v) && Object.keys(v).every((k) => k.startsWith("/"));
29
+
30
+ /**
31
+ * One component over a route table: it draws the component of the route that took the window's path. The route is the
32
+ * one the shell matched against the app's declared routes; a frame that matched none, an older one, is matched here against the table.
33
+ */
34
+ function routed(key: string, table: RouteTable): ComponentType<AppProps> {
35
+ const routes = Object.keys(table);
36
+ const Routed = (props: AppProps) => {
37
+ const m = props.route ? { route: props.route, params: props.params } : matchRoute(routes, props.path);
38
+ const View = m && table[m.route];
39
+ return View ? createElement(View, { ...props, route: m.route, params: m.params }) : createElement("p", { className: "desktop-empty" }, m ? `${key} has no component for its route ${m.route}.` : `No route of ${key} takes ${props.path}.`);
40
+ };
41
+ Routed.displayName = `Routes(${key})`;
42
+ return Routed;
43
+ }
44
+
45
+ /**
46
+ * `interface.tsx` exports `compose<typeof baba>({ apps: { lab: Lab, … } })`: a component for every app
47
+ * the baba declares in `baba({ apps })`, and none other; the keys are checked against them. An app that
48
+ * declares routes may be given a component per route instead, `{ "/": Prs, "/pr/:n": Pr }`, checked
49
+ * against its routes: every route, none other. One of voss's own apps, `maker`, `state`, `contract`,
50
+ * `promptware`, `settings`, may be given a component of the baba's to replace voss's.
51
+ * The old form, `compose({ shell })`, still loads.
52
+ */
53
+ export function compose<V extends { $app: string }>(def: { apps: { [K in V["$app"]]: EntryFor<RoutesFor<V, K>> } & { [K in VossApp]?: ComponentType<AppProps> } }): AppsDef;
54
+ export function compose(def: InterfaceDef): InterfaceDef;
55
+ export function compose(def: Interface | { apps: Record<string, ComponentType<AppProps> | RouteTable> }): Interface {
56
+ if ("scenes" in def) throw new Error("compose: `scenes` is now `apps`; rename it");
57
+ if (!("apps" in def) || typeof def.apps !== "object" || def.apps === null) return def as Interface;
58
+ return { apps: Object.fromEntries(Object.entries(def.apps).map(([k, v]) => [k, isTable(v) ? routed(k, v) : v])) };
59
+ }
60
+ /** The old spelling, `view({ component })`. */
61
+ export function view(def: { component: ComponentType }): InterfaceDef { return { shell: def.component }; }
62
+
63
+ /** Which baba the page is: the project's name. */
64
+ export const ScopeContext = createContext<string | null>(null);
65
+ export const ScopeProvider = ScopeContext.Provider;
66
+ export function useScope(): string {
67
+ const s = useContext(ScopeContext);
68
+ if (s === null) throw new Error("useScope: not inside a baba's page");
69
+ return s;
70
+ }
71
+
72
+ export class ApiError extends Error {
73
+ constructor(readonly code: string, msg: string, readonly status: number) { super(msg); }
74
+ }
75
+
76
+ // The page token: read once from the page the server served, unless configure() set one.
77
+ let token: string | null | undefined;
78
+
79
+ /** Overrides what the page says, for a page the server did not serve. */
80
+ export function configure(options: { token?: string }): void {
81
+ if (options.token !== undefined) token = options.token;
82
+ }
83
+
84
+ /** The page token the server put in the page: the API's header, the socket's hello. */
85
+ export function pageToken(): string | null {
86
+ if (token === undefined) token = globalThis.document?.querySelector('meta[name="voss-token"]')?.getAttribute("content") ?? null;
87
+ return token;
88
+ }
89
+
90
+ /** The headers every /api/ request carries: the page token, when the page has one. */
91
+ export function apiHeaders(): Record<string, string> {
92
+ const t = pageToken();
93
+ return t ? { authorization: `Bearer ${t}` } : {};
94
+ }
95
+
96
+ /** One API call: `/api/KIND/SCOPE[/TARGET]`, a JSON body, a JSON answer. */
97
+ export async function api(kind: "fire" | "read" | "state" | "raw", scope: string, target: string | null, body: unknown, init: { signal?: AbortSignal } = {}): Promise<any> {
98
+ const path = [kind, scope, target].filter((x) => x !== null).map((x) => encodeURIComponent(x!)).join("/");
99
+ const res = await fetch(`/api/${path}`, {
100
+ method: "POST",
101
+ headers: { "content-type": "application/json", ...apiHeaders() },
102
+ body: JSON.stringify(body ?? {}),
103
+ signal: init.signal,
104
+ });
105
+ const data = await res.json() as { value?: unknown; error?: { code: string; message: string } };
106
+ if (!res.ok || data.error) throw new ApiError(data.error?.code ?? "http", data.error?.message ?? res.statusText, res.status);
107
+ return data.value;
108
+ }
109
+
110
+ /**
111
+ * The baba's manifest, as the server describes it: null until it arrives.
112
+ * Asked once, then asked again "since the generation I have", so the answer
113
+ * comes when the baba reloads: a page that draws the manifest, the Maker's
114
+ * objects or the promptware, follows a change to the code as the state
115
+ * follows a round. A baba that is down is asked again in a moment.
116
+ */
117
+ export function useManifest(): Manifest | null {
118
+ const scope = useScope();
119
+ const [m, setM] = useState<Manifest | null>(null);
120
+ useEffect(() => {
121
+ const ctl = new AbortController();
122
+ (async () => {
123
+ let since = 0;
124
+ while (!ctl.signal.aborted) {
125
+ try {
126
+ const r = await fetch(`/api/describe/${encodeURIComponent(scope)}${since ? `?since=${since}` : ""}`, { signal: ctl.signal, headers: apiHeaders() });
127
+ const d = await r.json() as { gen?: number; manifest?: Manifest | null; error?: { message: string } };
128
+ if (!r.ok || typeof d.gen !== "number") throw new Error(d.error?.message ?? r.statusText);
129
+ since = d.gen;
130
+ if (d.manifest) setM(d.manifest);
131
+ } catch {
132
+ if (ctl.signal.aborted) return;
133
+ await new Promise((done) => setTimeout(done, 2_000));
134
+ }
135
+ }
136
+ })();
137
+ return () => ctl.abort();
138
+ }, [scope]);
139
+ return m;
140
+ }
@@ -0,0 +1,155 @@
1
+ // The generated form: which input each argument of a command gets, how the
2
+ // inputs become the arguments, and how a result is best shown. Pure, so the
3
+ // mapping is tested without a DOM; the runner renders it.
4
+
5
+ type Json = { [k: string]: unknown };
6
+
7
+ export type FieldKind = "string" | "number" | "integer" | "boolean" | "enum" | "list" | "json";
8
+
9
+ export interface Field {
10
+ name: string;
11
+ kind: FieldKind;
12
+ required: boolean;
13
+ /** Takes null as a value: an empty required input sends null. */
14
+ nullable: boolean;
15
+ description?: string;
16
+ /** For an enum. */
17
+ options?: string[];
18
+ /** For a list: the type of each line. */
19
+ item?: "string" | "number" | "integer" | "boolean";
20
+ }
21
+
22
+ export interface Plan {
23
+ /** The arguments are not an object with properties: one JSON field edits them whole. */
24
+ whole: boolean;
25
+ fields: Field[];
26
+ }
27
+
28
+ /** What the inputs hold: text for most, a flag for a boolean. */
29
+ export type Values = Record<string, string | boolean>;
30
+
31
+ export class FieldError extends Error {
32
+ constructor(readonly field: string, message: string) { super(`${field}: ${message}`); }
33
+ }
34
+
35
+ const scalars = ["string", "number", "integer", "boolean"] as const;
36
+ type Scalar = typeof scalars[number];
37
+ const isScalar = (t: unknown): t is Scalar => scalars.includes(t as Scalar);
38
+
39
+ /** The type of a schema without its null: `["string","null"]` is a nullable string. */
40
+ function typeOf(s: Json | undefined): { type: unknown; nullable: boolean } {
41
+ const t = s?.type;
42
+ if (Array.isArray(t)) return { type: t.find((x) => x !== "null"), nullable: t.includes("null") };
43
+ return { type: t, nullable: false };
44
+ }
45
+
46
+ export function fieldOf(name: string, schema: Json | undefined, required: boolean): Field {
47
+ const { type, nullable } = typeOf(schema);
48
+ const description = typeof schema?.description === "string" ? schema.description : undefined;
49
+ const base = { name, required, nullable, ...(description ? { description } : {}) };
50
+ if (Array.isArray(schema?.enum) && schema.enum.every((x) => typeof x === "string")) return { ...base, kind: "enum", options: schema.enum as string[] };
51
+ if (isScalar(type)) return { ...base, kind: type };
52
+ if (type === "array") {
53
+ const it = typeOf(schema?.items as Json | undefined);
54
+ if (isScalar(it.type) && !it.nullable && !(schema?.items as Json | undefined)?.enum) return { ...base, kind: "list", item: it.type };
55
+ }
56
+ return { ...base, kind: "json" };
57
+ }
58
+
59
+ /** The form of a command from its args schema: one field per property, required first, then by name. */
60
+ export function plan(args: Json | undefined): Plan {
61
+ const { type } = typeOf(args);
62
+ const props = args?.properties as Record<string, Json> | undefined;
63
+ if (type !== "object" || !props) {
64
+ // An object with no properties takes nothing; anything else is edited as JSON.
65
+ if (type === "object" && args?.additionalProperties === false) return { whole: false, fields: [] };
66
+ return { whole: true, fields: [{ name: "args", kind: "json", required: false, nullable: false }] };
67
+ }
68
+ const required = Array.isArray(args?.required) ? (args.required as string[]) : [];
69
+ const names = Object.keys(props);
70
+ names.sort((a, b) => Number(required.includes(b)) - Number(required.includes(a)));
71
+ return { whole: false, fields: names.map((n) => fieldOf(n, props[n], required.includes(n))) };
72
+ }
73
+
74
+ function item(f: Field, line: string): unknown {
75
+ if (f.item === "string") return line;
76
+ const t = line.trim();
77
+ if (f.item === "boolean") {
78
+ if (t === "true") return true;
79
+ if (t === "false") return false;
80
+ throw new FieldError(f.name, `each line is true or false, not ${JSON.stringify(t)}`);
81
+ }
82
+ const n = Number(t);
83
+ if (t === "" || !isFinite(n)) throw new FieldError(f.name, `not a number: ${JSON.stringify(t)}`);
84
+ if (f.item === "integer" && !Number.isInteger(n)) throw new FieldError(f.name, `not an integer: ${t}`);
85
+ return n;
86
+ }
87
+
88
+ /** One input's value, or undefined when it sends nothing. Throws a FieldError naming the input. */
89
+ export function read(f: Field, v: string | boolean | undefined): unknown {
90
+ if (f.kind === "boolean") {
91
+ if (v === true) return true;
92
+ return f.required ? false : undefined;
93
+ }
94
+ const text = typeof v === "string" ? v : "";
95
+ const empty = f.kind === "string" ? text === "" : text.trim() === "";
96
+ if (empty) {
97
+ // An empty input sends nothing; a required field that takes null sends null.
98
+ if (!f.required) return undefined;
99
+ if (f.nullable) return null;
100
+ throw new FieldError(f.name, "required");
101
+ }
102
+ switch (f.kind) {
103
+ case "string": return text;
104
+ case "enum":
105
+ if (!f.options?.includes(text)) throw new FieldError(f.name, `one of ${f.options?.join(", ")}`);
106
+ return text;
107
+ case "number":
108
+ case "integer": {
109
+ const n = Number(text.trim());
110
+ if (!isFinite(n)) throw new FieldError(f.name, "not a number");
111
+ if (f.kind === "integer" && !Number.isInteger(n)) throw new FieldError(f.name, "not an integer");
112
+ return n;
113
+ }
114
+ case "list": {
115
+ const lines = text.split("\n");
116
+ while (lines.length && lines.at(-1)!.trim() === "") lines.pop();
117
+ return lines.map((l) => item(f, l));
118
+ }
119
+ case "json":
120
+ try { return JSON.parse(text); } catch (e) { throw new FieldError(f.name, `not JSON: ${e instanceof Error ? e.message : String(e)}`); }
121
+ }
122
+ }
123
+
124
+ /** The arguments the inputs make. Throws a FieldError on the first input that does not read. */
125
+ export function collect(p: Plan, values: Values): unknown {
126
+ if (p.whole) return read(p.fields[0]!, values.args) ?? {};
127
+ const out: Record<string, unknown> = {};
128
+ for (const f of p.fields) {
129
+ const v = read(f, values[f.name]);
130
+ if (v !== undefined) out[f.name] = v;
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /** A short label of a field's type. */
136
+ export function describeType(f: Field): string {
137
+ const t = f.kind === "list" ? `${f.item}[]` : f.kind;
138
+ return f.nullable ? `${t} | null` : t;
139
+ }
140
+
141
+ const flat = (v: unknown) => v === null || ["string", "number", "boolean"].includes(typeof v);
142
+
143
+ /** A result as a table, when it is a non-empty array of objects whose values are all scalars. */
144
+ export function tableOf(value: unknown): { columns: string[]; rows: Record<string, unknown>[] } | null {
145
+ if (!Array.isArray(value) || value.length === 0) return null;
146
+ const columns: string[] = [];
147
+ for (const row of value) {
148
+ if (typeof row !== "object" || row === null || Array.isArray(row)) return null;
149
+ for (const [k, v] of Object.entries(row)) {
150
+ if (!flat(v)) return null;
151
+ if (!columns.includes(k)) columns.push(k);
152
+ }
153
+ }
154
+ return { columns, rows: value as Record<string, unknown>[] };
155
+ }