Skip to main content

Server API

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

Edge-safe collector middleware for Next.js. Import from @next-story/journey-recorder/server.

All server functions use only Web Crypto and TextEncoder — no Node.js Buffer, no node:crypto, no database drivers. They run safely on Cloudflare Workers, Deno Deploy, and Vercel Edge Functions.

withJourneyToken(config)

Middleware that mints and validates secure tokens for the client.

import { withJourneyToken } from "@next-story/journey-recorder/server";
import { NextResponse, type NextRequest } from "next/server";

export function middleware(request: NextRequest) {
const response = NextResponse.next();

return withJourneyToken({
request,
response,
tokenSecrets: {
current: process.env.NS_TOKEN_SECRET!,
previous: process.env.NS_TOKEN_SECRET_PREVIOUS,
},
siteResolver: (token) => ({
siteId: "my-site",
domain: process.env.NS_COOKIE_DOMAIN || "localhost",
}),
}).response;
}

Parameters

config: WithJourneyTokenConfig

  • request (required): NextRequest
    The incoming request.

  • response (required): NextResponse
    The outgoing response. A token will be set on this response.

  • tokenSecrets (required): { current: string; previous?: string }
    Base64-encoded secrets for token signing. current is required; previous is used during rotation.

  • siteResolver (required): (token) => { siteId: string; domain: string }
    A function that returns the site's ID and cookie domain for the current token. Called once per request.

Returns

{
response: NextResponse;
token: VerifiedToken | { ok: false; reason: string };
}

Constants

  • JOURNEY_TOKEN_HEADER: "x-ns-journey-token"
    Header name used internally for token verification.

  • TOKEN_COOKIE_NAME: "__ns_token"
    Cookie name for the signed token.

createCollectHandler(config)

Edge-safe Route Handler for collecting and validating event batches.

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,
},
sink: {
async storeEvents(batch) {
// Persist batch.events to your database
await db.events.insert(batch.events);
},
},
ipSalt: process.env.NS_IP_SALT!,
});

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

export const runtime = "edge";

Parameters

config: CollectHandlerConfig

  • tokenSecrets (required): { current: string; previous?: string }
    Base64-encoded secrets for token verification.

  • sink (required): { storeEvents(batch): Promise<void> }
    A callback that receives and persists each validated event batch.

  • ipSalt (required): string
    Base64-encoded salt for daily-rotating-salt IP hashing.

  • store (optional): Store
    A backing store for rate limiting (in-memory or Upstash Redis). Defaults to in-memory.

  • rateLimitConfig (optional): RateLimitConfig
    Rate limit thresholds. See Rate Limiting below.

Returns

A function with signature:

(request: NextRequest) => Promise<Response>

Constants

  • RECOMMENDED_MAX_DURATION: 30 (seconds)
    Recommended timeout for Edge runtime.

  • RECOMMENDED_RUNTIME: "edge"
    Recommended Vercel runtime setting.

Token Management

mintToken(payload, secrets)

Manually create a token (rarely needed; withJourneyToken handles this).

import { mintToken, type TokenSecrets } from "@next-story/journey-recorder/server";

const token = await mintToken(
{
siteId: "my-site",
origin: "https://example.com",
},
{ current: process.env.NS_TOKEN_SECRET! }
);

verifyToken(tokenString, secrets)

Manually verify a token.

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

const verified = await verifyToken(tokenString, {
current: process.env.NS_TOKEN_SECRET!,
previous: process.env.NS_TOKEN_SECRET_PREVIOUS,
});

if (verified.ok) {
console.log("Valid token for site:", verified.siteId);
} else {
console.error("Token invalid:", verified.reason);
}

reissueTokenIfNeeded(token, secrets)

Refresh a token if it is within the reissue window.

const reissued = await reissueTokenIfNeeded(token, secrets);

Rate Limiting

The collector enforces two rate limits:

  1. Per-IP burst limit: Max 50 requests per 60 seconds per IP (hashed with daily-rotating salt).
  2. Per-site daily budget: Max 10,000,000 events per day per site.

checkIpBurst(ipHash, store, config?)

const verdict = await checkIpBurst(ipHash, store, {
maxRequests: 50,
windowSeconds: 60,
});

if (verdict.ok) {
// Proceed
} else {
console.error("Rate limit exceeded:", verdict.reason);
}

checkSiteDailyBudget(siteId, store, config?)

const verdict = await checkSiteDailyBudget(siteId, store, {
maxEventsPerDay: 10_000_000,
});

if (!verdict.ok) {
return new Response("Daily event quota exceeded", { status: 429 });
}

PII Enforcement

Server-side mirrors of client-side normalization. These re-run the same pure functions from the shared module defensively:

enforceRoutePattern(raw)

Re-normalize and validate a route pattern.

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

const verdict = enforceRoutePattern(raw);
if (verdict.ok) {
console.log("Safe pattern:", verdict.routePattern);
} else {
console.error("Invalid pattern:", verdict.reason); // "query-or-hash-survived", "empty", etc.
}

enforceClickTargetLabel(raw)

Re-scrub and re-truncate a click-target label.

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

const scrubbed = enforceClickTargetLabel(raw);

hashIp(ip, salt, now?)

Hash an IP with a daily-rotating salt.

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

const hashed = await hashIp("192.0.2.1", process.env.NS_IP_SALT!);
// Returns: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" (truncated SHA256)

The IP is never stored. The hash is used only for per-IP rate limiting and bot detection, and the salt rotates daily.

Store Interface

For advanced use cases (custom rate limit logic), implement the Store interface:

export interface Store {
getex(key: string, expiryMs: number): Promise<string | null>;
increx(key: string, expiryMs: number): Promise<number>;
}
  • getex(key, expiryMs): Get a value and set its expiry.
  • increx(key, expiryMs): Increment a counter and set its expiry.

The library provides:

  • InMemoryStore: For local development (not production-safe).
  • UpstashStore: For Upstash Redis, suitable for distributed deployments.
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({
// ...
store,
});