0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

feat: reroute in SSG (#10843)

* feat: rerouting in ssg

* linting
This commit is contained in:
Emanuele Stoppa 2024-04-22 14:54:12 +01:00
parent 251ab8bb12
commit 56f2274907
9 changed files with 255 additions and 84 deletions

View file

@ -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<SinglePageBuiltModule> {
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<SinglePageBuiltModule> {
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();

View file

@ -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<RouteData, SinglePageBuiltModule> = new WeakMap<
RouteData,
SinglePageBuiltModule
>();
/**
* This cache is needed to map a single `RouteData` to its file path.
* @private
*/
#routesByFilePath: WeakMap<RouteData, string> = new WeakMap<RouteData, string>();
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,
@ -233,14 +252,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<ComponentInstance> {
throw new Error('unimplemented');
async getComponentByRoute(routeData: RouteData): Promise<ComponentInstance> {
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<SinglePageBuiltModule> {
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<SinglePageBuiltModule> {
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<SinglePageBuiltModule> {
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);
}

View file

@ -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',
@ -381,6 +383,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) {
@ -397,6 +400,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',

View file

@ -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;

View file

@ -222,6 +222,7 @@ export async function handleRoute({
fallbackRoutes: [],
isIndex: false,
};
renderContext = RenderContext.create({
pipeline: pipeline,
pathname,

View file

@ -0,0 +1,21 @@
---
export function getStaticPaths() {
return [
{ params: { id: 'hello' } },
];
}
return Astro.reroute("/")
---
<html>
<head>
<title>Dynamic [id].astro</title>
</head>
<body>
<h1>/dynamic/[id].astro</h1>
</body>
</html>

View file

@ -0,0 +1,20 @@
---
export function getStaticPaths() {
return [
{ params: { id: 'hello' } },
];
}
return Astro.reroute("/")
---
<html>
<head>
<title>Spread [...id].astro</title>
</head>
<body>
<h1>/spread/[...id].astro</h1>
</body>
</html>

View file

@ -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({

View file

@ -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');