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
This commit is contained in:
Matthew Phillips 2022-03-24 07:26:25 -04:00 committed by GitHub
parent 5c96145527
commit 5e52814d97
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 886 additions and 496 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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:*"
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"include": ["src"],
"compilerOptions": {
"allowJs": true,
"module": "ES2020",
"outDir": "./dist",
"target": "ES2020"
}
}

View file

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