diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index a41a1197d97..77f74580d60 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,9 +76,10 @@ "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:esm": "tsdown", "build:declarations": "tsc -p tsconfig.declarations.json", "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\"", @@ -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/bundled.ts b/packages/clerk-js/src/bundled.ts new file mode 100644 index 00000000000..aa2d52e7787 --- /dev/null +++ b/packages/clerk-js/src/bundled.ts @@ -0,0 +1,27 @@ +import type { Js } from './internal'; +import { JS_BRAND } from './internal'; + +import { Clerk } from './core/clerk'; + +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..d159251e99a --- /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..fcc76912c0c --- /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/tsdown.config.mts b/packages/clerk-js/tsdown.config.mts new file mode 100644 index 00000000000..de221c336be --- /dev/null +++ b/packages/clerk-js/tsdown.config.mts @@ -0,0 +1,35 @@ +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}"`, + __PKG_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/react/package.json b/packages/react/package.json index 057e59ba733..4ebe16e15e9 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -107,6 +107,7 @@ "tslib": "catalog:repo" }, "devDependencies": { + "@clerk/clerk-js": "workspace:*", "@clerk/localizations": "workspace:*", "@clerk/ui": "workspace:*", "@types/semver": "^7.7.1", diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index e42d315677f..32942367129 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -76,6 +76,7 @@ import type { HeadlessBrowserClerkConstructor, IsomorphicClerkOptions, } from './types'; +import { JS_BRAND } from './types'; import { isConstructor } from './utils'; if (typeof globalThis.__BUILD_DISABLE_RHC__ === 'undefined') { @@ -91,6 +92,7 @@ const SDK_METADATA = { export interface Global { Clerk?: HeadlessBrowserClerk | BrowserClerk; __internal_ClerkUICtor?: ClerkUiConstructor; + __internal_ClerkJSCtor?: BrowserClerkConstructor; } declare const global: Global; @@ -487,10 +489,32 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { } private async getClerkJsEntryChunk(): Promise { - // Hotload bundle - if (!this.options.Clerk && !__BUILD_DISABLE_RHC__) { + // Support bundled JS via js.ClerkJS prop + const jsProp = (this.options as { js?: { __brand?: string; ClerkJS?: BrowserClerkConstructor } }).js; + if (jsProp?.ClerkJS) { + global.Clerk = new jsProp.ClerkJS(this.#publishableKey, { proxyUrl: this.proxyUrl, domain: this.domain }); + return global.Clerk; + } + + // Support server-safe JS marker (react-server condition) + // When js prop is present but ClerkJS is absent, dynamically import + if (jsProp?.__brand === JS_BRAND) { + const { ClerkJs } = await import('@clerk/clerk-js/entry'); + global.Clerk = new ClerkJs(this.#publishableKey, { proxyUrl: this.proxyUrl, domain: this.domain }); + return global.Clerk; + } + + // Legacy: Support bundled Clerk via Clerk prop + if (this.options.Clerk) { + global.Clerk = isConstructor(this.options.Clerk) + ? new this.options.Clerk(this.#publishableKey, { proxyUrl: this.proxyUrl, domain: this.domain }) + : this.options.Clerk; + return global.Clerk; + } + + // Hotload bundle from CDN + if (!__BUILD_DISABLE_RHC__) { // the UMD script sets the global.Clerk instance - // we do not want to await here as we await loadClerkJSScript({ ...this.options, publishableKey: this.#publishableKey, @@ -500,13 +524,6 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { }); } - // Otherwise, set global.Clerk to the bundled ctor or instance - if (this.options.Clerk) { - global.Clerk = isConstructor(this.options.Clerk) - ? new this.options.Clerk(this.#publishableKey, { proxyUrl: this.proxyUrl, domain: this.domain }) - : this.options.Clerk; - } - if (!global.Clerk) { // TODO @nikos: somehow throw if clerk ui failed to load but it was not headless throw new Error('Failed to download latest ClerkJS. Contact support@clerk.com.'); diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 96c7e7a0b53..58293b6975b 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,4 +1,5 @@ import type { + BrowserClerkConstructor, Clerk, InitialState, IsomorphicClerkOptions, @@ -12,6 +13,40 @@ import type { ClerkUiConstructor } from '@clerk/shared/ui'; import type { Appearance, ExtractAppearanceType, Ui } from '@clerk/ui/internal'; import type React from 'react'; +/** + * Runtime brand value to identify valid JS objects + */ +export const JS_BRAND = '__clerkJS' as const; + +declare const Tags: unique symbol; +type Tagged = BaseType & { [Tags]: { [K in Tag]: void } }; + +/** + * 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' +>; + // Re-export types from @clerk/shared that are used by other modules export type { IsomorphicClerkOptions, @@ -30,13 +65,14 @@ declare global { __clerk_proxy_url?: Clerk['proxyUrl']; __clerk_domain?: Clerk['domain']; __internal_ClerkUICtor?: ClerkUiConstructor; + __internal_ClerkJSCtor?: BrowserClerkConstructor; } } /** * @interface */ -export type ClerkProviderProps = Omit & { +export type ClerkProviderProps = Omit & { children: React.ReactNode; /** * Provide an initial state of the Clerk client during server-side rendering. You don't need to set this value yourself unless you're [developing an SDK](https://clerk.com/docs/guides/development/sdk-development/overview). @@ -59,6 +95,12 @@ export type ClerkProviderProps = Omit = T & { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adb035f97a6..7ce8bfd24e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -544,6 +544,9 @@ importers: minimatch: specifier: ^10.0.3 version: 10.1.1 + tsdown: + specifier: catalog:repo + version: 0.15.7(publint@0.3.15)(typescript@5.8.3)(vue-tsc@3.1.4(typescript@5.8.3)) webpack-merge: specifier: ^5.10.0 version: 5.10.0 @@ -791,6 +794,9 @@ importers: specifier: catalog:repo version: 2.8.1 devDependencies: + '@clerk/clerk-js': + specifier: workspace:* + version: link:../clerk-js '@clerk/localizations': specifier: workspace:* version: link:../localizations