feat: SSR split mode (#7220)

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Emanuele Stoppa 2023-06-21 13:32:20 +01:00 committed by GitHub
parent c8bccb47d3
commit 459b5bd05f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 709 additions and 140 deletions

View file

@ -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
}
})
```

View file

@ -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<void>;
'astro:server:start'?: (options: { address: AddressInfo }) => void | Promise<void>;
'astro:server:done'?: () => void | Promise<void>;
'astro:build:ssr'?: (options: { manifest: SerializedSSRManifest }) => void | Promise<void>;
'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<RouteData, URL>;
}) => void | Promise<void>;
'astro:build:start'?: () => void | Promise<void>;
'astro:build:setup'?: (options: {
vite: vite.InlineConfig;

View file

@ -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<RouteData, RouteInfo>;
#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;
}
}

View file

@ -30,16 +30,16 @@ export interface RouteInfo {
export type SerializedRouteInfo = Omit<RouteInfo, 'routeData'> & {
routeData: SerializedRouteData;
};
type ImportComponentInstance = () => Promise<SinglePageBuiltModule>;
export interface SSRManifest {
export type ImportComponentInstance = () => Promise<SinglePageBuiltModule>;
export type SSRManifest = {
adapterName: string;
routes: RouteInfo[];
site?: string;
base?: string;
assetsPrefix?: string;
markdown: MarkdownRenderingOptions;
pageMap: Map<ComponentPath, ImportComponentInstance>;
renderers: SSRLoadedRenderer[];
/**
* Map of directive name (e.g. `load`) to the directive script code
@ -48,7 +48,9 @@ export interface SSRManifest {
entryModules: Record<string, string>;
assets: Set<string>;
componentMetadata: SSRResult['componentMetadata'];
}
pageModule?: SinglePageBuiltModule;
pageMap?: Map<ComponentPath, ImportComponentInstance>;
};
export type SerializedSSRManifest = Omit<
SSRManifest,

View file

@ -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<string>;
// The SSR entry chunk. Kept in internals to share between ssr/client build steps
ssrEntryChunk?: Rollup.OutputChunk;
entryPoints: Map<RouteData, URL>;
ssrSplitEntryChunks: Map<string, Rollup.OutputChunk>;
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(),
};
}

View file

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

View file

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

View file

@ -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,38 +73,10 @@ function vitePluginSSR(
contents.push(`const pageMap = new Map([${pageMap.join(',')}]);`);
exports.push(`export { pageMap }`);
const content = `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,
renderers,
});
_privateSetManifestDontUseThis(_manifest);
const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'};
${
adapter.exports
? `const _exports = adapter.createExports(_manifest, _args);
${adapter.exports
.map((name) => {
if (name === 'default') {
return `const _default = _exports['default'];
export { _default as default };`;
} else {
return `export const ${name} = _exports['${name}'];`;
}
})
.join('\n')}
`
: ''
}
const _start = 'start';
if(_start in adapter) {
adapter[_start](_manifest, _args);
}`;
return `${imports.join('\n')}${contents.join('\n')}${content}${exports.join('\n')}`;
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;
},
@ -127,15 +101,261 @@ if(_start in adapter) {
};
}
export async function injectManifest(buildOpts: StaticBuildOptions, internals: BuildInternals) {
if (!internals.ssrEntryChunk) {
throw new Error(`Did not generate an entry chunk for SSR`);
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<string> = 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},
renderers,
});
_privateSetManifestDontUseThis(_manifest);
const _args = ${adapter.args ? JSON.stringify(adapter.args) : 'undefined'};
${
adapter.exports
? `const _exports = adapter.createExports(_manifest, _args);
${adapter.exports
.map((name) => {
if (name === 'default') {
return `const _default = _exports['default'];
export { _default as default };`;
} else {
return `export const ${name} = _exports['${name}'];`;
}
})
.join('\n')}
`
: ''
}
const _start = 'start';
if(_start in adapter) {
adapter[_start](_manifest, _args);
}`);
return {
imports,
contents,
};
}
/**
* 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<OutputChunk>) {
const code = chunk.code;
return code.replace(replaceExp, () => {
return JSON.stringify(manifest);
});
}
export async function createManifest(
buildOpts: StaticBuildOptions,
internals: BuildInternals
): Promise<SerializedSSRManifest> {
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,
});
return buildManifest(buildOpts, internals, Array.from(staticFiles));
}
const chunk = internals.ssrEntryChunk;
const code = chunk.code;
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);
},
},
};
}

View file

@ -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<VitePlugin['outputOptions'], Function>;
@ -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, '.');
}

View file

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

View file

@ -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({}),

View file

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

View file

@ -309,16 +309,18 @@ export async function runHookBuildSsr({
config,
manifest,
logging,
entryPoints,
}: {
config: AstroConfig;
manifest: SerializedSSRManifest;
logging: LogOptions;
entryPoints: Map<RouteData, URL>;
}) {
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,
});
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'astro/config';
// https://astro.build/config
export default defineConfig({
build: {
split: false
}
});

View file

@ -0,0 +1,7 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
build: {
split: true
},
output: "server"
})

View file

@ -0,0 +1,8 @@
{
"name": "@test/ssr-split-manifest",
"version": "0.0.0",
"private": true,
"dependencies": {
"astro": "workspace:*"
}
}

View file

@ -0,0 +1,18 @@
---
export async function getStaticPaths() {
return [
{
params: { page: 1 },
},
{
params: { page: 2 },
},
{
params: { page: 3 }
}
]
};
---
<html>
</html>

View file

@ -0,0 +1,17 @@
---
import { manifest } from 'astro:ssr-manifest';
---
<html>
<head>
<title>Testing</title>
<style>
body {
background: green;
}
</style>
</head>
<body>
<h1>Testing</h1>
<div id="assets" set:html={JSON.stringify([...manifest.assets])}></div>
</body>
</html>

View file

@ -0,0 +1 @@
# Title

View file

@ -0,0 +1,17 @@
---
import { manifest } from 'astro:ssr-manifest';
---
<html>
<head>
<title>Testing</title>
<style>
body {
background: green;
}
</style>
</head>
<body>
<h1>Testing</h1>
<div id="assets" set:html={JSON.stringify([...manifest.assets])}></div>
</body>
</html>

View file

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

View file

@ -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);
}
},
},
};
}

View file

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

View file

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