Skip to content

worker

import { composeWorker, publicRoute, withEdgeCache, withHealing } from "louise-toolkit/worker";

The Worker entrypoint helpers. Every Louise site’s worker.ts has the same shape: try a few Louise-owned routes, fall through to the framework’s SSR handler, optionally wire queue / scheduled. composeWorker builds that ExportedHandler; withEdgeCache and withHealing wrap the fallback and the routes. No peers.

function composeWorker<Env>(options: ComposeWorkerOptions<Env>): ExportedHandler<Env>;
interface ComposeWorkerOptions<Env> {
routes?: WorkerRoute<Env>[]; // first to return a Response wins
fetch: ExportedHandler<Env>["fetch"]; // SSR fallback when no route matches
queue?: ExportedHandler<Env>["queue"];
scheduled?: ExportedHandler<Env>["scheduled"];
gate?: { resolveEditor: ResolveEditor<Env>; prefix?: string }; // see below
}

Composes an ExportedHandler from ordered WorkerRoutes over an SSR fallback. On fetch, each route runs in order and the first Response short-circuits; if none match, the fetch fallback handles it. A WorkerRoute returns a Response to handle the request, or undefined to pass it to the next route.

export default composeWorker<Env>({
routes: [louiseApiRoute, ogImageRoute],
fetch: ssrHandler, // for example, @astrojs/cloudflare's handle
queue: (batch, env) => processBatch(batch, (m) => handle(m, env)),
});

The editor API gate: gate and publicRoute(route)

Section titled “The editor API gate: gate and publicRoute(route)”

Every editor route checks for an editor session itself. gate makes the editor API safe even when one doesn’t: pass it, and every request under /api/louise must come from a signed-in editor unless it’s headed for a route marked public.

export default composeWorker<Env>({
routes: [...editorRoutes, formRoute({ form: contact }), vitalsRoute({ dataset })],
fetch: ssrHandler,
gate: { resolveEditor }, // the same function you pass the editor routes
});

What the gate does:

  • Denies by default. An anonymous request under the prefix gets 401, whether or not the route it would reach checks for itself. That includes paths no route matches, so probing for which paths exist reveals nothing. It also covers your own framework routes under /api/louise, which the fetch fallback serves after the gate.
  • Checks the origin of every write, and of WebSocket upgrades. An upgrade is a GET, but a cross-site page can open a socket that carries the editor’s session cookie, so the gate checks its Origin like a write’s.
  • Adds headers to route responses. Routes run before your framework’s middleware, so their responses never get its security headers. With gate, every route response gets X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and the rest of louiseSecurityHeaders, plus Cache-Control: no-store on gated responses. A header the route set itself wins, and a 101 WebSocket response is passed through untouched.

formRoute, vitalsRoute, and statusRoute are already public: a visitor submits a form, every browser sends vitals, and an outside probe reads the status. They still do their own checks: origin, validation, rate limits, and a status body that carries no error text. Mark any other route an anonymous caller must reach with publicRoute. Without it, the gate returns 401:

const webhook = publicRoute(async (request, env) => {
if (new URL(request.url).pathname !== "/api/louise/hooks/shop") return undefined;
// verify the signature before trusting anything in the body
});

The gate doesn’t replace the per-route checks. It decides whether a request may enter the API; the route still decides what this editor may do, and routes you mount without composeWorker (through runEditorRoute) are still protected. Both ask resolveEditor, and the answer is cached for the request, so the session is looked up once.

louiseApiGate(request, env, config) is the same check as a standalone function. It returns null to let the request through, or the 401 or 403 Response to send instead. prefix changes the protected path from /api/louise. Without gate, composeWorker behaves as before. The reasoning is in ADR 0012.

function withEdgeCache<Env>(
handler: (request, env, ctx) => Response | Promise<Response>,
config?: EdgeCacheConfig,
): (request, env, ctx) => Promise<Response>;
interface EdgeCacheConfig {
bypass?: (request: Request) => boolean; // skip cache, always run fresh
cache?: () => Cache; // defaults to caches.default; injectable for tests
signalHeader?: string; // defaults to cloudflare-cdn-cache-control
}

A cookie-aware edge cache for the SSR fallback. It caches public GETs in the Worker-controlled Cache API (caches.default), keyed by URL, and stores a response only when it carries a cacheable directive in the signal header. Bypassed requests (for example, an authenticated editor) and non-GETs always run the handler. Drop-in for composeWorker’s fetch.

Which header carries that decision is your host’s convention, not this layer’s, so signalHeader is configurable. It defaults to CDN_CACHE_CONTROL (cloudflare-cdn-cache-control), which is what a Cloudflare-targeting SSR adapter emits for a cacheable response—so most sites never set it. A host that signals some other way sets its own name and is not obliged to adopt Cloudflare’s.

The signal header is always stripped from the response, whichever one you use, for the reason in the caution below.

export default composeWorker<Env>({
fetch: withEdgeCache(ssrHandler, { bypass: (req) => hasEditorSession(req) }),
});
  • CDN_CACHE_CONTROL—the response header consumed as the “cache me” signal.
  • isCacheableDirective(directive)—is a Cache-Control value an opt-in (public/unspecified with a positive max-age, not no-store/no-cache/private)?

kvCached(kv, key, load, { ttlSeconds, cacheMisses? }) · kvBust(kv, key)

Section titled “kvCached(kv, key, load, { ttlSeconds, cacheMisses? }) · kvBust(kv, key)”

A read-through KV cache for one value looked up on every request, such as a tenant by hostname, a settings row, or a flag. withEdgeCache caches whole responses; this caches the lookup behind them.

const tenant = await kvCached(env.KV, `tenant:${label}`, () => findTenant(db, label), {
ttlSeconds: 300,
});
// after the merchant is edited:
await kvBust(env.KV, `tenant:${label}`);
  • Misses are cached too by default. A lookup keyed by something a visitor controls, like a hostname, otherwise costs one database read per garbage request. Pass cacheMisses: false to opt out.
  • It fails open. A KV error is ignored and load runs, so a cache outage costs speed, not correctness. kv may be undefined, for example when it isn’t bound in dev.
  • ttlSeconds is required and must be at least 60, KV’s minimum. Values are stored as JSON.
function withHealing<Env>(route: WorkerRoute<Env>, options: HealingOptions<Env>): WorkerRoute<Env>;
interface HealingOptions<Env> {
rules: Record<string, HealingRule<Env>>; // keyed by LouiseError.code
fallbackRule?: HealingRule<Env>; // for codes with no explicit rule
sleep?: (ms: number) => Promise<void>; // injectable for tests
}

Wraps a route so thrown LouiseErrors are healed by policy instead of surfacing as a 500. A rule (selected by error.code) composes three deterministic strategies: retry (re-run, optional exponential backoffMs), fallback (serve a degraded/stale Response), and escalate (hand the failure off out-of-band via ctx.waitUntil, so recovery never blocks the response). Non-LouiseErrors, and codes with no matching rule, re-throw.

const healed = withHealing(apiRoute, {
rules: {
DB_ERROR: {
retries: 2,
backoffMs: 50,
fallback: ({ request }) => serveStale(request),
escalate: ({ env, ...c }) => enqueue(env.HEAL_QUEUE, describeFailure(c)),
},
},
});

WorkerRoute, ComposeWorkerOptions, EdgeCacheConfig, HealingRule, HealingContext, HealingOptions, FailureReport.