Skip to content

Commit d20bdcf

Browse files
authored
feat(next, next-sample): support nextjs edge runtime (#469)
* fix(next-sample): use location assign for sign in redirect * chore: remove lodash * chore: ignore vercel cache * chore: bump essentials version * feat: next edge package * feat: add next edge sample * test: fix js test * fix: cr fix * fix: avoid error code breaking change * chore: fix build * fix: fix browser test * fix: fix package file
1 parent 9e9a8b0 commit d20bdcf

27 files changed

+639
-303
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,4 @@ cache
2929
.idea/
3030
*.pem
3131
.history
32+
.vercel

packages/browser/jest.setup.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ const { TextDecoder, TextEncoder } = require('text-encoder');
77
/* eslint-enable unicorn/prefer-module */
88

99
/* eslint-disable @silverhand/fp/no-mutation */
10-
global.crypto.subtle = crypto.webcrypto.subtle;
10+
// Mock WebCrypto in JSDOM
11+
if (global.window !== undefined) {
12+
global.CryptoKey = crypto.webcrypto.CryptoKey;
13+
global.crypto.subtle = crypto.webcrypto.subtle;
14+
}
15+
1116
global.TextDecoder = TextDecoder;
1217
global.TextEncoder = TextEncoder;
1318
/* eslint-enable @silverhand/fp/no-mutation */

packages/browser/package.json

-1
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
"eslint": "^8.38.0",
4444
"jest": "^29.5.0",
4545
"jest-environment-jsdom": "^29.5.0",
46-
"jest-location-mock": "^1.0.9",
4746
"jest-matcher-specific-error": "^1.0.0",
4847
"lint-staged": "^13.0.0",
4948
"prettier": "^2.8.7",

packages/client/package.json

+1-5
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,7 @@
3232
"@logto/js": "^1.1.2",
3333
"@silverhand/essentials": "^2.6.1",
3434
"camelcase-keys": "^7.0.1",
35-
"jose": "^4.13.2",
36-
"lodash.get": "^4.4.2",
37-
"lodash.once": "^4.1.1"
35+
"jose": "^4.13.2"
3836
},
3937
"devDependencies": {
4038
"@jest/types": "^29.5.0",
@@ -43,8 +41,6 @@
4341
"@swc/core": "^1.3.50",
4442
"@swc/jest": "^0.2.24",
4543
"@types/jest": "^29.5.0",
46-
"@types/lodash.get": "^4.4.6",
47-
"@types/lodash.once": "^4.1.7",
4844
"@types/node": "^18.0.0",
4945
"eslint": "^8.38.0",
5046
"jest": "^29.5.0",

packages/client/src/errors.ts

+4-20
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,18 @@
1-
import type { NormalizeKeyPaths } from '@silverhand/essentials';
2-
import get from 'lodash.get';
3-
41
const logtoClientErrorCodes = Object.freeze({
5-
sign_in_session: {
6-
invalid: 'Invalid sign-in session.',
7-
not_found: 'Sign-in session not found.',
8-
},
2+
'sign_in_session.invalid': 'Invalid sign-in session.',
3+
'sign_in_session.not_found': 'Sign-in session not found.',
94
not_authenticated: 'Not authenticated.',
105
fetch_user_info_failed: 'Unable to fetch user info. The access token may be invalid.',
116
});
127

13-
export type LogtoClientErrorCode = NormalizeKeyPaths<typeof logtoClientErrorCodes>;
14-
15-
const getMessageByErrorCode = (errorCode: LogtoClientErrorCode): string => {
16-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
17-
const message = get(logtoClientErrorCodes, errorCode);
18-
19-
if (typeof message === 'string') {
20-
return message;
21-
}
22-
23-
return errorCode;
24-
};
8+
export type LogtoClientErrorCode = keyof typeof logtoClientErrorCodes;
259

2610
export class LogtoClientError extends Error {
2711
code: LogtoClientErrorCode;
2812
data: unknown;
2913

3014
constructor(code: LogtoClientErrorCode, data?: unknown) {
31-
super(getMessageByErrorCode(code));
15+
super(logtoClientErrorCodes[code]);
3216
this.code = code;
3317
this.data = data;
3418
}

packages/client/src/index.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ import {
2020
} from '@logto/js';
2121
import type { Nullable } from '@silverhand/essentials';
2222
import { createRemoteJWKSet } from 'jose';
23-
import once from 'lodash.once';
2423

2524
import type { ClientAdapter } from './adapter';
2625
import { LogtoClientError } from './errors';
2726
import type { AccessToken, LogtoConfig, LogtoSignInSessionItem } from './types';
2827
import { isLogtoAccessTokenMap, isLogtoSignInSessionItem } from './types';
2928
import { buildAccessTokenKey, getDiscoveryEndpoint } from './utils';
29+
import { once } from './utils/once';
3030

3131
export type { IdTokenClaims, LogtoErrorCode, UserInfoResponse, InteractionMode } from '@logto/js';
3232
export {

packages/client/src/utils/once.ts

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
type Procedure<T> = (...args: unknown[]) => T;
2+
3+
// TODO @sijie move to essentials
4+
/* eslint-disable @silverhand/fp/no-let */
5+
/* eslint-disable @silverhand/fp/no-mutation */
6+
export function once<T>(function_: Procedure<T>): Procedure<T> {
7+
let called = false;
8+
let result: T;
9+
10+
return function (this: unknown, ...args: unknown[]) {
11+
if (!called) {
12+
called = true;
13+
result = function_.apply(this, args);
14+
}
15+
16+
return result;
17+
};
18+
}
19+
/* eslint-enable @silverhand/fp/no-mutation */
20+
/* eslint-enable @silverhand/fp/no-let */

packages/js/package.json

+1-3
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,7 @@
3131
"dependencies": {
3232
"@silverhand/essentials": "^2.6.1",
3333
"camelcase-keys": "^7.0.1",
34-
"jose": "^4.13.2",
35-
"lodash.get": "^4.4.2"
34+
"jose": "^4.13.2"
3635
},
3736
"devDependencies": {
3837
"@jest/types": "^29.5.0",
@@ -41,7 +40,6 @@
4140
"@swc/core": "^1.3.50",
4241
"@swc/jest": "^0.2.24",
4342
"@types/jest": "^29.5.0",
44-
"@types/lodash.get": "^4.4.6",
4543
"@types/node": "^18.0.0",
4644
"eslint": "^8.38.0",
4745
"jest": "^29.5.0",

packages/js/src/utils/errors.test.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ describe('LogtoError', () => {
1313
});
1414

1515
test('new LogtoError with error code, which is not related to unique message, should contain message equaling to error code', () => {
16-
const code: LogtoErrorCode = 'callback_uri_verification';
16+
const code: LogtoErrorCode = 'callback_uri_verification.missing_code';
1717
const error = 'error_value';
1818
const errorDescription = 'error_description_content';
1919
const logtoError = new LogtoError(code, new OidcError(error, errorDescription));
2020
expect(logtoError).toHaveProperty('code', code);
21-
expect(logtoError).toHaveProperty('message', code);
21+
expect(logtoError).toHaveProperty('message', 'Missing code in the callback URI');
2222
expect(logtoError).toHaveProperty('data', { error, errorDescription });
2323
expect(logtoError.data).toBeInstanceOf(OidcError);
2424
});

packages/js/src/utils/errors.ts

+10-27
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,26 @@
1-
import type { NormalizeKeyPaths } from '@silverhand/essentials';
2-
import get from 'lodash.get';
3-
41
import { isArbitraryObject } from './arbitrary-object';
52

63
const logtoErrorCodes = Object.freeze({
7-
id_token: {
8-
invalid_iat: 'Invalid issued at time in the ID token',
9-
invalid_token: 'Invalid ID token',
10-
},
11-
callback_uri_verification: {
12-
redirect_uri_mismatched: 'The callback URI mismatches the redirect URI.',
13-
error_found: 'Error found in the callback URI',
14-
missing_state: 'Missing state in the callback URI',
15-
state_mismatched: 'State mismatched in the callback URI',
16-
missing_code: 'Missing code in the callback URI',
17-
},
4+
'id_token.invalid_iat': 'Invalid issued at time in the ID token',
5+
'id_token.invalid_token': 'Invalid ID token',
6+
'callback_uri_verification.redirect_uri_mismatched':
7+
'The callback URI mismatches the redirect URI.',
8+
'callback_uri_verification.error_found': 'Error found in the callback URI',
9+
'callback_uri_verification.missing_state': 'Missing state in the callback URI',
10+
'callback_uri_verification.state_mismatched': 'State mismatched in the callback URI',
11+
'callback_uri_verification.missing_code': 'Missing code in the callback URI',
1812
crypto_subtle_unavailable: 'Crypto.subtle is unavailable in insecure contexts (non-HTTPS).',
1913
unexpected_response_error: 'Unexpected response error from the server.',
2014
});
2115

22-
export type LogtoErrorCode = NormalizeKeyPaths<typeof logtoErrorCodes>;
23-
24-
const getMessageByErrorCode = (errorCode: LogtoErrorCode): string => {
25-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
26-
const message = get(logtoErrorCodes, errorCode);
27-
28-
if (typeof message === 'string') {
29-
return message;
30-
}
31-
32-
return errorCode;
33-
};
16+
export type LogtoErrorCode = keyof typeof logtoErrorCodes;
3417

3518
export class LogtoError extends Error {
3619
code: LogtoErrorCode;
3720
data: unknown;
3821

3922
constructor(code: LogtoErrorCode, data?: unknown) {
40-
super(getMessageByErrorCode(code));
23+
super(logtoErrorCodes[code]);
4124
this.code = code;
4225
this.data = data;
4326
}

packages/next-sample/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
.next
2+
.vercel
+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const config = {
2+
appId: 'appId', // Replace with your own appId
3+
appSecret: 'appSecret', // Replace with your own appSecret
4+
endpoint: 'http://localhost:3001',
5+
baseUrl: 'http://localhost:3000',
6+
cookieSecret: 'complex_password_at_least_32_characters_long',
7+
cookieSecure: process.env.NODE_ENV === 'production',
8+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import LogtoClient from '@logto/next/edge';
2+
3+
import { config } from './config';
4+
5+
export const logtoClient = new LogtoClient(config);
+3-8
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
import LogtoClient from '@logto/next';
22

3-
export const logtoClient = new LogtoClient({
4-
appId: 'appId', // Replace with your own appId
5-
appSecret: 'appSecret', // Replace with your own appSecret
6-
endpoint: 'http://localhost:3001',
7-
baseUrl: 'http://localhost:3000',
8-
cookieSecret: 'complex_password_at_least_32_characters_long',
9-
cookieSecure: process.env.NODE_ENV === 'production',
10-
});
3+
import { config } from './config';
4+
5+
export const logtoClient = new LogtoClient(config);
+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { logtoClient } from '../../libraries/logto-edge';
2+
3+
export default logtoClient.withLogtoApiRoute((request, response) => {
4+
if (!request.user.isAuthenticated) {
5+
return new Response(
6+
JSON.stringify({
7+
message: 'Unauthorized',
8+
}),
9+
{
10+
status: 401,
11+
headers: {
12+
'content-type': 'application/json',
13+
},
14+
}
15+
);
16+
}
17+
18+
return new Response(
19+
JSON.stringify({
20+
data: 'Protected Resource in Edge API',
21+
}),
22+
{
23+
status: 200,
24+
headers: {
25+
'content-type': 'application/json',
26+
},
27+
}
28+
);
29+
});
30+
31+
export const config = {
32+
runtime: 'experimental-edge',
33+
};

packages/next/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
edge/lib

packages/next/edge/index.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import BaseClient from '../src/client';
2+
import type { LogtoNextConfig } from '../src/types';
3+
import { withIronSessionApiRoute, withIronSessionSsr } from './iron-session-edge';
4+
5+
export { ReservedScope, UserScope } from '@logto/node';
6+
7+
export type { LogtoContext, InteractionMode } from '@logto/node';
8+
9+
export default class LogtoClient extends BaseClient {
10+
constructor(config: LogtoNextConfig) {
11+
super(config, {
12+
withIronSessionApiRoute,
13+
withIronSessionSsr,
14+
});
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { IronSession } from 'iron-session';
2+
3+
export default function getPropertyDescriptorForRequestSession(
4+
session: IronSession
5+
): PropertyDescriptor {
6+
return {
7+
enumerable: true,
8+
get() {
9+
return session;
10+
},
11+
set(value) {
12+
const keys = Object.keys(value);
13+
const currentKeys = Object.keys(session);
14+
15+
for (const key of currentKeys) {
16+
if (!keys.includes(key)) {
17+
/* eslint-disable @typescript-eslint/no-dynamic-delete */
18+
/* eslint-disable @silverhand/fp/no-delete */
19+
// @ts-expect-error See comment in IronSessionData interface
20+
delete session[key];
21+
/* eslint-enable @silverhand/fp/no-delete */
22+
/* eslint-enable @typescript-eslint/no-dynamic-delete */
23+
}
24+
}
25+
26+
for (const key of keys) {
27+
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
28+
/* eslint-disable @silverhand/fp/no-mutation */
29+
// @ts-expect-error See comment in IronSessionData interface
30+
session[key] = value[key];
31+
/* eslint-enable @silverhand/fp/no-mutation */
32+
/* eslint-enable @typescript-eslint/no-unsafe-assignment */
33+
}
34+
},
35+
};
36+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { IncomingMessage, ServerResponse } from 'http';
2+
3+
import type { IronSessionOptions } from 'iron-session';
4+
import { getIronSession } from 'iron-session/edge';
5+
import type {
6+
NextApiHandler,
7+
GetServerSidePropsContext,
8+
GetServerSidePropsResult,
9+
NextApiRequest,
10+
NextApiResponse,
11+
} from 'next';
12+
13+
import getPropertyDescriptorForRequestSession from './get-property-descriptor-for-request-session';
14+
15+
// Argument types based on getIronSession function
16+
type GetIronSessionApiOptions = (
17+
request: NextApiRequest,
18+
response: NextApiResponse
19+
) => Promise<IronSessionOptions> | IronSessionOptions;
20+
21+
export function withIronSessionApiRoute(
22+
handler: NextApiHandler,
23+
options: IronSessionOptions | GetIronSessionApiOptions
24+
): NextApiHandler {
25+
return async (request, response) => {
26+
const sessionOptions = options instanceof Function ? await options(request, response) : options;
27+
const session = await getIronSession(request, response, sessionOptions);
28+
29+
// We define req.session as being enumerable (so console.log(req) shows it)
30+
// and we also want to allow people to do:
31+
// req.session = { admin: true }; or req.session = {...req.session, admin: true};
32+
// req.session.save();
33+
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
34+
Object.defineProperty(request, 'session', getPropertyDescriptorForRequestSession(session));
35+
36+
return handler(request, response);
37+
};
38+
}
39+
40+
// Argument type based on the SSR context
41+
type GetIronSessionSsrOptions = (
42+
request: IncomingMessage,
43+
response: ServerResponse
44+
) => Promise<IronSessionOptions> | IronSessionOptions;
45+
46+
export function withIronSessionSsr<P extends Record<string, unknown> = Record<string, unknown>>(
47+
handler: (
48+
context: GetServerSidePropsContext
49+
) => GetServerSidePropsResult<P> | Promise<GetServerSidePropsResult<P>>,
50+
options: IronSessionOptions | GetIronSessionSsrOptions
51+
) {
52+
return async (context: GetServerSidePropsContext) => {
53+
const sessionOptions =
54+
options instanceof Function ? await options(context.req, context.res) : options;
55+
const session = await getIronSession(context.req, context.res, sessionOptions);
56+
57+
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
58+
Object.defineProperty(context.req, 'session', getPropertyDescriptorForRequestSession(session));
59+
60+
return handler(context);
61+
};
62+
}

0 commit comments

Comments
 (0)