@mobana/react-native-sdk 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +249 -0
  3. package/android/build.gradle +50 -0
  4. package/android/src/main/AndroidManifest.xml +6 -0
  5. package/android/src/main/java/ai/mobana/sdk/MobanaModule.kt +67 -0
  6. package/android/src/main/java/ai/mobana/sdk/MobanaPackage.kt +19 -0
  7. package/app.plugin.js +274 -0
  8. package/ios/Mobana.h +11 -0
  9. package/ios/Mobana.m +20 -0
  10. package/lib/commonjs/Mobana.js +676 -0
  11. package/lib/commonjs/Mobana.js.map +1 -0
  12. package/lib/commonjs/NativeMobana.js +53 -0
  13. package/lib/commonjs/NativeMobana.js.map +1 -0
  14. package/lib/commonjs/api.js +201 -0
  15. package/lib/commonjs/api.js.map +1 -0
  16. package/lib/commonjs/bridge/index.js +19 -0
  17. package/lib/commonjs/bridge/index.js.map +1 -0
  18. package/lib/commonjs/bridge/injectBridge.js +528 -0
  19. package/lib/commonjs/bridge/injectBridge.js.map +1 -0
  20. package/lib/commonjs/components/FlowWebView.js +676 -0
  21. package/lib/commonjs/components/FlowWebView.js.map +1 -0
  22. package/lib/commonjs/components/MobanaProvider.js +275 -0
  23. package/lib/commonjs/components/MobanaProvider.js.map +1 -0
  24. package/lib/commonjs/components/index.js +20 -0
  25. package/lib/commonjs/components/index.js.map +1 -0
  26. package/lib/commonjs/device.js +49 -0
  27. package/lib/commonjs/device.js.map +1 -0
  28. package/lib/commonjs/index.js +20 -0
  29. package/lib/commonjs/index.js.map +1 -0
  30. package/lib/commonjs/package.json +1 -0
  31. package/lib/commonjs/storage.js +277 -0
  32. package/lib/commonjs/storage.js.map +1 -0
  33. package/lib/commonjs/types.js +2 -0
  34. package/lib/commonjs/types.js.map +1 -0
  35. package/lib/module/Mobana.js +673 -0
  36. package/lib/module/Mobana.js.map +1 -0
  37. package/lib/module/NativeMobana.js +49 -0
  38. package/lib/module/NativeMobana.js.map +1 -0
  39. package/lib/module/api.js +194 -0
  40. package/lib/module/api.js.map +1 -0
  41. package/lib/module/bridge/index.js +4 -0
  42. package/lib/module/bridge/index.js.map +1 -0
  43. package/lib/module/bridge/injectBridge.js +523 -0
  44. package/lib/module/bridge/injectBridge.js.map +1 -0
  45. package/lib/module/components/FlowWebView.js +672 -0
  46. package/lib/module/components/FlowWebView.js.map +1 -0
  47. package/lib/module/components/MobanaProvider.js +270 -0
  48. package/lib/module/components/MobanaProvider.js.map +1 -0
  49. package/lib/module/components/index.js +5 -0
  50. package/lib/module/components/index.js.map +1 -0
  51. package/lib/module/device.js +45 -0
  52. package/lib/module/device.js.map +1 -0
  53. package/lib/module/index.js +53 -0
  54. package/lib/module/index.js.map +1 -0
  55. package/lib/module/storage.js +257 -0
  56. package/lib/module/storage.js.map +1 -0
  57. package/lib/module/types.js +2 -0
  58. package/lib/module/types.js.map +1 -0
  59. package/lib/typescript/Mobana.d.ts +209 -0
  60. package/lib/typescript/Mobana.d.ts.map +1 -0
  61. package/lib/typescript/NativeMobana.d.ts +11 -0
  62. package/lib/typescript/NativeMobana.d.ts.map +1 -0
  63. package/lib/typescript/api.d.ts +34 -0
  64. package/lib/typescript/api.d.ts.map +1 -0
  65. package/lib/typescript/bridge/index.d.ts +3 -0
  66. package/lib/typescript/bridge/index.d.ts.map +1 -0
  67. package/lib/typescript/bridge/injectBridge.d.ts +23 -0
  68. package/lib/typescript/bridge/injectBridge.d.ts.map +1 -0
  69. package/lib/typescript/components/FlowWebView.d.ts +38 -0
  70. package/lib/typescript/components/FlowWebView.d.ts.map +1 -0
  71. package/lib/typescript/components/MobanaProvider.d.ts +65 -0
  72. package/lib/typescript/components/MobanaProvider.d.ts.map +1 -0
  73. package/lib/typescript/components/index.d.ts +5 -0
  74. package/lib/typescript/components/index.d.ts.map +1 -0
  75. package/lib/typescript/device.d.ts +6 -0
  76. package/lib/typescript/device.d.ts.map +1 -0
  77. package/lib/typescript/index.d.ts +46 -0
  78. package/lib/typescript/index.d.ts.map +1 -0
  79. package/lib/typescript/storage.d.ts +68 -0
  80. package/lib/typescript/storage.d.ts.map +1 -0
  81. package/lib/typescript/types.d.ts +298 -0
  82. package/lib/typescript/types.d.ts.map +1 -0
  83. package/mobana.podspec +19 -0
  84. package/package.json +131 -0
  85. package/src/Mobana.ts +742 -0
  86. package/src/NativeMobana.ts +61 -0
  87. package/src/api.ts +259 -0
  88. package/src/bridge/index.ts +2 -0
  89. package/src/bridge/injectBridge.ts +542 -0
  90. package/src/components/FlowWebView.tsx +826 -0
  91. package/src/components/MobanaProvider.tsx +393 -0
  92. package/src/components/index.ts +4 -0
  93. package/src/device.ts +42 -0
  94. package/src/index.ts +66 -0
  95. package/src/storage.ts +262 -0
  96. package/src/types.ts +362 -0
@@ -0,0 +1,542 @@
1
+ import type { Attribution, SafeArea, ColorScheme } from '../types';
2
+
3
+ /**
4
+ * Bridge context passed from native to WebView
5
+ */
6
+ export interface BridgeContext {
7
+ attribution: Attribution | null;
8
+ params: Record<string, unknown>;
9
+ installId: string;
10
+ platform: 'ios' | 'android';
11
+ colorScheme: ColorScheme;
12
+ localData: Record<string, unknown>;
13
+ safeArea: SafeArea;
14
+ }
15
+
16
+ /**
17
+ * Generate JavaScript code to inject into WebView
18
+ * Creates the window.Mobana bridge object
19
+ */
20
+ export function generateBridgeScript(context: BridgeContext): string {
21
+ const contextJson = JSON.stringify(context);
22
+
23
+ // This JavaScript runs inside the WebView
24
+ return `
25
+ (function() {
26
+ 'use strict';
27
+
28
+ // Bridge context from native
29
+ var __context = ${contextJson};
30
+ var __localData = __context.localData || {};
31
+
32
+ // Pending async requests (requestId -> { resolve, reject })
33
+ var __pendingRequests = {};
34
+ var __requestId = 0;
35
+
36
+ // Send message to native
37
+ function postMessage(type, payload, requestId) {
38
+ var message = {
39
+ type: type,
40
+ payload: payload,
41
+ requestId: requestId
42
+ };
43
+ window.ReactNativeWebView.postMessage(JSON.stringify(message));
44
+ }
45
+
46
+ // Make an async request to native and wait for response
47
+ function asyncRequest(type, payload) {
48
+ return new Promise(function(resolve, reject) {
49
+ var id = ++__requestId;
50
+ __pendingRequests[id] = { resolve: resolve, reject: reject };
51
+ postMessage(type, payload, id);
52
+ });
53
+ }
54
+
55
+ // Handle response from native (called via injectJavaScript)
56
+ window.__mobanaBridgeResponse = function(requestId, success, result) {
57
+ var pending = __pendingRequests[requestId];
58
+ if (pending) {
59
+ delete __pendingRequests[requestId];
60
+ if (success) {
61
+ pending.resolve(result);
62
+ } else {
63
+ pending.reject(new Error(result || 'Request failed'));
64
+ }
65
+ }
66
+ };
67
+
68
+ // Mobana bridge object
69
+ window.Mobana = {
70
+ // ============================================
71
+ // Data Access
72
+ // ============================================
73
+
74
+ /**
75
+ * Get attribution data for this install
76
+ * @returns {Object|null} Attribution object or null if not matched
77
+ */
78
+ getAttribution: function() {
79
+ return __context.attribution;
80
+ },
81
+
82
+ /**
83
+ * Get custom parameters passed to startFlow()
84
+ * @returns {Object} Parameters object
85
+ */
86
+ getParams: function() {
87
+ return __context.params || {};
88
+ },
89
+
90
+ /**
91
+ * Get the install ID
92
+ * @returns {string} Unique install identifier
93
+ */
94
+ getInstallId: function() {
95
+ return __context.installId;
96
+ },
97
+
98
+ /**
99
+ * Get the current platform
100
+ * @returns {string} 'ios' or 'android'
101
+ */
102
+ getPlatform: function() {
103
+ return __context.platform;
104
+ },
105
+
106
+ /**
107
+ * Get safe area insets for the device screen
108
+ * @returns {Object} { top, bottom, left, right, width, height }
109
+ */
110
+ getSafeArea: function() {
111
+ return __context.safeArea;
112
+ },
113
+
114
+ /**
115
+ * Get the device color scheme (light/dark mode)
116
+ * @returns {string} 'light' or 'dark'
117
+ */
118
+ getColorScheme: function() {
119
+ return __context.colorScheme;
120
+ },
121
+
122
+ /**
123
+ * Store data locally on device (persists across app sessions)
124
+ * @param {string} key - Data key
125
+ * @param {*} value - Data value
126
+ */
127
+ setLocalData: function(key, value) {
128
+ __localData[key] = value;
129
+ postMessage('setLocalData', { key: key, value: value });
130
+ },
131
+
132
+ /**
133
+ * Retrieve locally stored data
134
+ * @param {string} key - Data key
135
+ * @returns {*} Data value or undefined
136
+ */
137
+ getLocalData: function(key) {
138
+ return __localData[key];
139
+ },
140
+
141
+ // ============================================
142
+ // Flow Control
143
+ // ============================================
144
+
145
+ /**
146
+ * Complete the flow with optional data
147
+ * @param {Object} data - Optional data to return to the app
148
+ */
149
+ complete: function(data) {
150
+ postMessage('complete', { data: data });
151
+ },
152
+
153
+ /**
154
+ * Dismiss the flow
155
+ */
156
+ dismiss: function() {
157
+ postMessage('dismiss', {});
158
+ },
159
+
160
+ /**
161
+ * Track a custom event
162
+ * @param {string} name - Event name (snake_case, e.g., 'welcome_viewed')
163
+ */
164
+ trackEvent: function(name) {
165
+ postMessage('trackEvent', { name: name });
166
+ },
167
+
168
+ /**
169
+ * Request the app to perform an async action and return a result.
170
+ * The flow stays open while the app processes the request.
171
+ * Requires onCallback to be provided when starting the flow.
172
+ *
173
+ * @param {Object} data - Arbitrary data to send to the app's onCallback handler
174
+ * @param {Object} options - Optional configuration
175
+ * @param {number} options.timeout - Timeout in seconds (default: 300)
176
+ * @returns {Promise<Object>} Result returned by the app's onCallback handler
177
+ *
178
+ * @example
179
+ * // Request a purchase
180
+ * try {
181
+ * var result = await Mobana.requestCallback(
182
+ * { action: 'purchase', planId: 'premium' },
183
+ * { timeout: 120 }
184
+ * );
185
+ * if (result.success) {
186
+ * Mobana.complete({ purchased: true });
187
+ * }
188
+ * } catch (error) {
189
+ * // Timeout, no handler, or handler threw an error
190
+ * }
191
+ */
192
+ requestCallback: function(data, options) {
193
+ var opts = options || {};
194
+ var timeout = typeof opts.timeout === 'number' ? opts.timeout : 300;
195
+
196
+ var promise = asyncRequest('requestCallback', { data: data || {} });
197
+
198
+ // Wrap with timeout
199
+ var timeoutMs = timeout * 1000;
200
+ var timer;
201
+ var timeoutPromise = new Promise(function(_, reject) {
202
+ timer = setTimeout(function() {
203
+ reject(new Error('requestCallback timed out after ' + timeout + 's'));
204
+ }, timeoutMs);
205
+ });
206
+
207
+ return Promise.race([promise, timeoutPromise]).then(
208
+ function(result) { clearTimeout(timer); return result; },
209
+ function(error) { clearTimeout(timer); throw error; }
210
+ );
211
+ },
212
+
213
+ // ============================================
214
+ // Permissions
215
+ // ============================================
216
+
217
+ /**
218
+ * Request notification permission
219
+ * @returns {Promise<boolean>} True if granted
220
+ */
221
+ requestNotificationPermission: function() {
222
+ return asyncRequest('requestNotificationPermission', {});
223
+ },
224
+
225
+ /**
226
+ * Check notification permission status without requesting
227
+ * @returns {Promise<Object>} { status: string, granted: boolean, settings?: Object }
228
+ */
229
+ checkNotificationPermission: function() {
230
+ return asyncRequest('checkNotificationPermission', {});
231
+ },
232
+
233
+ /**
234
+ * Request App Tracking Transparency permission (iOS only)
235
+ * @returns {Promise<string>} 'authorized', 'denied', 'not-determined', or 'restricted'
236
+ */
237
+ requestATTPermission: function() {
238
+ return asyncRequest('requestATTPermission', {});
239
+ },
240
+
241
+ /**
242
+ * Check App Tracking Transparency status without requesting (iOS only)
243
+ * @returns {Promise<string>} 'authorized', 'denied', 'not-determined', or 'restricted'
244
+ */
245
+ checkATTPermission: function() {
246
+ return asyncRequest('checkATTPermission', {});
247
+ },
248
+
249
+ /**
250
+ * Request location permission
251
+ * @param {Object} options - Optional configuration
252
+ * @param {string} options.precision - 'precise' (default) or 'coarse'. On Android, this determines
253
+ * whether to request ACCESS_FINE_LOCATION or ACCESS_COARSE_LOCATION. On iOS, precision is
254
+ * controlled by the user in the permission dialog.
255
+ * @returns {Promise<string>} Permission result ('granted', 'denied', 'blocked', 'unavailable')
256
+ */
257
+ requestLocationPermission: function(options) {
258
+ var opts = options || {};
259
+ return asyncRequest('requestLocationPermission', { precision: opts.precision || 'precise' });
260
+ },
261
+
262
+ /**
263
+ * Request background location permission
264
+ * @returns {Promise<string>} Permission result ('granted', 'denied', 'blocked', 'unavailable')
265
+ */
266
+ requestBackgroundLocationPermission: function() {
267
+ return asyncRequest('requestBackgroundLocationPermission', {});
268
+ },
269
+
270
+ /**
271
+ * Get current location permission status
272
+ * @returns {Promise<Object>} Location permission status object:
273
+ * - foreground: 'granted' | 'denied' | 'blocked' | 'not_requested'
274
+ * - background: 'granted' | 'denied' | 'blocked' | 'not_requested'
275
+ * - precision: 'precise' | 'coarse' | 'unknown'
276
+ */
277
+ getLocationPermissionStatus: function() {
278
+ return asyncRequest('getLocationPermissionStatus', {});
279
+ },
280
+
281
+ /**
282
+ * Get current location
283
+ * @returns {Promise<Object>} Location coordinates
284
+ */
285
+ getCurrentLocation: function() {
286
+ return asyncRequest('getCurrentLocation', {});
287
+ },
288
+
289
+ // ============================================
290
+ // Native Utilities
291
+ // ============================================
292
+
293
+ /**
294
+ * Request app store review
295
+ * Note: This will complete the flow and show the review dialog after the flow closes.
296
+ * Due to iOS StoreKit limitations, reviews cannot be shown while a modal is visible.
297
+ * Use this as the final action in your flow.
298
+ */
299
+ requestAppReview: function() {
300
+ postMessage('requestAppReview', {});
301
+ },
302
+
303
+ /**
304
+ * Trigger haptic feedback
305
+ * @param {string} style - 'light', 'medium', 'heavy', 'success', 'warning', 'error', 'selection'
306
+ */
307
+ haptic: function(style) {
308
+ postMessage('haptic', { style: style || 'medium' });
309
+ },
310
+
311
+ /**
312
+ * Open a URL in the browser
313
+ * @param {string} url - URL to open
314
+ */
315
+ openURL: function(url) {
316
+ postMessage('openURL', { url: url });
317
+ },
318
+
319
+ /**
320
+ * Open app settings
321
+ */
322
+ openSettings: function() {
323
+ postMessage('openSettings', {});
324
+ },
325
+
326
+ /**
327
+ * Play a sound from a URL (external or base64 data URL)
328
+ * @param {string} url - Sound URL (https:// or data:audio/...)
329
+ * @param {Object} options - Optional playback options
330
+ * @param {number} options.volume - Volume level (0.0 - 1.0, default 1.0)
331
+ * @param {boolean} options.loop - Whether to loop the sound (default false)
332
+ * @param {function} options.onEnd - Callback when sound finishes playing
333
+ * @param {function} options.onError - Callback when an error occurs
334
+ * @returns {Object} Controller with { isPlaying, stop() }
335
+ */
336
+ playSound: function(url, options) {
337
+ var opts = options || {};
338
+ var volume = typeof opts.volume === 'number' ? Math.max(0, Math.min(1, opts.volume)) : 1.0;
339
+ var loop = opts.loop === true;
340
+ var onEnd = typeof opts.onEnd === 'function' ? opts.onEnd : null;
341
+ var onError = typeof opts.onError === 'function' ? opts.onError : null;
342
+
343
+ var controller = {
344
+ isPlaying: false,
345
+ stop: function() {}
346
+ };
347
+
348
+ try {
349
+ var audio = new Audio(url);
350
+ audio.volume = volume;
351
+ audio.loop = loop;
352
+
353
+ audio.onplay = function() {
354
+ controller.isPlaying = true;
355
+ };
356
+
357
+ audio.onended = function() {
358
+ controller.isPlaying = false;
359
+ if (onEnd) {
360
+ try { onEnd(); } catch (e) { console.warn('playSound onEnd error:', e); }
361
+ }
362
+ };
363
+
364
+ audio.onerror = function(e) {
365
+ controller.isPlaying = false;
366
+ console.warn('playSound error: Failed to load or play sound');
367
+ if (onError) {
368
+ try { onError(e); } catch (err) { console.warn('playSound onError callback error:', err); }
369
+ }
370
+ };
371
+
372
+ audio.onpause = function() {
373
+ if (!audio.ended) {
374
+ controller.isPlaying = false;
375
+ }
376
+ };
377
+
378
+ controller.stop = function() {
379
+ try {
380
+ audio.pause();
381
+ audio.currentTime = 0;
382
+ controller.isPlaying = false;
383
+ } catch (e) {
384
+ // Audio may have been garbage collected
385
+ }
386
+ };
387
+
388
+ audio.play().catch(function(e) {
389
+ controller.isPlaying = false;
390
+ console.warn('playSound error: ' + e.message);
391
+ if (onError) {
392
+ try { onError(e); } catch (err) { console.warn('playSound onError callback error:', err); }
393
+ }
394
+ });
395
+
396
+ controller.isPlaying = true;
397
+ } catch (e) {
398
+ console.warn('playSound error: ' + e.message);
399
+ if (onError) {
400
+ try { onError(e); } catch (err) { console.warn('playSound onError callback error:', err); }
401
+ }
402
+ }
403
+
404
+ return controller;
405
+ }
406
+ };
407
+
408
+ // Mark bridge as ready
409
+ window.__mobanaBridgeReady = true;
410
+
411
+ // Dispatch ready event for flows that want to wait
412
+ if (typeof document !== 'undefined') {
413
+ document.dispatchEvent(new Event('mobana:ready'));
414
+ }
415
+ })();
416
+ `;
417
+ }
418
+
419
+ /**
420
+ * Build complete HTML with injected bridge, styles, and safe area CSS variables
421
+ */
422
+ export function buildFlowHtml(
423
+ html: string,
424
+ css: string | undefined,
425
+ js: string | undefined,
426
+ bridgeScript: string,
427
+ safeArea?: SafeArea,
428
+ colorScheme?: ColorScheme
429
+ ): string {
430
+ let fullHtml = html;
431
+
432
+ // 0. Ensure viewport meta tag has viewport-fit=cover (required for edge-to-edge rendering on iOS)
433
+ const viewportMetaRegex = /<meta\s+[^>]*name=["']viewport["'][^>]*>/i;
434
+ const viewportMatch = fullHtml.match(viewportMetaRegex);
435
+ if (viewportMatch) {
436
+ const existingTag = viewportMatch[0];
437
+ if (!existingTag.includes('viewport-fit=cover')) {
438
+ // Append viewport-fit=cover to existing content attribute
439
+ const updatedTag = existingTag.replace(
440
+ /content=["']([^"']*)["']/i,
441
+ (match, content) => `content="${content}, viewport-fit=cover"`
442
+ );
443
+ fullHtml = fullHtml.replace(existingTag, updatedTag);
444
+ }
445
+ } else {
446
+ // No viewport meta tag — inject a sensible default
447
+ const defaultViewport = '<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">';
448
+ if (fullHtml.includes('</head>')) {
449
+ fullHtml = fullHtml.replace(/<head([^>]*)>/i, `<head$1>${defaultViewport}`);
450
+ } else if (fullHtml.includes('<body')) {
451
+ fullHtml = fullHtml.replace('<body', `<head>${defaultViewport}</head><body`);
452
+ } else {
453
+ fullHtml = `<head>${defaultViewport}</head>` + fullHtml;
454
+ }
455
+ }
456
+
457
+ // 1. Inject SDK base resets FIRST (before user CSS, so flows can override)
458
+ const resetStyle = `<style data-mobana="reset">
459
+ /* Mobana SDK base resets — flows can override any of these */
460
+ *, *::before, *::after {
461
+ margin: 0;
462
+ padding: 0;
463
+ box-sizing: border-box;
464
+ -webkit-tap-highlight-color: transparent;
465
+ }
466
+ body {
467
+ -webkit-font-smoothing: antialiased;
468
+ -webkit-user-select: none;
469
+ user-select: none;
470
+ -webkit-touch-callout: none;
471
+ overflow: hidden;
472
+ }
473
+ </style>`;
474
+ if (fullHtml.includes('</head>')) {
475
+ // Insert at the START of <head> so it comes before any flow styles
476
+ fullHtml = fullHtml.replace(/<head([^>]*)>/i, `<head$1>${resetStyle}`);
477
+ } else if (fullHtml.includes('<body')) {
478
+ fullHtml = fullHtml.replace('<body', `<head>${resetStyle}</head><body`);
479
+ } else {
480
+ fullHtml = resetStyle + fullHtml;
481
+ }
482
+
483
+ // 2. Inject user CSS (if separate) — after resets, before env vars
484
+ if (css) {
485
+ const styleTag = `<style>${css}</style>`;
486
+ if (fullHtml.includes('</head>')) {
487
+ fullHtml = fullHtml.replace('</head>', `${styleTag}</head>`);
488
+ } else if (fullHtml.includes('<body')) {
489
+ fullHtml = fullHtml.replace('<body', `<head>${styleTag}</head><body`);
490
+ } else {
491
+ fullHtml = styleTag + fullHtml;
492
+ }
493
+ }
494
+
495
+ // 3. Inject CSS environment variables AFTER user CSS (SDK values take precedence)
496
+ // Note: We use values from react-native-safe-area-context (not CSS env()) for reliable
497
+ // cross-platform insets. Step 0 ensures viewport-fit=cover for edge-to-edge rendering.
498
+ const envVarsStyle = `<style data-mobana="env">
499
+ :root {
500
+ /* Color scheme - enables light-dark() CSS function */
501
+ color-scheme: ${colorScheme || 'light'};
502
+ --color-scheme: ${colorScheme || 'light'};
503
+ /* Safe area insets - from react-native-safe-area-context */
504
+ --safe-area-top: ${safeArea?.top ?? 0}px;
505
+ --safe-area-right: ${safeArea?.right ?? 0}px;
506
+ --safe-area-bottom: ${safeArea?.bottom ?? 0}px;
507
+ --safe-area-left: ${safeArea?.left ?? 0}px;
508
+ /* Screen dimensions */
509
+ --screen-width: ${safeArea?.width ?? 0}px;
510
+ --screen-height: ${safeArea?.height ?? 0}px;
511
+ }
512
+ </style>`;
513
+ if (fullHtml.includes('</head>')) {
514
+ fullHtml = fullHtml.replace('</head>', `${envVarsStyle}</head>`);
515
+ } else if (fullHtml.includes('<body')) {
516
+ fullHtml = fullHtml.replace('<body', `<head>${envVarsStyle}</head><body`);
517
+ } else {
518
+ fullHtml = envVarsStyle + fullHtml;
519
+ }
520
+
521
+ // Inject JS if separate
522
+ if (js) {
523
+ const scriptTag = `<script>${js}</script>`;
524
+ if (fullHtml.includes('</body>')) {
525
+ fullHtml = fullHtml.replace('</body>', `${scriptTag}</body>`);
526
+ } else {
527
+ fullHtml = fullHtml + scriptTag;
528
+ }
529
+ }
530
+
531
+ // Inject bridge script (must come before user JS)
532
+ const bridgeTag = `<script>${bridgeScript}</script>`;
533
+ if (fullHtml.includes('</head>')) {
534
+ fullHtml = fullHtml.replace('</head>', `${bridgeTag}</head>`);
535
+ } else if (fullHtml.includes('<body')) {
536
+ fullHtml = fullHtml.replace('<body', `<head>${bridgeTag}</head><body`);
537
+ } else {
538
+ fullHtml = bridgeTag + fullHtml;
539
+ }
540
+
541
+ return fullHtml;
542
+ }