diff --git a/package.json b/package.json index e5a77c019..f1296f741 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "lerna": "lerna", "bootstrap": "lerna bootstrap", "prepare": "if test \"$NODE_ENV\" != \"production\" && test \"$CI\" != \"true\" ; then husky install ; fi", - "dev": "lerna run --stream prepack && lerna --scope=@logto/{core,ui} exec -- pnpm dev" + "dev": "lerna run --stream prepack && lerna --scope=@logto/{core,ui,console} exec -- pnpm dev" }, "devDependencies": { "@commitlint/cli": "^13.2.1", diff --git a/packages/console/package.json b/packages/console/package.json index 786387cac..6a6ebb0ec 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -9,8 +9,9 @@ "preinstall": "npx only-allow pnpm", "precommit": "lint-staged", "start": "parcel src/index.html", + "dev": "PORT=5002 parcel src/index.html --public-url /console --no-hmr", "check": "tsc --noEmit", - "build": "pnpm check && rm -rf dist && parcel build src/index.html --no-autoinstall", + "build": "pnpm check && rm -rf dist && parcel build src/index.html --no-autoinstall --public-url /console", "lint": "eslint --ext .ts --ext .tsx src", "stylelint": "stylelint \"src/**/*.scss\"" }, diff --git a/packages/core/src/app/init.test.ts b/packages/core/src/app/init.test.ts index 9ddde9ddd..2b141c4cd 100644 --- a/packages/core/src/app/init.test.ts +++ b/packages/core/src/app/init.test.ts @@ -4,7 +4,7 @@ import * as koaErrorHandler from '@/middleware/koa-error-handler'; import * as koaI18next from '@/middleware/koa-i18next'; import * as koaOIDCErrorHandler from '@/middleware/koa-oidc-error-handler'; import * as koaSlonikErrorHandler from '@/middleware/koa-slonik-error-handler'; -import * as koaUIProxy from '@/middleware/koa-ui-proxy'; +import * as koaSpaProxy from '@/middleware/koa-spa-proxy'; import * as koaUserLog from '@/middleware/koa-user-log'; import * as initOidc from '@/oidc/init'; import * as initRouter from '@/routes/init'; @@ -20,7 +20,7 @@ describe('App Init', () => { koaI18next, koaOIDCErrorHandler, koaSlonikErrorHandler, - koaUIProxy, + koaSpaProxy, koaUserLog, ]; const initMethods = [initRouter, initOidc]; diff --git a/packages/core/src/app/init.ts b/packages/core/src/app/init.ts index dbe35e215..b7206e27a 100644 --- a/packages/core/src/app/init.ts +++ b/packages/core/src/app/init.ts @@ -3,14 +3,15 @@ import https from 'https'; import Koa from 'koa'; import koaLogger from 'koa-logger'; +import mount from 'koa-mount'; -import { port } from '@/env/consts'; +import { MountedApps, port } from '@/env/consts'; import koaConnectorErrorHandler from '@/middleware/koa-connector-error-handle'; import koaErrorHandler from '@/middleware/koa-error-handler'; import koaI18next from '@/middleware/koa-i18next'; import koaOIDCErrorHandler from '@/middleware/koa-oidc-error-handler'; import koaSlonikErrorHandler from '@/middleware/koa-slonik-error-handler'; -import koaUIProxy from '@/middleware/koa-ui-proxy'; +import koaSpaProxy from '@/middleware/koa-spa-proxy'; import koaUserLog from '@/middleware/koa-user-log'; import initOidc from '@/oidc/init'; import initRouter from '@/routes/init'; @@ -29,7 +30,10 @@ export default async function initApp(app: Koa): Promise { const provider = await initOidc(app); initRouter(app, provider); - app.use(koaUIProxy()); + app.use( + mount('/' + MountedApps.Console, koaSpaProxy(MountedApps.Console, 5002, MountedApps.Console)) + ); + app.use(koaSpaProxy()); const { HTTPS_CERT, HTTPS_KEY } = process.env; diff --git a/packages/core/src/env/consts.ts b/packages/core/src/env/consts.ts index 87f63dc2e..3b17b484e 100644 --- a/packages/core/src/env/consts.ts +++ b/packages/core/src/env/consts.ts @@ -3,7 +3,11 @@ import { assertEnv, getEnv } from '@silverhand/essentials'; export const signIn = assertEnv('UI_SIGN_IN_ROUTE'); export const isProduction = getEnv('NODE_ENV') === 'production'; export const port = Number(getEnv('PORT', '3001')); -export const mountedApps = Object.freeze(['api', 'oidc']); +export enum MountedApps { + Api = 'api', + Oidc = 'oidc', + Console = 'console', +} export const developmentUserId = getEnv('DEVELOPMENT_USER_ID'); // Trusting TLS offloading proxies: https://github.com/panva/node-oidc-provider/blob/main/docs/README.md#trusting-tls-offloading-proxies diff --git a/packages/core/src/middleware/koa-spa-proxy.test.ts b/packages/core/src/middleware/koa-spa-proxy.test.ts new file mode 100644 index 000000000..596f210f2 --- /dev/null +++ b/packages/core/src/middleware/koa-spa-proxy.test.ts @@ -0,0 +1,72 @@ +import { MountedApps } from '@/env/consts'; +import { createContextWithRouteParameters } from '@/utils/test-utils'; + +import koaSpaProxy from './koa-spa-proxy'; + +const mockProxyMiddleware = jest.fn(); +const mockStaticMiddleware = jest.fn(); + +jest.mock('fs/promises', () => ({ + ...jest.requireActual('fs/promises'), + readdir: jest.fn().mockResolvedValue(['sign-in']), +})); + +jest.mock('koa-proxies', () => jest.fn(() => mockProxyMiddleware)); +jest.mock('koa-static', () => jest.fn(() => mockStaticMiddleware)); + +describe('koaSpaProxy middleware', () => { + const envBackup = process.env; + + beforeEach(() => { + process.env = { ...envBackup }; + jest.clearAllMocks(); + jest.resetModules(); + }); + + const next = jest.fn(); + + for (const app of Object.values(MountedApps)) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + it(`${app} path should not call dev proxy`, async () => { + const ctx = createContextWithRouteParameters({ + url: `/${app}/foo`, + }); + + await koaSpaProxy()(ctx, next); + + expect(mockProxyMiddleware).not.toBeCalled(); + }); + } + + it('dev env should call dev proxy for SPA paths', async () => { + const ctx = createContextWithRouteParameters(); + await koaSpaProxy()(ctx, next); + expect(mockProxyMiddleware).toBeCalled(); + }); + + it('production env should overwrite the request path to root if no target ui file are detected', async () => { + process.env.NODE_ENV = 'production'; + + const ctx = createContextWithRouteParameters({ + url: '/foo', + }); + + const { default: proxy } = await import('./koa-spa-proxy'); + await proxy()(ctx, next); + + expect(mockStaticMiddleware).toBeCalled(); + expect(ctx.request.path).toEqual('/'); + }); + + it('production env should call the static middleware if path hit the ui file directory', async () => { + process.env.NODE_ENV = 'production'; + + const { default: proxy } = await import('./koa-spa-proxy'); + const ctx = createContextWithRouteParameters({ + url: '/sign-in', + }); + + await proxy()(ctx, next); + expect(mockStaticMiddleware).toBeCalled(); + }); +}); diff --git a/packages/core/src/middleware/koa-spa-proxy.ts b/packages/core/src/middleware/koa-spa-proxy.ts new file mode 100644 index 000000000..5d8d2bbfc --- /dev/null +++ b/packages/core/src/middleware/koa-spa-proxy.ts @@ -0,0 +1,59 @@ +import fs from 'fs/promises'; +import path from 'path'; + +import { MiddlewareType } from 'koa'; +import proxy from 'koa-proxies'; +import { IRouterParamContext } from 'koa-router'; +import serveStatic from 'koa-static'; + +import { isProduction, MountedApps } from '@/env/consts'; + +export default function koaSpaProxy( + packagePath = 'ui', + port = 5001, + prefix = '' +): MiddlewareType { + type Middleware = MiddlewareType; + + const distPath = path.join('..', packagePath, 'dist'); + + const spaProxy: Middleware = isProduction + ? serveStatic(distPath) + : proxy('*', { + target: `http://localhost:${port}`, + changeOrigin: true, + logs: true, + rewrite: (requestPath) => { + // Static files + if (requestPath.includes('.')) { + return '/' + path.join(prefix, requestPath); + } + + // In-app routes + return requestPath; + }, + }); + + return async (ctx, next) => { + const requestPath = ctx.request.path; + + // Route has been handled by one of mounted apps + if ( + Object.values(MountedApps).some((app) => app !== prefix && requestPath.startsWith(`/${app}`)) + ) { + return next(); + } + + if (!isProduction) { + return spaProxy(ctx, next); + } + + const spaDistFiles = await fs.readdir(distPath); + + if (!spaDistFiles.some((file) => requestPath.startsWith('/' + file))) { + ctx.request.path = '/'; + } + + return spaProxy(ctx, next); + }; +} diff --git a/packages/core/src/middleware/koa-ui-proxy.test.ts b/packages/core/src/middleware/koa-ui-proxy.test.ts deleted file mode 100644 index 5de958606..000000000 --- a/packages/core/src/middleware/koa-ui-proxy.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { mountedApps } from '@/env/consts'; -import { createContextWithRouteParameters } from '@/utils/test-utils'; - -import koaUIProxy from './koa-ui-proxy'; - -const mockProxyMiddleware = jest.fn(); -const mockStaticMiddleware = jest.fn(); - -jest.mock('fs/promises', () => ({ - ...jest.requireActual('fs/promises'), - readdir: jest.fn().mockResolvedValue(['sign-in']), -})); - -jest.mock('koa-proxies', () => jest.fn(() => mockProxyMiddleware)); -jest.mock('koa-static', () => jest.fn(() => mockStaticMiddleware)); - -describe('koaUIProxy middleware', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.resetModules(); - }); - - const next = jest.fn(); - - for (const app of mountedApps) { - // eslint-disable-next-line @typescript-eslint/no-loop-func - it(`${app} path should not call uiProxy`, async () => { - const ctx = createContextWithRouteParameters({ - url: `/${app}/foo`, - }); - - await koaUIProxy()(ctx, next); - - expect(mockProxyMiddleware).not.toBeCalled(); - }); - } - - it('dev env should call proxy middleware for ui paths', async () => { - const ctx = createContextWithRouteParameters(); - await koaUIProxy()(ctx, next); - expect(mockProxyMiddleware).toBeCalled(); - }); - - it('production env should overwrite the request path to root if no target ui file are detected', async () => { - // Mock the @/env/consts - jest.mock('@/env/consts', () => ({ - ...jest.requireActual('@/env/consts'), - isProduction: true, - })); - - /* eslint-disable @typescript-eslint/no-require-imports */ - /* eslint-disable @typescript-eslint/no-var-requires */ - /* eslint-disable unicorn/prefer-module */ - const koaUIProxyModule = require('./koa-ui-proxy') as { default: typeof koaUIProxy }; - /* eslint-enable @typescript-eslint/no-require-imports */ - /* eslint-enable @typescript-eslint/no-var-requires */ - /* eslint-enable unicorn/prefer-module */ - const ctx = createContextWithRouteParameters({ - url: '/foo', - }); - - await koaUIProxyModule.default()(ctx, next); - expect(mockStaticMiddleware).toBeCalled(); - expect(ctx.request.path).toEqual('/'); - }); - - it('production env should call the static middleware if path hit the ui file directory', async () => { - // Mock the @/env/consts - jest.mock('@/env/consts', () => ({ - ...jest.requireActual('@/env/consts'), - isProduction: true, - })); - - /* eslint-disable @typescript-eslint/no-require-imports */ - /* eslint-disable @typescript-eslint/no-var-requires */ - /* eslint-disable unicorn/prefer-module */ - const koaUIProxyModule = require('./koa-ui-proxy') as { default: typeof koaUIProxy }; - /* eslint-enable @typescript-eslint/no-require-imports */ - /* eslint-enable @typescript-eslint/no-var-requires */ - /* eslint-enable unicorn/prefer-module */ - const ctx = createContextWithRouteParameters({ - url: '/sign-in', - }); - - await koaUIProxyModule.default()(ctx, next); - expect(mockStaticMiddleware).toBeCalled(); - }); -}); diff --git a/packages/core/src/middleware/koa-ui-proxy.ts b/packages/core/src/middleware/koa-ui-proxy.ts deleted file mode 100644 index 4e8a9d8ab..000000000 --- a/packages/core/src/middleware/koa-ui-proxy.ts +++ /dev/null @@ -1,45 +0,0 @@ -import fs from 'fs/promises'; - -import { MiddlewareType } from 'koa'; -import proxy from 'koa-proxies'; -import { IRouterParamContext } from 'koa-router'; -import serveStatic from 'koa-static'; - -import { isProduction, mountedApps } from '@/env/consts'; - -const PATH_TO_UI_DIST = '../ui/dist'; - -export default function koaUIProxy< - StateT, - ContextT extends IRouterParamContext, - ResponseBodyT ->(): MiddlewareType { - type Middleware = MiddlewareType; - - const uiProxy: Middleware = isProduction - ? serveStatic(PATH_TO_UI_DIST) - : proxy('*', { - target: 'http://localhost:5001', - changeOrigin: true, - logs: true, - }); - - return async (ctx, next) => { - // Route has been handled by one of mounted apps - if (mountedApps.some((app) => ctx.request.path.startsWith(`/${app}`))) { - return next(); - } - - if (!isProduction) { - return uiProxy(ctx, next); - } - - const uiDistFiles = await fs.readdir(PATH_TO_UI_DIST); - - if (!uiDistFiles.some((file) => ctx.request.path.startsWith(`/${file}`))) { - ctx.request.path = '/'; - } - - return uiProxy(ctx, next); - }; -} diff --git a/packages/ui/package.json b/packages/ui/package.json index a7c24376f..fc215e7be 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,7 +7,7 @@ "preinstall": "npx only-allow pnpm", "precommit": "lint-staged", "start": "parcel src/index.html", - "dev": "PORT=5001 pnpm start", + "dev": "PORT=5001 parcel src/index.html --no-hmr", "check": "tsc --noEmit", "build": "pnpm check && rm -rf dist && parcel build src/index.html --no-autoinstall", "lint": "eslint --ext .ts --ext .tsx src",