h2lean 0.0.0-stage → 0.1.1

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/lib/pieces.js ADDED
@@ -0,0 +1,134 @@
1
+ // The octets of a frame that arrived in pieces.
2
+ //
3
+ // A frame whose bytes do not fall inside one socket read is assembled out of the
4
+ // views it arrived in rather than copied into a buffer of its own: a DATA
5
+ // payload reaches the application as the views it arrived in (node hands over
6
+ // the same shape -- tools/probe-chunking.js), and a header block that a read
7
+ // split is decoded out of the views. This file is the machinery that needs:
8
+ // how long the views are together, how to read an octet or a 32-bit value out
9
+ // of them, how to cut a range out of them, and how to compare them with a
10
+ // buffer.
11
+ //
12
+ // The views are of the chunks node read off the socket, and node never writes
13
+ // into a chunk again, so a view stays valid for as long as anybody holds it.
14
+ // That is the rule the slab this replaced broke: the slab was reused for the
15
+ // next frame that arrived in pieces, so the bytes an application kept moved
16
+ // under it (AGENTS.md 5: issued buffers stay valid).
17
+ //
18
+ // Every function here takes the views and an offset into their concatenation.
19
+ // The offsets are into the *payload* of a frame, so a caller that has trimmed
20
+ // the padding off a frame passes the trimmed offsets.
21
+ 'use strict';
22
+
23
+ // How many octets the views hold together.
24
+ function piecesLength(pieces) {
25
+ let n = 0;
26
+ for (let i = 0; i < pieces.length; i++) {
27
+ n += pieces[i].length;
28
+ }
29
+ return n;
30
+ }
31
+
32
+ // The octet at `off` in the concatenation of the views, or -1 past the end.
33
+ function pieceByteAt(pieces, off) {
34
+ for (let i = 0; i < pieces.length; i++) {
35
+ const p = pieces[i];
36
+ if (off < p.length) {
37
+ return p[off];
38
+ }
39
+ off -= p.length;
40
+ }
41
+ return -1;
42
+ }
43
+
44
+ // A 32-bit value at `off`, big endian -- the frame header's stream id, the
45
+ // SETTINGS entries' values, the flow-control increments.
46
+ function pieceU32At(pieces, off) {
47
+ return ((pieceByteAt(pieces, off) * 0x1000000)
48
+ + (pieceByteAt(pieces, off + 1) << 16)
49
+ + (pieceByteAt(pieces, off + 2) << 8)
50
+ + pieceByteAt(pieces, off + 3)) >>> 0;
51
+ }
52
+
53
+ // The views that cover [from, to) of the concatenation. The parts that are not
54
+ // whole views are trimmed, so the result is the range and nothing else.
55
+ function pieceSlice(pieces, from, to) {
56
+ const out = [];
57
+ let off = 0;
58
+ for (let i = 0; i < pieces.length; i++) {
59
+ const p = pieces[i];
60
+ const end = off + p.length;
61
+ if (end > from && off < to) {
62
+ const a = from > off ? from - off : 0;
63
+ const b = to < end ? to - off : p.length;
64
+ out.push(p.subarray(a, b));
65
+ }
66
+ off = end;
67
+ if (off >= to) {
68
+ break;
69
+ }
70
+ }
71
+ return out;
72
+ }
73
+
74
+ // The octets of [from, to) copied into `dst` at `dstOff`. The copy a frame's
75
+ // first octets need when they are spread over several views: the pad length
76
+ // and the priority block of a HEADERS payload are at its front and are at most
77
+ // six octets.
78
+ function pieceCopyTo(dst, dstOff, pieces, from, to) {
79
+ let off = 0;
80
+ let written = 0;
81
+ for (let i = 0; i < pieces.length && written < to - from; i++) {
82
+ const p = pieces[i];
83
+ const end = off + p.length;
84
+ if (end > from && off < to) {
85
+ const a = from > off ? from - off : 0;
86
+ const b = to < end ? to - off : p.length;
87
+ p.copy(dst, dstOff + written, a, b);
88
+ written += b - a;
89
+ }
90
+ off = end;
91
+ }
92
+ return written;
93
+ }
94
+
95
+ // A fresh buffer with the octets of [from, to). This is the copy an application
96
+ // keeps, and the copy the shape cache stores -- the views themselves are not
97
+ // kept, because a view pins the socket chunk it came from.
98
+ function pieceConcat(pieces, from, to) {
99
+ const parts = pieceSlice(pieces, from, to);
100
+ if (parts.length === 1) {
101
+ return Buffer.from(parts[0]);
102
+ }
103
+ return Buffer.concat(parts, to - from);
104
+ }
105
+
106
+ // Are the octets of [from, to) the same as the buffer's? The shape cache's
107
+ // answer to "was this exact block seen before".
108
+ function pieceEquals(buf, pieces, from, to) {
109
+ if (buf.length !== to - from) {
110
+ return false;
111
+ }
112
+ let off = 0;
113
+ for (let i = 0; i < pieces.length; i++) {
114
+ const p = pieces[i];
115
+ const end = off + p.length;
116
+ if (end > from && off < to) {
117
+ const a = from > off ? from - off : 0;
118
+ const b = to < end ? to - off : p.length;
119
+ if (buf.compare(p, a, b, off - from + a, off - from + b) !== 0) {
120
+ return false;
121
+ }
122
+ }
123
+ off = end;
124
+ if (off >= to) {
125
+ break;
126
+ }
127
+ }
128
+ return true;
129
+ }
130
+
131
+ module.exports = {
132
+ piecesLength, pieceByteAt, pieceU32At, pieceSlice, pieceCopyTo, pieceConcat,
133
+ pieceEquals
134
+ };
package/lib/server.js ADDED
@@ -0,0 +1,272 @@
1
+ // Server front door, both transports: h2c on a net.Server and h2s on a tls.Server.
2
+ // This file owns the listening socket, one Http2Session per connection, and the
3
+ // decision of whether the compat (req, res) layer is instantiated at all.
4
+ //
5
+ // Connection-lifetime surface, measured against node 26 rather than against its
6
+ // documentation:
7
+ // * node's Http2Server has close, setTimeout and updateSettings -- and NOTHING else: no
8
+ // sessionTimeout, no closeIdleSessions, no sessions getter, no settings property. node keeps
9
+ // the settings updateSettings merges in a private options object; h2lean keeps them in the
10
+ // public `settings` field it also builds sessions from, which is a third name node does not
11
+ // have. The extras implemented here are additive, not substitutions:
12
+ // server.sessions -- the live session set, kept because h2lean tracks sessions
13
+ // in it and node keeps the same set under a symbol;
14
+ // server.closeIdleSessions() -- documented by node for years and gone in 26; the
15
+ // keep-alive policy it served is session.setTimeout() plus
16
+ // the application destroying the session, which both stacks
17
+ // do implement. Harmless to keep, surprising to drop.
18
+ // * what this server advertises on the wire matches node: the first SETTINGS frame carries only
19
+ // the values the application asked for -- an EMPTY payload when it asked for nothing -- and a
20
+ // later server.updateSettings() reaches the NEXT connection, not the live sessions. The local
21
+ // defaults still guard the inbound path; they are simply not advertised, because an advertised
22
+ // value is a promise to the peer rather than a local choice. Measured with
23
+ // bench/micro/probe-settings-frame.js: both stacks send an empty frame, then "3=100,5=32768" for
24
+ // settings {maxConcurrentStreams: 100, maxFrameSize: 32768}, then "3=100,4=65536" for the
25
+ // connection that follows server.updateSettings({initialWindowSize: 65536}).
26
+ // * closing a connection is not the same MOMENT on the two stacks, and one event depends on it.
27
+ // node's session survives close() until its graceful close completes (internal/http2/core.js:
28
+ // handle.ongracefulclosecomplete -> kMaybeDestroy), so it can still read -- and report -- the
29
+ // peer's answer to the GOAWAY we just sent. h2lean destroys the session as soon as that GOAWAY
30
+ // is written (lib/session.js: close() -> _maybeFinishClose), but its socket stays readable
31
+ // until the write is flushed, so the answer is read there too. The two therefore disagree
32
+ // about ONE event -- the 'goaway' the peer's answer produces -- and only inside a window:
33
+ // measured with tools/probe-goaway-echo.js, node's outcome moves with the load (40 of 40
34
+ // rounds on an idle machine, 26 of 40 and 29 of 30 in two loaded runs) while h2lean reports
35
+ // the echo in every round of every run. test/event-order.test.js drops the echo outside its
36
+ // 'goaway' scenario for that reason.
37
+ 'use strict';
38
+
39
+ const net = require('node:net');
40
+ const tls = require('node:tls');
41
+ const { Http2Session, OUR_DEFAULTS } = require('./session');
42
+ const { onServerStream, Http2ServerRequest, Http2ServerResponse } = require('./compat');
43
+
44
+ const kCompat = Symbol('h2lean.compatListener');
45
+ // The transport's own close/on, captured before the shared API overrides them:
46
+ // net.Server and tls.Server are unrelated base classes and JS has only single
47
+ // inheritance, so the shared behaviour is mixed onto the prototype the same way
48
+ // node's own core does it, with the overridden methods kept reachable by symbol.
49
+ const kTransportClose = Symbol('h2lean.transportClose');
50
+ const kTransportOn = Symbol('h2lean.transportOn');
51
+
52
+ function notImplemented(what) {
53
+ const err = new Error(`h2lean v1 does not implement ${what}`);
54
+ err.code = 'ERR_H2LEAN_NOT_IMPLEMENTED';
55
+ return err;
56
+ }
57
+
58
+ function initHttp2Server(server, options) {
59
+ const opts = options || {};
60
+ server.settings = { ...OUR_DEFAULTS, ...opts.settings };
61
+ server._sessionOptions = {
62
+ settings: server.settings,
63
+ // Which values the application asked for. server.settings is what the session's guards read,
64
+ // this list is what the peer is told, and the two are deliberately not the same object: node
65
+ // advertises only what was set (created with no options, its SETTINGS frame is empty), and an
66
+ // advertised number decides how hard peers drive the connection. See Http2Session._advertised.
67
+ advertised: Object.keys(opts.settings || {}),
68
+ // There is no copy to opt out of: a DATA payload reaches the application
69
+ // either as a view of the buffer node read off the socket or as a buffer
70
+ // built for that frame alone, and both stay readable for as long as the
71
+ // application holds them.
72
+ shapeCache: opts.shapeCache !== false,
73
+ // Opt-in: req.headers is frozen and one object per distinct header block.
74
+ // Off by default, because node lets the application write into headers.
75
+ shareHeaders: opts.shareHeaders === true
76
+ };
77
+ server._sessions = new Set();
78
+ server[kCompat] = null;
79
+ }
80
+
81
+ // Turns an accepted socket -- plain or already decrypted -- into a session.
82
+ function attachSession(server, socket) {
83
+ socket.setNoDelay(true);
84
+ const session = new Http2Session(socket, server._sessionOptions);
85
+ session.server = server;
86
+ server._sessions.add(session);
87
+ session.on('stream', (stream, headers, options, rawHeaders) =>
88
+ server.emit('stream', stream, headers, options, rawHeaders));
89
+ session.on('error', (err) => {
90
+ if (server.listenerCount('sessionError') > 0) {
91
+ server.emit('sessionError', err);
92
+ }
93
+ });
94
+ session.once('close', () => {
95
+ server._sessions.delete(session);
96
+ });
97
+ server.emit('session', session);
98
+ }
99
+
100
+ function applyHttp2ServerApi(proto) {
101
+ proto[kTransportClose] = proto.close;
102
+ proto[kTransportOn] = proto.on;
103
+
104
+ // node instantiates the compat req/res only when the application asks for
105
+ // them: a plain createServer() with a 'stream' handler never pays for two
106
+ // objects per request. Adding the first 'request' listener arms it.
107
+ proto._setupCompat = function () {
108
+ if (this[kCompat] !== null) {
109
+ return this;
110
+ }
111
+ this[kCompat] = onServerStream.bind(this);
112
+ this._sessionOptions.compat = true;
113
+ for (const session of this._sessions) {
114
+ session._wantRaw = true;
115
+ }
116
+ return proto[kTransportOn].call(this, 'stream', this[kCompat]);
117
+ };
118
+
119
+ proto.on = function (event, listener) {
120
+ if (event === 'request') {
121
+ this._setupCompat();
122
+ }
123
+ return proto[kTransportOn].call(this, event, listener);
124
+ };
125
+
126
+ proto.addListener = function (event, listener) {
127
+ return this.on(event, listener);
128
+ };
129
+
130
+ proto.close = function (cb) {
131
+ // node's Http2Server.close() is `ReflectApply(NETServer.prototype.close, this, arguments)`
132
+ // followed by closeAllSessions(this) (internal/http2/core): the listening socket goes away
133
+ // right away and its callback is net.Server's own. Waiting for the session set to empty first
134
+ // was ours, and it made close(cb) a promise that a session which cannot drain never keeps --
135
+ // 'close' then depended on every connection finishing.
136
+ proto[kTransportClose].call(this, cb);
137
+ for (const session of [...this._sessions]) {
138
+ session.close();
139
+ }
140
+ return this;
141
+ };
142
+
143
+ proto.closeIdleSessions = function () {
144
+ for (const session of this._sessions) {
145
+ if (session._liveStreams === 0) {
146
+ session.close();
147
+ }
148
+ }
149
+ return this;
150
+ };
151
+
152
+ Object.defineProperty(proto, 'sessions', {
153
+ get() {
154
+ return this._sessions;
155
+ },
156
+ configurable: true,
157
+ enumerable: false
158
+ });
159
+
160
+ // node's own Http2Server.setTimeout (internal/http2/core.js:3541) only records the value --
161
+ // `this.timeout = msecs`, the same field net.Server uses -- and attaches the callback. It
162
+ // does NOT walk the sessions. Measured against node with an idle connection and
163
+ // server.setTimeout(300): no 'timeout' on the session, no close, the connection simply
164
+ // stays. Arming a session idle timeout for every live session (what this used to do) both
165
+ // invented an event node does not emit and applied to connections that already existed,
166
+ // silently skipping the ones accepted later. The per-connection idle policy is
167
+ // session.setTimeout(), which is implemented on the session.
168
+ proto.setTimeout = function (msecs, cb) {
169
+ this.timeout = msecs;
170
+ if (cb !== undefined) {
171
+ if (typeof cb !== 'function') {
172
+ throw new TypeError('cb must be a function');
173
+ }
174
+ this.on('timeout', cb);
175
+ }
176
+ return this;
177
+ };
178
+
179
+ // node's Http2Server.updateSettings() (internal/http2/core.js, next to its setTimeout) only
180
+ // merges into the server's own settings object -- it does not walk the live sessions. That
181
+ // is exactly what the wire shows: after updateSettings({maxConcurrentStreams: 7}) the next
182
+ // connection is sent SETTINGS(3)=7 while an established session keeps the values it was
183
+ // opened with. `_sessionOptions.settings` is the object sessions are built from, so pointing
184
+ // it at the merged copy gives the same reach: new connections, no effect on old ones.
185
+ proto.updateSettings = function (settings) {
186
+ if (settings === null || typeof settings !== 'object') {
187
+ throw new TypeError('settings must be an object');
188
+ }
189
+ this.settings = { ...this.settings, ...settings };
190
+ this._sessionOptions.settings = this.settings;
191
+ // the values merged here become promises to the next peer, so they join the advertisement;
192
+ // first-seen order is kept, and it is the order the SETTINGS entries go out in
193
+ this._sessionOptions.advertised = [...new Set(
194
+ [...this._sessionOptions.advertised, ...Object.keys(settings)])];
195
+ return this;
196
+ };
197
+ }
198
+
199
+ class Http2Server extends net.Server {
200
+ constructor(options) {
201
+ super();
202
+ initHttp2Server(this, options);
203
+ this.on('connection', (socket) => attachSession(this, socket));
204
+ }
205
+ }
206
+
207
+ // node's h2s server insists on h2 and overwrites whatever ALPNProtocols the
208
+ // application passed: a peer offering only http/1.1 is refused by the handshake
209
+ // itself, with the TLS alert no_application_protocol, rather than after it. The
210
+ // list is therefore fixed here too, and the ALPN check in secureConnection below
211
+ // stays as the guard for contexts installed later through setSecureContext().
212
+ function alpnOptions(options) {
213
+ const opts = { ...(options || {}) };
214
+ opts.ALPNProtocols = ['h2'];
215
+ return opts;
216
+ }
217
+
218
+ class Http2SecureServer extends tls.Server {
219
+ constructor(options) {
220
+ super(alpnOptions(options));
221
+ initHttp2Server(this, options);
222
+ this.on('secureConnection', (socket) => {
223
+ // node's rule: 'h2' negotiated, or no ALPN at all -- which is h2 with prior
224
+ // knowledge carried over a TLS connection. Anything else is dropped without
225
+ // ever becoming a session.
226
+ const alpn = socket.alpnProtocol;
227
+ if (alpn === 'h2' || alpn === false) {
228
+ attachSession(this, socket);
229
+ } else {
230
+ socket.destroy();
231
+ }
232
+ });
233
+ }
234
+ }
235
+
236
+ applyHttp2ServerApi(Http2Server.prototype);
237
+ applyHttp2ServerApi(Http2SecureServer.prototype);
238
+
239
+ function createServer(options, onRequestHandler) {
240
+ let opts = options;
241
+ let handler = onRequestHandler;
242
+ if (typeof opts === 'function') {
243
+ handler = opts; opts = undefined;
244
+ }
245
+ const server = new Http2Server(opts);
246
+ if (typeof handler === 'function') {
247
+ server._setupCompat();
248
+ server.on('request', handler);
249
+ }
250
+ return server;
251
+ }
252
+
253
+ // node's createSecureServer takes the tls.Server options (key, cert, ca, ...) and
254
+ // hands the rest to the same machinery as the cleartext server.
255
+ function createSecureServer(options, onRequestHandler) {
256
+ let opts = options;
257
+ let handler = onRequestHandler;
258
+ if (typeof opts === 'function') {
259
+ handler = opts; opts = undefined;
260
+ }
261
+ const server = new Http2SecureServer(opts);
262
+ if (typeof handler === 'function') {
263
+ server._setupCompat();
264
+ server.on('request', handler);
265
+ }
266
+ return server;
267
+ }
268
+
269
+ module.exports = {
270
+ Http2Server, Http2SecureServer, createServer, createSecureServer, notImplemented,
271
+ Http2ServerRequest, Http2ServerResponse
272
+ };