@qontinui/ui-bridge-native 0.6.11 → 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 +853 -15
  19. package/dist/core/index.js.map +1 -1
  20. package/dist/core/index.mjs +835 -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 +317 -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 +1603 -118
  33. package/dist/index.js.map +1 -1
  34. package/dist/index.mjs +1585 -119
  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 +1420 -113
  39. package/dist/react/index.js.map +1 -1
  40. package/dist/react/index.mjs +1420 -113
  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 +348 -43
  52. package/dist/server/index.js.map +1 -1
  53. package/dist/server/index.mjs +348 -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
@@ -7,12 +7,31 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
7
7
 
8
8
  // src/server/types.ts
9
9
  var UI_BRIDGE_NATIVE_ROUTES = {
10
+ // Discovery / introspection — the two endpoints an agent hits first to learn
11
+ // the rest of this table without reading docs. Special-cased in
12
+ // `routeRequest` rather than living in `WS_ROUTES`, and appended by
13
+ // `buildRoutesPayload` so `_routes` describes itself.
14
+ HELP: {
15
+ method: "GET",
16
+ path: "/ui-bridge/_help",
17
+ description: "Route index + action reference + tips (rich discovery payload)"
18
+ },
19
+ ROUTES: {
20
+ method: "GET",
21
+ path: "/ui-bridge/_routes",
22
+ description: "Minimal {method, path} route list, matching the runner /_routes shape"
23
+ },
10
24
  // Control - Elements
11
25
  GET_ELEMENTS: {
12
26
  method: "GET",
13
27
  path: "/ui-bridge/control/elements",
14
28
  description: "List all registered elements"
15
29
  },
30
+ GET_ELEMENTS_FLAT: {
31
+ method: "GET",
32
+ path: "/ui-bridge/elements",
33
+ description: "List all registered elements (flat alias matching the runner path)"
34
+ },
16
35
  GET_ELEMENT: {
17
36
  method: "GET",
18
37
  path: "/ui-bridge/control/element/:id",
@@ -50,6 +69,11 @@ var UI_BRIDGE_NATIVE_ROUTES = {
50
69
  path: "/ui-bridge/control/find",
51
70
  description: "Find elements matching criteria"
52
71
  },
72
+ AI_FIND: {
73
+ method: "POST",
74
+ path: "/ui-bridge/ai/find",
75
+ description: "Find elements matching criteria (runner-parity alias for /control/find)"
76
+ },
53
77
  GET_SNAPSHOT: {
54
78
  method: "GET",
55
79
  path: "/ui-bridge/control/snapshot",
@@ -60,11 +84,78 @@ var UI_BRIDGE_NATIVE_ROUTES = {
60
84
  path: "/ui-bridge/control/discover",
61
85
  description: "Discover elements (alias for snapshot; web/runner parity)"
62
86
  },
87
+ DISCOVER_POST: {
88
+ method: "POST",
89
+ path: "/ui-bridge/control/discover",
90
+ description: "Discover elements over POST (same handler as the GET form; web/runner parity)"
91
+ },
63
92
  PAGE_HEALTH: {
64
93
  method: "POST",
65
94
  path: "/ui-bridge/control/page-health",
66
95
  description: "Holistic page health diagnostic: spatial coverage, layout regions, text signals, interactive readiness, anomalies + ASCII heatmap"
67
96
  },
97
+ PAGE_HEALTH_GET: {
98
+ method: "GET",
99
+ path: "/ui-bridge/control/page-health",
100
+ description: "Page health diagnostic over GET, for ad-hoc curls with no body"
101
+ },
102
+ // Screenshots — GET and POST both accepted so a curl works either way.
103
+ //
104
+ // Both keys carry an explicit verb suffix. The pre-existing multi-verb pairs
105
+ // do not agree on what an UNSUFFIXED key means — `DISCOVER` is the GET and
106
+ // `PAGE_HEALTH` is the POST — so a consumer reading `ROUTES.<NAME>.method`
107
+ // off a bare name gets opposite verbs from equally unmarked keys. Those two
108
+ // names are load-bearing for existing consumers and are left alone; new
109
+ // multi-verb entries name their verb rather than entrenching the ambiguity.
110
+ SCREENSHOT_GET: {
111
+ method: "GET",
112
+ path: "/ui-bridge/control/screenshot",
113
+ description: "PNG screenshot of the current screen"
114
+ },
115
+ SCREENSHOT_POST: {
116
+ method: "POST",
117
+ path: "/ui-bridge/control/screenshot",
118
+ description: "PNG screenshot of the current screen (POST form)"
119
+ },
120
+ // Coordinate-based interaction
121
+ CONTROL_TAP: {
122
+ method: "POST",
123
+ path: "/ui-bridge/control/tap",
124
+ description: "Synthesize a press at screen coords by matching registered element layout rects: body { x, y, action? }"
125
+ },
126
+ // App-agnostic interaction parity with the web/runner surface. The
127
+ // selector-based pair has no RN analog and answers NOT_SUPPORTED (501).
128
+ PAGE_CLICK_BY_TEXT: {
129
+ method: "POST",
130
+ path: "/ui-bridge/control/page/click-by-text",
131
+ description: "Press the first element matching free text: body { text, visibleOnly? }"
132
+ },
133
+ PAGE_CLICK_BY_SELECTOR: {
134
+ method: "POST",
135
+ path: "/ui-bridge/control/page/click-by-selector",
136
+ description: "Selector-based press (web-only; mobile returns NOT_SUPPORTED)"
137
+ },
138
+ PAGE_TYPE_INTO: {
139
+ method: "POST",
140
+ path: "/ui-bridge/control/page/type-into",
141
+ description: "Type into the input matching a label: body { label, text, clear?, visibleOnly? }"
142
+ },
143
+ PAGE_READ_VALUE: {
144
+ method: "POST",
145
+ path: "/ui-bridge/control/page/read-value",
146
+ description: "Selector-based value read (web-only; mobile returns NOT_SUPPORTED)"
147
+ },
148
+ PAGE_FIND_BY_TEXT: {
149
+ method: "POST",
150
+ path: "/ui-bridge/control/page/find-by-text",
151
+ description: "List elements matching free text without pressing: body { text, visibleOnly? }"
152
+ },
153
+ // Meta
154
+ META_METHODS: {
155
+ method: "GET",
156
+ path: "/ui-bridge/meta/methods",
157
+ description: "List every handler name (legacy introspection; prefer /_routes)"
158
+ },
68
159
  // Workflows
69
160
  GET_WORKFLOWS: {
70
161
  method: "GET",
@@ -143,6 +234,27 @@ var UI_BRIDGE_NATIVE_ROUTES = {
143
234
  path: "/ui-bridge/design/style-guide",
144
235
  description: "Clear loaded style guide"
145
236
  },
237
+ // Quality Evaluation
238
+ DESIGN_EVALUATE: {
239
+ method: "POST",
240
+ path: "/ui-bridge/design/evaluate",
241
+ description: "Score the current UI against a quality context"
242
+ },
243
+ DESIGN_EVALUATE_CONTEXTS: {
244
+ method: "GET",
245
+ path: "/ui-bridge/design/evaluate/contexts",
246
+ description: "List available quality evaluation contexts"
247
+ },
248
+ DESIGN_EVALUATE_BASELINE: {
249
+ method: "POST",
250
+ path: "/ui-bridge/design/evaluate/baseline",
251
+ description: "Save a quality baseline snapshot"
252
+ },
253
+ DESIGN_EVALUATE_DIFF: {
254
+ method: "POST",
255
+ path: "/ui-bridge/design/evaluate/diff",
256
+ description: "Diff the current UI against a saved baseline"
257
+ },
146
258
  // AI helpers
147
259
  AI_FILL_FORM: {
148
260
  method: "POST",
@@ -174,18 +286,34 @@ var UI_BRIDGE_NATIVE_ROUTES = {
174
286
  path: "/ui-bridge/ai/wait-for-element",
175
287
  description: "Wait for element predicate via HTTP (runner-only; mobile returns NOT_SUPPORTED \u2014 use WS waitForElement instead)"
176
288
  },
177
- // Test hooks — drive ModalDetector state from outside the React tree
178
- // (gated by `features.testHooks`). Mirrors `pushModal` / `dismissModal`
179
- // calls that components normally make in-process.
289
+ // Test hooks — drive ModalDetector state and the screen lock from outside
290
+ // the React tree (gated by `features.testHooks`). Mirrors `pushModal` /
291
+ // `dismissModal` calls that components normally make in-process.
292
+ //
293
+ // These are LISTED unconditionally — this table is a static constant with no
294
+ // config to consult — but each carries `requiresTestHooks: true`, which is
295
+ // the machine-readable form of the `(testHooks)` in its description. With the
296
+ // flag off `routeRequest` and `buildRoutesPayload` both drop them and the
297
+ // route answers a bare 404. `route-table-parity.test.ts` compares the whole
298
+ // table against `_routes` at `testHooks: true`, and the flagged subset
299
+ // against `_routes` at `testHooks: false`, so both surfaces are pinned.
180
300
  PUSH_MODAL: {
181
301
  method: "POST",
182
302
  path: "/ui-bridge/control/modal/push",
183
- description: "Push a modal onto the modal stack (testHooks)"
303
+ description: "Push a modal onto the modal stack (testHooks)",
304
+ requiresTestHooks: true
184
305
  },
185
306
  DISMISS_MODAL: {
186
307
  method: "POST",
187
308
  path: "/ui-bridge/control/modal/dismiss/:id",
188
- description: "Dismiss a modal by id (testHooks)"
309
+ description: "Dismiss a modal by id (testHooks)",
310
+ requiresTestHooks: true
311
+ },
312
+ KEEP_AWAKE: {
313
+ method: "POST",
314
+ path: "/ui-bridge/control/keep-awake",
315
+ description: "Keep the device screen awake via the injected keepAwakeProvider: body { enabled, durationMs? } (testHooks)",
316
+ requiresTestHooks: true
189
317
  },
190
318
  // Observability — last-N ring buffers (always mounted; `installed:false`
191
319
  // when the console/network patches are off, i.e. no testHooks).
@@ -380,6 +508,21 @@ function projectVisionFields(args) {
380
508
  return fields;
381
509
  }
382
510
 
511
+ // src/core/element-actions.ts
512
+ function serializeElementCustomActions(customActions) {
513
+ if (!customActions) return void 0;
514
+ const out = [];
515
+ for (const [id, action] of Object.entries(customActions)) {
516
+ if (!action) continue;
517
+ const entry = { id };
518
+ if (action.label !== void 0) entry.label = action.label;
519
+ if (action.description !== void 0) entry.description = action.description;
520
+ if (action.effect !== void 0) entry.effect = action.effect;
521
+ out.push(entry);
522
+ }
523
+ return out;
524
+ }
525
+
383
526
  // src/core/registry.ts
384
527
  function extractHandlerNames(props) {
385
528
  if (!props) return [];
@@ -408,6 +551,46 @@ function projectBbox(state, visibility, pixelRatio = 1) {
408
551
  h: toPx(layout.height)
409
552
  };
410
553
  }
554
+ function pageRectOf(state) {
555
+ const layout = state.layout;
556
+ if (!layout) return null;
557
+ if (layout.pageOriginUnmeasured === true) return null;
558
+ const hasPage = typeof layout.pageX === "number" && typeof layout.pageY === "number" && Number.isFinite(layout.pageX) && Number.isFinite(layout.pageY);
559
+ if (!hasPage) return null;
560
+ const left = layout.pageX;
561
+ const top = layout.pageY;
562
+ if (typeof layout.width !== "number" || typeof layout.height !== "number" || !Number.isFinite(layout.width) || !Number.isFinite(layout.height)) {
563
+ return null;
564
+ }
565
+ return { left, top, right: left + layout.width, bottom: top + layout.height };
566
+ }
567
+ function spansOverlap(aMin, aMax, bMin, bMax) {
568
+ if (aMin === aMax) return aMin >= bMin && aMin <= bMax;
569
+ return aMin < bMax && aMax > bMin;
570
+ }
571
+ function isEmptyRect(rect) {
572
+ return rect.right <= rect.left || rect.bottom <= rect.top;
573
+ }
574
+ function computeVisibility(state, clip) {
575
+ if (!state.visible) {
576
+ return { visibility: "hidden", visibilityReason: "hidden" };
577
+ }
578
+ if (state.layout === null) {
579
+ return { visibility: "likely-visible", visibilityReason: "no-layout" };
580
+ }
581
+ if (!clip) {
582
+ return { visibility: "visible" };
583
+ }
584
+ const rect = pageRectOf(state);
585
+ if (!rect) {
586
+ return { visibility: "visible" };
587
+ }
588
+ if (isEmptyRect(clip)) {
589
+ return { visibility: "hidden", visibilityReason: "off-screen" };
590
+ }
591
+ const onScreen = spansOverlap(rect.left, rect.right, clip.left, clip.right) && spansOverlap(rect.top, rect.bottom, clip.top, clip.bottom);
592
+ return onScreen ? { visibility: "visible" } : { visibility: "hidden", visibilityReason: "off-screen" };
593
+ }
411
594
 
412
595
  // src/design/design-inspector-native.ts
413
596
  function toCssValue(value) {
@@ -1355,16 +1538,21 @@ function createServerHandlers(registry, executor, config) {
1355
1538
  return {
1356
1539
  // Elements
1357
1540
  getElements: async (ctx) => {
1541
+ await registry.refreshMeasurements();
1358
1542
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
1359
1543
  const forRoute = ctx.query?.route || ctx.body?.route;
1360
1544
  let allElements = visibleOnly ? registry.getMountedVisibleElements() : registry.getAllElements();
1361
1545
  if (forRoute) {
1362
1546
  allElements = allElements.filter((e) => matchesCurrentRoute(e.registrationRoute, forRoute));
1363
1547
  }
1548
+ const viewport = registry.getViewportRect();
1364
1549
  const elements = allElements.map((e) => {
1365
1550
  const handlers = extractHandlerNames(e.props);
1366
1551
  const state = e.getState();
1367
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1552
+ const { visibility, visibilityReason } = computeVisibility(
1553
+ state,
1554
+ registry.getClipRectFor(e, viewport)
1555
+ );
1368
1556
  const bbox = projectBbox(state, visibility, registry.getPixelRatio());
1369
1557
  const vision = projectVisionFields({
1370
1558
  type: e.type,
@@ -1382,10 +1570,11 @@ function createServerHandlers(registry, executor, config) {
1382
1570
  identifier: e.getIdentifier(),
1383
1571
  state,
1384
1572
  actions: e.actions,
1385
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1573
+ customActions: serializeElementCustomActions(e.customActions),
1386
1574
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
1387
1575
  registrationRoute: e.registrationRoute,
1388
1576
  visibility,
1577
+ ...visibilityReason ? { visibilityReason } : {},
1389
1578
  ...bbox ? { bbox } : {},
1390
1579
  ...vision
1391
1580
  };
@@ -1407,7 +1596,7 @@ function createServerHandlers(registry, executor, config) {
1407
1596
  identifier: element.getIdentifier(),
1408
1597
  state: resolveElementState(element),
1409
1598
  actions: element.actions,
1410
- customActions: element.customActions ? Object.keys(element.customActions) : void 0,
1599
+ customActions: serializeElementCustomActions(element.customActions),
1411
1600
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
1412
1601
  registrationRoute: element.registrationRoute
1413
1602
  }
@@ -1428,13 +1617,9 @@ function createServerHandlers(registry, executor, config) {
1428
1617
  if (!envelope?.action) {
1429
1618
  return error2("Action is required", "INVALID_REQUEST");
1430
1619
  }
1431
- const response = await executor.executeAction(id, {
1432
- action: envelope.action,
1433
- params: envelope.params,
1434
- waitOptions: envelope.waitOptions
1435
- });
1620
+ const response = await executor.executeAction(id, envelope);
1436
1621
  if (!response.success) {
1437
- return error2(response.error || "Action failed", "ACTION_FAILED");
1622
+ return error2(response.error || "Action failed", response.code ?? "ACTION_FAILED");
1438
1623
  }
1439
1624
  return success2(response);
1440
1625
  },
@@ -1444,7 +1629,11 @@ function createServerHandlers(registry, executor, config) {
1444
1629
  id: c.id,
1445
1630
  name: c.name,
1446
1631
  description: c.description,
1447
- actions: c.actions.map((a) => ({ id: a.id, label: a.label })),
1632
+ // Phase 4 (plan 2026-08-20-ui-bridge-action-declaration-shape):
1633
+ // carry the safety annotation onto the native wire too — a walker
1634
+ // driving a React Native app needs the same `destructive` exclusion
1635
+ // the web channel gets.
1636
+ actions: c.actions.map((a) => ({ id: a.id, label: a.label, effect: a.effect })),
1448
1637
  elementIds: c.elementIds
1449
1638
  }));
1450
1639
  return success2({ components });
@@ -1463,7 +1652,9 @@ function createServerHandlers(registry, executor, config) {
1463
1652
  actions: component.actions.map((a) => ({
1464
1653
  id: a.id,
1465
1654
  label: a.label,
1466
- description: a.description
1655
+ description: a.description,
1656
+ // Phase 4 — see the listing twin above.
1657
+ effect: a.effect
1467
1658
  })),
1468
1659
  elementIds: component.elementIds
1469
1660
  }
@@ -1474,7 +1665,8 @@ function createServerHandlers(registry, executor, config) {
1474
1665
  const body = ctx.body;
1475
1666
  const response = await executor.executeComponentAction(id, {
1476
1667
  action: actionId,
1477
- params: body?.params
1668
+ params: body?.params,
1669
+ timeoutMs: body?.timeoutMs
1478
1670
  });
1479
1671
  if (!response.success) {
1480
1672
  return error2(response.error || "Action failed", "ACTION_FAILED");
@@ -1548,6 +1740,7 @@ function createServerHandlers(registry, executor, config) {
1548
1740
  });
1549
1741
  },
1550
1742
  getSnapshot: async (ctx) => {
1743
+ await registry.refreshMeasurements();
1551
1744
  const visibleOnly = ctx.query?.visibleOnly === "true" || ctx.body?.visibleOnly === true;
1552
1745
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
1553
1746
  const snapshot = registry.createSnapshot(void 0, { visibleOnly, currentRouteOnly });
@@ -1665,9 +1858,12 @@ function createServerHandlers(registry, executor, config) {
1665
1858
  //
1666
1859
  // The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
1667
1860
  // cheatsheet paths so callers (and the /manual-test skill in particular)
1668
- // get a structured envelope explaining the gap rather than a confusing
1669
- // HTTP 404. Each error message names an in-tree mobile replacement so
1670
- // operators aren't left guessing what to use instead.
1861
+ // get a structured envelope explaining the gap rather than a bare
1862
+ // "no such route". These answer **HTTP 501** — `httpStatusForResponse` in
1863
+ // server/http-server.ts maps NOT_SUPPORTED → 501, so the status line and
1864
+ // the envelope agree; an unmounted path answers 404 instead. Each error
1865
+ // message names an in-tree mobile replacement so operators aren't left
1866
+ // guessing what to use instead.
1671
1867
  aiForms: async () => {
1672
1868
  return error2(
1673
1869
  '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.',
@@ -1863,6 +2059,7 @@ function createServerHandlers(registry, executor, config) {
1863
2059
  // that don't measure (the fallback only matches single-rooted screens but
1864
2060
  // is good enough for the unit suite).
1865
2061
  tapAt: async (ctx) => {
2062
+ await registry.refreshMeasurements();
1866
2063
  const body = ctx.body;
1867
2064
  const x = body?.x;
1868
2065
  const y = body?.y;
@@ -1993,18 +2190,12 @@ function createServerHandlers(registry, executor, config) {
1993
2190
  pushModal: async (ctx) => {
1994
2191
  const detector = registry.getEnrichers().modalDetector;
1995
2192
  if (!detector) {
1996
- return error2(
1997
- "modal detector not registered",
1998
- "ENRICHER_UNAVAILABLE"
1999
- );
2193
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
2000
2194
  }
2001
2195
  const body = ctx.body;
2002
2196
  const id = body?.id;
2003
2197
  if (typeof id !== "string" || id.length === 0) {
2004
- return error2(
2005
- 'pushModal requires a non-empty "id"',
2006
- "INVALID_REQUEST"
2007
- );
2198
+ return error2('pushModal requires a non-empty "id"', "INVALID_REQUEST");
2008
2199
  }
2009
2200
  const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
2010
2201
  const requestedType = body?.type;
@@ -2037,10 +2228,7 @@ function createServerHandlers(registry, executor, config) {
2037
2228
  dismissModal: async (ctx) => {
2038
2229
  const detector = registry.getEnrichers().modalDetector;
2039
2230
  if (!detector) {
2040
- return error2(
2041
- "modal detector not registered",
2042
- "ENRICHER_UNAVAILABLE"
2043
- );
2231
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
2044
2232
  }
2045
2233
  const { id } = ctx.params;
2046
2234
  if (typeof id !== "string" || id.length === 0) {
@@ -2070,7 +2258,7 @@ function createServerHandlers(registry, executor, config) {
2070
2258
  };
2071
2259
  if (config?.appInfo) {
2072
2260
  response.uiBridge = {
2073
- version: "0.6.11",
2261
+ version: "0.6.13",
2074
2262
  ...config.appInfo,
2075
2263
  capabilities: ["elements", "components", "actions", "design"],
2076
2264
  elementCount: stats.elements,
@@ -2218,8 +2406,48 @@ function extractMethod(input, init) {
2218
2406
  var NetworkRequestBuffer = class {
2219
2407
  constructor(capacity = 100) {
2220
2408
  this.installed = false;
2409
+ /**
2410
+ * Synchronous re-entrancy depth: non-zero while the ORIGINAL `fetch` is
2411
+ * executing, which is exactly the window in which React Native's whatwg-fetch
2412
+ * polyfill constructs and opens its internal `XMLHttpRequest`.
2413
+ *
2414
+ * A counter rather than a boolean so a `fetch` called from inside a `fetch`
2415
+ * (an interceptor, a retry wrapper) still unwinds to zero. Safe as a plain
2416
+ * number: JS is single-threaded and the only writes are the paired
2417
+ * increment/decrement around one synchronous call.
2418
+ */
2419
+ this.fetchDepth = { value: 0 };
2221
2420
  this.buffer = new RingBuffer(capacity);
2222
2421
  }
2422
+ /**
2423
+ * Patch `fetch` and `XMLHttpRequest` so `GET /sdk/network-requests` can
2424
+ * report what the app talked to.
2425
+ *
2426
+ * ONE REQUEST MUST PRODUCE ONE ENTRY. React Native implements `fetch` **on
2427
+ * top of** XHR (whatwg-fetch), so both patches observe the same request and
2428
+ * the buffer used to carry every call twice — same url, timestamps ~1ms
2429
+ * apart, and on failure one `"Network request failed"` beside one
2430
+ * `"XMLHttpRequest error"`. A tester reads that as a double-fetch bug in the
2431
+ * app and chases a defect that does not exist.
2432
+ *
2433
+ * The de-dup: `fetchDepth` is raised across the synchronous call into the
2434
+ * original `fetch`, which is when the polyfill builds its XHR; `open` stamps
2435
+ * any XHR created in that window with `__uiBridgeFromFetch`, and `send`
2436
+ * skips recording those. The `fetch` patch keeps the entry, because it is
2437
+ * the layer the app actually called and the only one that sees `Response.ok`.
2438
+ *
2439
+ * Uninstalling both patches together keeps this sound: an XHR can only be
2440
+ * marked while the fetch patch is live.
2441
+ *
2442
+ * KNOWN LIMIT — it is coupled to the polyfill building its XHR SYNCHRONOUSLY,
2443
+ * which whatwg-fetch does (its work happens in the Promise executor). If the
2444
+ * host's `fetch` is wrapped by an interceptor that defers the real call to a
2445
+ * microtask or `setTimeout`, the depth is back to 0 by the time `open` runs
2446
+ * and every request duplicates again — the original symptom, with no signal
2447
+ * that the mechanism disengaged. Duplicates in `/sdk/network-requests` are
2448
+ * the thing to look for; they mean this assumption stopped holding, not that
2449
+ * the app double-fetched.
2450
+ */
2223
2451
  install() {
2224
2452
  if (this.installed) return;
2225
2453
  const g = globalThis;
@@ -2227,12 +2455,20 @@ var NetworkRequestBuffer = class {
2227
2455
  this.originalFetch = g.fetch;
2228
2456
  const buffer = this.buffer;
2229
2457
  const original = this.originalFetch;
2458
+ const fetchDepth = this.fetchDepth;
2230
2459
  const patched = async (input, init) => {
2231
2460
  const start = Date.now();
2232
2461
  const url = extractUrl(input);
2233
2462
  const method = extractMethod(input, init);
2234
2463
  try {
2235
- const res = await original.call(globalThis, input, init);
2464
+ let pending;
2465
+ fetchDepth.value += 1;
2466
+ try {
2467
+ pending = original.call(globalThis, input, init);
2468
+ } finally {
2469
+ fetchDepth.value -= 1;
2470
+ }
2471
+ const res = await pending;
2236
2472
  buffer.push({
2237
2473
  timestamp: start,
2238
2474
  method,
@@ -2262,10 +2498,14 @@ var NetworkRequestBuffer = class {
2262
2498
  this.originalXhrOpen = Xhr.prototype.open;
2263
2499
  this.originalXhrSend = Xhr.prototype.send;
2264
2500
  const buffer = this.buffer;
2501
+ const fetchDepth = this.fetchDepth;
2265
2502
  const origOpen = this.originalXhrOpen;
2266
2503
  Xhr.prototype.open = function(method, url, ...rest) {
2267
2504
  this.__uiBridgeMethod = method.toUpperCase();
2268
2505
  this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
2506
+ if (fetchDepth.value > 0) {
2507
+ this.__uiBridgeFromFetch = true;
2508
+ }
2269
2509
  return origOpen.call(
2270
2510
  this,
2271
2511
  method,
@@ -2275,6 +2515,9 @@ var NetworkRequestBuffer = class {
2275
2515
  };
2276
2516
  const origSend = this.originalXhrSend;
2277
2517
  Xhr.prototype.send = function(body) {
2518
+ if (this.__uiBridgeFromFetch === true) {
2519
+ return origSend.call(this, body);
2520
+ }
2278
2521
  const start = Date.now();
2279
2522
  const method = this.__uiBridgeMethod ?? "GET";
2280
2523
  const url = this.__uiBridgeUrl ?? "";
@@ -2354,6 +2597,16 @@ function isUnsubscribe(msg) {
2354
2597
  }
2355
2598
 
2356
2599
  // src/server/http-server.ts
2600
+ var ERROR_CODE_HTTP_STATUS = /* @__PURE__ */ new Map([
2601
+ ["NOT_SUPPORTED", 501],
2602
+ ["NOT_FOUND", 404],
2603
+ ["METHOD_NOT_ALLOWED", 405]
2604
+ ]);
2605
+ function httpStatusForResponse(response) {
2606
+ if (response.success) return 200;
2607
+ const mapped = response.code ? ERROR_CODE_HTTP_STATUS.get(response.code) : void 0;
2608
+ return mapped ?? 400;
2609
+ }
2357
2610
  function parsePath(pattern, actual) {
2358
2611
  const patternParts = pattern.split("/");
2359
2612
  const actualParts = actual.split("/");
@@ -2516,6 +2769,23 @@ var WS_ROUTES = [
2516
2769
  httpMethods: ["GET"]
2517
2770
  }
2518
2771
  ];
2772
+ var DISCOVERY_PATHS = /* @__PURE__ */ new Set(["_help", "_routes"]);
2773
+ function allowedMethodsForPath(path, testHooks) {
2774
+ const PREFIX = "/ui-bridge/";
2775
+ if (!path.startsWith(PREFIX)) return [];
2776
+ const stripped = path.slice(PREFIX.length);
2777
+ const methods = [];
2778
+ if (DISCOVERY_PATHS.has(stripped)) methods.push("GET");
2779
+ for (const route of WS_ROUTES) {
2780
+ if (!route.httpMethods) continue;
2781
+ if (route.requiresTestHooks && !testHooks) continue;
2782
+ if (parsePath(route.pattern, stripped) === null) continue;
2783
+ for (const verb of route.httpMethods) {
2784
+ if (!methods.includes(verb)) methods.push(verb);
2785
+ }
2786
+ }
2787
+ return methods;
2788
+ }
2519
2789
  var HANDLER_DESCRIPTIONS = {
2520
2790
  health: "Liveness probe; returns app metadata + element count",
2521
2791
  getElements: "Flat list of registered UI elements",
@@ -2578,6 +2848,10 @@ var ACTION_REFERENCE = [
2578
2848
  { name: "focus", params: "none" },
2579
2849
  { name: "blur", params: "none" },
2580
2850
  { name: "scroll", params: "ScrollActionParams { offset? {x,y}, to? }" },
2851
+ {
2852
+ name: "scrollIntoView",
2853
+ 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"
2854
+ },
2581
2855
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
2582
2856
  { name: "toggle", params: "none \u2014 flips boolean controls" }
2583
2857
  ];
@@ -2775,7 +3049,9 @@ var NativeUIBridgeServer = class {
2775
3049
  }
2776
3050
  /**
2777
3051
  * Set a route provider for reporting the current navigation route.
2778
- * When set, `control/snapshot` responses include `currentRoute` and `segments`.
3052
+ * When set, `control/snapshot` responses include `currentRoute` and
3053
+ * `segments`, plus the canonical `route` / `activeTab` aliases the web SDK's
3054
+ * `BridgeSnapshot` spells the same way.
2779
3055
  *
2780
3056
  * Also forwards the provider into the registry so the *default*
2781
3057
  * `getSnapshot` handler (and any other call site of
@@ -2790,7 +3066,12 @@ var NativeUIBridgeServer = class {
2790
3066
  this.handlers.getElements = async (ctx) => {
2791
3067
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
2792
3068
  if (currentRouteOnly && !ctx.query?.route && !ctx.body?.route) {
2793
- const currentRoute = provider.getCurrentRoute();
3069
+ let currentRoute;
3070
+ try {
3071
+ currentRoute = provider.getCurrentRoute();
3072
+ } catch {
3073
+ currentRoute = null;
3074
+ }
2794
3075
  if (currentRoute) {
2795
3076
  ctx.query = { ...ctx.query, route: currentRoute };
2796
3077
  }
@@ -2800,11 +3081,26 @@ var NativeUIBridgeServer = class {
2800
3081
  this.handlers.getSnapshot = async (ctx) => {
2801
3082
  const visibleOnly = ctx?.query?.visibleOnly === "true" || ctx?.body?.visibleOnly === true;
2802
3083
  const currentRouteOnly = ctx?.query?.currentRouteOnly === "true" || ctx?.body?.currentRouteOnly === true;
3084
+ let currentRoute;
3085
+ try {
3086
+ currentRoute = provider.getCurrentRoute() ?? null;
3087
+ } catch {
3088
+ currentRoute = null;
3089
+ }
3090
+ let segments;
3091
+ try {
3092
+ segments = provider.getSegments?.();
3093
+ } catch {
3094
+ segments = void 0;
3095
+ }
3096
+ let activeTab;
3097
+ try {
3098
+ activeTab = provider.getActiveTab?.();
3099
+ } catch {
3100
+ activeTab = void 0;
3101
+ }
2803
3102
  const snapshot = this.registry.createSnapshot(
2804
- {
2805
- currentRoute: provider.getCurrentRoute(),
2806
- segments: provider.getSegments?.()
2807
- },
3103
+ { currentRoute, segments, activeTab },
2808
3104
  { visibleOnly, currentRouteOnly }
2809
3105
  );
2810
3106
  return { success: true, data: snapshot, timestamp: Date.now() };
@@ -3470,8 +3766,15 @@ var NativeUIBridgeServer = class {
3470
3766
  }
3471
3767
  try {
3472
3768
  const response = await this.routeRequest(request);
3769
+ if (response.code === "METHOD_NOT_ALLOWED") {
3770
+ const allow = allowedMethodsForPath(request.path, this.config.testHooks === true);
3771
+ if (allow.length > 0) {
3772
+ headers["Allow"] = allow.join(", ");
3773
+ if (this.config.cors) headers["Access-Control-Expose-Headers"] = "Allow";
3774
+ }
3775
+ }
3473
3776
  return {
3474
- status: response.success ? 200 : 400,
3777
+ status: httpStatusForResponse(response),
3475
3778
  headers,
3476
3779
  body: JSON.stringify(response)
3477
3780
  };
@@ -3528,7 +3831,7 @@ var NativeUIBridgeServer = class {
3528
3831
  timestamp: Date.now()
3529
3832
  };
3530
3833
  }
3531
- let patternMatchedButWrongVerb = false;
3834
+ let patternMatchedButWrongVerb = DISCOVERY_PATHS.has(stripped);
3532
3835
  for (const route of WS_ROUTES) {
3533
3836
  const pathParams = parsePath(route.pattern, stripped);
3534
3837
  if (!pathParams) continue;
@@ -3545,9 +3848,10 @@ var NativeUIBridgeServer = class {
3545
3848
  const ctx = { params: pathParams, query, body };
3546
3849
  return this.handlers[route.handler](ctx);
3547
3850
  }
3851
+ const allowed = patternMatchedButWrongVerb ? allowedMethodsForPath(path, testHooks) : [];
3548
3852
  return {
3549
3853
  success: false,
3550
- error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}` : `Route not found: ${method} ${path}`,
3854
+ error: patternMatchedButWrongVerb ? `Method not allowed: ${method} ${path}${allowed.length > 0 ? ` (allowed: ${allowed.join(", ")})` : ""}` : `Route not found: ${method} ${path}`,
3551
3855
  code: patternMatchedButWrongVerb ? "METHOD_NOT_ALLOWED" : "NOT_FOUND",
3552
3856
  timestamp: Date.now()
3553
3857
  };
@@ -4244,6 +4548,6 @@ async function bindWithRetry(attempt, options = {}) {
4244
4548
  }
4245
4549
  }
4246
4550
 
4247
- export { ConsoleErrorBuffer, NativeUIBridgeServer, NetworkRequestBuffer, OPCODE_BINARY, OPCODE_CLOSE, OPCODE_CONTINUATION, OPCODE_PING, OPCODE_PONG, OPCODE_TEXT, UI_BRIDGE_NATIVE_ROUTES, WebSocketConnection, WebSocketEventBridge, bindWithRetry, buildUpgradeResponse, computeAcceptKey, createDesignHandlers, createNativeServer, createServerHandlers, decodeFrames, decodeTextPayload, encodeCloseFrame, encodePingFrame, encodePongFrame, encodeTextFrame, isAddrInUseError, isBatchRequest, isSubscribe, isUnsubscribe };
4551
+ export { ConsoleErrorBuffer, NativeUIBridgeServer, NetworkRequestBuffer, OPCODE_BINARY, OPCODE_CLOSE, OPCODE_CONTINUATION, OPCODE_PING, OPCODE_PONG, OPCODE_TEXT, UI_BRIDGE_NATIVE_ROUTES, WebSocketConnection, WebSocketEventBridge, bindWithRetry, buildUpgradeResponse, computeAcceptKey, createDesignHandlers, createNativeServer, createServerHandlers, decodeFrames, decodeTextPayload, encodeCloseFrame, encodePingFrame, encodePongFrame, encodeTextFrame, httpStatusForResponse, isAddrInUseError, isBatchRequest, isSubscribe, isUnsubscribe };
4248
4552
  //# sourceMappingURL=index.mjs.map
4249
4553
  //# sourceMappingURL=index.mjs.map