Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nextjs): Support Accountless mode #4602

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/brown-items-reflect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/clerk-js': minor
'@clerk/types': minor
---

accountless
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once we're ready, let's expand the changeset description here. And consolidate the two changeset files.

6 changes: 6 additions & 0 deletions .changeset/poor-books-hug.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@clerk/backend': minor
'@clerk/nextjs': minor
---

accountless
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo

6 changes: 3 additions & 3 deletions integration/tests/next-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
'src/app/nested-provider/page.tsx',
() => `import { ClerkProvider } from '@clerk/nextjs';
import { ClientComponent } from './client';

export default function Page() {
return (
<ClerkProvider dynamic>
Expand All @@ -147,10 +147,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
() => `'use client';

import { useAuth } from '@clerk/nextjs';

export function ClientComponent() {
useAuth();

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is just auto formatting

return <p>I am dynamically rendered</p>;
}
`,
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/api/endpoints/AccountlessApplicationsAPI.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { AccountlessApplication } from '../resources/AccountlessApplication';
import { AbstractAPI } from './AbstractApi';

const basePath = '/accountless_applications';

export class AccountlessApplicationAPI extends AbstractAPI {
public async createAccountlessApplication() {
return this.request<AccountlessApplication>({
method: 'POST',
path: basePath,
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './AccountlessApplicationsAPI';
export * from './AbstractApi';
export * from './AllowlistIdentifierApi';
export * from './ClientApi';
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
AccountlessApplicationAPI,
AllowlistIdentifierAPI,
ClientAPI,
DomainAPI,
Expand All @@ -23,6 +24,9 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
const request = buildRequest(options);

return {
__experimental_accountlessApplications: new AccountlessApplicationAPI(
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'd like to have this as experimental for a while

buildRequest({ ...options, skipSecretKey: true }),
),
allowlistIdentifiers: new AllowlistIdentifierAPI(request),
clients: new ClientAPI(request),
emailAddresses: new EmailAddressAPI(request),
Expand Down
14 changes: 12 additions & 2 deletions packages/backend/src/api/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,23 @@ type BuildRequestOptions = {
apiVersion?: string;
/* Library/SDK name */
userAgent?: string;

skipSecretKey?: boolean;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the endpoint for creating the accountless keys doesn't require a secret key

};
export function buildRequest(options: BuildRequestOptions) {
const requestFn = async <T>(requestOptions: ClerkBackendApiRequestOptions): Promise<ClerkBackendApiResponse<T>> => {
const { secretKey, apiUrl = API_URL, apiVersion = API_VERSION, userAgent = USER_AGENT } = options;
const {
secretKey,
skipSecretKey = false,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think requireSecretKey might be a better name.

Suggested change
skipSecretKey = false,
requireSecretKey = false,

apiUrl = API_URL,
apiVersion = API_VERSION,
userAgent = USER_AGENT,
} = options;
const { path, method, queryParams, headerParams, bodyParams, formData } = requestOptions;

assertValidSecretKey(secretKey);
if (!skipSecretKey) {
assertValidSecretKey(secretKey);
}

const url = joinPaths(apiUrl, apiVersion, path);

Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/api/resources/AccountlessApplication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { AccountlessApplicationJSON } from './JSON';

export class AccountlessApplication {
constructor(
readonly publishableKey: string,
readonly secretKey: string,
readonly claimUrl: string,
) {}

static fromJSON(data: AccountlessApplicationJSON): AccountlessApplication {
return new AccountlessApplication(data.publishable_key, data.secret_key, data.claim_url);
}
}
3 changes: 3 additions & 0 deletions packages/backend/src/api/resources/Deserializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
Token,
User,
} from '.';
import { AccountlessApplication } from './AccountlessApplication';
import type { PaginatedResponseJSON } from './JSON';
import { ObjectType } from './JSON';

Expand Down Expand Up @@ -65,6 +66,8 @@ function jsonToObject(item: any): any {
}

switch (item.object) {
case ObjectType.AccountlessApplication:
return AccountlessApplication.fromJSON(item);
case ObjectType.AllowlistIdentifier:
return AllowlistIdentifier.fromJSON(item);
case ObjectType.Client:
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/api/resources/JSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
} from './Enums';

export const ObjectType = {
AccountlessApplication: 'accountless_application',
AllowlistIdentifier: 'allowlist_identifier',
Client: 'client',
Email: 'email',
Expand Down Expand Up @@ -48,6 +49,13 @@ export interface TokenJSON {
jwt: string;
}

export interface AccountlessApplicationJSON extends ClerkResourceJSON {
object: typeof ObjectType.AccountlessApplication;
publishable_key: string;
secret_key: string;
claim_url: string;
}

export interface AllowlistIdentifierJSON extends ClerkResourceJSON {
object: typeof ObjectType.AllowlistIdentifier;
identifier: string;
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/api/resources/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './AccountlessApplication';
export * from './AllowlistIdentifier';
export * from './Client';
export * from './DeletedObject';
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export type { VerifyTokenOptions } from './tokens/verify';
* JSON types
*/
export type {
AccountlessApplicationJSON,
ClerkResourceJSON,
TokenJSON,
AllowlistIdentifierJSON,
Expand Down Expand Up @@ -87,6 +88,7 @@ export type {
* Resources
*/
export type {
AccountlessApplication,
AllowlistIdentifier,
Client,
EmailAddress,
Expand Down
10 changes: 10 additions & 0 deletions packages/nextjs/package.cjs.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
"#components": {
"react-server": "./components.server.js",
"default": "./components.client.js"
},
"#fs": {
"edge-light": "./runtime/browser/fs.js",
"worker": "./runtime/browser/fs.js",
"browser": "./runtime/browser/fs.js",
"node": {
"require": "./runtime/node/fs.js",
"import": "./runtime/node/fs.js"
},
"default": "./runtime/browser/fs.js"
Comment on lines +7 to +16
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixes a bug in nextjs where importing from node:fs is prohibited and the dev server or build step fails even if the code is wrapped with try/catch.

}
}
}
10 changes: 10 additions & 0 deletions packages/nextjs/package.esm.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@
"#components": {
"react-server": "./components.server.js",
"default": "./components.client.js"
},
"#fs": {
"edge-light": "./runtime/browser/fs.js",
"worker": "./runtime/browser/fs.js",
"browser": "./runtime/browser/fs.js",
"node": {
"require": "./runtime/node/fs.js",
"import": "./runtime/node/fs.js"
},
"default": "./runtime/browser/fs.js"
Comment on lines +8 to +16
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixes a bug in nextjs where importing from node:fs is prohibited and the dev server or build step fails even if the code is wrapped with try/catch.

}
}
}
44 changes: 44 additions & 0 deletions packages/nextjs/src/app-router/accountless-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
'use server';
import type { AccountlessApplication } from '@clerk/backend';
import { isDevelopmentEnvironment } from '@clerk/shared/utils';
import { getCookies } from 'ezheaders';
import { redirect, RedirectType } from 'next/navigation';

import { getAccountlessCookieName } from '../server/accountless';
import { ALLOW_ACCOUNTLESS } from '../server/constants';
import { isNextWithUnstableServerActions } from '../utils/sdk-versions';

export async function syncAccountlessKeysAction(args: AccountlessApplication & { returnUrl: string }): Promise<void> {
const { claimUrl, publishableKey, secretKey, returnUrl } = args;
void (await getCookies()).set(getAccountlessCookieName(), JSON.stringify({ claimUrl, publishableKey, secretKey }), {
secure: true,
httpOnly: true,
});

// TODO-ACCOUNTLESS: Do we even need this ? I think setting the cookie will reset the router cache.
redirect(`/clerk-sync-accountless?returnUrl=${returnUrl}`, RedirectType.replace);
Comment on lines +18 to +19
Copy link
Member

@BRKalow BRKalow Nov 27, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the route for specifically? Would be nice if we could remove! Would it be enough if we triggered a full page refresh?

}

export async function createAccountlessKeysAction(): Promise<null | Omit<AccountlessApplication, 'secretKey'>> {
if (!isDevelopmentEnvironment() || isNextWithUnstableServerActions || !ALLOW_ACCOUNTLESS) {
return null;
}

const result = await import('../server/accountless-node.js').then(m => m.createAccountlessKeys());

if (!result) {
return null;
}

const { claimUrl, publishableKey, secretKey } = result;

void (await getCookies()).set(getAccountlessCookieName(), JSON.stringify({ claimUrl, publishableKey, secretKey }), {
secure: false,
httpOnly: false,
});

return {
claimUrl,
publishableKey,
};
}
28 changes: 27 additions & 1 deletion packages/nextjs/src/app-router/client/ClerkProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,28 @@
'use client';
import { ClerkProvider as ReactClerkProvider } from '@clerk/clerk-react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import React, { useEffect, useTransition } from 'react';

import { useSafeLayoutEffect } from '../../client-boundary/hooks/useSafeLayoutEffect';
import { ClerkNextOptionsProvider, useClerkNextOptions } from '../../client-boundary/NextOptionsContext';
import { ALLOW_ACCOUNTLESS } from '../../server/constants';
import type { NextClerkProviderProps } from '../../types';
import { ClerkJSScript } from '../../utils/clerk-js-script';
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
import { isNextWithUnstableServerActions } from '../../utils/sdk-versions';
import { invalidateCacheAction } from '../server-actions';
import { useAwaitablePush } from './useAwaitablePush';
import { useAwaitableReplace } from './useAwaitableReplace';

/**
* Accountless creator 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 `<html><body>...`
*/
const LazyAccountlessCreator = dynamic(() =>
import('./lazy-accountless-creator.js').then(m => m.AccountlessCreateKeys),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the extension necessary?

Suggested change
import('./lazy-accountless-creator.js').then(m => m.AccountlessCreateKeys),
import('./lazy-accountless-creator').then(m => m.AccountlessCreateKeys),

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i believe eslint was complaining

);

declare global {
export interface Window {
__clerk_nav_await: Array<(value: void) => void>;
Expand All @@ -23,7 +34,7 @@ declare global {
}
}

export const ClientClerkProvider = (props: NextClerkProviderProps) => {
const NextClientClerkProvider = (props: NextClerkProviderProps) => {
const { __unstable_invokeMiddlewareOnAuthStateChange = true, children } = props;
const router = useRouter();
const push = useAwaitablePush();
Expand Down Expand Up @@ -99,3 +110,18 @@ export const ClientClerkProvider = (props: NextClerkProviderProps) => {
</ClerkNextOptionsProvider>
);
};

export const ClientClerkProvider = (props: NextClerkProviderProps) => {
const { children, ...rest } = props;
const safePk = mergeNextClerkPropsWithEnv(rest).publishableKey;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const safePk = mergeNextClerkPropsWithEnv(rest).publishableKey;
const safePublishableKey = mergeNextClerkPropsWithEnv(rest).publishableKey;


if (safePk || isNextWithUnstableServerActions || !ALLOW_ACCOUNTLESS) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (safePk || isNextWithUnstableServerActions || !ALLOW_ACCOUNTLESS) {
if (safePublishableKey || isNextWithUnstableServerActions || !ALLOW_ACCOUNTLESS) {

return <NextClientClerkProvider {...rest}>{children}</NextClientClerkProvider>;
}

return (
<LazyAccountlessCreator>
<NextClientClerkProvider {...rest}>{children}</NextClientClerkProvider>
</LazyAccountlessCreator>
);
};
23 changes: 23 additions & 0 deletions packages/nextjs/src/app-router/client/accountless-cookie-sync.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use client';

import type { AccountlessApplication } from '@clerk/backend';
import type { PropsWithChildren } from 'react';
import { useEffect } from 'react';

import { isNextWithUnstableServerActions } from '../../utils/sdk-versions';

export function AccountlessCookieSync(props: PropsWithChildren<AccountlessApplication>) {
useEffect(() => {
if (!isNextWithUnstableServerActions) {
void import('../accountless-actions.js').then(m =>
m.syncAccountlessKeysAction({
...props,
// Preserve the current url and return back, once keys are synced in the middleware
returnUrl: window.location.href,
}),
);
}
}, []);

return props.children;
}
25 changes: 25 additions & 0 deletions packages/nextjs/src/app-router/client/lazy-accountless-creator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import React, { useEffect } from 'react';

import type { NextClerkProviderProps } from '../../types';
import { createAccountlessKeysAction } from '../accountless-actions';

export const AccountlessCreateKeys = (props: NextClerkProviderProps) => {
const { children } = props;
const [state, fetchKeys] = React.useActionState(createAccountlessKeysAction, null);
useEffect(() => {
React.startTransition(() => {
fetchKeys();
});
}, []);

if (!React.isValidElement(children)) {
return children;
}

return React.cloneElement(children, {
key: state?.publishableKey,
publishableKey: state?.publishableKey,
__internal_claimAccountlessKeysUrl: state?.claimUrl,
__internal_bypassMissingPk: true,
} as any);
};
Loading