@executablemd/runtime 0.2.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/LICENSE +21 -0
- package/esm/apis.js +237 -0
- package/esm/find-free-port.js +33 -0
- package/esm/mod.js +20 -0
- package/esm/package.json +3 -0
- package/esm/test/mod.js +10 -0
- package/esm/test/stubs.js +80 -0
- package/package.json +40 -0
- package/types/apis.d.ts +154 -0
- package/types/find-free-port.d.ts +9 -0
- package/types/mod.d.ts +21 -0
- package/types/test/mod.d.ts +10 -0
- package/types/test/stubs.d.ts +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Taras Mankovski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/esm/apis.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime Context APIs — platform I/O operations with pluggable middleware.
|
|
3
|
+
*
|
|
4
|
+
* Five domain-specific context APIs built on `@effectionx/context-api`.
|
|
5
|
+
* Each API provides default Node.js implementations. Use `.around()` to
|
|
6
|
+
* install middleware (mocking, instrumentation, sandboxing) scoped to the
|
|
7
|
+
* current Effection scope.
|
|
8
|
+
*
|
|
9
|
+
* ## Architecture
|
|
10
|
+
*
|
|
11
|
+
* Import operation functions for normal calls, and use `API` only when
|
|
12
|
+
* installing middleware with `.around()`:
|
|
13
|
+
*
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { readTextFile, stat, API } from "@executablemd/runtime";
|
|
16
|
+
*
|
|
17
|
+
* // normal calls
|
|
18
|
+
* const file = yield* readTextFile("doc.md");
|
|
19
|
+
*
|
|
20
|
+
* // middleware
|
|
21
|
+
* yield* API.Fs.around({
|
|
22
|
+
* *readTextFile([path], next) {
|
|
23
|
+
* return yield* next(path);
|
|
24
|
+
* },
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* ## Why four separate APIs?
|
|
29
|
+
*
|
|
30
|
+
* - **Process** — subprocess lifecycle has its own cancellation semantics
|
|
31
|
+
* (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.
|
|
34
|
+
* - **Fetch** — HTTP has distinct timeout/body/abort semantics. Merging
|
|
35
|
+
* 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.
|
|
39
|
+
*
|
|
40
|
+
* ## Middleware
|
|
41
|
+
*
|
|
42
|
+
* ```typescript
|
|
43
|
+
* yield* API.Fs.around({
|
|
44
|
+
* *readTextFile([path], next) {
|
|
45
|
+
* return "mocked content";
|
|
46
|
+
* },
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* Middleware is **scoped** — it only affects operations within the
|
|
51
|
+
* current Effection scope and its children. Install before calling
|
|
52
|
+
* `runDocument()` or `durableRun()`.
|
|
53
|
+
*
|
|
54
|
+
* ## Test stubs
|
|
55
|
+
*
|
|
56
|
+
* Common stubs are provided by `@executablemd/runtime/test`:
|
|
57
|
+
* `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)`.
|
|
58
|
+
*/
|
|
59
|
+
import { createApi } from "@effectionx/context-api";
|
|
60
|
+
import { relative, sep } from "node:path";
|
|
61
|
+
import process from "node:process";
|
|
62
|
+
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
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Private helpers
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
function* withTimeout(label, timeout, operation) {
|
|
70
|
+
if (timeout === undefined) {
|
|
71
|
+
return yield* operation;
|
|
72
|
+
}
|
|
73
|
+
if (!Number.isFinite(timeout) || timeout < 0) {
|
|
74
|
+
throw new Error(`${label}: timeout must be a non-negative finite number`);
|
|
75
|
+
}
|
|
76
|
+
return (yield* race([
|
|
77
|
+
operation,
|
|
78
|
+
(function* () {
|
|
79
|
+
yield* sleep(timeout);
|
|
80
|
+
throw new Error(`${label} timed out after ${timeout}ms`);
|
|
81
|
+
})(),
|
|
82
|
+
]));
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Context APIs
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
export const API = {
|
|
88
|
+
/**
|
|
89
|
+
* Subprocess execution.
|
|
90
|
+
*
|
|
91
|
+
* Default implementation uses `@effectionx/process`.
|
|
92
|
+
* Cancellation kills the process via Effection scope teardown.
|
|
93
|
+
*/
|
|
94
|
+
Process: createApi("runtime.process", {
|
|
95
|
+
*exec(options) {
|
|
96
|
+
const { command, cwd, env, timeout } = options;
|
|
97
|
+
const [cmd, ...args] = command;
|
|
98
|
+
if (!cmd) {
|
|
99
|
+
throw new Error("exec: command array must not be empty");
|
|
100
|
+
}
|
|
101
|
+
const result = yield* withTimeout(`exec(${cmd})`, timeout, processExec(cmd, {
|
|
102
|
+
arguments: args,
|
|
103
|
+
cwd,
|
|
104
|
+
env,
|
|
105
|
+
}).join());
|
|
106
|
+
return {
|
|
107
|
+
exitCode: result.code ?? 1,
|
|
108
|
+
stdout: result.stdout,
|
|
109
|
+
stderr: result.stderr,
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
/**
|
|
114
|
+
* Filesystem operations.
|
|
115
|
+
*
|
|
116
|
+
* Default implementation uses `@effectionx/fs`.
|
|
117
|
+
*/
|
|
118
|
+
Fs: createApi("runtime.fs", {
|
|
119
|
+
*readTextFile(path) {
|
|
120
|
+
return yield* fsReadTextFile(path);
|
|
121
|
+
},
|
|
122
|
+
*stat(path) {
|
|
123
|
+
try {
|
|
124
|
+
const s = yield* fsStat(path);
|
|
125
|
+
return {
|
|
126
|
+
exists: true,
|
|
127
|
+
isFile: s.isFile(),
|
|
128
|
+
isDirectory: s.isDirectory(),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
if (err.code === "ENOENT") {
|
|
133
|
+
return { exists: false, isFile: false, isDirectory: false };
|
|
134
|
+
}
|
|
135
|
+
throw err;
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
*glob(options) {
|
|
139
|
+
const { patterns, root, exclude = [] } = options;
|
|
140
|
+
const results = [];
|
|
141
|
+
// Convert include/exclude patterns to RegExp for matching
|
|
142
|
+
// against relative paths from root
|
|
143
|
+
const includeRegexes = patterns.map((p) => globToRegExp(p, { extended: true, globstar: true }));
|
|
144
|
+
const excludeRegexes = exclude.map((e) => globToRegExp(e, { extended: true, globstar: true }));
|
|
145
|
+
// Walk the directory tree and match relative paths
|
|
146
|
+
const stream = walk(root, {
|
|
147
|
+
includeFiles: true,
|
|
148
|
+
includeDirs: false,
|
|
149
|
+
skip: excludeRegexes.length > 0 ? excludeRegexes : undefined,
|
|
150
|
+
});
|
|
151
|
+
for (const entry of yield* each(stream)) {
|
|
152
|
+
// Normalize to POSIX separators for consistent matching across platforms
|
|
153
|
+
const relPath = relative(root, entry.path).split(sep).join("/");
|
|
154
|
+
const matches = includeRegexes.some((re) => re.test(relPath));
|
|
155
|
+
if (matches) {
|
|
156
|
+
results.push({ path: relPath, isFile: entry.isFile });
|
|
157
|
+
}
|
|
158
|
+
yield* each.next();
|
|
159
|
+
}
|
|
160
|
+
return results;
|
|
161
|
+
},
|
|
162
|
+
}),
|
|
163
|
+
/**
|
|
164
|
+
* HTTP requests.
|
|
165
|
+
*
|
|
166
|
+
* Default implementation uses `@effectionx/fetch`.
|
|
167
|
+
* Cancellation aborts the request via Effection scope teardown.
|
|
168
|
+
*/
|
|
169
|
+
Fetch: createApi("runtime.fetch", {
|
|
170
|
+
*fetch(input, init) {
|
|
171
|
+
const timeout = init?.timeout;
|
|
172
|
+
const response = yield* withTimeout(`fetch(${input})`, timeout, effectionFetch(input, {
|
|
173
|
+
method: init?.method,
|
|
174
|
+
headers: init?.headers,
|
|
175
|
+
body: init?.body,
|
|
176
|
+
}));
|
|
177
|
+
return {
|
|
178
|
+
status: response.status,
|
|
179
|
+
headers: response.headers,
|
|
180
|
+
*text() {
|
|
181
|
+
return yield* withTimeout(`fetch(${input}).text()`, timeout, response.text());
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
/**
|
|
187
|
+
* Environment variables and platform information.
|
|
188
|
+
*
|
|
189
|
+
* These are synchronous lookups wrapped as Operations to satisfy
|
|
190
|
+
* context-api handler constraints.
|
|
191
|
+
*/
|
|
192
|
+
Env: createApi("runtime.env", {
|
|
193
|
+
// deno-lint-ignore require-yield
|
|
194
|
+
*cwd() {
|
|
195
|
+
return process.cwd();
|
|
196
|
+
},
|
|
197
|
+
// deno-lint-ignore require-yield
|
|
198
|
+
*env(name) {
|
|
199
|
+
return process.env[name];
|
|
200
|
+
},
|
|
201
|
+
// deno-lint-ignore require-yield
|
|
202
|
+
*platform() {
|
|
203
|
+
return {
|
|
204
|
+
os: process.platform,
|
|
205
|
+
arch: process.arch,
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
}),
|
|
209
|
+
/**
|
|
210
|
+
* Block compilation.
|
|
211
|
+
*
|
|
212
|
+
* Default handler throws — platform-specific middleware must be
|
|
213
|
+
* installed via `yield* API.Compiler.around(...)` before use.
|
|
214
|
+
* See `core/src/deno-compiler.ts` for the Deno implementation.
|
|
215
|
+
*/
|
|
216
|
+
Compiler: createApi("runtime.compiler", {
|
|
217
|
+
// deno-lint-ignore require-yield
|
|
218
|
+
*compile(_source, _options) {
|
|
219
|
+
throw new Error("compiler not installed — install platform-specific middleware via API.Compiler.around()");
|
|
220
|
+
},
|
|
221
|
+
}),
|
|
222
|
+
};
|
|
223
|
+
// ---------------------------------------------------------------------------
|
|
224
|
+
// Convenience operation exports
|
|
225
|
+
//
|
|
226
|
+
// Use these for normal operation calls. Use API.* only when installing
|
|
227
|
+
// middleware with .around().
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
export const exec = API.Process.operations.exec;
|
|
230
|
+
export const readTextFile = API.Fs.operations.readTextFile;
|
|
231
|
+
export const stat = API.Fs.operations.stat;
|
|
232
|
+
export const glob = API.Fs.operations.glob;
|
|
233
|
+
export const fetch = API.Fetch.operations.fetch;
|
|
234
|
+
export const env = API.Env.operations.env;
|
|
235
|
+
export const cwd = API.Env.operations.cwd;
|
|
236
|
+
export const platform = API.Env.operations.platform;
|
|
237
|
+
export const compile = API.Compiler.operations.compile;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* findFreePort — find an available TCP port using the OS.
|
|
3
|
+
*/
|
|
4
|
+
import { race } from "effection";
|
|
5
|
+
import { once } from "@effectionx/node";
|
|
6
|
+
import { createServer } from "node:net";
|
|
7
|
+
/**
|
|
8
|
+
* Find an available TCP port by binding to port 0 and reading the
|
|
9
|
+
* OS-assigned port number.
|
|
10
|
+
*/
|
|
11
|
+
export function* findFreePort() {
|
|
12
|
+
const server = createServer();
|
|
13
|
+
const listening = once(server, "listening");
|
|
14
|
+
const error = once(server, "error");
|
|
15
|
+
server.listen(0);
|
|
16
|
+
try {
|
|
17
|
+
const rethrowError = {
|
|
18
|
+
*[Symbol.iterator]() {
|
|
19
|
+
const [err] = yield* error;
|
|
20
|
+
throw err;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
yield* race([listening, rethrowError]);
|
|
24
|
+
const addr = server.address();
|
|
25
|
+
if (!addr || typeof addr !== "object") {
|
|
26
|
+
throw new Error("findFreePort: unexpected address format");
|
|
27
|
+
}
|
|
28
|
+
return addr.port;
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
server.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
package/esm/mod.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Runtime Context APIs for executable markdown.
|
|
4
|
+
*
|
|
5
|
+
* `API` is available for middleware (`.around()`).
|
|
6
|
+
* For normal calls, import operations directly.
|
|
7
|
+
*
|
|
8
|
+
* Six domain APIs:
|
|
9
|
+
* - `API.Process` — subprocess execution (`exec`)
|
|
10
|
+
* - `API.Fs` — filesystem (`readTextFile`, `stat`, `glob`)
|
|
11
|
+
* - `API.Fetch` — HTTP requests (`fetch`)
|
|
12
|
+
* - `API.Env` — environment variables and platform info (`cwd`, `env`, `platform`)
|
|
13
|
+
* - `API.Compiler` — block compilation (`compile`)
|
|
14
|
+
*
|
|
15
|
+
* See `apis.ts` for architecture rationale.
|
|
16
|
+
* See `@executablemd/runtime/test` for composable test stubs.
|
|
17
|
+
*/
|
|
18
|
+
export { API } from "./apis.js";
|
|
19
|
+
export { exec, readTextFile, stat, glob, fetch, cwd, env, platform, compile } from "./apis.js";
|
|
20
|
+
export { findFreePort } from "./find-free-port.js";
|
package/esm/package.json
ADDED
package/esm/test/mod.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Test helpers for executable markdown runtime.
|
|
4
|
+
*
|
|
5
|
+
* Composable stubs for runtime context APIs:
|
|
6
|
+
* - `useStubFs(files)` — in-memory filesystem
|
|
7
|
+
* - `useEchoExec()` — simple echo-based exec
|
|
8
|
+
* - `useFailingExec(exitCode, stderr)` — always-failing exec
|
|
9
|
+
*/
|
|
10
|
+
export { useStubFs, useEchoExec, useFailingExec } from "./stubs.js";
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable test stubs for runtime context APIs.
|
|
3
|
+
*
|
|
4
|
+
* These helpers install `around()` middleware on the runtime context APIs
|
|
5
|
+
* to replace real I/O with in-memory implementations. They are scoped to
|
|
6
|
+
* the current Effection scope — call them before `runDocument()` or
|
|
7
|
+
* `durableRun()` in your test body.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { useStubFs, useEchoExec } from "@executablemd/runtime/test";
|
|
12
|
+
*
|
|
13
|
+
* it("runs a document with stubbed I/O", function* () {
|
|
14
|
+
* yield* useStubFs({ "doc.md": "# Hello\n" });
|
|
15
|
+
* yield* useEchoExec();
|
|
16
|
+
*
|
|
17
|
+
* const execution = yield* runDocument({ docPath: "doc.md", stream });
|
|
18
|
+
* const output = yield* execution;
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import { API } from "../apis.js";
|
|
23
|
+
/**
|
|
24
|
+
* Install an in-memory filesystem stub.
|
|
25
|
+
*
|
|
26
|
+
* - `readTextFile` returns content from the `files` map; throws ENOENT for missing keys.
|
|
27
|
+
* - `stat` returns `{ exists: true, isFile: true }` for keys in the map.
|
|
28
|
+
* - `glob` throws (not stubbed). Install `API.Fs.around()` directly if needed.
|
|
29
|
+
*
|
|
30
|
+
* The `files` object is captured **by reference** — mutating it between
|
|
31
|
+
* operations changes what `readTextFile`/`stat` see. This is useful for
|
|
32
|
+
* testing file changes between runs.
|
|
33
|
+
*/
|
|
34
|
+
export function* useStubFs(files) {
|
|
35
|
+
yield* API.Fs.around({
|
|
36
|
+
*readTextFile([path], _next) {
|
|
37
|
+
const content = files[path];
|
|
38
|
+
if (content === undefined) {
|
|
39
|
+
throw new Error(`ENOENT: no such file: ${path}`);
|
|
40
|
+
}
|
|
41
|
+
return content;
|
|
42
|
+
},
|
|
43
|
+
*stat([path], _next) {
|
|
44
|
+
const exists = path in files;
|
|
45
|
+
return { exists, isFile: exists, isDirectory: false };
|
|
46
|
+
},
|
|
47
|
+
*glob(_args, _next) {
|
|
48
|
+
throw new Error("glob not stubbed");
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Install a simple exec stub that handles `echo` commands.
|
|
54
|
+
*
|
|
55
|
+
* Recognizes `bash -c "echo ..."` and returns the echo'd text as stdout.
|
|
56
|
+
* All other commands return the script text as stdout with exit code 0.
|
|
57
|
+
*/
|
|
58
|
+
export function* useEchoExec() {
|
|
59
|
+
yield* API.Process.around({
|
|
60
|
+
*exec([options], _next) {
|
|
61
|
+
const script = (options.command[2] ?? "").trim();
|
|
62
|
+
if (script.startsWith("echo ")) {
|
|
63
|
+
return { exitCode: 0, stdout: script.slice(5) + "\n", stderr: "" };
|
|
64
|
+
}
|
|
65
|
+
return { exitCode: 0, stdout: script + "\n", stderr: "" };
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Install an exec stub that always returns the given exit code and stderr.
|
|
71
|
+
*
|
|
72
|
+
* Useful for testing error handling paths.
|
|
73
|
+
*/
|
|
74
|
+
export function* useFailingExec(exitCode, stderr = "command failed") {
|
|
75
|
+
yield* API.Process.around({
|
|
76
|
+
*exec(_args, _next) {
|
|
77
|
+
return { exitCode, stdout: "", stderr };
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@executablemd/runtime",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Runtime host APIs for executable.md documents.",
|
|
5
|
+
"homepage": "https://executable.md",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/taras/executable.md.git"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/taras/executable.md/issues"
|
|
13
|
+
},
|
|
14
|
+
"module": "./esm/mod.js",
|
|
15
|
+
"types": "./types/mod.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"import": {
|
|
19
|
+
"types": "./types/mod.d.ts",
|
|
20
|
+
"default": "./esm/mod.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"./test": {
|
|
24
|
+
"import": {
|
|
25
|
+
"types": "./types/test/mod.d.ts",
|
|
26
|
+
"default": "./esm/test/mod.js"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"scripts": {},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@effectionx/context-api": "0.6.0",
|
|
33
|
+
"@effectionx/fetch": "0.2.0",
|
|
34
|
+
"@effectionx/fs": "0.3.0",
|
|
35
|
+
"@effectionx/node": "0.2.4",
|
|
36
|
+
"@effectionx/process": "0.8.1",
|
|
37
|
+
"effection": "4.1.0-alpha.7"
|
|
38
|
+
},
|
|
39
|
+
"_generatedBy": "dnt@dev"
|
|
40
|
+
}
|
package/types/apis.d.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime Context APIs — platform I/O operations with pluggable middleware.
|
|
3
|
+
*
|
|
4
|
+
* Five domain-specific context APIs built on `@effectionx/context-api`.
|
|
5
|
+
* Each API provides default Node.js implementations. Use `.around()` to
|
|
6
|
+
* install middleware (mocking, instrumentation, sandboxing) scoped to the
|
|
7
|
+
* current Effection scope.
|
|
8
|
+
*
|
|
9
|
+
* ## Architecture
|
|
10
|
+
*
|
|
11
|
+
* Import operation functions for normal calls, and use `API` only when
|
|
12
|
+
* installing middleware with `.around()`:
|
|
13
|
+
*
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { readTextFile, stat, API } from "@executablemd/runtime";
|
|
16
|
+
*
|
|
17
|
+
* // normal calls
|
|
18
|
+
* const file = yield* readTextFile("doc.md");
|
|
19
|
+
*
|
|
20
|
+
* // middleware
|
|
21
|
+
* yield* API.Fs.around({
|
|
22
|
+
* *readTextFile([path], next) {
|
|
23
|
+
* return yield* next(path);
|
|
24
|
+
* },
|
|
25
|
+
* });
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* ## Why four separate APIs?
|
|
29
|
+
*
|
|
30
|
+
* - **Process** — subprocess lifecycle has its own cancellation semantics
|
|
31
|
+
* (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.
|
|
34
|
+
* - **Fetch** — HTTP has distinct timeout/body/abort semantics. Merging
|
|
35
|
+
* 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.
|
|
39
|
+
*
|
|
40
|
+
* ## Middleware
|
|
41
|
+
*
|
|
42
|
+
* ```typescript
|
|
43
|
+
* yield* API.Fs.around({
|
|
44
|
+
* *readTextFile([path], next) {
|
|
45
|
+
* return "mocked content";
|
|
46
|
+
* },
|
|
47
|
+
* });
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* Middleware is **scoped** — it only affects operations within the
|
|
51
|
+
* current Effection scope and its children. Install before calling
|
|
52
|
+
* `runDocument()` or `durableRun()`.
|
|
53
|
+
*
|
|
54
|
+
* ## Test stubs
|
|
55
|
+
*
|
|
56
|
+
* Common stubs are provided by `@executablemd/runtime/test`:
|
|
57
|
+
* `useStubFs(files)`, `useEchoExec()`, `useFailingExec(code, stderr)`.
|
|
58
|
+
*/
|
|
59
|
+
import { type Api } from "@effectionx/context-api";
|
|
60
|
+
import type { Operation } from "effection";
|
|
61
|
+
/**
|
|
62
|
+
* Result of a `stat` call.
|
|
63
|
+
*
|
|
64
|
+
* For missing paths `stat` returns `{ exists: false, isFile: false, isDirectory: false }`
|
|
65
|
+
* instead of throwing — "does this exist?" has "no" as a valid answer.
|
|
66
|
+
*/
|
|
67
|
+
export interface StatResult {
|
|
68
|
+
exists: boolean;
|
|
69
|
+
isFile: boolean;
|
|
70
|
+
isDirectory: boolean;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Minimal response headers interface.
|
|
74
|
+
*
|
|
75
|
+
* Uses a minimal interface instead of the global `Headers` type to avoid
|
|
76
|
+
* requiring DOM lib types in tsconfig.
|
|
77
|
+
*/
|
|
78
|
+
export interface ResponseHeaders {
|
|
79
|
+
get(key: string): string | null;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Response shape returned by the fetch context API.
|
|
83
|
+
*
|
|
84
|
+
* Both the response object and `text()` are Operation-native — no Promises
|
|
85
|
+
* cross the interface boundary.
|
|
86
|
+
*/
|
|
87
|
+
export interface RuntimeFetchResponse {
|
|
88
|
+
status: number;
|
|
89
|
+
headers: ResponseHeaders;
|
|
90
|
+
/** Read the response body as text. */
|
|
91
|
+
text(): Operation<string>;
|
|
92
|
+
}
|
|
93
|
+
interface ProcessHandler {
|
|
94
|
+
exec(options: {
|
|
95
|
+
command: string[];
|
|
96
|
+
cwd?: string;
|
|
97
|
+
env?: Record<string, string>;
|
|
98
|
+
timeout?: number;
|
|
99
|
+
}): Operation<{
|
|
100
|
+
exitCode: number;
|
|
101
|
+
stdout: string;
|
|
102
|
+
stderr: string;
|
|
103
|
+
}>;
|
|
104
|
+
}
|
|
105
|
+
interface FsHandler {
|
|
106
|
+
readTextFile(path: string): Operation<string>;
|
|
107
|
+
stat(path: string): Operation<StatResult>;
|
|
108
|
+
glob(options: {
|
|
109
|
+
patterns: string[];
|
|
110
|
+
root: string;
|
|
111
|
+
exclude?: string[];
|
|
112
|
+
}): Operation<Array<{
|
|
113
|
+
path: string;
|
|
114
|
+
isFile: boolean;
|
|
115
|
+
}>>;
|
|
116
|
+
}
|
|
117
|
+
interface FetchHandler {
|
|
118
|
+
fetch(input: string, init?: {
|
|
119
|
+
method?: string;
|
|
120
|
+
headers?: Record<string, string>;
|
|
121
|
+
body?: string;
|
|
122
|
+
timeout?: number;
|
|
123
|
+
}): Operation<RuntimeFetchResponse>;
|
|
124
|
+
}
|
|
125
|
+
interface EnvHandler {
|
|
126
|
+
cwd(): Operation<string>;
|
|
127
|
+
env(name: string): Operation<string | undefined>;
|
|
128
|
+
platform(): Operation<{
|
|
129
|
+
os: string;
|
|
130
|
+
arch: string;
|
|
131
|
+
}>;
|
|
132
|
+
}
|
|
133
|
+
interface CompilerHandler {
|
|
134
|
+
compile(source: string, options?: {
|
|
135
|
+
imports: string[];
|
|
136
|
+
}): Operation<(env: Record<string, unknown>) => Generator<unknown, unknown, unknown>>;
|
|
137
|
+
}
|
|
138
|
+
export declare const API: {
|
|
139
|
+
Process: Api<ProcessHandler>;
|
|
140
|
+
Fs: Api<FsHandler>;
|
|
141
|
+
Fetch: Api<FetchHandler>;
|
|
142
|
+
Env: Api<EnvHandler>;
|
|
143
|
+
Compiler: Api<CompilerHandler>;
|
|
144
|
+
};
|
|
145
|
+
export declare const exec: typeof API.Process.operations.exec;
|
|
146
|
+
export declare const readTextFile: typeof API.Fs.operations.readTextFile;
|
|
147
|
+
export declare const stat: typeof API.Fs.operations.stat;
|
|
148
|
+
export declare const glob: typeof API.Fs.operations.glob;
|
|
149
|
+
export declare const fetch: typeof API.Fetch.operations.fetch;
|
|
150
|
+
export declare const env: typeof API.Env.operations.env;
|
|
151
|
+
export declare const cwd: typeof API.Env.operations.cwd;
|
|
152
|
+
export declare const platform: typeof API.Env.operations.platform;
|
|
153
|
+
export declare const compile: typeof API.Compiler.operations.compile;
|
|
154
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* findFreePort — find an available TCP port using the OS.
|
|
3
|
+
*/
|
|
4
|
+
import type { Operation } from "effection";
|
|
5
|
+
/**
|
|
6
|
+
* Find an available TCP port by binding to port 0 and reading the
|
|
7
|
+
* OS-assigned port number.
|
|
8
|
+
*/
|
|
9
|
+
export declare function findFreePort(): Operation<number>;
|
package/types/mod.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Runtime Context APIs for executable markdown.
|
|
4
|
+
*
|
|
5
|
+
* `API` is available for middleware (`.around()`).
|
|
6
|
+
* For normal calls, import operations directly.
|
|
7
|
+
*
|
|
8
|
+
* Six domain APIs:
|
|
9
|
+
* - `API.Process` — subprocess execution (`exec`)
|
|
10
|
+
* - `API.Fs` — filesystem (`readTextFile`, `stat`, `glob`)
|
|
11
|
+
* - `API.Fetch` — HTTP requests (`fetch`)
|
|
12
|
+
* - `API.Env` — environment variables and platform info (`cwd`, `env`, `platform`)
|
|
13
|
+
* - `API.Compiler` — block compilation (`compile`)
|
|
14
|
+
*
|
|
15
|
+
* See `apis.ts` for architecture rationale.
|
|
16
|
+
* See `@executablemd/runtime/test` for composable test stubs.
|
|
17
|
+
*/
|
|
18
|
+
export { API } from "./apis.js";
|
|
19
|
+
export { exec, readTextFile, stat, glob, fetch, cwd, env, platform, compile } from "./apis.js";
|
|
20
|
+
export type { ResponseHeaders, RuntimeFetchResponse, StatResult } from "./apis.js";
|
|
21
|
+
export { findFreePort } from "./find-free-port.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Test helpers for executable markdown runtime.
|
|
4
|
+
*
|
|
5
|
+
* Composable stubs for runtime context APIs:
|
|
6
|
+
* - `useStubFs(files)` — in-memory filesystem
|
|
7
|
+
* - `useEchoExec()` — simple echo-based exec
|
|
8
|
+
* - `useFailingExec(exitCode, stderr)` — always-failing exec
|
|
9
|
+
*/
|
|
10
|
+
export { useStubFs, useEchoExec, useFailingExec } from "./stubs.js";
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composable test stubs for runtime context APIs.
|
|
3
|
+
*
|
|
4
|
+
* These helpers install `around()` middleware on the runtime context APIs
|
|
5
|
+
* to replace real I/O with in-memory implementations. They are scoped to
|
|
6
|
+
* the current Effection scope — call them before `runDocument()` or
|
|
7
|
+
* `durableRun()` in your test body.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```typescript
|
|
11
|
+
* import { useStubFs, useEchoExec } from "@executablemd/runtime/test";
|
|
12
|
+
*
|
|
13
|
+
* it("runs a document with stubbed I/O", function* () {
|
|
14
|
+
* yield* useStubFs({ "doc.md": "# Hello\n" });
|
|
15
|
+
* yield* useEchoExec();
|
|
16
|
+
*
|
|
17
|
+
* const execution = yield* runDocument({ docPath: "doc.md", stream });
|
|
18
|
+
* const output = yield* execution;
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
import type { Operation } from "effection";
|
|
23
|
+
/**
|
|
24
|
+
* Install an in-memory filesystem stub.
|
|
25
|
+
*
|
|
26
|
+
* - `readTextFile` returns content from the `files` map; throws ENOENT for missing keys.
|
|
27
|
+
* - `stat` returns `{ exists: true, isFile: true }` for keys in the map.
|
|
28
|
+
* - `glob` throws (not stubbed). Install `API.Fs.around()` directly if needed.
|
|
29
|
+
*
|
|
30
|
+
* The `files` object is captured **by reference** — mutating it between
|
|
31
|
+
* operations changes what `readTextFile`/`stat` see. This is useful for
|
|
32
|
+
* testing file changes between runs.
|
|
33
|
+
*/
|
|
34
|
+
export declare function useStubFs(files: Record<string, string>): Operation<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Install a simple exec stub that handles `echo` commands.
|
|
37
|
+
*
|
|
38
|
+
* Recognizes `bash -c "echo ..."` and returns the echo'd text as stdout.
|
|
39
|
+
* All other commands return the script text as stdout with exit code 0.
|
|
40
|
+
*/
|
|
41
|
+
export declare function useEchoExec(): Operation<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Install an exec stub that always returns the given exit code and stderr.
|
|
44
|
+
*
|
|
45
|
+
* Useful for testing error handling paths.
|
|
46
|
+
*/
|
|
47
|
+
export declare function useFailingExec(exitCode: number, stderr?: string): Operation<void>;
|