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}`, }, };