diff --git a/packages/core/src/app/init.ts b/packages/core/src/app/init.ts index 0a52826a8..5dfd858c5 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -38,7 +38,7 @@ export default async function initApp(app: Koa): Promise { app.use(koaConnectorErrorHandler()); app.use(koaI18next()); - const provider = await initOidc(app); + const provider = initOidc(app); initRouter(app, provider); app.use(mount('/', koaRootProxy())); diff --git a/packages/core/src/env-set/create-pool-by-env.ts b/packages/core/src/env-set/create-pool-by-env.ts deleted file mode 100644 index 5d033d34b..000000000 --- a/packages/core/src/env-set/create-pool-by-env.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { assert, assertEnv } from '@silverhand/essentials'; -import chalk from 'chalk'; -import { createMockPool, createMockQueryResult, createPool, parseDsn } from 'slonik'; -import { createInterceptors } from 'slonik-interceptor-preset'; - -const createPoolByEnv = async (isTest: boolean) => { - // Database connection is disabled in unit test environment - if (isTest) { - return createMockPool({ query: async () => createMockQueryResult([]) }); - } - - const key = 'DB_URL'; - const interceptors = [...createInterceptors()]; - - try { - const databaseDsn = assertEnv(key); - assert(parseDsn(databaseDsn).databaseName, new Error('Database name is required in `DB_URL`')); - - return await createPool(databaseDsn, { interceptors }); - } catch (error: unknown) { - if (error instanceof Error && error.message === `env variable ${key} not found`) { - console.error( - `${chalk.red('[error]')} No Postgres DSN (${chalk.green( - key - )}) found in env variables.\n\n` + - ` Either provide it in your env, or add it to the ${chalk.blue( - '.env' - )} file in the Logto project root.\n\n` + - ` If you want to set up a new Logto database, run ${chalk.green( - 'npm run cli db seed' - )} before setting env ${chalk.green(key)}.\n\n` + - ` Visit ${chalk.blue( - 'https://docs.logto.io/docs/references/core/configuration' - )} for more info about setting up env.\n` - ); - } - - throw error; - } -}; - -export default createPoolByEnv; diff --git a/packages/core/src/env-set/create-pool.ts b/packages/core/src/env-set/create-pool.ts new file mode 100644 index 000000000..625b9dd12 --- /dev/null +++ b/packages/core/src/env-set/create-pool.ts @@ -0,0 +1,18 @@ +import { assert } from '@silverhand/essentials'; +import { createMockPool, createMockQueryResult, createPool, parseDsn } from 'slonik'; +import { createInterceptors } from 'slonik-interceptor-preset'; + +const createPoolByEnv = async (databaseDsn: string, isTest: boolean) => { + // Database connection is disabled in unit test environment + if (isTest) { + return createMockPool({ query: async () => createMockQueryResult([]) }); + } + + const interceptors = [...createInterceptors()]; + + assert(parseDsn(databaseDsn).databaseName, new Error('Database name is required')); + + return createPool(databaseDsn, { interceptors }); +}; + +export default createPoolByEnv; diff --git a/packages/core/src/env-set/create-query-client-by-env.ts b/packages/core/src/env-set/create-query-client-by-env.ts deleted file mode 100644 index cb950c5b1..000000000 --- a/packages/core/src/env-set/create-query-client-by-env.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { assert, assertEnv } from '@silverhand/essentials'; -import { PostgresQueryClient } from '@withtyped/postgres'; -import chalk from 'chalk'; -import { parseDsn } from 'slonik'; - -import { MockQueryClient } from '#src/test-utils/query-client.js'; - -const createQueryClientByEnv = (isTest: boolean) => { - // Database connection is disabled in unit test environment - if (isTest) { - return new MockQueryClient(); - } - - const key = 'DB_URL'; - - try { - const databaseDsn = assertEnv(key); - assert(parseDsn(databaseDsn), new Error('Database name is required in `DB_URL`')); - - return new PostgresQueryClient({ connectionString: databaseDsn }); - } catch (error: unknown) { - if (error instanceof Error && error.message === `env variable ${key} not found`) { - console.error( - `${chalk.red('[error]')} No Postgres DSN (${chalk.green( - key - )}) found in env variables.\n\n` + - ` Either provide it in your env, or add it to the ${chalk.blue( - '.env' - )} file in the Logto project root.\n\n` + - ` If you want to set up a new Logto database, run ${chalk.green( - 'npm run cli db seed' - )} before setting env ${chalk.green(key)}.\n\n` + - ` Visit ${chalk.blue( - 'https://docs.logto.io/docs/references/core/configuration' - )} for more info about setting up env.\n` - ); - } - - throw error; - } -}; - -export default createQueryClientByEnv; diff --git a/packages/core/src/env-set/create-query-client.ts b/packages/core/src/env-set/create-query-client.ts new file mode 100644 index 000000000..f091214e9 --- /dev/null +++ b/packages/core/src/env-set/create-query-client.ts @@ -0,0 +1,18 @@ +import { assert } from '@silverhand/essentials'; +import { PostgresQueryClient } from '@withtyped/postgres'; +import { parseDsn } from 'slonik'; + +import { MockQueryClient } from '#src/test-utils/query-client.js'; + +const createQueryClient = (databaseDsn: string, isTest: boolean) => { + // Database connection is disabled in unit test environment + if (isTest) { + return new MockQueryClient(); + } + + assert(parseDsn(databaseDsn), new Error('Database name is required')); + + return new PostgresQueryClient({ connectionString: databaseDsn }); +}; + +export default createQueryClient; diff --git a/packages/core/src/env-set/dot-env.ts b/packages/core/src/env-set/dot-env.ts deleted file mode 100644 index f95cd2b9f..000000000 --- a/packages/core/src/env-set/dot-env.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { appendFileSync } from 'fs'; - -import dotenv from 'dotenv'; -import { findUp } from 'find-up'; - -export const appendDotEnv = (key: string, value: string) => { - appendFileSync('.env', `${key}=${value}\n`); -}; - -export const configDotEnv = async () => { - dotenv.config({ path: await findUp('.env', {}) }); -}; diff --git a/packages/core/src/env-set/index.ts b/packages/core/src/env-set/index.ts index 5f1a775d1..da291b052 100644 --- a/packages/core/src/env-set/index.ts +++ b/packages/core/src/env-set/index.ts @@ -1,5 +1,6 @@ +import { tryThat } from '@logto/shared'; import type { Optional } from '@silverhand/essentials'; -import { getEnv, getEnvAsStringArray } from '@silverhand/essentials'; +import { assertEnv, getEnv, getEnvAsStringArray } from '@silverhand/essentials'; import type { PostgreSql } from '@withtyped/postgres'; import type { QueryClient } from '@withtyped/server'; import type { DatabasePool } from 'slonik'; @@ -8,10 +9,11 @@ import { getOidcConfigs } from '#src/libraries/logto-config.js'; import { appendPath } from '#src/utils/url.js'; import { checkAlterationState } from './check-alteration-state.js'; -import createPoolByEnv from './create-pool-by-env.js'; -import createQueryClientByEnv from './create-query-client-by-env.js'; +import createPool from './create-pool.js'; +import createQueryClient from './create-query-client.js'; import loadOidcValues from './oidc.js'; import { isTrue } from './parameters.js'; +import { throwErrorWithDsnMessage, throwNotLoadedError } from './throw-errors.js'; export enum MountedApps { Api = 'api', @@ -21,7 +23,7 @@ export enum MountedApps { Welcome = 'welcome', } -const loadEnvValues = async () => { +const loadEnvValues = () => { const isProduction = getEnv('NODE_ENV') === 'production'; const isTest = getEnv('NODE_ENV') === 'test'; const isIntegrationTest = isTrue(getEnv('INTEGRATION_TEST')); @@ -29,6 +31,7 @@ const loadEnvValues = async () => { const port = Number(getEnv('PORT', '3001')); const localhostUrl = `${isHttpsEnabled ? 'https' : 'http'}://localhost:${port}`; const endpoint = getEnv('ENDPOINT', localhostUrl); + const databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage); return Object.freeze({ isTest, @@ -40,6 +43,7 @@ const loadEnvValues = async () => { port, localhostUrl, endpoint, + dbUrl: databaseUrl, userDefaultRoleNames: getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES'), developmentUserId: getEnv('DEVELOPMENT_USER_ID'), trustProxyHeader: isTrue(getEnv('TRUST_PROXY_HEADER')), @@ -47,68 +51,71 @@ const loadEnvValues = async () => { }); }; -const throwNotLoadedError = () => { - throw new Error( - 'The env set is not loaded. Make sure to call `await envSet.load()` before using it.' - ); -}; +class EnvSet { + static envValues: ReturnType = loadEnvValues(); -/* eslint-disable @silverhand/fp/no-let, @silverhand/fp/no-mutation */ -function createEnvSet() { - let values: Optional>>; - let pool: Optional; + #pool: Optional; // Use another pool for `withtyped` while adopting the new model, // as we cannot extract the original PgPool from slonik - let queryClient: Optional>; - let oidc: Optional>>; + #queryClient: Optional>; + #oidc: Optional>>; - return { - get values() { - if (!values) { - return throwNotLoadedError(); - } + constructor(public readonly databaseUrl = EnvSet.envValues.dbUrl) {} - return values; - }, - get pool() { - if (!pool) { - return throwNotLoadedError(); - } + get values() { + return EnvSet.envValues; + } - return pool; - }, - get poolSafe() { - return pool; - }, - get queryClient() { - if (!queryClient) { - return throwNotLoadedError(); - } + get isTest() { + return EnvSet.envValues.isTest; + } - return queryClient; - }, - get queryClientSafe() { - return queryClient; - }, - get oidc() { - if (!oidc) { - return throwNotLoadedError(); - } + get pool() { + if (!this.#pool) { + return throwNotLoadedError(); + } - return oidc; - }, - load: async () => { - values = await loadEnvValues(); - pool = await createPoolByEnv(values.isTest); - queryClient = createQueryClientByEnv(values.isTest); + return this.#pool; + } - const [, oidcConfigs] = await Promise.all([checkAlterationState(pool), getOidcConfigs(pool)]); - oidc = await loadOidcValues(appendPath(values.endpoint, '/oidc').toString(), oidcConfigs); - }, - }; + get poolSafe() { + return this.#pool; + } + + get queryClient() { + if (!this.#queryClient) { + return throwNotLoadedError(); + } + + return this.#queryClient; + } + + get queryClientSafe() { + return this.#queryClient; + } + + get oidc() { + if (!this.#oidc) { + return throwNotLoadedError(); + } + + return this.#oidc; + } + + async load() { + const pool = await createPool(this.databaseUrl, this.isTest); + + this.#pool = pool; + this.#queryClient = createQueryClient(this.databaseUrl, this.isTest); + + const [, oidcConfigs] = await Promise.all([checkAlterationState(pool), getOidcConfigs(pool)]); + this.#oidc = await loadOidcValues( + appendPath(this.values.endpoint, '/oidc').toString(), + oidcConfigs + ); + } } -/* eslint-enable @silverhand/fp/no-let, @silverhand/fp/no-mutation */ -const envSet = createEnvSet(); +const envSet = new EnvSet(); export default envSet; diff --git a/packages/core/src/env-set/throw-errors.ts b/packages/core/src/env-set/throw-errors.ts new file mode 100644 index 000000000..a3ab6ef09 --- /dev/null +++ b/packages/core/src/env-set/throw-errors.ts @@ -0,0 +1,28 @@ +import chalk from 'chalk'; + +export const throwNotLoadedError = () => { + throw new Error( + 'The env set is not loaded. Make sure to call `await envSet.load()` before using it.' + ); +}; + +export const throwErrorWithDsnMessage = (error: unknown) => { + const key = 'DB_URL'; + + if (error instanceof Error && error.message === `env variable ${key} not found`) { + console.error( + `${chalk.red('[error]')} No Postgres DSN (${chalk.green(key)}) found in env variables.\n\n` + + ` Either provide it in your env, or add it to the ${chalk.blue( + '.env' + )} file in the Logto project root.\n\n` + + ` If you want to set up a new Logto database, run ${chalk.green( + 'npm run cli db seed' + )} before setting env ${chalk.green(key)}.\n\n` + + ` Visit ${chalk.blue( + 'https://docs.logto.io/docs/references/core/configuration' + )} for more info about setting up env.\n` + ); + } + + throw error; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8c7e8f903..ad51c7755 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,14 +1,19 @@ import { noop } from '@silverhand/essentials'; +import dotenv from 'dotenv'; +import { findUp } from 'find-up'; import Koa from 'koa'; -import { loadConnectorFactories } from './connectors/index.js'; -import { configDotEnv } from './env-set/dot-env.js'; -import envSet from './env-set/index.js'; import initI18n from './i18n/init.js'; +dotenv.config({ path: await findUp('.env', {}) }); + +// Import after env has configured +const { default: envSet } = await import('./env-set/index.js'); +await envSet.load(); + +const { loadConnectorFactories } = await import('./connectors/index.js'); + try { - await configDotEnv(); - await envSet.load(); const app = new Koa({ proxy: envSet.values.trustProxyHeader, }); diff --git a/packages/core/src/libraries/hook.test.ts b/packages/core/src/libraries/hook.test.ts index a1e2f119f..c23701718 100644 --- a/packages/core/src/libraries/hook.test.ts +++ b/packages/core/src/libraries/hook.test.ts @@ -48,7 +48,7 @@ mockEsm('#src/queries/application.js', () => ({ })); // eslint-disable-next-line unicorn/consistent-function-scoping -mockEsmDefault('#src/env-set/create-query-client-by-env.js', () => () => queryClient); +mockEsmDefault('#src/env-set/create-query-client.js', () => () => queryClient); jest.spyOn(queryClient, 'query').mockImplementation(queryFunction); const { triggerInteractionHooksIfNeeded } = await import('./hook.js'); diff --git a/packages/core/src/oidc/init.test.ts b/packages/core/src/oidc/init.test.ts index 6973dd129..b16a2949f 100644 --- a/packages/core/src/oidc/init.test.ts +++ b/packages/core/src/oidc/init.test.ts @@ -5,6 +5,6 @@ import initOidc from './init.js'; describe('oidc provider init', () => { it('init should not throw', async () => { const app = new Koa(); - await expect(initOidc(app)).resolves.not.toThrow(); + expect(() => initOidc(app)).not.toThrow(); }); }); diff --git a/packages/core/src/oidc/init.ts b/packages/core/src/oidc/init.ts index cf58f11ba..47070199f 100644 --- a/packages/core/src/oidc/init.ts +++ b/packages/core/src/oidc/init.ts @@ -23,7 +23,7 @@ import assertThat from '#src/utils/assert-that.js'; import { claimToUserKey, getUserClaims } from './scope.js'; -export default async function initOidc(app: Koa): Promise { +export default function initOidc(app: Koa): Provider { const { issuer, cookieKeys, privateJwks, defaultIdTokenTtl, defaultRefreshTokenTtl } = envSet.oidc; const logoutSource = readFileSync('static/html/logout.html', 'utf8'); diff --git a/packages/shared/src/utils/function.test.ts b/packages/shared/src/utils/function.test.ts index 7ddd797bd..44a5af294 100644 --- a/packages/shared/src/utils/function.test.ts +++ b/packages/shared/src/utils/function.test.ts @@ -1,9 +1,56 @@ -import { trySafe } from './function.js'; +import { trySafe, tryThat } from './function.js'; + +describe('tryThat()', () => { + it('should directly execute and return or throw if the function is not a Promise', () => { + expect(tryThat(() => 'foo', new Error('try'))).toStrictEqual('foo'); + expect(() => + tryThat(() => { + throw new Error('Test'); + }, new Error('try')) + ).toThrowError(new Error('try')); + expect(() => + tryThat( + () => { + throw new Error('Test'); + }, + (error) => { + throw new Error(String(error instanceof Error && error.message) + ' try'); + } + ) + ).toThrowError(new Error('Test try')); + }); + + it('should execute or unwrap a Promise and throw the error', async () => { + expect( + await tryThat( + new Promise((resolve) => { + setTimeout(() => { + resolve('bar'); + }, 0); + }), + new Error('try') + ) + ).toStrictEqual('bar'); + + await expect( + tryThat( + async () => + new Promise((resolve, reject) => { + reject(); + }), + () => { + throw new Error('try'); + } + ) + ).rejects.toStrictEqual(new Error('try')); + }); +}); describe('trySafe()', () => { it('should directly execute and return if the function is not a Promise', () => { expect(trySafe(() => 'foo')).toStrictEqual('foo'); expect( + // eslint-disable-next-line @typescript-eslint/no-confusing-void-expression trySafe(() => { throw new Error('Test'); }) diff --git a/packages/shared/src/utils/function.ts b/packages/shared/src/utils/function.ts index f55dfb727..bc505e37e 100644 --- a/packages/shared/src/utils/function.ts +++ b/packages/shared/src/utils/function.ts @@ -1,29 +1,41 @@ -export const tryThat = async ( - exec: Promise | (() => Promise), - onError: E | ((error: unknown) => never) -): Promise => { - try { - return await (typeof exec === 'function' ? exec() : exec); - } catch (error: unknown) { - if (onError instanceof Error) { - // https://github.com/typescript-eslint/typescript-eslint/issues/3814 - // eslint-disable-next-line @typescript-eslint/no-throw-literal - throw onError; - } - - return onError(error); - } -}; - export const isPromise = (value: unknown): value is Promise => value !== null && (typeof value === 'object' || typeof value === 'function') && 'then' in value && typeof value.then === 'function'; +export type TryThat = { + (exec: () => T, onError: Error | ((error: unknown) => never)): T; + ( + exec: Promise | (() => Promise), + onError: Error | ((error: unknown) => never) + ): Promise; +}; + +export const tryThat: TryThat = (exec, onError) => { + const handleError = (error: unknown) => { + if (onError instanceof Error) { + throw onError; + } + + return onError(error); + }; + + try { + const unwrapped = typeof exec === 'function' ? exec() : exec; + + return isPromise(unwrapped) + ? // eslint-disable-next-line promise/prefer-await-to-then + unwrapped.catch(handleError) + : unwrapped; + } catch (error: unknown) { + return handleError(error); + } +}; + export type TrySafe = { - (exec: Promise | (() => Promise)): Promise; (exec: () => T): T | undefined; + (exec: Promise | (() => Promise)): Promise; }; export const trySafe: TrySafe = (exec) => {