diff options
25 files changed, 428 insertions, 188 deletions
diff --git a/.changeset/small-horses-protect.md b/.changeset/small-horses-protect.md new file mode 100644 index 000000000..ba6ec4fd7 --- /dev/null +++ b/.changeset/small-horses-protect.md @@ -0,0 +1,6 @@ +--- +'astro': patch +'@astrojs/node': patch +--- + +Improves the build by building to a single file for rendering diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index b14fad504..050802ee0 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -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 + }); } } } diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index 310244c74..8eee93d0f 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -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); } diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index d78a94050..ea4bd9cc0 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -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>; } diff --git a/packages/astro/src/core/build/add-rollup-input.ts b/packages/astro/src/core/build/add-rollup-input.ts new file mode 100644 index 000000000..79feb3a7d --- /dev/null +++ b/packages/astro/src/core/build/add-rollup-input.ts @@ -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.`); +} diff --git a/packages/astro/src/core/build/common.ts b/packages/astro/src/core/build/common.ts index fca513781..074170762 100644 --- a/packages/astro/src/core/build/common.ts +++ b/packages/astro/src/core/build/common.ts @@ -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'; diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts index d3d2365b4..cbd5b3c2b 100644 --- a/packages/astro/src/core/build/generate.ts +++ b/packages/astro/src/core/build/generate.ts @@ -1,20 +1,21 @@ +import type { OutputAsset, OutputChunk, RollupOutput } from 'rollup'; +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 { 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 { BuildInternals } from '../../core/build/internal.js'; 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 url = new URL('./' + output.fileName, getOutRoot(astroConfig)); - const facadeId: string = output.facadeModuleId as string; - let pageData = getByFacadeId<PageBuildData>(facadeId, facadeIdToPageDataMap); + let timeStart = performance.now(); + const renderers = ssrEntry.renderers; - 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 pageInfo = getPageDataByComponent(internals, pageData.route.component); + const linkIds: string[] = Array.from(pageInfo?.css ?? []); + const hoistedId = pageInfo?.hoistedScript ?? null; - const linkIds = getByFacadeId<string[]>(facadeId, internals.facadeIdToAssetsMap) || []; - const hoistedId = getByFacadeId<string>(facadeId, internals.facadeIdToHoistedEntryMap) || null; + const pageModule = ssrEntry.pageMap.get(pageData.component); - let compiledModule = await import(url.toString()); + 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 generationOptions: Readonly<GeneratePathOptions> = { pageData, internals, linkIds, hoistedId, - mod: compiledModule, + mod: pageModule, renderers, }; diff --git a/packages/astro/src/core/build/internal.ts b/packages/astro/src/core/build/internal.ts index 9185e7e89..62186f678 100644 --- a/packages/astro/src/core/build/internal.ts +++ b/packages/astro/src/core/build/internal.ts @@ -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(); +} diff --git a/packages/astro/src/core/build/page-data.ts b/packages/astro/src/core/build/page-data.ts index 946ebbefc..7681c0664 100644 --- a/packages/astro/src/core/build/page-data.ts +++ b/packages/astro/src/core/build/page-data.ts @@ -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), diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 62e148c50..64579c2c8 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -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, diff --git a/packages/astro/src/core/build/types.d.ts b/packages/astro/src/core/build/types.d.ts index 33278ea8b..9bdab7582 100644 --- a/packages/astro/src/core/build/types.d.ts +++ b/packages/astro/src/core/build/types.d.ts @@ -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[]; +} diff --git a/packages/astro/src/core/build/vite-plugin-hoisted-scripts.ts b/packages/astro/src/core/build/vite-plugin-hoisted-scripts.ts index 13adf93f3..bad98209e 100644 --- a/packages/astro/src/core/build/vite-plugin-hoisted-scripts.ts +++ b/packages/astro/src/core/build/vite-plugin-hoisted-scripts.ts @@ -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; + } } } }, diff --git a/packages/astro/src/core/build/vite-plugin-pages.ts b/packages/astro/src/core/build/vite-plugin-pages.ts new file mode 100644 index 000000000..1a01e9672 --- /dev/null +++ b/packages/astro/src/core/build/vite-plugin-pages.ts @@ -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; + } + } + }; +} diff --git a/packages/astro/src/core/build/vite-plugin-ssr.ts b/packages/astro/src/core/build/vite-plugin-ssr.ts index 0ea9f1d84..989a0ceb8 100644 --- a/packages/astro/src/core/build/vite-plugin-ssr.ts +++ b/packages/astro/src/core/build/vite-plugin-ssr.ts @@ -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, }; diff --git a/packages/astro/src/core/routing/manifest/serialization.ts b/packages/astro/src/core/routing/manifest/serialization.ts index 521a31931..8e3a42204 100644 --- a/packages/astro/src/core/routing/manifest/serialization.ts +++ b/packages/astro/src/core/routing/manifest/serialization.ts @@ -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); } diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts index 17f06854d..ce5e307b9 100644 --- a/packages/astro/src/core/util.ts +++ b/packages/astro/src/core/util.ts @@ -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>`; + diff --git a/packages/astro/src/vite-plugin-build-css/index.ts b/packages/astro/src/vite-plugin-build-css/index.ts index e630cd578..e6f8b5fc6 100644 --- a/packages/astro/src/vite-plugin-build-css/index.ts +++ b/packages/astro/src/vite-plugin-build-css/index.ts @@ -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); } } } diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/food.json.js b/packages/astro/test/fixtures/ssr-api-route/src/pages/food.json.js new file mode 100644 index 000000000..0003f2ad4 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-api-route/src/pages/food.json.js @@ -0,0 +1,10 @@ + +export function get() { + return { + body: JSON.stringify([ + { name: 'lettuce' }, + { name: 'broccoli' }, + { name: 'pizza' } + ]) + }; +} diff --git a/packages/astro/test/fixtures/ssr-api-route/src/pages/index.astro b/packages/astro/test/fixtures/ssr-api-route/src/pages/index.astro new file mode 100644 index 000000000..53e029f04 --- /dev/null +++ b/packages/astro/test/fixtures/ssr-api-route/src/pages/index.astro @@ -0,0 +1,6 @@ +<html> +<head><title>Testing</title></head> +<body> + <h1>Testing</h1> +</body> +</html> diff --git a/packages/astro/test/ssr-api-route.test.js b/packages/astro/test/ssr-api-route.test.js new file mode 100644 index 000000000..4555dd56d --- /dev/null +++ b/packages/astro/test/ssr-api-route.test.js @@ -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); + }); +}); diff --git a/packages/astro/test/ssr-dynamic.test.js b/packages/astro/test/ssr-dynamic.test.js index 30d9fdd11..843243425 100644 --- a/packages/astro/test/ssr-dynamic.test.js +++ b/packages/astro/test/ssr-dynamic.test.js @@ -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(); diff --git a/packages/astro/test/static-build.test.js b/packages/astro/test/static-build.test.js index 0f04724ca..89860505e 100644 --- a/packages/astro/test/static-build.test.js +++ b/packages/astro/test/static-build.test.js @@ -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'); }); }); diff --git a/packages/astro/test/test-adapter.js b/packages/astro/test/test-adapter.js index 2665cc4c1..90ae3aa13 100644 --- a/packages/astro/test/test-adapter.js +++ b/packages/astro/test/test-adapter.js @@ -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) }; }`; } }, }, diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index 2bceb2748..88d29cc5a 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -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(); + } }; } diff --git a/packages/integrations/node/src/server.ts b/packages/integrations/node/src/server.ts index 79a51cdfe..d643e8367 100644 --- a/packages/integrations/node/src/server.ts +++ b/packages/integrations/node/src/server.ts @@ -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(); |