@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
@@ -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) {
@@ -1362,10 +1545,14 @@ function createServerHandlers(registry, executor, config) {
1362
1545
  if (forRoute) {
1363
1546
  allElements = allElements.filter((e) => matchesCurrentRoute(e.registrationRoute, forRoute));
1364
1547
  }
1548
+ const viewport = registry.getViewportRect();
1365
1549
  const elements = allElements.map((e) => {
1366
1550
  const handlers = extractHandlerNames(e.props);
1367
1551
  const state = e.getState();
1368
- const visibility = !state.visible ? "hidden" : state.layout !== null ? "visible" : "likely-visible";
1552
+ const { visibility, visibilityReason } = computeVisibility(
1553
+ state,
1554
+ registry.getClipRectFor(e, viewport)
1555
+ );
1369
1556
  const bbox = projectBbox(state, visibility, registry.getPixelRatio());
1370
1557
  const vision = projectVisionFields({
1371
1558
  type: e.type,
@@ -1383,10 +1570,11 @@ function createServerHandlers(registry, executor, config) {
1383
1570
  identifier: e.getIdentifier(),
1384
1571
  state,
1385
1572
  actions: e.actions,
1386
- customActions: e.customActions ? Object.keys(e.customActions) : void 0,
1573
+ customActions: serializeElementCustomActions(e.customActions),
1387
1574
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
1388
1575
  registrationRoute: e.registrationRoute,
1389
1576
  visibility,
1577
+ ...visibilityReason ? { visibilityReason } : {},
1390
1578
  ...bbox ? { bbox } : {},
1391
1579
  ...vision
1392
1580
  };
@@ -1408,7 +1596,7 @@ function createServerHandlers(registry, executor, config) {
1408
1596
  identifier: element.getIdentifier(),
1409
1597
  state: resolveElementState(element),
1410
1598
  actions: element.actions,
1411
- customActions: element.customActions ? Object.keys(element.customActions) : void 0,
1599
+ customActions: serializeElementCustomActions(element.customActions),
1412
1600
  registeredHandlers: handlers.length > 0 ? handlers : void 0,
1413
1601
  registrationRoute: element.registrationRoute
1414
1602
  }
@@ -1429,13 +1617,9 @@ function createServerHandlers(registry, executor, config) {
1429
1617
  if (!envelope?.action) {
1430
1618
  return error2("Action is required", "INVALID_REQUEST");
1431
1619
  }
1432
- const response = await executor.executeAction(id, {
1433
- action: envelope.action,
1434
- params: envelope.params,
1435
- waitOptions: envelope.waitOptions
1436
- });
1620
+ const response = await executor.executeAction(id, envelope);
1437
1621
  if (!response.success) {
1438
- return error2(response.error || "Action failed", "ACTION_FAILED");
1622
+ return error2(response.error || "Action failed", response.code ?? "ACTION_FAILED");
1439
1623
  }
1440
1624
  return success2(response);
1441
1625
  },
@@ -1445,7 +1629,11 @@ function createServerHandlers(registry, executor, config) {
1445
1629
  id: c.id,
1446
1630
  name: c.name,
1447
1631
  description: c.description,
1448
- 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 })),
1449
1637
  elementIds: c.elementIds
1450
1638
  }));
1451
1639
  return success2({ components });
@@ -1464,7 +1652,9 @@ function createServerHandlers(registry, executor, config) {
1464
1652
  actions: component.actions.map((a) => ({
1465
1653
  id: a.id,
1466
1654
  label: a.label,
1467
- description: a.description
1655
+ description: a.description,
1656
+ // Phase 4 — see the listing twin above.
1657
+ effect: a.effect
1468
1658
  })),
1469
1659
  elementIds: component.elementIds
1470
1660
  }
@@ -1475,7 +1665,8 @@ function createServerHandlers(registry, executor, config) {
1475
1665
  const body = ctx.body;
1476
1666
  const response = await executor.executeComponentAction(id, {
1477
1667
  action: actionId,
1478
- params: body?.params
1668
+ params: body?.params,
1669
+ timeoutMs: body?.timeoutMs
1479
1670
  });
1480
1671
  if (!response.success) {
1481
1672
  return error2(response.error || "Action failed", "ACTION_FAILED");
@@ -1667,9 +1858,12 @@ function createServerHandlers(registry, executor, config) {
1667
1858
  //
1668
1859
  // The mobile bridge mounts explicit NOT_SUPPORTED stubs at the runner's
1669
1860
  // cheatsheet paths so callers (and the /manual-test skill in particular)
1670
- // get a structured envelope explaining the gap rather than a confusing
1671
- // HTTP 404. Each error message names an in-tree mobile replacement so
1672
- // 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.
1673
1867
  aiForms: async () => {
1674
1868
  return error2(
1675
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.',
@@ -1996,18 +2190,12 @@ function createServerHandlers(registry, executor, config) {
1996
2190
  pushModal: async (ctx) => {
1997
2191
  const detector = registry.getEnrichers().modalDetector;
1998
2192
  if (!detector) {
1999
- return error2(
2000
- "modal detector not registered",
2001
- "ENRICHER_UNAVAILABLE"
2002
- );
2193
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
2003
2194
  }
2004
2195
  const body = ctx.body;
2005
2196
  const id = body?.id;
2006
2197
  if (typeof id !== "string" || id.length === 0) {
2007
- return error2(
2008
- 'pushModal requires a non-empty "id"',
2009
- "INVALID_REQUEST"
2010
- );
2198
+ return error2('pushModal requires a non-empty "id"', "INVALID_REQUEST");
2011
2199
  }
2012
2200
  const allowedTypes = ["modal", "sheet", "drawer", "popover", "alertdialog", "dialog"];
2013
2201
  const requestedType = body?.type;
@@ -2040,10 +2228,7 @@ function createServerHandlers(registry, executor, config) {
2040
2228
  dismissModal: async (ctx) => {
2041
2229
  const detector = registry.getEnrichers().modalDetector;
2042
2230
  if (!detector) {
2043
- return error2(
2044
- "modal detector not registered",
2045
- "ENRICHER_UNAVAILABLE"
2046
- );
2231
+ return error2("modal detector not registered", "ENRICHER_UNAVAILABLE");
2047
2232
  }
2048
2233
  const { id } = ctx.params;
2049
2234
  if (typeof id !== "string" || id.length === 0) {
@@ -2073,7 +2258,7 @@ function createServerHandlers(registry, executor, config) {
2073
2258
  };
2074
2259
  if (config?.appInfo) {
2075
2260
  response.uiBridge = {
2076
- version: "0.6.12",
2261
+ version: "0.6.13",
2077
2262
  ...config.appInfo,
2078
2263
  capabilities: ["elements", "components", "actions", "design"],
2079
2264
  elementCount: stats.elements,
@@ -2221,8 +2406,48 @@ function extractMethod(input, init) {
2221
2406
  var NetworkRequestBuffer = class {
2222
2407
  constructor(capacity = 100) {
2223
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 };
2224
2420
  this.buffer = new RingBuffer(capacity);
2225
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
+ */
2226
2451
  install() {
2227
2452
  if (this.installed) return;
2228
2453
  const g = globalThis;
@@ -2230,12 +2455,20 @@ var NetworkRequestBuffer = class {
2230
2455
  this.originalFetch = g.fetch;
2231
2456
  const buffer = this.buffer;
2232
2457
  const original = this.originalFetch;
2458
+ const fetchDepth = this.fetchDepth;
2233
2459
  const patched = async (input, init) => {
2234
2460
  const start = Date.now();
2235
2461
  const url = extractUrl(input);
2236
2462
  const method = extractMethod(input, init);
2237
2463
  try {
2238
- 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;
2239
2472
  buffer.push({
2240
2473
  timestamp: start,
2241
2474
  method,
@@ -2265,10 +2498,14 @@ var NetworkRequestBuffer = class {
2265
2498
  this.originalXhrOpen = Xhr.prototype.open;
2266
2499
  this.originalXhrSend = Xhr.prototype.send;
2267
2500
  const buffer = this.buffer;
2501
+ const fetchDepth = this.fetchDepth;
2268
2502
  const origOpen = this.originalXhrOpen;
2269
2503
  Xhr.prototype.open = function(method, url, ...rest) {
2270
2504
  this.__uiBridgeMethod = method.toUpperCase();
2271
2505
  this.__uiBridgeUrl = typeof url === "string" ? url : String(url);
2506
+ if (fetchDepth.value > 0) {
2507
+ this.__uiBridgeFromFetch = true;
2508
+ }
2272
2509
  return origOpen.call(
2273
2510
  this,
2274
2511
  method,
@@ -2278,6 +2515,9 @@ var NetworkRequestBuffer = class {
2278
2515
  };
2279
2516
  const origSend = this.originalXhrSend;
2280
2517
  Xhr.prototype.send = function(body) {
2518
+ if (this.__uiBridgeFromFetch === true) {
2519
+ return origSend.call(this, body);
2520
+ }
2281
2521
  const start = Date.now();
2282
2522
  const method = this.__uiBridgeMethod ?? "GET";
2283
2523
  const url = this.__uiBridgeUrl ?? "";
@@ -2357,6 +2597,16 @@ function isUnsubscribe(msg) {
2357
2597
  }
2358
2598
 
2359
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
+ }
2360
2610
  function parsePath(pattern, actual) {
2361
2611
  const patternParts = pattern.split("/");
2362
2612
  const actualParts = actual.split("/");
@@ -2519,6 +2769,23 @@ var WS_ROUTES = [
2519
2769
  httpMethods: ["GET"]
2520
2770
  }
2521
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
+ }
2522
2789
  var HANDLER_DESCRIPTIONS = {
2523
2790
  health: "Liveness probe; returns app metadata + element count",
2524
2791
  getElements: "Flat list of registered UI elements",
@@ -2581,6 +2848,10 @@ var ACTION_REFERENCE = [
2581
2848
  { name: "focus", params: "none" },
2582
2849
  { name: "blur", params: "none" },
2583
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
+ },
2584
2855
  { name: "swipe", params: "SwipeActionParams { direction, distance }" },
2585
2856
  { name: "toggle", params: "none \u2014 flips boolean controls" }
2586
2857
  ];
@@ -2778,7 +3049,9 @@ var NativeUIBridgeServer = class {
2778
3049
  }
2779
3050
  /**
2780
3051
  * Set a route provider for reporting the current navigation route.
2781
- * 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.
2782
3055
  *
2783
3056
  * Also forwards the provider into the registry so the *default*
2784
3057
  * `getSnapshot` handler (and any other call site of
@@ -2793,7 +3066,12 @@ var NativeUIBridgeServer = class {
2793
3066
  this.handlers.getElements = async (ctx) => {
2794
3067
  const currentRouteOnly = ctx.query?.currentRouteOnly === "true" || ctx.body?.currentRouteOnly === true;
2795
3068
  if (currentRouteOnly && !ctx.query?.route && !ctx.body?.route) {
2796
- const currentRoute = provider.getCurrentRoute();
3069
+ let currentRoute;
3070
+ try {
3071
+ currentRoute = provider.getCurrentRoute();
3072
+ } catch {
3073
+ currentRoute = null;
3074
+ }
2797
3075
  if (currentRoute) {
2798
3076
  ctx.query = { ...ctx.query, route: currentRoute };
2799
3077
  }
@@ -2803,11 +3081,26 @@ var NativeUIBridgeServer = class {
2803
3081
  this.handlers.getSnapshot = async (ctx) => {
2804
3082
  const visibleOnly = ctx?.query?.visibleOnly === "true" || ctx?.body?.visibleOnly === true;
2805
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
+ }
2806
3102
  const snapshot = this.registry.createSnapshot(
2807
- {
2808
- currentRoute: provider.getCurrentRoute(),
2809
- segments: provider.getSegments?.()
2810
- },
3103
+ { currentRoute, segments, activeTab },
2811
3104
  { visibleOnly, currentRouteOnly }
2812
3105
  );
2813
3106
  return { success: true, data: snapshot, timestamp: Date.now() };
@@ -3473,8 +3766,15 @@ var NativeUIBridgeServer = class {
3473
3766
  }
3474
3767
  try {
3475
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
+ }
3476
3776
  return {
3477
- status: response.success ? 200 : 400,
3777
+ status: httpStatusForResponse(response),
3478
3778
  headers,
3479
3779
  body: JSON.stringify(response)
3480
3780
  };
@@ -3531,7 +3831,7 @@ var NativeUIBridgeServer = class {
3531
3831
  timestamp: Date.now()
3532
3832
  };
3533
3833
  }
3534
- let patternMatchedButWrongVerb = false;
3834
+ let patternMatchedButWrongVerb = DISCOVERY_PATHS.has(stripped);
3535
3835
  for (const route of WS_ROUTES) {
3536
3836
  const pathParams = parsePath(route.pattern, stripped);
3537
3837
  if (!pathParams) continue;
@@ -3548,9 +3848,10 @@ var NativeUIBridgeServer = class {
3548
3848
  const ctx = { params: pathParams, query, body };
3549
3849
  return this.handlers[route.handler](ctx);
3550
3850
  }
3851
+ const allowed = patternMatchedButWrongVerb ? allowedMethodsForPath(path, testHooks) : [];
3551
3852
  return {
3552
3853
  success: false,
3553
- 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}`,
3554
3855
  code: patternMatchedButWrongVerb ? "METHOD_NOT_ALLOWED" : "NOT_FOUND",
3555
3856
  timestamp: Date.now()
3556
3857
  };
@@ -4247,6 +4548,6 @@ async function bindWithRetry(attempt, options = {}) {
4247
4548
  }
4248
4549
  }
4249
4550
 
4250
- 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 };
4251
4552
  //# sourceMappingURL=index.mjs.map
4252
4553
  //# sourceMappingURL=index.mjs.map