Concept project · runs in your browserTypeScript · React.js · Next.js · Web Crypto API
Webhook Relay and Event Logger Demo
Send sample webhooks from three fictional providers and watch the relay verify each HMAC-SHA256 signature for real, reject tampered or stale requests, ignore duplicate event ids and fan out to three destinations. One destination is flaky and one is down, so you can watch retries back off on a simulated clock and replay failed events from the log.
Interactive demo
## How it works
The receiver verifies the signature on the raw body before parsing JSON, using crypto.subtle HMAC-SHA256 and a constant-time compare. Paylane signs timestamp.body, so a request older than five minutes is refused even with a valid signature.
Every request is stored, including rejected ones (with verified = false), before the relay answers. Verified events are unique per provider event id, so a provider retrying the same delivery gets 200 duplicate and nothing is forwarded twice.
Each matching destination gets its own delivery row. A failure schedules the next attempt at 1 min, 5 min, 30 min and 2 h, then marks the delivery failed. Replay adds new delivery rows and keeps the old attempts as history.
## View source
The code running in the window above, exactly as deployed.
app/lab/webhook-relay/relay.ts393 lines
/**
* Webhook relay core: signature verification, idempotent storage and fan-out with retries.
* No React here. In production this is a Node.js receiver (raw body, no JSON parser in front of it)
* plus a delivery worker that reads due rows from PostgreSQL with FOR UPDATE SKIP LOCKED.
*
* Signatures are real HMAC-SHA256, computed with the Web Crypto API (crypto.subtle), which is also
* available in Node.js 20+ as globalThis.crypto.
*/
export type ProviderId = "paylane" | "bookly" | "shipfast";
export type Provider = {
id: ProviderId;
name: string;
header: string;
scheme: "timestamped" | "hex" | "base64";
secret: string;
events: string[];
};
export const PROVIDERS: Provider[] = [
{
id: "paylane",
name: "Paylane (payments)",
header: "paylane-signature",
scheme: "timestamped", // t=<unix>,v1=<hex of HMAC("t.body")>
secret: "whsec_pl_7f3a91c2d84e4b6f",
events: ["payment.succeeded", "payment.refunded", "invoice.payment_failed"],
},
{
id: "bookly",
name: "Bookly (bookings)",
header: "x-bookly-signature-256",
scheme: "hex", // sha256=<hex of HMAC(body)>
secret: "bk_secret_19d0e27a55c3",
events: ["booking.created", "booking.cancelled"],
},
{
id: "shipfast",
name: "Shipfast (shipping)",
header: "x-shipfast-hmac-sha256",
scheme: "base64", // base64 of HMAC(body)
secret: "sf_live_c0ffee42b7a1",
events: ["shipment.dispatched", "shipment.delivered"],
},
];
export type Health = "healthy" | "flaky" | "down";
export type Destination = { id: string; name: string; url: string; subscribes: string[]; health: Health };
export type HttpRequest = { method: "POST"; path: string; headers: Record<string, string>; body: string };
export type Verification = {
ok: boolean;
reason: string | null;
expected: string | null; // what we computed (shown in the inspector)
received: string | null;
timestamp: number | null;
};
export type EventRecord = {
id: string; // relay id, evt_…
provider: ProviderId;
providerEventId: string | null;
type: string;
receivedAt: number;
headers: Record<string, string>;
body: string;
verified: boolean;
reason: string | null;
duplicates: number;
};
export type Attempt = { at: number; status: number; ms: number; error?: string };
export type Delivery = {
id: string;
eventId: string;
destinationId: string;
status: "pending" | "delivered" | "failed";
attempts: Attempt[];
nextAttemptAt: number | null;
replay: boolean;
};
export type RelayLog = { id: number; at: number; text: string; tone: "ok" | "warn" | "err" | "info" | "dim" | "accent" };
export type RelayState = {
now: number;
seq: number;
logSeq: number;
rng: number;
destinations: Destination[];
events: EventRecord[];
deliveries: Delivery[];
log: RelayLog[];
};
/** Retry schedule after the first attempt: 1 min, 5 min, 30 min, 2 h. Five attempts in total. */
export const RETRY_SCHEDULE_MS = [60_000, 300_000, 1_800_000, 7_200_000];
export const TOLERANCE_S = 300;
/* ------------------------------------------------------------------ */
/* Crypto */
/* ------------------------------------------------------------------ */
const enc = new TextEncoder();
async function hmac(secret: string, message: string): Promise<Uint8Array> {
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
return new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(message)));
}
const toHex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
const toBase64 = (b: Uint8Array) => btoa(String.fromCharCode(...b));
/**
* Constant-time comparison, the browser equivalent of crypto.timingSafeEqual.
* It always walks the full length so the time taken does not reveal how many leading characters matched.
*/
export function timingSafeEqual(a: string, b: string): boolean {
let diff = a.length ^ b.length;
const n = Math.max(a.length, b.length);
for (let i = 0; i < n; i++) diff |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
return diff === 0;
}
/** The sender side: what the provider computes before it POSTs to us. */
export async function signatureHeader(p: Provider, body: string, unixTs: number, secret = p.secret): Promise<string> {
if (p.scheme === "timestamped") return `t=${unixTs},v1=${toHex(await hmac(secret, `${unixTs}.${body}`))}`;
if (p.scheme === "hex") return `sha256=${toHex(await hmac(secret, body))}`;
return toBase64(await hmac(secret, body));
}
/** The receiver side. Runs on the raw body, before any JSON parsing. */
export async function verify(p: Provider, req: HttpRequest, nowMs: number): Promise<Verification> {
const header = req.headers[p.header];
if (!header) return { ok: false, reason: `missing ${p.header} header`, expected: null, received: null, timestamp: null };
if (p.scheme === "timestamped") {
const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]));
const t = Number(parts.t);
const v1 = parts.v1 ?? "";
if (!Number.isFinite(t)) return { ok: false, reason: "malformed signature header", expected: null, received: v1, timestamp: null };
const expected = toHex(await hmac(p.secret, `${t}.${req.body}`));
if (!timingSafeEqual(expected, v1)) return { ok: false, reason: "signature mismatch", expected, received: v1, timestamp: t };
if (Math.abs(nowMs / 1000 - t) > TOLERANCE_S) return { ok: false, reason: `timestamp outside ${TOLERANCE_S}s tolerance (possible replay attack)`, expected, received: v1, timestamp: t };
return { ok: true, reason: null, expected, received: v1, timestamp: t };
}
const received = p.scheme === "hex" ? header.replace(/^sha256=/, "") : header;
const mac = await hmac(p.secret, req.body);
const expected = p.scheme === "hex" ? toHex(mac) : toBase64(mac);
if (!timingSafeEqual(expected, received)) return { ok: false, reason: "signature mismatch", expected, received, timestamp: null };
return { ok: true, reason: null, expected, received, timestamp: null };
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function rand(s: RelayState) {
s.rng = (s.rng + 0x6d2b79f5) | 0;
let t = s.rng;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
function log(s: RelayState, text: string, tone: RelayLog["tone"] = "info") {
s.logSeq += 1;
s.log = [{ id: s.logSeq, at: s.now, text, tone }, ...s.log].slice(0, 100);
}
const clone = (s: RelayState): RelayState => ({
...s,
destinations: s.destinations.map((d) => ({ ...d })),
events: s.events.map((e) => ({ ...e })),
deliveries: s.deliveries.map((d) => ({ ...d, attempts: [...d.attempts] })),
});
export const providerById = (id: string) => PROVIDERS.find((p) => p.id === id);
/** Show enough of a secret to recognise it, never the whole value. */
export const mask = (v: string) => (v.length <= 10 ? "••••" : `${v.slice(0, 6)}••••${v.slice(-4)}`);
export function maskHeaders(h: Record<string, string>) {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(h)) out[k] = /authorization|cookie|api-key/i.test(k) ? mask(v) : v;
return out;
}
/** Event id + type, read the way each provider shapes its JSON. Only called after verification. */
function parseEnvelope(body: string): { id: string | null; type: string } {
try {
const j = JSON.parse(body) as { id?: unknown; event_id?: unknown; type?: unknown; event?: unknown };
const id = typeof j.id === "string" ? j.id : typeof j.event_id === "string" ? j.event_id : null;
const type = typeof j.type === "string" ? j.type : typeof j.event === "string" ? j.event : "unknown";
return { id, type };
} catch {
return { id: null, type: "unparseable" };
}
}
const matches = (d: Destination, type: string) => d.subscribes.some((p) => p === "*" || type.startsWith(p));
/* ------------------------------------------------------------------ */
/* Receiver: POST /in/:provider */
/* ------------------------------------------------------------------ */
export type InboundResponse = { status: number; body: Record<string, unknown> };
/**
* Store first, answer fast, forward later. Called with the verification result computed
* (asynchronously) from the raw body; this part is synchronous so it acts like one DB transaction.
*/
export function ingest(prev: RelayState, providerId: ProviderId, req: HttpRequest, v: Verification): [RelayState, InboundResponse] {
const s = clone(prev);
const p = providerById(providerId)!;
const env = v.ok ? parseEnvelope(req.body) : { id: null, type: "unverified" };
log(s, `POST ${req.path} (${req.body.length} bytes)`, "dim");
// Idempotency: one row per (provider, provider event id). Only verified events count,
// so a forged request cannot "use up" the id of a real one.
if (v.ok && env.id) {
const existing = s.events.find((e) => e.verified && e.provider === p.id && e.providerEventId === env.id);
if (existing) {
existing.duplicates += 1;
log(s, `200 duplicate ${env.id}: already stored as ${existing.id}, not forwarded again`, "warn");
return [s, { status: 200, body: { received: true, duplicate: true, event: existing.id } }];
}
}
s.seq += 1;
const id = `evt_${(s.seq * 7919).toString(36).padStart(6, "0")}`;
s.events.unshift({
id,
provider: p.id,
providerEventId: env.id,
type: env.type,
receivedAt: s.now,
headers: req.headers,
body: req.body,
verified: v.ok,
reason: v.reason,
duplicates: 0,
});
s.events = s.events.slice(0, 60);
if (!v.ok) {
log(s, `401 ${p.name}: ${v.reason}. Stored as ${id} (verified=false), not forwarded`, "err");
return [s, { status: 401, body: { error: "invalid_signature", detail: v.reason, event: id } }];
}
const targets = s.destinations.filter((d) => matches(d, env.type));
for (const d of targets) s.deliveries.push(newDelivery(s, id, d.id, false));
log(s, `202 stored ${id} ${env.type}; ${targets.length} deliver${targets.length === 1 ? "y" : "ies"} queued`, "ok");
return [s, { status: 202, body: { received: true, event: id, deliveries: targets.length } }];
}
function newDelivery(s: RelayState, eventId: string, destinationId: string, replay: boolean): Delivery {
s.seq += 1;
return { id: `dlv_${s.seq}`, eventId, destinationId, status: "pending", attempts: [], nextAttemptAt: s.now, replay };
}
/** Replay creates new delivery rows; the old ones stay as history. */
export function replay(prev: RelayState, eventId: string, destinationId?: string): RelayState {
const s = clone(prev);
const e = s.events.find((x) => x.id === eventId);
if (!e || !e.verified) return prev;
const targets = s.destinations.filter((d) => (destinationId ? d.id === destinationId : matches(d, e.type)));
for (const d of targets) s.deliveries.push(newDelivery(s, e.id, d.id, true));
log(s, `replay ${e.id} → ${targets.map((d) => d.name).join(", ")}`, "accent");
return s;
}
export function setHealth(prev: RelayState, destinationId: string, health: Health): RelayState {
const s = clone(prev);
const d = s.destinations.find((x) => x.id === destinationId);
if (!d) return prev;
d.health = health;
log(s, `${d.name} is now ${health}`, health === "healthy" ? "ok" : "warn");
return s;
}
/* ------------------------------------------------------------------ */
/* Delivery worker */
/* ------------------------------------------------------------------ */
/** Advance the clock and send every due delivery. In production: SELECT … FOR UPDATE SKIP LOCKED. */
export function tick(prev: RelayState, dtMs: number): RelayState {
const due = prev.deliveries.some((d) => d.status === "pending" && d.nextAttemptAt !== null && d.nextAttemptAt <= prev.now + dtMs);
if (!due) return { ...prev, now: prev.now + dtMs };
const s = clone(prev);
s.now += dtMs;
for (const d of s.deliveries) {
if (d.status !== "pending" || d.nextAttemptAt === null || d.nextAttemptAt > s.now) continue;
attempt(s, d);
}
return s;
}
/** Jump the clock to the next scheduled retry (the demo's fast-forward). */
export function nextDueAt(s: RelayState): number | null {
const times = s.deliveries.filter((d) => d.status === "pending" && d.nextAttemptAt !== null).map((d) => d.nextAttemptAt!);
return times.length ? Math.min(...times) : null;
}
function attempt(s: RelayState, d: Delivery) {
const dest = s.destinations.find((x) => x.id === d.destinationId);
if (!dest) return;
const r = rand(s);
let status = 200;
let error: string | undefined;
let ms = Math.round(40 + r * 160);
if (dest.health === "down") {
status = 503;
error = "Service Unavailable";
} else if (dest.health === "flaky" && r < 0.5) {
if (r < 0.2) {
status = 0;
error = "timeout after 10000 ms";
ms = 10_000;
} else {
status = 500;
error = "Internal Server Error";
}
}
d.attempts.push({ at: s.now, status, ms, error });
const n = d.attempts.length;
if (status >= 200 && status < 300) {
d.status = "delivered";
d.nextAttemptAt = null;
log(s, `→ ${dest.name} ${d.eventId} 200 OK (${ms} ms, attempt ${n})`, "ok");
return;
}
const wait = RETRY_SCHEDULE_MS[n - 1];
if (wait === undefined) {
d.status = "failed";
d.nextAttemptAt = null;
log(s, `✗ ${dest.name} ${d.eventId} failed after ${n} attempts; marked failed (replay by hand)`, "err");
} else {
d.nextAttemptAt = s.now + wait;
log(s, `→ ${dest.name} ${d.eventId} ${status || "ERR"} ${error}; retry #${n} in ${fmtDuration(wait)}`, "warn");
}
}
export const fmtDuration = (ms: number) => (ms >= 3_600_000 ? `${ms / 3_600_000} h` : ms >= 60_000 ? `${ms / 60_000} min` : `${Math.round(ms / 1000)} s`);
export const fmtClock = (ms: number) => new Date(ms).toISOString().slice(11, 19);
/* ------------------------------------------------------------------ */
/* Sample traffic (what the fictional providers send) */
/* ------------------------------------------------------------------ */
export function samplePayload(p: Provider, type: string, n: number): string {
const amount = 4900 + (n % 7) * 1250;
if (p.id === "paylane")
return JSON.stringify({ id: `pl_evt_${1040 + n}`, type, created: 1794387600 + n * 60, data: { object: { id: `pay_${8800 + n}`, amount, currency: "usd", customer: "Acme Dental", order: `Order #${1040 + n}` } } });
if (p.id === "bookly")
return JSON.stringify({ event_id: `bk_${5100 + n}`, event: type, booking: { id: 300 + n, venue: "Harbor Gym", slot: "2026-11-14T10:00:00Z", guest: "Guest #" + (20 + n) } });
return JSON.stringify({ id: `sf_${77000 + n}`, type, shipment: { tracking: `SF${123400 + n}`, order: `Order #${1040 + n}`, carrier: "Northwind Freight" } });
}
/* ------------------------------------------------------------------ */
/* Seed */
/* ------------------------------------------------------------------ */
export const START = Date.UTC(2026, 10, 11, 9, 0, 0);
export function initialState(): RelayState {
return {
now: START,
seq: 100,
logSeq: 0,
rng: 7,
destinations: [
{ id: "orders", name: "orders-service", url: "https://orders.acme.internal/hooks", subscribes: ["payment.", "shipment."], health: "healthy" },
{ id: "crm", name: "crm-sync", url: "https://crm.acme.internal/webhooks", subscribes: ["payment.succeeded", "booking."], health: "flaky" },
{ id: "analytics", name: "analytics-sink", url: "https://events.acme.internal/ingest", subscribes: ["*"], health: "down" },
],
events: [],
deliveries: [],
log: [{ id: 0, at: START, text: "relay listening on /in/:provider (simulated)", tone: "ok" }],
};
}
export function isRelayState(v: unknown) {
const x = v as RelayState | null;
return !!x && typeof x.now === "number" && Array.isArray(x.events) && Array.isArray(x.deliveries) && Array.isArray(x.destinations);
}app/lab/webhook-relay/Demo.tsx551 lines
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { PanelTitle, Pill, TerminalLog, btn, btnPrimary, input, label, useInterval, useStoredState } from "@/components/lab/systems/ui";
import {
PROVIDERS,
RETRY_SCHEDULE_MS,
fmtClock,
fmtDuration,
ingest,
initialState,
isRelayState,
mask,
maskHeaders,
nextDueAt,
providerById,
replay,
samplePayload,
setHealth,
signatureHeader,
tick,
verify,
type Health,
type HttpRequest,
type InboundResponse,
type ProviderId,
type RelayState,
type Verification,
} from "./relay";
const KEY = "lab:webhook-relay:v1";
const TICK_MS = 250;
const SIM_PER_TICK = 15_000; // 1 real second = 1 simulated minute
type Filter = "all" | "verified" | "rejected" | "failing";
type Inspect = { req: HttpRequest; v: Verification; res: InboundResponse; provider: ProviderId };
export default function Demo() {
const [state, setState, resetState] = useStoredState<RelayState>(KEY, initialState, isRelayState);
const ref = useRef<RelayState | null>(null);
useEffect(() => {
ref.current = state;
}, [state]);
const apply = useCallback(
(fn: (s: RelayState) => RelayState) => {
if (!ref.current) return;
const next = fn(ref.current);
ref.current = next;
setState(next);
},
[setState],
);
const [running, setRunning] = useState(true);
const [provider, setProvider] = useState<ProviderId>("paylane");
const [eventType, setEventType] = useState("payment.succeeded");
const [tamper, setTamper] = useState(false);
const [wrongSecret, setWrongSecret] = useState(false);
const [stale, setStale] = useState(false);
const [duplicate, setDuplicate] = useState(false);
const [busy, setBusy] = useState(false);
const [inspect, setInspect] = useState<Inspect | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [filter, setFilter] = useState<Filter>("all");
const [query, setQuery] = useState("");
const [raw, setRaw] = useState<{ provider: ProviderId; headers: string; body: string } | null>(null);
const [rawError, setRawError] = useState<string | null>(null);
const lastBody = useRef<Partial<Record<ProviderId, string>>>({});
const seeded = useRef(false);
useInterval(() => apply((s) => tick(s, SIM_PER_TICK)), running && state ? TICK_MS : null);
const send = useCallback(
async (pid: ProviderId, req: HttpRequest) => {
const p = providerById(pid)!;
const now = ref.current?.now ?? 0;
const v = await verify(p, req, now);
let res: InboundResponse = { status: 500, body: {} };
apply((s) => {
const [next, r] = ingest(s, pid, req, v);
res = r;
return next;
});
setInspect({ req, v, res, provider: pid });
return res;
},
[apply],
);
const fire = useCallback(
async (opts: { pid: ProviderId; type: string; tamper?: boolean; wrongSecret?: boolean; stale?: boolean; duplicate?: boolean }) => {
const s = ref.current;
if (!s) return;
const p = providerById(opts.pid)!;
const prevBody = lastBody.current[p.id];
let body = opts.duplicate && prevBody ? prevBody : samplePayload(p, opts.type, s.seq);
lastBody.current[p.id] = body;
const ts = Math.floor(s.now / 1000) - (opts.stale ? 900 : 0);
const sig = await signatureHeader(p, body, ts, opts.wrongSecret ? "whsec_guessed_secret" : undefined);
// Tampering happens after signing: an attacker (or a proxy) changed the body in transit.
if (opts.tamper) body = body.replace(/"amount":\d+/, '"amount":1').replace(/"guest":"[^"]*"/, '"guest":"Mallory"').replace(/"carrier":"[^"]*"/, '"carrier":"Unknown"');
const req: HttpRequest = {
method: "POST",
path: `/in/${p.id}`,
headers: { "content-type": "application/json", "user-agent": `${p.id}-webhooks/1.0`, [p.header]: sig },
body,
};
return send(p.id, req);
},
[send],
);
// First visit: send three sample webhooks so the log is not empty.
useEffect(() => {
if (!state || seeded.current) return;
seeded.current = true;
if (state.events.length > 0) return;
void (async () => {
await fire({ pid: "paylane", type: "payment.succeeded" });
await fire({ pid: "bookly", type: "booking.created" });
await fire({ pid: "shipfast", type: "shipment.dispatched", tamper: true });
})();
}, [state, fire]);
async function onFire(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
await fire({ pid: provider, type: eventType, tamper, wrongSecret, stale, duplicate });
setBusy(false);
}
function openRaw() {
const src = inspect ?? null;
setRawError(null);
setRaw({
provider: src?.provider ?? provider,
headers: JSON.stringify(src?.req.headers ?? { "content-type": "application/json" }, null, 2),
body: src?.req.body ?? samplePayload(providerById(provider)!, eventType, 999),
});
}
async function resign() {
if (!raw || !ref.current) return;
const p = providerById(raw.provider)!;
try {
const h = JSON.parse(raw.headers) as Record<string, string>;
h[p.header] = await signatureHeader(p, raw.body, Math.floor(ref.current.now / 1000));
setRaw({ ...raw, headers: JSON.stringify(h, null, 2) });
setRawError(null);
} catch {
setRawError("Headers must be a JSON object of strings.");
}
}
async function sendRaw(e: React.FormEvent) {
e.preventDefault();
if (!raw) return;
let headers: Record<string, string>;
try {
const parsed: unknown = JSON.parse(raw.headers);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
headers = Object.fromEntries(Object.entries(parsed as Record<string, unknown>).map(([k, v]) => [k.toLowerCase(), String(v)]));
} catch {
setRawError("Headers must be a JSON object of strings.");
return;
}
setRawError(null);
await send(raw.provider, { method: "POST", path: `/in/${raw.provider}`, headers, body: raw.body });
}
function reset() {
resetState();
seeded.current = false;
lastBody.current = {};
setInspect(null);
setSelected(null);
setRaw(null);
setRunning(true);
}
const events = useMemo(() => {
if (!state) return [];
const q = query.trim().toLowerCase();
return state.events.filter((e) => {
const dels = state.deliveries.filter((d) => d.eventId === e.id);
if (filter === "verified" && !e.verified) return false;
if (filter === "rejected" && e.verified) return false;
if (filter === "failing" && !dels.some((d) => d.status === "failed" || (d.status === "pending" && d.attempts.length > 0))) return false;
if (q && !`${e.id} ${e.type} ${e.providerEventId ?? ""} ${e.provider}`.toLowerCase().includes(q)) return false;
return true;
});
}, [state, filter, query]);
if (!state) return <div className="p-6 font-mono text-sm text-fg-muted">starting relay…</div>;
const p = providerById(provider)!;
const sel = state.events.find((e) => e.id === selected) ?? null;
const due = nextDueAt(state);
return (
<div className="space-y-5 p-4 sm:p-6">
<div className="flex flex-wrap items-center gap-3 rounded-md border border-border bg-bg-elev p-3 font-mono text-xs">
<span>
clock <span className="text-accent">{fmtClock(state.now)} UTC</span>
</span>
<span className="text-fg-muted">1 s real = 1 min simulated</span>
<button type="button" className={btn} onClick={() => setRunning((r) => !r)} aria-pressed={running}>
{running ? "❚❚ pause" : "▶ run"}
</button>
<button type="button" className={btn} disabled={due === null} onClick={() => due !== null && apply((s) => tick(s, Math.max(0, due - s.now)))}>
⏭ skip to next retry{due !== null ? ` (${fmtDuration(Math.max(0, due - state.now))})` : ""}
</button>
<button type="button" className={`${btn} ml-auto`} onClick={reset}>
reset demo
</button>
</div>
<div className="grid gap-5 lg:grid-cols-[22rem_1fr]">
<div className="space-y-5">
<form onSubmit={onFire} className="space-y-3 rounded-md border border-border p-3">
<PanelTitle>fire a sample webhook</PanelTitle>
<label className={label}>
provider
<select
className={`${input} mt-1`}
value={provider}
onChange={(e) => {
const id = e.target.value as ProviderId;
setProvider(id);
setEventType(providerById(id)!.events[0]!);
}}
>
{PROVIDERS.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<label className={label}>
event
<select className={`${input} mt-1`} value={eventType} onChange={(e) => setEventType(e.target.value)}>
{p.events.map((ev) => (
<option key={ev}>{ev}</option>
))}
</select>
</label>
<fieldset className="space-y-1 text-xs">
<legend className="mb-1 font-medium text-fg-muted">break it</legend>
{[
[tamper, setTamper, "tamper with the body after signing"],
[wrongSecret, setWrongSecret, "sign with the wrong secret"],
[stale, setStale, "timestamp 15 min old (Paylane only)"],
[duplicate, setDuplicate, "resend the previous event (same id)"],
].map(([v, set, text]) => (
<label key={text as string} className="flex items-center gap-2">
<input type="checkbox" checked={v as boolean} onChange={(e) => (set as (b: boolean) => void)(e.target.checked)} />
{text as string}
</label>
))}
</fieldset>
<p className="font-mono text-[11px] text-fg-muted">
secret: {mask(p.secret)} · header: {p.header}
</p>
<div className="flex flex-wrap gap-2">
<button type="submit" className={btnPrimary} disabled={busy}>
{busy ? "Sending…" : `POST /in/${p.id}`}
</button>
<button type="button" className={btn} onClick={openRaw}>
edit raw request
</button>
</div>
</form>
<section className="rounded-md border border-border p-3" aria-label="Destinations">
<PanelTitle>destinations</PanelTitle>
<ul className="space-y-2">
{state.destinations.map((d) => {
const dels = state.deliveries.filter((x) => x.destinationId === d.id);
const ok = dels.filter((x) => x.status === "delivered").length;
const retrying = dels.filter((x) => x.status === "pending" && x.attempts.length > 0).length;
const failed = dels.filter((x) => x.status === "failed").length;
return (
<li key={d.id} className="rounded border border-border p-2 text-xs">
<div className="flex items-center gap-2">
<span className={`size-2 rounded-full ${d.health === "healthy" ? "bg-accent" : d.health === "flaky" ? "bg-warn-fg" : "bg-danger"}`} aria-hidden="true" />
<span className="font-mono">{d.name}</span>
<select
aria-label={`Health of ${d.name}`}
value={d.health}
onChange={(e) => apply((s) => setHealth(s, d.id, e.target.value as Health))}
className="ml-auto rounded border border-border bg-bg-elev px-1 font-mono"
>
<option value="healthy">healthy</option>
<option value="flaky">flaky (50%)</option>
<option value="down">down (503)</option>
</select>
</div>
<p className="mt-1 truncate font-mono text-[11px] text-fg-muted">{d.url}</p>
<p className="mt-1 font-mono text-[11px] text-fg-muted">
subscribes: {d.subscribes.join(", ")} · <span className="text-accent">✓{ok}</span> <span className="text-warn-fg">↻{retrying}</span> <span className="text-danger">✗{failed}</span>
</p>
</li>
);
})}
</ul>
<p className="mt-2 text-[11px] text-fg-muted">Retry schedule: {RETRY_SCHEDULE_MS.map(fmtDuration).join(" → ")}, then failed.</p>
</section>
</div>
<div className="min-w-0 space-y-5">
{raw && (
<form onSubmit={sendRaw} className="space-y-2 rounded-md border border-accent/40 p-3">
<PanelTitle
right={
<button type="button" className={btn} onClick={() => setRaw(null)}>
close
</button>
}
>
raw request console
</PanelTitle>
<label className={label}>
POST /in/
<select className={`${input} mt-1`} value={raw.provider} onChange={(e) => setRaw({ ...raw, provider: e.target.value as ProviderId })}>
{PROVIDERS.map((x) => (
<option key={x.id} value={x.id}>
{x.id}
</option>
))}
</select>
</label>
<div className="grid gap-2 md:grid-cols-2">
<label className={label}>
headers (JSON)
<textarea className={`${input} mt-1 h-36 font-mono text-[11px]`} value={raw.headers} onChange={(e) => setRaw({ ...raw, headers: e.target.value })} spellCheck={false} />
</label>
<label className={label}>
raw body (signed byte for byte)
<textarea className={`${input} mt-1 h-36 font-mono text-[11px]`} value={raw.body} onChange={(e) => setRaw({ ...raw, body: e.target.value })} spellCheck={false} />
</label>
</div>
{rawError && (
<p role="alert" className="text-xs text-danger">
{rawError}
</p>
)}
<div className="flex flex-wrap gap-2">
<button type="submit" className={btnPrimary}>
Send
</button>
<button type="button" className={btn} onClick={resign}>
re-sign with correct secret
</button>
<span className="self-center text-[11px] text-fg-muted">Edit one character of the body and send again to see the signature fail.</span>
</div>
</form>
)}
<section aria-label="Last inbound request" aria-live="polite" className="rounded-md border border-border p-3">
<PanelTitle right={inspect && <Pill tone={inspect.res.status === 202 ? "ok" : inspect.res.status === 200 ? "warn" : "err"}>{inspect.res.status}</Pill>}>
last inbound request
</PanelTitle>
{inspect ? (
<div className="grid gap-3 md:grid-cols-2">
<div className="min-w-0 space-y-1 text-xs">
<p className="font-mono">
{inspect.req.method} {inspect.req.path}
</p>
<ol className="space-y-1">
<Step ok={!!inspect.v.received}>signature header present</Step>
<Step ok={inspect.v.ok || inspect.v.reason !== "signature mismatch"} pending={!inspect.v.received}>
HMAC-SHA256 matches (constant-time compare)
</Step>
{inspect.v.timestamp !== null && <Step ok={!inspect.v.reason?.startsWith("timestamp")}>timestamp within 5 min</Step>}
<Step ok={inspect.res.status !== 200} pending={!inspect.v.ok}>
event id not seen before
</Step>
</ol>
<dl className="mt-2 space-y-1 font-mono text-[11px] break-all">
<dt className="text-fg-muted">expected</dt>
<dd>{inspect.v.expected ?? "—"}</dd>
<dt className="text-fg-muted">received</dt>
<dd className={inspect.v.ok ? "text-accent" : "text-danger"}>{inspect.v.received ?? "—"}</dd>
</dl>
</div>
<pre className="max-h-56 overflow-auto rounded bg-[#0b0b0d] p-2 font-mono text-[11px] text-[#a1a1aa]">
{`HTTP/1.1 ${inspect.res.status}\n`}
{JSON.stringify(inspect.res.body, null, 2)}
</pre>
</div>
) : (
<p className="text-xs text-fg-muted">Fire a webhook to see each verification step.</p>
)}
</section>
<section aria-label="Event log" className="rounded-md border border-border p-3">
<PanelTitle
right={
<span className="flex flex-wrap gap-2">
<label className="sr-only" htmlFor="wr-filter">
Filter events
</label>
<select id="wr-filter" value={filter} onChange={(e) => setFilter(e.target.value as Filter)} className="rounded border border-border bg-bg-elev px-1 font-mono text-xs">
<option value="all">all</option>
<option value="verified">verified</option>
<option value="rejected">rejected</option>
<option value="failing">failing deliveries</option>
</select>
<input aria-label="Search events" placeholder="search id or type" value={query} onChange={(e) => setQuery(e.target.value)} className="w-40 rounded border border-border bg-bg-elev px-2 font-mono text-xs" />
</span>
}
>
event log
</PanelTitle>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<caption className="sr-only">Stored webhook events</caption>
<thead className="font-mono text-fg-muted">
<tr className="border-b border-border">
<th className="py-1.5 pr-2 font-normal">received</th>
<th className="py-1.5 pr-2 font-normal">event</th>
<th className="py-1.5 pr-2 font-normal">type</th>
<th className="py-1.5 pr-2 font-normal">verified</th>
<th className="py-1.5 font-normal">deliveries</th>
</tr>
</thead>
<tbody>
{events.map((e) => {
const dels = state.deliveries.filter((d) => d.eventId === e.id);
return (
<tr key={e.id} className={`border-b border-border ${selected === e.id ? "bg-accent-soft/60" : ""}`}>
<td className="py-1.5 pr-2 font-mono">{fmtClock(e.receivedAt)}</td>
<td className="py-1.5 pr-2 font-mono">
<button type="button" className="hover:text-accent" onClick={() => setSelected(e.id)}>
{e.id}
</button>
{e.duplicates > 0 && <span className="ml-1 text-warn-fg">+{e.duplicates} dup</span>}
</td>
<td className="py-1.5 pr-2 font-mono">{e.type}</td>
<td className="py-1.5 pr-2">{e.verified ? <Pill tone="ok">yes</Pill> : <Pill tone="err">no</Pill>}</td>
<td className="py-1.5 font-mono whitespace-nowrap">
{dels.map((d) => (
<span key={d.id} title={`${d.destinationId}: ${d.status}`} className={`mr-1 ${d.status === "delivered" ? "text-accent" : d.status === "failed" ? "text-danger" : d.attempts.length ? "text-warn-fg" : "text-fg-muted"}`}>
{d.status === "delivered" ? "●" : d.status === "failed" ? "✗" : "○"}
</span>
))}
{!dels.length && <span className="text-fg-muted">—</span>}
</td>
</tr>
);
})}
{!events.length && (
<tr>
<td colSpan={5} className="py-4 text-center text-fg-muted">
No events match.
</td>
</tr>
)}
</tbody>
</table>
</div>
</section>
{sel && (
<section aria-label="Event details" className="rounded-md border border-border p-3">
<PanelTitle
right={
<span className="flex gap-1">
<button type="button" className={btn} disabled={!sel.verified} onClick={() => apply((s) => replay(s, sel.id))}>
replay to all
</button>
<button type="button" className={btn} onClick={() => setSelected(null)}>
close
</button>
</span>
}
>
<span className="text-accent">{sel.id}</span> · {sel.provider}
</PanelTitle>
{!sel.verified && <p className="mb-2 text-xs text-danger">Rejected: {sel.reason}. Stored for auditing, never forwarded, cannot be replayed.</p>}
<div className="grid gap-3 md:grid-cols-2">
<div className="min-w-0 space-y-2">
<pre className="max-h-48 overflow-auto rounded bg-bg-muted p-2 font-mono text-[11px]">{JSON.stringify(maskHeaders(sel.headers), null, 2)}</pre>
<pre className="max-h-48 overflow-auto rounded bg-bg-muted p-2 font-mono text-[11px]">{pretty(sel.body)}</pre>
</div>
<ul className="space-y-2 text-xs">
{state.deliveries
.filter((d) => d.eventId === sel.id)
.map((d) => {
const dest = state.destinations.find((x) => x.id === d.destinationId);
return (
<li key={d.id} className="rounded border border-border p-2">
<p className="flex flex-wrap items-center gap-2 font-mono">
{dest?.name}
{d.replay && <Pill tone="info">replay</Pill>}
<Pill tone={d.status === "delivered" ? "ok" : d.status === "failed" ? "err" : "warn"}>{d.status}</Pill>
{d.status === "pending" && d.nextAttemptAt !== null && <span className="text-fg-muted">next {fmtClock(d.nextAttemptAt)}</span>}
</p>
<ol className="mt-1 space-y-0.5 font-mono text-[11px] text-fg-muted">
{d.attempts.map((a, i) => (
<li key={i}>
#{i + 1} {fmtClock(a.at)}{" "}
<span className={a.status >= 200 && a.status < 300 ? "text-accent" : "text-danger"}>{a.status || "ERR"}</span> {a.error ?? ""} ({a.ms} ms)
</li>
))}
{!d.attempts.length && <li>queued</li>}
</ol>
{d.status === "failed" && (
<button type="button" className={`${btn} mt-1`} onClick={() => apply((s) => replay(s, sel.id, d.destinationId))}>
replay to {dest?.name}
</button>
)}
</li>
);
})}
</ul>
</div>
</section>
)}
<section aria-label="Relay log">
<PanelTitle>relay log</PanelTitle>
<TerminalLog lines={state.log.slice(0, 60).map((l) => ({ id: l.id, tone: l.tone, text: `${fmtClock(l.at)} ${l.text}` }))} label="Relay and delivery worker events" className="h-60" />
</section>
</div>
</div>
</div>
);
}
function Step({ ok, pending, children }: { ok: boolean; pending?: boolean; children: React.ReactNode }) {
return (
<li className={`flex gap-2 ${pending ? "text-fg-muted" : ok ? "text-accent" : "text-danger"}`}>
<span aria-hidden="true">{pending ? "·" : ok ? "✓" : "✗"}</span>
<span>
{children}
<span className="sr-only">{pending ? " (skipped)" : ok ? " (passed)" : " (failed)"}</span>
</span>
</li>
);
}
function pretty(body: string) {
try {
return JSON.stringify(JSON.parse(body), null, 2);
} catch {
return body;
}
}components/lab/systems/ui.tsx175 lines
"use client";
/**
* Small shared pieces for the systems/backend lab demos (job queue, webhook relay, uploads, chat).
* Theme-aware controls use tokens; terminal panels are always dark and use fixed colours.
*/
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
export const btn =
"inline-flex items-center justify-center gap-1.5 rounded-md border border-border px-2.5 py-1 font-mono text-xs hover:border-accent hover:text-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:text-inherit";
export const btnPrimary =
"inline-flex items-center justify-center gap-1.5 rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-fg hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50";
export const btnDanger =
"inline-flex items-center justify-center gap-1.5 rounded-md border border-border px-2.5 py-1 font-mono text-xs hover:border-danger hover:text-danger disabled:opacity-50";
export const input =
"w-full rounded-md border border-border bg-bg-elev px-2.5 py-1.5 text-sm outline-none focus:border-accent";
export const label = "block text-xs font-medium text-fg-muted";
export type Tone = "ok" | "warn" | "err" | "info" | "dim" | "accent";
export type LogLine = { id: number; text: string; tone: Tone };
export const TONE: Record<Tone, string> = {
ok: "text-[#4ade80]",
warn: "text-[#fbbf24]",
err: "text-[#f87171]",
info: "text-[#a1a1aa]",
dim: "text-[#71717a]",
accent: "text-[#60a5fa]",
};
/** Always-dark request/event log. Newest line first. */
export function TerminalLog({
lines,
label: name,
empty = "$ waiting for events…",
className = "h-56",
}: {
lines: LogLine[];
label: string;
empty?: string;
className?: string;
}) {
return (
<ol
role="log"
aria-live="polite"
aria-label={name}
className={`overflow-y-auto rounded-md border border-border bg-[#0b0b0d] p-3 font-mono text-[12px] leading-relaxed ${className}`}
>
{lines.length === 0 && <li className="text-[#71717a]">{empty}</li>}
{lines.map((l) => (
<li key={l.id} className={`break-words ${TONE[l.tone]}`}>
{l.text}
</li>
))}
</ol>
);
}
/** Log buffer with stable ids (no Date.now in render). */
export function useLog(max = 80) {
const [lines, setLines] = useState<LogLine[]>([]);
const seq = useRef(0);
const push = useCallback(
(text: string, tone: Tone = "info") => {
seq.current += 1;
const id = seq.current;
setLines((l) => [{ id, text, tone }, ...l].slice(0, max));
},
[max],
);
const clear = useCallback(() => setLines([]), []);
return { lines, push, clear };
}
/**
* State persisted to localStorage under `key`. The initial render uses `null` (server and first client
* render match); the stored value or the seed is loaded in an effect, so it is hydration-safe.
*/
export function useStoredState<T>(key: string, seed: () => T, isValid: (v: unknown) => boolean = () => true) {
const [state, setState] = useState<T | null>(null);
const seedRef = useRef(seed);
const validRef = useRef(isValid);
useEffect(() => {
let loaded: T | null = null;
try {
const raw = localStorage.getItem(key);
if (raw) {
const parsed: unknown = JSON.parse(raw);
if (validRef.current(parsed)) loaded = parsed as T;
}
} catch {
/* storage blocked or corrupted: fall back to the seed */
}
setState(loaded ?? seedRef.current());
}, [key]);
// Throttled save (at most every 750 ms) so a fast simulation does not write on every tick,
// but a simulation that changes state constantly still gets saved.
const latest = useRef<T | null>(null);
const timer = useRef<number | null>(null);
useEffect(() => {
latest.current = state;
if (state === null || timer.current !== null) return;
timer.current = window.setTimeout(() => {
timer.current = null;
try {
localStorage.setItem(key, JSON.stringify(latest.current));
} catch {
/* quota exceeded or storage blocked: the demo still works in memory */
}
}, 750);
}, [key, state]);
useEffect(
() => () => {
if (timer.current !== null) window.clearTimeout(timer.current);
},
[],
);
const reset = useCallback(() => {
try {
localStorage.removeItem(key);
} catch {
/* ignore */
}
setState(seedRef.current());
}, [key]);
return [state, setState, reset] as const;
}
/** setInterval that always calls the latest callback. `ms = null` stops it. */
export function useInterval(cb: () => void, ms: number | null) {
const ref = useRef(cb);
useEffect(() => {
ref.current = cb;
}, [cb]);
useEffect(() => {
if (ms === null) return;
const id = window.setInterval(() => ref.current(), ms);
return () => window.clearInterval(id);
}, [ms]);
}
/** Section heading used inside demos. */
export function PanelTitle({ children, right }: { children: ReactNode; right?: ReactNode }) {
return (
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<h3 className="font-mono text-sm font-semibold">{children}</h3>
{right}
</div>
);
}
/** Small status pill with token colours. */
export function Pill({ tone, children }: { tone: "ok" | "warn" | "err" | "muted" | "info"; children: ReactNode }) {
const cls = {
ok: "border-accent/40 bg-accent-soft text-accent",
warn: "border-warn-fg/40 bg-warn-bg text-warn-fg",
err: "border-danger/40 text-danger",
muted: "border-border text-fg-muted",
info: "border-accent-2/40 text-accent-2",
}[tone];
return <span className={`inline-flex items-center rounded-full border px-2 py-0.5 font-mono text-[11px] ${cls}`}>{children}</span>;
}
export function formatBytes(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}