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.
composeWorker(options)
Section titled “composeWorker(options)”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 thefetchfallback 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 itsOriginlike 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 getsX-Content-Type-Options,X-Frame-Options,Referrer-Policy, and the rest oflouiseSecurityHeaders, plusCache-Control: no-storeon gated responses. A header the route set itself wins, and a101WebSocket 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.
withEdgeCache(handler, config)
Section titled “withEdgeCache(handler, config)”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) }),});Helpers
Section titled “Helpers”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 positivemax-age, notno-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: falseto opt out. - It fails open. A KV error is ignored and
loadruns, so a cache outage costs speed, not correctness.kvmay beundefined, for example when it isn’t bound in dev. ttlSecondsis required and must be at least 60, KV’s minimum. Values are stored as JSON.
withHealing(route, options)
Section titled “withHealing(route, options)”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.