summaryrefslogtreecommitdiff
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/astro/src/@types/astro.ts97
-rw-r--r--packages/astro/src/core/build/index.ts6
-rw-r--r--packages/astro/src/core/config/config.ts6
-rw-r--r--packages/astro/src/core/config/schema.ts53
-rw-r--r--packages/astro/src/core/preview/index.ts195
-rw-r--r--packages/astro/src/core/preview/static-preview-server.ts164
-rw-r--r--packages/astro/src/core/util.ts5
-rw-r--r--packages/astro/src/integrations/index.ts32
-rw-r--r--packages/astro/test/benchmark/simple/astro.config.mjs2
-rw-r--r--packages/astro/test/fixtures/static-build-ssr/astro.config.mjs2
-rw-r--r--packages/integrations/cloudflare/src/index.ts34
-rw-r--r--packages/integrations/deno/src/index.ts15
-rw-r--r--packages/integrations/image/src/index.ts21
-rw-r--r--packages/integrations/netlify/src/integration-edge-functions.ts46
-rw-r--r--packages/integrations/netlify/src/integration-functions.ts28
-rw-r--r--packages/integrations/node/README.md81
-rw-r--r--packages/integrations/node/package.json5
-rw-r--r--packages/integrations/node/src/http-server.ts77
-rw-r--r--packages/integrations/node/src/index.ts30
-rw-r--r--packages/integrations/node/src/middleware.ts53
-rw-r--r--packages/integrations/node/src/preview.ts54
-rw-r--r--packages/integrations/node/src/server.ts53
-rw-r--r--packages/integrations/node/src/standalone.ts53
-rw-r--r--packages/integrations/node/src/types.ts17
-rw-r--r--packages/integrations/node/test/api-route.test.js2
-rw-r--r--packages/integrations/vercel/src/edge/adapter.ts28
-rw-r--r--packages/integrations/vercel/src/serverless/adapter.ts28
27 files changed, 882 insertions, 305 deletions
diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts
index 16b5b5226..7157de666 100644
--- a/packages/astro/src/@types/astro.ts
+++ b/packages/astro/src/@types/astro.ts
@@ -83,8 +83,17 @@ export interface CLIFlags {
}
export interface BuildConfig {
+ /**
+ * @deprecated Use config.build.client instead.
+ */
client: URL;
+ /**
+ * @deprecated Use config.build.server instead.
+ */
server: URL;
+ /**
+ * @deprecated Use config.build.serverEntry instead.
+ */
serverEntry: string;
}
@@ -381,6 +390,7 @@ export interface AstroUserConfig {
* @name outDir
* @type {string}
* @default `"./dist"`
+ * @see build.server
* @description Set the directory that `astro build` writes your final build to.
*
* The value can be either an absolute file system path or a path relative to the project root.
@@ -526,6 +536,68 @@ export interface AstroUserConfig {
* This means that when you create relative URLs using `new URL('./relative', Astro.url)`, you will get consistent behavior between dev and build.
*/
format?: 'file' | 'directory';
+ /**
+ * @docs
+ * @name build.client
+ * @type {string}
+ * @default `'./dist/client'`
+ * @description
+ * Controls the output directory of your client-side CSS and JavaScript when `output: 'server'` only.
+ * `outDir` controls where the code is built to.
+ *
+ * This value is relative to the `outDir`.
+ *
+ * ```js
+ * {
+ * output: 'server',
+ * build: {
+ * client: './client'
+ * }
+ * }
+ * ```
+ */
+ client?: string;
+ /**
+ * @docs
+ * @name build.server
+ * @type {string}
+ * @default `'./dist/server'`
+ * @description
+ * Controls the output directory of server JavaScript when building to SSR.
+ *
+ * This value is relative to the `outDir`.
+ *
+ * ```js
+ * {
+ * build: {
+ * server: './server'
+ * }
+ * }
+ * ```
+ */
+ server?: string;
+ /**
+ * @docs
+ * @name build.serverEntry
+ * @type {string}
+ * @default `'entry.mjs'`
+ * @description
+ * Specifies the file name of the server entrypoint when building to SSR.
+ * This entrypoint is usually dependent on which host you are deploying to and
+ * will be set by your adapter for you.
+ *
+ * Note that it is recommended that this file ends with `.mjs` so that the runtime
+ * detects that the file is a JavaScript module.
+ *
+ * ```js
+ * {
+ * build: {
+ * serverEntry: 'main.mjs'
+ * }
+ * }
+ * ```
+ */
+ serverEntry?: string;
};
/**
@@ -1073,6 +1145,7 @@ export type Params = Record<string, string | number | undefined>;
export interface AstroAdapter {
name: string;
serverEntrypoint?: string;
+ previewEntrypoint?: string;
exports?: string[];
args?: any;
}
@@ -1234,7 +1307,7 @@ export interface AstroIntegration {
hooks: {
'astro:config:setup'?: (options: {
config: AstroConfig;
- command: 'dev' | 'build';
+ command: 'dev' | 'build' | 'preview';
isRestart: boolean;
updateConfig: (newConfig: Record<string, any>) => void;
addRenderer: (renderer: AstroRenderer) => void;
@@ -1332,3 +1405,25 @@ export interface SSRResult {
}
export type MarkdownAstroData = { frontmatter: object };
+
+/* Preview server stuff */
+export interface PreviewServer {
+ host?: string;
+ port: number;
+ closed(): Promise<void>;
+ stop(): Promise<void>;
+}
+
+export interface PreviewServerParams {
+ outDir: URL;
+ client: URL;
+ serverEntrypoint: URL;
+ host: string | undefined;
+ port: number;
+}
+
+export type CreatePreviewServer = (params: PreviewServerParams) => PreviewServer | Promise<PreviewServer>;
+
+export interface PreviewModule {
+ default: CreatePreviewServer;
+}
diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts
index 51ad4ae93..25e6717d7 100644
--- a/packages/astro/src/core/build/index.ts
+++ b/packages/astro/src/core/build/index.ts
@@ -87,9 +87,9 @@ class AstroBuilder {
/** Run the build logic. build() is marked private because usage should go through ".run()" */
private async build({ viteConfig }: { viteConfig: vite.InlineConfig }) {
const buildConfig: BuildConfig = {
- client: new URL('./client/', this.settings.config.outDir),
- server: new URL('./server/', this.settings.config.outDir),
- serverEntry: 'entry.mjs',
+ client: this.settings.config.build.client,
+ server: this.settings.config.build.server,
+ serverEntry: this.settings.config.build.serverEntry,
};
await runHookBuildStart({ config: this.settings.config, buildConfig, logging: this.logging });
this.validateConfig();
diff --git a/packages/astro/src/core/config/config.ts b/packages/astro/src/core/config/config.ts
index 07f6b0320..d939c6e87 100644
--- a/packages/astro/src/core/config/config.ts
+++ b/packages/astro/src/core/config/config.ts
@@ -10,7 +10,7 @@ import { fileURLToPath, pathToFileURL } from 'url';
import * as vite from 'vite';
import { mergeConfig as mergeViteConfig } from 'vite';
import { LogOptions } from '../logger/core.js';
-import { arraify, isObject } from '../util.js';
+import { arraify, isObject, isURL } from '../util.js';
import { createRelativeSchema } from './schema.js';
load.use([loadTypeScript]);
@@ -346,6 +346,10 @@ function mergeConfigRecursively(
merged[key] = [...arraify(existing ?? []), ...arraify(value ?? [])];
continue;
}
+ if(isURL(existing) && isURL(value)) {
+ merged[key] = value;
+ continue;
+ }
if (isObject(existing) && isObject(value)) {
merged[key] = mergeConfigRecursively(existing, value, rootPath ? `${rootPath}.${key}` : key);
continue;
diff --git a/packages/astro/src/core/config/schema.ts b/packages/astro/src/core/config/schema.ts
index 9bcfd15b3..3c390c650 100644
--- a/packages/astro/src/core/config/schema.ts
+++ b/packages/astro/src/core/config/schema.ts
@@ -17,7 +17,12 @@ const ASTRO_CONFIG_DEFAULTS: AstroUserConfig & any = {
outDir: './dist',
base: '/',
trailingSlash: 'ignore',
- build: { format: 'directory' },
+ build: {
+ format: 'directory',
+ client: './dist/client/',
+ server: './dist/server/',
+ serverEntry: 'entry.mjs'
+ },
server: {
host: false,
port: 3000,
@@ -97,6 +102,20 @@ export const AstroConfigSchema = z.object({
.union([z.literal('file'), z.literal('directory')])
.optional()
.default(ASTRO_CONFIG_DEFAULTS.build.format),
+ client: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.client)
+ .transform((val) => new URL(val)),
+ server: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.server)
+ .transform((val) => new URL(val)),
+ serverEntry: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.serverEntry),
})
.optional()
.default({}),
@@ -233,6 +252,28 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: URL) {
.string()
.default(ASTRO_CONFIG_DEFAULTS.outDir)
.transform((val) => new URL(appendForwardSlash(val), fileProtocolRoot)),
+ build: z.object({
+ format: z
+ .union([z.literal('file'), z.literal('directory')])
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.format),
+ client: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.client)
+ .transform(val => new URL(val, fileProtocolRoot)),
+ server: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.server)
+ .transform(val => new URL(val, fileProtocolRoot)),
+ serverEntry: z
+ .string()
+ .optional()
+ .default(ASTRO_CONFIG_DEFAULTS.build.serverEntry),
+ })
+ .optional()
+ .default({}),
server: z.preprocess(
// preprocess
(val) =>
@@ -265,6 +306,16 @@ export function createRelativeSchema(cmd: string, fileProtocolRoot: URL) {
})
.optional()
.default({}),
+ }).transform(config => {
+ // If the user changed outDir but not build.server, build.config, adjust so those
+ // are relative to the outDir, as is the expected default.
+ if(!config.build.server.toString().startsWith(config.outDir.toString()) && config.build.server.toString().endsWith('dist/server/')) {
+ config.build.server = new URL('./dist/server/', config.outDir);
+ }
+ if(!config.build.client.toString().startsWith(config.outDir.toString()) && config.build.client.toString().endsWith('dist/client/')) {
+ config.build.client = new URL('./dist/client/', config.outDir);
+ }
+ return config;
});
return AstroConfigRelativeSchema;
diff --git a/packages/astro/src/core/preview/index.ts b/packages/astro/src/core/preview/index.ts
index 379d44e6f..ab244b14c 100644
--- a/packages/astro/src/core/preview/index.ts
+++ b/packages/astro/src/core/preview/index.ts
@@ -1,16 +1,9 @@
import type { AstroTelemetry } from '@astrojs/telemetry';
-import type { AddressInfo } from 'net';
-import type { AstroSettings } from '../../@types/astro';
+import type { AstroSettings, PreviewModule, PreviewServer } from '../../@types/astro';
+import { runHookConfigDone, runHookConfigSetup } from '../../integrations/index.js';
import type { LogOptions } from '../logger/core';
-
-import fs from 'fs';
-import http from 'http';
-import { performance } from 'perf_hooks';
-import sirv from 'sirv';
-import { fileURLToPath } from 'url';
-import { notFoundTemplate, subpathNotUsedTemplate } from '../../template/4xx.js';
-import { error, info } from '../logger/core.js';
-import * as msg from '../messages.js';
+import createStaticPreviewServer from './static-preview-server.js';
+import { createRequire } from 'module';
import { getResolvedHostForHttpServer } from './util.js';
interface PreviewOptions {
@@ -18,162 +11,48 @@ interface PreviewOptions {
telemetry: AstroTelemetry;
}
-export interface PreviewServer {
- host?: string;
- port: number;
- server: http.Server;
- closed(): Promise<void>;
- stop(): Promise<void>;
-}
-
-const HAS_FILE_EXTENSION_REGEXP = /^.*\.[^\\]+$/;
-
/** The primary dev action */
export default async function preview(
- settings: AstroSettings,
+ _settings: AstroSettings,
{ logging }: PreviewOptions
): Promise<PreviewServer> {
- if (settings.config.output === 'server') {
- throw new Error(
- `[preview] 'output: server' not supported. Use your deploy platform's preview command directly instead, if one exists. (ex: 'netlify dev', 'vercel dev', 'wrangler', etc.)`
- );
- }
- const startServerTime = performance.now();
- const defaultOrigin = 'http://localhost';
- const trailingSlash = settings.config.trailingSlash;
- /** Base request URL. */
- let baseURL = new URL(settings.config.base, new URL(settings.config.site || '/', defaultOrigin));
- const staticFileServer = sirv(fileURLToPath(settings.config.outDir), {
- dev: true,
- etag: true,
- maxAge: 0,
- });
- // Create the preview server, send static files out of the `dist/` directory.
- const server = http.createServer((req, res) => {
- const requestURL = new URL(req.url as string, defaultOrigin);
-
- // respond 404 to requests outside the base request directory
- if (!requestURL.pathname.startsWith(baseURL.pathname)) {
- res.statusCode = 404;
- res.end(subpathNotUsedTemplate(baseURL.pathname, requestURL.pathname));
- return;
- }
-
- /** Relative request path. */
- const pathname = requestURL.pathname.slice(baseURL.pathname.length - 1);
-
- const isRoot = pathname === '/';
- const hasTrailingSlash = isRoot || pathname.endsWith('/');
-
- function sendError(message: string) {
- res.statusCode = 404;
- res.end(notFoundTemplate(pathname, message));
- }
-
- switch (true) {
- case hasTrailingSlash && trailingSlash == 'never' && !isRoot:
- sendError('Not Found (trailingSlash is set to "never")');
- return;
- case !hasTrailingSlash &&
- trailingSlash == 'always' &&
- !isRoot &&
- !HAS_FILE_EXTENSION_REGEXP.test(pathname):
- sendError('Not Found (trailingSlash is set to "always")');
- return;
- default: {
- // HACK: rewrite req.url so that sirv finds the file
- req.url = '/' + req.url?.replace(baseURL.pathname, '');
- staticFileServer(req, res, () => {
- const errorPagePath = fileURLToPath(settings.config.outDir + '/404.html');
- if (fs.existsSync(errorPagePath)) {
- res.statusCode = 404;
- res.setHeader('Content-Type', 'text/html;charset=utf-8');
- res.end(fs.readFileSync(errorPagePath));
- } else {
- staticFileServer(req, res, () => {
- sendError('Not Found');
- });
- }
- });
- return;
- }
- }
+ const settings = await runHookConfigSetup({
+ settings: _settings,
+ command: 'preview',
+ logging: logging,
});
-
- let { port } = settings.config.server;
+ await runHookConfigDone({ settings: settings, logging: logging });
const host = getResolvedHostForHttpServer(settings.config.server.host);
+ const { port } = settings.config.server;
- let httpServer: http.Server;
-
- /** Expose dev server to `port` */
- function startServer(timerStart: number): Promise<void> {
- let showedPortTakenMsg = false;
- let showedListenMsg = false;
- return new Promise<void>((resolve, reject) => {
- const listen = () => {
- httpServer = server.listen(port, host, async () => {
- if (!showedListenMsg) {
- const resolvedUrls = msg.resolveServerUrls({
- address: server.address() as AddressInfo,
- host: settings.config.server.host,
- https: false,
- });
- info(
- logging,
- null,
- msg.serverStart({
- startupTime: performance.now() - timerStart,
- resolvedUrls,
- host: settings.config.server.host,
- site: baseURL,
- })
- );
- }
- showedListenMsg = true;
- resolve();
- });
- httpServer?.on('error', onError);
- };
-
- const onError = (err: NodeJS.ErrnoException) => {
- if (err.code && err.code === 'EADDRINUSE') {
- if (!showedPortTakenMsg) {
- info(logging, 'astro', msg.portInUse({ port }));
- showedPortTakenMsg = true; // only print this once
- }
- port++;
- return listen(); // retry
- } else {
- error(logging, 'astro', err.stack || err.message);
- httpServer?.removeListener('error', onError);
- reject(err); // reject
- }
- };
-
- listen();
- });
+ if (settings.config.output === 'static') {
+ const server = await createStaticPreviewServer(settings, { logging, host, port });
+ return server;
}
-
- // Start listening on `hostname:port`.
- await startServer(startServerTime);
-
- // Resolves once the server is closed
- function closed() {
- return new Promise<void>((resolve, reject) => {
- httpServer!.addListener('close', resolve);
- httpServer!.addListener('error', reject);
- });
+ if (!settings.adapter) {
+ throw new Error(`[preview] No adapter found.`);
+ }
+ if (!settings.adapter.previewEntrypoint) {
+ throw new Error(`[preview] adapter does not have previewEntrypoint.`);
+ }
+ // We need to use require.resolve() here so that advanced package managers like pnpm
+ // don't treat this as a dependency of Astro itself. This correctly resolves the
+ // preview entrypoint of the integration package, relative to the user's project root.
+ const require = createRequire(settings.config.root);
+ const previewEntrypoint = require.resolve(settings.adapter.previewEntrypoint);
+
+ const previewModule = (await import(previewEntrypoint)) as Partial<PreviewModule>;
+ if(typeof previewModule.default !== 'function') {
+ throw new Error(`[preview] ${settings.adapter.name} cannot preview your app.`);
}
- return {
+ const server = await previewModule.default({
+ outDir: settings.config.outDir,
+ client: settings.config.build.client,
+ serverEntrypoint: new URL(settings.config.build.serverEntry, settings.config.build.server),
host,
- port,
- closed,
- server: httpServer!,
- stop: async () => {
- await new Promise((resolve, reject) => {
- httpServer.close((err) => (err ? reject(err) : resolve(undefined)));
- });
- },
- };
+ port
+ });
+
+ return server;
}
diff --git a/packages/astro/src/core/preview/static-preview-server.ts b/packages/astro/src/core/preview/static-preview-server.ts
new file mode 100644
index 000000000..942567029
--- /dev/null
+++ b/packages/astro/src/core/preview/static-preview-server.ts
@@ -0,0 +1,164 @@
+import type { AddressInfo } from 'net';
+import type { AstroSettings } from '../../@types/astro';
+import type { LogOptions } from '../logger/core';
+
+import fs from 'fs';
+import http from 'http';
+import { performance } from 'perf_hooks';
+import sirv from 'sirv';
+import { fileURLToPath } from 'url';
+import { notFoundTemplate, subpathNotUsedTemplate } from '../../template/4xx.js';
+import { error, info } from '../logger/core.js';
+import * as msg from '../messages.js';
+
+export interface PreviewServer {
+ host?: string;
+ port: number;
+ server: http.Server;
+ closed(): Promise<void>;
+ stop(): Promise<void>;
+}
+
+const HAS_FILE_EXTENSION_REGEXP = /^.*\.[^\\]+$/;
+
+/** The primary dev action */
+export default async function createStaticPreviewServer(
+ settings: AstroSettings,
+ { logging, host, port }: { logging: LogOptions; host: string | undefined; port: number }
+): Promise<PreviewServer> {
+ const startServerTime = performance.now();
+ const defaultOrigin = 'http://localhost';
+ const trailingSlash = settings.config.trailingSlash;
+ /** Base request URL. */
+ let baseURL = new URL(settings.config.base, new URL(settings.config.site || '/', defaultOrigin));
+ const staticFileServer = sirv(fileURLToPath(settings.config.outDir), {
+ dev: true,
+ etag: true,
+ maxAge: 0,
+ });
+ // Create the preview server, send static files out of the `dist/` directory.
+ const server = http.createServer((req, res) => {
+ const requestURL = new URL(req.url as string, defaultOrigin);
+
+ // respond 404 to requests outside the base request directory
+ if (!requestURL.pathname.startsWith(baseURL.pathname)) {
+ res.statusCode = 404;
+ res.end(subpathNotUsedTemplate(baseURL.pathname, requestURL.pathname));
+ return;
+ }
+
+ /** Relative request path. */
+ const pathname = requestURL.pathname.slice(baseURL.pathname.length - 1);
+
+ const isRoot = pathname === '/';
+ const hasTrailingSlash = isRoot || pathname.endsWith('/');
+
+ function sendError(message: string) {
+ res.statusCode = 404;
+ res.end(notFoundTemplate(pathname, message));
+ }
+
+ switch (true) {
+ case hasTrailingSlash && trailingSlash == 'never' && !isRoot:
+ sendError('Not Found (trailingSlash is set to "never")');
+ return;
+ case !hasTrailingSlash &&
+ trailingSlash == 'always' &&
+ !isRoot &&
+ !HAS_FILE_EXTENSION_REGEXP.test(pathname):
+ sendError('Not Found (trailingSlash is set to "always")');
+ return;
+ default: {
+ // HACK: rewrite req.url so that sirv finds the file
+ req.url = '/' + req.url?.replace(baseURL.pathname, '');
+ staticFileServer(req, res, () => {
+ const errorPagePath = fileURLToPath(settings.config.outDir + '/404.html');
+ if (fs.existsSync(errorPagePath)) {
+ res.statusCode = 404;
+ res.setHeader('Content-Type', 'text/html;charset=utf-8');
+ res.end(fs.readFileSync(errorPagePath));
+ } else {
+ staticFileServer(req, res, () => {
+ sendError('Not Found');
+ });
+ }
+ });
+ return;
+ }
+ }
+ });
+
+ let httpServer: http.Server;
+
+ /** Expose dev server to `port` */
+ function startServer(timerStart: number): Promise<void> {
+ let showedPortTakenMsg = false;
+ let showedListenMsg = false;
+ return new Promise<void>((resolve, reject) => {
+ const listen = () => {
+ httpServer = server.listen(port, host, async () => {
+ if (!showedListenMsg) {
+ const resolvedUrls = msg.resolveServerUrls({
+ address: server.address() as AddressInfo,
+ host: settings.config.server.host,
+ https: false,
+ });
+ info(
+ logging,
+ null,
+ msg.serverStart({
+ startupTime: performance.now() - timerStart,
+ resolvedUrls,
+ host: settings.config.server.host,
+ site: baseURL,
+ })
+ );
+ }
+ showedListenMsg = true;
+ resolve();
+ });
+ httpServer?.on('error', onError);
+ };
+
+ const onError = (err: NodeJS.ErrnoException) => {
+ if (err.code && err.code === 'EADDRINUSE') {
+ if (!showedPortTakenMsg) {
+ info(logging, 'astro', msg.portInUse({ port }));
+ showedPortTakenMsg = true; // only print this once
+ }
+ port++;
+ return listen(); // retry
+ } else {
+ error(logging, 'astro', err.stack || err.message);
+ httpServer?.removeListener('error', onError);
+ reject(err); // reject
+ }
+ };
+
+ listen();
+ });
+ }
+
+ // Start listening on `hostname:port`.
+ await startServer(startServerTime);
+
+ // Resolves once the server is closed
+ function closed() {
+ return new Promise<void>((resolve, reject) => {
+ httpServer!.addListener('close', resolve);
+ httpServer!.addListener('error', reject);
+ });
+ }
+
+ return {
+ host,
+ port,
+ closed,
+ server: httpServer!,
+ stop: async () => {
+ await new Promise((resolve, reject) => {
+ httpServer.close((err) => (err ? reject(err) : resolve(undefined)));
+ });
+ },
+ };
+}
diff --git a/packages/astro/src/core/util.ts b/packages/astro/src/core/util.ts
index 4c1d387cb..a1a5f9264 100644
--- a/packages/astro/src/core/util.ts
+++ b/packages/astro/src/core/util.ts
@@ -13,6 +13,11 @@ export function isObject(value: unknown): value is Record<string, any> {
return typeof value === 'object' && value != null;
}
+/** Cross-realm compatible URL */
+export function isURL(value: unknown): value is URL {
+ return Object.prototype.toString.call(value) === '[object URL]';
+}
+
/** Wraps an object in an array. If an array is passed, ignore it. */
export function arraify<T>(target: T | T[]): T[] {
return Array.isArray(target) ? target : [target];
diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts
index 9a9acd2dd..7e333dbf9 100644
--- a/packages/astro/src/integrations/index.ts
+++ b/packages/astro/src/integrations/index.ts
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
import type { InlineConfig, ViteDevServer } from 'vite';
import {
AstroConfig,
+ AstroIntegration,
AstroRenderer,
AstroSettings,
BuildConfig,
@@ -13,7 +14,7 @@ import {
import type { SerializedSSRManifest } from '../core/app/types';
import type { PageBuildData } from '../core/build/types';
import { mergeConfig } from '../core/config/config.js';
-import { info, LogOptions } from '../core/logger/core.js';
+import { info, LogOptions, warn } from '../core/logger/core.js';
async function withTakingALongTimeMsg<T>({
name,
@@ -41,7 +42,7 @@ export async function runHookConfigSetup({
isRestart = false,
}: {
settings: AstroSettings;
- command: 'dev' | 'build';
+ command: 'dev' | 'build' | 'preview';
logging: LogOptions;
isRestart?: boolean;
}): Promise<AstroSettings> {
@@ -211,13 +212,40 @@ export async function runHookBuildStart({
buildConfig: BuildConfig;
logging: LogOptions;
}) {
+ function warnDeprecated(integration: AstroIntegration, prop: 'server' | 'client' | 'serverEntry') {
+ let value: any = Reflect.get(buildConfig, prop);
+ Object.defineProperty(buildConfig, prop, {
+ enumerable: true,
+ get() {
+ return value;
+ },
+ set(newValue) {
+ value = newValue;
+ warn(logging, 'astro:build:start', `Your adapter ${bold(integration.name)} is using a deprecated API, buildConfig. ${bold(prop)} config should be set via config.build.${prop} instead.`);
+ }
+ });
+ return () => {
+ Object.defineProperty(buildConfig, prop, {
+ enumerable: true,
+ value
+ });
+ }
+ }
+
+
for (const integration of config.integrations) {
if (integration?.hooks?.['astro:build:start']) {
+ const undoClientWarning = warnDeprecated(integration, 'client');
+ const undoServerWarning = warnDeprecated(integration, 'server');
+ const undoServerEntryWarning = warnDeprecated(integration, 'serverEntry');
await withTakingALongTimeMsg({
name: integration.name,
hookResult: integration.hooks['astro:build:start']({ buildConfig }),
logging,
});
+ undoClientWarning();
+ undoServerEntryWarning();
+ undoServerWarning();
}
}
}
diff --git a/packages/astro/test/benchmark/simple/astro.config.mjs b/packages/astro/test/benchmark/simple/astro.config.mjs
index ec4a0bec0..16a331be3 100644
--- a/packages/astro/test/benchmark/simple/astro.config.mjs
+++ b/packages/astro/test/benchmark/simple/astro.config.mjs
@@ -3,5 +3,5 @@ import nodejs from '@astrojs/node';
export default defineConfig({
output: 'server',
- adapter: nodejs(),
+ adapter: nodejs({ mode: 'middleware' }),
});
diff --git a/packages/astro/test/fixtures/static-build-ssr/astro.config.mjs b/packages/astro/test/fixtures/static-build-ssr/astro.config.mjs
index 3d24e915e..3b0e295b3 100644
--- a/packages/astro/test/fixtures/static-build-ssr/astro.config.mjs
+++ b/packages/astro/test/fixtures/static-build-ssr/astro.config.mjs
@@ -2,6 +2,6 @@ import { defineConfig } from 'astro/config';
import nodejs from '@astrojs/node';
export default defineConfig({
- adapter: nodejs(),
+ adapter: nodejs({ mode: 'middleware' }),
output: 'server',
});
diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts
index 13c8578ee..9cf6412b8 100644
--- a/packages/integrations/cloudflare/src/index.ts
+++ b/packages/integrations/cloudflare/src/index.ts
@@ -1,4 +1,4 @@
-import type { AstroAdapter, AstroConfig, AstroIntegration, BuildConfig } from 'astro';
+import type { AstroAdapter, AstroConfig, AstroIntegration } from 'astro';
import esbuild from 'esbuild';
import * as fs from 'fs';
import { fileURLToPath } from 'url';
@@ -7,6 +7,12 @@ type Options = {
mode: 'directory' | 'advanced';
};
+interface BuildConfig {
+ server: URL;
+ client: URL;
+ serverEntry: string;
+}
+
export function getAdapter(isModeDirectory: boolean): AstroAdapter {
return isModeDirectory
? {
@@ -29,14 +35,26 @@ const SHIM = `globalThis.process = {
export default function createIntegration(args?: Options): AstroIntegration {
let _config: AstroConfig;
let _buildConfig: BuildConfig;
+ let needsBuildConfig = false;
const isModeDirectory = args?.mode === 'directory';
return {
name: '@astrojs/cloudflare',
hooks: {
+ 'astro:config:setup': ({ config, updateConfig }) => {
+ needsBuildConfig = !config.build.client;
+ updateConfig({
+ build: {
+ client: new URL('./static/', config.outDir),
+ server: new URL('./', config.outDir),
+ serverEntry: '_worker.js',
+ }
+ });
+ },
'astro:config:done': ({ setAdapter, config }) => {
setAdapter(getAdapter(isModeDirectory));
_config = config;
+ _buildConfig = config.build;
if (config.output === 'static') {
throw new Error(`
@@ -45,12 +63,6 @@ export default function createIntegration(args?: Options): AstroIntegration {
`);
}
},
- 'astro:build:start': ({ buildConfig }) => {
- _buildConfig = buildConfig;
- buildConfig.client = new URL('./static/', _config.outDir);
- buildConfig.serverEntry = '_worker.js';
- buildConfig.server = new URL('./', _config.outDir);
- },
'astro:build:setup': ({ vite, target }) => {
if (target === 'server') {
vite.resolve = vite.resolve || {};
@@ -69,6 +81,14 @@ export default function createIntegration(args?: Options): AstroIntegration {
vite.ssr.target = vite.ssr.target || 'webworker';
}
},
+ 'astro:build:start': ({ buildConfig }) => {
+ // Backwards compat
+ if(needsBuildConfig) {
+ buildConfig.client = new URL('./static/', _config.outDir);
+ buildConfig.server = new URL('./', _config.outDir);
+ buildConfig.serverEntry = '_worker.js';
+ }
+ },
'astro:build:done': async () => {
const entryUrl = new URL(_buildConfig.serverEntry, _buildConfig.server);
const pkg = fileURLToPath(entryUrl);
diff --git a/packages/integrations/deno/src/index.ts b/packages/integrations/deno/src/index.ts
index 839c6fb39..9b0032710 100644
--- a/packages/integrations/deno/src/index.ts
+++ b/packages/integrations/deno/src/index.ts
@@ -4,6 +4,11 @@ import * as fs from 'fs';
import * as npath from 'path';
import { fileURLToPath } from 'url';
+interface BuildConfig {
+ server: URL;
+ serverEntry: string;
+}
+
interface Options {
port?: number;
hostname?: string;
@@ -24,13 +29,16 @@ export function getAdapter(args?: Options): AstroAdapter {
}
export default function createIntegration(args?: Options): AstroIntegration {
- let _buildConfig: any;
+ let _buildConfig: BuildConfig;
let _vite: any;
+ let needsBuildConfig = false;
return {
name: '@astrojs/deno',
hooks: {
'astro:config:done': ({ setAdapter, config }) => {
+ needsBuildConfig = !config.build.client;
setAdapter(getAdapter(args));
+ _buildConfig = config.build;
if (config.output === 'static') {
console.warn(`[@astrojs/deno] \`output: "server"\` is required to use this adapter.`);
@@ -40,7 +48,10 @@ export default function createIntegration(args?: Options): AstroIntegration {
}
},
'astro:build:start': ({ buildConfig }) => {
- _buildConfig = buildConfig;
+ // Backwards compat
+ if(needsBuildConfig) {
+ _buildConfig = buildConfig;
+ }
},
'astro:build:setup': ({ vite, target }) => {
if (target === 'server') {
diff --git a/packages/integrations/image/src/index.ts b/packages/integrations/image/src/index.ts
index 3aaf27315..46b14b6b8 100644
--- a/packages/integrations/image/src/index.ts
+++ b/packages/integrations/image/src/index.ts
@@ -1,4 +1,4 @@
-import type { AstroConfig, AstroIntegration, BuildConfig } from 'astro';
+import type { AstroConfig, AstroIntegration } from 'astro';
import { ssgBuild } from './build/ssg.js';
import type { ImageService, SSRImageService, TransformOptions } from './loaders/index.js';
import type { LoggerLevel } from './utils/logger.js';
@@ -12,6 +12,11 @@ export { getPicture } from './lib/get-picture.js';
const PKG_NAME = '@astrojs/image';
const ROUTE_PATTERN = '/_image';
+interface BuildConfig {
+ client: URL;
+ server: URL;
+}
+
interface ImageIntegration {
loader?: ImageService;
defaultLoader: SSRImageService;
@@ -42,6 +47,7 @@ export default function integration(options: IntegrationOptions = {}): AstroInte
let _config: AstroConfig;
let _buildConfig: BuildConfig;
+ let needsBuildConfig = false;
// During SSG builds, this is used to track all transformed images required.
const staticImages = new Map<string, Map<string, TransformOptions>>();
@@ -67,8 +73,8 @@ export default function integration(options: IntegrationOptions = {}): AstroInte
name: PKG_NAME,
hooks: {
'astro:config:setup': async ({ command, config, updateConfig, injectRoute }) => {
+ needsBuildConfig = !config.build?.server;
_config = config;
-
updateConfig({ vite: getViteConfiguration() });
if (command === 'dev' || config.output === 'server') {
@@ -88,8 +94,15 @@ export default function integration(options: IntegrationOptions = {}): AstroInte
defaultLoader,
};
},
- 'astro:build:start': async ({ buildConfig }) => {
- _buildConfig = buildConfig;
+ 'astro:config:done': ({ config }) => {
+ _config = config;
+ _buildConfig = config.build;
+ },
+ 'astro:build:start': ({ buildConfig }) => {
+ // Backwards compat
+ if(needsBuildConfig) {
+ _buildConfig = buildConfig;
+ }
},
'astro:build:setup': async () => {
// Used to cache all images rendered to HTML
diff --git a/packages/integrations/netlify/src/integration-edge-functions.ts b/packages/integrations/netlify/src/integration-edge-functions.ts
index 11a18beb9..b69667dde 100644
--- a/packages/integrations/netlify/src/integration-edge-functions.ts
+++ b/packages/integrations/netlify/src/integration-edge-functions.ts
@@ -1,4 +1,4 @@
-import type { AstroAdapter, AstroConfig, AstroIntegration, BuildConfig, RouteData } from 'astro';
+import type { AstroAdapter, AstroConfig, AstroIntegration, RouteData } from 'astro';
import esbuild from 'esbuild';
import * as fs from 'fs';
import * as npath from 'path';
@@ -6,6 +6,12 @@ import { fileURLToPath } from 'url';
import type { Plugin as VitePlugin } from 'vite';
import { createRedirects } from './shared.js';
+interface BuildConfig {
+ server: URL;
+ client: URL;
+ serverEntry: string;
+}
+
const SHIM = `globalThis.process = {
argv: [],
env: {},
@@ -74,8 +80,8 @@ async function createEdgeManifest(routes: RouteData[], entryFile: string, dir: U
await fs.promises.writeFile(manifestURL, _manifest, 'utf-8');
}
-async function bundleServerEntry(buildConfig: BuildConfig, vite: any) {
- const entryUrl = new URL(buildConfig.serverEntry, buildConfig.server);
+async function bundleServerEntry({ serverEntry, server }: BuildConfig, vite: any) {
+ const entryUrl = new URL(serverEntry, server);
const pth = fileURLToPath(entryUrl);
await esbuild.build({
target: 'es2020',
@@ -96,7 +102,7 @@ async function bundleServerEntry(buildConfig: BuildConfig, vite: any) {
const chunkFileNames =
vite?.build?.rollupOptions?.output?.chunkFileNames ?? 'chunks/chunk.[hash].mjs';
const chunkPath = npath.dirname(chunkFileNames);
- const chunksDirUrl = new URL(chunkPath + '/', buildConfig.server);
+ const chunksDirUrl = new URL(chunkPath + '/', server);
await fs.promises.rm(chunksDirUrl, { recursive: true, force: true });
} catch {}
}
@@ -105,17 +111,13 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {})
let _config: AstroConfig;
let entryFile: string;
let _buildConfig: BuildConfig;
+ let needsBuildConfig = false;
let _vite: any;
return {
name: '@astrojs/netlify/edge-functions',
hooks: {
'astro:config:setup': ({ config, updateConfig }) => {
- if (dist) {
- config.outDir = dist;
- } else {
- config.outDir = new URL('./dist/', config.root);
- }
-
+ needsBuildConfig = !config.build.client;
// Add a plugin that shims the global environment.
const injectPlugin: VitePlugin = {
name: '@astrojs/netlify/plugin-inject',
@@ -128,8 +130,14 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {})
}
},
};
-
+ const outDir = dist ?? new URL('./dist/', config.root);
updateConfig({
+ outDir,
+ build: {
+ client: outDir,
+ server: new URL('./.netlify/edge-functions/', config.root),
+ serverEntry: 'entry.js',
+ },
vite: {
plugins: [injectPlugin],
},
@@ -138,6 +146,8 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {})
'astro:config:done': ({ config, setAdapter }) => {
setAdapter(getAdapter());
_config = config;
+ _buildConfig = config.build;
+ entryFile = config.build.serverEntry.replace(/\.m?js/, '');
if (config.output === 'static') {
console.warn(`[@astrojs/netlify] \`output: "server"\` is required to use this adapter.`);
@@ -146,12 +156,14 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {})
);
}
},
- 'astro:build:start': async ({ buildConfig }) => {
- _buildConfig = buildConfig;
- entryFile = buildConfig.serverEntry.replace(/\.m?js/, '');
- buildConfig.client = _config.outDir;
- buildConfig.server = new URL('./.netlify/edge-functions/', _config.root);
- buildConfig.serverEntry = 'entry.js';
+ 'astro:build:start': ({ buildConfig }) => {
+ if(needsBuildConfig) {
+ buildConfig.client = _config.outDir;
+ buildConfig.server = new URL('./.netlify/edge-functions/', _config.root);
+ buildConfig.serverEntry = 'entry.js';
+ _buildConfig = buildConfig;
+ entryFile = buildConfig.serverEntry.replace(/\.m?js/, '');
+ }
},
'astro:build:setup': ({ vite, target }) => {
if (target === 'server') {
diff --git a/packages/integrations/netlify/src/integration-functions.ts b/packages/integrations/netlify/src/integration-functions.ts
index d78fb1f32..025250bc1 100644
--- a/packages/integrations/netlify/src/integration-functions.ts
+++ b/packages/integrations/netlify/src/integration-functions.ts
@@ -22,19 +22,25 @@ function netlifyFunctions({
}: NetlifyFunctionsOptions = {}): AstroIntegration {
let _config: AstroConfig;
let entryFile: string;
+ let needsBuildConfig = false;
return {
name: '@astrojs/netlify',
hooks: {
- 'astro:config:setup': ({ config }) => {
- if (dist) {
- config.outDir = dist;
- } else {
- config.outDir = new URL('./dist/', config.root);
- }
+ 'astro:config:setup': ({ config, updateConfig }) => {
+ needsBuildConfig = !config.build.client;
+ const outDir = dist ?? new URL('./dist/', config.root);
+ updateConfig({
+ outDir,
+ build: {
+ client: outDir,
+ server: new URL('./.netlify/functions-internal/', config.root),
+ }
+ });
},
'astro:config:done': ({ config, setAdapter }) => {
setAdapter(getAdapter({ binaryMediaTypes }));
_config = config;
+ entryFile = config.build.serverEntry.replace(/\.m?js/, '');
if (config.output === 'static') {
console.warn(`[@astrojs/netlify] \`output: "server"\` is required to use this adapter.`);
@@ -43,10 +49,12 @@ function netlifyFunctions({
);
}
},
- 'astro:build:start': async ({ buildConfig }) => {
- entryFile = buildConfig.serverEntry.replace(/\.m?js/, '');
- buildConfig.client = _config.outDir;
- buildConfig.server = new URL('./.netlify/functions-internal/', _config.root);
+ 'astro:build:start': ({ buildConfig }) => {
+ if(needsBuildConfig) {
+ buildConfig.client = _config.outDir;
+ buildConfig.server = new URL('./.netlify/functions-internal/', _config.root);
+ entryFile = buildConfig.serverEntry.replace(/\.m?js/, '');
+ }
},
'astro:build:done': async ({ routes, dir }) => {
await createRedirects(routes, dir, entryFile, false);
diff --git a/packages/integrations/node/README.md b/packages/integrations/node/README.md
index 7c95dd0ea..9e0f8150e 100644
--- a/packages/integrations/node/README.md
+++ b/packages/integrations/node/README.md
@@ -1,23 +1,23 @@
-# @astrojs/node 🔲
+# @astrojs/node
This adapter allows Astro to deploy your SSR site to Node targets.
- <strong>[Why Astro Node](#why-astro-node)</strong>
- <strong>[Installation](#installation)</strong>
-- <strong>[Usage](#usage)</strong>
- <strong>[Configuration](#configuration)</strong>
+- <strong>[Usage](#usage)</strong>
- <strong>[Troubleshooting](#troubleshooting)</strong>
- <strong>[Contributing](#contributing)</strong>
- <strong>[Changelog](#changelog)</strong>
-## Why Astro Node
+## Why @astrojs/node
If you're using Astro as a static site builder—its behavior out of the box—you don't need an adapter.
If you wish to [use server-side rendering (SSR)](https://docs.astro.build/en/guides/server-side-rendering/), Astro requires an adapter that matches your deployment runtime.
-[Node](https://nodejs.org/en/) is a JavaScript runtime for server-side code. Frameworks like [Express](https://expressjs.com/) are built on top of it and make it easier to write server applications in Node. This adapter provides access to Node's API and creates a script to run your Astro project that can be utilized in Node applications.
+[Node.js](https://nodejs.org/en/) is a JavaScript runtime for server-side code. @astrojs/node can be used either in standalone mode or as middleware for other http servers, such as [Express](https://expressjs.com/).
## Installation
@@ -42,23 +42,47 @@ If you prefer to install the adapter manually instead, complete the following tw
1. Add two new lines to your `astro.config.mjs` project configuration file.
- ```js title="astro.config.mjs" ins={2, 5-6}
+ ```js title="astro.config.mjs" ins={2, 5-8}
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
export default defineConfig({
output: 'server',
- adapter: node(),
+ adapter: node({
+ mode: 'standalone'
+ }),
});
```
+## Configuration
+
+@astrojs/node can be configured by passing options into the adapter function. The following options are available:
+
+### Mode
+
+Controls whether the adapter builds to `middleware` or `standalone` mode.
+
+- `middleware` mode allows the built output to be used as middleware for another Node.js server, like Express.js or Fastify.
+ ```js
+ import { defineConfig } from 'astro/config';
+ import nodejs from '@astrojs/node';
+
+ export default defineConfig({
+ output: 'server',
+ adapter: node({
+ mode: 'middleware'
+ }),
+ });
+ ```
+- `standalone` mode builds to server that automatically starts with the entry module is run. This allows you to more easily deploy your build to a host without any additional code.
+
## Usage
-After [performing a build](https://docs.astro.build/en/guides/deploy/#building-your-site-locally) there will be a `dist/server/entry.mjs` module that exposes a `handler` function. This works like a [middleware](https://expressjs.com/en/guide/using-middleware.html) function: it can handle incoming requests and respond accordingly.
+First, [performing a build](https://docs.astro.build/en/guides/deploy/#building-your-site-locally). Depending on which `mode` selected (see above) follow the appropriate steps below:
+### Middleware
-### Using a middleware framework
-You can use this `handler` with any framework that supports the Node `request` and `response` objects.
+The server entrypoint is built to `./dist/server/entry.mjs` by default. This module exports a `handler` function that can be used with any framework that supports the Node `request` and `response` objects.
For example, with Express:
@@ -73,40 +97,27 @@ app.use(ssrHandler);
app.listen(8080);
```
+Note that middleware mode does not do file servering. You'll need to configure your HTTP framework to do that for you. By default the client assets are written to `./dist/client/`.
-### Using `http`
-
-This output script does not require you use Express and can work with even the built-in `http` and `https` node modules. The handler does follow the convention calling an error function when either
+### Standalone
-- A route is not found for the request.
-- There was an error rendering.
+In standalone mode a server starts when the server entrypoint is run. By default it is built to `./dist/server/entry.mjs`. You can run it with:
-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);
+```shell
+node ./dist/server/entry.mjs
```
+For standalone mode the server handles file servering in addition to the page and API routes.
+#### HTTPS
-## Configuration
+By default the standalone server uses HTTP. This works well if you have a proxy server in front of it that does HTTPS. If you need the standalone server to run HTTPS itself you need to provide your SSL key and certificate.
-This adapter does not expose any configuration options.
+You can pass the path to your key and certification via the environment variables `SERVER_CERT_PATH` and `SERVER_KEY_PATH`. This is how you might pass them in bash:
+
+```bash
+SERVER_KEY_PATH=./private/key.pem SERVER_CERT_PATH=./private/cert.pem node ./dist/server/entry.mjs
+```
## Troubleshooting
diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json
index ffe8c07d8..77a027cd9 100644
--- a/packages/integrations/node/package.json
+++ b/packages/integrations/node/package.json
@@ -20,6 +20,7 @@
"exports": {
".": "./dist/index.js",
"./server.js": "./dist/server.js",
+ "./preview.js": "./dist/preview.js",
"./package.json": "./package.json"
},
"scripts": {
@@ -29,9 +30,11 @@
"test": "mocha --exit --timeout 20000 test/"
},
"dependencies": {
- "@astrojs/webapi": "^1.1.0"
+ "@astrojs/webapi": "^1.1.0",
+ "send": "^0.18.0"
},
"devDependencies": {
+ "@types/send": "^0.17.1",
"astro": "workspace:*",
"astro-scripts": "workspace:*",
"chai": "^4.3.6",
diff --git a/packages/integrations/node/src/http-server.ts b/packages/integrations/node/src/http-server.ts
new file mode 100644
index 000000000..34192c5f9
--- /dev/null
+++ b/packages/integrations/node/src/http-server.ts
@@ -0,0 +1,77 @@
+import fs from 'fs';
+import http from 'http';
+import https from 'https';
+import { fileURLToPath } from 'url';
+import send from 'send';
+
+interface CreateServerOptions {
+ client: URL;
+ port: number;
+ host: string | undefined;
+}
+
+export function createServer({ client, port, host }: CreateServerOptions, handler: http.RequestListener) {
+ const listener: http.RequestListener = (req, res) => {
+ if(req.url) {
+ const fileURL = new URL('.' + req.url, client);
+
+ const stream = send(req, fileURLToPath(fileURL), {
+ dotfiles: 'deny',
+ });
+
+ let forwardError = false;
+
+ stream.on('error', err => {
+ if(forwardError) {
+ // eslint-disable-next-line no-console
+ console.error(err.toString());
+ res.writeHead(500);
+ res.end('Internal server error');
+ return;
+ }
+ // File not found, forward to the SSR handler
+ handler(req, res);
+ });
+
+ stream.on('file', () => {
+ forwardError = true;
+ });
+ stream.pipe(res);
+ } else {
+ handler(req, res);
+ }
+ };
+
+ let httpServer: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse> |
+ https.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
+
+ if(process.env.SERVER_CERT_PATH && process.env.SERVER_KEY_PATH) {
+ httpServer = https.createServer({
+ key: fs.readFileSync(process.env.SERVER_KEY_PATH),
+ cert: fs.readFileSync(process.env.SERVER_CERT_PATH),
+ }, listener);
+ } else {
+ httpServer = http.createServer(listener);
+ }
+ httpServer.listen(port, host);
+
+ // Resolves once the server is closed
+ const closed = new Promise<void>((resolve, reject) => {
+ httpServer.addListener('close', resolve);
+ httpServer.addListener('error', reject);
+ });
+
+ return {
+ host,
+ port,
+ closed() {
+ return closed;
+ },
+ server: httpServer,
+ stop: async () => {
+ await new Promise((resolve, reject) => {
+ httpServer.close((err) => (err ? reject(err) : resolve(undefined)));
+ });
+ },
+ };
+}
diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts
index 53b94b916..80dfacdab 100644
--- a/packages/integrations/node/src/index.ts
+++ b/packages/integrations/node/src/index.ts
@@ -1,24 +1,48 @@
import type { AstroAdapter, AstroIntegration } from 'astro';
+import type { Options, UserOptions } from './types';
-export function getAdapter(): AstroAdapter {
+export function getAdapter(options: Options): AstroAdapter {
return {
name: '@astrojs/node',
serverEntrypoint: '@astrojs/node/server.js',
+ previewEntrypoint: '@astrojs/node/preview.js',
exports: ['handler'],
+ args: options
};
}
-export default function createIntegration(): AstroIntegration {
+export default function createIntegration(userOptions: UserOptions): AstroIntegration {
+ if(!userOptions?.mode) {
+ throw new Error(`[@astrojs/node] Setting the 'mode' option is required.`)
+ }
+
+ let needsBuildConfig = false;
+ let _options: Options;
return {
name: '@astrojs/node',
hooks: {
'astro:config:done': ({ setAdapter, config }) => {
- setAdapter(getAdapter());
+ needsBuildConfig = !config.build?.server;
+ _options = {
+ ...userOptions,
+ client: config.build.client?.toString(),
+ server: config.build.server?.toString(),
+ host: config.server.host,
+ port: config.server.port,
+ };
+ setAdapter(getAdapter(_options));
if (config.output === 'static') {
console.warn(`[@astrojs/node] \`output: "server"\` is required to use this adapter.`);
}
},
+ 'astro:build:start': ({ buildConfig }) => {
+ // Backwards compat
+ if(needsBuildConfig) {
+ _options.client = buildConfig.client.toString();
+ _options.server = buildConfig.server.toString();
+ }
+ }
},
};
}
diff --git a/packages/integrations/node/src/middleware.ts b/packages/integrations/node/src/middleware.ts
new file mode 100644
index 000000000..772461f2a
--- /dev/null
+++ b/packages/integrations/node/src/middleware.ts
@@ -0,0 +1,53 @@
+import type { NodeApp } from 'astro/app/node';
+import type { IncomingMessage, ServerResponse } from 'http';
+import type { Readable } from 'stream';
+
+export default function(app: NodeApp) {
+ return async function(req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) {
+ try {
+ const route = app.match(req);
+
+ if (route) {
+ try {
+ const response = await app.render(req);
+ await writeWebResponse(app, res, response);
+ } catch (err: unknown) {
+ if (next) {
+ next(err);
+ } else {
+ throw err;
+ }
+ }
+ } else if (next) {
+ return next();
+ } else {
+ res.writeHead(404);
+ res.end('Not found');
+ }
+ } catch (err: unknown) {
+ if (!res.headersSent) {
+ res.writeHead(500, `Server error`);
+ res.end();
+ }
+ }
+ };
+}
+
+async function writeWebResponse(app: NodeApp, res: ServerResponse, webResponse: Response) {
+ const { status, headers, body } = webResponse;
+
+ if (app.setCookieHeaders) {
+ const setCookieHeaders: Array<string> = Array.from(app.setCookieHeaders(webResponse));
+ if (setCookieHeaders.length) {
+ res.setHeader('Set-Cookie', setCookieHeaders);
+ }
+ }
+
+ res.writeHead(status, Object.fromEntries(headers.entries()));
+ if (body) {
+ for await (const chunk of body as unknown as Readable) {
+ res.write(chunk);
+ }
+ }
+ res.end();
+}
diff --git a/packages/integrations/node/src/preview.ts b/packages/integrations/node/src/preview.ts
new file mode 100644
index 000000000..33c2f18e2
--- /dev/null
+++ b/packages/integrations/node/src/preview.ts
@@ -0,0 +1,54 @@
+import type { CreatePreviewServer } from 'astro';
+import type { createExports } from './server';
+import http from 'http';
+import { fileURLToPath } from 'url';
+import { createServer } from './http-server.js';
+
+const preview: CreatePreviewServer = async function({
+ client,
+ serverEntrypoint,
+ host,
+ port,
+}) {
+ type ServerModule = ReturnType<typeof createExports>;
+ type MaybeServerModule = Partial<ServerModule>;
+ let ssrHandler: ServerModule['handler'];
+ try {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ const ssrModule: MaybeServerModule = await import(serverEntrypoint.toString());
+ if(typeof ssrModule.handler === 'function') {
+ ssrHandler = ssrModule.handler;
+ } else {
+ throw new Error(`The server entrypoint doesn't have a handler. Are you sure this is the right file?`);
+ }
+ } catch(_err) {
+ throw new Error(`The server entrypoint ${fileURLToPath} does not exist. Have you ran a build yet?`);
+ }
+
+ const handler: http.RequestListener = (req, res) => {
+ ssrHandler(req, res, (ssrErr: any) => {
+ if (ssrErr) {
+ res.writeHead(500);
+ res.end(ssrErr.toString());
+ } else {
+ res.writeHead(404);
+ res.end();
+ }
+ });
+ };
+
+ const server = createServer({
+ client,
+ port,
+ host,
+ }, handler);
+
+ // eslint-disable-next-line no-console
+ console.log(`Preview server listening on http://${host}:${port}`);
+
+ return server;
+}
+
+export {
+ preview as default
+};
diff --git a/packages/integrations/node/src/server.ts b/packages/integrations/node/src/server.ts
index 6ecd14931..202e66b7e 100644
--- a/packages/integrations/node/src/server.ts
+++ b/packages/integrations/node/src/server.ts
@@ -1,8 +1,9 @@
-import { polyfill } from '@astrojs/webapi';
import type { SSRManifest } from 'astro';
+import type { Options } from './types';
+import { polyfill } from '@astrojs/webapi';
import { NodeApp } from 'astro/app/node';
-import type { IncomingMessage, ServerResponse } from 'http';
-import type { Readable } from 'stream';
+import middleware from './middleware.js';
+import startServer from './standalone.js';
polyfill(globalThis, {
exclude: 'window document',
@@ -11,49 +12,15 @@ polyfill(globalThis, {
export function createExports(manifest: SSRManifest) {
const app = new NodeApp(manifest);
return {
- async handler(req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) {
- try {
- const route = app.match(req);
-
- if (route) {
- try {
- const response = await app.render(req);
- await writeWebResponse(app, res, response);
- } catch (err: unknown) {
- if (next) {
- next(err);
- } else {
- throw err;
- }
- }
- } else if (next) {
- return next();
- }
- } catch (err: unknown) {
- if (!res.headersSent) {
- res.writeHead(500, `Server error`);
- res.end();
- }
- }
- },
+ handler: middleware(app)
};
}
-async function writeWebResponse(app: NodeApp, res: ServerResponse, webResponse: Response) {
- const { status, headers, body } = webResponse;
-
- if (app.setCookieHeaders) {
- const setCookieHeaders: Array<string> = Array.from(app.setCookieHeaders(webResponse));
- if (setCookieHeaders.length) {
- res.setHeader('Set-Cookie', setCookieHeaders);
- }
+export function start(manifest: SSRManifest, options: Options) {
+ if(options.mode !== 'standalone' || process.env.ASTRO_NODE_AUTOSTART === 'disabled') {
+ return;
}
- res.writeHead(status, Object.fromEntries(headers.entries()));
- if (body) {
- for await (const chunk of body as unknown as Readable) {
- res.write(chunk);
- }
- }
- res.end();
+ const app = new NodeApp(manifest);
+ startServer(app, options);
}
diff --git a/packages/integrations/node/src/standalone.ts b/packages/integrations/node/src/standalone.ts
new file mode 100644
index 000000000..8fef96ed5
--- /dev/null
+++ b/packages/integrations/node/src/standalone.ts
@@ -0,0 +1,53 @@
+import type { NodeApp } from 'astro/app/node';
+import type { Options } from './types';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import middleware from './middleware.js';
+import { createServer } from './http-server.js';
+
+function resolvePaths(options: Options) {
+ const clientURLRaw = new URL(options.client);
+ const serverURLRaw = new URL(options.server);
+ const rel = path.relative(fileURLToPath(serverURLRaw), fileURLToPath(clientURLRaw));
+
+ const serverEntryURL = new URL(import.meta.url);
+ const clientURL = new URL(appendForwardSlash(rel), serverEntryURL);
+
+ return {
+ client: clientURL,
+ };
+}
+
+function appendForwardSlash(pth: string) {
+ return pth.endsWith('/') ? pth : pth + '/';
+}
+
+export function getResolvedHostForHttpServer(host: string | boolean) {
+ if (host === false) {
+ // Use a secure default
+ return '127.0.0.1';
+ } else if (host === true) {
+ // If passed --host in the CLI without arguments
+ return undefined; // undefined typically means 0.0.0.0 or :: (listen on all IPs)
+ } else {
+ return host;
+ }
+}
+
+export default function startServer(app: NodeApp, options: Options) {
+ const port = process.env.PORT ? Number(process.env.port) : (options.port ?? 8080);
+ const { client } = resolvePaths(options);
+ const handler = middleware(app);
+
+ const host = getResolvedHostForHttpServer(options.host);
+ const server = createServer({
+ client,
+ port,
+ host,
+ }, handler);
+
+ // eslint-disable-next-line no-console
+ console.log(`Server listening on http://${host}:${port}`);
+
+ return server.closed();
+}
diff --git a/packages/integrations/node/src/types.ts b/packages/integrations/node/src/types.ts
new file mode 100644
index 000000000..aaf3be942
--- /dev/null
+++ b/packages/integrations/node/src/types.ts
@@ -0,0 +1,17 @@
+
+export interface UserOptions {
+ /**
+ * Specifies the mode that the adapter builds to.
+ *
+ * - 'middleware' - Build to middleware, to be used within another Node.js server, such as Express.
+ * - 'standalone' - Build to a standalone server. The server starts up just by running the built script.
+ */
+ mode: 'middleware' | 'standalone';
+}
+
+export interface Options extends UserOptions {
+ host: string | boolean;
+ port: number;
+ server: string;
+ client: string;
+}
diff --git a/packages/integrations/node/test/api-route.test.js b/packages/integrations/node/test/api-route.test.js
index cd074ef27..2cc15c761 100644
--- a/packages/integrations/node/test/api-route.test.js
+++ b/packages/integrations/node/test/api-route.test.js
@@ -10,7 +10,7 @@ describe('API routes', () => {
fixture = await loadFixture({
root: './fixtures/api-route/',
output: 'server',
- adapter: nodejs(),
+ adapter: nodejs({ mode: 'middleware' }),
});
await fixture.build();
});
diff --git a/packages/integrations/vercel/src/edge/adapter.ts b/packages/integrations/vercel/src/edge/adapter.ts
index 971aa8eae..ecd13e1f8 100644
--- a/packages/integrations/vercel/src/edge/adapter.ts
+++ b/packages/integrations/vercel/src/edge/adapter.ts
@@ -17,16 +17,35 @@ export default function vercelEdge(): AstroIntegration {
let _config: AstroConfig;
let functionFolder: URL;
let serverEntry: string;
+ let needsBuildConfig = false;
return {
name: PACKAGE_NAME,
hooks: {
- 'astro:config:setup': ({ config }) => {
- config.outDir = getVercelOutput(config.root);
+ 'astro:config:setup': ({ config, updateConfig }) => {
+ needsBuildConfig = !config.build.client;
+ const outDir = getVercelOutput(config.root);
+ updateConfig({
+ outDir,
+ build: {
+ serverEntry: 'entry.mjs',
+ client: new URL('./static/', outDir),
+ server: new URL('./functions/render.func/', config.outDir),
+ }
+ });
},
'astro:config:done': ({ setAdapter, config }) => {
setAdapter(getAdapter());
_config = config;
+ serverEntry = config.build.serverEntry;
+ functionFolder = config.build.server;
+ },
+ 'astro:build:start': ({ buildConfig }) => {
+ if(needsBuildConfig) {
+ buildConfig.client = new URL('./static/', _config.outDir);
+ serverEntry = buildConfig.serverEntry = 'entry.mjs';
+ functionFolder = buildConfig.server = new URL('./functions/render.func/', _config.outDir);
+ }
},
'astro:build:setup': ({ vite, target }) => {
if (target === 'server') {
@@ -49,11 +68,6 @@ export default function vercelEdge(): AstroIntegration {
};
}
},
- 'astro:build:start': async ({ buildConfig }) => {
- buildConfig.serverEntry = serverEntry = 'entry.mjs';
- buildConfig.client = new URL('./static/', _config.outDir);
- buildConfig.server = functionFolder = new URL('./functions/render.func/', _config.outDir);
- },
'astro:build:done': async ({ routes }) => {
// Edge function config
// https://vercel.com/docs/build-output-api/v3#vercel-primitives/edge-functions/configuration
diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts
index f5ae4e8cb..dfc0367ae 100644
--- a/packages/integrations/vercel/src/serverless/adapter.ts
+++ b/packages/integrations/vercel/src/serverless/adapter.ts
@@ -19,16 +19,29 @@ export default function vercelEdge(): AstroIntegration {
let buildTempFolder: URL;
let functionFolder: URL;
let serverEntry: string;
+ let needsBuildConfig = false;
return {
name: PACKAGE_NAME,
hooks: {
- 'astro:config:setup': ({ config }) => {
- config.outDir = getVercelOutput(config.root);
+ 'astro:config:setup': ({ config, updateConfig }) => {
+ needsBuildConfig = !config.build.client;
+ const outDir = getVercelOutput(config.root);
+ updateConfig({
+ outDir,
+ build: {
+ serverEntry: 'entry.js',
+ client: new URL('./static/', outDir),
+ server: new URL('./dist/', config.root),
+ }
+ });
},
'astro:config:done': ({ setAdapter, config }) => {
setAdapter(getAdapter());
_config = config;
+ buildTempFolder = config.build.server;
+ functionFolder = new URL('./functions/render.func/', config.outDir);
+ serverEntry = config.build.serverEntry;
if (config.output === 'static') {
throw new Error(`
@@ -37,11 +50,12 @@ export default function vercelEdge(): AstroIntegration {
`);
}
},
- 'astro:build:start': async ({ buildConfig }) => {
- buildConfig.serverEntry = serverEntry = 'entry.js';
- buildConfig.client = new URL('./static/', _config.outDir);
- buildConfig.server = buildTempFolder = new URL('./dist/', _config.root);
- functionFolder = new URL('./functions/render.func/', _config.outDir);
+ 'astro:build:start': ({ buildConfig }) => {
+ if(needsBuildConfig) {
+ buildConfig.client = new URL('./static/', _config.outDir);
+ buildTempFolder = buildConfig.server = new URL('./dist/', _config.root);
+ serverEntry = buildConfig.serverEntry = 'entry.js';
+ }
},
'astro:build:done': async ({ routes }) => {
// Copy necessary files (e.g. node_modules/)