@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc

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 (230) hide show
  1. package/LICENSE-EXCEPTION +26 -0
  2. package/README.md +34 -0
  3. package/dist/chunk-3R6KEFOI.js +22 -0
  4. package/dist/chunk-67BDAMYX.js +376 -0
  5. package/dist/chunk-AITGS27R.js +139 -0
  6. package/dist/chunk-CMAEHQ4F.js +6 -0
  7. package/dist/chunk-MLKGABMK.js +9 -0
  8. package/dist/chunk-O3UIK34B.js +1430 -0
  9. package/dist/chunk-VHTUSZUX.js +176 -0
  10. package/dist/cli.js +9006 -0
  11. package/dist/docker.js +54 -0
  12. package/dist/index.js +1053 -0
  13. package/dist/ingress.js +436 -0
  14. package/dist/jsx-runtime.js +2 -0
  15. package/dist/preact.js +22 -0
  16. package/dist/service-url.js +11 -0
  17. package/dist/services.js +213 -0
  18. package/dist/test.js +5069 -0
  19. package/dist/ui.js +254 -0
  20. package/dist/worker-protocol.js +89 -0
  21. package/package.json +112 -0
  22. package/src/app-icon.ts +74 -0
  23. package/src/apply.ts +52 -0
  24. package/src/bundle-ui.ts +211 -0
  25. package/src/bundle.ts +546 -0
  26. package/src/cli/build-dev-landing.ts +84 -0
  27. package/src/cli/build-install-panel.ts +90 -0
  28. package/src/cli/build-runtime.ts +133 -0
  29. package/src/cli/build.ts +63 -0
  30. package/src/cli/dev.ts +107 -0
  31. package/src/cli/down.ts +60 -0
  32. package/src/cli/main.ts +56 -0
  33. package/src/cli/test.ts +61 -0
  34. package/src/cli/validate.ts +37 -0
  35. package/src/compile.ts +100 -0
  36. package/src/create-app.ts +982 -0
  37. package/src/deep-merge.ts +33 -0
  38. package/src/dev/fixture.ts +48 -0
  39. package/src/dev/install-handler.ts +383 -0
  40. package/src/dev/server.ts +968 -0
  41. package/src/dev/stack.ts +108 -0
  42. package/src/dev/state.ts +159 -0
  43. package/src/docker.ts +96 -0
  44. package/src/errors.ts +61 -0
  45. package/src/factory.ts +481 -0
  46. package/src/garage.ts +56 -0
  47. package/src/generator/hash.ts +6 -0
  48. package/src/generator/resolve-refs.ts +207 -0
  49. package/src/grants-builder.ts +100 -0
  50. package/src/grants.ts +260 -0
  51. package/src/home-api.ts +172 -0
  52. package/src/home-cookie.ts +10 -0
  53. package/src/home-registry.ts +120 -0
  54. package/src/home.ts +187 -0
  55. package/src/host-registry.ts +143 -0
  56. package/src/http-header.ts +20 -0
  57. package/src/identifiers.ts +49 -0
  58. package/src/index.ts +66 -0
  59. package/src/info.ts +64 -0
  60. package/src/ini.ts +50 -0
  61. package/src/json.ts +14 -0
  62. package/src/jsx-runtime/index.ts +5 -0
  63. package/src/keys.ts +29 -0
  64. package/src/mysql.ts +56 -0
  65. package/src/pack-build.ts +108 -0
  66. package/src/pack.ts +115 -0
  67. package/src/peer-registry.ts +121 -0
  68. package/src/peer-world.ts +117 -0
  69. package/src/permission-meta.ts +116 -0
  70. package/src/permissions.ts +108 -0
  71. package/src/pkg-root.ts +34 -0
  72. package/src/postgres.ts +79 -0
  73. package/src/preact/index.ts +27 -0
  74. package/src/redis.ts +43 -0
  75. package/src/ref.ts +271 -0
  76. package/src/remote-registry.ts +33 -0
  77. package/src/required-host-kinds.ts +110 -0
  78. package/src/resolve-context.ts +111 -0
  79. package/src/resolve.ts +471 -0
  80. package/src/runtime/docker/compose/compose-schema.ts +118 -0
  81. package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
  82. package/src/runtime/docker/compose/default-hardening.ts +21 -0
  83. package/src/runtime/docker/compose/effects.ts +107 -0
  84. package/src/runtime/docker/compose/index.ts +17 -0
  85. package/src/runtime/docker/compose/labels.ts +39 -0
  86. package/src/runtime/docker/compose/types.ts +78 -0
  87. package/src/runtime/docker/home-proxy-server/index.ts +177 -0
  88. package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
  89. package/src/runtime/docker/ingress/build-config.ts +819 -0
  90. package/src/runtime/docker/ingress/index.ts +16 -0
  91. package/src/runtime/docker/ingress/types.ts +182 -0
  92. package/src/runtime/docker/log-frames.ts +121 -0
  93. package/src/runtime/docker/logs-server/index.ts +366 -0
  94. package/src/runtime/docker/spawn-server/context-key.ts +82 -0
  95. package/src/runtime/docker/spawn-server/index.ts +492 -0
  96. package/src/runtime/docker/spawn-server/protocol.ts +70 -0
  97. package/src/runtime/docker/spawn-server/spec.ts +122 -0
  98. package/src/runtime/docker/stats-server/disks.ts +161 -0
  99. package/src/runtime/docker/stats-server/index.ts +322 -0
  100. package/src/runtime/home-client/index.ts +71 -0
  101. package/src/runtime/log-line.ts +24 -0
  102. package/src/runtime/logs-client/index.ts +175 -0
  103. package/src/runtime/spawn-client/index.ts +93 -0
  104. package/src/runtime/spawn-client/kaniko.ts +79 -0
  105. package/src/runtime/stats-client/index.ts +65 -0
  106. package/src/runtime/stats-sample.ts +95 -0
  107. package/src/safe-json.ts +37 -0
  108. package/src/sandbox.ts +1209 -0
  109. package/src/schema/build-apps.ts +38 -0
  110. package/src/schema/home.ts +217 -0
  111. package/src/schema/host.ts +792 -0
  112. package/src/schema/inputs.ts +26 -0
  113. package/src/schema/nodes.ts +75 -0
  114. package/src/schema/peer.ts +400 -0
  115. package/src/schema/public-domain.ts +116 -0
  116. package/src/schema/remote.ts +74 -0
  117. package/src/security.ts +594 -0
  118. package/src/serialize.ts +21 -0
  119. package/src/service-definition.ts +284 -0
  120. package/src/service-url.ts +64 -0
  121. package/src/service.ts +39 -0
  122. package/src/services/index.ts +5 -0
  123. package/src/shared.ts +65 -0
  124. package/src/speculative-silo.ts +96 -0
  125. package/src/stable-secret.ts +14 -0
  126. package/src/surfaces.ts +264 -0
  127. package/src/tailwind-bin.ts +56 -0
  128. package/src/test/boot.ts +192 -0
  129. package/src/test/compile.ts +501 -0
  130. package/src/test/describe.ts +117 -0
  131. package/src/test/index.ts +11 -0
  132. package/src/test/ingress-fixture.ts +37 -0
  133. package/src/test/types.ts +318 -0
  134. package/src/trusted-headers.ts +79 -0
  135. package/src/types.ts +126 -0
  136. package/src/ui/components/aside.tsx +96 -0
  137. package/src/ui/components/badge.tsx +42 -0
  138. package/src/ui/components/button.tsx +59 -0
  139. package/src/ui/components/card.tsx +106 -0
  140. package/src/ui/components/checkbox.tsx +37 -0
  141. package/src/ui/components/code-block.tsx +29 -0
  142. package/src/ui/components/combobox.tsx +119 -0
  143. package/src/ui/components/config-field.tsx +284 -0
  144. package/src/ui/components/dialog.tsx +137 -0
  145. package/src/ui/components/domain-editor.tsx +317 -0
  146. package/src/ui/components/domain-list.ts +205 -0
  147. package/src/ui/components/dropdown-menu.tsx +73 -0
  148. package/src/ui/components/empty-state.tsx +51 -0
  149. package/src/ui/components/highlight.ts +59 -0
  150. package/src/ui/components/icons.tsx +101 -0
  151. package/src/ui/components/image-posture.tsx +85 -0
  152. package/src/ui/components/input.tsx +28 -0
  153. package/src/ui/components/label.tsx +16 -0
  154. package/src/ui/components/line-chart.tsx +113 -0
  155. package/src/ui/components/page.tsx +39 -0
  156. package/src/ui/components/permission-row.tsx +262 -0
  157. package/src/ui/components/permission-section.tsx +51 -0
  158. package/src/ui/components/pie-chart.tsx +245 -0
  159. package/src/ui/components/schema-block.tsx +60 -0
  160. package/src/ui/components/separator.tsx +42 -0
  161. package/src/ui/components/sheet.tsx +130 -0
  162. package/src/ui/components/sidebar.tsx +650 -0
  163. package/src/ui/components/silo-picker.tsx +510 -0
  164. package/src/ui/components/skeleton.tsx +13 -0
  165. package/src/ui/components/sonner.tsx +33 -0
  166. package/src/ui/components/switch.tsx +58 -0
  167. package/src/ui/components/table-view.ts +219 -0
  168. package/src/ui/components/table.tsx +506 -0
  169. package/src/ui/components/tabs.tsx +59 -0
  170. package/src/ui/components/timeago.tsx +84 -0
  171. package/src/ui/components/tooltip.tsx +56 -0
  172. package/src/ui/components.json +21 -0
  173. package/src/ui/dev/dev-shell.tsx +145 -0
  174. package/src/ui/home/home-grid.tsx +177 -0
  175. package/src/ui/hooks/use-mobile.ts +19 -0
  176. package/src/ui/index.ts +189 -0
  177. package/src/ui/install/adapter.ts +193 -0
  178. package/src/ui/install/deploy-targets-picker.tsx +185 -0
  179. package/src/ui/install/install-app-panel.tsx +912 -0
  180. package/src/ui/install/install-error.tsx +62 -0
  181. package/src/ui/install/perms-inactive.ts +50 -0
  182. package/src/ui/install/required-fields.ts +37 -0
  183. package/src/ui/install/tabs-form.tsx +557 -0
  184. package/src/ui/tokens.css +279 -0
  185. package/src/ui/utils.ts +78 -0
  186. package/src/ui-error.ts +139 -0
  187. package/src/ui-runtime/dev-landing-entry.tsx +258 -0
  188. package/src/ui-runtime/index.ts +83 -0
  189. package/src/ui-runtime/install-entry.tsx +119 -0
  190. package/src/ui-runtime/mount.ts +367 -0
  191. package/src/ui-runtime/runtime.css +56 -0
  192. package/src/ui-surface.ts +451 -0
  193. package/src/valkey.ts +43 -0
  194. package/src/worker/extract-tar.ts +125 -0
  195. package/src/worker/identity.ts +183 -0
  196. package/src/worker/index.ts +16 -0
  197. package/src/worker/public-ip.ts +92 -0
  198. package/src/worker/queue.ts +54 -0
  199. package/src/worker/reconcile.ts +127 -0
  200. package/src/worker/start-worker.ts +227 -0
  201. package/src/worker/ui-proxy.ts +335 -0
  202. package/src/worker/uplink.ts +523 -0
  203. package/src/worker/validate.ts +34 -0
  204. package/src/worker/verify-relay.ts +102 -0
  205. package/src/worker-protocol/app-dirs.ts +94 -0
  206. package/src/worker-protocol/home-proxy-paths.ts +76 -0
  207. package/src/worker-protocol/index.ts +55 -0
  208. package/src/worker-protocol/ingress.ts +166 -0
  209. package/src/worker-protocol/logs-socket-path.ts +50 -0
  210. package/src/worker-protocol/networks.ts +39 -0
  211. package/src/worker-protocol/node-version.ts +70 -0
  212. package/src/worker-protocol/silo-paths.ts +114 -0
  213. package/src/worker-protocol/spawn-paths.ts +165 -0
  214. package/src/worker-protocol/stats-socket-path.ts +40 -0
  215. package/src/worker-protocol/update-files.ts +77 -0
  216. package/src/worker-protocol/uplink/constants.ts +86 -0
  217. package/src/worker-protocol/uplink/errors.ts +61 -0
  218. package/src/worker-protocol/uplink/frame.ts +114 -0
  219. package/src/worker-protocol/uplink/identity.ts +40 -0
  220. package/src/worker-protocol/uplink/index.ts +63 -0
  221. package/src/worker-protocol/uplink/methods.ts +462 -0
  222. package/src/worker-protocol/uplink/mux.ts +646 -0
  223. package/src/worker-protocol/uplink/protocol.ts +11 -0
  224. package/src/worker-protocol/uplink/rpc.ts +309 -0
  225. package/src/worker-protocol/uplink/session.ts +540 -0
  226. package/src/worker-protocol/uplink/test-support.ts +188 -0
  227. package/src/worker-protocol/worker-mount.ts +27 -0
  228. package/src/worker-protocol/worker-state.ts +190 -0
  229. package/src/xml.ts +49 -0
  230. package/src/yaml.ts +15 -0
package/src/sandbox.ts ADDED
@@ -0,0 +1,1209 @@
1
+ // QuickJS evaluator for bundled realm.ts modules.
2
+ //
3
+ // Walker dispatches on string kinds ("node.tab", "input.text",
4
+ // "host.port", etc.). Inputs carry their typed metadata in `meta`
5
+ // (`meta.title`, `meta.placeholder`, etc.); enum keeps `values` on the
6
+ // record so the sandbox can surface them to the UI.
7
+ //
8
+ // validateFetchData reads `node.responseSchema` directly: each
9
+ // `r.remote.fetchUrl(...).schema(...)` stores the live Zod schema as a
10
+ // non-enumerable property on the record, reachable from inside QuickJS
11
+ // where the schema's parse runs.
12
+
13
+ // Both eval paths (getDesiredState + the gate) run on the *asyncified* QuickJS
14
+ // variant — the sync one can't suspend for the gate's async `stableSecret`, and
15
+ // pulling in both variants makes a production build collide their identically
16
+ // named `emscripten-module.wasm` assets (only one survives, breaking whichever
17
+ // loader resolves the wrong build). Importing the single async variant directly
18
+ // keeps the sync variant out of the bundle entirely. getDesiredState never
19
+ // registers a host fn, so its sync `evalCode` runs without ever suspending — no
20
+ // asyncify cost on that path.
21
+ import { newQuickJSAsyncWASMModuleFromVariant } from "quickjs-emscripten-core";
22
+ import { z } from "zod";
23
+ import { RealmCompileError } from "./errors.ts";
24
+ import type { HomeApp, RequestManifest } from "./resolve.ts";
25
+ import type { Grant } from "./grants.ts";
26
+ import { hardenedJsonParse } from "./safe-json.ts";
27
+ import { sha256Hex } from "./generator/hash.ts";
28
+
29
+ // Interrupt handler that bounds an eval by the work *this VM* performs, not by
30
+ // wall-clock or process CPU. QuickJS calls it on loop back-edges and function
31
+ // entry, only while this VM executes bytecode, so counting its own invocations
32
+ // measures this eval's own progress and nothing else:
33
+ // - immune to scheduler starvation (a busy box stretches wall time but not
34
+ // tick count); and
35
+ // - immune to sibling evals/threads — unlike `process.cpuUsage()`, which is
36
+ // process-global and sums every concurrent eval's CPU into each one's
37
+ // budget, tripping unrelated evals under `deno test --parallel`.
38
+ // A runaway burns ticks without bound and trips; a bounded eval does a fixed
39
+ // amount of work and stays under the ceiling on any machine. Host suspends (the
40
+ // gate's `await stableSecret`) execute no bytecode, so they accrue no ticks.
41
+ function workBudget(maxTicks: number) {
42
+ let ticks = 0;
43
+ return () => ++ticks > maxTicks;
44
+ }
45
+
46
+ // Per-VM work ceilings (interrupt-handler ticks). The handler fires coarsely:
47
+ // a heavy *legit* eval measures ~6 ticks and the cold gate bundle-load ~4,
48
+ // while a runaway loop accrues ~13k ticks/sec. So these ceilings sit thousands
49
+ // of × above any real eval yet still kill a runaway in ~1-2s. EVAL bounds one
50
+ // eval; COLD is the gate's one-time bundle+shim load, kept higher purely for
51
+ // headroom.
52
+ const EVAL_TICK_BUDGET = 20_000;
53
+ const COLD_EVAL_TICK_BUDGET = 100_000;
54
+
55
+ /**
56
+ * Run a one-shot QuickJS eval against a bundled realm.ts module and return
57
+ * whatever JSON the snippet computes. Warms a throwaway sync VM (no host fns →
58
+ * plain `evalCode`), runs the snippet against the resident `__app`, and disposes
59
+ * — the cold path, one VM per call, no pooling. The bundle must assign its
60
+ * exports to `__realm_module__`; the snippet stringifies its result.
61
+ */
62
+ async function runInSandbox(bundledCode: string, snippet: string): Promise<unknown> {
63
+ const vm = await createVmInstance({ bundle: bundledCode });
64
+ try {
65
+ return await vm.call(snippet);
66
+ } finally {
67
+ await vm.dispose();
68
+ }
69
+ }
70
+
71
+ export interface ConfigFieldLeaf {
72
+ type: "text" | "secret" | "number" | "boolean" | "enum";
73
+ label?: string;
74
+ placeholder?: string | number;
75
+ default?: unknown;
76
+ description?: string;
77
+ optional?: boolean;
78
+ /** For enum fields: the allowed values. */
79
+ values?: string[];
80
+ /** Numeric-input bounds (`r.input.number().min/max`), rendered as
81
+ * `<input>` min/max. `step` is `1` when the author called `.int()`. */
82
+ min?: number;
83
+ max?: number;
84
+ step?: number;
85
+ }
86
+
87
+ export interface ConfigContainerNode {
88
+ type: "tab" | "section";
89
+ optional?: boolean;
90
+ /** Author's `.title(...)`, if set. The renderer falls back to the key. */
91
+ title?: string;
92
+ /** Author's `.describe(...)`, if set. */
93
+ description?: string;
94
+ fields: Record<string, ConfigField>;
95
+ }
96
+
97
+ /**
98
+ * Placeholder node marking where a permission leaf sits in the authoring
99
+ * tree. Preserves declaration order so the UI can render config + perms
100
+ * interleaved per the source, not grouped by kind. The renderer looks up
101
+ * the actual metadata in `permissionsFlat[path]`.
102
+ */
103
+ export interface ConfigPermissionRef {
104
+ type: "permission";
105
+ path: string;
106
+ }
107
+
108
+ export type ConfigField = ConfigFieldLeaf | ConfigContainerNode | ConfigPermissionRef;
109
+
110
+ export type ConfigFieldTree = Record<string, ConfigField>;
111
+
112
+ /**
113
+ * Permission record as it crosses the QuickJS boundary: kind tag,
114
+ * grant data, and meta. Anything non-enumerable (fetchUrl's live Zod
115
+ * schema, chain methods) drops out via JSON.stringify.
116
+ */
117
+ export interface SerializablePermission {
118
+ kind: Grant["kind"];
119
+ data: unknown;
120
+ meta: Record<string, unknown>;
121
+ }
122
+
123
+ /**
124
+ * Identifier of one ui surface declared on the app's `ui:` block.
125
+ * Iframe bundling iterates these to know what entries to generate.
126
+ */
127
+ export interface SurfaceDescriptor {
128
+ kind: "page" | "widget";
129
+ key: string;
130
+ }
131
+
132
+ /**
133
+ * Probe `__app.name` from a bundled realm.ts. Reads the shared
134
+ * `extractAppMeta` probe, so asking for the name costs nothing once any
135
+ * other extract has run against this bundle. The dev server needs it
136
+ * before compile runs to pick the docker-compose project name.
137
+ */
138
+ export async function extractAppName(bundledCode: string): Promise<string> {
139
+ const { name } = await extractAppMeta(bundledCode);
140
+ if (typeof name !== "string" || name.length === 0) {
141
+ throw new RealmCompileError({
142
+ kind: "validation",
143
+ path: ["name"],
144
+ message: "createApp({ name }) is required",
145
+ });
146
+ }
147
+ return name;
148
+ }
149
+
150
+ /**
151
+ * Probe `__app.kind`. Set by the `createApp` / `createSystemApp` factory,
152
+ * so always present as `"single"` or `"system"`.
153
+ */
154
+ export async function extractAppKind(bundledCode: string): Promise<"single" | "system"> {
155
+ const { kind } = await extractAppMeta(bundledCode);
156
+ if (kind === "single" || kind === "system") return kind;
157
+ throw new RealmCompileError({
158
+ kind: "validation",
159
+ path: ["kind"],
160
+ message: `expected "single" or "system", got ${JSON.stringify(kind)}`,
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Probe `__app.ui.pages` and `__app.ui.widgets` and emit the surface
166
+ * keys without invoking any render fn or evaluating the app's config.
167
+ * Runs entirely inside QuickJS so render closures (which can't cross
168
+ * the boundary) stay on the inside.
169
+ */
170
+ export async function extractUiSurfaceKeys(bundledCode: string): Promise<SurfaceDescriptor[]> {
171
+ const snippet = `
172
+ (function() {
173
+ var ui = __app.ui;
174
+ var out = [];
175
+ if (!ui || typeof ui !== "object") {
176
+ return JSON.stringify(out);
177
+ }
178
+ function collect(record, kind) {
179
+ if (!record || typeof record !== "object") return;
180
+ var keys = Object.keys(record);
181
+ for (var i = 0; i < keys.length; i++) {
182
+ out.push({ kind: kind, key: keys[i] });
183
+ }
184
+ }
185
+ collect(ui.pages, "page");
186
+ collect(ui.widgets, "widget");
187
+ return JSON.stringify(out);
188
+ })();
189
+ `;
190
+ const result = (await runInSandbox(bundledCode, snippet)) as SurfaceDescriptor[];
191
+ return result;
192
+ }
193
+
194
+ interface ExtractedSchema {
195
+ /**
196
+ * Tree mirroring the configSchema in declared order. Tabs and
197
+ * sections are container nodes; config leaves carry their metadata
198
+ * inline; permission leaves appear as `{type: "permission", path}`
199
+ * markers whose metadata lives in `permissionsFlat`.
200
+ */
201
+ configFields: ConfigFieldTree;
202
+ /**
203
+ * Dotted-path → permission record (kind + data + meta). Functions and
204
+ * non-enumerable properties (e.g. `remote.fetchUrl`'s live Zod schema)
205
+ * drop naturally on the way through.
206
+ */
207
+ permissionsFlat: Record<string, SerializablePermission>;
208
+ /**
209
+ * Dotted paths of every `r.input.secret` field. Used by
210
+ * `replaceSecrets` to swap plaintext for structured markers before the
211
+ * sandbox runs.
212
+ */
213
+ secretPaths: string[];
214
+ }
215
+
216
+ /**
217
+ * Walk `__app.configSchema` and emit configFields + permissionsFlat +
218
+ * secretPaths in a single pass. Handles the two authoring modes:
219
+ * - All top-level entries are `r.tab(...)` → tabbed UI, nested paths.
220
+ * - No top-level entries are tabs → flat leaves, top-level paths.
221
+ *
222
+ * Runs entirely inside QuickJS so live functions (chain methods) and
223
+ * non-enumerable references (the Zod schema on fetchUrl) are filtered
224
+ * before crossing the host boundary.
225
+ */
226
+ export async function extractSchema(bundledCode: string): Promise<ExtractedSchema> {
227
+ const { configFields, permissionsFlat, secretPaths } = await extractAppMeta(bundledCode);
228
+ assertOneSilo(permissionsFlat);
229
+ return { configFields, permissionsFlat, secretPaths };
230
+ }
231
+
232
+ /**
233
+ * One `host.silo()` per app. Opening this up later is additive; closing it
234
+ * later would break whatever had started relying on two.
235
+ */
236
+ function assertOneSilo(permissionsFlat: Record<string, SerializablePermission>): void {
237
+ const paths = Object.entries(permissionsFlat)
238
+ .filter(([, p]) => p.kind === "host.silo")
239
+ .map(([path]) => path);
240
+ if (paths.length < 2) return;
241
+ throw new RealmCompileError({
242
+ kind: "validation",
243
+ path: [paths[1]!],
244
+ message: `an app may join one Silo, but ${paths.length} are declared (${paths.join(", ")})`,
245
+ });
246
+ }
247
+
248
+ /**
249
+ * Everything a single VM boot can read off a bundled realm.ts: the two
250
+ * scalar probes (`name`, `kind`) plus the full configSchema walk.
251
+ */
252
+ interface AppMeta extends ExtractedSchema {
253
+ /** Raw `__app.name`; `extractAppName` validates it. */
254
+ name: unknown;
255
+ /** Raw `__app.kind`; `extractAppKind` validates it. */
256
+ kind: unknown;
257
+ }
258
+
259
+ // Memoized because a miss costs a whole QuickJS runtime plus a re-parse of the
260
+ // ~600KB bundle (~150ms), and the callers ask repeatedly for the same bundle:
261
+ // one `POST /api/apps/preview` probes name + kind + schema in analyzeSource and
262
+ // schema AGAIN in graph.ts's prepare(), and one recomputeAndDiff() probes per
263
+ // app for the capability gate and then per app PER WORKER inside evalApps —
264
+ // ~90 boots of blocking WASM at ~18 apps × 2 workers.
265
+ //
266
+ // Keyed by a digest of the bundle, not the bundle itself. `bundle()` reads from
267
+ // an on-disk cache and so hands back a fresh ~610KB string per call, which as a
268
+ // key would both rehash 610KB per lookup and pin every bundle ever probed in the
269
+ // heap. Hashing costs ~0.35ms against the ~150ms it saves. A rejected probe is
270
+ // evicted so a transient eval failure isn't pinned.
271
+ //
272
+ // Uncapped, with nothing sweeping it. A cap below the app count would evict
273
+ // every entry before it is reused — a recompute probes every installed app —
274
+ // taking the hit rate to zero. Entries are a digest plus a config-schema tree,
275
+ // a few KB, so a thousand apps is single-digit MB; it grows on churn with
276
+ // nothing to reclaim it, against a process that restarts on upgrade.
277
+ const metaCache = new Map<string, Promise<AppMeta>>();
278
+
279
+ async function extractAppMeta(bundledCode: string): Promise<AppMeta> {
280
+ const key = await sha256Hex(bundledCode);
281
+ const hit = metaCache.get(key);
282
+ if (hit) return await hit;
283
+ const p = extractAppMetaUncached(bundledCode);
284
+ metaCache.set(key, p);
285
+ p.catch(() => metaCache.delete(key));
286
+ return await p;
287
+ }
288
+
289
+ async function extractAppMetaUncached(bundledCode: string): Promise<AppMeta> {
290
+ const snippet = `
291
+ (function() {
292
+ var tabs = __app.configSchema;
293
+ var configFields = {};
294
+ var permissionsFlat = {};
295
+ var secretPaths = [];
296
+
297
+ function walk(node, path, name, parentFields) {
298
+ if (!node || typeof node !== "object") return;
299
+ var kind = node.kind;
300
+ if (typeof kind !== "string") return;
301
+ var meta = node.meta || {};
302
+
303
+ if (kind === "node.tab" || kind === "node.section") {
304
+ var specific = kind === "node.tab" ? "tab" : "section";
305
+ var container = { type: specific, fields: {} };
306
+ if (meta.isOptional) container.optional = true;
307
+ if (meta.title) container.title = meta.title;
308
+ if (meta.description) container.description = meta.description;
309
+ var children = node.children;
310
+ if (children && typeof children === "object") {
311
+ var keys = Object.keys(children);
312
+ for (var i = 0; i < keys.length; i++) {
313
+ var key = keys[i];
314
+ var childPath = path ? path + "." + key : key;
315
+ walk(children[key], childPath, key, container.fields);
316
+ }
317
+ }
318
+ if (parentFields) parentFields[name] = container;
319
+ return;
320
+ }
321
+
322
+ if (kind.indexOf("input.") === 0) {
323
+ var specific = kind.slice("input.".length);
324
+ var entry = { type: specific };
325
+ if (meta.title) entry.label = meta.title;
326
+ if (meta.placeholder !== undefined) entry.placeholder = meta.placeholder;
327
+ if (meta.prefill !== undefined) entry["default"] = meta.prefill;
328
+ if (meta.description) entry.description = meta.description;
329
+ if (meta.isOptional) entry.optional = true;
330
+ if (specific === "enum" && node.values) entry.values = node.values.slice();
331
+ if (specific === "number") {
332
+ if (meta.int) entry.step = 1;
333
+ if (meta.min !== undefined) entry.min = meta.min;
334
+ if (meta.max !== undefined) entry.max = meta.max;
335
+ }
336
+ if (specific === "secret") secretPaths.push(path);
337
+ if (parentFields) parentFields[name] = entry;
338
+ return;
339
+ }
340
+
341
+ // Permission leaves (host.* / peers.* / remote.*). Strip
342
+ // function fields (chain methods, etc.) and non-enumerable
343
+ // refs (fetchUrl's live Zod schema). Object.keys() already
344
+ // skips non-enumerable, and the function filter handles the
345
+ // chain methods.
346
+ var serial = {};
347
+ var nodeKeys = Object.keys(node);
348
+ for (var j = 0; j < nodeKeys.length; j++) {
349
+ var k = nodeKeys[j];
350
+ var v = node[k];
351
+ if (typeof v === "function") continue;
352
+ serial[k] = v;
353
+ }
354
+ permissionsFlat[path] = serial;
355
+ if (parentFields) parentFields[name] = { type: "permission", path: path };
356
+ }
357
+
358
+ // A non-object configSchema yields empty trees, but name/kind are
359
+ // still read below — the two probes must not depend on the app
360
+ // having a schema at all.
361
+ if (tabs && typeof tabs === "object") {
362
+ var topKeys = Object.keys(tabs);
363
+ for (var i = 0; i < topKeys.length; i++) {
364
+ var k = topKeys[i];
365
+ walk(tabs[k], k, k, configFields);
366
+ }
367
+ }
368
+
369
+ return JSON.stringify({
370
+ name: __app.name,
371
+ kind: __app.kind,
372
+ configFields: configFields,
373
+ permissionsFlat: permissionsFlat,
374
+ secretPaths: secretPaths,
375
+ });
376
+ })();
377
+ `;
378
+ return (await runInSandbox(bundledCode, snippet)) as AppMeta;
379
+ }
380
+
381
+ /**
382
+ * Validate raw JSON data against the live Zod schema declared on a
383
+ * `remote.fetchUrl` permission. Runs the schema's parse inside QuickJS
384
+ * so untrusted validation code can't escape.
385
+ *
386
+ * The schema doesn't cross the QuickJS boundary: `r.remote.fetchUrl`
387
+ * stores the live Zod object as a non-enumerable property on the record,
388
+ * which JSON.stringify drops on the way out but stays reachable when
389
+ * we re-enter the sandbox here.
390
+ */
391
+ export async function validateFetchData(
392
+ bundledCode: string,
393
+ permissionPath: string,
394
+ rawJson: string,
395
+ ): Promise<unknown> {
396
+ const snippet = [
397
+ `var __path = ${JSON.stringify(permissionPath)};`,
398
+ `var __tabs = __app.configSchema || {};`,
399
+ `// Navigate the typed tree by dotted path. createApp rejects keys`,
400
+ `// containing "." so the split is unambiguous.`,
401
+ `var __segments = __path.split(".");`,
402
+ `var __node = __tabs[__segments[0]];`,
403
+ `for (var __i = 1; __i < __segments.length; __i++) {`,
404
+ ` if (!__node) break;`,
405
+ ` if (__node.children) __node = __node.children[__segments[__i]];`,
406
+ ` else __node = __node[__segments[__i]];`,
407
+ `}`,
408
+ `if (!__node || !__node.responseSchema || typeof __node.responseSchema.parse !== "function")`,
409
+ ` throw new Error("No Zod schema on permission " + __path);`,
410
+ // String embedded into the QuickJS-side script: this JSON.parse runs
411
+ // inside the sandbox boundary, not the host, and feeds the app's own
412
+ // responseSchema.parse below.
413
+ `var __rawData = JSON.parse(${JSON.stringify(rawJson)});`,
414
+ `JSON.stringify(__node.responseSchema.parse(__rawData));`,
415
+ ].join("\n");
416
+ return runInSandbox(bundledCode, snippet);
417
+ }
418
+
419
+ /** Per-install preview context threaded to `getDesiredState`; null = persistent. */
420
+ interface PreviewArg {
421
+ previewKey: string;
422
+ }
423
+
424
+ /** Identity of one memoized eval; `null` in its place skips the cache. */
425
+ interface ExecuteCacheKey {
426
+ /** Digest of the app's source. See `executeCache` for why it's the source. */
427
+ bundleId: string;
428
+ /** Slug of the worker this eval resolved against. */
429
+ workerId: string;
430
+ }
431
+
432
+ /**
433
+ * Execute `app.evaluate({ config, appId, preview })` against a bundled
434
+ * realm.ts module. `data` is the unified config tree (config fields + resolved
435
+ * permission values, already merged by the caller). Per-worker context lands
436
+ * inside `data` under any granted `host.management()` path, not as a separate
437
+ * arg. `preview` is `null` for a normal install and `{ previewKey }` when the
438
+ * app is stamped as a template's preview child.
439
+ *
440
+ * `cache` keys the memo (see `executeCache`); `null` skips it, for a throwaway
441
+ * appId whose entry could never be read again.
442
+ */
443
+ export async function execute(
444
+ bundledCode: string,
445
+ data: Record<string, unknown>,
446
+ appId: string,
447
+ cache: ExecuteCacheKey | null,
448
+ preview?: PreviewArg | null,
449
+ ): Promise<HomeApp> {
450
+ const slot = cache === null ? "" : `${appId}\0${preview?.previewKey ?? ""}\0${cache.workerId}`;
451
+ const inputKey = JSON.stringify(data);
452
+ if (cache !== null) {
453
+ const hit = executeCache.get(slot);
454
+ if (hit && hit.inputKey === inputKey && hit.bundleId === cache.bundleId) {
455
+ return structuredClone(await hit.result);
456
+ }
457
+ }
458
+
459
+ const snippet = [
460
+ `globalThis.__realm_deferred_fns = [];`,
461
+ `var __config = ${JSON.stringify(data)};`,
462
+ `var __appId = ${JSON.stringify(appId)};`,
463
+ `var __preview = ${JSON.stringify(preview ?? null)};`,
464
+ "JSON.stringify(__app.evaluate({ config: __config, appId: __appId, preview: __preview }));",
465
+ ].join("\n");
466
+ const result = runInSandbox(bundledCode, snippet) as Promise<HomeApp>;
467
+
468
+ // Uncached callers own the only reference, so they skip the defensive clone.
469
+ if (cache === null) return await result;
470
+
471
+ executeCache.set(slot, { bundleId: cache.bundleId, inputKey, result });
472
+ result.catch(() => executeCache.delete(slot));
473
+ return structuredClone(await result);
474
+ }
475
+
476
+ /**
477
+ * Drop every cached eval whose app no longer exists, returning how many went.
478
+ *
479
+ * This bounds `executeCache` in place of a cap. Size tracks apps × workers by
480
+ * construction, so a cap could only ever fire on garbage, and a FIFO one would
481
+ * evict by age rather than liveness — exactly the wrong order. Home just read
482
+ * the app table, so it knows the real answer.
483
+ *
484
+ * Called from `recomputeAndDiff`, which fires on every event that can orphan a
485
+ * slot. A removed worker's entries wait for their apps to go; workers churn far
486
+ * more rarely than apps.
487
+ */
488
+ export function sweepExecuteCache(liveAppIds: ReadonlySet<string>): number {
489
+ let dropped = 0;
490
+ for (const slot of executeCache.keys()) {
491
+ // A preview child's own row id is the appId half, so previews are swept by
492
+ // their own liveness, not the parent's.
493
+ const appId = slot.slice(0, slot.indexOf("\0"));
494
+ if (!liveAppIds.has(appId)) {
495
+ executeCache.delete(slot);
496
+ dropped++;
497
+ }
498
+ }
499
+ return dropped;
500
+ }
501
+
502
+ // Memoized because `recomputeAndDiff` re-evaluates EVERY app on any change, and
503
+ // an install / config edit / grant change each fire one — building up to N apps
504
+ // costs O(N²) evals at ~100ms of blocking WASM apiece.
505
+ //
506
+ // SLOT: one per (app, preview child, worker), NOT one entry per input state. A
507
+ // recompute scans every app once per worker, so one slot per thing scanned hits
508
+ // on every step of that scan. Input-keying would make the scan touch N distinct
509
+ // keys and evict each before reuse — the FIFO worst case, 0% hits past the cap.
510
+ // The worker is in the slot for the same reason: an app with a granted
511
+ // `host.management()` resolves a different `workerId` per worker, so one shared
512
+ // slot has w1 and w2 overwriting each other every pass, killing the cache for
513
+ // exactly the system apps that run everywhere.
514
+ //
515
+ // INVALIDATION: `inputKey` + `bundleId`. `data` is the app's fully-resolved
516
+ // input, so a world that genuinely moved re-evals while an unrelated app's
517
+ // install hits — no need to classify which permission kinds are worker-
518
+ // dependent. `bundleId` is a digest of the SOURCE, so an updated app misses
519
+ // without the entry retaining the ~600KB bundle to compare against.
520
+ //
521
+ // Eval output is assumed deterministic: `stateHashCache` in apps-recompute.ts
522
+ // hashes it to decide whether a worker changed, so an app returning different
523
+ // output for identical inputs would reconcile-loop with or without this cache.
524
+ //
525
+ // Callers get a structuredClone, never the cached instance — HomeApp flows on
526
+ // through resolveRefs, build-wire and the gate registry, and sharing it would
527
+ // turn any in-place edit into a cross-app bug. Well under a millisecond against
528
+ // a ~100ms VM boot.
529
+ //
530
+ // Unbounded by design; `sweepExecuteCache` is what bounds it.
531
+ const executeCache = new Map<
532
+ string,
533
+ { bundleId: string; inputKey: string; result: Promise<HomeApp> }
534
+ >();
535
+
536
+ /**
537
+ * Second-shot for deferred markers. Re-runs the bundle in a fresh
538
+ * QuickJS VM and invokes each registered apply() callback with its
539
+ * resolved input. The caller has already merged the same data tree both
540
+ * passes see, so apply() callbacks observe the same `config.management`
541
+ * etc. across both runs.
542
+ */
543
+ export async function executeDeferred(
544
+ bundledCode: string,
545
+ data: Record<string, unknown>,
546
+ appId: string,
547
+ items: Array<{ id: number; input: unknown }>,
548
+ preview?: PreviewArg | null,
549
+ ): Promise<string[]> {
550
+ const snippet = [
551
+ `globalThis.__realm_deferred_fns = [];`,
552
+ `var __config = ${JSON.stringify(data)};`,
553
+ `var __appId = ${JSON.stringify(appId)};`,
554
+ `var __preview = ${JSON.stringify(preview ?? null)};`,
555
+ `__app.evaluate({ config: __config, appId: __appId, preview: __preview });`,
556
+ `var __items = ${JSON.stringify(items)};`,
557
+ `var __fns = globalThis.__realm_deferred_fns || [];`,
558
+ `var __out = [];`,
559
+ `for (var __i = 0; __i < __items.length; __i++) {`,
560
+ ` var __it = __items[__i];`,
561
+ ` var __fn = __fns[__it.id];`,
562
+ ` if (typeof __fn !== "function") {`,
563
+ ` throw new Error("apply() fn #" + __it.id + " not registered");`,
564
+ ` }`,
565
+ ` var __res = __fn(__it.input);`,
566
+ ` if (typeof __res !== "string") {`,
567
+ ` throw new Error("apply() fn #" + __it.id + " must return a string, got " + typeof __res);`,
568
+ ` }`,
569
+ ` __out.push(__res);`,
570
+ `}`,
571
+ `JSON.stringify(__out);`,
572
+ ].join("\n");
573
+ return (await runInSandbox(bundledCode, snippet)) as string[];
574
+ }
575
+
576
+ /**
577
+ * Run `app.info({ services })` against a bundled realm.ts module and return
578
+ * its raw dict — values are strings, `null`, or `{__realmRef}` markers the
579
+ * caller resolves post-eval with `resolveRefs`. `services` is plain data; no
580
+ * host fn enters the VM. `undefined`-valued keys drop in-VM. Returns `null`
581
+ * when the app declares no `info`.
582
+ */
583
+ export async function executeInfo(
584
+ bundledCode: string,
585
+ services: Record<string, string | null>,
586
+ ): Promise<Record<string, unknown> | null> {
587
+ const snippet = [
588
+ `var __services = ${JSON.stringify(services)};`,
589
+ `(function () {`,
590
+ ` if (typeof __app.info !== "function") return JSON.stringify(null);`,
591
+ ` var out = __app.info({ services: __services });`,
592
+ ` if (!out || typeof out !== "object") return JSON.stringify(null);`,
593
+ ` var cleaned = {};`,
594
+ ` var keys = Object.keys(out);`,
595
+ ` for (var i = 0; i < keys.length; i++) {`,
596
+ ` var k = keys[i];`,
597
+ ` var v = out[k];`,
598
+ ` if (v === undefined) continue;`,
599
+ ` cleaned[k] = v;`,
600
+ ` }`,
601
+ ` return JSON.stringify(cleaned);`,
602
+ `})();`,
603
+ ].join("\n");
604
+ return (await runInSandbox(bundledCode, snippet)) as Record<string, unknown> | null;
605
+ }
606
+
607
+ // ---- anonymous gate (verifyAnonymousRequest) -------------------------------
608
+ //
609
+ // Unlike getDesiredState / getTrustedHeaders, the gate is live logic over a
610
+ // real request, so it executes per call rather than being sampled to data. It
611
+ // runs in the *asyncified* QuickJS variant: secret derivation is async
612
+ // (crypto.subtle), and `newAsyncifiedFunction` lets the guest call
613
+ // `stableSecret(name)` synchronously while the host awaits underneath.
614
+ // REALM_SECRET never enters the VM — Home injects only a `deriveSecret(name)`
615
+ // capability already bound to this app's id.
616
+
617
+ /** The original request as the gate sees it: method, URL, headers — no body. */
618
+ export interface GateRequest {
619
+ method: string;
620
+ url: string;
621
+ /** Header name → value. Names match case-insensitively inside the VM. */
622
+ headers: Record<string, string>;
623
+ }
624
+
625
+ export const gateResultSchema = z.discriminatedUnion("kind", [
626
+ z.object({ kind: z.literal("allow") }),
627
+ z.object({ kind: z.literal("deny") }),
628
+ z.object({
629
+ kind: z.literal("respond"),
630
+ status: z.number(),
631
+ headers: z.record(z.string(), z.string()),
632
+ body: z.string().nullable(),
633
+ }),
634
+ ]);
635
+ export type GateResult = z.infer<typeof gateResultSchema>;
636
+
637
+ // Bare QuickJS has no Request/Response/Headers/URL. Define minimal versions on
638
+ // the VM global before the gate runs so the documented Web-standard surface
639
+ // (`request.headers.get`, `new URL(request.url)`, `new Response(...)`) works.
640
+ const GATE_SHIM = `
641
+ function __Headers(init) {
642
+ this._m = {};
643
+ if (init) for (var k in init) {
644
+ if (Object.prototype.hasOwnProperty.call(init, k)) this._m[String(k).toLowerCase()] = init[k];
645
+ }
646
+ }
647
+ __Headers.prototype.get = function (name) {
648
+ var v = this._m[String(name).toLowerCase()];
649
+ return v === undefined ? null : v;
650
+ };
651
+ __Headers.prototype.has = function (name) {
652
+ return Object.prototype.hasOwnProperty.call(this._m, String(name).toLowerCase());
653
+ };
654
+ function __Request(data) {
655
+ this.method = data.method;
656
+ this.url = data.url;
657
+ this.headers = new __Headers(data.headers);
658
+ }
659
+ function __Response(body, init) {
660
+ init = init || {};
661
+ this.status = init.status === undefined ? 200 : init.status;
662
+ var h = init.headers || {};
663
+ if (h instanceof __Headers) h = h._m;
664
+ this.headers = h;
665
+ this.body = body == null ? null : String(body);
666
+ this.__isResponse = true;
667
+ }
668
+ function __URL(input) {
669
+ var s = String(input);
670
+ var proto = "", host = "", rest = s;
671
+ var schemeEnd = s.indexOf("://");
672
+ if (schemeEnd >= 0) {
673
+ proto = s.slice(0, schemeEnd + 1);
674
+ rest = s.slice(schemeEnd + 3);
675
+ var slash = rest.indexOf("/");
676
+ if (slash >= 0) { host = rest.slice(0, slash); rest = rest.slice(slash); }
677
+ else { host = rest; rest = ""; }
678
+ }
679
+ var hash = "", search = "", pathname = rest || "/";
680
+ var hi = pathname.indexOf("#");
681
+ if (hi >= 0) { hash = pathname.slice(hi); pathname = pathname.slice(0, hi); }
682
+ var qi = pathname.indexOf("?");
683
+ if (qi >= 0) { search = pathname.slice(qi); pathname = pathname.slice(0, qi); }
684
+ if (pathname === "") pathname = "/";
685
+ this.protocol = proto; this.host = host; this.pathname = pathname;
686
+ this.search = search; this.hash = hash;
687
+ var params = {};
688
+ if (search.length > 1) {
689
+ var pairs = search.slice(1).split("&");
690
+ for (var i = 0; i < pairs.length; i++) {
691
+ if (!pairs[i]) continue;
692
+ var eq = pairs[i].indexOf("=");
693
+ var key = eq >= 0 ? pairs[i].slice(0, eq) : pairs[i];
694
+ var val = eq >= 0 ? pairs[i].slice(eq + 1) : "";
695
+ key = decodeURIComponent(key.split("+").join(" "));
696
+ val = decodeURIComponent(val.split("+").join(" "));
697
+ if (!Object.prototype.hasOwnProperty.call(params, key)) params[key] = val;
698
+ }
699
+ }
700
+ this.searchParams = {
701
+ get: function (k) { return Object.prototype.hasOwnProperty.call(params, k) ? params[k] : null; },
702
+ has: function (k) { return Object.prototype.hasOwnProperty.call(params, k); }
703
+ };
704
+ }
705
+ globalThis.Headers = __Headers;
706
+ globalThis.Request = __Request;
707
+ globalThis.Response = __Response;
708
+ globalThis.URL = __URL;
709
+ `;
710
+
711
+ // Per-request snippet. The bundle, the shim, and the stableSecret host fn are
712
+ // already loaded on the warm context — this only ships the request data and
713
+ // the gate call.
714
+ function gateCallSnippet(request: GateRequest): string {
715
+ return [
716
+ `var __requestData = ${JSON.stringify(request)};`,
717
+ `(function () {`,
718
+ ` if (typeof __app.verifyAnonymousRequest !== "function") return JSON.stringify({ kind: "deny" });`,
719
+ ` var request = new Request(__requestData);`,
720
+ ` var stableSecret = function (name) { return __realm_stableSecret(name); };`,
721
+ ` var verdict = __app.verifyAnonymousRequest(request, stableSecret);`,
722
+ ` if (verdict === true) return JSON.stringify({ kind: "allow" });`,
723
+ ` if (verdict && verdict.__isResponse) {`,
724
+ ` var __status = verdict.status;`,
725
+ ` if (typeof __status !== "number" || !(__status >= 200 && __status <= 599)) {`,
726
+ ` return JSON.stringify({ kind: "deny" });`, // malformed status → fail closed
727
+ ` }`,
728
+ ` var __headers = {};`,
729
+ ` var __src = verdict.headers || {};`,
730
+ ` for (var __k in __src) {`,
731
+ ` if (Object.prototype.hasOwnProperty.call(__src, __k)) __headers[__k] = String(__src[__k]);`,
732
+ ` }`,
733
+ ` return JSON.stringify({`,
734
+ ` kind: "respond",`,
735
+ ` status: __status,`,
736
+ ` headers: __headers,`,
737
+ ` body: verdict.body == null ? null : String(verdict.body)`,
738
+ ` });`,
739
+ ` }`,
740
+ ` return JSON.stringify({ kind: "deny" });`,
741
+ `})();`,
742
+ ].join("\n");
743
+ }
744
+
745
+ /** A warm VM handle handed to callers: it only calls in — the pool owns disposal.
746
+ * Internal until slice 2 exports `getVm` for the cold sites. */
747
+ interface VmHandle {
748
+ /** Eval a snippet against the resident `__app`, returning parsed JSON. Serialized. */
749
+ call(snippet: string): Promise<unknown>;
750
+ }
751
+
752
+ /** Internal per-VM lifecycle the pool manages (and the one-shot `runGate` uses). */
753
+ interface VmInstance {
754
+ call(snippet: string): Promise<unknown>;
755
+ /** Current QuickJS heap in bytes (`computeMemoryUsage().memory_used_size`). */
756
+ memoryUsageBytes(): number;
757
+ /** Drain any in-flight call on the still-live context, then free it. Idempotent. */
758
+ dispose(): Promise<void>;
759
+ }
760
+
761
+ let asyncModulePromise: ReturnType<typeof newQuickJSAsyncWASMModuleFromVariant> | null = null;
762
+
763
+ // Memoize the async WASM module load (shared by runInSandbox + the gate), but
764
+ // never cache a rejection: a transient load failure must not permanently wedge
765
+ // every gate into deny. Clear the slot on failure so the next call retries.
766
+ function loadAsyncModule(): ReturnType<typeof newQuickJSAsyncWASMModuleFromVariant> {
767
+ if (!asyncModulePromise) {
768
+ // Dynamic import (the variant's documented form) so only the asyncify
769
+ // variant enters the bundle - the sync variant never does, so a prod build
770
+ // can't collide their identically named wasm. Deno resolves the package as
771
+ // CJS, nesting the variant at `.default.default`; the loader's smartUnwrap
772
+ // and PromisedDefault both handle that.
773
+ const pending = newQuickJSAsyncWASMModuleFromVariant(
774
+ import("@jitl/quickjs-wasmfile-release-asyncify"),
775
+ );
776
+ pending.catch(() => {
777
+ if (asyncModulePromise === pending) asyncModulePromise = null;
778
+ });
779
+ asyncModulePromise = pending;
780
+ }
781
+ return asyncModulePromise;
782
+ }
783
+
784
+ /**
785
+ * Evaluate a bundle (+ optional preamble) once into a fresh context, then reuse
786
+ * it for every `call(snippet)`. Re-evaluating the whole bundle (zod + SDK + app)
787
+ * is ~150ms; a warm instance pays that once and each call is just the snippet.
788
+ * Calls are serialized: one asyncified context can't run two evals at once.
789
+ *
790
+ * `hostFns` are bound as asyncified host functions before the bundle runs — the
791
+ * guest calls `name(arg)` synchronously while the host awaits underneath, so the
792
+ * seed / REALM_SECRET stay host-side. Their presence picks the eval mode: a VM
793
+ * with host fns must suspend (the gate's `stableSecret`), so it uses
794
+ * `evalCodeAsync`; a host-fn-free VM (widget prefill) runs plain synchronous
795
+ * `evalCode` and pays no asyncify cost, on the same async build.
796
+ */
797
+ async function createVmInstance(opts: {
798
+ bundle: string;
799
+ preamble?: string;
800
+ hostFns?: Record<string, (arg: string) => Promise<string>>;
801
+ }): Promise<VmInstance> {
802
+ const QuickJS = await loadAsyncModule();
803
+ const suspends = Object.keys(opts.hostFns ?? {}).length > 0;
804
+
805
+ // Creation and disposal differ by mode, and the two must match:
806
+ // - Sync VMs (no host fns: the cold extracts + widget prefill) own their
807
+ // runtime explicitly so dispose() can call `runtime.dispose()` and RECLAIM
808
+ // the WASM heap. The shorthand `QuickJS.newContext()` + `context.dispose()`
809
+ // does NOT reclaim the runtime in quickjs-emscripten 0.32: every
810
+ // create/dispose leaks ~5MB, which over a control plane's recompute
811
+ // bursts climbs into gigabytes until `WebAssembly.Memory.grow()` fails
812
+ // and every eval OOMs.
813
+ // - Asyncify VMs (host fns: the gate's `stableSecret`) must use
814
+ // `QuickJS.newContext()`, whose single `vm.dispose()` frees the host fn's
815
+ // runtime-scoped HostRef in the right order; disposing an explicit runtime
816
+ // there double-frees the HostRef and throws. These are warm-pooled and
817
+ // disposed rarely, so the (unreclaimed) heap doesn't accumulate.
818
+ const runtime = suspends ? null : QuickJS.newRuntime();
819
+ const vm = runtime ? runtime.newContext() : QuickJS.newContext();
820
+ vm.runtime.setMemoryLimit(32 * 1024 * 1024); // 32 MB
821
+ for (const [name, fn] of Object.entries(opts.hostFns ?? {})) {
822
+ const hostFn = vm.newAsyncifiedFunction(name, async (argHandle) =>
823
+ vm.newString(await fn(vm.getString(argHandle))),
824
+ );
825
+ vm.setProp(vm.global, name, hostFn);
826
+ hostFn.dispose();
827
+ }
828
+
829
+ // Eval the bundle + preamble once; `__app` and any shimmed globals persist on
830
+ // the context for every later call(). Generous budget — this is the heavy step.
831
+ vm.runtime.setInterruptHandler(workBudget(COLD_EVAL_TICK_BUDGET));
832
+ const code = [opts.bundle, "", "var __app = __realm_module__.default;", opts.preamble ?? ""].join(
833
+ "\n",
834
+ );
835
+ const init = suspends ? await vm.evalCodeAsync(code) : vm.evalCode(code);
836
+ if (init.error) {
837
+ const err = vm.dump(init.error);
838
+ init.error.dispose();
839
+ vm.dispose();
840
+ runtime?.dispose();
841
+ throw new RealmCompileError({
842
+ kind: "eval",
843
+ message: typeof err === "string" ? err : JSON.stringify(err),
844
+ });
845
+ }
846
+ (init as unknown as { value: Parameters<typeof vm.dump>[0] }).value.dispose();
847
+
848
+ // One context can't run two asyncified evals concurrently — chain calls.
849
+ let queue: Promise<unknown> = Promise.resolve();
850
+ let disposing = false;
851
+
852
+ async function runOne(snippet: string): Promise<unknown> {
853
+ vm.runtime.setInterruptHandler(workBudget(EVAL_TICK_BUDGET));
854
+ const result = suspends ? await vm.evalCodeAsync(snippet) : vm.evalCode(snippet);
855
+ if (result.error) {
856
+ const err = vm.dump(result.error);
857
+ result.error.dispose();
858
+ throw new RealmCompileError({
859
+ kind: "eval",
860
+ message: typeof err === "string" ? err : JSON.stringify(err),
861
+ });
862
+ }
863
+ const handle = (result as unknown as { value: Parameters<typeof vm.dump>[0] }).value;
864
+ const json = vm.dump(handle);
865
+ handle.dispose();
866
+ if (typeof json !== "string") {
867
+ throw new Error(`Expected JSON string from sandbox, got ${typeof json}`);
868
+ }
869
+ return hardenedJsonParse(json);
870
+ }
871
+
872
+ return {
873
+ call(snippet) {
874
+ // Refuse once teardown has started — don't enqueue onto a context about to
875
+ // be freed; the caller fails closed.
876
+ if (disposing) return Promise.reject(new Error("vm is disposing"));
877
+ const result = queue.then(() => runOne(snippet));
878
+ queue = result.catch(() => {});
879
+ return result;
880
+ },
881
+ memoryUsageBytes() {
882
+ const handle = vm.runtime.computeMemoryUsage();
883
+ const used = (vm.dump(handle) as { memory_used_size?: number }).memory_used_size ?? 0;
884
+ handle.dispose();
885
+ return used;
886
+ },
887
+ async dispose() {
888
+ if (disposing) return; // idempotent — the pool may evict + invalidate
889
+ disposing = true;
890
+ // Let an in-flight call finish on the still-live VM before freeing it: a
891
+ // dispose mid-call (an eviction during a suspended host fn) would otherwise
892
+ // resume the asyncified eval into a freed context and hang.
893
+ await queue.catch(() => {});
894
+ vm.dispose();
895
+ runtime?.dispose();
896
+ },
897
+ };
898
+ }
899
+
900
+ // ---- warm VM pool ----------------------------------------------------------
901
+ //
902
+ // One module-level pool of warm contexts, keyed `${purpose}:${id}` (e.g.
903
+ // `gate:${appId}:${bundleHash}`). The single door: callers get a VM via `getVm`
904
+ // and never spin a context themselves. The bundle version is folded into the
905
+ // key, so a changed bundle is simply a new key — the stale entry idles out
906
+ // rather than needing explicit invalidation.
907
+ //
908
+ // Retention is lazy: each `getVm` sweeps entries idle past WARM_TTL_MS, and a
909
+ // count cap evicts the least-recently-used beyond MAX_WARM. No timer, so nothing
910
+ // to leak; an idle VM only lingers while the pool is completely quiet, which is
911
+ // exactly when nothing is under memory pressure.
912
+ //
913
+ // getVm stays internal until a second caller lands; only the gate wrapper
914
+ // below uses it.
915
+
916
+ const WARM_TTL_MS = 10 * 60 * 1000;
917
+ /** Backstop, not a target — a control plane gates a handful of apps. ~2.4MB each. */
918
+ const MAX_WARM = 64;
919
+
920
+ interface PoolEntry {
921
+ key: string;
922
+ instance: Promise<VmInstance>;
923
+ /** The settled instance, set when `instance` resolves — lets `snapshot()`
924
+ * read heap synchronously and tell a warm entry from a still-warming one. */
925
+ resolved: VmInstance | null;
926
+ lastUsedAt: number;
927
+ seq: number;
928
+ disposing: boolean;
929
+ }
930
+
931
+ const pool = new Map<string, PoolEntry>();
932
+ let poolSeq = 0;
933
+
934
+ interface GetVmOpts {
935
+ /** `${purpose}:${id}` — one warm VM per key, never shared across purposes. */
936
+ key: string;
937
+ /** The compiled app; evaluated once when the key is first warmed. */
938
+ bundle: string;
939
+ /** Code eval'd once after the bundle (e.g. the gate's Request/Response shim). */
940
+ preamble?: string;
941
+ /** Async host fns bound before the bundle runs; keeps secrets host-side. */
942
+ hostFns?: Record<string, (arg: string) => Promise<string>>;
943
+ }
944
+
945
+ /**
946
+ * The warm VM for `key`, created on first use and reused after. The handle only
947
+ * calls in — the pool owns the lifecycle (idle-TTL + LRU), so callers never
948
+ * dispose. A creation failure clears the slot so the next call re-warms rather
949
+ * than caching a rejection.
950
+ */
951
+ function getVm(opts: GetVmOpts): VmHandle {
952
+ sweepIdle();
953
+ const existing = pool.get(opts.key);
954
+ if (existing) {
955
+ existing.lastUsedAt = Date.now();
956
+ existing.seq = ++poolSeq;
957
+ return { call: (snippet) => existing.instance.then((i) => i.call(snippet)) };
958
+ }
959
+
960
+ const entry: PoolEntry = {
961
+ key: opts.key,
962
+ lastUsedAt: Date.now(),
963
+ seq: ++poolSeq,
964
+ disposing: false,
965
+ resolved: null,
966
+ instance: createVmInstance({
967
+ bundle: opts.bundle,
968
+ preamble: opts.preamble,
969
+ hostFns: opts.hostFns,
970
+ }),
971
+ };
972
+ entry.instance.then(
973
+ (i) => {
974
+ entry.resolved = i;
975
+ },
976
+ () => {
977
+ if (pool.get(entry.key) === entry) pool.delete(entry.key);
978
+ },
979
+ );
980
+ pool.set(entry.key, entry);
981
+ evictExcess();
982
+ return { call: (snippet) => entry.instance.then((i) => i.call(snippet)) };
983
+ }
984
+
985
+ function disposeEntry(entry: PoolEntry): void {
986
+ if (entry.disposing) return;
987
+ entry.disposing = true;
988
+ pool.delete(entry.key);
989
+ void entry.instance.then((i) => i.dispose()).catch(() => {});
990
+ }
991
+
992
+ function sweepIdle(): void {
993
+ const now = Date.now();
994
+ for (const entry of pool.values()) {
995
+ if (now - entry.lastUsedAt > WARM_TTL_MS) disposeEntry(entry);
996
+ }
997
+ }
998
+
999
+ function evictExcess(): void {
1000
+ if (pool.size <= MAX_WARM) return;
1001
+ const live = [...pool.values()].filter((e) => !e.disposing).sort((a, b) => a.seq - b.seq);
1002
+ for (const entry of live.slice(0, live.length - MAX_WARM)) disposeEntry(entry);
1003
+ }
1004
+
1005
+ /**
1006
+ * Dispose every warm VM, draining in-flight calls first. For process shutdown
1007
+ * and test teardown — production reuse relies on the lazy sweep, not this.
1008
+ */
1009
+ export async function disposeAllVms(): Promise<void> {
1010
+ await Promise.all(
1011
+ [...pool.values()].map((entry) => {
1012
+ if (entry.disposing) return Promise.resolve();
1013
+ entry.disposing = true;
1014
+ pool.delete(entry.key);
1015
+ return entry.instance.then((i) => i.dispose()).catch(() => {});
1016
+ }),
1017
+ );
1018
+ }
1019
+
1020
+ // ---- pool snapshot (admin diagnostics) -------------------------------------
1021
+
1022
+ /** One warm (or still-warming) context, flattened for the admin VM-pool view. */
1023
+ export interface VmPoolEntrySnapshot {
1024
+ /** `gate` or `prefill` — the key's first segment. */
1025
+ purpose: string;
1026
+ /** App id — the key's second segment (nameSchema-safe, so never a `:`). */
1027
+ appId: string;
1028
+ /** Bundle hash — the key's remainder (hex, so never a `:`). */
1029
+ bundleId: string;
1030
+ /** `warm` once the instance promise resolved; `warming` while still evaluating. */
1031
+ state: "warm" | "warming";
1032
+ /** Current QuickJS heap in bytes, or `null` if it couldn't be measured. */
1033
+ heapBytes: number | null;
1034
+ /** Epoch ms of the most recent `getVm` for this key. */
1035
+ lastUsedAt: number;
1036
+ }
1037
+
1038
+ /** In-process view of the warm VM pool for the admin diagnostics page. */
1039
+ export interface VmPoolSnapshot {
1040
+ entries: VmPoolEntrySnapshot[];
1041
+ /** LRU cap (`MAX_WARM`). */
1042
+ maxWarm: number;
1043
+ /** Idle TTL in ms (`WARM_TTL_MS`). */
1044
+ ttlMs: number;
1045
+ /** Sum of every measurable `heapBytes`. */
1046
+ totalHeapBytes: number;
1047
+ }
1048
+
1049
+ /**
1050
+ * Read-only snapshot of the warm VM pool. Splits each key into
1051
+ * purpose/appId/bundleId (on the first two colons — appId and bundleId hold
1052
+ * none), reads heap synchronously off the resolved instance (a still-warming
1053
+ * entry reports `warming` with no heap), and totals the cap + TTL + heap.
1054
+ * Best-effort: an asyncified gate VM suspended mid-call can't be measured, so
1055
+ * its `heapBytes` comes back `null`.
1056
+ */
1057
+ export function snapshot(): VmPoolSnapshot {
1058
+ const entries: VmPoolEntrySnapshot[] = [];
1059
+ let totalHeapBytes = 0;
1060
+ for (const entry of pool.values()) {
1061
+ if (entry.disposing) continue;
1062
+ const firstColon = entry.key.indexOf(":");
1063
+ const secondColon = entry.key.indexOf(":", firstColon + 1);
1064
+ let heapBytes: number | null = null;
1065
+ if (entry.resolved) {
1066
+ try {
1067
+ heapBytes = entry.resolved.memoryUsageBytes();
1068
+ totalHeapBytes += heapBytes;
1069
+ } catch {
1070
+ heapBytes = null;
1071
+ }
1072
+ }
1073
+ entries.push({
1074
+ purpose: entry.key.slice(0, firstColon),
1075
+ appId: entry.key.slice(firstColon + 1, secondColon),
1076
+ bundleId: entry.key.slice(secondColon + 1),
1077
+ state: entry.resolved ? "warm" : "warming",
1078
+ heapBytes,
1079
+ lastUsedAt: entry.lastUsedAt,
1080
+ });
1081
+ }
1082
+ return { entries, maxWarm: MAX_WARM, ttlMs: WARM_TTL_MS, totalHeapBytes };
1083
+ }
1084
+
1085
+ // ---- gate wrapper ----------------------------------------------------------
1086
+
1087
+ /**
1088
+ * Run one app's anonymous gate against a request, reusing its warm VM. The key
1089
+ * folds in `bundleId` (Home's bundle hash) so a redeploy transparently warms a
1090
+ * fresh context. `deriveSecret` is Home's app-scoped `deriveAppPassword`. Throws
1091
+ * on an eval error / malformed verdict, which the caller treats as deny (fail
1092
+ * closed); a gate that returns a bad status denies in-snippet.
1093
+ */
1094
+ export async function runAppGate(opts: {
1095
+ appId: string;
1096
+ bundleId: string;
1097
+ bundle: string;
1098
+ request: GateRequest;
1099
+ deriveSecret: (name: string) => Promise<string>;
1100
+ }): Promise<GateResult> {
1101
+ const vm = getVm({
1102
+ key: `gate:${opts.appId}:${opts.bundleId}`,
1103
+ bundle: opts.bundle,
1104
+ preamble: GATE_SHIM,
1105
+ hostFns: { __realm_stableSecret: opts.deriveSecret },
1106
+ });
1107
+ return gateResultSchema.parse(await vm.call(gateCallSnippet(opts.request)));
1108
+ }
1109
+
1110
+ /** One-shot gate: warm a throwaway VM, run once, dispose. Tests / rare callers. */
1111
+ export async function runGate(
1112
+ bundledCode: string,
1113
+ request: GateRequest,
1114
+ deriveSecret: (name: string) => Promise<string>,
1115
+ ): Promise<GateResult> {
1116
+ const vm = await createVmInstance({
1117
+ bundle: bundledCode,
1118
+ preamble: GATE_SHIM,
1119
+ hostFns: { __realm_stableSecret: deriveSecret },
1120
+ });
1121
+ try {
1122
+ return gateResultSchema.parse(await vm.call(gateCallSnippet(request)));
1123
+ } finally {
1124
+ await vm.dispose();
1125
+ }
1126
+ }
1127
+
1128
+ // ---- widget prefill (loader request stitching) -----------------------------
1129
+
1130
+ // Run every loader's request(seedState, config) inside the VM and collect the
1131
+ // descriptors. `seedState` is the surface's `stateSchema.parse({})` — the exact
1132
+ // seed the iframe runtime mounts with — so a descriptor produced here matches
1133
+ // the query key the runtime later builds from `request(state, config)`, and the
1134
+ // prefilled data seeds cleanly with no first-paint refetch. A loader whose
1135
+ // request is null (or throws) on the seed is lazy for this config → omitted.
1136
+ function stitchSnippet(collection: "pages" | "widgets", key: string, config: unknown): string {
1137
+ return [
1138
+ `var __config = ${JSON.stringify(config ?? null)};`,
1139
+ `if (__config == null) __config = {};`, // mirror the runtime's `opts.config ?? {}`
1140
+ `(function () {`,
1141
+ ` var ui = __app.ui;`,
1142
+ ` var surface = ui && ui.${collection} && ui.${collection}[${JSON.stringify(key)}];`,
1143
+ ` if (!surface) return JSON.stringify({});`,
1144
+ ` var seedState = {};`,
1145
+ ` if (surface.stateSchema && typeof surface.stateSchema.safeParse === "function") {`,
1146
+ ` var __p = surface.stateSchema.safeParse({});`,
1147
+ ` seedState = __p.success ? __p.data : {};`,
1148
+ ` }`,
1149
+ ` var out = {};`,
1150
+ ` var loaders = surface.loaders || {};`,
1151
+ ` var names = Object.keys(loaders);`,
1152
+ ` for (var i = 0; i < names.length; i++) {`,
1153
+ ` var loader = loaders[names[i]];`,
1154
+ ` if (!loader || typeof loader.request !== "function") continue;`,
1155
+ ` var req;`,
1156
+ ` try { req = loader.request(seedState, __config); } catch (e) { req = null; }`,
1157
+ ` out[names[i]] = req == null ? null : req;`,
1158
+ ` }`,
1159
+ ` return JSON.stringify(out);`,
1160
+ `})();`,
1161
+ ].join("\n");
1162
+ }
1163
+
1164
+ /**
1165
+ * Stitch every loader's request for one surface against the real per-instance
1166
+ * `config`, reusing a warm sync VM keyed `prefill:${appId}:${bundleId}`. Returns
1167
+ * a map of loader name → request descriptor (eager) or `null` (lazy). Home runs
1168
+ * this at shell-render time so its server-side prefill uses the placement's real
1169
+ * config; the returned descriptors match what the iframe runtime computes on
1170
+ * mount.
1171
+ */
1172
+ export async function stitchLoaderRequests(opts: {
1173
+ appId: string;
1174
+ bundleId: string;
1175
+ bundle: string;
1176
+ kind: "page" | "widget";
1177
+ key: string;
1178
+ config: unknown;
1179
+ }): Promise<Record<string, RequestManifest | null>> {
1180
+ const vm = getVm({ key: `prefill:${opts.appId}:${opts.bundleId}`, bundle: opts.bundle });
1181
+ const collection = opts.kind === "widget" ? "widgets" : "pages";
1182
+ const result = await vm.call(stitchSnippet(collection, opts.key, opts.config));
1183
+ return (result ?? {}) as Record<string, RequestManifest | null>;
1184
+ }
1185
+
1186
+ /**
1187
+ * Build a nested object by placing each flat-path value at its dotted
1188
+ * location. Used to recompose resolved permission values into the shape
1189
+ * `getDesiredState` expects (mirroring the tabs tree).
1190
+ *
1191
+ * nestByPaths({ "a.b": 1, "a.c": 2 }) → { a: { b: 1, c: 2 } }
1192
+ */
1193
+ export function nestByPaths(flat: Record<string, unknown>): Record<string, unknown> {
1194
+ const out: Record<string, unknown> = {};
1195
+ for (const [path, value] of Object.entries(flat)) {
1196
+ const segments = path.split(".");
1197
+ let cursor = out;
1198
+ for (let i = 0; i < segments.length - 1; i++) {
1199
+ const seg = segments[i]!;
1200
+ const next = cursor[seg];
1201
+ if (!next || typeof next !== "object" || Array.isArray(next)) {
1202
+ cursor[seg] = {};
1203
+ }
1204
+ cursor = cursor[seg] as Record<string, unknown>;
1205
+ }
1206
+ cursor[segments[segments.length - 1]!] = value;
1207
+ }
1208
+ return out;
1209
+ }