Skip to main content

Edge Runtime Notes

:::caution Pre-release This is a pre-1.0 library (v0.1.1) — API may change without notice. :::

The collector (createCollectHandler) is designed to run safely on Vercel Edge Functions, Cloudflare Workers, and Deno Deploy. It uses Web Crypto only — no Node.js Buffer, no node:crypto, no database drivers.

Web Crypto Only

The server module is declared in package.json with explicit runtime constraints:

// packages/journey-recorder/src/server/index.ts
// Edge-safe means Web Crypto + `TextEncoder` only: no `Buffer`, no
// `node:crypto`, no TCP database driver, no `runtime = "nodejs"` assumption.

This is enforced by:

  1. Build-time: The server exports are bundled by tsup with platform: 'neutral', excluding Node.js builtins.
  2. Runtime-time: The collect handler (createCollectHandler) only uses SubtleCrypto for hashing and HMAC operations.

Vercel Edge Functions

Use runtime = "edge" in your Route Handler:

import { createCollectHandler } from "@next-story/journey-recorder/server";

const handler = createCollectHandler({
tokenSecrets: {
current: process.env.NS_TOKEN_SECRET!,
previous: process.env.NS_TOKEN_SECRET_PREVIOUS,
},
ipSalt: process.env.NS_IP_SALT!,
sink: {
async storeEvents(batch) {
// Send to a database via fetch or other I/O
await fetch("https://api.example.com/events", {
method: "POST",
body: JSON.stringify(batch.events),
});
},
},
});

export async function POST(request: NextRequest) {
return handler(request);
}

export const runtime = "edge"; // Vercel Edge Functions
export const config = {
regions: ["sin1"], // Optional: specify regions
maxDuration: 30, // Edge Functions support 30-second timeout
};

export const RECOMMENDED_MAX_DURATION = 30; // Exported by the library
export const RECOMMENDED_RUNTIME = "edge"; // Exported by the library

Cloudflare Workers

The handler works with Cloudflare Workers via the fetch API:

import { createCollectHandler } from "@next-story/journey-recorder/server";
import { NextRequest } from "next/server"; // Or use native Request

const handler = createCollectHandler({
tokenSecrets: { current: env.NS_TOKEN_SECRET },
ipSalt: env.NS_IP_SALT,
sink: {
async storeEvents(batch) {
await env.EVENTS_KV.put(
`events-${Date.now()}`,
JSON.stringify(batch.events)
);
},
},
});

export default {
async fetch(request: Request) {
return handler(new NextRequest(request));
},
};

Database Connectivity

The collector does not connect to databases directly. Instead, it provides events to a sink callback that you implement:

const handler = createCollectHandler({
// ... secrets ...
sink: {
async storeEvents(batch) {
// Option 1: Use fetch to call a separate API endpoint
await fetch("https://your-db-api/events", {
method: "POST",
body: JSON.stringify(batch.events),
});

// Option 2: Use Neon serverless driver
const client = new Client({
connectionString: env.DATABASE_URL,
});
await client.connect();
await client.query("INSERT INTO raw_event (...) VALUES (...)", [...]);
await client.end();

// Option 3: Queue for later processing
await env.EVENTS_QUEUE.send({ events: batch.events });
},
},
});

This separation allows the collector to remain light and portable while you choose where events land.

Rate Limiting Store

By default, the collector uses an in-memory InMemoryStore for rate limits. In production with Edge replication, use Upstash Redis:

import { UpstashStore } from "@next-story/journey-recorder/server";

const store = new UpstashStore({
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
url: process.env.UPSTASH_REDIS_REST_URL!,
});

const handler = createCollectHandler({
// ... secrets ...
store, // Replicated rate limit state
// ... sink ...
});

Upstash REST API is HTTP-based and works anywhere, including Edge Functions.

Size and Performance

The compiled collector bundle is ~15 KB (minified, gzipped). Token verification and IP hashing are fast crypto operations suitable for sub-100ms latency.

Latency breakdown (typical):

  • Token verification: ~1 ms
  • IP hashing: ~2 ms
  • Rate limit checks: ~5 ms (memory), ~10-20 ms (Upstash)
  • Sink call (to your API): 10-500 ms (depends on your backend)
  • Total: 16-530 ms

Most of the time is spent in your sink implementation, not in the collector itself.

Timeouts

  • Vercel Edge Functions: 30-second max execution time (sufficient for most cases).
  • Cloudflare Workers: 30-second max execution time (standard).
  • Deno Deploy: 60-second max execution time.

If your sink needs longer, consider queueing events instead of processing synchronously:

sink: {
async storeEvents(batch) {
// Queue immediately, process async
await env.EVENTS_QUEUE.send(batch);
// Return quickly (< 100 ms) instead of waiting for sink
},
}

Example: Queued Sink

// Collect handler: queue events
const handler = createCollectHandler({
// ... secrets ...
sink: {
async storeEvents(batch) {
await env.EVENTS_QUEUE.send(batch);
},
},
});

// Separate worker/cron job: process queue
export async function processQueue() {
const events = await env.EVENTS_QUEUE.receive();
for (const batch of events) {
const db = new Client({ connectionString: env.DATABASE_URL });
await db.connect();
await db.query("INSERT INTO raw_event (...) VALUES (...)", [...]);
await db.end();
}
}

This pattern keeps the latency-critical collect path fast while processing events asynchronously.