diff --git a/.changeset/srvx-headers-node24.md b/.changeset/srvx-headers-node24.md new file mode 100644 index 0000000000..7e3c3fa420 --- /dev/null +++ b/.changeset/srvx-headers-node24.md @@ -0,0 +1,5 @@ +--- +"@workflow/core": patch +--- + +Fix webhooks returning 404 on Nitro apps running Node 24, where serializing the request's headers failed. diff --git a/packages/core/src/serialization/hardened.test.ts b/packages/core/src/serialization/hardened.test.ts index 7a792c77b4..9b909b841d 100644 --- a/packages/core/src/serialization/hardened.test.ts +++ b/packages/core/src/serialization/hardened.test.ts @@ -578,6 +578,42 @@ describe('hardened serialization: wire-format parity', () => { expect(revived.get('x-a')).toBe('1'); }); + // Shaped like srvx's NodeRequestHeaders (Nitro's incoming request + // headers): inherits from Headers.prototype and forwards to a wrapped + // native instance, but has no native header state of its own. + it('round-trips a Headers look-alike that wraps a native instance', () => { + class ForwardingHeaders { + #inner: Headers; + constructor(init: HeadersInit) { + this.#inner = new Headers(init); + } + get(name: string) { + return this.#inner.get(name); + } + entries() { + return this.#inner.entries(); + } + [Symbol.iterator]() { + return this.entries(); + } + } + Object.setPrototypeOf(ForwardingHeaders.prototype, Headers.prototype); + const headers = new ForwardingHeaders({ + 'content-type': 'application/json', + 'x-a': '1', + }) as unknown as Headers; + + const guestCodeStats: GuestCodeStats = { executions: [] }; + const bytes = devalueCodec.serialize(headers, 'step', { guestCodeStats }); + expect(guestCodeStats.executions).toEqual([ + { kind: 'method', detail: 'Headers[Symbol.iterator]' }, + ]); + + const revived = devalueCodec.deserialize(bytes, 'step') as Headers; + expect(revived).toBeInstanceOf(Headers); + expect([...revived]).toEqual([...headers]); + }); + // Error payloads carry realm-specific stack text (devalue stores the frames // as separate string elements), so these assert a structural round trip // rather than byte parity. diff --git a/packages/core/src/serialization/hardened.ts b/packages/core/src/serialization/hardened.ts index 41fb7075b0..9a0f16697f 100644 --- a/packages/core/src/serialization/hardened.ts +++ b/packages/core/src/serialization/hardened.ts @@ -439,13 +439,27 @@ export function urlSearchParamsToString(value: URLSearchParams): string { /** * Iterates a Headers instance through the captured host iterator, so the * iterator object, and its `next`, are host-realm. + * + * Some server runtimes hand out Headers look-alikes: objects that inherit + * from `Headers.prototype` and forward every method to a wrapped native + * instance (srvx, used by Nitro, does this for incoming request headers). + * They carry no native header state, so the host iterator rejects them with + * a TypeError on runtimes whose undici keeps that state in private fields + * (Node 24+). Those fall back to the object's own iterator, which runs + * non-intrinsic code and is recorded as such. */ export function headersToEntries(value: Headers): [string, string][] { if (isProxy(value)) { recordProxy(value); return Array.from(value) as [string, string][]; } - return [...headersIterator(value)] as [string, string][]; + try { + return [...headersIterator(value)] as [string, string][]; + } catch (error) { + if (!(error instanceof TypeError)) throw error; + recordGuestCode('method', 'Headers[Symbol.iterator]'); + return Array.from(value) as [string, string][]; + } } /**