@vincent_su/microservice-lite 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/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ import { CreateService } from "./src/main.mjs";
2
+ export { CreateService };
3
+ export default CreateService;
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@vincent_su/microservice-lite",
3
+ "version": "1.0.0",
4
+ "description": "",
5
+ "license": "ISC",
6
+ "author": "",
7
+ "type": "commonjs",
8
+ "main": "index.js",
9
+ "scripts": {
10
+ "test": "echo \"Error: no test specified\" && exit 1"
11
+ },
12
+ "dependencies": {
13
+ "dotenv": "^17.4.1",
14
+ "got": "^15.0.1",
15
+ "js-yaml": "^4.1.1",
16
+ "pino": "^10.3.1",
17
+ "redis": "^5.11.0",
18
+ "tough-cookie": "^6.0.1"
19
+ }
20
+ }
package/src/assert.mjs ADDED
@@ -0,0 +1,5 @@
1
+ function assert(cond, msg) {
2
+ if (!cond) throw new Error(`[config] ${msg}`);
3
+ }
4
+
5
+ export { assert };
@@ -0,0 +1,35 @@
1
+ import { config } from "./config.mjs";
2
+ import { startJob } from "./job.mjs";
3
+ import { logger } from "./logger.mjs";
4
+ import { compute } from "./compute.mjs";
5
+ import { init, saveState, loadState, subscribeState } from "./states.mjs";
6
+ import { publish, subscribe } from "./pubsub.mjs";
7
+ import { assert } from "./assert.mjs";
8
+ import { getService } from "./service.mjs";
9
+
10
+ let bindFunc = null;
11
+
12
+ async function start() {
13
+ await new Promise(() => {});
14
+ }
15
+
16
+ const _setupFunc = async () => {
17
+ init();
18
+ bindFunc = {
19
+ config,
20
+ startJob,
21
+ assert,
22
+ logger,
23
+ compute,
24
+ loadState,
25
+ saveState,
26
+ subscribeState,
27
+ publish,
28
+ subscribe,
29
+ getService,
30
+ start,
31
+
32
+ };
33
+ };
34
+
35
+ export { _setupFunc, bindFunc };
@@ -0,0 +1,186 @@
1
+ import { config } from "./config.mjs";
2
+ import { logger } from "./logger.mjs";
3
+ import { bindFunc } from "./bindFunc.mjs";
4
+ import __path from "path";
5
+ import { saveState } from "./states.mjs";
6
+ import os from "os";
7
+
8
+ const compute_modules = new Map();
9
+
10
+ const getComputeModule = async (compute_name, compute_path) => {
11
+ if (compute_modules.has(compute_name)) {
12
+ return compute_modules.get(compute_name);
13
+ }
14
+ try {
15
+ const module = await import(
16
+ `${__path.resolve(compute_path)}/${compute_name}.mjs`
17
+ );
18
+ compute_modules.set(compute_name, module.default);
19
+ return module.default;
20
+ } catch (error) {
21
+ const safeError =
22
+ error.response?.body?.errorMessage ||
23
+ error.response?.statusCode + error.response?.statusMessage ||
24
+ error.message ||
25
+ error.code;
26
+ return { error: safeError };
27
+ }
28
+ };
29
+
30
+ async function compute({
31
+ compute_path = "./compute",
32
+ COMPUTES = [],
33
+ STATE = {},
34
+ breakOnError = true,
35
+ }) {
36
+ STATE.COMPUTE = {
37
+ STATS: [],
38
+ ERRORS: [],
39
+ };
40
+
41
+ for (let i = 0; i < COMPUTES.length; i++) {
42
+ const sequence = COMPUTES[i];
43
+
44
+ const _promises = [];
45
+ const startTime = Date.now();
46
+ for (const compute of sequence) {
47
+ const computeFunc = await getComputeModule(compute, compute_path);
48
+
49
+ if (computeFunc.error) {
50
+ _promises.push(
51
+ tryCompute(async () => {
52
+ throw new Error(`Compute module error ${computeFunc.error}`);
53
+ }),
54
+ );
55
+ } else {
56
+ _promises.push(tryCompute(computeFunc, STATE));
57
+ }
58
+ }
59
+
60
+ const results = await Promise.all(_promises);
61
+
62
+ const computeInfo = {
63
+ completed: `${Date.now() - startTime}ms`,
64
+ hasError: false,
65
+ };
66
+
67
+ await saveState(STATE);
68
+
69
+ computeInfo.saved = `${Date.now() - startTime}ms`;
70
+
71
+ computeInfo.cpu_single_core = 0;
72
+ computeInfo.cpu_all_cores = 0;
73
+
74
+ for (let j = 0; j < results.length; j++) {
75
+ const sequenceInfo = {
76
+ name: sequence[j],
77
+ result: results[j],
78
+ };
79
+
80
+ computeInfo[sequenceInfo.name] = {
81
+ error: sequenceInfo.result.error,
82
+ timeElapsed: sequenceInfo.result.timeElapsed,
83
+ cpu_single_core: sequenceInfo.result.cpu.percentSingleCore,
84
+ cpu_all_cores: sequenceInfo.result.cpu.percentAllCores,
85
+ };
86
+
87
+ if (
88
+ sequenceInfo.result.cpu.percentSingleCore > computeInfo.cpu_single_core
89
+ ) {
90
+ computeInfo.cpu_single_core = sequenceInfo.result.cpu.percentSingleCore;
91
+ }
92
+
93
+ if (sequenceInfo.result.cpu.percentAllCores > computeInfo.cpu_all_cores) {
94
+ computeInfo.cpu_all_cores = sequenceInfo.result.cpu.percentAllCores;
95
+ }
96
+
97
+ //console.log(sequenceInfo.result);
98
+ /*
99
+ computeInfo[sequenceInfo.name] = sequenceInfo.result.error
100
+ ? `error: ${sequenceInfo.result.error}`
101
+ : sequenceInfo.result.success
102
+ ? `lapsed:${sequenceInfo.result.timeElapsed}ms, single-core:${sequenceInfo.result.cpu.percentSingleCore}%, all-cores:${sequenceInfo.result.cpu.percentAllCores}% `
103
+ : sequenceInfo.result;
104
+ */
105
+ if (sequenceInfo.result && sequenceInfo.result.error) {
106
+ computeInfo.hasError = true;
107
+ STATE.COMPUTE.ERRORS.push({
108
+ name: sequenceInfo.name,
109
+ error: sequenceInfo.result.error,
110
+ });
111
+ }
112
+ }
113
+
114
+ STATE.COMPUTE.STATS.push(computeInfo);
115
+
116
+ if (computeInfo.hasError) {
117
+ if (breakOnError) break;
118
+ }
119
+ }
120
+
121
+ if (config.server?.computeShowStats) {
122
+ logger.info("Compute stats:", STATE.COMPUTE);
123
+ }
124
+
125
+ if (STATE.COMPUTE.ERRORS.length > 0) {
126
+ logger.error("Errors STATE from compute:", STATE.COMPUTE.ERRORS);
127
+ }
128
+
129
+ return STATE.COMPUTE;
130
+ }
131
+
132
+ async function tryCompute(func, STATE = {}) {
133
+ const _startTime = Date.now();
134
+
135
+ const cpuStart = process.cpuUsage();
136
+ const hrStart = process.hrtime.bigint();
137
+
138
+ let errorResult = null;
139
+
140
+ try {
141
+ await func(bindFunc, STATE);
142
+ } catch (error) {
143
+ const safeError =
144
+ error.response?.body?.errorMessage ||
145
+ error.response?.body ||
146
+ error.response?.statusCode + error.response?.statusMessage ||
147
+ error.message ||
148
+ error.code;
149
+ errorResult = { error: safeError };
150
+ } finally {
151
+ const hrEnd = process.hrtime.bigint();
152
+ const cpuDelta = process.cpuUsage(cpuStart);
153
+
154
+ const wallMs = Number(hrEnd - hrStart) / 1e6;
155
+ const userMs = cpuDelta.user / 1000;
156
+ const systemMs = cpuDelta.system / 1000;
157
+ const totalCpuMs = userMs + systemMs;
158
+
159
+ const coreCount =
160
+ typeof os.availableParallelism === "function"
161
+ ? os.availableParallelism()
162
+ : os.cpus().length;
163
+
164
+ const percentSingleCore = wallMs > 0 ? (totalCpuMs / wallMs) * 100 : 0;
165
+
166
+ const percentAllCores =
167
+ wallMs > 0 ? (totalCpuMs / (wallMs * coreCount)) * 100 : 0;
168
+
169
+ const result = {
170
+ ...(errorResult ?? { success: true }),
171
+ timeElapsed: Date.now() - _startTime,
172
+ cpu: {
173
+ userMs: Number(userMs.toFixed(3)),
174
+ systemMs: Number(systemMs.toFixed(3)),
175
+ totalMs: Number(totalCpuMs.toFixed(3)),
176
+ wallMs: Number(wallMs.toFixed(3)),
177
+ percentSingleCore: Number(percentSingleCore.toFixed(2)),
178
+ percentAllCores: Number(percentAllCores.toFixed(2)),
179
+ },
180
+ };
181
+
182
+ return result;
183
+ }
184
+ }
185
+
186
+ export { compute };
package/src/config.mjs ADDED
@@ -0,0 +1,27 @@
1
+ import fs from "fs";
2
+ import yaml from "js-yaml";
3
+ import { assert } from "./assert.mjs";
4
+ import "dotenv/config";
5
+
6
+ function expandEnv(str) {
7
+ return str.replace(/\$\{(\w+)\}/g, (_, name) => process.env[name] ?? "");
8
+ }
9
+
10
+ function loadConfig(path) {
11
+ const raw = fs.readFileSync(path, "utf8");
12
+ const cfg = yaml.load(expandEnv(raw));
13
+ Object.freeze(cfg);
14
+ return cfg;
15
+ }
16
+
17
+ let config = null;
18
+ const _loadConfig = async (path = "./config.yml") => {
19
+ config = loadConfig(path);
20
+ assert(config.server, "Missing server config");
21
+ assert(config.server.appName, "Missing server.appName config");
22
+ assert(config.server.port, "Missing server.port config");
23
+
24
+ console.log(`====>config`, config)
25
+ };
26
+
27
+ export { config, _loadConfig };
package/src/job.mjs ADDED
@@ -0,0 +1,50 @@
1
+ function startJob(state, loop, onError = () => {}, intervalMs = 1000) {
2
+ let stopped = false;
3
+ let running = false;
4
+ let timer = null;
5
+ let errorIntervalMs = null;
6
+ let specialIntervalMs = null;
7
+
8
+ const tick = async () => {
9
+ if (stopped || running) return;
10
+ running = true;
11
+ try {
12
+ await loop(state, (intervalMs) => {
13
+ if (typeof intervalMs === "number") {
14
+ specialIntervalMs = intervalMs;
15
+ }
16
+ });
17
+ } catch (err) {
18
+ try {
19
+ onError(err, (intervalMs) => {
20
+ if (typeof intervalMs === "number") {
21
+ errorIntervalMs = intervalMs;
22
+ }
23
+ });
24
+ } catch {}
25
+ } finally {
26
+ running = false;
27
+ if (!stopped) {
28
+ timer = setTimeout(
29
+ tick,
30
+ errorIntervalMs || specialIntervalMs || intervalMs,
31
+ );
32
+ if (timer.unref) timer.unref();
33
+ errorIntervalMs = null;
34
+ specialIntervalMs = null;
35
+ }
36
+ }
37
+ };
38
+
39
+ timer = setTimeout(tick, 0);
40
+ if (timer.unref) timer.unref();
41
+
42
+ return {
43
+ abort() {
44
+ stopped = true;
45
+ if (timer) clearTimeout(timer);
46
+ },
47
+ };
48
+ }
49
+
50
+ export { startJob };
package/src/logger.mjs ADDED
@@ -0,0 +1,175 @@
1
+ import { config } from "./config.mjs";
2
+
3
+ import pino from "pino";
4
+ import { Writable } from "stream";
5
+
6
+ let logger = null;
7
+
8
+ /* ---------------- helpers ---------------- */
9
+ function isRetryableMongoErr(err) {
10
+ const labelsArr =
11
+ (err?.errorLabelSet && Array.from(err.errorLabelSet)) ||
12
+ (Array.isArray(err?.labels) ? err.labels : []);
13
+ const labels = new Set(labelsArr);
14
+
15
+ const has = (k) => labels.has(k);
16
+
17
+ if (
18
+ has("RetryableWriteError") ||
19
+ has("ResumableChangeStreamError") ||
20
+ has("TransientTransactionError") ||
21
+ has("NetworkError") ||
22
+ has("ResetPool") ||
23
+ has("InterruptInUseConnections") ||
24
+ has("PoolRequestedRetry")
25
+ )
26
+ return true;
27
+
28
+ const n = err?.name;
29
+ return (
30
+ n === "MongoNetworkError" ||
31
+ n === "MongoNetworkTimeoutError" ||
32
+ n === "MongoServerSelectionError" ||
33
+ n === "MongoNotConnectedError"
34
+ );
35
+ }
36
+
37
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
38
+ const jitter = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min;
39
+
40
+ /* ---------------- Wrap pino so it captures ALL args ---------------- */
41
+ function wrapLogger(base) {
42
+ const levels = ["trace", "debug", "info", "warn", "error", "fatal"];
43
+
44
+ const normalizeArg = (a) =>
45
+ a instanceof Error
46
+ ? { error: { name: a.name, message: a.message, stack: a.stack } }
47
+ : a;
48
+
49
+ const mergeArgs = (obj, extraArgs) => {
50
+ if (!extraArgs?.length) return obj;
51
+ if (obj && typeof obj === "object" && obj._args === undefined) {
52
+ return { ...obj, _args: extraArgs.map(normalizeArg) };
53
+ }
54
+ return {
55
+ _args: extraArgs.map(normalizeArg),
56
+ ...(obj && typeof obj === "object" ? obj : {}),
57
+ };
58
+ };
59
+
60
+ // helper: only attach _args if there’s something beyond a lone string
61
+ const shouldAttach = (extras) => {
62
+ if (!extras?.length) return false;
63
+ if (extras.length === 1 && typeof extras[0] === "string") return false; // <- skip lone string
64
+ return true; // objects, errors, multiple args, etc.
65
+ };
66
+
67
+ const wrapper = Object.create(base);
68
+
69
+ for (const lvl of levels) {
70
+ const fn = base[lvl];
71
+ wrapper[lvl] = function (...args) {
72
+ if (!args.length) return fn.call(base);
73
+
74
+ // Case 1: first arg is an object (Pino-style: logger.info(obj, msg, ...rest))
75
+ if (typeof args[0] === "object" && args[0] !== null) {
76
+ const [obj, msg, ...rest] = args;
77
+ const extras = [msg, ...rest].filter((x) => x !== undefined);
78
+
79
+ if (shouldAttach(extras)) {
80
+ const merged = mergeArgs(obj, extras);
81
+ return fn.call(base, merged, msg);
82
+ } else {
83
+ // just pass through without _args
84
+ return fn.call(base, obj, msg);
85
+ }
86
+ }
87
+
88
+ // Case 2: first arg is not an object (logger.info(msg, ...rest))
89
+ const [msg, ...rest] = args;
90
+
91
+ // lone string -> no _args
92
+ if (rest.length === 0 && typeof msg === "string") {
93
+ return fn.call(base, msg);
94
+ }
95
+
96
+ const extras = [msg, ...rest];
97
+ if (shouldAttach(extras)) {
98
+ const merged = mergeArgs({}, extras);
99
+ return fn.call(base, merged, msg);
100
+ } else {
101
+ return fn.call(base, msg);
102
+ }
103
+ };
104
+ }
105
+
106
+ return wrapper;
107
+ }
108
+
109
+ /* ---------------- setupLogger ---------------- */
110
+ export async function setupLogger({ logLevel }) {
111
+ const env = (process.env.NODE_ENV || "").toLowerCase();
112
+ const isDev = env === "development" || env === "dev";
113
+
114
+ const consoleDest = new Writable({
115
+ write(chunk, _enc, cb) {
116
+ try {
117
+ const obj = JSON.parse(chunk.toString());
118
+ const level =
119
+ obj.level >= 50
120
+ ? "ERROR"
121
+ : obj.level >= 40
122
+ ? "WARN"
123
+ : obj.level >= 30
124
+ ? "INFO"
125
+ : "DEBUG";
126
+
127
+ console.log(
128
+ `[${new Date(obj.time).toLocaleTimeString()}] ${level}: ${obj.msg}`,
129
+ );
130
+
131
+ for (let i = 1; i < obj._args?.length; i++) {
132
+ console.log(
133
+ `[${new Date(obj.time).toLocaleTimeString()}] ${level}:`,
134
+ obj._args[i],
135
+ );
136
+ }
137
+ } catch {
138
+ console.log("Failed to parse log line:");
139
+ console.log(chunk.toString());
140
+ }
141
+ cb();
142
+ },
143
+ });
144
+
145
+ const streams = [{ stream: consoleDest }];
146
+
147
+ const base = pino(
148
+ {
149
+ level: logLevel || "info",
150
+ errorLikeObjectKeys: ["err", "error"],
151
+ errorProps: "type,message,stack,cause",
152
+ },
153
+ pino.multistream(streams),
154
+ );
155
+
156
+ const onSig = async (sig) => {
157
+ process.kill(process.pid, sig); // default handler runs now
158
+ };
159
+
160
+ process.once("SIGINT", onSig);
161
+ process.once("SIGTERM", onSig);
162
+
163
+ return { logger: wrapLogger(base) };
164
+ }
165
+
166
+ const _setupLogger = async () => {
167
+ const result = await setupLogger({
168
+ logLevel: config.server?.logLevel || "info",
169
+ });
170
+
171
+ logger = result.logger;
172
+ logger.info("Logger initialized");
173
+ };
174
+
175
+ export { logger, _setupLogger };
package/src/main.mjs ADDED
@@ -0,0 +1,28 @@
1
+ import { _loadConfig } from "./config.mjs";
2
+ import { logger, _setupLogger } from "./logger.mjs";
3
+ import { _setupFunc, bindFunc } from "./bindFunc.mjs";
4
+
5
+ async function start() {
6
+ logger.info("Service started");
7
+
8
+ setInterval(() => {
9
+ logger.info("heartbeat");
10
+ }, 60_000);
11
+ }
12
+
13
+ async function CreateService(
14
+ { path = "./config.yml" } = { path: "./config.yml" },
15
+ ) {
16
+ await _loadConfig(path);
17
+ await _setupLogger();
18
+ await _setupFunc();
19
+
20
+ start().catch((err) => {
21
+ logger.error("Fatal error:", err);
22
+ process.exit(1);
23
+ });
24
+
25
+ return bindFunc;
26
+ }
27
+
28
+ export { CreateService };
package/src/pubsub.mjs ADDED
@@ -0,0 +1,251 @@
1
+ import { createClient } from "redis";
2
+ import { assert } from "./assert.mjs";
3
+ import { logger } from "./logger.mjs";
4
+ import { config } from "./config.mjs";
5
+
6
+ let redisClient = null;
7
+ let redisConnectPromise = null;
8
+
9
+ async function ensureRedis() {
10
+ if (redisClient?.isOpen) return redisClient;
11
+ if (redisConnectPromise) return redisConnectPromise;
12
+
13
+ const url = config?.server?.redisUrl || "redis://127.0.0.1:6379";
14
+
15
+ redisClient = createClient({
16
+ url,
17
+ socket: {
18
+ reconnectStrategy(retries) {
19
+ return Math.min(retries * 100, 3000);
20
+ },
21
+ },
22
+ });
23
+
24
+ redisClient.on("error", (error) => {
25
+ logger.error(`redis client error: ${error?.message || error}`);
26
+ });
27
+
28
+ redisClient.on("connect", () => {
29
+ logger.info(`redis socket connected: ${url}`);
30
+ });
31
+
32
+ redisClient.on("ready", () => {
33
+ logger.info("redis client ready");
34
+ });
35
+
36
+ redisClient.on("reconnecting", () => {
37
+ logger.info("redis reconnecting");
38
+ });
39
+
40
+ redisClient.on("end", () => {
41
+ logger.info("redis connection ended");
42
+ });
43
+
44
+ redisConnectPromise = redisClient
45
+ .connect()
46
+ .then(() => redisClient)
47
+ .catch((error) => {
48
+ redisClient = null;
49
+ throw error;
50
+ })
51
+ .finally(() => {
52
+ redisConnectPromise = null;
53
+ });
54
+
55
+ return redisConnectPromise;
56
+ }
57
+
58
+ async function publish(topic, payload) {
59
+ assert(typeof topic === "string" && topic.trim(), "topic must be a string");
60
+
61
+ try {
62
+ const client = await ensureRedis();
63
+ const message = JSON.stringify(cleanValue(payload));
64
+ const receivers = await client.publish(topic, message);
65
+
66
+ return {
67
+ ok: true,
68
+ topic,
69
+ receivers,
70
+ };
71
+ } catch (error) {
72
+ logger.error(
73
+ `error while publishing to redis topic ${topic}: ${error?.message || error}`,
74
+ );
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ async function subscribe(topic, onMessage, onError) {
80
+ assert(typeof topic === "string" && topic.trim(), "topic must be a string");
81
+ assert(typeof onMessage === "function", "onMessage must be a function");
82
+
83
+ let subscriber = null;
84
+ let closed = false;
85
+
86
+ try {
87
+ const client = await ensureRedis();
88
+
89
+ subscriber = client.duplicate();
90
+
91
+ subscriber.on("error", (error) => {
92
+ logger.error(
93
+ `redis subscriber error on topic ${topic}: ${error?.message || error}`,
94
+ );
95
+ onError?.(error);
96
+ });
97
+
98
+ subscriber.on("connect", () => {
99
+ logger.info(`redis subscriber connected: ${topic}`);
100
+ });
101
+
102
+ subscriber.on("ready", () => {
103
+ logger.info(`redis subscriber ready: ${topic}`);
104
+ });
105
+
106
+ subscriber.on("reconnecting", () => {
107
+ logger.info(`redis subscriber reconnecting: ${topic}`);
108
+ });
109
+
110
+ subscriber.on("end", () => {
111
+ logger.info(`redis subscriber ended: ${topic}`);
112
+ });
113
+
114
+ await subscriber.connect();
115
+
116
+ await subscriber.subscribe(topic, async (rawMessage, channel) => {
117
+ if (closed) return;
118
+
119
+ try {
120
+ let message = rawMessage;
121
+
122
+ try {
123
+ message = JSON.parse(rawMessage);
124
+ } catch {}
125
+
126
+ await onMessage(message, {
127
+ topic: channel,
128
+ rawMessage,
129
+ });
130
+ } catch (error) {
131
+ onError?.(error);
132
+ }
133
+ });
134
+
135
+ return {
136
+ topic,
137
+ async close() {
138
+ closed = true;
139
+
140
+ try {
141
+ if (subscriber?.isOpen) {
142
+ await subscriber.unsubscribe(topic);
143
+ await subscriber.quit();
144
+ } else if (subscriber) {
145
+ subscriber.disconnect();
146
+ }
147
+ } catch (error) {
148
+ onError?.(error);
149
+ try {
150
+ subscriber?.disconnect();
151
+ } catch {}
152
+ }
153
+ },
154
+ };
155
+ } catch (error) {
156
+ logger.error(
157
+ `error while subscribing to redis topic ${topic}: ${error?.message || error}`,
158
+ );
159
+ onError?.(error);
160
+
161
+ try {
162
+ if (subscriber?.isOpen) {
163
+ await subscriber.quit();
164
+ } else if (subscriber) {
165
+ subscriber.disconnect();
166
+ }
167
+ } catch {}
168
+
169
+ return null;
170
+ }
171
+ }
172
+
173
+ async function closeRedis() {
174
+ try {
175
+ if (redisClient?.isOpen) {
176
+ await redisClient.quit();
177
+ } else if (redisClient) {
178
+ redisClient.disconnect();
179
+ }
180
+ } catch (error) {
181
+ logger.error(`error while closing redis: ${error?.message || error}`);
182
+ try {
183
+ redisClient?.disconnect();
184
+ } catch {}
185
+ } finally {
186
+ redisClient = null;
187
+ redisConnectPromise = null;
188
+ }
189
+ }
190
+
191
+ function cleanValue(value, seen = new WeakSet()) {
192
+ if (value && typeof value === "object") {
193
+ if (seen.has(value)) return undefined;
194
+ seen.add(value);
195
+ }
196
+
197
+ if (value === null || typeof value !== "object") return value;
198
+
199
+ if (value instanceof Date) return value.toISOString();
200
+
201
+ if (Buffer.isBuffer(value)) {
202
+ return value.toString("base64");
203
+ }
204
+
205
+ if (Array.isArray(value)) {
206
+ return value.map((v) => cleanValue(v, seen)).filter((v) => v !== undefined);
207
+ }
208
+
209
+ if (value instanceof Map) {
210
+ return Object.fromEntries(
211
+ [...value.entries()]
212
+ .map(([k, v]) => [String(k), cleanValue(v, seen)])
213
+ .filter(([, v]) => v !== undefined),
214
+ );
215
+ }
216
+
217
+ if (value instanceof Set) {
218
+ return [...value]
219
+ .map((v) => cleanValue(v, seen))
220
+ .filter((v) => v !== undefined);
221
+ }
222
+
223
+ if (value instanceof Error) {
224
+ return {
225
+ name: value.name,
226
+ message: value.message,
227
+ stack: value.stack,
228
+ };
229
+ }
230
+
231
+ if (value.constructor && value.constructor !== Object) {
232
+ return undefined;
233
+ }
234
+
235
+ const out = {};
236
+ for (const [key, val] of Object.entries(value)) {
237
+ if (typeof val === "function" || typeof val === "symbol") continue;
238
+
239
+ const cleaned = cleanValue(val, seen);
240
+ if (cleaned !== undefined) out[key] = cleaned;
241
+ }
242
+
243
+ return out;
244
+ }
245
+
246
+ export {
247
+ ensureRedis,
248
+ publish,
249
+ subscribe,
250
+ closeRedis,
251
+ };
@@ -0,0 +1,60 @@
1
+ import http from "http";
2
+ import https from "https";
3
+ import { CookieJar } from "tough-cookie";
4
+ import got from "got";
5
+
6
+ /* ============================== HTTP ============================== */
7
+ // ---- Shared HTTP agents (reused across all clients) ----
8
+ const SHARED_HTTP_AGENT = new http.Agent({
9
+ keepAlive: true,
10
+ maxSockets: 200,
11
+ maxFreeSockets: 50,
12
+ });
13
+ const SHARED_HTTPS_AGENT = new https.Agent({
14
+ keepAlive: true,
15
+ maxSockets: 200,
16
+ maxFreeSockets: 50,
17
+ });
18
+
19
+ /* ---------------------------------- HTTP ---------------------------------- */
20
+ function createGotInstance(baseURL, { cookieJar, headers, timeout } = {}) {
21
+ const options = {
22
+ prefixUrl: baseURL,
23
+ agent: { http: SHARED_HTTP_AGENT, https: SHARED_HTTPS_AGENT },
24
+ timeout: timeout || { request: 60000, connect: 5000 },
25
+ decompress: true,
26
+ http2: false,
27
+ headers: { accept: "application/json, */*;q=0.8", ...(headers || {}) },
28
+ retry: { limit: 0 }, // disable got retries; let callers decide
29
+ };
30
+ if (cookieJar instanceof CookieJar) options.cookieJar = cookieJar;
31
+ return got.extend(options);
32
+ }
33
+
34
+ const _services = new Map();
35
+ const _normBase = (u) =>
36
+ String(u).endsWith("/") ? String(u) : String(u) + "/";
37
+ function getService(serviceUrl) {
38
+ const key = _normBase(serviceUrl);
39
+ if (!_services.has(key)) _services.set(key, createGotInstance(key));
40
+ return _services.get(key);
41
+ }
42
+
43
+ function getHttpAgentCount() {
44
+ return {
45
+ active: Object.fromEntries(
46
+ Object.entries(SHARED_HTTP_AGENT.sockets).map(([k, v]) => [k, v.length])
47
+ ),
48
+ idle: Object.fromEntries(
49
+ Object.entries(SHARED_HTTP_AGENT.freeSockets).map(([k, v]) => [
50
+ k,
51
+ v.length,
52
+ ])
53
+ ),
54
+ queued: Object.fromEntries(
55
+ Object.entries(SHARED_HTTP_AGENT.requests).map(([k, v]) => [k, v.length])
56
+ ),
57
+ };
58
+ }
59
+
60
+ export { getService, getHttpAgentCount };
package/src/states.mjs ADDED
@@ -0,0 +1,423 @@
1
+ import { createClient } from "redis";
2
+ import { assert } from "./assert.mjs";
3
+ import { logger } from "./logger.mjs";
4
+ import { config } from "./config.mjs";
5
+
6
+ let redisClient = null;
7
+ let redisConnectPromise = null;
8
+ let REDIS_KEY = null;
9
+ let REDIS_CHANNEL = null;
10
+
11
+ function init() {
12
+ const appName = config?.server?.appName || "APP";
13
+ REDIS_KEY = `__STATE_${appName}`;
14
+ REDIS_CHANNEL = `__STATE_CHANNEL_${appName}`;
15
+ }
16
+
17
+ function assertInitialized() {
18
+ assert(REDIS_KEY, "Redis state module not initialized. Call init() first.");
19
+ assert(
20
+ REDIS_CHANNEL,
21
+ "Redis state module not initialized. Call init() first.",
22
+ );
23
+ }
24
+
25
+ async function ensureRedis() {
26
+ assertInitialized();
27
+
28
+ if (redisClient?.isOpen) return redisClient;
29
+ if (redisConnectPromise) return redisConnectPromise;
30
+
31
+ const url = config?.server?.redisUrl || "redis://127.0.0.1:6379";
32
+
33
+ redisClient = createClient({
34
+ url,
35
+ socket: {
36
+ reconnectStrategy(retries) {
37
+ return Math.min(retries * 100, 3000);
38
+ },
39
+ },
40
+ });
41
+
42
+ redisClient.on("error", (error) => {
43
+ logger.error(`redis client error: ${error?.message || error}`);
44
+ });
45
+
46
+ redisClient.on("connect", () => {
47
+ logger.info(`redis socket connected: ${url}`);
48
+ });
49
+
50
+ redisClient.on("ready", () => {
51
+ logger.info("redis client ready");
52
+ });
53
+
54
+ redisClient.on("reconnecting", () => {
55
+ logger.info("redis reconnecting");
56
+ });
57
+
58
+ redisClient.on("end", () => {
59
+ logger.info("redis connection ended");
60
+ });
61
+
62
+ redisConnectPromise = redisClient
63
+ .connect()
64
+ .then(() => redisClient)
65
+ .catch((error) => {
66
+ redisClient = null;
67
+ throw error;
68
+ })
69
+ .finally(() => {
70
+ redisConnectPromise = null;
71
+ });
72
+
73
+ return redisConnectPromise;
74
+ }
75
+
76
+ async function subscribeState(
77
+ computeName,
78
+ onStateChange,
79
+ onError,
80
+ stateFilter = {},
81
+ ) {
82
+ assertInitialized();
83
+
84
+ let subscriber = null;
85
+ let closed = false;
86
+
87
+ try {
88
+ const client = await ensureRedis();
89
+
90
+ subscriber = client.duplicate();
91
+
92
+ subscriber.on("error", (error) => {
93
+ logger.error(`redis subscriber error: ${error?.message || error}`);
94
+ onError?.(error);
95
+ });
96
+
97
+ subscriber.on("connect", () => {
98
+ logger.info(`redis subscriber connected: ${REDIS_CHANNEL}`);
99
+ });
100
+
101
+ subscriber.on("ready", () => {
102
+ logger.info(`redis subscriber ready: ${REDIS_CHANNEL}`);
103
+ });
104
+
105
+ subscriber.on("end", () => {
106
+ logger.info(`redis subscriber ended: ${REDIS_CHANNEL}`);
107
+ });
108
+
109
+ await subscriber.connect();
110
+
111
+ const triggerRead = async () => {
112
+ if (closed) return;
113
+
114
+ try {
115
+ const state = await loadState();
116
+
117
+ if (matchesFilter(state, stateFilter)) {
118
+ await onStateChange?.(state, {
119
+ computeName,
120
+ source: "redis-json",
121
+ key: REDIS_KEY,
122
+ channel: REDIS_CHANNEL,
123
+ });
124
+ }
125
+ } catch (error) {
126
+ onError?.(error);
127
+ }
128
+ };
129
+
130
+ await triggerRead();
131
+
132
+ await subscriber.subscribe(REDIS_CHANNEL, async () => {
133
+ if (closed) return;
134
+
135
+ try {
136
+ await triggerRead();
137
+ } catch (error) {
138
+ onError?.(error);
139
+ }
140
+ });
141
+
142
+ return {
143
+ async close() {
144
+ closed = true;
145
+
146
+ try {
147
+ if (subscriber?.isOpen) {
148
+ await subscriber.unsubscribe(REDIS_CHANNEL);
149
+ await subscriber.quit();
150
+ } else if (subscriber) {
151
+ subscriber.disconnect();
152
+ }
153
+ } catch (error) {
154
+ onError?.(error);
155
+ try {
156
+ subscriber?.disconnect();
157
+ } catch {}
158
+ }
159
+ },
160
+ };
161
+ } catch (error) {
162
+ logger.error(
163
+ `error while subscribing state from redis: ${error?.message || error}`,
164
+ );
165
+ onError?.(error);
166
+
167
+ try {
168
+ if (subscriber?.isOpen) {
169
+ await subscriber.quit();
170
+ } else if (subscriber) {
171
+ subscriber.disconnect();
172
+ }
173
+ } catch {}
174
+
175
+ return null;
176
+ }
177
+ }
178
+
179
+ async function loadState() {
180
+ assertInitialized();
181
+
182
+ try {
183
+ const client = await ensureRedis();
184
+
185
+ const state = await client.json.get(REDIS_KEY, {
186
+ path: "$",
187
+ });
188
+
189
+ if (state == null) return {};
190
+
191
+ if (Array.isArray(state)) {
192
+ return state[0] ?? {};
193
+ }
194
+
195
+ return state;
196
+ } catch (error) {
197
+ logger.error(
198
+ `error while loading state from redis json: ${error?.message || error}`,
199
+ );
200
+ return {};
201
+ }
202
+ }
203
+
204
+ async function saveState(STATE) {
205
+ assertInitialized();
206
+ assert(STATE && typeof STATE === "object", "STATE must be an object");
207
+
208
+ const cleaned = cleanState(STATE);
209
+
210
+ try {
211
+ const client = await ensureRedis();
212
+
213
+ await client.json.set(REDIS_KEY, "$", cleaned ?? {});
214
+
215
+ await publishStateChange(client, {
216
+ key: REDIS_KEY,
217
+ updatedAt: Date.now(),
218
+ mode: "save",
219
+ });
220
+ } catch (error) {
221
+ logger.error(
222
+ `error while saving state to redis json: ${error?.message || error}`,
223
+ );
224
+ throw error;
225
+ }
226
+ }
227
+
228
+ async function updateState(path, value, publish = true) {
229
+ assertInitialized();
230
+ assert(typeof path === "string" && path.trim(), "path must be a string");
231
+
232
+ const cleaned = cleanState(value);
233
+ const normalizedPath = normalizePath(path);
234
+
235
+ try {
236
+ const client = await ensureRedis();
237
+
238
+ let state = await loadState();
239
+
240
+ if (!state || typeof state !== "object" || Array.isArray(state)) {
241
+ state = {};
242
+ }
243
+
244
+ setByPath(state, normalizedPath, cleaned);
245
+
246
+ await client.json.set(REDIS_KEY, "$", state);
247
+
248
+ if (publish) {
249
+ await publishStateChange(client, {
250
+ key: REDIS_KEY,
251
+ path: toRedisJsonPath(normalizedPath),
252
+ updatedAt: Date.now(),
253
+ mode: "update",
254
+ });
255
+ }
256
+ } catch (error) {
257
+ logger.error(
258
+ `error while updating state in redis json: ${error?.message || error}`,
259
+ );
260
+ throw error;
261
+ }
262
+ }
263
+
264
+ async function getStatePath(path) {
265
+ assertInitialized();
266
+ assert(typeof path === "string" && path.trim(), "path must be a string");
267
+
268
+ try {
269
+ const state = await loadState();
270
+ return getByPath(state, normalizePath(path));
271
+ } catch (error) {
272
+ logger.error(
273
+ `error while getting state path from redis json: ${error?.message || error}`,
274
+ );
275
+ return undefined;
276
+ }
277
+ }
278
+
279
+ async function closeRedis() {
280
+ try {
281
+ if (redisClient?.isOpen) {
282
+ await redisClient.quit();
283
+ } else if (redisClient) {
284
+ redisClient.disconnect();
285
+ }
286
+ } catch (error) {
287
+ logger.error(`error while closing redis: ${error?.message || error}`);
288
+ try {
289
+ redisClient?.disconnect();
290
+ } catch {}
291
+ } finally {
292
+ redisClient = null;
293
+ redisConnectPromise = null;
294
+ }
295
+ }
296
+
297
+ async function publishStateChange(client, payload) {
298
+ await client.publish(REDIS_CHANNEL, JSON.stringify(payload));
299
+ }
300
+
301
+ function toRedisJsonPath(path) {
302
+ const normalized = normalizePath(path);
303
+ if (!normalized) return "$";
304
+ return `$.${normalized}`;
305
+ }
306
+
307
+ function normalizePath(path) {
308
+ const trimmed = String(path).trim();
309
+
310
+ if (!trimmed || trimmed === "$") return "";
311
+ if (trimmed.startsWith("$.")) return trimmed.slice(2);
312
+ if (trimmed.startsWith("$")) return trimmed.slice(1).replace(/^\./, "");
313
+
314
+ return trimmed;
315
+ }
316
+
317
+ function getByPath(obj, path) {
318
+ const normalized = normalizePath(path);
319
+ if (!normalized) return obj;
320
+
321
+ return normalized.split(".").reduce((acc, key) => acc?.[key], obj);
322
+ }
323
+
324
+ function setByPath(obj, path, value) {
325
+ const normalized = normalizePath(path);
326
+ if (!normalized) return value;
327
+
328
+ const parts = normalized.split(".");
329
+ let curr = obj;
330
+
331
+ for (let i = 0; i < parts.length - 1; i += 1) {
332
+ const key = parts[i];
333
+
334
+ if (
335
+ curr[key] === undefined ||
336
+ curr[key] === null ||
337
+ typeof curr[key] !== "object" ||
338
+ Array.isArray(curr[key])
339
+ ) {
340
+ curr[key] = {};
341
+ }
342
+
343
+ curr = curr[key];
344
+ }
345
+
346
+ curr[parts[parts.length - 1]] = value;
347
+ return obj;
348
+ }
349
+
350
+ function cleanState(value, seen = new WeakSet()) {
351
+ if (value && typeof value === "object") {
352
+ if (seen.has(value)) return undefined;
353
+ seen.add(value);
354
+ }
355
+
356
+ if (value === null || typeof value !== "object") return value;
357
+
358
+ if (value instanceof Date) return value.toISOString();
359
+
360
+ if (Buffer.isBuffer(value)) {
361
+ return value.toString("base64");
362
+ }
363
+
364
+ if (Array.isArray(value)) {
365
+ return value.map((v) => cleanState(v, seen)).filter((v) => v !== undefined);
366
+ }
367
+
368
+ if (value instanceof Map) {
369
+ return Object.fromEntries(
370
+ [...value.entries()]
371
+ .map(([k, v]) => [String(k), cleanState(v, seen)])
372
+ .filter(([, v]) => v !== undefined),
373
+ );
374
+ }
375
+
376
+ if (value instanceof Set) {
377
+ return [...value]
378
+ .map((v) => cleanState(v, seen))
379
+ .filter((v) => v !== undefined);
380
+ }
381
+
382
+ if (value instanceof Error) {
383
+ return {
384
+ name: value.name,
385
+ message: value.message,
386
+ stack: value.stack,
387
+ };
388
+ }
389
+
390
+ if (value.constructor && value.constructor !== Object) {
391
+ return undefined;
392
+ }
393
+
394
+ const out = {};
395
+ for (const [key, val] of Object.entries(value)) {
396
+ if (typeof val === "function" || typeof val === "symbol") continue;
397
+
398
+ const cleaned = cleanState(val, seen);
399
+ if (cleaned !== undefined) out[key] = cleaned;
400
+ }
401
+
402
+ return out;
403
+ }
404
+
405
+ function matchesFilter(state, filter) {
406
+ if (!filter || typeof filter !== "object") return true;
407
+
408
+ for (const [key, expected] of Object.entries(filter)) {
409
+ if (getByPath(state, key) !== expected) return false;
410
+ }
411
+
412
+ return true;
413
+ }
414
+
415
+ export {
416
+ init,
417
+ loadState,
418
+ saveState,
419
+ updateState,
420
+ getStatePath,
421
+ subscribeState,
422
+ closeRedis,
423
+ };