replicas-computer 0.3.15 → 0.3.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -44,7 +44,7 @@ The server records every decision that changes the picture (viewer connects and
44
44
  | `REPLICAS_DESKTOP_STREAM_TRANSPORT` | `webrtc` | Transport for viewers whose URL does not choose one |
45
45
  | `REPLICAS_DESKTOP_STREAM_PROFILE` | `balanced` | Encoder profile for viewers whose URL does not choose one |
46
46
  | `REPLICAS_DESKTOP_STREAM_ADAPTIVE` | `1` | `0` pins the top quality rung by default |
47
- | `REPLICAS_DESKTOP_STREAM_ICE_SERVERS` | `stun:stun.l.google.com:19302` | Comma-separated STUN/TURN servers; `turn:user:secret@host:port?transport=tcp` adds a relay. Percent-encode the username and password; the browser receives their decoded values. Re-read from the workspace runtime env on every viewer, so the monolith's `DESKTOP_STREAM_ICE_SERVERS` reaches running workspaces |
47
+ | `REPLICAS_DESKTOP_STREAM_ICE_SERVERS` | `stun:stun.l.google.com:19302` | Comma-separated STUN/TURN servers; `turn:user:secret@host:port?transport=tcp` adds a relay. Percent-encode the username and password; the browser receives their decoded values. Re-read from the workspace runtime env on every viewer, so the monolith's `DESKTOP_STREAM_ICE_SERVERS` reaches running workspaces. Short-lived credentials for the [TURN relay](../services/turn/README.md) are fetched from the monolith and appended; the server itself only uses UDP relays |
48
48
  | `REPLICAS_DESKTOP_STREAM_ICE_PORTS` | ephemeral | `begin-end` UDP port range for the server's ICE candidates |
49
49
  | `REPLICAS_DESKTOP_STREAM_WEBRTC` | `1` | `0` serves the WebSocket transport only |
50
50
  | `REPLICAS_DESKTOP_STREAM_COLOR` | `bt709` | `legacy` disables the explicit BT.709 conversion and signalling |
package/dist/index.js CHANGED
@@ -2937,7 +2937,7 @@ async function computerStatusCommand() {
2937
2937
  }
2938
2938
 
2939
2939
  // src/version.ts
2940
- var COMPUTER_USE_VERSION = "0.3.15";
2940
+ var COMPUTER_USE_VERSION = "0.3.17";
2941
2941
  var COMPUTER_USE_PROTOCOL_VERSION = 1;
2942
2942
 
2943
2943
  // src/program.ts
@@ -3179,69 +3179,11 @@ async function runComputerClient(argv) {
3179
3179
  // src/media-server.ts
3180
3180
  import { spawn as spawn5 } from "child_process";
3181
3181
  import { createHash as createHash3 } from "crypto";
3182
- import { createReadStream, existsSync as existsSync7, readdirSync, readFileSync as readFileSync6, statSync as statSync2 } from "fs";
3182
+ import { createReadStream, existsSync as existsSync7, readdirSync, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
3183
3183
  import { createServer as createServer2 } from "http";
3184
3184
  import { availableParallelism as availableParallelism2, loadavg } from "os";
3185
3185
  import { dirname as dirname7, extname, resolve as resolve3 } from "path";
3186
3186
  import { fileURLToPath as fileURLToPath2 } from "url";
3187
-
3188
- // ../shared/src/runtime-env.ts
3189
- function parsePosixEnvFile(content) {
3190
- const result = {};
3191
- let pos = 0;
3192
- while (pos < content.length) {
3193
- let lineStart;
3194
- if (pos === 0 || content[pos - 1] === "\n") {
3195
- lineStart = pos;
3196
- } else {
3197
- const nl2 = content.indexOf("\n", pos);
3198
- if (nl2 === -1) break;
3199
- lineStart = nl2 + 1;
3200
- }
3201
- if (!content.startsWith("export ", lineStart)) {
3202
- const nl2 = content.indexOf("\n", lineStart);
3203
- if (nl2 === -1) break;
3204
- pos = nl2 + 1;
3205
- continue;
3206
- }
3207
- const nameStart = lineStart + "export ".length;
3208
- const eqIdx = content.indexOf("=", nameStart);
3209
- if (eqIdx === -1) break;
3210
- const name = content.slice(nameStart, eqIdx).trim();
3211
- let cursor = eqIdx + 1;
3212
- if (content[cursor] !== "'") {
3213
- const nl2 = content.indexOf("\n", cursor);
3214
- pos = nl2 === -1 ? content.length : nl2 + 1;
3215
- continue;
3216
- }
3217
- cursor += 1;
3218
- let value = "";
3219
- let closed = false;
3220
- while (cursor < content.length) {
3221
- const ch = content[cursor];
3222
- if (ch === "'") {
3223
- if (content.startsWith("\\''", cursor + 1)) {
3224
- value += "'";
3225
- cursor += 4;
3226
- continue;
3227
- }
3228
- cursor += 1;
3229
- closed = true;
3230
- break;
3231
- }
3232
- value += ch;
3233
- cursor += 1;
3234
- }
3235
- if (closed && name) {
3236
- result[name] = value;
3237
- }
3238
- const nl = content.indexOf("\n", cursor);
3239
- pos = nl === -1 ? content.length : nl + 1;
3240
- }
3241
- return result;
3242
- }
3243
-
3244
- // src/media-server.ts
3245
3187
  import WebSocket2, { WebSocketServer } from "ws";
3246
3188
 
3247
3189
  // src/encoder.ts
@@ -3573,13 +3515,17 @@ function superviseEncoder(options) {
3573
3515
  };
3574
3516
  }
3575
3517
 
3576
- // src/video-encoder.ts
3577
- import { existsSync as existsSync6 } from "fs";
3578
- import { availableParallelism } from "os";
3579
- import { dirname as dirname6, resolve as resolve2 } from "path";
3580
- import { fileURLToPath } from "url";
3518
+ // src/ice-servers.ts
3519
+ import { readFileSync as readFileSync6 } from "fs";
3581
3520
 
3582
3521
  // ../shared/src/desktop-stream.ts
3522
+ function isStreamIceServer(value) {
3523
+ return isRecord(value) && typeof value.urls === "string" && (value.username === void 0 || typeof value.username === "string") && (value.credential === void 0 || typeof value.credential === "string");
3524
+ }
3525
+ var DESKTOP_ICE_SERVERS_PATH = "/v1/engine/desktop/ice-servers";
3526
+ function isDesktopIceServers(value) {
3527
+ return isRecord(value) && typeof value.expiresAt === "number" && Array.isArray(value.iceServers) && value.iceServers.every(isStreamIceServer);
3528
+ }
3583
3529
  var DEFAULT_STREAM_REQUEST = {
3584
3530
  transport: "webrtc",
3585
3531
  profile: "balanced",
@@ -3636,6 +3582,144 @@ function isDesktopWebRtcFailure(value) {
3636
3582
  ].every((key) => typeof value[key] === "string" && value[key].length <= 200) && ["elapsedMs", "candidateErrors", "framesDecoded", "packetsReceived", "packetsLost", "bytesReceived", "lastFrameAgeMs", "encoderFps"].every((key) => typeof value[key] === "number" && Number.isFinite(value[key]));
3637
3583
  }
3638
3584
 
3585
+ // ../shared/src/runtime-env.ts
3586
+ var REPLICAS_RUNTIME_ENV_ALIASES = {
3587
+ monolithUrl: ["REPLICAS_MONOLITH_URL", "MONOLITH_URL"],
3588
+ workspaceId: ["REPLICAS_WORKSPACE_ID", "WORKSPACE_ID"],
3589
+ linearSessionId: ["REPLICAS_LINEAR_SESSION_ID", "LINEAR_SESSION_ID"],
3590
+ linearAccessToken: ["REPLICAS_LINEAR_ACCESS_TOKEN", "LINEAR_ACCESS_TOKEN"],
3591
+ slackBotToken: ["REPLICAS_SLACK_BOT_TOKEN", "SLACK_BOT_TOKEN"],
3592
+ slackChannelId: ["REPLICAS_SLACK_CHANNEL_ID", "SLACK_CHANNEL_ID"],
3593
+ slackThreadTs: ["REPLICAS_SLACK_THREAD_TS", "SLACK_THREAD_TS"]
3594
+ };
3595
+ function readReplicasRuntimeEnv(readEnv, [namespacedKey, legacyKey]) {
3596
+ return readEnv(namespacedKey) ?? readEnv(legacyKey);
3597
+ }
3598
+ function parsePosixEnvFile(content) {
3599
+ const result = {};
3600
+ let pos = 0;
3601
+ while (pos < content.length) {
3602
+ let lineStart;
3603
+ if (pos === 0 || content[pos - 1] === "\n") {
3604
+ lineStart = pos;
3605
+ } else {
3606
+ const nl2 = content.indexOf("\n", pos);
3607
+ if (nl2 === -1) break;
3608
+ lineStart = nl2 + 1;
3609
+ }
3610
+ if (!content.startsWith("export ", lineStart)) {
3611
+ const nl2 = content.indexOf("\n", lineStart);
3612
+ if (nl2 === -1) break;
3613
+ pos = nl2 + 1;
3614
+ continue;
3615
+ }
3616
+ const nameStart = lineStart + "export ".length;
3617
+ const eqIdx = content.indexOf("=", nameStart);
3618
+ if (eqIdx === -1) break;
3619
+ const name = content.slice(nameStart, eqIdx).trim();
3620
+ let cursor = eqIdx + 1;
3621
+ if (content[cursor] !== "'") {
3622
+ const nl2 = content.indexOf("\n", cursor);
3623
+ pos = nl2 === -1 ? content.length : nl2 + 1;
3624
+ continue;
3625
+ }
3626
+ cursor += 1;
3627
+ let value = "";
3628
+ let closed = false;
3629
+ while (cursor < content.length) {
3630
+ const ch = content[cursor];
3631
+ if (ch === "'") {
3632
+ if (content.startsWith("\\''", cursor + 1)) {
3633
+ value += "'";
3634
+ cursor += 4;
3635
+ continue;
3636
+ }
3637
+ cursor += 1;
3638
+ closed = true;
3639
+ break;
3640
+ }
3641
+ value += ch;
3642
+ cursor += 1;
3643
+ }
3644
+ if (closed && name) {
3645
+ result[name] = value;
3646
+ }
3647
+ const nl = content.indexOf("\n", cursor);
3648
+ pos = nl === -1 ? content.length : nl + 1;
3649
+ }
3650
+ return result;
3651
+ }
3652
+
3653
+ // src/ice-servers.ts
3654
+ var runtimeEnvFile = process.env.REPLICAS_RUNTIME_ENV_FILE || "/home/user/.replicas/runtime-env.sh";
3655
+ var refreshMs = 60 * 6e4;
3656
+ var retryMs = 6e4;
3657
+ var relays = [];
3658
+ var expiresAt = 0;
3659
+ var refreshAt = 0;
3660
+ var refreshing;
3661
+ async function currentIceServers() {
3662
+ let env = process.env;
3663
+ try {
3664
+ env = { ...env, ...parsePosixEnvFile(readFileSync6(runtimeEnvFile, "utf8")) };
3665
+ } catch {
3666
+ }
3667
+ const monolithUrl = readReplicasRuntimeEnv((name) => env[name], REPLICAS_RUNTIME_ENV_ALIASES.monolithUrl);
3668
+ const workspaceId = readReplicasRuntimeEnv((name) => env[name], REPLICAS_RUNTIME_ENV_ALIASES.workspaceId);
3669
+ const engineSecret = env.REPLICAS_ENGINE_SECRET;
3670
+ if (monolithUrl && workspaceId && engineSecret && Date.now() >= refreshAt) {
3671
+ const refreshed = refreshing ??= refreshRelays(monolithUrl, workspaceId, engineSecret).finally(() => {
3672
+ refreshing = void 0;
3673
+ });
3674
+ if (expiresAt <= Date.now()) await refreshed;
3675
+ }
3676
+ return toIceServers(env.REPLICAS_DESKTOP_STREAM_ICE_SERVERS, expiresAt > Date.now() ? relays : []);
3677
+ }
3678
+ async function refreshRelays(monolithUrl, workspaceId, engineSecret) {
3679
+ try {
3680
+ const response = await fetch(new URL(DESKTOP_ICE_SERVERS_PATH, monolithUrl), {
3681
+ headers: { authorization: `Bearer ${engineSecret}`, "x-workspace-id": workspaceId },
3682
+ redirect: "error",
3683
+ signal: AbortSignal.timeout(2e3)
3684
+ });
3685
+ if (response.status === 404) {
3686
+ relays = [];
3687
+ expiresAt = 0;
3688
+ } else {
3689
+ const body = response.ok ? await response.json() : void 0;
3690
+ if (!isDesktopIceServers(body)) throw new Error(`HTTP ${response.status}`);
3691
+ ({ iceServers: relays, expiresAt } = body);
3692
+ }
3693
+ refreshAt = Date.now() + refreshMs;
3694
+ } catch (error) {
3695
+ process.stderr.write(`[desktop-stream] TURN credentials unavailable: ${error instanceof Error ? error.message : String(error)}
3696
+ `);
3697
+ refreshAt = Date.now() + retryMs;
3698
+ }
3699
+ }
3700
+ function toIceServers(spec = "stun:stun.l.google.com:19302", relayServers) {
3701
+ const browser = spec.split(",").map((url) => url.trim()).filter(Boolean).map((url) => {
3702
+ const match = url.match(/^(turns?):([^:@]+):(.+)@([^@]+)$/);
3703
+ if (!match) return { urls: url };
3704
+ const [username, credential] = match.slice(2, 4).map((value) => {
3705
+ try {
3706
+ return decodeURIComponent(value);
3707
+ } catch {
3708
+ return value;
3709
+ }
3710
+ });
3711
+ return { urls: `${match[1]}:${match[4]}`, username, credential };
3712
+ }).concat(relayServers);
3713
+ const native = browser.filter(({ urls }) => !/^turns:|[?&]transport=tcp/i.test(urls)).map(({ urls, username, credential }) => username === void 0 || credential === void 0 ? urls : urls.replace(/^turn:/i, `turn:${encodeURIComponent(username)}:${encodeURIComponent(credential)}@`));
3714
+ return { native, browser };
3715
+ }
3716
+
3717
+ // src/video-encoder.ts
3718
+ import { existsSync as existsSync6 } from "fs";
3719
+ import { availableParallelism } from "os";
3720
+ import { dirname as dirname6, resolve as resolve2 } from "path";
3721
+ import { fileURLToPath } from "url";
3722
+
3639
3723
  // src/input-validation.ts
3640
3724
  var finite = (value) => typeof value === "number" && Number.isFinite(value);
3641
3725
  var optionalFinite = (value) => value === void 0 || finite(value);
@@ -3951,22 +4035,6 @@ async function loadWebRtc() {
3951
4035
  }
3952
4036
  }
3953
4037
  var cleanupWebRtc = () => ndc?.cleanup();
3954
- function parseIceServers(spec) {
3955
- const native = (spec ?? "stun:stun.l.google.com:19302").split(",").map((server) => server.trim()).filter(Boolean);
3956
- const browser = native.map((url) => {
3957
- const match = url.match(/^(turns?):([^:@]+):(.+)@([^@]+)$/);
3958
- if (!match) return { urls: url };
3959
- const [username, credential] = match.slice(2, 4).map((value) => {
3960
- try {
3961
- return decodeURIComponent(value);
3962
- } catch {
3963
- return value;
3964
- }
3965
- });
3966
- return { urls: `${match[1]}:${match[4]}`, username, credential };
3967
- });
3968
- return { native, browser };
3969
- }
3970
4038
  var WebRtcViewer = class {
3971
4039
  constructor(options, handlers) {
3972
4040
  this.handlers = handlers;
@@ -4478,7 +4546,6 @@ var standbyRung = 5;
4478
4546
  var port = Number.parseInt(process.env.REPLICAS_DESKTOP_STREAM_PORT || String(DESKTOP_STREAM_PORT), 10);
4479
4547
  var display = process.env.REPLICAS_DESKTOP_DISPLAY?.replace(/^([^:])/, ":$1") || ":99";
4480
4548
  var webrtcEnabled = process.env.REPLICAS_DESKTOP_STREAM_WEBRTC !== "0";
4481
- var runtimeEnvFile = process.env.REPLICAS_RUNTIME_ENV_FILE || "/home/user/.replicas/runtime-env.sh";
4482
4549
  var colorMode = process.env.REPLICAS_DESKTOP_STREAM_COLOR === "legacy" ? "legacy" : "bt709";
4483
4550
  var defaultRequest = parseStreamRequest(new URLSearchParams({
4484
4551
  transport: process.env.REPLICAS_DESKTOP_STREAM_TRANSPORT ?? "",
@@ -4534,14 +4601,6 @@ function mediaPacket(kind, timestamp, payload) {
4534
4601
  header.writeBigUInt64BE(BigInt(Math.max(0, timestamp)), 4);
4535
4602
  return Buffer.concat([header, payload]);
4536
4603
  }
4537
- function currentIceServers() {
4538
- let spec = process.env.REPLICAS_DESKTOP_STREAM_ICE_SERVERS;
4539
- try {
4540
- spec = parsePosixEnvFile(readFileSync6(runtimeEnvFile, "utf8")).REPLICAS_DESKTOP_STREAM_ICE_SERVERS ?? spec;
4541
- } catch {
4542
- }
4543
- return parseIceServers(spec);
4544
- }
4545
4604
  var appliedTheme;
4546
4605
  var pendingTheme;
4547
4606
  var applyingTheme = false;
@@ -4567,7 +4626,7 @@ function drainDesktopTheme() {
4567
4626
  function processCpuMs(pid) {
4568
4627
  if (!pid) return 0;
4569
4628
  try {
4570
- const stat = readFileSync6(`/proc/${pid}/stat`, "utf8");
4629
+ const stat = readFileSync7(`/proc/${pid}/stat`, "utf8");
4571
4630
  const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
4572
4631
  return (Number(fields[11]) + Number(fields[12])) * 10;
4573
4632
  } catch {
@@ -5252,10 +5311,13 @@ async function runMediaServer() {
5252
5311
  // A socket viewer whose WebRTC attempt failed tries again in the background;
5253
5312
  // the socket keeps carrying its media until the new peer shows video.
5254
5313
  case "retry-webrtc": {
5255
- const ice = webrtc && viewer.fallback && !viewer.rtc && viewer.request.transport === "webrtc" ? currentIceServers() : void 0;
5256
- if (!ice) return;
5257
- record("retrying webrtc", "socket media continues meanwhile");
5258
- openWebRtc(viewer, reply, ice.native);
5314
+ const retryable = () => webrtc && viewer.fallback && !viewer.rtc && viewer.request.transport === "webrtc" && viewer.socket.readyState === WebSocket2.OPEN;
5315
+ if (!retryable()) return;
5316
+ void currentIceServers().then((ice) => {
5317
+ if (!retryable()) return;
5318
+ record("retrying webrtc", "socket media continues meanwhile");
5319
+ openWebRtc(viewer, reply, ice.native);
5320
+ });
5259
5321
  return;
5260
5322
  }
5261
5323
  case "webrtc-ready":
@@ -5359,10 +5421,11 @@ async function runMediaServer() {
5359
5421
  if (extname(path) === ".html") {
5360
5422
  ensureEncoders();
5361
5423
  if (viewers.size === 0) stopEncodersSoon();
5424
+ if (webrtc) void currentIceServers();
5362
5425
  response.setHeader("cache-control", "no-store");
5363
5426
  const digest = createHash3("sha1");
5364
- for (const name of readdirSync(webroot).filter((file) => /\.(js|css)$/.test(file)).sort()) digest.update(readFileSync6(resolve3(webroot, name)));
5365
- response.end(readFileSync6(path, "utf8").replaceAll("__VERSION__", `${COMPUTER_USE_VERSION}-${digest.digest("hex").slice(0, 8)}`));
5427
+ for (const name of readdirSync(webroot).filter((file) => /\.(js|css)$/.test(file)).sort()) digest.update(readFileSync7(resolve3(webroot, name)));
5428
+ response.end(readFileSync7(path, "utf8").replaceAll("__VERSION__", `${COMPUTER_USE_VERSION}-${digest.digest("hex").slice(0, 8)}`));
5366
5429
  return;
5367
5430
  }
5368
5431
  response.setHeader("cache-control", "private, max-age=31536000, immutable");
@@ -5375,9 +5438,10 @@ async function runMediaServer() {
5375
5438
  socket.destroy();
5376
5439
  return;
5377
5440
  }
5378
- webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client, parseStreamRequest(url.searchParams, defaultRequest), url.searchParams.get("standby") === "1"));
5441
+ const streamRequest = parseStreamRequest(url.searchParams, defaultRequest);
5442
+ void (webrtc && streamRequest.transport === "webrtc" ? currentIceServers() : Promise.resolve(void 0)).then((ice) => webSocketServer.handleUpgrade(request, socket, head, (client) => webSocketServer.emit("connection", client, streamRequest, url.searchParams.get("standby") === "1", ice)));
5379
5443
  });
5380
- webSocketServer.on("connection", (socket, request, standby) => {
5444
+ webSocketServer.on("connection", (socket, request, standby, ice) => {
5381
5445
  const viewer = {
5382
5446
  socket,
5383
5447
  input: new InputBridge(display, width, height),
@@ -5411,8 +5475,6 @@ async function runMediaServer() {
5411
5475
  const reply = (message) => {
5412
5476
  if (socket.readyState === WebSocket2.OPEN) socket.send(JSON.stringify(message));
5413
5477
  };
5414
- const offerWebRtc = webrtc && request.transport === "webrtc";
5415
- const ice = offerWebRtc ? currentIceServers() : void 0;
5416
5478
  const fallbackProfile = profile() === "high444" ? "main" : profile();
5417
5479
  const configMessage = { ...config, videoCodec: H264_PROFILES[fallbackProfile].codec, iceServers: ice?.browser, request };
5418
5480
  reply(configMessage);
@@ -4,6 +4,9 @@ function isRecord(value) {
4
4
  }
5
5
 
6
6
  // ../shared/src/desktop-stream.ts
7
+ function isStreamIceServer(value) {
8
+ return isRecord(value) && typeof value.urls === "string" && (value.username === void 0 || typeof value.username === "string") && (value.credential === void 0 || typeof value.credential === "string");
9
+ }
7
10
  var DEFAULT_STREAM_REQUEST = {
8
11
  transport: "webrtc",
9
12
  profile: "balanced",
@@ -46,13 +49,10 @@ var DESKTOP_WEBRTC_FAILURE_REASONS = {
46
49
  full_chroma_undecodable: "WebRTC could not decode H.264 High 4:4:4"
47
50
  };
48
51
  var DESKTOP_QUALITY_MESSAGE = "replicas-desktop-quality";
52
+ var DESKTOP_CONNECTION_MESSAGE = "replicas-desktop-connection";
49
53
 
50
54
  // src/input-validation.ts
51
55
  var finite = (value) => typeof value === "number" && Number.isFinite(value);
52
- var iceServer = (value) => {
53
- if (!isRecord(value)) return false;
54
- return typeof value.urls === "string" && (value.username === void 0 || typeof value.username === "string") && (value.credential === void 0 || typeof value.credential === "string");
55
- };
56
56
  function validServerMessage(value) {
57
57
  if (!isRecord(value) || typeof value.type !== "string") return false;
58
58
  switch (value.type) {
@@ -66,7 +66,7 @@ function validServerMessage(value) {
66
66
  case "candidate":
67
67
  return typeof value.candidate === "string" && value.candidate.length < 1024 && typeof value.mid === "string" && value.mid.length < 16;
68
68
  case "config":
69
- return finite(value.protocolVersion) && (value.serverVersion === void 0 || typeof value.serverVersion === "string") && (value.webrtcUnavailableReason === void 0 || typeof value.webrtcUnavailableReason === "string") && (value.inputRace === void 0 || typeof value.inputRace === "boolean") && finite(value.width) && finite(value.height) && finite(value.fps) && typeof value.videoCodec === "string" && typeof value.audioCodec === "string" && finite(value.audioSampleRate) && finite(value.audioChannels) && isRecord(value.request) && (value.iceServers === void 0 || Array.isArray(value.iceServers) && value.iceServers.every(iceServer));
69
+ return finite(value.protocolVersion) && (value.serverVersion === void 0 || typeof value.serverVersion === "string") && (value.webrtcUnavailableReason === void 0 || typeof value.webrtcUnavailableReason === "string") && (value.inputRace === void 0 || typeof value.inputRace === "boolean") && finite(value.width) && finite(value.height) && finite(value.fps) && typeof value.videoCodec === "string" && typeof value.audioCodec === "string" && finite(value.audioSampleRate) && finite(value.audioChannels) && isRecord(value.request) && (value.iceServers === void 0 || Array.isArray(value.iceServers) && value.iceServers.every(isStreamIceServer));
70
70
  case "telemetry":
71
71
  return isRecord(value.encoder) && isRecord(value.cpu) && isRecord(value.viewer) && Array.isArray(value.events);
72
72
  default:
@@ -103,6 +103,7 @@ var webrtcFirstFrameMs = 3e3;
103
103
  var webrtcRetryFirstMs = 15e3;
104
104
  var webrtcRetryMaxMs = 3e5;
105
105
  var webrtcRetryTimeoutMs = 1e4;
106
+ var hiddenDisconnectMs = 6e4;
106
107
  var webrtcSkipsAfterFailure = 3;
107
108
  var pointerIntervalMs = 4;
108
109
  var qualityWindowMs = 3e4;
@@ -114,8 +115,10 @@ try {
114
115
  } catch {
115
116
  }
116
117
  var socket;
117
- var standby = innerWidth === 0 || innerHeight === 0;
118
+ var intersecting = innerWidth > 0 && innerHeight > 0;
119
+ var standby = !intersecting;
118
120
  var standbyTimer;
121
+ var hiddenTimer;
119
122
  var config;
120
123
  var transport;
121
124
  var transportReason = "";
@@ -172,6 +175,24 @@ var restartQuality = () => {
172
175
  qualitySequence = 0;
173
176
  presentedAt = 0;
174
177
  };
178
+ var newConnectionTotals = () => ({
179
+ sampledAt: 0,
180
+ presented: 0,
181
+ shownMs: 0,
182
+ frames: 0,
183
+ samples: 0,
184
+ rung: 0,
185
+ worstRung: 0,
186
+ cpu: 0,
187
+ rtt: 0,
188
+ rttSamples: 0,
189
+ worstRtt: 0,
190
+ loss: 0,
191
+ lossSamples: 0,
192
+ longestGapMs: 0,
193
+ reportedAt: 0
194
+ });
195
+ var connectionTotals = newConnectionTotals();
175
196
  var state = {};
176
197
  globalThis.__replicasDesktop = state;
177
198
  var viewerEvents = [];
@@ -258,6 +279,31 @@ var announceTransport = () => {
258
279
  if (window.parent === window) return;
259
280
  window.parent.postMessage({ type: DESKTOP_TRANSPORT_MESSAGE, transport: transport ?? "connecting", reason: transportReason, path: route.path, codec: route.codec, failure, recovery }, "*");
260
281
  };
282
+ var average = (sum, count) => count ? Math.round(sum * 10 / count) / 10 : void 0;
283
+ var reportConnection = (ended) => {
284
+ if (window.parent === window || !config) return;
285
+ const totals = connectionTotals;
286
+ totals.reportedAt = performance.now();
287
+ window.parent.postMessage({
288
+ type: DESKTOP_CONNECTION_MESSAGE,
289
+ serverVersion: config.serverVersion ?? "unknown",
290
+ firstFrameMs: totals.firstFrameAt === void 0 ? void 0 : Math.round(totals.firstFrameAt - connectionStartedAt),
291
+ shownMs: Math.round(totals.shownMs),
292
+ displayedFps: totals.shownMs >= 1e3 ? Math.round(totals.frames * 1e4 / totals.shownMs) / 10 : void 0,
293
+ inputRttMs: average(totals.rtt, totals.rttSamples),
294
+ worstInputRttMs: totals.rttSamples ? Math.round(totals.worstRtt) : void 0,
295
+ qualityRung: average(totals.rung, totals.samples),
296
+ worstQualityRung: totals.samples ? totals.worstRung : void 0,
297
+ longestFrameGapMs: totals.samples ? Math.round(totals.longestGapMs) : void 0,
298
+ lossPercent: average(totals.loss, totals.lossSamples),
299
+ cpuLoadPercent: average(totals.cpu, totals.samples),
300
+ ended
301
+ }, "*");
302
+ };
303
+ var endConnection = (ended) => {
304
+ if (ended || connectionTotals.sampledAt) reportConnection(ended);
305
+ connectionTotals = newConnectionTotals();
306
+ };
261
307
  var closePeer = (preserveDeadline = false) => {
262
308
  cancelRetry();
263
309
  if (!preserveDeadline) {
@@ -311,9 +357,11 @@ var configureVideo = async (next, preferHardware = true) => {
311
357
  if (configuration !== videoConfiguration) return;
312
358
  const selectedConfig = support.supported && support.config ? support.config : decoderConfig(next);
313
359
  activeVideoConfig = selectedConfig;
360
+ const totals = connectionTotals;
314
361
  const decoder = new VideoDecoder({
315
362
  output: (frame) => {
316
363
  socketDecodedFrames += 1;
364
+ totals.firstFrameAt ??= performance.now();
317
365
  const writer = videoWriter;
318
366
  if (!writer || (writer.desiredSize ?? 1) <= 0) {
319
367
  frame.close();
@@ -777,6 +825,7 @@ var handleServerText = (data, viaSocket) => {
777
825
  };
778
826
  var connect = () => {
779
827
  if (document.visibilityState !== "visible") return;
828
+ endConnection();
780
829
  clearTimeout(reconnectTimer);
781
830
  closePeer();
782
831
  transport = void 0;
@@ -819,9 +868,12 @@ var connect = () => {
819
868
  });
820
869
  nextSocket.addEventListener("close", () => {
821
870
  if (socket !== nextSocket) return;
871
+ endConnection();
822
872
  closePeer();
823
- transport = void 0;
824
- announceTransport();
873
+ if (transport) {
874
+ transport = void 0;
875
+ announceTransport();
876
+ }
825
877
  reconnectAttempt += 1;
826
878
  const delay = Math.min(8e3, 250 * 2 ** Math.min(reconnectAttempt, 5)) * (0.85 + Math.random() * 0.3);
827
879
  note("socket closed", `reconnect in ${Math.round(delay)} ms`);
@@ -856,6 +908,8 @@ var observeFrame = () => {
856
908
  lastPresentedCount = metadata.presentedFrames;
857
909
  lastFrameAt = performance.now();
858
910
  if (presentedAt && qualityWindow) qualityWindow.longestGapMs = Math.max(qualityWindow.longestGapMs, lastFrameAt - Math.max(presentedAt, qualityWindow.at));
911
+ const { shownSince } = connectionTotals;
912
+ if (presentedAt && shownSince !== void 0) connectionTotals.longestGapMs = Math.max(connectionTotals.longestGapMs, lastFrameAt - Math.max(presentedAt, shownSince));
859
913
  presentedAt = lastFrameAt;
860
914
  status.classList.remove("visible");
861
915
  observeFrame();
@@ -910,22 +964,32 @@ var setStandby = (next) => {
910
964
  sendSocket({ type: "standby", standby });
911
965
  if (!standby && !audioPrompt.hidden) unlockAudio();
912
966
  };
913
- new IntersectionObserver(([entry]) => {
914
- if (entry.isIntersecting) setStandby(false);
967
+ var syncStandby = () => {
968
+ if (intersecting && document.visibilityState === "visible") setStandby(false);
915
969
  else if (!standby) {
916
970
  clearTimeout(standbyTimer);
917
971
  standbyTimer = setTimeout(() => setStandby(true), 1e3);
918
972
  }
973
+ };
974
+ new IntersectionObserver(([entry]) => {
975
+ intersecting = entry.isIntersecting;
976
+ syncStandby();
919
977
  }).observe(document.documentElement);
920
978
  window.addEventListener("online", connect);
921
979
  document.addEventListener("visibilitychange", () => {
980
+ clearTimeout(hiddenTimer);
981
+ syncStandby();
922
982
  if (document.visibilityState === "hidden") {
923
- disconnect();
983
+ hiddenTimer = setTimeout(() => {
984
+ endConnection("hidden");
985
+ disconnect();
986
+ }, hiddenDisconnectMs);
924
987
  return;
925
988
  }
926
- connect();
989
+ if (!socket || socket.readyState >= WebSocket.CLOSING) connect();
927
990
  });
928
991
  window.addEventListener("pagehide", () => {
992
+ endConnection();
929
993
  disconnect();
930
994
  screen.cancelVideoFrameCallback(frameCallback);
931
995
  videoDecoder?.close();
@@ -964,6 +1028,32 @@ var updateDiagnostics = (stats) => {
964
1028
  viewerEvents.length ? `viewer events: ${describeEvents(viewerEvents.slice(-5))}` : ""
965
1029
  ].filter(Boolean).join("\n");
966
1030
  };
1031
+ var sampleConnection = (stats, current, now) => {
1032
+ const totals = connectionTotals;
1033
+ const shown = !standby && document.visibilityState === "visible" && qualityUnseen.samples < 4;
1034
+ if (shown && totals.sampledAt) {
1035
+ totals.shownSince ??= totals.sampledAt;
1036
+ totals.shownMs += now - totals.sampledAt;
1037
+ totals.frames += presentedFrames - totals.presented;
1038
+ totals.samples += 1;
1039
+ totals.rung += current.encoder.rung;
1040
+ totals.worstRung = Math.max(totals.worstRung, current.encoder.rung);
1041
+ totals.cpu += current.cpu.load * 100 / current.cpu.cores;
1042
+ if (stats.rttMs > 0) {
1043
+ totals.rtt += stats.rttMs;
1044
+ totals.rttSamples += 1;
1045
+ totals.worstRtt = Math.max(totals.worstRtt, stats.rttMs);
1046
+ }
1047
+ if (stats.lossPercent !== void 0) {
1048
+ totals.loss += stats.lossPercent;
1049
+ totals.lossSamples += 1;
1050
+ }
1051
+ totals.longestGapMs = Math.max(totals.longestGapMs, now - Math.max(presentedAt, totals.shownSince));
1052
+ } else if (!shown) totals.shownSince = void 0;
1053
+ totals.sampledAt = now;
1054
+ totals.presented = presentedFrames;
1055
+ if (now - totals.reportedAt >= 2e3) reportConnection();
1056
+ };
967
1057
  var reportQuality = (stats) => {
968
1058
  if (window.parent === window || !transport || !config || !telemetry) return;
969
1059
  const now = performance.now();
@@ -971,6 +1061,7 @@ var reportQuality = (stats) => {
971
1061
  const decoded = transport === "webrtc" ? inbound?.framesDecoded ?? 0 : socketDecodedFrames;
972
1062
  qualityUnseen = { decoded, presented: presentedFrames, samples: decoded > qualityUnseen.decoded && presentedFrames === qualityUnseen.presented ? qualityUnseen.samples + 1 : 0 };
973
1063
  if (qualityUnseen.samples >= 4) current.unseen = true;
1064
+ sampleConnection(stats, telemetry, now);
974
1065
  qualitySamples.push({ stats, telemetry });
975
1066
  const windowMs = now - current.at;
976
1067
  if (windowMs < qualityWindowMs) return;
@@ -1049,6 +1140,7 @@ var reportWebRtcStats = async (pc, stats) => {
1049
1140
  };
1050
1141
  if (counters.framesDecoded > (inbound?.framesDecoded ?? 0)) {
1051
1142
  lastFrameAt = now;
1143
+ connectionTotals.firstFrameAt ??= now;
1052
1144
  clearTimeout(webrtcDeadline);
1053
1145
  webrtcDeadline = void 0;
1054
1146
  if (fullChromaFailure) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replicas-computer",
3
- "version": "0.3.15",
3
+ "version": "0.3.17",
4
4
  "description": "Replicas workspace computer-use runtime",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",