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.
@@ -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); h2lean'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('h2lean').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(`h2lean: not implemented: ${what}`);
23
+ err.code = 'ERR_H2LEAN_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 };