Skip to content

Commit 966d6ea

Browse files
authored
Automatic support for preconditions in HEAD & GET requests (#39)
2 parents 3730676 + 79fdd5e commit 966d6ea

File tree

3 files changed

+73
-4
lines changed

3 files changed

+73
-4
lines changed

src/Server.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import http from "node:http";
22
import packageJson from "../package.json" with {type: "json"};
33
import {Request} from "./Request.js";
4+
import {EmptyResponse} from "./response/index.js";
45
import {Response} from "./response/Response.js";
56
import {RouteRegistry} from "./routing/RouteRegistry.js";
67
import {ServerErrorRegistry} from "./ServerErrorRegistry.js";
@@ -16,6 +17,7 @@ class Server {
1617
public readonly routes = new RouteRegistry();
1718
private readonly server: http.Server;
1819
private readonly copyOrigin: boolean;
20+
private readonly handleConditionalRequests: boolean;
1921

2022
/**
2123
* This server's error registry.
@@ -36,6 +38,7 @@ class Server {
3638
this.globalHeaders.set("Server", `cldn/${packageJson.version}`);
3739

3840
this.copyOrigin = options.copyOrigin ?? false;
41+
this.handleConditionalRequests = options.handleConditionalRequests ?? true;
3942

4043
this.server.listen(options.port);
4144
}
@@ -80,7 +83,53 @@ class Server {
8083
response = this.errors._get(ServerErrorRegistry.ErrorCodes.INTERNAL, apiRequest);
8184
}
8285
}
83-
response._send(res, this, apiRequest);
86+
await this.sendResponse(response, res, apiRequest);
87+
}
88+
89+
private async sendResponse(response: Response, res: http.ServerResponse, req: Request): Promise<void> {
90+
conditional: if (
91+
this.handleConditionalRequests
92+
&& response.statusCode === 200
93+
&& [Request.Method.GET, Request.Method.HEAD].includes(req.method)
94+
) {
95+
const responseHeaders = response.allHeaders(res, this, req);
96+
const etag = responseHeaders.get("etag");
97+
const lastModified = responseHeaders.has("last-modified")
98+
? new Date(responseHeaders.get("last-modified")!)
99+
: null;
100+
if (etag === null && lastModified === null)
101+
break conditional;
102+
103+
if (req.headers.has("if-match")) {
104+
if (!this.getETags(req.headers.get("if-match")!)
105+
.filter(t => !t.startsWith("W/"))
106+
.includes(etag!))
107+
return this.errors._get(ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED, req)._send(res, this, req);
108+
}
109+
else if (req.headers.has("if-unmodified-since")) {
110+
if (lastModified === null
111+
|| lastModified.getTime() > new Date(req.headers.get("if-unmodified-since")!).getTime())
112+
return this.errors._get(ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED, req)._send(res, this, req);
113+
}
114+
115+
if (req.headers.has("if-none-match")) {
116+
if (this.getETags(req.headers.get("if-none-match")!)
117+
.includes(etag!))
118+
return new EmptyResponse(responseHeaders, 304)._send(res, this, req);
119+
}
120+
else if (req.headers.has("if-modified-since")) {
121+
if (lastModified !== null
122+
&& lastModified.getTime() <= new Date(req.headers.get("if-modified-since")!).getTime())
123+
return new EmptyResponse(responseHeaders, 304)._send(res, this, req);
124+
}
125+
}
126+
response._send(res, this, req);
127+
}
128+
129+
private getETags(header: string) {
130+
return header
131+
.split(",")
132+
.map(t => t.trim())
84133
}
85134

86135
public close(): Promise<void> {
@@ -119,6 +168,13 @@ namespace Server {
119168
* @default false
120169
*/
121170
readonly copyOrigin?: boolean;
171+
172+
/**
173+
* Automatically handle conditional requests for GET and HEAD requests that result in a 200 status code.
174+
* `If-Range` headers are ignored.
175+
* @default true
176+
*/
177+
readonly handleConditionalRequests?: boolean;
122178
}
123179
}
124180

src/ServerErrorRegistry.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class ServerErrorRegistry {
2121

2222
[ServerErrorRegistry.ErrorCodes.INTERNAL]:
2323
new TextResponse("An internal error occurred.", 500),
24+
25+
[ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED]:
26+
new TextResponse("Precondition failed.", 412),
2427
};
2528
}
2629

@@ -49,6 +52,7 @@ namespace ServerErrorRegistry {
4952
BAD_URL,
5053
NO_ROUTE,
5154
INTERNAL,
55+
PRECONDITION_FAILED,
5256
}
5357
}
5458

src/response/Response.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export abstract class Response {
1010
/**
1111
* The HTTP response status code to send.
1212
*/
13-
protected readonly statusCode: number;
13+
public readonly statusCode: number;
1414

1515
/**
1616
* The HTTP response headers to send.
@@ -43,9 +43,10 @@ export abstract class Response {
4343
}
4444

4545
/**
46-
* Set the HTTP response status code and headers.
46+
* All (final) headers to send to the client.
47+
* @internal
4748
*/
48-
protected writeHead(res: http.ServerResponse, server: Server, req?: Request) {
49+
public allHeaders(res: http.ServerResponse, server: Server, req?: Request) {
4950
const headers = new Headers(this.headers);
5051
if (req !== undefined)
5152
for (const [key, value] of req._responseHeaders)
@@ -62,6 +63,14 @@ export abstract class Response {
6263
headers.set("connection", "keep-alive");
6364
headers.set("keep-alive", "timeout=" + server._keepAliveTimeout);
6465
}
66+
return headers;
67+
}
68+
69+
/**
70+
* Set the HTTP response status code and headers.
71+
*/
72+
protected writeHead(res: http.ServerResponse, server: Server, req?: Request) {
73+
const headers = this.allHeaders(res, server, req);
6574
for (const [key, value] of Array.from(headers.entries())
6675
.sort((a, b) => a[0].localeCompare(b[0])))
6776
res.setHeader(key, value);

0 commit comments

Comments
 (0)