diff --git a/docs/3.http-handling.md b/docs/3.http-handling.md index 71b033c..30e4e71 100644 --- a/docs/3.http-handling.md +++ b/docs/3.http-handling.md @@ -82,6 +82,9 @@ result.removeHeader("X-Rate-Limit"); // Get all headers const headers = result.getHeaders(); + +// Read headers without creating the mutable store +const existingHeaders = result.peekHeaders(); ``` ### The `withHeaders` static method diff --git a/src/index.ts b/src/index.ts index 6a61fab..f9d2283 100644 --- a/src/index.ts +++ b/src/index.ts @@ -65,7 +65,7 @@ export class HTTPResult { /** * Additional response headers */ - private readonly headers: Record = {}; + private headers?: Record; /** * Create a new HTTPResult from the given body or previous HTTPResult and the provided headers. @@ -150,6 +150,13 @@ export class HTTPResult { return this.contentType; } + private getMutableHeaders(): Record { + if (!this.headers) { + this.headers = {}; + } + return this.headers; + } + /** * Add an additional header to the response. * @@ -157,7 +164,7 @@ export class HTTPResult { * @param value Header value */ public addHeader(name: string, value: string) { - this.headers[name] = value; + this.getMutableHeaders()[name] = value; } /** @@ -166,7 +173,9 @@ export class HTTPResult { * @param name Header name */ public removeHeader(name: string) { - delete this.headers[name]; + if (this.headers) { + delete this.headers[name]; + } } /** @@ -175,6 +184,15 @@ export class HTTPResult { * @returns Headers object */ public getHeaders(): Record { + return this.getMutableHeaders(); + } + + /** + * Read the headers without creating the headers key-value store. + * + * @returns Current headers, or undefined when no header has been added or retrieved + */ + public peekHeaders(): Readonly> | undefined { return this.headers; } diff --git a/src/tests/index.test.ts b/src/tests/index.test.ts index 795e065..15a898b 100644 --- a/src/tests/index.test.ts +++ b/src/tests/index.test.ts @@ -1232,6 +1232,19 @@ interface StubServerResponse { end: () => void; } +interface CapturedServerResponse { + req: StubRequest; + headers: Record; + status?: number; + body?: string; + ended: boolean; + writeHead: ( + status: number, + headers?: Record, + ) => CapturedServerResponse; + end: (body?: string) => void; +} + function createResponseStub(): ServerResponse { const stub: StubServerResponse = { req: { method: "GET", url: "/boom" }, @@ -1241,6 +1254,95 @@ function createResponseStub(): ServerResponse { return stub as unknown as ServerResponse; } +function createCapturedResponse(): CapturedServerResponse { + const response: CapturedServerResponse = { + req: { method: "GET", url: "/result" }, + headers: {}, + ended: false, + writeHead(status, headers) { + response.status = status; + for (const [name, value] of Object.entries(headers ?? {})) { + response.headers[name.toLowerCase()] = value; + } + return response; + }, + end(body) { + response.body = body; + response.ended = true; + }, + }; + return response; +} + +describe("HTTPResult response contract", () => { + it("Serializes object bodies immediately", () => { + const body = { message: "before" }; + const result = new LocalHTTPResult(200, body); + + body.message = "after"; + + assert.equal(result.getBody(), '{"message":"before"}'); + assert.equal(result.getContentType(), "application/json"); + }); + + it("Peeks at headers without creating the mutable store", () => { + const result = new LocalHTTPResult(); + + assert.equal(result.peekHeaders(), undefined); + + result.addHeader("X-Added", "yes"); + assert.equal(result.peekHeaders()?.["X-Added"], "yes"); + + const headers = result.getHeaders(); + assert.strictEqual(result.peekHeaders(), headers); + headers["X-Mutable"] = "yes"; + assert.equal(result.peekHeaders()?.["X-Mutable"], "yes"); + }); + + it("Sends the current own headers, content type, status, and body", () => { + const result = new LocalHTTPResult(202, { ok: true }); + const headers = result.getHeaders(); + Object.setPrototypeOf(headers, { "X-Inherited": "excluded" }); + headers["X-Current"] = "current"; + result.addHeader("Content-Type", "text/html"); + const response = createCapturedResponse(); + + result.sendResponse(response as unknown as ServerResponse); + + assert.equal(response.status, 202); + assert.equal(response.body, '{"ok":true}'); + assert.equal(response.headers["x-current"], "current"); + assert.equal(response.headers["x-inherited"], undefined); + assert.equal(response.headers["content-type"], "application/json"); + }); + + it("Sends HEAD without a body and closes an attached stream", () => { + const result = new LocalHTTPResult(204, { ignored: true }); + const stream = result.getWriteStream("text/event-stream", 206); + const response = createCapturedResponse(); + + result.sendHeadResponse(response as unknown as ServerResponse); + + assert.equal(response.status, 206); + assert.equal(response.body, undefined); + assert.equal(response.headers["content-type"], "text/event-stream"); + assert.equal(response.ended, true); + assert.equal(stream.writableEnded, true); + }); + + it("Ends an aborted stream response without piping", () => { + const result = new LocalHTTPResult(); + result.getWriteStream(); + const response = createCapturedResponse(); + + result.sendResponse(response as unknown as ServerResponse, true); + + assert.equal(response.status, 200); + assert.equal(response.body, ""); + assert.equal(response.ended, true); + }); +}); + describe("HTTPResult error logging", () => { const logs: Log[] = []; const captureLog = (log: Log) => logs.push(log);