studio-engine 0.74.12 → 0.75.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.
@@ -0,0 +1,647 @@
1
+ // studio/session.js — the browser half of the session-drift primitive.
2
+ // docs/SESSION_DRIFT.md is the contract; this header is the map.
3
+ //
4
+ // WHAT IT DOES. Every page rendered by a Studio::ErrorHandling controller carries
5
+ // a stamp in <meta name="studio-session">: the session's state, its fingerprint,
6
+ // when it was issued and when it lapses, where to rehydrate it, and the
7
+ // identities the host bound it to. This store reads that stamp and keeps asking
8
+ // one question: does this page still describe the browser's session?
9
+ //
10
+ // THE STATES (SessionContext::STATES on the server):
11
+ // anonymous nobody is signed in. First-class, not an error.
12
+ // authenticated somebody is signed in and the page still describes them.
13
+ // stale the page learned its stamp is out of date; a rehydrate is due.
14
+ // changed an identity source observes someone other than the bound identity.
15
+ // rehydrated the page pulled the server's session in place and is signed in.
16
+ // signed_out the page was signed in and the server now reports nobody.
17
+ //
18
+ // WHERE DRIFT COMES FROM. Identity sources. The engine ships three built-in sources
19
+ // (together the "session" scope) and a plug-in interface for everything else:
20
+ // peer — other tabs announce their fingerprint on a BroadcastChannel; a
21
+ // newer, different one makes this page stale.
22
+ // expiry — a timer at the stamp's expiresAt.
23
+ // server — returning to a tab after it was hidden (and a bfcache restore)
24
+ // probes the rehydrate endpoint; a 401 reads as revoked.
25
+ // plug-ins — registerIdentitySource({ name, start(report) }) reports the
26
+ // identity it observes; the store compares it to the stamp's
27
+ // identities[name]. The engine never interprets either string.
28
+ //
29
+ // WHAT IT PUBLISHES. window.StudioSession (below), the document events
30
+ // `session:changed` (every transition) and `session:mismatch` (undeclared drift
31
+ // only, the one a warning listens to), and Alpine.store('studioSession') when
32
+ // Alpine is on the page. It never touches a host's own stores.
33
+ //
34
+ // DORMANT UNTIL A STAMP. A store that has never seen a meta tag (the partial did
35
+ // not render, or rendered no stamp) leaves current().state null, fetches nothing
36
+ // and broadcasts nothing, until a Turbo navigation brings a stamp in. Once seated
37
+ // it stays seated: a later page without a stamp keeps the binding it has.
38
+ //
39
+ // Written as ES5 with Promises and no framework, like the other engine assets,
40
+ // so every host's asset pipeline accepts it untouched.
41
+ (function () {
42
+ "use strict";
43
+
44
+ if (typeof window === "undefined" || typeof document === "undefined") return;
45
+ if (window.StudioSession && window.StudioSession.loaded) return;
46
+
47
+ var STATES = ["anonymous", "authenticated", "stale", "changed", "rehydrated", "signed_out"];
48
+ var SESSION_SCOPE = "session";
49
+ var BUILT_IN_SOURCES = ["peer", "expiry", "server"];
50
+ var RESERVED_NAMES = BUILT_IN_SOURCES.concat([SESSION_SCOPE, "*"]);
51
+ var SOURCE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
52
+ var META_SELECTOR = 'meta[name="studio-session"]';
53
+ var CHANNEL_NAME = "studio-session";
54
+ var MESSAGE_VERSION = 1;
55
+ // setTimeout's ceiling. A later expiry is re-armed by a later stamp.
56
+ var MAX_TIMER_MS = 2147483647;
57
+
58
+ var config = {
59
+ // How long a tab must be hidden before coming back probes the server.
60
+ revalidateAfterHiddenMs: 30000,
61
+ // How long an expectChange hold lasts when the caller names no timeout.
62
+ holdTimeoutMs: 60000,
63
+ // The longest a hold may last, whatever the caller asks (Infinity included),
64
+ // so a forgotten hold cannot hide drift for the life of the tab.
65
+ maxHoldMs: 600000,
66
+ // How long after expiresAt the expiry source fires. The stamp's expiresAt is
67
+ // computed before the response commits a sliding session cookie, so AT
68
+ // expiresAt the cookie can still be live, and probing then would renew the
69
+ // session it meant to find expired: an idle tab would keep itself signed in.
70
+ expiryGraceMs: 5000
71
+ };
72
+
73
+ var tabId = Math.random().toString(36).slice(2) + Date.now().toString(36);
74
+
75
+ var bound = null; // the stamp this page last adopted
76
+ var boundState = null; // anonymous | authenticated | rehydrated | signed_out
77
+ var context = null; // the host payload from the last rehydrate
78
+ var stale = null; // { source, observed } while a rehydrate is due
79
+ var mismatches = {}; // plug-in source name -> { bound, observed }
80
+ var sources = {}; // plug-in source name -> entry
81
+ var holds = [];
82
+ var subscribers = [];
83
+
84
+ var state = null;
85
+ var lastReason = null;
86
+ var lastSource = null;
87
+ var lastExpected = false;
88
+
89
+ var channel = null;
90
+ var expiryTimer = null;
91
+ var hiddenAt = null;
92
+ var inFlight = null;
93
+
94
+ // ---- reading --------------------------------------------------------------
95
+
96
+ function readStamp() {
97
+ var meta = document.querySelector ? document.querySelector(META_SELECTOR) : null;
98
+ if (!meta) return null;
99
+ try {
100
+ var stamp = JSON.parse(meta.getAttribute("content"));
101
+ return validStamp(stamp) ? stamp : null;
102
+ } catch (e) {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ function validStamp(stamp) {
108
+ return !!(stamp && typeof stamp.fingerprint === "string" && stamp.fingerprint &&
109
+ (stamp.state === "anonymous" || stamp.state === "authenticated") &&
110
+ typeof stamp.issuedAt === "number");
111
+ }
112
+
113
+ function copy(object) {
114
+ var out = {};
115
+ if (!object) return out;
116
+ for (var key in object) {
117
+ if (Object.prototype.hasOwnProperty.call(object, key)) out[key] = object[key];
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function keys(object) {
123
+ var out = [];
124
+ for (var key in object) {
125
+ if (Object.prototype.hasOwnProperty.call(object, key)) out.push(key);
126
+ }
127
+ return out;
128
+ }
129
+
130
+ function current() {
131
+ return {
132
+ state: state,
133
+ reason: lastReason,
134
+ source: lastSource,
135
+ expected: lastExpected,
136
+ fingerprint: bound ? bound.fingerprint : null,
137
+ identities: bound ? copy(bound.identities) : {},
138
+ context: context,
139
+ issuedAt: bound ? bound.issuedAt : null,
140
+ expiresAt: bound ? (bound.expiresAt == null ? null : bound.expiresAt) : null,
141
+ rehydrateUrl: bound ? (bound.rehydrateUrl || null) : null,
142
+ mismatches: keys(mismatches)
143
+ };
144
+ }
145
+
146
+ // ---- holds ----------------------------------------------------------------
147
+
148
+ // True when an active hold names any of `scopes` (a string or an array), or "*".
149
+ function isExpected(scopes) {
150
+ var wanted = Object.prototype.toString.call(scopes) === "[object Array]" ? scopes : [scopes];
151
+ var now = Date.now();
152
+ holds = holds.filter(function (hold) { return !hold.released && hold.until > now; });
153
+ for (var i = 0; i < holds.length; i++) {
154
+ if (holds[i].scopes.indexOf("*") !== -1) return true;
155
+ for (var j = 0; j < wanted.length; j++) {
156
+ if (wanted[j] && holds[i].scopes.indexOf(wanted[j]) !== -1) return true;
157
+ }
158
+ }
159
+ return false;
160
+ }
161
+
162
+ // A built-in source's drift is covered by a hold on "session" or on its own name.
163
+ function sessionScopes(source) {
164
+ return [SESSION_SCOPE, source];
165
+ }
166
+
167
+ function expectChange(scope, options) {
168
+ var scopes = scope == null ? ["*"] : (Object.prototype.toString.call(scope) === "[object Array]" ? scope : [scope]);
169
+ scopes = scopes.map(function (value) { return String(value); });
170
+ var timeout = options && options.timeoutMs > 0 ? options.timeoutMs : config.holdTimeoutMs;
171
+ timeout = Math.min(timeout, config.maxHoldMs);
172
+ var hold = { scopes: scopes, until: Date.now() + timeout, released: false };
173
+ holds.push(hold);
174
+ return {
175
+ scopes: scopes.slice(),
176
+ release: function () { hold.released = true; },
177
+ isActive: function () { return !hold.released && hold.until > Date.now(); }
178
+ };
179
+ }
180
+
181
+ // ---- transitions ----------------------------------------------------------
182
+
183
+ function computeState() {
184
+ if (!bound) return null;
185
+ if (keys(mismatches).length) return "changed";
186
+ if (stale) return "stale";
187
+ return boundState;
188
+ }
189
+
190
+ // Emits when the state moves, or when the detail is drift or an adoption even
191
+ // if the state label stays put (a second identity switch is still news).
192
+ function transition(detail) {
193
+ var previous = state;
194
+ var next = computeState();
195
+ if (next === previous && !detail.drift && !detail.adopted) return;
196
+
197
+ state = next;
198
+ lastReason = detail.reason || null;
199
+ lastSource = detail.source || null;
200
+ lastExpected = !!detail.expected;
201
+
202
+ var payload = {
203
+ state: next,
204
+ previous: previous,
205
+ reason: lastReason,
206
+ source: lastSource,
207
+ expected: lastExpected,
208
+ drift: !!detail.drift,
209
+ observed: detail.observed === undefined ? null : detail.observed,
210
+ error: detail.error || null,
211
+ snapshot: current()
212
+ };
213
+
214
+ subscribers.slice().forEach(function (fn) {
215
+ try { fn(payload.snapshot, payload); } catch (e) { report(e); }
216
+ });
217
+ dispatch("session:changed", payload);
218
+ if (payload.drift && !payload.expected) dispatch("session:mismatch", payload);
219
+ }
220
+
221
+ function dispatch(name, detail) {
222
+ if (typeof document.dispatchEvent !== "function" || typeof CustomEvent !== "function") return;
223
+ try { document.dispatchEvent(new CustomEvent(name, { detail: detail })); } catch (e) { report(e); }
224
+ }
225
+
226
+ function report(error) {
227
+ if (typeof console !== "undefined" && console.error) console.error("[StudioSession]", error);
228
+ }
229
+
230
+ // ---- plug-in identity sources ---------------------------------------------
231
+
232
+ function boundIdentityFor(entry) {
233
+ var name = entry.name;
234
+ if (typeof entry.source.bound === "function") {
235
+ var value = entry.source.bound(current());
236
+ return value == null || value === "" ? null : String(value);
237
+ }
238
+ var identities = bound && bound.identities;
239
+ if (!identities || identities[name] == null || identities[name] === "") return null;
240
+ return String(identities[name]);
241
+ }
242
+
243
+ function identitiesEqual(entry, boundValue, observed) {
244
+ if (typeof entry.source.equals === "function") return !!entry.source.equals(boundValue, observed);
245
+ return boundValue === observed;
246
+ }
247
+
248
+ // Recomputes one source's mismatch. Returns "new", "resolved" or null.
249
+ // undefined means the source cannot tell, which changes nothing. An UNBOUND
250
+ // source (no bound identity) records what it sees but never mismatches: an
251
+ // anonymous page with an identity in view is not a warning.
252
+ //
253
+ // A source whose bound() or equals() throws cannot compare, which is the same
254
+ // as "cannot tell": the error is reported and nothing changes. It must never
255
+ // escape, because evaluate also runs inside an adoption, between swapping the
256
+ // stamp and announcing the transition.
257
+ function evaluate(entry) {
258
+ if (!bound || entry.observed === undefined) return null;
259
+ var name = entry.name;
260
+ var had = Object.prototype.hasOwnProperty.call(mismatches, name);
261
+ var boundValue;
262
+ var equal;
263
+ try {
264
+ boundValue = boundIdentityFor(entry);
265
+ equal = boundValue === null || identitiesEqual(entry, boundValue, entry.observed);
266
+ } catch (e) {
267
+ report(e);
268
+ return null;
269
+ }
270
+
271
+ if (equal) {
272
+ if (!had) return null;
273
+ delete mismatches[name];
274
+ return "resolved";
275
+ }
276
+ var previous = mismatches[name];
277
+ mismatches[name] = { bound: boundValue, observed: entry.observed };
278
+ return previous && previous.observed === entry.observed && previous.bound === boundValue ? null : "new";
279
+ }
280
+
281
+ function registerIdentitySource(source) {
282
+ if (!source || typeof source.start !== "function") {
283
+ throw new TypeError("StudioSession.registerIdentitySource: a source needs a start(report) function");
284
+ }
285
+ var name = String(source.name || "");
286
+ if (!SOURCE_NAME.test(name) || RESERVED_NAMES.indexOf(name) !== -1) {
287
+ throw new TypeError("StudioSession.registerIdentitySource: invalid or reserved source name " + JSON.stringify(name));
288
+ }
289
+ if (sources[name]) {
290
+ throw new Error("StudioSession.registerIdentitySource: a source named " + JSON.stringify(name) + " is already registered");
291
+ }
292
+
293
+ var entry = { name: name, source: source, observed: undefined, stop: null };
294
+ sources[name] = entry;
295
+
296
+ var reportIdentity = function (value) {
297
+ if (sources[name] !== entry) return;
298
+ entry.observed = value === undefined ? undefined : (value === null ? null : String(value));
299
+ var outcome = evaluate(entry);
300
+ if (outcome === "new") {
301
+ transition({ reason: "source", source: name, drift: true, expected: isExpected(name),
302
+ observed: entry.observed });
303
+ } else if (outcome === "resolved") {
304
+ transition({ reason: "resolved", source: name, expected: true, observed: entry.observed });
305
+ }
306
+ };
307
+
308
+ try {
309
+ var stop = source.start(reportIdentity);
310
+ entry.stop = typeof stop === "function" ? stop : null;
311
+ } catch (e) {
312
+ report(e);
313
+ }
314
+
315
+ return {
316
+ name: name,
317
+ unregister: function () {
318
+ if (sources[name] !== entry) return;
319
+ delete sources[name];
320
+ if (entry.stop) {
321
+ try { entry.stop(); } catch (e) { report(e); }
322
+ }
323
+ if (Object.prototype.hasOwnProperty.call(mismatches, name)) {
324
+ delete mismatches[name];
325
+ transition({ reason: "unregistered", source: name, expected: true });
326
+ }
327
+ }
328
+ };
329
+ }
330
+
331
+ function observed(name) {
332
+ var entry = sources[String(name)];
333
+ return entry ? entry.observed : undefined;
334
+ }
335
+
336
+ // ---- adopting a stamp -----------------------------------------------------
337
+
338
+ // How a page takes on a stamp. `navigation` is this tab rendering a new page:
339
+ // a fresh render, so the plain server state and no drift. Anything else is a
340
+ // rehydrate, where a different fingerprint IS drift.
341
+ function adopt(stamp, hostContext, options) {
342
+ var previousFingerprint = bound ? bound.fingerprint : null;
343
+ var wasSignedIn = boundState === "authenticated" || boundState === "rehydrated";
344
+ var fingerprintChanged = previousFingerprint !== stamp.fingerprint;
345
+
346
+ bound = stamp;
347
+ if (hostContext !== undefined) {
348
+ context = hostContext;
349
+ } else if (options.navigation) {
350
+ // A new render carries its own host payload; the last rehydrate's is old.
351
+ context = null;
352
+ }
353
+ stale = null;
354
+
355
+ if (options.navigation || previousFingerprint === null) {
356
+ boundState = stamp.state;
357
+ } else if (fingerprintChanged) {
358
+ if (stamp.state === "anonymous") {
359
+ boundState = wasSignedIn ? "signed_out" : "anonymous";
360
+ } else {
361
+ boundState = "rehydrated";
362
+ }
363
+ }
364
+
365
+ keys(sources).forEach(function (name) { evaluate(sources[name]); });
366
+ armExpiry();
367
+
368
+ var drift = fingerprintChanged && !options.navigation && previousFingerprint !== null;
369
+ transition({
370
+ reason: options.reason,
371
+ source: options.source,
372
+ expected: !!(options.navigation || options.manual || isExpected(sessionScopes(options.source))),
373
+ drift: drift,
374
+ adopted: fingerprintChanged
375
+ });
376
+
377
+ if (fingerprintChanged) announce();
378
+ }
379
+
380
+ // ---- the built-in sources -------------------------------------------------
381
+
382
+ // A session source saw a different truth. With a rehydrate URL the page goes
383
+ // stale and repairs itself; without one, stale is final and IS the drift.
384
+ function markStale(source, observedValue) {
385
+ if (!bound) return;
386
+ stale = { source: source, observed: observedValue === undefined ? null : observedValue };
387
+ var repairable = !!bound.rehydrateUrl && typeof fetch === "function";
388
+ transition({ reason: source, source: source, expected: isExpected(sessionScopes(source)),
389
+ drift: !repairable, observed: stale.observed });
390
+ if (repairable) rehydrate(source, false);
391
+ }
392
+
393
+ function armExpiry() {
394
+ if (expiryTimer !== null) clearTimeout(expiryTimer);
395
+ expiryTimer = null;
396
+ if (!bound || bound.state !== "authenticated" || typeof bound.expiresAt !== "number") return;
397
+ var delay = Math.max(0, bound.expiresAt + config.expiryGraceMs - Date.now());
398
+ if (delay > MAX_TIMER_MS) return;
399
+ var armedFor = bound;
400
+ expiryTimer = setTimeout(function () {
401
+ expiryTimer = null;
402
+ if (bound === armedFor) markStale("expiry", { expiresAt: armedFor.expiresAt });
403
+ }, delay);
404
+ }
405
+
406
+ // A stale page knows its stamp is out of date, so it has nothing to tell a peer.
407
+ function announce() {
408
+ if (!channel || !bound || stale) return;
409
+ try {
410
+ channel.postMessage({ v: MESSAGE_VERSION, type: "announce", tabId: tabId,
411
+ fingerprint: bound.fingerprint, state: bound.state, issuedAt: bound.issuedAt });
412
+ } catch (e) { report(e); }
413
+ }
414
+
415
+ function hello() {
416
+ if (!channel || !bound) return;
417
+ try { channel.postMessage({ v: MESSAGE_VERSION, type: "hello", tabId: tabId }); } catch (e) { report(e); }
418
+ }
419
+
420
+ function onPeerMessage(event) {
421
+ var message = event && event.data;
422
+ if (!message || message.v !== MESSAGE_VERSION || message.tabId === tabId || !bound) return;
423
+ if (message.type === "hello") { announce(); return; }
424
+ if (message.type !== "announce" || typeof message.fingerprint !== "string") return;
425
+ if (message.fingerprint === bound.fingerprint) return;
426
+ if (typeof message.issuedAt !== "number") return;
427
+ // Only a NEWER truth moves this page. An older one is the peer's problem, and
428
+ // our own announce is what tells it. STRICTLY older: two tabs that disagree
429
+ // at the same millisecond must not answer each other forever.
430
+ if (message.issuedAt < bound.issuedAt) {
431
+ announce();
432
+ return;
433
+ }
434
+ if (message.issuedAt === bound.issuedAt) return;
435
+ // The same drift, heard again (a peer answering another tab's hello), is not
436
+ // news: one drift, one warning.
437
+ if (stale && stale.source === "peer" && (inFlight || (stale.observed && stale.observed.fingerprint === message.fingerprint))) return;
438
+ markStale("peer", { state: message.state, fingerprint: message.fingerprint, issuedAt: message.issuedAt });
439
+ }
440
+
441
+ // ---- rehydrate ------------------------------------------------------------
442
+
443
+ function swapCsrf(token) {
444
+ if (!token || !document.querySelector) return;
445
+ var meta = document.querySelector('meta[name="csrf-token"]');
446
+ if (meta) meta.setAttribute("content", token);
447
+ }
448
+
449
+ function rehydrate(source, manual) {
450
+ if (!bound || !bound.rehydrateUrl || typeof fetch !== "function") return Promise.resolve(current());
451
+ if (inFlight) {
452
+ // A deliberate refresh must see the session as it is AFTER the caller's own
453
+ // change. A probe already on the wire may have left before that change, so
454
+ // a manual refresh waits for it and then asks again.
455
+ return manual ? inFlight.then(function () { return rehydrate(source, true); }) : inFlight;
456
+ }
457
+
458
+ var url = bound.rehydrateUrl;
459
+ inFlight = fetch(url, {
460
+ method: "GET",
461
+ credentials: "same-origin",
462
+ cache: "no-store",
463
+ headers: { "Accept": "application/json" }
464
+ }).then(function (response) {
465
+ if (response.status === 401) return { revoked: true };
466
+ if (!response.ok) throw new Error("rehydrate answered HTTP " + response.status);
467
+ return response.json();
468
+ }).then(function (body) {
469
+ inFlight = null;
470
+ if (body && body.revoked) {
471
+ revoke(source, manual);
472
+ return current();
473
+ }
474
+ if (!body || !validStamp(body.session)) throw new Error("rehydrate returned no session stamp");
475
+ swapCsrf(body.csrf);
476
+ adopt(body.session, body.context === undefined ? null : body.context,
477
+ { reason: manual ? "manual" : source, source: source, manual: manual });
478
+ return current();
479
+ }).catch(function (error) {
480
+ inFlight = null;
481
+ // A probe that fails on a page with no known drift changes nothing. A page
482
+ // already stale could not confirm what it learned, so that IS drift.
483
+ if (stale) {
484
+ transition({ reason: "rehydrate_failed", source: source, expected: isExpected(sessionScopes(source)),
485
+ drift: true, error: String(error && error.message || error) });
486
+ }
487
+ return current();
488
+ });
489
+ return inFlight;
490
+ }
491
+
492
+ // The server refused the session (a host filter answered 401). The page is
493
+ // now anonymous as far as this browser is concerned. issuedAt is NOT advanced:
494
+ // this tab's clock is not the server's, and a peer should learn the same fact
495
+ // from its own probe rather than trust a time this tab invented.
496
+ function revoke(source, manual) {
497
+ adopt({
498
+ v: bound ? bound.v : 1,
499
+ state: "anonymous",
500
+ fingerprint: "anonymous",
501
+ issuedAt: bound ? bound.issuedAt : Date.now(),
502
+ expiresAt: null,
503
+ rehydrateUrl: bound ? bound.rehydrateUrl : null,
504
+ identities: {}
505
+ }, null, { reason: "revoked", source: source, manual: manual });
506
+ }
507
+
508
+ // ---- lifecycle ------------------------------------------------------------
509
+
510
+ function seat(reason) {
511
+ var stamp = readStamp();
512
+ if (!stamp) return;
513
+
514
+ if (!bound) {
515
+ adopt(stamp, undefined, { reason: reason, navigation: true });
516
+ return;
517
+ }
518
+ if (stamp.fingerprint === bound.fingerprint) {
519
+ // The same session, rendered NO LATER than the stamp this tab holds: Back or
520
+ // Forward to a page Turbo cached. It holds nothing this tab does not already
521
+ // know, and it must not undo what the tab learned since — a stale tab stays
522
+ // stale (Back is not a sign-in), and a current tab keeps its newer stamp and
523
+ // newer expiry. EQUAL counts: Forward restores the very page the tab went
524
+ // stale on, and two renders never share a millisecond.
525
+ if (stamp.issuedAt <= bound.issuedAt) return;
526
+ bound = stamp;
527
+ stale = null;
528
+ boundState = stamp.state;
529
+ keys(sources).forEach(function (name) { evaluate(sources[name]); });
530
+ armExpiry();
531
+ transition({ reason: reason, expected: true });
532
+ return;
533
+ }
534
+ if (stamp.issuedAt >= bound.issuedAt) {
535
+ adopt(stamp, undefined, { reason: reason, navigation: true });
536
+ } else {
537
+ // A restored snapshot (Turbo's cache) rendered for an OLDER session than
538
+ // this tab already knows. The page on screen describes that stamp, so it is
539
+ // what the store binds to; then it asks the server, and whatever differs
540
+ // between the two is drift this page really has.
541
+ bound = stamp;
542
+ boundState = stamp.state;
543
+ context = null;
544
+ keys(sources).forEach(function (name) { evaluate(sources[name]); });
545
+ armExpiry();
546
+ markStale("restored", { fingerprint: stamp.fingerprint, issuedAt: stamp.issuedAt });
547
+ }
548
+ }
549
+
550
+ function onVisibilityChange() {
551
+ if (document.hidden) { hiddenAt = Date.now(); return; }
552
+ if (hiddenAt === null) return;
553
+ var away = Date.now() - hiddenAt;
554
+ hiddenAt = null;
555
+ if (away >= config.revalidateAfterHiddenMs) rehydrate("server", false);
556
+ }
557
+
558
+ function onPageShow(event) {
559
+ if (!event || !event.persisted) return;
560
+ hello();
561
+ rehydrate("server", false);
562
+ }
563
+
564
+ function subscribe(fn) {
565
+ if (typeof fn !== "function") throw new TypeError("StudioSession.subscribe needs a function");
566
+ subscribers.push(fn);
567
+ return function unsubscribe() {
568
+ subscribers = subscribers.filter(function (candidate) { return candidate !== fn; });
569
+ };
570
+ }
571
+
572
+ // A deliberate refresh by page code: whatever it finds is expected.
573
+ function refresh() {
574
+ return rehydrate("server", true);
575
+ }
576
+
577
+ function configure(options) {
578
+ if (!options) return copy(config);
579
+ if (options.revalidateAfterHiddenMs >= 0) config.revalidateAfterHiddenMs = options.revalidateAfterHiddenMs;
580
+ if (options.holdTimeoutMs > 0) config.holdTimeoutMs = options.holdTimeoutMs;
581
+ if (options.maxHoldMs > 0) config.maxHoldMs = options.maxHoldMs;
582
+ if (options.expiryGraceMs >= 0) {
583
+ config.expiryGraceMs = options.expiryGraceMs;
584
+ armExpiry();
585
+ }
586
+ return copy(config);
587
+ }
588
+
589
+ // ---- Alpine bridge --------------------------------------------------------
590
+
591
+ function alpineStoreValue(snapshot) {
592
+ var value = copy(snapshot);
593
+ value.is = function (name) { return this.state === name; };
594
+ return value;
595
+ }
596
+
597
+ function installAlpineStore() {
598
+ var Alpine = window.Alpine;
599
+ if (!Alpine || typeof Alpine.store !== "function") return;
600
+ if (!Alpine.store("studioSession")) Alpine.store("studioSession", alpineStoreValue(current()));
601
+ subscribe(function (snapshot) {
602
+ var store = Alpine.store("studioSession");
603
+ if (!store) return;
604
+ for (var key in snapshot) {
605
+ if (Object.prototype.hasOwnProperty.call(snapshot, key)) store[key] = snapshot[key];
606
+ }
607
+ });
608
+ }
609
+
610
+ // ---- boot -----------------------------------------------------------------
611
+
612
+ window.StudioSession = {
613
+ loaded: true,
614
+ version: 1,
615
+ STATES: STATES.slice(),
616
+ SESSION_SCOPE: SESSION_SCOPE,
617
+ current: current,
618
+ subscribe: subscribe,
619
+ refresh: refresh,
620
+ expectChange: expectChange,
621
+ registerIdentitySource: registerIdentitySource,
622
+ observed: observed,
623
+ configure: configure
624
+ };
625
+
626
+ if (typeof BroadcastChannel === "function") {
627
+ try {
628
+ channel = new BroadcastChannel(CHANNEL_NAME);
629
+ channel.onmessage = onPeerMessage;
630
+ } catch (e) {
631
+ channel = null;
632
+ }
633
+ }
634
+
635
+ seat("render");
636
+ hello();
637
+
638
+ document.addEventListener("turbo:load", function () { seat("navigation"); });
639
+ document.addEventListener("visibilitychange", onVisibilityChange);
640
+ window.addEventListener("pageshow", onPageShow);
641
+
642
+ if (window.Alpine) {
643
+ installAlpineStore();
644
+ } else {
645
+ document.addEventListener("alpine:init", installAlpineStore);
646
+ }
647
+ })();
@@ -2,6 +2,11 @@ module Studio
2
2
  module ErrorHandling
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ # The session-drift stamp every page carries (docs/SESSION_DRIFT.md). Pulled
6
+ # in here so every consumer has it without wiring anything; it adds helper
7
+ # methods only.
8
+ include Studio::SessionDrift
9
+
5
10
  included do
6
11
  # ORDER IS LOAD-BEARING — ActiveSupport::Rescuable resolves handlers with
7
12
  # `reverse_each`, so the LAST matching rescue_from registered wins. The
@@ -160,9 +165,11 @@ module Studio
160
165
  @wallet_context ||= SessionContext.new(user: current_user, onchain_session: onchain_session?)
161
166
  end
162
167
 
163
- # Payload serialised into #session-context for Alpine.store('session').
164
- # Baseline = identity only (SessionContext stays RPC-free). Apps override to
165
- # merge on-chain balances/tokens they already preloaded for the request.
168
+ # The host's page payload (SessionContext#to_h), which a host serialises into
169
+ # its own page for its own client store. Baseline = identity only
170
+ # (SessionContext stays RPC-free). Apps override to merge values they already
171
+ # preloaded for the request. The rehydrate endpoint
172
+ # (Studio::SessionStatesController) returns it as `context`.
166
173
  def client_session_payload
167
174
  wallet_context.to_h
168
175
  end