browser-broker 0.1.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/.env.example +173 -0
- package/LICENSE +21 -0
- package/README.md +374 -0
- package/RELEASES.md +97 -0
- package/dist/package.json +58 -0
- package/dist/src/adapter/conformance/case.js +1 -0
- package/dist/src/adapter/conformance/cases.js +429 -0
- package/dist/src/adapter/conformance/discovery.js +156 -0
- package/dist/src/adapter/conformance/driver.js +1 -0
- package/dist/src/adapter/conformance/drivers.js +36 -0
- package/dist/src/adapter/conformance/run.js +224 -0
- package/dist/src/adapter/conformance/service-subject.js +165 -0
- package/dist/src/adapter/contract.js +24 -0
- package/dist/src/adapter/operations.js +114 -0
- package/dist/src/adapter/service-seam.js +1 -0
- package/dist/src/artifacts/names.js +229 -0
- package/dist/src/artifacts/store.js +174 -0
- package/dist/src/bin/broker-tool.js +63 -0
- package/dist/src/bin/broker.js +111 -0
- package/dist/src/browser/adoption.js +143 -0
- package/dist/src/browser/automation-probe.js +113 -0
- package/dist/src/browser/conformance/case.js +1 -0
- package/dist/src/browser/conformance/cases.js +192 -0
- package/dist/src/browser/conformance/run.js +102 -0
- package/dist/src/browser/conformance/subjects.js +19 -0
- package/dist/src/browser/discovery.js +226 -0
- package/dist/src/browser/driver.js +195 -0
- package/dist/src/browser/fake.js +585 -0
- package/dist/src/browser/launch.js +504 -0
- package/dist/src/browser/real.js +1425 -0
- package/dist/src/browser/setup.js +161 -0
- package/dist/src/capture/accounting.js +59 -0
- package/dist/src/capture/image.js +112 -0
- package/dist/src/capture/ladder.js +72 -0
- package/dist/src/capture/legibility.js +195 -0
- package/dist/src/capture/pipeline.js +153 -0
- package/dist/src/capture/tiers.js +166 -0
- package/dist/src/cli/adapter.js +233 -0
- package/dist/src/cli/commands.js +270 -0
- package/dist/src/cli/conformance-driver.js +119 -0
- package/dist/src/cli/diffs.js +122 -0
- package/dist/src/cli/image.js +274 -0
- package/dist/src/cli/index.js +895 -0
- package/dist/src/cli/login-command.js +401 -0
- package/dist/src/cli/operations-commands.js +186 -0
- package/dist/src/cli/reconcile-command.js +137 -0
- package/dist/src/cli/sign-in.js +134 -0
- package/dist/src/cli/telemetry.js +222 -0
- package/dist/src/config/environment.js +446 -0
- package/dist/src/diff/artifact-path.js +77 -0
- package/dist/src/diff/crops.js +102 -0
- package/dist/src/diff/geometry.js +122 -0
- package/dist/src/diff/image.js +132 -0
- package/dist/src/diff/mask.js +46 -0
- package/dist/src/diff/regions.js +263 -0
- package/dist/src/diff/settings.js +135 -0
- package/dist/src/doctor/checks.js +588 -0
- package/dist/src/doctor/report.js +152 -0
- package/dist/src/doctor/session.js +161 -0
- package/dist/src/errors.js +36 -0
- package/dist/src/feedback/read.js +119 -0
- package/dist/src/feedback/record.js +199 -0
- package/dist/src/operations/addresses.js +175 -0
- package/dist/src/operations/derive.js +109 -0
- package/dist/src/operations/ledger.js +194 -0
- package/dist/src/operations/status.js +197 -0
- package/dist/src/operations/telemetry.js +280 -0
- package/dist/src/report/document.js +419 -0
- package/dist/src/report/escape.js +68 -0
- package/dist/src/report/snapshot.js +97 -0
- package/dist/src/service/arbitration.js +537 -0
- package/dist/src/service/artifacts.js +85 -0
- package/dist/src/service/bridge.js +577 -0
- package/dist/src/service/broker.js +120 -0
- package/dist/src/service/browser-session.js +269 -0
- package/dist/src/service/capacity.js +62 -0
- package/dist/src/service/capture-seam.js +83 -0
- package/dist/src/service/capture-store.js +91 -0
- package/dist/src/service/comparison-store.js +101 -0
- package/dist/src/service/comparison.js +173 -0
- package/dist/src/service/events.js +93 -0
- package/dist/src/service/keys.js +68 -0
- package/dist/src/service/leases.js +147 -0
- package/dist/src/service/nudge.js +66 -0
- package/dist/src/service/operations/claim.js +692 -0
- package/dist/src/service/operations/give-back.js +131 -0
- package/dist/src/service/operations/pages.js +771 -0
- package/dist/src/service/operations/sign-in.js +915 -0
- package/dist/src/service/operations/status.js +62 -0
- package/dist/src/service/ownership.js +93 -0
- package/dist/src/service/pages.js +616 -0
- package/dist/src/service/pending-seeds.js +20 -0
- package/dist/src/service/queue.js +233 -0
- package/dist/src/service/reconcile.js +220 -0
- package/dist/src/service/refusals.js +262 -0
- package/dist/src/service/runtime.js +131 -0
- package/dist/src/service/signin-recovery.js +148 -0
- package/dist/src/service/storage-seed.js +239 -0
- package/dist/src/service/tabs.js +123 -0
- package/dist/src/store/budget.js +99 -0
- package/dist/src/store/location.js +42 -0
- package/dist/src/store/network-path.js +182 -0
- package/dist/src/store/network-volume.js +92 -0
- package/dist/src/store/open.js +226 -0
- package/dist/src/store/schema/step-001-initial.js +523 -0
- package/dist/src/store/schema/step-002-tab-budget.js +53 -0
- package/dist/src/store/schema/step-003-queue-order.js +110 -0
- package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
- package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
- package/dist/src/store/schema/step-006-signin-events.js +104 -0
- package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
- package/dist/src/store/schema/step-008-signin-owner.js +76 -0
- package/dist/src/store/schema/step-009-named-browsers.js +138 -0
- package/dist/src/store/schema/step-010-signin-request.js +135 -0
- package/dist/src/store/schema/step.js +172 -0
- package/dist/src/store/schema/steps.js +58 -0
- package/dist/src/store/transaction.js +37 -0
- package/dist/src/tool/adapter.js +90 -0
- package/dist/src/tool/conformance-driver.js +184 -0
- package/dist/src/tool/protocol.js +310 -0
- package/dist/src/tool/session.js +351 -0
- package/dist/src/tool/tools.js +310 -0
- package/package.json +58 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { renderAddress } from "../operations/addresses.js";
|
|
2
|
+
import { secondsBetween } from "../operations/derive.js";
|
|
3
|
+
import { escapeHtml, escapeValue } from "./escape.js";
|
|
4
|
+
/**
|
|
5
|
+
* The styling, inlined.
|
|
6
|
+
*
|
|
7
|
+
* Plain CSS with no custom fonts and no imports, so the file needs nothing
|
|
8
|
+
* from a network to render. The colour choices carry meaning that is also
|
|
9
|
+
* carried in words — an expired lease says "expired" as well as being
|
|
10
|
+
* coloured — because a document sent to somebody may be read by a person who
|
|
11
|
+
* does not see the colours, or printed.
|
|
12
|
+
*/
|
|
13
|
+
const STYLE = `
|
|
14
|
+
:root { color-scheme: light dark; }
|
|
15
|
+
* { box-sizing: border-box; }
|
|
16
|
+
body {
|
|
17
|
+
margin: 0; padding: 2rem 1.5rem 4rem;
|
|
18
|
+
font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
19
|
+
background: #fbfbfc; color: #1c1e21;
|
|
20
|
+
}
|
|
21
|
+
main { max-width: 76rem; margin: 0 auto; }
|
|
22
|
+
h1 { font-size: 1.5rem; margin: 0 0 .25rem; }
|
|
23
|
+
h2 { font-size: 1.05rem; margin: 2.25rem 0 .5rem; letter-spacing: .01em; }
|
|
24
|
+
p { margin: .35rem 0; }
|
|
25
|
+
.taken {
|
|
26
|
+
margin: 1rem 0 1.75rem; padding: .85rem 1rem;
|
|
27
|
+
border: 1px solid #d8b74a; border-left-width: 5px; border-radius: 4px;
|
|
28
|
+
background: #fdf7e3;
|
|
29
|
+
}
|
|
30
|
+
.taken .moment { font-size: 1.15rem; font-weight: 650; }
|
|
31
|
+
.taken .warning { margin-top: .3rem; color: #6b5312; }
|
|
32
|
+
table { width: 100%; border-collapse: collapse; margin: .4rem 0 .5rem; font-size: 14px; }
|
|
33
|
+
th, td { text-align: left; padding: .4rem .6rem; border-bottom: 1px solid #e6e7ea; vertical-align: top; }
|
|
34
|
+
th { font-weight: 600; color: #4a4d52; background: #f2f3f5; border-bottom-color: #d9dadd; }
|
|
35
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
36
|
+
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
|
|
37
|
+
.empty { color: #6b6e73; font-style: italic; }
|
|
38
|
+
.tag { display: inline-block; padding: .05rem .4rem; border-radius: 3px; font-size: 12px; font-weight: 600; }
|
|
39
|
+
.tag-ok { background: #dcf0dc; color: #1d4620; }
|
|
40
|
+
.tag-warn { background: #fbecd0; color: #6b4a06; }
|
|
41
|
+
.tag-bad { background: #f7dada; color: #6d1f1f; }
|
|
42
|
+
.tag-idle { background: #e6e7ea; color: #43464b; }
|
|
43
|
+
.unreachable { font-weight: 650; color: #8a2020; }
|
|
44
|
+
.counts { display: flex; flex-wrap: wrap; gap: 1.5rem; margin: .5rem 0 0; padding: 0; list-style: none; }
|
|
45
|
+
.counts li { min-width: 7rem; }
|
|
46
|
+
.counts .label { display: block; font-size: 12px; color: #5c5f64; text-transform: uppercase; letter-spacing: .04em; }
|
|
47
|
+
.counts .value { font-size: 1.5rem; font-weight: 650; font-variant-numeric: tabular-nums; }
|
|
48
|
+
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #e6e7ea; color: #5c5f64; font-size: 13px; }
|
|
49
|
+
@media (prefers-color-scheme: dark) {
|
|
50
|
+
body { background: #17181b; color: #e6e7ea; }
|
|
51
|
+
.taken { background: #2c2612; border-color: #7d6a24; }
|
|
52
|
+
.taken .warning { color: #d9c684; }
|
|
53
|
+
th { background: #212327; color: #b9bcc2; border-bottom-color: #33363b; }
|
|
54
|
+
th, td { border-bottom-color: #26282c; }
|
|
55
|
+
.empty, .counts .label, footer { color: #9c9fa5; }
|
|
56
|
+
.tag-ok { background: #1d3a1f; color: #b9e2bb; }
|
|
57
|
+
.tag-warn { background: #40320f; color: #eccf8e; }
|
|
58
|
+
.tag-bad { background: #451c1c; color: #efb5b5; }
|
|
59
|
+
.tag-idle { background: #2b2d31; color: #b9bcc2; }
|
|
60
|
+
.unreachable { color: #f08b8b; }
|
|
61
|
+
footer { border-top-color: #33363b; }
|
|
62
|
+
}
|
|
63
|
+
`.trim();
|
|
64
|
+
/**
|
|
65
|
+
* The behaviour, inlined — and there is deliberately almost none.
|
|
66
|
+
*
|
|
67
|
+
* §4.1 forbids polling, a countdown and a live indicator, so what is left is
|
|
68
|
+
* one thing that makes the document *more* honest rather than less: it reads
|
|
69
|
+
* the moment already written into the page and says how long ago that was,
|
|
70
|
+
* **once, at open time.** It never repeats, so nothing in the page changes
|
|
71
|
+
* after it has been read — which is the property the rule protects.
|
|
72
|
+
*
|
|
73
|
+
* The value it computes is the reader's own clock against a moment from
|
|
74
|
+
* another machine's, so it is rounded to a coarse unit and labelled
|
|
75
|
+
* approximate. A precise figure would imply the two clocks agree.
|
|
76
|
+
*
|
|
77
|
+
* If it does not run — scripts disabled, or a viewer that strips them — the
|
|
78
|
+
* document is unaffected: the timestamp is already in the markup, and this
|
|
79
|
+
* only appends to it.
|
|
80
|
+
*/
|
|
81
|
+
const SCRIPT = `
|
|
82
|
+
(function () {
|
|
83
|
+
var node = document.getElementById('age');
|
|
84
|
+
if (!node) { return; }
|
|
85
|
+
var taken = Date.parse(node.getAttribute('data-taken') || '');
|
|
86
|
+
if (isNaN(taken)) { return; }
|
|
87
|
+
var seconds = Math.max(0, Math.round((Date.now() - taken) / 1000));
|
|
88
|
+
var text;
|
|
89
|
+
if (seconds < 90) { text = 'moments ago'; }
|
|
90
|
+
else if (seconds < 5400) { text = 'about ' + Math.round(seconds / 60) + ' minutes ago'; }
|
|
91
|
+
else if (seconds < 172800) { text = 'about ' + Math.round(seconds / 3600) + ' hours ago'; }
|
|
92
|
+
else { text = 'about ' + Math.round(seconds / 86400) + ' days ago'; }
|
|
93
|
+
node.textContent = ' — taken ' + text + ' (approximate: your clock, not the store\\u2019s)';
|
|
94
|
+
})();
|
|
95
|
+
`.trim();
|
|
96
|
+
/** Seconds as something a person reads, e.g. `4m 12s`. */
|
|
97
|
+
export function humaniseSeconds(seconds) {
|
|
98
|
+
const negative = seconds < 0;
|
|
99
|
+
const total = Math.abs(Math.round(seconds));
|
|
100
|
+
const parts = total < 60
|
|
101
|
+
? `${String(total)}s`
|
|
102
|
+
: total < 3600
|
|
103
|
+
? `${String(Math.floor(total / 60))}m ${String(total % 60)}s`
|
|
104
|
+
: `${String(Math.floor(total / 3600))}h ${String(Math.floor((total % 3600) / 60))}m`;
|
|
105
|
+
return negative ? `${parts} ago` : parts;
|
|
106
|
+
}
|
|
107
|
+
/** How an expiry is shown: the remaining time, or that it has passed. */
|
|
108
|
+
function expiryCell(secondsUntilExpiry) {
|
|
109
|
+
if (secondsUntilExpiry <= 0) {
|
|
110
|
+
// Reachable only in the window between a lease lapsing and this document
|
|
111
|
+
// deriving it — which the derivation closes. Kept because a lease with a
|
|
112
|
+
// renewal in flight can land here, and because a cell that could only
|
|
113
|
+
// ever be positive would be a claim this module cannot make.
|
|
114
|
+
return `<span class="tag tag-bad">lapsed ${escapeHtml(humaniseSeconds(secondsUntilExpiry))}</span>`;
|
|
115
|
+
}
|
|
116
|
+
const urgent = secondsUntilExpiry < 60;
|
|
117
|
+
return `<span class="tag ${urgent ? 'tag-warn' : 'tag-ok'}">${escapeHtml(humaniseSeconds(secondsUntilExpiry))}</span>`;
|
|
118
|
+
}
|
|
119
|
+
function browserStateTag(state) {
|
|
120
|
+
const className = state === 'running'
|
|
121
|
+
? 'tag-ok'
|
|
122
|
+
: state === 'failed'
|
|
123
|
+
? 'tag-bad'
|
|
124
|
+
: state === 'starting' || state === 'signing-in'
|
|
125
|
+
? 'tag-warn'
|
|
126
|
+
: 'tag-idle';
|
|
127
|
+
return `<span class="tag ${className}">${escapeHtml(state)}</span>`;
|
|
128
|
+
}
|
|
129
|
+
/** A table, or an italic sentence when there is nothing in it. */
|
|
130
|
+
function table(headers, rows, empty) {
|
|
131
|
+
if (rows.length === 0) {
|
|
132
|
+
return `<p class="empty">${escapeHtml(empty)}</p>`;
|
|
133
|
+
}
|
|
134
|
+
const head = headers.map((header) => `<th>${escapeHtml(header)}</th>`).join('');
|
|
135
|
+
return `<table><thead><tr>${head}</tr></thead><tbody>${rows.join('')}</tbody></table>`;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* The address cell (§4.2a).
|
|
139
|
+
*
|
|
140
|
+
* The **only** place a live-read address becomes markup, and it goes through
|
|
141
|
+
* {@link renderAddress} so the three outcomes are decided in one place. The
|
|
142
|
+
* `unreachable` word carries a class so it is visually distinct, but the word
|
|
143
|
+
* itself is the signal — a reader who cannot see the colour still reads it.
|
|
144
|
+
*/
|
|
145
|
+
function addressCell(tabId, addresses) {
|
|
146
|
+
if (tabId === null) {
|
|
147
|
+
return `<td class="empty">no tab</td>`;
|
|
148
|
+
}
|
|
149
|
+
const address = addresses.get(tabId);
|
|
150
|
+
const text = renderAddress(address);
|
|
151
|
+
const unreachable = address === undefined || address.kind === 'unreachable';
|
|
152
|
+
const reason = address !== undefined && address.kind === 'unreachable' ? address.reason : '';
|
|
153
|
+
return unreachable
|
|
154
|
+
? `<td><span class="unreachable" title="${escapeHtml(reason)}">${escapeHtml(text)}</span></td>`
|
|
155
|
+
: `<td class="mono">${escapeHtml(text)}</td>`;
|
|
156
|
+
}
|
|
157
|
+
function leaseRow(lease, addresses, showSession) {
|
|
158
|
+
const session = showSession ? `<td class="mono">${escapeValue(lease.sessionId)}</td>` : '';
|
|
159
|
+
return [
|
|
160
|
+
'<tr>',
|
|
161
|
+
session,
|
|
162
|
+
`<td class="mono">${escapeValue(lease.claimId)}</td>`,
|
|
163
|
+
`<td>${escapeValue(lease.browserId)}</td>`,
|
|
164
|
+
`<td>${escapeValue(lease.purpose)}</td>`,
|
|
165
|
+
// Derived, never stored. `LeaseView.state` is the output of
|
|
166
|
+
// `deriveClaimState` and the type carries no stored column to print.
|
|
167
|
+
`<td><span class="tag tag-ok">${escapeValue(lease.state)}</span></td>`,
|
|
168
|
+
`<td>${expiryCell(lease.secondsUntilExpiry)}</td>`,
|
|
169
|
+
`<td class="num">${escapeValue(lease.renewCount)}</td>`,
|
|
170
|
+
addressCell(lease.tabId, addresses),
|
|
171
|
+
'</tr>',
|
|
172
|
+
].join('');
|
|
173
|
+
}
|
|
174
|
+
function queueRow(entry) {
|
|
175
|
+
return [
|
|
176
|
+
'<tr>',
|
|
177
|
+
`<td class="num">${escapeValue(entry.position)}</td>`,
|
|
178
|
+
`<td class="mono">${escapeValue(entry.sessionId)}</td>`,
|
|
179
|
+
`<td>${escapeValue(entry.browserId)}</td>`,
|
|
180
|
+
`<td>${escapeValue(entry.purpose)}</td>`,
|
|
181
|
+
`<td>${escapeHtml(humaniseSeconds(entry.waitedSeconds))}</td>`,
|
|
182
|
+
`<td>${expiryCell(entry.secondsUntilExpiry)}</td>`,
|
|
183
|
+
'</tr>',
|
|
184
|
+
].join('');
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The moment, prominently and in the document (§4.1).
|
|
188
|
+
*
|
|
189
|
+
* Both halves are here on purpose. The timestamp answers *when*; the sentence
|
|
190
|
+
* beside it answers *what that means* — that nothing on the page will change,
|
|
191
|
+
* that the numbers are as they were at that instant, and that a queue may
|
|
192
|
+
* have drained since. §4.1's rule is that the document must not pretend to be
|
|
193
|
+
* a window, and a timestamp on its own does not say that.
|
|
194
|
+
*/
|
|
195
|
+
function takenBanner(at) {
|
|
196
|
+
return [
|
|
197
|
+
'<div class="taken" role="note">',
|
|
198
|
+
`<div class="moment">Snapshot taken at <span class="mono">${escapeHtml(at)}</span><span id="age" data-taken="${escapeHtml(at)}"></span></div>`,
|
|
199
|
+
'<p class="warning">This is a photograph, not a window. <strong>Nothing on this page refreshes</strong> — every',
|
|
200
|
+
'lease, expiry and queue position is as it was at the moment above, and may have changed since.',
|
|
201
|
+
'Generate a new snapshot to see the current picture.</p>',
|
|
202
|
+
'</div>',
|
|
203
|
+
].join('\n');
|
|
204
|
+
}
|
|
205
|
+
function browsersSection(status) {
|
|
206
|
+
const rows = status.browsers.map((browser) => {
|
|
207
|
+
const record = browser.discoveryRecorded
|
|
208
|
+
? browser.identityRecorded
|
|
209
|
+
? '<span class="tag tag-idle">address and identity recorded</span>'
|
|
210
|
+
: '<span class="tag tag-warn">address only</span>'
|
|
211
|
+
: '<span class="tag tag-idle">none recorded</span>';
|
|
212
|
+
return [
|
|
213
|
+
'<tr>',
|
|
214
|
+
`<td>${escapeValue(browser.id)}</td>`,
|
|
215
|
+
`<td>${browserStateTag(browser.state)}</td>`,
|
|
216
|
+
`<td class="num">${escapeValue(browser.restartCount)}</td>`,
|
|
217
|
+
`<td class="num">${escapeValue(browser.pid)}</td>`,
|
|
218
|
+
`<td class="mono">${escapeValue(browser.launchedAt)}</td>`,
|
|
219
|
+
`<td>${record}</td>`,
|
|
220
|
+
`<td class="num">${escapeValue(browser.liveTabs)}</td>`,
|
|
221
|
+
'</tr>',
|
|
222
|
+
].join('');
|
|
223
|
+
});
|
|
224
|
+
return [
|
|
225
|
+
'<h2>Browsers</h2>',
|
|
226
|
+
table(['Browser', 'State', 'Restarts', 'Process', 'Launched', 'Discovery record', 'Leased tabs'], rows, 'No browsers are recorded, which should not happen — the schema seeds two.'),
|
|
227
|
+
// §1.2c: the record is a claim, not a proof. The document reports what the
|
|
228
|
+
// store holds; only `broker doctor` reaches the endpoint and can say
|
|
229
|
+
// whether it checks out. Saying so here is what stops a reader taking
|
|
230
|
+
// "recorded" for "working".
|
|
231
|
+
'<p class="empty">A discovery record is a claim, not a proof — it survives the browser it names.',
|
|
232
|
+
'Whether one actually answers, and answers as the expected browser, is what <code>broker doctor</code> checks.</p>',
|
|
233
|
+
].join('\n');
|
|
234
|
+
}
|
|
235
|
+
function budgetSection(status) {
|
|
236
|
+
const { budget } = status;
|
|
237
|
+
const limit = budget.limit === null ? 'not recorded' : String(budget.limit);
|
|
238
|
+
return [
|
|
239
|
+
'<h2>Budget</h2>',
|
|
240
|
+
'<ul class="counts">',
|
|
241
|
+
`<li><span class="label">Tab budget</span><span class="value">${escapeHtml(limit)}</span></li>`,
|
|
242
|
+
`<li><span class="label">Live leases</span><span class="value">${escapeValue(budget.used)}</span></li>`,
|
|
243
|
+
`<li><span class="label">Holding a tab</span><span class="value">${escapeValue(budget.active)}</span></li>`,
|
|
244
|
+
`<li><span class="label">Queued</span><span class="value">${escapeValue(budget.queued)}</span></li>`,
|
|
245
|
+
`<li><span class="label">Keeper tabs</span><span class="value">${escapeValue(budget.keeperTabsExpected)}</span></li>`,
|
|
246
|
+
'</ul>',
|
|
247
|
+
// §3.15: the keeper tab is not capacity and is not counted against the
|
|
248
|
+
// budget, and it is "reported wherever pages are counted" precisely so a
|
|
249
|
+
// person looking at a browser window can reconcile what they see.
|
|
250
|
+
'<p class="empty">Keeper tabs are one blank page per browser. They are never leased and never counted against the',
|
|
251
|
+
'budget, so a browser window shows one more tab than the budget accounts for. Counted here so the two reconcile.',
|
|
252
|
+
budget.limit === null
|
|
253
|
+
? 'The budget has not been recorded in this store yet, so there is no bound to compare against.'
|
|
254
|
+
: '',
|
|
255
|
+
'</p>',
|
|
256
|
+
].join('\n');
|
|
257
|
+
}
|
|
258
|
+
function leasesSection(status, addresses) {
|
|
259
|
+
const headers = ['Lease', 'Browser', 'Purpose', 'State', 'Expires in', 'Renewals', 'Address'];
|
|
260
|
+
const groups = status.sessions.map((session) => {
|
|
261
|
+
const rows = session.leases.map((lease) => leaseRow(lease, addresses, false));
|
|
262
|
+
return [
|
|
263
|
+
`<h3 class="mono">${escapeValue(session.sessionId)} — ${String(session.leases.length)} lease(s)</h3>`,
|
|
264
|
+
table(headers, rows, 'No live leases for this session.'),
|
|
265
|
+
].join('\n');
|
|
266
|
+
});
|
|
267
|
+
return [
|
|
268
|
+
'<h2>Live leases</h2>',
|
|
269
|
+
// §2.4's standing rule, said in the document rather than only in the code,
|
|
270
|
+
// because a reader comparing this against a direct table read deserves to
|
|
271
|
+
// know which one is right.
|
|
272
|
+
'<p class="empty">Grouped by session, so one caller holding several tabs reads as one caller.',
|
|
273
|
+
'Every lease here had the expiry derivation applied at the moment above — a lease that had lapsed but',
|
|
274
|
+
'had not yet been swept is not shown as live.</p>',
|
|
275
|
+
groups.length === 0 ? '<p class="empty">Nothing holds a tab.</p>' : groups.join('\n'),
|
|
276
|
+
].join('\n');
|
|
277
|
+
}
|
|
278
|
+
function queueSection(status) {
|
|
279
|
+
const front = status.queue[0];
|
|
280
|
+
const depth = status.queue.length;
|
|
281
|
+
return [
|
|
282
|
+
'<h2>Queue</h2>',
|
|
283
|
+
`<p>Depth <strong>${String(depth)}</strong>${front === undefined
|
|
284
|
+
? '. Nobody is waiting.'
|
|
285
|
+
: `. The caller at the front has waited <strong>${escapeHtml(humaniseSeconds(front.waitedSeconds))}</strong>.`}</p>`,
|
|
286
|
+
table(['#', 'Session', 'Browser', 'Purpose', 'Waited', 'Place expires in'], status.queue.map((entry) => queueRow(entry)), 'Nobody is waiting.'),
|
|
287
|
+
].join('\n');
|
|
288
|
+
}
|
|
289
|
+
function leakedSection(status) {
|
|
290
|
+
const rows = status.leakedTabs.map((tab) => [
|
|
291
|
+
'<tr>',
|
|
292
|
+
`<td class="mono">${escapeValue(tab.tabId)}</td>`,
|
|
293
|
+
`<td>${escapeValue(tab.browserId)}</td>`,
|
|
294
|
+
`<td class="mono">${escapeValue(tab.claimId)}</td>`,
|
|
295
|
+
`<td>${escapeValue(tab.state)}</td>`,
|
|
296
|
+
`<td class="num">${escapeValue(tab.closeAttempts)}</td>`,
|
|
297
|
+
`<td class="mono">${escapeValue(tab.updatedAt)}</td>`,
|
|
298
|
+
'</tr>',
|
|
299
|
+
].join(''));
|
|
300
|
+
return [
|
|
301
|
+
'<h2>Leaked tabs</h2>',
|
|
302
|
+
// §2.4b: a leaked tab is not a leaked lease. The capacity is already back;
|
|
303
|
+
// what is left is a page nobody owns. Saying so stops a reader treating
|
|
304
|
+
// this section as a capacity problem.
|
|
305
|
+
'<p class="empty">A tab that would not close after its lease ended. The capacity came back when the lease did —',
|
|
306
|
+
'what is left is a page nobody owns, which costs memory and not budget.</p>',
|
|
307
|
+
table(['Tab', 'Browser', 'Was leased by', 'State', 'Close attempts', 'Last seen'], rows, 'No tabs have leaked.'),
|
|
308
|
+
].join('\n');
|
|
309
|
+
}
|
|
310
|
+
function ledgerSection(status) {
|
|
311
|
+
const rows = status.recentEvents.map((entry) => [
|
|
312
|
+
'<tr>',
|
|
313
|
+
`<td class="num">${escapeValue(entry.id)}</td>`,
|
|
314
|
+
`<td class="mono">${escapeValue(entry.at)}</td>`,
|
|
315
|
+
`<td>${escapeValue(entry.kind)}</td>`,
|
|
316
|
+
`<td>${entry.outcome === 'deny'
|
|
317
|
+
? `<span class="tag tag-bad">deny</span>`
|
|
318
|
+
: `<span class="tag tag-ok">allow</span>`}</td>`,
|
|
319
|
+
`<td>${escapeValue(entry.guard)}</td>`,
|
|
320
|
+
`<td>${escapeValue(entry.adapter)}</td>`,
|
|
321
|
+
`<td class="mono">${escapeValue(entry.sessionId)}</td>`,
|
|
322
|
+
'</tr>',
|
|
323
|
+
].join(''));
|
|
324
|
+
const refusals = status.refusalsByGuard.map((guard) => [
|
|
325
|
+
'<tr>',
|
|
326
|
+
`<td class="mono">${escapeValue(guard.guard)}</td>`,
|
|
327
|
+
`<td class="num">${escapeValue(guard.count)}</td>`,
|
|
328
|
+
'</tr>',
|
|
329
|
+
].join(''));
|
|
330
|
+
const highest = status.recentEvents.reduce((best, entry) => (best === null || entry.id > best ? entry.id : best), null);
|
|
331
|
+
return [
|
|
332
|
+
'<h2>Recent ledger entries</h2>',
|
|
333
|
+
table(['#', 'At', 'Kind', 'Outcome', 'Rule', 'Route', 'Session'], rows, 'The ledger is empty.'),
|
|
334
|
+
highest === null
|
|
335
|
+
? ''
|
|
336
|
+
: `<p class="empty">Most recent entry is <code>#${String(highest)}</code>. Read on with <code>broker events --since ${String(highest)}</code>.</p>`,
|
|
337
|
+
'<h2>Refusals by rule</h2>',
|
|
338
|
+
// §1.6: an allowed row does not record which rules passed, so "has this
|
|
339
|
+
// rule ever fired" is answered by the refusals and by nothing else.
|
|
340
|
+
'<p class="empty">Only refusals name a rule, so this is the whole of what the ledger can say about which rules have fired.</p>',
|
|
341
|
+
table(['Rule', 'Times refused'], refusals, 'Nothing has been refused.'),
|
|
342
|
+
].join('\n');
|
|
343
|
+
}
|
|
344
|
+
function feedbackSection(status) {
|
|
345
|
+
const rows = status.feedback.map((entry) => [
|
|
346
|
+
'<tr>',
|
|
347
|
+
`<td class="mono">${escapeValue(entry.at)}</td>`,
|
|
348
|
+
`<td class="num">${escapeValue(entry.rating)}</td>`,
|
|
349
|
+
`<td>${escapeValue(entry.category)}</td>`,
|
|
350
|
+
`<td class="mono">${escapeValue(entry.sessionId)}</td>`,
|
|
351
|
+
`<td>${escapeValue(entry.lastGuard)}</td>`,
|
|
352
|
+
`<td>${escapeValue(entry.note)}</td>`,
|
|
353
|
+
'</tr>',
|
|
354
|
+
].join(''));
|
|
355
|
+
return [
|
|
356
|
+
'<h2>What callers reported</h2>',
|
|
357
|
+
// §3.16 and §4.2: the tool has a planned removal, and "this section
|
|
358
|
+
// disappearing entirely is the signal that tool has done its job". An
|
|
359
|
+
// empty section is therefore not a defect and the document says so.
|
|
360
|
+
'<p class="empty">The rating is not satisfaction — it is whether this service moved the caller’s work forward',
|
|
361
|
+
'or got in its way. This section emptying out permanently is the signal that the feedback tool has done its job.</p>',
|
|
362
|
+
table(['At', 'Rating', 'Category', 'Session', 'Rule hit', 'Note'], rows, 'No caller has reported anything.'),
|
|
363
|
+
].join('\n');
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Assemble the document.
|
|
367
|
+
*
|
|
368
|
+
* Returns the string rather than writing it, so the whole of it is assertable
|
|
369
|
+
* from a test without a filesystem — and so the command that writes it has
|
|
370
|
+
* one job, which is writing it.
|
|
371
|
+
*/
|
|
372
|
+
export function renderDocument(input) {
|
|
373
|
+
const { status } = input;
|
|
374
|
+
const addressNote = input.addressNote === undefined ? '' : `<p class="empty">${escapeHtml(input.addressNote)}</p>`;
|
|
375
|
+
return [
|
|
376
|
+
'<!doctype html>',
|
|
377
|
+
'<html lang="en">',
|
|
378
|
+
'<head>',
|
|
379
|
+
'<meta charset="utf-8">',
|
|
380
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
381
|
+
// Not a refresh directive, and its absence is the point (§4.1). Nothing
|
|
382
|
+
// in this head reloads, polls or redirects.
|
|
383
|
+
`<title>Browser Broker — snapshot ${escapeHtml(status.at)}</title>`,
|
|
384
|
+
`<style>${STYLE}</style>`,
|
|
385
|
+
'</head>',
|
|
386
|
+
'<body>',
|
|
387
|
+
'<main>',
|
|
388
|
+
'<h1>Browser Broker — operations snapshot</h1>',
|
|
389
|
+
takenBanner(status.at),
|
|
390
|
+
browsersSection(status),
|
|
391
|
+
budgetSection(status),
|
|
392
|
+
leasesSection(status, input.addresses),
|
|
393
|
+
addressNote,
|
|
394
|
+
// §4.2a, said where the addresses are: they were asked for, live, under a
|
|
395
|
+
// per-tab timeout, and `unreachable` means the question went unanswered.
|
|
396
|
+
'<p class="empty">Addresses were read from the browsers at the moment above, each under its own timeout.',
|
|
397
|
+
`<strong>${escapeHtml('unreachable')}</strong> means the browser was asked and did not answer — which is a different fact from a lease having no tab.</p>`,
|
|
398
|
+
queueSection(status),
|
|
399
|
+
leakedSection(status),
|
|
400
|
+
ledgerSection(status),
|
|
401
|
+
feedbackSection(status),
|
|
402
|
+
'<footer>',
|
|
403
|
+
`<p>Generated by Browser Broker${input.version === undefined ? '' : ` ${escapeHtml(input.version)}`} at <span class="mono">${escapeHtml(status.at)}</span>.`,
|
|
404
|
+
'Read-only: this document has no controls, and nothing here acts on the service.',
|
|
405
|
+
'To act on the service as it is now, run a command against it.</p>',
|
|
406
|
+
'</footer>',
|
|
407
|
+
'</main>',
|
|
408
|
+
`<script>${SCRIPT}</script>`,
|
|
409
|
+
'</body>',
|
|
410
|
+
'</html>',
|
|
411
|
+
'',
|
|
412
|
+
].join('\n');
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The instant a lease has left, for a caller that wants the number without
|
|
416
|
+
* the markup. Exported because the doctor report uses the same arithmetic and
|
|
417
|
+
* two copies of it would drift.
|
|
418
|
+
*/
|
|
419
|
+
export { secondsBetween };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escaping, on its own, because a document assembled by concatenation has
|
|
3
|
+
* exactly one way to go wrong and this is it.
|
|
4
|
+
*
|
|
5
|
+
* Everything in the operations document comes from somewhere this service
|
|
6
|
+
* does not control: a session identity another system minted (`SCHEMA.md`
|
|
7
|
+
* §1.3), a free-text purpose a caller wrote, a free-text feedback note
|
|
8
|
+
* (§3.16), an error message from a browser, and — via §4.2a — **a page
|
|
9
|
+
* address read live from a browser**. Any of those can contain the characters
|
|
10
|
+
* that end an attribute or open a tag.
|
|
11
|
+
*
|
|
12
|
+
* The document is also **shared**: §4's whole argument for a file is that it
|
|
13
|
+
* "can be sent to somebody, kept beside a report, or opened on a machine that
|
|
14
|
+
* has never run this service". So the person who opens it is frequently not
|
|
15
|
+
* the person who generated it, and markup that came in from a page address is
|
|
16
|
+
* markup that runs in their browser.
|
|
17
|
+
*
|
|
18
|
+
* There is no templating engine here and there will not be one — the binding
|
|
19
|
+
* is no runtime dependencies, and a self-contained file with a build step
|
|
20
|
+
* behind it would contradict the reason it is self-contained. So escaping is
|
|
21
|
+
* a function that is called, and the check is that everything which
|
|
22
|
+
* interpolates calls it.
|
|
23
|
+
*/
|
|
24
|
+
/**
|
|
25
|
+
* Escape text for HTML, covering both element content and attribute values.
|
|
26
|
+
*
|
|
27
|
+
* Five characters rather than three: the two quote forms are included so one
|
|
28
|
+
* function is correct in both positions. A separate attribute escaper is a
|
|
29
|
+
* second function to remember to call, and the one anybody forgets is
|
|
30
|
+
* whichever they use less.
|
|
31
|
+
*
|
|
32
|
+
* The ampersand is replaced first, and the order is load-bearing rather than
|
|
33
|
+
* stylistic: doing it later would re-escape the ampersands the other
|
|
34
|
+
* replacements have just introduced, turning `<` into `&lt;`.
|
|
35
|
+
*/
|
|
36
|
+
export function escapeHtml(value) {
|
|
37
|
+
return value
|
|
38
|
+
.replace(/&/g, '&')
|
|
39
|
+
.replace(/</g, '<')
|
|
40
|
+
.replace(/>/g, '>')
|
|
41
|
+
.replace(/"/g, '"')
|
|
42
|
+
.replace(/'/g, ''');
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Escape a value of unknown type for HTML.
|
|
46
|
+
*
|
|
47
|
+
* Null and undefined become an em dash rather than the words "null" or
|
|
48
|
+
* "undefined", because the document is read by a person and those two words
|
|
49
|
+
* are this language's, not this service's. **This is not the `unreachable`
|
|
50
|
+
* case** — that is a different fact with its own explicit word (§4.2a), and
|
|
51
|
+
* it is decided in `addresses.ts` before anything reaches here.
|
|
52
|
+
*
|
|
53
|
+
* **Only the four primitive shapes a cell can honestly hold are rendered**,
|
|
54
|
+
* and anything else becomes the em dash too. That is not defensiveness: a
|
|
55
|
+
* value that reached a table cell as an object or an array is a mistake in
|
|
56
|
+
* the caller, and stringifying it would print the language's own placeholder
|
|
57
|
+
* for "I could not do this" into a document a person reads as a fact. An
|
|
58
|
+
* em dash says the cell has nothing in it, which is at least true.
|
|
59
|
+
*/
|
|
60
|
+
export function escapeValue(value) {
|
|
61
|
+
if (typeof value === 'string') {
|
|
62
|
+
return escapeHtml(value);
|
|
63
|
+
}
|
|
64
|
+
if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') {
|
|
65
|
+
return escapeHtml(value.toString());
|
|
66
|
+
}
|
|
67
|
+
return '—';
|
|
68
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readAddresses, } from "../operations/addresses.js";
|
|
4
|
+
import { readOperationsStatus } from "../operations/status.js";
|
|
5
|
+
import { renderDocument } from "./document.js";
|
|
6
|
+
/**
|
|
7
|
+
* `broker snapshot` — write the operations document to a path and exit
|
|
8
|
+
* (`SCHEMA.md` §4.5, §5.5).
|
|
9
|
+
*
|
|
10
|
+
* **One command, one file. It writes the document wherever it is told,
|
|
11
|
+
* reports the path, and exits. It leaves nothing behind and holds nothing
|
|
12
|
+
* open.**
|
|
13
|
+
*
|
|
14
|
+
* ── The order of operations here is the design ──────────────────────────
|
|
15
|
+
*
|
|
16
|
+
* 1. Read the status. One statement set, derived (§2.4), and it finishes.
|
|
17
|
+
* 2. **Then** ask the browsers where their tabs are (§4.2a), each under its
|
|
18
|
+
* own timeout.
|
|
19
|
+
* 3. Render, and write.
|
|
20
|
+
*
|
|
21
|
+
* Step 2 is after step 1 and outside everything, which is what keeps §2.4b's
|
|
22
|
+
* hard rule true here: **browser work never happens inside the arbitration
|
|
23
|
+
* transaction.** The status read is over by the time a browser is asked
|
|
24
|
+
* anything, and nothing in this file opens a transaction around either.
|
|
25
|
+
* `MILESTONES.md` #70 says the same thing from the other direction —
|
|
26
|
+
* "generation happens outside any transaction, so this does not violate the
|
|
27
|
+
* never-do-browser-I/O-inside-the-arbitration-transaction rule — but it MUST
|
|
28
|
+
* carry a timeout".
|
|
29
|
+
*
|
|
30
|
+
* ── Failing to reach the browsers is not failing to produce a document ───
|
|
31
|
+
*
|
|
32
|
+
* §4.2a: a generator that inherits a browser's hang "produces nothing at all,
|
|
33
|
+
* which is a worse outcome than an incomplete document". So there is no path
|
|
34
|
+
* through this function that throws because a browser was unavailable. Every
|
|
35
|
+
* such outcome becomes `unreachable` in the document, which is the explicit
|
|
36
|
+
* word the rule requires.
|
|
37
|
+
*/
|
|
38
|
+
/** How long one tab's address read may take, in milliseconds. */
|
|
39
|
+
export const DEFAULT_ADDRESS_TIMEOUT_MS = 2000;
|
|
40
|
+
/**
|
|
41
|
+
* Generate the document and write it.
|
|
42
|
+
*
|
|
43
|
+
* The directory is created if it is absent, which is the one filesystem
|
|
44
|
+
* liberty this takes and it is the same one every writer of a named output
|
|
45
|
+
* path takes: a person naming a path inside a directory that does not exist
|
|
46
|
+
* meant the path, and refusing would be pedantry rather than safety.
|
|
47
|
+
*/
|
|
48
|
+
export async function writeSnapshot(db, options) {
|
|
49
|
+
// Step 1: the derived status read. Finishes before any browser is touched.
|
|
50
|
+
const status = readOperationsStatus(db, {
|
|
51
|
+
eventLimit: options.eventLimit,
|
|
52
|
+
feedbackLimit: options.feedbackLimit,
|
|
53
|
+
now: options.now,
|
|
54
|
+
});
|
|
55
|
+
// Step 2: the live address read, outside everything.
|
|
56
|
+
let addresses = new Map();
|
|
57
|
+
let addressNote;
|
|
58
|
+
let tabsAsked = 0;
|
|
59
|
+
const liveTabIds = status.sessions
|
|
60
|
+
.flatMap((session) => session.leases)
|
|
61
|
+
.map((lease) => lease.tabId)
|
|
62
|
+
.filter((tabId) => tabId !== null);
|
|
63
|
+
if (options.addresses === undefined) {
|
|
64
|
+
if (liveTabIds.length > 0) {
|
|
65
|
+
addressNote =
|
|
66
|
+
'No browser connection was available when this snapshot was taken, so no address could be read. Every address below reads as unreachable for that one reason rather than because fifteen separate reads failed.';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Only ask about tabs the document is actually going to show. A request
|
|
71
|
+
// for a tab no live lease holds would be a read of a page nobody owns,
|
|
72
|
+
// which is exactly the browsing-history shape §1.4 deletes the column to
|
|
73
|
+
// avoid.
|
|
74
|
+
const shown = new Set(liveTabIds);
|
|
75
|
+
const requests = options.addresses.requests.filter((request) => shown.has(request.tabId));
|
|
76
|
+
tabsAsked = requests.length;
|
|
77
|
+
addresses = await readAddresses(options.addresses.source, requests, {
|
|
78
|
+
timeoutMs: options.addresses.timeoutMs ?? DEFAULT_ADDRESS_TIMEOUT_MS,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Step 3: render and write.
|
|
82
|
+
const html = renderDocument({ status, addresses, addressNote, version: options.version });
|
|
83
|
+
const resolved = path.resolve(options.outputPath);
|
|
84
|
+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
|
85
|
+
fs.writeFileSync(resolved, html, 'utf8');
|
|
86
|
+
const unreachable = [...addresses.values()].filter((address) => address.kind === 'unreachable').length;
|
|
87
|
+
return {
|
|
88
|
+
path: resolved,
|
|
89
|
+
bytes: Buffer.byteLength(html, 'utf8'),
|
|
90
|
+
at: status.at,
|
|
91
|
+
tabsAsked,
|
|
92
|
+
// Tabs that were never asked about count as unreachable, because that is
|
|
93
|
+
// how the document renders them — a number that disagreed with the
|
|
94
|
+
// document would be worse than no number.
|
|
95
|
+
tabsUnreachable: unreachable + (liveTabIds.length - tabsAsked),
|
|
96
|
+
};
|
|
97
|
+
}
|