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.currentis required;previousis 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:
- Per-IP burst limit: Max 50 requests per 60 seconds per IP (hashed with daily-rotating salt).
- 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,
});