Skip to content

Commit a659eaf

Browse files
committed
feat(react-router): Keyless support
1 parent 1bd1747 commit a659eaf

9 files changed

Lines changed: 313 additions & 18 deletions

File tree

packages/react-router/src/client/ReactRouterClerkProvider.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ function ClerkProviderBase<TUi extends Ui = Ui>({ children, ...rest }: ClerkProv
6767
__prefetchUI,
6868
__telemetryDisabled,
6969
__telemetryDebug,
70+
__keylessClaimUrl,
71+
__keylessApiKeysUrl,
7072
} = clerkState?.__internal_clerk_state || {};
7173

7274
React.useEffect(() => {
@@ -100,6 +102,13 @@ function ClerkProviderBase<TUi extends Ui = Ui>({ children, ...rest }: ClerkProv
100102
},
101103
};
102104

105+
const keylessProps = __keylessClaimUrl
106+
? {
107+
__internal_keyless_claimKeylessApplicationUrl: __keylessClaimUrl,
108+
__internal_keyless_copyInstanceKeysUrl: __keylessApiKeysUrl,
109+
}
110+
: {};
111+
103112
return (
104113
<ClerkReactRouterOptionsProvider options={mergedProps}>
105114
<ReactClerkProvider
@@ -108,6 +117,7 @@ function ClerkProviderBase<TUi extends Ui = Ui>({ children, ...rest }: ClerkProv
108117
initialState={__clerk_ssr_state}
109118
sdkMetadata={SDK_METADATA}
110119
{...mergedProps}
120+
{...keylessProps}
111121
{...restProps}
112122
>
113123
{children}

packages/react-router/src/client/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export type ClerkState = {
2424
__prefetchUI: boolean | undefined;
2525
__telemetryDisabled: boolean | undefined;
2626
__telemetryDebug: boolean | undefined;
27+
__keylessClaimUrl?: string;
28+
__keylessApiKeysUrl?: string;
2729
};
2830
};
2931

packages/react-router/src/server/clerkMiddleware.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import type { MiddlewareFunction } from 'react-router';
77
import { createContext } from 'react-router';
88

99
import { clerkClient } from './clerkClient';
10+
import { resolveKeysWithKeylessFallback } from './keyless/utils';
1011
import { loadOptions } from './loadOptions';
11-
import type { ClerkMiddlewareOptions } from './types';
12+
import type { ClerkMiddlewareOptions, RequestStateWithRedirectUrls } from './types';
1213
import { patchRequest } from './utils';
1314

1415
export const authFnContext = createContext<((options?: PendingSessionOptions) => AuthObject) | null>(null);
@@ -35,16 +36,30 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun
3536
const clerkRequest = createClerkRequest(patchRequest(args.request));
3637
const loadedOptions = loadOptions(args, options);
3738

39+
// Resolve keys with keyless fallback
40+
const {
41+
publishableKey,
42+
secretKey,
43+
claimUrl: __keylessClaimUrl,
44+
apiKeysUrl: __keylessApiKeysUrl,
45+
} = await resolveKeysWithKeylessFallback(loadedOptions.publishableKey, loadedOptions.secretKey, args, options);
46+
47+
// Update loaded options with resolved keys
48+
if (publishableKey) {
49+
loadedOptions.publishableKey = publishableKey;
50+
}
51+
if (secretKey) {
52+
loadedOptions.secretKey = secretKey;
53+
}
54+
3855
// Pick only the properties needed by authenticateRequest.
3956
// Used when manually providing options to the middleware.
4057
const {
4158
apiUrl,
42-
secretKey,
4359
jwtKey,
4460
proxyUrl,
4561
isSatellite,
4662
domain,
47-
publishableKey,
4863
machineSecretKey,
4964
audience,
5065
authorizedParties,
@@ -55,12 +70,12 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun
5570

5671
const requestState = await clerkClient(args, options).authenticateRequest(clerkRequest, {
5772
apiUrl,
58-
secretKey,
73+
secretKey: loadedOptions.secretKey,
5974
jwtKey,
6075
proxyUrl,
6176
isSatellite,
6277
domain,
63-
publishableKey,
78+
publishableKey: loadedOptions.publishableKey,
6479
machineSecretKey,
6580
audience,
6681
authorizedParties,
@@ -70,28 +85,34 @@ export const clerkMiddleware = (options?: ClerkMiddlewareOptions): MiddlewareFun
7085
acceptsToken: 'any',
7186
});
7287

73-
const locationHeader = requestState.headers.get(constants.Headers.Location);
88+
// Attach keyless URLs to requestState
89+
const requestStateWithKeyless = Object.assign(requestState, {
90+
__keylessClaimUrl,
91+
__keylessApiKeysUrl,
92+
}) as RequestStateWithRedirectUrls;
93+
94+
const locationHeader = requestStateWithKeyless.headers.get(constants.Headers.Location);
7495
if (locationHeader) {
7596
handleNetlifyCacheInDevInstance({
7697
locationHeader,
77-
requestStateHeaders: requestState.headers,
78-
publishableKey: requestState.publishableKey,
98+
requestStateHeaders: requestStateWithKeyless.headers,
99+
publishableKey: requestStateWithKeyless.publishableKey,
79100
});
80101
// Trigger a handshake redirect
81-
return new Response(null, { status: 307, headers: requestState.headers });
102+
return new Response(null, { status: 307, headers: requestStateWithKeyless.headers });
82103
}
83104

84-
if (requestState.status === AuthStatus.Handshake) {
105+
if (requestStateWithKeyless.status === AuthStatus.Handshake) {
85106
throw new Error('Clerk: handshake status without redirect');
86107
}
87108

88-
args.context.set(authFnContext, (options?: PendingSessionOptions) => requestState.toAuth(options));
89-
args.context.set(requestStateContext, requestState);
109+
args.context.set(authFnContext, (opts?: PendingSessionOptions) => requestStateWithKeyless.toAuth(opts));
110+
args.context.set(requestStateContext, requestStateWithKeyless);
90111

91112
const response = await next();
92113

93-
if (requestState.headers) {
94-
requestState.headers.forEach((value, key) => {
114+
if (requestStateWithKeyless.headers) {
115+
requestStateWithKeyless.headers.forEach((value, key) => {
95116
response.headers.append(key, value);
96117
});
97118
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { createNodeFileStorage, type KeylessStorage } from '@clerk/shared/keyless';
2+
3+
export type { KeylessStorage };
4+
5+
export interface FileStorageOptions {
6+
cwd?: () => string;
7+
}
8+
9+
/**
10+
* Creates a file-based storage adapter for keyless mode.
11+
* Uses dynamic imports to avoid breaking Cloudflare Workers.
12+
*
13+
* @throws {Error} If called in a non-Node.js environment
14+
*/
15+
export async function createFileStorage(options: FileStorageOptions = {}): Promise<KeylessStorage> {
16+
const { cwd = () => process.cwd() } = options;
17+
18+
try {
19+
// Dynamic import to avoid bundler issues with edge runtimes
20+
const [fs, path] = await Promise.all([import('node:fs'), import('node:path')]);
21+
22+
return createNodeFileStorage(fs, path, {
23+
cwd,
24+
frameworkPackageName: '@clerk/react-router',
25+
});
26+
} catch (error) {
27+
throw new Error(
28+
'Keyless mode requires a Node.js runtime with file system access. ' +
29+
'Set VITE_CLERK_KEYLESS_DISABLED=1 to disable keyless mode.',
30+
);
31+
}
32+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { createKeylessService } from '@clerk/shared/keyless';
2+
3+
import { clerkClient } from '../clerkClient';
4+
import type { DataFunctionArgs } from '../loadOptions';
5+
import type { ClerkMiddlewareOptions } from '../types';
6+
import { createFileStorage } from './fileStorage';
7+
8+
// Singleton with lazy initialization
9+
let keylessServiceInstance: ReturnType<typeof createKeylessService> | null = null;
10+
let keylessInitPromise: Promise<ReturnType<typeof createKeylessService> | null> | null = null;
11+
12+
/**
13+
* Detects if the current runtime supports file system operations.
14+
*/
15+
function canUseFileSystem(): boolean {
16+
try {
17+
return typeof process !== 'undefined' && typeof process.cwd === 'function';
18+
} catch {
19+
return false;
20+
}
21+
}
22+
23+
/**
24+
* Gets or creates the keyless service instance.
25+
*
26+
* Returns null for non-Node.js runtimes (Cloudflare Workers).
27+
* This function is async because storage creation may involve dynamic imports.
28+
*/
29+
export async function keyless(
30+
args?: DataFunctionArgs,
31+
options?: ClerkMiddlewareOptions,
32+
): Promise<ReturnType<typeof createKeylessService> | null> {
33+
// Guard: Return null for non-Node.js runtimes
34+
if (!canUseFileSystem()) {
35+
return null;
36+
}
37+
38+
// Return existing instance
39+
if (keylessServiceInstance) {
40+
return keylessServiceInstance;
41+
}
42+
43+
// Return in-flight initialization
44+
if (keylessInitPromise) {
45+
return keylessInitPromise;
46+
}
47+
48+
// Initialize service
49+
keylessInitPromise = (async () => {
50+
try {
51+
const storage = await createFileStorage();
52+
53+
const service = createKeylessService({
54+
storage,
55+
api: {
56+
async createAccountlessApplication(requestHeaders?: Headers) {
57+
try {
58+
// Create a default args object if not provided
59+
const client = args ? clerkClient(args, options) : clerkClient({} as any, options);
60+
return await client.__experimental_accountlessApplications.createAccountlessApplication({
61+
requestHeaders,
62+
});
63+
} catch {
64+
return null;
65+
}
66+
},
67+
async completeOnboarding(requestHeaders?: Headers) {
68+
try {
69+
const client = args ? clerkClient(args, options) : clerkClient({} as any, options);
70+
return await client.__experimental_accountlessApplications.completeAccountlessApplicationOnboarding({
71+
requestHeaders,
72+
});
73+
} catch {
74+
return null;
75+
}
76+
},
77+
},
78+
framework: 'react-router',
79+
frameworkVersion: PACKAGE_VERSION,
80+
});
81+
82+
keylessServiceInstance = service;
83+
return service;
84+
} catch (error) {
85+
console.warn('[Clerk] Failed to initialize keyless service:', error);
86+
return null;
87+
} finally {
88+
keylessInitPromise = null;
89+
}
90+
})();
91+
92+
return keylessInitPromise;
93+
}
94+
95+
/**
96+
* Resets the keyless service instance (for testing).
97+
* @internal
98+
*/
99+
export function resetKeylessService(): void {
100+
keylessServiceInstance = null;
101+
keylessInitPromise = null;
102+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { AccountlessApplication } from '@clerk/shared/keyless';
2+
import { clerkDevelopmentCache, createConfirmationMessage, createKeylessModeMessage } from '@clerk/shared/keyless';
3+
4+
import { canUseKeyless } from '../../utils/feature-flags';
5+
import type { DataFunctionArgs } from '../loadOptions';
6+
import type { ClerkMiddlewareOptions } from '../types';
7+
import { keyless } from './index';
8+
9+
export interface KeylessResult {
10+
publishableKey: string | undefined;
11+
secretKey: string | undefined;
12+
claimUrl: string | undefined;
13+
apiKeysUrl: string | undefined;
14+
}
15+
16+
/**
17+
* Resolves Clerk keys, falling back to keyless mode in development if configured keys are missing.
18+
*
19+
* Implements the TanStack keyless pattern:
20+
* 1. Check if keyless mode is enabled (dev + not disabled)
21+
* 2. If running with claimed keys (configured === stored), complete onboarding
22+
* 3. If no keys configured, create/read keyless keys from storage
23+
* 4. Return resolved keys + keyless URLs
24+
*
25+
* @returns The resolved keys + keyless URLs to inject into state
26+
*/
27+
export async function resolveKeysWithKeylessFallback(
28+
configuredPublishableKey: string | undefined,
29+
configuredSecretKey: string | undefined,
30+
args?: DataFunctionArgs,
31+
options?: ClerkMiddlewareOptions,
32+
): Promise<KeylessResult> {
33+
let publishableKey = configuredPublishableKey;
34+
let secretKey = configuredSecretKey;
35+
let claimUrl: string | undefined;
36+
let apiKeysUrl: string | undefined;
37+
38+
// Early return if keyless is disabled
39+
if (!canUseKeyless) {
40+
return { publishableKey, secretKey, claimUrl, apiKeysUrl };
41+
}
42+
43+
try {
44+
const keylessService = await keyless(args, options);
45+
46+
// Early return if keyless service unavailable (e.g., Cloudflare)
47+
if (!keylessService) {
48+
return { publishableKey, secretKey, claimUrl, apiKeysUrl };
49+
}
50+
51+
const locallyStoredKeys = keylessService.readKeys();
52+
53+
// Scenario 1: Running with claimed keys
54+
const runningWithClaimedKeys =
55+
Boolean(configuredPublishableKey) && configuredPublishableKey === locallyStoredKeys?.publishableKey;
56+
57+
if (runningWithClaimedKeys && locallyStoredKeys) {
58+
// Complete onboarding (throttled by dev cache)
59+
try {
60+
await clerkDevelopmentCache?.run(() => keylessService.completeOnboarding(), {
61+
cacheKey: `${locallyStoredKeys.publishableKey}_complete`,
62+
onSuccessStale: 24 * 60 * 60 * 1000, // 24 hours
63+
});
64+
} catch {
65+
// noop - non-critical
66+
}
67+
68+
clerkDevelopmentCache?.log({
69+
cacheKey: `${locallyStoredKeys.publishableKey}_claimed`,
70+
msg: createConfirmationMessage(),
71+
});
72+
73+
return { publishableKey, secretKey, claimUrl, apiKeysUrl };
74+
}
75+
76+
// Scenario 2: Keyless mode (no keys configured)
77+
if (!publishableKey || !secretKey) {
78+
const keylessApp: AccountlessApplication | null = await keylessService.getOrCreateKeys();
79+
80+
if (keylessApp) {
81+
publishableKey = publishableKey || keylessApp.publishableKey;
82+
secretKey = secretKey || keylessApp.secretKey;
83+
claimUrl = keylessApp.claimUrl;
84+
apiKeysUrl = keylessApp.apiKeysUrl;
85+
86+
clerkDevelopmentCache?.log({
87+
cacheKey: keylessApp.publishableKey,
88+
msg: createKeylessModeMessage(keylessApp),
89+
});
90+
}
91+
}
92+
} catch (error) {
93+
// Graceful fallback - never break the app
94+
console.warn('[Clerk] Keyless resolution failed:', error);
95+
}
96+
97+
return { publishableKey, secretKey, claimUrl, apiKeysUrl };
98+
}

packages/react-router/src/server/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,17 @@ export type RootAuthLoaderOptions = ClerkMiddlewareOptions & {
6363
loadOrganization?: boolean;
6464
};
6565

66+
export interface KeylessUrls {
67+
__keylessClaimUrl?: string;
68+
__keylessApiKeysUrl?: string;
69+
}
70+
6671
export type RequestStateWithRedirectUrls = RequestState &
6772
SignInForceRedirectUrl &
6873
SignInFallbackRedirectUrl &
6974
SignUpForceRedirectUrl &
70-
SignUpFallbackRedirectUrl;
75+
SignUpFallbackRedirectUrl &
76+
KeylessUrls;
7177

7278
export type RootAuthLoaderCallback<Options extends RootAuthLoaderOptions> = (
7379
args: LoaderFunctionArgsWithAuth<Options>,

0 commit comments

Comments
 (0)