sandboxedjs 0.2.6 → 0.2.8

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
@@ -51,38 +51,45 @@ plenty of them answer with 405. A container whose guest calls a real API
51
51
  therefore works under Node and fails in a browser, for a reason that belongs to
52
52
  the page rather than to anything in the container.
53
53
 
54
- Give it somewhere to send those requests instead. In development that is a
55
- process:
54
+ Give it somewhere to send those requests instead. Add that somewhere to the
55
+ project that is already being deployed, and the container finds it by itself:
56
56
 
57
57
  ```bash
58
- npx sandboxedjs-egress 4181 --allow api.openai.com,ollama.com
58
+ npx sandboxedjs-egress init --allow api.openai.com,ollama.com
59
59
  ```
60
60
 
61
- ```ts
62
- const box = await createContainer({
63
- network: { allowOutbound: true, proxy: "http://localhost:4181" },
64
- });
61
+ That writes one file at the path the host platform serves — Cloudflare Pages,
62
+ Vercel and Netlify are detected, `--target` names one — and nothing else
63
+ changes. A container in a page probes its own origin for the proxy before it
64
+ gives up, so no project passes `proxy` and none of them has to be told twice.
65
+
66
+ In development the same proxy runs as a process, on the port the probe checks:
67
+
68
+ ```bash
69
+ npx sandboxedjs-egress
65
70
  ```
66
71
 
67
- In an app that already has a server, mount it there instead the container
68
- then calls its own origin, so there is no second port and no CORS on the proxy
69
- itself:
72
+ In an app that already has a server, mount the handler on the well-known path.
73
+ Before any body parser: it reads the request stream itself.
70
74
 
71
75
  ```ts
72
- import { egressNodeHandler } from "sandboxedjs/egress";
73
- app.post("/egress", egressNodeHandler({ allow: ["ollama.com"] }));
74
- // createContainer({ network: { allowOutbound: true, proxy: "/egress" } })
76
+ import { egressNodeHandler, EGRESS_PATH } from "sandboxedjs/egress";
77
+ app.use(EGRESS_PATH, egressNodeHandler({ allow: ["ollama.com"] }));
75
78
  ```
76
79
 
77
- A static site has no server, which is what the `Request` form is for a
78
- Cloudflare Pages Function, a Worker, a route handler:
80
+ A static host takes the `Request` form — which is what `init` writes:
79
81
 
80
82
  ```ts
81
- // functions/egress.ts
83
+ // functions/__sandboxedjs__/egress.ts, on Cloudflare Pages
82
84
  import { handleEgressRequest } from "sandboxedjs/egress";
83
85
  export const onRequest = ({ request }) => handleEgressRequest(request, { allow: ["ollama.com"] });
84
86
  ```
85
87
 
88
+ `network: { proxy }` still names one explicitly — a proxy somewhere else, or
89
+ one that discovery should not be trusted to find — and it is never overridden.
90
+ `sandboxedjs-serve` mounts the proxy itself, so an app served by it needs none
91
+ of this.
92
+
86
93
  Every exit honours it — `curl`, `wget`, a guest's own `fetch`, and Python's
87
94
  sockets — so it means the same thing whatever the project is written in.
88
95
  Loopback stays inside the container, and the container's outbound policy still
@@ -21,9 +21,126 @@
21
21
  */
22
22
 
23
23
  import { createServer } from "node:http";
24
+ import { mkdir, writeFile, access } from "node:fs/promises";
25
+ import { dirname, join } from "node:path";
24
26
  import { egressNodeHandler } from "../dist/egress.js";
25
27
 
26
28
  const args = process.argv.slice(2);
29
+
30
+ /*
31
+ * `sandboxedjs-egress init` — the proxy as a file in the project that is
32
+ * already being deployed.
33
+ *
34
+ * Running a process beside a site works on a laptop and nowhere else, and a
35
+ * static host has no server to add one to. What every static host does have is
36
+ * a functions directory, so the proxy goes there: one file, at the path the
37
+ * container already looks for, and a deployment that needed a setting needs
38
+ * nothing.
39
+ */
40
+ const TARGETS = {
41
+ cloudflare: {
42
+ file: "functions/__sandboxedjs__/egress.ts",
43
+ detect: ["wrangler.toml", "wrangler.json", "wrangler.jsonc", "functions"],
44
+ source: (allow) => `import { handleEgressRequest } from "sandboxedjs/egress";
45
+ ${allow}
46
+ /* Cloudflare Pages serves this file at /__sandboxedjs__/egress, which is where
47
+ * a container in this site's pages looks for its way out. */
48
+ export const onRequest = ({ request }) => handleEgressRequest(request, options);
49
+ `,
50
+ },
51
+ vercel: {
52
+ file: "api/__sandboxedjs__/egress.ts",
53
+ detect: ["vercel.json", "api"],
54
+ source: (allow) => `import { handleEgressRequest } from "sandboxedjs/egress";
55
+ ${allow}
56
+ export const config = { runtime: "edge" };
57
+
58
+ /* Vercel serves this file at /api/__sandboxedjs__/egress, which is one of the
59
+ * paths a container in this site's pages probes for its way out. */
60
+ export default (request) => handleEgressRequest(request, options);
61
+ `,
62
+ },
63
+ netlify: {
64
+ file: "netlify/functions/sandboxedjs-egress.ts",
65
+ detect: ["netlify.toml", "netlify"],
66
+ source: (allow) => `import { handleEgressRequest } from "sandboxedjs/egress";
67
+ ${allow}
68
+ export const config = { path: "/.netlify/functions/sandboxedjs-egress" };
69
+
70
+ export default (request) => handleEgressRequest(request, options);
71
+ `,
72
+ },
73
+ };
74
+
75
+ const NODE_SNIPPET = ` import { egressNodeHandler, EGRESS_PATH } from "sandboxedjs/egress";
76
+ // Before any body parser: the handler reads the request stream itself.
77
+ app.use(EGRESS_PATH, egressNodeHandler({ allow: ["api.example.com"] }));`;
78
+
79
+ async function exists(path) {
80
+ try { await access(path); return true; } catch { return false; }
81
+ }
82
+
83
+ async function detectTarget(root) {
84
+ for (const [name, target] of Object.entries(TARGETS)) {
85
+ for (const marker of target.detect) {
86
+ if (await exists(join(root, marker))) return name;
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ async function init(argv) {
93
+ const root = argv.find((a) => !a.startsWith("-")) ?? process.cwd();
94
+ const at = argv.indexOf("--target");
95
+ const allowAt = argv.indexOf("--allow");
96
+ const allowed = allowAt === -1
97
+ ? null
98
+ : (argv[allowAt + 1] ?? "").split(",").map((h) => h.trim()).filter(Boolean);
99
+ const name = at === -1 ? await detectTarget(root) : argv[at + 1];
100
+ if (!name || !TARGETS[name]) {
101
+ console.error(
102
+ name
103
+ ? `sandboxedjs-egress init: unknown target ${name}`
104
+ : "sandboxedjs-egress init: could not tell what this project deploys to.",
105
+ );
106
+ console.error(`Targets: ${Object.keys(TARGETS).join(", ")} (--target <name>)`);
107
+ console.error(`A server of your own takes the handler directly:\n\n${NODE_SNIPPET}\n`);
108
+ process.exitCode = 1;
109
+ return;
110
+ }
111
+ const target = TARGETS[name];
112
+ const path = join(root, target.file);
113
+ if (await exists(path)) {
114
+ console.log(`${target.file} is already there; leaving it alone.`);
115
+ return;
116
+ }
117
+ /* An open proxy carries whatever credentials the container holds to wherever
118
+ * it is asked, so the list is the limit and its absence is said out loud. */
119
+ const allow = allowed?.length
120
+ ? `\n/* The hosts this proxy will fetch, subdomains included. */\nconst options = { allow: ${JSON.stringify(allowed)} };\n`
121
+ : `\n/* Anything that can reach this route can make a request through it, carrying\n * whatever credentials the container holds. Narrow it before this is public:\n * const options = { allow: ["api.example.com"] };\n */\nconst options = {};\n`;
122
+ await mkdir(dirname(path), { recursive: true });
123
+ await writeFile(path, target.source(allow));
124
+ console.log(`Wrote ${target.file} (${name}).`);
125
+ if (!allowed?.length) console.log("It will fetch any host: set `allow` in that file before deploying it publicly.");
126
+ console.log("Deploy it, and containers in this site's pages find it with no further configuration.");
127
+ }
128
+
129
+ if (args[0] === "init") {
130
+ await init(args.slice(1));
131
+ process.exit(process.exitCode ?? 0);
132
+ }
133
+
134
+ if (args.includes("--help") || args.includes("-h")) {
135
+ console.log(`sandboxedjs-egress — outbound HTTP for a container that runs in a browser
136
+
137
+ Usage:
138
+ sandboxedjs-egress [port=4181] [--allow host,host] run the proxy here
139
+ sandboxedjs-egress init [dir] [--target cloudflare|vercel|netlify] [--allow host,host]
140
+ add it to a deployment
141
+ `);
142
+ process.exit(0);
143
+ }
27
144
  const at = args.indexOf("--allow");
28
145
  const allow = at === -1 ? undefined : (args[at + 1] ?? "").split(",").map((h) => h.trim()).filter(Boolean);
29
146
  const port = Number(args.find((a) => /^\d+$/.test(a)) ?? 4181);
@@ -4,6 +4,138 @@ var http = require('http');
4
4
  var promises = require('fs/promises');
5
5
  var path = require('path');
6
6
 
7
+ // src/hosting/browser-host.ts
8
+
9
+ // src/net/proxy-fetch.ts
10
+ function toBase64(bytes) {
11
+ let text = "";
12
+ for (let at = 0; at < bytes.length; at += 32768) {
13
+ text += String.fromCharCode(...bytes.subarray(at, at + 32768));
14
+ }
15
+ return btoa(text);
16
+ }
17
+ function fromBase64(text) {
18
+ const binary = atob(text);
19
+ const bytes = new Uint8Array(binary.length);
20
+ for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at);
21
+ return bytes;
22
+ }
23
+
24
+ // src/net/discover-egress.ts
25
+ var EGRESS_PATH = "/__sandboxedjs__/egress";
26
+ var EGRESS_MARKER = { sandboxedjs: "egress", protocol: 1 };
27
+
28
+ // src/net/egress-handler.ts
29
+ var HOP = /* @__PURE__ */ new Set([
30
+ "connection",
31
+ "keep-alive",
32
+ "proxy-authenticate",
33
+ "proxy-authorization",
34
+ "te",
35
+ "trailer",
36
+ "transfer-encoding",
37
+ "upgrade",
38
+ "host",
39
+ "content-length"
40
+ ]);
41
+ var HEADERS = {
42
+ "access-control-allow-origin": "*",
43
+ "access-control-allow-methods": "GET, POST, OPTIONS",
44
+ "access-control-allow-headers": "content-type",
45
+ "access-control-max-age": "86400",
46
+ "cross-origin-resource-policy": "cross-origin"
47
+ };
48
+ var refuse = (status, message) => new Response(message, { status, headers: { ...HEADERS, "content-type": "text/plain; charset=utf-8" } });
49
+ async function handleEgressRequest(request, options = {}) {
50
+ if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: HEADERS });
51
+ if (request.method === "GET" || request.method === "HEAD") {
52
+ return new Response(request.method === "HEAD" ? null : JSON.stringify(EGRESS_MARKER), {
53
+ headers: { ...HEADERS, "content-type": "application/json" }
54
+ });
55
+ }
56
+ if (request.method !== "POST") return refuse(405, "POST a JSON request here.");
57
+ let payload;
58
+ try {
59
+ payload = await request.json();
60
+ } catch (error) {
61
+ return refuse(400, `could not read the request: ${error instanceof Error ? error.message : String(error)}`);
62
+ }
63
+ let target;
64
+ try {
65
+ target = new URL(String(payload.url));
66
+ } catch {
67
+ return refuse(400, `not a URL: ${payload.url}`);
68
+ }
69
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
70
+ return refuse(400, `${target.protocol} is not a protocol this proxy speaks`);
71
+ }
72
+ const allow = options.allow;
73
+ if (allow && !allow.some((host) => target.hostname === host || target.hostname.endsWith(`.${host}`))) {
74
+ return refuse(403, `${target.hostname} is not in this proxy's allow list`);
75
+ }
76
+ const headers = {};
77
+ for (const [name, value] of Object.entries(payload.headers ?? {})) {
78
+ if (!HOP.has(name.toLowerCase())) headers[name] = value;
79
+ }
80
+ const perform = options.fetch ?? fetch;
81
+ let answer;
82
+ try {
83
+ answer = await perform(target.toString(), {
84
+ method: payload.method ?? "GET",
85
+ headers,
86
+ ...payload.body ? { body: fromBase64(payload.body) } : {},
87
+ redirect: "follow"
88
+ });
89
+ } catch (error) {
90
+ return refuse(502, `the request to ${target.hostname} failed: ${error instanceof Error ? error.message : String(error)}`);
91
+ }
92
+ const back = {};
93
+ answer.headers.forEach((value, name) => {
94
+ if (!HOP.has(name.toLowerCase())) back[name] = value;
95
+ });
96
+ const body = new Uint8Array(await answer.arrayBuffer());
97
+ return new Response(
98
+ JSON.stringify({ status: answer.status, statusText: answer.statusText, headers: back, body: toBase64(body) }),
99
+ { headers: { ...HEADERS, "content-type": "application/json" } }
100
+ );
101
+ }
102
+ function egressNodeHandler(options = {}) {
103
+ return async (request, response) => {
104
+ const chunks = [];
105
+ await new Promise((done, fail) => {
106
+ request.on("data", (chunk) => chunks.push(chunk));
107
+ request.on("end", () => done());
108
+ request.on("error", (error) => fail(error));
109
+ });
110
+ let total = 0;
111
+ for (const chunk of chunks) total += chunk.length;
112
+ const body = new Uint8Array(total);
113
+ let at = 0;
114
+ for (const chunk of chunks) {
115
+ body.set(chunk, at);
116
+ at += chunk.length;
117
+ }
118
+ const headers = {};
119
+ for (const [name, value] of Object.entries(request.headers)) {
120
+ if (typeof value === "string") headers[name] = value;
121
+ }
122
+ const answer = await handleEgressRequest(
123
+ new Request("http://egress.invalid/", {
124
+ method: request.method ?? "GET",
125
+ headers,
126
+ ...body.length && request.method !== "GET" && request.method !== "HEAD" ? { body } : {}
127
+ }),
128
+ options
129
+ );
130
+ const out = {};
131
+ answer.headers.forEach((value, name) => {
132
+ out[name] = value;
133
+ });
134
+ response.writeHead(answer.status, out);
135
+ response.end(Buffer.from(await answer.arrayBuffer()));
136
+ };
137
+ }
138
+
7
139
  // src/hosting/browser-host.ts
8
140
  var browserIsolationHeaders = Object.freeze({
9
141
  "Cross-Origin-Opener-Policy": "same-origin",
@@ -33,7 +165,12 @@ async function serveBrowserApp(options) {
33
165
  const rel = path.relative(root, path$1);
34
166
  return rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel);
35
167
  };
168
+ const egress = options.egress === false ? null : egressNodeHandler({ ...options.egress?.allow ? { allow: options.egress.allow } : {} });
36
169
  const handle = async (req, res) => {
170
+ if (egress && (req.url ?? "").split("?")[0] === EGRESS_PATH) {
171
+ await egress(req, res);
172
+ return;
173
+ }
37
174
  for (const [name, value] of Object.entries(browserIsolationHeaders)) res.setHeader(name, value);
38
175
  res.setHeader("X-Content-Type-Options", "nosniff");
39
176
  res.setHeader("Cache-Control", "no-cache");
@@ -16,6 +16,18 @@ declare function serveBrowserApp(options: {
16
16
  directory: string;
17
17
  port?: number;
18
18
  hostname?: string;
19
+ /**
20
+ * The outbound proxy this host serves for the containers it hosts.
21
+ *
22
+ * On by default, because a container in a browser cannot reach an API that
23
+ * sends no CORS headers without one, and a host that serves the page is
24
+ * already the right place to put it -- same origin, nothing else to start,
25
+ * and the container finds it without being configured. `allow` narrows what
26
+ * it will fetch; `false` turns it off for a host that wants none.
27
+ */
28
+ egress?: false | {
29
+ allow?: string[];
30
+ };
19
31
  }): Promise<{
20
32
  server: Server;
21
33
  url: string;
@@ -16,6 +16,18 @@ declare function serveBrowserApp(options: {
16
16
  directory: string;
17
17
  port?: number;
18
18
  hostname?: string;
19
+ /**
20
+ * The outbound proxy this host serves for the containers it hosts.
21
+ *
22
+ * On by default, because a container in a browser cannot reach an API that
23
+ * sends no CORS headers without one, and a host that serves the page is
24
+ * already the right place to put it -- same origin, nothing else to start,
25
+ * and the container finds it without being configured. `allow` narrows what
26
+ * it will fetch; `false` turns it off for a host that wants none.
27
+ */
28
+ egress?: false | {
29
+ allow?: string[];
30
+ };
19
31
  }): Promise<{
20
32
  server: Server;
21
33
  url: string;
@@ -2,6 +2,138 @@ import { createServer } from 'http';
2
2
  import { realpath, stat, readFile } from 'fs/promises';
3
3
  import { resolve, extname, relative, sep, isAbsolute } from 'path';
4
4
 
5
+ // src/hosting/browser-host.ts
6
+
7
+ // src/net/proxy-fetch.ts
8
+ function toBase64(bytes) {
9
+ let text = "";
10
+ for (let at = 0; at < bytes.length; at += 32768) {
11
+ text += String.fromCharCode(...bytes.subarray(at, at + 32768));
12
+ }
13
+ return btoa(text);
14
+ }
15
+ function fromBase64(text) {
16
+ const binary = atob(text);
17
+ const bytes = new Uint8Array(binary.length);
18
+ for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at);
19
+ return bytes;
20
+ }
21
+
22
+ // src/net/discover-egress.ts
23
+ var EGRESS_PATH = "/__sandboxedjs__/egress";
24
+ var EGRESS_MARKER = { sandboxedjs: "egress", protocol: 1 };
25
+
26
+ // src/net/egress-handler.ts
27
+ var HOP = /* @__PURE__ */ new Set([
28
+ "connection",
29
+ "keep-alive",
30
+ "proxy-authenticate",
31
+ "proxy-authorization",
32
+ "te",
33
+ "trailer",
34
+ "transfer-encoding",
35
+ "upgrade",
36
+ "host",
37
+ "content-length"
38
+ ]);
39
+ var HEADERS = {
40
+ "access-control-allow-origin": "*",
41
+ "access-control-allow-methods": "GET, POST, OPTIONS",
42
+ "access-control-allow-headers": "content-type",
43
+ "access-control-max-age": "86400",
44
+ "cross-origin-resource-policy": "cross-origin"
45
+ };
46
+ var refuse = (status, message) => new Response(message, { status, headers: { ...HEADERS, "content-type": "text/plain; charset=utf-8" } });
47
+ async function handleEgressRequest(request, options = {}) {
48
+ if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: HEADERS });
49
+ if (request.method === "GET" || request.method === "HEAD") {
50
+ return new Response(request.method === "HEAD" ? null : JSON.stringify(EGRESS_MARKER), {
51
+ headers: { ...HEADERS, "content-type": "application/json" }
52
+ });
53
+ }
54
+ if (request.method !== "POST") return refuse(405, "POST a JSON request here.");
55
+ let payload;
56
+ try {
57
+ payload = await request.json();
58
+ } catch (error) {
59
+ return refuse(400, `could not read the request: ${error instanceof Error ? error.message : String(error)}`);
60
+ }
61
+ let target;
62
+ try {
63
+ target = new URL(String(payload.url));
64
+ } catch {
65
+ return refuse(400, `not a URL: ${payload.url}`);
66
+ }
67
+ if (target.protocol !== "http:" && target.protocol !== "https:") {
68
+ return refuse(400, `${target.protocol} is not a protocol this proxy speaks`);
69
+ }
70
+ const allow = options.allow;
71
+ if (allow && !allow.some((host) => target.hostname === host || target.hostname.endsWith(`.${host}`))) {
72
+ return refuse(403, `${target.hostname} is not in this proxy's allow list`);
73
+ }
74
+ const headers = {};
75
+ for (const [name, value] of Object.entries(payload.headers ?? {})) {
76
+ if (!HOP.has(name.toLowerCase())) headers[name] = value;
77
+ }
78
+ const perform = options.fetch ?? fetch;
79
+ let answer;
80
+ try {
81
+ answer = await perform(target.toString(), {
82
+ method: payload.method ?? "GET",
83
+ headers,
84
+ ...payload.body ? { body: fromBase64(payload.body) } : {},
85
+ redirect: "follow"
86
+ });
87
+ } catch (error) {
88
+ return refuse(502, `the request to ${target.hostname} failed: ${error instanceof Error ? error.message : String(error)}`);
89
+ }
90
+ const back = {};
91
+ answer.headers.forEach((value, name) => {
92
+ if (!HOP.has(name.toLowerCase())) back[name] = value;
93
+ });
94
+ const body = new Uint8Array(await answer.arrayBuffer());
95
+ return new Response(
96
+ JSON.stringify({ status: answer.status, statusText: answer.statusText, headers: back, body: toBase64(body) }),
97
+ { headers: { ...HEADERS, "content-type": "application/json" } }
98
+ );
99
+ }
100
+ function egressNodeHandler(options = {}) {
101
+ return async (request, response) => {
102
+ const chunks = [];
103
+ await new Promise((done, fail) => {
104
+ request.on("data", (chunk) => chunks.push(chunk));
105
+ request.on("end", () => done());
106
+ request.on("error", (error) => fail(error));
107
+ });
108
+ let total = 0;
109
+ for (const chunk of chunks) total += chunk.length;
110
+ const body = new Uint8Array(total);
111
+ let at = 0;
112
+ for (const chunk of chunks) {
113
+ body.set(chunk, at);
114
+ at += chunk.length;
115
+ }
116
+ const headers = {};
117
+ for (const [name, value] of Object.entries(request.headers)) {
118
+ if (typeof value === "string") headers[name] = value;
119
+ }
120
+ const answer = await handleEgressRequest(
121
+ new Request("http://egress.invalid/", {
122
+ method: request.method ?? "GET",
123
+ headers,
124
+ ...body.length && request.method !== "GET" && request.method !== "HEAD" ? { body } : {}
125
+ }),
126
+ options
127
+ );
128
+ const out = {};
129
+ answer.headers.forEach((value, name) => {
130
+ out[name] = value;
131
+ });
132
+ response.writeHead(answer.status, out);
133
+ response.end(Buffer.from(await answer.arrayBuffer()));
134
+ };
135
+ }
136
+
5
137
  // src/hosting/browser-host.ts
6
138
  var browserIsolationHeaders = Object.freeze({
7
139
  "Cross-Origin-Opener-Policy": "same-origin",
@@ -31,7 +163,12 @@ async function serveBrowserApp(options) {
31
163
  const rel = relative(root, path);
32
164
  return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
33
165
  };
166
+ const egress = options.egress === false ? null : egressNodeHandler({ ...options.egress?.allow ? { allow: options.egress.allow } : {} });
34
167
  const handle = async (req, res) => {
168
+ if (egress && (req.url ?? "").split("?")[0] === EGRESS_PATH) {
169
+ await egress(req, res);
170
+ return;
171
+ }
35
172
  for (const [name, value] of Object.entries(browserIsolationHeaders)) res.setHeader(name, value);
36
173
  res.setHeader("X-Content-Type-Options", "nosniff");
37
174
  res.setHeader("Cache-Control", "no-cache");
package/dist/egress.cjs CHANGED
@@ -15,6 +15,10 @@ function fromBase64(text) {
15
15
  return bytes;
16
16
  }
17
17
 
18
+ // src/net/discover-egress.ts
19
+ var EGRESS_PATH = "/__sandboxedjs__/egress";
20
+ var EGRESS_MARKER = { sandboxedjs: "egress", protocol: 1 };
21
+
18
22
  // src/net/egress-handler.ts
19
23
  var HOP = /* @__PURE__ */ new Set([
20
24
  "connection",
@@ -30,7 +34,7 @@ var HOP = /* @__PURE__ */ new Set([
30
34
  ]);
31
35
  var HEADERS = {
32
36
  "access-control-allow-origin": "*",
33
- "access-control-allow-methods": "POST, OPTIONS",
37
+ "access-control-allow-methods": "GET, POST, OPTIONS",
34
38
  "access-control-allow-headers": "content-type",
35
39
  "access-control-max-age": "86400",
36
40
  "cross-origin-resource-policy": "cross-origin"
@@ -38,6 +42,11 @@ var HEADERS = {
38
42
  var refuse = (status, message) => new Response(message, { status, headers: { ...HEADERS, "content-type": "text/plain; charset=utf-8" } });
39
43
  async function handleEgressRequest(request, options = {}) {
40
44
  if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: HEADERS });
45
+ if (request.method === "GET" || request.method === "HEAD") {
46
+ return new Response(request.method === "HEAD" ? null : JSON.stringify(EGRESS_MARKER), {
47
+ headers: { ...HEADERS, "content-type": "application/json" }
48
+ });
49
+ }
41
50
  if (request.method !== "POST") return refuse(405, "POST a JSON request here.");
42
51
  let payload;
43
52
  try {
@@ -121,6 +130,8 @@ function egressNodeHandler(options = {}) {
121
130
  };
122
131
  }
123
132
 
133
+ exports.EGRESS_MARKER = EGRESS_MARKER;
134
+ exports.EGRESS_PATH = EGRESS_PATH;
124
135
  exports.egressNodeHandler = egressNodeHandler;
125
136
  exports.handleEgressRequest = handleEgressRequest;
126
137
  //# sourceMappingURL=egress.cjs.map
package/dist/egress.d.cts CHANGED
@@ -48,4 +48,36 @@ interface NodeResponse {
48
48
  */
49
49
  declare function egressNodeHandler(options?: EgressOptions): (request: NodeRequest, response: NodeResponse) => Promise<void>;
50
50
 
51
- export { type EgressOptions, egressNodeHandler, handleEgressRequest };
51
+ /**
52
+ * Finding the proxy instead of being told where it is.
53
+ *
54
+ * A page cannot read a response from a host that does not send CORS headers,
55
+ * so a container in a browser needs a proxy to make its outbound requests --
56
+ * and until now every project had to say where that proxy was. That is a
57
+ * setting whose right value is almost always the same, and getting it wrong
58
+ * looks like the container being broken: the guest reports "Failed to fetch"
59
+ * from code that works everywhere else.
60
+ *
61
+ * So a browser container with no proxy configured looks for one before it
62
+ * gives up. A host that mounts `handleEgressRequest` at `EGRESS_PATH` on its
63
+ * own origin, or runs `sandboxedjs-egress` on its default port, is found
64
+ * without being named.
65
+ *
66
+ * A probe is a GET, which the handler answers with a marker. That matters more
67
+ * than it sounds: a single-page host answers every unknown path with its own
68
+ * index.html and status 200, so "the request succeeded" cannot mean "the proxy
69
+ * is here". Only the marker does.
70
+ *
71
+ * Discovery never overrides a proxy the host named, and finding nothing is not
72
+ * an error -- the request is then made directly, which is right on a server
73
+ * and is what produces the explanatory CORS failure in a browser.
74
+ */
75
+ /** Where a host app mounts the proxy for its own containers to find. */
76
+ declare const EGRESS_PATH = "/__sandboxedjs__/egress";
77
+ /** What a GET to the proxy answers, so a probe can tell it from a 200 page. */
78
+ declare const EGRESS_MARKER: {
79
+ readonly sandboxedjs: "egress";
80
+ readonly protocol: 1;
81
+ };
82
+
83
+ export { EGRESS_MARKER, EGRESS_PATH, type EgressOptions, egressNodeHandler, handleEgressRequest };
package/dist/egress.d.ts CHANGED
@@ -48,4 +48,36 @@ interface NodeResponse {
48
48
  */
49
49
  declare function egressNodeHandler(options?: EgressOptions): (request: NodeRequest, response: NodeResponse) => Promise<void>;
50
50
 
51
- export { type EgressOptions, egressNodeHandler, handleEgressRequest };
51
+ /**
52
+ * Finding the proxy instead of being told where it is.
53
+ *
54
+ * A page cannot read a response from a host that does not send CORS headers,
55
+ * so a container in a browser needs a proxy to make its outbound requests --
56
+ * and until now every project had to say where that proxy was. That is a
57
+ * setting whose right value is almost always the same, and getting it wrong
58
+ * looks like the container being broken: the guest reports "Failed to fetch"
59
+ * from code that works everywhere else.
60
+ *
61
+ * So a browser container with no proxy configured looks for one before it
62
+ * gives up. A host that mounts `handleEgressRequest` at `EGRESS_PATH` on its
63
+ * own origin, or runs `sandboxedjs-egress` on its default port, is found
64
+ * without being named.
65
+ *
66
+ * A probe is a GET, which the handler answers with a marker. That matters more
67
+ * than it sounds: a single-page host answers every unknown path with its own
68
+ * index.html and status 200, so "the request succeeded" cannot mean "the proxy
69
+ * is here". Only the marker does.
70
+ *
71
+ * Discovery never overrides a proxy the host named, and finding nothing is not
72
+ * an error -- the request is then made directly, which is right on a server
73
+ * and is what produces the explanatory CORS failure in a browser.
74
+ */
75
+ /** Where a host app mounts the proxy for its own containers to find. */
76
+ declare const EGRESS_PATH = "/__sandboxedjs__/egress";
77
+ /** What a GET to the proxy answers, so a probe can tell it from a 200 page. */
78
+ declare const EGRESS_MARKER: {
79
+ readonly sandboxedjs: "egress";
80
+ readonly protocol: 1;
81
+ };
82
+
83
+ export { EGRESS_MARKER, EGRESS_PATH, type EgressOptions, egressNodeHandler, handleEgressRequest };