@qontinui/ui-bridge 0.9.1 → 0.11.0

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.
@@ -1616,6 +1616,30 @@ interface UseCommandRelayOptions {
1616
1616
  * build-time defaults baked into a static config).
1617
1617
  */
1618
1618
  version?: string;
1619
+ /**
1620
+ * Optional hook that returns the current session token (the raw token
1621
+ * value, WITHOUT a `Bearer ` prefix). When supplied and returns a
1622
+ * non-empty string, the SDK:
1623
+ *
1624
+ * - attaches `Authorization: Bearer <value>` to outbound
1625
+ * `POST {basePath}/commands` and `POST {basePath}/heartbeat`;
1626
+ * - attaches `Authorization: Bearer <value>` to the SSE
1627
+ * `GET {basePath}/commands/stream` request as well — the stream is
1628
+ * consumed via `fetch` streaming, so the header rides on the request
1629
+ * (no token in the URL).
1630
+ *
1631
+ * Called fresh on every outbound request — implementations should read
1632
+ * the token from a live source (e.g. `sessionStorage`) so a token
1633
+ * rotation is picked up without remounting the listener. Returning
1634
+ * `null` / `undefined` / empty string means "no auth this call" and
1635
+ * the transport falls back to legacy unauth'd / cookie-based behavior
1636
+ * (matching the SDK's pre-`authHeader` shape).
1637
+ *
1638
+ * Required to authenticate against any relay endpoint that enforces a
1639
+ * session-bound auth gate (e.g. qontinui-web's
1640
+ * `UI_BRIDGE_REQUIRE_AUTH=1` mode).
1641
+ */
1642
+ authHeader?: () => string | null | undefined;
1619
1643
  }
1620
1644
  /**
1621
1645
  * Hook that connects the browser to the server's command relay.
@@ -1660,6 +1684,14 @@ interface CommandRelayListenerProps {
1660
1684
  * defaults baked into the static config.
1661
1685
  */
1662
1686
  version?: string;
1687
+ /**
1688
+ * Optional hook returning the current session token (raw value, no
1689
+ * `Bearer ` prefix). When supplied, the SDK attaches
1690
+ * `Authorization: Bearer <value>` to outbound `POST /commands` and
1691
+ * `POST /heartbeat` and appends `_auth=<value>` to the SSE URL.
1692
+ * See `UseCommandRelayOptions.authHeader` for the full contract.
1693
+ */
1694
+ authHeader?: () => string | null | undefined;
1663
1695
  }
1664
1696
  declare function CommandRelayListener(props: CommandRelayListenerProps): null;
1665
1697
 
@@ -1616,6 +1616,30 @@ interface UseCommandRelayOptions {
1616
1616
  * build-time defaults baked into a static config).
1617
1617
  */
1618
1618
  version?: string;
1619
+ /**
1620
+ * Optional hook that returns the current session token (the raw token
1621
+ * value, WITHOUT a `Bearer ` prefix). When supplied and returns a
1622
+ * non-empty string, the SDK:
1623
+ *
1624
+ * - attaches `Authorization: Bearer <value>` to outbound
1625
+ * `POST {basePath}/commands` and `POST {basePath}/heartbeat`;
1626
+ * - attaches `Authorization: Bearer <value>` to the SSE
1627
+ * `GET {basePath}/commands/stream` request as well — the stream is
1628
+ * consumed via `fetch` streaming, so the header rides on the request
1629
+ * (no token in the URL).
1630
+ *
1631
+ * Called fresh on every outbound request — implementations should read
1632
+ * the token from a live source (e.g. `sessionStorage`) so a token
1633
+ * rotation is picked up without remounting the listener. Returning
1634
+ * `null` / `undefined` / empty string means "no auth this call" and
1635
+ * the transport falls back to legacy unauth'd / cookie-based behavior
1636
+ * (matching the SDK's pre-`authHeader` shape).
1637
+ *
1638
+ * Required to authenticate against any relay endpoint that enforces a
1639
+ * session-bound auth gate (e.g. qontinui-web's
1640
+ * `UI_BRIDGE_REQUIRE_AUTH=1` mode).
1641
+ */
1642
+ authHeader?: () => string | null | undefined;
1619
1643
  }
1620
1644
  /**
1621
1645
  * Hook that connects the browser to the server's command relay.
@@ -1660,6 +1684,14 @@ interface CommandRelayListenerProps {
1660
1684
  * defaults baked into the static config.
1661
1685
  */
1662
1686
  version?: string;
1687
+ /**
1688
+ * Optional hook returning the current session token (raw value, no
1689
+ * `Bearer ` prefix). When supplied, the SDK attaches
1690
+ * `Authorization: Bearer <value>` to outbound `POST /commands` and
1691
+ * `POST /heartbeat` and appends `_auth=<value>` to the SSE URL.
1692
+ * See `UseCommandRelayOptions.authHeader` for the full contract.
1693
+ */
1694
+ authHeader?: () => string | null | undefined;
1663
1695
  }
1664
1696
  declare function CommandRelayListener(props: CommandRelayListenerProps): null;
1665
1697
 
@@ -24948,7 +24948,7 @@ function exposeProviderOnWindow(internals) {
24948
24948
  if (typeof window === "undefined") return;
24949
24949
  const w = window;
24950
24950
  const root = w.__UI_BRIDGE__ ?? (w.__UI_BRIDGE__ = {});
24951
- root.version = "0.9.1";
24951
+ root.version = "0.11.0";
24952
24952
  root.specs = { getGlobalSpecStore };
24953
24953
  root.registry = internals.registry;
24954
24954
  root.browserCapture = internals.browserCapture;
@@ -32911,10 +32911,44 @@ function safeJsonStringify(value) {
32911
32911
  });
32912
32912
  }
32913
32913
  var HEARTBEAT_INTERVAL_MS = 1e4;
32914
+ function resolveAuthToken(authHeader) {
32915
+ if (!authHeader) return null;
32916
+ try {
32917
+ const value = authHeader();
32918
+ if (typeof value !== "string") return null;
32919
+ const trimmed = value.trim();
32920
+ return trimmed.length > 0 ? trimmed : null;
32921
+ } catch {
32922
+ return null;
32923
+ }
32924
+ }
32925
+ function transportHeaders(authHeader) {
32926
+ const headers = {
32927
+ "Content-Type": "application/json"
32928
+ };
32929
+ const token = resolveAuthToken(authHeader);
32930
+ if (token) {
32931
+ headers.Authorization = `Bearer ${token}`;
32932
+ }
32933
+ return headers;
32934
+ }
32935
+ function parseSSEDataBlock(block) {
32936
+ const dataLines = [];
32937
+ for (const line of block.split("\n")) {
32938
+ if (line.startsWith(":")) continue;
32939
+ if (!line.startsWith("data:")) continue;
32940
+ let value = line.slice(5);
32941
+ if (value.startsWith(" ")) value = value.slice(1);
32942
+ dataLines.push(value);
32943
+ }
32944
+ return dataLines.length > 0 ? dataLines.join("\n") : null;
32945
+ }
32914
32946
  function useCommandRelay(options) {
32915
32947
  const enabled = options?.enabled ?? true;
32916
32948
  const basePath = options?.basePath ?? "/api/ui-bridge";
32917
32949
  const heartbeatIntervalMs = options?.heartbeatInterval ?? HEARTBEAT_INTERVAL_MS;
32950
+ const authHeaderRef = react.useRef(void 0);
32951
+ authHeaderRef.current = options?.authHeader;
32918
32952
  const uiBridge = useUIBridge();
32919
32953
  const context = useUIBridgeOptional();
32920
32954
  const bridge2 = react.useMemo(
@@ -32947,7 +32981,6 @@ function useCommandRelay(options) {
32947
32981
  }),
32948
32982
  [uiBridge, context]
32949
32983
  );
32950
- const eventSourceRef = react.useRef(null);
32951
32984
  const reconnectTimeoutRef = react.useRef(null);
32952
32985
  const processCommandRef = react.useRef(() => {
32953
32986
  });
@@ -32970,7 +33003,7 @@ function useCommandRelay(options) {
32970
33003
  try {
32971
33004
  await fetch(`${basePath}/commands`, {
32972
33005
  method: "POST",
32973
- headers: { "Content-Type": "application/json" },
33006
+ headers: transportHeaders(authHeaderRef.current),
32974
33007
  body: safeJsonStringify({
32975
33008
  commandId,
32976
33009
  success: ok,
@@ -33023,10 +33056,6 @@ function useCommandRelay(options) {
33023
33056
  processCommandRef.current = processCommand;
33024
33057
  react.useEffect(() => {
33025
33058
  if (!enabled) {
33026
- if (eventSourceRef.current) {
33027
- eventSourceRef.current.close();
33028
- eventSourceRef.current = null;
33029
- }
33030
33059
  if (reconnectTimeoutRef.current) {
33031
33060
  clearTimeout(reconnectTimeoutRef.current);
33032
33061
  reconnectTimeoutRef.current = null;
@@ -33034,35 +33063,73 @@ function useCommandRelay(options) {
33034
33063
  return;
33035
33064
  }
33036
33065
  let isMounted = true;
33066
+ let abortController = null;
33067
+ const closeStream = () => {
33068
+ if (abortController) {
33069
+ abortController.abort();
33070
+ abortController = null;
33071
+ }
33072
+ };
33073
+ const scheduleReconnect = () => {
33074
+ if (!isMounted) return;
33075
+ if (reconnectTimeoutRef.current) return;
33076
+ reconnectTimeoutRef.current = setTimeout(() => {
33077
+ reconnectTimeoutRef.current = null;
33078
+ if (isMounted) connect();
33079
+ }, SSE_RECONNECT_DELAY_MS);
33080
+ };
33037
33081
  const connect = () => {
33038
33082
  if (!isMounted) return;
33039
- const es = new EventSource(
33040
- `${basePath}/commands/stream${tabId ? `?tabId=${encodeURIComponent(tabId)}` : ""}`
33041
- );
33042
- eventSourceRef.current = es;
33043
- es.onmessage = (event) => {
33044
- try {
33045
- const data = JSON.parse(event.data);
33046
- if (data.type === "connected") return;
33047
- if (data.commandId && data.action) processCommandRef.current(data);
33048
- } catch (e) {
33049
- console.error("[UIBridge] Failed to parse SSE message:", e);
33083
+ closeStream();
33084
+ const controller = new AbortController();
33085
+ abortController = controller;
33086
+ const url = `${basePath}/commands/stream${tabId ? `?tabId=${encodeURIComponent(tabId)}` : ""}`;
33087
+ const headers = { Accept: "text/event-stream" };
33088
+ const token = resolveAuthToken(authHeaderRef.current);
33089
+ if (token) headers.Authorization = `Bearer ${token}`;
33090
+ fetch(url, { method: "GET", headers, signal: controller.signal, cache: "no-store" }).then(async (resp) => {
33091
+ if (!resp.ok || !resp.body) {
33092
+ if (abortController === controller) abortController = null;
33093
+ scheduleReconnect();
33094
+ return;
33050
33095
  }
33051
- };
33052
- es.onerror = () => {
33053
- es.close();
33054
- if (eventSourceRef.current === es) eventSourceRef.current = null;
33055
- if (isMounted) {
33056
- reconnectTimeoutRef.current = setTimeout(() => {
33057
- reconnectTimeoutRef.current = null;
33058
- if (isMounted) connect();
33059
- }, SSE_RECONNECT_DELAY_MS);
33096
+ const reader = resp.body.getReader();
33097
+ const decoder = new TextDecoder();
33098
+ let buffer = "";
33099
+ try {
33100
+ for (; ; ) {
33101
+ const { value, done } = await reader.read();
33102
+ if (done) break;
33103
+ buffer += decoder.decode(value, { stream: true });
33104
+ let sep;
33105
+ while ((sep = buffer.indexOf("\n\n")) !== -1) {
33106
+ const block = buffer.slice(0, sep);
33107
+ buffer = buffer.slice(sep + 2);
33108
+ const data = parseSSEDataBlock(block);
33109
+ if (data == null) continue;
33110
+ try {
33111
+ const parsed = JSON.parse(data);
33112
+ if (parsed.type === "connected") continue;
33113
+ if (parsed.commandId && parsed.action) {
33114
+ processCommandRef.current(parsed);
33115
+ }
33116
+ } catch (e) {
33117
+ console.error("[UIBridge] Failed to parse SSE message:", e);
33118
+ }
33119
+ }
33120
+ }
33121
+ } catch {
33060
33122
  }
33061
- };
33123
+ if (abortController === controller) abortController = null;
33124
+ if (isMounted && !controller.signal.aborted) scheduleReconnect();
33125
+ }).catch(() => {
33126
+ if (abortController === controller) abortController = null;
33127
+ if (isMounted && !controller.signal.aborted) scheduleReconnect();
33128
+ });
33062
33129
  };
33063
33130
  const handleVisibility = () => {
33064
33131
  if (document.visibilityState === "visible") {
33065
- if (!eventSourceRef.current) {
33132
+ if (!abortController) {
33066
33133
  if (reconnectTimeoutRef.current) {
33067
33134
  clearTimeout(reconnectTimeoutRef.current);
33068
33135
  reconnectTimeoutRef.current = null;
@@ -33075,10 +33142,7 @@ function useCommandRelay(options) {
33075
33142
  document.addEventListener("visibilitychange", handleVisibility);
33076
33143
  forceReconnectRef.current = () => {
33077
33144
  if (!isMounted) return;
33078
- if (eventSourceRef.current) {
33079
- eventSourceRef.current.close();
33080
- eventSourceRef.current = null;
33081
- }
33145
+ closeStream();
33082
33146
  if (reconnectTimeoutRef.current) {
33083
33147
  clearTimeout(reconnectTimeoutRef.current);
33084
33148
  reconnectTimeoutRef.current = null;
@@ -33089,10 +33153,7 @@ function useCommandRelay(options) {
33089
33153
  isMounted = false;
33090
33154
  forceReconnectRef.current = () => {
33091
33155
  };
33092
- if (eventSourceRef.current) {
33093
- eventSourceRef.current.close();
33094
- eventSourceRef.current = null;
33095
- }
33156
+ closeStream();
33096
33157
  if (reconnectTimeoutRef.current) {
33097
33158
  clearTimeout(reconnectTimeoutRef.current);
33098
33159
  reconnectTimeoutRef.current = null;
@@ -33125,7 +33186,7 @@ function useCommandRelay(options) {
33125
33186
  if (heartbeatVersion !== void 0) body.version = heartbeatVersion;
33126
33187
  const resp = await fetch(`${basePath}/heartbeat`, {
33127
33188
  method: "POST",
33128
- headers: { "Content-Type": "application/json" },
33189
+ headers: transportHeaders(authHeaderRef.current),
33129
33190
  body: JSON.stringify(body)
33130
33191
  });
33131
33192
  if (resp.ok) {
@@ -33256,7 +33317,8 @@ function CommandRelayListener(props) {
33256
33317
  appType: props.appType,
33257
33318
  framework: props.framework,
33258
33319
  capabilities: props.capabilities,
33259
- version: props.version
33320
+ version: props.version,
33321
+ authHeader: props.authHeader
33260
33322
  };
33261
33323
  useCommandRelay(options);
33262
33324
  return null;