@mentra/jspolyfill 0.1.0-dev.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.
package/src/startup.ts ADDED
@@ -0,0 +1,1062 @@
1
+ /**
2
+ * MentraJS polyfill bundle entry point.
3
+ *
4
+ * This file gets compiled (via scripts/build.mjs) into a single IIFE that
5
+ * is `evaluateScript`-ed inside every per-miniapp JSContext at spawn
6
+ * time, BEFORE the miniapp's own `background/index.js`. By the time the
7
+ * miniapp runs, `globalThis` looks like a Web Worker — console, fetch,
8
+ * WebSocket, setTimeout, localStorage, crypto, all installed.
9
+ *
10
+ * Native (Swift on iOS, Kotlin on Android) has already injected the
11
+ * following before this file runs:
12
+ * - __dispatch(iface, method, argsJson) — synchronous on iOS,
13
+ * suspending under the hood on Android. Always JSON-only across the
14
+ * bridge to keep the surface platform-symmetric.
15
+ * - __hostLog(level, messageJson) — console.* sink.
16
+ * - __hostError(payloadJson) — window.onerror sink.
17
+ * - __hostUnhandledRejection(payloadJson) — Promise rejection sink.
18
+ * - __nativeSetTimeout(callbackToken, delayMs) — schedules a fire-once
19
+ * timer; native calls __deliverTimer(token) when it elapses.
20
+ * - __nativeClearTimer(token) — cancels.
21
+ *
22
+ * On Android Zipline pre-injects `console.{log,info,warn,error}` and
23
+ * `setTimeout` / `clearTimeout`. We guard those installs with
24
+ * `if (!globalThis.X)` so we don't clobber the pre-injected versions.
25
+ *
26
+ * NO imports from other files in this package — the bundler inlines
27
+ * what's needed. This file is the only entry; types live in types.ts
28
+ * but are stripped at build time.
29
+ */
30
+
31
+ declare const __dispatch: (iface: string, method: string, argsJson: string) => string | null
32
+ declare const __hostLog: (level: string, messageJson: string) => void
33
+ declare const __hostError: (payloadJson: string) => void
34
+ declare const __hostUnhandledRejection: (payloadJson: string) => void
35
+ declare const __nativeSetTimeout: (token: number, delayMs: number) => void
36
+ declare const __nativeClearTimer: (token: number) => void
37
+
38
+ ;(function installMentraJSRuntime(): void {
39
+ const g = globalThis as Record<string, unknown> & {
40
+ console?: Console
41
+ setTimeout?: typeof setTimeout
42
+ clearTimeout?: typeof clearTimeout
43
+ setInterval?: typeof setInterval
44
+ clearInterval?: typeof clearInterval
45
+ queueMicrotask?: typeof queueMicrotask
46
+ Promise: PromiseConstructor
47
+ }
48
+
49
+ // ---------- console -------------------------------------------------------
50
+ // Zipline pre-injects console.{log,info,warn,error}. On iOS-JSC nothing is
51
+ // pre-injected. Always rewire through __hostLog so dev / Sentry see the
52
+ // logs; preserve the pre-injected console.log behaviour underneath when
53
+ // Zipline ships one (so the same string still surfaces to logcat).
54
+ function installConsole(): void {
55
+ const safeStringify = (args: unknown[]): string => {
56
+ try {
57
+ return JSON.stringify(
58
+ args.map((a) => {
59
+ if (a instanceof Error) {
60
+ return {__error: true, name: a.name, message: a.message, stack: a.stack}
61
+ }
62
+ return a
63
+ }),
64
+ )
65
+ } catch {
66
+ // Cyclic? Fallback to toString. Worst case we lose structure but
67
+ // never crash the host.
68
+ return JSON.stringify(args.map((a) => String(a)))
69
+ }
70
+ }
71
+ const make = (level: string, prev?: (...a: unknown[]) => void) => {
72
+ return (...args: unknown[]) => {
73
+ try {
74
+ __hostLog(level, safeStringify(args))
75
+ } catch {
76
+ // host might not be ready; swallow
77
+ }
78
+ if (prev) {
79
+ try {
80
+ prev(...args)
81
+ } catch {
82
+ // ignore — native sink already received it
83
+ }
84
+ }
85
+ }
86
+ }
87
+ const prevConsole = g.console
88
+ const c = {
89
+ log: make("log", prevConsole?.log?.bind(prevConsole)),
90
+ info: make("info", prevConsole?.info?.bind(prevConsole)),
91
+ warn: make("warn", prevConsole?.warn?.bind(prevConsole)),
92
+ error: make("error", prevConsole?.error?.bind(prevConsole)),
93
+ debug: make("debug", prevConsole?.debug?.bind(prevConsole)),
94
+ trace: make("trace", prevConsole?.trace?.bind(prevConsole)),
95
+ } as unknown as Console
96
+ g.console = c
97
+ }
98
+ installConsole()
99
+
100
+ // ---------- error / rejection rewiring ------------------------------------
101
+ // window.onerror is a DOM concept. JSC / QuickJS still let us install a
102
+ // global onerror property; we also wire process.on('unhandledRejection')
103
+ // style by listening on Promise.reject through a microtask trampoline.
104
+ ;(g as unknown as {onerror?: (msg: string, src?: string, line?: number, col?: number, err?: Error) => void}).onerror =
105
+ (msg, src, line, col, err) => {
106
+ try {
107
+ __hostError(
108
+ JSON.stringify({
109
+ message: String(msg),
110
+ src: src ?? "",
111
+ line: line ?? 0,
112
+ col: col ?? 0,
113
+ stack: err && err.stack ? err.stack : "",
114
+ }),
115
+ )
116
+ } catch {
117
+ // host not ready
118
+ }
119
+ return false
120
+ }
121
+ // Promise.reject hook: replace Promise to capture unhandled rejections.
122
+ // QuickJS and JSC both fire host promise rejection tracking callbacks at
123
+ // the engine level but those don't reach JS; we approximate via a
124
+ // microtask-trampolined hook on the prototype.
125
+ try {
126
+ const origThen = g.Promise.prototype.then
127
+ const seen = new WeakSet<Promise<unknown>>()
128
+ g.Promise.prototype.then = function patchedThen(
129
+ this: Promise<unknown>,
130
+ onFulfilled?: ((v: unknown) => unknown) | null,
131
+ onRejected?: ((r: unknown) => unknown) | null,
132
+ ) {
133
+ seen.add(this)
134
+ return origThen.call(this, onFulfilled, onRejected)
135
+ } as PromiseConstructor["prototype"]["then"]
136
+ // Promises whose `then`/`catch` is never called and which reject get
137
+ // collected. Without engine hooks we can only catch ones that the
138
+ // miniapp explicitly logs; rely on engine-level callbacks for real
139
+ // coverage. We at least expose a helper miniapps can use:
140
+ ;(g as Record<string, unknown>).__reportUnhandledRejection = (reason: unknown) => {
141
+ try {
142
+ __hostUnhandledRejection(
143
+ JSON.stringify({
144
+ reason: reason instanceof Error ? {message: reason.message, stack: reason.stack} : reason,
145
+ }),
146
+ )
147
+ } catch {
148
+ // host not ready
149
+ }
150
+ }
151
+ } catch {
152
+ // ignore — Promise prototype frozen in some weird builds
153
+ }
154
+
155
+ // ---------- timers --------------------------------------------------------
156
+ // Native owns the scheduler — we install thin JS wrappers that track the
157
+ // callback and arguments by an opaque integer token. Native calls back via
158
+ // globalThis.__deliverTimer(token) when the timer fires.
159
+ // Zipline pre-injects setTimeout/clearTimeout on Android, so we guard.
160
+ function installTimers(): void {
161
+ const callbacks = new Map<number, {fn: () => void; repeating: boolean; interval: number}>()
162
+ let nextToken = 1
163
+ ;(g as Record<string, unknown>).__deliverTimer = (token: number) => {
164
+ const entry = callbacks.get(token)
165
+ if (!entry) return
166
+ try {
167
+ entry.fn()
168
+ } catch (e) {
169
+ try {
170
+ __hostError(
171
+ JSON.stringify({
172
+ message: e instanceof Error ? e.message : String(e),
173
+ stack: e instanceof Error ? e.stack : undefined,
174
+ source: "timer",
175
+ token,
176
+ }),
177
+ )
178
+ } catch {
179
+ /* ignore */
180
+ }
181
+ }
182
+ if (entry.repeating) {
183
+ // Re-schedule another tick. Native is the source of truth for
184
+ // wall-clock; we just bounce the request back.
185
+ try {
186
+ __nativeSetTimeout(token, entry.interval)
187
+ } catch {
188
+ callbacks.delete(token)
189
+ }
190
+ } else {
191
+ callbacks.delete(token)
192
+ }
193
+ }
194
+
195
+ const installSetTimeout = (g.setTimeout == null) || (g as Record<string, unknown>).__mentraOwnsSetTimeout
196
+ if (installSetTimeout) {
197
+ g.setTimeout = ((fn: (...args: unknown[]) => void, delayMs?: number, ...rest: unknown[]) => {
198
+ const token = nextToken++
199
+ const ms = typeof delayMs === "number" ? Math.max(0, delayMs) : 0
200
+ callbacks.set(token, {
201
+ fn: () => fn(...rest),
202
+ repeating: false,
203
+ interval: ms,
204
+ })
205
+ try {
206
+ __nativeSetTimeout(token, ms)
207
+ } catch {
208
+ callbacks.delete(token)
209
+ return -1 as unknown as ReturnType<typeof setTimeout>
210
+ }
211
+ return token as unknown as ReturnType<typeof setTimeout>
212
+ }) as typeof setTimeout
213
+ g.clearTimeout = ((token?: number) => {
214
+ if (typeof token !== "number") return
215
+ callbacks.delete(token)
216
+ try {
217
+ __nativeClearTimer(token)
218
+ } catch {
219
+ /* native may already have evicted */
220
+ }
221
+ }) as typeof clearTimeout
222
+ ;(g as Record<string, unknown>).__mentraOwnsSetTimeout = true
223
+ }
224
+
225
+ g.setInterval = ((fn: (...args: unknown[]) => void, delayMs?: number, ...rest: unknown[]) => {
226
+ const token = nextToken++
227
+ const ms = typeof delayMs === "number" ? Math.max(0, delayMs) : 0
228
+ callbacks.set(token, {
229
+ fn: () => fn(...rest),
230
+ repeating: true,
231
+ interval: ms,
232
+ })
233
+ try {
234
+ __nativeSetTimeout(token, ms)
235
+ } catch {
236
+ callbacks.delete(token)
237
+ return -1 as unknown as ReturnType<typeof setInterval>
238
+ }
239
+ return token as unknown as ReturnType<typeof setInterval>
240
+ }) as typeof setInterval
241
+
242
+ g.clearInterval = ((token?: number) => {
243
+ if (typeof token !== "number") return
244
+ callbacks.delete(token)
245
+ try {
246
+ __nativeClearTimer(token)
247
+ } catch {
248
+ /* native may already have evicted */
249
+ }
250
+ }) as typeof clearInterval
251
+
252
+ if (typeof g.queueMicrotask !== "function") {
253
+ // Promise.resolve().then(fn) is the universal microtask path; both JSC
254
+ // and QuickJS schedule the .then callback on the microtask queue.
255
+ g.queueMicrotask = ((cb: () => void) => {
256
+ g.Promise.resolve().then(() => {
257
+ try {
258
+ cb()
259
+ } catch (e) {
260
+ try {
261
+ __hostError(
262
+ JSON.stringify({
263
+ message: e instanceof Error ? e.message : String(e),
264
+ stack: e instanceof Error ? e.stack : undefined,
265
+ source: "queueMicrotask",
266
+ }),
267
+ )
268
+ } catch {
269
+ /* ignore */
270
+ }
271
+ }
272
+ })
273
+ }) as typeof queueMicrotask
274
+ }
275
+ }
276
+ installTimers()
277
+
278
+ // ---------- AbortController / AbortSignal --------------------------------
279
+ // JSC + QuickJS don't ship the DOM AbortController. The miniapp SDK
280
+ // uses it for RPC cancellation (`session.ui.handle`'s ctx.signal and
281
+ // `mentra.request`'s options.signal). We install a minimal polyfill
282
+ // covering the surface that ships in the SDK:
283
+ // - new AbortController()
284
+ // - ctrl.signal, ctrl.abort(reason?)
285
+ // - signal.aborted, signal.reason
286
+ // - signal.addEventListener("abort", cb) / removeEventListener
287
+ // - AbortSignal.any([...])
288
+ // No EventTarget inheritance — the SDK only listens for "abort" so we
289
+ // keep the impl simple (a `Set<cb>` per signal).
290
+ function installAbortController(): void {
291
+ if (typeof (g as Record<string, unknown>).AbortController === "function") return
292
+
293
+ type Listener = () => void
294
+ class MentraAbortSignal {
295
+ aborted = false
296
+ reason: unknown = undefined
297
+ private listeners: Set<Listener> = new Set()
298
+ addEventListener(type: string, cb: Listener): void {
299
+ if (type !== "abort" || typeof cb !== "function") return
300
+ if (this.aborted) {
301
+ try { cb() } catch { /* swallow */ }
302
+ return
303
+ }
304
+ this.listeners.add(cb)
305
+ }
306
+ removeEventListener(type: string, cb: Listener): void {
307
+ if (type !== "abort") return
308
+ this.listeners.delete(cb)
309
+ }
310
+ /** @internal — only invoked by the owning AbortController.abort(). */
311
+ __fire(reason: unknown): void {
312
+ if (this.aborted) return
313
+ this.aborted = true
314
+ this.reason = reason
315
+ for (const cb of this.listeners) {
316
+ try { cb() } catch { /* swallow */ }
317
+ }
318
+ this.listeners.clear()
319
+ }
320
+ }
321
+ class MentraAbortController {
322
+ readonly signal: MentraAbortSignal = new MentraAbortSignal()
323
+ abort(reason?: unknown): void {
324
+ this.signal.__fire(reason ?? new Error("aborted"))
325
+ }
326
+ }
327
+ // `AbortSignal.any([signals])` — composes a single signal that aborts
328
+ // when any input does. Used by useRpc's mergeSignals fallback.
329
+ ;(MentraAbortSignal as unknown as {any: (signals: MentraAbortSignal[]) => MentraAbortSignal}).any =
330
+ (signals: MentraAbortSignal[]): MentraAbortSignal => {
331
+ const out = new MentraAbortSignal()
332
+ const onAbort = (s: MentraAbortSignal): void => {
333
+ if (!out.aborted) out.__fire(s.reason)
334
+ }
335
+ for (const s of signals) {
336
+ if (s.aborted) {
337
+ onAbort(s)
338
+ break
339
+ }
340
+ s.addEventListener("abort", () => onAbort(s))
341
+ }
342
+ return out
343
+ }
344
+ ;(g as Record<string, unknown>).AbortController = MentraAbortController
345
+ ;(g as Record<string, unknown>).AbortSignal = MentraAbortSignal
346
+ }
347
+ installAbortController()
348
+
349
+ // ---------- dispatch / deliver -------------------------------------------
350
+ // Request/response correlator. SDK code calls into __dispatch through a
351
+ // thin helper that returns a Promise; the host posts back via __deliver.
352
+ const pending = new Map<string, {resolve: (v: unknown) => void; reject: (e: unknown) => void}>()
353
+ let nextReqId = 1
354
+
355
+ ;(g as Record<string, unknown>).__deliver = (envelopeJson: string) => {
356
+ let env: {
357
+ kind?: string
358
+ sessionId?: string
359
+ reqId?: string
360
+ iface?: string
361
+ payload?: unknown
362
+ result?: unknown
363
+ ok?: boolean
364
+ error?: {code: string; message?: string; details?: Record<string, unknown>}
365
+ }
366
+ try {
367
+ env = JSON.parse(envelopeJson)
368
+ } catch {
369
+ try {
370
+ __hostError(JSON.stringify({message: "Bad __deliver envelope JSON", source: "__deliver"}))
371
+ } catch {
372
+ /* ignore */
373
+ }
374
+ return
375
+ }
376
+ if (env.kind === "response" && env.reqId) {
377
+ const handlers = pending.get(env.reqId)
378
+ if (handlers) {
379
+ pending.delete(env.reqId)
380
+ if (env.ok) {
381
+ handlers.resolve(env.result)
382
+ } else {
383
+ handlers.reject(env.error ?? {code: "NATIVE_THROW", message: "Unknown native error"})
384
+ }
385
+ }
386
+ return
387
+ }
388
+ if (env.kind === "event" && typeof env.iface === "string") {
389
+ // Fan-out is handled by the SDK side. We just publish on a
390
+ // well-known global the SDK listens to.
391
+ const listeners = ((g as Record<string, unknown>).__mentraEventListeners ?? new Map()) as Map<
392
+ string,
393
+ Set<(p: unknown) => void>
394
+ >
395
+ const set = listeners.get(env.iface)
396
+ if (set) {
397
+ for (const l of set) {
398
+ try {
399
+ l(env.payload)
400
+ } catch (e) {
401
+ try {
402
+ __hostError(
403
+ JSON.stringify({
404
+ message: e instanceof Error ? e.message : String(e),
405
+ stack: e instanceof Error ? e.stack : undefined,
406
+ source: `event:${env.iface}`,
407
+ }),
408
+ )
409
+ } catch {
410
+ /* ignore */
411
+ }
412
+ }
413
+ }
414
+ }
415
+ return
416
+ }
417
+ if (env.kind === "ws-event") {
418
+ const wsHook = (g as Record<string, unknown>).__mentraDeliverWebSocketEvent as
419
+ | ((sid: string, type: string, payload: Record<string, unknown>) => void)
420
+ | undefined
421
+ const sid = (env as {sid?: string}).sid
422
+ const wsType = (env as {wsType?: string}).wsType
423
+ if (typeof wsHook === "function" && typeof sid === "string" && typeof wsType === "string") {
424
+ wsHook(sid, wsType, (env as unknown as {payload?: Record<string, unknown>}).payload ?? {})
425
+ }
426
+ return
427
+ }
428
+ if (env.kind === "bridge" && typeof (env as {raw?: unknown}).raw === "string") {
429
+ // Host pushes a raw SDK envelope (DISPLAY / SUBSCRIBE /
430
+ // STATE_FOR_BRIDGE / etc.) to be delivered into DispatchTransport's
431
+ // onMessage handler. The transport installs this hook on open().
432
+ const deliver = (g as Record<string, unknown>).__mentraDeliverBridgeRaw as ((raw: string) => void) | undefined
433
+ if (typeof deliver === "function") {
434
+ deliver((env as {raw: string}).raw)
435
+ }
436
+ return
437
+ }
438
+ if (env.kind === "init" && typeof env.sessionId === "string") {
439
+ // Stamp the global; the SDK's session factory consumes this to build
440
+ // the typed MiniappSession.
441
+ ;(g as Record<string, unknown>).__mentraSessionId = env.sessionId
442
+ const initCb = (g as Record<string, unknown>).__mentraInitCallback as ((sid: string) => void) | undefined
443
+ if (initCb) initCb(env.sessionId)
444
+ }
445
+ }
446
+
447
+ // SDK helpers exposed for the typed wrappers. The SDK is the only caller.
448
+ ;(g as Record<string, unknown>).__mentraSendOneShot = (iface: string, method: string, args: unknown[]) => {
449
+ try {
450
+ __dispatch(iface, method, JSON.stringify(args ?? []))
451
+ } catch (e) {
452
+ try {
453
+ __hostError(
454
+ JSON.stringify({
455
+ message: e instanceof Error ? e.message : String(e),
456
+ source: `oneShot:${iface}.${method}`,
457
+ }),
458
+ )
459
+ } catch {
460
+ /* ignore */
461
+ }
462
+ }
463
+ }
464
+
465
+ ;(g as Record<string, unknown>).__mentraSendRequest = (
466
+ iface: string,
467
+ method: string,
468
+ args: unknown[],
469
+ ): Promise<unknown> => {
470
+ return new g.Promise<unknown>((resolve, reject) => {
471
+ const reqId = `${nextReqId++}`
472
+ pending.set(reqId, {resolve, reject})
473
+ try {
474
+ __dispatch(iface, method, JSON.stringify({args: args ?? [], reqId}))
475
+ } catch (e) {
476
+ pending.delete(reqId)
477
+ reject({code: "NATIVE_THROW", message: e instanceof Error ? e.message : String(e)})
478
+ }
479
+ })
480
+ }
481
+
482
+ // Event subscribe / unsubscribe helpers — the SDK's session._subscribe()
483
+ // path calls these. We keep the map on globalThis so __deliver can fan
484
+ // out without re-importing this module.
485
+ ;(g as Record<string, unknown>).__mentraEventListeners = new Map<string, Set<(p: unknown) => void>>()
486
+ ;(g as Record<string, unknown>).__mentraSubscribe = (iface: string, cb: (p: unknown) => void) => {
487
+ const map = (g as Record<string, unknown>).__mentraEventListeners as Map<string, Set<(p: unknown) => void>>
488
+ let set = map.get(iface)
489
+ if (!set) {
490
+ set = new Set()
491
+ map.set(iface, set)
492
+ }
493
+ set.add(cb)
494
+ return () => {
495
+ set!.delete(cb)
496
+ }
497
+ }
498
+
499
+ // ---------- localStorage --------------------------------------------------
500
+ // Bridged through __dispatch — `localStorage` iface. Storage is per-miniapp
501
+ // (native side scopes by packageName), so the contract is just (key, value)
502
+ // string-string pairs. We don't do quota enforcement on the JS side.
503
+ type DispatchLike = (iface: string, method: string, args: unknown[]) => unknown
504
+ const dispatchSyncOrNull = (iface: string, method: string, args: unknown[]): unknown => {
505
+ try {
506
+ const raw = __dispatch(iface, method, JSON.stringify(args ?? []))
507
+ if (raw == null) return null
508
+ try {
509
+ return JSON.parse(raw)
510
+ } catch {
511
+ return raw
512
+ }
513
+ } catch {
514
+ return null
515
+ }
516
+ }
517
+ ;(g as Record<string, unknown>).__mentraDispatchSync = dispatchSyncOrNull as DispatchLike
518
+
519
+ const localStorage = {
520
+ getItem(key: string): string | null {
521
+ const v = dispatchSyncOrNull("localStorage", "getItem", [String(key)])
522
+ return typeof v === "string" ? v : null
523
+ },
524
+ setItem(key: string, value: string): void {
525
+ dispatchSyncOrNull("localStorage", "setItem", [String(key), String(value)])
526
+ },
527
+ removeItem(key: string): void {
528
+ dispatchSyncOrNull("localStorage", "removeItem", [String(key)])
529
+ },
530
+ clear(): void {
531
+ dispatchSyncOrNull("localStorage", "clear", [])
532
+ },
533
+ key(index: number): string | null {
534
+ const v = dispatchSyncOrNull("localStorage", "key", [Number(index)])
535
+ return typeof v === "string" ? v : null
536
+ },
537
+ get length(): number {
538
+ const v = dispatchSyncOrNull("localStorage", "length", [])
539
+ return typeof v === "number" ? v : 0
540
+ },
541
+ }
542
+ ;(g as Record<string, unknown>).localStorage = localStorage
543
+
544
+ // ---------- crypto.getRandomValues + randomUUID --------------------------
545
+ // crypto.subtle is wired through __dispatch in a later phase; for v1 we
546
+ // ship just enough for crypto.randomUUID + getRandomValues, which the
547
+ // existing SDK envelope code calls (see envelope.ts → reqId UUID).
548
+ const cryptoNs = (((g as Record<string, unknown>).crypto as Record<string, unknown> | undefined) ?? {}) as Record<
549
+ string,
550
+ unknown
551
+ >
552
+ if (typeof cryptoNs.getRandomValues !== "function") {
553
+ cryptoNs.getRandomValues = (arr: ArrayBufferView) => {
554
+ const bytes = dispatchSyncOrNull("crypto", "getRandomBytes", [arr.byteLength]) as number[] | null
555
+ if (!Array.isArray(bytes) || bytes.length !== arr.byteLength) {
556
+ // Fallback path is "best-effort, not cryptographically strong"; the
557
+ // host should always satisfy this call. Math.random keeps tests
558
+ // running but production native MUST implement getRandomBytes.
559
+ const view = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength)
560
+ for (let i = 0; i < view.length; i++) view[i] = Math.floor(Math.random() * 256)
561
+ } else {
562
+ const view = new Uint8Array(arr.buffer, arr.byteOffset, arr.byteLength)
563
+ for (let i = 0; i < bytes.length; i++) view[i] = bytes[i]! & 0xff
564
+ }
565
+ return arr
566
+ }
567
+ }
568
+ if (typeof cryptoNs.randomUUID !== "function") {
569
+ cryptoNs.randomUUID = (): string => {
570
+ // RFC 4122 v4. Pull 16 random bytes then format.
571
+ const buf = new Uint8Array(16)
572
+ ;(cryptoNs.getRandomValues as (a: Uint8Array) => Uint8Array)(buf)
573
+ buf[6] = (buf[6]! & 0x0f) | 0x40
574
+ buf[8] = (buf[8]! & 0x3f) | 0x80
575
+ const hex = Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("")
576
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
577
+ }
578
+ }
579
+ // crypto.subtle is deferred to a follow-up (SHA / AES-GCM / HMAC /
580
+ // X25519 over CryptoKit on iOS + javax.crypto + Tink on Android).
581
+ // Until then, miniapps that reach for it get a clear runtime error
582
+ // pointing at the SDK gap instead of an "undefined is not a function"
583
+ // from the engine. Cheaper than silent failures for early authors.
584
+ if (!cryptoNs.subtle) {
585
+ const notImplemented = () => {
586
+ throw new Error(
587
+ "crypto.subtle is not yet implemented in MentraJS — see " +
588
+ "agents/mentrajs-two-layer-miniapp-architecture.md (Polyfill " +
589
+ "strategy section). Use a pure-JS hash/encrypt library for now.",
590
+ )
591
+ }
592
+ cryptoNs.subtle = new Proxy(
593
+ {},
594
+ {
595
+ get: () => notImplemented,
596
+ },
597
+ )
598
+ }
599
+ ;(g as Record<string, unknown>).crypto = cryptoNs
600
+
601
+ // ---------- TextEncoder / TextDecoder ------------------------------------
602
+ // Both engines lack these. Tiny implementations sufficient for the SDK's
603
+ // existing usage (UTF-8 only; we don't expose stream encoding because the
604
+ // miniapp doesn't need it).
605
+ if (typeof (g as Record<string, unknown>).TextEncoder !== "function") {
606
+ class TextEncoderPolyfill {
607
+ readonly encoding = "utf-8"
608
+ encode(input?: string): Uint8Array {
609
+ const str = input ?? ""
610
+ const out: number[] = []
611
+ for (let i = 0; i < str.length; i++) {
612
+ let code = str.charCodeAt(i)
613
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
614
+ const next = str.charCodeAt(i + 1)
615
+ if (next >= 0xdc00 && next <= 0xdfff) {
616
+ code = ((code - 0xd800) << 10) + (next - 0xdc00) + 0x10000
617
+ i++
618
+ }
619
+ }
620
+ if (code < 0x80) {
621
+ out.push(code)
622
+ } else if (code < 0x800) {
623
+ out.push(0xc0 | (code >> 6), 0x80 | (code & 0x3f))
624
+ } else if (code < 0x10000) {
625
+ out.push(0xe0 | (code >> 12), 0x80 | ((code >> 6) & 0x3f), 0x80 | (code & 0x3f))
626
+ } else {
627
+ out.push(
628
+ 0xf0 | (code >> 18),
629
+ 0x80 | ((code >> 12) & 0x3f),
630
+ 0x80 | ((code >> 6) & 0x3f),
631
+ 0x80 | (code & 0x3f),
632
+ )
633
+ }
634
+ }
635
+ return new Uint8Array(out)
636
+ }
637
+ }
638
+ ;(g as Record<string, unknown>).TextEncoder = TextEncoderPolyfill
639
+ }
640
+ if (typeof (g as Record<string, unknown>).TextDecoder !== "function") {
641
+ class TextDecoderPolyfill {
642
+ readonly encoding: string
643
+ constructor(encoding = "utf-8") {
644
+ this.encoding = encoding
645
+ }
646
+ decode(buffer?: ArrayBuffer | ArrayBufferView | null): string {
647
+ if (!buffer) return ""
648
+ const bytes =
649
+ buffer instanceof Uint8Array
650
+ ? buffer
651
+ : buffer instanceof ArrayBuffer
652
+ ? new Uint8Array(buffer)
653
+ : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength)
654
+ let out = ""
655
+ let i = 0
656
+ while (i < bytes.length) {
657
+ const b1 = bytes[i++]!
658
+ let code: number
659
+ if (b1 < 0x80) {
660
+ code = b1
661
+ } else if (b1 < 0xc0) {
662
+ // Invalid start byte; emit replacement character.
663
+ code = 0xfffd
664
+ } else if (b1 < 0xe0) {
665
+ code = ((b1 & 0x1f) << 6) | (bytes[i++]! & 0x3f)
666
+ } else if (b1 < 0xf0) {
667
+ code = ((b1 & 0x0f) << 12) | ((bytes[i++]! & 0x3f) << 6) | (bytes[i++]! & 0x3f)
668
+ } else {
669
+ code =
670
+ ((b1 & 0x07) << 18) |
671
+ ((bytes[i++]! & 0x3f) << 12) |
672
+ ((bytes[i++]! & 0x3f) << 6) |
673
+ (bytes[i++]! & 0x3f)
674
+ }
675
+ if (code > 0xffff) {
676
+ code -= 0x10000
677
+ out += String.fromCharCode(0xd800 + (code >> 10), 0xdc00 + (code & 0x3ff))
678
+ } else {
679
+ out += String.fromCharCode(code)
680
+ }
681
+ }
682
+ return out
683
+ }
684
+ }
685
+ ;(g as Record<string, unknown>).TextDecoder = TextDecoderPolyfill
686
+ }
687
+
688
+ // ---------- atob / btoa --------------------------------------------------
689
+ if (typeof (g as Record<string, unknown>).btoa !== "function") {
690
+ const b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
691
+ ;(g as Record<string, unknown>).btoa = (input: string) => {
692
+ let out = ""
693
+ let i = 0
694
+ while (i < input.length) {
695
+ const c1 = input.charCodeAt(i++)
696
+ const c2 = i < input.length ? input.charCodeAt(i++) : NaN
697
+ const c3 = i < input.length ? input.charCodeAt(i++) : NaN
698
+ const e1 = c1 >> 2
699
+ const e2 = ((c1 & 3) << 4) | (Number.isNaN(c2) ? 0 : c2 >> 4)
700
+ const e3 = Number.isNaN(c2) ? 64 : ((c2 & 15) << 2) | (Number.isNaN(c3) ? 0 : c3 >> 6)
701
+ const e4 = Number.isNaN(c3) ? 64 : c3 & 63
702
+ out += b64[e1]! + b64[e2]! + (e3 === 64 ? "=" : b64[e3]!) + (e4 === 64 ? "=" : b64[e4]!)
703
+ }
704
+ return out
705
+ }
706
+ ;(g as Record<string, unknown>).atob = (input: string) => {
707
+ const clean = input.replace(/=+$/, "")
708
+ let out = ""
709
+ let buf = 0
710
+ let bits = 0
711
+ for (let i = 0; i < clean.length; i++) {
712
+ const idx = b64.indexOf(clean[i]!)
713
+ if (idx < 0) continue
714
+ buf = (buf << 6) | idx
715
+ bits += 6
716
+ if (bits >= 8) {
717
+ bits -= 8
718
+ out += String.fromCharCode((buf >> bits) & 0xff)
719
+ }
720
+ }
721
+ return out
722
+ }
723
+ }
724
+
725
+ // ---------- fetch (thin native bridge) -----------------------------------
726
+ // Async — uses __mentraSendRequest under the hood with iface "fetch". The
727
+ // native handler is expected to return {status, statusText, headers, body}
728
+ // where body is either a JSON-encodable value or a base64 string.
729
+ if (typeof (g as Record<string, unknown>).fetch !== "function") {
730
+ ;(g as Record<string, unknown>).fetch = async (input: string | URL, init?: RequestInit) => {
731
+ const url = typeof input === "string" ? input : input.toString()
732
+ const method = (init?.method ?? "GET").toUpperCase()
733
+ let bodyString: string | null = null
734
+ const reqBody = init?.body as unknown
735
+ if (typeof reqBody === "string") {
736
+ bodyString = reqBody
737
+ } else if (reqBody && typeof (reqBody as {toString?: () => string}).toString === "function") {
738
+ bodyString = (reqBody as {toString: () => string}).toString()
739
+ }
740
+ const headers: Record<string, string> = {}
741
+ const rawHeaders = init?.headers
742
+ if (rawHeaders) {
743
+ // Headers-like / record / array of tuples
744
+ if (Array.isArray(rawHeaders)) {
745
+ for (const [k, v] of rawHeaders) {
746
+ if (k != null) headers[String(k)] = String(v)
747
+ }
748
+ } else if (typeof (rawHeaders as Headers).forEach === "function") {
749
+ ;(rawHeaders as Headers).forEach((v, k) => {
750
+ headers[k] = v
751
+ })
752
+ } else {
753
+ for (const [k, v] of Object.entries(rawHeaders as Record<string, string>)) {
754
+ headers[String(k)] = String(v)
755
+ }
756
+ }
757
+ }
758
+ const sendRequest = (g as Record<string, unknown>).__mentraSendRequest as (
759
+ iface: string,
760
+ method: string,
761
+ args: unknown[],
762
+ ) => Promise<unknown>
763
+ const result = (await sendRequest("fetch", "request", [
764
+ {url, method, headers, body: bodyString},
765
+ ])) as {
766
+ status: number
767
+ statusText?: string
768
+ headers?: Record<string, string>
769
+ body?: string | null
770
+ ok?: boolean
771
+ }
772
+ const bodyText = typeof result.body === "string" ? result.body : ""
773
+ const responseHeaders = new Map(Object.entries(result.headers ?? {}))
774
+ // Minimal Response polyfill — enough for SDK usage.
775
+ class ResponseLike {
776
+ readonly status = result.status
777
+ readonly statusText = result.statusText ?? ""
778
+ readonly ok = result.ok ?? (result.status >= 200 && result.status < 300)
779
+ readonly url = url
780
+ readonly headers = {
781
+ get: (k: string) => responseHeaders.get(k.toLowerCase()) ?? null,
782
+ has: (k: string) => responseHeaders.has(k.toLowerCase()),
783
+ forEach: (cb: (v: string, k: string) => void) => {
784
+ for (const [k, v] of responseHeaders) cb(v, k)
785
+ },
786
+ }
787
+ async text(): Promise<string> {
788
+ return bodyText
789
+ }
790
+ async json(): Promise<unknown> {
791
+ try {
792
+ return JSON.parse(bodyText)
793
+ } catch (e) {
794
+ // Match the browser fetch().json() behaviour — a SyntaxError
795
+ // is thrown that names the response in its message so the
796
+ // miniapp author can tell which call failed.
797
+ const err = new Error(
798
+ `Failed to parse JSON response from ${url}: ${
799
+ e instanceof Error ? e.message : String(e)
800
+ }`,
801
+ )
802
+ ;(err as Error & {name: string}).name = "SyntaxError"
803
+ throw err
804
+ }
805
+ }
806
+ async arrayBuffer(): Promise<ArrayBuffer> {
807
+ const enc = new (g as unknown as {TextEncoder: new () => TextEncoder}).TextEncoder()
808
+ return enc.encode(bodyText).buffer as ArrayBuffer
809
+ }
810
+ }
811
+ return new ResponseLike()
812
+ }
813
+ }
814
+
815
+ // ---------- WebSocket ----------------------------------------------------
816
+ // Native bridge over URLSessionWebSocketTask (iOS) / OkHttp WebSocket
817
+ // (Android). The JS shim is an EventTarget-shaped wrapper that opens a
818
+ // session id via `__dispatch("ws", "open", [{url, protocols, headers}])`
819
+ // and routes inbound `{kind: "ws-event", sid, ...}` envelopes from
820
+ // __deliver into the matching socket's listeners.
821
+ //
822
+ // RFC 6455 readyState constants: 0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED.
823
+ if (typeof (g as Record<string, unknown>).WebSocket !== "function") {
824
+ type WSListener = (ev: Record<string, unknown>) => void
825
+
826
+ const sockets = new Map<string, MentraWebSocket>()
827
+
828
+ // __deliver fans out ws-event envelopes here. Installed once; the
829
+ // existing __deliver dispatcher (event/response/bridge/init) walks
830
+ // its kind-switch and falls through to this hook for ws-event.
831
+ ;(g as Record<string, unknown>).__mentraDeliverWebSocketEvent = (
832
+ sid: string,
833
+ type: "open" | "message" | "error" | "close",
834
+ payload: Record<string, unknown> | undefined,
835
+ ) => {
836
+ const sock = sockets.get(sid)
837
+ if (!sock) return
838
+ sock._deliver(type, payload ?? {})
839
+ }
840
+
841
+ class MentraWebSocket {
842
+ static readonly CONNECTING = 0
843
+ static readonly OPEN = 1
844
+ static readonly CLOSING = 2
845
+ static readonly CLOSED = 3
846
+
847
+ readonly CONNECTING = 0
848
+ readonly OPEN = 1
849
+ readonly CLOSING = 2
850
+ readonly CLOSED = 3
851
+
852
+ url: string
853
+ readyState: number = 0
854
+ bufferedAmount = 0
855
+ extensions = ""
856
+ protocol = ""
857
+ binaryType: "blob" | "arraybuffer" = "arraybuffer"
858
+
859
+ onopen: WSListener | null = null
860
+ onmessage: WSListener | null = null
861
+ onerror: WSListener | null = null
862
+ onclose: WSListener | null = null
863
+
864
+ private listeners: Record<string, Set<WSListener>> = {
865
+ open: new Set(),
866
+ message: new Set(),
867
+ error: new Set(),
868
+ close: new Set(),
869
+ }
870
+ private sid: string
871
+
872
+ constructor(url: string, protocols?: string | string[]) {
873
+ this.url = String(url)
874
+ const protoList = Array.isArray(protocols) ? protocols : protocols ? [protocols] : []
875
+ // Allocate a session id JS-side so the dispatcher's response is a
876
+ // simple ack rather than carrying the sid we then have to wait for.
877
+ this.sid = `ws-${Date.now()}-${Math.floor(Math.random() * 1e9)}`
878
+ sockets.set(this.sid, this)
879
+ try {
880
+ __dispatch(
881
+ "ws",
882
+ "open",
883
+ JSON.stringify([{sid: this.sid, url: this.url, protocols: protoList}]),
884
+ )
885
+ } catch (e) {
886
+ // Native bridge unavailable — synthesize an immediate failure.
887
+ this.readyState = 3
888
+ queueMicrotaskSafe(() => this._deliver("error", {message: String(e)}))
889
+ queueMicrotaskSafe(() => this._deliver("close", {code: 1006, reason: "bridge unavailable"}))
890
+ }
891
+ }
892
+
893
+ addEventListener(type: string, cb: WSListener): void {
894
+ if (!this.listeners[type]) this.listeners[type] = new Set()
895
+ this.listeners[type].add(cb)
896
+ }
897
+
898
+ removeEventListener(type: string, cb: WSListener): void {
899
+ this.listeners[type]?.delete(cb)
900
+ }
901
+
902
+ send(data: string | ArrayBuffer | ArrayBufferView): void {
903
+ if (this.readyState === 3) {
904
+ throw new Error("WebSocket is already in CLOSING or CLOSED state.")
905
+ }
906
+ let kind: "text" | "binary"
907
+ let payload: string
908
+ if (typeof data === "string") {
909
+ kind = "text"
910
+ payload = data
911
+ } else {
912
+ kind = "binary"
913
+ const bytes =
914
+ data instanceof ArrayBuffer
915
+ ? new Uint8Array(data)
916
+ : new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
917
+ payload = bytesToBase64(bytes)
918
+ }
919
+ // bufferedAmount is intentionally a static 0 — native owns the
920
+ // real queue (URLSessionWebSocketTask / OkHttp); reading it
921
+ // accurately from JS requires a synchronous round-trip we
922
+ // don't expose. No production miniapp surveyed observes it.
923
+ try {
924
+ __dispatch("ws", "send", JSON.stringify([{sid: this.sid, kind, payload}]))
925
+ } catch (e) {
926
+ this._deliver("error", {message: String(e)})
927
+ }
928
+ }
929
+
930
+ close(code?: number, reason?: string): void {
931
+ if (this.readyState === 2 || this.readyState === 3) return
932
+ this.readyState = 2
933
+ try {
934
+ __dispatch(
935
+ "ws",
936
+ "close",
937
+ JSON.stringify([{sid: this.sid, code: code ?? 1000, reason: reason ?? ""}]),
938
+ )
939
+ } catch (e) {
940
+ // Treat native failure as immediate close.
941
+ this.readyState = 3
942
+ this._deliver("close", {code: 1006, reason: String(e)})
943
+ }
944
+ }
945
+
946
+ /** @internal — called by __deliver via the global hook. */
947
+ _deliver(type: "open" | "message" | "error" | "close", ev: Record<string, unknown>): void {
948
+ if (type === "open") {
949
+ this.readyState = 1
950
+ if (typeof ev.protocol === "string") this.protocol = ev.protocol
951
+ } else if (type === "close") {
952
+ this.readyState = 3
953
+ sockets.delete(this.sid)
954
+ }
955
+ const synth: Record<string, unknown> = {type, target: this, ...ev}
956
+ // Reconstitute binary frames into ArrayBuffer for arraybuffer
957
+ // binaryType (the default we ship; no Blob shim yet).
958
+ if (type === "message" && ev.kind === "binary" && typeof ev.data === "string") {
959
+ synth.data = base64ToBytes(ev.data).buffer
960
+ } else if (type === "message" && ev.kind === "text") {
961
+ synth.data = ev.data
962
+ }
963
+ const propMap: Record<string, "onopen" | "onmessage" | "onerror" | "onclose"> = {
964
+ open: "onopen",
965
+ message: "onmessage",
966
+ error: "onerror",
967
+ close: "onclose",
968
+ }
969
+ const prop = propMap[type]
970
+ const handler = (this as Record<string, unknown>)[prop] as WSListener | null
971
+ if (handler) {
972
+ try {
973
+ handler(synth)
974
+ } catch (e) {
975
+ try {
976
+ __hostError(
977
+ JSON.stringify({
978
+ message: e instanceof Error ? e.message : String(e),
979
+ source: `WebSocket.${prop}`,
980
+ }),
981
+ )
982
+ } catch {
983
+ /* ignore */
984
+ }
985
+ }
986
+ }
987
+ const set = this.listeners[type]
988
+ if (set) {
989
+ for (const cb of set) {
990
+ try {
991
+ cb(synth)
992
+ } catch (e) {
993
+ try {
994
+ __hostError(
995
+ JSON.stringify({
996
+ message: e instanceof Error ? e.message : String(e),
997
+ source: `WebSocket.addEventListener("${type}")`,
998
+ }),
999
+ )
1000
+ } catch {
1001
+ /* ignore */
1002
+ }
1003
+ }
1004
+ }
1005
+ }
1006
+ }
1007
+ }
1008
+
1009
+ function bytesToBase64(bytes: Uint8Array): string {
1010
+ const b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
1011
+ let out = ""
1012
+ let i = 0
1013
+ while (i < bytes.length) {
1014
+ const c1 = bytes[i++]!
1015
+ const c2 = i < bytes.length ? bytes[i++]! : NaN
1016
+ const c3 = i < bytes.length ? bytes[i++]! : NaN
1017
+ const e1 = c1 >> 2
1018
+ const e2 = ((c1 & 3) << 4) | (Number.isNaN(c2) ? 0 : c2 >> 4)
1019
+ const e3 = Number.isNaN(c2) ? 64 : ((c2 & 15) << 2) | (Number.isNaN(c3) ? 0 : c3 >> 6)
1020
+ const e4 = Number.isNaN(c3) ? 64 : c3 & 63
1021
+ out += b64[e1]! + b64[e2]! + (e3 === 64 ? "=" : b64[e3]!) + (e4 === 64 ? "=" : b64[e4]!)
1022
+ }
1023
+ return out
1024
+ }
1025
+ function base64ToBytes(s: string): Uint8Array {
1026
+ const b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
1027
+ const clean = s.replace(/=+$/, "")
1028
+ const out: number[] = []
1029
+ let buf = 0
1030
+ let bits = 0
1031
+ for (let i = 0; i < clean.length; i++) {
1032
+ const idx = b64.indexOf(clean[i]!)
1033
+ if (idx < 0) continue
1034
+ buf = (buf << 6) | idx
1035
+ bits += 6
1036
+ if (bits >= 8) {
1037
+ bits -= 8
1038
+ out.push((buf >> bits) & 0xff)
1039
+ }
1040
+ }
1041
+ return new Uint8Array(out)
1042
+ }
1043
+ function queueMicrotaskSafe(fn: () => void): void {
1044
+ const q = (g as Record<string, unknown>).queueMicrotask as ((cb: () => void) => void) | undefined
1045
+ if (typeof q === "function") q(fn)
1046
+ else g.Promise.resolve().then(fn)
1047
+ }
1048
+
1049
+ ;(g as Record<string, unknown>).WebSocket = MentraWebSocket
1050
+ }
1051
+
1052
+ // ---------- signal ready --------------------------------------------------
1053
+ // Tell the native host we're done installing. Host has a NACK timer
1054
+ // (15s cold-start / 3s steady-state) that arms before spawn and clears
1055
+ // when this fires. The host also re-fires the timer for every
1056
+ // dispatchToJs so the host knows the miniapp's event loop is alive.
1057
+ try {
1058
+ __dispatch("__runtime", "ready", JSON.stringify([]))
1059
+ } catch {
1060
+ // Tests may not have __dispatch installed; ignore.
1061
+ }
1062
+ })()