sproutboat 0.53.1 → 0.55.0

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/src/target.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Which endpoint and which stored profile a command talks to. One pure function, so the precedence is written down
3
+ * once and tested:
4
+ *
5
+ * 1. `--api-url` and `--profile`, the flags. Both given must agree.
6
+ * 2. `SPROUTBOAT_API_URL` and `SPROUTBOAT_PROFILE`, the environment. Both set must agree. A flag beats the other
7
+ * kind of variable: `--profile` beats `SPROUTBOAT_API_URL`, and `--api-url` beats `SPROUTBOAT_PROFILE`.
8
+ * 3. The machine's active profile.
9
+ * 4. The default endpoint.
10
+ *
11
+ * A profile fixes its endpoint, so a profile named next to a different URL is an error, never a quiet pick. That
12
+ * is the mistake this exists to stop: a stored Community login deploying to Community when the Cloud was meant.
13
+ */
14
+ import { PROFILE_NAME } from "./credentials";
15
+
16
+ export type ProfileSource = "--profile" | "SPROUTBOAT_PROFILE" | "active profile";
17
+ export type UrlSource = "--api-url" | "SPROUTBOAT_API_URL" | "profile" | "default";
18
+
19
+ export type Target = {
20
+ apiUrl: string;
21
+ source: UrlSource;
22
+ /** The profile selected by name, or the active one used for lack of anything else. Unset when the URL decided. */
23
+ profile?: { name: string; source: ProfileSource; exists: boolean };
24
+ };
25
+
26
+ export type TargetInput = {
27
+ flagUrl?: string;
28
+ flagProfile?: string;
29
+ envUrl?: string;
30
+ envProfile?: string;
31
+ /** Stored profiles by name, with their endpoints. */
32
+ profiles: Readonly<Record<string, { apiUrl: string }>>;
33
+ activeProfile?: string;
34
+ defaultUrl: string;
35
+ /** `login` may name a profile that does not exist yet; it is made on save. */
36
+ allowNew?: boolean;
37
+ };
38
+
39
+ export class TargetError extends Error {}
40
+
41
+ const trim = (url: string) => url.replace(/\/$/, "");
42
+
43
+ export function chooseTarget(input: TargetInput): Target {
44
+ const flagUrl = input.flagUrl ? trim(input.flagUrl) : undefined;
45
+ const envUrl = input.envUrl ? trim(input.envUrl) : undefined;
46
+
47
+ const named = (
48
+ name: string,
49
+ source: "--profile" | "SPROUTBOAT_PROFILE",
50
+ otherUrl?: string,
51
+ otherSource?: "--api-url" | "SPROUTBOAT_API_URL",
52
+ newUrl?: string,
53
+ ) => {
54
+ if (!PROFILE_NAME.test(name))
55
+ throw new TargetError(`${source} "${name}" is not a profile name (letters, digits, . _ -; at most 64)`);
56
+ const stored = input.profiles[name];
57
+ if (stored === undefined) {
58
+ if (!input.allowNew) {
59
+ const known = Object.keys(input.profiles);
60
+ throw new TargetError(
61
+ `no profile named "${name}"${known.length > 0 ? ` (profiles: ${known.join(", ")})` : ""}; create it with \`sproutboat login --profile ${name}\``,
62
+ );
63
+ }
64
+ const apiUrl = otherUrl ?? newUrl ?? trim(input.defaultUrl);
65
+ return {
66
+ apiUrl,
67
+ source:
68
+ otherUrl !== undefined && otherSource ? otherSource : newUrl !== undefined ? "SPROUTBOAT_API_URL" : "default",
69
+ profile: { name, source, exists: false },
70
+ } satisfies Target;
71
+ }
72
+ const apiUrl = trim(stored.apiUrl);
73
+ if (otherUrl !== undefined && otherUrl !== apiUrl)
74
+ throw new TargetError(
75
+ `the profile "${name}" is for ${apiUrl}, but ${otherSource} says ${otherUrl}; drop one of them`,
76
+ );
77
+ return { apiUrl, source: "profile", profile: { name, source, exists: true } } satisfies Target;
78
+ };
79
+
80
+ if (input.flagProfile !== undefined) return named(input.flagProfile, "--profile", flagUrl, "--api-url", envUrl);
81
+ if (flagUrl !== undefined) return { apiUrl: flagUrl, source: "--api-url" };
82
+ if (input.envProfile !== undefined) {
83
+ // The environment names a profile and also a URL: they must be the same place.
84
+ return named(input.envProfile, "SPROUTBOAT_PROFILE", envUrl, "SPROUTBOAT_API_URL");
85
+ }
86
+ if (envUrl !== undefined) return { apiUrl: envUrl, source: "SPROUTBOAT_API_URL" };
87
+ const active = input.activeProfile;
88
+ if (active !== undefined && input.profiles[active])
89
+ return {
90
+ apiUrl: trim(input.profiles[active].apiUrl),
91
+ source: "profile",
92
+ profile: { name: active, source: "active profile", exists: true },
93
+ };
94
+ return { apiUrl: trim(input.defaultUrl), source: "default" };
95
+ }
@@ -0,0 +1,72 @@
1
+ // Generated by scripts/embed-templates.ts from templates/. Do not edit: run `bun run templates:embed`.
2
+ // The starter templates `sproutboat init --template` writes, as text, so the compiled executable carries them.
3
+ export const EMBEDDED_TEMPLATES: Record<string, { description: string; files: Record<string, string> }> = {
4
+ "api-d1": {
5
+ "description": "A JSON API on D1 with migrations and input validation",
6
+ "files": {
7
+ ".gitignore": ".sproutboat/\n.dev.vars\nnode_modules/\n",
8
+ "README.md": "# api-d1\n\nA small JSON API on D1: tasks you can create, list, read, update and delete, with the schema in migrations and every input validated.\n\nIt is standard Cloudflare Workers TypeScript: the same files run on Cloudflare unchanged. `Env` comes from `worker-configuration.d.ts`, which `sproutboat types` writes from `sproutboat.jsonc`; run it again after you change the bindings.\n\n## What it shows\n\n- Plain `fetch` routing: a `switch` on the method and one regular expression for `/tasks/:id`. At this size a router costs more than it saves.\n- The schema in `migrations/0001_create_tasks.sql`, applied by `sproutboat d1 migrations apply DB`, not created by the Worker on its first request.\n- Validation as plain functions from untrusted input to a typed value or a list of what is wrong (`src/validate.ts`), tested on their own. No library.\n- Prepared statements with bound values only, `RETURNING` to get the row back in one round trip, and keyset pagination (`after=<id>`) that asks for one extra row instead of running a `COUNT`.\n- Errors as `{ \"error\": { \"code\", \"message\", \"details\" } }` with the status that says what was wrong: 400, 404, 405 with `Allow`, 413, 415.\n\n## Run it\n\n```sh\nnpm install\nsproutboat d1 migrations apply DB --local # create the tables in the local database\nsproutboat dev # http://localhost:8787, rebuilt on save\n```\n\nOr as one executable with its bindings inside it. A standalone binary keeps its database in `$SB_DATA_DIR/d1/DB.sqlite`, so apply the migrations to that directory first:\n\n```sh\nsproutboat build --standalone --target host\nsproutboat d1 migrations apply DB --local --persist-to ./data\nSB_DATA_DIR=./data PORT=8080 ./dist/api-d1\n```\n\n## Try it\n\n```sh\ncurl -X POST localhost:8787/tasks -H 'content-type: application/json' -d '{\"title\":\"read the docs\"}'\ncurl localhost:8787/tasks?limit=2\ncurl -X PATCH localhost:8787/tasks/1 -H 'content-type: application/json' -d '{\"done\":true}'\ncurl localhost:8787/tasks?done=true\ncurl -X DELETE localhost:8787/tasks/1\n```\n\nA change to the schema is a new file: `sproutboat d1 migrations create DB add_due_date`, edit the `.sql` it writes, apply it. Never edit one that has been applied.\n\n## Test it\n\n```sh\nnpm test # unit tests for the validation, then the API against `sproutboat dev`\nnpm run typecheck\n```\n\n`npm test` applies the migrations to the local database before it starts the server. To drive a server that is already running (and already migrated), set `BASE_URL`. The tests use Node's own test runner (Node 22.18 or later runs the `.ts` files as they are).\n\n## Deploy it\n\n```sh\nsproutboat login\nsproutboat deploy # prints https://api-d1.<team>.sproutboat.app\nsproutboat d1 migrations apply DB --remote\n```\n\nThe first deploy creates the D1 database and writes its id into `sproutboat.jsonc`; commit that change. There is no authentication here: put one in front of the write routes before you expose it.\n\n## Moving to Wrangler\n\nThe config is `sproutboat.jsonc` today. It uses only keys that exist in a `wrangler.jsonc` with the same meaning (`name`, `main`, `compatibility_date`, `d1_databases`), and nothing in `src/` is Sproutboat-specific, so converting is mechanical: `d1_databases` becomes a list of `{ binding, database_name, database_id }`. The migrations are the same files; Wrangler reads `migrations/` too. Sproutboat is making `wrangler.jsonc` a native config; `sproutboat migrate` will then do the conversion for you.\n",
9
+ "migrations/0001_create_tasks.sql": "-- Migration number: 0001\n-- Apply with `sproutboat d1 migrations apply DB --local` (dev) or `--remote` (a deployed app). Applied files are\n-- recorded in the d1_migrations table, so each one runs once. Add the next change as 0002_<name>.sql, never edit this.\nCREATE TABLE tasks (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),\n done INTEGER NOT NULL DEFAULT 0 CHECK (done IN (0, 1)),\n created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),\n updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))\n);\n\nCREATE INDEX tasks_done_id ON tasks (done, id);\n",
10
+ "package.json": "{\n \"name\": \"api-d1\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"description\": \"A JSON API on D1 with migrations and input validation\",\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"sproutboat dev\",\n \"deploy\": \"sproutboat deploy\",\n \"check\": \"sproutboat check\",\n \"types\": \"sproutboat types\",\n \"typecheck\": \"tsc -p . && tsc -p test\",\n \"test\": \"node --test test/*.test.ts\",\n \"db:migrate\": \"sproutboat d1 migrations apply DB --local\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^24.0.0\",\n \"typescript\": \"^7.0.2\"\n }\n}\n",
11
+ "sproutboat.jsonc": "{\n \"$schema\": \"https://sproutboat.com/schema.json\",\n \"name\": \"api-d1\",\n \"main\": \"src/index.ts\",\n \"compatibility_date\": \"2026-11-18\",\n \"d1_databases\": [\"DB\"],\n}\n",
12
+ "src/index.ts": "// A small JSON API on D1: tasks you can create, list, read, update and delete.\n//\n// GET /tasks?limit=20&after=<id>&done=false a page of tasks\n// POST /tasks {\"title\": \"...\"} create one (201 and a Location header)\n// GET /tasks/:id read one\n// PATCH /tasks/:id {\"title\"?, \"done\"?} change one\n// DELETE /tasks/:id remove one (204)\n//\n// The schema lives in migrations/ and is applied with `sproutboat d1 migrations apply DB`, not by the Worker.\nimport { createTask, deleteTask, getTask, listTasks, updateTask } from \"./tasks.ts\";\nimport { parseId, parseListQuery, parseNewTask, parseTaskPatch, type Parsed } from \"./validate.ts\";\n\nconst MAX_BODY_BYTES = 8 * 1024;\n\nfunction problem(status: number, code: string, message: string, details?: string[], headers?: HeadersInit): Response {\n return Response.json({ error: { code, message, ...(details ? { details } : {}) } }, { status, headers });\n}\n\nconst invalid = (errors: string[]) => problem(400, \"invalid_request\", \"the request is not valid\", errors);\nconst notFound = () => problem(404, \"not_found\", \"no such task\");\n\n/** The JSON body, or the response that refuses it: wrong media type, too large, or not JSON. */\nasync function readJson(request: Request): Promise<Parsed<unknown> | Response> {\n if (!request.headers.get(\"content-type\")?.toLowerCase().startsWith(\"application/json\"))\n return problem(415, \"unsupported_media_type\", \"send application/json\");\n const text = await request.text();\n if (new TextEncoder().encode(text).length > MAX_BODY_BYTES)\n return problem(413, \"payload_too_large\", `the body is limited to ${MAX_BODY_BYTES} bytes`);\n try {\n return { ok: true, value: JSON.parse(text) };\n } catch {\n return invalid([\"the body is not valid JSON\"]);\n }\n}\n\nexport default {\n async fetch(request, env) {\n const url = new URL(request.url);\n const { pathname } = url;\n\n if (pathname === \"/\")\n return Response.json({ name: \"api-d1\", routes: [\"GET /tasks\", \"POST /tasks\", \"GET /tasks/:id\"] });\n\n if (pathname === \"/tasks\") {\n if (request.method === \"GET\") {\n const query = parseListQuery(url.searchParams);\n if (!query.ok) return invalid(query.errors);\n return Response.json(await listTasks(env.DB, query.value));\n }\n if (request.method === \"POST\") {\n const body = await readJson(request);\n if (body instanceof Response) return body;\n if (!body.ok) return invalid(body.errors);\n const input = parseNewTask(body.value);\n if (!input.ok) return invalid(input.errors);\n const task = await createTask(env.DB, input.value.title);\n return Response.json(task, { status: 201, headers: { location: `/tasks/${task.id}` } });\n }\n return problem(405, \"method_not_allowed\", \"use GET or POST\", undefined, { allow: \"GET, POST\" });\n }\n\n const route = /^\\/tasks\\/([^/]+)$/.exec(pathname);\n if (route) {\n const id = parseId(route[1]);\n if (id === null) return notFound();\n switch (request.method) {\n case \"GET\": {\n const task = await getTask(env.DB, id);\n return task ? Response.json(task) : notFound();\n }\n case \"PATCH\": {\n const body = await readJson(request);\n if (body instanceof Response) return body;\n if (!body.ok) return invalid(body.errors);\n const patch = parseTaskPatch(body.value);\n if (!patch.ok) return invalid(patch.errors);\n const task = await updateTask(env.DB, id, patch.value);\n return task ? Response.json(task) : notFound();\n }\n case \"DELETE\":\n return (await deleteTask(env.DB, id)) ? new Response(null, { status: 204 }) : notFound();\n default:\n return problem(405, \"method_not_allowed\", \"use GET, PATCH or DELETE\", undefined, {\n allow: \"GET, PATCH, DELETE\",\n });\n }\n }\n\n return problem(404, \"not_found\", \"no such route\");\n },\n} satisfies ExportedHandler<Env>;\n",
13
+ "src/tasks.ts": "// The queries. Every statement is prepared once with `?` placeholders and the values are bound, never concatenated.\nimport type { ListQuery, TaskPatch } from \"./validate.ts\";\n\nexport interface Task {\n id: number;\n title: string;\n done: boolean;\n createdAt: string;\n updatedAt: string;\n}\n\ninterface TaskRow {\n id: number;\n title: string;\n done: number;\n created_at: string;\n updated_at: string;\n}\n\nconst COLUMNS = \"id, title, done, created_at, updated_at\";\n\nconst toTask = (row: TaskRow): Task => ({\n id: row.id,\n title: row.title,\n done: row.done === 1,\n createdAt: row.created_at,\n updatedAt: row.updated_at,\n});\n\nexport async function createTask(db: Env[\"DB\"], title: string): Promise<Task> {\n const row = await db\n .prepare(`INSERT INTO tasks (title) VALUES (?) RETURNING ${COLUMNS}`)\n .bind(title)\n .first<TaskRow>();\n if (!row) throw new Error(\"insert returned no row\");\n return toTask(row);\n}\n\nexport async function getTask(db: Env[\"DB\"], id: number): Promise<Task | null> {\n const row = await db.prepare(`SELECT ${COLUMNS} FROM tasks WHERE id = ?`).bind(id).first<TaskRow>();\n return row ? toTask(row) : null;\n}\n\n/** One page, oldest first. `next` is the id to pass as `after` for the following page, or null at the end. */\nexport async function listTasks(db: Env[\"DB\"], query: ListQuery): Promise<{ tasks: Task[]; next: number | null }> {\n const filter = query.done === undefined ? \"\" : \" AND done = ?\";\n const values =\n query.done === undefined ? [query.after, query.limit + 1] : [query.after, Number(query.done), query.limit + 1];\n const { results } = await db\n .prepare(`SELECT ${COLUMNS} FROM tasks WHERE id > ?${filter} ORDER BY id LIMIT ?`)\n .bind(...values)\n .all<TaskRow>();\n // One more row than asked for says whether there is another page without a second COUNT query.\n const page = results.slice(0, query.limit).map(toTask);\n return { tasks: page, next: results.length > query.limit ? page[page.length - 1].id : null };\n}\n\nexport async function updateTask(db: Env[\"DB\"], id: number, patch: TaskPatch): Promise<Task | null> {\n const row = await db\n .prepare(\n `UPDATE tasks SET title = COALESCE(?, title), done = COALESCE(?, done),\n updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')\n WHERE id = ? RETURNING ${COLUMNS}`,\n )\n .bind(patch.title ?? null, patch.done === undefined ? null : Number(patch.done), id)\n .first<TaskRow>();\n return row ? toTask(row) : null;\n}\n\nexport async function deleteTask(db: Env[\"DB\"], id: number): Promise<boolean> {\n const { meta } = await db.prepare(\"DELETE FROM tasks WHERE id = ?\").bind(id).run();\n return meta.changes > 0;\n}\n",
14
+ "src/validate.ts": "// Input validation: plain functions from untrusted input to a typed value or a list of what is wrong. No library,\n// nothing here touches the runtime, so it is tested on its own (test/validate.test.ts).\n\nexport type Parsed<T> = { ok: true; value: T } | { ok: false; errors: string[] };\n\nexport const MAX_TITLE = 200;\nexport const DEFAULT_LIMIT = 20;\nexport const MAX_LIMIT = 100;\n\nconst fail = (...errors: string[]): { ok: false; errors: string[] } => ({ ok: false, errors });\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction parseTitle(value: unknown): Parsed<string> {\n if (typeof value !== \"string\") return fail(\"title must be a string\");\n const title = value.trim();\n if (title.length === 0) return fail(\"title must not be empty\");\n if (title.length > MAX_TITLE) return fail(`title must be at most ${MAX_TITLE} characters`);\n return { ok: true, value: title };\n}\n\n/** A task id from a path segment: a positive integer that fits in a JavaScript number exactly. */\nexport function parseId(raw: string): number | null {\n return /^[1-9][0-9]{0,14}$/.test(raw) ? Number(raw) : null;\n}\n\nexport function parseNewTask(body: unknown): Parsed<{ title: string }> {\n if (!isRecord(body)) return fail(\"the body must be a JSON object\");\n const unknown = Object.keys(body).filter((key) => key !== \"title\");\n if (unknown.length > 0) return fail(`unknown fields: ${unknown.join(\", \")}`);\n const title = parseTitle(body.title);\n return title.ok ? { ok: true, value: { title: title.value } } : title;\n}\n\nexport interface TaskPatch {\n title?: string;\n done?: boolean;\n}\n\nexport function parseTaskPatch(body: unknown): Parsed<TaskPatch> {\n if (!isRecord(body)) return fail(\"the body must be a JSON object\");\n const unknown = Object.keys(body).filter((key) => key !== \"title\" && key !== \"done\");\n if (unknown.length > 0) return fail(`unknown fields: ${unknown.join(\", \")}`);\n const patch: TaskPatch = {};\n const errors: string[] = [];\n if (\"title\" in body) {\n const title = parseTitle(body.title);\n if (title.ok) patch.title = title.value;\n else errors.push(...title.errors);\n }\n if (\"done\" in body) {\n if (typeof body.done === \"boolean\") patch.done = body.done;\n else errors.push(\"done must be true or false\");\n }\n if (errors.length > 0) return { ok: false, errors };\n if (Object.keys(patch).length === 0) return fail(\"send title, done or both\");\n return { ok: true, value: patch };\n}\n\nexport interface ListQuery {\n limit: number;\n /** Keyset pagination: only tasks with an id greater than this. */\n after: number;\n done?: boolean;\n}\n\nexport function parseListQuery(params: URLSearchParams): Parsed<ListQuery> {\n const errors: string[] = [];\n const query: ListQuery = { limit: DEFAULT_LIMIT, after: 0 };\n\n const limit = params.get(\"limit\");\n if (limit !== null) {\n if (/^[0-9]{1,4}$/.test(limit) && Number(limit) >= 1 && Number(limit) <= MAX_LIMIT) query.limit = Number(limit);\n else errors.push(`limit must be a whole number from 1 to ${MAX_LIMIT}`);\n }\n const after = params.get(\"after\");\n if (after !== null) {\n // 0 is \"from the start\", which is what a client sends for the first page.\n const id = after === \"0\" ? 0 : parseId(after);\n if (id === null) errors.push(\"after must be 0 or a task id\");\n else query.after = id;\n }\n const done = params.get(\"done\");\n if (done !== null) {\n if (done === \"true\" || done === \"false\") query.done = done === \"true\";\n else errors.push(\"done must be true or false\");\n }\n return errors.length > 0 ? { ok: false, errors } : { ok: true, value: query };\n}\n",
15
+ "test/api.test.ts": "import assert from \"node:assert/strict\";\nimport { after, before, describe, test } from \"node:test\";\nimport { startServer, type Server } from \"./server.ts\";\n\ninterface Task {\n id: number;\n title: string;\n done: boolean;\n createdAt: string;\n updatedAt: string;\n}\n\ndescribe(\"tasks API\", () => {\n let server: Server;\n // The database may already hold rows (a server that was running before), so every title carries this run's tag.\n const tag = `t${Date.now().toString(36)}`;\n before(async () => (server = await startServer()));\n after(async () => server.stop());\n\n const send = (path: string, init?: RequestInit & { json?: unknown }) =>\n fetch(server.base + path, {\n ...init,\n headers: { ...(init?.json === undefined ? {} : { \"content-type\": \"application/json\" }), ...init?.headers },\n body: init?.json === undefined ? init?.body : JSON.stringify(init.json),\n });\n const create = async (title: string): Promise<Task> => {\n const response = await send(\"/tasks\", { method: \"POST\", json: { title } });\n assert.equal(response.status, 201);\n return (await response.json()) as Task;\n };\n\n test(\"creates a task and says where it is\", async () => {\n const response = await send(\"/tasks\", { method: \"POST\", json: { title: `${tag} first ` } });\n assert.equal(response.status, 201);\n const task = (await response.json()) as Task;\n assert.equal(task.title, `${tag} first`);\n assert.equal(task.done, false);\n assert.ok(task.id > 0);\n assert.ok(!Number.isNaN(Date.parse(task.createdAt)));\n assert.equal(response.headers.get(\"location\"), `/tasks/${task.id}`);\n });\n\n test(\"reads, changes and deletes one task\", async () => {\n const task = await create(`${tag} lifecycle`);\n const read = await send(`/tasks/${task.id}`);\n assert.deepEqual(await read.json(), task);\n\n const patched = await send(`/tasks/${task.id}`, { method: \"PATCH\", json: { done: true } });\n assert.equal(patched.status, 200);\n const done = (await patched.json()) as Task;\n assert.equal(done.done, true);\n assert.equal(done.title, task.title);\n\n const renamed = (await (\n await send(`/tasks/${task.id}`, { method: \"PATCH\", json: { title: `${tag} renamed` } })\n ).json()) as Task;\n assert.equal(renamed.title, `${tag} renamed`);\n assert.equal(renamed.done, true);\n\n assert.equal((await send(`/tasks/${task.id}`, { method: \"DELETE\" })).status, 204);\n assert.equal((await send(`/tasks/${task.id}`)).status, 404);\n assert.equal((await send(`/tasks/${task.id}`, { method: \"DELETE\" })).status, 404);\n });\n\n test(\"lists in pages, oldest first, and filters on done\", async () => {\n const made = [await create(`${tag} page 1`), await create(`${tag} page 2`), await create(`${tag} page 3`)];\n await send(`/tasks/${made[1].id}`, { method: \"PATCH\", json: { done: true } });\n\n const seen: Task[] = [];\n let after = 0;\n for (let pages = 0; pages < 1000; pages++) {\n const response = await send(`/tasks?limit=2&after=${after}`);\n const page = (await response.json()) as { tasks: Task[]; next: number | null };\n assert.ok(page.tasks.length <= 2);\n seen.push(...page.tasks);\n if (page.next === null) break;\n after = page.next;\n }\n const ours = seen.filter((task) => task.title.startsWith(`${tag} page`));\n assert.deepEqual(\n ours.map((task) => task.id),\n made.map((task) => task.id),\n );\n\n const finished = (await (await send(\"/tasks?done=true&limit=100\")).json()) as { tasks: Task[] };\n assert.ok(finished.tasks.every((task) => task.done));\n assert.ok(finished.tasks.some((task) => task.id === made[1].id));\n for (const task of made) await send(`/tasks/${task.id}`, { method: \"DELETE\" });\n });\n\n test(\"refuses input it cannot accept, and says why\", async () => {\n const empty = await send(\"/tasks\", { method: \"POST\", json: { title: \" \" } });\n assert.equal(empty.status, 400);\n const body = (await empty.json()) as { error: { code: string; details: string[] } };\n assert.equal(body.error.code, \"invalid_request\");\n assert.match(body.error.details[0], /title/);\n\n assert.equal((await send(\"/tasks\", { method: \"POST\", json: { title: \"a\", surprise: 1 } })).status, 400);\n assert.equal(\n (await send(\"/tasks\", { method: \"POST\", body: \"{\", headers: { \"content-type\": \"application/json\" } })).status,\n 400,\n );\n assert.equal(\n (await send(\"/tasks\", { method: \"POST\", body: \"title=a\", headers: { \"content-type\": \"text/plain\" } })).status,\n 415,\n );\n assert.equal((await send(\"/tasks\", { method: \"POST\", json: { title: \"x\".repeat(9000) } })).status, 413);\n assert.equal((await send(\"/tasks?limit=1000\")).status, 400);\n assert.equal((await send(\"/tasks/not-a-number\")).status, 404);\n });\n\n test(\"answers the wrong method with Allow, and an unknown route with 404\", async () => {\n const wrong = await send(\"/tasks\", { method: \"DELETE\" });\n assert.equal(wrong.status, 405);\n assert.equal(wrong.headers.get(\"allow\"), \"GET, POST\");\n assert.equal((await send(\"/tasks/1\", { method: \"PUT\", json: {} })).status, 405);\n assert.equal((await send(\"/nowhere\")).status, 404);\n assert.equal((await send(\"/\")).status, 200);\n });\n\n test(\"keeps quotes, emoji and SQL in a title as plain text\", async () => {\n const title = `${tag} Robert'); DROP TABLE tasks;-- 🌱`;\n const task = await create(title);\n assert.equal(((await (await send(`/tasks/${task.id}`)).json()) as Task).title, title);\n await send(`/tasks/${task.id}`, { method: \"DELETE\" });\n });\n});\n",
16
+ "test/server.ts": "// The server under test. BASE_URL points at one that is already running (a deployed copy, a standalone build);\n// without it, this applies the migrations to the local database, starts `sproutboat dev` for this project on a\n// free port and stops it afterwards.\nimport { spawn, spawnSync, type ChildProcess } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { createServer } from \"node:net\";\nimport { fileURLToPath } from \"node:url\";\n\nconst project = fileURLToPath(new URL(\"..\", import.meta.url));\nconst bin = fileURLToPath(new URL(\"../node_modules/.bin/sproutboat\", import.meta.url));\n\nexport interface Server {\n base: string;\n stop(): Promise<void>;\n}\n\nasync function freePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const probe = createServer();\n probe.once(\"error\", reject);\n probe.listen(0, \"127.0.0.1\", () => {\n const address = probe.address();\n probe.close(() =>\n address && typeof address === \"object\" ? resolve(address.port) : reject(new Error(\"no port\")),\n );\n });\n });\n}\n\nexport async function startServer(): Promise<Server> {\n const given = process.env.BASE_URL;\n if (given) return { base: given.replace(/\\/$/, \"\"), stop: async () => {} };\n\n const command = existsSync(bin) ? bin : \"sproutboat\";\n // The schema is not the Worker's job: it is in migrations/, and a fresh local database has none of it yet.\n const migrated = spawnSync(command, [\"d1\", \"migrations\", \"apply\", \"DB\", \"--local\"], {\n cwd: project,\n encoding: \"utf8\",\n });\n if (migrated.status !== 0) throw new Error(`applying the migrations failed:\\n${migrated.stdout}${migrated.stderr}`);\n\n const port = await freePort();\n const child: ChildProcess = spawn(command, [\"dev\", \"--port\", String(port), \"--no-watch\"], {\n cwd: project,\n stdio: [\"ignore\", \"ignore\", \"pipe\"],\n });\n let stderr = \"\";\n child.stderr?.on(\"data\", (chunk) => (stderr += chunk));\n let exited = false;\n child.once(\"exit\", () => (exited = true));\n\n const base = `http://127.0.0.1:${port}`;\n // The first build compiles the app, which takes a while; any answer at all means it is up.\n const deadline = Date.now() + 180_000;\n let up = false;\n while (!up && !exited && Date.now() < deadline) {\n try {\n await fetch(base + \"/\");\n up = true;\n } catch {\n await new Promise((resolve) => setTimeout(resolve, 250));\n }\n }\n if (!up) {\n child.kill(\"SIGTERM\");\n throw new Error(`sproutboat dev did not come up${exited ? \" (it exited)\" : \" in 180 s\"}:\\n${stderr}`);\n }\n return {\n base,\n stop: async () => {\n if (exited) return;\n const done = new Promise((resolve) => child.once(\"exit\", resolve));\n child.kill(\"SIGTERM\");\n await done;\n },\n };\n}\n",
17
+ "test/tsconfig.json": "{\n \"extends\": \"../tsconfig.json\",\n \"compilerOptions\": { \"types\": [\"node\"] },\n \"include\": [\"../src\", \"../worker-configuration.d.ts\", \".\"]\n}\n",
18
+ "test/validate.test.ts": "import assert from \"node:assert/strict\";\nimport { describe, test } from \"node:test\";\nimport { MAX_LIMIT, MAX_TITLE, parseId, parseListQuery, parseNewTask, parseTaskPatch } from \"../src/validate.ts\";\n\ndescribe(\"validation\", () => {\n test(\"a task id is a positive whole number\", () => {\n assert.equal(parseId(\"42\"), 42);\n for (const bad of [\"0\", \"-1\", \"1.5\", \"abc\", \"007\", \"\", \"9\".repeat(20)]) assert.equal(parseId(bad), null, bad);\n });\n\n test(\"a new task needs a title, trimmed, and nothing else\", () => {\n assert.deepEqual(parseNewTask({ title: \" write tests \" }), { ok: true, value: { title: \"write tests\" } });\n for (const body of [\n null,\n [],\n \"x\",\n {},\n { title: 1 },\n { title: \" \" },\n { title: \"x\".repeat(MAX_TITLE + 1) },\n { title: \"a\", done: true },\n ])\n assert.equal(parseNewTask(body).ok, false, JSON.stringify(body));\n });\n\n test(\"a patch changes title, done or both, and says what is wrong\", () => {\n assert.deepEqual(parseTaskPatch({ done: true }), { ok: true, value: { done: true } });\n assert.deepEqual(parseTaskPatch({ title: \" a \", done: false }), { ok: true, value: { title: \"a\", done: false } });\n assert.equal(parseTaskPatch({}).ok, false);\n assert.equal(parseTaskPatch({ extra: 1 }).ok, false);\n const bad = parseTaskPatch({ title: 5, done: \"yes\" });\n assert.equal(bad.ok, false);\n if (!bad.ok) assert.equal(bad.errors.length, 2);\n });\n\n test(\"list queries have defaults and bounds\", () => {\n assert.deepEqual(parseListQuery(new URLSearchParams()), { ok: true, value: { limit: 20, after: 0 } });\n assert.deepEqual(parseListQuery(new URLSearchParams(\"after=0\")), { ok: true, value: { limit: 20, after: 0 } });\n assert.deepEqual(parseListQuery(new URLSearchParams(\"limit=5&after=9&done=true\")), {\n ok: true,\n value: { limit: 5, after: 9, done: true },\n });\n for (const query of [\"limit=0\", `limit=${MAX_LIMIT + 1}`, \"limit=x\", \"after=-3\", \"after=abc\", \"done=maybe\"])\n assert.equal(parseListQuery(new URLSearchParams(query)).ok, false, query);\n });\n});\n",
19
+ "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n \"types\": [],\n \"strict\": true,\n \"noEmit\": true,\n \"skipLibCheck\": true,\n \"isolatedModules\": true,\n \"allowImportingTsExtensions\": true,\n \"erasableSyntaxOnly\": true\n },\n \"include\": [\"src\", \"worker-configuration.d.ts\"]\n}\n"
20
+ }
21
+ },
22
+ "jobs": {
23
+ "description": "A cron trigger that enqueues work, a queue consumer that processes it, and KV for state",
24
+ "files": {
25
+ ".gitignore": ".sproutboat/\n.dev.vars\nnode_modules/\n",
26
+ "README.md": "# jobs\n\nScheduled work with a queue in the middle: a cron trigger decides what to do and enqueues it, a queue consumer does it, and KV holds the state. Here the job is a daily digest: count the events recorded for a UTC date and store the totals.\n\nIt is standard Cloudflare Workers TypeScript: the same files run on Cloudflare unchanged. `Env` comes from `worker-configuration.d.ts`, which `sproutboat types` writes from `sproutboat.jsonc`; run it again after you change the bindings.\n\n```\ncron (every hour) -> scheduled() -> JOBS queue -> queue() -> KV\nPOST /jobs -> fetch() -> JOBS queue -> queue() -> KV (the same path, by hand)\n```\n\n## What it shows\n\n- `scheduled(controller, env)` that only enqueues: fast, and nothing can be half-done when it ends.\n- `queue(batch, env)` that processes each message on its own, `ack()`s a success, `retry({ delaySeconds })`s a failure with a growing delay, and after three attempts records the failure where `GET /jobs/:id` shows it and acks, so it is not redelivered forever.\n- Idempotence, because queues and cron are both at-least-once: the cron job's id is the hour, so a trigger that fires twice enqueues one job, and a redelivered message finds the job `done` and does nothing.\n- KV as state: one key per event (so two events at the same instant cannot overwrite a count), job records with a TTL, and the stored digests.\n- The cron path is tested by calling the handlers with small fakes (`test/handlers.test.ts`), because a test cannot wait for the hour.\n\n## Run it\n\n```sh\nnpm install\nsproutboat dev # http://localhost:8787, rebuilt on save\n```\n\nOr as one executable with its bindings inside it:\n\n```sh\nsproutboat build --standalone --target host\nPORT=8080 ./dist/jobs\n```\n\n## Try it\n\n```sh\ncurl -X POST localhost:8787/events -H 'content-type: application/json' -d '{\"name\":\"signup\"}'\ncurl -X POST localhost:8787/events -H 'content-type: application/json' -d '{\"name\":\"signup\"}'\ncurl -X POST localhost:8787/jobs -d '{}' # 202 {\"id\":\"...\",\"date\":\"2026-11-13\"}\ncurl localhost:8787/jobs/<id> # queued, then done, with the digest\ncurl localhost:8787/digests/2026-11-13 # {\"total\":2,\"byName\":{\"signup\":2},...}\n```\n\nThe cron expression is in `sproutboat.jsonc` (`\"0 * * * *\"`, every hour on the hour, UTC). Change it there; nothing in `src/` repeats it.\n\n## Test it\n\n```sh\nnpm test # the handlers with fakes, then the whole path against `sproutboat dev`\nBASE_URL=http://localhost:8080 npm test # or drive a server that is already running\nnpm run typecheck\n```\n\nThe tests use Node's own test runner (Node 22.18 or later runs the `.ts` files as they are).\n\n## Deploy it\n\n```sh\nsproutboat login\nsproutboat deploy # prints https://jobs.<team>.sproutboat.app\n```\n\nThe first deploy creates the KV namespace and the queue and writes their ids into `sproutboat.jsonc`; commit that change. A queue message that keeps failing is dropped after its attempts; if you cannot afford that, add a dead-letter queue and read it from a second consumer.\n\n## Moving to Wrangler\n\nThe config is `sproutboat.jsonc` today. It uses only keys that exist in a `wrangler.jsonc` with the same meaning (`name`, `main`, `compatibility_date`, `kv_namespaces`, `queues`, `triggers.crons`), and nothing in `src/` is Sproutboat-specific, so converting is mechanical: `queues` becomes a `producers` and a `consumers` list for the same queue, and `kv_namespaces` a list of `{ binding, id }`. Sproutboat is making `wrangler.jsonc` a native config; `sproutboat migrate` will then do the conversion for you.\n",
27
+ "package.json": "{\n \"name\": \"jobs\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"description\": \"A cron trigger that enqueues work, a queue consumer that processes it, and KV for state\",\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"sproutboat dev\",\n \"deploy\": \"sproutboat deploy\",\n \"check\": \"sproutboat check\",\n \"types\": \"sproutboat types\",\n \"typecheck\": \"tsc -p . && tsc -p test\",\n \"test\": \"node --test test/*.test.ts\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^24.0.0\",\n \"typescript\": \"^7.0.2\"\n }\n}\n",
28
+ "sproutboat.jsonc": "{\n \"$schema\": \"https://sproutboat.com/schema.json\",\n \"name\": \"jobs\",\n \"main\": \"src/index.ts\",\n \"compatibility_date\": \"2026-11-18\",\n \"kv_namespaces\": [\"STATE\"],\n \"queues\": [\"JOBS\"],\n // Every hour, on the hour (UTC).\n \"triggers\": { \"crons\": [\"0 * * * *\"] },\n}\n",
29
+ "src/digest.ts": "// The work: count the events recorded for one UTC date and store the totals. Pure KV, so it runs the same anywhere.\n\n/** The message on the queue. Keep it small and self-describing: it is stored, retried and may be read by a later version. */\nexport interface DigestJob {\n id: string;\n date: string;\n}\n\nexport interface Digest {\n date: string;\n total: number;\n byName: Record<string, number>;\n processedAt: string;\n}\n\nexport type JobRecord =\n | { id: string; date: string; status: \"queued\"; queuedAt: string }\n | { id: string; date: string; status: \"done\"; queuedAt: string; digest: Digest; attempts: number }\n | { id: string; date: string; status: \"failed\"; queuedAt: string; error: string; attempts: number };\n\nconst DAY = 24 * 60 * 60;\nexport const EVENT_TTL = 8 * DAY;\nexport const JOB_TTL = 2 * DAY;\nexport const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,39}$/;\nexport const DATE_PATTERN = /^\\d{4}-\\d{2}-\\d{2}$/;\n\nexport const today = (now = new Date()): string => now.toISOString().slice(0, 10);\nexport const jobKey = (id: string) => `job:${id}`;\nexport const digestKey = (date: string) => `digest:${date}`;\n\n/** One key per event, so two events recorded at the same moment can never overwrite each other's count. */\nexport async function recordEvent(kv: Env[\"STATE\"], name: string, now = new Date()): Promise<void> {\n const key = `event:${today(now)}:${name}:${crypto.randomUUID()}`;\n await kv.put(key, \"1\", { expirationTtl: EVENT_TTL });\n}\n\nexport async function buildDigest(kv: Env[\"STATE\"], date: string, now = new Date()): Promise<Digest> {\n const byName: Record<string, number> = {};\n let total = 0;\n let cursor: string | undefined;\n do {\n const page = await kv.list({ prefix: `event:${date}:`, cursor });\n for (const { name: key } of page.keys) {\n const name = key.split(\":\")[2];\n byName[name] = (byName[name] ?? 0) + 1;\n total++;\n }\n cursor = page.list_complete ? undefined : page.cursor;\n } while (cursor);\n return { date, total, byName, processedAt: now.toISOString() };\n}\n\nexport async function getJob(kv: Env[\"STATE\"], id: string): Promise<JobRecord | null> {\n return kv.get<JobRecord>(jobKey(id), \"json\");\n}\n\nexport async function putJob(kv: Env[\"STATE\"], record: JobRecord): Promise<void> {\n await kv.put(jobKey(record.id), JSON.stringify(record), { expirationTtl: JOB_TTL });\n}\n\n/** Record the job and put it on the queue. The record goes first, so a status request never finds a job it cannot see. */\nexport async function enqueueDigest(env: Env, job: DigestJob, now = new Date()): Promise<void> {\n if ((await getJob(env.STATE, job.id)) !== null) return; // the same id twice (a retried cron, a double click) is one job\n await putJob(env.STATE, { id: job.id, date: job.date, status: \"queued\", queuedAt: now.toISOString() });\n await env.JOBS.send(job);\n}\n\n/** Run one job. Safe to run twice for the same message: queues deliver at least once. */\nexport async function processDigest(env: Env, job: DigestJob, attempts: number): Promise<void> {\n const existing = await getJob(env.STATE, job.id);\n if (existing?.status === \"done\") return;\n const digest = await buildDigest(env.STATE, job.date);\n await env.STATE.put(digestKey(job.date), JSON.stringify(digest));\n await putJob(env.STATE, {\n id: job.id,\n date: job.date,\n status: \"done\",\n queuedAt: existing?.queuedAt ?? digest.processedAt,\n digest,\n attempts,\n });\n}\n\nexport async function failJob(env: Env, job: DigestJob, attempts: number, error: unknown): Promise<void> {\n const existing = await getJob(env.STATE, job.id);\n await putJob(env.STATE, {\n id: job.id,\n date: job.date,\n status: \"failed\",\n queuedAt: existing?.queuedAt ?? new Date().toISOString(),\n error: error instanceof Error ? error.message : String(error),\n attempts,\n });\n}\n",
30
+ "src/index.ts": "// Scheduled work with a queue in the middle.\n//\n// cron (hourly) scheduled() -> JOBS queue -> queue() -> KV\n//\n// The cron handler only decides what to do and enqueues it, which is fast and cannot be half-done. The queue\n// consumer does the work, is retried when it throws, and is safe to run twice (queues deliver at least once).\n// POST /jobs enqueues the same job by hand, so you can try the whole path without waiting for the hour.\n//\n// POST /events {\"name\": \"signup\"} record an event for today (UTC)\n// POST /jobs {\"date\": \"2026-11-13\"} enqueue a digest of that day (default today), 202\n// GET /jobs/:id queued, done (with the digest) or failed\n// GET /digests/:date the latest stored digest of a day\nimport {\n DATE_PATTERN,\n NAME_PATTERN,\n digestKey,\n enqueueDigest,\n failJob,\n getJob,\n processDigest,\n recordEvent,\n today,\n type DigestJob,\n} from \"./digest.ts\";\n\nconst MAX_ATTEMPTS = 3;\nconst HOUR = 60 * 60 * 1000;\n\nasync function readObject(request: Request): Promise<Record<string, unknown> | null> {\n const body: unknown = await request.json().catch(() => null);\n return typeof body === \"object\" && body !== null && !Array.isArray(body) ? (body as Record<string, unknown>) : null;\n}\n\nconst bad = (message: string) => Response.json({ error: message }, { status: 400 });\n\nexport default {\n async fetch(request, env) {\n const { pathname } = new URL(request.url);\n\n if (pathname === \"/\")\n return Response.json({\n name: \"jobs\",\n routes: [\"POST /events\", \"POST /jobs\", \"GET /jobs/:id\", \"GET /digests/:date\"],\n });\n\n if (pathname === \"/events\" && request.method === \"POST\") {\n const body = await readObject(request);\n const name = body?.name;\n if (typeof name !== \"string\" || !NAME_PATTERN.test(name))\n return bad(\"name is 1 to 40 characters: a-z, 0-9, dot, dash, underscore\");\n await recordEvent(env.STATE, name);\n return Response.json({ recorded: name, date: today() }, { status: 201 });\n }\n\n if (pathname === \"/jobs\" && request.method === \"POST\") {\n const body = (await readObject(request)) ?? {};\n const date = body.date ?? today();\n if (typeof date !== \"string\" || !DATE_PATTERN.test(date) || Number.isNaN(Date.parse(date)))\n return bad(\"date is YYYY-MM-DD\");\n const job: DigestJob = { id: crypto.randomUUID(), date };\n await enqueueDigest(env, job);\n return Response.json(job, { status: 202, headers: { location: `/jobs/${job.id}` } });\n }\n\n const job = /^\\/jobs\\/([0-9a-z-]{1,64})$/.exec(pathname);\n if (job && request.method === \"GET\") {\n const record = await getJob(env.STATE, job[1]);\n return record ? Response.json(record) : Response.json({ error: \"no such job\" }, { status: 404 });\n }\n\n const digest = /^\\/digests\\/(\\d{4}-\\d{2}-\\d{2})$/.exec(pathname);\n if (digest && request.method === \"GET\") {\n const stored = await env.STATE.get(digestKey(digest[1]), \"json\");\n return stored ? Response.json(stored) : Response.json({ error: \"no digest for that day yet\" }, { status: 404 });\n }\n\n return new Response(\"not found\\n\", { status: 404 });\n },\n\n // Runs on the cron expression in sproutboat.jsonc. The job id is the hour, so a trigger that fires twice for the\n // same hour (cron is at least once too) enqueues one job.\n async scheduled(controller, env) {\n const at = new Date(controller.scheduledTime);\n const hour = Math.floor(controller.scheduledTime / HOUR);\n await enqueueDigest(env, { id: `hourly-${hour}`, date: today(at) }, at);\n },\n\n async queue(batch, env) {\n for (const message of batch.messages) {\n try {\n await processDigest(env, message.body, message.attempts);\n message.ack();\n } catch (error) {\n console.error(`job ${message.body.id} failed (attempt ${message.attempts})`, error);\n if (message.attempts < MAX_ATTEMPTS) {\n message.retry({ delaySeconds: 2 ** message.attempts });\n } else {\n // Out of attempts: keep the reason where GET /jobs/:id shows it, and stop redelivering.\n await failJob(env, message.body, message.attempts, error);\n message.ack();\n }\n }\n }\n },\n} satisfies ExportedHandler<Env, DigestJob>;\n",
31
+ "test/fakes.ts": "// Just enough KV and Queue for the handlers' unit tests: a Map, and an array of what was sent.\nexport function fakeKv() {\n const store = new Map<string, string>();\n const kv = {\n store,\n async get(key: string, type?: string) {\n const value = store.get(key) ?? null;\n return type === \"json\" && value !== null ? JSON.parse(value) : value;\n },\n async put(key: string, value: string) {\n store.set(key, value);\n },\n async list({ prefix = \"\" }: { prefix?: string } = {}) {\n return {\n keys: [...store.keys()].filter((key) => key.startsWith(prefix)).map((name) => ({ name })),\n list_complete: true,\n };\n },\n };\n return kv;\n}\n\nexport function fakeEnv() {\n const STATE = fakeKv();\n const sent: unknown[] = [];\n const JOBS = {\n async send(message: unknown) {\n sent.push(message);\n },\n };\n // SAFETY: only the methods the handlers call exist on the fakes.\n return { env: { STATE, JOBS } as unknown as Env, state: STATE, sent };\n}\n\n/** A queue message that records what the consumer did with it. */\nexport function fakeMessage<Body>(body: Body, attempts = 1) {\n const calls: string[] = [];\n return {\n calls,\n message: {\n body,\n attempts,\n ack: () => calls.push(\"ack\"),\n retry: (options?: { delaySeconds?: number }) => calls.push(`retry:${options?.delaySeconds ?? 0}`),\n },\n };\n}\n",
32
+ "test/handlers.test.ts": "// The scheduled and queue handlers, called directly with fakes: a cron tick cannot be waited for in a test, and these\n// two are where the logic is.\nimport assert from \"node:assert/strict\";\nimport { describe, mock, test } from \"node:test\";\nimport worker from \"../src/index.ts\";\nimport { recordEvent } from \"../src/digest.ts\";\nimport { fakeEnv, fakeMessage } from \"./fakes.ts\";\n\nconst scheduled = (time: number) =>\n ({ scheduledTime: time, cron: \"0 * * * *\", noRetry() {} }) as unknown as ScheduledController;\nconst batch = (...messages: Array<ReturnType<typeof fakeMessage<{ id: string; date: string }>>[\"message\"]>) =>\n ({ queue: \"JOBS\", messages }) as unknown as MessageBatch<{ id: string; date: string }>;\n\ndescribe(\"scheduled()\", () => {\n test(\"enqueues one digest job for the day, keyed by the hour\", async () => {\n const { env, sent } = fakeEnv();\n const time = Date.UTC(2026, 10, 13, 9, 30);\n await worker.scheduled!(scheduled(time), env);\n assert.equal(sent.length, 1);\n assert.deepEqual(sent[0], { id: `hourly-${Math.floor(time / 3_600_000)}`, date: \"2026-11-13\" });\n });\n\n test(\"a trigger that fires twice for the same hour enqueues once\", async () => {\n const { env, sent } = fakeEnv();\n const time = Date.UTC(2026, 10, 13, 9, 0);\n await worker.scheduled!(scheduled(time), env);\n await worker.scheduled!(scheduled(time + 1000), env);\n assert.equal(sent.length, 1);\n });\n});\n\ndescribe(\"queue()\", () => {\n test(\"counts the day's events, stores the digest and acks\", async () => {\n const { env, state } = fakeEnv();\n const now = new Date(\"2026-11-13T10:00:00Z\");\n for (const name of [\"signup\", \"signup\", \"login\"]) await recordEvent(env.STATE, name, now);\n await recordEvent(env.STATE, \"signup\", new Date(\"2026-11-12T10:00:00Z\"));\n\n const { message, calls } = fakeMessage({ id: \"job-1\", date: \"2026-11-13\" });\n await worker.queue!(batch(message), env);\n assert.deepEqual(calls, [\"ack\"]);\n\n const digest = JSON.parse(state.store.get(\"digest:2026-11-13\") ?? \"null\");\n assert.equal(digest.total, 3);\n assert.deepEqual(digest.byName, { signup: 2, login: 1 });\n assert.equal(JSON.parse(state.store.get(\"job:job-1\") ?? \"null\").status, \"done\");\n });\n\n test(\"a redelivered message is acked without doing the work again\", async () => {\n const { env, state } = fakeEnv();\n const first = fakeMessage({ id: \"job-2\", date: \"2026-11-13\" });\n await worker.queue!(batch(first.message), env);\n const stored = state.store.get(\"digest:2026-11-13\");\n await recordEvent(env.STATE, \"late\", new Date(\"2026-11-13T11:00:00Z\"));\n const again = fakeMessage({ id: \"job-2\", date: \"2026-11-13\" }, 2);\n await worker.queue!(batch(again.message), env);\n assert.deepEqual(again.calls, [\"ack\"]);\n assert.equal(state.store.get(\"digest:2026-11-13\"), stored);\n });\n\n test(\"a failure is retried with a growing delay, then recorded and acked\", async () => {\n const { env, state } = fakeEnv();\n mock.method(console, \"error\", () => {}); // the consumer logs each failed attempt; this test makes three\n state.list = async () => {\n throw new Error(\"list is down\");\n };\n const job = { id: \"job-3\", date: \"2026-11-13\" };\n for (const [attempt, expected] of [\n [1, \"retry:2\"],\n [2, \"retry:4\"],\n [3, \"ack\"],\n ] as const) {\n const { message, calls } = fakeMessage(job, attempt);\n await worker.queue!(batch(message), env);\n assert.deepEqual(calls, [expected], `attempt ${attempt}`);\n }\n const record = JSON.parse(state.store.get(\"job:job-3\") ?? \"null\");\n assert.equal(record.status, \"failed\");\n assert.equal(record.error, \"list is down\");\n assert.equal(record.attempts, 3);\n });\n});\n",
33
+ "test/jobs.test.ts": "import assert from \"node:assert/strict\";\nimport { after, before, describe, test } from \"node:test\";\nimport { startServer, type Server } from \"./server.ts\";\n\ninterface Record {\n id: string;\n status: string;\n digest?: { total: number; byName: { [name: string]: number } };\n}\n\ndescribe(\"jobs, end to end\", () => {\n let server: Server;\n before(async () => (server = await startServer()));\n after(async () => server.stop());\n\n const post = (path: string, body: unknown) =>\n fetch(server.base + path, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(body),\n });\n\n /** The consumer runs out of band, so poll rather than assume it already has. */\n async function until(id: string, status: string): Promise<Record> {\n for (let i = 0; i < 80; i++) {\n const record = (await (await fetch(`${server.base}/jobs/${id}`)).json()) as Record;\n if (record.status === status) return record;\n await new Promise((resolve) => setTimeout(resolve, 250));\n }\n throw new Error(`job ${id} never reached ${status}`);\n }\n\n test(\"a queued digest job is processed by the consumer and stored\", async () => {\n const name = `t${Date.now().toString(36)}`;\n for (let i = 0; i < 3; i++) assert.equal((await post(\"/events\", { name })).status, 201);\n\n const queued = await post(\"/jobs\", {});\n assert.equal(queued.status, 202);\n const job = (await queued.json()) as { id: string; date: string };\n assert.equal(queued.headers.get(\"location\"), `/jobs/${job.id}`);\n\n const done = await until(job.id, \"done\");\n assert.ok((done.digest?.byName[name] ?? 0) >= 3);\n const stored = (await (await fetch(`${server.base}/digests/${job.date}`)).json()) as { total: number };\n assert.ok(stored.total >= 3);\n });\n\n test(\"refuses what it cannot queue or record\", async () => {\n assert.equal((await post(\"/events\", { name: \"Not Valid\" })).status, 400);\n assert.equal((await post(\"/events\", {})).status, 400);\n assert.equal((await post(\"/jobs\", { date: \"yesterday\" })).status, 400);\n assert.equal((await post(\"/jobs\", { date: \"2026-13-45\" })).status, 400);\n assert.equal((await fetch(`${server.base}/jobs/unknown-id`)).status, 404);\n assert.equal((await fetch(`${server.base}/digests/1999-01-01`)).status, 404);\n });\n});\n",
34
+ "test/server.ts": "// The server under test. BASE_URL points at one that is already running (a deployed copy, a standalone build);\n// without it, this starts `sproutboat dev` for this project on a free port and stops it afterwards.\nimport { spawn, type ChildProcess } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { createServer } from \"node:net\";\nimport { fileURLToPath } from \"node:url\";\n\nconst project = fileURLToPath(new URL(\"..\", import.meta.url));\nconst bin = fileURLToPath(new URL(\"../node_modules/.bin/sproutboat\", import.meta.url));\n\nexport interface Server {\n base: string;\n stop(): Promise<void>;\n}\n\nasync function freePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const probe = createServer();\n probe.once(\"error\", reject);\n probe.listen(0, \"127.0.0.1\", () => {\n const address = probe.address();\n probe.close(() =>\n address && typeof address === \"object\" ? resolve(address.port) : reject(new Error(\"no port\")),\n );\n });\n });\n}\n\nexport async function startServer(): Promise<Server> {\n const given = process.env.BASE_URL;\n if (given) return { base: given.replace(/\\/$/, \"\"), stop: async () => {} };\n\n const port = await freePort();\n const child: ChildProcess = spawn(\n existsSync(bin) ? bin : \"sproutboat\",\n [\"dev\", \"--port\", String(port), \"--no-watch\"],\n {\n cwd: project,\n stdio: [\"ignore\", \"ignore\", \"pipe\"],\n },\n );\n let stderr = \"\";\n child.stderr?.on(\"data\", (chunk) => (stderr += chunk));\n let exited = false;\n child.once(\"exit\", () => (exited = true));\n\n const base = `http://127.0.0.1:${port}`;\n // The first build compiles the app, which takes a while; any answer at all means it is up.\n const deadline = Date.now() + 180_000;\n let up = false;\n while (!up && !exited && Date.now() < deadline) {\n try {\n await fetch(base + \"/\");\n up = true;\n } catch {\n await new Promise((resolve) => setTimeout(resolve, 250));\n }\n }\n if (!up) {\n child.kill(\"SIGTERM\");\n throw new Error(`sproutboat dev did not come up${exited ? \" (it exited)\" : \" in 180 s\"}:\\n${stderr}`);\n }\n return {\n base,\n stop: async () => {\n if (exited) return;\n const done = new Promise((resolve) => child.once(\"exit\", resolve));\n child.kill(\"SIGTERM\");\n await done;\n },\n };\n}\n",
35
+ "test/tsconfig.json": "{\n \"extends\": \"../tsconfig.json\",\n \"compilerOptions\": { \"types\": [\"node\"] },\n \"include\": [\"../src\", \"../worker-configuration.d.ts\", \".\"]\n}\n",
36
+ "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n \"types\": [],\n \"strict\": true,\n \"noEmit\": true,\n \"skipLibCheck\": true,\n \"isolatedModules\": true,\n \"allowImportingTsExtensions\": true,\n \"erasableSyntaxOnly\": true\n },\n \"include\": [\"src\", \"worker-configuration.d.ts\"]\n}\n"
37
+ }
38
+ },
39
+ "realtime-room": {
40
+ "description": "A chat room with presence on Durable Objects and hibernating WebSockets",
41
+ "files": {
42
+ ".gitignore": ".sproutboat/\n.dev.vars\nnode_modules/\n",
43
+ "README.md": "# realtime-room\n\nA chat room with presence, on Durable Objects and hibernating WebSockets. One object per room name; everyone connected to a room is connected to the same object.\n\nIt is standard Cloudflare Workers TypeScript: the same files run on Cloudflare unchanged. `Env` comes from `worker-configuration.d.ts`, which `sproutboat types` writes from `sproutboat.jsonc`; run it again after you change the bindings.\n\n## What it shows\n\n- `ctx.acceptWebSocket(socket, tags)` instead of `socket.accept()`: the object does not hold its connections, so a room with nobody talking can be unloaded and is woken by the next frame (`src/room.ts`).\n- `socket.serializeAttachment()` for what a socket must remember (its name), and `getWebSockets(tag)` to find a socket by tag. Nothing is kept in a class field, because fields do not survive hibernation.\n- `webSocketMessage`, `webSocketClose` and `webSocketError` as the object's handlers, and `setWebSocketAutoResponse` so a client's `ping` is answered without waking the room.\n- Durable Object storage for the last 50 messages, replayed to whoever joins.\n- An RPC method (`room.presence()`) called from the Worker, and `room.fetch(request)` to hand the upgrade to the object.\n- Validation at the edge of the room: names, frame shape and size, one connection per name.\n\n## Run it\n\n```sh\nnpm install\nsproutboat dev # http://localhost:8787, rebuilt on save\n```\n\nOpen it in two browser tabs, pick a room, and talk to yourself. Or as one executable with its bindings inside it:\n\n```sh\nsproutboat build --standalone --target host\nPORT=8080 ./dist/realtime-room\n```\n\n## Try it\n\nThe protocol is JSON text frames. Connect to `/rooms/<room>?name=<you>`:\n\n```sh\nnpx wscat -c 'ws://localhost:8787/rooms/lobby?name=ada'\n> {\"type\":\"chat\",\"text\":\"hello\"}\n< {\"type\":\"message\",\"from\":\"ada\",\"text\":\"hello\",\"at\":1763000000000}\ncurl localhost:8787/rooms/lobby/presence # [\"ada\"]\n```\n\nThe server sends `welcome` (who is here and the history), `join`, `leave`, `message` and `error` frames. A room name is 1 to 32 lowercase letters, digits or dashes; a name is 1 to 24 letters, digits, spaces, dots, dashes or underscores, and is unique in its room.\n\n## Test it\n\n```sh\nnpm test # starts `sproutboat dev` and drives it over HTTP and WebSockets\nBASE_URL=http://localhost:8080 npm test # or drive a server that is already running\nnpm run typecheck\n```\n\nThe tests use Node's own test runner (Node 22.18 or later runs the `.ts` files as they are).\n\n## Deploy it\n\n```sh\nsproutboat login\nsproutboat deploy # prints https://realtime-room.<team>.sproutboat.app\n```\n\nBefore you put it in front of people, add what a public room needs: a rate limit per connection and per address (see the `ratelimit` example and the chat room in Sproutboat's own `kitchen-sink`), and a login so a name means someone.\n\n## Moving to Wrangler\n\nThe config is `sproutboat.jsonc` today. It uses only keys that exist in a `wrangler.jsonc` with the same meaning (`name`, `main`, `compatibility_date`, `durable_objects`), and nothing in `src/` is Sproutboat-specific, so converting is mechanical: `durable_objects` becomes a `bindings` list plus a `migrations` entry (`new_sqlite_classes: [\"Room\"]`). Sproutboat is making `wrangler.jsonc` a native config; `sproutboat migrate` will then do the conversion for you.\n",
44
+ "package.json": "{\n \"name\": \"realtime-room\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"description\": \"A chat room with presence on Durable Objects and hibernating WebSockets\",\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"sproutboat dev\",\n \"deploy\": \"sproutboat deploy\",\n \"check\": \"sproutboat check\",\n \"types\": \"sproutboat types\",\n \"typecheck\": \"tsc -p . && tsc -p test\",\n \"test\": \"node --test test/*.test.ts\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^24.0.0\",\n \"typescript\": \"^7.0.2\"\n }\n}\n",
45
+ "sproutboat.jsonc": "{\n \"$schema\": \"https://sproutboat.com/schema.json\",\n \"name\": \"realtime-room\",\n \"main\": \"src/index.ts\",\n \"compatibility_date\": \"2026-11-18\",\n \"durable_objects\": { \"ROOMS\": \"Room\" },\n}\n",
46
+ "src/index.ts": "// A realtime chat room: one Durable Object per room name, reached over WebSockets.\n//\n// GET / the chat page\n// GET /rooms/:room?name=ada upgrade to a WebSocket and join as \"ada\"\n// GET /rooms/:room/presence who is in the room, as JSON\n//\n// The Room class lives here, in the entry module: the class a `durable_objects` binding names must be exported from\n// it, and `sproutboat types` reads it from here to type `env.ROOMS` (so `room.presence()` is checked).\n//\n// The room holds its sockets with the WebSocket Hibernation API. The object never keeps the connections itself: it\n// hands each one to `ctx.acceptWebSocket()` and is called back on `webSocketMessage` / `webSocketClose`, so a room\n// with nobody talking can be unloaded and woken by the next frame. What it must remember about a socket is stored on\n// the socket (`serializeAttachment`) or looked up by tag (`getWebSockets(tag)`), never in a field of this class,\n// because fields do not survive hibernation.\nimport { DurableObject } from \"cloudflare:workers\";\nimport { PAGE } from \"./page.ts\";\n\nconst HISTORY_LIMIT = 50;\nconst MAX_TEXT = 500;\nconst NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9 ._-]{0,23}$/;\n\nexport interface ChatMessage {\n from: string;\n text: string;\n at: number;\n}\n\n/** What the server sends. */\nexport type ServerFrame =\n | { type: \"welcome\"; name: string; presence: string[]; history: ChatMessage[] }\n | { type: \"join\"; name: string; presence: string[] }\n | { type: \"leave\"; name: string; presence: string[] }\n | ({ type: \"message\" } & ChatMessage)\n | { type: \"error\"; error: string };\n\n/** Stored on each socket with `serializeAttachment`: it is still there after the object hibernates and wakes. */\ninterface Attachment {\n name: string;\n joinedAt: number;\n}\n\n/** A tag per name, so `getWebSockets(tag)` finds a user's socket without reading every attachment. */\nconst nameTag = (name: string) => `name:${name.toLowerCase()}`;\n\nexport class Room extends DurableObject<Env> {\n /** Who is here, as an RPC method: `await room.presence()` from the Worker. */\n presence(leaving?: string): string[] {\n const names: string[] = [];\n for (const socket of this.ctx.getWebSockets()) {\n const who = socket.deserializeAttachment() as Attachment | null;\n if (who && who.name !== leaving) names.push(who.name);\n }\n return names.sort((a, b) => a.localeCompare(b));\n }\n\n async fetch(request: Request): Promise<Response> {\n const name = new URL(request.url).searchParams.get(\"name\") ?? \"\";\n if (!NAME_PATTERN.test(name))\n return Response.json(\n { error: \"name is 1 to 24 letters, digits, spaces, dots, dashes or underscores\" },\n { status: 400 },\n );\n // Names are unique in a room; a closing socket can still be listed for a moment, so a quick reconnect may see this.\n if (this.ctx.getWebSockets(nameTag(name)).length > 0)\n return Response.json({ error: `${name} is already in this room` }, { status: 409 });\n if (request.headers.get(\"upgrade\")?.toLowerCase() !== \"websocket\")\n return new Response(\"expected a WebSocket upgrade\\n\", { status: 426, headers: { upgrade: \"websocket\" } });\n\n // Answered by the runtime without waking the object: a client's keep-alive costs the room nothing. It is set on\n // the object, not the socket, so setting it on each connection is harmless.\n this.ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(\"ping\", \"pong\"));\n const pair = new WebSocketPair();\n const [client, server] = [pair[0], pair[1]];\n this.ctx.acceptWebSocket(server, [nameTag(name)]);\n server.serializeAttachment({ name, joinedAt: Date.now() } satisfies Attachment);\n\n const history = (await this.ctx.storage.get<ChatMessage[]>(\"history\")) ?? [];\n this.send(server, { type: \"welcome\", name, presence: this.presence(), history });\n this.broadcast({ type: \"join\", name, presence: this.presence() }, server);\n return new Response(null, { status: 101, webSocket: client });\n }\n\n async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {\n const who = ws.deserializeAttachment() as Attachment | null;\n if (!who) return void ws.close(1011, \"unknown connection\");\n if (typeof message !== \"string\") return this.send(ws, { type: \"error\", error: \"send text frames\" });\n\n let frame: unknown;\n try {\n frame = JSON.parse(message);\n } catch {\n return this.send(ws, { type: \"error\", error: \"frames are JSON\" });\n }\n const text = typeof frame === \"object\" && frame !== null && \"text\" in frame ? frame.text : undefined;\n if (\n typeof frame !== \"object\" ||\n frame === null ||\n !(\"type\" in frame) ||\n frame.type !== \"chat\" ||\n typeof text !== \"string\"\n )\n return this.send(ws, { type: \"error\", error: 'send {\"type\":\"chat\",\"text\":\"...\"}' });\n const trimmed = text.trim();\n if (trimmed.length === 0 || trimmed.length > MAX_TEXT)\n return this.send(ws, { type: \"error\", error: `text is 1 to ${MAX_TEXT} characters` });\n\n const entry: ChatMessage = { from: who.name, text: trimmed, at: Date.now() };\n // Storage is the only memory that outlives hibernation: keep the last HISTORY_LIMIT messages.\n const history = (await this.ctx.storage.get<ChatMessage[]>(\"history\")) ?? [];\n history.push(entry);\n await this.ctx.storage.put(\"history\", history.slice(-HISTORY_LIMIT));\n this.broadcast({ type: \"message\", ...entry });\n }\n\n webSocketClose(ws: WebSocket, code: number): void {\n const who = ws.deserializeAttachment() as Attachment | null;\n // 1005, 1006 and 1015 describe a close, they cannot be sent as one.\n ws.close([1005, 1006, 1015].includes(code) ? 1000 : code, \"closed\");\n if (who) this.broadcast({ type: \"leave\", name: who.name, presence: this.presence(who.name) });\n }\n\n webSocketError(ws: WebSocket, error: unknown): void {\n console.error(\"socket error\", error instanceof Error ? error.message : String(error));\n ws.close(1011, \"error\");\n }\n\n private send(ws: WebSocket, frame: ServerFrame): void {\n ws.send(JSON.stringify(frame));\n }\n\n private broadcast(frame: ServerFrame, except?: WebSocket): void {\n const text = JSON.stringify(frame);\n for (const socket of this.ctx.getWebSockets()) {\n if (socket === except) continue;\n try {\n socket.send(text);\n } catch {\n // A socket that is closing can still be listed, and has nothing to receive.\n }\n }\n }\n}\n\nconst ROOM_NAME = /^[a-z0-9][a-z0-9-]{0,31}$/;\n\nexport default {\n async fetch(request, env) {\n const url = new URL(request.url);\n if (url.pathname === \"/\") return new Response(PAGE, { headers: { \"content-type\": \"text/html; charset=utf-8\" } });\n\n const route = /^\\/rooms\\/([^/]+)(\\/presence)?$/.exec(url.pathname);\n if (!route) return new Response(\"not found\\n\", { status: 404 });\n const [, name, presence] = route;\n if (!ROOM_NAME.test(name))\n return Response.json({ error: \"a room name is 1 to 32 lowercase letters, digits or dashes\" }, { status: 400 });\n\n // The same name is always the same object, wherever it runs, so everyone in a room reaches the same sockets.\n const room = env.ROOMS.getByName(name);\n if (presence) return Response.json(await room.presence());\n return room.fetch(request);\n },\n} satisfies ExportedHandler<Env>;\n",
47
+ "src/page.ts": "// The page at \"/\": a room name, a nickname and a message box. Plain DOM, no build step.\nexport const PAGE = `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>Realtime room</title>\n<style>\n body { font: 16px/1.4 system-ui, sans-serif; max-width: 40rem; margin: 2rem auto; padding: 0 1rem; }\n form { display: flex; gap: .5rem; margin: .5rem 0; }\n input { flex: 1; padding: .4rem; font: inherit; }\n button { padding: .4rem .8rem; font: inherit; }\n #log { border: 1px solid #8884; border-radius: 6px; height: 50vh; overflow-y: auto; padding: .5rem; }\n #log p { margin: .2rem 0; }\n .note { color: #888; }\n</style>\n</head>\n<body>\n<h1>Realtime room</h1>\n<form id=\"join\">\n <input id=\"room\" value=\"lobby\" aria-label=\"Room\" pattern=\"[a-z0-9][a-z0-9-]*\" required>\n <input id=\"name\" placeholder=\"Your name\" aria-label=\"Your name\" required>\n <button>Join</button>\n</form>\n<div id=\"log\" role=\"log\" aria-live=\"polite\"></div>\n<form id=\"say\" hidden>\n <input id=\"text\" placeholder=\"Say something\" aria-label=\"Message\" autocomplete=\"off\" maxlength=\"500\" required>\n <button>Send</button>\n</form>\n<script>\n const $ = (id) => document.getElementById(id);\n let socket;\n let keepAlive;\n function show(text, note) {\n const line = document.createElement(\"p\");\n line.textContent = text;\n if (note) line.className = \"note\";\n $(\"log\").append(line);\n $(\"log\").scrollTop = $(\"log\").scrollHeight;\n }\n $(\"join\").addEventListener(\"submit\", (event) => {\n event.preventDefault();\n if (socket) socket.close();\n const url = new URL(\"/rooms/\" + $(\"room\").value, location.href);\n url.protocol = url.protocol === \"https:\" ? \"wss:\" : \"ws:\";\n url.searchParams.set(\"name\", $(\"name\").value);\n socket = new WebSocket(url);\n socket.onopen = () => {\n $(\"say\").hidden = false;\n // The runtime answers \"ping\" with \"pong\" itself, which keeps idle connections open through proxies.\n keepAlive = setInterval(() => socket.send(\"ping\"), 30000);\n };\n socket.onclose = () => {\n clearInterval(keepAlive);\n $(\"say\").hidden = true;\n show(\"disconnected\", true);\n };\n socket.onmessage = ({ data }) => {\n if (data === \"pong\") return;\n const frame = JSON.parse(data);\n if (frame.type === \"welcome\") {\n $(\"log\").replaceChildren();\n for (const message of frame.history) show(message.from + \": \" + message.text);\n show(\"here: \" + frame.presence.join(\", \"), true);\n } else if (frame.type === \"message\") show(frame.from + \": \" + frame.text);\n else if (frame.type === \"join\") show(frame.name + \" joined\", true);\n else if (frame.type === \"leave\") show(frame.name + \" left\", true);\n else if (frame.type === \"error\") show(frame.error, true);\n };\n });\n $(\"say\").addEventListener(\"submit\", (event) => {\n event.preventDefault();\n socket.send(JSON.stringify({ type: \"chat\", text: $(\"text\").value }));\n $(\"text\").value = \"\";\n });\n</script>\n</body>\n</html>\n`;\n",
48
+ "test/room.test.ts": "import assert from \"node:assert/strict\";\nimport { after, before, describe, test } from \"node:test\";\nimport { startServer, type Server } from \"./server.ts\";\n\ninterface Frame {\n type: string;\n [key: string]: unknown;\n}\n\n/** A WebSocket client that keeps every frame it receives, so a test can wait for the one it expects. */\nclass Client {\n private readonly frames: Frame[] = [];\n private readonly listeners: Array<() => void> = [];\n\n private readonly socket: WebSocket;\n\n private constructor(socket: WebSocket) {\n this.socket = socket;\n socket.addEventListener(\"message\", ({ data }) => {\n this.frames.push(data === \"pong\" ? { type: \"pong\" } : JSON.parse(String(data)));\n this.wake();\n });\n }\n\n static async join(base: string, room: string, name: string): Promise<Client> {\n const socket = new WebSocket(`${base.replace(\"http\", \"ws\")}/rooms/${room}?name=${encodeURIComponent(name)}`);\n const client = new Client(socket);\n await new Promise<void>((resolve, reject) => {\n socket.addEventListener(\"open\", () => resolve());\n socket.addEventListener(\"error\", () => reject(new Error(`could not join ${room} as ${name}`)));\n });\n return client;\n }\n\n private wake() {\n for (const listener of this.listeners.splice(0)) listener();\n }\n\n /** The next frame of this type that has not been taken, or a failure after `ms`. */\n async next(type: string, ms = 5000): Promise<Frame> {\n const deadline = Date.now() + ms;\n for (;;) {\n const at = this.frames.findIndex((frame) => frame.type === type);\n if (at >= 0) return this.frames.splice(at, 1)[0];\n const left = deadline - Date.now();\n if (left <= 0) throw new Error(`no ${type} frame within ${ms} ms; got ${JSON.stringify(this.frames)}`);\n await new Promise<void>((resolve) => {\n this.listeners.push(resolve);\n setTimeout(resolve, left);\n });\n }\n }\n\n /** Whether a frame of this type shows up in the next `ms`. */\n async receives(type: string, ms = 400): Promise<boolean> {\n return this.next(type, ms).then(\n () => true,\n () => false,\n );\n }\n\n say(text: string) {\n this.socket.send(JSON.stringify({ type: \"chat\", text }));\n }\n\n sendRaw(data: string) {\n this.socket.send(data);\n }\n\n async close() {\n const closed = new Promise<void>((resolve) => this.socket.addEventListener(\"close\", () => resolve()));\n this.socket.close(1000);\n await closed;\n }\n}\n\ndescribe(\"realtime room\", () => {\n let server: Server;\n // A room per run, so a server that was already running (BASE_URL) starts each run with an empty one.\n const run = Date.now().toString(36);\n const room = (name: string) => `${name}-${run}`;\n before(async () => (server = await startServer()));\n after(async () => server.stop());\n\n test(\"serves the chat page\", async () => {\n const page = await fetch(server.base + \"/\");\n assert.equal(page.status, 200);\n assert.match(page.headers.get(\"content-type\") ?? \"\", /text\\/html/);\n assert.match(await page.text(), /Realtime room/);\n });\n\n test(\"welcomes a new socket with who is here and what was said\", async () => {\n const ada = await Client.join(server.base, room(\"welcome\"), \"ada\");\n const welcome = await ada.next(\"welcome\");\n assert.deepEqual(welcome.presence, [\"ada\"]);\n assert.deepEqual(welcome.history, []);\n await ada.close();\n });\n\n test(\"delivers a message to everyone in the room, and to no other room\", async () => {\n const [ada, bob, eve] = await Promise.all([\n Client.join(server.base, room(\"talk\"), \"ada\"),\n Client.join(server.base, room(\"talk\"), \"bob\"),\n Client.join(server.base, room(\"elsewhere\"), \"eve\"),\n ]);\n await bob.next(\"welcome\");\n ada.say(\"hello room\");\n const heard = await bob.next(\"message\");\n assert.equal(heard.from, \"ada\");\n assert.equal(heard.text, \"hello room\");\n assert.equal((await ada.next(\"message\")).text, \"hello room\");\n assert.equal(await eve.receives(\"message\"), false);\n await Promise.all([ada.close(), bob.close(), eve.close()]);\n });\n\n test(\"announces joins and leaves, and lists who is present\", async () => {\n const name = room(\"presence\");\n const ada = await Client.join(server.base, name, \"ada\");\n await ada.next(\"welcome\");\n const bob = await Client.join(server.base, name, \"bob\");\n assert.deepEqual((await ada.next(\"join\")).presence, [\"ada\", \"bob\"]);\n\n const listed = await fetch(`${server.base}/rooms/${name}/presence`);\n assert.deepEqual(await listed.json(), [\"ada\", \"bob\"]);\n\n await bob.close();\n const left = await ada.next(\"leave\");\n assert.equal(left.name, \"bob\");\n assert.deepEqual(left.presence, [\"ada\"]);\n await ada.close();\n });\n\n test(\"replays the history to a late joiner\", async () => {\n const name = room(\"history\");\n const ada = await Client.join(server.base, name, \"ada\");\n await ada.next(\"welcome\");\n ada.say(\"first\");\n ada.say(\"second\");\n await ada.next(\"message\");\n await ada.next(\"message\");\n const late = await Client.join(server.base, name, \"late\");\n const history = (await late.next(\"welcome\")).history as Array<{ text: string }>;\n assert.deepEqual(\n history.map((message) => message.text),\n [\"first\", \"second\"],\n );\n await Promise.all([ada.close(), late.close()]);\n });\n\n test(\"answers a ping without involving the room\", async () => {\n const ada = await Client.join(server.base, room(\"ping\"), \"ada\");\n await ada.next(\"welcome\");\n ada.sendRaw(\"ping\");\n assert.equal((await ada.next(\"pong\")).type, \"pong\");\n await ada.close();\n });\n\n test(\"answers a bad frame with an error and keeps the socket\", async () => {\n const ada = await Client.join(server.base, room(\"errors\"), \"ada\");\n await ada.next(\"welcome\");\n ada.sendRaw(\"not json\");\n assert.match(String((await ada.next(\"error\")).error), /JSON/);\n ada.sendRaw(JSON.stringify({ type: \"chat\", text: \"x\".repeat(501) }));\n assert.match(String((await ada.next(\"error\")).error), /1 to 500/);\n ada.say(\"still here\");\n assert.equal((await ada.next(\"message\")).text, \"still here\");\n await ada.close();\n });\n\n test(\"refuses what it cannot serve before upgrading\", async () => {\n const name = room(\"refusals\");\n const bad = await fetch(`${server.base}/rooms/Not_Valid?name=ada`);\n assert.equal(bad.status, 400);\n const anonymous = await fetch(`${server.base}/rooms/${name}`);\n assert.equal(anonymous.status, 400);\n const plain = await fetch(`${server.base}/rooms/${name}?name=ada`);\n assert.equal(plain.status, 426);\n\n const ada = await Client.join(server.base, name, \"ada\");\n await ada.next(\"welcome\");\n const taken = await fetch(`${server.base}/rooms/${name}?name=ada`);\n assert.equal(taken.status, 409);\n await ada.close();\n assert.equal((await fetch(server.base + \"/nowhere\")).status, 404);\n });\n});\n",
49
+ "test/server.ts": "// The server under test. BASE_URL points at one that is already running (a deployed copy, a standalone build);\n// without it, this starts `sproutboat dev` for this project on a free port and stops it afterwards.\nimport { spawn, type ChildProcess } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { createServer } from \"node:net\";\nimport { fileURLToPath } from \"node:url\";\n\nconst project = fileURLToPath(new URL(\"..\", import.meta.url));\nconst bin = fileURLToPath(new URL(\"../node_modules/.bin/sproutboat\", import.meta.url));\n\nexport interface Server {\n base: string;\n stop(): Promise<void>;\n}\n\nasync function freePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const probe = createServer();\n probe.once(\"error\", reject);\n probe.listen(0, \"127.0.0.1\", () => {\n const address = probe.address();\n probe.close(() =>\n address && typeof address === \"object\" ? resolve(address.port) : reject(new Error(\"no port\")),\n );\n });\n });\n}\n\nexport async function startServer(): Promise<Server> {\n const given = process.env.BASE_URL;\n if (given) return { base: given.replace(/\\/$/, \"\"), stop: async () => {} };\n\n const port = await freePort();\n const child: ChildProcess = spawn(\n existsSync(bin) ? bin : \"sproutboat\",\n [\"dev\", \"--port\", String(port), \"--no-watch\"],\n {\n cwd: project,\n stdio: [\"ignore\", \"ignore\", \"pipe\"],\n },\n );\n let stderr = \"\";\n child.stderr?.on(\"data\", (chunk) => (stderr += chunk));\n let exited = false;\n child.once(\"exit\", () => (exited = true));\n\n const base = `http://127.0.0.1:${port}`;\n // The first build compiles the app, which takes a while; any answer at all means it is up.\n const deadline = Date.now() + 180_000;\n let up = false;\n while (!up && !exited && Date.now() < deadline) {\n try {\n await fetch(base + \"/\");\n up = true;\n } catch {\n await new Promise((resolve) => setTimeout(resolve, 250));\n }\n }\n if (!up) {\n child.kill(\"SIGTERM\");\n throw new Error(`sproutboat dev did not come up${exited ? \" (it exited)\" : \" in 180 s\"}:\\n${stderr}`);\n }\n return {\n base,\n stop: async () => {\n if (exited) return;\n const done = new Promise((resolve) => child.once(\"exit\", resolve));\n child.kill(\"SIGTERM\");\n await done;\n },\n };\n}\n",
50
+ "test/tsconfig.json": "{\n \"extends\": \"../tsconfig.json\",\n \"compilerOptions\": { \"types\": [\"node\"] },\n \"include\": [\"../src\", \"../worker-configuration.d.ts\", \".\"]\n}\n",
51
+ "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n \"types\": [],\n \"strict\": true,\n \"noEmit\": true,\n \"skipLibCheck\": true,\n \"isolatedModules\": true,\n \"allowImportingTsExtensions\": true,\n \"erasableSyntaxOnly\": true\n },\n \"include\": [\"src\", \"worker-configuration.d.ts\"]\n}\n"
52
+ }
53
+ },
54
+ "site-api": {
55
+ "description": "A static site and its API routes in one Worker",
56
+ "files": {
57
+ ".gitignore": ".sproutboat/\n.dev.vars\nnode_modules/\n",
58
+ "README.md": "# site-api\n\nA static front end and its API in one Worker: the files in `public/` are served as they are, and `/api/*` goes to the handler. The page is a small sign-up form that stores addresses in KV.\n\nIt is standard Cloudflare Workers TypeScript: the same files run on Cloudflare unchanged. `Env` comes from `worker-configuration.d.ts`, which `sproutboat types` writes from `sproutboat.jsonc`; run it again after you change the bindings.\n\n## What it shows\n\n- `assets` with `run_sprout_first: [\"/api/*\"]`: the handler runs first for the API routes, so a file can never shadow one, and every other path is a file.\n- `env.ASSETS.fetch(request)` to hand a request on to the files, and `not_found_handling: \"404-page\"` to answer a path with no file with `public/404.html` and a 404.\n- A form that posts JSON to the same origin, with no CORS to set up.\n- KV for the list: one key per address, so a repeated sign-up is a read and not a write, and a counter that cannot lose an update because there is none.\n- Validation of the body and the method, and `cache-control: no-store` on API answers.\n\n## Run it\n\n```sh\nnpm install\nsproutboat dev # http://localhost:8787, rebuilt on save\n```\n\nOr as one executable with the files and the bindings inside it:\n\n```sh\nsproutboat build --standalone --target host\nPORT=8080 ./dist/site-api\n```\n\n## Try it\n\n```sh\nopen http://localhost:8787\ncurl localhost:8787/api/health # {\"ok\":true}\ncurl -X POST localhost:8787/api/subscribe -H 'content-type: application/json' -d '{\"email\":\"ada@example.com\"}'\ncurl localhost:8787/api/subscribers/count # {\"count\":1}\ncurl -i localhost:8787/nope # 404, with public/404.html\n```\n\nCounting walks the list, which is fine for hundreds of addresses. For many more, keep a counter in a Durable Object and have the handler update it.\n\n## Test it\n\n```sh\nnpm test # starts `sproutboat dev` and drives the site and the API\nBASE_URL=http://localhost:8080 npm test # or drive a server that is already running\nnpm run typecheck\n```\n\nThe tests use Node's own test runner (Node 22.18 or later runs the `.ts` files as they are).\n\n## Deploy it\n\n```sh\nsproutboat login\nsproutboat deploy # prints https://site-api.<team>.sproutboat.app\n```\n\nThe first deploy creates the KV namespace and writes its id into `sproutboat.jsonc`; commit that change.\n\n## Moving to Wrangler\n\nThe config is `sproutboat.jsonc` today. It uses only keys that exist in a `wrangler.jsonc` with the same meaning (`name`, `main`, `compatibility_date`, `kv_namespaces`, `assets`), and nothing in `src/` is Sproutboat-specific, so converting is mechanical. Two spellings change: `kv_namespaces` becomes a list of `{ binding, id }`, and `run_sprout_first` becomes `run_worker_first`. Sproutboat is making `wrangler.jsonc` a native config; `sproutboat migrate` will then do the conversion for you.\n",
59
+ "package.json": "{\n \"name\": \"site-api\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"description\": \"A static site and its API routes in one Worker\",\n \"type\": \"module\",\n \"scripts\": {\n \"dev\": \"sproutboat dev\",\n \"deploy\": \"sproutboat deploy\",\n \"check\": \"sproutboat check\",\n \"types\": \"sproutboat types\",\n \"typecheck\": \"tsc -p . && tsc -p test\",\n \"test\": \"node --test test/*.test.ts\"\n },\n \"devDependencies\": {\n \"@types/node\": \"^24.0.0\",\n \"typescript\": \"^7.0.2\"\n }\n}\n",
60
+ "public/404.html": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Not found</title>\n <link rel=\"stylesheet\" href=\"/style.css\" />\n </head>\n <body>\n <main>\n <h1>Not found</h1>\n <p><a href=\"/\">Back to the home page</a></p>\n </main>\n </body>\n</html>\n",
61
+ "public/app.js": "const form = document.getElementById(\"signup\");\nconst status = document.getElementById(\"status\");\nconst count = document.getElementById(\"count\");\n\nasync function showCount() {\n const response = await fetch(\"/api/subscribers/count\");\n if (response.ok) count.textContent = `${(await response.json()).count} subscribed so far`;\n}\n\nform.addEventListener(\"submit\", async (event) => {\n event.preventDefault();\n status.textContent = \"Subscribing...\";\n const response = await fetch(\"/api/subscribe\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ email: form.elements.email.value }),\n });\n const body = await response.json();\n status.textContent = response.ok\n ? body.already\n ? \"You are already on the list.\"\n : \"You are on the list.\"\n : body.error;\n if (response.ok) await showCount();\n});\n\nshowCount();\n",
62
+ "public/index.html": "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Site with an API</title>\n <link rel=\"stylesheet\" href=\"/style.css\" />\n </head>\n <body>\n <main>\n <h1>Site with an API</h1>\n <p>\n This page is a file in <code>public/</code>. The form below talks to the Worker at <code>/api/subscribe</code>.\n </p>\n <form id=\"signup\">\n <label for=\"email\">Email</label>\n <input id=\"email\" name=\"email\" type=\"email\" autocomplete=\"email\" required />\n <button>Subscribe</button>\n </form>\n <p id=\"status\" role=\"status\" aria-live=\"polite\"></p>\n <p id=\"count\"></p>\n </main>\n <script src=\"/app.js\" type=\"module\"></script>\n </body>\n</html>\n",
63
+ "public/style.css": ":root { color-scheme: light dark; }\nbody { font: 16px/1.5 system-ui, sans-serif; margin: 0; }\nmain { max-width: 32rem; margin: 3rem auto; padding: 0 1rem; }\nform { display: grid; gap: 0.5rem; margin: 1.5rem 0; }\ninput, button { font: inherit; padding: 0.5rem 0.75rem; }\n",
64
+ "sproutboat.jsonc": "{\n \"$schema\": \"https://sproutboat.com/schema.json\",\n \"name\": \"site-api\",\n \"main\": \"src/index.ts\",\n \"compatibility_date\": \"2026-11-18\",\n \"kv_namespaces\": [\"SUBSCRIBERS\"],\n \"assets\": {\n \"directory\": \"public\",\n \"binding\": \"ASSETS\",\n // The handler goes first for /api/*, so a route is never shadowed by a file. Everything else is a file.\n \"run_sprout_first\": [\"/api/*\"],\n \"not_found_handling\": \"404-page\",\n },\n}\n",
65
+ "src/index.ts": "// A static site and its API in one Worker.\n//\n// The files in public/ are served by the platform. The handler runs first only for /api/*, answers those routes, and\n// hands everything else on to env.ASSETS, which serves the file (or public/404.html when there is none).\n//\n// GET /api/health {\"ok\":true}\n// GET /api/subscribers/count {\"count\": 12}\n// POST /api/subscribe {\"email\": \"ada@example.com\"} -> 201, or 200 when it was already on the list\n\nconst MAX_EMAIL = 254;\n// Deliberately loose: one @, something on each side, no spaces. The only real check of an address is mailing it.\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\nconst json = (body: unknown, status = 200, headers?: HeadersInit) =>\n Response.json(body, { status, headers: { \"cache-control\": \"no-store\", ...headers } });\n\nasync function countSubscribers(kv: Env[\"SUBSCRIBERS\"]): Promise<number> {\n let count = 0;\n let cursor: string | undefined;\n do {\n const page = await kv.list({ prefix: \"subscriber:\", cursor });\n count += page.keys.length;\n cursor = page.list_complete ? undefined : page.cursor;\n } while (cursor);\n return count;\n}\n\nasync function subscribe(request: Request, env: Env): Promise<Response> {\n if (!request.headers.get(\"content-type\")?.toLowerCase().startsWith(\"application/json\"))\n return json({ error: \"send application/json\" }, 415);\n const body: unknown = await request.json().catch(() => null);\n const email =\n typeof body === \"object\" && body !== null && \"email\" in body && typeof body.email === \"string\"\n ? body.email.trim().toLowerCase()\n : \"\";\n if (email.length === 0 || email.length > MAX_EMAIL || !EMAIL.test(email))\n return json({ error: \"email must be an email address\" }, 400);\n\n const key = `subscriber:${email}`;\n if ((await env.SUBSCRIBERS.get(key)) !== null) return json({ subscribed: true, already: true });\n await env.SUBSCRIBERS.put(key, JSON.stringify({ at: new Date().toISOString() }));\n return json({ subscribed: true, already: false }, 201);\n}\n\nexport default {\n async fetch(request, env) {\n const { pathname } = new URL(request.url);\n if (!pathname.startsWith(\"/api/\")) return env.ASSETS.fetch(request);\n\n if (pathname === \"/api/health\") return json({ ok: true });\n if (pathname === \"/api/subscribers/count\") {\n if (request.method !== \"GET\") return json({ error: \"use GET\" }, 405, { allow: \"GET\" });\n return json({ count: await countSubscribers(env.SUBSCRIBERS) });\n }\n if (pathname === \"/api/subscribe\") {\n if (request.method !== \"POST\") return json({ error: \"use POST\" }, 405, { allow: \"POST\" });\n return subscribe(request, env);\n }\n return json({ error: \"no such route\" }, 404);\n },\n} satisfies ExportedHandler<Env>;\n",
66
+ "test/server.ts": "// The server under test. BASE_URL points at one that is already running (a deployed copy, a standalone build);\n// without it, this starts `sproutboat dev` for this project on a free port and stops it afterwards.\nimport { spawn, type ChildProcess } from \"node:child_process\";\nimport { existsSync } from \"node:fs\";\nimport { createServer } from \"node:net\";\nimport { fileURLToPath } from \"node:url\";\n\nconst project = fileURLToPath(new URL(\"..\", import.meta.url));\nconst bin = fileURLToPath(new URL(\"../node_modules/.bin/sproutboat\", import.meta.url));\n\nexport interface Server {\n base: string;\n stop(): Promise<void>;\n}\n\nasync function freePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const probe = createServer();\n probe.once(\"error\", reject);\n probe.listen(0, \"127.0.0.1\", () => {\n const address = probe.address();\n probe.close(() =>\n address && typeof address === \"object\" ? resolve(address.port) : reject(new Error(\"no port\")),\n );\n });\n });\n}\n\nexport async function startServer(): Promise<Server> {\n const given = process.env.BASE_URL;\n if (given) return { base: given.replace(/\\/$/, \"\"), stop: async () => {} };\n\n const port = await freePort();\n const child: ChildProcess = spawn(\n existsSync(bin) ? bin : \"sproutboat\",\n [\"dev\", \"--port\", String(port), \"--no-watch\"],\n {\n cwd: project,\n stdio: [\"ignore\", \"ignore\", \"pipe\"],\n },\n );\n let stderr = \"\";\n child.stderr?.on(\"data\", (chunk) => (stderr += chunk));\n let exited = false;\n child.once(\"exit\", () => (exited = true));\n\n const base = `http://127.0.0.1:${port}`;\n // The first build compiles the app, which takes a while; any answer at all means it is up.\n const deadline = Date.now() + 180_000;\n let up = false;\n while (!up && !exited && Date.now() < deadline) {\n try {\n await fetch(base + \"/\");\n up = true;\n } catch {\n await new Promise((resolve) => setTimeout(resolve, 250));\n }\n }\n if (!up) {\n child.kill(\"SIGTERM\");\n throw new Error(`sproutboat dev did not come up${exited ? \" (it exited)\" : \" in 180 s\"}:\\n${stderr}`);\n }\n return {\n base,\n stop: async () => {\n if (exited) return;\n const done = new Promise((resolve) => child.once(\"exit\", resolve));\n child.kill(\"SIGTERM\");\n await done;\n },\n };\n}\n",
67
+ "test/site.test.ts": "import assert from \"node:assert/strict\";\nimport { after, before, describe, test } from \"node:test\";\nimport { startServer, type Server } from \"./server.ts\";\n\ndescribe(\"site and API\", () => {\n let server: Server;\n // The list may already hold addresses (a server that was running before), so each run uses its own.\n const tag = `t${Date.now().toString(36)}`;\n before(async () => (server = await startServer()));\n after(async () => server.stop());\n\n const subscribe = (body: string, type = \"application/json\") =>\n fetch(server.base + \"/api/subscribe\", { method: \"POST\", headers: { \"content-type\": type }, body });\n const count = async () =>\n ((await (await fetch(server.base + \"/api/subscribers/count\")).json()) as { count: number }).count;\n\n test(\"serves the page and its files from public/\", async () => {\n const home = await fetch(server.base + \"/\");\n assert.equal(home.status, 200);\n assert.match(home.headers.get(\"content-type\") ?? \"\", /text\\/html/);\n assert.match(await home.text(), /Site with an API/);\n const script = await fetch(server.base + \"/app.js\");\n assert.equal(script.status, 200);\n assert.match(script.headers.get(\"content-type\") ?? \"\", /javascript/);\n assert.match((await fetch(server.base + \"/style.css\")).headers.get(\"content-type\") ?? \"\", /css/);\n });\n\n test(\"answers a path with no file with the 404 page\", async () => {\n const missing = await fetch(server.base + \"/no/such/page\");\n assert.equal(missing.status, 404);\n assert.match(await missing.text(), /Not found/);\n });\n\n test(\"keeps /api/* for the handler\", async () => {\n const health = await fetch(server.base + \"/api/health\");\n assert.deepEqual(await health.json(), { ok: true });\n assert.equal(health.headers.get(\"cache-control\"), \"no-store\");\n const unknown = await fetch(server.base + \"/api/nothing\");\n assert.equal(unknown.status, 404);\n assert.deepEqual(await unknown.json(), { error: \"no such route\" });\n });\n\n test(\"subscribes an address once, in any case\", async () => {\n const before = await count();\n const first = await subscribe(JSON.stringify({ email: ` Ada.${tag}@Example.com ` }));\n assert.equal(first.status, 201);\n assert.deepEqual(await first.json(), { subscribed: true, already: false });\n const again = await subscribe(JSON.stringify({ email: `ada.${tag}@example.com` }));\n assert.equal(again.status, 200);\n assert.deepEqual(await again.json(), { subscribed: true, already: true });\n assert.equal(await count(), before + 1);\n });\n\n test(\"refuses what is not an address, and the wrong method or media type\", async () => {\n for (const body of [JSON.stringify({}), JSON.stringify({ email: \"nope\" }), JSON.stringify({ email: 5 }), \"{\"])\n assert.equal((await subscribe(body)).status, 400, body);\n assert.equal((await subscribe(\"email=a@b.co\", \"application/x-www-form-urlencoded\")).status, 415);\n const wrong = await fetch(server.base + \"/api/subscribe\");\n assert.equal(wrong.status, 405);\n assert.equal(wrong.headers.get(\"allow\"), \"POST\");\n });\n});\n",
68
+ "test/tsconfig.json": "{\n \"extends\": \"../tsconfig.json\",\n \"compilerOptions\": { \"types\": [\"node\"] },\n \"include\": [\"../src\", \"../worker-configuration.d.ts\", \".\"]\n}\n",
69
+ "tsconfig.json": "{\n \"compilerOptions\": {\n \"target\": \"ES2022\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"lib\": [\"ES2022\", \"DOM\", \"DOM.Iterable\"],\n \"types\": [],\n \"strict\": true,\n \"noEmit\": true,\n \"skipLibCheck\": true,\n \"isolatedModules\": true,\n \"allowImportingTsExtensions\": true,\n \"erasableSyntaxOnly\": true\n },\n \"include\": [\"src\", \"worker-configuration.d.ts\"]\n}\n"
70
+ }
71
+ }
72
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The starter templates `sproutboat init --template <name>` writes.
3
+ *
4
+ * The sources are the projects in `templates/` at the root of this repository, which CI runs. They are embedded in
5
+ * the CLI as text (`src/templates-embedded.ts`, generated by `bun run templates:embed`) because the shipped CLI is one
6
+ * compiled executable with no files beside it. `hello` is the original starter, written by `init.ts`, and stays the
7
+ * default.
8
+ */
9
+ import { EMBEDDED_TEMPLATES } from "./templates-embedded";
10
+ import { jsonObject, parseJsonValue } from "./json";
11
+ import { planPackageJson } from "./init-package";
12
+
13
+ export const DEFAULT_TEMPLATE = "hello";
14
+ const HELLO_DESCRIPTION = "The smallest handler: one fetch in JavaScript, no bindings (the default)";
15
+
16
+ export type TemplateInfo = { name: string; description: string };
17
+ export type TemplateFile = { path: string; content: string };
18
+
19
+ /** What to run first, after installing, for a template whose dev server needs more than `dev` (a package.json script). */
20
+ const FIRST_RUN = new Map([["api-d1", "db:migrate"]]);
21
+
22
+ export const firstRunScript = (template: string): string | undefined => FIRST_RUN.get(template);
23
+ export const firstRunTemplates = (): string[] => [...FIRST_RUN.keys()];
24
+
25
+ export function listTemplates(): TemplateInfo[] {
26
+ return [
27
+ { name: DEFAULT_TEMPLATE, description: HELLO_DESCRIPTION },
28
+ ...Object.entries(EMBEDDED_TEMPLATES).map(([name, { description }]) => ({ name, description })),
29
+ ];
30
+ }
31
+
32
+ export const isTemplate = (name: string): boolean =>
33
+ name === DEFAULT_TEMPLATE || Object.hasOwn(EMBEDDED_TEMPLATES, name);
34
+
35
+ /** Every file of a template for a project called `name`: its own, with the template's name replaced by the project's. */
36
+ export function templateFiles(template: string, name: string, cliVersion: string): TemplateFile[] {
37
+ const source = EMBEDDED_TEMPLATES[template];
38
+ if (!source) throw new Error(`no embedded template "${template}"`);
39
+ return Object.entries(source.files).map(([path, content]) => {
40
+ if (path === "sproutboat.jsonc")
41
+ return { path, content: content.replace(/("name"\s*:\s*")[^"]*(")/, `$1${name}$2`) };
42
+ if (path === "package.json") {
43
+ const pkg = jsonObject(parseJsonValue(content));
44
+ if (!pkg) throw new Error(`templates/${template}/package.json is not an object`);
45
+ // The template's own devDependencies stay; `sproutboat` is added at this CLI's version, as for the hello starter.
46
+ return {
47
+ path,
48
+ content: planPackageJson(name, `${JSON.stringify({ ...pkg, name }, null, 2)}\n`, cliVersion).text,
49
+ };
50
+ }
51
+ return { path, content };
52
+ });
53
+ }
package/src/toolchain.ts CHANGED
@@ -19,6 +19,8 @@ import { BEARSSL_VERSION } from "./bearssl";
19
19
  import {
20
20
  PORFFOR_A22_CHANNEL,
21
21
  PORFFOR_A22_COMMIT,
22
+ PORFFOR_A23_CHANNEL,
23
+ PORFFOR_A23_COMMIT,
22
24
  standaloneChannel,
23
25
  type StandaloneChannel,
24
26
  } from "@sproutboat/toolchain/a21";
@@ -694,6 +696,7 @@ export function porfforRoot(start = import.meta.dir): string {
694
696
  /** The Porffor release a build compiles with, on the standalone channel it uses (default: the environment's). */
695
697
  export function porfforVersion(channel: StandaloneChannel = standaloneChannel()): string {
696
698
  if (process.env.PORFFOR_VERSION) return process.env.PORFFOR_VERSION;
699
+ if (channel === "alpha-23") return `${PORFFOR_A23_CHANNEL} (${PORFFOR_A23_COMMIT})`;
697
700
  if (channel === "alpha-22") return `${PORFFOR_A22_CHANNEL} (${PORFFOR_A22_COMMIT})`;
698
701
  return `${PORFFOR_CHANNEL} (${PORFFOR_COMMIT})`;
699
702
  }