diff --git a/.changeset/big-cups-drive.md b/.changeset/big-cups-drive.md new file mode 100644 index 000000000000..48d783374449 --- /dev/null +++ b/.changeset/big-cups-drive.md @@ -0,0 +1,5 @@ +--- +'astro': major +--- + +Deprecates `loadManifest()` and `loadApp()` from `astro/app/node` (Adapter API) - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/#deprecated-loadmanifest-and-loadapp-from-astroappnode-adapter-api)) diff --git a/.changeset/good-clubs-cover.md b/.changeset/good-clubs-cover.md new file mode 100644 index 000000000000..58a81043b9d1 --- /dev/null +++ b/.changeset/good-clubs-cover.md @@ -0,0 +1,21 @@ +--- +'astro': minor +--- + +Exports new `createRequest()` and `writeResponse()` utilities from `astro/app/node` + +To replace the deprecated `NodeApp.createRequest()` and `NodeApp.writeResponse()` methods, the `astro/app/node` module now exposes new `createRequest()` and `writeResponse()` utilities. These can be used to convert a NodeJS `IncomingMessage` into a web-standard `Request` and stream a web-standard `Response` into a NodeJS `ServerResponse`: + +```js +import { createApp } from 'astro/app/entrypoint'; +import { createRequest, writeResponse } from 'astro/app/node'; +import { createServer } from 'node:http'; + +const app = createApp(); + +const server = createServer(async (req, res) => { + const request = createRequest(req); + const response = await app.render(request); + await writeResponse(response, res); +}) +``` \ No newline at end of file diff --git a/.changeset/olive-dots-jam.md b/.changeset/olive-dots-jam.md new file mode 100644 index 000000000000..996eceb5c065 --- /dev/null +++ b/.changeset/olive-dots-jam.md @@ -0,0 +1,5 @@ +--- +'astro': patch +--- + +Fixes the types of `createApp()` exported from `astro/app/entrypoint` diff --git a/.changeset/polite-terms-shop.md b/.changeset/polite-terms-shop.md new file mode 100644 index 000000000000..1f3f58b3067d --- /dev/null +++ b/.changeset/polite-terms-shop.md @@ -0,0 +1,5 @@ +--- +'astro': major +--- + +Deprecates `NodeApp` from `astro/app/node` (Adapter API) - ([v6 upgrade guidance](https://v6.docs.astro.build/en/guides/upgrade-to/v6/#deprecated-nodeapp-from-astroappnode-adapter-api)) diff --git a/benchmark/packages/timer/src/server.ts b/benchmark/packages/timer/src/server.ts index d9f5802cf29a..797d140dc7e3 100644 --- a/benchmark/packages/timer/src/server.ts +++ b/benchmark/packages/timer/src/server.ts @@ -1,16 +1,17 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import type { SSRManifest } from 'astro'; -import { NodeApp } from 'astro/app/node'; +import { createApp } from 'astro/app/entrypoint'; +import { createRequest } from 'astro/app/node'; -export function createExports(manifest: SSRManifest) { - const app = new NodeApp(manifest); - return { - handler: async (req: IncomingMessage, res: ServerResponse) => { - const start = performance.now(); - await app.render(req); - const end = performance.now(); - res.write(end - start + ''); - res.end(); - }, - }; +const app = createApp(); + +export async function handler(req: IncomingMessage, res: ServerResponse): Promise { + const start = performance.now(); + await app.render( + createRequest(req, { + allowedDomains: app.manifest.allowedDomains, + }), + ); + const end = performance.now(); + res.write(end - start + ''); + res.end(); } diff --git a/packages/astro/package.json b/packages/astro/package.json index 161d9d923cca..14480932e9b7 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -49,12 +49,12 @@ "./runtime/*": "./dist/runtime/*", "./config": "./dist/config/entrypoint.js", "./container": "./dist/container/index.js", - "./app": "./dist/core/app/index.js", - "./app/node": "./dist/core/app/node.js", - "./app/entrypoint": "./dist/core/app/entrypoint.js", - "./app/entrypoint/dev": "./dist/core/app/entrypoint/dev.js", - "./app/entrypoint/prod": "./dist/core/app/entrypoint/prod.js", - "./app/manifest": "./dist/core/app/manifest.js", + "./app": "./dist/core/app/entrypoints/index.js", + "./app/node": "./dist/core/app/entrypoints/node.js", + "./app/entrypoint": "./dist/core/app/entrypoints/virtual/index.js", + "./app/entrypoint/dev": "./dist/core/app/entrypoints/virtual/dev.js", + "./app/entrypoint/prod": "./dist/core/app/entrypoints/virtual/prod.js", + "./app/manifest": "./dist/core/app/entrypoints/manifest.js", "./entrypoints/prerender": "./dist/entrypoints/prerender.js", "./entrypoints/legacy": "./dist/entrypoints/legacy.js", "./client/*": "./dist/runtime/client/*", diff --git a/packages/astro/src/core/app/entrypoint.ts b/packages/astro/src/core/app/entrypoint.ts deleted file mode 100644 index d5c75e3d9041..000000000000 --- a/packages/astro/src/core/app/entrypoint.ts +++ /dev/null @@ -1 +0,0 @@ -export { createApp } from 'virtual:astro:app'; diff --git a/packages/astro/src/core/app/entrypoints/index.ts b/packages/astro/src/core/app/entrypoints/index.ts new file mode 100644 index 000000000000..63fdafd87d91 --- /dev/null +++ b/packages/astro/src/core/app/entrypoints/index.ts @@ -0,0 +1,13 @@ +export type { RoutesList } from '../../../types/astro.js'; +export { App } from '../app.js'; +export { BaseApp, type RenderErrorOptions, type RenderOptions } from '../base.js'; +export { fromRoutingStrategy, toRoutingStrategy } from '../common.js'; +export { createConsoleLogger } from '../logging.js'; +export { + deserializeManifest, + deserializeRouteData, + deserializeRouteInfo, + serializeRouteData, + serializeRouteInfo, +} from '../manifest.js'; +export { AppPipeline } from '../pipeline.js'; diff --git a/packages/astro/src/core/app/entrypoints/manifest.ts b/packages/astro/src/core/app/entrypoints/manifest.ts new file mode 100644 index 000000000000..f199b51af071 --- /dev/null +++ b/packages/astro/src/core/app/entrypoints/manifest.ts @@ -0,0 +1,8 @@ +export { + type SerializedRouteData, + deserializeManifest, + deserializeRouteData, + deserializeRouteInfo, + serializeRouteData, + serializeRouteInfo, +} from '../manifest.js'; diff --git a/packages/astro/src/core/app/entrypoints/node.ts b/packages/astro/src/core/app/entrypoints/node.ts new file mode 100644 index 000000000000..022735aac6a6 --- /dev/null +++ b/packages/astro/src/core/app/entrypoints/node.ts @@ -0,0 +1 @@ +export { NodeApp, loadApp, loadManifest, createRequest, writeResponse } from '../node.js'; diff --git a/packages/astro/src/core/app/entrypoint/dev.ts b/packages/astro/src/core/app/entrypoints/virtual/dev.ts similarity index 77% rename from packages/astro/src/core/app/entrypoint/dev.ts rename to packages/astro/src/core/app/entrypoints/virtual/dev.ts index 824f31e8e67a..53bf243624c4 100644 --- a/packages/astro/src/core/app/entrypoint/dev.ts +++ b/packages/astro/src/core/app/entrypoints/virtual/dev.ts @@ -1,9 +1,9 @@ import { manifest } from 'virtual:astro:manifest'; -import type { BaseApp } from '../base.js'; -import { DevApp } from '../dev/app.js'; -import { createConsoleLogger } from '../logging.js'; -import type { RouteInfo } from '../types.js'; -import type { RoutesList } from '../../../types/astro.js'; +import type { BaseApp } from '../../base.js'; +import { DevApp } from '../../dev/app.js'; +import { createConsoleLogger } from '../../logging.js'; +import type { RouteInfo } from '../../types.js'; +import type { RoutesList } from '../../../../types/astro.js'; let currentDevApp: DevApp | null = null; diff --git a/packages/astro/src/core/app/entrypoints/virtual/index.ts b/packages/astro/src/core/app/entrypoints/virtual/index.ts new file mode 100644 index 000000000000..88bbdd19485b --- /dev/null +++ b/packages/astro/src/core/app/entrypoints/virtual/index.ts @@ -0,0 +1,4 @@ +import { createApp as _createApp } from 'virtual:astro:app'; +import type { BaseApp } from '../../base.js'; + +export const createApp: () => BaseApp = _createApp; diff --git a/packages/astro/src/core/app/entrypoint/prod.ts b/packages/astro/src/core/app/entrypoints/virtual/prod.ts similarity index 59% rename from packages/astro/src/core/app/entrypoint/prod.ts rename to packages/astro/src/core/app/entrypoints/virtual/prod.ts index 314944c8c32d..75135148e60a 100644 --- a/packages/astro/src/core/app/entrypoint/prod.ts +++ b/packages/astro/src/core/app/entrypoints/virtual/prod.ts @@ -1,6 +1,6 @@ import { manifest } from 'virtual:astro:manifest'; -import type { BaseApp } from '../base.js'; -import { App } from '../app.js'; +import type { BaseApp } from '../../base.js'; +import { App } from '../../app.js'; export function createApp(): BaseApp { return new App(manifest); diff --git a/packages/astro/src/core/app/index.ts b/packages/astro/src/core/app/index.ts deleted file mode 100644 index 6d6307f9e2b1..000000000000 --- a/packages/astro/src/core/app/index.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type { RoutesList } from '../../types/astro.js'; -export { App } from './app.js'; -export { BaseApp, type RenderErrorOptions, type RenderOptions } from './base.js'; -export { fromRoutingStrategy, toRoutingStrategy } from './common.js'; -export { createConsoleLogger } from './logging.js'; -export { - deserializeManifest, - deserializeRouteData, - deserializeRouteInfo, - serializeRouteData, - serializeRouteInfo, -} from './manifest.js'; -export { AppPipeline } from './pipeline.js'; diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index e0c4b04d3566..11635e2718ec 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -6,8 +6,8 @@ import type { RemotePattern } from '../../types/public/config.js'; import { clientAddressSymbol, nodeRequestAbortControllerCleanupSymbol } from '../constants.js'; import { deserializeManifest } from './manifest.js'; import { createOutgoingHttpHeaders } from './createOutgoingHttpHeaders.js'; -import type { RenderOptions } from './index.js'; -import { App } from './index.js'; +import type { RenderOptions } from './base.js'; +import { App } from './app.js'; import type { NodeAppHeadersJson, SerializedSSRManifest, SSRManifest } from './types.js'; import { validateForwardedHeaders, validateHost } from './validate-headers.js'; @@ -19,6 +19,213 @@ interface NodeRequest extends IncomingMessage { body?: unknown; } +/** + * Converts a NodeJS IncomingMessage into a web standard Request. + * ```js + * import { createApp } from 'astro/app/entrypoint'; + * import { createRequest } from 'astro/app/node'; + * import { createServer } from 'node:http'; + * + * const app = createApp(); + * + * const server = createServer(async (req, res) => { + * const request = createRequest(req); + * const response = await app.render(request); + * }) + * ``` + */ +export function createRequest( + req: NodeRequest, + { + skipBody = false, + allowedDomains = [], + }: { skipBody?: boolean; allowedDomains?: Partial[] } = {}, +): Request { + const controller = new AbortController(); + + const isEncrypted = 'encrypted' in req.socket && req.socket.encrypted; + + // Parses multiple header and returns first value if available. + const getFirstForwardedValue = (multiValueHeader?: string | string[]) => { + return multiValueHeader + ?.toString() + ?.split(',') + .map((e) => e.trim())?.[0]; + }; + + const providedProtocol = isEncrypted ? 'https' : 'http'; + const untrustedHostname = req.headers.host ?? req.headers[':authority']; + + // Validate forwarded headers + // NOTE: Header values may have commas/spaces from proxy chains, extract first value + const validated = validateForwardedHeaders( + getFirstForwardedValue(req.headers['x-forwarded-proto']), + getFirstForwardedValue(req.headers['x-forwarded-host']), + getFirstForwardedValue(req.headers['x-forwarded-port']), + allowedDomains, + ); + + const protocol = validated.protocol ?? providedProtocol; + // validated.host is already validated against allowedDomains + // For the Host header, we also need to validate against allowedDomains to prevent SSRF + // The Host header is only trusted if allowedDomains is configured AND the host matches + // Otherwise, fall back to 'localhost' to prevent SSRF attacks + const validatedHostname = validateHost( + typeof untrustedHostname === 'string' ? untrustedHostname : undefined, + protocol, + allowedDomains, + ); + const hostname = validated.host ?? validatedHostname ?? 'localhost'; + const port = validated.port; + + let url: URL; + try { + const hostnamePort = getHostnamePort(hostname, port); + url = new URL(`${protocol}://${hostnamePort}${req.url}`); + } catch { + // Fallback using validated hostname to prevent SSRF + const hostnamePort = getHostnamePort(hostname, port); + url = new URL(`${protocol}://${hostnamePort}`); + } + + const options: RequestInit = { + method: req.method || 'GET', + headers: makeRequestHeaders(req), + signal: controller.signal, + }; + const bodyAllowed = options.method !== 'HEAD' && options.method !== 'GET' && skipBody === false; + if (bodyAllowed) { + Object.assign(options, makeRequestBody(req)); + } + + const request = new Request(url, options); + + const socket = getRequestSocket(req); + if (socket && typeof socket.on === 'function') { + const existingCleanup = getAbortControllerCleanup(req); + if (existingCleanup) { + existingCleanup(); + } + let cleanedUp = false; + + const removeSocketListener = () => { + if (typeof socket.off === 'function') { + socket.off('close', onSocketClose); + } else if (typeof socket.removeListener === 'function') { + socket.removeListener('close', onSocketClose); + } + }; + + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + removeSocketListener(); + controller.signal.removeEventListener('abort', cleanup); + Reflect.deleteProperty(req, nodeRequestAbortControllerCleanupSymbol); + }; + + const onSocketClose = () => { + cleanup(); + if (!controller.signal.aborted) { + controller.abort(); + } + }; + + socket.on('close', onSocketClose); + controller.signal.addEventListener('abort', cleanup, { once: true }); + Reflect.set(req, nodeRequestAbortControllerCleanupSymbol, cleanup); + + if (socket.destroyed) { + onSocketClose(); + } + } + + // Get the IP of end client behind the proxy. + // @example "1.1.1.1,8.8.8.8" => "1.1.1.1" + const forwardedClientIp = getFirstForwardedValue(req.headers['x-forwarded-for']); + const clientIp = forwardedClientIp || req.socket?.remoteAddress; + if (clientIp) { + Reflect.set(request, clientAddressSymbol, clientIp); + } + + return request; +} + +/** + * Streams a web-standard Response into a NodeJS Server Response. + * ```js + * import { createApp } from 'astro/app/entrypoint'; + * import { createRequest, writeResponse } from 'astro/app/node'; + * import { createServer } from 'node:http'; + * + * const app = createApp(); + * + * const server = createServer(async (req, res) => { + * const request = createRequest(req); + * const response = await app.render(request); + * await writeResponse(response, res); + * }) + * ``` + * @param source WhatWG Response + * @param destination NodeJS ServerResponse + */ +export async function writeResponse(source: Response, destination: ServerResponse) { + const { status, headers, body, statusText } = source; + // HTTP/2 doesn't support statusMessage + if (!(destination instanceof Http2ServerResponse)) { + destination.statusMessage = statusText; + } + destination.writeHead(status, createOutgoingHttpHeaders(headers)); + + const cleanupAbortFromDestination = getAbortControllerCleanup( + (destination.req as NodeRequest | undefined) ?? undefined, + ); + if (cleanupAbortFromDestination) { + const runCleanup = () => { + cleanupAbortFromDestination(); + if (typeof destination.off === 'function') { + destination.off('finish', runCleanup); + destination.off('close', runCleanup); + } else { + destination.removeListener?.('finish', runCleanup); + destination.removeListener?.('close', runCleanup); + } + }; + destination.on('finish', runCleanup); + destination.on('close', runCleanup); + } + if (!body) return destination.end(); + 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(); + } + destination.end(); + // the error will be logged by the "on end" callback above + } catch (err) { + destination.write('Internal server error', () => { + err instanceof Error ? destination.destroy(err) : destination.destroy(); + }); + } +} + +/** + * @deprecated Use `App` or `createApp()` instead, and use in conjunction with `convertRequest()` + * and `writeResponse()` helpers. This will be removed in a future major version. + */ export class NodeApp extends App { headersMap: NodeAppHeadersJson | undefined = undefined; @@ -28,7 +235,7 @@ export class NodeApp extends App { match(req: NodeRequest | Request, allowPrerenderedRoutes = false) { if (!(req instanceof Request)) { - req = NodeApp.createRequest(req, { + req = createRequest(req, { skipBody: true, allowedDomains: this.manifest.allowedDomains, }); @@ -38,7 +245,7 @@ export class NodeApp extends App { render(request: NodeRequest | Request, options?: RenderOptions): Promise { if (!(request instanceof Request)) { - request = NodeApp.createRequest(request, { + request = createRequest(request, { allowedDomains: this.manifest.allowedDomains, }); } @@ -58,122 +265,7 @@ export class NodeApp extends App { * }) * ``` */ - static createRequest( - req: NodeRequest, - { - skipBody = false, - allowedDomains = [], - }: { skipBody?: boolean; allowedDomains?: Partial[] } = {}, - ): Request { - const controller = new AbortController(); - - const isEncrypted = 'encrypted' in req.socket && req.socket.encrypted; - - // Parses multiple header and returns first value if available. - const getFirstForwardedValue = (multiValueHeader?: string | string[]) => { - return multiValueHeader - ?.toString() - ?.split(',') - .map((e) => e.trim())?.[0]; - }; - - const providedProtocol = isEncrypted ? 'https' : 'http'; - const untrustedHostname = req.headers.host ?? req.headers[':authority']; - - // Validate forwarded headers - // NOTE: Header values may have commas/spaces from proxy chains, extract first value - const validated = validateForwardedHeaders( - getFirstForwardedValue(req.headers['x-forwarded-proto']), - getFirstForwardedValue(req.headers['x-forwarded-host']), - getFirstForwardedValue(req.headers['x-forwarded-port']), - allowedDomains, - ); - - const protocol = validated.protocol ?? providedProtocol; - // validated.host is already validated against allowedDomains - // For the Host header, we also need to validate against allowedDomains to prevent SSRF - // The Host header is only trusted if allowedDomains is configured AND the host matches - // Otherwise, fall back to 'localhost' to prevent SSRF attacks - const validatedHostname = validateHost( - typeof untrustedHostname === 'string' ? untrustedHostname : undefined, - protocol, - allowedDomains, - ); - const hostname = validated.host ?? validatedHostname ?? 'localhost'; - const port = validated.port; - - let url: URL; - try { - const hostnamePort = getHostnamePort(hostname, port); - url = new URL(`${protocol}://${hostnamePort}${req.url}`); - } catch { - // Fallback using validated hostname to prevent SSRF - const hostnamePort = getHostnamePort(hostname, port); - url = new URL(`${protocol}://${hostnamePort}`); - } - - const options: RequestInit = { - method: req.method || 'GET', - headers: makeRequestHeaders(req), - signal: controller.signal, - }; - const bodyAllowed = options.method !== 'HEAD' && options.method !== 'GET' && skipBody === false; - if (bodyAllowed) { - Object.assign(options, makeRequestBody(req)); - } - - const request = new Request(url, options); - - const socket = getRequestSocket(req); - if (socket && typeof socket.on === 'function') { - const existingCleanup = getAbortControllerCleanup(req); - if (existingCleanup) { - existingCleanup(); - } - let cleanedUp = false; - - const removeSocketListener = () => { - if (typeof socket.off === 'function') { - socket.off('close', onSocketClose); - } else if (typeof socket.removeListener === 'function') { - socket.removeListener('close', onSocketClose); - } - }; - - const cleanup = () => { - if (cleanedUp) return; - cleanedUp = true; - removeSocketListener(); - controller.signal.removeEventListener('abort', cleanup); - Reflect.deleteProperty(req, nodeRequestAbortControllerCleanupSymbol); - }; - - const onSocketClose = () => { - cleanup(); - if (!controller.signal.aborted) { - controller.abort(); - } - }; - - socket.on('close', onSocketClose); - controller.signal.addEventListener('abort', cleanup, { once: true }); - Reflect.set(req, nodeRequestAbortControllerCleanupSymbol, cleanup); - - if (socket.destroyed) { - onSocketClose(); - } - } - - // Get the IP of end client behind the proxy. - // @example "1.1.1.1,8.8.8.8" => "1.1.1.1" - const forwardedClientIp = getFirstForwardedValue(req.headers['x-forwarded-for']); - const clientIp = forwardedClientIp || req.socket?.remoteAddress; - if (clientIp) { - Reflect.set(request, clientAddressSymbol, clientIp); - } - - return request; - } + static createRequest = createRequest; /** * Streams a web-standard Response into a NodeJS Server Response. @@ -190,58 +282,7 @@ export class NodeApp extends App { * @param source WhatWG Response * @param destination NodeJS ServerResponse */ - static async writeResponse(source: Response, destination: ServerResponse) { - const { status, headers, body, statusText } = source; - // HTTP/2 doesn't support statusMessage - if (!(destination instanceof Http2ServerResponse)) { - destination.statusMessage = statusText; - } - destination.writeHead(status, createOutgoingHttpHeaders(headers)); - - const cleanupAbortFromDestination = getAbortControllerCleanup( - (destination.req as NodeRequest | undefined) ?? undefined, - ); - if (cleanupAbortFromDestination) { - const runCleanup = () => { - cleanupAbortFromDestination(); - if (typeof destination.off === 'function') { - destination.off('finish', runCleanup); - destination.off('close', runCleanup); - } else { - destination.removeListener?.('finish', runCleanup); - destination.removeListener?.('close', runCleanup); - } - }; - destination.on('finish', runCleanup); - destination.on('close', runCleanup); - } - if (!body) return destination.end(); - 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(); - } - destination.end(); - // the error will be logged by the "on end" callback above - } catch (err) { - destination.write('Internal server error', () => { - err instanceof Error ? destination.destroy(err) : destination.destroy(); - }); - } - } + static writeResponse = writeResponse; } function getHostnamePort(hostname: string | string[] | undefined, port?: string): string { @@ -322,6 +363,7 @@ function getRequestSocket(req: NodeRequest): Socket | undefined { return undefined; } +/** @deprecated This will be removed in a future major version. */ export async function loadManifest(rootFolder: URL): Promise { const manifestFile = new URL('./manifest.json', rootFolder); const rawManifest = await fs.promises.readFile(manifestFile, 'utf-8'); @@ -329,6 +371,7 @@ export async function loadManifest(rootFolder: URL): Promise { return deserializeManifest(serializedManifest); } +/** @deprecated This will be removed in a future major version. */ export async function loadApp(rootFolder: URL): Promise { const manifest = await loadManifest(rootFolder); return new NodeApp(manifest); diff --git a/packages/astro/src/core/app/types.ts b/packages/astro/src/core/app/types.ts index bc5ccfecc304..e56a6445a586 100644 --- a/packages/astro/src/core/app/types.ts +++ b/packages/astro/src/core/app/types.ts @@ -197,6 +197,7 @@ export type SerializedSSRManifest = Omit< key: string; }; +/** @deprecated This will be removed in a future major version. */ export type NodeAppHeadersJson = { pathname: string; headers: { diff --git a/packages/astro/src/core/build/app.ts b/packages/astro/src/core/build/app.ts index 6bda9d6883e8..92900bbc80fd 100644 --- a/packages/astro/src/core/build/app.ts +++ b/packages/astro/src/core/build/app.ts @@ -1,4 +1,4 @@ -import { BaseApp, type RenderErrorOptions } from '../app/index.js'; +import { BaseApp, type RenderErrorOptions } from '../app/entrypoints/index.js'; import type { SSRManifest } from '../app/types.js'; import type { BuildInternals } from './internal.js'; import { BuildPipeline } from './pipeline.js'; diff --git a/packages/astro/src/core/build/pipeline.ts b/packages/astro/src/core/build/pipeline.ts index bcf1af3d3138..5e835607d389 100644 --- a/packages/astro/src/core/build/pipeline.ts +++ b/packages/astro/src/core/build/pipeline.ts @@ -4,7 +4,7 @@ import type { RouteData, SSRElement, SSRResult } from '../../types/public/intern import { VIRTUAL_PAGE_RESOLVED_MODULE_ID } from '../../vite-plugin-pages/const.js'; import { getVirtualModulePageName } from '../../vite-plugin-pages/util.js'; import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../vite-plugin-scripts/index.js'; -import { createConsoleLogger } from '../app/index.js'; +import { createConsoleLogger } from '../app/entrypoints/index.js'; import type { SSRManifest } from '../app/types.js'; import type { TryRewriteResult } from '../base-pipeline.js'; import { RedirectSinglePageBuiltModule } from '../redirects/component.js'; diff --git a/packages/astro/src/core/build/plugins/plugin-manifest.ts b/packages/astro/src/core/build/plugins/plugin-manifest.ts index 5aab13b876e4..8ef56913e876 100644 --- a/packages/astro/src/core/build/plugins/plugin-manifest.ts +++ b/packages/astro/src/core/build/plugins/plugin-manifest.ts @@ -7,7 +7,7 @@ import { SERIALIZED_MANIFEST_RESOLVED_ID } from '../../../manifest/serialized.js import type { ExtractedChunk } from '../static-build.js'; import { BEFORE_HYDRATION_SCRIPT_ID, PAGE_SCRIPT_ID } from '../../../vite-plugin-scripts/index.js'; import { toFallbackType } from '../../app/common.js'; -import { serializeRouteData, toRoutingStrategy } from '../../app/index.js'; +import { serializeRouteData, toRoutingStrategy } from '../../app/entrypoints/index.js'; import type { SerializedRouteInfo, SerializedSSRManifest, diff --git a/packages/astro/src/core/routing/manifest/create.ts b/packages/astro/src/core/routing/manifest/create.ts index 3cba6332f5e4..64d4d724348d 100644 --- a/packages/astro/src/core/routing/manifest/create.ts +++ b/packages/astro/src/core/routing/manifest/create.ts @@ -10,7 +10,7 @@ import { getPrerenderDefault } from '../../../prerender/utils.js'; import type { AstroSettings, RoutesList } from '../../../types/astro.js'; import type { AstroConfig } from '../../../types/public/config.js'; import type { RouteData, RoutePart } from '../../../types/public/internal.js'; -import { toRoutingStrategy } from '../../app/index.js'; +import { toRoutingStrategy } from '../../app/entrypoints/index.js'; import { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from '../../constants.js'; import { InvalidRedirectDestination, diff --git a/packages/astro/src/manifest/serialized.ts b/packages/astro/src/manifest/serialized.ts index 1837d95828ad..8560496147e4 100644 --- a/packages/astro/src/manifest/serialized.ts +++ b/packages/astro/src/manifest/serialized.ts @@ -1,7 +1,7 @@ import type { Plugin, ViteDevServer } from 'vite'; import { ACTIONS_ENTRYPOINT_VIRTUAL_MODULE_ID } from '../actions/consts.js'; import { toFallbackType } from '../core/app/common.js'; -import { toRoutingStrategy } from '../core/app/index.js'; +import { toRoutingStrategy } from '../core/app/entrypoints/index.js'; import type { SerializedSSRManifest, SSRManifestCSP, SSRManifestI18n } from '../core/app/types.js'; import { MANIFEST_REPLACE } from '../core/build/plugins/plugin-manifest.js'; import { diff --git a/packages/astro/src/virtual-modules/i18n.ts b/packages/astro/src/virtual-modules/i18n.ts index 48e714dfd5ca..e736381f8ad9 100644 --- a/packages/astro/src/virtual-modules/i18n.ts +++ b/packages/astro/src/virtual-modules/i18n.ts @@ -1,7 +1,7 @@ // @ts-expect-error This is an internal module import * as config from 'astro:config/server'; import { toFallbackType } from '../core/app/common.js'; -import { toRoutingStrategy } from '../core/app/index.js'; +import { toRoutingStrategy } from '../core/app/entrypoints/index.js'; import type { SSRManifest } from '../core/app/types.js'; import { IncorrectStrategyForI18n, diff --git a/packages/astro/src/vite-plugin-app/app.ts b/packages/astro/src/vite-plugin-app/app.ts index 086367a62558..bb2258ddcd4a 100644 --- a/packages/astro/src/vite-plugin-app/app.ts +++ b/packages/astro/src/vite-plugin-app/app.ts @@ -1,6 +1,6 @@ import type http from 'node:http'; import { removeTrailingForwardSlash } from '@astrojs/internal-helpers/path'; -import { BaseApp, type RenderErrorOptions } from '../core/app/index.js'; +import { BaseApp, type RenderErrorOptions } from '../core/app/entrypoints/index.js'; import { shouldAppendForwardSlash } from '../core/build/util.js'; import { clientLocalsSymbol } from '../core/constants.js'; import { diff --git a/packages/astro/src/vite-plugin-astro-server/plugin.ts b/packages/astro/src/vite-plugin-astro-server/plugin.ts index 91edffe613e5..1ab92fc2e69f 100644 --- a/packages/astro/src/vite-plugin-astro-server/plugin.ts +++ b/packages/astro/src/vite-plugin-astro-server/plugin.ts @@ -3,7 +3,7 @@ import { IncomingMessage } from 'node:http'; import type * as vite from 'vite'; import { isRunnableDevEnvironment, type RunnableDevEnvironment } from 'vite'; import { toFallbackType } from '../core/app/common.js'; -import { toRoutingStrategy } from '../core/app/index.js'; +import { toRoutingStrategy } from '../core/app/entrypoints/index.js'; import type { SSRManifest, SSRManifestCSP, SSRManifestI18n } from '../core/app/types.js'; import { ASTRO_VITE_ENVIRONMENT_NAMES } from '../core/constants.js'; import { diff --git a/packages/astro/src/vite-plugin-routes/index.ts b/packages/astro/src/vite-plugin-routes/index.ts index 68cd74721c6a..5161154905a6 100644 --- a/packages/astro/src/vite-plugin-routes/index.ts +++ b/packages/astro/src/vite-plugin-routes/index.ts @@ -3,7 +3,7 @@ import { extname } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import colors from 'piccolore'; import { normalizePath, type Plugin, type ViteDevServer } from 'vite'; -import { serializeRouteData } from '../core/app/index.js'; +import { serializeRouteData } from '../core/app/entrypoints/index.js'; import type { SerializedRouteInfo } from '../core/app/types.js'; import { warnMissingAdapter } from '../core/dev/adapter-validation.js'; import type { Logger } from '../core/logger/core.js'; diff --git a/packages/astro/test/astro-global.test.js b/packages/astro/test/astro-global.test.js index 9ee3ab9ee9fb..21dfea26e181 100644 --- a/packages/astro/test/astro-global.test.js +++ b/packages/astro/test/astro-global.test.js @@ -141,7 +141,7 @@ describe('Astro Global', () => { }); describe('app', () => { - /** @type {import('../dist/core/app/index.js').App} */ + /** @type {import('../dist/core/app/app.js').App} */ let app; before(async () => { diff --git a/packages/astro/test/middleware.test.js b/packages/astro/test/middleware.test.js index 6f029f9b7cc6..c47d1604618f 100644 --- a/packages/astro/test/middleware.test.js +++ b/packages/astro/test/middleware.test.js @@ -225,7 +225,7 @@ describe('Middleware API in PROD mode, SSR', () => { /** @type {import('./test-utils').Fixture} */ let fixture; let middlewarePath; - /** @type {import('../src/core/app/index').App} */ + /** @type {import('../src/core/app/app.js').App} */ let app; before(async () => { diff --git a/packages/astro/test/sessions.test.js b/packages/astro/test/sessions.test.js index c48d1c03f31f..b65201e43d60 100644 --- a/packages/astro/test/sessions.test.js +++ b/packages/astro/test/sessions.test.js @@ -8,7 +8,7 @@ describe('Astro.session', () => { describe('Production', () => { /** @type {import('./test-utils').Fixture} */ let fixture; - /** @type {import('../src/core/app/index').App} response */ + /** @type {import('../src/core/app/app.js').App} response */ let app; before(async () => { diff --git a/packages/astro/test/test-utils.js b/packages/astro/test/test-utils.js index c455be231a08..f6b7cff026a8 100644 --- a/packages/astro/test/test-utils.js +++ b/packages/astro/test/test-utils.js @@ -22,7 +22,7 @@ process.env.ASTRO_TELEMETRY_DISABLED = true; * @typedef {Omit & { root?: string | URL }} AstroInlineConfig * @typedef {import('../src/types/public/config.js').AstroConfig} AstroConfig * @typedef {import('../src/core/preview/index').PreviewServer} PreviewServer - * @typedef {import('../src/core/app/index').App} App + * @typedef {import('../src/core/app/app.js').App} App * @typedef {import('../src/cli/check/index').AstroChecker} AstroChecker * @typedef {import('../src/cli/check/index').CheckPayload} CheckPayload * @typedef {import('http').IncomingMessage} NodeRequest diff --git a/packages/astro/test/units/app/node.test.js b/packages/astro/test/units/app/node.test.js index 33dbd6f3663c..f461e13d3e09 100644 --- a/packages/astro/test/units/app/node.test.js +++ b/packages/astro/test/units/app/node.test.js @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { describe, it } from 'node:test'; -import { NodeApp } from '../../../dist/core/app/node.js'; +import { createRequest, writeResponse } from '../../../dist/core/app/node.js'; const mockNodeRequest = { url: '/', @@ -15,11 +15,11 @@ const mockNodeRequest = { }, }; -describe('NodeApp', () => { +describe('node', () => { describe('createRequest', () => { describe('x-forwarded-for', () => { it('parses client IP from single-value x-forwarded-for header', () => { - const result = NodeApp.createRequest({ + const result = createRequest({ ...mockNodeRequest, headers: { 'x-forwarded-for': '1.1.1.1', @@ -29,7 +29,7 @@ describe('NodeApp', () => { }); it('parses client IP from multi-value x-forwarded-for header', () => { - const result = NodeApp.createRequest({ + const result = createRequest({ ...mockNodeRequest, headers: { 'x-forwarded-for': '1.1.1.1,8.8.8.8', @@ -39,7 +39,7 @@ describe('NodeApp', () => { }); it('parses client IP from multi-value x-forwarded-for header with spaces', () => { - const result = NodeApp.createRequest({ + const result = createRequest({ ...mockNodeRequest, headers: { 'x-forwarded-for': ' 1.1.1.1, 8.8.8.8, 8.8.8.2', @@ -49,7 +49,7 @@ describe('NodeApp', () => { }); it('fallbacks to remoteAddress when no x-forwarded-for header is present', () => { - const result = NodeApp.createRequest({ + const result = createRequest({ ...mockNodeRequest, headers: {}, }); @@ -59,7 +59,7 @@ describe('NodeApp', () => { describe('x-forwarded-host', () => { it('parses host from single-value x-forwarded-host header', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -72,7 +72,7 @@ describe('NodeApp', () => { }); it('parses host from multi-value x-forwarded-host header', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -85,7 +85,7 @@ describe('NodeApp', () => { }); it('fallbacks to host header when no x-forwarded-host header is present', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -98,7 +98,7 @@ describe('NodeApp', () => { }); it('bad values are ignored and fallback to host header', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -112,7 +112,7 @@ describe('NodeApp', () => { }); it('rejects empty x-forwarded-host and falls back to host header', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -126,7 +126,7 @@ describe('NodeApp', () => { }); it('rejects x-forwarded-host with no dots (e.g. localhost) against single wildcard pattern', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -141,7 +141,7 @@ describe('NodeApp', () => { }); it('rejects x-forwarded-host with path separator (path injection attempt)', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -156,7 +156,7 @@ describe('NodeApp', () => { }); it('rejects x-forwarded-host with multiple path segments', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -171,7 +171,7 @@ describe('NodeApp', () => { }); it('rejects x-forwarded-host with backslash path separator (path injection attempt)', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -186,7 +186,7 @@ describe('NodeApp', () => { }); it('parses x-forwarded-host with embedded port when allowedDomains has port pattern', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -202,7 +202,7 @@ describe('NodeApp', () => { }); it('rejects Host header with path separator (path injection attempt)', () => { - const result = NodeApp.createRequest({ + const result = createRequest({ ...mockNodeRequest, headers: { host: 'example.com/admin', @@ -213,7 +213,7 @@ describe('NodeApp', () => { }); it('rejects Host header with backslash path separator (path injection attempt)', () => { - const result = NodeApp.createRequest({ + const result = createRequest({ ...mockNodeRequest, headers: { host: 'example.com\\admin', @@ -225,7 +225,7 @@ describe('NodeApp', () => { describe('Host header validation', () => { it('rejects Host header when allowedDomains is configured but host does not match', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -239,7 +239,7 @@ describe('NodeApp', () => { }); it('accepts Host header when it matches allowedDomains', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -252,7 +252,7 @@ describe('NodeApp', () => { }); it('accepts Host header with port when it matches allowedDomains pattern', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -265,7 +265,7 @@ describe('NodeApp', () => { }); it('accepts Host header with wildcard pattern in allowedDomains', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -278,7 +278,7 @@ describe('NodeApp', () => { }); it('falls back to localhost when no allowedDomains is configured', () => { - const result = NodeApp.createRequest({ + const result = createRequest({ ...mockNodeRequest, headers: { host: 'any-host.com', @@ -289,7 +289,7 @@ describe('NodeApp', () => { }); it('falls back to localhost when allowedDomains is empty', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -303,7 +303,7 @@ describe('NodeApp', () => { }); it('prefers x-forwarded-host over Host header when both match allowedDomains', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -319,7 +319,7 @@ describe('NodeApp', () => { describe('x-forwarded-proto', () => { it('parses protocol from single-value x-forwarded-proto header', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -334,7 +334,7 @@ describe('NodeApp', () => { }); it('parses protocol from multi-value x-forwarded-proto header', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -349,7 +349,7 @@ describe('NodeApp', () => { }); it('fallbacks to encrypted property when no x-forwarded-proto header is present', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -362,7 +362,7 @@ describe('NodeApp', () => { }); it('rejects malicious x-forwarded-proto with URL injection (https://www.malicious-url.com/?tank=)', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -376,7 +376,7 @@ describe('NodeApp', () => { }); it('rejects malicious x-forwarded-proto with middleware bypass attempt (x:admin?)', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -390,7 +390,7 @@ describe('NodeApp', () => { }); it('rejects malicious x-forwarded-proto with cache poison attempt (https://localhost/vulnerable?)', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -404,7 +404,7 @@ describe('NodeApp', () => { }); it('rejects malicious x-forwarded-proto with XSS attempt (javascript:alert(document.cookie)//)', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -418,7 +418,7 @@ describe('NodeApp', () => { }); it('rejects empty x-forwarded-proto and falls back to encrypted property', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -434,7 +434,7 @@ describe('NodeApp', () => { describe('x-forwarded-port', () => { it('parses port from single-value x-forwarded-port header (with allowedDomains)', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -453,7 +453,7 @@ describe('NodeApp', () => { }); it('parses port from multi-value x-forwarded-port header (with allowedDomains)', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -472,7 +472,7 @@ describe('NodeApp', () => { }); it('rejects x-forwarded-port without allowedDomains patterns (strict security default)', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -486,7 +486,7 @@ describe('NodeApp', () => { }); it('prefers port from host', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -500,7 +500,7 @@ describe('NodeApp', () => { }); it('uses port embedded in x-forwarded-host', () => { - const result = NodeApp.createRequest( + const result = createRequest( { ...mockNodeRequest, headers: { @@ -521,7 +521,7 @@ describe('NodeApp', () => { socket.encrypted = true; socket.remoteAddress = '2.2.2.2'; socket.destroyed = false; - const result = NodeApp.createRequest({ + const result = createRequest({ ...mockNodeRequest, socket, }); @@ -540,13 +540,13 @@ describe('NodeApp', () => { ...mockNodeRequest, socket, }; - const result = NodeApp.createRequest(nodeRequest); + const result = createRequest(nodeRequest); assert.equal(typeof result.signal.addEventListener, 'function'); assert.equal(socket.listenerCount('close') > 0, true); const response = new Response('ok'); const destination = new MockServerResponse(nodeRequest); - await NodeApp.writeResponse(response, destination); + await writeResponse(response, destination); assert.equal(result.signal.aborted, false); assert.equal(socket.listenerCount('close'), 0);