@mentra/miniapp 3.2.1-beta.319 → 3.2.1-beta.354

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 (102) hide show
  1. package/README.md +16 -0
  2. package/dist/background/index.d.ts +5 -3
  3. package/dist/background/index.d.ts.map +1 -1
  4. package/dist/background/index.js +1 -0
  5. package/dist/background/index.js.map +1 -1
  6. package/dist/index.d.ts +6 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/modules/display.d.ts +33 -0
  11. package/dist/modules/display.d.ts.map +1 -1
  12. package/dist/modules/display.js +2 -0
  13. package/dist/modules/display.js.map +1 -1
  14. package/dist/modules/meeting.d.ts +66 -6
  15. package/dist/modules/meeting.d.ts.map +1 -1
  16. package/dist/modules/meeting.js +72 -19
  17. package/dist/modules/meeting.js.map +1 -1
  18. package/dist/modules/stream.d.ts +46 -0
  19. package/dist/modules/stream.d.ts.map +1 -1
  20. package/dist/modules/stream.js +107 -0
  21. package/dist/modules/stream.js.map +1 -1
  22. package/dist/protocol.d.ts +39 -0
  23. package/dist/protocol.d.ts.map +1 -1
  24. package/dist/protocol.js +36 -0
  25. package/dist/protocol.js.map +1 -1
  26. package/dist/react/StreamPreview.d.ts +41 -0
  27. package/dist/react/StreamPreview.d.ts.map +1 -0
  28. package/dist/react/StreamPreview.js +144 -0
  29. package/dist/react/StreamPreview.js.map +1 -0
  30. package/dist/react/index.d.ts +2 -0
  31. package/dist/react/index.d.ts.map +1 -1
  32. package/dist/react/index.js +1 -0
  33. package/dist/react/index.js.map +1 -1
  34. package/dist/react/stream-preview/PreviewConnection.d.ts +139 -0
  35. package/dist/react/stream-preview/PreviewConnection.d.ts.map +1 -0
  36. package/dist/react/stream-preview/PreviewConnection.js +462 -0
  37. package/dist/react/stream-preview/PreviewConnection.js.map +1 -0
  38. package/dist/react/stream-preview/controlChannel.d.ts +75 -0
  39. package/dist/react/stream-preview/controlChannel.d.ts.map +1 -0
  40. package/dist/react/stream-preview/controlChannel.js +55 -0
  41. package/dist/react/stream-preview/controlChannel.js.map +1 -0
  42. package/dist/react/stream-preview/creditLoop.d.ts +81 -0
  43. package/dist/react/stream-preview/creditLoop.d.ts.map +1 -0
  44. package/dist/react/stream-preview/creditLoop.js +146 -0
  45. package/dist/react/stream-preview/creditLoop.js.map +1 -0
  46. package/dist/react/stream-preview/protocol.d.ts +113 -0
  47. package/dist/react/stream-preview/protocol.d.ts.map +1 -0
  48. package/dist/react/stream-preview/protocol.js +148 -0
  49. package/dist/react/stream-preview/protocol.js.map +1 -0
  50. package/dist/react/stream-preview/resizeCoalescer.d.ts +38 -0
  51. package/dist/react/stream-preview/resizeCoalescer.d.ts.map +1 -0
  52. package/dist/react/stream-preview/resizeCoalescer.js +71 -0
  53. package/dist/react/stream-preview/resizeCoalescer.js.map +1 -0
  54. package/dist/react/stream-preview/testFrames.d.ts +38 -0
  55. package/dist/react/stream-preview/testFrames.d.ts.map +1 -0
  56. package/dist/react/stream-preview/testFrames.js +50 -0
  57. package/dist/react/stream-preview/testFrames.js.map +1 -0
  58. package/dist/react/stream-preview/tiers.d.ts +21 -0
  59. package/dist/react/stream-preview/tiers.d.ts.map +1 -0
  60. package/dist/react/stream-preview/tiers.js +33 -0
  61. package/dist/react/stream-preview/tiers.js.map +1 -0
  62. package/dist/react/stream-preview/trace.d.ts +23 -0
  63. package/dist/react/stream-preview/trace.d.ts.map +1 -0
  64. package/dist/react/stream-preview/trace.js +71 -0
  65. package/dist/react/stream-preview/trace.js.map +1 -0
  66. package/dist/react/stream-preview/transport.d.ts +79 -0
  67. package/dist/react/stream-preview/transport.d.ts.map +1 -0
  68. package/dist/react/stream-preview/transport.js +209 -0
  69. package/dist/react/stream-preview/transport.js.map +1 -0
  70. package/dist/react/stream-preview/yuvRenderer.d.ts +31 -0
  71. package/dist/react/stream-preview/yuvRenderer.d.ts.map +1 -0
  72. package/dist/react/stream-preview/yuvRenderer.js +398 -0
  73. package/dist/react/stream-preview/yuvRenderer.js.map +1 -0
  74. package/dist/session.d.ts +5 -0
  75. package/dist/session.d.ts.map +1 -1
  76. package/dist/session.js +5 -0
  77. package/dist/session.js.map +1 -1
  78. package/dist/ui/index.d.ts +2 -0
  79. package/dist/ui/index.d.ts.map +1 -1
  80. package/dist/ui/index.js +1 -0
  81. package/dist/ui/index.js.map +1 -1
  82. package/package.json +8 -3
  83. package/src/background/index.ts +14 -1
  84. package/src/index.ts +15 -0
  85. package/src/modules/display.ts +37 -0
  86. package/src/modules/meeting.ts +133 -23
  87. package/src/modules/stream.ts +153 -1
  88. package/src/protocol.ts +75 -0
  89. package/src/react/StreamPreview.tsx +187 -0
  90. package/src/react/index.ts +2 -0
  91. package/src/react/stream-preview/PreviewConnection.ts +552 -0
  92. package/src/react/stream-preview/controlChannel.ts +119 -0
  93. package/src/react/stream-preview/creditLoop.ts +189 -0
  94. package/src/react/stream-preview/protocol.ts +227 -0
  95. package/src/react/stream-preview/resizeCoalescer.ts +88 -0
  96. package/src/react/stream-preview/testFrames.ts +86 -0
  97. package/src/react/stream-preview/tiers.ts +37 -0
  98. package/src/react/stream-preview/trace.ts +81 -0
  99. package/src/react/stream-preview/transport.ts +242 -0
  100. package/src/react/stream-preview/yuvRenderer.ts +453 -0
  101. package/src/session.ts +8 -0
  102. package/src/ui/index.ts +2 -0
@@ -0,0 +1,552 @@
1
+ /**
2
+ * The preview connection for this document.
3
+ *
4
+ * Three owners with three lifetimes meet here:
5
+ * - The **lease** belongs to the miniapp's background (`session.stream.preview()`). The page
6
+ * never creates or releases it; it only hears `lease_available` / `lease_ended`.
7
+ * - The **connection** belongs to this document: the host config (token, docGen), the frame
8
+ * transport and the credit loop. It is a module-scoped singleton so it survives
9
+ * `<StreamPreview>` unmounting and remounting; a remount sends `start` without a handshake.
10
+ * It ends with the document (reload, navigation, `pagehide`) or when the lease ends.
11
+ * - The **renderer** belongs to the component and is attached here only as a draw callback.
12
+ *
13
+ * Control is desired-state: the component says which mount epoch wants which tier and whether it
14
+ * wants frames, and `sync()` sends the difference to the host over `_preview`. A new document
15
+ * always needs a new handshake; a new mount never does.
16
+ */
17
+
18
+ import {createMentraControlChannel, PreviewControlError} from "./controlChannel"
19
+ import type {
20
+ PreviewControlChannel,
21
+ PreviewControlReply,
22
+ PreviewControlRequest,
23
+ PreviewDocumentConfig,
24
+ PreviewHandshakeResult,
25
+ PreviewHostEvent,
26
+ } from "./controlChannel"
27
+ import {CreditLoop, type CreditLoopStats} from "./creditLoop"
28
+ import type {ParsedFrame} from "./protocol"
29
+ import {sameTier, type PreviewTier} from "./tiers"
30
+ import {previewTrace, previewTraceWarn, previewTraceWarnLimited} from "./trace"
31
+ import {ackMessage, createPreviewTransport, type PreviewTransport, type PreviewTransportOptions} from "./transport"
32
+
33
+ /** The only control protocol this SDK speaks. Anything else is reported as `unsupported`. */
34
+ export const PREVIEW_PROTOCOL_VERSION = 1
35
+
36
+ /** Automatic re-handshakes after a transport failure, per window, before giving up until remount. */
37
+ const RECONNECT_BUDGET = 3
38
+ const RECONNECT_WINDOW_MS = 60_000
39
+ /** Immediate re-handshakes after the host answers a handshake as stale. */
40
+ const STALE_HANDSHAKE_RETRIES = 2
41
+
42
+ export type PreviewConnectionState =
43
+ /** No handshake attempted yet in this document, or the lease ended and nobody wants frames. */
44
+ | "idle"
45
+ | "handshaking"
46
+ /** The host has no lease for this miniapp yet. Not an error: `lease_available` will follow. */
47
+ | "waiting_for_lease"
48
+ | "connecting"
49
+ | "open"
50
+ /** A failure that a re-handshake may fix (transport lost, ack timeout, host refused). */
51
+ | "error"
52
+ /** Terminal for this document: unknown protocol version or missing platform support. */
53
+ | "unsupported"
54
+ /** Not running inside a Mentra App WebView. */
55
+ | "unavailable"
56
+ | "closed"
57
+
58
+ export interface PreviewConnectionStateDetail {
59
+ code?: string
60
+ reason?: string
61
+ paused?: boolean
62
+ }
63
+
64
+ /** What a mounted `<StreamPreview>` gives the connection. */
65
+ export interface PreviewSink {
66
+ readonly mountEpoch: number
67
+ /** Submit one frame; null when the renderer has no usable context. */
68
+ draw(frame: ParsedFrame): number | null
69
+ onState(state: PreviewConnectionState, detail: PreviewConnectionStateDetail): void
70
+ onStatus(status: Record<string, unknown>): void
71
+ onError(code: string): void
72
+ }
73
+
74
+ export interface PreviewDesiredState {
75
+ /** Rendered box in device pixels; only sent when `tier` changes. */
76
+ boxWidth: number
77
+ boxHeight: number
78
+ /** Null means hidden (zero-size box). */
79
+ tier: PreviewTier | null
80
+ /** False while the page is hidden (`visibilitychange`) or the component is unmounting. */
81
+ running: boolean
82
+ }
83
+
84
+ export interface PreviewConnectionCounters {
85
+ handshakes: number
86
+ reconnects: number
87
+ staleReplies: number
88
+ staleLocalOps: number
89
+ tierChanges: number
90
+ page: CreditLoopStats
91
+ }
92
+
93
+ export interface PreviewConnectionDeps {
94
+ channel: PreviewControlChannel | null
95
+ createTransport?: (transport: "websocket" | "webmessage", options: PreviewTransportOptions) => PreviewTransport
96
+ now?: () => number
97
+ /** Document event hookup; defaults to the real `document`/`window`. */
98
+ listen?: (target: "document" | "window", type: string, handler: () => void) => () => void
99
+ isDocumentVisible?: () => boolean
100
+ }
101
+
102
+ function defaultListen(target: "document" | "window", type: string, handler: () => void): () => void {
103
+ const node =
104
+ target === "document"
105
+ ? typeof document === "undefined"
106
+ ? null
107
+ : document
108
+ : typeof window === "undefined"
109
+ ? null
110
+ : window
111
+ if (!node) return () => {}
112
+ node.addEventListener(type, handler)
113
+ return () => node.removeEventListener(type, handler)
114
+ }
115
+
116
+ function defaultIsVisible(): boolean {
117
+ return typeof document === "undefined" || document.visibilityState !== "hidden"
118
+ }
119
+
120
+ interface Applied {
121
+ epoch: number
122
+ tier: PreviewTier | null
123
+ running: boolean
124
+ }
125
+
126
+ const NOTHING_APPLIED: Applied = {epoch: -1, tier: null, running: false}
127
+
128
+ export class PreviewConnection {
129
+ private state: PreviewConnectionState
130
+ private detail: PreviewConnectionStateDetail = {}
131
+ private docGen = 0
132
+ private config: PreviewDocumentConfig | null = null
133
+ private transport: PreviewTransport | null = null
134
+ /** Bumped whenever the transport is dropped, so a stale async continuation can tell. */
135
+ private connectionGen = 0
136
+ private mountEpochCounter = 0
137
+ private latestEpoch = 0
138
+ private sink: PreviewSink | null = null
139
+ private desired: {epoch: number; state: PreviewDesiredState} | null = null
140
+ private applied: Applied = NOTHING_APPLIED
141
+ /** Epoch whose production the host halted on a non-recoverable error; not restarted until remount. */
142
+ private haltedEpoch = -1
143
+ private handshaking: Promise<boolean> | null = null
144
+ private opChain: Promise<void> = Promise.resolve()
145
+ private reconnectTimes: number[] = []
146
+ private paused = false
147
+ private readonly creditLoop: CreditLoop
148
+ private readonly createTransport: NonNullable<PreviewConnectionDeps["createTransport"]>
149
+ private readonly now: () => number
150
+ private readonly isVisible: () => boolean
151
+ private readonly unsubscribers: Array<() => void> = []
152
+ private counters = {handshakes: 0, reconnects: 0, staleReplies: 0, staleLocalOps: 0, tierChanges: 0}
153
+
154
+ constructor(private readonly deps: PreviewConnectionDeps) {
155
+ this.createTransport = deps.createTransport ?? createPreviewTransport
156
+ this.now = deps.now ?? (() => (typeof performance !== "undefined" ? performance.now() : Date.now()))
157
+ this.isVisible = deps.isDocumentVisible ?? defaultIsVisible
158
+ this.state = deps.channel ? "idle" : "unavailable"
159
+ this.creditLoop = new CreditLoop({
160
+ sendAck: (gen, seq) => this.transport?.sendText(ackMessage(gen, seq)),
161
+ onParseError: (reason) =>
162
+ previewTraceWarnLimited(`parse:${reason}`, "parse_reject", {reason, docGen: this.docGen}),
163
+ })
164
+ if (deps.channel) {
165
+ this.unsubscribers.push(deps.channel.onEvent((event) => this.handleHostEvent(event)))
166
+ const listen = deps.listen ?? defaultListen
167
+ this.unsubscribers.push(listen("window", "pagehide", () => this.close("pagehide")))
168
+ // A page restored from the back/forward cache is the same document with a dead transport.
169
+ this.unsubscribers.push(
170
+ listen("window", "pageshow", () => {
171
+ if (this.state !== "closed") return
172
+ this.setState("idle", {reason: "pageshow"})
173
+ this.sync()
174
+ }),
175
+ )
176
+ this.unsubscribers.push(listen("document", "visibilitychange", () => this.handleVisibility()))
177
+ }
178
+ }
179
+
180
+ get currentState(): PreviewConnectionState {
181
+ return this.state
182
+ }
183
+
184
+ get documentGeneration(): number {
185
+ return this.docGen
186
+ }
187
+
188
+ snapshot(): PreviewConnectionCounters {
189
+ return {...this.counters, page: this.creditLoop.snapshot()}
190
+ }
191
+
192
+ /** A fresh, strictly increasing epoch for a component mounting in this document. */
193
+ nextMountEpoch(): number {
194
+ this.mountEpochCounter += 1
195
+ return this.mountEpochCounter
196
+ }
197
+
198
+ /**
199
+ * Route frames to this component's renderer. The newest mount wins; an older sink detaching
200
+ * later cannot remove a newer one.
201
+ */
202
+ attach(sink: PreviewSink): () => void {
203
+ if (sink.mountEpoch < this.latestEpoch) {
204
+ this.counters.staleLocalOps += 1
205
+ return () => {}
206
+ }
207
+ this.latestEpoch = sink.mountEpoch
208
+ this.sink = sink
209
+ this.creditLoop.setDraw((frame) => sink.draw(frame))
210
+ sink.onState(this.state, {...this.detail, paused: this.paused})
211
+ return () => {
212
+ if (this.sink !== sink) return
213
+ this.sink = null
214
+ this.creditLoop.setDraw(null)
215
+ }
216
+ }
217
+
218
+ /** Declare what `mountEpoch` wants. Operations from a superseded epoch are ignored. */
219
+ update(mountEpoch: number, state: PreviewDesiredState): void {
220
+ if (mountEpoch < this.latestEpoch) {
221
+ this.counters.staleLocalOps += 1
222
+ previewTrace("stale_local_op", {mountEpoch, latestEpoch: this.latestEpoch})
223
+ return
224
+ }
225
+ this.latestEpoch = mountEpoch
226
+ this.desired = {epoch: mountEpoch, state}
227
+ this.sync()
228
+ }
229
+
230
+ reportRendererError(mountEpoch: number, message: string): void {
231
+ this.creditLoop.recordError(message)
232
+ if (this.state !== "open" || !this.config) return
233
+ void this.send({cmd: "rendererError", mountEpoch, message}).catch(() => undefined)
234
+ }
235
+
236
+ /** End of document. The lease is untouched; a new document will handshake again. */
237
+ close(reason: string): void {
238
+ if (this.state === "closed") return
239
+ previewTrace("connection_closed", {reason, docGen: this.docGen})
240
+ this.dropTransport()
241
+ this.setState("closed", {reason})
242
+ }
243
+
244
+ /** Test seam: detach from the channel and document. */
245
+ dispose(): void {
246
+ for (const unsubscribe of this.unsubscribers.splice(0)) unsubscribe()
247
+ this.dropTransport()
248
+ }
249
+
250
+ // ---------------------------------------------------------------------------
251
+
252
+ private sync(): void {
253
+ this.opChain = this.opChain
254
+ .then(() => this.syncOnce())
255
+ .catch((error: unknown) => {
256
+ previewTraceWarn("sync_failed", {error: error instanceof Error ? error.message : String(error)})
257
+ })
258
+ }
259
+
260
+ private wantsFrames(): boolean {
261
+ const desired = this.desired
262
+ return !!desired && desired.state.running && desired.state.tier !== null && desired.epoch !== this.haltedEpoch
263
+ }
264
+
265
+ private async syncOnce(): Promise<void> {
266
+ const desired = this.desired
267
+ if (!desired) return
268
+ if (this.state !== "open") {
269
+ // Hidden components never force a handshake; the next visible one will.
270
+ if (!this.wantsFrames()) return
271
+ if (!(await this.ensureOpen())) return
272
+ }
273
+ const gen = this.connectionGen
274
+ const {epoch, state} = desired
275
+ if (state.tier && !sameTier(state.tier, this.applied.tier)) {
276
+ const reply = await this.send({
277
+ cmd: "configure",
278
+ mountEpoch: epoch,
279
+ boxWidth: state.boxWidth,
280
+ boxHeight: state.boxHeight,
281
+ })
282
+ if (gen !== this.connectionGen || !this.accept(reply, "configure")) return
283
+ this.counters.tierChanges += 1
284
+ previewTrace("tier_sent", {
285
+ mountEpoch: epoch,
286
+ width: state.tier.width,
287
+ height: state.tier.height,
288
+ docGen: this.docGen,
289
+ })
290
+ this.applied = {...this.applied, tier: state.tier}
291
+ }
292
+ const running = this.wantsFrames()
293
+ if (running !== this.applied.running || (running && epoch !== this.applied.epoch)) {
294
+ const reply = await this.send({cmd: running ? "start" : "stop", mountEpoch: epoch})
295
+ if (gen !== this.connectionGen || !this.accept(reply, running ? "start" : "stop")) return
296
+ this.applied = {...this.applied, epoch, running}
297
+ }
298
+ }
299
+
300
+ /** False when the host dropped the op as stale; our identity is then out of date. */
301
+ private accept(reply: PreviewControlReply | null, cmd: string): boolean {
302
+ if (!reply) return false
303
+ if (!reply.stale) return true
304
+ this.counters.staleReplies += 1
305
+ previewTraceWarn("control_stale", {cmd, docGen: this.docGen})
306
+ // The host no longer recognises this document's credential: start over with a handshake.
307
+ this.dropTransport()
308
+ this.setState("idle", {reason: "stale_identity"})
309
+ this.scheduleReconnect("stale_identity")
310
+ return false
311
+ }
312
+
313
+ private async send(request: Omit<PreviewControlRequest, "docGen" | "token">): Promise<PreviewControlReply | null> {
314
+ const channel = this.deps.channel
315
+ if (!channel) return null
316
+ try {
317
+ const reply = await channel.request({...request, docGen: this.docGen, token: this.config?.token})
318
+ return (reply ?? {}) as PreviewControlReply
319
+ } catch (error) {
320
+ const code = error instanceof PreviewControlError ? error.code : "transport_failed"
321
+ previewTraceWarn("control_failed", {cmd: request.cmd, code, docGen: this.docGen})
322
+ this.sink?.onError(code)
323
+ return null
324
+ }
325
+ }
326
+
327
+ private ensureOpen(): Promise<boolean> {
328
+ if (this.state === "open") return Promise.resolve(true)
329
+ if (this.state === "unsupported" || this.state === "unavailable" || this.state === "closed") {
330
+ return Promise.resolve(false)
331
+ }
332
+ if (!this.handshaking) {
333
+ this.handshaking = this.handshake().finally(() => {
334
+ this.handshaking = null
335
+ })
336
+ }
337
+ return this.handshaking
338
+ }
339
+
340
+ private async handshake(staleRetries = STALE_HANDSHAKE_RETRIES): Promise<boolean> {
341
+ const channel = this.deps.channel
342
+ if (!channel) return false
343
+ const gen = this.connectionGen
344
+ const mountEpoch = this.desired?.epoch ?? 0
345
+ this.setState("handshaking")
346
+ previewTrace("handshake_attempt", {docGen: this.docGen, mountEpoch})
347
+ let result: PreviewHandshakeResult
348
+ try {
349
+ result = (await channel.request({cmd: "handshake", docGen: this.docGen, mountEpoch})) as PreviewHandshakeResult
350
+ } catch (error) {
351
+ const code = error instanceof PreviewControlError ? error.code : "transport_failed"
352
+ const message = error instanceof Error ? error.message.slice(0, 120) : undefined
353
+ previewTraceWarn("handshake_failed", {code, message, docGen: this.docGen})
354
+ // A bind that is not ready yet is reported as unsupported. That used to
355
+ // stick for the whole document. Treat it like a transport error and try again.
356
+ this.setState("error", {code})
357
+ this.sink?.onError(code)
358
+ if (this.isVisible() && this.wantsFrames()) this.scheduleReconnect(code)
359
+ return false
360
+ }
361
+ if (gen !== this.connectionGen) return false
362
+ if (!result || typeof result !== "object") {
363
+ this.setState("error", {code: "transport_failed"})
364
+ return false
365
+ }
366
+ if ("stale" in result) {
367
+ // The docGen this page claimed (or the one minted for it mid-handshake) was superseded,
368
+ // e.g. by the `ready` that follows `handshake_without_ready`. Ask again as a page that does
369
+ // not know its document yet; the host answers with its current one.
370
+ this.counters.staleReplies += 1
371
+ previewTraceWarn("handshake_stale", {docGen: this.docGen, staleRetries})
372
+ this.docGen = 0
373
+ if (staleRetries > 0) return this.handshake(staleRetries - 1)
374
+ this.setState("error", {reason: "stale_identity"})
375
+ return false
376
+ }
377
+ if (result.t === "waiting_for_lease") {
378
+ if (typeof result.docGen === "number") this.docGen = result.docGen
379
+ previewTrace("waiting_for_lease", {docGen: this.docGen})
380
+ this.setState("waiting_for_lease")
381
+ return false
382
+ }
383
+ return this.openTransport(result)
384
+ }
385
+
386
+ private async openTransport(config: PreviewDocumentConfig): Promise<boolean> {
387
+ if (config.protocolVersion !== PREVIEW_PROTOCOL_VERSION) {
388
+ previewTraceWarn("handshake_rejected", {reason: "unsupported_version", protocolVersion: config.protocolVersion})
389
+ this.setState("unsupported", {code: "unsupported"})
390
+ this.sink?.onError("unsupported")
391
+ return false
392
+ }
393
+ this.docGen = config.docGen
394
+ this.config = config
395
+ this.counters.handshakes += 1
396
+ this.setState("connecting")
397
+ const gen = ++this.connectionGen
398
+ let transport: PreviewTransport
399
+ try {
400
+ transport = this.createTransport(config.transport, {
401
+ url: config.url,
402
+ portName: config.portName,
403
+ token: config.token,
404
+ })
405
+ transport.onBinary((buffer) => this.creditLoop.handleBinary(buffer))
406
+ transport.onError((reason) => this.handleTransportError(gen, reason))
407
+ await transport.connect()
408
+ } catch (error) {
409
+ const reason = (error as {reason?: string}).reason ?? "connect_failed"
410
+ previewTraceWarn("transport_failed", {reason, docGen: this.docGen, transport: config.transport})
411
+ if (gen === this.connectionGen) {
412
+ this.config = null
413
+ this.setState("error", {code: "transport_failed", reason})
414
+ this.sink?.onError("transport_failed")
415
+ }
416
+ return false
417
+ }
418
+ if (gen !== this.connectionGen) {
419
+ transport.close()
420
+ return false
421
+ }
422
+ this.transport = transport
423
+ this.applied = NOTHING_APPLIED
424
+ previewTrace("handshake_ok", {
425
+ docGen: this.docGen,
426
+ transport: config.transport,
427
+ handshakes: this.counters.handshakes,
428
+ })
429
+ this.setState("open")
430
+ return true
431
+ }
432
+
433
+ private handleTransportError(gen: number, reason: string): void {
434
+ if (gen !== this.connectionGen) return
435
+ previewTraceWarn("transport_closed", {reason, docGen: this.docGen})
436
+ this.dropTransport()
437
+ this.setState("error", {code: "transport_failed", reason})
438
+ this.sink?.onError("transport_failed")
439
+ // A socket the OS tore down while suspended is re-established on foreground instead.
440
+ if (this.isVisible()) this.scheduleReconnect(reason)
441
+ }
442
+
443
+ private handleHostEvent(event: PreviewHostEvent): void {
444
+ switch (event.t) {
445
+ case "lease_available":
446
+ previewTrace("lease_available", {docGen: this.docGen, state: this.state})
447
+ if (
448
+ this.state === "waiting_for_lease" ||
449
+ this.state === "idle" ||
450
+ this.state === "error" ||
451
+ this.state === "unsupported"
452
+ ) {
453
+ this.setState("idle")
454
+ // Hidden stays connected-but-stopped, so handshake as soon as anyone has mounted.
455
+ if (this.desired) void this.ensureOpen().then((open) => open && this.sync())
456
+ }
457
+ return
458
+ case "lease_ended":
459
+ previewTrace("lease_ended", {reason: event.reason, docGen: this.docGen})
460
+ this.dropTransport()
461
+ this.setState("waiting_for_lease", {reason: event.reason})
462
+ if (event.reason === "source_ended") this.sink?.onError("source_ended")
463
+ return
464
+ case "error":
465
+ if (typeof event.docGen === "number" && event.docGen !== this.docGen) return
466
+ this.handleHostError(event.code)
467
+ return
468
+ case "status":
469
+ this.sink?.onStatus({...event, page: this.snapshot()})
470
+ return
471
+ case "paused_background":
472
+ this.paused = true
473
+ this.sink?.onState(this.state, {...this.detail, paused: true})
474
+ return
475
+ case "resumed":
476
+ this.paused = false
477
+ this.sink?.onState(this.state, {...this.detail, paused: false})
478
+ return
479
+ case "config":
480
+ if (event.docGen === this.docGen && this.state !== "open" && this.state !== "connecting") {
481
+ void this.openTransport(event).then((open) => open && this.sync())
482
+ }
483
+ return
484
+ }
485
+ }
486
+
487
+ private handleHostError(code: string): void {
488
+ previewTraceWarn("host_error", {code, docGen: this.docGen})
489
+ this.sink?.onError(code)
490
+ if (code === "transport_failed" || code === "ack_timeout") {
491
+ this.dropTransport()
492
+ this.setState("error", {code})
493
+ this.scheduleReconnect(code)
494
+ return
495
+ }
496
+ // The host halted production (pack_failed, diagnostics_disabled, ...). Restarting would only
497
+ // hit the same failure; a new mount epoch may try again.
498
+ this.applied = {...this.applied, running: false}
499
+ this.haltedEpoch = this.desired?.epoch ?? this.haltedEpoch
500
+ }
501
+
502
+ private handleVisibility(): void {
503
+ const visible = this.isVisible()
504
+ previewTrace("visibility", {visible, state: this.state, docGen: this.docGen})
505
+ if (visible && (this.state === "error" || this.state === "idle")) this.sync()
506
+ }
507
+
508
+ private scheduleReconnect(reason: string): void {
509
+ const at = this.now()
510
+ this.reconnectTimes = this.reconnectTimes.filter((t) => at - t < RECONNECT_WINDOW_MS)
511
+ if (this.reconnectTimes.length >= RECONNECT_BUDGET) {
512
+ previewTraceWarn("reconnect_budget_exhausted", {reason, docGen: this.docGen})
513
+ return
514
+ }
515
+ this.reconnectTimes.push(at)
516
+ this.counters.reconnects += 1
517
+ previewTrace("reconnect", {reason, docGen: this.docGen, reconnects: this.counters.reconnects})
518
+ this.sync()
519
+ }
520
+
521
+ private dropTransport(): void {
522
+ this.connectionGen += 1
523
+ const transport = this.transport
524
+ this.transport = null
525
+ this.config = null
526
+ this.applied = NOTHING_APPLIED
527
+ if (transport) {
528
+ transport.close()
529
+ previewTrace("transport_close", {docGen: this.docGen})
530
+ }
531
+ }
532
+
533
+ private setState(state: PreviewConnectionState, detail: PreviewConnectionStateDetail = {}): void {
534
+ this.state = state
535
+ this.detail = detail
536
+ this.sink?.onState(state, {...detail, paused: this.paused})
537
+ }
538
+ }
539
+
540
+ let shared: PreviewConnection | null = null
541
+
542
+ /** The connection for this document, created on first use. */
543
+ export function getPreviewConnection(): PreviewConnection {
544
+ if (!shared) shared = new PreviewConnection({channel: createMentraControlChannel()})
545
+ return shared
546
+ }
547
+
548
+ /** Test seam: replace (or clear) the document singleton, as a reload would. */
549
+ export function setPreviewConnectionForTests(next: PreviewConnection | null): void {
550
+ shared?.dispose()
551
+ shared = next
552
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The reserved `_preview` UI channel, as seen from the page.
3
+ *
4
+ * The Mentra App answers this channel itself; it never reaches the miniapp's background. Requests
5
+ * ride `mentra.request`, and host events arrive on `mentra.on("_preview")`. Every control request
6
+ * carries the page identity (`docGen`, `token`, `mountEpoch`) so the host can drop operations
7
+ * from a document or a component that has since been replaced.
8
+ */
9
+
10
+ import {PREVIEW_UI_CHANNEL} from "../../protocol"
11
+
12
+ /** `injectFault` is honoured only in debug builds or with the host's hidden developer setting. */
13
+ export type PreviewCommand = "handshake" | "configure" | "start" | "stop" | "rendererError" | "injectFault"
14
+
15
+ export interface PreviewControlRequest {
16
+ cmd: PreviewCommand
17
+ /** 0 until the first handshake has told the page which document it is. */
18
+ docGen: number
19
+ token?: string
20
+ mountEpoch: number
21
+ /** `configure` only: rendered box in device pixels, after rotation. */
22
+ boxWidth?: number
23
+ boxHeight?: number
24
+ /** `rendererError` only. */
25
+ message?: string
26
+ /** `injectFault` only: `ack_delay`, `ack_drop`, `transport_close`, `pack_throw`, `sink_throw`, `clear`. */
27
+ kind?: string
28
+ ms?: number
29
+ }
30
+
31
+ export interface PreviewDocumentConfig {
32
+ t: "config"
33
+ protocolVersion: number
34
+ transport: "webmessage" | "websocket"
35
+ url?: string
36
+ portName?: string
37
+ token: string
38
+ docGen: number
39
+ }
40
+
41
+ /** `stale`: the host minted a newer document while this handshake was in flight. */
42
+ export type PreviewHandshakeResult =
43
+ | PreviewDocumentConfig
44
+ | {t: "waiting_for_lease"; docGen: number}
45
+ | {t?: undefined; stale: true}
46
+
47
+ /** Reply to configure/start/stop. `stale` means the host dropped it; that is not an error. */
48
+ export interface PreviewControlReply {
49
+ applied?: boolean
50
+ stale?: boolean
51
+ }
52
+
53
+ export type PreviewHostEvent =
54
+ | PreviewDocumentConfig
55
+ | ({t: "status"} & Record<string, unknown>)
56
+ | {t: "lease_available"}
57
+ | {t: "lease_ended"; reason: string}
58
+ | {t: "error"; code: string; docGen?: number}
59
+ | {t: "paused_background"}
60
+ | {t: "resumed"}
61
+
62
+ /** A `_preview` request the host refused. `code` is a preview error code. */
63
+ export class PreviewControlError extends Error {
64
+ readonly code: string
65
+
66
+ constructor(code: string, message?: string) {
67
+ super(message ?? code)
68
+ this.name = "PreviewControlError"
69
+ this.code = code
70
+ }
71
+ }
72
+
73
+ export interface PreviewControlChannel {
74
+ request(request: PreviewControlRequest): Promise<unknown>
75
+ onEvent(cb: (event: PreviewHostEvent) => void): () => void
76
+ }
77
+
78
+ interface MentraLike {
79
+ request(channel: string, payload: unknown, options?: {timeout?: number}): Promise<unknown>
80
+ on(channel: string, cb: (payload: unknown) => void): () => void
81
+ }
82
+
83
+ /** Host replies are local and fast; this only bounds a host that never answers. */
84
+ const REQUEST_TIMEOUT_MS = 10_000
85
+
86
+ function mentraGlobal(): MentraLike | null {
87
+ const candidate = (globalThis as unknown as {mentra?: Partial<MentraLike>}).mentra
88
+ if (!candidate || typeof candidate.request !== "function" || typeof candidate.on !== "function") return null
89
+ return candidate as MentraLike
90
+ }
91
+
92
+ /** The channel over the injected `window.mentra` bridge. Null outside a Mentra App WebView. */
93
+ export function createMentraControlChannel(): PreviewControlChannel | null {
94
+ const mentra = mentraGlobal()
95
+ if (!mentra) return null
96
+ return {
97
+ async request(request) {
98
+ try {
99
+ return await mentra.request(PREVIEW_UI_CHANNEL, request, {timeout: REQUEST_TIMEOUT_MS})
100
+ } catch (error) {
101
+ const value = (error ?? {}) as {code?: unknown; name?: unknown; message?: unknown}
102
+ const code =
103
+ typeof value.code === "string"
104
+ ? value.code
105
+ : value.name === "MentraRpcTimeoutError"
106
+ ? "transport_failed"
107
+ : "unsupported"
108
+ throw new PreviewControlError(code, typeof value.message === "string" ? value.message : undefined)
109
+ }
110
+ },
111
+ onEvent(cb) {
112
+ return mentra.on(PREVIEW_UI_CHANNEL, (payload) => {
113
+ if (payload && typeof payload === "object" && typeof (payload as {t?: unknown}).t === "string") {
114
+ cb(payload as PreviewHostEvent)
115
+ }
116
+ })
117
+ },
118
+ }
119
+ }