Skip to content

Commit c45580c

Browse files
committed
fix(webhooks): address wyattjoh review feedback
- Replace --headers (comma-separated) with repeatable -H/--header flag so duplicate Set-Cookie headers are preserved end-to-end via Headers.append() - Strip hop-by-hop headers (host, connection, transfer-encoding, etc.) before forwarding so the request looks like it originated at the forward target - Add .catch() on onTokenRotated() chain to prevent unhandled promise rejection - Use Promise.withResolvers<void>() in RelayClient.start() and webhooksListen - Add SIGINT double-press re-entrancy guard and 2s drain timeout in listen - Upgrade dropped-delivery log from debug to warn - Add IRelayClient interface so FakeRelayClient in tests can use implements - Rename cliSigintHandler to CLI_SIGINT_HANDLER (global handler naming convention) - Reword "Pin it:" to "Use this token:" for clarity
1 parent 51fa3ac commit c45580c

10 files changed

Lines changed: 146 additions & 102 deletions

File tree

packages/cli-core/src/cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
#!/usr/bin/env bun
22
import { createProgram, runProgram } from "./cli-program.ts";
3-
import { cliSigintHandler } from "./lib/signals.ts";
3+
import { CLI_SIGINT_HANDLER } from "./lib/signals.ts";
44
// Named handler (not an inline arrow) so `webhooks listen` can removeListener it
55
// to install its own graceful-drain SIGINT handling.
6-
process.on("SIGINT", cliSigintHandler);
6+
process.on("SIGINT", CLI_SIGINT_HANDLER);
77

88
// Fast path for shell completion — intercept before Commander parses
99
// to avoid validation errors on partial input from Tab presses.

packages/cli-core/src/commands/webhooks/forward.test.ts

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,29 @@
11
import { test, expect, describe, afterEach } from "bun:test";
22
import { CliError } from "../../lib/errors.ts";
33
import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts";
4-
import { buildForwardHeaders, forwardDelivery, parseHeaderPairs } from "./forward.ts";
4+
import { buildForwardHeaders, forwardDelivery, parseHeaderFlag } from "./forward.ts";
55

66
const originalFetch = globalThis.fetch;
77

8-
describe("parseHeaderPairs", () => {
9-
const parseCases: Array<{
10-
label: string;
11-
value: string | undefined;
12-
expected: Record<string, string>;
13-
}> = [
14-
{ label: "undefined", value: undefined, expected: {} },
15-
{ label: "empty string", value: "", expected: {} },
16-
{ label: "single pair", value: "x-env:dev", expected: { "x-env": "dev" } },
17-
{
18-
label: "multiple pairs with whitespace",
19-
value: " x-env : dev , x-team:core ",
20-
expected: { "x-env": "dev", "x-team": "core" },
21-
},
8+
describe("parseHeaderFlag", () => {
9+
test.each<{ label: string; value: string; expected: [string, string] }>([
10+
{ label: "simple pair", value: "x-env:dev", expected: ["x-env", "dev"] },
2211
{
2312
label: "value containing colons (split on FIRST colon)",
2413
value: "authorization:Bearer abc:def",
25-
expected: { authorization: "Bearer abc:def" },
14+
expected: ["authorization", "Bearer abc:def"],
2615
},
27-
{ label: "trailing comma", value: "x-env:dev,", expected: { "x-env": "dev" } },
28-
{ label: "empty value", value: "x-empty:", expected: { "x-empty": "" } },
29-
];
30-
31-
test.each(parseCases)("parses $label", ({ value, expected }) => {
32-
expect(parseHeaderPairs(value)).toEqual(expected);
16+
{ label: "empty value", value: "x-empty:", expected: ["x-empty", ""] },
17+
{ label: "trims whitespace", value: " x-env : dev ", expected: ["x-env", "dev"] },
18+
])("parses $label", ({ value, expected }) => {
19+
expect(parseHeaderFlag(value)).toEqual(expected);
3320
});
3421

3522
test.each([
3623
{ label: "pair without a colon", value: "not-a-pair" },
3724
{ label: "pair with an empty key", value: ":value" },
3825
])("throws a usage error on $label", ({ value }) => {
39-
expect(() => parseHeaderPairs(value)).toThrow(CliError);
26+
expect(() => parseHeaderFlag(value)).toThrow(CliError);
4027
});
4128
});
4229

@@ -49,15 +36,17 @@ describe("buildForwardHeaders", () => {
4936
};
5037

5138
test("preserves delivery headers and adds extras", () => {
52-
const headers = buildForwardHeaders(eventHeaders, { "x-env": "dev" });
39+
const extra = new Headers({ "x-env": "dev" });
40+
const headers = buildForwardHeaders(eventHeaders, extra);
5341

5442
expect(headers.get("svix-id")).toBe("msg_1");
5543
expect(headers.get("content-type")).toBe("application/json");
5644
expect(headers.get("x-env")).toBe("dev");
5745
});
5846

5947
test("extras may override non-svix delivery headers", () => {
60-
const headers = buildForwardHeaders(eventHeaders, { "Content-Type": "text/plain" });
48+
const extra = new Headers({ "Content-Type": "text/plain" });
49+
const headers = buildForwardHeaders(eventHeaders, extra);
6150

6251
expect(headers.get("content-type")).toBe("text/plain");
6352
});
@@ -67,10 +56,35 @@ describe("buildForwardHeaders", () => {
6756
{ label: "uppercase", key: "SVIX-SIGNATURE" },
6857
{ label: "mixed case", key: "Svix-Signature" },
6958
])("extras can never override svix-* headers ($label)", ({ key }) => {
70-
const headers = buildForwardHeaders(eventHeaders, { [key]: "v1,forged" });
59+
const extra = new Headers({ [key]: "v1,forged" });
60+
const headers = buildForwardHeaders(eventHeaders, extra);
7161

7262
expect(headers.get("svix-signature")).toBe("v1,abc");
7363
});
64+
65+
test("strips hop-by-hop headers from event headers", () => {
66+
const withHopByHop = {
67+
...eventHeaders,
68+
host: "svix-relay.example.com",
69+
connection: "keep-alive",
70+
"transfer-encoding": "chunked",
71+
};
72+
const headers = buildForwardHeaders(withHopByHop, new Headers());
73+
74+
expect(headers.has("host")).toBe(false);
75+
expect(headers.has("connection")).toBe(false);
76+
expect(headers.has("transfer-encoding")).toBe(false);
77+
expect(headers.get("svix-id")).toBe("msg_1");
78+
});
79+
80+
test("allows duplicate extra headers via append", () => {
81+
const extra = new Headers();
82+
extra.append("x-custom", "first");
83+
extra.append("x-custom", "second");
84+
const headers = buildForwardHeaders({}, extra);
85+
86+
expect(headers.get("x-custom")).toBe("first, second");
87+
});
7488
});
7589

7690
describe("forwardDelivery", () => {
@@ -95,7 +109,7 @@ describe("forwardDelivery", () => {
95109
const outcome = await forwardDelivery({
96110
forwardTo: "http://localhost:3000/api/webhooks",
97111
method: "POST",
98-
headers: buildForwardHeaders({ "svix-id": "msg_1" }, {}),
112+
headers: buildForwardHeaders({ "svix-id": "msg_1" }, new Headers()),
99113
body: '{"type":"user.created"}',
100114
});
101115

packages/cli-core/src/commands/webhooks/forward.ts

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,39 +11,62 @@ export interface ForwardOutcome {
1111
failed: boolean;
1212
}
1313

14-
/** Comma-separated `k:v` pairs, split on the FIRST colon, whitespace trimmed. */
15-
export function parseHeaderPairs(value: string | undefined): Record<string, string> {
16-
if (!value) return {};
17-
const headers: Record<string, string> = {};
18-
for (const pair of value.split(",")) {
19-
const trimmed = pair.trim();
20-
if (!trimmed) continue;
21-
const colonIndex = trimmed.indexOf(":");
22-
const key = colonIndex === -1 ? "" : trimmed.slice(0, colonIndex).trim();
23-
if (!key) {
24-
throwUsageError(
25-
`Invalid --headers pair "${trimmed}". Expected key:value. Commas separate pairs and can't appear in values — use multiple --headers flags instead.`,
26-
);
27-
}
28-
headers[key] = trimmed.slice(colonIndex + 1).trim();
14+
/**
15+
* Hop-by-hop headers that must not be forwarded to the target. These are
16+
* meaningful only for the single transport hop and would confuse the target
17+
* server or cause protocol violations (e.g. chunked encoding mismatch).
18+
*/
19+
const HOP_BY_HOP_HEADERS = new Set([
20+
"connection",
21+
"keep-alive",
22+
"proxy-authenticate",
23+
"proxy-authorization",
24+
"te",
25+
"trailers",
26+
"transfer-encoding",
27+
"upgrade",
28+
"host",
29+
]);
30+
31+
/**
32+
* Parse one `--header` value (`key:value`, split on the FIRST colon, whitespace
33+
* trimmed) into a [key, value] pair. Throws a usage error on malformed input.
34+
*/
35+
export function parseHeaderFlag(value: string): [string, string] {
36+
const colonIndex = value.indexOf(":");
37+
const key = colonIndex === -1 ? "" : value.slice(0, colonIndex).trim();
38+
if (!key) {
39+
throwUsageError(`Invalid --header "${value}". Expected key:value (e.g. --header x-env:dev).`);
2940
}
30-
return headers;
41+
return [key, value.slice(colonIndex + 1).trim()];
3142
}
3243

3344
/**
34-
* Delivery headers plus `--headers` extras. Extras may override non-svix
35-
* delivery headers, but the delivery's `svix-*` headers always win — they are
36-
* what `verify` (and the user's handler) authenticate against.
45+
* Delivery headers plus `--header` extras, with hop-by-hop headers stripped.
46+
* Extras may override non-svix delivery headers, but the delivery's `svix-*`
47+
* headers always win — they are what `verify` (and the user's handler)
48+
* authenticate against.
3749
*/
3850
export function buildForwardHeaders(
3951
eventHeaders: Record<string, string>,
40-
extraHeaders: Record<string, string>,
52+
extraHeaders: Headers,
4153
): Headers {
42-
const headers = new Headers(eventHeaders);
43-
for (const [key, value] of Object.entries(extraHeaders)) {
44-
if (key.toLowerCase().startsWith("svix-")) continue;
54+
const headers = new Headers();
55+
for (const [key, value] of Object.entries(eventHeaders)) {
56+
if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
4557
headers.set(key, value);
4658
}
59+
const seenExtra = new Set<string>();
60+
for (const [key, value] of extraHeaders) {
61+
const lower = key.toLowerCase();
62+
if (lower.startsWith("svix-")) continue;
63+
if (seenExtra.has(lower)) {
64+
headers.append(key, value);
65+
} else {
66+
headers.set(key, value);
67+
seenExtra.add(lower);
68+
}
69+
}
4770
return headers;
4871
}
4972

packages/cli-core/src/commands/webhooks/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,9 @@ export function registerWebhooks(program: Program): void {
5353
"Pin the relay token so the inbox URL stays fixed across machines. Format: c_ + 10 base62 chars (generate with `clerk webhooks token`)",
5454
)
5555
.option(
56-
"--headers <pairs>",
57-
"Extra headers for the forwarded request, comma-separated k:v pairs; values can't contain commas (svix-* can't be overridden)",
56+
"-H, --header <key:value>",
57+
"Extra header for the forwarded request (key:value); repeat for multiple headers (svix-* can't be overridden)",
58+
(val: string, prev: string[] = []) => [...prev, val],
5859
)
5960
.option("--json", "Output as NDJSON (agent/pipe mode)")
6061
.setExamples([

packages/cli-core/src/commands/webhooks/listen.test.ts

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,18 @@
11
import { test, expect, describe, beforeEach, afterEach, mock, spyOn } from "bun:test";
22
import { ERROR_CODE } from "../../lib/errors.ts";
33
import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts";
4+
import type { IRelayClient, RelayClientOptions } from "./relay-client.ts";
45
import type { RelayEventFrame } from "./relay-protocol.ts";
56

6-
type EventHandler = (event: RelayEventFrame, reply: (frame: string) => void) => void;
7-
8-
interface FakeClientOptions {
9-
token: string;
10-
onEvent: EventHandler;
11-
onTokenRotated: (token: string) => Promise<void>;
12-
onReconnect: () => void;
13-
}
14-
157
const relayClients: FakeRelayClient[] = [];
168
const lastClient = () => relayClients.at(-1);
179

18-
class FakeRelayClient {
10+
class FakeRelayClient implements IRelayClient {
1911
token: string;
2012
started = false;
2113
stopped = false;
2214

23-
constructor(readonly options: FakeClientOptions) {
15+
constructor(readonly options: RelayClientOptions) {
2416
this.token = options.token;
2517
relayClients.push(this);
2618
}
@@ -110,8 +102,8 @@ describe("webhooks listen (V1, relay-only)", () => {
110102
mockIsAgent.mockReset();
111103
});
112104

113-
test("invalid --headers is a usage error before any persistence", async () => {
114-
await expect(webhooksListen({ headers: "not-a-pair" })).rejects.toMatchObject({
105+
test("invalid --header is a usage error before any persistence", async () => {
106+
await expect(webhooksListen({ header: ["not-a-pair"] })).rejects.toMatchObject({
115107
code: ERROR_CODE.USAGE_ERROR,
116108
});
117109
expect(mockGetRelayEntry).not.toHaveBeenCalled();
@@ -208,8 +200,8 @@ describe("webhooks listen (V1, relay-only)", () => {
208200
},
209201
);
210202

211-
test("svix-* in --headers warns at startup that it can't be overridden", async () => {
212-
await startListen({ headers: "svix-id:forged" }, captured);
203+
test("svix-* in --header warns at startup that it can't be overridden", async () => {
204+
await startListen({ header: ["svix-id:forged"] }, captured);
213205
expect(captured.err).toContain("can't be overridden");
214206
});
215207

@@ -226,7 +218,7 @@ describe("webhooks listen (V1, relay-only)", () => {
226218
});
227219

228220
await startListen(
229-
{ forwardTo: "http://localhost:3000/api/webhooks", headers: "x-env:dev" },
221+
{ forwardTo: "http://localhost:3000/api/webhooks", header: ["x-env:dev"] },
230222
captured,
231223
);
232224
captured.clear();

packages/cli-core/src/commands/webhooks/listen.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { getRelayEntry, setRelayEntry } from "../../lib/config.ts";
22
import { EXIT_CODE, errorMessage, throwUsageError } from "../../lib/errors.ts";
3-
import { cliSigintHandler } from "../../lib/signals.ts";
3+
import { CLI_SIGINT_HANDLER } from "../../lib/signals.ts";
44
import { withSpinner } from "../../lib/spinner.ts";
55
import { dim } from "../../lib/color.ts";
66
import { log } from "../../lib/log.ts";
77
import { isAgent } from "../../mode.ts";
8-
import { buildForwardHeaders, forwardDelivery, parseHeaderPairs } from "./forward.ts";
8+
import { buildForwardHeaders, forwardDelivery, parseHeaderFlag } from "./forward.ts";
99
import { RelayClient } from "./relay-client.ts";
1010
import {
1111
decodeEventBody,
@@ -27,7 +27,7 @@ import type { WebhooksGlobalOptions } from "./shared.ts";
2727

2828
export interface WebhooksListenOptions extends WebhooksGlobalOptions {
2929
forwardTo?: string;
30-
headers?: string;
30+
header?: string[];
3131
token?: string;
3232
}
3333

@@ -84,17 +84,18 @@ function forwardPath(forwardTo: string): string {
8484

8585
export async function webhooksListen(options: WebhooksListenOptions = {}): Promise<void> {
8686
const ndjson = Boolean(options.json) || isAgent();
87-
const extraHeaders = parseHeaderPairs(options.headers);
8887
const forwardTo = assertForwardTo(options.forwardTo);
8988

9089
if (options.token) assertRelayToken(options.token);
9190

92-
// svix-* headers can't be overridden (delivery headers always win) — warn once
93-
// at startup instead of silently dropping them.
94-
for (const key of Object.keys(extraHeaders)) {
91+
const extraHeaders = new Headers();
92+
for (const raw of options.header ?? []) {
93+
const [key, value] = parseHeaderFlag(raw);
9594
if (key.toLowerCase().startsWith("svix-")) {
96-
log.warn(`--headers: "${key}" can't be overridden — delivery svix-* headers always win.`);
95+
log.warn(`--header: "${key}" can't be overridden — delivery svix-* headers always win.`);
96+
continue;
9797
}
98+
extraHeaders.append(key, value);
9899
}
99100

100101
// Persist the token so the inbox URL stays stable across runs; --token pins
@@ -111,21 +112,26 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi
111112
// Deliveries can arrive the moment the relay handshake completes, but the
112113
// ready banner/line must print first. Gate processing until setup is done;
113114
// the SIGINT path also resolves it so the drain can never hang.
114-
let resolveSetupGate!: () => void;
115-
const setupGate = new Promise<void>((resolve) => {
116-
resolveSetupGate = resolve;
117-
});
115+
const { promise: setupGate, resolve: resolveSetupGate } = Promise.withResolvers<void>();
118116

119117
// Own SIGINT handling, registered BEFORE the socket opens. The global handler
120118
// (cli.ts) is a cleanup-free exit(130) and would fire first, so remove it:
121119
// close the socket, drain in-flight forwards, then exit 130.
122-
process.removeListener("SIGINT", cliSigintHandler);
120+
process.removeListener("SIGINT", CLI_SIGINT_HANDLER);
123121
process.on("SIGINT", () => {
122+
if (shuttingDown) {
123+
// Double Ctrl+C: force-quit immediately.
124+
process.exit(EXIT_CODE.SIGINT);
125+
}
124126
void (async () => {
125127
shuttingDown = true; // MUST precede resolveSetupGate so processDelivery short-circuits
126128
resolveSetupGate(); // gated deliveries must settle or the drain hangs
127129
client?.stop();
128-
await Promise.allSettled([...inFlight, ...(tokenRotationTask ? [tokenRotationTask] : [])]);
130+
const pending = [...inFlight, ...(tokenRotationTask ? [tokenRotationTask] : [])];
131+
await Promise.race([
132+
Promise.allSettled(pending),
133+
new Promise<void>((resolve) => setTimeout(resolve, 2_000)),
134+
]);
129135
process.exit(EXIT_CODE.SIGINT);
130136
})();
131137
});
@@ -180,7 +186,7 @@ export async function webhooksListen(options: WebhooksListenOptions = {}): Promi
180186
token,
181187
onEvent: (event, reply) => {
182188
const task = processDelivery(event, reply).catch((error) => {
183-
log.debug(`relay: delivery handling failed: ${errorMessage(error)}`);
189+
log.warn(`relay: delivery dropped: ${errorMessage(error)}`);
184190
});
185191
inFlight.add(task);
186192
void task.finally(() => inFlight.delete(task));

0 commit comments

Comments
 (0)