@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 +34 -0
- package/examples/browser/index.html +360 -0
- package/package.json +7 -2
- package/scripts/build-browser-site.mjs +24 -0
- package/scripts/serve-browser-example.mjs +40 -0
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.
|
|
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
|
+
});
|