@tinysandbox/js-runtime 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -43,6 +43,40 @@ const result = engine.runCode("console.log(context.value(null))", {
43
43
  });
44
44
  ```
45
45
 
46
+ ## Browser playground
47
+
48
+ The package includes a minimal two-pane
49
+ [browser example](examples/browser/index.html) that displays guest console
50
+ output, the evaluated script's return value, exit status, elapsed time, and
51
+ initial/peak wasm linear memory. It deliberately ends with an expression so
52
+ the example can capture that value through a synchronous custom global; the
53
+ browser never evaluates the source itself.
54
+
55
+ From this directory in either a source checkout or the unpacked package:
56
+
57
+ ```sh
58
+ npm run example:browser
59
+ ```
60
+
61
+ Then open `http://127.0.0.1:4173/`. The default script prints that `window` and
62
+ `document` are unavailable inside the guest. Each click creates the same fresh,
63
+ bounded QuickJS/WASM instance as a direct `runCode()` call.
64
+
65
+ ### Cloudflare Pages
66
+
67
+ The site is entirely static. In a Cloudflare Pages Git integration, use:
68
+
69
+ | Setting | Value |
70
+ | --- | --- |
71
+ | Framework preset | `None` |
72
+ | Root directory | `tinysandbox-js-runtime` |
73
+ | Build command | `npm run build:site` |
74
+ | Build output directory | `site-dist` |
75
+
76
+ No environment variables, Pages Functions, Wrangler configuration, or separate
77
+ deployment workflow are required. `build:site` creates a clean output directory
78
+ containing only the playground, `runtime.js`, and `quickjs.wasm`.
79
+
46
80
  `runCode(code, options)` returns `exitCode`, UTF-8 `stdout` and `stderr`, and
47
81
  initial/peak wasm memory bytes. Its defaults are a 64 MiB wasm maximum, 32 MiB
48
82
  QuickJS heap, 30 second monotonic deadline, and 1 MiB each for source, serialized
@@ -0,0 +1,360 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>tinysandbox · QuickJS/WASM playground</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: dark;
10
+ --background: #10110f;
11
+ --panel: #161714;
12
+ --text: #e8eadf;
13
+ --muted: #92968a;
14
+ --line: #34372f;
15
+ --accent: #c4f36b;
16
+ --error: #ff8b7d;
17
+ }
18
+
19
+ * { box-sizing: border-box; }
20
+
21
+ body {
22
+ margin: 0;
23
+ min-height: 100vh;
24
+ background: var(--background);
25
+ color: var(--text);
26
+ font: 14px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
27
+ }
28
+
29
+ main {
30
+ width: min(1180px, 100%);
31
+ margin: 0 auto;
32
+ padding: 28px;
33
+ }
34
+
35
+ header {
36
+ display: flex;
37
+ align-items: flex-start;
38
+ justify-content: space-between;
39
+ gap: 24px;
40
+ margin-bottom: 24px;
41
+ }
42
+
43
+ h1 {
44
+ margin: 0 0 6px;
45
+ font-size: clamp(18px, 3vw, 24px);
46
+ font-weight: 600;
47
+ letter-spacing: -0.04em;
48
+ }
49
+
50
+ p {
51
+ max-width: 760px;
52
+ margin: 0;
53
+ color: var(--muted);
54
+ }
55
+
56
+ .header-meta {
57
+ display: flex;
58
+ align-items: stretch;
59
+ gap: 8px;
60
+ flex: none;
61
+ }
62
+
63
+ .runtime,
64
+ .repository {
65
+ border: 1px solid var(--line);
66
+ padding: 8px 10px;
67
+ font-size: 11px;
68
+ letter-spacing: 0.08em;
69
+ text-transform: uppercase;
70
+ }
71
+
72
+ .runtime { color: var(--accent); }
73
+
74
+ .repository {
75
+ display: flex;
76
+ align-items: center;
77
+ color: var(--text);
78
+ text-decoration: none;
79
+ }
80
+
81
+ .repository:hover,
82
+ .repository:focus-visible {
83
+ border-color: var(--accent);
84
+ color: var(--accent);
85
+ outline: none;
86
+ }
87
+
88
+ .workspace {
89
+ display: grid;
90
+ grid-template-columns: 1fr 1fr;
91
+ border: 1px solid var(--line);
92
+ background: var(--panel);
93
+ }
94
+
95
+ section + section { border-left: 1px solid var(--line); }
96
+
97
+ .pane-header {
98
+ display: flex;
99
+ min-height: 45px;
100
+ align-items: center;
101
+ justify-content: space-between;
102
+ gap: 16px;
103
+ padding: 8px 12px;
104
+ border-bottom: 1px solid var(--line);
105
+ color: var(--muted);
106
+ font-size: 11px;
107
+ letter-spacing: 0.08em;
108
+ text-transform: uppercase;
109
+ }
110
+
111
+ .actions { display: flex; gap: 8px; }
112
+
113
+ button {
114
+ border: 1px solid var(--line);
115
+ border-radius: 0;
116
+ padding: 6px 10px;
117
+ background: transparent;
118
+ color: var(--text);
119
+ font: inherit;
120
+ font-size: 11px;
121
+ text-transform: uppercase;
122
+ cursor: pointer;
123
+ }
124
+
125
+ button:hover:not(:disabled), button:focus-visible {
126
+ border-color: var(--accent);
127
+ color: var(--accent);
128
+ outline: none;
129
+ }
130
+
131
+ button:disabled { cursor: wait; opacity: 0.45; }
132
+ button.primary { background: var(--accent); color: #171a11; border-color: var(--accent); }
133
+ button.primary:hover:not(:disabled), button.primary:focus-visible { color: #171a11; filter: brightness(1.08); }
134
+
135
+ textarea {
136
+ display: block;
137
+ width: 100%;
138
+ height: min(62vh, 620px);
139
+ min-height: 360px;
140
+ resize: vertical;
141
+ border: 0;
142
+ border-radius: 0;
143
+ padding: 18px;
144
+ background: var(--panel);
145
+ color: var(--text);
146
+ font: inherit;
147
+ line-height: 1.65;
148
+ tab-size: 2;
149
+ outline: none;
150
+ }
151
+
152
+ textarea:focus { box-shadow: inset 0 0 0 1px var(--accent); }
153
+ textarea[readonly] { color: #c9ccbf; }
154
+
155
+ footer {
156
+ display: flex;
157
+ flex-wrap: wrap;
158
+ gap: 8px 18px;
159
+ padding: 10px 12px;
160
+ border: 1px solid var(--line);
161
+ border-top: 0;
162
+ color: var(--muted);
163
+ font-size: 11px;
164
+ letter-spacing: 0.04em;
165
+ text-transform: uppercase;
166
+ }
167
+
168
+ #exit[data-state="ok"] { color: var(--accent); }
169
+ #exit[data-state="error"] { color: var(--error); }
170
+ kbd { color: var(--text); font: inherit; }
171
+
172
+ @media (max-width: 760px) {
173
+ main { padding: 18px; }
174
+ header { display: block; }
175
+ .header-meta { display: inline-flex; margin-top: 16px; }
176
+ .workspace { grid-template-columns: 1fr; }
177
+ section + section { border-left: 0; border-top: 1px solid var(--line); }
178
+ textarea { height: 42vh; min-height: 280px; }
179
+ }
180
+ </style>
181
+ </head>
182
+ <body data-runtime="quickjs-wasm">
183
+ <main>
184
+ <header>
185
+ <div>
186
+ <h1>tinysandbox / js</h1>
187
+ <p>Source runs in a fresh QuickJS instance inside WebAssembly. Browser globals, DOM, filesystem, and network capabilities are not exposed.</p>
188
+ </div>
189
+ <div class="header-meta">
190
+ <div class="runtime">host: V8 · guest: QuickJS/WASM</div>
191
+ <a class="repository" href="https://github.com/danthegoodman1/tinysandbox" target="_blank" rel="noreferrer">GitHub ↗</a>
192
+ </div>
193
+ </header>
194
+
195
+ <div class="workspace">
196
+ <section>
197
+ <div class="pane-header">
198
+ <span>input.js</span>
199
+ <div class="actions">
200
+ <button id="reset" type="button">reset</button>
201
+ <button class="primary" id="run" type="button" disabled>run</button>
202
+ </div>
203
+ </div>
204
+ <textarea id="source" aria-label="JavaScript source" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
205
+ </section>
206
+
207
+ <section>
208
+ <div class="pane-header">
209
+ <span>output</span>
210
+ <span>console + return value</span>
211
+ </div>
212
+ <textarea id="output" aria-label="Execution output" readonly>Loading QuickJS WebAssembly…</textarea>
213
+ </section>
214
+ </div>
215
+
216
+ <footer aria-live="polite">
217
+ <span id="exit">loading wasm</span>
218
+ <span id="elapsed">time —</span>
219
+ <span id="memory">wasm peak —</span>
220
+ <span>limit 16 MiB</span>
221
+ <span><kbd>⌘/ctrl + enter</kbd> to run</span>
222
+ </footer>
223
+ <pre id="result" hidden>RUNNING</pre>
224
+ </main>
225
+
226
+ <script type="module">
227
+ import { createEngine } from "../../dist/index.js";
228
+
229
+ const MEMORY_LIMIT_BYTES = 16 * 1024 * 1024;
230
+ const DEFAULT_SOURCE = `console.log("hello from QuickJS");
231
+ console.log("window:", typeof window);
232
+ console.log("document:", typeof document);
233
+
234
+ ({ answer: 6 * 7, engine: "quickjs-wasm" });`;
235
+
236
+ const source = document.querySelector("#source");
237
+ const output = document.querySelector("#output");
238
+ const runButton = document.querySelector("#run");
239
+ const resetButton = document.querySelector("#reset");
240
+ const exit = document.querySelector("#exit");
241
+ const elapsed = document.querySelector("#elapsed");
242
+ const memory = document.querySelector("#memory");
243
+ const smoke = document.querySelector("#result");
244
+ source.value = DEFAULT_SOURCE;
245
+
246
+ let engine;
247
+ let firstRun = true;
248
+
249
+ function formatBytes(bytes) {
250
+ return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
251
+ }
252
+
253
+ function guestProgram(userSource) {
254
+ // The browser only constructs this string. QuickJS evaluates it inside
255
+ // the wasm instance; browser eval() and Function() are never used.
256
+ return `(function () {
257
+ const capture = playground.capture;
258
+ const value = (0, eval)(${JSON.stringify(userSource)});
259
+ const type = value === null ? "null" : typeof value;
260
+ let text;
261
+ if (type === "undefined") text = "undefined";
262
+ else if (type === "bigint") text = String(value) + "n";
263
+ else if (type === "function" || type === "symbol") text = String(value);
264
+ else {
265
+ try {
266
+ text = JSON.stringify(value, function (_key, item) {
267
+ return typeof item === "bigint" ? String(item) + "n" : item;
268
+ }, 2);
269
+ } catch (_error) {
270
+ text = String(value);
271
+ }
272
+ if (text === undefined) text = String(value);
273
+ }
274
+ capture({ type: type, text: text });
275
+ })()`;
276
+ }
277
+
278
+ function renderResult(result, returned, durationMs) {
279
+ const sections = [];
280
+ if (result.stdout) sections.push(`stdout\n${result.stdout.trimEnd()}`);
281
+ if (result.stderr) sections.push(`stderr\n${result.stderr.trimEnd()}`);
282
+ sections.push(returned ? `return (${returned.type})\n${returned.text}` : "return\n<not reached>");
283
+ output.value = sections.join("\n\n");
284
+
285
+ exit.textContent = `exit ${result.exitCode}`;
286
+ exit.dataset.state = result.exitCode === 0 ? "ok" : "error";
287
+ elapsed.textContent = `time ${durationMs.toFixed(1)} ms`;
288
+ memory.textContent = `wasm peak ${formatBytes(result.peakWasmMemoryBytes)} · initial ${formatBytes(result.initialWasmMemoryBytes)}`;
289
+
290
+ if (firstRun) {
291
+ firstRun = false;
292
+ smoke.textContent = result.exitCode === 0 &&
293
+ result.stdout.includes("hello from QuickJS") &&
294
+ result.stdout.includes("window: undefined") &&
295
+ returned?.text.includes('"answer": 42')
296
+ ? "PASS"
297
+ : "FAIL";
298
+ }
299
+ }
300
+
301
+ async function run() {
302
+ if (!engine) return;
303
+ runButton.disabled = true;
304
+ output.value = "Running inside QuickJS/WASM…";
305
+ exit.textContent = "running";
306
+ exit.dataset.state = "";
307
+ await new Promise(resolve => setTimeout(resolve, 0));
308
+
309
+ let returned;
310
+ const started = performance.now();
311
+ try {
312
+ const result = engine.runCode(guestProgram(source.value), {
313
+ globals: {
314
+ "playground.capture": value => {
315
+ returned = value;
316
+ return null;
317
+ },
318
+ },
319
+ wasmMemoryBytes: MEMORY_LIMIT_BYTES,
320
+ quickjsHeapBytes: 8 * 1024 * 1024,
321
+ timeoutMs: 1000,
322
+ });
323
+ renderResult(result, returned, performance.now() - started);
324
+ } catch (error) {
325
+ output.value = `host error\n${error instanceof Error ? error.stack : String(error)}`;
326
+ exit.textContent = "host error";
327
+ exit.dataset.state = "error";
328
+ elapsed.textContent = `time ${(performance.now() - started).toFixed(1)} ms`;
329
+ } finally {
330
+ runButton.disabled = false;
331
+ }
332
+ }
333
+
334
+ runButton.addEventListener("click", run);
335
+ resetButton.addEventListener("click", () => {
336
+ source.value = DEFAULT_SOURCE;
337
+ run();
338
+ });
339
+ source.addEventListener("keydown", event => {
340
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
341
+ event.preventDefault();
342
+ run();
343
+ }
344
+ });
345
+
346
+ try {
347
+ const response = await fetch("../../quickjs.wasm");
348
+ if (!response.ok) throw new Error(`could not load quickjs.wasm (${response.status})`);
349
+ engine = await createEngine(await response.arrayBuffer());
350
+ runButton.disabled = false;
351
+ await run();
352
+ } catch (error) {
353
+ output.value = `initialization error\n${error instanceof Error ? error.stack : String(error)}`;
354
+ exit.textContent = "load error";
355
+ exit.dataset.state = "error";
356
+ smoke.textContent = "FAIL";
357
+ }
358
+ </script>
359
+ </body>
360
+ </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinysandbox/js-runtime",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Stateless QuickJS runtime for standard WebAssembly hosts",
5
5
  "type": "module",
6
6
  "exports": {
@@ -12,10 +12,13 @@
12
12
  },
13
13
  "files": [
14
14
  "dist/",
15
+ "examples/browser/",
15
16
  "quickjs.wasm",
16
17
  "README.md",
17
18
  "LICENSE-APACHE",
18
- "LICENSE-MIT"
19
+ "LICENSE-MIT",
20
+ "scripts/build-browser-site.mjs",
21
+ "scripts/serve-browser-example.mjs"
19
22
  ],
20
23
  "sideEffects": false,
21
24
  "engines": {
@@ -23,6 +26,8 @@
23
26
  },
24
27
  "scripts": {
25
28
  "build": "node scripts/build.mjs",
29
+ "build:site": "node scripts/build-browser-site.mjs",
30
+ "example:browser": "node scripts/serve-browser-example.mjs",
26
31
  "test": "npm run build && npm run typecheck && node --test test/*.test.mjs",
27
32
  "typecheck": "tsc -p tsconfig.json",
28
33
  "test:browser": "node test/browser-smoke.mjs",
@@ -0,0 +1,24 @@
1
+ import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+
3
+ const output = new URL("../site-dist/", import.meta.url);
4
+ const source = new URL("../examples/browser/index.html", import.meta.url);
5
+ const runtime = new URL("../dist/index.js", import.meta.url);
6
+ const wasm = new URL("../quickjs.wasm", import.meta.url);
7
+
8
+ let html = await readFile(source, "utf8");
9
+ const replacements = [
10
+ ["../../dist/index.js", "./runtime.js"],
11
+ ["../../quickjs.wasm", "./quickjs.wasm"],
12
+ ];
13
+ for (const [from, to] of replacements) {
14
+ if (!html.includes(from)) throw new Error(`browser example is missing expected asset path ${from}`);
15
+ html = html.replaceAll(from, to);
16
+ }
17
+
18
+ await rm(output, { recursive: true, force: true });
19
+ await mkdir(output, { recursive: true });
20
+ await writeFile(new URL("index.html", output), html);
21
+ await copyFile(runtime, new URL("runtime.js", output));
22
+ await copyFile(wasm, new URL("quickjs.wasm", output));
23
+
24
+ console.log("Cloudflare Pages site built in site-dist/");
@@ -0,0 +1,40 @@
1
+ import { createServer } from "node:http";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, extname, resolve, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
7
+ const port = Number(process.env.PORT ?? 4173);
8
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
9
+ throw new RangeError("PORT must be an integer between 1 and 65535");
10
+ }
11
+
12
+ const contentTypes = new Map([
13
+ [".html", "text/html; charset=utf-8"],
14
+ [".js", "text/javascript; charset=utf-8"],
15
+ [".mjs", "text/javascript; charset=utf-8"],
16
+ [".wasm", "application/wasm"],
17
+ ]);
18
+
19
+ const server = createServer(async (request, response) => {
20
+ try {
21
+ const url = new URL(request.url ?? "/", "http://localhost");
22
+ const relative = url.pathname === "/"
23
+ ? "examples/browser/index.html"
24
+ : decodeURIComponent(url.pathname).replace(/^\/+/, "");
25
+ const path = resolve(packageRoot, relative);
26
+ if (path !== packageRoot && !path.startsWith(`${packageRoot}${sep}`)) {
27
+ throw new Error("path is outside the package root");
28
+ }
29
+ const body = await readFile(path);
30
+ response.setHeader("content-type", contentTypes.get(extname(path)) ?? "application/octet-stream");
31
+ response.end(body);
32
+ } catch (error) {
33
+ response.statusCode = 404;
34
+ response.end(error instanceof Error ? error.message : String(error));
35
+ }
36
+ });
37
+
38
+ server.listen(port, "127.0.0.1", () => {
39
+ console.log(`QuickJS/WASM playground: http://127.0.0.1:${port}/`);
40
+ });