@volter/tabnode 0.5.11 → 0.5.13

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.
@@ -17621,17 +17621,10 @@ var implementation$1 = function bind2(that) {
17621
17621
  var implementation = implementation$1;
17622
17622
  var functionBind = Function.prototype.bind || implementation;
17623
17623
  var functionCall = Function.prototype.call;
17624
- var functionApply;
17625
- var hasRequiredFunctionApply;
17626
- function requireFunctionApply() {
17627
- if (hasRequiredFunctionApply) return functionApply;
17628
- hasRequiredFunctionApply = 1;
17629
- functionApply = Function.prototype.apply;
17630
- return functionApply;
17631
- }
17624
+ var functionApply = Function.prototype.apply;
17632
17625
  var reflectApply = typeof Reflect !== "undefined" && Reflect && Reflect.apply;
17633
17626
  var bind$2 = functionBind;
17634
- var $apply$1 = requireFunctionApply();
17627
+ var $apply$1 = functionApply;
17635
17628
  var $call$2 = functionCall;
17636
17629
  var $reflectApply = reflectApply;
17637
17630
  var actualApply = $reflectApply || bind$2.call($call$2, $apply$1);
@@ -17751,7 +17744,7 @@ var hasSymbols = requireHasSymbols()();
17751
17744
  var getProto = requireGetProto();
17752
17745
  var $ObjectGPO = requireObject_getPrototypeOf();
17753
17746
  var $ReflectGPO = requireReflect_getPrototypeOf();
17754
- var $apply = requireFunctionApply();
17747
+ var $apply = functionApply;
17755
17748
  var $call = functionCall;
17756
17749
  var needsEval = {};
17757
17750
  var TypedArray = typeof Uint8Array === "undefined" || !getProto ? undefined$1 : getProto(Uint8Array);
@@ -18392,7 +18385,7 @@ function requireApplyBind() {
18392
18385
  if (hasRequiredApplyBind) return applyBind;
18393
18386
  hasRequiredApplyBind = 1;
18394
18387
  var bind3 = functionBind;
18395
- var $apply2 = requireFunctionApply();
18388
+ var $apply2 = functionApply;
18396
18389
  var actualApply$1 = actualApply;
18397
18390
  applyBind = function applyBind2() {
18398
18391
  return actualApply$1(bind3, $apply2, arguments);
package/dist/index.d.ts CHANGED
@@ -44,6 +44,7 @@ import { Runtime, RuntimeOptions } from './runtime';
44
44
  import { PackageManager } from './npm';
45
45
  import { ServerBridge } from './server-bridge';
46
46
  import { type Socket as NetSocket } from './node-lib/net-module';
47
+ import { type NativeStreamHandleView } from './native-stream-owner';
47
48
  import { runPid, processByPid } from './process-tokens';
48
49
  export { runPid, processByPid };
49
50
  export { createProcessRegistryScope, installProcessRegistry, ownerProcessRegistryScope, ownerProcessTable } from './process-tokens';
@@ -52,7 +53,7 @@ export type { NodeProcessLaunch, NodeProcessHost } from './node-process-host';
52
53
  export type { ProcessIdentity, ProcessRegistry, ProcessRegistryScope, InitialProcessRegistration } from './process-registry';
53
54
  export { NativeStreamScope } from './native-stream-owner';
54
55
  export { installNativeStreamTransport, nativeStreamDescriptor } from './native-stream-binding';
55
- export type { NativeStreamDescriptor, NativeStreamLimits, NativeStreamEvent, NativeStreamOperation, NativeStreamReply, NativeStreamTransport } from './native-stream-owner';
56
+ export type { NativeStreamDescriptor, NativeStreamLimits, NativeStreamEvent, NativeStreamOperation, NativeStreamReply, NativeStreamTransport, NativeStreamHandleView } from './native-stream-owner';
56
57
  export interface RunResult {
57
58
  stdout: string;
58
59
  stderr: string;
@@ -116,6 +117,8 @@ export declare function createContainer(options?: ContainerOptions): {
116
117
  processPorts: (token: string) => number[];
117
118
  /** The pid of the process listening on a port of this engine, where a guest process is: `/proc`'s socket owner. */
118
119
  portPid: (port: number) => number | undefined;
120
+ /** The stream handles the processes of other workers hold through this engine: `/proc/<pid>/fd`. */
121
+ streamHandles: () => NativeStreamHandleView[];
119
122
  /** Deliver a signal to the named run as another process's `kill(pid)` does; false when the run is gone. */
120
123
  signalProcess: (token: string, signal: string) => boolean;
121
124
  stopProcess: (token: string) => boolean;
package/dist/index.mjs CHANGED
@@ -35728,7 +35728,7 @@ class WorkerRuntime {
35728
35728
  this.worker = new Worker(
35729
35729
  new URL(
35730
35730
  /* @vite-ignore */
35731
- "/assets/runtime-worker-C5pP4aT7.js",
35731
+ "/assets/runtime-worker-CQv8H3sj.js",
35732
35732
  import.meta.url
35733
35733
  ),
35734
35734
  { type: "module" }
@@ -37278,13 +37278,14 @@ function __requestOverLoopback(port, method, url2, headers, body) {
37278
37278
  if (body && body.length > 0) socket.write(body);
37279
37279
  });
37280
37280
  }
37281
- function __streamOverLoopback(port, method, url2, headers, body, onStart, onChunk, onEnd) {
37281
+ function __streamOverLoopback(port, method, url2, headers, body, onStart, onChunk, onEnd, flow) {
37282
37282
  if (!__listening(port)) {
37283
37283
  onStart(503, "Service Unavailable", { "Content-Type": "text/plain" });
37284
37284
  onEnd();
37285
37285
  return Promise.resolve();
37286
37286
  }
37287
37287
  return new Promise((resolve2) => {
37288
+ var _a2;
37288
37289
  const collected = emptyCollected();
37289
37290
  let started2 = false;
37290
37291
  let ended = false;
@@ -37304,6 +37305,18 @@ function __streamOverLoopback(port, method, url2, headers, body, onStart, onChun
37304
37305
  (chunk) => onChunk(chunk)
37305
37306
  );
37306
37307
  const socket = netModule.connect({ port, host: "127.0.0.1" });
37308
+ (_a2 = flow == null ? void 0 : flow.control) == null ? void 0 : _a2.call(flow, () => socket.pause(), () => socket.resume());
37309
+ if (flow == null ? void 0 : flow.signal) {
37310
+ if (flow.signal.aborted) {
37311
+ socket.destroy();
37312
+ finish();
37313
+ return;
37314
+ }
37315
+ flow.signal.addEventListener("abort", () => {
37316
+ socket.destroy();
37317
+ finish();
37318
+ }, { once: true });
37319
+ }
37307
37320
  socket.on("data", (chunk) => {
37308
37321
  if (ended) return;
37309
37322
  const read = parser.execute(chunk);
@@ -37380,6 +37393,9 @@ function __upgradeOverLoopback(port, method, url2, headers) {
37380
37393
  });
37381
37394
  }
37382
37395
  const _encoder = new TextEncoder();
37396
+ const FLOW_MAX_REQUEST_BYTES = 64 * 1024 * 1024;
37397
+ const FLOW_MAX_CHUNK_BYTES = 65536;
37398
+ const FLOW_QUEUED_CHUNKS = 4;
37383
37399
  function ownedBytes(view) {
37384
37400
  const copy2 = new Uint8Array(view.byteLength);
37385
37401
  copy2.set(new Uint8Array(view.buffer, view.byteOffset, view.byteLength));
@@ -37649,6 +37665,8 @@ const _ServerBridge = class _ServerBridge extends EventEmitter {
37649
37665
  /** The server the service worker answers at the origin's root, the preview's; null for none. */
37650
37666
  __publicField(this, "primaryPort", null);
37651
37667
  __publicField(this, "ownDocuments");
37668
+ /** Flow-controlled requests in flight: a pull is one chunk's credit, a cancel ends the upstream. */
37669
+ __publicField(this, "controlledStreams", /* @__PURE__ */ new Map());
37652
37670
  this.options = options;
37653
37671
  if (typeof location !== "undefined") {
37654
37672
  this.baseUrl = options.baseUrl || `${location.protocol}//${location.host}`;
@@ -37819,6 +37837,7 @@ const _ServerBridge = class _ServerBridge extends EventEmitter {
37819
37837
  await controllerReady;
37820
37838
  const reinit = () => {
37821
37839
  if (navigator.serviceWorker.controller) {
37840
+ for (const stream of [...this.controlledStreams.values()]) stream.cancel();
37822
37841
  this.messageChannel = new MessageChannel();
37823
37842
  this.messageChannel.port1.onmessage = this.handleServiceWorkerMessage.bind(this);
37824
37843
  navigator.serviceWorker.controller.postMessage(
@@ -37850,6 +37869,17 @@ const _ServerBridge = class _ServerBridge extends EventEmitter {
37850
37869
  var _a2, _b2, _c2;
37851
37870
  const { type: type2, id, data: data2 } = event.data;
37852
37871
  _ServerBridge.DEBUG && console.log("[ServerBridge] SW message:", type2, id, data2 == null ? void 0 : data2.url);
37872
+ if (type2 === "stream-pull" || type2 === "stream-cancel") {
37873
+ const stream = this.controlledStreams.get(id);
37874
+ if (type2 === "stream-pull") stream == null ? void 0 : stream.pull();
37875
+ else stream == null ? void 0 : stream.cancel();
37876
+ return;
37877
+ }
37878
+ if (type2 === "request" && (data2 == null ? void 0 : data2.flowControl) === 1) {
37879
+ const { port, method, url: url2, headers, body } = data2;
37880
+ await this.controlledStream(id, port, method, url2, headers, body);
37881
+ return;
37882
+ }
37853
37883
  if (type2 === "request") {
37854
37884
  const { port, method, url: url2, headers, body, streaming } = data2;
37855
37885
  _ServerBridge.DEBUG && console.log("[ServerBridge] Handling request:", port, method, url2, "streaming:", streaming);
@@ -37898,7 +37928,7 @@ const _ServerBridge = class _ServerBridge extends EventEmitter {
37898
37928
  * it; a server the host registered answers through its own streaming
37899
37929
  * method where it has one, else its buffered answer is delivered whole.
37900
37930
  */
37901
- async handleStreamingRequest(port, method, url2, headers, body, callbacks) {
37931
+ async handleStreamingRequest(port, method, url2, headers, body, callbacks, flow) {
37902
37932
  const virtualServer = this.servers.get(port);
37903
37933
  if (!virtualServer) return false;
37904
37934
  const bodyBuffer = body ? Buffer$9.from(new Uint8Array(body)) : void 0;
@@ -37913,7 +37943,8 @@ const _ServerBridge = class _ServerBridge extends EventEmitter {
37913
37943
  bodyBuffer,
37914
37944
  (statusCode, statusMessage, respHeaders) => callbacks.start(statusCode, statusMessage, respHeaders),
37915
37945
  (chunk) => callbacks.chunk(ownedBytes(chunk)),
37916
- () => callbacks.end()
37946
+ () => callbacks.end(),
37947
+ flow
37917
37948
  );
37918
37949
  return true;
37919
37950
  }
@@ -38043,7 +38074,91 @@ const _ServerBridge = class _ServerBridge extends EventEmitter {
38043
38074
  */
38044
38075
  notifyServiceWorker(type2, data2) {
38045
38076
  if (this.serviceWorkerReady && this.messageChannel) {
38046
- this.messageChannel.port1.postMessage({ type: type2, data: data2 });
38077
+ const flow = type2 === "server-registered" ? { flowControl: 1, maxRequestBytes: FLOW_MAX_REQUEST_BYTES, maxChunkBytes: FLOW_MAX_CHUNK_BYTES } : {};
38078
+ this.messageChannel.port1.postMessage({ type: type2, data: { ...data2, ...flow } });
38079
+ }
38080
+ }
38081
+ /**
38082
+ * A request the worker sent under flow control: the response starts with
38083
+ * its head, then goes one chunk (at most FLOW_MAX_CHUNK_BYTES) for each
38084
+ * `stream-pull`, and ends with `stream-end` once the server finished and
38085
+ * every chunk went out. The upstream connection is paused while chunks wait
38086
+ * for credit, so a slow reader holds the server back; `stream-cancel` ends it.
38087
+ */
38088
+ async controlledStream(id, port, method, url2, headers, body) {
38089
+ const channel = this.messageChannel;
38090
+ const post = (type2, data2) => {
38091
+ channel == null ? void 0 : channel.port1.postMessage({ type: type2, id, ...data2 === void 0 ? {} : { data: data2 } });
38092
+ };
38093
+ const queue = [];
38094
+ const abort = new AbortController();
38095
+ let credits = 0;
38096
+ let upstreamEnded = false;
38097
+ let finished = false;
38098
+ let paused = false;
38099
+ let pause = () => void 0;
38100
+ let resume = () => void 0;
38101
+ const finish = () => {
38102
+ finished = true;
38103
+ this.controlledStreams.delete(id);
38104
+ };
38105
+ const flush = () => {
38106
+ if (finished) return;
38107
+ while (credits > 0 && queue.length > 0) {
38108
+ credits -= 1;
38109
+ post("stream-chunk", { chunkBase64: uint8ToBase64(queue.shift()) });
38110
+ }
38111
+ if (upstreamEnded && queue.length === 0) {
38112
+ post("stream-end");
38113
+ finish();
38114
+ return;
38115
+ }
38116
+ if (queue.length >= FLOW_QUEUED_CHUNKS && !paused) {
38117
+ paused = true;
38118
+ pause();
38119
+ } else if (queue.length < FLOW_QUEUED_CHUNKS && paused) {
38120
+ paused = false;
38121
+ resume();
38122
+ }
38123
+ };
38124
+ this.controlledStreams.set(id, {
38125
+ pull: () => {
38126
+ credits += 1;
38127
+ flush();
38128
+ },
38129
+ cancel: () => {
38130
+ if (finished) return;
38131
+ finish();
38132
+ queue.length = 0;
38133
+ abort.abort();
38134
+ }
38135
+ });
38136
+ try {
38137
+ const answered = await this.handleStreamingRequest(port, method, url2, headers, body, {
38138
+ start: (statusCode, statusMessage, respHeaders) => post("stream-start", { statusCode, statusMessage, headers: respHeaders }),
38139
+ chunk: (chunk) => {
38140
+ if (finished) return;
38141
+ for (let at2 = 0; at2 < chunk.byteLength; at2 += FLOW_MAX_CHUNK_BYTES) queue.push(chunk.slice(at2, at2 + FLOW_MAX_CHUNK_BYTES));
38142
+ flush();
38143
+ },
38144
+ end: () => {
38145
+ upstreamEnded = true;
38146
+ flush();
38147
+ }
38148
+ }, { signal: abort.signal, control: (p, r) => {
38149
+ pause = p;
38150
+ resume = r;
38151
+ } });
38152
+ if (!answered) {
38153
+ post("stream-start", { statusCode: 503, statusMessage: "Service Unavailable", headers: {} });
38154
+ post("stream-end");
38155
+ finish();
38156
+ }
38157
+ } catch (error) {
38158
+ if (!finished) {
38159
+ post("stream-error", { message: error instanceof Error ? error.message : String(error) });
38160
+ finish();
38161
+ }
38047
38162
  }
38048
38163
  }
38049
38164
  /**
@@ -38098,6 +38213,7 @@ function resetServerBridge() {
38098
38213
  globalBridge = null;
38099
38214
  }
38100
38215
  const scopes = /* @__PURE__ */ new WeakMap();
38216
+ const liveScopes = /* @__PURE__ */ new Set();
38101
38217
  class NativeStreamScope {
38102
38218
  /**
38103
38219
  * `ownerPid` names the process whose worker this scope serves: a listener
@@ -38116,6 +38232,19 @@ class NativeStreamScope {
38116
38232
  this.ownerPid = ownerPid;
38117
38233
  if ([limits.maxHandles, limits.maxReadChunkBytes, limits.maxQueuedBytes, limits.maxQueuedHandles, limits.maxPendingWriteBytes].some((value) => !Number.isSafeInteger(value) || value < 1) || limits.maxReadChunkBytes > 2147483647 || limits.maxReadChunkBytes > limits.maxQueuedBytes || limits.maxReadChunkBytes > limits.maxPendingWriteBytes) throw new Error("Invalid native stream resource limits.");
38118
38234
  this.limits = Object.freeze({ ...limits });
38235
+ liveScopes.add(this);
38236
+ }
38237
+ /** This scope's open handles, with the process it serves. */
38238
+ handles() {
38239
+ var _a2;
38240
+ if (this.disposed) return [];
38241
+ const pid = (_a2 = this.ownerPid) == null ? void 0 : _a2.call(this);
38242
+ return [...this.entries.values()].map(({ handle, descriptor }) => ({
38243
+ ...pid !== void 0 ? { pid } : {},
38244
+ id: descriptor.id,
38245
+ kind: descriptor.kind,
38246
+ listening: !!handle.listening
38247
+ }));
38119
38248
  }
38120
38249
  entry(id) {
38121
38250
  return this.disposed ? void 0 : this.entries.get(id);
@@ -38387,6 +38516,7 @@ class NativeStreamScope {
38387
38516
  dispose() {
38388
38517
  if (this.disposed) return;
38389
38518
  this.disposed = true;
38519
+ liveScopes.delete(this);
38390
38520
  const failures = [];
38391
38521
  for (const id of [...this.entries.keys()]) {
38392
38522
  try {
@@ -38402,6 +38532,9 @@ function nativeStreamOwnerPid(handle) {
38402
38532
  var _a2, _b2;
38403
38533
  return (_b2 = (_a2 = scopes.get(handle)) == null ? void 0 : _a2.ownerPid) == null ? void 0 : _b2.call(_a2);
38404
38534
  }
38535
+ function nativeStreamHandles() {
38536
+ return [...liveScopes].flatMap((scope) => scope.handles());
38537
+ }
38405
38538
  let __nextRunName = 1;
38406
38539
  function createContainer(options) {
38407
38540
  const vfs2 = options && options.vfs || new VirtualFS();
@@ -38464,6 +38597,7 @@ function createContainer(options) {
38464
38597
  if (!listener) return void 0;
38465
38598
  return ((_a2 = runPid(ownerOf(listener))) == null ? void 0 : _a2.pid) ?? nativeStreamOwnerPid(listener);
38466
38599
  },
38600
+ streamHandles: () => nativeStreamHandles(),
38467
38601
  /**
38468
38602
  * Deliver a signal to the named run as another process's `kill(pid)` does:
38469
38603
  * the guest's listeners for it run, else its default action ends the run.
@@ -88,6 +88,13 @@ export interface NativeStreamTransport {
88
88
  write(id: number, bytes: Uint8Array, handle?: number): Promise<number>;
89
89
  onEvent(listener: (event: NativeStreamEvent) => void): () => void;
90
90
  }
91
+ /** One open stream handle, as a process's descriptor table shows it. */
92
+ export interface NativeStreamHandleView {
93
+ pid?: number;
94
+ id: number;
95
+ kind: 'tcp' | 'pipe';
96
+ listening: boolean;
97
+ }
91
98
  /**
92
99
  * One capability namespace per worker. Only the trusted host can inherit a
93
100
  * descriptor from another scope; wire operations can name this scope's IDs only.
@@ -108,6 +115,8 @@ export declare class NativeStreamScope {
108
115
  * it opens is that process's socket, as the container's /proc shows it.
109
116
  */
110
117
  constructor(limits: NativeStreamLimits, emit: (event: NativeStreamEvent) => void, ownerPid?: (() => number | undefined) | undefined);
118
+ /** This scope's open handles, with the process it serves. */
119
+ handles(): NativeStreamHandleView[];
111
120
  private entry;
112
121
  private publish;
113
122
  private claim;
@@ -126,3 +135,5 @@ export declare class NativeStreamScope {
126
135
  }
127
136
  /** The process a handle opened for another worker's process belongs to, where a scope holds it. */
128
137
  export declare function nativeStreamOwnerPid(handle: object): number | undefined;
138
+ /** Every open native stream handle in this worker, by the process each scope serves. */
139
+ export declare function nativeStreamHandles(): NativeStreamHandleView[];
@@ -22,7 +22,17 @@ export declare function __requestOverLoopback(port: number, method: string, url:
22
22
  * the server writes, then the end. This is what the service worker streams
23
23
  * to the page.
24
24
  */
25
- export declare function __streamOverLoopback(port: number, method: string, url: string, headers: Record<string, string>, body: Buffer | undefined, onStart: (statusCode: number, statusMessage: string, headers: Record<string, string>) => void, onChunk: (chunk: Uint8Array) => void, onEnd: () => void): Promise<void>;
25
+ /**
26
+ * How a caller paces a streamed answer: `signal` ends the connection (the
27
+ * reader went away), and `control` is handed the connection's pause and
28
+ * resume, so a reader that holds no credit holds the server back instead of
29
+ * a queue growing without bound.
30
+ */
31
+ export interface LoopbackStreamFlow {
32
+ signal?: AbortSignal;
33
+ control?(pause: () => void, resume: () => void): void;
34
+ }
35
+ export declare function __streamOverLoopback(port: number, method: string, url: string, headers: Record<string, string>, body: Buffer | undefined, onStart: (statusCode: number, statusMessage: string, headers: Record<string, string>) => void, onChunk: (chunk: Uint8Array) => void, onEnd: () => void, flow?: LoopbackStreamFlow): Promise<void>;
26
36
  /** What an upgrade answered with: the 101 and the socket the page now owns. */
27
37
  export interface UpgradeResult {
28
38
  statusCode: number;
@@ -2,7 +2,7 @@
2
2
  * Server Bridge
3
3
  * Connects Service Worker requests to virtual HTTP servers
4
4
  */
5
- import { type ResponseData } from './node-lib/http-bridge';
5
+ import { type ResponseData, type LoopbackStreamFlow } from './node-lib/http-bridge';
6
6
  import { EventEmitter } from './node-lib/events-module';
7
7
  /**
8
8
  * Interface for virtual servers that can be registered with the bridge
@@ -125,12 +125,22 @@ export declare class ServerBridge extends EventEmitter {
125
125
  start(statusCode: number, statusMessage: string, headers: Record<string, string>): void;
126
126
  chunk(chunk: Uint8Array): void;
127
127
  end(): void;
128
- }): Promise<boolean>;
128
+ }, flow?: LoopbackStreamFlow): Promise<boolean>;
129
129
  private streamToServiceWorker;
130
130
  /**
131
131
  * Send message to Service Worker
132
132
  */
133
133
  private notifyServiceWorker;
134
+ /** Flow-controlled requests in flight: a pull is one chunk's credit, a cancel ends the upstream. */
135
+ private readonly controlledStreams;
136
+ /**
137
+ * A request the worker sent under flow control: the response starts with
138
+ * its head, then goes one chunk (at most FLOW_MAX_CHUNK_BYTES) for each
139
+ * `stream-pull`, and ends with `stream-end` once the server finished and
140
+ * every chunk went out. The upstream connection is paused while chunks wait
141
+ * for credit, so a slow reader holds the server back; `stream-cancel` ends it.
142
+ */
143
+ private controlledStream;
134
144
  /**
135
145
  * Create a mock request handler for testing without Service Worker
136
146
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/tabnode",
3
- "version": "0.5.11",
3
+ "version": "0.5.13",
4
4
  "description": "Node's own library, in a browser tab.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -56,7 +56,7 @@ import { runCommand, registerRunStreams, releaseRunStreams, sendStdin } from './
56
56
  import { Server as NetServer, __releaseOwnedHandles, type Socket as NetSocket } from './node-lib/net-module';
57
57
  import { __adoptHandle, ownerOf, type OwnedHandle } from './node-lib/binding/handles';
58
58
  import { listenerOnPort } from './node-lib/binding/tcp_wrap';
59
- import { nativeStreamOwnerPid } from './native-stream-owner';
59
+ import { nativeStreamHandles, nativeStreamOwnerPid, type NativeStreamHandleView } from './native-stream-owner';
60
60
 
61
61
  import { __currentProcessToken, __runFor, __signalOwnedProcess, __takeTermination, __stopOwnedProcess, runPid, processByPid } from './process-tokens';
62
62
  export { runPid, processByPid };
@@ -66,7 +66,7 @@ export type { NodeProcessLaunch, NodeProcessHost } from './node-process-host';
66
66
  export type { ProcessIdentity, ProcessRegistry, ProcessRegistryScope, InitialProcessRegistration } from './process-registry';
67
67
  export { NativeStreamScope } from './native-stream-owner';
68
68
  export { installNativeStreamTransport, nativeStreamDescriptor } from './native-stream-binding';
69
- export type { NativeStreamDescriptor, NativeStreamLimits, NativeStreamEvent, NativeStreamOperation, NativeStreamReply, NativeStreamTransport } from './native-stream-owner';
69
+ export type { NativeStreamDescriptor, NativeStreamLimits, NativeStreamEvent, NativeStreamOperation, NativeStreamReply, NativeStreamTransport, NativeStreamHandleView } from './native-stream-owner';
70
70
 
71
71
  export interface RunResult {
72
72
  stdout: string;
@@ -129,6 +129,8 @@ export function createContainer(options?: ContainerOptions): {
129
129
  processPorts: (token: string) => number[];
130
130
  /** The pid of the process listening on a port of this engine, where a guest process is: `/proc`'s socket owner. */
131
131
  portPid: (port: number) => number | undefined;
132
+ /** The stream handles the processes of other workers hold through this engine: `/proc/<pid>/fd`. */
133
+ streamHandles: () => NativeStreamHandleView[];
132
134
  /** Deliver a signal to the named run as another process's `kill(pid)` does; false when the run is gone. */
133
135
  signalProcess: (token: string, signal: string) => boolean;
134
136
  stopProcess: (token: string) => boolean;
@@ -224,6 +226,7 @@ export function createContainer(options?: ContainerOptions): {
224
226
  // a guest's own listener, or one this engine holds for another worker's process
225
227
  return runPid(ownerOf(listener as unknown as OwnedHandle))?.pid ?? nativeStreamOwnerPid(listener);
226
228
  },
229
+ streamHandles: (): NativeStreamHandleView[] => nativeStreamHandles(),
227
230
  /**
228
231
  * Deliver a signal to the named run as another process's `kill(pid)` does:
229
232
  * the guest's listeners for it run, else its default action ends the run.
@@ -41,6 +41,12 @@ export interface NativeStreamTransport {
41
41
  type NativeHandle = TCP | Pipe;
42
42
  interface Entry { handle: NativeHandle; descriptor: NativeStreamDescriptor; buffer?: Uint8Array }
43
43
  const scopes = new WeakMap<LibuvStreamWrap, NativeStreamScope>();
44
+ // Every scope not yet disposed: what the container's /proc lists as each
45
+ // process's descriptors.
46
+ const liveScopes = new Set<NativeStreamScope>();
47
+
48
+ /** One open stream handle, as a process's descriptor table shows it. */
49
+ export interface NativeStreamHandleView { pid?: number; id: number; kind: 'tcp' | 'pipe'; listening: boolean }
44
50
 
45
51
  /**
46
52
  * One capability namespace per worker. Only the trusted host can inherit a
@@ -66,6 +72,16 @@ export class NativeStreamScope {
66
72
  || limits.maxReadChunkBytes > limits.maxQueuedBytes
67
73
  || limits.maxReadChunkBytes > limits.maxPendingWriteBytes) throw new Error('Invalid native stream resource limits.');
68
74
  this.limits = Object.freeze({ ...limits });
75
+ liveScopes.add(this);
76
+ }
77
+
78
+ /** This scope's open handles, with the process it serves. */
79
+ handles(): NativeStreamHandleView[] {
80
+ if (this.disposed) return [];
81
+ const pid = this.ownerPid?.();
82
+ return [...this.entries.values()].map(({ handle, descriptor }) => ({
83
+ ...(pid !== undefined ? { pid } : {}), id: descriptor.id, kind: descriptor.kind, listening: !!handle.listening,
84
+ }));
69
85
  }
70
86
 
71
87
  private entry(id: number): Entry | undefined {
@@ -318,6 +334,7 @@ export class NativeStreamScope {
318
334
  dispose(): void {
319
335
  if (this.disposed) return;
320
336
  this.disposed = true;
337
+ liveScopes.delete(this);
321
338
  const failures: unknown[] = [];
322
339
  for (const id of [...this.entries.keys()]) {
323
340
  try { this.close(id); } catch (cause) { failures.push(cause); }
@@ -330,3 +347,8 @@ export class NativeStreamScope {
330
347
  export function nativeStreamOwnerPid(handle: object): number | undefined {
331
348
  return scopes.get(handle as NativeHandle)?.ownerPid?.();
332
349
  }
350
+
351
+ /** Every open native stream handle in this worker, by the process each scope serves. */
352
+ export function nativeStreamHandles(): NativeStreamHandleView[] {
353
+ return [...liveScopes].flatMap(scope => scope.handles());
354
+ }
@@ -195,11 +195,23 @@ export function __requestOverLoopback(
195
195
  * the server writes, then the end. This is what the service worker streams
196
196
  * to the page.
197
197
  */
198
+ /**
199
+ * How a caller paces a streamed answer: `signal` ends the connection (the
200
+ * reader went away), and `control` is handed the connection's pause and
201
+ * resume, so a reader that holds no credit holds the server back instead of
202
+ * a queue growing without bound.
203
+ */
204
+ export interface LoopbackStreamFlow {
205
+ signal?: AbortSignal;
206
+ control?(pause: () => void, resume: () => void): void;
207
+ }
208
+
198
209
  export function __streamOverLoopback(
199
210
  port: number, method: string, url: string, headers: Record<string, string>, body: Buffer | undefined,
200
211
  onStart: (statusCode: number, statusMessage: string, headers: Record<string, string>) => void,
201
212
  onChunk: (chunk: Uint8Array) => void,
202
213
  onEnd: () => void,
214
+ flow?: LoopbackStreamFlow,
203
215
  ): Promise<void> {
204
216
  if (!__listening(port)) {
205
217
  onStart(503, 'Service Unavailable', { 'Content-Type': 'text/plain' });
@@ -217,6 +229,11 @@ export function __streamOverLoopback(
217
229
  (chunk) => onChunk(chunk),
218
230
  );
219
231
  const socket = netModule.connect({ port, host: '127.0.0.1' }) as unknown as Socket;
232
+ flow?.control?.(() => socket.pause(), () => socket.resume());
233
+ if (flow?.signal) {
234
+ if (flow.signal.aborted) { socket.destroy(); finish(); return; }
235
+ flow.signal.addEventListener('abort', () => { socket.destroy(); finish(); }, { once: true });
236
+ }
220
237
  socket.on('data', (chunk: Uint8Array) => {
221
238
  if (ended) return;
222
239
  const read = parser.execute(chunk);
@@ -5,7 +5,7 @@
5
5
 
6
6
  import {
7
7
  __requestOverLoopback, __streamOverLoopback, __upgradeOverLoopback, __listening,
8
- type ResponseData,
8
+ type ResponseData, type LoopbackStreamFlow,
9
9
  } from './node-lib/http-bridge';
10
10
  import { setPortWatchers } from './node-lib/net-module';
11
11
  import { EventEmitter } from './node-lib/events-module';
@@ -15,6 +15,12 @@ import { Buffer } from './node-lib/buffer-module';
15
15
  import { uint8ToBase64 } from './utils/binary-encoding';
16
16
 
17
17
  const _encoder = new TextEncoder();
18
+ // The worker's flow-controlled stream: a request body up to this size, and a
19
+ // response in chunks of exactly the worker's own maximum, one per pull.
20
+ const FLOW_MAX_REQUEST_BYTES = 64 * 1024 * 1024;
21
+ const FLOW_MAX_CHUNK_BYTES = 65536;
22
+ // Chunks that may wait for the reader's credit before the server is paused.
23
+ const FLOW_QUEUED_CHUNKS = 4;
18
24
 
19
25
  /** The bytes this view names, in a buffer of their own. A pooled Buffer is a window on 8 KB. */
20
26
  function ownedBytes(view: Uint8Array): Uint8Array {
@@ -649,6 +655,7 @@ export class ServerBridge extends EventEmitter {
649
655
  // to all clients when a request arrives but mainPort is null.
650
656
  const reinit = () => {
651
657
  if (navigator.serviceWorker.controller) {
658
+ for (const stream of [...this.controlledStreams.values()]) stream.cancel();
652
659
  this.messageChannel = new MessageChannel();
653
660
  this.messageChannel.port1.onmessage = this.handleServiceWorkerMessage.bind(this);
654
661
  navigator.serviceWorker.controller.postMessage(
@@ -684,6 +691,16 @@ export class ServerBridge extends EventEmitter {
684
691
 
685
692
  ServerBridge.DEBUG && console.log('[ServerBridge] SW message:', type, id, data?.url);
686
693
 
694
+ if (type === 'stream-pull' || type === 'stream-cancel') {
695
+ const stream = this.controlledStreams.get(id);
696
+ if (type === 'stream-pull') stream?.pull(); else stream?.cancel();
697
+ return;
698
+ }
699
+ if (type === 'request' && data?.flowControl === 1) {
700
+ const { port, method, url, headers, body } = data;
701
+ await this.controlledStream(id, port, method, url, headers, body);
702
+ return;
703
+ }
687
704
  if (type === 'request') {
688
705
  const { port, method, url, headers, body, streaming } = data;
689
706
 
@@ -752,6 +769,7 @@ export class ServerBridge extends EventEmitter {
752
769
  chunk(chunk: Uint8Array): void;
753
770
  end(): void;
754
771
  },
772
+ flow?: LoopbackStreamFlow,
755
773
  ): Promise<boolean> {
756
774
  const virtualServer = this.servers.get(port);
757
775
  if (!virtualServer) return false;
@@ -764,6 +782,7 @@ export class ServerBridge extends EventEmitter {
764
782
  (statusCode, statusMessage, respHeaders) => callbacks.start(statusCode, statusMessage, respHeaders),
765
783
  (chunk) => callbacks.chunk(ownedBytes(chunk)),
766
784
  () => callbacks.end(),
785
+ flow,
767
786
  );
768
787
  return true;
769
788
  }
@@ -909,7 +928,71 @@ export class ServerBridge extends EventEmitter {
909
928
  */
910
929
  private notifyServiceWorker(type: string, data: unknown): void {
911
930
  if (this.serviceWorkerReady && this.messageChannel) {
912
- this.messageChannel.port1.postMessage({ type, data });
931
+ // Every port this bridge answers speaks the worker's flow-controlled
932
+ // stream: a response is paced by the reader's pulls, one chunk a pull,
933
+ // and a reader that goes away ends the request (virtual HTTP flow
934
+ // control, the worker's side of which is in public/__sw__.js).
935
+ const flow = type === 'server-registered'
936
+ ? { flowControl: 1, maxRequestBytes: FLOW_MAX_REQUEST_BYTES, maxChunkBytes: FLOW_MAX_CHUNK_BYTES } : {};
937
+ this.messageChannel.port1.postMessage({ type, data: { ...(data as object), ...flow } });
938
+ }
939
+ }
940
+
941
+ /** Flow-controlled requests in flight: a pull is one chunk's credit, a cancel ends the upstream. */
942
+ private readonly controlledStreams = new Map<number, { pull(): void; cancel(): void }>();
943
+
944
+ /**
945
+ * A request the worker sent under flow control: the response starts with
946
+ * its head, then goes one chunk (at most FLOW_MAX_CHUNK_BYTES) for each
947
+ * `stream-pull`, and ends with `stream-end` once the server finished and
948
+ * every chunk went out. The upstream connection is paused while chunks wait
949
+ * for credit, so a slow reader holds the server back; `stream-cancel` ends it.
950
+ */
951
+ private async controlledStream(id: number, port: number, method: string, url: string, headers: Record<string, string>, body?: ArrayBuffer): Promise<void> {
952
+ // the stream belongs to the channel it arrived on; a new channel's worker failed it already
953
+ const channel = this.messageChannel;
954
+ const post = (type: string, data?: unknown): void => { channel?.port1.postMessage({ type, id, ...(data === undefined ? {} : { data }) }); };
955
+ const queue: Uint8Array[] = [];
956
+ const abort = new AbortController();
957
+ let credits = 0;
958
+ let upstreamEnded = false;
959
+ let finished = false;
960
+ let paused = false;
961
+ let pause = (): void => undefined;
962
+ let resume = (): void => undefined;
963
+ const finish = (): void => { finished = true; this.controlledStreams.delete(id); };
964
+ const flush = (): void => {
965
+ if (finished) return;
966
+ while (credits > 0 && queue.length > 0) {
967
+ credits -= 1;
968
+ post('stream-chunk', { chunkBase64: uint8ToBase64(queue.shift()!) });
969
+ }
970
+ if (upstreamEnded && queue.length === 0) { post('stream-end'); finish(); return; }
971
+ // a few chunks may wait for credit; more than that, and the server waits
972
+ if (queue.length >= FLOW_QUEUED_CHUNKS && !paused) { paused = true; pause(); }
973
+ else if (queue.length < FLOW_QUEUED_CHUNKS && paused) { paused = false; resume(); }
974
+ };
975
+ this.controlledStreams.set(id, {
976
+ pull: () => { credits += 1; flush(); },
977
+ cancel: () => { if (finished) return; finish(); queue.length = 0; abort.abort(); },
978
+ });
979
+ try {
980
+ const answered = await this.handleStreamingRequest(port, method, url, headers, body, {
981
+ start: (statusCode, statusMessage, respHeaders) => post('stream-start', { statusCode, statusMessage, headers: respHeaders }),
982
+ chunk: (chunk) => {
983
+ if (finished) return;
984
+ for (let at = 0; at < chunk.byteLength; at += FLOW_MAX_CHUNK_BYTES) queue.push(chunk.slice(at, at + FLOW_MAX_CHUNK_BYTES));
985
+ flush();
986
+ },
987
+ end: () => { upstreamEnded = true; flush(); },
988
+ }, { signal: abort.signal, control: (p, r) => { pause = p; resume = r; } });
989
+ if (!answered) {
990
+ post('stream-start', { statusCode: 503, statusMessage: 'Service Unavailable', headers: {} });
991
+ post('stream-end');
992
+ finish();
993
+ }
994
+ } catch (error) {
995
+ if (!finished) { post('stream-error', { message: error instanceof Error ? error.message : String(error) }); finish(); }
913
996
  }
914
997
  }
915
998