Skip to content

Commit 5e20ada

Browse files
committed
Add support for 1xx codes
1 parent 9516261 commit 5e20ada

4 files changed

Lines changed: 335 additions & 0 deletions

File tree

src/endpoints/http-index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,4 @@ export * from './http/redirect.js';
6464
export * from './http/bytes.js';
6565
export * from './http/stream.js';
6666
export * from './http/stream-bytes.js';
67+
export * from './http/informational.js';
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { STATUS_CODES } from 'http';
2+
import { StatusError } from '@httptoolkit/util';
3+
import { HttpEndpoint, HttpHandler, HttpResponse } from '../http-index.js';
4+
import { httpCustomResponses } from '../groups.js';
5+
6+
const PREFIX = '/info/';
7+
8+
// 101 is reserved for protocol upgrades and isn't meaningful as a standalone
9+
// informational response, so we exclude it.
10+
const isSupportedInformationalCode = (code: number): boolean =>
11+
code >= 100 && code < 200 && code !== 101;
12+
13+
const parseCode = (path: string): number => {
14+
const rest = path.slice(PREFIX.length);
15+
const end = rest.indexOf('/');
16+
const codeStr = end === -1 ? rest : rest.slice(0, end);
17+
return parseInt(codeStr, 10);
18+
};
19+
20+
const getRemainingPath = (path: string): string | undefined => {
21+
const idx = path.indexOf('/', PREFIX.length);
22+
return idx !== -1 ? path.slice(idx) : undefined;
23+
};
24+
25+
const sendInformational = (
26+
res: HttpResponse,
27+
code: number,
28+
headers: Record<string, string | string[]>
29+
): void => {
30+
const writeRaw = (res as { _writeRaw?: (chunk: string, encoding: string) => void })._writeRaw;
31+
if (typeof writeRaw === 'function') {
32+
let raw = `HTTP/1.1 ${code} ${STATUS_CODES[code] ?? ''}\r\n`;
33+
for (const [name, value] of Object.entries(headers)) {
34+
for (const v of Array.isArray(value) ? value : [value]) {
35+
raw += `${name}: ${v}\r\n`;
36+
}
37+
}
38+
raw += '\r\n';
39+
writeRaw.call(res, raw, 'latin1');
40+
return;
41+
}
42+
43+
const stream = (res as { stream?: { additionalHeaders?: (h: object) => void } }).stream;
44+
if (stream?.additionalHeaders) {
45+
stream.additionalHeaders({ ':status': code, ...headers });
46+
return;
47+
}
48+
49+
throw new StatusError(500, 'No mechanism available to send informational response');
50+
};
51+
52+
const handle: HttpHandler = (_req, res, { path, query }) => {
53+
const code = parseCode(path);
54+
55+
const headers: Record<string, string | string[]> = {};
56+
const links = query.getAll('link');
57+
if (links.length > 0) headers['link'] = links;
58+
59+
sendInformational(res, code, headers);
60+
61+
if (getRemainingPath(path)) {
62+
return; // Chain continues to the next endpoint as the final response
63+
}
64+
65+
const body = JSON.stringify({
66+
sent: { code, headers }
67+
}, null, 2);
68+
res.writeHead(200, {
69+
'content-type': 'application/json',
70+
'content-length': Buffer.byteLength(body)
71+
});
72+
res.end(body);
73+
};
74+
75+
export const informational: HttpEndpoint = {
76+
matchPath: (path) => {
77+
if (!path.startsWith(PREFIX)) return false;
78+
const code = parseCode(path);
79+
if (isNaN(code)) {
80+
throw new StatusError(400, `Invalid status code in ${path}`);
81+
}
82+
if (!isSupportedInformationalCode(code)) {
83+
throw new StatusError(400,
84+
`${code} is not a 1xx informational status code. ` +
85+
`Use any code in 100-199 except 101 (which is reserved for protocol upgrades).`
86+
);
87+
}
88+
return true;
89+
},
90+
handle,
91+
getRemainingPath,
92+
meta: {
93+
path: '/info/{code}',
94+
description:
95+
'Sends a 1xx informational response (100, 102, or 103) before the final response. ' +
96+
'Supports all codes except 101 which is reserved for protocol upgrades (e.g. websockets). ' +
97+
'Use `?link=...` (repeatable) to attach Link headers for testing 103 Early Hints. ' +
98+
'Chains with other endpoints: e.g. /info/103/status/404 sends 103 then 404.',
99+
examples: [
100+
'/info/102',
101+
'/info/103?link=%3C/style.css%3E;rel=preload;as=style',
102+
'/info/110/info/199/echo'
103+
],
104+
group: httpCustomResponses
105+
}
106+
};

src/endpoints/http/status.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ export const status: HttpEndpoint = {
1919
if (isNaN(statusCode)) {
2020
throw new StatusError(400, `Invalid status code in ${path}`);
2121
}
22+
if (statusCode >= 100 && statusCode < 200) {
23+
throw new StatusError(400,
24+
`1xx codes are informational and cannot be used as a final response. ` +
25+
`Use /info/${statusCode} instead.`
26+
);
27+
}
2228
return true;
2329
},
2430
handle,

test/informational.spec.ts

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
import * as net from 'net';
2+
import * as http from 'http';
3+
import * as http2 from 'http2';
4+
import * as streamConsumers from 'stream/consumers';
5+
6+
import { expect } from 'chai';
7+
import { DestroyableServer, makeDestroyable } from 'destroyable-server';
8+
9+
import { createTestServer } from './test-helpers.js';
10+
11+
interface Informational {
12+
statusCode: number;
13+
headers: http.IncomingHttpHeaders;
14+
}
15+
16+
async function h1Request(port: number, path: string): Promise<{
17+
informationals: Informational[];
18+
finalStatusCode: number;
19+
body: string;
20+
}> {
21+
return new Promise((resolve, reject) => {
22+
const informationals: Informational[] = [];
23+
24+
const req = http.request({ port, path, method: 'GET' }, (res) => {
25+
const chunks: Buffer[] = [];
26+
res.on('data', (c) => chunks.push(c));
27+
res.on('end', () => {
28+
resolve({
29+
informationals,
30+
finalStatusCode: res.statusCode!,
31+
body: Buffer.concat(chunks).toString('utf8')
32+
});
33+
});
34+
res.on('error', reject);
35+
});
36+
37+
req.on('information', (info) => {
38+
informationals.push({ statusCode: info.statusCode, headers: info.headers });
39+
});
40+
req.on('error', reject);
41+
req.end();
42+
});
43+
}
44+
45+
describe("Informational endpoint", () => {
46+
47+
let server: DestroyableServer;
48+
let serverPort: number;
49+
50+
beforeEach(async () => {
51+
server = makeDestroyable(await createTestServer());
52+
await new Promise<void>((resolve) => server.listen(resolve));
53+
serverPort = (server.address() as net.AddressInfo).port;
54+
});
55+
56+
afterEach(async () => {
57+
await server.destroy();
58+
});
59+
60+
it("sends a 103 Early Hints with a link header, then a 200", async () => {
61+
const result = await h1Request(serverPort,
62+
'/info/103?link=%3C/style.css%3E%3Brel%3Dpreload%3Bas%3Dstyle'
63+
);
64+
65+
expect(result.informationals).to.have.length(1);
66+
expect(result.informationals[0].statusCode).to.equal(103);
67+
expect(result.informationals[0].headers['link']).to.equal(
68+
'</style.css>;rel=preload;as=style'
69+
);
70+
71+
expect(result.finalStatusCode).to.equal(200);
72+
const body = JSON.parse(result.body);
73+
expect(body.sent.code).to.equal(103);
74+
});
75+
76+
it("sends multiple link headers in a single 103", async () => {
77+
const result = await h1Request(serverPort,
78+
'/info/103?link=%3C/a.css%3E&link=%3C/b.js%3E'
79+
);
80+
81+
// Node folds repeated Link headers into a comma-separated string.
82+
expect(result.informationals[0].headers['link']).to.equal(
83+
'</a.css>, </b.js>'
84+
);
85+
});
86+
87+
it("supports non-standard 1xx codes such as 199", async () => {
88+
const result = await h1Request(serverPort, '/info/199');
89+
expect(result.informationals.map(i => i.statusCode)).to.deep.equal([199]);
90+
expect(result.finalStatusCode).to.equal(200);
91+
});
92+
93+
it("chains multiple /info hops to send multiple informationals", async () => {
94+
const result = await h1Request(serverPort, '/info/103/info/103/info/100/anything');
95+
expect(result.informationals.map(i => i.statusCode)).to.deep.equal([103, 103, 100]);
96+
expect(result.finalStatusCode).to.equal(200);
97+
});
98+
99+
it("chains: /info/103/anything sends 103 then runs /anything", async () => {
100+
const result = await h1Request(serverPort, '/info/103/anything');
101+
expect(result.informationals.map(i => i.statusCode)).to.deep.equal([103]);
102+
expect(result.finalStatusCode).to.equal(200);
103+
const body = JSON.parse(result.body);
104+
expect(body.url).to.match(/\/anything$/);
105+
});
106+
107+
it("chains: /info/103/status/418 sends 103 then a 418", async () => {
108+
const result = await h1Request(serverPort, '/info/103/status/418');
109+
expect(result.informationals.map(i => i.statusCode)).to.deep.equal([103]);
110+
expect(result.finalStatusCode).to.equal(418);
111+
});
112+
113+
it("rejects 101 with a 400 (reserved for upgrades)", async () => {
114+
const response = await fetch(`http://localhost:${serverPort}/info/101`);
115+
expect(response.status).to.equal(400);
116+
});
117+
118+
it("rejects non-1xx codes with a 400", async () => {
119+
const response = await fetch(`http://localhost:${serverPort}/info/200`);
120+
expect(response.status).to.equal(400);
121+
});
122+
123+
it("rejects non-numeric codes with a 400", async () => {
124+
const response = await fetch(`http://localhost:${serverPort}/info/abc`);
125+
expect(response.status).to.equal(400);
126+
});
127+
128+
});
129+
130+
describe("Status endpoint with 1xx codes", () => {
131+
132+
let server: DestroyableServer;
133+
let serverPort: number;
134+
135+
beforeEach(async () => {
136+
server = makeDestroyable(await createTestServer());
137+
await new Promise<void>((resolve) => server.listen(resolve));
138+
serverPort = (server.address() as net.AddressInfo).port;
139+
});
140+
141+
afterEach(async () => {
142+
await server.destroy();
143+
});
144+
145+
it("rejects /status/100 with a 400 pointing to /info", async () => {
146+
const response = await fetch(`http://localhost:${serverPort}/status/100`);
147+
expect(response.status).to.equal(400);
148+
const body = await response.text();
149+
expect(body).to.include('/info/100');
150+
});
151+
152+
});
153+
154+
describe("Informational endpoint over HTTP/2", () => {
155+
156+
let server: DestroyableServer;
157+
let serverPort: number;
158+
159+
beforeEach(async () => {
160+
server = makeDestroyable(await createTestServer());
161+
await new Promise<void>((resolve) => server.listen(resolve));
162+
serverPort = (server.address() as net.AddressInfo).port;
163+
});
164+
165+
afterEach(async () => {
166+
await server.destroy();
167+
});
168+
169+
async function h2Request(path: string): Promise<{
170+
informationals: Array<{ statusCode: number; headers: http2.IncomingHttpHeaders }>;
171+
finalStatusCode: number;
172+
body: string;
173+
}> {
174+
const client = http2.connect(`http://localhost:${serverPort}`);
175+
try {
176+
const request = client.request({ ':path': path, ':method': 'GET' });
177+
const informationals: Array<{ statusCode: number; headers: http2.IncomingHttpHeaders }> = [];
178+
let finalHeaders: http2.IncomingHttpHeaders & http2.IncomingHttpStatusHeader | undefined;
179+
180+
await new Promise<void>((resolve, reject) => {
181+
request.on('headers', (headers) => {
182+
informationals.push({
183+
statusCode: headers[':status'] as number,
184+
headers
185+
});
186+
});
187+
request.on('response', (headers) => {
188+
finalHeaders = headers;
189+
resolve();
190+
});
191+
request.on('error', reject);
192+
});
193+
194+
const body = await streamConsumers.text(request);
195+
return {
196+
informationals,
197+
finalStatusCode: finalHeaders![':status']!,
198+
body
199+
};
200+
} finally {
201+
client.close();
202+
}
203+
}
204+
205+
it("sends a 103 with a Link header before the final response", async () => {
206+
const result = await h2Request('/info/103?link=%3C/style.css%3E');
207+
208+
expect(result.informationals.map(i => i.statusCode)).to.deep.equal([103]);
209+
expect(result.informationals[0].headers['link']).to.equal('</style.css>');
210+
expect(result.finalStatusCode).to.equal(200);
211+
});
212+
213+
it("chains 1xx with /anything as the final response", async () => {
214+
const result = await h2Request('/info/103/anything');
215+
216+
expect(result.informationals.map(i => i.statusCode)).to.deep.equal([103]);
217+
expect(result.finalStatusCode).to.equal(200);
218+
const body = JSON.parse(result.body);
219+
expect(body.url).to.match(/\/anything$/);
220+
});
221+
222+
});

0 commit comments

Comments
 (0)