@instafy/cli 0.2.3 → 0.3.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 (53) hide show
  1. package/README.md +4 -0
  2. package/dist/bind/LICENSE +661 -0
  3. package/dist/bind/bin/instafy-bind.mjs +542 -0
  4. package/dist/bind/client/bind-client.js +1219 -0
  5. package/dist/bind/src/apply.mjs +293 -0
  6. package/dist/bind/src/bound.mjs +202 -0
  7. package/dist/bind/src/budget.mjs +58 -0
  8. package/dist/bind/src/check.mjs +176 -0
  9. package/dist/bind/src/config.mjs +169 -0
  10. package/dist/bind/src/controller.mjs +244 -0
  11. package/dist/bind/src/detached-env.mjs +27 -0
  12. package/dist/bind/src/executors/agent.mjs +336 -0
  13. package/dist/bind/src/executors/script.mjs +163 -0
  14. package/dist/bind/src/handler-kit.mjs +150 -0
  15. package/dist/bind/src/init.mjs +238 -0
  16. package/dist/bind/src/jobs.mjs +316 -0
  17. package/dist/bind/src/net.mjs +281 -0
  18. package/dist/bind/src/page-fetch.mjs +43 -0
  19. package/dist/bind/src/preview-host.mjs +114 -0
  20. package/dist/bind/src/protocol.mjs +103 -0
  21. package/dist/bind/src/records-schema.mjs +580 -0
  22. package/dist/bind/src/records.mjs +161 -0
  23. package/dist/bind/src/save-owner.mjs +492 -0
  24. package/dist/bind/src/schema-lite.mjs +212 -0
  25. package/dist/bind/src/server.mjs +908 -0
  26. package/dist/bind/src/sse.mjs +119 -0
  27. package/dist/bind/src/starters/blank/app.js +17 -0
  28. package/dist/bind/src/starters/blank/index.html +22 -0
  29. package/dist/bind/src/starters/checklist/app.js +171 -0
  30. package/dist/bind/src/starters/checklist/bound.json +29 -0
  31. package/dist/bind/src/starters/checklist/data.json +26 -0
  32. package/dist/bind/src/starters/checklist/index.html +23 -0
  33. package/dist/bind/src/starters/compare/app.js +316 -0
  34. package/dist/bind/src/starters/compare/bound.json +26 -0
  35. package/dist/bind/src/starters/compare/data.json +170 -0
  36. package/dist/bind/src/starters/compare/index.html +23 -0
  37. package/dist/bind/src/starters/handlers/read_offer.mjs +55 -0
  38. package/dist/bind/src/starters/handlers/set_check.mjs +41 -0
  39. package/dist/bind/src/starters/ranked/app.js +166 -0
  40. package/dist/bind/src/starters/ranked/bound.json +26 -0
  41. package/dist/bind/src/starters/ranked/data.json +142 -0
  42. package/dist/bind/src/starters/ranked/index.html +23 -0
  43. package/dist/bind/src/starters/site.js +888 -0
  44. package/dist/bind/src/starters/theme.css +492 -0
  45. package/dist/bind/src/starters/timeline/app.js +143 -0
  46. package/dist/bind/src/starters/timeline/data.json +41 -0
  47. package/dist/bind/src/starters/timeline/index.html +22 -0
  48. package/dist/bind/src/static.mjs +141 -0
  49. package/dist/bind/src/tokens.mjs +102 -0
  50. package/dist/bind/src/try.mjs +139 -0
  51. package/dist/bind/src/web.mjs +191 -0
  52. package/dist/cli.js +2261 -387
  53. package/package.json +3 -2
@@ -0,0 +1,1219 @@
1
+ /*!
2
+ * instafy-bind client (served by the binder at __bind/client.js).
3
+ * SPDX-License-Identifier: AGPL-3.0-only
4
+ *
5
+ * Load it with a script tag whose src is "__bind/client.js" (relative to the page), then:
6
+ * const { intents } = await instafyBind.ready;
7
+ * const id = instafyBind.intent("check_product", { url, size, ref }, { ref });
8
+ * instafyBind.on("records", ({ records }) => render(records));
9
+ * instafyBind.on("done", (msg) => { if (msg.id === id) ... });
10
+ *
11
+ * Events for on(type, fn): hello, ack, progress, results, done, error, command, records (store changed),
12
+ * intent (local), and "*" (fn(type, payload)). Each also fires as a window CustomEvent "bind:<type>".
13
+ * Transports in: SSE from the binder, postMessage from window.parent (source "instafy-host", only from the origins
14
+ * the binder lists in health.parentOrigins), and polling data/version.json while SSE is down. All are deduped.
15
+ * Record strings are data: render them with textContent.
16
+ *
17
+ * Ask Octo (agent intents): the binder never runs them for a viewer. Framed by a host that takes asks (Studio: layout
18
+ * caps.ask === true), they are in `names` like any intent and intent() hands the click to the host, which sends it as
19
+ * the clicker's own chat message: {source:"instafy-bind", v:1, type:"ask", payload:{id, intent, label, params, ref}}.
20
+ * The payload also carries the intent's `say` (bound.json's chat line) and `confirm: true` when it has them.
21
+ * The host answers {type:"ask-status", payload:{id, state: "confirm"|"sent"|"done"|"declined"|"failed", reason?}}
22
+ * ("confirm": it is asking the person first, so the click waits instead of timing out). When caps.ask
23
+ * is a string, this person may not send messages there: the asks are listed with `disabled: <that reason>` and a click
24
+ * says so. Anywhere else asks are not offered (the page hides them like any intent not in `names`).
25
+ *
26
+ * Location: `await instafyBind.location()` (or location({watch: true}, onPosition)). In a host that offers it (layout
27
+ * caps.location === "host") the host asks the browser once and shows which site got the position; a page of its own
28
+ * asks navigator.geolocation; a framed page with no such host rejects with code "unavailable" ("Location is not
29
+ * available in this preview yet."). Studio's side is not built yet.
30
+ *
31
+ * Follow mode (Studio's Present / Follow): framed by Studio, the page shares its view while Studio
32
+ * says "present": path, query, hash, the element with an id at the top of the window and the scroll position. A
33
+ * follower's copy applies it (history.replaceState + popstate, then scrolls there). Keep sort and filters in the URL
34
+ * query and give rows ids, and that is all. A page with more state: instafyBind.follow({ get: () => state,
35
+ * apply: (state) => show(state) }). instafyBindConfig.view = false turns it off.
36
+ *
37
+ * Studio's look: its layout message sets <html data-theme="light|dark"> (unless <html data-theme-fixed>) and
38
+ * data-host-title="shown" while Studio shows the page title; the starters' theme.css reads both.
39
+ *
40
+ * The viewer token arrives once as #k=<token>. The client trades it for an HttpOnly cookie (POST __bind/session),
41
+ * removes it from the address bar and never exposes it to other scripts.
42
+ */
43
+ (function () {
44
+ "use strict";
45
+ if (window.instafyBind && window.instafyBind.version) return;
46
+
47
+ var HOST_TYPES = ["hello", "ack", "progress", "results", "done", "error", "command"];
48
+ var LOW_TRUST_LANES = ["assist", "capture"];
49
+ var NO_LISTENER_MS = 5000;
50
+ var cfg = window.instafyBindConfig || {};
51
+ var script = document.currentScript;
52
+ var base = script && script.src ? script.src.replace(/client\.js(?:[?#].*)?$/, "") : new URL("__bind/", location.href).href;
53
+ var siteRoot = base.replace(/__bind\/$/, "");
54
+ var listeners = {};
55
+ var seen = new Set();
56
+ var seenOrder = [];
57
+ var store = new Map();
58
+ var pending = new Map();
59
+ var active = new Map(); // every viewer's running intents, from acks: id -> {name, ref, status}
60
+ var catalog = null;
61
+ var asks = []; // agent intents: asks the host sends as the clicker's message (catalog entries from the binder)
62
+ var askIds = new Set(); // ids of asks sent to the host, so only its answers about them count
63
+ var hostReady = false;
64
+ var sseOpen = false;
65
+ var parentOrigin = null;
66
+ var parentOrigins = null; // the origins that may host this page in a frame (the binder's list + this origin); null until known
67
+ var parentQueue = [];
68
+ var lastVersion = 0;
69
+ var hasToken = false;
70
+ var resolveReady;
71
+ var ready = new Promise(function (resolve) { resolveReady = resolve; });
72
+
73
+ // ---- token: #k=<token> in the share URL. Read once, then removed from the address bar.
74
+ function takeToken() {
75
+ var match = /(?:^#|[#&])k=([A-Za-z0-9_-]{16,128})/.exec(location.hash || "");
76
+ if (!match) return "";
77
+ try {
78
+ var rest = location.hash.replace(/^#/, "").split("&").filter(function (part) { return part.indexOf("k=") !== 0; }).join("&");
79
+ history.replaceState(history.state, "", location.pathname + location.search + (rest ? "#" + rest : ""));
80
+ } catch (e) { /* sandboxed frames may refuse */ }
81
+ return match[1];
82
+ }
83
+ var token = takeToken();
84
+ function authHeaders(extra) {
85
+ var headers = extra || {};
86
+ if (token) headers["x-bind-token"] = token;
87
+ return headers;
88
+ }
89
+
90
+ // ---- events
91
+ function emit(type, payload) {
92
+ (listeners[type] || []).slice().forEach(function (fn) {
93
+ try { fn(payload); } catch (e) { console.error("[instafy-bind] listener for " + type + " failed", e); }
94
+ });
95
+ (listeners["*"] || []).slice().forEach(function (fn) {
96
+ try { fn(type, payload); } catch (e) { console.error("[instafy-bind] * listener failed", e); }
97
+ });
98
+ try { window.dispatchEvent(new CustomEvent("bind:" + type, { detail: payload })); } catch (e) { /* old browsers */ }
99
+ }
100
+ function on(type, fn) {
101
+ (listeners[type] = listeners[type] || []).push(fn);
102
+ return function off() {
103
+ var list = listeners[type] || [];
104
+ var i = list.indexOf(fn);
105
+ if (i >= 0) list.splice(i, 1);
106
+ };
107
+ }
108
+
109
+ // ---- records store (instafy.records@1 merge rules, same as the binder)
110
+ function recordKey(r) {
111
+ if (r.type === "claim") return "c:" + r.cid;
112
+ if (r.type === "offer") return "o:" + r.oid;
113
+ if (r.type === "item") return "i:" + r.iid;
114
+ return null;
115
+ }
116
+ function observed(r) { return Date.parse((r && r.prov && r.prov.observed_at) || "") || 0; }
117
+ function laneOf(r) { return (r && r.prov && r.prov.lane) || null; }
118
+ function canReplace(prev, next) {
119
+ if (LOW_TRUST_LANES.indexOf(laneOf(next)) >= 0 && LOW_TRUST_LANES.indexOf(laneOf(prev)) < 0) return false;
120
+ return observed(next) >= observed(prev);
121
+ }
122
+ function mergeRecords(list) {
123
+ var changed = [];
124
+ (list || []).forEach(function (r) {
125
+ if (!r || typeof r !== "object") return;
126
+ var key = recordKey(r);
127
+ if (!key || !r.prov) return;
128
+ var prev = store.get(key);
129
+ if (prev) {
130
+ if (canReplace(prev, r) && JSON.stringify(prev) !== JSON.stringify(r)) { store.set(key, r); changed.push(r); }
131
+ return;
132
+ }
133
+ if (r.type === "claim") {
134
+ var older = null;
135
+ store.forEach(function (x, k) {
136
+ if (!older && x.type === "claim" && x.ref === r.ref && x.attr === r.attr && x.prov && x.prov.source === r.prov.source && laneOf(x) === laneOf(r)) older = k;
137
+ });
138
+ if (older) {
139
+ if (canReplace(store.get(older), r)) { store.delete(older); store.set(key, r); changed.push(r); }
140
+ return;
141
+ }
142
+ }
143
+ store.set(key, r);
144
+ changed.push(r);
145
+ });
146
+ return changed;
147
+ }
148
+ function allRecords(ref) {
149
+ var out = [];
150
+ store.forEach(function (r) { if (!ref || r.ref === ref) out.push(r); });
151
+ return out;
152
+ }
153
+ function ingestRecords(list, source) {
154
+ var changed = mergeRecords(list);
155
+ if (changed.length) emit("records", { records: allRecords(), changed: changed, source: source });
156
+ return changed;
157
+ }
158
+
159
+ // ---- host messages
160
+ function dedupeKey(m) {
161
+ switch (m.type) {
162
+ case "hello": return null;
163
+ case "ack": return "ack|" + m.id + "|" + m.status;
164
+ case "done": case "error": return m.type + "|" + m.id;
165
+ case "progress": return "progress|" + m.id + "|" + (m.at || "") + "|" + JSON.stringify(m.progress || {});
166
+ case "results": return "results|" + m.id + "|" + (m.at || "") + "|" + JSON.stringify(m.files || {}) + "|" + ((m.records || []).length);
167
+ case "command": return "command|" + (m.at || "") + "|" + JSON.stringify(m.command || {});
168
+ default: return null;
169
+ }
170
+ }
171
+ function remember(key) {
172
+ if (!key) return true;
173
+ if (seen.has(key)) return false;
174
+ seen.add(key);
175
+ seenOrder.push(key);
176
+ if (seenOrder.length > 2000) seen.delete(seenOrder.shift());
177
+ return true;
178
+ }
179
+ // Older binders report a coalesced job under its first intent id; re-address it to this page's own id.
180
+ function localize(m) {
181
+ if (!m.id || pending.has(m.id) || !Array.isArray(m.ids)) return m;
182
+ for (var i = 0; i < m.ids.length; i++) {
183
+ if (pending.has(m.ids[i])) { var copy = Object.assign({}, m); copy.id = m.ids[i]; return copy; }
184
+ }
185
+ return m;
186
+ }
187
+ // Built-in toast (Instafy look: charcoal, works on light and dark pages). Placement sits on the
188
+ // .instafy-bind-toast class so a host page's element rules (div { position: relative; padding: 0 })
189
+ // cannot break it; the look sits in :where() and reads --bind-toast-* custom properties, so a site
190
+ // re-skins it with those properties or with any .instafy-bind-toast rule of its own.
191
+ var TOAST_CSS = ".instafy-bind-toast{position:fixed;inset:auto auto max(24px,env(safe-area-inset-bottom)) 50%;" +
192
+ "transform:translateX(-50%);box-sizing:border-box;width:max-content;height:auto;max-width:min(calc(100vw - 32px),480px);" +
193
+ "margin:0;padding:var(--bind-toast-padding,10px 18px);z-index:2147483647;pointer-events:none}" +
194
+ ":where(.instafy-bind-toast){border-radius:var(--bind-toast-radius,16px);background:var(--bind-toast-bg,var(--if-toast-bg,#262626));" +
195
+ "color:var(--bind-toast-ink,var(--if-toast-fg,#fafafa));border:1px solid var(--bind-toast-border,rgb(255 255 255/.08));" +
196
+ "box-shadow:0 12px 32px -12px rgb(0 0 0/.45);" +
197
+ "font:500 14px/1.4 var(--bind-toast-font,var(--if-font,Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif))}";
198
+ var toastEl = null, toastTimer = null;
199
+ function showToast(text) {
200
+ if (!document.getElementById("instafy-bind-style")) {
201
+ var style = document.createElement("style");
202
+ style.id = "instafy-bind-style";
203
+ style.textContent = TOAST_CSS;
204
+ document.head.appendChild(style);
205
+ }
206
+ if (!toastEl) {
207
+ toastEl = document.createElement("div");
208
+ toastEl.className = "instafy-bind-toast";
209
+ toastEl.setAttribute("role", "status");
210
+ }
211
+ toastEl.textContent = String(text || "").slice(0, 200);
212
+ document.body.appendChild(toastEl);
213
+ clearTimeout(toastTimer);
214
+ toastTimer = setTimeout(function () { toastEl.remove(); }, 4000);
215
+ }
216
+ function handleHost(raw, via) {
217
+ if (!raw || raw.source !== "instafy-host" || raw.v !== 2 || HOST_TYPES.indexOf(raw.type) < 0) return;
218
+ var m = localize(raw);
219
+ if (!remember(dedupeKey(m))) return;
220
+ var ids = m.id ? [m.id] : [];
221
+ var myIntent = null;
222
+ ids.forEach(function (id) { if (pending.has(id)) myIntent = pending.get(id); });
223
+ if (m.type === "hello") {
224
+ if (m.capabilities && Array.isArray(m.capabilities.intents) && !catalog) {
225
+ catalog = m.capabilities.intents.map(function (name) { return { name: name }; });
226
+ finishReady({ connected: true, via: via });
227
+ }
228
+ } else if (m.type === "ack") {
229
+ ids.forEach(function (id) {
230
+ var p = pending.get(id);
231
+ if (m.status === "rejected") { pending.delete(id); active.delete(id); return; }
232
+ if (p) { p.status = m.status; p.job = m.job || p.job; if (p.timer) { clearTimeout(p.timer); p.timer = null; } }
233
+ if (m.name) active.set(id, { id: id, name: m.name, ref: m.ref || (p && p.ref) || null, status: m.status, mine: !!p });
234
+ });
235
+ } else if (m.type === "progress") {
236
+ ids.forEach(function (id) {
237
+ var p = pending.get(id);
238
+ if (p) { p.status = "running"; p.progress = m.progress; }
239
+ var a = active.get(id);
240
+ if (a) { a.status = "running"; a.progress = m.progress; }
241
+ });
242
+ } else if (m.type === "results") {
243
+ ingestRecords(m.records, via);
244
+ } else if (m.type === "done" || m.type === "error") {
245
+ ids.forEach(function (id) { pending.delete(id); active.delete(id); });
246
+ }
247
+ emit(m.type, m);
248
+ if (ids.length && (m.type === "ack" || m.type === "progress" || m.type === "done" || m.type === "error")) emitPending();
249
+ // A failed click must never look like nothing happened: when the page shows no errors itself, say so.
250
+ if (m.type === "error" && myIntent && cfg.builtins !== false && !(m.error && m.error.code === "cancelled") &&
251
+ !(listeners.error && listeners.error.length) && !(listeners["*"] && listeners["*"].length)) {
252
+ var why = (m.error && m.error.message) || m.message || "it failed";
253
+ showToast(String(myIntent.name).replace(/_/g, " ") + " did not work: " + String(why).slice(0, 140));
254
+ }
255
+ if (m.type === "command" && cfg.builtins !== false && m.command) {
256
+ if (m.command.name === "toast") showToast(m.command.args && m.command.args.text);
257
+ if (m.command.name === "reload") setTimeout(function () { location.reload(); }, 50);
258
+ }
259
+ }
260
+ // Anything a page draws from isPending (a "Checking…" button) must redraw when this fires: on send, ack,
261
+ // progress, done, error, a local reject and cancel. `records` alone fires before `done` clears the click.
262
+ function pendingList() {
263
+ var list = [];
264
+ var add = function (x) { if (!list.some(function (y) { return y.id === x.id; })) list.push({ id: x.id, name: x.name, ref: x.ref || null, status: x.status || null }); };
265
+ pending.forEach(add);
266
+ active.forEach(add);
267
+ return list;
268
+ }
269
+ function emitPending() { emit("pending", { pending: pendingList() }); }
270
+ function localAck(id, status, reason, extra) {
271
+ handleHost(Object.assign({ source: "instafy-host", v: 2, type: "ack", id: id, status: status, reason: reason }, extra || {}), "local");
272
+ }
273
+
274
+ // ---- intents out
275
+ /** n uniform base36 characters (bytes 252-255 are dropped, so no character is likelier than another) */
276
+ function rand36(n) {
277
+ var out = "";
278
+ while (out.length < n) {
279
+ var bytes = new Uint8Array(n * 2);
280
+ (window.crypto || {}).getRandomValues ? crypto.getRandomValues(bytes) : bytes.forEach(function (_, i) { bytes[i] = Math.random() * 256; });
281
+ bytes.forEach(function (b) { if (b < 252 && out.length < n) out += (b % 36).toString(36); });
282
+ }
283
+ return out;
284
+ }
285
+ function newId() {
286
+ return "int_" + Date.now().toString(36) + "_" + rand36(4);
287
+ }
288
+ function post(msg) {
289
+ return fetch(base + "intent", { method: "POST", headers: authHeaders({ "content-type": "application/json" }), body: JSON.stringify(msg), credentials: "same-origin" })
290
+ .then(function (res) {
291
+ return res.json().catch(function () { return {}; }).then(function (body) {
292
+ if (msg.name === "cancel") return;
293
+ // An agent intent the binder will not run for this viewer: it is an ask, which only the host can send.
294
+ if (res.status === 409 && body.reason === "ask_via_host") return askHost(msg);
295
+ if (res.status === 202 || res.status === 409) {
296
+ var p = pending.get(msg.id);
297
+ if (p && body.cancel_key) p.cancelKey = body.cancel_key;
298
+ handleHost({ source: "instafy-host", v: 2, type: "ack", id: msg.id, job: body.job, status: body.status || "queued" }, "http");
299
+ if (p && p.cancelRequested && pending.has(msg.id)) cancel(msg.id);
300
+ } else {
301
+ var reason = body.reason || (res.status === 401 ? "token" : res.status === 429 ? "cooldown" : "http " + res.status);
302
+ localAck(msg.id, "rejected", String(reason).slice(0, 140), { retry_after_s: body.retry_after_s });
303
+ }
304
+ });
305
+ })
306
+ .catch(function () { localAck(msg.id, "rejected", "offline"); });
307
+ }
308
+ function toParent(msg) {
309
+ // Only to a parent that said hello from an allowed origin; never to "*".
310
+ if (window.parent === window || !parentOrigin) return false;
311
+ try { window.parent.postMessage(msg, parentOrigin); return true; } catch (e) { return false; }
312
+ }
313
+ // ---- Ask Octo through the host (see the header)
314
+ var ASK_ELSEWHERE = "Ask Octo works when this page is open in Instafy.";
315
+ var ASK_NO_ANSWER = "Instafy did not answer. Try again, or ask in the chat.";
316
+ /** true: the host sends asks as the clicker's message; a string: it does not let this person send (why); else null. */
317
+ function askState() {
318
+ var a = hostLayout && embedded() ? hostLayout.caps.ask : null;
319
+ return a === true || typeof a === "string" ? a : null;
320
+ }
321
+ /** An agent intent: one of the asks, or one the binder listed as its own (the owner's local --agent-executor). */
322
+ function askDef(name) {
323
+ var list = asks.concat((catalog || []).filter(function (d) { return d.executor === "agent"; }));
324
+ for (var i = 0; i < list.length; i += 1) if (list[i].name === name) return list[i];
325
+ return null;
326
+ }
327
+ /** Asks are in the catalog only while a host takes them (or says why this person cannot). */
328
+ function syncAsks() {
329
+ if (!catalog) return;
330
+ var state = askState();
331
+ catalog = catalog.filter(function (d) { return d.via !== "host"; });
332
+ if (state === null) return;
333
+ asks.forEach(function (d) {
334
+ catalog.push(Object.assign({}, d, { via: "host" }, typeof state === "string" ? { disabled: state } : {}));
335
+ });
336
+ }
337
+ // An ask becomes a chat message everyone in the conversation reads, so the viewer's position goes rounded to about
338
+ // 100 m (3 decimals) without accuracy, altitude, heading or speed, and params the intent lists in private_params
339
+ // stay out. The same rules as the binder's (src/apply.mjs shareableParams, src/executors/agent.mjs coarsen).
340
+ var LOCATION_PARAM_RE = /^(lat|lng|lon|long|latitude|longitude|coords?|coordinates|position|geo|geolocation|location|accuracy|altitude|heading|near|near_me|my_location|user_location)$/i;
341
+ var POSITION_DETAIL_RE = /^(accuracy|altitude|heading|speed)$/i;
342
+ var COORD_PAIR_RE = /^\s*-?\d{1,3}\.\d+\s*,\s*-?\d{1,3}\.\d+\s*$/;
343
+ function looksLikePosition(v) {
344
+ if (typeof v === "string") return COORD_PAIR_RE.test(v);
345
+ if (!v || typeof v !== "object") return false;
346
+ if (Array.isArray(v)) return v.length >= 2 && v.length <= 3 && v.every(function (n) { return typeof n === "number"; }) && Math.abs(v[0]) <= 180 && Math.abs(v[1]) <= 180 && v.some(function (n) { return !Number.isInteger(n); });
347
+ return Object.keys(v).some(function (k) { return LOCATION_PARAM_RE.test(k); });
348
+ }
349
+ function coarse(n) { return Math.round(n * 1000) / 1000; }
350
+ function coarsen(v) {
351
+ if (typeof v === "number") return Number.isInteger(v) ? v : coarse(v);
352
+ if (typeof v === "string") return v.replace(/-?\d{1,3}\.\d+/g, function (m) { return String(coarse(Number(m))); });
353
+ if (Array.isArray(v)) return v.map(coarsen);
354
+ if (v && typeof v === "object") {
355
+ var out = {};
356
+ Object.keys(v).forEach(function (k) { if (!POSITION_DETAIL_RE.test(k)) out[k] = coarsen(v[k]); });
357
+ return out;
358
+ }
359
+ return v;
360
+ }
361
+ /** {params, withheld, coarsened}: what an ask may carry into the chat. */
362
+ function askParams(params, def) {
363
+ var declared = Array.isArray(def.private_params) ? def.private_params : [];
364
+ var out = {}, withheld = [], coarsened = [];
365
+ Object.keys(params && typeof params === "object" ? params : {}).forEach(function (k) {
366
+ var v = params[k];
367
+ if (declared.indexOf(k) >= 0 || POSITION_DETAIL_RE.test(k)) { withheld.push(k); return; }
368
+ if (LOCATION_PARAM_RE.test(k) || looksLikePosition(v)) { out[k] = coarsen(v); coarsened.push(k); return; }
369
+ out[k] = v;
370
+ });
371
+ return { params: out, withheld: withheld, coarsened: coarsened };
372
+ }
373
+ function askHost(msg) {
374
+ var def = askDef(msg.name);
375
+ var state = askState();
376
+ if (!def || state === null) return localAck(msg.id, "rejected", ASK_ELSEWHERE);
377
+ if (typeof state === "string") return localAck(msg.id, "rejected", state);
378
+ var shared = askParams(msg.params, def);
379
+ var payload = { id: msg.id, intent: msg.name, label: String(def.label || msg.name).slice(0, 120), params: shared.params, ref: msg.ref };
380
+ // bound.json's own words for the chat line ("Check size {size}") and whether the host confirms first
381
+ if (typeof def.say === "string" && def.say) payload.say = def.say.slice(0, 120);
382
+ if (def.confirm === true) payload.confirm = true;
383
+ if (shared.withheld.length) payload.withheld = shared.withheld;
384
+ if (shared.coarsened.length) payload.coarsened = shared.coarsened;
385
+ askIds.add(msg.id);
386
+ if (!toHost("ask", payload)) { askIds.delete(msg.id); return localAck(msg.id, "rejected", ASK_ELSEWHERE); }
387
+ var p = pending.get(msg.id);
388
+ if (p) p.timer = setTimeout(function () { if (pending.has(msg.id) && pending.get(msg.id).status === "sending") { askIds.delete(msg.id); localAck(msg.id, "rejected", ASK_NO_ANSWER); } }, NO_LISTENER_MS);
389
+ }
390
+ function onAskStatus(p) {
391
+ var id = typeof p.id === "string" ? p.id : null;
392
+ if (!id || !askIds.has(id)) return;
393
+ var reason = typeof p.reason === "string" && p.reason ? p.reason.slice(0, 140) : null;
394
+ var base2 = { source: "instafy-host", v: 2, id: id, name: (pending.get(id) || {}).name };
395
+ if (p.state === "sent") handleHost(Object.assign({ type: "ack", status: "sent" }, base2), "host");
396
+ // the host shows its own confirm first (the intent says confirm: true): the click waits for the person there
397
+ else if (p.state === "confirm") handleHost(Object.assign({ type: "ack", status: "needs_confirm" }, base2), "host");
398
+ // the ask's run ended: Octo answers through the site's records files, so fetch them fresh for this page
399
+ else if (p.state === "done") { askIds.delete(id); handleHost(Object.assign({ type: "done", summary: reason }, base2), "host"); if (hostReady) loadRecords(); }
400
+ else if (p.state === "declined") { askIds.delete(id); localAck(id, "rejected", reason || "Not sent."); }
401
+ else if (p.state === "failed") { askIds.delete(id); handleHost(Object.assign({ type: "error", error: { code: "ask_failed", message: reason || "Octo could not answer." } }, base2), "host"); }
402
+ }
403
+
404
+ function intent(name, params, opts) {
405
+ opts = opts || {};
406
+ if (catalog && name !== "cancel" && !catalog.some(function (i) { return i.name === name; })) {
407
+ throw new Error("instafy-bind: intent \"" + name + "\" is not available on this site");
408
+ }
409
+ var isAsk = name !== "cancel" && asks.some(function (d) { return d.name === name; }) &&
410
+ !(catalog || []).some(function (i) { return i.name === name && i.via !== "host"; });
411
+ var msg = {
412
+ source: "instafy-site", v: 2, type: "intent", id: newId(), name: name, params: params || {},
413
+ ui: opts.ui == null ? null : String(opts.ui), ref: opts.ref == null ? null : String(opts.ref),
414
+ site: { title: String(document.title || "").slice(0, 120), path: location.pathname.slice(0, 300) },
415
+ at: new Date().toISOString()
416
+ };
417
+ if (opts.attr) msg.attr = String(opts.attr);
418
+ if (opts.state && typeof opts.state === "object") msg.state = opts.state;
419
+ if (name !== "cancel") pending.set(msg.id, { id: msg.id, name: name, params: msg.params, ref: msg.ref || (params && typeof params.ref === "string" ? params.ref : null), status: "sending", at: msg.at });
420
+ emit("intent", msg);
421
+ if (name !== "cancel") emitPending();
422
+ // An ask never goes to the binder or to a v2 parent: only the host that takes asks may send it, as the clicker.
423
+ if (isAsk) { askHost(msg); return msg.id; }
424
+ var sentToParent = toParent(msg);
425
+ ready.then(function () {
426
+ if (hostReady) post(msg);
427
+ else if (sentToParent && name !== "cancel") {
428
+ // Embedded without a binder: the parent must answer, or the click is cleared (spec: 5 s).
429
+ var p = pending.get(msg.id);
430
+ if (p) p.timer = setTimeout(function () { if (pending.has(msg.id) && pending.get(msg.id).status === "sending") localAck(msg.id, "rejected", "No host answered this page."); }, NO_LISTENER_MS);
431
+ } else if (name !== "cancel") {
432
+ localAck(msg.id, "rejected", "No binder is connected to this page. Run instafy-bind serve.");
433
+ }
434
+ });
435
+ return msg.id;
436
+ }
437
+ function cancel(id) {
438
+ var p = pending.get(id);
439
+ // Cancelled before the binder answered: send the cancel once the cancel key arrives.
440
+ if (p && !p.cancelKey && p.status === "sending" && hostReady) { p.cancelRequested = true; return null; }
441
+ return intent("cancel", { id: id, key: (p && p.cancelKey) || "" });
442
+ }
443
+
444
+ // ---- helpers pages keep re-writing
445
+ function isPending(ref, name) {
446
+ var hit = false;
447
+ var match = function (x) { if ((!ref || x.ref === ref) && (!name || x.name === name)) hit = true; };
448
+ pending.forEach(match);
449
+ active.forEach(match);
450
+ return hit;
451
+ }
452
+ function ageSeconds(record) { var at = observed(record); return at ? Math.max(0, Math.round((Date.now() - at) / 1000)) : null; }
453
+ function ageText(record) {
454
+ var s = ageSeconds(record);
455
+ if (s == null) return "";
456
+ if (s < 60) return "just now";
457
+ if (s < 3600) return Math.round(s / 60) + " min ago";
458
+ if (s < 86400) return Math.round(s / 3600) + " h ago";
459
+ return Math.round(s / 86400) + " d ago";
460
+ }
461
+ function expired(record) {
462
+ var ttl = record && record.prov && record.prov.ttl_s;
463
+ return typeof ttl === "number" && ttl > 0 && observed(record) + ttl * 1000 < Date.now();
464
+ }
465
+ /**
466
+ * Run the intent that produced a record again, with the same params (prov.intent). Returns the new id, or null
467
+ * when there is nothing to re-send or the binder withheld params (a viewer's position): ask for them again.
468
+ */
469
+ function retry(record, opts) {
470
+ var src = record && record.prov && record.prov.intent;
471
+ if (!src || !src.name) return null;
472
+ if (Array.isArray(src.withheld) && src.withheld.length) return null;
473
+ var params = Object.assign({}, src.params || {});
474
+ return intent(src.name, params, Object.assign({ ref: typeof params.ref === "string" ? params.ref : record.ref || null }, opts || {}));
475
+ }
476
+
477
+ // ---- connection
478
+ function finishReady(extra) {
479
+ var list = (catalog || []).slice();
480
+ list.forEach(function (def) { Object.defineProperty(list, def.name, { value: def, enumerable: false, configurable: true }); });
481
+ var byName = {};
482
+ list.forEach(function (def) { byName[def.name] = def; });
483
+ resolveReady(Object.assign({ intents: list, byName: byName, names: list.map(function (d) { return d.name; }) }, extra));
484
+ }
485
+ function getJson(url) {
486
+ return fetch(url, { cache: "no-store", headers: authHeaders(), credentials: "same-origin" }).then(function (r) { return r.ok ? r.json() : null; });
487
+ }
488
+ function loadRecords() {
489
+ return getJson(base + "records").then(function (body) {
490
+ if (!body) return;
491
+ if (typeof body.v === "number") lastVersion = Math.max(lastVersion, body.v);
492
+ ingestRecords(body.records, "load");
493
+ }).catch(function () {});
494
+ }
495
+ function loadStaticRecords() {
496
+ // No binder (a static snapshot): load the records files bound.json and version.json name.
497
+ var files = {};
498
+ return Promise.all([
499
+ getJson(siteRoot + "bound.json").catch(function () { return null; }),
500
+ getJson(siteRoot + "data/version.json").catch(function () { return null; })
501
+ ]).then(function (both) {
502
+ var bound = both[0], version = both[1];
503
+ if (bound && Array.isArray(bound.parentOrigins)) setParentOrigins(bound.parentOrigins);
504
+ if (bound && bound.results && Array.isArray(bound.results.files)) bound.results.files.forEach(function (f) { files[f] = 1; });
505
+ if (bound && bound.intents) Object.keys(bound.intents).forEach(function (n) { var t = bound.intents[n].target; files[(t && t.records) || "data/records/" + n + ".json"] = 1; });
506
+ if (version && version.files) Object.keys(version.files).forEach(function (f) { files[f] = 1; });
507
+ return Promise.all(Object.keys(files).filter(function (f) { return /^data\/records\/[A-Za-z0-9_.-]+\.json$/.test(f); }).map(function (f) {
508
+ return getJson(siteRoot + f).then(function (body) {
509
+ if (body && body.schema === "instafy.records@1") ingestRecords(body.records, "static");
510
+ }).catch(function () {});
511
+ }));
512
+ });
513
+ }
514
+ function openEvents(queryToken) {
515
+ if (typeof EventSource !== "function") return;
516
+ // The session cookie authenticates the stream; ?k= only when the browser refused the cookie.
517
+ var es = new EventSource(base + "events" + (queryToken ? "?k=" + encodeURIComponent(queryToken) : ""), { withCredentials: false });
518
+ HOST_TYPES.forEach(function (type) {
519
+ es.addEventListener(type, function (e) {
520
+ try { handleHost(JSON.parse(e.data), "sse"); } catch (err) { /* malformed frame */ }
521
+ });
522
+ });
523
+ es.onopen = function () { sseOpen = true; };
524
+ es.onerror = function () { sseOpen = false; };
525
+ api._events = es;
526
+ }
527
+ function startSession() {
528
+ if (!token) return Promise.resolve(false);
529
+ return fetch(base + "session", { method: "POST", headers: authHeaders(), credentials: "same-origin" })
530
+ .then(function (r) { return r.ok; })
531
+ .catch(function () { return false; });
532
+ }
533
+ function poll() {
534
+ var delay = pending.size ? 3000 : 30000;
535
+ setTimeout(function () {
536
+ if (sseOpen || document.hidden || !hostReady) return poll();
537
+ getJson(siteRoot + "data/version.json").then(function (v) {
538
+ if (!v) return;
539
+ if (v.jobs) Object.keys(v.jobs).forEach(function (id) {
540
+ if (pending.has(id) && (v.jobs[id] === "done" || v.jobs[id] === "error")) {
541
+ handleHost({ source: "instafy-host", v: 2, type: v.jobs[id], id: id, error: v.jobs[id] === "error" ? { code: "upstream", message: "See the binder log" } : undefined }, "poll");
542
+ }
543
+ });
544
+ if (typeof v.v === "number" && v.v > lastVersion) { lastVersion = v.v; return loadRecords(); }
545
+ }).catch(function () {}).then(poll);
546
+ }, delay);
547
+ }
548
+
549
+ // ---- parent frame: only the origins the binder lists (bound.json parentOrigins, and in a preview the Studio that
550
+ // opened it), else this page's own origin. Nobody else, however it frames the page, is its host.
551
+ function setParentOrigins(list) {
552
+ if (parentOrigins) return;
553
+ parentOrigins = (Array.isArray(list) ? list : []).filter(function (o) { return typeof o === "string" && /^https?:\/\/[^\s\/]+$/.test(o); });
554
+ if (parentOrigins.indexOf(location.origin) < 0) parentOrigins.push(location.origin);
555
+ var queued = parentQueue;
556
+ parentQueue = [];
557
+ queued.forEach(onParentMessage);
558
+ }
559
+ function onParentMessage(e) {
560
+ if (!parentOrigins) { if (parentQueue.length < 50) parentQueue.push(e); return; }
561
+ if (e.data.v === 1) return onHostV1(e);
562
+ if (parentOrigins.indexOf(e.origin) < 0) return;
563
+ var data = e.data;
564
+ if (data.type === "hello") parentOrigin = e.origin;
565
+ handleHost(data, "parent");
566
+ }
567
+ window.addEventListener("message", function (e) {
568
+ if (window.parent === window || e.source !== window.parent) return;
569
+ var data = e.data;
570
+ if (!data || data.source !== "instafy-host") return;
571
+ onParentMessage(e);
572
+ });
573
+
574
+ // ---- location. Inside Studio only Studio calls the browser's geolocation: one prompt for Instafy, and Studio
575
+ // shows which site got the position. A host that says so in its layout (caps.location === "host") answers
576
+ // {source:"instafy-bind", v:1, type:"location-request", payload:{id, watch?, highAccuracy?}} with "location",
577
+ // "location-error" and "location-stop" messages; anywhere else the page asks navigator.geolocation itself.
578
+ var LOCATION_MESSAGES = {
579
+ denied: "Location is blocked. Allow it in the browser's settings, then try again.",
580
+ unavailable: "Your location is not available right now.",
581
+ timeout: "Finding your location took too long. Try again.",
582
+ stopped: "Location is off for this site · turn it on in Studio's menu for this preview.",
583
+ framed: "Location is not available in this preview yet.",
584
+ hidden: "This page is not on screen, so it gets no location."
585
+ };
586
+ var LAYOUT_WAIT_MS = 2000; // framed with ?instafy_host=: how long location() waits for the host's first layout
587
+ var HOST_ANSWER_MS = 90000; // a one-shot request the host never answers (the person may be reading a prompt)
588
+ var hostQuery = /[?&]instafy_host=[a-z]/.test(location.search || "");
589
+ var hostLayout = null; // the last layout from the host frame (v1), {caps, insets, chrome, title, theme}
590
+ var hostOrigin = null; // the origin that sent the first layout keeps the frame
591
+ var layoutWaiters = [];
592
+ var locationRequests = new Map(); // id -> {position(p), fail(code), end()}
593
+
594
+ function embedded() { try { return window.parent !== window; } catch (e) { return true; } }
595
+ function hostV1OriginOk(origin) {
596
+ if (!origin || origin === "null" || !parentOrigins) return false;
597
+ if (hostOrigin) return origin === hostOrigin;
598
+ if (parentOrigins.indexOf(origin) < 0) return false;
599
+ // where the browser names the frame's parent (ancestorOrigins), it must be the sender too
600
+ var anc = location.ancestorOrigins;
601
+ return !(anc && anc.length && anc[0] && anc[0] !== "null" && anc[0] !== origin);
602
+ }
603
+ // Studio answered "denied": Instafy itself may not use location, and Studio shows where to change that.
604
+ var HOST_DENIED_MESSAGE = "Location is blocked for Instafy. Studio shows how to turn it on.";
605
+ function locationError(code, fromHost) {
606
+ if (!LOCATION_MESSAGES[code]) code = "unavailable";
607
+ var text = fromHost && code === "denied" ? HOST_DENIED_MESSAGE : LOCATION_MESSAGES[code];
608
+ // framed by a page that offers no location: the browser would refuse the frame, and its "allow it in your
609
+ // settings" would send the person to fix the wrong thing. Pages see code "unavailable".
610
+ if (code === "framed") code = "unavailable";
611
+ var err = new Error(text);
612
+ err.name = "LocationError";
613
+ err.code = code;
614
+ return err;
615
+ }
616
+ function num(v, min, max) { return typeof v === "number" && isFinite(v) && v >= min && v <= max ? v : null; }
617
+ function readPosition(raw, via) {
618
+ var c = raw && raw.coords;
619
+ if (!c || typeof c !== "object") return null;
620
+ var lat = num(c.latitude, -90, 90), lng = num(c.longitude, -180, 180);
621
+ if (lat == null || lng == null) return null;
622
+ lastFix = { lat: lat, lng: lng, at: Date.now() };
623
+ return {
624
+ coords: {
625
+ latitude: lat, longitude: lng, accuracy: num(c.accuracy, 0, 1e7),
626
+ altitude: num(c.altitude, -1e5, 1e6), heading: num(c.heading, 0, 360), speed: num(c.speed, 0, 1e4)
627
+ },
628
+ timestamp: num(raw.timestamp, 0, 1e15) || Date.now(),
629
+ via: via
630
+ };
631
+ }
632
+ function onHostV1(e) {
633
+ var d = e.data;
634
+ if (!hostV1OriginOk(e.origin)) return;
635
+ if (d.type === "layout") {
636
+ hostOrigin = e.origin;
637
+ var caps = d.caps && typeof d.caps === "object" ? d.caps : {};
638
+ hostLayout = {
639
+ caps: {
640
+ geolocation: caps.geolocation === true, location: caps.location === "host" ? "host" : null,
641
+ ask: caps.ask === true ? true : typeof caps.ask === "string" && caps.ask.trim() ? caps.ask.trim().slice(0, 140) : null
642
+ },
643
+ insets: d.insets && typeof d.insets === "object" ? d.insets : null,
644
+ chrome: d.chrome === "host" ? "host" : null,
645
+ title: d.title === "shown" || d.title === "hidden" ? d.title : null,
646
+ theme: d.theme === "light" || d.theme === "dark" ? d.theme : null
647
+ };
648
+ applyHostLook(hostLayout);
649
+ syncAsks();
650
+ var waiting = layoutWaiters;
651
+ layoutWaiters = [];
652
+ waiting.forEach(function (fn) { fn(); });
653
+ emit("layout", hostLayout);
654
+ // follow mode: tell the host this page can share and apply its view. No "insets": Studio keeps padding the frame.
655
+ if (viewOn) toHost("layout-ack", { v: 1, supports: ["view", "tap"] });
656
+ return;
657
+ }
658
+ if (d.type === "present" || d.type === "apply-view" || d.type === "apply-tap") return onFollow(d);
659
+ if (d.type === "ask-status") return onAskStatus(d.payload && typeof d.payload === "object" ? d.payload : {});
660
+ // location answers: hostV1OriginOk held them to the origin that sent the layout, and ids are only ever sent there
661
+ var p = d.payload && typeof d.payload === "object" ? d.payload : {};
662
+ var req = typeof p.id === "string" ? locationRequests.get(p.id) : null;
663
+ if (!req) return;
664
+ if (d.type === "location") {
665
+ var pos = readPosition(p, "host");
666
+ if (pos) req.position(pos);
667
+ } else if (d.type === "location-error") {
668
+ req.fail(typeof p.code === "string" ? p.code : "unavailable", true);
669
+ } else if (d.type === "location-stop") {
670
+ req.end();
671
+ }
672
+ }
673
+ // Studio's look on <html>: data-theme="light|dark" (theme.css reads it; a page with data-theme-fixed keeps its own)
674
+ // and data-host-title="shown" while Studio's bar shows the page title (theme.css then hides the page's own h1).
675
+ function applyHostLook(l) {
676
+ var root = document.documentElement;
677
+ if (!root || typeof root.setAttribute !== "function") return;
678
+ if (l.theme && !root.hasAttribute("data-theme-fixed")) root.setAttribute("data-theme", l.theme);
679
+ if (l.title === "shown") root.setAttribute("data-host-title", "shown");
680
+ else if (l.title === "hidden") root.removeAttribute("data-host-title");
681
+ }
682
+ function toHost(type, payload) {
683
+ if (!hostOrigin || !embedded()) return false;
684
+ try { window.parent.postMessage({ source: "instafy-bind", v: 1, type: type, payload: payload }, hostOrigin); return true; } catch (e) { return false; }
685
+ }
686
+ /**
687
+ * "host" when the frame's host answers location requests; "browser" on a page of its own, or in a frame whose host
688
+ * says the frame may ask the browser (caps.geolocation); else "framed" (no host offers it here). Waits briefly for a
689
+ * host's first layout.
690
+ */
691
+ function locationRoute() {
692
+ function decide() {
693
+ if (!embedded()) return "browser";
694
+ if (hostLayout && hostLayout.caps.location === "host") return "host";
695
+ return hostLayout && hostLayout.caps.geolocation ? "browser" : "framed";
696
+ }
697
+ if (hostLayout || !embedded() || !hostQuery) return Promise.resolve(decide());
698
+ return new Promise(function (resolve) {
699
+ var done = function () { clearTimeout(timer); resolve(decide()); };
700
+ var timer = setTimeout(function () {
701
+ var i = layoutWaiters.indexOf(done);
702
+ if (i >= 0) layoutWaiters.splice(i, 1);
703
+ resolve(decide());
704
+ }, LAYOUT_WAIT_MS);
705
+ layoutWaiters.push(done);
706
+ });
707
+ }
708
+ function locationId() {
709
+ return "loc_" + rand36(6);
710
+ }
711
+ function browserCode(err) { return err && err.code === 1 ? "denied" : err && err.code === 3 ? "timeout" : "unavailable"; }
712
+ function browserPosition(p) {
713
+ var c = p && p.coords;
714
+ return c ? readPosition({ coords: { latitude: c.latitude, longitude: c.longitude, accuracy: c.accuracy, altitude: c.altitude, heading: c.heading, speed: c.speed }, timestamp: p.timestamp }, "browser") : null;
715
+ }
716
+ function geoOptions(opts) {
717
+ var o = { enableHighAccuracy: !!opts.highAccuracy };
718
+ if (typeof opts.timeout === "number") o.timeout = opts.timeout;
719
+ if (typeof opts.maximumAge === "number") o.maximumAge = opts.maximumAge;
720
+ return o;
721
+ }
722
+ function geo() { return (window.navigator || {}).geolocation || null; }
723
+
724
+ function locateOnce(opts) {
725
+ return locationRoute().then(function (via) {
726
+ return new Promise(function (resolve, reject) {
727
+ if (via === "framed") return reject(locationError("framed"));
728
+ if (via === "browser") {
729
+ var g = geo();
730
+ if (!g) return reject(locationError("unavailable"));
731
+ return g.getCurrentPosition(function (p) {
732
+ var pos = browserPosition(p);
733
+ pos ? resolve(pos) : reject(locationError("unavailable"));
734
+ }, function (err) { reject(locationError(browserCode(err))); }, geoOptions(opts));
735
+ }
736
+ var id = locationId();
737
+ var finish = function () { clearTimeout(timer); locationRequests.delete(id); };
738
+ var timer = setTimeout(function () { finish(); toHost("location-stop", { id: id }); reject(locationError("timeout")); },
739
+ typeof opts.timeout === "number" ? opts.timeout : HOST_ANSWER_MS);
740
+ locationRequests.set(id, {
741
+ position: function (pos) { finish(); resolve(pos); },
742
+ fail: function (code, fromHost) { finish(); reject(locationError(code, fromHost)); },
743
+ end: function () { finish(); reject(locationError("stopped")); }
744
+ });
745
+ var payload = { id: id };
746
+ if (opts.highAccuracy) payload.highAccuracy = true;
747
+ if (!toHost("location-request", payload)) { finish(); reject(locationError("unavailable")); }
748
+ });
749
+ });
750
+ }
751
+
752
+ /** A watch: callbacks, and an async iterator of positions (the latest unread one is kept). */
753
+ function watchLocation(opts, onPosition, onError) {
754
+ var stopped = false, ended = false, via = null, geoWatch = null, id = null;
755
+ var latest = null, failure = null, waiter = null, located = false;
756
+ function settle() {
757
+ if (!waiter) return;
758
+ var w = waiter;
759
+ if (latest) { waiter = null; var p = latest; latest = null; w.resolve({ value: p, done: false }); }
760
+ else if (failure) { waiter = null; var err = failure; failure = null; w.reject(err); }
761
+ else if (ended) { waiter = null; w.resolve({ value: undefined, done: true }); }
762
+ }
763
+ function position(pos) {
764
+ if (ended) return;
765
+ located = true;
766
+ latest = pos;
767
+ if (onPosition) { try { onPosition(pos); } catch (e) { console.error("[instafy-bind] location callback failed", e); } }
768
+ settle();
769
+ }
770
+ function fail(code, fromHost) {
771
+ if (ended) return;
772
+ var err = locationError(code, fromHost);
773
+ if (onError) { try { onError(err); } catch (e) { console.error("[instafy-bind] location error callback failed", e); } }
774
+ // every error the page hears ends the watch (Studio's broker sends only final ones); a page that wants to
775
+ // retry calls location() again
776
+ failure = err;
777
+ if (geoWatch != null) { var g = geo(); if (g) g.clearWatch(geoWatch); geoWatch = null; }
778
+ end();
779
+ }
780
+ function end() {
781
+ if (ended) return;
782
+ ended = true;
783
+ if (id) locationRequests.delete(id);
784
+ settle();
785
+ }
786
+ var handle = {
787
+ get via() { return via; },
788
+ get active() { return !ended; },
789
+ stop: function () {
790
+ if (stopped) return;
791
+ stopped = true;
792
+ if (via === "host" && id && !ended) toHost("location-stop", { id: id });
793
+ if (geoWatch != null) { var g = geo(); if (g) g.clearWatch(geoWatch); geoWatch = null; }
794
+ end();
795
+ },
796
+ next: function () {
797
+ return new Promise(function (resolve, reject) { waiter = { resolve: resolve, reject: reject }; settle(); });
798
+ },
799
+ "return": function () { handle.stop(); return Promise.resolve({ value: undefined, done: true }); }
800
+ };
801
+ if (typeof Symbol === "function" && Symbol.asyncIterator) handle[Symbol.asyncIterator] = function () { return handle; };
802
+ locationRoute().then(function (route) {
803
+ if (stopped) return;
804
+ via = route;
805
+ if (route === "framed") { fail("framed"); return; }
806
+ if (route === "browser") {
807
+ var g = geo();
808
+ if (!g) { fail("unavailable"); return; }
809
+ geoWatch = g.watchPosition(function (p) { var pos = browserPosition(p); if (pos) position(pos); },
810
+ function (err) {
811
+ // the browser keeps a watch running after "unavailable" or "timeout" (a moved position, a weak signal):
812
+ // once it has found the person, only a refusal ends it
813
+ var code = browserCode(err);
814
+ if (located && code !== "denied") return;
815
+ fail(code);
816
+ }, geoOptions(opts));
817
+ return;
818
+ }
819
+ id = locationId();
820
+ locationRequests.set(id, { position: position, fail: fail, end: end });
821
+ var payload = { id: id, watch: true };
822
+ if (opts.highAccuracy) payload.highAccuracy = true;
823
+ if (!toHost("location-request", payload)) fail("unavailable");
824
+ });
825
+ return handle;
826
+ }
827
+
828
+ /**
829
+ * The viewer's position. location({highAccuracy?, timeout?}) → Promise<{coords, timestamp, via}>, rejecting with an
830
+ * Error whose code is denied | unavailable | timeout | stopped | hidden and whose message is fit to show.
831
+ * location({watch: true, onPosition?, onError?}) (or location({watch: true}, onPosition, onError)) → a handle with
832
+ * stop(), via and active, that is also an async iterator: `for await (const pos of watch) …`. Any error ends a watch
833
+ * (the iterator rejects once); Studio pauses it, silently, while the page is hidden.
834
+ */
835
+ function locate(opts, onPosition, onError) {
836
+ opts = opts && typeof opts === "object" ? opts : {};
837
+ if (opts.watch) return watchLocation(opts, typeof opts.onPosition === "function" ? opts.onPosition : onPosition, typeof opts.onError === "function" ? opts.onError : onError);
838
+ return locateOnce(opts);
839
+ }
840
+ window.addEventListener("pagehide", function () {
841
+ locationRequests.forEach(function (_, id) { toHost("location-stop", { id: id }); });
842
+ });
843
+
844
+
845
+ // ---- follow mode. Studio → page: present {on, share:{location}}, apply-view {seq, payload, from,
846
+ // smooth}, apply-tap {payload, from}; page → Studio: view {seq, payload} (≤ 2 KB, ≤ 10/s, the latest wins, with a
847
+ // trailing send), tap {seq, payload:{x, y, at}}, interact (the person's own wheel, touch or keys). Only between the
848
+ // page and the host that sent the layout; a presenting page never applies views, so nothing echoes.
849
+ var VIEW_MAX_BYTES = 2048, VIEW_GAP_MS = 100, VIEW_POLL_MS = 500, TAP_GAP_MS = 200, INTERACT_GAP_MS = 300, QUIET_MS = 400;
850
+ var HOST_PARAMS = ["instafy_host"];
851
+ var viewOn = cfg.view !== false;
852
+ var presenting = false, shareLocation = false, viewSeq = 0, lastView = null, lastViewAt = -Infinity, viewTimer = null, viewPoll = null;
853
+ var following = false, touchedAt = -Infinity, lastInteract = -Infinity, lastTap = -Infinity;
854
+ var custom = { get: null, apply: null, auto: true, locationFree: false };
855
+ // the position location() handed this page: kept out of views unless the presenter shares it, for as long as this
856
+ // page lives (it may still hold it in its URL or state), not for a fixed time
857
+ var lastFix = null;
858
+ function now() { return Date.now(); }
859
+ function bytes(o) { try { return new TextEncoder().encode(JSON.stringify(o)).length; } catch (e) { return Infinity; } }
860
+ function later(fn) { if (typeof requestAnimationFrame === "function") requestAnimationFrame(function () { requestAnimationFrame(fn); }); else setTimeout(fn, 32); }
861
+ function sendFollow(msg) {
862
+ if (!hostOrigin || !embedded()) return false;
863
+ try { window.parent.postMessage(msg, hostOrigin); return true; } catch (e) { return false; }
864
+ }
865
+ /** the query's parts, as written (no re-encoding) */
866
+ function queryParts(search) { return String(search || "").replace(/^\?/, "").split("&").filter(Boolean); }
867
+ function isHostPart(part) { var k = part.split("=")[0]; try { k = decodeURIComponent(k); } catch (e) { /* */ } return HOST_PARAMS.indexOf(k) >= 0; }
868
+ /** the query without the host's own markers (?instafy_host=studio): every viewer has those */
869
+ function cleanSearch(search) {
870
+ var parts = queryParts(search).filter(function (x) { return !isHostPart(x); });
871
+ return parts.length ? "?" + parts.join("&") : "";
872
+ }
873
+ /** a presenter's query with this frame's own host markers kept */
874
+ function withHostParams(search) {
875
+ var parts = queryParts(cleanSearch(search)).concat(queryParts(location.search).filter(isHostPart));
876
+ return parts.length ? "?" + parts.join("&") : "";
877
+ }
878
+ function scrollMax() {
879
+ var d = document.documentElement || {}, b = document.body || {};
880
+ return Math.max(0, Math.max(d.scrollHeight || 0, b.scrollHeight || 0) - (window.innerHeight || 0));
881
+ }
882
+ function scrollTop() { return window.scrollY || window.pageYOffset || 0; }
883
+ /** fixed or sticky, itself or inside such an element: it sits in the same place whatever the scroll, so it can't say where the page is */
884
+ function pinned(el) {
885
+ if (typeof window.getComputedStyle !== "function") return false;
886
+ for (var n = el, i = 0; n && i < 40 && n !== document.body && n !== document.documentElement; n = n.parentElement, i++) {
887
+ var pos = "";
888
+ try { pos = window.getComputedStyle(n).position; } catch (e) { /* */ }
889
+ if (pos === "sticky" || pos === "fixed") return true;
890
+ }
891
+ return false;
892
+ }
893
+ /** the nearest element (the anchor itself or one around it) that scrolls sideways: a wide table on a phone */
894
+ function sideScroller(el) {
895
+ if (!el || typeof window.getComputedStyle !== "function") return null;
896
+ for (var n = el, i = 0; n && i < 40 && n !== document.body && n !== document.documentElement; n = n.parentElement, i++) {
897
+ if (!(n.scrollWidth > n.clientWidth + 1)) continue;
898
+ var ox = "";
899
+ try { ox = window.getComputedStyle(n).overflowX; } catch (e) { /* */ }
900
+ if (ox === "auto" || ox === "scroll") return n;
901
+ }
902
+ return null;
903
+ }
904
+ /**
905
+ * The element with an id at the top of the window (a row, a section) and where the window's top is in its heights:
906
+ * 0..1 inside it, negative when it starts below the top. Sticky and fixed elements (a toolbar with a filter) never
907
+ * count. At the very top of the page there is no anchor: the scroll position (0) says it all.
908
+ */
909
+ function anchorNow() {
910
+ if (scrollTop() < 1) return null;
911
+ var list = document.querySelectorAll ? document.querySelectorAll("[id]") : [];
912
+ var H = window.innerHeight || 0, cross = [], below = [];
913
+ for (var i = 0; i < list.length && i < 3000; i++) {
914
+ var el = list[i];
915
+ if (!el.id || el === document.documentElement || el === document.body || /^instafy-bind/.test(el.id)) continue;
916
+ var r = el.getBoundingClientRect();
917
+ if (!r || !r.height || r.bottom <= 0 || r.top >= H) continue;
918
+ (r.top <= 0 ? cross : below).push({ el: el, r: r });
919
+ }
920
+ cross.sort(function (x, y) { return x.r.height - y.r.height; });
921
+ below.sort(function (x, y) { return x.r.top - y.r.top; });
922
+ var all = cross.concat(below);
923
+ for (var j = 0; j < all.length && j < 50; j++) {
924
+ var hit = all[j];
925
+ if (pinned(hit.el)) continue;
926
+ var o = Math.max(-1000, Math.min(1, -hit.r.top / hit.r.height));
927
+ return { el: hit.el, a: String(hit.el.id).slice(0, 100), o: Math.round(o * 1000) / 1000 };
928
+ }
929
+ return null;
930
+ }
931
+ function numbersIn(text) { return (String(text).match(/-?\d{1,3}\.\d{3,}/g) || []).map(Number); }
932
+ /** does the payload carry the position this page was given (lat and lng to 4 decimals, about 11 m)? */
933
+ function hasFix(p) {
934
+ if (shareLocation || !lastFix) return false;
935
+ var n = numbersIn(JSON.stringify(p));
936
+ var near = function (v) { return n.some(function (x) { return Math.abs(x - v) < 0.0001; }); };
937
+ return near(lastFix.lat) && near(lastFix.lng);
938
+ }
939
+ function viewNow() {
940
+ var p = { k: "page" };
941
+ if (custom.auto) {
942
+ var h = String(location.hash || "");
943
+ p.path = String(location.pathname || "/");
944
+ p.q = cleanSearch(location.search);
945
+ p.h = h.length > 1 ? h : "";
946
+ var a = anchorNow();
947
+ if (a) {
948
+ p.a = a.a; p.o = a.o;
949
+ var side = sideScroller(a.el), wide = side ? side.scrollWidth - side.clientWidth : 0;
950
+ if (wide > 0) p.sx = Math.round(Math.min(1, Math.max(0, side.scrollLeft / wide)) * 10000) / 10000;
951
+ }
952
+ var max = scrollMax();
953
+ p.sy = max ? Math.round(Math.min(1, Math.max(0, scrollTop() / max)) * 10000) / 10000 : 0;
954
+ }
955
+ if (custom.get) {
956
+ try { var s = custom.get(); if (s !== undefined) p.s = JSON.parse(JSON.stringify(s)); } catch (e) { console.warn("[instafy-bind] follow get() failed", e); }
957
+ }
958
+ // the viewer's own position stays home. Once this page was given one, the address and the page's own state may hold
959
+ // it in any form (rounded, a geohash, custom state), so they go unless the presenter shares location or the page
960
+ // said its view never holds it (follow({locationFree: true})); the scroll position stays
961
+ if (lastFix && !shareLocation && !custom.locationFree) {
962
+ if (p.q || p.h || "s" in p) p.withheld = ["location"];
963
+ delete p.q; delete p.h; delete p.s;
964
+ }
965
+ if (hasFix(p)) { delete p.q; delete p.h; delete p.s; p.withheld = ["location"]; if (hasFix(p)) return null; }
966
+ // ≤ 2 KB: the hash first, then the query, then the page's own state
967
+ ["h", "q", "s"].forEach(function (k) { if (bytes(p) > VIEW_MAX_BYTES && k in p) { delete p[k]; p.trimmed = true; } });
968
+ return bytes(p) <= VIEW_MAX_BYTES ? p : null;
969
+ }
970
+ function flushView() {
971
+ if (!presenting) return false;
972
+ if (viewTimer) { clearTimeout(viewTimer); viewTimer = null; }
973
+ var p = viewNow();
974
+ if (!p) return false;
975
+ var key = JSON.stringify(p);
976
+ if (key === lastView) return false;
977
+ lastView = key; lastViewAt = now();
978
+ return sendFollow({ source: "instafy-bind", v: 1, type: "view", seq: ++viewSeq, payload: p });
979
+ }
980
+ /** the view may have changed: send it, at most every 100 ms (the latest wins, with a trailing send) */
981
+ function viewChanged() {
982
+ if (!presenting || viewTimer) return;
983
+ var wait = VIEW_GAP_MS - (now() - lastViewAt);
984
+ if (wait <= 0) { flushView(); return; }
985
+ viewTimer = setTimeout(function () { viewTimer = null; flushView(); }, wait);
986
+ }
987
+ function stopPresenting() {
988
+ presenting = false; lastView = null;
989
+ if (viewTimer) { clearTimeout(viewTimer); viewTimer = null; }
990
+ if (viewPoll) { clearInterval(viewPoll); viewPoll = null; }
991
+ }
992
+ function onFollow(d) {
993
+ if (!viewOn) return;
994
+ if (d.type === "present") {
995
+ if (d.on === true) {
996
+ presenting = true; following = false; lastView = null;
997
+ shareLocation = !!(d.share && d.share.location === true);
998
+ // a page that changes its query with replaceState fires no event: look again twice a second
999
+ if (!viewPoll) viewPoll = setInterval(viewChanged, VIEW_POLL_MS);
1000
+ flushView();
1001
+ } else stopPresenting();
1002
+ emit("present", { on: presenting, share: { location: shareLocation } });
1003
+ return;
1004
+ }
1005
+ if (presenting) return; // a presenter never follows, so nothing echoes
1006
+ var from = d.from && typeof d.from === "object" && typeof d.from.name === "string" ? { name: d.from.name.replace(/[\u0000-\u001f\u007f]+/g, " ").trim().slice(0, 40) } : null;
1007
+ if (d.type === "apply-view") {
1008
+ var smooth = d.smooth !== false;
1009
+ if (smooth && now() - touchedAt < QUIET_MS) return; // one already in flight must not yank the person's own scroll
1010
+ var p = d.payload;
1011
+ if (!p || typeof p !== "object" || p.k !== "page" || bytes(p) > VIEW_MAX_BYTES) return;
1012
+ following = true;
1013
+ applyView(p, smooth, from);
1014
+ return;
1015
+ }
1016
+ if (d.type === "apply-tap") showRipple(d.payload, from);
1017
+ }
1018
+ /**
1019
+ * A presenter's path, only as a path of this same site: anything that would leave it (//host, /\\host, a tab or
1020
+ * newline the URL parser drops, a scheme) keeps the follower where it is.
1021
+ */
1022
+ function samePath(path) {
1023
+ if (typeof path !== "string" || path.length > 1024 || path.charAt(0) !== "/" || path.charAt(1) === "/" || /[\\\u0000-\u001f\u007f]/.test(path)) return location.pathname;
1024
+ try {
1025
+ var u = new URL(path, location.href);
1026
+ return u.origin === location.origin ? u.pathname : location.pathname;
1027
+ } catch (e) { return location.pathname; }
1028
+ }
1029
+ function applyView(p, smooth, from) {
1030
+ if (typeof p.path === "string" || typeof p.q === "string" || typeof p.h === "string") {
1031
+ var path = samePath(p.path);
1032
+ // the presenter's address: no query or hash in the view means none
1033
+ var q = withHostParams(typeof p.q === "string" ? p.q : "");
1034
+ var h = typeof p.h === "string" && p.h ? (p.h.charAt(0) === "#" ? p.h : "#" + p.h) : "";
1035
+ if (path + q + h !== location.pathname + location.search + location.hash) {
1036
+ // another page of a multi-page site: go there (the next view finds it); a single-page app re-renders on popstate
1037
+ if (path !== location.pathname && !custom.apply) {
1038
+ try { var to = new URL(path + q + h, location.href); if (to.origin === location.origin) location.replace(to.pathname + to.search + to.hash); } catch (e) { /* */ }
1039
+ return;
1040
+ }
1041
+ var hashOnly = path === location.pathname && q === location.search;
1042
+ try {
1043
+ history.replaceState(history.state, "", path + q + h);
1044
+ window.dispatchEvent(new PopStateEvent("popstate", { state: history.state }));
1045
+ if (hashOnly) window.dispatchEvent(new HashChangeEvent("hashchange"));
1046
+ } catch (e) { console.warn("[instafy-bind] apply view:", e && e.message); }
1047
+ }
1048
+ }
1049
+ if (custom.apply && "s" in p) { try { custom.apply(p.s, { from: from, smooth: smooth }); } catch (e) { console.warn("[instafy-bind] follow apply() failed", e); } }
1050
+ emit("view", { payload: p, from: from, smooth: smooth });
1051
+ if (p.a == null && typeof p.sy !== "number") return;
1052
+ // after the page re-rendered the new query: the same row at the top, else the same share of the page
1053
+ later(function () {
1054
+ var el = typeof p.a === "string" && document.getElementById ? document.getElementById(p.a) : null, top = null;
1055
+ if (el) { var r = el.getBoundingClientRect(); top = scrollTop() + r.top + (typeof p.o === "number" ? p.o : 0) * r.height; }
1056
+ else if (typeof p.sy === "number") top = p.sy * scrollMax();
1057
+ if (top != null && typeof window.scrollTo === "function") window.scrollTo({ top: Math.max(0, Math.round(top)), behavior: smooth ? "smooth" : "auto" });
1058
+ // the same columns of a wide table that scrolls sideways
1059
+ var side = el && typeof p.sx === "number" ? sideScroller(el) : null, wide = side ? side.scrollWidth - side.clientWidth : 0;
1060
+ if (wide > 0) side.scrollLeft = Math.round(Math.min(1, Math.max(0, p.sx)) * wide);
1061
+ });
1062
+ }
1063
+ var RIPPLE_CSS = ".instafy-bind-ripple{position:fixed;width:0;height:0;pointer-events:none;z-index:2147483646}" +
1064
+ ".instafy-bind-ripple i{position:absolute;left:-22px;top:-22px;width:44px;height:44px;box-sizing:border-box;border-radius:50%;" +
1065
+ "border:3px solid var(--bind-ripple,#2563eb);animation:instafy-bind-ripple 1.2s ease-out forwards}" +
1066
+ ".instafy-bind-ripple span{position:absolute;left:0;top:26px;transform:translateX(-50%);white-space:nowrap;padding:2px 8px;" +
1067
+ "border-radius:999px;background:var(--bind-ripple,#2563eb);color:#fff;font:600 12px/1.4 system-ui,sans-serif}" +
1068
+ "@keyframes instafy-bind-ripple{from{transform:scale(.35);opacity:1}to{transform:scale(1.6);opacity:0}}" +
1069
+ "@media (prefers-reduced-motion:reduce){.instafy-bind-ripple i{animation:none;opacity:.8}}";
1070
+ /** the presenter tapped: a ring with their name for 1.2 s, on the same element when this page has it */
1071
+ function showRipple(t, from) {
1072
+ if (!t || typeof t !== "object" || !document.createElement) return;
1073
+ var x = null, y = null;
1074
+ var at = t.at && typeof t.at === "object" ? t.at : null;
1075
+ var el = at && typeof at.id === "string" && document.getElementById ? document.getElementById(at.id) : null;
1076
+ if (el) { var r = el.getBoundingClientRect(); x = r.left + (num(at.fx, 0, 1) || 0) * r.width; y = r.top + (num(at.fy, 0, 1) || 0) * r.height; }
1077
+ else if (num(t.x, 0, 1) != null && num(t.y, 0, 1) != null) { x = t.x * (window.innerWidth || 0); y = t.y * (window.innerHeight || 0); }
1078
+ if (x == null) return;
1079
+ if (!document.getElementById("instafy-bind-ripple-style")) {
1080
+ var style = document.createElement("style");
1081
+ style.id = "instafy-bind-ripple-style";
1082
+ style.textContent = RIPPLE_CSS;
1083
+ document.head.appendChild(style);
1084
+ }
1085
+ var ring = document.createElement("div");
1086
+ ring.className = "instafy-bind-ripple";
1087
+ ring.setAttribute("aria-hidden", "true");
1088
+ ring.style.left = Math.round(x) + "px"; ring.style.top = Math.round(y) + "px";
1089
+ ring.appendChild(document.createElement("i"));
1090
+ if (from && from.name) { var label = document.createElement("span"); label.textContent = from.name; ring.appendChild(label); }
1091
+ document.body.appendChild(ring);
1092
+ setTimeout(function () { ring.remove(); }, 1200);
1093
+ }
1094
+ function ownGesture(e) {
1095
+ if (!e || e.isTrusted === false) return false;
1096
+ if (e.type === "keydown") return !(e.metaKey || e.ctrlKey || e.altKey) && ["Shift", "Control", "Alt", "Meta", "F6", "Tab", "CapsLock"].indexOf(e.key) < 0;
1097
+ return true;
1098
+ }
1099
+ function onGesture(e) {
1100
+ if (!ownGesture(e)) return;
1101
+ var t = now();
1102
+ touchedAt = t;
1103
+ if (!following || presenting || t - lastInteract < INTERACT_GAP_MS) return;
1104
+ lastInteract = t;
1105
+ sendFollow({ source: "instafy-bind", v: 1, type: "interact" });
1106
+ }
1107
+ function onTap(e) {
1108
+ if (!presenting || !e || e.isTrusted === false || typeof e.clientX !== "number") return;
1109
+ var t = now();
1110
+ if (t - lastTap < TAP_GAP_MS) return;
1111
+ var target = e.target && e.target.closest ? e.target : null;
1112
+ // a site marks what shows the viewer's own position with data-instafy-private: a tap there is not shared
1113
+ if (target && !shareLocation && target.closest("[data-instafy-private]")) return;
1114
+ var W = window.innerWidth || 1, H = window.innerHeight || 1, r3 = function (v) { return Math.round(Math.min(1, Math.max(0, v)) * 1000) / 1000; };
1115
+ var payload = { x: r3(e.clientX / W), y: r3(e.clientY / H) };
1116
+ var host = target && target.closest("[id]");
1117
+ if (host && host !== document.body && host !== document.documentElement) {
1118
+ var r = host.getBoundingClientRect();
1119
+ if (r && r.width && r.height) payload.at = { id: String(host.id).slice(0, 100), fx: r3((e.clientX - r.left) / r.width), fy: r3((e.clientY - r.top) / r.height) };
1120
+ }
1121
+ lastTap = t;
1122
+ sendFollow({ source: "instafy-bind", v: 1, type: "tap", seq: ++viewSeq, payload: payload });
1123
+ }
1124
+ if (viewOn && document.addEventListener) {
1125
+ ["pointerdown", "wheel", "touchstart", "keydown"].forEach(function (type) { document.addEventListener(type, onGesture, { capture: true, passive: true }); });
1126
+ ["pointermove", "touchmove"].forEach(function (type) {
1127
+ document.addEventListener(type, function (e) { if (e.isTrusted !== false && (e.type === "touchmove" || e.buttons)) touchedAt = now(); }, { capture: true, passive: true });
1128
+ });
1129
+ document.addEventListener("click", onTap, { capture: true, passive: true });
1130
+ ["resize", "popstate", "hashchange"].forEach(function (type) { window.addEventListener(type, viewChanged, { passive: true }); });
1131
+ // capture: a table scrolled sideways scrolls an element, and element scrolls don't bubble
1132
+ window.addEventListener("scroll", viewChanged, { passive: true, capture: true });
1133
+ }
1134
+ /**
1135
+ * Share more than the address and scroll position: get() returns this page's own view state (JSON, small), apply(state,
1136
+ * {from, smooth}) shows a presenter's. {auto: false} leaves out the address and scroll. Returns {changed(), stop()}.
1137
+ */
1138
+ function follow(o) {
1139
+ o = o || {};
1140
+ custom.get = typeof o.get === "function" ? o.get : null;
1141
+ custom.apply = typeof o.apply === "function" ? o.apply : null;
1142
+ custom.auto = o.auto !== false;
1143
+ custom.locationFree = o.locationFree === true;
1144
+ viewChanged();
1145
+ return { changed: viewChanged, stop: function () { custom.get = null; custom.apply = null; custom.auto = true; custom.locationFree = false; } };
1146
+ }
1147
+
1148
+ var api = {
1149
+ version: "0.2.0",
1150
+ ready: ready,
1151
+ base: base,
1152
+ intent: intent,
1153
+ cancel: cancel,
1154
+ on: on,
1155
+ records: function (ref) { return allRecords(ref); },
1156
+ pending: function () { return Array.from(pending.keys()); },
1157
+ pendingInfo: function (id) { var p = pending.get(id) || active.get(id); return p ? Object.assign({}, p, { cancelKey: undefined, timer: undefined }) : null; },
1158
+ /** Running intents of every viewer: [{id, name, ref, status, mine}]. */
1159
+ active: function () { return Array.from(active.values()).map(function (a) { return Object.assign({}, a); }); },
1160
+ isPending: isPending,
1161
+ age: ageSeconds,
1162
+ ageText: ageText,
1163
+ expired: expired,
1164
+ retry: retry,
1165
+ /** The binder's job list (debug view). */
1166
+ jobs: function () { return getJson(base + "jobs").then(function (b) { return (b && b.jobs) || []; }); },
1167
+ ingest: function (msg) { handleHost(msg, "api"); },
1168
+ /** The viewer's position, through the host frame when it offers that (Studio), else navigator.geolocation. */
1169
+ location: locate,
1170
+ /** The host frame's last layout ({caps, insets, chrome, title, theme}), or null when no host sent one. */
1171
+ get hostLayout() { return hostLayout ? JSON.parse(JSON.stringify(hostLayout)) : null; },
1172
+ /** The intents this binder runs, like `(await ready).names`; [] until the binder answers. Pages often read it directly. */
1173
+ get names() { return (catalog || []).map(function (d) { return d.name; }); },
1174
+ get connected() { return hostReady; },
1175
+ get hasToken() { return hasToken; },
1176
+ /** Follow mode: instafyBind.follow({get, apply, auto?}) for a page's own view state (see the header). */
1177
+ follow: follow,
1178
+ /** The same as parts: view.share(get), view.onApply(apply), view.changed() (send now), presenting / following. */
1179
+ view: {
1180
+ share: function (fn) { custom.get = typeof fn === "function" ? fn : null; viewChanged(); },
1181
+ onApply: function (fn) { custom.apply = typeof fn === "function" ? fn : null; },
1182
+ changed: function () { viewChanged(); },
1183
+ get presenting() { return presenting; },
1184
+ get following() { return following; }
1185
+ }
1186
+ };
1187
+ window.instafyBind = api;
1188
+
1189
+ fetch(base + "health", { cache: "no-store", headers: authHeaders(), credentials: "same-origin" })
1190
+ .then(function (r) { if (!r.ok) throw new Error("no binder"); return r.json(); })
1191
+ .then(function (health) {
1192
+ hostReady = true;
1193
+ setParentOrigins(health.parentOrigins);
1194
+ catalog = Array.isArray(health.catalog) ? health.catalog : (health.intents || []).map(function (name) { return { name: name }; });
1195
+ asks = (Array.isArray(health.asks) ? health.asks : []).filter(function (d) { return d && typeof d.name === "string"; });
1196
+ // Framed by a host (?instafy_host=): its first layout says whether it takes asks, so wait for it briefly.
1197
+ var layoutFirst = asks.length && embedded() && hostQuery && !hostLayout
1198
+ ? new Promise(function (resolve) {
1199
+ var done = function () { clearTimeout(timer); resolve(); };
1200
+ var timer = setTimeout(function () { var i = layoutWaiters.indexOf(done); if (i >= 0) layoutWaiters.splice(i, 1); resolve(); }, LAYOUT_WAIT_MS);
1201
+ layoutWaiters.push(done);
1202
+ })
1203
+ : null;
1204
+ return Promise.all([startSession(), layoutFirst]).then(function (both) {
1205
+ var cookieSet = both[0];
1206
+ syncAsks();
1207
+ hasToken = !health.token || cookieSet || !!health.viewer;
1208
+ finishReady({ connected: true, host: health.host, site: health.site, executors: health.executors, tokenRequired: !!health.token, hasToken: hasToken });
1209
+ openEvents(token && !cookieSet ? token : "");
1210
+ return loadRecords();
1211
+ });
1212
+ })
1213
+ .catch(function () {
1214
+ loadStaticRecords().then(function () { setParentOrigins([]); });
1215
+ // Without a binder, a parent hello (Studio pane) can still resolve ready; otherwise resolve as offline.
1216
+ setTimeout(function () { if (!catalog) { catalog = []; finishReady({ connected: false }); } }, 1500);
1217
+ })
1218
+ .then(poll);
1219
+ })();