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 +85 -92
- package/bin/sandboxedjs-serve.mjs +24 -0
- package/dist/browser-host.cjs +112 -0
- package/dist/browser-host.cjs.map +1 -0
- package/dist/browser-host.d.cts +25 -0
- package/dist/browser-host.d.ts +25 -0
- package/dist/browser-host.js +109 -0
- package/dist/browser-host.js.map +1 -0
- package/dist/index.cjs +504 -198
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -26
- package/dist/index.d.ts +26 -26
- package/dist/index.js +504 -198
- package/dist/index.js.map +1 -1
- package/dist/worker-entry.js +130 -109
- package/dist/worker-entry.js.map +1 -1
- package/package.json +9 -3
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
145
|
+
Browser-only example, after starting a server in `box`:
|
|
146
146
|
|
|
147
147
|
```ts
|
|
148
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
- *
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
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"]}
|