Skip to content
This repository was archived by the owner on Jul 17, 2026. It is now read-only.

Composition

Eugene Lazutkin edited this page Apr 20, 2026 · 1 revision

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.

Terminal: one adapter owns the whole URL

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.

Multiple adapters on one runtime (manual dispatch)

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.

Composing with Hono

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.raw is the original Fetch Request. Always pass the raw request — the adapter reads the body itself.
  • onMiss: () => null widens the handler's return type to Promise<Response | null> so ?? works with c.notFound().
  • app.all('/planets/*', …) forwards every method + path under /planets to the adapter; Hono's method/path matching is terminal there.

Composing with itty-router

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 *).

Cloudflare Workers with Durable Objects / static assets

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);
  }
};

Scoping body caps per adapter

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.

Middleware-style wrappers

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.

Attaching per-request context

Fetch Request objects are immutable — you can't set request.user = ... the way Express's middleware chain does. Three idioms:

1. Wrap the handler and pass context via a closure

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.

2. Clone the request with extra headers

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.

3. AsyncLocalStorage (Node / Bun / Cloudflare Workers with compatibility_flags = ["nodejs_compat"])

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.

Decision tree

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(…)