Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

97 lines · 4.2 KBRaw History
1import { timingSafeEqual } from "node:crypto";
2import { createServer, request, type IncomingMessage, type Server, type ServerResponse } from "node:http";
3import type { AddressInfo } from "node:net";
4import path from "node:path";
5import { startSandbox, type SandboxHandle } from "./sandbox.js";
6
7/**
8 * Local development and end-to-end tests only: many sandboxes in one process,
9 * standing in for one Cloudflare Container per sandbox (containers don't run
10 * under `wrangler dev`). The Worker boots a sandbox with the same environment a
11 * container would get (`POST /boot`), then reaches it under `/s/<id>/...`.
12 */
13
14export interface PoolOptions {
15 port: number;
16 host?: string;
17 /** Guards /boot; each sandbox still checks its own secret. */
18 secret: string;
19 workRoot: string;
20}
21
22const same = (a: string, b: string) => a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
23
24function json(res: ServerResponse, status: number, body: unknown): void {
25 const data = JSON.stringify(body);
26 res.writeHead(status, { "content-type": "application/json", "content-length": Buffer.byteLength(data) });
27 res.end(data);
28}
29
30async function readJson(req: IncomingMessage): Promise<Record<string, unknown>> {
31 const chunks: Buffer[] = [];
32 for await (const c of req) chunks.push(c as Buffer);
33 return JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}") as Record<string, unknown>;
34}
35
36export async function startSandboxPool(o: PoolOptions): Promise<{ server: Server; port: number; close(): Promise<void> }> {
37 const sandboxes = new Map<string, SandboxHandle>();
38 const server = createServer((req, res) => {
39 void (async () => {
40 const url = new URL(req.url ?? "/", "http://pool");
41 try {
42 const poolAuth = () => same(/^Bearer\s+(.+)$/i.exec(req.headers.authorization ?? "")?.[1]?.trim() ?? "", o.secret);
43 if (req.method === "POST" && url.pathname === "/boot") {
44 if (!poolAuth()) return json(res, 401, { error: "unauthorized" });
45 const b = await readJson(req);
46 const id = String(b.id ?? "");
47 if (!/^[A-Za-z0-9_-]{1,64}$/.test(id) || typeof b.secret !== "string") return json(res, 400, { error: "`id` and `secret` are required" });
48 const env = (b.env ?? {}) as Record<string, string>;
49 await sandboxes.get(id)?.close();
50 const sb = await startSandbox({
51 port: 0,
52 host: "127.0.0.1",
53 secret: b.secret,
54 workRoot: path.join(o.workRoot, id),
55 cloneUrl: env.DIFFINERY_CLONE_URL,
56 ref: env.DIFFINERY_REF,
57 token: env.DIFFINERY_JOB_TOKEN,
58 baseEnv: { ...process.env, ...env },
59 });
60 sandboxes.set(id, sb);
61 return json(res, 200, { ok: true });
62 }
63 if (req.method === "POST" && url.pathname.startsWith("/shutdown/")) {
64 if (!poolAuth()) return json(res, 401, { error: "unauthorized" });
65 const id = url.pathname.slice("/shutdown/".length);
66 await sandboxes.get(id)?.close();
67 sandboxes.delete(id);
68 return json(res, 200, { ok: true });
69 }
70 const m = /^\/s\/([A-Za-z0-9_-]+)(\/.*)$/.exec(url.pathname);
71 const sb = m ? sandboxes.get(m[1]!) : undefined;
72 if (!m || !sb) return json(res, 404, { error: "sandbox not found" });
73 // Forward as is; the sandbox checks its own secret.
74 const up = request({ host: "127.0.0.1", port: sb.port, method: req.method, path: m[2]! + url.search, headers: req.headers }, (r) => {
75 res.writeHead(r.statusCode ?? 502, r.headers);
76 r.pipe(res);
77 });
78 up.on("error", (e) => json(res, 502, { error: e.message }));
79 req.pipe(up);
80 } catch (e) {
81 json(res, 500, { error: e instanceof Error ? e.message : String(e) });
82 }
83 })();
84 });
85 await new Promise<void>((resolve, reject) => {
86 server.once("error", reject);
87 server.listen(o.port, o.host ?? "127.0.0.1", () => resolve());
88 });
89 return {
90 server,
91 port: (server.address() as AddressInfo).port,
92 close: async () => {
93 await Promise.all([...sandboxes.values()].map((s) => s.close()));
94 await new Promise<void>((r) => server.close(() => r()));
95 },
96 };
97}