@twasik4/pocket-service 1.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 +241 -0
- package/package.json +39 -0
- package/src/index.ts +1 -0
- package/src/worker-thread.ts +159 -0
- package/src/workers/db/clickhouse/index.ts +76 -0
- package/src/workers/db/mongo/collection.ts +292 -0
- package/src/workers/db/mongo/mongo.ts +285 -0
- package/src/workers/express-types.ts +314 -0
- package/src/workers/index.ts +6 -0
- package/src/workers/logger.ts +19 -0
- package/src/workers/service.ts +921 -0
- package/src/workers/stream-handler.ts +25 -0
- package/src/workers/types.ts +59 -0
- package/src/workers/utils.ts +19 -0
- package/tests/mongo.spec.ts +92 -0
- package/tests/redis.spec.ts +36 -0
- package/tests/service.spec.ts +82 -0
- package/tsconfig.json +12 -0
- package/tsup.config.ts +11 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
import express, { Express, Router } from "express";
|
|
2
|
+
import { existsSync, readdirSync } from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { createClient, RedisClientType } from "redis";
|
|
5
|
+
import { Logger } from "winston";
|
|
6
|
+
import { Worker } from "worker_threads";
|
|
7
|
+
import { createMetaRouter, RouteDefinition } from "./express-types";
|
|
8
|
+
import { ClickhouseConfig, createTypedClickhouse } from "./db/clickhouse";
|
|
9
|
+
import {
|
|
10
|
+
createMongo,
|
|
11
|
+
InferMongoCollections,
|
|
12
|
+
MongoCollectionsConfig,
|
|
13
|
+
MongoDatabase,
|
|
14
|
+
} from "./db/mongo/mongo";
|
|
15
|
+
import { createLogger } from "./logger";
|
|
16
|
+
import { generateRandomHexString, getUseableDatesFromMs } from "./utils";
|
|
17
|
+
import { CustomModules, ExpressOptions } from "./types";
|
|
18
|
+
import cors from "cors";
|
|
19
|
+
|
|
20
|
+
export class Service<TCollections extends MongoCollectionsConfig = {}> {
|
|
21
|
+
private name: string =
|
|
22
|
+
process.env.SERVICE_NAME ?? generateRandomHexString(12);
|
|
23
|
+
private url: string = process.env.SERVICE_URL ?? "http://localhost";
|
|
24
|
+
private port: number = process.env.SERVICE_PORT
|
|
25
|
+
? Number(process.env.SERVICE_PORT)
|
|
26
|
+
: 3100;
|
|
27
|
+
private workerCount: number = 1;
|
|
28
|
+
private handlers: Record<string, string> = {};
|
|
29
|
+
private loadedEventListeners: string[] = [];
|
|
30
|
+
private routers: {
|
|
31
|
+
base: string;
|
|
32
|
+
router: Router;
|
|
33
|
+
routes?: RouteDefinition<any, any, boolean>[];
|
|
34
|
+
}[] = [];
|
|
35
|
+
private startedAt: number = Date.now();
|
|
36
|
+
private dependencies: Map<string, unknown> = new Map<string, unknown>();
|
|
37
|
+
private dependencyOrder: string[] = [];
|
|
38
|
+
private isWorkerRunning: boolean = false;
|
|
39
|
+
private serviceStreamSubscriptions: string[] = [];
|
|
40
|
+
private _status: "starting" | "running" | "stopped" | "stopping" = "starting";
|
|
41
|
+
private readonly HEARTBEAT_INTERVAL = 1000 * 5;
|
|
42
|
+
private expressOptions: ExpressOptions = {
|
|
43
|
+
corsWhitelist: ["*"],
|
|
44
|
+
asJson: true,
|
|
45
|
+
customMiddleware: [],
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
get streamKey() {
|
|
49
|
+
return this.simpleName + ":events";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
get simpleName() {
|
|
53
|
+
return this.name.split("-")[0];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get fullUrl() {
|
|
57
|
+
return `${this.url}:${this.port}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get api(): Express {
|
|
61
|
+
if (!this.dependencies.has("expressApp")) {
|
|
62
|
+
this.dependencies.set("expressApp", express());
|
|
63
|
+
}
|
|
64
|
+
return this.dependencies.get("expressApp") as Express;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get log(): Logger {
|
|
68
|
+
if (!this.dependencies.has("logger")) {
|
|
69
|
+
this.dependencies.set("logger", createLogger(this.name));
|
|
70
|
+
}
|
|
71
|
+
const logger = this.dependencies.get("logger");
|
|
72
|
+
if (!(logger instanceof Logger)) {
|
|
73
|
+
throw new Error("Logger is not of type Logger.");
|
|
74
|
+
}
|
|
75
|
+
return logger;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get routes() {
|
|
79
|
+
return this.routers.flatMap(
|
|
80
|
+
(r) =>
|
|
81
|
+
r.routes?.map((route) => {
|
|
82
|
+
const base = r.base === "/" ? "" : r.base;
|
|
83
|
+
const fullPath = route.fullPath === "/" ? "" : route.fullPath;
|
|
84
|
+
const combined =
|
|
85
|
+
`${base}${fullPath}` === "" ? "/" : `${base}${fullPath}`;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
path: combined,
|
|
89
|
+
method: route.method,
|
|
90
|
+
requireAuth: route.requireAuth ?? false,
|
|
91
|
+
meta: route.meta,
|
|
92
|
+
};
|
|
93
|
+
}) || [],
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get status() {
|
|
98
|
+
return this._status;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* MongoDB client instance.
|
|
103
|
+
*
|
|
104
|
+
* @requires MongoDB module to be added via withMongo()
|
|
105
|
+
*/
|
|
106
|
+
get mongo() {
|
|
107
|
+
if (!this.dependencies.has("mongoClient")) {
|
|
108
|
+
throw new Error("MongoDB module has not been added.");
|
|
109
|
+
}
|
|
110
|
+
return this.dependencies.get("mongoClient") as MongoDatabase<TCollections>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Typed MongoDB collection wrappers registered through `withMongo()`.
|
|
115
|
+
*
|
|
116
|
+
* @requires MongoDB module to be added via withMongo()
|
|
117
|
+
*/
|
|
118
|
+
get db(): InferMongoCollections<TCollections> {
|
|
119
|
+
if (!this.dependencies.has("mongoClient")) {
|
|
120
|
+
throw new Error("MongoDB module has not been added.");
|
|
121
|
+
}
|
|
122
|
+
return this.mongo.collections;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Clickhouse client instance. This will throw an error if Clickhouse module has not been added.
|
|
127
|
+
*
|
|
128
|
+
* @requires ClickhouseModule to be added via withClickhouse()
|
|
129
|
+
* @beta
|
|
130
|
+
*/
|
|
131
|
+
get clickhouse() {
|
|
132
|
+
if (!this.dependencies.has("clickhouse")) {
|
|
133
|
+
throw new Error("Clickhouse module has not been added.");
|
|
134
|
+
}
|
|
135
|
+
return this.dependencies.get("clickhouse") as ReturnType<
|
|
136
|
+
typeof createTypedClickhouse
|
|
137
|
+
>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Redis client instance. This will throw an error if Redis module has not been added.
|
|
142
|
+
*
|
|
143
|
+
* @requires RedisModule to be added via withRedis()
|
|
144
|
+
*/
|
|
145
|
+
get redis(): RedisClientType {
|
|
146
|
+
if (!this.dependencies.has("redis")) {
|
|
147
|
+
throw new Error("Redis module has not been added.");
|
|
148
|
+
}
|
|
149
|
+
return this.dependencies.get("redis") as RedisClientType;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Registers the service in Redis for service discovery.
|
|
154
|
+
*
|
|
155
|
+
* @requires RedisModule to be added via withRedis()
|
|
156
|
+
*/
|
|
157
|
+
async register() {
|
|
158
|
+
if (!this.dependencies.has("redis")) {
|
|
159
|
+
this.log.warn(
|
|
160
|
+
"Redis module not initialized. Call withRedis() to enable service registration.",
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const key = `services:registry:${this.name}`;
|
|
165
|
+
const value = JSON.stringify({
|
|
166
|
+
name: this.name,
|
|
167
|
+
streamKey: this.streamKey,
|
|
168
|
+
url: this.fullUrl,
|
|
169
|
+
dependencies: Object.keys(this.dependencies),
|
|
170
|
+
routes: this.routes,
|
|
171
|
+
lastHeartbeat: Date.now(),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await this.redis.set(key, value, {
|
|
175
|
+
EX: 30, // TTL of 30 seconds
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Periodically update the service registration in Redis.
|
|
181
|
+
*
|
|
182
|
+
* @requires RedisModule to be added via withRedis()
|
|
183
|
+
*/
|
|
184
|
+
async heartbeat() {
|
|
185
|
+
if (!this.dependencies.has("redis")) {
|
|
186
|
+
this.log.warn(
|
|
187
|
+
"Redis module not initialized. Call withRedis() to enable heartbeat.",
|
|
188
|
+
);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
setInterval(async () => {
|
|
192
|
+
await this.register();
|
|
193
|
+
}, this.HEARTBEAT_INTERVAL);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Builds and starts the service by initializing dependencies, setting up routes, and starting the API server.
|
|
198
|
+
*
|
|
199
|
+
* The build process includes:
|
|
200
|
+
* 1. Initializing dependencies in the order they were added (MongoDB, Redis, Clickhouse). This includes the redis workers module if withWorkers() was called.
|
|
201
|
+
* 2. Initializing any custom modules provided through withModules().
|
|
202
|
+
* 3. Setting up Express middleware and CORS based on provided options.
|
|
203
|
+
* 4. Registering default routes like /health and /workers.
|
|
204
|
+
* 5. Registering any custom routers added through addRouter().
|
|
205
|
+
* 6. Starting the Express server on the configured port.
|
|
206
|
+
*/
|
|
207
|
+
build() {
|
|
208
|
+
try {
|
|
209
|
+
this._status = "starting";
|
|
210
|
+
|
|
211
|
+
Promise.allSettled(
|
|
212
|
+
this.dependencyOrder
|
|
213
|
+
.filter((f) => f !== "customDependencies")
|
|
214
|
+
.map((key) => {
|
|
215
|
+
switch (key) {
|
|
216
|
+
case "redis": {
|
|
217
|
+
if (this.dependencies.has("workers")) {
|
|
218
|
+
this.loadHandlers();
|
|
219
|
+
this.runWorkers();
|
|
220
|
+
} else {
|
|
221
|
+
this.log.warn(
|
|
222
|
+
"Worker module not initialized. Call withWorkers() to enable worker threads and dynamically load handlers.",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.redis
|
|
227
|
+
.connect()
|
|
228
|
+
.then(async () => {
|
|
229
|
+
this.log.info("Redis module loaded");
|
|
230
|
+
this.register();
|
|
231
|
+
this.log.info("Service registered in Redis");
|
|
232
|
+
this.heartbeat();
|
|
233
|
+
this.log.info("Heartbeat started");
|
|
234
|
+
|
|
235
|
+
process.on("SIGINT", () => {
|
|
236
|
+
this.log.info(
|
|
237
|
+
"SIGINT received. Shutting down gracefully...",
|
|
238
|
+
);
|
|
239
|
+
this.shutdown();
|
|
240
|
+
});
|
|
241
|
+
})
|
|
242
|
+
.catch((err) => {
|
|
243
|
+
this.log.error(`Failed to load Redis module: ${err}`);
|
|
244
|
+
});
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case "mongoClient": {
|
|
248
|
+
this.log.info("Connecting to MongoDB...");
|
|
249
|
+
this.mongo
|
|
250
|
+
.connect()
|
|
251
|
+
.then(() => {
|
|
252
|
+
this.log.info("Mongo module loaded");
|
|
253
|
+
})
|
|
254
|
+
.catch((err) => {
|
|
255
|
+
this.log.error(`Failed to connect to MongoDB: ${err}`);
|
|
256
|
+
throw err;
|
|
257
|
+
});
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case "clickhouse": {
|
|
261
|
+
this.log.info("Connecting to ClickHouse...");
|
|
262
|
+
this.clickhouse
|
|
263
|
+
.ensureTables()
|
|
264
|
+
.then(() => {
|
|
265
|
+
this.log.info("ClickHouse module loaded");
|
|
266
|
+
})
|
|
267
|
+
.catch((err) => {
|
|
268
|
+
this.log.error(`Failed to load ClickHouse module: ${err}`);
|
|
269
|
+
});
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
default:
|
|
273
|
+
this.log.info(`Unknown module ${key} during initialization.`);
|
|
274
|
+
}
|
|
275
|
+
}),
|
|
276
|
+
).then((results) => {
|
|
277
|
+
results.forEach((result, index) => {
|
|
278
|
+
const key = this.dependencyOrder[index];
|
|
279
|
+
|
|
280
|
+
if (result.status === "fulfilled") {
|
|
281
|
+
this.log.info(`Module "${key}" initialized successfully.`);
|
|
282
|
+
} else {
|
|
283
|
+
this.log.error(
|
|
284
|
+
`Failed to initialize module "${key}": ${result.reason}`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (this.dependencies.has("customDependencies")) {
|
|
290
|
+
const customModules = this.dependencies.get(
|
|
291
|
+
"customDependencies",
|
|
292
|
+
) as CustomModules;
|
|
293
|
+
|
|
294
|
+
for (const module of customModules) {
|
|
295
|
+
try {
|
|
296
|
+
module.init(this.log);
|
|
297
|
+
} catch (err) {
|
|
298
|
+
this.log.error(
|
|
299
|
+
`Failed to initialize custom module "${module.name}": ${err}`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
if (this.expressOptions.asJson) {
|
|
307
|
+
this.log.info("Configuring Express to parse JSON bodies");
|
|
308
|
+
this.api.use(express.json());
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const mw of this.expressOptions.customMiddleware || []) {
|
|
312
|
+
this.log.info("Adding custom middleware to Express");
|
|
313
|
+
this.api.use(mw);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if ("corsWhitelist" in this.expressOptions) {
|
|
317
|
+
this.log.info(
|
|
318
|
+
"Configuring CORS with whitelist: " +
|
|
319
|
+
this.expressOptions.corsWhitelist,
|
|
320
|
+
);
|
|
321
|
+
this.api.use(
|
|
322
|
+
cors({
|
|
323
|
+
origin: this.expressOptions.corsWhitelist,
|
|
324
|
+
credentials: this.expressOptions.credentials,
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
} else if ("corsFn" in this.expressOptions) {
|
|
328
|
+
this.log.info("Configuring CORS with function");
|
|
329
|
+
this.api.use(
|
|
330
|
+
cors({
|
|
331
|
+
origin: this.expressOptions.corsFn,
|
|
332
|
+
credentials: this.expressOptions.credentials,
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// routers
|
|
338
|
+
if (!this.expressOptions.omitDefaultRoutes) {
|
|
339
|
+
this.registerDefaultRoutes();
|
|
340
|
+
}
|
|
341
|
+
this.log.info(`Registering ${this.routers.length} routers`);
|
|
342
|
+
this.routers.forEach(({ base, router }) => {
|
|
343
|
+
this.api.use(base, router);
|
|
344
|
+
});
|
|
345
|
+
this.log.info(
|
|
346
|
+
"Routers registered:" +
|
|
347
|
+
this.routers
|
|
348
|
+
.map((rt) => {
|
|
349
|
+
const maxMethodLength = rt.routes
|
|
350
|
+
? Math.max(...rt.routes?.map((r) => r.method.length))
|
|
351
|
+
: 0;
|
|
352
|
+
const maxPathLength = rt.routes
|
|
353
|
+
? Math.max(...rt.routes?.map((r) => r.fullPath.length))
|
|
354
|
+
: 0;
|
|
355
|
+
const maxAuthLength = rt.routes
|
|
356
|
+
? Math.max(...rt.routes?.map((r) => (r.requireAuth ? 6 : 8)))
|
|
357
|
+
: 0;
|
|
358
|
+
|
|
359
|
+
return `\nRouter ${rt.base}: ${
|
|
360
|
+
rt.routes?.length || 0
|
|
361
|
+
} routes registered.\n${
|
|
362
|
+
rt.routes
|
|
363
|
+
?.map(
|
|
364
|
+
(r) =>
|
|
365
|
+
`\t${("[" + r.method + "]").padEnd(maxMethodLength + 2)} ${(r.requireAuth ? "[Auth]" : "[Public]").padEnd(maxAuthLength)} ${r.fullPath.padEnd(maxPathLength)} | ${r.meta?.description || ""}`,
|
|
366
|
+
)
|
|
367
|
+
.join("\n") || ""
|
|
368
|
+
}`;
|
|
369
|
+
})
|
|
370
|
+
.join("\n"),
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
this.api.listen(this.port, () => {
|
|
374
|
+
this.log.info(`API listening at ${this.fullUrl}`);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
this._status = "running";
|
|
378
|
+
return this;
|
|
379
|
+
} catch (err) {
|
|
380
|
+
this.log.error(`Failed to build service: ${err}`);
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Reads all files in the src/handlers directory and imports them dynamically.
|
|
387
|
+
*/
|
|
388
|
+
private loadHandlers() {
|
|
389
|
+
if (!this.dependencies.has("redis")) {
|
|
390
|
+
this.log.warn("Redis module not initialized. Skipping handler loading.");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const handlersDir = path.resolve(process.cwd(), "src/handlers");
|
|
394
|
+
if (!existsSync(handlersDir)) {
|
|
395
|
+
this.log.warn("Handlers directory does not exist.");
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const files = readdirSync(handlersDir).filter(
|
|
400
|
+
(f) => f.endsWith(".ts") || f.endsWith(".js"),
|
|
401
|
+
);
|
|
402
|
+
|
|
403
|
+
for (const file of files) {
|
|
404
|
+
const modulePath = path.join(handlersDir, file);
|
|
405
|
+
this.handlers[path.basename(file, path.extname(file))] = modulePath;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
this.log.info(`Loaded ${Object.keys(this.handlers).length} handlers:`);
|
|
409
|
+
for (const handlerName of Object.keys(this.handlers)) {
|
|
410
|
+
this.log.info(`- ${handlerName}`);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Registers default routes like /health and /workers
|
|
416
|
+
*/
|
|
417
|
+
private registerDefaultRoutes() {
|
|
418
|
+
this.initBaseRoutes();
|
|
419
|
+
if (this.dependencies.has("redis") && this.dependencies.has("workers")) {
|
|
420
|
+
this.log.info(
|
|
421
|
+
"Redis and handler modules detected. Registering worker routes.",
|
|
422
|
+
);
|
|
423
|
+
this.initWorkerRoutes();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
this.log.info("Default routes registered.");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private initBaseRoutes() {
|
|
430
|
+
this.log.info("Registering base routes");
|
|
431
|
+
const { router, routes, addRoute } = createMetaRouter();
|
|
432
|
+
|
|
433
|
+
addRoute(
|
|
434
|
+
{
|
|
435
|
+
method: "GET",
|
|
436
|
+
fullPath: "/",
|
|
437
|
+
requireAuth: false,
|
|
438
|
+
meta: {
|
|
439
|
+
description: "Base route",
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
(_, res) => {
|
|
443
|
+
res.send('OK from "' + this.name + '" service');
|
|
444
|
+
},
|
|
445
|
+
);
|
|
446
|
+
|
|
447
|
+
addRoute(
|
|
448
|
+
{
|
|
449
|
+
method: "GET",
|
|
450
|
+
fullPath: "/health",
|
|
451
|
+
requireAuth: false,
|
|
452
|
+
meta: {
|
|
453
|
+
description:
|
|
454
|
+
"Health check route that returns service status and uptime",
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
(_, res) => {
|
|
458
|
+
res.status(200).json({
|
|
459
|
+
service: this.name,
|
|
460
|
+
status: this.status,
|
|
461
|
+
startedAt: new Date(this.startedAt),
|
|
462
|
+
uptime: getUseableDatesFromMs(Math.floor(process.uptime() * 1000)),
|
|
463
|
+
workerRunning: this.isWorkerRunning,
|
|
464
|
+
modulesLoaded: Array.from(this.dependencies.keys())
|
|
465
|
+
.filter((f) => f !== "customDependencies")
|
|
466
|
+
.concat(
|
|
467
|
+
Array.from(
|
|
468
|
+
this.dependencies.has("customDependencies")
|
|
469
|
+
? (
|
|
470
|
+
this.dependencies.get(
|
|
471
|
+
"customDependencies",
|
|
472
|
+
) as CustomModules
|
|
473
|
+
).map((d) => `custom:${d.name}`)
|
|
474
|
+
: [],
|
|
475
|
+
),
|
|
476
|
+
),
|
|
477
|
+
routesLoaded: this.routes,
|
|
478
|
+
handlersLoaded: this.loadedEventListeners,
|
|
479
|
+
});
|
|
480
|
+
},
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
this.internalAddRouter("/", router, routes);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Default worker routes under /workers
|
|
488
|
+
*/
|
|
489
|
+
private initWorkerRoutes(): void {
|
|
490
|
+
const { router, routes, addRoute } = createMetaRouter();
|
|
491
|
+
|
|
492
|
+
addRoute(
|
|
493
|
+
{
|
|
494
|
+
method: "GET",
|
|
495
|
+
fullPath: "/",
|
|
496
|
+
requireAuth: true,
|
|
497
|
+
meta: {
|
|
498
|
+
description: "Get worker status",
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
(_, res) => {
|
|
502
|
+
res.json({
|
|
503
|
+
status: this.isWorkerRunning ? "Running" : "Stopped",
|
|
504
|
+
handlersLoaded: Object.keys(this.handlers),
|
|
505
|
+
});
|
|
506
|
+
},
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
addRoute(
|
|
510
|
+
{
|
|
511
|
+
method: "GET",
|
|
512
|
+
fullPath: "/:stream",
|
|
513
|
+
requireAuth: true,
|
|
514
|
+
meta: {
|
|
515
|
+
description: "Get stream messages",
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
async (_, res) => {
|
|
519
|
+
if (!this.redis) {
|
|
520
|
+
return res.status(500).json({ error: "Redis client not connected" });
|
|
521
|
+
}
|
|
522
|
+
const messages = await this.redis.xRange(this.streamKey, "-", "+", {
|
|
523
|
+
COUNT: 10,
|
|
524
|
+
});
|
|
525
|
+
res.json({ status: "Stream", messages });
|
|
526
|
+
},
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
addRoute(
|
|
530
|
+
{
|
|
531
|
+
method: "POST",
|
|
532
|
+
fullPath: "/:stream/:id/ack",
|
|
533
|
+
requireAuth: true,
|
|
534
|
+
meta: {
|
|
535
|
+
description: "Acknowledge a message in the stream",
|
|
536
|
+
},
|
|
537
|
+
},
|
|
538
|
+
async (req, res) => {
|
|
539
|
+
const { stream, id } = req.params;
|
|
540
|
+
if (!this.redis) {
|
|
541
|
+
return res.status(500).json({ error: "Redis client not connected" });
|
|
542
|
+
}
|
|
543
|
+
const result = await this.redis.xAck(
|
|
544
|
+
stream,
|
|
545
|
+
`${this.simpleName}:consumer-group`,
|
|
546
|
+
id,
|
|
547
|
+
);
|
|
548
|
+
res.json({ status: "Message acknowledged", result });
|
|
549
|
+
},
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
addRoute(
|
|
553
|
+
{
|
|
554
|
+
method: "GET",
|
|
555
|
+
fullPath: "/:stream/dlq",
|
|
556
|
+
requireAuth: true,
|
|
557
|
+
meta: {
|
|
558
|
+
description: "Get DLQ stream messages",
|
|
559
|
+
},
|
|
560
|
+
},
|
|
561
|
+
async (_, res) => {
|
|
562
|
+
if (!this.redis) {
|
|
563
|
+
return res.status(500).json({ error: "Redis client not connected" });
|
|
564
|
+
}
|
|
565
|
+
const messages = await this.redis.xRange(this.streamKey, "-", "+", {
|
|
566
|
+
COUNT: 10,
|
|
567
|
+
});
|
|
568
|
+
res.json({ status: "DLQ Stream", messages });
|
|
569
|
+
},
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
addRoute(
|
|
573
|
+
{
|
|
574
|
+
method: "POST",
|
|
575
|
+
fullPath: "/:stream/dlq/:id/retry",
|
|
576
|
+
requireAuth: true,
|
|
577
|
+
meta: {
|
|
578
|
+
description: "Retry a message in the DLQ",
|
|
579
|
+
},
|
|
580
|
+
},
|
|
581
|
+
async (req, res) => {
|
|
582
|
+
const { stream, id } = req.params;
|
|
583
|
+
if (!this.redis) {
|
|
584
|
+
return res.status(500).json({ error: "Redis client not connected" });
|
|
585
|
+
}
|
|
586
|
+
const dlqMessage = await this.redis.xRange(stream, id, id);
|
|
587
|
+
if (dlqMessage.length === 0 || dlqMessage[0].id !== id) {
|
|
588
|
+
return res.status(404).json({ error: "Message not found in DLQ" });
|
|
589
|
+
}
|
|
590
|
+
const result = await this.redis.xAdd(
|
|
591
|
+
stream,
|
|
592
|
+
`${this.simpleName}:consumer-group`,
|
|
593
|
+
dlqMessage[0].message,
|
|
594
|
+
);
|
|
595
|
+
res.json({ status: "Message retried", result });
|
|
596
|
+
},
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
this.internalAddRouter("/workers", router, routes);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Spawns worker threads to process messages from Redis streams.
|
|
604
|
+
* Each worker thread runs the worker-thread.ts file.
|
|
605
|
+
* Each worker thread is passed the service name, redis URL, and handlers.
|
|
606
|
+
* Workers communicate back via parentPort to log messages.
|
|
607
|
+
*/
|
|
608
|
+
private runWorkers(
|
|
609
|
+
{ workerCount }: { workerCount: number } = { workerCount: 1 },
|
|
610
|
+
) {
|
|
611
|
+
if (!this.name) {
|
|
612
|
+
this.log.error("Service name is not set. Cannot start workers.");
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (!this.dependencies.has("redis")) {
|
|
617
|
+
this.log.warn("Redis module not initialized. Skipping worker setup.");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (isNaN(workerCount) || workerCount < 1) {
|
|
622
|
+
throw new Error("Worker count must be a number greater than 0.");
|
|
623
|
+
}
|
|
624
|
+
this.workerCount = workerCount < 1 ? 1 : workerCount;
|
|
625
|
+
|
|
626
|
+
for (let i = 0; i < workerCount; i++) {
|
|
627
|
+
const workerThread = new Worker(
|
|
628
|
+
new URL("./worker-thread.js", import.meta.url),
|
|
629
|
+
{
|
|
630
|
+
workerData: {
|
|
631
|
+
name: this.name,
|
|
632
|
+
redisUrl: this.redis.options?.url,
|
|
633
|
+
handlers: this.handlers,
|
|
634
|
+
index: i,
|
|
635
|
+
subscriptions: this.serviceStreamSubscriptions ?? [
|
|
636
|
+
`${this.name}:events`,
|
|
637
|
+
],
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
this.isWorkerRunning = true;
|
|
643
|
+
|
|
644
|
+
workerThread.on("error", (err) => {
|
|
645
|
+
this.log.error(`Worker ${i} error: ${err.message}`);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
workerThread.on("message", (msg) => {
|
|
649
|
+
switch (msg.level) {
|
|
650
|
+
case "info":
|
|
651
|
+
this.log.info(msg.message);
|
|
652
|
+
break;
|
|
653
|
+
case "warn":
|
|
654
|
+
this.log.warn(msg.message);
|
|
655
|
+
break;
|
|
656
|
+
case "error":
|
|
657
|
+
this.log.error(msg.message);
|
|
658
|
+
break;
|
|
659
|
+
case "create":
|
|
660
|
+
this.loadedEventListeners = msg.message;
|
|
661
|
+
break;
|
|
662
|
+
default:
|
|
663
|
+
this.log.info(msg.message);
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
workerThread.on("exit", (code) => {
|
|
668
|
+
if (code !== 0) {
|
|
669
|
+
this.log.error(`Worker ${i} stopped with exit code ${code}`);
|
|
670
|
+
} else {
|
|
671
|
+
this.log.info(`Worker ${i} exited gracefully.`);
|
|
672
|
+
}
|
|
673
|
+
this.isWorkerRunning = false;
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
this.log.info(`Worker ${i} started for service ${this.name}`);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* The MongoDB module. This is initialized by calling withMongo() and provides access to the configured MongoDB collections through the `db` property. The MongoDB client will automatically connect when the service is built.
|
|
682
|
+
*
|
|
683
|
+
* @param config MongoDB configuration including URI, database name, and collections.
|
|
684
|
+
*
|
|
685
|
+
* @example
|
|
686
|
+
* const t = new Service()
|
|
687
|
+
* .withMongo({
|
|
688
|
+
* dbName: "test",
|
|
689
|
+
* collections: {
|
|
690
|
+
* users: { schema: z.object({ id: z.string(), name: z.string() }) },
|
|
691
|
+
* },
|
|
692
|
+
* })
|
|
693
|
+
* .build();
|
|
694
|
+
*
|
|
695
|
+
* const l = await t.db.users.get({ id: "123" });
|
|
696
|
+
*/
|
|
697
|
+
withMongo<TNextCollections extends MongoCollectionsConfig>(config: {
|
|
698
|
+
dbName: string;
|
|
699
|
+
uri?: string;
|
|
700
|
+
collections: TNextCollections;
|
|
701
|
+
}) {
|
|
702
|
+
if (!this.dependencies.has("mongoClient")) {
|
|
703
|
+
const mongo = createMongo(config.dbName, config.collections, {
|
|
704
|
+
uri: config.uri,
|
|
705
|
+
});
|
|
706
|
+
this.dependencies.set("mongoClient", mongo);
|
|
707
|
+
this.dependencyOrder.push("mongoClient");
|
|
708
|
+
}
|
|
709
|
+
return this as unknown as Service<TNextCollections>;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* The Clickhouse module. This is initialized by calling withClickhouse() and provides access to the configured Clickhouse tables through the `clickhouse` property. The Clickhouse client will automatically connect when the service is built.
|
|
714
|
+
*
|
|
715
|
+
* This module is a **WIP**.
|
|
716
|
+
*
|
|
717
|
+
* @param config Clickhouse configuration including tables.
|
|
718
|
+
* @beta
|
|
719
|
+
*/
|
|
720
|
+
withClickhouse<TTables extends Record<string, any>>(
|
|
721
|
+
config: ClickhouseConfig<TTables>,
|
|
722
|
+
) {
|
|
723
|
+
if (!this.dependencies.has("clickhouse")) {
|
|
724
|
+
this.dependencies.set("clickhouse", createTypedClickhouse(config));
|
|
725
|
+
this.dependencyOrder.push("clickhouse");
|
|
726
|
+
}
|
|
727
|
+
return this as Service<TCollections> & {
|
|
728
|
+
clickhouse: ReturnType<typeof createTypedClickhouse<TTables>>;
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Optional. Port number for the express server.
|
|
734
|
+
*
|
|
735
|
+
* Defaults in the following order:
|
|
736
|
+
* 1. PORT environment variable
|
|
737
|
+
* 2. 3100
|
|
738
|
+
*
|
|
739
|
+
* @param port port number
|
|
740
|
+
*/
|
|
741
|
+
withPort(port: number = Number(process.env.PORT) || 3100) {
|
|
742
|
+
this.port = port;
|
|
743
|
+
return this;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Optional. Base URL without port.
|
|
748
|
+
*
|
|
749
|
+
* Defaults in the following order:
|
|
750
|
+
* 1. SERVICE_URL environment variable
|
|
751
|
+
* 2. "http://localhost"
|
|
752
|
+
*
|
|
753
|
+
* @param url base url without port
|
|
754
|
+
*/
|
|
755
|
+
withUrl(url: string) {
|
|
756
|
+
this.url = url;
|
|
757
|
+
return this;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* Optional. Name of the service. Used for logging and service discovery.
|
|
762
|
+
*
|
|
763
|
+
* Defaults in the following order:
|
|
764
|
+
* 1. SERVICE_NAME environment variable
|
|
765
|
+
* 2. A random hex string
|
|
766
|
+
* @param name Name of the service
|
|
767
|
+
*/
|
|
768
|
+
withName(name: string) {
|
|
769
|
+
this.name = name;
|
|
770
|
+
return this;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* The Redis module. This is initialized by calling withRedis() and provides a Redis client instance through the `redis` property. The Redis client will automatically connect when the service is built.
|
|
775
|
+
* @param url Redis URL. Defaults to: redis://redis:6379
|
|
776
|
+
*/
|
|
777
|
+
withRedis(url: string = "redis://redis:6379") {
|
|
778
|
+
if (!this.dependencies.has("redis")) {
|
|
779
|
+
this.dependencies.set("redis", createClient({ url }));
|
|
780
|
+
this.dependencyOrder.push("redis");
|
|
781
|
+
}
|
|
782
|
+
return this;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Adds worker threads to the service. These are used to process messages from Redis streams. Worker threads will be spawned when build() is called. This must be called after withRedis() to work properly.
|
|
787
|
+
*
|
|
788
|
+
* **Note**: This will not initialize without at least one service stream.
|
|
789
|
+
* @param workerCount Defaults to 1
|
|
790
|
+
* @param serviceStreams Defaults to empty array
|
|
791
|
+
* @returns
|
|
792
|
+
*/
|
|
793
|
+
withWorkers(workerCount: number, serviceStreams: string[] = []) {
|
|
794
|
+
if (!this.dependencies.has("redis")) {
|
|
795
|
+
this.log.warn(
|
|
796
|
+
"Redis module not initialized. Call withRedis() before setting worker subscriptions.",
|
|
797
|
+
);
|
|
798
|
+
return this;
|
|
799
|
+
}
|
|
800
|
+
if (!this.dependencies.has("workers") && serviceStreams.length > 0) {
|
|
801
|
+
this.dependencies.set("workers", true);
|
|
802
|
+
this.serviceStreamSubscriptions = serviceStreams;
|
|
803
|
+
this.workerCount = workerCount < 1 ? 1 : workerCount;
|
|
804
|
+
this.dependencyOrder.push("workers");
|
|
805
|
+
}
|
|
806
|
+
return this;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* Loads custom dependencies that can be used in the service. The factory functions will be called during build() after all other dependencies are loaded and can be used to initialize any custom logic or connections. The result of each factory function will be logged.
|
|
811
|
+
* @param dependencies Object where keys are dependency names and values are factory functions that return a boolean indicating success or failure of initialization.
|
|
812
|
+
*/
|
|
813
|
+
withModules(dependencies: CustomModules) {
|
|
814
|
+
if (!this.dependencies.has("customDependencies")) {
|
|
815
|
+
this.dependencies.set(
|
|
816
|
+
"customDependencies",
|
|
817
|
+
dependencies.map((dep) => ({
|
|
818
|
+
name: dep.name,
|
|
819
|
+
init: dep.init,
|
|
820
|
+
shutdown: dep.shutdown,
|
|
821
|
+
})),
|
|
822
|
+
);
|
|
823
|
+
this.dependencyOrder.push("customDependencies");
|
|
824
|
+
}
|
|
825
|
+
return this;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
withExpressOptions(options: Partial<ExpressOptions>) {
|
|
829
|
+
this.expressOptions = { ...this.expressOptions, ...options };
|
|
830
|
+
return this;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private internalAddRouter(
|
|
834
|
+
base: string,
|
|
835
|
+
router: Router,
|
|
836
|
+
routes: RouteDefinition<any, any, boolean>[] = [],
|
|
837
|
+
) {
|
|
838
|
+
this.routers.push({ base, router, routes });
|
|
839
|
+
return this;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Registers an express router under a base path.
|
|
844
|
+
* @param base Base path for this router.
|
|
845
|
+
* @param router Router instance.
|
|
846
|
+
* @param routes List of route definitions.
|
|
847
|
+
* @returns
|
|
848
|
+
*/
|
|
849
|
+
addRouter(
|
|
850
|
+
base: string,
|
|
851
|
+
router: Router,
|
|
852
|
+
routes: RouteDefinition<any, any, boolean>[] = [],
|
|
853
|
+
) {
|
|
854
|
+
if (base === "/") {
|
|
855
|
+
this.log.warn("Base path '/' is not allowed. Skipping.");
|
|
856
|
+
return this;
|
|
857
|
+
}
|
|
858
|
+
this.internalAddRouter(base, router, routes);
|
|
859
|
+
return this;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Gracefully shuts down the service by closing all connections and active workers.
|
|
864
|
+
*/
|
|
865
|
+
async shutdown(
|
|
866
|
+
opts: {
|
|
867
|
+
/**
|
|
868
|
+
* Optional callback to run before shutdown. Can be used to perform cleanup tasks like closing database connections, etc.
|
|
869
|
+
*/
|
|
870
|
+
beforeShutdown?: () => Promise<void>;
|
|
871
|
+
/**
|
|
872
|
+
* Optional callback to run after shutdown. Can be used to perform any final tasks before the process exits.
|
|
873
|
+
*/
|
|
874
|
+
afterShutdown?: () => Promise<void>;
|
|
875
|
+
} = {},
|
|
876
|
+
) {
|
|
877
|
+
await opts.beforeShutdown?.();
|
|
878
|
+
this._status = "stopping";
|
|
879
|
+
if (this.dependencies.has("redis")) {
|
|
880
|
+
await this.redis.quit();
|
|
881
|
+
}
|
|
882
|
+
if (this.dependencies.has("mongoClient")) {
|
|
883
|
+
await this.mongo.disconnect();
|
|
884
|
+
}
|
|
885
|
+
if (this.dependencies.has("clickhouse")) {
|
|
886
|
+
await this.clickhouse.client.close();
|
|
887
|
+
}
|
|
888
|
+
if (this.dependencies.has("customDependencies")) {
|
|
889
|
+
for (const dep of this.dependencies.get(
|
|
890
|
+
"customDependencies",
|
|
891
|
+
) as CustomModules) {
|
|
892
|
+
if (dep.shutdown) {
|
|
893
|
+
try {
|
|
894
|
+
const result = await dep.shutdown(this.log);
|
|
895
|
+
this.log.info(
|
|
896
|
+
`Custom dependency "${dep.name}" shutdown with result: ${result}`,
|
|
897
|
+
);
|
|
898
|
+
} catch (err) {
|
|
899
|
+
this.log.error(
|
|
900
|
+
`Failed to shutdown custom dependency "${dep.name}": ${err}`,
|
|
901
|
+
);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
this.log.info(`Service ${this.name} shutting down.`);
|
|
907
|
+
this._status = "stopped";
|
|
908
|
+
await opts.afterShutdown?.();
|
|
909
|
+
process.exit(0);
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
[Symbol.dispose]() {
|
|
913
|
+
this.shutdown();
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
*[Symbol.iterator]() {
|
|
917
|
+
for (const dependency of Object.values(this.dependencies)) {
|
|
918
|
+
yield dependency;
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
}
|