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
5 changes: 5 additions & 0 deletions .changeset/srvx-headers-node24.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/core": patch
---

Fix webhooks returning 404 on Nitro apps running Node 24, where serializing the request's headers failed.
36 changes: 36 additions & 0 deletions packages/core/src/serialization/hardened.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/serialization/hardened.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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][];
}
}

/**
Expand Down
Loading