@executablemd/runtime 0.6.0 → 0.8.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/esm/apis.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Runtime Context APIs — platform I/O operations with pluggable middleware.
3
3
  *
4
- * Five domain-specific context APIs built on `@effectionx/context-api`.
4
+ * Five host-backed domain APIs plus the provider-neutral Service Api, built on
5
+ * `@effectionx/context-api`.
5
6
  * Each API provides default Node.js implementations. Use `.around()` to
6
7
  * install middleware (mocking, instrumentation, sandboxing) scoped to the
7
8
  * current Effection scope.
@@ -25,17 +26,29 @@
25
26
  * });
26
27
  * ```
27
28
  *
28
- * ## Why four separate APIs?
29
+ * ## Why separate APIs?
29
30
  *
30
31
  * - **Process** — subprocess lifecycle has its own cancellation semantics
31
32
  * (killing processes on scope teardown). Middleware targets exec only.
32
- * - **Fs** — readTextFile, stat, and glob form a cohesive file-IO surface
33
- * used together for component resolution and replay guards.
33
+ * - **Fs** — the low-level host file surface: reading, writing, and inspecting
34
+ * paths the engine itself resolves, for component lookup, replay guards, and
35
+ * the root document. It is the host adapter's own dependency, not the
36
+ * boundary a document's paths cross.
37
+ * - **Files** — document filesystem access, in whole semantic operations
38
+ * (`files.ts`). `<File>`, `<Glob>`, and `<TempDir>` speak only this Api, so
39
+ * the same document means the same thing whether its paths resolve in the
40
+ * caller's filesystem or in a run-owned logical one. Its terminal handler
41
+ * throws: an uninstalled provider must not silently reach the host.
34
42
  * - **Fetch** — HTTP has distinct timeout/body/abort semantics. Merging
35
43
  * with Fs or Process would blur cancellation boundaries.
36
- * - **Env** — synchronous host metadata (env vars, platform). Kept as a
37
- * context-api despite being sync because tests use `.around()` to mock
38
- * platform/env for deterministic replay testing.
44
+ * - **Env** — the host itself: metadata (env vars, platform) plus the two
45
+ * capabilities only the entrypoint can supply, `command` (how to re-invoke
46
+ * this xmd) and `compile` (how this host loads a generated module). Tests
47
+ * use `.around()` to mock platform/env for deterministic replay; an
48
+ * entrypoint installs its `command` and `compile` with `{ at: "min" }` so
49
+ * ordinary middleware can wrap them.
50
+ * - **Service** — scoped service attachment. Its terminal handler requires an
51
+ * explicit host provider and never detects or imports a runtime.
39
52
  *
40
53
  * ## Middleware
41
54
  *
@@ -54,16 +67,100 @@
54
67
  * ## Test stubs
55
68
  *
56
69
  * Common stubs are provided by `@executablemd/runtime/test`:
57
- * `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)`.
70
+ * `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)`,
71
+ * `useStubService(endpoint)`.
58
72
  */
59
73
  import { createApi } from "@effectionx/context-api";
60
- import { relative, sep } from "node:path";
74
+ import { join } from "node:path";
61
75
  import process from "node:process";
76
+ import { realpath as fsRealpath, rename as fsRename } from "node:fs/promises";
62
77
  import { fetch as effectionFetch } from "@effectionx/fetch";
63
- import { readTextFile as fsReadTextFile, stat as fsStat, globToRegExp, walk } from "@effectionx/fs";
64
- import { exec as processExec } from "@effectionx/process";
65
- import { each, race, sleep } from "effection";
66
- import { timeout as contextualTimeout } from "./config.js";
78
+ import { ensureDir as fsEnsureDir, FsApi, globToRegExp, readTextFile as fsReadTextFile, rm as fsRm, stat as fsStat, writeTextFile as fsWriteTextFile, } from "@effectionx/fs";
79
+ import { exec as processExec, Stdio } from "@effectionx/process";
80
+ import { race, scoped, sleep, until } from "effection";
81
+ import { timeoutFetch as contextualFetchTimeout } from "./config.js";
82
+ import { Files } from "./files.js";
83
+ import { Service } from "./service.js";
84
+ /**
85
+ * The `errno` string a failed filesystem call carries, when it carries one.
86
+ *
87
+ * Read rather than asserted: `catch` gives back `unknown`, and what arrives
88
+ * there is only conventionally an `ErrnoException`. Narrowing says what is
89
+ * actually known about the value instead of claiming a shape it may not have.
90
+ */
91
+ function errorCode(error) {
92
+ if (typeof error !== "object" || error === null || !("code" in error)) {
93
+ return undefined;
94
+ }
95
+ const { code } = error;
96
+ return typeof code === "string" ? code : undefined;
97
+ }
98
+ /**
99
+ * Record what this call receives, from before the child exists.
100
+ *
101
+ * Installed on the stdio chain before acquisition, so a chunk forwarded while
102
+ * the child is being started is received like any other. What arrives here is
103
+ * what enclosing middleware forwarded: a host that transforms, redacts,
104
+ * redirects, or consumes output upstream of this call is trusted preprocessing,
105
+ * and its result is what a caller is told the command produced.
106
+ *
107
+ * What this does not promise is the tail. The record is read when the `Process`
108
+ * operation settles, and that can happen before the pumps have finished, so
109
+ * this claims no pump-complete delivery (effectionx #244).
110
+ */
111
+ function* retaining() {
112
+ let stdout = "";
113
+ let stderr = "";
114
+ // One decoder per channel: a code point split across chunks belongs to the
115
+ // channel that split it, and sharing decoder state would let one channel
116
+ // corrupt the other's partial character.
117
+ const fromStdout = new TextDecoder();
118
+ const fromStderr = new TextDecoder();
119
+ yield* Stdio.around({
120
+ *stdout([bytes], next) {
121
+ stdout += fromStdout.decode(bytes, { stream: true });
122
+ return yield* next(bytes);
123
+ },
124
+ *stderr([bytes], next) {
125
+ stderr += fromStderr.decode(bytes, { stream: true });
126
+ return yield* next(bytes);
127
+ },
128
+ });
129
+ // Flushed once, when the caller reads — that is, when the `Process` operation
130
+ // settles. `Process.join()` may settle before the pumps and their middleware
131
+ // finish, so a tail written as they settle may never have reached the
132
+ // handlers above; effectionx #244 owns that.
133
+ return {
134
+ stdout: () => stdout + fromStdout.decode(),
135
+ stderr: () => stderr + fromStderr.decode(),
136
+ };
137
+ }
138
+ /**
139
+ * Run one child to completion and report what the caller asked to keep.
140
+ *
141
+ * Forwarding and retention are separate paths through the same process: the
142
+ * `Stdio` chain displays every chunk whatever this decides, and a transient run
143
+ * subscribes to nothing, so a command that writes a gigabyte costs a gigabyte
144
+ * of nothing.
145
+ */
146
+ function* run(options) {
147
+ return yield* scoped(function* () {
148
+ // Before acquisition, so a chunk written while the child is being started
149
+ // is retained rather than raced for.
150
+ const kept = options.retain ? yield* retaining() : undefined;
151
+ const child = yield* processExec(options.command, {
152
+ arguments: options.args,
153
+ cwd: options.cwd,
154
+ env: options.env,
155
+ });
156
+ const status = yield* child.join();
157
+ return {
158
+ exitCode: status.code ?? 1,
159
+ stdout: kept?.stdout(),
160
+ stderr: kept?.stderr(),
161
+ };
162
+ });
163
+ }
67
164
  function* withTimeout(label, timeout, operation) {
68
165
  if (timeout === undefined) {
69
166
  return yield* operation;
@@ -79,6 +176,97 @@ function* withTimeout(label, timeout, operation) {
79
176
  })(),
80
177
  ]));
81
178
  }
179
+ /**
180
+ * A glob pattern as a matcher for relative POSIX paths.
181
+ *
182
+ * A pattern that cannot be compiled throws here — an unterminated character
183
+ * class is a `SyntaxError` from `RegExp` — so it surfaces from `glob` itself
184
+ * rather than silently matching nothing.
185
+ */
186
+ function toRegExp(pattern) {
187
+ return globToRegExp(pattern, { extended: true, globstar: true });
188
+ }
189
+ /**
190
+ * A matcher for directories whose entire subtree an exclusion covers, when the
191
+ * pattern is one that can prove it.
192
+ *
193
+ * Skipping a subtree is only sound if *every* path beneath it is excluded, and
194
+ * matching the directory tells us nothing of the sort: `foo` does not match
195
+ * `foo/deep/keep.md`, and `foo/*` matches `foo/direct.md` but stops at the next
196
+ * separator. Testing the directory — or the directory with a trailing separator
197
+ * — as a proxy for "all descendants" prunes more than the pattern selects.
198
+ *
199
+ * A trailing `/**` is the form that does prove it. It compiles to
200
+ * `(?:[^/]*(?:/|$))*`, which matches any sequence of segments, so once the part
201
+ * before it matches a directory the pattern matches every path under that
202
+ * directory at any depth. `**` alone covers the whole tree the same way.
203
+ *
204
+ * Anything else returns `undefined` and the subtree is walked, with its files
205
+ * filtered one at a time. That is the conservative direction: descending a
206
+ * subtree whose files are all excluded costs reads, while skipping one that
207
+ * holds a match loses the match.
208
+ */
209
+ const SUBTREE = "/**";
210
+ function isRegExp(value) {
211
+ return value !== undefined;
212
+ }
213
+ function pruneMatcher(pattern) {
214
+ if (pattern === "**") {
215
+ return toRegExp("**");
216
+ }
217
+ if (!pattern.endsWith(SUBTREE)) {
218
+ return undefined;
219
+ }
220
+ return toRegExp(pattern.slice(0, -SUBTREE.length));
221
+ }
222
+ /**
223
+ * Collect matches under `directory`, whose path relative to the glob root is
224
+ * `prefix`.
225
+ *
226
+ * A plain recursive generator rather than `@effectionx/fs`'s `walk()`, whose
227
+ * producer runs in a spawned task: a `readdir` that fails there tears down the
228
+ * surrounding scope instead of throwing at the call site, so no caller can
229
+ * report it. Recursing here makes a failure `glob`'s own, and makes every
230
+ * directory read a cancellation point.
231
+ *
232
+ * Paths are assembled from entry names with `/`, so what patterns match is the
233
+ * relative POSIX path on every platform.
234
+ *
235
+ * Exclusion is decided per **candidate**: a file or symlink whose own path an
236
+ * exclude pattern matches is not reported, which is what makes exclusions win.
237
+ * A directory is not a candidate — it is never reported — so its own path is
238
+ * not tested against exclusions at all. The only question a directory raises is
239
+ * whether walking it can still produce something, and that is `pruneMatcher`'s.
240
+ */
241
+ function* descend(directory, prefix, walk) {
242
+ for (const entry of yield* FsApi.operations.readdirDirents(directory)) {
243
+ const path = prefix === "" ? entry.name : `${prefix}/${entry.name}`;
244
+ // Before the exclusion test, because a directory is not a candidate: an
245
+ // exclusion matching its path says nothing about the files beneath it.
246
+ // A symlink to a directory takes the branches below instead — `isDirectory`
247
+ // is false for one — so traversal never follows it.
248
+ if (entry.isDirectory()) {
249
+ if (!walk.prune.some((re) => re.test(path))) {
250
+ yield* descend(join(directory, entry.name), path, walk);
251
+ }
252
+ continue;
253
+ }
254
+ if (walk.exclude.some((re) => re.test(path))) {
255
+ continue;
256
+ }
257
+ // A symlink is reported by its own path and never followed, so traversal
258
+ // stays under the root and cannot cycle.
259
+ if (entry.isSymbolicLink()) {
260
+ if (walk.include.some((re) => re.test(path))) {
261
+ walk.matched.push({ path, isFile: false });
262
+ }
263
+ continue;
264
+ }
265
+ if (entry.isFile() && walk.include.some((re) => re.test(path))) {
266
+ walk.matched.push({ path, isFile: true });
267
+ }
268
+ }
269
+ }
82
270
  export const API = {
83
271
  /**
84
272
  * Subprocess execution.
@@ -88,22 +276,14 @@ export const API = {
88
276
  */
89
277
  Process: createApi("runtime.process", {
90
278
  *exec(options) {
91
- const { command, cwd, env, timeout } = options;
279
+ const { command, cwd, env, timeout, retain = true } = options;
92
280
  const [cmd, ...args] = command;
93
281
  if (!cmd) {
94
282
  throw new Error("exec: command array must not be empty");
95
283
  }
96
- const effectiveTimeout = timeout ?? (yield* contextualTimeout);
97
- const result = yield* withTimeout(`exec(${cmd})`, effectiveTimeout, processExec(cmd, {
98
- arguments: args,
99
- cwd,
100
- env,
101
- }).join());
102
- return {
103
- exitCode: result.code ?? 1,
104
- stdout: result.stdout,
105
- stderr: result.stderr,
106
- };
284
+ // No contextual fallback: what bounds an exec block is resolved where the
285
+ // block is, and arrives here as this option (spec §Config).
286
+ return yield* withTimeout(`exec(${cmd})`, timeout, run({ command: cmd, args, cwd, env, retain }));
107
287
  },
108
288
  }),
109
289
  /**
@@ -125,7 +305,7 @@ export const API = {
125
305
  };
126
306
  }
127
307
  catch (err) {
128
- if (err.code === "ENOENT") {
308
+ if (errorCode(err) === "ENOENT") {
129
309
  return { exists: false, isFile: false, isDirectory: false };
130
310
  }
131
311
  throw err;
@@ -133,27 +313,38 @@ export const API = {
133
313
  },
134
314
  *glob(options) {
135
315
  const { patterns, root, exclude = [] } = options;
136
- const results = [];
137
- // Convert include/exclude patterns to RegExp for matching
138
- // against relative paths from root
139
- const includeRegexes = patterns.map((p) => globToRegExp(p, { extended: true, globstar: true }));
140
- const excludeRegexes = exclude.map((e) => globToRegExp(e, { extended: true, globstar: true }));
141
- // Walk the directory tree and match relative paths
142
- const stream = walk(root, {
143
- includeFiles: true,
144
- includeDirs: false,
145
- skip: excludeRegexes.length > 0 ? excludeRegexes : undefined,
316
+ const matched = [];
317
+ yield* descend(root, "", {
318
+ include: patterns.map(toRegExp),
319
+ exclude: exclude.map(toRegExp),
320
+ prune: exclude.map(pruneMatcher).filter(isRegExp),
321
+ matched,
146
322
  });
147
- for (const entry of yield* each(stream)) {
148
- // Normalize to POSIX separators for consistent matching across platforms
149
- const relPath = relative(root, entry.path).split(sep).join("/");
150
- const matches = includeRegexes.some((re) => re.test(relPath));
151
- if (matches) {
152
- results.push({ path: relPath, isFile: entry.isFile });
323
+ return matched;
324
+ },
325
+ *writeTextFile(path, content) {
326
+ yield* fsWriteTextFile(path, content);
327
+ },
328
+ *ensureDir(path) {
329
+ yield* fsEnsureDir(path);
330
+ },
331
+ *rename(from, to) {
332
+ yield* until(fsRename(from, to));
333
+ },
334
+ *remove(path, options) {
335
+ yield* fsRm(path, options);
336
+ },
337
+ *realpath(path) {
338
+ try {
339
+ return yield* until(fsRealpath(path));
340
+ }
341
+ catch (err) {
342
+ const code = errorCode(err);
343
+ if (code === "ENOENT" || code === "ENOTDIR") {
344
+ return undefined;
153
345
  }
154
- yield* each.next();
346
+ throw err;
155
347
  }
156
- return results;
157
348
  },
158
349
  }),
159
350
  /**
@@ -164,7 +355,7 @@ export const API = {
164
355
  */
165
356
  Fetch: createApi("runtime.fetch", {
166
357
  *fetch(input, init) {
167
- const timeout = init?.timeout ?? (yield* contextualTimeout);
358
+ const timeout = init?.timeout ?? (yield* contextualFetchTimeout);
168
359
  const response = yield* withTimeout(`fetch(${input})`, timeout, effectionFetch(input, {
169
360
  method: init?.method,
170
361
  headers: init?.headers,
@@ -201,27 +392,66 @@ export const API = {
201
392
  arch: process.arch,
202
393
  };
203
394
  },
204
- }),
205
- /**
206
- * Block compilation.
207
- *
208
- * Default handler throws platform-specific middleware must be
209
- * installed via `yield* API.Compiler.around(...)` before use.
210
- * See `packages/core/src/deno-compiler.ts` for the Deno implementation.
211
- */
212
- Compiler: createApi("runtime.compiler", {
395
+ /**
396
+ * The default cannot be derived. `process.execPath` names the executable
397
+ * but not how it was launched — `deno run --allow-all <entry>` and
398
+ * `node <entry>` are not recoverable from "deno" or "node", and a
399
+ * compiled binary takes no entry script at all. Only the entrypoint that
400
+ * started this process knows.
401
+ */
402
+ // deno-lint-ignore require-yield
403
+ *command(_args) {
404
+ throw new Error("xmd command not installed — a runtime-named entrypoint must install it via API.Env.around()");
405
+ },
406
+ /**
407
+ * Compiling an eval block means loading a module the way this host
408
+ * loads modules, so the implementation belongs with the entrypoint that
409
+ * knows the host — beside `command`, installed in the same call.
410
+ */
213
411
  // deno-lint-ignore require-yield
214
412
  *compile(_source, _options) {
215
- throw new Error("compiler not installed — install platform-specific middleware via API.Compiler.around()");
413
+ throw new Error("compiler not installed — install platform-specific middleware via API.Env.around()");
216
414
  },
217
415
  }),
416
+ Files,
417
+ Service,
218
418
  };
219
- export const exec = API.Process.operations.exec;
419
+ export function exec(options) {
420
+ return API.Process.operations.exec(options);
421
+ }
220
422
  export const readTextFile = API.Fs.operations.readTextFile;
221
423
  export const stat = API.Fs.operations.stat;
222
424
  export const glob = API.Fs.operations.glob;
425
+ export const writeTextFile = API.Fs.operations.writeTextFile;
426
+ export const ensureDir = API.Fs.operations.ensureDir;
427
+ export const rename = API.Fs.operations.rename;
428
+ export const remove = API.Fs.operations.remove;
429
+ export const realpath = API.Fs.operations.realpath;
223
430
  export const fetch = API.Fetch.operations.fetch;
224
431
  export const env = API.Env.operations.env;
225
432
  export const cwd = API.Env.operations.cwd;
226
433
  export const platform = API.Env.operations.platform;
227
- export const compile = API.Compiler.operations.compile;
434
+ export const command = API.Env.operations.command;
435
+ export const compile = API.Env.operations.compile;
436
+ /**
437
+ * Discard the standard output of subprocesses started in this scope.
438
+ *
439
+ * For a caller whose subprocess output is an *answer* rather than something to
440
+ * show: a command whose stdout is parsed and returned would otherwise also
441
+ * print itself into whatever the process was rendering. `stderr` is left alone,
442
+ * because that is where a failing command explains itself and a diagnostic is
443
+ * worth seeing.
444
+ *
445
+ * It lives here because reaching the process Api's stdio directly is host
446
+ * behavior, and modules held to the runtime-neutral boundary may not import a
447
+ * host process module of their own.
448
+ *
449
+ * Installed at the display boundary, where the host's own writer sits: not
450
+ * showing something and not knowing it are different, and a caller that asked
451
+ * for the answer must still be given it. Anything upstream — this adapter's
452
+ * retention, a document's capture, a run's record — reads the bytes first and
453
+ * only the host is left out.
454
+ */
455
+ export function useQuietProcessOutput() {
456
+ return Stdio.around({ *stdout() { } }, { at: "min" });
457
+ }
package/esm/config.js CHANGED
@@ -1,29 +1,58 @@
1
1
  /**
2
2
  * Config Api — shared execution configuration with pluggable middleware.
3
3
  *
4
- * Supplies the contextual timeout in milliseconds. Process, Fetch, and
5
- * Agent operations read it when a call does not provide an explicit
6
- * timeout. Override it for a scope with:
4
+ * Three timeouts, three owners, no defaults:
5
+ *
6
+ * - `timeout` is the deadline for the whole run — preparation and execution
7
+ * together — and only the outer run boundary consumes it.
8
+ * - `timeoutExec` is what each exec block gets, and only exec blocks and the
9
+ * built-in `timeout` modifier consume it.
10
+ * - `timeoutFetch` is what each Fetch gets, and only Fetch consumes it.
11
+ *
12
+ * `undefined` means no timeout, and it is what every field starts as. An
13
+ * operation nobody bounded runs until it finishes or the run's own deadline
14
+ * cancels it; a general "shared timeout" that quietly bounded processes,
15
+ * requests, prompts, and services alike is what this replaces. Override a
16
+ * field for a scope with:
7
17
  *
8
18
  * ```typescript
9
- * yield* Config.around({ timeout: () => 30_000 }, { at: "min" });
19
+ * yield* Config.around({ timeoutExec: () => 30_000 }, { at: "min" });
10
20
  * ```
21
+ *
22
+ * Installing at `min` is what lets a nested override win: a block's own
23
+ * `timeout=` outranks the value the command line established for the run.
24
+ * Omitting a field inherits the enclosing value rather than clearing it.
11
25
  */
12
26
  import { createApi } from "@effectionx/context-api";
13
27
  export const Config = createApi("Config", {
14
- timeout: 120_000,
28
+ timeout: undefined,
29
+ timeoutExec: undefined,
30
+ timeoutFetch: undefined,
15
31
  });
16
32
  /**
17
- * The validated contextual timeout. Always a positive, finite number of
18
- * milliseconds a middleware-supplied value that is not valid fails loudly
19
- * here rather than silently disabling or corrupting timeouts downstream.
33
+ * A configured duration is milliseconds or nothing. Anything else fails here,
34
+ * before the operation it was meant to bound starts, rather than disabling or
35
+ * corrupting the bound downstream.
20
36
  */
21
- export const timeout = {
22
- *[Symbol.iterator]() {
23
- const value = yield* Config.operations.timeout;
24
- if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
25
- throw new Error(`Config timeout must be a positive, finite number of milliseconds, got ${String(value)}`);
26
- }
27
- return value;
28
- },
29
- };
37
+ function validate(name, value) {
38
+ if (value === undefined) {
39
+ return undefined;
40
+ }
41
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
42
+ throw new Error(`Config ${name} must be a positive, finite number of milliseconds, got ${String(value)}`);
43
+ }
44
+ return value;
45
+ }
46
+ function validated(name, source) {
47
+ return {
48
+ *[Symbol.iterator]() {
49
+ return validate(name, yield* source);
50
+ },
51
+ };
52
+ }
53
+ /** The validated run deadline. Read by the run boundary and nothing else. */
54
+ export const timeout = validated("timeout", Config.operations.timeout);
55
+ /** The validated default timeout for an exec block. */
56
+ export const timeoutExec = validated("timeoutExec", Config.operations.timeoutExec);
57
+ /** The validated default timeout for a Fetch. */
58
+ export const timeoutFetch = validated("timeoutFetch", Config.operations.timeoutFetch);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The duration grammar, in one place.
3
+ *
4
+ * Every timeout a caller or a document writes is spelled the same way: the
5
+ * three CLI options, and the `timeout=` modifier a block declares. A duration
6
+ * is a positive whole number with a unit — `500ms`, `30s`, `5min`, `20min` —
7
+ * or bare digits, which are milliseconds.
8
+ *
9
+ * Nothing here substitutes a value. An empty, zero, negative, or malformed
10
+ * duration is refused where it was written, because the alternative is a run
11
+ * bounded by a number nobody asked for.
12
+ */
13
+ const DURATION = /^(\d+)(ms|s|min|m)?$/;
14
+ const MULTIPLIER = {
15
+ ms: 1,
16
+ s: 1_000,
17
+ m: 60_000,
18
+ min: 60_000,
19
+ };
20
+ /** Milliseconds, or `undefined` when `text` is not a duration. */
21
+ export function asDuration(text) {
22
+ const match = DURATION.exec(text.trim());
23
+ if (match === null) {
24
+ return undefined;
25
+ }
26
+ const [, digits = "", unit = "ms"] = match;
27
+ const value = Number(digits) * (MULTIPLIER[unit] ?? 1);
28
+ if (!Number.isFinite(value) || value <= 0) {
29
+ return undefined;
30
+ }
31
+ return value;
32
+ }
33
+ /** What a rejected duration says, with `label` naming where it was written. */
34
+ export function durationError(label, text) {
35
+ return new Error(`${label} must be a duration like 500ms, 30s, or 5min, got ${JSON.stringify(text)}`);
36
+ }
37
+ /** Milliseconds. Throws when `text` is not a duration this grammar accepts. */
38
+ export function parseDuration(text, label) {
39
+ const value = asDuration(text);
40
+ if (value === undefined) {
41
+ throw durationError(label, text);
42
+ }
43
+ return value;
44
+ }