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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/README.md +3 -0
- data/app/assets/javascripts/studio/session.js +647 -0
- data/app/controllers/concerns/studio/error_handling.rb +10 -3
- data/app/controllers/concerns/studio/session_drift.rb +99 -0
- data/app/controllers/studio/session_states_controller.rb +43 -0
- data/app/models/session_context.rb +32 -0
- data/app/views/layouts/studio/_flash.html.erb +3 -1
- data/app/views/layouts/studio/_head.html.erb +3 -0
- data/app/views/studio/_session_stamp.html.erb +32 -0
- data/app/views/studio/modals/blocks/_success_card.html.erb +1 -1
- data/lib/studio/engine.rb +1 -0
- data/lib/studio/session_fingerprint.rb +109 -0
- data/lib/studio/session_state.rb +109 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +37 -0
- metadata +7 -1
|
@@ -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
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
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
|