@mentra/miniapp 0.3.0-beta.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.
Files changed (277) hide show
  1. package/README.md +259 -0
  2. package/dist/background/index.d.ts +60 -0
  3. package/dist/background/index.d.ts.map +1 -0
  4. package/dist/background/index.js +25 -0
  5. package/dist/background/index.js.map +1 -0
  6. package/dist/background/register.d.ts +38 -0
  7. package/dist/background/register.d.ts.map +1 -0
  8. package/dist/background/register.js +75 -0
  9. package/dist/background/register.js.map +1 -0
  10. package/dist/dev-reload.d.ts +21 -0
  11. package/dist/dev-reload.d.ts.map +1 -0
  12. package/dist/dev-reload.js +62 -0
  13. package/dist/dev-reload.js.map +1 -0
  14. package/dist/envelope.d.ts +23 -0
  15. package/dist/envelope.d.ts.map +1 -0
  16. package/dist/envelope.js +47 -0
  17. package/dist/envelope.js.map +1 -0
  18. package/dist/globals.d.ts +54 -0
  19. package/dist/globals.d.ts.map +1 -0
  20. package/dist/globals.js +12 -0
  21. package/dist/globals.js.map +1 -0
  22. package/dist/index.d.ts +59 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +31 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/modules/actions.d.ts +54 -0
  27. package/dist/modules/actions.d.ts.map +1 -0
  28. package/dist/modules/actions.js +122 -0
  29. package/dist/modules/actions.js.map +1 -0
  30. package/dist/modules/auth.d.ts +25 -0
  31. package/dist/modules/auth.d.ts.map +1 -0
  32. package/dist/modules/auth.js +52 -0
  33. package/dist/modules/auth.js.map +1 -0
  34. package/dist/modules/base64.d.ts +16 -0
  35. package/dist/modules/base64.d.ts.map +1 -0
  36. package/dist/modules/base64.js +92 -0
  37. package/dist/modules/base64.js.map +1 -0
  38. package/dist/modules/blob.d.ts +161 -0
  39. package/dist/modules/blob.d.ts.map +1 -0
  40. package/dist/modules/blob.js +277 -0
  41. package/dist/modules/blob.js.map +1 -0
  42. package/dist/modules/camera.d.ts +170 -0
  43. package/dist/modules/camera.d.ts.map +1 -0
  44. package/dist/modules/camera.js +116 -0
  45. package/dist/modules/camera.js.map +1 -0
  46. package/dist/modules/cloud.d.ts +34 -0
  47. package/dist/modules/cloud.d.ts.map +1 -0
  48. package/dist/modules/cloud.js +70 -0
  49. package/dist/modules/cloud.js.map +1 -0
  50. package/dist/modules/dashboard.d.ts +16 -0
  51. package/dist/modules/dashboard.d.ts.map +1 -0
  52. package/dist/modules/dashboard.js +27 -0
  53. package/dist/modules/dashboard.js.map +1 -0
  54. package/dist/modules/display.d.ts +106 -0
  55. package/dist/modules/display.d.ts.map +1 -0
  56. package/dist/modules/display.js +57 -0
  57. package/dist/modules/display.js.map +1 -0
  58. package/dist/modules/events.d.ts +160 -0
  59. package/dist/modules/events.d.ts.map +1 -0
  60. package/dist/modules/events.js +168 -0
  61. package/dist/modules/events.js.map +1 -0
  62. package/dist/modules/glasses.d.ts +28 -0
  63. package/dist/modules/glasses.d.ts.map +1 -0
  64. package/dist/modules/glasses.js +39 -0
  65. package/dist/modules/glasses.js.map +1 -0
  66. package/dist/modules/heading.d.ts +16 -0
  67. package/dist/modules/heading.d.ts.map +1 -0
  68. package/dist/modules/heading.js +20 -0
  69. package/dist/modules/heading.js.map +1 -0
  70. package/dist/modules/imu.d.ts +31 -0
  71. package/dist/modules/imu.d.ts.map +1 -0
  72. package/dist/modules/imu.js +40 -0
  73. package/dist/modules/imu.js.map +1 -0
  74. package/dist/modules/input.d.ts +34 -0
  75. package/dist/modules/input.d.ts.map +1 -0
  76. package/dist/modules/input.js +50 -0
  77. package/dist/modules/input.js.map +1 -0
  78. package/dist/modules/led.d.ts +35 -0
  79. package/dist/modules/led.d.ts.map +1 -0
  80. package/dist/modules/led.js +39 -0
  81. package/dist/modules/led.js.map +1 -0
  82. package/dist/modules/location.d.ts +27 -0
  83. package/dist/modules/location.d.ts.map +1 -0
  84. package/dist/modules/location.js +35 -0
  85. package/dist/modules/location.js.map +1 -0
  86. package/dist/modules/mic.d.ts +44 -0
  87. package/dist/modules/mic.d.ts.map +1 -0
  88. package/dist/modules/mic.js +67 -0
  89. package/dist/modules/mic.js.map +1 -0
  90. package/dist/modules/miniapps.d.ts +72 -0
  91. package/dist/modules/miniapps.d.ts.map +1 -0
  92. package/dist/modules/miniapps.js +48 -0
  93. package/dist/modules/miniapps.js.map +1 -0
  94. package/dist/modules/navigation.d.ts +326 -0
  95. package/dist/modules/navigation.d.ts.map +1 -0
  96. package/dist/modules/navigation.js +386 -0
  97. package/dist/modules/navigation.js.map +1 -0
  98. package/dist/modules/permissions.d.ts +58 -0
  99. package/dist/modules/permissions.d.ts.map +1 -0
  100. package/dist/modules/permissions.js +66 -0
  101. package/dist/modules/permissions.js.map +1 -0
  102. package/dist/modules/phone.d.ts +95 -0
  103. package/dist/modules/phone.d.ts.map +1 -0
  104. package/dist/modules/phone.js +110 -0
  105. package/dist/modules/phone.js.map +1 -0
  106. package/dist/modules/pivots/engine.d.ts +169 -0
  107. package/dist/modules/pivots/engine.d.ts.map +1 -0
  108. package/dist/modules/pivots/engine.js +779 -0
  109. package/dist/modules/pivots/engine.js.map +1 -0
  110. package/dist/modules/pivots/geometry.d.ts +84 -0
  111. package/dist/modules/pivots/geometry.d.ts.map +1 -0
  112. package/dist/modules/pivots/geometry.js +371 -0
  113. package/dist/modules/pivots/geometry.js.map +1 -0
  114. package/dist/modules/pivots/instructions.d.ts +139 -0
  115. package/dist/modules/pivots/instructions.d.ts.map +1 -0
  116. package/dist/modules/pivots/instructions.js +280 -0
  117. package/dist/modules/pivots/instructions.js.map +1 -0
  118. package/dist/modules/speaker.d.ts +169 -0
  119. package/dist/modules/speaker.d.ts.map +1 -0
  120. package/dist/modules/speaker.js +251 -0
  121. package/dist/modules/speaker.js.map +1 -0
  122. package/dist/modules/storage.d.ts +50 -0
  123. package/dist/modules/storage.d.ts.map +1 -0
  124. package/dist/modules/storage.js +99 -0
  125. package/dist/modules/storage.js.map +1 -0
  126. package/dist/modules/stream.d.ts +129 -0
  127. package/dist/modules/stream.d.ts.map +1 -0
  128. package/dist/modules/stream.js +52 -0
  129. package/dist/modules/stream.js.map +1 -0
  130. package/dist/modules/system.d.ts +45 -0
  131. package/dist/modules/system.d.ts.map +1 -0
  132. package/dist/modules/system.js +42 -0
  133. package/dist/modules/system.js.map +1 -0
  134. package/dist/modules/transcription.d.ts +75 -0
  135. package/dist/modules/transcription.d.ts.map +1 -0
  136. package/dist/modules/transcription.js +106 -0
  137. package/dist/modules/transcription.js.map +1 -0
  138. package/dist/modules/translation.d.ts +59 -0
  139. package/dist/modules/translation.d.ts.map +1 -0
  140. package/dist/modules/translation.js +119 -0
  141. package/dist/modules/translation.js.map +1 -0
  142. package/dist/modules/ui.d.ts +186 -0
  143. package/dist/modules/ui.d.ts.map +1 -0
  144. package/dist/modules/ui.js +293 -0
  145. package/dist/modules/ui.js.map +1 -0
  146. package/dist/protocol.d.ts +299 -0
  147. package/dist/protocol.d.ts.map +1 -0
  148. package/dist/protocol.js +325 -0
  149. package/dist/protocol.js.map +1 -0
  150. package/dist/react/MentraProvider.d.ts +33 -0
  151. package/dist/react/MentraProvider.d.ts.map +1 -0
  152. package/dist/react/MentraProvider.js +15 -0
  153. package/dist/react/MentraProvider.js.map +1 -0
  154. package/dist/react/MiniappHeader.d.ts +44 -0
  155. package/dist/react/MiniappHeader.d.ts.map +1 -0
  156. package/dist/react/MiniappHeader.js +32 -0
  157. package/dist/react/MiniappHeader.js.map +1 -0
  158. package/dist/react/index.d.ts +13 -0
  159. package/dist/react/index.d.ts.map +1 -0
  160. package/dist/react/index.js +9 -0
  161. package/dist/react/index.js.map +1 -0
  162. package/dist/react/useCapabilities.d.ts +26 -0
  163. package/dist/react/useCapabilities.d.ts.map +1 -0
  164. package/dist/react/useCapabilities.js +27 -0
  165. package/dist/react/useCapabilities.js.map +1 -0
  166. package/dist/react/useCapsuleHeaderStyle.d.ts +27 -0
  167. package/dist/react/useCapsuleHeaderStyle.d.ts.map +1 -0
  168. package/dist/react/useCapsuleHeaderStyle.js +36 -0
  169. package/dist/react/useCapsuleHeaderStyle.js.map +1 -0
  170. package/dist/react/useColorScheme.d.ts +18 -0
  171. package/dist/react/useColorScheme.d.ts.map +1 -0
  172. package/dist/react/useColorScheme.js +24 -0
  173. package/dist/react/useColorScheme.js.map +1 -0
  174. package/dist/react/useConnected.d.ts +17 -0
  175. package/dist/react/useConnected.d.ts.map +1 -0
  176. package/dist/react/useConnected.js +39 -0
  177. package/dist/react/useConnected.js.map +1 -0
  178. package/dist/react/useRpc.d.ts +29 -0
  179. package/dist/react/useRpc.d.ts.map +1 -0
  180. package/dist/react/useRpc.js +82 -0
  181. package/dist/react/useRpc.js.map +1 -0
  182. package/dist/react/useSafeArea.d.ts +21 -0
  183. package/dist/react/useSafeArea.d.ts.map +1 -0
  184. package/dist/react/useSafeArea.js +22 -0
  185. package/dist/react/useSafeArea.js.map +1 -0
  186. package/dist/session.d.ts +312 -0
  187. package/dist/session.d.ts.map +1 -0
  188. package/dist/session.js +653 -0
  189. package/dist/session.js.map +1 -0
  190. package/dist/sub-path-types.test-d.d.ts +28 -0
  191. package/dist/sub-path-types.test-d.d.ts.map +1 -0
  192. package/dist/sub-path-types.test-d.js +37 -0
  193. package/dist/sub-path-types.test-d.js.map +1 -0
  194. package/dist/transport/auto.d.ts +35 -0
  195. package/dist/transport/auto.d.ts.map +1 -0
  196. package/dist/transport/auto.js +125 -0
  197. package/dist/transport/auto.js.map +1 -0
  198. package/dist/transport/dispatch.d.ts +43 -0
  199. package/dist/transport/dispatch.d.ts.map +1 -0
  200. package/dist/transport/dispatch.js +91 -0
  201. package/dist/transport/dispatch.js.map +1 -0
  202. package/dist/transport/local-socket.d.ts +28 -0
  203. package/dist/transport/local-socket.d.ts.map +1 -0
  204. package/dist/transport/local-socket.js +80 -0
  205. package/dist/transport/local-socket.js.map +1 -0
  206. package/dist/transport/mock.d.ts +54 -0
  207. package/dist/transport/mock.d.ts.map +1 -0
  208. package/dist/transport/mock.js +207 -0
  209. package/dist/transport/mock.js.map +1 -0
  210. package/dist/transport/postmessage.d.ts +36 -0
  211. package/dist/transport/postmessage.d.ts.map +1 -0
  212. package/dist/transport/postmessage.js +78 -0
  213. package/dist/transport/postmessage.js.map +1 -0
  214. package/dist/transport/types.d.ts +25 -0
  215. package/dist/transport/types.d.ts.map +1 -0
  216. package/dist/transport/types.js +11 -0
  217. package/dist/transport/types.js.map +1 -0
  218. package/dist/ui/index.d.ts +98 -0
  219. package/dist/ui/index.d.ts.map +1 -0
  220. package/dist/ui/index.js +32 -0
  221. package/dist/ui/index.js.map +1 -0
  222. package/package.json +84 -0
  223. package/src/background/index.ts +152 -0
  224. package/src/background/register.ts +85 -0
  225. package/src/dev-reload.ts +60 -0
  226. package/src/envelope.ts +54 -0
  227. package/src/globals.ts +62 -0
  228. package/src/index.ts +171 -0
  229. package/src/modules/actions.ts +156 -0
  230. package/src/modules/auth.ts +68 -0
  231. package/src/modules/base64.ts +87 -0
  232. package/src/modules/blob.ts +341 -0
  233. package/src/modules/camera.ts +242 -0
  234. package/src/modules/cloud.ts +94 -0
  235. package/src/modules/dashboard.ts +31 -0
  236. package/src/modules/display.ts +122 -0
  237. package/src/modules/events.ts +291 -0
  238. package/src/modules/glasses.ts +44 -0
  239. package/src/modules/heading.ts +23 -0
  240. package/src/modules/imu.ts +44 -0
  241. package/src/modules/input.ts +71 -0
  242. package/src/modules/led.ts +61 -0
  243. package/src/modules/location.ts +39 -0
  244. package/src/modules/mic.ts +74 -0
  245. package/src/modules/miniapps.ts +90 -0
  246. package/src/modules/navigation.ts +670 -0
  247. package/src/modules/permissions.ts +84 -0
  248. package/src/modules/phone.ts +155 -0
  249. package/src/modules/pivots/engine.ts +863 -0
  250. package/src/modules/pivots/geometry.ts +409 -0
  251. package/src/modules/pivots/instructions.ts +343 -0
  252. package/src/modules/speaker.ts +335 -0
  253. package/src/modules/storage.ts +109 -0
  254. package/src/modules/stream.ts +163 -0
  255. package/src/modules/system.ts +76 -0
  256. package/src/modules/transcription.ts +145 -0
  257. package/src/modules/translation.ts +149 -0
  258. package/src/modules/ui.ts +498 -0
  259. package/src/protocol.ts +378 -0
  260. package/src/react/MentraProvider.tsx +50 -0
  261. package/src/react/MiniappHeader.tsx +122 -0
  262. package/src/react/index.ts +12 -0
  263. package/src/react/useCapabilities.ts +29 -0
  264. package/src/react/useCapsuleHeaderStyle.ts +54 -0
  265. package/src/react/useColorScheme.ts +26 -0
  266. package/src/react/useConnected.ts +47 -0
  267. package/src/react/useRpc.ts +106 -0
  268. package/src/react/useSafeArea.ts +40 -0
  269. package/src/session.ts +886 -0
  270. package/src/sub-path-types.test-d.ts +49 -0
  271. package/src/transport/auto.ts +143 -0
  272. package/src/transport/dispatch.ts +106 -0
  273. package/src/transport/local-socket.ts +93 -0
  274. package/src/transport/mock.ts +251 -0
  275. package/src/transport/postmessage.ts +95 -0
  276. package/src/transport/types.ts +26 -0
  277. package/src/ui/index.ts +122 -0
@@ -0,0 +1,498 @@
1
+ /**
2
+ * session.ui — bus between a background JSContext miniapp and its
3
+ * on-demand UI WebView. Supports two interaction patterns:
4
+ *
5
+ * 1. **Broadcast** (fire-and-forget, either direction)
6
+ * - background → UI: `session.ui.send(channel, payload)`
7
+ * - UI → background: `mentra.send(channel, payload)`
8
+ * - subscribe: `session.ui.on(channel, cb)` / `mentra.on(channel, cb)`
9
+ *
10
+ * 2. **RPC** (request/response, UI → background only)
11
+ * - UI side: `await mentra.request(channel, payload, options?)`
12
+ * - background side: `session.ui.handle(channel, (payload, ctx?) => result)`
13
+ * - single handler per channel; double-register throws synchronously.
14
+ * - errors thrown in the handler reject the caller's promise.
15
+ * - cancellation via `options.signal` aborts the handler's `ctx.signal`
16
+ * and drops the eventual reply.
17
+ *
18
+ * Broadcast vs. RPC is declared at the channel level: wrap a channel's
19
+ * payload type in `Rpc<Req, Res>` in the shared registry to make it RPC.
20
+ * Wrong-API-for-channel is a compile-time error.
21
+ *
22
+ * Buffering:
23
+ * - `mentra.send` BUFFERS until `mentra.ready()` acks. The WebView is
24
+ * the short-lived side and shouldn't drop user input.
25
+ * - `session.ui.send` silently DROPS when no WebView is bound.
26
+ * Background is the source of truth; UI state shouldn't accumulate.
27
+ * - Per-channel inbound buffering (up to 32 payloads) covers the
28
+ * `controller pushed before React attached the listener` race — see
29
+ * the WebView shim for details.
30
+ *
31
+ * Wire envelopes (internal — not part of the SDK surface):
32
+ * - `UI_OPEN` — WebView posted `{type:"ready"}`.
33
+ * - `UI_CLOSE` — host tore down the WebView.
34
+ * - `UI_MESSAGE` — WebView → background. `requestId` set on RPC calls.
35
+ * - `UI_SEND` — background → WebView. `requestId` set on RPC replies.
36
+ * - `UI_CANCEL` — either direction. Carries only `requestId`; aborts
37
+ * the in-flight handler's signal.
38
+ */
39
+ import type {MiniappSession} from "../session"
40
+
41
+ export type UIChannelHandler<T = unknown> = (payload: T) => void
42
+ export type UIUnsubscribe = () => void
43
+
44
+ /**
45
+ * Brand for declaring an RPC channel in the shared Channels registry.
46
+ *
47
+ * Wrap a channel's payload type in `Rpc<Req, Res>` to mark it as
48
+ * request/response. The SDK's `mentra.request` / `session.ui.handle`
49
+ * accept only `Rpc<...>` channels; `mentra.send` / `session.ui.on`
50
+ * accept only non-RPC channels. Using the wrong API for the wrong
51
+ * channel is a compile-time error.
52
+ *
53
+ * export interface Channels {
54
+ * "live-transcript": {text: string} // broadcast
55
+ * "compute-route": Rpc<RouteOpts, RouteResult> // RPC
56
+ * }
57
+ */
58
+ declare const __rpc_brand: unique symbol
59
+ export type Rpc<Req, Res> = {readonly [__rpc_brand]: true; readonly req: Req; readonly res: Res}
60
+
61
+ /** True if `T` is an `Rpc<...>` channel entry. */
62
+ export type IsRpc<T> = T extends Rpc<unknown, unknown> ? true : false
63
+ /** Request payload type of an `Rpc<Req, Res>` entry. */
64
+ export type RpcReq<T> = T extends Rpc<infer Req, unknown> ? Req : never
65
+ /** Response payload type of an `Rpc<Req, Res>` entry. */
66
+ export type RpcRes<T> = T extends Rpc<unknown, infer Res> ? Res : never
67
+
68
+ /** Options accepted by `mentra.request`. */
69
+ export interface RpcRequestOptions {
70
+ /** Abort the in-flight call. Sends UI_CANCEL to the handler. */
71
+ signal?: AbortSignal
72
+ /** Reject with `MentraRpcTimeoutError` after this many ms. No default. */
73
+ timeout?: number
74
+ }
75
+
76
+ /** Context passed as the optional 2nd arg to an `ui.handle` handler. */
77
+ export interface RpcHandlerContext {
78
+ /** Aborts when the UI side cancels the call (or its timeout fires). */
79
+ signal: AbortSignal
80
+ }
81
+
82
+ /**
83
+ * Error thrown by `mentra.request` when the handler threw or returned an
84
+ * error envelope. Plain `Error` subclass — distinguished by `err.name`.
85
+ * `err.cause` is `{code?: string}` if the handler attached one.
86
+ */
87
+ export class MentraRpcError extends Error {
88
+ constructor(message: string, options?: {cause?: {code?: string}}) {
89
+ super(message)
90
+ this.name = "MentraRpcError"
91
+ // Assign `cause` directly: the package's tsconfig targets ES2020 lib
92
+ // where `Error`'s ctor is typed as 1-arity (no `ErrorOptions`).
93
+ // Modern JS engines still allow setting `cause` as a plain property.
94
+ if (options?.cause !== undefined) {
95
+ ;(this as Error & {cause?: unknown}).cause = options.cause
96
+ }
97
+ }
98
+ }
99
+
100
+ /** Thrown by `mentra.request` when its `{timeout}` elapses. */
101
+ export class MentraRpcTimeoutError extends Error {
102
+ constructor(message = "RPC timed out") {
103
+ super(message)
104
+ this.name = "MentraRpcTimeoutError"
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Public surface mirrored on `session.ui`. Generic over a `Channels`
110
+ * type-map so miniapps importing the typed `shared/channels.ts` get
111
+ * compile-time enforcement on channel names + payload shapes.
112
+ *
113
+ * Broadcast vs. RPC channels are distinguished at the type level:
114
+ * - Channel value `Rpc<Req, Res>` → only `handle()` accepts it on
115
+ * background; only `mentra.request(...)` accepts it on UI.
116
+ * - Channel value anything else → only `send()`/`on()` accept it
117
+ * on both sides.
118
+ *
119
+ * The default `Record<string, unknown>` mapping lets unannotated usage
120
+ * compile — the SDK doesn't impose a registry of its own.
121
+ */
122
+ export interface UIModule<TChannels extends object = Record<string, unknown>> {
123
+ /** True iff a WebView is currently bound to this miniapp. */
124
+ isOpen(): boolean
125
+
126
+ /**
127
+ * Subscribe to the "WebView mounted + ready()" lifecycle event. If
128
+ * a WebView is already mounted when subscribe() is called, the
129
+ * handler fires immediately for the current binding.
130
+ */
131
+ onOpen(cb: () => void): UIUnsubscribe
132
+
133
+ /**
134
+ * Subscribe to the "WebView unmounted" lifecycle event. Fires once
135
+ * per close; if no WebView is bound at subscribe time the handler
136
+ * stays armed for the next mount → close cycle.
137
+ */
138
+ onClose(cb: () => void): UIUnsubscribe
139
+
140
+ /**
141
+ * Broadcast a typed message to the bound WebView. Silently drops if
142
+ * no WebView is bound. Compile-error if `C` is an RPC channel.
143
+ */
144
+ send<C extends keyof TChannels & string>(
145
+ channel: IsRpc<TChannels[C]> extends true ? never : C,
146
+ payload: TChannels[C],
147
+ ): void
148
+
149
+ /**
150
+ * Subscribe to broadcast messages from the bound WebView. Returns an
151
+ * unsubscribe fn. Compile-error if `C` is an RPC channel — use
152
+ * `handle()` for RPC channels.
153
+ */
154
+ on<C extends keyof TChannels & string>(
155
+ channel: IsRpc<TChannels[C]> extends true ? never : C,
156
+ cb: UIChannelHandler<TChannels[C]>,
157
+ ): UIUnsubscribe
158
+
159
+ /**
160
+ * Register the single handler for an RPC channel. The UI side calls
161
+ * `mentra.request(channel, payload, options?)`; this handler resolves
162
+ * the call.
163
+ *
164
+ * Throws synchronously if a handler is already registered for the
165
+ * channel. Returns a deregister fn that removes the handler.
166
+ *
167
+ * Compile-error if `C` is a broadcast (non-Rpc) channel.
168
+ */
169
+ handle<C extends keyof TChannels & string>(
170
+ channel: IsRpc<TChannels[C]> extends true ? C : never,
171
+ handler: (
172
+ payload: RpcReq<TChannels[C]>,
173
+ ctx?: RpcHandlerContext,
174
+ ) => Promise<RpcRes<TChannels[C]>> | RpcRes<TChannels[C]>,
175
+ ): UIUnsubscribe
176
+ }
177
+
178
+ /**
179
+ * Wire-level envelope types. Internal — not exported.
180
+ *
181
+ * `requestId` is set on RPC frames (call, result, cancel). Broadcast
182
+ * `UI_MESSAGE` / `UI_SEND` frames carry no `requestId`. `UI_CANCEL`
183
+ * frames carry only `requestId` (no channel, no payload).
184
+ */
185
+ type UISendEnvelope =
186
+ | {type: "UI_SEND"; channel: string; payload: unknown; seq: number; requestId?: string}
187
+ | {type: "UI_CANCEL"; requestId: string}
188
+
189
+ type UIInboundEnvelope =
190
+ | {type: "UI_MESSAGE"; channel: string; payload: unknown; seq: number; requestId?: string}
191
+ | {type: "UI_OPEN"}
192
+ | {type: "UI_CLOSE"}
193
+ | {type: "UI_CANCEL"; requestId: string}
194
+
195
+ function rpcErrorFromUnknown(e: unknown): RpcErrorEnvelope {
196
+ if (e instanceof Error) {
197
+ // `cause` is ES2022; this package's lib targets ES2020 where Error
198
+ // has no `cause` field. Read it via a structural cast. Diagnostic fields
199
+ // (code/stage/transport) may live as own props (PhotoError-style) or on
200
+ // the cause — own props win.
201
+ const own = e as Error & {code?: string; stage?: string; transport?: string; cause?: unknown}
202
+ const cause = own.cause as {code?: string} | undefined
203
+ return compactEnvelope({
204
+ message: e.message,
205
+ code: own.code ?? cause?.code,
206
+ stage: own.stage,
207
+ transport: own.transport,
208
+ })
209
+ }
210
+ if (e && typeof e === "object") {
211
+ // Hosts reject RPCs with plain structured objects ({code, message, stage,
212
+ // transport}); stringifying them produced the infamous "[object Object]".
213
+ const o = e as {message?: unknown; code?: unknown; stage?: unknown; transport?: unknown}
214
+ return compactEnvelope({
215
+ message: typeof o.message === "string" ? o.message : JSON.stringify(e),
216
+ code: typeof o.code === "string" ? o.code : undefined,
217
+ stage: typeof o.stage === "string" ? o.stage : undefined,
218
+ transport: typeof o.transport === "string" ? o.transport : undefined,
219
+ })
220
+ }
221
+ return {message: String(e)}
222
+ }
223
+
224
+ export interface RpcErrorEnvelope {
225
+ message: string
226
+ code?: string
227
+ stage?: string
228
+ transport?: string
229
+ }
230
+
231
+ function compactEnvelope(env: RpcErrorEnvelope): RpcErrorEnvelope {
232
+ const out: RpcErrorEnvelope = {message: env.message}
233
+ if (env.code) out.code = env.code
234
+ if (env.stage) out.stage = env.stage
235
+ if (env.transport) out.transport = env.transport
236
+ return out
237
+ }
238
+
239
+ export class UIModuleImpl<TChannels extends object = Record<string, unknown>>
240
+ implements UIModule<TChannels>
241
+ {
242
+ /** True between UI_OPEN and the matching UI_CLOSE. */
243
+ private bound = false
244
+ /** Monotonic outbound seq number. Reset on bind. */
245
+ private nextSeq = 1
246
+ /** Open-lifecycle handlers — fire on every UI_OPEN. */
247
+ private readonly openHandlers: Set<() => void> = new Set()
248
+ private readonly closeHandlers: Set<() => void> = new Set()
249
+ /** channel → set of subscribers. */
250
+ private readonly channelHandlers: Map<string, Set<UIChannelHandler>> = new Map()
251
+
252
+ /** channel → single registered RPC handler. */
253
+ private readonly rpcHandlers: Map<
254
+ string,
255
+ (payload: unknown, ctx: RpcHandlerContext) => Promise<unknown> | unknown
256
+ > = new Map()
257
+
258
+ /** requestId → AbortController for in-flight RPC handler invocations. */
259
+ private readonly inflightRpc: Map<string, AbortController> = new Map()
260
+
261
+ constructor(private readonly session: MiniappSession) {
262
+ // The session forwards UI_OPEN / UI_CLOSE / UI_MESSAGE envelopes via
263
+ // its internal stream subscriber surface. The UIModule registers
264
+ // once via session._subscribe to receive them. The "_ui" stream
265
+ // name is internal — not exposed in the public stream list — so
266
+ // the host router knows to route lifecycle frames here without
267
+ // bumping any existing stream type.
268
+ this.session._subscribe("_ui", (env: unknown) => this.handleInbound(env as UIInboundEnvelope))
269
+ }
270
+
271
+ // Arrow-property bindings make every public method safe to destructure
272
+ // (`const {send} = session.ui`) or pass as a bare callback — a plain
273
+ // method loses `this` and crashes on `this.bound`.
274
+ isOpen = (): boolean => {
275
+ return this.bound
276
+ }
277
+
278
+ onOpen = (cb: () => void): UIUnsubscribe => {
279
+ this.openHandlers.add(cb)
280
+ if (this.bound) {
281
+ // Late subscriber — fire once for the current binding so callers
282
+ // that wire onOpen *after* the WebView mounted don't miss it.
283
+ // Gate on session readiness for the same reason handleInbound does:
284
+ // a late onOpen must still observe populated capabilities, not the
285
+ // pre-CONNECT_ACK null snapshot. If not yet ready, the "ready"
286
+ // listener registered in handleInbound's UI_OPEN branch will fan
287
+ // out to every openHandler (this one included), so we don't wire a
288
+ // second listener here.
289
+ if (this.session.ready) {
290
+ try {
291
+ cb()
292
+ } catch (e) {
293
+ // eslint-disable-next-line no-console
294
+ console.warn("session.ui.onOpen late-fire threw:", e)
295
+ }
296
+ }
297
+ }
298
+ return () => {
299
+ this.openHandlers.delete(cb)
300
+ }
301
+ }
302
+
303
+ onClose = (cb: () => void): UIUnsubscribe => {
304
+ this.closeHandlers.add(cb)
305
+ return () => {
306
+ this.closeHandlers.delete(cb)
307
+ }
308
+ }
309
+
310
+ send = <C extends keyof TChannels & string>(channel: C, payload: TChannels[C]): void => {
311
+ if (!this.bound) {
312
+ // Per spec: drop silently when no WebView is bound. Background
313
+ // is the source of truth — the WebView re-fetches state on next
314
+ // open via session.ui.onOpen.
315
+ return
316
+ }
317
+ const seq = this.nextSeq++
318
+ const envelope: UISendEnvelope = {type: "UI_SEND", channel, payload, seq}
319
+ this.session.sendOneShot(envelope)
320
+ }
321
+
322
+ on = <C extends keyof TChannels & string>(
323
+ channel: C,
324
+ cb: UIChannelHandler<TChannels[C]>,
325
+ ): UIUnsubscribe => {
326
+ let set = this.channelHandlers.get(channel as string)
327
+ if (!set) {
328
+ set = new Set()
329
+ this.channelHandlers.set(channel as string, set)
330
+ }
331
+ set.add(cb as UIChannelHandler)
332
+ return () => {
333
+ set!.delete(cb as UIChannelHandler)
334
+ }
335
+ }
336
+
337
+ handle = <C extends keyof TChannels & string>(
338
+ channel: IsRpc<TChannels[C]> extends true ? C : never,
339
+ handler: (
340
+ payload: RpcReq<TChannels[C]>,
341
+ ctx?: RpcHandlerContext,
342
+ ) => Promise<RpcRes<TChannels[C]>> | RpcRes<TChannels[C]>,
343
+ ): UIUnsubscribe => {
344
+ const key = channel as unknown as string
345
+ if (this.rpcHandlers.has(key)) {
346
+ throw new Error(`session.ui.handle: a handler is already registered for "${key}"`)
347
+ }
348
+ this.rpcHandlers.set(
349
+ key,
350
+ handler as unknown as (payload: unknown, ctx: RpcHandlerContext) => Promise<unknown> | unknown,
351
+ )
352
+ return () => {
353
+ this.rpcHandlers.delete(key)
354
+ }
355
+ }
356
+
357
+ /** Fire every registered open handler once. Guarded per-handler. */
358
+ private fireOpenHandlers(): void {
359
+ for (const h of this.openHandlers) {
360
+ try {
361
+ h()
362
+ } catch (e) {
363
+ // eslint-disable-next-line no-console
364
+ console.warn("session.ui.onOpen handler threw", e)
365
+ }
366
+ }
367
+ }
368
+
369
+ /** @internal — handle UI_OPEN / UI_CLOSE / UI_MESSAGE envelopes from the host. */
370
+ private handleInbound(env: UIInboundEnvelope): void {
371
+ if (env.type === "UI_OPEN") {
372
+ this.bound = true
373
+ this.nextSeq = 1
374
+ // onOpen handlers almost always read session.capabilities /
375
+ // session.ready to hydrate the fresh WebView. The WebView's
376
+ // `mentra.ready()` (which produces this UI_OPEN) races the
377
+ // background session's CONNECT_ACK — on a fast bridge UI_OPEN can
378
+ // arrive first, leaving capabilities null and the UI rendering a
379
+ // "no glasses" snapshot that never self-corrects. Gate the open
380
+ // fan-out on session readiness so handlers always observe a
381
+ // populated session. The WebView is marked bound immediately
382
+ // (send/isOpen work, inbound stays buffered) — only the open
383
+ // callbacks wait.
384
+ if (this.session.ready) {
385
+ this.fireOpenHandlers()
386
+ } else {
387
+ const off = this.session.on("ready", () => {
388
+ off()
389
+ // If the WebView closed during the connect window, don't fire
390
+ // stale open handlers — a later UI_OPEN will re-trigger them.
391
+ if (this.bound) this.fireOpenHandlers()
392
+ })
393
+ }
394
+ return
395
+ }
396
+ if (env.type === "UI_CLOSE") {
397
+ this.bound = false
398
+ for (const h of this.closeHandlers) {
399
+ try {
400
+ h()
401
+ } catch (e) {
402
+ // eslint-disable-next-line no-console
403
+ console.warn("session.ui.onClose handler threw", e)
404
+ }
405
+ }
406
+ return
407
+ }
408
+ if (env.type === "UI_MESSAGE") {
409
+ // RPC call: requestId set → dispatch to handle() handler.
410
+ if (typeof env.requestId === "string") {
411
+ this.dispatchRpcCall(env.channel, env.payload, env.requestId)
412
+ return
413
+ }
414
+ // Broadcast: fan out to on() subscribers.
415
+ const set = this.channelHandlers.get(env.channel)
416
+ if (!set || set.size === 0) return
417
+ for (const h of set) {
418
+ try {
419
+ h(env.payload)
420
+ } catch (e) {
421
+ // eslint-disable-next-line no-console
422
+ console.warn(`session.ui.on(${env.channel}) threw`, e)
423
+ }
424
+ }
425
+ return
426
+ }
427
+ if (env.type === "UI_CANCEL") {
428
+ const ctrl = this.inflightRpc.get(env.requestId)
429
+ if (ctrl) {
430
+ try {
431
+ ctrl.abort()
432
+ } catch {
433
+ /* ignore */
434
+ }
435
+ }
436
+ return
437
+ }
438
+ }
439
+
440
+ /** @internal — invoke a registered RPC handler and send back the reply. */
441
+ private dispatchRpcCall(channel: string, payload: unknown, requestId: string): void {
442
+ const handler = this.rpcHandlers.get(channel)
443
+ if (!handler) {
444
+ // No handler registered. Reply with a structured error so the
445
+ // UI's request promise rejects with a useful message.
446
+ this.sendRpcReply(channel, requestId, {
447
+ ok: false,
448
+ error: {message: `no handler registered for "${channel}"`},
449
+ })
450
+ return
451
+ }
452
+ const ctrl = new AbortController()
453
+ this.inflightRpc.set(requestId, ctrl)
454
+ const ctx: RpcHandlerContext = {signal: ctrl.signal}
455
+
456
+ const finish = (
457
+ envelope:
458
+ | {ok: true; result: unknown}
459
+ | {ok: false; error: RpcErrorEnvelope},
460
+ ): void => {
461
+ this.inflightRpc.delete(requestId)
462
+ // If the controller already aborted (UI cancelled), drop the
463
+ // reply — the UI side has already removed its listener.
464
+ if (ctrl.signal.aborted) return
465
+ this.sendRpcReply(channel, requestId, envelope)
466
+ }
467
+
468
+ let result: unknown
469
+ try {
470
+ result = handler(payload, ctx)
471
+ } catch (e) {
472
+ finish({ok: false, error: rpcErrorFromUnknown(e)})
473
+ return
474
+ }
475
+ if (result && typeof (result as {then?: unknown}).then === "function") {
476
+ ;(result as Promise<unknown>).then(
477
+ (v) => finish({ok: true, result: v}),
478
+ (e) => finish({ok: false, error: rpcErrorFromUnknown(e)}),
479
+ )
480
+ } else {
481
+ finish({ok: true, result})
482
+ }
483
+ }
484
+
485
+ /** @internal — send a UI_SEND envelope tagged with a requestId. */
486
+ private sendRpcReply(
487
+ channel: string,
488
+ requestId: string,
489
+ payload:
490
+ | {ok: true; result: unknown}
491
+ | {ok: false; error: RpcErrorEnvelope},
492
+ ): void {
493
+ if (!this.bound) return
494
+ const seq = this.nextSeq++
495
+ const envelope: UISendEnvelope = {type: "UI_SEND", channel, payload, seq, requestId}
496
+ this.session.sendOneShot(envelope)
497
+ }
498
+ }