diff --git a/.changeset/cool-foxes-talk.md b/.changeset/cool-foxes-talk.md new file mode 100644 index 0000000000..4513661a84 --- /dev/null +++ b/.changeset/cool-foxes-talk.md @@ -0,0 +1,32 @@ +--- +"astro": minor +--- + +Adds new helper functions for adapter developers. + +- `Astro.clientAddress` can now be passed directly to the `app.render()` method. +```ts +const response = await app.render(request, { clientAddress: "012.123.23.3" }) +``` + +- Helper functions for converting Node.js HTTP request and response objects to web-compatible `Request` and `Response` objects are now provided as static methods on the `NodeApp` class. +```ts +http.createServer((nodeReq, nodeRes) => { + const request: Request = NodeApp.createRequest(nodeReq) + const response = await app.render(request) + await NodeApp.writeResponse(response, nodeRes) +}) +``` + +- Cookies added via `Astro.cookies.set()` can now be automatically added to the `Response` object by passing the `addCookieHeader` option to `app.render()`. +```diff +-const response = await app.render(request) +-const setCookieHeaders: Array = Array.from(app.setCookieHeaders(webResponse)); + +-if (setCookieHeaders.length) { +- for (const setCookieHeader of setCookieHeaders) { +- headers.append('set-cookie', setCookieHeader); +- } +-} ++const response = await app.render(request, { addCookieHeader: true }) +``` diff --git a/.changeset/early-cups-poke.md b/.changeset/early-cups-poke.md new file mode 100644 index 0000000000..d4f816dcee --- /dev/null +++ b/.changeset/early-cups-poke.md @@ -0,0 +1,7 @@ +--- +"@astrojs/vercel": major +--- + +**Breaking**: Minimum required Astro version is now 4.2.0. +Reorganizes internals to be more maintainable. +--- diff --git a/.changeset/tame-squids-film.md b/.changeset/tame-squids-film.md new file mode 100644 index 0000000000..d5d295621a --- /dev/null +++ b/.changeset/tame-squids-film.md @@ -0,0 +1,7 @@ +--- +'@astrojs/node': major +--- + +If host is unset in standalone mode, the server host will now fallback to `localhost` instead of `127.0.0.1`. When `localhost` is used, the operating system can decide to use either `::1` (ipv6) or `127.0.0.1` (ipv4) itself. This aligns with how the Astro dev and preview server works by default. + +If you relied on `127.0.0.1` (ipv4) before, you can set the `HOST` environment variable to `127.0.0.1` to explicitly use ipv4. For example, `HOST=127.0.0.1 node ./dist/server/entry.mjs`. diff --git a/.changeset/unlucky-stingrays-clean.md b/.changeset/unlucky-stingrays-clean.md new file mode 100644 index 0000000000..c13fd500f8 --- /dev/null +++ b/.changeset/unlucky-stingrays-clean.md @@ -0,0 +1,5 @@ +--- +"@astrojs/node": patch +--- + +Fixes an issue where the preview server appeared to be ready to serve requests before binding to a port. diff --git a/.changeset/weak-apes-add.md b/.changeset/weak-apes-add.md new file mode 100644 index 0000000000..b8723453e9 --- /dev/null +++ b/.changeset/weak-apes-add.md @@ -0,0 +1,6 @@ +--- +"@astrojs/node": major +--- + +**Breaking**: Minimum required Astro version is now 4.2.0. +Reorganizes internals to be more maintainable. diff --git a/examples/ssr/src/api.ts b/examples/ssr/src/api.ts index ec4ba9eec7..74e09eb735 100644 --- a/examples/ssr/src/api.ts +++ b/examples/ssr/src/api.ts @@ -17,16 +17,13 @@ interface Cart { }>; } -function getOrigin(request: Request): string { - return new URL(request.url).origin.replace('localhost', '127.0.0.1'); -} - async function get( incomingReq: Request, endpoint: string, cb: (response: Response) => Promise ): Promise { - const response = await fetch(`${getOrigin(incomingReq)}${endpoint}`, { + const origin = new URL(incomingReq.url).origin; + const response = await fetch(`${origin}${endpoint}`, { credentials: 'same-origin', headers: incomingReq.headers, }); diff --git a/packages/integrations/node/src/createOutgoingHttpHeaders.ts b/packages/astro/src/core/app/createOutgoingHttpHeaders.ts similarity index 87% rename from packages/integrations/node/src/createOutgoingHttpHeaders.ts rename to packages/astro/src/core/app/createOutgoingHttpHeaders.ts index 44bbf81ca9..e9cb3e1caa 100644 --- a/packages/integrations/node/src/createOutgoingHttpHeaders.ts +++ b/packages/astro/src/core/app/createOutgoingHttpHeaders.ts @@ -4,8 +4,8 @@ import type { OutgoingHttpHeaders } from 'node:http'; * Takes in a nullable WebAPI Headers object and produces a NodeJS OutgoingHttpHeaders object suitable for usage * with ServerResponse.writeHead(..) or ServerResponse.setHeader(..) * - * @param webHeaders WebAPI Headers object - * @returns NodeJS OutgoingHttpHeaders object with multiple set-cookie handled as an array of values + * @param headers WebAPI Headers object + * @returns {OutgoingHttpHeaders} NodeJS OutgoingHttpHeaders object with multiple set-cookie handled as an array of values */ export const createOutgoingHttpHeaders = ( headers: Headers | undefined | null diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts index 9c7d3828f2..0340a19f38 100644 --- a/packages/astro/src/core/app/index.ts +++ b/packages/astro/src/core/app/index.ts @@ -29,15 +29,46 @@ import { EndpointNotFoundError, SSRRoutePipeline } from './ssrPipeline.js'; import type { RouteInfo } from './types.js'; export { deserializeManifest } from './common.js'; -const clientLocalsSymbol = Symbol.for('astro.locals'); - +const localsSymbol = Symbol.for('astro.locals'); +const clientAddressSymbol = Symbol.for('astro.clientAddress'); const responseSentSymbol = Symbol.for('astro.responseSent'); -const STATUS_CODES = new Set([404, 500]); +/** + * A response with one of these status codes will be rewritten + * with the result of rendering the respective error page. + */ +const REROUTABLE_STATUS_CODES = new Set([404, 500]); export interface RenderOptions { - routeData?: RouteData; + /** + * Whether to automatically add all cookies written by `Astro.cookie.set()` to the response headers. + * + * When set to `true`, they will be added to the `Set-Cookie` header as comma-separated key=value pairs. You can use the standard `response.headers.getSetCookie()` API to read them individually. + * + * When set to `false`, the cookies will only be available from `App.getSetCookieFromResponse(response)`. + * + * @default {false} + */ + addCookieHeader?: boolean; + + /** + * The client IP address that will be made available as `Astro.clientAddress` in pages, and as `ctx.clientAddress` in API routes and middleware. + * + * Default: `request[Symbol.for("astro.clientAddress")]` + */ + clientAddress?: string; + + /** + * The mutable object that will be made available as `Astro.locals` in pages, and as `ctx.locals` in API routes and middleware. + */ locals?: object; + + /** + * **Advanced API**: you probably do not need to use this. + * + * Default: `app.match(request)` + */ + routeData?: RouteData; } export interface RenderErrorOptions { @@ -160,11 +191,24 @@ export class App { ): Promise { let routeData: RouteData | undefined; let locals: object | undefined; + let clientAddress: string | undefined; + let addCookieHeader: boolean | undefined; if ( routeDataOrOptions && - ('routeData' in routeDataOrOptions || 'locals' in routeDataOrOptions) + ( + 'addCookieHeader' in routeDataOrOptions || + 'clientAddress' in routeDataOrOptions || + 'locals' in routeDataOrOptions || + 'routeData' in routeDataOrOptions + ) ) { + if ('addCookieHeader' in routeDataOrOptions) { + addCookieHeader = routeDataOrOptions.addCookieHeader; + } + if ('clientAddress' in routeDataOrOptions) { + clientAddress = routeDataOrOptions.clientAddress; + } if ('routeData' in routeDataOrOptions) { routeData = routeDataOrOptions.routeData; } @@ -178,7 +222,12 @@ export class App { this.#logRenderOptionsDeprecationWarning(); } } - + if (locals) { + Reflect.set(request, localsSymbol, locals); + } + if (clientAddress) { + Reflect.set(request, clientAddressSymbol, clientAddress) + } // Handle requests with duplicate slashes gracefully by cloning with a cleaned-up request URL if (request.url !== collapseDuplicateSlashes(request.url)) { request = new Request(collapseDuplicateSlashes(request.url), request); @@ -189,7 +238,6 @@ export class App { if (!routeData) { return this.#renderError(request, { status: 404 }); } - Reflect.set(request, clientLocalsSymbol, locals ?? {}); const pathname = this.#getPathnameFromRequest(request); const defaultStatus = this.#getDefaultStatusCode(routeData, pathname); const mod = await this.#getModuleForRoute(routeData); @@ -206,7 +254,7 @@ export class App { ); let response; try { - let i18nMiddleware = createI18nMiddleware( + const i18nMiddleware = createI18nMiddleware( this.#manifest.i18n, this.#manifest.base, this.#manifest.trailingSlash @@ -233,16 +281,21 @@ export class App { } } + // endpoints do not participate in implicit rerouting if (routeData.type === 'page' || routeData.type === 'redirect') { - if (STATUS_CODES.has(response.status)) { + if (REROUTABLE_STATUS_CODES.has(response.status)) { return this.#renderError(request, { response, status: response.status as 404 | 500, }); } - Reflect.set(response, responseSentSymbol, true); - return response; } + if (addCookieHeader) { + for (const setCookieHeaderValue of App.getSetCookieFromResponse(response)) { + response.headers.append('set-cookie', setCookieHeaderValue); + } + } + Reflect.set(response, responseSentSymbol, true); return response; } @@ -259,6 +312,19 @@ export class App { return getSetCookiesFromResponse(response); } + /** + * Reads all the cookies written by `Astro.cookie.set()` onto the passed response. + * For example, + * ```ts + * for (const cookie_ of App.getSetCookieFromResponse(response)) { + * const cookie: string = cookie_ + * } + * ``` + * @param response The response to read cookies from. + * @returns An iterator that yields key-value pairs as equal-sign-separated strings. + */ + static getSetCookieFromResponse = getSetCookiesFromResponse + /** * Creates the render context of the current route */ diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index f5ea38cc18..aa777db883 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -1,51 +1,135 @@ +import fs from 'node:fs'; +import { App } from './index.js'; +import { deserializeManifest } from './common.js'; +import { createOutgoingHttpHeaders } from './createOutgoingHttpHeaders.js'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import type { RouteData } from '../../@types/astro.js'; import type { RenderOptions } from './index.js'; import type { SerializedSSRManifest, SSRManifest } from './types.js'; -import * as fs from 'node:fs'; -import { IncomingMessage } from 'node:http'; -import { TLSSocket } from 'node:tls'; -import { deserializeManifest } from './common.js'; -import { App } from './index.js'; export { apply as applyPolyfills } from '../polyfill.js'; const clientAddressSymbol = Symbol.for('astro.clientAddress'); -type CreateNodeRequestOptions = { - emptyBody?: boolean; -}; - -type BodyProps = Partial; - -function createRequestFromNodeRequest( - req: NodeIncomingMessage, - options?: CreateNodeRequestOptions -): Request { - const protocol = - req.socket instanceof TLSSocket || req.headers['x-forwarded-proto'] === 'https' - ? 'https' - : 'http'; - const hostname = req.headers.host || req.headers[':authority']; - const url = `${protocol}://${hostname}${req.url}`; - const headers = makeRequestHeaders(req); - const method = req.method || 'GET'; - let bodyProps: BodyProps = {}; - const bodyAllowed = method !== 'HEAD' && method !== 'GET' && !options?.emptyBody; - if (bodyAllowed) { - bodyProps = makeRequestBody(req); - } - const request = new Request(url, { - method, - headers, - ...bodyProps, - }); - if (req.socket?.remoteAddress) { - Reflect.set(request, clientAddressSymbol, req.socket.remoteAddress); - } - return request; +/** + * Allow the request body to be explicitly overridden. For example, this + * is used by the Express JSON middleware. + */ +interface NodeRequest extends IncomingMessage { + body?: unknown; } -function makeRequestHeaders(req: NodeIncomingMessage): Headers { +export class NodeApp extends App { + match(req: NodeRequest | Request) { + if (!(req instanceof Request)) { + req = NodeApp.createRequest(req, { + skipBody: true, + }); + } + return super.match(req); + } + render(request: NodeRequest | Request, options?: RenderOptions): Promise; + /** + * @deprecated Instead of passing `RouteData` and locals individually, pass an object with `routeData` and `locals` properties. + * See https://github.com/withastro/astro/pull/9199 for more information. + */ + render( + request: NodeRequest | Request, + routeData?: RouteData, + locals?: object + ): Promise; + render( + req: NodeRequest | Request, + routeDataOrOptions?: RouteData | RenderOptions, + maybeLocals?: object + ) { + if (!(req instanceof Request)) { + req = NodeApp.createRequest(req); + } + // @ts-expect-error The call would have succeeded against the implementation, but implementation signatures of overloads are not externally visible. + return super.render(req, routeDataOrOptions, maybeLocals); + } + + /** + * Converts a NodeJS IncomingMessage into a web standard Request. + * ```js + * import { NodeApp } from 'astro/app/node'; + * import { createServer } from 'node:http'; + * + * const server = createServer(async (req, res) => { + * const request = NodeApp.createRequest(req); + * const response = await app.render(request); + * await NodeApp.writeResponse(response, res); + * }) + * ``` + */ + static createRequest( + req: NodeRequest, + { skipBody = false } = {} + ): Request { + const protocol = req.headers['x-forwarded-proto'] ?? + ('encrypted' in req.socket && req.socket.encrypted ? 'https' : 'http'); + const hostname = req.headers.host || req.headers[':authority']; + const url = `${protocol}://${hostname}${req.url}`; + const options: RequestInit = { + method: req.method || 'GET', + headers: makeRequestHeaders(req), + } + const bodyAllowed = options.method !== 'HEAD' && options.method !== 'GET' && skipBody === false; + if (bodyAllowed) { + Object.assign(options, makeRequestBody(req)); + } + const request = new Request(url, options); + if (req.socket?.remoteAddress) { + Reflect.set(request, clientAddressSymbol, req.socket.remoteAddress); + } + return request; + } + + /** + * Streams a web-standard Response into a NodeJS Server Response. + * ```js + * import { NodeApp } from 'astro/app/node'; + * import { createServer } from 'node:http'; + * + * const server = createServer(async (req, res) => { + * const request = NodeApp.createRequest(req); + * const response = await app.render(request); + * await NodeApp.writeResponse(response, res); + * }) + * ``` + * @param source WhatWG Response + * @param destination NodeJS ServerResponse + */ + static async writeResponse(source: Response, destination: ServerResponse) { + const { status, headers, body } = source; + destination.writeHead(status, createOutgoingHttpHeaders(headers)); + if (body) { + try { + const reader = body.getReader(); + destination.on('close', () => { + // Cancelling the reader may reject not just because of + // an error in the ReadableStream's cancel callback, but + // also because of an error anywhere in the stream. + reader.cancel().catch(err => { + console.error(`There was an uncaught error in the middle of the stream while rendering ${destination.req.url}.`, err); + }); + }); + let result = await reader.read(); + while (!result.done) { + destination.write(result.value); + result = await reader.read(); + } + // the error will be logged by the "on end" callback above + } catch { + destination.write('Internal server error'); + } + } + destination.end(); + }; +} + +function makeRequestHeaders(req: NodeRequest): Headers { const headers = new Headers(); for (const [name, value] of Object.entries(req.headers)) { if (value === undefined) { @@ -62,7 +146,7 @@ function makeRequestHeaders(req: NodeIncomingMessage): Headers { return headers; } -function makeRequestBody(req: NodeIncomingMessage): BodyProps { +function makeRequestBody(req: NodeRequest): RequestInit { if (req.body !== undefined) { if (typeof req.body === 'string' && req.body.length > 0) { return { body: Buffer.from(req.body) }; @@ -86,7 +170,7 @@ function makeRequestBody(req: NodeIncomingMessage): BodyProps { return asyncIterableToBodyProps(req); } -function asyncIterableToBodyProps(iterable: AsyncIterable): BodyProps { +function asyncIterableToBodyProps(iterable: AsyncIterable): RequestInit { return { // Node uses undici for the Request implementation. Undici accepts // a non-standard async iterable for the body. @@ -95,49 +179,8 @@ function asyncIterableToBodyProps(iterable: AsyncIterable): BodyProps { // The duplex property is required when using a ReadableStream or async // iterable for the body. The type definitions do not include the duplex // property because they are not up-to-date. - // @ts-expect-error duplex: 'half', - } satisfies BodyProps; -} - -class NodeIncomingMessage extends IncomingMessage { - /** - * Allow the request body to be explicitly overridden. For example, this - * is used by the Express JSON middleware. - */ - body?: unknown; -} - -export class NodeApp extends App { - match(req: NodeIncomingMessage | Request) { - if (!(req instanceof Request)) { - req = createRequestFromNodeRequest(req, { - emptyBody: true, - }); - } - return super.match(req); - } - render(request: NodeIncomingMessage | Request, options?: RenderOptions): Promise; - /** - * @deprecated Instead of passing `RouteData` and locals individually, pass an object with `routeData` and `locals` properties. - * See https://github.com/withastro/astro/pull/9199 for more information. - */ - render( - request: NodeIncomingMessage | Request, - routeData?: RouteData, - locals?: object - ): Promise; - render( - req: NodeIncomingMessage | Request, - routeDataOrOptions?: RouteData | RenderOptions, - maybeLocals?: object - ) { - if (!(req instanceof Request)) { - req = createRequestFromNodeRequest(req); - } - // @ts-expect-error The call would have succeeded against the implementation, but implementation signatures of overloads are not externally visible. - return super.render(req, routeDataOrOptions, maybeLocals); - } + }; } export async function loadManifest(rootFolder: URL): Promise { diff --git a/packages/astro/test/astro-cookies.test.js b/packages/astro/test/astro-cookies.test.js index 0af8d30b78..7b6cfafa74 100644 --- a/packages/astro/test/astro-cookies.test.js +++ b/packages/astro/test/astro-cookies.test.js @@ -89,6 +89,24 @@ describe('Astro.cookies', () => { expect(headers[0]).to.match(/Expires/); }); + it('app.render can include the cookie in the Set-Cookie header', async () => { + const request = new Request('http://example.com/set-value', { + method: 'POST', + }); + const response = await app.render(request, { addCookieHeader: true }) + expect(response.status).to.equal(200); + expect(response.headers.get("Set-Cookie")).to.be.a('string').and.satisfy(value => value.startsWith("admin=true; Expires=")); + }); + + it('app.render can exclude the cookie from the Set-Cookie header', async () => { + const request = new Request('http://example.com/set-value', { + method: 'POST', + }); + const response = await app.render(request, { addCookieHeader: false }) + expect(response.status).to.equal(200); + expect(response.headers.get("Set-Cookie")).to.equal(null); + }); + it('Early returning a Response still includes set headers', async () => { const response = await fetchResponse('/early-return', { headers: { diff --git a/packages/astro/test/client-address.test.js b/packages/astro/test/client-address.test.js index 6e78832cef..5d6df5ac9e 100644 --- a/packages/astro/test/client-address.test.js +++ b/packages/astro/test/client-address.test.js @@ -29,6 +29,15 @@ describe('Astro.clientAddress', () => { const $ = cheerio.load(html); expect($('#address').text()).to.equal('0.0.0.0'); }); + + it('app.render can provide the address', async () => { + const app = await fixture.loadTestAdapterApp(); + const request = new Request('http://example.com/'); + const response = await app.render(request, { clientAddress: "1.1.1.1" }); + const html = await response.text(); + const $ = cheerio.load(html); + expect($('#address').text()).to.equal('1.1.1.1'); + }); }); describe('Development', () => { diff --git a/packages/integrations/node/test/createOutgoingHttpHeaders.test.js b/packages/astro/test/units/app/headers.test.js similarity index 96% rename from packages/integrations/node/test/createOutgoingHttpHeaders.test.js rename to packages/astro/test/units/app/headers.test.js index 2f7063b1c0..d9e5d6f8e4 100644 --- a/packages/integrations/node/test/createOutgoingHttpHeaders.test.js +++ b/packages/astro/test/units/app/headers.test.js @@ -1,6 +1,6 @@ import { expect } from 'chai'; -import { createOutgoingHttpHeaders } from '../dist/createOutgoingHttpHeaders.js'; +import { createOutgoingHttpHeaders } from '../../../dist/core/app/createOutgoingHttpHeaders.js'; describe('createOutgoingHttpHeaders', () => { it('undefined input headers', async () => { diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index c3d952840a..347eba0d69 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -37,7 +37,7 @@ "server-destroy": "^1.0.1" }, "peerDependencies": { - "astro": "^4.0.0" + "astro": "^4.2.0" }, "devDependencies": { "@types/node": "^18.17.8", diff --git a/packages/integrations/node/src/get-network-address.ts b/packages/integrations/node/src/get-network-address.ts deleted file mode 100644 index 3834c76172..0000000000 --- a/packages/integrations/node/src/get-network-address.ts +++ /dev/null @@ -1,48 +0,0 @@ -import os from 'os'; -interface NetworkAddressOpt { - local: string[]; - network: string[]; -} - -const wildcardHosts = new Set(['0.0.0.0', '::', '0000:0000:0000:0000:0000:0000:0000:0000']); -type Protocol = 'http' | 'https'; - -// this code from vite https://github.com/vitejs/vite/blob/d09bbd093a4b893e78f0bbff5b17c7cf7821f403/packages/vite/src/node/utils.ts#L892-L914 -export function getNetworkAddress( - protocol: Protocol = 'http', - hostname: string | undefined, - port: number, - base?: string -) { - const NetworkAddress: NetworkAddressOpt = { - local: [], - network: [], - }; - Object.values(os.networkInterfaces()) - .flatMap((nInterface) => nInterface ?? []) - .filter( - (detail) => - detail && - detail.address && - (detail.family === 'IPv4' || - // @ts-expect-error Node 18.0 - 18.3 returns number - detail.family === 4) - ) - .forEach((detail) => { - let host = detail.address.replace( - '127.0.0.1', - hostname === undefined || wildcardHosts.has(hostname) ? 'localhost' : hostname - ); - // ipv6 host - if (host.includes(':')) { - host = `[${host}]`; - } - const url = `${protocol}://${host}:${port}${base ? base : ''}`; - if (detail.address.includes('127.0.0.1')) { - NetworkAddress.local.push(url); - } else { - NetworkAddress.network.push(url); - } - }); - return NetworkAddress; -} diff --git a/packages/integrations/node/src/http-server.ts b/packages/integrations/node/src/http-server.ts deleted file mode 100644 index 9049376017..0000000000 --- a/packages/integrations/node/src/http-server.ts +++ /dev/null @@ -1,131 +0,0 @@ -import https from 'https'; -import fs from 'node:fs'; -import http from 'node:http'; -import { fileURLToPath } from 'node:url'; -import send from 'send'; -import enableDestroy from 'server-destroy'; - -interface CreateServerOptions { - client: URL; - port: number; - host: string | undefined; - removeBase: (pathname: string) => string; - assets: string; -} - -function parsePathname(pathname: string, host: string | undefined, port: number) { - try { - const urlPathname = new URL(pathname, `http://${host}:${port}`).pathname; - return decodeURI(encodeURI(urlPathname)); - } catch (err) { - return undefined; - } -} - -export function createServer( - { client, port, host, removeBase, assets }: CreateServerOptions, - handler: http.RequestListener -) { - // The `base` is removed before passed to this function, so we don't - // need to check for it here. - const assetsPrefix = `/${assets}/`; - function isImmutableAsset(pathname: string) { - return pathname.startsWith(assetsPrefix); - } - - const listener: http.RequestListener = (req, res) => { - if (req.url) { - let pathname: string | undefined = removeBase(req.url); - pathname = pathname[0] === '/' ? pathname : '/' + pathname; - const encodedURI = parsePathname(pathname, host, port); - - if (!encodedURI) { - res.writeHead(400); - res.end('Bad request.'); - return res; - } - - const stream = send(req, encodedURI, { - root: fileURLToPath(client), - dotfiles: pathname.startsWith('/.well-known/') ? 'allow' : 'deny', - }); - - let forwardError = false; - - stream.on('error', (err) => { - if (forwardError) { - 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('headers', (_res: http.ServerResponse) => { - if (isImmutableAsset(encodedURI)) { - // Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable - _res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); - } - }); - stream.on('directory', () => { - // On directory find, redirect to the trailing slash - let location: string; - if (req.url!.includes('?')) { - const [url = '', search] = req.url!.split('?'); - location = `${url}/?${search}`; - } else { - location = req.url + '/'; - } - - res.statusCode = 301; - res.setHeader('Location', location); - res.end(location); - }); - 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); - enableDestroy(httpServer); - - // 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.destroy((err) => (err ? reject(err) : resolve(undefined))); - }); - }, - }; -} diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index bac5c25ef5..e7d655403b 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -1,6 +1,7 @@ -import type { AstroAdapter, AstroIntegration } from 'astro'; import { AstroError } from 'astro/errors'; +import type { AstroAdapter, AstroIntegration } from 'astro'; import type { Options, UserOptions } from './types.js'; + export function getAdapter(options: Options): AstroAdapter { return { name: '@astrojs/node', diff --git a/packages/integrations/node/src/log-listening-on.ts b/packages/integrations/node/src/log-listening-on.ts new file mode 100644 index 0000000000..4f56b3ee8b --- /dev/null +++ b/packages/integrations/node/src/log-listening-on.ts @@ -0,0 +1,84 @@ +import os from "node:os"; +import type http from "node:http"; +import https from "node:https"; +import type { AstroIntegrationLogger } from "astro"; +import type { Options } from './types.js'; +import type { AddressInfo } from "node:net"; + +export async function logListeningOn(logger: AstroIntegrationLogger, server: http.Server | https.Server, options: Pick) { + await new Promise(resolve => server.once('listening', resolve)) + const protocol = server instanceof https.Server ? 'https' : 'http'; + // Allow to provide host value at runtime + const host = getResolvedHostForHttpServer( + process.env.HOST !== undefined && process.env.HOST !== '' ? process.env.HOST : options.host + ); + const { port } = server.address() as AddressInfo; + const address = getNetworkAddress(protocol, host, port); + + if (host === undefined) { + logger.info( + `Server listening on \n local: ${address.local[0]} \t\n network: ${address.network[0]}\n` + ); + } else { + logger.info(`Server listening on ${address.local[0]}`); + } +} + +function getResolvedHostForHttpServer(host: string | boolean) { + if (host === false) { + // Use a secure default + return 'localhost'; + } 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; + } +} + +interface NetworkAddressOpt { + local: string[]; + network: string[]; +} + +const wildcardHosts = new Set(['0.0.0.0', '::', '0000:0000:0000:0000:0000:0000:0000:0000']); + +// this code from vite https://github.com/vitejs/vite/blob/d09bbd093a4b893e78f0bbff5b17c7cf7821f403/packages/vite/src/node/utils.ts#L892-L914 +export function getNetworkAddress( + protocol: 'http' | 'https' = 'http', + hostname: string | undefined, + port: number, + base?: string +) { + const NetworkAddress: NetworkAddressOpt = { + local: [], + network: [], + }; + Object.values(os.networkInterfaces()) + .flatMap((nInterface) => nInterface ?? []) + .filter( + (detail) => + detail && + detail.address && + (detail.family === 'IPv4' || + // @ts-expect-error Node 18.0 - 18.3 returns number + detail.family === 4) + ) + .forEach((detail) => { + let host = detail.address.replace( + '127.0.0.1', + hostname === undefined || wildcardHosts.has(hostname) ? 'localhost' : hostname + ); + // ipv6 host + if (host.includes(':')) { + host = `[${host}]`; + } + const url = `${protocol}://${host}:${port}${base ? base : ''}`; + if (detail.address.includes('127.0.0.1')) { + NetworkAddress.local.push(url); + } else { + NetworkAddress.network.push(url); + } + }); + return NetworkAddress; +} diff --git a/packages/integrations/node/src/middleware.ts b/packages/integrations/node/src/middleware.ts new file mode 100644 index 0000000000..a936dc5bc9 --- /dev/null +++ b/packages/integrations/node/src/middleware.ts @@ -0,0 +1,43 @@ +import { createAppHandler } from './serve-app.js'; +import type { RequestHandler } from "./types.js"; +import type { NodeApp } from "astro/app/node"; + +/** + * Creates a middleware that can be used with Express, Connect, etc. + * + * Similar to `createAppHandler` but can additionally be placed in the express + * chain as an error middleware. + * + * https://expressjs.com/en/guide/using-middleware.html#middleware.error-handling + */ +export default function createMiddleware( + app: NodeApp, +): RequestHandler { + const handler = createAppHandler(app) + const logger = app.getAdapterLogger() + // using spread args because express trips up if the function's + // stringified body includes req, res, next, locals directly + return async function (...args) { + // assume normal invocation at first + const [req, res, next, locals] = args; + // short circuit if it is an error invocation + if (req instanceof Error) { + const error = req; + if (next) { + return next(error); + } else { + throw error; + } + } + try { + await handler(req, res, next, locals); + } catch (err) { + logger.error(`Could not render ${req.url}`); + console.error(err); + if (!res.headersSent) { + res.writeHead(500, `Server error`); + res.end(); + } + } + } +} diff --git a/packages/integrations/node/src/nodeMiddleware.ts b/packages/integrations/node/src/nodeMiddleware.ts deleted file mode 100644 index a13cc5da3b..0000000000 --- a/packages/integrations/node/src/nodeMiddleware.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { NodeApp } from 'astro/app/node'; -import type { ServerResponse } from 'node:http'; -import { createOutgoingHttpHeaders } from './createOutgoingHttpHeaders.js'; -import type { ErrorHandlerParams, Options, RequestHandlerParams } from './types.js'; -import type { AstroIntegrationLogger } from 'astro'; - -// Disable no-unused-vars to avoid breaking signature change -export default function (app: NodeApp, mode: Options['mode']) { - return async function (...args: RequestHandlerParams | ErrorHandlerParams) { - let error = null; - let locals; - let [req, res, next] = args as RequestHandlerParams; - if (mode === 'middleware') { - let { [3]: _locals } = args; - locals = _locals; - } - - if (args[0] instanceof Error) { - [error, req, res, next] = args as ErrorHandlerParams; - if (mode === 'middleware') { - let { [4]: _locals } = args as ErrorHandlerParams; - locals = _locals; - } - if (error) { - if (next) { - return next(error); - } else { - throw error; - } - } - } - - const logger = app.getAdapterLogger(); - - try { - const routeData = app.match(req); - if (routeData) { - try { - const response = await app.render(req, { routeData, locals }); - await writeWebResponse(app, res, response, logger); - } catch (err: unknown) { - if (next) { - next(err); - } else { - throw err; - } - } - } else if (next) { - return next(); - } else { - const response = await app.render(req); - await writeWebResponse(app, res, response, logger); - } - } catch (err: unknown) { - logger.error(`Could not render ${req.url}`); - console.error(err); - if (!res.headersSent) { - res.writeHead(500, `Server error`); - res.end(); - } - } - }; -} - -async function writeWebResponse( - app: NodeApp, - res: ServerResponse, - webResponse: Response, - logger: AstroIntegrationLogger -) { - const { status, headers, body } = webResponse; - - if (app.setCookieHeaders) { - const setCookieHeaders: Array = Array.from(app.setCookieHeaders(webResponse)); - - if (setCookieHeaders.length) { - for (const setCookieHeader of setCookieHeaders) { - headers.append('set-cookie', setCookieHeader); - } - } - } - - const nodeHeaders = createOutgoingHttpHeaders(headers); - res.writeHead(status, nodeHeaders); - if (body) { - try { - const reader = body.getReader(); - res.on('close', () => { - // Cancelling the reader may reject not just because of - // an error in the ReadableStream's cancel callback, but - // also because of an error anywhere in the stream. - reader.cancel().catch((err) => { - logger.error( - `There was an uncaught error in the middle of the stream while rendering ${res.req.url}.` - ); - console.error(err); - }); - }); - let result = await reader.read(); - while (!result.done) { - res.write(result.value); - result = await reader.read(); - } - // the error will be logged by the "on end" callback above - } catch { - res.write('Internal server error'); - } - } - res.end(); -} diff --git a/packages/integrations/node/src/preview.ts b/packages/integrations/node/src/preview.ts index 89baa1897b..26b91756c8 100644 --- a/packages/integrations/node/src/preview.ts +++ b/packages/integrations/node/src/preview.ts @@ -1,26 +1,19 @@ -import type { CreatePreviewServer } from 'astro'; -import { AstroError } from 'astro/errors'; -import type http from 'node:http'; import { fileURLToPath } from 'node:url'; -import { getNetworkAddress } from './get-network-address.js'; -import { createServer } from './http-server.js'; +import { AstroError } from 'astro/errors'; +import { logListeningOn } from './log-listening-on.js'; +import { createServer } from './standalone.js'; +import type { CreatePreviewServer } from 'astro'; import type { createExports } from './server.js'; -const preview: CreatePreviewServer = async function ({ - client, - serverEntrypoint, - host, - port, - base, - logger, -}) { - type ServerModule = ReturnType; - type MaybeServerModule = Partial; +type ServerModule = ReturnType; +type MaybeServerModule = Partial; + +const createPreviewServer: CreatePreviewServer = async function (preview) { let ssrHandler: ServerModule['handler']; let options: ServerModule['options']; try { process.env.ASTRO_NODE_AUTOSTART = 'disabled'; - const ssrModule: MaybeServerModule = await import(serverEntrypoint.toString()); + const ssrModule: MaybeServerModule = await import(preview.serverEntrypoint.toString()); if (typeof ssrModule.handler === 'function') { ssrHandler = ssrModule.handler; options = ssrModule.options!; @@ -33,49 +26,23 @@ const preview: CreatePreviewServer = async function ({ if ((err as any).code === 'ERR_MODULE_NOT_FOUND') { throw new AstroError( `The server entrypoint ${fileURLToPath( - serverEntrypoint + preview.serverEntrypoint )} does not exist. Have you ran a build yet?` ); } else { throw err; } } - - const handler: http.RequestListener = (req, res) => { - ssrHandler(req, res); - }; - - const baseWithoutTrailingSlash: string = base.endsWith('/') - ? base.slice(0, base.length - 1) - : base; - function removeBase(pathname: string): string { - if (pathname.startsWith(base)) { - return pathname.slice(baseWithoutTrailingSlash.length); - } - return pathname; - } - - const server = createServer( - { - client, - port, - host, - removeBase, - assets: options.assets, - }, - handler - ); - const address = getNetworkAddress('http', host, port); - - if (host === undefined) { - logger.info( - `Preview server listening on \n local: ${address.local[0]} \t\n network: ${address.network[0]}\n` - ); - } else { - logger.info(`Preview server listening on ${address.local[0]}`); - } - + const host = preview.host ?? "localhost" + const port = preview.port ?? 4321 + const server = createServer(ssrHandler, host, port); + logListeningOn(preview.logger, server.server, options) + await new Promise((resolve, reject) => { + server.server.once('listening', resolve); + server.server.once('error', reject); + server.server.listen(port, host); + }); return server; }; -export { preview as default }; +export { createPreviewServer as default } diff --git a/packages/integrations/node/src/serve-app.ts b/packages/integrations/node/src/serve-app.ts new file mode 100644 index 0000000000..51ef315752 --- /dev/null +++ b/packages/integrations/node/src/serve-app.ts @@ -0,0 +1,27 @@ +import { NodeApp } from "astro/app/node" +import type { RequestHandler } from "./types.js"; + +/** + * Creates a Node.js http listener for on-demand rendered pages, compatible with http.createServer and Connect middleware. + * If the next callback is provided, it will be called if the request does not have a matching route. + * Intended to be used in both standalone and middleware mode. + */ +export function createAppHandler(app: NodeApp): RequestHandler { + return async (req, res, next, locals) => { + const request = NodeApp.createRequest(req); + const routeData = app.match(request); + if (routeData) { + const response = await app.render(request, { + addCookieHeader: true, + locals, + routeData, + }); + await NodeApp.writeResponse(response, res); + } else if (next) { + return next(); + } else { + const response = await app.render(req); + await NodeApp.writeResponse(response, res); + } + } +} diff --git a/packages/integrations/node/src/serve-static.ts b/packages/integrations/node/src/serve-static.ts new file mode 100644 index 0000000000..ee3bdaf791 --- /dev/null +++ b/packages/integrations/node/src/serve-static.ts @@ -0,0 +1,86 @@ +import path from "node:path"; +import url from "node:url"; +import send from "send"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Options } from "./types.js"; +import type { NodeApp } from "astro/app/node"; + +/** + * Creates a Node.js http listener for static files and prerendered pages. + * In standalone mode, the static handler is queried first for the static files. + * If one matching the request path is not found, it relegates to the SSR handler. + * Intended to be used only in the standalone mode. + */ +export function createStaticHandler(app: NodeApp, options: Options) { + const client = resolveClientDir(options); + /** + * @param ssr The SSR handler to be called if the static handler does not find a matching file. + */ + return (req: IncomingMessage, res: ServerResponse, ssr: () => unknown) => { + if (req.url) { + let pathname = app.removeBase(req.url); + pathname = decodeURI(new URL(pathname, 'http://host').pathname); + + const stream = send(req, pathname, { + root: client, + dotfiles: pathname.startsWith('/.well-known/') ? 'allow' : 'deny', + }); + + let forwardError = false; + + stream.on('error', (err) => { + if (forwardError) { + console.error(err.toString()); + res.writeHead(500); + res.end('Internal server error'); + return; + } + // File not found, forward to the SSR handler + ssr(); + }); + stream.on('headers', (_res: ServerResponse) => { + // assets in dist/_astro are hashed and should get the immutable header + if (pathname.startsWith(`/${options.assets}/`)) { + // This is the "far future" cache header, used for static files whose name includes their digest hash. + // 1 year (31,536,000 seconds) is convention. + // Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable + _res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + } + }); + stream.on('directory', () => { + // On directory find, redirect to the trailing slash + let location: string; + if (req.url!.includes('?')) { + const [url1 = '', search] = req.url!.split('?'); + location = `${url1}/?${search}`; + } else { + location = appendForwardSlash(req.url!); + } + + res.statusCode = 301; + res.setHeader('Location', location); + res.end(location); + }); + stream.on('file', () => { + forwardError = true; + }); + stream.pipe(res); + } else { + ssr(); + } + }; +} + +function resolveClientDir(options: Options) { + const clientURLRaw = new URL(options.client); + const serverURLRaw = new URL(options.server); + const rel = path.relative(url.fileURLToPath(serverURLRaw), url.fileURLToPath(clientURLRaw)); + const serverEntryURL = new URL(import.meta.url); + const clientURL = new URL(appendForwardSlash(rel), serverEntryURL); + const client = url.fileURLToPath(clientURL); + return client; +} + +function appendForwardSlash(pth: string) { + return pth.endsWith('/') ? pth : pth + '/'; +} diff --git a/packages/integrations/node/src/server.ts b/packages/integrations/node/src/server.ts index 88bcd7d62e..5c2577ff8d 100644 --- a/packages/integrations/node/src/server.ts +++ b/packages/integrations/node/src/server.ts @@ -1,7 +1,8 @@ -import type { SSRManifest } from 'astro'; import { NodeApp, applyPolyfills } from 'astro/app/node'; -import middleware from './nodeMiddleware.js'; +import { createStandaloneHandler } from './standalone.js'; import startServer from './standalone.js'; +import createMiddleware from './middleware.js'; +import type { SSRManifest } from 'astro'; import type { Options } from './types.js'; applyPolyfills(); @@ -9,7 +10,10 @@ export function createExports(manifest: SSRManifest, options: Options) { const app = new NodeApp(manifest); return { options: options, - handler: middleware(app, options.mode), + handler: + options.mode === "middleware" + ? createMiddleware(app) + : createStandaloneHandler(app, options), startServer: () => startServer(app, options), }; } diff --git a/packages/integrations/node/src/standalone.ts b/packages/integrations/node/src/standalone.ts index e167e8ab69..fc1875e972 100644 --- a/packages/integrations/node/src/standalone.ts +++ b/packages/integrations/node/src/standalone.ts @@ -1,75 +1,90 @@ -import type { NodeApp } from 'astro/app/node'; +import http from 'node:http'; import https from 'https'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { getNetworkAddress } from './get-network-address.js'; -import { createServer } from './http-server.js'; -import middleware from './nodeMiddleware.js'; +import fs from 'node:fs'; +import enableDestroy from 'server-destroy'; +import { createAppHandler } from './serve-app.js'; +import { createStaticHandler } from './serve-static.js'; +import { logListeningOn } from './log-listening-on.js'; +import type { NodeApp } from 'astro/app/node'; import type { Options } from './types.js'; +import type { PreviewServer } from 'astro'; -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 logger = app.getAdapterLogger(); +export default function standalone(app: NodeApp, options: Options) { const port = process.env.PORT ? Number(process.env.PORT) : options.port ?? 8080; - const { client } = resolvePaths(options); - const handler = middleware(app, options.mode); - // Allow to provide host value at runtime - const host = getResolvedHostForHttpServer( - process.env.HOST !== undefined && process.env.HOST !== '' ? process.env.HOST : options.host - ); - const server = createServer( - { - client, - port, - host, - removeBase: app.removeBase.bind(app), - assets: options.assets, - }, - handler - ); - - const protocol = server.server instanceof https.Server ? 'https' : 'http'; - const address = getNetworkAddress(protocol, host, port); - - if (host === undefined) { - logger.info( - `Server listening on \n local: ${address.local[0]} \t\n network: ${address.network[0]}\n` - ); - } else { - logger.info(`Server listening on ${address.local[0]}`); + const hostOptions = typeof options.host === "boolean" ? "localhost" : options.host + const host = process.env.HOST ?? hostOptions; + const handler = createStandaloneHandler(app, options); + const server = createServer(handler, host, port); + server.server.listen(port, host) + if (process.env.ASTRO_NODE_LOGGING !== "disabled") { + logListeningOn(app.getAdapterLogger(), server.server, options) } - return { server, done: server.closed(), }; } + +// also used by server entrypoint +export function createStandaloneHandler(app: NodeApp, options: Options) { + const appHandler = createAppHandler(app); + const staticHandler = createStaticHandler(app, options); + return (req: http.IncomingMessage, res: http.ServerResponse) => { + try { + // validate request path + decodeURI(req.url!); + } catch { + res.writeHead(400); + res.end('Bad request.'); + return; + } + staticHandler(req, res, () => appHandler(req, res)); + } +} + +// also used by preview entrypoint +export function createServer( + listener: http.RequestListener, + host: string, + port: number +) { + 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); + } + enableDestroy(httpServer); + + // Resolves once the server is closed + const closed = new Promise((resolve, reject) => { + httpServer.addListener('close', resolve); + httpServer.addListener('error', reject); + }); + + const previewable = { + host, + port, + closed() { + return closed; + }, + async stop() { + await new Promise((resolve, reject) => { + httpServer.destroy((err) => (err ? reject(err) : resolve(undefined))); + }); + } + } satisfies PreviewServer; + + return { + server: httpServer, + ...previewable, + }; +} diff --git a/packages/integrations/node/src/types.ts b/packages/integrations/node/src/types.ts index 273b805292..9e4f4ce919 100644 --- a/packages/integrations/node/src/types.ts +++ b/packages/integrations/node/src/types.ts @@ -1,3 +1,4 @@ +import type { NodeApp } from 'astro/app/node'; import type { IncomingMessage, ServerResponse } from 'node:http'; export interface UserOptions { @@ -18,11 +19,19 @@ export interface Options extends UserOptions { assets: string; } +export interface CreateServerOptions { + app: NodeApp; + assets: string; + client: URL; + port: number; + host: string | undefined; + removeBase: (pathname: string) => string; +} + +export type RequestHandler = (...args: RequestHandlerParams) => void | Promise; export type RequestHandlerParams = [ req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void, locals?: object, ]; - -export type ErrorHandlerParams = [unknown, ...RequestHandlerParams]; diff --git a/packages/integrations/node/test/bad-urls.test.js b/packages/integrations/node/test/bad-urls.test.js index 894729e367..bfef81278d 100644 --- a/packages/integrations/node/test/bad-urls.test.js +++ b/packages/integrations/node/test/bad-urls.test.js @@ -34,9 +34,9 @@ describe('Bad URLs', () => { for (const weirdUrl of weirdURLs) { const fetchResult = await fixture.fetch(weirdUrl); - expect([400, 500]).to.include( + expect([400, 404, 500]).to.include( fetchResult.status, - `${weirdUrl} returned something else than 400 or 500` + `${weirdUrl} returned something else than 400, 404, or 500` ); } const stillWork = await fixture.fetch('/'); diff --git a/packages/integrations/node/test/node-middleware.test.js b/packages/integrations/node/test/node-middleware.test.js index 009f403c21..6b67859535 100644 --- a/packages/integrations/node/test/node-middleware.test.js +++ b/packages/integrations/node/test/node-middleware.test.js @@ -21,7 +21,6 @@ describe('behavior from middleware, standalone', () => { let server; before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ root: './fixtures/node-middleware/', @@ -61,7 +60,6 @@ describe('behavior from middleware, middleware', () => { let server; before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ root: './fixtures/node-middleware/', diff --git a/packages/integrations/node/test/prerender-404-500.test.js b/packages/integrations/node/test/prerender-404-500.test.js index f8bf0778c7..745a1958c6 100644 --- a/packages/integrations/node/test/prerender-404-500.test.js +++ b/packages/integrations/node/test/prerender-404-500.test.js @@ -21,7 +21,6 @@ describe('Prerender 404', () => { describe('With base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = true; fixture = await loadFixture({ @@ -107,7 +106,6 @@ describe('Prerender 404', () => { describe('Without base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = true; fixture = await loadFixture({ @@ -171,7 +169,6 @@ describe('Hybrid 404', () => { describe('With base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ // inconsequential config that differs between tests @@ -229,7 +226,6 @@ describe('Hybrid 404', () => { describe('Without base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ // inconsequential config that differs between tests diff --git a/packages/integrations/node/test/prerender.test.js b/packages/integrations/node/test/prerender.test.js index 65e3b4cb2e..0d87e77110 100644 --- a/packages/integrations/node/test/prerender.test.js +++ b/packages/integrations/node/test/prerender.test.js @@ -18,7 +18,6 @@ describe('Prerendering', () => { describe('With base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = true; fixture = await loadFixture({ @@ -86,7 +85,6 @@ describe('Prerendering', () => { describe('Without base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = true; fixture = await loadFixture({ @@ -151,7 +149,6 @@ describe('Hybrid rendering', () => { describe('With base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ base: '/some-base', @@ -217,7 +214,6 @@ describe('Hybrid rendering', () => { describe('Without base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ root: './fixtures/prerender/', diff --git a/packages/integrations/node/test/test-utils.js b/packages/integrations/node/test/test-utils.js index 70ceaed258..6c8c5d2706 100644 --- a/packages/integrations/node/test/test-utils.js +++ b/packages/integrations/node/test/test-utils.js @@ -2,6 +2,8 @@ import httpMocks from 'node-mocks-http'; import { EventEmitter } from 'node:events'; import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js'; +process.env.ASTRO_NODE_AUTOSTART = "disabled"; +process.env.ASTRO_NODE_LOGGING = "disabled"; /** * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture */ diff --git a/packages/integrations/vercel/package.json b/packages/integrations/vercel/package.json index 1968209d8d..1a8554783e 100644 --- a/packages/integrations/vercel/package.json +++ b/packages/integrations/vercel/package.json @@ -59,7 +59,7 @@ "web-vitals": "^3.4.0" }, "peerDependencies": { - "astro": "^4.0.2" + "astro": "^4.2.0" }, "devDependencies": { "@types/set-cookie-parser": "^2.4.6", diff --git a/packages/integrations/vercel/src/serverless/adapter.ts b/packages/integrations/vercel/src/serverless/adapter.ts index 3e4934bc21..5a3c92b02a 100644 --- a/packages/integrations/vercel/src/serverless/adapter.ts +++ b/packages/integrations/vercel/src/serverless/adapter.ts @@ -112,7 +112,7 @@ export default function vercelServerless({ webAnalytics, speedInsights, includeFiles, - excludeFiles, + excludeFiles = [], imageService, imagesConfig, devImageService = 'sharp', @@ -189,9 +189,10 @@ export default function vercelServerless({ 'astro:config:done': ({ setAdapter, config, logger }) => { if (functionPerRoute === true) { logger.warn( - `Vercel's hosting plans might have limits to the number of functions you can create. -Make sure to check your plan carefully to avoid incurring additional costs. -You can set functionPerRoute: false to prevent surpassing the limit.` + `\n` + + `\tVercel's hosting plans might have limits to the number of functions you can create.\n` + + `\tMake sure to check your plan carefully to avoid incurring additional costs.\n` + + `\tYou can set functionPerRoute: false to prevent surpassing the limit.\n` ); } setAdapter(getAdapter({ functionPerRoute, edgeMiddleware })); @@ -205,7 +206,6 @@ You can set functionPerRoute: false to prevent surpassing the limit.` ); } }, - 'astro:build:ssr': async ({ entryPoints, middlewareEntryPoint }) => { _entryPoints = entryPoints; if (middlewareEntryPoint) { @@ -223,7 +223,6 @@ You can set functionPerRoute: false to prevent surpassing the limit.` extraFilesToInclude.push(bundledMiddlewarePath); } }, - 'astro:build:done': async ({ routes, logger }) => { // Merge any includes from `vite.assetsInclude if (_config.vite.assetsInclude) { @@ -245,7 +244,7 @@ You can set functionPerRoute: false to prevent surpassing the limit.` const filesToInclude = includeFiles?.map((file) => new URL(file, _config.root)) || []; filesToInclude.push(...extraFilesToInclude); - validateRuntime(); + const runtime = getRuntime(process, logger); // Multiple entrypoint support if (_entryPoints.size) { @@ -263,6 +262,7 @@ You can set functionPerRoute: false to prevent surpassing the limit.` await createFunctionFolder({ functionName: func, + runtime, entry: entryFile, config: _config, logger, @@ -279,6 +279,7 @@ You can set functionPerRoute: false to prevent surpassing the limit.` } else { await createFunctionFolder({ functionName: 'render', + runtime, entry: new URL(serverEntry, buildTempFolder), config: _config, logger, @@ -342,19 +343,23 @@ You can set functionPerRoute: false to prevent surpassing the limit.` }; } +type Runtime = `nodejs${string}.x`; + interface CreateFunctionFolderArgs { functionName: string; + runtime: Runtime; entry: URL; config: AstroConfig; logger: AstroIntegrationLogger; NTF_CACHE: any; includeFiles: URL[]; - excludeFiles?: string[]; + excludeFiles: string[]; maxDuration: number | undefined; } async function createFunctionFolder({ functionName, + runtime, entry, config, logger, @@ -363,7 +368,10 @@ async function createFunctionFolder({ excludeFiles, maxDuration, }: CreateFunctionFolderArgs) { + // .vercel/output/functions/.func/ const functionFolder = new URL(`./functions/${functionName}.func/`, config.outDir); + const packageJson = new URL(`./functions/${functionName}.func/package.json`, config.outDir); + const vcConfig = new URL(`./functions/${functionName}.func/.vc-config.json`, config.outDir); // Copy necessary files (e.g. node_modules/) const { handler } = await copyDependenciesToFunction( @@ -371,7 +379,7 @@ async function createFunctionFolder({ entry, outDir: functionFolder, includeFiles, - excludeFiles: excludeFiles?.map((file) => new URL(file, config.root)) || [], + excludeFiles: excludeFiles.map((file) => new URL(file, config.root)), logger, }, NTF_CACHE @@ -379,14 +387,12 @@ async function createFunctionFolder({ // Enable ESM // https://aws.amazon.com/blogs/compute/using-node-js-es-modules-and-top-level-await-in-aws-lambda/ - await writeJson(new URL(`./package.json`, functionFolder), { - type: 'module', - }); + await writeJson(packageJson, { type: 'module' }); // Serverless function config // https://vercel.com/docs/build-output-api/v3#vercel-primitives/serverless-functions/configuration - await writeJson(new URL(`./.vc-config.json`, functionFolder), { - runtime: getRuntime(), + await writeJson(vcConfig, { + runtime, handler, launcherType: 'Nodejs', maxDuration, @@ -394,44 +400,43 @@ async function createFunctionFolder({ }); } -function validateRuntime() { - const version = process.version.slice(1); // 'v16.5.0' --> '16.5.0' - const major = version.split('.')[0]; // '16.5.0' --> '16' +function getRuntime(process: NodeJS.Process, logger: AstroIntegrationLogger): Runtime { + const version = process.version.slice(1); // 'v18.19.0' --> '18.19.0' + const major = version.split('.')[0]; // '18.19.0' --> '18' const support = SUPPORTED_NODE_VERSIONS[major]; if (support === undefined) { - console.warn( - `[${PACKAGE_NAME}] The local Node.js version (${major}) is not supported by Vercel Serverless Functions.` + logger.warn( + `\n` + + `\tThe local Node.js version (${major}) is not supported by Vercel Serverless Functions.\n` + + `\tYour project will use Node.js 18 as the runtime instead.\n` + + `\tConsider switching your local version to 18.\n` ); - console.warn(`[${PACKAGE_NAME}] Your project will use Node.js 18 as the runtime instead.`); - console.warn(`[${PACKAGE_NAME}] Consider switching your local version to 18.`); - return; } - if (support.status === 'beta') { - console.warn( - `[${PACKAGE_NAME}] The local Node.js version (${major}) is currently in beta for Vercel Serverless Functions.` + if (support.status === 'current') { + return `nodejs${major}.x`; + } else if (support?.status === 'beta') { + logger.warn( + `Your project is being built for Node.js ${major} as the runtime, which is currently in beta for Vercel Serverless Functions.` ); - console.warn(`[${PACKAGE_NAME}] Make sure to update your Vercel settings to use ${major}.`); - return; - } - if (support.status === 'deprecated') { - console.warn( - `[${PACKAGE_NAME}] Your project is being built for Node.js ${major} as the runtime.` + return `nodejs${major}.x`; + } else if (support.status === 'deprecated') { + const removeDate = new Intl.DateTimeFormat(undefined, { dateStyle: 'long' }).format( + support.removal ); - console.warn( - `[${PACKAGE_NAME}] This version is deprecated by Vercel Serverless Functions, and scheduled to be disabled on ${new Intl.DateTimeFormat( - undefined, - { dateStyle: 'long' } - ).format(support.removal)}.` + logger.warn( + `\n` + + `\tYour project is being built for Node.js ${major} as the runtime.\n` + + `\tThis version is deprecated by Vercel Serverless Functions, and scheduled to be disabled on ${removeDate}.\n` + + `\tConsider upgrading your local version to 18.\n` + ); + return `nodejs${major}.x`; + } else { + logger.warn( + `\n` + + `\tThe local Node.js version (${major}) is not supported by Vercel Serverless Functions.\n` + + `\tYour project will use Node.js 18 as the runtime instead.\n` + + `\tConsider switching your local version to 18.\n` ); - console.warn(`[${PACKAGE_NAME}] Consider upgrading your local version to 18.`); - } -} - -function getRuntime() { - const version = process.version.slice(1); // 'v16.5.0' --> '16.5.0' - const major = version.split('.')[0]; // '16.5.0' --> '16' - const support = SUPPORTED_NODE_VERSIONS[major]; - if (support === undefined) { return 'nodejs18.x'; } return `nodejs${major}.x`; diff --git a/packages/integrations/vercel/src/serverless/entrypoint.ts b/packages/integrations/vercel/src/serverless/entrypoint.ts index 513c34640a..5d4c7cb21c 100644 --- a/packages/integrations/vercel/src/serverless/entrypoint.ts +++ b/packages/integrations/vercel/src/serverless/entrypoint.ts @@ -1,35 +1,21 @@ import type { SSRManifest } from 'astro'; -import { App } from 'astro/app'; -import { applyPolyfills } from 'astro/app/node'; +import { applyPolyfills, NodeApp } from 'astro/app/node'; import type { IncomingMessage, ServerResponse } from 'node:http'; - import { ASTRO_LOCALS_HEADER } from './adapter.js'; -import { getRequest, setResponse } from './request-transform.js'; applyPolyfills(); export const createExports = (manifest: SSRManifest) => { - const app = new App(manifest); - + const app = new NodeApp(manifest); const handler = async (req: IncomingMessage, res: ServerResponse) => { - let request: Request; - - try { - request = await getRequest(`https://${req.headers.host}`, req); - } catch (err: any) { - res.statusCode = err.status || 400; - return res.end(err.reason || 'Invalid request body'); - } - - let routeData = app.match(request); - let locals = {}; - if (request.headers.has(ASTRO_LOCALS_HEADER)) { - let localsAsString = request.headers.get(ASTRO_LOCALS_HEADER); - if (localsAsString) { - locals = JSON.parse(localsAsString); - } - } - await setResponse(app, res, await app.render(request, { routeData, locals })); + const clientAddress = req.headers['x-forwarded-for'] as string | undefined; + const localsHeader = req.headers[ASTRO_LOCALS_HEADER] + const locals = + typeof localsHeader === "string" ? JSON.parse(localsHeader) + : Array.isArray(localsHeader) ? JSON.parse(localsHeader[0]) + : {}; + const webResponse = await app.render(req, { locals, clientAddress }) + await NodeApp.writeResponse(webResponse, res); }; return { default: handler }; diff --git a/packages/integrations/vercel/src/serverless/request-transform.ts b/packages/integrations/vercel/src/serverless/request-transform.ts deleted file mode 100644 index 31aa377af6..0000000000 --- a/packages/integrations/vercel/src/serverless/request-transform.ts +++ /dev/null @@ -1,203 +0,0 @@ -import type { App } from 'astro/app'; -import type { IncomingMessage, ServerResponse } from 'node:http'; -import { splitCookiesString } from 'set-cookie-parser'; - -const clientAddressSymbol = Symbol.for('astro.clientAddress'); - -/* - Credits to the SvelteKit team - https://github.com/sveltejs/kit/blob/8d1ba04825a540324bc003e85f36559a594aadc2/packages/kit/src/exports/node/index.js -*/ - -function get_raw_body(req: IncomingMessage, body_size_limit?: number): ReadableStream | null { - const h = req.headers; - - if (!h['content-type']) { - return null; - } - - const content_length = Number(h['content-length']); - - // check if no request body - if ( - (req.httpVersionMajor === 1 && isNaN(content_length) && h['transfer-encoding'] == null) || - content_length === 0 - ) { - return null; - } - - let length = content_length; - - if (body_size_limit) { - if (!length) { - length = body_size_limit; - } else if (length > body_size_limit) { - throw new HTTPError( - 413, - `Received content-length of ${length}, but only accept up to ${body_size_limit} bytes.` - ); - } - } - - if (req.destroyed) { - const readable = new ReadableStream(); - readable.cancel(); - return readable; - } - - let size = 0; - let cancelled = false; - - return new ReadableStream({ - start(controller) { - req.on('error', (error) => { - cancelled = true; - controller.error(error); - }); - - req.on('end', () => { - if (cancelled) return; - controller.close(); - }); - - req.on('data', (chunk) => { - if (cancelled) return; - - size += chunk.length; - if (size > length) { - cancelled = true; - controller.error( - new HTTPError( - 413, - `request body size exceeded ${ - content_length ? "'content-length'" : 'BODY_SIZE_LIMIT' - } of ${length}` - ) - ); - return; - } - - controller.enqueue(chunk); - - if (controller.desiredSize === null || controller.desiredSize <= 0) { - req.pause(); - } - }); - }, - - pull() { - req.resume(); - }, - - cancel(reason) { - cancelled = true; - req.destroy(reason); - }, - }); -} - -export async function getRequest( - base: string, - req: IncomingMessage, - bodySizeLimit?: number -): Promise { - let headers = req.headers as Record; - let request = new Request(base + req.url, { - // @ts-expect-error -- duplex does exist in Vercel requests - duplex: 'half', - method: req.method, - headers, - body: get_raw_body(req, bodySizeLimit), - }); - Reflect.set(request, clientAddressSymbol, headers['x-forwarded-for']); - return request; -} - -export async function setResponse( - app: App, - res: ServerResponse, - response: Response -): Promise { - const headers = Object.fromEntries(response.headers); - let cookies: string[] = []; - - if (response.headers.has('set-cookie')) { - const header = response.headers.get('set-cookie')!; - const split = splitCookiesString(header); - cookies = split; - } - - if (app.setCookieHeaders) { - for (const setCookieHeader of app.setCookieHeaders(response)) { - cookies.push(setCookieHeader); - } - } - - res.writeHead(response.status, { ...headers, 'set-cookie': cookies }); - - if (!response.body) { - res.end(); - return; - } - - if (response.body.locked) { - res.write( - 'Fatal error: Response body is locked. ' + - `This can happen when the response was already read (for example through 'response.json()' or 'response.text()').` - ); - res.end(); - return; - } - - const reader = response.body.getReader(); - - if (res.destroyed) { - reader.cancel(); - return; - } - - const cancel = (error?: Error) => { - res.off('close', cancel); - res.off('error', cancel); - - // If the reader has already been interrupted with an error earlier, - // then it will appear here, it is useless, but it needs to be catch. - reader.cancel(error).catch(() => {}); - if (error) res.destroy(error); - }; - - res.on('close', cancel); - res.on('error', cancel); - - next(); - async function next() { - try { - for (;;) { - const { done, value } = await reader.read(); - - if (done) break; - - if (!res.write(value)) { - res.once('drain', next); - return; - } - } - res.end(); - } catch (error) { - cancel(error instanceof Error ? error : new Error(String(error))); - } - } -} - -class HTTPError extends Error { - status: number; - - constructor(status: number, reason: string) { - super(reason); - this.status = status; - } - - get reason() { - return super.message; - } -}