@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
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host `API.Files` provider — document filesystem access in the caller's
|
|
3
|
+
* own filesystem.
|
|
4
|
+
*
|
|
5
|
+
* This is what `xmd run` installs. A document's relative path is resolved
|
|
6
|
+
* against the contextual working directory and used as an ordinary host path,
|
|
7
|
+
* so a document can hand a file to a tool the caller already has. Everything
|
|
8
|
+
* below is built on the low-level `API.Fs`, which is deliberate: a host that
|
|
9
|
+
* already wraps `API.Fs` to observe or sandbox the engine's own file access
|
|
10
|
+
* keeps seeing a document's access on the same terms.
|
|
11
|
+
*
|
|
12
|
+
* ## What containment means here
|
|
13
|
+
*
|
|
14
|
+
* Access is confined to the contextual directory, judged against the filesystem
|
|
15
|
+
* as this adapter observes it. An empty path, an absolute path, and a lexical
|
|
16
|
+
* `..` escape are refused without touching the filesystem at all; a symlink
|
|
17
|
+
* leading out is refused once resolution can see it.
|
|
18
|
+
*
|
|
19
|
+
* That is sound **while the host pathname namespace is stable**, and every
|
|
20
|
+
* guarantee here is stated on that basis. It is not a sandbox. Another process
|
|
21
|
+
* can replace a directory, symlink, junction, or reparse point between the
|
|
22
|
+
* moment this adapter observes a path and the moment it uses one, and nothing
|
|
23
|
+
* available on the shipped runtimes closes that window without a native
|
|
24
|
+
* dependency. What is contained is the document's own children — the case a
|
|
25
|
+
* document controls — because resolution is deferred until after they run.
|
|
26
|
+
*
|
|
27
|
+
* ## Writes
|
|
28
|
+
*
|
|
29
|
+
* A write goes through a sibling temporary file and a rename. The rename is the
|
|
30
|
+
* commit point: everything before it can fail or be cancelled with the previous
|
|
31
|
+
* file untouched, and once it begins the target holds the complete old file or
|
|
32
|
+
* the complete new one, never a partial write. It is a commit rather than a
|
|
33
|
+
* transaction — a rename that returned is not undone by a later cancellation.
|
|
34
|
+
* The temporary also closes the one hole resolution cannot: a dangling symlink
|
|
35
|
+
* has nothing to resolve, and `rename` replaces the link rather than following
|
|
36
|
+
* it wherever it points.
|
|
37
|
+
*
|
|
38
|
+
* ## What crosses the boundary
|
|
39
|
+
*
|
|
40
|
+
* Nothing from a caught platform error. An errno code *selects* a
|
|
41
|
+
* `FilesReason`, and the reason is all the consumer receives — no message, no
|
|
42
|
+
* code, no resolved path, no temporary path, and no symlink target. A platform
|
|
43
|
+
* error names the path it failed on, and for a write that path can be a
|
|
44
|
+
* temporary the document never chose.
|
|
45
|
+
*/
|
|
46
|
+
import { ensure, Err, Ok, resource, scoped, until } from "effection";
|
|
47
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
48
|
+
import { randomUUID } from "node:crypto";
|
|
49
|
+
import { mkdtempSync } from "node:fs";
|
|
50
|
+
import { realpath } from "node:fs/promises";
|
|
51
|
+
import { tmpdir } from "node:os";
|
|
52
|
+
import { FsApi, rm } from "@effectionx/fs";
|
|
53
|
+
import { API } from "./apis.js";
|
|
54
|
+
import { fileWriteFailure, fileWriteSuccess, filesFailure, FilesInvariantError } from "./files.js";
|
|
55
|
+
/**
|
|
56
|
+
* The errno codes this adapter recognizes, and the reason each selects.
|
|
57
|
+
*
|
|
58
|
+
* A `Map` rather than an object literal, because a lookup on one answers for
|
|
59
|
+
* inherited keys — `codes["toString"]` would hand back a function — and the code
|
|
60
|
+
* is chosen by whatever implements `API.Fs`.
|
|
61
|
+
*/
|
|
62
|
+
const REASON_BY_CODE = new Map([
|
|
63
|
+
["ENOENT", "missing"],
|
|
64
|
+
["ENOTDIR", "not-directory"],
|
|
65
|
+
["EISDIR", "directory"],
|
|
66
|
+
["ENOTEMPTY", "directory-not-empty"],
|
|
67
|
+
["EACCES", "permission-denied"],
|
|
68
|
+
["EPERM", "permission-denied"],
|
|
69
|
+
["EROFS", "read-only"],
|
|
70
|
+
["ELOOP", "too-many-symlinks"],
|
|
71
|
+
["ENAMETOOLONG", "path-too-long"],
|
|
72
|
+
["ENOSPC", "no-space"],
|
|
73
|
+
["EDQUOT", "quota-exhausted"],
|
|
74
|
+
["EXDEV", "cross-device"],
|
|
75
|
+
["EBUSY", "busy"],
|
|
76
|
+
["EMFILE", "too-many-open-files"],
|
|
77
|
+
]);
|
|
78
|
+
/**
|
|
79
|
+
* The `errno` string a failed call carries, when it carries one.
|
|
80
|
+
*
|
|
81
|
+
* Read rather than asserted: `catch` gives back `unknown`, and what arrives
|
|
82
|
+
* there is only conventionally an `ErrnoException`.
|
|
83
|
+
*/
|
|
84
|
+
function errorCode(error) {
|
|
85
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const { code } = error;
|
|
89
|
+
return typeof code === "string" ? code : undefined;
|
|
90
|
+
}
|
|
91
|
+
/** The reason a caught platform error selects, defaulting to the generic one. */
|
|
92
|
+
function reasonOf(error) {
|
|
93
|
+
const code = errorCode(error);
|
|
94
|
+
if (code === undefined) {
|
|
95
|
+
return "operation-failed";
|
|
96
|
+
}
|
|
97
|
+
return REASON_BY_CODE.get(code) ?? "operation-failed";
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Why an authored path is inadmissible, decided from arithmetic alone.
|
|
101
|
+
*
|
|
102
|
+
* `resolve` normalizes `..` lexically, so this holds against the contextual
|
|
103
|
+
* directory as given — canonicalizing it belongs to resolution and would only
|
|
104
|
+
* move the same comparison onto a different pair of strings.
|
|
105
|
+
*/
|
|
106
|
+
function inadmissible(input) {
|
|
107
|
+
if (input.path.length === 0) {
|
|
108
|
+
return "empty-path";
|
|
109
|
+
}
|
|
110
|
+
if (isAbsolute(input.path)) {
|
|
111
|
+
return "absolute-path";
|
|
112
|
+
}
|
|
113
|
+
if (!within(input.cwd, resolve(input.cwd, input.path))) {
|
|
114
|
+
return "lexical-escape";
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Whether `path` names `base` or something inside it.
|
|
120
|
+
*
|
|
121
|
+
* The directory itself is contained — `.` is not an escape. That it is a
|
|
122
|
+
* directory is a question about the target, which the target check answers.
|
|
123
|
+
*
|
|
124
|
+
* Only a complete `..` segment leaves. A name that merely starts with two dots
|
|
125
|
+
* — `..notes.md` — is an ordinary file inside, and a prefix test would refuse it.
|
|
126
|
+
*/
|
|
127
|
+
function within(base, path) {
|
|
128
|
+
const rel = relative(base, path);
|
|
129
|
+
if (isAbsolute(rel)) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
if (rel.length === 0) {
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
return rel !== ".." && !rel.startsWith(`..${sep}`);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* `path` with every symlink in its existing prefix resolved.
|
|
139
|
+
*
|
|
140
|
+
* `realpath` needs the whole path to exist, and a write commonly names one that
|
|
141
|
+
* does not yet, so the walk gives up one trailing segment at a time until
|
|
142
|
+
* something answers and then puts the segments back. The working directory
|
|
143
|
+
* always exists, so the loop terminates there at the latest.
|
|
144
|
+
*/
|
|
145
|
+
function* resolveExisting(path) {
|
|
146
|
+
const trailing = [];
|
|
147
|
+
let current = path;
|
|
148
|
+
while (true) {
|
|
149
|
+
const resolved = yield* API.Fs.operations.realpath(current);
|
|
150
|
+
if (resolved !== undefined) {
|
|
151
|
+
return trailing.length === 0 ? resolved : join(resolved, ...trailing);
|
|
152
|
+
}
|
|
153
|
+
const parent = dirname(current);
|
|
154
|
+
if (parent === current) {
|
|
155
|
+
return join(current, ...trailing);
|
|
156
|
+
}
|
|
157
|
+
trailing.unshift(basename(current));
|
|
158
|
+
current = parent;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The path as the filesystem currently has it.
|
|
163
|
+
*
|
|
164
|
+
* Resolves the part of the path that is already on disk — the file itself when
|
|
165
|
+
* it is there, the deepest existing ancestor when it is not — and re-checks the
|
|
166
|
+
* result, which is what catches a symlink pointing out. What comes back is that
|
|
167
|
+
* resolved path, so an internal symlink is followed to the file it names rather
|
|
168
|
+
* than replaced.
|
|
169
|
+
*
|
|
170
|
+
* Both sides of the comparison are canonical, so a working directory reached
|
|
171
|
+
* through a symlink — macOS's `/var` against `/private/var` — does not read as
|
|
172
|
+
* an escape.
|
|
173
|
+
*/
|
|
174
|
+
function* destination(input) {
|
|
175
|
+
try {
|
|
176
|
+
const base = (yield* API.Fs.operations.realpath(input.cwd)) ?? input.cwd;
|
|
177
|
+
const path = yield* resolveExisting(resolve(input.cwd, input.path));
|
|
178
|
+
if (!within(base, path)) {
|
|
179
|
+
return { reason: "resolved-escape" };
|
|
180
|
+
}
|
|
181
|
+
return { path };
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
return { reason: reasonOf(error) };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function nonWriteFailure(operation, phase, reason) {
|
|
188
|
+
return Err(filesFailure({ operation, phase, reason }));
|
|
189
|
+
}
|
|
190
|
+
function writeFailure(input) {
|
|
191
|
+
return Err(fileWriteFailure(input));
|
|
192
|
+
}
|
|
193
|
+
function notify(observe, event) {
|
|
194
|
+
observe?.(event);
|
|
195
|
+
}
|
|
196
|
+
/** Code point order: what a document branches on must not depend on a locale. */
|
|
197
|
+
function byCodePoint(left, right) {
|
|
198
|
+
if (left < right) {
|
|
199
|
+
return -1;
|
|
200
|
+
}
|
|
201
|
+
if (left > right) {
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Build a host provider.
|
|
208
|
+
*
|
|
209
|
+
* Exported so a test can drive one operation directly; entrypoints install it
|
|
210
|
+
* with {@link useHostFiles}.
|
|
211
|
+
*/
|
|
212
|
+
export function hostFilesHandler(options = {}) {
|
|
213
|
+
const observe = options.observe;
|
|
214
|
+
function* checkFilePath(input) {
|
|
215
|
+
const reason = inadmissible(input);
|
|
216
|
+
if (reason !== undefined) {
|
|
217
|
+
return nonWriteFailure("check-file-path", "lexical", reason);
|
|
218
|
+
}
|
|
219
|
+
return Ok(undefined);
|
|
220
|
+
}
|
|
221
|
+
function* readTextFile(input) {
|
|
222
|
+
const lexical = inadmissible(input);
|
|
223
|
+
if (lexical !== undefined) {
|
|
224
|
+
return nonWriteFailure("read", "lexical", lexical);
|
|
225
|
+
}
|
|
226
|
+
const target = yield* destination(input);
|
|
227
|
+
if ("reason" in target) {
|
|
228
|
+
return nonWriteFailure("read", "resolution", target.reason);
|
|
229
|
+
}
|
|
230
|
+
notify(observe, { operation: "read", phase: "target" });
|
|
231
|
+
try {
|
|
232
|
+
const info = yield* API.Fs.operations.stat(target.path);
|
|
233
|
+
if (!info.exists) {
|
|
234
|
+
return nonWriteFailure("read", "target", "missing");
|
|
235
|
+
}
|
|
236
|
+
if (info.isDirectory) {
|
|
237
|
+
return nonWriteFailure("read", "target", "directory");
|
|
238
|
+
}
|
|
239
|
+
if (!info.isFile) {
|
|
240
|
+
return nonWriteFailure("read", "target", "special-file");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
return nonWriteFailure("read", "target", reasonOf(error));
|
|
245
|
+
}
|
|
246
|
+
notify(observe, { operation: "read", phase: "access" });
|
|
247
|
+
try {
|
|
248
|
+
return Ok(yield* API.Fs.operations.readTextFile(target.path));
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
return nonWriteFailure("read", "access", reasonOf(error));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Replace the target with exactly `content`.
|
|
256
|
+
*
|
|
257
|
+
* Admission is repeated here from the authored path and contextual directory
|
|
258
|
+
* rather than carried over from `checkFilePath`. The check answers whether
|
|
259
|
+
* children may expand; a child can change what a path means, and a
|
|
260
|
+
* destination resolved before they ran would not be the one this write lands
|
|
261
|
+
* on.
|
|
262
|
+
*
|
|
263
|
+
* Removal of the temporary is registered before it is written rather than
|
|
264
|
+
* after. The write is where an interruption is most likely to land, and a
|
|
265
|
+
* cleanup installed on the far side of it would not run for the one failure it
|
|
266
|
+
* exists to handle. `remove` is forced, so registering it for a file that was
|
|
267
|
+
* never created — or one the rename has already consumed — is a no-op.
|
|
268
|
+
*
|
|
269
|
+
* Both halves are collected rather than thrown. A destructor that threw would
|
|
270
|
+
* replace the failure it is unwinding, and a write's own failure must not hide
|
|
271
|
+
* the fact that a temporary was left behind.
|
|
272
|
+
*/
|
|
273
|
+
function* writeTextFile(input) {
|
|
274
|
+
const lexical = inadmissible(input);
|
|
275
|
+
if (lexical !== undefined) {
|
|
276
|
+
return writeFailure({ phase: "lexical", reason: lexical });
|
|
277
|
+
}
|
|
278
|
+
const target = yield* destination(input);
|
|
279
|
+
if ("reason" in target) {
|
|
280
|
+
return writeFailure({ phase: "resolution", reason: target.reason });
|
|
281
|
+
}
|
|
282
|
+
notify(observe, { operation: "write", phase: "target" });
|
|
283
|
+
try {
|
|
284
|
+
const info = yield* API.Fs.operations.stat(target.path);
|
|
285
|
+
if (info.exists && !info.isFile) {
|
|
286
|
+
return writeFailure({
|
|
287
|
+
phase: "target",
|
|
288
|
+
reason: info.isDirectory ? "directory" : "special-file",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
return writeFailure({ phase: "target", reason: reasonOf(error) });
|
|
294
|
+
}
|
|
295
|
+
notify(observe, { operation: "write", phase: "parents" });
|
|
296
|
+
try {
|
|
297
|
+
yield* API.Fs.operations.ensureDir(dirname(target.path));
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
return writeFailure({ phase: "parents", reason: reasonOf(error) });
|
|
301
|
+
}
|
|
302
|
+
let failed;
|
|
303
|
+
let cleanup;
|
|
304
|
+
// Which step the write reached, which is what decides what may be said
|
|
305
|
+
// about the target: everything before the rename leaves the previous file
|
|
306
|
+
// in place, and a rename that threw may have run or not.
|
|
307
|
+
let step = "temporary";
|
|
308
|
+
// Whether the write reached its own end. Cleanup runs on every exit, and
|
|
309
|
+
// the two exits need different answers: one has a Result to compose with
|
|
310
|
+
// and the other does not.
|
|
311
|
+
let settled = false;
|
|
312
|
+
yield* scoped(function* () {
|
|
313
|
+
const temporary = `${target.path}.xmd-${randomUUID().slice(0, 8)}.tmp`;
|
|
314
|
+
yield* ensure(function* () {
|
|
315
|
+
notify(observe, { operation: "write", phase: "cleanup" });
|
|
316
|
+
try {
|
|
317
|
+
yield* API.Fs.operations.remove(temporary, { force: true });
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
if (settled) {
|
|
321
|
+
cleanup = reasonOf(error);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// Cancellation is unwinding, so there is no outcome to report this
|
|
325
|
+
// beside — and manufacturing one would turn a halt into a write
|
|
326
|
+
// result. It leaves the scope as an infrastructure failure instead,
|
|
327
|
+
// carrying neither the platform's error nor the generated temporary's
|
|
328
|
+
// name, and the engine's fatal discovery finds it there.
|
|
329
|
+
throw new FilesInvariantError("teardown");
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
try {
|
|
333
|
+
notify(observe, { operation: "write", phase: "temporary" });
|
|
334
|
+
yield* API.Fs.operations.writeTextFile(temporary, input.content);
|
|
335
|
+
step = "commit";
|
|
336
|
+
notify(observe, { operation: "write", phase: "commit" });
|
|
337
|
+
yield* API.Fs.operations.rename(temporary, target.path);
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
failed = reasonOf(error);
|
|
341
|
+
}
|
|
342
|
+
settled = true;
|
|
343
|
+
});
|
|
344
|
+
if (failed !== undefined) {
|
|
345
|
+
return writeFailure({ phase: step, reason: failed, cleanup });
|
|
346
|
+
}
|
|
347
|
+
if (cleanup !== undefined) {
|
|
348
|
+
return writeFailure({ phase: "cleanup", cleanup });
|
|
349
|
+
}
|
|
350
|
+
return Ok(fileWriteSuccess("host-committed"));
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The regular files under `cwd` that `include` selects and `exclude` does not.
|
|
354
|
+
*
|
|
355
|
+
* Traversal is `API.Fs`'s: it reports directories and symbolic links too, and
|
|
356
|
+
* never follows one, which is what keeps the walk inside `cwd` and free of
|
|
357
|
+
* cycles. What this adds is the document-facing shape — regular files only,
|
|
358
|
+
* deduplicated, and sorted, so a document that branches on a listing branches
|
|
359
|
+
* the same way on every host.
|
|
360
|
+
*/
|
|
361
|
+
function* globFiles(input) {
|
|
362
|
+
try {
|
|
363
|
+
const info = yield* API.Fs.operations.stat(input.cwd);
|
|
364
|
+
if (!info.exists) {
|
|
365
|
+
return nonWriteFailure("glob", "target", "missing");
|
|
366
|
+
}
|
|
367
|
+
if (!info.isDirectory) {
|
|
368
|
+
return nonWriteFailure("glob", "target", "not-directory");
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
catch (error) {
|
|
372
|
+
return nonWriteFailure("glob", "target", reasonOf(error));
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const matched = yield* traverse(input, observe);
|
|
376
|
+
const files = matched.filter((entry) => entry.isFile).map((entry) => entry.path);
|
|
377
|
+
return Ok([...new Set(files)].sort(byCodePoint));
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
// The Api compiles patterns as it starts, so an unusable one — an
|
|
381
|
+
// unterminated character class — arrives as a `SyntaxError` from `RegExp`
|
|
382
|
+
// rather than as an errno. It is the one failure here a document can fix
|
|
383
|
+
// by editing what it wrote.
|
|
384
|
+
if (error instanceof SyntaxError) {
|
|
385
|
+
return nonWriteFailure("glob", "pattern", "invalid-pattern");
|
|
386
|
+
}
|
|
387
|
+
return nonWriteFailure("glob", "traversal", reasonOf(error));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* A directory this call created, named by its canonical path.
|
|
392
|
+
*
|
|
393
|
+
* Creation is synchronous so that nothing can suspend between it and the
|
|
394
|
+
* `ensure` that removes it. `until()` cannot cancel the promise it is waiting
|
|
395
|
+
* on, so an asynchronous `mkdtemp` halted mid-flight would go on to create a
|
|
396
|
+
* directory after the generator had already stopped — one nothing owns and
|
|
397
|
+
* nothing removes. `mkdtemp` names and creates at once, so the directory is
|
|
398
|
+
* never one an earlier run left behind.
|
|
399
|
+
*
|
|
400
|
+
* Everything after that is ordinary work and suspends. The path is
|
|
401
|
+
* canonicalized: on macOS `tmpdir()` is a symlink (`/var/folders/…`) while a
|
|
402
|
+
* child process resolves it (`/private/var/…`), and canonicalizing is what
|
|
403
|
+
* makes the rendered path, the contextual directory, and a subprocess's own
|
|
404
|
+
* `cwd` the same string. Cleanup is already registered by then, so a halt
|
|
405
|
+
* during it still takes the directory away.
|
|
406
|
+
*/
|
|
407
|
+
function temporaryDirectory() {
|
|
408
|
+
return resource(function* (provide) {
|
|
409
|
+
let created;
|
|
410
|
+
try {
|
|
411
|
+
// oxlint-disable-next-line local/no-sync-filesystem
|
|
412
|
+
created = mkdtempSync(join(tmpdir(), "xmd-tempdir-"));
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
yield* provide(nonWriteFailure("temporary-directory", "acquire", reasonOf(error)));
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
yield* ensure(() => discard(created));
|
|
419
|
+
let canonical;
|
|
420
|
+
try {
|
|
421
|
+
canonical = yield* until(realpath(created));
|
|
422
|
+
}
|
|
423
|
+
catch (error) {
|
|
424
|
+
yield* provide(nonWriteFailure("temporary-directory", "acquire", reasonOf(error)));
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
yield* provide(Ok(canonical));
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return { checkFilePath, readTextFile, writeTextFile, globFiles, temporaryDirectory };
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Remove a temporary directory as its scope ends.
|
|
434
|
+
*
|
|
435
|
+
* A removal that fails during teardown is reported as an invariant rather than
|
|
436
|
+
* passed along: the failure it would otherwise carry names the generated
|
|
437
|
+
* directory, which the document never chose, and it can be unwinding a failure
|
|
438
|
+
* of its own that must not be replaced by platform text.
|
|
439
|
+
*/
|
|
440
|
+
function* discard(directory) {
|
|
441
|
+
try {
|
|
442
|
+
yield* rm(directory, { recursive: true, force: true });
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
throw new FilesInvariantError("teardown");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Run the traversal, announcing each directory read when an observer is watching.
|
|
450
|
+
*
|
|
451
|
+
* The announcement is installed as `API.Fs` middleware for the duration of this
|
|
452
|
+
* one call rather than left in place, so an observer sees the reads this glob
|
|
453
|
+
* performs and nothing else.
|
|
454
|
+
*/
|
|
455
|
+
function traverse(input, observe) {
|
|
456
|
+
const search = { patterns: input.include, root: input.cwd, exclude: input.exclude };
|
|
457
|
+
if (observe === undefined) {
|
|
458
|
+
return API.Fs.operations.glob(search);
|
|
459
|
+
}
|
|
460
|
+
return scoped(function* () {
|
|
461
|
+
yield* FsApi.around({
|
|
462
|
+
*readdirDirents([directory], next) {
|
|
463
|
+
observe({ operation: "glob", phase: "read-dir" });
|
|
464
|
+
return yield* next(directory);
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
return yield* API.Fs.operations.glob(search);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Install the host provider beneath ordinary middleware.
|
|
472
|
+
*
|
|
473
|
+
* `at: "min"` is what lets a host wrap document filesystem access without
|
|
474
|
+
* replacing it — middleware installed later sees these operations and can
|
|
475
|
+
* delegate to them.
|
|
476
|
+
*/
|
|
477
|
+
export function useHostFiles(options = {}) {
|
|
478
|
+
const handler = hostFilesHandler(options);
|
|
479
|
+
return API.Files.around({
|
|
480
|
+
*checkFilePath([input]) {
|
|
481
|
+
return yield* handler.checkFilePath(input);
|
|
482
|
+
},
|
|
483
|
+
*readTextFile([input]) {
|
|
484
|
+
return yield* handler.readTextFile(input);
|
|
485
|
+
},
|
|
486
|
+
*writeTextFile([input]) {
|
|
487
|
+
return yield* handler.writeTextFile(input);
|
|
488
|
+
},
|
|
489
|
+
*globFiles([input]) {
|
|
490
|
+
return yield* handler.globFiles(input);
|
|
491
|
+
},
|
|
492
|
+
*temporaryDirectory() {
|
|
493
|
+
return yield* handler.temporaryDirectory();
|
|
494
|
+
},
|
|
495
|
+
}, { at: "min" });
|
|
496
|
+
}
|
package/esm/mod.js
CHANGED
|
@@ -5,18 +5,26 @@
|
|
|
5
5
|
* `API` is available for middleware (`.around()`).
|
|
6
6
|
* For normal calls, import operations directly.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* Seven domain APIs:
|
|
9
9
|
* - `API.Process` — subprocess execution (`exec`)
|
|
10
|
-
* - `API.Fs` — filesystem (`readTextFile`, `
|
|
10
|
+
* - `API.Fs` — the low-level host filesystem (`readTextFile`, `writeTextFile`,
|
|
11
|
+
* `stat`, `glob`, `realpath`, `ensureDir`, `rename`, `remove`)
|
|
12
|
+
* - `API.Files` — document filesystem access as whole semantic operations,
|
|
13
|
+
* with no host default. `useHostFiles()` installs the host provider.
|
|
11
14
|
* - `API.Fetch` — HTTP requests (`fetch`)
|
|
12
|
-
* - `API.Env` —
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* - `API.Env` — the host: variables, platform info, the command that invokes
|
|
16
|
+
* this xmd, and eval-block compilation
|
|
17
|
+
* (`cwd`, `env`, `platform`, `command`, `compile`)
|
|
18
|
+
* - `API.Service` — scoped attached service startup (`startService`)
|
|
19
|
+
* - `Config` — shared execution config (`timeout`, `timeoutExec`, `timeoutFetch`)
|
|
15
20
|
*
|
|
16
21
|
* See `apis.ts` for architecture rationale.
|
|
17
22
|
* See `@executablemd/runtime/test` for composable test stubs.
|
|
18
23
|
*/
|
|
19
24
|
export { API } from "./apis.js";
|
|
20
|
-
export { exec, readTextFile, stat, glob, fetch, cwd, env, platform, compile } from "./apis.js";
|
|
21
|
-
export {
|
|
22
|
-
export { Config, timeout } from "./config.js";
|
|
25
|
+
export { exec, readTextFile, writeTextFile, stat, glob, realpath, ensureDir, rename, remove, fetch, cwd, env, platform, command, compile, useQuietProcessOutput, } from "./apis.js";
|
|
26
|
+
export { Service, SERVICE_HOSTNAME, SERVICE_READY_PREFIX, ServiceProcessExitBeforeReadyError, ServiceProtocolDuplicateError, ServiceProtocolHostnameMismatchError, ServiceProtocolIncompatibleError, ServiceProtocolMalformedError, ServiceProtocolTokenMismatchError, ServiceProviderError, ServiceStartupTimeoutError, ServiceTeardownError, ServiceUnexpectedExitError, parseServiceReadyRecord, startService, } from "./service.js";
|
|
27
|
+
export { Config, timeout, timeoutExec, timeoutFetch } from "./config.js";
|
|
28
|
+
export { asDuration, durationError, parseDuration } from "./duration.js";
|
|
29
|
+
export { asFilesFatal, FILES_ERROR, FILES_ERROR_MESSAGE, FILES_FATAL, FILES_INVARIANT_MESSAGE, FILES_OPERATION_DENIED_MESSAGE, FILES_PROVIDER_UNAVAILABLE_MESSAGE, FILES_WRITE_SUCCESS, Files, FilesError, FilesInvariantError, FilesOperationDeniedError, FilesProviderUnavailableError, fileWriteFailure, fileWriteSuccess, filesFailure, isFilesFatal, parseFilesPhase, parseFilesReason, parseFileWriteFailure, parseFileWritePhase, parseFileWriteSuccess, parseFilesFailure, parseFilesFatal, } from "./files.js";
|
|
30
|
+
export { hostFilesHandler, useHostFiles } from "./host-files.js";
|
package/esm/service.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-neutral attached-service lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* The shared runtime owns the XMD service handshake shape and validation. A
|
|
5
|
+
* runtime-named host adapter supplies process startup through `API.Service`
|
|
6
|
+
* middleware.
|
|
7
|
+
*/
|
|
8
|
+
import { createApi } from "@effectionx/context-api";
|
|
9
|
+
export const SERVICE_READY_PREFIX = "XMD_SERVICE_READY:";
|
|
10
|
+
export const SERVICE_HOSTNAME = "127.0.0.1";
|
|
11
|
+
export class ServiceProviderError extends Error {
|
|
12
|
+
name = "ServiceProviderError";
|
|
13
|
+
constructor() {
|
|
14
|
+
super("attached service startup requires a host provider; install runtime.service middleware before execution");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export class ServiceProtocolMalformedError extends Error {
|
|
18
|
+
name = "ServiceProtocolMalformedError";
|
|
19
|
+
constructor() {
|
|
20
|
+
super("attached service emitted a malformed XMD service handshake record");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export class ServiceProtocolIncompatibleError extends Error {
|
|
24
|
+
name = "ServiceProtocolIncompatibleError";
|
|
25
|
+
constructor() {
|
|
26
|
+
super("attached service emitted an incompatible XMD service handshake record");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class ServiceProtocolTokenMismatchError extends Error {
|
|
30
|
+
name = "ServiceProtocolTokenMismatchError";
|
|
31
|
+
constructor() {
|
|
32
|
+
super("XMD service handshake authentication failed");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export class ServiceProtocolHostnameMismatchError extends Error {
|
|
36
|
+
name = "ServiceProtocolHostnameMismatchError";
|
|
37
|
+
constructor() {
|
|
38
|
+
super("XMD service handshake hostname is not authorized");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class ServiceProtocolDuplicateError extends Error {
|
|
42
|
+
name = "ServiceProtocolDuplicateError";
|
|
43
|
+
constructor() {
|
|
44
|
+
super("attached service emitted more than one XMD service handshake record");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export class ServiceStartupTimeoutError extends Error {
|
|
48
|
+
name = "ServiceStartupTimeoutError";
|
|
49
|
+
constructor(timeout) {
|
|
50
|
+
super(`attached service handshake did not complete within ${timeout}ms`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function exitDescription(status) {
|
|
54
|
+
if (status.signal !== undefined) {
|
|
55
|
+
return `signal ${status.signal}`;
|
|
56
|
+
}
|
|
57
|
+
if (status.code !== undefined) {
|
|
58
|
+
return `exit code ${status.code}`;
|
|
59
|
+
}
|
|
60
|
+
return "an unknown exit status";
|
|
61
|
+
}
|
|
62
|
+
export class ServiceProcessExitBeforeReadyError extends Error {
|
|
63
|
+
name = "ServiceProcessExitBeforeReadyError";
|
|
64
|
+
constructor(status) {
|
|
65
|
+
super(`attached service process exited before handshake with ${exitDescription(status)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export class ServiceUnexpectedExitError extends Error {
|
|
69
|
+
name = "ServiceUnexpectedExitError";
|
|
70
|
+
constructor(status) {
|
|
71
|
+
super(`attached service process exited after handshake with ${exitDescription(status)}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export class ServiceTeardownError extends Error {
|
|
75
|
+
name = "ServiceTeardownError";
|
|
76
|
+
constructor(options) {
|
|
77
|
+
super("attached service process failed to terminate cleanly", options);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function isRecord(value) {
|
|
81
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
82
|
+
}
|
|
83
|
+
function hasExactMembers(record) {
|
|
84
|
+
const members = Object.keys(record);
|
|
85
|
+
return (members.length === 4 &&
|
|
86
|
+
members.includes("version") &&
|
|
87
|
+
members.includes("token") &&
|
|
88
|
+
members.includes("hostname") &&
|
|
89
|
+
members.includes("port"));
|
|
90
|
+
}
|
|
91
|
+
/** Parse and authenticate one prefix-stripped v1 handshake payload. */
|
|
92
|
+
export function parseServiceReadyRecord(payload, expectedToken) {
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(payload);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new ServiceProtocolMalformedError();
|
|
99
|
+
}
|
|
100
|
+
if (!isRecord(parsed) || !hasExactMembers(parsed)) {
|
|
101
|
+
throw new ServiceProtocolMalformedError();
|
|
102
|
+
}
|
|
103
|
+
if (parsed.version !== 1) {
|
|
104
|
+
throw new ServiceProtocolIncompatibleError();
|
|
105
|
+
}
|
|
106
|
+
if (typeof parsed.token !== "string" || parsed.token !== expectedToken) {
|
|
107
|
+
throw new ServiceProtocolTokenMismatchError();
|
|
108
|
+
}
|
|
109
|
+
if (parsed.hostname !== SERVICE_HOSTNAME) {
|
|
110
|
+
throw new ServiceProtocolHostnameMismatchError();
|
|
111
|
+
}
|
|
112
|
+
if (typeof parsed.port !== "number" ||
|
|
113
|
+
!Number.isInteger(parsed.port) ||
|
|
114
|
+
parsed.port < 1 ||
|
|
115
|
+
parsed.port > 65_535) {
|
|
116
|
+
throw new ServiceProtocolMalformedError();
|
|
117
|
+
}
|
|
118
|
+
return Object.freeze({ hostname: SERVICE_HOSTNAME, port: parsed.port });
|
|
119
|
+
}
|
|
120
|
+
export const Service = createApi("runtime.service", {
|
|
121
|
+
// deno-lint-ignore require-yield
|
|
122
|
+
*start(_options) {
|
|
123
|
+
throw new ServiceProviderError();
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
export const startService = Service.operations.start;
|
package/esm/test/mod.js
CHANGED
|
@@ -6,5 +6,6 @@
|
|
|
6
6
|
* - `useStubFs(files)` — in-memory filesystem
|
|
7
7
|
* - `useEchoExec()` — simple echo-based exec
|
|
8
8
|
* - `useFailingExec(exitCode, stderr)` — always-failing exec
|
|
9
|
+
* - `useStubService(endpoint)` — scoped provider-neutral service attachment
|
|
9
10
|
*/
|
|
10
|
-
export { useStubFs, useEchoExec, useFailingExec } from "./stubs.js";
|
|
11
|
+
export { useStubFs, useEchoExec, useFailingExec, useStubService } from "./stubs.js";
|