From 8e32ed8eeff4a66cda86e116c46207e9d716515d Mon Sep 17 00:00:00 2001 From: Emanuele Stoppa Date: Mon, 22 Apr 2024 14:54:12 +0100 Subject: [PATCH] feat: reroute in SSG (#10843) * feat: rerouting in ssg * linting --- packages/astro/src/core/build/generate.ts | 77 ++-------- packages/astro/src/core/build/pipeline.ts | 144 ++++++++++++++++-- packages/astro/src/core/render-context.ts | 4 + .../src/vite-plugin-astro-server/pipeline.ts | 6 +- .../src/vite-plugin-astro-server/route.ts | 1 + .../reroute/src/pages/dynamic/[id].astro | 21 +++ .../reroute/src/pages/spread/[...id].astro | 20 +++ .../astro/test/i18n-routing-manual.test.js | 2 - packages/astro/test/reroute.test.js | 64 +++++++- 9 files changed, 255 insertions(+), 84 deletions(-) create mode 100644 packages/astro/test/fixtures/reroute/src/pages/dynamic/[id].astro create mode 100644 packages/astro/test/fixtures/reroute/src/pages/spread/[...id].astro diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index 402c68c2ab53..13d31b3acb6f 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -35,24 +35,14 @@ import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js import type { SSRManifestI18n } from '../app/types.js'; import { NoPrerenderedRoutesWithDomains } from '../errors/errors-data.js'; import { AstroError, AstroErrorData } from '../errors/index.js'; -import { routeIsFallback } from '../redirects/helpers.js'; -import { - RedirectSinglePageBuiltModule, - getRedirectLocationOrThrow, - routeIsRedirect, -} from '../redirects/index.js'; +import { getRedirectLocationOrThrow, routeIsRedirect } from '../redirects/index.js'; import { RenderContext } from '../render-context.js'; import { callGetStaticPaths } from '../render/route-cache.js'; import { createRequest } from '../request.js'; import { matchRoute } from '../routing/match.js'; import { getOutputFilename } from '../util.js'; import { getOutDirWithinCwd, getOutFile, getOutFolder } from './common.js'; -import { - cssOrder, - getEntryFilePathFromComponentPath, - getPageDataByComponent, - mergeInlineCss, -} from './internal.js'; +import { cssOrder, getPageDataByComponent, mergeInlineCss } from './internal.js'; import { BuildPipeline } from './pipeline.js'; import type { PageBuildData, @@ -66,46 +56,6 @@ function createEntryURL(filePath: string, outFolder: URL) { return new URL('./' + filePath + `?time=${Date.now()}`, outFolder); } -async function getEntryForRedirectRoute( - route: RouteData, - internals: BuildInternals, - outFolder: URL -): Promise { - if (route.type !== 'redirect') { - throw new Error(`Expected a redirect route.`); - } - if (route.redirectRoute) { - const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); - if (filePath) { - const url = createEntryURL(filePath, outFolder); - const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); - return ssrEntryPage; - } - } - - return RedirectSinglePageBuiltModule; -} - -async function getEntryForFallbackRoute( - route: RouteData, - internals: BuildInternals, - outFolder: URL -): Promise { - if (route.type !== 'fallback') { - throw new Error(`Expected a redirect route.`); - } - if (route.redirectRoute) { - const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); - if (filePath) { - const url = createEntryURL(filePath, outFolder); - const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); - return ssrEntryPage; - } - } - - return RedirectSinglePageBuiltModule; -} - // Gives back a facadeId that is relative to the root. // ie, src/pages/index.astro instead of /Users/name..../src/pages/index.astro export function rootRelativeFacadeId(facadeId: string, settings: AstroSettings): string { @@ -185,14 +135,15 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil }); } - const ssrEntryURLPage = createEntryURL(filePath, outFolder); - const ssrEntryPage = await import(ssrEntryURLPage.toString()); + const ssrEntryPage = await pipeline.retrieveSsrEntry(pageData.route, filePath); if (options.settings.adapter?.adapterFeatures?.functionPerRoute) { // forcing to use undefined, so we fail in an expected way if the module is not even there. + // @ts-expect-error When building for `functionPerRoute`, the module exports a `pageModule` function instead const ssrEntry = ssrEntryPage?.pageModule; if (ssrEntry) { await generatePage(pageData, ssrEntry, builtPaths, pipeline); } else { + const ssrEntryURLPage = createEntryURL(filePath, outFolder); throw new Error( `Unable to find the manifest for the module ${ssrEntryURLPage.toString()}. This is unexpected and likely a bug in Astro, please report.` ); @@ -205,18 +156,8 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil } } else { for (const [pageData, filePath] of pagesToGenerate) { - if (routeIsRedirect(pageData.route)) { - const entry = await getEntryForRedirectRoute(pageData.route, internals, outFolder); - await generatePage(pageData, entry, builtPaths, pipeline); - } else if (routeIsFallback(pageData.route)) { - const entry = await getEntryForFallbackRoute(pageData.route, internals, outFolder); - await generatePage(pageData, entry, builtPaths, pipeline); - } else { - const ssrEntryURLPage = createEntryURL(filePath, outFolder); - const entry: SinglePageBuiltModule = await import(ssrEntryURLPage.toString()); - - await generatePage(pageData, entry, builtPaths, pipeline); - } + const entry = await pipeline.retrieveSsrEntry(pageData.route, filePath); + await generatePage(pageData, entry, builtPaths, pipeline); } } logger.info( @@ -232,12 +173,12 @@ export async function generatePages(options: StaticBuildOptions, internals: Buil .map((x) => x.transforms.size) .reduce((a, b) => a + b, 0); const cpuCount = os.cpus().length; - const assetsCreationpipeline = await prepareAssetsGenerationEnv(pipeline, totalCount); + const assetsCreationPipeline = await prepareAssetsGenerationEnv(pipeline, totalCount); const queue = new PQueue({ concurrency: Math.max(cpuCount, 1) }); const assetsTimer = performance.now(); for (const [originalPath, transforms] of staticImageList) { - await generateImagesForPath(originalPath, transforms, assetsCreationpipeline, queue); + await generateImagesForPath(originalPath, transforms, assetsCreationPipeline, queue); } await queue.onIdle(); diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts index 39e9ebc03a42..47cf8d9555ae 100644 --- a/packages/astro/src/core/build/pipeline.ts +++ b/packages/astro/src/core/build/pipeline.ts @@ -1,10 +1,9 @@ import type { + ComponentInstance, + ReroutePayload, RouteData, SSRLoadedRenderer, SSRResult, - MiddlewareHandler, - ReroutePayload, - ComponentInstance, } from '../../@types/astro.js'; import { getOutputDirectory, isServerLikeOutput } from '../../prerender/utils.js'; import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; @@ -19,22 +18,42 @@ import { import { type BuildInternals, cssOrder, + getEntryFilePathFromComponentPath, getPageDataByComponent, mergeInlineCss, } from './internal.js'; import { ASTRO_PAGE_MODULE_ID, ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js'; import { RESOLVED_SPLIT_MODULE_ID } from './plugins/plugin-ssr.js'; -import { getVirtualModulePageNameFromPath } from './plugins/util.js'; -import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; -import type { PageBuildData, StaticBuildOptions } from './types.js'; +import { + ASTRO_PAGE_EXTENSION_POST_PATTERN, + getVirtualModulePageNameFromPath, +} from './plugins/util.js'; +import type { PageBuildData, SinglePageBuiltModule, StaticBuildOptions } from './types.js'; import { i18nHasFallback } from './util.js'; -import { defineMiddleware } from '../middleware/index.js'; -import { undefined } from 'zod'; +import { RedirectSinglePageBuiltModule } from '../redirects/index.js'; +import { getOutDirWithinCwd } from './common.js'; /** * The build pipeline is responsible to gather the files emitted by the SSR build and generate the pages by executing these files. */ export class BuildPipeline extends Pipeline { + #componentsInterner: WeakMap = new WeakMap< + RouteData, + SinglePageBuiltModule + >(); + /** + * This cache is needed to map a single `RouteData` to its file path. + * @private + */ + #routesByFilePath: WeakMap = new WeakMap(); + + get outFolder() { + const ssr = isServerLikeOutput(this.settings.config); + return ssr + ? this.settings.config.build.server + : getOutDirWithinCwd(this.settings.config.outDir); + } + private constructor( readonly internals: BuildInternals, readonly manifest: SSRManifest, @@ -232,14 +251,115 @@ export class BuildPipeline extends Pipeline { } } + for (const [buildData, filePath] of pages.entries()) { + this.#routesByFilePath.set(buildData.route, filePath); + } + return pages; } - getComponentByRoute(_routeData: RouteData): Promise { - throw new Error('unimplemented'); + async getComponentByRoute(routeData: RouteData): Promise { + if (this.#componentsInterner.has(routeData)) { + // SAFETY: checked before + const entry = this.#componentsInterner.get(routeData)!; + return await entry.page(); + } else { + // SAFETY: the pipeline calls `retrieveRoutesToGenerate`, which is in charge to fill the cache. + const filePath = this.#routesByFilePath.get(routeData)!; + const module = await this.retrieveSsrEntry(routeData, filePath); + return module.page(); + } } - tryReroute(_reroutePayload: ReroutePayload): Promise<[RouteData, ComponentInstance]> { - throw new Error('unimplemented'); + async tryReroute(payload: ReroutePayload): Promise<[RouteData, ComponentInstance]> { + let foundRoute: RouteData | undefined; + // options.manifest is the actual type that contains the information + for (const route of this.options.manifest.routes) { + if (payload instanceof URL) { + if (route.pattern.test(payload.pathname)) { + foundRoute = route; + break; + } + } else if (payload instanceof Request) { + const url = new URL(payload.url); + if (route.pattern.test(url.pathname)) { + foundRoute = route; + break; + } + } else { + if (route.pattern.test(decodeURI(payload))) { + foundRoute = route; + break; + } + } + } + if (foundRoute) { + const componentInstance = await this.getComponentByRoute(foundRoute); + return [foundRoute, componentInstance]; + } else { + throw new Error('Route not found'); + } } + + async retrieveSsrEntry(route: RouteData, filePath: string): Promise { + if (this.#componentsInterner.has(route)) { + // SAFETY: it is checked inside the if + return this.#componentsInterner.get(route)!; + } + let entry; + if (routeIsRedirect(route)) { + entry = await this.#getEntryForRedirectRoute(route, this.internals, this.outFolder); + } else if (routeIsFallback(route)) { + entry = await this.#getEntryForFallbackRoute(route, this.internals, this.outFolder); + } else { + const ssrEntryURLPage = createEntryURL(filePath, this.outFolder); + entry = await import(ssrEntryURLPage.toString()); + } + this.#componentsInterner.set(route, entry); + return entry; + } + + async #getEntryForFallbackRoute( + route: RouteData, + internals: BuildInternals, + outFolder: URL + ): Promise { + if (route.type !== 'fallback') { + throw new Error(`Expected a redirect route.`); + } + if (route.redirectRoute) { + const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); + if (filePath) { + const url = createEntryURL(filePath, outFolder); + const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); + return ssrEntryPage; + } + } + + return RedirectSinglePageBuiltModule; + } + + async #getEntryForRedirectRoute( + route: RouteData, + internals: BuildInternals, + outFolder: URL + ): Promise { + if (route.type !== 'redirect') { + throw new Error(`Expected a redirect route.`); + } + if (route.redirectRoute) { + const filePath = getEntryFilePathFromComponentPath(internals, route.redirectRoute.component); + if (filePath) { + const url = createEntryURL(filePath, outFolder); + const ssrEntryPage: SinglePageBuiltModule = await import(url.toString()); + return ssrEntryPage; + } + } + + return RedirectSinglePageBuiltModule; + } +} + +function createEntryURL(filePath: string, outFolder: URL) { + return new URL('./' + filePath + `?time=${Date.now()}`, outFolder); } diff --git a/packages/astro/src/core/render-context.ts b/packages/astro/src/core/render-context.ts index 877b0f072151..0a76c7b6fed9 100644 --- a/packages/astro/src/core/render-context.ts +++ b/packages/astro/src/core/render-context.ts @@ -195,6 +195,7 @@ export class RenderContext { new Response(null, { status, headers: { Location: path } }); const reroute = async (reroutePayload: ReroutePayload) => { + pipeline.logger.debug('router', 'Called rerouting to:', reroutePayload); try { const [routeData, component] = await pipeline.tryReroute(reroutePayload); this.routeData = routeData; @@ -212,6 +213,7 @@ export class RenderContext { this.isRerouting = true; return await this.render(component); } catch (e) { + pipeline.logger.debug('router', 'Routing failed.', e); return new Response('Not found', { status: 404, statusText: 'Not found', @@ -336,6 +338,7 @@ export class RenderContext { const reroute = async (reroutePayload: ReroutePayload) => { try { + pipeline.logger.debug('router', 'Calling rerouting: ', reroutePayload); const [routeData, component] = await pipeline.tryReroute(reroutePayload); this.routeData = routeData; if (reroutePayload instanceof Request) { @@ -352,6 +355,7 @@ export class RenderContext { this.isRerouting = true; return await this.render(component); } catch (e) { + pipeline.logger.debug('router', 'Rerouting failed, returning a 404.', e); return new Response('Not found', { status: 404, statusText: 'Not found', diff --git a/packages/astro/src/vite-plugin-astro-server/pipeline.ts b/packages/astro/src/vite-plugin-astro-server/pipeline.ts index 4217ebd139e9..7667bb377b12 100644 --- a/packages/astro/src/vite-plugin-astro-server/pipeline.ts +++ b/packages/astro/src/vite-plugin-astro-server/pipeline.ts @@ -203,7 +203,11 @@ export class DevPipeline extends Pipeline { break; } } else if (payload instanceof Request) { - // TODO: handle request, if needed + const url = new URL(payload.url); + if (route.pattern.test(url.pathname)) { + foundRoute = route; + break; + } } else { if (route.pattern.test(decodeURI(payload))) { foundRoute = route; diff --git a/packages/astro/src/vite-plugin-astro-server/route.ts b/packages/astro/src/vite-plugin-astro-server/route.ts index e62bfe34ed37..85bf969f9db9 100644 --- a/packages/astro/src/vite-plugin-astro-server/route.ts +++ b/packages/astro/src/vite-plugin-astro-server/route.ts @@ -222,6 +222,7 @@ export async function handleRoute({ fallbackRoutes: [], isIndex: false, }; + renderContext = RenderContext.create({ pipeline: pipeline, pathname, diff --git a/packages/astro/test/fixtures/reroute/src/pages/dynamic/[id].astro b/packages/astro/test/fixtures/reroute/src/pages/dynamic/[id].astro new file mode 100644 index 000000000000..fc1b64fbba1a --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/dynamic/[id].astro @@ -0,0 +1,21 @@ +--- + +export function getStaticPaths() { + return [ + { params: { id: 'hello' } }, + ]; +} + + +return Astro.reroute("/") + +--- + + + + Dynamic [id].astro + + +

/dynamic/[id].astro

+ + diff --git a/packages/astro/test/fixtures/reroute/src/pages/spread/[...id].astro b/packages/astro/test/fixtures/reroute/src/pages/spread/[...id].astro new file mode 100644 index 000000000000..1169380cab62 --- /dev/null +++ b/packages/astro/test/fixtures/reroute/src/pages/spread/[...id].astro @@ -0,0 +1,20 @@ +--- +export function getStaticPaths() { + return [ + { params: { id: 'hello' } }, + ]; +} + +return Astro.reroute("/") + +--- + + + + + Spread [...id].astro + + +

/spread/[...id].astro

+ + diff --git a/packages/astro/test/i18n-routing-manual.test.js b/packages/astro/test/i18n-routing-manual.test.js index d664b3797889..1feaf963348c 100644 --- a/packages/astro/test/i18n-routing-manual.test.js +++ b/packages/astro/test/i18n-routing-manual.test.js @@ -58,8 +58,6 @@ describe('Dev server manual routing', () => { describe('SSG manual routing', () => { /** @type {import('./test-utils').Fixture} */ let fixture; - /** @type {import('./test-utils').DevServer} */ - let devServer; before(async () => { fixture = await loadFixture({ diff --git a/packages/astro/test/reroute.test.js b/packages/astro/test/reroute.test.js index 8740d318fdc8..e90f4e6723ce 100644 --- a/packages/astro/test/reroute.test.js +++ b/packages/astro/test/reroute.test.js @@ -34,7 +34,69 @@ describe('Dev reroute', () => { }); it('the render the index page when navigating /blog/salut ', async () => { - const html = await fixture.fetch('/blog/hello').then((res) => res.text()); + const html = await fixture.fetch('/blog/salut').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('the render the index page when navigating dynamic route /dynamic/[id] ', async () => { + const html = await fixture.fetch('/dynamic/hello').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('the render the index page when navigating spread route /spread/[...spread] ', async () => { + const html = await fixture.fetch('/spread/hello').then((res) => res.text()); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); +}); + +describe('Build reroute', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/reroute/', + }); + await fixture.build(); + }); + + it('the render the index page when navigating /reroute ', async () => { + const html = await fixture.readFile('/reroute/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('the render the index page when navigating /blog/hello ', async () => { + const html = await fixture.readFile('/blog/hello/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('the render the index page when navigating /blog/salut ', async () => { + const html = await fixture.readFile('/blog/salut/index.html'); + + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('the render the index page when navigating dynamic route /dynamic/[id] ', async () => { + const html = await fixture.readFile('/dynamic/hello/index.html'); + const $ = cheerioLoad(html); + + assert.equal($('h1').text(), 'Index'); + }); + + it('the render the index page when navigating spread route /spread/[...spread] ', async () => { + const html = await fixture.readFile('/spread/hello/index.html'); const $ = cheerioLoad(html); assert.equal($('h1').text(), 'Index');