diff --git a/.changeset/wet-readers-join.md b/.changeset/wet-readers-join.md new file mode 100644 index 000000000000..abf77e6c97ca --- /dev/null +++ b/.changeset/wet-readers-join.md @@ -0,0 +1,31 @@ +--- +'astro': minor +--- + +Shipped a new SSR build configuration mode: `split`. +When enabled, Astro will "split" the single `entry.mjs` file and instead emit a separate file to render each individual page during the build process. + +These files will be emitted inside `dist/pages`, mirroring the directory structure of your page files in `src/pages/`, for example: + +``` +├── pages +│ ├── blog +│ │ ├── entry._slug_.astro.mjs +│ │ └── entry.about.astro.mjs +│ └── entry.index.astro.mjs +``` + +To enable, set `build.split: true` in your Astro config: + +```js +// src/astro.config.mjs +export default defineConfig({ + output: "server", + adapter: node({ + mode: "standalone" + }), + build: { + split: true + } +}) +``` \ No newline at end of file diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index fea28d092a87..1a5679f03e9c 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -838,6 +838,30 @@ export interface AstroUserConfig { * ``` */ inlineStylesheets?: 'always' | 'auto' | 'never'; + + /** + * @docs + * @name build.split + * @type {boolean} + * @default {false} + * @version 2.7.0 + * @description + * Defines how the SSR code should be bundled when built. + * + * When `split` is `true`, Astro will emit a file for each page. + * Each file emitted will render only one page. The pages will be emitted + * inside a `dist/pages/` directory, and the emitted files will keep the same file paths + * of the `src/pages` directory. + * + * ```js + * { + * build: { + * split: true + * } + * } + * ``` + */ + split?: boolean; }; /** @@ -1824,7 +1848,14 @@ export interface AstroIntegration { 'astro:server:setup'?: (options: { server: vite.ViteDevServer }) => void | Promise; 'astro:server:start'?: (options: { address: AddressInfo }) => void | Promise; 'astro:server:done'?: () => void | Promise; - 'astro:build:ssr'?: (options: { manifest: SerializedSSRManifest }) => void | Promise; + 'astro:build:ssr'?: (options: { + manifest: SerializedSSRManifest; + /** + * This maps a {@link RouteData} to an {@link URL}, this URL represents + * the physical file you should import. + */ + entryPoints: Map; + }) => void | Promise; 'astro:build:start'?: () => void | Promise; 'astro:build:setup'?: (options: { vite: vite.InlineConfig; diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index ae83b301623a..00b93cd09cce 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -4,9 +4,9 @@ import type { MiddlewareResponseHandler, RouteData, SSRElement, + SSRManifest, } from '../../@types/astro'; -import type { RouteInfo, SSRManifest as Manifest } from './types'; - +import type { RouteInfo } from './types'; import mime from 'mime'; import type { SinglePageBuiltModule } from '../build/types'; import { attachToResponse, getSetCookiesFromResponse } from '../cookies/index.js'; @@ -41,7 +41,7 @@ export interface MatchOptions { export class App { #env: Environment; - #manifest: Manifest; + #manifest: SSRManifest; #manifestData: ManifestData; #routeDataToRouteInfo: Map; #encoder = new TextEncoder(); @@ -52,7 +52,7 @@ export class App { #base: string; #baseWithoutTrailingSlash: string; - constructor(manifest: Manifest, streaming = true) { + constructor(manifest: SSRManifest, streaming = true) { this.#manifest = manifest; this.#manifestData = { routes: manifest.routes.map((route) => route.routeData), @@ -175,14 +175,23 @@ export class App { if (route.type === 'redirect') { return RedirectSinglePageBuiltModule; } else { - const importComponentInstance = this.#manifest.pageMap.get(route.component); - if (!importComponentInstance) { + if (this.#manifest.pageMap) { + const importComponentInstance = this.#manifest.pageMap.get(route.component); + if (!importComponentInstance) { + throw new Error( + `Unexpectedly unable to find a component instance for route ${route.route}` + ); + } + const pageModule = await importComponentInstance(); + return pageModule; + } else if (this.#manifest.pageModule) { + const importComponentInstance = this.#manifest.pageModule; + return importComponentInstance; + } else { throw new Error( - `Unexpectedly unable to find a component instance for route ${route.route}` + "Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error, please file an issue." ); } - const built = await importComponentInstance(); - return built; } } diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index 1283f1a10f26..9af15bf50252 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -30,16 +30,16 @@ export interface RouteInfo { export type SerializedRouteInfo = Omit & { routeData: SerializedRouteData; }; -type ImportComponentInstance = () => Promise; -export interface SSRManifest { +export type ImportComponentInstance = () => Promise; + +export type SSRManifest = { adapterName: string; routes: RouteInfo[]; site?: string; base?: string; assetsPrefix?: string; markdown: MarkdownRenderingOptions; - pageMap: Map; renderers: SSRLoadedRenderer[]; /** * Map of directive name (e.g. `load`) to the directive script code @@ -48,7 +48,9 @@ export interface SSRManifest { entryModules: Record; assets: Set; componentMetadata: SSRResult['componentMetadata']; -} + pageModule?: SinglePageBuiltModule; + pageMap?: Map; +}; export type SerializedSSRManifest = Omit< SSRManifest, diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index 4cf40cb9ad83..7a13e4d08a73 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -1,14 +1,11 @@ import type { Rollup } from 'vite'; -import type { SSRResult } from '../../@types/astro'; +import type { RouteData, SSRResult } from '../../@types/astro'; import type { PageOptions } from '../../vite-plugin-astro/types'; import { prependForwardSlash, removeFileExtension } from '../path.js'; import { viteID } from '../util.js'; -import { - ASTRO_PAGE_EXTENSION_POST_PATTERN, - ASTRO_PAGE_MODULE_ID, - getVirtualModulePageIdFromPath, -} from './plugins/plugin-pages.js'; +import { ASTRO_PAGE_MODULE_ID, getVirtualModulePageIdFromPath } from './plugins/plugin-pages.js'; import type { PageBuildData, StylesheetAsset, ViteID } from './types'; +import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; export interface BuildInternals { /** @@ -84,6 +81,8 @@ export interface BuildInternals { staticFiles: Set; // The SSR entry chunk. Kept in internals to share between ssr/client build steps ssrEntryChunk?: Rollup.OutputChunk; + entryPoints: Map; + ssrSplitEntryChunks: Map; componentMetadata: SSRResult['componentMetadata']; } @@ -114,6 +113,8 @@ export function createBuildInternals(): BuildInternals { discoveredScripts: new Set(), staticFiles: new Set(), componentMetadata: new Map(), + ssrSplitEntryChunks: new Map(), + entryPoints: new Map(), }; } diff --git a/packages/astro/src/core/build/plugins/index.ts b/packages/astro/src/core/build/plugins/index.ts index f6fcacfb1b7d..4563bb6964d7 100644 --- a/packages/astro/src/core/build/plugins/index.ts +++ b/packages/astro/src/core/build/plugins/index.ts @@ -11,7 +11,7 @@ import { pluginMiddleware } from './plugin-middleware.js'; import { pluginPages } from './plugin-pages.js'; import { pluginPrerender } from './plugin-prerender.js'; import { pluginRenderers } from './plugin-renderers.js'; -import { pluginSSR } from './plugin-ssr.js'; +import { pluginSSR, pluginSSRSplit } from './plugin-ssr.js'; export function registerAllPlugins({ internals, options, register }: AstroBuildPluginContainer) { register(pluginComponentEntry(internals)); @@ -27,4 +27,5 @@ export function registerAllPlugins({ internals, options, register }: AstroBuildP register(astroConfigBuildPlugin(options, internals)); register(pluginHoistedScripts(options, internals)); register(pluginSSR(options, internals)); + register(pluginSSRSplit(options, internals)); } diff --git a/packages/astro/src/core/build/plugins/plugin-pages.ts b/packages/astro/src/core/build/plugins/plugin-pages.ts index c6f89a558bee..ca224d851d25 100644 --- a/packages/astro/src/core/build/plugins/plugin-pages.ts +++ b/packages/astro/src/core/build/plugins/plugin-pages.ts @@ -1,4 +1,4 @@ -import { extname } from 'node:path'; +import { getPathFromVirtualModulePageName, ASTRO_PAGE_EXTENSION_POST_PATTERN } from './util.js'; import type { Plugin as VitePlugin } from 'vite'; import { routeIsRedirect } from '../../redirects/index.js'; import { addRollupInput } from '../add-rollup-input.js'; @@ -7,12 +7,10 @@ import type { AstroBuildPlugin } from '../plugin'; import type { StaticBuildOptions } from '../types'; import { MIDDLEWARE_MODULE_ID } from './plugin-middleware.js'; import { RENDERERS_MODULE_ID } from './plugin-renderers.js'; +import { extname } from 'node:path'; export const ASTRO_PAGE_MODULE_ID = '@astro-page:'; -export const ASTRO_PAGE_RESOLVED_MODULE_ID = '\0@astro-page:'; - -// This is an arbitrary string that we are going to replace the dot of the extension -export const ASTRO_PAGE_EXTENSION_POST_PATTERN = '@_@'; +export const ASTRO_PAGE_RESOLVED_MODULE_ID = '\0' + ASTRO_PAGE_MODULE_ID; /** * 1. We add a fixed prefix, which is used as virtual module naming convention; @@ -64,13 +62,8 @@ function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): V if (id.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) { const imports: string[] = []; const exports: string[] = []; - - // we remove the module name prefix from id, this will result into a string that will start with "src/..." - const pageName = id.slice(ASTRO_PAGE_RESOLVED_MODULE_ID.length); - // We replaced the `.` of the extension with ASTRO_PAGE_EXTENSION_POST_PATTERN, let's replace it back - const pageData = internals.pagesByComponent.get( - `${pageName.replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, '.')}` - ); + const pageName = getPathFromVirtualModulePageName(ASTRO_PAGE_RESOLVED_MODULE_ID, id); + const pageData = internals.pagesByComponent.get(pageName); if (pageData) { const resolvedPage = await this.resolve(pageData.moduleSpecifier); if (resolvedPage) { diff --git a/packages/astro/src/core/build/plugins/plugin-ssr.ts b/packages/astro/src/core/build/plugins/plugin-ssr.ts index 3c2825e4c63c..f9ba9190922f 100644 --- a/packages/astro/src/core/build/plugins/plugin-ssr.ts +++ b/packages/astro/src/core/build/plugins/plugin-ssr.ts @@ -1,7 +1,7 @@ import glob from 'fast-glob'; -import { fileURLToPath } from 'url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import type { Plugin as VitePlugin } from 'vite'; -import type { AstroAdapter } from '../../../@types/astro'; +import type { AstroAdapter, AstroConfig } from '../../../@types/astro'; import { runHookBuildSsr } from '../../../integrations/index.js'; import { isServerLikeOutput } from '../../../prerender/utils.js'; import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; @@ -13,9 +13,11 @@ import { addRollupInput } from '../add-rollup-input.js'; import { getOutFile, getOutFolder } from '../common.js'; import { cssOrder, mergeInlineCss, type BuildInternals } from '../internal.js'; import type { AstroBuildPlugin } from '../plugin'; -import type { StaticBuildOptions } from '../types'; -import { getVirtualModulePageNameFromPath } from './plugin-pages.js'; +import type { OutputChunk, StaticBuildOptions } from '../types'; +import { getPathFromVirtualModulePageName, getVirtualModulePageNameFromPath } from './util.js'; import { RENDERERS_MODULE_ID } from './plugin-renderers.js'; +import { ASTRO_PAGE_MODULE_ID } from './plugin-pages.js'; +import { join } from 'node:path'; export const SSR_VIRTUAL_MODULE_ID = '@astrojs-ssr-virtual-entry'; const RESOLVED_SSR_VIRTUAL_MODULE_ID = '\0' + SSR_VIRTUAL_MODULE_ID; @@ -28,7 +30,7 @@ function vitePluginSSR( options: StaticBuildOptions ): VitePlugin { return { - name: '@astrojs/vite-plugin-astro-ssr', + name: '@astrojs/vite-plugin-astro-ssr-server', enforce: 'post', options(opts) { return addRollupInput(opts, [SSR_VIRTUAL_MODULE_ID]); @@ -54,7 +56,7 @@ function vitePluginSSR( if (routeIsRedirect(pageData.route)) { continue; } - const virtualModuleName = getVirtualModulePageNameFromPath(path); + const virtualModuleName = getVirtualModulePageNameFromPath(ASTRO_PAGE_MODULE_ID, path); let module = await this.resolve(virtualModuleName); if (module) { const variable = `_page${i}`; @@ -71,12 +73,226 @@ function vitePluginSSR( contents.push(`const pageMap = new Map([${pageMap.join(',')}]);`); exports.push(`export { pageMap }`); - const content = `import * as adapter from '${adapter.serverEntrypoint}'; + const ssrCode = generateSSRCode(options.settings.config, adapter); + imports.push(...ssrCode.imports); + contents.push(...ssrCode.contents); + return `${imports.join('\n')}${contents.join('\n')}${exports.join('\n')}`; + } + return void 0; + }, + async generateBundle(_opts, bundle) { + // Add assets from this SSR chunk as well. + for (const [_chunkName, chunk] of Object.entries(bundle)) { + if (chunk.type === 'asset') { + internals.staticFiles.add(chunk.fileName); + } + } + + for (const [chunkName, chunk] of Object.entries(bundle)) { + if (chunk.type === 'asset') { + continue; + } + if (chunk.modules[RESOLVED_SSR_VIRTUAL_MODULE_ID]) { + internals.ssrEntryChunk = chunk; + delete bundle[chunkName]; + } + } + }, + }; +} + +export function pluginSSR( + options: StaticBuildOptions, + internals: BuildInternals +): AstroBuildPlugin { + const ssr = isServerLikeOutput(options.settings.config); + return { + build: 'ssr', + hooks: { + 'build:before': () => { + let vitePlugin = + ssr && !options.settings.config.build.split + ? vitePluginSSR(internals, options.settings.adapter!, options) + : undefined; + + return { + enforce: 'after-user-plugins', + vitePlugin, + }; + }, + 'build:post': async ({ mutate }) => { + if (!ssr) { + return; + } + + if (options.settings.config.build.split) { + return; + } + + if (!internals.ssrEntryChunk) { + throw new Error(`Did not generate an entry chunk for SSR`); + } + // Mutate the filename + internals.ssrEntryChunk.fileName = options.settings.config.build.serverEntry; + + const manifest = await createManifest(options, internals); + await runHookBuildSsr({ + config: options.settings.config, + manifest, + logging: options.logging, + entryPoints: internals.entryPoints, + }); + const code = injectManifest(manifest, internals.ssrEntryChunk); + mutate(internals.ssrEntryChunk, 'server', code); + }, + }, + }; +} + +export const SPLIT_MODULE_ID = '@astro-page-split:'; +export const RESOLVED_SPLIT_MODULE_ID = '\0@astro-page-split:'; + +function vitePluginSSRSplit( + internals: BuildInternals, + adapter: AstroAdapter, + options: StaticBuildOptions +): VitePlugin { + return { + name: '@astrojs/vite-plugin-astro-ssr-split', + enforce: 'post', + options(opts) { + if (options.settings.config.build.split) { + const inputs: Set = new Set(); + + for (const path of Object.keys(options.allPages)) { + inputs.add(getVirtualModulePageNameFromPath(SPLIT_MODULE_ID, path)); + } + + return addRollupInput(opts, Array.from(inputs)); + } + }, + resolveId(id) { + if (id.startsWith(SPLIT_MODULE_ID)) { + return '\0' + id; + } + }, + async load(id) { + if (id.startsWith(RESOLVED_SPLIT_MODULE_ID)) { + const { + settings: { config }, + allPages, + } = options; + const imports: string[] = []; + const contents: string[] = []; + const exports: string[] = []; + + const path = getPathFromVirtualModulePageName(RESOLVED_SPLIT_MODULE_ID, id); + const virtualModuleName = getVirtualModulePageNameFromPath(ASTRO_PAGE_MODULE_ID, path); + let module = await this.resolve(virtualModuleName); + if (module) { + // we need to use the non-resolved ID in order to resolve correctly the virtual module + imports.push(`import * as pageModule from "${virtualModuleName}";`); + } + + const ssrCode = generateSSRCode(options.settings.config, adapter); + imports.push(...ssrCode.imports); + contents.push(...ssrCode.contents); + + return `${imports.join('\n')}${contents.join('\n')}${exports.join('\n')}`; + } + return void 0; + }, + async generateBundle(_opts, bundle) { + // Add assets from this SSR chunk as well. + for (const [_chunkName, chunk] of Object.entries(bundle)) { + if (chunk.type === 'asset') { + internals.staticFiles.add(chunk.fileName); + } + } + + for (const [chunkName, chunk] of Object.entries(bundle)) { + if (chunk.type === 'asset') { + continue; + } + let shouldDeleteBundle = false; + for (const moduleKey of Object.keys(chunk.modules)) { + if (moduleKey.startsWith(RESOLVED_SPLIT_MODULE_ID)) { + internals.ssrSplitEntryChunks.set(moduleKey, chunk); + storeEntryPoint(moduleKey, options, internals, chunk.fileName); + shouldDeleteBundle = true; + } + } + if (shouldDeleteBundle) { + delete bundle[chunkName]; + } + } + }, + }; +} + +export function pluginSSRSplit( + options: StaticBuildOptions, + internals: BuildInternals +): AstroBuildPlugin { + const ssr = isServerLikeOutput(options.settings.config); + return { + build: 'ssr', + hooks: { + 'build:before': () => { + let vitePlugin = + ssr && options.settings.config.build.split + ? vitePluginSSRSplit(internals, options.settings.adapter!, options) + : undefined; + + return { + enforce: 'after-user-plugins', + vitePlugin, + }; + }, + 'build:post': async ({ mutate }) => { + if (!ssr) { + return; + } + if (!options.settings.config.build.split) { + return; + } + + if (internals.ssrSplitEntryChunks.size === 0) { + throw new Error(`Did not generate an entry chunk for SSR serverless`); + } + + const manifest = await createManifest(options, internals); + await runHookBuildSsr({ + config: options.settings.config, + manifest, + logging: options.logging, + entryPoints: internals.entryPoints, + }); + for (const [moduleName, chunk] of internals.ssrSplitEntryChunks) { + const code = injectManifest(manifest, chunk); + mutate(chunk, 'server', code); + } + }, + }, + }; +} + +function generateSSRCode(config: AstroConfig, adapter: AstroAdapter) { + const imports: string[] = []; + const contents: string[] = []; + let pageMap; + if (config.build.split) { + pageMap = 'pageModule'; + } else { + pageMap = 'pageMap'; + } + + contents.push(`import * as adapter from '${adapter.serverEntrypoint}'; import { renderers } from '${RENDERERS_MODULE_ID}'; import { deserializeManifest as _deserializeManifest } from 'astro/app'; import { _privateSetManifestDontUseThis } from 'astro:ssr-manifest'; const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), { - pageMap, + ${pageMap}, renderers, }); _privateSetManifestDontUseThis(_manifest); @@ -101,41 +317,45 @@ export { _default as default };`; const _start = 'start'; if(_start in adapter) { adapter[_start](_manifest, _args); -}`; - return `${imports.join('\n')}${contents.join('\n')}${content}${exports.join('\n')}`; - } - return void 0; - }, - async generateBundle(_opts, bundle) { - // Add assets from this SSR chunk as well. - for (const [_chunkName, chunk] of Object.entries(bundle)) { - if (chunk.type === 'asset') { - internals.staticFiles.add(chunk.fileName); - } - } - - for (const [chunkName, chunk] of Object.entries(bundle)) { - if (chunk.type === 'asset') { - continue; - } - if (chunk.modules[RESOLVED_SSR_VIRTUAL_MODULE_ID]) { - internals.ssrEntryChunk = chunk; - delete bundle[chunkName]; - } - } - }, +}`); + return { + imports, + contents, }; } -export async function injectManifest(buildOpts: StaticBuildOptions, internals: BuildInternals) { - if (!internals.ssrEntryChunk) { - throw new Error(`Did not generate an entry chunk for SSR`); +/** + * It injects the manifest in the given output rollup chunk. It returns the new emitted code + * @param buildOpts + * @param internals + * @param chunk + */ +export function injectManifest(manifest: SerializedSSRManifest, chunk: Readonly) { + const code = chunk.code; + + return code.replace(replaceExp, () => { + return JSON.stringify(manifest); + }); +} + +export async function createManifest( + buildOpts: StaticBuildOptions, + internals: BuildInternals +): Promise { + if (buildOpts.settings.config.build.split) { + if (internals.ssrSplitEntryChunks.size === 0) { + throw new Error(`Did not generate an entry chunk for SSR in serverless mode`); + } + } else { + if (!internals.ssrEntryChunk) { + throw new Error(`Did not generate an entry chunk for SSR`); + } } // Add assets from the client build. const clientStatics = new Set( await glob('**/*', { - cwd: fileURLToPath(buildOpts.buildConfig.client), + cwd: fileURLToPath(buildOpts.settings.config.build.client), }) ); for (const file of clientStatics) { @@ -143,19 +363,29 @@ export async function injectManifest(buildOpts: StaticBuildOptions, internals: B } const staticFiles = internals.staticFiles; - const manifest = buildManifest(buildOpts, internals, Array.from(staticFiles)); - await runHookBuildSsr({ - config: buildOpts.settings.config, - manifest, - logging: buildOpts.logging, - }); - - const chunk = internals.ssrEntryChunk; - const code = chunk.code; + return buildManifest(buildOpts, internals, Array.from(staticFiles)); +} - return code.replace(replaceExp, () => { - return JSON.stringify(manifest); - }); +/** + * Because we delete the bundle from rollup at the end of this function, + * we can't use `writeBundle` hook to get the final file name of the entry point written on disk. + * We use this hook instead. + * + * We retrieve the {@link RouteData} that belongs the current moduleKey + */ +function storeEntryPoint( + moduleKey: string, + options: StaticBuildOptions, + internals: BuildInternals, + fileName: string +) { + const componentPath = getPathFromVirtualModulePageName(RESOLVED_SPLIT_MODULE_ID, moduleKey); + for (const [page, pageData] of Object.entries(options.allPages)) { + if (componentPath == page) { + const publicPath = fileURLToPath(options.settings.config.outDir); + internals.entryPoints.set(pageData.route, pathToFileURL(join(publicPath, fileName))); + } + } } function buildManifest( @@ -254,7 +484,6 @@ function buildManifest( base: settings.config.base, assetsPrefix: settings.config.build.assetsPrefix, markdown: settings.config.markdown, - pageMap: null as any, componentMetadata: Array.from(internals.componentMetadata), renderers: [], clientDirectives: Array.from(settings.clientDirectives), @@ -264,39 +493,3 @@ function buildManifest( return ssrManifest; } - -export function pluginSSR( - options: StaticBuildOptions, - internals: BuildInternals -): AstroBuildPlugin { - const ssr = isServerLikeOutput(options.settings.config); - return { - build: 'ssr', - hooks: { - 'build:before': () => { - let vitePlugin = ssr - ? vitePluginSSR(internals, options.settings.adapter!, options) - : undefined; - - return { - enforce: 'after-user-plugins', - vitePlugin, - }; - }, - 'build:post': async ({ mutate }) => { - if (!ssr) { - return; - } - - if (!internals.ssrEntryChunk) { - throw new Error(`Did not generate an entry chunk for SSR`); - } - // Mutate the filename - internals.ssrEntryChunk.fileName = options.settings.config.build.serverEntry; - - const code = await injectManifest(options, internals); - mutate(internals.ssrEntryChunk, 'server', code); - }, - }, - }; -} diff --git a/packages/astro/src/core/build/plugins/util.ts b/packages/astro/src/core/build/plugins/util.ts index 50f5e07059b8..1fbf0fcdc133 100644 --- a/packages/astro/src/core/build/plugins/util.ts +++ b/packages/astro/src/core/build/plugins/util.ts @@ -1,4 +1,5 @@ import type { Plugin as VitePlugin } from 'vite'; +import { extname } from 'node:path'; // eslint-disable-next-line @typescript-eslint/ban-types type OutputOptionsHook = Extract; @@ -38,3 +39,33 @@ export function extendManualChunks(outputOptions: OutputOptions, hooks: ExtendMa return null; }; } + +// This is an arbitrary string that we are going to replace the dot of the extension +export const ASTRO_PAGE_EXTENSION_POST_PATTERN = '@_@'; + +/** + * 1. We add a fixed prefix, which is used as virtual module naming convention; + * 2. We replace the dot that belongs extension with an arbitrary string. + * + * @param virtualModulePrefix + * @param path + */ +export function getVirtualModulePageNameFromPath(virtualModulePrefix: string, path: string) { + // we mask the extension, so this virtual file + // so rollup won't trigger other plugins in the process + const extension = extname(path); + return `${virtualModulePrefix}${path.replace( + extension, + extension.replace('.', ASTRO_PAGE_EXTENSION_POST_PATTERN) + )}`; +} + +/** + * + * @param virtualModulePrefix + * @param id + */ +export function getPathFromVirtualModulePageName(virtualModulePrefix: string, id: string) { + const pageName = id.slice(virtualModulePrefix.length); + return pageName.replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, '.'); +} diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 327ef5f16035..e783c28b11cb 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -25,14 +25,14 @@ import { trackPageData } from './internal.js'; import { createPluginContainer, type AstroBuildPluginContainer } from './plugin.js'; import { registerAllPlugins } from './plugins/index.js'; import { MIDDLEWARE_MODULE_ID } from './plugins/plugin-middleware.js'; -import { - ASTRO_PAGE_EXTENSION_POST_PATTERN, - ASTRO_PAGE_RESOLVED_MODULE_ID, -} from './plugins/plugin-pages.js'; +import { ASTRO_PAGE_RESOLVED_MODULE_ID } from './plugins/plugin-pages.js'; import { RESOLVED_RENDERERS_MODULE_ID } from './plugins/plugin-renderers.js'; -import { SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js'; +import { RESOLVED_SPLIT_MODULE_ID, SSR_VIRTUAL_MODULE_ID } from './plugins/plugin-ssr.js'; import type { AllPagesData, PageBuildData, StaticBuildOptions } from './types'; import { getTimeStat } from './util.js'; +import { ASTRO_PAGE_EXTENSION_POST_PATTERN } from './plugins/util.js'; +import { extname } from 'node:path'; +import type { RouteData } from '../../@types/astro'; export async function viteBuild(opts: StaticBuildOptions) { const { allPages, settings } = opts; @@ -147,7 +147,7 @@ async function ssrBuild( const { allPages, settings, viteConfig } = opts; const ssr = isServerLikeOutput(settings.config); const out = ssr ? opts.buildConfig.server : getOutDirWithinCwd(settings.config.outDir); - + const routes = Object.values(allPages).map((pd) => pd.route); const { lastVitePlugins, vitePlugins } = container.runBeforeHook('ssr', input); const viteBuildConfig: vite.InlineConfig = { @@ -176,7 +176,13 @@ async function ssrBuild( ...viteConfig.build?.rollupOptions?.output, entryFileNames(chunkInfo) { if (chunkInfo.facadeModuleId?.startsWith(ASTRO_PAGE_RESOLVED_MODULE_ID)) { - return makeAstroPageEntryPointFileName(chunkInfo.facadeModuleId, allPages); + return makeAstroPageEntryPointFileName( + ASTRO_PAGE_RESOLVED_MODULE_ID, + chunkInfo.facadeModuleId, + routes + ); + } else if (chunkInfo.facadeModuleId?.startsWith(RESOLVED_SPLIT_MODULE_ID)) { + return makeSplitEntryPointFileName(chunkInfo.facadeModuleId, routes); } else if (chunkInfo.facadeModuleId === MIDDLEWARE_MODULE_ID) { return 'middleware.mjs'; } else if (chunkInfo.facadeModuleId === SSR_VIRTUAL_MODULE_ID) { @@ -422,19 +428,65 @@ async function ssrMoveAssets(opts: StaticBuildOptions) { * Input: `@astro-page:../node_modules/my-dep/injected@_@astro` * Output: `pages/injected.mjs` * - * 1. We clean the `facadeModuleId` by removing the `@astro-page:` prefix and `@_@` suffix + * 1. We clean the `facadeModuleId` by removing the `ASTRO_PAGE_MODULE_ID` prefix and `ASTRO_PAGE_EXTENSION_POST_PATTERN`. * 2. We find the matching route pattern in the manifest (or fallback to the cleaned module id) * 3. We replace square brackets with underscore (`[slug]` => `_slug_`) and `...` with `` (`[...slug]` => `_---slug_`). * 4. We append the `.mjs` extension, so the file will always be an ESM module * + * @param prefix string * @param facadeModuleId string * @param pages AllPagesData */ -function makeAstroPageEntryPointFileName(facadeModuleId: string, pages: AllPagesData) { +export function makeAstroPageEntryPointFileName( + prefix: string, + facadeModuleId: string, + routes: RouteData[] +) { const pageModuleId = facadeModuleId - .replace(ASTRO_PAGE_RESOLVED_MODULE_ID, '') + .replace(prefix, '') .replace(ASTRO_PAGE_EXTENSION_POST_PATTERN, '.'); - let name = pages[pageModuleId]?.route?.route ?? pageModuleId; + let route = routes.find((routeData) => { + return routeData.route === pageModuleId; + }); + let name = pageModuleId; + if (route) { + name = route.route; + } if (name.endsWith('/')) name += 'index'; - return `pages${name.replaceAll('[', '_').replaceAll(']', '_').replaceAll('...', '---')}.mjs`; + const fileName = `${name.replaceAll('[', '_').replaceAll(']', '_').replaceAll('...', '---')}.mjs`; + if (name.startsWith('..')) { + return `pages${fileName}`; + } + return fileName; +} + +/** + * The `facadeModuleId` has a shape like: \0@astro-serverless-page:src/pages/index@_@astro. + * + * 1. We call `makeAstroPageEntryPointFileName` which normalise its name, making it like a file path + * 2. We split the file path using the file system separator and attempt to retrieve the last entry + * 3. The last entry should be the file + * 4. We prepend the file name with `entry.` + * 5. We built the file path again, using the new entry built in the previous step + * + * @param facadeModuleId + * @param opts + */ +export function makeSplitEntryPointFileName(facadeModuleId: string, routes: RouteData[]) { + const filePath = `${makeAstroPageEntryPointFileName( + RESOLVED_SPLIT_MODULE_ID, + facadeModuleId, + routes + )}`; + + const pathComponents = filePath.split(path.sep); + const lastPathComponent = pathComponents.pop(); + if (lastPathComponent) { + const extension = extname(lastPathComponent); + if (extension.length > 0) { + const newFileName = `entry.${lastPathComponent}`; + return [...pathComponents, newFileName].join(path.sep); + } + } + return filePath; } diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts index 3edabc5d54e3..99ef653d0788 100644 --- a/packages/astro/src/core/config/schema.ts +++ b/packages/astro/src/core/config/schema.ts @@ -24,6 +24,7 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = { serverEntry: 'entry.mjs', redirects: true, inlineStylesheets: 'never', + split: false, }, compressHTML: false, server: { @@ -120,6 +121,8 @@ export const AstroConfigSchema = z.object({ .enum(['always', 'auto', 'never']) .optional() .default(ASTRO_CONFIG_DEFAULTS.build.inlineStylesheets), + + split: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.split), }) .optional() .default({}), @@ -279,6 +282,8 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: URL) { .enum(['always', 'auto', 'never']) .optional() .default(ASTRO_CONFIG_DEFAULTS.build.inlineStylesheets), + + split: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.build.split), }) .optional() .default({}), diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index a47087336073..b8babd91a06d 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -817,6 +817,17 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati `Invalid glob pattern: \`${globPattern}\`. Glob patterns must start with './', '../' or '/'.`, hint: 'See https://docs.astro.build/en/guides/imports/#glob-patterns for more information on supported glob patterns.', }, + /** + * @docs + * @description + * Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error. + */ + FailedToFindPageMapSSR: { + title: "Astro couldn't find the correct page to render", + code: 4003, + message: + "Astro couldn't find the correct page to render, probably because it wasn't correctly mapped for SSR usage. This is an internal error. Please file an issue.", + }, /** * @docs * @kind heading diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index 53a75fbea1ad..ae1ff97618d1 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -309,16 +309,18 @@ export async function runHookBuildSsr({ config, manifest, logging, + entryPoints, }: { config: AstroConfig; manifest: SerializedSSRManifest; logging: LogOptions; + entryPoints: Map; }) { for (const integration of config.integrations) { if (integration?.hooks?.['astro:build:ssr']) { await withTakingALongTimeMsg({ name: integration.name, - hookResult: integration.hooks['astro:build:ssr']({ manifest }), + hookResult: integration.hooks['astro:build:ssr']({ manifest, entryPoints }), logging, }); } diff --git a/packages/astro/test/fixtures/ssr-request/astro.config.mjs b/packages/astro/test/fixtures/ssr-request/astro.config.mjs new file mode 100644 index 000000000000..3bd2a19a386e --- /dev/null +++ b/packages/astro/test/fixtures/ssr-request/astro.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'astro/config'; + +// https://astro.build/config +export default defineConfig({ + build: { + split: false + } +}); diff --git a/packages/astro/test/fixtures/ssr-split-manifest/astro.config.mjs b/packages/astro/test/fixtures/ssr-split-manifest/astro.config.mjs new file mode 100644 index 000000000000..171de39d9e24 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-split-manifest/astro.config.mjs @@ -0,0 +1,7 @@ +import { defineConfig } from 'astro/config'; +export default defineConfig({ + build: { + split: true + }, + output: "server" +}) \ No newline at end of file diff --git a/packages/astro/test/fixtures/ssr-split-manifest/package.json b/packages/astro/test/fixtures/ssr-split-manifest/package.json new file mode 100644 index 000000000000..b980cc8a7b2e --- /dev/null +++ b/packages/astro/test/fixtures/ssr-split-manifest/package.json @@ -0,0 +1,8 @@ +{ + "name": "@test/ssr-split-manifest", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*" + } +} diff --git a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/[...post].astro b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/[...post].astro new file mode 100644 index 000000000000..8bac75eb9404 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/[...post].astro @@ -0,0 +1,18 @@ +--- +export async function getStaticPaths() { + return [ + { + params: { page: 1 }, + }, + { + params: { page: 2 }, + }, + { + params: { page: 3 } + } + ] +}; +--- + + + \ No newline at end of file diff --git a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/index.astro b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/index.astro new file mode 100644 index 000000000000..f189e711c19a --- /dev/null +++ b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/index.astro @@ -0,0 +1,17 @@ +--- +import { manifest } from 'astro:ssr-manifest'; +--- + + + Testing + + + +

Testing

+
+ + diff --git a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/lorem.md b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/lorem.md new file mode 100644 index 000000000000..8a38d58c1963 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/lorem.md @@ -0,0 +1 @@ +# Title \ No newline at end of file diff --git a/packages/astro/test/fixtures/ssr-split-manifest/src/pages/zod.astro b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/zod.astro new file mode 100644 index 000000000000..06d949d47f6c --- /dev/null +++ b/packages/astro/test/fixtures/ssr-split-manifest/src/pages/zod.astro @@ -0,0 +1,17 @@ +--- +import { manifest } from 'astro:ssr-manifest'; +--- + + + Testing + + + +

Testing

+
+ + \ No newline at end of file diff --git a/packages/astro/test/ssr-split-manifest.test.js b/packages/astro/test/ssr-split-manifest.test.js new file mode 100644 index 000000000000..5005f6279953 --- /dev/null +++ b/packages/astro/test/ssr-split-manifest.test.js @@ -0,0 +1,49 @@ +import { expect } from 'chai'; +import { loadFixture } from './test-utils.js'; +import testAdapter from './test-adapter.js'; +import * as cheerio from 'cheerio'; +import { fileURLToPath } from 'node:url'; +import { existsSync } from 'node:fs'; + +describe('astro:ssr-manifest, split', () => { + /** @type {import('./test-utils').Fixture} */ + let fixture; + let entryPoints; + let currentRoutes; + + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr-split-manifest/', + output: 'server', + adapter: testAdapter({ + setEntryPoints(entries) { + entryPoints = entries; + }, + setRoutes(routes) { + currentRoutes = routes; + }, + }), + }); + await fixture.build(); + }); + + it('should be able to render a specific entry point', async () => { + const pagePath = 'src/pages/index.astro'; + const app = await fixture.loadEntryPoint(pagePath, currentRoutes); + const request = new Request('http://example.com/'); + const response = await app.render(request); + const html = await response.text(); + + const $ = cheerio.load(html); + expect($('#assets').text()).to.equal('["/_astro/index.a8a337e4.css"]'); + }); + + it('should give access to entry points that exists on file system', async () => { + // number of the pages inside src/ + expect(entryPoints.size).to.equal(4); + for (const fileUrl in entryPoints.values()) { + let filePath = fileURLToPath(fileUrl); + expect(existsSync(filePath)).to.be.true; + } + }); +}); diff --git a/packages/astro/test/test-adapter.js b/packages/astro/test/test-adapter.js index d74cfaf81a71..af5a7777b8c1 100644 --- a/packages/astro/test/test-adapter.js +++ b/packages/astro/test/test-adapter.js @@ -4,7 +4,11 @@ import { viteID } from '../dist/core/util.js'; * * @returns {import('../src/@types/astro').AstroIntegration} */ -export default function ({ provideAddress = true, extendAdapter } = { provideAddress: true }) { +export default function ( + { provideAddress = true, extendAdapter, setEntryPoints = undefined, setRoutes = undefined } = { + provideAddress: true, + } +) { return { name: 'my-ssr-adapter', hooks: { @@ -70,6 +74,16 @@ export default function ({ provideAddress = true, extendAdapter } = { provideAdd ...extendAdapter, }); }, + 'astro:build:ssr': ({ entryPoints }) => { + if (setEntryPoints) { + setEntryPoints(entryPoints); + } + }, + 'astro:build:done': ({ routes }) => { + if (setRoutes) { + setRoutes(routes); + } + }, }, }; } diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index b618f8593a42..11b181779bbc 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -13,6 +13,9 @@ import dev from '../dist/core/dev/index.js'; import { nodeLogDestination } from '../dist/core/logger/node.js'; import preview from '../dist/core/preview/index.js'; import { check } from '../dist/cli/check/index.js'; +import { getVirtualModulePageNameFromPath } from '../dist/core/build/plugins/util.js'; +import { RESOLVED_SPLIT_MODULE_ID } from '../dist/core/build/plugins/plugin-ssr.js'; +import { makeSplitEntryPointFileName } from '../dist/core/build/static-build.js'; // polyfill WebAPIs to globalThis for Node v12, Node v14, and Node v16 polyfill(globalThis, { @@ -245,6 +248,15 @@ export async function loadFixture(inlineConfig) { app.manifest = manifest; return app; }, + loadEntryPoint: async (pagePath, routes, streaming) => { + const virtualModule = getVirtualModulePageNameFromPath(RESOLVED_SPLIT_MODULE_ID, pagePath); + const filePath = makeSplitEntryPointFileName(virtualModule, routes); + const url = new URL(`./server/${filePath}?id=${fixtureId}`, config.outDir); + const { createApp, manifest, middleware } = await import(url); + const app = createApp(streaming); + app.manifest = manifest; + return app; + }, editFile: async (filePath, newContentsOrCallback) => { const fileUrl = new URL(filePath.replace(/^\//, ''), config.root); const contents = await fs.promises.readFile(fileUrl, 'utf-8'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29d581936ece..45c034404385 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3339,6 +3339,12 @@ importers: specifier: ^10.11.0 version: 10.13.2 + packages/astro/test/fixtures/ssr-split-manifest: + dependencies: + astro: + specifier: workspace:* + version: link:../../.. + packages/astro/test/fixtures/static-build: dependencies: '@astrojs/preact': @@ -4421,7 +4427,7 @@ importers: version: 9.2.2 vite: specifier: ^4.3.1 - version: 4.3.1(@types/node@18.16.3)(sass@1.52.2) + version: 4.3.1(@types/node@14.18.21) packages/integrations/netlify/test/edge-functions/fixtures/dynimport: dependencies: @@ -4945,7 +4951,7 @@ importers: version: 3.0.0(vite@4.3.1)(vue@3.2.47) '@vue/babel-plugin-jsx': specifier: ^1.1.1 - version: 1.1.1(@babel/core@7.21.8) + version: 1.1.1 '@vue/compiler-sfc': specifier: ^3.2.39 version: 3.2.39 @@ -9332,6 +9338,23 @@ packages: resolution: {integrity: sha512-hz4R8tS5jMn8lDq6iD+yWL6XNB699pGIVLk7WSJnn1dbpjaazsjZQkieJoRX6gW5zpYSCFqQ7jUquPNY65tQYA==} dev: false + /@vue/babel-plugin-jsx@1.1.1: + resolution: {integrity: sha512-j2uVfZjnB5+zkcbc/zsOc0fSNGCMMjaEXP52wdwdIfn0qjFfEYpYZBFKFg+HHnQeJCVrjOeO0YxgaL7DMrym9w==} + dependencies: + '@babel/helper-module-imports': 7.21.4 + '@babel/plugin-syntax-jsx': 7.21.4(@babel/core@7.18.2) + '@babel/template': 7.20.7 + '@babel/traverse': 7.18.2 + '@babel/types': 7.21.5 + '@vue/babel-helper-vue-transform-on': 1.0.2 + camelcase: 6.3.0 + html-tags: 3.3.1 + svg-tags: 1.0.0 + transitivePeerDependencies: + - '@babel/core' + - supports-color + dev: false + /@vue/babel-plugin-jsx@1.1.1(@babel/core@7.21.8): resolution: {integrity: sha512-j2uVfZjnB5+zkcbc/zsOc0fSNGCMMjaEXP52wdwdIfn0qjFfEYpYZBFKFg+HHnQeJCVrjOeO0YxgaL7DMrym9w==} dependencies: @@ -17663,6 +17686,39 @@ packages: - supports-color dev: false + /vite@4.3.1(@types/node@14.18.21): + resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 14.18.21 + esbuild: 0.17.18 + postcss: 8.4.23 + rollup: 3.21.8 + optionalDependencies: + fsevents: 2.3.2 + dev: true + /vite@4.3.1(@types/node@18.16.3)(sass@1.52.2): resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==} engines: {node: ^14.18.0 || >=16.0.0}