diff --git a/packages/core/package.json b/packages/core/package.json index f855c9481..6a23c3810 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -10,7 +10,8 @@ "precommit": "lint-staged", "build": "rm -rf build/ && tsc", "lint": "eslint --format pretty --ext .ts src", - "dev": "rm -rf build/ && tsc-watch --preserveWatchOutput --onSuccess \"node ./build/index.js\"" + "dev": "rm -rf build/ && tsc-watch --preserveWatchOutput --onSuccess \"node ./build/index.js\"", + "start": "NODE_ENV=production node build/index.js" }, "dependencies": { "@logto/essentials": "^1.1.0-rc.2", @@ -29,6 +30,7 @@ "koa-mount": "^4.0.0", "koa-proxies": "^0.12.1", "koa-router": "^10.0.0", + "koa-static": "^5.0.0", "lodash.pick": "^4.4.0", "module-alias": "^2.2.2", "nanoid": "^3.1.23", @@ -44,6 +46,7 @@ "@types/koa-logger": "^3.1.1", "@types/koa-mount": "^4.0.0", "@types/koa-router": "^7.4.2", + "@types/koa-static": "^4.0.2", "@types/lodash.pick": "^4.4.6", "@types/node": "^16.3.1", "@types/oidc-provider": "^7.4.1", diff --git a/packages/core/src/consts.ts b/packages/core/src/consts.ts index 850ccb256..cf1832a0a 100644 --- a/packages/core/src/consts.ts +++ b/packages/core/src/consts.ts @@ -9,5 +9,7 @@ export const routes = Object.freeze({ }, }); +export const isProduction = getEnv('NODE_ENV') === 'production'; export const port = Number(getEnv('PORT', '3001')); export const oidcIssuer = getEnv('OIDC_ISSUER', `http://localhost:${port}/oidc`); +export const mountedApps = Object.freeze(['api', 'oidc']); diff --git a/packages/core/src/include.d/koa-body.d.ts b/packages/core/src/include.d/koa-body.d.ts index 581f4b76f..57f347378 100644 --- a/packages/core/src/include.d/koa-body.d.ts +++ b/packages/core/src/include.d/koa-body.d.ts @@ -1,12 +1,12 @@ declare module 'koa-body' { import { IKoaBodyOptions } from 'node_modules/koa-body'; - import { Middleware } from 'koa'; + import { MiddlewareType } from 'koa'; declare function koaBody< StateT = Record, ContextT = Record, ResponseBodyT = any - >(options?: IKoaBodyOptions): Middleware; + >(options?: IKoaBodyOptions): MiddlewareType; export = koaBody; } diff --git a/packages/core/src/include.d/koa.d.ts b/packages/core/src/include.d/koa.d.ts new file mode 100644 index 000000000..354088a9f --- /dev/null +++ b/packages/core/src/include.d/koa.d.ts @@ -0,0 +1,13 @@ +import { DefaultState, DefaultContext, ParameterizedContext, Next } from 'koa'; + +declare module 'koa' { + // Have to do this patch since `compose.Middleware` returns `any`. + export type KoaNext = () => Promise; + export type KoaMiddleware = (context: T, next: KoaNext) => Promise; + export type MiddlewareType< + StateT = DefaultState, + ContextT = DefaultContext, + ResponseBodyT = any, + NextT = void + > = KoaMiddleware, NextT>; +} diff --git a/packages/core/src/init/router.ts b/packages/core/src/init/apis.ts similarity index 59% rename from packages/core/src/init/router.ts rename to packages/core/src/init/apis.ts index 93ad3841a..ff1559b68 100644 --- a/packages/core/src/init/router.ts +++ b/packages/core/src/init/apis.ts @@ -3,21 +3,22 @@ import Router from 'koa-router'; import { Provider } from 'oidc-provider'; import signInRoutes from '@/routes/sign-in'; import registerRoutes from '@/routes/register'; -import uiProxy from '@/proxies/ui'; import swaggerRoutes from '@/routes/swagger'; +import mount from 'koa-mount'; const createRouter = (provider: Provider): Router => { - const router = new Router({ prefix: '/api' }); + const router = new Router(); - router.use(signInRoutes(provider)); - router.use(registerRoutes()); - router.use(swaggerRoutes()); + signInRoutes(router, provider); + registerRoutes(router); + swaggerRoutes(router); return router; }; -export default function initRouter(app: Koa, provider: Provider): Router { +export default function initRouter(app: Koa, provider: Provider) { const router = createRouter(provider); - app.use(router.routes()).use(uiProxy()).use(router.allowedMethods()); - return router; + const apisApp = new Koa().use(router.routes()).use(router.allowedMethods()); + + app.use(mount('/api', apisApp)); } diff --git a/packages/core/src/init/app.ts b/packages/core/src/init/app.ts index c839ffb63..34b0efbb6 100644 --- a/packages/core/src/init/app.ts +++ b/packages/core/src/init/app.ts @@ -3,8 +3,9 @@ import koaLogger from 'koa-logger'; import koaErrorHandler from '@/middleware/koa-error-handler'; import { port } from '@/consts'; +import koaUIProxy from '@/middleware/koa-ui-proxy'; import initOidc from './oidc'; -import initRouter from './router'; +import initRouter from './apis'; export default async function initApp(app: Koa): Promise { app.use(koaErrorHandler()); @@ -13,6 +14,8 @@ export default async function initApp(app: Koa): Promise { const provider = await initOidc(app); initRouter(app, provider); + app.use(koaUIProxy()); + app.listen(port, () => { console.log(`App is listening on port ${port}`); }); diff --git a/packages/core/src/middleware/koa-guard.ts b/packages/core/src/middleware/koa-guard.ts index 19ad380d8..a397c8c3c 100644 --- a/packages/core/src/middleware/koa-guard.ts +++ b/packages/core/src/middleware/koa-guard.ts @@ -1,6 +1,6 @@ import RequestError from '@/errors/RequestError'; import { has } from '@logto/essentials'; -import { Middleware } from 'koa'; +import { MiddlewareType } from 'koa'; import koaBody from 'koa-body'; import { IMiddleware, IRouterParamContext } from 'koa-router'; import { ZodType } from 'zod'; @@ -51,12 +51,12 @@ export default function koaGuard< query, body, params, -}: GuardConfig): Middleware< +}: GuardConfig): MiddlewareType< StateT, WithGuarded, ResponseBodyT > { - const guard: Middleware< + const guard: MiddlewareType< StateT, WithGuarded, ResponseBodyT @@ -72,24 +72,21 @@ export default function koaGuard< throw new RequestError('guard.invalid_input', error); } - await next(); + return next(); }; const guardMiddleware: WithGuardConfig< - Middleware< + MiddlewareType< StateT, WithGuarded, ResponseBodyT > > = async function (ctx, next) { if (body) { - await koaBody()(ctx, async () => { - await guard(ctx, next); - }); - return; + return koaBody()(ctx, async () => guard(ctx, next)); } - await guard(ctx, next); + return guard(ctx, next); }; guardMiddleware.config = { query, body, params }; diff --git a/packages/core/src/middleware/koa-ui-proxy.ts b/packages/core/src/middleware/koa-ui-proxy.ts new file mode 100644 index 000000000..3ad4d279d --- /dev/null +++ b/packages/core/src/middleware/koa-ui-proxy.ts @@ -0,0 +1,41 @@ +import fs from 'fs'; +import { Middleware } from 'koa'; +import proxy from 'koa-proxies'; +import serveStatic from 'koa-static'; +import { IRouterParamContext } from 'koa-router'; +import { isProduction, mountedApps } from '@/consts'; + +const PATH_TO_UI_DIST = '../ui/build/public'; +const uiDistFiles = fs.readdirSync(PATH_TO_UI_DIST); + +export default function koaUIProxy< + StateT, + ContextT extends IRouterParamContext, + ResponseBodyT +>(): Middleware { + const developmentProxy = proxy('*', { + target: 'http://localhost:5000', + changeOrigin: true, + logs: true, + }); + const staticProxy = serveStatic(PATH_TO_UI_DIST); + + return async (context, next) => { + // Route has been handled by one of mounted apps + if (mountedApps.some((app) => context.request.path.startsWith(`/${app}`))) { + return next(); + } + + if (!isProduction) { + await developmentProxy(context, next); + return next(); + } + + if (!uiDistFiles.some((file) => context.request.path.startsWith(`/${file}`))) { + context.request.path = '/'; + } + + await staticProxy(context, next); + return next(); + }; +} diff --git a/packages/core/src/proxies/ui.ts b/packages/core/src/proxies/ui.ts deleted file mode 100644 index 4b1d17b6a..000000000 --- a/packages/core/src/proxies/ui.ts +++ /dev/null @@ -1,10 +0,0 @@ -import proxy from 'koa-proxies'; - -// CAUTION: this is for testing only -export default function uiProxy() { - return proxy(/^\/(?!api|oidc).*$/, { - target: 'http://localhost:5000', - changeOrigin: true, - logs: true, - }); -} diff --git a/packages/core/src/routes/register.ts b/packages/core/src/routes/register.ts index 4d9a0a2a2..734241729 100644 --- a/packages/core/src/routes/register.ts +++ b/packages/core/src/routes/register.ts @@ -22,9 +22,7 @@ const generateUserId = async (maxRetries = 500) => { throw new Error('Cannot generate user ID in reasonable retries'); }; -export default function registerRoutes() { - const router = new Router(); - +export default function registerRoutes(router: Router) { router.post( '/register', koaGuard({ @@ -33,7 +31,7 @@ export default function registerRoutes() { password: string().min(6), }), }), - async (ctx) => { + async (ctx, next) => { const { username, password } = ctx.guard.body; if (await hasUser(username)) { @@ -59,8 +57,7 @@ export default function registerRoutes() { }); ctx.body = { id }; + return next(); } ); - - return router.routes(); } diff --git a/packages/core/src/routes/sign-in.ts b/packages/core/src/routes/sign-in.ts index e572da395..741173c55 100644 --- a/packages/core/src/routes/sign-in.ts +++ b/packages/core/src/routes/sign-in.ts @@ -9,13 +9,11 @@ import koaGuard from '@/middleware/koa-guard'; import RequestError from '@/errors/RequestError'; import { LogtoErrorCode } from '@logto/phrases'; -export default function signInRoutes(provider: Provider) { - const router = new Router(); - +export default function signInRoutes(router: Router, provider: Provider) { router.post( '/sign-in', koaGuard({ body: object({ username: string().optional(), password: string().optional() }) }), - async (ctx) => { + async (ctx, next) => { const { prompt: { name }, } = await provider.interactionDetails(ctx.req, ctx.res); @@ -60,10 +58,12 @@ export default function signInRoutes(provider: Provider) { } else { throw new Error(`Prompt not supported: ${name}`); } + + return next(); } ); - router.post('/sign-in/consent', async (ctx) => { + router.post('/sign-in/consent', async (ctx, next) => { const { session, grantId, params, prompt } = await provider.interactionDetails( ctx.req, ctx.res @@ -95,16 +95,17 @@ export default function signInRoutes(provider: Provider) { { mergeWithLastSubmission: true } ); ctx.body = { redirectTo }; + + return next(); }); - router.post('/sign-in/abort', async (ctx) => { + router.post('/sign-in/abort', async (ctx, next) => { await provider.interactionDetails(ctx.req, ctx.res); const error: LogtoErrorCode = 'oidc.aborted'; const redirectTo = await provider.interactionResult(ctx.req, ctx.res, { error, }); ctx.body = { redirectTo }; + return next(); }); - - return router.routes(); } diff --git a/packages/core/src/routes/swagger.ts b/packages/core/src/routes/swagger.ts index bb5d0e044..ff1e3e315 100644 --- a/packages/core/src/routes/swagger.ts +++ b/packages/core/src/routes/swagger.ts @@ -4,10 +4,8 @@ import { isGuardMiddleware, WithGuardConfig } from '@/middleware/koa-guard'; import { toTitle } from '@/utils/string'; import { zodTypeToSwagger } from '@/utils/zod'; -export default function swaggerRoutes() { - const router = new Router(); - - router.get('/swagger.json', async (ctx) => { +export default function swaggerRoutes(router: Router) { + router.get('/swagger.json', async (ctx, next) => { const routes = ctx.router.stack.map(({ path, stack, methods }) => { const guard = stack.find((function_): function_ is WithGuardConfig => isGuardMiddleware(function_) @@ -17,16 +15,15 @@ export default function swaggerRoutes() { const paths = Object.fromEntries( routes.map<[string, OpenAPIV3.PathItemObject]>(({ path, methods, guard }) => { - const trimmedPath = path.slice(4); const body = guard?.config.body; return [ - trimmedPath, + `/api${path}`, Object.fromEntries( methods.map<[string, OpenAPIV3.OperationObject]>((method) => [ method.toLowerCase(), { - tags: [toTitle(trimmedPath.split('/')[1] ?? 'General')], + tags: [toTitle(path.split('/')[1] ?? 'General')], requestBody: body && { required: true, content: { @@ -57,7 +54,7 @@ export default function swaggerRoutes() { }; ctx.body = document; - }); - return router.routes(); + return next(); + }); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5959fded8..f887f1b8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,7 @@ importers: '@types/koa-logger': ^3.1.1 '@types/koa-mount': ^4.0.0 '@types/koa-router': ^7.4.2 + '@types/koa-static': ^4.0.2 '@types/lodash.pick': ^4.4.6 '@types/node': ^16.3.1 '@types/oidc-provider': ^7.4.1 @@ -45,6 +46,7 @@ importers: koa-mount: ^4.0.0 koa-proxies: ^0.12.1 koa-router: ^10.0.0 + koa-static: ^5.0.0 lint-staged: ^11.1.1 lodash.pick: ^4.4.0 module-alias: ^2.2.2 @@ -74,6 +76,7 @@ importers: koa-mount: 4.0.0 koa-proxies: 0.12.1_koa@2.13.1 koa-router: 10.0.0 + koa-static: 5.0.0 lodash.pick: 4.4.0 module-alias: 2.2.2 nanoid: 3.1.23 @@ -88,6 +91,7 @@ importers: '@types/koa-logger': 3.1.1 '@types/koa-mount': 4.0.0 '@types/koa-router': 7.4.4 + '@types/koa-static': 4.0.2 '@types/lodash.pick': 4.4.6 '@types/node': 16.4.6 '@types/oidc-provider': 7.4.2 @@ -3142,6 +3146,19 @@ packages: '@types/koa': 2.13.4 dev: true + /@types/koa-send/4.1.3: + resolution: {integrity: sha512-daaTqPZlgjIJycSTNjKpHYuKhXYP30atFc1pBcy6HHqB9+vcymDgYTguPdx9tO4HMOqNyz6bz/zqpxt5eLR+VA==} + dependencies: + '@types/koa': 2.13.4 + dev: true + + /@types/koa-static/4.0.2: + resolution: {integrity: sha512-ns/zHg+K6XVPMuohjpOlpkR1WLa4VJ9czgUP9bxkCDn0JZBtUWbD/wKDZzPGDclkQK1bpAEScufCHOy8cbfL0w==} + dependencies: + '@types/koa': 2.13.4 + '@types/koa-send': 4.1.3 + dev: true + /@types/koa/2.13.4: resolution: {integrity: sha512-dfHYMfU+z/vKtQB7NUrthdAEiSvnLebvBjwHtfFmpZmB7em2N3WVQdHgnFq+xvyVgxW5jKDmjWfLD3lw4g4uTw==} dependencies: @@ -5832,7 +5849,6 @@ packages: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} dependencies: ms: 2.1.3 - dev: true /debug/4.3.2: resolution: {integrity: sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==} @@ -8029,7 +8045,6 @@ packages: inherits: 2.0.3 setprototypeof: 1.1.0 statuses: 1.5.0 - dev: true /http-errors/1.7.2: resolution: {integrity: sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg==} @@ -8325,7 +8340,6 @@ packages: /inherits/2.0.3: resolution: {integrity: sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=} - dev: true /inherits/2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -9664,6 +9678,27 @@ packages: - supports-color dev: false + /koa-send/5.0.1: + resolution: {integrity: sha512-tmcyQ/wXXuxpDxyNXv5yNNkdAMdFRqwtegBXUaowiQzUKqJehttS0x2j0eOZDQAyloAth5w6wwBImnFzkUz3pQ==} + engines: {node: '>= 8'} + dependencies: + debug: 4.3.2 + http-errors: 1.8.0 + resolve-path: 1.4.0 + transitivePeerDependencies: + - supports-color + dev: false + + /koa-static/5.0.0: + resolution: {integrity: sha512-UqyYyH5YEXaJrf9S8E23GoJFQZXkBVJ9zYYMPGz919MSX1KuvAcycIuS0ci150HCoPf4XQVhQ84Qf8xRPWxFaQ==} + engines: {node: '>= 7.6.0'} + dependencies: + debug: 3.2.7 + koa-send: 5.0.1 + transitivePeerDependencies: + - supports-color + dev: false + /koa/2.13.1: resolution: {integrity: sha512-Lb2Dloc72auj5vK4X4qqL7B5jyDPQaZucc9sR/71byg7ryoD1NCaCm63CShk9ID9quQvDEi1bGR/iGjCG7As3w==} engines: {node: ^4.8.4 || ^6.10.1 || ^7.10.1 || >= 8.1.4} @@ -10556,7 +10591,6 @@ packages: /ms/2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - dev: true /multi-fork/0.0.2: resolution: {integrity: sha1-gFiuxGFBJMftqhWBm4juiJ0+tOA=} @@ -11514,7 +11548,6 @@ packages: /path-is-absolute/1.0.1: resolution: {integrity: sha1-F0uSaHNVNP+8es5r9TpanhtcX18=} engines: {node: '>=0.10.0'} - dev: true /path-is-inside/1.0.2: resolution: {integrity: sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=} @@ -13206,6 +13239,14 @@ packages: global-dirs: 0.1.1 dev: true + /resolve-path/1.4.0: + resolution: {integrity: sha1-xL2p9e+y/OZSR4c6s2u02DT+Fvc=} + engines: {node: '>= 0.8'} + dependencies: + http-errors: 1.6.3 + path-is-absolute: 1.0.1 + dev: false + /resolve-pathname/3.0.0: resolution: {integrity: sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==} dev: false @@ -13632,7 +13673,6 @@ packages: /setprototypeof/1.1.0: resolution: {integrity: sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==} - dev: true /setprototypeof/1.1.1: resolution: {integrity: sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==}