@executablemd/runtime 0.7.0 → 0.8.1

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.
@@ -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,20 +5,26 @@
5
5
  * `API` is available for middleware (`.around()`).
6
6
  * For normal calls, import operations directly.
7
7
  *
8
- * Six domain APIs:
8
+ * Seven domain APIs:
9
9
  * - `API.Process` — subprocess execution (`exec`)
10
- * - `API.Fs` — filesystem (`readTextFile`, `writeTextFile`, `stat`, `glob`,
11
- * `realpath`, `ensureDir`, `rename`, `remove`)
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.
12
14
  * - `API.Fetch` — HTTP requests (`fetch`)
13
15
  * - `API.Env` — the host: variables, platform info, the command that invokes
14
16
  * this xmd, and eval-block compilation
15
17
  * (`cwd`, `env`, `platform`, `command`, `compile`)
16
- * - `Config` — shared execution config (`timeout`)
18
+ * - `API.Service` — scoped attached service startup (`startService`)
19
+ * - `Config` — shared execution config (`timeout`, `timeoutExec`, `timeoutFetch`)
17
20
  *
18
21
  * See `apis.ts` for architecture rationale.
19
22
  * See `@executablemd/runtime/test` for composable test stubs.
20
23
  */
21
24
  export { API } from "./apis.js";
22
- export { exec, readTextFile, writeTextFile, stat, glob, realpath, ensureDir, rename, remove, fetch, cwd, env, platform, command, compile, } from "./apis.js";
23
- export { findFreePort } from "./find-free-port.js";
24
- 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";