0
Fork 0
mirror of https://github.com/withastro/astro.git synced 2024-12-16 21:46:22 -05:00

Adapter enhancements (#9661)

* quality of life updates for `App` (#9579)

* feat(app): writeResponse for node-based adapters

* add changeset

* Apply suggestions from code review

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* Apply suggestions from code review

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* add examples for NodeApp static methods

* unexpose createOutgoingHttpHeaders from public api

* move headers test to core

* clientAddress test

* cookies test

* destructure renderOptions right at the start

---------

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* Fallback node standalone to localhost (#9545)

* Fallback node standalone to localhost

* Update .changeset/tame-squids-film.md

* quality of life updates for the node adapter (#9582)

* descriptive names for files and functions

* update tests

* add changeset

* appease linter

* Apply suggestions from code review

Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>

* `server-entrypoint.js` -> `server.js`

* prevent crash on stream error (from PR 9533)

* Apply suggestions from code review

Co-authored-by: Luiz Ferraz <luiz@lferraz.com>

* `127.0.0.1` -> `localhost`

* add changeset for fryuni's fix

* Apply suggestions from code review

* Apply suggestions from code review

Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

---------

Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
Co-authored-by: Luiz Ferraz <luiz@lferraz.com>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>

* chore(vercel): delete request response conversion logic (#9583)

* refactor

* add changeset

* bump peer dependencies

* unexpose symbols (#9683)

* Update .changeset/tame-squids-film.md

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

---------

Co-authored-by: Arsh <69170106+lilnasy@users.noreply.github.com>
Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com>
Co-authored-by: Nate Moore <natemoo-re@users.noreply.github.com>
Co-authored-by: Luiz Ferraz <luiz@lferraz.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
This commit is contained in:
Emanuele Stoppa 2024-01-17 13:10:43 +00:00 committed by GitHub
parent 3a4d5ec800
commit d6edc75408
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 716 additions and 799 deletions

View file

@ -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<string> = 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 })
```

View file

@ -0,0 +1,7 @@
---
"@astrojs/vercel": major
---
**Breaking**: Minimum required Astro version is now 4.2.0.
Reorganizes internals to be more maintainable.
---

View file

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

View file

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

View file

@ -0,0 +1,6 @@
---
"@astrojs/node": major
---
**Breaking**: Minimum required Astro version is now 4.2.0.
Reorganizes internals to be more maintainable.

View file

@ -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<T>(
incomingReq: Request,
endpoint: string,
cb: (response: Response) => Promise<T>
): Promise<T> {
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,
});

View file

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

View file

@ -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<Response> {
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
*/

View file

@ -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<RequestInit>;
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<Response>;
/**
* @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<Response>;
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<any>): BodyProps {
function asyncIterableToBodyProps(iterable: AsyncIterable<any>): 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<any>): 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<Response>;
/**
* @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<Response>;
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<SSRManifest> {

View file

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

View file

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

View file

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

View file

@ -37,7 +37,7 @@
"server-destroy": "^1.0.1"
},
"peerDependencies": {
"astro": "^4.0.0"
"astro": "^4.2.0"
},
"devDependencies": {
"@types/node": "^18.17.8",

View file

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

View file

@ -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<http.IncomingMessage>) => {
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<typeof http.IncomingMessage, typeof http.ServerResponse>
| https.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
if (process.env.SERVER_CERT_PATH && process.env.SERVER_KEY_PATH) {
httpServer = https.createServer(
{
key: fs.readFileSync(process.env.SERVER_KEY_PATH),
cert: fs.readFileSync(process.env.SERVER_CERT_PATH),
},
listener
);
} else {
httpServer = http.createServer(listener);
}
httpServer.listen(port, host);
enableDestroy(httpServer);
// Resolves once the server is closed
const closed = new Promise<void>((resolve, reject) => {
httpServer.addListener('close', resolve);
httpServer.addListener('error', reject);
});
return {
host,
port,
closed() {
return closed;
},
server: httpServer,
stop: async () => {
await new Promise((resolve, reject) => {
httpServer.destroy((err) => (err ? reject(err) : resolve(undefined)));
});
},
};
}

View file

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

View file

@ -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<Options, "host">) {
await new Promise<void>(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;
}

View file

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

View file

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

View file

@ -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<typeof createExports>;
type MaybeServerModule = Partial<ServerModule>;
type ServerModule = ReturnType<typeof createExports>;
type MaybeServerModule = Partial<ServerModule>;
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<void>((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 }

View file

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

View file

@ -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 + '/';
}

View file

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

View file

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

View file

@ -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<void>;
export type RequestHandlerParams = [
req: IncomingMessage,
res: ServerResponse,
next?: (err?: unknown) => void,
locals?: object,
];
export type ErrorHandlerParams = [unknown, ...RequestHandlerParams];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/<name>.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`;

View file

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

View file

@ -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<Request> {
let headers = req.headers as Record<string, string>;
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<void> {
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;
}
}