Dotyc
API Reference

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

OptionDescription
originAllowed origins for the CORS/origin check (e.g. ['https://app.example.com']).
maxBodySizePayload size limit, e.g. '256kb'. Oversized requests are rejected before parsing.
guardCustom async hook running before ingestion — the place to plug your rate limiting or anti-spam.
waitUntilServerless 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.

Draft — subject to change: the exact contract of the 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 a source: 'trusted' event that arrived over HTTP. Reported via onError, 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 anonymousId cookie 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 http transport batches; the handler natively accepts the array. An envelope is never mixed: it carries durable events or best-effort events, not both.

See also

On this page