sandboxedjs 0.2.4 → 0.2.5
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 +44 -13
- package/bin/sandboxedjs-egress.mjs +8 -69
- package/dist/agent.d.cts +2 -2
- package/dist/agent.d.ts +2 -2
- package/dist/{container-C16pCOWC.d.ts → container-BQx27_d-.d.cts} +1 -1
- package/dist/{container-DJfFjito.d.cts → container-D-e5SMXQ.d.ts} +1 -1
- package/dist/{contracts-CXZ_25-O.d.cts → contracts-BHo4LdY2.d.cts} +9 -0
- package/dist/{contracts-CXZ_25-O.d.ts → contracts-BHo4LdY2.d.ts} +9 -0
- package/dist/egress.cjs +127 -0
- package/dist/egress.d.cts +51 -0
- package/dist/egress.d.ts +51 -0
- package/dist/egress.js +124 -0
- package/dist/index.cjs +102 -57
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +102 -57
- package/dist/{memory-volume-Bp71nqvc.d.cts → memory-volume-e-uJFRnG.d.cts} +1 -1
- package/dist/{memory-volume-C4xwtIRZ.d.ts → memory-volume-meoRW8ST.d.ts} +1 -1
- package/dist/python-abi.d.cts +3 -3
- package/dist/python-abi.d.ts +3 -3
- package/dist/service-worker.js +70 -2
- package/dist/worker-entry.js +88 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -51,7 +51,8 @@ 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
|
|
54
|
+
Give it somewhere to send those requests instead. In development that is a
|
|
55
|
+
process:
|
|
55
56
|
|
|
56
57
|
```bash
|
|
57
58
|
npx sandboxedjs-egress 4181 --allow api.openai.com,ollama.com
|
|
@@ -63,18 +64,48 @@ const box = await createContainer({
|
|
|
63
64
|
});
|
|
64
65
|
```
|
|
65
66
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { egressNodeHandler } from "sandboxedjs/egress";
|
|
73
|
+
app.post("/egress", egressNodeHandler({ allow: ["ollama.com"] }));
|
|
74
|
+
// createContainer({ network: { allowOutbound: true, proxy: "/egress" } })
|
|
75
|
+
```
|
|
76
|
+
|
|
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:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// functions/egress.ts
|
|
82
|
+
import { handleEgressRequest } from "sandboxedjs/egress";
|
|
83
|
+
export const onRequest = ({ request }) => handleEgressRequest(request, { allow: ["ollama.com"] });
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Every exit honours it — `curl`, `wget`, a guest's own `fetch`, and Python's
|
|
87
|
+
sockets — so it means the same thing whatever the project is written in.
|
|
88
|
+
Loopback stays inside the container, and the container's outbound policy still
|
|
89
|
+
applies before anything is handed over: a proxy widens what a *page* can reach,
|
|
90
|
+
not what the container may.
|
|
91
|
+
|
|
92
|
+
Anything that can reach the proxy can make requests through it carrying
|
|
93
|
+
whatever credentials the guest holds, so keep `--allow` set, keep it on
|
|
94
|
+
loopback in development, and put it behind your own authentication in
|
|
95
|
+
production.
|
|
96
|
+
|
|
97
|
+
## Servers inside the container
|
|
98
|
+
|
|
99
|
+
A project split into a frontend and a backend calls `http://localhost:8000/api`
|
|
100
|
+
from the frontend. That address is true inside the container and is what the
|
|
101
|
+
project uses everywhere else, so it is left exactly as written: a script in
|
|
102
|
+
each previewed page rewrites loopback addresses to the preview's own origin,
|
|
103
|
+
and the service worker routes them by the port in the path.
|
|
104
|
+
|
|
105
|
+
That works between pages of the preview, and from another tab of the same
|
|
106
|
+
browser while the page holding the container is open. It does not work from
|
|
107
|
+
outside that browser — Postman, curl, another machine — because the container
|
|
108
|
+
is the tab. There is no server anywhere to reach.
|
|
78
109
|
|
|
79
110
|
## Why
|
|
80
111
|
|
|
@@ -21,82 +21,21 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { createServer } from "node:http";
|
|
24
|
+
import { egressNodeHandler } from "../dist/egress.js";
|
|
24
25
|
|
|
25
26
|
const args = process.argv.slice(2);
|
|
26
27
|
const at = args.indexOf("--allow");
|
|
27
|
-
const
|
|
28
|
+
const allow = at === -1 ? undefined : (args[at + 1] ?? "").split(",").map((h) => h.trim()).filter(Boolean);
|
|
28
29
|
const port = Number(args.find((a) => /^\d+$/.test(a)) ?? 4181);
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
* Forwarding them describes this hop's framing to a server that is not on it. */
|
|
32
|
-
const HOP = new Set([
|
|
33
|
-
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
|
|
34
|
-
"te", "trailer", "transfer-encoding", "upgrade", "host", "content-length",
|
|
35
|
-
]);
|
|
36
|
-
|
|
37
|
-
const cors = {
|
|
38
|
-
"access-control-allow-origin": "*",
|
|
39
|
-
"access-control-allow-methods": "POST, OPTIONS",
|
|
40
|
-
"access-control-allow-headers": "content-type",
|
|
41
|
-
"access-control-max-age": "86400",
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
const send = (res, status, body, type = "text/plain; charset=utf-8") =>
|
|
45
|
-
res.writeHead(status, { ...cors, "content-type": type }).end(body);
|
|
31
|
+
const handle = egressNodeHandler({ ...(allow ? { allow } : {}) });
|
|
46
32
|
|
|
47
33
|
createServer(async (req, res) => {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
let payload;
|
|
52
|
-
try {
|
|
53
|
-
const chunks = [];
|
|
54
|
-
for await (const chunk of req) chunks.push(chunk);
|
|
55
|
-
payload = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
56
|
-
} catch (error) {
|
|
57
|
-
return send(res, 400, `could not read the request: ${error.message}`);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
let target;
|
|
61
|
-
try {
|
|
62
|
-
target = new URL(payload.url);
|
|
63
|
-
} catch {
|
|
64
|
-
return send(res, 400, `not a URL: ${payload.url}`);
|
|
65
|
-
}
|
|
66
|
-
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
67
|
-
return send(res, 400, `${target.protocol} is not a protocol this proxy speaks`);
|
|
68
|
-
}
|
|
69
|
-
if (allowed && !allowed.some((host) => target.hostname === host || target.hostname.endsWith(`.${host}`))) {
|
|
70
|
-
return send(res, 403, `${target.hostname} is not in this proxy's --allow list`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const headers = Object.fromEntries(
|
|
74
|
-
Object.entries(payload.headers ?? {}).filter(([name]) => !HOP.has(name.toLowerCase())),
|
|
75
|
-
);
|
|
76
|
-
try {
|
|
77
|
-
const answer = await fetch(target, {
|
|
78
|
-
method: payload.method ?? "GET",
|
|
79
|
-
headers,
|
|
80
|
-
...(payload.body ? { body: Buffer.from(payload.body, "base64") } : {}),
|
|
81
|
-
redirect: "follow",
|
|
82
|
-
});
|
|
83
|
-
const body = Buffer.from(await answer.arrayBuffer());
|
|
84
|
-
const back = {};
|
|
85
|
-
answer.headers.forEach((value, name) => {
|
|
86
|
-
if (!HOP.has(name.toLowerCase())) back[name] = value;
|
|
87
|
-
});
|
|
88
|
-
console.log(`${payload.method ?? "GET"} ${target.href} -> ${answer.status} (${body.length} bytes)`);
|
|
89
|
-
send(res, 200, JSON.stringify({
|
|
90
|
-
status: answer.status,
|
|
91
|
-
statusText: answer.statusText,
|
|
92
|
-
headers: back,
|
|
93
|
-
body: body.toString("base64"),
|
|
94
|
-
}), "application/json");
|
|
95
|
-
} catch (error) {
|
|
96
|
-
console.error(`${payload.method ?? "GET"} ${target.href} -> ${error.message}`);
|
|
97
|
-
send(res, 502, `the request to ${target.hostname} failed: ${error.message}`);
|
|
98
|
-
}
|
|
34
|
+
const started = Date.now();
|
|
35
|
+
await handle(req, res);
|
|
36
|
+
console.log(`${req.method} -> ${res.statusCode} (${Date.now() - started}ms)`);
|
|
99
37
|
}).listen(port, "127.0.0.1", () => {
|
|
100
|
-
console.log(`sandboxedjs-egress on http://localhost:${port}${
|
|
38
|
+
console.log(`sandboxedjs-egress on http://localhost:${port}${allow ? ` (allowing ${allow.join(", ")})` : ""}`);
|
|
101
39
|
console.log(` createContainer({ network: { allowOutbound: true, proxy: "http://localhost:${port}" } })`);
|
|
40
|
+
console.log(" or mount it in your own app: import { egressNodeHandler } from \"sandboxedjs/egress\"");
|
|
102
41
|
});
|
package/dist/agent.d.cts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { C as Container } from './container-
|
|
2
|
-
import './contracts-
|
|
1
|
+
import { C as Container } from './container-BQx27_d-.cjs';
|
|
2
|
+
import './contracts-BHo4LdY2.cjs';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Structural copies of the LangChain Deep Agents backend contract.
|
package/dist/agent.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { V as Vfs, C as Cred, f as RuntimePod, O as OutboundPolicy, D as DirEntry, p as Stats } from './contracts-
|
|
1
|
+
import { V as Vfs, C as Cred, f as RuntimePod, O as OutboundPolicy, D as DirEntry, p as Stats } from './contracts-BHo4LdY2.cjs';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Byte streams for stdin/stdout/stderr, pipelines and redirections.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { V as Vfs, C as Cred, f as RuntimePod, O as OutboundPolicy, D as DirEntry, p as Stats } from './contracts-
|
|
1
|
+
import { V as Vfs, C as Cred, f as RuntimePod, O as OutboundPolicy, D as DirEntry, p as Stats } from './contracts-BHo4LdY2.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Byte streams for stdin/stdout/stderr, pipelines and redirections.
|
|
@@ -114,6 +114,15 @@ interface OutboundPolicy {
|
|
|
114
114
|
allowOutbound: boolean;
|
|
115
115
|
/** When outbound is on, the hosts it may reach (subdomains included). `null` means any. */
|
|
116
116
|
allowedHosts: string[] | null;
|
|
117
|
+
/**
|
|
118
|
+
* A URL that performs this container's outbound requests on its behalf.
|
|
119
|
+
*
|
|
120
|
+
* Carried with the policy rather than beside it, because every exit has to
|
|
121
|
+
* honour it: `curl`, the Python egress and a guest's own `fetch` all leave
|
|
122
|
+
* the same way, and a proxy that covered only some of them would be a
|
|
123
|
+
* setting whose meaning depended on which language the guest was written in.
|
|
124
|
+
*/
|
|
125
|
+
proxy?: string;
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
/**
|
|
@@ -114,6 +114,15 @@ interface OutboundPolicy {
|
|
|
114
114
|
allowOutbound: boolean;
|
|
115
115
|
/** When outbound is on, the hosts it may reach (subdomains included). `null` means any. */
|
|
116
116
|
allowedHosts: string[] | null;
|
|
117
|
+
/**
|
|
118
|
+
* A URL that performs this container's outbound requests on its behalf.
|
|
119
|
+
*
|
|
120
|
+
* Carried with the policy rather than beside it, because every exit has to
|
|
121
|
+
* honour it: `curl`, the Python egress and a guest's own `fetch` all leave
|
|
122
|
+
* the same way, and a proxy that covered only some of them would be a
|
|
123
|
+
* setting whose meaning depended on which language the guest was written in.
|
|
124
|
+
*/
|
|
125
|
+
proxy?: string;
|
|
117
126
|
}
|
|
118
127
|
|
|
119
128
|
/**
|
package/dist/egress.cjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/net/proxy-fetch.ts
|
|
4
|
+
function toBase64(bytes) {
|
|
5
|
+
let text = "";
|
|
6
|
+
for (let at = 0; at < bytes.length; at += 32768) {
|
|
7
|
+
text += String.fromCharCode(...bytes.subarray(at, at + 32768));
|
|
8
|
+
}
|
|
9
|
+
return btoa(text);
|
|
10
|
+
}
|
|
11
|
+
function fromBase64(text) {
|
|
12
|
+
const binary = atob(text);
|
|
13
|
+
const bytes = new Uint8Array(binary.length);
|
|
14
|
+
for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at);
|
|
15
|
+
return bytes;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/net/egress-handler.ts
|
|
19
|
+
var HOP = /* @__PURE__ */ new Set([
|
|
20
|
+
"connection",
|
|
21
|
+
"keep-alive",
|
|
22
|
+
"proxy-authenticate",
|
|
23
|
+
"proxy-authorization",
|
|
24
|
+
"te",
|
|
25
|
+
"trailer",
|
|
26
|
+
"transfer-encoding",
|
|
27
|
+
"upgrade",
|
|
28
|
+
"host",
|
|
29
|
+
"content-length"
|
|
30
|
+
]);
|
|
31
|
+
var HEADERS = {
|
|
32
|
+
"access-control-allow-origin": "*",
|
|
33
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
34
|
+
"access-control-allow-headers": "content-type",
|
|
35
|
+
"access-control-max-age": "86400",
|
|
36
|
+
"cross-origin-resource-policy": "cross-origin"
|
|
37
|
+
};
|
|
38
|
+
var refuse = (status, message) => new Response(message, { status, headers: { ...HEADERS, "content-type": "text/plain; charset=utf-8" } });
|
|
39
|
+
async function handleEgressRequest(request, options = {}) {
|
|
40
|
+
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: HEADERS });
|
|
41
|
+
if (request.method !== "POST") return refuse(405, "POST a JSON request here.");
|
|
42
|
+
let payload;
|
|
43
|
+
try {
|
|
44
|
+
payload = await request.json();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return refuse(400, `could not read the request: ${error instanceof Error ? error.message : String(error)}`);
|
|
47
|
+
}
|
|
48
|
+
let target;
|
|
49
|
+
try {
|
|
50
|
+
target = new URL(String(payload.url));
|
|
51
|
+
} catch {
|
|
52
|
+
return refuse(400, `not a URL: ${payload.url}`);
|
|
53
|
+
}
|
|
54
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
55
|
+
return refuse(400, `${target.protocol} is not a protocol this proxy speaks`);
|
|
56
|
+
}
|
|
57
|
+
const allow = options.allow;
|
|
58
|
+
if (allow && !allow.some((host) => target.hostname === host || target.hostname.endsWith(`.${host}`))) {
|
|
59
|
+
return refuse(403, `${target.hostname} is not in this proxy's allow list`);
|
|
60
|
+
}
|
|
61
|
+
const headers = {};
|
|
62
|
+
for (const [name, value] of Object.entries(payload.headers ?? {})) {
|
|
63
|
+
if (!HOP.has(name.toLowerCase())) headers[name] = value;
|
|
64
|
+
}
|
|
65
|
+
const perform = options.fetch ?? fetch;
|
|
66
|
+
let answer;
|
|
67
|
+
try {
|
|
68
|
+
answer = await perform(target.toString(), {
|
|
69
|
+
method: payload.method ?? "GET",
|
|
70
|
+
headers,
|
|
71
|
+
...payload.body ? { body: fromBase64(payload.body) } : {},
|
|
72
|
+
redirect: "follow"
|
|
73
|
+
});
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return refuse(502, `the request to ${target.hostname} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
76
|
+
}
|
|
77
|
+
const back = {};
|
|
78
|
+
answer.headers.forEach((value, name) => {
|
|
79
|
+
if (!HOP.has(name.toLowerCase())) back[name] = value;
|
|
80
|
+
});
|
|
81
|
+
const body = new Uint8Array(await answer.arrayBuffer());
|
|
82
|
+
return new Response(
|
|
83
|
+
JSON.stringify({ status: answer.status, statusText: answer.statusText, headers: back, body: toBase64(body) }),
|
|
84
|
+
{ headers: { ...HEADERS, "content-type": "application/json" } }
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
function egressNodeHandler(options = {}) {
|
|
88
|
+
return async (request, response) => {
|
|
89
|
+
const chunks = [];
|
|
90
|
+
await new Promise((done, fail) => {
|
|
91
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
92
|
+
request.on("end", () => done());
|
|
93
|
+
request.on("error", (error) => fail(error));
|
|
94
|
+
});
|
|
95
|
+
let total = 0;
|
|
96
|
+
for (const chunk of chunks) total += chunk.length;
|
|
97
|
+
const body = new Uint8Array(total);
|
|
98
|
+
let at = 0;
|
|
99
|
+
for (const chunk of chunks) {
|
|
100
|
+
body.set(chunk, at);
|
|
101
|
+
at += chunk.length;
|
|
102
|
+
}
|
|
103
|
+
const headers = {};
|
|
104
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
105
|
+
if (typeof value === "string") headers[name] = value;
|
|
106
|
+
}
|
|
107
|
+
const answer = await handleEgressRequest(
|
|
108
|
+
new Request("http://egress.invalid/", {
|
|
109
|
+
method: request.method ?? "GET",
|
|
110
|
+
headers,
|
|
111
|
+
...body.length && request.method !== "GET" && request.method !== "HEAD" ? { body } : {}
|
|
112
|
+
}),
|
|
113
|
+
options
|
|
114
|
+
);
|
|
115
|
+
const out = {};
|
|
116
|
+
answer.headers.forEach((value, name) => {
|
|
117
|
+
out[name] = value;
|
|
118
|
+
});
|
|
119
|
+
response.writeHead(answer.status, out);
|
|
120
|
+
response.end(Buffer.from(await answer.arrayBuffer()));
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
exports.egressNodeHandler = egressNodeHandler;
|
|
125
|
+
exports.handleEgressRequest = handleEgressRequest;
|
|
126
|
+
//# sourceMappingURL=egress.cjs.map
|
|
127
|
+
//# sourceMappingURL=egress.cjs.map
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proxy, as something a host app can mount instead of run.
|
|
3
|
+
*
|
|
4
|
+
* `sandboxedjs-egress` is a whole process, which suits a terminal and suits
|
|
5
|
+
* nothing else. A host app usually already has a server — an Express route, a
|
|
6
|
+
* Cloudflare Pages Function, a Worker — and mounting the proxy there is better
|
|
7
|
+
* than standing another one beside it: the container then calls its own
|
|
8
|
+
* origin, so there is no second port to start, no CORS on the proxy itself,
|
|
9
|
+
* and one thing to deploy rather than two.
|
|
10
|
+
*
|
|
11
|
+
* A static site has no server at all, which is the case this exists for. Those
|
|
12
|
+
* hosts all take a function of a `Request`, so that is the shape here, with a
|
|
13
|
+
* Node adapter for hosts that predate it.
|
|
14
|
+
*
|
|
15
|
+
* // Cloudflare Pages: functions/egress.ts
|
|
16
|
+
* import { handleEgressRequest } from "sandboxedjs/egress";
|
|
17
|
+
* export const onRequest = ({ request }) =>
|
|
18
|
+
* handleEgressRequest(request, { allow: ["ollama.com"] });
|
|
19
|
+
*
|
|
20
|
+
* Anything that can reach this can make requests through it, carrying whatever
|
|
21
|
+
* credentials the guest holds. `allow` is the limit on where those go, and on
|
|
22
|
+
* a public deployment it should be set and the route should be behind whatever
|
|
23
|
+
* authentication the rest of the app uses.
|
|
24
|
+
*/
|
|
25
|
+
interface EgressOptions {
|
|
26
|
+
/** Hosts this proxy will fetch, subdomains included. Unset means any. */
|
|
27
|
+
allow?: string[];
|
|
28
|
+
/** The `fetch` used to make the real request. */
|
|
29
|
+
fetch?: typeof globalThis.fetch;
|
|
30
|
+
}
|
|
31
|
+
/** Answer one proxied request. Safe to mount wherever a `Request` arrives. */
|
|
32
|
+
declare function handleEgressRequest(request: Request, options?: EgressOptions): Promise<Response>;
|
|
33
|
+
/** Minimal shapes of Node's request and response, so this needs no `@types/node`. */
|
|
34
|
+
interface NodeRequest {
|
|
35
|
+
method?: string;
|
|
36
|
+
url?: string;
|
|
37
|
+
headers: Record<string, string | string[] | undefined>;
|
|
38
|
+
on(event: string, listener: (chunk?: unknown) => void): unknown;
|
|
39
|
+
}
|
|
40
|
+
interface NodeResponse {
|
|
41
|
+
writeHead(status: number, headers: Record<string, string>): unknown;
|
|
42
|
+
end(body?: unknown): unknown;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The same handler for Express and `node:http`.
|
|
46
|
+
*
|
|
47
|
+
* app.post("/egress", egressNodeHandler({ allow: ["ollama.com"] }));
|
|
48
|
+
*/
|
|
49
|
+
declare function egressNodeHandler(options?: EgressOptions): (request: NodeRequest, response: NodeResponse) => Promise<void>;
|
|
50
|
+
|
|
51
|
+
export { type EgressOptions, egressNodeHandler, handleEgressRequest };
|
package/dist/egress.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The proxy, as something a host app can mount instead of run.
|
|
3
|
+
*
|
|
4
|
+
* `sandboxedjs-egress` is a whole process, which suits a terminal and suits
|
|
5
|
+
* nothing else. A host app usually already has a server — an Express route, a
|
|
6
|
+
* Cloudflare Pages Function, a Worker — and mounting the proxy there is better
|
|
7
|
+
* than standing another one beside it: the container then calls its own
|
|
8
|
+
* origin, so there is no second port to start, no CORS on the proxy itself,
|
|
9
|
+
* and one thing to deploy rather than two.
|
|
10
|
+
*
|
|
11
|
+
* A static site has no server at all, which is the case this exists for. Those
|
|
12
|
+
* hosts all take a function of a `Request`, so that is the shape here, with a
|
|
13
|
+
* Node adapter for hosts that predate it.
|
|
14
|
+
*
|
|
15
|
+
* // Cloudflare Pages: functions/egress.ts
|
|
16
|
+
* import { handleEgressRequest } from "sandboxedjs/egress";
|
|
17
|
+
* export const onRequest = ({ request }) =>
|
|
18
|
+
* handleEgressRequest(request, { allow: ["ollama.com"] });
|
|
19
|
+
*
|
|
20
|
+
* Anything that can reach this can make requests through it, carrying whatever
|
|
21
|
+
* credentials the guest holds. `allow` is the limit on where those go, and on
|
|
22
|
+
* a public deployment it should be set and the route should be behind whatever
|
|
23
|
+
* authentication the rest of the app uses.
|
|
24
|
+
*/
|
|
25
|
+
interface EgressOptions {
|
|
26
|
+
/** Hosts this proxy will fetch, subdomains included. Unset means any. */
|
|
27
|
+
allow?: string[];
|
|
28
|
+
/** The `fetch` used to make the real request. */
|
|
29
|
+
fetch?: typeof globalThis.fetch;
|
|
30
|
+
}
|
|
31
|
+
/** Answer one proxied request. Safe to mount wherever a `Request` arrives. */
|
|
32
|
+
declare function handleEgressRequest(request: Request, options?: EgressOptions): Promise<Response>;
|
|
33
|
+
/** Minimal shapes of Node's request and response, so this needs no `@types/node`. */
|
|
34
|
+
interface NodeRequest {
|
|
35
|
+
method?: string;
|
|
36
|
+
url?: string;
|
|
37
|
+
headers: Record<string, string | string[] | undefined>;
|
|
38
|
+
on(event: string, listener: (chunk?: unknown) => void): unknown;
|
|
39
|
+
}
|
|
40
|
+
interface NodeResponse {
|
|
41
|
+
writeHead(status: number, headers: Record<string, string>): unknown;
|
|
42
|
+
end(body?: unknown): unknown;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The same handler for Express and `node:http`.
|
|
46
|
+
*
|
|
47
|
+
* app.post("/egress", egressNodeHandler({ allow: ["ollama.com"] }));
|
|
48
|
+
*/
|
|
49
|
+
declare function egressNodeHandler(options?: EgressOptions): (request: NodeRequest, response: NodeResponse) => Promise<void>;
|
|
50
|
+
|
|
51
|
+
export { type EgressOptions, egressNodeHandler, handleEgressRequest };
|
package/dist/egress.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// src/net/proxy-fetch.ts
|
|
2
|
+
function toBase64(bytes) {
|
|
3
|
+
let text = "";
|
|
4
|
+
for (let at = 0; at < bytes.length; at += 32768) {
|
|
5
|
+
text += String.fromCharCode(...bytes.subarray(at, at + 32768));
|
|
6
|
+
}
|
|
7
|
+
return btoa(text);
|
|
8
|
+
}
|
|
9
|
+
function fromBase64(text) {
|
|
10
|
+
const binary = atob(text);
|
|
11
|
+
const bytes = new Uint8Array(binary.length);
|
|
12
|
+
for (let at = 0; at < binary.length; at += 1) bytes[at] = binary.charCodeAt(at);
|
|
13
|
+
return bytes;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// src/net/egress-handler.ts
|
|
17
|
+
var HOP = /* @__PURE__ */ new Set([
|
|
18
|
+
"connection",
|
|
19
|
+
"keep-alive",
|
|
20
|
+
"proxy-authenticate",
|
|
21
|
+
"proxy-authorization",
|
|
22
|
+
"te",
|
|
23
|
+
"trailer",
|
|
24
|
+
"transfer-encoding",
|
|
25
|
+
"upgrade",
|
|
26
|
+
"host",
|
|
27
|
+
"content-length"
|
|
28
|
+
]);
|
|
29
|
+
var HEADERS = {
|
|
30
|
+
"access-control-allow-origin": "*",
|
|
31
|
+
"access-control-allow-methods": "POST, OPTIONS",
|
|
32
|
+
"access-control-allow-headers": "content-type",
|
|
33
|
+
"access-control-max-age": "86400",
|
|
34
|
+
"cross-origin-resource-policy": "cross-origin"
|
|
35
|
+
};
|
|
36
|
+
var refuse = (status, message) => new Response(message, { status, headers: { ...HEADERS, "content-type": "text/plain; charset=utf-8" } });
|
|
37
|
+
async function handleEgressRequest(request, options = {}) {
|
|
38
|
+
if (request.method === "OPTIONS") return new Response(null, { status: 204, headers: HEADERS });
|
|
39
|
+
if (request.method !== "POST") return refuse(405, "POST a JSON request here.");
|
|
40
|
+
let payload;
|
|
41
|
+
try {
|
|
42
|
+
payload = await request.json();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
return refuse(400, `could not read the request: ${error instanceof Error ? error.message : String(error)}`);
|
|
45
|
+
}
|
|
46
|
+
let target;
|
|
47
|
+
try {
|
|
48
|
+
target = new URL(String(payload.url));
|
|
49
|
+
} catch {
|
|
50
|
+
return refuse(400, `not a URL: ${payload.url}`);
|
|
51
|
+
}
|
|
52
|
+
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
53
|
+
return refuse(400, `${target.protocol} is not a protocol this proxy speaks`);
|
|
54
|
+
}
|
|
55
|
+
const allow = options.allow;
|
|
56
|
+
if (allow && !allow.some((host) => target.hostname === host || target.hostname.endsWith(`.${host}`))) {
|
|
57
|
+
return refuse(403, `${target.hostname} is not in this proxy's allow list`);
|
|
58
|
+
}
|
|
59
|
+
const headers = {};
|
|
60
|
+
for (const [name, value] of Object.entries(payload.headers ?? {})) {
|
|
61
|
+
if (!HOP.has(name.toLowerCase())) headers[name] = value;
|
|
62
|
+
}
|
|
63
|
+
const perform = options.fetch ?? fetch;
|
|
64
|
+
let answer;
|
|
65
|
+
try {
|
|
66
|
+
answer = await perform(target.toString(), {
|
|
67
|
+
method: payload.method ?? "GET",
|
|
68
|
+
headers,
|
|
69
|
+
...payload.body ? { body: fromBase64(payload.body) } : {},
|
|
70
|
+
redirect: "follow"
|
|
71
|
+
});
|
|
72
|
+
} catch (error) {
|
|
73
|
+
return refuse(502, `the request to ${target.hostname} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
74
|
+
}
|
|
75
|
+
const back = {};
|
|
76
|
+
answer.headers.forEach((value, name) => {
|
|
77
|
+
if (!HOP.has(name.toLowerCase())) back[name] = value;
|
|
78
|
+
});
|
|
79
|
+
const body = new Uint8Array(await answer.arrayBuffer());
|
|
80
|
+
return new Response(
|
|
81
|
+
JSON.stringify({ status: answer.status, statusText: answer.statusText, headers: back, body: toBase64(body) }),
|
|
82
|
+
{ headers: { ...HEADERS, "content-type": "application/json" } }
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
function egressNodeHandler(options = {}) {
|
|
86
|
+
return async (request, response) => {
|
|
87
|
+
const chunks = [];
|
|
88
|
+
await new Promise((done, fail) => {
|
|
89
|
+
request.on("data", (chunk) => chunks.push(chunk));
|
|
90
|
+
request.on("end", () => done());
|
|
91
|
+
request.on("error", (error) => fail(error));
|
|
92
|
+
});
|
|
93
|
+
let total = 0;
|
|
94
|
+
for (const chunk of chunks) total += chunk.length;
|
|
95
|
+
const body = new Uint8Array(total);
|
|
96
|
+
let at = 0;
|
|
97
|
+
for (const chunk of chunks) {
|
|
98
|
+
body.set(chunk, at);
|
|
99
|
+
at += chunk.length;
|
|
100
|
+
}
|
|
101
|
+
const headers = {};
|
|
102
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
103
|
+
if (typeof value === "string") headers[name] = value;
|
|
104
|
+
}
|
|
105
|
+
const answer = await handleEgressRequest(
|
|
106
|
+
new Request("http://egress.invalid/", {
|
|
107
|
+
method: request.method ?? "GET",
|
|
108
|
+
headers,
|
|
109
|
+
...body.length && request.method !== "GET" && request.method !== "HEAD" ? { body } : {}
|
|
110
|
+
}),
|
|
111
|
+
options
|
|
112
|
+
);
|
|
113
|
+
const out = {};
|
|
114
|
+
answer.headers.forEach((value, name) => {
|
|
115
|
+
out[name] = value;
|
|
116
|
+
});
|
|
117
|
+
response.writeHead(answer.status, out);
|
|
118
|
+
response.end(Buffer.from(await answer.arrayBuffer()));
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export { egressNodeHandler, handleEgressRequest };
|
|
123
|
+
//# sourceMappingURL=egress.js.map
|
|
124
|
+
//# sourceMappingURL=egress.js.map
|