@stim-cli/server 0.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Janic Duplessis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,161 @@
1
1
  # @stim-cli/server
2
2
 
3
- Placeholder. Install a version published with the other Stim packages.
3
+ `stim-server` serves Stim state to paired read-only clients, such as the Stim
4
+ phone app. It runs on the Mac, next to Stim, and changes no Stim state: it
5
+ writes only its own pairing state under `$STIM_HOME/server/`.
6
+
7
+ The design is in
8
+ [`docs/specs/2026-09-25-stim-server-design.md`](../../docs/specs/2026-09-25-stim-server-design.md).
9
+
10
+ ## Commands
11
+
12
+ ```bash
13
+ stim-server [--port <n>] # serve paired clients, port 7787 by default
14
+ stim-server pair [--port <n>] # print a single-use pairing payload
15
+ stim-server devices [list] # list paired devices
16
+ stim-server devices revoke <id> # revoke a paired device
17
+ ```
18
+
19
+ `pair --json` prints `{ "qr": <payload>, "expiresAt": "<ISO time>" }`, and
20
+ `devices --json` prints `{ "devices": [...] }` with each device's `id`, `name`,
21
+ `identity`, `pairedAt`, `lastSeenAt` and `capabilities`, never its token hash.
22
+
23
+ `GET http://127.0.0.1:7787/health` answers requests from this Mac with the
24
+ server's name, versions, protocol, `stimHome`, and the Tailscale state it
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
31
+ `tailscale serve`, on a Tailscale address, or with a `Host` other than
32
+ `127.0.0.1` or `localhost` gets HTTP 426, like any other plain HTTP request.
33
+
34
+ `stim-server` runs the `stim` version this package was released with, not the
35
+ one on your PATH. It reads the login shell's environment once at start, so
36
+ `PATH`, `ANDROID_HOME`, and `STIM_*` variables match your terminal even when
37
+ another app starts it.
38
+
39
+ ## Tailscale
40
+
41
+ Tailscale carries and encrypts the traffic between the phone and the Mac, on
42
+ the same Wi-Fi or across networks, and identifies each device. Stim adds no
43
+ cryptography of its own. You need Tailscale on the Mac and on the phone, in the
44
+ same tailnet or with the Mac shared to the phone's user.
45
+
46
+ `stim-server` listens only on `127.0.0.1` and on the Mac's Tailscale
47
+ addresses, never on every interface. Run this once so clients can use
48
+ `wss://<mac>.<tailnet>.ts.net:7443` with a valid certificate:
49
+
50
+ ```bash
51
+ tailscale serve --bg --https=7443 http://127.0.0.1:7787
52
+ ```
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
+
65
+ When Tailscale is not running, `stim-server` listens on loopback only and
66
+ says so on stderr. Start Tailscale, then restart `stim-server`.
67
+
68
+ ## Pairing
69
+
70
+ `stim-server pair` prints the JSON that the pairing QR code encodes:
71
+
72
+ ```json
73
+ { "v": 1, "name": "Janic's MacBook Pro", "endpoint": "wss://janics-mbp.tail1234.ts.net:7443", "pairingToken": "..." }
74
+ ```
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
+
80
+ The pairing token works once and expires after 5 minutes. A client spends it in
81
+ `hello` and receives a random device token, which it presents on every later
82
+ connection. The server stores only the SHA-256 hash of each token.
83
+
84
+ At pairing, the server records the peer's tailnet node and user from
85
+ `tailscale whois`. A device token presented from any other node is refused, so
86
+ a leaked token alone is not enough. A device paired over loopback, from this
87
+ Mac, is accepted only over loopback. `tailscale serve` connects from loopback
88
+ and names the peer in `X-Forwarded-For`, which the server trusts only on
89
+ loopback connections. Forward only `tailscale serve` in HTTP mode to the
90
+ loopback port: a forwarder that omits that header, such as `tailscale serve
91
+ --tcp`, `ssh -L`, or a tunnel, makes every remote peer look like this Mac.
92
+
93
+ A connection must send `hello` within 5 seconds. Five failed attempts from the
94
+ same peer within a minute block new connections from it for up to a minute.
95
+
96
+ Paired devices live in `$STIM_HOME/server/devices.json`. Revoking a device
97
+ closes its open connections.
98
+
99
+ ## Protocol
100
+
101
+ JSON messages over a WebSocket. Requests are `{ "id", "method", "params" }`,
102
+ answered by `{ "id", "result" }` or `{ "id", "error": { "code", "message" } }`.
103
+ Events are `{ "event", "subscription", ... }`.
104
+
105
+ - `hello` must come first. Params: `protocol` (1), `client` (`name`,
106
+ `version`), and `auth`, either `{ "pairingToken", "deviceName" }` or
107
+ `{ "deviceToken" }`. The result carries the server name and versions, the
108
+ granted capabilities (`read` only in protocol 1), the paired device, and the
109
+ new `deviceToken` when the hello paired.
110
+ - `status.subscribe` returns a subscription id. Each `status` event carries a
111
+ full payload as `stim status --watch --json` prints it. All subscribers share
112
+ one `stim status --watch --json` child, which stops with the last
113
+ subscriber.
114
+ - `logs.query` returns `{ "records" }`, and `logs.subscribe` sends `logs`
115
+ events: first the last `tail` matching records, then new ones in batches.
116
+ Both take the Stim Desktop log viewer's filters: `workspace` (required),
117
+ `sources` (`metro`, `client`, `device`, `build`), `slot`, `level` (the
118
+ minimum), `grep` (a regular expression), `errors`, and `tail` (1 to 5000,
119
+ 5000 by default). Without `sources`, `errors` keeps the CLI's default error
120
+ scope. They run `stim logs --json` and `stim logs --json --follow` in the
121
+ workspace. Subscribers with the same workspace and filters share one
122
+ `--follow` child, which stops with the last of them.
123
+ - `stats.get` and `settings.get` return the payload of `stim stats --json` and
124
+ `stim settings --json`, which masks sensitive values. Without `workspace`,
125
+ they run in the home directory and cover the machine only.
126
+ - `frames.subscribe` takes `workspace`, `platform` (`ios` or `android`) and
127
+ `slot` (`default` when absent) and sends `frame` events: a JPEG screenshot,
128
+ base64 in `data`, with `width`, `height` and `capturedAt`. It serves only a
129
+ booted simulator or a running emulator that `stim status` lists as owned by
130
+ that workspace; any other device ends the subscription with a
131
+ `frames-failed` `error` event, and so does a device that stops or changes
132
+ owner. Simulators are captured with `xcrun simctl io <udid> screenshot`, of
133
+ the primary display, or of the default display when that `simctl` does not
134
+ accept `primary`. Emulators are captured through their gRPC `getScreenshot`,
135
+ found through the discovery file and token the emulator writes when Stim
136
+ boots it, scaled to fit 1280 pixels and converted with `sips`. An emulator
137
+ Stim booted before it passed `-grpc` has no endpoint. A frame is sent only
138
+ when the screen changed: up to 5 per second while it changes, backing off to
139
+ one capture per second while it does not, with capturing taking at most half
140
+ of each device's time. All subscribers of a device share one capture loop,
141
+ which sends a new subscriber the latest frame and stops with the last
142
+ subscriber, and at most two captures run at once. A client whose socket has
143
+ more than 1 MiB unsent skips frames and gets the newest once it catches up.
144
+ - `unsubscribe` ends a subscription.
145
+ - An `error` event ends a subscription whose source failed, or whose client
146
+ fell behind (`slow-client`); subscribe again.
147
+
148
+ `workspace` is an environment `path` from a status payload. Any other path is
149
+ refused with `unknown-workspace` and runs nothing. A connection holds at most
150
+ 32 subscriptions and runs at most 4 `logs.query`, `stats.get` and
151
+ `settings.get` requests at a time. Those requests fail after 60 seconds or
152
+ 32 MiB of output, and closing the connection stops them. A `stim` child that
153
+ ignores SIGTERM gets SIGKILL a second later. A log subscriber whose socket has more than
154
+ 4 MiB unsent gets no more batches until it catches up; past 20,000
155
+ waiting records the server ends that subscription with `slow-client`.
156
+
157
+ The error codes `unauthorized`, `pairing-expired`, and `protocol-unsupported`
158
+ refuse the client until it pairs again or updates; clients retry the others.
159
+
160
+ The package exports the message types, and the build writes their JSON Schema
161
+ to `dist/protocol.schema.json`, exported as `@stim-cli/server/protocol.schema.json`.
@@ -0,0 +1,407 @@
1
+ //#region src/protocol.ts
2
+ const PROTOCOL_VERSION = 1;
3
+ const PROTOCOL_SCHEMA_FILE = "protocol.schema.json";
4
+ const CAPABILITIES = ["read"];
5
+ const METHODS = [
6
+ "hello",
7
+ "status.subscribe",
8
+ "logs.query",
9
+ "logs.subscribe",
10
+ "stats.get",
11
+ "settings.get",
12
+ "frames.subscribe",
13
+ "unsubscribe"
14
+ ];
15
+ /**
16
+ * `unauthorized`, `pairing-expired` and `protocol-unsupported` refuse the client until it pairs again or
17
+ * updates; clients retry the others.
18
+ */
19
+ const ERROR_CODES = [
20
+ "unauthorized",
21
+ "pairing-expired",
22
+ "protocol-unsupported",
23
+ "identity-unavailable",
24
+ "bad-request",
25
+ "unknown-method",
26
+ "already-authenticated",
27
+ "unknown-subscription",
28
+ "unknown-workspace",
29
+ "limit-exceeded",
30
+ "slow-client",
31
+ "status-failed",
32
+ "logs-failed",
33
+ "stim-failed",
34
+ "frames-failed"
35
+ ];
36
+ const LOG_SOURCES = [
37
+ "metro",
38
+ "client",
39
+ "device",
40
+ "build"
41
+ ];
42
+ const LOG_LEVELS = [
43
+ "debug",
44
+ "info",
45
+ "warn",
46
+ "error",
47
+ "fatal"
48
+ ];
49
+ const MAX_LOG_TAIL = 5e3;
50
+ const requestId = { type: ["integer", "string"] };
51
+ const protocolError = {
52
+ type: "object",
53
+ required: ["code", "message"],
54
+ additionalProperties: false,
55
+ properties: {
56
+ code: { enum: [...ERROR_CODES] },
57
+ message: { type: "string" }
58
+ }
59
+ };
60
+ function request(method, params) {
61
+ return {
62
+ type: "object",
63
+ required: params ? [
64
+ "id",
65
+ "method",
66
+ "params"
67
+ ] : ["id", "method"],
68
+ additionalProperties: false,
69
+ properties: {
70
+ id: requestId,
71
+ method: { const: method },
72
+ params: params ?? {
73
+ type: "object",
74
+ maxProperties: 0
75
+ }
76
+ }
77
+ };
78
+ }
79
+ function optionalParams(method, params) {
80
+ return {
81
+ type: "object",
82
+ required: ["id", "method"],
83
+ additionalProperties: false,
84
+ properties: {
85
+ id: requestId,
86
+ method: { const: method },
87
+ params
88
+ }
89
+ };
90
+ }
91
+ /** The JSON Schema of every message on the wire, written to `dist/protocol.schema.json` at build time. */
92
+ function protocolJsonSchema() {
93
+ return {
94
+ $schema: "https://json-schema.org/draft/2020-12/schema",
95
+ title: "Stim server protocol",
96
+ description: `Messages exchanged over the stim-server WebSocket, protocol version 1.`,
97
+ "x-stim-protocol": 1,
98
+ $defs: {
99
+ ProtocolError: protocolError,
100
+ HelloParams: {
101
+ type: "object",
102
+ required: [
103
+ "protocol",
104
+ "client",
105
+ "auth"
106
+ ],
107
+ additionalProperties: false,
108
+ properties: {
109
+ protocol: { type: "integer" },
110
+ client: {
111
+ type: "object",
112
+ required: ["name", "version"],
113
+ properties: {
114
+ name: { type: "string" },
115
+ version: { type: "string" }
116
+ }
117
+ },
118
+ auth: { oneOf: [{
119
+ type: "object",
120
+ required: ["pairingToken", "deviceName"],
121
+ additionalProperties: false,
122
+ properties: {
123
+ pairingToken: { type: "string" },
124
+ deviceName: {
125
+ type: "string",
126
+ minLength: 1
127
+ }
128
+ }
129
+ }, {
130
+ type: "object",
131
+ required: ["deviceToken"],
132
+ additionalProperties: false,
133
+ properties: { deviceToken: { type: "string" } }
134
+ }] }
135
+ }
136
+ },
137
+ HelloResult: {
138
+ type: "object",
139
+ required: [
140
+ "protocol",
141
+ "server",
142
+ "capabilities",
143
+ "device"
144
+ ],
145
+ additionalProperties: false,
146
+ properties: {
147
+ protocol: { type: "integer" },
148
+ server: {
149
+ type: "object",
150
+ required: [
151
+ "name",
152
+ "version",
153
+ "stim"
154
+ ],
155
+ additionalProperties: false,
156
+ properties: {
157
+ name: { type: "string" },
158
+ version: { type: "string" },
159
+ stim: { type: "string" }
160
+ }
161
+ },
162
+ capabilities: {
163
+ type: "array",
164
+ items: { enum: [...CAPABILITIES] }
165
+ },
166
+ device: {
167
+ type: "object",
168
+ required: ["id", "name"],
169
+ additionalProperties: false,
170
+ properties: {
171
+ id: { type: "string" },
172
+ name: { type: "string" }
173
+ }
174
+ },
175
+ deviceToken: { type: "string" }
176
+ }
177
+ },
178
+ LogRecord: {
179
+ type: "object",
180
+ description: "One record as `stim logs --json` prints it.",
181
+ properties: {
182
+ ts: { type: "number" },
183
+ src: { type: "string" },
184
+ level: { type: "string" },
185
+ msg: { type: "string" }
186
+ }
187
+ },
188
+ LogFilter: {
189
+ type: "object",
190
+ required: ["workspace"],
191
+ additionalProperties: false,
192
+ properties: {
193
+ workspace: {
194
+ type: "string",
195
+ description: "An environment path from a status payload."
196
+ },
197
+ sources: {
198
+ type: "array",
199
+ minItems: 1,
200
+ items: { enum: [...LOG_SOURCES] }
201
+ },
202
+ level: { enum: [...LOG_LEVELS] },
203
+ slot: {
204
+ type: "string",
205
+ minLength: 1
206
+ },
207
+ grep: {
208
+ type: "string",
209
+ description: "A regular expression matched against each message."
210
+ },
211
+ errors: { type: "boolean" },
212
+ tail: {
213
+ type: "integer",
214
+ minimum: 1,
215
+ maximum: MAX_LOG_TAIL,
216
+ default: MAX_LOG_TAIL
217
+ }
218
+ }
219
+ },
220
+ FrameTarget: {
221
+ type: "object",
222
+ required: ["workspace", "platform"],
223
+ additionalProperties: false,
224
+ properties: {
225
+ workspace: {
226
+ type: "string",
227
+ description: "An environment path from a status payload."
228
+ },
229
+ platform: { enum: ["ios", "android"] },
230
+ slot: {
231
+ type: "string",
232
+ minLength: 1,
233
+ default: "default"
234
+ }
235
+ }
236
+ },
237
+ WorkspaceParams: {
238
+ type: "object",
239
+ additionalProperties: false,
240
+ properties: { workspace: {
241
+ type: "string",
242
+ description: "An environment path from a status payload."
243
+ } }
244
+ },
245
+ ClientRequest: { oneOf: [
246
+ request("hello", { $ref: "#/$defs/HelloParams" }),
247
+ request("status.subscribe"),
248
+ request("logs.query", { $ref: "#/$defs/LogFilter" }),
249
+ request("logs.subscribe", { $ref: "#/$defs/LogFilter" }),
250
+ request("frames.subscribe", { $ref: "#/$defs/FrameTarget" }),
251
+ optionalParams("stats.get", { $ref: "#/$defs/WorkspaceParams" }),
252
+ optionalParams("settings.get", { $ref: "#/$defs/WorkspaceParams" }),
253
+ request("unsubscribe", {
254
+ type: "object",
255
+ required: ["subscription"],
256
+ additionalProperties: false,
257
+ properties: { subscription: { type: "string" } }
258
+ })
259
+ ] },
260
+ ServerResponse: { oneOf: [{
261
+ type: "object",
262
+ required: ["id", "result"],
263
+ additionalProperties: false,
264
+ properties: {
265
+ id: requestId,
266
+ result: { anyOf: [
267
+ { $ref: "#/$defs/HelloResult" },
268
+ {
269
+ type: "object",
270
+ required: ["subscription"],
271
+ additionalProperties: false,
272
+ properties: { subscription: { type: "string" } }
273
+ },
274
+ {
275
+ type: "object",
276
+ required: ["records"],
277
+ additionalProperties: false,
278
+ properties: { records: {
279
+ type: "array",
280
+ items: { $ref: "#/$defs/LogRecord" }
281
+ } }
282
+ },
283
+ {
284
+ type: "object",
285
+ description: "The payload of `stim stats --json` or `stim settings --json`, or {} for unsubscribe."
286
+ }
287
+ ] }
288
+ }
289
+ }, {
290
+ type: "object",
291
+ required: ["id", "error"],
292
+ additionalProperties: false,
293
+ properties: {
294
+ id: { type: [
295
+ "integer",
296
+ "string",
297
+ "null"
298
+ ] },
299
+ error: { $ref: "#/$defs/ProtocolError" }
300
+ }
301
+ }] },
302
+ ServerEvent: { oneOf: [
303
+ {
304
+ type: "object",
305
+ required: [
306
+ "event",
307
+ "subscription",
308
+ "payload"
309
+ ],
310
+ additionalProperties: false,
311
+ properties: {
312
+ event: { const: "status" },
313
+ subscription: { type: "string" },
314
+ payload: {
315
+ type: "object",
316
+ description: "A full payload, as `stim status --watch --json` prints it."
317
+ }
318
+ }
319
+ },
320
+ {
321
+ type: "object",
322
+ required: [
323
+ "event",
324
+ "subscription",
325
+ "records"
326
+ ],
327
+ additionalProperties: false,
328
+ properties: {
329
+ event: { const: "logs" },
330
+ subscription: { type: "string" },
331
+ records: {
332
+ type: "array",
333
+ items: { $ref: "#/$defs/LogRecord" }
334
+ }
335
+ }
336
+ },
337
+ {
338
+ type: "object",
339
+ required: [
340
+ "event",
341
+ "subscription",
342
+ "platform",
343
+ "slot",
344
+ "mime",
345
+ "width",
346
+ "height",
347
+ "capturedAt",
348
+ "data"
349
+ ],
350
+ additionalProperties: false,
351
+ properties: {
352
+ event: { const: "frame" },
353
+ subscription: { type: "string" },
354
+ platform: { enum: ["ios", "android"] },
355
+ slot: { type: "string" },
356
+ mime: { const: "image/jpeg" },
357
+ width: { type: "integer" },
358
+ height: { type: "integer" },
359
+ capturedAt: {
360
+ type: "string",
361
+ format: "date-time"
362
+ },
363
+ data: {
364
+ type: "string",
365
+ contentEncoding: "base64"
366
+ }
367
+ }
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
+ },
383
+ {
384
+ type: "object",
385
+ required: [
386
+ "event",
387
+ "subscription",
388
+ "error"
389
+ ],
390
+ additionalProperties: false,
391
+ properties: {
392
+ event: { const: "error" },
393
+ subscription: { type: "string" },
394
+ error: { $ref: "#/$defs/ProtocolError" }
395
+ }
396
+ }
397
+ ] }
398
+ },
399
+ oneOf: [
400
+ { $ref: "#/$defs/ClientRequest" },
401
+ { $ref: "#/$defs/ServerResponse" },
402
+ { $ref: "#/$defs/ServerEvent" }
403
+ ]
404
+ };
405
+ }
406
+ //#endregion
407
+ export { MAX_LOG_TAIL as a, PROTOCOL_VERSION as c, LOG_SOURCES as i, protocolJsonSchema as l, ERROR_CODES as n, METHODS as o, LOG_LEVELS as r, PROTOCOL_SCHEMA_FILE as s, CAPABILITIES as t };