@expo/serve-sim 0.2.3 → 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.3",
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") {
@@ -1502,10 +1488,12 @@ export interface SimMiddlewareOptions {
1502
1488
  /** Stream transport and codec settings for the preview. */
1503
1489
  streamSettings?: StreamSettings;
1504
1490
  /**
1505
- * Origins allowed to read the `/metrics` SSE stream cross-origin (e.g. a
1506
- * hosted dashboard). Read-only telemetry only; the control routes stay
1507
- * 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.
1508
1494
  */
1495
+ corsOrigins?: string[];
1496
+ /** @deprecated Use `corsOrigins`. */
1509
1497
  metricsCorsOrigins?: string[];
1510
1498
  frameAncestors?: string[];
1511
1499
  /** Public page the Share button copies instead of this preview's address. */
@@ -1548,7 +1536,6 @@ export function handleMetricsRequest(
1548
1536
  res: SimRes,
1549
1537
  state: ServeSimState | null,
1550
1538
  samplerCache: MetricsSamplerCache = metricsSamplerCache,
1551
- corsOrigins: readonly string[] = [],
1552
1539
  tracker: ForegroundTrackerCache = foregroundTracker,
1553
1540
  ): void {
1554
1541
  if (!state) {
@@ -1561,7 +1548,6 @@ export function handleMetricsRequest(
1561
1548
  "Cache-Control": "no-cache",
1562
1549
  Connection: "keep-alive",
1563
1550
  "X-Accel-Buffering": "no",
1564
- ...corsAllowOriginHeaders(req.headers.origin, corsOrigins),
1565
1551
  });
1566
1552
  res.write(":\n\n");
1567
1553
  // Keep the foreground tail warm for this stream's lifetime so the sampler can scope to the
@@ -1594,9 +1580,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1594
1580
  // can't read this value (it's only injected into the preview page's config).
1595
1581
  const execToken = options?.execToken ?? randomBytes(32).toString("base64url");
1596
1582
  const requirePreviewToken = options?.requirePreviewToken ?? false;
1597
- const metricsCorsOrigins = options?.metricsCorsOrigins ?? [];
1583
+ const corsOrigins = [...(options?.corsOrigins ?? []), ...(options?.metricsCorsOrigins ?? [])];
1598
1584
  const frameAncestors = options?.frameAncestors ?? [];
1599
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
+ : {};
1600
1590
 
1601
1591
  // Simulator-settings requests run in-process (just the underlying simctl /
1602
1592
  // ax-tool spawn) instead of round-tripping a full `node <cli>` exec per
@@ -1646,6 +1636,25 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1646
1636
  const rawUrl: string = req.url ?? "";
1647
1637
  const qIndex = rawUrl.indexOf("?");
1648
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
+ }
1649
1658
  const requestedDevice = queryDevice(rawUrl);
1650
1659
  const selectedDevice = requestedDevice ?? options?.device ?? null;
1651
1660
  const devtoolsFrontendBase = base === "/" ? "/devtools-frontend" : `${base}/devtools-frontend`;
@@ -1691,10 +1700,13 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1691
1700
  const upstream = await fetch(
1692
1701
  `https://chrome-devtools-frontend.appspot.com/serve_rev/@${DEVTOOLS_FRONTEND_REV}/${assetPath}${upstreamSuffix}`,
1693
1702
  );
1703
+ const contentType = upstream.headers.get("content-type");
1704
+ const isDocument = contentType?.startsWith("text/html") ?? false;
1694
1705
  const headers: Record<string, string> = {
1695
- "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 : {}),
1696
1709
  };
1697
- const contentType = upstream.headers.get("content-type");
1698
1710
  if (contentType) headers["Content-Type"] = contentType;
1699
1711
  res.writeHead(upstream.status, headers);
1700
1712
  res.end(Buffer.from(await upstream.arrayBuffer()));
@@ -1738,9 +1750,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
1738
1750
  res.writeHead(200, {
1739
1751
  "Content-Type": "text/html; charset=utf-8",
1740
1752
  "Cache-Control": "no-store",
1741
- ...(requirePreviewToken
1742
- ? { "Content-Security-Policy": frameAncestorsPolicy(frameAncestors) }
1743
- : {}),
1753
+ ...framePolicyHeaders,
1744
1754
  });
1745
1755
  res.end(html);
1746
1756
  return;
@@ -2185,7 +2195,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2185
2195
  if (url === base + "/api/screenshot") {
2186
2196
  if (req.method !== "POST") {
2187
2197
  res.writeHead(405, {
2188
- ...SCREENSHOT_RESPONSE_HEADERS,
2198
+ "Cache-Control": "no-store",
2189
2199
  "Content-Type": "text/plain; charset=utf-8",
2190
2200
  });
2191
2201
  res.end("method not allowed");
@@ -2194,7 +2204,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2194
2204
  let udid = selectedDevice;
2195
2205
  if (udid && !isSimulatorUdid(udid)) {
2196
2206
  res.writeHead(400, {
2197
- ...SCREENSHOT_RESPONSE_HEADERS,
2207
+ "Cache-Control": "no-store",
2198
2208
  "Content-Type": "application/json",
2199
2209
  });
2200
2210
  res.end(JSON.stringify({ ok: false, error: "Invalid simulator device ID" }));
@@ -2206,7 +2216,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2206
2216
  }
2207
2217
  if (!udid) {
2208
2218
  res.writeHead(400, {
2209
- ...SCREENSHOT_RESPONSE_HEADERS,
2219
+ "Cache-Control": "no-store",
2210
2220
  "Content-Type": "application/json",
2211
2221
  });
2212
2222
  res.end(JSON.stringify({ ok: false, error: "No booted simulator to screenshot" }));
@@ -2229,7 +2239,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2229
2239
  });
2230
2240
  const png = await readFile(file);
2231
2241
  res.writeHead(200, {
2232
- ...SCREENSHOT_RESPONSE_HEADERS,
2242
+ "Cache-Control": "no-store",
2233
2243
  "Content-Type": "image/png",
2234
2244
  });
2235
2245
  res.end(png);
@@ -2239,7 +2249,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2239
2249
  (typeof stderr === "string" && stderr.trim()) ||
2240
2250
  (err instanceof Error ? err.message : String(err));
2241
2251
  res.writeHead(500, {
2242
- ...SCREENSHOT_RESPONSE_HEADERS,
2252
+ "Cache-Control": "no-store",
2243
2253
  "Content-Type": "application/json",
2244
2254
  });
2245
2255
  res.end(JSON.stringify({ ok: false, error: message }));
@@ -2463,7 +2473,7 @@ export function simMiddleware(options?: SimMiddlewareOptions): SimMiddleware {
2463
2473
  if (url === base + "/metrics") {
2464
2474
  const states = await readServeSimStates();
2465
2475
  const state = selectServeSimState(states, selectedDevice);
2466
- handleMetricsRequest(req, res, state, metricsSamplerCache, metricsCorsOrigins);
2476
+ handleMetricsRequest(req, res, state, metricsSamplerCache);
2467
2477
  return;
2468
2478
  }
2469
2479