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.
package/lib/stream.js ADDED
@@ -0,0 +1,519 @@
1
+ // Http2Stream equivalent: the object a request handler is handed.
2
+ //
3
+ // v1 deliberately implements the parts of the contract that can be honoured
4
+ // honestly, and omits the ones that cannot:
5
+ // * the read side releases flow-control credit as the application takes the
6
+ // bytes. With no reader the peer's window stays shut, so an unread body
7
+ // costs the sender instead of our heap; data that arrives before anyone is
8
+ // listening is buffered and replayed in order, so late listeners lose
9
+ // nothing. pause()/resume()/isPaused() are implemented on that mechanism --
10
+ // there is no partial credit accounting, the side either is read or it isn't.
11
+ // * a delivered chunk stays readable for as long as the application holds it:
12
+ // it is either a view of the buffer node read off the socket, or the private
13
+ // buffer a frame that straddled two reads was assembled into. Neither is ever
14
+ // recycled underneath the application, so queueing a chunk nobody has read yet
15
+ // is safe and the receive path performs no copy.
16
+ // * write()/end() are real and respect both the stream and connection send
17
+ // windows, returning false when the window is exhausted. A body larger than the
18
+ // reference threshold is queued as a view of the caller's buffer and copied
19
+ // nowhere, so the caller must not write into it before the bytes have gone out --
20
+ // the same promise node:http2 makes.
21
+ // * a response the application never started is started for it: the first write()
22
+ // sends HEADERS{:status 200} before the DATA, as node does. An end() with no body at
23
+ // all is the one shape where h2lean answers (200, empty DATA/ES) and node says nothing
24
+ // -- measured, and recorded in AGENTS.md section 10.
25
+ 'use strict';
26
+
27
+ const { EventEmitter } = require('node:events');
28
+ const frames = require('./frames');
29
+ const hdr = require('./headers');
30
+
31
+ const EMPTY = Buffer.alloc(0);
32
+
33
+ // process.nextTick(fn, args) instead of an arrow closure per event: the arrow
34
+ // allocated a new function object for every request, and in the compat layer
35
+ // that showed up as GC time.
36
+ function emitNT(target, name, arg) {
37
+ if (arg === undefined) {
38
+ target.emit(name);
39
+ } else {
40
+ target.emit(name, arg);
41
+ }
42
+ }
43
+
44
+ function emitErrorCloseNT(target, err) {
45
+ target.emit('error', err);
46
+ target.emit('close');
47
+ }
48
+
49
+ // 'end' and 'close' for a stream whose two halves finished in the same turn.
50
+ // They were already going to run in the same tick drain, so folding them into
51
+ // one callback only removes one TickObject per request.
52
+ function emitEndCloseNT(target) {
53
+ target.emit('end');
54
+ target.emit('close');
55
+ }
56
+
57
+ const {
58
+ ST_OPEN, ST_HALF_CLOSED_LOCAL, ST_HALF_CLOSED_REMOTE, ST_CLOSED, NO_ERROR, PROTOCOL_ERROR
59
+ } = frames;
60
+
61
+ class Http2Stream extends EventEmitter {
62
+ constructor(session, id) {
63
+ super();
64
+ this.id = id;
65
+ this.session = session;
66
+ this.rstCode = 0;
67
+ this.sentHeaders = undefined;
68
+ this.sentInfoHeaders = [];
69
+ this.sentTrailers = undefined;
70
+ this.headersSent = false;
71
+ this.pending = true;
72
+ this.aborted = false;
73
+ this.endAfterHeaders = false;
74
+ this.allowHalfOpen = false;
75
+ this.state = {
76
+ state: ST_OPEN,
77
+ weight: 16,
78
+ sumDependencyWeight: 0,
79
+ localClose: 0,
80
+ remoteClose: 0,
81
+ localWindowSize: session.localSettings.initialWindowSize
82
+ };
83
+ this._sendWin = session.remoteSettings.initialWindowSize;
84
+ this._recvUsed = 0; // inbound bytes not yet released as credit
85
+ this._unannounced = 0; // ...consumed but not yet sent back as credit
86
+ this._expLen = -1; // content-length promised by the request, -1 = nothing
87
+ this._bodyLen = 0; // ...octets of body actually received
88
+ this._queued = null; // Buffers waiting on window credit
89
+ this._pending = null; // inbound chunks nobody has read yet
90
+ this._endPending = false; // END_STREAM arrived with no reader
91
+ this._consumeState = 0; // 0 no reader, 1 delivering, 2 reader is full
92
+ this._endQueued = false;
93
+ this._waitForTrailers = false;
94
+ this._pendingTrailers = null; // trailer block waiting behind a queued body
95
+ this._destroyed = false;
96
+ // The compat layer's handler for this stream's events, or null when the
97
+ // application uses the stream API. See emit() below.
98
+ this._compatDispatch = null;
99
+ }
100
+
101
+ get destroyed() {
102
+ return this._destroyed;
103
+ }
104
+ get closed() {
105
+ return this.state.state === ST_CLOSED;
106
+ }
107
+
108
+ respond(headers, options) {
109
+ if (this.headersSent) {
110
+ const err = errorOf('ERR_HTTP2_HEADERS_SENT', 'stream has already sent HEADERS');
111
+ this.emit('error', err);
112
+ throw err;
113
+ }
114
+ const opts = options || {};
115
+ // node puts a `date` on every response of this API too: prepareResponseHeadersObject()
116
+ // (internal/http2/core.js:2702) adds one unless the application set one itself or
117
+ // `options.sendDate` says otherwise -- and its test is `sendDate == null || sendDate`,
118
+ // so the default is on and only an explicit false (or any other falsy value) turns it
119
+ // off. Measured against a live node server: respond({':status':200}) carries a date,
120
+ // respond({':status':204}) and 304 do too, and sendDate:false removes it.
121
+ const sendDate = opts.sendDate == null ? true : !!opts.sendDate;
122
+ const block = hdr.block(headers, opts.sensitiveHeaders,
123
+ { sendDate }, this.session);
124
+ this.sentHeaders = headers;
125
+ this.headersSent = true;
126
+ this.pending = false;
127
+ this._waitForTrailers = !!opts.waitForTrailers;
128
+ // node does not infer END_STREAM from the request having ended: the local
129
+ // side only closes when the application says so
130
+ const empty = opts.endStream === true;
131
+ this.session.sendHeaders(this.id, block, empty);
132
+ if (empty) {
133
+ // This response has already carried END_STREAM, and nothing may follow it on this
134
+ // stream: RFC 9113 6.1 puts a trailing HEADERS *before* the close, and node shows
135
+ // what that means for this combination -- respond({waitForTrailers:true,
136
+ // endStream:true}) puts a single HEADERS/ES/EH on the wire and no trailer block.
137
+ // So the outstanding request for trailers is dropped instead of answered after the
138
+ // stream is closed, and 'wantTrailers' is not emitted for a stream that cannot send.
139
+ this._waitForTrailers = false;
140
+ this._endLocalSide();
141
+ }
142
+ return this;
143
+ }
144
+
145
+ write(chunk, encoding, cb) {
146
+ if (typeof encoding === 'function') {
147
+ cb = encoding; encoding = undefined;
148
+ }
149
+ if (this._endQueued || this.state.localClose === 1) {
150
+ const err = errorOf('ERR_HTTP2_STREAM_CANCEL', 'stream write after end');
151
+ if (cb) {
152
+ return process.nextTick(cb, err);
153
+ }
154
+ this.emit('error', err);
155
+ return false;
156
+ }
157
+ // The response starts itself if the application never called respond(). Node does this on
158
+ // the first WRITE of a server stream, not on the first end(): measured, `write('x')` alone
159
+ // puts HEADERS{:status 200} on the wire ahead of the DATA. Doing it here as well is what
160
+ // keeps DATA from arriving on a stream that has no response headers yet -- a peer that sees
161
+ // DATA first has nothing to attach it to, and nghttp2 answers that with a stream error.
162
+ if (!this.headersSent) {
163
+ this.respond({ ':status': 200 });
164
+ }
165
+ const buf = typeof chunk === 'string' ? Buffer.from(chunk, encoding || 'utf8') : chunk;
166
+ const canContinue = this.session.sendData(this, buf, false);
167
+ if (cb) {
168
+ process.nextTick(cb);
169
+ }
170
+ return canContinue;
171
+ }
172
+
173
+ end(chunk, encoding, cb) {
174
+ if (typeof chunk === 'function') {
175
+ cb = chunk; chunk = null;
176
+ } else if (typeof encoding === 'function') {
177
+ cb = encoding; encoding = null;
178
+ }
179
+ if (!this.headersSent) {
180
+ // A body takes the auto-respond of write() above; an empty end() is where h2lean and node
181
+ // part company, and the split is written down in AGENTS.md section 10: node puts NOTHING on
182
+ // the wire for `end()` before respond() -- no HEADERS, no DATA, no END_STREAM, and
183
+ // headersSent stays false -- while h2lean answers a plain 200 rather than leave a stream
184
+ // that no peer will ever see an answer on.
185
+ this.respond({ ':status': 200 });
186
+ }
187
+ const buf = chunk == null ? EMPTY : (typeof chunk === 'string'
188
+ ? Buffer.from(chunk, encoding || 'utf8') : chunk);
189
+ // A stream that still owes a trailer block must not close the stream with its body.
190
+ // RFC 9113 6.1 puts a trailing HEADERS before END_STREAM, and node's frame sequence
191
+ // shows the placement: HEADERS/EH DATA HEADERS/ES/EH for a body, and no DATA at all
192
+ // for an empty one -- where h2lean used to send DATA/ES first and then a trailer block
193
+ // that no peer could still read. The application-level half-close happens here (that
194
+ // is what emits 'wantTrailers'), the wire-level one happens in sendTrailers().
195
+ const oweTrailers = this._waitForTrailers;
196
+ this.session.sendData(this, buf, !oweTrailers);
197
+ if (oweTrailers) {
198
+ this._endLocalSide();
199
+ }
200
+ if (cb) {
201
+ process.nextTick(cb);
202
+ }
203
+ return this;
204
+ }
205
+
206
+ sendTrailers(trailers) {
207
+ if (!this._waitForTrailers) {
208
+ this.emit('error', errorOf('ERR_HTTP2_TRAILERS_NOT_READY',
209
+ 'trailers cannot be sent unless a request is made by setting waitForTrailers'));
210
+ return;
211
+ }
212
+ const block = hdr.block(trailers, undefined, { trailer: true }, this.session);
213
+ this.sentTrailers = trailers;
214
+ // the trailers are out: the flag that permitted this call must not keep asking for
215
+ // wantTrailers, whatever happens to the bytes below
216
+ this._waitForTrailers = false;
217
+ if (this._queued !== null) {
218
+ // The body is still waiting for credit or for the socket, and this block has to
219
+ // follow it: END_STREAM rides on the trailers, so a HEADERS sent now would outrun
220
+ // DATA that has not gone out. Stash a copy (the block is a view into shared scratch)
221
+ // and let the session put it on the wire when the queue drains.
222
+ this._pendingTrailers = Buffer.from(block);
223
+ return this;
224
+ }
225
+ this.session.sendHeaders(this.id, block, true);
226
+ // the local half closes for good now: the bytes that owed it are on the wire
227
+ this._endLocalSide();
228
+ }
229
+
230
+ close(code = NO_ERROR, reason) {
231
+ this._destroy(code, reason, true);
232
+ }
233
+
234
+ destroy(codeOrError, reason) {
235
+ const code = typeof codeOrError === 'number' ? codeOrError : NO_ERROR;
236
+ this._destroy(code, reason, true, typeof codeOrError === 'object' ? codeOrError : null);
237
+ }
238
+
239
+ // An informational (1xx) HEADERS block, sent before the final response.
240
+ // node: Http2Stream.additionalHeaders().
241
+ additionalHeaders(headers) {
242
+ // `{ info: true }` is node's separate rule for these blocks: :status must be 100..199
243
+ // (101 has its own error), there is no default status and no generated date.
244
+ const block = hdr.block(headers, undefined, { info: true }, this.session);
245
+ this.sentInfoHeaders.push(headers);
246
+ this.session.sendHeaders(this.id, block, false);
247
+ return this;
248
+ }
249
+
250
+ priority() {
251
+ // RFC 9218 deprecates the priority scheme; node still accepts the call.
252
+ // Returning true without reordering anything is the honest v1 answer.
253
+ return true;
254
+ }
255
+
256
+ // Readable-side control, node's contract: while paused no credit is released,
257
+ // so the sender is the one that waits, and whatever arrives in the meantime is
258
+ // buffered and replayed on resume(). Returns this, like the Duplex does.
259
+ pause() {
260
+ this._pauseConsuming();
261
+ return this;
262
+ }
263
+
264
+ resume() {
265
+ this._startConsuming();
266
+ return this;
267
+ }
268
+
269
+ isPaused() {
270
+ return this._consumeState === 2;
271
+ }
272
+
273
+ // node maps a per-stream timeout onto the connection's idle timeout.
274
+ setTimeout(ms, cb) {
275
+ this.session.setTimeout(ms, cb);
276
+ return this;
277
+ }
278
+
279
+ // --- internals used by the session ------------------------------------------
280
+ // Both halves have to converge: a handler that answers synchronously inside
281
+ // the 'stream' emit closes the local side BEFORE the peer's END_STREAM is
282
+ // reported, and vice versa. Whichever call sees both halves closed is the one
283
+ // that removes the stream from the session -- without it every request leaks
284
+ // an Http2Stream (and _liveStreams never falls, so close() never finishes).
285
+ _remoteEnded() {
286
+ if (this.state.remoteClose === 1) {
287
+ return;
288
+ }
289
+ // The other half of the content-length promise: too many octets is caught as they arrive
290
+ // (session _onDataFrame, a connection error), too few can only be seen here, where the
291
+ // message ends. Measured on node (h2spec http2/8.1.2.6): RST_STREAM(PROTOCOL_ERROR) and the
292
+ // application is told ERR_HTTP2_STREAM_ERROR -- the short body is one request's failure, not
293
+ // the session's, which is the same split nghttp2 makes and the reason the two directions of
294
+ // the same lie get two different codes.
295
+ if (this._expLen >= 0 && this._bodyLen < this._expLen) {
296
+ this.session._streamError(this, PROTOCOL_ERROR, `content-length ${this._expLen} with only ` +
297
+ `${this._bodyLen} octets received`);
298
+ return;
299
+ }
300
+ this.state.remoteClose = 1;
301
+ this.endAfterHeaders = true;
302
+ // the readable side is done: 'end' after the last 'data', like a Readable.
303
+ // No auto-destroy: the application still has to answer. node keeps the
304
+ // writable side of an unanswered request open as well.
305
+ if (this.state.localClose === 1) {
306
+ this._closeNow();
307
+ process.nextTick(emitEndCloseNT, this);
308
+ return;
309
+ }
310
+ if (this.state.state === ST_OPEN) {
311
+ this.state.state = ST_HALF_CLOSED_REMOTE;
312
+ }
313
+ if (!this._isConsuming()) {
314
+ // Nothing has read the buffered bytes yet, so 'end' must not overtake them:
315
+ // a reader that attaches later is owed the whole sequence, EOF included.
316
+ this._endPending = true;
317
+ return;
318
+ }
319
+ process.nextTick(emitNT, this, 'end');
320
+ }
321
+
322
+ _endLocalSide() {
323
+ const first = this.state.localClose !== 1;
324
+ this.state.localClose = 1;
325
+ this._endQueued = false;
326
+ if (this.state.state === ST_OPEN) {
327
+ this.state.state = ST_HALF_CLOSED_LOCAL;
328
+ }
329
+ if (this._waitForTrailers) {
330
+ // one shot: a later end() must not ask for trailers a second time. The flag
331
+ // itself stays set, because sendTrailers() is only allowed while it is set.
332
+ if (first) {
333
+ process.nextTick(emitNT, this, 'wantTrailers');
334
+ }
335
+ return;
336
+ }
337
+ if (this.state.remoteClose === 1) {
338
+ this._closeIfBothDone();
339
+ }
340
+ }
341
+
342
+ _closeIfBothDone() {
343
+ if (this._destroyed || this.state.localClose !== 1 || this.state.remoteClose !== 1) {
344
+ return;
345
+ }
346
+ this._closeNow();
347
+ // An EOF that arrived while nobody was reading is still owed to the application, and it
348
+ // goes out before 'close' -- the same pair the both-halves-in-one-turn path above uses.
349
+ // Without this the stream simply never reported 'end' for a request that ended on its
350
+ // HEADERS frame (END_STREAM on HEADERS is every GET): _remoteEnded() had parked the EOF
351
+ // in _endPending for a reader that never appeared, and the local close below was the last
352
+ // event the stream ever produced. Measured against node on exactly that shape: it emits
353
+ // stream.end then stream.close (test/event-order.test.js, scenario eof).
354
+ if (this._endPending) {
355
+ this._endPending = false;
356
+ process.nextTick(emitEndCloseNT, this);
357
+ return;
358
+ }
359
+ process.nextTick(emitNT, this, 'close');
360
+ }
361
+
362
+ _closeNow() {
363
+ this.state.state = ST_CLOSED;
364
+ this._destroyed = true;
365
+ this._pending = null;
366
+ this.session._dropStream(this, false);
367
+ }
368
+
369
+ _destroy(code, reason, sendRst, err) {
370
+ if (this._destroyed) {
371
+ return;
372
+ }
373
+ this._destroyed = true;
374
+ this.rstCode = code;
375
+ // node's rule for 'aborted' is about the RESPONSE, not about the request:
376
+ // "if the writable side of the Http2Stream is still open, emit the 'aborted'
377
+ // event" (internal/http2/core.js, closeStream). Measured against a live node
378
+ // server: a peer that resets a stream whose request has already ended but
379
+ // whose response never started does report 'aborted', and a peer that resets
380
+ // a stream whose response was ended does not.
381
+ const responseOpen = this.state.localClose !== 1;
382
+ this.state.localClose = 1;
383
+ this.state.state = ST_CLOSED;
384
+ if (sendRst && this.session._open) {
385
+ this.session.sendRst(this.id, code, reason);
386
+ }
387
+ this._pending = null;
388
+ this.session._dropStream(this, true);
389
+ // The event is emitted synchronously -- before the 'end' that an already-queued
390
+ // tick would otherwise deliver first. Marking the remote half closed keeps a
391
+ // late END_STREAM from emitting 'end' after 'close'.
392
+ this.state.remoteClose = 1;
393
+ if (responseOpen) {
394
+ this.aborted = true;
395
+ this.emit('aborted');
396
+ }
397
+ if (err) {
398
+ process.nextTick(emitErrorCloseNT, this, err);
399
+ } else {
400
+ process.nextTick(emitNT, this, 'close');
401
+ }
402
+ }
403
+
404
+ // --- the compat layer's handlers ------------------------------------------------
405
+ // The compat layer does not subscribe to the stream's events: it installs one
406
+ // dispatch function and the stream calls it -- subscriptions are an order of
407
+ // magnitude more expensive per request than the dispatch (OPTIMIZATION.md
408
+ // item 5) -- and the handlers want the stream as `this` anyway.
409
+ //
410
+ // The dispatch runs BEFORE the event reaches the emitter, which is the order
411
+ // the subscriptions had: the compat layer's own handlers first, the
412
+ // application's listeners after.
413
+ emit(name, arg) {
414
+ const dispatch = this._compatDispatch;
415
+ if (dispatch !== null) {
416
+ dispatch.call(this, name, arg);
417
+ }
418
+ // The argument list is forwarded as it came: a listener that counts its
419
+ // arguments must not see an extra undefined.
420
+ if (arguments.length === 1) {
421
+ return super.emit(name);
422
+ }
423
+ if (arguments.length === 2) {
424
+ return super.emit(name, arg);
425
+ }
426
+ return super.emit.apply(this, arguments);
427
+ }
428
+
429
+ // --- inbound flow control ----------------------------------------------------
430
+ // Attaching a reader is how the application says "send me the body": replay
431
+ // whatever arrived before it did. attach after super.on(), or the replay would
432
+ // emit into an empty listener list. once()/addListener are covered by the
433
+ // listenerCount() check at delivery time.
434
+ on(name, fn) {
435
+ const r = super.on(name, fn);
436
+ if (name === 'data' || name === 'readable') {
437
+ this._startConsuming();
438
+ }
439
+ return r;
440
+ }
441
+
442
+ once(name, fn) {
443
+ const r = super.once(name, fn);
444
+ if (name === 'data' || name === 'readable') {
445
+ this._startConsuming();
446
+ }
447
+ return r;
448
+ }
449
+
450
+ // Credit goes back when the application takes the bytes, so a body nobody
451
+ // reads closes the sender's window instead of growing our heap. The buffer of
452
+ // unread chunks cannot exceed the window we advertised, because the peer is
453
+ // not allowed to send more than that without a credit we never gave.
454
+ _isConsuming() {
455
+ if (this._consumeState === 2) {
456
+ return false;
457
+ } // the reader asked us to stop
458
+ return this._consumeState === 1 || this.listenerCount('data') > 0 ||
459
+ this.listenerCount('readable') > 0;
460
+ }
461
+
462
+ // Called when a reader shows up: replay what was buffered, in order, and let
463
+ // later frames through directly.
464
+ _startConsuming() {
465
+ this._consumeState = 1;
466
+ const pending = this._pending;
467
+ if (pending !== null) {
468
+ this._pending = null;
469
+ for (let i = 0; i < pending.length; i++) {
470
+ const chunk = pending[i];
471
+ this._release(chunk.length);
472
+ this.emit('data', chunk);
473
+ }
474
+ }
475
+ if (this._endPending && !this._destroyed) {
476
+ this._endPending = false;
477
+ process.nextTick(emitNT, this, 'end');
478
+ }
479
+ }
480
+
481
+ // Internal pause, used by the compat layer when its Readable is full. There is
482
+ // no public pause()/resume() yet, and this is the only reason it exists.
483
+ _pauseConsuming() {
484
+ this._consumeState = 2;
485
+ }
486
+
487
+ _release(n) {
488
+ if (n > 0) {
489
+ this.session._consume(this, n);
490
+ }
491
+ }
492
+
493
+ _recvData(buf) {
494
+ const n = buf.length;
495
+ if (n === 0) {
496
+ return;
497
+ } // an empty DATA frame carries no payload
498
+ this._recvUsed += n;
499
+ if (this._isConsuming()) {
500
+ this._release(n);
501
+ this.emit('data', buf);
502
+ return;
503
+ }
504
+ // the chunk is either a view of a socket read or a buffer built for this frame
505
+ // alone; both outlive the tick, so it can simply be queued
506
+ if (this._pending === null) {
507
+ this._pending = [];
508
+ }
509
+ this._pending.push(buf);
510
+ }
511
+ }
512
+
513
+ function errorOf(code, msg) {
514
+ const err = new Error(msg);
515
+ err.code = code;
516
+ return err;
517
+ }
518
+
519
+ module.exports = { Http2Stream, errorOf, emitNT };
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "h2lean",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
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
+ "main": "index.js",
6
+ "files": [
7
+ "lib/",
8
+ "index.js"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/MakeNodeGreatAgain/h2lean.git"
13
+ },
14
+ "keywords": [
15
+ "http2",
16
+ "h2c",
17
+ "node:http2",
18
+ "nghttp2",
19
+ "server",
20
+ "client",
21
+ "pure-javascript",
22
+ "undici",
23
+ "fastify",
24
+ "http",
25
+ "grpc"
26
+ ],
27
+ "scripts": {
28
+ "test": "node --test --test-timeout=120000 test/*.test.js && node --test --test-timeout=120000 test/client/*.test.js",
29
+ "test:server": "node --test --test-timeout=120000 test/*.test.js",
30
+ "test:client": "node --test --test-timeout=120000 test/client/*.test.js",
31
+ "lint": "eslint .",
32
+ "spec": "node tools/check-h2spec.js",
33
+ "bench:showcase": "node bench/showcase/matrix.js",
34
+ "bench:client": "node bench/client/matrix.js",
35
+ "gen": "node tools/gen-hpack-data.js"
36
+ },
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "license": "Apache-2.0",
41
+ "author": {
42
+ "name": "MakeNodeGreatAgain",
43
+ "email": "351629238+MakeNodeGreatAgain@users.noreply.github.com"
44
+ },
45
+ "devDependencies": {
46
+ "@eslint/js": "^10.0.1",
47
+ "eslint": "^10.14.0",
48
+ "fast-xml-parser": "^5.12.0",
49
+ "uWebSockets.js": "github:uNetworking/UWebSockets.js#v20.70.0"
50
+ }
51
+ }