@stim-cli/server 1.10.0 → 1.10.1

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
@@ -22,7 +22,12 @@ stim-server devices revoke <id> # revoke a paired device
22
22
 
23
23
  `GET http://127.0.0.1:7787/health` answers requests from this Mac with the
24
24
  server's name, versions, protocol, `stimHome`, and the Tailscale state it
25
- started with. Stim Desktop uses it to find a running server. A request through
25
+ started with. While Tailscale runs, it also carries `route`, read from
26
+ `tailscale serve status --json` on each request: `routed` with the HTTPS
27
+ `port` that proxies to the server, `funneled` with the Funnel `ports` that do,
28
+ `missing`, or `unknown` with a `reason`; the last three carry the `port` the
29
+ setup command would use. Stim Desktop uses it to find a running server and
30
+ show its route. A request through
26
31
  `tailscale serve`, on a Tailscale address, or with a `Host` other than
27
32
  `127.0.0.1` or `localhost` gets HTTP 426, like any other plain HTTP request.
28
33
 
@@ -40,12 +45,23 @@ same tailnet or with the Mac shared to the phone's user.
40
45
 
41
46
  `stim-server` listens only on `127.0.0.1` and on the Mac's Tailscale
42
47
  addresses, never on every interface. Run this once so clients can use
43
- `wss://<mac>.<tailnet>.ts.net` with a valid certificate:
48
+ `wss://<mac>.<tailnet>.ts.net:7443` with a valid certificate:
44
49
 
45
50
  ```bash
46
- tailscale serve --bg http://127.0.0.1:7787
51
+ tailscale serve --bg --https=7443 http://127.0.0.1:7787
47
52
  ```
48
53
 
54
+ The dedicated port 7443 keeps the server tailnet only and leaves port 443 to
55
+ other apps. Do not serve `stim-server` on a port where Tailscale Funnel is on:
56
+ Funnel makes every handler on that port reachable from the public internet.
57
+
58
+ `stim-server` reads `tailscale serve status --json` at start and on every
59
+ `pair`. It uses the HTTPS port whose `/` handler proxies to its loopback port,
60
+ preferring 7443. Without such a route, it assumes 7443 and prints the command
61
+ above, or the next free port when 7443 is taken. It never suggests a Funnel
62
+ port. Any handler, at any path, or TCP forward that reaches the server on a
63
+ Funnel port makes it public, and `pair` refuses.
64
+
49
65
  When Tailscale is not running, `stim-server` listens on loopback only and
50
66
  says so on stderr. Start Tailscale, then restart `stim-server`.
51
67
 
@@ -54,9 +70,13 @@ says so on stderr. Start Tailscale, then restart `stim-server`.
54
70
  `stim-server pair` prints the JSON that the pairing QR code encodes:
55
71
 
56
72
  ```json
57
- { "v": 1, "name": "Janic's MacBook Pro", "endpoint": "wss://janics-mbp.tail1234.ts.net", "pairingToken": "..." }
73
+ { "v": 1, "name": "Janic's MacBook Pro", "endpoint": "wss://janics-mbp.tail1234.ts.net:7443", "pairingToken": "..." }
58
74
  ```
59
75
 
76
+ The endpoint names the route's port, and omits it for 443. When a route to
77
+ the server is on a port with Funnel on, `pair` refuses and exits 1 without
78
+ creating a token.
79
+
60
80
  The pairing token works once and expires after 5 minutes. A client spends it in
61
81
  `hello` and receives a random device token, which it presents on every later
62
82
  connection. The server stores only the SHA-256 hash of each token.
@@ -366,6 +366,20 @@ function protocolJsonSchema() {
366
366
  }
367
367
  }
368
368
  },
369
+ {
370
+ type: "object",
371
+ required: [
372
+ "event",
373
+ "subscription",
374
+ "delayed"
375
+ ],
376
+ additionalProperties: false,
377
+ properties: {
378
+ event: { const: "frame-delayed" },
379
+ subscription: { type: "string" },
380
+ delayed: { type: "boolean" }
381
+ }
382
+ },
369
383
  {
370
384
  type: "object",
371
385
  required: [
@@ -178,10 +178,19 @@ interface FrameEvent {
178
178
  /** Base64-encoded image bytes. */
179
179
  data: string;
180
180
  }
181
- type ServerEvent = StatusEvent | LogsEvent | FrameEvent | ErrorEvent;
181
+ /**
182
+ * Captures for a `frames.subscribe` subscription are slow or a timed-out capture is being retried; the
183
+ * client keeps showing its last frame. Followed by `delayed: false` once captures recover.
184
+ */
185
+ interface FrameDelayedEvent {
186
+ event: "frame-delayed";
187
+ subscription: string;
188
+ delayed: boolean;
189
+ }
190
+ type ServerEvent = StatusEvent | LogsEvent | FrameEvent | FrameDelayedEvent | ErrorEvent;
182
191
  type ServerMessage = ServerResponse | ServerEvent;
183
192
  type JsonSchema = Record<string, unknown>;
184
193
  /** The JSON Schema of every message on the wire, written to `dist/protocol.schema.json` at build time. */
185
194
  declare function protocolJsonSchema(): JsonSchema;
186
195
  //#endregion
187
- export { CAPABILITIES, Capability, ClientRequest, DeviceAuth, ERROR_CODES, ErrorCode, ErrorEvent, FrameEvent, FrameTarget, HelloParams, HelloResult, LOG_LEVELS, LOG_SOURCES, LogFilter, LogLevel, LogRecord, LogSource, LogsEvent, LogsQueryResult, MAX_LOG_TAIL, METHODS, Method, Methods, PROTOCOL_SCHEMA_FILE, PROTOCOL_VERSION, PairingAuth, Platform, ProtocolError, RequestId, ServerEvent, ServerMessage, ServerResponse, SettingsResult, StatsResult, StatusEvent, SubscribeResult, UnsubscribeParams, WorkspaceParams, protocolJsonSchema };
196
+ export { CAPABILITIES, Capability, ClientRequest, DeviceAuth, ERROR_CODES, ErrorCode, ErrorEvent, FrameDelayedEvent, FrameEvent, FrameTarget, HelloParams, HelloResult, LOG_LEVELS, LOG_SOURCES, LogFilter, LogLevel, LogRecord, LogSource, LogsEvent, LogsQueryResult, MAX_LOG_TAIL, METHODS, Method, Methods, PROTOCOL_SCHEMA_FILE, PROTOCOL_VERSION, PairingAuth, Platform, ProtocolError, RequestId, ServerEvent, ServerMessage, ServerResponse, SettingsResult, StatsResult, StatusEvent, SubscribeResult, UnsubscribeParams, WorkspaceParams, protocolJsonSchema };
package/dist/protocol.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { a as MAX_LOG_TAIL, c as PROTOCOL_VERSION, i as LOG_SOURCES, l as protocolJsonSchema, n as ERROR_CODES, o as METHODS, r as LOG_LEVELS, s as PROTOCOL_SCHEMA_FILE, t as CAPABILITIES } from "./protocol-dn2_TUeH.mjs";
1
+ import { a as MAX_LOG_TAIL, c as PROTOCOL_VERSION, i as LOG_SOURCES, l as protocolJsonSchema, n as ERROR_CODES, o as METHODS, r as LOG_LEVELS, s as PROTOCOL_SCHEMA_FILE, t as CAPABILITIES } from "./protocol-B3k9Ykuu.mjs";
2
2
  export { CAPABILITIES, ERROR_CODES, LOG_LEVELS, LOG_SOURCES, MAX_LOG_TAIL, METHODS, PROTOCOL_SCHEMA_FILE, PROTOCOL_VERSION, protocolJsonSchema };
@@ -632,6 +632,26 @@
632
632
  }
633
633
  }
634
634
  },
635
+ {
636
+ "type": "object",
637
+ "required": [
638
+ "event",
639
+ "subscription",
640
+ "delayed"
641
+ ],
642
+ "additionalProperties": false,
643
+ "properties": {
644
+ "event": {
645
+ "const": "frame-delayed"
646
+ },
647
+ "subscription": {
648
+ "type": "string"
649
+ },
650
+ "delayed": {
651
+ "type": "boolean"
652
+ }
653
+ }
654
+ },
635
655
  {
636
656
  "type": "object",
637
657
  "required": [
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { a as MAX_LOG_TAIL, i as LOG_SOURCES, r as LOG_LEVELS, t as CAPABILITIES } from "./protocol-dn2_TUeH.mjs";
2
+ import { a as MAX_LOG_TAIL, i as LOG_SOURCES, r as LOG_LEVELS, t as CAPABILITIES } from "./protocol-B3k9Ykuu.mjs";
3
3
  import { createRequire } from "node:module";
4
4
  import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, watch, writeFileSync } from "node:fs";
5
5
  import { homedir, hostname, tmpdir } from "node:os";
@@ -407,10 +407,15 @@ var FeedPool = class {
407
407
  };
408
408
  //#endregion
409
409
  //#region src/frames.ts
410
+ const DEFAULT_FRAME_LIMITS = {
411
+ toolTimeoutMs: 3e4,
412
+ slowCaptureMs: 3e3,
413
+ failureBackoffMs: 3e3,
414
+ maxConsecutiveFailures: 3
415
+ };
410
416
  const MIN_INTERVAL_MS = 200;
411
417
  const MAX_INTERVAL_MS = 1e3;
412
418
  const MAX_CAPTURES = 2;
413
- const TOOL_TIMEOUT_MS = 1e4;
414
419
  const MAX_EDGE = 1280;
415
420
  const JPEG_QUALITY = 70;
416
421
  function deviceKey(device) {
@@ -469,7 +474,14 @@ function jpegSize(bytes) {
469
474
  }
470
475
  return null;
471
476
  }
472
- function runTool(file, args, env, running) {
477
+ /** A rejection with `transient: true` is a timeout on a machine that is merely busy, not a broken capturer. */
478
+ function timeoutError(message) {
479
+ return Object.assign(new Error(message), { transient: true });
480
+ }
481
+ function isTransient(error) {
482
+ return error instanceof Error && error.transient === true;
483
+ }
484
+ function runTool(file, args, env, running, timeoutMs) {
473
485
  return new Promise((resolve, reject) => {
474
486
  const child = spawn(file, args, {
475
487
  env,
@@ -485,8 +497,8 @@ function runTool(file, args, env, running) {
485
497
  let stderr = "";
486
498
  const timer = setTimeout(() => {
487
499
  terminate(child);
488
- reject(/* @__PURE__ */ new Error(`${file} ${args[0]} did not finish within ${TOOL_TIMEOUT_MS / 1e3} s.`));
489
- }, TOOL_TIMEOUT_MS);
500
+ reject(timeoutError(`${file} ${args[0]} did not finish within ${timeoutMs / 1e3} s.`));
501
+ }, timeoutMs);
490
502
  child.stdout.on("data", (chunk) => chunks.push(chunk));
491
503
  child.stderr.setEncoding("utf8");
492
504
  child.stderr.on("data", (chunk) => {
@@ -515,7 +527,7 @@ async function stopTools(running, tmp) {
515
527
  * can be the unlit one and comes back black; `primary` is the panel CoreDevice reports as primary. A simctl
516
528
  * that rejects `primary` gets the default display instead.
517
529
  */
518
- function simulatorCapturer(udid, env) {
530
+ function simulatorCapturer(udid, env, limits) {
519
531
  let tmp = null;
520
532
  let display = ["--display=primary"];
521
533
  let closed = false;
@@ -528,7 +540,7 @@ function simulatorCapturer(udid, env) {
528
540
  "--type=jpeg",
529
541
  ...display,
530
542
  output
531
- ], env, running);
543
+ ], env, running, limits.toolTimeoutMs);
532
544
  return {
533
545
  capture: async () => {
534
546
  tmp ??= mkdtempSync(join(serverDir(), "frames-"));
@@ -654,7 +666,7 @@ function screenshotImage(body) {
654
666
  for (const entry of protoFields(message)) if (entry.field === 4 && "bytes" in entry) return Buffer.from(entry.bytes);
655
667
  throw new Error("getScreenshot returned no image.");
656
668
  }
657
- function emulatorCapturer(serial, env) {
669
+ function emulatorCapturer(serial, env, limits) {
658
670
  let session = null;
659
671
  let tmp = null;
660
672
  const running = /* @__PURE__ */ new Set();
@@ -673,18 +685,26 @@ function emulatorCapturer(serial, env) {
673
685
  const chunks = [];
674
686
  let status;
675
687
  let message;
688
+ let timedOut = false;
676
689
  const record = (headers) => {
677
690
  if (headers["grpc-status"] !== void 0) status = String(headers["grpc-status"]);
678
691
  if (headers["grpc-message"] !== void 0) message = String(headers["grpc-message"]);
679
692
  };
680
- request.setTimeout(TOOL_TIMEOUT_MS, () => request.close());
693
+ request.setTimeout(limits.toolTimeoutMs, () => {
694
+ timedOut = true;
695
+ request.close();
696
+ });
681
697
  request.on("response", record);
682
698
  request.on("trailers", record);
683
699
  request.on("data", (chunk) => chunks.push(chunk));
684
- request.on("error", (error) => reject(/* @__PURE__ */ new Error(`getScreenshot failed on ${serial}: ${error.message}`)));
700
+ request.on("error", (error) => {
701
+ const text = `getScreenshot failed on ${serial}: ${error.message}`;
702
+ reject(timedOut ? timeoutError(text) : new Error(text));
703
+ });
685
704
  request.on("close", () => {
686
705
  if (status === "0") return resolve(Buffer.concat(chunks));
687
- reject(/* @__PURE__ */ new Error(`getScreenshot failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`));
706
+ const text = `getScreenshot failed on ${serial}: ${message ?? `status ${status ?? "missing"}`}`;
707
+ reject(timedOut ? timeoutError(text) : new Error(text));
688
708
  });
689
709
  request.end(screenshotRequest());
690
710
  });
@@ -712,7 +732,7 @@ function emulatorCapturer(serial, env) {
712
732
  input,
713
733
  "--out",
714
734
  output
715
- ], env, running);
735
+ ], env, running, limits.toolTimeoutMs);
716
736
  return readFileSync(output);
717
737
  }
718
738
  };
@@ -749,18 +769,23 @@ var FrameSource = class {
749
769
  interval = MIN_INTERVAL_MS;
750
770
  timer = null;
751
771
  stopped = false;
772
+ delayed = false;
773
+ consecutiveFailures = 0;
752
774
  capturer;
753
775
  limiter;
776
+ limits;
754
777
  ended;
755
- constructor(capturer, limiter, ended) {
778
+ constructor(capturer, limiter, limits, ended) {
756
779
  this.capturer = capturer;
757
780
  this.limiter = limiter;
781
+ this.limits = limits;
758
782
  this.ended = ended;
759
783
  this.tick();
760
784
  }
761
785
  add(listener) {
762
786
  this.listeners.add(listener);
763
787
  if (this.last) listener.frame(this.last);
788
+ if (this.delayed) listener.delayed(true);
764
789
  return () => {
765
790
  if (this.listeners.delete(listener) && this.listeners.size === 0) this.stop();
766
791
  };
@@ -773,6 +798,11 @@ var FrameSource = class {
773
798
  this.ended();
774
799
  return this.capturer.close();
775
800
  }
801
+ setDelayed(delayed) {
802
+ if (this.delayed === delayed) return;
803
+ this.delayed = delayed;
804
+ for (const listener of this.listeners) listener.delayed(delayed);
805
+ }
776
806
  async tick() {
777
807
  this.timer = null;
778
808
  let took = 0;
@@ -797,11 +827,19 @@ var FrameSource = class {
797
827
  return true;
798
828
  });
799
829
  if (this.stopped) return;
830
+ this.consecutiveFailures = 0;
831
+ this.setDelayed(took > this.limits.slowCaptureMs);
800
832
  if (changed) for (const listener of this.listeners) listener.frame(this.last);
801
833
  this.interval = changed ? MIN_INTERVAL_MS : Math.min(this.interval * 2, MAX_INTERVAL_MS);
802
834
  this.timer = setTimeout(() => void this.tick(), Math.max(this.interval - took, took));
803
835
  } catch (error) {
804
836
  if (this.stopped) return;
837
+ if (isTransient(error) && this.consecutiveFailures + 1 < this.limits.maxConsecutiveFailures) {
838
+ this.consecutiveFailures++;
839
+ this.setDelayed(true);
840
+ this.timer = setTimeout(() => void this.tick(), this.limits.failureBackoffMs);
841
+ return;
842
+ }
805
843
  const listeners = [...this.listeners];
806
844
  this.stop();
807
845
  for (const listener of listeners) listener.failed(error.message);
@@ -818,14 +856,16 @@ var FramePool = class {
818
856
  sources = /* @__PURE__ */ new Map();
819
857
  limiter = new Limiter(MAX_CAPTURES);
820
858
  env;
821
- constructor(env) {
859
+ limits;
860
+ constructor(env, limits = DEFAULT_FRAME_LIMITS) {
822
861
  this.env = env;
862
+ this.limits = limits;
823
863
  }
824
864
  subscribe(device, listener) {
825
865
  const key = deviceKey(device);
826
866
  let source = this.sources.get(key);
827
867
  if (!source) {
828
- const created = new FrameSource(device.platform === "ios" ? simulatorCapturer(device.udid, this.env) : emulatorCapturer(device.serial, this.env), this.limiter, () => {
868
+ const created = new FrameSource(device.platform === "ios" ? simulatorCapturer(device.udid, this.env, this.limits) : emulatorCapturer(device.serial, this.env, this.limits), this.limiter, this.limits, () => {
829
869
  if (this.sources.get(key) === created) this.sources.delete(key);
830
870
  });
831
871
  this.sources.set(key, created);
@@ -914,6 +954,8 @@ var LogBatcher = class {
914
954
  //#region src/tailscale.ts
915
955
  const MAC_APP_BINARY = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
916
956
  const TIMEOUT_MS = 3e3;
957
+ /** The tailnet-only HTTPS port the setup command serves stim-server on. */
958
+ const SERVE_PORT = 7443;
917
959
  function executable(path) {
918
960
  try {
919
961
  accessSync(path, constants.X_OK);
@@ -967,6 +1009,124 @@ function tailscaleStatus(binary, env) {
967
1009
  };
968
1010
  }
969
1011
  }
1012
+ function reaches(address, target, hosts) {
1013
+ if (typeof address !== "string") return null;
1014
+ let url;
1015
+ try {
1016
+ url = new URL(address.includes("://") ? address : `tcp://${address}`);
1017
+ } catch {
1018
+ return null;
1019
+ }
1020
+ return hosts.has(url.hostname) && url.port === String(target) ? url : null;
1021
+ }
1022
+ function portOf(hostPort) {
1023
+ return Number(hostPort.slice(hostPort.lastIndexOf(":") + 1));
1024
+ }
1025
+ /**
1026
+ * Reads `tailscale serve status --json`, an ipn.ServeConfig: `TCP` maps a port to `{ HTTPS }` or
1027
+ * `{ TCPForward }`, `Web` maps `<host>:<port>` to `{ Handlers: { <mount>: { Proxy } } }`,
1028
+ * `AllowFunnel` maps `<host>:<port>` to true, and `Foreground` holds the same shape per
1029
+ * foreground session. Any handler or TCP forward that reaches the server on a Funnel port exposes
1030
+ * it; only a `/` HTTP proxy on an HTTPS port is a route a client can use.
1031
+ */
1032
+ function parseServeStatus(value, target, ips) {
1033
+ if (!isJsonObject(value)) return {
1034
+ state: "unknown",
1035
+ reason: "it printed no serve config",
1036
+ port: SERVE_PORT
1037
+ };
1038
+ const hosts = /* @__PURE__ */ new Set([
1039
+ "127.0.0.1",
1040
+ "localhost",
1041
+ "[::1]",
1042
+ "0.0.0.0",
1043
+ "[::]",
1044
+ ...ips.map((ip) => ip.includes(":") ? `[${ip}]` : ip)
1045
+ ]);
1046
+ const configs = [value, ...isJsonObject(value.Foreground) ? Object.values(value.Foreground) : []].filter(isJsonObject);
1047
+ const used = /* @__PURE__ */ new Set();
1048
+ const funneled = /* @__PURE__ */ new Set();
1049
+ const reaching = /* @__PURE__ */ new Set();
1050
+ const routes = /* @__PURE__ */ new Set();
1051
+ for (const config of configs) {
1052
+ const tcp = isJsonObject(config.TCP) ? config.TCP : {};
1053
+ for (const [port, listener] of Object.entries(tcp)) {
1054
+ used.add(Number(port));
1055
+ if (isJsonObject(listener) && reaches(listener.TCPForward, target, hosts)) reaching.add(Number(port));
1056
+ }
1057
+ for (const [hostPort, allowed] of Object.entries(isJsonObject(config.AllowFunnel) ? config.AllowFunnel : {})) if (allowed === true) funneled.add(portOf(hostPort));
1058
+ for (const [hostPort, web] of Object.entries(isJsonObject(config.Web) ? config.Web : {})) {
1059
+ const port = portOf(hostPort);
1060
+ const listener = tcp[String(port)];
1061
+ const https = isJsonObject(listener) && listener.HTTPS === true;
1062
+ const handlers = isJsonObject(web) && isJsonObject(web.Handlers) ? web.Handlers : {};
1063
+ for (const [mount, handler] of Object.entries(handlers)) {
1064
+ const url = isJsonObject(handler) ? reaches(handler.Proxy, target, hosts) : null;
1065
+ if (!url) continue;
1066
+ reaching.add(port);
1067
+ if (https && mount === "/" && url.protocol === "http:" && url.pathname === "/") routes.add(port);
1068
+ }
1069
+ }
1070
+ }
1071
+ let free = SERVE_PORT;
1072
+ while (used.has(free) || funneled.has(free)) free++;
1073
+ const exposed = [...reaching].filter((port) => funneled.has(port)).toSorted((a, b) => a - b);
1074
+ if (exposed.length) return {
1075
+ state: "funneled",
1076
+ ports: exposed,
1077
+ port: free
1078
+ };
1079
+ const tailnet = [...routes].toSorted((a, b) => a - b);
1080
+ if (tailnet.length) return {
1081
+ state: "routed",
1082
+ port: tailnet.includes(SERVE_PORT) ? SERVE_PORT : tailnet[0]
1083
+ };
1084
+ return {
1085
+ state: "missing",
1086
+ port: free
1087
+ };
1088
+ }
1089
+ function serveRoute(binary, env, target, ips, timeoutMs = TIMEOUT_MS) {
1090
+ if (!binary) return Promise.resolve({
1091
+ state: "unknown",
1092
+ reason: "the tailscale command was not found",
1093
+ port: SERVE_PORT
1094
+ });
1095
+ return new Promise((resolve) => {
1096
+ execFile(binary, [
1097
+ "serve",
1098
+ "status",
1099
+ "--json"
1100
+ ], {
1101
+ env,
1102
+ timeout: timeoutMs,
1103
+ killSignal: "SIGKILL",
1104
+ encoding: "utf8"
1105
+ }, (error, stdout) => {
1106
+ let route;
1107
+ try {
1108
+ route = error ? {
1109
+ state: "unknown",
1110
+ reason: error.killed ? "it timed out" : error.message.split("\n")[0],
1111
+ port: SERVE_PORT
1112
+ } : parseServeStatus(stdout.trim() ? JSON.parse(stdout) : {}, target, ips);
1113
+ } catch {
1114
+ route = {
1115
+ state: "unknown",
1116
+ reason: "it printed output that is not JSON",
1117
+ port: SERVE_PORT
1118
+ };
1119
+ }
1120
+ resolve(route);
1121
+ });
1122
+ });
1123
+ }
1124
+ function tailnetEndpoint(dnsName, port) {
1125
+ return port === 443 ? `wss://${dnsName}` : `wss://${dnsName}:${port}`;
1126
+ }
1127
+ function serveCommand(port, target) {
1128
+ return `tailscale serve --bg --https=${port} http://127.0.0.1:${target}`;
1129
+ }
970
1130
  /** Reads `tailscale whois --json`: the peer's node (`Node.StableID`, `Node.Name`) and user (`UserProfile.LoginName`). */
971
1131
  function parseWhois(value) {
972
1132
  if (!isJsonObject(value) || !isJsonObject(value.Node)) return null;
@@ -1044,6 +1204,7 @@ const STATUS_FEED = {
1044
1204
  keep: 1,
1045
1205
  label: "stim status --watch"
1046
1206
  };
1207
+ const HEALTH_ROUTE_TIMEOUT_MS = 1e3;
1047
1208
  const COMMAND_LIMITS = {
1048
1209
  timeoutMs: 6e4,
1049
1210
  maxOutputBytes: 33554432
@@ -1110,7 +1271,11 @@ async function startServer(options) {
1110
1271
  const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
1111
1272
  const authTimeoutMs = options.authTimeoutMs ?? 5e3;
1112
1273
  const feeds = new FeedPool(options.stimCli, options.env);
1113
- const frames = new FramePool(options.env);
1274
+ const frameLimits = {
1275
+ ...DEFAULT_FRAME_LIMITS,
1276
+ ...options.frameLimits
1277
+ };
1278
+ const frames = new FramePool(options.env, frameLimits);
1114
1279
  const running = /* @__PURE__ */ new Set();
1115
1280
  const logLimits = {
1116
1281
  ...LOG_LIMITS,
@@ -1389,6 +1554,13 @@ async function startServer(options) {
1389
1554
  pending = frame;
1390
1555
  if (!retry) flush();
1391
1556
  },
1557
+ delayed: (delayed) => {
1558
+ if (!ended) send(socket, {
1559
+ event: "frame-delayed",
1560
+ subscription,
1561
+ delayed
1562
+ });
1563
+ },
1392
1564
  failed: end
1393
1565
  };
1394
1566
  let unsubscribeStatus = null;
@@ -1529,6 +1701,14 @@ async function startServer(options) {
1529
1701
  stimHome: configDir(),
1530
1702
  tailscale: healthTailscale(options.tailscaleState)
1531
1703
  };
1704
+ const answerHealth = async (response) => {
1705
+ const tailscale = options.tailscaleState;
1706
+ const route = tailscale.state === "running" && tailscale.dnsName ? await serveRoute(options.tailscale, options.env, addresses[0].port, tailscale.ips, HEALTH_ROUTE_TIMEOUT_MS) : void 0;
1707
+ response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({
1708
+ ...health,
1709
+ route
1710
+ }));
1711
+ };
1532
1712
  const servers = [];
1533
1713
  const addresses = [];
1534
1714
  const close = async () => {
@@ -1547,7 +1727,7 @@ async function startServer(options) {
1547
1727
  for (const host of options.hosts) {
1548
1728
  const server = createServer((request, response) => {
1549
1729
  if (localHealthRequest(request)) {
1550
- response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(health));
1730
+ answerHealth(response);
1551
1731
  return;
1552
1732
  }
1553
1733
  response.writeHead(426, { "content-type": "text/plain" }).end("stim-server speaks WebSocket only.\n");
@@ -1590,12 +1770,18 @@ function fail(message) {
1590
1770
  console.error(`stim-server: ${message}`);
1591
1771
  process.exit(1);
1592
1772
  }
1593
- function tailscaleNote(tailscale, port) {
1594
- const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server and run \`tailscale serve --bg http://127.0.0.1:${port}\` once.`;
1773
+ function tailscaleNote(tailscale) {
1774
+ const remedy = `Remote clients cannot connect until Tailscale runs; then restart stim-server, which prints the \`tailscale serve\` command to run once.`;
1595
1775
  if (tailscale.state === "not-running") return `Tailscale is not running (${tailscale.backendState}). ${remedy}`;
1596
1776
  if (tailscale.state === "unavailable") return `Tailscale is unavailable: ${tailscale.reason}. ${remedy}`;
1597
1777
  return null;
1598
1778
  }
1779
+ function routeNote(route, port) {
1780
+ if (route.state === "missing") return `No \`tailscale serve\` route reaches port ${port}, so phones cannot connect yet. Run this once to serve it on a tailnet-only port: \`${serveCommand(route.port, port)}\``;
1781
+ if (route.state === "unknown") return `Could not read \`tailscale serve status --json\` (${route.reason}); assuming stim-server is served on port ${route.port}.`;
1782
+ if (route.state === "funneled") return `Tailscale Funnel is on for port ${route.ports.join(", ")}, which proxies to stim-server, so the server is reachable from the public internet. Remove that handler (see \`tailscale serve status\`), then serve stim-server on a tailnet-only port: \`${serveCommand(route.port, port)}\``;
1783
+ return null;
1784
+ }
1599
1785
  function macName(tailscale) {
1600
1786
  return tailscale.state === "running" && tailscale.hostName || hostname();
1601
1787
  }
@@ -1628,8 +1814,13 @@ async function serve(port) {
1628
1814
  }
1629
1815
  console.log(`stim-server ${pkg.version} (stim ${stim.version}, protocol 1)`);
1630
1816
  for (const { host, port: bound } of server.addresses) console.log(`listening on ws://${host.includes(":") ? `[${host}]` : host}:${bound}`);
1631
- if (tailscale.state === "running" && tailscale.dnsName) console.log(`tailnet endpoint: wss://${tailscale.dnsName}`);
1632
- const note = tailscaleNote(tailscale, port);
1817
+ let note = tailscaleNote(tailscale);
1818
+ if (tailscale.state === "running" && tailscale.dnsName) {
1819
+ const route = await serveRoute(tailscaleBinary, env, port, tailscale.ips);
1820
+ if (route.state !== "funneled") console.log(`tailnet endpoint: ${tailnetEndpoint(tailscale.dnsName, route.port)}`);
1821
+ note = routeNote(route, port);
1822
+ if (route.state === "funneled") note = `${note} Pairing is refused until then.`;
1823
+ }
1633
1824
  if (note) console.error(note);
1634
1825
  const shutdown = () => {
1635
1826
  server.close().then(() => process.exit(0));
@@ -1638,14 +1829,23 @@ async function serve(port) {
1638
1829
  process.on("SIGTERM", shutdown);
1639
1830
  process.on("SIGHUP", shutdown);
1640
1831
  }
1641
- function pair(port, json) {
1642
- const tailscale = tailscaleStatus(findTailscale(process.env), process.env);
1832
+ async function pair(port, json) {
1833
+ const binary = findTailscale(process.env);
1834
+ const tailscale = tailscaleStatus(binary, process.env);
1835
+ let endpoint = `ws://127.0.0.1:${port}`;
1836
+ let note = tailscaleNote(tailscale);
1837
+ if (note) note = `${note} The endpoint above only works on this Mac.`;
1838
+ if (tailscale.state === "running" && tailscale.dnsName) {
1839
+ const route = await serveRoute(binary, process.env, port, tailscale.ips);
1840
+ if (route.state === "funneled") fail(`refusing to pair. ${routeNote(route, port)}`);
1841
+ endpoint = tailnetEndpoint(tailscale.dnsName, route.port);
1842
+ note = routeNote(route, port);
1843
+ }
1643
1844
  const { token, expiresAt } = createPairingToken();
1644
- const running = tailscale.state === "running";
1645
1845
  const payload = {
1646
1846
  v: 1,
1647
1847
  name: macName(tailscale),
1648
- endpoint: running && tailscale.dnsName ? `wss://${tailscale.dnsName}` : `ws://127.0.0.1:${port}`,
1848
+ endpoint,
1649
1849
  pairingToken: token
1650
1850
  };
1651
1851
  if (json) return void console.log(JSON.stringify({
@@ -1654,8 +1854,7 @@ function pair(port, json) {
1654
1854
  }));
1655
1855
  console.log(JSON.stringify(payload));
1656
1856
  console.error(`The pairing token is single use and expires at ${expiresAt}.`);
1657
- const note = tailscaleNote(tailscale, port);
1658
- if (note) console.error(`${note} The endpoint above only works on this Mac.`);
1857
+ if (note) console.error(note);
1659
1858
  }
1660
1859
  function describe(device) {
1661
1860
  const from = device.identity.kind === "local" ? "this Mac" : `${device.identity.nodeName || device.identity.nodeId}${device.identity.user ? ` (${device.identity.user})` : ""}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stim-cli/server",
3
- "version": "1.10.0",
3
+ "version": "1.10.1",
4
4
  "description": "Serves Stim status to paired read-only clients over Tailscale.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,8 +28,8 @@
28
28
  "./protocol.schema.json": "./dist/protocol.schema.json"
29
29
  },
30
30
  "dependencies": {
31
- "@stim-cli/core": "^1.10.0",
32
- "stim": "1.10.0",
31
+ "@stim-cli/core": "^1.10.1",
32
+ "stim": "1.10.1",
33
33
  "ws": "^8.21.3"
34
34
  },
35
35
  "devDependencies": {