@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 +287 -57
- package/esm/config.js +46 -17
- package/esm/duration.js +44 -0
- package/esm/files.js +558 -0
- package/esm/host-files.js +496 -0
- package/esm/mod.js +16 -8
- package/esm/service.js +126 -0
- package/esm/test/mod.js +2 -1
- package/esm/test/stubs.js +41 -6
- package/package.json +10 -4
- package/types/apis.d.ts +142 -24
- package/types/config.d.ts +30 -12
- package/types/duration.d.ts +18 -0
- package/types/files.d.ts +299 -0
- package/types/host-files.d.ts +80 -0
- package/types/mod.d.ts +21 -9
- package/types/service.d.ts +77 -0
- package/types/test/mod.d.ts +2 -1
- package/types/test/stubs.d.ts +12 -0
- package/esm/find-free-port.js +0 -33
- package/types/find-free-port.d.ts +0 -9
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
|
|
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
|
|
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** —
|
|
33
|
-
*
|
|
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** —
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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 {
|
|
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,
|
|
64
|
-
import { exec as processExec } from "@effectionx/process";
|
|
65
|
-
import {
|
|
66
|
-
import {
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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*
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* timeout
|
|
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({
|
|
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:
|
|
28
|
+
timeout: undefined,
|
|
29
|
+
timeoutExec: undefined,
|
|
30
|
+
timeoutFetch: undefined,
|
|
15
31
|
});
|
|
16
32
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
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);
|
package/esm/duration.js
ADDED
|
@@ -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
|
+
}
|