diff --git a/.changeset/nasty-baboons-cheer.md b/.changeset/nasty-baboons-cheer.md
new file mode 100644
index 00000000000..9d2f49a215c
--- /dev/null
+++ b/.changeset/nasty-baboons-cheer.md
@@ -0,0 +1,6 @@
+---
+"@clerk/astro": patch
+"@clerk/shared": patch
+---
+
+Introduce functions that can be reused across front-end SDKs
diff --git a/packages/astro/src/client/bundled.ts b/packages/astro/src/client/bundled.ts
index b2a7b9380d9..151505bac0f 100644
--- a/packages/astro/src/client/bundled.ts
+++ b/packages/astro/src/client/bundled.ts
@@ -17,9 +17,7 @@ export function createClerkInstanceInternal(options?: AstroClerkCreateInstancePa
let clerkJSInstance = window.Clerk as unknown as Clerk;
if (!clerkJSInstance) {
clerkJSInstance = new Clerk(options!.publishableKey);
- // @ts-ignore
$clerk.set(clerkJSInstance);
- // @ts-ignore
window.Clerk = clerkJSInstance;
}
diff --git a/packages/astro/src/client/index.ts b/packages/astro/src/client/index.ts
index ffe5b8c612b..67f030f1311 100644
--- a/packages/astro/src/client/index.ts
+++ b/packages/astro/src/client/index.ts
@@ -1,4 +1,5 @@
-import { waitForClerkScript } from '../internal/utils/loadClerkJSScript';
+import { loadClerkJsScript } from '@clerk/shared/loadClerkJsScript';
+
import { $clerk, $csrState } from '../stores/internal';
import type { AstroClerkIntegrationParams, AstroClerkUpdateOptions } from '../types';
import { mountAllClerkAstroJSComponents } from './mount-clerk-astro-js-components';
@@ -6,6 +7,8 @@ import { runOnce } from './run-once';
let initOptions: AstroClerkIntegrationParams | undefined;
+const HARDCODED_LATEST_CLERK_JS_VERSION = '5';
+
/**
* Prevents firing clerk.load multiple times
*/
@@ -14,7 +17,10 @@ export const createClerkInstance = runOnce(createClerkInstanceInternal);
export async function createClerkInstanceInternal(options?: AstroClerkIntegrationParams) {
let clerkJSInstance = window.Clerk;
if (!clerkJSInstance) {
- await waitForClerkScript();
+ await loadClerkJsScript({
+ clerkJSVersion: HARDCODED_LATEST_CLERK_JS_VERSION,
+ ...(options as any),
+ });
if (!window.Clerk) {
throw new Error('Failed to download latest ClerkJS. Contact support@clerk.com.');
@@ -23,13 +29,11 @@ export async function createClerkInstanceInternal(options?: AstroClerkIntegratio
}
if (!$clerk.get()) {
- // @ts-ignore
$clerk.set(clerkJSInstance);
}
initOptions = options;
- // TODO: Update Clerk type from @clerk/types to include this method
- return (clerkJSInstance as any)
+ return clerkJSInstance
.load(options)
.then(() => {
$csrState.setKey('isLoaded', true);
diff --git a/packages/astro/src/internal/merge-env-vars-with-params.ts b/packages/astro/src/internal/merge-env-vars-with-params.ts
index b8aebfa02fc..69768fca35f 100644
--- a/packages/astro/src/internal/merge-env-vars-with-params.ts
+++ b/packages/astro/src/internal/merge-env-vars-with-params.ts
@@ -14,13 +14,21 @@ const mergeEnvVarsWithParams = (params?: AstroClerkIntegrationParams & { publish
...rest
} = params || {};
+ // We have an eslint config that requires us to declare env variables in the turbo.json file.
+ // We're skipping/disabling it below as we use it only for the Astro integration.
return {
- signInUrl: paramSignIn || import.meta.env.PUBLIC_ASTRO_APP_CLERK_SIGN_IN_URL,
- signUpUrl: paramSignUp || import.meta.env.PUBLIC_ASTRO_APP_CLERK_SIGN_UP_URL,
- isSatellite: paramSatellite || import.meta.env.PUBLIC_ASTRO_APP_CLERK_IS_SATELLITE,
- proxyUrl: paramProxy || import.meta.env.PUBLIC_ASTRO_APP_CLERK_PROXY_URL,
- domain: paramDomain || import.meta.env.PUBLIC_ASTRO_APP_CLERK_DOMAIN,
- publishableKey: paramPublishableKey || import.meta.env.PUBLIC_ASTRO_APP_CLERK_PUBLISHABLE_KEY || '',
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
+ signInUrl: paramSignIn || import.meta.env.PUBLIC_CLERK_SIGN_IN_URL,
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
+ signUpUrl: paramSignUp || import.meta.env.PUBLIC_CLERK_SIGN_UP_URL,
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
+ isSatellite: paramSatellite || import.meta.env.PUBLIC_CLERK_IS_SATELLITE,
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
+ proxyUrl: paramProxy || import.meta.env.PUBLIC_CLERK_PROXY_URL,
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
+ domain: paramDomain || import.meta.env.PUBLIC_CLERK_DOMAIN,
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
+ publishableKey: paramPublishableKey || import.meta.env.PUBLIC_CLERK_PUBLISHABLE_KEY || '',
...rest,
};
};
diff --git a/packages/astro/src/internal/utils/loadClerkJSScript.ts b/packages/astro/src/internal/utils/loadClerkJSScript.ts
deleted file mode 100644
index 6d250441339..00000000000
--- a/packages/astro/src/internal/utils/loadClerkJSScript.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-const FAILED_TO_FIND_CLERK_SCRIPT = 'Clerk: Failed find clerk-js script';
-
-// TODO-SHARED: Something similar exists inside clerk-react
-export const waitForClerkScript = () => {
- return new Promise((resolve, reject) => {
- const script = document.querySelector('script[data-clerk-script]');
-
- if (!script) {
- return reject(FAILED_TO_FIND_CLERK_SCRIPT);
- }
-
- script.addEventListener('load', () => {
- script.remove();
- resolve(script);
- });
- });
-};
diff --git a/packages/astro/src/internal/utils/versionSelector.ts b/packages/astro/src/internal/utils/versionSelector.ts
deleted file mode 100644
index 12b8d4c2fdd..00000000000
--- a/packages/astro/src/internal/utils/versionSelector.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-const HARDCODED_LATEST_CLERK_JS_VERSION = '5';
-
-export const versionSelector = (clerkJSVersion: string | undefined): string => {
- if (clerkJSVersion) {
- return clerkJSVersion;
- }
-
- return HARDCODED_LATEST_CLERK_JS_VERSION;
-};
diff --git a/packages/astro/src/server/build-clerk-hotload-script.ts b/packages/astro/src/server/build-clerk-hotload-script.ts
deleted file mode 100644
index 4b0ed219383..00000000000
--- a/packages/astro/src/server/build-clerk-hotload-script.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { createDevOrStagingUrlCache, parsePublishableKey } from '@clerk/shared/keys';
-import { isValidProxyUrl, proxyUrlToAbsoluteURL } from '@clerk/shared/proxy';
-import { addClerkPrefix } from '@clerk/shared/url';
-import type { APIContext } from 'astro';
-
-import { versionSelector } from '../internal/utils/versionSelector';
-import { getSafeEnv } from './get-safe-env';
-
-const { isDevOrStagingUrl } = createDevOrStagingUrlCache();
-
-type BuildClerkJsScriptOptions = {
- proxyUrl: string;
- domain: string;
- clerkJSUrl?: string;
- clerkJSVariant?: 'headless' | '';
- clerkJSVersion?: string;
- publishableKey: string;
-};
-
-const clerkJsScriptUrl = (opts: BuildClerkJsScriptOptions) => {
- const { clerkJSUrl, clerkJSVariant, clerkJSVersion, proxyUrl, domain, publishableKey } = opts;
-
- if (clerkJSUrl) {
- return clerkJSUrl;
- }
-
- let scriptHost = '';
- if (!!proxyUrl && isValidProxyUrl(proxyUrl)) {
- scriptHost = proxyUrlToAbsoluteURL(proxyUrl).replace(/http(s)?:\/\//, '');
- } else if (domain && !isDevOrStagingUrl(parsePublishableKey(publishableKey)?.frontendApi || '')) {
- scriptHost = addClerkPrefix(domain);
- } else {
- scriptHost = parsePublishableKey(publishableKey)?.frontendApi || '';
- }
-
- const variant = clerkJSVariant ? `${clerkJSVariant.replace(/\.+$/, '')}.` : '';
- const version = versionSelector(clerkJSVersion);
- return `https://${scriptHost}/npm/@clerk/clerk-js@${version}/dist/clerk.${variant}browser.js`;
-};
-
-function buildClerkHotloadScript(locals: APIContext['locals']) {
- const publishableKey = getSafeEnv(locals).pk!;
- const proxyUrl = getSafeEnv(locals).proxyUrl!;
- const domain = getSafeEnv(locals).domain!;
- const scriptSrc = clerkJsScriptUrl({
- clerkJSUrl: getSafeEnv(locals).clerkJsUrl,
- clerkJSVariant: getSafeEnv(locals).clerkJsVariant,
- clerkJSVersion: getSafeEnv(locals).clerkJsVersion,
- domain,
- proxyUrl,
- publishableKey,
- });
- return `
- \n`;
-}
-
-export { buildClerkHotloadScript };
diff --git a/packages/astro/src/server/clerk-middleware.ts b/packages/astro/src/server/clerk-middleware.ts
index be08f6642af..8e62ca47423 100644
--- a/packages/astro/src/server/clerk-middleware.ts
+++ b/packages/astro/src/server/clerk-middleware.ts
@@ -8,7 +8,6 @@ import type { APIContext } from 'astro';
// @ts-ignore
import { authAsyncStorage } from '#async-local-storage';
-import { buildClerkHotloadScript } from './build-clerk-hotload-script';
import { clerkClient } from './clerk-client';
import { createCurrentUser } from './current-user';
import { getAuth } from './get-auth';
@@ -263,7 +262,6 @@ async function decorateRequest(
const clerkSafeEnvVariables = encoder.encode(
`\n`,
);
- const hotloadScript = encoder.encode(buildClerkHotloadScript(locals));
const stream = res.body!.pipeThrough(
new TransformStream({
@@ -279,10 +277,6 @@ async function decorateRequest(
controller.enqueue(clerkAstroData);
controller.enqueue(clerkSafeEnvVariables);
- if (__HOTLOAD__) {
- controller.enqueue(hotloadScript);
- }
-
controller.enqueue(closingHeadTag);
controller.enqueue(chunk.slice(index + closingHeadTag.length));
} else {
diff --git a/packages/astro/src/stores/external.ts b/packages/astro/src/stores/external.ts
index 9ed82c6a8cc..d122767c191 100644
--- a/packages/astro/src/stores/external.ts
+++ b/packages/astro/src/stores/external.ts
@@ -1,8 +1,8 @@
+import { deriveState } from '@clerk/shared/deriveState';
import { eventMethodCalled } from '@clerk/shared/telemetry';
import { computed, onMount, type Store } from 'nanostores';
import { $clerk, $csrState, $initialState } from './internal';
-import { deriveState } from './utils';
/**
* A client side store that is prepopulated with the authentication context during SSR.
diff --git a/packages/shared/global.d.ts b/packages/shared/global.d.ts
index 8c7a4b73b56..d080e40fece 100644
--- a/packages/shared/global.d.ts
+++ b/packages/shared/global.d.ts
@@ -2,5 +2,6 @@ export {};
declare global {
const PACKAGE_VERSION: string;
+ const JS_PACKAGE_VERSION: string;
const __DEV__: boolean;
}
diff --git a/packages/shared/jest.config.js b/packages/shared/jest.config.js
index f9da269204a..fe05710e000 100644
--- a/packages/shared/jest.config.js
+++ b/packages/shared/jest.config.js
@@ -1,4 +1,5 @@
const { name } = require('./package.json');
+const { version: clerkJsVersion } = require('../clerk-js/package.json');
/** @type {import('ts-jest').JestConfigWithTsJest} */
const config = {
@@ -17,6 +18,10 @@ const config = {
transform: {
'^.+\\.m?tsx?$': ['ts-jest', { tsconfig: 'tsconfig.test.json', diagnostics: false }],
},
+
+ globals: {
+ JS_PACKAGE_VERSION: clerkJsVersion,
+ },
};
module.exports = config;
diff --git a/packages/shared/package.json b/packages/shared/package.json
index a074e7d3427..871ab22abe5 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -51,6 +51,7 @@
"cookie",
"date",
"deprecated",
+ "deriveState",
"error",
"file",
"globs",
@@ -58,12 +59,14 @@
"isomorphicAtob",
"isomorphicBtoa",
"keys",
+ "loadClerkJsScript",
"loadScript",
"localStorageBroadcastChannel",
"poller",
"proxy",
"underscore",
"url",
+ "versionSelector",
"react",
"constants",
"apiUrlFromPublishableKey",
diff --git a/packages/shared/src/__tests__/deriveState.test.ts b/packages/shared/src/__tests__/deriveState.test.ts
new file mode 100644
index 00000000000..244cb3fe857
--- /dev/null
+++ b/packages/shared/src/__tests__/deriveState.test.ts
@@ -0,0 +1,36 @@
+import type { InitialState, Resources } from '@clerk/types';
+
+import { deriveState } from '../deriveState';
+
+describe('deriveState', () => {
+ const mockInitialState = {
+ userId: 'user_2U330vGHg3llBga8Oi0fzzeNAaG',
+ sessionId: 'sess_2j1R7g3AUeKMx9M23dBO0XLEQGY',
+ orgId: 'org_2U330vGHg3llBga8Oi0fzzeNAaG',
+ } as InitialState;
+
+ const mockResources = {
+ client: {},
+ user: { id: mockInitialState.userId },
+ session: { id: mockInitialState.sessionId },
+ organization: { id: mockInitialState.orgId },
+ } as Resources;
+
+ it('uses SSR state when !clerkLoaded and initialState is provided', () => {
+ expect(deriveState(false, {} as Resources, mockInitialState)).toEqual(mockInitialState);
+ });
+
+ it('uses CSR state when clerkLoaded', () => {
+ const result = deriveState(true, mockResources, undefined);
+ expect(result.userId).toBe(mockInitialState.userId);
+ expect(result.sessionId).toBe(mockInitialState.sessionId);
+ expect(result.orgId).toBe(mockInitialState.orgId);
+ });
+
+ it('handles !clerkLoaded and undefined initialState', () => {
+ const result = deriveState(false, {} as Resources, undefined);
+ expect(result.userId).toBeUndefined();
+ expect(result.sessionId).toBeUndefined();
+ expect(result.orgId).toBeUndefined();
+ });
+});
diff --git a/packages/shared/src/__tests__/loadClerkJsScript.test.ts b/packages/shared/src/__tests__/loadClerkJsScript.test.ts
new file mode 100644
index 00000000000..af2fb5d58b0
--- /dev/null
+++ b/packages/shared/src/__tests__/loadClerkJsScript.test.ts
@@ -0,0 +1,126 @@
+import { buildClerkJsScriptAttributes, clerkJsScriptUrl, loadClerkJsScript } from '../loadClerkJsScript';
+import { loadScript } from '../loadScript';
+
+jest.mock('../loadScript');
+
+describe('loadClerkJsScript()', () => {
+ const mockPublishableKey = 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (loadScript as jest.Mock).mockResolvedValue(undefined);
+ document.querySelector = jest.fn().mockReturnValue(null);
+ });
+
+ test('throws error when publishableKey is missing', () => {
+ expect(() => loadClerkJsScript({} as any)).rejects.toThrow(
+ 'Clerk: Missing publishableKey. You can get your key at https://dashboard.clerk.com/last-active?path=api-keys.',
+ );
+ });
+
+ test('loads script when no existing script is found', async () => {
+ await loadClerkJsScript({ publishableKey: mockPublishableKey });
+
+ expect(loadScript).toHaveBeenCalledWith(
+ expect.stringContaining('https://foo-bar-13.clerk.accounts.dev/npm/@clerk/clerk-js@5/dist/clerk.browser.js'),
+ expect.objectContaining({
+ async: true,
+ crossOrigin: 'anonymous',
+ beforeLoad: expect.any(Function),
+ }),
+ );
+ });
+
+ test('uses existing script when found', async () => {
+ const mockExistingScript = document.createElement('script');
+ document.querySelector = jest.fn().mockReturnValue(mockExistingScript);
+
+ const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey });
+ mockExistingScript.dispatchEvent(new Event('load'));
+
+ await expect(loadPromise).resolves.toBe(mockExistingScript);
+ expect(loadScript).not.toHaveBeenCalled();
+ });
+
+ test('rejects when existing script fails to load', async () => {
+ const mockExistingScript = document.createElement('script');
+ document.querySelector = jest.fn().mockReturnValue(mockExistingScript);
+
+ const loadPromise = loadClerkJsScript({ publishableKey: mockPublishableKey });
+ mockExistingScript.dispatchEvent(new Event('error'));
+
+ await expect(loadPromise).rejects.toBe('Clerk: Failed to load Clerk');
+ expect(loadScript).not.toHaveBeenCalled();
+ });
+
+ test('throws error when loadScript fails', async () => {
+ (loadScript as jest.Mock).mockRejectedValue(new Error('Script load failed'));
+
+ await expect(loadClerkJsScript({ publishableKey: mockPublishableKey })).rejects.toThrow(
+ 'Clerk: Failed to load Clerk',
+ );
+ });
+});
+
+describe('clerkJsScriptUrl()', () => {
+ const mockDevPublishableKey = 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk';
+ const mockProdPublishableKey = 'pk_live_ZXhhbXBsZS5jbGVyay5hY2NvdW50cy5kZXYk';
+
+ test('returns clerkJSUrl when provided', () => {
+ const customUrl = 'https://custom.clerk.com/clerk.js';
+ const result = clerkJsScriptUrl({ clerkJSUrl: customUrl, publishableKey: mockDevPublishableKey });
+ expect(result).toBe(customUrl);
+ });
+
+ test('constructs URL correctly for development key', () => {
+ const result = clerkJsScriptUrl({ publishableKey: mockDevPublishableKey });
+ expect(result).toBe('https://foo-bar-13.clerk.accounts.dev/npm/@clerk/clerk-js@5/dist/clerk.browser.js');
+ });
+
+ test('constructs URL correctly for production key', () => {
+ const result = clerkJsScriptUrl({ publishableKey: mockProdPublishableKey });
+ expect(result).toBe('https://example.clerk.accounts.dev/npm/@clerk/clerk-js@5/dist/clerk.browser.js');
+ });
+
+ test('includes clerkJSVariant in URL when provided', () => {
+ const result = clerkJsScriptUrl({ publishableKey: mockProdPublishableKey, clerkJSVariant: 'headless' });
+ expect(result).toBe('https://example.clerk.accounts.dev/npm/@clerk/clerk-js@5/dist/clerk.headless.browser.js');
+ });
+
+ test('uses provided clerkJSVersion', () => {
+ const result = clerkJsScriptUrl({ publishableKey: mockDevPublishableKey, clerkJSVersion: '6' });
+ expect(result).toContain('/npm/@clerk/clerk-js@6/');
+ });
+});
+
+describe('buildClerkJsScriptAttributes()', () => {
+ const mockPublishableKey = 'pk_test_Zm9vLWJhci0xMy5jbGVyay5hY2NvdW50cy5kZXYk';
+ const mockProxyUrl = 'https://proxy.clerk.com';
+ const mockDomain = 'custom.com';
+
+ test.each([
+ [
+ 'all options',
+ { publishableKey: mockPublishableKey, proxyUrl: mockProxyUrl, domain: mockDomain },
+ {
+ 'data-clerk-publishable-key': mockPublishableKey,
+ 'data-clerk-proxy-url': mockProxyUrl,
+ 'data-clerk-domain': mockDomain,
+ },
+ ],
+ [
+ 'only publishableKey',
+ { publishableKey: mockPublishableKey },
+ { 'data-clerk-publishable-key': mockPublishableKey },
+ ],
+ [
+ 'publishableKey and proxyUrl',
+ { publishableKey: mockPublishableKey, proxyUrl: mockProxyUrl },
+ { 'data-clerk-publishable-key': mockPublishableKey, 'data-clerk-proxy-url': mockProxyUrl },
+ ],
+ ['no options', {}, {}],
+ ])('returns correct attributes with %s', (_, input, expected) => {
+ // @ts-ignore input loses correct type because of empty object
+ expect(buildClerkJsScriptAttributes(input)).toEqual(expected);
+ });
+});
diff --git a/packages/shared/src/__tests__/versionSelector.test.ts b/packages/shared/src/__tests__/versionSelector.test.ts
new file mode 100644
index 00000000000..40e2812e25d
--- /dev/null
+++ b/packages/shared/src/__tests__/versionSelector.test.ts
@@ -0,0 +1,50 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+import { versionSelector } from '../versionSelector';
+
+describe('versionSelector', () => {
+ it('should return the clerkJSVersion if it is provided', () => {
+ expect(versionSelector('1.0.0')).toEqual('1.0.0');
+ });
+
+ it('should use the major clerkJS version if there is no prerelease tag', () => {
+ // @ts-ignore
+ global.JS_PACKAGE_VERSION = '2.0.0';
+
+ expect(versionSelector(undefined)).toEqual('2');
+ });
+
+ it('should use the prerelease tag when it is not snapshot', () => {
+ // @ts-ignore
+ global.JS_PACKAGE_VERSION = '2.0.0-next.0';
+
+ expect(versionSelector(undefined)).toEqual('next');
+ });
+
+ it('should use the exact JS version if tag is snapshot', () => {
+ // @ts-ignore
+ global.JS_PACKAGE_VERSION = '2.0.0-snapshot.0';
+
+ expect(versionSelector(undefined)).toEqual('2.0.0-snapshot.0');
+ });
+
+ // We replaced semver with 2 custom regexes
+ // so we're testing the same cases as semver tests
+ // https://github.com/npm/node-semver/blob/main/test/functions/prerelease.js
+ // https://github.com/npm/node-semver/blob/main/test/functions/major.js
+ test.each([
+ ['1.2.3', 1],
+ [' 1.2.3 ', 1],
+ [' 2.2.3-4 ', 4],
+ [' 3.2.3-pre ', 'pre'],
+ ['v5.2.3', 5],
+ [' v8.2.3 ', 8],
+ ['\t13.2.3', 13],
+ ['1.2.2-alpha.1', 'alpha'],
+ ['1.2.2-beta.1', 'beta'],
+ ['1.2.2-rc.1', 'rc'],
+ ['1.2.2', 1],
+ ['1.2.2-pre', 'pre'],
+ ])('versionSelector(%s) should return %i', (version, expected) => {
+ expect(versionSelector(undefined, version)).toEqual(expected.toString());
+ });
+});
diff --git a/packages/shared/src/deriveState.ts b/packages/shared/src/deriveState.ts
new file mode 100644
index 00000000000..6a280fe4a6e
--- /dev/null
+++ b/packages/shared/src/deriveState.ts
@@ -0,0 +1,71 @@
+import type {
+ ActiveSessionResource,
+ InitialState,
+ OrganizationCustomPermissionKey,
+ OrganizationCustomRoleKey,
+ OrganizationResource,
+ Resources,
+ UserResource,
+} from '@clerk/types';
+
+export const deriveState = (clerkLoaded: boolean, state: Resources, initialState: InitialState | undefined) => {
+ if (!clerkLoaded && initialState) {
+ return deriveFromSsrInitialState(initialState);
+ }
+ return deriveFromClientSideState(state);
+};
+
+const deriveFromSsrInitialState = (initialState: InitialState) => {
+ const userId = initialState.userId;
+ const user = initialState.user as UserResource;
+ const sessionId = initialState.sessionId;
+ const session = initialState.session as ActiveSessionResource;
+ const organization = initialState.organization as OrganizationResource;
+ const orgId = initialState.orgId;
+ const orgRole = initialState.orgRole as OrganizationCustomRoleKey;
+ const orgPermissions = initialState.orgPermissions as OrganizationCustomPermissionKey[];
+ const orgSlug = initialState.orgSlug;
+ const actor = initialState.actor;
+
+ return {
+ userId,
+ user,
+ sessionId,
+ session,
+ organization,
+ orgId,
+ orgRole,
+ orgPermissions,
+ orgSlug,
+ actor,
+ };
+};
+
+const deriveFromClientSideState = (state: Resources) => {
+ const userId: string | null | undefined = state.user ? state.user.id : state.user;
+ const user = state.user;
+ const sessionId: string | null | undefined = state.session ? state.session.id : state.session;
+ const session = state.session;
+ const actor = session?.actor;
+ const organization = state.organization;
+ const orgId: string | null | undefined = state.organization ? state.organization.id : state.organization;
+ const orgSlug = organization?.slug;
+ const membership = organization
+ ? user?.organizationMemberships?.find(om => om.organization.id === orgId)
+ : organization;
+ const orgPermissions = membership ? membership.permissions : membership;
+ const orgRole = membership ? membership.role : membership;
+
+ return {
+ userId,
+ user,
+ sessionId,
+ session,
+ organization,
+ orgId,
+ orgRole,
+ orgSlug,
+ orgPermissions,
+ actor,
+ };
+};
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index f9152c1b5fd..d1c7723d9b7 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -17,18 +17,21 @@ export * from './color';
export * from './constants';
export * from './date';
export * from './deprecated';
+export { deriveState } from './deriveState';
export * from './error';
export * from './file';
export { handleValueOrFn } from './handleValueOrFn';
export { isomorphicAtob } from './isomorphicAtob';
export { isomorphicBtoa } from './isomorphicBtoa';
export * from './keys';
+export * from './loadClerkJsScript';
export { loadScript } from './loadScript';
export { LocalStorageBroadcastChannel } from './localStorageBroadcastChannel';
export * from './poller';
export * from './proxy';
export * from './underscore';
export * from './url';
+export { versionSelector } from './versionSelector';
export * from './object';
export * from './logger';
export { createWorkerTimers } from './workerTimers';
diff --git a/packages/shared/src/loadClerkJsScript.ts b/packages/shared/src/loadClerkJsScript.ts
new file mode 100644
index 00000000000..f09bf99b760
--- /dev/null
+++ b/packages/shared/src/loadClerkJsScript.ts
@@ -0,0 +1,102 @@
+import type { ClerkOptions, SDKMetadata, Without } from '@clerk/types';
+
+import { createDevOrStagingUrlCache, parsePublishableKey } from './keys';
+import { loadScript } from './loadScript';
+import { isValidProxyUrl, proxyUrlToAbsoluteURL } from './proxy';
+import { addClerkPrefix } from './url';
+import { versionSelector } from './versionSelector';
+
+const FAILED_TO_LOAD_ERROR = 'Clerk: Failed to load Clerk';
+const MISSING_PUBLISHABLE_KEY_ERROR =
+ 'Clerk: Missing publishableKey. You can get your key at https://dashboard.clerk.com/last-active?path=api-keys.';
+
+const { isDevOrStagingUrl } = createDevOrStagingUrlCache();
+
+type LoadClerkJsScriptOptions = Without & {
+ publishableKey: string;
+ clerkJSUrl?: string;
+ clerkJSVariant?: 'headless' | '';
+ clerkJSVersion?: string;
+ sdkMetadata?: SDKMetadata;
+ proxyUrl?: string;
+ domain?: string;
+};
+
+const loadClerkJsScript = async (opts: LoadClerkJsScriptOptions) => {
+ const { publishableKey } = opts;
+
+ if (!publishableKey) {
+ throw new Error(MISSING_PUBLISHABLE_KEY_ERROR);
+ }
+
+ const existingScript = document.querySelector('script[data-clerk-js-script]');
+
+ if (existingScript) {
+ return new Promise((resolve, reject) => {
+ existingScript.addEventListener('load', () => {
+ resolve(existingScript);
+ });
+
+ existingScript.addEventListener('error', () => {
+ reject(FAILED_TO_LOAD_ERROR);
+ });
+ });
+ }
+
+ return loadScript(clerkJsScriptUrl(opts), {
+ async: true,
+ crossOrigin: 'anonymous',
+ beforeLoad: applyClerkJsScriptAttributes(opts),
+ }).catch(() => {
+ throw new Error(FAILED_TO_LOAD_ERROR);
+ });
+};
+
+const clerkJsScriptUrl = (opts: LoadClerkJsScriptOptions) => {
+ const { clerkJSUrl, clerkJSVariant, clerkJSVersion = '5', proxyUrl, domain, publishableKey } = opts;
+
+ if (clerkJSUrl) {
+ return clerkJSUrl;
+ }
+
+ let scriptHost = '';
+ if (!!proxyUrl && isValidProxyUrl(proxyUrl)) {
+ scriptHost = proxyUrlToAbsoluteURL(proxyUrl).replace(/http(s)?:\/\//, '');
+ } else if (domain && !isDevOrStagingUrl(parsePublishableKey(publishableKey)?.frontendApi || '')) {
+ scriptHost = addClerkPrefix(domain);
+ } else {
+ scriptHost = parsePublishableKey(publishableKey)?.frontendApi || '';
+ }
+
+ const variant = clerkJSVariant ? `${clerkJSVariant.replace(/\.+$/, '')}.` : '';
+ const version = versionSelector(clerkJSVersion);
+ return `https://${scriptHost}/npm/@clerk/clerk-js@${version}/dist/clerk.${variant}browser.js`;
+};
+
+const buildClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => {
+ const obj: Record = {};
+
+ if (options.publishableKey) {
+ obj['data-clerk-publishable-key'] = options.publishableKey;
+ }
+
+ if (options.proxyUrl) {
+ obj['data-clerk-proxy-url'] = options.proxyUrl;
+ }
+
+ if (options.domain) {
+ obj['data-clerk-domain'] = options.domain;
+ }
+
+ return obj;
+};
+
+const applyClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => (script: HTMLScriptElement) => {
+ const attributes = buildClerkJsScriptAttributes(options);
+ for (const attribute in attributes) {
+ script.setAttribute(attribute, attributes[attribute]);
+ }
+};
+
+export { loadClerkJsScript, buildClerkJsScriptAttributes, clerkJsScriptUrl };
+export type { LoadClerkJsScriptOptions };
diff --git a/packages/shared/src/versionSelector.ts b/packages/shared/src/versionSelector.ts
new file mode 100644
index 00000000000..6bc045b6e37
--- /dev/null
+++ b/packages/shared/src/versionSelector.ts
@@ -0,0 +1,34 @@
+/**
+ * This version selector is a bit complicated, so here is the flow:
+ * 1. Use the clerkJSVersion prop on the provider
+ * 2. Use the exact `@clerk/clerk-js` version if tag is snapshot
+ * 3. Use the prerelease tag of `@clerk/clerk-js`
+ * 4. Fallback to the major version of `@clerk/clerk-js`
+ * @param clerkJSVersion - The optional clerkJSVersion prop on the provider
+ * @param packageVersion - The version of `@clerk/clerk-js` that will be used if an explicit version is not provided
+ * @returns The npm tag, version or major version to use
+ */
+export const versionSelector = (clerkJSVersion: string | undefined, packageVersion = JS_PACKAGE_VERSION) => {
+ if (clerkJSVersion) {
+ return clerkJSVersion;
+ }
+
+ const prereleaseTag = getPrereleaseTag(packageVersion);
+ if (prereleaseTag) {
+ if (prereleaseTag === 'snapshot') {
+ return JS_PACKAGE_VERSION;
+ }
+
+ return prereleaseTag;
+ }
+
+ return getMajorVersion(packageVersion);
+};
+
+const getPrereleaseTag = (packageVersion: string) =>
+ packageVersion
+ .trim()
+ .replace(/^v/, '')
+ .match(/-(.+?)(\.|$)/)?.[1];
+
+const getMajorVersion = (packageVersion: string) => packageVersion.trim().replace(/^v/, '').split('.')[0];
diff --git a/packages/shared/subpaths.mjs b/packages/shared/subpaths.mjs
index 37f18538715..3ec9dc42cdb 100644
--- a/packages/shared/subpaths.mjs
+++ b/packages/shared/subpaths.mjs
@@ -8,6 +8,7 @@ export const subpathNames = [
'cookie',
'date',
'deprecated',
+ 'deriveState',
'error',
'file',
'globs',
@@ -15,12 +16,14 @@ export const subpathNames = [
'isomorphicAtob',
'isomorphicBtoa',
'keys',
+ 'loadClerkJsScript',
'loadScript',
'localStorageBroadcastChannel',
'poller',
'proxy',
'underscore',
'url',
+ 'versionSelector',
'constants',
'apiUrlFromPublishableKey',
'telemetry',
diff --git a/packages/shared/tsup.config.ts b/packages/shared/tsup.config.ts
index 8c912b55a08..12bb36aac30 100644
--- a/packages/shared/tsup.config.ts
+++ b/packages/shared/tsup.config.ts
@@ -3,6 +3,7 @@ import { transform } from 'esbuild';
import { readFile } from 'fs/promises';
import { defineConfig } from 'tsup';
+import { version as clerkJsVersion } from '../clerk-js/package.json';
import { name, version } from './package.json';
export default defineConfig(overrideOptions => {
@@ -21,6 +22,7 @@ export default defineConfig(overrideOptions => {
define: {
PACKAGE_NAME: `"${name}"`,
PACKAGE_VERSION: `"${version}"`,
+ JS_PACKAGE_VERSION: `"${clerkJsVersion}"`,
__DEV__: `${isWatch}`,
},
};