/** Shared helpers for the sites e2e scripts. */ import { request as httpRequest } from "node:http"; import { gzipSync } from "node:zlib"; export const HTTP_OK = 200; const HTTP_CREATED = 201; /** Prints a progress line; e2e scripts report on stdout. */ export function log(line: string): void { process.stdout.write(`${line}\n`); } export const FORGE = new URL(process.env.SITES_E2E_URL ?? "http://localhost:3000"); export const DOMAIN = process.env.SITES_DOMAIN ?? "gitforstatic.localhost"; export type Reply = { status: number; headers: Record; body: string }; export function send( host: string, path: string, init: { method?: string; headers?: Record; body?: Uint8Array } = {}, ): Promise { return new Promise((resolve, reject) => { const req = httpRequest( { host: FORGE.hostname, port: FORGE.port || 80, path, method: init.method ?? "GET", headers: { host: `${host}:${FORGE.port}`, ...init.headers }, }, (res) => { const chunks: Buffer[] = []; res.on("data", (chunk: Buffer) => chunks.push(chunk)); res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString() }), ); }, ); req.on("error", reject); if (init.body) req.write(init.body); req.end(); }); } export function tar(files: Record): Uint8Array { const blocks: Uint8Array[] = []; const encoder = new TextEncoder(); for (const [name, content] of Object.entries(files)) { const data = encoder.encode(content); const header = new Uint8Array(512); const put = (value: string, offset: number) => header.set(encoder.encode(value), offset); put(name, 0); put("0000644\0", 100); put("0000000\0", 108); put("0000000\0", 116); put(`${data.byteLength.toString(8).padStart(11, "0")}\0`, 124); put( `${Math.floor(Date.now() / 1000) .toString(8) .padStart(11, "0")}\0`, 136, ); put(" ", 148); put("0", 156); put("ustar\0", 257); put("00", 263); const sum = header.reduce((total, byte) => total + byte, 0); put(`${sum.toString(8).padStart(6, "0")}\0 `, 148); blocks.push(header, data, new Uint8Array((512 - (data.byteLength % 512)) % 512)); } blocks.push(new Uint8Array(1024)); return gzipSync(Buffer.concat(blocks)); } export const WORKER = ` export default { async fetch(request, env) { const url = new URL(request.url); if (url.pathname === "/echo") { return Response.json({ cookie: request.headers.get("cookie"), user: request.headers.get("x-gitfor-user"), env: Object.keys(env) }); } if (url.pathname === "/set") { const headers = new Headers(); headers.append("set-cookie", "site_token=" + url.hostname + "; Path=/"); headers.append("set-cookie", "__Host-gitfor_site=forged; Path=/; Secure"); return new Response("ok", { headers }); } if (url.pathname === "/toss") { const parent = url.hostname.slice(url.hostname.indexOf(".") + 1); const headers = new Headers(); headers.append("set-cookie", "tossed=1; Path=/; Domain=" + parent); headers.append("set-cookie", "own=1; Path=/; Domain=" + url.hostname); return new Response("tossed", { headers }); } if (url.pathname === "/fetch") { try { const response = await fetch(url.searchParams.get("u")); return new Response("fetched " + response.status); } catch (error) { return new Response("blocked", { status: 599 }); } } return env.ASSETS.fetch(request); }, };`; export async function deployScratch(marker: string): Promise<{ host: string; claimUrl: string; claimToken: string }> { const body = tar({ "index.html": `

${marker}

`, "_worker.js": WORKER }); const reply = await send(DOMAIN, "/__scratch", { method: "POST", headers: { "content-type": "application/gzip", "content-length": String(body.byteLength) }, body, }); if (reply.status !== HTTP_CREATED) throw new Error(`scratch deploy failed: ${reply.status} ${reply.body}`); const json = JSON.parse(reply.body) as { url: string; claimUrl: string; claimToken: string }; return { host: new URL(json.url).hostname, claimUrl: json.claimUrl, claimToken: json.claimToken }; } const EXPLORER = new URL("/cdn-cgi/local/explorer/api", FORGE).toString(); /** Runs SQL against the dev server's local D1 through the Local Explorer API (dev servers only). */ export async function d1(sql: string, params: (string | number)[] = []): Promise { const list = (await (await fetch(`${EXPLORER}/d1/database`)).json()) as { result: { uuid: string }[] }; const id = list.result[0]?.uuid; const reply = (await ( await fetch(`${EXPLORER}/d1/database/${id}/raw`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sql, params }), }) ).json()) as { result: { results: { columns: string[]; rows: unknown[][] } | null }[] | null }; const results = reply.result?.[0]?.results; if (!results) return []; const { columns, rows } = results; return rows.map((row) => Object.fromEntries(columns.map((column, index) => [column, row[index]])) as T); } /** Fires the Worker's cron handler (scratch sweep, reconcile) through the dev server's scheduled trigger. */ export async function runCron(): Promise { const reply = await fetch(new URL("/cdn-cgi/handler/scheduled?cron=*/5+*+*+*+*", FORGE)); if (!reply.ok) throw new Error(`cron dispatch failed: ${reply.status} ${await reply.text()}`); } export type ScratchRepo = { url: string; remote: string; pushToken: string; claimUrl: string; claimToken: string; repo: string; }; export async function createScratchRepo(): Promise { const reply = await fetch(new URL("/api/v1/scratch", FORGE), { method: "POST" }); if (reply.status !== HTTP_CREATED) throw new Error(`scratch repo failed: ${reply.status} ${await reply.text()}`); return (await reply.json()) as ScratchRepo; } /** Commits `files` on `branch` in a temp repo and pushes it with the scratch token; returns git's exit code. */ export async function gitPush( scratch: ScratchRepo, files: Record, branch = "main", ref?: string, ): Promise<{ code: number; output: string }> { const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = await import("node:fs"); const { tmpdir } = await import("node:os"); const { join, dirname } = await import("node:path"); const dir = mkdtempSync(join(tmpdir(), "scratch-git-")); try { for (const [path, content] of Object.entries(files)) { mkdirSync(dirname(join(dir, path)), { recursive: true }); writeFileSync(join(dir, path), content); } const remote = new URL(scratch.remote); remote.username = "x"; remote.password = scratch.pushToken; const git = (...args: string[]) => Bun.spawnSync( ["git", "-c", "credential.helper=", "-c", "user.name=e2e", "-c", "user.email=e2e@example.com", ...args], { cwd: dir, // Never touch the user's system/global git config or keychain credential helpers. env: { ...process.env, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0" }, }, ); git("init", "-q", "-b", branch); git("add", "-A"); git("commit", "-q", "-m", "scratch"); const pushed = git("push", "-q", remote.toString(), ref ?? `HEAD:refs/heads/${branch}`); return { code: pushed.exitCode, output: `${pushed.stdout}${pushed.stderr}` }; } finally { rmSync(dir, { recursive: true, force: true }); } } /** Polls a site host until its body contains `marker` (deploys run in a Workflow). */ export async function waitForSite(host: string, marker: string, timeoutMs = 90_000): Promise { const deadline = Date.now() + timeoutMs; let last: Reply = { status: 0, headers: {}, body: "" }; while (Date.now() < deadline) { last = await send(host, "/"); if (last.body.includes(marker)) return last; await Bun.sleep(1500); } return last; }