-
Notifications
You must be signed in to change notification settings - Fork 0
Composition
Fetch-handler runtimes come in two flavors:
-
Terminal — one handler per runtime (Cloudflare Workers
export default {fetch},Bun.serve({fetch}),Deno.serve(handler)). The handler owns every URL. - Composed — a host router (Hono, itty-router, a handwritten dispatcher) receives every request and delegates to one of many handlers.
The adapter supports both through two options: mountPath (a string prefix to strip) and onMiss (a hook for unknown routes). This page walks the decision tree.
Simplest case. No mountPath needed if the adapter serves /, or pass mountPath to serve under a single prefix.
// /earth, /mars, /-by-names — all at the root.
const handler = createFetchAdapter(planets);
Deno.serve(handler);
// /planets/earth, /planets/mars — one adapter, but mounted below /planets.
const handler = createFetchAdapter(planets, {mountPath: '/planets'});
export default {fetch: handler};Unknown routes (e.g. /robots.txt, /a/b/c) resolve to an empty 404 Response. No further configuration needed.
When a single runtime hosts more than one adapter, dispatch on mountPath manually and call the matching handler:
import {createFetchAdapter} from 'dynamodb-toolkit-fetch';
const planetsHandler = createFetchAdapter(planets, {mountPath: '/planets'});
const moonsHandler = createFetchAdapter(moons, {mountPath: '/moons'});
const probesHandler = createFetchAdapter(probes, {mountPath: '/probes'});
const routes = [
{prefix: '/planets', handler: planetsHandler},
{prefix: '/moons', handler: moonsHandler},
{prefix: '/probes', handler: probesHandler}
];
export default {
fetch(request) {
const pathname = new URL(request.url).pathname;
const match = routes.find(r => pathname === r.prefix || pathname.startsWith(r.prefix + '/'));
if (match) return match.handler(request);
return new Response('Not Found', {status: 404});
}
};Each adapter handles its own route pack; the outer dispatcher only routes by prefix.
Hono is the idiomatic Fetch router for multi-route APIs. Pass onMiss: () => null to the adapter so it yields control back to Hono when the request isn't one of its own:
import {Hono} from 'hono';
import {createFetchAdapter} from 'dynamodb-toolkit-fetch';
const planetsHandler = createFetchAdapter(planets, {mountPath: '/planets', onMiss: () => null});
const moonsHandler = createFetchAdapter(moons, {mountPath: '/moons', onMiss: () => null});
const app = new Hono();
app.get('/health', c => c.json({ok: true}));
app.all('/planets/*', async c => (await planetsHandler(c.req.raw)) ?? c.notFound());
app.all('/moons/*', async c => (await moonsHandler(c.req.raw)) ?? c.notFound());
export default app;Key points:
-
c.req.rawis the original FetchRequest. Always pass the raw request — the adapter reads the body itself. -
onMiss: () => nullwidens the handler's return type toPromise<Response | null>so??works withc.notFound(). -
app.all('/planets/*', …)forwards every method + path under/planetsto the adapter; Hono's method/path matching is terminal there.
itty-router uses a different composition style — handlers return Response, and the router calls them in order until one returns a non-null value:
import {Router} from 'itty-router';
import {createFetchAdapter} from 'dynamodb-toolkit-fetch';
const planetsHandler = createFetchAdapter(planets, {mountPath: '/planets', onMiss: () => null});
const router = Router();
router.all('/planets/*', ({raw}) => planetsHandler(raw));
router.get('/health', () => Response.json({ok: true}));
router.all('*', () => new Response('Not Found', {status: 404}));
export default {fetch: request => router.fetch(request)};{raw} in itty-router exposes the original Request the same way Hono's c.req.raw does. Adapter's onMiss: () => null means planetsHandler(raw) returns null when the path is recognized as /planets/* by itty-router but doesn't match an adapter route — itty-router then falls through to the next handler (the catch-all *).
Workers often sit in front of multiple resource types. Use the same miss-aware pattern to split between a DynamoDB-backed API and static assets:
import {createFetchAdapter} from 'dynamodb-toolkit-fetch';
const apiHandler = createFetchAdapter(planets, {mountPath: '/api/planets', onMiss: () => null});
export default {
async fetch(request, env) {
const apiResponse = await apiHandler(request);
if (apiResponse) return apiResponse;
// Fall through to static-assets binding or KV
return env.ASSETS.fetch(request);
}
};Each adapter holds its own maxBodyBytes, so per-route caps drop out naturally:
const planets = createFetchAdapter(planetsAdapter, {mountPath: '/planets', maxBodyBytes: 256 * 1024, onMiss: () => null});
const bulk = createFetchAdapter(bulkAdapter, {mountPath: '/bulk', maxBodyBytes: 16 * 1024 * 1024, onMiss: () => null});
const app = new Hono();
app.all('/planets/*', async c => (await planets(c.req.raw)) ?? c.notFound());
app.all('/bulk/*', async c => (await bulk(c.req.raw)) ?? c.notFound());The adapter's body reader enforces the cap via Content-Length fast-path + streaming byte counter.
Fetch composition is just function composition. Add cross-cutting middleware by wrapping the handler:
const authGuard = handler => async request => {
const token = request.headers.get('authorization');
if (!token) return new Response('Unauthorized', {status: 401});
// ... validate token, attach context via a wrapped Request (see below)
return handler(request);
};
const withLogging = handler => async request => {
const start = Date.now();
const response = await handler(request);
console.log(`${request.method} ${new URL(request.url).pathname} → ${response.status} (${Date.now() - start}ms)`);
return response;
};
const handler = withLogging(authGuard(createFetchAdapter(planets)));
export default {fetch: handler};Because the adapter writes its response via new Response(...), everything outside can observe and transform that response — replicating compression, ETag, CORS, and similar concerns at a thin wrapper layer.
Fetch Request objects are immutable — you can't set request.user = ... the way Express's middleware chain does. Three idioms:
const handler = createFetchAdapter(planets, {
exampleFromContext: (query, _body, request) => ({
tenantId: request.headers.get('x-tenant-id') || 'default'
})
});Read request-scoped data out of headers or URL params inside exampleFromContext / keyFromPath / hooks.
const auth = handler => async request => {
const user = await verifyToken(request.headers.get('authorization'));
const enriched = new Request(request, {
headers: new Headers([...request.headers, ['x-user-id', user.id]])
});
return handler(enriched);
};Downstream, request.headers.get('x-user-id') carries the verified identity.
import {AsyncLocalStorage} from 'node:async_hooks';
const ctx = new AsyncLocalStorage();
const requestCtx = handler => (request, ...rest) =>
ctx.run({request, traceId: crypto.randomUUID()}, () => handler(request, ...rest));
const handler = createFetchAdapter(planets, {
exampleFromContext: (_query, _body, _request) => {
const c = ctx.getStore();
// pull anything c carries
return {traceId: c?.traceId};
}
});
export default {fetch: requestCtx(handler)};Not every runtime exposes AsyncLocalStorage — Deno Deploy and some older Workers configurations don't. The header-cloning idiom (#2) is the most portable.
Pick the right combination of mountPath and onMiss:
| Situation | mountPath |
onMiss |
|---|---|---|
| Single collection, adapter owns the whole URL | unset | unset (default 404) |
| Single collection, adapter lives under a prefix |
/planets (etc.) |
unset |
Multiple collections, handwritten fetch() dispatcher |
per-adapter prefix | unset |
| Inside Hono / itty-router / similar host router | per-adapter prefix | () => null |
| Custom miss response (not a router — just a prettier 404) | as needed | () => new Response(…) |