@rivalis/fleet 8.0.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/README.md +417 -0
- package/bin/rivalis-fleet.js +10 -0
- package/lib/AgentAuthenticator.js +56 -0
- package/lib/CommandEngine.js +258 -0
- package/lib/EventReconciler.js +90 -0
- package/lib/FleetAgent.js +1217 -0
- package/lib/FleetControl.js +139 -0
- package/lib/FleetState.js +865 -0
- package/lib/Orchestrator.js +2834 -0
- package/lib/Poller.js +113 -0
- package/lib/Snapshot.js +471 -0
- package/lib/canonical.js +82 -0
- package/lib/cli.js +3076 -0
- package/lib/domain.js +97 -0
- package/lib/env.js +99 -0
- package/lib/main.d.ts +592 -0
- package/lib/main.js +3618 -0
- package/lib/module.js +3582 -0
- package/lib/routers.js +598 -0
- package/lib/wire.js +507 -0
- package/package.json +78 -0
package/lib/routers.js
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/routers/index.ts
|
|
31
|
+
var routers_exports = {};
|
|
32
|
+
__export(routers_exports, {
|
|
33
|
+
AUTH_FAILURE_LIMIT: () => AUTH_FAILURE_LIMIT,
|
|
34
|
+
AUTH_FAILURE_WINDOW_MS: () => AUTH_FAILURE_WINDOW_MS,
|
|
35
|
+
AuthThrottle: () => AuthThrottle,
|
|
36
|
+
MAX_BODY_BYTES: () => MAX_BODY_BYTES,
|
|
37
|
+
MAX_SSE_STREAMS: () => MAX_SSE_STREAMS,
|
|
38
|
+
MAX_THROTTLE_BUCKETS: () => MAX_THROTTLE_BUCKETS,
|
|
39
|
+
SSE_PING_MS: () => SSE_PING_MS,
|
|
40
|
+
createHttpApi: () => createHttpApi
|
|
41
|
+
});
|
|
42
|
+
module.exports = __toCommonJS(routers_exports);
|
|
43
|
+
var import_fastify = __toESM(require("fastify"));
|
|
44
|
+
var import_cors = __toESM(require("@fastify/cors"));
|
|
45
|
+
var import_node8 = require("@toolcase/node");
|
|
46
|
+
|
|
47
|
+
// src/routers/shared.ts
|
|
48
|
+
var import_node_crypto2 = require("crypto");
|
|
49
|
+
var import_base = require("@toolcase/base");
|
|
50
|
+
var import_node2 = require("@toolcase/node");
|
|
51
|
+
|
|
52
|
+
// src/orchestrator/AgentAuthenticator.ts
|
|
53
|
+
var import_node_crypto = require("crypto");
|
|
54
|
+
function matchKey(presented, keys) {
|
|
55
|
+
if (typeof presented !== "string" || presented.length === 0 || keys.length === 0) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const presentedDigest = (0, import_node_crypto.createHash)("sha256").update(presented).digest();
|
|
59
|
+
let matched = null;
|
|
60
|
+
for (const key of keys) {
|
|
61
|
+
const candidate = (0, import_node_crypto.createHash)("sha256").update(key).digest();
|
|
62
|
+
if ((0, import_node_crypto.timingSafeEqual)(presentedDigest, candidate)) {
|
|
63
|
+
matched = key;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return matched;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/domain/roomId.ts
|
|
70
|
+
var ROOM_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
71
|
+
|
|
72
|
+
// src/domain/roomCreate.ts
|
|
73
|
+
var roomCreateSchema = {
|
|
74
|
+
type: { type: "string", required: true, min: 1 },
|
|
75
|
+
roomId: { type: "string", pattern: ROOM_ID_PATTERN.source },
|
|
76
|
+
placement: { type: "object" }
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// src/domain/errors.ts
|
|
80
|
+
var import_node = require("@toolcase/node");
|
|
81
|
+
var CODE_TO_STATUS = {
|
|
82
|
+
VALIDATION: 400,
|
|
83
|
+
UNAUTHORIZED: 401,
|
|
84
|
+
INSTANCE_NOT_FOUND: 404,
|
|
85
|
+
ROOM_NOT_FOUND: 404,
|
|
86
|
+
NO_CANDIDATE: 409,
|
|
87
|
+
ROOM_EXISTS: 409,
|
|
88
|
+
INSTANCE_DRAINING: 409,
|
|
89
|
+
PAYLOAD_TOO_LARGE: 413,
|
|
90
|
+
INSTANCE_BUSY: 429,
|
|
91
|
+
AUTH_THROTTLED: 429,
|
|
92
|
+
SSE_LIMIT: 429,
|
|
93
|
+
COMMAND_FAILED: 502,
|
|
94
|
+
INSTANCE_DISCONNECTED: 502,
|
|
95
|
+
COMMAND_TIMEOUT: 504
|
|
96
|
+
};
|
|
97
|
+
var FleetError = class extends import_node.EndpointError {
|
|
98
|
+
constructor(code, message) {
|
|
99
|
+
super(CODE_TO_STATUS[code], code, message);
|
|
100
|
+
this.name = "FleetError";
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// src/util/errors.ts
|
|
105
|
+
function describe(error) {
|
|
106
|
+
return error instanceof Error ? error.message : String(error);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/routers/shared.ts
|
|
110
|
+
var MAX_BODY_BYTES = 64 * 1024;
|
|
111
|
+
var SSE_PING_MS = 15e3;
|
|
112
|
+
var AUTH_FAILURE_LIMIT = 10;
|
|
113
|
+
var AUTH_FAILURE_WINDOW_MS = 6e4;
|
|
114
|
+
var MAX_SSE_STREAMS = 100;
|
|
115
|
+
var MAX_THROTTLE_BUCKETS = 4096;
|
|
116
|
+
function createContext(deps) {
|
|
117
|
+
const now = deps.now ?? Date.now;
|
|
118
|
+
return {
|
|
119
|
+
deps,
|
|
120
|
+
throttle: new AuthThrottle(AUTH_FAILURE_LIMIT, AUTH_FAILURE_WINDOW_MS, now),
|
|
121
|
+
streams: /* @__PURE__ */ new Set(),
|
|
122
|
+
pingMs: deps.ssePingMs ?? SSE_PING_MS,
|
|
123
|
+
maxStreams: deps.maxSseStreams ?? MAX_SSE_STREAMS,
|
|
124
|
+
authInfo: /* @__PURE__ */ new WeakMap()
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
function restOk(reply, data, status = import_base.HTTP.Status.OK) {
|
|
128
|
+
reply.code(status);
|
|
129
|
+
return new import_base.HTTP.RESTResponse(status, data);
|
|
130
|
+
}
|
|
131
|
+
function restError(reply, status, cause) {
|
|
132
|
+
reply.code(status);
|
|
133
|
+
return new import_base.HTTP.RESTError(status, cause).toJSON();
|
|
134
|
+
}
|
|
135
|
+
function installErrorHandlers(fastify, getLogger) {
|
|
136
|
+
fastify.setNotFoundHandler((req, reply) => restError(reply, import_base.HTTP.Status.NOT_FOUND, "NOT_FOUND"));
|
|
137
|
+
fastify.setErrorHandler((error, req, reply) => {
|
|
138
|
+
const meta = (0, import_node2.errorMeta)(error);
|
|
139
|
+
if (meta !== null) {
|
|
140
|
+
return restError(reply, meta.status, meta.code ?? "INTERNAL");
|
|
141
|
+
}
|
|
142
|
+
const status = error.statusCode;
|
|
143
|
+
if (status === import_base.HTTP.Status.PAYLOAD_TOO_LARGE) {
|
|
144
|
+
return restError(reply, import_base.HTTP.Status.PAYLOAD_TOO_LARGE, "PAYLOAD_TOO_LARGE");
|
|
145
|
+
}
|
|
146
|
+
if (typeof status === "number" && status >= 400 && status < 500) {
|
|
147
|
+
return restError(reply, status, "VALIDATION");
|
|
148
|
+
}
|
|
149
|
+
getLogger().error(`unhandled error on ${req.method} ${pathOf(req)}: ${describe(error)}`);
|
|
150
|
+
return restError(reply, import_base.HTTP.Status.INTERNAL_SERVER_ERROR, "INTERNAL");
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function sendConditional(req, reply, deps, data) {
|
|
154
|
+
const etag = weakEtag(deps.fleet.stats.stateHash);
|
|
155
|
+
reply.header("etag", etag);
|
|
156
|
+
if (ifNoneMatchMatches(req, etag)) {
|
|
157
|
+
reply.code(import_base.HTTP.Status.NOT_MODIFIED);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return restOk(reply, data);
|
|
161
|
+
}
|
|
162
|
+
function weakEtag(stateHash) {
|
|
163
|
+
return `W/"${stateHash}"`;
|
|
164
|
+
}
|
|
165
|
+
function ifNoneMatchMatches(req, etag) {
|
|
166
|
+
const header = req.headers["if-none-match"];
|
|
167
|
+
if (typeof header !== "string") {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
if (header.trim() === "*") {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
return header.split(",").some((candidate) => candidate.trim() === etag);
|
|
174
|
+
}
|
|
175
|
+
function bearerToken(req) {
|
|
176
|
+
const header = req.headers["authorization"];
|
|
177
|
+
if (typeof header !== "string") {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
181
|
+
return match === null ? null : match[1];
|
|
182
|
+
}
|
|
183
|
+
function fingerprint(key) {
|
|
184
|
+
return "key#" + (0, import_node_crypto2.createHash)("sha256").update(key).digest("hex").slice(0, 8);
|
|
185
|
+
}
|
|
186
|
+
function remoteIp(req) {
|
|
187
|
+
return req.ip ?? "unknown";
|
|
188
|
+
}
|
|
189
|
+
function pathOf(req) {
|
|
190
|
+
const url = req.url;
|
|
191
|
+
const q = url.indexOf("?");
|
|
192
|
+
return q === -1 ? url : url.slice(0, q);
|
|
193
|
+
}
|
|
194
|
+
function isEventsPath(req) {
|
|
195
|
+
return req.method === "GET" && pathOf(req) === "/v1/events";
|
|
196
|
+
}
|
|
197
|
+
function isMutatingRoute(req) {
|
|
198
|
+
const path = pathOf(req);
|
|
199
|
+
if (req.method === "POST" && path === "/v1/rooms") {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (req.method === "DELETE" && /^\/v1\/rooms\/.+$/.test(path)) {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
if (req.method === "POST" && /^\/v1\/instances\/[^/]+\/(drain|undrain)$/.test(path)) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
async function authHook(ctx, req) {
|
|
211
|
+
const ip = remoteIp(req);
|
|
212
|
+
const path = pathOf(req);
|
|
213
|
+
if (ctx.throttle.blocked(ip)) {
|
|
214
|
+
ctx.deps.getLogger().warning(`auth throttled ip=${ip} route=${req.method} ${path}`);
|
|
215
|
+
throw new FleetError("AUTH_THROTTLED", "too many failed authentication attempts");
|
|
216
|
+
}
|
|
217
|
+
let matched = matchKey(bearerToken(req), ctx.deps.config.adminKeys);
|
|
218
|
+
if (matched === null && isEventsPath(req) && ctx.deps.config.sseQueryAuth) {
|
|
219
|
+
const queryKey = req.query?.["key"];
|
|
220
|
+
if (typeof queryKey === "string") {
|
|
221
|
+
matched = matchKey(queryKey, ctx.deps.config.adminKeys);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (matched === null) {
|
|
225
|
+
ctx.throttle.recordFailure(ip);
|
|
226
|
+
ctx.deps.getLogger().warning(`auth failure ip=${ip} route=${req.method} ${path}`);
|
|
227
|
+
throw new FleetError("UNAUTHORIZED", "missing or invalid admin key");
|
|
228
|
+
}
|
|
229
|
+
ctx.authInfo.set(req, { fingerprint: fingerprint(matched), ip });
|
|
230
|
+
}
|
|
231
|
+
async function auditHook(ctx, req, reply) {
|
|
232
|
+
if (!isMutatingRoute(req)) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const info = ctx.authInfo.get(req);
|
|
236
|
+
ctx.deps.getLogger().info(
|
|
237
|
+
`audit route=${req.method} ${pathOf(req)} key=${info?.fingerprint ?? "unknown"} ip=${info?.ip ?? remoteIp(req)} outcome=${reply.statusCode}`
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
function corsHeadersForSse(req, cors2) {
|
|
241
|
+
if (cors2 === false) {
|
|
242
|
+
return {};
|
|
243
|
+
}
|
|
244
|
+
const origin = req.headers["origin"];
|
|
245
|
+
if (typeof origin !== "string") {
|
|
246
|
+
return {};
|
|
247
|
+
}
|
|
248
|
+
if (cors2.origins.includes("*")) {
|
|
249
|
+
return { "access-control-allow-origin": "*" };
|
|
250
|
+
}
|
|
251
|
+
if (cors2.origins.includes(origin)) {
|
|
252
|
+
return { "access-control-allow-origin": origin, vary: "Origin" };
|
|
253
|
+
}
|
|
254
|
+
return {};
|
|
255
|
+
}
|
|
256
|
+
var AuthThrottle = class {
|
|
257
|
+
constructor(limit, windowMs, now, maxBuckets = MAX_THROTTLE_BUCKETS) {
|
|
258
|
+
this.limit = limit;
|
|
259
|
+
this.windowMs = windowMs;
|
|
260
|
+
this.now = now;
|
|
261
|
+
this.maxBuckets = maxBuckets;
|
|
262
|
+
}
|
|
263
|
+
limit;
|
|
264
|
+
windowMs;
|
|
265
|
+
now;
|
|
266
|
+
maxBuckets;
|
|
267
|
+
buckets = /* @__PURE__ */ new Map();
|
|
268
|
+
/** Wall-clock of the last opportunistic sweep; gates pruning to once per window. */
|
|
269
|
+
lastPruneAt = -Infinity;
|
|
270
|
+
/** True when the IP is over its failed-auth budget (no tokens left). */
|
|
271
|
+
blocked(ip) {
|
|
272
|
+
return this.refill(ip).tokens < 1;
|
|
273
|
+
}
|
|
274
|
+
/** Charge one token for a failed attempt (floored at zero). */
|
|
275
|
+
recordFailure(ip) {
|
|
276
|
+
const bucket = this.refill(ip);
|
|
277
|
+
bucket.tokens = Math.max(0, bucket.tokens - 1);
|
|
278
|
+
}
|
|
279
|
+
/** Current bucket count — a test seam for the §13 memory-bound assertions. */
|
|
280
|
+
get size() {
|
|
281
|
+
return this.buckets.size;
|
|
282
|
+
}
|
|
283
|
+
refill(ip) {
|
|
284
|
+
const now = this.now();
|
|
285
|
+
this.prune(now);
|
|
286
|
+
let bucket = this.buckets.get(ip);
|
|
287
|
+
if (bucket === void 0) {
|
|
288
|
+
bucket = { tokens: this.limit, last: now };
|
|
289
|
+
this.buckets.set(ip, bucket);
|
|
290
|
+
this.evictIfOver();
|
|
291
|
+
return bucket;
|
|
292
|
+
}
|
|
293
|
+
const elapsed = now - bucket.last;
|
|
294
|
+
if (elapsed > 0) {
|
|
295
|
+
bucket.tokens = Math.min(this.limit, bucket.tokens + elapsed / this.windowMs * this.limit);
|
|
296
|
+
bucket.last = now;
|
|
297
|
+
}
|
|
298
|
+
return bucket;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Opportunistic sweep (≤ once per window): delete every bucket that has fully
|
|
302
|
+
* refilled and not been touched within the last window. Such a bucket holds no
|
|
303
|
+
* information — a fresh IP starts full — so removing it cannot un-throttle anyone.
|
|
304
|
+
* Computing the *refilled* token count (not the stored one) also reclaims buckets
|
|
305
|
+
* stuck below full only because the IP never returned after a single failure.
|
|
306
|
+
*/
|
|
307
|
+
prune(now) {
|
|
308
|
+
if (now - this.lastPruneAt < this.windowMs) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
this.lastPruneAt = now;
|
|
312
|
+
for (const [ip, bucket] of this.buckets) {
|
|
313
|
+
const elapsed = now - bucket.last;
|
|
314
|
+
if (elapsed <= this.windowMs) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
const refilled = Math.min(this.limit, bucket.tokens + elapsed / this.windowMs * this.limit);
|
|
318
|
+
if (refilled >= this.limit) {
|
|
319
|
+
this.buckets.delete(ip);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/** Hard cap: when over {@link maxBuckets}, evict the oldest-touched bucket. */
|
|
324
|
+
evictIfOver() {
|
|
325
|
+
if (this.buckets.size <= this.maxBuckets) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
let oldestIp = null;
|
|
329
|
+
let oldest = Infinity;
|
|
330
|
+
for (const [ip, bucket] of this.buckets) {
|
|
331
|
+
if (bucket.last < oldest) {
|
|
332
|
+
oldest = bucket.last;
|
|
333
|
+
oldestIp = ip;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (oldestIp !== null) {
|
|
337
|
+
this.buckets.delete(oldestIp);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// src/routers/HealthRouter.ts
|
|
343
|
+
var import_node3 = require("@toolcase/node");
|
|
344
|
+
var import_base2 = require("@toolcase/base");
|
|
345
|
+
var HealthRouter = class extends import_node3.RouteHandler {
|
|
346
|
+
constructor(ctx) {
|
|
347
|
+
super();
|
|
348
|
+
this.ctx = ctx;
|
|
349
|
+
}
|
|
350
|
+
ctx;
|
|
351
|
+
register(fastify) {
|
|
352
|
+
fastify.get("/healthz", async (_req, reply) => restOk(reply));
|
|
353
|
+
fastify.get("/readyz", async (_req, reply) => {
|
|
354
|
+
if (this.ctx.deps.isReady()) {
|
|
355
|
+
return restOk(reply);
|
|
356
|
+
}
|
|
357
|
+
return restError(reply, import_base2.HTTP.Status.SERVICE_UNAVAILABLE, "NOT_READY");
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// src/routers/StatsRouter.ts
|
|
363
|
+
var import_node4 = require("@toolcase/node");
|
|
364
|
+
var StatsRouter = class extends import_node4.RouteHandler {
|
|
365
|
+
constructor(ctx) {
|
|
366
|
+
super();
|
|
367
|
+
this.ctx = ctx;
|
|
368
|
+
}
|
|
369
|
+
ctx;
|
|
370
|
+
register(fastify) {
|
|
371
|
+
fastify.get("/stats", async (req, reply) => sendConditional(req, reply, this.ctx.deps, this.ctx.deps.fleet.stats));
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// src/routers/InstancesRouter.ts
|
|
376
|
+
var import_node5 = require("@toolcase/node");
|
|
377
|
+
var InstancesRouter = class extends import_node5.RouteHandler {
|
|
378
|
+
constructor(ctx) {
|
|
379
|
+
super();
|
|
380
|
+
this.ctx = ctx;
|
|
381
|
+
}
|
|
382
|
+
ctx;
|
|
383
|
+
register(fastify) {
|
|
384
|
+
const deps = this.ctx.deps;
|
|
385
|
+
fastify.get("/instances", async (req, reply) => sendConditional(req, reply, deps, deps.fleet.instances));
|
|
386
|
+
fastify.get("/instances/:id", async (req, reply) => {
|
|
387
|
+
const id = paramId(req);
|
|
388
|
+
const instance = deps.fleet.getInstance(id);
|
|
389
|
+
if (instance === null) {
|
|
390
|
+
throw new FleetError("INSTANCE_NOT_FOUND", `instance ${id} not found`);
|
|
391
|
+
}
|
|
392
|
+
return restOk(reply, instance);
|
|
393
|
+
});
|
|
394
|
+
fastify.get("/instances/:id/rooms", async (req, reply) => {
|
|
395
|
+
const id = paramId(req);
|
|
396
|
+
if (deps.fleet.getInstance(id) === null) {
|
|
397
|
+
throw new FleetError("INSTANCE_NOT_FOUND", `instance ${id} not found`);
|
|
398
|
+
}
|
|
399
|
+
return restOk(reply, deps.fleet.findRooms({ instanceId: id }));
|
|
400
|
+
});
|
|
401
|
+
fastify.post("/instances/:id/drain", async (req, reply) => {
|
|
402
|
+
await deps.fleet.drainInstance(paramId(req));
|
|
403
|
+
return restOk(reply);
|
|
404
|
+
});
|
|
405
|
+
fastify.post("/instances/:id/undrain", async (req, reply) => {
|
|
406
|
+
await deps.fleet.undrainInstance(paramId(req));
|
|
407
|
+
return restOk(reply);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
function paramId(req) {
|
|
412
|
+
return req.params.id;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/routers/RoomsRouter.ts
|
|
416
|
+
var import_node6 = require("@toolcase/node");
|
|
417
|
+
var import_base3 = require("@toolcase/base");
|
|
418
|
+
var roomCreateBodySchema = (0, import_node6.deriveJsonSchema)(roomCreateSchema, "create");
|
|
419
|
+
var RoomsRouter = class extends import_node6.RouteHandler {
|
|
420
|
+
constructor(ctx) {
|
|
421
|
+
super();
|
|
422
|
+
this.ctx = ctx;
|
|
423
|
+
}
|
|
424
|
+
ctx;
|
|
425
|
+
register(fastify) {
|
|
426
|
+
const deps = this.ctx.deps;
|
|
427
|
+
fastify.get("/rooms", async (req, reply) => sendConditional(req, reply, deps, deps.fleet.findRooms(roomFilter(req))));
|
|
428
|
+
fastify.post("/rooms", { schema: { body: roomCreateBodySchema } }, async (req, reply) => {
|
|
429
|
+
const created = await deps.fleet.createRoom(
|
|
430
|
+
req.body
|
|
431
|
+
);
|
|
432
|
+
return restOk(reply, created, import_base3.HTTP.Status.CREATED);
|
|
433
|
+
});
|
|
434
|
+
fastify.get("/rooms/:roomId", async (req, reply) => {
|
|
435
|
+
const roomId = publicRoomId(req);
|
|
436
|
+
const room = deps.fleet.getRoom(roomId);
|
|
437
|
+
if (room === null) {
|
|
438
|
+
throw new FleetError("ROOM_NOT_FOUND", `room ${roomId} not found`);
|
|
439
|
+
}
|
|
440
|
+
return restOk(reply, room);
|
|
441
|
+
});
|
|
442
|
+
fastify.delete("/rooms/:roomId", async (req, reply) => {
|
|
443
|
+
await deps.fleet.destroyRoom(publicRoomId(req));
|
|
444
|
+
return restOk(reply);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
function roomFilter(req) {
|
|
449
|
+
const query = req.query ?? {};
|
|
450
|
+
const filter = {};
|
|
451
|
+
if (typeof query.type === "string") {
|
|
452
|
+
filter.type = query.type;
|
|
453
|
+
}
|
|
454
|
+
if (typeof query.instanceId === "string") {
|
|
455
|
+
filter.instanceId = query.instanceId;
|
|
456
|
+
}
|
|
457
|
+
const raw = query.label;
|
|
458
|
+
const labelParams = Array.isArray(raw) ? raw : raw !== void 0 ? [raw] : [];
|
|
459
|
+
if (labelParams.length > 0) {
|
|
460
|
+
const labels = {};
|
|
461
|
+
for (const entry of labelParams) {
|
|
462
|
+
if (typeof entry !== "string") {
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
const idx = entry.indexOf(":");
|
|
466
|
+
if (idx > 0) {
|
|
467
|
+
labels[entry.slice(0, idx)] = entry.slice(idx + 1);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
filter.labels = labels;
|
|
471
|
+
}
|
|
472
|
+
return filter;
|
|
473
|
+
}
|
|
474
|
+
function publicRoomId(req) {
|
|
475
|
+
const path = pathnameOf(req);
|
|
476
|
+
const segments = path.split("/");
|
|
477
|
+
return segments[segments.length - 1] ?? "";
|
|
478
|
+
}
|
|
479
|
+
function pathnameOf(req) {
|
|
480
|
+
const url = req.url;
|
|
481
|
+
const q = url.indexOf("?");
|
|
482
|
+
return q === -1 ? url : url.slice(0, q);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// src/routers/EventsRouter.ts
|
|
486
|
+
var import_node7 = require("@toolcase/node");
|
|
487
|
+
var EventsRouter = class extends import_node7.RouteHandler {
|
|
488
|
+
constructor(ctx) {
|
|
489
|
+
super();
|
|
490
|
+
this.ctx = ctx;
|
|
491
|
+
}
|
|
492
|
+
ctx;
|
|
493
|
+
register(fastify) {
|
|
494
|
+
fastify.get("/events", async (req, reply) => this.stream(req, reply));
|
|
495
|
+
}
|
|
496
|
+
stream(req, reply) {
|
|
497
|
+
const ctx = this.ctx;
|
|
498
|
+
if (ctx.streams.size >= ctx.maxStreams) {
|
|
499
|
+
ctx.deps.getLogger().warning(
|
|
500
|
+
`sse stream cap reached (${ctx.maxStreams}) \u2014 rejecting new stream from ip=${remoteIp(req)}`
|
|
501
|
+
);
|
|
502
|
+
throw new FleetError("SSE_LIMIT", `concurrent SSE stream cap reached (${ctx.maxStreams})`);
|
|
503
|
+
}
|
|
504
|
+
reply.hijack();
|
|
505
|
+
const raw = reply.raw;
|
|
506
|
+
raw.writeHead(200, {
|
|
507
|
+
...corsHeadersForSse(req, ctx.deps.config.cors),
|
|
508
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
509
|
+
"cache-control": "no-cache, no-transform",
|
|
510
|
+
connection: "keep-alive",
|
|
511
|
+
// No Last-Event-ID replay (§10): a reconnecting consumer re-GETs stats+instances.
|
|
512
|
+
"x-accel-buffering": "no"
|
|
513
|
+
});
|
|
514
|
+
const write = (chunk) => {
|
|
515
|
+
if (raw.writableEnded || raw.destroyed) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
try {
|
|
519
|
+
raw.write(chunk);
|
|
520
|
+
} catch {
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
write(": connected\n\n");
|
|
524
|
+
const unsubscribe = ctx.deps.subscribe((event) => {
|
|
525
|
+
write(`event: ${event.type}
|
|
526
|
+
data: ${JSON.stringify(event.data ?? null)}
|
|
527
|
+
|
|
528
|
+
`);
|
|
529
|
+
});
|
|
530
|
+
const ping = setInterval(() => write(": ping\n\n"), ctx.pingMs);
|
|
531
|
+
ping.unref?.();
|
|
532
|
+
const stream = {
|
|
533
|
+
end: () => {
|
|
534
|
+
if (!raw.writableEnded) {
|
|
535
|
+
raw.end();
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
cleanup: () => {
|
|
539
|
+
clearInterval(ping);
|
|
540
|
+
unsubscribe();
|
|
541
|
+
ctx.streams.delete(stream);
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
ctx.streams.add(stream);
|
|
545
|
+
req.raw.on("close", () => stream.cleanup());
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
// src/routers/index.ts
|
|
550
|
+
function createHttpApi(deps, options = {}) {
|
|
551
|
+
const ctx = createContext(deps);
|
|
552
|
+
const base = { logger: false, bodyLimit: MAX_BODY_BYTES, trustProxy: deps.config.trustProxy };
|
|
553
|
+
const fastify = options.serverFactory !== void 0 ? (0, import_fastify.default)({ ...base, serverFactory: options.serverFactory }) : (0, import_fastify.default)({ ...base });
|
|
554
|
+
installErrorHandlers(fastify, deps.getLogger);
|
|
555
|
+
if (deps.config.cors !== false) {
|
|
556
|
+
const origins = deps.config.cors.origins;
|
|
557
|
+
void fastify.register(import_cors.default, { origin: origins.includes("*") ? "*" : origins });
|
|
558
|
+
}
|
|
559
|
+
new HealthRouter(ctx).register(fastify);
|
|
560
|
+
if (deps.config.api) {
|
|
561
|
+
void fastify.register(async (v1) => {
|
|
562
|
+
v1.addHook("onRequest", (req) => authHook(ctx, req));
|
|
563
|
+
v1.addHook("onResponse", (req, reply) => auditHook(ctx, req, reply));
|
|
564
|
+
new import_node8.Router().add(new StatsRouter(ctx)).add(new InstancesRouter(ctx)).add(new RoomsRouter(ctx)).add(new EventsRouter(ctx)).register(v1);
|
|
565
|
+
}, { prefix: "/v1" });
|
|
566
|
+
}
|
|
567
|
+
const drainStreams = () => {
|
|
568
|
+
for (const stream of [...ctx.streams]) {
|
|
569
|
+
stream.cleanup();
|
|
570
|
+
stream.end();
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
return {
|
|
574
|
+
fastify,
|
|
575
|
+
ready: async () => {
|
|
576
|
+
await fastify.ready();
|
|
577
|
+
},
|
|
578
|
+
listen: async (opts) => {
|
|
579
|
+
await fastify.listen(opts);
|
|
580
|
+
},
|
|
581
|
+
shutdown: drainStreams,
|
|
582
|
+
close: async () => {
|
|
583
|
+
drainStreams();
|
|
584
|
+
await fastify.close();
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
589
|
+
0 && (module.exports = {
|
|
590
|
+
AUTH_FAILURE_LIMIT,
|
|
591
|
+
AUTH_FAILURE_WINDOW_MS,
|
|
592
|
+
AuthThrottle,
|
|
593
|
+
MAX_BODY_BYTES,
|
|
594
|
+
MAX_SSE_STREAMS,
|
|
595
|
+
MAX_THROTTLE_BUCKETS,
|
|
596
|
+
SSE_PING_MS,
|
|
597
|
+
createHttpApi
|
|
598
|
+
});
|