-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.).
// 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// 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// 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 # shipNode 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});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/plutoThe full route pack is documented on Framework adapters.
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).
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 ispathname.slice(mountPath.length). - Anything else → miss path (see
onMiss).
Matching is case-sensitive. No trailing-slash normalization beyond what URL parsing does.
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 returnsnull, so the caller can try the next handler in a composed router. - Returns
undefined/void→ fall back to the default404 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'}})
});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. 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 *).
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);
}
};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: ({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: () => {
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(...) |
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:
- If the declared
Content-LengthexceedsmaxBodyBytes, reject413 PayloadTooLargebefore reading any bytes. - 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 reject413. - Decode UTF-8 and accumulate. An empty body returns
null; a non-empty body that parses returns the value; a body that failsJSON.parserejects400 BadJsonBody.
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.
const handler = createFetchAdapter(planets); // 1 MiB by defaultmaxBodyBytes 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});| 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).
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.
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.
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.
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.
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.
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).
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.
Paths that don't match any route shape (e.g. three-segment paths) and paths outside mountPath go through the miss path:
- Without
onMiss: empty404 Response. Terminal. - With
onMiss: the hook decides. Returnnullto yield control to a host router,Responseto ship one,undefinedto fall back to the default 404.
See Composition for when to use each shape.
Body-reading rejections (PayloadTooLarge, BadJsonBody, BadLoadBody) are structured errors that flow through the same error path. See Body reading for the full mechanism.
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 carriesframework: 'fetch'and the incomingrequest(below). -
mountPathandonMiss— Fetch-specific; no equivalents in the core handler becausenode:httpservers don't compose on the handler surface. Documented in Composition above.
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'
})
});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.
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');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);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.
Start here
- Getting started
- Concepts
- Key and field design
- Compatibility
- Migration: v2 to v3
- SDK v2 to v3 cheat sheet
Guides
- Hierarchical data walkthrough
- Key expression patterns
- Multi-type tables
- Pagination
- Mass operation semantics
- URL schema design
Adapter
- Adapter
- Constructor options
- CRUD methods
- Mass methods
- Batch builders
- Hooks
- Raw marker
- Indirect indices
- Transaction auto-upgrade
Expression builders
Batch / transactions / mass / paths
REST surface
Framework adapters
Recipes
- Recipes index
- List records of a tier
- Per-tier sparse GSI markers
- Tier within a partition
- Reservation with auto-release
- Keys-only GSI, runtime projection
- Cascade subtree operations
- Querying subtrees with buildKey
- Filter URL grammar
- Text search
- Provisioning workflow
- Resumable mass operations
History