@omega.js/client 0.1.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.
Files changed (72) hide show
  1. package/LICENSE +98 -0
  2. package/README.md +874 -0
  3. package/dist/index.js +999 -0
  4. package/dist/modules/analytics.js +584 -0
  5. package/dist/modules/auth.js +469 -0
  6. package/dist/modules/bindings.js +319 -0
  7. package/dist/modules/device.js +282 -0
  8. package/dist/modules/dom.js +96 -0
  9. package/dist/modules/features.js +30 -0
  10. package/dist/modules/firestore.js +313 -0
  11. package/dist/modules/form-manager.js +1577 -0
  12. package/dist/modules/icon-core.js +226 -0
  13. package/dist/modules/icon-renderer.js +149 -0
  14. package/dist/modules/live-page.js +235 -0
  15. package/dist/modules/logger.js +36 -0
  16. package/dist/modules/motion.js +853 -0
  17. package/dist/modules/notifications.js +433 -0
  18. package/dist/modules/path-prefix.js +22 -0
  19. package/dist/modules/request.js +223 -0
  20. package/dist/modules/sentry.js +108 -0
  21. package/dist/modules/service-worker.js +237 -0
  22. package/dist/modules/storage.js +133 -0
  23. package/dist/modules/triggers.js +117 -0
  24. package/dist/modules/utilities.js +479 -0
  25. package/dist/modules/vert-document.js +354 -0
  26. package/dist/modules/verts.js +1133 -0
  27. package/dist/vendor/account/engine.js +182 -0
  28. package/dist/vendor/account/features.js +220 -0
  29. package/dist/vendor/account/index.js +53 -0
  30. package/dist/vendor/account/schema.js +272 -0
  31. package/dist/vendor/account/subscription.js +38 -0
  32. package/dist/vendor/analytics/adapters/ga4.js +26 -0
  33. package/dist/vendor/analytics/adapters/meta.js +26 -0
  34. package/dist/vendor/analytics/adapters/resolve.js +130 -0
  35. package/dist/vendor/analytics/adapters/tiktok.js +27 -0
  36. package/dist/vendor/analytics/catalog.js +908 -0
  37. package/dist/vendor/analytics/consent.js +49 -0
  38. package/dist/vendor/analytics/core.js +141 -0
  39. package/dist/vendor/analytics/identity.js +136 -0
  40. package/dist/vendor/analytics/index.js +170 -0
  41. package/dist/vendor/analytics/logger.js +40 -0
  42. package/dist/vendor/analytics/transports/browser.js +110 -0
  43. package/dist/vendor/monitoring/browser.js +207 -0
  44. package/dist/vendor/monitoring/core.js +180 -0
  45. package/dist/vendor/monitoring/logger.js +39 -0
  46. package/docs/architecture.md +59 -0
  47. package/docs/bindings.md +235 -0
  48. package/docs/build-system.md +32 -0
  49. package/docs/cdp-debugging.md +29 -0
  50. package/docs/code-patterns.md +96 -0
  51. package/docs/common-tasks.md +36 -0
  52. package/docs/dependencies.md +19 -0
  53. package/docs/index.md +159 -0
  54. package/docs/modules.md +180 -0
  55. package/docs/shared/agent-docs.md +89 -0
  56. package/docs/shared/analytics.md +612 -0
  57. package/docs/shared/brands.md +51 -0
  58. package/docs/shared/breaking-changes.md +497 -0
  59. package/docs/shared/config.md +1387 -0
  60. package/docs/shared/deploys.md +215 -0
  61. package/docs/shared/icons.md +201 -0
  62. package/docs/shared/local-dev.md +147 -0
  63. package/docs/shared/logging.md +202 -0
  64. package/docs/shared/monitoring.md +153 -0
  65. package/docs/shared/publishing.md +183 -0
  66. package/docs/shared/rulings.md +34 -0
  67. package/docs/shared/testing.md +147 -0
  68. package/docs/shared/theming.md +604 -0
  69. package/docs/shared/translation.md +291 -0
  70. package/docs/shared/updates.md +61 -0
  71. package/docs/testing.md +9 -0
  72. package/package.json +65 -0
@@ -0,0 +1,433 @@
1
+ import { createLogger } from './logger.js';
2
+
3
+ const logger = createLogger('push');
4
+ const syncLogger = createLogger('push:sync');
5
+
6
+ class Notifications {
7
+ constructor(manager) {
8
+ this.manager = manager;
9
+ this._requestInProgress = false;
10
+ }
11
+
12
+ initialize(config) {
13
+ // Canonical home: omega.json5 `cloud.messaging.vapidKey` (public by
14
+ // design — VAPID public keys ship to every browser). The nested
15
+ // firebase.messaging shape is the web/extension bridge contract,
16
+ // mirroring _resolveFirebaseConfig's cloud-first order.
17
+ this._vapidKey = this.manager.config?.cloud?.messaging?.vapidKey
18
+ || this.manager.config?.firebase?.messaging?.config?.vapidKey
19
+ || null;
20
+
21
+ const storage = this.manager.storage();
22
+ const stored = storage.get('notifications');
23
+ const permission = typeof Notification !== 'undefined' ? Notification.permission : 'default';
24
+
25
+ logger.log('Page load check:', { storedSubscribed: stored?.subscribed, storedToken: stored?.token?.slice(-8), permission });
26
+
27
+ // If localStorage says subscribed but browser permission disagrees, clear it
28
+ if (stored?.subscribed && permission !== 'granted') {
29
+ logger.log('Clearing stale subscription — permission is', permission);
30
+ storage.set('notifications', { subscribed: false, token: null });
31
+ }
32
+
33
+ // Arm auto-request if not currently subscribed (including just-cleared)
34
+ const autoRequest = config?.autoRequest;
35
+ if ((!stored?.subscribed || permission !== 'granted') && autoRequest > 0) {
36
+ logger.log('Arming auto-request (delay:', `${autoRequest}ms)`);
37
+ this._setupAutoRequest(autoRequest);
38
+ }
39
+
40
+ // Listen for foreground messages (tab is focused)
41
+ if (permission === 'granted') {
42
+ logger.log('Setting up foreground listener...', { supported: this.isSupported(), hasMessaging: !!this.manager.firebaseMessaging });
43
+ this.onMessage((payload) => {
44
+ logger.log('Foreground message received:', payload);
45
+ }).then(unsub => {
46
+ logger.log('Foreground listener registered:', typeof unsub === 'function' ? 'OK' : 'FAILED (got empty fn)');
47
+ });
48
+ }
49
+ }
50
+
51
+ _setupAutoRequest(delay) {
52
+ if (typeof document === 'undefined') {
53
+ return;
54
+ }
55
+
56
+ const handleClick = () => {
57
+ document.removeEventListener('click', handleClick);
58
+
59
+ setTimeout(() => {
60
+ logger.log('Auto-requesting notification permissions...');
61
+ this.subscribe().catch(err => {
62
+ logger.error('Auto-subscription failed:', err.message);
63
+ });
64
+ }, delay);
65
+ };
66
+
67
+ document.addEventListener('click', handleClick);
68
+ }
69
+
70
+ // Check if notifications are supported
71
+ isSupported() {
72
+ return 'Notification' in window &&
73
+ 'serviceWorker' in navigator &&
74
+ !!this.manager.firebaseMessaging;
75
+ }
76
+
77
+ // Check if user is subscribed to notifications
78
+ async isSubscribed() {
79
+ try {
80
+ if (!this.isSupported()) {
81
+ return false;
82
+ }
83
+
84
+ return Notification.permission === 'granted';
85
+ } catch (error) {
86
+ console.error('Check subscription error:', error);
87
+ return false;
88
+ }
89
+ }
90
+
91
+ // Subscribe to push notifications
92
+ async subscribe(options = {}) {
93
+ try {
94
+ if (!this.isSupported()) {
95
+ throw new Error('Push notifications are not supported');
96
+ }
97
+
98
+ if (this._requestInProgress) {
99
+ throw new Error('Subscription request already in progress');
100
+ }
101
+
102
+ this._requestInProgress = true;
103
+
104
+ // Get Firebase messaging
105
+ const messaging = this.manager.firebaseMessaging;
106
+ if (!messaging) {
107
+ throw new Error('Firebase Messaging not initialized');
108
+ }
109
+
110
+ // Get service worker registration
111
+ const swRegistration = this.manager.state.serviceWorker;
112
+ if (!swRegistration) {
113
+ throw new Error('Service Worker not registered');
114
+ }
115
+
116
+ // Request notification permission if not already granted
117
+ if (Notification.permission === 'default') {
118
+ const permission = await this._askPermission();
119
+ if (permission !== 'granted') {
120
+ throw new Error('Notification permission denied');
121
+ }
122
+ } else if (Notification.permission === 'denied') {
123
+ throw new Error('Notification permission denied');
124
+ }
125
+
126
+ // Get FCM token
127
+ const { getToken } = await import('firebase/messaging');
128
+ const tokenOptions = { serviceWorkerRegistration: swRegistration };
129
+ if (this._vapidKey) { tokenOptions.vapidKey = this._vapidKey; }
130
+ const token = await getToken(messaging, tokenOptions);
131
+
132
+ if (!token) {
133
+ throw new Error('Failed to get FCM token');
134
+ }
135
+
136
+ // Save subscription info
137
+ await this._saveSubscription(token);
138
+
139
+ // Track in local storage
140
+ const storage = this.manager.storage();
141
+ storage.set('notifications', {
142
+ subscribed: true,
143
+ token: token,
144
+ timestamp: new Date().toISOString(),
145
+ uid: this.manager.auth().getUser()?.uid || null
146
+ });
147
+
148
+ this._requestInProgress = false;
149
+ return { subscribed: true, token };
150
+
151
+ } catch (error) {
152
+ this._requestInProgress = false;
153
+ console.error('Subscribe error:', error);
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ // Unsubscribe from push notifications
159
+ async unsubscribe() {
160
+ try {
161
+ if (!this.isSupported()) {
162
+ return false;
163
+ }
164
+
165
+ const { deleteToken } = await import('firebase/messaging');
166
+ const messaging = this.manager.firebaseMessaging;
167
+
168
+ if (messaging) {
169
+ await deleteToken(messaging);
170
+ }
171
+
172
+ // Clear local storage
173
+ const storage = this.manager.storage();
174
+ storage.remove('notifications');
175
+
176
+ return true;
177
+ } catch (error) {
178
+ console.error('Unsubscribe error:', error);
179
+ throw error;
180
+ }
181
+ }
182
+
183
+ // Request permission (without subscribing)
184
+ async requestPermission() {
185
+ try {
186
+ if (!this.isSupported()) {
187
+ throw new Error('Notifications not supported');
188
+ }
189
+
190
+ const permission = await this._askPermission();
191
+ return permission === 'granted';
192
+ } catch (error) {
193
+ console.error('Request permission error:', error);
194
+ return false;
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Ask the browser, and COUNT the asking ([#328](https://github.com/Omega-JS-Stack/omega/issues/328)
200
+ * inventory gap 9: nothing fired the permission trio, so the drop-off between
201
+ * being asked and saying yes was invisible).
202
+ *
203
+ * Only a real prompt is counted — a permission the browser has already been
204
+ * answered for shows the visitor no UI at all. A DISMISSED prompt leaves the
205
+ * permission at 'default' and fires neither outcome on purpose: the visitor
206
+ * denied nothing, and the gap between requested and answered is the dismissal.
207
+ *
208
+ * @returns {Promise<string>} The browser's permission after the ask.
209
+ */
210
+ async _askPermission() {
211
+ const prompted = Notification.permission === 'default';
212
+ const analytics = this.manager.analytics();
213
+
214
+ if (prompted) {
215
+ analytics.event('notification_permission_request');
216
+ }
217
+
218
+ const permission = await Notification.requestPermission();
219
+
220
+ if (prompted && permission === 'granted') {
221
+ analytics.event('notification_permission_grant');
222
+ } else if (prompted && permission === 'denied') {
223
+ analytics.event('notification_permission_deny');
224
+ }
225
+
226
+ return permission;
227
+ }
228
+
229
+ // Get current FCM token
230
+ async getToken() {
231
+ try {
232
+ if (!this.isSupported()) {
233
+ return null;
234
+ }
235
+
236
+ const messaging = this.manager.firebaseMessaging;
237
+ if (!messaging) {
238
+ return null;
239
+ }
240
+
241
+ const { getToken } = await import('firebase/messaging');
242
+ const swRegistration = this.manager.state.serviceWorker;
243
+
244
+ const tokenOptions = { serviceWorkerRegistration: swRegistration };
245
+ if (this._vapidKey) { tokenOptions.vapidKey = this._vapidKey; }
246
+ return await getToken(messaging, tokenOptions);
247
+ } catch (error) {
248
+ console.error('Get token error:', error);
249
+ return null;
250
+ }
251
+ }
252
+
253
+ // Listen for foreground messages
254
+ async onMessage(callback) {
255
+ try {
256
+ if (!this.isSupported()) {
257
+ return () => {};
258
+ }
259
+
260
+ const { onMessage } = await import('firebase/messaging');
261
+ const messaging = this.manager.firebaseMessaging;
262
+
263
+ if (!messaging) {
264
+ return () => {};
265
+ }
266
+
267
+ return onMessage(messaging, (payload) => {
268
+ console.log('Foreground message received:', payload);
269
+
270
+ // Extract notification data - handle both payload.notification and payload.data formats
271
+ const notificationData = payload.notification || payload.data || {};
272
+ const { title, body, icon, badge, image, click_action, url, tag } = notificationData;
273
+
274
+ // Determine the click URL (prioritize click_action, then url, then data.url)
275
+ const clickUrl = click_action || url || payload.data?.click_action || payload.data?.url;
276
+
277
+ // Show notification if we have at least a title
278
+ if (title) {
279
+ const notification = new Notification(title, {
280
+ body: body || '',
281
+ icon: icon || '/favicon.ico',
282
+ badge: badge,
283
+ image: image,
284
+ tag: tag || 'default',
285
+ data: { ...payload.data, clickUrl },
286
+ requireInteraction: true,
287
+ renotify: true
288
+ });
289
+
290
+ notification.onclick = (event) => {
291
+ event.preventDefault();
292
+
293
+ // Focus or open the target window
294
+ if (clickUrl) {
295
+ // Try to find an existing window/tab with this URL
296
+ window.focus();
297
+ window.open(clickUrl, '_blank');
298
+ } else {
299
+ // Just focus the current window if no URL
300
+ window.focus();
301
+ }
302
+
303
+ notification.close();
304
+ };
305
+ }
306
+
307
+ // Call the user's callback with the full payload
308
+ if (callback) {
309
+ callback(payload);
310
+ }
311
+ });
312
+ } catch (error) {
313
+ console.error('Message listener error:', error);
314
+ return () => {};
315
+ }
316
+ }
317
+
318
+ // Sync subscription when auth state changes or on page load.
319
+ // Re-fetches the current FCM token — if it changed (browser rotated it,
320
+ // service worker was re-registered, etc.), saves the new token to Firestore
321
+ // and updates localStorage. Clears the subscribed state if the token is gone.
322
+ async syncSubscription() {
323
+ try {
324
+ const storage = this.manager.storage();
325
+ const storedNotification = storage.get('notifications');
326
+
327
+ const permission = typeof Notification !== 'undefined' ? Notification.permission : 'default';
328
+
329
+ syncLogger.log('Starting sync:', { storedSubscribed: storedNotification?.subscribed, storedToken: storedNotification?.token?.slice(-8), permission });
330
+
331
+ if (permission !== 'granted') {
332
+ if (storedNotification?.subscribed) {
333
+ syncLogger.log('Permission not granted — clearing localStorage');
334
+ storage.set('notifications', { subscribed: false, token: null });
335
+ } else {
336
+ syncLogger.log('Permission not granted and not subscribed — nothing to do');
337
+ }
338
+ return false;
339
+ }
340
+
341
+ // Permission is granted — check if there's a live token (covers localStorage cleared, new browser, etc.)
342
+ const currentToken = await this.getToken();
343
+
344
+ if (!currentToken) {
345
+ syncLogger.log('Token fetch returned null — clearing localStorage');
346
+ storage.set('notifications', { subscribed: false, token: null });
347
+ return false;
348
+ }
349
+
350
+ syncLogger.log('Token valid:', currentToken.slice(-8), storedNotification?.token ? (storedNotification.token.slice(-8) === currentToken.slice(-8) ? '(unchanged)' : `(CHANGED from ${storedNotification.token.slice(-8)})`) : '(recovered — localStorage was empty)');
351
+
352
+ await this._saveSubscription(currentToken);
353
+
354
+ const user = this.manager.auth().getUser();
355
+ storage.set('notifications', {
356
+ subscribed: true,
357
+ token: currentToken,
358
+ uid: user?.uid || null,
359
+ timestamp: new Date().toISOString(),
360
+ });
361
+
362
+ syncLogger.log('Sync complete — subscribed');
363
+ return true;
364
+ } catch (error) {
365
+ syncLogger.error('Sync error:', error);
366
+ return false;
367
+ }
368
+ }
369
+
370
+ // Save subscription to Firestore
371
+ async _saveSubscription(token) {
372
+ try {
373
+ const firestore = this.manager.firestore();
374
+ const user = this.manager.auth().getUser();
375
+ const storage = this.manager.storage();
376
+
377
+ if (!token) {
378
+ return;
379
+ }
380
+
381
+ const now = new Date();
382
+ const timestamp = now.toISOString();
383
+ const timestampUNIX = Math.floor(now.getTime() / 1000);
384
+
385
+ // Get context for client information
386
+ const context = this.manager.utilities().getContext();
387
+ const clientData = context.client;
388
+
389
+ // Reference to the notification document (ID is the token)
390
+ const notificationDoc = firestore.doc(`notifications/${token}`);
391
+
392
+ // Check if document already exists
393
+ const existingDoc = await notificationDoc.get();
394
+ const existingData = existingDoc.exists() ? existingDoc.data() : null;
395
+
396
+ // Determine if we need to update
397
+ const currentUid = user?.uid || null;
398
+ const existingOwner = existingData?.owner || null;
399
+ const needsUpdate = existingOwner !== currentUid;
400
+
401
+ // Create or update the document as needed
402
+ if (!existingData) {
403
+ // New subscription - create the document
404
+ await notificationDoc.set({
405
+ token,
406
+ owner: currentUid,
407
+ tags: ['general'],
408
+ attribution: storage.get('attribution', {}),
409
+ context: { client: clientData },
410
+ metadata: {
411
+ created: { timestamp, timestampUNIX },
412
+ updated: { timestamp, timestampUNIX },
413
+ },
414
+ });
415
+ } else if (needsUpdate) {
416
+ // Existing subscription needs update (userId changed)
417
+ // Use dot-notation to avoid overwriting metadata.created
418
+ await notificationDoc.update({
419
+ owner: currentUid,
420
+ context: { client: clientData },
421
+ 'metadata.updated': { timestamp, timestampUNIX },
422
+ });
423
+ }
424
+ // If no update needed, do nothing
425
+
426
+ } catch (error) {
427
+ console.error('Save subscription error:', error);
428
+ // Don't throw - this is not critical for the subscription process
429
+ }
430
+ }
431
+ }
432
+
433
+ export default Notifications;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The base path this page is mounted under (#355), read off the stamp the web
3
+ * build's HTML pass writes on `<html data-omega-path-prefix>`.
4
+ *
5
+ * Mirrors the web package's runtime helper (core/js/libs/path-prefix.js) rather
6
+ * than importing across the package boundary — nothing else is shared between
7
+ * the two runtimes today. One difference, deliberate: the domain root reads as
8
+ * '' here, so a caller concatenates without a special case.
9
+ */
10
+
11
+ // '' (domain root) or '/workkit'-shaped, no trailing slash.
12
+ export function pathPrefix() {
13
+ // Every absence means the same thing — a site at the domain root: no stamp,
14
+ // no document (a worker scope), or a surface whose DOM has no <html> yet.
15
+ const stamped = typeof document === 'undefined'
16
+ ? ''
17
+ : document.documentElement?.dataset?.omegaPathPrefix;
18
+
19
+ return stamped === '/' ? '' : (stamped || '');
20
+ }
21
+
22
+ export default pathPrefix;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * request — the harmonized API-fetch layer (successor to legacy UJM's authorizedFetch).
3
+ *
4
+ * One implementation for every surface: the browser singleton exposes it as
5
+ * `omega.request(url, options)`; desktop main and the extension service worker
6
+ * construct their own instance via `createRequest(deps)` with their framework's
7
+ * url/auth plumbing. Every response's `omega-properties` header (code, tag,
8
+ * usage current+limits, schema, additional — emitted by @omega.js/backend's
9
+ * assistant on every respond/errorify) is parsed automatically; contexts with
10
+ * bindings get server usage merged into the top-level `usage` bindings key.
11
+ *
12
+ * deps contract:
13
+ * getApiUrl() -> base API url (required for route-relative paths)
14
+ * getIdToken(force) -> fresh Firebase ID token, or null when signed out
15
+ * onProperties(props) -> optional; called with the parsed omega-properties object
16
+ * onUnauthorized() -> optional; called (never awaited) when an authenticated
17
+ * request comes back 401 (the session probe, #798)
18
+ *
19
+ * `wakeup: true` is the one option that changes the SHAPE of the call: it warms
20
+ * a cold backend and returns nothing (see the branch below).
21
+ */
22
+
23
+ import { createLogger } from './logger.js';
24
+
25
+ const logger = createLogger('request');
26
+
27
+
28
+ const PROPERTIES_HEADER = 'omega-properties';
29
+
30
+ // The route every wakeup aims at, on every surface. ONE home because a wakeup
31
+ // never runs a route: @omega.js/backend's middleware answers it before it loads
32
+ // one, so the path is a label rather than a destination, and every caller
33
+ // naming "the route I am about to need" would be a dozen spellings of the same
34
+ // warm function. `/omega/health` is the public, input-free liveness probe — the
35
+ // one route whose name means exactly what this call does, and the only one that
36
+ // would still be harmless if the short-circuit ever stopped short-circuiting
37
+ // ([#644](https://github.com/Omega-JS-Stack/omega/issues/644)).
38
+ const WAKEUP_ROUTE = '/omega/health';
39
+
40
+ // Delay between retry attempts (options.tries)
41
+ const RETRY_DELAY = 500;
42
+
43
+ function createRequest(deps) {
44
+ if (typeof deps?.getApiUrl !== 'function' || typeof deps?.getIdToken !== 'function') {
45
+ throw new Error('createRequest requires getApiUrl and getIdToken deps');
46
+ }
47
+
48
+ return async function request(url, options = {}) {
49
+ // Route-relative paths resolve through the host's getApiUrl(); absolute urls pass through
50
+ const target = url.startsWith('/')
51
+ ? `${deps.getApiUrl()}${url}`
52
+ : url;
53
+
54
+ // A wakeup is a fire-and-forget ping that warms a cold backend, nothing
55
+ // more: @omega.js/backend's middleware sees `wakeup` in the request data
56
+ // and answers it BEFORE it loads a route or authenticates, so every route
57
+ // costs the same and none of them runs. Nothing is read back, no token is
58
+ // minted, and a dead network resolves like a live one — the caller is not
59
+ // waiting on an answer.
60
+ if (options.wakeup) {
61
+ fetch(withWakeupParam(target), { method: 'GET' }).catch(() => {});
62
+ return;
63
+ }
64
+
65
+ const headers = { ...(options.headers || {}) };
66
+
67
+ // Attach a fresh Bearer ID token unless the caller opted out (public routes)
68
+ if (options.auth !== false) {
69
+ const idToken = await Promise.resolve(deps.getIdToken(true)).catch(() => null);
70
+
71
+ if (idToken) {
72
+ headers['Authorization'] = `Bearer ${idToken}`;
73
+ } else {
74
+ logger.warn('No authenticated user — sending without Authorization. Did auth settle yet?');
75
+ }
76
+ }
77
+
78
+ // JSON-encode object bodies (strings/FormData/URLSearchParams pass through)
79
+ let body = options.body;
80
+ if (body && typeof body === 'object' && !isRawBody(body)) {
81
+ body = JSON.stringify(body);
82
+ if (!hasHeader(headers, 'content-type')) {
83
+ headers['Content-Type'] = 'application/json';
84
+ }
85
+ }
86
+
87
+ // Fetch with bounded retries (network errors + 5xx) and an optional
88
+ // per-attempt timeout — the wonderful-fetch semantics the legacy
89
+ // authorizedFetch callers relied on (tries, timeout).
90
+ const tries = Math.max(1, options.tries || 1);
91
+ let response;
92
+
93
+ for (let attempt = 1; ; attempt++) {
94
+ try {
95
+ response = await fetch(target, {
96
+ ...options,
97
+ // A body with no explicit method infers POST — fetch throws a
98
+ // TypeError on GET/HEAD carrying a body, so the GET default is
99
+ // never right there.
100
+ method: options.method || (options.body ? 'POST' : 'GET'),
101
+ headers,
102
+ body,
103
+ ...(options.timeout ? { signal: AbortSignal.timeout(options.timeout) } : {}),
104
+ });
105
+
106
+ if (response.status < 500 || attempt >= tries) {
107
+ break;
108
+ }
109
+ } catch (e) {
110
+ if (attempt >= tries) {
111
+ throw e;
112
+ }
113
+ }
114
+
115
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
116
+ }
117
+
118
+ // omega-properties rides EVERY assistant response (success and error)
119
+ const properties = parseProperties(response.headers.get(PROPERTIES_HEADER));
120
+ if (properties && deps.onProperties) {
121
+ deps.onProperties(properties);
122
+ }
123
+
124
+ const data = await parseBody(response);
125
+
126
+ if (!response.ok) {
127
+ // A 401 on an authenticated call is a moment of doubt
128
+ // ([#798](https://github.com/Omega-JS-Stack/omega/issues/798)): the
129
+ // backend refused this token, so ask the Auth server whether the session
130
+ // still exists at all. Never awaited and never fatal: the caller's error
131
+ // must not wait on a token refresh, and a probe that fails is not this
132
+ // request's failure.
133
+ if (options.auth !== false && response.status === 401) {
134
+ Promise.resolve(deps.onUnauthorized?.()).catch(() => {});
135
+ }
136
+
137
+ const message = (data && typeof data === 'object' && data.message)
138
+ || (typeof data === 'string' && data)
139
+ || `Request failed with status ${response.status}`;
140
+ const error = new Error(message);
141
+ error.code = response.status;
142
+ error.properties = properties;
143
+ error.data = data;
144
+ throw error;
145
+ }
146
+
147
+ if (options.output === 'complete') {
148
+ return { status: response.status, ok: response.ok, headers: response.headers, data, properties };
149
+ }
150
+
151
+ return data;
152
+ };
153
+ }
154
+
155
+ // Merge server usage (current counters + plan limits) from an omega-properties
156
+ // payload into the top-level `usage` bindings key — the same key auth settle
157
+ // seeds, so `data-omega-bind` elements refresh automatically after every request.
158
+ // Shape per feature: { monthly, daily, ..., limit }.
159
+ function mergeUsageIntoBindings(bindings, properties) {
160
+ const current = properties?.usage?.current;
161
+ if (!current || !Object.keys(current).length) {
162
+ return;
163
+ }
164
+
165
+ const limits = properties.usage.limits || {};
166
+ const existing = bindings.getContext().usage || {};
167
+ const usage = { ...existing };
168
+
169
+ for (const key of Object.keys(current)) {
170
+ usage[key] = {
171
+ ...existing[key],
172
+ ...current[key],
173
+ // A key the server reports usage for but omits from limits must not
174
+ // clobber the catalog-seeded limit from auth settle
175
+ limit: limits[key] ?? existing[key]?.limit ?? 0,
176
+ };
177
+ }
178
+
179
+ bindings.update({ usage });
180
+ }
181
+
182
+ function parseProperties(raw) {
183
+ if (!raw) {
184
+ return null;
185
+ }
186
+
187
+ try {
188
+ return JSON.parse(raw);
189
+ } catch (e) {
190
+ logger.warn('Failed to parse omega-properties header:', e.message);
191
+ return null;
192
+ }
193
+ }
194
+
195
+ async function parseBody(response) {
196
+ const contentType = response.headers.get('content-type') || '';
197
+
198
+ if (contentType.includes('application/json')) {
199
+ return response.json().catch(() => null);
200
+ }
201
+
202
+ return response.text();
203
+ }
204
+
205
+ // The param rides the URL because a wakeup is a GET, and the middleware reads
206
+ // it from the request data it merges the query string into.
207
+ function withWakeupParam(url) {
208
+ return `${url}${url.includes('?') ? '&' : '?'}wakeup=true`;
209
+ }
210
+
211
+ function hasHeader(headers, name) {
212
+ return Object.keys(headers).some((key) => key.toLowerCase() === name);
213
+ }
214
+
215
+ function isRawBody(body) {
216
+ return (typeof FormData !== 'undefined' && body instanceof FormData)
217
+ || (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams)
218
+ || (typeof Blob !== 'undefined' && body instanceof Blob)
219
+ || (typeof ArrayBuffer !== 'undefined' && body instanceof ArrayBuffer);
220
+ }
221
+
222
+ export { createRequest, mergeUsageIntoBindings, WAKEUP_ROUTE };
223
+ export default createRequest;