Skip to content

Commit 7dbcd8b

Browse files
committed
examples/egress: Demonstrate egress policies
Run the same HTTP request through the container shell, Worker shell, and Worker JavaScript backends. Apply one EGRESS_MODE policy to all three and return comparable status, MIME type, or error results. Include blocked, direct, and allowlisted gateway modes with focused tests and local run instructions. Pin the container runtime to Debian bookworm so package signature verification and the FUSE 2 library remain compatible.
1 parent 5affc94 commit 7dbcd8b

12 files changed

Lines changed: 15277 additions & 0 deletions

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ public surface. Each is a Worker workspace with its own README.
6060
- [`examples/worker-javascript`](examples/worker-javascript) — mirrors
6161
`worker-shell`, but `exec` evaluates an ECMAScript module in a Dynamic
6262
Worker instead of running a shell command.
63+
- [`examples/egress`](examples/egress) — sends one URL through the container,
64+
Worker shell, and Worker JavaScript backends with matching `none`, `all`, or
65+
custom egress policies.
6366
- [`examples/think`](examples/think) — a [`@cloudflare/think`](https://www.npmjs.com/package/@cloudflare/think)
6467
chat agent that uses the workspace as its working directory, reachable
6568
from a terminal.

examples/egress/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules/
2+
dist/
3+
.wrangler/
4+
.dev.vars*
5+
.env*

examples/egress/Dockerfile

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
FROM ghcr.io/cloudflare/computer-computerd-linux-x64:0.1.1 AS computerd
2+
3+
FROM debian:bookworm-slim
4+
5+
RUN apt-get update \
6+
&& apt-get install -y --no-install-recommends \
7+
fuse3 libfuse2 ca-certificates curl \
8+
&& rm -rf /var/lib/apt/lists/*
9+
10+
COPY --from=computerd /usr/local/bin/computerd /usr/local/bin/computerd
11+
12+
ENV PORT=8080
13+
ENV MOUNT_POINT=/workspace
14+
ENV FUSE_MOUNT=auto
15+
EXPOSE 8080
16+
17+
ENTRYPOINT ["/usr/local/bin/computerd"]

examples/egress/README.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# egress example
2+
3+
> [!IMPORTANT]
4+
> **PREVIEW ONLY** This package is provided as a preview for feedback only.
5+
> APIs are unstable and the design is subject to change.
6+
7+
This Worker sends one URL through all three Cloudflare Computer execution backends:
8+
9+
- the container shell runs `curl`;
10+
- the Worker shell runs its `curl` command;
11+
- Worker JavaScript runs `fetch`.
12+
13+
`EGRESS_MODE` applies the same egress policy to every backend.
14+
15+
| `EGRESS_MODE` | Workspace policy | Behavior |
16+
| --- | --- | --- |
17+
| `none` | `{ mode: "none" }` | Blocks outbound network access. This is the default. |
18+
| `all` | `{ mode: "direct" }` | Allows direct outbound access. |
19+
| `custom` | `{ mode: "http-gateway" }` | Routes requests through a gateway that allows only `https://example.com`. Other origins receive `403`. |
20+
21+
## Run it
22+
23+
The container backend requires Docker. Start the example with one of the three modes:
24+
25+
```sh
26+
npm run dev --workspace @example/computer-egress -- --var EGRESS_MODE:none
27+
npm run dev --workspace @example/computer-egress -- --var EGRESS_MODE:all
28+
npm run dev --workspace @example/computer-egress -- --var EGRESS_MODE:custom
29+
```
30+
31+
Then send a request:
32+
33+
```sh
34+
curl -X POST 'http://127.0.0.1:8787/fetch?url=https%3A%2F%2Fexample.com'
35+
```
36+
37+
The endpoint returns the status code and MIME type from each backend, or an error when no response was available:
38+
39+
```json
40+
{
41+
"mode": "all",
42+
"url": "https://example.com/",
43+
"container": { "status": 200, "mimeType": "text/html" },
44+
"worker-shell": { "status": 200, "mimeType": "text/html" },
45+
"worker-javascript": { "status": 200, "mimeType": "text/html" }
46+
}
47+
```
48+
49+
With `EGRESS_MODE=none`, each backend returns an `error` object. With `EGRESS_MODE=custom`, try an origin outside the allowlist to see the gateway response:
50+
51+
```sh
52+
curl -X POST 'http://127.0.0.1:8787/fetch?url=https%3A%2F%2Fcloudflare.com'
53+
```
54+
55+
Each backend reports status `403` and MIME type `text/plain`.
56+
57+
## HTTP surface
58+
59+
```text
60+
POST /fetch?url=<HTTP-or-HTTPS-URL>
61+
```
62+
63+
The endpoint accepts only `POST`. Missing, malformed, and non-HTTP URLs return `400`.
64+
65+
## Layout
66+
67+
```text
68+
examples/egress/
69+
Dockerfile computerd, FUSE libraries, and curl
70+
wrangler.jsonc Worker Loader, container, durable object, and EGRESS_MODE
71+
src/egress.ts egress policy and response helpers
72+
src/index.ts gateway, three backends, and HTTP handler
73+
```

examples/egress/package.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "@example/computer-egress",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"description": "Example Worker that compares egress policies across the Cloudflare Computer execution backends.",
7+
"scripts": {
8+
"dev": "wrangler dev",
9+
"deploy": "wrangler deploy",
10+
"test": "vitest run",
11+
"typecheck": "tsc --noEmit"
12+
},
13+
"dependencies": {
14+
"@cloudflare/computer": "*"
15+
},
16+
"devDependencies": {
17+
"typescript": "^6.0.3",
18+
"vitest": "^4.1.7",
19+
"wrangler": "^4.107.1"
20+
}
21+
}

examples/egress/src/egress.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
3+
import { mimeType, parseShellFetchResult, targetUrl, workspaceEgressPolicy } from "./egress.js";
4+
5+
describe("workspaceEgressPolicy", () => {
6+
it("blocks egress in none mode", () => {
7+
const gateway = vi.fn<() => Fetcher>();
8+
9+
expect(workspaceEgressPolicy("none", gateway)).toEqual({ mode: "none" });
10+
expect(gateway).not.toHaveBeenCalled();
11+
});
12+
13+
it("uses direct egress in all mode", () => {
14+
const gateway = vi.fn<() => Fetcher>();
15+
16+
expect(workspaceEgressPolicy("all", gateway)).toEqual({ mode: "direct" });
17+
expect(gateway).not.toHaveBeenCalled();
18+
});
19+
20+
it("uses the custom HTTP gateway in custom mode", () => {
21+
const fetcher = { fetch: vi.fn() } as unknown as Fetcher;
22+
const gateway = vi.fn(() => fetcher);
23+
24+
expect(workspaceEgressPolicy("custom", gateway)).toEqual({
25+
mode: "http-gateway",
26+
gateway: fetcher,
27+
revision: "v1",
28+
});
29+
expect(gateway).toHaveBeenCalledOnce();
30+
});
31+
32+
it("rejects unsupported modes", () => {
33+
expect(() => workspaceEgressPolicy("invalid", vi.fn())).toThrow(
34+
'EGRESS_MODE must be "none", "all", or "custom"; got "invalid"',
35+
);
36+
});
37+
});
38+
39+
describe("mimeType", () => {
40+
it("removes content type parameters", () => {
41+
expect(mimeType("text/html; charset=UTF-8")).toBe("text/html");
42+
});
43+
44+
it("returns null for a missing content type", () => {
45+
expect(mimeType(null)).toBeNull();
46+
expect(mimeType("")).toBeNull();
47+
});
48+
});
49+
50+
describe("targetUrl", () => {
51+
it("accepts HTTP and HTTPS URLs", () => {
52+
expect(targetUrl("https://example.com/path")).toBe("https://example.com/path");
53+
expect(targetUrl("http://example.com")).toBe("http://example.com/");
54+
});
55+
56+
it("rejects missing, malformed, and non-HTTP URLs", () => {
57+
expect(() => targetUrl(null)).toThrow("url query parameter is required");
58+
expect(() => targetUrl("not a URL")).toThrow("url must be a valid HTTP or HTTPS URL");
59+
expect(() => targetUrl("ftp://example.com/file")).toThrow(
60+
"url must be a valid HTTP or HTTPS URL",
61+
);
62+
});
63+
});
64+
65+
describe("parseShellFetchResult", () => {
66+
it("returns the status and MIME type from curl output", () => {
67+
expect(parseShellFetchResult(0, "204\ntext/plain; charset=utf-8", "")).toEqual({
68+
status: 204,
69+
mimeType: "text/plain",
70+
});
71+
});
72+
73+
it("returns an error when curl fails", () => {
74+
expect(parseShellFetchResult(7, "", "curl: network access denied\n")).toEqual({
75+
error: "curl: network access denied",
76+
});
77+
});
78+
79+
it("returns an error for malformed curl output", () => {
80+
expect(parseShellFetchResult(0, "not-a-status\ntext/plain", "")).toEqual({
81+
error: "curl returned an invalid status code",
82+
});
83+
});
84+
});

examples/egress/src/egress.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import type { WorkspaceEgressPolicy } from "@cloudflare/computer";
2+
3+
export type EgressMode = "none" | "all" | "custom";
4+
5+
export type FetchResult = { status: number; mimeType: string | null } | { error: string };
6+
7+
export function workspaceEgressPolicy(mode: string, gateway: () => Fetcher): WorkspaceEgressPolicy {
8+
switch (mode) {
9+
case "none":
10+
return { mode: "none" };
11+
case "all":
12+
return { mode: "direct" };
13+
case "custom":
14+
return { mode: "http-gateway", gateway: gateway(), revision: "v1" };
15+
default:
16+
throw new Error(`EGRESS_MODE must be "none", "all", or "custom"; got "${mode}"`);
17+
}
18+
}
19+
20+
export function mimeType(contentType: string | null): string | null {
21+
return contentType?.split(";", 1)[0]?.trim() || null;
22+
}
23+
24+
export function targetUrl(value: string | null): string {
25+
if (value === null || value.length === 0) {
26+
throw new Error("url query parameter is required");
27+
}
28+
29+
let url: URL;
30+
try {
31+
url = new URL(value);
32+
} catch {
33+
throw new Error("url must be a valid HTTP or HTTPS URL");
34+
}
35+
if (url.protocol !== "http:" && url.protocol !== "https:") {
36+
throw new Error("url must be a valid HTTP or HTTPS URL");
37+
}
38+
return url.href;
39+
}
40+
41+
export function parseShellFetchResult(
42+
exitCode: number,
43+
stdout: string,
44+
stderr: string,
45+
): FetchResult {
46+
if (exitCode !== 0) {
47+
return { error: stderr.trim() || `curl exited with code ${exitCode}` };
48+
}
49+
50+
const [statusLine, contentType = ""] = stdout.trimEnd().split("\n");
51+
const status = Number(statusLine);
52+
if (!Number.isInteger(status) || status < 100 || status > 599) {
53+
return { error: "curl returned an invalid status code" };
54+
}
55+
return { status, mimeType: mimeType(contentType) };
56+
}
57+
58+
export function errorResult(error: unknown): FetchResult {
59+
return { error: error instanceof Error ? error.message : String(error) };
60+
}

0 commit comments

Comments
 (0)