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.
Files changed (2) hide show
  1. package/lib/server.js +167 -13
  2. 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 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().
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.ALPNProtocols = ['h2'];
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
- super(alpnOptions(options));
221
- initHttp2Server(this, options);
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 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.
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' || alpn === false) {
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.1.1",
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
  }