From 360ec9f2ba918a6cb0fb4daf475685cc3ffeab2e Mon Sep 17 00:00:00 2001 From: "Fred K. Schott" Date: Mon, 26 Sep 2022 11:58:26 -0700 Subject: [PATCH 01/17] wip --- packages/astro/src/@types/astro.ts | 3 +- packages/astro/src/core/preview/index.ts | 187 +++--------------- .../src/core/preview/static-preview-server.ts | 164 +++++++++++++++ packages/astro/src/integrations/index.ts | 2 +- packages/integrations/node/README.md | 2 +- packages/integrations/node/package.json | 1 + packages/integrations/node/src/index.ts | 1 + packages/integrations/node/src/preview.ts | 57 ++++++ 8 files changed, 254 insertions(+), 163 deletions(-) create mode 100644 packages/astro/src/core/preview/static-preview-server.ts create mode 100644 packages/integrations/node/src/preview.ts diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 0d6e4d5a5307..dfd41adc24b9 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1081,6 +1081,7 @@ export type Props = Record; export interface AstroAdapter { name: string; serverEntrypoint?: string; + previewEntrypoint?: string; exports?: string[]; args?: any; } @@ -1141,7 +1142,7 @@ export interface AstroIntegration { hooks: { 'astro:config:setup'?: (options: { config: AstroConfig; - command: 'dev' | 'build'; + command: 'dev' | 'build' | 'preview'; updateConfig: (newConfig: Record) => void; addRenderer: (renderer: AstroRenderer) => void; injectScript: (stage: InjectedScriptStage, content: string) => void; diff --git a/packages/astro/src/core/preview/index.ts b/packages/astro/src/core/preview/index.ts index 379d44e6f75d..5ed6fa27d77e 100644 --- a/packages/astro/src/core/preview/index.ts +++ b/packages/astro/src/core/preview/index.ts @@ -1,16 +1,10 @@ import type { AstroTelemetry } from '@astrojs/telemetry'; -import type { AddressInfo } from 'net'; +import { execa } from 'execa'; import type { AstroSettings } 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 +12,35 @@ interface PreviewOptions { telemetry: AstroTelemetry; } -export interface PreviewServer { - host?: string; - port: number; - server: http.Server; - closed(): Promise; - stop(): Promise; -} - -const HAS_FILE_EXTENSION_REGEXP = /^.*\.[^\\]+$/; - /** The primary dev action */ export default async function preview( - settings: AstroSettings, + _settings: AstroSettings, { logging }: PreviewOptions -): Promise { - 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, +): Promise { + 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; - - /** Expose dev server to `port` */ - function startServer(timerStart: number): Promise { - let showedPortTakenMsg = false; - let showedListenMsg = false; - return new Promise((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.closed(); } - - // Start listening on `hostname:port`. - await startServer(startServerTime); - - // Resolves once the server is closed - function closed() { - return new Promise((resolve, reject) => { - httpServer!.addListener('close', resolve); - httpServer!.addListener('error', reject); - }); + if (!settings.adapter) { + throw new Error(`[preview] No adapter found.`); } - - return { - host, - port, - closed, - server: httpServer!, - stop: async () => { - await new Promise((resolve, reject) => { - httpServer.close((err) => (err ? reject(err) : resolve(undefined))); - }); - }, - }; + 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); + return previewModule.default({ outDir: settings.config.outDir, host, port }); } 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 000000000000..942567029361 --- /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; + stop(): Promise; +} + +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 { + 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 { + let showedPortTakenMsg = false; + let showedListenMsg = false; + return new Promise((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((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/integrations/index.ts b/packages/astro/src/integrations/index.ts index d533a1a8e0a1..a001828fed64 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -39,7 +39,7 @@ export async function runHookConfigSetup({ logging, }: { settings: AstroSettings; - command: 'dev' | 'build'; + command: 'dev' | 'build' | 'preview'; logging: LogOptions; }): Promise { // An adapter is an integration, so if one is provided push it. diff --git a/packages/integrations/node/README.md b/packages/integrations/node/README.md index 7c95dd0eaddb..63b3337d44c1 100644 --- a/packages/integrations/node/README.md +++ b/packages/integrations/node/README.md @@ -1,4 +1,4 @@ -# @astrojs/node 🔲 +# @astrojs/node This adapter allows Astro to deploy your SSR site to Node targets. diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index ffe8c07d8978..f036dd0bfb1b 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": { diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index 53b94b9166bc..a74742131b6a 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -4,6 +4,7 @@ export function getAdapter(): AstroAdapter { return { name: '@astrojs/node', serverEntrypoint: '@astrojs/node/server.js', + previewEntrypoint: '@astrojs/node/preview.js', exports: ['handler'], }; } diff --git a/packages/integrations/node/src/preview.ts b/packages/integrations/node/src/preview.ts new file mode 100644 index 000000000000..b607ca45fcad --- /dev/null +++ b/packages/integrations/node/src/preview.ts @@ -0,0 +1,57 @@ +import http from 'http'; +import fs from 'fs'; + +export default async function preview({ + outDir, + host, + port, +}: { + outDir: string; + host: string; + port: number; +}) { + const ssrHandler = (await import(new URL('server/entry.mjs', outDir).toString())).handler; + const httpServer = http + .createServer(function (req, res) { + fs.readFile(new URL('client' + req.url, outDir), function (err, data) { + // Static asset found! Serve that directly. + if (!err) { + res.writeHead(200); + res.end(data); + return; + } + // Otherwise, request the page from our server application. + ssrHandler(req, res, (err: any) => { + if (err) { + res.writeHead(500); + res.end(err.toString()); + } else { + res.writeHead(404); + res.end(); + } + }); + }); + }) + .listen(port, host); + + // Resolves once the server is closed + function closed() { + return new Promise((resolve, reject) => { + httpServer!.addListener('close', resolve); + httpServer!.addListener('error', reject); + }); + } + + console.log(`Preview server listening on http://${host}:${port}`); + return { + host, + port, + closed, + server: httpServer!, + stop: async () => { + await new Promise((resolve, reject) => { + httpServer.close((err) => (err ? reject(err) : resolve(undefined))); + }); + }, + }; +} From 64d6439f5a4d2a36bec4e365d8626640e9d92d3f Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 11 Oct 2022 09:03:05 -0400 Subject: [PATCH 02/17] Deprecate buildConfig and move to config.build --- packages/astro/src/@types/astro.ts | 91 +++++++++++++++++++ packages/astro/src/core/build/index.ts | 6 +- packages/astro/src/core/config/schema.ts | 53 ++++++++++- packages/astro/src/core/preview/index.ts | 23 ++++- packages/astro/src/integrations/index.ts | 31 ++++++- packages/integrations/cloudflare/src/index.ts | 24 +++-- packages/integrations/deno/src/index.ts | 11 ++- packages/integrations/image/src/index.ts | 14 ++- .../netlify/src/integration-edge-functions.ts | 37 ++++---- .../netlify/src/integration-functions.ts | 21 ++--- .../integrations/vercel/src/edge/adapter.ts | 19 ++-- .../vercel/src/serverless/adapter.ts | 21 +++-- 12 files changed, 282 insertions(+), 69 deletions(-) diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index dfd41adc24b9..c84dbef90f06 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; } @@ -533,6 +542,69 @@ 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 code, both CSS and JavaScript. + * Note that this config option is only used when `output: 'server'`. In SSG mode + * `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; }; /** @@ -1238,3 +1310,22 @@ export interface SSRResult { } export type MarkdownAstroData = { frontmatter: object }; + +/* Preview server stuff */ +export interface PreviewServer { + host?: string; + port: number; + closed(): Promise; + stop(): Promise; +} + +export interface PreviewServerParams { + outDir: URL; + serverEntrypoint: URL; + host: string | undefined; + port: number; +} + +export interface PreviewModule { + default: (params: PreviewServerParams) => PreviewServer | Promise; +} diff --git a/packages/astro/src/core/build/index.ts b/packages/astro/src/core/build/index.ts index 51ad4ae935b2..25e6717d7cdf 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/schema.ts b/packages/astro/src/core/config/schema.ts index 9bcfd15b30e8..3c390c650897 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 5ed6fa27d77e..d8019c0b550a 100644 --- a/packages/astro/src/core/preview/index.ts +++ b/packages/astro/src/core/preview/index.ts @@ -1,6 +1,5 @@ import type { AstroTelemetry } from '@astrojs/telemetry'; -import { execa } from 'execa'; -import type { AstroSettings } from '../../@types/astro'; +import type { AstroSettings, PreviewModule } from '../../@types/astro'; import { runHookConfigDone, runHookConfigSetup } from '../../integrations/index.js'; import type { LogOptions } from '../logger/core'; import createStaticPreviewServer from './static-preview-server.js'; @@ -41,6 +40,22 @@ export default async function preview( // 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); - return previewModule.default({ outDir: settings.config.outDir, host, port }); + + type MaybePreviewModule = { + default?: PreviewModule['default']; + } + const previewModule = (await import(previewEntrypoint)) as MaybePreviewModule; + + if(typeof previewModule.default !== 'function') { + throw new Error(`[preview] ${settings.adapter.name} cannot preview your app.`); + } + + const server = await previewModule.default({ + outDir: settings.config.outDir, + serverEntrypoint: new URL(settings.config.build.serverEntry, settings.config.build.server), + host, + port + }); + + return server.closed(); } diff --git a/packages/astro/src/integrations/index.ts b/packages/astro/src/integrations/index.ts index a001828fed64..4abcdbd251ab 100644 --- a/packages/astro/src/integrations/index.ts +++ b/packages/astro/src/integrations/index.ts @@ -3,6 +3,7 @@ import type { AddressInfo } from 'net'; import type { InlineConfig, ViteDevServer } from 'vite'; import { AstroConfig, + AstroIntegration, AstroRenderer, AstroSettings, BuildConfig, @@ -12,7 +13,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({ name, @@ -204,13 +205,41 @@ 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', `The buildConfig property has been deprecated. In order to set [${prop}] you should set config.build.${prop} in the astro:config:setup hook instead.`); + 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/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 13c8578eeb8d..192a4e987a87 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 ? { @@ -34,9 +40,19 @@ export default function createIntegration(args?: Options): AstroIntegration { return { name: '@astrojs/cloudflare', hooks: { + 'astro:config:setup': ({ config, updateConfig }) => { + 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 +61,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 || {}; diff --git a/packages/integrations/deno/src/index.ts b/packages/integrations/deno/src/index.ts index 839c6fb39f5a..8907c489da01 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,14 @@ export function getAdapter(args?: Options): AstroAdapter { } export default function createIntegration(args?: Options): AstroIntegration { - let _buildConfig: any; + let _buildConfig: BuildConfig; let _vite: any; return { name: '@astrojs/deno', hooks: { 'astro:config:done': ({ setAdapter, config }) => { setAdapter(getAdapter(args)); + _buildConfig = config.build; if (config.output === 'static') { console.warn(`[@astrojs/deno] \`output: "server"\` is required to use this adapter.`); @@ -39,9 +45,6 @@ export default function createIntegration(args?: Options): AstroIntegration { ); } }, - 'astro:build:start': ({ buildConfig }) => { - _buildConfig = buildConfig; - }, 'astro:build:setup': ({ vite, target }) => { if (target === 'server') { _vite = vite; diff --git a/packages/integrations/image/src/index.ts b/packages/integrations/image/src/index.ts index 3aaf2731534e..46884c1d5eb4 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; @@ -67,8 +72,6 @@ export default function integration(options: IntegrationOptions = {}): AstroInte name: PKG_NAME, hooks: { 'astro:config:setup': async ({ command, config, updateConfig, injectRoute }) => { - _config = config; - updateConfig({ vite: getViteConfiguration() }); if (command === 'dev' || config.output === 'server') { @@ -88,8 +91,9 @@ 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: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 11a18beb9e70..458f5736462c 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 {} } @@ -110,12 +116,6 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {}) name: '@astrojs/netlify/edge-functions', hooks: { 'astro:config:setup': ({ config, updateConfig }) => { - if (dist) { - config.outDir = dist; - } else { - config.outDir = new URL('./dist/', config.root); - } - // Add a plugin that shims the global environment. const injectPlugin: VitePlugin = { name: '@astrojs/netlify/plugin-inject', @@ -128,8 +128,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 +144,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,13 +154,6 @@ 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:setup': ({ vite, target }) => { if (target === 'server') { _vite = vite; diff --git a/packages/integrations/netlify/src/integration-functions.ts b/packages/integrations/netlify/src/integration-functions.ts index d78fb1f32fbc..4260b8bafa9d 100644 --- a/packages/integrations/netlify/src/integration-functions.ts +++ b/packages/integrations/netlify/src/integration-functions.ts @@ -25,16 +25,20 @@ function netlifyFunctions({ 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 }) => { + 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,11 +47,6 @@ 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:done': async ({ routes, dir }) => { await createRedirects(routes, dir, entryFile, false); }, diff --git a/packages/integrations/vercel/src/edge/adapter.ts b/packages/integrations/vercel/src/edge/adapter.ts index 971aa8eae278..7a038a98932c 100644 --- a/packages/integrations/vercel/src/edge/adapter.ts +++ b/packages/integrations/vercel/src/edge/adapter.ts @@ -21,12 +21,22 @@ export default function vercelEdge(): AstroIntegration { return { name: PACKAGE_NAME, hooks: { - 'astro:config:setup': ({ config }) => { - config.outDir = getVercelOutput(config.root); + 'astro:config:setup': ({ config, updateConfig }) => { + 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:setup': ({ vite, target }) => { if (target === 'server') { @@ -49,11 +59,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 f5ae4e8cb52d..dcb8c90faf86 100644 --- a/packages/integrations/vercel/src/serverless/adapter.ts +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -23,12 +23,23 @@ export default function vercelEdge(): AstroIntegration { return { name: PACKAGE_NAME, hooks: { - 'astro:config:setup': ({ config }) => { - config.outDir = getVercelOutput(config.root); + 'astro:config:setup': ({ config, updateConfig }) => { + 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,12 +48,6 @@ 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:done': async ({ routes }) => { // Copy necessary files (e.g. node_modules/) const { handler } = await copyDependenciesToFunction( From 178eef34ff3184c22a2da2fd23b2fdefbb2351c5 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 11 Oct 2022 14:42:25 -0400 Subject: [PATCH 03/17] Implement the standalone server --- examples/ssr/astro.config.mjs | 4 +- packages/astro/src/@types/astro.ts | 5 +- packages/astro/src/core/preview/index.ts | 7 +- packages/integrations/node/README.md | 79 +++++++++------- packages/integrations/node/package.json | 4 +- packages/integrations/node/src/http-server.ts | 77 ++++++++++++++++ packages/integrations/node/src/index.ts | 19 +++- packages/integrations/node/src/middleware.ts | 53 +++++++++++ packages/integrations/node/src/preview.ts | 91 +++++++++---------- packages/integrations/node/src/server.ts | 53 ++--------- packages/integrations/node/src/standalone.ts | 53 +++++++++++ packages/integrations/node/src/types.ts | 17 ++++ pnpm-lock.yaml | 88 ++++++++++++++++-- 13 files changed, 406 insertions(+), 144 deletions(-) create mode 100644 packages/integrations/node/src/http-server.ts create mode 100644 packages/integrations/node/src/middleware.ts create mode 100644 packages/integrations/node/src/standalone.ts create mode 100644 packages/integrations/node/src/types.ts diff --git a/examples/ssr/astro.config.mjs b/examples/ssr/astro.config.mjs index b859914ac3ab..2ff8bbaf55b5 100644 --- a/examples/ssr/astro.config.mjs +++ b/examples/ssr/astro.config.mjs @@ -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()], }); diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index c84dbef90f06..16ccfa80406e 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1321,11 +1321,14 @@ export interface PreviewServer { export interface PreviewServerParams { outDir: URL; + client: URL; serverEntrypoint: URL; host: string | undefined; port: number; } +export type CreatePreviewServer = (params: PreviewServerParams) => PreviewServer | Promise; + export interface PreviewModule { - default: (params: PreviewServerParams) => PreviewServer | Promise; + default: CreatePreviewServer; } diff --git a/packages/astro/src/core/preview/index.ts b/packages/astro/src/core/preview/index.ts index d8019c0b550a..7d070bd0e405 100644 --- a/packages/astro/src/core/preview/index.ts +++ b/packages/astro/src/core/preview/index.ts @@ -41,17 +41,14 @@ export default async function preview( const require = createRequire(settings.config.root); const previewEntrypoint = require.resolve(settings.adapter.previewEntrypoint); - type MaybePreviewModule = { - default?: PreviewModule['default']; - } - const previewModule = (await import(previewEntrypoint)) as MaybePreviewModule; - + const previewModule = (await import(previewEntrypoint)) as Partial; if(typeof previewModule.default !== 'function') { throw new Error(`[preview] ${settings.adapter.name} cannot preview your app.`); } 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 diff --git a/packages/integrations/node/README.md b/packages/integrations/node/README.md index 63b3337d44c1..acaab177b329 100644 --- a/packages/integrations/node/README.md +++ b/packages/integrations/node/README.md @@ -11,13 +11,13 @@ This adapter allows Astro to deploy your SSR site to Node targets. - [Changelog](#changelog) -## 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,49 @@ 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 + +This adapter does not expose any configuration options. + +@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 +99,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. - -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'; +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: -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 f036dd0bfb1b..77a027cd95e2 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -30,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 000000000000..34192c5f9f21 --- /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 | + https.Server; + + 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((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 a74742131b6a..a5a34fb0c14e 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -1,20 +1,33 @@ 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.`) + } + return { name: '@astrojs/node', hooks: { 'astro:config:done': ({ setAdapter, config }) => { - setAdapter(getAdapter()); + const options: 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.`); diff --git a/packages/integrations/node/src/middleware.ts b/packages/integrations/node/src/middleware.ts new file mode 100644 index 000000000000..772461f2af97 --- /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 = 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 index b607ca45fcad..33c2f18e2622 100644 --- a/packages/integrations/node/src/preview.ts +++ b/packages/integrations/node/src/preview.ts @@ -1,57 +1,54 @@ +import type { CreatePreviewServer } from 'astro'; +import type { createExports } from './server'; import http from 'http'; -import fs from 'fs'; +import { fileURLToPath } from 'url'; +import { createServer } from './http-server.js'; -export default async function preview({ - outDir, +const preview: CreatePreviewServer = async function({ + client, + serverEntrypoint, host, port, -}: { - outDir: string; - host: string; - port: number; }) { - const ssrHandler = (await import(new URL('server/entry.mjs', outDir).toString())).handler; - const httpServer = http - .createServer(function (req, res) { - fs.readFile(new URL('client' + req.url, outDir), function (err, data) { - // Static asset found! Serve that directly. - if (!err) { - res.writeHead(200); - res.end(data); - return; - } - // Otherwise, request the page from our server application. - ssrHandler(req, res, (err: any) => { - if (err) { - res.writeHead(500); - res.end(err.toString()); - } else { - res.writeHead(404); - res.end(); - } - }); - }); - }) - .listen(port, host); + type ServerModule = ReturnType; + type MaybeServerModule = Partial; + 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?`); + } - // Resolves once the server is closed - function closed() { - return new Promise((resolve, reject) => { - httpServer!.addListener('close', resolve); - httpServer!.addListener('error', reject); + 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(); + } }); - } + }; - console.log(`Preview server listening on http://${host}:${port}`); - return { - host, + const server = createServer({ + client, port, - closed, - server: httpServer!, - stop: async () => { - await new Promise((resolve, reject) => { - httpServer.close((err) => (err ? reject(err) : resolve(undefined))); - }); - }, - }; + 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 6ecd149310d6..202e66b7e933 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 = 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 000000000000..8fef96ed50ca --- /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 000000000000..aaf3be942580 --- /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/pnpm-lock.yaml b/pnpm-lock.yaml index fc38bc22122c..cee705e4d974 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2903,14 +2903,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 @@ -3769,6 +3773,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'} @@ -11158,6 +11169,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'} @@ -11298,7 +11320,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==} @@ -11308,6 +11329,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'} @@ -11428,6 +11454,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'} @@ -11453,6 +11483,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: @@ -12124,6 +12159,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'} @@ -12352,6 +12391,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'} @@ -12574,7 +12618,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==} @@ -13140,7 +13183,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==} @@ -14464,7 +14506,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==} @@ -14635,12 +14676,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==} @@ -14848,6 +14892,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: @@ -15767,7 +15818,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==} @@ -16395,6 +16445,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'} @@ -16422,7 +16493,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==} @@ -16697,7 +16767,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==} @@ -17103,7 +17172,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==} From cd00b9b7b62c8dfc8c4ca71795b8e858d6c40540 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 11 Oct 2022 15:13:58 -0400 Subject: [PATCH 04/17] Stay backwards compat --- packages/integrations/cloudflare/src/index.ts | 10 ++++++++++ packages/integrations/deno/src/index.ts | 8 ++++++++ packages/integrations/image/src/index.ts | 8 ++++++++ .../netlify/src/integration-edge-functions.ts | 11 +++++++++++ .../netlify/src/integration-functions.ts | 9 +++++++++ packages/integrations/node/src/index.ts | 18 ++++++++++++++---- .../integrations/vercel/src/edge/adapter.ts | 9 +++++++++ .../vercel/src/serverless/adapter.ts | 9 +++++++++ 8 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts index 192a4e987a87..9cf6412b8f27 100644 --- a/packages/integrations/cloudflare/src/index.ts +++ b/packages/integrations/cloudflare/src/index.ts @@ -35,12 +35,14 @@ 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), @@ -79,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 8907c489da01..9b003271057f 100644 --- a/packages/integrations/deno/src/index.ts +++ b/packages/integrations/deno/src/index.ts @@ -31,10 +31,12 @@ export function getAdapter(args?: Options): AstroAdapter { export default function createIntegration(args?: Options): AstroIntegration { 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; @@ -45,6 +47,12 @@ export default function createIntegration(args?: Options): AstroIntegration { ); } }, + 'astro:build:start': ({ buildConfig }) => { + // Backwards compat + if(needsBuildConfig) { + _buildConfig = buildConfig; + } + }, 'astro:build:setup': ({ vite, target }) => { if (target === 'server') { _vite = vite; diff --git a/packages/integrations/image/src/index.ts b/packages/integrations/image/src/index.ts index 46884c1d5eb4..53b1ff86fc13 100644 --- a/packages/integrations/image/src/index.ts +++ b/packages/integrations/image/src/index.ts @@ -47,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>(); @@ -72,6 +73,7 @@ export default function integration(options: IntegrationOptions = {}): AstroInte name: PKG_NAME, hooks: { 'astro:config:setup': async ({ command, config, updateConfig, injectRoute }) => { + needsBuildConfig = !config.build?.server; updateConfig({ vite: getViteConfiguration() }); if (command === 'dev' || config.output === 'server') { @@ -95,6 +97,12 @@ export default function integration(options: IntegrationOptions = {}): AstroInte _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 // Added to globalThis to share the same map in Node and Vite diff --git a/packages/integrations/netlify/src/integration-edge-functions.ts b/packages/integrations/netlify/src/integration-edge-functions.ts index 458f5736462c..b69667dde716 100644 --- a/packages/integrations/netlify/src/integration-edge-functions.ts +++ b/packages/integrations/netlify/src/integration-edge-functions.ts @@ -111,11 +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 }) => { + needsBuildConfig = !config.build.client; // Add a plugin that shims the global environment. const injectPlugin: VitePlugin = { name: '@astrojs/netlify/plugin-inject', @@ -154,6 +156,15 @@ export function netlifyEdgeFunctions({ dist }: NetlifyEdgeFunctionsOptions = {}) ); } }, + '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') { _vite = vite; diff --git a/packages/integrations/netlify/src/integration-functions.ts b/packages/integrations/netlify/src/integration-functions.ts index 4260b8bafa9d..025250bc104e 100644 --- a/packages/integrations/netlify/src/integration-functions.ts +++ b/packages/integrations/netlify/src/integration-functions.ts @@ -22,10 +22,12 @@ function netlifyFunctions({ }: NetlifyFunctionsOptions = {}): AstroIntegration { let _config: AstroConfig; let entryFile: string; + let needsBuildConfig = false; return { name: '@astrojs/netlify', hooks: { 'astro:config:setup': ({ config, updateConfig }) => { + needsBuildConfig = !config.build.client; const outDir = dist ?? new URL('./dist/', config.root); updateConfig({ outDir, @@ -47,6 +49,13 @@ function netlifyFunctions({ ); } }, + '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/src/index.ts b/packages/integrations/node/src/index.ts index a5a34fb0c14e..80dfacdab507 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -16,23 +16,33 @@ export default function createIntegration(userOptions: UserOptions): AstroIntegr 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 }) => { - const options: Options = { + needsBuildConfig = !config.build?.server; + _options = { ...userOptions, - client: config.build.client.toString(), - server: config.build.server.toString(), + client: config.build.client?.toString(), + server: config.build.server?.toString(), host: config.server.host, port: config.server.port, }; - setAdapter(getAdapter(options)); + 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/vercel/src/edge/adapter.ts b/packages/integrations/vercel/src/edge/adapter.ts index 7a038a98932c..ecd13e1f83fb 100644 --- a/packages/integrations/vercel/src/edge/adapter.ts +++ b/packages/integrations/vercel/src/edge/adapter.ts @@ -17,11 +17,13 @@ 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, updateConfig }) => { + needsBuildConfig = !config.build.client; const outDir = getVercelOutput(config.root); updateConfig({ outDir, @@ -38,6 +40,13 @@ export default function vercelEdge(): AstroIntegration { 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') { vite.resolve ||= {}; diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts index dcb8c90faf86..dfc0367ae420 100644 --- a/packages/integrations/vercel/src/serverless/adapter.ts +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -19,11 +19,13 @@ 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, updateConfig }) => { + needsBuildConfig = !config.build.client; const outDir = getVercelOutput(config.root); updateConfig({ outDir, @@ -48,6 +50,13 @@ export default function vercelEdge(): AstroIntegration { `); } }, + '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/) const { handler } = await copyDependenciesToFunction( From 69e32cbba2ee8537a002755c34598dab834898c9 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 11 Oct 2022 15:33:17 -0400 Subject: [PATCH 05/17] Add changesets --- .changeset/cyan-paws-fry.md | 38 +++++++++++++++++++++++ .changeset/metal-pumas-walk.md | 43 ++++++++++++++++++++++++++ .changeset/stupid-points-refuse.md | 49 ++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 .changeset/cyan-paws-fry.md create mode 100644 .changeset/metal-pumas-walk.md create mode 100644 .changeset/stupid-points-refuse.md diff --git a/.changeset/cyan-paws-fry.md b/.changeset/cyan-paws-fry.md new file mode 100644 index 000000000000..df2585ecb929 --- /dev/null +++ b/.changeset/cyan-paws-fry.md @@ -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. diff --git a/.changeset/metal-pumas-walk.md b/.changeset/metal-pumas-walk.md new file mode 100644 index 000000000000..072dc7b8731d --- /dev/null +++ b/.changeset/metal-pumas-walk.md @@ -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'` which builds 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' + }) +}); +``` diff --git a/.changeset/stupid-points-refuse.md b/.changeset/stupid-points-refuse.md new file mode 100644 index 000000000000..f1c3cdbdb420 --- /dev/null +++ b/.changeset/stupid-points-refuse.md @@ -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 granular 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 if 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: '...' + } + }); + } + } + } +} +``` From 166a428fec712db1818c18f427f0717b880c3b82 Mon Sep 17 00:00:00 2001 From: Matthew Phillips Date: Tue, 11 Oct 2022 16:13:22 -0400 Subject: [PATCH 06/17] correctly merge URLs --- examples/ssr/package.json | 2 +- examples/ssr/server/server.mjs | 44 ------------------------ packages/astro/src/core/config/config.ts | 6 +++- packages/astro/src/core/util.ts | 5 +++ packages/astro/src/integrations/index.ts | 1 - 5 files changed, 11 insertions(+), 47 deletions(-) delete mode 100644 examples/ssr/server/server.mjs diff --git a/examples/ssr/package.json b/examples/ssr/package.json index 5d05fca697ac..bbc19e2bd2b6 100644 --- a/examples/ssr/package.json +++ b/examples/ssr/package.json @@ -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": { diff --git a/examples/ssr/server/server.mjs b/examples/ssr/server/server.mjs deleted file mode 100644 index d7a0a7a40f77..000000000000 --- a/examples/ssr/server/server.mjs +++ /dev/null @@ -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