summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Matthew Phillips <matthew@skypack.dev> 2022-03-24 07:26:25 -0400
committerGravatar GitHub <noreply@github.com> 2022-03-24 07:26:25 -0400
commit5e52814d97a5723dbe7ebb32fbe040a7a4c0ea77 (patch)
treeb4cd5790933ad9829159314191487462bd5e9d2e
parent5c96145527f9480e7b7b16b599f4b2091e41aa6c (diff)
downloadastro-5e52814d97a5723dbe7ebb32fbe040a7a4c0ea77.tar.gz
astro-5e52814d97a5723dbe7ebb32fbe040a7a4c0ea77.tar.zst
astro-5e52814d97a5723dbe7ebb32fbe040a7a4c0ea77.zip
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
-rw-r--r--.changeset/hot-plants-help.md29
-rw-r--r--examples/ssr/astro.config.mjs2
-rw-r--r--examples/ssr/build.mjs12
-rw-r--r--examples/ssr/package.json4
-rw-r--r--examples/ssr/server/server.mjs48
-rw-r--r--packages/astro/package.json4
-rw-r--r--packages/astro/src/@types/astro.ts27
-rw-r--r--packages/astro/src/adapter-ssg/index.ts23
-rw-r--r--packages/astro/src/core/app/index.ts3
-rw-r--r--packages/astro/src/core/app/node.ts3
-rw-r--r--packages/astro/src/core/build/common.ts64
-rw-r--r--packages/astro/src/core/build/generate.ts244
-rw-r--r--packages/astro/src/core/build/index.ts6
-rw-r--r--packages/astro/src/core/build/static-build.ts456
-rw-r--r--packages/astro/src/core/build/types.d.ts18
-rw-r--r--packages/astro/src/core/build/vite-plugin-internals.ts55
-rw-r--r--packages/astro/src/core/build/vite-plugin-ssr.ts119
-rw-r--r--packages/astro/src/core/config.ts4
-rw-r--r--packages/astro/src/integrations/index.ts30
-rw-r--r--packages/astro/src/vite-plugin-build-css/index.ts2
-rw-r--r--packages/astro/test/ssr-dynamic.test.js9
-rw-r--r--packages/astro/test/test-adapter.js43
-rw-r--r--packages/astro/test/test-utils.js1
-rw-r--r--packages/integrations/node/package.json32
-rw-r--r--packages/integrations/node/readme.md53
-rw-r--r--packages/integrations/node/src/index.ts20
-rw-r--r--packages/integrations/node/src/server.ts48
-rw-r--r--packages/integrations/node/tsconfig.json10
-rw-r--r--pnpm-lock.yaml13
29 files changed, 886 insertions, 496 deletions
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<typeof AstroConfigSchema> {
// 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<string, unknown>;
type Body = string;
+export interface AstroAdapter {
+ name: string;
+ serverEntrypoint?: string;
+ exports?: string[];
+}
+
export interface EndpointOutput<Output extends Body = Body> {
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<void>;
+ 'astro:config:done'?: (options: {config: AstroConfig, setAdapter: (adapter: AstroAdapter) => void; }) => void | Promise<void>;
'astro:server:setup'?: (options: { server: vite.ViteDevServer }) => void | Promise<void>;
'astro:server:start'?: (options: { address: AddressInfo }) => void | Promise<void>;
'astro:server:done'?: () => void | Promise<void>;
- 'astro:build:start'?: () => void | Promise<void>;
+ 'astro:build:start'?: (options: { buildConfig: BuildConfig }) => void | Promise<void>;
'astro:build:done'?: (options: { pages: { pathname: string }[]; dir: URL }) => void | Promise<void>;
};
}
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<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 = [];
+ 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<string, PageBuildData>) {
+ 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<string, PageBuildData>,
+ renderers: SSRLoadedRenderer[]
+) {
+ const { astroConfig } = opts;
+
+ let url = new URL('./' + output.fileName, getOutRoot(astroConfig));
+ const facadeId: string = output.facadeModuleId as string;
+ let pageData = getByFacadeId<PageBuildData>(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<string[]>(facadeId, internals.facadeIdToAssetsMap) || [];
+ const hoistedId = getByFacadeId<string>(facadeId, internals.facadeIdToHoistedEntryMap) || null;
+
+ let compiledModule = await import(url.toString());
+
+ const generationOptions: Readonly<GeneratePathOptions> = {
+ pageData,
+ internals,
+ linkIds,
+ hoistedId,
+ mod: compiledModule,
+ 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<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, '\\'))
- );
-}
+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<string>();
@@ -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<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)));
-}
-
-async function generatePages(result: RollupOutput, opts: StaticBuildOptions, internals: BuildInternals, facadeIdToPageDataMap: Map<string, PageBuildData>) {
- 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<string, PageBuildData>,
- renderers: SSRLoadedRenderer[]
-) {
- const { astroConfig } = opts;
-
- let url = new URL('./' + output.fileName, getOutRoot(astroConfig));
- const facadeId: string = output.facadeModuleId as string;
- let pageData = getByFacadeId<PageBuildData>(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<string[]>(facadeId, internals.facadeIdToAssetsMap) || [];
- const hoistedId = getByFacadeId<string>(facadeId, internals.facadeIdToHoistedEntryMap) || null;
-
- let compiledModule = await import(url.toString());
-
- const generationOptions: Readonly<GeneratePathOptions> = {
- pageData,
- internals,
- linkIds,
- hoistedId,
- mod: compiledModule,
- 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<string, OutputChunk>();
- 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<string[]>(facadeId, internals.facadeIdToAssetsMap) || [];
- const hoistedScript = getByFacadeId<string>(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<string>, internals: BuildInternals, ext: 'js' | 'mjs'): VitePlugin {
- return {
- name: '@astro/rollup-plugin-new-build',
-
- config(config, options) {
- const extra: Partial<UserConfig> = {};
- 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<string, string>();
- 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<string, PageBuildData>;
+
+/** 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<string>, internals: BuildInternals): VitePlugin {
+ return {
+ name: '@astro/plugin-build-internals',
+
+ config(config, options) {
+ const extra: Partial<UserConfig> = {};
+ 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<string, string>();
+ 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<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);
+ }
+ }
+ }
+
+ 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<string[]>(facadeId, internals.facadeIdToAssetsMap) || [];
+ const hoistedScript = getByFacadeId<string>(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<Ast
});
return {
...(await AstroConfigRelativeSchema.parseAsync(userConfig)),
- _ctx: { scripts: [], renderers: [] },
+ _ctx: { scripts: [], renderers: [], adapter: undefined },
};
}
diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts
index af6736780..470b3d032 100644
--- a/packages/astro/src/integrations/index.ts
+++ b/packages/astro/src/integrations/index.ts
@@ -1,9 +1,14 @@
import type { AddressInfo } from 'net';
import type { ViteDevServer } from 'vite';
-import { AstroConfig, AstroRenderer } from '../@types/astro.js';
+import { AstroConfig, AstroRenderer, BuildConfig } from '../@types/astro.js';
import { mergeConfig } from '../core/config.js';
+import ssgAdapter from '../adapter-ssg/index.js';
export async function runHookConfigSetup({ config: _config, command }: { config: AstroConfig; command: 'dev' | 'build' }): Promise<AstroConfig> {
+ 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