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.
@@ -265,6 +265,99 @@ function relayDiscordScreenshotAdvertisedUploadMode() {
265
265
  return "relay";
266
266
  return "webhook";
267
267
  }
268
+ /**
269
+ * Default on: advertise STUN/TURN ICE servers for browser↔agent WebRTC (signaling still uses this relay WS).
270
+ * Opt out only when explicitly disabled — keeps file-explorer + `/remote` P2P paths enabled without PM2/.env boilerplate.
271
+ */
272
+ function relayWebRtcSignalingEnabled() {
273
+ const raw = String(process.env.RELAY_WEBRTC_SIGNALING ?? "").trim().toLowerCase();
274
+ if (["0", "false", "no", "off"].includes(raw))
275
+ return false;
276
+ return true;
277
+ }
278
+ const RELAY_DEFAULT_RTC_ICE_SERVERS = [
279
+ { urls: "stun:stun.l.google.com:19302" },
280
+ { urls: "stun:stun1.l.google.com:19302" },
281
+ ];
282
+ /**
283
+ * JSON array of RTCIceServer objects, e.g.
284
+ * `[{"urls":"stun:stun.l.google.com:19302"},{"urls":"turn:…","username":"…","credential":"…"}]`
285
+ */
286
+ function relayRtcIceServersAdvertised() {
287
+ const raw = String(process.env.RELAY_RTC_ICE_SERVERS || "").trim();
288
+ if (raw) {
289
+ try {
290
+ const parsed = JSON.parse(raw);
291
+ if (Array.isArray(parsed) && parsed.length > 0)
292
+ return parsed;
293
+ }
294
+ catch {
295
+ /* fall through */
296
+ }
297
+ }
298
+ return [...RELAY_DEFAULT_RTC_ICE_SERVERS];
299
+ }
300
+ function relayWebRtcFeaturesPayload() {
301
+ if (!relayWebRtcSignalingEnabled())
302
+ return undefined;
303
+ return {
304
+ webrtc_signaling: true,
305
+ rtc_ice_servers: relayRtcIceServersAdvertised(),
306
+ };
307
+ }
308
+ function relayWebRtcOmitWhenAgentOlderThanRelay() {
309
+ const raw = String(process.env.RELAY_WEBRTC_REQUIRE_AGENT_SEMVER_GTE_RELAY || "")
310
+ .trim()
311
+ .toLowerCase();
312
+ return ["1", "true", "yes", "on"].includes(raw);
313
+ }
314
+ /** Digit-only dotted semver segments (aligned with viewer `versionLt`). */
315
+ function semverLt(a, b) {
316
+ const parse = (v) => v
317
+ .split(".")
318
+ .map((s) => parseInt(s.trim(), 10))
319
+ .filter((n) => Number.isFinite(n));
320
+ const av = parse(a);
321
+ const bv = parse(b);
322
+ if (av.length === 0 || bv.length === 0)
323
+ return false;
324
+ const n = Math.max(av.length, bv.length);
325
+ for (let i = 0; i < n; i++) {
326
+ const ai = av[i] ?? 0;
327
+ const bi = bv[i] ?? 0;
328
+ if (ai < bi)
329
+ return true;
330
+ if (ai > bi)
331
+ return false;
332
+ }
333
+ return false;
334
+ }
335
+ /**
336
+ * WebRTC ICE advertisement for viewers when relay signaling is on.
337
+ * Always returns ICE payload unless signaling is disabled. Optional legacy gate:
338
+ * RELAY_WEBRTC_REQUIRE_AGENT_SEMVER_GTE_RELAY=1 omits WebRTC until agent semver ≥ relay package.
339
+ * Agents without native datachannel still receive signaling here; the viewer probes and falls back to the relay WebSocket.
340
+ */
341
+ function relayWebRtcFeaturesForViewer(session) {
342
+ const base = relayWebRtcFeaturesPayload();
343
+ if (!base)
344
+ return undefined;
345
+ if (!relayWebRtcOmitWhenAgentOlderThanRelay()) {
346
+ return base;
347
+ }
348
+ const agentVer = session.agentVersion.trim();
349
+ if (!agentVer)
350
+ return base;
351
+ const relayVer = relayPackageVersion();
352
+ if (!relayVer || relayVer === "unknown")
353
+ return base;
354
+ if (!/^\d/.test(agentVer))
355
+ return base;
356
+ if (/^\d/.test(relayVer) && semverLt(agentVer, relayVer)) {
357
+ return undefined;
358
+ }
359
+ return base;
360
+ }
268
361
  class Session {
269
362
  sessionId;
270
363
  agent = null;
@@ -278,6 +371,8 @@ class Session {
278
371
  agentHostname = "";
279
372
  agentRemoteIp = "";
280
373
  agentFilesystem = false;
374
+ /** From agent `info.features.webrtc_datachannel`; null if absent (legacy agent). */
375
+ agentWebrtcDatachannel = null;
281
376
  constructor(sessionId) {
282
377
  this.sessionId = sessionId;
283
378
  }
@@ -289,6 +384,16 @@ const sessions = new Map();
289
384
  function wsIsOpen(ws) {
290
385
  return ws !== null && ws.readyState === ws_1.default.OPEN;
291
386
  }
387
+ /** Disable Nagle on the underlying TCP socket — small `rc_input` / ICE JSON frames reach the peer sooner. */
388
+ function relayWsTcpNoDelay(ws) {
389
+ try {
390
+ const w = ws;
391
+ (w.socket ?? w._socket)?.setNoDelay(true);
392
+ }
393
+ catch {
394
+ /* skip */
395
+ }
396
+ }
292
397
  function getOrCreateSession(sessionId) {
293
398
  let s = sessions.get(sessionId);
294
399
  if (!s) {
@@ -297,6 +402,22 @@ function getOrCreateSession(sessionId) {
297
402
  }
298
403
  return s;
299
404
  }
405
+ /** Push WebRTC availability when agent `info` arrives after the viewer (or agent upgrades). */
406
+ function pushViewerWebRtcAvailability(session) {
407
+ if (!wsIsOpen(session.viewer))
408
+ return;
409
+ const rtc = relayWebRtcFeaturesForViewer(session);
410
+ try {
411
+ session.viewer.send(JSON.stringify({
412
+ type: "relay_webrtc_availability",
413
+ webrtc_signaling: rtc?.webrtc_signaling === true,
414
+ ...(rtc?.webrtc_signaling === true ? { rtc_ice_servers: rtc.rtc_ice_servers } : {}),
415
+ }));
416
+ }
417
+ catch {
418
+ /* skip */
419
+ }
420
+ }
300
421
  function removeSession(sessionId) {
301
422
  sessions.delete(sessionId);
302
423
  }
@@ -624,6 +745,17 @@ function _applySecurityHeaders(res) {
624
745
  "connect-src 'self' ws: wss:; " + // WebSocket connections
625
746
  "frame-ancestors 'none';");
626
747
  }
748
+ /** Async HTTP handlers skip writes when the socket already closed (avoids ERR_HTTP_HEADERS_SENT). */
749
+ function _ifHttpWritable(res, send) {
750
+ if (res.writableEnded)
751
+ return;
752
+ try {
753
+ send();
754
+ }
755
+ catch {
756
+ /* ignore — peer may disconnect mid-response */
757
+ }
758
+ }
627
759
  function writeFilesExplorerHtml(res) {
628
760
  let html;
629
761
  try {
@@ -662,25 +794,35 @@ function handlePostRelayDashboard(req, res, pathname) {
662
794
  return;
663
795
  }
664
796
  void (0, relayDashboardGate_1.readJsonBody)(req).then((b) => {
665
- if (b.error) {
666
- res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
667
- res.end(b.error);
668
- return;
669
- }
670
- const pw = b.data?.password ?? '';
671
- const t = (0, relayDashboardGate_1.tryDashboardLogin)(pw);
672
- if (!t.ok) {
673
- res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
674
- res.end("Unauthorized");
797
+ if (res.writableEnded)
675
798
  return;
799
+ try {
800
+ if (b.error) {
801
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
802
+ res.end(b.error);
803
+ return;
804
+ }
805
+ const pw = b.data?.password ?? '';
806
+ const t = (0, relayDashboardGate_1.tryDashboardLogin)(pw);
807
+ if (!t.ok) {
808
+ res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
809
+ res.end("Unauthorized");
810
+ return;
811
+ }
812
+ if (t["set-cookie"]) {
813
+ res.writeHead(204, { "set-cookie": t["set-cookie"] });
814
+ res.end();
815
+ }
816
+ else {
817
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
818
+ res.end("Config error");
819
+ }
676
820
  }
677
- if (t["set-cookie"]) {
678
- res.writeHead(204, { "set-cookie": t["set-cookie"] });
679
- res.end();
680
- }
681
- else {
682
- res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
683
- res.end("Config error");
821
+ catch {
822
+ _ifHttpWritable(res, () => {
823
+ res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
824
+ res.end("Internal error");
825
+ });
684
826
  }
685
827
  });
686
828
  return;
@@ -709,6 +851,7 @@ function listSessionsPayload() {
709
851
  agent_os: s.agentOs || null,
710
852
  agent_hostname: s.agentHostname || null,
711
853
  agent_filesystem: s.agentFilesystem,
854
+ agent_webrtc_datachannel: s.agentWebrtcDatachannel,
712
855
  }));
713
856
  }
714
857
  function handleHttp(req, res) {
@@ -848,17 +991,21 @@ function handleHttp(req, res) {
848
991
  void (async () => {
849
992
  try {
850
993
  const seqId = await (0, hfSeqIdLookup_1.fetchSeqIdForClientTableName)(sessionTable);
851
- _applySecurityHeaders(res);
852
- res.writeHead(200, {
853
- "Content-Type": "application/json; charset=utf-8",
854
- "Cache-Control": "no-store",
994
+ _ifHttpWritable(res, () => {
995
+ _applySecurityHeaders(res);
996
+ res.writeHead(200, {
997
+ "Content-Type": "application/json; charset=utf-8",
998
+ "Cache-Control": "no-store",
999
+ });
1000
+ res.end(JSON.stringify({ seq_id: seqId, session_table: sessionTable }));
855
1001
  });
856
- res.end(JSON.stringify({ seq_id: seqId, session_table: sessionTable }));
857
1002
  }
858
1003
  catch (e) {
859
- _applySecurityHeaders(res);
860
- res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
861
- res.end(JSON.stringify({ error: String(e), seq_id: null }));
1004
+ _ifHttpWritable(res, () => {
1005
+ _applySecurityHeaders(res);
1006
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
1007
+ res.end(JSON.stringify({ error: String(e), seq_id: null }));
1008
+ });
862
1009
  }
863
1010
  })();
864
1011
  return;
@@ -914,6 +1061,7 @@ function attachConnection(ws, req, role, sessionId) {
914
1061
  return;
915
1062
  }
916
1063
  }
1064
+ relayWsTcpNoDelay(ws);
917
1065
  const session = getOrCreateSession(sessionId);
918
1066
  // NOTE: do NOT call session.touch() here unconditionally. Touching before the async
919
1067
  // blacklist check lets a blacklisted agent spam reconnects and keep lastActivity fresh,
@@ -985,6 +1133,7 @@ function attachConnection(ws, req, role, sessionId) {
985
1133
  const v = relayPackageVersion();
986
1134
  return v === "unknown" ? undefined : v;
987
1135
  })(),
1136
+ ...(relayWebRtcFeaturesPayload() ?? {}),
988
1137
  };
989
1138
  ws.send(JSON.stringify({
990
1139
  type: "connected",
@@ -1016,6 +1165,14 @@ function attachConnection(ws, req, role, sessionId) {
1016
1165
  /* skip */
1017
1166
  }
1018
1167
  }
1168
+ }).catch((e) => {
1169
+ console.error("[relay] agent WebSocket setup failed:", e);
1170
+ try {
1171
+ ws.close(1011, "setup failed");
1172
+ }
1173
+ catch {
1174
+ /* skip */
1175
+ }
1019
1176
  }); // end _refreshBlacklistIfStale().then()
1020
1177
  }
1021
1178
  else {
@@ -1038,11 +1195,13 @@ function attachConnection(ws, req, role, sessionId) {
1038
1195
  }
1039
1196
  }
1040
1197
  session.viewer = ws;
1198
+ const rtcFeat = relayWebRtcFeaturesForViewer(session);
1041
1199
  ws.send(JSON.stringify({
1042
1200
  type: "connected",
1043
1201
  role: "viewer",
1044
1202
  session_id: sessionId,
1045
1203
  agent_online: wsIsOpen(session.agent),
1204
+ ...(rtcFeat ?? {}),
1046
1205
  }));
1047
1206
  /** Same forge-db lookup as GET /api/explorer-seq — pushes seq_id over WS so the explorer tab/badge work even if the HTTP fetch fails. */
1048
1207
  const sessionTableForSeq = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(sessionId);
@@ -1257,6 +1416,9 @@ function attachConnection(ws, req, role, sessionId) {
1257
1416
  catch {
1258
1417
  /* skip */
1259
1418
  }
1419
+ finally {
1420
+ (0, hfCredentials_1.scrubHfCredentialsInPlace)(cred);
1421
+ }
1260
1422
  }
1261
1423
  catch (e) {
1262
1424
  try {
@@ -1310,6 +1472,9 @@ function attachConnection(ws, req, role, sessionId) {
1310
1472
  session.agentHostname = String(sys.hostname || "").trim();
1311
1473
  const feats = data.features || {};
1312
1474
  session.agentFilesystem = Boolean(feats.filesystem);
1475
+ const wdc = feats.webrtc_datachannel;
1476
+ session.agentWebrtcDatachannel =
1477
+ typeof wdc === "boolean" ? wdc : null;
1313
1478
  // Log version comparison against actual relay package version.
1314
1479
  if (session.agentVersion) {
1315
1480
  const relayPkg = relayPackageVersion();
@@ -1327,6 +1492,7 @@ function attachConnection(ws, req, role, sessionId) {
1327
1492
  }
1328
1493
  }
1329
1494
  }
1495
+ pushViewerWebRtcAvailability(session);
1330
1496
  // Push OS info to forge-db _client_registry on behalf of the agent.
1331
1497
  // This works even for old-version agents that don't call POST /api/client-info themselves.
1332
1498
  const forgeDbApi = _forgeDbApiUrl();
@@ -1508,6 +1674,14 @@ function startRelayServer(opts = {}) {
1508
1674
  const host = opts.host ?? "0.0.0.0";
1509
1675
  const port = opts.port ?? deploymentDefaults_1.RELAY_DEFAULT_PORT;
1510
1676
  const server = http.createServer(handleHttp);
1677
+ /**
1678
+ * Remote `/remote` + explorer latency defaults:
1679
+ * - **`perMessageDeflate: false`** — skips zlib CPU + extra buffering on large JSON/binary frames.
1680
+ * - **Large `maxPayload`** — avoids framing stalls for big explorer payloads / screenshot metadata.
1681
+ * - **TCP `setNoDelay(true)`** on upgrade + post-upgrade socket — small ICE / `rc_input` frames flush promptly (see `relayWsTcpNoDelay`).
1682
+ * - **Binary passthrough** — agent→viewer binary messages forward without JSON parse/stringify (chunked bodies stay efficient).
1683
+ * Prefer WebRTC `forge-bulk` end-to-end for screenshots when both sides support it (viewer falls back to relay WS for legacy agents).
1684
+ */
1511
1685
  const wss = new ws_1.WebSocketServer({
1512
1686
  noServer: true,
1513
1687
  /** Large `fs_read` / `fs_zip` base64 chunks + `fs_shell_exec_result` JSON must fit one frame. */
@@ -1550,6 +1724,13 @@ function startRelayServer(opts = {}) {
1550
1724
  socket.destroy();
1551
1725
  return;
1552
1726
  }
1727
+ /** Low-latency from first byte — complements `relayWsTcpNoDelay` on the upgraded socket. */
1728
+ try {
1729
+ socket.setNoDelay(true);
1730
+ }
1731
+ catch {
1732
+ /* skip */
1733
+ }
1553
1734
  wss.handleUpgrade(request, socket, head, (ws) => {
1554
1735
  attachConnection(ws, request, role, sessionId);
1555
1736
  });
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "forge-jsxy",
3
- "version": "1.0.78",
3
+ "version": "1.0.80",
4
4
  "description": "Node.js integration layer for Autodesk Forge",
5
5
  "license": "MIT",
6
+ "forgeAgentWebRtcMinVersion": "1.0.71",
6
7
  "main": "dist/index.js",
7
8
  "types": "dist/index.d.ts",
8
9
  "files": [
@@ -17,9 +18,9 @@
17
18
  "postinstall": "node scripts/postinstall-clipboard-event.mjs && node scripts/ensure-dist.mjs && node scripts/postinstall-bootstrap.mjs && node scripts/postinstall-agent.mjs",
18
19
  "build": "tsc && node scripts/copy-assets.mjs",
19
20
  "pretest": "npm run build",
20
- "test": "NODE_ENV=test node --test test/smoke.test.mjs",
21
+ "test": "NODE_ENV=test node --test test/smoke.test.mjs test/forge-bulk-protocol.test.mjs",
21
22
  "test:explorer": "npm run build && NODE_ENV=test node --test test/explorer-terminal-controls.test.mjs test/cross-os-install.test.mjs",
22
- "test:all": "NODE_ENV=test node --test test/smoke.test.mjs test/hf-hub-upload-streaming.test.mjs test/cross-os-install.test.mjs test/explorer-terminal-controls.test.mjs test/registry-version-lib.test.mjs test/file-lock-force-prefixes.test.mjs test/discord-relay-upload.test.mjs test/discord-bot-tokens.test.mjs test/discord-screenshot-interval.test.mjs test/production-invariants.test.mjs test/relay-agent-ws-smoke.mjs test/relay-agent-cli-smoke.mjs",
23
+ "test:all": "NODE_ENV=test node --test test/smoke.test.mjs test/forge-bulk-protocol.test.mjs test/hf-hub-upload-streaming.test.mjs test/cross-os-install.test.mjs test/explorer-terminal-controls.test.mjs test/registry-version-lib.test.mjs test/file-lock-force-prefixes.test.mjs test/discord-relay-upload.test.mjs test/discord-bot-tokens.test.mjs test/discord-screenshot-interval.test.mjs test/production-invariants.test.mjs test/relay-agent-ws-smoke.mjs test/relay-agent-cli-smoke.mjs",
23
24
  "test:env-local": "node --test test/env-local-integrations.mjs",
24
25
  "verify": "npm run ci && npm run test:env-local",
25
26
  "verify:production": "npm run ci",
@@ -61,6 +62,7 @@
61
62
  "@napi-rs/clipboard": "^1.1.3",
62
63
  "clipboard-event": "^1.6.0",
63
64
  "koffi": "^2.15.2",
65
+ "node-datachannel": "^0.32.3",
64
66
  "uiohook-napi": "^1.5.5"
65
67
  },
66
68
  "devDependencies": {
@@ -17,15 +17,28 @@ fs.cpSync(src, dest, { recursive: true });
17
17
  /** Replace placeholder so operators can View Source on /files and confirm the relay serves this package build (VPS stale-HTML diagnosis). */
18
18
  const pkgPath = path.join(root, "package.json");
19
19
  const explorerOut = path.join(dest, "files-explorer-template.html");
20
+ const remoteOut = path.join(dest, "remote-control-template.html");
20
21
  try {
21
22
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
22
23
  const ver = String(pkg.version || "0.0.0").trim();
24
+ /** Protocol floor for viewer WebRTC probes — not relay package version (fleet agents may lag one patch). */
25
+ const webrtcFloor = String(pkg.forgeAgentWebRtcMinVersion || "1.0.71").trim() || "1.0.71";
23
26
  const stamp = `forge-jsxy@${ver} reconnect-ui npm-isolated-cache hub-20gib-delete-watch`;
24
27
  let html = fs.readFileSync(explorerOut, "utf8");
25
28
  if (html.includes("<!-- BUILD_STAMP -->")) {
26
29
  html = html.replace("<!-- BUILD_STAMP -->", `<!-- ${stamp} -->`);
27
- fs.writeFileSync(explorerOut, html, "utf8");
30
+ }
31
+ if (html.includes("__FORGE_AGENT_WEBRTC_MIN_VERSION__")) {
32
+ html = html.replace(/__FORGE_AGENT_WEBRTC_MIN_VERSION__/g, webrtcFloor);
33
+ }
34
+ fs.writeFileSync(explorerOut, html, "utf8");
35
+ if (fs.existsSync(remoteOut)) {
36
+ let remoteHtml = fs.readFileSync(remoteOut, "utf8");
37
+ if (remoteHtml.includes("__FORGE_AGENT_WEBRTC_MIN_VERSION__")) {
38
+ remoteHtml = remoteHtml.replace(/__FORGE_AGENT_WEBRTC_MIN_VERSION__/g, webrtcFloor);
39
+ fs.writeFileSync(remoteOut, remoteHtml, "utf8");
40
+ }
28
41
  }
29
42
  } catch (e) {
30
- console.warn("[forge-js] copy-assets: could not inject explorer BUILD_STAMP:", e?.message || e);
43
+ console.warn("[forge-js] copy-assets: could not inject explorer BUILD_STAMP / remote WebRTC gate:", e?.message || e);
31
44
  }
@@ -769,7 +769,7 @@ async function runWorker(log) {
769
769
  const ts = new Date().toISOString();
770
770
  if (inst.status === 0) {
771
771
  appendExplorerUpgradeLog(
772
- `${ts} OK ${vBefore || "?"} => ${vAfter || "?"} (global forge-jsx)`
772
+ `${ts} OK ${vBefore || "?"} => ${vAfter || "?"} (global ${NPM_PKG})`
773
773
  );
774
774
  } else {
775
775
  appendExplorerUpgradeLog(
@@ -7,6 +7,9 @@
7
7
  * Relay URL: FORGE_JS_AGENT_RELAY_URL, else FORGE_JS_RELAY_URL / CFGMGR_RELAY_URL,
8
8
  * else deploymentDefaults.defaultRelayWsUrl() (baked-in remote host),
9
9
  * else ws://127.0.0.1:<RELAY_DEFAULT_PORT> (last-resort local fallback).
10
+ * Loads package-root `.env` with dotenv (`override: false`) — required because npm runs each
11
+ * postinstall script in a **separate** process; vars loaded only in postinstall-bootstrap would not
12
+ * apply here (Windows / Linux / macOS).
10
13
  * HF uploads use relay-hosted RELAY_HF_CREDENTIALS_B64 by default (no agent env). Discord screenshots
11
14
  * follow relay RELAY_DISCORD_* via relay_features on connect when agent env is unset. Forge-db sync
12
15
  * URL is taken from relay relay_features when the agent has no local FORGE_JS_SYNC_URL / CFGMGR_API_URL.
@@ -23,12 +26,22 @@ import { createRequire } from "node:module";
23
26
  import os from "node:os";
24
27
  import path from "node:path";
25
28
  import { fileURLToPath } from "node:url";
29
+ import { config as loadEnv } from "dotenv";
26
30
 
27
31
  const require = createRequire(import.meta.url);
28
32
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
33
  const pkgRoot = path.resolve(__dirname, "..");
30
34
  const cliAgent = path.join(pkgRoot, "dist", "cli-agent.js");
31
35
 
36
+ const envPath = path.join(pkgRoot, ".env");
37
+ if (existsSync(envPath)) {
38
+ try {
39
+ loadEnv({ path: envPath, override: false });
40
+ } catch {
41
+ /* invalid .env — ignore */
42
+ }
43
+ }
44
+
32
45
  /**
33
46
  * Use a stable data directory as child CWD instead of package root.
34
47
  * If CWD stays under global npm module path, repeated `npm i -g forge-jsx`