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,213 @@
1
+ // Objects: OOUX read off the ECS. An object is a component with a key plus
2
+ // whatever rides on the same entity; its attributes are their fields, its
3
+ // relationships the entity fields and the co-location, its calls to action
4
+ // the actions that take it. All of it is derived from the manifest and a
5
+ // state, live or a film's frame: nothing is declared twice.
6
+ import { useMemo, useState, type ReactNode } from "react";
7
+ import type { Manifest, Call } from "../ecs/baba.ts";
8
+ import type { Json } from "../kernel/schema.ts";
9
+ import type { Replica } from "../ecs/replica.ts";
10
+ import type { Hand } from "./app.tsx";
11
+ import { Button, Input } from "./ui.tsx";
12
+ import { collect, plan, type Field, type Plan } from "./form.ts";
13
+
14
+ export interface ObjectRef { field: string; component: string; to: string }
15
+ export interface ObjectAction { name: string; call: Call; arg: string; alone: boolean }
16
+ /** One object of the baba: the keyed component that names it, and what the manifest says about it. */
17
+ export interface ObjectType {
18
+ name: string;
19
+ owner: string;
20
+ /** The key field of the component; null for an object known by its entity alone, such as a window or a run. */
21
+ key: string | null;
22
+ fields: string[];
23
+ /** Entity fields on the key component, and on what rides with it, pointing at other objects. */
24
+ refs: ObjectRef[];
25
+ /** Actions that take this object: by an `s.entity(name)` arg, or by an arg named like the key from the same system. */
26
+ actions: ObjectAction[];
27
+ }
28
+
29
+ const props = (schema: Json): Record<string, Json> => (schema.properties as Record<string, Json> | undefined) ?? {};
30
+ const keyOf = (schema: Json): string | undefined => Object.entries(props(schema)).find(([, p]) => p["x-key"] === true)?.[0];
31
+ const required = (schema: Json): string[] => (schema.required as string[] | undefined) ?? [];
32
+
33
+ /**
34
+ * The objects a manifest describes: every keyed component, with its relationships and calls to action. Given a state, also the
35
+ * objects known by their entity alone: the first component of each entity that carries no keyed one, a window, a run, an opening.
36
+ */
37
+ export function objectsOf(m: Manifest, w?: Replica<any>): ObjectType[] {
38
+ const keyed: { name: string; owner: string; key: string | null; schema: Json }[] = Object.entries(m.components).flatMap(([name, c]) => { const k = keyOf(c.schema); return k === undefined ? [] : [{ name, owner: c.owner, key: k, schema: c.schema }]; });
39
+ const names = new Set(keyed.map((k) => k.name));
40
+ if (w) {
41
+ for (const e of w.all()) {
42
+ const parts = Object.keys(w.of(e));
43
+ if (parts.some((n) => names.has(n))) continue;
44
+ const first = parts.find((n) => n in m.components);
45
+ if (first && !names.has(first)) { names.add(first); keyed.push({ name: first, owner: m.components[first]!.owner, key: null, schema: m.components[first]!.schema }); }
46
+ }
47
+ }
48
+ return keyed.map((k) => {
49
+ const refs: ObjectRef[] = [];
50
+ for (const [cname, c] of Object.entries(m.components)) {
51
+ for (const [field, p] of Object.entries(props(c.schema))) {
52
+ const to = p["x-entity"] as string | undefined;
53
+ if (to && names.has(to) && (cname === k.name || !keyOf(c.schema))) refs.push({ field, component: cname, to });
54
+ }
55
+ }
56
+ const actions: ObjectAction[] = [];
57
+ for (const [name, call] of Object.entries(m.actions)) {
58
+ const ps = props(call.args);
59
+ const byEntity = Object.entries(ps).find(([, p]) => p["x-entity"] === k.name)?.[0];
60
+ // An arg named like the key, from the owning system, names the object too, unless the action already names an entity of some object.
61
+ const namesSome = Object.values(ps).some((p) => typeof p["x-entity"] === "string");
62
+ const byKey = !byEntity && !namesSome && k.key !== null && call.owner === k.owner && k.key in ps ? k.key : undefined;
63
+ const arg = byEntity ?? byKey;
64
+ if (!arg) continue;
65
+ actions.push({ name, call, arg, alone: required(call.args).every((r) => r === arg) });
66
+ }
67
+ return { name: k.name, owner: k.owner, key: k.key, fields: Object.keys(props(k.schema)), refs: refs.filter((r) => r.component === k.name || r.to !== k.name), actions };
68
+ }).sort((a, b) => a.owner.localeCompare(b.owner) || a.name.localeCompare(b.name));
69
+ }
70
+
71
+ /** The entities of a state that carry an object, with everything on them. */
72
+ export function instancesOf(w: Replica<any>, type: ObjectType): { entity: number; parts: Record<string, unknown> }[] {
73
+ return w.all().map((e) => ({ entity: e, parts: w.of(e) })).filter((x) => type.name in x.parts);
74
+ }
75
+ /** The components seen beside an object in this state: what rides with it. */
76
+ export function ridersOf(w: Replica<any>, type: ObjectType): string[] {
77
+ const seen = new Map<string, number>();
78
+ for (const i of instancesOf(w, type)) for (const n of Object.keys(i.parts)) if (n !== type.name) seen.set(n, (seen.get(n) ?? 0) + 1);
79
+ return [...seen.entries()].sort((a, b) => b[1] - a[1]).map(([n]) => n);
80
+ }
81
+ /** Entities carrying no object at all: worth seeing, usually a smell. */
82
+ export function looseOf(w: Replica<any>, types: ObjectType[]): { entity: number; parts: Record<string, unknown> }[] {
83
+ const names = new Set(types.map((t) => t.name));
84
+ return w.all().map((e) => ({ entity: e, parts: w.of(e) })).filter((x) => !Object.keys(x.parts).some((n) => names.has(n)));
85
+ }
86
+
87
+ const short = (v: unknown, n = 60): string => { const s = typeof v === "string" ? v : JSON.stringify(v); return s.length > n ? s.slice(0, n - 1) + "…" : s; };
88
+ const isPlain = (v: unknown) => v === null || ["string", "number", "boolean"].includes(typeof v);
89
+
90
+ /** The object map as a rail: one row per object, its owner and how many there are; the sheet beside the pane says the rest. */
91
+ export function ObjectMap({ types, w, picked, onPick }: { types: ObjectType[]; w?: Replica<any>; picked: string | null; onPick: (name: string) => void }) {
92
+ return (
93
+ <div className="bv-object-map">
94
+ {types.map((t) => (
95
+ <button key={t.name} className={`bv-object-col${picked === t.name ? " is-open" : ""}`} onClick={() => onPick(t.name)}>
96
+ <span className="bv-object-head"><b>{t.name}</b><span className="bv-muted">{t.owner}</span></span>
97
+ {w && <span className="bv-object-count">{instancesOf(w, t).length}</span>}
98
+ </button>
99
+ ))}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ /** What the manifest and this state say about an object, OOUX's four: key, attributes, what rides beside it and what it points to, calls to action. */
105
+ export function ObjectSheet({ type, w }: { type: ObjectType; w: Replica<any> }) {
106
+ const riders = ridersOf(w, type);
107
+ const to = [...new Set(type.refs.map((r) => r.to))];
108
+ return (
109
+ <dl className="bv-object-sheet">
110
+ <dt>key</dt><dd>{type.key === null ? "the entity" : <code>{type.key}</code>}</dd>
111
+ <dt>attributes</dt><dd>{type.fields.filter((f) => f !== type.key).join(", ") || "—"}</dd>
112
+ {riders.length > 0 && <><dt>beside it</dt><dd>{riders.join(", ")}</dd></>}
113
+ {to.length > 0 && <><dt>points to</dt><dd>{to.join(", ")}</dd></>}
114
+ <dt>actions</dt><dd>{type.actions.length ? type.actions.map((a) => a.name).join(", ") : "—"}</dd>
115
+ </dl>
116
+ );
117
+ }
118
+
119
+ /** Every instance of an object in a state, as cards. */
120
+ export function ObjectCards({ w, type, types, hand, onFollow }: { w: Replica<any>; type: ObjectType; types: ObjectType[]; hand?: Hand; onFollow?: (type: string, entity: number) => void }) {
121
+ const all = instancesOf(w, type);
122
+ if (all.length === 0) return <p className="bv-muted">No {type.name} in this state.</p>;
123
+ return <div className="bv-object-cards">{all.map((i) => <ObjectCard key={i.entity} w={w} entity={i.entity} parts={i.parts} type={type} types={types} hand={hand} onFollow={onFollow} />)}</div>;
124
+ }
125
+
126
+ /** One object: its key as the title, its attributes by component, its relationships as links, its calls to action as buttons when there is a hand. */
127
+ export function ObjectCard({ w, entity, parts, type, types, hand, onFollow }: { w: Replica<any>; entity: number; parts: Record<string, unknown>; type: ObjectType; types: ObjectType[]; hand?: Hand; onFollow?: (type: string, entity: number) => void }) {
128
+ const [open, setOpen] = useState(false);
129
+ const [asking, setAsking] = useState<string | null>(null);
130
+ const own = parts[type.name] as Record<string, unknown>;
131
+ const key = type.key === null ? entity : own?.[type.key];
132
+ const byName = useMemo(() => new Map(types.map((t) => [t.name, t])), [types]);
133
+ const riders = Object.keys(parts).filter((n) => n !== type.name);
134
+ const refOf = (component: string, field: string) => type.refs.find((r) => r.component === component && r.field === field) ?? (byName.get(component)?.refs.find((r) => r.field === field));
135
+ const field = (component: string, name: string, value: unknown): ReactNode => {
136
+ const ref = refOf(component, name);
137
+ if (ref && typeof value === "number" && w.alive(value)) {
138
+ const target = byName.get(ref.to); const tp = w.of(value)[ref.to] as Record<string, unknown> | undefined;
139
+ return <button className="bv-object-ref" onClick={() => onFollow?.(ref.to, value)} title={`${ref.to} #${value}`}>→ {ref.to} {tp && target && target.key !== null ? short(tp[target.key], 24) : `#${value}`}</button>;
140
+ }
141
+ if (value === true) return <span className="bv-object-tag">yes</span>;
142
+ if (isPlain(value)) return <span className={typeof value === "string" ? "" : "bv-object-num"}>{value === null ? "—" : String(value) === "" ? "“”" : short(value)}</span>;
143
+ return <code title={JSON.stringify(value)}>{short(value, 40)}</code>;
144
+ };
145
+ const group = (component: string, value: unknown) => {
146
+ if (value === true) return <span className="bv-object-tag">tag</span>;
147
+ if (!value || typeof value !== "object") return field(component, "", value);
148
+ const entries = Object.entries(value as Record<string, unknown>).filter(([k]) => !(component === type.name && k === type.key));
149
+ const shown = open ? entries : entries.slice(0, 6);
150
+ return <dl className="bv-object-fields">{shown.map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{field(component, k, v)}</dd></div>)}{entries.length > shown.length && <div><dt /><dd><button className="bv-object-more" onClick={() => setOpen(true)}>{entries.length - shown.length} more</button></dd></div>}</dl>;
151
+ };
152
+ return (
153
+ <article className="bv-object-card">
154
+ <header className="bv-object-card-head"><b>{type.name}</b>{type.key !== null && <span className="bv-object-key" title={String(key)}>{short(key, 48)}</span>}<span className="bv-muted">#{entity}</span></header>
155
+ {group(type.name, own)}
156
+ {riders.map((r) => <section key={r} className="bv-object-rider"><h4>{r}<span className="bv-muted">{types.find((t) => t.name === r) ? "object" : ""}</span></h4>{group(r, parts[r])}</section>)}
157
+ {type.actions.length > 0 && (
158
+ <footer className="bv-object-actions">
159
+ {type.actions.map((a) => a.alone
160
+ ? <Button key={a.name} variant="quiet" disabled={!hand} title={a.call.summary} onClick={() => hand?.fire(a.name, { [a.arg]: type.key === null ? entity : key })}>{a.name}</Button>
161
+ : <Button key={a.name} variant="quiet" className={asking === a.name ? "is-open" : "bv-object-needs"} disabled={!hand} title={`${a.call.summary} · needs ${required(a.call.args).filter((r) => r !== a.arg).join(", ")}`} onClick={() => setAsking(asking === a.name ? null : a.name)}>{a.name}…</Button>)}
162
+ </footer>
163
+ )}
164
+ {asking && hand && (() => { const a = type.actions.find((x) => x.name === asking)!; return <ArgsForm key={a.name} action={a} objectValue={type.key === null ? entity : key} onFire={(args) => { hand.fire(a.name, args); setAsking(null); }} onClose={() => setAsking(null)} />; })()}
165
+ </article>
166
+ );
167
+ }
168
+
169
+ /** Entities that carry no object, listed bare. */
170
+ export function LooseList({ w, types }: { w: Replica<any>; types: ObjectType[] }) {
171
+ const loose = looseOf(w, types);
172
+ if (loose.length === 0) return null;
173
+ return (
174
+ <section className="bv-object-loose">
175
+ <h4>loose <span className="bv-muted">{loose.length} entit{loose.length === 1 ? "y" : "ies"} carrying no object</span></h4>
176
+ {loose.map((x) => <div key={x.entity} className="bv-state-entity"><code>#{x.entity}</code>{Object.entries(x.parts).map(([n, v]) => <span key={n} className="bv-state-part"><b>{n}</b>{v === true ? "" : <code>{short(v, 60)}</code>}</span>)}</div>)}
177
+ </section>
178
+ );
179
+ }
180
+
181
+ /** The rest of an action's arguments, asked on the card: the object's own arg is filled in; the others come from the action's schema, as the runner would ask them. */
182
+ function ArgsForm({ action, objectValue, onFire, onClose }: { action: ObjectAction; objectValue: unknown; onFire: (args: unknown) => void; onClose: () => void }) {
183
+ const p: Plan = useMemo(() => plan(action.call.args), [action]);
184
+ const fields = p.fields.filter((f) => f.name !== action.arg);
185
+ const [values, setValues] = useState<Record<string, string | boolean>>({});
186
+ const [error, setError] = useState<string | null>(null);
187
+ const set = (f: Field, v: string | boolean) => setValues((x) => ({ ...x, [f.name]: v }));
188
+ const submit = () => {
189
+ try {
190
+ const rest = collect({ ...p, fields }, values) as Record<string, unknown>;
191
+ onFire({ ...rest, [action.arg]: objectValue });
192
+ } catch (e) { setError(e instanceof Error ? e.message : String(e)); }
193
+ };
194
+ return (
195
+ <form className="bv-object-form" onSubmit={(e) => { e.preventDefault(); submit(); }}>
196
+ <div className="bv-object-form-head"><b>{action.name}</b><span className="bv-muted">{action.call.summary}</span></div>
197
+ {fields.map((f) => (
198
+ <label key={f.name} className="bv-object-field">
199
+ <span>{f.name}{f.required ? "" : <em className="bv-muted"> optional</em>}</span>
200
+ {f.kind === "boolean"
201
+ ? <input type="checkbox" checked={values[f.name] === true} onChange={(e) => set(f, e.target.checked)} />
202
+ : f.kind === "enum"
203
+ ? <select value={String(values[f.name] ?? "")} onChange={(e) => set(f, e.target.value)}><option value="">—</option>{(f.options ?? []).map((o) => <option key={o} value={o}>{o}</option>)}</select>
204
+ : f.kind === "json" || f.kind === "list"
205
+ ? <textarea rows={2} value={String(values[f.name] ?? "")} placeholder={f.kind === "list" ? "one per line" : "JSON"} onChange={(e) => set(f, e.target.value)} />
206
+ : <Input value={String(values[f.name] ?? "")} placeholder={f.description ?? f.kind} onChange={(e) => set(f, e.target.value)} />}
207
+ </label>
208
+ ))}
209
+ {error && <p className="bv-error">{error}</p>}
210
+ <div className="bv-row"><Button variant="primary" type="submit">{action.name}</Button><Button variant="quiet" onClick={onClose}>cancel</Button></div>
211
+ </form>
212
+ );
213
+ }
@@ -0,0 +1,84 @@
1
+ // A window's socket to its baba: hello with the page token, the round held
2
+ // and what it follows, then frames down and calls up, in order, on one
3
+ // connection. Closed, it reconnects with a little patience and hellos again
4
+ // from where it stands; refused for good, it stops and says so.
5
+ import { ApiError, pageToken } from "./core.tsx";
6
+ import { CLOSE, SOCKET_PROTOCOL, type Down, type Interest, type Up, type Welcome } from "../server/protocol.ts";
7
+ export type { Welcome };
8
+
9
+ export interface Hears {
10
+ welcome(w: Welcome): void;
11
+ frame(bytes: Uint8Array): void;
12
+ /** The connection came or went; gone for good, with why. */
13
+ connected(up: boolean, refused?: string): void;
14
+ }
15
+
16
+ /** An answer to a call: what it returned, and the round a fire or a raw write ran in. */
17
+ export interface Answer { value: unknown; round?: number }
18
+
19
+ /** Close codes after which another try would end the same way. */
20
+ const FINAL = new Set<number>([CLOSE.unauthorized, CLOSE.badProtocol, CLOSE.unknown]);
21
+
22
+ export class Link {
23
+ private ws: WebSocket | null = null;
24
+ private pending = new Map<number, { resolve: (a: Answer) => void; reject: (e: Error) => void }>();
25
+ private nextId = 1;
26
+ private patience = 250;
27
+ private gone = false;
28
+
29
+ constructor(private scope: string, private held: () => number, private interest: () => Interest, private hears: Hears) { this.connect(); }
30
+
31
+ private connect(): void {
32
+ if (this.gone) return;
33
+ const url = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws/${encodeURIComponent(this.scope)}`;
34
+ const ws = new WebSocket(url);
35
+ ws.binaryType = "arraybuffer";
36
+ ws.onopen = () => { this.patience = 250; this.hello(); this.hears.connected(true); };
37
+ ws.onmessage = (e) => {
38
+ if (typeof e.data === "string") this.down(JSON.parse(e.data) as Down);
39
+ else this.hears.frame(new Uint8Array(e.data as ArrayBuffer));
40
+ };
41
+ ws.onclose = (e) => {
42
+ if (this.ws !== ws) return;
43
+ this.ws = null;
44
+ for (const p of this.pending.values()) p.reject(new ApiError("closed", "the connection closed", 0));
45
+ this.pending.clear();
46
+ if (FINAL.has(e.code)) { this.gone = true; this.hears.connected(false, e.reason || `closed with ${e.code}`); return; }
47
+ this.hears.connected(false);
48
+ if (!this.gone) setTimeout(() => this.connect(), this.patience);
49
+ this.patience = Math.min(this.patience * 2, 4000);
50
+ };
51
+ ws.onerror = () => { /* the close says it */ };
52
+ this.ws = ws;
53
+ }
54
+
55
+ private down(m: Down): void {
56
+ if (m.type === "welcome") { this.hears.welcome(m); return; }
57
+ if (m.type === "generation") { this.hello(); return; }
58
+ const p = this.pending.get(m.id);
59
+ if (!p) return;
60
+ this.pending.delete(m.id);
61
+ if (m.ok) p.resolve({ value: m.value, ...(m.round !== undefined ? { round: m.round } : {}) });
62
+ else p.reject(new ApiError(m.code, m.message, 0));
63
+ }
64
+
65
+ get up(): boolean { return this.ws?.readyState === WebSocket.OPEN; }
66
+
67
+ /** Says hello from the round held and with what the window follows: a welcome and the state since come back. */
68
+ hello(): void { this.send({ type: "hello", protocol: SOCKET_PROTOCOL, token: pageToken(), since: this.held(), interest: this.interest() }); }
69
+ /** What the window follows now, whole: the edge cuts every frame to it from here on. */
70
+ subscribe(interest: Interest): void { this.send({ type: "subscribe", interest }); }
71
+ /** Asks for the whole again: after a frame that did not apply. */
72
+ resync(): void { this.send({ type: "resync" }); }
73
+
74
+ private send(m: Up): void { if (this.up) this.ws!.send(JSON.stringify(m)); }
75
+
76
+ /** A fire, a read or a raw write over the socket; refused when it is down, so the caller may try the API. */
77
+ call(m: Extract<Up, { id: number }> extends infer U ? (U extends { id: number } ? Omit<U, "id"> : never) : never): Promise<Answer> {
78
+ if (!this.up) return Promise.reject(new ApiError("down", "the socket is not connected", 0));
79
+ const id = this.nextId++;
80
+ return new Promise<Answer>((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.ws!.send(JSON.stringify({ ...m, id })); });
81
+ }
82
+
83
+ close(): void { this.gone = true; this.ws?.close(); this.ws = null; }
84
+ }
@@ -0,0 +1,69 @@
1
+ // The baba in a window: its calls and its state of connection, and, with
2
+ // useBabaState, the whole state as a replica that every round replaces. The
3
+ // targeted hooks beside it (useQuery, useComponent, …) read parts of the
4
+ // state and wake only when those move; useBabaState wakes every round and asks
5
+ // the socket for everything.
6
+ import { useEffect, useMemo, useSyncExternalStore } from "react";
7
+ import { useLiveStore } from "./hooks.ts";
8
+ import type { Replica } from "../ecs/replica.ts";
9
+ import type { Raw } from "../ecs/runtime.ts";
10
+ import type { ActOf as ContractActs, ReadOf as ContractReads } from "../ecs/baba.ts";
11
+ export type { FollowSample, Probe } from "./store.ts";
12
+
13
+ type Calls = Record<string, { args: unknown; result: unknown }>;
14
+ type ActOf<V> = [ContractActs<V>] extends [never] ? Calls : unknown extends ContractActs<V> ? Calls : ContractActs<V>;
15
+ type ReadOf<V> = [ContractReads<V>] extends [never] ? Calls : unknown extends ContractReads<V> ? Calls : ContractReads<V>;
16
+ type ArgsOf<D> = D extends { args: infer A } ? ({} extends A ? [args?: A] : [args: A]) : [];
17
+ type ResultOf<D> = D extends { result: infer R } ? R : unknown;
18
+
19
+ /** The baba from a window: what to call, and whether the window is connected. */
20
+ export interface BabaView<V> {
21
+ /** Fires an action: what it returned. */
22
+ fire<K extends keyof ActOf<V> & string>(name: K, ...args: ArgsOf<ActOf<V>[K]>): Promise<ResultOf<ActOf<V>[K]>>;
23
+ /** Fires an action: what it returned, and the round it ran in, which the window reaches when its effect is in view (`useSettled`). */
24
+ fireIn<K extends keyof ActOf<V> & string>(name: K, ...args: ArgsOf<ActOf<V>[K]>): Promise<{ value: ResultOf<ActOf<V>[K]>; round: number | undefined }>;
25
+ /** Reads a query. */
26
+ read<K extends keyof ReadOf<V> & string>(name: K, ...args: ArgsOf<ReadOf<V>[K]>): Promise<ResultOf<ReadOf<V>[K]>>;
27
+ /** A write at layer zero: the inspector's, and a shell's. */
28
+ raw(op: Raw): Promise<unknown>;
29
+ /** A first frame has landed. */
30
+ ready: boolean;
31
+ /** The socket is up: frames come as rounds end. Down, calls go over the API and the state waits. */
32
+ connected: boolean;
33
+ /** Why the socket will not come back, when it will not. */
34
+ refused: string | null;
35
+ }
36
+
37
+ export interface BabaStateView<V> extends BabaView<V> {
38
+ /** The state at the latest round, read-only; every round replaces it. */
39
+ w: Replica<V>;
40
+ round: number;
41
+ }
42
+
43
+ /** The baba's calls and standing, without the state: for a view built on the targeted hooks. Wakes only when the connection changes. */
44
+ export function useBaba<V = unknown>(_v?: V): BabaView<V> {
45
+ const st = useLiveStore();
46
+ const ready = useSyncExternalStore(st.subscribe, () => st.ready);
47
+ const connected = useSyncExternalStore(st.subscribe, () => st.connected);
48
+ const refused = useSyncExternalStore(st.subscribe, () => st.refused);
49
+ return useMemo(() => ({
50
+ ready, connected, refused,
51
+ fire: (name: string, args?: unknown) => st.fire(name, args),
52
+ fireIn: (name: string, args?: unknown) => st.fireIn(name, args),
53
+ read: (name: string, args?: unknown) => st.read(name, args),
54
+ raw: (op: Raw) => st.raw(op),
55
+ }) as BabaView<V>, [st, ready, connected, refused]);
56
+ }
57
+
58
+ /**
59
+ * `import baba from "./index"` in the view, then `const { w, fire } = useBabaState(baba)`:
60
+ * `w.query(tasks.task)` reads, `fire("refresh")` writes. Without `baba`, untyped.
61
+ * The whole state, every round: for a page that reads anything anywhere.
62
+ */
63
+ export function useBabaState<V = unknown>(v?: V): BabaStateView<V> {
64
+ const st = useLiveStore();
65
+ const baba = useBaba(v);
66
+ useEffect(() => st.wantAll(), [st]);
67
+ const w = useSyncExternalStore(st.subscribe, () => st.replica) as Replica<V>;
68
+ return useMemo(() => ({ ...baba, w, round: w.round }), [baba, w]);
69
+ }
@@ -0,0 +1,221 @@
1
+ // The state as one window holds it: a replica moved by the frames on the
2
+ // window's socket, the hooks' subscriptions over it, what the hooks are
3
+ // interested in, the calls, and the probe. One per baba per page, shared
4
+ // by every hook and every useBabaState on it; opened when the first holder
5
+ // mounts, closed a moment after the last one leaves.
6
+ import { Replica } from "../ecs/replica.ts";
7
+ import { isDelta, type StateAnswer } from "../ecs/snapshot.ts";
8
+ import { codecOf, decodeFrame, type Codec } from "../ecs/codec.ts";
9
+ import type { Component, Entity, Resource, Select, Term } from "../ecs/state.ts";
10
+ import type { Raw } from "../ecs/runtime.ts";
11
+ import type { Interest } from "../server/protocol.ts";
12
+ import { api } from "./core.tsx";
13
+ import { Link, type Answer } from "./socket.ts";
14
+
15
+ /** One round as the window received it. Times in ms. */
16
+ export interface FollowSample {
17
+ round: number;
18
+ /** When it arrived: performance.now(). */
19
+ at: number;
20
+ /** The frame's size on the wire. */
21
+ bytes: number;
22
+ /** A whole, not a delta: first contact, a resync, or too far behind. */
23
+ whole: boolean;
24
+ /** The entities the frame carried. */
25
+ entities: number;
26
+ /** From the round's time to the arrival, on the machine's one clock: how far behind the state the window was. NaN when the frame carries no time. */
27
+ lagMs: number;
28
+ /** Decoding the frame and applying it to the replica. */
29
+ applyMs: number;
30
+ /** From the replica set to the page committed: the render of everything that reads the state. NaN until committed. */
31
+ renderMs: number;
32
+ }
33
+ /** What following has cost this window: the last rounds, oldest first. A new list every round; the last sample's render time is filled in after its commit. */
34
+ export interface Probe { samples: FollowSample[] }
35
+ const PROBE_KEEP = 300;
36
+ /** How long a store outlives its last holder: a strict-mode remount, a page's panels swapping. */
37
+ const LINGER_MS = 300;
38
+
39
+ /** A count per name. */
40
+ class Counts {
41
+ private n = new Map<string, number>();
42
+ add(k: string): void { this.n.set(k, (this.n.get(k) ?? 0) + 1); }
43
+ drop(k: string): void { const c = (this.n.get(k) ?? 0) - 1; if (c <= 0) this.n.delete(k); else this.n.set(k, c); }
44
+ keys(): string[] { return [...this.n.keys()]; }
45
+ }
46
+
47
+ export class StateStore {
48
+ replica = new Replica();
49
+ ready = false;
50
+ connected = false;
51
+ /** Why the socket will not come back, when it will not: a wrong token, another protocol, no such baba. */
52
+ refused: string | null = null;
53
+ probe: Probe = { samples: [] };
54
+ private readonly samples: FollowSample[] = [];
55
+ private pending: FollowSample | null = null;
56
+ private listeners = new Set<() => void>();
57
+ private link: Link | null = null;
58
+ private codec: Codec | null = null;
59
+ private holders = 0;
60
+ private linger: ReturnType<typeof setTimeout> | null = null;
61
+ // The interest, counted per hook; told as one when it changed, and what was let go of forgotten then.
62
+ private all = 0;
63
+ private presence = new Counts();
64
+ private values = new Counts();
65
+ private parts = new Map<string, Counts>();
66
+ private resources = new Counts();
67
+ private told: Interest = {};
68
+ private telling: ReturnType<typeof setTimeout> | null = null;
69
+
70
+ constructor(readonly scope: string) {}
71
+
72
+ // ---- holders --------------------------------------------------------------
73
+
74
+ /** A hook mounted: the socket opens for the first; answers what to call when it unmounts. */
75
+ retain(): () => void {
76
+ this.holders++;
77
+ if (this.linger) { clearTimeout(this.linger); this.linger = null; }
78
+ if (!this.link) this.open();
79
+ let released = false;
80
+ return () => {
81
+ if (released) return;
82
+ released = true;
83
+ this.holders--;
84
+ if (this.holders > 0) return;
85
+ this.linger = setTimeout(() => { this.linger = null; if (this.holders === 0) this.close(); }, LINGER_MS);
86
+ };
87
+ }
88
+
89
+ private open(): void {
90
+ this.replica = new Replica(); this.ready = false; this.codec = null; this.refused = null;
91
+ this.link = new Link(this.scope, () => this.replica.round, () => { this.told = this.interest(); return this.told; }, {
92
+ welcome: (m) => { this.codec = codecOf(m.components, m.resources); },
93
+ connected: (up, refused) => { this.connected = up; if (refused) this.refused = refused; this.notify(); },
94
+ frame: (bytes) => this.frame(bytes),
95
+ });
96
+ }
97
+
98
+ private close(): void {
99
+ this.link?.close();
100
+ this.link = null;
101
+ this.replica = new Replica(); this.ready = false; this.connected = false; this.told = {};
102
+ this.notify();
103
+ }
104
+
105
+ private frame(bytes: Uint8Array): void {
106
+ const l = this.link;
107
+ if (!this.codec || !l) return;
108
+ const at = performance.now();
109
+ const held = this.replica;
110
+ let s: StateAnswer;
111
+ try { s = decodeFrame(this.codec, bytes); } catch { l.resync(); return; }
112
+ if (isDelta(s) && s.since !== held.round) { if (s.round > held.round) l.resync(); return; }
113
+ const next = held.apply(s);
114
+ const sample: FollowSample = {
115
+ round: s.round, at, bytes: bytes.byteLength, whole: !isDelta(s), entities: Object.keys(s.entities).length,
116
+ lagMs: s.now === undefined ? NaN : Date.now() - s.now, applyMs: performance.now() - at, renderMs: NaN,
117
+ };
118
+ this.samples.push(sample);
119
+ if (this.samples.length > PROBE_KEEP) this.samples.splice(0, this.samples.length - PROBE_KEEP);
120
+ this.probe = { samples: this.samples.slice() };
121
+ this.pending = sample;
122
+ this.replica = next; this.ready = true;
123
+ this.notify();
124
+ }
125
+
126
+ /** The page committed with the replica now held: the last frame's render is over. */
127
+ committed(): void {
128
+ const s = this.pending;
129
+ if (s) { s.renderMs = performance.now() - s.at - s.applyMs; this.pending = null; }
130
+ }
131
+
132
+ // ---- subscriptions ----------------------------------------------------------
133
+
134
+ subscribe = (fn: () => void): (() => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
135
+ private notify(): void { for (const fn of this.listeners) fn(); }
136
+
137
+ // ---- interest ---------------------------------------------------------------
138
+
139
+ /** What the window's hooks ask for, as one. */
140
+ interest(): Interest {
141
+ if (this.all > 0) return { all: true };
142
+ const parts: Record<string, number[]> = {};
143
+ for (const [name, c] of this.parts) { const ids = c.keys().map(Number); if (ids.length) parts[name] = ids; }
144
+ return { presence: this.presence.keys(), values: this.values.keys(), parts, resources: this.resources.keys() };
145
+ }
146
+
147
+ /** The interest changed: after this turn, so a commit's hooks speak together, tell the socket and let go of what nobody asks for any more. */
148
+ private tell(): void {
149
+ if (this.telling) return;
150
+ this.telling = setTimeout(() => { this.telling = null; this.tellNow(); }, 0);
151
+ }
152
+ private tellNow(): void {
153
+ const next = this.interest();
154
+ if (JSON.stringify(next) === JSON.stringify(this.told)) return;
155
+ this.forget(this.told, next);
156
+ this.told = next;
157
+ this.link?.subscribe(next);
158
+ }
159
+
160
+ /** Every component the interest `was` held that `now` holds less of: the values nobody asks for become held presences, or go. */
161
+ private forget(was: Interest, now: Interest): void {
162
+ if (now.all) return;
163
+ const names = was.all ? this.replica.components() : new Set([...(was.presence ?? []), ...(was.values ?? []), ...Object.keys(was.parts ?? {})]);
164
+ const presence = new Set(now.presence ?? []), values = new Set(now.values ?? []);
165
+ for (const name of names) {
166
+ if (values.has(name)) continue;
167
+ this.replica.forget(name, { presence: presence.has(name), values: new Set(now.parts?.[name] ?? []) });
168
+ }
169
+ }
170
+
171
+ wantAll(): () => void { this.all++; this.tell(); return () => { this.all--; this.tell(); }; }
172
+ wantPresence(c: Component): () => void { this.presence.add(c.name); this.tell(); return () => { this.presence.drop(c.name); this.tell(); }; }
173
+ wantValues(c: Component): () => void { this.values.add(c.name); this.tell(); return () => { this.values.drop(c.name); this.tell(); }; }
174
+ wantPart(e: Entity, c: Component): () => void {
175
+ let p = this.parts.get(c.name);
176
+ if (!p) { p = new Counts(); this.parts.set(c.name, p); }
177
+ p.add(String(e)); this.tell();
178
+ return () => { p!.drop(String(e)); this.tell(); };
179
+ }
180
+ wantResource(r: Resource): () => void { this.resources.add(r.name); this.tell(); return () => { this.resources.drop(r.name); this.tell(); }; }
181
+
182
+ /** What a select needs: the presence of every component it names, and their values when it has a predicate, an order or a change term. */
183
+ wantSelect(sel: Select<readonly Term[]>): () => void {
184
+ const comps: Component[] = [];
185
+ let sensitive = sel.pred !== null || sel.order !== null;
186
+ for (const t of sel.terms) {
187
+ if (t.kind === "component") comps.push(t);
188
+ else if (t.kind === "not") comps.push(...t.components);
189
+ else { comps.push(t.component); sensitive = true; }
190
+ }
191
+ const drops = comps.map((c) => (sensitive ? this.wantValues(c) : this.wantPresence(c)));
192
+ return () => { for (const d of drops) d(); };
193
+ }
194
+
195
+ // ---- calls ------------------------------------------------------------------
196
+
197
+ /** Over the socket when it is up, over the API when it is not. */
198
+ async fireIn(name: string, args?: unknown): Promise<Answer> {
199
+ const l = this.link;
200
+ if (l?.up) return l.call({ type: "fire", name, args: args ?? {} });
201
+ return { value: await api("fire", this.scope, name, args ?? {}) };
202
+ }
203
+ fire(name: string, args?: unknown): Promise<unknown> { return this.fireIn(name, args).then((a) => a.value); }
204
+ read(name: string, args?: unknown): Promise<unknown> {
205
+ const l = this.link;
206
+ return l?.up ? l.call({ type: "read", name, args: args ?? {} }).then((a) => a.value) : api("read", this.scope, name, args ?? {});
207
+ }
208
+ raw(op: Raw): Promise<unknown> {
209
+ const { op: kind, ...rest } = op;
210
+ const l = this.link;
211
+ return l?.up ? l.call({ type: "raw", op: kind, ...rest }).then((a) => a.value) : api("raw", this.scope, kind, rest);
212
+ }
213
+ }
214
+
215
+ const stores = new Map<string, StateStore>();
216
+ /** The one store of a baba on this page. */
217
+ export function storeFor(scope: string): StateStore {
218
+ let s = stores.get(scope);
219
+ if (!s) { s = new StateStore(scope); stores.set(scope, s); }
220
+ return s;
221
+ }