@statewalker/webrun-rpc 0.8.0 → 0.10.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 +156 -10
- package/dist/duplex-over-port.d.ts +3 -1
- package/dist/duplex-over-port.d.ts.map +1 -1
- package/dist/index.js +446 -95
- package/dist/multiplex-port.d.ts.map +1 -1
- package/dist/port-credit.d.ts +51 -0
- package/dist/port-credit.d.ts.map +1 -1
- package/dist/port-types.d.ts +24 -2
- package/dist/port-types.d.ts.map +1 -1
- package/dist/structured-codec.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/duplex-over-port.ts +337 -40
- package/src/multiplex-port.ts +161 -16
- package/src/port-credit.ts +86 -0
- package/src/port-types.ts +19 -3
- package/src/structured-codec.ts +5 -1
package/dist/index.js
CHANGED
|
@@ -471,6 +471,101 @@ async function* callBidi(port, input, { options = {}, ...params } = {}) {
|
|
|
471
471
|
if (outerError !== void 0) throw outerError;
|
|
472
472
|
}
|
|
473
473
|
//#endregion
|
|
474
|
+
//#region src/port-credit.ts
|
|
475
|
+
/**
|
|
476
|
+
* Runs `fn` on a later task, never a microtask: a producer looping on
|
|
477
|
+
* `whenPortReady` must let the transport's own events run. `setImmediate`
|
|
478
|
+
* where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
|
|
479
|
+
* are clamped to 4 ms in browsers.
|
|
480
|
+
*/
|
|
481
|
+
const setImmediateFn = globalThis.setImmediate;
|
|
482
|
+
/** @internal */
|
|
483
|
+
const nextTask = setImmediateFn ? (fn) => {
|
|
484
|
+
setImmediateFn(fn);
|
|
485
|
+
} : (() => {
|
|
486
|
+
let channel;
|
|
487
|
+
const queue = [];
|
|
488
|
+
return (fn) => {
|
|
489
|
+
if (!channel) {
|
|
490
|
+
channel = new MessageChannel();
|
|
491
|
+
channel.port1.onmessage = () => queue.shift()?.();
|
|
492
|
+
}
|
|
493
|
+
queue.push(fn);
|
|
494
|
+
channel.port2.postMessage(0);
|
|
495
|
+
};
|
|
496
|
+
})();
|
|
497
|
+
const states = /* @__PURE__ */ new WeakMap();
|
|
498
|
+
/** @internal Registered by the multiplexer for each port with a window. */
|
|
499
|
+
function setPortSendState(port, state) {
|
|
500
|
+
states.set(port, state);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Bytes queued on `port` because its window is spent (0 for a port without
|
|
504
|
+
* flow control). A sender that should not queue without bound waits for
|
|
505
|
+
* `whenPortReady` before posting more.
|
|
506
|
+
*/
|
|
507
|
+
function portBufferedAmount(port) {
|
|
508
|
+
return states.get(port)?.bufferedAmount() ?? 0;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Resolves once at most `below` bytes are queued on `port`, always on a later
|
|
512
|
+
* task (for a port without flow control, on the next one).
|
|
513
|
+
*/
|
|
514
|
+
function whenPortReady(port, below = 0) {
|
|
515
|
+
return states.get(port)?.ready(below) ?? new Promise((resolve) => nextTask(resolve));
|
|
516
|
+
}
|
|
517
|
+
const agreements = /* @__PURE__ */ new WeakMap();
|
|
518
|
+
/** @internal Registered by the multiplexer for each port with a window. */
|
|
519
|
+
function setPortAgreement(port, agreement) {
|
|
520
|
+
agreements.set(port, agreement);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* @internal What has been agreed with the peer on `port` so far; `undefined`
|
|
524
|
+
* on a port without a window. Not exported from the package.
|
|
525
|
+
*/
|
|
526
|
+
function portAgreement(port) {
|
|
527
|
+
return agreements.get(port)?.();
|
|
528
|
+
}
|
|
529
|
+
const ends = /* @__PURE__ */ new WeakMap();
|
|
530
|
+
/** @internal Registered by the multiplexer for each port with a window. */
|
|
531
|
+
function setPortEndState(port, state) {
|
|
532
|
+
ends.set(port, state);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* @internal End this side's direction of `port`: an `end` is queued behind
|
|
536
|
+
* everything already posted on it, and anything posted afterwards is dropped.
|
|
537
|
+
* `false`, and nothing changes, when `end` was not agreed (no window, the
|
|
538
|
+
* handshake not answered yet, or a peer that does not speak it): the caller
|
|
539
|
+
* then closes the port. Never waits on the peer. Not exported from the package.
|
|
540
|
+
*/
|
|
541
|
+
function endPort(port) {
|
|
542
|
+
return ends.get(port)?.end() ?? false;
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* @internal Resolves once the peer has ended its direction of `port`. It never
|
|
546
|
+
* settles otherwise: not on a port where `end` is not agreed when it is asked
|
|
547
|
+
* (no window, the handshake not answered yet, a peer or codec without it), and
|
|
548
|
+
* not when the port closes first, so a caller races it against the port's
|
|
549
|
+
* close signal. Not exported from the package.
|
|
550
|
+
*/
|
|
551
|
+
function whenPortEnded(port) {
|
|
552
|
+
let waiting = peerEnds.get(port);
|
|
553
|
+
if (!waiting) {
|
|
554
|
+
let resolve = () => {};
|
|
555
|
+
const promise = new Promise((r) => {
|
|
556
|
+
resolve = r;
|
|
557
|
+
});
|
|
558
|
+
if (!ends.get(port)?.watchPeerEnd(resolve)) return promise;
|
|
559
|
+
waiting = {
|
|
560
|
+
promise,
|
|
561
|
+
resolve
|
|
562
|
+
};
|
|
563
|
+
peerEnds.set(port, waiting);
|
|
564
|
+
}
|
|
565
|
+
return waiting.promise;
|
|
566
|
+
}
|
|
567
|
+
const peerEnds = /* @__PURE__ */ new WeakMap();
|
|
568
|
+
//#endregion
|
|
474
569
|
//#region src/async-dispose.ts
|
|
475
570
|
/**
|
|
476
571
|
* Makes `target` disposable with `await using`: installs `dispose` as its
|
|
@@ -548,6 +643,61 @@ function returningAtOnce(gen, abandon) {
|
|
|
548
643
|
* tests and adapters assert on it.
|
|
549
644
|
*/
|
|
550
645
|
const STREAM_ABORT = "webrun-rpc:stream-abort";
|
|
646
|
+
/**
|
|
647
|
+
* The `type` of a pipelined chunk: a plain port message, not a `callPort`
|
|
648
|
+
* request, sent only on a port where both ends agreed `pipeline`. The window's
|
|
649
|
+
* credit is its confirmation.
|
|
650
|
+
*/
|
|
651
|
+
const CHUNK = "webrun-rpc:chunk";
|
|
652
|
+
/**
|
|
653
|
+
* What a pipelined chunk counts beyond its bytes, against the window, on the
|
|
654
|
+
* receiving side. Both codecs' windows count more for the chunk's envelope
|
|
655
|
+
* (the estimate at least 64, msgpack's keys alone more than this), so an honest
|
|
656
|
+
* sender never makes the receiver count past the window; a sender of empty
|
|
657
|
+
* chunks still cannot have it hold an unbounded number of them.
|
|
658
|
+
*/
|
|
659
|
+
const CHUNK_OVERHEAD = 32;
|
|
660
|
+
/**
|
|
661
|
+
* The bytes of `view` as a plain `Uint8Array` that owns its whole buffer:
|
|
662
|
+
* `view` itself when it already is one, else a copy. Structured clone carries
|
|
663
|
+
* and keeps a view's whole `ArrayBuffer`, and a byte codec may decode into
|
|
664
|
+
* views of the frame, while the window and the receiver's count see only the
|
|
665
|
+
* view: a 4 KiB `subarray` of a 4 MiB buffer would cost 4 MiB per chunk.
|
|
666
|
+
* Copied with the `Uint8Array` constructor, not `slice()`: a Node `Buffer`'s
|
|
667
|
+
* `slice()` is another view on the same memory.
|
|
668
|
+
*/
|
|
669
|
+
function ownBytes(view) {
|
|
670
|
+
if (view instanceof Uint8Array && view.constructor === Uint8Array && view.byteOffset === 0 && view.byteLength === view.buffer.byteLength) return view;
|
|
671
|
+
return new Uint8Array(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* A producer's chunk as bytes this end may send, in either mode: any view or
|
|
675
|
+
* `ArrayBuffer`, normalised by `ownBytes`. Anything else fails the stream here,
|
|
676
|
+
* as the producer's own error, rather than reaching a peer that would refuse it.
|
|
677
|
+
*/
|
|
678
|
+
/**
|
|
679
|
+
* A received confirmed chunk's value, as its own bytes, put back in place of
|
|
680
|
+
* the original in `chunk`: `listenPort` keeps the request until the handler
|
|
681
|
+
* returns, and the original would keep its whole buffer that long. A function
|
|
682
|
+
* of its own, so that no temporary of the suspended handler holds the
|
|
683
|
+
* original either.
|
|
684
|
+
*/
|
|
685
|
+
function adoptValue(chunk) {
|
|
686
|
+
const original = chunk.value;
|
|
687
|
+
if (ArrayBuffer.isView(original)) chunk.value = ownBytes(original);
|
|
688
|
+
return chunk.value;
|
|
689
|
+
}
|
|
690
|
+
function chunkBytes(value) {
|
|
691
|
+
if (ArrayBuffer.isView(value)) return ownBytes(value);
|
|
692
|
+
if (value instanceof ArrayBuffer || Object.prototype.toString.call(value) === "[object ArrayBuffer]") return new Uint8Array(value);
|
|
693
|
+
throw new TypeError(`webrun-rpc: a stream chunk must be bytes (a typed array, a DataView or an ArrayBuffer), got ${value === null ? "null" : typeof value}`);
|
|
694
|
+
}
|
|
695
|
+
const MIXED_MESSAGE = "webrun-rpc: peer mixed confirmed and pipelined chunks; the stream port is closed";
|
|
696
|
+
/** This end may pipeline its own chunks: agreed, and the window holds them. */
|
|
697
|
+
const mayPipeline = (port) => {
|
|
698
|
+
const agreement = portAgreement(port);
|
|
699
|
+
return !!agreement?.enforced && agreement.caps.has("pipeline");
|
|
700
|
+
};
|
|
551
701
|
/** Caller's input travels on this channel; the handler listens on it. */
|
|
552
702
|
const CHANNEL_IN = "in";
|
|
553
703
|
/** The handler's output travels on this channel; the caller listens on it. */
|
|
@@ -560,7 +710,9 @@ const CHANNEL_OUT = "out";
|
|
|
560
710
|
* same way on the other channel. Within each direction the next chunk is never
|
|
561
711
|
* sent until the previous one has been delivered *and* pulled past by the
|
|
562
712
|
* consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
|
|
563
|
-
* `listenPort` withholds it until then.
|
|
713
|
+
* `listenPort` withholds it until then. On a port whose window both ends
|
|
714
|
+
* speak and that agreed `pipeline`, chunks go as plain messages instead, and
|
|
715
|
+
* the window's credit is the confirmation: up to a window is in flight.
|
|
564
716
|
*
|
|
565
717
|
* A stream port carries exactly one invocation. To make several calls, open
|
|
566
718
|
* several ports: `mux.openPort({ kind: "stream" })` per call.
|
|
@@ -577,22 +729,49 @@ function serveDuplexOverPort(port, handler, options = {}) {
|
|
|
577
729
|
const notice = installAbortNotice(port, controller);
|
|
578
730
|
abortOnPortClose(port, controller);
|
|
579
731
|
const clock = installStreamTimeout(controller, options.timeout);
|
|
580
|
-
const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
|
|
732
|
+
const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch, notice);
|
|
581
733
|
let output;
|
|
582
734
|
try {
|
|
583
735
|
output = handler(inbound.stream);
|
|
584
736
|
} catch (err) {
|
|
585
|
-
const pump = sendChunks(port, CHANNEL_OUT, failing(err), options, controller.signal, clock.touch);
|
|
737
|
+
const pump = sendChunks(port, CHANNEL_OUT, failing(err), options, controller.signal, clock.touch, () => {});
|
|
586
738
|
pump.catch(() => {});
|
|
587
739
|
disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
|
|
588
740
|
return teardownOnce(controller, notice, clock, inbound, void 0);
|
|
589
741
|
}
|
|
590
|
-
|
|
742
|
+
let onSent = () => {};
|
|
743
|
+
const sent = new Promise((resolve) => {
|
|
744
|
+
onSent = resolve;
|
|
745
|
+
});
|
|
746
|
+
const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch, onSent);
|
|
591
747
|
pump.catch(() => {});
|
|
592
748
|
disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
|
|
749
|
+
endServedStream(port, sent, inbound.over);
|
|
593
750
|
return teardownOnce(controller, notice, clock, inbound, output);
|
|
594
751
|
}
|
|
595
752
|
/**
|
|
753
|
+
* The serving side's normal finish. Once the handler's last chunk has gone
|
|
754
|
+
* without an error, and no more input is coming (`over`: the caller's last
|
|
755
|
+
* chunk arrived without an error and was read, or the caller ended its
|
|
756
|
+
* direction behind it, for a handler that did not read all its input), this
|
|
757
|
+
* side ends its direction too, where `end` was agreed. Without `end` nothing
|
|
758
|
+
* happens here: the caller closes the port. A stream that never gets that far
|
|
759
|
+
* was abandoned, and the caller's notice and `close` free the port; this then
|
|
760
|
+
* never resumes. It needs no race against the port's close signal: nothing
|
|
761
|
+
* awaits it, and what keeps it waiting is the input's watcher, a listener of
|
|
762
|
+
* the port that a close drops, after which the wait is collected.
|
|
763
|
+
*
|
|
764
|
+
* Nothing this side still posts is lost behind its `end`. The caller ends only
|
|
765
|
+
* once its own last chunk was confirmed, so no confirmation is owed to it
|
|
766
|
+
* here, and what the freed port's close signal aborts has settled: the last
|
|
767
|
+
* chunk has left, and input nobody reads is dropped, as on a close.
|
|
768
|
+
*/
|
|
769
|
+
async function endServedStream(port, sent, over) {
|
|
770
|
+
await sent;
|
|
771
|
+
await over;
|
|
772
|
+
endPort(port);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
596
775
|
* A stream that completes normally has nobody left to call the returned
|
|
597
776
|
* teardown — the caller only knows the stream ended, not that it must also
|
|
598
777
|
* dispose the serve-side handle. Without this, the last `touch()`'s timer
|
|
@@ -632,22 +811,31 @@ function runCallerSide(port, input, options) {
|
|
|
632
811
|
const notice = installAbortNotice(port, controller);
|
|
633
812
|
abortOnPortClose(port, controller);
|
|
634
813
|
const clock = installStreamTimeout(controller, options.timeout);
|
|
635
|
-
const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
|
|
636
|
-
|
|
814
|
+
const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch, notice);
|
|
815
|
+
let sent = false;
|
|
816
|
+
const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch, () => {
|
|
817
|
+
sent = true;
|
|
818
|
+
});
|
|
637
819
|
pump.catch(() => {});
|
|
638
820
|
return returningAtOnce((async function* () {
|
|
821
|
+
let received = false;
|
|
639
822
|
try {
|
|
640
823
|
yield* inbound.stream;
|
|
824
|
+
received = true;
|
|
641
825
|
} finally {
|
|
826
|
+
const ended = received && sent;
|
|
642
827
|
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
|
|
643
|
-
notice.post();
|
|
828
|
+
if (!ended) notice.post();
|
|
644
829
|
notice.stop();
|
|
645
830
|
clock.stop();
|
|
646
831
|
inbound.stop();
|
|
647
832
|
await pump.catch(() => {});
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
833
|
+
if (!(ended && endPort(port))) {
|
|
834
|
+
notice.post();
|
|
835
|
+
try {
|
|
836
|
+
await port.close?.();
|
|
837
|
+
} catch {}
|
|
838
|
+
}
|
|
651
839
|
}
|
|
652
840
|
})(), () => {
|
|
653
841
|
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
|
|
@@ -741,7 +929,7 @@ function installStreamTimeout(controller, timeout) {
|
|
|
741
929
|
* the local consumer has not started iterating, an inbound chunk waits for it —
|
|
742
930
|
* which is the correct backpressure, and different from having no listener.
|
|
743
931
|
*/
|
|
744
|
-
function receiveChunks(port, channelName, controller, touch) {
|
|
932
|
+
function receiveChunks(port, channelName, controller, touch, notice) {
|
|
745
933
|
let deliver;
|
|
746
934
|
const waiting = [];
|
|
747
935
|
let finished = false;
|
|
@@ -751,25 +939,64 @@ function receiveChunks(port, channelName, controller, touch) {
|
|
|
751
939
|
const ended = new Promise((resolve) => {
|
|
752
940
|
resolveEnded = resolve;
|
|
753
941
|
});
|
|
942
|
+
let resolveOver = () => {};
|
|
943
|
+
const over = new Promise((resolve) => {
|
|
944
|
+
resolveOver = resolve;
|
|
945
|
+
});
|
|
754
946
|
let aborted = false;
|
|
755
947
|
let abortReason;
|
|
756
948
|
const ready = () => deliver || finished ? Promise.resolve() : new Promise((r) => waiting.push(r));
|
|
757
949
|
const wake = () => {
|
|
758
950
|
for (const r of waiting.splice(0)) r();
|
|
759
951
|
};
|
|
760
|
-
const
|
|
952
|
+
const violate = (message) => {
|
|
953
|
+
poison = new Error(message);
|
|
954
|
+
controller.abort(poison);
|
|
955
|
+
setTimeout(() => {
|
|
956
|
+
try {
|
|
957
|
+
port.close?.();
|
|
958
|
+
} catch {}
|
|
959
|
+
}, 0);
|
|
960
|
+
return poison;
|
|
961
|
+
};
|
|
962
|
+
let lastChunkArrived = false;
|
|
963
|
+
let watching = false;
|
|
964
|
+
const watchPeerEnd = () => {
|
|
965
|
+
if (watching) return true;
|
|
966
|
+
if (!portAgreement(port)?.caps.has("end")) return false;
|
|
967
|
+
watching = true;
|
|
968
|
+
whenPortEnded(port).then(() => {
|
|
969
|
+
port.removeEventListener("message", watcher);
|
|
970
|
+
if (lastChunkArrived) {
|
|
971
|
+
resolveOver();
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
notice.post();
|
|
975
|
+
violate("webrun-rpc: the peer ended its direction without finishing it; the stream port is closed");
|
|
976
|
+
});
|
|
977
|
+
return true;
|
|
978
|
+
};
|
|
979
|
+
const watcher = (event) => {
|
|
980
|
+
const data = event.data;
|
|
981
|
+
if (data?.channelName !== channelName) return;
|
|
982
|
+
const chunk = data.type === CHUNK ? data.chunk : data.type === "request" ? data.params : void 0;
|
|
983
|
+
if (!chunk) return;
|
|
984
|
+
if (!watchPeerEnd()) {
|
|
985
|
+
port.removeEventListener("message", watcher);
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (chunk.done && !chunk.error) lastChunkArrived = true;
|
|
989
|
+
};
|
|
990
|
+
port.addEventListener("message", watcher);
|
|
991
|
+
watchPeerEnd();
|
|
992
|
+
let held = 0;
|
|
993
|
+
const off = listenPort(port, async (params) => {
|
|
994
|
+
const { done, error } = params;
|
|
995
|
+
const value = adoptValue(params);
|
|
761
996
|
touch();
|
|
762
997
|
if (poison) throw poison;
|
|
763
|
-
if (
|
|
764
|
-
|
|
765
|
-
controller.abort(poison);
|
|
766
|
-
setTimeout(() => {
|
|
767
|
-
try {
|
|
768
|
-
port.close?.();
|
|
769
|
-
} catch {}
|
|
770
|
-
}, 0);
|
|
771
|
-
throw poison;
|
|
772
|
-
}
|
|
998
|
+
if (held > 0) throw violate(MIXED_MESSAGE);
|
|
999
|
+
if (outstanding) throw violate("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
|
|
773
1000
|
outstanding = true;
|
|
774
1001
|
try {
|
|
775
1002
|
await ready();
|
|
@@ -779,15 +1006,60 @@ function receiveChunks(port, channelName, controller, touch) {
|
|
|
779
1006
|
value,
|
|
780
1007
|
error: error ? deserializeError(error) : void 0
|
|
781
1008
|
});
|
|
1009
|
+
if (done && !error) resolveOver();
|
|
782
1010
|
} finally {
|
|
783
1011
|
outstanding = false;
|
|
784
1012
|
}
|
|
785
1013
|
if (done) resolveEnded();
|
|
786
1014
|
}, { channelName });
|
|
1015
|
+
const onChunk = (event) => {
|
|
1016
|
+
const data = event.data;
|
|
1017
|
+
if (data?.type !== CHUNK || data.channelName !== channelName || !data.chunk) return;
|
|
1018
|
+
if (poison) return;
|
|
1019
|
+
const agreement = portAgreement(port);
|
|
1020
|
+
if (!agreement?.caps.has("pipeline")) {
|
|
1021
|
+
violate("webrun-rpc: peer sent a pipelined chunk where pipelining was not agreed");
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (outstanding) {
|
|
1025
|
+
violate(MIXED_MESSAGE);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (data.chunk.value !== void 0 && !ArrayBuffer.isView(data.chunk.value)) {
|
|
1029
|
+
violate("webrun-rpc: peer sent a pipelined chunk whose value is not bytes");
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
if (held > agreement.window) {
|
|
1033
|
+
violate(`webrun-rpc: peer sent past the window (${agreement.window} bytes) without waiting for credit; the stream port is closed`);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
touch();
|
|
1037
|
+
const { done, error } = data.chunk;
|
|
1038
|
+
const value = data.chunk.value && ownBytes(data.chunk.value);
|
|
1039
|
+
const size = (value?.byteLength ?? 0) + CHUNK_OVERHEAD;
|
|
1040
|
+
held += size;
|
|
1041
|
+
return (async () => {
|
|
1042
|
+
try {
|
|
1043
|
+
await ready();
|
|
1044
|
+
if (finished) return;
|
|
1045
|
+
await deliver?.({
|
|
1046
|
+
done,
|
|
1047
|
+
value,
|
|
1048
|
+
error: error ? deserializeError(error) : void 0
|
|
1049
|
+
});
|
|
1050
|
+
if (done && !error) resolveOver();
|
|
1051
|
+
if (done) resolveEnded();
|
|
1052
|
+
} finally {
|
|
1053
|
+
held -= size;
|
|
1054
|
+
}
|
|
1055
|
+
})();
|
|
1056
|
+
};
|
|
1057
|
+
port.addEventListener("message", onChunk);
|
|
787
1058
|
const detach = () => {
|
|
788
1059
|
finished = true;
|
|
789
1060
|
wake();
|
|
790
1061
|
off();
|
|
1062
|
+
port.removeEventListener("message", onChunk);
|
|
791
1063
|
controller.signal.removeEventListener("abort", onAbort);
|
|
792
1064
|
resolveEnded();
|
|
793
1065
|
};
|
|
@@ -817,25 +1089,35 @@ function receiveChunks(port, channelName, controller, touch) {
|
|
|
817
1089
|
return detach;
|
|
818
1090
|
}),
|
|
819
1091
|
stop: detach,
|
|
820
|
-
ended
|
|
1092
|
+
ended,
|
|
1093
|
+
over
|
|
821
1094
|
};
|
|
822
1095
|
}
|
|
823
1096
|
/**
|
|
824
1097
|
* The sending half of one direction: one `callPort` per chunk, one call
|
|
825
|
-
* outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8)
|
|
1098
|
+
* outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8) —
|
|
1099
|
+
* or, once pipelining is agreed, plain chunk messages held back by the window.
|
|
1100
|
+
* `onSent` runs once the end of the output has left without an error: posted
|
|
1101
|
+
* when pipelined, confirmed when not.
|
|
826
1102
|
*/
|
|
827
|
-
async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
|
|
1103
|
+
async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch, onSent) {
|
|
828
1104
|
const framed = maxMessageSize ? toChunks(maxMessageSize)(output) : output;
|
|
829
1105
|
const stream = throughAbort(framed, signal);
|
|
830
1106
|
const producer = output;
|
|
831
1107
|
const returnProducer = () => void Promise.resolve(producer.return?.()).catch(() => {});
|
|
832
1108
|
if (framed !== output) signal.addEventListener("abort", returnProducer, { once: true });
|
|
1109
|
+
let onAborted = () => {};
|
|
1110
|
+
const aborted = new Promise((resolve) => {
|
|
1111
|
+
onAborted = () => resolve();
|
|
1112
|
+
});
|
|
1113
|
+
if (signal.aborted) onAborted();
|
|
1114
|
+
else signal.addEventListener("abort", onAborted, { once: true });
|
|
833
1115
|
try {
|
|
834
1116
|
await sendIterator(async ({ done, value, error }) => {
|
|
835
1117
|
if (signal.aborted) return;
|
|
836
1118
|
const chunk = {
|
|
837
1119
|
done,
|
|
838
|
-
value,
|
|
1120
|
+
value: done ? value : chunkBytes(value),
|
|
839
1121
|
error: error === void 0 ? void 0 : serializeError(error)
|
|
840
1122
|
};
|
|
841
1123
|
log?.("[duplexOverPort] send", {
|
|
@@ -843,65 +1125,34 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
|
|
|
843
1125
|
done,
|
|
844
1126
|
size: value?.byteLength
|
|
845
1127
|
});
|
|
1128
|
+
if (mayPipeline(port)) {
|
|
1129
|
+
port.postMessage({
|
|
1130
|
+
type: CHUNK,
|
|
1131
|
+
channelName,
|
|
1132
|
+
chunk
|
|
1133
|
+
});
|
|
1134
|
+
touch();
|
|
1135
|
+
if (done && !error) onSent();
|
|
1136
|
+
if (portBufferedAmount(port) > 0) await Promise.race([whenPortReady(port), aborted]);
|
|
1137
|
+
return;
|
|
1138
|
+
}
|
|
846
1139
|
await callPort(port, chunk, {
|
|
847
1140
|
channelName,
|
|
848
1141
|
timeout: NO_TIMEOUT,
|
|
849
1142
|
signal
|
|
850
1143
|
});
|
|
851
1144
|
touch();
|
|
1145
|
+
if (done && !error) onSent();
|
|
852
1146
|
}, stream);
|
|
853
1147
|
} catch (err) {
|
|
854
1148
|
if (signal.aborted) return;
|
|
855
1149
|
throw err;
|
|
856
1150
|
} finally {
|
|
857
1151
|
signal.removeEventListener("abort", returnProducer);
|
|
1152
|
+
signal.removeEventListener("abort", onAborted);
|
|
858
1153
|
}
|
|
859
1154
|
}
|
|
860
1155
|
//#endregion
|
|
861
|
-
//#region src/port-credit.ts
|
|
862
|
-
/**
|
|
863
|
-
* Runs `fn` on a later task, never a microtask: a producer looping on
|
|
864
|
-
* `whenPortReady` must let the transport's own events run. `setImmediate`
|
|
865
|
-
* where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
|
|
866
|
-
* are clamped to 4 ms in browsers.
|
|
867
|
-
*/
|
|
868
|
-
const setImmediateFn = globalThis.setImmediate;
|
|
869
|
-
/** @internal */
|
|
870
|
-
const nextTask = setImmediateFn ? (fn) => {
|
|
871
|
-
setImmediateFn(fn);
|
|
872
|
-
} : (() => {
|
|
873
|
-
let channel;
|
|
874
|
-
const queue = [];
|
|
875
|
-
return (fn) => {
|
|
876
|
-
if (!channel) {
|
|
877
|
-
channel = new MessageChannel();
|
|
878
|
-
channel.port1.onmessage = () => queue.shift()?.();
|
|
879
|
-
}
|
|
880
|
-
queue.push(fn);
|
|
881
|
-
channel.port2.postMessage(0);
|
|
882
|
-
};
|
|
883
|
-
})();
|
|
884
|
-
const states = /* @__PURE__ */ new WeakMap();
|
|
885
|
-
/** @internal Registered by the multiplexer for each port with a window. */
|
|
886
|
-
function setPortSendState(port, state) {
|
|
887
|
-
states.set(port, state);
|
|
888
|
-
}
|
|
889
|
-
/**
|
|
890
|
-
* Bytes queued on `port` because its window is spent (0 for a port without
|
|
891
|
-
* flow control). A sender that should not queue without bound waits for
|
|
892
|
-
* `whenPortReady` before posting more.
|
|
893
|
-
*/
|
|
894
|
-
function portBufferedAmount(port) {
|
|
895
|
-
return states.get(port)?.bufferedAmount() ?? 0;
|
|
896
|
-
}
|
|
897
|
-
/**
|
|
898
|
-
* Resolves once at most `below` bytes are queued on `port`, always on a later
|
|
899
|
-
* task (for a port without flow control, on the next one).
|
|
900
|
-
*/
|
|
901
|
-
function whenPortReady(port, below = 0) {
|
|
902
|
-
return states.get(port)?.ready(below) ?? new Promise((resolve) => nextTask(resolve));
|
|
903
|
-
}
|
|
904
|
-
//#endregion
|
|
905
1156
|
//#region src/virtual-port.ts
|
|
906
1157
|
/**
|
|
907
1158
|
* One virtual port.
|
|
@@ -988,6 +1239,21 @@ const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
|
|
|
988
1239
|
const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
|
|
989
1240
|
/** The longest delay a timer holds; past it `setInterval` fires at once. */
|
|
990
1241
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
1242
|
+
/**
|
|
1243
|
+
* What a windowed port offers in its `open`: every capability this end
|
|
1244
|
+
* speaks. `end` only over a codec that reads it (`PortCodec.halfClose`).
|
|
1245
|
+
*/
|
|
1246
|
+
function ownCaps(codec) {
|
|
1247
|
+
return codec.halfClose ? ["pipeline", "end"] : ["pipeline"];
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* The capabilities in a peer's `caps` that this end speaks too, or
|
|
1251
|
+
* `undefined` when the peer listed none (it is then treated as an older peer).
|
|
1252
|
+
*/
|
|
1253
|
+
function sharedCaps(own, listed) {
|
|
1254
|
+
if (!Array.isArray(listed)) return void 0;
|
|
1255
|
+
return new Set(own.filter((cap) => listed.includes(cap)));
|
|
1256
|
+
}
|
|
991
1257
|
/** Ceiling on concurrently open virtual ports. Bounds the id table only. */
|
|
992
1258
|
const DEFAULT_MAX_PORTS = 1024;
|
|
993
1259
|
/** Default ceiling on one message reassembled from frames (`maxReassembledBytes`). */
|
|
@@ -1053,6 +1319,8 @@ function multiplexPort(port, options) {
|
|
|
1053
1319
|
let muxClosed = false;
|
|
1054
1320
|
/** This end counts encoded bytes and reassembles frames. */
|
|
1055
1321
|
const exactCodec = codec.encode != null && codec.decode != null;
|
|
1322
|
+
/** What this end offers on a windowed port. */
|
|
1323
|
+
const caps = ownCaps(codec);
|
|
1056
1324
|
const post = (envelope, transfer) => {
|
|
1057
1325
|
if (muxClosed) return;
|
|
1058
1326
|
codecPost(envelope, transfer);
|
|
@@ -1110,13 +1378,39 @@ function multiplexPort(port, options) {
|
|
|
1110
1378
|
port.postMessage(bytes);
|
|
1111
1379
|
} catch {}
|
|
1112
1380
|
};
|
|
1381
|
+
/**
|
|
1382
|
+
* Both directions have ended: free the id as a close would, with no `close`
|
|
1383
|
+
* on the wire (each end frees it on its own once it has seen both ends).
|
|
1384
|
+
* Aborting the close signal is how the port's consumer learns it is gone.
|
|
1385
|
+
*/
|
|
1386
|
+
const freeIfEnded = (id, c) => {
|
|
1387
|
+
const entry = open.get(id);
|
|
1388
|
+
if (!c.endSent || !c.peerEnded || entry?.credit !== c) return;
|
|
1389
|
+
freePartial(entry);
|
|
1390
|
+
open.delete(id);
|
|
1391
|
+
leaks?.unregister(entry.handle);
|
|
1392
|
+
entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port ended in both directions"));
|
|
1393
|
+
};
|
|
1394
|
+
/** A peer that broke the port's protocol: hold none of its traffic, and close. */
|
|
1395
|
+
const failPort = (id, entry, error, reason) => {
|
|
1396
|
+
freePartial(entry);
|
|
1397
|
+
dropOutgoing(id);
|
|
1398
|
+
open.delete(id);
|
|
1399
|
+
releaseWaiters(entry.credit);
|
|
1400
|
+
entry.handle.markClosed(error);
|
|
1401
|
+
post({
|
|
1402
|
+
type: "close",
|
|
1403
|
+
id,
|
|
1404
|
+
reason
|
|
1405
|
+
});
|
|
1406
|
+
};
|
|
1113
1407
|
const releaseWaiters = (c) => {
|
|
1114
1408
|
for (const w of c?.waiters.splice(0) ?? []) w.resolve();
|
|
1115
1409
|
};
|
|
1116
1410
|
const flush = (c) => {
|
|
1117
1411
|
while (c.queue.length) {
|
|
1118
1412
|
const next = c.queue[0];
|
|
1119
|
-
if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
|
|
1413
|
+
if (next.size > 0 && c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
|
|
1120
1414
|
c.queue.shift();
|
|
1121
1415
|
c.queued -= next.size;
|
|
1122
1416
|
c.outstanding += next.size;
|
|
@@ -1154,15 +1448,20 @@ function multiplexPort(port, options) {
|
|
|
1154
1448
|
enforced: false,
|
|
1155
1449
|
opener,
|
|
1156
1450
|
exact,
|
|
1451
|
+
caps: /* @__PURE__ */ new Set(),
|
|
1157
1452
|
outstanding: 0,
|
|
1158
1453
|
queue: [],
|
|
1159
1454
|
queued: 0,
|
|
1160
1455
|
unconsumed: 0,
|
|
1161
1456
|
waiters: [],
|
|
1162
1457
|
toGrant: 0,
|
|
1163
|
-
grantScheduled: false
|
|
1458
|
+
grantScheduled: false,
|
|
1459
|
+
ending: false,
|
|
1460
|
+
endSent: false,
|
|
1461
|
+
peerEnded: false
|
|
1164
1462
|
} : void 0;
|
|
1165
1463
|
const self = newVirtualPort((payload, transfer) => {
|
|
1464
|
+
if (c?.ending) return;
|
|
1166
1465
|
touch(id);
|
|
1167
1466
|
const envelope = {
|
|
1168
1467
|
type: "message",
|
|
@@ -1249,6 +1548,38 @@ function multiplexPort(port, options) {
|
|
|
1249
1548
|
});
|
|
1250
1549
|
})
|
|
1251
1550
|
});
|
|
1551
|
+
setPortEndState(self.port, {
|
|
1552
|
+
end: () => {
|
|
1553
|
+
if (isClosed() || !c.caps.has("end")) return false;
|
|
1554
|
+
if (c.ending) return true;
|
|
1555
|
+
c.ending = true;
|
|
1556
|
+
c.queue.push({
|
|
1557
|
+
size: 0,
|
|
1558
|
+
send: () => sendOnPort(id, () => {
|
|
1559
|
+
if (open.get(id)?.credit !== c) return;
|
|
1560
|
+
post({
|
|
1561
|
+
type: "end",
|
|
1562
|
+
id
|
|
1563
|
+
});
|
|
1564
|
+
c.endSent = true;
|
|
1565
|
+
freeIfEnded(id, c);
|
|
1566
|
+
})
|
|
1567
|
+
});
|
|
1568
|
+
flush(c);
|
|
1569
|
+
return true;
|
|
1570
|
+
},
|
|
1571
|
+
watchPeerEnd: (resolve) => {
|
|
1572
|
+
if (!c.caps.has("end")) return false;
|
|
1573
|
+
if (c.peerEnded) resolve();
|
|
1574
|
+
else c.onPeerEnd = new WeakRef(resolve);
|
|
1575
|
+
return true;
|
|
1576
|
+
}
|
|
1577
|
+
});
|
|
1578
|
+
setPortAgreement(self.port, () => ({
|
|
1579
|
+
window: c.window,
|
|
1580
|
+
caps: c.caps,
|
|
1581
|
+
enforced: c.enforced
|
|
1582
|
+
}));
|
|
1252
1583
|
}
|
|
1253
1584
|
const record = {
|
|
1254
1585
|
deliver: self.deliver,
|
|
@@ -1300,16 +1631,24 @@ function multiplexPort(port, options) {
|
|
|
1300
1631
|
}
|
|
1301
1632
|
const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
|
|
1302
1633
|
const credit = open.get(envelope.id)?.credit;
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1634
|
+
const listed = sharedCaps(caps, envelope.caps);
|
|
1635
|
+
if (credit) {
|
|
1636
|
+
const ack = credit.exact ? {
|
|
1637
|
+
type: "credit",
|
|
1638
|
+
id: envelope.id,
|
|
1639
|
+
bytes: 0,
|
|
1640
|
+
exact: true
|
|
1641
|
+
} : {
|
|
1642
|
+
type: "credit",
|
|
1643
|
+
id: envelope.id,
|
|
1644
|
+
bytes: 0
|
|
1645
|
+
};
|
|
1646
|
+
if (listed) credit.caps = listed;
|
|
1647
|
+
post(listed ? {
|
|
1648
|
+
...ack,
|
|
1649
|
+
caps: [...listed]
|
|
1650
|
+
} : ack);
|
|
1651
|
+
}
|
|
1313
1652
|
let accepted = false;
|
|
1314
1653
|
if (onPort) try {
|
|
1315
1654
|
accepted = onPort(handle.port, envelope.meta) !== false;
|
|
@@ -1337,6 +1676,7 @@ function multiplexPort(port, options) {
|
|
|
1337
1676
|
c.exact = false;
|
|
1338
1677
|
c.outstanding = 0;
|
|
1339
1678
|
}
|
|
1679
|
+
c.caps = sharedCaps(caps, envelope.caps) ?? /* @__PURE__ */ new Set();
|
|
1340
1680
|
post({
|
|
1341
1681
|
type: "credit",
|
|
1342
1682
|
id: envelope.id,
|
|
@@ -1348,6 +1688,10 @@ function multiplexPort(port, options) {
|
|
|
1348
1688
|
flush(c);
|
|
1349
1689
|
return;
|
|
1350
1690
|
}
|
|
1691
|
+
if ((envelope.type === "message" || envelope.type === "frame") && entry?.credit?.peerEnded) {
|
|
1692
|
+
failPort(envelope.id, entry, /* @__PURE__ */ new Error("webrun-rpc: the peer sent on a port after its end"), "protocol");
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1351
1695
|
if (envelope.type === "message") {
|
|
1352
1696
|
const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
|
|
1353
1697
|
if (!entry) return;
|
|
@@ -1372,16 +1716,7 @@ function multiplexPort(port, options) {
|
|
|
1372
1716
|
const charged = partial.room == null ? Math.max(0, partial.bytes + size - c.window) : 0;
|
|
1373
1717
|
const overrun = partial.room != null && partial.bytes + size > partial.room ? `what the window left (${partial.room} of ${c.window} bytes, ${partial.owed} still owed)` : reassembling - partial.charged + charged > maxReassembledBytes ? `maxReassembledBytes (${maxReassembledBytes} bytes past the window, across all ports)` : void 0;
|
|
1374
1718
|
if (overrun) {
|
|
1375
|
-
|
|
1376
|
-
dropOutgoing(envelope.id);
|
|
1377
|
-
open.delete(envelope.id);
|
|
1378
|
-
releaseWaiters(c);
|
|
1379
|
-
entry.handle.markClosed(/* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`));
|
|
1380
|
-
post({
|
|
1381
|
-
type: "close",
|
|
1382
|
-
id: envelope.id,
|
|
1383
|
-
reason: "max-reassembled-bytes"
|
|
1384
|
-
});
|
|
1719
|
+
failPort(envelope.id, entry, /* @__PURE__ */ new RangeError(`webrun-rpc: a framed message passed ${overrun}`), "max-reassembled-bytes");
|
|
1385
1720
|
return;
|
|
1386
1721
|
}
|
|
1387
1722
|
partial.bytes += size;
|
|
@@ -1401,6 +1736,19 @@ function multiplexPort(port, options) {
|
|
|
1401
1736
|
entry.handle.deliver(inner.payload, whole.byteLength);
|
|
1402
1737
|
return;
|
|
1403
1738
|
}
|
|
1739
|
+
if (envelope.type === "end") {
|
|
1740
|
+
if (!entry) return;
|
|
1741
|
+
const c = entry.credit;
|
|
1742
|
+
if (!c?.caps.has("end")) {
|
|
1743
|
+
failPort(envelope.id, entry, /* @__PURE__ */ new Error("webrun-rpc: the peer ended a port where end was not agreed"), "protocol");
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
touch(envelope.id);
|
|
1747
|
+
c.peerEnded = true;
|
|
1748
|
+
c.onPeerEnd?.deref()?.();
|
|
1749
|
+
freeIfEnded(envelope.id, c);
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1404
1752
|
dropOutgoing(envelope.id);
|
|
1405
1753
|
if (!entry) return;
|
|
1406
1754
|
freePartial(entry);
|
|
@@ -1482,12 +1830,14 @@ function multiplexPort(port, options) {
|
|
|
1482
1830
|
id,
|
|
1483
1831
|
meta,
|
|
1484
1832
|
window,
|
|
1485
|
-
exact: true
|
|
1833
|
+
exact: true,
|
|
1834
|
+
caps: [...caps]
|
|
1486
1835
|
} : {
|
|
1487
1836
|
type: "open",
|
|
1488
1837
|
id,
|
|
1489
1838
|
meta,
|
|
1490
|
-
window
|
|
1839
|
+
window,
|
|
1840
|
+
caps: [...caps]
|
|
1491
1841
|
} : {
|
|
1492
1842
|
type: "open",
|
|
1493
1843
|
id,
|
|
@@ -1710,7 +2060,7 @@ function isEnvelope(value) {
|
|
|
1710
2060
|
const candidate = value;
|
|
1711
2061
|
if (typeof candidate.id !== "number") return false;
|
|
1712
2062
|
if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
|
|
1713
|
-
return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong";
|
|
2063
|
+
return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong" || candidate.type === "end";
|
|
1714
2064
|
}
|
|
1715
2065
|
/**
|
|
1716
2066
|
* For ports whose messages are structured values — a real `MessagePort`, a
|
|
@@ -1722,6 +2072,7 @@ function isEnvelope(value) {
|
|
|
1722
2072
|
* not send anything a byte codec could not also carry.
|
|
1723
2073
|
*/
|
|
1724
2074
|
const structuredCodec = {
|
|
2075
|
+
halfClose: true,
|
|
1725
2076
|
post(port, envelope, transfer) {
|
|
1726
2077
|
if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
|
|
1727
2078
|
else port.postMessage(envelope);
|