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/cyan-needles-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@clerk/astro": patch
---

Fixed a bug where the `clerkMiddleware()` helper would consume the body of the request.
8 changes: 8 additions & 0 deletions .changeset/eleven-corners-boil.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion packages/astro/src/server/clerk-middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<any>;
Expand Down
2 changes: 1 addition & 1 deletion packages/react-router/src/server/loadOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<MiddlewareFunction<Response>>[0];

Expand Down
26 changes: 0 additions & 26 deletions packages/react-router/src/server/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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);
Expand Down Expand Up @@ -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',
Expand All @@ -64,5 +51,6 @@ describe('patchRequest', () => {
const cloned = patchRequest(original);
expect(cloned.method).toBe('POST');
expect(cloned.body).toBeNull();
expect(original.bodyUsed).toBe(false);
});
});
20 changes: 20 additions & 0 deletions packages/shared/src/patchRequest.ts
Original file line number Diff line number Diff line change
@@ -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;
};
3 changes: 2 additions & 1 deletion packages/tanstack-react-start/src/server/clerkMiddleware.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down
29 changes: 0 additions & 29 deletions packages/tanstack-react-start/src/server/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Loading