@statewalker/webrun-http-browser 0.7.1 → 0.9.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/dist/index.js +620 -70
- package/dist/relay-sw.js +606 -88
- package/dist/relay-worker.js +606 -88
- package/dist/sw/protocol.d.ts +2 -0
- package/dist/sw/protocol.d.ts.map +1 -1
- package/dist/sw/sw-http-adapter.d.ts +2 -0
- package/dist/sw/sw-http-adapter.d.ts.map +1 -1
- package/dist/sw/worker-service.d.ts.map +1 -1
- package/dist/sw-worker.js +606 -68
- package/dist/sw.js +620 -70
- package/dist/worker.js +615 -69
- package/package.json +3 -3
- package/src/link/http-link.ts +42 -6
- package/src/sw/protocol.ts +4 -3
- package/src/sw/sw-http-adapter.ts +7 -0
- package/src/sw/worker-service.ts +14 -1
package/dist/worker.js
CHANGED
|
@@ -1411,7 +1411,7 @@ async function fetchOverDuplex(call, request, options = {}) {
|
|
|
1411
1411
|
else await release?.();
|
|
1412
1412
|
return new Response(null, responseInit);
|
|
1413
1413
|
}
|
|
1414
|
-
return new Response(
|
|
1414
|
+
return new Response(responseBody(respBody, request.signal, release), responseInit);
|
|
1415
1415
|
}
|
|
1416
1416
|
/**
|
|
1417
1417
|
* Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
|
|
@@ -1542,15 +1542,78 @@ function trackingEnd(source) {
|
|
|
1542
1542
|
settle
|
|
1543
1543
|
};
|
|
1544
1544
|
}
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1545
|
+
/**
|
|
1546
|
+
* How long a cancelled body waits for the read in flight before it abandons the call anyway.
|
|
1547
|
+
* The read's answer tells a body that has ended (the call is finished: an upload the handler
|
|
1548
|
+
* reads after answering goes on) from one that has not; a peer that sends nothing more never
|
|
1549
|
+
* answers it. A body whose end comes later than this after the cancel is abandoned too, so a
|
|
1550
|
+
* handler still streaming its response and still reading the upload then loses the upload.
|
|
1551
|
+
*/
|
|
1552
|
+
const CANCEL_GRACE_MS = 1e3;
|
|
1553
|
+
/**
|
|
1554
|
+
* The response body as a stream that the request's signal can end at any time.
|
|
1555
|
+
*
|
|
1556
|
+
* An abort errors the stream with the abort's reason at once — a read waiting
|
|
1557
|
+
* for a peer that sends nothing more rejects then, not when a next chunk
|
|
1558
|
+
* arrives, which may be never — and abandons the call (`release`), so on a
|
|
1559
|
+
* multiplexed transport its port is given back whether or not anyone still
|
|
1560
|
+
* reads the body. The listener is removed once the body ends, however it ends:
|
|
1561
|
+
* read to its end, cancelled, failed or aborted. A body nobody reads or cancels
|
|
1562
|
+
* keeps it until the signal aborts (and keeps the call open until then anyway).
|
|
1563
|
+
* A cancel abandons the call too — unless the read in flight comes back with the
|
|
1564
|
+
* body's end, which finishes it instead (see `cancel`).
|
|
1565
|
+
*/
|
|
1566
|
+
function responseBody(body, signal, release) {
|
|
1567
|
+
const it = body[Symbol.asyncIterator]();
|
|
1568
|
+
let controller;
|
|
1569
|
+
let ended = false;
|
|
1570
|
+
let reading = false;
|
|
1571
|
+
const end = () => {
|
|
1572
|
+
ended = true;
|
|
1573
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1574
|
+
};
|
|
1575
|
+
const onAbort = () => {
|
|
1576
|
+
if (ended) return;
|
|
1577
|
+
end();
|
|
1578
|
+
try {
|
|
1579
|
+
controller.error(abortReason$1(signal));
|
|
1580
|
+
} catch {}
|
|
1581
|
+
release?.();
|
|
1582
|
+
};
|
|
1583
|
+
return new ReadableStream({
|
|
1584
|
+
start(c) {
|
|
1585
|
+
controller = c;
|
|
1586
|
+
if (signal?.aborted) onAbort();
|
|
1587
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
1588
|
+
},
|
|
1589
|
+
async pull(c) {
|
|
1590
|
+
try {
|
|
1591
|
+
reading = true;
|
|
1592
|
+
const { done, value } = await it.next().finally(() => {
|
|
1593
|
+
reading = false;
|
|
1594
|
+
});
|
|
1595
|
+
if (ended) return;
|
|
1596
|
+
if (done) {
|
|
1597
|
+
end();
|
|
1598
|
+
c.close();
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
c.enqueue(value);
|
|
1602
|
+
} catch (err) {
|
|
1603
|
+
if (ended) return;
|
|
1604
|
+
end();
|
|
1605
|
+
c.error(err);
|
|
1606
|
+
}
|
|
1607
|
+
},
|
|
1608
|
+
cancel() {
|
|
1609
|
+
end();
|
|
1610
|
+
const returned = Promise.resolve(it.return?.()).catch(() => {});
|
|
1611
|
+
if (!reading) return;
|
|
1612
|
+
const grace = setTimeout(() => void release?.(), CANCEL_GRACE_MS);
|
|
1613
|
+
grace.unref?.();
|
|
1614
|
+
returned.finally(() => clearTimeout(grace));
|
|
1615
|
+
}
|
|
1616
|
+
});
|
|
1554
1617
|
}
|
|
1555
1618
|
//#endregion
|
|
1556
1619
|
//#region ../webrun-rpc/dist/index.js
|
|
@@ -1675,8 +1738,11 @@ function listenPort(port, handler, { channelName = "", log = () => {} } = {}) {
|
|
|
1675
1738
|
error
|
|
1676
1739
|
});
|
|
1677
1740
|
};
|
|
1678
|
-
|
|
1679
|
-
|
|
1741
|
+
const listener = (event) => {
|
|
1742
|
+
onMessage(event);
|
|
1743
|
+
};
|
|
1744
|
+
port.addEventListener("message", listener);
|
|
1745
|
+
return () => port.removeEventListener("message", listener);
|
|
1680
1746
|
}
|
|
1681
1747
|
/**
|
|
1682
1748
|
* Wraps an async iterable so that an `AbortSignal` firing causes the wrapper
|
|
@@ -2081,6 +2147,33 @@ async function sendChunks(port, channelName, output, { maxMessageSize, log }, si
|
|
|
2081
2147
|
}
|
|
2082
2148
|
}
|
|
2083
2149
|
/**
|
|
2150
|
+
* Runs `fn` on a later task, never a microtask: a producer looping on
|
|
2151
|
+
* `whenPortReady` must let the transport's own events run. `setImmediate`
|
|
2152
|
+
* where it exists; elsewhere a `MessageChannel`, because nested `setTimeout`s
|
|
2153
|
+
* are clamped to 4 ms in browsers.
|
|
2154
|
+
*/
|
|
2155
|
+
const setImmediateFn = globalThis.setImmediate;
|
|
2156
|
+
/** @internal */
|
|
2157
|
+
const nextTask = setImmediateFn ? (fn) => {
|
|
2158
|
+
setImmediateFn(fn);
|
|
2159
|
+
} : (() => {
|
|
2160
|
+
let channel;
|
|
2161
|
+
const queue = [];
|
|
2162
|
+
return (fn) => {
|
|
2163
|
+
if (!channel) {
|
|
2164
|
+
channel = new MessageChannel();
|
|
2165
|
+
channel.port1.onmessage = () => queue.shift()?.();
|
|
2166
|
+
}
|
|
2167
|
+
queue.push(fn);
|
|
2168
|
+
channel.port2.postMessage(0);
|
|
2169
|
+
};
|
|
2170
|
+
})();
|
|
2171
|
+
const states = /* @__PURE__ */ new WeakMap();
|
|
2172
|
+
/** @internal Registered by the multiplexer for each port with a window. */
|
|
2173
|
+
function setPortSendState(port, state) {
|
|
2174
|
+
states.set(port, state);
|
|
2175
|
+
}
|
|
2176
|
+
/**
|
|
2084
2177
|
* One virtual port.
|
|
2085
2178
|
*
|
|
2086
2179
|
* `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
|
|
@@ -2095,6 +2188,14 @@ function newVirtualPort(send, requestClose) {
|
|
|
2095
2188
|
if (closing.signal.aborted) return;
|
|
2096
2189
|
closing.abort(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the port was closed"));
|
|
2097
2190
|
};
|
|
2191
|
+
const close = (reason) => {
|
|
2192
|
+
if (closed) return;
|
|
2193
|
+
closed = true;
|
|
2194
|
+
const notify = requestClose;
|
|
2195
|
+
listeners.clear();
|
|
2196
|
+
notify(reason);
|
|
2197
|
+
abort(reason);
|
|
2198
|
+
};
|
|
2098
2199
|
const port = {
|
|
2099
2200
|
addEventListener(_type, listener) {
|
|
2100
2201
|
listeners.add(listener);
|
|
@@ -2106,24 +2207,28 @@ function newVirtualPort(send, requestClose) {
|
|
|
2106
2207
|
if (closed) return;
|
|
2107
2208
|
send(message, transfer);
|
|
2108
2209
|
},
|
|
2109
|
-
close
|
|
2110
|
-
if (closed) return;
|
|
2111
|
-
closed = true;
|
|
2112
|
-
const notify = requestClose;
|
|
2113
|
-
listeners.clear();
|
|
2114
|
-
notify();
|
|
2115
|
-
abort();
|
|
2116
|
-
}
|
|
2210
|
+
close
|
|
2117
2211
|
};
|
|
2118
2212
|
setPortCloseSignal(port, closing.signal);
|
|
2213
|
+
const dispose = Symbol.dispose;
|
|
2214
|
+
if (dispose) Object.defineProperty(port, dispose, {
|
|
2215
|
+
value: () => close(),
|
|
2216
|
+
configurable: true
|
|
2217
|
+
});
|
|
2119
2218
|
return {
|
|
2120
2219
|
port,
|
|
2121
2220
|
deliver(payload) {
|
|
2122
2221
|
if (closed) return;
|
|
2123
2222
|
const event = new MessageEvent("message", { data: payload });
|
|
2223
|
+
let pending;
|
|
2124
2224
|
for (const listener of [...listeners]) try {
|
|
2125
|
-
listener(event);
|
|
2225
|
+
const result = listener(event);
|
|
2226
|
+
if (result && typeof result.then === "function") {
|
|
2227
|
+
pending ??= [];
|
|
2228
|
+
pending.push(result);
|
|
2229
|
+
}
|
|
2126
2230
|
} catch {}
|
|
2231
|
+
return pending ? Promise.allSettled(pending) : void 0;
|
|
2127
2232
|
},
|
|
2128
2233
|
markClosed(reason) {
|
|
2129
2234
|
closed = true;
|
|
@@ -2132,53 +2237,309 @@ function newVirtualPort(send, requestClose) {
|
|
|
2132
2237
|
},
|
|
2133
2238
|
isClosed() {
|
|
2134
2239
|
return closed;
|
|
2240
|
+
},
|
|
2241
|
+
hasListeners() {
|
|
2242
|
+
return listeners.size > 0;
|
|
2135
2243
|
}
|
|
2136
2244
|
};
|
|
2137
2245
|
}
|
|
2138
2246
|
/**
|
|
2139
|
-
* The
|
|
2140
|
-
*
|
|
2247
|
+
* The id of keepalive's `ping` / `pong`: one no port ever takes. An older mux
|
|
2248
|
+
* whose codec passes unknown envelopes through reads them as a close of that
|
|
2249
|
+
* id, which must therefore not be a real port (0 is the initiator's first).
|
|
2141
2250
|
*/
|
|
2142
|
-
const
|
|
2251
|
+
const KEEPALIVE_ID = Number.MAX_SAFE_INTEGER;
|
|
2252
|
+
/** The longest delay a timer holds; past it `setInterval` fires at once. */
|
|
2253
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
2143
2254
|
/** Ceiling on concurrently open virtual ports. Bounds the id table only. */
|
|
2144
2255
|
const DEFAULT_MAX_PORTS = 1024;
|
|
2145
2256
|
/**
|
|
2257
|
+
* What a message costs a port's window when the codec cannot say
|
|
2258
|
+
* (`PortCodec.encode`): its bytes and string lengths, found in its values up
|
|
2259
|
+
* to 4 levels deep (at most 256 values), and at least 64. Both ends compute it
|
|
2260
|
+
* from the same structured value, so they agree.
|
|
2261
|
+
*/
|
|
2262
|
+
function estimateMessageSize(payload) {
|
|
2263
|
+
let budget = 256;
|
|
2264
|
+
const walk = (v, depth) => {
|
|
2265
|
+
if (budget-- <= 0) return 0;
|
|
2266
|
+
if (typeof v === "string") return v.length;
|
|
2267
|
+
if (v instanceof ArrayBuffer) return v.byteLength;
|
|
2268
|
+
if (ArrayBuffer.isView(v)) return v.byteLength;
|
|
2269
|
+
if (v === null || typeof v !== "object" || depth >= 4) return 8;
|
|
2270
|
+
let n = 16;
|
|
2271
|
+
if (Array.isArray(v)) for (const x of v) n += walk(x, depth + 1);
|
|
2272
|
+
else for (const k in v) n += k.length + walk(v[k], depth + 1);
|
|
2273
|
+
return n;
|
|
2274
|
+
};
|
|
2275
|
+
return Math.max(64, walk(payload, 0));
|
|
2276
|
+
}
|
|
2277
|
+
/**
|
|
2146
2278
|
* The default `PortMux`: emulates multiplexing over a single port.
|
|
2147
2279
|
*
|
|
2148
2280
|
* A transport that already multiplexes natively supplies its own `PortMux`
|
|
2149
2281
|
* instead — this implementation is for transports that offer one pipe.
|
|
2150
2282
|
*/
|
|
2151
2283
|
function multiplexPort(port, options) {
|
|
2152
|
-
const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize } = options;
|
|
2284
|
+
const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize, signal, window, frameSize, keepalive, onLeak } = options;
|
|
2285
|
+
const misses = keepalive?.misses ?? 3;
|
|
2286
|
+
if (keepalive && !(Number.isFinite(keepalive.intervalMs) && keepalive.intervalMs > 0 && keepalive.intervalMs <= MAX_TIMER_MS && Number.isSafeInteger(misses) && misses > 0)) throw new RangeError(`webrun-rpc: keepalive needs an intervalMs in (0, ${MAX_TIMER_MS}] and a positive integer misses`);
|
|
2287
|
+
if (frameSize != null && !(Number.isSafeInteger(frameSize) && frameSize > 0)) throw new RangeError(`webrun-rpc: frameSize must be a positive integer, got ${frameSize}`);
|
|
2153
2288
|
const open = /* @__PURE__ */ new Map();
|
|
2289
|
+
const leaks = onLeak ? new FinalizationRegistry(({ id, record, release }) => {
|
|
2290
|
+
const entry = open.get(id);
|
|
2291
|
+
if (entry?.handle !== record) return;
|
|
2292
|
+
const { handle: _, credit: __, partial: ___, ...info } = entry;
|
|
2293
|
+
if (!record.hasListeners()) {
|
|
2294
|
+
release("leaked");
|
|
2295
|
+
record.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port was dropped without close()"));
|
|
2296
|
+
}
|
|
2297
|
+
try {
|
|
2298
|
+
onLeak?.(info);
|
|
2299
|
+
} catch {}
|
|
2300
|
+
}) : void 0;
|
|
2301
|
+
const touch = (id) => {
|
|
2302
|
+
const entry = open.get(id);
|
|
2303
|
+
if (entry) entry.lastActivity = Date.now();
|
|
2304
|
+
};
|
|
2154
2305
|
let nextId = side === "initiator" ? 0 : 1;
|
|
2155
2306
|
let muxClosed = false;
|
|
2307
|
+
/** This end counts encoded bytes and reassembles frames. */
|
|
2308
|
+
const exactCodec = codec.encode != null && codec.decode != null;
|
|
2156
2309
|
const post = (envelope, transfer) => {
|
|
2157
2310
|
if (muxClosed) return;
|
|
2311
|
+
codecPost(envelope, transfer);
|
|
2312
|
+
};
|
|
2313
|
+
/** Like `post`, also while closing: the mux's own close notices. */
|
|
2314
|
+
const codecPost = (envelope, transfer) => {
|
|
2158
2315
|
try {
|
|
2159
2316
|
codec.post(port, envelope, transfer);
|
|
2160
2317
|
} catch {}
|
|
2161
2318
|
};
|
|
2162
|
-
const
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2319
|
+
const outgoing = /* @__PURE__ */ new Map();
|
|
2320
|
+
const ring = [];
|
|
2321
|
+
let pumping = false;
|
|
2322
|
+
const pump = async () => {
|
|
2323
|
+
pumping = true;
|
|
2324
|
+
try {
|
|
2325
|
+
while (ring.length && !muxClosed) {
|
|
2326
|
+
if (port.whenDrained) await port.whenDrained();
|
|
2327
|
+
if (muxClosed) break;
|
|
2328
|
+
const id = ring.shift();
|
|
2329
|
+
const queue = outgoing.get(id);
|
|
2330
|
+
const item = queue?.shift();
|
|
2331
|
+
if (queue?.length) ring.push(id);
|
|
2332
|
+
else outgoing.delete(id);
|
|
2333
|
+
item?.();
|
|
2334
|
+
}
|
|
2335
|
+
} catch (err) {
|
|
2336
|
+
close(err).catch(() => {});
|
|
2337
|
+
} finally {
|
|
2338
|
+
pumping = false;
|
|
2339
|
+
}
|
|
2340
|
+
};
|
|
2341
|
+
const sendOnPort = (id, item) => {
|
|
2342
|
+
if (frameSize == null) {
|
|
2343
|
+
item();
|
|
2344
|
+
return;
|
|
2345
|
+
}
|
|
2346
|
+
let queue = outgoing.get(id);
|
|
2347
|
+
if (!queue) {
|
|
2348
|
+
queue = [];
|
|
2349
|
+
outgoing.set(id, queue);
|
|
2350
|
+
ring.push(id);
|
|
2351
|
+
}
|
|
2352
|
+
queue.push(item);
|
|
2353
|
+
if (!pumping) pump();
|
|
2354
|
+
};
|
|
2355
|
+
const dropOutgoing = (id) => {
|
|
2356
|
+
if (!outgoing.delete(id)) return;
|
|
2357
|
+
const at = ring.indexOf(id);
|
|
2358
|
+
if (at >= 0) ring.splice(at, 1);
|
|
2359
|
+
};
|
|
2360
|
+
const postBytes = (bytes) => {
|
|
2361
|
+
if (muxClosed) return;
|
|
2362
|
+
try {
|
|
2363
|
+
port.postMessage(bytes);
|
|
2364
|
+
} catch {}
|
|
2365
|
+
};
|
|
2366
|
+
const releaseWaiters = (c) => {
|
|
2367
|
+
for (const w of c?.waiters.splice(0) ?? []) w.resolve();
|
|
2368
|
+
};
|
|
2369
|
+
const flush = (c) => {
|
|
2370
|
+
while (c.queue.length) {
|
|
2371
|
+
const next = c.queue[0];
|
|
2372
|
+
if (c.enforced && c.outstanding > 0 && c.outstanding + next.size > c.window) break;
|
|
2373
|
+
c.queue.shift();
|
|
2374
|
+
c.queued -= next.size;
|
|
2375
|
+
c.outstanding += next.size;
|
|
2376
|
+
next.send();
|
|
2377
|
+
}
|
|
2378
|
+
for (const w of [...c.waiters]) if (c.queued <= w.below) {
|
|
2379
|
+
c.waiters.splice(c.waiters.indexOf(w), 1);
|
|
2380
|
+
w.resolve();
|
|
2381
|
+
}
|
|
2382
|
+
};
|
|
2383
|
+
const grant = (id, c, bytes) => {
|
|
2384
|
+
c.unconsumed -= bytes;
|
|
2385
|
+
c.toGrant += bytes;
|
|
2386
|
+
const send = () => {
|
|
2387
|
+
c.grantScheduled = false;
|
|
2388
|
+
if (!c.toGrant || !open.has(id)) return;
|
|
2169
2389
|
post({
|
|
2390
|
+
type: "credit",
|
|
2391
|
+
id,
|
|
2392
|
+
bytes: c.toGrant
|
|
2393
|
+
});
|
|
2394
|
+
c.toGrant = 0;
|
|
2395
|
+
};
|
|
2396
|
+
if (c.toGrant >= c.window / 4) {
|
|
2397
|
+
send();
|
|
2398
|
+
return;
|
|
2399
|
+
}
|
|
2400
|
+
if (c.grantScheduled) return;
|
|
2401
|
+
c.grantScheduled = true;
|
|
2402
|
+
setTimeout(send, 0);
|
|
2403
|
+
};
|
|
2404
|
+
const attach = (id, meta, portWindow, opener, exact) => {
|
|
2405
|
+
const c = typeof portWindow === "number" && Number.isSafeInteger(portWindow) && portWindow > 0 ? {
|
|
2406
|
+
window: portWindow,
|
|
2407
|
+
enforced: false,
|
|
2408
|
+
opener,
|
|
2409
|
+
exact,
|
|
2410
|
+
outstanding: 0,
|
|
2411
|
+
queue: [],
|
|
2412
|
+
queued: 0,
|
|
2413
|
+
unconsumed: 0,
|
|
2414
|
+
waiters: [],
|
|
2415
|
+
toGrant: 0,
|
|
2416
|
+
grantScheduled: false
|
|
2417
|
+
} : void 0;
|
|
2418
|
+
const self = newVirtualPort((payload, transfer) => {
|
|
2419
|
+
touch(id);
|
|
2420
|
+
const envelope = {
|
|
2421
|
+
type: "message",
|
|
2422
|
+
id,
|
|
2423
|
+
payload
|
|
2424
|
+
};
|
|
2425
|
+
if (!c) {
|
|
2426
|
+
post(envelope, transfer);
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
const bytes = c.exact ? codec.encode?.(envelope) : void 0;
|
|
2430
|
+
const size = bytes?.byteLength ?? estimateMessageSize(payload);
|
|
2431
|
+
const waits = frameSize != null || c.queue.length > 0 || c.enforced && c.outstanding > 0 && c.outstanding + size > c.window;
|
|
2432
|
+
const held = !bytes && waits ? {
|
|
2433
|
+
...envelope,
|
|
2434
|
+
payload: structuredClone(payload, { transfer })
|
|
2435
|
+
} : void 0;
|
|
2436
|
+
const send = () => {
|
|
2437
|
+
if (!bytes) {
|
|
2438
|
+
sendOnPort(id, held ? () => post(held) : () => post(envelope, transfer));
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
if (frameSize != null && c.enforced && c.exact && bytes.byteLength > frameSize) {
|
|
2442
|
+
for (let at = 0; at < bytes.byteLength; at += frameSize) {
|
|
2443
|
+
const data = bytes.subarray(at, at + frameSize);
|
|
2444
|
+
const frame = at + frameSize >= bytes.byteLength ? {
|
|
2445
|
+
type: "frame",
|
|
2446
|
+
id,
|
|
2447
|
+
data,
|
|
2448
|
+
last: true
|
|
2449
|
+
} : {
|
|
2450
|
+
type: "frame",
|
|
2451
|
+
id,
|
|
2452
|
+
data
|
|
2453
|
+
};
|
|
2454
|
+
sendOnPort(id, () => post(frame));
|
|
2455
|
+
}
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
sendOnPort(id, () => postBytes(bytes));
|
|
2459
|
+
};
|
|
2460
|
+
c.queue.push({
|
|
2461
|
+
send,
|
|
2462
|
+
size
|
|
2463
|
+
});
|
|
2464
|
+
c.queued += size;
|
|
2465
|
+
flush(c);
|
|
2466
|
+
}, (reason) => {
|
|
2467
|
+
release(reason);
|
|
2468
|
+
record.markClosed(reason);
|
|
2469
|
+
});
|
|
2470
|
+
function release(reason) {
|
|
2471
|
+
open.delete(id);
|
|
2472
|
+
leaks?.unregister(record);
|
|
2473
|
+
if (c?.queue.length) {
|
|
2474
|
+
for (const item of c.queue.splice(0)) item.send();
|
|
2475
|
+
c.queued = 0;
|
|
2476
|
+
}
|
|
2477
|
+
sendOnPort(id, () => post({
|
|
2170
2478
|
type: "close",
|
|
2171
2479
|
id,
|
|
2172
2480
|
reason
|
|
2481
|
+
}));
|
|
2482
|
+
releaseWaiters(c);
|
|
2483
|
+
}
|
|
2484
|
+
const { markClosed, isClosed, hasListeners } = self;
|
|
2485
|
+
if (c) {
|
|
2486
|
+
const deliver = self.deliver;
|
|
2487
|
+
self.deliver = (payload, size = estimateMessageSize(payload)) => {
|
|
2488
|
+
c.unconsumed += size;
|
|
2489
|
+
const done = deliver(payload);
|
|
2490
|
+
if (done) done.then(() => grant(id, c, size));
|
|
2491
|
+
else grant(id, c, size);
|
|
2492
|
+
return done;
|
|
2493
|
+
};
|
|
2494
|
+
setPortSendState(self.port, {
|
|
2495
|
+
bufferedAmount: () => c.queued,
|
|
2496
|
+
ready: (below = 0) => new Promise((resolve) => {
|
|
2497
|
+
if (c.queued <= below || isClosed()) nextTask(resolve);
|
|
2498
|
+
else c.waiters.push({
|
|
2499
|
+
below,
|
|
2500
|
+
resolve: () => nextTask(resolve)
|
|
2501
|
+
});
|
|
2502
|
+
})
|
|
2173
2503
|
});
|
|
2174
|
-
|
|
2175
|
-
|
|
2504
|
+
}
|
|
2505
|
+
const record = {
|
|
2506
|
+
deliver: self.deliver,
|
|
2507
|
+
markClosed,
|
|
2508
|
+
isClosed,
|
|
2509
|
+
hasListeners,
|
|
2510
|
+
close: (reason) => {
|
|
2511
|
+
if (isClosed()) return;
|
|
2512
|
+
release(reason);
|
|
2513
|
+
markClosed(reason);
|
|
2514
|
+
}
|
|
2515
|
+
};
|
|
2516
|
+
leaks?.register(self.port, {
|
|
2517
|
+
id,
|
|
2518
|
+
record,
|
|
2519
|
+
release
|
|
2520
|
+
}, record);
|
|
2521
|
+
const now = Date.now();
|
|
2522
|
+
open.set(id, {
|
|
2523
|
+
id,
|
|
2524
|
+
meta,
|
|
2525
|
+
openedAt: now,
|
|
2526
|
+
lastActivity: now,
|
|
2527
|
+
handle: record,
|
|
2528
|
+
credit: c
|
|
2176
2529
|
});
|
|
2177
|
-
open.set(id, self);
|
|
2178
2530
|
return self;
|
|
2179
2531
|
};
|
|
2180
|
-
const handleEnvelope = (envelope) => {
|
|
2532
|
+
const handleEnvelope = (envelope, event) => {
|
|
2181
2533
|
if (muxClosed) return;
|
|
2534
|
+
heardSincePing = true;
|
|
2535
|
+
if (envelope.type === "ping" || envelope.type === "pong") {
|
|
2536
|
+
peerKeepsAlive = true;
|
|
2537
|
+
if (envelope.type === "ping") post({
|
|
2538
|
+
type: "pong",
|
|
2539
|
+
id: KEEPALIVE_ID
|
|
2540
|
+
});
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2182
2543
|
if (envelope.type === "open") {
|
|
2183
2544
|
if (open.has(envelope.id)) return;
|
|
2184
2545
|
if (open.size >= maxPorts) {
|
|
@@ -2189,7 +2550,18 @@ function multiplexPort(port, options) {
|
|
|
2189
2550
|
});
|
|
2190
2551
|
return;
|
|
2191
2552
|
}
|
|
2192
|
-
const handle = attach(envelope.id);
|
|
2553
|
+
const handle = attach(envelope.id, envelope.meta, envelope.window, false, envelope.exact === true && exactCodec);
|
|
2554
|
+
const credit = open.get(envelope.id)?.credit;
|
|
2555
|
+
if (credit) post(credit.exact ? {
|
|
2556
|
+
type: "credit",
|
|
2557
|
+
id: envelope.id,
|
|
2558
|
+
bytes: 0,
|
|
2559
|
+
exact: true
|
|
2560
|
+
} : {
|
|
2561
|
+
type: "credit",
|
|
2562
|
+
id: envelope.id,
|
|
2563
|
+
bytes: 0
|
|
2564
|
+
});
|
|
2193
2565
|
let accepted = false;
|
|
2194
2566
|
if (onPort) try {
|
|
2195
2567
|
accepted = onPort(handle.port, envelope.meta) !== false;
|
|
@@ -2204,57 +2576,191 @@ function multiplexPort(port, options) {
|
|
|
2204
2576
|
id: envelope.id,
|
|
2205
2577
|
reason: "rejected"
|
|
2206
2578
|
});
|
|
2579
|
+
return;
|
|
2207
2580
|
}
|
|
2208
2581
|
return;
|
|
2209
2582
|
}
|
|
2210
|
-
const
|
|
2583
|
+
const entry = open.get(envelope.id);
|
|
2584
|
+
if (envelope.type === "credit") {
|
|
2585
|
+
const c = entry?.credit;
|
|
2586
|
+
if (!c || !(envelope.bytes >= 0)) return;
|
|
2587
|
+
if (!c.enforced && c.opener) {
|
|
2588
|
+
if (c.exact && envelope.exact !== true) {
|
|
2589
|
+
c.exact = false;
|
|
2590
|
+
c.outstanding = 0;
|
|
2591
|
+
}
|
|
2592
|
+
post({
|
|
2593
|
+
type: "credit",
|
|
2594
|
+
id: envelope.id,
|
|
2595
|
+
bytes: 0
|
|
2596
|
+
});
|
|
2597
|
+
}
|
|
2598
|
+
c.enforced = true;
|
|
2599
|
+
c.outstanding = Math.max(0, c.outstanding - envelope.bytes);
|
|
2600
|
+
flush(c);
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2211
2603
|
if (envelope.type === "message") {
|
|
2212
|
-
|
|
2213
|
-
|
|
2604
|
+
const sizeOf = (c) => (c.exact ? byteLengthOf(event.data) : void 0) ?? estimateMessageSize(envelope.payload);
|
|
2605
|
+
if (!entry) return;
|
|
2606
|
+
touch(envelope.id);
|
|
2607
|
+
entry.handle.deliver(envelope.payload, entry.credit ? sizeOf(entry.credit) : void 0);
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
if (envelope.type === "frame") {
|
|
2611
|
+
if (!entry?.credit?.exact || !codec.decode || !(envelope.data instanceof Uint8Array)) return;
|
|
2612
|
+
touch(envelope.id);
|
|
2613
|
+
const partial = entry.partial ?? {
|
|
2614
|
+
chunks: [],
|
|
2615
|
+
bytes: 0
|
|
2616
|
+
};
|
|
2617
|
+
entry.partial = partial;
|
|
2618
|
+
partial.chunks.push(envelope.data);
|
|
2619
|
+
partial.bytes += envelope.data.byteLength;
|
|
2620
|
+
if (!envelope.last) return;
|
|
2621
|
+
entry.partial = void 0;
|
|
2622
|
+
const whole = new Uint8Array(partial.bytes);
|
|
2623
|
+
let at = 0;
|
|
2624
|
+
for (const chunk of partial.chunks) {
|
|
2625
|
+
whole.set(chunk, at);
|
|
2626
|
+
at += chunk.byteLength;
|
|
2627
|
+
}
|
|
2628
|
+
const inner = codec.decode(whole);
|
|
2629
|
+
if (inner?.type !== "message" || inner.id !== envelope.id) return;
|
|
2630
|
+
entry.handle.deliver(inner.payload, whole.byteLength);
|
|
2214
2631
|
return;
|
|
2215
2632
|
}
|
|
2216
|
-
|
|
2633
|
+
dropOutgoing(envelope.id);
|
|
2634
|
+
if (!entry) return;
|
|
2217
2635
|
open.delete(envelope.id);
|
|
2218
|
-
|
|
2636
|
+
releaseWaiters(entry.credit);
|
|
2637
|
+
entry.handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
|
|
2219
2638
|
};
|
|
2220
2639
|
const listener = (event) => {
|
|
2221
2640
|
const envelope = codec.read(event);
|
|
2222
|
-
if (envelope) handleEnvelope(envelope);
|
|
2641
|
+
if (envelope) handleEnvelope(envelope, event);
|
|
2642
|
+
};
|
|
2643
|
+
let peerKeepsAlive = false;
|
|
2644
|
+
let heardSincePing = true;
|
|
2645
|
+
let unanswered = 0;
|
|
2646
|
+
let pinger;
|
|
2647
|
+
const tick = () => {
|
|
2648
|
+
if (!keepalive) return;
|
|
2649
|
+
const backlogged = (port.bufferedAmount ?? 0) > 0;
|
|
2650
|
+
if (heardSincePing) unanswered = 0;
|
|
2651
|
+
else if (peerKeepsAlive && !backlogged) unanswered++;
|
|
2652
|
+
if (unanswered >= misses) {
|
|
2653
|
+
close(/* @__PURE__ */ new Error(`webrun-rpc: the peer did not answer ${misses} keepalive pings, ${keepalive.intervalMs} ms apart`)).catch(() => {});
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
heardSincePing = false;
|
|
2657
|
+
post({
|
|
2658
|
+
type: "ping",
|
|
2659
|
+
id: KEEPALIVE_ID
|
|
2660
|
+
});
|
|
2661
|
+
};
|
|
2662
|
+
const close = async (reason) => {
|
|
2663
|
+
if (muxClosed) return;
|
|
2664
|
+
clearInterval(pinger);
|
|
2665
|
+
muxClosed = true;
|
|
2666
|
+
signal?.removeEventListener("abort", onTransportAbort);
|
|
2667
|
+
port.removeEventListener("message", listener);
|
|
2668
|
+
const closedNow = /* @__PURE__ */ new Set();
|
|
2669
|
+
for (const [id, { handle, credit }] of [...open]) {
|
|
2670
|
+
closedNow.add(id);
|
|
2671
|
+
codecPost({
|
|
2672
|
+
type: "close",
|
|
2673
|
+
id
|
|
2674
|
+
});
|
|
2675
|
+
open.delete(id);
|
|
2676
|
+
releaseWaiters(credit);
|
|
2677
|
+
handle.markClosed(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the multiplexer was closed"));
|
|
2678
|
+
}
|
|
2679
|
+
for (const id of outgoing.keys()) if (!closedNow.has(id)) codecPost({
|
|
2680
|
+
type: "close",
|
|
2681
|
+
id
|
|
2682
|
+
});
|
|
2683
|
+
outgoing.clear();
|
|
2684
|
+
ring.length = 0;
|
|
2685
|
+
await port.close?.();
|
|
2686
|
+
};
|
|
2687
|
+
const onTransportAbort = () => {
|
|
2688
|
+
close(signal?.reason).catch(() => {});
|
|
2223
2689
|
};
|
|
2224
2690
|
port.addEventListener("message", listener);
|
|
2225
2691
|
port.start?.();
|
|
2692
|
+
if (keepalive) {
|
|
2693
|
+
pinger = setInterval(tick, keepalive.intervalMs);
|
|
2694
|
+
pinger.unref?.();
|
|
2695
|
+
}
|
|
2696
|
+
if (signal?.aborted) close(signal.reason).catch(() => {});
|
|
2697
|
+
else signal?.addEventListener("abort", onTransportAbort, { once: true });
|
|
2226
2698
|
return {
|
|
2227
2699
|
maxMessageSize,
|
|
2228
|
-
async openPort(meta) {
|
|
2700
|
+
async openPort(meta, { signal: portSignal } = {}) {
|
|
2229
2701
|
if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
|
|
2230
|
-
if (
|
|
2702
|
+
if (portSignal?.aborted) throw portSignal.reason;
|
|
2703
|
+
if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached; open ports by kind: ${describeKinds(open.values())}`);
|
|
2231
2704
|
while (open.has(nextId)) nextId += 2;
|
|
2232
2705
|
const id = nextId;
|
|
2233
2706
|
nextId += 2;
|
|
2234
|
-
const handle = attach(id);
|
|
2235
|
-
post({
|
|
2707
|
+
const handle = attach(id, meta, window, true, exactCodec);
|
|
2708
|
+
post(window ? exactCodec ? {
|
|
2709
|
+
type: "open",
|
|
2710
|
+
id,
|
|
2711
|
+
meta,
|
|
2712
|
+
window,
|
|
2713
|
+
exact: true
|
|
2714
|
+
} : {
|
|
2715
|
+
type: "open",
|
|
2716
|
+
id,
|
|
2717
|
+
meta,
|
|
2718
|
+
window
|
|
2719
|
+
} : {
|
|
2236
2720
|
type: "open",
|
|
2237
2721
|
id,
|
|
2238
2722
|
meta
|
|
2239
2723
|
});
|
|
2724
|
+
if (portSignal) {
|
|
2725
|
+
const record = open.get(id)?.handle;
|
|
2726
|
+
const onAbort = () => {
|
|
2727
|
+
if (record && open.get(id)?.handle === record) record.close(portSignal.reason);
|
|
2728
|
+
};
|
|
2729
|
+
portSignal.addEventListener("abort", onAbort, { once: true });
|
|
2730
|
+
getPortCloseSignal(handle.port)?.addEventListener("abort", () => portSignal.removeEventListener("abort", onAbort), { once: true });
|
|
2731
|
+
}
|
|
2240
2732
|
return handle.port;
|
|
2241
2733
|
},
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
}
|
|
2252
|
-
muxClosed = true;
|
|
2253
|
-
port.removeEventListener("message", listener);
|
|
2254
|
-
await port.close?.();
|
|
2734
|
+
close,
|
|
2735
|
+
ports() {
|
|
2736
|
+
return [...open.values()].map(({ handle: _, credit, ...info }) => credit ? {
|
|
2737
|
+
...info,
|
|
2738
|
+
...credit.enforced ? { window: credit.window } : {},
|
|
2739
|
+
queued: credit.queued,
|
|
2740
|
+
outstanding: credit.outstanding,
|
|
2741
|
+
unconsumed: credit.unconsumed
|
|
2742
|
+
} : info);
|
|
2255
2743
|
}
|
|
2256
2744
|
};
|
|
2257
2745
|
}
|
|
2746
|
+
/** The byte length of a received message's data, or `undefined` if it is not bytes. */
|
|
2747
|
+
function byteLengthOf(data) {
|
|
2748
|
+
if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return data.byteLength;
|
|
2749
|
+
}
|
|
2750
|
+
/**
|
|
2751
|
+
* `"http: 1000, ws: 24"`: the three commonest `meta.kind`s among `entries`, so
|
|
2752
|
+
* a full table names who filled it. A port without a kind counts as `"?"`.
|
|
2753
|
+
*/
|
|
2754
|
+
function describeKinds(entries) {
|
|
2755
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2756
|
+
for (const { meta } of entries) {
|
|
2757
|
+
const kind = meta?.kind;
|
|
2758
|
+
const name = typeof kind === "string" ? kind : "?";
|
|
2759
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
2760
|
+
}
|
|
2761
|
+
if (!counts.size) return "none";
|
|
2762
|
+
return [...counts].sort((x, y) => y[1] - x[1]).slice(0, 3).map(([name, n]) => `${name}: ${n}`).join(", ");
|
|
2763
|
+
}
|
|
2258
2764
|
/**
|
|
2259
2765
|
* Announced with every port this pair opens. Layer 1 never reads `meta` — it is
|
|
2260
2766
|
* here so a peer running something else over the same multiplexer can tell a
|
|
@@ -2361,7 +2867,7 @@ function isEnvelope(value) {
|
|
|
2361
2867
|
const candidate = value;
|
|
2362
2868
|
if (typeof candidate.id !== "number") return false;
|
|
2363
2869
|
if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
|
|
2364
|
-
return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
|
|
2870
|
+
return candidate.type === "open" || candidate.type === "message" || candidate.type === "close" || candidate.type === "credit" || candidate.type === "frame" || candidate.type === "ping" || candidate.type === "pong";
|
|
2365
2871
|
}
|
|
2366
2872
|
/**
|
|
2367
2873
|
* For ports whose messages are structured values — a real `MessagePort`, a
|
|
@@ -2523,7 +3029,7 @@ function trackedLink(send, release) {
|
|
|
2523
3029
|
finish();
|
|
2524
3030
|
return response;
|
|
2525
3031
|
}
|
|
2526
|
-
return new Response(guardBody(response.body, lost, () => lostReason, finish), {
|
|
3032
|
+
return new Response(guardBody(response.body, lost, () => lostReason, finish, request.signal), {
|
|
2527
3033
|
status: response.status,
|
|
2528
3034
|
statusText: response.statusText,
|
|
2529
3035
|
headers: response.headers
|
|
@@ -2542,33 +3048,65 @@ function trackedLink(send, release) {
|
|
|
2542
3048
|
return asyncDisposable(link, () => link.close());
|
|
2543
3049
|
}
|
|
2544
3050
|
/**
|
|
2545
|
-
* A response body that reports its end (done, cancelled or
|
|
2546
|
-
* with the link's reason when the link is lost mid-stream.
|
|
3051
|
+
* A response body that reports its end (done, cancelled, failed or aborted) and
|
|
3052
|
+
* fails with the link's reason when the link is lost mid-stream. An abort of the
|
|
3053
|
+
* request's signal ends it at once, read or not: the exchange is over (the layer
|
|
3054
|
+
* below has given its port back), so the request leaves `inflight` then.
|
|
2547
3055
|
*/
|
|
2548
|
-
function guardBody(body, lost, reason, finish) {
|
|
3056
|
+
function guardBody(body, lost, reason, finish, signal) {
|
|
2549
3057
|
const reader = body.getReader();
|
|
3058
|
+
let controller;
|
|
3059
|
+
let ended = false;
|
|
3060
|
+
const end = () => {
|
|
3061
|
+
ended = true;
|
|
3062
|
+
signal?.removeEventListener("abort", onAbort);
|
|
3063
|
+
finish();
|
|
3064
|
+
};
|
|
3065
|
+
const onAbort = () => {
|
|
3066
|
+
if (ended) return;
|
|
3067
|
+
end();
|
|
3068
|
+
reader.cancel().catch(() => {});
|
|
3069
|
+
try {
|
|
3070
|
+
controller.error(abortError(signal));
|
|
3071
|
+
} catch {}
|
|
3072
|
+
};
|
|
2550
3073
|
return new ReadableStream({
|
|
3074
|
+
start(c) {
|
|
3075
|
+
controller = c;
|
|
3076
|
+
if (signal?.aborted) onAbort();
|
|
3077
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
3078
|
+
},
|
|
2551
3079
|
async pull(controller) {
|
|
2552
3080
|
try {
|
|
2553
3081
|
const { done, value } = await Promise.race([reader.read(), lost]);
|
|
3082
|
+
if (ended) return;
|
|
2554
3083
|
if (done) {
|
|
2555
|
-
|
|
3084
|
+
end();
|
|
2556
3085
|
controller.close();
|
|
2557
3086
|
return;
|
|
2558
3087
|
}
|
|
2559
3088
|
controller.enqueue(value);
|
|
2560
3089
|
} catch (error) {
|
|
2561
|
-
|
|
3090
|
+
if (ended) return;
|
|
3091
|
+
end();
|
|
2562
3092
|
reader.cancel().catch(() => {});
|
|
2563
3093
|
controller.error(reason() ?? error);
|
|
2564
3094
|
}
|
|
2565
3095
|
},
|
|
2566
3096
|
async cancel(why) {
|
|
2567
|
-
|
|
3097
|
+
end();
|
|
2568
3098
|
await reader.cancel(why).catch(() => {});
|
|
2569
3099
|
}
|
|
2570
3100
|
});
|
|
2571
3101
|
}
|
|
3102
|
+
/** The signal's reason as an `Error` (`AbortError` when it gave none). */
|
|
3103
|
+
function abortError(signal) {
|
|
3104
|
+
const why = signal.reason;
|
|
3105
|
+
if (why instanceof Error) return why;
|
|
3106
|
+
const err = new Error(why === void 0 ? "Aborted" : String(why));
|
|
3107
|
+
err.name = "AbortError";
|
|
3108
|
+
return err;
|
|
3109
|
+
}
|
|
2572
3110
|
//#endregion
|
|
2573
3111
|
//#region src/link/locks.ts
|
|
2574
3112
|
function lockManager() {
|
|
@@ -3715,12 +4253,18 @@ function serveHttpInWorker(handler, options = {}) {
|
|
|
3715
4253
|
const ownerLock = newLockName("webrun-http-owner");
|
|
3716
4254
|
const held = holdLock(ownerLock);
|
|
3717
4255
|
const controls = /* @__PURE__ */ new Set();
|
|
4256
|
+
const seen = /* @__PURE__ */ new Map();
|
|
3718
4257
|
const tellPages = (message) => {
|
|
3719
4258
|
for (const control of controls) control.postMessage(message);
|
|
3720
4259
|
};
|
|
3721
4260
|
const endpoint = new HandlerEndpoint(handler, {
|
|
3722
4261
|
...options,
|
|
3723
|
-
requestRelink: () =>
|
|
4262
|
+
requestRelink: () => {
|
|
4263
|
+
for (const control of controls) control.postMessage({
|
|
4264
|
+
type: "relink",
|
|
4265
|
+
seen: seen.get(control) ?? 0
|
|
4266
|
+
});
|
|
4267
|
+
},
|
|
3724
4268
|
keepAlive: () => tellPages({ type: "keepalive" })
|
|
3725
4269
|
});
|
|
3726
4270
|
const onMessage = (event) => {
|
|
@@ -3732,6 +4276,7 @@ function serveHttpInWorker(handler, options = {}) {
|
|
|
3732
4276
|
control.onmessage = (message) => {
|
|
3733
4277
|
const msg = message.data;
|
|
3734
4278
|
if (msg?.type === "link") {
|
|
4279
|
+
if (msg.seq !== void 0) seen.set(control, msg.seq);
|
|
3735
4280
|
const [down, ctl, up] = message.ports;
|
|
3736
4281
|
if (down) endpoint.link(msg.key, {
|
|
3737
4282
|
down,
|
|
@@ -3772,6 +4317,7 @@ function serveHttpInWorker(handler, options = {}) {
|
|
|
3772
4317
|
await endpoint.close();
|
|
3773
4318
|
for (const control of controls) control.close();
|
|
3774
4319
|
controls.clear();
|
|
4320
|
+
seen.clear();
|
|
3775
4321
|
(await held)();
|
|
3776
4322
|
}
|
|
3777
4323
|
};
|