@statewalker/webrun-streams 0.2.2 → 0.3.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.
@@ -1,557 +0,0 @@
1
- import { type Duplex, TransportClosedError } from "./duplex.js";
2
- import { deserializeError, serializeError } from "./errors.js";
3
- import {
4
- type CreditGrantor,
5
- type CreditLedger,
6
- newCreditGrantor,
7
- newCreditLedger,
8
- } from "./flow-control.js";
9
- import { decodeUint32, encodeUint32 } from "./uint32.js";
10
-
11
- /**
12
- * The minimum a transport must expose to be wrapped by `emulateMux`. Inbound
13
- * bytes are surfaced as an async iterable; outbound is imperative `send`. All
14
- * five message-oriented transports (WebSocket, LiveKit data channel, PeerJS
15
- * DataConnection, MessagePort, in-process pipe) match this shape after a thin
16
- * wrapper.
17
- */
18
- export type ByteChannel = {
19
- send(bytes: Uint8Array): void;
20
- recv: AsyncIterable<Uint8Array>;
21
- closed: Promise<void>;
22
- close(): void;
23
- };
24
-
25
- const TYPE_OPEN = 0x01;
26
- const TYPE_DATA = 0x02;
27
- const TYPE_ACK = 0x03;
28
- const TYPE_END = 0x04;
29
- const TYPE_ERROR = 0x05;
30
- const TYPE_CLOSE = 0x06;
31
-
32
- const DEFAULT_MAX_STREAMS = 256;
33
- const DEFAULT_MTU = 64 * 1024;
34
- const DEFAULT_MAX_STREAM_BUFFER = 8 * 1024 * 1024;
35
-
36
- const textEncoder = new TextEncoder();
37
- const textDecoder = new TextDecoder();
38
-
39
- interface Stream {
40
- id: number;
41
- inbound: AsyncGenerator<Uint8Array>;
42
- pushIn: (chunk: Uint8Array) => Promise<boolean>;
43
- doneIn: (err?: Error) => Promise<boolean>;
44
- /** Credit the peer has granted us for sending on this stream. */
45
- outboundCredit: CreditLedger;
46
- /** Tracks consumer drainage to decide when to grant the peer more credit. */
47
- grantor: CreditGrantor;
48
- closed: boolean;
49
- /** Peer sent END (or the stream was torn down): nothing more arrives. */
50
- inDone: boolean;
51
- /** Our outbound pump sent END: nothing more will be sent. */
52
- outDone: boolean;
53
- /** Inbound bytes pushed but not yet taken by the consumer. */
54
- queuedBytes: number;
55
- /**
56
- * Cancels the caller's outbound input, set by `pumpOutbound`.
57
- *
58
- * WHY THIS EXISTS. `pumpOutbound` is fire-and-forget and checks `s.closed`
59
- * only AFTER a chunk arrives, so an input that yields nothing more leaves
60
- * the pump parked on `input.next()` for ever — holding the producer and its
61
- * `finally` — even though the consumer has cancelled and the slot is gone.
62
- * Teardown has to reach back and return the input rather than wait for it.
63
- */
64
- cancelInput?: () => void;
65
- }
66
-
67
- export interface EmulateMuxOptions {
68
- maxStreams?: number;
69
- mtu?: number;
70
- /**
71
- * Cap on inbound bytes one stream may hold for a consumer that has not
72
- * drained them. Exceeding it tears down that stream alone.
73
- *
74
- * It is also the credit this side advertises to the peer, so it must be at
75
- * least 1 — a window of 0 authorises nothing and hangs the peer forever —
76
- * and it travels in a uint32, so a value above `MAX_UINT32` (4 GiB - 1) is
77
- * advertised as `MAX_UINT32`.
78
- */
79
- maxStreamBuffer?: number;
80
- /**
81
- * Stream-id allocation side. Initiator uses even ids (2, 4, …); responder
82
- * uses odd ids (1, 3, …). Pick one per peer so allocations don't collide.
83
- */
84
- side?: "initiator" | "responder";
85
- }
86
-
87
- export function emulateMux(
88
- channel: ByteChannel,
89
- opts: EmulateMuxOptions = {},
90
- ): {
91
- call: Duplex;
92
- serve: (handler: Duplex) => () => Promise<void>;
93
- close: () => Promise<void>;
94
- } {
95
- const maxStreams = opts.maxStreams ?? DEFAULT_MAX_STREAMS;
96
- const mtu = opts.mtu ?? DEFAULT_MTU;
97
- const maxStreamBuffer = opts.maxStreamBuffer ?? DEFAULT_MAX_STREAM_BUFFER;
98
- // A window of 0 is not a small window, it is a permanent stall: the peer is
99
- // authorised to send nothing and no event would ever grant it more. Refuse
100
- // it at construction rather than deadlocking on the first call.
101
- if (!(maxStreamBuffer >= 1)) {
102
- throw new RangeError(`emulateMux: maxStreamBuffer must be at least 1, got ${maxStreamBuffer}`);
103
- }
104
- const side = opts.side ?? "initiator";
105
-
106
- const streams = new Map<number, Stream>();
107
- let nextLocalId = side === "initiator" ? 2 : 1;
108
- let handler: Duplex | null = null;
109
- let muxClosed = false;
110
-
111
- const sendFrame = (id: number, type: number, payload?: Uint8Array): void => {
112
- if (muxClosed) return;
113
- const idEnc = encodeVarint(id);
114
- const total = idEnc.length + 1 + (payload?.byteLength ?? 0);
115
- const frame = new Uint8Array(total);
116
- frame.set(idEnc, 0);
117
- frame[idEnc.length] = type;
118
- if (payload && payload.byteLength > 0) frame.set(payload, idEnc.length + 1);
119
- try {
120
- channel.send(frame);
121
- } catch {
122
- /* underlying transport closed; inbound loop will detect */
123
- }
124
- };
125
-
126
- const teardownStream = (s: Stream, err?: Error): void => {
127
- if (s.closed) return;
128
- s.closed = true;
129
- // A sender parked in `reserve()` unwinds here: the credit it is waiting
130
- // for will never arrive, so fail the ledger rather than leave it queued.
131
- s.outboundCredit.fail(err ?? new TransportClosedError("emulateMux: stream closed"));
132
- void s.doneIn(err);
133
- // Return the caller's input so its `finally` runs. Not awaited, and that
134
- // is deliberate: `.return()` on a generator parked awaiting its own source
135
- // is QUEUED behind that pending `next()`, so awaiting it here would make
136
- // teardown hang on exactly the input we are trying to abandon.
137
- s.cancelInput?.();
138
- streams.delete(s.id);
139
- };
140
-
141
- /**
142
- * Graceful counterpart to `teardownStream`. A stream occupies a slot in
143
- * `streams` until BOTH directions finish; releasing on inbound END alone
144
- * would set `closed` while our own `pumpOutbound` is still sending, and it
145
- * checks that flag to decide whether to keep going.
146
- *
147
- * Without this, only abnormal endings (cancel, ERROR, CLOSE, transport
148
- * failure) ever freed a slot, so every normally-completed call leaked one
149
- * until `maxStreams` began rejecting new calls.
150
- */
151
- const releaseIfComplete = (s: Stream): void => {
152
- if (s.closed || !s.inDone || !s.outDone) return;
153
- s.closed = true;
154
- streams.delete(s.id);
155
- };
156
-
157
- const failAll = (err: Error): void => {
158
- if (muxClosed) return;
159
- muxClosed = true;
160
- for (const s of [...streams.values()]) teardownStream(s, err);
161
- streams.clear();
162
- };
163
-
164
- const createStream = (id: number): Stream => {
165
- const queue = makeInboundQueue();
166
- const state: Stream = {
167
- id,
168
- inbound: queue.generator(() => {
169
- if (!state.closed && !muxClosed) sendFrame(state.id, TYPE_CLOSE);
170
- teardownStream(state);
171
- }),
172
- pushIn: queue.push,
173
- doneIn: queue.done,
174
- outboundCredit: newCreditLedger(0),
175
- grantor: newCreditGrantor(maxStreamBuffer),
176
- closed: false,
177
- inDone: false,
178
- outDone: false,
179
- queuedBytes: 0,
180
- };
181
- return state;
182
- };
183
-
184
- const pumpOutbound = async (
185
- s: Stream,
186
- input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
187
- ): Promise<void> => {
188
- // Hand teardown a way to cancel this input. An async iterator's `return`
189
- // is the only cancellation the protocol has, and the pump cannot poll for
190
- // one: between two chunks it is parked inside `next()`, which for a
191
- // long-lived session is where it spends its whole life.
192
- //
193
- // THE ITERATOR IS ACQUIRED ONCE AND DRIVEN BY HAND. `for await (… of
194
- // input)` would call `[Symbol.asyncIterator]()` again; for a generator
195
- // that returns the same object, but for any other iterable it returns a
196
- // SECOND iterator — and then `cancelInput` would be cancelling something
197
- // the loop is not reading.
198
- const iterator: AsyncIterator<Uint8Array> | Iterator<Uint8Array> =
199
- (input as AsyncIterable<Uint8Array>)[Symbol.asyncIterator]?.() ??
200
- (input as Iterable<Uint8Array>)[Symbol.iterator]();
201
- s.cancelInput = () => {
202
- void (iterator as AsyncIterator<Uint8Array>).return?.(undefined);
203
- };
204
- try {
205
- for (;;) {
206
- const next = await iterator.next();
207
- if (next.done === true) break;
208
- const chunk = next.value;
209
- if (s.closed || muxClosed) return;
210
- if (chunk.byteLength === 0) continue;
211
- let off = 0;
212
- while (off < chunk.byteLength) {
213
- if (s.closed || muxClosed) return;
214
- // Reserve BEFORE framing. `reserve` returns what the peer's window
215
- // can actually take, up to one mtu, so a piece is never larger than
216
- // the credit that exists for it — which is what stops a sender whose
217
- // mtu exceeds the peer's whole window from stalling forever.
218
- const take = await s.outboundCredit.reserve(Math.min(mtu, chunk.byteLength - off));
219
- if (s.closed || muxClosed) return;
220
- const end = off + take;
221
- sendFrame(s.id, TYPE_DATA, chunk.subarray(off, end));
222
- off = end;
223
- }
224
- }
225
- if (!s.closed && !muxClosed) {
226
- sendFrame(s.id, TYPE_END);
227
- s.outDone = true;
228
- releaseIfComplete(s);
229
- }
230
- } catch (err) {
231
- if (!s.closed && !muxClosed) {
232
- const e = err instanceof Error ? err : new Error(String(err));
233
- sendFrame(s.id, TYPE_ERROR, encodeError(e));
234
- teardownStream(s, e);
235
- }
236
- }
237
- };
238
-
239
- const runHandler = async (s: Stream, h: Duplex): Promise<void> => {
240
- let outbound: AsyncGenerator<Uint8Array>;
241
- try {
242
- outbound = h(s.inbound);
243
- } catch (err) {
244
- const e = err instanceof Error ? err : new Error(String(err));
245
- sendFrame(s.id, TYPE_ERROR, encodeError(e));
246
- teardownStream(s, e);
247
- return;
248
- }
249
- await pumpOutbound(s, outbound);
250
- // The response is complete. If the handler never consumed the request
251
- // body, nothing will ever drain the inbound queue, so no ACK is sent, the
252
- // caller's pump parks forever awaiting one, and neither peer frees the
253
- // slot — while the caller's `for await` completes normally, so nothing
254
- // looks wrong from user code. Tell the peer we no longer want the body.
255
- //
256
- // This is why a handler must consume `input` within its generator's
257
- // lifetime: draining it from a detached task is indistinguishable, from
258
- // here, from not draining it at all.
259
- if (!s.inDone && !s.closed && !muxClosed) {
260
- sendFrame(s.id, TYPE_CLOSE);
261
- teardownStream(s);
262
- }
263
- };
264
-
265
- const handleFrame = (frame: Uint8Array): void => {
266
- if (muxClosed) return;
267
- if (frame.byteLength < 2) return;
268
-
269
- // The id is the one field parsed from untrusted bytes before we know which
270
- // stream a frame belongs to, and decodeVarint throws on a truncated or
271
- // over-long encoding. Left unguarded that exception escapes handleFrame,
272
- // reaches the inbound loop's catch, and calls failAll — so a single
273
- // malformed frame tears down every stream on the connection.
274
- //
275
- // A ByteChannel is message-oriented, so frames are discrete and a corrupt
276
- // one cannot desync the next. Dropping it is therefore safe, and is what
277
- // keeps one bad frame from becoming a denial of service against every
278
- // healthy stream sharing the mux.
279
- let id: number;
280
- let offset: number;
281
- try {
282
- ({ value: id, offset } = decodeVarint(frame, 0));
283
- } catch {
284
- return;
285
- }
286
- if (offset >= frame.byteLength) return; // id consumed the whole frame: no type byte
287
- const type = frame[offset];
288
- const payload = frame.subarray(offset + 1);
289
-
290
- if (type === TYPE_OPEN) {
291
- // A duplicate OPEN for a LIVE stream is ignored. A replay of one that
292
- // already finished is deliberately not guarded: every transport here is
293
- // ordered and reliable, so a replay cannot occur by accident, and a
294
- // hostile peer gains nothing by it — opening a fresh id invokes the same
295
- // handler just as well. An earlier monotonic high-water mark did guard
296
- // it, at the cost of letting one OPEN with a high id permanently refuse
297
- // every later stream, including that peer's own legitimate traffic.
298
- if (streams.has(id)) return;
299
- if (streams.size >= maxStreams) {
300
- sendFrame(
301
- id,
302
- TYPE_ERROR,
303
- encodeError(new RangeError(`emulateMux: maxStreams=${maxStreams} exceeded`)),
304
- );
305
- return;
306
- }
307
- if (!handler) {
308
- sendFrame(id, TYPE_ERROR, encodeError(new Error("emulateMux: no handler registered")));
309
- return;
310
- }
311
- const s = createStream(id);
312
- streams.set(id, s);
313
- // The opener's advertisement is our whole sending allowance; our own
314
- // goes back so the opener can start. Both ledgers begin at zero, so the
315
- // advertisement and every later grant are the same operation — an
316
- // increment — and the ACK handler needs no special first case.
317
- const advertised = decodeUint32(payload);
318
- if (advertised !== undefined) s.outboundCredit.grant(advertised);
319
- sendFrame(id, TYPE_ACK, encodeUint32(maxStreamBuffer));
320
- void runHandler(s, handler);
321
- return;
322
- }
323
-
324
- const s = streams.get(id);
325
- if (!s) return;
326
-
327
- switch (type) {
328
- case TYPE_DATA: {
329
- // Flow control is the peer spending only the credit we granted it —
330
- // voluntary, and a hostile peer simply does not. Pushes are
331
- // fire-and-forget by necessity (see the comment below), so nothing
332
- // else bounds this queue: a peer that floods DATA at a handler which
333
- // has not started draining retains every payload. Refuse past a
334
- // per-stream cap and tear down THAT stream, never the whole mux.
335
- s.queuedBytes += payload.byteLength;
336
- if (s.queuedBytes > maxStreamBuffer) {
337
- const err = new RangeError(
338
- `emulateMux: stream ${id} buffered ${s.queuedBytes} bytes past maxStreamBuffer=${maxStreamBuffer} without being drained`,
339
- );
340
- sendFrame(id, TYPE_ERROR, encodeError(err));
341
- teardownStream(s, err);
342
- return;
343
- }
344
- const copy = payload.byteLength === 0 ? payload : new Uint8Array(payload);
345
- // Push fire-and-forget; grant after the consumer drains. The inbound
346
- // loop must NOT block on consumer drainage — our own sender is parked
347
- // in `reserve()` waiting for the peer's grants, so blocking here
348
- // causes a cross-direction deadlock where ACK frames can't be
349
- // processed.
350
- void s.pushIn(copy).then((handled) => {
351
- s.queuedBytes -= copy.byteLength;
352
- if (!handled || s.closed || muxClosed) return;
353
- // Grant only for bytes the consumer actually took, batched while the
354
- // receiver is behind and flushed the moment its queue empties.
355
- const grant = s.grantor.consumed(copy.byteLength, s.queuedBytes === 0);
356
- if (grant > 0) sendFrame(id, TYPE_ACK, encodeUint32(grant));
357
- });
358
- return;
359
- }
360
- case TYPE_ACK: {
361
- const granted = decodeUint32(payload);
362
- // A payload-less ACK is not from a credit-speaking peer; ignore it
363
- // rather than granting an arbitrary amount.
364
- if (granted !== undefined) s.outboundCredit.grant(granted);
365
- return;
366
- }
367
- case TYPE_END: {
368
- s.inDone = true;
369
- void s.doneIn();
370
- releaseIfComplete(s);
371
- return;
372
- }
373
- case TYPE_ERROR: {
374
- teardownStream(s, decodeError(payload));
375
- return;
376
- }
377
- case TYPE_CLOSE: {
378
- teardownStream(s);
379
- return;
380
- }
381
- default:
382
- return;
383
- }
384
- };
385
-
386
- // Inbound consumer
387
- void (async () => {
388
- try {
389
- for await (const frame of channel.recv) {
390
- if (muxClosed) break;
391
- handleFrame(frame);
392
- }
393
- } catch (err) {
394
- failAll(err instanceof Error ? err : new Error(String(err)));
395
- return;
396
- }
397
- failAll(new TransportClosedError());
398
- })();
399
-
400
- void channel.closed.then(() => failAll(new TransportClosedError())).catch(() => {});
401
-
402
- const call: Duplex = (input) => {
403
- if (muxClosed) return failedGenerator(new TransportClosedError());
404
- if (streams.size >= maxStreams) {
405
- return failedGenerator(new RangeError(`emulateMux: maxStreams=${maxStreams} exceeded`));
406
- }
407
- const id = nextLocalId;
408
- nextLocalId += 2;
409
- const s = createStream(id);
410
- streams.set(id, s);
411
- sendFrame(id, TYPE_OPEN, encodeUint32(maxStreamBuffer));
412
- void pumpOutbound(s, input);
413
- return s.inbound;
414
- };
415
-
416
- const serve = (h: Duplex): (() => Promise<void>) => {
417
- handler = h;
418
- let torn = false;
419
- return async () => {
420
- if (torn) return;
421
- torn = true;
422
- if (handler === h) handler = null;
423
- };
424
- };
425
-
426
- const close = async (): Promise<void> => {
427
- if (muxClosed) return;
428
- failAll(new TransportClosedError());
429
- try {
430
- channel.close();
431
- } catch {
432
- /* ignore */
433
- }
434
- };
435
-
436
- return { call, serve, close };
437
- }
438
-
439
- function failedGenerator(err: Error): AsyncGenerator<Uint8Array> {
440
- return (async function* () {
441
- if ((0 as number) === 0) throw err;
442
- yield new Uint8Array(0);
443
- })();
444
- }
445
-
446
- /**
447
- * Push/pull queue with eager push/done handles. Unlike `newAsyncGenerator`, the
448
- * push and done functions are usable *before* the consumer begins iterating —
449
- * `emulateMux` needs to enqueue frames from inbound traffic regardless of when
450
- * (or whether) the consumer pulls them.
451
- *
452
- * `onCancel` fires only when the consumer terminates the generator before
453
- * `done()` was called — i.e., a unilateral cancellation. If `done()` is
454
- * called first (peer END/ERROR/CLOSE), the generator ends naturally and
455
- * `onCancel` does not fire.
456
- */
457
- function makeInboundQueue(): {
458
- generator: (onCancel: () => void) => AsyncGenerator<Uint8Array>;
459
- push: (chunk: Uint8Array) => Promise<boolean>;
460
- done: (err?: Error) => Promise<boolean>;
461
- } {
462
- type Slot =
463
- | { type: "value"; value: Uint8Array; resolve: (v: boolean) => void }
464
- | { type: "done"; err?: Error; resolve: (v: boolean) => void };
465
- const slots: Slot[] = [];
466
- let wake: (() => void) | null = null;
467
- let queueClosed = false;
468
- let doneCalled = false;
469
-
470
- const push = (chunk: Uint8Array): Promise<boolean> => {
471
- if (queueClosed) return Promise.resolve(false);
472
- return new Promise<boolean>((resolve) => {
473
- slots.push({ type: "value", value: chunk, resolve });
474
- wake?.();
475
- });
476
- };
477
-
478
- const done = (err?: Error): Promise<boolean> => {
479
- if (queueClosed || doneCalled) return Promise.resolve(false);
480
- doneCalled = true;
481
- return new Promise<boolean>((resolve) => {
482
- slots.push({ type: "done", err, resolve });
483
- wake?.();
484
- });
485
- };
486
-
487
- async function* generator(onCancel: () => void): AsyncGenerator<Uint8Array> {
488
- try {
489
- while (true) {
490
- if (slots.length === 0) {
491
- await new Promise<void>((r) => {
492
- wake = r;
493
- });
494
- wake = null;
495
- continue;
496
- }
497
- const slot = slots.shift() as Slot;
498
- if (slot.type === "done") {
499
- slot.resolve(true);
500
- if (slot.err) throw slot.err;
501
- return;
502
- }
503
- yield slot.value;
504
- slot.resolve(true);
505
- }
506
- } finally {
507
- queueClosed = true;
508
- for (const s of slots) s.resolve(false);
509
- slots.length = 0;
510
- if (!doneCalled) onCancel();
511
- }
512
- }
513
-
514
- return { generator, push, done };
515
- }
516
-
517
- function encodeVarint(value: number): Uint8Array {
518
- if (!Number.isInteger(value) || value < 0) {
519
- throw new RangeError(`encodeVarint: ${value} is not a non-negative integer`);
520
- }
521
- const out: number[] = [];
522
- let v = value;
523
- while (v >= 0x80) {
524
- out.push((v & 0x7f) | 0x80);
525
- v >>>= 7;
526
- }
527
- out.push(v & 0x7f);
528
- return new Uint8Array(out);
529
- }
530
-
531
- function decodeVarint(buf: Uint8Array, start: number): { value: number; offset: number } {
532
- let value = 0;
533
- let shift = 0;
534
- let i = start;
535
- while (i < buf.length) {
536
- // biome-ignore lint/style/noNonNullAssertion: i < buf.length, checked by the while condition, so this index exists.
537
- const b = buf[i++]!;
538
- value |= (b & 0x7f) << shift;
539
- if ((b & 0x80) === 0) return { value: value >>> 0, offset: i };
540
- shift += 7;
541
- if (shift > 28) throw new Error("decodeVarint: too long");
542
- }
543
- throw new Error("decodeVarint: truncated");
544
- }
545
-
546
- function encodeError(err: Error): Uint8Array {
547
- return textEncoder.encode(JSON.stringify(serializeError(err)));
548
- }
549
-
550
- function decodeError(buf: Uint8Array): Error {
551
- if (buf.byteLength === 0) return new Error("unknown stream error");
552
- try {
553
- return deserializeError(JSON.parse(textDecoder.decode(buf)));
554
- } catch {
555
- return new Error(textDecoder.decode(buf));
556
- }
557
- }
package/src/uint32.ts DELETED
@@ -1,34 +0,0 @@
1
- /**
2
- * Credit payloads are a single big-endian uint32, matching the frame header's
3
- * byte order.
4
- *
5
- * Deliberately **not** re-exported from `index.ts`: this is an internal codec
6
- * for the `emulateMux` wire format, not a compatibility commitment. Tests
7
- * import it by path.
8
- */
9
- /** The largest credit a single frame can advertise or grant: 2^32 - 1. */
10
- export const MAX_UINT32 = 0xffffffff;
11
-
12
- /**
13
- * Clamps rather than wraps. `n >>> 0` is the obvious spelling and it is wrong
14
- * here: 2^32 becomes **0**, so a 4 GiB window advertises *zero credit* and the
15
- * peer stalls forever with no error — the exact silent hang credit exists to
16
- * remove. 2^32 + 5 becomes 5, which looks like a working window and is worse.
17
- * Anything above the ceiling is advertised as the ceiling.
18
- */
19
- export function encodeUint32(n: number): Uint8Array {
20
- const bytes = new Uint8Array(4);
21
- const clamped = Number.isFinite(n) ? Math.min(MAX_UINT32, Math.max(0, Math.floor(n))) : 0;
22
- new DataView(bytes.buffer).setUint32(0, clamped, false);
23
- return bytes;
24
- }
25
-
26
- /**
27
- * Returns `undefined` rather than a garbage number when the payload is too
28
- * short, so a truncated frame — or one from a peer predating credit — is
29
- * detectable at the call site instead of silently granting nonsense.
30
- */
31
- export function decodeUint32(bytes: Uint8Array): number | undefined {
32
- if (bytes.byteLength < 4) return undefined;
33
- return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, false);
34
- }