PageSourceSearch

https://jagdishsweets.com/src/js/modules/realtimeOrderUpdates.js?v=2

js jagdishsweets.com collected 2026-10-02 01:33:26 UTC 12,714 bytes, 267 lines download raw bytes

1/**
2 * realtimeOrderUpdates.js — push-driven "refresh now" for the past-order tracking
3 * page, in place of waiting up to 30s for the status poll.
4 *
5 * Hands the raw event to the caller and interprets nothing: the vendor speaks
6 * 'PREPARED', this codebase numeric codes, and pastOrders.js owns that mapping.
7 * All SDK knowledge stays in this file.
8 *
9 * Fails silent, always — the 30s poll in pastOrders.js is the fallback for every
10 * failure below, and a page that renders beats a push that arrives.
11 *
12 * Auth: backend mints a short-lived, ORDER-SCOPED token and ships it inline on
13 * the client/cart response (`realtimeToken` / `realtimeChannel` /
14 * `realtimeTokenExpiresIn`). The predecessor put a business-issuer token in the
15 * page; it was not tenant-scoped — verified 2026-09-08, ours returned 200 for
16 * orders in tenants 49859 and 6353 — and order ids are sequential, so devtools
17 * bought any brand's orders. Don't reintroduce a page-held business token.
18 *
19 * Doc: docs/Realtime-Order-Updates-SDK.md
20 *
21 * Cache: nginx holds /src/js for 30 days and nested imports are unversioned, so
22 * a later edit here can serve stale. Bump the ?v= on pastOrders.js's import when
23 * a change must land hard.
24 */
25
26// 2.0.0 is required, not housekeeping: 1.0.0 has no `toChannelPath`, so it put
27// backend's bare channel id ("48427947") into the subscribe frame and AppSync
28// answered UnauthorizedException. 2.0.0 maps a bare name to `/channels/<name>`.
29// 3.0.0 is what the app uses, but the browser CDN 403s on it (2026-09-21).
30const SDK_URL = 'https://cdn.platform.uengage.io/browser-sdk/2.0.0/realtime.min.js';
31// Update in lockstep with SDK_URL's version or the script is blocked outright.
32const SDK_INTEGRITY = 'sha384-VbzEY+ZQo51Y1Mizk5Wiu9jg7hqZkRXZMaRddaD6DSbG1X6VS9LM5Xe+RldpmuYi';
33
34const API_BASE    = (window.AppConfig?.apiBase || '').replace(/\/$/, '');
35const ACTIVE_PATH = '/client/getActiveOrders';
36
37let sdkPromise = null;
38
39function cfg() {
40    return window.AppConfig?.realtime || {};
41}
42
43/** The brand opt-out flag, and nothing else — there is no per-environment config. */
44export function isRealtimeConfigured() {
45    return !!cfg().enabled;
46}
47
48// The SDK cannot be handed a token: its three auth modes are tokenUrl,
49// client_credentials (a secret in the page: never) and none. But `fetchFn` is a
50// documented option and the tokenUrl mint goes through it, so we point it at a
51// URL that never leaves the page and answer that request ourselves.
52const TOKEN_SENTINEL = 'https://realtime.invalid/token';
53
54/**
55 * cfg + this order's credential → SDK createClient() options.
56 * `cred` may be the credential object or a getter returning the current one.
57 */
58export function buildClientOptions(c, cred) {
59    const opts = { transport: 'auto', tokenUrl: TOKEN_SENTINEL };
60    if (c.env) opts.env = c.env;
61
62    const poll = parseInt(c.pollIntervalMs, 10);
63    if (!isNaN(poll) && poll > 0) opts.pollIntervalMs = poll;
64
65    // Answer the mint ourselves; delegate every other request to the real fetch.
66    // `expires_in` is SECONDS and backend reports REMAINING life, not the original
67    // duration — pass it straight through.
68    opts.fetchFn = function (input, init) {
69        const url = typeof input === 'string' ? input : (input && input.url);
70        if (url !== TOKEN_SENTINEL) return fetch(input, init);
71        // Resolved per call, never captured: `cred` is a getter into the caller's
72        // live credential, which a later cart read replaces wholesale.
73        const now = typeof cred === 'function' ? cred() : cred;
74        if (!now || !now.token) {
75            return Promise.resolve(new Response(
76                JSON.stringify({ error: 'no realtime credential for this order' }),
77                { status: 401, headers: { 'Content-Type': 'application/json' } }
78            ));
79        }
80        return Promise.resolve(new Response(
81            JSON.stringify({ token: now.token, expires_in: now.expiresIn }),
82            { status: 200, headers: { 'Content-Type': 'application/json' } }
83        ));
84    };
85
86    return opts;
87}
88
89// ── SDK loader ────────────────────────────────────────────────────────
90// Same shape as payment.js's Razorpay loader: promise-cached, with a
91// querySelector guard against a duplicate <script>.
92
93function loadSdk() {
94    if (sdkPromise) return sdkPromise;
95    sdkPromise = new Promise((resolve, reject) => {
96        if (window.Uengage?.realtime) { resolve(); return; }
97
98        const existing = document.querySelector(`script[src="${SDK_URL}"]`);
99        if (existing) {
100            existing.addEventListener('load',  () => resolve());
101            existing.addEventListener('error', () => reject(new Error('Realtime SDK failed to load')));
102            return;
103        }
104
105        const script = document.createElement('script');
106        script.src   = SDK_URL;
107        script.async = true;
108        script.integrity   = SDK_INTEGRITY;
109        script.crossOrigin = 'anonymous';
110        script.onload  = () => resolve();
111        script.onerror = () => {
112            // Clear the memo, or loadSdk()'s `if (sdkPromise) return sdkPromise`
113            // hands back this rejected promise forever and realtime stays dead for
114            // the session even once connectivity returns.
115            sdkPromise = null;
116            reject(new Error('Realtime SDK failed to load'));
117        };
118        document.head.appendChild(script);
119    });
120    return sdkPromise;
121}
122
123// ── Active-order gate ─────────────────────────────────────────────────
124
125function getUser() {
126    for (const key of ['userdata', 'authState']) {
127        try {
128            const raw = localStorage.getItem(key);
129            if (raw && raw !== 'null') {
130                const obj = JSON.parse(raw);
131                if (obj && (obj.contactMappingId || obj.contact_mapping_id)) return obj;
132            }
133        } catch { /* corrupt entry — try the next key */ }
134    }
135    return null;
136}
137
138/**
139 * Is this order still live? Gating on it means a delivered order never opens a
140 * socket it would immediately close. Fails CLOSED: a wrong "yes" costs a stuck
141 * socket, a wrong "no" costs only latency we already tolerate.
142 *
143 * The fetch + localStorage read are duplicated from activeOrders.js deliberately
144 * — adding exports to an already-cached module has broken pages here before.
145 */
146async function isOrderActive(orderId) {
147    const user = getUser();
148    if (!user || !orderId) return false;
149
150    const params = new URLSearchParams({
151        contactMappingId: String(user.contactMappingId ?? user.contact_mapping_id ?? ''),
152        token:            String(user.token ?? ''),
153        // This endpoint wants `mobileNo`; userdata stores it as `mobile`.
154        mobileNo:         String(user.mobile ?? user.mobileNo ?? ''),
155        // Parent id, never the outlet id — AppConfig.businessId IS the parent.
156        parentBusinessId: String(window.AppConfig?.businessId ?? ''),
157    });
158
159    const res = await fetch(`${API_BASE}${ACTIVE_PATH}?${params}`, {
160        headers: { token: user.token || '', Authorization: 'Bearer ' + (user.token || '') },
161    });
162    if (!res.ok) return false;
163
164    const json   = await res.json();
165    const orders = Array.isArray(json?.active_orders) ? json.active_orders : [];
166    const wanted = String(orderId);
167
168    return orders.some((o) => String(o.orderId ?? o.order_id ?? o.id ?? '') === wanted);
169}
170
171// ── Subscription ──────────────────────────────────────────────────────
172
173/**
174 * Subscribe to one order's push updates. Returns unsubscribe SYNCHRONOUSLY even
175 * though the work behind it is async; calling it before that settles cancels the
176 * chain. onSignal(event) receives the raw platform event.
177 */
178export function subscribeToOrder(orderId, onSignal, opts) {
179    if (!isRealtimeConfigured() || !orderId || typeof onSignal !== 'function') {
180        // Every bail logs its reason: a socket that never opened is otherwise
181        // indistinguishable from one that opened and received nothing.
182        console.debug('[realtime] not started — brand flag off or bad arguments');
183        return function noop() {};
184    }
185
186    let cancelled = false;
187    let teardown  = null;
188
189    // Promise.resolve().then() so a synchronous throw inside createClient() lands
190    // in the same .catch() as an async rejection — one failure path, not tw
190o.
191    Promise.resolve()
192        // Prefer the caller's assertion: pastOrders.js already holds the order and
193        // has passed its own isTerminalOrder() check. It also dodges a real
194        // fragility — getActiveOrders filters on `mobileNo` while userdata stores
195        // `mobile`, so a stale one returns an empty list for a live order and would
196        // switch the SDK off for exactly the orders it exists to serve.
197        .then(() => (opts && typeof opts.active === 'boolean' ? opts.active : isOrderActive(orderId)))
198        .then((active) => {
199            if (!active || cancelled) {
200                if (!active) console.debug('[realtime] order is not active — no socket');
201                return null;
202            }
203            return loadSdk();
204        })
205        .then((loaded) => {
206            if (loaded === null || cancelled) return;
207
208            const create = window.Uengage?.realtime?.createClient;
209            if (typeof create !== 'function') {
210                console.warn('[realtime] SDK loaded but createClient is missing');
211                return;
212            }
213
214            // The order's own credential, off the client/cart response. Without it
215            // there is nothing to authorise with, and the poll carries the page.
216            // `credentials` may be a getter. It has to be, for the re-mint to work:
217            // the caller replaces its credential object on every poll, so a value
218            // captured here would pin fetchFn to the ORIGINAL expires_in — and since
219            // backend reports REMAINING life, that is the largest figure it will ever
220            // send. The SDK would schedule the re-mint off it, ask after the token had
221            // actually lapsed, take a 401, mark the error permanent and tear down.
222            const credSrc  = opts && opts.credentials;
223            const credNow  = typeof credSrc === 'function' ? credSrc() : credSrc;
224            if (!credNow || !credNow.token || !credNow.channel) {
225                console.warn('[realtime] no credential for this order — poll continues',
226                             { token: !!(credNow && credNow.token), channel: credNow && credNow.channel });
227                return;
228            }
229            console.debug('[realtime] subscribing to', credNow.channel);
230
231            // The channel is fixed for the order's life, so it is read once. Only the
232            // token needs to stay live, which is why the SOURCE goes to fetchFn.
233            const cred = credNow;
234            const client = create(buildClientOptions(cfg(), credSrc));
235            // The server's channel string, VERBATIM — never constructed here. A
236            // mismatch is silent: valid token, healthy socket, no events, no error.
237            const sub = client.subscribe(cred.channel, {
238                onUpdate: (event) => { if (!cancelled) onSignal(event); },
239                // `permanent` means retrying cannot help. Tear down so the SDK stops
240                // polling a route that will keep saying no.
241                onError: (err) => {
242                    if (err?.permanent) {
243                        console.warn('[realtime] disabled (' + (err.code || 'permanent') + '), poll continues', err);
244                        if (teardown) { teardown(); teardown = null; }
245                        return;
246                    }
247                    console.warn('[realtime] transient error, SDK retrying', err);
248                },
249            });
250
251            teardown = () => {
252                try { sub?.unsubscribe?.(); } catch { /* already gone */ }
253                try { client?.close?.(); }    catch { /* already gone */ }
254            };
255
256            // No refresh timer: the SDK re-mints through fetchFn before the token
257            // lapses, reading whichever credential the latest cart read left behind.
258
259            if (cancelled) teardown();
260        })
261        .catch((err) => console.warn('[realtime] unavailable, poll continues', err));
262
263    return function unsubscribe() {
264        cancelled = true;
265        if (teardown) { teardown(); teardown = null; }
266    };
267}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.