diff --git a/.changeset/shiny-owls-dance.md b/.changeset/shiny-owls-dance.md index fc4839c0112..9a31f270cbc 100644 --- a/.changeset/shiny-owls-dance.md +++ b/.changeset/shiny-owls-dance.md @@ -1,4 +1,5 @@ --- +'@clerk/clerk-js': minor '@clerk/ui': minor '@clerk/react': minor '@clerk/nextjs': minor @@ -8,4 +9,4 @@ '@clerk/shared': minor --- -Add `ui` prop to `ClerkProvider` for passing `@clerk/ui` +Add `ui` and `js` props to `ClerkProvider` for passing `@clerk/ui` and `@clerk/clerk-js` diff --git a/packages/astro/src/env.d.ts b/packages/astro/src/env.d.ts index 933fc2aea3f..25427547c69 100644 --- a/packages/astro/src/env.d.ts +++ b/packages/astro/src/env.d.ts @@ -8,6 +8,7 @@ interface InternalEnv { readonly PUBLIC_CLERK_UI_URL?: string; readonly PUBLIC_CLERK_UI_VERSION?: string; readonly PUBLIC_CLERK_PREFETCH_UI?: string; + readonly PUBLIC_CLERK_SKIP_JS_CDN?: string; readonly CLERK_API_KEY?: string; readonly CLERK_API_URL?: string; readonly CLERK_API_VERSION?: string; diff --git a/packages/astro/src/integration/create-integration.ts b/packages/astro/src/integration/create-integration.ts index dd566287a1f..ec93cce90bc 100644 --- a/packages/astro/src/integration/create-integration.ts +++ b/packages/astro/src/integration/create-integration.ts @@ -24,6 +24,7 @@ function createIntegration() const clerkUIVersion = (params as any)?.clerkUIVersion as string | undefined; const prefetchUI = (params as any)?.prefetchUI as boolean | undefined; const hasUI = !!(params as any)?.ui; + const hasJS = !!(params as any)?.js; return { name: '@clerk/astro/integration', @@ -64,6 +65,7 @@ function createIntegration() prefetchUI === false || hasUI ? 'false' : undefined, 'PUBLIC_CLERK_PREFETCH_UI', ), + ...buildEnvVarFromOption(hasJS ? 'true' : undefined, 'PUBLIC_CLERK_SKIP_JS_CDN'), }, ssr: { @@ -176,6 +178,7 @@ function createClerkEnvSchema() { PUBLIC_CLERK_JS_VERSION: envField.string({ context: 'client', access: 'public', optional: true }), PUBLIC_CLERK_UI_VERSION: envField.string({ context: 'client', access: 'public', optional: true }), PUBLIC_CLERK_PREFETCH_UI: envField.string({ context: 'client', access: 'public', optional: true }), + PUBLIC_CLERK_SKIP_JS_CDN: envField.string({ context: 'client', access: 'public', optional: true }), PUBLIC_CLERK_UI_URL: envField.string({ context: 'client', access: 'public', optional: true, url: true }), PUBLIC_CLERK_TELEMETRY_DISABLED: envField.boolean({ context: 'client', access: 'public', optional: true }), PUBLIC_CLERK_TELEMETRY_DEBUG: envField.boolean({ context: 'client', access: 'public', optional: true }), diff --git a/packages/astro/src/internal/create-clerk-instance.ts b/packages/astro/src/internal/create-clerk-instance.ts index 1f84cd4a311..5754041c609 100644 --- a/packages/astro/src/internal/create-clerk-instance.ts +++ b/packages/astro/src/internal/create-clerk-instance.ts @@ -3,7 +3,7 @@ import { loadClerkUIScript, setClerkJSLoadingErrorPackageName, } from '@clerk/shared/loadClerkJsScript'; -import type { ClerkOptions } from '@clerk/shared/types'; +import type { BrowserClerkConstructor, ClerkOptions } from '@clerk/shared/types'; import type { ClerkUIConstructor } from '@clerk/shared/ui'; import type { Ui } from '@clerk/ui/internal'; @@ -102,9 +102,18 @@ function updateClerkOptions(options: AstroClerkUpdateOption /** * Loads clerk-js script if not already loaded. + * Uses bundled ClerkJS constructor when js.ClerkJS is present. * Returns early if window.Clerk already exists. */ async function getClerkJsEntryChunk(options?: AstroClerkCreateInstanceParams): Promise { + const jsProp = options as { js?: { ClerkJS?: BrowserClerkConstructor } } | undefined; + if (jsProp?.js?.ClerkJS) { + window.Clerk = new jsProp.js.ClerkJS(options?.publishableKey as string, { + proxyUrl: options?.proxyUrl as string, + domain: options?.domain as string, + }) as any; + return; + } await loadClerkJSScript(options); } diff --git a/packages/astro/src/server/build-clerk-hotload-script.ts b/packages/astro/src/server/build-clerk-hotload-script.ts index 5510759b387..a1453dec238 100644 --- a/packages/astro/src/server/build-clerk-hotload-script.ts +++ b/packages/astro/src/server/build-clerk-hotload-script.ts @@ -12,6 +12,30 @@ function buildClerkHotloadScript(locals: APIContext['locals']) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const domain = env.domain!; + // Skip ClerkJS CDN script when js prop is bundled + if (env.skipJsCdn) { + if (env.prefetchUI === false) { + return '\n'; + } + + const clerkUIScriptSrc = clerkUIScriptUrl({ + clerkUIUrl: env.clerkUIUrl, + clerkUIVersion: env.clerkUIVersion, + domain, + proxyUrl, + publishableKey, + }); + + const clerkUIPreload = ` + `; + + return clerkUIPreload + '\n'; + } + const clerkJsScriptSrc = clerkJSScriptUrl({ clerkJSUrl: env.clerkJsUrl, clerkJSVersion: env.clerkJsVersion, diff --git a/packages/astro/src/server/get-safe-env.ts b/packages/astro/src/server/get-safe-env.ts index ebf98b99136..a900387fa58 100644 --- a/packages/astro/src/server/get-safe-env.ts +++ b/packages/astro/src/server/get-safe-env.ts @@ -35,6 +35,7 @@ function getSafeEnv(context: ContextOrLocals) { clerkUIUrl: getContextEnvVar('PUBLIC_CLERK_UI_URL', context), clerkUIVersion: getContextEnvVar('PUBLIC_CLERK_UI_VERSION', context), prefetchUI: getContextEnvVar('PUBLIC_CLERK_PREFETCH_UI', context) === 'false' ? false : undefined, + skipJsCdn: getContextEnvVar('PUBLIC_CLERK_SKIP_JS_CDN', context) === 'true', apiVersion: getContextEnvVar('CLERK_API_VERSION', context), apiUrl: getContextEnvVar('CLERK_API_URL', context), telemetryDisabled: isTruthy(getContextEnvVar('PUBLIC_CLERK_TELEMETRY_DISABLED', context)), diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index a41a1197d97..cfeea52c742 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -45,6 +45,26 @@ "types": "./dist/types/index.d.ts", "default": "./dist/clerk.no-rhc.js" } + }, + "./bundled": { + "react-server": { + "types": "./dist/esm/server.d.mts", + "import": "./dist/esm/server.mjs", + "default": "./dist/esm/server.mjs" + }, + "types": "./dist/esm/bundled.d.mts", + "import": "./dist/esm/bundled.mjs", + "default": "./dist/esm/bundled.mjs" + }, + "./entry": { + "types": "./dist/esm/entry.d.mts", + "import": "./dist/esm/entry.mjs", + "default": "./dist/esm/entry.mjs" + }, + "./internal": { + "types": "./dist/esm/internal/index.d.mts", + "import": "./dist/esm/internal/index.mjs", + "default": "./dist/esm/internal/index.mjs" } }, "main": "dist/clerk.js", @@ -56,10 +76,11 @@ "no-rhc" ], "scripts": { - "build": "pnpm build:bundle && pnpm build:declarations", + "build": "pnpm build:bundle && pnpm build:esm && pnpm build:declarations", "build:analyze": "rspack build --config rspack.config.js --env production --env variant=\"clerk.browser\" --env analysis --analyze", "build:bundle": "pnpm clean && rspack build --config rspack.config.js --env production", "build:declarations": "tsc -p tsconfig.declarations.json", + "build:esm": "tsdown", "build:sandbox": "pnpm --filter @clerk/ui build:umd && rspack build --config rspack.config.js --env production --env sandbox", "build:stats": "rspack build --config rspack.config.js --env production --json=stats.json --env variant=\"clerk.browser\"", "bundlewatch": "FORCE_COLOR=1 bundlewatch --config bundlewatch.config.json", @@ -72,7 +93,7 @@ "format": "node ../../scripts/format-package.mjs", "format:check": "node ../../scripts/format-package.mjs --check", "lint": "eslint src", - "lint:attw": "attw --pack . --profile node16 --ignore-rules named-exports --ignore-rules false-cjs", + "lint:attw": "attw --pack . --profile node16 --ignore-rules named-exports --ignore-rules false-cjs --ignore-rules cjs-resolves-to-esm", "lint:publint": "publint || true", "postbuild:disabled": "node ../../scripts/search-for-rhc.mjs file dist/clerk.no-rhc.mjs", "test": "vitest --watch=false", @@ -114,6 +135,7 @@ "bundlewatch": "^0.4.1", "jsdom": "26.1.0", "minimatch": "^10.0.3", + "tsdown": "catalog:repo", "webpack-merge": "^5.10.0" }, "engines": { diff --git a/packages/clerk-js/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/clerk-js/src/__tests__/__snapshots__/exports.test.ts.snap new file mode 100644 index 00000000000..cae80597cb7 --- /dev/null +++ b/packages/clerk-js/src/__tests__/__snapshots__/exports.test.ts.snap @@ -0,0 +1,16 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`module exports > bundled export (bundled.ts) > should have the expected shape 1`] = ` +[ + "ClerkJS", + "__brand", + "version", +] +`; + +exports[`module exports > server export (server.ts) > should have the expected shape 1`] = ` +[ + "__brand", + "version", +] +`; diff --git a/packages/clerk-js/src/__tests__/exports.test.ts b/packages/clerk-js/src/__tests__/exports.test.ts new file mode 100644 index 00000000000..88e4f37c9fd --- /dev/null +++ b/packages/clerk-js/src/__tests__/exports.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import { js } from '../bundled'; +import { js as serverJs } from '../server'; + +describe('module exports', () => { + describe('bundled export (bundled.ts)', () => { + it('should have the expected shape', () => { + expect(Object.keys(js).sort()).toMatchSnapshot(); + }); + + it('should include __brand marker', () => { + expect((js as any).__brand).toBe('__clerkJS'); + }); + + it('should include ClerkJS constructor', () => { + expect((js as any).ClerkJS).toBeDefined(); + expect(typeof (js as any).ClerkJS).toBe('function'); + }); + + it('should include version', () => { + expect((js as any).version).toBeDefined(); + expect(typeof (js as any).version).toBe('string'); + }); + }); + + describe('server export (server.ts)', () => { + it('should have the expected shape', () => { + expect(Object.keys(serverJs).sort()).toMatchSnapshot(); + }); + + it('should include __brand marker', () => { + expect((serverJs as any).__brand).toBe('__clerkJS'); + }); + + it('should NOT include ClerkJS constructor', () => { + expect((serverJs as any).ClerkJS).toBeUndefined(); + }); + + it('should include version', () => { + expect((serverJs as any).version).toBeDefined(); + expect(typeof (serverJs as any).version).toBe('string'); + }); + }); +}); diff --git a/packages/clerk-js/src/bundled.ts b/packages/clerk-js/src/bundled.ts new file mode 100644 index 00000000000..f2c46ab4631 --- /dev/null +++ b/packages/clerk-js/src/bundled.ts @@ -0,0 +1,26 @@ +import { Clerk } from './core/clerk'; +import type { Js } from './internal'; +import { JS_BRAND } from './internal'; + +declare const PACKAGE_VERSION: string; + +/** + * JS object for bundled Clerk JS. + * Pass this to ClerkProvider to use the bundled clerk-js instead of loading from CDN. + * + * @example + * ```tsx + * import { js } from '@clerk/clerk-js/bundled'; + * + * + * ... + * + * ``` + */ +export const js = { + __brand: JS_BRAND, + version: PACKAGE_VERSION, + ClerkJS: Clerk, +} as unknown as Js; + +export type { Js } from './internal'; diff --git a/packages/clerk-js/src/entry.ts b/packages/clerk-js/src/entry.ts new file mode 100644 index 00000000000..3a5f7ad0ae8 --- /dev/null +++ b/packages/clerk-js/src/entry.ts @@ -0,0 +1,5 @@ +/** + * Entry point for dynamic import of ClerkJS constructor. + * Used by the SDK when the js prop is a server-safe marker (without ClerkJS constructor). + */ +export { Clerk as ClerkJS } from './core/clerk'; diff --git a/packages/clerk-js/src/internal/index.ts b/packages/clerk-js/src/internal/index.ts new file mode 100644 index 00000000000..41c74d50d27 --- /dev/null +++ b/packages/clerk-js/src/internal/index.ts @@ -0,0 +1,35 @@ +import type { BrowserClerkConstructor } from '@clerk/shared/types'; + +declare const Tags: unique symbol; +type Tagged = BaseType & { [Tags]: { [K in Tag]: void } }; + +/** + * Runtime brand value to identify valid JS objects + */ +export const JS_BRAND = '__clerkJS' as const; + +/** + * Js type that carries type information via phantom property + * Tagged to ensure only official js objects from @clerk/clerk-js can be used + * + * ClerkJS is optional to support server-safe marker exports (react-server condition). + * When ClerkJS is absent, the SDK will dynamically import it. + */ +export type Js = Tagged< + { + /** + * Runtime brand to identify valid JS objects + */ + __brand: typeof JS_BRAND; + /** + * ClerkJS constructor. Optional to support server-safe marker exports. + * When absent (e.g., in React Server Components), the SDK resolves it via dynamic import. + */ + ClerkJS?: BrowserClerkConstructor; + /** + * Version of the JS package (for potential future use) + */ + version?: string; + }, + 'ClerkJS' +>; diff --git a/packages/clerk-js/src/server.ts b/packages/clerk-js/src/server.ts new file mode 100644 index 00000000000..af80aa69157 --- /dev/null +++ b/packages/clerk-js/src/server.ts @@ -0,0 +1,28 @@ +import type { Js } from './internal'; +import { JS_BRAND } from './internal'; + +declare const PACKAGE_VERSION: string; + +/** + * Server-safe JS marker for React Server Components. + * + * This export does not include the ClerkJS constructor, making it safe to import + * in server components. The constructor is resolved via dynamic import when needed. + * + * @example + * ```tsx + * // app/layout.tsx (server component) + * import { ClerkProvider } from '@clerk/nextjs'; + * import { js } from '@clerk/clerk-js/bundled'; + * + * export default function Layout({ children }) { + * return {children}; + * } + * ``` + */ +export const js = { + __brand: JS_BRAND, + version: PACKAGE_VERSION, +} as unknown as Js; + +export type { Js } from './internal'; diff --git a/packages/clerk-js/tsconfig.declarations.json b/packages/clerk-js/tsconfig.declarations.json index 1ed4e456652..fb8615b6d29 100644 --- a/packages/clerk-js/tsconfig.declarations.json +++ b/packages/clerk-js/tsconfig.declarations.json @@ -11,5 +11,5 @@ "declarationDir": "./dist/types", "noImplicitReturns": false }, - "include": ["src/index.ts", "src/index.browser.ts", "src/**/*.d.ts"] + "include": ["src/index.ts", "src/index.browser.ts", "src/internal/index.ts", "src/**/*.d.ts"] } diff --git a/packages/clerk-js/tsdown.config.mts b/packages/clerk-js/tsdown.config.mts new file mode 100644 index 00000000000..a35c6cdf16b --- /dev/null +++ b/packages/clerk-js/tsdown.config.mts @@ -0,0 +1,34 @@ +import type { Options } from 'tsdown'; +import { defineConfig } from 'tsdown'; + +import clerkJsPackage from './package.json' with { type: 'json' }; + +export default defineConfig(({ watch }) => { + const common = { + dts: true, + sourcemap: true, + clean: false, + target: 'es2022', + platform: 'browser', + external: ['@clerk/shared'], + format: ['esm'], + minify: false, + define: { + PACKAGE_NAME: `"${clerkJsPackage.name}"`, + PACKAGE_VERSION: `"${clerkJsPackage.version}"`, + __DEV__: `${watch}`, + __BUILD_DISABLE_RHC__: JSON.stringify(false), + }, + } satisfies Options; + + return [ + // Build internal types, server (marker), entry point, and bundled wrapper + // These are unbundled - the user's bundler handles dependency resolution + { + ...common, + entry: ['./src/internal/index.ts', './src/server.ts', './src/entry.ts', './src/bundled.ts'], + outDir: './dist/esm', + unbundle: true, + }, + ]; +}); diff --git a/packages/clerk-js/vitest.config.mts b/packages/clerk-js/vitest.config.mts index e343397351c..b86333b7e29 100644 --- a/packages/clerk-js/vitest.config.mts +++ b/packages/clerk-js/vitest.config.mts @@ -29,6 +29,8 @@ export default defineConfig({ __BUILD_VARIANT_CHIPS__: JSON.stringify(false), __PKG_NAME__: JSON.stringify('@clerk/clerk-js'), __PKG_VERSION__: JSON.stringify('test'), + PACKAGE_NAME: JSON.stringify('@clerk/clerk-js'), + PACKAGE_VERSION: JSON.stringify('0.0.0-test'), }, test: { coverage: { diff --git a/packages/nextjs/src/app-router/client/ClerkProvider.tsx b/packages/nextjs/src/app-router/client/ClerkProvider.tsx index 3177c997026..82dd83846d4 100644 --- a/packages/nextjs/src/app-router/client/ClerkProvider.tsx +++ b/packages/nextjs/src/app-router/client/ClerkProvider.tsx @@ -2,6 +2,7 @@ import { ClerkProvider as ReactClerkProvider } from '@clerk/react'; import type { Ui } from '@clerk/react/internal'; import { InitialStateProvider } from '@clerk/shared/react'; +import type { BrowserClerkConstructor } from '@clerk/shared/types'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import React from 'react'; @@ -17,6 +18,8 @@ import { invalidateCacheAction } from '../server-actions'; import { useAwaitablePush } from './useAwaitablePush'; import { useAwaitableReplace } from './useAwaitableReplace'; +let _resolvedClerkJS: Promise | undefined; + /** * LazyCreateKeylessApplication should only be loaded if the conditions below are met. * Note: Using lazy() with Suspense instead of dynamic is not possible as React will throw a hydration error when `ClerkProvider` wraps `...` @@ -85,6 +88,20 @@ const NextClientClerkProvider = (props: NextClerkProviderPr routerReplace: replace, }); + // Resolve ClerkJS for RSC: when the js prop is serialized through React Server Components, + // the ClerkJS constructor is stripped (not serializable). Re-import it on the client. + const jsProp = mergedProps.js as { __brand?: string; ClerkJS?: unknown } | undefined; + if (jsProp?.__brand && !jsProp?.ClerkJS) { + // webpackIgnore prevents the bundler from statically resolving @clerk/clerk-js/entry at build time, + // since this import is only needed when the js prop is passed. + // @ts-expect-error - @clerk/clerk-js/entry is resolved by the user's Next.js bundler at runtime + // eslint-disable-next-line import/no-unresolved + _resolvedClerkJS ??= import(/* webpackIgnore: true */ '@clerk/clerk-js/entry').then( + (m: { ClerkJS: BrowserClerkConstructor }) => m.ClerkJS, + ); + mergedProps.js = { ...mergedProps.js, ClerkJS: _resolvedClerkJS }; + } + return ( diff --git a/packages/nextjs/src/types.ts b/packages/nextjs/src/types.ts index 5a43befa80c..6d01585029d 100644 --- a/packages/nextjs/src/types.ts +++ b/packages/nextjs/src/types.ts @@ -1,8 +1,11 @@ import type { ClerkProviderProps } from '@clerk/react'; -import type { Ui } from '@clerk/react/internal'; +import type { Js, Ui } from '@clerk/react/internal'; import type { Without } from '@clerk/shared/types'; -export type NextClerkProviderProps = Without, 'publishableKey'> & { +export type NextClerkProviderProps = Without< + ClerkProviderProps, + 'publishableKey' +> & { /** * Used to override the default NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY env variable if needed. * This is optional for NextJS as the ClerkProvider will automatically use the NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY env variable if it exists. diff --git a/packages/nextjs/src/utils/clerk-script.tsx b/packages/nextjs/src/utils/clerk-script.tsx index d0dc019e0e6..df34e7a1de8 100644 --- a/packages/nextjs/src/utils/clerk-script.tsx +++ b/packages/nextjs/src/utils/clerk-script.tsx @@ -38,7 +38,7 @@ function ClerkScript(props: ClerkScriptProps) { } export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] }) { - const { publishableKey, clerkJSUrl, clerkJSVersion, clerkUIUrl, nonce, prefetchUI, ui } = useClerkNextOptions(); + const { publishableKey, clerkJSUrl, clerkJSVersion, clerkUIUrl, nonce, prefetchUI, ui, js } = useClerkNextOptions(); const { domain, proxyUrl } = useClerk(); if (!publishableKey) { @@ -57,12 +57,14 @@ export function ClerkScripts({ router }: { router: ClerkScriptProps['router'] }) return ( <> - + {!js && ( + + )} {/* Use instead of