@crvouga/mockingbird-service-mailosaur 1.7.0 → 1.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/CHANGELOG.md +21 -4
- package/dist/{chunk-W3PE5FFQ.js → chunk-BUZ5RYK4.js} +501 -34
- package/dist/chunk-BUZ5RYK4.js.map +7 -0
- package/dist/{chunk-QI7IPPVX.js → chunk-L64DXRT4.js} +167 -28
- package/dist/chunk-L64DXRT4.js.map +7 -0
- package/dist/cli.js +2 -2
- package/dist/index.d.ts +31 -2
- 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-QI7IPPVX.js.map +0 -7
- package/dist/chunk-W3PE5FFQ.js.map +0 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
1
|
# Changelog — @crvouga/mockingbird-service-mailosaur
|
|
2
2
|
|
|
3
|
-
## 1.
|
|
3
|
+
## 1.8.0 (2026-10-02)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
6
|
|
|
7
|
-
-
|
|
7
|
+
- coordinate HTTP and protocol mocks ([3168653](https://github.com/crvouga/mockingbird/commit/31686534c55a9823fdaa92afc9cc456cf741b809))
|
|
8
|
+
|
|
9
|
+
### Fixes and improvements
|
|
8
10
|
|
|
9
|
-
|
|
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.7.1 (2026-10-01)
|
|
19
|
+
|
|
20
|
+
### Fixes and improvements
|
|
10
21
|
|
|
11
|
-
-
|
|
22
|
+
- freeze clock before setting time ([a054a1d](https://github.com/crvouga/mockingbird/commit/a054a1defad62539d719b1d31c15107afea623bd))
|
|
23
|
+
|
|
24
|
+
## 1.7.0 (2026-09-30)
|
|
25
|
+
|
|
26
|
+
### Features
|
|
27
|
+
|
|
28
|
+
- record SQL timelines and serve the admin explorer ([8ced17a](https://github.com/crvouga/mockingbird/commit/8ced17ac398e386ef4e9d7e01fc7744c34b95d07))
|
|
12
29
|
|
|
13
30
|
## 1.6.1 (2026-09-30)
|
|
14
31
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
createClock,
|
|
3
|
+
createRuntime,
|
|
4
|
+
parseDuration
|
|
5
|
+
} from "./chunk-L64DXRT4.js";
|
|
4
6
|
|
|
5
7
|
// src/server.ts
|
|
6
8
|
import { writeFileSync } from "node:fs";
|
|
7
|
-
import { readFile as
|
|
9
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
8
10
|
import {
|
|
9
11
|
connect,
|
|
10
12
|
createServer as createNetServer
|
|
@@ -12,9 +14,16 @@ import {
|
|
|
12
14
|
import { createServer as createTlsServer } from "node:tls";
|
|
13
15
|
|
|
14
16
|
// ../../adapters/node/dist/cli.js
|
|
15
|
-
import { readFile } from "node:fs/promises";
|
|
17
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
18
|
+
import { createRequire } from "node:module";
|
|
19
|
+
import { resolve } from "node:path";
|
|
20
|
+
import { pathToFileURL } from "node:url";
|
|
16
21
|
import { parseArgs } from "node:util";
|
|
17
22
|
|
|
23
|
+
// ../../adapters/node/dist/fleet.js
|
|
24
|
+
import { randomUUID } from "node:crypto";
|
|
25
|
+
import { chmod, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
26
|
+
|
|
18
27
|
// ../../adapters/node/dist/serve.js
|
|
19
28
|
import { createServer } from "node:http";
|
|
20
29
|
var serve = async (api, options = {}) => {
|
|
@@ -76,7 +85,7 @@ var serve = async (api, options = {}) => {
|
|
|
76
85
|
if (done)
|
|
77
86
|
break;
|
|
78
87
|
if (!res.write(value))
|
|
79
|
-
await new Promise((
|
|
88
|
+
await new Promise((resolve2) => res.once("drain", resolve2));
|
|
80
89
|
}
|
|
81
90
|
res.end();
|
|
82
91
|
} catch {
|
|
@@ -85,9 +94,9 @@ var serve = async (api, options = {}) => {
|
|
|
85
94
|
reader.releaseLock();
|
|
86
95
|
}
|
|
87
96
|
});
|
|
88
|
-
await new Promise((
|
|
97
|
+
await new Promise((resolve2, reject) => {
|
|
89
98
|
server.once("error", reject);
|
|
90
|
-
server.listen(options.port ?? 0, options.host,
|
|
99
|
+
server.listen(options.port ?? 0, options.host, resolve2);
|
|
91
100
|
});
|
|
92
101
|
return server;
|
|
93
102
|
};
|
|
@@ -104,18 +113,445 @@ var listen = async (api, options = {}) => {
|
|
|
104
113
|
port,
|
|
105
114
|
host,
|
|
106
115
|
server,
|
|
107
|
-
close: () => new Promise((
|
|
116
|
+
close: () => new Promise((resolve2, reject) => {
|
|
108
117
|
server.close((error) => {
|
|
109
118
|
if (error && error.code !== "ERR_SERVER_NOT_RUNNING")
|
|
110
119
|
reject(error);
|
|
111
120
|
else
|
|
112
|
-
|
|
121
|
+
resolve2();
|
|
113
122
|
});
|
|
114
123
|
server.closeAllConnections?.();
|
|
115
124
|
})
|
|
116
125
|
};
|
|
117
126
|
};
|
|
118
127
|
|
|
128
|
+
// ../../adapters/node/dist/fleet.js
|
|
129
|
+
var EMPTY = {
|
|
130
|
+
activeRequests: 0,
|
|
131
|
+
activeConnections: 0,
|
|
132
|
+
blockedCommands: 0,
|
|
133
|
+
pendingJobs: 0,
|
|
134
|
+
pendingWebhooks: 0,
|
|
135
|
+
unmatchedRequests: 0
|
|
136
|
+
};
|
|
137
|
+
var NS = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
138
|
+
var json = (status, value) => Response.json(value, { status });
|
|
139
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
140
|
+
var pending = (d) => d.activeRequests + d.blockedCommands + d.pendingJobs + d.pendingWebhooks;
|
|
141
|
+
var publicUrl = (raw) => {
|
|
142
|
+
const url = new URL(raw);
|
|
143
|
+
url.password = "";
|
|
144
|
+
return url.href;
|
|
145
|
+
};
|
|
146
|
+
var atomicWrite = async (path, value) => {
|
|
147
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
148
|
+
try {
|
|
149
|
+
await writeFile(temp, `${JSON.stringify(value, null, 2)}
|
|
150
|
+
`, { mode: 384, flag: "wx" });
|
|
151
|
+
await chmod(temp, 384);
|
|
152
|
+
await rename(temp, path);
|
|
153
|
+
} finally {
|
|
154
|
+
await rm(temp, { force: true });
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
var removeOwned = async (path, id) => {
|
|
158
|
+
if (!path)
|
|
159
|
+
return;
|
|
160
|
+
try {
|
|
161
|
+
const value = JSON.parse(await readFile(path, "utf8"));
|
|
162
|
+
if (isObject(value) && value.id === id)
|
|
163
|
+
await rm(path, { force: true });
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (error.code !== "ENOENT")
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
var httpChild = async (target, entry, options) => {
|
|
170
|
+
const runtime = await target.create(entry.options ?? {}, {
|
|
171
|
+
adminKey: entry.adminKey,
|
|
172
|
+
seed: entry.seed,
|
|
173
|
+
onLog: options.onLog
|
|
174
|
+
});
|
|
175
|
+
runtime.isolateNamespaces();
|
|
176
|
+
const locked = /* @__PURE__ */ new Set();
|
|
177
|
+
const active = /* @__PURE__ */ new Map();
|
|
178
|
+
const api = {
|
|
179
|
+
async fetch(request) {
|
|
180
|
+
const namespace = runtime.namespaceOf(request);
|
|
181
|
+
if (locked.has(namespace))
|
|
182
|
+
return json(409, { error: "fleet control in progress", namespace });
|
|
183
|
+
active.set(namespace, (active.get(namespace) ?? 0) + 1);
|
|
184
|
+
try {
|
|
185
|
+
return await runtime.fetch(request);
|
|
186
|
+
} finally {
|
|
187
|
+
active.set(namespace, (active.get(namespace) ?? 1) - 1);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
let server;
|
|
192
|
+
try {
|
|
193
|
+
server = await listen(api, {
|
|
194
|
+
port: entry.port ?? target.defaultPort,
|
|
195
|
+
host: entry.host ?? "127.0.0.1"
|
|
196
|
+
});
|
|
197
|
+
target.listening?.(server);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
runtime.stop?.();
|
|
200
|
+
await server?.close();
|
|
201
|
+
runtime.webhooks?.clear();
|
|
202
|
+
await runtime.webhooks?.idle();
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
protocol: "http",
|
|
207
|
+
url: server.url,
|
|
208
|
+
healthUrl: `${server.url}/health`,
|
|
209
|
+
adminUrl: `${server.url}/__admin`,
|
|
210
|
+
namespaces: { header: "x-mockingbird-namespace", path: "/ns/{name}" },
|
|
211
|
+
ready: async () => {
|
|
212
|
+
try {
|
|
213
|
+
return (await fetch(`${server.url}/health`, { signal: AbortSignal.timeout(2e3) })).ok;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
async close() {
|
|
219
|
+
runtime.stop?.();
|
|
220
|
+
runtime.webhooks?.clear();
|
|
221
|
+
await server.close();
|
|
222
|
+
await runtime.webhooks?.idle();
|
|
223
|
+
},
|
|
224
|
+
lock(namespace) {
|
|
225
|
+
if (locked.has(namespace))
|
|
226
|
+
throw new Error("namespace already locked");
|
|
227
|
+
locked.add(namespace);
|
|
228
|
+
return () => locked.delete(namespace);
|
|
229
|
+
},
|
|
230
|
+
diagnostics(namespace) {
|
|
231
|
+
return {
|
|
232
|
+
...EMPTY,
|
|
233
|
+
activeRequests: active.get(namespace) ?? 0,
|
|
234
|
+
pendingWebhooks: runtime.webhooks?.pending(namespace) ?? 0,
|
|
235
|
+
unmatchedRequests: runtime.metrics.report(namespace).unmatched.reduce((n, r) => n + r.count, 0)
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
async checkpoint(namespace) {
|
|
239
|
+
return runtime.fleetSnapshot(namespace);
|
|
240
|
+
},
|
|
241
|
+
async restore(namespace, checkpoint) {
|
|
242
|
+
runtime.fleetRestore(checkpoint, namespace);
|
|
243
|
+
},
|
|
244
|
+
reset: (namespace) => runtime.reset(namespace),
|
|
245
|
+
clock: (namespace) => runtime.namespaceClock(namespace),
|
|
246
|
+
async settle(namespace) {
|
|
247
|
+
const instance = runtime.instance(namespace);
|
|
248
|
+
instance.tick?.();
|
|
249
|
+
await runtime.webhooks?.idle();
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
};
|
|
253
|
+
async function startFleet(config, options) {
|
|
254
|
+
const id = randomUUID();
|
|
255
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
256
|
+
const children = /* @__PURE__ */ new Map();
|
|
257
|
+
const clocks = /* @__PURE__ */ new Map();
|
|
258
|
+
const checkpoints = /* @__PURE__ */ new Map();
|
|
259
|
+
let supervisor;
|
|
260
|
+
let stopped = false;
|
|
261
|
+
let controlling = false;
|
|
262
|
+
const close = async () => {
|
|
263
|
+
if (stopped)
|
|
264
|
+
return;
|
|
265
|
+
stopped = true;
|
|
266
|
+
await Promise.allSettled([...children.values()].map((child) => child.close()));
|
|
267
|
+
await supervisor?.close();
|
|
268
|
+
await removeOwned(options.readyFile, id);
|
|
269
|
+
await removeOwned(options.connectionsFile, id);
|
|
270
|
+
};
|
|
271
|
+
if (options.readyFile)
|
|
272
|
+
await rm(options.readyFile, { force: true });
|
|
273
|
+
if (options.connectionsFile)
|
|
274
|
+
await rm(options.connectionsFile, { force: true });
|
|
275
|
+
const clock = (namespace) => {
|
|
276
|
+
let value = clocks.get(namespace);
|
|
277
|
+
if (!value) {
|
|
278
|
+
value = createClock();
|
|
279
|
+
clocks.set(namespace, value);
|
|
280
|
+
}
|
|
281
|
+
return value;
|
|
282
|
+
};
|
|
283
|
+
const select = (body) => {
|
|
284
|
+
if (body.services === void 0)
|
|
285
|
+
return children;
|
|
286
|
+
if (!Array.isArray(body.services) || body.services.length === 0)
|
|
287
|
+
throw new Error("services must be a nonempty list");
|
|
288
|
+
const selected = /* @__PURE__ */ new Map();
|
|
289
|
+
for (const name2 of body.services) {
|
|
290
|
+
if (typeof name2 !== "string" || !children.has(name2))
|
|
291
|
+
throw new Error("unknown service in selection");
|
|
292
|
+
selected.set(name2, children.get(name2));
|
|
293
|
+
}
|
|
294
|
+
return selected;
|
|
295
|
+
};
|
|
296
|
+
const diagnostics = (namespace) => Object.fromEntries([...children].map(([name2, child]) => [name2, child.diagnostics(namespace)]));
|
|
297
|
+
const transaction = async (namespace, selected, run) => {
|
|
298
|
+
if (controlling)
|
|
299
|
+
return json(409, { error: "another fleet operation is in progress" });
|
|
300
|
+
controlling = true;
|
|
301
|
+
const release = [];
|
|
302
|
+
const before = /* @__PURE__ */ new Map();
|
|
303
|
+
const results = {};
|
|
304
|
+
let failure;
|
|
305
|
+
try {
|
|
306
|
+
for (const [name2, child] of selected) {
|
|
307
|
+
failure = name2;
|
|
308
|
+
release.push(child.lock(namespace));
|
|
309
|
+
}
|
|
310
|
+
const busy = Object.fromEntries([...selected].filter(([, child]) => {
|
|
311
|
+
const state = child.diagnostics(namespace);
|
|
312
|
+
return state.activeRequests + state.blockedCommands + state.pendingWebhooks + (state.activeJobs ?? 0) > 0;
|
|
313
|
+
}).map(([name2, child]) => [name2, child.diagnostics(namespace)]));
|
|
314
|
+
if (Object.keys(busy).length)
|
|
315
|
+
return json(409, { error: "namespace is not quiescent", services: busy });
|
|
316
|
+
for (const [name2, child] of selected) {
|
|
317
|
+
failure = name2;
|
|
318
|
+
before.set(name2, await child.checkpoint(namespace));
|
|
319
|
+
}
|
|
320
|
+
for (const [name2, child] of selected) {
|
|
321
|
+
failure = name2;
|
|
322
|
+
results[name2] = { status: "ok", result: await run(name2, child) };
|
|
323
|
+
}
|
|
324
|
+
return json(200, { status: "ok", namespace, services: results });
|
|
325
|
+
} catch {
|
|
326
|
+
const rollback = {};
|
|
327
|
+
for (const [name2, point] of before) {
|
|
328
|
+
try {
|
|
329
|
+
await selected.get(name2)?.restore(namespace, point);
|
|
330
|
+
rollback[name2] = "restored";
|
|
331
|
+
} catch {
|
|
332
|
+
rollback[name2] = "failed";
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return json(500, {
|
|
336
|
+
status: "failed",
|
|
337
|
+
namespace,
|
|
338
|
+
failedService: failure,
|
|
339
|
+
services: results,
|
|
340
|
+
rollback,
|
|
341
|
+
rolledBack: before.size > 0 && Object.values(rollback).every((value) => value === "restored")
|
|
342
|
+
});
|
|
343
|
+
} finally {
|
|
344
|
+
for (const unlock of release.reverse())
|
|
345
|
+
unlock();
|
|
346
|
+
controlling = false;
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
const api = {
|
|
350
|
+
async fetch(request) {
|
|
351
|
+
const url = new URL(request.url);
|
|
352
|
+
if (url.pathname === "/health" && request.method === "GET") {
|
|
353
|
+
const states = await Promise.all([...children].map(async ([name2, child]) => {
|
|
354
|
+
let protocolReady = false;
|
|
355
|
+
try {
|
|
356
|
+
protocolReady = await child.ready();
|
|
357
|
+
} catch {
|
|
358
|
+
}
|
|
359
|
+
return [
|
|
360
|
+
name2,
|
|
361
|
+
{
|
|
362
|
+
processReady: !stopped,
|
|
363
|
+
protocolReady,
|
|
364
|
+
status: protocolReady ? "ready" : "failed",
|
|
365
|
+
url: publicUrl(child.url)
|
|
366
|
+
}
|
|
367
|
+
];
|
|
368
|
+
}));
|
|
369
|
+
const ready = !stopped && states.every(([, state]) => state.protocolReady);
|
|
370
|
+
return json(ready ? 200 : 503, {
|
|
371
|
+
status: ready ? "ready" : "failed",
|
|
372
|
+
id,
|
|
373
|
+
services: Object.fromEntries(states)
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
if (!url.pathname.startsWith("/__fleet/"))
|
|
377
|
+
return json(404, { error: "not found" });
|
|
378
|
+
const adminKey = config.adminKey ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
379
|
+
if (adminKey !== void 0 && request.headers.get("x-mockingbird-admin-key") !== adminKey)
|
|
380
|
+
return json(401, { error: "admin key required" });
|
|
381
|
+
const route = /^\/__fleet\/namespaces\/([^/]+)\/(reset|snapshots)(?:\/([^/]+)\/restore)?$/.exec(url.pathname);
|
|
382
|
+
const namespace = route ? decodeURIComponent(route[1]) : url.searchParams.get("namespace") ?? request.headers.get("x-mockingbird-namespace") ?? config.namespace ?? "default";
|
|
383
|
+
if (!NS.test(namespace))
|
|
384
|
+
return json(400, { error: "invalid namespace" });
|
|
385
|
+
if (request.method === "GET" && url.pathname === "/__fleet/metrics")
|
|
386
|
+
return json(200, { namespace, services: diagnostics(namespace) });
|
|
387
|
+
if (request.method === "GET" && url.pathname === "/__fleet/clock")
|
|
388
|
+
return json(200, {
|
|
389
|
+
namespace,
|
|
390
|
+
...clock(namespace).state(),
|
|
391
|
+
services: Object.fromEntries([...children].map(([name2, child]) => [name2, child.clock(namespace).state()]))
|
|
392
|
+
});
|
|
393
|
+
if (request.method !== "POST")
|
|
394
|
+
return json(405, { error: "method not allowed" });
|
|
395
|
+
let body;
|
|
396
|
+
try {
|
|
397
|
+
const text2 = await request.text();
|
|
398
|
+
const value = text2 ? JSON.parse(text2) : {};
|
|
399
|
+
if (!isObject(value))
|
|
400
|
+
throw new Error("expected object");
|
|
401
|
+
body = value;
|
|
402
|
+
} catch {
|
|
403
|
+
return json(400, { error: "expected JSON object" });
|
|
404
|
+
}
|
|
405
|
+
let selected;
|
|
406
|
+
try {
|
|
407
|
+
selected = select(body);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
return json(400, { error: error instanceof Error ? error.message : "bad selection" });
|
|
410
|
+
}
|
|
411
|
+
if (url.pathname === "/__fleet/wait-until-idle") {
|
|
412
|
+
const services = diagnostics(namespace);
|
|
413
|
+
const idle = Object.values(services).every((d) => pending(d) === 0);
|
|
414
|
+
return json(idle ? 200 : 409, { status: idle ? "idle" : "busy", namespace, services });
|
|
415
|
+
}
|
|
416
|
+
if (url.pathname === "/__fleet/clock") {
|
|
417
|
+
const current = clock(namespace);
|
|
418
|
+
let target = current.now();
|
|
419
|
+
if (body.set !== void 0) {
|
|
420
|
+
target = typeof body.set === "number" ? body.set : typeof body.set === "string" ? Date.parse(body.set) : NaN;
|
|
421
|
+
if (!Number.isFinite(target))
|
|
422
|
+
return json(400, { error: "set must be epoch ms or ISO-8601" });
|
|
423
|
+
}
|
|
424
|
+
if (body.advance !== void 0) {
|
|
425
|
+
const delta = parseDuration(body.advance);
|
|
426
|
+
if (delta === void 0)
|
|
427
|
+
return json(400, { error: "invalid advance" });
|
|
428
|
+
target += delta;
|
|
429
|
+
}
|
|
430
|
+
const frozen = body.freeze === void 0 ? current.state().frozen : body.freeze;
|
|
431
|
+
if (typeof frozen !== "boolean")
|
|
432
|
+
return json(400, { error: "freeze must be boolean" });
|
|
433
|
+
const response2 = await transaction(namespace, selected, async (_name, child) => {
|
|
434
|
+
const childClock = child.clock(namespace);
|
|
435
|
+
childClock.freeze();
|
|
436
|
+
childClock.set(target);
|
|
437
|
+
if (!frozen)
|
|
438
|
+
childClock.unfreeze();
|
|
439
|
+
await child.settle?.(namespace);
|
|
440
|
+
return childClock.state();
|
|
441
|
+
});
|
|
442
|
+
if (response2.ok) {
|
|
443
|
+
current.freeze();
|
|
444
|
+
current.set(target);
|
|
445
|
+
if (!frozen)
|
|
446
|
+
current.unfreeze();
|
|
447
|
+
}
|
|
448
|
+
return response2;
|
|
449
|
+
}
|
|
450
|
+
if (!route)
|
|
451
|
+
return json(404, { error: "not found" });
|
|
452
|
+
if (route[2] === "reset") {
|
|
453
|
+
if (route[3])
|
|
454
|
+
return json(404, { error: "not found" });
|
|
455
|
+
const response2 = await transaction(namespace, selected, async (_name, child) => child.reset(namespace));
|
|
456
|
+
if (response2.ok && selected.size === children.size)
|
|
457
|
+
clock(namespace).reset();
|
|
458
|
+
return response2;
|
|
459
|
+
}
|
|
460
|
+
if (route[3]) {
|
|
461
|
+
const point = checkpoints.get(route[3]);
|
|
462
|
+
if (!point || point.namespace !== namespace)
|
|
463
|
+
return json(404, { error: "no snapshot in this namespace" });
|
|
464
|
+
const restored = new Map([...point.values].map(([name2]) => [name2, children.get(name2)]));
|
|
465
|
+
const response2 = await transaction(namespace, restored, async (name2, child) => child.restore(namespace, point.values.get(name2)));
|
|
466
|
+
if (response2.ok) {
|
|
467
|
+
const current = clock(namespace);
|
|
468
|
+
current.freeze();
|
|
469
|
+
current.set(point.clock.now);
|
|
470
|
+
if (!point.clock.frozen)
|
|
471
|
+
current.unfreeze();
|
|
472
|
+
}
|
|
473
|
+
return response2;
|
|
474
|
+
}
|
|
475
|
+
const snapshotId = randomUUID();
|
|
476
|
+
const values = /* @__PURE__ */ new Map();
|
|
477
|
+
const response = await transaction(namespace, selected, async (name2, child) => {
|
|
478
|
+
values.set(name2, await child.checkpoint(namespace));
|
|
479
|
+
return { checkpoint: `${snapshotId}:${name2}` };
|
|
480
|
+
});
|
|
481
|
+
if (!response.ok)
|
|
482
|
+
return response;
|
|
483
|
+
checkpoints.set(snapshotId, { namespace, values, clock: clock(namespace).state() });
|
|
484
|
+
return json(201, {
|
|
485
|
+
...await response.json(),
|
|
486
|
+
id: snapshotId,
|
|
487
|
+
label: body.label
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
try {
|
|
492
|
+
if (!isObject(config.services) || Object.keys(config.services).length === 0)
|
|
493
|
+
throw new Error("config must contain at least one service");
|
|
494
|
+
for (const [name2, entry] of Object.entries(config.services)) {
|
|
495
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name2))
|
|
496
|
+
throw new Error("invalid service name");
|
|
497
|
+
try {
|
|
498
|
+
const target = await options.load(name2);
|
|
499
|
+
if (entry.protocol && entry.protocol !== ("protocol" in target ? target.protocol : "http"))
|
|
500
|
+
throw new Error("configured protocol does not match target");
|
|
501
|
+
const adminKey = entry.adminKey ?? config.adminKey ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
502
|
+
const child = "start" in target ? await target.start(entry) : await httpChild(target, { ...entry, ...adminKey !== void 0 ? { adminKey } : {} }, options);
|
|
503
|
+
children.set(name2, child);
|
|
504
|
+
if (!await child.ready())
|
|
505
|
+
throw new Error("protocol readiness failed");
|
|
506
|
+
} catch {
|
|
507
|
+
throw new Error(`service ${name2} failed to start; all started listeners were closed`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
supervisor = await listen(api, {
|
|
511
|
+
port: config.control?.port ?? 0,
|
|
512
|
+
host: config.control?.host ?? "127.0.0.1"
|
|
513
|
+
});
|
|
514
|
+
const manifest = {
|
|
515
|
+
version: 1,
|
|
516
|
+
state: "ready",
|
|
517
|
+
pid: process.pid,
|
|
518
|
+
id,
|
|
519
|
+
startedAt,
|
|
520
|
+
adminBase: `${supervisor.url}/__fleet`,
|
|
521
|
+
healthUrl: `${supervisor.url}/health`,
|
|
522
|
+
services: Object.fromEntries([...children].map(([name2, child]) => [
|
|
523
|
+
name2,
|
|
524
|
+
{
|
|
525
|
+
protocol: child.protocol,
|
|
526
|
+
url: publicUrl(child.url),
|
|
527
|
+
healthUrl: child.healthUrl ?? `${supervisor?.url}/health`,
|
|
528
|
+
adminUrl: child.adminUrl ?? `${supervisor?.url}/__fleet`,
|
|
529
|
+
namespaces: child.namespaces,
|
|
530
|
+
processReady: true,
|
|
531
|
+
protocolReady: true
|
|
532
|
+
}
|
|
533
|
+
]))
|
|
534
|
+
};
|
|
535
|
+
if (options.connectionsFile)
|
|
536
|
+
await atomicWrite(options.connectionsFile, {
|
|
537
|
+
version: 1,
|
|
538
|
+
id,
|
|
539
|
+
pid: process.pid,
|
|
540
|
+
services: Object.fromEntries([...children].map(([name2, child]) => [
|
|
541
|
+
name2,
|
|
542
|
+
{ url: child.connection ?? child.url, namespaces: child.connections }
|
|
543
|
+
]))
|
|
544
|
+
});
|
|
545
|
+
if (options.readyFile)
|
|
546
|
+
await atomicWrite(options.readyFile, manifest);
|
|
547
|
+
options.onReady?.(manifest);
|
|
548
|
+
return { manifest, children, fetch: api.fetch, close };
|
|
549
|
+
} catch (error) {
|
|
550
|
+
await close();
|
|
551
|
+
throw error;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
119
555
|
// ../../adapters/node/dist/cli.js
|
|
120
556
|
var optionHelp = (options) => Object.entries(options).map(([name2, option]) => {
|
|
121
557
|
const flag = `--${name2}${option.type === "string" ? ` ${option.value ?? "<value>"}` : ""}`;
|
|
@@ -205,6 +641,20 @@ var COMMON_SERVE_OPTIONS = {
|
|
|
205
641
|
type: "string",
|
|
206
642
|
value: "<file>",
|
|
207
643
|
description: "Serve every service in a mockingbird.json config instead"
|
|
644
|
+
},
|
|
645
|
+
"ready-file": {
|
|
646
|
+
type: "string",
|
|
647
|
+
description: "Atomically publish fleet endpoint JSON",
|
|
648
|
+
value: "<file>"
|
|
649
|
+
},
|
|
650
|
+
"connections-file": {
|
|
651
|
+
type: "string",
|
|
652
|
+
description: "Write private connection URLs with mode 0600",
|
|
653
|
+
value: "<file>"
|
|
654
|
+
},
|
|
655
|
+
"ready-json": {
|
|
656
|
+
type: "boolean",
|
|
657
|
+
description: "Emit one public JSON record after fleet readiness"
|
|
208
658
|
}
|
|
209
659
|
};
|
|
210
660
|
var formatLog = (format) => {
|
|
@@ -228,7 +678,13 @@ var loadTarget = async (name2, own) => {
|
|
|
228
678
|
return own;
|
|
229
679
|
const specifier = `@crvouga/mockingbird-service-${name2}/server`;
|
|
230
680
|
try {
|
|
231
|
-
|
|
681
|
+
let mod;
|
|
682
|
+
try {
|
|
683
|
+
mod = await import(specifier);
|
|
684
|
+
} catch {
|
|
685
|
+
const fromProject = createRequire(resolve(process.cwd(), "package.json"));
|
|
686
|
+
mod = await import(pathToFileURL(fromProject.resolve(specifier)).href);
|
|
687
|
+
}
|
|
232
688
|
if (!mod.serveTarget)
|
|
233
689
|
throw new Error(`${specifier} exports no serveTarget`);
|
|
234
690
|
return mod.serveTarget;
|
|
@@ -253,17 +709,20 @@ var start = async (target, values, config) => {
|
|
|
253
709
|
console.log(`${target.name} ${line}`);
|
|
254
710
|
return listening;
|
|
255
711
|
};
|
|
256
|
-
var untilSignal = async (servers) => new Promise((
|
|
712
|
+
var untilSignal = async (servers) => new Promise((resolve2) => {
|
|
257
713
|
const stop = async () => {
|
|
714
|
+
;
|
|
715
|
+
process.removeListener("SIGINT", stop);
|
|
716
|
+
process.removeListener("SIGTERM", stop);
|
|
258
717
|
await Promise.allSettled(servers.map((s) => s.close()));
|
|
259
|
-
|
|
718
|
+
resolve2(0);
|
|
260
719
|
};
|
|
261
720
|
process.once("SIGINT", stop);
|
|
262
721
|
process.once("SIGTERM", stop);
|
|
263
722
|
});
|
|
264
723
|
var serveCommand = (target) => ({
|
|
265
|
-
summary: `Serve the ${target.name} mock
|
|
266
|
-
options: { ...COMMON_SERVE_OPTIONS, ...target.options },
|
|
724
|
+
summary: `Serve the ${target.name} mock or a configured fleet`,
|
|
725
|
+
options: { ...COMMON_SERVE_OPTIONS, ..."options" in target ? target.options : {} },
|
|
267
726
|
async run(values) {
|
|
268
727
|
const log = values["log-requests"] === true ? "json" : asString(values.log) ?? "pretty";
|
|
269
728
|
if (!["pretty", "json", "off"].includes(log)) {
|
|
@@ -272,25 +731,33 @@ var serveCommand = (target) => ({
|
|
|
272
731
|
}
|
|
273
732
|
const configPath = asString(values.config);
|
|
274
733
|
if (configPath !== void 0) {
|
|
275
|
-
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
276
|
-
const servers = [];
|
|
277
734
|
try {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
735
|
+
const config = JSON.parse(await readFile2(configPath, "utf8"));
|
|
736
|
+
const adminKey2 = config.adminKey ?? asString(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
737
|
+
if (adminKey2 !== void 0)
|
|
738
|
+
config.adminKey = adminKey2;
|
|
739
|
+
const fleet = await startFleet(config, {
|
|
740
|
+
load: (name2) => loadTarget(name2, target),
|
|
741
|
+
onLog: formatLog(config.log ?? log),
|
|
742
|
+
...asString(values["ready-file"]) ? { readyFile: asString(values["ready-file"]) } : {},
|
|
743
|
+
...asString(values["connections-file"]) ? { connectionsFile: asString(values["connections-file"]) } : {},
|
|
744
|
+
onReady: (manifest) => {
|
|
745
|
+
if (values["ready-json"])
|
|
746
|
+
console.log(JSON.stringify(manifest));
|
|
747
|
+
else
|
|
748
|
+
for (const [name2, endpoint] of Object.entries(manifest.services))
|
|
749
|
+
console.log(`${name2} mock listening on ${endpoint.url}`);
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
return untilSignal([fleet]);
|
|
288
753
|
} catch (error) {
|
|
289
|
-
await Promise.allSettled(servers.map((s) => s.close()));
|
|
290
754
|
console.error(error instanceof Error ? error.message : String(error));
|
|
291
755
|
return 1;
|
|
292
756
|
}
|
|
293
|
-
|
|
757
|
+
}
|
|
758
|
+
if ("start" in target) {
|
|
759
|
+
console.error("Use --config to supervise protocol services with this command");
|
|
760
|
+
return 2;
|
|
294
761
|
}
|
|
295
762
|
const port = asString(values.port);
|
|
296
763
|
const adminKey = asString(values["admin-key"]) ?? process.env.MOCKINGBIRD_ADMIN_KEY;
|
|
@@ -398,9 +865,9 @@ var selfSignedCertificate = (hosts = ["localhost", "127.0.0.1"]) => {
|
|
|
398
865
|
// src/server.ts
|
|
399
866
|
var DEFAULT_PORT = 8793;
|
|
400
867
|
var listenOn = async (server, bind) => {
|
|
401
|
-
await new Promise((
|
|
868
|
+
await new Promise((resolve2, reject) => {
|
|
402
869
|
server.once("error", reject);
|
|
403
|
-
server.listen(bind.port, bind.host,
|
|
870
|
+
server.listen(bind.port, bind.host, resolve2);
|
|
404
871
|
});
|
|
405
872
|
const address = server.address();
|
|
406
873
|
return typeof address === "object" && address !== null ? address.port : bind.port;
|
|
@@ -457,10 +924,10 @@ var secureDoor = async (certificate, upstream, bind) => {
|
|
|
457
924
|
const port = await listenOn(door, bind);
|
|
458
925
|
return {
|
|
459
926
|
port,
|
|
460
|
-
close: () => new Promise((
|
|
927
|
+
close: () => new Promise((resolve2) => {
|
|
461
928
|
for (const socket of sockets) socket.destroy();
|
|
462
929
|
terminator.close();
|
|
463
|
-
door.close(() =>
|
|
930
|
+
door.close(() => resolve2());
|
|
464
931
|
})
|
|
465
932
|
};
|
|
466
933
|
};
|
|
@@ -536,7 +1003,7 @@ var serveTarget = {
|
|
|
536
1003
|
throw new Error("--tls-cert and --tls-key go together");
|
|
537
1004
|
}
|
|
538
1005
|
const certificate = certificateFor(
|
|
539
|
-
certFile && keyFile ? { cert: await
|
|
1006
|
+
certFile && keyFile ? { cert: await readFile3(certFile, "utf8"), key: await readFile3(keyFile, "utf8") } : {}
|
|
540
1007
|
);
|
|
541
1008
|
const out = text(values["tls-cert-out"]);
|
|
542
1009
|
if (out) writeFileSync(out, certificate.cert);
|
|
@@ -568,4 +1035,4 @@ export {
|
|
|
568
1035
|
createServer2 as createServer,
|
|
569
1036
|
serveTarget
|
|
570
1037
|
};
|
|
571
|
-
//# sourceMappingURL=chunk-
|
|
1038
|
+
//# sourceMappingURL=chunk-BUZ5RYK4.js.map
|