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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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;
};
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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;
};
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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;
};
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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;
};
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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;
};
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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;
};
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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;
};
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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;
};
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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