@expo/serve-sim 0.2.2 → 0.2.4

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": "@expo/serve-sim",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "author": {
6
6
  "name": "Evan Bacon",
@@ -53,6 +53,9 @@ export function connectToFetch(
53
53
 
54
54
  let status = 200;
55
55
  let responseHeaders = new Headers();
56
+ // Set before the status is written, the way `ServerResponse.setHeader` allows. `writeHead`
57
+ // values win, matching Node.
58
+ const presetHeaders = new Headers();
56
59
  let headersSent = false;
57
60
  let writableEnded = false;
58
61
  let writableFinished = false;
@@ -96,6 +99,9 @@ export function connectToFetch(
96
99
  const ensureResponse = () => {
97
100
  if (headersSent) return;
98
101
  headersSent = true;
102
+ for (const [name, value] of presetHeaders) {
103
+ if (!responseHeaders.has(name)) responseHeaders.set(name, value);
104
+ }
99
105
  resolveOnce(new Response(statusAllowsBody() ? body : null, { status, headers: responseHeaders }));
100
106
  };
101
107
  const writeChunk = (chunk: Buffer | string | Uint8Array) => {
@@ -122,6 +128,9 @@ export function connectToFetch(
122
128
  get writableNeedDrain() { return writableNeedDrain; },
123
129
  get statusCode() { return status; },
124
130
  set statusCode(nextStatus: number) { status = nextStatus; },
131
+ setHeader(name: string, value: string) {
132
+ presetHeaders.set(name, value);
133
+ },
125
134
  writeHead(nextStatus: number, headers?: Record<string, string | number | string[]>) {
126
135
  status = nextStatus;
127
136
  if (headers) {
package/src/middleware.ts CHANGED
@@ -21,7 +21,6 @@ import {
21
21
  closeDeviceSession,
22
22
  getDeviceSession,
23
23
  peekDeviceSession,
24
- sendCorsPreflight,
25
24
  type HidSocket,
26
25
  } from "./device-session";
27
26
  import { assertPreviewAccess, assertUpgradeAccess } from "./session-auth";
@@ -179,11 +178,6 @@ function isSimulatorUdid(value: string): boolean {
179
178
  return /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/i.test(value);
180
179
  }
181
180
 
182
- const SCREENSHOT_RESPONSE_HEADERS = {
183
- "Access-Control-Allow-Origin": "*",
184
- "Cache-Control": "no-store",
185
- };
186
-
187
181
  /** What to do with a persisted device state when reaping during a grid poll. */
188
182
  type StaleStateAction = "keep" | "recycle-self" | "recycle-helper";
189
183
 
@@ -807,14 +801,6 @@ function serveHelperInProcess(
807
801
  void handleCameraStatus(req, res, device);
808
802
  return true;
809
803
  }
810
- if (
811
- (endpoint === "/webrtc/offer" || endpoint === "/webrtc/close" || endpoint === "/webrtc/stats"
812
- || endpoint === "/stream-settings")
813
- && req.method === "OPTIONS"
814
- ) {
815
- sendCorsPreflight(res);
816
- return true;
817
- }
818
804
  // Polled once a second by the panel and the recorder, so creating a session here would start a
819
805
  // capture nothing asked for, and re-create one every tick after the simulator is shut down.
820
806
  if (endpoint === "/webrtc/stats") {
@@ -977,6 +963,8 @@ export function previewConfigForState(
977
963
  execToken: string,
978
964
  streamSettingsOrCodec?: StreamSettings | string,
979
965
  proxyHelpers = false,
966
+ requirePreviewToken = false,
967
+ shareUrl?: string,
980
968
  ): ServeSimState & {
981
969
  basePath: string;
982
970
  logsEndpoint: string;
@@ -1003,6 +991,8 @@ export function previewConfigForState(
1003
991
  codec?: string;
1004
992
  streamSettings?: StreamSettings;
1005
993
  proxyHelpers?: boolean;
994
+ requireToken?: boolean;
995
+ shareUrl?: string;
1006
996
  } {
1007
997
  const gridApiBase = (base === "" ? "" : base) + "/grid/api";
1008
998
  const legacyCodec = typeof streamSettingsOrCodec === "string" ? streamSettingsOrCodec : undefined;
@@ -1037,6 +1027,8 @@ export function previewConfigForState(
1037
1027
  ...(legacyCodec ? { codec: legacyCodec } : {}),
1038
1028
  ...(streamSettings ? { streamSettings } : {}),
1039
1029
  ...(proxyHelpers ? { proxyHelpers: true } : {}),
1030
+ ...(requirePreviewToken ? { requireToken: true } : {}),
1031
+ ...(shareUrl ? { shareUrl } : {}),
1040
1032
  };
1041
1033
  }
1042
1034
 
@@ -1496,12 +1488,16 @@ export interface SimMiddlewareOptions {
1496
1488
  /** Stream transport and codec settings for the preview. */
1497
1489
  streamSettings?: StreamSettings;
1498
1490
  /**
1499
- * Origins allowed to read the `/metrics` SSE stream cross-origin (e.g. a
1500
- * hosted dashboard). Read-only telemetry only; the control routes stay
1501
- * same-origin + token-gated regardless. Loopback is always allowed.
1491
+ * Origins allowed to read this preview cross-origin (e.g. a hosted dashboard).
1492
+ * Every route answers with the policy, so the token gate stays the thing that
1493
+ * decides access. Loopback is always allowed.
1502
1494
  */
1495
+ corsOrigins?: string[];
1496
+ /** @deprecated Use `corsOrigins`. */
1503
1497
  metricsCorsOrigins?: string[];
1504
1498
  frameAncestors?: string[];
1499
+ /** Public page the Share button copies instead of this preview's address. */
1500
+ shareUrl?: string;
1505
1501
  /** @deprecated Use `streamSettings: { transport: "http", codec }`. */
1506
1502
  codec?: string;
1507
1503
  /**
@@ -1540,7 +1536,6 @@ export function handleMetricsRequest(
1540
1536
  res: SimRes,
1541
1537
  state: ServeSimState | null,
1542
1538
  samplerCache: MetricsSamplerCache = metricsSamplerCache,
1543
- corsOrigins: readonly string[] = [],
1544
1539
  tracker: ForegroundTrackerCache = foregroundTracker,
1545
1540
  ): void {
1546
1541
  if (!state) {
@@ -1553,7 +1548,6 @@ export function handleMetricsRequest(
1553
1548
  "Cache-Control": "no-cache",
1554
1549
  Connection: "keep-alive",
1555
1550
  "X-Accel-Buffering": "no",
1556
- ...corsAllowOriginHeaders(req.headers.origin, corsOrigins),
1557
1551
  });
1558
1552
  res.write(":\n\n");
1559
1553
  // Keep the foreground tail warm for this stream's lifetime so the sampler can scope to the
@@ -1586,8 +1580,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1586
1580
  // can't read this value (it's only injected into the preview page's config).
1587
1581
  const execToken = options?.execToken ?? randomBytes(32).toString("base64url");
1588
1582
  const requirePreviewToken = options?.requirePreviewToken ?? false;
1589
- const metricsCorsOrigins = options?.metricsCorsOrigins ?? [];
1583
+ const corsOrigins = [...(options?.corsOrigins ?? []), ...(options?.metricsCorsOrigins ?? [])];
1590
1584
  const frameAncestors = options?.frameAncestors ?? [];
1585
+ const shareUrl = options?.shareUrl;
1586
+ // The proxied DevTools frontend sits behind the same cookie, so its document needs the policy too.
1587
+ const framePolicyHeaders: Record<string, string> = requirePreviewToken
1588
+ ? { "Content-Security-Policy": frameAncestorsPolicy(frameAncestors) }
1589
+ : {};
1591
1590
 
1592
1591
  // Simulator-settings requests run in-process (just the underlying simctl /
1593
1592
  // ax-tool spawn) instead of round-tripping a full `node <cli>` exec per
@@ -1637,6 +1636,25 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1637
1636
  const rawUrl: string = req.url ?? "";
1638
1637
  const qIndex = rawUrl.indexOf("?");
1639
1638
  const url = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
1639
+ const ownPath = url === base || url.startsWith(`${base}/`);
1640
+ // Whole-middleware, so a new route is reachable cross-origin without opting in.
1641
+ if (ownPath) {
1642
+ // Unconditional, including when the origin is refused: a cached copy carrying no policy
1643
+ // would otherwise be replayed to an origin that is allowed one.
1644
+ res.setHeader("Vary", "Origin");
1645
+ const corsHeaders = corsAllowOriginHeaders(req.headers.origin, corsOrigins);
1646
+ for (const [name, value] of Object.entries(corsHeaders)) res.setHeader(name, value);
1647
+ }
1648
+ // A preflight carries no cookie and no token, so it has to be answered before the gate.
1649
+ if (ownPath && req.method === "OPTIONS") {
1650
+ res.writeHead(204, {
1651
+ "Access-Control-Allow-Methods": "GET, POST, PATCH, OPTIONS",
1652
+ "Access-Control-Allow-Headers": "Authorization, Content-Type",
1653
+ "Access-Control-Max-Age": "600",
1654
+ });
1655
+ res.end();
1656
+ return;
1657
+ }
1640
1658
  const requestedDevice = queryDevice(rawUrl);
1641
1659
  const selectedDevice = requestedDevice ?? options?.device ?? null;
1642
1660
  const devtoolsFrontendBase = base === "/" ? "/devtools-frontend" : `${base}/devtools-frontend`;
@@ -1682,10 +1700,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1682
1700
  const upstream = await fetch(
1683
1701
  `https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_FRONTEND_REV}/${assetPath}${upstreamSuffix}`,
1684
1702
  );
1703
+ const contentType = upstream.headers.get("content-type");
1704
+ const isDocument = contentType?.startsWith("text/html") ?? false;
1685
1705
  const headers: Record<string, string> = {
1686
- "Cache-Control": "public, max-age=604800",
1706
+ // A cached copy would otherwise outlive the policy it was fetched under.
1707
+ "Cache-Control": isDocument ? "no-store" : "public, max-age=604800",
1708
+ ...(isDocument ? framePolicyHeaders : {}),
1687
1709
  };
1688
- const contentType = upstream.headers.get("content-type");
1689
1710
  if (contentType) headers["Content-Type"] = contentType;
1690
1711
  res.writeHead(upstream.status, headers);
1691
1712
  res.end(Buffer.from(await upstream.arrayBuffer()));
@@ -1706,7 +1727,11 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1706
1727
  // Empty-state UI still polls /exec (boot/list helpers), so the page
1707
1728
  // needs the bearer token even before a helper attaches. Inject a
1708
1729
  // minimal config with just the basePath + token.
1709
- const minimal = JSON.stringify({ basePath: base, execToken });
1730
+ const minimal = JSON.stringify({
1731
+ basePath: base,
1732
+ execToken,
1733
+ ...(shareUrl ? { shareUrl } : {}),
1734
+ });
1710
1735
  html = html.replace(
1711
1736
  "<!--__SIM_PREVIEW_CONFIG__-->",
1712
1737
  `<script>window.__SIM_PREVIEW__=${minimal}</script>`,
@@ -1715,7 +1740,9 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1715
1740
 
1716
1741
  if (state) {
1717
1742
  const remoteState = rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers);
1718
- const config = JSON.stringify(previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers));
1743
+ const config = JSON.stringify(
1744
+ previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers, requirePreviewToken, shareUrl),
1745
+ );
1719
1746
  const configScript = `<script>window.__SIM_PREVIEW__=${config}</script>`;
1720
1747
  html = html.replace("<!--__SIM_PREVIEW_CONFIG__-->", configScript);
1721
1748
  }
@@ -1723,9 +1750,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1723
1750
  res.writeHead(200, {
1724
1751
  "Content-Type": "text/html; charset=utf-8",
1725
1752
  "Cache-Control": "no-store",
1726
- ...(requirePreviewToken
1727
- ? { "Content-Security-Policy": frameAncestorsPolicy(frameAncestors) }
1728
- : {}),
1753
+ ...framePolicyHeaders,
1729
1754
  });
1730
1755
  res.end(html);
1731
1756
  return;
@@ -2153,7 +2178,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2153
2178
  "Cache-Control": "no-store",
2154
2179
  });
2155
2180
  const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
2156
- res.end(JSON.stringify(remoteState ? previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers) : null));
2181
+ res.end(
2182
+ JSON.stringify(
2183
+ remoteState
2184
+ ? previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers, requirePreviewToken, shareUrl)
2185
+ : null,
2186
+ ),
2187
+ );
2157
2188
  return;
2158
2189
  }
2159
2190
 
@@ -2164,7 +2195,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2164
2195
  if (url === base + "/api/screenshot") {
2165
2196
  if (req.method !== "POST") {
2166
2197
  res.writeHead(405, {
2167
- ...SCREENSHOT_RESPONSE_HEADERS,
2198
+ "Cache-Control": "no-store",
2168
2199
  "Content-Type": "text/plain; charset=utf-8",
2169
2200
  });
2170
2201
  res.end("method not allowed");
@@ -2173,7 +2204,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2173
2204
  let udid = selectedDevice;
2174
2205
  if (udid && !isSimulatorUdid(udid)) {
2175
2206
  res.writeHead(400, {
2176
- ...SCREENSHOT_RESPONSE_HEADERS,
2207
+ "Cache-Control": "no-store",
2177
2208
  "Content-Type": "application/json",
2178
2209
  });
2179
2210
  res.end(JSON.stringify({ ok: false, error: "Invalid simulator device ID" }));
@@ -2185,7 +2216,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2185
2216
  }
2186
2217
  if (!udid) {
2187
2218
  res.writeHead(400, {
2188
- ...SCREENSHOT_RESPONSE_HEADERS,
2219
+ "Cache-Control": "no-store",
2189
2220
  "Content-Type": "application/json",
2190
2221
  });
2191
2222
  res.end(JSON.stringify({ ok: false, error: "No booted simulator to screenshot" }));
@@ -2208,7 +2239,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2208
2239
  });
2209
2240
  const png = await readFile(file);
2210
2241
  res.writeHead(200, {
2211
- ...SCREENSHOT_RESPONSE_HEADERS,
2242
+ "Cache-Control": "no-store",
2212
2243
  "Content-Type": "image/png",
2213
2244
  });
2214
2245
  res.end(png);
@@ -2218,7 +2249,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2218
2249
  (typeof stderr === "string" && stderr.trim()) ||
2219
2250
  (err instanceof Error ? err.message : String(err));
2220
2251
  res.writeHead(500, {
2221
- ...SCREENSHOT_RESPONSE_HEADERS,
2252
+ "Cache-Control": "no-store",
2222
2253
  "Content-Type": "application/json",
2223
2254
  });
2224
2255
  res.end(JSON.stringify({ ok: false, error: message }));
@@ -2289,7 +2320,9 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2289
2320
  const state = selectServeSimState(states, selectedDevice);
2290
2321
  const remoteState = state ? rewriteStateForRequestHost(state, hostForRequest(req), base, httpProtocolForRequest(req), proxyHelpers) : null;
2291
2322
  return JSON.stringify(
2292
- remoteState ? previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers) : null,
2323
+ remoteState
2324
+ ? previewConfigForState(remoteState, base, execToken, streamSettings, proxyHelpers, requirePreviewToken, shareUrl)
2325
+ : null,
2293
2326
  );
2294
2327
  };
2295
2328
 
@@ -2440,7 +2473,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2440
2473
  if (url === base + "/metrics") {
2441
2474
  const states = await readServeSimStates();
2442
2475
  const state = selectServeSimState(states, selectedDevice);
2443
- handleMetricsRequest(req, res, state, metricsSamplerCache, metricsCorsOrigins);
2476
+ handleMetricsRequest(req, res, state, metricsSamplerCache);
2444
2477
  return;
2445
2478
  }
2446
2479