browser-commander 0.18.0 → 0.19.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.
@@ -46,6 +46,7 @@
46
46
  "266b9cb95f783cdf": 1,
47
47
  "26bb09996580437c": 1,
48
48
  "26ebb2e251c9a39e": 1,
49
+ "29edfb9e08094991": 1,
49
50
  "2a78ee4b05679c8d": 1,
50
51
  "2b1a759e2cfd21a2": 1,
51
52
  "2c0f8b5c6f28420b": 1,
@@ -55,6 +56,7 @@
55
56
  "2f9e15467cc2f12a": 1,
56
57
  "3101802d3ea65339": 1,
57
58
  "33357ce94e07cc4b": 1,
59
+ "35cf2769645f3234": 1,
58
60
  "37bd4114d0089f49": 1,
59
61
  "389df6fb4b8315a1": 1,
60
62
  "39eaada6d1879f4a": 1,
@@ -97,6 +99,7 @@
97
99
  "608e1dd27138ec6f": 1,
98
100
  "6098544c77ff17a2": 1,
99
101
  "60d0c58a01fc8746": 1,
102
+ "61ae5da6cc003836": 1,
100
103
  "61bcadcd8dc9f63e": 1,
101
104
  "63ff613b1780c67a": 1,
102
105
  "642cc89ed49f8a21": 1,
@@ -110,7 +113,9 @@
110
113
  "6af3c307939ef75e": 1,
111
114
  "6b35b48512756192": 1,
112
115
  "6cd00d6b6a039022": 1,
116
+ "6da3fe1d15b66ae0": 1,
113
117
  "6e2457cd3e1f69e7": 1,
118
+ "6f30e86d1407e88c": 1,
114
119
  "6fa62e2642cc73c3": 1,
115
120
  "7009c5e9bd61dbe8": 1,
116
121
  "709348ea44394b52": 1,
@@ -168,10 +173,12 @@
168
173
  "acc95d694eb2f581": 1,
169
174
  "ad4469f2e8131535": 1,
170
175
  "ad4cfa76daa1b6fc": 1,
176
+ "ad50b48938af8aef": 1,
171
177
  "adae1329361a81a1": 1,
172
178
  "ae477ba64bafba3f": 1,
173
179
  "aee1e5c65f783848": 1,
174
180
  "aee45e8b58c2d87f": 1,
181
+ "af702b20ebd45ef2": 1,
175
182
  "af9d6a2f4eba146e": 1,
176
183
  "b010686af23efa90": 1,
177
184
  "b2a0686231220c8a": 1,
@@ -182,6 +189,7 @@
182
189
  "b8c2d21f2a26f77d": 1,
183
190
  "bb269582b94ed42d": 1,
184
191
  "bb3e6d0f7e7edccd": 1,
192
+ "bc9115be3a462930": 1,
185
193
  "bccf0d5cc57c08df": 1,
186
194
  "bd0ae42c13d5b4f9": 1,
187
195
  "bd6c62a6e1c6a071": 1,
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4401d63: Record traces continuously across navigation and live state, and export them as
8
+ Links Notation.
9
+
10
+ `startTrace({mode: 'continuous'})` now keeps recording when the page navigates,
11
+ changes route or creates a frame: the observers are reinstalled from an init
12
+ script that runs before the page's own code, so nothing that happens during a
13
+ load is lost. What a person changes but the DOM does not show - typing,
14
+ checking, selecting, focus and scroll - is recorded as semantic live-state
15
+ records and applied on replay, child-list records carry the position a node was
16
+ inserted at or removed from, and every record carries stable trace, browser
17
+ context, page, navigation, frame and action identities. `initialCheckpoint` (on
18
+ by default) captures the page before the first action so the first interval has
19
+ a base to replay onto, and the viewer says what it is: a diagnostic replay of
20
+ what was recorded, not a re-execution of the session.
21
+
22
+ `writeTraceLinks(trace, './run.lino', {include})` writes a portable Links
23
+ Notation view of a bundle, and `startTrace({links: {output}})` writes the same
24
+ view incrementally as the run happens. One link per line: one per ordered
25
+ timeline event with its sequence, time, kind, owner ids, actor, action, target
26
+ and outcome; one per checkpoint naming its HTML, state and screenshot members by
27
+ their path inside the bundle; one per control that changed between two
28
+ checkpoints. Dropped and partial records are explicit, binary content is never
29
+ duplicated, and redaction is whatever the bundle already decided. The JSON
30
+ bundle stays authoritative - this is an adapter, not a replacement.
31
+
32
+ A managed download whose CDP completion arrives before the file does is no
33
+ longer reported as a missing file: the staged bytes are waited for, and a
34
+ timeout or a genuinely missing file is still a failure rather than a path that
35
+ does not exist.
36
+
3
37
  ## 0.18.0
4
38
 
5
39
  ### Minor Changes
package/README.md CHANGED
@@ -657,8 +657,56 @@ await writeTraceViewer(bundle, '/tmp/traces/checkout/viewer.html');
657
657
  ```
658
658
 
659
659
  Redaction runs before anything reaches disk, and a run cut short by a closed
660
- page or a size limit still leaves a readable partial trace. The viewer seeks,
661
- diffs and replays the captured DOM with scripts and network disabled.
660
+ page or a size limit still leaves a readable partial trace. In `continuous`
661
+ mode the observers are reinstalled from an init script before the page's own
662
+ code runs, so a trace keeps recording across navigations, SPA route changes and
663
+ same-document updates, and it records what a person cannot see in the DOM -
664
+ typing, checking, selecting, focus and scroll - as semantic live-state records
665
+ (issue #93). `initialCheckpoint` (on by default) captures the page as it was
666
+ before the first action, so the first interval has a base to replay onto. The
667
+ viewer seeks, diffs and replays the captured DOM with scripts and network
668
+ disabled; it is a diagnostic replay of what was recorded, not a pixel-accurate
669
+ re-execution of the session.
670
+
671
+ #### Links Notation Export
672
+
673
+ The bundle stays authoritative, and `writeTraceLinks()` writes a second,
674
+ portable view of it for tools that keep semantic, actor-aware histories - audit
675
+ graphs, agent memory, cross-run diffing (issue #94):
676
+
677
+ ```javascript
678
+ import { readTrace, writeTraceLinks } from 'browser-commander';
679
+
680
+ const trace = await readTrace('./run.bc-trace');
681
+ await writeTraceLinks(trace, './run.lino', {
682
+ include: ['timeline', 'checkpoints', 'control-diffs'],
683
+ });
684
+ ```
685
+
686
+ A long-lived agent can have the same export written as the run happens, so a
687
+ process that is killed still leaves everything it had recorded:
688
+
689
+ ```javascript
690
+ const trace = await commander.startTrace({
691
+ output: './run.bc-trace',
692
+ links: { output: './run.lino' },
693
+ });
694
+ // trace.links === './run.lino'
695
+ ```
696
+
697
+ One link per line: one `(timeline: ...)` link per ordered event carrying its
698
+ sequence, time, kind, owner ids, actor, action, target and outcome; one
699
+ `(checkpoint: ...)` link naming its HTML, state and screenshot members by their
700
+ path inside the bundle; one `(control-diff: ...)` link per control that changed
701
+ between two checkpoints, with `path`, `before`, `after` and who changed it.
702
+ Dropped and partial records are written as such rather than left out, no binary
703
+ content is ever copied into the export, and redaction is whatever the bundle
704
+ already decided - the export only reads what was written there. The
705
+ representation is pinned by a golden test against
706
+ [`links-notation`](https://www.npmjs.com/package/links-notation) 0.20, whose
707
+ Python and Rust implementations read the same file;
708
+ `experiments/trace-links-export.mjs` records a real run and parses its export
709
+ from Python to prove it.
662
710
 
663
711
  ### Truthful Click Results
664
712
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "browser-commander",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -68,6 +68,7 @@
68
68
  },
69
69
  "dependencies": {
70
70
  "better-sqlite3": "^12.11.1",
71
+ "links-notation": "^0.20.0",
71
72
  "log-lazy": "^1.0.4",
72
73
  "test-anywhere": "^0.9.1"
73
74
  },
@@ -85,6 +85,9 @@ function playwrightDownloadContext({ browser, page }) {
85
85
  * @param {string} [options.conflict='rename'] - 'rename', 'overwrite' or 'error'
86
86
  * @param {Function} [options.filename] - Naming callback for every download
87
87
  * @param {Function} [options.validate] - Validation for every download
88
+ * @param {number} [options.stagingTimeout] - How long a completed download has
89
+ * to become readable on disk before it is reported as failed (issue #92)
90
+ * @param {number} [options.stagingPollInterval] - Milliseconds between readings
88
91
  * @param {Object} [options.log] - Logger
89
92
  * @returns {Promise<Object>} Download manager
90
93
  */
@@ -99,6 +102,8 @@ export async function createDownloadManager(options = {}) {
99
102
  conflict = DOWNLOAD_CONFLICT.RENAME,
100
103
  filename,
101
104
  validate,
105
+ stagingTimeout,
106
+ stagingPollInterval,
102
107
  log,
103
108
  } = options;
104
109
 
@@ -194,20 +199,30 @@ export async function createDownloadManager(options = {}) {
194
199
  },
195
200
 
196
201
  /**
197
- * Save a download whose bytes the engine has finished writing.
202
+ * Register work a download is not finished without.
203
+ *
204
+ * Engine events are fire-and-forget, so anything a source starts in one -
205
+ * saving the bytes, or waiting for them to land on disk at all - has to be
206
+ * registered here: otherwise `dispose()` could return while a download is
207
+ * still in flight and leave a `.partial` file as its only trace.
198
208
  *
199
- * Engine events are fire-and-forget, so the save is registered as in-flight
200
- * work: otherwise `dispose()` could return while bytes are still being
201
- * written and leave a `.partial` file as the only trace of the download.
209
+ * @param {Promise} work - Work to wait for in `idle()` and `dispose()`
210
+ * @returns {Promise<void>} The same work, tracked
211
+ */
212
+ track(work) {
213
+ inFlight.add(work);
214
+ return work.finally(() => inFlight.delete(work));
215
+ },
216
+
217
+ /**
218
+ * Save a download whose bytes the engine has finished writing.
202
219
  *
203
220
  * @param {Object} artifact - Artifact record
204
221
  * @param {Object} source - `{path}` or `{stream}` to read the bytes from
205
222
  * @returns {Promise<void>} Resolves once the artifact has been published
206
223
  */
207
224
  finished(artifact, source) {
208
- const work = save(artifact, source);
209
- inFlight.add(work);
210
- return work.finally(() => inFlight.delete(work));
225
+ return sink.track(save(artifact, source));
211
226
  },
212
227
 
213
228
  /**
@@ -293,7 +308,15 @@ export async function createDownloadManager(options = {}) {
293
308
  try {
294
309
  cdpSession = await openBrowserCdpSession({ engine, browser, page });
295
310
  if (cdpSession) {
296
- attached.push(await attachCdpSource({ session: cdpSession, root, sink }));
311
+ attached.push(
312
+ await attachCdpSource({
313
+ session: cdpSession,
314
+ root,
315
+ sink,
316
+ stagingTimeout,
317
+ stagingPollInterval,
318
+ })
319
+ );
297
320
  }
298
321
  } catch (error) {
299
322
  cdpSession = undefined;
@@ -11,6 +11,12 @@ import path from 'node:path';
11
11
  import fs from 'node:fs/promises';
12
12
 
13
13
  import { ARTIFACT_DIRECTORY_MODE } from './destination.js';
14
+ import {
15
+ DEFAULT_STAGING_POLL_INTERVAL,
16
+ DEFAULT_STAGING_TIMEOUT,
17
+ describeStagingTimeout,
18
+ waitForStagedFile,
19
+ } from './staging.js';
14
20
 
15
21
  /** Why a download ended without a file. */
16
22
  export const DOWNLOAD_FAILURE = Object.freeze({
@@ -85,10 +91,18 @@ export function attachPlaywrightSource({ context, sink }) {
85
91
  * @param {Object} options - Source options
86
92
  * @param {Object} options.session - CDP session with Browser domain access
87
93
  * @param {string} options.root - Managed download directory
88
- * @param {Object} options.sink - `{started, finished, failed}` callbacks
94
+ * @param {Object} options.sink - `{started, finished, failed, track}` callbacks
95
+ * @param {number} [options.stagingTimeout] - Budget for the bytes to land on disk
96
+ * @param {number} [options.stagingPollInterval] - Milliseconds between readings
89
97
  * @returns {Promise<Object>} Handle with `detach()` and `stagingDirectory`
90
98
  */
91
- export async function attachCdpSource({ session, root, sink }) {
99
+ export async function attachCdpSource({
100
+ session,
101
+ root,
102
+ sink,
103
+ stagingTimeout,
104
+ stagingPollInterval,
105
+ }) {
92
106
  const stagingDirectory = path.join(root, STAGING_DIRECTORY);
93
107
  await fs.mkdir(stagingDirectory, {
94
108
  recursive: true,
@@ -117,6 +131,43 @@ export async function attachCdpSource({ session, root, sink }) {
117
131
  );
118
132
  };
119
133
 
134
+ /**
135
+ * Publish a completed download once its staged bytes are readable.
136
+ *
137
+ * `state: "completed"` is the browser's word, not the filesystem's. Chromium
138
+ * writes `<guid>.crdownload` and renames it, so opening `<guid>` the instant
139
+ * the event arrives could - and did, in issue #92 - fail with `ENOENT` on a
140
+ * download that had in fact succeeded. Waiting for the file turns that race
141
+ * into either a saved artifact or a named failure, never a lost one.
142
+ *
143
+ * @param {Object} record - Artifact record
144
+ * @param {string} stagedPath - Path Chromium staged the bytes under
145
+ * @returns {Promise<void>}
146
+ */
147
+ const publishWhenStaged = async (record, stagedPath) => {
148
+ const budget = stagingTimeout ?? DEFAULT_STAGING_TIMEOUT;
149
+ const settled = await waitForStagedFile({
150
+ path: stagedPath,
151
+ timeout: budget,
152
+ interval: stagingPollInterval ?? DEFAULT_STAGING_POLL_INTERVAL,
153
+ });
154
+
155
+ if (!settled.ready) {
156
+ sink.failed(
157
+ record,
158
+ DOWNLOAD_FAILURE.FAILED,
159
+ describeStagingTimeout({
160
+ path: stagedPath,
161
+ timeout: budget,
162
+ reason: settled.reason,
163
+ })
164
+ );
165
+ return;
166
+ }
167
+
168
+ await sink.finished(record, { path: stagedPath, removeSource: true });
169
+ };
170
+
120
171
  const onProgress = ({ guid, state }) => {
121
172
  const record = byGuid.get(guid);
122
173
  if (!record || state === 'inProgress') {
@@ -125,10 +176,9 @@ export async function attachCdpSource({ session, root, sink }) {
125
176
 
126
177
  byGuid.delete(guid);
127
178
  if (state === 'completed') {
128
- sink.finished(record, {
129
- path: path.join(stagingDirectory, guid),
130
- removeSource: true,
131
- });
179
+ // Tracked before it is awaited: the wait is part of the download's
180
+ // lifecycle, so `idle()` and `dispose()` must not return while it runs.
181
+ sink.track(publishWhenStaged(record, path.join(stagingDirectory, guid)));
132
182
  return;
133
183
  }
134
184
  sink.failed(
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Waiting for a staged download to actually be on disk (issue #92).
3
+ *
4
+ * `Browser.downloadProgress` with `state: "completed"` is the browser saying it
5
+ * has stopped downloading, not the filesystem saying the bytes are there under
6
+ * the name we are about to open. Chromium writes into `<guid>.crdownload` and
7
+ * renames it, and the event can reach us before that rename is visible - which
8
+ * is how a completed download became
9
+ * `ENOENT: no such file or directory, open '<root>/.browser-commander-staging/<guid>'`.
10
+ *
11
+ * This module turns "the browser said completed" into "the file is readable and
12
+ * has stopped growing", within a bounded budget. Running out of that budget is
13
+ * an explicit failure, never a silent success: issue #88's rule that "a
14
+ * download UI entry is not completion evidence" is exactly the rule being
15
+ * applied one level lower down.
16
+ */
17
+
18
+ import fs from 'node:fs/promises';
19
+ import { setTimeout as delay } from 'node:timers/promises';
20
+
21
+ import { createDeadline } from '../core/readiness.js';
22
+
23
+ /** How long to wait for a completed download's bytes to appear, by default. */
24
+ export const DEFAULT_STAGING_TIMEOUT = 10000;
25
+
26
+ /** How often the staged file is looked at while waiting. */
27
+ export const DEFAULT_STAGING_POLL_INTERVAL = 25;
28
+
29
+ /** Suffixes Chromium uses for a file it is still writing. */
30
+ export const STAGING_IN_PROGRESS_SUFFIXES = Object.freeze([
31
+ '.crdownload',
32
+ '.partial',
33
+ ]);
34
+
35
+ /**
36
+ * Look at a staged file once.
37
+ *
38
+ * @param {string} filePath - Path the engine staged the download under
39
+ * @param {string[]} suffixes - Suffixes that mark a file still being written
40
+ * @returns {Promise<{size: number|null, reason: string|null}>} What was seen
41
+ */
42
+ async function observeStagedFile(filePath, suffixes) {
43
+ for (const suffix of suffixes) {
44
+ try {
45
+ await fs.stat(`${filePath}${suffix}`);
46
+ return { size: null, reason: `a ${suffix} file is still being written` };
47
+ } catch {
48
+ // Absent is what we want: the rename to the final staged name has
49
+ // already happened, or Chromium never used this suffix at all.
50
+ }
51
+ }
52
+
53
+ let handle;
54
+ try {
55
+ // Opening rather than stat'ing is deliberate: "readable" is the property
56
+ // `saveDownload()` needs, and a name that resolves is not the same thing
57
+ // as a file this process is allowed to read.
58
+ handle = await fs.open(filePath, 'r');
59
+ } catch (error) {
60
+ return {
61
+ size: null,
62
+ reason:
63
+ error.code === 'ENOENT'
64
+ ? 'the file has not appeared yet'
65
+ : `the file could not be opened: ${error.message}`,
66
+ };
67
+ }
68
+
69
+ try {
70
+ const stats = await handle.stat();
71
+ return { size: stats.size, reason: null };
72
+ } finally {
73
+ await handle.close();
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Wait until a staged download is readable and has stopped growing.
79
+ *
80
+ * Stability is decided by two consecutive readings of the same size, the same
81
+ * evidence the Selenium directory watcher uses: a file that merely exists is
82
+ * not evidence that its last byte was written.
83
+ *
84
+ * @param {Object} options - Wait options
85
+ * @param {string} options.path - Path the engine staged the download under
86
+ * @param {number} [options.timeout] - Total budget in milliseconds
87
+ * @param {number} [options.interval] - Milliseconds between readings
88
+ * @param {string[]} [options.inProgressSuffixes] - Suffixes of a partial file
89
+ * @returns {Promise<{ready: boolean, bytes: number|null, reason: string|null, waitedMs: number}>} Outcome
90
+ */
91
+ export async function waitForStagedFile(options = {}) {
92
+ const {
93
+ path: filePath,
94
+ timeout = DEFAULT_STAGING_TIMEOUT,
95
+ interval = DEFAULT_STAGING_POLL_INTERVAL,
96
+ inProgressSuffixes = STAGING_IN_PROGRESS_SUFFIXES,
97
+ } = options;
98
+
99
+ const deadline = createDeadline({ timeout });
100
+ let previousSize;
101
+ // Always set by the reading below before the deadline can be checked.
102
+ let reason;
103
+
104
+ for (;;) {
105
+ const seen = await observeStagedFile(filePath, inProgressSuffixes);
106
+ if (seen.reason !== null) {
107
+ reason = seen.reason;
108
+ // A file that vanished has to prove itself stable again from scratch.
109
+ previousSize = undefined;
110
+ } else if (seen.size === previousSize) {
111
+ return {
112
+ ready: true,
113
+ bytes: seen.size,
114
+ reason: null,
115
+ waitedMs: deadline.elapsedMs(),
116
+ };
117
+ } else {
118
+ previousSize = seen.size;
119
+ reason = `the file was still growing at ${seen.size} bytes`;
120
+ }
121
+
122
+ const remaining = deadline.remainingMs();
123
+ if (remaining === 0) {
124
+ return {
125
+ ready: false,
126
+ bytes: null,
127
+ reason,
128
+ waitedMs: deadline.elapsedMs(),
129
+ };
130
+ }
131
+ await delay(Math.min(interval, remaining));
132
+ }
133
+ }
134
+
135
+ /**
136
+ * The failure text a staged download that never materialized is reported under.
137
+ *
138
+ * @param {Object} options - Failure details
139
+ * @param {string} options.path - Path the engine staged the download under
140
+ * @param {number} options.timeout - Budget that was exhausted
141
+ * @param {string} options.reason - What the last reading saw
142
+ * @returns {string} Message kept verbatim on the artifact
143
+ */
144
+ export function describeStagingTimeout({ path: filePath, timeout, reason }) {
145
+ return `the browser reported the download as completed, but ${filePath} was not readable within ${timeout}ms: ${reason}`;
146
+ }
package/src/exports.js CHANGED
@@ -237,6 +237,20 @@ export {
237
237
  } from './traces/schema.js';
238
238
  export { diffControlState, parseNdjson, readTrace } from './traces/reader.js';
239
239
  export { renderTraceViewer, writeTraceViewer } from './traces/viewer.js';
240
+ // A Links Notation view of the same bundle, for consumers that keep semantic,
241
+ // actor-aware histories of a run (issue #94).
242
+ export {
243
+ decodeLinkText,
244
+ encodeLinkText,
245
+ formatTraceLinks,
246
+ traceLinks,
247
+ TRACE_LINK_IDS,
248
+ TRACE_LINK_OUTCOME,
249
+ TRACE_LINKS_FILE,
250
+ TRACE_LINKS_SECTIONS,
251
+ TRACE_LINKS_VERSION,
252
+ writeTraceLinks,
253
+ } from './traces/links.js';
240
254
  export {
241
255
  DEFAULT_REDACT_ATTRIBUTES,
242
256
  DEFAULT_REDACT_SELECTORS,
@@ -32,7 +32,7 @@ export const TRACE_DIRECTORY_MODE = 0o700;
32
32
  /**
33
33
  * Open a bundle for writing.
34
34
  *
35
- * @param {Object} options - `{output, limits, strict, now}`
35
+ * @param {Object} options - `{output, limits, strict, now, onEvent}`
36
36
  * @returns {Promise<Object>} The bundle writer
37
37
  */
38
38
  export async function openTraceBundle(options = {}) {
@@ -41,6 +41,7 @@ export async function openTraceBundle(options = {}) {
41
41
  strict = false,
42
42
  limits = {},
43
43
  now = () => Date.now(),
44
+ onEvent = null,
44
45
  } = options;
45
46
 
46
47
  if (typeof output !== 'string' || output === '') {
@@ -138,6 +139,20 @@ export async function openTraceBundle(options = {}) {
138
139
  await pending;
139
140
  written += bytes;
140
141
  counts.events += 1;
142
+ if (onEvent) {
143
+ try {
144
+ await onEvent(record);
145
+ } catch (error) {
146
+ // A side export that fails is a gap in that export, not in the
147
+ // bundle. Reporting it through `drop` would write an event and call
148
+ // this hook again, so it is noted where a caller can still see it.
149
+ problems.push({
150
+ reason: TRACE_DROP_REASON.WRITE_FAILED,
151
+ member: 'links',
152
+ detail: error.message,
153
+ });
154
+ }
155
+ }
141
156
  return record;
142
157
  } catch (error) {
143
158
  if (retry) {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * A budget for one capture (issue #87).
3
+ *
4
+ * A page that stopped answering must cost the trace one record, not the run.
5
+ */
6
+
7
+ /**
8
+ * Run a capture with its own deadline.
9
+ *
10
+ * @param {Promise} work - The capture
11
+ * @param {number} timeoutMs - Budget in milliseconds; falsy means no deadline
12
+ * @param {string} what - Name used in the timeout message
13
+ * @returns {Promise<*>} The capture's result
14
+ */
15
+ export async function withDeadline(work, timeoutMs, what) {
16
+ if (!timeoutMs) {
17
+ return work;
18
+ }
19
+ let timer;
20
+ try {
21
+ return await Promise.race([
22
+ work,
23
+ new Promise((_resolve, reject) => {
24
+ timer = setTimeout(
25
+ () => reject(new Error(`${what} timed out after ${timeoutMs}ms`)),
26
+ timeoutMs
27
+ );
28
+ }),
29
+ ]);
30
+ } finally {
31
+ clearTimeout(timer);
32
+ }
33
+ }