h2lean 0.1.1 → 0.2.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/lib/server.js +167 -13
- package/package.json +2 -1
package/lib/server.js
CHANGED
|
@@ -34,6 +34,22 @@
|
|
|
34
34
|
// rounds on an idle machine, 26 of 40 and 29 of 30 in two loaded runs) while h2lean reports
|
|
35
35
|
// the echo in every round of every run. test/event-order.test.js drops the echo outside its
|
|
36
36
|
// 'goaway' scenario for that reason.
|
|
37
|
+
// * allowHTTP1 + http1Options (both createSecureServer options, per node's docs): when the
|
|
38
|
+
// fallback is live, node serves HTTP/1.1 from the SAME object -- _http_server's private
|
|
39
|
+
// storeHTTPOptions() writes the HTTP/1 options into private fields of the Http2SecureServer
|
|
40
|
+
// and http's _connectionListener is bound to it -- so one emitter and one listener list
|
|
41
|
+
// carry both protocols. Those symbols are not reachable from node:http, so the fallback
|
|
42
|
+
// here is an embedded http.Server that never listens, fed the sockets the ALPN verdict
|
|
43
|
+
// hands it, with a newListener mirror (node's own setupCompat hook) carrying the
|
|
44
|
+
// application's request/upgrade/clientError/... listeners onto it. Three written-down
|
|
45
|
+
// consequences of that model: a removeListener does not unmirror (node has no such seam);
|
|
46
|
+
// the embedded server's own idle-connection bookkeeping never starts, because its
|
|
47
|
+
// 'listening' never fires, so the handed-off sockets are tracked here and close() shuts
|
|
48
|
+
// the idle keep-alive ones down while letting a response in flight finish (node waits
|
|
49
|
+
// through net.Server's own bookkeeping); and an unknownProtocol socket -- no ALPN, no
|
|
50
|
+
// allowHTTP1 -- is answered with node's 403 text instead of being destroyed silently,
|
|
51
|
+
// which is what node does (core.js connectionListener) and what this file used to claim
|
|
52
|
+
// wrong: no ALPN is not h2 prior knowledge, and it never became a session on node.
|
|
37
53
|
'use strict';
|
|
38
54
|
|
|
39
55
|
const net = require('node:net');
|
|
@@ -76,6 +92,21 @@ function initHttp2Server(server, options) {
|
|
|
76
92
|
};
|
|
77
93
|
server._sessions = new Set();
|
|
78
94
|
server[kCompat] = null;
|
|
95
|
+
// node validates this as uint32 and defaults it to 10000 (initializeOptions, core.js);
|
|
96
|
+
// it bounds how long an unknown-protocol socket may sit after the 403 text.
|
|
97
|
+
if (opts.unknownProtocolTimeout !== undefined &&
|
|
98
|
+
(!Number.isInteger(opts.unknownProtocolTimeout) || opts.unknownProtocolTimeout < 0)) {
|
|
99
|
+
const err = new RangeError(`Invalid unknownProtocolTimeout: ${opts.unknownProtocolTimeout}`);
|
|
100
|
+
err.code = 'ERR_OUT_OF_RANGE';
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
server._sessionOptions.unknownProtocolTimeout = opts.unknownProtocolTimeout ?? 10000;
|
|
104
|
+
// allowHTTP1 has nothing to negotiate over a plain socket (no ALPN, so the branch
|
|
105
|
+
// that would consume it is unreachable -- the same reason node's cleartext server
|
|
106
|
+
// ignores it), and http1Options is accepted here only so that the option bag a
|
|
107
|
+
// caller builds for node keeps working verbatim. The live HTTP/1 machinery is
|
|
108
|
+
// built, when allowHTTP1 is set, in initHttp1Fallback below.
|
|
109
|
+
server._http1 = null;
|
|
79
110
|
}
|
|
80
111
|
|
|
81
112
|
// Turns an accepted socket -- plain or already decrypted -- into a session.
|
|
@@ -132,7 +163,13 @@ function applyHttp2ServerApi(proto) {
|
|
|
132
163
|
// followed by closeAllSessions(this) (internal/http2/core): the listening socket goes away
|
|
133
164
|
// right away and its callback is net.Server's own. Waiting for the session set to empty first
|
|
134
165
|
// 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.
|
|
166
|
+
// 'close' then depended on every connection finishing. When the HTTP/1 fallback is live,
|
|
167
|
+
// node also runs httpServerPreClose() (closeIdleConnections + the checker interval); the
|
|
168
|
+
// public-API equivalent here shuts the idle keep-alive sockets down and lets the active
|
|
169
|
+
// responses finish, see closeHttp1().
|
|
170
|
+
if (this._http1) {
|
|
171
|
+
closeHttp1(this);
|
|
172
|
+
}
|
|
136
173
|
proto[kTransportClose].call(this, cb);
|
|
137
174
|
for (const session of [...this._sessions]) {
|
|
138
175
|
session.close();
|
|
@@ -204,28 +241,145 @@ class Http2Server extends net.Server {
|
|
|
204
241
|
}
|
|
205
242
|
}
|
|
206
243
|
|
|
207
|
-
// node's h2s server
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
244
|
+
// node's h2s server fixes the ALPN list the way initializeTLSOptions (core.js) does:
|
|
245
|
+
// ['h2'] -- with 'http/1.1' added when allowHTTP1 asks for the fallback -- and an
|
|
246
|
+
// options.ALPNCallback keeps the application's list out of reach. A peer offering only
|
|
247
|
+
// http/1.1 without allowHTTP1 is therefore still refused by the handshake itself, with
|
|
248
|
+
// the TLS alert no_application_protocol, rather than after it. The ALPN check in
|
|
249
|
+
// secureConnection below stays as the guard for contexts installed later through
|
|
250
|
+
// setSecureContext().
|
|
212
251
|
function alpnOptions(options) {
|
|
213
252
|
const opts = { ...(options || {}) };
|
|
214
|
-
opts.
|
|
253
|
+
if (!opts.ALPNCallback) {
|
|
254
|
+
opts.ALPNProtocols = ['h2'];
|
|
255
|
+
if (opts.allowHTTP1 === true) {
|
|
256
|
+
opts.ALPNProtocols.push('http/1.1');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
215
259
|
return opts;
|
|
216
260
|
}
|
|
217
261
|
|
|
262
|
+
// The events an HTTP/1 connection produces that an application registers on the h2
|
|
263
|
+
// server. node serves both protocols from ONE emitter (http's _connectionListener is
|
|
264
|
+
// bound to the Http2SecureServer itself), so a listener sees both kinds of request;
|
|
265
|
+
// the mirror below is what makes one 'request' handler keep working here, where the
|
|
266
|
+
// fallback lives on an embedded server instead.
|
|
267
|
+
const HTTP1_MIRROR = new Set([
|
|
268
|
+
'request', 'checkContinue', 'checkExpectation', 'upgrade', 'connect', 'clientError'
|
|
269
|
+
]);
|
|
270
|
+
|
|
271
|
+
// node's fallback is a mixin: _http_server's private storeHTTPOptions() writes the
|
|
272
|
+
// HTTP/1 options into private fields of the Http2SecureServer itself, so there is no
|
|
273
|
+
// second server object and no second listener list. Those symbols are not reachable
|
|
274
|
+
// from node:http, so the fallback here is a real http.Server that never listens and
|
|
275
|
+
// receives the sockets the ALPN verdict hands it. Consequences written down in the
|
|
276
|
+
// header comment: the mirror is one-way (removeListener does not unmirror), and the
|
|
277
|
+
// embedded server's own connection bookkeeping never starts because 'listening' never
|
|
278
|
+
// fires -- the handed-off sockets are tracked here instead.
|
|
279
|
+
function initHttp1Fallback(server, opts) {
|
|
280
|
+
const http = require('node:http');
|
|
281
|
+
// DEP0202 (doc/api/deprecations.md): the top-level Http1IncomingMessage and
|
|
282
|
+
// Http1ServerResponse names migrate into http1Options.
|
|
283
|
+
const http1Options = { ...(opts.http1Options || {}) };
|
|
284
|
+
if (opts.Http1IncomingMessage !== undefined && http1Options.IncomingMessage === undefined) {
|
|
285
|
+
http1Options.IncomingMessage = opts.Http1IncomingMessage;
|
|
286
|
+
}
|
|
287
|
+
if (opts.Http1ServerResponse !== undefined && http1Options.ServerResponse === undefined) {
|
|
288
|
+
http1Options.ServerResponse = opts.Http1ServerResponse;
|
|
289
|
+
}
|
|
290
|
+
server._http1 = new http.Server(http1Options);
|
|
291
|
+
server._http1Sockets = new Set();
|
|
292
|
+
server._http1Active = 0;
|
|
293
|
+
server._http1Closing = false;
|
|
294
|
+
server.on('newListener', (event, listener) => {
|
|
295
|
+
if (HTTP1_MIRROR.has(event)) {
|
|
296
|
+
server._http1.on(event, listener);
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
// Per-socket activity, so close() can tell an idle keep-alive socket (destroy it,
|
|
300
|
+
// the way closeIdleConnections does on node) from one with a response in flight.
|
|
301
|
+
server._http1.on('request', (req, res) => {
|
|
302
|
+
server._http1Active++;
|
|
303
|
+
req.socket._h2leanActive = true;
|
|
304
|
+
res.once('finish', () => {
|
|
305
|
+
server._http1Active--;
|
|
306
|
+
req.socket._h2leanActive = false;
|
|
307
|
+
if (server._http1Closing && server._http1Active === 0) {
|
|
308
|
+
for (const socket of server._http1Sockets) {
|
|
309
|
+
socket.destroy();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function handOffHttp1(server, socket) {
|
|
317
|
+
server._http1Sockets.add(socket);
|
|
318
|
+
socket.once('close', () => server._http1Sockets.delete(socket));
|
|
319
|
+
server._http1.emit('connection', socket);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// The public-API equivalent of node's httpServerPreClose(): the idle keep-alive
|
|
323
|
+
// sockets go away right away, a response in flight is allowed to finish, and when the
|
|
324
|
+
// last one finishes the sockets it was talking on go too. node's mixin waits through
|
|
325
|
+
// net.Server's own connection bookkeeping instead; that bookkeeping is exactly what
|
|
326
|
+
// the embedded-server model cannot reach.
|
|
327
|
+
function closeHttp1(server) {
|
|
328
|
+
server._http1Closing = true;
|
|
329
|
+
for (const socket of [...server._http1Sockets]) {
|
|
330
|
+
if (!socket._h2leanActive) {
|
|
331
|
+
socket.destroy();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function unknownProtocol(server, socket) {
|
|
337
|
+
if (server.emit('unknownProtocol', socket)) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
// core.js connectionListener: with no listener, the socket is told in a format it
|
|
341
|
+
// might understand why it got nothing, and an unref'd timer bounds how long a peer
|
|
342
|
+
// that never closes can hold it.
|
|
343
|
+
socket.end('HTTP/1.0 403 Forbidden\r\n' +
|
|
344
|
+
'Content-Type: text/plain\r\n\r\n' +
|
|
345
|
+
'Missing ALPN Protocol, expected `h2` to be available.\n' +
|
|
346
|
+
'If this is a HTTP request: The server was not ' +
|
|
347
|
+
'configured with the `allowHTTP1` option or a ' +
|
|
348
|
+
'listener for the `unknownProtocol` event.\n');
|
|
349
|
+
const timer = setTimeout(() => {
|
|
350
|
+
if (!socket.destroyed) {
|
|
351
|
+
socket.destroy();
|
|
352
|
+
}
|
|
353
|
+
}, server._sessionOptions.unknownProtocolTimeout);
|
|
354
|
+
timer.unref();
|
|
355
|
+
socket.once('close', () => clearTimeout(timer));
|
|
356
|
+
}
|
|
357
|
+
|
|
218
358
|
class Http2SecureServer extends tls.Server {
|
|
219
359
|
constructor(options) {
|
|
220
|
-
|
|
221
|
-
|
|
360
|
+
const opts = options || {};
|
|
361
|
+
super(alpnOptions(opts));
|
|
362
|
+
initHttp2Server(this, opts);
|
|
363
|
+
if (opts.allowHTTP1 === true) {
|
|
364
|
+
initHttp1Fallback(this, opts);
|
|
365
|
+
}
|
|
222
366
|
this.on('secureConnection', (socket) => {
|
|
223
|
-
// node's
|
|
224
|
-
//
|
|
225
|
-
//
|
|
367
|
+
// node's connectionListener (core.js): 'h2' negotiated becomes a session;
|
|
368
|
+
// 'http/1.1' -- or no ALPN at all, which is NOT h2 prior knowledge, whatever an
|
|
369
|
+
// older comment here claimed -- goes to the HTTP/1 fallback when allowHTTP1 is
|
|
370
|
+
// set, and otherwise to 'unknownProtocol', with the 403 text when nobody
|
|
371
|
+
// listens. Measured on node 26 in this session: a no-ALPN client speaking
|
|
372
|
+
// HTTP/1.1 is served by the fallback under allowHTTP1 and receives the 403
|
|
373
|
+
// without it.
|
|
226
374
|
const alpn = socket.alpnProtocol;
|
|
227
|
-
if (alpn === 'h2'
|
|
375
|
+
if (alpn === 'h2') {
|
|
228
376
|
attachSession(this, socket);
|
|
377
|
+
} else if (alpn === 'http/1.1' || alpn === false) {
|
|
378
|
+
if (this._http1) {
|
|
379
|
+
handOffHttp1(this, socket);
|
|
380
|
+
} else {
|
|
381
|
+
unknownProtocol(this, socket);
|
|
382
|
+
}
|
|
229
383
|
} else {
|
|
230
384
|
socket.destroy();
|
|
231
385
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "h2lean",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Pure-JavaScript HTTP/2 for Node.js — a drop-in node:http2 replacement (server and client, h2c and TLS) with zero dependencies",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@eslint/js": "^10.0.1",
|
|
47
47
|
"eslint": "^10.14.0",
|
|
48
|
+
"fastify": "^5.12.8",
|
|
48
49
|
"fast-xml-parser": "^5.12.0",
|
|
49
50
|
"uWebSockets.js": "github:uNetworking/UWebSockets.js#v20.70.0"
|
|
50
51
|
}
|