Skip to content

Commit 83ad42c

Browse files
authored
feat: Initial JS sandbox primitives. (#1753)
1 parent 24afb73 commit 83ad42c

File tree

20 files changed

+904
-14
lines changed

20 files changed

+904
-14
lines changed

deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"./packages/iframe-sandbox",
1212
"./packages/integration",
1313
"./packages/js-runtime",
14+
"./packages/js-sandbox",
1415
"./packages/schema-generator",
1516
"./packages/llm",
1617
"./packages/memory",

deno.lock

Lines changed: 37 additions & 14 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export default {
2+
include: {
3+
"../static/assets": "static",
4+
},
5+
esbuildConfig: {
6+
supported: {
7+
using: false,
8+
},
9+
external: [
10+
"jsdom",
11+
"source-map-support",
12+
"canvas",
13+
"inspector",
14+
],
15+
tsconfigRaw: {
16+
compilerOptions: {
17+
// `useDefineForClassFields` is critical when using Lit
18+
// with esbuild, even when not using decorators.
19+
useDefineForClassFields: false,
20+
experimentalDecorators: true,
21+
},
22+
},
23+
},
24+
};

packages/js-sandbox/deno.json

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "@commontools/js-sandbox",
3+
"tasks": {
4+
"test": {
5+
"dependencies": [
6+
"deno-test",
7+
"browser-test"
8+
]
9+
},
10+
"deno-test": "deno test --unstable-raw-imports --allow-env --allow-read test/*.test.ts",
11+
"browser-test": "deno run --allow-env --allow-read --allow-write --allow-run --allow-net ../deno-web-test/cli.ts test/*.test.ts"
12+
},
13+
"imports": {
14+
"quickjs-emscripten-core": "npm:quickjs-emscripten-core",
15+
"@jitl/quickjs-singlefile-mjs-debug-sync": "npm:@jitl/quickjs-singlefile-mjs-debug-sync"
16+
},
17+
"exports": {
18+
".": "./mod.ts"
19+
}
20+
}

packages/js-sandbox/mod.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { RecipeSandbox } from "./recipe-sandbox.ts";
2+
export type { SandboxConfig, SandboxStats } from "./sandbox/mod.ts";
3+
export * from "./types.ts";
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { QuickJSHandle } from "./sandbox/quick.ts";
2+
import { type JsScript } from "@commontools/js-runtime";
3+
import { GuestMessage, SandboxValue } from "./types.ts";
4+
import { Sandbox, SandboxConfig, SandboxStats } from "./sandbox/mod.ts";
5+
6+
type SandboxState = "unloaded" | "loaded" | "disposed";
7+
8+
// A VM to execute recipe code.
9+
//
10+
// Before usage, `await RecipeSandbox.initialize()`
11+
// must be called at least once.
12+
//
13+
// Each sandbox can load a single recipe compiled with
14+
// `js-runtime`, and then loaded via `sandbox.load(..)`.
15+
// Functions exported by the typescript modules can be
16+
// then be invoked.
17+
export class RecipeSandbox {
18+
#state: SandboxState;
19+
#sandbox: Sandbox;
20+
// Bundle function return value containing
21+
// "main" property, and "exportMap" property.
22+
#exports?: QuickJSHandle;
23+
24+
constructor(config: SandboxConfig = {}) {
25+
this.#sandbox = new Sandbox(config);
26+
this.#state = "unloaded";
27+
}
28+
29+
messages(): GuestMessage[] {
30+
return this.#sandbox.messages();
31+
}
32+
33+
// Invoke function exported by load script.
34+
invoke(
35+
exportFile: string,
36+
exportName: string,
37+
args: SandboxValue[],
38+
): unknown {
39+
if (this.#state !== "loaded" || !this.#exports) {
40+
throw new Error(`Cannot invoke a non-loaded sandbox.`);
41+
}
42+
const vm = this.#sandbox.vm();
43+
using exportMap = vm.getProp(this.#exports, "exportMap");
44+
using fileExports = vm.getProp(exportMap, exportFile);
45+
using fn = vm.getProp(fileExports, exportName);
46+
47+
return this.#sandbox.invoke(fn, vm.undefined, args);
48+
}
49+
50+
// Load and evaluate a compiled recipe script within the sandbox.
51+
load(module: JsScript): unknown {
52+
if (this.#state !== "unloaded") {
53+
throw new Error(`Cannot load script in ${this.#state} sandbox.`);
54+
}
55+
const vm = this.#sandbox.vm();
56+
using result = this.#sandbox.loadRaw(module);
57+
this.#exports = this.#sandbox.invokeRaw(result, vm.undefined, [{}]);
58+
using main = vm.getProp(this.#exports, "main");
59+
this.#state = "loaded";
60+
return this.#sandbox.fromVm(main);
61+
}
62+
63+
stats(): SandboxStats {
64+
return this.#sandbox.stats();
65+
}
66+
67+
[Symbol.dispose]() {
68+
this.dispose();
69+
}
70+
71+
dispose() {
72+
if (this.#state === "disposed") {
73+
return;
74+
}
75+
this.#state = "disposed";
76+
if (this.#exports) this.#exports.dispose();
77+
this.#sandbox.dispose();
78+
}
79+
80+
static async initialize() {
81+
await Sandbox.initialize();
82+
}
83+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { GuestMessage, isGuestMessage, SandboxValue } from "../../types.ts";
2+
import { QuickJSContext, QuickJSHandle } from "../quick.ts";
3+
import Script_00_Primordials from "./environment/00_primordials.js" with {
4+
type: "text",
5+
};
6+
import Script_01_Console from "./environment/01_console.js" with {
7+
type: "text",
8+
};
9+
import { fromVm, toVm } from "./encoding.ts";
10+
import { Primordials } from "./primordials.ts";
11+
12+
// Order of scripts to be injected. Primordials
13+
// must be registered first, as they're used as args
14+
// for later injected scripts.
15+
const injectScripts = [
16+
Script_01_Console,
17+
];
18+
19+
export class Bindings {
20+
#vm: QuickJSContext;
21+
#primordials: Primordials;
22+
#messages: GuestMessage[] = [];
23+
24+
constructor(vm: QuickJSContext) {
25+
this.#vm = vm;
26+
27+
// Create IPC
28+
using ipc = vm.newObject();
29+
using sendHandle = vm.newFunction("send", (data) => {
30+
this.#onGuestMessage(this.fromVm(data));
31+
});
32+
vm.setProp(ipc, "send", sendHandle);
33+
vm.setProp(vm.global, "__ipc", ipc);
34+
35+
// Register primordials
36+
this.#primordials = new Primordials(
37+
vm,
38+
vm.unwrapResult(
39+
vm.evalCode(Script_00_Primordials),
40+
),
41+
);
42+
43+
// Register other scripts
44+
for (const script of injectScripts) {
45+
using handle = vm.unwrapResult(
46+
vm.evalCode(script),
47+
);
48+
vm.unwrapResult(
49+
vm.callFunction(handle, vm.null, [this.#primordials.handle()]),
50+
)
51+
.dispose();
52+
}
53+
}
54+
55+
primordials(): Primordials {
56+
return this.#primordials;
57+
}
58+
59+
vm(): QuickJSContext {
60+
return this.#vm;
61+
}
62+
63+
drainMessages(): GuestMessage[] {
64+
const messages = [...this.#messages];
65+
this.#messages.length = 0;
66+
return messages;
67+
}
68+
69+
// Cast a value into the VM, returning an owned handle.
70+
toVm(value: SandboxValue): QuickJSHandle {
71+
return toVm(this, value);
72+
}
73+
74+
// Cast a value from the VM.
75+
fromVm(value: QuickJSHandle): SandboxValue {
76+
return fromVm(this, value);
77+
}
78+
79+
#onGuestMessage = (message: unknown) => {
80+
if (!isGuestMessage(message)) {
81+
let formatted;
82+
try {
83+
formatted = JSON.stringify(message);
84+
} catch (e) {
85+
if (
86+
message && typeof message === "object" && "toString" in message &&
87+
typeof message.toString === "function"
88+
) {
89+
formatted = message.toString() as string;
90+
} else {
91+
formatted = message;
92+
}
93+
}
94+
this.#messages.push({
95+
type: "error",
96+
error: `Received invalid message: ${formatted}`,
97+
});
98+
return;
99+
}
100+
this.#messages.push(message);
101+
};
102+
103+
[Symbol.dispose]() {
104+
this.dispose();
105+
}
106+
107+
dispose() {
108+
this.#primordials.dispose();
109+
}
110+
}

0 commit comments

Comments
 (0)