diff --git a/packages/platform-shared/src/shared/helper/getAppData.ts b/packages/platform-shared/src/shared/helper/getAppData.ts index 2d08e20a1..f9fd62b8b 100644 --- a/packages/platform-shared/src/shared/helper/getAppData.ts +++ b/packages/platform-shared/src/shared/helper/getAppData.ts @@ -7,6 +7,7 @@ export type IData = { export type IAppData = { ssr: boolean; + basename?: string; runtimeConfig?: Record; appState?: appState; pageData?: { diff --git a/packages/platform-web/src/node/features/html-render/lib/renderToHTML.ts b/packages/platform-web/src/node/features/html-render/lib/renderToHTML.ts index 30e228137..6e9531a9d 100644 --- a/packages/platform-web/src/node/features/html-render/lib/renderToHTML.ts +++ b/packages/platform-web/src/node/features/html-render/lib/renderToHTML.ts @@ -13,6 +13,12 @@ export async function renderToHTML({ }): Promise { let result: Response; const renderer = new Renderer({ serverPluginContext }); + const { + config: { ssr }, + appConfig: { + router: { basename } + } + } = serverPluginContext; const { serverCreateAppTrace } = req._traces; const { application } = resources.server; const app = serverCreateAppTrace @@ -23,7 +29,8 @@ export async function renderToHTML({ .traceFn(() => application.createApp({ req, - ssr: serverPluginContext.config.ssr + ssr, + basename }) ); @@ -37,7 +44,7 @@ export async function renderToHTML({ result = await renderer.renderView({ req, app: app.getPublicAPI(), - ssr: serverPluginContext.config.ssr + ssr }); } finally { await app.dispose(); diff --git a/packages/platform-web/src/node/features/html-render/lib/renderer/ssr.ts b/packages/platform-web/src/node/features/html-render/lib/renderer/ssr.ts index f04f77aba..421a5531e 100644 --- a/packages/platform-web/src/node/features/html-render/lib/renderer/ssr.ts +++ b/packages/platform-web/src/node/features/html-render/lib/renderer/ssr.ts @@ -34,7 +34,8 @@ export class SsrRenderer extends BaseRenderer { ...result.appData, ssr: true, appState: store.getState(), - pageData + pageData, + basename: router.basename }; appData.runtimeConfig = getPublicRuntimeConfig() || {}; diff --git a/packages/platform-web/src/node/features/on-demand-compile-page/onDemandRouteManager.ts b/packages/platform-web/src/node/features/on-demand-compile-page/onDemandRouteManager.ts index 36b671408..3991ab607 100644 --- a/packages/platform-web/src/node/features/on-demand-compile-page/onDemandRouteManager.ts +++ b/packages/platform-web/src/node/features/on-demand-compile-page/onDemandRouteManager.ts @@ -1,4 +1,5 @@ import { matchRoutes } from '@shuvi/router'; +import { normalizeBase } from '@shuvi/router/lib/utils'; import resources from '@shuvi/service/lib/resources'; import { ShuviRequestHandler, IServerPluginContext } from '@shuvi/service'; import { DevMiddleware } from '@shuvi/service/lib/server/middlewares/dev'; @@ -85,8 +86,15 @@ export default class OnDemandRouteManager { } async ensureRoutes(pathname: string): Promise { + const { + router: { basename } + } = this._serverPluginContext.appConfig; const matchedRoutes = - matchRoutes(resources.server.pageRoutes, pathname) || []; + matchRoutes( + resources.server.pageRoutes, + pathname, + normalizeBase(basename) + ) || []; const modulesToActivate = matchedRoutes .map(({ route: { __componentRawRequest__ } }) => __componentRawRequest__) diff --git a/packages/platform-web/src/node/shuvi-type-extensions-node.ts b/packages/platform-web/src/node/shuvi-type-extensions-node.ts index ae68e8a64..af8e1d4ae 100644 --- a/packages/platform-web/src/node/shuvi-type-extensions-node.ts +++ b/packages/platform-web/src/node/shuvi-type-extensions-node.ts @@ -17,6 +17,9 @@ import { export {}; declare module '@shuvi/service/lib/resources' { + interface IGetRoutes { + (routes: IPageRouteRecord[]): IPageRouteRecord[]; + } export interface IResources { server: { server: IServerModule; @@ -27,6 +30,7 @@ declare module '@shuvi/service/lib/resources' { createApp: CreateAppServer; }; view: IViewServer; + getRoutes: IGetRoutes; }; documentPath: string; clientManifest: IManifest; diff --git a/packages/platform-web/src/shared/appTypes.ts b/packages/platform-web/src/shared/appTypes.ts index 593b36803..579d093d1 100644 --- a/packages/platform-web/src/shared/appTypes.ts +++ b/packages/platform-web/src/shared/appTypes.ts @@ -16,7 +16,11 @@ export type InternalApplication = _ApplicationImpl; export type Application = _Application; export interface CreateAppServer { - (options: { req: ShuviRequest; ssr: boolean }): InternalApplication; + (options: { + req: ShuviRequest; + ssr: boolean; + basename: string; + }): InternalApplication; } export interface CreateAppClient { diff --git a/packages/platform-web/src/shuvi-app/app/client.ts b/packages/platform-web/src/shuvi-app/app/client.ts index db1fbaf94..9fd8adfc4 100644 --- a/packages/platform-web/src/shuvi-app/app/client.ts +++ b/packages/platform-web/src/shuvi-app/app/client.ts @@ -38,7 +38,7 @@ export const createApp: CreateAppClient = ({ return app; } - const { appState, ssr } = appData; + const { appState, ssr, basename } = appData; let history: History; if (historyMode === 'hash') { history = createHashHistory(); @@ -48,7 +48,8 @@ export const createApp: CreateAppClient = ({ const router = createRouter({ history, - routes: getRoutes(routes) + routes: getRoutes(routes), + basename }); app = application({ diff --git a/packages/platform-web/src/shuvi-app/app/server.ts b/packages/platform-web/src/shuvi-app/app/server.ts index 7f1659927..48a0d5597 100644 --- a/packages/platform-web/src/shuvi-app/app/server.ts +++ b/packages/platform-web/src/shuvi-app/app/server.ts @@ -21,14 +21,15 @@ import { serializeServerError } from '../helper/serializeServerError'; const { SHUVI_SERVER_RUN_LOADERS } = SERVER_CREATE_APP.events; export const createApp: CreateAppServer = options => { - const { req, ssr } = options; + const { req, ssr, basename } = options; const history = createMemoryHistory({ initialEntries: [(req && req.url) || '/'], initialIndex: 0 }); const router = createRouter({ history, - routes: getRoutes(routes) + routes: getRoutes(routes), + basename }) as IRouter; let app: InternalApplication; if (ssr) { diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index 99272d73d..fa4bd4663 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -86,6 +86,10 @@ class Router implements IRouter { return this._history.action; } + get basename(): string { + return this._basename; + } + init = () => { const setup = () => this._history.setup(); this._history.transitionTo(this._getCurrent(), { diff --git a/packages/router/src/types/router.ts b/packages/router/src/types/router.ts index 25dafaa5a..1660f1fbc 100644 --- a/packages/router/src/types/router.ts +++ b/packages/router/src/types/router.ts @@ -94,6 +94,7 @@ export interface IRouter< > { current: IRoute; action: History['action']; + basename: string; push(to: PathRecord, state?: any): void; replace(to: PathRecord, state?: any): void; go: History['go']; diff --git a/packages/service/src/server/middlewares/setupAppConfigMiddleware.ts b/packages/service/src/server/middlewares/setupAppConfigMiddleware.ts new file mode 100644 index 000000000..689284241 --- /dev/null +++ b/packages/service/src/server/middlewares/setupAppConfigMiddleware.ts @@ -0,0 +1,19 @@ +import { IServerPluginContext } from '../plugin'; +import { ShuviRequestHandler } from '../shuviServerTypes'; + +export const setupAppConfigMiddleware = ( + context: IServerPluginContext +): ShuviRequestHandler => { + return async (req, _res, next) => { + const appConfig = context.serverPluginRunner.getAppConfig({ req }); + if (appConfig) { + if (typeof appConfig.router.basename !== 'string') { + throw new Error( + '[ServerPlugin Hook getAppConfig] appConfig.router.basename must be a string' + ); + } + context.appConfig = appConfig; + } + next(); + }; +}; diff --git a/packages/service/src/server/plugin.ts b/packages/service/src/server/plugin.ts index 5c3b0d7da..57607d12e 100644 --- a/packages/service/src/server/plugin.ts +++ b/packages/service/src/server/plugin.ts @@ -1,6 +1,7 @@ import { createAsyncParallelHook, createHookManager, + createSyncBailHook, IPluginInstance, IPluginHandlers, HookMap @@ -8,11 +9,13 @@ import { import { createPluginCreator } from '@shuvi/shared/plugins'; import { IPluginContext } from '../core'; import { CustomServerPluginHooks } from './pluginTypes'; +import { ShuviRequest } from './shuviServerTypes'; export * from './pluginTypes'; export interface IServerPluginContext extends IPluginContext { serverPluginRunner: PluginManager['runner']; + appConfig: AppConfig; } export type PluginManager = ReturnType; @@ -21,12 +24,25 @@ export type PluginRunner = PluginManager['runner']; const listen = createAsyncParallelHook<{ port: number; hostname?: string }>(); +type AppConfigCtx = { + req: ShuviRequest; +}; + +type AppConfig = { + router: { + basename: string; + }; +}; +const getAppConfig = createSyncBailHook(); + const internalHooks = { - listen + listen, + getAppConfig }; export interface BuiltInServerPluginHooks extends HookMap { listen: typeof listen; + getAppConfig: typeof getAppConfig; } export interface ServerPluginHooks @@ -63,7 +79,13 @@ export const initServerPlugins = ( ): IServerPluginContext => { const serverContext = Object.assign( { - serverPluginRunner: manager.runner + serverPluginRunner: manager.runner, + // default appConfig, can be override by setupAppConfigMiddleware + appConfig: { + router: { + basename: '' + } + } }, pluginContext ); diff --git a/packages/service/src/server/shuviDevServer.ts b/packages/service/src/server/shuviDevServer.ts index fcce7d771..78a434bd4 100644 --- a/packages/service/src/server/shuviDevServer.ts +++ b/packages/service/src/server/shuviDevServer.ts @@ -23,6 +23,7 @@ import { applyHttpProxyMiddleware } from './middlewares/httpProxyMiddleware'; import { getAssetMiddleware } from './middlewares/getAssetMiddleware'; import { ShuviDevServerOptions, ShuviRequestHandler } from './shuviServerTypes'; import { loadDotenvConfig } from '../config/env'; +import { setupAppConfigMiddleware } from './middlewares/setupAppConfigMiddleware'; export class ShuviDevServer extends ShuviServer { private _bundler: Bundler; @@ -60,6 +61,8 @@ export class ShuviDevServer extends ShuviServer { next(); }) as ShuviRequestHandler); + server.use(setupAppConfigMiddleware(context)); + if (this._options.getMiddlewaresBeforeDevMiddlewares) { const serverMiddlewaresBeforeDevMiddleware = [ this._options.getMiddlewaresBeforeDevMiddlewares(devMiddleware, context) diff --git a/packages/service/src/server/shuviProdServer.ts b/packages/service/src/server/shuviProdServer.ts index a74366872..d6046c2e7 100644 --- a/packages/service/src/server/shuviProdServer.ts +++ b/packages/service/src/server/shuviProdServer.ts @@ -1,9 +1,11 @@ import { ShuviServer } from './shuviServer'; import { getAssetMiddleware } from './middlewares/getAssetMiddleware'; +import { setupAppConfigMiddleware } from './middlewares/setupAppConfigMiddleware'; export class ShuviProdServer extends ShuviServer { async init() { const { _serverContext: context } = this; this._server.use(getAssetMiddleware(context)); + this._server.use(setupAppConfigMiddleware(context)); await this._initMiddlewares(); } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24f918e75..a3c48eb59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -995,6 +995,12 @@ importers: specifier: workspace:* version: link:../../packages/shared + test/fixtures/basename: + dependencies: + shuvi: + specifier: workspace:* + version: link:../../../packages/shuvi + test/fixtures/basic: dependencies: shuvi: diff --git a/test/e2e/basename.test.ts b/test/e2e/basename.test.ts new file mode 100644 index 000000000..27f8c661f --- /dev/null +++ b/test/e2e/basename.test.ts @@ -0,0 +1,51 @@ +import { AppCtx, Page, devFixture } from '../utils'; + +jest.setTimeout(5 * 60 * 1000); + +describe('Basename Support', () => { + let ctx: AppCtx; + let page: Page; + + describe('basename should work', () => { + beforeAll(async () => { + ctx = await devFixture('basename'); + }); + afterAll(async () => { + await page.close(); + await ctx.close(); + }); + + test('basename should work for route matching', async () => { + page = await ctx.browser.page(ctx.url('/base-name')); + expect(await page.$text('#index')).toEqual('Index Page'); + + page = await ctx.browser.page(ctx.url('/base-name/en')); + expect(await page.$text('#lng')).toEqual('en'); + }); + + test.skip('basename should work when client navigation', async () => { + page = await ctx.browser.page(ctx.url('/base-name')); + expect(await page.$text('#index')).toEqual('Index Page'); + + await page.shuvi.navigate('/en'); + await page.waitForSelector('#lng'); + expect(await page.$text('#lng')).toEqual('en'); + + await page.shuvi.navigate('/'); + await page.waitForSelector('#index'); + expect(await page.$text('#index')).toEqual('index page'); + }); + }); + + test('basename must be a string', async () => { + ctx = await devFixture('basename', { + plugins: [['./plugin', { basename: null }]] + }); + page = await ctx.browser.page(ctx.url('/base-name')); + const result = await page.goto(ctx.url('/base-name')); + expect(result?.status()).toBe(500); + expect(await page.$text('body')).toContain( + 'appConfig.router.basename must be a string' + ); + }); +}); diff --git a/test/fixtures/basename/package.json b/test/fixtures/basename/package.json new file mode 100644 index 000000000..f3d1b3309 --- /dev/null +++ b/test/fixtures/basename/package.json @@ -0,0 +1,6 @@ +{ + "name": "fixture-basename", + "dependencies": { + "shuvi": "workspace:*" + } +} diff --git a/test/fixtures/basename/plugin/server.js b/test/fixtures/basename/plugin/server.js new file mode 100644 index 000000000..01a09ff94 --- /dev/null +++ b/test/fixtures/basename/plugin/server.js @@ -0,0 +1,15 @@ +const { createServerPlugin } = require('shuvi'); + +module.exports = ({ basename = '' }) => + createServerPlugin({ + getAppConfig: ({ req }) => { + global._req_url = req.url; + const appConfig = { + router: { + basename + } + }; + global._app_config = appConfig; + return appConfig; + } + }); diff --git a/test/fixtures/basename/shuvi.config.js b/test/fixtures/basename/shuvi.config.js new file mode 100644 index 000000000..06bd2febe --- /dev/null +++ b/test/fixtures/basename/shuvi.config.js @@ -0,0 +1,7 @@ +export default { + ssr: true, + router: { + history: 'browser' + }, + plugins: [['./plugin', { basename: '/base-name' }]] +}; diff --git a/test/fixtures/basename/src/routes/$lng/page.js b/test/fixtures/basename/src/routes/$lng/page.js new file mode 100644 index 000000000..810654ec5 --- /dev/null +++ b/test/fixtures/basename/src/routes/$lng/page.js @@ -0,0 +1,11 @@ +import { useParams, Link } from '@shuvi/runtime'; + +export default function Page() { + const { lng } = useParams(); + return ( +
+
{lng}
+ Go to / +
+ ); +} diff --git a/test/fixtures/basename/src/routes/layout.js b/test/fixtures/basename/src/routes/layout.js new file mode 100644 index 000000000..d1f9a5579 --- /dev/null +++ b/test/fixtures/basename/src/routes/layout.js @@ -0,0 +1,6 @@ +import { RouterView, useRouter } from '@shuvi/runtime'; +export default function Page() { + const router = useRouter(); + console.log('----router', router); + return ; +} diff --git a/test/fixtures/basename/src/routes/page.js b/test/fixtures/basename/src/routes/page.js new file mode 100644 index 000000000..e6cd48cb3 --- /dev/null +++ b/test/fixtures/basename/src/routes/page.js @@ -0,0 +1,18 @@ +import { Link, useRouter } from '@shuvi/runtime'; +export default function Page() { + const router = useRouter(); + const goToAbout = () => { + router.push('/about'); + }; + return ( +
+
Index Page
+
+ Go to /en +
+
+ +
+
+ ); +}