diff --git a/.changeset/proud-horses-smell.md b/.changeset/proud-horses-smell.md new file mode 100644 index 00000000000..974bfd4599b --- /dev/null +++ b/.changeset/proud-horses-smell.md @@ -0,0 +1,5 @@ +--- +"@clerk/astro": minor +--- + +Add support for Astro `static` and `hybrid` outputs. diff --git a/integration/presets/astro.ts b/integration/presets/astro.ts index a34a07e4776..5b1bb70ec84 100644 --- a/integration/presets/astro.ts +++ b/integration/presets/astro.ts @@ -20,6 +20,9 @@ const astroNode = applicationConfig() .addDependency('@clerk/types', clerkTypesLocal) .addDependency('@clerk/localizations', clerkLocalizationLocal); +const astroStatic = astroNode.clone().setName('astro-hybrid').useTemplate(templates['astro-hybrid']); + export const astro = { node: astroNode, -}; + static: astroStatic, +} as const; diff --git a/integration/presets/longRunningApps.ts b/integration/presets/longRunningApps.ts index 77f8a27b428..4b08e8e6317 100644 --- a/integration/presets/longRunningApps.ts +++ b/integration/presets/longRunningApps.ts @@ -25,6 +25,7 @@ export const createLongRunningApps = () => { { id: 'quickstart.next.appRouter', config: next.appRouterQuickstart, env: envs.withEmailCodesQuickstart }, { id: 'elements.next.appRouter', config: elements.nextAppRouter, env: envs.withEmailCodes }, { id: 'astro.node.withCustomRoles', config: astro.node, env: envs.withCustomRoles }, + { id: 'astro.static.withCustomRoles', config: astro.static, env: envs.withCustomRoles }, { id: 'expo.expo-web', config: expo.expoWeb, env: envs.withEmailCodes }, ] as const; diff --git a/integration/templates/astro-hybrid/.gitignore b/integration/templates/astro-hybrid/.gitignore new file mode 100644 index 00000000000..016b59ea143 --- /dev/null +++ b/integration/templates/astro-hybrid/.gitignore @@ -0,0 +1,24 @@ +# build output +dist/ + +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store + +# jetbrains setting folder +.idea/ diff --git a/integration/templates/astro-hybrid/astro.config.mjs b/integration/templates/astro-hybrid/astro.config.mjs new file mode 100644 index 00000000000..30ff739e8a3 --- /dev/null +++ b/integration/templates/astro-hybrid/astro.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig } from 'astro/config'; +import clerk from '@clerk/astro'; +import react from '@astrojs/react'; + +export default defineConfig({ + output: 'hybrid', + integrations: [clerk(), react()], + server: { + port: Number(process.env.PORT), + }, +}); diff --git a/integration/templates/astro-hybrid/package.json b/integration/templates/astro-hybrid/package.json new file mode 100644 index 00000000000..e5600f80bb0 --- /dev/null +++ b/integration/templates/astro-hybrid/package.json @@ -0,0 +1,23 @@ +{ + "name": "astro-clerk-hybrid-playground", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "start": "astro dev --port $PORT", + "build": "astro check && astro build", + "preview": "astro preview --port $PORT", + "astro": "astro" + }, + "dependencies": { + "@astrojs/check": "^0.7.0", + "@astrojs/react": "^3.6.0", + "@astrojs/node": "^8.3.2", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "astro": "^4.11.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "^5.5.3" + } +} diff --git a/integration/templates/astro-hybrid/public/favicon.svg b/integration/templates/astro-hybrid/public/favicon.svg new file mode 100644 index 00000000000..f157bd1c5e2 --- /dev/null +++ b/integration/templates/astro-hybrid/public/favicon.svg @@ -0,0 +1,9 @@ + + + + diff --git a/integration/templates/astro-hybrid/src/layouts/Layout.astro b/integration/templates/astro-hybrid/src/layouts/Layout.astro new file mode 100644 index 00000000000..b5c6c9717ba --- /dev/null +++ b/integration/templates/astro-hybrid/src/layouts/Layout.astro @@ -0,0 +1,24 @@ +--- +interface Props { + title: string; +} + +const { title } = Astro.props; +--- + + + + + + + + + + {title} + + +
+ +
+ + diff --git a/integration/templates/astro-hybrid/src/middleware.ts b/integration/templates/astro-hybrid/src/middleware.ts new file mode 100644 index 00000000000..cd6f0baf9d5 --- /dev/null +++ b/integration/templates/astro-hybrid/src/middleware.ts @@ -0,0 +1,3 @@ +import { clerkMiddleware } from '@clerk/astro/server'; + +export const onRequest = clerkMiddleware(); diff --git a/integration/templates/astro-hybrid/src/pages/index.astro b/integration/templates/astro-hybrid/src/pages/index.astro new file mode 100644 index 00000000000..b13e67a0a46 --- /dev/null +++ b/integration/templates/astro-hybrid/src/pages/index.astro @@ -0,0 +1,19 @@ +--- +import { UserButton, SignInButton, SignedIn, SignedOut } from "@clerk/astro/components"; +import { OrganizationSwitcher } from "@clerk/astro/react"; +import Layout from "../layouts/Layout.astro"; + +export const prerender = true; +--- + + + +

Signed out

+ +
+ +

Signed in

+ + +
+
diff --git a/integration/templates/astro-hybrid/src/pages/only-admins.astro b/integration/templates/astro-hybrid/src/pages/only-admins.astro new file mode 100644 index 00000000000..14465c76c80 --- /dev/null +++ b/integration/templates/astro-hybrid/src/pages/only-admins.astro @@ -0,0 +1,13 @@ +--- +import { Protect } from "@clerk/astro/components"; +import Layout from "../layouts/Layout.astro"; + +export const prerender = true; +--- + + + +

I'm an admin

+

Not an admin

+
+
diff --git a/integration/templates/astro-hybrid/src/pages/only-members.astro b/integration/templates/astro-hybrid/src/pages/only-members.astro new file mode 100644 index 00000000000..70eac5ff274 --- /dev/null +++ b/integration/templates/astro-hybrid/src/pages/only-members.astro @@ -0,0 +1,13 @@ +--- +import { Protect } from "@clerk/astro/components"; +import Layout from "../layouts/Layout.astro"; + +export const prerender = false; +--- + + + +

I'm a member

+

Not a member

+
+
diff --git a/integration/templates/astro-hybrid/src/pages/ssr.astro b/integration/templates/astro-hybrid/src/pages/ssr.astro new file mode 100644 index 00000000000..b0ad0b253ef --- /dev/null +++ b/integration/templates/astro-hybrid/src/pages/ssr.astro @@ -0,0 +1,19 @@ +--- +import { UserButton, SignInButton, SignedIn, SignedOut } from "@clerk/astro/components"; +import { OrganizationSwitcher } from "@clerk/astro/react"; +import Layout from "../layouts/Layout.astro"; + +export const prerender = false; +--- + + + +

Signed out

+ +
+ +

Signed in

+ + +
+
diff --git a/integration/templates/astro-hybrid/tsconfig.json b/integration/templates/astro-hybrid/tsconfig.json new file mode 100644 index 00000000000..b7243b92ccf --- /dev/null +++ b/integration/templates/astro-hybrid/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "astro/tsconfigs/strict", + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "react" + } +} diff --git a/integration/templates/index.ts b/integration/templates/index.ts index e4c2f4ad2fe..791590c666f 100644 --- a/integration/templates/index.ts +++ b/integration/templates/index.ts @@ -12,6 +12,7 @@ export const templates = { 'remix-node': resolve(__dirname, './remix-node'), 'elements-next': resolve(__dirname, './elements-next'), 'astro-node': resolve(__dirname, './astro-node'), + 'astro-hybrid': resolve(__dirname, './astro-hybrid'), 'expo-web': resolve(__dirname, './expo-web'), } as const; diff --git a/integration/tests/astro/hybrid.test.ts b/integration/tests/astro/hybrid.test.ts new file mode 100644 index 00000000000..a0ff4c92fb3 --- /dev/null +++ b/integration/tests/astro/hybrid.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from '@playwright/test'; + +import type { FakeOrganization, FakeUser } from '../../testUtils'; +import { createTestUtils, testAgainstRunningApps } from '../../testUtils'; + +testAgainstRunningApps({ withPattern: ['astro.static.withCustomRoles'] })( + 'basic flows for @astro hybrid output', + ({ app }) => { + test.describe.configure({ mode: 'serial' }); + + let fakeAdmin: FakeUser; + let fakeOrganization: FakeOrganization; + let fakeAdmin2: FakeUser; + let fakeOrganization2: FakeOrganization; + + test.beforeAll(async () => { + const m = createTestUtils({ app }); + fakeAdmin = m.services.users.createFakeUser(); + const admin = await m.services.users.createBapiUser(fakeAdmin); + fakeOrganization = await m.services.users.createFakeOrganization(admin.id); + + fakeAdmin2 = m.services.users.createFakeUser(); + const admin2 = await m.services.users.createBapiUser(fakeAdmin2); + fakeOrganization2 = await m.services.users.createFakeOrganization(admin2.id); + }); + + test.afterAll(async () => { + await fakeOrganization.delete(); + await fakeAdmin.deleteIfExists(); + + await fakeOrganization2.delete(); + await fakeAdmin2.deleteIfExists(); + await app.teardown(); + }); + + test('render SignedIn and SignedOut contents (prerendered)', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + + await u.page.waitForClerkJsLoaded(); + + await u.po.expect.toBeSignedOut(); + await expect(u.page.getByText('Signed out')).toBeVisible(); + await expect(u.page.getByText('Signed in')).toBeHidden(); + + await u.page.getByRole('button', { name: /Sign in/i }).click(); + + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + await expect(u.page.getByText('Signed out')).toBeHidden(); + await expect(u.page.getByText('Signed in')).toBeVisible(); + }); + + test('render SignedIn and SignedOut contents (SSR)', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/ssr'); + + await u.page.waitForClerkJsLoaded(); + + await u.po.expect.toBeSignedOut(); + await expect(u.page.getByText('Signed out')).toBeVisible(); + await expect(u.page.getByText('Signed in')).toBeHidden(); + + await u.page.getByRole('button', { name: /Sign in/i }).click(); + + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + await expect(u.page.getByText('Signed out')).toBeHidden(); + await expect(u.page.getByText('Signed in')).toBeVisible(); + }); + + test('render Protect contents for admin', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + + await u.page.waitForClerkJsLoaded(); + + // Sign in + await u.page.getByRole('button', { name: /Sign in/i }).click(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + // Select an organization + await u.po.organizationSwitcher.waitForMounted(); + await u.po.organizationSwitcher.waitForAnOrganizationToSelected(); + + await u.page.goToRelative('/only-admins'); + + await expect(u.page.getByText("I'm an admin")).toBeVisible(); + }); + + test('render Protect fallback', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToAppHome(); + + await u.page.waitForClerkJsLoaded(); + + // Sign in + await u.page.getByRole('button', { name: /Sign in/i }).click(); + await u.po.signIn.waitForMounted(); + await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeAdmin.email, password: fakeAdmin.password }); + await u.po.expect.toBeSignedIn(); + + await u.page.goToRelative('/only-members'); + + await expect(u.page.getByText('Not a member')).toBeVisible(); + }); + }, +); diff --git a/packages/astro/.eslintrc.cjs b/packages/astro/.eslintrc.cjs index 39dd0364e5a..84bc665e0e0 100644 --- a/packages/astro/.eslintrc.cjs +++ b/packages/astro/.eslintrc.cjs @@ -4,11 +4,7 @@ module.exports = { rules: { 'import/no-unresolved': ['error', { ignore: ['^#'] }], }, - ignorePatterns: [ - 'src/astro-components/index.ts', - 'src/astro-components/interactive/UserButton/index.ts', - 'src/astro-components/interactive/UserProfile/index.ts', - ], + ignorePatterns: ['src/astro-components/**/*.ts'], overrides: [ { files: ['./env.d.ts'], diff --git a/packages/astro/.gitignore b/packages/astro/.gitignore index 29d852eaa1c..17fa296850d 100644 --- a/packages/astro/.gitignore +++ b/packages/astro/.gitignore @@ -5,6 +5,7 @@ astro-components/ components/ !src/components/ .output/ +/types.ts .vscode/ .idea/ diff --git a/packages/astro/bundled/package.json b/packages/astro/bundled/package.json deleted file mode 100644 index a59118805b6..00000000000 --- a/packages/astro/bundled/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "main": "../dist/bundled.js" -} diff --git a/packages/astro/package.json b/packages/astro/package.json index 5ed154c0c29..41009fd79dc 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -30,7 +30,7 @@ "dev": "tsup --watch --onSuccess \"npm run build:dts\"", "build": "tsup --onSuccess \"npm run build:dts\" && npm run copy:components", "build:dts": "tsc --emitDeclarationOnly --declaration", - "copy:components": "rm -rf ./components && mkdir -p ./components/ && cp -r ./src/astro-components/* ./components/", + "copy:components": "rm -rf ./components && mkdir -p ./components/ && cp -r ./src/astro-components/* ./components/ && cp ./src/types.ts ./", "lint": "eslint src/", "lint:attw": "attw --pack .", "lint:publint": "publint", @@ -38,12 +38,12 @@ }, "files": [ "dist", - "bundled", "client", "server", "internal", "components", - "env.d.ts" + "env.d.ts", + "types.ts" ], "exports": { ".": { diff --git a/packages/astro/src/astro-components/control/BaseClerkControlElement.ts b/packages/astro/src/astro-components/control/BaseClerkControlElement.ts new file mode 100644 index 00000000000..f992af25978 --- /dev/null +++ b/packages/astro/src/astro-components/control/BaseClerkControlElement.ts @@ -0,0 +1,35 @@ +import { $authStore, $isLoadedStore } from '@clerk/astro/client'; + +export type AuthState = ReturnType; + +export class BaseClerkControlElement extends HTMLElement { + protected authStoreListener: (() => void) | null = null; + protected isLoadedStoreListener: (() => void) | null = null; + + constructor() { + super(); + } + + connectedCallback() { + this.isLoadedStoreListener = $isLoadedStore.subscribe(loaded => { + if (loaded) { + this.toggleContentVisibility(); + } + }); + } + + disconnectedCallback() { + this.authStoreListener?.(); + this.isLoadedStoreListener?.(); + } + + toggleContentVisibility() { + this.authStoreListener = $authStore.subscribe(state => { + this.onAuthStateChange(state); + }); + } + + // This method will be overridden by subclasses + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected onAuthStateChange(state: AuthState): void {} +} diff --git a/packages/astro/src/astro-components/control/Protect.astro b/packages/astro/src/astro-components/control/Protect.astro index 74ba18846d1..e1ccd138c31 100644 --- a/packages/astro/src/astro-components/control/Protect.astro +++ b/packages/astro/src/astro-components/control/Protect.astro @@ -1,38 +1,35 @@ --- -import type { - CheckAuthorizationWithCustomPermissions, - OrganizationCustomPermissionKey, - OrganizationCustomRoleKey, -} from '@clerk/types'; +import ProtectCSR from './ProtectCSR.astro'; +import ProtectSSR from './ProtectSSR.astro'; -type Props = - | { - condition?: never; - role: OrganizationCustomRoleKey; - permission?: never; - } - | { - condition?: never; - role?: never; - permission: OrganizationCustomPermissionKey; - } - | { - condition: (has: CheckAuthorizationWithCustomPermissions) => boolean; - role?: never; - permission?: never; - } - | { - condition?: never; - role?: never; - permission?: never; - }; +import { isStaticOutput } from "virtual:@clerk/astro/config"; +import type { ProtectProps } from '../../types'; -const { has, userId } = Astro.locals.auth(); -const isUnauthorized = - !userId || - (typeof Astro.props.condition === "function" && - !Astro.props.condition(has)) || - ((Astro.props.role || Astro.props.permission) && !has(Astro.props)); +type Props = ProtectProps & { + isStatic?: boolean + /** + * The class name to apply to the outermost element of the component. + * This class is only applied to static components. + */ + class?: string; + /** + * The class name to apply to the wrapper element of the default slot. + * This class is only applied to static components. + */ + defaultSlotWrapperClass?: string; + /** + * The class name to apply to the wrapper element of the fallback slot. + * This class is only applied to static components. + */ + fallbackSlotWrapperClass?: string; +} + +const { isStatic, ...props } = Astro.props; + +const ProtectComponent = isStaticOutput(isStatic) ? ProtectCSR : ProtectSSR; --- -{isUnauthorized ? : } + + + + diff --git a/packages/astro/src/astro-components/control/ProtectCSR.astro b/packages/astro/src/astro-components/control/ProtectCSR.astro new file mode 100644 index 00000000000..18a02b18128 --- /dev/null +++ b/packages/astro/src/astro-components/control/ProtectCSR.astro @@ -0,0 +1,57 @@ +--- +import type { ProtectProps } from '../../types'; + +type Props = Omit & { + class?: string; + defaultSlotWrapperClass?: string; + fallbackSlotWrapperClass?: string; +}; + +const { role, permission, class: className, defaultSlotWrapperClass, fallbackSlotWrapperClass } = Astro.props; +--- + + + + + + + diff --git a/packages/astro/src/astro-components/control/ProtectSSR.astro b/packages/astro/src/astro-components/control/ProtectSSR.astro new file mode 100644 index 00000000000..4f0d58a98ea --- /dev/null +++ b/packages/astro/src/astro-components/control/ProtectSSR.astro @@ -0,0 +1,14 @@ +--- +import type { ProtectProps } from '../../types'; + +type Props = ProtectProps; + +const { has, userId } = Astro.locals.auth(); +const isUnauthorized = + !userId || + (typeof Astro.props.condition === "function" && + !Astro.props.condition(has)) || + ((Astro.props.role || Astro.props.permission) && !has(Astro.props)); +--- + +{isUnauthorized ? : } diff --git a/packages/astro/src/astro-components/control/SignedIn.astro b/packages/astro/src/astro-components/control/SignedIn.astro index c1657391909..4c6c33b0a4c 100644 --- a/packages/astro/src/astro-components/control/SignedIn.astro +++ b/packages/astro/src/astro-components/control/SignedIn.astro @@ -1,6 +1,23 @@ --- -const { userId } = Astro.locals.auth() ---- +import SignedInCSR from './SignedInCSR.astro'; +import SignedInSSR from './SignedInSSR.astro'; + +import { isStaticOutput } from 'virtual:@clerk/astro/config'; + +type Props = { + isStatic?: boolean + /** + * The class name to apply to the outermost element of the component. + * This class is only applied to static components. + */ + class?: string +} +const { isStatic, class: className } = Astro.props + +const SignedInComponent = isStaticOutput(isStatic) ? SignedInCSR : SignedInSSR; +--- -{ userId ? : null } \ No newline at end of file + + + diff --git a/packages/astro/src/astro-components/control/SignedInCSR.astro b/packages/astro/src/astro-components/control/SignedInCSR.astro new file mode 100644 index 00000000000..c1010a4a179 --- /dev/null +++ b/packages/astro/src/astro-components/control/SignedInCSR.astro @@ -0,0 +1,27 @@ +--- +type Props = { + class?: string +} + +const { class: className } = Astro.props +--- + + + + diff --git a/packages/astro/src/astro-components/control/SignedInSSR.astro b/packages/astro/src/astro-components/control/SignedInSSR.astro new file mode 100644 index 00000000000..4253bcfe875 --- /dev/null +++ b/packages/astro/src/astro-components/control/SignedInSSR.astro @@ -0,0 +1,5 @@ +--- +const { userId } = Astro.locals.auth() +--- + +{ userId ? : null } diff --git a/packages/astro/src/astro-components/control/SignedOut.astro b/packages/astro/src/astro-components/control/SignedOut.astro index bbcf2c8cc5c..bbc0be0997c 100644 --- a/packages/astro/src/astro-components/control/SignedOut.astro +++ b/packages/astro/src/astro-components/control/SignedOut.astro @@ -1,6 +1,23 @@ --- -const { userId } = Astro.locals.auth() ---- +import SignedOutCSR from './SignedOutCSR.astro'; +import SignedOutSSR from './SignedOutSSR.astro'; + +import { isStaticOutput } from 'virtual:@clerk/astro/config'; + +type Props = { + isStatic?: boolean + /** + * The class name to apply to the outermost element of the component. + * This class is only applied to static components. + */ + class?: string +} +const { isStatic, class: className } = Astro.props + +const SignedOutComponent = isStaticOutput(isStatic) ? SignedOutCSR : SignedOutSSR; +--- -{ !userId ? : null } \ No newline at end of file + + +c diff --git a/packages/astro/src/astro-components/control/SignedOutCSR.astro b/packages/astro/src/astro-components/control/SignedOutCSR.astro new file mode 100644 index 00000000000..4540ef42ec9 --- /dev/null +++ b/packages/astro/src/astro-components/control/SignedOutCSR.astro @@ -0,0 +1,27 @@ +--- +type Props = { + class?: string +} + +const { class: className } = Astro.props +--- + + + + diff --git a/packages/astro/src/astro-components/control/SignedOutSSR.astro b/packages/astro/src/astro-components/control/SignedOutSSR.astro new file mode 100644 index 00000000000..8b1b8154ad9 --- /dev/null +++ b/packages/astro/src/astro-components/control/SignedOutSSR.astro @@ -0,0 +1,5 @@ +--- +const { userId } = Astro.locals.auth() +--- + +{ !userId ? : null } diff --git a/packages/astro/src/env.d.ts b/packages/astro/src/env.d.ts index 57dc7096ab1..0d0c534341c 100644 --- a/packages/astro/src/env.d.ts +++ b/packages/astro/src/env.d.ts @@ -29,3 +29,10 @@ declare namespace App { runtime: { env: InternalEnv }; } } + +declare module 'virtual:@clerk/astro/config' { + import type { AstroConfig } from 'astro'; + + export const astroConfig: AstroConfig; + export function isStaticOutput(forceStatic?: boolean): boolean; +} diff --git a/packages/astro/src/integration/create-integration.ts b/packages/astro/src/integration/create-integration.ts index 246bba537af..b640dedbbf4 100644 --- a/packages/astro/src/integration/create-integration.ts +++ b/packages/astro/src/integration/create-integration.ts @@ -3,6 +3,7 @@ import type { AstroIntegration } from 'astro'; import { name as packageName, version as packageVersion } from '../../package.json'; import type { AstroClerkIntegrationParams } from '../types'; +import { vitePluginAstroConfig } from './vite-plugin-astro-config'; const buildEnvVarFromOption = (valueToBeStored: unknown, envName: keyof InternalEnv) => { return valueToBeStored ? { [`import.meta.env.${envName}`]: JSON.stringify(valueToBeStored) } : {}; @@ -27,11 +28,7 @@ function createIntegration() name: '@clerk/astro/integration', hooks: { 'astro:config:setup': ({ config, injectScript, updateConfig, logger, command }) => { - if (config.output === 'static') { - logger.error(`${packageName} requires SSR to be turned on. Please update output to "server"`); - } - - if (!config.adapter) { + if (['server', 'hybrid'].includes(config.output) && !config.adapter) { logger.error('Missing adapter, please update your Astro config to use one.'); } @@ -53,6 +50,7 @@ function createIntegration() // Set params as envs so backend code has access to them updateConfig({ vite: { + plugins: [vitePluginAstroConfig(config)], define: { /** * Convert the integration params to environment variable in order for it to be readable from the server @@ -62,8 +60,6 @@ function createIntegration() ...buildEnvVarFromOption(isSatellite, 'PUBLIC_CLERK_IS_SATELLITE'), ...buildEnvVarFromOption(proxyUrl, 'PUBLIC_CLERK_PROXY_URL'), ...buildEnvVarFromOption(domain, 'PUBLIC_CLERK_DOMAIN'), - ...buildEnvVarFromOption(domain, 'PUBLIC_CLERK_DOMAIN'), - ...buildEnvVarFromOption(domain, 'PUBLIC_CLERK_DOMAIN'), ...buildEnvVarFromOption(clerkJSUrl, 'PUBLIC_CLERK_JS_URL'), ...buildEnvVarFromOption(clerkJSVariant, 'PUBLIC_CLERK_JS_VARIANT'), ...buildEnvVarFromOption(clerkJSVersion, 'PUBLIC_CLERK_JS_VERSION'), diff --git a/packages/astro/src/integration/vite-plugin-astro-config.ts b/packages/astro/src/integration/vite-plugin-astro-config.ts new file mode 100644 index 00000000000..f9505aeb580 --- /dev/null +++ b/packages/astro/src/integration/vite-plugin-astro-config.ts @@ -0,0 +1,44 @@ +import type { AstroConfig } from 'astro'; + +type VitePlugin = Required['plugins'][number]; + +/** + * This Vite module exports a `isStaticOutput` function that is imported inside our control components + * to determine which components to use depending on the Astro config output option. + * + * @param {AstroConfig} astroConfig - The Astro configuration object + * @returns {VitePlugin} A Vite plugin + */ +export function vitePluginAstroConfig(astroConfig: AstroConfig): VitePlugin { + const virtualModuleId = 'virtual:@clerk/astro/config'; + const resolvedVirtualModuleId = '\0' + virtualModuleId; + + return { + name: 'vite-plugin-astro-config', + resolveId(id) { + if (id === virtualModuleId) { + return resolvedVirtualModuleId; + } + }, + load(id) { + if (id === resolvedVirtualModuleId) { + return ` + export const astroConfig = ${JSON.stringify(astroConfig)}; + + export function isStaticOutput(forceStatic) { + if (astroConfig.output === 'hybrid' && forceStatic === undefined) { + // Default page is prerendered in hybrid mode + return true; + } + + if (forceStatic !== undefined) { + return forceStatic; + } + + return astroConfig.output === 'static'; + } + `; + } + }, + }; +} diff --git a/packages/astro/src/react/controlComponents.tsx b/packages/astro/src/react/controlComponents.tsx index 22c1bd65ccf..5f04461401e 100644 --- a/packages/astro/src/react/controlComponents.tsx +++ b/packages/astro/src/react/controlComponents.tsx @@ -1,14 +1,10 @@ -import type { - CheckAuthorizationWithCustomPermissions, - HandleOAuthCallbackParams, - OrganizationCustomPermissionKey, - OrganizationCustomRoleKey, -} from '@clerk/types'; +import type { CheckAuthorizationWithCustomPermissions, HandleOAuthCallbackParams } from '@clerk/types'; import { computed } from 'nanostores'; import type { PropsWithChildren } from 'react'; import React, { useEffect, useState } from 'react'; import { $csrState } from '../stores/internal'; +import type { ProtectProps as _ProtectProps } from '../types'; import { useAuth } from './hooks'; import type { WithClerkProp } from './utils'; import { withClerk } from './utils'; @@ -73,32 +69,7 @@ export const ClerkLoading = ({ children }: React.PropsWithChildren): JSX.Element return <>{children}; }; -export type ProtectProps = React.PropsWithChildren< - ( - | { - condition?: never; - role: OrganizationCustomRoleKey; - permission?: never; - } - | { - condition?: never; - role?: never; - permission: OrganizationCustomPermissionKey; - } - | { - condition: (has: CheckAuthorizationWithCustomPermissions) => boolean; - role?: never; - permission?: never; - } - | { - condition?: never; - role?: never; - permission?: never; - } - ) & { - fallback?: React.ReactNode; - } ->; +export type ProtectProps = React.PropsWithChildren<_ProtectProps & { fallback?: React.ReactNode }>; /** * Use `` in order to prevent unauthenticated or unauthorized users from accessing the children passed to the component. diff --git a/packages/astro/src/stores/external.ts b/packages/astro/src/stores/external.ts index 3ce77d20a8f..5dbcb2ef88d 100644 --- a/packages/astro/src/stores/external.ts +++ b/packages/astro/src/stores/external.ts @@ -4,6 +4,16 @@ import { batched, computed, onMount, type Store } from 'nanostores'; import { $clerk, $csrState, $initialState } from './internal'; +/** + * A client side store that returns the loaded state of clerk-js. + * + * @example + * A simple example: + * + * $isLoadedStore.subscribe((authloaded => console.log(loaded)) + */ +export const $isLoadedStore = computed([$csrState], state => state.isLoaded); + /** * A client side store that is prepopulated with the authentication context during SSR. * It is a nanostore, for instructions on how to use nanostores please review the [documentation](https://github.com/nanostores/nanostores) diff --git a/packages/astro/src/stores/utils.ts b/packages/astro/src/stores/utils.ts deleted file mode 100644 index efb6aadef9f..00000000000 --- a/packages/astro/src/stores/utils.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { - ActiveSessionResource, - InitialState, - OrganizationCustomPermissionKey, - OrganizationCustomRoleKey, - OrganizationResource, - Resources, - UserResource, -} from '@clerk/types'; - -export function deriveState(clerkLoaded: boolean, state: Resources, initialState: InitialState | undefined) { - if (!clerkLoaded && initialState) { - return { - ...deriveFromSsrInitialState(initialState), - clerkLoaded, - }; - } - - return { - ...deriveFromClientSideState(state), - clerkLoaded, - }; -} - -function deriveFromSsrInitialState(initialState: InitialState) { - const userId = initialState.userId; - const user = initialState.user as any as UserResource; - const sessionId = initialState.sessionId; - const session = initialState.session as any as ActiveSessionResource; - const organization = initialState.organization as any 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, - }; -} - -function 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/astro/src/types.ts b/packages/astro/src/types.ts index 9462b751ec2..f70631f5eb5 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -1,4 +1,13 @@ -import type { Clerk, ClerkOptions, ClientResource, MultiDomainAndOrProxyPrimitives, Without } from '@clerk/types'; +import type { + CheckAuthorizationWithCustomPermissions, + Clerk, + ClerkOptions, + ClientResource, + MultiDomainAndOrProxyPrimitives, + OrganizationCustomPermissionKey, + OrganizationCustomRoleKey, + Without, +} from '@clerk/types'; type AstroClerkUpdateOptions = Pick; @@ -38,4 +47,26 @@ declare global { } } -export type { AstroClerkUpdateOptions, AstroClerkIntegrationParams, AstroClerkCreateInstanceParams }; +type ProtectProps = + | { + condition?: never; + role: OrganizationCustomRoleKey; + permission?: never; + } + | { + condition?: never; + role?: never; + permission: OrganizationCustomPermissionKey; + } + | { + condition: (has: CheckAuthorizationWithCustomPermissions) => boolean; + role?: never; + permission?: never; + } + | { + condition?: never; + role?: never; + permission?: never; + }; + +export type { AstroClerkUpdateOptions, AstroClerkIntegrationParams, AstroClerkCreateInstanceParams, ProtectProps }; diff --git a/packages/astro/tsconfig.json b/packages/astro/tsconfig.json index d1e9ae67c05..514256274a8 100644 --- a/packages/astro/tsconfig.json +++ b/packages/astro/tsconfig.json @@ -24,5 +24,12 @@ "declarationDir": "dist/types", "noUncheckedIndexedAccess": true }, - "exclude": ["dist", "build", "node_modules", "src/astro-components"] + "exclude": [ + "dist", + "build", + "node_modules", + // We're ignoring the files below because they are published as-is and is not processed/bundled with tsup. + // This cause TS to throw errors even though we're not bundling it. + "src/astro-components/**/*.ts" + ] }