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,616 @@
|
|
|
1
|
+
import { ARTIFACT_COLLECTION, PAGE_ACTIONS, READ_ARTIFACTS, } from "../browser/driver.js";
|
|
2
|
+
import { BrokerError } from "../errors.js";
|
|
3
|
+
/**
|
|
4
|
+
* The page verbs: what a caller may ask of a tab it owns, and every way that
|
|
5
|
+
* asking is refused (rows #22, #61, #62, #63, #64, #23, #24).
|
|
6
|
+
*
|
|
7
|
+
* โโ What is in this file and what is deliberately not โโโโโโโโโโโโโโโโโโโ
|
|
8
|
+
*
|
|
9
|
+
* Everything here is **argument validation and shaping**. Nothing in this
|
|
10
|
+
* file opens a transaction, reads the store, or talks to a browser, and that
|
|
11
|
+
* is a boundary rather than a coincidence:
|
|
12
|
+
*
|
|
13
|
+
* - Ownership โ ยง7.1 `tab.owned` and `tab.open` โ is answered in `tabs.ts`,
|
|
14
|
+
* against the store, by a query selecting on both the tab and its lease.
|
|
15
|
+
* Validating an argument cannot establish who owns anything.
|
|
16
|
+
* - Browser work happens after the arbitration transaction commits (ยง2.4b).
|
|
17
|
+
* A validator that could reach a browser is a validator that will
|
|
18
|
+
* eventually be called from inside one.
|
|
19
|
+
*
|
|
20
|
+
* So the functions here take a caller's arguments and either return the
|
|
21
|
+
* typed request the driver seam declares, or throw. **They are the step that
|
|
22
|
+
* turns `unknown` into `ActionRequest`** โ the seam's own note says a cast at
|
|
23
|
+
* the boundary makes its union decorative, and this is the code that makes
|
|
24
|
+
* the cast unnecessary.
|
|
25
|
+
*
|
|
26
|
+
* โโ Refusals are the specification โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
27
|
+
*
|
|
28
|
+
* `SCHEMA.md` ยง7's opening line โ "a rule that never refuses anything
|
|
29
|
+
* protects nothing, so the refusals are the specification" โ is why this file
|
|
30
|
+
* is mostly refusals and why each one carries the ยง7 rule that produced it.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* A refusal from one of these operations.
|
|
34
|
+
*
|
|
35
|
+
* **Deliberately not the service layer's `CallRefusal`.** That taxonomy is a
|
|
36
|
+
* closed table of codes owned by the arbitration row, and its own comment
|
|
37
|
+
* states the discipline it keeps: "a code nothing can raise is exactly the
|
|
38
|
+
* assertion-over-an-empty-set that passes forever and silently โ add the row
|
|
39
|
+
* with the guard, never before it." This module is the guards; the codes for
|
|
40
|
+
* them are added to that table when the two are wired together, and inventing
|
|
41
|
+
* them here would either duplicate the table or edit another row's file.
|
|
42
|
+
*
|
|
43
|
+
* What is carried now is the part that must not be lost in the meantime: the
|
|
44
|
+
* ยง7 rule name, which is what the ledger is grepped by and what ยง8's parity
|
|
45
|
+
* assertion counts over.
|
|
46
|
+
*/
|
|
47
|
+
export class PageRefusal extends BrokerError {
|
|
48
|
+
/** ยง3.14's "any details" โ the numbers a caller branches on after the rule. */
|
|
49
|
+
detail;
|
|
50
|
+
constructor(rule, message, detail = {}) {
|
|
51
|
+
super(rule, message);
|
|
52
|
+
this.name = 'PageRefusal';
|
|
53
|
+
this.detail = detail;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/* โโโโโโโโโโโโโโโโโโโโโโโโโ navigate (#22) โโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
57
|
+
/**
|
|
58
|
+
* The address schemes a navigation may use.
|
|
59
|
+
*
|
|
60
|
+
* **An allowlist, and the shape of the rule is the rule.** ยง7.1
|
|
61
|
+
* `navigate.scheme_allowed` requires "ordinary web traffic or a blank page",
|
|
62
|
+
* and a denylist of the schemes to refuse would be wrong in the direction
|
|
63
|
+
* that matters: every scheme nobody thought of would be permitted, and
|
|
64
|
+
* browsers carry a great many. The refusal ยง3.7 names explicitly is the local
|
|
65
|
+
* file, because it "turns a browser lease into an arbitrary read of the
|
|
66
|
+
* machine's filesystem, which no part of this contract intends to grant" โ
|
|
67
|
+
* but it is refused here by not being on this list, not by being matched.
|
|
68
|
+
*/
|
|
69
|
+
const ALLOWED_SCHEMES = ['http:', 'https:'];
|
|
70
|
+
/** The one address that is not a scheme match: a deliberately blank page. */
|
|
71
|
+
const BLANK_PAGE = 'about:blank';
|
|
72
|
+
/**
|
|
73
|
+
* Check an address before anything navigates to it.
|
|
74
|
+
*
|
|
75
|
+
* Returns the address to navigate to. Refuses anything that is not ordinary
|
|
76
|
+
* web traffic or a blank page.
|
|
77
|
+
*/
|
|
78
|
+
export function validateNavigationTarget(url) {
|
|
79
|
+
if (typeof url !== 'string' || url.trim() === '') {
|
|
80
|
+
throw new PageRefusal('navigate.scheme_allowed', 'A navigation needs an address: ordinary web traffic, or a blank page.', { allowedSchemes: [...ALLOWED_SCHEMES], blankPage: BLANK_PAGE });
|
|
81
|
+
}
|
|
82
|
+
const candidate = url.trim();
|
|
83
|
+
if (candidate === BLANK_PAGE)
|
|
84
|
+
return candidate;
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = new URL(candidate);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new PageRefusal('navigate.scheme_allowed', `That is not an address this service can navigate to. Use ordinary web traffic (${ALLOWED_SCHEMES.join(', ')}) or ${BLANK_PAGE}.`, { allowedSchemes: [...ALLOWED_SCHEMES], blankPage: BLANK_PAGE });
|
|
91
|
+
}
|
|
92
|
+
if (!ALLOWED_SCHEMES.includes(parsed.protocol)) {
|
|
93
|
+
// The local-file case lands here, and ยง3.7 refuses it specifically. It is
|
|
94
|
+
// named in the sentence rather than matched in the condition: matching it
|
|
95
|
+
// would suggest the other schemes are fine, and the allowlist above is
|
|
96
|
+
// what actually decides.
|
|
97
|
+
throw new PageRefusal('navigate.scheme_allowed', `The address uses "${parsed.protocol}", which this service does not navigate to. Ordinary web traffic (${ALLOWED_SCHEMES.join(', ')}) or ${BLANK_PAGE} only โ a local-file address in particular would turn a browser lease into a read of this machine's filesystem.`, { scheme: parsed.protocol, allowedSchemes: [...ALLOWED_SCHEMES], blankPage: BLANK_PAGE });
|
|
98
|
+
}
|
|
99
|
+
return candidate;
|
|
100
|
+
}
|
|
101
|
+
/* โโโโโโโโโโโโโโโโโโโโโโโโโ act (#22, #61โ#64) โโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
102
|
+
/**
|
|
103
|
+
* The refusal that **lists every action by name**.
|
|
104
|
+
*
|
|
105
|
+
* `SCHEMA.md` ยง3.8: "Refused for an action that is not on the list, **listing
|
|
106
|
+
* every action** โ the discoverability cost of folding them into one tool is
|
|
107
|
+
* paid back here or not at all."
|
|
108
|
+
*
|
|
109
|
+
* That last clause is the whole argument and it is worth keeping in front of
|
|
110
|
+
* whoever edits this. Rows #61 to #64 each fold a capability into
|
|
111
|
+
* `browser_act` instead of adding a tool, on the grounds that each is
|
|
112
|
+
* tab-scoped, non-destructive, invisible to other callers and leaves nothing
|
|
113
|
+
* to recover from. **What folding costs is discoverability**: a caller
|
|
114
|
+
* reading a list of tools sees one entry where it would have seen five. This
|
|
115
|
+
* refusal is the entire repayment. A refusal that said "unknown action" would
|
|
116
|
+
* take the saving and default on the debt.
|
|
117
|
+
*
|
|
118
|
+
* The list comes from {@link PAGE_ACTIONS} rather than being written out
|
|
119
|
+
* again here, so a row adding a verb cannot add it to the union and forget
|
|
120
|
+
* the refusal โ there is one place, which is what the seam's comment says the
|
|
121
|
+
* closed union is for.
|
|
122
|
+
*/
|
|
123
|
+
export function refuseUnknownAction(action) {
|
|
124
|
+
throw new PageRefusal('act.action_known', `There is no "${String(action)}" action. Every action this service performs: ${PAGE_ACTIONS.join(', ')}.`, { action, actions: [...PAGE_ACTIONS] });
|
|
125
|
+
}
|
|
126
|
+
/** Whether a string is one of the verbs. */
|
|
127
|
+
export function isPageAction(action) {
|
|
128
|
+
return typeof action === 'string' && PAGE_ACTIONS.includes(action);
|
|
129
|
+
}
|
|
130
|
+
/** An element reference, refused when it is absent or empty. */
|
|
131
|
+
function requireRef(value, field, action) {
|
|
132
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
133
|
+
throw new PageRefusal('act.ref_required', `The "${action}" action addresses an element, so it needs ${field} โ a reference taken from the tab's most recent snapshot.`, { action, field });
|
|
134
|
+
}
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
/** A value, refused when the action needs one and it is absent. */
|
|
138
|
+
function requireValue(value, action) {
|
|
139
|
+
if (typeof value !== 'string') {
|
|
140
|
+
throw new PageRefusal('act.value_required', `The "${action}" action needs a value to apply.`, {
|
|
141
|
+
action,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The largest viewport this service will set, per side.
|
|
148
|
+
*
|
|
149
|
+
* A bound rather than none, and the reason is not politeness: a viewport is
|
|
150
|
+
* allocated, so an unbounded one is a memory request from a caller that costs
|
|
151
|
+
* the machine rather than the caller. The number is generous enough that no
|
|
152
|
+
* real review hits it โ well past the largest ordinary display โ which is the
|
|
153
|
+
* property that makes it a guard against a mistake rather than a limit
|
|
154
|
+
* anybody has to plan around.
|
|
155
|
+
*/
|
|
156
|
+
export const MAX_VIEWPORT_SIDE = 16384;
|
|
157
|
+
/** A viewport side: a positive whole number within the bound. */
|
|
158
|
+
function requireViewportSide(value, side) {
|
|
159
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
|
|
160
|
+
throw new PageRefusal('act.viewport_bounded', `A viewport ${side} is a whole number of pixels greater than zero.`, { side, value });
|
|
161
|
+
}
|
|
162
|
+
if (value > MAX_VIEWPORT_SIDE) {
|
|
163
|
+
throw new PageRefusal('act.viewport_bounded', `A viewport ${side} of ${String(value)} is past this service's bound of ${String(MAX_VIEWPORT_SIDE)}.`, { side, value, maximum: MAX_VIEWPORT_SIDE });
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
|
167
|
+
/** The values each media preference accepts (ยง3.8's table). */
|
|
168
|
+
const MEDIA_PREFERENCES = {
|
|
169
|
+
colourScheme: ['light', 'dark', 'no-preference'],
|
|
170
|
+
reducedMotion: ['reduce', 'no-preference'],
|
|
171
|
+
forcedColours: ['active', 'none'],
|
|
172
|
+
};
|
|
173
|
+
/** The preferences by name, for a refusal that lists them. */
|
|
174
|
+
export const MEDIA_PREFERENCE_NAMES = Object.keys(MEDIA_PREFERENCES);
|
|
175
|
+
/**
|
|
176
|
+
* The most fields one batch fill may carry.
|
|
177
|
+
*
|
|
178
|
+
* Batch fill exists because 78 measured calls across 35 sessions filled
|
|
179
|
+
* several fields in a row (#64); it is not a bulk-data channel, and a bound
|
|
180
|
+
* is what keeps the second reading from becoming available by accident.
|
|
181
|
+
*/
|
|
182
|
+
export const MAX_FORM_FIELDS = 64;
|
|
183
|
+
/**
|
|
184
|
+
* Turn a caller's arguments into the typed request the driver takes, or
|
|
185
|
+
* refuse.
|
|
186
|
+
*
|
|
187
|
+
* **The input is `unknown` because that is what a caller's arguments are.**
|
|
188
|
+
* The seam's own note is explicit that a cast at the boundary makes its union
|
|
189
|
+
* decorative โ the compiler would then be checking a claim this function made
|
|
190
|
+
* up rather than a fact it established. So every field is examined.
|
|
191
|
+
*/
|
|
192
|
+
export function validateAction(raw) {
|
|
193
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
194
|
+
throw new PageRefusal('act.action_known', 'An action names what to do and what to do it to.', {
|
|
195
|
+
actions: [...PAGE_ACTIONS],
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const input = raw;
|
|
199
|
+
const action = input.action;
|
|
200
|
+
if (!isPageAction(action))
|
|
201
|
+
refuseUnknownAction(action);
|
|
202
|
+
switch (action) {
|
|
203
|
+
case 'click':
|
|
204
|
+
case 'hover':
|
|
205
|
+
case 'check':
|
|
206
|
+
return { action, ref: requireRef(input.ref, 'an element reference', action) };
|
|
207
|
+
case 'type':
|
|
208
|
+
case 'fill':
|
|
209
|
+
case 'select':
|
|
210
|
+
return {
|
|
211
|
+
action,
|
|
212
|
+
ref: requireRef(input.ref, 'an element reference', action),
|
|
213
|
+
value: requireValue(input.value, action),
|
|
214
|
+
};
|
|
215
|
+
case 'press':
|
|
216
|
+
// The reference is optional: a press with none goes to whatever the
|
|
217
|
+
// page has focused, which is how a caller sends a key to a page rather
|
|
218
|
+
// than to a particular field.
|
|
219
|
+
return {
|
|
220
|
+
action,
|
|
221
|
+
...(input.ref === undefined
|
|
222
|
+
? {}
|
|
223
|
+
: { ref: requireRef(input.ref, 'an element reference', action) }),
|
|
224
|
+
value: requireValue(input.value, action),
|
|
225
|
+
};
|
|
226
|
+
case 'scroll':
|
|
227
|
+
return {
|
|
228
|
+
action,
|
|
229
|
+
...(input.ref === undefined
|
|
230
|
+
? {}
|
|
231
|
+
: { ref: requireRef(input.ref, 'an element reference', action) }),
|
|
232
|
+
};
|
|
233
|
+
case 'resize': {
|
|
234
|
+
// #61. Two integers, not a string to be re-parsed โ see `Viewport` on
|
|
235
|
+
// the seam for why that shape is the one that carries meaning.
|
|
236
|
+
const viewport = input.viewport;
|
|
237
|
+
if (typeof viewport !== 'object' || viewport === null) {
|
|
238
|
+
// **The syntax, not only the semantics.** The previous wording said
|
|
239
|
+
// what a resize needs and never how to write it, so a caller who had
|
|
240
|
+
// supplied a width and a height in pixels โ in one of five reasonable
|
|
241
|
+
// spellings โ read a message telling them to supply a width and a
|
|
242
|
+
// height in pixels. There was no way to converge by guessing, and the
|
|
243
|
+
// session that hit it stopped after five attempts. An example ends
|
|
244
|
+
// that in one call.
|
|
245
|
+
throw new PageRefusal('act.viewport_bounded', "A resize sets the tab's viewport, so it needs a width and a height in pixels: " +
|
|
246
|
+
'`--width 390 --height 844`, or `--value 390x844`.', { action });
|
|
247
|
+
}
|
|
248
|
+
const { width, height } = viewport;
|
|
249
|
+
return {
|
|
250
|
+
action,
|
|
251
|
+
viewport: {
|
|
252
|
+
width: requireViewportSide(width, 'width'),
|
|
253
|
+
height: requireViewportSide(height, 'height'),
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
case 'emulate': {
|
|
258
|
+
// #62. Every preference is optional independently โ a caller switching
|
|
259
|
+
// to dark mode says nothing about motion or contrast โ but an emulate
|
|
260
|
+
// naming none of them is a call that means nothing, and that is the
|
|
261
|
+
// refusal.
|
|
262
|
+
const preferences = input.preferences;
|
|
263
|
+
if (typeof preferences !== 'object' || preferences === null) {
|
|
264
|
+
// The names were always here, and they were the good half of this
|
|
265
|
+
// message. What was missing is how to write one โ see the resize
|
|
266
|
+
// refusal above for the same fix and the same reason.
|
|
267
|
+
throw new PageRefusal('act.emulate_preference_named', `An emulate sets media preferences, so it names at least one of: ${MEDIA_PREFERENCE_NAMES.join(', ')}. ` +
|
|
268
|
+
'For example `--colour-scheme dark`.', { action, preferences: MEDIA_PREFERENCE_NAMES });
|
|
269
|
+
}
|
|
270
|
+
const supplied = preferences;
|
|
271
|
+
const validated = {};
|
|
272
|
+
for (const name of MEDIA_PREFERENCE_NAMES) {
|
|
273
|
+
const value = supplied[name];
|
|
274
|
+
if (value === undefined)
|
|
275
|
+
continue;
|
|
276
|
+
const allowed = MEDIA_PREFERENCES[name];
|
|
277
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
278
|
+
throw new PageRefusal('act.emulate_preference_named', `"${name}" is one of: ${allowed.join(', ')}.`, { action, preference: name, allowed: [...allowed], value });
|
|
279
|
+
}
|
|
280
|
+
validated[name] = value;
|
|
281
|
+
}
|
|
282
|
+
if (Object.keys(validated).length === 0) {
|
|
283
|
+
throw new PageRefusal('act.emulate_preference_named', `An emulate names at least one preference to set: ${MEDIA_PREFERENCE_NAMES.join(', ')}. ` +
|
|
284
|
+
'For example `--colour-scheme dark`.', { action, preferences: MEDIA_PREFERENCE_NAMES });
|
|
285
|
+
}
|
|
286
|
+
return { action, preferences: validated };
|
|
287
|
+
}
|
|
288
|
+
case 'dialog': {
|
|
289
|
+
// #63. Here on consequence rather than frequency: an unhandled dialog
|
|
290
|
+
// blocks its tab, so the caller holds a lease it cannot use and burns
|
|
291
|
+
// it.
|
|
292
|
+
const response = input.response;
|
|
293
|
+
if (typeof response !== 'object' || response === null) {
|
|
294
|
+
throw new PageRefusal('act.dialog_answer_named', 'Answering a dialog says whether to accept it or dismiss it.', { action });
|
|
295
|
+
}
|
|
296
|
+
const { accept, promptText } = response;
|
|
297
|
+
if (typeof accept !== 'boolean') {
|
|
298
|
+
throw new PageRefusal('act.dialog_answer_named', 'Answering a dialog says whether to accept it or dismiss it.', { action });
|
|
299
|
+
}
|
|
300
|
+
if (promptText !== undefined && typeof promptText !== 'string') {
|
|
301
|
+
throw new PageRefusal('act.dialog_answer_named', "A dialog's prompt text is text.", {
|
|
302
|
+
action,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
if (promptText !== undefined && !accept) {
|
|
306
|
+
// Text plus a dismissal describes two intentions at once, and
|
|
307
|
+
// guessing which one was meant is how a caller ends up believing it
|
|
308
|
+
// answered a prompt it actually threw away.
|
|
309
|
+
throw new PageRefusal('act.dialog_answer_named', 'Prompt text is what to type before accepting, so it cannot accompany a dismissal. Accept the dialog, or dismiss it without text.', { action });
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
action,
|
|
313
|
+
response: { accept, ...(promptText === undefined ? {} : { promptText }) },
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
case 'fill_form': {
|
|
317
|
+
// #64, the measured half: 78 calls across 35 sessions.
|
|
318
|
+
const fields = input.fields;
|
|
319
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
320
|
+
throw new PageRefusal('act.form_fields_bounded', 'A batch fill needs at least one field to fill, each with an element reference and a value.', { action, maximum: MAX_FORM_FIELDS });
|
|
321
|
+
}
|
|
322
|
+
if (fields.length > MAX_FORM_FIELDS) {
|
|
323
|
+
throw new PageRefusal('act.form_fields_bounded', `A batch fill carries at most ${String(MAX_FORM_FIELDS)} fields, and this one carries ${String(fields.length)}.`, { action, count: fields.length, maximum: MAX_FORM_FIELDS });
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
action,
|
|
327
|
+
fields: fields.map((field, index) => {
|
|
328
|
+
const entry = (typeof field === 'object' && field !== null ? field : {});
|
|
329
|
+
if (typeof entry.ref !== 'string' || entry.ref.trim() === '') {
|
|
330
|
+
throw new PageRefusal('act.ref_required', `Field ${String(index)} of the batch fill needs an element reference taken from the tab's most recent snapshot.`, { action, index });
|
|
331
|
+
}
|
|
332
|
+
if (typeof entry.value !== 'string') {
|
|
333
|
+
throw new PageRefusal('act.value_required', `Field ${String(index)} needs a value.`, {
|
|
334
|
+
action,
|
|
335
|
+
index,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return { ref: entry.ref, value: entry.value };
|
|
339
|
+
}),
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
case 'drag': {
|
|
343
|
+
// #64, the unexercised half: **zero calls across 2,007 transcripts**
|
|
344
|
+
// over a month. Folded in at low priority with that number recorded, so
|
|
345
|
+
// that if it turns out to matter it arrives with the number to argue
|
|
346
|
+
// against. In-page, element to element โ there is no
|
|
347
|
+
// file-from-the-desktop shape, because a lease is a tab.
|
|
348
|
+
const ref = requireRef(input.ref, 'an element reference for what is being dragged', action);
|
|
349
|
+
const targetRef = requireRef(input.targetRef, 'a second element reference for where it is being dragged to', action);
|
|
350
|
+
if (ref === targetRef) {
|
|
351
|
+
throw new PageRefusal('act.drag_ends_differ', 'A drag moves something from one element to another, so its two references cannot be the same element.', { action, ref });
|
|
352
|
+
}
|
|
353
|
+
return { action, ref, targetRef };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/* โโโโโโโโโโโโโโโโโโโโโโโโโ read (#23) โโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
358
|
+
/**
|
|
359
|
+
* Which artefacts a read will write, given what the caller asked for.
|
|
360
|
+
*
|
|
361
|
+
* โโ The default is the snapshot, and the filter is free โโโโโโโโโโโโโโโโโ
|
|
362
|
+
*
|
|
363
|
+
* ยง7.1 `read.default_snapshot_only` is a **default, not a refusal**. The
|
|
364
|
+
* snapshot is on because it is the only load-bearing artefact โ every element
|
|
365
|
+
* reference `browser_act` takes comes from it (ยง3.8), so a read that omitted
|
|
366
|
+
* it would be useless in the ordinary case. The other three are off.
|
|
367
|
+
*
|
|
368
|
+
* **And the reason the narrow default is cheap rather than a trap is worth
|
|
369
|
+
* stating where the code is, because it is the part a reader gets wrong:**
|
|
370
|
+
* console output and network activity are **accumulated continuously by the
|
|
371
|
+
* browsing context**, from the moment the context exists, whether or not
|
|
372
|
+
* anybody intends to ask. There is no request that starts or stops the
|
|
373
|
+
* collection. So this is a filter on **what gets written to disk**, not on
|
|
374
|
+
* what gets collected, and **the cost of not asking is zero** โ a caller that
|
|
375
|
+
* realises afterwards that it wanted the console asks on its next read and
|
|
376
|
+
* gets the accumulated history, not a recording that started when it asked.
|
|
377
|
+
*
|
|
378
|
+
* A default that withheld something expensive to reproduce would push callers
|
|
379
|
+
* into asking for everything defensively. This one withholds nothing that
|
|
380
|
+
* becomes harder to get.
|
|
381
|
+
*
|
|
382
|
+
* **Cookies are the exception and it is named as one.** A cookie summary is a
|
|
383
|
+
* live query against the browsing context, answered at the moment of asking:
|
|
384
|
+
* there is no accumulated log to read from, so asking is a real operation
|
|
385
|
+
* with a real cost โ small, but not zero โ and the answer describes that
|
|
386
|
+
* instant rather than a history. Off by default for that reason as well as
|
|
387
|
+
* for the obvious one. {@link ARTIFACT_COLLECTION} on the seam is where this
|
|
388
|
+
* per-artefact fact lives so it is not something to reason out each time.
|
|
389
|
+
*/
|
|
390
|
+
export function resolveReadArtifacts(requested) {
|
|
391
|
+
if (requested === undefined || requested === null)
|
|
392
|
+
return ['snapshot'];
|
|
393
|
+
if (!Array.isArray(requested)) {
|
|
394
|
+
throw new PageRefusal('read.artifact_known', `A read names which artefacts it wants: ${READ_ARTIFACTS.join(', ')}.`, { artifacts: [...READ_ARTIFACTS] });
|
|
395
|
+
}
|
|
396
|
+
for (const artifact of requested) {
|
|
397
|
+
if (typeof artifact !== 'string' || !READ_ARTIFACTS.includes(artifact)) {
|
|
398
|
+
throw new PageRefusal('read.artifact_known', `There is no "${String(artifact)}" artefact. Every artefact a read can ask for: ${READ_ARTIFACTS.join(', ')}.`, { artifact, artifacts: [...READ_ARTIFACTS] });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const asked = new Set(requested);
|
|
402
|
+
// The snapshot is added rather than required, because a caller asking only
|
|
403
|
+
// for the console still needs somewhere to take its next element reference
|
|
404
|
+
// from, and a read that handed back a console log and no snapshot would
|
|
405
|
+
// leave the tab unusable until the caller worked out it had to ask again.
|
|
406
|
+
asked.add('snapshot');
|
|
407
|
+
// Returned in the seam's declared order rather than the caller's, so that
|
|
408
|
+
// two callers asking for the same set get the same answer and a test can
|
|
409
|
+
// name the order it expects.
|
|
410
|
+
return READ_ARTIFACTS.filter((artifact) => asked.has(artifact));
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Whether asking for an artefact costs anything, which is the honest answer
|
|
414
|
+
* to *"should I ask for this defensively"*.
|
|
415
|
+
*/
|
|
416
|
+
export function artifactIsLiveQuery(artifact) {
|
|
417
|
+
return ARTIFACT_COLLECTION[artifact] === 'live';
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The fields a cookie summary carries, and the whole of them.
|
|
421
|
+
*
|
|
422
|
+
* Written down as data rather than left to whatever a serialiser happens to
|
|
423
|
+
* emit, because ยง7.1 `read.cookies_no_values` is a **shape** and a shape
|
|
424
|
+
* needs something to be checked against. A test that walks a serialised
|
|
425
|
+
* cookie's keys and compares them to this list fails the moment a field is
|
|
426
|
+
* added, which is the moment a value would arrive if one ever did.
|
|
427
|
+
*/
|
|
428
|
+
export const COOKIE_SUMMARY_FIELDS = [
|
|
429
|
+
'name',
|
|
430
|
+
'domain',
|
|
431
|
+
'path',
|
|
432
|
+
'expires',
|
|
433
|
+
'httpOnly',
|
|
434
|
+
'secure',
|
|
435
|
+
'sameSite',
|
|
436
|
+
];
|
|
437
|
+
/**
|
|
438
|
+
* Shape a cookie summary for writing, **naming every field that survives**.
|
|
439
|
+
*
|
|
440
|
+
* โโ Why this rebuilds the object instead of passing it through โโโโโโโโโโ
|
|
441
|
+
*
|
|
442
|
+
* Passing a driver's cookie object straight to a serialiser emits **whatever
|
|
443
|
+
* that object happens to have**, which is a different set from what this
|
|
444
|
+
* service has decided to return โ and the field it would most plausibly
|
|
445
|
+
* acquire is the value, since every browser automation library's own cookie
|
|
446
|
+
* type carries one. `CookieSummary`
|
|
447
|
+
* has no value field, so a driver implementing this seam has to drop it; this
|
|
448
|
+
* function is the second lock, and it fails closed: a field not named here
|
|
449
|
+
* does not come out, whatever arrived.
|
|
450
|
+
*
|
|
451
|
+
* **A service handing over cookie values is a credential-export feature
|
|
452
|
+
* whatever else it is called** (ยง3.9), and ยง3.13 refuses the write side for
|
|
453
|
+
* the same reason. The value is *absent*, not truncated and not masked โ
|
|
454
|
+
* masking implies the value was in this process and got hidden, and the
|
|
455
|
+
* design's claim is stronger than that.
|
|
456
|
+
*/
|
|
457
|
+
export function shapeCookieSummary(cookie) {
|
|
458
|
+
return {
|
|
459
|
+
name: cookie.name,
|
|
460
|
+
domain: cookie.domain,
|
|
461
|
+
path: cookie.path,
|
|
462
|
+
expires: cookie.expires,
|
|
463
|
+
httpOnly: cookie.httpOnly,
|
|
464
|
+
secure: cookie.secure,
|
|
465
|
+
sameSite: cookie.sameSite,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
/* โโโโโโโโโโโโโโโโโโโโโโโโโ evaluate (#24) โโโโโโโโโโโโโโโโโโโโโโโโโ */
|
|
469
|
+
/**
|
|
470
|
+
* The largest expression this service will evaluate.
|
|
471
|
+
*
|
|
472
|
+
* ยง7.1 `evaluate.expression_bounded`, and ยง3.10 gives the reasoning in one
|
|
473
|
+
* line: **"a long expression is a program, and a program wants a capability
|
|
474
|
+
* that is not on offer"** (ยง3.13). The bound is not about cost. It is the
|
|
475
|
+
* line between *"compute this measurement in the page"* โ computed styles,
|
|
476
|
+
* contrast ratios, box geometry, spacing, line height, reading width, a few
|
|
477
|
+
* hundred tokens of structured data โ and shipping a body of code into a
|
|
478
|
+
* browser to run.
|
|
479
|
+
*/
|
|
480
|
+
export const MAX_EXPRESSION_BYTES = 4096;
|
|
481
|
+
/**
|
|
482
|
+
* The largest result returned inline, before it is written to a file instead.
|
|
483
|
+
*
|
|
484
|
+
* ยง3.10: "Returns the value inline when it is small, and a path when it is
|
|
485
|
+
* not." The cap exists for the reason the whole read surface returns paths โ
|
|
486
|
+
* a large result entering a conversation is paid for once in money and on
|
|
487
|
+
* every later turn in context โ and the spill is what stops that being a
|
|
488
|
+
* refusal: the caller asked a legitimate question and gets its answer, in the
|
|
489
|
+
* place large answers go.
|
|
490
|
+
*/
|
|
491
|
+
export const MAX_INLINE_RESULT_BYTES = 8192;
|
|
492
|
+
/**
|
|
493
|
+
* Check an expression before it is evaluated.
|
|
494
|
+
*
|
|
495
|
+
* โโ The thing this must never become โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
496
|
+
*
|
|
497
|
+
* **Evaluation happens inside the page, sandboxed by the browser, and it must
|
|
498
|
+
* never be widened to run in the automation server's own process.** That is a
|
|
499
|
+
* different capability wearing a similar name: the server's process reaches
|
|
500
|
+
* its own filesystem, its own network, and every browser and every tab it can
|
|
501
|
+
* see โ past the caller's own lease entirely.
|
|
502
|
+
*
|
|
503
|
+
* ยง3.10 records what the sampling found when a verb like that existed. Of 328
|
|
504
|
+
* measured calls across 53 sessions, **101 calls across 33 sessions did
|
|
505
|
+
* something a page-scoped expression could not**: 16 calls in one session
|
|
506
|
+
* enumerated other callers' tabs and drove one it did not own; 2 read a local
|
|
507
|
+
* environment file and extracted administrative credentials in cleartext; 49
|
|
508
|
+
* made authenticated outbound network requests from the server process, which
|
|
509
|
+
* is not a browser operation at all.
|
|
510
|
+
*
|
|
511
|
+
* **So the refusal is evidence rather than caution, and it is refused by
|
|
512
|
+
* absence.** There is no argument here selecting a target, no option naming a
|
|
513
|
+
* context, and nothing to widen โ the expression goes to the page the tab is,
|
|
514
|
+
* and that is the only place it can go. **Do not reintroduce it by
|
|
515
|
+
* accident**: a `target`, a `context`, a `world` or a `scope` parameter on
|
|
516
|
+
* this path is that capability arriving, whatever it is called.
|
|
517
|
+
*
|
|
518
|
+
* โโ What is deliberately *not* checked โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
519
|
+
*
|
|
520
|
+
* The expression's **contents**. No allowlist, no fixed vocabulary of
|
|
521
|
+
* permitted measurements, no filtering of what comes back, and this is
|
|
522
|
+
* settled rather than pending (ยง3.10): a lease on the signed-in browser
|
|
523
|
+
* already grants the ability to act as the signed-in user โ that is what the
|
|
524
|
+
* lease is *for* โ so an expression reading a page's own storage does
|
|
525
|
+
* something strictly smaller than what the same lease can do by driving the
|
|
526
|
+
* page. A restricted vocabulary would have to be guessed in advance, and
|
|
527
|
+
* every measurement nobody guessed becomes a screenshot instead, pushing
|
|
528
|
+
* callers toward the expensive path. **Refusing the obvious storage
|
|
529
|
+
* accessors was considered and rejected as theatre**: it stops nobody who is
|
|
530
|
+
* trying and teaches a reader that a hole is closed when it is not.
|
|
531
|
+
*
|
|
532
|
+
* The exposure is real and it is handled at the artifact-write layer
|
|
533
|
+
* (`artifact.write_scanned`, ยง7.1) โ one shape-matcher over everything
|
|
534
|
+
* written to disk, on every path that writes, because **a page snapshot can
|
|
535
|
+
* capture a rendered credential with nobody having chosen to evaluate
|
|
536
|
+
* anything** and a control on this path would not have been near it.
|
|
537
|
+
*/
|
|
538
|
+
export function validateExpression(expression) {
|
|
539
|
+
if (typeof expression !== 'string' || expression.trim() === '') {
|
|
540
|
+
throw new PageRefusal('evaluate.expression_bounded', 'An evaluation needs an expression to evaluate in the page.', { maximumBytes: MAX_EXPRESSION_BYTES });
|
|
541
|
+
}
|
|
542
|
+
const bytes = Buffer.byteLength(expression, 'utf8');
|
|
543
|
+
if (bytes > MAX_EXPRESSION_BYTES) {
|
|
544
|
+
throw new PageRefusal('evaluate.expression_bounded', `That expression is ${String(bytes)} bytes and the limit is ${String(MAX_EXPRESSION_BYTES)}. This evaluates an expression in the page โ a measurement, a computed style, some geometry โ rather than running a program.`, { bytes, maximumBytes: MAX_EXPRESSION_BYTES });
|
|
545
|
+
}
|
|
546
|
+
return expression;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Check a capture's two mode arguments before the shutter is pressed.
|
|
550
|
+
*
|
|
551
|
+
* โโ Why this exists as its own guard, and what it was found by โโโโโโโโโโ
|
|
552
|
+
*
|
|
553
|
+
* `capture.exclusive_mode` is a ยง7.1 rule โ *"a selector and a full page are
|
|
554
|
+
* not both asked for"*, refused with *"cannot do both"* โ and it had **no
|
|
555
|
+
* implementation anywhere**. A capture naming both was accepted, and what it
|
|
556
|
+
* then did was decided by whichever argument the pipeline happened to read
|
|
557
|
+
* first. That is the shape ยง7's own header calls out: a rule that never
|
|
558
|
+
* refuses anything protects nothing.
|
|
559
|
+
*
|
|
560
|
+
* It went unnoticed because the only thing asserting it was a service double
|
|
561
|
+
* that implemented the rule itself, so the assertion was about the double.
|
|
562
|
+
* Running the same cases against the real service is what surfaced it.
|
|
563
|
+
*
|
|
564
|
+
* โโ Why it is a refusal rather than a precedence rule โโโโโโโโโโโโโโโโโโโ
|
|
565
|
+
*
|
|
566
|
+
* Picking a winner would be the worse answer and ยง1.9's reasoning is the
|
|
567
|
+
* same one: the two arguments express **different intentions**, not different
|
|
568
|
+
* amounts of one. A caller asking for an element and for the whole page has
|
|
569
|
+
* contradicted itself, and any resolution silently gives it a picture of
|
|
570
|
+
* something it did not ask for โ which it cannot detect, because a capture
|
|
571
|
+
* comes back as a path and some dimensions rather than as pixels it could
|
|
572
|
+
* check.
|
|
573
|
+
*
|
|
574
|
+
* **This is a refusal about a malformed argument and never about cost**,
|
|
575
|
+
* which `capture.never_refused_for_cost` (ยง7.3) requires be kept true: a
|
|
576
|
+
* capture is never refused for being expensive, and nothing here reads a tier,
|
|
577
|
+
* a size or a count.
|
|
578
|
+
*/
|
|
579
|
+
export function validateCaptureMode(options) {
|
|
580
|
+
if (options.fullPage && options.selector !== undefined) {
|
|
581
|
+
throw new PageRefusal('capture.exclusive_mode', 'A capture takes a selector or the whole page, and this call asked for both. They are different pictures rather than different amounts of one, so nothing here can pick for you: ask for the element, or ask for the page.', { fullPage: true, selector: options.selector });
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Decide whether a result comes back inline or goes to a file.
|
|
586
|
+
*
|
|
587
|
+
* **Serialising is what measures it**, rather than any estimate from the
|
|
588
|
+
* value's shape: the cap is about what enters a conversation, and what enters
|
|
589
|
+
* a conversation is the serialised bytes. A check against, say, an array's
|
|
590
|
+
* length would let one enormous string through and spill a long list of small
|
|
591
|
+
* numbers.
|
|
592
|
+
*
|
|
593
|
+
* A value that cannot be serialised at all is refused rather than silently
|
|
594
|
+
* becoming `undefined` โ a caller told its expression returned nothing, when
|
|
595
|
+
* it actually returned something with a cycle in it, debugs the wrong thing.
|
|
596
|
+
*/
|
|
597
|
+
export function disposeEvaluationResult(value) {
|
|
598
|
+
let serialised;
|
|
599
|
+
try {
|
|
600
|
+
// `undefined` has no serialisation, and an expression that genuinely
|
|
601
|
+
// evaluated to it is an ordinary outcome rather than an error, so it is
|
|
602
|
+
// spelled out instead of falling into the catch.
|
|
603
|
+
serialised = value === undefined ? 'null' : JSON.stringify(value);
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
throw new PageRefusal('evaluate.result_serialisable', 'That expression produced a value this service cannot return โ a cycle, or something with no plain representation. Evaluate to plain data: a number, a string, or an object of them.', { reason: error instanceof Error ? error.message : String(error) });
|
|
607
|
+
}
|
|
608
|
+
if (serialised === undefined) {
|
|
609
|
+
// `JSON.stringify` answers `undefined` rather than throwing for a
|
|
610
|
+
// function or a symbol, so the two unserialisable outcomes arrive by
|
|
611
|
+
// different routes and both are refused.
|
|
612
|
+
throw new PageRefusal('evaluate.result_serialisable', 'That expression produced a value this service cannot return. Evaluate to plain data: a number, a string, or an object of them.', {});
|
|
613
|
+
}
|
|
614
|
+
const bytes = Buffer.byteLength(serialised, 'utf8');
|
|
615
|
+
return { serialised, bytes, spill: bytes > MAX_INLINE_RESULT_BYTES };
|
|
616
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** A store of pending seeds, scoped to one process. */
|
|
2
|
+
export function createPendingSeeds() {
|
|
3
|
+
const waiting = new Map();
|
|
4
|
+
return {
|
|
5
|
+
put: (claimId, entries) => {
|
|
6
|
+
if (entries.length === 0) {
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
waiting.set(claimId, entries);
|
|
10
|
+
},
|
|
11
|
+
take: (claimId) => {
|
|
12
|
+
const entries = waiting.get(claimId);
|
|
13
|
+
if (entries === undefined) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
waiting.delete(claimId);
|
|
17
|
+
return entries;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|