@vizejs/plugin-sdk 0.429.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { FactGroups } from "./facts.js";
2
+
3
+ export declare const BATCH_SCHEMA: 1;
4
+ export interface PluginNode {
5
+ readonly id: number;
6
+ readonly parent?: number;
7
+ readonly kind: string;
8
+ readonly name?: string;
9
+ readonly value?: string;
10
+ readonly attrs?: readonly (readonly [string, string | null])[];
11
+ readonly alias?: {
12
+ readonly value: string;
13
+ readonly key: string | null;
14
+ readonly index: string | null;
15
+ };
16
+ }
17
+ export interface ScopeEntry {
18
+ readonly name: string;
19
+ readonly position: "value" | "key" | "index" | "slot";
20
+ }
21
+ export interface RuleContext {
22
+ readonly nodes: readonly PluginNode[];
23
+ facts(name: "templateScopes"): ReadonlyMap<number, readonly ScopeEntry[]>;
24
+ facts<K extends keyof FactGroups>(name: K): FactGroups[K];
25
+ facts<T = unknown>(name: string): ReadonlyMap<string | number, T>;
26
+ ancestors(node: PluginNode, kind: string): IterableIterator<PluginNode>;
27
+ /** Replace the whole reported node span; ranges are assigned by the host. */
28
+ report(node: PluginNode, message: string, fix?: string): void;
29
+ }
30
+ export interface PluginDefinition {
31
+ name: string;
32
+ version: string;
33
+ visit?: readonly string[];
34
+ demands?: readonly string[];
35
+ /** Explicit list of all captured configuration and ambient inputs. */
36
+ cacheInputs?: readonly { name: string; value: string }[];
37
+ rules: Readonly<Record<string, (context: RuleContext) => void>>;
38
+ }
39
+ /** Frozen at runtime. Array declarations interoperate with generated native descriptors. */
40
+ export interface Plugin extends Omit<PluginDefinition, "visit" | "demands" | "cacheInputs"> {
41
+ readonly visit?: string[];
42
+ readonly demands: string[];
43
+ readonly cacheInputs?: { name: string; value: string }[];
44
+ readonly fingerprint: string;
45
+ run(batchJson: string): string;
46
+ }
47
+ export interface VisitBatch {
48
+ schema: 1;
49
+ plugin?: string;
50
+ file?: string;
51
+ parents: number[];
52
+ nodes: PluginNode[];
53
+ facts: Record<string, Array<[string | number, unknown]>>;
54
+ }
55
+ export declare function definePlugin(definition: PluginDefinition): Plugin;
56
+ export declare function runBatch(
57
+ plugin: Plugin,
58
+ batch: VisitBatch,
59
+ ): Array<{ rule: string; node: number; message: string; fix?: string }>;
60
+
61
+ export declare function runRules(
62
+ plugin: Plugin,
63
+ source: {
64
+ nodes: readonly PluginNode[];
65
+ byId: ReadonlyMap<number, PluginNode>;
66
+ facts(name: string): ReadonlyMap<string | number, unknown>;
67
+ parent(id: number): number;
68
+ },
69
+ ): Array<{ rule: string; node: number; message: string; fix?: string }>;
70
+
71
+ export { defineFactProvider, defineOutputPlugin, defineTransformPlugin } from "./stages.js";
72
+ export { applyFixes } from "./fixes.js";
73
+
74
+ export type * from "./facts.js";
75
+ export type * from "./stages.js";
package/index.js ADDED
@@ -0,0 +1,101 @@
1
+ // Authoring API for serialized L2 visits and native compiler hooks.
2
+ // See README.md for the runtime and trust boundaries.
3
+ import { createHash } from "node:crypto";
4
+ import { SDK_FINGERPRINT } from "./identity.js";
5
+
6
+ /** Every batch this SDK reads is at this wire version. */
7
+ export const BATCH_SCHEMA = 1;
8
+
9
+ /**
10
+ * A plugin: static manifest (`visit` kinds, `demands` fact groups) plus
11
+ * rules `(ctx) => void`. `run` is what the native host calls, once per
12
+ * document: batch JSON in, report JSON out.
13
+ */
14
+ export function definePlugin({ name, version, visit, demands = [], cacheInputs, rules }) {
15
+ if (
16
+ !name ||
17
+ !version ||
18
+ !rules ||
19
+ Object.values(rules).some((rule) => typeof rule !== "function")
20
+ ) {
21
+ throw new TypeError("a plugin needs name, version and synchronous rule functions");
22
+ }
23
+ const code = Object.entries(rules).map(([id, rule]) => [id, String(rule)]);
24
+ const fingerprint = createHash("sha256")
25
+ .update(JSON.stringify([SDK_FINGERPRINT, name, version, visit ?? null, demands, code]))
26
+ .digest("hex");
27
+ const plugin = {
28
+ name,
29
+ version,
30
+ fingerprint,
31
+ visit: visit && Object.freeze([...visit]),
32
+ demands: Object.freeze([...demands]),
33
+ cacheInputs:
34
+ cacheInputs && Object.freeze(cacheInputs.map((input) => Object.freeze({ ...input }))),
35
+ rules: Object.freeze({ ...rules }),
36
+ };
37
+ plugin.run = (batchJson) => JSON.stringify(runBatch(plugin, JSON.parse(batchJson)));
38
+ return Object.freeze(plugin);
39
+ }
40
+
41
+ /** Run every rule over one parsed batch; returns the report array. */
42
+ export function runBatch(plugin, batch) {
43
+ if (batch.schema !== BATCH_SCHEMA) {
44
+ throw new Error(`${plugin.name}: batch schema ${batch.schema}, SDK reads ${BATCH_SCHEMA}`);
45
+ }
46
+ freeze(batch);
47
+ const byId = new Map(batch.nodes.map((node) => [node.id, node]));
48
+ const facts = (name) => new Map(batch.facts[name]);
49
+ const parent = (id) => batch.parents[id];
50
+ return runRules(plugin, { nodes: batch.nodes, byId, facts, parent });
51
+ }
52
+
53
+ export function runRules(plugin, source) {
54
+ const reports = [];
55
+ for (const [rule, check] of Object.entries(plugin.rules)) {
56
+ const returned = check(context(plugin, rule, source, reports));
57
+ if (returned?.then)
58
+ throw new TypeError(`${plugin.name}/${rule}: asynchronous rules are unsupported`);
59
+ }
60
+ return reports;
61
+ }
62
+
63
+ function context(plugin, rule, { nodes, byId, facts, parent }, reports) {
64
+ const where = `${plugin.name}/${rule}`;
65
+ const visits = (kind) => !plugin.visit || plugin.visit.includes(kind);
66
+ return Object.freeze({
67
+ nodes,
68
+ facts(name) {
69
+ if (!plugin.demands.includes(name)) {
70
+ throw new Error(`${where}: fact group \`${name}\` was not declared in demands`);
71
+ }
72
+ return facts(name);
73
+ },
74
+ *ancestors(node, kind) {
75
+ if (!visits(kind)) {
76
+ throw new Error(`${where}: ancestors of kind ${kind} need ${kind} in visit`);
77
+ }
78
+ for (let id = parent(node.id); id !== -1; id = parent(id)) {
79
+ const found = byId.get(id);
80
+ if (found && found.kind === kind) yield found;
81
+ }
82
+ },
83
+ report(node, message, fix) {
84
+ if (!byId.has(node.id)) throw new Error(`${where}: report needs a visited node`);
85
+ if (typeof message !== "string" || (fix !== undefined && typeof fix !== "string")) {
86
+ throw new TypeError(`${where}: message and optional fix must be strings`);
87
+ }
88
+ reports.push({ rule, node: node.id, message, ...(fix === undefined ? {} : { fix }) });
89
+ },
90
+ });
91
+ }
92
+
93
+ function freeze(value) {
94
+ if (value && typeof value === "object" && !Object.isFrozen(value)) {
95
+ for (const child of Object.values(value)) freeze(child);
96
+ Object.freeze(value);
97
+ }
98
+ }
99
+
100
+ export { defineFactProvider, defineOutputPlugin, defineTransformPlugin } from "./stages.js";
101
+ export { applyFixes } from "./fixes.js";
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "bugs": {
3
+ "url": "https://github.com/ubugeeei-prod/vize/issues"
4
+ },
5
+ "description": "Deterministic authoring and isolated execution for Vize JavaScript plugins",
6
+ "devDependencies": {
7
+ "@vizejs/native": "0.429.0"
8
+ },
9
+ "engines": {
10
+ "node": ">=22"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "default": "./index.js",
15
+ "types": "./index.d.ts"
16
+ },
17
+ "./fixes": {
18
+ "default": "./fixes.js",
19
+ "types": "./fixes.d.ts"
20
+ },
21
+ "./sandbox": {
22
+ "default": "./sandbox.js",
23
+ "types": "./sandbox.d.ts"
24
+ }
25
+ },
26
+ "files": [
27
+ "index.js",
28
+ "identity.js",
29
+ "index.d.ts",
30
+ "facts.d.ts",
31
+ "stages.js",
32
+ "stages.d.ts",
33
+ "fixes.js",
34
+ "fixes.d.ts",
35
+ "README.md",
36
+ "sandbox.js",
37
+ "sandbox-worker.js",
38
+ "sandbox.d.ts"
39
+ ],
40
+ "homepage": "https://github.com/ubugeeei-prod/vize",
41
+ "license": "MIT",
42
+ "metadata": {
43
+ "vize": {
44
+ "stability": "alpha-supported",
45
+ "stabilityDocs": "https://github.com/ubugeeei-prod/vize/blob/main/docs/content/stability.md#package-support-tiers"
46
+ }
47
+ },
48
+ "name": "@vizejs/plugin-sdk",
49
+ "peerDependencies": {
50
+ "@vizejs/native": ">=0.429.0"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "@vizejs/native": {
54
+ "optional": true
55
+ }
56
+ },
57
+ "publishConfig": {
58
+ "access": "public"
59
+ },
60
+ "repository": {
61
+ "directory": "npm/plugin-sdk",
62
+ "type": "git",
63
+ "url": "https://github.com/ubugeeei-prod/vize.git"
64
+ },
65
+ "sideEffects": false,
66
+ "type": "module",
67
+ "types": "./index.d.ts",
68
+ "version": "0.429.0"
69
+ }
@@ -0,0 +1,64 @@
1
+ // This program executes only inside the isolated container. Its VM is not a
2
+ // security boundary: the container supplies filesystem/network/process limits.
3
+ import { readFileSync, writeSync } from "node:fs";
4
+ import { runInThisContext } from "node:vm";
5
+
6
+ const request = JSON.parse(readFileSync(0, "utf8"));
7
+ const sdk = await import(request.sdk);
8
+ const { definition, batchJson } = request;
9
+ const configuration = freeze(definition.configuration ?? null);
10
+ const callback = (source) => {
11
+ // Evaluation happens only in the container; the VM supplies no isolation.
12
+ const fn = runInThisContext(`"use strict"; (${source})`);
13
+ if (typeof fn !== "function") throw new TypeError("callback source must be a function");
14
+ return (batch) => fn(batch, configuration);
15
+ };
16
+ const common = {
17
+ name: definition.name,
18
+ version: definition.version,
19
+ visit: definition.visit,
20
+ demands: definition.demands,
21
+ cacheInputs: definition.cacheInputs,
22
+ };
23
+ let plugin;
24
+ switch (definition.family) {
25
+ case "rule":
26
+ plugin = sdk.definePlugin({
27
+ ...common,
28
+ rules: Object.fromEntries(
29
+ Object.entries(definition.rules).map(([name, source]) => [name, callback(source)]),
30
+ ),
31
+ });
32
+ break;
33
+ case "transform":
34
+ plugin = sdk.defineTransformPlugin({ ...common, transform: callback(definition.callback) });
35
+ break;
36
+ case "provider":
37
+ plugin = sdk.defineFactProvider({
38
+ ...common,
39
+ provides: definition.provides,
40
+ provide: callback(definition.callback),
41
+ });
42
+ break;
43
+ case "formatter":
44
+ case "output":
45
+ plugin = sdk.defineOutputPlugin({
46
+ ...common,
47
+ family: definition.family,
48
+ output: callback(definition.callback),
49
+ });
50
+ break;
51
+ default:
52
+ throw new TypeError("unsupported sandbox family");
53
+ }
54
+ const output = plugin.run(batchJson);
55
+ // The parent bounds every stdout/stderr byte, including unsolicited writes.
56
+ writeSync(1, output);
57
+
58
+ function freeze(value) {
59
+ if (value && typeof value === "object") {
60
+ for (const child of Object.values(value)) freeze(child);
61
+ Object.freeze(value);
62
+ }
63
+ return value;
64
+ }
package/sandbox.d.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { NativeHook, JsonValue, HookIdentity } from "./stages.js";
2
+
3
+ /** A standalone function expression. It receives (contextOrBatch, configuration).
4
+ * Closures and host imports are unavailable; process access stays in the container.
5
+ */
6
+ export type CallbackSource = string;
7
+ export interface SandboxIdentity extends HookIdentity {
8
+ visit?: readonly string[];
9
+ demands?: readonly string[];
10
+ configuration?: JsonValue;
11
+ }
12
+ export type SandboxDefinition = SandboxIdentity &
13
+ (
14
+ | { family?: "rule"; rules: Readonly<Record<string, CallbackSource>> }
15
+ | { family: "provider"; provides: readonly string[]; callback: CallbackSource }
16
+ | { family: "transform" | "formatter" | "output"; callback: CallbackSource }
17
+ );
18
+ export interface SandboxLimits {
19
+ /** 100..60000 ms; default 5000. Runtime check and cleanup each add at most 5000 ms. */
20
+ timeoutMs?: number;
21
+ /** 1024..4194304 bytes for serialized request and each output stream; default 1 MiB. */
22
+ maxBytes?: number;
23
+ }
24
+ /** Requires Docker and a pre-pulled SANDBOX_IMAGE. There are no host mounts or
25
+ * host environment variables, network is disabled, root is read-only, and each
26
+ * invocation has fixed memory/CPU/PID limits and confirmed container cleanup.
27
+ * Runtime filesystem contents remain readable. Native callbacks are trusted.
28
+ */
29
+ export type SandboxHook = NativeHook & { readonly visit?: string[]; readonly demands?: string[] };
30
+ export declare function createSandboxRunner(
31
+ definition: SandboxIdentity & {
32
+ family: "provider";
33
+ provides: readonly string[];
34
+ callback: CallbackSource;
35
+ },
36
+ limits?: SandboxLimits,
37
+ ): SandboxHook & { readonly provides: string[] };
38
+ export declare function createSandboxRunner(
39
+ definition: SandboxIdentity & { family: "formatter" | "output"; callback: CallbackSource },
40
+ limits?: SandboxLimits,
41
+ ): SandboxHook & { readonly family: "formatter" | "output" };
42
+ export declare function createSandboxRunner(
43
+ definition: SandboxDefinition,
44
+ limits?: SandboxLimits,
45
+ ): SandboxHook;
46
+ export type SandboxErrorCode =
47
+ | "runtime_unavailable"
48
+ | "execution_stopped"
49
+ | "execution_failed"
50
+ | "cleanup_failed";
51
+ export declare class SandboxRuntimeError extends Error {
52
+ readonly code: SandboxErrorCode;
53
+ constructor(code: SandboxErrorCode, message: string, options?: ErrorOptions);
54
+ }
55
+ export declare const SANDBOX_IMAGE: string;
package/sandbox.js ADDED
@@ -0,0 +1,328 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ import { Script } from "node:vm";
5
+
6
+ /** Official Node 24.14.0 multi-platform index; changing this changes cache keys. */
7
+ export const SANDBOX_IMAGE =
8
+ "node:24.14.0-bookworm-slim@sha256:d8e448a56fc63242f70026718378bd4b00f8c82e78d20eefb199224a4d8e33d8";
9
+
10
+ /** Typed refusal; unavailable isolation never falls back to a trusted callback. */
11
+ export class SandboxRuntimeError extends Error {
12
+ constructor(code, message, options) {
13
+ super(message, options);
14
+ this.name = "SandboxRuntimeError";
15
+ this.code = code;
16
+ }
17
+ }
18
+
19
+ /** Run serialized callback source in a restricted container with no host mounts. */
20
+ export function createSandboxRunner(input, limits = {}) {
21
+ const definition = validate(input);
22
+ const timeoutMs = integer(limits.timeoutMs ?? 5000, 100, 60000, "timeoutMs");
23
+ const maxBytes = integer(limits.maxBytes ?? 1048576, 1024, 4194304, "maxBytes");
24
+ if (Object.keys(limits).some((key) => !["timeoutMs", "maxBytes"].includes(key))) {
25
+ throw new TypeError("unsupported sandbox limit");
26
+ }
27
+ const worker = readFileSync(new URL("./sandbox-worker.js", import.meta.url), "utf8");
28
+ const sdk = bundledSdk();
29
+ const fingerprint = createHash("sha256")
30
+ .update(JSON.stringify([definition, SANDBOX_IMAGE, worker, sdk, timeoutMs, maxBytes]))
31
+ .digest("hex");
32
+ return Object.freeze({
33
+ name: definition.name,
34
+ version: definition.version,
35
+ visit: definition.visit,
36
+ demands: definition.demands,
37
+ provides: definition.provides,
38
+ family: ["formatter", "output"].includes(definition.family) ? definition.family : undefined,
39
+ cacheInputs: definition.cacheInputs,
40
+ fingerprint,
41
+ run(batchJson) {
42
+ if (typeof batchJson !== "string") throw new TypeError("sandbox batch must be JSON text");
43
+ JSON.parse(batchJson);
44
+ const request = JSON.stringify({ definition, batchJson, sdk });
45
+ if (Buffer.byteLength(request) > maxBytes)
46
+ throw new RangeError("sandbox input limit exceeded");
47
+ const prerequisite = spawnSync(
48
+ "docker",
49
+ ["image", "inspect", "--format={{.Id}}", SANDBOX_IMAGE],
50
+ {
51
+ encoding: "utf8",
52
+ timeout: 5000,
53
+ maxBuffer: 16384,
54
+ },
55
+ );
56
+ if (prerequisite.error || prerequisite.status !== 0) {
57
+ throw new SandboxRuntimeError(
58
+ "runtime_unavailable",
59
+ `Docker and the pre-pulled SANDBOX_IMAGE are required: ${prerequisite.error?.code ?? prerequisite.stderr.trim()}`,
60
+ { cause: prerequisite.error },
61
+ );
62
+ }
63
+ const name = `vize-plugin-${randomUUID()}`;
64
+ let failure;
65
+ let output;
66
+ try {
67
+ const result = spawnSync(
68
+ "docker",
69
+ [
70
+ "run",
71
+ "--rm",
72
+ "--pull=never",
73
+ "--name",
74
+ name,
75
+ "--label",
76
+ "vize.plugin-sandbox=1",
77
+ "--label",
78
+ `vize.plugin-sandbox.host=${process.pid}`,
79
+ "--network=none",
80
+ "--read-only",
81
+ "--user=65534:65534",
82
+ "--cap-drop=ALL",
83
+ "--security-opt=no-new-privileges:true",
84
+ "--pids-limit=32",
85
+ "--memory=128m",
86
+ "--memory-swap=128m",
87
+ "--cpus=1",
88
+ "--ulimit=nofile=64:64",
89
+ "--ipc=none",
90
+ "--log-driver=none",
91
+ "--workdir=/",
92
+ "--entrypoint=node",
93
+ "-i",
94
+ SANDBOX_IMAGE,
95
+ "--max-old-space-size=64",
96
+ "--input-type=module",
97
+ "--eval",
98
+ worker,
99
+ ],
100
+ {
101
+ input: request,
102
+ encoding: "utf8",
103
+ timeout: timeoutMs,
104
+ maxBuffer: maxBytes,
105
+ killSignal: "SIGKILL",
106
+ windowsHide: true,
107
+ },
108
+ );
109
+ if (result.error)
110
+ throw new SandboxRuntimeError(
111
+ "execution_stopped",
112
+ `sandbox execution stopped: ${result.error.code}`,
113
+ { cause: result.error },
114
+ );
115
+ if (result.status !== 0) {
116
+ throw new SandboxRuntimeError(
117
+ "execution_failed",
118
+ `sandbox execution failed (${result.status}): ${result.stderr.trim()}`,
119
+ );
120
+ }
121
+ if (Buffer.byteLength(result.stdout) > maxBytes)
122
+ throw new SandboxRuntimeError("execution_stopped", "sandbox output limit exceeded");
123
+ JSON.parse(result.stdout);
124
+ output = result.stdout;
125
+ } catch (error) {
126
+ failure = error;
127
+ }
128
+ // Execution errors are captured above, so cleanup also runs after failure.
129
+ // Killing the attached CLI does not kill its container.
130
+ try {
131
+ confirmRemoval(name);
132
+ } catch (cleanup) {
133
+ failure = new SandboxRuntimeError(
134
+ "cleanup_failed",
135
+ `sandbox container cleanup was not confirmed: ${cleanup.message}`,
136
+ { cause: new AggregateError([failure, cleanup].filter(Boolean)) },
137
+ );
138
+ }
139
+ if (failure) throw failure;
140
+ return output;
141
+ },
142
+ });
143
+ }
144
+
145
+ function confirmRemoval(name) {
146
+ const deadline = performance.now() + 5000;
147
+ const run = (args) =>
148
+ spawnSync("docker", args, {
149
+ encoding: "utf8",
150
+ timeout: Math.max(1, Math.ceil(deadline - performance.now())),
151
+ maxBuffer: 16384,
152
+ killSignal: "SIGKILL",
153
+ });
154
+ const detail = (result) =>
155
+ `${result.error?.code ?? `status ${result.status}`}: ${result.stderr?.trim() ?? ""}`;
156
+ let last = "cleanup deadline expired";
157
+ while (performance.now() < deadline) {
158
+ const removed = run(["rm", "--force", name]);
159
+ if (!removed.error && removed.status === 0) return;
160
+ last = `rm: ${detail(removed)}`;
161
+ if (performance.now() >= deadline) break;
162
+ // --rm may already be deleting the container. An rm error is not proof of
163
+ // absence: inspect this exact unique name before returning or retrying.
164
+ const inspected = run(["container", "inspect", "--format={{.Id}}", name]);
165
+ last += `; inspect: ${detail(inspected)}`;
166
+ if (
167
+ !inspected.error &&
168
+ inspected.status !== 0 &&
169
+ new RegExp(
170
+ `^(?:Error response from daemon: |Error: )No such (?:object|container): ${name}$`,
171
+ ).test(inspected.stderr.trim())
172
+ )
173
+ return;
174
+ if (
175
+ inspected.error ||
176
+ inspected.status !== 0 ||
177
+ !/^[a-f0-9]{64}$/.test(inspected.stdout.trim())
178
+ ) {
179
+ throw new Error(last);
180
+ }
181
+ // It still exists, including removal-in-progress. All retries share the
182
+ // original deadline; daemon errors and timeout never count as success.
183
+ }
184
+ throw new Error(last);
185
+ }
186
+
187
+ function validate(input) {
188
+ if (!input || typeof input !== "object" || Array.isArray(input))
189
+ throw new TypeError("sandbox manifest required");
190
+ const allowed = [
191
+ "name",
192
+ "version",
193
+ "family",
194
+ "visit",
195
+ "demands",
196
+ "provides",
197
+ "cacheInputs",
198
+ "rules",
199
+ "callback",
200
+ "configuration",
201
+ ];
202
+ if (Object.keys(input).some((key) => !allowed.includes(key)))
203
+ throw new TypeError("unsupported sandbox manifest field");
204
+ if (
205
+ typeof input.name !== "string" ||
206
+ !input.name ||
207
+ typeof input.version !== "string" ||
208
+ !input.version
209
+ ) {
210
+ throw new TypeError("sandbox name and version must be nonempty strings");
211
+ }
212
+ const family = input.family ?? "rule";
213
+ if (!["rule", "transform", "provider", "formatter", "output"].includes(family))
214
+ throw new TypeError("unsupported sandbox family");
215
+ for (const key of ["visit", "demands", "provides"]) {
216
+ if (
217
+ input[key] !== undefined &&
218
+ (!Array.isArray(input[key]) || input[key].some((v) => typeof v !== "string"))
219
+ ) {
220
+ throw new TypeError(`sandbox ${key} must contain strings`);
221
+ }
222
+ }
223
+ if (family === "provider" && !input.provides?.length)
224
+ throw new TypeError("provider must declare provides");
225
+ if (
226
+ input.cacheInputs !== undefined &&
227
+ (!Array.isArray(input.cacheInputs) ||
228
+ input.cacheInputs.some(
229
+ (v) =>
230
+ !v ||
231
+ typeof v.name !== "string" ||
232
+ typeof v.value !== "string" ||
233
+ Object.keys(v).length !== 2,
234
+ ))
235
+ )
236
+ throw new TypeError("sandbox cache inputs need name and value strings");
237
+ if (family === "rule") {
238
+ if (
239
+ !input.rules ||
240
+ Array.isArray(input.rules) ||
241
+ typeof input.rules !== "object" ||
242
+ input.callback !== undefined
243
+ ) {
244
+ throw new TypeError("rule sandbox needs callback sources in rules");
245
+ }
246
+ for (const [id, source] of Object.entries(input.rules)) {
247
+ if (!id) throw new TypeError("rule id must be nonempty");
248
+ parseCallback(source);
249
+ }
250
+ } else {
251
+ if (input.rules !== undefined) throw new TypeError("stage sandbox uses callback, not rules");
252
+ parseCallback(input.callback);
253
+ }
254
+ assertJson(input.configuration ?? null);
255
+ return freeze(JSON.parse(JSON.stringify({ ...input, family })));
256
+ }
257
+
258
+ function parseCallback(source) {
259
+ if (
260
+ typeof source !== "string" ||
261
+ source.length > 65536 ||
262
+ !/^(?:async\s+)?(?:function\b|\([^]*?\)\s*=>|[\w$]+\s*=>)/.test(source.trim())
263
+ ) {
264
+ throw new TypeError("sandbox callback must be standalone function source");
265
+ }
266
+ // Parsing only: this never evaluates guest code in the host process.
267
+ new Script(`(${source})`, { filename: "sandbox-callback.js" });
268
+ }
269
+
270
+ function assertJson(value) {
271
+ if (
272
+ value === null ||
273
+ typeof value === "string" ||
274
+ typeof value === "boolean" ||
275
+ (typeof value === "number" && Number.isFinite(value))
276
+ )
277
+ return;
278
+ if (
279
+ value &&
280
+ typeof value === "object" &&
281
+ (Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype)
282
+ ) {
283
+ for (const child of Object.values(value)) assertJson(child);
284
+ return;
285
+ }
286
+ throw new TypeError("sandbox configuration must be JSON data");
287
+ }
288
+
289
+ function bundledSdk() {
290
+ const data = (source) => `data:text/javascript;base64,${Buffer.from(source).toString("base64")}`;
291
+ const files = Object.fromEntries(
292
+ ["index.js", "stages.js", "identity.js", "package.json"].map((file) => [
293
+ file,
294
+ readFileSync(new URL(file, import.meta.url)),
295
+ ]),
296
+ );
297
+ // Match the actual package identity's byte-level hash. Its filesystem reader
298
+ // cannot run from a data URL, so transport the resulting identity as a module.
299
+ const hash = createHash("sha256");
300
+ for (const [file, bytes] of Object.entries(files)) {
301
+ hash.update(file).update(String(bytes.length)).update(bytes);
302
+ }
303
+ const identity = data(`export const SDK_FINGERPRINT = ${JSON.stringify(hash.digest("hex"))};`);
304
+ const stage = data(
305
+ files["stages.js"].toString("utf8").replace('from "./identity.js"', `from "${identity}"`),
306
+ );
307
+ const fixes = data(readFileSync(new URL("./fixes.js", import.meta.url), "utf8"));
308
+ const index = files["index.js"]
309
+ .toString("utf8")
310
+ .replace('from "./identity.js"', `from "${identity}"`)
311
+ .replace('from "./stages.js"', `from "${stage}"`)
312
+ .replace('from "./fixes.js"', `from "${fixes}"`);
313
+ return data(index);
314
+ }
315
+
316
+ function integer(value, min, max, name) {
317
+ if (!Number.isInteger(value) || value < min || value > max)
318
+ throw new RangeError(`invalid sandbox ${name}`);
319
+ return value;
320
+ }
321
+
322
+ function freeze(value) {
323
+ if (value && typeof value === "object") {
324
+ for (const child of Object.values(value)) freeze(child);
325
+ Object.freeze(value);
326
+ }
327
+ return value;
328
+ }