Skip to content

Fetch adapter

Eugene Lazutkin edited this page Jul 16, 2026 · 1 revision

Fetch adapter

The Fetch adapter turns an Adapter into a plain Fetch handler — (Request) => Promise<Response> — for Cloudflare Workers, Deno Deploy, Bun.serve, Hono, itty-router, and Node's native fetch servers:

import {createFetchAdapter} from 'dynamodb-toolkit/fetch';

It uses only standard Fetch APIs (Request, Response, URL, URLSearchParams, TextDecoder, ReadableStream), so there is no framework peer dependency — those are platform primitives on every target runtime. Shared behavior (routes, options, envelopes, error mapping) is documented on Framework adapters.

Getting started

You need a DynamoDB table (or DynamoDB Local running at http://localhost:8000 for experimentation) and AWS credentials reachable from the target runtime (env vars, IAM role, wrangler secret, etc.).

Bun.serve

// server.js
import {DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {DynamoDBDocumentClient} from '@aws-sdk/lib-dynamodb';
import {Adapter} from 'dynamodb-toolkit';
import {createFetchAdapter} from 'dynamodb-toolkit/fetch';

const ddb = DynamoDBDocumentClient.from(
  new DynamoDBClient({region: 'us-east-1'}),
  {marshallOptions: {removeUndefinedValues: true}}
);

const planets = new Adapter({client: ddb, table: 'planets', keyFields: ['name']});

const handler = createFetchAdapter(planets, {mountPath: '/planets'});

Bun.serve({port: 3000, fetch: handler});
console.log('Listening on http://localhost:3000');
bun run server.js

Deno.serve

// server.ts
import {DynamoDBClient} from 'npm:@aws-sdk/client-dynamodb';
import {DynamoDBDocumentClient} from 'npm:@aws-sdk/lib-dynamodb';
import {Adapter} from 'npm:dynamodb-toolkit';
import {createFetchAdapter} from 'npm:dynamodb-toolkit/fetch';

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({region: 'us-east-1'}));
const planets = new Adapter({client: ddb, table: 'planets', keyFields: ['name']});

const handler = createFetchAdapter(planets, {mountPath: '/planets'});

Deno.serve({port: 3000}, handler);
deno run --allow-net --allow-env server.ts

Cloudflare Workers

// src/index.js
import {DynamoDBClient} from '@aws-sdk/client-dynamodb';
import {DynamoDBDocumentClient} from '@aws-sdk/lib-dynamodb';
import {Adapter} from 'dynamodb-toolkit';
import {createFetchAdapter} from 'dynamodb-toolkit/fetch';

let handler;  // lazy -- reuses the same Adapter across warm invocations

const buildHandler = env => {
  const ddb = DynamoDBDocumentClient.from(
    new DynamoDBClient({
      region: env.AWS_REGION,
      credentials: {
        accessKeyId: env.AWS_ACCESS_KEY_ID,
        secretAccessKey: env.AWS_SECRET_ACCESS_KEY
      }
    })
  );
  const planets = new Adapter({client: ddb, table: 'planets', keyFields: ['name']});
  return createFetchAdapter(planets, {mountPath: '/planets'});
};

export default {
  fetch(request, env) {
    handler ||= buildHandler(env);
    return handler(request);
  }
};
# wrangler.toml
name = "planets-api"
main = "src/index.js"
compatibility_date = "2025-01-01"
wrangler dev         # local
wrangler deploy      # ship

Node (with a Fetch server)

Node 20+ ships Request / Response / URL globally. Any wrapper that exposes Fetch will work; @hono/node-server is a light option:

import {serve} from '@hono/node-server';
import {createFetchAdapter} from 'dynamodb-toolkit/fetch';

const handler = createFetchAdapter(planets, {mountPath: '/planets'});
serve({port: 3000, fetch: handler});

Try it

Assuming the server is running on http://localhost:3000:

# Create
curl -X POST http://localhost:3000/planets/ \
     -H 'content-type: application/json' \
     -d '{"name":"earth","mass":5.97,"climate":"temperate"}'

# Read one
curl http://localhost:3000/planets/earth

# Partial update (PATCH merges, does not replace)
curl -X PATCH http://localhost:3000/planets/earth \
     -H 'content-type: application/json' \
     -d '{"population":8.2e9}'

# Delete
curl -X DELETE http://localhost:3000/planets/pluto

The full route pack is documented on Framework adapters.

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 Fetch-specific options: mountPath (a string prefix to strip) and onMiss (a hook for unknown routes).

mountPath

Path prefix the adapter owns. Stripped from the incoming URL.pathname before route matching. Default: unset — the adapter sees the full pathname and treats it as rooted at /.

Match rules:

  • Exact match (pathname === mountPath) → route is /.
  • Prefix match (pathname.startsWith(mountPath + '/')) → route is pathname.slice(mountPath.length).
  • Anything else → miss path (see onMiss).

Matching is case-sensitive. No trailing-slash normalization beyond what URL parsing does.

onMiss

Hook invoked when the request can't be dispatched to a known route: the pathname is outside mountPath, or the route matcher returns 'unknown' for this method + path.

Without onMiss, unknown / off-mount requests resolve to an empty 404 Response — the handler is terminal. Suitable for single-collection Workers and Deno.serve(handler).

With onMiss supplied, the handler's return type widens from Promise<Response> to Promise<Response | null>, and the hook decides:

  • Returns Response → the handler returns it.
  • Returns null → the handler returns null, so the caller can try the next handler in a composed router.
  • Returns undefined / void → fall back to the default 404 Response.
  • Returns Promise<...> → the above semantics applied to the resolved value.
// Router composition -- yield control back to Hono / itty-router on a miss.
const planets = createFetchAdapter(adapter, {mountPath: '/planets', onMiss: () => null});

// Custom miss response -- e.g. send something friendlier than an empty 404.
const planets = createFetchAdapter(adapter, {
  mountPath: '/planets',
  onMiss: request => new Response(JSON.stringify({
    error: 'not found',
    path: new URL(request.url).pathname
  }), {status: 404, headers: {'content-type': 'application/json'}})
});

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

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. Read request-scoped data inside callbacks

const handler = createFetchAdapter(planets, {
  exampleFromContext: ({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: () => {
    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(...)

Body reading

The adapter reads JSON bodies from Fetch Request objects with a two-stage size guard: a Content-Length fast-path reject, then a mid-stream byte counter for requests without a declared length (chunked transfer) or whose Content-Length lies. The flow:

  1. If the declared Content-Length exceeds maxBodyBytes, reject 413 PayloadTooLarge before reading any bytes.
  2. Otherwise read request.body.getReader() chunk by chunk with a running byte total; as soon as the total crosses the cap, cancel the reader and reject 413.
  3. Decode UTF-8 and accumulate. An empty body returns null; a non-empty body that parses returns the value; a body that fails JSON.parse rejects 400 BadJsonBody.

Why two-stage?

request.json() is a one-liner but has no size cap; a Content-Length: 1GB header plus 1 GB of data will OOM the runtime before throwing. Pure streaming with getReader() is safe but costs allocations for the well-behaved case. The hybrid gives:

  • Zero-byte reject for uploads that honestly declare their size above the cap.
  • Safe mid-stream reject for chunked / CL-liar requests — a running counter catches them before the streaming decoder grows out of bounds.

Under attack load (CL = 2 GB, maxBodyBytes = 1 MiB), the fast-path costs one header read; the streaming fallback would cost at least one chunk-read.

Default cap

const handler = createFetchAdapter(planets);  // 1 MiB by default

maxBodyBytes defaults to 1048576 — matches the bundled node:http handler plus the koa / express adapters. Override per adapter:

// Low-power device -- cap at 64 KiB
createFetchAdapter(planets, {maxBodyBytes: 64 * 1024});

// Dedicated bulk-load -- allow larger bodies
createFetchAdapter(bulk, {maxBodyBytes: 16 * 1024 * 1024});

Error responses

Condition Status code
Content-Length over cap 413 PayloadTooLarge
Body streamed past cap 413 PayloadTooLarge
Body is not valid JSON 400 BadJsonBody
PUT /-load body is not an array 400 BadLoadBody

Body is null for requests with no payload (empty body on POST, or GET-shaped Requests).

Per-adapter body caps

Each adapter holds its own maxBodyBytes, so per-route caps drop out naturally when you mount multiple adapters — they don't share state:

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

See Composition for how to route between them on a single runtime.

Why reader.cancel() is wrapped in try/catch

reader.cancel() can reject if the stream source has already errored. The 413 we throw is authoritative — we wrap cancel() in try/catch, swallow its error, and let the 413 propagate. The cancel is just a hint to the runtime that the upstream can stop sending; backpressure and GC handle the dangling bytes.

Interaction with host body parsers

Unlike the express adapter, there's no body-parser middleware to defer to. Fetch-handler hosts (Cloudflare Workers, Bun.serve, Deno.serve, Hono, itty-router) all pass the Request through with an unread body stream. The adapter always reads the body itself.

If your host router has already consumed request.body (e.g. Hono's c.req.json() before delegating), pass the original raw Request — in Hono that's c.req.raw, itty-router exposes it similarly. See Composition for examples.

Bodies on non-body routes

GET / and DELETE / don't read the body. DELETE /-by-names reads the body only when ?names= is absent. All other body-capable routes always read. The adapter's dispatch logic decides whether to read the body based on the route; unused body bytes stay in the runtime's buffer until GC.

Error handling

The adapter wraps every route handler in a try / catch. Thrown errors are mapped to a status code and serialized into a JSON body. Callers never see stack traces or raw SDK errors unless you opt in. Sources of thrown errors:

  • The Adapter (CRUD methods, hooks, expression builders, batch retries).
  • The request body / query parsers in rest-core (validation rejections).
  • The adapter itself (unknown method on a known route → 405, bad JSON → 400, oversized body → 413).

Because Fetch handlers are inherently async and the adapter is wrapped in a try/catch, there is no equivalent to Express's next(err) escape hatch — the adapter always returns a Response, never throws out of the top level.

Any error object with a status property in the 400-599 range passes through to the response as-is; anything else goes through mapErrorStatus(err, policy.statusCodes), and the body is built by policy.errorBody. The status-mapping table, policy.statusCodes overrides, and the default error-body shape are shared across adapters and documented on Framework adapters and REST core.

Including debug info in development

buildErrorBody accepts an includeDebug flag that attaches err.stack. Wire it via a custom policy.errorBody, with a runtime-appropriate environment check:

import {buildErrorBody} from 'dynamodb-toolkit/rest-core';

const isDev = globalThis.process?.env?.NODE_ENV !== 'production';  // Node/Bun
// or on Cloudflare Workers: const isDev = env.ENVIRONMENT === 'dev';

createFetchAdapter(adapter, {
  policy: {
    errorBody: err => buildErrorBody(err, {includeDebug: isDev})
  }
});

Also supports per-request correlation IDs via the errorId option:

policy: {
  errorBody: err => buildErrorBody(err, {errorId: crypto.randomUUID()})
}

Note that policy.errorBody receives only err; the original Request isn't threaded through. For per-request IDs that need to match against application logs, either generate the ID inside the error body (above — random per error), or wrap the handler (below).

Wrapping the handler for request-context error bodies

If you need request context in the error body (user ID, request ID, locale, Worker trace ID), wrap the handler:

const inner = createFetchAdapter(adapter);

export default {
  async fetch(request) {
    const requestId = request.headers.get('x-request-id') || crypto.randomUUID();
    const response = await inner(request);

    // Enrich 4xx/5xx JSON bodies with requestId; leave success responses untouched.
    if (!response.ok && response.headers.get('content-type')?.includes('application/json')) {
      const body = await response.json();
      return new Response(JSON.stringify({...body, requestId}), {
        status: response.status,
        headers: response.headers
      });
    }
    return response;
  }
};

This keeps the adapter's default error-body logic intact and layers request context on top.

Unrecognized routes

Paths that don't match any route shape (e.g. three-segment paths) and paths outside mountPath go through the miss path:

  • Without onMiss: empty 404 Response. Terminal.
  • With onMiss: the hook decides. Return null to yield control to a host router, Response to ship one, undefined to fall back to the default 404.

See Composition for when to use each shape.

413 / 400 from body reading

Body-reading rejections (PayloadTooLarge, BadJsonBody, BadLoadBody) are structured errors that flow through the same error path. See Body reading for the full mechanism.

Fetch-specific options

The options surface of createFetchAdapter(adapter, options?) is a direct superset of the core HTTP handler's HandlerOptions:

  • policy, sortableIndices, keyFromPath, maxBodyBytes — identical shape and semantics; documented on Framework adapters.
  • exampleFromContext — the shared context-bag callback; on this adapter the bag carries framework: 'fetch' and the incoming request (below).
  • mountPath and onMiss — Fetch-specific; no equivalents in the core handler because node:http servers don't compose on the handler surface. Documented in Composition above.

The request field of the exampleFromContext bag

The callback receives {query, body, adapter, framework: 'fetch', request} where request is the full Fetch Request. Inspect request.headers, request.url, request.method, and host-specific context (e.g. Cloudflare Workers' request.cf). request.body is already consumed by the time this runs — don't try to re-read it.

createFetchAdapter(adapter, {
  exampleFromContext: ({query, request}) => ({
    tenant: request.headers.get('x-tenant-id') || 'default',
    status: query.status || 'active'
  })
});

Compatibility

Target runtimes

Every runtime that ships the standard Fetch primitives is supported. If you run the adapter inside a router (Hono, itty-router, wrangler, etc.), you install that router separately — it's your choice, not a peer requirement.

Runtime Status Notes
Node 20+ supported Fetch globals shipped; types via @types/node 20+.
Bun supported Native Fetch.
Deno supported Native Fetch.
Cloudflare Workers supported Full Fetch API in the Workers runtime.
Deno Deploy supported Same as Deno; use Deno.serve(handler).
Hono supported Pass c.req.raw to the adapter. See Composition.
itty-router supported Pass the raw Request. See Composition.
AWS Lambda no Lambda triggers deliver JSON events, not Fetch requests — use the Lambda adapter.

The adapter has no node:* imports at runtime — there's nothing Node-specific in the source; it runs verbatim on Workers / Deno.

CommonJS consumers

require('dynamodb-toolkit/fetch') works on Nodes that ship require(esm): 20.19+ on the 20.x line, 22.12+ on 22.x, unflagged everywhere newer.

const {createFetchAdapter} = require('dynamodb-toolkit/fetch');

TypeScript consumers

The factory is generic in TItem so typed Adapter<Item, Key> instances flow through without casts — typed adapters reach keyFromPath's second argument as-is:

import {Adapter} from 'dynamodb-toolkit';
import {createFetchAdapter, type FetchAdapterOptions} from 'dynamodb-toolkit/fetch';

interface Planet extends Record<string, unknown> {
  name: string;
  climate?: string;
}
type PlanetKey = Pick<Planet, 'name'>;

const adapter = new Adapter<Planet, PlanetKey>({client, table: 'planets', keyFields: ['name']});

const opts: FetchAdapterOptions<Planet> = {
  mountPath: '/planets',
  keyFromPath: (raw, a) => ({[a.keyFields[0].name]: raw}),
  policy: {defaultLimit: 25, maxLimit: 200}
};
const handler = createFetchAdapter(adapter, opts);

Conditional return type with onMiss

The factory is overloaded so the handler's return type reflects whether onMiss is supplied:

// No onMiss -> Promise<Response>
const terminal = createFetchAdapter(adapter);
const res: Response = await terminal(request);

// With onMiss -> Promise<Response | null>
const composable = createFetchAdapter(adapter, {onMiss: () => null});
const res2: Response | null = await composable(request);

The narrower type lets Workers / Bun.serve / Deno.serve consumers use the terminal shape without null-guarding, while router integrations get the accurate | null return.

Clone this wiki locally