@executablemd/runtime 0.7.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 +119 -24
- 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 +13 -7
- package/esm/service.js +126 -0
- package/esm/test/mod.js +2 -1
- package/esm/test/stubs.js +38 -6
- package/package.json +10 -4
- package/types/apis.d.ts +91 -18
- 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 +17 -7
- package/types/service.d.ts +77 -0
- package/types/test/mod.d.ts +2 -1
- package/types/test/stubs.d.ts +9 -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,14 +26,19 @@
|
|
|
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** — reading, writing, and inspecting
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
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.
|
|
36
42
|
* - **Fetch** — HTTP has distinct timeout/body/abort semantics. Merging
|
|
37
43
|
* with Fs or Process would blur cancellation boundaries.
|
|
38
44
|
* - **Env** — the host itself: metadata (env vars, platform) plus the two
|
|
@@ -41,6 +47,8 @@
|
|
|
41
47
|
* use `.around()` to mock platform/env for deterministic replay; an
|
|
42
48
|
* entrypoint installs its `command` and `compile` with `{ at: "min" }` so
|
|
43
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.
|
|
44
52
|
*
|
|
45
53
|
* ## Middleware
|
|
46
54
|
*
|
|
@@ -59,7 +67,8 @@
|
|
|
59
67
|
* ## Test stubs
|
|
60
68
|
*
|
|
61
69
|
* Common stubs are provided by `@executablemd/runtime/test`:
|
|
62
|
-
* `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)
|
|
70
|
+
* `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)`,
|
|
71
|
+
* `useStubService(endpoint)`.
|
|
63
72
|
*/
|
|
64
73
|
import { createApi } from "@effectionx/context-api";
|
|
65
74
|
import { join } from "node:path";
|
|
@@ -67,9 +76,11 @@ import process from "node:process";
|
|
|
67
76
|
import { realpath as fsRealpath, rename as fsRename } from "node:fs/promises";
|
|
68
77
|
import { fetch as effectionFetch } from "@effectionx/fetch";
|
|
69
78
|
import { ensureDir as fsEnsureDir, FsApi, globToRegExp, readTextFile as fsReadTextFile, rm as fsRm, stat as fsStat, writeTextFile as fsWriteTextFile, } from "@effectionx/fs";
|
|
70
|
-
import { exec as processExec } from "@effectionx/process";
|
|
71
|
-
import { race, sleep, until } from "effection";
|
|
72
|
-
import {
|
|
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";
|
|
73
84
|
/**
|
|
74
85
|
* The `errno` string a failed filesystem call carries, when it carries one.
|
|
75
86
|
*
|
|
@@ -84,6 +95,72 @@ function errorCode(error) {
|
|
|
84
95
|
const { code } = error;
|
|
85
96
|
return typeof code === "string" ? code : undefined;
|
|
86
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
|
+
}
|
|
87
164
|
function* withTimeout(label, timeout, operation) {
|
|
88
165
|
if (timeout === undefined) {
|
|
89
166
|
return yield* operation;
|
|
@@ -199,22 +276,14 @@ export const API = {
|
|
|
199
276
|
*/
|
|
200
277
|
Process: createApi("runtime.process", {
|
|
201
278
|
*exec(options) {
|
|
202
|
-
const { command, cwd, env, timeout } = options;
|
|
279
|
+
const { command, cwd, env, timeout, retain = true } = options;
|
|
203
280
|
const [cmd, ...args] = command;
|
|
204
281
|
if (!cmd) {
|
|
205
282
|
throw new Error("exec: command array must not be empty");
|
|
206
283
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
cwd,
|
|
211
|
-
env,
|
|
212
|
-
}).join());
|
|
213
|
-
return {
|
|
214
|
-
exitCode: result.code ?? 1,
|
|
215
|
-
stdout: result.stdout,
|
|
216
|
-
stderr: result.stderr,
|
|
217
|
-
};
|
|
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 }));
|
|
218
287
|
},
|
|
219
288
|
}),
|
|
220
289
|
/**
|
|
@@ -286,7 +355,7 @@ export const API = {
|
|
|
286
355
|
*/
|
|
287
356
|
Fetch: createApi("runtime.fetch", {
|
|
288
357
|
*fetch(input, init) {
|
|
289
|
-
const timeout = init?.timeout ?? (yield*
|
|
358
|
+
const timeout = init?.timeout ?? (yield* contextualFetchTimeout);
|
|
290
359
|
const response = yield* withTimeout(`fetch(${input})`, timeout, effectionFetch(input, {
|
|
291
360
|
method: init?.method,
|
|
292
361
|
headers: init?.headers,
|
|
@@ -344,8 +413,12 @@ export const API = {
|
|
|
344
413
|
throw new Error("compiler not installed — install platform-specific middleware via API.Env.around()");
|
|
345
414
|
},
|
|
346
415
|
}),
|
|
416
|
+
Files,
|
|
417
|
+
Service,
|
|
347
418
|
};
|
|
348
|
-
export
|
|
419
|
+
export function exec(options) {
|
|
420
|
+
return API.Process.operations.exec(options);
|
|
421
|
+
}
|
|
349
422
|
export const readTextFile = API.Fs.operations.readTextFile;
|
|
350
423
|
export const stat = API.Fs.operations.stat;
|
|
351
424
|
export const glob = API.Fs.operations.glob;
|
|
@@ -360,3 +433,25 @@ export const cwd = API.Env.operations.cwd;
|
|
|
360
433
|
export const platform = API.Env.operations.platform;
|
|
361
434
|
export const command = API.Env.operations.command;
|
|
362
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
|
+
}
|