@crvouga/mockingbird-service-junction 1.8.1 → 1.9.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/CHANGELOG.md +27 -0
- package/README.md +15 -0
- package/dist/{chunk-HTA5IZVB.js → chunk-S4CJ5QWE.js} +497 -30
- package/dist/chunk-S4CJ5QWE.js.map +7 -0
- package/dist/{chunk-GDU23FW6.js → chunk-WL4WPDHG.js} +300 -67
- package/dist/chunk-WL4WPDHG.js.map +7 -0
- package/dist/cli.js +2 -2
- package/dist/index.d.ts +32 -3
- package/dist/index.js +1 -1
- package/dist/server.d.ts +62 -4
- package/dist/server.js +2 -2
- package/package.json +1 -1
- package/dist/chunk-GDU23FW6.js.map +0 -7
- package/dist/chunk-HTA5IZVB.js.map +0 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# Changelog — @crvouga/mockingbird-service-junction
|
|
2
2
|
|
|
3
|
+
## 1.9.0 (2026-10-02)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- coordinate HTTP and protocol mocks ([3168653](https://github.com/crvouga/mockingbird/commit/31686534c55a9823fdaa92afc9cc456cf741b809))
|
|
8
|
+
|
|
9
|
+
### Fixes and improvements
|
|
10
|
+
|
|
11
|
+
- decouple smoke types from built packages ([a4b31c6](https://github.com/crvouga/mockingbird/commit/a4b31c6205d92aa0c6f7c9de3518d5ae44d38829))
|
|
12
|
+
- protect child admin endpoints ([5410b94](https://github.com/crvouga/mockingbird/commit/5410b948a30dcedc21e9a70e44fd415c1ea25394))
|
|
13
|
+
- harden protocol startup and state ([6f5b557](https://github.com/crvouga/mockingbird/commit/6f5b557e87b48b31985949e04f3b5ef58f51d295))
|
|
14
|
+
- preserve checkpoints and lifecycle ([50e8c32](https://github.com/crvouga/mockingbird/commit/50e8c322feded2f7f45afce9d8cdef43c37ab9ab))
|
|
15
|
+
- clean branch state on reset ([3b714d4](https://github.com/crvouga/mockingbird/commit/3b714d4a5df8e75fee0bb31e98d7c7566e164381))
|
|
16
|
+
- type signal listener cleanup ([5279852](https://github.com/crvouga/mockingbird/commit/5279852e56ea60262ccf17aa6e46b3c31091a34a))
|
|
17
|
+
|
|
18
|
+
## 1.8.2 (2026-10-01)
|
|
19
|
+
|
|
20
|
+
### Fixes and improvements
|
|
21
|
+
|
|
22
|
+
- move lifecycle notes out of generated support ([defb7a4](https://github.com/crvouga/mockingbird/commit/defb7a4ca917fff29fdb3b05e57ff207d1691ef8))
|
|
23
|
+
- complete simulated testkits asynchronously ([681fac0](https://github.com/crvouga/mockingbird/commit/681fac0431480e93b6e30b3dd3421f3baaa67f94))
|
|
24
|
+
- align appointment and cancelled testkit parity ([f2f9cba](https://github.com/crvouga/mockingbird/commit/f2f9cba02b147f8bd60ee9c142fff8dc57612caa))
|
|
25
|
+
- validate collection method before cancellation ([2e0016b](https://github.com/crvouga/mockingbird/commit/2e0016bb4ec8eaf130e1abf17d0c07a86c3a4e18))
|
|
26
|
+
- match sandbox booking lifecycle and cleanup ([461ae8d](https://github.com/crvouga/mockingbird/commit/461ae8d57c5b17efcfd1dd9a6d74be80dcbc54a3))
|
|
27
|
+
- declare observed simulation error response ([20eb7dc](https://github.com/crvouga/mockingbird/commit/20eb7dc11f17baf9891868afb362b92bb764289a))
|
|
28
|
+
- match cancelled testkit simulation failures ([cea5965](https://github.com/crvouga/mockingbird/commit/cea5965b5821854ea0af30b8c7022d5bb1557037))
|
|
29
|
+
|
|
3
30
|
## 1.8.1 (2026-10-01)
|
|
4
31
|
|
|
5
32
|
### Fixes and improvements
|
package/README.md
CHANGED
|
@@ -894,4 +894,19 @@ bun run parity -- --mode=empty # legacy empty-start differenti
|
|
|
894
894
|
Webhook parity needs a public receiver; see
|
|
895
895
|
[docs/webhook-parity.md](https://github.com/crvouga/mockingbird/blob/main/packages/service/junction/docs/webhook-parity.md).
|
|
896
896
|
|
|
897
|
+
## Asynchronous testkit simulation
|
|
898
|
+
|
|
899
|
+
A completion-target simulation on an active testkit advances through delivery and queues
|
|
900
|
+
completion for the next millisecond of the injected `now` clock. This is a deterministic
|
|
901
|
+
mock scheduling boundary, not a claim about Junction's processing latency. Order, result,
|
|
902
|
+
transaction, and order-list reads apply due work to its owning order. A second simulation
|
|
903
|
+
is not required. Results remain unavailable while the testkit is `delivered_to_lab`.
|
|
904
|
+
Cancelled orders never complete from queued simulation work.
|
|
905
|
+
|
|
906
|
+
The parity harness polls order reads after successful immediate completion simulations on
|
|
907
|
+
both targets. Testkit completion must settle within 15 seconds or verification fails;
|
|
908
|
+
explicitly delayed simulations and unsuccessful requests are not treated as settled.
|
|
909
|
+
The USSL testkit result metadata follows the observed sandbox fixture (null specimen dates
|
|
910
|
+
and CLIA `05D2130115`); other laboratories retain their existing metadata behavior.
|
|
911
|
+
|
|
897
912
|
Part of [mockingbird](https://github.com/crvouga/mockingbird) — agent integration guide: [README](https://github.com/crvouga/mockingbird#readme) · [llms.txt](https://github.com/crvouga/mockingbird/blob/main/llms.txt) · [report an issue or request a feature](https://github.com/crvouga/mockingbird/blob/main/docs/REPORTING_ISSUES.md).
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
IDENTITY_MODES,
|
|
3
|
+
createClock,
|
|
3
4
|
createRuntime,
|
|
4
5
|
defaultBillingTypesFrom,
|
|
6
|
+
parseDuration,
|
|
5
7
|
pullCorpus
|
|
6
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-WL4WPDHG.js";
|
|
7
9
|
import {
|
|
8
10
|
defaultCorpus
|
|
9
11
|
} from "./chunk-C2UGIPPM.js";
|
|
@@ -12,12 +14,19 @@ import {
|
|
|
12
14
|
} from "./chunk-ISDNA2TU.js";
|
|
13
15
|
|
|
14
16
|
// src/server.ts
|
|
15
|
-
import { readFile as
|
|
17
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
16
18
|
|
|
17
19
|
// ../../adapters/node/dist/cli.js
|
|
18
|
-
import { readFile } from "node:fs/promises";
|
|
20
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
import { resolve } from "node:path";
|
|
23
|
+
import { pathToFileURL } from "node:url";
|
|
19
24
|
import { parseArgs } from "node:util";
|
|
20
25
|
|
|
26
|
+
// ../../adapters/node/dist/fleet.js
|
|
27
|
+
import { randomUUID } from "node:crypto";
|
|
28
|
+
import { chmod, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
29
|
+
|
|
21
30
|
// ../../adapters/node/dist/serve.js
|
|
22
31
|
import { createServer } from "node:http";
|
|
23
32
|
var serve = async (api, options = {}) => {
|
|
@@ -79,7 +88,7 @@ var serve = async (api, options = {}) => {
|
|
|
79
88
|
if (done)
|
|
80
89
|
break;
|
|
81
90
|
if (!res.write(value))
|
|
82
|
-
await new Promise((
|
|
91
|
+
await new Promise((resolve2) => res.once("drain", resolve2));
|
|
83
92
|
}
|
|
84
93
|
res.end();
|
|
85
94
|
} catch {
|
|
@@ -88,9 +97,9 @@ var serve = async (api, options = {}) => {
|
|
|
88
97
|
reader.releaseLock();
|
|
89
98
|
}
|
|
90
99
|
});
|
|
91
|
-
await new Promise((
|
|
100
|
+
await new Promise((resolve2, reject) => {
|
|
92
101
|
server.once("error", reject);
|
|
93
|
-
server.listen(options.port ?? 0, options.host,
|
|
102
|
+
server.listen(options.port ?? 0, options.host, resolve2);
|
|
94
103
|
});
|
|
95
104
|
return server;
|
|
96
105
|
};
|
|
@@ -107,18 +116,445 @@ var listen = async (api, options = {}) => {
|
|
|
107
116
|
port,
|
|
108
117
|
host,
|
|
109
118
|
server,
|
|
110
|
-
close: () => new Promise((
|
|
119
|
+
close: () => new Promise((resolve2, reject) => {
|
|
111
120
|
server.close((error) => {
|
|
112
121
|
if (error && error.code !== "ERR_SERVER_NOT_RUNNING")
|
|
113
122
|
reject(error);
|
|
114
123
|
else
|
|
115
|
-
|
|
124
|
+
resolve2();
|
|
116
125
|
});
|
|
117
126
|
server.closeAllConnections?.();
|
|
118
127
|
})
|
|
119
128
|
};
|
|
120
129
|
};
|
|
121
130
|
|
|
131
|
+
// ../../adapters/node/dist/fleet.js
|
|
132
|
+
var EMPTY = {
|
|
133
|
+
activeRequests: 0,
|
|
134
|
+
activeConnections: 0,
|
|
135
|
+
blockedCommands: 0,
|
|
136
|
+
pendingJobs: 0,
|
|
137
|
+
pendingWebhooks: 0,
|
|
138
|
+
unmatchedRequests: 0
|
|
139
|
+
};
|
|
140
|
+
var NS = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
141
|
+
var json = (status, value) => Response.json(value, { status });
|
|
142
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
143
|
+
var pending = (d) => d.activeRequests + d.blockedCommands + d.pendingJobs + d.pendingWebhooks;
|
|
144
|
+
var publicUrl = (raw) => {
|
|
145
|
+
const url = new URL(raw);
|
|
146
|
+
url.password = "";
|
|
147
|
+
return url.href;
|
|
148
|
+
};
|
|
149
|
+
var atomicWrite = async (path, value) => {
|
|
150
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
151
|
+
try {
|
|
152
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}
|
|
153
|
+
`, { mode: 384, flag: "wx" });
|
|
154
|
+
await chmod(temp, 384);
|
|
155
|
+
await rename(temp, path);
|
|
156
|
+
} finally {
|
|
157
|
+
await rm(temp, { force: true });
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
var removeOwned = async (path, id) => {
|
|
161
|
+
if (!path)
|
|
162
|
+
return;
|
|
163
|
+
try {
|
|
164
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
165
|
+
if (isObject(value) && value.id === id)
|
|
166
|
+
await rm(path, { force: true });
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error.code !== "ENOENT")
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
var httpChild = async (target, entry, options) => {
|
|
173
|
+
const runtime = await target.create(entry.options ?? {}, {
|
|
174
|
+
adminKey: entry.adminKey,
|
|
175
|
+
seed: entry.seed,
|
|
176
|
+
onLog: options.onLog
|
|
177
|
+
});
|
|
178
|
+
runtime.isolateNamespaces();
|
|
179
|
+
const locked = /* @__PURE__ */ new Set();
|
|
180
|
+
const active = /* @__PURE__ */ new Map();
|
|
181
|
+
const api = {
|
|
182
|
+
async fetch(request) {
|
|
183
|
+
const namespace = runtime.namespaceOf(request);
|
|
184
|
+
if (locked.has(namespace))
|
|
185
|
+
return json(409, { error: "fleet control in progress", namespace });
|
|
186
|
+
active.set(namespace, (active.get(namespace) ?? 0) + 1);
|
|
187
|
+
try {
|
|
188
|
+
return await runtime.fetch(request);
|
|
189
|
+
} finally {
|
|
190
|
+
active.set(namespace, (active.get(namespace) ?? 1) - 1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
let server;
|
|
195
|
+
try {
|
|
196
|
+
server = await listen(api, {
|
|
197
|
+
port: entry.port ?? target.defaultPort,
|
|
198
|
+
host: entry.host ?? "127.0.0.1"
|
|
199
|
+
});
|
|
200
|
+
target.listening?.(server);
|
|
201
|
+
} catch (error) {
|
|
202
|
+
runtime.stop?.();
|
|
203
|
+
await server?.close();
|
|
204
|
+
runtime.webhooks?.clear();
|
|
205
|
+
await runtime.webhooks?.idle();
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
protocol: "http",
|
|
210
|
+
url: server.url,
|
|
211
|
+
healthUrl: `${server.url}/health`,
|
|
212
|
+
adminUrl: `${server.url}/__admin`,
|
|
213
|
+
namespaces: { header: "x-mockingbird-namespace", path: "/ns/{name}" },
|
|
214
|
+
ready: async () => {
|
|
215
|
+
try {
|
|
216
|
+
return (await fetch(`${server.url}/health`, { signal: AbortSignal.timeout(2e3) })).ok;
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
async close() {
|
|
222
|
+
runtime.stop?.();
|
|
223
|
+
runtime.webhooks?.clear();
|
|
224
|
+
await server.close();
|
|
225
|
+
await runtime.webhooks?.idle();
|
|
226
|
+
},
|
|
227
|
+
lock(namespace) {
|
|
228
|
+
if (locked.has(namespace))
|
|
229
|
+
throw new Error("namespace already locked");
|
|
230
|
+
locked.add(namespace);
|
|
231
|
+
return () => locked.delete(namespace);
|
|
232
|
+
},
|
|
233
|
+
diagnostics(namespace) {
|
|
234
|
+
return {
|
|
235
|
+
...EMPTY,
|
|
236
|
+
activeRequests: active.get(namespace) ?? 0,
|
|
237
|
+
pendingWebhooks: runtime.webhooks?.pending(namespace) ?? 0,
|
|
238
|
+
unmatchedRequests: runtime.metrics.report(namespace).unmatched.reduce((n, r) => n + r.count, 0)
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
async checkpoint(namespace) {
|
|
242
|
+
return runtime.fleetSnapshot(namespace);
|
|
243
|
+
},
|
|
244
|
+
async restore(namespace, checkpoint) {
|
|
245
|
+
runtime.fleetRestore(checkpoint, namespace);
|
|
246
|
+
},
|
|
247
|
+
reset: (namespace) => runtime.reset(namespace),
|
|
248
|
+
clock: (namespace) => runtime.namespaceClock(namespace),
|
|
249
|
+
async settle(namespace) {
|
|
250
|
+
const instance = runtime.instance(namespace);
|
|
251
|
+
instance.tick?.();
|
|
252
|
+
await runtime.webhooks?.idle();
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
};
|
|
256
|
+
async function startFleet(config, options) {
|
|
257
|
+
const id = randomUUID();
|
|
258
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
259
|
+
const children = /* @__PURE__ */ new Map();
|
|
260
|
+
const clocks = /* @__PURE__ */ new Map();
|
|
261
|
+
const checkpoints = /* @__PURE__ */ new Map();
|
|
262
|
+
let supervisor;
|
|
263
|
+
let stopped = false;
|
|
264
|
+
let controlling = false;
|
|
265
|
+
const close = async () => {
|
|
266
|
+
if (stopped)
|
|
267
|
+
return;
|
|
268
|
+
stopped = true;
|
|
269
|
+
await Promise.allSettled([...children.values()].map((child) => child.close()));
|
|
270
|
+
await supervisor?.close();
|
|
271
|
+
await removeOwned(options.readyFile, id);
|
|
272
|
+
await removeOwned(options.connectionsFile, id);
|
|
273
|
+
};
|
|
274
|
+
if (options.readyFile)
|
|
275
|
+
await rm(options.readyFile, { force: true });
|
|
276
|
+
if (options.connectionsFile)
|
|
277
|
+
await rm(options.connectionsFile, { force: true });
|
|
278
|
+
const clock = (namespace) => {
|
|
279
|
+
let value = clocks.get(namespace);
|
|
280
|
+
if (!value) {
|
|
281
|
+
value = createClock();
|
|
282
|
+
clocks.set(namespace, value);
|
|
283
|
+
}
|
|
284
|
+
return value;
|
|
285
|
+
};
|
|
286
|
+
const select = (body) => {
|
|
287
|
+
if (body.services === void 0)
|
|
288
|
+
return children;
|
|
289
|
+
if (!Array.isArray(body.services) || body.services.length === 0)
|
|
290
|
+
throw new Error("services must be a nonempty list");
|
|
291
|
+
const selected = /* @__PURE__ */ new Map();
|
|
292
|
+
for (const name of body.services) {
|
|
293
|
+
if (typeof name !== "string" || !children.has(name))
|
|
294
|
+
throw new Error("unknown service in selection");
|
|
295
|
+
selected.set(name, children.get(name));
|
|
296
|
+
}
|
|
297
|
+
return selected;
|
|
298
|
+
};
|
|
299
|
+
const diagnostics = (namespace) => Object.fromEntries([...children].map(([name, child]) => [name, child.diagnostics(namespace)]));
|
|
300
|
+
const transaction = async (namespace, selected, run) => {
|
|
301
|
+
if (controlling)
|
|
302
|
+
return json(409, { error: "another fleet operation is in progress" });
|
|
303
|
+
controlling = true;
|
|
304
|
+
const release = [];
|
|
305
|
+
const before = /* @__PURE__ */ new Map();
|
|
306
|
+
const results = {};
|
|
307
|
+
let failure;
|
|
308
|
+
try {
|
|
309
|
+
for (const [name, child] of selected) {
|
|
310
|
+
failure = name;
|
|
311
|
+
release.push(child.lock(namespace));
|
|
312
|
+
}
|
|
313
|
+
const busy = Object.fromEntries([...selected].filter(([, child]) => {
|
|
314
|
+
const state = child.diagnostics(namespace);
|
|
315
|
+
return state.activeRequests + state.blockedCommands + state.pendingWebhooks + (state.activeJobs ?? 0) > 0;
|
|
316
|
+
}).map(([name, child]) => [name, child.diagnostics(namespace)]));
|
|
317
|
+
if (Object.keys(busy).length)
|
|
318
|
+
return json(409, { error: "namespace is not quiescent", services: busy });
|
|
319
|
+
for (const [name, child] of selected) {
|
|
320
|
+
failure = name;
|
|
321
|
+
before.set(name, await child.checkpoint(namespace));
|
|
322
|
+
}
|
|
323
|
+
for (const [name, child] of selected) {
|
|
324
|
+
failure = name;
|
|
325
|
+
results[name] = { status: "ok", result: await run(name, child) };
|
|
326
|
+
}
|
|
327
|
+
return json(200, { status: "ok", namespace, services: results });
|
|
328
|
+
} catch {
|
|
329
|
+
const rollback = {};
|
|
330
|
+
for (const [name, point] of before) {
|
|
331
|
+
try {
|
|
332
|
+
await selected.get(name)?.restore(namespace, point);
|
|
333
|
+
rollback[name] = "restored";
|
|
334
|
+
} catch {
|
|
335
|
+
rollback[name] = "failed";
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return json(500, {
|
|
339
|
+
status: "failed",
|
|
340
|
+
namespace,
|
|
341
|
+
failedService: failure,
|
|
342
|
+
services: results,
|
|
343
|
+
rollback,
|
|
344
|
+
rolledBack: before.size > 0 && Object.values(rollback).every((value) => value === "restored")
|
|
345
|
+
});
|
|
346
|
+
} finally {
|
|
347
|
+
for (const unlock of release.reverse())
|
|
348
|
+
unlock();
|
|
349
|
+
controlling = false;
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const api = {
|
|
353
|
+
async fetch(request) {
|
|
354
|
+
const url = new URL(request.url);
|
|
355
|
+
if (url.pathname === "/health" && request.method === "GET") {
|
|
356
|
+
const states = await Promise.all([...children].map(async ([name, child]) => {
|
|
357
|
+
let protocolReady = false;
|
|
358
|
+
try {
|
|
359
|
+
protocolReady = await child.ready();
|
|
360
|
+
} catch {
|
|
361
|
+
}
|
|
362
|
+
return [
|
|
363
|
+
name,
|
|
364
|
+
{
|
|
365
|
+
processReady: !stopped,
|
|
366
|
+
protocolReady,
|
|
367
|
+
status: protocolReady ? "ready" : "failed",
|
|
368
|
+
url: publicUrl(child.url)
|
|
369
|
+
}
|
|
370
|
+
];
|
|
371
|
+
}));
|
|
372
|
+
const ready = !stopped && states.every(([, state]) => state.protocolReady);
|
|
373
|
+
return json(ready ? 200 : 503, {
|
|
374
|
+
status: ready ? "ready" : "failed",
|
|
375
|
+
id,
|
|
376
|
+
services: Object.fromEntries(states)
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
if (!url.pathname.startsWith("/__fleet/"))
|
|
380
|
+
return json(404, { error: "not found" });
|
|
381
|
+
const adminKey = config.adminKey ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
382
|
+
if (adminKey !== void 0 && request.headers.get("x-mockingbird-admin-key") !== adminKey)
|
|
383
|
+
return json(401, { error: "admin key required" });
|
|
384
|
+
const route = /^\/__fleet\/namespaces\/([^/]+)\/(reset|snapshots)(?:\/([^/]+)\/restore)?$/.exec(url.pathname);
|
|
385
|
+
const namespace = route ? decodeURIComponent(route[1]) : url.searchParams.get("namespace") ?? request.headers.get("x-mockingbird-namespace") ?? config.namespace ?? "default";
|
|
386
|
+
if (!NS.test(namespace))
|
|
387
|
+
return json(400, { error: "invalid namespace" });
|
|
388
|
+
if (request.method === "GET" && url.pathname === "/__fleet/metrics")
|
|
389
|
+
return json(200, { namespace, services: diagnostics(namespace) });
|
|
390
|
+
if (request.method === "GET" && url.pathname === "/__fleet/clock")
|
|
391
|
+
return json(200, {
|
|
392
|
+
namespace,
|
|
393
|
+
...clock(namespace).state(),
|
|
394
|
+
services: Object.fromEntries([...children].map(([name, child]) => [name, child.clock(namespace).state()]))
|
|
395
|
+
});
|
|
396
|
+
if (request.method !== "POST")
|
|
397
|
+
return json(405, { error: "method not allowed" });
|
|
398
|
+
let body;
|
|
399
|
+
try {
|
|
400
|
+
const text = await request.text();
|
|
401
|
+
const value = text ? JSON.parse(text) : {};
|
|
402
|
+
if (!isObject(value))
|
|
403
|
+
throw new Error("expected object");
|
|
404
|
+
body = value;
|
|
405
|
+
} catch {
|
|
406
|
+
return json(400, { error: "expected JSON object" });
|
|
407
|
+
}
|
|
408
|
+
let selected;
|
|
409
|
+
try {
|
|
410
|
+
selected = select(body);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
return json(400, { error: error instanceof Error ? error.message : "bad selection" });
|
|
413
|
+
}
|
|
414
|
+
if (url.pathname === "/__fleet/wait-until-idle") {
|
|
415
|
+
const services = diagnostics(namespace);
|
|
416
|
+
const idle = Object.values(services).every((d) => pending(d) === 0);
|
|
417
|
+
return json(idle ? 200 : 409, { status: idle ? "idle" : "busy", namespace, services });
|
|
418
|
+
}
|
|
419
|
+
if (url.pathname === "/__fleet/clock") {
|
|
420
|
+
const current = clock(namespace);
|
|
421
|
+
let target = current.now();
|
|
422
|
+
if (body.set !== void 0) {
|
|
423
|
+
target = typeof body.set === "number" ? body.set : typeof body.set === "string" ? Date.parse(body.set) : NaN;
|
|
424
|
+
if (!Number.isFinite(target))
|
|
425
|
+
return json(400, { error: "set must be epoch ms or ISO-8601" });
|
|
426
|
+
}
|
|
427
|
+
if (body.advance !== void 0) {
|
|
428
|
+
const delta = parseDuration(body.advance);
|
|
429
|
+
if (delta === void 0)
|
|
430
|
+
return json(400, { error: "invalid advance" });
|
|
431
|
+
target += delta;
|
|
432
|
+
}
|
|
433
|
+
const frozen = body.freeze === void 0 ? current.state().frozen : body.freeze;
|
|
434
|
+
if (typeof frozen !== "boolean")
|
|
435
|
+
return json(400, { error: "freeze must be boolean" });
|
|
436
|
+
const response2 = await transaction(namespace, selected, async (_name, child) => {
|
|
437
|
+
const childClock = child.clock(namespace);
|
|
438
|
+
childClock.freeze();
|
|
439
|
+
childClock.set(target);
|
|
440
|
+
if (!frozen)
|
|
441
|
+
childClock.unfreeze();
|
|
442
|
+
await child.settle?.(namespace);
|
|
443
|
+
return childClock.state();
|
|
444
|
+
});
|
|
445
|
+
if (response2.ok) {
|
|
446
|
+
current.freeze();
|
|
447
|
+
current.set(target);
|
|
448
|
+
if (!frozen)
|
|
449
|
+
current.unfreeze();
|
|
450
|
+
}
|
|
451
|
+
return response2;
|
|
452
|
+
}
|
|
453
|
+
if (!route)
|
|
454
|
+
return json(404, { error: "not found" });
|
|
455
|
+
if (route[2] === "reset") {
|
|
456
|
+
if (route[3])
|
|
457
|
+
return json(404, { error: "not found" });
|
|
458
|
+
const response2 = await transaction(namespace, selected, async (_name, child) => child.reset(namespace));
|
|
459
|
+
if (response2.ok && selected.size === children.size)
|
|
460
|
+
clock(namespace).reset();
|
|
461
|
+
return response2;
|
|
462
|
+
}
|
|
463
|
+
if (route[3]) {
|
|
464
|
+
const point = checkpoints.get(route[3]);
|
|
465
|
+
if (!point || point.namespace !== namespace)
|
|
466
|
+
return json(404, { error: "no snapshot in this namespace" });
|
|
467
|
+
const restored = new Map([...point.values].map(([name]) => [name, children.get(name)]));
|
|
468
|
+
const response2 = await transaction(namespace, restored, async (name, child) => child.restore(namespace, point.values.get(name)));
|
|
469
|
+
if (response2.ok) {
|
|
470
|
+
const current = clock(namespace);
|
|
471
|
+
current.freeze();
|
|
472
|
+
current.set(point.clock.now);
|
|
473
|
+
if (!point.clock.frozen)
|
|
474
|
+
current.unfreeze();
|
|
475
|
+
}
|
|
476
|
+
return response2;
|
|
477
|
+
}
|
|
478
|
+
const snapshotId = randomUUID();
|
|
479
|
+
const values = /* @__PURE__ */ new Map();
|
|
480
|
+
const response = await transaction(namespace, selected, async (name, child) => {
|
|
481
|
+
values.set(name, await child.checkpoint(namespace));
|
|
482
|
+
return { checkpoint: `${snapshotId}:${name}` };
|
|
483
|
+
});
|
|
484
|
+
if (!response.ok)
|
|
485
|
+
return response;
|
|
486
|
+
checkpoints.set(snapshotId, { namespace, values, clock: clock(namespace).state() });
|
|
487
|
+
return json(201, {
|
|
488
|
+
...await response.json(),
|
|
489
|
+
id: snapshotId,
|
|
490
|
+
label: body.label
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
try {
|
|
495
|
+
if (!isObject(config.services) || Object.keys(config.services).length === 0)
|
|
496
|
+
throw new Error("config must contain at least one service");
|
|
497
|
+
for (const [name, entry] of Object.entries(config.services)) {
|
|
498
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
|
|
499
|
+
throw new Error("invalid service name");
|
|
500
|
+
try {
|
|
501
|
+
const target = await options.load(name);
|
|
502
|
+
if (entry.protocol && entry.protocol !== ("protocol" in target ? target.protocol : "http"))
|
|
503
|
+
throw new Error("configured protocol does not match target");
|
|
504
|
+
const adminKey = entry.adminKey ?? config.adminKey ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
505
|
+
const child = "start" in target ? await target.start(entry) : await httpChild(target, { ...entry, ...adminKey !== void 0 ? { adminKey } : {} }, options);
|
|
506
|
+
children.set(name, child);
|
|
507
|
+
if (!await child.ready())
|
|
508
|
+
throw new Error("protocol readiness failed");
|
|
509
|
+
} catch {
|
|
510
|
+
throw new Error(`service ${name} failed to start; all started listeners were closed`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
supervisor = await listen(api, {
|
|
514
|
+
port: config.control?.port ?? 0,
|
|
515
|
+
host: config.control?.host ?? "127.0.0.1"
|
|
516
|
+
});
|
|
517
|
+
const manifest = {
|
|
518
|
+
version: 1,
|
|
519
|
+
state: "ready",
|
|
520
|
+
pid: process.pid,
|
|
521
|
+
id,
|
|
522
|
+
startedAt,
|
|
523
|
+
adminBase: `${supervisor.url}/__fleet`,
|
|
524
|
+
healthUrl: `${supervisor.url}/health`,
|
|
525
|
+
services: Object.fromEntries([...children].map(([name, child]) => [
|
|
526
|
+
name,
|
|
527
|
+
{
|
|
528
|
+
protocol: child.protocol,
|
|
529
|
+
url: publicUrl(child.url),
|
|
530
|
+
healthUrl: child.healthUrl ?? `${supervisor?.url}/health`,
|
|
531
|
+
adminUrl: child.adminUrl ?? `${supervisor?.url}/__fleet`,
|
|
532
|
+
namespaces: child.namespaces,
|
|
533
|
+
processReady: true,
|
|
534
|
+
protocolReady: true
|
|
535
|
+
}
|
|
536
|
+
]))
|
|
537
|
+
};
|
|
538
|
+
if (options.connectionsFile)
|
|
539
|
+
await atomicWrite(options.connectionsFile, {
|
|
540
|
+
version: 1,
|
|
541
|
+
id,
|
|
542
|
+
pid: process.pid,
|
|
543
|
+
services: Object.fromEntries([...children].map(([name, child]) => [
|
|
544
|
+
name,
|
|
545
|
+
{ url: child.connection ?? child.url, namespaces: child.connections }
|
|
546
|
+
]))
|
|
547
|
+
});
|
|
548
|
+
if (options.readyFile)
|
|
549
|
+
await atomicWrite(options.readyFile, manifest);
|
|
550
|
+
options.onReady?.(manifest);
|
|
551
|
+
return { manifest, children, fetch: api.fetch, close };
|
|
552
|
+
} catch (error) {
|
|
553
|
+
await close();
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
122
558
|
// ../../adapters/node/dist/cli.js
|
|
123
559
|
var optionHelp = (options) => Object.entries(options).map(([name, option]) => {
|
|
124
560
|
const flag = `--${name}${option.type === "string" ? ` ${option.value ?? "<value>"}` : ""}`;
|
|
@@ -208,6 +644,20 @@ var COMMON_SERVE_OPTIONS = {
|
|
|
208
644
|
type: "string",
|
|
209
645
|
value: "<file>",
|
|
210
646
|
description: "Serve every service in a mockingbird.json config instead"
|
|
647
|
+
},
|
|
648
|
+
"ready-file": {
|
|
649
|
+
type: "string",
|
|
650
|
+
description: "Atomically publish fleet endpoint JSON",
|
|
651
|
+
value: "<file>"
|
|
652
|
+
},
|
|
653
|
+
"connections-file": {
|
|
654
|
+
type: "string",
|
|
655
|
+
description: "Write private connection URLs with mode 0600",
|
|
656
|
+
value: "<file>"
|
|
657
|
+
},
|
|
658
|
+
"ready-json": {
|
|
659
|
+
type: "boolean",
|
|
660
|
+
description: "Emit one public JSON record after fleet readiness"
|
|
211
661
|
}
|
|
212
662
|
};
|
|
213
663
|
var formatLog = (format) => {
|
|
@@ -231,7 +681,13 @@ var loadTarget = async (name, own) => {
|
|
|
231
681
|
return own;
|
|
232
682
|
const specifier = `@crvouga/mockingbird-service-${name}/server`;
|
|
233
683
|
try {
|
|
234
|
-
|
|
684
|
+
let mod;
|
|
685
|
+
try {
|
|
686
|
+
mod = await import(specifier);
|
|
687
|
+
} catch {
|
|
688
|
+
const fromProject = createRequire(resolve(process.cwd(), "package.json"));
|
|
689
|
+
mod = await import(pathToFileURL(fromProject.resolve(specifier)).href);
|
|
690
|
+
}
|
|
235
691
|
if (!mod.serveTarget)
|
|
236
692
|
throw new Error(`${specifier} exports no serveTarget`);
|
|
237
693
|
return mod.serveTarget;
|
|
@@ -256,17 +712,20 @@ var start = async (target, values, config) => {
|
|
|
256
712
|
console.log(`${target.name} ${line}`);
|
|
257
713
|
return listening;
|
|
258
714
|
};
|
|
259
|
-
var untilSignal = async (servers) => new Promise((
|
|
715
|
+
var untilSignal = async (servers) => new Promise((resolve2) => {
|
|
260
716
|
const stop = async () => {
|
|
717
|
+
;
|
|
718
|
+
process.removeListener("SIGINT", stop);
|
|
719
|
+
process.removeListener("SIGTERM", stop);
|
|
261
720
|
await Promise.allSettled(servers.map((s) => s.close()));
|
|
262
|
-
|
|
721
|
+
resolve2(0);
|
|
263
722
|
};
|
|
264
723
|
process.once("SIGINT", stop);
|
|
265
724
|
process.once("SIGTERM", stop);
|
|
266
725
|
});
|
|
267
726
|
var serveCommand = (target) => ({
|
|
268
|
-
summary: `Serve the ${target.name} mock
|
|
269
|
-
options: { ...COMMON_SERVE_OPTIONS, ...target.options },
|
|
727
|
+
summary: `Serve the ${target.name} mock or a configured fleet`,
|
|
728
|
+
options: { ...COMMON_SERVE_OPTIONS, ..."options" in target ? target.options : {} },
|
|
270
729
|
async run(values) {
|
|
271
730
|
const log = values["log-requests"] === true ? "json" : asString(values.log) ?? "pretty";
|
|
272
731
|
if (!["pretty", "json", "off"].includes(log)) {
|
|
@@ -275,25 +734,33 @@ var serveCommand = (target) => ({
|
|
|
275
734
|
}
|
|
276
735
|
const configPath = asString(values.config);
|
|
277
736
|
if (configPath !== void 0) {
|
|
278
|
-
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
279
|
-
const servers = [];
|
|
280
737
|
try {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
738
|
+
const config = JSON.parse(await readFile2(configPath, "utf8"));
|
|
739
|
+
const adminKey2 = config.adminKey ?? asString(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
740
|
+
if (adminKey2 !== void 0)
|
|
741
|
+
config.adminKey = adminKey2;
|
|
742
|
+
const fleet = await startFleet(config, {
|
|
743
|
+
load: (name) => loadTarget(name, target),
|
|
744
|
+
onLog: formatLog(config.log ?? log),
|
|
745
|
+
...asString(values["ready-file"]) ? { readyFile: asString(values["ready-file"]) } : {},
|
|
746
|
+
...asString(values["connections-file"]) ? { connectionsFile: asString(values["connections-file"]) } : {},
|
|
747
|
+
onReady: (manifest) => {
|
|
748
|
+
if (values["ready-json"])
|
|
749
|
+
console.log(JSON.stringify(manifest));
|
|
750
|
+
else
|
|
751
|
+
for (const [name, endpoint] of Object.entries(manifest.services))
|
|
752
|
+
console.log(`${name} mock listening on ${endpoint.url}`);
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
return untilSignal([fleet]);
|
|
291
756
|
} catch (error) {
|
|
292
|
-
await Promise.allSettled(servers.map((s) => s.close()));
|
|
293
757
|
console.error(error instanceof Error ? error.message : String(error));
|
|
294
758
|
return 1;
|
|
295
759
|
}
|
|
296
|
-
|
|
760
|
+
}
|
|
761
|
+
if ("start" in target) {
|
|
762
|
+
console.error("Use --config to supervise protocol services with this command");
|
|
763
|
+
return 2;
|
|
297
764
|
}
|
|
298
765
|
const port = asString(values.port);
|
|
299
766
|
const adminKey = asString(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
@@ -321,7 +788,7 @@ var loadCorpus = async (source) => {
|
|
|
321
788
|
if (typeof source !== "string") return source;
|
|
322
789
|
if (source === "none") return void 0;
|
|
323
790
|
if (source === "default") return defaultCorpus;
|
|
324
|
-
const raw = await
|
|
791
|
+
const raw = await readFile3(source, "utf8").catch(() => void 0);
|
|
325
792
|
if (raw === void 0) throw new Error(`junction mock corpus not found: ${source}`);
|
|
326
793
|
return parseSealedCorpus(JSON.parse(raw));
|
|
327
794
|
};
|
|
@@ -343,7 +810,7 @@ var count = (value, flag) => {
|
|
|
343
810
|
return Number.parseInt(value, 10);
|
|
344
811
|
};
|
|
345
812
|
var readJson = async (path) => {
|
|
346
|
-
const raw = await
|
|
813
|
+
const raw = await readFile3(path, "utf8").catch(() => void 0);
|
|
347
814
|
if (raw === void 0) throw new Error(`junction mock: file not found: ${path}`);
|
|
348
815
|
try {
|
|
349
816
|
return JSON.parse(raw);
|
|
@@ -537,4 +1004,4 @@ export {
|
|
|
537
1004
|
createServer2 as createServer,
|
|
538
1005
|
serveTarget
|
|
539
1006
|
};
|
|
540
|
-
//# sourceMappingURL=chunk-
|
|
1007
|
+
//# sourceMappingURL=chunk-S4CJ5QWE.js.map
|