Node.js standalone mode + support for astro preview (#5056)

* wip

* Deprecate buildConfig and move to config.build

* Implement the standalone server

* Stay backwards compat

* Add changesets

* correctly merge URLs

* Get config earlier

* update node tests

* Return the preview server

* update remaining tests

* swap usage and config ordering

* Update packages/astro/src/@types/astro.ts

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/metal-pumas-walk.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/metal-pumas-walk.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/stupid-points-refuse.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Update .changeset/stupid-points-refuse.md

Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>

* Link to build.server config

Co-authored-by: Fred K. Schott <fkschott@gmail.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Matthew Phillips 2022-10-12 17:25:51 -04:00 committed by GitHub
parent 2b7fb848bb
commit e55af8a232
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 1094 additions and 361 deletions

View file

@ -0,0 +1,38 @@
---
'astro': minor
'@astrojs/node': minor
---
# Adapter support for `astro preview`
Adapters are now about to support the `astro preview` command via a new integration option. The Node.js adapter `@astrojs/node` is the first of the built-in adapters to gain support for this. What this means is that if you are using `@astrojs/node` you can new preview your SSR app by running:
```shell
npm run preview
```
## Adapter API
We will be updating the other first party Astro adapters to support preview over time. Adapters can opt-in to this feature by providing the `previewEntrypoint` via the `setAdapter` function in `astro:config:done` hook. The Node.js adapter's code looks like this:
```diff
export default function() {
return {
name: '@astrojs/node',
hooks: {
'astro:config:done': ({ setAdapter, config }) => {
setAdapter({
name: '@astrojs/node',
serverEntrypoint: '@astrojs/node/server.js',
+ previewEntrypoint: '@astrojs/node/preview.js',
exports: ['handler'],
});
// more here
}
}
};
}
```
The `previewEntrypoint` is a module in the adapter's package that is a Node.js script. This script is run when `astro preview` is run and is charged with starting up the built server. See the Node.js implementation in `@astrojs/node` to see how that is implemented.

View file

@ -0,0 +1,43 @@
---
'@astrojs/node': major
---
# Standalone mode for the Node.js adapter
New in `@astrojs/node` is support for __standalone mode__. With standalone mode you can start your production server without needing to write any server JavaScript yourself. The server starts simply by running the script like so:
```shell
node ./dist/server/entry.mjs
```
To enable standalone mode, set the new `mode` to `'standalone'` option in your Astro config:
```js
import { defineConfig } from 'astro/config';
import nodejs from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: nodejs({
mode: 'standalone'
})
});
```
See the @astrojs/node documentation to learn all of the options available in standalone mode.
## Breaking change
This is a semver major change because the new `mode` option is required. Existing @astrojs/node users who are using their own HTTP server framework such as Express can upgrade by setting the `mode` option to `'middleware'` in order to build to a middleware mode, which is the same behavior and API as before.
```js
import { defineConfig } from 'astro/config';
import nodejs from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: nodejs({
mode: 'middleware'
})
});
```

View file

@ -0,0 +1,49 @@
---
'astro': minor
'@astrojs/cloudflare': minor
'@astrojs/deno': minor
'@astrojs/image': minor
'@astrojs/netlify': minor
'@astrojs/node': minor
'@astrojs/vercel': minor
---
# New build configuration
The ability to customize SSR build configuration more granularly is now available in Astro. You can now customize the output folder for `server` (the server code for SSR), `client` (your client-side JavaScript and assets), and `serverEntry` (the name of the entrypoint server module). Here are the defaults:
```js
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'server',
build: {
server: './dist/server/',
client: './dist/client/',
serverEntry: 'entry.mjs',
}
});
```
These new configuration options are only supported in SSR mode and are ignored when building to SSG (a static site).
## Integration hook change
The integration hook `astro:build:start` includes a param `buildConfig` which includes all of these same options. You can continue to use this param in Astro 1.x, but it is deprecated in favor of the new `build.config` options. All of the built-in adapters have been updated to the new format. If you have an integration that depends on this param we suggest upgrading to do this instead:
```js
export default function myIntegration() {
return {
name: 'my-integration',
hooks: {
'astro:config:setup': ({ updateConfig }) => {
updateConfig({
build: {
server: '...'
}
});
}
}
}
}
```

View file

@ -5,6 +5,8 @@ import node from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
output: 'server',
adapter: node(),
adapter: node({
mode: 'standalone'
}),
integrations: [svelte()],
});

View file

@ -9,7 +9,7 @@
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"server": "node server/server.mjs"
"server": "node dist/server/entry.mjs"
},
"devDependencies": {},
"dependencies": {

View file

@ -1,44 +0,0 @@
import { createServer } from 'http';
import fs from 'fs';
import mime from 'mime';
import { handler as ssrHandler } from '../dist/server/entry.mjs';
const clientRoot = new URL('../dist/client/', import.meta.url);
async function handle(req, res) {
ssrHandler(req, res, async (err) => {
if (err) {
res.writeHead(500);
res.end(err.stack);
return;
}
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) => {
handle(req, res).catch((err) => {
console.error(err);
res.writeHead(500, {
'Content-Type': 'text/plain',
});
res.end(err.toString());
});
});
server.listen(8085);
console.log('Serving at http://localhost:8085');
// Silence weird <time> warning
console.error = () => {};

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
const settings = await runHookConfigSetup({
settings: _settings,
command: 'preview',
logging: logging,
});
// 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 { 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;
if (settings.config.output === 'static') {
const server = await createStaticPreviewServer(settings, { logging, host, port });
return server;
}
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);
/** 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();
});
const previewModule = (await import(previewEntrypoint)) as Partial<PreviewModule>;
if(typeof previewModule.default !== 'function') {
throw new Error(`[preview] ${settings.adapter.name} cannot preview your app.`);
}
// 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 {
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;
}

View file

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

View file

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

View file

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

View file

@ -3,5 +3,5 @@ import nodejs from '@astrojs/node';
export default defineConfig({
output: 'server',
adapter: nodejs(),
adapter: nodejs({ mode: 'middleware' }),
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2916,14 +2916,18 @@ importers:
packages/integrations/node:
specifiers:
'@astrojs/webapi': ^1.1.0
'@types/send': ^0.17.1
astro: workspace:*
astro-scripts: workspace:*
chai: ^4.3.6
mocha: ^9.2.2
node-mocks-http: ^1.11.0
send: ^0.18.0
dependencies:
'@astrojs/webapi': link:../../webapi
'@astrojs/webapi': 1.1.0
send: 0.18.0
devDependencies:
'@types/send': 0.17.1
astro: link:../../astro
astro-scripts: link:../../../scripts
chai: 4.3.6
@ -3785,6 +3789,13 @@ packages:
vfile-message: 3.1.2
dev: false
/@astrojs/webapi/1.1.0:
resolution: {integrity: sha512-yLSksFKv9kRbI3WWPuRvbBjS+J5ZNmZHacJ6Io8XQleKIHHHcw7RoNcrLK0s+9iwVPhqMYIzja6HJuvnO93oFw==}
dependencies:
global-agent: 3.0.0
node-fetch: 3.2.10
dev: false
/@babel/code-frame/7.18.6:
resolution: {integrity: sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==}
engines: {node: '>=6.9.0'}
@ -11193,6 +11204,17 @@ packages:
engines: {node: '>=0.11'}
dev: false
/debug/2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dependencies:
ms: 2.0.0
dev: false
/debug/4.3.3_supports-color@8.1.1:
resolution: {integrity: sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==}
engines: {node: '>=6.0'}
@ -11338,7 +11360,6 @@ packages:
/depd/2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dev: true
/deprecation/2.3.1:
resolution: {integrity: sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==}
@ -11348,6 +11369,11 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
/destroy/1.2.0:
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
dev: false
/detect-indent/6.1.0:
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
engines: {node: '>=8'}
@ -11468,6 +11494,10 @@ packages:
sigmund: 1.0.1
dev: true
/ee-first/1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
dev: false
/ejs/3.1.8:
resolution: {integrity: sha512-/sXZeMlhS0ArkfX2Aw780gJzXSMPnKjtspYZv+f3NiKLlubezAHDU5+9xz6gd3/NhG3txQCo6xlglmTS+oTGEQ==}
engines: {node: '>=0.10.0'}
@ -11493,6 +11523,11 @@ packages:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
dev: false
/encodeurl/1.0.2:
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'}
dev: false
/end-of-stream/1.4.4:
resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
dependencies:
@ -12164,6 +12199,10 @@ packages:
resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==}
engines: {node: '>=6'}
/escape-html/1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
dev: false
/escape-string-regexp/1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
@ -12391,6 +12430,11 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
/etag/1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
dev: false
/event-target-shim/5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
@ -12613,7 +12657,6 @@ packages:
/fresh/0.5.2:
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
dev: true
/fs-constants/1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@ -13179,7 +13222,6 @@ packages:
setprototypeof: 1.2.0
statuses: 2.0.1
toidentifier: 1.0.1
dev: true
/http-proxy-agent/4.0.1:
resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==}
@ -14502,7 +14544,6 @@ packages:
resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
engines: {node: '>=4'}
hasBin: true
dev: true
/mime/3.0.0:
resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
@ -14673,12 +14714,15 @@ packages:
engines: {node: '>=10'}
dev: false
/ms/2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
dev: false
/ms/2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
/ms/2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: true
/mustache/4.2.0:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
@ -14886,6 +14930,13 @@ packages:
has-symbols: 1.0.3
object-keys: 1.1.1
/on-finished/2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
dependencies:
ee-first: 1.1.1
dev: false
/once/1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
dependencies:
@ -15805,7 +15856,6 @@ packages:
/range-parser/1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
dev: true
/raw-body/2.5.1:
resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==}
@ -16429,6 +16479,27 @@ packages:
dependencies:
lru-cache: 6.0.0
/send/0.18.0:
resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==}
engines: {node: '>= 0.8.0'}
dependencies:
debug: 2.6.9
depd: 2.0.0
destroy: 1.2.0
encodeurl: 1.0.2
escape-html: 1.0.3
etag: 1.8.1
fresh: 0.5.2
http-errors: 2.0.0
mime: 1.6.0
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.2.1
statuses: 2.0.1
transitivePeerDependencies:
- supports-color
dev: false
/serialize-error/7.0.1:
resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==}
engines: {node: '>=10'}
@ -16456,7 +16527,6 @@ packages:
/setprototypeof/1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
dev: true
/sharp/0.31.1:
resolution: {integrity: sha512-GR8M1wBwOiFKLkm9JPun27OQnNRZdHfSf9VwcdZX6UrRmM1/XnOrLFTF0GAil+y/YK4E6qcM/ugxs80QirsHxg==}
@ -16731,7 +16801,6 @@ packages:
/statuses/2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'}
dev: true
/stream-transform/2.1.3:
resolution: {integrity: sha512-9GHUiM5hMiCi6Y03jD2ARC1ettBXkQBoQAe7nJsPknnI0ow10aXjTnew8QtYQmLjzn974BnmWEAJgCY6ZP1DeQ==}
@ -17136,7 +17205,6 @@ packages:
/toidentifier/1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
dev: true
/totalist/1.1.0:
resolution: {integrity: sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==}