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

feat(next): astro:routes:resolved (#12329)

Co-authored-by: Luiz Ferraz <luiz@lferraz.com>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Florian Lefebvre 2024-11-21 12:30:49 +01:00 committed by GitHub
parent 3f02d5f12b
commit 8309c61f0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 434 additions and 35 deletions

View file

@ -0,0 +1,50 @@
---
'astro': minor
---
Adds a new `astro:routes:resolved` hook to the Integration API. Also update the `astro:build:done` hook by deprecating `routes` and adding a new `assets` map.
When building an integration, you can now get access to routes inside the `astro:routes:resolved` hook:
```js
const integration = () => {
return {
name: 'my-integration',
hooks: {
'astro:routes:resolved': ({ routes }) => {
console.log(routes)
}
}
}
}
```
This hook runs before `astro:config:done`, and whenever a route changes in development.
The `routes` array from `astro:build:done` is now deprecated, and exposed properties are now available on `astro:routes:resolved`, except for `distURL`. For this, you can use the newly exposed `assets` map:
```diff
const integration = () => {
+ let routes
return {
name: 'my-integration',
hooks: {
+ 'astro:routes:resolved': (params) => {
+ routes = params.routes
+ },
'astro:build:done': ({
- routes
+ assets
}) => {
+ for (const route of routes) {
+ const distURL = assets.get(route.pattern)
+ if (distURL) {
+ Object.assign(route, { distURL })
+ }
+ }
console.log(routes)
}
}
}
}
```

View file

@ -18,10 +18,11 @@ export default function astroIntegrationActionsRouteHandler({
name: VIRTUAL_MODULE_ID,
hooks: {
async 'astro:config:setup'(params) {
params.injectRoute({
settings.injectedRoutes.push({
pattern: ACTION_RPC_ROUTE_PATTERN,
entrypoint: 'astro/actions/runtime/route.js',
prerender: false,
origin: 'internal',
});
params.addMiddleware({

View file

@ -63,5 +63,6 @@ function getImageEndpointData(
pathname: settings.config.image.endpoint.route,
prerender: false,
fallbackRoutes: [],
origin: 'internal',
};
}

View file

@ -544,6 +544,7 @@ export class experimental_AstroContainer {
type,
fallbackRoutes: [],
isIndex: false,
origin: 'internal',
};
}

View file

@ -7,6 +7,7 @@ import * as vite from 'vite';
import {
runHookConfigDone,
runHookConfigSetup,
runHookRoutesResolved,
runHookServerDone,
runHookServerStart,
} from '../../integrations/hooks.js';
@ -83,10 +84,11 @@ export async function createContainer({
.filter(Boolean) as string[];
// Create the route manifest already outside of Vite so that `runHookConfigDone` can use it to inform integrations of the build output
let manifest = await createRouteManifest({ settings, fsMod: fs }, logger);
let manifest = await createRouteManifest({ settings, fsMod: fs }, logger, { dev: true });
const devSSRManifest = createDevelopmentManifest(settings);
manifest = injectDefaultDevRoutes(settings, devSSRManifest, manifest);
await runHookRoutesResolved({ settings, logger, routes: manifest.routes });
await runHookConfigDone({ settings, logger, command: 'dev' });

View file

@ -15,6 +15,7 @@ export const DEFAULT_404_ROUTE: RouteData = {
route: '/404',
fallbackRoutes: [],
isIndex: false,
origin: 'internal',
};
export const DEFAULT_500_ROUTE: RouteData = {
@ -29,6 +30,7 @@ export const DEFAULT_500_ROUTE: RouteData = {
route: '/500',
fallbackRoutes: [],
isIndex: false,
origin: 'internal',
};
export function ensure404Route(manifest: ManifestData) {

View file

@ -20,6 +20,7 @@ import { routeComparator } from '../priority.js';
import { getRouteGenerator } from './generator.js';
import { getPattern } from './pattern.js';
import { getRoutePrerenderOption } from './prerender.js';
import { runHookRoutesResolved } from '../../../integrations/hooks.js';
const require = createRequire(import.meta.url);
interface Item {
@ -255,6 +256,7 @@ function createFileBasedRoutes(
prerender,
fallbackRoutes: [],
distURL: [],
origin: 'project',
});
}
}
@ -280,7 +282,7 @@ function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): Rou
const routes: RouteData[] = [];
for (const injectedRoute of settings.injectedRoutes) {
const { pattern: name, entrypoint, prerender: prerenderInjected } = injectedRoute;
const { pattern: name, entrypoint, prerender: prerenderInjected, origin } = injectedRoute;
const { resolved, component } = resolveInjectedRoute(entrypoint.toString(), config.root, cwd);
const segments = removeLeadingForwardSlash(name)
@ -320,6 +322,7 @@ function createInjectedRoutes({ settings, cwd }: CreateRouteManifestParams): Rou
prerender: prerenderInjected ?? prerender,
fallbackRoutes: [],
distURL: [],
origin,
});
}
@ -389,6 +392,7 @@ function createRedirectRoutes(
redirectRoute: routeMap.get(destination),
fallbackRoutes: [],
distURL: [],
origin: 'project',
});
}
@ -480,6 +484,7 @@ function detectRouteCollision(a: RouteData, b: RouteData, _config: AstroConfig,
export async function createRouteManifest(
params: CreateRouteManifestParams,
logger: Logger,
{ dev = false }: { dev?: boolean } = {},
): Promise<ManifestData> {
const { settings } = params;
const { config } = settings;
@ -727,6 +732,10 @@ export async function createRouteManifest(
}
}
if (!dev) {
await runHookRoutesResolved({ routes, settings, logger });
}
return {
routes,
};

View file

@ -41,5 +41,6 @@ export function deserializeRouteData(rawRouteData: SerializedRouteData): RouteDa
return deserializeRouteData(fallback);
}),
isIndex: rawRouteData.isIndex,
origin: rawRouteData.origin,
};
}

View file

@ -31,6 +31,7 @@ export function getServerIslandRouteData(config: ConfigFields) {
isIndex: false,
fallbackRoutes: [],
route: SERVER_ISLAND_ROUTE,
origin: 'internal',
};
return route;
}

View file

@ -24,7 +24,9 @@ import type {
import type {
AstroIntegration,
AstroRenderer,
BaseIntegrationHooks,
HookParameters,
IntegrationResolvedRoute,
IntegrationRouteData,
RouteOptions,
} from '../types/public/integrations.js';
@ -39,7 +41,7 @@ async function withTakingALongTimeMsg<T>({
logger,
}: {
name: string;
hookName: string;
hookName: keyof BaseIntegrationHooks;
hookResult: T | Promise<T>;
timeoutMs?: number;
logger: Logger;
@ -204,7 +206,7 @@ export async function runHookConfigSetup({
);
injectRoute.entrypoint = injectRoute.entryPoint as string;
}
updatedSettings.injectedRoutes.push(injectRoute);
updatedSettings.injectedRoutes.push({ ...injectRoute, origin: 'external' });
},
addWatchFile: (path) => {
updatedSettings.watchFiles.push(path instanceof URL ? fileURLToPath(path) : path);
@ -599,6 +601,9 @@ export async function runHookBuildDone({ settings, pages, routes, logging }: Run
pages: pages.map((p) => ({ pathname: p })),
dir,
routes: integrationRoutes,
assets: new Map(
routes.filter((r) => r.distURL !== undefined).map((r) => [r.route, r.distURL!]),
),
logger,
}),
logger: logging,
@ -648,6 +653,47 @@ export async function runHookRouteSetup({
}
}
export async function runHookRoutesResolved({
routes,
settings,
logger,
}: { routes: Array<RouteData>; settings: AstroSettings; logger: Logger }) {
for (const integration of settings.config.integrations) {
if (integration?.hooks?.['astro:routes:resolved']) {
const integrationLogger = getLogger(integration, logger);
await withTakingALongTimeMsg({
name: integration.name,
hookName: 'astro:routes:resolved',
hookResult: integration.hooks['astro:routes:resolved']({
routes: routes.map((route) => toIntegrationResolvedRoute(route)),
logger: integrationLogger,
}),
logger,
});
}
}
}
function toIntegrationResolvedRoute(route: RouteData): IntegrationResolvedRoute {
return {
isPrerendered: route.prerender,
entrypoint: route.component,
pattern: route.route,
params: route.params,
origin: route.origin,
generate: route.generate,
patternRegex: route.pattern,
segments: route.segments,
type: route.type,
pathname: route.pathname,
redirect: route.redirect,
redirectRoute: route.redirectRoute
? toIntegrationResolvedRoute(route.redirectRoute)
: undefined,
};
}
function toIntegrationRouteData(route: RouteData): IntegrationRouteData {
return {
route: route.route,

View file

@ -10,12 +10,10 @@ import type { ContentEntryType, DataEntryType } from './public/content.js';
import type {
AstroAdapter,
AstroRenderer,
InjectedRoute,
InjectedScriptStage,
InjectedType,
ResolvedInjectedRoute,
} from './public/integrations.js';
import type { RouteData } from './public/internal.js';
import type { InternalInjectedRoute, RouteData, ResolvedInjectedRoute } from './public/internal.js';
import type { DevToolbarAppEntry } from './public/toolbar.js';
export type SerializedRouteData = Omit<
@ -35,7 +33,7 @@ export interface AstroSettings {
config: AstroConfig;
adapter: AstroAdapter | undefined;
preferences: AstroPreferences;
injectedRoutes: InjectedRoute[];
injectedRoutes: InternalInjectedRoute[];
resolvedInjectedRoutes: ResolvedInjectedRoute[];
pageExtensions: string[];
contentEntryTypes: ContentEntryType[];

View file

@ -8,7 +8,7 @@ import type { getToolbarServerCommunicationHelpers } from '../../integrations/ho
import type { DeepPartial } from '../../type-utils.js';
import type { AstroConfig } from './config.js';
import type { RefreshContentOptions } from './content.js';
import type { RouteData } from './internal.js';
import type { InternalInjectedRoute, RouteData } from './internal.js';
import type { DevToolbarAppEntry } from './toolbar.js';
export interface RouteOptions {
@ -138,15 +138,7 @@ export type AstroAdapterFeatureMap = {
*/
export type InjectedScriptStage = 'before-hydration' | 'head-inline' | 'page' | 'page-ssr';
export interface InjectedRoute {
pattern: string;
entrypoint: string | URL;
prerender?: boolean;
}
export interface ResolvedInjectedRoute extends InjectedRoute {
resolvedEntryPoint?: URL;
}
export type InjectedRoute = Omit<InternalInjectedRoute, 'origin'>;
export interface InjectedType {
filename: string;
@ -225,13 +217,19 @@ export interface BaseIntegrationHooks {
'astro:build:done': (options: {
pages: { pathname: string }[];
dir: URL;
/** @deprecated Use the `assets` map and the new `astro:routes:resolved` hook */
routes: IntegrationRouteData[];
assets: Map<string, URL[]>;
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
'astro:route:setup': (options: {
route: RouteOptions;
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
'astro:routes:resolved': (options: {
routes: IntegrationResolvedRoute[];
logger: AstroIntegrationLogger;
}) => void | Promise<void>;
}
export interface AstroIntegration {
@ -245,13 +243,45 @@ export interface AstroIntegration {
/**
* A smaller version of the {@link RouteData} that is used in the integrations.
* @deprecated Use {@link IntegrationResolvedRoute}
*/
export type IntegrationRouteData = Omit<
RouteData,
'isIndex' | 'fallbackRoutes' | 'redirectRoute'
'isIndex' | 'fallbackRoutes' | 'redirectRoute' | 'origin'
> & {
/**
* {@link RouteData.redirectRoute}
*/
redirectRoute?: IntegrationRouteData;
};
export interface IntegrationResolvedRoute
extends Pick<
RouteData,
'generate' | 'params' | 'pathname' | 'segments' | 'type' | 'redirect' | 'origin'
> {
/**
* {@link RouteData.route}
*/
pattern: RouteData['route'];
/**
* {@link RouteData.pattern}
*/
patternRegex: RouteData['pattern'];
/**
* {@link RouteData.component}
*/
entrypoint: RouteData['component'];
/**
* {@link RouteData.prerender}
*/
isPrerendered: RouteData['prerender'];
/**
* {@link RouteData.redirectRoute}
*/
redirectRoute?: IntegrationResolvedRoute;
}

View file

@ -136,6 +136,11 @@ export interface RouteData {
* - src/pages/blog/index.astro
*/
isIndex: boolean;
/**
* Whether the route comes from Astro core, an integration or the user's project
*/
origin: 'internal' | 'external' | 'project';
}
/**
@ -284,3 +289,16 @@ export interface SSRMetadata {
}
export type SSRError = Error & ViteErrorPayload['err'];
// `origin` is set within the hook, but the user doesn't have access to this property. That's why
// we need an intermediary interface
export interface InternalInjectedRoute {
pattern: string;
entrypoint: string | URL;
prerender?: boolean;
origin: RouteData['origin'];
}
export interface ResolvedInjectedRoute extends InternalInjectedRoute {
resolvedEntryPoint?: URL;
}

View file

@ -2,6 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
import type fs from 'node:fs';
import { IncomingMessage } from 'node:http';
import type * as vite from 'vite';
import { normalizePath } from 'vite';
import type { SSRManifest, SSRManifestI18n } from '../core/app/types.js';
import { warnMissingAdapter } from '../core/dev/adapter-validation.js';
import { createKey } from '../core/encryption.js';
@ -21,6 +22,9 @@ import { recordServerError } from './error.js';
import { DevPipeline } from './pipeline.js';
import { handleRequest } from './request.js';
import { setRouteError } from './server-state.js';
import { fileURLToPath } from 'node:url';
import { getRoutePrerenderOption } from '../core/routing/manifest/prerender.js';
import { runHookRoutesResolved } from '../integrations/hooks.js';
export interface AstroPluginOptions {
settings: AstroSettings;
@ -50,26 +54,46 @@ export default function createVitePluginAstroServer({
const controller = createController({ loader });
const localStorage = new AsyncLocalStorage();
/** rebuild the route cache + manifest, as needed. */
async function rebuildManifest(needsManifestRebuild: boolean) {
/** rebuild the route cache + manifest */
async function rebuildManifest(path: string | null = null) {
pipeline.clearRouteCache();
if (needsManifestRebuild) {
// If a route changes, we check if it's part of the manifest and check for its prerender value
if (path !== null) {
const route = routeManifest.routes.find(
(r) =>
normalizePath(path) ===
normalizePath(fileURLToPath(new URL(r.component, settings.config.root))),
);
if (!route) {
return;
}
if (route.type !== 'page' && route.type !== 'endpoint') return;
const routePath = fileURLToPath(new URL(route.component, settings.config.root));
try {
const content = await fsMod.promises.readFile(routePath, 'utf-8');
await getRoutePrerenderOption(content, route, settings, logger);
} catch (_) {}
} else {
routeManifest = injectDefaultDevRoutes(
settings,
devSSRManifest,
await createRouteManifest({ settings, fsMod }, logger), // TODO: Handle partial updates to the manifest
await createRouteManifest({ settings, fsMod }, logger, { dev: true }),
);
warnMissingAdapter(logger, settings);
pipeline.manifest.checkOrigin =
settings.config.security.checkOrigin && settings.buildOutput === 'server';
pipeline.setManifestData(routeManifest);
}
await runHookRoutesResolved({ routes: routeManifest.routes, settings, logger });
warnMissingAdapter(logger, settings);
pipeline.manifest.checkOrigin =
settings.config.security.checkOrigin && settings.buildOutput === 'server';
pipeline.setManifestData(routeManifest);
}
// Rebuild route manifest on file change, if needed.
viteServer.watcher.on('add', rebuildManifest.bind(null, true));
viteServer.watcher.on('unlink', rebuildManifest.bind(null, true));
viteServer.watcher.on('change', rebuildManifest.bind(null, false));
// Rebuild route manifest on file change
viteServer.watcher.on('add', rebuildManifest.bind(null, null));
viteServer.watcher.on('unlink', rebuildManifest.bind(null, null));
viteServer.watcher.on('change', rebuildManifest);
function handleUnhandledRejection(rejection: any) {
const error = new AstroError({

View file

@ -5,7 +5,7 @@ import type { AstroSettings } from '../types/astro.js';
import { normalizePath } from 'vite';
import { runHookServerSetup } from '../integrations/hooks.js';
import type { InjectedRoute, ResolvedInjectedRoute } from '../types/public/integrations.js';
import type { InternalInjectedRoute, ResolvedInjectedRoute } from '../types/public/internal.js';
/** Connect Astro integrations into Vite, as needed. */
export default function astroIntegrationsContainerPlugin({
@ -33,7 +33,7 @@ export default function astroIntegrationsContainerPlugin({
async function resolveEntryPoint(
this: PluginContext,
route: InjectedRoute,
route: InternalInjectedRoute,
): Promise<ResolvedInjectedRoute> {
const resolvedId = await this.resolve(route.entrypoint.toString())
.then((res) => res?.id)

View file

@ -7,7 +7,7 @@ import {
runHookBuildSetup,
runHookConfigSetup,
} from '../../../dist/integrations/hooks.js';
import { defaultLogger } from '../test-utils.js';
import { createFixture, defaultLogger, runInContainer } from '../test-utils.js';
const defaultConfig = {
root: new URL('./', import.meta.url),
@ -131,6 +131,221 @@ describe('Integration API', () => {
assert.equal(updatedSettings.config.site, site);
assert.equal(updatedSettings.config.integrations.length, 2);
});
describe('Routes resolved hooks', () => {
it('should work in dev', async () => {
let routes = [];
const fixture = await createFixture({
'/src/pages/about.astro': '',
'/src/actions.ts': 'export const server = {}',
'/src/foo.astro': '',
});
await runInContainer(
{
inlineConfig: {
root: fixture.path,
integrations: [
{
name: 'test',
hooks: {
'astro:config:setup': (params) => {
params.injectRoute({
entrypoint: './src/foo.astro',
pattern: '/foo',
});
},
'astro:routes:resolved': (params) => {
routes = params.routes.map((r) => ({
isPrerendered: r.isPrerendered,
entrypoint: r.entrypoint,
pattern: r.pattern,
params: r.params,
origin: r.origin,
}));
routes.sort((a, b) => a.pattern.localeCompare(b.pattern));
},
},
},
],
},
},
async (container) => {
assert.deepEqual(
routes,
[
{
isPrerendered: false,
entrypoint: '_server-islands.astro',
pattern: '/_server-islands/[name]',
params: ['name'],
origin: 'internal',
},
{
isPrerendered: false,
entrypoint: '../../../../dist/actions/runtime/route.js',
pattern: '/_actions/[...path]',
params: ['...path'],
origin: 'internal',
},
{
isPrerendered: true,
entrypoint: 'src/pages/about.astro',
pattern: '/about',
params: [],
origin: 'project',
},
{
isPrerendered: true,
entrypoint: 'src/foo.astro',
pattern: '/foo',
params: [],
origin: 'external',
},
{
isPrerendered: false,
entrypoint: '../../../../dist/assets/endpoint/node.js',
pattern: '/_image',
params: [],
origin: 'internal',
},
{
isPrerendered: false,
entrypoint: 'astro-default-404.astro',
pattern: '/404',
params: [],
origin: 'internal',
},
].sort((a, b) => a.pattern.localeCompare(b.pattern)),
);
await fixture.writeFile('/src/pages/bar.astro', '');
container.viteServer.watcher.emit(
'add',
fixture.getPath('/src/pages/bar.astro').replace(/\\/g, '/'),
);
await new Promise((r) => setTimeout(r, 100));
assert.deepEqual(
routes,
[
{
isPrerendered: false,
entrypoint: '_server-islands.astro',
pattern: '/_server-islands/[name]',
params: ['name'],
origin: 'internal',
},
{
isPrerendered: false,
entrypoint: '../../../../dist/actions/runtime/route.js',
pattern: '/_actions/[...path]',
params: ['...path'],
origin: 'internal',
},
{
isPrerendered: true,
entrypoint: 'src/pages/about.astro',
pattern: '/about',
params: [],
origin: 'project',
},
{
isPrerendered: true,
entrypoint: 'src/pages/bar.astro',
pattern: '/bar',
params: [],
origin: 'project',
},
{
isPrerendered: true,
entrypoint: 'src/foo.astro',
pattern: '/foo',
params: [],
origin: 'external',
},
{
isPrerendered: false,
entrypoint: '../../../../dist/assets/endpoint/node.js',
pattern: '/_image',
params: [],
origin: 'internal',
},
{
isPrerendered: false,
entrypoint: 'astro-default-404.astro',
pattern: '/404',
params: [],
origin: 'internal',
},
].sort((a, b) => a.pattern.localeCompare(b.pattern)),
);
await fixture.writeFile('/src/pages/about.astro', '---\nexport const prerender=false\n');
container.viteServer.watcher.emit(
'change',
fixture.getPath('/src/pages/about.astro').replace(/\\/g, '/'),
);
await new Promise((r) => setTimeout(r, 100));
assert.deepEqual(
routes,
[
{
isPrerendered: false,
entrypoint: '_server-islands.astro',
pattern: '/_server-islands/[name]',
params: ['name'],
origin: 'internal',
},
{
isPrerendered: false,
entrypoint: '../../../../dist/actions/runtime/route.js',
pattern: '/_actions/[...path]',
params: ['...path'],
origin: 'internal',
},
{
isPrerendered: false,
entrypoint: 'src/pages/about.astro',
pattern: '/about',
params: [],
origin: 'project',
},
{
isPrerendered: true,
entrypoint: 'src/pages/bar.astro',
pattern: '/bar',
params: [],
origin: 'project',
},
{
isPrerendered: true,
entrypoint: 'src/foo.astro',
pattern: '/foo',
params: [],
origin: 'external',
},
{
isPrerendered: false,
entrypoint: '../../../../dist/assets/endpoint/node.js',
pattern: '/_image',
params: [],
origin: 'internal',
},
{
isPrerendered: false,
entrypoint: 'astro-default-404.astro',
pattern: '/404',
params: [],
origin: 'internal',
},
].sort((a, b) => a.pattern.localeCompare(b.pattern)),
);
},
);
});
});
});
describe('Astro feature map', function () {