sandboxedjs 0.1.30 → 0.1.32

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
@@ -134,97 +134,37 @@ await bridge.close();
134
134
 
135
135
  ### Showing a live preview in an IDE
136
136
 
137
- This is the StackBlitz/CodeSandbox preview pane: the user runs a dev server inside the
138
- container, and sees the rendered page, not just logs.
137
+ A container's port is virtual. Its printed `http://localhost:5173` is not automatically
138
+ reachable from your browser. Render a whole site in an iframe using the bridge for your host:
139
139
 
140
- **It works today, as an `<iframe>` not as a `<div>`.** A `<div>` cannot host it: the preview
141
- is a whole HTML document with its own scripts, styles, `<base>` and routing, and it must not be
142
- able to reach into your IDE's DOM. An iframe on its own origin is exactly the isolation you
143
- want, and it is what StackBlitz and CodeSandbox use too.
140
+ | Container host | API | Browser-facing URL |
141
+ | --- | --- | --- |
142
+ | Browser only | `createPreview(box)` | A service-worker URL from `preview.urlFor(port)` |
143
+ | Node.js | `box.expose(port)` | A real loopback HTTP URL from `bridge.url` |
144
144
 
145
- `expose(port)` gives you a real, browser-loadable URL:
145
+ Browser-only example, after starting a server in `box`:
146
146
 
147
147
  ```ts
148
- box.spawn("npm run dev", { cwd: "/app" });
149
- await box.waitForPort(5173);
150
-
151
- const preview = await box.expose(5173);
152
- document.querySelector("#preview").src = preview.url; // an <iframe>
153
- ```
154
-
155
- A minimal IDE preview pane, in React:
156
-
157
- ```tsx
158
- function PreviewPane({ box, port }) {
159
- const [url, setUrl] = useState(null);
160
- const frame = useRef(null);
161
-
162
- useEffect(() => {
163
- let bridge;
164
- let cancelled = false;
165
-
166
- (async () => {
167
- if (!(await box.waitForPort(port, { timeoutMs: 60_000 }))) return;
168
- bridge = await box.expose(port);
169
- if (!cancelled) setUrl(bridge.url);
170
- })();
171
-
172
- return () => {
173
- cancelled = true;
174
- void bridge?.close();
175
- };
176
- }, [box, port]);
177
-
178
- // Call this after a rebuild to refresh the pane.
179
- const reload = () => {
180
- if (frame.current) frame.current.src = frame.current.src;
181
- };
182
-
183
- if (!url) return <div>starting…</div>;
184
- return <iframe ref={frame} src={url} style={{ width: "100%", height: "100%", border: 0 }} />;
148
+ import { createPreview } from 'sandboxedjs';
149
+ if (!(await box.waitForPort(5173, { timeoutMs: 60_000 }))) {
150
+ throw new Error('Server did not start; inspect its logs');
185
151
  }
152
+ const preview = await createPreview(box);
153
+ if (!preview) throw new Error('Secure context and service-worker support required');
154
+ iframe.src = preview.urlFor(5173);
155
+ // On teardown: remove the iframe, await preview.dispose(), then dispose the container.
186
156
  ```
187
157
 
188
- Pin the port if you want a stable URL across restarts:
189
-
190
- ```ts
191
- const preview = await box.expose(5173, { hostPort: 5173 }); // http://127.0.0.1:5173
192
- ```
193
-
194
- Serve several ports by exposing each one; every call gets its own host port.
195
-
196
- #### Refreshing on change
197
-
198
- **`expose()` proxies HTTP, not WebSockets.** A dev server's hot-reload channel is a WebSocket,
199
- so HMR and live-reload overlays will not reach the iframe. Drive the refresh from your IDE
200
- instead — which you need to do anyway if you are compiling on save:
201
-
202
- ```ts
203
- // after writing the user's edit and rebuilding
204
- await box.fs.writeFile("/app/src/App.jsx", nextSource);
205
- await box.exec("npm run build", { cwd: "/app" });
206
- frame.current.src = frame.current.src; // reload the pane
207
- ```
208
-
209
- That is a full reload rather than hot module replacement: state in the page is lost. For most
210
- IDE previews that is acceptable; if you need true HMR, the WebSocket proxy is the missing piece.
158
+ The service worker routes that iframe's HTML, scripts, styles and API requests into the
159
+ container without rewriting the site's asset paths. Keep its owner page alive.
211
160
 
212
- #### What the IDE can and cannot do with the frame
161
+ **Trust and scope:** this browser preview runs on the host page's origin. It is for trusted
162
+ code, not a security boundary for arbitrary projects. Both preview bridges handle HTTP;
163
+ WebSocket HMR is not implemented, so reload the iframe after changes.
213
164
 
214
- The iframe is cross-origin, so your IDE cannot read its DOM. You can still:
215
-
216
- - `postMessage` to it, if the app inside cooperates;
217
- - watch `/app/**` through the container to know when to rebuild;
218
- - read the dev server's stdout from `spawn()` for a log pane.
219
-
220
- #### In a pure browser IDE
221
-
222
- `expose()` opens a real `node:http` listener, so it needs a Node host — an Electron app, or a web
223
- IDE with a Node backend. With no backend at all, an iframe needs a real URL to load, and giving
224
- it one means a service worker that intercepts requests and routes them to `request()`. That is
225
- not built yet; see [Running in a browser](#running-in-a-browser). Until it is, you can still
226
- drive an in-container server programmatically through `request()` and render the result
227
- yourself.
165
+ For complete browser and Node examples, IDE lifecycle/port selection, hosting requirements,
166
+ single-response rendering and troubleshooting, see [Server preview integration](docs/server-previews.md).
167
+ For the startup bug investigation and validation, see [Runtime lifecycle fixes](docs/runtime-lifecycle-fixes.md).
228
168
 
229
169
  ### npm and npx
230
170
 
@@ -634,16 +574,21 @@ mentions Rolldown. Excluding the binding leaves it served from `node_modules`, w
634
574
  resolves. The compiler is loaded only when an installed
635
575
  project actually contains Rolldown, so ordinary container boot does not pay its WASM startup cost.
636
576
 
577
+ Host-backed compiler promises now count as active process work. The runtime keeps the guest
578
+ alive while WASM initialization/build operations are pending, rather than guessing a startup
579
+ delay from unreferenced timers. Listening servers remain live until closed, unreferenced, or
580
+ killed. Cancellation releases owned child processes and ports, including children behind
581
+ synchronous `npm`/`npx` wrappers; inherited terminal input bypasses a blocked parent worker.
582
+ These are runtime lifecycle rules, not Vite-specific command replacements.
583
+
637
584
  **Not done — these things still stand between this and a complete browser IDE.**
638
585
 
639
- - *A service worker.* A preview iframe needs a real URL, and giving it one means a worker on your
640
- own origin that intercepts requests and routes them into the container. `request()` works
641
- everywhere and is unaffected; only the iframe needs this. Earlier versions re-exported one from
642
- the package this was built on, at `sandboxedjs/vite` and `sandboxedjs/server`. Both are gone
643
- along with that dependency, and a replacement is not written yet.
644
- - *Worker isolation.* The runtime executes in whichever realm you boot it from, which in a browser
645
- is the main thread — so a long build blocks the UI, and container code can reach page globals.
646
- `comlink` is a dependency in anticipation of this and is not used yet.
586
+ - *Preview isolation and WebSockets.* `createPreview()` registers the bundled service worker
587
+ and routes HTTP requests into the container. It is an origin-local preview for trusted code;
588
+ WebSocket forwarding/HMR is not implemented. Reload the iframe after changes.
589
+ - *Complete worker isolation.* Worker-capable programs run off the main thread, but host-backed
590
+ compiler modules can require the local runtime. Container code is not a security boundary
591
+ from the host page; do not treat it as one.
647
592
  - *esbuild-dependent tools.* The runtime cannot execute any build of esbuild itself: one dlopens a compiled addon,
648
593
  the other drives a Go program through facilities the sandbox does not have. On Node it borrows
649
594
  the host's `esbuild-wasm`. Vite 8's Rolldown path works in a browser, but tools which call
@@ -820,6 +765,51 @@ the `null` return rather than a throw. Responses are served with both
820
765
  `Cross-Origin-Resource-Policy` and `Cross-Origin-Embedder-Policy`, so a preview still frames
821
766
  correctly inside the cross-origin isolated page that Rolldown requires.
822
767
 
768
+ ### Serving a built browser application
769
+
770
+ The host page must enable cross-origin isolation **before** `createContainer()` runs.
771
+ This is a prerequisite for both synchronous child processes and threaded WASM; it is
772
+ not something `npm create`, `npm install`, or `npm run` inside the container can fix.
773
+ Headers configured on a development server do not carry over to a plain static server.
774
+
775
+ SandboxedJs ships a local browser host for built applications:
776
+
777
+ ```sh
778
+ npx sandboxedjs-serve ./dist 4173
779
+ ```
780
+
781
+ It serves the app on `http://127.0.0.1:4173` with COOP/COEP on documents, scripts,
782
+ workers, and WASM. Missing assets return 404 rather than an HTML fallback. It binds
783
+ to loopback by default and does not serve dotfiles or symlinks outside the web root.
784
+ This is a local static host, not a production deployment server or SPA route rewriter.
785
+
786
+ For a Node host integration (this subpath must not be imported into browser code):
787
+
788
+ ```ts
789
+ import { serveBrowserApp, browserIsolationHeaders } from 'sandboxedjs/browser-host';
790
+ const host = await serveBrowserApp({ directory: './dist', port: 4173 });
791
+ console.log(host.url);
792
+ // Or apply browserIsolationHeaders to your existing server's responses.
793
+ // await host.close();
794
+ ```
795
+
796
+ For remote deployments configure HTTPS plus those headers on your actual web server
797
+ or CDN. Check `crossOriginIsolated` in the page, not just the network response: iframe
798
+ permissions and embedded browser restrictions can still deny shared memory. A loaded
799
+ page cannot grant itself this permission with a polyfill or a meta tag. Cross-origin
800
+ resources also need CORS or an appropriate CORP policy under `require-corp`.
801
+
802
+ Require worker support at boot when the application depends on synchronous Node APIs:
803
+
804
+ ```ts
805
+ const box = await createContainer({ isolation: 'worker' });
806
+ ```
807
+
808
+ This rejects with the missing prerequisite or worker-load cause before commands run.
809
+ The default `isolation: 'auto'` retains compatibility but reports fallback through
810
+ `onRuntimeFallback(error)` (or `console.warn` if omitted). `isolation: 'realm'` is an
811
+ explicit opt-out and does not support synchronous child processes.
812
+
823
813
  ## Isolation
824
814
 
825
815
  By default each guest program runs on its own thread, in a Worker. The volume, the kernel, the
@@ -832,7 +822,7 @@ they cannot be expressed any other way, because a synchronous call has to block
832
822
  the child it is waiting for still makes progress, which is impossible when both are the same
833
823
  thread.
834
824
 
835
- It is chosen automatically and falls back on its own, so nothing that runs today stops running:
825
+ In default `auto` mode the runtime reports its fallback reason and stays usable:
836
826
 
837
827
  | Condition | What happens |
838
828
  |---|---|
@@ -843,7 +833,10 @@ It is chosen automatically and falls back on its own, so nothing that runs today
843
833
 
844
834
  In a browser this needs the same COOP/COEP headers Rolldown does, which is the usual reason to
845
835
  find yourself in the fallback. `createContainer({ isolation: "realm" })` opts out entirely, and
846
- `workerUrl` points at the guest bundle when a bundler has moved it.
836
+ `workerUrl` points at the guest bundle when a bundler has moved it. Explicit
837
+ `isolation: "worker"` rejects at boot instead of taking either environment fallback.
838
+ Host-native module processes still use the realm runtime; selecting worker mode is
839
+ not a guarantee that every package executes off the host thread.
847
840
 
848
841
  ## Known limits
849
842
 
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { serveBrowserApp } from "../dist/browser-host.js";
3
+
4
+ const args = process.argv.slice(2);
5
+ if (args.includes("--help") || args.includes("-h")) {
6
+ console.log("Usage: sandboxedjs-serve [directory=dist] [port=4173]\nServes a built browser app on loopback with the headers required by SandboxedJs workers and WASM.");
7
+ } else {
8
+ const port = Number(args[1] ?? 4173);
9
+ if (args.length > 2 || !Number.isInteger(port) || port < 0 || port > 65535) {
10
+ console.error("Usage: sandboxedjs-serve [directory=dist] [port=4173]");
11
+ process.exitCode = 1;
12
+ } else {
13
+ try {
14
+ const host = await serveBrowserApp({ directory: args[0] ?? "dist", port });
15
+ console.log(`SandboxedJs browser host: ${host.url}`);
16
+ const stop = () => { void host.close().then(() => process.exit(0)); };
17
+ process.once("SIGINT", stop);
18
+ process.once("SIGTERM", stop);
19
+ } catch (error) {
20
+ console.error(`sandboxedjs-serve: ${error.message}`);
21
+ process.exitCode = 1;
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+
3
+ var http = require('http');
4
+ var promises = require('fs/promises');
5
+ var path = require('path');
6
+
7
+ // src/hosting/browser-host.ts
8
+ var browserIsolationHeaders = Object.freeze({
9
+ "Cross-Origin-Opener-Policy": "same-origin",
10
+ "Cross-Origin-Embedder-Policy": "require-corp"
11
+ });
12
+ var mime = {
13
+ ".html": "text/html; charset=utf-8",
14
+ ".js": "text/javascript; charset=utf-8",
15
+ ".mjs": "text/javascript; charset=utf-8",
16
+ ".css": "text/css; charset=utf-8",
17
+ ".json": "application/json",
18
+ ".wasm": "application/wasm",
19
+ ".svg": "image/svg+xml",
20
+ ".png": "image/png",
21
+ ".jpg": "image/jpeg",
22
+ ".jpeg": "image/jpeg",
23
+ ".ico": "image/x-icon",
24
+ ".webp": "image/webp",
25
+ ".woff": "font/woff",
26
+ ".woff2": "font/woff2",
27
+ ".txt": "text/plain; charset=utf-8"
28
+ };
29
+ async function serveBrowserApp(options) {
30
+ const root = await promises.realpath(path.resolve(options.directory));
31
+ if (!(await promises.stat(root)).isDirectory()) throw new Error("Browser app directory is not a directory");
32
+ const inside = (path$1) => {
33
+ const rel = path.relative(root, path$1);
34
+ return rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
35
+ };
36
+ const handle = async (req, res) => {
37
+ for (const [name, value] of Object.entries(browserIsolationHeaders)) res.setHeader(name, value);
38
+ res.setHeader("X-Content-Type-Options", "nosniff");
39
+ res.setHeader("Cache-Control", "no-cache");
40
+ const reply = (status, body) => {
41
+ res.statusCode = status;
42
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
43
+ res.end(req.method === "HEAD" ? void 0 : body);
44
+ };
45
+ if (req.method !== "GET" && req.method !== "HEAD") {
46
+ res.setHeader("Allow", "GET, HEAD");
47
+ reply(405, "Method not allowed");
48
+ return;
49
+ }
50
+ let pathname;
51
+ try {
52
+ pathname = decodeURIComponent((req.url ?? "/").split("?")[0]);
53
+ } catch {
54
+ reply(400, "Invalid URL");
55
+ return;
56
+ }
57
+ if (pathname.includes("\0") || pathname.includes("\\") || pathname.split("/").some((p) => p.startsWith("."))) {
58
+ reply(403, "Forbidden");
59
+ return;
60
+ }
61
+ let candidate = path.resolve(root, `.${pathname.startsWith("/") ? pathname : `/${pathname}`}`);
62
+ try {
63
+ if (!inside(candidate)) {
64
+ reply(403, "Forbidden");
65
+ return;
66
+ }
67
+ if ((await promises.stat(candidate)).isDirectory()) candidate = path.resolve(candidate, "index.html");
68
+ candidate = await promises.realpath(candidate);
69
+ if (!inside(candidate)) {
70
+ reply(403, "Forbidden");
71
+ return;
72
+ }
73
+ if (!(await promises.stat(candidate)).isFile()) {
74
+ reply(404, "Not found");
75
+ return;
76
+ }
77
+ const body = await promises.readFile(candidate);
78
+ res.setHeader("Content-Type", mime[path.extname(candidate)] ?? "application/octet-stream");
79
+ res.setHeader("Content-Length", body.byteLength);
80
+ res.end(req.method === "HEAD" ? void 0 : body);
81
+ } catch (error) {
82
+ const code = error.code;
83
+ reply(code === "ENOENT" || code === "ENOTDIR" ? 404 : 500, "Unable to serve file");
84
+ }
85
+ };
86
+ const server = http.createServer((req, res) => {
87
+ void handle(req, res);
88
+ });
89
+ const hostname = options.hostname ?? "127.0.0.1";
90
+ await new Promise((done, reject) => {
91
+ server.once("error", reject);
92
+ server.listen(options.port ?? 4173, hostname, () => {
93
+ server.off("error", reject);
94
+ done();
95
+ });
96
+ });
97
+ const address = server.address();
98
+ if (!address || typeof address === "string") throw new Error("Browser host has no TCP address");
99
+ return {
100
+ server,
101
+ url: `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${address.port}`,
102
+ close: () => new Promise((done, reject) => {
103
+ server.close((error) => error ? reject(error) : done());
104
+ server.closeIdleConnections();
105
+ })
106
+ };
107
+ }
108
+
109
+ exports.browserIsolationHeaders = browserIsolationHeaders;
110
+ exports.serveBrowserApp = serveBrowserApp;
111
+ //# sourceMappingURL=browser-host.cjs.map
112
+ //# sourceMappingURL=browser-host.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hosting/browser-host.ts"],"names":["realpath","resolve","stat","path","relative","sep","isAbsolute","readFile","extname","createServer"],"mappings":"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","file":"browser-host.cjs","sourcesContent":["/** Node-side hosting for a built SandboxedJs browser application. */\nimport { createServer, type Server, type IncomingMessage, type ServerResponse } from \"node:http\";\nimport { readFile, realpath, stat } from \"node:fs/promises\";\nimport { extname, isAbsolute, relative, resolve, sep } from \"node:path\";\n\n/** These must be sent on the host document, not just the guest's HTTP responses. */\nexport const browserIsolationHeaders = Object.freeze({\n \"Cross-Origin-Opener-Policy\": \"same-origin\",\n \"Cross-Origin-Embedder-Policy\": \"require-corp\",\n});\n\nconst mime: Record<string, string> = {\n \".html\": \"text/html; charset=utf-8\", \".js\": \"text/javascript; charset=utf-8\",\n \".mjs\": \"text/javascript; charset=utf-8\", \".css\": \"text/css; charset=utf-8\",\n \".json\": \"application/json\", \".wasm\": \"application/wasm\",\n \".svg\": \"image/svg+xml\", \".png\": \"image/png\", \".jpg\": \"image/jpeg\",\n \".jpeg\": \"image/jpeg\", \".ico\": \"image/x-icon\", \".webp\": \"image/webp\",\n \".woff\": \"font/woff\", \".woff2\": \"font/woff2\", \".txt\": \"text/plain; charset=utf-8\",\n};\n\n/**\n * Serve a built host application with the prerequisites for guest workers and\n * threaded WASM. Loopback-only by default; use HTTPS at a reverse proxy for\n * remote access. An arbitrary HTTP origin is not a secure browser context.\n */\nexport async function serveBrowserApp(options: {\n directory: string;\n port?: number;\n hostname?: string;\n}): Promise<{ server: Server; url: string; close(): Promise<void> }> {\n const root = await realpath(resolve(options.directory));\n if (!(await stat(root)).isDirectory()) throw new Error(\"Browser app directory is not a directory\");\n const inside = (path: string) => {\n const rel = relative(root, path);\n return rel !== \"..\" && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);\n };\n const handle = async (req: IncomingMessage, res: ServerResponse) => {\n for (const [name, value] of Object.entries(browserIsolationHeaders)) res.setHeader(name, value);\n res.setHeader(\"X-Content-Type-Options\", \"nosniff\");\n res.setHeader(\"Cache-Control\", \"no-cache\");\n const reply = (status: number, body: string) => {\n res.statusCode = status;\n res.setHeader(\"Content-Type\", \"text/plain; charset=utf-8\");\n res.end(req.method === \"HEAD\" ? undefined : body);\n };\n if (req.method !== \"GET\" && req.method !== \"HEAD\") {\n res.setHeader(\"Allow\", \"GET, HEAD\");\n reply(405, \"Method not allowed\");\n return;\n }\n let pathname: string;\n try { pathname = decodeURIComponent((req.url ?? \"/\").split(\"?\")[0]!); }\n catch { reply(400, \"Invalid URL\"); return; }\n // Reject traversal, hidden files, and platform-dependent separators before resolving.\n if (pathname.includes(\"\\0\") || pathname.includes(\"\\\\\") || pathname.split(\"/\").some(p => p.startsWith(\".\"))) {\n reply(403, \"Forbidden\"); return;\n }\n let candidate = resolve(root, `.${pathname.startsWith(\"/\") ? pathname : `/${pathname}`}`);\n try {\n if (!inside(candidate)) { reply(403, \"Forbidden\"); return; }\n if ((await stat(candidate)).isDirectory()) candidate = resolve(candidate, \"index.html\");\n candidate = await realpath(candidate);\n // A symlink inside the web root must not expose files outside it.\n if (!inside(candidate)) { reply(403, \"Forbidden\"); return; }\n if (!(await stat(candidate)).isFile()) { reply(404, \"Not found\"); return; }\n const body = await readFile(candidate);\n res.setHeader(\"Content-Type\", mime[extname(candidate)] ?? \"application/octet-stream\");\n res.setHeader(\"Content-Length\", body.byteLength);\n res.end(req.method === \"HEAD\" ? undefined : body);\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n reply(code === \"ENOENT\" || code === \"ENOTDIR\" ? 404 : 500, \"Unable to serve file\");\n }\n };\n const server = createServer((req, res) => { void handle(req, res); });\n const hostname = options.hostname ?? \"127.0.0.1\";\n await new Promise<void>((done, reject) => {\n server.once(\"error\", reject);\n server.listen(options.port ?? 4173, hostname, () => { server.off(\"error\", reject); done(); });\n });\n const address = server.address();\n if (!address || typeof address === \"string\") throw new Error(\"Browser host has no TCP address\");\n return {\n server,\n url: `http://${hostname.includes(\":\") ? `[${hostname}]` : hostname}:${address.port}`,\n close: () => new Promise<void>((done, reject) => {\n server.close(error => error ? reject(error) : done());\n server.closeIdleConnections();\n }),\n };\n}\n"]}
@@ -0,0 +1,25 @@
1
+ import { Server } from 'node:http';
2
+
3
+ /** Node-side hosting for a built SandboxedJs browser application. */
4
+
5
+ /** These must be sent on the host document, not just the guest's HTTP responses. */
6
+ declare const browserIsolationHeaders: Readonly<{
7
+ "Cross-Origin-Opener-Policy": "same-origin";
8
+ "Cross-Origin-Embedder-Policy": "require-corp";
9
+ }>;
10
+ /**
11
+ * Serve a built host application with the prerequisites for guest workers and
12
+ * threaded WASM. Loopback-only by default; use HTTPS at a reverse proxy for
13
+ * remote access. An arbitrary HTTP origin is not a secure browser context.
14
+ */
15
+ declare function serveBrowserApp(options: {
16
+ directory: string;
17
+ port?: number;
18
+ hostname?: string;
19
+ }): Promise<{
20
+ server: Server;
21
+ url: string;
22
+ close(): Promise<void>;
23
+ }>;
24
+
25
+ export { browserIsolationHeaders, serveBrowserApp };
@@ -0,0 +1,25 @@
1
+ import { Server } from 'node:http';
2
+
3
+ /** Node-side hosting for a built SandboxedJs browser application. */
4
+
5
+ /** These must be sent on the host document, not just the guest's HTTP responses. */
6
+ declare const browserIsolationHeaders: Readonly<{
7
+ "Cross-Origin-Opener-Policy": "same-origin";
8
+ "Cross-Origin-Embedder-Policy": "require-corp";
9
+ }>;
10
+ /**
11
+ * Serve a built host application with the prerequisites for guest workers and
12
+ * threaded WASM. Loopback-only by default; use HTTPS at a reverse proxy for
13
+ * remote access. An arbitrary HTTP origin is not a secure browser context.
14
+ */
15
+ declare function serveBrowserApp(options: {
16
+ directory: string;
17
+ port?: number;
18
+ hostname?: string;
19
+ }): Promise<{
20
+ server: Server;
21
+ url: string;
22
+ close(): Promise<void>;
23
+ }>;
24
+
25
+ export { browserIsolationHeaders, serveBrowserApp };
@@ -0,0 +1,109 @@
1
+ import { createServer } from 'http';
2
+ import { realpath, stat, readFile } from 'fs/promises';
3
+ import { resolve, extname, relative, sep, isAbsolute } from 'path';
4
+
5
+ // src/hosting/browser-host.ts
6
+ var browserIsolationHeaders = Object.freeze({
7
+ "Cross-Origin-Opener-Policy": "same-origin",
8
+ "Cross-Origin-Embedder-Policy": "require-corp"
9
+ });
10
+ var mime = {
11
+ ".html": "text/html; charset=utf-8",
12
+ ".js": "text/javascript; charset=utf-8",
13
+ ".mjs": "text/javascript; charset=utf-8",
14
+ ".css": "text/css; charset=utf-8",
15
+ ".json": "application/json",
16
+ ".wasm": "application/wasm",
17
+ ".svg": "image/svg+xml",
18
+ ".png": "image/png",
19
+ ".jpg": "image/jpeg",
20
+ ".jpeg": "image/jpeg",
21
+ ".ico": "image/x-icon",
22
+ ".webp": "image/webp",
23
+ ".woff": "font/woff",
24
+ ".woff2": "font/woff2",
25
+ ".txt": "text/plain; charset=utf-8"
26
+ };
27
+ async function serveBrowserApp(options) {
28
+ const root = await realpath(resolve(options.directory));
29
+ if (!(await stat(root)).isDirectory()) throw new Error("Browser app directory is not a directory");
30
+ const inside = (path) => {
31
+ const rel = relative(root, path);
32
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
33
+ };
34
+ const handle = async (req, res) => {
35
+ for (const [name, value] of Object.entries(browserIsolationHeaders)) res.setHeader(name, value);
36
+ res.setHeader("X-Content-Type-Options", "nosniff");
37
+ res.setHeader("Cache-Control", "no-cache");
38
+ const reply = (status, body) => {
39
+ res.statusCode = status;
40
+ res.setHeader("Content-Type", "text/plain; charset=utf-8");
41
+ res.end(req.method === "HEAD" ? void 0 : body);
42
+ };
43
+ if (req.method !== "GET" && req.method !== "HEAD") {
44
+ res.setHeader("Allow", "GET, HEAD");
45
+ reply(405, "Method not allowed");
46
+ return;
47
+ }
48
+ let pathname;
49
+ try {
50
+ pathname = decodeURIComponent((req.url ?? "/").split("?")[0]);
51
+ } catch {
52
+ reply(400, "Invalid URL");
53
+ return;
54
+ }
55
+ if (pathname.includes("\0") || pathname.includes("\\") || pathname.split("/").some((p) => p.startsWith("."))) {
56
+ reply(403, "Forbidden");
57
+ return;
58
+ }
59
+ let candidate = resolve(root, `.${pathname.startsWith("/") ? pathname : `/${pathname}`}`);
60
+ try {
61
+ if (!inside(candidate)) {
62
+ reply(403, "Forbidden");
63
+ return;
64
+ }
65
+ if ((await stat(candidate)).isDirectory()) candidate = resolve(candidate, "index.html");
66
+ candidate = await realpath(candidate);
67
+ if (!inside(candidate)) {
68
+ reply(403, "Forbidden");
69
+ return;
70
+ }
71
+ if (!(await stat(candidate)).isFile()) {
72
+ reply(404, "Not found");
73
+ return;
74
+ }
75
+ const body = await readFile(candidate);
76
+ res.setHeader("Content-Type", mime[extname(candidate)] ?? "application/octet-stream");
77
+ res.setHeader("Content-Length", body.byteLength);
78
+ res.end(req.method === "HEAD" ? void 0 : body);
79
+ } catch (error) {
80
+ const code = error.code;
81
+ reply(code === "ENOENT" || code === "ENOTDIR" ? 404 : 500, "Unable to serve file");
82
+ }
83
+ };
84
+ const server = createServer((req, res) => {
85
+ void handle(req, res);
86
+ });
87
+ const hostname = options.hostname ?? "127.0.0.1";
88
+ await new Promise((done, reject) => {
89
+ server.once("error", reject);
90
+ server.listen(options.port ?? 4173, hostname, () => {
91
+ server.off("error", reject);
92
+ done();
93
+ });
94
+ });
95
+ const address = server.address();
96
+ if (!address || typeof address === "string") throw new Error("Browser host has no TCP address");
97
+ return {
98
+ server,
99
+ url: `http://${hostname.includes(":") ? `[${hostname}]` : hostname}:${address.port}`,
100
+ close: () => new Promise((done, reject) => {
101
+ server.close((error) => error ? reject(error) : done());
102
+ server.closeIdleConnections();
103
+ })
104
+ };
105
+ }
106
+
107
+ export { browserIsolationHeaders, serveBrowserApp };
108
+ //# sourceMappingURL=browser-host.js.map
109
+ //# sourceMappingURL=browser-host.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hosting/browser-host.ts"],"names":[],"mappings":"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","file":"browser-host.js","sourcesContent":["/** Node-side hosting for a built SandboxedJs browser application. */\nimport { createServer, type Server, type IncomingMessage, type ServerResponse } from \"node:http\";\nimport { readFile, realpath, stat } from \"node:fs/promises\";\nimport { extname, isAbsolute, relative, resolve, sep } from \"node:path\";\n\n/** These must be sent on the host document, not just the guest's HTTP responses. */\nexport const browserIsolationHeaders = Object.freeze({\n \"Cross-Origin-Opener-Policy\": \"same-origin\",\n \"Cross-Origin-Embedder-Policy\": \"require-corp\",\n});\n\nconst mime: Record<string, string> = {\n \".html\": \"text/html; charset=utf-8\", \".js\": \"text/javascript; charset=utf-8\",\n \".mjs\": \"text/javascript; charset=utf-8\", \".css\": \"text/css; charset=utf-8\",\n \".json\": \"application/json\", \".wasm\": \"application/wasm\",\n \".svg\": \"image/svg+xml\", \".png\": \"image/png\", \".jpg\": \"image/jpeg\",\n \".jpeg\": \"image/jpeg\", \".ico\": \"image/x-icon\", \".webp\": \"image/webp\",\n \".woff\": \"font/woff\", \".woff2\": \"font/woff2\", \".txt\": \"text/plain; charset=utf-8\",\n};\n\n/**\n * Serve a built host application with the prerequisites for guest workers and\n * threaded WASM. Loopback-only by default; use HTTPS at a reverse proxy for\n * remote access. An arbitrary HTTP origin is not a secure browser context.\n */\nexport async function serveBrowserApp(options: {\n directory: string;\n port?: number;\n hostname?: string;\n}): Promise<{ server: Server; url: string; close(): Promise<void> }> {\n const root = await realpath(resolve(options.directory));\n if (!(await stat(root)).isDirectory()) throw new Error(\"Browser app directory is not a directory\");\n const inside = (path: string) => {\n const rel = relative(root, path);\n return rel !== \"..\" && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);\n };\n const handle = async (req: IncomingMessage, res: ServerResponse) => {\n for (const [name, value] of Object.entries(browserIsolationHeaders)) res.setHeader(name, value);\n res.setHeader(\"X-Content-Type-Options\", \"nosniff\");\n res.setHeader(\"Cache-Control\", \"no-cache\");\n const reply = (status: number, body: string) => {\n res.statusCode = status;\n res.setHeader(\"Content-Type\", \"text/plain; charset=utf-8\");\n res.end(req.method === \"HEAD\" ? undefined : body);\n };\n if (req.method !== \"GET\" && req.method !== \"HEAD\") {\n res.setHeader(\"Allow\", \"GET, HEAD\");\n reply(405, \"Method not allowed\");\n return;\n }\n let pathname: string;\n try { pathname = decodeURIComponent((req.url ?? \"/\").split(\"?\")[0]!); }\n catch { reply(400, \"Invalid URL\"); return; }\n // Reject traversal, hidden files, and platform-dependent separators before resolving.\n if (pathname.includes(\"\\0\") || pathname.includes(\"\\\\\") || pathname.split(\"/\").some(p => p.startsWith(\".\"))) {\n reply(403, \"Forbidden\"); return;\n }\n let candidate = resolve(root, `.${pathname.startsWith(\"/\") ? pathname : `/${pathname}`}`);\n try {\n if (!inside(candidate)) { reply(403, \"Forbidden\"); return; }\n if ((await stat(candidate)).isDirectory()) candidate = resolve(candidate, \"index.html\");\n candidate = await realpath(candidate);\n // A symlink inside the web root must not expose files outside it.\n if (!inside(candidate)) { reply(403, \"Forbidden\"); return; }\n if (!(await stat(candidate)).isFile()) { reply(404, \"Not found\"); return; }\n const body = await readFile(candidate);\n res.setHeader(\"Content-Type\", mime[extname(candidate)] ?? \"application/octet-stream\");\n res.setHeader(\"Content-Length\", body.byteLength);\n res.end(req.method === \"HEAD\" ? undefined : body);\n } catch (error) {\n const code = (error as NodeJS.ErrnoException).code;\n reply(code === \"ENOENT\" || code === \"ENOTDIR\" ? 404 : 500, \"Unable to serve file\");\n }\n };\n const server = createServer((req, res) => { void handle(req, res); });\n const hostname = options.hostname ?? \"127.0.0.1\";\n await new Promise<void>((done, reject) => {\n server.once(\"error\", reject);\n server.listen(options.port ?? 4173, hostname, () => { server.off(\"error\", reject); done(); });\n });\n const address = server.address();\n if (!address || typeof address === \"string\") throw new Error(\"Browser host has no TCP address\");\n return {\n server,\n url: `http://${hostname.includes(\":\") ? `[${hostname}]` : hostname}:${address.port}`,\n close: () => new Promise<void>((done, reject) => {\n server.close(error => error ? reject(error) : done());\n server.closeIdleConnections();\n }),\n };\n}\n"]}