diff --git a/.changeset/cyan-needles-listen.md b/.changeset/cyan-needles-listen.md new file mode 100644 index 00000000000..5ea86c219a9 --- /dev/null +++ b/.changeset/cyan-needles-listen.md @@ -0,0 +1,5 @@ +--- +"@clerk/astro": patch +--- + +Fixed a bug where the `clerkMiddleware()` helper would consume the body of the request. diff --git a/.changeset/eleven-corners-boil.md b/.changeset/eleven-corners-boil.md new file mode 100644 index 00000000000..23bc4c0649a --- /dev/null +++ b/.changeset/eleven-corners-boil.md @@ -0,0 +1,8 @@ +--- +"@clerk/astro": patch +"@clerk/react-router": patch +"@clerk/shared": patch +"@clerk/tanstack-react-start": patch +--- + +Moved the internal `patchRequest()` helper for reuse across framework SDKs. diff --git a/packages/astro/src/server/clerk-middleware.ts b/packages/astro/src/server/clerk-middleware.ts index cd455e0280d..6bbeb09f7dd 100644 --- a/packages/astro/src/server/clerk-middleware.ts +++ b/packages/astro/src/server/clerk-middleware.ts @@ -18,6 +18,7 @@ import { import { htmlSafeJson } from '@clerk/shared/htmlSafeJson'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; +import { patchRequest } from '@clerk/shared/patchRequest'; import { isMalformedURLError } from '@clerk/shared/pathMatcher'; import { isHttpOrHttps } from '@clerk/shared/proxy'; import type { PendingSessionOptions } from '@clerk/shared/types'; @@ -83,7 +84,8 @@ export const clerkMiddleware: ClerkMiddleware = (...args: unknown[]): any => { await initCloudflareEnv(); - const clerkRequest = createClerkRequest(context.request); + const patchedRequest = patchRequest(context.request); + const clerkRequest = createClerkRequest(patchedRequest); // Resolve keyless URLs per-request in development let keylessClaimUrl: string | undefined; diff --git a/packages/react-router/src/server/clerkMiddleware.ts b/packages/react-router/src/server/clerkMiddleware.ts index 4a27b95d8a6..7b6e9a81413 100644 --- a/packages/react-router/src/server/clerkMiddleware.ts +++ b/packages/react-router/src/server/clerkMiddleware.ts @@ -2,6 +2,7 @@ import type { AuthObject } from '@clerk/backend'; import type { RequestState } from '@clerk/backend/internal'; import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; +import { patchRequest } from '@clerk/shared/patchRequest'; import type { PendingSessionOptions } from '@clerk/shared/types'; import type { MiddlewareFunction } from 'react-router'; import { createContext } from 'react-router'; @@ -10,7 +11,6 @@ import { clerkClient } from './clerkClient'; import { resolveKeysWithKeylessFallback } from './keyless/utils'; import { loadOptions } from './loadOptions'; import type { AdditionalStateOptions, ClerkMiddlewareOptions } from './types'; -import { patchRequest } from './utils'; type RequestStateContextValue = { requestState: RequestState; diff --git a/packages/react-router/src/server/loadOptions.ts b/packages/react-router/src/server/loadOptions.ts index 9832552883b..50ffc136519 100644 --- a/packages/react-router/src/server/loadOptions.ts +++ b/packages/react-router/src/server/loadOptions.ts @@ -2,6 +2,7 @@ import { createClerkRequest } from '@clerk/backend/internal'; import { apiUrlFromPublishableKey } from '@clerk/shared/apiUrlFromPublishableKey'; import { getEnvVariable } from '@clerk/shared/getEnvVariable'; import { isDevelopmentFromSecretKey } from '@clerk/shared/keys'; +import { patchRequest } from '@clerk/shared/patchRequest'; import { isHttpOrHttps, isProxyUrlRelative } from '@clerk/shared/proxy'; import { handleValueOrFn } from '@clerk/shared/utils'; import type { MiddlewareFunction } from 'react-router'; @@ -10,7 +11,6 @@ import { getPublicEnvVariables } from '../utils/env'; import { noSecretKeyError, satelliteAndMissingProxyUrlAndDomain, satelliteAndMissingSignInUrl } from '../utils/errors'; import { canUseKeyless } from '../utils/feature-flags'; import type { ClerkMiddlewareOptions } from './types'; -import { patchRequest } from './utils'; export type DataFunctionArgs = Parameters>[0]; diff --git a/packages/react-router/src/server/utils.ts b/packages/react-router/src/server/utils.ts index cf553f824f2..98904f11116 100644 --- a/packages/react-router/src/server/utils.ts +++ b/packages/react-router/src/server/utils.ts @@ -141,29 +141,3 @@ export function getResponseClerkState( export const wrapWithClerkState = (data: any) => { return { clerkState: { __internal_clerk_state: { ...data } } }; }; - -/** - * Patches request to avoid duplex issues with unidici - * For more information, see: - * https://github.com/nodejs/node/issues/46221 - * https://github.com/whatwg/fetch/pull/1457 - * @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, - }); - - // If duplex is not set, set it to 'half' to avoid duplex issues with unidici - if (clonedRequest.method !== 'GET' && clonedRequest.body !== null && !('duplex' in clonedRequest)) { - (clonedRequest as unknown as { duplex: 'half' }).duplex = 'half'; - } - - return clonedRequest; -}; diff --git a/packages/tanstack-react-start/src/__tests__/patchRequest.test.ts b/packages/shared/src/__tests__/patchRequest.spec.ts similarity index 68% rename from packages/tanstack-react-start/src/__tests__/patchRequest.test.ts rename to packages/shared/src/__tests__/patchRequest.spec.ts index 1914ee0454a..b6dc3f0ae32 100644 --- a/packages/tanstack-react-start/src/__tests__/patchRequest.test.ts +++ b/packages/shared/src/__tests__/patchRequest.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { patchRequest } from '../server/utils'; +import { patchRequest } from '../patchRequest'; describe('patchRequest', () => { it('preserves the URL including path and query string', () => { @@ -10,9 +10,6 @@ describe('patchRequest', () => { }); it('preserves an encoded nested redirect_url with its own query and port', () => { - // Mirrors the shape reported in the TanStack + Lovable handshake bug: - // the outer URL's `redirect_url` param is a percent-encoded inner URL with - // a port and its own query string, which must survive the clone verbatim. const nested = 'https://localhost:8080/?token=abc'; const original = new Request(`https://example.com/handshake?redirect_url=${encodeURIComponent(nested)}`); const cloned = patchRequest(original); @@ -45,17 +42,7 @@ 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('clones POST requests without forwarding the body', () => { - // patchRequest deliberately omits `body` from the cloned init (see #7020) - // so the original request's body stays intact for downstream consumers and - // the undici duplex issues the helper was written to avoid do not resurface. const original = new Request('https://example.com/api', { method: 'POST', body: 'payload', @@ -64,5 +51,6 @@ describe('patchRequest', () => { const cloned = patchRequest(original); expect(cloned.method).toBe('POST'); expect(cloned.body).toBeNull(); + expect(original.bodyUsed).toBe(false); }); }); diff --git a/packages/shared/src/patchRequest.ts b/packages/shared/src/patchRequest.ts new file mode 100644 index 00000000000..d49df08aebd --- /dev/null +++ b/packages/shared/src/patchRequest.ts @@ -0,0 +1,20 @@ +/** + * Clones a request without its body or signal for authentication. + * + * @internal + */ +export const patchRequest = (request: Request) => { + // Node's bundled undici rejects cross-realm signals from framework Request subclasses. + const clonedRequest = new Request(request.url, { + headers: request.headers, + method: request.method, + redirect: request.redirect, + cache: request.cache, + }); + + if (clonedRequest.method !== 'GET' && clonedRequest.body !== null && !('duplex' in clonedRequest)) { + (clonedRequest as unknown as { duplex: 'half' }).duplex = 'half'; + } + + return clonedRequest; +}; diff --git a/packages/tanstack-react-start/src/server/clerkMiddleware.ts b/packages/tanstack-react-start/src/server/clerkMiddleware.ts index 4b9b69b659c..5ba42112a7c 100644 --- a/packages/tanstack-react-start/src/server/clerkMiddleware.ts +++ b/packages/tanstack-react-start/src/server/clerkMiddleware.ts @@ -1,6 +1,7 @@ import type { RequestState } from '@clerk/backend/internal'; import { AuthStatus, constants, createClerkRequest } from '@clerk/backend/internal'; import { handleNetlifyCacheInDevInstance } from '@clerk/shared/netlifyCacheHandler'; +import { patchRequest } from '@clerk/shared/patchRequest'; import type { PendingSessionOptions } from '@clerk/shared/types'; import type { AnyRequestMiddleware } from '@tanstack/react-start'; import { createMiddleware } from '@tanstack/react-start'; @@ -10,7 +11,7 @@ import { clerkClient } from './clerkClient'; import { resolveKeysWithKeylessFallback } from './keyless/utils'; import { loadOptions } from './loadOptions'; import type { ClerkMiddlewareOptions, ClerkMiddlewareOptionsCallback } from './types'; -import { getResponseClerkState, patchRequest } from './utils'; +import { getResponseClerkState } from './utils'; export const clerkMiddleware = ( options?: ClerkMiddlewareOptions | ClerkMiddlewareOptionsCallback, diff --git a/packages/tanstack-react-start/src/server/utils/index.ts b/packages/tanstack-react-start/src/server/utils/index.ts index 49d9efab7b1..4ac5f0c359b 100644 --- a/packages/tanstack-react-start/src/server/utils/index.ts +++ b/packages/tanstack-react-start/src/server/utils/index.ts @@ -69,32 +69,3 @@ export function getResponseClerkState(requestState: RequestState, additionalStat return clerkInitialState; } - -/** - * Patches request to avoid duplex issues with unidici - * For more information, see: - * https://github.com/nodejs/node/issues/46221 - * https://github.com/whatwg/fetch/pull/1457 - * @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, - }); - - // If duplex is not set, set it to 'half' to avoid duplex issues with unidici - if (clonedRequest.method !== 'GET' && clonedRequest.body !== null && !('duplex' in clonedRequest)) { - (clonedRequest as unknown as { duplex: 'half' }).duplex = 'half'; - } - - return clonedRequest; -};