diff --git a/.github/actions/init-blacksmith/action.yml b/.github/actions/init-blacksmith/action.yml index 9f8c4d624ed..fba1064209c 100644 --- a/.github/actions/init-blacksmith/action.yml +++ b/.github/actions/init-blacksmith/action.yml @@ -4,7 +4,7 @@ inputs: node-version: description: 'The node version to use' required: false - default: '24.15.0' + default: '22' playwright-enabled: description: 'Enable Playwright?' required: false diff --git a/.github/actions/init/action.yml b/.github/actions/init/action.yml index ec2a7d7c88b..849c5ca0255 100644 --- a/.github/actions/init/action.yml +++ b/.github/actions/init/action.yml @@ -4,7 +4,7 @@ inputs: node-version: description: 'The node version to use' required: false - default: '24.15.0' + default: '22' playwright-enabled: description: 'Enable Playwright?' required: false diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 504ea00eb45..c3bf58f0b9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,10 +218,7 @@ jobs: fail-fast: false matrix: include: - - node-version: 24.15.0 - test-filter: "**" - filter-label: "**" - - node-version: 20.19.0 + - node-version: 22 test-filter: "**" filter-label: "**" @@ -260,7 +257,7 @@ jobs: - name: Run Typedoc tests run: | # Only run Typedoc tests for one matrix version and main test run - if [ "${{ matrix.node-version }}" == "24.15.0" ] && [ "${{ matrix.test-filter }}" = "**" ]; then + if [ "${{ matrix.node-version }}" == "22" ] && [ "${{ matrix.test-filter }}" = "**" ]; then pnpm turbo run //#test:typedoc fi env: @@ -504,7 +501,7 @@ jobs: uses: ./.github/actions/init-blacksmith with: turbo-enabled: true - node-version: 24.15.0 + node-version: 22 turbo-signature: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} turbo-summarize: ${{ env.TURBO_SUMMARIZE }} turbo-team: ${{ vars.TURBO_TEAM }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e8dddbde9a..4e802db8b7d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -524,7 +524,7 @@ jobs: strategy: matrix: - version: [24] # NOTE: 20 is cached in the main release workflow + version: [22] # NOTE: 18 is cached in the main release workflow steps: - name: Checkout Repo diff --git a/.nvmrc b/.nvmrc index 5bf4400f229..7af24b7ddbd 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.15.0 +22.11.0 diff --git a/package.json b/package.json index 5c5159220c4..f218a3e18b8 100644 --- a/package.json +++ b/package.json @@ -152,7 +152,7 @@ }, "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319", "engines": { - "node": ">=24.15.0", + "node": ">=22.11.0", "pnpm": ">=10.33.0" }, "pnpm": { diff --git a/packages/backend/src/__tests__/proxy.test.ts b/packages/backend/src/__tests__/proxy.test.ts index 661be0053e6..c8ad63192e5 100644 --- a/packages/backend/src/__tests__/proxy.test.ts +++ b/packages/backend/src/__tests__/proxy.test.ts @@ -572,11 +572,7 @@ describe('proxy', () => { expect(response.status).toBe(200); }); - 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. + it('propagates abort signal to upstream fetch', async () => { const mockResponse = new Response(JSON.stringify({}), { status: 200 }); mockFetch.mockResolvedValue(mockResponse); @@ -591,7 +587,7 @@ describe('proxy', () => { }); const [, options] = mockFetch.mock.calls[0]; - expect(options.signal).toBeUndefined(); + expect(options.signal).toBe(request.signal); }); it('includes Cache-Control: no-store on error responses', async () => { diff --git a/packages/backend/src/proxy.ts b/packages/backend/src/proxy.ts index bf2e25789b2..e11babd8028 100644 --- a/packages/backend/src/proxy.ts +++ b/packages/backend/src/proxy.ts @@ -297,15 +297,12 @@ export async function clerkFrontendApiProxy(request: Request, options?: Frontend try { // Make the proxied request - // 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. + // TODO: Consider adding AbortSignal.timeout(30_000) via AbortSignal.any() const fetchOptions: RequestInit = { method: request.method, headers, redirect: 'manual', + signal: request.signal, }; // Only set duplex when body is present (required for streaming bodies) diff --git a/packages/backend/src/tokens/clerkRequest.ts b/packages/backend/src/tokens/clerkRequest.ts index 7dc0380bb51..89ab5e6bc6d 100644 --- a/packages/backend/src/tokens/clerkRequest.ts +++ b/packages/backend/src/tokens/clerkRequest.ts @@ -26,25 +26,7 @@ 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); - // 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. - 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); + super(url, init || typeof input === 'string' ? undefined : input); this.clerkUrl = this.deriveUrlFromHeaders(this); this.cookies = this.parseCookies(this); } diff --git a/packages/react-router/src/server/utils.ts b/packages/react-router/src/server/utils.ts index c55d8a635ef..b33f147205a 100644 --- a/packages/react-router/src/server/utils.ts +++ b/packages/react-router/src/server/utils.ts @@ -135,14 +135,12 @@ 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). 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 diff --git a/packages/tanstack-react-start/src/__tests__/patchRequest.test.ts b/packages/tanstack-react-start/src/__tests__/patchRequest.test.ts index 1914ee0454a..cc513064f13 100644 --- a/packages/tanstack-react-start/src/__tests__/patchRequest.test.ts +++ b/packages/tanstack-react-start/src/__tests__/patchRequest.test.ts @@ -45,12 +45,14 @@ describe('patchRequest', () => { expect(cloned.cache).toBe('no-cache'); }); - // 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('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); + }); it('clones POST requests without forwarding the body', () => { // patchRequest deliberately omits `body` from the cloned init (see #7020) diff --git a/packages/tanstack-react-start/src/server/utils/index.ts b/packages/tanstack-react-start/src/server/utils/index.ts index 8c35f07d63c..717f4312807 100644 --- a/packages/tanstack-react-start/src/server/utils/index.ts +++ b/packages/tanstack-react-start/src/server/utils/index.ts @@ -69,17 +69,12 @@ 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