diff --git a/package.json b/package.json index 1902221..1d10693 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ } }, "dependencies": { - "@antelopejs/interface-api": ">=0.0.10 <1.0.0", + "@antelopejs/interface-api": ">=0.0.11 <1.0.0", "@antelopejs/interface-api-util": "^0.1.1", "@antelopejs/interface-core": "^0.0.6", "reflect-metadata": "^0.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ab9d98..1b0f70f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,11 +9,11 @@ importers: .: dependencies: '@antelopejs/interface-api': - specifier: '>=0.0.10 <1.0.0' - version: 0.0.10(@antelopejs/interface-core@0.0.6) + specifier: '>=0.0.11 <1.0.0' + version: 0.0.11(@antelopejs/interface-core@0.0.6) '@antelopejs/interface-api-util': specifier: ^0.1.1 - version: 0.1.1(@antelopejs/interface-api@0.0.10(@antelopejs/interface-core@0.0.6))(@antelopejs/interface-core@0.0.6) + version: 0.1.1(@antelopejs/interface-api@0.0.11(@antelopejs/interface-core@0.0.6))(@antelopejs/interface-core@0.0.6) '@antelopejs/interface-core': specifier: ^0.0.6 version: 0.0.6 @@ -85,8 +85,8 @@ packages: '@antelopejs/interface-api': '>=0.0.3 <1.0.0' '@antelopejs/interface-core': '>=0.0.3 <1.0.0' - '@antelopejs/interface-api@0.0.10': - resolution: {integrity: sha512-nToojyDDp/175hR3ryuxFxlU9zyKcSr2/V+ftEySlpVHO469skNkG9s9U8OPmFMH9wj/t2iul9gNB6/NvNo7KQ==} + '@antelopejs/interface-api@0.0.11': + resolution: {integrity: sha512-egWmk2m+aVSN022xmxBCq5SbPgkN1VVJosXvLnNX2nBB+YAPKmPwsJGg53jota7TE7dK1gdL42HmiwWRHhh1eA==} peerDependencies: '@antelopejs/interface-core': '>=0.0.3 <1.0.0' @@ -1231,9 +1231,9 @@ packages: snapshots: - '@antelopejs/interface-api-util@0.1.1(@antelopejs/interface-api@0.0.10(@antelopejs/interface-core@0.0.6))(@antelopejs/interface-core@0.0.6)': + '@antelopejs/interface-api-util@0.1.1(@antelopejs/interface-api@0.0.11(@antelopejs/interface-core@0.0.6))(@antelopejs/interface-core@0.0.6)': dependencies: - '@antelopejs/interface-api': 0.0.10(@antelopejs/interface-core@0.0.6) + '@antelopejs/interface-api': 0.0.11(@antelopejs/interface-core@0.0.6) '@antelopejs/interface-core': 0.0.6 '@antelopejs/interface-api-util@0.1.1(@antelopejs/interface-api@0.0.8(@antelopejs/interface-core@0.0.6))(@antelopejs/interface-core@0.0.6)': @@ -1241,7 +1241,7 @@ snapshots: '@antelopejs/interface-api': 0.0.8(@antelopejs/interface-core@0.0.6) '@antelopejs/interface-core': 0.0.6 - '@antelopejs/interface-api@0.0.10(@antelopejs/interface-core@0.0.6)': + '@antelopejs/interface-api@0.0.11(@antelopejs/interface-core@0.0.6)': dependencies: '@antelopejs/interface-core': 0.0.6 diff --git a/src/server.ts b/src/server.ts index 3ae70ea..dd2a4b4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -547,23 +547,37 @@ function extractError(error: unknown) { return error; } +function copyHeaders(source: HTTPResult, target: HTTPResult) { + if (source === target) { + return; + } + for (const [name, value] of Object.entries(source.peekHeaders() ?? {})) { + target.addHeader(name, value); + } +} + +function replaceResponse( + requestContext: RequestContext, + result: unknown, + status: number, +) { + const previousResponse = requestContext.response; + const response = + result instanceof HTTPResult ? result : new HTTPResult(status, result); + copyHeaders(previousResponse, response); + requestContext.response = response; +} + function setHandlerResponse(requestContext: RequestContext, result: unknown) { if (requestContext.response.isStream()) { return; } - if (result) { - requestContext.response = HTTPResult.withHeaders( - result, - requestContext.response.getHeaders(), - 200, - ); + if (result instanceof HTTPResult) { + replaceResponse(requestContext, result, 200); return; } - requestContext.response = HTTPResult.withHeaders( - "", - requestContext.response.getHeaders(), - 200, - ); + requestContext.response.setBody(result || ""); + requestContext.response.setStatus(200); } function cloneResponse(response: HTTPResult) { @@ -572,9 +586,7 @@ function cloneResponse(response: HTTPResult) { response.getBody(), response.getContentType(), ); - for (const [name, value] of Object.entries(response.getHeaders())) { - snapshot.addHeader(name, value); - } + copyHeaders(response, snapshot); return snapshot; } @@ -731,13 +743,15 @@ function setMiddlewareResponse( requestContext: RequestContext, result: unknown, ): void { - if (result) { - requestContext.response = HTTPResult.withHeaders( - result, - requestContext.response.getHeaders(), - 200, - ); + if (!result) { + return; + } + if (result instanceof HTTPResult || requestContext.response.isStream()) { + replaceResponse(requestContext, result, 200); + return; } + requestContext.response.setBody(result); + requestContext.response.setStatus(200); } function executePostfix( @@ -775,6 +789,10 @@ function executeRequest( handler = getHandler("get", path, roots.handler, false, exactPath); } const selectedHandler = Array.isArray(handler) ? undefined : handler; + if (!selectedHandler) { + requestContext.response.setBody("Not Found"); + requestContext.response.setStatus(404); + } if (!selectedHandler && method !== "options") { return; } @@ -806,11 +824,7 @@ function completeRequest( requestContext: RequestContext, ): Awaitable { if (didFail) { - requestContext.response = HTTPResult.withHeaders( - extractError(error), - requestContext.response.getHeaders(), - 500, - ); + replaceResponse(requestContext, extractError(error), 500); } requestContext.error = error; const monitorExecution = executeMonitors(method, path, requestContext); @@ -837,7 +851,7 @@ function processRequest( rawResponse: res, url, routeParameters: {}, - response: new HTTPResult(404, "Not Found"), + response: new HTTPResult(), }; const path = url.pathname.split("/").filter((part) => part); const method = req.method?.toLowerCase() || "get"; @@ -927,11 +941,7 @@ export async function upgradeListener( ); const prefixResult = prefixExecution ? await prefixExecution : undefined; if (prefixResult) { - requestContext.response = HTTPResult.withHeaders( - prefixResult, - requestContext.response.getHeaders(), - 200, - ); + setMiddlewareResponse(requestContext, prefixResult); mustSendResponse = true; mustDestroySocket = true; // Fall through to finally block to execute monitors. @@ -945,11 +955,7 @@ export async function upgradeListener( requestError = error; mustDestroySocket = true; if (!hasUpgradedConnection) { - requestContext.response = HTTPResult.withHeaders( - extractError(error), - requestContext.response.getHeaders(), - 500, - ); + replaceResponse(requestContext, extractError(error), 500); mustSendResponse = true; } } finally { diff --git a/src/test/response-lifecycle.test.ts b/src/test/response-lifecycle.test.ts new file mode 100644 index 0000000..8d3c6f0 --- /dev/null +++ b/src/test/response-lifecycle.test.ts @@ -0,0 +1,243 @@ +import assert from "node:assert"; +import * as http from "node:http"; +import { HandlerPriority, HTTPResult } from "@antelopejs/interface-api"; +import { + type RequestContext, + registerHandler, + requestListener, + unregisterHandler, +} from "../server"; + +const HOST = "127.0.0.1"; +const ROUTE_ROOT = "/response-lifecycle"; +const registeredHandlers: string[] = []; + +interface CapturedResponse { + status: number; + headers: http.IncomingHttpHeaders; + body: string; +} + +function register( + mode: "prefix" | "postfix" | "handler" | "monitor", + path: string, + callback: (context: RequestContext) => unknown, +) { + const id = `${mode}-${path}-${registeredHandlers.length}`; + registeredHandlers.push(id); + registerHandler(id, mode, "get", path, callback, HandlerPriority.NORMAL); +} + +function listen(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, HOST, () => { + const address = server.address(); + assert.ok(address && typeof address === "object"); + resolve(address.port); + }); + }); +} + +function close(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); +} + +function request(port: number, path: string, method = "GET") { + return new Promise((resolve, reject) => { + const req = http.request({ host: HOST, port, path, method }, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => + resolve({ + status: res.statusCode ?? 0, + headers: res.headers, + body: Buffer.concat(chunks).toString(), + }), + ); + }); + req.once("error", reject); + req.end(); + }); +} + +describe("HTTP response lifecycle", () => { + let server: http.Server; + let port: number; + + before(async () => { + server = http.createServer( + (req, res) => void requestListener(req, res, "http"), + ); + port = await listen(server); + }); + + after(async () => { + for (const id of registeredHandlers) { + unregisterHandler(id); + } + await close(server); + }); + + it("reuses the matched route response without observing headers", async () => { + const path = `${ROUTE_ROOT}/common`; + let contextResponse: HTTPResult | undefined; + let initialStatus = 0; + let initialBody: unknown; + let headerReads = 0; + const originalGetHeaders = HTTPResult.prototype.getHeaders; + register("handler", path, (context) => { + contextResponse = context.response; + initialStatus = context.response.getStatus(); + initialBody = context.response.getBody(); + return { ok: true }; + }); + HTTPResult.prototype.getHeaders = function getHeaders() { + headerReads += 1; + return originalGetHeaders.call(this); + }; + + try { + const response = await request(port, path); + assert.equal(response.status, 200); + assert.equal(response.body, '{"ok":true}'); + assert.equal(initialStatus, 200); + assert.equal(initialBody, ""); + assert.equal(contextResponse?.getStatus(), 200); + assert.equal(contextResponse?.getBody(), '{"ok":true}'); + assert.equal(headerReads, 0); + } finally { + HTTPResult.prototype.getHeaders = originalGetHeaders; + } + }); + + it("uses the published non-allocating header lookup", async () => { + const path = `${ROUTE_ROOT}/peek-headers`; + let headerReads = 0; + const originalGetHeaders = HTTPResult.prototype.getHeaders; + register("handler", path, () => new HTTPResult(203, "result")); + HTTPResult.prototype.getHeaders = function getHeaders() { + headerReads += 1; + return originalGetHeaders.call(this); + }; + + try { + const response = await request(port, path); + assert.equal(response.status, 203); + assert.equal(response.body, "result"); + assert.equal(headerReads, 0); + } finally { + HTTPResult.prototype.getHeaders = originalGetHeaders; + } + }); + + it("preserves headers across prefix, handler, and postfix results", async () => { + const path = `${ROUTE_ROOT}/headers`; + register("prefix", path, ({ response }) => + response.addHeader("X-Prefix", "1"), + ); + register("handler", path, ({ response }) => { + response.addHeader("X-Handler", "2"); + return "handler"; + }); + register("postfix", path, () => { + const response = new HTTPResult(202, "postfix"); + response.addHeader("X-Postfix", "3"); + return response; + }); + + const response = await request(port, path); + assert.equal(response.status, 202); + assert.equal(response.body, "postfix"); + assert.equal(response.headers["x-prefix"], "1"); + assert.equal(response.headers["x-handler"], "2"); + assert.equal(response.headers["x-postfix"], "3"); + }); + + it("keeps the status and body of a returned HTTPResult", async () => { + const path = `${ROUTE_ROOT}/result`; + register("handler", path, () => new HTTPResult(201, { created: true })); + + const response = await request(port, path); + assert.equal(response.status, 201); + assert.equal(response.body, '{"created":true}'); + assert.equal(response.headers["content-type"], "application/json"); + }); + + it("creates the Not Found response only for a route miss", async () => { + const path = `${ROUTE_ROOT}/missing`; + let monitorStatus = 0; + let monitorBody: unknown; + register("monitor", path, ({ response }) => { + monitorStatus = response.getStatus(); + monitorBody = response.getBody(); + }); + + const response = await request(port, path); + assert.equal(response.status, 404); + assert.equal(response.body, "Not Found"); + assert.equal(monitorStatus, 404); + assert.equal(monitorBody, "Not Found"); + }); + + it("isolates monitor response snapshots", async () => { + const path = `${ROUTE_ROOT}/monitor`; + let snapshot: HTTPResult | undefined; + register("handler", path, ({ response }) => { + response.addHeader("X-Handler", "kept"); + return "body"; + }); + register("monitor", path, ({ response }) => { + snapshot = response; + response.setBody("changed"); + response.addHeader("X-Monitor", "isolated"); + }); + + const response = await request(port, path); + assert.equal(response.body, "body"); + assert.equal(response.headers["x-handler"], "kept"); + assert.equal(response.headers["x-monitor"], undefined); + assert.equal(snapshot?.getBody(), "changed"); + }); + + it("preserves headers while converting thrown errors", async () => { + const path = `${ROUTE_ROOT}/error`; + register("prefix", path, ({ response }) => + response.addHeader("X-Prefix", "kept"), + ); + register("handler", path, () => { + throw new Error("failure"); + }); + + const response = await request(port, path); + assert.equal(response.status, 500); + assert.equal(response.body, "failure"); + assert.equal(response.headers["x-prefix"], "kept"); + }); + + it("sends HEAD through HTTPResult without a response body", async () => { + const path = `${ROUTE_ROOT}/head`; + register("handler", path, () => "not sent"); + + const response = await request(port, path, "HEAD"); + assert.equal(response.status, 200); + assert.equal(response.body, ""); + assert.equal(response.headers["content-type"], "text/plain"); + }); + + it("keeps stream status, headers, and body", async () => { + const path = `${ROUTE_ROOT}/stream`; + register("handler", path, ({ response }) => { + response.addHeader("X-Stream", "kept"); + response.getWriteStream("text/event-stream", 206).end("event"); + }); + + const response = await request(port, path); + assert.equal(response.status, 206); + assert.equal(response.body, "event"); + assert.equal(response.headers["content-type"], "text/event-stream"); + assert.equal(response.headers["x-stream"], "kept"); + }); +});