1/**
2 * Answers `Expect: 100-continue` on behalf of workerd, which never sends an
3 * interim `100 Continue`. Without it a client that asks (e.g. the AWS SDK v3
4 * in Node for uploads of 2 MB or more, curl for large bodies) waits out its
5 * own timeout before sending the body.
6 *
7 * The proxy relays raw bytes, so this is an observer over the client's
8 * outbound stream: it tracks HTTP/1.1 request framing (head, then a
9 * `Content-Length` or chunked body) only to find where each request head
10 * starts, and never modifies forwarded bytes. When a complete head carries
11 * the expectation, it writes `HTTP/1.1 100 Continue` to the client before
12 * the head reaches the upstream, so the interim response always precedes
13 * the final one.
14 *
15 * It is best-effort and fails open: an upgrade (WebSocket), `CONNECT`, an
16 * oversized or malformed head, or malformed chunk framing stops tracking
17 * for the rest of the connection, which is then a plain pipe again (the
18 * client falls back to its own timeout, as before). Clients that send the
19 * expectation wait before sending the body and do not pipeline, so the
20 * previous response on the connection is complete when the next head
21 * arrives and the interim response never lands inside it.
22 */
23
24/** Largest request head (request line + headers) the observer will buffer. */
25const MAX_HEAD_BYTES = 64 * 1024;
26
27/** Largest chunk-size line (hex size + extensions) accepted. */
28const MAX_CHUNK_LINE_BYTES = 4 * 1024;
29
30export const CONTINUE_RESPONSE = "HTTP/1.1 100 Continue\r\n\r\n";
31
32const CRLF = Buffer.from("\r\n");
33// Header bytes are opaque octets; decode 1:1 so framing offsets hold.
34const latin1 = new TextDecoder("latin1");
35const HEAD_END = Buffer.from("\r\n\r\n");
36
37type State =
38 /** Accumulating a request head. */
39 | { readonly _tag: "Head"; buffer: Buffer }
40 /** Skipping a `Content-Length` body. */
41 | { readonly _tag: "Body"; remaining: number }
42 /** Reading a chunk-size line. */
43 | { readonly _tag: "ChunkSize"; buffer: Buffer }
44 /** Skipping chunk data plus its trailing CRLF. */
45 | { readonly _tag: "ChunkData"; remaining: number }
46 /** Reading trailer lines after the last chunk, until an empty line. */
47 | { readonly _tag: "Trailers"; buffer: Buffer }
48 /** Not HTTP we understand (upgrade, CONNECT, malformed): stop tracking. */
49 | { readonly _tag: "Off" };
50
51const head = (): State => ({ _tag: "Head", buffer: Buffer.alloc(0) });
52
53interface ParsedHead {
54 readonly method: string;
55 readonly version: string;
56 readonly headers: ReadonlyMap<string, string>;
57}
58
59const parseHead = (raw: Buffer): ParsedHead | undefined => {
60 const lines = latin1.decode(raw).split("\r\n");
61 const requestLine = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+) \S+ (HTTP\/\d\.\d)$/.exec(
62 lines[0] ?? "",
63 );
64 if (requestLine === null) return undefined;
65 const headers = new Map<string, string>();
66 for (const line of lines.slice(1)) {
67 if (line === "") continue;
68 const colon = line.indexOf(":");
69 if (colon <= 0) return undefined;
70 const name = line.slice(0, colon).trim().toLowerCase();
71 const value = line.slice(colon + 1).trim();
72 const existing = headers.get(name);
73 headers.set(name, existing === undefined ? value : `${existing}, ${value}`);
74 }
75 return { method: requestLine[1]!, version: requestLine[2]!, headers };
76};
77
78/**
79 * Creates an observer for one client connection. Feed it every chunk the
80 * client sends, in order; it calls `sendContinue` once per request head
81 * that expects `100-continue`.
82 */
83export const makeExpectContinueObserver = (sendContinue: () => void) => {
84 let state: State = head();
85
86 /** State after a complete head: the body to skip, or off. */
87 const afterHead = (parsed: ParsedHead | undefined): State => {
88 if (parsed === undefined) return { _tag: "Off" };
89 const { method, version, headers } = parsed;
90 // A tunnel follows: nothing after this head is HTTP framing.
91 if (method === "CONNECT" || headers.has("upgrade")) return { _tag: "Off" };
92 // HTTP/1.0 requests' expectations must be ignored (RFC 9110 §10.1.1)
93 if (
94 version === "HTTP/1.1" &&
95 headers.get("expect")?.toLowerCase() === "100-continue"
96 ) {
97 sendContinue();
98 }
99 const transferEncoding = headers.get("transfer-encoding")?.toLowerCase();
100 if (transferEncoding !== undefined) {
101 // Only a final `chunked` coding has self-delimiting framing
102 return transferEncoding.split(",").at(-1)?.trim() === "chunked"
103 ? { _tag: "ChunkSize", buffer: Buffer.alloc(0) }
104 : { _tag: "Off" };
105 }
106 const contentLength = headers.get("content-length");
107 if (contentLength === undefined) return head();
108 if (!/^\d+$/.test(contentLength)) return { _tag: "Off" };
109 const length = Number(contentLength);
110 return length === 0 ? head() : { _tag: "Body", remaining: length };
111 };
112
113 const step = (chunk: Buffer): Buffer => {
114 switch (state._tag) {
115 case "Off":
116 return Buffer.alloc(0);
117 case "Head": {
118 const buffer = Buffer.concat([state.buffer, chunk]);
119 // Tolerate CRLFs between messages (RFC 9112 §2.2)
120 let start = 0;
121 while (buffer.subarray(start, start + 2).equals(CRLF)) start += 2;
122 const end = buffer.indexOf(HEAD_END, start);
123 if (end === -1) {
124 state =
125 buffer.length - start > MAX_HEAD_BYTES
126 ? { _tag: "Off" }
127 : { _tag: "Head", buffer: buffer.subarray(start) };
128 return Buffer.alloc(0);
129 }
130 state = afterHead(parseHead(buffer.subarray(start, end)));
131 return buffer.subarray(end + HEAD_END.length);
132 }
133 case "Body": {
134 const taken = Math.min(state.remaining, chunk.length);
135 state.remaining -= taken;
136 if (state.remaining === 0) state = head();
137 return chunk.subarray(taken);
138 }
139 case "ChunkSize": {
140 const buffer = Buffer.concat([state.buffer, chunk]);
141 const end = buffer.indexOf(CRLF);
142 if (end === -1) {
143 state =
144 buffer.length > MAX_CHUNK_LINE_BYTES
145 ? { _tag: "Off" }
146 : { _tag: "ChunkSize", buffer };
147 return Buffer.alloc(0);
148 }
149 const sizeField = latin1
150 .decode(buffer.subarray(0, end))
151 .split(";")[0]!
152 .trim();
153 if (!/^[0-9a-fA-F]+$/.test(sizeField)) {
154 state = { _tag: "Off" };
155 return Buffer.alloc(0);
156 }
157 const size = Number.parseInt(sizeField, 16);
158 state =
159 size === 0
160 ? { _tag: "Trailers", buffer: Buffer.alloc(0) }
161 : { _tag: "ChunkData", remaining: size + CRLF.length };
162 return buffer.subarray(end + CRLF.length);
163 }
164 case "ChunkData": {
165 const taken = Math.min(state.remaining, chunk.length);
166 state.remaining -= taken;
167 if (state.remaining === 0) {
168 state = { _tag: "ChunkSize", buffer: Buffer.alloc(0) };
169 }
170 return chunk.subarray(taken);
171 }
172 case "Trailers": {
173 const buffer = Buffer.concat([state.buffer, chunk]);
174 let offset = 0;
175 for (;;) {
176 const end = buffer.indexOf(CRLF, offset);
177 if (end === -1) {
178 const rest = buffer.subarray(offset);
179 state =
180 rest.length > MAX_HEAD_BYTES
181 ? { _tag: "Off" }
182 : { _tag: "Trailers", buffer: rest };
183 return Buffer.alloc(0);
184 }
185 if (end === offset) {
186 // Empty line: the message is complete
187 state = head();
188 return buffer.subarray(end + CRLF.length);
189 }
190 offset = end + CRLF.length;
191 }
192 }
193 }
194 };
195
196 return {
197 /** Observe the next chunk the client sent. */
198 observe: (chunk: Buffer) => {
199 let rest = chunk;
200 while (rest.length > 0 && state._tag !== "Off") rest = step(rest);
201 },
202 /** Whether tracking stopped (exposed for tests). */
203 get stopped() {
204 return state._tag === "Off";
205 },
206 };
207};