Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
9fcc619
chore: upgrade monorepo to Node.js 24
jacekradko Apr 18, 2026
d50f674
chore: add changeset for Node 24 upgrade
jacekradko Apr 18, 2026
6d4945a
Merge branch 'main' into jacek/upgrade-to-node-24
jacekradko Apr 20, 2026
9bf6afa
Merge branch 'main' into jacek/upgrade-to-node-24
jacekradko Apr 20, 2026
711329c
Merge branch 'main' into jacek/upgrade-to-node-24
jacekradko Apr 21, 2026
d70ee79
fix(backend): clone Request without cross-realm AbortSignal on Node 24
jacekradko Apr 22, 2026
e9f8d1a
fix: pin Node to 24.15.0 and use Proxy to hide signal in clone
jacekradko Apr 22, 2026
8d74519
fix(react-router): omit signal from patchRequest clone for Node 24
jacekradko Apr 22, 2026
8619779
fix(backend): satisfy curly lint rule in ClerkRequest signal proxy
jacekradko Apr 22, 2026
b736bc2
ci(repo): pin minimum-supported Node 20.9.0 in unit-test matrix
jacekradko Apr 23, 2026
a14d934
ci(repo): bump unit-test Node 20 matrix to 20.19.0 for build toolchai…
jacekradko Apr 23, 2026
05d014c
Merge branch 'main' into jacek/upgrade-to-node-24
jacekradko Apr 23, 2026
7913812
Merge branch 'main' into jacek/upgrade-to-node-24
jacekradko Apr 24, 2026
844e18c
Delete .claude/scheduled_tasks.lock
jacekradko Apr 24, 2026
d0bffa3
Delete .changeset/giant-mirrors-find.md
jacekradko Apr 24, 2026
06f4e3c
fix(backend,tanstack-react-start): omit cross-realm AbortSignal in pr…
jacekradko Apr 25, 2026
878fbb8
Merge branch 'main' into jacek/upgrade-to-node-24
jacekradko Apr 25, 2026
7f3767c
Merge remote-tracking branch 'origin/main' into jacek/upgrade-to-node-24
jacekradko Apr 27, 2026
02b5a08
fix(tanstack-react-start): bridge AbortSignal in patchRequest via loc…
jacekradko Apr 27, 2026
b24182f
fix(tanstack-react-start): keep signal omitted in patchRequest under …
jacekradko Apr 27, 2026
302a2ae
test(tanstack-react-start): drop unworkable signal-forward regression…
jacekradko Apr 27, 2026
e967b12
Merge branch 'main' into jacek/upgrade-to-node-24
jacekradko Apr 28, 2026
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
11 changes: 11 additions & 0 deletions .changeset/node24-clerk-request-signal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@clerk/backend': patch
'@clerk/react-router': patch
'@clerk/tanstack-react-start': patch
---

Fix `Request` cloning and outbound `fetch` to omit cross-realm `AbortSignal`. Node 24's bundled undici tightened the `instanceof AbortSignal` check on `RequestInit.signal`, which broke:

- Cloning framework-specific requests such as `NextRequest` in `@clerk/backend`'s `ClerkRequest`.
- Subclassed `Request`s passed through `patchRequest` in `@clerk/react-router` and `@clerk/tanstack-react-start`.
- Frontend API proxying in `@clerk/backend`'s `clerkFrontendApiProxy`, which forwarded the inbound request's signal to the upstream `fetch`. Abort propagation will be restored in a follow-up via an in-realm `AbortController` bridge.
2 changes: 1 addition & 1 deletion .github/actions/init-blacksmith/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ inputs:
node-version:
description: 'The node version to use'
required: false
default: '22'
default: '24.15.0'
playwright-enabled:
description: 'Enable Playwright?'
required: false
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/init/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ inputs:
node-version:
description: 'The node version to use'
required: false
default: '22'
default: '24.15.0'
playwright-enabled:
description: 'Enable Playwright?'
required: false
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,10 @@ jobs:
fail-fast: false
matrix:
include:
- node-version: 22
- node-version: 24.15.0
test-filter: "**"
filter-label: "**"
- node-version: 20.19.0
test-filter: "**"
filter-label: "**"

Expand Down Expand Up @@ -257,7 +260,7 @@ jobs:
- name: Run Typedoc tests
run: |
# Only run Typedoc tests for one matrix version and main test run
if [ "${{ matrix.node-version }}" == "22" ] && [ "${{ matrix.test-filter }}" = "**" ]; then
if [ "${{ matrix.node-version }}" == "24.15.0" ] && [ "${{ matrix.test-filter }}" = "**" ]; then
pnpm turbo run //#test:typedoc
fi
env:
Expand Down Expand Up @@ -501,7 +504,7 @@ jobs:
uses: ./.github/actions/init-blacksmith
with:
turbo-enabled: true
node-version: 22
node-version: 24.15.0
turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
turbo-summarize: ${{ env.TURBO_SUMMARIZE }}
turbo-team: ${{ vars.TURBO_TEAM }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ jobs:

strategy:
matrix:
version: [22] # NOTE: 18 is cached in the main release workflow
version: [24] # NOTE: 20 is cached in the main release workflow

steps:
- name: Checkout Repo
Expand Down
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
22.11.0
24.15.0
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319",
"engines": {
"node": ">=22.11.0",
"node": ">=24.15.0",
"pnpm": ">=10.33.0"
},
"pnpm": {
Expand Down
8 changes: 6 additions & 2 deletions packages/backend/src/__tests__/proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,11 @@ describe('proxy', () => {
expect(response.status).toBe(200);
});

it('propagates abort signal to upstream fetch', async () => {
it('omits signal from upstream fetch (Node 24 undici cross-realm AbortSignal)', async () => {
// Node 24's bundled undici tightened the instanceof AbortSignal check on
// RequestInit.signal, which throws on cross-realm signals carried by
// framework Request subclasses. Until we bridge abort propagation via an
// in-realm AbortController, the signal is intentionally omitted.
const mockResponse = new Response(JSON.stringify({}), { status: 200 });
mockFetch.mockResolvedValue(mockResponse);

Expand All @@ -587,7 +591,7 @@ describe('proxy', () => {
});

const [, options] = mockFetch.mock.calls[0];
expect(options.signal).toBe(request.signal);
expect(options.signal).toBeUndefined();
});

it('includes Cache-Control: no-store on error responses', async () => {
Expand Down
7 changes: 5 additions & 2 deletions packages/backend/src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,12 +297,15 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend

try {
// Make the proxied request
// TODO: Consider adding AbortSignal.timeout(30_000) via AbortSignal.any()
// TODO: Restore abort cascade via an in-realm AbortController bridge,
// and consider adding AbortSignal.timeout(30_000) via AbortSignal.any().
// `request.signal` is intentionally omitted: Node 24's bundled undici
// tightened the instanceof AbortSignal check on RequestInit.signal, which
// rejects cross-realm signals carried by framework Request subclasses.
const fetchOptions: RequestInit = {
method: request.method,
headers,
redirect: 'manual',
signal: request.signal,
};

// Only set duplex when body is present (required for streaming bodies)
Expand Down
20 changes: 19 additions & 1 deletion packages/backend/src/tokens/clerkRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,25 @@ class ClerkRequest extends Request {
// https://github.com/nodejs/undici/issues/2155
// https://github.com/nodejs/undici/blob/7153a1c78d51840bbe16576ce353e481c3934701/lib/fetch/request.js#L854
const url = typeof input !== 'string' && 'url' in input ? input.url : String(input);
super(url, init || typeof input === 'string' ? undefined : input);
// When cloning a Request by passing it as init, hide its `signal`. Undici's
// Request constructor in Node 24 performs a strict instanceof check on the
// signal and rejects ones from a different realm (e.g. NextRequest). Using a
// Proxy keeps property access lazy so environments that don't implement
// optional getters (e.g. Cloudflare Workers' Request lacks `cache`) still work.
Comment thread
jacekradko marked this conversation as resolved.
let cloneInit: RequestInit | undefined;
if (init) {
cloneInit = init;
} else if (typeof input !== 'string') {
cloneInit = new Proxy(input as Request, {
get(target, prop) {
if (prop === 'signal') {
return undefined;
}
return Reflect.get(target, prop, target);
},
}) as unknown as RequestInit;
}
super(url, cloneInit);
Comment on lines +29 to +47
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can confirm this makes sense. Undici validates init.signal when constructing a Request, and that can reject signals coming from framework-specific request objects

https://github.com/nodejs/undici/blob/main/lib/web/fetch/request.js#L1077-L1085

this.clerkUrl = this.deriveUrlFromHeaders(this);
this.cookies = this.parseCookies(this);
}
Expand Down
4 changes: 3 additions & 1 deletion packages/react-router/src/server/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,14 @@ export const wrapWithClerkState = (data: any) => {
* @internal
*/
export const patchRequest = (request: Request) => {
// Omit `signal` from the clone: Node 24's bundled undici tightened the
// instanceof AbortSignal check, which rejects cross-realm signals (e.g.
// those carried by framework Request subclasses).
Comment thread
jacekradko marked this conversation as resolved.
const clonedRequest = new Request(request.url, {
headers: request.headers,
method: request.method,
redirect: request.redirect,
cache: request.cache,
signal: request.signal,
});

// If duplex is not set, set it to 'half' to avoid duplex issues with unidici
Expand Down
14 changes: 6 additions & 8 deletions packages/tanstack-react-start/src/__tests__/patchRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,12 @@ describe('patchRequest', () => {
expect(cloned.cache).toBe('no-cache');
});

it('forwards signal aborts from the original request', () => {
const controller = new AbortController();
const original = new Request('https://example.com/', { signal: controller.signal });
const cloned = patchRequest(original);
expect(cloned.signal.aborted).toBe(false);
controller.abort();
expect(cloned.signal.aborted).toBe(true);
});
// The previous "forwards signal aborts" regression test cannot run under Node
// 24 + jsdom + undici: constructing `new Request(url, { signal })` with any
// AbortSignal throws TypeError due to undici's tightened cross-realm
// instanceof check. patchRequest intentionally omits the signal to avoid that
// error; verifying the trade-off in a unit test isn't possible in this
// environment.

it('clones POST requests without forwarding the body', () => {
// patchRequest deliberately omits `body` from the cloned init (see #7020)
Expand Down
7 changes: 6 additions & 1 deletion packages/tanstack-react-start/src/server/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,17 @@ export function getResponseClerkState(requestState: RequestState, additionalStat
* @internal
*/
export const patchRequest = (request: Request) => {
// Omit `signal` from the clone: Node 24's bundled undici tightened the
// instanceof AbortSignal check on RequestInit.signal and rejects any signal
// it does not recognize as its own — including the standard AbortSignal from
// framework Request subclasses or from `new AbortController()`. Until the
// ecosystem stabilizes, abort propagation through this clone is intentionally
// dropped. See packages/backend/src/proxy.ts for the same workaround.
const clonedRequest = new Request(request.url, {
headers: request.headers,
method: request.method,
redirect: request.redirect,
cache: request.cache,
signal: request.signal,
});

// If duplex is not set, set it to 'half' to avoid duplex issues with unidici
Expand Down
Loading