@mentra/miniapp 3.2.1-beta.315 → 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.
- package/README.md +16 -0
- package/dist/background/index.d.ts +5 -3
- package/dist/background/index.d.ts.map +1 -1
- package/dist/background/index.js +1 -0
- package/dist/background/index.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/display.d.ts +33 -0
- package/dist/modules/display.d.ts.map +1 -1
- package/dist/modules/display.js +2 -0
- package/dist/modules/display.js.map +1 -1
- package/dist/modules/meeting.d.ts +66 -6
- package/dist/modules/meeting.d.ts.map +1 -1
- package/dist/modules/meeting.js +72 -19
- package/dist/modules/meeting.js.map +1 -1
- package/dist/modules/stream.d.ts +46 -0
- package/dist/modules/stream.d.ts.map +1 -1
- package/dist/modules/stream.js +107 -0
- package/dist/modules/stream.js.map +1 -1
- package/dist/protocol.d.ts +39 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +36 -0
- package/dist/protocol.js.map +1 -1
- package/dist/react/StreamPreview.d.ts +41 -0
- package/dist/react/StreamPreview.d.ts.map +1 -0
- package/dist/react/StreamPreview.js +144 -0
- package/dist/react/StreamPreview.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/stream-preview/PreviewConnection.d.ts +139 -0
- package/dist/react/stream-preview/PreviewConnection.d.ts.map +1 -0
- package/dist/react/stream-preview/PreviewConnection.js +462 -0
- package/dist/react/stream-preview/PreviewConnection.js.map +1 -0
- package/dist/react/stream-preview/controlChannel.d.ts +75 -0
- package/dist/react/stream-preview/controlChannel.d.ts.map +1 -0
- package/dist/react/stream-preview/controlChannel.js +55 -0
- package/dist/react/stream-preview/controlChannel.js.map +1 -0
- package/dist/react/stream-preview/creditLoop.d.ts +81 -0
- package/dist/react/stream-preview/creditLoop.d.ts.map +1 -0
- package/dist/react/stream-preview/creditLoop.js +146 -0
- package/dist/react/stream-preview/creditLoop.js.map +1 -0
- package/dist/react/stream-preview/protocol.d.ts +113 -0
- package/dist/react/stream-preview/protocol.d.ts.map +1 -0
- package/dist/react/stream-preview/protocol.js +148 -0
- package/dist/react/stream-preview/protocol.js.map +1 -0
- package/dist/react/stream-preview/resizeCoalescer.d.ts +38 -0
- package/dist/react/stream-preview/resizeCoalescer.d.ts.map +1 -0
- package/dist/react/stream-preview/resizeCoalescer.js +71 -0
- package/dist/react/stream-preview/resizeCoalescer.js.map +1 -0
- package/dist/react/stream-preview/testFrames.d.ts +38 -0
- package/dist/react/stream-preview/testFrames.d.ts.map +1 -0
- package/dist/react/stream-preview/testFrames.js +50 -0
- package/dist/react/stream-preview/testFrames.js.map +1 -0
- package/dist/react/stream-preview/tiers.d.ts +21 -0
- package/dist/react/stream-preview/tiers.d.ts.map +1 -0
- package/dist/react/stream-preview/tiers.js +33 -0
- package/dist/react/stream-preview/tiers.js.map +1 -0
- package/dist/react/stream-preview/trace.d.ts +23 -0
- package/dist/react/stream-preview/trace.d.ts.map +1 -0
- package/dist/react/stream-preview/trace.js +71 -0
- package/dist/react/stream-preview/trace.js.map +1 -0
- package/dist/react/stream-preview/transport.d.ts +79 -0
- package/dist/react/stream-preview/transport.d.ts.map +1 -0
- package/dist/react/stream-preview/transport.js +209 -0
- package/dist/react/stream-preview/transport.js.map +1 -0
- package/dist/react/stream-preview/yuvRenderer.d.ts +31 -0
- package/dist/react/stream-preview/yuvRenderer.d.ts.map +1 -0
- package/dist/react/stream-preview/yuvRenderer.js +398 -0
- package/dist/react/stream-preview/yuvRenderer.js.map +1 -0
- package/dist/session.d.ts +5 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +5 -0
- package/dist/session.js.map +1 -1
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/index.js.map +1 -1
- package/package.json +8 -3
- package/src/background/index.ts +14 -1
- package/src/index.ts +15 -0
- package/src/modules/display.ts +37 -0
- package/src/modules/meeting.ts +133 -23
- package/src/modules/stream.ts +153 -1
- package/src/protocol.ts +75 -0
- package/src/react/StreamPreview.tsx +187 -0
- package/src/react/index.ts +2 -0
- package/src/react/stream-preview/PreviewConnection.ts +552 -0
- package/src/react/stream-preview/controlChannel.ts +119 -0
- package/src/react/stream-preview/creditLoop.ts +189 -0
- package/src/react/stream-preview/protocol.ts +227 -0
- package/src/react/stream-preview/resizeCoalescer.ts +88 -0
- package/src/react/stream-preview/testFrames.ts +86 -0
- package/src/react/stream-preview/tiers.ts +37 -0
- package/src/react/stream-preview/trace.ts +81 -0
- package/src/react/stream-preview/transport.ts +242 -0
- package/src/react/stream-preview/yuvRenderer.ts +453 -0
- package/src/session.ts +8 -0
- 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
|
+
}
|