Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions docs/3.http-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 21 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export class HTTPResult {
/**
* Additional response headers
*/
private readonly headers: Record<string, string> = {};
private headers?: Record<string, string>;

/**
* Create a new HTTPResult from the given body or previous HTTPResult and the provided headers.
Expand Down Expand Up @@ -150,14 +150,21 @@ export class HTTPResult {
return this.contentType;
}

private getMutableHeaders(): Record<string, string> {
if (!this.headers) {
this.headers = {};
}
return this.headers;
}

/**
* Add an additional header to the response.
*
* @param name Header name
* @param value Header value
*/
public addHeader(name: string, value: string) {
this.headers[name] = value;
this.getMutableHeaders()[name] = value;
}

/**
Expand All @@ -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];
}
}

/**
Expand All @@ -175,6 +184,15 @@ export class HTTPResult {
* @returns Headers object
*/
public getHeaders(): Record<string, string> {
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<Record<string, string>> | undefined {
return this.headers;
}

Expand Down
102 changes: 102 additions & 0 deletions src/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1232,6 +1232,19 @@ interface StubServerResponse {
end: () => void;
}

interface CapturedServerResponse {
req: StubRequest;
headers: Record<string, string>;
status?: number;
body?: string;
ended: boolean;
writeHead: (
status: number,
headers?: Record<string, string>,
) => CapturedServerResponse;
end: (body?: string) => void;
}

function createResponseStub(): ServerResponse {
const stub: StubServerResponse = {
req: { method: "GET", url: "/boom" },
Expand All @@ -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);
Expand Down
Loading