Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adapter enhancements #9661

Merged
merged 8 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/cool-foxes-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"astro": minor
---

Adds new helper functions for adapter developers.

- Symbols used to represent `Astro.locals` and `Astro.clientAddress` are now available as static properties on the `App` class: `App.Symbol.locals` and `App.Symbol.clientAddress`.

- `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)
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 changes: 7 additions & 0 deletions .changeset/tame-squids-film.md
Original file line number Diff line number Diff line change
@@ -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 rely 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`.
ematipico marked this conversation as resolved.
Show resolved Hide resolved
7 changes: 2 additions & 5 deletions examples/ssr/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
34 changes: 34 additions & 0 deletions packages/astro/src/core/app/createOutgoingHttpHeaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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 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
): OutgoingHttpHeaders | undefined => {
if (!headers) {
return undefined;
}

// at this point, a multi-value'd set-cookie header is invalid (it was concatenated as a single CSV, which is not valid for set-cookie)
const nodeHeaders: OutgoingHttpHeaders = Object.fromEntries(headers.entries());

if (Object.keys(nodeHeaders).length === 0) {
return undefined;
}

// if there is > 1 set-cookie header, we have to fix it to be an array of values
if (headers.has('set-cookie')) {
const cookieHeaders = headers.getSetCookie();
if (cookieHeaders.length > 1) {
// the Headers.entries() API already normalized all header names to lower case so we can safely index this as 'set-cookie'
nodeHeaders['set-cookie'] = cookieHeaders;
}
}

return nodeHeaders;
};
95 changes: 84 additions & 11 deletions packages/astro/src/core/app/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,44 @@ import { EndpointNotFoundError, SSRRoutePipeline } from './ssrPipeline.js';
import type { RouteInfo } from './types.js';
export { deserializeManifest } from './common.js';

const clientLocalsSymbol = Symbol.for('astro.locals');

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 {
Expand All @@ -51,6 +80,15 @@ export interface RenderErrorOptions {
}

export class App {

/**
* Symbols that the Astro app reads on the passed Request instance. Use these when you can't directly provide these values to `app.render()`.
*/
static readonly Symbol = Object.freeze({
locals: Symbol.for('astro.locals'),
clientAddress: Symbol.for('astro.clientAddress'),
})

/**
* The current environment of the application
*/
Expand Down Expand Up @@ -160,11 +198,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;
}
Expand All @@ -178,7 +229,12 @@ export class App {
this.#logRenderOptionsDeprecationWarning();
}
}

if (locals) {
Reflect.set(request, App.Symbol.locals, locals);
}
if (clientAddress) {
Reflect.set(request, App.Symbol.clientAddress, 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);
Expand All @@ -189,7 +245,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);
Expand All @@ -206,7 +261,7 @@ export class App {
);
let response;
try {
let i18nMiddleware = createI18nMiddleware(
const i18nMiddleware = createI18nMiddleware(
this.#manifest.i18n,
this.#manifest.base,
this.#manifest.trailingSlash
Expand All @@ -233,16 +288,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;
}

Expand All @@ -259,6 +319,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
*/
Expand Down
Loading