toHandler
Wrap a dispatcher in a fetch-standard handler with origin check, body-size limit, guard hook and serverless waitUntil.
Wraps a dispatcher in a fetch-standard handler — (Request) => Response. Modern frameworks (Next.js, Hono, Remix, SvelteKit, Bun, Deno, Elysia, ...) all speak Web Request/Response, so this single handler covers them all.
import { toHandler } from 'dotyc/ingest'Signature
function toHandler(
dispatcher: Dispatcher,
options?: {
origin?: string[]
maxBodySize?: string
guard?: (request: Request) => Promise<unknown>
waitUntil?: (promise: Promise<unknown>) => void
}
): (request: Request) => Promise<Response>Options
| Option | Description |
|---|---|
origin | Allowed origins for the CORS/origin check (e.g. ['https://app.example.com']). |
maxBodySize | Payload size limit, e.g. '256kb'. Oversized requests are rejected before parsing. |
guard | Custom async hook running before ingestion — the place to plug your rate limiting or anti-spam. |
waitUntil | Serverless lifetime hook (Vercel, Cloudflare Workers, Lambda-style runtimes). Pass your platform's waitUntil so fan-out and adapter flush() can complete after the response is sent, instead of being killed when the invocation freezes. Durable events are persisted and acked before the response either way; waitUntil protects the fan-out tail. |
Basic endpoint security is provided (size limit, origin check, guard hook); everything beyond that belongs to your infrastructure.
guard hook (how it rejects a request, what it may return) is not finalized.Return value
A (Request) => Promise<Response> function, mountable as-is as a route handler.
The response: per-event acks
The response body reports the fate of each event in the envelope:
accepted— validated (durables: persisted to the outbox) and handed to fan-out.rejected— invalid against the current schema, or asource: 'trusted'event that arrived over HTTP. Reported viaonError, never dispatched.legacy— emitted against an older schema version found in the registry; validated against that version, dispatched flagged.
The emitter uses these acks: its durable lane keeps an event queued and retries until it is acked, and in development, rejections are surfaced loudly in the console.
Examples
Next.js route handler (app/api/ingest/route.ts):
import { toHandler } from 'dotyc/ingest'
import { waitUntil } from '@vercel/functions'
import { dispatcher } from '@/packages/analytics/ingest'
export const POST = toHandler(dispatcher, {
origin: ['https://app.example.com'],
maxBodySize: '256kb',
guard: async (request) => rateLimit(request),
waitUntil,
})Hono:
const handler = toHandler(dispatcher)
app.post('/api/ingest', (c) => handler(c.req.raw))The dual-mount QoS pattern (optional)
toHandler returns a plain function, so mounting the same dispatcher on two routes is free. That lets you give durable business facts and best-effort noise different infrastructure — separate rate limits, scaling, WAF rules — without duplicating any pipeline:
// app/api/ingest/route.ts → best-effort volume
export const POST = toHandler(dispatcher, { maxBodySize: '256kb' })
// app/api/ingest/critical/route.ts → durable events, stricter guard, laxer limits
export const POST = toHandler(dispatcher, { guard: strictGuard })This is a documented pattern, not a requirement: even on a single route, durable and best-effort events never share an envelope (the emitter keeps two lanes), and ingest prioritizes durables under pressure.
Notes
- First-party by design. The handler is mounted on your domain, so ingestion is invisible to ad blockers and the
anonymousIdcookie is same-origin. - Sugar, not the contract. Anything the handler does, you can do yourself with
dispatcher.ingest(envelope, { request })— custom routes, legacy Node frameworks, queue workers. - One POST = one envelope = N events. The emitter's
httptransport batches; the handler natively accepts the array. An envelope is never mixed: it carries durable events or best-effort events, not both.