@mmmbuto/nexuscrew 0.9.52 → 0.9.54
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.
- package/CHANGELOG.md +19 -0
- package/frontend/dist/assets/{index-CcZskBmy.css → index-Da4oBb4f.css} +1 -1
- package/frontend/dist/assets/index-DdTN41V3.js +169 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +73 -16
- package/lib/cli/init.js +17 -7
- package/lib/cli/messages.js +55 -0
- package/lib/cli/url.js +6 -2
- package/lib/config.js +32 -1
- package/lib/fleet/identity-provision.js +19 -9
- package/lib/notify/ask-answer-service.js +1 -0
- package/lib/notify/asks.js +58 -7
- package/lib/notify/event-feed-client.js +420 -48
- package/lib/notify/event-feed-routes.js +44 -3
- package/lib/notify/persist.js +69 -12
- package/lib/notify/routes.js +19 -1
- package/lib/prefs/routes.js +52 -0
- package/lib/prefs/store.js +112 -0
- package/lib/proxy/federation.js +66 -5
- package/lib/server.js +112 -34
- package/lib/settings/routes.js +14 -4
- package/lib/update/core.js +45 -0
- package/lib/update/manager.js +31 -8
- package/lib/update/npm-env.js +109 -0
- package/lib/update/runner.js +54 -14
- package/lib/update/start-lock.js +106 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-5MtkSiOQ.js +0 -169
|
@@ -15,6 +15,16 @@ const BACKOFF_MIN_MS = 5000;
|
|
|
15
15
|
const BACKOFF_MAX_MS = 60000;
|
|
16
16
|
const IDLE_TIMEOUT_MS = 60000;
|
|
17
17
|
const MAX_CONSECUTIVE_RESETS = 3;
|
|
18
|
+
// `resync-exhausted` ferma il loop ma non deve parcheggiare la view
|
|
19
|
+
// per sempre. Al tappo la view prende un cooldown a gradini — 30 s → 2 min →
|
|
20
|
+
// 5 min, poi fisso — e alla scadenza paga UN tentativo di snapshot fresco.
|
|
21
|
+
// Iniettabile per i test (timer veri, passi piccoli).
|
|
22
|
+
const RESYNC_COOLDOWN_STEPS_MS = [30 * 1000, 2 * 60 * 1000, 5 * 60 * 1000];
|
|
23
|
+
// INVARIANTE: al massimo UNO snapshot per finestra, per owner, da qualunque
|
|
24
|
+
// causa di resync (409, frame malformato, oversize, seq discontinua, errore
|
|
25
|
+
// di trasporto). È la garanzia strutturale: le vie singole possono cambiare,
|
|
26
|
+
// il tetto delle richieste no. Di default 30 s, iniettabile per i test.
|
|
27
|
+
const MIN_SNAPSHOT_INTERVAL_MS = 30 * 1000;
|
|
18
28
|
|
|
19
29
|
// Ingress budgets: 120 frames/min and 1 MiB/min per owner, plus a separate
|
|
20
30
|
// federated global window of 480 frames/min and 4 MiB/min. The counters are
|
|
@@ -36,11 +46,39 @@ const SNAPSHOT_MAX_CELLS = 1000;
|
|
|
36
46
|
const SNAPSHOT_ELEMENT_MAX_BYTES = 16 * 1024;
|
|
37
47
|
const SNAPSHOT_ID_MAX_CHARS = 64;
|
|
38
48
|
|
|
49
|
+
// Lo snapshot è JSON DICHIARATO: se la risposta dichiara un media type, questo
|
|
50
|
+
// dev'essere application/json (i parametri come charset sono ammessi). Un
|
|
51
|
+
// corpo che parsisce ma viaggia come text/plain non è la superficie dello
|
|
52
|
+
// snapshot — quale che sia il suo contenuto, non decide niente. La
|
|
53
|
+
// dichiarazione ASSENTE (proxy che spoglia gli header, trasporti degradati
|
|
54
|
+
// verso la view) resta tollerata: la regola colpisce chi dichiara un tipo
|
|
55
|
+
// sbagliato, non chi non dichiara.
|
|
56
|
+
function isJsonContentType(value) {
|
|
57
|
+
if (typeof value !== 'string') return false;
|
|
58
|
+
return value.split(';')[0].trim().toLowerCase() === 'application/json';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Lettura difensiva dell'header: risposte senza headers (stub, trasporti
|
|
62
|
+
// degradati) valgono come dichiarazione ASSENTE, che il validatore tollera.
|
|
63
|
+
function contentTypeOf(r) {
|
|
64
|
+
try {
|
|
65
|
+
return r && r.headers && typeof r.headers.get === 'function' ? r.headers.get('content-type') : null;
|
|
66
|
+
} catch (_) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
39
71
|
function createEventFeedClient(opts = {}) {
|
|
40
72
|
// deps: { nodesPath, token(), eventsHub, now, fetchImpl, log }
|
|
41
73
|
const fetchImpl = opts.fetchImpl || fetch;
|
|
42
74
|
const now = opts.now || Date.now;
|
|
43
75
|
const log = typeof opts.log === 'function' ? opts.log : () => {};
|
|
76
|
+
const cooldownSteps = Array.isArray(opts.resyncCooldownStepsMs) && opts.resyncCooldownStepsMs.length > 0
|
|
77
|
+
? opts.resyncCooldownStepsMs
|
|
78
|
+
: RESYNC_COOLDOWN_STEPS_MS;
|
|
79
|
+
const minSnapshotIntervalMs = typeof opts.minSnapshotIntervalMs === 'number' && opts.minSnapshotIntervalMs >= 0
|
|
80
|
+
? opts.minSnapshotIntervalMs
|
|
81
|
+
: MIN_SNAPSHOT_INTERVAL_MS;
|
|
44
82
|
// ownerId -> view state {cursor, generation, asks, notifications, fleetState,
|
|
45
83
|
// stale, lastError}; generation guards against obsolete fetches.
|
|
46
84
|
const views = new Map();
|
|
@@ -62,14 +100,60 @@ function createEventFeedClient(opts = {}) {
|
|
|
62
100
|
return views.get(ownerId);
|
|
63
101
|
}
|
|
64
102
|
|
|
103
|
+
// Asks this node knows are CLOSED on the owner. The owner's snapshot can still
|
|
104
|
+
// carry one for a moment (its closing frame and our re-snapshot race), and the
|
|
105
|
+
// UI would re-import it — the card that comes back after the X.
|
|
106
|
+
// The tombstone carries a TTL and the view epoch it was made in: an id reused
|
|
107
|
+
// after an owner reset must NOT stay suppressed forever.
|
|
108
|
+
const ASK_TOMBSTONE_TTL_MS = 10 * 60 * 1000;
|
|
109
|
+
const askTombstones = new Map(); // `${ownerId}|${askId}` -> {at, epoch}
|
|
110
|
+
|
|
111
|
+
function askTombstoneKey(ownerId, askId) { return `${ownerId}|${askId}`; }
|
|
112
|
+
|
|
113
|
+
function noteAskClosed(ownerId, askId, epoch) {
|
|
114
|
+
if (!ownerId || !askId) return;
|
|
115
|
+
askTombstones.set(askTombstoneKey(ownerId, askId), { at: now(), epoch });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function askIsClosed(ownerId, askId, epoch) {
|
|
119
|
+
const key = askTombstoneKey(ownerId, askId);
|
|
120
|
+
const hit = askTombstones.get(key);
|
|
121
|
+
if (!hit) return false;
|
|
122
|
+
if (now() - hit.at > ASK_TOMBSTONE_TTL_MS) { askTombstones.delete(key); return false; }
|
|
123
|
+
if (typeof hit.epoch === 'number' && typeof epoch === 'number' && hit.epoch !== epoch) return false;
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function dropAskFromView(ownerId, askId) {
|
|
128
|
+
const view = viewFor(ownerId);
|
|
129
|
+
view.asks = view.asks.filter((a) => (a.ownerAskId || a.id) !== askId);
|
|
130
|
+
return view;
|
|
131
|
+
}
|
|
132
|
+
|
|
65
133
|
// One resync round that ended WITHOUT a usable frame counts as a reset: a
|
|
66
134
|
// peer that keeps asking to resync (409 reset-required / cursor-future, or a
|
|
67
135
|
// frame from an unknown epoch) would otherwise be re-snapshotted forever.
|
|
68
136
|
// Past the cap the view goes to the error state and the poll gate stops it;
|
|
69
137
|
// a single healthy frame clears the streak.
|
|
138
|
+
// Arma (o riarma) il cooldown della view al gradino successivo della
|
|
139
|
+
// scala. Vale sia per l'esaurimento del tappo (noteReset) sia per un
|
|
140
|
+
// tentativo di recupero fallito (poll): in entrambi i casi la view torna a
|
|
141
|
+
// chiedere SOLO alla scadenza, un tentativo per scadenza.
|
|
142
|
+
function armResyncCooldown(view, why) {
|
|
143
|
+
view.resyncExhaustions = (view.resyncExhaustions || 0) + 1;
|
|
144
|
+
const step = cooldownSteps[Math.min(view.resyncExhaustions - 1, cooldownSteps.length - 1)];
|
|
145
|
+
view.resyncBlockedUntil = now() + step;
|
|
146
|
+
log(`event-feed-client: ${String(view.ownerId || '').slice(0, 8)}… ${why}: cooldown ${Math.round(step / 1000)} s (exhaustions ${view.resyncExhaustions})`);
|
|
147
|
+
}
|
|
148
|
+
|
|
70
149
|
function noteReset(view) {
|
|
71
150
|
view.consecutiveResets = (view.consecutiveResets || 0) + 1;
|
|
72
151
|
if (view.consecutiveResets >= MAX_CONSECUTIVE_RESETS) {
|
|
152
|
+
// Il tappo ferma il loop, ma la view non resta parcheggiata: il
|
|
153
|
+
// cooldown cresce a ogni riesaurimento SENZA un frame sano in mezzo (una
|
|
154
|
+
// connessione che funziona riporta la scala al primo gradino, vedi
|
|
155
|
+
// streamOnce) e alla scadenza paga uno snapshot fresco (vedi poll).
|
|
156
|
+
armResyncCooldown(view, 'resync exhausted');
|
|
73
157
|
view.stale = true;
|
|
74
158
|
view.lastError = 'resync-exhausted';
|
|
75
159
|
}
|
|
@@ -99,12 +183,24 @@ function createEventFeedClient(opts = {}) {
|
|
|
99
183
|
const view = viewFor(ownerId);
|
|
100
184
|
if (gen !== generation) return false;
|
|
101
185
|
view.cursor = snap.cursor || null;
|
|
102
|
-
|
|
186
|
+
// A snapshot taken before the owner processed a dismiss still carries the
|
|
187
|
+
// ask: the tombstone is what keeps it from coming back.
|
|
188
|
+
view.asks = Array.isArray(snap.asks)
|
|
189
|
+
? snap.asks
|
|
190
|
+
.filter((a) => a && !askIsClosed(ownerId, a.ownerAskId || a.id, snap.viewEpoch))
|
|
191
|
+
.map((a) => ({ ...a, ownerId }))
|
|
192
|
+
: [];
|
|
103
193
|
view.notifications = Array.isArray(snap.notifications) ? snap.notifications.map((e) => ({ ...e, ownerId })) : [];
|
|
104
194
|
view.fleetState = snap.fleetState || null;
|
|
105
195
|
view.askReplyAccess = snap.askReplyAccess === true;
|
|
106
196
|
view.viewEpoch = snap.viewEpoch;
|
|
107
197
|
view.stale = false;
|
|
198
|
+
// Lo snapshot fresco è la guarigione visibile: stale torna falso e
|
|
199
|
+
// l'ultimo errore cessa di essere mostrato. Lo streak NON si tocca qui:
|
|
200
|
+
// il conto dei reset misura i round SENZA frame sano, e ogni round di
|
|
201
|
+
// ricognizione inizia con uno snapshot — azzerarlo qui annullerebbe il
|
|
202
|
+
// tappo anti-reset. Lo sblocco dello streak sta alla scadenza del cooldown.
|
|
203
|
+
view.lastError = null;
|
|
108
204
|
view.generation = gen;
|
|
109
205
|
return true;
|
|
110
206
|
}
|
|
@@ -133,6 +229,10 @@ function createEventFeedClient(opts = {}) {
|
|
|
133
229
|
opts.eventsHub.broadcast({ type: 'ask', ownerId: envelope.ownerId, eventId: envelope.eventId,
|
|
134
230
|
ask: { id: f.askId, question: f.question, options: f.options, session: f.session, ownerId: envelope.ownerId } });
|
|
135
231
|
} else if (f.type === 'ask-closed') {
|
|
232
|
+
// The owner says the ask is closed: the VIEW drops it too, not only the
|
|
233
|
+
// UI, or the next state read hands the card back.
|
|
234
|
+
const closedView = dropAskFromView(envelope.ownerId, f.askId);
|
|
235
|
+
if (f.outcome === 'dismissed') noteAskClosed(envelope.ownerId, f.askId, closedView.viewEpoch);
|
|
136
236
|
opts.eventsHub.broadcast({ type: f.outcome === 'dismissed' ? 'ask-dismissed' : 'ask-answered',
|
|
137
237
|
id: f.askId, ownerId: envelope.ownerId, eventId: envelope.eventId });
|
|
138
238
|
} else if (f.type === 'file-notice') {
|
|
@@ -241,7 +341,7 @@ function createEventFeedClient(opts = {}) {
|
|
|
241
341
|
const dataLine = block.split('\n').find((l) => l.startsWith('data: '));
|
|
242
342
|
if (!dataLine) continue;
|
|
243
343
|
// Frame cap BEFORE JSON.parse.
|
|
244
|
-
if (dataLine.length > 16 * 1024) { log('event-feed-client: oversized frame, resync'); view.cursor = null; return; }
|
|
344
|
+
if (dataLine.length > 16 * 1024) { log('event-feed-client: oversized frame, resync'); view.cursor = null; noteReset(view); return; }
|
|
245
345
|
// Ingress budget, counted per FRAME on the wire BEFORE it is parsed or
|
|
246
346
|
// applied: over the cap this peer is disconnected by name and blocked
|
|
247
347
|
// for the rest of its window, while the other owners keep streaming.
|
|
@@ -260,7 +360,7 @@ function createEventFeedClient(opts = {}) {
|
|
|
260
360
|
return;
|
|
261
361
|
}
|
|
262
362
|
let envelope;
|
|
263
|
-
try { envelope = JSON.parse(dataLine.slice(6)); } catch (_) { view.cursor = null; return; }
|
|
363
|
+
try { envelope = JSON.parse(dataLine.slice(6)); } catch (_) { view.cursor = null; noteReset(view); return; }
|
|
264
364
|
// Owner mismatch on a frame: disconnect, never apply.
|
|
265
365
|
if (envelope.ownerId !== ownerId) { log('event-feed-client: owner mismatch, disconnect'); return; }
|
|
266
366
|
// Sequence continuity within the same epoch; anything else resyncs.
|
|
@@ -271,9 +371,10 @@ function createEventFeedClient(opts = {}) {
|
|
|
271
371
|
noteReset(view);
|
|
272
372
|
return;
|
|
273
373
|
}
|
|
274
|
-
if (view.lastSeq !== undefined && Number(sStr) !== view.lastSeq + 1) { view.cursor = null; return; }
|
|
374
|
+
if (view.lastSeq !== undefined && Number(sStr) !== view.lastSeq + 1) { view.cursor = null; noteReset(view); return; }
|
|
275
375
|
view.lastSeq = Number(sStr);
|
|
276
376
|
view.consecutiveResets = 0; // a usable frame: the streak is over
|
|
377
|
+
view.resyncExhaustions = 0; // and a working stream: the cooldown ladder back to the first step
|
|
277
378
|
view.cursor = `${epochStr}:${sStr}`;
|
|
278
379
|
reemit(envelope);
|
|
279
380
|
}
|
|
@@ -308,56 +409,174 @@ function createEventFeedClient(opts = {}) {
|
|
|
308
409
|
return true;
|
|
309
410
|
}
|
|
310
411
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
412
|
+
// Il VALIDATORE unico dello snapshot: entrambe le vie (sottoscritta e non
|
|
413
|
+
// sottoscritta) passano da qui. Niente logica duplicata: una regola nuova
|
|
414
|
+
// vale per tutte le porte. Il pre-check della dimensione prima del parse
|
|
415
|
+
// resta nei chiamanti come scorciatoia (non si paga il parse di un corpo
|
|
416
|
+
// rifiutato): la regola vive qui.
|
|
417
|
+
//
|
|
418
|
+
// Due PROFILI dello stesso contratto, non due validatori:
|
|
419
|
+
// - 'decision' è ciò che rende l'elenco asks AUTOREVOLE per la chiusura
|
|
420
|
+
// degli alias importati (ownerId atteso, resyncRequired ben formato e
|
|
421
|
+
// non dichiarante, content-type dichiarato corretto, asks elenco di
|
|
422
|
+
// oggetti con id stringa 1..64, cap di pagina e per-elemento, cap del
|
|
423
|
+
// corpo). È il profilo della PORTA: la decisione di chiusura legge SOLO
|
|
424
|
+
// questi campi, e un campo che la decisione non legge non può tener
|
|
425
|
+
// aperta una card che l'owner ha chiuso (la vista che non si può
|
|
426
|
+
// applicare per colpa di cursor o notifications è un problema della
|
|
427
|
+
// vista, non una prova che l'ask sia ancora viva).
|
|
428
|
+
// - 'view' (default) aggiunge lo SCHEMA COMPLETO dei campi che la vista
|
|
429
|
+
// applica (cursor, viewEpoch, askReplyAccess, notifications, fleetState,
|
|
430
|
+
// i campi interni di ogni ask): qui un tipo sbagliato in QUALUNQUE
|
|
431
|
+
// punto non è mai 'ok' (reason 'snapshot-schema:<campo>') — lo snapshot
|
|
432
|
+
// non si applica, niente applicazione parziale.
|
|
433
|
+
// Produttore di riferimento: lib/notify/event-feed-routes.js buildSnapshot
|
|
434
|
+
// (emette sempre i campi della view e marca resyncRequired a ogni cap).
|
|
435
|
+
function validateSnapshot(ownerId, snap, bytes, contentType, profile = 'view') {
|
|
436
|
+
if (!snap || typeof snap !== 'object') return { ok: false, reason: 'shape' };
|
|
437
|
+
if (bytes > 3 * 1024 * 1024) return { ok: false, reason: 'snapshot-oversize' };
|
|
438
|
+
// ownerId: stringa E uguale all'atteso — un valore di altro tipo non può
|
|
439
|
+
// coincidere con l'id atteso, quindi owner-mismatch copre anche la forma.
|
|
440
|
+
if (snap.ownerId !== ownerId) return { ok: false, reason: 'owner-mismatch' };
|
|
441
|
+
// Uno snapshot incompleto è un floor, non un tutto: mai autorevole.
|
|
442
|
+
if (snap.resyncRequired === true) return { ok: false, reason: 'snapshot-resync-required' };
|
|
443
|
+
// Il trasporto dichiara il tipo: dichiarato ma non application/json non è
|
|
444
|
+
// la superficie dello snapshot. L'ASSENZA di dichiarazione è tollerata.
|
|
445
|
+
if (contentType != null && !isJsonContentType(contentType)) return { ok: false, reason: 'snapshot-content-type' };
|
|
446
|
+
const schema = (field) => ({ ok: false, reason: `snapshot-schema:${field}` });
|
|
447
|
+
// resyncRequired, quando c'è, è un booleano del produttore: 1, "true" o
|
|
448
|
+
// un oggetto sono un'altra versione del contratto, non un caso limite.
|
|
449
|
+
if (snap.resyncRequired !== undefined && typeof snap.resyncRequired !== 'boolean') return schema('resyncRequired');
|
|
450
|
+
// asks È l'elenco che decide la chiusura degli alias: OBBLIGATORIO, array.
|
|
451
|
+
// Senza questa richiesta uno snapshot senza asks passava come 'ok' e la
|
|
452
|
+
// riconciliazione chiudeva alias ancora aperti su un elenco che non c'era.
|
|
453
|
+
if (!Array.isArray(snap.asks)) return schema('asks');
|
|
334
454
|
const boundedId = (id) => typeof id === 'string' && id.length > 0 && id.length <= SNAPSHOT_ID_MAX_CHARS;
|
|
335
455
|
const boundedElement = (e) => !!e && typeof e === 'object'
|
|
336
456
|
&& Buffer.byteLength(JSON.stringify(e), 'utf8') <= SNAPSHOT_ELEMENT_MAX_BYTES;
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
457
|
+
// Cap di pagina e per-elemento: identità e misura. Id non stringa o vuoto,
|
|
458
|
+
// elemento non oggetto o fuori budget → 'snapshot-element-oversize'
|
|
459
|
+
// (mapping storico invariato, il cap resta un cap). Il cap di pagina vale
|
|
460
|
+
// a quota PIENA (>= SNAPSHOT_MAX_ASKS): il produttore marca resyncRequired
|
|
461
|
+
// proprio a quella quota, quindi un elenco al cap SENZA marcatore non è
|
|
462
|
+
// una forma dell'owner e non prova l'assenza di nessuna domanda.
|
|
463
|
+
if (snap.asks.length >= SNAPSHOT_MAX_ASKS
|
|
464
|
+
|| !snap.asks.every((a) => boundedElement(a) && boundedId(a && a.id))) {
|
|
465
|
+
return { ok: false, reason: 'snapshot-element-oversize' };
|
|
466
|
+
}
|
|
467
|
+
if (profile !== 'view') return { ok: true };
|
|
468
|
+
// — da qui in poi solo lo schema della VISTA —
|
|
469
|
+
// Flag e scalari col tipo esatto con cui la vista li applica (il
|
|
470
|
+
// produttore li emette sempre: un tipo diverso è un'altra versione, non
|
|
471
|
+
// un caso limite da tollerare). Assenti restano ammessi dove applySnapshot
|
|
472
|
+
// ha un default; sbagliati mai.
|
|
473
|
+
if (snap.cursor !== undefined && snap.cursor !== null && typeof snap.cursor !== 'string') return schema('cursor');
|
|
474
|
+
if (snap.viewEpoch !== undefined && snap.viewEpoch !== null && typeof snap.viewEpoch !== 'number') return schema('viewEpoch');
|
|
475
|
+
if (snap.askReplyAccess !== undefined && typeof snap.askReplyAccess !== 'boolean') return schema('askReplyAccess');
|
|
476
|
+
if (snap.notifications !== undefined && snap.notifications !== null
|
|
477
|
+
&& !Array.isArray(snap.notifications)) return schema('notifications');
|
|
478
|
+
if (snap.fleetState !== undefined && snap.fleetState !== null
|
|
479
|
+
&& (typeof snap.fleetState !== 'object' || Array.isArray(snap.fleetState))) return schema('fleetState');
|
|
480
|
+
if (snap.fleetState && typeof snap.fleetState === 'object') {
|
|
481
|
+
if (snap.fleetState.available !== undefined && typeof snap.fleetState.available !== 'boolean') return schema('fleetState');
|
|
482
|
+
if (snap.fleetState.cells !== undefined && !Array.isArray(snap.fleetState.cells)) return schema('fleetState');
|
|
342
483
|
}
|
|
343
484
|
if (Array.isArray(snap.notifications)
|
|
344
485
|
&& (snap.notifications.length > SNAPSHOT_MAX_NOTIFY
|
|
345
486
|
|| !snap.notifications.every((e) => boundedElement(e) && boundedId(e && e.eventId)))) {
|
|
346
|
-
|
|
347
|
-
return false;
|
|
487
|
+
return { ok: false, reason: 'snapshot-element-oversize' };
|
|
348
488
|
}
|
|
349
489
|
if (snap.fleetState && typeof snap.fleetState === 'object') {
|
|
350
490
|
const cells = Array.isArray(snap.fleetState.cells) ? snap.fleetState.cells : [];
|
|
351
491
|
if (cells.length > SNAPSHOT_MAX_CELLS || !boundedElement(snap.fleetState)
|
|
352
492
|
|| !cells.every((c) => boundedElement(c) && boundedId(c && c.cell))) {
|
|
353
|
-
|
|
354
|
-
|
|
493
|
+
return { ok: false, reason: 'snapshot-element-oversize' };
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// Ogni ask: i campi che la card e la risposta usano davvero, col tipo del
|
|
497
|
+
// produttore. Assente è ammesso dove il produttore omette (options, ts);
|
|
498
|
+
// sbagliato mai — un numero al posto dell'id owner non deve diventare una
|
|
499
|
+
// chiave di risposta.
|
|
500
|
+
const askFieldsOk = (a) => (a.ownerAskId === undefined || (typeof a.ownerAskId === 'string' && a.ownerAskId.length > 0))
|
|
501
|
+
&& (a.question === undefined || typeof a.question === 'string')
|
|
502
|
+
&& (a.options === undefined || Array.isArray(a.options))
|
|
503
|
+
&& (a.session === undefined || typeof a.session === 'string')
|
|
504
|
+
&& (a.ts === undefined || typeof a.ts === 'number');
|
|
505
|
+
if (!snap.asks.every(askFieldsOk)) return schema('asks');
|
|
506
|
+
return { ok: true };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Uno snapshot per finestra, per owner, da qualunque chiamante (poll della
|
|
510
|
+
// view o porta). ESITO strutturato: { state: 'applied' | 'failed' |
|
|
511
|
+
// 'skipped', asks?, appliedToView? }.
|
|
512
|
+
// - profilo 'view' (default, il poll): lo schema completo decide; solo un
|
|
513
|
+
// snapshot pienamente valido entra nella view ('applied' qui significa
|
|
514
|
+
// applicato davvero);
|
|
515
|
+
// - profilo 'decision' (la porta): decide il contratto della CHIUSURA. Se
|
|
516
|
+
// lo snapshot è autorevole per la decisione ma NON applicabile alla
|
|
517
|
+
// vista (cursor malformato, notifications di tipo sbagliato, …) la
|
|
518
|
+
// vista resta ferma e STALE con la sua causa — ma l'elenco asks torna
|
|
519
|
+
// comunque alla porta, che è autorizzata a decidere: un campo che la
|
|
520
|
+
// chiusura non legge non può tenere aperta una card chiusa dall'owner.
|
|
521
|
+
async function snapshotOnce(peer, ownerId, store, { profile = 'view' } = {}) {
|
|
522
|
+
const view = viewFor(ownerId);
|
|
523
|
+
// INVARIANTE (punto d'ingresso, per owner): al massimo uno snapshot per
|
|
524
|
+
// finestra, da qualunque causa di resync. Se la finestra non è trascorsa
|
|
525
|
+
// non parte nessuna richiesta e NON si tocca nulla: né la scala, né il
|
|
526
|
+
// cooldown di recupero — il campo della finestra è separato
|
|
527
|
+
// (nextSnapshotAllowedAt) proprio perché i due meccanismi non si
|
|
528
|
+
// mangino a vicenda. Lo stato resta stale e la visura in state() mostra
|
|
529
|
+
// quando il prossimo tentativo è lecito.
|
|
530
|
+
const since = view.lastSnapshotAt === undefined
|
|
531
|
+
? Number.POSITIVE_INFINITY
|
|
532
|
+
: now() - view.lastSnapshotAt;
|
|
533
|
+
if (since < minSnapshotIntervalMs) {
|
|
534
|
+
view.stale = true;
|
|
535
|
+
view.nextSnapshotAllowedAt = view.lastSnapshotAt + minSnapshotIntervalMs;
|
|
536
|
+
return { state: 'skipped' };
|
|
537
|
+
}
|
|
538
|
+
view.lastSnapshotAt = now();
|
|
539
|
+
view.nextSnapshotAllowedAt = null;
|
|
540
|
+
const gen = generation;
|
|
541
|
+
const r = await fetchImpl(routeUrl(peer, '/event-feed/snapshot'), {
|
|
542
|
+
headers: identityHeaders(peer, store),
|
|
543
|
+
});
|
|
544
|
+
if (!r.ok) { view.stale = true; throw new Error(`snapshot HTTP ${r.status}`); }
|
|
545
|
+
// Size cap BEFORE JSON.parse: a body over the server budget is refused on
|
|
546
|
+
// sight, never parsed. La regola vive nel validatore unico; qui è la
|
|
547
|
+
// scorciatoia che evita di pagare il parse di un corpo già rifiutato.
|
|
548
|
+
const text = await r.text();
|
|
549
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
550
|
+
if (bytes > 3 * 1024 * 1024) {
|
|
551
|
+
view.stale = true; view.lastError = 'snapshot-oversize';
|
|
552
|
+
return { state: 'failed' };
|
|
553
|
+
}
|
|
554
|
+
const snap = JSON.parse(text);
|
|
555
|
+
// Il contratto della decisione decide l'ESITO dello snapshot; lo schema
|
|
556
|
+
// della vista decide l'APPLICAZIONE. Due giudizi, una sola funzione di
|
|
557
|
+
// regole.
|
|
558
|
+
const decision = profile === 'view'
|
|
559
|
+
? null
|
|
560
|
+
: validateSnapshot(ownerId, snap, bytes, contentTypeOf(r), 'decision');
|
|
561
|
+
const forView = validateSnapshot(ownerId, snap, bytes, contentTypeOf(r), 'view');
|
|
562
|
+
if (profile !== 'view' && !decision.ok) {
|
|
563
|
+
// La causa resta in lastError: è ciò che l'operatore (e il cooldown
|
|
564
|
+
// riarmato) mostrano finché l'owner non torna sanitario.
|
|
565
|
+
view.stale = true; view.lastError = decision.reason;
|
|
566
|
+
return { state: 'failed' };
|
|
567
|
+
}
|
|
568
|
+
if (!forView.ok) {
|
|
569
|
+
if (profile !== 'view') {
|
|
570
|
+
// Autorevole per la chiusura, non applicabile alla vista: la vista
|
|
571
|
+
// resta com'è (stale, con la causa sua), la decisione va avanti.
|
|
572
|
+
view.stale = true; view.lastError = forView.reason;
|
|
573
|
+
return { state: 'applied', asks: snap.asks, appliedToView: false };
|
|
355
574
|
}
|
|
575
|
+
view.stale = true; view.lastError = forView.reason;
|
|
576
|
+
return { state: 'failed' };
|
|
356
577
|
}
|
|
357
|
-
// A snapshot over the server budget is a floor, not a whole: resync.
|
|
358
|
-
if (snap.resyncRequired) { view.stale = true; return false; }
|
|
359
578
|
applySnapshot(ownerId, snap, gen);
|
|
360
|
-
return true;
|
|
579
|
+
return { state: 'applied', asks: snap.asks, appliedToView: true };
|
|
361
580
|
}
|
|
362
581
|
|
|
363
582
|
// One reconciliation round across every enabled peer. Never throws.
|
|
@@ -370,18 +589,68 @@ function createEventFeedClient(opts = {}) {
|
|
|
370
589
|
await Promise.all(peers.map(async (peer) => {
|
|
371
590
|
const ownerId = peer.nodeId;
|
|
372
591
|
if (running.has(ownerId)) return; // one loop per pair
|
|
592
|
+
// Il guard vale per TUTTO il round, non solo per lo stream: due poll
|
|
593
|
+
// sovrapposti (tick + backoff, o macchina carica) facevano due snapshot
|
|
594
|
+
// nella stessa finestra e il secondo, senza il flag di recupero,
|
|
595
|
+
// saltava il riarma del cooldown. streamOnce
|
|
596
|
+
// sovrascrive lo slot col vero handle di abort.
|
|
597
|
+
const slot = { abort: () => {} };
|
|
598
|
+
running.set(ownerId, slot);
|
|
373
599
|
const view = viewFor(ownerId);
|
|
374
600
|
try {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
601
|
+
// L'invariante PRIMA del consumo del cooldown: se la finestra non è
|
|
602
|
+
// trascorsa il tick non fa nulla — non consuma la scadenza del
|
|
603
|
+
// cooldown di recupero (il tentativo vero avverrà a finestra passata,
|
|
604
|
+
// col suo flag di recupero intatto) e non tocca il conto dei reset.
|
|
605
|
+
if (!view.cursor && view.lastSnapshotAt !== undefined
|
|
606
|
+
&& now() - view.lastSnapshotAt < minSnapshotIntervalMs) {
|
|
607
|
+
view.stale = true;
|
|
608
|
+
return;
|
|
380
609
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
610
|
+
// Il cooldown scaduto paga UN tentativo e azzera lo streak: se il
|
|
611
|
+
// tentativo fallisce, una nuova sequenza di reset deve poter
|
|
612
|
+
// ri-esaurire e ri-armare il cooldown al gradino successivo. Finché il
|
|
613
|
+
// cooldown è in piedi nessuna richiesta parte (il tappo resta in piedi).
|
|
614
|
+
let recovery = false;
|
|
615
|
+
if (view.resyncBlockedUntil) {
|
|
616
|
+
if (now() < view.resyncBlockedUntil) return;
|
|
617
|
+
view.resyncBlockedUntil = null;
|
|
618
|
+
view.consecutiveResets = 0;
|
|
619
|
+
recovery = true;
|
|
620
|
+
log(`event-feed-client: ${ownerId.slice(0, 8)}… resync cooldown expired: one fresh snapshot attempt`);
|
|
384
621
|
}
|
|
622
|
+
if (view.unsupported || (view.ingressBlockedUntil || 0) > Date.now()) return;
|
|
623
|
+
// La fase di ACQUISIZIONE del tentativo (capability + snapshot) ha UN
|
|
624
|
+
// SOLO punto di uscita per gli esiti non riusciti: throw, timeout,
|
|
625
|
+
// abort e 'failed' convergono tutti lì — non un ramo per tipo di
|
|
626
|
+
// errore, così il prossimo modo di fallire non sfugge. Un fallimento
|
|
627
|
+
// in questa fase con il cooldown scaduto RIARMA il gradino successivo
|
|
628
|
+
// (un tentativo per scadenza, la causa resta in lastError); l'errore
|
|
629
|
+
// lanciato si risolleva per il catch esterno (stale, lastError,
|
|
630
|
+
// backoff). Lo stream resta fuori dalla fase: un suo errore dopo uno
|
|
631
|
+
// snapshot riuscito è regime di trasporto (backoff), non di resync.
|
|
632
|
+
// 'skipped' non è un fallimento: l'invariante della finestra ha solo
|
|
633
|
+
// posticipato la richiesta, la scala non si tocca.
|
|
634
|
+
let attemptError = null;
|
|
635
|
+
let snapState = null; // null = tentativo non fatto (cursor già valido)
|
|
636
|
+
try {
|
|
637
|
+
if (!view.capabilityChecked) {
|
|
638
|
+
const capOk = await checkCapability(peer, ownerId);
|
|
639
|
+
if (!capOk) return; // senza la capability il gate blocca: nessun tentativo fatto
|
|
640
|
+
}
|
|
641
|
+
if (!view.cursor) {
|
|
642
|
+
snapState = await snapshotOnce(peer, ownerId, store); // profilo view
|
|
643
|
+
}
|
|
644
|
+
} catch (e) {
|
|
645
|
+
attemptError = e;
|
|
646
|
+
}
|
|
647
|
+
if (attemptError || (snapState && snapState.state === 'failed')) {
|
|
648
|
+
if (recovery) armResyncCooldown(view, attemptError ? 'recovery attempt failed' : 'recovery snapshot failed');
|
|
649
|
+
}
|
|
650
|
+
if (attemptError) throw attemptError;
|
|
651
|
+
// Un tentativo fallito o posticipato ferma il round; NESSUN tentativo
|
|
652
|
+
// (cursor già valido) va dritto allo stream.
|
|
653
|
+
if (snapState && snapState.state !== 'applied') return;
|
|
385
654
|
await streamOnce(peer, ownerId, store);
|
|
386
655
|
} catch (e) {
|
|
387
656
|
view.stale = true;
|
|
@@ -393,6 +662,10 @@ function createEventFeedClient(opts = {}) {
|
|
|
393
662
|
const delay = backoffMs(peer);
|
|
394
663
|
log(`event-feed-client: ${ownerId.slice(0, 8)}… retry in ${delay} ms (${view.lastError})`);
|
|
395
664
|
setTimeout(() => { void poll(); }, delay);
|
|
665
|
+
} finally {
|
|
666
|
+
// Libera lo slot solo se è ancora il NOSTRO: se il round è arrivato a
|
|
667
|
+
// streamOnce, quello ha già messo (e alla fine tolto) il suo slot vero.
|
|
668
|
+
if (running.get(ownerId) === slot) running.delete(ownerId);
|
|
396
669
|
}
|
|
397
670
|
}));
|
|
398
671
|
}
|
|
@@ -421,6 +694,12 @@ function createEventFeedClient(opts = {}) {
|
|
|
421
694
|
return {
|
|
422
695
|
views: [...views.entries()].map(([ownerId, v]) => ({
|
|
423
696
|
ownerId, cursor: v.cursor, viewEpoch: v.viewEpoch, stale: v.stale,
|
|
697
|
+
// Diagnostica (doctor / feed-state): dove la view è e quanto le
|
|
698
|
+
// manca — il cooldown residuo si legge con un confronto col clock.
|
|
699
|
+
resyncBlockedUntil: v.resyncBlockedUntil || null,
|
|
700
|
+
resyncExhaustions: v.resyncExhaustions || 0,
|
|
701
|
+
// L'attesa dell'invariante finestra, separata dal cooldown.
|
|
702
|
+
nextSnapshotAllowedAt: v.lastSnapshotAt === undefined ? null : v.lastSnapshotAt + minSnapshotIntervalMs,
|
|
424
703
|
// Both sides of the merge matter to the UI: the answer/dismiss gate
|
|
425
704
|
// (askReplyAccess) and the ingress/error state of the view.
|
|
426
705
|
askReplyAccess: v.askReplyAccess === true,
|
|
@@ -432,7 +711,100 @@ function createEventFeedClient(opts = {}) {
|
|
|
432
711
|
};
|
|
433
712
|
}
|
|
434
713
|
|
|
435
|
-
|
|
714
|
+
// A dismiss this node has CONFIRMED with the owner (the relay returned 2xx, or
|
|
715
|
+
// the owner said so in a frame): only then does the view forget the ask, and
|
|
716
|
+
// only then is it tombstoned. An uncertain or failed dismiss hides nothing.
|
|
717
|
+
function dismissConfirmed(ownerId, askId) {
|
|
718
|
+
const view = dropAskFromView(ownerId, askId);
|
|
719
|
+
noteAskClosed(ownerId, askId, view.viewEpoch);
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Esito TIPIZZATO: {status:'ok', asks} | {status:'skipped'|'pending'|'error',
|
|
724
|
+
// reason, retryAt}. SOLO 'ok' è autorevole (snapshot fresco, col contratto
|
|
725
|
+
// della decisione soddisfatto, acquisito in questa chiamata): uno skip, un
|
|
726
|
+
// pending, una finestra chiusa, un errore o un timeout non decidono MAI la
|
|
727
|
+
// chiusura di un alias.
|
|
728
|
+
const doorWindowAt = new Map(); // ownerId -> ultimo tentativo (owner non sottoscritti)
|
|
729
|
+
const doorInFlight = new Set(); // ownerId -> tentativo in volo (owner sottoscritti)
|
|
730
|
+
const DOOR_TIMEOUT_MS = 3000;
|
|
731
|
+
function doorTimeoutPromise() {
|
|
732
|
+
return new Promise((resolve) => {
|
|
733
|
+
const t = setTimeout(() => resolve(null), DOOR_TIMEOUT_MS);
|
|
734
|
+
if (typeof t.unref === 'function') t.unref();
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
async function ownerSnapshotAsks(ownerId) {
|
|
738
|
+
const store = opts.loadStore();
|
|
739
|
+
if (!store) return { status: 'error', reason: 'no-store' };
|
|
740
|
+
const id = String(ownerId);
|
|
741
|
+
const peer = (store.nodes || []).find((n) => n && n.nodeId === id && n.token && n.localPort);
|
|
742
|
+
if (!peer) return { status: 'error', reason: 'peer-unknown' };
|
|
743
|
+
if (peer.eventsReceive === true) {
|
|
744
|
+
if (doorInFlight.has(id)) {
|
|
745
|
+
const view = views.get(id);
|
|
746
|
+
const retryAt = (view && typeof view.lastSnapshotAt === 'number' ? view.lastSnapshotAt : now()) + minSnapshotIntervalMs;
|
|
747
|
+
return { status: 'pending', reason: 'attempt-in-flight', retryAt };
|
|
748
|
+
}
|
|
749
|
+
doorInFlight.add(id);
|
|
750
|
+
let state = null;
|
|
751
|
+
try {
|
|
752
|
+
// Profilo DECISION: la porta decide la chiusura degli alias, legge
|
|
753
|
+
// l'elenco che la decisione usa — e solo quello. La vista, nello
|
|
754
|
+
// stesso snapshotOnce, applica il suo schema completo o resta ferma.
|
|
755
|
+
state = await Promise.race([snapshotOnce(peer, id, store, { profile: 'decision' }), doorTimeoutPromise()]);
|
|
756
|
+
} catch (_) {
|
|
757
|
+
return { status: 'error', reason: 'transport' };
|
|
758
|
+
} finally {
|
|
759
|
+
doorInFlight.delete(id);
|
|
760
|
+
}
|
|
761
|
+
const view = views.get(id);
|
|
762
|
+
const retryAt = (view && typeof view.lastSnapshotAt === 'number' ? view.lastSnapshotAt : now()) + minSnapshotIntervalMs;
|
|
763
|
+
if (state === null) return { status: 'pending', reason: 'door-timeout', retryAt };
|
|
764
|
+
if (state.state === 'skipped') return { status: 'skipped', reason: 'window', retryAt };
|
|
765
|
+
if (state.state === 'applied') {
|
|
766
|
+
// L'elenco autorevole è quello dello snapshot: anche quando la vista
|
|
767
|
+
// non ha potuto applicarlo (appliedToView false) la decisione ha il
|
|
768
|
+
// suo contratto soddisfatto.
|
|
769
|
+
return Array.isArray(state.asks)
|
|
770
|
+
? { status: 'ok', asks: state.asks }
|
|
771
|
+
: { status: 'error', reason: 'applied-without-view' };
|
|
772
|
+
}
|
|
773
|
+
return { status: 'error', reason: 'snapshot-failed' };
|
|
774
|
+
}
|
|
775
|
+
const last = doorWindowAt.get(id);
|
|
776
|
+
if (last !== undefined && now() - last < minSnapshotIntervalMs) {
|
|
777
|
+
return { status: 'skipped', reason: 'window', retryAt: last + minSnapshotIntervalMs };
|
|
778
|
+
}
|
|
779
|
+
doorWindowAt.set(id, now());
|
|
780
|
+
// UN AbortController per fetch E corpo: un owner che manda gli header e
|
|
781
|
+
// non chiude il corpo non può appendere chi ha chiesto lo snapshot.
|
|
782
|
+
const ctrl = new AbortController();
|
|
783
|
+
const timer = setTimeout(() => ctrl.abort(), DOOR_TIMEOUT_MS);
|
|
784
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
785
|
+
try {
|
|
786
|
+
const r = await fetchImpl(routeUrl(peer, '/event-feed/snapshot'), {
|
|
787
|
+
headers: identityHeaders(peer, store), signal: ctrl.signal,
|
|
788
|
+
});
|
|
789
|
+
if (!r.ok) return { status: 'error', reason: `http-${r.status}` };
|
|
790
|
+
const text = await r.text();
|
|
791
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
792
|
+
let snap;
|
|
793
|
+
try { snap = JSON.parse(text); } catch (_) { return { status: 'error', reason: 'shape' }; }
|
|
794
|
+
// STESSO validatore della via sottoscritta, allo STESSO profilo
|
|
795
|
+
// decisionale: ownerId atteso, content-type, resyncRequired e i cap
|
|
796
|
+
// dell'elenco decidono qui esattamente come decidono là.
|
|
797
|
+
const verdict = validateSnapshot(id, snap, bytes, contentTypeOf(r), 'decision');
|
|
798
|
+
if (!verdict.ok) return { status: 'error', reason: verdict.reason };
|
|
799
|
+
return { status: 'ok', asks: snap.asks };
|
|
800
|
+
} catch (_) {
|
|
801
|
+
return { status: 'error', reason: 'transport' };
|
|
802
|
+
} finally {
|
|
803
|
+
clearTimeout(timer);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
return { start, stop, poll, state, reemit, viewFor, dismissConfirmed, ownerSnapshotAsks };
|
|
436
808
|
}
|
|
437
809
|
|
|
438
810
|
module.exports = { createEventFeedClient };
|