@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.
- package/LICENSE-EXCEPTION +26 -0
- package/README.md +34 -0
- package/dist/chunk-3R6KEFOI.js +22 -0
- package/dist/chunk-67BDAMYX.js +376 -0
- package/dist/chunk-AITGS27R.js +139 -0
- package/dist/chunk-CMAEHQ4F.js +6 -0
- package/dist/chunk-MLKGABMK.js +9 -0
- package/dist/chunk-O3UIK34B.js +1430 -0
- package/dist/chunk-VHTUSZUX.js +176 -0
- package/dist/cli.js +9006 -0
- package/dist/docker.js +54 -0
- package/dist/index.js +1053 -0
- package/dist/ingress.js +436 -0
- package/dist/jsx-runtime.js +2 -0
- package/dist/preact.js +22 -0
- package/dist/service-url.js +11 -0
- package/dist/services.js +213 -0
- package/dist/test.js +5069 -0
- package/dist/ui.js +254 -0
- package/dist/worker-protocol.js +89 -0
- package/package.json +112 -0
- package/src/app-icon.ts +74 -0
- package/src/apply.ts +52 -0
- package/src/bundle-ui.ts +211 -0
- package/src/bundle.ts +546 -0
- package/src/cli/build-dev-landing.ts +84 -0
- package/src/cli/build-install-panel.ts +90 -0
- package/src/cli/build-runtime.ts +133 -0
- package/src/cli/build.ts +63 -0
- package/src/cli/dev.ts +107 -0
- package/src/cli/down.ts +60 -0
- package/src/cli/main.ts +56 -0
- package/src/cli/test.ts +61 -0
- package/src/cli/validate.ts +37 -0
- package/src/compile.ts +100 -0
- package/src/create-app.ts +982 -0
- package/src/deep-merge.ts +33 -0
- package/src/dev/fixture.ts +48 -0
- package/src/dev/install-handler.ts +383 -0
- package/src/dev/server.ts +968 -0
- package/src/dev/stack.ts +108 -0
- package/src/dev/state.ts +159 -0
- package/src/docker.ts +96 -0
- package/src/errors.ts +61 -0
- package/src/factory.ts +481 -0
- package/src/garage.ts +56 -0
- package/src/generator/hash.ts +6 -0
- package/src/generator/resolve-refs.ts +207 -0
- package/src/grants-builder.ts +100 -0
- package/src/grants.ts +260 -0
- package/src/home-api.ts +172 -0
- package/src/home-cookie.ts +10 -0
- package/src/home-registry.ts +120 -0
- package/src/home.ts +187 -0
- package/src/host-registry.ts +143 -0
- package/src/http-header.ts +20 -0
- package/src/identifiers.ts +49 -0
- package/src/index.ts +66 -0
- package/src/info.ts +64 -0
- package/src/ini.ts +50 -0
- package/src/json.ts +14 -0
- package/src/jsx-runtime/index.ts +5 -0
- package/src/keys.ts +29 -0
- package/src/mysql.ts +56 -0
- package/src/pack-build.ts +108 -0
- package/src/pack.ts +115 -0
- package/src/peer-registry.ts +121 -0
- package/src/peer-world.ts +117 -0
- package/src/permission-meta.ts +116 -0
- package/src/permissions.ts +108 -0
- package/src/pkg-root.ts +34 -0
- package/src/postgres.ts +79 -0
- package/src/preact/index.ts +27 -0
- package/src/redis.ts +43 -0
- package/src/ref.ts +271 -0
- package/src/remote-registry.ts +33 -0
- package/src/required-host-kinds.ts +110 -0
- package/src/resolve-context.ts +111 -0
- package/src/resolve.ts +471 -0
- package/src/runtime/docker/compose/compose-schema.ts +118 -0
- package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
- package/src/runtime/docker/compose/default-hardening.ts +21 -0
- package/src/runtime/docker/compose/effects.ts +107 -0
- package/src/runtime/docker/compose/index.ts +17 -0
- package/src/runtime/docker/compose/labels.ts +39 -0
- package/src/runtime/docker/compose/types.ts +78 -0
- package/src/runtime/docker/home-proxy-server/index.ts +177 -0
- package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
- package/src/runtime/docker/ingress/build-config.ts +819 -0
- package/src/runtime/docker/ingress/index.ts +16 -0
- package/src/runtime/docker/ingress/types.ts +182 -0
- package/src/runtime/docker/log-frames.ts +121 -0
- package/src/runtime/docker/logs-server/index.ts +366 -0
- package/src/runtime/docker/spawn-server/context-key.ts +82 -0
- package/src/runtime/docker/spawn-server/index.ts +492 -0
- package/src/runtime/docker/spawn-server/protocol.ts +70 -0
- package/src/runtime/docker/spawn-server/spec.ts +122 -0
- package/src/runtime/docker/stats-server/disks.ts +161 -0
- package/src/runtime/docker/stats-server/index.ts +322 -0
- package/src/runtime/home-client/index.ts +71 -0
- package/src/runtime/log-line.ts +24 -0
- package/src/runtime/logs-client/index.ts +175 -0
- package/src/runtime/spawn-client/index.ts +93 -0
- package/src/runtime/spawn-client/kaniko.ts +79 -0
- package/src/runtime/stats-client/index.ts +65 -0
- package/src/runtime/stats-sample.ts +95 -0
- package/src/safe-json.ts +37 -0
- package/src/sandbox.ts +1209 -0
- package/src/schema/build-apps.ts +38 -0
- package/src/schema/home.ts +217 -0
- package/src/schema/host.ts +792 -0
- package/src/schema/inputs.ts +26 -0
- package/src/schema/nodes.ts +75 -0
- package/src/schema/peer.ts +400 -0
- package/src/schema/public-domain.ts +116 -0
- package/src/schema/remote.ts +74 -0
- package/src/security.ts +594 -0
- package/src/serialize.ts +21 -0
- package/src/service-definition.ts +284 -0
- package/src/service-url.ts +64 -0
- package/src/service.ts +39 -0
- package/src/services/index.ts +5 -0
- package/src/shared.ts +65 -0
- package/src/speculative-silo.ts +96 -0
- package/src/stable-secret.ts +14 -0
- package/src/surfaces.ts +264 -0
- package/src/tailwind-bin.ts +56 -0
- package/src/test/boot.ts +192 -0
- package/src/test/compile.ts +501 -0
- package/src/test/describe.ts +117 -0
- package/src/test/index.ts +11 -0
- package/src/test/ingress-fixture.ts +37 -0
- package/src/test/types.ts +318 -0
- package/src/trusted-headers.ts +79 -0
- package/src/types.ts +126 -0
- package/src/ui/components/aside.tsx +96 -0
- package/src/ui/components/badge.tsx +42 -0
- package/src/ui/components/button.tsx +59 -0
- package/src/ui/components/card.tsx +106 -0
- package/src/ui/components/checkbox.tsx +37 -0
- package/src/ui/components/code-block.tsx +29 -0
- package/src/ui/components/combobox.tsx +119 -0
- package/src/ui/components/config-field.tsx +284 -0
- package/src/ui/components/dialog.tsx +137 -0
- package/src/ui/components/domain-editor.tsx +317 -0
- package/src/ui/components/domain-list.ts +205 -0
- package/src/ui/components/dropdown-menu.tsx +73 -0
- package/src/ui/components/empty-state.tsx +51 -0
- package/src/ui/components/highlight.ts +59 -0
- package/src/ui/components/icons.tsx +101 -0
- package/src/ui/components/image-posture.tsx +85 -0
- package/src/ui/components/input.tsx +28 -0
- package/src/ui/components/label.tsx +16 -0
- package/src/ui/components/line-chart.tsx +113 -0
- package/src/ui/components/page.tsx +39 -0
- package/src/ui/components/permission-row.tsx +262 -0
- package/src/ui/components/permission-section.tsx +51 -0
- package/src/ui/components/pie-chart.tsx +245 -0
- package/src/ui/components/schema-block.tsx +60 -0
- package/src/ui/components/separator.tsx +42 -0
- package/src/ui/components/sheet.tsx +130 -0
- package/src/ui/components/sidebar.tsx +650 -0
- package/src/ui/components/silo-picker.tsx +510 -0
- package/src/ui/components/skeleton.tsx +13 -0
- package/src/ui/components/sonner.tsx +33 -0
- package/src/ui/components/switch.tsx +58 -0
- package/src/ui/components/table-view.ts +219 -0
- package/src/ui/components/table.tsx +506 -0
- package/src/ui/components/tabs.tsx +59 -0
- package/src/ui/components/timeago.tsx +84 -0
- package/src/ui/components/tooltip.tsx +56 -0
- package/src/ui/components.json +21 -0
- package/src/ui/dev/dev-shell.tsx +145 -0
- package/src/ui/home/home-grid.tsx +177 -0
- package/src/ui/hooks/use-mobile.ts +19 -0
- package/src/ui/index.ts +189 -0
- package/src/ui/install/adapter.ts +193 -0
- package/src/ui/install/deploy-targets-picker.tsx +185 -0
- package/src/ui/install/install-app-panel.tsx +912 -0
- package/src/ui/install/install-error.tsx +62 -0
- package/src/ui/install/perms-inactive.ts +50 -0
- package/src/ui/install/required-fields.ts +37 -0
- package/src/ui/install/tabs-form.tsx +557 -0
- package/src/ui/tokens.css +279 -0
- package/src/ui/utils.ts +78 -0
- package/src/ui-error.ts +139 -0
- package/src/ui-runtime/dev-landing-entry.tsx +258 -0
- package/src/ui-runtime/index.ts +83 -0
- package/src/ui-runtime/install-entry.tsx +119 -0
- package/src/ui-runtime/mount.ts +367 -0
- package/src/ui-runtime/runtime.css +56 -0
- package/src/ui-surface.ts +451 -0
- package/src/valkey.ts +43 -0
- package/src/worker/extract-tar.ts +125 -0
- package/src/worker/identity.ts +183 -0
- package/src/worker/index.ts +16 -0
- package/src/worker/public-ip.ts +92 -0
- package/src/worker/queue.ts +54 -0
- package/src/worker/reconcile.ts +127 -0
- package/src/worker/start-worker.ts +227 -0
- package/src/worker/ui-proxy.ts +335 -0
- package/src/worker/uplink.ts +523 -0
- package/src/worker/validate.ts +34 -0
- package/src/worker/verify-relay.ts +102 -0
- package/src/worker-protocol/app-dirs.ts +94 -0
- package/src/worker-protocol/home-proxy-paths.ts +76 -0
- package/src/worker-protocol/index.ts +55 -0
- package/src/worker-protocol/ingress.ts +166 -0
- package/src/worker-protocol/logs-socket-path.ts +50 -0
- package/src/worker-protocol/networks.ts +39 -0
- package/src/worker-protocol/node-version.ts +70 -0
- package/src/worker-protocol/silo-paths.ts +114 -0
- package/src/worker-protocol/spawn-paths.ts +165 -0
- package/src/worker-protocol/stats-socket-path.ts +40 -0
- package/src/worker-protocol/update-files.ts +77 -0
- package/src/worker-protocol/uplink/constants.ts +86 -0
- package/src/worker-protocol/uplink/errors.ts +61 -0
- package/src/worker-protocol/uplink/frame.ts +114 -0
- package/src/worker-protocol/uplink/identity.ts +40 -0
- package/src/worker-protocol/uplink/index.ts +63 -0
- package/src/worker-protocol/uplink/methods.ts +462 -0
- package/src/worker-protocol/uplink/mux.ts +646 -0
- package/src/worker-protocol/uplink/protocol.ts +11 -0
- package/src/worker-protocol/uplink/rpc.ts +309 -0
- package/src/worker-protocol/uplink/session.ts +540 -0
- package/src/worker-protocol/uplink/test-support.ts +188 -0
- package/src/worker-protocol/worker-mount.ts +27 -0
- package/src/worker-protocol/worker-state.ts +190 -0
- package/src/xml.ts +49 -0
- 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
|
+
}
|