@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 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,14 +26,19 @@
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** — reading, writing, and inspecting files form a cohesive file-IO
33
- * surface used together for component resolution, replay guards, and the
34
- * `<File>` component. Middleware installed here sees a document's own file
35
- * access on the same terms as the engine's.
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 { timeout as contextualTimeout } from "./config.js";
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
- const effectiveTimeout = timeout ?? (yield* contextualTimeout);
208
- const result = yield* withTimeout(`exec(${cmd})`, effectiveTimeout, processExec(cmd, {
209
- arguments: args,
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* contextualTimeout);
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 const exec = API.Process.operations.exec;
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
- * 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
+ }