h2lean 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +265 -2
- package/index.js +72 -0
- package/lib/client-session.js +2068 -0
- package/lib/client-stream.js +166 -0
- package/lib/client.js +85 -0
- package/lib/compat.js +849 -0
- package/lib/fast-timer.js +436 -0
- package/lib/frames.js +127 -0
- package/lib/headers.js +463 -0
- package/lib/hpack-data.js +333 -0
- package/lib/hpack.js +1288 -0
- package/lib/pieces.js +134 -0
- package/lib/server.js +272 -0
- package/lib/session.js +2698 -0
- package/lib/stream.js +519 -0
- package/package.json +49 -4
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// ClientHttp2Stream: the Duplex node hands back from `session.request()`.
|
|
2
|
+
//
|
|
3
|
+
// This mirrors `Http2Stream`/`ClientHttp2Stream` in node's bundled
|
|
4
|
+
// `internal/http2/core.js`. Two shapes are load-bearing for a drop-in:
|
|
5
|
+
// * it is a `stream.Duplex` with `allowHalfOpen: true`, so ending the request
|
|
6
|
+
// body (writable) does not close the response (readable). node's `Http2Stream`
|
|
7
|
+
// extends Duplex (core.js:2128); h2lite's SERVER stream is an EventEmitter
|
|
8
|
+
// (AGENTS.md 10) but a client stream the application `.pipe()`s and reads
|
|
9
|
+
// `:status` off has to be a real Duplex or the common
|
|
10
|
+
// `req.pipe(server).pipe(res)` idiom breaks.
|
|
11
|
+
// * the protocol work (DATA framing, flow control, HPACK) belongs to the
|
|
12
|
+
// session, not here. This file only moves bytes between the Duplex's two
|
|
13
|
+
// queues and the session, and emits the events the application subscribes to
|
|
14
|
+
// ('response', 'trailers', 'close', 'error', informational 'headers').
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const { Duplex } = require('node:stream');
|
|
18
|
+
|
|
19
|
+
// `sentHeaders`/`sentTrailers`/`responseHeaders` and the state flags node exposes
|
|
20
|
+
// through symbols are kept as plain fields here; they are not part of the
|
|
21
|
+
// documented client surface an application is expected to touch, and pinning them
|
|
22
|
+
// to node's symbol identity is an M2 compatibility decision, not a round-trip one.
|
|
23
|
+
class ClientHttp2Stream extends Duplex {
|
|
24
|
+
constructor(session, id, _opts) {
|
|
25
|
+
super({ allowHalfOpen: true, decodeStrings: false });
|
|
26
|
+
this.id = id;
|
|
27
|
+
this.session = session;
|
|
28
|
+
// Per-stream flow-control state; the connection window lives on the session.
|
|
29
|
+
// Both start at the peer's advertised initial window (65535 until its SETTINGS
|
|
30
|
+
// land) exactly as the server side does. The value written here is the
|
|
31
|
+
// default one: the session overwrites it with the peer's number when the
|
|
32
|
+
// stream is actually opened (RFC 9113 6.9.2 -- a stream runs on the peer's
|
|
33
|
+
// SETTINGS_INITIAL_WINDOW_SIZE), which may be later than this construction.
|
|
34
|
+
this._sendWin = session.remoteSettings.initialWindowSize;
|
|
35
|
+
this._recvUsed = 0;
|
|
36
|
+
// Whether this stream is already in the session's _debt list, i.e. whether the
|
|
37
|
+
// next credit pass has to visit it. Declared here so every stream shares one
|
|
38
|
+
// V8 object shape; only lib/client-session.js reads or writes it.
|
|
39
|
+
this._debtMarked = false;
|
|
40
|
+
// Bytes the application has written but that flow control has not yet let us
|
|
41
|
+
// frame; kept by reference so a stalled upload costs no copy.
|
|
42
|
+
this._pending = null;
|
|
43
|
+
this._pendingOff = 0;
|
|
44
|
+
this._writeCb = null;
|
|
45
|
+
this._finalCb = null;
|
|
46
|
+
// Whether the request HEADERS frame has been committed to the outbound queue.
|
|
47
|
+
// _pump() returns early until this is true, so a write() that lands before the
|
|
48
|
+
// 'connect' event (or before the queued request is framed) cannot put DATA on
|
|
49
|
+
// the wire ahead of its stream's HEADERS -- nghttp2 reads that as INTERNAL_ERROR.
|
|
50
|
+
this._headersSent = false;
|
|
51
|
+
// Whether the request message is over. `endAfterHeaders` is the GET case:
|
|
52
|
+
// HEADERS already carried END_STREAM, so `_final` must not emit an empty DATA.
|
|
53
|
+
this._endSent = false;
|
|
54
|
+
this.endAfterHeaders = false;
|
|
55
|
+
// The readable half has seen END_STREAM (server finished the response body).
|
|
56
|
+
this._remoteEnded = false;
|
|
57
|
+
this.rstCode = null;
|
|
58
|
+
this.headersSent = false; // node flips this once HEADERS are sent
|
|
59
|
+
this.sentHeaders = null;
|
|
60
|
+
this.sentStatus = null;
|
|
61
|
+
this.responseEmitted = false;
|
|
62
|
+
this.additionalHeaders = [];
|
|
63
|
+
this.trailers = null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- writable side ----------------------------------------------------------
|
|
67
|
+
// The application's bytes go to the session, which frames them as DATA inside
|
|
68
|
+
// the two windows. If there is no credit, `_pump` leaves `_pending` set and the
|
|
69
|
+
// session resumes us on WINDOW_UPDATE; the Duplex callback is not run until the
|
|
70
|
+
// bytes are handed over, which is what makes `write()` honour backpressure.
|
|
71
|
+
_write(chunk, encoding, cb) {
|
|
72
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding);
|
|
73
|
+
this._pending = buf;
|
|
74
|
+
this._pendingOff = 0;
|
|
75
|
+
this._writeCb = cb;
|
|
76
|
+
this.session._pump(this);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
_final(cb) {
|
|
80
|
+
this._finalCb = cb;
|
|
81
|
+
this.session._pump(this);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Called by the session once the writable half has been fully framed and
|
|
85
|
+
// END_STREAM put on the wire.
|
|
86
|
+
_writableDone() {
|
|
87
|
+
this._endSent = true;
|
|
88
|
+
const cb = this._finalCb;
|
|
89
|
+
if (cb !== null) {
|
|
90
|
+
this._finalCb = null; cb();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- readable side ----------------------------------------------------------
|
|
95
|
+
// The application pulling does not gate what we have already buffered (we hand
|
|
96
|
+
// DATA straight to `push`, returning flow-control credit as we consume it), so
|
|
97
|
+
// `_read` has nothing to fetch. Withholding credit until here is the backpressure
|
|
98
|
+
// refinement M2 adds; for a body that fits the window the response is complete
|
|
99
|
+
// whether or not the consumer has read it yet.
|
|
100
|
+
_read() {}
|
|
101
|
+
|
|
102
|
+
// The session decoded a response (or informational / trailer) block for us.
|
|
103
|
+
// `headers` is a null-prototype object with `:status` coerced to a number and
|
|
104
|
+
// `set-cookie` as an array, built the way the server's `_toObject` builds a
|
|
105
|
+
// request, so both stacks hand the application the same shape.
|
|
106
|
+
_onHeaders(headers, end) {
|
|
107
|
+
const status = headers[':status'];
|
|
108
|
+
if (status !== undefined && status >= 100 && status < 200 && !this.responseEmitted) {
|
|
109
|
+
// 1xx informational (including the 100 Continue that node surfaces as
|
|
110
|
+
// 'continue'): emit 'headers', keep waiting for the final response. The
|
|
111
|
+
// 100-Continue translation node does is a 'headers' whose :status is 100.
|
|
112
|
+
this.additionalHeaders.push(headers);
|
|
113
|
+
this.emit('headers', headers);
|
|
114
|
+
if (status === 100) {
|
|
115
|
+
this.emit('continue', headers);
|
|
116
|
+
}
|
|
117
|
+
} else if (!this.responseEmitted) {
|
|
118
|
+
this.responseEmitted = true;
|
|
119
|
+
this.responseHeaders = headers;
|
|
120
|
+
this.sentStatus = status;
|
|
121
|
+
this.emit('response', headers);
|
|
122
|
+
} else {
|
|
123
|
+
// A second final-status block after the response is the trailer block.
|
|
124
|
+
this.trailers = headers;
|
|
125
|
+
this.emit('trailers', headers);
|
|
126
|
+
}
|
|
127
|
+
if (end) {
|
|
128
|
+
this._endRead();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// The session delivered a DATA payload for this stream.
|
|
133
|
+
_onData(buf) {
|
|
134
|
+
this._remoteEnded = false;
|
|
135
|
+
this.push(buf);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_endRead() {
|
|
139
|
+
if (this._remoteEnded) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this._remoteEnded = true;
|
|
143
|
+
this.push(null);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The peer reset this stream, or we reset it locally.
|
|
147
|
+
_onRst(code) {
|
|
148
|
+
this.rstCode = code;
|
|
149
|
+
// node emits 'aborted' then 'error' for a reset that arrives mid-response.
|
|
150
|
+
// For M1 the observable requirement is: the stream errors and closes, and the
|
|
151
|
+
// session is untouched. Refining the abort-vs-error ordering is M2.
|
|
152
|
+
this.destroy(Object.assign(new Error('HTTP/2 stream error'), {
|
|
153
|
+
code: 'ERR_HTTP2_STREAM_ERROR', code2: undefined }));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Destroying the Duplex is how the application abandons a request (`stream.destroy()`,
|
|
157
|
+
// or `close()`). If the stream is still open on the wire this side owes the peer an
|
|
158
|
+
// RST_STREAM; when it has already completed the session has dropped it and rstStream
|
|
159
|
+
// is a no-op, so normal teardown never emits a stray reset.
|
|
160
|
+
_destroy(err, cb) {
|
|
161
|
+
this.session.rstStream(this.id);
|
|
162
|
+
cb(err);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { ClientHttp2Stream };
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// connect(): the client front door. Mirrors node's `connect(authority, options,
|
|
2
|
+
// listener)` in internal/http2/core.js -- same argument juggling, same
|
|
3
|
+
// authority/protocol/port derivation, same `createConnection` override -- so that
|
|
4
|
+
// `require('h2lite').connect(url)` is a drop-in for `require('node:http2').connect(url)`.
|
|
5
|
+
//
|
|
6
|
+
// Both transports are supported: h2c over `net`, and h2s over `tls.connect` the
|
|
7
|
+
// way core.js initializeTLSOptions builds it -- the ALPN list is forced to ['h2']
|
|
8
|
+
// (an application ALPNProtocols list is ignored, the way node's is; only an
|
|
9
|
+
// options.ALPNCallback keeps it), and servername is defaulted to the host when
|
|
10
|
+
// the host is not an IP. The session itself stays transport-agnostic; it holds
|
|
11
|
+
// its handshake back until the socket reports connected (client-session.js
|
|
12
|
+
// _start, deferred to 'secureConnect' under TLS the way core.js defers
|
|
13
|
+
// setupHandle), and certificate validation is delegated to node:tls -- no
|
|
14
|
+
// validation logic lives here.
|
|
15
|
+
'use strict';
|
|
16
|
+
|
|
17
|
+
const net = require('node:net');
|
|
18
|
+
const tls = require('node:tls');
|
|
19
|
+
const { ClientHttp2Session } = require('./client-session');
|
|
20
|
+
|
|
21
|
+
function notImplemented(what) {
|
|
22
|
+
const err = new Error(`h2lite: not implemented: ${what}`);
|
|
23
|
+
err.code = 'ERR_H2LITE_NOT_IMPLEMENTED';
|
|
24
|
+
return err;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function connect(authority, options, listener) {
|
|
28
|
+
if (typeof options === 'function') {
|
|
29
|
+
listener = options; options = undefined;
|
|
30
|
+
}
|
|
31
|
+
options = { ...options };
|
|
32
|
+
|
|
33
|
+
if (typeof authority === 'string') {
|
|
34
|
+
authority = new URL(authority);
|
|
35
|
+
}
|
|
36
|
+
if (authority === null || typeof authority !== 'object') {
|
|
37
|
+
throw new TypeError('The "authority" argument must be of type string, an object, or a URL');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const protocol = authority.protocol || options.protocol || 'https:';
|
|
41
|
+
const port = '' + (authority.port !== ''
|
|
42
|
+
? authority.port : (authority.protocol === 'http:' ? 80 : 443));
|
|
43
|
+
let host = 'localhost';
|
|
44
|
+
if (authority.hostname) {
|
|
45
|
+
host = authority.hostname;
|
|
46
|
+
if (host[0] === '[') {
|
|
47
|
+
host = host.slice(1, -1);
|
|
48
|
+
}
|
|
49
|
+
} else if (authority.host) {
|
|
50
|
+
host = authority.host;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let socket;
|
|
54
|
+
if (typeof options.createConnection === 'function') {
|
|
55
|
+
socket = options.createConnection(authority, options);
|
|
56
|
+
} else if (protocol === 'http:') {
|
|
57
|
+
socket = net.connect({ port: Number(port), host, ...options });
|
|
58
|
+
} else if (protocol === 'https:') {
|
|
59
|
+
// core.js initializeTLSOptions: the ALPN list belongs to the protocol, not
|
|
60
|
+
// to the application -- only an ALPNCallback keeps ours out. servername is
|
|
61
|
+
// defaulted to the host for the SNI extension when the host is a name
|
|
62
|
+
// (net.isIP host means no SNI, the same default tls.connect applies).
|
|
63
|
+
const tlsOptions = { ...options };
|
|
64
|
+
if (!tlsOptions.ALPNCallback) {
|
|
65
|
+
tlsOptions.ALPNProtocols = ['h2'];
|
|
66
|
+
}
|
|
67
|
+
if (!tlsOptions.servername && !net.isIP(host)) {
|
|
68
|
+
tlsOptions.servername = host;
|
|
69
|
+
}
|
|
70
|
+
socket = tls.connect({ port: Number(port), host, ...tlsOptions });
|
|
71
|
+
} else {
|
|
72
|
+
throw notImplemented(`connect over ${protocol}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const scheme = protocol === 'https:' ? 'https' : 'http';
|
|
76
|
+
const authorityHeader = `${host}:${port}`;
|
|
77
|
+
const session = new ClientHttp2Session(socket, options, scheme, authorityHeader);
|
|
78
|
+
|
|
79
|
+
if (typeof listener === 'function') {
|
|
80
|
+
session.once('connect', listener);
|
|
81
|
+
}
|
|
82
|
+
return session;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = { connect };
|