From 5e52814d97a5723dbe7ebb32fbe040a7a4c0ea77 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Thu, 24 Mar 2022 07:26:25 -0400 Subject: [PATCH] Adapters v0 (#2855) * Adapter v0 * Finalizing adapters * Update the lockfile * Add the default adapter after config setup is called * Create the default adapter in config:done * Fix lint error * Remove unused callConfigSetup * remove unused export * Use a test adapter to test SSR * Adds a changeset * Updated based on feedback * Updated the lockfile * Only throw if set to a different adapter * Clean up outdated comments * Move the adapter to an config option * Make adapter optional * Update the docs/changeset to reflect config API change * Clarify regular Node usage --- .changeset/hot-plants-help.md | 29 ++ examples/ssr/astro.config.mjs | 2 + examples/ssr/build.mjs | 12 - examples/ssr/package.json | 4 +- examples/ssr/server/server.mjs | 48 +- packages/astro/package.json | 4 + packages/astro/src/@types/astro.ts | 27 +- packages/astro/src/adapter-ssg/index.ts | 23 + packages/astro/src/core/app/index.ts | 3 +- packages/astro/src/core/app/node.ts | 3 +- packages/astro/src/core/build/common.ts | 64 +++ packages/astro/src/core/build/generate.ts | 244 ++++++++++ packages/astro/src/core/build/index.ts | 6 +- packages/astro/src/core/build/static-build.ts | 456 +----------------- packages/astro/src/core/build/types.d.ts | 18 +- .../src/core/build/vite-plugin-internals.ts | 55 +++ .../astro/src/core/build/vite-plugin-ssr.ts | 119 +++++ packages/astro/src/core/config.ts | 4 +- packages/astro/src/integrations/index.ts | 30 +- .../astro/src/vite-plugin-build-css/index.ts | 2 +- packages/astro/test/ssr-dynamic.test.js | 9 +- packages/astro/test/test-adapter.js | 43 ++ packages/astro/test/test-utils.js | 1 - packages/integrations/node/package.json | 32 ++ packages/integrations/node/readme.md | 53 ++ packages/integrations/node/src/index.ts | 20 + packages/integrations/node/src/server.ts | 48 ++ packages/integrations/node/tsconfig.json | 10 + pnpm-lock.yaml | 13 + 29 files changed, 886 insertions(+), 496 deletions(-) create mode 100644 .changeset/hot-plants-help.md delete mode 100644 examples/ssr/build.mjs create mode 100644 packages/astro/src/adapter-ssg/index.ts create mode 100644 packages/astro/src/core/build/common.ts create mode 100644 packages/astro/src/core/build/generate.ts create mode 100644 packages/astro/src/core/build/vite-plugin-internals.ts create mode 100644 packages/astro/src/core/build/vite-plugin-ssr.ts create mode 100644 packages/astro/test/test-adapter.js create mode 100644 packages/integrations/node/package.json create mode 100644 packages/integrations/node/readme.md create mode 100644 packages/integrations/node/src/index.ts create mode 100644 packages/integrations/node/src/server.ts create mode 100644 packages/integrations/node/tsconfig.json diff --git a/.changeset/hot-plants-help.md b/.changeset/hot-plants-help.md new file mode 100644 index 000000000..4ea02e579 --- /dev/null +++ b/.changeset/hot-plants-help.md @@ -0,0 +1,29 @@ +--- +'astro': patch +--- + +Adds support for the Node adapter (SSR) + +This provides the first SSR adapter available using the `integrations` API. It is a Node.js adapter that can be used with the `http` module or any framework that wraps it, like Express. + +In your astro.config.mjs use: + +```js +import nodejs from '@astrojs/node'; + +export default { + adapter: nodejs() +} +``` + +After performing a build there will be a `dist/server/entry.mjs` module that works like a middleware function. You can use with any framework that supports the Node `request` and `response` objects. For example, with Express you can do: + +```js +import express from 'express'; +import { handler as ssrHandler } from '@astrojs/node'; + +const app = express(); +app.use(handler); + +app.listen(8080); +``` diff --git a/examples/ssr/astro.config.mjs b/examples/ssr/astro.config.mjs index 481576db1..f6aba20ce 100644 --- a/examples/ssr/astro.config.mjs +++ b/examples/ssr/astro.config.mjs @@ -1,8 +1,10 @@ import { defineConfig } from 'astro/config'; import svelte from '@astrojs/svelte'; +import nodejs from '@astrojs/node'; // https://astro.build/config export default defineConfig({ + adapter: nodejs(), integrations: [svelte()], vite: { server: { diff --git a/examples/ssr/build.mjs b/examples/ssr/build.mjs deleted file mode 100644 index 168c3c55f..000000000 --- a/examples/ssr/build.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { execa } from 'execa'; - -const api = execa('npm', ['run', 'dev-api']); -api.stdout.pipe(process.stdout); -api.stderr.pipe(process.stderr); - -const build = execa('pnpm', ['astro', 'build', '--experimental-ssr']); -build.stdout.pipe(process.stdout); -build.stderr.pipe(process.stderr); -await build; - -api.kill(); diff --git a/examples/ssr/package.json b/examples/ssr/package.json index 44eebe3a1..44ffc3bfa 100644 --- a/examples/ssr/package.json +++ b/examples/ssr/package.json @@ -7,12 +7,12 @@ "dev-server": "astro dev --experimental-ssr", "dev": "concurrently \"npm run dev-api\" \"astro dev --experimental-ssr\"", "start": "astro dev", - "build": "echo 'Run pnpm run build-ssr instead'", - "build-ssr": "node build.mjs", + "build": "astro build --experimental-ssr", "server": "node server/server.mjs" }, "devDependencies": { "@astrojs/svelte": "^0.0.2-next.0", + "@astrojs/node": "^0.0.1", "astro": "^0.25.0-next.2", "concurrently": "^7.0.0", "lightcookie": "^1.0.25", diff --git a/examples/ssr/server/server.mjs b/examples/ssr/server/server.mjs index c6f35685e..bed49b749 100644 --- a/examples/ssr/server/server.mjs +++ b/examples/ssr/server/server.mjs @@ -1,43 +1,31 @@ import { createServer } from 'http'; import fs from 'fs'; import mime from 'mime'; -import { loadApp } from 'astro/app/node'; -import { polyfill } from '@astrojs/webapi'; import { apiHandler } from './api.mjs'; - -polyfill(globalThis); +import { handler as ssrHandler } from '../dist/server/entry.mjs'; const clientRoot = new URL('../dist/client/', import.meta.url); -const serverRoot = new URL('../dist/server/', import.meta.url); -const app = await loadApp(serverRoot); async function handle(req, res) { - const route = app.match(req); + ssrHandler(req, res, async () => { + // Did not match an SSR route - if (route) { - /** @type {Response} */ - const response = await app.render(req, route); - const html = await response.text(); - res.writeHead(response.status, { - 'Content-Type': 'text/html; charset=utf-8', - 'Content-Length': Buffer.byteLength(html, 'utf-8'), - }); - res.end(html); - } else if (/^\/api\//.test(req.url)) { - return apiHandler(req, res); - } else { - let local = new URL('.' + req.url, clientRoot); - try { - const data = await fs.promises.readFile(local); - res.writeHead(200, { - 'Content-Type': mime.getType(req.url), - }); - res.end(data); - } catch { - res.writeHead(404); - res.end(); + if (/^\/api\//.test(req.url)) { + return apiHandler(req, res); + } else { + let local = new URL('.' + req.url, clientRoot); + try { + const data = await fs.promises.readFile(local); + res.writeHead(200, { + 'Content-Type': mime.getType(req.url), + }); + res.end(data); + } catch { + res.writeHead(404); + res.end(); + } } - } + }); } const server = createServer((req, res) => { diff --git a/packages/astro/package.json b/packages/astro/package.json index f36d37879..9f4493527 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -13,11 +13,15 @@ "bugs": "https://github.com/withastro/astro/issues", "homepage": "https://astro.build", "types": "./dist/types/@types/astro.d.ts", + "typesVersions": { + "*": { "app/*": ["./dist/types/core/app/*"] } + }, "exports": { ".": "./astro.js", "./env": "./env.d.ts", "./config": "./config.mjs", "./internal": "./internal.js", + "./app": "./dist/core/app/index.js", "./app/node": "./dist/core/app/node.js", "./client/*": "./dist/runtime/client/*", "./components": "./components/index.js", diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index c393e7c4d..9f2668d4f 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -5,6 +5,7 @@ import type { z } from 'zod'; import type { AstroConfigSchema } from '../core/config'; import type { AstroComponentFactory, Metadata } from '../runtime/server'; import type { AstroRequest } from '../core/render/request'; +export type { SSRManifest } from '../core/app/types'; export interface AstroBuiltinProps { 'client:load'?: boolean; @@ -37,6 +38,10 @@ export interface CLIFlags { drafts?: boolean; } +export interface BuildConfig { + staticMode: boolean | undefined; +} + /** * Astro.* available in all components * Docs: https://docs.astro.build/reference/api-reference/#astro-global @@ -154,6 +159,16 @@ export interface AstroUserConfig { */ integrations?: AstroIntegration[]; + /** + * @docs + * @name adapter + * @type {AstroIntegration} + * @default `undefined` + * @description + * Add an adapter to build for SSR (server-side rendering). An adapter makes it easy to connect a deployed Astro app to a hosting provider or runtime environment. + */ + adapter?: AstroIntegration; + /** @deprecated - Use "integrations" instead. Run Astro to learn more about migrating. */ renderers?: string[]; @@ -461,11 +476,13 @@ export interface AstroConfig extends z.output { // This is a more detailed type than zod validation gives us. // TypeScript still confirms zod validation matches this type. integrations: AstroIntegration[]; + adapter?: AstroIntegration; // Private: // We have a need to pass context based on configured state, // that is different from the user-exposed configuration. // TODO: Create an AstroConfig class to manage this, long-term. _ctx: { + adapter: AstroAdapter | undefined; renderers: AstroRenderer[]; scripts: { stage: InjectedScriptStage; content: string }[]; }; @@ -596,6 +613,12 @@ export type Props = Record; type Body = string; +export interface AstroAdapter { + name: string; + serverEntrypoint?: string; + exports?: string[]; +} + export interface EndpointOutput { body: Output; } @@ -642,11 +665,11 @@ export interface AstroIntegration { // more generalized. Consider the SSR use-case as well. // injectElement: (stage: vite.HtmlTagDescriptor, element: string) => void; }) => void; - 'astro:config:done'?: (options: { config: AstroConfig }) => void | Promise; + 'astro:config:done'?: (options: {config: AstroConfig, setAdapter: (adapter: AstroAdapter) => void; }) => void | Promise; 'astro:server:setup'?: (options: { server: vite.ViteDevServer }) => void | Promise; 'astro:server:start'?: (options: { address: AddressInfo }) => void | Promise; 'astro:server:done'?: () => void | Promise; - 'astro:build:start'?: () => void | Promise; + 'astro:build:start'?: (options: { buildConfig: BuildConfig }) => void | Promise; 'astro:build:done'?: (options: { pages: { pathname: string }[]; dir: URL }) => void | Promise; }; } diff --git a/packages/astro/src/adapter-ssg/index.ts b/packages/astro/src/adapter-ssg/index.ts new file mode 100644 index 000000000..ca1f7127d --- /dev/null +++ b/packages/astro/src/adapter-ssg/index.ts @@ -0,0 +1,23 @@ +import type { AstroAdapter, AstroIntegration } from '../@types/astro'; + +export function getAdapter(): AstroAdapter { + return { + name: '@astrojs/ssg', + // This one has no server entrypoint and is mostly just an integration + //serverEntrypoint: '@astrojs/ssg/server.js', + }; +} + +export default function createIntegration(): AstroIntegration { + return { + name: '@astrojs/ssg', + hooks: { + 'astro:config:done': ({ setAdapter }) => { + setAdapter(getAdapter()); + }, + 'astro:build:start': ({ buildConfig }) => { + buildConfig.staticMode = true; + } + } + }; +} diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index c05b713ab..b14fad504 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -2,6 +2,7 @@ import type { ComponentInstance, ManifestData, RouteData, SSRLoadedRenderer } fr import type { SSRManifest as Manifest, RouteInfo } from './types'; import { defaultLogOptions } from '../logger.js'; +export { deserializeManifest } from './common.js'; import { matchRoute } from '../routing/match.js'; import { render } from '../render/core.js'; import { RouteCache } from '../render/route-cache.js'; @@ -64,7 +65,7 @@ export class App { throw new Error(`Unable to resolve [${specifier}]`); } const bundlePath = manifest.entryModules[specifier]; - return prependForwardSlash(bundlePath); + return bundlePath.startsWith('data:') ? bundlePath : prependForwardSlash(bundlePath); }, route: routeData, routeCache: this.#routeCache, diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index 8d6968c69..310244c74 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -1,5 +1,4 @@ import type { SSRManifest, SerializedSSRManifest } from './types'; -import type { IncomingHttpHeaders } from 'http'; import * as fs from 'fs'; import { App } from './index.js'; @@ -16,7 +15,7 @@ function createRequestFromNodeRequest(req: IncomingMessage): Request { return request; } -class NodeApp extends App { +export class NodeApp extends App { match(req: IncomingMessage | Request) { return super.match(req instanceof Request ? req : createRequestFromNodeRequest(req)); } diff --git a/packages/astro/src/core/build/common.ts b/packages/astro/src/core/build/common.ts new file mode 100644 index 000000000..fca513781 --- /dev/null +++ b/packages/astro/src/core/build/common.ts @@ -0,0 +1,64 @@ +import type { AstroConfig, RouteType } from '../../@types/astro'; +import npath from 'path'; +import { appendForwardSlash } from '../../core/path.js'; + +const STATUS_CODE_PAGES = new Set(['/404', '/500']); + +export function getOutRoot(astroConfig: AstroConfig): URL { + return new URL('./', astroConfig.dist); +} + +export function getServerRoot(astroConfig: AstroConfig): URL { + const rootFolder = getOutRoot(astroConfig); + const serverFolder = new URL('./server/', rootFolder); + return serverFolder; +} + +export function getClientRoot(astroConfig: AstroConfig): URL { + const rootFolder = getOutRoot(astroConfig); + const serverFolder = new URL('./client/', rootFolder); + return serverFolder; +} + +export function getOutFolder(astroConfig: AstroConfig, pathname: string, routeType: RouteType): URL { + const outRoot = getOutRoot(astroConfig); + + // This is the root folder to write to. + switch (routeType) { + case 'endpoint': + return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot); + case 'page': + switch (astroConfig.buildOptions.pageUrlFormat) { + case 'directory': { + if (STATUS_CODE_PAGES.has(pathname)) { + return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot); + } + return new URL('.' + appendForwardSlash(pathname), outRoot); + } + case 'file': { + return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot); + } + } + } +} + +export function getOutFile(astroConfig: AstroConfig, outFolder: URL, pathname: string, routeType: RouteType): URL { + switch (routeType) { + case 'endpoint': + return new URL(npath.basename(pathname), outFolder); + case 'page': + switch (astroConfig.buildOptions.pageUrlFormat) { + case 'directory': { + if (STATUS_CODE_PAGES.has(pathname)) { + const baseName = npath.basename(pathname); + return new URL('./' + (baseName || 'index') + '.html', outFolder); + } + return new URL('./index.html', outFolder); + } + case 'file': { + const baseName = npath.basename(pathname); + return new URL('./' + (baseName || 'index') + '.html', outFolder); + } + } + } +} diff --git a/packages/astro/src/core/build/generate.ts b/packages/astro/src/core/build/generate.ts new file mode 100644 index 000000000..9d3d9f8de --- /dev/null +++ b/packages/astro/src/core/build/generate.ts @@ -0,0 +1,244 @@ +import type { OutputAsset, OutputChunk, RollupOutput } from 'rollup'; +import type { PageBuildData } from './types'; +import type { AstroConfig, AstroRenderer, ComponentInstance, EndpointHandler, SSRLoadedRenderer } from '../../@types/astro'; +import type { StaticBuildOptions } 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 } from '../../core/logger.js'; +import { prependForwardSlash } from '../../core/path.js'; +import { resolveDependency } from '../../core/util.js'; +import { call as callEndpoint } from '../endpoint/index.js'; +import { render } from '../render/core.js'; +import { createLinkStylesheetElementSet, createModuleScriptElementWithSrcSet } from '../render/ssr-element.js'; +import { getOutRoot, getOutFolder, getOutFile } from './common.js'; + + +// Render is usually compute, which Node.js can't parallelize well. +// In real world testing, dropping from 10->1 showed a notiable perf +// improvement. In the future, we can revisit a smarter parallel +// 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 { + const mod = (await import(resolveDependency(renderer.serverEntrypoint, config))) as { default: SSRLoadedRenderer['ssr'] }; + return { ...renderer, ssr: mod.default }; +} + +async function loadRenderers(config: AstroConfig): Promise { + return Promise.all(config._ctx.renderers.map((r) => loadRenderer(r, config))); +} + +export function getByFacadeId(facadeId: string, map: Map): 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 = []; + let i = 0; + for (let path of inPaths) { + tmp.push(path); + if (i === max) { + yield tmp; + // Empties the array, to avoid allocating a new one. + tmp.length = 0; + i = 0; + } else { + i++; + } + } + + // If tmp has items in it, that means there were less than {max} paths remaining + // at the end, so we need to yield these too. + if (tmp.length) { + yield tmp; + } +} + +// Gives back a facadeId that is relative to the root. +// ie, src/pages/index.astro instead of /Users/name..../src/pages/index.astro +export function rootRelativeFacadeId(facadeId: string, astroConfig: AstroConfig): string { + return facadeId.slice(fileURLToPath(astroConfig.projectRoot).length); +} + +// Determines of a Rollup chunk is an entrypoint page. +export function chunkIsPage(astroConfig: AstroConfig, output: OutputAsset | OutputChunk, internals: BuildInternals) { + if (output.type !== 'chunk') { + return false; + } + const chunk = output as OutputChunk; + if (chunk.facadeModuleId) { + const facadeToEntryId = prependForwardSlash(rootRelativeFacadeId(chunk.facadeModuleId, astroConfig)); + return internals.entrySpecifierToBundleMap.has(facadeToEntryId); + } + return false; +} + +export async function generatePages(result: RollupOutput, opts: StaticBuildOptions, internals: BuildInternals, facadeIdToPageDataMap: Map) { + debug('build', 'Finish build. Begin generating.'); + + // Get renderers to be shared for each page generation. + const renderers = await loadRenderers(opts.astroConfig); + + for (let output of result.output) { + if (chunkIsPage(opts.astroConfig, output, internals)) { + await generatePage(output as OutputChunk, opts, internals, facadeIdToPageDataMap, renderers); + } + } +} + +async function generatePage( + output: OutputChunk, + opts: StaticBuildOptions, + internals: BuildInternals, + facadeIdToPageDataMap: Map, + renderers: SSRLoadedRenderer[] +) { + const { astroConfig } = opts; + + let url = new URL('./' + output.fileName, getOutRoot(astroConfig)); + const facadeId: string = output.facadeModuleId as string; + let pageData = getByFacadeId(facadeId, facadeIdToPageDataMap); + + 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 linkIds = getByFacadeId(facadeId, internals.facadeIdToAssetsMap) || []; + const hoistedId = getByFacadeId(facadeId, internals.facadeIdToHoistedEntryMap) || null; + + let compiledModule = await import(url.toString()); + + const generationOptions: Readonly = { + pageData, + internals, + linkIds, + hoistedId, + mod: compiledModule, + renderers, + }; + + const renderPromises = []; + // Throttle the paths to avoid overloading the CPU with too many tasks. + for (const paths of throttle(MAX_CONCURRENT_RENDERS, pageData.paths)) { + for (const path of paths) { + renderPromises.push(generatePath(path, opts, generationOptions)); + } + // This blocks generating more paths until these 10 complete. + await Promise.all(renderPromises); + // This empties the array without allocating a new one. + renderPromises.length = 0; + } +} + +interface GeneratePathOptions { + pageData: PageBuildData; + internals: BuildInternals; + linkIds: string[]; + hoistedId: string | null; + mod: ComponentInstance; + renderers: SSRLoadedRenderer[]; +} + + +function addPageName(pathname: string, opts: StaticBuildOptions): void { + opts.pageNames.push(pathname.replace(/\/?$/, '/').replace(/^\//, '')); +} + +async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) { + const { astroConfig, logging, origin, routeCache } = opts; + const { mod, internals, linkIds, hoistedId, pageData, renderers } = gopts; + + // This adds the page name to the array so it can be shown as part of stats. + if (pageData.route.type === 'page') { + addPageName(pathname, opts); + } + + debug('build', `Generating: ${pathname}`); + + const site = astroConfig.buildOptions.site; + const links = createLinkStylesheetElementSet(linkIds.reverse(), site); + const scripts = createModuleScriptElementWithSrcSet(hoistedId ? [hoistedId] : [], site); + + // Add all injected scripts to the page. + for (const script of astroConfig._ctx.scripts) { + if (script.stage === 'head-inline') { + scripts.add({ + props: {}, + children: script.content, + }); + } + } + + try { + const options: RenderOptions = { + legacyBuild: false, + links, + logging, + markdownRender: astroConfig.markdownOptions.render, + mod, + origin, + pathname, + scripts, + renderers, + async resolve(specifier: string) { + const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier); + if (typeof hashedFilePath !== 'string') { + // If no "astro:scripts/before-hydration.js" script exists in the build, + // then we can assume that no before-hydration scripts are needed. + // Return this as placeholder, which will be ignored by the browser. + // TODO: In the future, we hope to run this entire script through Vite, + // removing the need to maintain our own custom Vite-mimic resolve logic. + if (specifier === 'astro:scripts/before-hydration.js') { + return 'data:text/javascript;charset=utf-8,//[no before-hydration script]'; + } + throw new Error(`Cannot find the built path for ${specifier}`); + } + const relPath = npath.posix.relative(pathname, '/' + hashedFilePath); + const fullyRelativePath = relPath[0] === '.' ? relPath : './' + relPath; + return fullyRelativePath; + }, + method: 'GET', + headers: new Headers(), + route: pageData.route, + routeCache, + site: astroConfig.buildOptions.site, + ssr: opts.astroConfig.buildOptions.experimentalSsr, + }; + + let body: string; + if (pageData.route.type === 'endpoint') { + const result = await callEndpoint(mod as unknown as EndpointHandler, options); + + if (result.type === 'response') { + throw new Error(`Returning a Response from an endpoint is not supported in SSG mode.`); + } + body = result.body; + } else { + const result = await render(options); + + // If there's a redirect or something, just do nothing. + if (result.type !== 'html') { + return; + } + body = result.html; + } + + const outFolder = getOutFolder(astroConfig, pathname, pageData.route.type); + const outFile = getOutFile(astroConfig, outFolder, pathname, pageData.route.type); + await fs.promises.mkdir(outFolder, { recursive: true }); + await fs.promises.writeFile(outFile, body, 'utf-8'); + } catch (err) { + error(opts.logging, 'build', `Error rendering:`, err); + } +} diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 8996fc559..5b3bd3a36 100644 --- a/packages/astro/src/core/build/index.ts +++ b/packages/astro/src/core/build/index.ts @@ -1,4 +1,4 @@ -import type { AstroConfig, ManifestData } from '../../@types/astro'; +import type { AstroConfig, BuildConfig, ManifestData } from '../../@types/astro'; import type { LogOptions } from '../logger'; import fs from 'fs'; @@ -74,7 +74,8 @@ class AstroBuilder { const viteServer = await vite.createServer(viteConfig); this.viteServer = viteServer; debug('build', timerMessage('Vite started', timer.viteStart)); - await runHookBuildStart({ config: this.config }); + const buildConfig: BuildConfig = { staticMode: undefined }; + await runHookBuildStart({ config: this.config, buildConfig }); timer.loadStart = performance.now(); const { assets, allPages } = await collectPagesData({ @@ -119,6 +120,7 @@ class AstroBuilder { pageNames, routeCache: this.routeCache, viteConfig: this.viteConfig, + buildConfig, }); } else { await scanBasedBuild({ diff --git a/packages/astro/src/core/build/static-build.ts b/packages/astro/src/core/build/static-build.ts index 36a3af840..2ae97ae53 100644 --- a/packages/astro/src/core/build/static-build.ts +++ b/packages/astro/src/core/build/static-build.ts @@ -1,108 +1,26 @@ +import type { RollupOutput } from 'rollup'; +import type { BuildInternals } from '../../core/build/internal.js'; +import type { ViteConfigWithSSR } from '../create-vite'; +import type { PageBuildData, StaticBuildOptions } from './types'; + import glob from 'fast-glob'; import fs from 'fs'; import npath from 'path'; -import type { OutputAsset, OutputChunk, RollupOutput } from 'rollup'; import { fileURLToPath } from 'url'; -import type { Manifest as ViteManifest, Plugin as VitePlugin, UserConfig } from 'vite'; import * as vite from 'vite'; -import type { AstroConfig, AstroRenderer, ComponentInstance, EndpointHandler, ManifestData, RouteType, SSRLoadedRenderer } from '../../@types/astro'; -import type { BuildInternals } from '../../core/build/internal.js'; import { createBuildInternals } from '../../core/build/internal.js'; -import { debug, error } from '../../core/logger.js'; import { appendForwardSlash, prependForwardSlash } from '../../core/path.js'; -import type { RenderOptions } from '../../core/render/core'; -import { emptyDir, removeDir, resolveDependency } from '../../core/util.js'; +import { emptyDir, removeDir } from '../../core/util.js'; import { rollupPluginAstroBuildCSS } from '../../vite-plugin-build-css/index.js'; -import type { SerializedRouteInfo, SerializedSSRManifest } from '../app/types'; -import type { ViteConfigWithSSR } from '../create-vite'; -import { call as callEndpoint } from '../endpoint/index.js'; -import type { LogOptions } from '../logger'; -import { render } from '../render/core.js'; -import { RouteCache } from '../render/route-cache.js'; -import { createLinkStylesheetElementSet, createModuleScriptElementWithSrcSet } from '../render/ssr-element.js'; -import { serializeRouteData } from '../routing/index.js'; -import type { AllPagesData, PageBuildData } from './types'; import { vitePluginHoistedScripts } from './vite-plugin-hoisted-scripts.js'; - -export interface StaticBuildOptions { - allPages: AllPagesData; - astroConfig: AstroConfig; - logging: LogOptions; - manifest: ManifestData; - origin: string; - pageNames: string[]; - routeCache: RouteCache; - viteConfig: ViteConfigWithSSR; -} - -// Render is usually compute, which Node.js can't parallelize well. -// In real world testing, dropping from 10->1 showed a notiable perf -// improvement. In the future, we can revisit a smarter parallel -// system, possibly one that parallelizes if async IO is detected. -const MAX_CONCURRENT_RENDERS = 1; - -const STATUS_CODE_PAGES = new Set(['/404', '/500']); - -function addPageName(pathname: string, opts: StaticBuildOptions): void { - opts.pageNames.push(pathname.replace(/\/?$/, '/').replace(/^\//, '')); -} - -// Gives back a facadeId that is relative to the root. -// ie, src/pages/index.astro instead of /Users/name..../src/pages/index.astro -function rootRelativeFacadeId(facadeId: string, astroConfig: AstroConfig): string { - return facadeId.slice(fileURLToPath(astroConfig.projectRoot).length); -} - -// Determines of a Rollup chunk is an entrypoint page. -function chunkIsPage(astroConfig: AstroConfig, output: OutputAsset | OutputChunk, internals: BuildInternals) { - if (output.type !== 'chunk') { - return false; - } - const chunk = output as OutputChunk; - if (chunk.facadeModuleId) { - const facadeToEntryId = prependForwardSlash(rootRelativeFacadeId(chunk.facadeModuleId, astroConfig)); - return internals.entrySpecifierToBundleMap.has(facadeToEntryId); - } - return false; -} - -// Throttle the rendering a paths to prevents creating too many Promises on the microtask queue. -function* throttle(max: number, inPaths: string[]) { - let tmp = []; - let i = 0; - for (let path of inPaths) { - tmp.push(path); - if (i === max) { - yield tmp; - // Empties the array, to avoid allocating a new one. - tmp.length = 0; - i = 0; - } else { - i++; - } - } - - // If tmp has items in it, that means there were less than {max} paths remaining - // at the end, so we need to yield these too. - if (tmp.length) { - yield tmp; - } -} - -function getByFacadeId(facadeId: string, map: Map): T | undefined { - return ( - map.get(facadeId) || - // Windows the facadeId has forward slashes, no idea why - map.get(facadeId.replace(/\//g, '\\')) - ); -} +import { vitePluginInternals } from './vite-plugin-internals.js'; +import { vitePluginSSR } from './vite-plugin-ssr.js'; +import { generatePages } from './generate.js'; +import { getClientRoot, getServerRoot, getOutRoot } from './common.js'; export async function staticBuild(opts: StaticBuildOptions) { const { allPages, astroConfig } = opts; - // Basic options - const staticMode = !astroConfig.buildOptions.experimentalSsr; - // The pages to be built for rendering purposes. const pageInput = new Set(); @@ -158,18 +76,16 @@ export async function staticBuild(opts: StaticBuildOptions) { // condition, so we are doing it ourselves emptyDir(astroConfig.dist, new Set('.git')); + // Run client build first, so the assets can be fed into the SSR rendered version. + await clientBuild(opts, internals, jsInput); + // Build your project (SSR application code, assets, client JS, etc.) const ssrResult = (await ssrBuild(opts, internals, pageInput)) as RollupOutput; - await clientBuild(opts, internals, jsInput); - - // SSG mode, generate pages. - if (staticMode) { - // Generate each of the pages. + if(opts.buildConfig.staticMode) { await generatePages(ssrResult, opts, internals, facadeIdToPageDataMap); await cleanSsrOutput(opts); } else { - await generateManifest(ssrResult, opts, internals); await ssrMoveAssets(opts); } } @@ -186,13 +102,10 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp build: { ...viteConfig.build, emptyOutDir: false, - manifest: ssr, + manifest: false, outDir: fileURLToPath(out), ssr: true, rollupOptions: { - // onwarn(warn) { - // console.log(warn); - // }, input: Array.from(input), output: { format: 'esm', @@ -209,11 +122,14 @@ async function ssrBuild(opts: StaticBuildOptions, internals: BuildInternals, inp reportCompressedSize: false, }, plugins: [ - vitePluginNewBuild(input, internals, 'mjs'), + vitePluginInternals(input, internals), rollupPluginAstroBuildCSS({ internals, }), ...(viteConfig.plugins || []), + // SSR needs to be last + opts.astroConfig._ctx.adapter?.serverEntrypoint && + vitePluginSSR(opts, internals, opts.astroConfig._ctx.adapter), ], publicDir: ssr ? false : viteConfig.publicDir, root: viteConfig.root, @@ -256,7 +172,7 @@ async function clientBuild(opts: StaticBuildOptions, internals: BuildInternals, target: 'esnext', // must match an esbuild target }, plugins: [ - vitePluginNewBuild(input, internals, 'js'), + vitePluginInternals(input, internals), vitePluginHoistedScripts(astroConfig, internals), rollupPluginAstroBuildCSS({ internals, @@ -271,283 +187,6 @@ async function clientBuild(opts: StaticBuildOptions, internals: BuildInternals, }); } -async function loadRenderer(renderer: AstroRenderer, config: AstroConfig): Promise { - const mod = (await import(resolveDependency(renderer.serverEntrypoint, config))) as { default: SSRLoadedRenderer['ssr'] }; - return { ...renderer, ssr: mod.default }; -} - -async function loadRenderers(config: AstroConfig): Promise { - return Promise.all(config._ctx.renderers.map((r) => loadRenderer(r, config))); -} - -async function generatePages(result: RollupOutput, opts: StaticBuildOptions, internals: BuildInternals, facadeIdToPageDataMap: Map) { - debug('build', 'Finish build. Begin generating.'); - - // Get renderers to be shared for each page generation. - const renderers = await loadRenderers(opts.astroConfig); - - for (let output of result.output) { - if (chunkIsPage(opts.astroConfig, output, internals)) { - await generatePage(output as OutputChunk, opts, internals, facadeIdToPageDataMap, renderers); - } - } -} - -async function generatePage( - output: OutputChunk, - opts: StaticBuildOptions, - internals: BuildInternals, - facadeIdToPageDataMap: Map, - renderers: SSRLoadedRenderer[] -) { - const { astroConfig } = opts; - - let url = new URL('./' + output.fileName, getOutRoot(astroConfig)); - const facadeId: string = output.facadeModuleId as string; - let pageData = getByFacadeId(facadeId, facadeIdToPageDataMap); - - 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 linkIds = getByFacadeId(facadeId, internals.facadeIdToAssetsMap) || []; - const hoistedId = getByFacadeId(facadeId, internals.facadeIdToHoistedEntryMap) || null; - - let compiledModule = await import(url.toString()); - - const generationOptions: Readonly = { - pageData, - internals, - linkIds, - hoistedId, - mod: compiledModule, - renderers, - }; - - const renderPromises = []; - // Throttle the paths to avoid overloading the CPU with too many tasks. - for (const paths of throttle(MAX_CONCURRENT_RENDERS, pageData.paths)) { - for (const path of paths) { - renderPromises.push(generatePath(path, opts, generationOptions)); - } - // This blocks generating more paths until these 10 complete. - await Promise.all(renderPromises); - // This empties the array without allocating a new one. - renderPromises.length = 0; - } -} - -interface GeneratePathOptions { - pageData: PageBuildData; - internals: BuildInternals; - linkIds: string[]; - hoistedId: string | null; - mod: ComponentInstance; - renderers: SSRLoadedRenderer[]; -} - -async function generatePath(pathname: string, opts: StaticBuildOptions, gopts: GeneratePathOptions) { - const { astroConfig, logging, origin, routeCache } = opts; - const { mod, internals, linkIds, hoistedId, pageData, renderers } = gopts; - - // This adds the page name to the array so it can be shown as part of stats. - if (pageData.route.type === 'page') { - addPageName(pathname, opts); - } - - debug('build', `Generating: ${pathname}`); - - const site = astroConfig.buildOptions.site; - const links = createLinkStylesheetElementSet(linkIds.reverse(), site); - const scripts = createModuleScriptElementWithSrcSet(hoistedId ? [hoistedId] : [], site); - - // Add all injected scripts to the page. - for (const script of astroConfig._ctx.scripts) { - if (script.stage === 'head-inline') { - scripts.add({ - props: {}, - children: script.content, - }); - } - } - - try { - const options: RenderOptions = { - legacyBuild: false, - links, - logging, - markdownRender: astroConfig.markdownOptions.render, - mod, - origin, - pathname, - scripts, - renderers, - async resolve(specifier: string) { - const hashedFilePath = internals.entrySpecifierToBundleMap.get(specifier); - if (typeof hashedFilePath !== 'string') { - // If no "astro:scripts/before-hydration.js" script exists in the build, - // then we can assume that no before-hydration scripts are needed. - // Return this as placeholder, which will be ignored by the browser. - // TODO: In the future, we hope to run this entire script through Vite, - // removing the need to maintain our own custom Vite-mimic resolve logic. - if (specifier === 'astro:scripts/before-hydration.js') { - return 'data:text/javascript;charset=utf-8,//[no before-hydration script]'; - } - throw new Error(`Cannot find the built path for ${specifier}`); - } - const relPath = npath.posix.relative(pathname, '/' + hashedFilePath); - const fullyRelativePath = relPath[0] === '.' ? relPath : './' + relPath; - return fullyRelativePath; - }, - method: 'GET', - headers: new Headers(), - route: pageData.route, - routeCache, - site: astroConfig.buildOptions.site, - ssr: opts.astroConfig.buildOptions.experimentalSsr, - }; - - let body: string; - if (pageData.route.type === 'endpoint') { - const result = await callEndpoint(mod as unknown as EndpointHandler, options); - - if (result.type === 'response') { - throw new Error(`Returning a Response from an endpoint is not supported in SSG mode.`); - } - body = result.body; - } else { - const result = await render(options); - - // If there's a redirect or something, just do nothing. - if (result.type !== 'html') { - return; - } - body = result.html; - } - - const outFolder = getOutFolder(astroConfig, pathname, pageData.route.type); - const outFile = getOutFile(astroConfig, outFolder, pathname, pageData.route.type); - await fs.promises.mkdir(outFolder, { recursive: true }); - await fs.promises.writeFile(outFile, body, 'utf-8'); - } catch (err) { - error(opts.logging, 'build', `Error rendering:`, err); - } -} - -async function generateManifest(result: RollupOutput, opts: StaticBuildOptions, internals: BuildInternals) { - const { astroConfig, manifest } = opts; - const manifestFile = new URL('./manifest.json', getServerRoot(astroConfig)); - - const inputManifestJSON = await fs.promises.readFile(manifestFile, 'utf-8'); - const data: ViteManifest = JSON.parse(inputManifestJSON); - - const rootRelativeIdToChunkMap = new Map(); - for (const output of result.output) { - if (chunkIsPage(astroConfig, output, internals)) { - const chunk = output as OutputChunk; - if (chunk.facadeModuleId) { - const id = rootRelativeFacadeId(chunk.facadeModuleId, astroConfig); - rootRelativeIdToChunkMap.set(id, chunk); - } - } - } - - const routes: SerializedRouteInfo[] = []; - - for (const routeData of manifest.routes) { - const componentPath = routeData.component; - const entry = data[componentPath]; - - if (!rootRelativeIdToChunkMap.has(componentPath)) { - throw new Error('Unable to find chunk for ' + componentPath); - } - - const chunk = rootRelativeIdToChunkMap.get(componentPath)!; - const facadeId = chunk.facadeModuleId!; - const links = getByFacadeId(facadeId, internals.facadeIdToAssetsMap) || []; - const hoistedScript = getByFacadeId(facadeId, internals.facadeIdToHoistedEntryMap); - const scripts = hoistedScript ? [hoistedScript] : []; - - routes.push({ - file: entry?.file, - links, - scripts, - routeData: serializeRouteData(routeData), - }); - } - - const ssrManifest: SerializedSSRManifest = { - routes, - site: astroConfig.buildOptions.site, - markdown: { - render: astroConfig.markdownOptions.render, - }, - renderers: astroConfig._ctx.renderers, - entryModules: Object.fromEntries(internals.entrySpecifierToBundleMap.entries()), - }; - - const outputManifestJSON = JSON.stringify(ssrManifest, null, ' '); - await fs.promises.writeFile(manifestFile, outputManifestJSON, 'utf-8'); -} - -function getOutRoot(astroConfig: AstroConfig): URL { - return new URL('./', astroConfig.dist); -} - -function getServerRoot(astroConfig: AstroConfig): URL { - const rootFolder = getOutRoot(astroConfig); - const serverFolder = new URL('./server/', rootFolder); - return serverFolder; -} - -function getClientRoot(astroConfig: AstroConfig): URL { - const rootFolder = getOutRoot(astroConfig); - const serverFolder = new URL('./client/', rootFolder); - return serverFolder; -} - -function getOutFolder(astroConfig: AstroConfig, pathname: string, routeType: RouteType): URL { - const outRoot = getOutRoot(astroConfig); - - // This is the root folder to write to. - switch (routeType) { - case 'endpoint': - return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot); - case 'page': - switch (astroConfig.buildOptions.pageUrlFormat) { - case 'directory': { - if (STATUS_CODE_PAGES.has(pathname)) { - return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot); - } - return new URL('.' + appendForwardSlash(pathname), outRoot); - } - case 'file': { - return new URL('.' + appendForwardSlash(npath.dirname(pathname)), outRoot); - } - } - } -} - -function getOutFile(astroConfig: AstroConfig, outFolder: URL, pathname: string, routeType: RouteType): URL { - switch (routeType) { - case 'endpoint': - return new URL(npath.basename(pathname), outFolder); - case 'page': - switch (astroConfig.buildOptions.pageUrlFormat) { - case 'directory': { - if (STATUS_CODE_PAGES.has(pathname)) { - const baseName = npath.basename(pathname); - return new URL('./' + (baseName || 'index') + '.html', outFolder); - } - return new URL('./index.html', outFolder); - } - case 'file': { - const baseName = npath.basename(pathname); - return new URL('./' + (baseName || 'index') + '.html', outFolder); - } - } - } -} async function cleanSsrOutput(opts: StaticBuildOptions) { // The SSR output is all .mjs files, the client output is not. @@ -583,58 +222,5 @@ async function ssrMoveAssets(opts: StaticBuildOptions) { }) ); - await removeDir(serverAssets); -} - -export function vitePluginNewBuild(input: Set, internals: BuildInternals, ext: 'js' | 'mjs'): VitePlugin { - return { - name: '@astro/rollup-plugin-new-build', - - config(config, options) { - const extra: Partial = {}; - const noExternal = [], - external = []; - if (options.command === 'build' && config.build?.ssr) { - noExternal.push('astro'); - external.push('shiki'); - } - - // @ts-ignore - extra.ssr = { - external, - noExternal, - }; - return extra; - }, - - configResolved(resolvedConfig) { - // Delete this hook because it causes assets not to be built - const plugins = resolvedConfig.plugins as VitePlugin[]; - const viteAsset = plugins.find((p) => p.name === 'vite:asset'); - if (viteAsset) { - delete viteAsset.generateBundle; - } - }, - - async generateBundle(_options, bundle) { - const promises = []; - const mapping = new Map(); - for (const specifier of input) { - promises.push( - this.resolve(specifier).then((result) => { - if (result) { - mapping.set(result.id, specifier); - } - }) - ); - } - await Promise.all(promises); - for (const [, chunk] of Object.entries(bundle)) { - if (chunk.type === 'chunk' && chunk.facadeModuleId) { - const specifier = mapping.get(chunk.facadeModuleId) || chunk.facadeModuleId; - internals.entrySpecifierToBundleMap.set(specifier, chunk.fileName); - } - } - }, - }; + removeDir(serverAssets); } diff --git a/packages/astro/src/core/build/types.d.ts b/packages/astro/src/core/build/types.d.ts index fa37ff888..33278ea8b 100644 --- a/packages/astro/src/core/build/types.d.ts +++ b/packages/astro/src/core/build/types.d.ts @@ -1,5 +1,8 @@ import type { ComponentPreload } from '../render/dev/index'; -import type { RouteData } from '../../@types/astro'; +import type { AstroConfig, BuildConfig, ManifestData, RouteData } from '../../@types/astro'; +import type { ViteConfigWithSSR } from '../../create-vite'; +import type { LogOptions } from '../../logger'; +import type { RouteCache } from '../../render/route-cache.js'; export interface PageBuildData { paths: string[]; @@ -7,3 +10,16 @@ export interface PageBuildData { route: RouteData; } export type AllPagesData = Record; + +/** Options for the static build */ +export interface StaticBuildOptions { + allPages: AllPagesData; + astroConfig: AstroConfig; + buildConfig: BuildConfig; + logging: LogOptions; + manifest: ManifestData; + origin: string; + pageNames: string[]; + routeCache: RouteCache; + viteConfig: ViteConfigWithSSR; +} diff --git a/packages/astro/src/core/build/vite-plugin-internals.ts b/packages/astro/src/core/build/vite-plugin-internals.ts new file mode 100644 index 000000000..b0f10f0fd --- /dev/null +++ b/packages/astro/src/core/build/vite-plugin-internals.ts @@ -0,0 +1,55 @@ +import type { Plugin as VitePlugin, UserConfig } from 'vite'; +import type { BuildInternals } from './internal.js'; + +export function vitePluginInternals(input: Set, internals: BuildInternals): VitePlugin { + return { + name: '@astro/plugin-build-internals', + + config(config, options) { + const extra: Partial = {}; + const noExternal = [], + external = []; + if (options.command === 'build' && config.build?.ssr) { + noExternal.push('astro'); + external.push('shiki'); + } + + // @ts-ignore + extra.ssr = { + external, + noExternal, + }; + return extra; + }, + + configResolved(resolvedConfig) { + // Delete this hook because it causes assets not to be built + const plugins = resolvedConfig.plugins as VitePlugin[]; + const viteAsset = plugins.find((p) => p.name === 'vite:asset'); + if (viteAsset) { + delete viteAsset.generateBundle; + } + }, + + async generateBundle(_options, bundle) { + const promises = []; + const mapping = new Map(); + for (const specifier of input) { + promises.push( + this.resolve(specifier).then((result) => { + if (result) { + mapping.set(result.id, specifier); + } + }) + ); + } + await Promise.all(promises); + for (const [, chunk] of Object.entries(bundle)) { + if (chunk.type === 'chunk' && chunk.facadeModuleId) { + const specifier = mapping.get(chunk.facadeModuleId) || chunk.facadeModuleId; + internals.entrySpecifierToBundleMap.set(specifier, chunk.fileName); + } + } + }, + }; +} diff --git a/packages/astro/src/core/build/vite-plugin-ssr.ts b/packages/astro/src/core/build/vite-plugin-ssr.ts new file mode 100644 index 000000000..ed4ad2284 --- /dev/null +++ b/packages/astro/src/core/build/vite-plugin-ssr.ts @@ -0,0 +1,119 @@ +import type { OutputBundle, OutputChunk } from 'rollup'; +import type { Plugin as VitePlugin } from 'vite'; +import type { BuildInternals } from './internal.js'; +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'; + +const virtualModuleId = '@astrojs-ssr-virtual-entry'; +const resolvedVirtualModuleId = '\0' + virtualModuleId; +const manifestReplace = '@@ASTRO_MANIFEST_REPLACE@@'; + +export function vitePluginSSR(buildOpts: StaticBuildOptions, internals: BuildInternals, adapter: AstroAdapter): VitePlugin { + return { + name: '@astrojs/vite-plugin-astro-ssr', + options(opts) { + if(Array.isArray(opts.input)) { + opts.input.push(virtualModuleId); + } else { + return { + input: [virtualModuleId] + }; + } + }, + resolveId(id) { + if(id === virtualModuleId) { + return resolvedVirtualModuleId; + } + }, + load(id) { + if(id === resolvedVirtualModuleId) { + return `import * as adapter from '${adapter.serverEntrypoint}'; +import { deserializeManifest as _deserializeManifest } from 'astro/app'; +const _manifest = _deserializeManifest('${manifestReplace}'); + +${adapter.exports ? `const _exports = adapter.createExports(_manifest); +${adapter.exports.map(name => `export const ${name} = _exports['${name}'];`).join('\n')} +` : ''} +const _start = 'start'; +if(_start in adapter) { + adapter[_start](_manifest); +}`; + } + return void 0; + }, + + 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 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(); + 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); + } + } + } + + 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); + } + + const chunk = rootRelativeIdToChunkMap.get(componentPath)!; + const facadeId = chunk.facadeModuleId!; + const links = getByFacadeId(facadeId, internals.facadeIdToAssetsMap) || []; + const hoistedScript = getByFacadeId(facadeId, internals.facadeIdToHoistedEntryMap); + const scripts = hoistedScript ? [hoistedScript] : []; + + routes.push({ + file: chunk.fileName, + links, + scripts, + routeData: serializeRouteData(routeData), + }); + } + + // HACK! Patch this special one. + const entryModules = Object.fromEntries(internals.entrySpecifierToBundleMap.entries()); + entryModules['astro:scripts/before-hydration.js'] = 'data:text/javascript;charset=utf-8,//[no before-hydration script]'; + + const ssrManifest: SerializedSSRManifest = { + routes, + site: astroConfig.buildOptions.site, + markdown: { + render: astroConfig.markdownOptions.render, + }, + renderers: astroConfig._ctx.renderers, + entryModules, + }; + + return ssrManifest; +} diff --git a/packages/astro/src/core/config.ts b/packages/astro/src/core/config.ts index 5568519d9..26ebc0931 100644 --- a/packages/astro/src/core/config.ts +++ b/packages/astro/src/core/config.ts @@ -11,6 +11,7 @@ import load from '@proload/core'; import loadTypeScript from '@proload/plugin-tsm'; import postcssrc from 'postcss-load-config'; import { arraify, isObject } from './util.js'; +import ssgAdapter from '../adapter-ssg/index.js'; load.use([loadTypeScript]); @@ -82,6 +83,7 @@ export const AstroConfigSchema = z.object({ message: `Astro integrations are still experimental, and only official integrations are currently supported`, }) ), + adapter: z.object({ name: z.string(), hooks: z.object({}).passthrough().default({}) }).optional(), styleOptions: z .object({ postcss: z @@ -210,7 +212,7 @@ export async function validateConfig(userConfig: any, root: string): Promise { + if(_config.adapter) { + _config.integrations.push(_config.adapter); + } + let updatedConfig: AstroConfig = { ..._config }; for (const integration of _config.integrations) { if (integration.hooks['astro:config:setup']) { @@ -30,6 +35,25 @@ export async function runHookConfigDone({ config }: { config: AstroConfig }) { if (integration.hooks['astro:config:done']) { await integration.hooks['astro:config:done']({ config, + setAdapter(adapter) { + if(config._ctx.adapter && config._ctx.adapter.name !== adapter.name) { + throw new Error(`Adapter already set to ${config._ctx.adapter.name}. You can only have one adapter.`); + } + config._ctx.adapter = adapter; + } + }); + } + } + // Call the default adapter + if(!config._ctx.adapter) { + const integration = ssgAdapter(); + config.integrations.push(integration); + if(integration.hooks['astro:config:done']) { + await integration.hooks['astro:config:done']({ + config, + setAdapter(adapter) { + config._ctx.adapter = adapter; + } }); } } @@ -59,10 +83,10 @@ export async function runHookServerDone({ config }: { config: AstroConfig }) { } } -export async function runHookBuildStart({ config }: { config: AstroConfig }) { +export async function runHookBuildStart({ config, buildConfig }: { config: AstroConfig, buildConfig: BuildConfig }) { for (const integration of config.integrations) { if (integration.hooks['astro:build:start']) { - await integration.hooks['astro:build:start'](); + await integration.hooks['astro:build:start']({ buildConfig }); } } } diff --git a/packages/astro/src/vite-plugin-build-css/index.ts b/packages/astro/src/vite-plugin-build-css/index.ts index 2ddd74f3d..e630cd578 100644 --- a/packages/astro/src/vite-plugin-build-css/index.ts +++ b/packages/astro/src/vite-plugin-build-css/index.ts @@ -184,7 +184,7 @@ export function rollupPluginAstroBuildCSS(options: PluginOptions): VitePlugin { // Removes imports for pure CSS chunks. if (hasPureCSSChunks) { - if (internals.pureCSSChunks.has(chunk)) { + if (internals.pureCSSChunks.has(chunk) && !chunk.exports.length) { // Delete pure CSS chunks, these are JavaScript chunks that only import // other CSS files, so are empty at the end of bundling. delete bundle[chunkId]; diff --git a/packages/astro/test/ssr-dynamic.test.js b/packages/astro/test/ssr-dynamic.test.js index 1679cf8a3..127cfa65c 100644 --- a/packages/astro/test/ssr-dynamic.test.js +++ b/packages/astro/test/ssr-dynamic.test.js @@ -1,6 +1,8 @@ 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', () => { @@ -12,15 +14,16 @@ describe('Dynamic pages in SSR', () => { buildOptions: { experimentalSsr: true, }, + adapter: testAdapter() }); await fixture.build(); }); it('Do not have to implement getStaticPaths', async () => { - const app = await fixture.loadSSRApp(); + 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 request = new Request('http://example.com/123'); - const route = app.match(request); - const response = await app.render(request, route); + const response = await app.render(request); const html = await response.text(); const $ = cheerioLoad(html); expect($('h1').text()).to.equal('Item 123'); diff --git a/packages/astro/test/test-adapter.js b/packages/astro/test/test-adapter.js new file mode 100644 index 000000000..b1efe0f09 --- /dev/null +++ b/packages/astro/test/test-adapter.js @@ -0,0 +1,43 @@ +import { viteID } from '../dist/core/util.js'; + +/** + * + * @returns {import('../src/@types/astro').AstroIntegration} + */ +export default function() { + return { + name: 'my-ssr-adapter', + hooks: { + 'astro:config:setup': ({ updateConfig }) => { + updateConfig({ + vite: { + plugins: [ + { + resolveId(id) { + if(id === '@my-ssr') { + return id; + } else if(id === 'astro/app') { + const id = viteID(new URL('../dist/core/app/index.js', import.meta.url)); + return id; + } + }, + load(id) { + if(id === '@my-ssr') { + return `import { App } from 'astro/app';export function createExports(manifest) { return { manifest, createApp: (root) => new App(manifest, root) }; }`; + } + } + } + ], + } + }) + }, + 'astro:config:done': ({ setAdapter }) => { + setAdapter({ + name: 'my-ssr-adapter', + serverEntrypoint: '@my-ssr', + exports: ['manifest', 'createApp'] + }); + } + }, + } +} diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index 004f75b66..2bceb2748 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -82,7 +82,6 @@ export async function loadFixture(inlineConfig) { const previewServer = await preview(config, { logging: 'error', ...opts }); return previewServer; }, - loadSSRApp: () => loadApp(new URL('./server/', config.dist)), 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 }), diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json new file mode 100644 index 000000000..1208dbd4a --- /dev/null +++ b/packages/integrations/node/package.json @@ -0,0 +1,32 @@ +{ + "name": "@astrojs/node", + "description": "Deploy your site to a Node.js server", + "version": "0.0.1", + "type": "module", + "types": "./dist/index.d.ts", + "author": "withastro", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/withastro/astro.git", + "directory": "packages/integrations/node" + }, + "bugs": "https://github.com/withastro/astro/issues", + "homepage": "https://astro.build", + "exports": { + ".": "./dist/index.js", + "./server.js": "./dist/server.js", + "./package.json": "./package.json" + }, + "scripts": { + "build": "astro-scripts build \"src/**/*.ts\" && tsc", + "dev": "astro-scripts dev \"src/**/*.ts\"" + }, + "dependencies": { + "@astrojs/webapi": "^0.11.0" + }, + "devDependencies": { + "astro": "workspace:*", + "astro-scripts": "workspace:*" + } +} diff --git a/packages/integrations/node/readme.md b/packages/integrations/node/readme.md new file mode 100644 index 000000000..011485278 --- /dev/null +++ b/packages/integrations/node/readme.md @@ -0,0 +1,53 @@ +# @astrojs/node + +An experimental static-side rendering adapter for use with Node.js servers. + +In your astro.config.mjs use: + +```js +import nodejs from '@astrojs/node'; + +export default { + adapter: nodejs() +} +``` + +After performing a build there will be a `dist/server/entry.mjs` module that works like a middleware function. You can use with any framework that supports the Node `request` and `response` objects. For example, with Express you can do: + +```js +import express from 'express'; +import { handler as ssrHandler } from './dist/server/entry.mjs'; + +const app = express(); +app.use(ssrHandler); + +app.listen(8080); +``` + +# Using `http` + +This adapter does not require you use Express and can work with even the `http` and `https` modules. The adapter does following the Expression convention of calling a function when either + +- A route is not found for the request. +- There was an error rendering. + +You can use these to implement your own 404 behavior like so: + +```js +import http from 'http'; +import { handler as ssrHandler } from './dist/server/entry.mjs'; + +http.createServer(function(req, res) { + ssrHandler(req, res, err => { + if(err) { + res.writeHead(500); + res.end(err.toString()); + } else { + // Serve your static assets here maybe? + // 404? + res.writeHead(404); + res.end(); + } + }); +}).listen(8080); +``` diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts new file mode 100644 index 000000000..903d5b1cc --- /dev/null +++ b/packages/integrations/node/src/index.ts @@ -0,0 +1,20 @@ +import type { AstroAdapter, AstroIntegration } from 'astro'; + +export function getAdapter(): AstroAdapter { + return { + name: '@astrojs/node', + serverEntrypoint: '@astrojs/node/server.js', + exports: ['handler'], + }; +} + +export default function createIntegration(): AstroIntegration { + return { + name: '@astrojs/node', + hooks: { + 'astro:config:done': ({ setAdapter }) => { + setAdapter(getAdapter()); + } + } + }; +} diff --git a/packages/integrations/node/src/server.ts b/packages/integrations/node/src/server.ts new file mode 100644 index 000000000..791dc58b2 --- /dev/null +++ b/packages/integrations/node/src/server.ts @@ -0,0 +1,48 @@ +import type { SSRManifest } from 'astro'; +import type { IncomingMessage, ServerResponse } from 'http'; +import { NodeApp } from 'astro/app/node'; +import { polyfill } from '@astrojs/webapi'; + +polyfill(globalThis, { + exclude: 'window document' +}); + +export function createExports(manifest: SSRManifest) { + const app = new NodeApp(manifest, new URL(import.meta.url)); + return { + async handler(req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) { + const route = app.match(req); + + if(route) { + try { + const response = await app.render(req); + await writeWebResponse(res, response); + } catch(err: unknown) { + if(next) { + next(err); + } else { + throw err; + } + } + } else if(next) { + return next(); + } + } + } +} + +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); + } + } + } + res.end(); +} diff --git a/packages/integrations/node/tsconfig.json b/packages/integrations/node/tsconfig.json new file mode 100644 index 000000000..44baf375c --- /dev/null +++ b/packages/integrations/node/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "include": ["src"], + "compilerOptions": { + "allowJs": true, + "module": "ES2020", + "outDir": "./dist", + "target": "ES2020" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 865ae49b4..a0bb3d0bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -286,6 +286,7 @@ importers: examples/ssr: specifiers: + '@astrojs/node': ^0.0.1 '@astrojs/svelte': ^0.0.2-next.0 astro: ^0.25.0-next.2 concurrently: ^7.0.0 @@ -296,6 +297,7 @@ importers: dependencies: svelte: 3.46.4 devDependencies: + '@astrojs/node': link:../../packages/integrations/node '@astrojs/svelte': link:../../packages/integrations/svelte astro: link:../../packages/astro concurrently: 7.0.0 @@ -1175,6 +1177,17 @@ importers: astro: link:../../astro astro-scripts: link:../../../scripts + packages/integrations/node: + specifiers: + '@astrojs/webapi': ^0.11.0 + astro: workspace:* + astro-scripts: workspace:* + dependencies: + '@astrojs/webapi': link:../../webapi + devDependencies: + astro: link:../../astro + astro-scripts: link:../../../scripts + packages/integrations/partytown: specifiers: '@builder.io/partytown': ^0.4.5