Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 25 additions & 6 deletions app/dashboard/[[...tab]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { useParams } from "next/navigation";
import { copyText } from "@/lib/clipboard";
import { formatStoredWebhookPayload } from "@/lib/webhook-payload";
import {
filterSignupsByQuery,
filterSignupsByStatus,
Expand Down Expand Up @@ -968,12 +969,30 @@ function DomainWebhooksPanel({ onError, onOk }: { onError: (m: string) => void;
<h3 className="ed-h">Recent inbound events ({data.events?.length || 0})</h3>
<ul className="list">
{(!data.events || data.events.length === 0) && <li className="muted">Nothing received yet.</li>}
{data.events?.map((e: any) => (
<li key={e.id}>
<span><b>{e.event_type || "event"}</b> <span className="muted">{e.source ? `· ${String(e.source).slice(0, 40)}` : ""}</span></span>
<span className="muted">{e.created_at}</span>
</li>
))}
{data.events?.map((e: any) => {
const payload = formatStoredWebhookPayload(e.payload);
return (
<li key={e.id} style={{ alignItems: "flex-start", flexWrap: "wrap" }}>
<span><b>{e.event_type || "event"}</b> <span className="muted">{e.source ? `· ${String(e.source).slice(0, 40)}` : ""}</span></span>
<span className="muted">{e.created_at}</span>
<details style={{ flexBasis: "100%" }}>
<summary style={{ cursor: "pointer" }}>Inspect payload</summary>
<pre style={{ margin: "8px 0", overflowX: "auto", whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{payload}</pre>
<button
type="button"
className="btn2 ghost"
onClick={async () => {
const copied = await copyText(payload);
if (copied) onOk("Payload copied. 🤘");
else onError("Copy failed — select the payload and copy it manually.");
}}
>
Copy payload
</button>
</details>
</li>
);
})}
</ul>
</>
)}
Expand Down
72 changes: 72 additions & 0 deletions lib/webhook-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
function previousNonWhitespace(value: string, from: number): string | null {
for (let index = from; index >= 0; index--) {
if (!/\s/.test(value[index])) return value[index];
}
return null;
}

function nextNonWhitespace(value: string, from: number): string | null {
for (let index = from; index < value.length; index++) {
if (!/\s/.test(value[index])) return value[index];
}
return null;
}

const MAX_PRETTY_DEPTH = 32;
const MAX_FORMATTED_LENGTH = 64 * 1024;

/** Pretty-print valid JSON without reparsing number literals into JavaScript numbers. */
export function formatStoredWebhookPayload(value: unknown): string {
const raw = typeof value === "string" ? value : String(value ?? "");
try {
JSON.parse(raw);
} catch {
return raw;
}

let formatted = "";
let indent = 0;
let inString = false;
let escaped = false;
const pad = () => " ".repeat(indent);
const outputLimit = Math.min(MAX_FORMATTED_LENGTH, Math.max(4096, raw.length * 4));
const append = (text: string): boolean => {
formatted += text;
return formatted.length <= outputLimit;
};

for (let index = 0; index < raw.length; index++) {
const char = raw[index];
if (inString) {
if (!append(char)) return raw;
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') inString = false;
continue;
}

if (char === '"') {
inString = true;
if (!append(char)) return raw;
} else if (char === "{" || char === "[") {
if (!append(char)) return raw;
indent++;
if (indent > MAX_PRETTY_DEPTH) return raw;
const closing = char === "{" ? "}" : "]";
if (nextNonWhitespace(raw, index + 1) !== closing && !append(`\n${pad()}`)) return raw;
} else if (char === "}" || char === "]") {
indent--;
const opening = char === "}" ? "{" : "[";
if (previousNonWhitespace(raw, index - 1) !== opening && !append(`\n${pad()}`)) return raw;
if (!append(char)) return raw;
} else if (char === ",") {
if (!append(`,\n${pad()}`)) return raw;
} else if (char === ":") {
if (!append(": ")) return raw;
} else if (!/\s/.test(char)) {
if (!append(char)) return raw;
}
}

return formatted;
}
54 changes: 54 additions & 0 deletions tests/webhook-payload.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";

import { formatStoredWebhookPayload } from "../lib/webhook-payload.ts";

test("stored JSON webhook payloads are formatted for inspection and copying", () => {
assert.equal(
formatStoredWebhookPayload('{"event":"payment.succeeded","data":{"amount":100,"paid":true}}'),
`{
"event": "payment.succeeded",
"data": {
"amount": 100,
"paid": true
}
}`,
);
});

test("non-JSON webhook payloads fall back to the stored body verbatim", () => {
const raw = "payment.succeeded\namount=100&paid=true";
assert.equal(formatStoredWebhookPayload(raw), raw);
assert.equal(formatStoredWebhookPayload(""), "");
});

test("JSON formatting preserves number literals exactly", () => {
const raw = '{"id":9007199254740993,"ratio":0.1234567890123456789,"empty":[]}';
const formatted = formatStoredWebhookPayload(raw);

assert.match(formatted, /9007199254740993/);
assert.match(formatted, /0\.1234567890123456789/);
assert.equal(formatted, `{
"id": 9007199254740993,
"ratio": 0.1234567890123456789,
"empty": []
}`);
});

test("JSON formatting preserves escaped strings and duplicate keys", () => {
const raw = '{"text":"comma, colon: braces {} [\\"quoted\\"]","key":1,"key":2}';

assert.equal(formatStoredWebhookPayload(raw), `{
"text": "comma, colon: braces {} [\\"quoted\\"]",
"key": 1,
"key": 2
}`);
});

test("deep JSON falls back to raw text instead of amplifying indentation", () => {
const raw = `${"[".repeat(2000)}0${"]".repeat(2000)}`;
const formatted = formatStoredWebhookPayload(raw);

assert.equal(formatted, raw);
assert.equal(formatted.length, 4001);
});
Loading