serve-sim 0.1.35 → 0.1.36

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.35",
3
+ "version": "0.1.36",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
package/src/ax.ts CHANGED
@@ -159,6 +159,7 @@ function sseMessage(payload: unknown) {
159
159
  interface AxStreamer {
160
160
  addClient(res: { write(chunk: string): void }): () => void;
161
161
  setPort(port: number): void;
162
+ dispose(): void;
162
163
  }
163
164
 
164
165
  function createAxStreamer({ port }: { port: number }): AxStreamer {
@@ -168,15 +169,16 @@ function createAxStreamer({ port }: { port: number }): AxStreamer {
168
169
  let pollIntervalMs = POLL_INTERVAL_MS;
169
170
  let polling = false;
170
171
  let currentPort = port;
172
+ let disposed = false;
171
173
 
172
174
  const schedule = () => {
173
- if (clients.size === 0 || timer) return;
175
+ if (disposed || clients.size === 0 || timer) return;
174
176
  timer = setTimeout(poll, pollIntervalMs);
175
177
  };
176
178
 
177
179
  const poll = async () => {
178
180
  timer = null;
179
- if (polling || clients.size === 0) {
181
+ if (disposed || polling || clients.size === 0) {
180
182
  schedule();
181
183
  return;
182
184
  }
@@ -206,7 +208,7 @@ function createAxStreamer({ port }: { port: number }): AxStreamer {
206
208
 
207
209
  return {
208
210
  setPort(nextPort: number) {
209
- if (nextPort === currentPort) return;
211
+ if (disposed || nextPort === currentPort) return;
210
212
  currentPort = nextPort;
211
213
  latestMessage = null;
212
214
  // Avoid sitting on the unavailable-backoff interval (15s) when the
@@ -219,6 +221,7 @@ function createAxStreamer({ port }: { port: number }): AxStreamer {
219
221
  void poll();
220
222
  },
221
223
  addClient(res) {
224
+ if (disposed) return () => {};
222
225
  clients.add(res);
223
226
  if (latestMessage) res.write(latestMessage);
224
227
  void poll();
@@ -230,10 +233,26 @@ function createAxStreamer({ port }: { port: number }): AxStreamer {
230
233
  }
231
234
  };
232
235
  },
236
+ dispose() {
237
+ if (disposed) return;
238
+ disposed = true;
239
+ if (timer) {
240
+ clearTimeout(timer);
241
+ timer = null;
242
+ }
243
+ clients.clear();
244
+ latestMessage = null;
245
+ },
233
246
  };
234
247
  }
235
248
 
236
- export function createAxStreamerCache() {
249
+ export interface AxStreamerCache {
250
+ get(udid: string, port: number): AxStreamer;
251
+ prune(activeUdids: Iterable<string>): void;
252
+ size(): number;
253
+ }
254
+
255
+ export function createAxStreamerCache(): AxStreamerCache {
237
256
  const streamers = new Map<string, AxStreamer>();
238
257
 
239
258
  return {
@@ -253,5 +272,23 @@ export function createAxStreamerCache() {
253
272
  streamers.set(udid, streamer);
254
273
  return streamer;
255
274
  },
275
+ /**
276
+ * Drop streamers for simulators no longer present in `activeUdids`.
277
+ * Without this, the cache grew append-only across a server's lifetime
278
+ * as devices were booted/erased/reset, each entry holding a poll
279
+ * timer, last-snapshot buffer, and SSE client set.
280
+ */
281
+ prune(activeUdids) {
282
+ const active = activeUdids instanceof Set ? activeUdids : new Set(activeUdids);
283
+ for (const [udid, streamer] of streamers) {
284
+ if (!active.has(udid)) {
285
+ streamer.dispose();
286
+ streamers.delete(udid);
287
+ }
288
+ }
289
+ },
290
+ size() {
291
+ return streamers.size;
292
+ },
256
293
  };
257
294
  }
package/src/middleware.ts CHANGED
@@ -83,6 +83,11 @@ export interface ServeSimState {
83
83
  }
84
84
 
85
85
  const axStreamerCache = createAxStreamerCache();
86
+
87
+ // Hard cap on the SSE line-assembly buffer for child-process stdout.
88
+ // A malformed log entry without a newline can't grow this beyond 1 MB;
89
+ // the partial line is dropped rather than retained indefinitely.
90
+ const SSE_LINE_BUFFER_LIMIT = 1024 * 1024;
86
91
  let inspectWebKitBridge: Promise<WebKitBridge> | null = null;
87
92
 
88
93
  // Known bundle IDs that are always React Native shells (used as a fallback
@@ -257,6 +262,38 @@ function endpoint(base: string, path: string, device: string): string {
257
262
  return `${value}?device=${encodeURIComponent(device)}`;
258
263
  }
259
264
 
265
+ /**
266
+ * Rewrite the helper URLs in a state so they point at the hostname the request
267
+ * came in on. The helper binds on `*:<port>`, so once the host portion matches
268
+ * the dev-server origin, a remote viewer (LAN, or tunnel exposing the helper
269
+ * port under the same hostname) can reach the stream. Loopback callers get
270
+ * the state untouched.
271
+ */
272
+ export function rewriteStateForRequestHost(
273
+ state: ServeSimState,
274
+ hostHeader: string | undefined,
275
+ ): ServeSimState {
276
+ if (!hostHeader) return state;
277
+ let hostname: string;
278
+ try {
279
+ hostname = new URL(`http://${hostHeader}`).hostname;
280
+ } catch {
281
+ return state;
282
+ }
283
+ // `URL.hostname` keeps brackets around IPv6 literals, so the IPv6 loopback
284
+ // comparison is against the bracketed form rather than `::1`.
285
+ if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") {
286
+ return state;
287
+ }
288
+ const rewrite = (s: string) => s.replace("127.0.0.1", hostname);
289
+ return {
290
+ ...state,
291
+ url: rewrite(state.url),
292
+ streamUrl: rewrite(state.streamUrl),
293
+ wsUrl: rewrite(state.wsUrl),
294
+ };
295
+ }
296
+
260
297
  export function previewConfigForState(
261
298
  state: ServeSimState,
262
299
  base: string,
@@ -725,7 +762,8 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
725
762
  }
726
763
 
727
764
  if (state) {
728
- const config = JSON.stringify(previewConfigForState(state, base, serveSimBinPath(), execToken));
765
+ const remoteState = rewriteStateForRequestHost(state, req.headers?.host);
766
+ const config = JSON.stringify(previewConfigForState(remoteState, base, serveSimBinPath(), execToken));
729
767
  const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
730
768
  html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
731
769
  }
@@ -755,17 +793,18 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
755
793
  const sims = listAllSimulators();
756
794
  const devices = sims.map((d) => {
757
795
  const helper = helperByUdid.get(d.udid);
796
+ const remoteHelper = helper ? rewriteStateForRequestHost(helper, req.headers?.host) : null;
758
797
  return {
759
798
  device: d.udid,
760
799
  name: d.name,
761
800
  runtime: d.runtime,
762
801
  state: d.state,
763
- helper: helper
802
+ helper: remoteHelper
764
803
  ? {
765
- port: helper.port,
766
- url: helper.url,
767
- streamUrl: helper.streamUrl,
768
- wsUrl: helper.wsUrl,
804
+ port: remoteHelper.port,
805
+ url: remoteHelper.url,
806
+ streamUrl: remoteHelper.streamUrl,
807
+ wsUrl: remoteHelper.wsUrl,
769
808
  }
770
809
  : null,
771
810
  };
@@ -1003,7 +1042,8 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
1003
1042
  "Content-Type": "application/json",
1004
1043
  "Cache-Control": "no-store",
1005
1044
  });
1006
- res.end(JSON.stringify(state ? previewConfigForState(state, base, serveSimBinPath(), execToken) : null));
1045
+ const remoteState = state ? rewriteStateForRequestHost(state, req.headers?.host) : null;
1046
+ res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base, serveSimBinPath(), execToken) : null));
1007
1047
  return;
1008
1048
  }
1009
1049
 
@@ -1023,6 +1063,7 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
1023
1063
  "X-Accel-Buffering": "no",
1024
1064
  });
1025
1065
  res.write(":\n\n");
1066
+ axStreamerCache.prune(states.map((s) => s.device));
1026
1067
  const ax = axStreamerCache.get(state.device, state.port);
1027
1068
  const removeClient = ax.addClient(res);
1028
1069
  req.on("close", removeClient);
@@ -1138,10 +1179,17 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
1138
1179
  buf = buf.slice(nl + 1);
1139
1180
  if (line) res.write("data: " + line + "\n\n");
1140
1181
  }
1182
+ // Drop a runaway partial line so a malformed/never-terminated
1183
+ // log entry can't grow `buf` without bound.
1184
+ if (buf.length > SSE_LINE_BUFFER_LIMIT) buf = "";
1141
1185
  });
1142
1186
 
1187
+ child.on("error", () => { try { res.end(); } catch {} });
1143
1188
  child.on("close", () => res.end());
1144
- req.on("close", () => child.kill());
1189
+ req.on("close", () => {
1190
+ child.stdout?.destroy();
1191
+ child.kill();
1192
+ });
1145
1193
  return;
1146
1194
  }
1147
1195
 
@@ -1225,11 +1273,17 @@ export function simMiddleware(options?: SimMiddlewareOptions) {
1225
1273
  if (!event) continue;
1226
1274
  emitApp(event.bundleId, event.pid);
1227
1275
  }
1276
+ if (buf.length > SSE_LINE_BUFFER_LIMIT) buf = "";
1228
1277
  });
1229
1278
 
1279
+ child.on("error", () => {
1280
+ closed = true;
1281
+ try { res.end(); } catch {}
1282
+ });
1230
1283
  child.on("close", () => res.end());
1231
1284
  req.on("close", () => {
1232
1285
  closed = true;
1286
+ child.stdout?.destroy();
1233
1287
  child.kill();
1234
1288
  });
1235
1289
  return;