@volter/twin-temporal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +104 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +36 -0
- package/dist/src/generated/hpack.json +1 -0
- package/dist/src/generated/temporal-api.json +1 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.js +70 -0
- package/dist/src/temporal-budget.d.ts +31 -0
- package/dist/src/temporal-budget.js +50 -0
- package/dist/src/temporal-capabilities.d.ts +7 -0
- package/dist/src/temporal-capabilities.js +650 -0
- package/dist/src/temporal-conformance.d.ts +8 -0
- package/dist/src/temporal-conformance.js +54 -0
- package/dist/src/temporal-connector.d.ts +32 -0
- package/dist/src/temporal-connector.js +121 -0
- package/dist/src/temporal-engine.d.ts +274 -0
- package/dist/src/temporal-engine.js +2132 -0
- package/dist/src/temporal-grpc.d.ts +51 -0
- package/dist/src/temporal-grpc.js +124 -0
- package/dist/src/temporal-h2.d.ts +40 -0
- package/dist/src/temporal-h2.js +644 -0
- package/dist/src/temporal-history.d.ts +27 -0
- package/dist/src/temporal-history.js +85 -0
- package/dist/src/temporal-http.d.ts +17 -0
- package/dist/src/temporal-http.js +160 -0
- package/dist/src/temporal-json.d.ts +7 -0
- package/dist/src/temporal-json.js +68 -0
- package/dist/src/temporal-proto.d.ts +17 -0
- package/dist/src/temporal-proto.js +46 -0
- package/dist/src/temporal-server.d.ts +25 -0
- package/dist/src/temporal-server.js +56 -0
- package/dist/src/temporal-twin.d.ts +25 -0
- package/dist/src/temporal-twin.js +100 -0
- package/dist/src/temporal-visibility.d.ts +49 -0
- package/dist/src/temporal-visibility.js +293 -0
- package/package.json +59 -0
- package/src/cli.ts +36 -0
- package/src/generated/hpack.json +1 -0
- package/src/generated/temporal-api.json +1 -0
- package/src/index.ts +81 -0
- package/src/temporal-budget.ts +75 -0
- package/src/temporal-capabilities.ts +679 -0
- package/src/temporal-conformance.ts +52 -0
- package/src/temporal-connector.ts +118 -0
- package/src/temporal-engine.ts +2176 -0
- package/src/temporal-grpc.ts +139 -0
- package/src/temporal-h2.ts +614 -0
- package/src/temporal-history.ts +99 -0
- package/src/temporal-http.ts +146 -0
- package/src/temporal-json.ts +77 -0
- package/src/temporal-proto.ts +53 -0
- package/src/temporal-server.ts +67 -0
- package/src/temporal-twin.ts +102 -0
- package/src/temporal-visibility.ts +245 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
// HTTP/2 over a plain TCP socket (h2c with prior knowledge, RFC 9113 §3.3) — the transport gRPC
|
|
2
|
+
// rides on, written here because the runtime this repo develops under cannot serve it: Bun 1.3's
|
|
3
|
+
// `node:http2` server writes frames nghttp2 rejects at the connection preface (a node client gets
|
|
4
|
+
// ERR_HTTP2_ERROR before the first SETTINGS; measured 2026-09-27, Bun 1.3.11, Node 22.23.2, the
|
|
5
|
+
// probe is temporal-h2.test.ts's "node:http2 client" case). A socket works under both runtimes.
|
|
6
|
+
//
|
|
7
|
+
// Scope, stated: a SERVER for request/response exchanges. It frames HEADERS/CONTINUATION, DATA,
|
|
8
|
+
// SETTINGS, PING, WINDOW_UPDATE, RST_STREAM, GOAWAY and PRIORITY (ignored), decodes HPACK (RFC 7541,
|
|
9
|
+
// the tables derived from the vendored RFC into generated/hpack.json), honours both directions of
|
|
10
|
+
// flow control, and never pushes. The encoder emits literals without indexing, which RFC 7541 §6.2.2
|
|
11
|
+
// allows for any header, so the peer's dynamic table is never touched.
|
|
12
|
+
import type { Socket } from 'node:net';
|
|
13
|
+
import hpack from './generated/hpack.json' with { type: 'json' };
|
|
14
|
+
|
|
15
|
+
const PREFACE = Buffer.from('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n', 'latin1');
|
|
16
|
+
const FRAME = { DATA: 0, HEADERS: 1, PRIORITY: 2, RST_STREAM: 3, SETTINGS: 4, PUSH_PROMISE: 5, PING: 6, GOAWAY: 7, WINDOW_UPDATE: 8, CONTINUATION: 9 } as const;
|
|
17
|
+
const FLAG = { END_STREAM: 0x1, ACK: 0x1, END_HEADERS: 0x4, PADDED: 0x8, PRIORITY: 0x20 } as const;
|
|
18
|
+
const ERROR = { NO_ERROR: 0, PROTOCOL_ERROR: 1, INTERNAL_ERROR: 2, FLOW_CONTROL_ERROR: 3, STREAM_CLOSED: 5, FRAME_SIZE_ERROR: 6, REFUSED_STREAM: 7, CANCEL: 8, COMPRESSION_ERROR: 9 } as const;
|
|
19
|
+
const DEFAULT_WINDOW = 65_535;
|
|
20
|
+
/** what this server grants each stream and the connection to receive */
|
|
21
|
+
const RECEIVE_WINDOW = 16 * 1024 * 1024;
|
|
22
|
+
const MAX_CONCURRENT_STREAMS = 1000;
|
|
23
|
+
/** a request body larger than this resets its stream: a memory bound, not Temporal's message limit (the
|
|
24
|
+
* SDKs' gRPC default is 4 MiB; this server does not enforce that one) */
|
|
25
|
+
const MAX_REQUEST_BYTES = 64 * 1024 * 1024;
|
|
26
|
+
/** a header block (HEADERS + CONTINUATION) larger than this closes the connection */
|
|
27
|
+
const MAX_HEADER_BLOCK = 256 * 1024;
|
|
28
|
+
|
|
29
|
+
// ---- HPACK ------------------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
const STATIC_TABLE = hpack.staticTable as Array<[string, string]>;
|
|
32
|
+
|
|
33
|
+
type HuffmanNode = { symbol?: number; zero?: HuffmanNode; one?: HuffmanNode };
|
|
34
|
+
const HUFFMAN_ROOT: HuffmanNode = (() => {
|
|
35
|
+
const root: HuffmanNode = {};
|
|
36
|
+
(hpack.huffman as Array<[number, number]>).forEach(([code, length], symbol) => {
|
|
37
|
+
let node = root;
|
|
38
|
+
for (let bit = length - 1; bit >= 0; bit -= 1) {
|
|
39
|
+
const key = (code >>> bit) & 1 ? 'one' : 'zero';
|
|
40
|
+
node = node[key] ??= {};
|
|
41
|
+
}
|
|
42
|
+
node.symbol = symbol;
|
|
43
|
+
});
|
|
44
|
+
return root;
|
|
45
|
+
})();
|
|
46
|
+
|
|
47
|
+
export class HpackError extends Error {}
|
|
48
|
+
|
|
49
|
+
export function huffmanDecode(bytes: Uint8Array): string {
|
|
50
|
+
const out: number[] = [];
|
|
51
|
+
let node = HUFFMAN_ROOT;
|
|
52
|
+
let pending = 0; // bits consumed since the last symbol
|
|
53
|
+
let allOnes = true;
|
|
54
|
+
for (const byte of bytes) {
|
|
55
|
+
for (let bit = 7; bit >= 0; bit -= 1) {
|
|
56
|
+
const one = (byte >> bit) & 1;
|
|
57
|
+
const next = one ? node.one : node.zero;
|
|
58
|
+
if (!next) throw new HpackError('invalid huffman code');
|
|
59
|
+
pending += 1;
|
|
60
|
+
allOnes &&= one === 1;
|
|
61
|
+
if (next.symbol !== undefined) {
|
|
62
|
+
if (next.symbol === 256) throw new HpackError('EOS symbol inside a huffman string'); // §5.2
|
|
63
|
+
out.push(next.symbol);
|
|
64
|
+
node = HUFFMAN_ROOT;
|
|
65
|
+
pending = 0;
|
|
66
|
+
allOnes = true;
|
|
67
|
+
} else {
|
|
68
|
+
node = next;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// §5.2: padding is at most 7 bits and is the most significant bits of EOS (all ones)
|
|
73
|
+
if (pending > 7 || !allOnes) throw new HpackError('invalid huffman padding');
|
|
74
|
+
return Buffer.from(out).toString('latin1');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class HpackDecoder {
|
|
78
|
+
private table: Array<[string, string]> = [];
|
|
79
|
+
private size = 0;
|
|
80
|
+
private maxSize: number;
|
|
81
|
+
/** the SETTINGS_HEADER_TABLE_SIZE this endpoint advertised (the decoder's ceiling; 4096 is the
|
|
82
|
+
* protocol default, which this server never changes) */
|
|
83
|
+
private readonly limit: number;
|
|
84
|
+
|
|
85
|
+
constructor(headerTableSize = 4096) {
|
|
86
|
+
this.maxSize = headerTableSize;
|
|
87
|
+
this.limit = headerTableSize;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
decode(block: Uint8Array): Array<[string, string]> {
|
|
91
|
+
const headers: Array<[string, string]> = [];
|
|
92
|
+
let offset = 0;
|
|
93
|
+
const integer = (prefixBits: number): number => {
|
|
94
|
+
const mask = (1 << prefixBits) - 1;
|
|
95
|
+
let value = block[offset]! & mask;
|
|
96
|
+
offset += 1;
|
|
97
|
+
if (value < mask) return value;
|
|
98
|
+
let shift = 0;
|
|
99
|
+
for (;;) {
|
|
100
|
+
if (offset >= block.length) throw new HpackError('truncated integer');
|
|
101
|
+
const byte = block[offset]!;
|
|
102
|
+
offset += 1;
|
|
103
|
+
value += (byte & 0x7f) * 2 ** shift;
|
|
104
|
+
shift += 7;
|
|
105
|
+
if ((byte & 0x80) === 0) return value;
|
|
106
|
+
if (shift > 28) throw new HpackError('integer overflow');
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const string = (): string => {
|
|
110
|
+
if (offset >= block.length) throw new HpackError('truncated string');
|
|
111
|
+
const huffman = (block[offset]! & 0x80) !== 0;
|
|
112
|
+
const length = integer(7);
|
|
113
|
+
if (offset + length > block.length) throw new HpackError('truncated string');
|
|
114
|
+
const bytes = block.subarray(offset, offset + length);
|
|
115
|
+
offset += length;
|
|
116
|
+
return huffman ? huffmanDecode(bytes) : Buffer.from(bytes).toString('latin1');
|
|
117
|
+
};
|
|
118
|
+
const entry = (index: number): [string, string] => {
|
|
119
|
+
if (index <= 0) throw new HpackError('index 0');
|
|
120
|
+
if (index <= STATIC_TABLE.length) return STATIC_TABLE[index - 1]!;
|
|
121
|
+
const dynamic = this.table[index - STATIC_TABLE.length - 1];
|
|
122
|
+
if (!dynamic) throw new HpackError(`index ${index} is beyond the dynamic table`);
|
|
123
|
+
return dynamic;
|
|
124
|
+
};
|
|
125
|
+
while (offset < block.length) {
|
|
126
|
+
const byte = block[offset]!;
|
|
127
|
+
if (byte & 0x80) { // §6.1 indexed
|
|
128
|
+
headers.push(entry(integer(7)));
|
|
129
|
+
} else if (byte & 0x40) { // §6.2.1 literal with incremental indexing
|
|
130
|
+
const index = integer(6);
|
|
131
|
+
const name = index === 0 ? string() : entry(index)[0];
|
|
132
|
+
const value = string();
|
|
133
|
+
headers.push([name, value]);
|
|
134
|
+
this.insert(name, value);
|
|
135
|
+
} else if (byte & 0x20) { // §6.3 dynamic table size update
|
|
136
|
+
const size = integer(5);
|
|
137
|
+
if (size > this.limit) throw new HpackError('table size update above the advertised limit');
|
|
138
|
+
this.maxSize = size;
|
|
139
|
+
this.evict();
|
|
140
|
+
} else { // §6.2.2 without indexing / §6.2.3 never indexed
|
|
141
|
+
const index = integer(4);
|
|
142
|
+
const name = index === 0 ? string() : entry(index)[0];
|
|
143
|
+
headers.push([name, string()]);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return headers;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private insert(name: string, value: string): void {
|
|
150
|
+
this.table.unshift([name, value]);
|
|
151
|
+
this.size += name.length + value.length + 32;
|
|
152
|
+
this.evict();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private evict(): void {
|
|
156
|
+
while (this.size > this.maxSize && this.table.length > 0) {
|
|
157
|
+
const [name, value] = this.table.pop()!;
|
|
158
|
+
this.size -= name.length + value.length + 32;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function hpackInteger(value: number, prefixBits: number, firstByte: number): number[] {
|
|
164
|
+
const mask = (1 << prefixBits) - 1;
|
|
165
|
+
if (value < mask) return [firstByte | value];
|
|
166
|
+
const out = [firstByte | mask];
|
|
167
|
+
let rest = value - mask;
|
|
168
|
+
while (rest >= 128) { out.push((rest % 128) + 128); rest = Math.floor(rest / 128); }
|
|
169
|
+
out.push(rest);
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Every header as a literal without indexing, new name, no huffman (RFC 7541 §6.2.2). */
|
|
174
|
+
export function hpackEncode(headers: Array<[string, string]>): Uint8Array {
|
|
175
|
+
const out: number[] = [];
|
|
176
|
+
for (const [name, value] of headers) {
|
|
177
|
+
const nameBytes = Buffer.from(name.toLowerCase(), 'latin1');
|
|
178
|
+
const valueBytes = Buffer.from(value, 'latin1');
|
|
179
|
+
out.push(0x00, ...hpackInteger(nameBytes.length, 7, 0), ...nameBytes, ...hpackInteger(valueBytes.length, 7, 0), ...valueBytes);
|
|
180
|
+
}
|
|
181
|
+
return Uint8Array.from(out);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- framing ----------------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
export type H2Request = {
|
|
187
|
+
headers: Record<string, string>;
|
|
188
|
+
body: Uint8Array;
|
|
189
|
+
/** aborted when the peer resets the stream or the connection goes away */
|
|
190
|
+
signal: AbortSignal;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export type H2Response = {
|
|
194
|
+
/** send response headers; `end` closes the stream with them (a trailers-only answer) */
|
|
195
|
+
headers(headers: Array<[string, string]>, end?: boolean): void;
|
|
196
|
+
data(bytes: Uint8Array): void;
|
|
197
|
+
/** close the stream with trailing headers */
|
|
198
|
+
trailers(headers: Array<[string, string]>): void;
|
|
199
|
+
readonly closed: boolean;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export type H2Handler = (request: H2Request, response: H2Response) => void;
|
|
203
|
+
|
|
204
|
+
type Outbound = { kind: 'headers'; block: Uint8Array; end: boolean } | { kind: 'data'; bytes: Uint8Array; offset: number };
|
|
205
|
+
|
|
206
|
+
type StreamState = {
|
|
207
|
+
id: number;
|
|
208
|
+
headerBlock: Uint8Array[] | null;
|
|
209
|
+
headers: Record<string, string>;
|
|
210
|
+
body: Uint8Array[];
|
|
211
|
+
bodyBytes: number;
|
|
212
|
+
remoteClosed: boolean;
|
|
213
|
+
localClosed: boolean;
|
|
214
|
+
sendWindow: number;
|
|
215
|
+
outbound: Outbound[];
|
|
216
|
+
abort: AbortController;
|
|
217
|
+
/** handed to the handler: a stream is dispatched at most once */
|
|
218
|
+
dispatched?: boolean;
|
|
219
|
+
/** a header block arrived after dispatch */
|
|
220
|
+
late?: boolean;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
function frameHeader(length: number, type: number, flags: number, streamId: number): Buffer {
|
|
224
|
+
const header = Buffer.alloc(9);
|
|
225
|
+
header.writeUIntBE(length, 0, 3);
|
|
226
|
+
header[3] = type;
|
|
227
|
+
header[4] = flags;
|
|
228
|
+
header.writeUInt32BE(streamId & 0x7fffffff, 5);
|
|
229
|
+
return header;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
class H2Connection {
|
|
233
|
+
private buffer: Buffer = Buffer.alloc(0);
|
|
234
|
+
private prefaceSeen = false;
|
|
235
|
+
private readonly decoder = new HpackDecoder();
|
|
236
|
+
private readonly streams = new Map<number, StreamState>();
|
|
237
|
+
private continuing: StreamState | null = null;
|
|
238
|
+
private lastStreamId = 0;
|
|
239
|
+
private peerInitialWindow = DEFAULT_WINDOW;
|
|
240
|
+
private peerMaxFrame = 16_384;
|
|
241
|
+
private connectionSendWindow = DEFAULT_WINDOW;
|
|
242
|
+
private dead = false;
|
|
243
|
+
|
|
244
|
+
private readonly socket: Socket;
|
|
245
|
+
private readonly handler: H2Handler;
|
|
246
|
+
private readonly http1: Http1Handler | undefined;
|
|
247
|
+
private http1Mode = false;
|
|
248
|
+
|
|
249
|
+
constructor(socket: Socket, handler: H2Handler, http1?: Http1Handler) {
|
|
250
|
+
this.socket = socket;
|
|
251
|
+
this.handler = handler;
|
|
252
|
+
this.http1 = http1;
|
|
253
|
+
socket.on('data', (chunk: Buffer) => this.receive(chunk));
|
|
254
|
+
socket.on('close', () => this.teardown());
|
|
255
|
+
socket.on('error', () => this.teardown());
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The server's connection preface (§3.4), sent once the client's has arrived: no push, a stream
|
|
259
|
+
* ceiling and a large receive window. Waiting for the client's is what lets an HTTP/1 request on
|
|
260
|
+
* the same port be answered as one instead of receiving binary frames first. */
|
|
261
|
+
private sendPreface(): void {
|
|
262
|
+
const settings = Buffer.alloc(18);
|
|
263
|
+
settings.writeUInt16BE(0x2, 0); settings.writeUInt32BE(0, 2); // ENABLE_PUSH
|
|
264
|
+
settings.writeUInt16BE(0x3, 6); settings.writeUInt32BE(MAX_CONCURRENT_STREAMS, 8);
|
|
265
|
+
settings.writeUInt16BE(0x4, 12); settings.writeUInt32BE(RECEIVE_WINDOW, 14);
|
|
266
|
+
this.write(FRAME.SETTINGS, 0, 0, settings);
|
|
267
|
+
this.windowUpdate(0, RECEIVE_WINDOW - DEFAULT_WINDOW);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** An HTTP/1.1 request on this port (a World's door probe, a curl of the HTTP API): read one
|
|
271
|
+
* request, answer it through the fetch handler, close. */
|
|
272
|
+
private receiveHttp1(): void {
|
|
273
|
+
const end = this.buffer.indexOf('\r\n\r\n');
|
|
274
|
+
if (end < 0) {
|
|
275
|
+
if (this.buffer.length > 64 * 1024) this.teardown();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const head = this.buffer.subarray(0, end).toString('latin1').split('\r\n');
|
|
279
|
+
const [method, target] = head[0]!.split(' ');
|
|
280
|
+
const headers = new Headers();
|
|
281
|
+
try {
|
|
282
|
+
for (const line of head.slice(1)) {
|
|
283
|
+
const colon = line.indexOf(':');
|
|
284
|
+
if (colon > 0) headers.append(line.slice(0, colon).trim(), line.slice(colon + 1).trim());
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
this.respondHttp1(new Response('bad header', { status: 400 }));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (headers.get('transfer-encoding')) { this.respondHttp1(new Response('chunked request bodies are not accepted', { status: 411 })); return; }
|
|
291
|
+
const length = Number(headers.get('content-length') ?? 0);
|
|
292
|
+
if (!Number.isFinite(length) || length < 0 || length > MAX_REQUEST_BYTES) { this.respondHttp1(new Response('bad content-length', { status: 400 })); return; }
|
|
293
|
+
if (this.buffer.length < end + 4 + length) return;
|
|
294
|
+
const body = this.buffer.subarray(end + 4, end + 4 + length);
|
|
295
|
+
this.buffer = Buffer.alloc(0);
|
|
296
|
+
this.socket.removeAllListeners('data');
|
|
297
|
+
let request: Request;
|
|
298
|
+
try {
|
|
299
|
+
// the URL is built on loopback, never on the client's Host header
|
|
300
|
+
if (!target?.startsWith('/')) throw new Error('request target must be origin-form');
|
|
301
|
+
request = new Request(`http://127.0.0.1${target}`, {
|
|
302
|
+
method: method!, headers, ...(method === 'GET' || method === 'HEAD' ? {} : { body: new Uint8Array(body) }),
|
|
303
|
+
});
|
|
304
|
+
} catch {
|
|
305
|
+
this.respondHttp1(new Response('bad request', { status: 400 }));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
void Promise.resolve().then(() => this.http1!(request)).then((response) => this.respondHttp1(response), () => this.respondHttp1(new Response('internal error', { status: 500 })));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private respondHttp1(response: Response): void {
|
|
312
|
+
void response.arrayBuffer().then((bytes) => {
|
|
313
|
+
if (this.dead) return;
|
|
314
|
+
const lines = [`HTTP/1.1 ${response.status} ${response.statusText || 'OK'}`];
|
|
315
|
+
response.headers.forEach((value, key) => { if (key !== 'content-length' && key !== 'connection') lines.push(`${key}: ${value}`); });
|
|
316
|
+
lines.push(`content-length: ${bytes.byteLength}`, 'connection: close', '', '');
|
|
317
|
+
this.socket.end(Buffer.concat([Buffer.from(lines.join('\r\n'), 'latin1'), Buffer.from(bytes)]));
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private write(type: number, flags: number, streamId: number, payload: Uint8Array = new Uint8Array(0)): void {
|
|
322
|
+
if (this.dead) return;
|
|
323
|
+
this.socket.write(Buffer.concat([frameHeader(payload.length, type, flags, streamId), payload]));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private windowUpdate(streamId: number, increment: number): void {
|
|
327
|
+
if (increment <= 0) return;
|
|
328
|
+
const payload = Buffer.alloc(4);
|
|
329
|
+
payload.writeUInt32BE(increment & 0x7fffffff, 0);
|
|
330
|
+
this.write(FRAME.WINDOW_UPDATE, 0, streamId, payload);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private goaway(code: number, message: string): void {
|
|
334
|
+
const payload = Buffer.concat([Buffer.alloc(8), Buffer.from(message, 'utf8')]);
|
|
335
|
+
payload.writeUInt32BE(this.lastStreamId & 0x7fffffff, 0);
|
|
336
|
+
payload.writeUInt32BE(code, 4);
|
|
337
|
+
this.write(FRAME.GOAWAY, 0, 0, payload);
|
|
338
|
+
// end, not destroy: the GOAWAY frame is flushed before the socket closes
|
|
339
|
+
this.dead = true;
|
|
340
|
+
for (const stream of this.streams.values()) stream.abort.abort();
|
|
341
|
+
this.streams.clear();
|
|
342
|
+
this.socket.end();
|
|
343
|
+
// a peer that never closes its half is cut off after a grace
|
|
344
|
+
setTimeout(() => this.socket.destroy(), 1000).unref?.();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private resetStream(stream: StreamState, code: number): void {
|
|
348
|
+
const payload = Buffer.alloc(4);
|
|
349
|
+
payload.writeUInt32BE(code, 0);
|
|
350
|
+
this.write(FRAME.RST_STREAM, 0, stream.id, payload);
|
|
351
|
+
this.closeStream(stream);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private closeStream(stream: StreamState): void {
|
|
355
|
+
stream.localClosed = true;
|
|
356
|
+
stream.remoteClosed = true;
|
|
357
|
+
stream.outbound = [];
|
|
358
|
+
stream.abort.abort();
|
|
359
|
+
this.streams.delete(stream.id);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
teardown(): void {
|
|
363
|
+
if (this.dead) { this.socket.destroy(); return; }
|
|
364
|
+
this.dead = true;
|
|
365
|
+
for (const stream of this.streams.values()) stream.abort.abort();
|
|
366
|
+
this.streams.clear();
|
|
367
|
+
this.socket.destroy();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
private receive(chunk: Buffer): void {
|
|
371
|
+
if (this.dead) return;
|
|
372
|
+
this.buffer = this.buffer.length ? Buffer.concat([this.buffer, chunk]) : chunk;
|
|
373
|
+
if (this.http1Mode) { this.receiveHttp1(); return; }
|
|
374
|
+
if (!this.prefaceSeen) {
|
|
375
|
+
const seen = this.buffer.subarray(0, Math.min(this.buffer.length, PREFACE.length));
|
|
376
|
+
if (!PREFACE.subarray(0, seen.length).equals(seen)) {
|
|
377
|
+
// not HTTP/2: an HTTP/1 request line is answered by the fetch handler, anything else refused
|
|
378
|
+
const start = this.buffer.subarray(0, 8).toString('latin1');
|
|
379
|
+
if (this.http1 && this.buffer.length < 8 && /^[A-Z]+$/.test(start)) return; // a method still arriving
|
|
380
|
+
if (this.http1 && /^(GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS) /.test(start)) {
|
|
381
|
+
this.http1Mode = true;
|
|
382
|
+
this.receiveHttp1();
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
this.socket.end();
|
|
386
|
+
this.teardown();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (this.buffer.length < PREFACE.length) return;
|
|
390
|
+
this.prefaceSeen = true;
|
|
391
|
+
this.buffer = this.buffer.subarray(PREFACE.length);
|
|
392
|
+
this.sendPreface();
|
|
393
|
+
}
|
|
394
|
+
while (!this.dead && this.buffer.length >= 9) {
|
|
395
|
+
const length = this.buffer.readUIntBE(0, 3);
|
|
396
|
+
if (length > 16_384) { this.goaway(ERROR.FRAME_SIZE_ERROR, 'frame larger than SETTINGS_MAX_FRAME_SIZE'); return; }
|
|
397
|
+
if (this.buffer.length < 9 + length) return;
|
|
398
|
+
const type = this.buffer[3]!;
|
|
399
|
+
const flags = this.buffer[4]!;
|
|
400
|
+
const streamId = this.buffer.readUInt32BE(5) & 0x7fffffff;
|
|
401
|
+
const payload = this.buffer.subarray(9, 9 + length);
|
|
402
|
+
this.buffer = this.buffer.subarray(9 + length);
|
|
403
|
+
try {
|
|
404
|
+
this.frame(type, flags, streamId, payload);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
this.goaway(error instanceof HpackError ? ERROR.COMPRESSION_ERROR : ERROR.PROTOCOL_ERROR, (error as Error).message);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private frame(type: number, flags: number, streamId: number, payload: Buffer): void {
|
|
413
|
+
if (this.continuing && type !== FRAME.CONTINUATION) throw new Error('expected CONTINUATION'); // §6.10
|
|
414
|
+
switch (type) {
|
|
415
|
+
case FRAME.SETTINGS: {
|
|
416
|
+
if (streamId !== 0) throw new Error('SETTINGS on a stream');
|
|
417
|
+
if (flags & FLAG.ACK) return;
|
|
418
|
+
if (payload.length % 6 !== 0) throw new Error('SETTINGS length');
|
|
419
|
+
for (let i = 0; i < payload.length; i += 6) {
|
|
420
|
+
const id = payload.readUInt16BE(i);
|
|
421
|
+
const value = payload.readUInt32BE(i + 2);
|
|
422
|
+
if (id === 0x4) {
|
|
423
|
+
if (value > 0x7fffffff) throw new Error('SETTINGS_INITIAL_WINDOW_SIZE too large');
|
|
424
|
+
const delta = value - this.peerInitialWindow;
|
|
425
|
+
this.peerInitialWindow = value;
|
|
426
|
+
for (const stream of this.streams.values()) stream.sendWindow += delta;
|
|
427
|
+
} else if (id === 0x5) {
|
|
428
|
+
if (value < 16_384 || value > 16_777_215) throw new Error('SETTINGS_MAX_FRAME_SIZE out of range');
|
|
429
|
+
this.peerMaxFrame = value;
|
|
430
|
+
}
|
|
431
|
+
// HEADER_TABLE_SIZE governs the peer's decoder of OUR blocks, which never index; the
|
|
432
|
+
// rest (push, concurrency, header list size) bound what this server already never does
|
|
433
|
+
}
|
|
434
|
+
this.write(FRAME.SETTINGS, FLAG.ACK, 0);
|
|
435
|
+
this.flush();
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
case FRAME.PING: {
|
|
439
|
+
if (payload.length !== 8) throw new Error('PING length');
|
|
440
|
+
if (!(flags & FLAG.ACK)) this.write(FRAME.PING, FLAG.ACK, 0, payload);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
case FRAME.GOAWAY: {
|
|
444
|
+
this.socket.end();
|
|
445
|
+
this.teardown();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
case FRAME.WINDOW_UPDATE: {
|
|
449
|
+
if (payload.length !== 4) throw new Error('WINDOW_UPDATE length');
|
|
450
|
+
const increment = payload.readUInt32BE(0) & 0x7fffffff;
|
|
451
|
+
if (streamId === 0) {
|
|
452
|
+
if (increment === 0) throw new Error('WINDOW_UPDATE of 0');
|
|
453
|
+
this.connectionSendWindow += increment;
|
|
454
|
+
} else {
|
|
455
|
+
const stream = this.streams.get(streamId);
|
|
456
|
+
if (stream) stream.sendWindow += increment;
|
|
457
|
+
}
|
|
458
|
+
this.flush();
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
case FRAME.RST_STREAM: {
|
|
462
|
+
const stream = this.streams.get(streamId);
|
|
463
|
+
if (stream) this.closeStream(stream);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
case FRAME.PRIORITY:
|
|
467
|
+
return;
|
|
468
|
+
case FRAME.PUSH_PROMISE:
|
|
469
|
+
throw new Error('a client never sends PUSH_PROMISE');
|
|
470
|
+
case FRAME.HEADERS: {
|
|
471
|
+
if (streamId === 0 || streamId % 2 === 0) throw new Error('HEADERS on an invalid stream id');
|
|
472
|
+
let body = payload;
|
|
473
|
+
let pad = 0;
|
|
474
|
+
if (flags & FLAG.PADDED) { pad = body[0]!; body = body.subarray(1); }
|
|
475
|
+
if (flags & FLAG.PRIORITY) body = body.subarray(5);
|
|
476
|
+
if (pad > body.length) throw new Error('padding exceeds HEADERS payload');
|
|
477
|
+
body = body.subarray(0, body.length - pad);
|
|
478
|
+
let stream = this.streams.get(streamId);
|
|
479
|
+
if (!stream) {
|
|
480
|
+
if (streamId <= this.lastStreamId) throw new Error('stream id reused'); // §5.1.1
|
|
481
|
+
this.lastStreamId = streamId;
|
|
482
|
+
stream = {
|
|
483
|
+
id: streamId, headerBlock: [], headers: {}, body: [], bodyBytes: 0, remoteClosed: false, localClosed: false,
|
|
484
|
+
sendWindow: this.peerInitialWindow, outbound: [], abort: new AbortController(),
|
|
485
|
+
};
|
|
486
|
+
this.streams.set(streamId, stream);
|
|
487
|
+
} else {
|
|
488
|
+
// trailers from the client: this server's methods carry none, but the block must still
|
|
489
|
+
// be decoded to keep the HPACK table in step
|
|
490
|
+
stream.headerBlock = [];
|
|
491
|
+
}
|
|
492
|
+
stream.headerBlock!.push(body);
|
|
493
|
+
if (stream.dispatched) stream.late = true; // decoded for the HPACK table, then refused
|
|
494
|
+
if (flags & FLAG.END_STREAM) stream.remoteClosed = true;
|
|
495
|
+
if (flags & FLAG.END_HEADERS) this.headersDone(stream); else this.continuing = stream;
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
case FRAME.CONTINUATION: {
|
|
499
|
+
const stream = this.continuing;
|
|
500
|
+
if (!stream || stream.id !== streamId) throw new Error('CONTINUATION without HEADERS');
|
|
501
|
+
stream.headerBlock!.push(payload);
|
|
502
|
+
if (stream.headerBlock!.reduce((n, part) => n + part.length, 0) > MAX_HEADER_BLOCK) throw new Error('header block too large');
|
|
503
|
+
if (flags & FLAG.END_HEADERS) { this.continuing = null; this.headersDone(stream); }
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
case FRAME.DATA: {
|
|
507
|
+
let body = payload;
|
|
508
|
+
if (flags & FLAG.PADDED) {
|
|
509
|
+
const pad = body[0]!;
|
|
510
|
+
if (pad >= body.length) throw new Error('padding exceeds DATA payload');
|
|
511
|
+
body = body.subarray(1, body.length - pad);
|
|
512
|
+
}
|
|
513
|
+
// replenish what the frame consumed (padding included), connection and stream
|
|
514
|
+
this.windowUpdate(0, payload.length);
|
|
515
|
+
const stream = this.streams.get(streamId);
|
|
516
|
+
if (!stream || stream.remoteClosed) {
|
|
517
|
+
if (streamId > this.lastStreamId) throw new Error('DATA on an idle stream');
|
|
518
|
+
return; // a stream this server already closed or reset
|
|
519
|
+
}
|
|
520
|
+
if (!(flags & FLAG.END_STREAM)) this.windowUpdate(streamId, payload.length);
|
|
521
|
+
stream.body.push(Buffer.from(body));
|
|
522
|
+
stream.bodyBytes += body.length;
|
|
523
|
+
if (stream.bodyBytes > MAX_REQUEST_BYTES) { this.resetStream(stream, ERROR.REFUSED_STREAM); return; }
|
|
524
|
+
if (flags & FLAG.END_STREAM) { stream.remoteClosed = true; this.dispatch(stream); }
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
default:
|
|
528
|
+
return; // §4.1: unknown frame types are ignored
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private headersDone(stream: StreamState): void {
|
|
533
|
+
const block = Buffer.concat(stream.headerBlock!);
|
|
534
|
+
stream.headerBlock = null;
|
|
535
|
+
const decoded = this.decoder.decode(block);
|
|
536
|
+
// the decoded list is bounded too: indexed references re-emit dynamic entries, so a small block
|
|
537
|
+
// can decode to far more than it carries (SETTINGS_MAX_HEADER_LIST_SIZE's concern, RFC 9113 §6.5.2)
|
|
538
|
+
if (decoded.reduce((n, [name, value]) => n + name.length + value.length + 32, 0) > MAX_HEADER_BLOCK) throw new HpackError('decoded header list too large');
|
|
539
|
+
// a header block on a stream this server already answers is a stream error (RFC 9113 §5.1,
|
|
540
|
+
// half-closed (remote)): it is never dispatched a second time
|
|
541
|
+
if (stream.late) { this.resetStream(stream, ERROR.STREAM_CLOSED); return; }
|
|
542
|
+
const first = Object.keys(stream.headers).length === 0;
|
|
543
|
+
if (first) for (const [name, value] of decoded) stream.headers[name] = stream.headers[name] === undefined ? value : `${stream.headers[name]}, ${value}`;
|
|
544
|
+
if (this.streams.size > MAX_CONCURRENT_STREAMS) { this.resetStream(stream, ERROR.REFUSED_STREAM); return; }
|
|
545
|
+
if (stream.remoteClosed) this.dispatch(stream);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
private dispatch(stream: StreamState): void {
|
|
549
|
+
if (stream.dispatched) return;
|
|
550
|
+
stream.dispatched = true;
|
|
551
|
+
const response: H2Response = {
|
|
552
|
+
headers: (headers, end = false) => this.enqueue(stream, { kind: 'headers', block: hpackEncode(headers), end }),
|
|
553
|
+
data: (bytes) => this.enqueue(stream, { kind: 'data', bytes, offset: 0 }),
|
|
554
|
+
trailers: (headers) => this.enqueue(stream, { kind: 'headers', block: hpackEncode(headers), end: true }),
|
|
555
|
+
get closed() { return stream.localClosed; },
|
|
556
|
+
};
|
|
557
|
+
try {
|
|
558
|
+
this.handler({ headers: stream.headers, body: Buffer.concat(stream.body), signal: stream.abort.signal }, response);
|
|
559
|
+
} catch {
|
|
560
|
+
this.resetStream(stream, ERROR.INTERNAL_ERROR);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private enqueue(stream: StreamState, item: Outbound): void {
|
|
565
|
+
if (stream.localClosed || this.dead) return;
|
|
566
|
+
stream.outbound.push(item);
|
|
567
|
+
if (item.kind === 'headers' && item.end) stream.localClosed = true;
|
|
568
|
+
this.flush();
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
private flush(): void {
|
|
572
|
+
for (const stream of [...this.streams.values()]) {
|
|
573
|
+
while (stream.outbound.length > 0 && !this.dead) {
|
|
574
|
+
const item = stream.outbound[0]!;
|
|
575
|
+
if (item.kind === 'headers') {
|
|
576
|
+
// one HEADERS frame, CONTINUATION for a block larger than the peer's frame size
|
|
577
|
+
let offset = 0;
|
|
578
|
+
const block = item.block;
|
|
579
|
+
const firstLength = Math.min(block.length, this.peerMaxFrame);
|
|
580
|
+
const last = firstLength === block.length;
|
|
581
|
+
this.write(FRAME.HEADERS, (item.end ? FLAG.END_STREAM : 0) | (last ? FLAG.END_HEADERS : 0), stream.id, block.subarray(0, firstLength));
|
|
582
|
+
offset = firstLength;
|
|
583
|
+
while (offset < block.length) {
|
|
584
|
+
const length = Math.min(block.length - offset, this.peerMaxFrame);
|
|
585
|
+
this.write(FRAME.CONTINUATION, offset + length === block.length ? FLAG.END_HEADERS : 0, stream.id, block.subarray(offset, offset + length));
|
|
586
|
+
offset += length;
|
|
587
|
+
}
|
|
588
|
+
stream.outbound.shift();
|
|
589
|
+
if (item.end) { this.streams.delete(stream.id); break; }
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
const remaining = item.bytes.length - item.offset;
|
|
593
|
+
const allowance = Math.min(remaining, this.peerMaxFrame, this.connectionSendWindow, stream.sendWindow);
|
|
594
|
+
if (remaining > 0 && allowance <= 0) break; // wait for WINDOW_UPDATE
|
|
595
|
+
const chunk = item.bytes.subarray(item.offset, item.offset + allowance);
|
|
596
|
+
this.write(FRAME.DATA, 0, stream.id, chunk);
|
|
597
|
+
item.offset += allowance;
|
|
598
|
+
this.connectionSendWindow -= allowance;
|
|
599
|
+
stream.sendWindow -= allowance;
|
|
600
|
+
if (item.offset >= item.bytes.length) stream.outbound.shift();
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** An HTTP/1 request arriving on the same port, answered by a fetch handler. */
|
|
607
|
+
export type Http1Handler = (request: Request) => Response | Promise<Response>;
|
|
608
|
+
|
|
609
|
+
/** Speak HTTP/2 (h2c with prior knowledge) on an accepted socket; an HTTP/1 request line is handed to
|
|
610
|
+
* `http1` when given. The listener that accepts sockets is the server module's (temporal-server.ts). */
|
|
611
|
+
export function acceptH2Connection(socket: Socket, handler: H2Handler, http1?: Http1Handler): { teardown(): void } {
|
|
612
|
+
socket.setNoDelay(true);
|
|
613
|
+
return new H2Connection(socket, handler, http1);
|
|
614
|
+
}
|