@qontinui/ui-bridge-native 0.6.12 → 0.6.13

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.
Files changed (63) hide show
  1. package/README.md +36 -16
  2. package/dist/control/action-executor.d.ts +42 -2
  3. package/dist/control/action-executor.d.ts.map +1 -1
  4. package/dist/control/index.js +723 -4
  5. package/dist/control/index.js.map +1 -1
  6. package/dist/control/index.mjs +723 -5
  7. package/dist/control/index.mjs.map +1 -1
  8. package/dist/control/types.d.ts +82 -1
  9. package/dist/control/types.d.ts.map +1 -1
  10. package/dist/core/abortable.d.ts +151 -0
  11. package/dist/core/abortable.d.ts.map +1 -0
  12. package/dist/core/action-effect.d.ts +74 -0
  13. package/dist/core/action-effect.d.ts.map +1 -0
  14. package/dist/core/element-actions.d.ts +38 -0
  15. package/dist/core/element-actions.d.ts.map +1 -0
  16. package/dist/core/index.d.ts +4 -0
  17. package/dist/core/index.d.ts.map +1 -1
  18. package/dist/core/index.js +756 -15
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/index.mjs +738 -16
  21. package/dist/core/index.mjs.map +1 -1
  22. package/dist/core/param-schema.d.ts +282 -0
  23. package/dist/core/param-schema.d.ts.map +1 -0
  24. package/dist/core/registry.d.ts +272 -2
  25. package/dist/core/registry.d.ts.map +1 -1
  26. package/dist/core/types.d.ts +231 -32
  27. package/dist/core/types.d.ts.map +1 -1
  28. package/dist/debug/index.js.map +1 -1
  29. package/dist/debug/index.mjs.map +1 -1
  30. package/dist/index.d.ts +10 -5
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1502 -117
  33. package/dist/index.js.map +1 -1
  34. package/dist/index.mjs +1484 -118
  35. package/dist/index.mjs.map +1 -1
  36. package/dist/react/UIBridgeNativeProvider.d.ts +1 -23
  37. package/dist/react/UIBridgeNativeProvider.d.ts.map +1 -1
  38. package/dist/react/index.js +1319 -112
  39. package/dist/react/index.js.map +1 -1
  40. package/dist/react/index.mjs +1319 -112
  41. package/dist/react/index.mjs.map +1 -1
  42. package/dist/react/useUIBridge.d.ts +19 -22
  43. package/dist/react/useUIBridge.d.ts.map +1 -1
  44. package/dist/react/useUIComponent.d.ts +113 -8
  45. package/dist/react/useUIComponent.d.ts.map +1 -1
  46. package/dist/react/useUIElement.d.ts +34 -8
  47. package/dist/react/useUIElement.d.ts.map +1 -1
  48. package/dist/server/handlers.d.ts.map +1 -1
  49. package/dist/server/http-server.d.ts +15 -2
  50. package/dist/server/http-server.d.ts.map +1 -1
  51. package/dist/server/index.js +345 -43
  52. package/dist/server/index.js.map +1 -1
  53. package/dist/server/index.mjs +345 -44
  54. package/dist/server/index.mjs.map +1 -1
  55. package/dist/server/observability.d.ts +40 -0
  56. package/dist/server/observability.d.ts.map +1 -1
  57. package/dist/server/types.d.ts +57 -1
  58. package/dist/server/types.d.ts.map +1 -1
  59. package/dist/transport/CloudRelayClient.d.ts.map +1 -1
  60. package/dist/transport/DeviceAnnouncer.d.ts.map +1 -1
  61. package/dist/transport/relay-logging.d.ts +50 -0
  62. package/dist/transport/relay-logging.d.ts.map +1 -0
  63. package/package.json +2 -2
@@ -9,12 +9,31 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
9
9
 
10
10
  // src/server/types.ts
11
11
  var UI_BRIDGE_NATIVE_ROUTES = {
12
+ // Discovery / introspection — the two endpoints an agent hits first to learn
13
+ // the rest of this table without reading docs. Special-cased in
14
+ // `routeRequest` rather than living in `WS_ROUTES`, and appended by
15
+ // `buildRoutesPayload` so `_routes` describes itself.
16
+ HELP: {
17
+ method: "GET",
18
+ path: "/ui-bridge/_help",
19
+ description: "Route index + action reference + tips (rich discovery payload)"
20
+ },
21
+ ROUTES: {
22
+ method: "GET",
23
+ path: "/ui-bridge/_routes",
24
+ description: "Minimal {method, path} route list, matching the runner /_routes shape"
25
+ },
12
26
  // Control - Elements
13
27
  GET_ELEMENTS: {
14
28
  method: "GET",
15
29
  path: "/ui-bridge/control/elements",
16
30
  description: "List all registered elements"
17
31
  },
32
+ GET_ELEMENTS_FLAT: {
33
+ method: "GET",
34
+ path: "/ui-bridge/elements",
35
+ description: "List all registered elements (flat alias matching the runner path)"
36
+ },
18
37
  GET_ELEMENT: {
19
38
  method: "GET",
20
39
  path: "/ui-bridge/control/element/:id",
@@ -52,6 +71,11 @@ var UI_BRIDGE_NATIVE_ROUTES = {
52
71
  path: "/ui-bridge/control/find",
53
72
  description: "Find elements matching criteria"
54
73
  },
74
+ AI_FIND: {
75
+ method: "POST",
76
+ path: "/ui-bridge/ai/find",
77
+ description: "Find elements matching criteria (runner-parity alias for /control/find)"
78
+ },
55
79
  GET_SNAPSHOT: {
56
80
  method: "GET",
57
81
  path: "/ui-bridge/control/snapshot",
@@ -62,11 +86,78 @@ var UI_BRIDGE_NATIVE_ROUTES = {
62
86
  path: "/ui-bridge/control/discover",
63
87
  description: "Discover elements (alias for snapshot; web/runner parity)"
64
88
  },
89
+ DISCOVER_POST: {
90
+ method: "POST",
91
+ path: "/ui-bridge/control/discover",
92
+ description: "Discover elements over POST (same handler as the GET form; web/runner parity)"
93
+ },
65
94
  PAGE_HEALTH: {
66
95
  method: "POST",
67
96
  path: "/ui-bridge/control/page-health",
68
97
  description: "Holistic page health diagnostic: spatial coverage, layout regions, text signals, interactive readiness, anomalies + ASCII heatmap"
69
98
  },
99
+ PAGE_HEALTH_GET: {
100
+ method: "GET",
101
+ path: "/ui-bridge/control/page-health",
102
+ description: "Page health diagnostic over GET, for ad-hoc curls with no body"
103
+ },
104
+ // Screenshots — GET and POST both accepted so a curl works either way.
105
+ //
106
+ // Both keys carry an explicit verb suffix. The pre-existing multi-verb pairs
107
+ // do not agree on what an UNSUFFIXED key means — `DISCOVER` is the GET and
108
+ // `PAGE_HEALTH` is the POST — so a consumer reading `ROUTES.<NAME>.method`
109
+ // off a bare name gets opposite verbs from equally unmarked keys. Those two
110
+ // names are load-bearing for existing consumers and are left alone; new
111
+ // multi-verb entries name their verb rather than entrenching the ambiguity.
112
+ SCREENSHOT_GET: {
113
+ method: "GET",
114
+ path: "/ui-bridge/control/screenshot",
115
+ description: "PNG screenshot of the current screen"
116
+ },
117
+ SCREENSHOT_POST: {
118
+ method: "POST",
119
+ path: "/ui-bridge/control/screenshot",
120
+ description: "PNG screenshot of the current screen (POST form)"
121
+ },
122
+ // Coordinate-based interaction
123
+ CONTROL_TAP: {
124
+ method: "POST",
125
+ path: "/ui-bridge/control/tap",
126
+ description: "Synthesize a press at screen coords by matching registered element layout rects: body { x, y, action? }"
127
+ },
128
+ // App-agnostic interaction parity with the web/runner surface. The
129
+ // selector-based pair has no RN analog and answers NOT_SUPPORTED (501).
130
+ PAGE_CLICK_BY_TEXT: {
131
+ method: "POST",
132
+ path: "/ui-bridge/control/page/click-by-text",
133
+ description: "Press the first element matching free text: body { text, visibleOnly? }"
134
+ },
135
+ PAGE_CLICK_BY_SELECTOR: {
136
+ method: "POST",
137
+ path: "/ui-bridge/control/page/click-by-selector",
138
+ description: "Selector-based press (web-only; mobile returns NOT_SUPPORTED)"
139
+ },
140
+ PAGE_TYPE_INTO: {
141
+ method: "POST",
142
+ path: "/ui-bridge/control/page/type-into",
143
+ description: "Type into the input matching a label: body { label, text, clear?, visibleOnly? }"
144
+ },
145
+ PAGE_READ_VALUE: {
146
+ method: "POST",
147
+ path: "/ui-bridge/control/page/read-value",
148
+ description: "Selector-based value read (web-only; mobile returns NOT_SUPPORTED)"
149
+ },
150
+ PAGE_FIND_BY_TEXT: {
151
+ method: "POST",
152
+ path: "/ui-bridge/control/page/find-by-text",
153
+ description: "List elements matching free text without pressing: body { text, visibleOnly? }"
154
+ },
155
+ // Meta
156
+ META_METHODS: {
157
+ method: "GET",
158
+ path: "/ui-bridge/meta/methods",
159
+ description: "List every handler name (legacy introspection; prefer /_routes)"
160
+ },
70
161
  // Workflows
71
162
  GET_WORKFLOWS: {
72
163
  method: "GET",
@@ -145,6 +236,27 @@ var UI_BRIDGE_NATIVE_ROUTES = {
145
236
  path: "/ui-bridge/design/style-guide",
146
237
  description: "Clear loaded style guide"
147
238
  },
239
+ // Quality Evaluation
240
+ DESIGN_EVALUATE: {
241
+ method: "POST",
242
+ path: "/ui-bridge/design/evaluate",
243
+ description: "Score the current UI against a quality context"
244
+ },
245
+ DESIGN_EVALUATE_CONTEXTS: {
246
+ method: "GET",
247
+ path: "/ui-bridge/design/evaluate/contexts",
248
+ description: "List available quality evaluation contexts"
249
+ },
250
+ DESIGN_EVALUATE_BASELINE: {
251
+ method: "POST",
252
+ path: "/ui-bridge/design/evaluate/baseline",
253
+ description: "Save a quality baseline snapshot"
254
+ },
255
+ DESIGN_EVALUATE_DIFF: {
256
+ method: "POST",
257
+ path: "/ui-bridge/design/evaluate/diff",
258
+ description: "Diff the current UI against a saved baseline"
259
+ },
148
260
  // AI helpers
149
261
  AI_FILL_FORM: {
150
262
  method: "POST",
@@ -176,18 +288,34 @@ var UI_BRIDGE_NATIVE_ROUTES = {
176
288
  path: "/ui-bridge/ai/wait-for-element",
177
289
  description: "Wait for element predicate via HTTP (runner-only; mobile returns NOT_SUPPORTED \u2014 use WS waitForElement instead)"
178
290
  },
179
- // Test hooks — drive ModalDetector state from outside the React tree
180
- // (gated by `features.testHooks`). Mirrors `pushModal` / `dismissModal`
181
- // calls that components normally make in-process.
291
+ // Test hooks — drive ModalDetector state and the screen lock from outside
292
+ // the React tree (gated by `features.testHooks`). Mirrors `pushModal` /
293
+ // `dismissModal` calls that components normally make in-process.
294
+ //
295
+ // These are LISTED unconditionally — this table is a static constant with no
296
+ // config to consult — but each carries `requiresTestHooks: true`, which is
297
+ // the machine-readable form of the `(testHooks)` in its description. With the
298
+ // flag off `routeRequest` and `buildRoutesPayload` both drop them and the
299
+ // route answers a bare 404. `route-table-parity.test.ts` compares the whole
300
+ // table against `_routes` at `testHooks: true`, and the flagged subset
301
+ // against `_routes` at `testHooks: false`, so both surfaces are pinned.
182
302
  PUSH_MODAL: {
183
303
  method: "POST",
184
304
  path: "/ui-bridge/control/modal/push",
185
- description: "Push a modal onto the modal stack (testHooks)"
305
+ description: "Push a modal onto the modal stack (testHooks)",
306
+ requiresTestHooks: true
186
307
  },
187
308
  DISMISS_MODAL: {
188
309
  method: "POST",
189
310
  path: "/ui-bridge/control/modal/dismiss/:id",
190
- description: "Dismiss a modal by id (testHooks)"
311
+ description: "Dismiss a modal by id (testHooks)",
312
+ requiresTestHooks: true
313
+ },
314
+ KEEP_AWAKE: {
315
+ method: "POST",
316
+ path: "/ui-bridge/control/keep-awake",
317
+ description: "Keep the device screen awake via the injected keepAwakeProvider: body { enabled, durationMs? } (testHooks)",
318
+ requiresTestHooks: true
191
319
  },
192
320
  // Observability — last-N ring buffers (always mounted; `installed:false`
193
321
  // when the console/network patches are off, i.e. no testHooks).
@@ -382,6 +510,21 @@ function projectVisionFields(args) {
382
510
  return fields;
383
511
  }
384
512
 
513
+ // src/core/element-actions.ts
514
+ function serializeElementCustomActions(customActions) {
515
+ if (!customActions) return void 0;
516
+ const out = [];
517
+ for (const [id, action] of Object.entries(customActions)) {
518
+ if (!action) continue;
519
+ const entry = { id };
520
+ if (action.label !== void 0) entry.label = action.label;
521
+ if (action.description !== void 0) entry.description = action.description;
522
+ if (action.effect !== void 0) entry.effect = action.effect;
523
+ out.push(entry);
524
+ }
525
+ return out;
526
+ }
527
+
385
528
  // src/core/registry.ts
386
529
  function extractHandlerNames(props) {
387
530
  if (!props) return [];
@@ -410,6 +553,46 @@ function projectBbox(state, visibility, pixelRatio = 1) {
410
553
  h: toPx(layout.height)
411
554
  };
412
555
  }
556
+ function pageRectOf(state) {
557
+ const layout = state.layout;
558
+ if (!layout) return null;
559
+ if (layout.pageOriginUnmeasured === true) return null;
560
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
561
+ if (!hasPage) return null;
562
+ const left = layout.pageX;
563
+ const top = layout.pageY;
564
+ if (typeof layout.width !== "number" || typeof layout.height !== "number" || !Number.isFinite(layout.width) || !Number.isFinite(layout.height)) {
565
+ return null;
566
+ }
567
+ return { left, top, right: left + layout.width, bottom: top + layout.height };
568
+ }
569
+ function spansOverlap(aMin, aMax, bMin, bMax) {
570
+ if (aMin === aMax) return aMin >= bMin && aMin <= bMax;
571
+ return aMin < bMax && aMax > bMin;
572
+ }
573
+ function isEmptyRect(rect) {
574
+ return rect.right <= rect.left || rect.bottom <= rect.top;
575
+ }
576
+ function computeVisibility(state, clip) {
577
+ if (!state.visible) {
578
+ return { visibility: "hidden", visibilityReason: "hidden" };
579
+ }
580
+ if (state.layout === null) {
581
+ return { visibility: "likely-visible", visibilityReason: "no-layout" };
582
+ }
583
+ if (!clip) {
584
+ return { visibility: "visible" };
585
+ }
586
+ const rect = pageRectOf(state);
587
+ if (!rect) {
588
+ return { visibility: "visible" };
589
+ }
590
+ if (isEmptyRect(clip)) {
591
+ return { visibility: "hidden", visibilityReason: "off-screen" };
592
+ }
593
+ const onScreen = spansOverlap(rect.left, rect.right, clip.left, clip.right) && spansOverlap(rect.top, rect.bottom, clip.top, clip.bottom);
594
+ return onScreen ? { visibility: "visible" } : { visibility: "hidden", visibilityReason: "off-screen" };
595
+ }
413
596
 
414
597
  // src/design/design-inspector-native.ts
415
598
  function toCssValue(value) {
@@ -1364,10 +1547,14 @@ function createServerHandlers(registry, executor, config) {
1364
1547
  if (forRoute) {
1365
1548
  allElements = allElements.filter((e) => matchesCurrentRoute(e.registrationRoute, forRoute));
1366
1549
  }
1550
+ const viewport = registry.getViewportRect();
1367
1551
  const elements = allElements.map((e) => {
1368
1552
  const handlers = extractHandlerNames(e.props);
1369
1553
  const state = e.getState();
1370
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1554
+ const { visibility, visibilityReason } = computeVisibility(
1555
+ state,
1556
+ registry.getClipRectFor(e, viewport)
1557
+ );
1371
1558
  const bbox = projectBbox(state, visibility, registry.getPixelRatio());
1372
1559
  const vision = projectVisionFields({
1373
1560
  type: e.type,
@@ -1385,10 +1572,11 @@ function createServerHandlers(registry, executor, config) {
1385
1572
  identifier: e.getIdentifier(),
1386
1573
  state,
1387
1574
  actions: e.actions,
1388
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1575
+ customActions: serializeElementCustomActions(e.customActions),
1389
1576
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
1390
1577
  registrationRoute: e.registrationRoute,
1391
1578
  visibility,
1579
+ ...visibilityReason ? { visibilityReason } : {},
1392
1580
  ...bbox ? { bbox } : {},
1393
1581
  ...vision
1394
1582
  };
@@ -1410,7 +1598,7 @@ function createServerHandlers(registry, executor, config) {
1410
1598
  identifier: element.getIdentifier(),
1411
1599
  state: resolveElementState(element),
1412
1600
  actions: element.actions,
1413
- customActions: element.customActions ? Object.keys(element.customActions) : void 0,
1601
+ customActions: serializeElementCustomActions(element.customActions),
1414
1602
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
1415
1603
  registrationRoute: element.registrationRoute
1416
1604
  }
@@ -1431,13 +1619,9 @@ function createServerHandlers(registry, executor, config) {
1431
1619
  if (!envelope?.action) {
1432
1620
  return error2("Action is required", "INVALID_REQUEST");
1433
1621
  }
1434
- const response = await executor.executeAction(id, {
1435
- action: envelope.action,
1436
- params: envelope.params,
1437
- waitOptions: envelope.waitOptions
1438
- });
1622
+ const response = await executor.executeAction(id, envelope);
1439
1623
  if (!response.success) {
1440
- return error2(response.error || "Action failed", "ACTION_FAILED");
1624
+ return error2(response.error || "Action failed", response.code ?? "ACTION_FAILED");
1441
1625
  }
1442
1626
  return success2(response);
1443
1627
  },
@@ -1447,7 +1631,11 @@ function createServerHandlers(registry, executor, config) {
1447
1631
  id: c.id,
1448
1632
  name: c.name,
1449
1633
  description: c.description,
1450
- actions: c.actions.map((a) => ({ id: a.id, label: a.label })),
1634
+ // Phase 4 (plan 2026-08-20-ui-bridge-action-declaration-shape):
1635
+ // carry the safety annotation onto the native wire too — a walker
1636
+ // driving a React Native app needs the same `destructive` exclusion
1637
+ // the web channel gets.
1638
+ actions: c.actions.map((a) => ({ id: a.id, label: a.label, effect: a.effect })),
1451
1639
  elementIds: c.elementIds
1452
1640
  }));
1453
1641
  return success2({ components });
@@ -1466,7 +1654,9 @@ function createServerHandlers(registry, executor, config) {
1466
1654
  actions: component.actions.map((a) => ({
1467
1655
  id: a.id,
1468
1656
  label: a.label,
1469
- description: a.description
1657
+ description: a.description,
1658
+ // Phase 4 — see the listing twin above.
1659
+ effect: a.effect
1470
1660
  })),
1471
1661
  elementIds: component.elementIds
1472
1662
  }
@@ -1477,7 +1667,8 @@ function createServerHandlers(registry, executor, config) {
1477
1667
  const body = ctx.body;
1478
1668
  const response = await executor.executeComponentAction(id, {
1479
1669
  action: actionId,
1480
- params: body?.params
1670
+ params: body?.params,
1671
+ timeoutMs: body?.timeoutMs
1481
1672
  });
1482
1673
  if (!response.success) {
1483
1674
  return error2(response.error || "Action failed", "ACTION_FAILED");
@@ -1669,9 +1860,12 @@ function createServerHandlers(registry, executor, config) {
1669
1860
  //
1670
1861
  // The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
1671
1862
  // cheatsheet paths so callers (and the /manual-test skill in particular)
1672
- // get a structured envelope explaining the gap rather than a confusing
1673
- // HTTP 404. Each error message names an in-tree mobile replacement so
1674
- // operators aren't left guessing what to use instead.
1863
+ // get a structured envelope explaining the gap rather than a bare
1864
+ // "no such route". These answer **HTTP 501** — `httpStatusForResponse` in
1865
+ // server/http-server.ts maps NOT_SUPPORTED → 501, so the status line and
1866
+ // the envelope agree; an unmounted path answers 404 instead. Each error
1867
+ // message names an in-tree mobile replacement so operators aren't left
1868
+ // guessing what to use instead.
1675
1869
  aiForms: async () => {
1676
1870
  return error2(
1677
1871
  'ai/forms (form discovery) is runner-only; mobile React Native has no DOM to walk. Use GET /ui-bridge/control/snapshot and filter for type === "input" instead.',
@@ -1998,18 +2192,12 @@ function createServerHandlers(registry, executor, config) {
1998
2192
  pushModal: async (ctx) => {
1999
2193
  const detector = registry.getEnrichers().modalDetector;
2000
2194
  if (!detector) {
2001
- return error2(
2002
- "modal detector not registered",
2003
- "ENRICHER_UNAVAILABLE"
2004
- );
2195
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
2005
2196
  }
2006
2197
  const body = ctx.body;
2007
2198
  const id = body?.id;
2008
2199
  if (typeof id !== "string" || id.length === 0) {
2009
- return error2(
2010
- 'pushModal requires a non-empty "id"',
2011
- "INVALID_REQUEST"
2012
- );
2200
+ return error2('pushModal requires a non-empty "id"', "INVALID_REQUEST");
2013
2201
  }
2014
2202
  const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
2015
2203
  const requestedType = body?.type;
@@ -2042,10 +2230,7 @@ function createServerHandlers(registry, executor, config) {
2042
2230
  dismissModal: async (ctx) => {
2043
2231
  const detector = registry.getEnrichers().modalDetector;
2044
2232
  if (!detector) {
2045
- return error2(
2046
- "modal detector not registered",
2047
- "ENRICHER_UNAVAILABLE"
2048
- );
2233
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
2049
2234
  }
2050
2235
  const { id } = ctx.params;
2051
2236
  if (typeof id !== "string" || id.length === 0) {
@@ -2075,7 +2260,7 @@ function createServerHandlers(registry, executor, config) {
2075
2260
  };
2076
2261
  if (config?.appInfo) {
2077
2262
  response.uiBridge = {
2078
- version: "0.6.12",
2263
+ version: "0.6.13",
2079
2264
  ...config.appInfo,
2080
2265
  capabilities: ["elements", "components", "actions", "design"],
2081
2266
  elementCount: stats.elements,
@@ -2223,8 +2408,48 @@ function extractMethod(input, init) {
2223
2408
  var NetworkRequestBuffer = class {
2224
2409
  constructor(capacity = 100) {
2225
2410
  this.installed = false;
2411
+ /**
2412
+ * Synchronous re-entrancy depth: non-zero while the ORIGINAL `fetch` is
2413
+ * executing, which is exactly the window in which React Native's whatwg-fetch
2414
+ * polyfill constructs and opens its internal `XMLHttpRequest`.
2415
+ *
2416
+ * A counter rather than a boolean so a `fetch` called from inside a `fetch`
2417
+ * (an interceptor, a retry wrapper) still unwinds to zero. Safe as a plain
2418
+ * number: JS is single-threaded and the only writes are the paired
2419
+ * increment/decrement around one synchronous call.
2420
+ */
2421
+ this.fetchDepth = { value: 0 };
2226
2422
  this.buffer = new RingBuffer(capacity);
2227
2423
  }
2424
+ /**
2425
+ * Patch `fetch` and `XMLHttpRequest` so `GET /sdk/network-requests` can
2426
+ * report what the app talked to.
2427
+ *
2428
+ * ONE REQUEST MUST PRODUCE ONE ENTRY. React Native implements `fetch` **on
2429
+ * top of** XHR (whatwg-fetch), so both patches observe the same request and
2430
+ * the buffer used to carry every call twice — same url, timestamps ~1ms
2431
+ * apart, and on failure one `"Network request failed"` beside one
2432
+ * `"XMLHttpRequest error"`. A tester reads that as a double-fetch bug in the
2433
+ * app and chases a defect that does not exist.
2434
+ *
2435
+ * The de-dup: `fetchDepth` is raised across the synchronous call into the
2436
+ * original `fetch`, which is when the polyfill builds its XHR; `open` stamps
2437
+ * any XHR created in that window with `__uiBridgeFromFetch`, and `send`
2438
+ * skips recording those. The `fetch` patch keeps the entry, because it is
2439
+ * the layer the app actually called and the only one that sees `Response.ok`.
2440
+ *
2441
+ * Uninstalling both patches together keeps this sound: an XHR can only be
2442
+ * marked while the fetch patch is live.
2443
+ *
2444
+ * KNOWN LIMIT — it is coupled to the polyfill building its XHR SYNCHRONOUSLY,
2445
+ * which whatwg-fetch does (its work happens in the Promise executor). If the
2446
+ * host's `fetch` is wrapped by an interceptor that defers the real call to a
2447
+ * microtask or `setTimeout`, the depth is back to 0 by the time `open` runs
2448
+ * and every request duplicates again — the original symptom, with no signal
2449
+ * that the mechanism disengaged. Duplicates in `/sdk/network-requests` are
2450
+ * the thing to look for; they mean this assumption stopped holding, not that
2451
+ * the app double-fetched.
2452
+ */
2228
2453
  install() {
2229
2454
  if (this.installed) return;
2230
2455
  const g = globalThis;
@@ -2232,12 +2457,20 @@ var NetworkRequestBuffer = class {
2232
2457
  this.originalFetch = g.fetch;
2233
2458
  const buffer = this.buffer;
2234
2459
  const original = this.originalFetch;
2460
+ const fetchDepth = this.fetchDepth;
2235
2461
  const patched = async (input, init) => {
2236
2462
  const start = Date.now();
2237
2463
  const url = extractUrl(input);
2238
2464
  const method = extractMethod(input, init);
2239
2465
  try {
2240
- const res = await original.call(globalThis, input, init);
2466
+ let pending;
2467
+ fetchDepth.value += 1;
2468
+ try {
2469
+ pending = original.call(globalThis, input, init);
2470
+ } finally {
2471
+ fetchDepth.value -= 1;
2472
+ }
2473
+ const res = await pending;
2241
2474
  buffer.push({
2242
2475
  timestamp: start,
2243
2476
  method,
@@ -2267,10 +2500,14 @@ var NetworkRequestBuffer = class {
2267
2500
  this.originalXhrOpen = Xhr.prototype.open;
2268
2501
  this.originalXhrSend = Xhr.prototype.send;
2269
2502
  const buffer = this.buffer;
2503
+ const fetchDepth = this.fetchDepth;
2270
2504
  const origOpen = this.originalXhrOpen;
2271
2505
  Xhr.prototype.open = function(method, url, ...rest) {
2272
2506
  this.__uiBridgeMethod = method.toUpperCase();
2273
2507
  this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
2508
+ if (fetchDepth.value > 0) {
2509
+ this.__uiBridgeFromFetch = true;
2510
+ }
2274
2511
  return origOpen.call(
2275
2512
  this,
2276
2513
  method,
@@ -2280,6 +2517,9 @@ var NetworkRequestBuffer = class {
2280
2517
  };
2281
2518
  const origSend = this.originalXhrSend;
2282
2519
  Xhr.prototype.send = function(body) {
2520
+ if (this.__uiBridgeFromFetch === true) {
2521
+ return origSend.call(this, body);
2522
+ }
2283
2523
  const start = Date.now();
2284
2524
  const method = this.__uiBridgeMethod ?? "GET";
2285
2525
  const url = this.__uiBridgeUrl ?? "";
@@ -2359,6 +2599,16 @@ function isUnsubscribe(msg) {
2359
2599
  }
2360
2600
 
2361
2601
  // src/server/http-server.ts
2602
+ var ERROR_CODE_HTTP_STATUS = /* @__PURE__ */ new Map([
2603
+ ["NOT_SUPPORTED", 501],
2604
+ ["NOT_FOUND", 404],
2605
+ ["METHOD_NOT_ALLOWED", 405]
2606
+ ]);
2607
+ function httpStatusForResponse(response) {
2608
+ if (response.success) return 200;
2609
+ const mapped = response.code ? ERROR_CODE_HTTP_STATUS.get(response.code) : void 0;
2610
+ return mapped ?? 400;
2611
+ }
2362
2612
  function parsePath(pattern, actual) {
2363
2613
  const patternParts = pattern.split("/");
2364
2614
  const actualParts = actual.split("/");
@@ -2521,6 +2771,23 @@ var WS_ROUTES = [
2521
2771
  httpMethods: ["GET"]
2522
2772
  }
2523
2773
  ];
2774
+ var DISCOVERY_PATHS = /* @__PURE__ */ new Set(["_help", "_routes"]);
2775
+ function allowedMethodsForPath(path, testHooks) {
2776
+ const PREFIX = "/ui-bridge/";
2777
+ if (!path.startsWith(PREFIX)) return [];
2778
+ const stripped = path.slice(PREFIX.length);
2779
+ const methods = [];
2780
+ if (DISCOVERY_PATHS.has(stripped)) methods.push("GET");
2781
+ for (const route of WS_ROUTES) {
2782
+ if (!route.httpMethods) continue;
2783
+ if (route.requiresTestHooks && !testHooks) continue;
2784
+ if (parsePath(route.pattern, stripped) === null) continue;
2785
+ for (const verb of route.httpMethods) {
2786
+ if (!methods.includes(verb)) methods.push(verb);
2787
+ }
2788
+ }
2789
+ return methods;
2790
+ }
2524
2791
  var HANDLER_DESCRIPTIONS = {
2525
2792
  health: "Liveness probe; returns app metadata + element count",
2526
2793
  getElements: "Flat list of registered UI elements",
@@ -2583,6 +2850,10 @@ var ACTION_REFERENCE = [
2583
2850
  { name: "focus", params: "none" },
2584
2851
  { name: "blur", params: "none" },
2585
2852
  { name: "scroll", params: "ScrollActionParams { offset? {x,y}, to? }" },
2853
+ {
2854
+ name: "scrollIntoView",
2855
+ params: "ScrollIntoViewActionParams { padding? (dp, default 16) } \u2014 scrolls the element's DECLARED scrollAncestorId ScrollView; returns { alreadyVisible, scrolled }; NOT_SUPPORTED (501) without a usable declared ancestor"
2856
+ },
2586
2857
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
2587
2858
  { name: "toggle", params: "none \u2014 flips boolean controls" }
2588
2859
  ];
@@ -2780,7 +3051,9 @@ var NativeUIBridgeServer = class {
2780
3051
  }
2781
3052
  /**
2782
3053
  * Set a route provider for reporting the current navigation route.
2783
- * When set, `control/snapshot` responses include `currentRoute` and `segments`.
3054
+ * When set, `control/snapshot` responses include `currentRoute` and
3055
+ * `segments`, plus the canonical `route` / `activeTab` aliases the web SDK's
3056
+ * `BridgeSnapshot` spells the same way.
2784
3057
  *
2785
3058
  * Also forwards the provider into the registry so the *default*
2786
3059
  * `getSnapshot` handler (and any other call site of
@@ -2795,7 +3068,12 @@ var NativeUIBridgeServer = class {
2795
3068
  this.handlers.getElements = async (ctx) => {
2796
3069
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
2797
3070
  if (currentRouteOnly && !ctx.query?.route && !ctx.body?.route) {
2798
- const currentRoute = provider.getCurrentRoute();
3071
+ let currentRoute;
3072
+ try {
3073
+ currentRoute = provider.getCurrentRoute();
3074
+ } catch {
3075
+ currentRoute = null;
3076
+ }
2799
3077
  if (currentRoute) {
2800
3078
  ctx.query = { ...ctx.query, route: currentRoute };
2801
3079
  }
@@ -2805,11 +3083,26 @@ var NativeUIBridgeServer = class {
2805
3083
  this.handlers.getSnapshot = async (ctx) => {
2806
3084
  const visibleOnly = ctx?.query?.visibleOnly === "true" || ctx?.body?.visibleOnly === true;
2807
3085
  const currentRouteOnly = ctx?.query?.currentRouteOnly === "true" || ctx?.body?.currentRouteOnly === true;
3086
+ let currentRoute;
3087
+ try {
3088
+ currentRoute = provider.getCurrentRoute() ?? null;
3089
+ } catch {
3090
+ currentRoute = null;
3091
+ }
3092
+ let segments;
3093
+ try {
3094
+ segments = provider.getSegments?.();
3095
+ } catch {
3096
+ segments = void 0;
3097
+ }
3098
+ let activeTab;
3099
+ try {
3100
+ activeTab = provider.getActiveTab?.();
3101
+ } catch {
3102
+ activeTab = void 0;
3103
+ }
2808
3104
  const snapshot = this.registry.createSnapshot(
2809
- {
2810
- currentRoute: provider.getCurrentRoute(),
2811
- segments: provider.getSegments?.()
2812
- },
3105
+ { currentRoute, segments, activeTab },
2813
3106
  { visibleOnly, currentRouteOnly }
2814
3107
  );
2815
3108
  return { success: true, data: snapshot, timestamp: Date.now() };
@@ -3475,8 +3768,15 @@ var NativeUIBridgeServer = class {
3475
3768
  }
3476
3769
  try {
3477
3770
  const response = await this.routeRequest(request);
3771
+ if (response.code === "METHOD_NOT_ALLOWED") {
3772
+ const allow = allowedMethodsForPath(request.path, this.config.testHooks === true);
3773
+ if (allow.length > 0) {
3774
+ headers["Allow"] = allow.join(", ");
3775
+ if (this.config.cors) headers["Access-Control-Expose-Headers"] = "Allow";
3776
+ }
3777
+ }
3478
3778
  return {
3479
- status: response.success ? 200 : 400,
3779
+ status: httpStatusForResponse(response),
3480
3780
  headers,
3481
3781
  body: JSON.stringify(response)
3482
3782
  };
@@ -3533,7 +3833,7 @@ var NativeUIBridgeServer = class {
3533
3833
  timestamp: Date.now()
3534
3834
  };
3535
3835
  }
3536
- let patternMatchedButWrongVerb = false;
3836
+ let patternMatchedButWrongVerb = DISCOVERY_PATHS.has(stripped);
3537
3837
  for (const route of WS_ROUTES) {
3538
3838
  const pathParams = parsePath(route.pattern, stripped);
3539
3839
  if (!pathParams) continue;
@@ -3550,9 +3850,10 @@ var NativeUIBridgeServer = class {
3550
3850
  const ctx = { params: pathParams, query, body };
3551
3851
  return this.handlers[route.handler](ctx);
3552
3852
  }
3853
+ const allowed = patternMatchedButWrongVerb ? allowedMethodsForPath(path, testHooks) : [];
3553
3854
  return {
3554
3855
  success: false,
3555
- error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}` : `Route not found: ${method} ${path}`,
3856
+ error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}${allowed.length > 0 ? ` (allowed: ${allowed.join(", ")})` : ""}` : `Route not found: ${method} ${path}`,
3556
3857
  code: patternMatchedButWrongVerb ? "METHOD_NOT_ALLOWED" : "NOT_FOUND",
3557
3858
  timestamp: Date.now()
3558
3859
  };
@@ -4273,6 +4574,7 @@ exports.encodeCloseFrame = encodeCloseFrame;
4273
4574
  exports.encodePingFrame = encodePingFrame;
4274
4575
  exports.encodePongFrame = encodePongFrame;
4275
4576
  exports.encodeTextFrame = encodeTextFrame;
4577
+ exports.httpStatusForResponse = httpStatusForResponse;
4276
4578
  exports.isAddrInUseError = isAddrInUseError;
4277
4579
  exports.isBatchRequest = isBatchRequest;
4278
4580
  exports.isSubscribe = isSubscribe;