Build to a single file (#2873)

* Build to a single file

* Updates based on initial code review

* Adds a changeset

* Use the default export for cjs module

* Await generatePages

* Prevent timing from causing module to not import

* Fix shared CSS

* Properly handle windows ids

* Dont shadow

* Fix ts errors

* Remove console.log
This commit is contained in:
Matthew Phillips 2022-03-24 17:08:36 -04:00 committed by GitHub
parent e2885df50b
commit e4025d1f53
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 429 additions and 189 deletions

View file

@ -0,0 +1,6 @@
---
'astro': patch
'@astrojs/node': patch
---
Improves the build by building to a single file for rendering

View file

@ -1,10 +1,12 @@
import type { ComponentInstance, ManifestData, RouteData, SSRLoadedRenderer } from '../../@types/astro';
import type { ComponentInstance, EndpointHandler, ManifestData, RouteData } from '../../@types/astro';
import type { SSRManifest as Manifest, RouteInfo } from './types';
import mime from 'mime';
import { defaultLogOptions } from '../logger.js';
export { deserializeManifest } from './common.js';
import { matchRoute } from '../routing/match.js';
import { render } from '../render/core.js';
import { call as callEndpoint } from '../endpoint/index.js';
import { RouteCache } from '../render/route-cache.js';
import { createLinkStylesheetElementSet, createModuleScriptElementWithSrcSet } from '../render/ssr-element.js';
import { prependForwardSlash } from '../path.js';
@ -12,20 +14,17 @@ import { prependForwardSlash } from '../path.js';
export class App {
#manifest: Manifest;
#manifestData: ManifestData;
#rootFolder: URL;
#routeDataToRouteInfo: Map<RouteData, RouteInfo>;
#routeCache: RouteCache;
#renderersPromise: Promise<SSRLoadedRenderer[]>;
#encoder = new TextEncoder();
constructor(manifest: Manifest, rootFolder: URL) {
constructor(manifest: Manifest) {
this.#manifest = manifest;
this.#manifestData = {
routes: manifest.routes.map((route) => route.routeData),
};
this.#rootFolder = rootFolder;
this.#routeDataToRouteInfo = new Map(manifest.routes.map((route) => [route.routeData, route]));
this.#routeCache = new RouteCache(defaultLogOptions);
this.#renderersPromise = this.#loadRenderers();
}
match(request: Request): RouteData | undefined {
const url = new URL(request.url);
@ -42,11 +41,22 @@ export class App {
}
}
const manifest = this.#manifest;
const info = this.#routeDataToRouteInfo.get(routeData!)!;
const [mod, renderers] = await Promise.all([this.#loadModule(info.file), this.#renderersPromise]);
const mod = this.#manifest.pageMap.get(routeData.component)!;
if(routeData.type === 'page') {
return this.#renderPage(request, routeData, mod);
} else if(routeData.type === 'endpoint') {
return this.#callEndpoint(request, routeData, mod);
} else {
throw new Error(`Unsupported route type [${routeData.type}].`);
}
}
async #renderPage(request: Request, routeData: RouteData, mod: ComponentInstance): Promise<Response> {
const url = new URL(request.url);
const manifest = this.#manifest;
const renderers = manifest.renderers;
const info = this.#routeDataToRouteInfo.get(routeData!)!;
const links = createLinkStylesheetElementSet(info.links, manifest.site);
const scripts = createModuleScriptElementWithSrcSet(info.scripts, manifest.site);
@ -80,26 +90,44 @@ export class App {
}
let html = result.html;
return new Response(html, {
let bytes = this.#encoder.encode(html);
return new Response(bytes, {
status: 200,
headers: {
'Content-Type': 'text/html',
'Content-Length': bytes.byteLength.toString()
}
});
}
async #loadRenderers(): Promise<SSRLoadedRenderer[]> {
return await Promise.all(
this.#manifest.renderers.map(async (renderer) => {
const mod = (await import(renderer.serverEntrypoint)) as { default: SSRLoadedRenderer['ssr'] };
return { ...renderer, ssr: mod.default };
})
);
}
async #loadModule(rootRelativePath: string): Promise<ComponentInstance> {
let modUrl = new URL(rootRelativePath, this.#rootFolder).toString();
let mod: ComponentInstance;
try {
mod = await import(modUrl);
return mod;
} catch (err) {
throw new Error(`Unable to import ${modUrl}. Does this file exist?`);
async #callEndpoint(request: Request, routeData: RouteData, mod: ComponentInstance): Promise<Response> {
const url = new URL(request.url);
const handler = mod as unknown as EndpointHandler;
const result = await callEndpoint(handler, {
headers: request.headers,
logging: defaultLogOptions,
method: request.method,
origin: url.origin,
pathname: url.pathname,
routeCache: this.#routeCache,
ssr: true,
});
if(result.type === 'response') {
return result.response;
} else {
const body = result.body;
const headers = new Headers();
const mimeType = mime.getType(url.pathname);
if(mimeType) {
headers.set('Content-Type', mimeType);
}
const bytes = this.#encoder.encode(body);
headers.set('Content-Length', bytes.byteLength.toString());
return new Response(bytes, {
status: 200,
headers
});
}
}
}

View file

@ -33,5 +33,5 @@ export async function loadManifest(rootFolder: URL): Promise<SSRManifest> {
export async function loadApp(rootFolder: URL): Promise<NodeApp> {
const manifest = await loadManifest(rootFolder);
return new NodeApp(manifest, rootFolder);
return new NodeApp(manifest);
}

View file

@ -1,4 +1,6 @@
import type { RouteData, SerializedRouteData, MarkdownRenderOptions, AstroRenderer } from '../../@types/astro';
import type { RouteData, SerializedRouteData, MarkdownRenderOptions, ComponentInstance, SSRLoadedRenderer } from '../../@types/astro';
export type ComponentPath = string;
export interface RouteInfo {
routeData: RouteData;
@ -17,7 +19,8 @@ export interface SSRManifest {
markdown: {
render: MarkdownRenderOptions;
};
renderers: AstroRenderer[];
pageMap: Map<ComponentPath, ComponentInstance>;
renderers: SSRLoadedRenderer[];
entryModules: Record<string, string>;
}

View file

@ -0,0 +1,43 @@
import { InputOptions } from 'rollup';
function fromEntries<V>(entries: [string, V][]) {
const obj: Record<string, V> = {};
for (const [k, v] of entries) {
obj[k] = v;
}
return obj;
}
export function addRollupInput(inputOptions: InputOptions, newInputs: string[]): InputOptions {
// Add input module ids to existing input option, whether it's a string, array or object
// this way you can use multiple html plugins all adding their own inputs
if (!inputOptions.input) {
return { ...inputOptions, input: newInputs };
}
if (typeof inputOptions.input === 'string') {
return {
...inputOptions,
input: [inputOptions.input, ...newInputs],
};
}
if (Array.isArray(inputOptions.input)) {
return {
...inputOptions,
input: [...inputOptions.input, ...newInputs],
};
}
if (typeof inputOptions.input === 'object') {
return {
...inputOptions,
input: {
...inputOptions.input,
...fromEntries(newInputs.map((i) => [i.split('/').slice(-1)[0].split('.')[0], i])),
},
};
}
throw new Error(`Unknown rollup input type. Supported inputs are string, array and object.`);
}

View file

@ -1,4 +1,5 @@
import type { AstroConfig, RouteType } from '../../@types/astro';
import type { StaticBuildOptions } from './types';
import npath from 'path';
import { appendForwardSlash } from '../../core/path.js';

View file

@ -1,20 +1,21 @@
import fs from 'fs';
import { bgMagenta, black, cyan, dim, magenta } from 'kleur/colors';
import npath from 'path';
import type { OutputAsset, OutputChunk, RollupOutput } from 'rollup';
import { fileURLToPath } from 'url';
import type { AstroConfig, AstroRenderer, ComponentInstance, EndpointHandler, SSRLoadedRenderer } from '../../@types/astro';
import type { AstroConfig, ComponentInstance, EndpointHandler, SSRLoadedRenderer } from '../../@types/astro';
import type { PageBuildData, StaticBuildOptions, SingleFileBuiltModule } from './types';
import type { BuildInternals } from '../../core/build/internal.js';
import type { RenderOptions } from '../../core/render/core';
import fs from 'fs';
import npath from 'path';
import { fileURLToPath } from 'url';
import { debug, error, info } from '../../core/logger.js';
import { prependForwardSlash } from '../../core/path.js';
import type { RenderOptions } from '../../core/render/core';
import { resolveDependency } from '../../core/util.js';
import { BEFORE_HYDRATION_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
import { call as callEndpoint } from '../endpoint/index.js';
import { render } from '../render/core.js';
import { createLinkStylesheetElementSet, createModuleScriptElementWithSrcSet } from '../render/ssr-element.js';
import { getOutFile, getOutFolder, getOutRoot } from './common.js';
import type { PageBuildData, StaticBuildOptions } from './types';
import { getOutFile, getOutRoot, getOutFolder, getServerRoot } from './common.js';
import { getPageDataByComponent, eachPageData } from './internal.js';
import { bgMagenta, black, cyan, dim, magenta } from 'kleur/colors';
import { getTimeStat } from './util.js';
// Render is usually compute, which Node.js can't parallelize well.
@ -23,24 +24,6 @@ import { getTimeStat } from './util.js';
// system, possibly one that parallelizes if async IO is detected.
const MAX_CONCURRENT_RENDERS = 1;
// Utility functions
async function loadRenderer(renderer: AstroRenderer, config: AstroConfig): Promise<SSRLoadedRenderer> {
const mod = (await import(resolveDependency(renderer.serverEntrypoint, config))) as { default: SSRLoadedRenderer['ssr'] };
return { ...renderer, ssr: mod.default };
}
async function loadRenderers(config: AstroConfig): Promise<SSRLoadedRenderer[]> {
return Promise.all(config._ctx.renderers.map((r) => loadRenderer(r, config)));
}
export function getByFacadeId<T>(facadeId: string, map: Map<string, T>): T | undefined {
return (
map.get(facadeId) ||
// Windows the facadeId has forward slashes, no idea why
map.get(facadeId.replace(/\//g, '\\'))
);
}
// Throttle the rendering a paths to prevents creating too many Promises on the microtask queue.
function* throttle(max: number, inPaths: string[]) {
let tmp = [];
@ -86,45 +69,42 @@ export function chunkIsPage(astroConfig: AstroConfig, output: OutputAsset | Outp
export async function generatePages(result: RollupOutput, opts: StaticBuildOptions, internals: BuildInternals, facadeIdToPageDataMap: Map<string, PageBuildData>) {
info(opts.logging, null, `\n${bgMagenta(black(' generating static routes '))}\n`);
// Get renderers to be shared for each page generation.
const renderers = await loadRenderers(opts.astroConfig);
const ssr = !!opts.astroConfig._ctx.adapter?.serverEntrypoint;
const outFolder = ssr ? getServerRoot(opts.astroConfig) : getOutRoot(opts.astroConfig);
const ssrEntryURL = new URL(`./entry.mjs?time=${Date.now()}`, outFolder);
const ssrEntry = await import(ssrEntryURL.toString());
for (let output of result.output) {
if (chunkIsPage(opts.astroConfig, output, internals)) {
await generatePage(output as OutputChunk, opts, internals, facadeIdToPageDataMap, renderers);
}
for(const pageData of eachPageData(internals)) {
await generatePage(opts, internals, pageData, ssrEntry);
}
}
async function generatePage(
output: OutputChunk,
//output: OutputChunk,
opts: StaticBuildOptions,
internals: BuildInternals,
facadeIdToPageDataMap: Map<string, PageBuildData>,
renderers: SSRLoadedRenderer[]
pageData: PageBuildData,
ssrEntry: SingleFileBuiltModule
) {
let timeStart = performance.now();
const { astroConfig } = opts;
let timeStart = performance.now();
const renderers = ssrEntry.renderers;
let url = new URL('./' + output.fileName, getOutRoot(astroConfig));
const facadeId: string = output.facadeModuleId as string;
let pageData = getByFacadeId<PageBuildData>(facadeId, facadeIdToPageDataMap);
const pageInfo = getPageDataByComponent(internals, pageData.route.component);
const linkIds: string[] = Array.from(pageInfo?.css ?? []);
const hoistedId = pageInfo?.hoistedScript ?? null;
if (!pageData) {
throw new Error(`Unable to find a PageBuildData for the Astro page: ${facadeId}. There are the PageBuildDatas we have ${Array.from(facadeIdToPageDataMap.keys()).join(', ')}`);
const pageModule = ssrEntry.pageMap.get(pageData.component);
if(!pageModule) {
throw new Error(`Unable to find the module for ${pageData.component}. This is unexpected and likely a bug in Astro, please report.`);
}
const linkIds = getByFacadeId<string[]>(facadeId, internals.facadeIdToAssetsMap) || [];
const hoistedId = getByFacadeId<string>(facadeId, internals.facadeIdToHoistedEntryMap) || null;
let compiledModule = await import(url.toString());
const generationOptions: Readonly<GeneratePathOptions> = {
pageData,
internals,
linkIds,
hoistedId,
mod: compiledModule,
mod: pageModule,
renderers,
};

View file

@ -1,26 +1,47 @@
import type { RouteData } from '../../@types/astro';
import type { RenderedChunk } from 'rollup';
import type { PageBuildData, ViteID } from './types';
import { viteID } from '../util.js';
export interface BuildInternals {
// Pure CSS chunks are chunks that only contain CSS.
pureCSSChunks: Set<RenderedChunk>;
// chunkToReferenceIdMap maps them to a hash id used to find the final file.
chunkToReferenceIdMap: Map<string, string>;
// This is a mapping of pathname to the string source of all collected
// inline <style> for a page.
astroStyleMap: Map<string, string>;
// This is a virtual JS module that imports all dependent styles for a page.
astroPageStyleMap: Map<string, string>;
// A mapping to entrypoints (facadeId) to assets (styles) that are added.
facadeIdToAssetsMap: Map<string, string[]>;
// TODO document what this is
hoistedScriptIdToHoistedMap: Map<string, Set<string>>;
facadeIdToHoistedEntryMap: Map<string, string>;
// A mapping of specifiers like astro/client/idle.js to the hashed bundled name.
// Used to render pages with the correct specifiers.
entrySpecifierToBundleMap: Map<string, string>;
/**
* A map for page-specific information.
*/
pagesByComponent: Map<string, PageBuildData>;
/**
* A map for page-specific information by Vite ID (a path-like string)
*/
pagesByViteID: Map<ViteID, PageBuildData>;
/**
* chunkToReferenceIdMap maps them to a hash id used to find the final file.
* @deprecated This Map is only used for the legacy build.
*/
chunkToReferenceIdMap: Map<string, string>;
/**
* This is a mapping of pathname to the string source of all collected inline <style> for a page.
* @deprecated This Map is only used for the legacy build.
*/
astroStyleMap: Map<string, string>;
/**
* This is a virtual JS module that imports all dependent styles for a page.
* @deprecated This Map is only used for the legacy build.
*/
astroPageStyleMap: Map<string, string>;
}
/**
@ -39,21 +60,52 @@ export function createBuildInternals(): BuildInternals {
// This is a virtual JS module that imports all dependent styles for a page.
const astroPageStyleMap = new Map<string, string>();
// A mapping to entrypoints (facadeId) to assets (styles) that are added.
const facadeIdToAssetsMap = new Map<string, string[]>();
// These are for tracking hoisted script bundling
const hoistedScriptIdToHoistedMap = new Map<string, Set<string>>();
const facadeIdToHoistedEntryMap = new Map<string, string>();
return {
pureCSSChunks,
chunkToReferenceIdMap,
astroStyleMap,
astroPageStyleMap,
facadeIdToAssetsMap,
hoistedScriptIdToHoistedMap,
facadeIdToHoistedEntryMap,
entrySpecifierToBundleMap: new Map<string, string>(),
pagesByComponent: new Map(),
pagesByViteID: new Map(),
};
}
export function trackPageData(internals: BuildInternals, component: string, pageData: PageBuildData, componentModuleId: string, componentURL: URL): void {
pageData.moduleSpecifier = componentModuleId;
internals.pagesByComponent.set(component, pageData);
internals.pagesByViteID.set(viteID(componentURL), pageData);
}
export function * getPageDatasByChunk(internals: BuildInternals, chunk: RenderedChunk): Generator<PageBuildData, void, unknown> {
const pagesByViteID = internals.pagesByViteID;
for(const [modulePath] of Object.entries(chunk.modules)) {
if(pagesByViteID.has(modulePath)) {
yield pagesByViteID.get(modulePath)!;
}
}
}
export function getPageDataByComponent(internals: BuildInternals, component: string): PageBuildData | undefined {
if(internals.pagesByComponent.has(component)) {
return internals.pagesByComponent.get(component);
}
return undefined;
}
export function getPageDataByViteID(internals: BuildInternals, viteid: ViteID): PageBuildData | undefined {
if(internals.pagesByViteID.has(viteid)) {
return internals.pagesByViteID.get(viteid);
}
return undefined;
}
export function * eachPageData(internals: BuildInternals) {
yield * internals.pagesByComponent.values();
}

View file

@ -58,8 +58,13 @@ export async function collectPagesData(opts: CollectPagesDataOptions): Promise<C
clearInterval(routeCollectionLogTimeout);
}, 10000);
allPages[route.component] = {
component: route.component,
route,
paths: [route.pathname],
moduleSpecifier: '',
css: new Set(),
hoistedScript: undefined,
scripts: new Set(),
preload: await ssrPreload({
astroConfig,
filePath: new URL(`./${route.component}`, astroConfig.projectRoot),
@ -120,8 +125,13 @@ export async function collectPagesData(opts: CollectPagesDataOptions): Promise<C
}
const finalPaths = result.staticPaths.map((staticPath) => staticPath.params && route.generate(staticPath.params)).filter(Boolean);
allPages[route.component] = {
component: route.component,
route,
paths: finalPaths,
moduleSpecifier: '',
css: new Set(),
hoistedScript: undefined,
scripts: new Set(),
preload: await ssrPreload({
astroConfig,
filePath: new URL(`./${route.component}`, astroConfig.projectRoot),

View file

@ -16,8 +16,11 @@ import { rollupPluginAstroBuildCSS } from '../../vite-plugin-build-css/index.js'
import { vitePluginHoistedScripts } from './vite-plugin-hoisted-scripts.js';
import { vitePluginInternals } from './vite-plugin-internals.js';
import { vitePluginSSR } from './vite-plugin-ssr.js';
import { vitePluginPages } from './vite-plugin-pages.js';
import { generatePages } from './generate.js';
import { trackPageData } from './internal.js';
import { getClientRoot, getServerRoot, getOutRoot } from './common.js';
import { isBuildingToSSR } from '../util.js';
import { getTimeStat } from './util.js';
export async function staticBuild(opts: StaticBuildOptions) {
@ -45,6 +48,9 @@ export async function staticBuild(opts: StaticBuildOptions) {
const astroModuleURL = new URL('./' + component, astroConfig.projectRoot);
const astroModuleId = prependForwardSlash(component);
// Track the page data in internals
trackPageData(internals, component, pageData, astroModuleId, astroModuleURL);
if (pageData.route.type === 'page') {
const [renderers, mod] = pageData.preload;
const metadata = mod.$$metadata;
@ -96,7 +102,6 @@ export async function staticBuild(opts: StaticBuildOptions) {
timer.generate = performance.now();
if (opts.buildConfig.staticMode) {
console.log('huh?');
await generatePages(ssrResult, opts, internals, facadeIdToPageDataMap);
await cleanSsrOutput(opts);
} else {
@ -122,10 +127,10 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp
outDir: fileURLToPath(out),
ssr: true,
rollupOptions: {
input: Array.from(input),
input: [],
output: {
format: 'esm',
entryFileNames: 'entry.[hash].mjs',
entryFileNames: 'entry.mjs',
chunkFileNames: 'chunks/chunk.[hash].mjs',
assetFileNames: 'assets/asset.[hash][extname]',
},
@ -139,12 +144,14 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp
},
plugins: [
vitePluginInternals(input, internals),
vitePluginPages(opts, internals),
rollupPluginAstroBuildCSS({
internals,
}),
...(viteConfig.plugins || []),
// SSR needs to be last
opts.astroConfig._ctx.adapter?.serverEntrypoint && vitePluginSSR(opts, internals, opts.astroConfig._ctx.adapter),
isBuildingToSSR(opts.astroConfig) &&
vitePluginSSR(opts, internals, opts.astroConfig._ctx.adapter!),
],
publicDir: ssr ? false : viteConfig.publicDir,
root: viteConfig.root,

View file

@ -1,15 +1,23 @@
import type { ComponentPreload } from '../render/dev/index';
import type { AstroConfig, BuildConfig, ManifestData, RouteData } from '../../@types/astro';
import type { AstroConfig, BuildConfig, ManifestData, RouteData, ComponentInstance, SSRLoadedRenderer } from '../../@types/astro';
import type { ViteConfigWithSSR } from '../../create-vite';
import type { LogOptions } from '../../logger';
import type { RouteCache } from '../../render/route-cache.js';
export type ComponentPath = string;
export type ViteID = string;
export interface PageBuildData {
component: ComponentPath;
paths: string[];
preload: ComponentPreload;
route: RouteData;
moduleSpecifier: string;
css: Set<string>;
hoistedScript: string | undefined;
scripts: Set<string>;
}
export type AllPagesData = Record<string, PageBuildData>;
export type AllPagesData = Record<ComponentPath, PageBuildData>;
/** Options for the static build */
export interface StaticBuildOptions {
@ -23,3 +31,8 @@ export interface StaticBuildOptions {
routeCache: RouteCache;
viteConfig: ViteConfigWithSSR;
}
export interface SingleFileBuiltModule {
pageMap: Map<ComponentPath, ComponentInstance>;
renderers: SSRLoadedRenderer[];
}

View file

@ -1,7 +1,8 @@
import type { AstroConfig } from '../../@types/astro';
import type { Plugin as VitePlugin } from 'vite';
import type { BuildInternals } from '../../core/build/internal.js';
import { fileURLToPath } from 'url';
import { viteID } from '../util.js';
import { getPageDataByViteID } from './internal.js';
function virtualHoistedEntry(id: string) {
return id.endsWith('.astro/hoisted.js') || id.endsWith('.md/hoisted.js');
@ -37,8 +38,12 @@ export function vitePluginHoistedScripts(astroConfig: AstroConfig, internals: Bu
if (output.type === 'chunk' && output.facadeModuleId && virtualHoistedEntry(output.facadeModuleId)) {
const facadeId = output.facadeModuleId!;
const pathname = facadeId.slice(0, facadeId.length - '/hoisted.js'.length);
const filename = fileURLToPath(new URL('.' + pathname, astroConfig.projectRoot));
internals.facadeIdToHoistedEntryMap.set(filename, id);
const vid = viteID(new URL('.' + pathname, astroConfig.projectRoot));
const pageInfo = getPageDataByViteID(internals, vid);
if(pageInfo) {
pageInfo.hoistedScript = id;
}
}
}
},

View file

@ -0,0 +1,58 @@
import type { Plugin as VitePlugin } from 'vite';
import type { BuildInternals } from './internal.js';
import type { StaticBuildOptions } from './types';
import { addRollupInput } from './add-rollup-input.js';
import { eachPageData } from './internal.js';
import { isBuildingToSSR } from '../util.js';
export const virtualModuleId = '@astrojs-pages-virtual-entry';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
export function vitePluginPages(opts: StaticBuildOptions, internals: BuildInternals): VitePlugin {
return {
name: '@astro/plugin-build-pages',
options(options) {
if(!isBuildingToSSR(opts.astroConfig)) {
return addRollupInput(options, [virtualModuleId]);
}
},
resolveId(id) {
if(id === virtualModuleId) {
return resolvedVirtualModuleId;
}
},
load(id) {
if(id === resolvedVirtualModuleId) {
let importMap = '';
let imports = [];
let i = 0;
for(const pageData of eachPageData(internals)) {
const variable = `_page${i}`;
imports.push(`import * as ${variable} from '${pageData.moduleSpecifier}';`);
importMap += `['${pageData.component}', ${variable}],`;
i++;
}
i = 0;
let rendererItems = '';
for(const renderer of opts.astroConfig._ctx.renderers) {
const variable = `_renderer${i}`;
imports.push(`import ${variable} from '${renderer.serverEntrypoint}';`);
rendererItems += `Object.assign(${JSON.stringify(renderer)}, { ssr: ${variable} }),`
i++;
}
const def = `${imports.join('\n')}
export const pageMap = new Map([${importMap}]);
export const renderers = [${rendererItems}];`;
return def;
}
}
};
}

View file

@ -5,11 +5,13 @@ import type { AstroAdapter } from '../../@types/astro';
import type { StaticBuildOptions } from './types';
import type { SerializedRouteInfo, SerializedSSRManifest } from '../app/types';
import { chunkIsPage, rootRelativeFacadeId, getByFacadeId } from './generate.js';
import { serializeRouteData } from '../routing/index.js';
import { eachPageData } from './internal.js';
import { addRollupInput } from './add-rollup-input.js';
import { virtualModuleId as pagesVirtualModuleId } from './vite-plugin-pages.js';
import { BEFORE_HYDRATION_SCRIPT_ID } from '../../vite-plugin-scripts/index.js';
const virtualModuleId = '@astrojs-ssr-virtual-entry';
export const virtualModuleId = '@astrojs-ssr-virtual-entry';
const resolvedVirtualModuleId = '\0' + virtualModuleId;
const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@';
@ -17,13 +19,7 @@ export function vitePluginSSR(buildOpts: StaticBuildOptions, internals: BuildInt
return {
name: '@astrojs/vite-plugin-astro-ssr',
options(opts) {
if (Array.isArray(opts.input)) {
opts.input.push(virtualModuleId);
} else {
return {
input: [virtualModuleId],
};
}
return addRollupInput(opts, [virtualModuleId]);
},
resolveId(id) {
if (id === virtualModuleId) {
@ -33,8 +29,12 @@ export function vitePluginSSR(buildOpts: StaticBuildOptions, internals: BuildInt
load(id) {
if (id === resolvedVirtualModuleId) {
return `import * as adapter from '${adapter.serverEntrypoint}';
import * as _main from '${pagesVirtualModuleId}';
import { deserializeManifest as _deserializeManifest } from 'astro/app';
const _manifest = _deserializeManifest('${manifestReplace}');
const _manifest = Object.assign(_deserializeManifest('${manifestReplace}'), {
pageMap: _main.pageMap,
renderers: _main.renderers
});
${
adapter.exports
@ -52,57 +52,38 @@ if(_start in adapter) {
},
generateBundle(opts, bundle) {
const manifest = buildManifest(bundle, buildOpts, internals);
for (const [_chunkName, chunk] of Object.entries(bundle)) {
if (chunk.type === 'asset') continue;
if (chunk.modules[resolvedVirtualModuleId]) {
const manifest = buildManifest(buildOpts, internals);
for(const [_chunkName, chunk] of Object.entries(bundle)) {
if(chunk.type === 'asset') continue;
if(chunk.modules[resolvedVirtualModuleId]) {
const exp = new RegExp(`['"]${manifestReplace}['"]`);
const code = chunk.code;
chunk.code = code.replace(exp, () => {
return JSON.stringify(manifest);
});
chunk.fileName = 'entry.mjs';
}
}
},
};
}
function buildManifest(bundle: OutputBundle, opts: StaticBuildOptions, internals: BuildInternals): SerializedSSRManifest {
const { astroConfig, manifest } = opts;
const rootRelativeIdToChunkMap = new Map<string, OutputChunk>();
for (const [_outputName, output] of Object.entries(bundle)) {
if (chunkIsPage(astroConfig, output, internals)) {
const chunk = output as OutputChunk;
if (chunk.facadeModuleId) {
const id = rootRelativeFacadeId(chunk.facadeModuleId, astroConfig);
rootRelativeIdToChunkMap.set(id, chunk);
}
}
}
function buildManifest(opts: StaticBuildOptions, internals: BuildInternals): SerializedSSRManifest {
const { astroConfig } = opts;
const routes: SerializedRouteInfo[] = [];
for (const routeData of manifest.routes) {
const componentPath = routeData.component;
if (!rootRelativeIdToChunkMap.has(componentPath)) {
throw new Error('Unable to find chunk for ' + componentPath);
for(const pageData of eachPageData(internals)) {
const scripts = Array.from(pageData.scripts);
if(pageData.hoistedScript) {
scripts.unshift(pageData.hoistedScript);
}
const chunk = rootRelativeIdToChunkMap.get(componentPath)!;
const facadeId = chunk.facadeModuleId!;
const links = getByFacadeId<string[]>(facadeId, internals.facadeIdToAssetsMap) || [];
const hoistedScript = getByFacadeId<string>(facadeId, internals.facadeIdToHoistedEntryMap);
const scripts = hoistedScript ? [hoistedScript] : [];
routes.push({
file: chunk.fileName,
links,
file: '',
links: Array.from(pageData.css),
scripts,
routeData: serializeRouteData(routeData),
routeData: serializeRouteData(pageData.route),
});
}
@ -116,7 +97,8 @@ function buildManifest(bundle: OutputBundle, opts: StaticBuildOptions, internals
markdown: {
render: astroConfig.markdownOptions.render,
},
renderers: astroConfig._ctx.renderers,
pageMap: null as any,
renderers: [],
entryModules,
};

View file

@ -1,8 +1,8 @@
import type { RouteData, SerializedRouteData } from '../../../@types/astro';
function createRouteData(pattern: RegExp, params: string[], component: string, pathname: string | undefined): RouteData {
function createRouteData(pattern: RegExp, params: string[], component: string, pathname: string | undefined, type: 'page' | 'endpoint'): RouteData {
return {
type: 'page',
type,
pattern,
params,
component,
@ -20,7 +20,7 @@ export function serializeRouteData(routeData: RouteData): SerializedRouteData {
}
export function deserializeRouteData(rawRouteData: SerializedRouteData) {
const { component, params, pathname } = rawRouteData;
const { component, params, pathname, type } = rawRouteData;
const pattern = new RegExp(rawRouteData.pattern);
return createRouteData(pattern, params, component, pathname);
return createRouteData(pattern, params, component, pathname, type);
}

View file

@ -133,6 +133,10 @@ export function emptyDir(_dir: URL, skip?: Set<string>): void {
}
}
export function isBuildingToSSR(config: AstroConfig): boolean {
return !!config._ctx.adapter?.serverEntrypoint;
}
// Vendored from https://github.com/genmon/aboutfeeds/blob/main/tools/pretty-feed-v3.xsl
/** Basic stylesheet for RSS feeds */
export const PRETTY_FEED_V3 = `<?xml version="1.0" encoding="utf-8"?>
@ -235,3 +239,4 @@ This file is in BETA. Please test and contribute to the discussion:
</html>
</xsl:template>
</xsl:stylesheet>`;

View file

@ -4,6 +4,7 @@ import * as path from 'path';
import esbuild from 'esbuild';
import { Plugin as VitePlugin } from 'vite';
import { isCSSRequest } from '../core/render/dev/css.js';
import { getPageDatasByChunk } from '../core/build/internal.js';
const PLUGIN_NAME = '@astrojs/rollup-plugin-build-css';
@ -137,12 +138,8 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin {
internals.chunkToReferenceIdMap.set(chunk.fileName, referenceId);
if (chunk.type === 'chunk') {
const fileName = this.getFileName(referenceId);
if (chunk.facadeModuleId) {
const facadeId = chunk.facadeModuleId!;
if (!internals.facadeIdToAssetsMap.has(facadeId)) {
internals.facadeIdToAssetsMap.set(facadeId, []);
}
internals.facadeIdToAssetsMap.get(facadeId)!.push(fileName);
for(const pageData of getPageDatasByChunk(internals, chunk)) {
pageData.css.add(fileName);
}
}
@ -161,22 +158,15 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin {
for (const [chunkId, chunk] of Object.entries(bundle)) {
if (chunk.type === 'chunk') {
// If the chunk has a facadeModuleId it is an entrypoint chunk.
// This find shared chunks of CSS and adds them to the main CSS chunks,
// so that shared CSS is added to the page.
if (chunk.facadeModuleId) {
if (!internals.facadeIdToAssetsMap.has(chunk.facadeModuleId)) {
internals.facadeIdToAssetsMap.set(chunk.facadeModuleId, []);
}
const assets = internals.facadeIdToAssetsMap.get(chunk.facadeModuleId)!;
const assetSet = new Set(assets);
for(const { css: cssSet } of getPageDatasByChunk(internals, chunk)) {
for (const imp of chunk.imports) {
if (internals.chunkToReferenceIdMap.has(imp) && !pureChunkFilenames.has(imp)) {
const referenceId = internals.chunkToReferenceIdMap.get(imp)!;
const fileName = this.getFileName(referenceId);
if (!assetSet.has(fileName)) {
assetSet.add(fileName);
assets.push(fileName);
if (!cssSet.has(fileName)) {
cssSet.add(fileName);
}
}
}

View file

@ -0,0 +1,10 @@
export function get() {
return {
body: JSON.stringify([
{ name: 'lettuce' },
{ name: 'broccoli' },
{ name: 'pizza' }
])
};
}

View file

@ -0,0 +1,6 @@
<html>
<head><title>Testing</title></head>
<body>
<h1>Testing</h1>
</body>
</html>

View file

@ -0,0 +1,39 @@
import { expect } from 'chai';
import { loadFixture } from './test-utils.js';
import testAdapter from './test-adapter.js';
// Asset bundling
describe('API routes in SSR', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
fixture = await loadFixture({
projectRoot: './fixtures/ssr-api-route/',
buildOptions: {
experimentalSsr: true,
},
adapter: testAdapter()
});
await fixture.build();
});
it('Basic pages work', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/');
const response = await app.render(request);
const html = await response.text();
expect(html).to.not.be.empty;
});
it('Can load the API route too', async () => {
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/food.json');
const response = await app.render(request);
expect(response.status).to.equal(200);
expect(response.headers.get('Content-Type')).to.equal('application/json');
expect(response.headers.get('Content-Length')).to.not.be.empty;
const body = await response.json();
expect(body.length).to.equal(3);
});
});

View file

@ -2,10 +2,10 @@ import { expect } from 'chai';
import { load as cheerioLoad } from 'cheerio';
import { loadFixture } from './test-utils.js';
import testAdapter from './test-adapter.js';
import { App } from '../dist/core/app/index.js';
// Asset bundling
describe('Dynamic pages in SSR', () => {
/** @type {import('./test-utils').Fixture} */
let fixture;
before(async () => {
@ -20,8 +20,7 @@ describe('Dynamic pages in SSR', () => {
});
it('Do not have to implement getStaticPaths', async () => {
const { createApp } = await import('./fixtures/ssr-dynamic/dist/server/entry.mjs');
const app = createApp(new URL('./fixtures/ssr-dynamic/dist/server/', import.meta.url));
const app = await fixture.loadTestAdapterApp();
const request = new Request('http://example.com/123');
const response = await app.render(request);
const html = await response.text();

View file

@ -1,5 +1,5 @@
import { expect } from 'chai';
import cheerio from 'cheerio';
import { load as cheerioLoad } from 'cheerio';
import { loadFixture } from './test-utils.js';
function addLeadingSlash(path) {
@ -23,7 +23,7 @@ describe('Static build', () => {
it('can build pages using fetchContent', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
const $ = cheerioLoad(html);
const link = $('.posts a');
const href = link.attr('href');
expect(href).to.be.equal('/subpath/posts/thoughts');
@ -69,7 +69,7 @@ describe('Static build', () => {
function createFindEvidence(expected, prefix) {
return async function findEvidence(pathname) {
const html = await fixture.readFile(pathname);
const $ = cheerio.load(html);
const $ = cheerioLoad(html);
const links = $('link[rel=stylesheet]');
for (const link of links) {
const href = $(link).attr('href').slice('/subpath'.length);
@ -118,23 +118,23 @@ describe('Static build', () => {
describe('Hoisted scripts', () => {
it('Get bundled together on the page', async () => {
const html = await fixture.readFile('/hoisted/index.html');
const $ = cheerio.load(html);
const $ = cheerioLoad(html);
expect($('script[type="module"]').length).to.equal(1, 'hoisted script added');
});
it('Do not get added to the wrong page', async () => {
const hoistedHTML = await fixture.readFile('/hoisted/index.html');
const $ = cheerio.load(hoistedHTML);
const $ = cheerioLoad(hoistedHTML);
const href = $('script[type="module"]').attr('src');
const indexHTML = await fixture.readFile('/index.html');
const $$ = cheerio.load(indexHTML);
const $$ = cheerioLoad(indexHTML);
expect($$(`script[src="${href}"]`).length).to.equal(0, 'no script added to different page');
});
});
it('honors ssr config', async () => {
const html = await fixture.readFile('/index.html');
const $ = cheerio.load(html);
const $ = cheerioLoad(html);
expect($('#ssr-config').text()).to.equal('testing');
});
});

View file

@ -22,8 +22,8 @@ export default function () {
}
},
load(id) {
if (id === '@my-ssr') {
return `import { App } from 'astro/app';export function createExports(manifest) { return { manifest, createApp: (root) => new App(manifest, root) }; }`;
if(id === '@my-ssr') {
return `import { App } from 'astro/app';export function createExports(manifest) { return { manifest, createApp: () => new App(manifest) }; }`;
}
},
},

View file

@ -20,6 +20,7 @@ polyfill(globalThis, {
* @typedef {import('../src/core/dev/index').DevServer} DevServer
* @typedef {import('../src/@types/astro').AstroConfig} AstroConfig
* @typedef {import('../src/core/preview/index').PreviewServer} PreviewServer
* @typedef {import('../src/core/app/index').App} App
*
*
* @typedef {Object} Fixture
@ -30,6 +31,7 @@ polyfill(globalThis, {
* @property {() => Promise<DevServer>} startDevServer
* @property {() => Promise<PreviewServer>} preview
* @property {() => Promise<void>} clean
* @property {() => Promise<App>} loadTestAdapterApp
*/
/**
@ -85,6 +87,11 @@ export async function loadFixture(inlineConfig) {
readFile: (filePath) => fs.promises.readFile(new URL(filePath.replace(/^\//, ''), config.dist), 'utf8'),
readdir: (fp) => fs.promises.readdir(new URL(fp.replace(/^\//, ''), config.dist)),
clean: () => fs.promises.rm(config.dist, { maxRetries: 10, recursive: true, force: true }),
loadTestAdapterApp: async () => {
const url = new URL('./server/entry.mjs', config.dist);
const {createApp} = await import(url);
return createApp();
}
};
}

View file

@ -1,5 +1,6 @@
import type { SSRManifest } from 'astro';
import type { IncomingMessage, ServerResponse } from 'http';
import type { Readable } from 'stream';
import { NodeApp } from 'astro/app/node';
import { polyfill } from '@astrojs/webapi';
@ -8,7 +9,7 @@ polyfill(globalThis, {
});
export function createExports(manifest: SSRManifest) {
const app = new NodeApp(manifest, new URL(import.meta.url));
const app = new NodeApp(manifest);
return {
async handler(req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) {
const route = app.match(req);
@ -35,13 +36,8 @@ async function writeWebResponse(res: ServerResponse, webResponse: Response) {
const { status, headers, body } = webResponse;
res.writeHead(status, Object.fromEntries(headers.entries()));
if (body) {
const reader = body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
res.write(value);
}
for await(const chunk of (body as unknown as Readable)) {
res.write(chunk);
}
}
res.end();