@fougere/calls 0.5.0-alpha.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.
- package/LICENSE +21 -0
- package/README.md +31 -0
- package/dist/CallRing.d.ts +45 -0
- package/dist/CallRing.d.ts.map +1 -0
- package/dist/CallRing.js +119 -0
- package/dist/CallRing.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +179 -0
- package/dist/index.js.map +1 -0
- package/dist/page.d.ts +23 -0
- package/dist/page.d.ts.map +1 -0
- package/dist/page.js +536 -0
- package/dist/page.js.map +1 -0
- package/dist/panel.d.ts +30 -0
- package/dist/panel.d.ts.map +1 -0
- package/dist/panel.js +102 -0
- package/dist/panel.js.map +1 -0
- package/dist/rings.d.ts +88 -0
- package/dist/rings.d.ts.map +1 -0
- package/dist/rings.js +112 -0
- package/dist/rings.js.map +1 -0
- package/package.json +58 -0
- package/src/CallRing.ts +119 -0
- package/src/index.ts +205 -0
- package/src/page.ts +535 -0
- package/src/panel.ts +123 -0
- package/src/rings.ts +158 -0
package/src/page.ts
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panel, as one file with no dependency.
|
|
3
|
+
*
|
|
4
|
+
* Inline on purpose: this package ships no framework and no build step, and a dev tool that
|
|
5
|
+
* needs `npm install` before it shows anything is a dev tool nobody opens. The page reads
|
|
6
|
+
* its own origin — the door that served it also serves its events — so there is no address
|
|
7
|
+
* to configure and no CORS to arrange.
|
|
8
|
+
*
|
|
9
|
+
* FOUR views, and the number is a decision. Six grew one tab at a time, each with its own
|
|
10
|
+
* empty state and its own caveat, and the result read as noise rather than as an answer.
|
|
11
|
+
* They fold along what a reader actually asks:
|
|
12
|
+
*
|
|
13
|
+
* Calls what happened, and did it hold — the screen that IS the argument
|
|
14
|
+
* Refused what did not, from two sources, since one of them is never a call
|
|
15
|
+
* Activity what the process did along the way — logs and statements are one stream,
|
|
16
|
+
* both chronological, both uncorrelated to a call; two tabs said that twice
|
|
17
|
+
* Served what it would answer if asked, filled before anything is called
|
|
18
|
+
*
|
|
19
|
+
* Traces lost its tab: it spoke for the receiving side only, and a trace already shows in a
|
|
20
|
+
* call's detail. A tab that half-delivers is worse than a field that says what it knows.
|
|
21
|
+
*/
|
|
22
|
+
export function page(title: string): string {
|
|
23
|
+
return `<!doctype html>
|
|
24
|
+
<html lang="en">
|
|
25
|
+
<head>
|
|
26
|
+
<meta charset="utf-8">
|
|
27
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
28
|
+
<title>${title} — calls</title>
|
|
29
|
+
<style>
|
|
30
|
+
:root {
|
|
31
|
+
color-scheme: light dark;
|
|
32
|
+
/* What the sticky header occupies — the one number two rules must agree on. */
|
|
33
|
+
--head: 76px;
|
|
34
|
+
--bg: #fbfbfd; --panel: #fff; --raise: #f4f4f8; --line: #e5e5ee; --ink: #16161d; --dim: #6f6f7e;
|
|
35
|
+
--local: #0f7b34; --remote: #0a6f92; --system: #7040cf; --bad: #c3202c; --warn: #a06800;
|
|
36
|
+
--r: 10px;
|
|
37
|
+
}
|
|
38
|
+
@media (prefers-color-scheme: dark) {
|
|
39
|
+
:root {
|
|
40
|
+
--bg: #0c0c10; --panel: #14141a; --raise: #1b1b23; --line: #26262f; --ink: #e9e9f2; --dim: #8b8b9b;
|
|
41
|
+
/* Coloured text stays BELOW the ink in dark: #3fc9f6 read brighter than --ink and
|
|
42
|
+
bloomed on OLED. */
|
|
43
|
+
--local: #56b86a; --remote: #5ab4d8; --system: #a684e0; --bad: #f08078; --warn: #d4a944;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
* { box-sizing: border-box; }
|
|
47
|
+
/* Three steps and no fourth: 11px for headers and chips, 12px for monospace, 13px for
|
|
48
|
+
prose. The half-pixel scale that came before was tuned by hand, one tab at a time. */
|
|
49
|
+
body { margin: 0; background: var(--bg); color: var(--ink);
|
|
50
|
+
font: 0.8125rem/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
51
|
+
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.75rem;
|
|
52
|
+
font-feature-settings: "liga" 0; }
|
|
53
|
+
|
|
54
|
+
/* ── the shell: one bar, one row of tabs, one surface ───────────────────── */
|
|
55
|
+
header { position: sticky; top: 0; z-index: 3; background: var(--bg);
|
|
56
|
+
border-bottom: 1px solid var(--line); padding: 12px 20px 0; }
|
|
57
|
+
.bar { display: flex; align-items: baseline; gap: 14px; }
|
|
58
|
+
h1 { margin: 0; font-size: 0.9375rem; font-weight: 650; letter-spacing: -.01em; }
|
|
59
|
+
h1 span { color: var(--dim); font-weight: 450; margin-left: 6px; }
|
|
60
|
+
.vitals { margin-left: auto; display: flex; gap: 16px; color: var(--dim);
|
|
61
|
+
font-variant-numeric: tabular-nums; font-size: 0.75rem; }
|
|
62
|
+
.vitals i { font-style: normal; }
|
|
63
|
+
.vitals b { color: var(--ink); font-weight: 600; }
|
|
64
|
+
.dot { display: inline-block; width: 6px; height: 6px; border-radius: 99px;
|
|
65
|
+
background: var(--dim); margin-right: 6px; vertical-align: middle; }
|
|
66
|
+
.dot.on { background: var(--local); } .dot.off { background: var(--bad); }
|
|
67
|
+
|
|
68
|
+
nav { display: flex; gap: 20px; margin-top: 10px; }
|
|
69
|
+
nav a { padding: 0 0 9px; color: var(--dim); text-decoration: none; font-weight: 550;
|
|
70
|
+
border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
|
71
|
+
nav a[aria-current="page"] { color: var(--ink); border-bottom-color: var(--ink); }
|
|
72
|
+
nav a b { display: inline-block; margin-left: 5px; padding: 0 5px; border-radius: 99px;
|
|
73
|
+
background: var(--bad); color: #fff; font-size: 10.5px; font-weight: 700; }
|
|
74
|
+
|
|
75
|
+
/* Past this, an hour on the left and a verdict on the right stop being one glance. */
|
|
76
|
+
main { padding: 14px 20px 60px; max-width: 1400px; }
|
|
77
|
+
|
|
78
|
+
/* ── one visual system: every view is the same table ─────────────────────── */
|
|
79
|
+
.pills { display: flex; gap: 5px; margin-bottom: 10px; }
|
|
80
|
+
.pills button { font: inherit; font-size: 12px; padding: 3px 10px; border-radius: 99px;
|
|
81
|
+
cursor: pointer; border: 1px solid var(--line); background: var(--panel); color: var(--dim); }
|
|
82
|
+
.pills button[aria-pressed="true"] { color: var(--ink); border-color: var(--ink); }
|
|
83
|
+
|
|
84
|
+
table { width: 100%; border-collapse: collapse; }
|
|
85
|
+
/* Sticky under the bar: 400 rows and one scroll used to leave the columns unnamed. */
|
|
86
|
+
th { position: sticky; top: var(--head); z-index: 2; background: var(--bg);
|
|
87
|
+
text-align: left; font-size: 0.6875rem; font-weight: 550; color: var(--dim);
|
|
88
|
+
padding: 6px 10px; box-shadow: inset 0 -1px var(--line); }
|
|
89
|
+
td { padding: 5px 10px; vertical-align: baseline; }
|
|
90
|
+
/* Zebra rather than borders: a 55%-transparent line on a dark ground was 2% contrast,
|
|
91
|
+
so the densest view lost its rows exactly where it needed them most. */
|
|
92
|
+
tbody tr:nth-child(even) { background: color-mix(in oklab, var(--raise) 55%, transparent); }
|
|
93
|
+
tbody tr:hover { background: var(--raise); }
|
|
94
|
+
tbody tr.pick { cursor: pointer; }
|
|
95
|
+
tbody tr.fresh td { animation: in .6s ease-out; }
|
|
96
|
+
/* Against --raise, not a hue: 14% of cyan is invisible on white and a flash on black. */
|
|
97
|
+
@keyframes in { from { background: var(--raise); } }
|
|
98
|
+
|
|
99
|
+
.lead { font-weight: 550; }
|
|
100
|
+
.soft { color: var(--dim); }
|
|
101
|
+
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
102
|
+
.tag { font-size: 0.6875rem; font-weight: 600; }
|
|
103
|
+
.local { color: var(--local); } .remote { color: var(--remote); }
|
|
104
|
+
.system { color: var(--system); } .unrouted, .failed, .error { color: var(--bad); }
|
|
105
|
+
.warn { color: var(--warn); }
|
|
106
|
+
/* The bar says HOW LONG, and its colour says how long too — the route is said in words
|
|
107
|
+
beside it. Painting the route here spent three hues on a fact constant all session. */
|
|
108
|
+
td.bar { width: 60px; padding-left: 0; }
|
|
109
|
+
.meter { display: inline-block; height: 5px; border-radius: 3px; vertical-align: middle; }
|
|
110
|
+
.meter.quick { background: var(--dim); opacity: .45; }
|
|
111
|
+
.meter.warm { background: var(--warn); }
|
|
112
|
+
.meter.slow { background: var(--bad); }
|
|
113
|
+
.chip { font-size: .6875rem; font-weight: 650; padding: 1px 6px; border-radius: 5px;
|
|
114
|
+
background: color-mix(in oklab, currentColor 14%, transparent); }
|
|
115
|
+
|
|
116
|
+
/* One line, never a paragraph. A caveat is a footnote, not an apology. */
|
|
117
|
+
.empty { padding: 64px 10px; text-align: center; color: var(--dim); }
|
|
118
|
+
.note { margin: 14px 0 0; color: var(--dim); font-size: 12px; }
|
|
119
|
+
code { background: var(--raise); border-radius: 4px; padding: 1px 5px; font-size: 12px; }
|
|
120
|
+
|
|
121
|
+
/* The graph: SVG, no library. A node is a frond, an edge is a call that was made. */
|
|
122
|
+
.shape { width: 100%; height: min(62vh, 520px); display: block; }
|
|
123
|
+
.shape text { font: 12px ui-sans-serif, system-ui, sans-serif; fill: var(--ink); }
|
|
124
|
+
.shape .sub { font-size: 10.5px; fill: var(--dim); }
|
|
125
|
+
.shape .node { fill: var(--panel); stroke: var(--line); stroke-width: 1.5; cursor: pointer; }
|
|
126
|
+
.shape .node.here { stroke: var(--ink); }
|
|
127
|
+
.shape .node.off { stroke: var(--bad); stroke-dasharray: 4 3; }
|
|
128
|
+
.shape .edge { fill: none; stroke: currentColor; opacity: .55; }
|
|
129
|
+
.shape .edge.dead { stroke-dasharray: 5 4; opacity: .4; }
|
|
130
|
+
.shape g.pick:hover .node { fill: var(--raise); }
|
|
131
|
+
.legend { display: flex; gap: 18px; color: var(--dim); font-size: 0.75rem; margin-top: 6px; }
|
|
132
|
+
/* The most structural thing on this tab was the smallest text on the page. */
|
|
133
|
+
h2.frond { font-size: 0.8125rem; font-weight: 650; margin: 24px 0 8px;
|
|
134
|
+
display: flex; align-items: baseline; gap: 8px; }
|
|
135
|
+
|
|
136
|
+
aside { position: fixed; inset: 0 0 0 auto; width: min(420px, 100%); background: var(--panel);
|
|
137
|
+
border-left: 1px solid var(--line); padding: 18px 20px; overflow: auto;
|
|
138
|
+
transform: translateX(100%); transition: transform .16s ease-out; z-index: 4; }
|
|
139
|
+
aside[open] { transform: none; }
|
|
140
|
+
aside h2 { margin: 0 0 14px; font-size: 13.5px; }
|
|
141
|
+
aside dl { display: grid; grid-template-columns: auto 1fr; gap: 5px 14px; margin: 0; }
|
|
142
|
+
aside dt { color: var(--dim); font-size: 12.5px; }
|
|
143
|
+
aside dd { margin: 0; word-break: break-all; }
|
|
144
|
+
aside button { position: absolute; top: 12px; right: 16px; background: none; border: none;
|
|
145
|
+
color: var(--dim); font-size: 17px; cursor: pointer; }
|
|
146
|
+
</style>
|
|
147
|
+
</head>
|
|
148
|
+
<body>
|
|
149
|
+
<header>
|
|
150
|
+
<div class="bar">
|
|
151
|
+
<h1>${title}<span id="where"></span></h1>
|
|
152
|
+
<div class="vitals">
|
|
153
|
+
<span>in flight <b id="inflight">0</b></span>
|
|
154
|
+
<span>calls <b id="total">0</b><i id="dropped" class="failed"></i></span>
|
|
155
|
+
<span><i class="dot" id="dot"></i><span id="live">connecting</span></span>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
<nav id="nav">
|
|
159
|
+
<a href="#calls" aria-current="page">Calls</a>
|
|
160
|
+
<a href="#refused">Refused</a>
|
|
161
|
+
<a href="#activity">Activity</a>
|
|
162
|
+
<a href="#shape">Shape</a>
|
|
163
|
+
<a href="#served">Served</a>
|
|
164
|
+
</nav>
|
|
165
|
+
</header>
|
|
166
|
+
<main id="main"></main>
|
|
167
|
+
<aside id="detail"><button id="close" aria-label="close">×</button><div id="body"></div></aside>
|
|
168
|
+
|
|
169
|
+
<script>
|
|
170
|
+
const state = { calls: [], errors: [], logs: [], queries: [], model: null,
|
|
171
|
+
cursors: {}, view: 'calls', filter: 'all', kind: 'all', fronds: [] };
|
|
172
|
+
const main = document.getElementById('main');
|
|
173
|
+
|
|
174
|
+
const el = (tag, attrs = {}, html = '') => {
|
|
175
|
+
const node = document.createElement(tag);
|
|
176
|
+
for (const [k, v] of Object.entries(attrs)) if (v !== null) node.setAttribute(k, v);
|
|
177
|
+
if (html) node.innerHTML = html;
|
|
178
|
+
return node;
|
|
179
|
+
};
|
|
180
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
181
|
+
const clock = (at) => new Date(at).toTimeString().slice(0, 8);
|
|
182
|
+
const routeOf = (c) => c.route ?? 'unrouted';
|
|
183
|
+
/** What colour is FOR on this page: how much a call cost, not where it ran. */
|
|
184
|
+
const costOf = (ms) => (ms >= 1000 ? 'slow' : ms >= 100 ? 'warm' : 'quick');
|
|
185
|
+
|
|
186
|
+
/** Every view is this table. One rhythm, so the eye moves the same way on each tab. */
|
|
187
|
+
function table(head, rows) {
|
|
188
|
+
const t = el('table', {}, '<thead><tr>' + head.map((h) => '<th' + (h.num ? ' class="num"' : '')
|
|
189
|
+
+ (h.w ? ' style="width:' + h.w + '"' : '') + '>' + h.label + '</th>').join('') + '</tr></thead>');
|
|
190
|
+
const body = el('tbody');
|
|
191
|
+
for (const row of rows) body.append(row);
|
|
192
|
+
t.append(body);
|
|
193
|
+
|
|
194
|
+
return t;
|
|
195
|
+
}
|
|
196
|
+
const empty = (line) => el('div', { class: 'empty' }, line);
|
|
197
|
+
const note = (line) => el('p', { class: 'note' }, line);
|
|
198
|
+
|
|
199
|
+
// ── Calls ────────────────────────────────────────────────────────────────────
|
|
200
|
+
/** One filter row, wherever a view has a dimension worth narrowing. */
|
|
201
|
+
function pills(options, held, set) {
|
|
202
|
+
const row = el('div', { class: 'pills' });
|
|
203
|
+
for (const one of options) {
|
|
204
|
+
const b = el('button', { 'aria-pressed': String(held === one) }, one);
|
|
205
|
+
b.onclick = () => { set(one); draw(); };
|
|
206
|
+
row.append(b);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return row;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function calls() {
|
|
213
|
+
const kept = state.calls.filter((c) => state.filter === 'all' || routeOf(c) === state.filter);
|
|
214
|
+
const bar = pills(['all', 'local', 'remote', 'system'], state.filter, (v) => { state.filter = v; });
|
|
215
|
+
|
|
216
|
+
if (kept.length === 0) {
|
|
217
|
+
return [bar, empty('Nothing dispatched yet — call an operation and it lands here.')];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// The recent window, not the session's high-water mark: one 4-second migration at boot
|
|
221
|
+
// and every bar after it was 2px for good.
|
|
222
|
+
const slowest = Math.max(1, ...kept.slice(-100).map((c) => c.ms ?? 0));
|
|
223
|
+
|
|
224
|
+
// One frond, one word repeated 400 times, 112px stolen from what the eye scans.
|
|
225
|
+
const many = new Set(state.calls.map((c) => c.frond)).size > 1;
|
|
226
|
+
|
|
227
|
+
const rows = kept.slice(-400).reverse().map((c) => {
|
|
228
|
+
const route = routeOf(c);
|
|
229
|
+
const width = c.ms ? Math.max(2, Math.round((c.ms / slowest) * 56)) : 0;
|
|
230
|
+
// Cleared as it is drawn: the flag marks the first render of a call, and a redraw is
|
|
231
|
+
// not a new call. Left set, every SSE arrival re-animated on every redraw — and pull()
|
|
232
|
+
// redraws up to three times a second, so the whole table strobed.
|
|
233
|
+
const arrived = c.fresh;
|
|
234
|
+
c.fresh = false;
|
|
235
|
+
const row = el('tr', { class: 'pick' + (arrived ? ' fresh' : '') },
|
|
236
|
+
'<td class="soft mono">' + clock(c.startedAt) + '</td>'
|
|
237
|
+
+ (many ? '<td class="soft">' + esc(c.frond ?? '—') + '</td>' : '')
|
|
238
|
+
+ '<td class="lead">' + esc(c.entity + '.' + c.operation)
|
|
239
|
+
+ (c.surface ? '<span class="soft">/' + esc(c.surface) + '</span>' : '')
|
|
240
|
+
+ (c.verdict === 'failed' ? ' <span class="chip failed">' + esc(c.refusal?.code ?? 'failed') + '</span>' : '')
|
|
241
|
+
+ (c.verdict === 'running' ? ' <span class="chip warn">running</span>' : '') + '</td>'
|
|
242
|
+
+ '<td class="soft">' + route + '</td>'
|
|
243
|
+
+ '<td class="num">' + (c.ms === undefined ? '' : c.ms + 'ms') + '</td>'
|
|
244
|
+
+ '<td class="bar">' + (width ? '<span class="meter ' + costOf(c.ms) + '" style="width:'
|
|
245
|
+
+ width + 'px"></span>' : '') + '</td>'
|
|
246
|
+
+ '');
|
|
247
|
+
row.onclick = () => show(c);
|
|
248
|
+
return row;
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
return [bar, table([{ label: 'time', w: '9ch' }, ...(many ? [{ label: 'frond', w: '10ch' }] : []),
|
|
252
|
+
{ label: 'operation' }, { label: 'route', w: '9ch' }, { label: 'took', num: true, w: '8ch' },
|
|
253
|
+
{ label: '', w: '60px' }], rows)];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Refused ──────────────────────────────────────────────────────────────────
|
|
257
|
+
function refused() {
|
|
258
|
+
if (state.errors.length === 0) {
|
|
259
|
+
return [empty('Nothing refused.'),
|
|
260
|
+
note('Two sources feed this: what a call carried, and what was logged at <code>error</code> outside any call — so a storage that would not open shows here too.')];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const rows = [];
|
|
264
|
+
for (const one of [...state.errors].sort((a, b) => b.lastAt - a.lastAt)) {
|
|
265
|
+
rows.push(el('tr', {},
|
|
266
|
+
'<td class="soft mono">' + clock(one.lastAt) + '</td>'
|
|
267
|
+
+ '<td class="failed tag">' + esc(one.code) + '</td>'
|
|
268
|
+
+ '<td class="lead">' + esc(one.entity ? one.entity + '.' + one.operation : one.from) + '</td>'
|
|
269
|
+
+ '<td>' + esc(one.message) + '</td>'
|
|
270
|
+
+ '<td class="num soft">' + (one.count > 1 ? '×' + one.count : '') + '</td>'));
|
|
271
|
+
|
|
272
|
+
// The reason a dispatch beats a span here: which field was refused, and why.
|
|
273
|
+
for (const f of one.fields) {
|
|
274
|
+
rows.push(el('tr', {}, '<td></td><td></td><td class="soft mono">' + esc(f.path) + '</td>'
|
|
275
|
+
+ '<td class="soft" colspan="2">' + esc(f.message) + '</td>'));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return [table([{ label: 'time', w: '4.5rem' }, { label: 'code', w: '11rem' },
|
|
280
|
+
{ label: 'where', w: '13rem' }, { label: 'message' }, { label: '', num: true, w: '4rem' }], rows)];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ── Activity ─────────────────────────────────────────────────────────────────
|
|
284
|
+
function activity() {
|
|
285
|
+
// Logs and statements are one stream: both chronological, both uncorrelated to a call.
|
|
286
|
+
// Two tabs said that twice and let the eye believe they were different kinds of fact.
|
|
287
|
+
const stream = [
|
|
288
|
+
...state.logs.map((l) => ({ at: l.at, kind: l.level, what: l.message, detail: l.args.join(' '), from: l.name })),
|
|
289
|
+
...state.queries.map((q) => ({ at: q.at, kind: 'query', what: q.sql, from: q.storage,
|
|
290
|
+
detail: q.parameters + (q.parameters === 1 ? ' param' : ' params'), ms: q.ms, failed: q.failed })),
|
|
291
|
+
].sort((a, b) => a.at - b.at);
|
|
292
|
+
|
|
293
|
+
const bar = pills(['all', 'query', 'log', 'error'], state.kind, (v) => { state.kind = v; });
|
|
294
|
+
const shown = state.kind === 'all' ? stream
|
|
295
|
+
: state.kind === 'query' ? stream.filter((o) => o.kind === 'query')
|
|
296
|
+
: state.kind === 'error' ? stream.filter((o) => o.kind === 'error')
|
|
297
|
+
: stream.filter((o) => o.kind !== 'query');
|
|
298
|
+
|
|
299
|
+
if (stream.length === 0) {
|
|
300
|
+
return [bar, empty('Nothing written and nothing read, yet.'),
|
|
301
|
+
note('Statements need <code>@fougere/adapter-sql</code>; log lines below the level in force never reach here — <code>FOUGERE_LOG_LEVEL=debug</code> lowers it.')];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const slowest = Math.max(1, ...state.queries.map((q) => q.ms));
|
|
305
|
+
const rows = shown.slice(-500).reverse().map((one) => {
|
|
306
|
+
const tone = one.kind === 'query' ? (one.failed ? 'failed' : 'local')
|
|
307
|
+
: one.kind === 'error' ? 'error' : one.kind === 'warn' ? 'warn' : 'soft';
|
|
308
|
+
const width = one.ms ? Math.max(2, Math.round((one.ms / slowest) * 40)) : 0;
|
|
309
|
+
return el('tr', {},
|
|
310
|
+
'<td class="soft mono">' + clock(one.at) + '</td>'
|
|
311
|
+
+ '<td class="tag ' + tone + '">' + one.kind + '</td>'
|
|
312
|
+
+ '<td class="soft">' + esc(one.from) + '</td>'
|
|
313
|
+
+ '<td class="' + (one.kind === 'query' ? 'mono' : '') + '">'
|
|
314
|
+
+ esc(one.what.length > 150 ? one.what.slice(0, 150) + '…' : one.what)
|
|
315
|
+
+ (one.detail ? ' <span class="soft">' + esc(one.detail) + '</span>' : '') + '</td>'
|
|
316
|
+
+ '<td class="num soft">' + (one.ms === undefined ? '' : one.ms + 'ms')
|
|
317
|
+
+ (width ? '<span class="meter local" style="width:' + width + 'px"></span>' : '') + '</td>');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return [bar, table([{ label: 'time', w: '9ch' }, { label: 'kind', w: '7ch' },
|
|
321
|
+
{ label: 'from', w: '14ch' }, { label: 'what' }, { label: 'took', num: true, w: '8ch' }], rows),
|
|
322
|
+
note('Chronological. Tying a line or a statement to the call it happened inside needs an async context this package does not open — so it is not claimed.')];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ── Served ───────────────────────────────────────────────────────────────────
|
|
326
|
+
function served() {
|
|
327
|
+
if (!state.model || state.model.fronds.length === 0) return [empty('This process hosts no frond.')];
|
|
328
|
+
|
|
329
|
+
const out = [];
|
|
330
|
+
for (const frond of state.model.fronds) {
|
|
331
|
+
// Declared against observed. Nowhere else can this line be drawn: elsewhere the
|
|
332
|
+
// placement is not the developer's to declare, so there is only ever one value.
|
|
333
|
+
const seen = new Set(state.calls.filter((c) => c.frond === frond.name && c.route).map((c) => c.route));
|
|
334
|
+
const observed = seen.size === 0 ? null : [...seen].join(' + ');
|
|
335
|
+
const off = observed !== null && !seen.has(frond.declared);
|
|
336
|
+
out.push(el('h2', { class: 'frond' },
|
|
337
|
+
esc(frond.name) + ' <span class="chip ' + frond.declared + '">' + frond.declared + '</span>'
|
|
338
|
+
+ (frond.at ? ' <span class="soft mono">' + esc(frond.at) + '</span>' : '')
|
|
339
|
+
+ ' <span class="' + (off ? 'failed' : 'soft') + '">· observed '
|
|
340
|
+
+ (observed === null ? 'nothing yet' : observed) + (off ? ' — they disagree' : '') + '</span>'
|
|
341
|
+
+ (frond.entities.length ? ' <span class="soft">· ' + esc(frond.entities.join(', ')) + '</span>' : '')));
|
|
342
|
+
|
|
343
|
+
out.push(table([{ label: 'operation' }, { label: 'kind', w: '6rem' }, { label: 'in → out', w: '14rem' },
|
|
344
|
+
{ label: 'parameters' }, { label: 'served by', w: '12rem' }],
|
|
345
|
+
frond.operations.map((op) => el('tr', {},
|
|
346
|
+
'<td class="lead">' + esc(op.address) + '</td>'
|
|
347
|
+
+ '<td><span class="tag ' + op.kind + '">' + op.kind + '</span></td>'
|
|
348
|
+
+ '<td class="soft">' + esc((op.input ?? '—') + ' → ' + (op.output ?? '—')
|
|
349
|
+
+ (op.cardinality ? ' (' + op.cardinality + ')' : '')) + '</td>'
|
|
350
|
+
+ '<td class="soft mono">' + esc(op.parameters.map((p) => p.name + '←' + p.binding).join(' ') || '—') + '</td>'
|
|
351
|
+
+ '<td class="soft">' + esc((op.adapters.join(', ') || 'no adapter') + ' · ' + (op.surfaces.join(', ') || '—')) + '</td>'))));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── Shape ────────────────────────────────────────────────────────────────────
|
|
358
|
+
/**
|
|
359
|
+
* What this process is arranged as — DECLARED and OBSERVED on one picture.
|
|
360
|
+
*
|
|
361
|
+
* Both halves are already on this page: the model says where a call is meant to go, the
|
|
362
|
+
* ring says where it went. Nobody else can draw the pair, because elsewhere the placement
|
|
363
|
+
* is not the developer's to declare — there is only ever one value, so there is nothing to
|
|
364
|
+
* disagree with.
|
|
365
|
+
*
|
|
366
|
+
* One process's view, and it says so: a frond hosted elsewhere is a node with no inside.
|
|
367
|
+
*/
|
|
368
|
+
function shape() {
|
|
369
|
+
if (!state.model || state.model.fronds.length === 0) return [empty('This process hosts no frond.')];
|
|
370
|
+
|
|
371
|
+
const seenOf = (name) => {
|
|
372
|
+
const mine = state.calls.filter((c) => c.frond === name);
|
|
373
|
+
const routes = new Set(mine.filter((c) => c.route).map((c) => c.route));
|
|
374
|
+
return { count: mine.length, failed: mine.filter((c) => c.verdict === 'failed').length, routes };
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const here = state.model.fronds.filter((f) => f.declared === 'local');
|
|
378
|
+
const away = state.model.fronds.filter((f) => f.declared === 'remote');
|
|
379
|
+
const W = 900, ROW = 74, TOP = 34;
|
|
380
|
+
const H = TOP + Math.max(here.length, away.length, 1) * ROW + 20;
|
|
381
|
+
const svg = el('svg', { class: 'shape', viewBox: '0 0 ' + W + ' ' + H, preserveAspectRatio: 'xMidYMin meet' });
|
|
382
|
+
|
|
383
|
+
const box = (x, y, frond, side) => {
|
|
384
|
+
const seen = seenOf(frond.name);
|
|
385
|
+
const off = seen.routes.size > 0 && !seen.routes.has(frond.declared);
|
|
386
|
+
const g = el('g', { class: 'pick' });
|
|
387
|
+
g.innerHTML =
|
|
388
|
+
'<rect class="node ' + (side === 'here' ? 'here' : '') + (off ? ' off' : '') + '" x="' + x + '" y="' + y
|
|
389
|
+
+ '" width="230" height="52" rx="9"/>'
|
|
390
|
+
+ '<text x="' + (x + 14) + '" y="' + (y + 22) + '">' + esc(frond.name) + '</text>'
|
|
391
|
+
+ '<text class="sub" x="' + (x + 14) + '" y="' + (y + 39) + '">'
|
|
392
|
+
+ frond.operations.length + ' op' + (frond.operations.length === 1 ? '' : 's')
|
|
393
|
+
+ (frond.at ? ' · ' + esc(frond.at.split('://').pop()) : '')
|
|
394
|
+
+ (seen.count ? ' · ' + seen.count + ' called' : '') + '</text>'
|
|
395
|
+
+ (off ? '<text class="sub" x="' + (x + 216) + '" y="' + (y + 22) + '" text-anchor="end" fill="var(--bad)">≠</text>' : '');
|
|
396
|
+
g.onclick = () => { state.view = 'served'; location.hash = 'served'; };
|
|
397
|
+
return g;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
here.forEach((frond, i) => svg.append(box(30, TOP + i * ROW, frond, 'here')));
|
|
401
|
+
away.forEach((frond, i) => {
|
|
402
|
+
const y = TOP + i * ROW;
|
|
403
|
+
svg.append(box(W - 260, y, frond, 'away'));
|
|
404
|
+
|
|
405
|
+
// One edge per declared remote, drawn from what was OBSERVED: solid when calls crossed,
|
|
406
|
+
// dashed when the line exists and nothing has used it.
|
|
407
|
+
const seen = seenOf(frond.name);
|
|
408
|
+
const from = TOP + Math.min(i, Math.max(0, here.length - 1)) * ROW + 26;
|
|
409
|
+
const tone = seen.failed > 0 ? 'var(--bad)' : seen.count > 0 ? 'var(--remote)' : 'var(--dim)';
|
|
410
|
+
svg.insertAdjacentHTML('afterbegin',
|
|
411
|
+
'<path class="edge' + (seen.count ? '' : ' dead') + '" style="color:' + tone + '" '
|
|
412
|
+
+ 'stroke-width="' + (seen.count ? Math.min(6, 1.5 + Math.log2(seen.count + 1)) : 1.5) + '" '
|
|
413
|
+
+ 'd="M 260 ' + from + ' C ' + (W / 2) + ' ' + from + ', ' + (W / 2) + ' ' + (y + 26) + ', ' + (W - 260) + ' ' + (y + 26) + '"/>'
|
|
414
|
+
+ '<text class="sub" x="' + (W / 2) + '" y="' + ((from + y + 26) / 2 - 6) + '" text-anchor="middle">'
|
|
415
|
+
+ (seen.count ? seen.count + ' call' + (seen.count === 1 ? '' : 's') + (seen.failed ? ' · ' + seen.failed + ' refused' : '') : 'never called')
|
|
416
|
+
+ '</text>');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const legend = el('div', { class: 'legend' },
|
|
420
|
+
'<span><b class="local">solid</b> — hosted here</span>'
|
|
421
|
+
+ '<span><b class="remote">line</b> — a call that crossed a process</span>'
|
|
422
|
+
+ '<span><b class="failed">≠</b> — declared and observed disagree</span>');
|
|
423
|
+
|
|
424
|
+
return [svg, legend,
|
|
425
|
+
away.length === 0 ? note('Everything runs in this process. A <code>remotes:</code> line is what puts a frond on the right.')
|
|
426
|
+
: note('This is one process. A frond on the right runs elsewhere, so its own panel holds what happened inside it.')];
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── the shell ────────────────────────────────────────────────────────────────
|
|
430
|
+
function draw() {
|
|
431
|
+
const views = { calls, refused, activity, shape, served };
|
|
432
|
+
main.replaceChildren(...views[state.view]());
|
|
433
|
+
document.getElementById('total').textContent = String(state.calls.length);
|
|
434
|
+
|
|
435
|
+
const refusals = state.errors.reduce((n, one) => n + one.count, 0);
|
|
436
|
+
for (const link of document.querySelectorAll('#nav a')) {
|
|
437
|
+
const view = link.hash.slice(1);
|
|
438
|
+
if (view === state.view) link.setAttribute('aria-current', 'page');
|
|
439
|
+
else link.removeAttribute('aria-current');
|
|
440
|
+
// The one badge on the bar: what is wrong finds the eye without a tab of its own.
|
|
441
|
+
if (view === 'refused') link.innerHTML = 'Refused' + (refusals ? ' <b>' + refusals + '</b>' : '');
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function show(call) {
|
|
446
|
+
const op = state.model?.fronds.flatMap((f) => f.operations)
|
|
447
|
+
.find((one) => one.address === call.entity + '.' + call.operation);
|
|
448
|
+
const rows = [
|
|
449
|
+
['route', routeOf(call)],
|
|
450
|
+
['verdict', call.verdict === 'failed' ? (call.refusal?.code ?? 'failed') : call.verdict],
|
|
451
|
+
['took', call.ms === undefined ? 'unsettled' : call.ms + 'ms'],
|
|
452
|
+
['frond', call.frond ?? '—'],
|
|
453
|
+
['surface', call.surface ?? '—'],
|
|
454
|
+
['caller', call.caller ?? 'unsigned'],
|
|
455
|
+
['trace', call.trace ?? 'none on this side'],
|
|
456
|
+
];
|
|
457
|
+
if (call.refusal) rows.push(['refusal', (call.refusal.code ? call.refusal.code + ' — ' : '') + call.refusal.message]);
|
|
458
|
+
if (op) {
|
|
459
|
+
rows.push(['kind', op.kind], ['handler', op.handler], ['file', op.file]);
|
|
460
|
+
if (op.description) rows.push(['purpose', op.description]);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
document.getElementById('body').innerHTML =
|
|
464
|
+
'<h2>' + esc(call.entity + '.' + call.operation) + '</h2><dl>'
|
|
465
|
+
+ rows.map(([k, v]) => '<dt>' + k + '</dt><dd class="' + (k === 'trace' || k === 'file' ? 'mono' : '') + '">'
|
|
466
|
+
+ esc(v) + '</dd>').join('') + '</dl>';
|
|
467
|
+
document.getElementById('detail').setAttribute('open', '');
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
document.getElementById('close').onclick = () => document.getElementById('detail').removeAttribute('open');
|
|
471
|
+
addEventListener('hashchange', () => { state.view = location.hash.slice(1) || 'calls'; draw(); });
|
|
472
|
+
state.view = location.hash.slice(1) || 'calls';
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
fetch('./model.json').then((a) => a.json()).then((m) => { state.model = m; draw(); });
|
|
476
|
+
|
|
477
|
+
function pull() {
|
|
478
|
+
for (const name of ['logs', 'errors', 'queries']) {
|
|
479
|
+
fetch('./' + name + '.json?since=' + (state.cursors[name] ?? 0))
|
|
480
|
+
.then((a) => a.json())
|
|
481
|
+
.then((page) => {
|
|
482
|
+
if (!page.lines || page.lines.length === 0) return;
|
|
483
|
+
state.cursors[name] = page.cursor;
|
|
484
|
+
if (name === 'errors') {
|
|
485
|
+
// A group is mutated in place and re-numbered, so it comes back with its count.
|
|
486
|
+
for (const g of page.lines) {
|
|
487
|
+
const at = state.errors.findIndex((one) => one.key === g.key);
|
|
488
|
+
if (at === -1) state.errors.push(g); else state.errors[at] = g;
|
|
489
|
+
}
|
|
490
|
+
} else state[name].push(...page.lines);
|
|
491
|
+
// Only what is being looked at, plus the badge: three rings answering every second
|
|
492
|
+
// redrew the table three times a second for nothing.
|
|
493
|
+
if (state.view === name || state.view === 'activity' || name === 'errors') draw();
|
|
494
|
+
})
|
|
495
|
+
.catch(() => {});
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
setInterval(pull, 1000);
|
|
499
|
+
pull();
|
|
500
|
+
|
|
501
|
+
const events = new EventSource('./events');
|
|
502
|
+
events.addEventListener('open', () => {
|
|
503
|
+
document.getElementById('live').textContent = 'live';
|
|
504
|
+
document.getElementById('dot').className = 'dot on';
|
|
505
|
+
});
|
|
506
|
+
events.addEventListener('error', () => {
|
|
507
|
+
document.getElementById('live').textContent = 'disconnected';
|
|
508
|
+
document.getElementById('dot').className = 'dot off';
|
|
509
|
+
});
|
|
510
|
+
events.addEventListener('hello', (m) => {
|
|
511
|
+
const held = JSON.parse(m.data);
|
|
512
|
+
state.fronds = held.fronds ?? [];
|
|
513
|
+
document.getElementById('where').textContent = state.fronds.length ? ' · ' + state.fronds.join(', ') : '';
|
|
514
|
+
state.calls = held.calls;
|
|
515
|
+
draw();
|
|
516
|
+
});
|
|
517
|
+
events.addEventListener('call', (m) => {
|
|
518
|
+
const call = JSON.parse(m.data);
|
|
519
|
+
call.fresh = true;
|
|
520
|
+
state.calls.push(call);
|
|
521
|
+
draw();
|
|
522
|
+
});
|
|
523
|
+
events.addEventListener('vitals', (m) => {
|
|
524
|
+
const vitals = JSON.parse(m.data);
|
|
525
|
+
document.getElementById('inflight').textContent = String(vitals.inFlight);
|
|
526
|
+
// The ring's own promise — "an absence is named rather than left to look like a quiet
|
|
527
|
+
// period" — kept by the only view that can keep it.
|
|
528
|
+
document.getElementById('dropped').textContent = vitals.dropped ? ' +' + vitals.dropped + ' dropped' : '';
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
draw();
|
|
532
|
+
</script>
|
|
533
|
+
</body>
|
|
534
|
+
</html>`;
|
|
535
|
+
}
|
package/src/panel.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { createServer, type Server } from 'node:http';
|
|
2
|
+
import type { CallRecord } from '@fougere/core';
|
|
3
|
+
import type { CallRing } from './CallRing.js';
|
|
4
|
+
import { page } from './page.js';
|
|
5
|
+
|
|
6
|
+
export interface PanelOptions {
|
|
7
|
+
/** The port to listen on. `0` takes whatever is free and prints it. */
|
|
8
|
+
port?: number;
|
|
9
|
+
/** What the page calls itself, and what a reader recognises in a browser tab. */
|
|
10
|
+
title?: string;
|
|
11
|
+
/** The fronds this process hosts, shown beside the title. */
|
|
12
|
+
fronds?: string[];
|
|
13
|
+
/** What this process serves — read once at boot, since a boot does not change it. */
|
|
14
|
+
model?: unknown;
|
|
15
|
+
/** The three other rings, each answering what a reader has not seen. */
|
|
16
|
+
logs?: (cursor: number) => unknown;
|
|
17
|
+
errors?: (cursor: number) => unknown;
|
|
18
|
+
queries?: (cursor: number) => unknown;
|
|
19
|
+
/** In flight, right now — asked at each beat rather than held. */
|
|
20
|
+
inFlight?: () => number;
|
|
21
|
+
/** Told where it is listening, once. */
|
|
22
|
+
announce?: (url: string) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** How often the page is told how many calls are in flight. */
|
|
26
|
+
const BEAT_MS = 1000;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The panel's own door: the page, and the events that fill it.
|
|
30
|
+
*
|
|
31
|
+
* Its own server and not the app's, for one measured reason: `serve()` from
|
|
32
|
+
* `@fougere/transport-http` answers 404 to everything that is not `POST /_fougere/call`,
|
|
33
|
+
* which is what makes it safe to expose — a page cannot enter it without changing what it
|
|
34
|
+
* is. So the panel binds 127.0.0.1 only, and serves both halves itself, which also means
|
|
35
|
+
* the page reads its own origin and no CORS has to be arranged.
|
|
36
|
+
*/
|
|
37
|
+
export function servePanel(ring: CallRing, options: PanelOptions = {}): Promise<() => Promise<void>> {
|
|
38
|
+
const title = options.title ?? 'fougere';
|
|
39
|
+
const readers = new Set<{ write: (chunk: string) => void; end: () => void }>();
|
|
40
|
+
const html = page(title);
|
|
41
|
+
|
|
42
|
+
const send = (event: string, data: unknown): void => {
|
|
43
|
+
const framed = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
44
|
+
for (const reader of readers) {
|
|
45
|
+
try { reader.write(framed); } catch { readers.delete(reader); }
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const stopWatching = ring.watch((record: CallRecord) => send('call', record));
|
|
50
|
+
const beat = setInterval(() => {
|
|
51
|
+
if (readers.size === 0) return;
|
|
52
|
+
send('vitals', { inFlight: options.inFlight?.() ?? 0, dropped: ring.since(Number.MAX_SAFE_INTEGER).dropped });
|
|
53
|
+
}, BEAT_MS);
|
|
54
|
+
beat.unref();
|
|
55
|
+
|
|
56
|
+
const server: Server = createServer((request, response) => {
|
|
57
|
+
const path = (request.url ?? '/').split('?')[0];
|
|
58
|
+
|
|
59
|
+
if (path === '/events') {
|
|
60
|
+
response.writeHead(200, {
|
|
61
|
+
'content-type': 'text/event-stream',
|
|
62
|
+
'cache-control': 'no-cache',
|
|
63
|
+
connection: 'keep-alive',
|
|
64
|
+
});
|
|
65
|
+
// The page is handed the ring as it stands, so a reader that opens late is not
|
|
66
|
+
// looking at an empty screen that suggests a quiet app.
|
|
67
|
+
const held = ring.since(0);
|
|
68
|
+
response.write(`event: hello\ndata: ${JSON.stringify({ calls: held.calls, fronds: options.fronds ?? [] })}\n\n`);
|
|
69
|
+
readers.add(response);
|
|
70
|
+
request.on('close', () => readers.delete(response));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// One door per ring, each taking a cursor: a reader asks for what is above its own,
|
|
75
|
+
// which is the whole protocol — no subscription to hold, nothing to replay.
|
|
76
|
+
for (const [name, read] of [['logs', options.logs], ['errors', options.errors], ['queries', options.queries]] as const) {
|
|
77
|
+
if (path !== `/${name}.json`) continue;
|
|
78
|
+
const since = Number(new URL(request.url ?? '/', 'http://panel').searchParams.get('since') ?? 0);
|
|
79
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
80
|
+
response.end(JSON.stringify(read?.(Number.isFinite(since) ? since : 0) ?? { lines: [], cursor: 0, dropped: 0 }));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (path === '/model.json') {
|
|
85
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
86
|
+
response.end(JSON.stringify(options.model ?? { fronds: [] }));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (path === '/calls.json') {
|
|
91
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
92
|
+
response.end(JSON.stringify(ring.since(0)));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (path === '/' || path === '/index.html') {
|
|
97
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
98
|
+
response.end(html);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
response.writeHead(404, { 'content-type': 'text/plain' });
|
|
103
|
+
response.end('the panel serves /, /events, /calls.json, /model.json, /logs.json, /errors.json and /queries.json\n');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return new Promise((ready, refuse) => {
|
|
107
|
+
server.on('error', refuse);
|
|
108
|
+
// Loopback only. This door has no judge — `serve()` refuses to start beyond loopback
|
|
109
|
+
// without `verify`, and this one would not know how to refuse at all.
|
|
110
|
+
server.listen(options.port ?? 0, '127.0.0.1', () => {
|
|
111
|
+
const port = (server.address() as { port: number }).port;
|
|
112
|
+
options.announce?.(`http://127.0.0.1:${port}`);
|
|
113
|
+
|
|
114
|
+
ready(async () => {
|
|
115
|
+
clearInterval(beat);
|
|
116
|
+
stopWatching();
|
|
117
|
+
for (const reader of readers) { try { reader.end(); } catch { /* going away */ } }
|
|
118
|
+
readers.clear();
|
|
119
|
+
await new Promise<void>((closed) => server.close(() => closed()));
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|