The contract
A wirl app is a static folder, or a static folder plus one backend module. A static folder needs nothing else: point wirl deploy at it and it is served as it is.
A server-side app adds one JavaScript or TypeScript module exporting fetch(request, env), named by server in wirl.json. The module can return its own pages, or a built front end can sit beside it (next section) with the module as its API.
A front end beside the module#
Name the folder of built files in wirl.json:
{ "app": "board", "server": "server/index.js", "static": "dist" }
- A file in that folder is served at its own path under the root:
dist/assets/app.jsis/assets/app.js, and/isdist/index.html. A file wins over the module for its exact path. - A path names one file exactly, with one rule added: a path ending in
/serves theindex.htmlin that folder (/guide/isdist/guide/index.html). Nothing else is tried, so/aboutnever findsabout.html, norabout/index.html, and there is no404.htmlconvention. In an app that is only a static folder the same mapping applies, and a path that names no file gets the folder's topindex.htmlwith a200, unless the file it names ends in a dot and one to eight letters or digits (/logo.png, or/guide/, which namesguide/index.html): that gets a plain404. - The module answers everything else: every path that is no file, and every request that is not a
GETor aHEAD, even at a file's path. So/api/...is the module's with no configuration. - Add
"spa": truewhen the front end has a client-side router. A page navigation that matches no file (the browser asks fortext/html, and the path has no extension) then getsindex.htmland never reaches the module. Afetch()from the page, and a missing asset, still reach the module. - Nothing outside that folder is ever served: not the module's source, not
wirl.json. The module cannot sit inside the folder, and the folder cannot be the whole deploy; both are refused. - A schedule's path, a flow's steps and a public path always go to the module, whatever files exist.
wirl runs no build step, so build first and deploy the project folder: the module, the built folder, and whatever else is there are uploaded, and only the built folder is served. A deploy that names a folder with nothing in it is refused, naming the folder, and so is wirl dev. wirl dev serves the folder by the same rules.
A Next.js app is deployed as a static export, since next start cannot run: set output: "export" and trailingSlash: true in next.config, so that next build writes out/ with each page as a folder's index.html, which the rule above serves at the page's path ending in /. Name out under "static", and move the API routes into the module, on env.DB; with no API routes, deploy out alone ("dir": "out").
What does not run#
No language other than JavaScript or TypeScript, and no long-running process: wirl calls your module's fetch for each request, it does not start a program or open a port.
npm packages work in the server module. Install them in the project as usual (npm install pdf-lib), import them, and wirl deploy bundles the module on your machine, where the packages are, and sends the result up with the source. node_modules itself is never uploaded and nothing is installed on the server. The local MCP server's deploy tool does the same.
What will not work is a package that needs something the platform does not have: native code (sharp, better-sqlite3, bcrypt; use a pure-JavaScript equivalent), a real disk, or a long-lived process. A missing package is refused on your machine before anything is sent, naming it. The bundled module may be at most 10 MB.
Node's built-ins do work, deployed and under wirl dev alike: node:crypto, node:buffer, node:path, node:events, node:util, node:stream and the rest of what Cloudflare's Node compatibility provides, with Buffer and process as globals. Import them with or without the node: prefix. The one to be careful of is node:fs: a write succeeds, and the file is gone by the next request, because there is no disk. Keep data in env.DB.
wirl runs no build step. Deploy built output, not source a browser cannot run. For a static-only app, point wirl.json's dir at dist, not src. With a server module and a static folder, leave dir at the project root, because the module has to be uploaded too. The CLI reads dir; the MCP wirl_deploy tool is given its directory as an argument. A Vite or Parcel project (a vite.config file, or vite, a @vitejs/ package or parcel among the dependencies or devDependencies of a package.json, either one at the top of the deploy or directly in the folder that holds the page) whose index.html still loads source is refused on your machine before anything is sent, when wirl would serve that page as it is: with no server module, or with a static folder that holds it. Source here is a module script whose src ends in .ts, .tsx, .jsx, .vue or .svelte, or starts with src/ or /src/. The refusal says to run the build and deploy the folder it writes (wirl deploy dist) or, beside a server module, to name that folder under static. With a server module and no static folder, wirl serves none of the project's files, so the deploy goes ahead and says so in a warning, with what to do.
There is no filesystem beyond env.DB. wirl preview puts a build up at its own address, behind the same sign-in as the app, before you deploy it live; see Previews. wirl dev runs it here instead, under Node with your local packages, which can pass where the deploy then fails.
Data: env.DB#
env.DB is Cloudflare D1: a SQLite database that belongs to this one app, in D1's prepared-statement shape. It is created on the app's first deploy and stays with the app, not with any one deployment — a redeploy and a wirl rollback both see the same rows, because rolling back code never rolls back data.
Make its tables with numbered SQL files in a migrations/ folder, which wirl applies to the database before the code that reads them runs (see Tables: migrations/, below). A first file, migrations/0001_init.sql, one statement a line:
create table issues (id integer primary key, title text not null, state text not null, updated_at text not null)
create table stars (user_id text not null, issue_id integer not null, primary key (user_id, issue_id))
create index issues_state on issues (state)
Two limits are worth knowing before you hit them:
- A prepared statement takes at most 100 bound values with
.bind(). Past that it fails when it runs. exec()runs one statement per line, exactly as D1 does, so a statement written across several lines for readability fails on its first line:
Correct, one statement on one line:
await env.DB.exec('create table notes (id text primary key, body text)');
Wrong, the same statement split across lines for readability:
await env.DB.exec(`
create table notes (
id text primary key,
body text
)
`);
A file in migrations/ is read the same way: one statement a line.
Use .prepare() and .bind() for anything with a value in it.
What each call gives back, the same here as under wirl dev:
.all()and.run():{ success, results, meta }.resultsis an array of row objects (empty for a write).meta.last_row_idis the id an insert just made, andmeta.changesis how many rows a write touched..first(): the first row as an object, ornull..first("column"): that one value, ornull..raw(): rows as arrays of values, not objects.env.DB.batch([...]): an array of those results, one per statement, in order.env.DB.exec(sql):{ count, duration }, wherecountis how many statements ran.
Tables: migrations/#
Keep an app's tables in numbered SQL files in migrations/. wirl applies the files a database has not recorded yet, in number order: to a preview's own database when the preview is put up, to the live database before a new version goes live, and to .wirl/local.sqlite when wirl dev starts. Anyone who may deploy the app may change its tables this way, and a file that can lose data also waits for a person before it changes the live database (below).
The folder sits at the top of what is deployed. In the layout wirl init --server writes, that is beside wirl.json. When you deploy build output (wirl deploy dist), your build must copy migrations/ to the top of that folder with its files as they are, byte for byte: wirl deploy and wirl preview refuse an upload whose copy differs from the migrations/ beside wirl.json, which is the one wirl dev applies. A .wirlignore that keeps a migration file out is refused, since migrations/ goes up whole, and so is a migrations/ that is a symbolic link, as wirl reads and uploads no links. So are files in a Migrations/ folder (or any other spelling), and files named like migrations in a migrations/ beside a server module in a subfolder, each with a sentence of its own.
A file's name is four digits, an underscore, and 1 to 60 lowercase letters, digits or underscores, ending .sql: 0001_init.sql, 0002_add_status.sql. Each number is used once; gaps are fine. A file directly in migrations/ whose name ends .sql in any case, or starts with four digits, and is not such a name, is refused by name, so a mistyped one is never skipped. Other files there, such as a README.md, and subfolders are left alone. A file must be UTF-8 text holding at least one statement, with no NUL character and no carriage return that does not end a line. See Limits for how many files and bytes the folder may hold.
Each statement is one line, as exec() takes it: a statement written across lines, or two statements on one line, is refused, naming the line. A ; at the end is optional. Blank lines, lines that are only a comment, a comment after a statement, indentation and line endings are not part of what wirl compares, so they can change after a file has applied without counting as an edit.
wirl records each file it applies, with a hash of its statements, in a _wirl_migrations table inside that database, and for a hosted database checks that record against its own records of it before applying anything. A file that has applied is never edited and never removed: a deploy whose copy of an applied file differs is refused, naming the next number to make the change in instead; one that lacks an applied file is refused until it is put back; and a new file numbered below one already applied is refused. Leave _wirl_migrations to wirl: a migration may not name it, and a change the app's own code makes to it can stop wirl applying migrations to that database until someone looks, or make a file run a second time.
On a deploy the files apply after the upload and its checks, and before the new version goes live; the version already live, if any, keeps serving meanwhile. A file that fails fails the deploy: nothing in that file applies, files before it stay applied and are named, and the version that was live, if any, keeps serving. So each migration must be safe for the code already live: add tables and columns, and never rename or drop what that code reads. To rename or drop something live code reads, take two deploys: first one whose code no longer reads it (with any table or column that replaces it added in its own migration), then one whose migration renames or drops it.
A preview's database is built the same way, from every file in migrations/ the first time that name is put up, with no rows but the ones those files insert, and keeps what it has applied when the same name is put up again. A file edited after it applied there is refused, saying to put it back as it was or to start over: delete the preview (wirl preview --delete <name>) and put it up again. Under wirl dev the files apply only when it starts: a change to migrations/ is said on stderr and restarts nothing, so a new file needs wirl dev run again. A local database it refuses (an applied file edited, or tables that no migration made) is one to move aside, since it holds only what you made locally.
One apply at a time touches a database: a deploy that finds another applying to the same database is turned away with 409, and a Retry-After saying when that apply's hold ends at the latest. At most 2 of an org's databases are applied to at once, at most 2 applies run for one person at once, whatever the org, and at most 16 on one control plane; a deploy over any of these is turned away with 409 and a Retry-After of a minute. One app applies to one of its databases at a time, its live one and its previews' together: a deploy to another of its databases meanwhile is turned away the same way. A deploy that brings no file its database has not applied does not wait for the org's, the app's or the person's limit: it reads that database's record instead and goes on with nothing applied, unless another apply holds that database before it is done reading, an earlier apply's answer there is still to be settled, or the record cannot be read then. A deploy turned away applies nothing, and an adopt is turned away the same way.
D1 gives each request 30 seconds. On a database already on migrations wirl sends one file a request, so one file's statements share those 30 seconds; on a new database it sends as many files together as fit in 500 statements and 4 MB, never splitting a file. A request that runs longer, as one statement touching hundreds of thousands of rows can, is cut off by Cloudflare with nothing of it applied, and the deploy fails. wirl sends no more files once it has been applying for 2 minutes, and says how to apply the rest.
PRAGMA defer_foreign_keys = true (or on, or 1) is the one pragma a migration may use: D1 enforces foreign keys, and this holds their checks until the transaction ends, so a file can rebuild a table that others point at.
A rollback never undoes a migration: wirl rollback changes which version serves, never the database, so the version it returns to meets the tables as the later files left them. It says so, naming the files the database keeps that the version it returns to does not have, and which of them needed the "destructive" list: a rollback cannot bring back what they dropped, renamed, replaced, emptied or rewrote. Before it applies anything to the live database, wirl takes a restore point (a Time Travel bookmark of the database) and keeps it with its record of the deploy. If it cannot take one, a file that can lose data does not go, and the deploy says to try again in a minute; a file that only adds goes without one. Restoring one puts the whole database back as it was then, so it is a step wirl's operator takes, with the app owner's consent.
Files that can lose data#
A statement that can lose data is refused unless wirl.json names its file, so that one written by accident never runs:
{ "app": "notes", "server": "server.mjs", "migrations": { "destructive": ["0007_drop_legacy.sql"] } }
drop table: drops a table.alter table ... drop: drops a column.alter table ... rename: renames a table or a column, which the code already live still reads by its old name.replace into,insert or replace,update or replace, and a constraint withon conflict replace: each replaces rows, deleting the ones it replaces.- An
insert ... on conflict ... do update: overwrites the rows it conflicts with. Aninsert ... on conflict ... do nothingkeeps them, and needs no name. deletewith nowhere: deletes every row of a table.updatewith nowhere: changes every row of a table. A backfill is the exception: anupdatethat sets only columns analter table ... addearlier in the same file made, with nothing after itssetlist, loses nothing and needs no name.update or replaceis never one.- An
insertwhose column list names its own table, which is how a full-text table takes a command, such as'delete-all', which empties its index.
where 1 counts as a where: the rule is there to catch a forgotten one. The list is an accident guard, not a permission: anyone who can deploy the app can add a file to it, and the app's own code can drop a table at run time anyway. A name in it that is not a file in migrations/ is refused. A rollback never brings the data back.
On the live database, once it is on migrations (it has applied or adopted a file), a file holding such a statement goes alone: a deploy where it waits beside another new file is refused before anything applies, naming both. Deploy the others first (that file has not applied, so moving it aside is allowed), then deploy it on its own. A preview's database, the local one, and a live database that has applied nothing yet take it with the rest.
A person confirms each one#
A deploy that would apply a destructive file (one wirl.json's "destructive" list must name) to the live database waits for a person, whatever signed it in: a person's own sign-in, the local MCP tools, an agent's shell, CI or a deploy token. A first deploy of an app that already carries such a file asks too: wirl cannot tell a new database from an old one without asking it, and it asks a person before it asks the database anything. Previews and wirl dev never ask.
A person comes in one of two ways:
- The approve page. The refused deploy (
409,migration_needs_approval) names a page for each file it waits on. Someone who may deploy the app opens it, reads the file's statements and the lines that can lose data, and approves that exact text; then deploy again. Someone not signed in who opens the link signs in and lands on the page. A token can read the page, but approving needs a person signed in at a browser. An app keeps at most 5 files waiting; past that, the refusal says the rest get their pages once those are approved. For a program, the refusal'swaitinglists each file with its sha256 and, once it has one, its page. - A terminal. Run at a terminal, signed in as yourself (a full-scope sign-in, not a deploy token: tick full access on the approval page when you sign in) and without
--json,wirl deployshows the file's destructive lines and where to read the whole file, and asks the person to type its name; that one deploy then goes on. Anything else typed, Ctrl-C or Ctrl-D stops it, and wirl sends nothing more. No flag skips the person,--yesincluded. A deploy token, a pipe,--jsonand the MCP tools always get the page.
An approval holds for one apply of that exact text (a change to any statement needs another; comments and blank lines do not), and only in the org the app was in when it was given. The first live apply that sends the file uses it up, whether the file then applies or not: if that apply fails, or its answer is lost, a person approves again. Once a deploy sends the file, it no longer waits on the page, and while the live database holds it applied, the page says so and approves nothing. When wirl's operator restores a database and clears its rows, any approval of the files the restore undid is taken back, so the next deploy that would apply one asks a person again.
This is a guard against accidents, not a security boundary. It stops an agent's or a CI job's deploy from dropping, renaming or emptying live data by mistake: a deploy token never gets past it but through a person's approval on the page. It does not stop an agent, or any program, that holds a person's own full-scope sign-in and means to get through. Such a program can fake a terminal, or send the confirmation itself, since that is the file's name and its sha256, which anyone with the file can work out. Nor does it stop the app's own code, which can delete or rewrite rows at run time. The restore point wirl takes before a live apply is the way back, through wirl's operator.
What a migration cannot hold#
Each of these is refused, with a sentence naming the line:
- A statement that starts
begin,commit,end,rollback,savepointorrelease, and anor rollbackoron conflict rollbackclause: wirl runs each file as one transaction already. attachanddetach: a migration works on this app's own database only.- Every
pragmastatement butpragma defer_foreign_keys, above. vacuum,reindex,analyzeandexplain, andpragma_optimizewherever it appears, which runsanalyze.create temp(ortemporary): such a table, view or index would be gone when the request that made it ends.create trigger: Cloudflare's D1 HTTP API, which wirl applies migrations through, cannot create one, so do that work in the app's code.select, and any statement withreturning: a migration has no use for rows, and they would come back to wirl.- A parameter (
?,?1,:name,@name,$name,#name): nothing is bound, so write the value itself. load_extension.- Any name starting
sqlite_,_wirl_or_cf_, and any quoted text that mentions one: those are SQLite's, wirl's and D1's own. - A statement of any other kind: wirl runs only what it can place.
Apps that make their tables in code#
Making tables in the app's own code, with env.DB.exec(...) when the module starts, keeps working: a deploy with no migration file applies nothing and makes no _wirl_migrations table. Such an app moves onto migrations once, before its first migration file, because a live database with tables that no migration made refuses a deploy with files in migrations/, saying to run wirl migrations adopt.
wirl migrations adopt reads the live database's shape (its tables, views and indexes, never a row), writes it as migrations/0001_init.sql beside wirl.json, one statement a line, and records that file as applied on the live database, and in .wirl/local.sqlite too when that has the same shape and no record of other files, so wirl dev keeps your local rows. It needs migrations/ to hold no .sql file yet. Then take the create table lines out of the code, and put the next change in migrations/0002_<what>.sql. On wirl the adopted file runs only on a new database: a new preview's, or a new local one. Adopting again replaces the record of an earlier adopt, and is refused once a later file has applied.
Some shapes cannot be written as a migration, and adopt refuses them by name, recording nothing, among them a trigger, a statement a deploy would take only with its file named under "destructive" (such as a constraint with on conflict replace), a line break inside quotes, and anything that looks like a credential. Change or remove that object in the app's code first, then run wirl migrations adopt again. A schema of more than 500 tables, views, indexes and triggers, or more than 512 KiB of names and SQL, cannot be adopted: keep making its tables in code, and keep migrations/ free of .sql files.
Who is asking#
Six headers arrive on every request, set by wirl after it verified the visitor: x-wirl-user-id, x-wirl-user-email, x-wirl-user-name, x-wirl-role, x-wirl-app-id, and x-wirl-org. The browser cannot forge them. The name is percent-encoded, because a header cannot carry anything outside Latin-1 and a name can be anything: read it with decodeURIComponent(request.headers.get("x-wirl-user-name") || ""). An email address in plain ASCII, as nearly all are, arrives as it is; one that is not arrives percent-encoded like the name, so with no @ in it until it is decoded. The first four (x-wirl-user-id, x-wirl-user-email, x-wirl-user-name, x-wirl-role) are empty strings for an anonymous visitor to a public app, a schedule run, a flow step, or a public path. x-wirl-app-id and x-wirl-org are set on every request regardless, so test x-wirl-user-id for whether anyone is signed in at all.
x-wirl-role is the visitor's role on this app: owner for the app's owner while they are a member of its organisation, and for an owner or admin of the organisation; otherwise editor or viewer for someone the app is shared with in that role; otherwise viewer for any other member of the organisation when the app is visible to the whole org. An owner the organisation has removed is not owner: they get only what a share gives them. It is empty for anyone who holds none of those: an anonymous visitor, a schedule run, a flow step, a public path, and, on a public app, someone signed in with no role on it, a plain member of its organisation included. Deployed, it is never anything else, so there is no admin; under wirl dev it is whatever --role says, owner unless told, or what a request sends itself in x-wirl-role.
On a preview host, every request the module gets carries one more header, x-wirl-preview, holding the preview's name — not one of the six, since it names the build the request reached, not the visitor — and wirl marks the preview's HTML pages (by the badge's rules: a 200, and never a page the app gave its own Content-Security-Policy) saying which preview it is and when it goes (see Previews). An x-wirl-preview header that arrived with a request is removed with the other x-wirl-* headers; wirl dev sets none of it.
The visitor's IP address arrives as cf-connecting-ip, on signed-in requests and public paths alike. Cloudflare sets it and refuses any request that tries to send one, so it cannot be forged. It is what to key a rate limit on for a public path.
A rule that actually matters — "you only see your own notes" — has to be a line of SQL against these headers, because a check written into the page is only a suggestion to whoever is holding the browser:
export default {
async fetch(request, env) {
const userId = request.headers.get("x-wirl-user-id") || "";
const { results } = await env.DB
.prepare("select id, body from notes where user_id = ?")
.bind(userId)
.all();
return new Response(JSON.stringify(results), {
headers: { "content-type": "application/json" },
});
},
};
The same prepared-statement shape gives you .run() for an insert or an update, bound the same way, and env.DB.batch([...]) takes an array of bound statements and runs them as one transaction: the fast way to write many rows. Each statement in a batch still takes at most 100 bound values. The SQL is SQLite's, so insert ... on conflict (...) do update works.
Calling out#
A third-party API is fetch("https://api.hubspot.com/..."), once the connection is declared in wirl.json. wirl intercepts the call on its way out, attaches the key, and forwards it — the key never arrives in the app. Connections says what each kind of connection adds to the request.
One request, or one schedule run, may make up to 10,000 outgoing calls: that is Cloudflare's default for a Worker's subrequests, which wirl does not change for an app's Worker. Six outgoing calls are open at a time, so a larger Promise.all is not refused, it queues. A run that fetched 200 URLs one by one has been measured at about ten seconds.
Database queries are bounded in practice by time. Cloudflare's D1 limits page lists 1,000 queries per Worker invocation on paid plans; measured on wirl on 2026-10-07, one request ran 2,000 env.DB queries in a row and every one succeeded, at about 35 ms each (900 in 31.8 seconds, 1,001 in 35.1 seconds, 2,000 in 71.4 seconds), and a run of 5,000 was still going when the caller stopped waiting. Each query is a round trip, so put many statements in one env.DB.batch([...]) rather than looping single queries.
Streaming a response#
A response goes to the visitor as your module sends it. Return a ReadableStream as the body and each piece arrives when you enqueue it: server-sent events, a chat answer as it is written, a large download. The same happens under wirl dev.
export default {
async fetch(request) {
const encoder = new TextEncoder();
let n = 0;
const body = new ReadableStream({
async pull(controller) {
if (n === 5) return controller.close();
if (n > 0) await new Promise((done) => setTimeout(done, 1000));
n += 1;
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ n })}\n\n`));
},
});
return new Response(body, {
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
});
},
};
In a browser, new EventSource("/events") reads it. To look at a stream in a preview, wirl fetch /events --preview <name> shows what arrived in the first 10 seconds, or in as many as --wait <seconds> says, up to 300 (5 minutes), each line with the time it arrived, and says whether it was still going. There are no WebSockets, and a request's body is read whole before your module is called. The responses still read whole are an HTML page that carries the wirl badge, and a preview's HTML page that carries its preview marker; see Limits for that and the other details.
A model's reply can stream through your module the same way. Ask the vendor for a stream, and pass its body straight on: a 2xx answer sent as text/event-stream or application/x-ndjson reaches your module as the vendor sends it, not when the vendor has finished, and returning that body hands each piece to the visitor as it comes. Here with Anthropic, through a connection for the host api.anthropic.com of kind http_header, whose key goes in the x-api-key header (see Connections):
const MODEL = "<a model name from the vendor's documentation>";
export default {
async fetch(request) {
const { messages } = await request.json();
const answer = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
body: JSON.stringify({ model: MODEL, max_tokens: 1024, messages, stream: true }),
});
return new Response(answer.body, {
status: answer.status,
headers: {
"content-type": answer.headers.get("content-type") ?? "application/json",
"cache-control": "no-cache",
},
});
},
};
For MODEL, take a current name from Anthropic's models overview, which lists each model's name for the API; this page names none, so that it does not go stale.
stream: true is what asks this vendor for a stream, and most model APIs spell it the same way; Gemini's is its :streamGenerateContent method with alt=sse on the URL (alt=sse on :generateContent is not a stream). An answer that is not an event stream, including the vendor's error, is read whole before your module gets it, so the example passes that on too, all at once. To reshape the events rather than pass them on, read the body yourself, and split what you have read on line ends before parsing it: an event can arrive split across two reads. A streamed answer has 60 seconds to start, then 13 minutes for the rest, is cut after about two minutes with nothing arriving (4 minutes under wirl dev), and an app holds at most 20 at once. A cut can look like an ordinary end, so look for the vendor's own last event (Anthropic's stream ends with event: message_stop) before treating the answer as complete. When wirl itself cuts an event stream it writes a last event: error first, which Anthropic's and OpenAI's SDKs throw on, but that does not replace the check. Limits has the rest.
Live updates between people#
There are no WebSockets, and nothing but env.DB is shared between requests: another person's request may run in another copy of your module, so a variable is no way to tell it something, and one request cannot wake another. A page that shows what other people change, such as a board two people edit at once, uses a server-sent event stream that watches env.DB: every write also bumps a change counter, the /events stream reads the counter once a second and sends an event when it moves, and the page fetches what it shows again on each event.
The cards and the counter's one row, in migrations/0001_init.sql:
create table cards (id integer primary key, title text not null)
create table changes (id integer primary key check (id = 1), n integer not null)
insert into changes (id, n) values (1, 0)
The module:
const encoder = new TextEncoder();
const sleep = (ms) => new Promise((done) => setTimeout(done, ms));
// The write and the counter in one batch, so the event never comes before the change.
async function addCard(env, title) {
await env.DB.batch([
env.DB.prepare("insert into cards (title) values (?)").bind(title),
env.DB.prepare("update changes set n = n + 1 where id = 1"),
]);
}
// An event at once, then one each time the counter moves; closed after 120 seconds.
function changes(env) {
const opened = Date.now();
let seen = null;
let stopped = false;
return new ReadableStream({
async pull(controller) {
while (!stopped) {
if (Date.now() - opened >= 120_000) return controller.close();
const n = await env.DB.prepare("select n from changes where id = 1").first("n");
if (n !== seen) {
seen = n;
return controller.enqueue(encoder.encode(`data: ${n}\n\n`));
}
await sleep(1000);
}
},
cancel() {
stopped = true;
},
});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/events") {
return new Response(changes(env), {
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
});
}
if (url.pathname === "/cards" && request.method === "POST") {
const { title } = await request.json();
await addCard(env, String(title));
return new Response(null, { status: 204 });
}
if (url.pathname === "/cards") {
const { results } = await env.DB.prepare("select id, title from cards order by id").all();
return Response.json(results);
}
return new Response("Not found", { status: 404 });
},
};
And in the page:
async function showCards() {
const cards = await (await fetch("/cards")).json();
document.querySelector("#cards").textContent = cards.map((card) => card.title).join("\n");
}
new EventSource("/events").onmessage = showCards;
- The stream closes itself after 120 seconds, and the browser's
EventSourcereconnects by itself a few seconds later. Each read of the counter is a database query, a round trip of about 35 ms (measured on 2026-10-07, see Calling out). One request has run 2,000 queries, and how many more one may run has not been measured, so a stream that read the counter for ever could still meet a ceiling; 120 seconds is about 120 queries. wirl may end the stream sooner, once the visit it opened on is 8 minutes old (Limits), and the browser reconnects the same way. - The first event on each connection is the counter as it stands, so the page fetches again after every reconnect, and a change made while it was away is not missed.
- Each
pullwaits, on its own timer, until it has something to send: apullthat returns having sent nothing is not called again, and the stream hangs there, open and silent. With no sign-in behind the visit, as on a public app, the page then stops updating for good; otherwise it waits until wirl ends the stream at the visit's 8 minutes above, and the browser reconnects. - The 20 streamed answers an app may have open at once are answers from vendors, through a connection; the streams your module sends its own visitors are not counted, however many pages are open.
Schedules#
wirl fires a schedule as a POST to /__wirl/schedule/<name>, and wirl signs it, so your app does not need to check anything to know it is legitimate. A run gets 5 minutes unless that schedule's own timeout says otherwise, and 15 minutes at most. See Schedules for what the request carries and how retries and overlap work.
Flows#
Work that outlives one request (waiting for a person, trying again tomorrow, a long loop) is a flow. Declare it under flows in wirl.json, and wirl posts each of its steps to /__wirl/flow/<name>, one request at a time, with what the last step answered; between steps it sleeps, or waits for an event, for as long as the step asked. See Flows.
Running it locally#
wirl dev runs the module under Node, with a stub identity in the headers so the first run is already signed in; pass --user and --role to see what someone else would see. To try two people on one app, run wirl dev a second time in the same folder with another --user: the two share .wirl/local.sqlite, so each sees what the other writes. When the module or any file it imports changes, wirl dev rebuilds it and restarts it at the same address, with a line on stderr (the app's own write to a file does not count); an edit that does not build leaves the last build running and says why, and it tries again every second, and whenever you save a file in the project, until one builds, so a new file or a package installed with npm counts. wirl.json, .wirl/mocks.json and migrations/ are read once, at start, when migrations/ is applied to .wirl/local.sqlite, so a change to any of them needs wirl dev run again; a change to migrations/ is said once on stderr and restarts nothing. That is the migrations/ beside wirl.json, applied as Tables: migrations/ says. --port <n> keeps one address across restarts, --verbose prints a line per request when its answer ends (method, path without its query, status and ms), and a flag wirl dev does not take is refused rather than ignored. A request to a schedule's path, a flow's path or a declared public path arrives the way it does deployed, with the four visitor headers empty; a public path's signature is not checked locally, so x-wirl-verified is never set there. Nothing runs a flow from one step to the next here: wirl dev prints a curl for each flow's first step, and a flow runs for real in a preview. env holds DB and nothing else, the same as production, and the local database enforces D1's own two limits (above), so a query that trips either limit here would have tripped it deployed too. It also refuses a single bound value over 2,000,000 bytes, the size Cloudflare publishes for D1, which deployed D1 has been measured to exceed, and it hands a BLOB back as a plain array of numbers, as deployed D1 does: Limits has the sizes and the shape. Keys for outgoing calls are read from .wirl/keys.json, which git ignores, so you can exercise a real outbound call before you deploy, and .wirl/mocks.json lets you try one with no key, by sending a connection's calls to a server of your own (see Connections). A call to a host no connection declares is refused here as it is deployed, except the app's own call to 127.0.0.1, localhost or [::1], which goes there directly, as it always has. A connection that is still waiting on an admin works here and is refused deployed until it is approved. If a .wirlignore would keep files out of a deploy, wirl dev says how many, because it runs them all the same. wirl dev bundles the module with the same packages a deploy would. One more difference: a file written with node:fs is still there on the next request locally, and is gone deployed (see "What does not run", above), so the first time the app writes one, wirl dev says so on stderr, naming the path.
What wirl keeps#
An app's owner, while still a member of the organisation, or an organisation owner or admin, deletes it with Delete on its Settings page, with wirl delete <name>, or with an agent's wirl_delete_app tool, each with a full-scope sign-in. Its address and its previews stop answering at once, its schedules stop, and its unfinished runs are cancelled.
Delete an app and its data — bundles, its server module, its database — stays for 30 days before it is removed. Inside that window its owner, while still a member of the organisation, or an organisation owner or admin, can bring it back with wirl apps restore <name>, or from the Recently deleted list on the organisation's tools page. An app that was public when it was deleted comes back only from that list: public is a person's click, never a token's. It returns at the same address, exactly as wide as it was when it was deleted and no wider, with its live version, shares and connections as they were (a share revoked while it was deleted stays revoked), and its schedules resume from their next time, so none that came due while it was deleted runs late. Runs that were unfinished when it was deleted stay cancelled, and previews that have not expired open again. A name stays taken after its app is deleted, so a new app cannot have it, and a deploy under it is refused with the command that restores the app (the Recently deleted list, for one that was public), or who can; once the 30 days are over, the app cannot be restored and the name stays taken for good.
A live app keeps the bundle and server module of its newest 10 versions. Older ones are pruned: the files are gone and a wirl rollback can no longer return to them. The version currently live, and the one a rollback would return to, are always kept regardless of age.
Audit rows are kept for a year.
Every app and version the job removes is recorded in the org's activity, so a pruned version or a reaped app is something you can find, not something that quietly stopped being there. Two things nobody could ever open are cleared without a record: the leftover files of a preview build whose deploy failed, or that a newer build of the same preview replaced, and the server module of a deploy that failed after uploading it (that version's files are kept like any other version's).