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