Skip to content

Commit d6edc75

Browse files
ematipicolilnasybluwynatemoo-reFryuni
authoredJan 17, 2024
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>
1 parent 3a4d5ec commit d6edc75

34 files changed

+706
-789
lines changed
 

‎.changeset/cool-foxes-talk.md

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"astro": minor
3+
---
4+
5+
Adds new helper functions for adapter developers.
6+
7+
- `Astro.clientAddress` can now be passed directly to the `app.render()` method.
8+
```ts
9+
const response = await app.render(request, { clientAddress: "012.123.23.3" })
10+
```
11+
12+
- 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.
13+
```ts
14+
http.createServer((nodeReq, nodeRes) => {
15+
const request: Request = NodeApp.createRequest(nodeReq)
16+
const response = await app.render(request)
17+
await NodeApp.writeResponse(response, nodeRes)
18+
})
19+
```
20+
21+
- Cookies added via `Astro.cookies.set()` can now be automatically added to the `Response` object by passing the `addCookieHeader` option to `app.render()`.
22+
```diff
23+
-const response = await app.render(request)
24+
-const setCookieHeaders: Array<string> = Array.from(app.setCookieHeaders(webResponse));
25+
26+
-if (setCookieHeaders.length) {
27+
- for (const setCookieHeader of setCookieHeaders) {
28+
- headers.append('set-cookie', setCookieHeader);
29+
- }
30+
-}
31+
+const response = await app.render(request, { addCookieHeader: true })
32+
```

‎.changeset/early-cups-poke.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@astrojs/vercel": major
3+
---
4+
5+
**Breaking**: Minimum required Astro version is now 4.2.0.
6+
Reorganizes internals to be more maintainable.
7+
---

‎.changeset/tame-squids-film.md

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@astrojs/node': major
3+
---
4+
5+
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.
6+
7+
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`.

‎.changeset/unlucky-stingrays-clean.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@astrojs/node": patch
3+
---
4+
5+
Fixes an issue where the preview server appeared to be ready to serve requests before binding to a port.

‎.changeset/weak-apes-add.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@astrojs/node": major
3+
---
4+
5+
**Breaking**: Minimum required Astro version is now 4.2.0.
6+
Reorganizes internals to be more maintainable.

‎examples/ssr/src/api.ts

+2-5
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,13 @@ interface Cart {
1717
}>;
1818
}
1919

20-
function getOrigin(request: Request): string {
21-
return new URL(request.url).origin.replace('localhost', '127.0.0.1');
22-
}
23-
2420
async function get<T>(
2521
incomingReq: Request,
2622
endpoint: string,
2723
cb: (response: Response) => Promise<T>
2824
): Promise<T> {
29-
const response = await fetch(`${getOrigin(incomingReq)}${endpoint}`, {
25+
const origin = new URL(incomingReq.url).origin;
26+
const response = await fetch(`${origin}${endpoint}`, {
3027
credentials: 'same-origin',
3128
headers: incomingReq.headers,
3229
});

‎packages/integrations/node/src/createOutgoingHttpHeaders.ts ‎packages/astro/src/core/app/createOutgoingHttpHeaders.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import type { OutgoingHttpHeaders } from 'node:http';
44
* Takes in a nullable WebAPI Headers object and produces a NodeJS OutgoingHttpHeaders object suitable for usage
55
* with ServerResponse.writeHead(..) or ServerResponse.setHeader(..)
66
*
7-
* @param webHeaders WebAPI Headers object
8-
* @returns NodeJS OutgoingHttpHeaders object with multiple set-cookie handled as an array of values
7+
* @param headers WebAPI Headers object
8+
* @returns {OutgoingHttpHeaders} NodeJS OutgoingHttpHeaders object with multiple set-cookie handled as an array of values
99
*/
1010
export const createOutgoingHttpHeaders = (
1111
headers: Headers | undefined | null

‎packages/astro/src/core/app/index.ts

+77-11
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,46 @@ import { EndpointNotFoundError, SSRRoutePipeline } from './ssrPipeline.js';
2929
import type { RouteInfo } from './types.js';
3030
export { deserializeManifest } from './common.js';
3131

32-
const clientLocalsSymbol = Symbol.for('astro.locals');
33-
32+
const localsSymbol = Symbol.for('astro.locals');
33+
const clientAddressSymbol = Symbol.for('astro.clientAddress');
3434
const responseSentSymbol = Symbol.for('astro.responseSent');
3535

36-
const STATUS_CODES = new Set([404, 500]);
36+
/**
37+
* A response with one of these status codes will be rewritten
38+
* with the result of rendering the respective error page.
39+
*/
40+
const REROUTABLE_STATUS_CODES = new Set([404, 500]);
3741

3842
export interface RenderOptions {
39-
routeData?: RouteData;
43+
/**
44+
* Whether to automatically add all cookies written by `Astro.cookie.set()` to the response headers.
45+
*
46+
* 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.
47+
*
48+
* When set to `false`, the cookies will only be available from `App.getSetCookieFromResponse(response)`.
49+
*
50+
* @default {false}
51+
*/
52+
addCookieHeader?: boolean;
53+
54+
/**
55+
* The client IP address that will be made available as `Astro.clientAddress` in pages, and as `ctx.clientAddress` in API routes and middleware.
56+
*
57+
* Default: `request[Symbol.for("astro.clientAddress")]`
58+
*/
59+
clientAddress?: string;
60+
61+
/**
62+
* The mutable object that will be made available as `Astro.locals` in pages, and as `ctx.locals` in API routes and middleware.
63+
*/
4064
locals?: object;
65+
66+
/**
67+
* **Advanced API**: you probably do not need to use this.
68+
*
69+
* Default: `app.match(request)`
70+
*/
71+
routeData?: RouteData;
4172
}
4273

4374
export interface RenderErrorOptions {
@@ -160,11 +191,24 @@ export class App {
160191
): Promise<Response> {
161192
let routeData: RouteData | undefined;
162193
let locals: object | undefined;
194+
let clientAddress: string | undefined;
195+
let addCookieHeader: boolean | undefined;
163196

164197
if (
165198
routeDataOrOptions &&
166-
('routeData' in routeDataOrOptions || 'locals' in routeDataOrOptions)
199+
(
200+
'addCookieHeader' in routeDataOrOptions ||
201+
'clientAddress' in routeDataOrOptions ||
202+
'locals' in routeDataOrOptions ||
203+
'routeData' in routeDataOrOptions
204+
)
167205
) {
206+
if ('addCookieHeader' in routeDataOrOptions) {
207+
addCookieHeader = routeDataOrOptions.addCookieHeader;
208+
}
209+
if ('clientAddress' in routeDataOrOptions) {
210+
clientAddress = routeDataOrOptions.clientAddress;
211+
}
168212
if ('routeData' in routeDataOrOptions) {
169213
routeData = routeDataOrOptions.routeData;
170214
}
@@ -178,7 +222,12 @@ export class App {
178222
this.#logRenderOptionsDeprecationWarning();
179223
}
180224
}
181-
225+
if (locals) {
226+
Reflect.set(request, localsSymbol, locals);
227+
}
228+
if (clientAddress) {
229+
Reflect.set(request, clientAddressSymbol, clientAddress)
230+
}
182231
// Handle requests with duplicate slashes gracefully by cloning with a cleaned-up request URL
183232
if (request.url !== collapseDuplicateSlashes(request.url)) {
184233
request = new Request(collapseDuplicateSlashes(request.url), request);
@@ -189,7 +238,6 @@ export class App {
189238
if (!routeData) {
190239
return this.#renderError(request, { status: 404 });
191240
}
192-
Reflect.set(request, clientLocalsSymbol, locals ?? {});
193241
const pathname = this.#getPathnameFromRequest(request);
194242
const defaultStatus = this.#getDefaultStatusCode(routeData, pathname);
195243
const mod = await this.#getModuleForRoute(routeData);
@@ -206,7 +254,7 @@ export class App {
206254
);
207255
let response;
208256
try {
209-
let i18nMiddleware = createI18nMiddleware(
257+
const i18nMiddleware = createI18nMiddleware(
210258
this.#manifest.i18n,
211259
this.#manifest.base,
212260
this.#manifest.trailingSlash
@@ -233,16 +281,21 @@ export class App {
233281
}
234282
}
235283

284+
// endpoints do not participate in implicit rerouting
236285
if (routeData.type === 'page' || routeData.type === 'redirect') {
237-
if (STATUS_CODES.has(response.status)) {
286+
if (REROUTABLE_STATUS_CODES.has(response.status)) {
238287
return this.#renderError(request, {
239288
response,
240289
status: response.status as 404 | 500,
241290
});
242291
}
243-
Reflect.set(response, responseSentSymbol, true);
244-
return response;
245292
}
293+
if (addCookieHeader) {
294+
for (const setCookieHeaderValue of App.getSetCookieFromResponse(response)) {
295+
response.headers.append('set-cookie', setCookieHeaderValue);
296+
}
297+
}
298+
Reflect.set(response, responseSentSymbol, true);
246299
return response;
247300
}
248301

@@ -259,6 +312,19 @@ export class App {
259312
return getSetCookiesFromResponse(response);
260313
}
261314

315+
/**
316+
* Reads all the cookies written by `Astro.cookie.set()` onto the passed response.
317+
* For example,
318+
* ```ts
319+
* for (const cookie_ of App.getSetCookieFromResponse(response)) {
320+
* const cookie: string = cookie_
321+
* }
322+
* ```
323+
* @param response The response to read cookies from.
324+
* @returns An iterator that yields key-value pairs as equal-sign-separated strings.
325+
*/
326+
static getSetCookieFromResponse = getSetCookiesFromResponse
327+
262328
/**
263329
* Creates the render context of the current route
264330
*/

‎packages/astro/src/core/app/node.ts

+122-79
Original file line numberDiff line numberDiff line change
@@ -1,51 +1,135 @@
1+
import fs from 'node:fs';
2+
import { App } from './index.js';
3+
import { deserializeManifest } from './common.js';
4+
import { createOutgoingHttpHeaders } from './createOutgoingHttpHeaders.js';
5+
import type { IncomingMessage, ServerResponse } from 'node:http';
16
import type { RouteData } from '../../@types/astro.js';
27
import type { RenderOptions } from './index.js';
38
import type { SerializedSSRManifest, SSRManifest } from './types.js';
49

5-
import * as fs from 'node:fs';
6-
import { IncomingMessage } from 'node:http';
7-
import { TLSSocket } from 'node:tls';
8-
import { deserializeManifest } from './common.js';
9-
import { App } from './index.js';
1010
export { apply as applyPolyfills } from '../polyfill.js';
1111

1212
const clientAddressSymbol = Symbol.for('astro.clientAddress');
1313

14-
type CreateNodeRequestOptions = {
15-
emptyBody?: boolean;
16-
};
17-
18-
type BodyProps = Partial<RequestInit>;
14+
/**
15+
* Allow the request body to be explicitly overridden. For example, this
16+
* is used by the Express JSON middleware.
17+
*/
18+
interface NodeRequest extends IncomingMessage {
19+
body?: unknown;
20+
}
1921

20-
function createRequestFromNodeRequest(
21-
req: NodeIncomingMessage,
22-
options?: CreateNodeRequestOptions
23-
): Request {
24-
const protocol =
25-
req.socket instanceof TLSSocket || req.headers['x-forwarded-proto'] === 'https'
26-
? 'https'
27-
: 'http';
28-
const hostname = req.headers.host || req.headers[':authority'];
29-
const url = `${protocol}://${hostname}${req.url}`;
30-
const headers = makeRequestHeaders(req);
31-
const method = req.method || 'GET';
32-
let bodyProps: BodyProps = {};
33-
const bodyAllowed = method !== 'HEAD' && method !== 'GET' && !options?.emptyBody;
34-
if (bodyAllowed) {
35-
bodyProps = makeRequestBody(req);
22+
export class NodeApp extends App {
23+
match(req: NodeRequest | Request) {
24+
if (!(req instanceof Request)) {
25+
req = NodeApp.createRequest(req, {
26+
skipBody: true,
27+
});
28+
}
29+
return super.match(req);
30+
}
31+
render(request: NodeRequest | Request, options?: RenderOptions): Promise<Response>;
32+
/**
33+
* @deprecated Instead of passing `RouteData` and locals individually, pass an object with `routeData` and `locals` properties.
34+
* See https://github.com/withastro/astro/pull/9199 for more information.
35+
*/
36+
render(
37+
request: NodeRequest | Request,
38+
routeData?: RouteData,
39+
locals?: object
40+
): Promise<Response>;
41+
render(
42+
req: NodeRequest | Request,
43+
routeDataOrOptions?: RouteData | RenderOptions,
44+
maybeLocals?: object
45+
) {
46+
if (!(req instanceof Request)) {
47+
req = NodeApp.createRequest(req);
48+
}
49+
// @ts-expect-error The call would have succeeded against the implementation, but implementation signatures of overloads are not externally visible.
50+
return super.render(req, routeDataOrOptions, maybeLocals);
3651
}
37-
const request = new Request(url, {
38-
method,
39-
headers,
40-
...bodyProps,
41-
});
42-
if (req.socket?.remoteAddress) {
43-
Reflect.set(request, clientAddressSymbol, req.socket.remoteAddress);
52+
53+
/**
54+
* Converts a NodeJS IncomingMessage into a web standard Request.
55+
* ```js
56+
* import { NodeApp } from 'astro/app/node';
57+
* import { createServer } from 'node:http';
58+
*
59+
* const server = createServer(async (req, res) => {
60+
* const request = NodeApp.createRequest(req);
61+
* const response = await app.render(request);
62+
* await NodeApp.writeResponse(response, res);
63+
* })
64+
* ```
65+
*/
66+
static createRequest(
67+
req: NodeRequest,
68+
{ skipBody = false } = {}
69+
): Request {
70+
const protocol = req.headers['x-forwarded-proto'] ??
71+
('encrypted' in req.socket && req.socket.encrypted ? 'https' : 'http');
72+
const hostname = req.headers.host || req.headers[':authority'];
73+
const url = `${protocol}://${hostname}${req.url}`;
74+
const options: RequestInit = {
75+
method: req.method || 'GET',
76+
headers: makeRequestHeaders(req),
77+
}
78+
const bodyAllowed = options.method !== 'HEAD' && options.method !== 'GET' && skipBody === false;
79+
if (bodyAllowed) {
80+
Object.assign(options, makeRequestBody(req));
81+
}
82+
const request = new Request(url, options);
83+
if (req.socket?.remoteAddress) {
84+
Reflect.set(request, clientAddressSymbol, req.socket.remoteAddress);
85+
}
86+
return request;
4487
}
45-
return request;
88+
89+
/**
90+
* Streams a web-standard Response into a NodeJS Server Response.
91+
* ```js
92+
* import { NodeApp } from 'astro/app/node';
93+
* import { createServer } from 'node:http';
94+
*
95+
* const server = createServer(async (req, res) => {
96+
* const request = NodeApp.createRequest(req);
97+
* const response = await app.render(request);
98+
* await NodeApp.writeResponse(response, res);
99+
* })
100+
* ```
101+
* @param source WhatWG Response
102+
* @param destination NodeJS ServerResponse
103+
*/
104+
static async writeResponse(source: Response, destination: ServerResponse) {
105+
const { status, headers, body } = source;
106+
destination.writeHead(status, createOutgoingHttpHeaders(headers));
107+
if (body) {
108+
try {
109+
const reader = body.getReader();
110+
destination.on('close', () => {
111+
// Cancelling the reader may reject not just because of
112+
// an error in the ReadableStream's cancel callback, but
113+
// also because of an error anywhere in the stream.
114+
reader.cancel().catch(err => {
115+
console.error(`There was an uncaught error in the middle of the stream while rendering ${destination.req.url}.`, err);
116+
});
117+
});
118+
let result = await reader.read();
119+
while (!result.done) {
120+
destination.write(result.value);
121+
result = await reader.read();
122+
}
123+
// the error will be logged by the "on end" callback above
124+
} catch {
125+
destination.write('Internal server error');
126+
}
127+
}
128+
destination.end();
129+
};
46130
}
47131

48-
function makeRequestHeaders(req: NodeIncomingMessage): Headers {
132+
function makeRequestHeaders(req: NodeRequest): Headers {
49133
const headers = new Headers();
50134
for (const [name, value] of Object.entries(req.headers)) {
51135
if (value === undefined) {
@@ -62,7 +146,7 @@ function makeRequestHeaders(req: NodeIncomingMessage): Headers {
62146
return headers;
63147
}
64148

65-
function makeRequestBody(req: NodeIncomingMessage): BodyProps {
149+
function makeRequestBody(req: NodeRequest): RequestInit {
66150
if (req.body !== undefined) {
67151
if (typeof req.body === 'string' && req.body.length > 0) {
68152
return { body: Buffer.from(req.body) };
@@ -86,7 +170,7 @@ function makeRequestBody(req: NodeIncomingMessage): BodyProps {
86170
return asyncIterableToBodyProps(req);
87171
}
88172

89-
function asyncIterableToBodyProps(iterable: AsyncIterable<any>): BodyProps {
173+
function asyncIterableToBodyProps(iterable: AsyncIterable<any>): RequestInit {
90174
return {
91175
// Node uses undici for the Request implementation. Undici accepts
92176
// a non-standard async iterable for the body.
@@ -95,49 +179,8 @@ function asyncIterableToBodyProps(iterable: AsyncIterable<any>): BodyProps {
95179
// The duplex property is required when using a ReadableStream or async
96180
// iterable for the body. The type definitions do not include the duplex
97181
// property because they are not up-to-date.
98-
// @ts-expect-error
99182
duplex: 'half',
100-
} satisfies BodyProps;
101-
}
102-
103-
class NodeIncomingMessage extends IncomingMessage {
104-
/**
105-
* Allow the request body to be explicitly overridden. For example, this
106-
* is used by the Express JSON middleware.
107-
*/
108-
body?: unknown;
109-
}
110-
111-
export class NodeApp extends App {
112-
match(req: NodeIncomingMessage | Request) {
113-
if (!(req instanceof Request)) {
114-
req = createRequestFromNodeRequest(req, {
115-
emptyBody: true,
116-
});
117-
}
118-
return super.match(req);
119-
}
120-
render(request: NodeIncomingMessage | Request, options?: RenderOptions): Promise<Response>;
121-
/**
122-
* @deprecated Instead of passing `RouteData` and locals individually, pass an object with `routeData` and `locals` properties.
123-
* See https://github.com/withastro/astro/pull/9199 for more information.
124-
*/
125-
render(
126-
request: NodeIncomingMessage | Request,
127-
routeData?: RouteData,
128-
locals?: object
129-
): Promise<Response>;
130-
render(
131-
req: NodeIncomingMessage | Request,
132-
routeDataOrOptions?: RouteData | RenderOptions,
133-
maybeLocals?: object
134-
) {
135-
if (!(req instanceof Request)) {
136-
req = createRequestFromNodeRequest(req);
137-
}
138-
// @ts-expect-error The call would have succeeded against the implementation, but implementation signatures of overloads are not externally visible.
139-
return super.render(req, routeDataOrOptions, maybeLocals);
140-
}
183+
};
141184
}
142185

143186
export async function loadManifest(rootFolder: URL): Promise<SSRManifest> {

‎packages/astro/test/astro-cookies.test.js

+18
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,24 @@ describe('Astro.cookies', () => {
8989
expect(headers[0]).to.match(/Expires/);
9090
});
9191

92+
it('app.render can include the cookie in the Set-Cookie header', async () => {
93+
const request = new Request('http://example.com/set-value', {
94+
method: 'POST',
95+
});
96+
const response = await app.render(request, { addCookieHeader: true })
97+
expect(response.status).to.equal(200);
98+
expect(response.headers.get("Set-Cookie")).to.be.a('string').and.satisfy(value => value.startsWith("admin=true; Expires="));
99+
});
100+
101+
it('app.render can exclude the cookie from the Set-Cookie header', async () => {
102+
const request = new Request('http://example.com/set-value', {
103+
method: 'POST',
104+
});
105+
const response = await app.render(request, { addCookieHeader: false })
106+
expect(response.status).to.equal(200);
107+
expect(response.headers.get("Set-Cookie")).to.equal(null);
108+
});
109+
92110
it('Early returning a Response still includes set headers', async () => {
93111
const response = await fetchResponse('/early-return', {
94112
headers: {

‎packages/astro/test/client-address.test.js

+9
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,15 @@ describe('Astro.clientAddress', () => {
2929
const $ = cheerio.load(html);
3030
expect($('#address').text()).to.equal('0.0.0.0');
3131
});
32+
33+
it('app.render can provide the address', async () => {
34+
const app = await fixture.loadTestAdapterApp();
35+
const request = new Request('http://example.com/');
36+
const response = await app.render(request, { clientAddress: "1.1.1.1" });
37+
const html = await response.text();
38+
const $ = cheerio.load(html);
39+
expect($('#address').text()).to.equal('1.1.1.1');
40+
});
3241
});
3342

3443
describe('Development', () => {

‎packages/integrations/node/test/createOutgoingHttpHeaders.test.js ‎packages/astro/test/units/app/headers.test.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { expect } from 'chai';
22

3-
import { createOutgoingHttpHeaders } from '../dist/createOutgoingHttpHeaders.js';
3+
import { createOutgoingHttpHeaders } from '../../../dist/core/app/createOutgoingHttpHeaders.js';
44

55
describe('createOutgoingHttpHeaders', () => {
66
it('undefined input headers', async () => {

‎packages/integrations/node/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"server-destroy": "^1.0.1"
3838
},
3939
"peerDependencies": {
40-
"astro": "^4.0.0"
40+
"astro": "^4.2.0"
4141
},
4242
"devDependencies": {
4343
"@types/node": "^18.17.8",

‎packages/integrations/node/src/get-network-address.ts

-48
This file was deleted.

‎packages/integrations/node/src/http-server.ts

-131
This file was deleted.

‎packages/integrations/node/src/index.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { AstroAdapter, AstroIntegration } from 'astro';
21
import { AstroError } from 'astro/errors';
2+
import type { AstroAdapter, AstroIntegration } from 'astro';
33
import type { Options, UserOptions } from './types.js';
4+
45
export function getAdapter(options: Options): AstroAdapter {
56
return {
67
name: '@astrojs/node',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import os from "node:os";
2+
import type http from "node:http";
3+
import https from "node:https";
4+
import type { AstroIntegrationLogger } from "astro";
5+
import type { Options } from './types.js';
6+
import type { AddressInfo } from "node:net";
7+
8+
export async function logListeningOn(logger: AstroIntegrationLogger, server: http.Server | https.Server, options: Pick<Options, "host">) {
9+
await new Promise<void>(resolve => server.once('listening', resolve))
10+
const protocol = server instanceof https.Server ? 'https' : 'http';
11+
// Allow to provide host value at runtime
12+
const host = getResolvedHostForHttpServer(
13+
process.env.HOST !== undefined && process.env.HOST !== '' ? process.env.HOST : options.host
14+
);
15+
const { port } = server.address() as AddressInfo;
16+
const address = getNetworkAddress(protocol, host, port);
17+
18+
if (host === undefined) {
19+
logger.info(
20+
`Server listening on \n local: ${address.local[0]} \t\n network: ${address.network[0]}\n`
21+
);
22+
} else {
23+
logger.info(`Server listening on ${address.local[0]}`);
24+
}
25+
}
26+
27+
function getResolvedHostForHttpServer(host: string | boolean) {
28+
if (host === false) {
29+
// Use a secure default
30+
return 'localhost';
31+
} else if (host === true) {
32+
// If passed --host in the CLI without arguments
33+
return undefined; // undefined typically means 0.0.0.0 or :: (listen on all IPs)
34+
} else {
35+
return host;
36+
}
37+
}
38+
39+
interface NetworkAddressOpt {
40+
local: string[];
41+
network: string[];
42+
}
43+
44+
const wildcardHosts = new Set(['0.0.0.0', '::', '0000:0000:0000:0000:0000:0000:0000:0000']);
45+
46+
// this code from vite https://github.com/vitejs/vite/blob/d09bbd093a4b893e78f0bbff5b17c7cf7821f403/packages/vite/src/node/utils.ts#L892-L914
47+
export function getNetworkAddress(
48+
protocol: 'http' | 'https' = 'http',
49+
hostname: string | undefined,
50+
port: number,
51+
base?: string
52+
) {
53+
const NetworkAddress: NetworkAddressOpt = {
54+
local: [],
55+
network: [],
56+
};
57+
Object.values(os.networkInterfaces())
58+
.flatMap((nInterface) => nInterface ?? [])
59+
.filter(
60+
(detail) =>
61+
detail &&
62+
detail.address &&
63+
(detail.family === 'IPv4' ||
64+
// @ts-expect-error Node 18.0 - 18.3 returns number
65+
detail.family === 4)
66+
)
67+
.forEach((detail) => {
68+
let host = detail.address.replace(
69+
'127.0.0.1',
70+
hostname === undefined || wildcardHosts.has(hostname) ? 'localhost' : hostname
71+
);
72+
// ipv6 host
73+
if (host.includes(':')) {
74+
host = `[${host}]`;
75+
}
76+
const url = `${protocol}://${host}:${port}${base ? base : ''}`;
77+
if (detail.address.includes('127.0.0.1')) {
78+
NetworkAddress.local.push(url);
79+
} else {
80+
NetworkAddress.network.push(url);
81+
}
82+
});
83+
return NetworkAddress;
84+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createAppHandler } from './serve-app.js';
2+
import type { RequestHandler } from "./types.js";
3+
import type { NodeApp } from "astro/app/node";
4+
5+
/**
6+
* Creates a middleware that can be used with Express, Connect, etc.
7+
*
8+
* Similar to `createAppHandler` but can additionally be placed in the express
9+
* chain as an error middleware.
10+
*
11+
* https://expressjs.com/en/guide/using-middleware.html#middleware.error-handling
12+
*/
13+
export default function createMiddleware(
14+
app: NodeApp,
15+
): RequestHandler {
16+
const handler = createAppHandler(app)
17+
const logger = app.getAdapterLogger()
18+
// using spread args because express trips up if the function's
19+
// stringified body includes req, res, next, locals directly
20+
return async function (...args) {
21+
// assume normal invocation at first
22+
const [req, res, next, locals] = args;
23+
// short circuit if it is an error invocation
24+
if (req instanceof Error) {
25+
const error = req;
26+
if (next) {
27+
return next(error);
28+
} else {
29+
throw error;
30+
}
31+
}
32+
try {
33+
await handler(req, res, next, locals);
34+
} catch (err) {
35+
logger.error(`Could not render ${req.url}`);
36+
console.error(err);
37+
if (!res.headersSent) {
38+
res.writeHead(500, `Server error`);
39+
res.end();
40+
}
41+
}
42+
}
43+
}

‎packages/integrations/node/src/nodeMiddleware.ts

-110
This file was deleted.
+20-53
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,19 @@
1-
import type { CreatePreviewServer } from 'astro';
2-
import { AstroError } from 'astro/errors';
3-
import type http from 'node:http';
41
import { fileURLToPath } from 'node:url';
5-
import { getNetworkAddress } from './get-network-address.js';
6-
import { createServer } from './http-server.js';
2+
import { AstroError } from 'astro/errors';
3+
import { logListeningOn } from './log-listening-on.js';
4+
import { createServer } from './standalone.js';
5+
import type { CreatePreviewServer } from 'astro';
76
import type { createExports } from './server.js';
87

9-
const preview: CreatePreviewServer = async function ({
10-
client,
11-
serverEntrypoint,
12-
host,
13-
port,
14-
base,
15-
logger,
16-
}) {
17-
type ServerModule = ReturnType<typeof createExports>;
18-
type MaybeServerModule = Partial<ServerModule>;
8+
type ServerModule = ReturnType<typeof createExports>;
9+
type MaybeServerModule = Partial<ServerModule>;
10+
11+
const createPreviewServer: CreatePreviewServer = async function (preview) {
1912
let ssrHandler: ServerModule['handler'];
2013
let options: ServerModule['options'];
2114
try {
2215
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
23-
const ssrModule: MaybeServerModule = await import(serverEntrypoint.toString());
16+
const ssrModule: MaybeServerModule = await import(preview.serverEntrypoint.toString());
2417
if (typeof ssrModule.handler === 'function') {
2518
ssrHandler = ssrModule.handler;
2619
options = ssrModule.options!;
@@ -33,49 +26,23 @@ const preview: CreatePreviewServer = async function ({
3326
if ((err as any).code === 'ERR_MODULE_NOT_FOUND') {
3427
throw new AstroError(
3528
`The server entrypoint ${fileURLToPath(
36-
serverEntrypoint
29+
preview.serverEntrypoint
3730
)} does not exist. Have you ran a build yet?`
3831
);
3932
} else {
4033
throw err;
4134
}
4235
}
43-
44-
const handler: http.RequestListener = (req, res) => {
45-
ssrHandler(req, res);
46-
};
47-
48-
const baseWithoutTrailingSlash: string = base.endsWith('/')
49-
? base.slice(0, base.length - 1)
50-
: base;
51-
function removeBase(pathname: string): string {
52-
if (pathname.startsWith(base)) {
53-
return pathname.slice(baseWithoutTrailingSlash.length);
54-
}
55-
return pathname;
56-
}
57-
58-
const server = createServer(
59-
{
60-
client,
61-
port,
62-
host,
63-
removeBase,
64-
assets: options.assets,
65-
},
66-
handler
67-
);
68-
const address = getNetworkAddress('http', host, port);
69-
70-
if (host === undefined) {
71-
logger.info(
72-
`Preview server listening on \n local: ${address.local[0]} \t\n network: ${address.network[0]}\n`
73-
);
74-
} else {
75-
logger.info(`Preview server listening on ${address.local[0]}`);
76-
}
77-
36+
const host = preview.host ?? "localhost"
37+
const port = preview.port ?? 4321
38+
const server = createServer(ssrHandler, host, port);
39+
logListeningOn(preview.logger, server.server, options)
40+
await new Promise<void>((resolve, reject) => {
41+
server.server.once('listening', resolve);
42+
server.server.once('error', reject);
43+
server.server.listen(port, host);
44+
});
7845
return server;
7946
};
8047

81-
export { preview as default };
48+
export { createPreviewServer as default }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { NodeApp } from "astro/app/node"
2+
import type { RequestHandler } from "./types.js";
3+
4+
/**
5+
* Creates a Node.js http listener for on-demand rendered pages, compatible with http.createServer and Connect middleware.
6+
* If the next callback is provided, it will be called if the request does not have a matching route.
7+
* Intended to be used in both standalone and middleware mode.
8+
*/
9+
export function createAppHandler(app: NodeApp): RequestHandler {
10+
return async (req, res, next, locals) => {
11+
const request = NodeApp.createRequest(req);
12+
const routeData = app.match(request);
13+
if (routeData) {
14+
const response = await app.render(request, {
15+
addCookieHeader: true,
16+
locals,
17+
routeData,
18+
});
19+
await NodeApp.writeResponse(response, res);
20+
} else if (next) {
21+
return next();
22+
} else {
23+
const response = await app.render(req);
24+
await NodeApp.writeResponse(response, res);
25+
}
26+
}
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import path from "node:path";
2+
import url from "node:url";
3+
import send from "send";
4+
import type { IncomingMessage, ServerResponse } from "node:http";
5+
import type { Options } from "./types.js";
6+
import type { NodeApp } from "astro/app/node";
7+
8+
/**
9+
* Creates a Node.js http listener for static files and prerendered pages.
10+
* In standalone mode, the static handler is queried first for the static files.
11+
* If one matching the request path is not found, it relegates to the SSR handler.
12+
* Intended to be used only in the standalone mode.
13+
*/
14+
export function createStaticHandler(app: NodeApp, options: Options) {
15+
const client = resolveClientDir(options);
16+
/**
17+
* @param ssr The SSR handler to be called if the static handler does not find a matching file.
18+
*/
19+
return (req: IncomingMessage, res: ServerResponse, ssr: () => unknown) => {
20+
if (req.url) {
21+
let pathname = app.removeBase(req.url);
22+
pathname = decodeURI(new URL(pathname, 'http://host').pathname);
23+
24+
const stream = send(req, pathname, {
25+
root: client,
26+
dotfiles: pathname.startsWith('/.well-known/') ? 'allow' : 'deny',
27+
});
28+
29+
let forwardError = false;
30+
31+
stream.on('error', (err) => {
32+
if (forwardError) {
33+
console.error(err.toString());
34+
res.writeHead(500);
35+
res.end('Internal server error');
36+
return;
37+
}
38+
// File not found, forward to the SSR handler
39+
ssr();
40+
});
41+
stream.on('headers', (_res: ServerResponse) => {
42+
// assets in dist/_astro are hashed and should get the immutable header
43+
if (pathname.startsWith(`/${options.assets}/`)) {
44+
// This is the "far future" cache header, used for static files whose name includes their digest hash.
45+
// 1 year (31,536,000 seconds) is convention.
46+
// Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable
47+
_res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
48+
}
49+
});
50+
stream.on('directory', () => {
51+
// On directory find, redirect to the trailing slash
52+
let location: string;
53+
if (req.url!.includes('?')) {
54+
const [url1 = '', search] = req.url!.split('?');
55+
location = `${url1}/?${search}`;
56+
} else {
57+
location = appendForwardSlash(req.url!);
58+
}
59+
60+
res.statusCode = 301;
61+
res.setHeader('Location', location);
62+
res.end(location);
63+
});
64+
stream.on('file', () => {
65+
forwardError = true;
66+
});
67+
stream.pipe(res);
68+
} else {
69+
ssr();
70+
}
71+
};
72+
}
73+
74+
function resolveClientDir(options: Options) {
75+
const clientURLRaw = new URL(options.client);
76+
const serverURLRaw = new URL(options.server);
77+
const rel = path.relative(url.fileURLToPath(serverURLRaw), url.fileURLToPath(clientURLRaw));
78+
const serverEntryURL = new URL(import.meta.url);
79+
const clientURL = new URL(appendForwardSlash(rel), serverEntryURL);
80+
const client = url.fileURLToPath(clientURL);
81+
return client;
82+
}
83+
84+
function appendForwardSlash(pth: string) {
85+
return pth.endsWith('/') ? pth : pth + '/';
86+
}

‎packages/integrations/node/src/server.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1-
import type { SSRManifest } from 'astro';
21
import { NodeApp, applyPolyfills } from 'astro/app/node';
3-
import middleware from './nodeMiddleware.js';
2+
import { createStandaloneHandler } from './standalone.js';
43
import startServer from './standalone.js';
4+
import createMiddleware from './middleware.js';
5+
import type { SSRManifest } from 'astro';
56
import type { Options } from './types.js';
67

78
applyPolyfills();
89
export function createExports(manifest: SSRManifest, options: Options) {
910
const app = new NodeApp(manifest);
1011
return {
1112
options: options,
12-
handler: middleware(app, options.mode),
13+
handler:
14+
options.mode === "middleware"
15+
? createMiddleware(app)
16+
: createStandaloneHandler(app, options),
1317
startServer: () => startServer(app, options),
1418
};
1519
}
+72-57
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,90 @@
1-
import type { NodeApp } from 'astro/app/node';
1+
import http from 'node:http';
22
import https from 'https';
3-
import path from 'node:path';
4-
import { fileURLToPath } from 'node:url';
5-
import { getNetworkAddress } from './get-network-address.js';
6-
import { createServer } from './http-server.js';
7-
import middleware from './nodeMiddleware.js';
3+
import fs from 'node:fs';
4+
import enableDestroy from 'server-destroy';
5+
import { createAppHandler } from './serve-app.js';
6+
import { createStaticHandler } from './serve-static.js';
7+
import { logListeningOn } from './log-listening-on.js';
8+
import type { NodeApp } from 'astro/app/node';
89
import type { Options } from './types.js';
10+
import type { PreviewServer } from 'astro';
911

10-
function resolvePaths(options: Options) {
11-
const clientURLRaw = new URL(options.client);
12-
const serverURLRaw = new URL(options.server);
13-
const rel = path.relative(fileURLToPath(serverURLRaw), fileURLToPath(clientURLRaw));
14-
15-
const serverEntryURL = new URL(import.meta.url);
16-
const clientURL = new URL(appendForwardSlash(rel), serverEntryURL);
17-
12+
export default function standalone(app: NodeApp, options: Options) {
13+
const port = process.env.PORT ? Number(process.env.PORT) : options.port ?? 8080;
14+
// Allow to provide host value at runtime
15+
const hostOptions = typeof options.host === "boolean" ? "localhost" : options.host
16+
const host = process.env.HOST ?? hostOptions;
17+
const handler = createStandaloneHandler(app, options);
18+
const server = createServer(handler, host, port);
19+
server.server.listen(port, host)
20+
if (process.env.ASTRO_NODE_LOGGING !== "disabled") {
21+
logListeningOn(app.getAdapterLogger(), server.server, options)
22+
}
1823
return {
19-
client: clientURL,
24+
server,
25+
done: server.closed(),
2026
};
2127
}
2228

23-
function appendForwardSlash(pth: string) {
24-
return pth.endsWith('/') ? pth : pth + '/';
25-
}
26-
27-
export function getResolvedHostForHttpServer(host: string | boolean) {
28-
if (host === false) {
29-
// Use a secure default
30-
return '127.0.0.1';
31-
} else if (host === true) {
32-
// If passed --host in the CLI without arguments
33-
return undefined; // undefined typically means 0.0.0.0 or :: (listen on all IPs)
34-
} else {
35-
return host;
29+
// also used by server entrypoint
30+
export function createStandaloneHandler(app: NodeApp, options: Options) {
31+
const appHandler = createAppHandler(app);
32+
const staticHandler = createStaticHandler(app, options);
33+
return (req: http.IncomingMessage, res: http.ServerResponse) => {
34+
try {
35+
// validate request path
36+
decodeURI(req.url!);
37+
} catch {
38+
res.writeHead(400);
39+
res.end('Bad request.');
40+
return;
41+
}
42+
staticHandler(req, res, () => appHandler(req, res));
3643
}
3744
}
3845

39-
export default function startServer(app: NodeApp, options: Options) {
40-
const logger = app.getAdapterLogger();
41-
const port = process.env.PORT ? Number(process.env.PORT) : options.port ?? 8080;
42-
const { client } = resolvePaths(options);
43-
const handler = middleware(app, options.mode);
44-
45-
// Allow to provide host value at runtime
46-
const host = getResolvedHostForHttpServer(
47-
process.env.HOST !== undefined && process.env.HOST !== '' ? process.env.HOST : options.host
48-
);
49-
const server = createServer(
50-
{
51-
client,
52-
port,
53-
host,
54-
removeBase: app.removeBase.bind(app),
55-
assets: options.assets,
56-
},
57-
handler
58-
);
59-
60-
const protocol = server.server instanceof https.Server ? 'https' : 'http';
61-
const address = getNetworkAddress(protocol, host, port);
46+
// also used by preview entrypoint
47+
export function createServer(
48+
listener: http.RequestListener,
49+
host: string,
50+
port: number
51+
) {
52+
let httpServer: http.Server | https.Server;
6253

63-
if (host === undefined) {
64-
logger.info(
65-
`Server listening on \n local: ${address.local[0]} \t\n network: ${address.network[0]}\n`
54+
if (process.env.SERVER_CERT_PATH && process.env.SERVER_KEY_PATH) {
55+
httpServer = https.createServer(
56+
{
57+
key: fs.readFileSync(process.env.SERVER_KEY_PATH),
58+
cert: fs.readFileSync(process.env.SERVER_CERT_PATH),
59+
},
60+
listener
6661
);
6762
} else {
68-
logger.info(`Server listening on ${address.local[0]}`);
63+
httpServer = http.createServer(listener);
6964
}
65+
enableDestroy(httpServer);
66+
67+
// Resolves once the server is closed
68+
const closed = new Promise<void>((resolve, reject) => {
69+
httpServer.addListener('close', resolve);
70+
httpServer.addListener('error', reject);
71+
});
72+
73+
const previewable = {
74+
host,
75+
port,
76+
closed() {
77+
return closed;
78+
},
79+
async stop() {
80+
await new Promise((resolve, reject) => {
81+
httpServer.destroy((err) => (err ? reject(err) : resolve(undefined)));
82+
});
83+
}
84+
} satisfies PreviewServer;
7085

7186
return {
72-
server,
73-
done: server.closed(),
87+
server: httpServer,
88+
...previewable,
7489
};
7590
}

‎packages/integrations/node/src/types.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { NodeApp } from 'astro/app/node';
12
import type { IncomingMessage, ServerResponse } from 'node:http';
23

34
export interface UserOptions {
@@ -18,11 +19,19 @@ export interface Options extends UserOptions {
1819
assets: string;
1920
}
2021

22+
export interface CreateServerOptions {
23+
app: NodeApp;
24+
assets: string;
25+
client: URL;
26+
port: number;
27+
host: string | undefined;
28+
removeBase: (pathname: string) => string;
29+
}
30+
31+
export type RequestHandler = (...args: RequestHandlerParams) => void | Promise<void>;
2132
export type RequestHandlerParams = [
2233
req: IncomingMessage,
2334
res: ServerResponse,
2435
next?: (err?: unknown) => void,
2536
locals?: object,
2637
];
27-
28-
export type ErrorHandlerParams = [unknown, ...RequestHandlerParams];

‎packages/integrations/node/test/bad-urls.test.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ describe('Bad URLs', () => {
3434

3535
for (const weirdUrl of weirdURLs) {
3636
const fetchResult = await fixture.fetch(weirdUrl);
37-
expect([400, 500]).to.include(
37+
expect([400, 404, 500]).to.include(
3838
fetchResult.status,
39-
`${weirdUrl} returned something else than 400 or 500`
39+
`${weirdUrl} returned something else than 400, 404, or 500`
4040
);
4141
}
4242
const stillWork = await fixture.fetch('/');

‎packages/integrations/node/test/node-middleware.test.js

-2
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ describe('behavior from middleware, standalone', () => {
2121
let server;
2222

2323
before(async () => {
24-
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
2524
process.env.PRERENDER = false;
2625
fixture = await loadFixture({
2726
root: './fixtures/node-middleware/',
@@ -61,7 +60,6 @@ describe('behavior from middleware, middleware', () => {
6160
let server;
6261

6362
before(async () => {
64-
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
6563
process.env.PRERENDER = false;
6664
fixture = await loadFixture({
6765
root: './fixtures/node-middleware/',

‎packages/integrations/node/test/prerender-404-500.test.js

-4
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ describe('Prerender 404', () => {
2121

2222
describe('With base', async () => {
2323
before(async () => {
24-
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
2524
process.env.PRERENDER = true;
2625

2726
fixture = await loadFixture({
@@ -107,7 +106,6 @@ describe('Prerender 404', () => {
107106

108107
describe('Without base', async () => {
109108
before(async () => {
110-
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
111109
process.env.PRERENDER = true;
112110

113111
fixture = await loadFixture({
@@ -171,7 +169,6 @@ describe('Hybrid 404', () => {
171169

172170
describe('With base', async () => {
173171
before(async () => {
174-
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
175172
process.env.PRERENDER = false;
176173
fixture = await loadFixture({
177174
// inconsequential config that differs between tests
@@ -229,7 +226,6 @@ describe('Hybrid 404', () => {
229226

230227
describe('Without base', async () => {
231228
before(async () => {
232-
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
233229
process.env.PRERENDER = false;
234230
fixture = await loadFixture({
235231
// inconsequential config that differs between tests

‎packages/integrations/node/test/prerender.test.js

-4
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ describe('Prerendering', () => {
1818

1919
describe('With base', async () => {
2020
before(async () => {
21-
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
2221
process.env.PRERENDER = true;
2322

2423
fixture = await loadFixture({
@@ -86,7 +85,6 @@ describe('Prerendering', () => {
8685

8786
describe('Without base', async () => {
8887
before(async () => {
89-
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
9088
process.env.PRERENDER = true;
9189

9290
fixture = await loadFixture({
@@ -151,7 +149,6 @@ describe('Hybrid rendering', () => {
151149

152150
describe('With base', async () => {
153151
before(async () => {
154-
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
155152
process.env.PRERENDER = false;
156153
fixture = await loadFixture({
157154
base: '/some-base',
@@ -217,7 +214,6 @@ describe('Hybrid rendering', () => {
217214

218215
describe('Without base', async () => {
219216
before(async () => {
220-
process.env.ASTRO_NODE_AUTOSTART = 'disabled';
221217
process.env.PRERENDER = false;
222218
fixture = await loadFixture({
223219
root: './fixtures/prerender/',

‎packages/integrations/node/test/test-utils.js

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import httpMocks from 'node-mocks-http';
22
import { EventEmitter } from 'node:events';
33
import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js';
44

5+
process.env.ASTRO_NODE_AUTOSTART = "disabled";
6+
process.env.ASTRO_NODE_LOGGING = "disabled";
57
/**
68
* @typedef {import('../../../astro/test/test-utils').Fixture} Fixture
79
*/

‎packages/integrations/vercel/package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"web-vitals": "^3.4.0"
6060
},
6161
"peerDependencies": {
62-
"astro": "^4.0.2"
62+
"astro": "^4.2.0"
6363
},
6464
"devDependencies": {
6565
"@types/set-cookie-parser": "^2.4.6",

‎packages/integrations/vercel/src/serverless/adapter.ts

+50-45
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export default function vercelServerless({
112112
webAnalytics,
113113
speedInsights,
114114
includeFiles,
115-
excludeFiles,
115+
excludeFiles = [],
116116
imageService,
117117
imagesConfig,
118118
devImageService = 'sharp',
@@ -189,9 +189,10 @@ export default function vercelServerless({
189189
'astro:config:done': ({ setAdapter, config, logger }) => {
190190
if (functionPerRoute === true) {
191191
logger.warn(
192-
`Vercel's hosting plans might have limits to the number of functions you can create.
193-
Make sure to check your plan carefully to avoid incurring additional costs.
194-
You can set functionPerRoute: false to prevent surpassing the limit.`
192+
`\n` +
193+
`\tVercel's hosting plans might have limits to the number of functions you can create.\n` +
194+
`\tMake sure to check your plan carefully to avoid incurring additional costs.\n` +
195+
`\tYou can set functionPerRoute: false to prevent surpassing the limit.\n`
195196
);
196197
}
197198
setAdapter(getAdapter({ functionPerRoute, edgeMiddleware }));
@@ -205,7 +206,6 @@ You can set functionPerRoute: false to prevent surpassing the limit.`
205206
);
206207
}
207208
},
208-
209209
'astro:build:ssr': async ({ entryPoints, middlewareEntryPoint }) => {
210210
_entryPoints = entryPoints;
211211
if (middlewareEntryPoint) {
@@ -223,7 +223,6 @@ You can set functionPerRoute: false to prevent surpassing the limit.`
223223
extraFilesToInclude.push(bundledMiddlewarePath);
224224
}
225225
},
226-
227226
'astro:build:done': async ({ routes, logger }) => {
228227
// Merge any includes from `vite.assetsInclude
229228
if (_config.vite.assetsInclude) {
@@ -245,7 +244,7 @@ You can set functionPerRoute: false to prevent surpassing the limit.`
245244
const filesToInclude = includeFiles?.map((file) => new URL(file, _config.root)) || [];
246245
filesToInclude.push(...extraFilesToInclude);
247246

248-
validateRuntime();
247+
const runtime = getRuntime(process, logger);
249248

250249
// Multiple entrypoint support
251250
if (_entryPoints.size) {
@@ -263,6 +262,7 @@ You can set functionPerRoute: false to prevent surpassing the limit.`
263262

264263
await createFunctionFolder({
265264
functionName: func,
265+
runtime,
266266
entry: entryFile,
267267
config: _config,
268268
logger,
@@ -279,6 +279,7 @@ You can set functionPerRoute: false to prevent surpassing the limit.`
279279
} else {
280280
await createFunctionFolder({
281281
functionName: 'render',
282+
runtime,
282283
entry: new URL(serverEntry, buildTempFolder),
283284
config: _config,
284285
logger,
@@ -342,19 +343,23 @@ You can set functionPerRoute: false to prevent surpassing the limit.`
342343
};
343344
}
344345

346+
type Runtime = `nodejs${string}.x`;
347+
345348
interface CreateFunctionFolderArgs {
346349
functionName: string;
350+
runtime: Runtime;
347351
entry: URL;
348352
config: AstroConfig;
349353
logger: AstroIntegrationLogger;
350354
NTF_CACHE: any;
351355
includeFiles: URL[];
352-
excludeFiles?: string[];
356+
excludeFiles: string[];
353357
maxDuration: number | undefined;
354358
}
355359

356360
async function createFunctionFolder({
357361
functionName,
362+
runtime,
358363
entry,
359364
config,
360365
logger,
@@ -363,75 +368,75 @@ async function createFunctionFolder({
363368
excludeFiles,
364369
maxDuration,
365370
}: CreateFunctionFolderArgs) {
371+
// .vercel/output/functions/<name>.func/
366372
const functionFolder = new URL(`./functions/${functionName}.func/`, config.outDir);
373+
const packageJson = new URL(`./functions/${functionName}.func/package.json`, config.outDir);
374+
const vcConfig = new URL(`./functions/${functionName}.func/.vc-config.json`, config.outDir);
367375

368376
// Copy necessary files (e.g. node_modules/)
369377
const { handler } = await copyDependenciesToFunction(
370378
{
371379
entry,
372380
outDir: functionFolder,
373381
includeFiles,
374-
excludeFiles: excludeFiles?.map((file) => new URL(file, config.root)) || [],
382+
excludeFiles: excludeFiles.map((file) => new URL(file, config.root)),
375383
logger,
376384
},
377385
NTF_CACHE
378386
);
379387

380388
// Enable ESM
381389
// https://aws.amazon.com/blogs/compute/using-node-js-es-modules-and-top-level-await-in-aws-lambda/
382-
await writeJson(new URL(`./package.json`, functionFolder), {
383-
type: 'module',
384-
});
390+
await writeJson(packageJson, { type: 'module' });
385391

386392
// Serverless function config
387393
// https://vercel.com/docs/build-output-api/v3#vercel-primitives/serverless-functions/configuration
388-
await writeJson(new URL(`./.vc-config.json`, functionFolder), {
389-
runtime: getRuntime(),
394+
await writeJson(vcConfig, {
395+
runtime,
390396
handler,
391397
launcherType: 'Nodejs',
392398
maxDuration,
393399
supportsResponseStreaming: true,
394400
});
395401
}
396402

397-
function validateRuntime() {
398-
const version = process.version.slice(1); // 'v16.5.0' --> '16.5.0'
399-
const major = version.split('.')[0]; // '16.5.0' --> '16'
403+
function getRuntime(process: NodeJS.Process, logger: AstroIntegrationLogger): Runtime {
404+
const version = process.version.slice(1); // 'v18.19.0' --> '18.19.0'
405+
const major = version.split('.')[0]; // '18.19.0' --> '18'
400406
const support = SUPPORTED_NODE_VERSIONS[major];
401407
if (support === undefined) {
402-
console.warn(
403-
`[${PACKAGE_NAME}] The local Node.js version (${major}) is not supported by Vercel Serverless Functions.`
408+
logger.warn(
409+
`\n` +
410+
`\tThe local Node.js version (${major}) is not supported by Vercel Serverless Functions.\n` +
411+
`\tYour project will use Node.js 18 as the runtime instead.\n` +
412+
`\tConsider switching your local version to 18.\n`
404413
);
405-
console.warn(`[${PACKAGE_NAME}] Your project will use Node.js 18 as the runtime instead.`);
406-
console.warn(`[${PACKAGE_NAME}] Consider switching your local version to 18.`);
407-
return;
408414
}
409-
if (support.status === 'beta') {
410-
console.warn(
411-
`[${PACKAGE_NAME}] The local Node.js version (${major}) is currently in beta for Vercel Serverless Functions.`
415+
if (support.status === 'current') {
416+
return `nodejs${major}.x`;
417+
} else if (support?.status === 'beta') {
418+
logger.warn(
419+
`Your project is being built for Node.js ${major} as the runtime, which is currently in beta for Vercel Serverless Functions.`
412420
);
413-
console.warn(`[${PACKAGE_NAME}] Make sure to update your Vercel settings to use ${major}.`);
414-
return;
415-
}
416-
if (support.status === 'deprecated') {
417-
console.warn(
418-
`[${PACKAGE_NAME}] Your project is being built for Node.js ${major} as the runtime.`
421+
return `nodejs${major}.x`;
422+
} else if (support.status === 'deprecated') {
423+
const removeDate = new Intl.DateTimeFormat(undefined, { dateStyle: 'long' }).format(
424+
support.removal
419425
);
420-
console.warn(
421-
`[${PACKAGE_NAME}] This version is deprecated by Vercel Serverless Functions, and scheduled to be disabled on ${new Intl.DateTimeFormat(
422-
undefined,
423-
{ dateStyle: 'long' }
424-
).format(support.removal)}.`
426+
logger.warn(
427+
`\n` +
428+
`\tYour project is being built for Node.js ${major} as the runtime.\n` +
429+
`\tThis version is deprecated by Vercel Serverless Functions, and scheduled to be disabled on ${removeDate}.\n` +
430+
`\tConsider upgrading your local version to 18.\n`
431+
);
432+
return `nodejs${major}.x`;
433+
} else {
434+
logger.warn(
435+
`\n` +
436+
`\tThe local Node.js version (${major}) is not supported by Vercel Serverless Functions.\n` +
437+
`\tYour project will use Node.js 18 as the runtime instead.\n` +
438+
`\tConsider switching your local version to 18.\n`
425439
);
426-
console.warn(`[${PACKAGE_NAME}] Consider upgrading your local version to 18.`);
427-
}
428-
}
429-
430-
function getRuntime() {
431-
const version = process.version.slice(1); // 'v16.5.0' --> '16.5.0'
432-
const major = version.split('.')[0]; // '16.5.0' --> '16'
433-
const support = SUPPORTED_NODE_VERSIONS[major];
434-
if (support === undefined) {
435440
return 'nodejs18.x';
436441
}
437442
return `nodejs${major}.x`;

‎packages/integrations/vercel/src/serverless/entrypoint.ts

+10-24
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,21 @@
11
import type { SSRManifest } from 'astro';
2-
import { App } from 'astro/app';
3-
import { applyPolyfills } from 'astro/app/node';
2+
import { applyPolyfills, NodeApp } from 'astro/app/node';
43
import type { IncomingMessage, ServerResponse } from 'node:http';
5-
64
import { ASTRO_LOCALS_HEADER } from './adapter.js';
7-
import { getRequest, setResponse } from './request-transform.js';
85

96
applyPolyfills();
107

118
export const createExports = (manifest: SSRManifest) => {
12-
const app = new App(manifest);
13-
9+
const app = new NodeApp(manifest);
1410
const handler = async (req: IncomingMessage, res: ServerResponse) => {
15-
let request: Request;
16-
17-
try {
18-
request = await getRequest(`https://${req.headers.host}`, req);
19-
} catch (err: any) {
20-
res.statusCode = err.status || 400;
21-
return res.end(err.reason || 'Invalid request body');
22-
}
23-
24-
let routeData = app.match(request);
25-
let locals = {};
26-
if (request.headers.has(ASTRO_LOCALS_HEADER)) {
27-
let localsAsString = request.headers.get(ASTRO_LOCALS_HEADER);
28-
if (localsAsString) {
29-
locals = JSON.parse(localsAsString);
30-
}
31-
}
32-
await setResponse(app, res, await app.render(request, { routeData, locals }));
11+
const clientAddress = req.headers['x-forwarded-for'] as string | undefined;
12+
const localsHeader = req.headers[ASTRO_LOCALS_HEADER]
13+
const locals =
14+
typeof localsHeader === "string" ? JSON.parse(localsHeader)
15+
: Array.isArray(localsHeader) ? JSON.parse(localsHeader[0])
16+
: {};
17+
const webResponse = await app.render(req, { locals, clientAddress })
18+
await NodeApp.writeResponse(webResponse, res);
3319
};
3420

3521
return { default: handler };

‎packages/integrations/vercel/src/serverless/request-transform.ts

-203
This file was deleted.

0 commit comments

Comments
 (0)
Please sign in to comment.