serve-sim 0.1.16 → 0.1.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "serve-sim",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
package/src/ax-shared.ts CHANGED
@@ -1,5 +1,4 @@
1
- export const AXE_INSTALL_URL = "https://github.com/cameroncooke/AXe";
2
- export const AXE_NOT_INSTALLED_ERROR = `AXe is not installed. Install it from ${AXE_INSTALL_URL}.`;
1
+ export const AX_UNAVAILABLE_ERROR = "Accessibility unavailable on this simulator.";
3
2
 
4
3
  export interface AxRect {
5
4
  x: number;
package/src/ax.ts CHANGED
@@ -1,6 +1,4 @@
1
- import { execFile } from "child_process";
2
- import { promisify } from "util";
3
- import { AXE_NOT_INSTALLED_ERROR } from "./ax-shared";
1
+ import { AX_UNAVAILABLE_ERROR } from "./ax-shared";
4
2
  import type { AxElement, AxRect, AxSnapshot } from "./ax-shared";
5
3
 
6
4
  export type { AxElement, AxRect, AxSnapshot } from "./ax-shared";
@@ -10,7 +8,6 @@ const MAX_ELEMENTS = 500;
10
8
  const POLL_INTERVAL_MS = 500;
11
9
  const MAX_POLL_INTERVAL_MS = 2000;
12
10
  const UNAVAILABLE_RETRY_INTERVAL_MS = 15_000;
13
- const execFileAsync = promisify(execFile);
14
11
 
15
12
  interface RawAxeNode {
16
13
  AXUniqueId: string | null;
@@ -23,14 +20,6 @@ interface RawAxeNode {
23
20
  children: RawAxeNode[];
24
21
  }
25
22
 
26
- async function execFileText(command: string, args: string[]) {
27
- const { stdout } = await execFileAsync(command, args, {
28
- timeout: SNAPSHOT_TIMEOUT_MS,
29
- maxBuffer: 8 * 1024 * 1024,
30
- });
31
- return stdout.toString();
32
- }
33
-
34
23
  function chooseScreenFrame(roots: RawAxeNode[]) {
35
24
  return roots[0]?.frame ?? {
36
25
  x: 0,
@@ -90,17 +79,32 @@ function normalizeAxTree(roots: RawAxeNode[]): AxSnapshot {
90
79
  };
91
80
  }
92
81
 
93
- async function snapshotWithAxe(udid: string) {
94
- const output = await execFileText("axe", ["describe-ui", "--udid", udid]);
95
- return normalizeAxTree(JSON.parse(output) as RawAxeNode[]);
96
- }
97
-
98
- function isAxeNotInstalledError(error: unknown) {
99
- return (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT";
82
+ async function snapshotFromHelper(port: number): Promise<AxSnapshot> {
83
+ const controller = new AbortController();
84
+ const timer = setTimeout(() => controller.abort(), SNAPSHOT_TIMEOUT_MS);
85
+ try {
86
+ const res = await fetch(`http://127.0.0.1:${port}/ax`, { signal: controller.signal });
87
+ if (res.status === 503) {
88
+ // Helper is up but the simulator can't satisfy accessibility right
89
+ // now (framework missing, SpringBoard restarting, etc). Surface as
90
+ // the standard "unavailable" error so the streamer backs off.
91
+ return {
92
+ screen: { width: 1, height: 1 },
93
+ elements: [],
94
+ errors: [AX_UNAVAILABLE_ERROR],
95
+ };
96
+ }
97
+ if (!res.ok) {
98
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
99
+ }
100
+ return normalizeAxTree(await res.json() as RawAxeNode[]);
101
+ } finally {
102
+ clearTimeout(timer);
103
+ }
100
104
  }
101
105
 
102
- function isAxeUnavailableSnapshot(snapshot: AxSnapshot | null) {
103
- return snapshot?.errors?.includes(AXE_NOT_INSTALLED_ERROR) ?? false;
106
+ function isAxUnavailableSnapshot(snapshot: AxSnapshot | null) {
107
+ return snapshot?.errors?.includes(AX_UNAVAILABLE_ERROR) ?? false;
104
108
  }
105
109
 
106
110
  function isUsableAxSnapshot(snapshot: AxSnapshot) {
@@ -111,14 +115,15 @@ function isUsableAxSnapshot(snapshot: AxSnapshot) {
111
115
  );
112
116
  }
113
117
 
114
- async function collectAxSnapshot(udid: string) {
118
+ async function collectAxSnapshot(port: number) {
115
119
  const errors: string[] = [];
116
120
 
117
121
  try {
118
- const snapshot = await snapshotWithAxe(udid);
122
+ const snapshot = await snapshotFromHelper(port);
123
+ if (snapshot.errors?.length) return snapshot;
119
124
  if (!isUsableAxSnapshot(snapshot)) {
120
125
  throw new Error(
121
- `axe returned ${snapshot.elements.length} elements in ${snapshot.screen.width}x${snapshot.screen.height} AX space`,
126
+ `helper returned ${snapshot.elements.length} elements in ${snapshot.screen.width}x${snapshot.screen.height} AX space`,
122
127
  );
123
128
  }
124
129
  return {
@@ -126,12 +131,18 @@ async function collectAxSnapshot(udid: string) {
126
131
  errors,
127
132
  };
128
133
  } catch (error) {
129
- const err = error as Error & { stderr?: string };
130
- errors.push(
131
- isAxeNotInstalledError(error)
132
- ? AXE_NOT_INSTALLED_ERROR
133
- : err.stderr || err.message || String(error),
134
- );
134
+ const err = error as Error & { cause?: { code?: string }; code?: string };
135
+ const code = err.cause?.code ?? err.code;
136
+ const message = err.message || String(error);
137
+ // Helper not yet up (or just restarted). Node sets cause.code; Bun/undici
138
+ // surface it as a free-text "Unable to connect" message. Either way,
139
+ // treat as unavailable so the SSE consumer renders a friendly state
140
+ // rather than churning per-poll error stacks.
141
+ const isConnectFailure =
142
+ code === "ECONNREFUSED" ||
143
+ code === "ECONNRESET" ||
144
+ /unable to connect|fetch failed|ECONNREFUSED/i.test(message);
145
+ errors.push(isConnectFailure ? AX_UNAVAILABLE_ERROR : message);
135
146
  }
136
147
 
137
148
  return {
@@ -145,16 +156,18 @@ function sseMessage(payload: unknown) {
145
156
  return `data: ${JSON.stringify(payload)}\n\n`;
146
157
  }
147
158
 
148
- function createAxStreamer({
149
- udid,
150
- }: {
151
- udid: string;
152
- }) {
159
+ interface AxStreamer {
160
+ addClient(res: { write(chunk: string): void }): () => void;
161
+ setPort(port: number): void;
162
+ }
163
+
164
+ function createAxStreamer({ port }: { port: number }): AxStreamer {
153
165
  const clients = new Set<{ write(chunk: string): void }>();
154
166
  let timer: ReturnType<typeof setTimeout> | null = null;
155
167
  let latestMessage: string | null = null;
156
168
  let pollIntervalMs = POLL_INTERVAL_MS;
157
169
  let polling = false;
170
+ let currentPort = port;
158
171
 
159
172
  const schedule = () => {
160
173
  if (clients.size === 0 || timer) return;
@@ -170,7 +183,7 @@ function createAxStreamer({
170
183
 
171
184
  polling = true;
172
185
  try {
173
- const next = await collectAxSnapshot(udid);
186
+ const next = await collectAxSnapshot(currentPort);
174
187
  const nextMessage = sseMessage(next);
175
188
  if (nextMessage !== latestMessage) {
176
189
  for (const client of clients) client.write(nextMessage);
@@ -179,10 +192,10 @@ function createAxStreamer({
179
192
  pollIntervalMs = Math.min(pollIntervalMs * 2, MAX_POLL_INTERVAL_MS);
180
193
  }
181
194
  latestMessage = nextMessage;
182
- // Back off aggressively while axe is missing so we don't spawn a
183
- // subprocess every 2s, but keep polling so we recover automatically
184
- // once the user installs it.
185
- if (isAxeUnavailableSnapshot(next)) {
195
+ // If the helper says AX is unavailable (framework missing, sim
196
+ // booting), keep polling but back off so we recover automatically
197
+ // without spamming requests.
198
+ if (isAxUnavailableSnapshot(next)) {
186
199
  pollIntervalMs = UNAVAILABLE_RETRY_INTERVAL_MS;
187
200
  }
188
201
  } finally {
@@ -192,7 +205,20 @@ function createAxStreamer({
192
205
  };
193
206
 
194
207
  return {
195
- addClient(res: { write(chunk: string): void }) {
208
+ setPort(nextPort: number) {
209
+ if (nextPort === currentPort) return;
210
+ currentPort = nextPort;
211
+ latestMessage = null;
212
+ // Avoid sitting on the unavailable-backoff interval (15s) when the
213
+ // helper has just come up on a new port.
214
+ pollIntervalMs = POLL_INTERVAL_MS;
215
+ if (timer) {
216
+ clearTimeout(timer);
217
+ timer = null;
218
+ }
219
+ void poll();
220
+ },
221
+ addClient(res) {
196
222
  clients.add(res);
197
223
  if (latestMessage) res.write(latestMessage);
198
224
  void poll();
@@ -208,14 +234,22 @@ function createAxStreamer({
208
234
  }
209
235
 
210
236
  export function createAxStreamerCache() {
211
- const streamers = new Map<string, ReturnType<typeof createAxStreamer>>();
237
+ const streamers = new Map<string, AxStreamer>();
212
238
 
213
239
  return {
214
- get(udid: string) {
240
+ /**
241
+ * Get (or create) a streamer for the given simulator. The port is
242
+ * the helper's HTTP port — if the helper restarts on a different
243
+ * port, pass the new value and the cached streamer will retarget.
244
+ */
245
+ get(udid: string, port: number) {
215
246
  const existing = streamers.get(udid);
216
- if (existing) return existing;
247
+ if (existing) {
248
+ existing.setPort(port);
249
+ return existing;
250
+ }
217
251
 
218
- const streamer = createAxStreamer({ udid });
252
+ const streamer = createAxStreamer({ port });
219
253
  streamers.set(udid, streamer);
220
254
  return streamer;
221
255
  },
package/src/middleware.ts CHANGED
@@ -532,7 +532,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
532
532
  "X-Accel-Buffering": "no",
533
533
  });
534
534
  res.write(":\n\n");
535
- const ax = axStreamerCache.get(state.device);
535
+ const ax = axStreamerCache.get(state.device, state.port);
536
536
  const removeClient = ax.addClient(res);
537
537
  req.on("close", removeClient);
538
538
  return;