Repository navigation
Expand file tree
/
Copy pathbight.js
More file actions
1581 lines (1566 loc) · 68.7 KB
/
Copy pathbight.js
File metadata and controls
1581 lines (1566 loc) · 68.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Bight: a mempool monitor in a tab, in the shape everyone knows from mempool.space, over blaketestnode's browser node.
// The node in the tab is the shared loader (browser/tabnode.js, pinned by commit); this file is the page: the projected
// blocks the tab itself would build from its own mempool, the last blocks it validated, the fee bands, the sources,
// a graph over the session, and a search. Every transaction shown was validated here; nothing is relayed onward.
// The decisions (packing, bands, what was seen first, the chain cache, search, the status words, the sources, the
// settings, the markup of tiles and details) are lib/*.mjs, tested; this file wires them to the document, patching in
// place so a focused or selected element survives the next update.
export const VERSION = '2026-10-01.15';
const $ = (id) => document.getElementById(id);
const NODE = 'https://cdn.jsdelivr.net/gh/bitcoin-blake/blaketestnode@f1da4a6b64a6a9a6f791d2e5dea8a1c1be81ea09';
const RELAYS = [
'wss://relay.primal.net',
'wss://nostr.oxtr.dev',
'wss://nos.lol',
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nostr.mom',
];
export const MODULES = [
'pack',
'fees',
'seen',
'series',
'search',
'fmt',
'node-text',
'status',
'sources',
'version',
'chain',
'schedule',
'settings',
'view',
];
const store = (s) => ({
get: (k) => {
try {
return s().getItem(k);
} catch {
return null;
}
},
set: (k, v) => {
try {
s().setItem(k, v);
return true;
} catch {
return false;
}
},
del: (k) => {
try {
s().removeItem(k);
} catch {}
},
});
const LS = store(() => localStorage),
SS = store(() => sessionStorage);
const q = new URLSearchParams(location.search);
const embedded = q.get('embedded') === '1';
if (embedded) document.body.classList.add('embedded');
// every reload keeps the page's query (embedded=1, an accepted source) and changes only what it must
const reloadWith = (set = {}, drop = []) => {
const p = new URLSearchParams(location.search);
for (const k of drop) p.delete(k);
for (const [k, v] of Object.entries(set)) p.set(k, v);
const s = p.toString();
location.replace(location.pathname + (s ? '?' + s : ''));
};
// ---- notices: one line each under the top bar, patched in place (a focused button in one survives a change of its words);
// said to screen readers when a notice is new or grows more serious, not on every change of its words (a count of minutes
// would otherwise be spoken once a minute)
const RANK = { info: 0, warn: 1, bad: 2 };
function banner(id, cls, text, actions = []) {
let el = document.querySelector(`#banners [data-b="${id}"]`);
const sig = actions.map(([l]) => l).join('\n');
if (el && el.dataset.text === cls + text && el.dataset.sig === sig) return;
const prev = el?.dataset.cls;
if (!el) {
el = document.createElement('div');
el.dataset.b = id;
el.appendChild(document.createElement('span'));
$('banners').appendChild(el);
}
el.dataset.text = cls + text;
el.dataset.cls = cls;
el.className = 'banner ' + cls;
el.firstChild.textContent = text;
const btns = [...el.querySelectorAll('button')];
if (el.dataset.sig !== sig) {
// the buttons change only when their labels do; otherwise only what they do is updated
for (const b of btns) b.remove();
for (const [label, fn] of actions) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btn';
b.textContent = label;
b.onclick = fn;
el.appendChild(b);
}
el.dataset.sig = sig;
} else btns.forEach((b, i) => (b.onclick = actions[i][1]));
if (prev == null || (RANK[cls] ?? 0) > (RANK[prev] ?? 0)) say(text);
}
// a notice that goes while it holds the focus first hands the focus on (to the next notice's button, else the search), so
// a keyboard user is not dropped at the top of the page
const unbanner = (id) => {
const el = document.querySelector(`#banners [data-b="${id}"]`);
if (!el) return;
if (el.contains(document.activeElement)) {
const next = [...document.querySelectorAll('#banners [data-b] button')].find((b) => !el.contains(b));
(next ?? $('q')).focus();
}
el.remove();
};
// what is said to screen readers, one at a time, so a second message does not cut off the first
const sayQueue = [];
let saying = false;
const say = (text) => {
if (!text || sayQueue.at(-1) === text) return;
sayQueue.push(text);
if (saying) return;
saying = true;
const next = () => {
const t = sayQueue.shift();
if (t == null) return (saying = false);
$('announce').textContent = '';
setTimeout(() => {
$('announce').textContent = t;
setTimeout(next, 1500);
}, 50);
};
next();
};
// a stopped node is said once: the fatal notice replaces the node's own error notice (the same words), which pill() then
// leaves out for as long as the fatal one is there
// What is on screen stays, greyed, with the time it stopped: the figures are as they were then, not live.
const fatal = (text) => {
$('syncmsg').textContent = text;
unbanner('nodeerr');
banner('fatal', 'bad', text, [['Reload', () => location.reload()]]);
markStopped();
};
function markStopped() {
const main = document.querySelector('main');
if (!main || main.classList.contains('stopped')) return;
const at = new Date().toLocaleTimeString();
main.classList.add('stopped');
state.stoppedAt = at;
asOfDetail();
const note = document.createElement('p');
note.id = 'stopnote';
note.className = 'note';
note.textContent = `Stopped at ${at}: the blocks and the mempool below are as they were then, not live.`;
main.prepend(note);
for (const id of ['blocks-h', 'mp-h']) {
const h = $(id);
if (!h) continue;
const s = document.createElement('span');
s.className = 'mut asof';
s.textContent = ` · as of ${at}`;
h.append(s);
}
}
// the open detail, once stopped, says when its figures are from (a detail opened after the stop too)
function asOfDetail() {
const h = $('detail').querySelector('#dtitle');
// (a projected block's title says "as of" already: the time it was packed, the stop time once stopped)
if (!state.stoppedAt || !h || h.querySelector('.asof') || / as of /.test(h.textContent)) return;
const s = document.createElement('span');
s.className = 'mut asof';
s.textContent = ` · as of ${state.stoppedAt}`;
h.append(s);
}
const topBar = document.querySelector('.top');
function padTop() {
const sticky = getComputedStyle(topBar).position === 'sticky';
document.documentElement.style.scrollPaddingTop = sticky ? `${topBar.offsetHeight + 8}px` : '';
}
if ('ResizeObserver' in window) new ResizeObserver(padTop).observe(topBar);
addEventListener('resize', padTop);
padTop();
const fatalShown = () => !!document.querySelector('#banners [data-b="fatal"]');
// in a frame whose page is on another site, nothing that downloads, trusts a source or overrides the lock is done on a click
// (the page around it could have placed that click); it says to open Bight in its own tab instead
const foreignFrame = (() => {
if (top === self) return false;
try {
return top.location.origin !== location.origin;
} catch {
return true;
}
})();
const ownTab = () => {
const u = new URL(location.href);
u.searchParams.delete('embedded');
return u.href;
};
const openOwnTab = () => window.open(ownTab(), '_blank', 'noopener');
// ---- a cached page and a script from different releases: reload once for this pair of versions (only if the browser
// can remember that it did, or it would reload for ever), and run nothing meanwhile
{
const asked = document.querySelector('script[src*="bight.js"]')?.src.match(/v=([^&]+)/)?.[1];
const key = 'bight:mixed:' + decodeURIComponent(asked ?? '') + '>' + VERSION;
if (asked && decodeURIComponent(asked) !== VERSION && !SS.get(key) && SS.set(key, '1')) {
reloadWith({ v: VERSION });
await new Promise(() => {});
}
}
// ---- the code: the node's loader from the CDN (with a deadline: a CDN that hangs would leave "Starting…" for ever), then
// this page's own modules, cache-busted with the version so a page never runs another release's modules
let createTabNode, PARAMS, P, F, SE, SR, SC, FM, NT, ST, SO, VS, CH, SD, SET, V;
try {
const deadline = new Promise((_, no) => setTimeout(() => no(new Error('no answer in 20 seconds')), 20e3));
[{ createTabNode }, PARAMS] = await Promise.race([
Promise.all([import(`${NODE}/browser/tabnode.js`), import(`${NODE}/lib/params.mjs`)]),
deadline,
]);
[P, F, SE, SR, SC, FM, NT, ST, SO, VS, CH, SD, SET, V] = await Promise.all(MODULES.map((m) => import(`./lib/${m}.mjs?v=${VERSION}`)));
} catch (e) {
fatal(`Bight could not load its code (${e.message}). The CDN (cdn.jsdelivr.net) may be unreachable: check the connection and reload.`);
throw e;
}
const { esc, n, fmtAge, txUrl } = FM;
$('ver').textContent = VERSION;
const SNAP_BASE = PARAMS.SNAPSHOT.baseHeight;
const SNAP_SIZE = FM.fmtGB(PARAMS.SNAPSHOT.bytes);
// the welcome's figures, from the node this page pins
$('wl-base').textContent = n(SNAP_BASE);
$('wl-size').textContent = SNAP_SIZE;
// ---- the sources: stored, or proposed by a link and used only if the person agrees, for this visit
const DEFS = SO.defaultsFor(PARAMS);
const SRC = SO.resolveSources({ get: LS.get, query: q, accepted: (k) => SS.get('bight:accept:' + k), defaults: DEFS });
const storedSources = (app) => ({
snapshot: SO.storedValue(LS.get(app + ':snapshot')),
blocks: SO.storedValue(LS.get(app + ':blocks')),
});
for (const k of Object.keys(SRC.proposed))
if (SRC.use[k] !== SRC.proposed[k]) {
const v = SRC.proposed[k];
banner(
'src-' + k,
'warn',
`This link asks Bight to read the ${k === 'snapshot' ? 'snapshot' : 'blocks'} from ${SO.hostOf(v)}. A source you do not trust can show you a chain that is not the real one. It is ignored unless you choose it, for this visit only.`,
SO.validUrl(v)
? [
[
foreignFrame ? 'Open Bight in its own tab to choose' : 'Use it for this visit',
() => {
if (foreignFrame) return openOwnTab();
if (SS.set('bight:accept:' + k, v)) location.reload();
},
],
['Ignore', () => unbanner('src-' + k)],
]
: [['Ignore', () => unbanner('src-' + k)]],
);
}
// a source that is not the default, in use: said for as long as it is, with the way back
{
const custom = SO.nonDefault(SRC.use, DEFS);
if (custom.length)
banner(
'custom-src',
'warn',
`Reading ${custom.map((k) => `the ${k} from ${SO.hostOf(SRC.use[k])}`).join(' and ')}, not the default source. What this page shows is that source's chain.`,
[
[
'Back to the default',
() => {
for (const k of ['snapshot', 'blocks']) SS.del('bight:accept:' + k);
const w = SET.sourceWrites({ fields: DEFS, bight: storedSources('bight'), reef: storedSources('reef'), defaults: DEFS });
for (const x of w) x.del ? LS.del(x.key) : LS.set(x.key, x.set);
reloadWith({}, ['snapshot', 'blocks']);
},
],
],
);
}
const OPT = SO.parseOptions(LS.get('bight:options'));
const tn = createTabNode({ base: NODE, snapshotUrl: SRC.use.snapshot, blocksUrl: SRC.use.blocks, torrent: OPT.torrent, seed: OPT.seed });
const node = tn.node;
window.bight = { node, OPT, VERSION };
const state = {
blocks: new Map(), // height → { height, hash, prev, time, nTx, size, txids, fees }: the last ones, for the tiles
found: new Map(), // height → block: older blocks asked by a search, kept apart from the tiles (the last few)
wanted: new Map(), // height → when asked
searchId: 0, // each search's own req (chain.reqOf), so the node's answer, or its error, is matched to that search alone
arrived: new Map(), // height → { at (ms), alone }: when this page learned of it (chain.markArrived)
gen: 0, // the generation of the block requests: a reorganisation starts a new one, and older replies are ignored
seenTx: new Map(),
refusals: [],
samples: [],
template: null,
selected: null, // height of the mined block shown in the detail
openFound: null, // height of a block a search fetched, shown in the detail (it is not a tile)
selectedProj: null, // index of the projected block shown in the detail
opener: null, // { kind: 'p' | 'h' | 'r' | 'q', key } what opened the detail, for the focus on Close
pendingTx: null, // { txid, opener, timer }: a transaction asked of the node for the detail
openTx: null, // { txid, opener, fed }: the mempool transaction the detail shows, from the node's whole copy
pendingSearch: null, // { height, gen, timer }: a block asked of the node by a search
highlight: null,
followedAt: null, // when the page last began listening without a gap (ms): the first mempool state, moved on after a gap
followStartedAt: null, // when the first mempool state arrived (ms)
feedBeat: null, // the node's last heartbeat value, and when this page saw it change (ms, this browser's clock)
feedSeenAt: null,
lastTick: Date.now(),
skew: null, // this browser's clock less the web server's, in seconds (from the Date of version.json), for the node
templateWanted: false, // a template asked while the tab was hidden: asked when it is shown
lastSampleAt: 0,
announcedSync: false,
afterGap: false, // a gap in listening was seen (the timer late, online again, a frozen tab resumed): the next sync is a catch-up
notStarted: false, // "Not now" on the welcome: nothing runs until Start
refused: false, // the browser refused the lock (or has no Web Locks): nothing runs until Run anyway
following: false,
running: false,
idle: false,
wiped: false,
dirty: false,
log: [],
};
const now = () => Math.floor(Date.now() / 1000);
// the mempool as a list, computed once per state the node sends
let listOf = null,
listMemo = [];
// nothing is coming until the person acts: not started, or the lock refused
const notRunning = () => state.notStarted || state.refused;
// the mempool in words when it shows nothing, with why nothing is coming
const emptyOf = (mp) => ST.emptyWords(mp, { following: state.following, notStarted: state.notStarted, refused: state.refused });
const list = () => {
if (node.mempool !== listOf) {
listOf = node.mempool;
listMemo = P.mempoolList(node.mempool);
}
return listMemo;
};
const blockAt = (h) => state.blocks.get(h) ?? state.found.get(h);
// minutes between blocks lately, from the header times of the blocks shown: for the projected blocks' ETAs
const spacing = () => FM.spacingMin([...state.blocks.values()]);
// ---- status bar, pill and the node's state in words
tn.on('sync', ({ msg, pct, eta }) => {
$('syncmsg').textContent = msg;
$('synceta').textContent = eta || '';
if (pct == null) $('pb').hidden = true;
else {
$('pb').hidden = false;
$('pbi').style.width = Math.max(0, Math.min(100, pct)).toFixed(1) + '%';
$('pb').setAttribute('aria-valuenow', pct.toFixed(0));
}
pill();
});
function pill() {
const p = ST.pillState(node, { wiped: state.wiped, notStarted: state.notStarted });
$('pilldot').className = p.level;
$('pilltxt').textContent = p.text;
$('nodeinfo').textContent = node.st
? `${node.coins ? n(node.coins) + ' coins · ' : ''}${node.recv ? FM.fmtGB(node.recv) + ' fetched this session' : "from this browser's storage"}`
: '';
// what the node says about the chain and its sources, as notices
if (node.unresponsive) banner('slow', 'warn', ST.plainError('not answered for two minutes'), [['Reload', () => location.reload()]]);
else unbanner('slow');
if (node.error && !state.idle && !node.unresponsive && !fatalShown())
banner('nodeerr', 'bad', ST.plainError(node.error, node.errorName), [['Reload', () => location.reload()]]);
else unbanner('nodeerr');
const c = node.synced ? ST.chainState(node) : null;
if (c && (c.level === 'warn' || c.level === 'bad')) banner('chain', c.level === 'bad' ? 'bad' : 'warn', c.text);
else unbanner('chain');
$('chainnote').textContent = c ? c.text : '';
const fd = ST.feedState(node.mempool, { seenAt: state.feedSeenAt, followedAt: state.followStartedAt });
if (fd) banner('feed', 'warn', fd.text);
else unbanner('feed');
}
tn.on('log', ({ text, level }) => {
const r = NT.parseRefusal(text);
if (r) {
state.refusals.unshift(
`${new Date().toLocaleTimeString()} ${r.txid ? r.txid + '…' : '(no txid)'} ${r.reason}${r.from ? ' (' + r.from + ')' : ''}`,
);
state.refusals.length = Math.min(state.refusals.length, 30);
$('refusals').textContent = state.refusals.join('\n');
}
const s = NT.parseSeedLine(text);
if (s) $('s-seed').textContent = s.ok ? `${s.accepted} of ${s.total}` : `not loaded: ${s.error}`;
if (level === 'err') {
state.log.push(
String(text)
.replace(/https?:\/\/\S+/g, '<url>')
.slice(0, 200),
); // for diagnostics, without addresses
if (state.log.length > 20) state.log.shift();
}
});
tn.on('nostr', () => {
pill();
scheduleRender();
});
tn.on('message', (m) => {
unbanner('slowstart');
// a wipe that left no node running (the loader says so as a fatal error): nothing more is asked of it
if (m.type === 'error' && m.fatal) {
state.running = false;
fatal(ST.plainError(m.text, m.name));
}
if (m.type === 'responsive' || m.type === 'unresponsive' || m.type === 'error') pill();
if (m.type === 'mempool-tx') onMempoolTx(m);
if (m.type === 'error' && m.req === TEMPLATE_REQ) {
// a fault in the node's files is said once, in the notice; the tile only points at it. Else the whole plain sentence
const why = ST.isStorageFault(m) ? ST.FILES_FAILED : ST.plainError(m.text);
state.template = { height: (node.height ?? 0) + 1, txs: 0, fees: 0, weight: 0, checks: { ok: false, failed: [why] }, buildError: why };
scheduleRender();
}
// a request the node cannot answer (a block it does not have) is answered with an error that echoes its req: the search's
// answer only when it is the search still waiting (a tile's request refused by a rollback, or an earlier search's, is not)
if (m.type === 'error' && m.req != null && state.pendingSearch && m.req === state.pendingSearch.req) {
const h = state.pendingSearch.height;
dropPending();
// nothing is open for that height any more: a later reorganisation does not bring the abandoned search back
if (state.openFound === h) state.openFound = null;
// a fault in the node's files is not "no such block": it says so and points at the notice the loader's error raised
// "Block not found" is the node's own word for a height it has not; a fault in its files points at the notice; any
// other failure (a decode error, a timeout) is said as it is, not taken for a missing block
const words = ST.isStorageFault(m)
? `block ${esc(n(h))} could not be read: ${esc(ST.FILES_FAILED)}`
: /Block not found/.test(m.text ?? '')
? `the node has no block ${esc(n(h))} on its chain now (a reorganisation or a rollback may have just changed it); search again`
: `block ${esc(n(h))} could not be read: ${esc(ST.plainError(m.text, m.name))}`;
openDetail(`<h2 id="dtitle">Block ${esc(n(h))}</h2><p class="mut">${words}</p>`, { kind: 'q' }, { focus: false });
}
});
// ---- the chain tip and the last blocks: asked from the worker by height, cached, dropped when a reorganisation replaced them
tn.on('synced', (m) => {
// a gap in listening first: a block the node applied while the computer slept was not watched arriving. Nor was one
// applied by the first sync (it replays from where the files left off), nor by the first sync after any gap the timer,
// the browser's online event or a resumed tab saw first (the wake sync must fetch before it answers)
const gap = checkGap();
const watched = !gap && !state.afterGap && state.announcedSync;
state.afterGap = false;
if (!state.following && !state.wiped) {
state.following = true;
tn.followMempool({ relays: RELAYS });
$('s-relays').textContent = `${RELAYS.length} asked (the node does not report which answer)`;
}
const dropped = CH.onSynced(state.blocks, m);
// a reorganisation or a rollback: the requests already out may be answered from the branch it replaced, so a new
// generation is started, and the heights it replaced are asked again
const from = m.height - (m.applied ?? 0) + 1;
const replaced = [...state.wanted.keys()].filter((h) => h > m.height || (m.applied > 0 && h >= from && state.arrived.has(h)));
const gone = (h) => h > m.height || dropped.includes(h) || (m.applied > 0 && h >= from);
// every request still out was asked under the old generation, and its reply will be ignored: all are asked again now,
// not after the 30 s retry (the tiles would sit empty until then)
if (dropped.length || replaced.length) {
state.gen++;
state.wanted.clear();
}
// a block a search fetched at a height this pass applied (or above the tip) is of the branch it replaced, whatever the
// generation: it is fetched again if searched again
for (const h of [...state.found.keys()]) if (gone(h)) state.found.delete(h);
CH.markArrived(state.arrived, m, Date.now(), { watched }); // every height this pass applied reached this tab now
// an open block that a reorganisation replaced is not left on screen as validated: it says so, and the new block is
// shown when the node answers (the tile's reply, or a search asked again under the new generation)
const open = !$('detail').hidden;
if (open && state.selected != null && dropped.includes(state.selected)) {
const h = state.selected;
replacedDetail(h);
// the tiles ask for the last 8 heights only: an older block shown is asked for as a search would ask it
if (h <= m.height - 8) askSearch(h, { keepOpen: true });
}
if (open && state.openFound != null && gone(state.openFound)) {
const h = state.openFound;
state.found.delete(h);
replacedDetail(h, { opener: { kind: 'q' } });
if (h <= m.height) askSearch(h, { keepOpen: true });
}
// a search still waiting was asked under the generation just replaced: asked again
if (state.pendingSearch && state.pendingSearch.gen !== state.gen) askSearch(state.pendingSearch.height, { keepOpen: true });
document.title = `Bight · txbt4 · ${n(m.height)}`;
pill();
if (!state.announcedSync) {
state.announcedSync = true;
say(`Up to date at block ${n(m.height)}`);
}
wantBlocks();
askTemplate();
});
function wantBlocks() {
// nothing is asked before the first sync is done: the node's one queue is busy with it, and the heights are not final
if (state.wiped || !state.running || !node.synced) return;
// what was asked of heights no longer shown, and when blocks below the tiles arrived (the lowest tile keeps its
// predecessor's, for seen-first), are forgotten
if (node.height != null) CH.pruneBelow(state.wanted, node.height - 8);
if (state.blocks.size) CH.pruneBelow(state.arrived, Math.min(...state.blocks.keys()) - 1);
for (const h of CH.wantHeights({ height: node.height, blocks: state.blocks, wanted: state.wanted, now: Date.now(), floor: SNAP_BASE }))
askBlock(h, CH.reqOf(state.gen));
scheduleRender();
}
// a block asked of the node; nothing is asked of a node that is stopped or wiped
function askBlock(height, req) {
if (!state.running || state.wiped) return false;
tn.post({ type: 'block', height, req });
return true;
}
const blockOf = (m) => ({
height: m.height,
hash: m.hash,
prev: m.previousblockhash,
time: m.header?.time ?? null,
nTx: m.nTx,
size: m.size,
txids: m.txids ?? [],
// the fees its coinbase claimed: its value (the node sends it since c3f6a46) less the subsidy; null from an older node
fees: CH.feesClaimed(m.coinbaseValue, m.height),
});
tn.on('block', (m) => {
const r = CH.parseReq(m.req);
if (!r || state.wiped) return;
if (r.search) return onSearchBlock(m, r);
if (r.gen !== state.gen) return; // asked on a branch a reorganisation replaced: asked again under the new generation
state.wanted.delete(m.height);
const kept = CH.acceptBlock(state.blocks, blockOf(m), { tipHeight: node.height, tipHash: node.hash });
if (!kept) return;
if (state.blocks.size > 40) state.blocks.delete(Math.min(...state.blocks.keys()));
scheduleRender();
// the block open in the detail is shown again with what the node now says (after a reorganisation, the new block),
// without moving the focus
if (state.selected === m.height && !$('detail').hidden) showBlock(m.height, { focus: false });
});
// ---- the mempool: the loader keeps node.mempool; the page remembers every txid it accepted
tn.on('mempool', (mp) => {
checkGap();
state.followedAt ??= Date.now();
state.followStartedAt ??= Date.now();
// the publisher's heartbeat, by this browser's clock: a change seen now is a beat now; the node's first reading is the
// file's own time (another computer's clock), never taken as later than now
if (mp?.feedFileAt != null && mp.feedFileAt !== state.feedBeat) {
state.feedBeat = mp.feedFileAt;
state.feedSeenAt = Math.min(mp.feedFileAt, Date.now());
}
SE.rememberSeen(state.seenTx, list());
refreshTx();
// a sample for the graph from the node's own messages too: a background tab's timers are slowed, its messages are not
if (Date.now() - state.lastSampleAt >= 5000) sample();
scheduleRender();
askTemplate();
});
// the worker's own block for the next height: asked at most every 2 s, and within 2 s of a change however busy the mempool;
// whether it is still wanted is decided when the request goes, not when it was queued
// a hidden tab does not ask (each build checks a whole block in the worker's one queue, beside the mempool's adds): it asks
// once when shown
// the template request names itself: a build that throws is answered with an error that echoes it (kept out of the node's
// state by the loader), shown as the build failing, not as the node failing
const TEMPLATE_REQ = 'bight:t';
const askTemplate = (() => {
const go = SD.throttle(() => {
if (document.hidden) return void (state.templateWanted = true);
if (node.synced && !state.wiped && !state.idle) tn.post({ type: 'template', pay: '6a00', req: TEMPLATE_REQ });
}, 2000);
return () => node.synced && !state.wiped && go();
})();
tn.on('template', (m) => {
state.template = m;
scheduleRender();
});
// ---- rendering: once per frame at most, and not while the tab is hidden (it catches up when shown)
function scheduleRender() {
if (state.dirty) return;
state.dirty = true;
if (document.hidden) return;
requestAnimationFrame(renderAll);
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) return;
if (state.dirty) requestAnimationFrame(renderAll);
if (state.templateWanted) {
state.templateWanted = false;
askTemplate();
}
});
function renderAll() {
state.dirty = false;
if (state.wiped || state.idle) return;
const blocks = node.mempool ? P.packBlocks(list(), { rdts: state.template?.rdts ?? true }) : [];
renderProjected(blocks);
renderMempool(blocks);
renderMined();
centreRow();
pill();
refreshTx(); // the projected blocks were just packed again
}
// the next block and the chain tip in view: the row of tiles is scrolled to the divider between them when it first
// overflows and whenever the number of projected tiles changes, until the person scrolls the row themselves
const row = $('blocksrow');
let rowCount = -1,
rowManual = false;
const rowScrolled = () => (rowManual = true);
row.addEventListener('wheel', rowScrolled, { passive: true });
row.addEventListener('touchmove', rowScrolled, { passive: true });
row.addEventListener('pointerdown', (e) => e.target === row && rowScrolled()); // its scrollbar
function centreRow() {
const count = $('proj').children.length;
if (rowManual || count === rowCount || row.scrollWidth <= row.clientWidth) return;
rowCount = count;
row.scrollLeft = Math.max(0, row.querySelector('.divider').offsetLeft - row.clientWidth / 2);
}
// patch a row of tiles in place: one <button> per key, its content and name updated, moved only when out of place, so
// the focused one keeps the focus. What is no longer shown is removed first, so the walk below moves only what is out of
// order (a stale tile in the way would otherwise make it move every tile after it).
function patchTiles(container, items) {
const keys = new Set(items.map((it) => it.key));
const have = new Map();
for (const el of [...container.children]) {
const want = items.find((it) => it.key === el.dataset.k);
if (!keys.has(el.dataset.k) || el.tagName !== (want?.tag ?? 'button').toUpperCase()) el.remove();
else have.set(el.dataset.k, el);
}
let at = container.firstChild;
for (const it of items) {
let el = have.get(it.key);
if (!el) {
el = document.createElement(it.tag ?? 'button');
if (el.tagName === 'BUTTON') el.type = 'button';
el.dataset.k = it.key;
}
if (el.className !== it.cls) el.className = it.cls;
if (el.dataset.html !== it.html) {
el.innerHTML = it.html;
el.dataset.html = it.html;
}
// a name only on what can have one (a button); a plain box says what it says
if (it.label != null && el.tagName === 'BUTTON') el.setAttribute('aria-label', it.label);
if (it.expanded != null) {
el.setAttribute('aria-expanded', String(it.expanded));
el.setAttribute('aria-controls', 'detail');
}
for (const [k, v] of Object.entries(it.style ?? {})) el.style.setProperty(k, v);
el.onclick = it.onActivate ?? null;
if (el !== at) container.insertBefore(el, at);
else at = at.nextSibling;
}
}
const EMPTY_BLOCK = { txs: [], vsize: 0, fees: 0, weight: 0, min: null, max: null, med: null, wmed: null };
// the node's build of the next block in words, judged against the mempool this page shows and the block it packed
const templateNow = (blocks = state.lastBlocks ?? []) =>
NT.templateWords(state.template, node.height, node.hash, node.mempool ? { count: node.mempool.count, block: blocks[0] ?? null } : null);
function renderProjected(blocks) {
const tw = templateNow(blocks);
const note = tw.failed ? 'build fails' : tw.otherMempool ? 'built on a different mempool' : tw.differs ? 'the node’s build differs' : '';
const items = [];
const sel = (i) => state.selectedProj === i;
if (!blocks.length) {
// the same key as the full next block, so the focus stays on it when the mempool empties or fills
const e = V.emptyNextTile({
built: !!node.mempool && tw.ok,
words: emptyOf(node.mempool) ?? 'the mempool is empty',
});
items.push({
key: 'p0',
cls: `blk empty${sel(0) ? ' sel' : ''}`,
html: e.html,
label: e.label,
expanded: sel(0),
onActivate: () =>
node.mempool
? showProjected(EMPTY_BLOCK, 0)
: ((state.selectedProj = 0),
openDetail(`<h2 id="dtitle">The next block</h2><p class="mut">${esc(e.label)}</p>`, { kind: 'p', i: 0 })),
});
} else {
const cov = P.coverage(node.mempool, list());
if (cov.truncated)
items.push({
key: 'more',
cls: 'blk empty more',
tag: 'div',
html: `<span class="h">more</span><span class="s">${esc(n(Math.max(0, cov.totalVb - cov.shownVb)))} vB more in the mempool than this page sees</span>`,
});
const rest = P.remainder(list(), blocks, { rdts: state.template?.rdts ?? true });
if (rest.count) {
const r = V.remainderTile(rest);
items.push({ key: 'rest', cls: 'blk empty more', tag: 'div', html: r.html });
}
// in the order they read: the furthest projection first, the next block beside the chain tip
for (let i = blocks.length - 1; i >= 0; i--) {
const b = blocks[i];
const t = V.projTile(b, i, { built: i === 0 && tw.ok ? state.template : null, note: i === 0 ? note : '', spacing: spacing() });
const c = F.feeColors(b.wmed);
items.push({
key: 'p' + i,
cls: `blk proj${sel(i) ? ' sel' : ''}`,
html: t.html,
label: t.label,
expanded: sel(i),
style: { '--c1': c.c1, '--c2': c.c2 },
onActivate: () => showProjected(b, i),
});
}
}
patchTiles($('proj'), items);
state.lastBlocks = blocks;
}
function renderMined() {
const hs = [...state.blocks.keys()].sort((a, b) => b - a).slice(0, 8);
const sh = ST.signedHeight(node.nostr);
if (!hs.length) {
patchTiles($('mined'), [
{
key: 'empty',
cls: 'blk empty',
tag: 'div',
html: `<span class="h">last blocks</span><span class="s">${node.synced ? 'asking the node for the last blocks…' : notRunning() ? 'not started' : 'appear once the tab is up to date'}</span>`,
},
]);
return;
}
patchTiles(
$('mined'),
hs.map((h) => {
const b = state.blocks.get(h);
const known = b.txids
.slice(1)
.map((t) => state.seenTx.get(t))
.filter(Boolean);
const sw = seenOf(b);
const unsigned = sh == null || h > sh;
// how long ago it reached this tab, when it was watched arriving on its own; one that came in a catch-up (the first
// sync, after sleep) is aged by its header, and says so
const arr = state.arrived.get(h);
const fromArrival = !!arr?.alone;
const t = V.minedTile(b, {
med: P.weightedMedian(known),
known: known.length,
others: sw.others,
seenWords: sw.words,
unsigned,
ageS: fromArrival ? Math.round((Date.now() - arr.at) / 1000) : now() - (b.time ?? now()),
ageFrom: fromArrival ? 'arrival' : 'header',
});
return {
key: 'h' + h,
cls: `blk mined${state.selected === h ? ' sel' : ''}${unsigned ? ' unsigned' : ''}`,
html: t.html,
label: t.label,
expanded: state.selected === h,
onActivate: () => showBlock(h),
};
}),
);
}
const seenOf = (b) =>
SE.blockSeen(b, state.seenTx, {
followedAt: state.followedAt,
arrival: state.arrived.get(b.height) ?? null,
prevArrival: state.arrived.get(b.height - 1) ?? null,
});
// the detail panel takes the focus when it opens (the region's name, its title, is then read; it is not said again) and
// gives it back to what opened it when it closes; Close sits in its header, and Escape closes it
function openDetail(html, opener, { focus = true, keepPending = false } = {}) {
if (!keepPending) dropPending();
state.openTx = null; // set again by showTx when it is a mempool transaction
const d = $('detail');
// the focus on something inside the detail (Close) would be lost with the content: it goes to the detail itself
const inside = d.contains(document.activeElement) && document.activeElement !== d;
d.hidden = false;
const close = '<button class="btn" type="button" id="dclose">Close</button>';
const m = /^(<h2 id="dtitle">[\s\S]*?<\/h2>)/.exec(html);
d.innerHTML = m ? `<div class="dhead">${m[1]}${close}</div>${html.slice(m[1].length)}` : `<div class="dhead">${close}</div>${html}`;
state.opener = opener;
$('dclose').onclick = () => closeDetail();
if (focus || inside) d.focus();
if (!focus) {
const h = d.querySelector('#dtitle');
if (h && d.dataset.said !== h.textContent) say(h.textContent);
}
d.dataset.said = d.querySelector('#dtitle')?.textContent ?? '';
asOfDetail();
// a table in the detail is a tab stop (to scroll it by keyboard) only while it is wider than its box
scrollStops();
}
function scrollStops() {
for (const box of $('detail').querySelectorAll('.dtbl')) {
if (box.scrollWidth > box.clientWidth) box.setAttribute('tabindex', '0');
else box.removeAttribute('tabindex');
}
}
addEventListener('resize', () => !$('detail').hidden && scrollStops());
// Escape closes the detail from anywhere on the page (a tile, a row, the detail itself), unless a dialog is open: the
// dialog's own Escape is meant then
// The focus goes back to what opened it only when it was in the detail, on that opener, or nowhere; from anywhere else
// (a row, the search) it stays where it is
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape' || e.defaultPrevented || $('detail').hidden || document.querySelector('dialog[open]')) return;
e.preventDefault();
const a = document.activeElement;
closeDetail({ restore: !a || a === document.body || $('detail').contains(a) || a === openerEl(state.opener) });
});
// a transaction or a block asked of the node for the detail is forgotten when the detail closes or shows something else
function dropPending() {
for (const k of ['pendingTx', 'pendingSearch']) {
clearTimeout(state[k]?.timer);
state[k] = null;
}
}
// what opened the detail, in the document: a search (the box), a row's txid button, a tile
const openerEl = (o) =>
o?.kind === 'q'
? $('q')
: o?.kind === 'r'
? document.querySelector(`#rows tr[data-t="${o.key}"] .txbtn`)
: o
? document.querySelector(`[data-k="${o.kind}${o.key}"]`)
: null;
function closeDetail({ restore = true } = {}) {
dropPending();
$('detail').hidden = true;
$('detail').dataset.said = '';
const o = state.opener;
state.selected = null;
state.openFound = null;
state.selectedProj = null;
renderMined();
renderProjected(state.lastBlocks ?? []);
if (restore) (openerEl(o) ?? $('q')).focus();
}
function showBlock(h, { focus = true, foundTxid = null, opener = null } = {}) {
const b = blockAt(h);
if (!b) return;
state.selected = state.blocks.has(h) ? h : null;
state.openFound = state.blocks.has(h) ? null : h;
state.selectedProj = null;
renderMined();
renderProjected(state.lastBlocks ?? []);
const sw = seenOf(b);
const mpIds = new Set(list().map((x) => x.txid));
const sh = ST.signedHeight(node.nostr);
openDetail(
V.blockDetail(b, {
sw,
seenTx: state.seenTx,
signedWords:
sh == null
? 'no signed chain tip to check it against yet'
: h > sh
? 'above the signed chain tip: not yet signed'
: 'the signed chain tip agrees',
inMempool: b.txids.filter((t) => mpIds.has(t)).length,
foundTxid,
arrival: state.arrived.get(h) ?? null,
}),
opener ?? (foundTxid || !state.blocks.has(h) ? { kind: 'q' } : { kind: 'h', key: h }),
{ focus },
);
}
function showProjected(b, i) {
state.selected = null;
state.openFound = null;
state.selectedProj = i;
state.highlight = null;
renderMined();
renderProjected(state.lastBlocks ?? []);
const tw = templateNow();
openDetail(V.projDetail(b, i, { tw: i === 0 && state.template ? tw : null, asOf: state.stoppedAt ?? new Date().toLocaleTimeString() }), {
kind: 'p',
key: i,
});
}
// ---- the mempool panel, the histogram, the table
function renderMempool(blocks) {
const m = node.mempool;
if (!m) {
// no mempool yet: the table says why, and the figures are "—" when nothing is coming (not started), "…" while it is
const words = emptyOf(null);
for (const id of ['m-count', 'm-size', 'm-fees', 'm-med', 'm-next']) $(id).textContent = notRunning() ? '—' : '…';
// where it comes from: nothing is asked while nothing runs
if (notRunning()) for (const id of ['s-relays', 's-seed', 's-feed', 's-feedtx']) $(id).textContent = '—';
$('rows').innerHTML = `<tr><td colspan="6" class="mut">${esc(words)}</td></tr>`;
return;
}
const all = list();
const cov = P.coverage(m, all);
const wmed = P.weightedMedian(all);
const tw = templateNow(blocks);
$('m-count').textContent = n(m.count);
$('m-size').textContent = `${n(m.bytes)} vB`;
$('m-fees').textContent = `${n(m.fees)} sat`;
$('m-med').textContent = wmed != null ? `${wmed.toFixed(1)} sat/vB${cov.truncated ? ' (of those shown)' : ''}` : '—';
$('m-next').textContent = tw.text;
$('m-next').classList.toggle('badt', !!tw.failed && !tw.stale);
$('m-cover').textContent = cov.truncated
? `This page sees the top ${n(cov.shown)} of ${n(cov.total)} transactions by fee rate; the bands, the median, the projected blocks and the table cover those.`
: '';
$('s-seen').textContent = n(m.stats.seen);
$('s-acc').textContent = n(m.stats.accepted);
$('s-ref').textContent = n(m.stats.refused);
$('s-drop').textContent = n(m.stats.dropped);
$('s-feed').textContent = state.feedSeenAt
? `${fmtAge(Math.max(0, Math.round((Date.now() - state.feedSeenAt) / 1000)))} ago`
: m.following
? 'not yet'
: '…';
$('s-feedtx').textContent = m.lastFeedAt ? `${fmtAge(Math.max(0, now() - Math.floor(m.lastFeedAt / 1000)))} ago` : 'none yet';
// the bands: bars for the eye, a table for a screen reader
const counts = F.histogram(all);
const max = Math.max(1, ...counts);
const bars = F.BANDS.map(
(b, i) =>
`<div style="height:${((counts[i] / max) * 100).toFixed(1)}%" title="${esc(F.bandLabel(i))} sat/vB: ${esc(n(counts[i]))} vB"><span>${i % 2 ? '' : i === 0 ? '<1' : b}</span></div>`,
).join('');
if ($('hist').dataset.html !== bars) {
$('hist').innerHTML = bars;
$('hist').dataset.html = bars;
const top = counts
.map((c, i) => [c, i])
.filter(([c]) => c > 0)
.sort((a, b) => b[0] - a[0])
.slice(0, 3);
$('hist').setAttribute(
'aria-label',
top.length
? `vB by fee rate; the largest bands: ${top.map(([c, i]) => `${F.bandLabel(i)} sat/vB, ${n(c)} vB`).join('; ')}`
: 'vB by fee rate: none',
);
$('histtbl').innerHTML = F.BANDS.map((_, i) => `<tr><td>${esc(F.bandLabel(i))}</td><td>${esc(n(counts[i]))}</td></tr>`).join('');
}
renderRows(all, blocks);
}
// the table: rows keyed by txid and patched in place; the age cell is updated on its own, so a row's link or a text
// selection in it survives the next update
function renderRows(all, blocks) {
const tb = $('rows');
const sel = getSelection();
if (sel && !sel.isCollapsed && tb.contains(sel.anchorNode)) return; // someone is selecting a txid: leave it
const blockOf = P.blockIndex(blocks);
const t0 = now();
const rows = [...all].sort((a, b) => b.at - a.at).slice(0, 300);
if (!rows.length) {
tb.innerHTML = `<tr><td colspan="6" class="mut">${esc(emptyOf(node.mempool) ?? 'empty')}</td></tr>`;
return;
}
// what is no longer listed goes first (but not a row holding the focus), so the walk moves only what is out of order
const keep = new Set(rows.map((t) => t.txid));
const have = new Map();
for (const tr of [...tb.children]) {
if (tr.dataset.t && (keep.has(tr.dataset.t) || tr.contains(document.activeElement))) have.set(tr.dataset.t, tr);
else tr.remove();
}
let at = tb.firstChild;
for (const t of rows) {
let tr = have.get(t.txid);
const i = blockOf.get(t.txid);
const html = `<td class="mono"><button type="button" class="txbtn" data-open="${esc(t.txid)}">${esc(t.txid.slice(0, 20))}…</button> <a class="ext" href="${esc(txUrl(t.txid))}" target="_blank" rel="noopener" aria-label="transaction ${esc(t.txid.slice(0, 12))} on mempool.guide (another site, not checked by this tab)" title="on mempool.guide: another site, not checked by this tab">↗</a></td><td class="age"></td><td class="amt">${esc(n(t.vsize))}</td><td class="amt">${esc(n(t.fee))}</td><td class="amt">${esc(t.feeRate.toFixed(1))}</td><td>${i == null ? 'later' : i === 0 ? 'next' : `#${i + 1}`}</td>`;
if (!tr) {
tr = document.createElement('tr');
tr.dataset.t = t.txid;
}
if (tr.dataset.html !== html) {
tr.innerHTML = html;
tr.dataset.html = html;
}
tr.classList.toggle('hi', state.highlight === t.txid);
tr.querySelector('.age').textContent = fmtAge(Math.max(0, t0 - t.at));
if (tr !== at) tb.insertBefore(tr, at);
else at = at.nextSibling;
}
// a focused row no longer listed stays, after the others, until the focus leaves it
for (const tr of have.values()) if (!keep.has(tr.dataset.t) && !tr.contains(document.activeElement)) tr.remove();
}
$('rows').addEventListener('click', (e) => {
const b = e.target.closest('button[data-open]');
if (b) openTx(b.dataset.open, { kind: 'r', key: b.dataset.open });
});
// one transaction of the mempool, in the page: from the full copy the node sent when it has one, else asked of the node
// (given up after 15 s); with where it would go in the projected blocks
function openTx(txid, opener) {
const full = (node.mempool?.txs ?? []).find((x) => x.txid === txid);
state.highlight = txid;
state.selected = null;
state.openFound = null;
state.selectedProj = null;
scheduleRender();
if (full) return showTx(full, opener);
const block = P.blockIndex(state.lastBlocks ?? []).get(txid) ?? null;
openDetail(`<h2 id="dtitle">In this tab's mempool</h2><p class="mut">asking the node for ${esc(txid.slice(0, 20))}…</p>`, opener);
const timer = setTimeout(() => {
if (state.pendingTx?.txid !== txid) return;
state.pendingTx = null;
openDetail(
`<h2 id="dtitle">No answer</h2><p class="mut">the node did not answer for ${esc(txid.slice(0, 20))}… in 15 seconds; search again to retry</p>`,
opener,
{ focus: false },
);
}, 15e3);