@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/README.md +31 -0
- package/assets/startup.js +804 -0
- package/dist/startup.js +804 -0
- package/package.json +40 -0
- package/src/index.ts +14 -0
- package/src/startup.ts +1054 -0
- package/src/types.ts +61 -0
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
|
+
})()
|