h2lean 0.1.0 → 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/README.md +33 -33
- package/index.js +1 -1
- package/lib/client-session.js +2 -2
- package/lib/client-stream.js +1 -1
- package/lib/client.js +3 -3
- package/lib/compat.js +10 -10
- package/lib/fast-timer.js +2 -2
- package/lib/headers.js +1 -1
- package/lib/hpack.js +1 -1
- package/lib/server.js +176 -22
- package/lib/session.js +12 -12
- package/lib/stream.js +4 -4
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="assets/banner.svg" alt="
|
|
3
|
+
<img src="assets/banner.svg" alt="h2lean banner" width="720">
|
|
4
4
|
|
|
5
5
|
### The pure-JavaScript HTTP/2 stack for Node.js
|
|
6
6
|
|
|
7
7
|
**Drop-in replacement for `node:http2` — without the nghttp2 C layer.**
|
|
8
8
|
|
|
9
|
-
[](https://www.npmjs.com/package/
|
|
9
|
+
[](https://www.npmjs.com/package/h2lean)
|
|
10
10
|
[](https://nodejs.org)
|
|
11
11
|
[](package.json)
|
|
12
12
|
[](LICENSE)
|
|
@@ -19,7 +19,7 @@ Up to **8× less CPU per request** and **6.8× more requests per second** than `
|
|
|
19
19
|
|
|
20
20
|
## 📑 Table of Contents
|
|
21
21
|
|
|
22
|
-
- ⚡ [Why
|
|
22
|
+
- ⚡ [Why h2lean?](#-why-h2lean)
|
|
23
23
|
- 🏎️ [Performance](#️-performance)
|
|
24
24
|
- 🔌 [Compatibility — Drop-in Replacement](#-compatibility--drop-in-replacement)
|
|
25
25
|
- 📦 [Installation](#-installation)
|
|
@@ -37,18 +37,18 @@ Up to **8× less CPU per request** and **6.8× more requests per second** than `
|
|
|
37
37
|
- 🤝 [Contributing](#-contributing)
|
|
38
38
|
- ⚖️ [License](#️-license)
|
|
39
39
|
|
|
40
|
-
## ⚡ Why
|
|
40
|
+
## ⚡ Why h2lean?
|
|
41
41
|
|
|
42
42
|
Node's native `node:http2` (stable since v10.10.0) has a fundamental architectural constraint: every HTTP/2 frame crosses the JS ↔ C++ boundary between V8 and bundled nghttp2. Frame dispatch, HPACK encoding/decoding, and flow-control bookkeeping all pay for that crossing — on every single frame of every single request.
|
|
43
43
|
|
|
44
|
-
**
|
|
44
|
+
**h2lean removes the crossing entirely.** The entire protocol — frame parsing, HPACK (RFC 7541), flow control, connection state machines — lives in pure JavaScript, tuned item by item against measured data ([OPTIMIZATION.md](./OPTIMIZATION.md), 35 numbered techniques). Zero native code, zero runtime dependencies.
|
|
45
45
|
|
|
46
46
|
The result is a library that keeps `node:http2`'s exact public surface while spending dramatically less CPU per request. Perfect for:
|
|
47
47
|
|
|
48
48
|
- 🧩 **Microservices** — header-heavy service-to-service traffic is where the gap is widest
|
|
49
49
|
- 📡 **gRPC servers** — many small streams, all header-bound
|
|
50
50
|
- 🤖 **AI backends** — long-lived streams, high connection counts, every CPU cycle matters
|
|
51
|
-
- 🔁 **Behind a reverse proxy** — pair
|
|
51
|
+
- 🔁 **Behind a reverse proxy** — pair h2lean with an HTTP/2-terminating edge and stop paying for HTTP/1.1
|
|
52
52
|
|
|
53
53
|
## 🏎️ Performance
|
|
54
54
|
|
|
@@ -61,14 +61,14 @@ Every number below is `node`'s own per-request CPU time (μs/req, read inside th
|
|
|
61
61
|
| node:http (HTTP/1.1) | 9.11 | 111,816 | 1.21× |
|
|
62
62
|
| uWebSockets C++ (HTTP/1.1) | 4.87 | 203,283 | 2.27× |
|
|
63
63
|
| node:http2 (stream API) | 7.30 | 171,835 | 1.52× |
|
|
64
|
-
| **
|
|
65
|
-
| **
|
|
64
|
+
| **h2lean (stream API)** | **1.38** | **765,149** | **8.01×** |
|
|
65
|
+
| **h2lean (compat API)** | **2.85** | **367,275** | **3.88×** |
|
|
66
66
|
|
|
67
|
-
**Server, with response body:** 16 kB — **2.50× less CPU** than `node:http2` ([PERFORMANCE.md](./PERFORMANCE.md)). 256 kB — parity, as expected: the work is body-bound, and
|
|
67
|
+
**Server, with response body:** 16 kB — **2.50× less CPU** than `node:http2` ([PERFORMANCE.md](./PERFORMANCE.md)). 256 kB — parity, as expected: the work is body-bound, and h2lean does not win by cutting corners.
|
|
68
68
|
|
|
69
|
-
**Client, same-server paired measurement** (`
|
|
69
|
+
**Client, same-server paired measurement** (`h2lean.connect()` vs `node:http2.connect()`, driven against nghttpd):
|
|
70
70
|
|
|
71
|
-
| Workload | node client μs/req |
|
|
71
|
+
| Workload | node client μs/req | h2lean client μs/req | Advantage |
|
|
72
72
|
|---|--:|--:|---|
|
|
73
73
|
| Headers only | 12.08 | **3.61** | 3.35× |
|
|
74
74
|
| 16 kB | 19.66 | **11.73** | 1.68× |
|
|
@@ -78,11 +78,11 @@ Every number below is `node`'s own per-request CPU time (μs/req, read inside th
|
|
|
78
78
|
|
|
79
79
|
## 🔌 Compatibility — Drop-in Replacement
|
|
80
80
|
|
|
81
|
-
Compatibility target is the **public surface** of `node:http2`: names, argument shapes, event orders, wire-visible frame sequences, `rstCode`/`goawayCode` numbers — verified differentially. The test suite runs the **same application scripts** against both `node:http2` and
|
|
81
|
+
Compatibility target is the **public surface** of `node:http2`: names, argument shapes, event orders, wire-visible frame sequences, `rstCode`/`goawayCode` numbers — verified differentially. The test suite runs the **same application scripts** against both `node:http2` and h2lean and compares the transcripts ([test/diff-harness.js](./test/diff-harness.js)); [h2spec](https://github.com/summerwind/h2spec) conformance is 146/146 — the same verdict node's own HTTP/2 receives.
|
|
82
82
|
|
|
83
83
|
```diff
|
|
84
84
|
- const http2 = require('node:http2');
|
|
85
|
-
+ const http2 = require('
|
|
85
|
+
+ const http2 = require('h2lean');
|
|
86
86
|
|
|
87
87
|
const server = http2.createServer((req, res) => {
|
|
88
88
|
res.end('hello world');
|
|
@@ -97,7 +97,7 @@ Where behavior deliberately differs from node, it is **documented, measured, and
|
|
|
97
97
|
## 📦 Installation
|
|
98
98
|
|
|
99
99
|
```bash
|
|
100
|
-
npm install
|
|
100
|
+
npm install h2lean
|
|
101
101
|
```
|
|
102
102
|
|
|
103
103
|
Requires Node.js **≥ 20** (verified against v26.12.1). Zero runtime dependencies — `lib/` imports nothing but `node:` builtins.
|
|
@@ -109,12 +109,12 @@ Requires Node.js **≥ 20** (verified against v26.12.1). Zero runtime dependenci
|
|
|
109
109
|
The compat API feels like HTTP/1.1 but speaks HTTP/2 — this is what replaces `node:http2` in an existing application:
|
|
110
110
|
|
|
111
111
|
```javascript
|
|
112
|
-
const http2 = require('
|
|
112
|
+
const http2 = require('h2lean');
|
|
113
113
|
|
|
114
114
|
const server = http2.createServer((req, res) => {
|
|
115
115
|
// req is an Http2ServerRequest, res is an Http2ServerResponse
|
|
116
116
|
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
117
|
-
res.end('hello from
|
|
117
|
+
res.end('hello from h2lean');
|
|
118
118
|
});
|
|
119
119
|
|
|
120
120
|
server.listen(8080);
|
|
@@ -125,7 +125,7 @@ server.listen(8080);
|
|
|
125
125
|
Maximum throughput: skip the compat objects entirely and work with streams and header maps:
|
|
126
126
|
|
|
127
127
|
```javascript
|
|
128
|
-
const http2 = require('
|
|
128
|
+
const http2 = require('h2lean');
|
|
129
129
|
|
|
130
130
|
const server = http2.createServer();
|
|
131
131
|
|
|
@@ -140,7 +140,7 @@ server.listen(8081);
|
|
|
140
140
|
### Secure server (h2s / TLS)
|
|
141
141
|
|
|
142
142
|
```javascript
|
|
143
|
-
const http2 = require('
|
|
143
|
+
const http2 = require('h2lean');
|
|
144
144
|
|
|
145
145
|
const server = http2.createSecureServer({
|
|
146
146
|
key: require('fs').readFileSync('key.pem'),
|
|
@@ -160,7 +160,7 @@ TLS decisions stay delegated to `node:tls` — ALPN is pinned to `h2` exactly th
|
|
|
160
160
|
### Client — `connect()`
|
|
161
161
|
|
|
162
162
|
```javascript
|
|
163
|
-
const http2 = require('
|
|
163
|
+
const http2 = require('h2lean');
|
|
164
164
|
|
|
165
165
|
const session = http2.connect('http://localhost:8080');
|
|
166
166
|
|
|
@@ -179,20 +179,20 @@ Works over `https://…` too — the client speaks both h2c and h2s.
|
|
|
179
179
|
|
|
180
180
|
### Fastify and other frameworks
|
|
181
181
|
|
|
182
|
-
Any framework that lets you hand it a server instance takes
|
|
182
|
+
Any framework that lets you hand it a server instance takes h2lean in two lines. Fastify's [`serverFactory`](https://fastify.dev/docs/latest/Reference/Server/#serverfactory):
|
|
183
183
|
|
|
184
184
|
```javascript
|
|
185
185
|
const Fastify = require('fastify');
|
|
186
|
-
const
|
|
186
|
+
const h2lean = require('h2lean');
|
|
187
187
|
|
|
188
188
|
const fastify = Fastify({
|
|
189
189
|
http2: true,
|
|
190
190
|
serverFactory: (handler) => {
|
|
191
|
-
return
|
|
191
|
+
return h2lean.createServer((req, res) => handler(req, res));
|
|
192
192
|
}
|
|
193
193
|
});
|
|
194
194
|
|
|
195
|
-
fastify.get('/', async () => ({ hello: 'from
|
|
195
|
+
fastify.get('/', async () => ({ hello: 'from h2lean under Fastify' }));
|
|
196
196
|
fastify.listen({ port: 8080 });
|
|
197
197
|
```
|
|
198
198
|
|
|
@@ -203,16 +203,16 @@ If a framework requires `node:http2` internally and offers no injection point, p
|
|
|
203
203
|
```javascript
|
|
204
204
|
// patch.js — require this first: node -r ./patch.js app.js
|
|
205
205
|
const nodeHttp2 = require('node:http2');
|
|
206
|
-
const
|
|
206
|
+
const h2lean = require('h2lean');
|
|
207
207
|
|
|
208
|
-
nodeHttp2.createServer =
|
|
209
|
-
nodeHttp2.createSecureServer =
|
|
210
|
-
nodeHttp2.connect =
|
|
208
|
+
nodeHttp2.createServer = h2lean.createServer;
|
|
209
|
+
nodeHttp2.createSecureServer = h2lean.createSecureServer;
|
|
210
|
+
nodeHttp2.connect = h2lean.connect;
|
|
211
211
|
```
|
|
212
212
|
|
|
213
213
|
## 🛠️ Advanced Tuning
|
|
214
214
|
|
|
215
|
-
- ♻️ **Freeze reusable header objects.** `Object.freeze()` on a header set enables
|
|
215
|
+
- ♻️ **Freeze reusable header objects.** `Object.freeze()` on a header set enables h2lean's per-connection response-block cache — the HPACK bytes are encoded once and copied afterwards. Measured **~25% CPU saved** on a headers-only workload ([PERFORMANCE.md](./PERFORMANCE.md)):
|
|
216
216
|
|
|
217
217
|
```javascript
|
|
218
218
|
const HEADERS = Object.freeze({ ':status': 200, 'content-type': 'application/json' });
|
|
@@ -225,18 +225,18 @@ nodeHttp2.connect = h2lite.connect;
|
|
|
225
225
|
|
|
226
226
|
## 🔒 Security
|
|
227
227
|
|
|
228
|
-
|
|
228
|
+
h2lean terminates **untrusted bytes from the internet** and turns them into objects your application trusts — the threat model is treated as the primary design constraint ([SECURITY.md](./SECURITY.md)):
|
|
229
229
|
|
|
230
|
-
- 🛡️ **Every denial-of-service limit matches nghttp2** (the engine inside node) or was measured against a live node server — a peer's budget against node is its budget against
|
|
231
|
-
- 🚫 **Request-smuggling defense by construction** (RFC 9113 §10.3): field names must be lower-case tokens, values may not carry NUL/CR/LF, connection-specific fields are refused in both directions — all refused at encode time, *before a single octet reaches the wire*.
|
|
230
|
+
- 🛡️ **Every denial-of-service limit matches nghttp2** (the engine inside node) or was measured against a live node server — a peer's budget against node is its budget against h2lean: SETTINGS-entry cap (CVE-2020-11080), CONTINUATION cap (CVE-2024-28182), rapid-reset budget (CVE-2023-44487), outbound staging, parked-stream and history bounds.
|
|
231
|
+
- 🚫 **Request-smuggling defense by construction** (RFC 9113 §10.3): field names must be lower-case tokens, values may not carry NUL/CR/LF, connection-specific fields are refused in both directions — all refused at encode time, *before a single octet reaches the wire*. h2lean is stricter than node here, deliberately and measurably.
|
|
232
232
|
- ✅ **Verified, not assumed:**
|
|
233
|
-
- **300+ differential tests** — application scripts run against both `node:http2` and
|
|
233
|
+
- **300+ differential tests** — application scripts run against both `node:http2` and h2lean, transcripts compared;
|
|
234
234
|
- **h2spec 146/146** — full RFC 9113/7541 conformance, same verdict as node's own HTTP/2 (`npm run spec`);
|
|
235
235
|
- **mutation-tested guards** — every defensive limit is rewritten in place and proven to fail the suite ([tools/mutate-guards.js](./tools/mutate-guards.js)).
|
|
236
236
|
|
|
237
237
|
## ⚠️ Not Implemented — Said Loudly
|
|
238
238
|
|
|
239
|
-
Two APIs are absent, and both fail **loudly** with `
|
|
239
|
+
Two APIs are absent, and both fail **loudly** with `ERR_H2LEAN_NOT_IMPLEMENTED` — never a silent wrong answer:
|
|
240
240
|
|
|
241
241
|
- **Server push** — removed from browsers in 2022 and considered obsolete; the safer replacement is [Early Hints (103)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/103).
|
|
242
242
|
- **`performServerHandshake`** — no known use case outside node's own tests.
|
package/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// h2lean -- a pure-JS HTTP/2 library with a node:http2-shaped API.
|
|
2
2
|
//
|
|
3
3
|
// Servers: h2c and h2s (prior-knowledge, ALPN 'h2'), the `stream` and compat
|
|
4
4
|
// (`request`) APIs. Client: `connect()` + `session.request()` over h2c and h2s
|
package/lib/client-session.js
CHANGED
|
@@ -561,7 +561,7 @@ class ClientHttp2Session extends EventEmitter {
|
|
|
561
561
|
// is up (internal/http2/core.js setupHandle -> nextTick(emit, 'connect')) and
|
|
562
562
|
// submits the request then, so the request goes out in the first flight. Measured
|
|
563
563
|
// against a raw server that delays its SETTINGS by 200 ms: node's request HEADERS
|
|
564
|
-
// arrive at 5 ms, while
|
|
564
|
+
// arrive at 5 ms, while h2lean's used to arrive at 225 ms -- after the SETTINGS it
|
|
565
565
|
// was waiting for -- which is a round trip on every session's first request
|
|
566
566
|
// (tools/probe-client-first.js).
|
|
567
567
|
//
|
|
@@ -1795,7 +1795,7 @@ class ClientHttp2Session extends EventEmitter {
|
|
|
1795
1795
|
// state.pendingStreams.forEach((stream) => stream.destroy(cancel))) so an
|
|
1796
1796
|
// application can tell "my request was never sent" from "the connection broke
|
|
1797
1797
|
// under it". Measured on a TLS handshake failure: node answers a queued request
|
|
1798
|
-
// with stream error ERR_HTTP2_STREAM_CANCEL before the session error,
|
|
1798
|
+
// with stream error ERR_HTTP2_STREAM_CANCEL before the session error, h2lean used
|
|
1799
1799
|
// to leave it silent.
|
|
1800
1800
|
if (this._queuedReq !== null && this._queuedReq.length > 0) {
|
|
1801
1801
|
for (const r of this._queuedReq) {
|
package/lib/client-stream.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// `internal/http2/core.js`. Two shapes are load-bearing for a drop-in:
|
|
5
5
|
// * it is a `stream.Duplex` with `allowHalfOpen: true`, so ending the request
|
|
6
6
|
// body (writable) does not close the response (readable). node's `Http2Stream`
|
|
7
|
-
// extends Duplex (core.js:2128);
|
|
7
|
+
// extends Duplex (core.js:2128); h2lean's SERVER stream is an EventEmitter
|
|
8
8
|
// (AGENTS.md 10) but a client stream the application `.pipe()`s and reads
|
|
9
9
|
// `:status` off has to be a real Duplex or the common
|
|
10
10
|
// `req.pipe(server).pipe(res)` idiom breaks.
|
package/lib/client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// connect(): the client front door. Mirrors node's `connect(authority, options,
|
|
2
2
|
// listener)` in internal/http2/core.js -- same argument juggling, same
|
|
3
3
|
// authority/protocol/port derivation, same `createConnection` override -- so that
|
|
4
|
-
// `require('
|
|
4
|
+
// `require('h2lean').connect(url)` is a drop-in for `require('node:http2').connect(url)`.
|
|
5
5
|
//
|
|
6
6
|
// Both transports are supported: h2c over `net`, and h2s over `tls.connect` the
|
|
7
7
|
// way core.js initializeTLSOptions builds it -- the ALPN list is forced to ['h2']
|
|
@@ -19,8 +19,8 @@ const tls = require('node:tls');
|
|
|
19
19
|
const { ClientHttp2Session } = require('./client-session');
|
|
20
20
|
|
|
21
21
|
function notImplemented(what) {
|
|
22
|
-
const err = new Error(`
|
|
23
|
-
err.code = '
|
|
22
|
+
const err = new Error(`h2lean: not implemented: ${what}`);
|
|
23
|
+
err.code = 'ERR_H2LEAN_NOT_IMPLEMENTED';
|
|
24
24
|
return err;
|
|
25
25
|
}
|
|
26
26
|
|
package/lib/compat.js
CHANGED
|
@@ -5,22 +5,22 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Deliberate differences from node, all of them loud rather than silent:
|
|
7
7
|
// * req.pause()/read() only affect the local Readable. The flow-control window
|
|
8
|
-
// is still replenished, because
|
|
8
|
+
// is still replenished, because h2lean releases stream credit as the buffered
|
|
9
9
|
// body is consumed and connection credit in half-window steps, neither of them
|
|
10
10
|
// per read(). A paused request buffers in memory instead of slowing the peer down.
|
|
11
|
-
// * res.cork()/uncork() are absent, not no-ops:
|
|
11
|
+
// * res.cork()/uncork() are absent, not no-ops: h2lean already coalesces every
|
|
12
12
|
// frame produced in one turn of the loop into a single write().
|
|
13
13
|
// * res.write() returning false is honest backpressure, and 'drain' is emitted
|
|
14
14
|
// when the send window reopens.
|
|
15
|
-
// * createPushResponse() throws
|
|
15
|
+
// * createPushResponse() throws ERR_H2LEAN_NOT_IMPLEMENTED (no server push in
|
|
16
16
|
// v1) instead of failing later with a protocol error.
|
|
17
17
|
// * res 'finish' means the local half of the stream was closed, which for
|
|
18
|
-
//
|
|
18
|
+
// h2lean happens as the frames are queued for the socket; node reports it
|
|
19
19
|
// when the stream closes, which is later. A response the peer cut short
|
|
20
20
|
// reports 'finish' on both stacks.
|
|
21
21
|
// * the order of the teardown events differs: node reports the request's
|
|
22
22
|
// events (its 'end', its 'aborted', its 'close') before the response's
|
|
23
|
-
// ('finish', then 'close'),
|
|
23
|
+
// ('finish', then 'close'), h2lean reports the response's first. Every
|
|
24
24
|
// event fires on both stacks -- measured against a live node server over
|
|
25
25
|
// the four shapes of bench/micro/probe-teardown-order.js, the sets are equal -- and
|
|
26
26
|
// only their order within the teardown turn differs. One pair is not a
|
|
@@ -108,7 +108,7 @@ function describeType(value) {
|
|
|
108
108
|
// rewrite is refused loudly instead of silently corrupting another request.
|
|
109
109
|
function assertWritable(headers) {
|
|
110
110
|
if (Object.isFrozen(headers)) {
|
|
111
|
-
throw err('
|
|
111
|
+
throw err('ERR_H2LEAN_FROZEN_HEADERS',
|
|
112
112
|
'req.headers is frozen because the server was created with shareHeaders:true');
|
|
113
113
|
}
|
|
114
114
|
}
|
|
@@ -396,7 +396,7 @@ function onResponseClose() {
|
|
|
396
396
|
res.writable = false;
|
|
397
397
|
// node emits 'finish' and 'close' together, when the stream closes
|
|
398
398
|
// (internal/http2/compat, onStreamCloseResponse), so a response the peer cut
|
|
399
|
-
// short still reports 'finish'.
|
|
399
|
+
// short still reports 'finish'. h2lean emits 'finish' when the application
|
|
400
400
|
// ends the response, which is the same event one turn earlier; this covers
|
|
401
401
|
// the case where the application never got that far.
|
|
402
402
|
if (!res._state.finishEmitted) {
|
|
@@ -651,7 +651,7 @@ class Http2ServerResponse extends Stream {
|
|
|
651
651
|
// either. A merged copy was built here until the header path was measured:
|
|
652
652
|
// it bought nothing (OPTIMIZATION.md item 25) -- the map is never frozen
|
|
653
653
|
// and the HPACK block cache -- which is keyed by the identity of a frozen
|
|
654
|
-
// object -- cannot use it.
|
|
654
|
+
// object -- cannot use it. h2lean's own stream.sentHeaders is that map;
|
|
655
655
|
// node's server-side getter
|
|
656
656
|
// returns undefined, because its respond() never sets the field.
|
|
657
657
|
stream.respond(this._headers, {
|
|
@@ -770,7 +770,7 @@ class Http2ServerResponse extends Stream {
|
|
|
770
770
|
}
|
|
771
771
|
|
|
772
772
|
if (this._trailerNames > 0) {
|
|
773
|
-
// node sends the trailers from the 'wantTrailers' callback;
|
|
773
|
+
// node sends the trailers from the 'wantTrailers' callback; h2lean closes
|
|
774
774
|
// the local side as soon as the application says so, so the recorded
|
|
775
775
|
// trailers go out here, carrying END_STREAM with them
|
|
776
776
|
stream.sendTrailers(this._trailers);
|
|
@@ -802,7 +802,7 @@ class Http2ServerResponse extends Stream {
|
|
|
802
802
|
}
|
|
803
803
|
|
|
804
804
|
createPushResponse(headers, callback) {
|
|
805
|
-
const e = err('
|
|
805
|
+
const e = err('ERR_H2LEAN_NOT_IMPLEMENTED', 'h2lean v1 does not implement server push');
|
|
806
806
|
if (typeof callback !== 'function') {
|
|
807
807
|
throw e;
|
|
808
808
|
}
|
package/lib/fast-timer.js
CHANGED
|
@@ -4,10 +4,10 @@
|
|
|
4
4
|
//
|
|
5
5
|
// This file is deliberately a byte-for-byte copy of that source, with no local
|
|
6
6
|
// changes, so that it can be lifted out into a standalone npm package and have
|
|
7
|
-
//
|
|
7
|
+
// h2lean require it instead. Any deviation here would make that extraction a
|
|
8
8
|
// merge rather than a move, and would break the upstream tests that come with it.
|
|
9
9
|
//
|
|
10
|
-
// What
|
|
10
|
+
// What h2lean uses it for: one shared 499 ms clock for all idle/keep-alive
|
|
11
11
|
// timeouts, instead of one libuv timer handle per connection. See
|
|
12
12
|
// Http2Session.setTimeout() in lib/session.js.
|
|
13
13
|
|
package/lib/headers.js
CHANGED
|
@@ -160,7 +160,7 @@ function nameCacheSize() {
|
|
|
160
160
|
// A field whose value carries one is ignored (NGHTTP2_ERR_IGN_HTTP_HEADER) and the
|
|
161
161
|
// stream lives -- the request is served, without the field.
|
|
162
162
|
//
|
|
163
|
-
// The OUTBOUND check is
|
|
163
|
+
// The OUTBOUND check is h2lean's own, and stricter than node's: node does not look at
|
|
164
164
|
// an outbound value at all and puts a CR on the wire (bench/micro/probe-out-value-matrix.js --
|
|
165
165
|
// every one of the fifteen characters tried is accepted, and the CR arrives in the
|
|
166
166
|
// HEADERS frame). Refusing here is the mitigation the CVE-2020-8287 class needs: the
|
package/lib/hpack.js
CHANGED
package/lib/server.js
CHANGED
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// documentation:
|
|
7
7
|
// * node's Http2Server has close, setTimeout and updateSettings -- and NOTHING else: no
|
|
8
8
|
// sessionTimeout, no closeIdleSessions, no sessions getter, no settings property. node keeps
|
|
9
|
-
// the settings updateSettings merges in a private options object;
|
|
9
|
+
// the settings updateSettings merges in a private options object; h2lean keeps them in the
|
|
10
10
|
// public `settings` field it also builds sessions from, which is a third name node does not
|
|
11
11
|
// have. The extras implemented here are additive, not substitutions:
|
|
12
|
-
// server.sessions -- the live session set, kept because
|
|
12
|
+
// server.sessions -- the live session set, kept because h2lean tracks sessions
|
|
13
13
|
// in it and node keeps the same set under a symbol;
|
|
14
14
|
// server.closeIdleSessions() -- documented by node for years and gone in 26; the
|
|
15
15
|
// keep-alive policy it served is session.setTimeout() plus
|
|
@@ -26,14 +26,30 @@
|
|
|
26
26
|
// * closing a connection is not the same MOMENT on the two stacks, and one event depends on it.
|
|
27
27
|
// node's session survives close() until its graceful close completes (internal/http2/core.js:
|
|
28
28
|
// handle.ongracefulclosecomplete -> kMaybeDestroy), so it can still read -- and report -- the
|
|
29
|
-
// peer's answer to the GOAWAY we just sent.
|
|
29
|
+
// peer's answer to the GOAWAY we just sent. h2lean destroys the session as soon as that GOAWAY
|
|
30
30
|
// is written (lib/session.js: close() -> _maybeFinishClose), but its socket stays readable
|
|
31
31
|
// until the write is flushed, so the answer is read there too. The two therefore disagree
|
|
32
32
|
// about ONE event -- the 'goaway' the peer's answer produces -- and only inside a window:
|
|
33
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
|
|
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');
|
|
@@ -41,17 +57,17 @@ const tls = require('node:tls');
|
|
|
41
57
|
const { Http2Session, OUR_DEFAULTS } = require('./session');
|
|
42
58
|
const { onServerStream, Http2ServerRequest, Http2ServerResponse } = require('./compat');
|
|
43
59
|
|
|
44
|
-
const kCompat = Symbol('
|
|
60
|
+
const kCompat = Symbol('h2lean.compatListener');
|
|
45
61
|
// The transport's own close/on, captured before the shared API overrides them:
|
|
46
62
|
// net.Server and tls.Server are unrelated base classes and JS has only single
|
|
47
63
|
// inheritance, so the shared behaviour is mixed onto the prototype the same way
|
|
48
64
|
// node's own core does it, with the overridden methods kept reachable by symbol.
|
|
49
|
-
const kTransportClose = Symbol('
|
|
50
|
-
const kTransportOn = Symbol('
|
|
65
|
+
const kTransportClose = Symbol('h2lean.transportClose');
|
|
66
|
+
const kTransportOn = Symbol('h2lean.transportOn');
|
|
51
67
|
|
|
52
68
|
function notImplemented(what) {
|
|
53
|
-
const err = new Error(`
|
|
54
|
-
err.code = '
|
|
69
|
+
const err = new Error(`h2lean v1 does not implement ${what}`);
|
|
70
|
+
err.code = 'ERR_H2LEAN_NOT_IMPLEMENTED';
|
|
55
71
|
return err;
|
|
56
72
|
}
|
|
57
73
|
|
|
@@ -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/lib/session.js
CHANGED
|
@@ -84,7 +84,7 @@ const MAX_SHAPES = 128;
|
|
|
84
84
|
// v26.12.1 with tools/probe-inbound-limits.js: a 4-field block whose names and values total 46
|
|
85
85
|
// octets is REFUSED under a limit of 48 (46 + 4*32 = 174), and for a 5-field block the line
|
|
86
86
|
// between served and refused falls between 87 and 93 decoded octets (250 - 5*32 = 90). The
|
|
87
|
-
// plain sum of the field octets is what
|
|
87
|
+
// plain sum of the field octets is what h2lean compared, and it is 128 entry octets away from
|
|
88
88
|
// node's verdict for a four-field request: three of the measured blocks were served that node
|
|
89
89
|
// refuses. test/inbound-limits.test.js pins both sides of the line.
|
|
90
90
|
const ENTRY_OVERHEAD = 32;
|
|
@@ -139,7 +139,7 @@ const OPT_NO_END_STREAM = { endStream: false };
|
|
|
139
139
|
// How long the teardown waits for the goodbye to reach the peer before the socket is
|
|
140
140
|
// taken away anyway. The FIN is queued behind whatever we wrote, so a peer that stopped
|
|
141
141
|
// reading would hold it forever -- node does not have that problem because its data sits
|
|
142
|
-
// inside nghttp2 until the window allows it, while
|
|
142
|
+
// inside nghttp2 until the window allows it, while h2lean hands it to the socket. The
|
|
143
143
|
// wait itself is what makes the difference between a closed connection and a reset
|
|
144
144
|
// (h2spec's http2/7), so it gets a bound of its own: one second is three orders of
|
|
145
145
|
// magnitude longer than the flush takes when the peer is reading.
|
|
@@ -1146,7 +1146,7 @@ class Http2Session extends EventEmitter {
|
|
|
1146
1146
|
// RFC 9113 5.5: "Implementations MUST discard frames that have unknown or unsupported
|
|
1147
1147
|
// types." Measured on a live node server with tools/probe-inbound-limits.js: type 10
|
|
1148
1148
|
// (ALTSVC, RFC 7807), type 99 and type 0xff with a 4 KiB payload all leave the
|
|
1149
|
-
// connection up and the next request served, and
|
|
1149
|
+
// connection up and the next request served, and h2lean answers the same. Answering a
|
|
1150
1150
|
// fatal error here would break every extension frame a peer has not told us about.
|
|
1151
1151
|
// What is NOT ignored is a frame that interrupts a field block -- 5.5 forbids those
|
|
1152
1152
|
// outright, and that is the case above.
|
|
@@ -1225,7 +1225,7 @@ class Http2Session extends EventEmitter {
|
|
|
1225
1225
|
// block fragment"), so the block ends where the padding starts. Feeding the
|
|
1226
1226
|
// padding to the HPACK decoder is what a server must not do: the padding is
|
|
1227
1227
|
// not part of the block, and a block that ends with eight zero octets decodes
|
|
1228
|
-
// as a literal header with no name and no value -- which is what
|
|
1228
|
+
// as a literal header with no name and no value -- which is what h2lean
|
|
1229
1229
|
// answered to a valid padded HEADERS with GOAWAY(COMPRESSION_ERROR), while node
|
|
1230
1230
|
// served the request (measured: tools/probe-hdr-pad.js, "the RFC shape: pad
|
|
1231
1231
|
// length, block, padding"; the same frame is what h2spec's generic/3.2 sends,
|
|
@@ -1329,7 +1329,7 @@ class Http2Session extends EventEmitter {
|
|
|
1329
1329
|
}
|
|
1330
1330
|
// No maxHeaderListSize test here, although this is where the octets are counted: the limit
|
|
1331
1331
|
// is about the size of the header LIST, which is only known once the block is decoded, and
|
|
1332
|
-
//
|
|
1332
|
+
// h2lean applies it there (_processHeaders). Measured against node, an over-limit block
|
|
1333
1333
|
// arriving in one HEADERS frame gets RST_STREAM(ENHANCE_YOUR_CALM) and the connection
|
|
1334
1334
|
// stays up; a check on the compressed length here made it GOAWAY(PROTOCOL_ERROR) instead,
|
|
1335
1335
|
// which turns one bad request into an error for every other client on the session. The
|
|
@@ -1696,7 +1696,7 @@ class Http2Session extends EventEmitter {
|
|
|
1696
1696
|
// closed the message with its own END_STREAM is a connection error of type STREAM_CLOSED
|
|
1697
1697
|
// whichever fields the block holds -- measured on node (probe cases U3, U9), where the second
|
|
1698
1698
|
// block is not a valid request at all and the verdict is still GOAWAY(STREAM_CLOSED). 5.1 reads a
|
|
1699
|
-
// frame on a closed stream as "MAY ... treat ... as a connection error", and
|
|
1699
|
+
// frame on a closed stream as "MAY ... treat ... as a connection error", and h2lean takes that
|
|
1700
1700
|
// option for BOTH the still-present (half-closed(remote)) and the just-closed (_ended) shape: a
|
|
1701
1701
|
// stream error would be the other reading of half-closed(remote), but node does not make that
|
|
1702
1702
|
// distinction and h2spec's half-closed(remote) HEADERS check waits for the error, not the silence
|
|
@@ -1917,7 +1917,7 @@ class Http2Session extends EventEmitter {
|
|
|
1917
1917
|
return undefined;
|
|
1918
1918
|
}
|
|
1919
1919
|
// RFC 9113 5.1 and 8.2: the peer already closed the message with its END_STREAM, so a DATA
|
|
1920
|
-
// that follows it names a stream that has nothing left to receive.
|
|
1920
|
+
// that follows it names a stream that has nothing left to receive. h2lean ends the connection
|
|
1921
1921
|
// with STREAM_CLOSED -- the discretion 5.1 grants a closed stream ("MAY ... treat ... as a
|
|
1922
1922
|
// connection error") and what h2spec's closed checks wait for; a silent discard is legal by
|
|
1923
1923
|
// the letter but the conformance tool times out on it, and this is the just-closed id (it left
|
|
@@ -1959,7 +1959,7 @@ class Http2Session extends EventEmitter {
|
|
|
1959
1959
|
// connection ends. RFC 9113 6.1 reads a DATA on a stream outside "open"/"half-closed(local)"
|
|
1960
1960
|
// as a stream error of type STREAM_CLOSED, and h2spec's half-closed(remote) DATA and 6.1 checks
|
|
1961
1961
|
// accept the reset, the GOAWAY, or a closed connection -- but node makes no present/closed
|
|
1962
|
-
// distinction and answers the connection error below on BOTH stacks, so
|
|
1962
|
+
// distinction and answers the connection error below on BOTH stacks, so h2lean keeps node's
|
|
1963
1963
|
// verdict here rather than the stream error (which regressed the tool's DATA echo). Leaving it
|
|
1964
1964
|
// is the honest call: it is node's deviation, the parity rule keeps it, and 5.1's closed-state
|
|
1965
1965
|
// read is the discretionary one ("MAY ... treat ... as a connection error"). The arms that
|
|
@@ -2228,7 +2228,7 @@ class Http2Session extends EventEmitter {
|
|
|
2228
2228
|
|
|
2229
2229
|
_onGoaway(buf, start, len) {
|
|
2230
2230
|
// node begins this handler with `if (session.destroyed) return;`, because its nghttp2 handle
|
|
2231
|
-
// can report a frame after the session was destroyed.
|
|
2231
|
+
// can report a frame after the session was destroyed. h2lean cannot: _dispatch() answers with
|
|
2232
2232
|
// `this._open` and the read batch stops at the frame that destroyed the session, so a
|
|
2233
2233
|
// destroyed session has no frames left to reach. That stop is what is tested -- in
|
|
2234
2234
|
// test/goaway.test.js, 'the read batch stops at the frame that destroyed the session' -- and
|
|
@@ -2306,7 +2306,7 @@ class Http2Session extends EventEmitter {
|
|
|
2306
2306
|
// that had already closed), our flush-per-read-batch put a WINDOW_UPDATE(sid 0) on the wire in
|
|
2307
2307
|
// front of the frame the tool was waiting for, where node sent nothing at all. Measured on a
|
|
2308
2308
|
// 640 KiB upload (tools/probe-conn-credit.js): node releases the connection window in steps of
|
|
2309
|
-
// 32768 octets -- half the 65535 it advertises -- and the same threshold here takes
|
|
2309
|
+
// 32768 octets -- half the 65535 it advertises -- and the same threshold here takes h2lean's
|
|
2310
2310
|
// cadence from one frame per read batch to the same half-window steps, at the same throughput.
|
|
2311
2311
|
// A peer that keeps sending is therefore credited before its window can run out, so the
|
|
2312
2312
|
// anti-deadlock rule that this counter exists for still holds; below the threshold the bytes
|
|
@@ -2483,7 +2483,7 @@ class Http2Session extends EventEmitter {
|
|
|
2483
2483
|
// That is exactly what a peer's GOAWAY does -- destroy(new ERR_HTTP2_SESSION_ERROR(code),
|
|
2484
2484
|
// NO_ERROR) -- and the measurement below is what made the distinction visible: the application
|
|
2485
2485
|
// is told WHY (the code that arrived) while the peer is told goodbye with NO_ERROR, because
|
|
2486
|
-
// this side did not fail. A number as the first argument is
|
|
2486
|
+
// this side did not fail. A number as the first argument is h2lean's own convenience, used by
|
|
2487
2487
|
// the socket handlers and _fatal(): destroy with this GOAWAY code and nothing to report.
|
|
2488
2488
|
destroy(error = NO_ERROR, code) {
|
|
2489
2489
|
if (this.destroyed) {
|
|
@@ -2527,7 +2527,7 @@ class Http2Session extends EventEmitter {
|
|
|
2527
2527
|
// node's teardown shape (internal/http2/core.js, finishSessionClose): the socket is
|
|
2528
2528
|
// ended first and the destroy waits for the write to be flushed, so the GOAWAY queued
|
|
2529
2529
|
// above reaches the peer and the connection ends with a FIN instead of a reset.
|
|
2530
|
-
//
|
|
2530
|
+
// h2lean used to destroy the socket outright, which threw that GOAWAY away and closed
|
|
2531
2531
|
// the connection with a reset whenever the write had not reached the OS yet -- under
|
|
2532
2532
|
// load that is what h2spec's http2/7 reads as a failure ("connection reset by peer"
|
|
2533
2533
|
// where node's "connection closed" passes), measured in bench/micro/probe-goaway-ping.js.
|
package/lib/stream.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
// the same promise node:http2 makes.
|
|
21
21
|
// * a response the application never started is started for it: the first write()
|
|
22
22
|
// sends HEADERS{:status 200} before the DATA, as node does. An end() with no body at
|
|
23
|
-
// all is the one shape where
|
|
23
|
+
// all is the one shape where h2lean answers (200, empty DATA/ES) and node says nothing
|
|
24
24
|
// -- measured, and recorded in AGENTS.md section 10.
|
|
25
25
|
'use strict';
|
|
26
26
|
|
|
@@ -177,10 +177,10 @@ class Http2Stream extends EventEmitter {
|
|
|
177
177
|
cb = encoding; encoding = null;
|
|
178
178
|
}
|
|
179
179
|
if (!this.headersSent) {
|
|
180
|
-
// A body takes the auto-respond of write() above; an empty end() is where
|
|
180
|
+
// A body takes the auto-respond of write() above; an empty end() is where h2lean and node
|
|
181
181
|
// part company, and the split is written down in AGENTS.md section 10: node puts NOTHING on
|
|
182
182
|
// the wire for `end()` before respond() -- no HEADERS, no DATA, no END_STREAM, and
|
|
183
|
-
// headersSent stays false -- while
|
|
183
|
+
// headersSent stays false -- while h2lean answers a plain 200 rather than leave a stream
|
|
184
184
|
// that no peer will ever see an answer on.
|
|
185
185
|
this.respond({ ':status': 200 });
|
|
186
186
|
}
|
|
@@ -189,7 +189,7 @@ class Http2Stream extends EventEmitter {
|
|
|
189
189
|
// A stream that still owes a trailer block must not close the stream with its body.
|
|
190
190
|
// RFC 9113 6.1 puts a trailing HEADERS before END_STREAM, and node's frame sequence
|
|
191
191
|
// shows the placement: HEADERS/EH DATA HEADERS/ES/EH for a body, and no DATA at all
|
|
192
|
-
// for an empty one -- where
|
|
192
|
+
// for an empty one -- where h2lean used to send DATA/ES first and then a trailer block
|
|
193
193
|
// that no peer could still read. The application-level half-close happens here (that
|
|
194
194
|
// is what emits 'wantTrailers'), the wire-level one happens in sendTrailers().
|
|
195
195
|
const oweTrailers = this._waitForTrailers;
|
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": [
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
],
|
|
10
10
|
"repository": {
|
|
11
11
|
"type": "git",
|
|
12
|
-
"url": "git+https://github.com/MakeNodeGreatAgain/
|
|
12
|
+
"url": "git+https://github.com/MakeNodeGreatAgain/h2lean.git"
|
|
13
13
|
},
|
|
14
14
|
"keywords": [
|
|
15
15
|
"http2",
|
|
@@ -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
|
}
|