An incremental application replica for the browser
spool keeps a replica of what a page needs on the visitor's device. The server sends templates and authorised, versioned data. A service worker stores them and computes eligible pages on the device, so a repeat view makes no request and costs the server no work.
It runs on any runtime that speaks Request and Response, bridges node:http for the rest, and
has no runtime dependencies. A site with no server code can load it from two files and a manifest.
- Why Spool
- Install
- Quick Start
- Page Runtime
- Runtimes
- Compatibility
- Static Sites
- Strategies
- Freshness Contracts
- Authenticated Data
- Catalogue
- Analytics Batching
- Third-Party Files
- Subresource Handling
- Shadow Mode
- Proxy Mode
- Trimmed Stylesheets
- Out of Scope
- Browser Support
- Subpath Exports
- Testing
- Related Repositories
- License
A response is a deterministic transformation of a set of authorised, versioned dependencies. The server half names those dependencies and sends the snapshot the visitor may hold. The worker keeps a graph of records, projections and responses, with their versions, and syncs only what the next page needs.
Your application keeps every write, every access decision and every page it cannot reproduce safely on the device. A route the worker cannot answer goes to the network, so adopting spool changes no page that was not declared.
docs/impact.md holds the energy model and the measurements behind it.
bun add @drupflare/spoolThe package ships ES modules and type declarations. Node, Deno, Bun, Workers and bundlers import it directly.
A page can load the runtime from cdn.drupflare.com with a Subresource Integrity hash. The release
notes list the hash for each version.
<script
src="https://cdn.drupflare.com/spool/<version>/client.js"
integrity="sha384-<hash from the release notes>"
crossorigin="anonymous"
defer
data-spool-client
></script>Page Runtime shows how createSpool injects the same tag.
spool-sw.js, client.js and manifest.json are attached to every GitHub release and published
under https://cdn.drupflare.com/spool/<version>/. A site that serves them as plain files needs no
server library. See Static Sites.
Every runtime below makes the same three moves:
- Declare the routes the device may answer, and the version store that tracks changes.
- Wrap the application's handler with
spool.handle. It answers/.spool/*and/spool-sw.js. - Send the client tag, which is the script tag for the page runtime.
handleinjects it into every HTML page the application returns, andspool.decoratedoes the same where there is no Fetch response to wrap.
A route has a path pattern, a strategy, the tags it depends on and a load function that returns
the data for one page. load runs on the server and may check the visitor's identity. Only its
result reaches the device.
import { defineRoute } from '@drupflare/spool/server';
export const routes = [
defineRoute('/node/:id', {
strategy: 'template',
template: {
source:
'<!doctype html><html><head><title>Node {{id}}</title></head><body><h1>Node {{id}}</h1></body></html>'
},
depends: (ctx) => [`node:${ctx.params['id']}`],
load: (ctx) => ({ id: ctx.params['id'] })
})
];
const escape = (text: string) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
export const page = (request: Request) => {
const id = escape(new URL(request.url).pathname.split('/')[2] ?? '');
return new Response(
`<!doctype html><html><head><title>Node ${id}</title></head><body><h1>Node ${id}</h1></body></html>`,
{ headers: { 'content-type': 'text/html; charset=utf-8' } }
);
};page stands in for your application. The page it returns has to match what the template
renders from the route's data, because the device renders the same route without asking you.
The version store records which tags changed. memoryStore serves a single process. Every other
deployment needs a store that all instances share, which is any object with three methods:
bump(tags), cursor() and diff(from). On Cloudflare, durableObjectStore keeps the vector in
a Durable Object.
import { memoryStore } from '@drupflare/spool/stores';
export const store = memoryStore();createSpool joins the two:
import { createSpool } from '@drupflare/spool/server';
import { routes } from './routes';
import { store } from './store';
export const spool = createSpool({ routes, store });After a write, call await spool.invalidate(['node:1']) with the tags it touched. Devices
holding a page that depends on them fetch a fresh copy before they show it again.
handle and decorate inject one script tag into each HTML page. By default the script is
served from the same origin at /.spool/<version>/client.js, which suits a strict
script-src 'self' policy. To serve it from cdn.drupflare.com instead, pass the integrity hash
that the release notes list for your version. The URL defaults to that version's file.
import { createSpool } from '@drupflare/spool/server';
import { memoryStore } from '@drupflare/spool/stores';
import { routes } from './routes';
export const spool = createSpool({
routes,
store: memoryStore(),
cdn: { integrity: 'sha384-<hash from the release notes>' }
});The CDN copy carries Cache-Control: public, max-age=31536000, immutable and the browser checks
it against the hash, so a changed file never runs. Release files are never overwritten. If the
script fails to load, the page works as it did before.
The script registers /spool-sw.js and exposes window.spool.ready, which resolves to the client
handle or to null where service workers do not exist. To bundle the runtime yourself, import
register from @drupflare/spool/client.
Each example uses the routes, page and spool from Quick Start. The
compatibility table lists what each framework hands a handler.
Bind the Durable Object that holds the version vector, then pass its namespace to
durableObjectStore.
import { createSpool } from '@drupflare/spool/server';
import { durableObjectStore } from '@drupflare/spool/stores/durable-object';
import { page, routes } from './routes';
export { SpoolVersions } from '@drupflare/spool/stores/durable-object';
export default {
fetch(request: Request, env: Cloudflare.Env) {
const spool = createSpool({ routes, store: durableObjectStore(env.SPOOL_VERSIONS) });
return spool.handle(request, page);
}
};A function takes a context with the request and a next that runs the next function or the
static asset server.
import { spool } from './spool';
export const onRequest = (context: { request: Request; next(): Promise<Response> }) =>
spool.handle(context.request, () => context.next());Hono ignores the response a middleware returns once a route has answered, so assign it to
c.res.
import { Hono } from 'hono';
import { page } from './routes';
import { spool } from './spool';
const app = new Hono();
app.use('*', async (c, next) => {
c.res = await spool.handle(c.req.raw, async () => {
await next();
return c.res;
});
});
app.get('/node/:id', (c) => page(c.req.raw));
export default app;onRequest answers the spool paths and returns nothing for the rest. The hook has to be an
async function, because Elysia does not await a promise that a plain function returns.
import { Elysia } from 'elysia';
import { page } from './routes';
import { spool } from './spool';
export const app = new Elysia()
.onRequest(async ({ request }) => {
const { pathname } = new URL(request.url);
return pathname === '/spool-sw.js' ? spool.stub() : await spool.respond(request);
})
.get('/node/:id', ({ request }) => spool.decorate(request, page(request)));A handler that returns undefined passes the request to the next one, which is what
spool.respond returns for a path that is not spool's.
import { IttyRouter } from 'itty-router';
import { page } from './routes';
import { spool } from './spool';
const router = IttyRouter();
router
.all('*', (request) => spool.respond(request))
.get('/spool-sw.js', () => spool.stub())
.get('/node/:id', (request) => spool.decorate(request, page(request)));
export default { fetch: router.fetch };Bun serves a default export that has a fetch function.
import { page } from './routes';
import { spool } from './spool';
export default {
port: 3000,
fetch: (request: Request) => spool.handle(request, page)
};Run it with deno serve.
import { page } from './routes';
import { spool } from './spool';
export default {
fetch: (request: Request) => spool.handle(request, page)
};A function takes a Request and returns a Response. config.path sends the spool paths and the
pages to it.
import type { Config, Context } from '@netlify/functions';
import { page } from './routes';
import { spool } from './spool';
export default (request: Request, context: Context) => spool.handle(request, page);
export const config: Config = { path: ['/node/*', '/spool-sw.js', '/.spool/*'] };An edge function runs in front of the site. context.next() returns the response the site would
have sent, which handle decorates.
import type { Config, Context } from '@netlify/edge-functions';
import { spool } from './spool';
export default (request: Request, context: Context) => spool.handle(request, () => context.next());
export const config: Config = { path: '/*' };A function under api/ takes a default export with fetch. Send every path to it from
vercel.json, so the function sees /.spool/* and /spool-sw.js as well as the pages. Function
instances do not share memory, so store has to be a shared VersionStore.
{ "rewrites": [{ "source": "/(.*)", "destination": "/api" }] }import { page } from './routes';
import { spool } from './spool';
export default {
fetch: (request: Request) => spool.handle(request, page)
};Next.js route handlers return Response objects, so handle wraps one directly. The spool
endpoints and the worker script go through the proxy, which returns undefined for every other
request. Next.js 16 calls the file proxy.ts and the export proxy. Earlier versions call them
middleware.ts and middleware. The proxy and the route handlers need the same shared store.
import { spool } from './spool';
export function proxy(request: Request) {
const { pathname } = new URL(request.url);
return pathname === '/spool-sw.js' ? spool.stub() : spool.respond(request);
}import { page } from './routes';
import { spool } from './spool';
export const GET = (request: Request) => spool.handle(request, page);h3 turns an event into a standard request with toWebRequest, and it sends a Response that a
handler returns. A server middleware that returns nothing passes the request on. Put the first
file in server/middleware/ and the second in server/routes/node/[id].ts. Both share one store
from ./store.
import { defineEventHandler, toWebRequest } from 'h3';
import { spool } from './spool';
export default defineEventHandler((event) => {
const request = toWebRequest(event);
return new URL(request.url).pathname === '/spool-sw.js' ? spool.stub() : spool.respond(request);
});import { defineEventHandler, toWebRequest } from 'h3';
import { page } from './routes';
import { spool } from './spool';
export default defineEventHandler((event) => spool.handle(toWebRequest(event), page));Lambda gives a function a JSON event and expects a JSON result, so spool needs an adapter. Hono's
hono/aws-lambda converts Function URL, API Gateway HTTP API and ALB events. Save the Hono app
above as hono.ts and wrap it.
import { handle } from 'hono/aws-lambda';
import app from './hono';
export const handler = handle(app);An Azure v4 HTTP function takes an HttpRequest, which follows the Fetch standard, and returns an
HttpResponseInit. Route every path to it with {*path}.
import { app, type HttpRequest, type HttpResponseInit } from '@azure/functions';
import { page } from './routes';
import { spool } from './spool';
export async function handler(request: HttpRequest): Promise<HttpResponseInit> {
const hasBody = request.method !== 'GET' && request.method !== 'HEAD';
const response = await spool.handle(
new Request(request.url, {
method: request.method,
headers: [...request.headers],
body: hasBody ? await request.arrayBuffer() : undefined
}),
page
);
return {
status: response.status,
headers: [...response.headers],
body: await response.arrayBuffer()
};
}
app.http('spool', {
route: '{*path}',
methods: ['GET', 'POST'],
authLevel: 'anonymous',
handler
});toNodeListener turns a Fetch-style handler into a node:http listener. Its toRequest and
writeResponse parts are what the frameworks below use to cross between Node and Fetch.
import { createServer } from 'node:http';
import { toNodeListener } from '@drupflare/spool/node';
import { page } from './routes';
import { spool } from './spool';
export const server = createServer(toNodeListener((request) => spool.handle(request, page)));Express responds through its own res, so it has no Fetch response to wrap. spool.respond
answers /.spool/* and returns undefined for everything else, which sends the request on to the
next Express handler. A route passes its page through spool.decorate to get the envelope and the
script tag.
import express from 'express';
import { toNodeListener, toRequest, writeResponse } from '@drupflare/spool/node';
import { page } from './routes';
import { spool } from './spool';
export const app = express();
app.use(toNodeListener((request) => spool.respond(request)));
app.get('/spool-sw.js', (req, res) => writeResponse(res, spool.stub()));
app.get('/node/:id', async (req, res) => {
const request = toRequest(req, res);
await writeResponse(res, await spool.decorate(request, page(request)));
});Fastify's reply.send takes a Fetch Response. An onRequest hook answers the spool paths from
the raw request before Fastify reads the body.
import Fastify from 'fastify';
import { toRequest } from '@drupflare/spool/node';
import { page } from './routes';
import { spool } from './spool';
export const app = Fastify();
app.addHook('onRequest', async (req, reply) => {
const response = await spool.respond(toRequest(req.raw, reply.raw));
if (response) return reply.send(response);
});
app.get('/spool-sw.js', (req, reply) => reply.send(spool.stub()));
app.get('/node/:id', async (req, reply) => {
const request = toRequest(req.raw, reply.raw);
return reply.send(await spool.decorate(request, page(request)));
});Koa exposes the Node objects as ctx.req and ctx.res. Setting ctx.respond to false stops
Koa from writing a second response.
import Koa from 'koa';
import { toRequest, writeResponse } from '@drupflare/spool/node';
import { page } from './routes';
import { spool } from './spool';
export const app = new Koa();
app.use(async (ctx, next) => {
const request = toRequest(ctx.req, ctx.res);
const response = ctx.path === '/spool-sw.js' ? spool.stub() : await spool.respond(request);
if (!response) return next();
ctx.respond = false;
await writeResponse(ctx.res, response);
});
app.use(async (ctx) => {
if (!ctx.path.startsWith('/node/')) return;
const request = toRequest(ctx.req, ctx.res);
ctx.respond = false;
await writeResponse(ctx.res, await spool.decorate(request, page(request)));
});hapi keeps the Node objects at request.raw. Returning h.abandon tells it the response is
already written.
import Hapi from '@hapi/hapi';
import { toRequest, writeResponse } from '@drupflare/spool/node';
import { page } from './routes';
import { spool } from './spool';
export const server = Hapi.server();
server.ext('onRequest', async (request, h) => {
const { req, res } = request.raw;
const spoolRequest = toRequest(req, res);
const response =
request.path === '/spool-sw.js' ? spool.stub() : await spool.respond(spoolRequest);
if (!response) return h.continue;
await writeResponse(res, response);
return h.abandon;
});
server.route({
method: 'GET',
path: '/node/{id}',
handler: async (request, h) => {
const { req, res } = request.raw;
const spoolRequest = toRequest(req, res);
await writeResponse(res, await spool.decorate(spoolRequest, page(spoolRequest)));
return h.abandon;
}
});On the Express adapter, app.use takes spool's listener and a controller method that injects
@Req() and @Res() writes the page. The example needs experimentalDecorators in
tsconfig.json.
import { Controller, Get, Module, Req, Res } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import type { Request, Response } from 'express';
import 'reflect-metadata';
import { toNodeListener, toRequest, writeResponse } from '@drupflare/spool/node';
import { page } from './routes';
import { spool } from './spool';
@Controller()
class NodeController {
@Get('node/:id')
async show(@Req() req: Request, @Res() res: Response) {
const request = toRequest(req, res);
await writeResponse(res, await spool.decorate(request, page(request)));
}
}
@Module({ controllers: [NodeController] })
class AppModule {}
export async function bootstrap() {
const app = await NestFactory.create(AppModule, { logger: false });
app.use(toNodeListener((request) => spool.respond(request)));
app.use('/spool-sw.js', (req: Request, res: Response) => writeResponse(res, spool.stub()));
return app;
}The Functions Framework gives an HTTP function Express-style req and res, so the Node listener
fits as it is.
import { http } from '@google-cloud/functions-framework';
import { toNodeListener } from '@drupflare/spool/node';
import { page } from './routes';
import { spool } from './spool';
http(
'spool',
toNodeListener((request) => spool.handle(request, page))
);The gate column says how far each row is checked. runs means the fixture above drives the framework's own package. stand-in means a fixture calls the platform's handler with a plain object where the platform would pass its context. not tested means the adapter column comes from the framework's documentation and nothing here has run it.
| Framework or host | What a handler receives | Spool entry | Gate |
|---|---|---|---|
| Express | Node req, res |
toNodeListener, respond, decorate |
runs |
| Fastify | Node req.raw, reply.raw; reply.send takes a Response |
toRequest, respond, decorate |
runs |
| Koa | Node ctx.req, ctx.res |
toRequest, writeResponse |
runs |
| hapi | Node request.raw.req, request.raw.res |
toRequest, writeResponse |
runs |
| NestJS, Express adapter | Node, through Express | app.use with toNodeListener; toRequest in a controller |
runs |
| NestJS, Fastify adapter | Node, through Fastify | the Fastify entry | not tested |
| AdonisJS | Node at ctx.request.request and ctx.response.response |
toRequest, writeResponse in a middleware |
not tested |
| Hono | Fetch | handle |
runs |
| Elysia | Fetch | respond, decorate |
runs |
| itty-router | Fetch | respond, decorate |
runs |
| Oak | Fetch, through app.handle; published on JSR only |
handle |
not tested |
| h3, Nitro, Nuxt | Fetch, through toWebRequest |
respond, handle |
runs |
| Analog | Nitro and h3 routes | as Nuxt | not tested |
| Next.js | Fetch | respond in the proxy, handle in the route |
runs |
| SvelteKit | Fetch event.request in the handle hook |
handle(event.request, () => resolve(event)) |
not tested |
| Astro | context.request in onRequest(context, next) |
handle(context.request, () => next()) |
not tested |
| React Router v7, Remix | Fetch request in handleRequest and the server build |
handle around the request handler |
not tested |
| TanStack Start | Fetch, in the server entry's fetch(request) |
handle(request, () => handler.fetch(request)) |
not tested |
| SolidStart | an event in onRequest that may return a Response |
respond in the middleware |
not tested |
| Qwik City | requestEv in onRequest; send(Response) |
respond in the middleware |
not tested |
| Cloudflare Workers | Fetch | handle |
runs, in workerd |
| Cloudflare Pages Functions | Fetch, in onRequest(context) |
handle(request, () => context.next()) |
stand-in |
| Netlify Functions | Fetch | handle |
stand-in |
| Netlify Edge Functions | Fetch; returning undefined passes on |
handle(request, () => context.next()) |
stand-in |
| Deno, Deno Deploy | Fetch, through Deno.serve or deno serve |
handle |
stand-in |
| Bun | Fetch | handle |
runs, under Bun.serve |
| Vercel Functions | Fetch | handle |
stand-in |
| AWS Lambda, Function URL or HTTP API | JSON event version 2.0 | hono/aws-lambda around a Hono app |
stand-in event |
| AWS Lambda, API Gateway REST API | JSON event version 1.0 | hono/aws-lambda around a Hono app |
not tested |
| Lambda@Edge | CloudFront event with header arrays | none; a converter to Request is needed |
not tested |
| Fastly Compute | Fetch, through event.request in a fetch listener |
event.respondWith(spool.handle(event.request, next)) |
not tested |
| Azure Functions v4 | HttpRequest, which follows Fetch; returns HttpResponseInit |
convert, then handle |
runs, with a real HttpRequest |
| Google Cloud Functions | Node req, res from the Functions Framework |
toNodeListener |
runs, on the Functions Framework test server |
| Google Cloud Run | any HTTP server on PORT |
the Node example | not tested |
A host that serves static files and runs no code, such as an object store or a plain web server, uses Static Sites.
A site with no spool server can still answer repeat views from the device. It serves three files from one directory:
spool-sw.js, the worker, from a release orhttps://cdn.drupflare.com/spool/<version>/.client.js, the page script, from the same place.spool.json, the list of files the worker may keep.
Each page loads the script with the tag from CDN Script, or from the site's own copy.
The worker has no server to talk to, so it reads spool.json from the directory its own script
sits in.
{
"schema": 1,
"routes": [
{
"pattern": "/",
"strategy": "static",
"version": "sha256-<base64 digest of the page>",
"freshness": { "maxStale": 3600, "mode": "soft" }
},
{ "pattern": "/about", "strategy": "static", "version": "sha256-<base64 digest of the page>" },
{ "pattern": "/css/site.css", "strategy": "static", "version": "build-42" },
{
"pattern": "/client.js",
"strategy": "static",
"version": "sha384-<base64 digest of the file>"
}
]
}A route names one path, or a :name pattern whose paths share one version. version says what
the file is now:
- A
sha256-,sha384-orsha512-value is checked against the bytes the worker fetched. A mismatch keeps nothing. - An entity tag such as
"abc"is checked against the response'sETag. - Any other string is a label you change when the file changes.
The first view of a path goes to the network, and the worker keeps a copy of the response as it
arrives: the bytes and the headers, including any Content-Security-Policy. Later views of that
path make no request until the route's maxStale passes, which defaults to 15 minutes. After that
a soft route shows the copy and fetches spool.json once, and a hard route fetches it first.
A file whose version changed, or whose route left the manifest, is dropped and the next view
fetches it again. Set sync to deferred to wait for a request the page makes anyway, and
offline to refuse to keep a hard route from showing a copy it cannot check.
The worker keeps files up to 4 MiB and never a redirect or an error. It sends no analytics batches
and makes no request to /.spool/*. Serve spool-sw.js with Cache-Control: max-age=86400; that
header is what lets Chromium and Firefox skip the update check. To pin the script to the CDN, make
/spool-sw.js a one-line file:
importScripts('https://cdn.drupflare.com/spool/<version>/spool-sw.js');importScripts has no integrity check, so this form trusts the CDN host with the worker.
A route is answered locally in one of five ways. A template route stores JSON and renders it
through a compiled template. An assembly route stores a shell with named holes, fragment markup
for the holes and values for named slots. A static route stores a file as the server sent it. A
data route stores JSON and answers a GET for it. A projection route stores JSON and renders a
page from it with a function your site bundles into its own worker.
import { placeholder, slot } from '@drupflare/spool/protocol';
import { defineRoute } from '@drupflare/spool/server';
export const about = defineRoute('/about', {
strategy: 'assembly',
depends: () => ['menu'],
load: () => ({
shell: `<!doctype html><html><head><title>About</title></head><body>${placeholder('menu')}<main>Updated ${slot('updated')}</main></body></html>`,
fragments: { menu: '<nav><a href="/">Home</a></nav>' },
slots: { updated: '2026-10-19' }
})
});The worker leaves a hole it cannot fill in place and refuses to render a page with a gap, so the request goes to the network instead.
A page the worker renders keeps the site's security headers. The worker remembers
Content-Security-Policy (and its report-only form), X-Frame-Options, X-Content-Type-Options,
Referrer-Policy, Permissions-Policy and the three Cross-Origin-* policies from the network
answer to each path, and sends them with every local render of it. A path with no answer of its own
uses the latest set from any navigation. A page with neither goes to the network until the worker
has seen one, so a local view never goes out without the site's policy.
Pages that read the same data share one stored copy. ctx.record(id, data) stores an
authoritative value once under its id, and ctx.projection(id, data) stores a value derived from
records. Both return a reference to put in the page's data. A record's id is its invalidation tag.
Invalidating course:101 refreshes the records and projections that read it, and no others.
import { defineRoute } from '@drupflare/spool/server';
const courses: Record<string, string> = { '101': 'Intro', '102': 'Advanced' };
export const course = defineRoute('/courses/:id', {
strategy: 'template',
template: {
source: '<h1>{{title}}</h1><nav>{{#each menu}}<a>{{this}}</a>{{/each}}</nav>'
},
load: (ctx) => {
const id = ctx.params['id'] ?? '';
return {
title: ctx.record(`course:${id}`, courses[id]),
menu: ctx.projection(
'menu',
Object.keys(courses).map((key) => ctx.record(`course:${key}`, courses[key]))
)
};
}
});The server sends only the records and projections the device does not already hold at a current version. A sync for the next page transfers the entries that page reads and that changed.
Templates use Mustache-shaped syntax with no expressions, so the worker needs no eval.
| Syntax | Meaning |
|---|---|
{{field}} |
the field, HTML-escaped; a.b reaches into objects |
{{{field}}} |
the field as markup; allowed only for names in trusted |
{{#each list}}...{{/each}} |
repeats for each item; {{this}} and {{@index}} work |
{{#if field}}...{{else}}...{{/if}} |
renders a branch on whether the field is truthy |
{{! note }} |
a comment that renders nothing |
import { defineRoute } from '@drupflare/spool/server';
export const news = defineRoute('/news/:slug', {
strategy: 'template',
template: {
source:
'<article><h1>{{title}}</h1>{{#if byline}}<p>{{byline}}</p>{{else}}<p>Staff</p>{{/if}}<div>{{{body}}}</div><ul>{{#each tags}}<li>{{@index}} {{this}}</li>{{/each}}</ul></article>',
trusted: ['body']
},
depends: (ctx) => [`news:${ctx.params['slug']}`],
load: () => ({
title: 'Hello',
byline: null,
body: '<p>Trusted markup</p>',
tags: ['a', 'b']
})
});A template that names a field it does not trust for markup fails when the route is declared.
A static route has no load and no template. The application serves the file as it always did,
and handle or decorate marks the response for the worker, which keeps the bytes. A tag in
depends ties the file to a deploy, and spool.invalidate after one makes devices fetch the
file again. A static route is always public and is always answered from the device, so it takes no
scope, persist or mode. A query string makes a different file: /app.js?v=2 is not
answered with the copy of /app.js, and a query the worker has not stored goes to the network.
import { defineRoute } from '@drupflare/spool/server';
export const about = defineRoute('/about', {
strategy: 'static',
depends: () => ['site'],
freshness: { maxStale: 3600, mode: 'soft' }
});A data route answers a GET for JSON, such as an API read, from the stored value, so the page's
fetch makes no request. The worker answers with JSON.stringify of what load returned, which
means the application's own handler has to return the same JSON. A navigation to the URL still goes
to the network.
A projection route needs a renderer. Spool ships no bundler: write a worker with serve from
@drupflare/spool/sw, bundle it with your own toolchain, and pass the bundle to createSpool as
worker. The server puts the config in front of your bundle as self.__SPOOL_CONFIG__, and
spool.config is the same object as JSON if you would rather inline it at build time. A route with
no renderer is refused by the worker and the network answers.
import { defineRoute } from '@drupflare/spool/server';
export const course = defineRoute('/api/courses/:id', {
strategy: 'data',
depends: (ctx) => [`course:${ctx.params['id']}`],
load: (ctx) => ({ id: ctx.params['id'], title: 'Intro' }),
provision: { reuse: 0.8, futureViews: 6 }
});
export const roster = defineRoute('/rosters/:id', {
strategy: 'projection',
depends: (ctx) => [`roster:${ctx.params['id']}`],
load: (ctx) => ({ id: ctx.params['id'], names: ['Ada', 'Bo'] })
});import { escapeHtml } from '@drupflare/spool/protocol';
import { serve } from '@drupflare/spool/sw';
declare const CONFIG: string;
serve(JSON.parse(CONFIG), {
'/rosters/:id': (artifact) => {
const { names } = (artifact.body as { data: { names: string[] } }).data;
return `<!doctype html><html><head><title>Roster</title></head><body><ul>${names.map((name) => `<li>${escapeHtml(name)}</li>`).join('')}</ul></body></html>`;
}
});The server also decides whether to send an artifact at all. It weighs the bytes it would send,
records included, against what a local view saves: reuse times future views times the bytes a view
avoids, less the cost of the request. When that is not positive it answers 204 with
Spool-Provision: declined, and the worker leaves that path to the network until it restarts.
The priors are scenario assumptions: set them from your own telemetry in provision, or per route
as above. A data route weighs against its own size, so it needs reuse times future views above
- With the defaults of 0.5 and 2 it breaks even and is left out.
Each route declares how long a stored page may go without validation. maxStale is in seconds.
- hard: once
maxStalepasses, the worker validates the page with the server before it shows it. Offline, the route'sofflinerule decides. - soft: the worker shows the stored page at once and syncs afterward. A soft bound is a stale-while-revalidate window and promises nothing about how fresh the shown page is.
Public routes default to 15 minutes soft, and routes with scope: 'principal' to five minutes
hard; Authenticated Data covers how their pages are stored. offline
defaults to serve for public routes and refuse for principal routes. A soft route may set sync: 'deferred' to queue its refresh
for the next network request that happens anyway.
import { defineRoute } from '@drupflare/spool/server';
export const stock = defineRoute('/stock/:sku', {
strategy: 'template',
template: { source: '<p>{{sku}}: {{count}}</p>' },
depends: (ctx) => [`stock:${ctx.params['sku']}`],
load: (ctx) => ({ sku: ctx.params['sku'], count: 3 }),
freshness: { maxStale: 60, mode: 'hard' },
offline: 'refuse'
});
export const notice = defineRoute('/notices/:id', {
strategy: 'template',
template: { source: '<p>{{text}}</p>' },
depends: (ctx) => [`notice:${ctx.params['id']}`],
load: () => ({ text: 'Closed on Friday' }),
freshness: { maxStale: 3600, mode: 'soft' },
sync: 'deferred'
});The worker syncs the smallest set of changed dependencies the next page needs. One worker leads the sync for every tab of an origin. A response to a request that happens anyway can carry invalidations, so a sync does not always need a request of its own.
Set scope: 'principal' on a route that depends on who is signed in, and give createSpool an
identity option. The server sends Spool-Partition, an HMAC of the principal under your secret,
so the browser can tell users apart without learning who they are. The worker keeps each partition
in its own IndexedDB database with the artifacts, records, projections and sync state sealed with
AES-GCM. Each value is bound to its partition, kind and id, so a row copied into another slot does
not decrypt.
retention picks the key. persistent is a non-extractable key in IndexedDB that survives a
restart and is deleted on logout or a change of partition. session, the default, is a key held in
the worker's memory, which the browser discards when it stops the worker. Deleting a key makes what
it sealed unreadable, which is crypto-shredding of data at rest. It does not protect against script
running on your origin, which can ask the worker for whatever the page can. The ids and versions
that serve as storage keys stay readable, so use opaque ids for anything sensitive.
A principal route defaults to a five-minute hard bound and offline: 'refuse'. Revocation rides
the same bound: tag a page with the permission it needs, call spool.invalidate when the
permission goes, and the first required sync past the bound finds the page invalid.
A different Spool-Partition wipes the partition before it. A login or account-switch handler has
to send the new value itself with spool.partition(principal), because the request it answers
still carries the old identity. A sync answer carries Spool-Partition for the request's identity
too, so a sign-in the worker never saw ends at the first required sync, no later than the route's
maxStale plus the next view. Paths in logout are wiped when the request reaches the worker,
before it is sent, so logout empties every tab even with the network down. The worker first sends
the same-origin posts it holds in beacons, while they still carry the session that is ending,
and drops any that do not get through. The page runtime
dispatches a spool:cleared event on window in every tab when that happens, so a page can blank
what it shows. A response can also send
Spool-Clear: * wipes everything, a partition id wipes one. decorate(request, response, { clear: ['*'], hardReset: true }) adds Clear-Site-Data: "storage" as well, the browser's own reset
of everything the origin holds, service worker included. Its Safari support is unverified, so rely
on the worker's wipe.
import { createSpool, defineRoute } from '@drupflare/spool/server';
import { memoryStore } from '@drupflare/spool/stores';
export const grades = defineRoute('/grades', {
strategy: 'template',
scope: 'principal',
retention: 'persistent',
template: { source: '<h1>Grades for {{owner}}</h1>' },
depends: (ctx) => [`perm:${ctx.identity?.principal}`, `grades:${ctx.identity?.principal}`],
load: (ctx) => ({ owner: ctx.identity?.principal })
});
export const spool = createSpool({
routes: [grades],
store: memoryStore(),
identity: {
secret: 'a long random value kept on the server',
resolve: (request) => {
const user = /user=(\w+)/.exec(request.headers.get('cookie') ?? '')?.[1];
return user ? { principal: user } : null;
}
},
logout: ['/logout']
});
export async function login(user: string): Promise<Response> {
return new Response('Signed in', {
headers: {
'set-cookie': `user=${user}; Path=/`,
'spool-partition': await spool.partition(user)
}
});
}A sync that asks for everything changed lists the tags of every change, so tags for principal data
should be opaque too. The worker syncs a principal page with need, which names only the tags
that page reads.
A catalogue sends the data of pages a visitor may open next, so that opening one makes no
request. An entry costs its bytes whether or not the page is opened, and an opened one saves a
document request, so an entry pays only when enough visitors open it. The break-even share is
k * E / (k * D + o), where E is the entry's compressed bytes, D the bytes of the document
it replaces, k the joules to move a byte and o the origin request a view avoids. An entry the
size of the document needs 96% of visitors to open it, which no page gets. An entry that is only
the data for a template the device already holds is a few hundred bytes to a few kilobytes and
pays at a few percent. On the payload reading, the rendered fields of a node were a median 473
bytes compressed on 19 pages from two sites (L1, measurements/payload), against a document of
about 17,000 bytes. References such as media and terms are left out of that figure, so a real entry
is larger. An entry is data for a template, projection or data route and never HTML.
The site supplies the candidates. A route's catalogue returns the pages a visitor on it may open
next, each with the share expected to. Take the share from your own nextShare over the sink
(each spool.view names the route the visitor came from), from link position, or from a fixed
prior. documentBytes on the entry's route says what a hit saves, and the provisioning prior is
used when it is absent.
import { createSpool, defineRoute } from '@drupflare/spool/server';
import { memoryStore } from '@drupflare/spool/stores';
export const home = defineRoute('/', {
strategy: 'template',
template: { source: '<h1>Courses</h1>' },
load: () => ({}),
catalogue: () => [
{ path: '/courses/101', p: 0.3 },
{ path: '/courses/102', p: 0.1 }
]
});
export const course = defineRoute('/courses/:id', {
strategy: 'template',
template: { source: '<h1>{{title}}</h1>' },
depends: (ctx) => [`course:${ctx.params['id']}`],
load: (ctx) => ({ title: `Course ${ctx.params['id']}` }),
documentBytes: 17_000
});
export const spool = createSpool({
routes: [home, course],
store: memoryStore(),
catalogueBytes: 16_384
});The server sends an entry only when its share reaches its own break-even, best expected net first,
until catalogueBytes is spent. The cap counts compressed bytes and defaults to 32 KiB. It builds
at most 32 pages for one request, measures each entry with Brotli where the runtime has it and
gzip where it does not, and skips a static or assembly route. A request with Save-Data: on
gets no entries.
The ask rides the beacon batch the worker already sends. A due ask also makes the next network
moment send that batch, when a view is held to carry it, so a visit of two or three pages gets its
entries without waiting for 20 views or an hour. That costs one POST /.spool/beacon for each
catalogue page, device and day, sent at a request that happens anyway. A local view never sends
it, and a moment with nothing held sends nothing. The worker stores the entries like provisioned
pages, and the first view of an unopened one is reported with the reason catalogue. catalogueFigures reads the views and the worker's ledger for the entries sent
and used, the hit share, and the bytes sent against the bytes saved. Until shadow views measure
the shares, the saving is a scenario.
A hit saves the document request and nothing else. On the Drupal pages measured (75 first views of
internal pages on 10 sites, L1, measurements/trim), a view of a page still fetched that page's
new images and stylesheet and script aggregates: only 3% of those first views moved nothing but
the document. A catalogue view takes the document off the wire, and the rest of the page is
the site's own HTTP cache and asset trimming.
A principal route can be an entry. The worker then names every catalogue entry no page has
opened at each sync of a principal page, so a revoked one is dropped at the first required sync.
The request that asks for a catalogue names the path of the page the visitor is on, including one
the worker showed locally, so a route that should not tell the server leaves catalogue off.
docs/security.md covers both.
A page that keeps analytics scripts posting to an endpoint makes a request on every view, which
defeats a local view. List the same-origin POST paths in beacons. The worker answers each post
with a 204 and queues it in IndexedDB. At the next network request that happens anyway it sends
the post to the URL the page meant, with its method, content type, query and body, so the vendor
receives what the page sent. Spool's own events go to /.spool/beacon in one batch, where a
sink receives them. A held post goes out on its own at most once every 24 hours, when the oldest
is a day old. A body over 64 KiB, counted in bytes, or a binary body goes to the network at once.
Firefox does not route fetch calls made with keepalive: true through the worker, so a script
that posts that way is not queued there.
import { createSpool, jsonLinesSink } from '@drupflare/spool/server';
import { memoryStore } from '@drupflare/spool/stores';
import { routes } from './routes';
export const spool = createSpool({
routes,
store: memoryStore(),
beacons: ['/cdn-cgi/rum'],
sink: jsonLinesSink((line) => console.log(line))
});jsonLinesSink writes one JSON line per event. analyticsEngineSink writes to a Workers
Analytics Engine dataset, splitting a long body across data points so nothing is cut off, and
memorySink keeps events for tests. The worker also queues one
spool.view event per page view with the tier, the reason, the staleness and the route the
visitor came from (a pattern or external, never a URL). viewsOf reads
those events back. Set views: false to turn that off. workSeries records how long each route's
handler took, for peak-work reports.
A collectors entry holds requests to another origin, such as an analytics host. The page gets an
empty answer at once and the request leaves at a network moment, inside maxAgeMs. An event past
the cap is dropped, never sent late, and each drop is counted and written to the sink as
/.spool/dropped. With delivery: 'replay' the worker sends the held request to the vendor with no
cookies, so identity has to be in the payload, and stamp writes the event's time into a query
parameter for a vendor that takes one. With delivery: 'sink' the server half forwards it.
GA4's browser collector takes no backdating. The Measurement Protocol accepts events with their
own timestamps up to 72 hours old and wants them within 48 hours to attribute them, and it needs an
API secret the page must not hold. GA4_COLLECTOR holds the g/collect hit for 48 hours and
ga4Sink sends it as a Measurement Protocol request carrying the original timestamp_micros.
ga4Sink checks its requests against the rules spool knows (mpViolations); set debug: true to
send them to /debug/mp/collect with your own measurement id and secret. Spool holds events
whether or not the visitor consented, so gate the analytics tag itself, and ask your counsel
whether holding an event in IndexedDB needs consent where you operate. Programmatic ads are never
held, replayed or answered locally.
import { GA4_COLLECTOR } from '@drupflare/spool/protocol';
import { combineSinks, createSpool, ga4Sink, jsonLinesSink } from '@drupflare/spool/server';
import { memoryStore } from '@drupflare/spool/stores';
import { routes } from './routes';
export const spool = createSpool({
routes,
store: memoryStore(),
collectors: [
GA4_COLLECTOR,
{
vendor: 'matomo',
match: 'https://stats.example.edu/matomo.php',
maxAgeMs: 23 * 3_600_000,
delivery: 'replay',
stamp: { param: 'cdt', kind: 'datetime-utc' }
}
],
sink: combineSinks(
jsonLinesSink((line) => console.log(line)),
ga4Sink({ measurementId: 'G-XXXXXXXXXX', apiSecret: 'kept on the server' })
)
});A view that loads a file from another origin is not zero-network. The page reports, when it is
hidden, how many cross-origin resources it loaded and from which origins (origins only, never
paths). thirdPartyReport reads those reports from viewsOf and ranks the origins by the views
each one alone blocks, with a class and a remedy from a short table you can replace. The counts
are an upper bound on network requests, since a cross-origin timing entry says nothing about cache
hits without Timing-Allow-Origin.
The opt-in thirdParty cache removes the request for files that name their own bytes. List the
origins in allow. The worker learns a file from the first view and keeps it only when the URL
carries a hash, a version or a /v12/ segment, or the origin marks it immutable. A file versioned
only by a query parameter is checked again at a network moment once it is a day old. It keeps
nothing else, and nothing it cannot read: an opaque response counts about 7 MB against quota in
Chromium, so allowOpaque is off by default. WebKit does not send font requests from a stylesheet
through the worker, so fonts stay on the network there.
import { thirdPartyReport } from '@drupflare/spool/report';
import { createSpool, memorySink, viewsOf } from '@drupflare/spool/server';
import { memoryStore } from '@drupflare/spool/stores';
import { routes } from './routes';
const sink = memorySink();
export const spool = createSpool({
routes,
store: memoryStore(),
sink,
thirdParty: {
allow: ['https://fonts.gstatic.com', 'https://cdn.example.edu'],
maxEntries: 32
}
});
export const report = () => thirdPartyReport(viewsOf(sink.events));By default the worker answers an immutable same-origin asset from CacheStorage. assets changes
that. passthrough leaves assets to the browser's cache and never touches them. static-routing
asks the browser through InstallEvent.addRoutes to send only navigations, beacons and routed
paths to the worker and the rest of the origin to the network, and acts as passthrough where the
API does not exist: Chromium has it, Playwright's WebKit has it on macOS and not on Linux, and
Firefox does not. Both options cut the service worker's fetch events (Static Routing took them from
32 to 3 per view on a desktop Chromium rig); neither changes server-side Worker invocations.
Neither is measured to save energy. In the same run, new-tab views drew 2.78 J,
2.83 J and 2.84 J of RAPL package energy above idle with CacheStorage, navigation-only handling
and Static Routing (8 rounds each), and the ranges overlap. Under Static Routing a same-origin write no
longer reaches the worker, so it is not a network moment and its response cannot carry
invalidations; navigations still are.
import { createSpool } from '@drupflare/spool/server';
import { memoryStore } from '@drupflare/spool/stores';
import { routes } from './routes';
export const spool = createSpool({
routes,
store: memoryStore(),
assets: 'static-routing'
});In shadow mode the worker decides whether it could serve a page locally, renders it, still fetches
the page from the server, compares the two with normalise and reports the result. It never
serves the local page. Use it to measure eligibility and mismatches on real traffic before turning
serving on. A static route has no shadow comparison.
import { createSpool } from '@drupflare/spool/server';
import { memoryStore } from '@drupflare/spool/stores';
import { routes } from './routes';
export const shadowed = createSpool({ routes, store: memoryStore(), mode: 'shadow' });
export const canary = createSpool({ routes, store: memoryStore(), shadowSample: 0.05 });mode: 'shadow' applies to every route that does not name its own mode. shadowSample keeps a
share of views in shadow while the rest are served, which gives a standing canary. A mismatch
names its route, so a route whose template and application disagree shows up before a visitor
sees it. The worker records the shadow comparison for navigations only. Subresources and other
requests keep the browser's own path.
spoolProxy fronts an origin that cannot load the library, such as PHP, Drupal with no module,
or anything else that speaks HTTP. It returns a Fetch handler. The proxy answers /.spool/* and
/spool-sw.js. Every other request goes to the origin with its method, headers and body, and a
redirect is passed on, not followed. A page the proxy may keep is decorated like a createSpool
response, so the worker stores it and answers later views with no request.
spool takes the options createSpool takes, minus routes. The proxy adds one public
assembly route that matches every path (/**) and uses the origin's finished HTML as the
artifact. routes lists overrides, such as a shorter freshness bound for one pattern. They
match first and take the same fields as defineRoute, without load.
On Node the handler goes through toNodeListener. origin is the address of the real server
and has no path.
import { createServer } from 'node:http';
import { toNodeListener } from '@drupflare/spool/node';
import { spoolProxy } from '@drupflare/spool/proxy';
import { memoryStore } from '@drupflare/spool/stores';
const proxy = spoolProxy({
origin: process.env['ORIGIN'] as string,
spool: { store: memoryStore() },
purgeSecret: process.env['PURGE_SECRET']
});
export const server = createServer(toNodeListener(proxy));On Workers the proxy is built once per isolate and keeps the version vector in a Durable Object,
bound as in Cloudflare Workers. Set the purge secret with
wrangler secret put PURGE_SECRET.
import { spoolProxy, type SpoolProxy } from '@drupflare/spool/proxy';
import { durableObjectStore } from '@drupflare/spool/stores/durable-object';
export { SpoolVersions } from '@drupflare/spool/stores/durable-object';
let proxy: SpoolProxy | undefined;
export default {
fetch(request: Request, env: Cloudflare.Env) {
proxy ??= spoolProxy({
origin: 'https://origin.example.edu',
spool: { store: durableObjectStore(env.SPOOL_VERSIONS) },
purgeSecret: env.PURGE_SECRET
});
return proxy(request);
}
};The tags the origin sends become the page's dependencies. The proxy reads Cache-Tag (comma
separated), Surrogate-Key and X-Drupal-Cache-Tags (space separated) and xkey (spaces or
commas), then removes them from the response unless exposeTags is true. A page with no tags
depends on one tag derived from its path.
The proxy keeps a response only when all of these hold:
- the request is a GET or HEAD, has no query string and no
Authorizationheader, and carries no cookie outsidepublicCookies, which is empty by default - the response is a 200
text/html, or the answer of adataroute - the page is UTF-8, or declares no charset in its
Content-Typeor in a<meta>within its first 1024 bytes - the response sets no cookie outside
publicCookies, has noprivateorno-storeinCache-Control, aVarythat names onlyAccept-EncodingandCookie, no more thanmaxTagstags and no more thanmaxBytesbytes
Everything else is forwarded untouched. A page the proxy declines tells the worker to drop its
copy of that path, and a response that sets a cookie outside publicCookies tells the worker to
clear everything it holds, so a login leaves no anonymous copy to show. Put cookies that carry no
identity on the list, such as a bot-check or load-balancer cookie set on every response; otherwise
proxy mode never keeps a page on that site. Writes always go to the origin and are never
answered locally.
Principal routes are off unless spool.identity is set. With it, a route declared
scope: 'principal' follows the partitions in Authenticated Data. The
cookie, Authorization and private rules do not apply to it. Set-Cookie and no-store
still do.
Two signals reach the proxy. When a page it forwards differs from the last body it saw, it bumps
that page's tags. The proxy hashes the body after normalise, and ignore takes patterns to
blank, such as a per-render form token. POST /.spool/purge bumps the tags it is given, so a CMS
hook or Drupal's purge module can call it. It needs Authorization: Bearer <purgeSecret> and a
JSON body, and it refuses every call when no secret is set.
curl -X POST https://example.edu/.spool/purge \
-H "Authorization: Bearer $PURGE_SECRET" \
-H "Content-Type: application/json" \
-d '{"tags": ["node:1", "config:system.menu.main"]}'The proxy sees finished HTML, not the database behind it. An artifact is a whole page, the
replica holds no records, and two pages never share a fragment. A page stays on a device until its
route's maxStale passes and the next sync reports a change, so freshness is bounded by
maxStale plus the time a purge takes to arrive.
A tag shared by many pages invalidates all of them when one changes. The remembered bodies live
in the instance: a restart forgets them, and each instance of a scaled-out proxy keeps its own.
A whole page is a large artifact, and the provisioning rule declines one that costs more than the
priors say it saves, so set provision on the spool for large pages. A Content-Security-Policy
that allows only nonce or hash scripts blocks the injected client tag. A URL with a query string
always goes to the origin. Trimmed Stylesheets covers the trim option.
A new page of a Drupal site usually loads a stylesheet aggregate the visitor has not seen, and
most of its rules match nothing on the page. @drupflare/spool/trim builds a manifest that removes
the rules no page of the site can match, and spoolProxy serves the trimmed text in place of the
origin's file. Each file is trimmed against every page and script of the site, never against one
page, so it keeps its URL and its one cached copy. Rules are only removed, so the cascade of what
stays is unchanged.
import { spoolProxy } from '@drupflare/spool/proxy';
import { memoryStore } from '@drupflare/spool/stores';
import { buildTrimManifest } from '@drupflare/spool/trim';
export const manifest = await buildTrimManifest({
pages: ['<body class="hero"><a class="button">Apply</a></body>'],
scripts: [{ url: '/js/site.js', text: 'menu.classList.add("open")' }],
styles: [{ url: '/css/site.css', text: '.hero{a:b}.button{c:d}.open{e:f}.print-only{g:h}' }]
});
export const proxy = spoolProxy({
origin: 'https://origin.example.edu',
spool: { store: memoryStore() },
trim: manifest
});pages is HTML, scripts and styles are the same-origin files the pages load, and url is the
address the page links. Crawl with a browser and pass the page's DOM after load and after a
scroll as well as the origin's HTML: a class that a script builds from parts, such as
'fs-carousel' + '-enabled', is only in the rendered page. Pass the scripts of other origins that
the pages load as foreignScripts, since a widget that builds markup adds classes too.
Two checks protect a visitor. The proxy serves a trimmed file only when the origin's bytes still
hash to the manifest's source, and otherwise sends the origin's file and counts a mismatch. It
also reads every HTML page before it leaves: a page with a token the trim removed a rule for, or a
same-origin script the manifest was not built with, links the full file through spool-trim=0.
proxy.trim counts the files served, the mismatches and the guard firings, and the sink gets
each mismatch and firing as an event. docs/security.md names
what the guard cannot see: a class a vendor script adds after the crawl, markup injected after load
from a page the crawl never requested, and a class name built at runtime that no crawl rendered.
The proxy trims CSS and leaves JavaScript alone. Coverage at load cannot see what a click or a scroll will need: the used share of same-origin JavaScript rose from 36% at load to 40% after one scroll on the pages measured (L1), so no load-time figure says which of the rest is dead. The same saving from a build step that runs PurgeCSS with a safelist needs no proxy. The proxy gets it without a change to the origin. The saving applies to a network view of a page the visitor has not seen, never to a repeat or local view, and it is not part of the per-view headline; docs/impact.md has the bytes, joules and prices.
- spool never authorises on the client. The device holds only what the server already sent that principal.
- It never shows a hard route past its bound without validating it first.
- It never writes. Your application owns every write, and
invalidateonly announces one. - It never infers dependencies from opaque HTML. A route names its tags, or the records it reads, and in proxy mode the origin names them in its cache tag headers.
- It never replaces a published release file on the CDN.
| Engine | Controlled navigation |
|---|---|
| Chromium | no request, new tabs included |
| Firefox | no request, new tabs included |
| WebKit | one request to re-check /spool-sw.js, and one refetch of each cacheable subresource on the first controlled load |
The worker registers with updateViaCache: 'all' and the script is served at
max-age=86400, which is what lets Chromium and Firefox skip the update check. A deploy changes
the script bytes and reaches a browser within that day. A browser without service workers gets the
page it always got.
A page that loads a resource from another origin is not a zero-request view. spool counts those loads per view and reports the view as such.
| Import | What is in it |
|---|---|
@drupflare/spool |
the protocol and report modules |
@drupflare/spool/protocol |
envelopes, artifacts, patterns, templates, the manifest, view events |
@drupflare/spool/server |
createSpool, defineRoute, sinks, identity and work series |
@drupflare/spool/node |
toNodeListener, toRequest, writeResponse |
@drupflare/spool/proxy |
spoolProxy, the tag readers and the eligibility verdict |
@drupflare/spool/stores |
memoryStore and the VersionStore type |
@drupflare/spool/stores/durable-object |
durableObjectStore and the SpoolVersions class |
@drupflare/spool/sw |
serve, for a site that bundles its own worker |
@drupflare/spool/client |
register and the page handle |
@drupflare/spool/report |
the energy model |
@drupflare/spool/trim |
buildTrimManifest, trimCss and guardPage |
bun run typecheck
bun run test
bun run test:coverage
bun run test:browser
bun run prettier:check
bun run docs:build
bun run buildtest runs the unit project in workerd and the node project, which includes a test that packs the
package and imports every subpath in plain Node. test:browser runs Chromium, Firefox and WebKit
against real servers and counts the requests that reach them. bunx playwright install chromium firefox webkit fetches the browsers.
| Repository | What it is |
|---|---|
drupflare/worker |
Drupal on Cloudflare Workers |
drupflare/cartridge |
runs a blocking wasm interpreter inside a Durable Object |
drupflare/durabledb |
the limits of Durable Object SQLite, encoded as a codec |
drupflare/burrow |
executes WebAssembly on Workers |
MIT (c) Gregory Mitchell 2026. See LICENSE.
{ "durable_objects": { "bindings": [{ "name": "SPOOL_VERSIONS", "class_name": "SpoolVersions" }] }, "migrations": [{ "tag": "v1", "new_sqlite_classes": ["SpoolVersions"] }] }