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.
Files changed (123) hide show
  1. package/.env.example +173 -0
  2. package/LICENSE +21 -0
  3. package/README.md +374 -0
  4. package/RELEASES.md +97 -0
  5. package/dist/package.json +58 -0
  6. package/dist/src/adapter/conformance/case.js +1 -0
  7. package/dist/src/adapter/conformance/cases.js +429 -0
  8. package/dist/src/adapter/conformance/discovery.js +156 -0
  9. package/dist/src/adapter/conformance/driver.js +1 -0
  10. package/dist/src/adapter/conformance/drivers.js +36 -0
  11. package/dist/src/adapter/conformance/run.js +224 -0
  12. package/dist/src/adapter/conformance/service-subject.js +165 -0
  13. package/dist/src/adapter/contract.js +24 -0
  14. package/dist/src/adapter/operations.js +114 -0
  15. package/dist/src/adapter/service-seam.js +1 -0
  16. package/dist/src/artifacts/names.js +229 -0
  17. package/dist/src/artifacts/store.js +174 -0
  18. package/dist/src/bin/broker-tool.js +63 -0
  19. package/dist/src/bin/broker.js +111 -0
  20. package/dist/src/browser/adoption.js +143 -0
  21. package/dist/src/browser/automation-probe.js +113 -0
  22. package/dist/src/browser/conformance/case.js +1 -0
  23. package/dist/src/browser/conformance/cases.js +192 -0
  24. package/dist/src/browser/conformance/run.js +102 -0
  25. package/dist/src/browser/conformance/subjects.js +19 -0
  26. package/dist/src/browser/discovery.js +226 -0
  27. package/dist/src/browser/driver.js +195 -0
  28. package/dist/src/browser/fake.js +585 -0
  29. package/dist/src/browser/launch.js +504 -0
  30. package/dist/src/browser/real.js +1425 -0
  31. package/dist/src/browser/setup.js +161 -0
  32. package/dist/src/capture/accounting.js +59 -0
  33. package/dist/src/capture/image.js +112 -0
  34. package/dist/src/capture/ladder.js +72 -0
  35. package/dist/src/capture/legibility.js +195 -0
  36. package/dist/src/capture/pipeline.js +153 -0
  37. package/dist/src/capture/tiers.js +166 -0
  38. package/dist/src/cli/adapter.js +233 -0
  39. package/dist/src/cli/commands.js +270 -0
  40. package/dist/src/cli/conformance-driver.js +119 -0
  41. package/dist/src/cli/diffs.js +122 -0
  42. package/dist/src/cli/image.js +274 -0
  43. package/dist/src/cli/index.js +895 -0
  44. package/dist/src/cli/login-command.js +401 -0
  45. package/dist/src/cli/operations-commands.js +186 -0
  46. package/dist/src/cli/reconcile-command.js +137 -0
  47. package/dist/src/cli/sign-in.js +134 -0
  48. package/dist/src/cli/telemetry.js +222 -0
  49. package/dist/src/config/environment.js +446 -0
  50. package/dist/src/diff/artifact-path.js +77 -0
  51. package/dist/src/diff/crops.js +102 -0
  52. package/dist/src/diff/geometry.js +122 -0
  53. package/dist/src/diff/image.js +132 -0
  54. package/dist/src/diff/mask.js +46 -0
  55. package/dist/src/diff/regions.js +263 -0
  56. package/dist/src/diff/settings.js +135 -0
  57. package/dist/src/doctor/checks.js +588 -0
  58. package/dist/src/doctor/report.js +152 -0
  59. package/dist/src/doctor/session.js +161 -0
  60. package/dist/src/errors.js +36 -0
  61. package/dist/src/feedback/read.js +119 -0
  62. package/dist/src/feedback/record.js +199 -0
  63. package/dist/src/operations/addresses.js +175 -0
  64. package/dist/src/operations/derive.js +109 -0
  65. package/dist/src/operations/ledger.js +194 -0
  66. package/dist/src/operations/status.js +197 -0
  67. package/dist/src/operations/telemetry.js +280 -0
  68. package/dist/src/report/document.js +419 -0
  69. package/dist/src/report/escape.js +68 -0
  70. package/dist/src/report/snapshot.js +97 -0
  71. package/dist/src/service/arbitration.js +537 -0
  72. package/dist/src/service/artifacts.js +85 -0
  73. package/dist/src/service/bridge.js +577 -0
  74. package/dist/src/service/broker.js +120 -0
  75. package/dist/src/service/browser-session.js +269 -0
  76. package/dist/src/service/capacity.js +62 -0
  77. package/dist/src/service/capture-seam.js +83 -0
  78. package/dist/src/service/capture-store.js +91 -0
  79. package/dist/src/service/comparison-store.js +101 -0
  80. package/dist/src/service/comparison.js +173 -0
  81. package/dist/src/service/events.js +93 -0
  82. package/dist/src/service/keys.js +68 -0
  83. package/dist/src/service/leases.js +147 -0
  84. package/dist/src/service/nudge.js +66 -0
  85. package/dist/src/service/operations/claim.js +692 -0
  86. package/dist/src/service/operations/give-back.js +131 -0
  87. package/dist/src/service/operations/pages.js +771 -0
  88. package/dist/src/service/operations/sign-in.js +915 -0
  89. package/dist/src/service/operations/status.js +62 -0
  90. package/dist/src/service/ownership.js +93 -0
  91. package/dist/src/service/pages.js +616 -0
  92. package/dist/src/service/pending-seeds.js +20 -0
  93. package/dist/src/service/queue.js +233 -0
  94. package/dist/src/service/reconcile.js +220 -0
  95. package/dist/src/service/refusals.js +262 -0
  96. package/dist/src/service/runtime.js +131 -0
  97. package/dist/src/service/signin-recovery.js +148 -0
  98. package/dist/src/service/storage-seed.js +239 -0
  99. package/dist/src/service/tabs.js +123 -0
  100. package/dist/src/store/budget.js +99 -0
  101. package/dist/src/store/location.js +42 -0
  102. package/dist/src/store/network-path.js +182 -0
  103. package/dist/src/store/network-volume.js +92 -0
  104. package/dist/src/store/open.js +226 -0
  105. package/dist/src/store/schema/step-001-initial.js +523 -0
  106. package/dist/src/store/schema/step-002-tab-budget.js +53 -0
  107. package/dist/src/store/schema/step-003-queue-order.js +110 -0
  108. package/dist/src/store/schema/step-004-tab-never-opened.js +100 -0
  109. package/dist/src/store/schema/step-005-storage-seed-event.js +90 -0
  110. package/dist/src/store/schema/step-006-signin-events.js +104 -0
  111. package/dist/src/store/schema/step-007-signin-without-process.js +92 -0
  112. package/dist/src/store/schema/step-008-signin-owner.js +76 -0
  113. package/dist/src/store/schema/step-009-named-browsers.js +138 -0
  114. package/dist/src/store/schema/step-010-signin-request.js +135 -0
  115. package/dist/src/store/schema/step.js +172 -0
  116. package/dist/src/store/schema/steps.js +58 -0
  117. package/dist/src/store/transaction.js +37 -0
  118. package/dist/src/tool/adapter.js +90 -0
  119. package/dist/src/tool/conformance-driver.js +184 -0
  120. package/dist/src/tool/protocol.js +310 -0
  121. package/dist/src/tool/session.js +351 -0
  122. package/dist/src/tool/tools.js +310 -0
  123. 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 `&amp;lt;`.
35
+ */
36
+ export function escapeHtml(value) {
37
+ return value
38
+ .replace(/&/g, '&amp;')
39
+ .replace(/</g, '&lt;')
40
+ .replace(/>/g, '&gt;')
41
+ .replace(/"/g, '&quot;')
42
+ .replace(/'/g, '&#39;');
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
+ }