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:
parent
3a4d5ec800
commit
d6edc75408
34 changed files with 716 additions and 799 deletions
32
.changeset/cool-foxes-talk.md
Normal file
32
.changeset/cool-foxes-talk.md
Normal 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 })
|
||||
```
|
7
.changeset/early-cups-poke.md
Normal file
7
.changeset/early-cups-poke.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
"@astrojs/vercel": major
|
||||
---
|
||||
|
||||
**Breaking**: Minimum required Astro version is now 4.2.0.
|
||||
Reorganizes internals to be more maintainable.
|
||||
---
|
7
.changeset/tame-squids-film.md
Normal file
7
.changeset/tame-squids-film.md
Normal 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`.
|
5
.changeset/unlucky-stingrays-clean.md
Normal file
5
.changeset/unlucky-stingrays-clean.md
Normal 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.
|
6
.changeset/weak-apes-add.md
Normal file
6
.changeset/weak-apes-add.md
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
"@astrojs/node": major
|
||||
---
|
||||
|
||||
**Breaking**: Minimum required Astro version is now 4.2.0.
|
||||
Reorganizes internals to be more maintainable.
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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 () => {
|
|
@ -37,7 +37,7 @@
|
|||
"server-destroy": "^1.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"astro": "^4.0.0"
|
||||
"astro": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.17.8",
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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)));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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',
|
||||
|
|
84
packages/integrations/node/src/log-listening-on.ts
Normal file
84
packages/integrations/node/src/log-listening-on.ts
Normal 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;
|
||||
}
|
43
packages/integrations/node/src/middleware.ts
Normal file
43
packages/integrations/node/src/middleware.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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 }
|
||||
|
|
27
packages/integrations/node/src/serve-app.ts
Normal file
27
packages/integrations/node/src/serve-app.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
86
packages/integrations/node/src/serve-static.ts
Normal file
86
packages/integrations/node/src/serve-static.ts
Normal 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 + '/';
|
||||
}
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
|
|
|
@ -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('/');
|
||||
|
|
|
@ -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/',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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/',
|
||||
|
|
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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`;
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue