@samanbayaka/core 0.0.1

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.
@@ -0,0 +1,91 @@
1
+ import * as fs from "fs"
2
+ import * as path from "path"
3
+ import * as acorn from "acorn"
4
+
5
+ import { ABSOLUTE_PATH } from '#hUti/file-handler.mjs'
6
+
7
+ const extensions = new Set([".js", ".mjs"])
8
+
9
+ const getFiles = async(dir) => {
10
+ const entries = await fs.promises.readdir(dir, {
11
+ withFileTypes: true
12
+ })
13
+
14
+ const files = []
15
+
16
+ for (const entry of entries) {
17
+ if (entry.name === "node_modules") {
18
+ continue
19
+ }
20
+
21
+ const fullPath = path.join(dir, entry.name)
22
+
23
+ if (entry.isDirectory()) {
24
+ files.push(...await getFiles(fullPath))
25
+ } else if (extensions.has(path.extname(entry.name))) {
26
+ files.push(fullPath)
27
+ }
28
+ }
29
+
30
+ return files
31
+ }
32
+
33
+ const checkSyntax = async(file) => {
34
+ const source = await fs.promises.readFile(file, "utf8")
35
+
36
+ try {
37
+ acorn.parse(source, {
38
+ ecmaVersion: "latest",
39
+ sourceType: "module",
40
+ locations: true
41
+ })
42
+
43
+ return null
44
+ } catch (err) {
45
+ return {
46
+ file,
47
+ message: err.message,
48
+ line: err.loc?.line,
49
+ column: err.loc?.column
50
+ }
51
+ }
52
+ }
53
+
54
+ export const checkProjectSyntax = async(projectDir = ABSOLUTE_PATH) => {
55
+ const files = await getFiles(path.resolve(projectDir))
56
+ const errors = []
57
+
58
+ for (const file of files) {
59
+ const error = await checkSyntax(file)
60
+
61
+ if (error) {
62
+ errors.push(error)
63
+ }
64
+ }
65
+
66
+ return {
67
+ filesChecked: files.length,
68
+ errors
69
+ }
70
+ }
71
+
72
+ // // Example
73
+ // const result = await checkProjectSyntax()
74
+
75
+ // if (result.errors.length === 0) {
76
+ // console.log(
77
+ // `No syntax errors found (${result.filesChecked} files checked)`
78
+ // )
79
+ // } else {
80
+ // console.error(
81
+ // `Found ${result.errors.length} syntax error(s):`
82
+ // )
83
+
84
+ // for (const error of result.errors) {
85
+ // console.error(
86
+ // `${error.file}:${error.line}:${error.column} - ${error.message}`
87
+ // )
88
+ // }
89
+
90
+ // process.exitCode = 1
91
+ // }
@@ -0,0 +1,161 @@
1
+ import { Etcd3 } from "etcd3"
2
+ import YAML from 'yaml'
3
+
4
+ import * as errHdl from '#hUti/error-handler.mjs'
5
+
6
+ /**
7
+ * Handle uncaught errors
8
+ */
9
+ process.on("uncaughtException", async (err) => {
10
+ })
11
+ process.on("unhandledRejection", async (err) => {
12
+ })
13
+
14
+
15
+ /**
16
+ * User credentials for accessing and reading configurations from
17
+ * the configuration server.
18
+ * @type {string}
19
+ * @return {array} [user, password, port]
20
+ */
21
+ export const etcdCred = errHdl.validateConfigCred(process.env.SBK_CONFIG_CRED)
22
+
23
+ /**
24
+ * Configuration Reader Client Options
25
+ * @type {object}
26
+ */
27
+ export const client = new Etcd3({
28
+ hosts: `http://etcd:${etcdCred[2]}`,
29
+ auth: {
30
+ username: etcdCred[0],
31
+ password: etcdCred[1]
32
+ }
33
+ })
34
+
35
+ /**
36
+ * Getting configurations against a Etcd key
37
+ * @param {string}
38
+ * @return {object | string}
39
+ */
40
+ export const getConfigs = async (path, isYaml = false) => {
41
+ try {
42
+ const yamlText = (await client.get(path))?.toString()
43
+ if ( path.split('/').includes('yaml') ) {
44
+ return isYaml ? yamlText : Object.freeze( YAML.parse( yamlText ) )
45
+ }
46
+ else {
47
+ return yamlText
48
+ }
49
+
50
+ } catch (err) {
51
+ throw new Error(`Unable to read configuration: ${err.message}`)
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Getting all keys having prefix path
57
+ * @param {string} path
58
+ * @param {Boolean} fullPath
59
+ * @return {array}
60
+ */
61
+ export const getConfigKeys = async (path, fullPath = false) => {
62
+ try {
63
+ const entries = await client.getAll()
64
+ .prefix(path)
65
+ .strings()
66
+
67
+ const keys = Object.keys(entries)
68
+
69
+ return fullPath ? keys : keys.map( e => e.split('/').pop() )
70
+ }
71
+ catch (err) {
72
+ throw new Error(`Unable to read configuration keys: ${err.message}`)
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Create Etcd key with value
78
+ * @param {string} path
79
+ * @param {string} data
80
+ */
81
+ export const putConfig = async(path, data) => {
82
+ await client.put(path)
83
+ .value(data)
84
+ }
85
+
86
+ /**
87
+ * Delete Etcd key
88
+ * @param {string} path
89
+ * @return {string}
90
+ */
91
+ export const delConfig = async(path) => {
92
+ const deletedData = await client
93
+ .delete()
94
+ .key(path)
95
+ .getPrevious()
96
+
97
+ return deletedData.map(item => ({
98
+ ...item,
99
+ key: Buffer.from(item.key).toString("utf8"),
100
+ value: Buffer.from(item.value).toString("utf8"),
101
+ }))
102
+
103
+ }
104
+
105
+
106
+ export const getConfigAll = async (path, fullPath = false) => {
107
+ try {
108
+ const entries = await client.getAll()
109
+ .prefix(path)
110
+ .strings()
111
+
112
+ return fullPath
113
+ ? entries
114
+ : Object.fromEntries(
115
+ Object.entries(entries).map(([key, value]) => [
116
+ key.replace(path, ""),
117
+ value
118
+ ])
119
+ )
120
+ }
121
+ catch (err) {
122
+ throw new Error(`Unable to read configuration keys: ${err.message}`)
123
+ }
124
+ }
125
+
126
+
127
+ export const watchConfigs = async (path, callback) => {
128
+ try {
129
+ const watcher = await client
130
+ .watch()
131
+ .prefix(path)
132
+ .create()
133
+
134
+ watcher.on("put", event => {
135
+ const evVal = path.split('/').includes('yaml')
136
+ ? Object.freeze( YAML.parse(event.value.toString()) )
137
+ : event.value?.toString()
138
+ callback(
139
+ "put",
140
+ event.key?.toString(),
141
+ evVal
142
+ )
143
+ })
144
+
145
+ watcher.on("delete", event => {
146
+ callback(
147
+ "delete",
148
+ event.key.toString(),
149
+ null
150
+ )
151
+ })
152
+ return watcher
153
+ }
154
+ catch (err) {
155
+ throw new Error(`Unable to watck configuration: ${err.message}`)
156
+ }
157
+
158
+ }
159
+
160
+
161
+
@@ -0,0 +1,456 @@
1
+ import dns from 'node:dns'
2
+ import {STATUS_CODES} from 'node:http'
3
+ import { Errors } from "moleculer"
4
+ import { COMMIT_HASH } from "../../commit-hash.mjs"
5
+ import { checkProjectSyntax } from '#hUti/check-syntax.mjs'
6
+
7
+
8
+ const NODE_VER_REQ = 22
9
+ const envRegx = /^(development|production|testing)$/
10
+ const sysClassification = { development: 'dev', testing: 'tst', production: 'prd' }
11
+ const commitHsRegx = /^[a-f0-9]{7}$/i
12
+ const configCredRegex = /^[a-zA-Z0-9]{3,16} [a-zA-Z0-9!@#$^_-]{3,16} [0-9]{4,5}$/
13
+ const serviceNmRegx = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
14
+ const portRegx = /^876[0-9]$/
15
+ const logLvlRegx = /^(fatal|error|warn|info|debug|trace)$/i
16
+ const telemetryRegx = /^(true|false)$/i
17
+
18
+
19
+ /**
20
+ * Retain the lines from the error stack that contain "file:///"
21
+ * @param {string} error
22
+ * @return {string}
23
+ */
24
+ export const extractFileLines = (stack) => {
25
+ if (!String(stack).includes("file:///")) {
26
+ return stack
27
+ }
28
+
29
+ const str = String(stack)
30
+ .split(/\r?\n/)
31
+
32
+ return [
33
+ str[0],
34
+ ...str.filter(line => line.includes("file:///"))
35
+ ].join("\n")
36
+ }
37
+
38
+ /**
39
+ * Validate Node version
40
+ * @return {number}
41
+ *
42
+ * @throws error
43
+ */
44
+ export const checkMinNoveVer = () => {
45
+ const NODE_VER_CUR = parseInt(process.versions.node.split('.')[0])
46
+ if(NODE_VER_CUR < NODE_VER_REQ){
47
+ throw new Error(`Node.js >=${NODE_VER_REQ}.x.x is required but current is ${NODE_VER_CUR}.x.x.`)
48
+ }
49
+ return NODE_VER_REQ
50
+ }
51
+
52
+
53
+ /**
54
+ * Validate APP_ENV entry
55
+ * @return {string}
56
+ *
57
+ * @throws error
58
+ */
59
+ export const isValidAppEnv = (env) => {
60
+ if (!envRegx.test(env)) {
61
+ throw new Error(`APP_ENV is '${env}', it must be 'development', 'production', or 'testing'`)
62
+ }
63
+ return sysClassification[env]
64
+ }
65
+
66
+
67
+ /**
68
+ * Validate git commit hash
69
+ * @return {string}
70
+ *
71
+ * @throws error
72
+ */
73
+ export const isValidCommitHash = (hs) => {
74
+ if(!commitHsRegx.test(hs)){
75
+ throw new Error(`Git commit hash is '${hs}', it must be 7-character hex string.`)
76
+ }
77
+ return hs
78
+ }
79
+
80
+
81
+ /**
82
+ * Validate namespace
83
+ * @retutn {string}
84
+ */
85
+ export const validateNamespace = (env) => {
86
+ return `${isValidAppEnv(env)}-sbk-${isValidCommitHash(COMMIT_HASH)}`.toUpperCase()
87
+ }
88
+
89
+
90
+ /**
91
+ * Validate the string for establis Etcd3 client connection
92
+ * @return {string}
93
+ *
94
+ * @throws error
95
+ */
96
+ export const validateConfigCred = (env) => {
97
+ if(!configCredRegex.test(env)){
98
+ throw new Error(`Environment variable SBK_CONFIG_CRED is "${env}". It must be a valid Etcd user credential in the format "user password port".`)
99
+ }
100
+ return env.split(" ")
101
+ }
102
+
103
+
104
+ /**
105
+ * Validate that the MAX_L1_TTL is defined and does not exceed 120 seconds
106
+ * @return {number} between 0 to 120
107
+ *
108
+ * @throws error
109
+ */
110
+ export const validateL1TTL = (ttl = 120) => {
111
+ const num = Number(ttl)
112
+ if (!Number.isInteger(num) || num < 0 || num > 120) {
113
+ throw new Error(`Configuration variable 'MAX_L1_TTL' is ${ttl}, it must be between 0 and 120`)
114
+ }
115
+
116
+ return num
117
+ }
118
+
119
+
120
+ /**
121
+ * Validate that the MAX_L2_TTL is defined and does not exceed 600 seconds
122
+ * @return {number} between 0 to 600
123
+ *
124
+ * @throws error
125
+ */
126
+ export const validateL2TTL = (ttl = 600) => {
127
+ const num = Number(ttl)
128
+ if (!Number.isInteger(num) || num < 0 || num > 600) {
129
+ throw new Error(`Configuration variable 'MAX_L2_TTL' is ${ttl}, it must be between 0 and 600`)
130
+ }
131
+
132
+ return num
133
+ }
134
+
135
+
136
+ /**
137
+ * Validate configuration variable SBK_PORT for Apigateway port number
138
+ * @return {number} between 8760 to 8769
139
+ *
140
+ * @throws error
141
+ */
142
+ export const validatePort = (port) => {
143
+ if ( port !== undefined && !(portRegx.test(port)) ) {
144
+ throw new Error(`Configuration variable 'PORT' is ${port}, it must be between 8760 and 8769`)
145
+ }
146
+ return Number(port || 8765)
147
+ }
148
+
149
+
150
+ /**
151
+ * Validate environment variable SBK_LOG_LEVEL
152
+ * @return {string}
153
+ *
154
+ * @throws error
155
+ */
156
+ export const validateLogLevel = (env) => {
157
+ if ( !(logLvlRegx.test(env)) ) {
158
+ throw new Error(`LOG_LEVEL is ${env}, it must be fatal | error | warn | info | debug | trace `)
159
+ }
160
+ return env
161
+ }
162
+
163
+
164
+ /**
165
+ * Validate hosts entry
166
+ * @param {string} dnsTxt
167
+ * @param {Boolean} isOptional
168
+ * @return {boolean}
169
+ *
170
+ * @throws error
171
+ * @throws warn
172
+ */
173
+ export const validateHostEntry = (dnsTxt, isOptional = false) => {
174
+ return new Promise((resolve, reject) => {
175
+ dns.lookup(dnsTxt, (err, address) => {
176
+ if (err || !address) {
177
+ if (isOptional){
178
+ console.warn("\x1b[33m%s\x1b[0m", `SBKWRN ❖ Missing hosts entry for "${dnsTxt}".`)
179
+ return resolve(false)
180
+ }
181
+ return reject(new Error(`Missing hosts entry for "${dnsTxt}".`))
182
+ }
183
+ resolve(true)
184
+ })
185
+ })
186
+ }
187
+
188
+
189
+ /**
190
+ * Validates a service name against a predefined regex pattern.
191
+ *
192
+ * @param {string} name - The service name to validate.
193
+ * @returns {boolean} Returns true if the service name is valid.
194
+ *
195
+ * @throws error
196
+ */
197
+ export const validateServiceName = (schema, dirName) => {
198
+ const pkgName = dirName.replace(/^\$/, '')
199
+ if(!serviceNmRegx.test(pkgName)) {
200
+ throw new Error(`Invalid service name "${schema.name}".\n Only lowercase letters (a–z), digits (0–9), and hyphens (-) are allowed.`)
201
+ }
202
+ if(schema.name.replace(/^\$/, '') != pkgName){
203
+ throw new Error(`Service name and package name must be the same, but found "${schema.name.replace(/^\$/, '')}" and "${pkgName}"`)
204
+ }
205
+
206
+ /**
207
+ * convert package name like "kafka-produce"
208
+ * to moleculer supported service name "kafka.produce"
209
+ */
210
+ schema.name = schema.name.replace(/-/g, ".")
211
+ return true
212
+ }
213
+
214
+
215
+ /**
216
+ * Telemetry enable or disable inputs
217
+ * @param {string}
218
+ * @return {boolean}
219
+ */
220
+ export const validateTelemetry = (env) => {
221
+ if ( !(telemetryRegx.test(env)) ) {
222
+ throw new Error(`TELEMETRY is ${env}, it must be true | false `)
223
+ }
224
+ return env.toLowerCase() === "true" ? true : false
225
+ }
226
+
227
+ /**
228
+ * Validate config revision
229
+ * @param {string}
230
+ * @return {object}
231
+ */
232
+ export const validateConfRevision = (env) => {
233
+ env = env || '{"pub":0,"edg":0,"mem":{"system":0,"gateway":0}}'
234
+ return JSON.parse(env)
235
+ }
236
+
237
+
238
+ /**
239
+ * Graceful shutdown handler
240
+ */
241
+ export const gracefulShutdown = async (broker, signal, err=null) => {
242
+ // broker.logger.info({tag:"SBK", message: `${signal} detected, Stopping the broker...`})
243
+ try {
244
+ await broker.stop()
245
+ if(err) {
246
+ const { message, stack } = err
247
+ // broker.logger.error({tag: "SBK", message: `Broker stopped after detecting the ${signal} signal.`}, {err})
248
+ broker.logger.error({tag: "SBK", message, stack: extractFileLines(stack)})
249
+ process.exit(1)
250
+ }
251
+ else{
252
+ broker.logger.info({tag: "SBK", message: "Broker stopped gracefully."})
253
+ process.exit(0)
254
+ }
255
+ } catch (brokerStoppingErr) {
256
+ if(broker) {
257
+ broker.logger.error({tag: "SBK"}, {brokerStoppingErr}, "Unable to stop the broker.")
258
+ }
259
+ else {
260
+ const { message, stack } = err
261
+ if (err instanceof SyntaxError) {
262
+ const result = await checkProjectSyntax()
263
+ if(result.errors.length > 0){
264
+ for (const error of result.errors) {
265
+ const {file, line, column} = error
266
+ console.error(
267
+ "\x1b[31m%s\x1b[0m",
268
+ ` ❖❖ SBKERR ❖❖ \nSyntaxError: ${err.message}\n at ${file.replace(/^.*node_modules\//, "../")}:${line}:${column}`
269
+ )
270
+ }
271
+ }
272
+ }
273
+ else {
274
+ console.error("\x1b[31m%s\x1b[0m", ` ❖❖ SBKERR ❖❖ \n ${extractFileLines(stack)}`)
275
+ }
276
+
277
+ }
278
+
279
+ process.exit(1)
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Captures errors thrown during request processing, logs relevant details
285
+ * (message, stack trace, request context), and formats a standardized
286
+ * error response before sending it to the client.
287
+ *
288
+ * @type {Object}
289
+ */
290
+ export const ErrorFormatterMW = {
291
+ localAction: (next) => {
292
+ return async(ctx) => {
293
+ try {
294
+ return await next(ctx)
295
+ }
296
+ catch (err) {
297
+ if(!err?.data?.requestID) {
298
+ const requestID = ctx?.requestID
299
+ const status = err?.code || 500
300
+ const type = err?.type || 'INTERNAL_ERROR'
301
+ const instance = ctx?.action?.name
302
+ ? `${ctx?.action?.name}`
303
+ : `event.${ctx?.event?.name}`
304
+ const title = err?.message || ''
305
+ const errors = Array.isArray(err?.data)
306
+ ? err.data.map(({ nodeID, action, ...rest }) => rest)
307
+ : err?.data || []
308
+
309
+ const stack = err?.stack || ""
310
+ const timestamp = new Date().toISOString()
311
+
312
+ if(err.code){
313
+ ctx.broker.logger.error({tag: "SBK", requestID, type, instance, errors, stack: ""})
314
+ }
315
+ else{
316
+ ctx.broker.logger.error({tag: "SBK", requestID, type, instance, errors, stack: extractFileLines(stack)})
317
+ }
318
+
319
+
320
+ throw new Errors.MoleculerError(
321
+ title,
322
+ status,
323
+ type,
324
+ {
325
+ type,
326
+ title,
327
+ status,
328
+ timestamp,
329
+ requestID,
330
+ errors,
331
+ }
332
+ )
333
+ }
334
+ else{
335
+ throw err // IMPORTANT: rethrow
336
+ }
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+
343
+ export const httpErrorSchema = {
344
+ type: "object",
345
+ properties: {
346
+ type: {
347
+ type: "string",
348
+ description: "Application-specific error type",
349
+ examples: [
350
+ "NOT_FOUND",
351
+ "UNAUTHORIZED",
352
+ "VALIDATION_ERROR",
353
+ "INTERNAL_ERROR",
354
+ ]
355
+ },
356
+ title: {
357
+ type: "string",
358
+ description: "Short, human-readable summary of the problem",
359
+ example: "test error"
360
+ },
361
+ status: {
362
+ type: "integer",
363
+ description: "HTTP status code",
364
+ examples: [400, 401, 422, 500]
365
+ },
366
+ detail: {
367
+ type: "string",
368
+ description: "Human-readable explanation of the problem",
369
+ example: "test error"
370
+ },
371
+ timestamp: {
372
+ type: "string",
373
+ description: "Timestamp when the error occurred",
374
+ example: "2026-04-25T18:55:45.035Z"
375
+ },
376
+ requestID: {
377
+ type: "string",
378
+ description: "Unique request identifier for tracing",
379
+ example: "110e025a-7362-4694-87da-63aada9cae53"
380
+ },
381
+ instance: {
382
+ type: "string",
383
+ description: "Request method and URI that caused the error",
384
+ example: "GET /api/test"
385
+ },
386
+ errors: {
387
+ type: "array",
388
+ default: [],
389
+ items: {
390
+ type: "object",
391
+ properties: {
392
+ path: {
393
+ type: "string",
394
+ example: "params.id"
395
+ },
396
+ message: {
397
+ type: "string",
398
+ example: "id should be integer"
399
+ },
400
+ },
401
+ required: ["path", "message"],
402
+ additionalProperties: false
403
+ },
404
+ },
405
+ },
406
+ required: [
407
+ "type",
408
+ "title",
409
+ "status",
410
+ "detail",
411
+ "timestamp",
412
+ "requestID",
413
+ "instance",
414
+ "errors"
415
+ ],
416
+ additionalProperties: false
417
+ }
418
+
419
+ /**
420
+ * Normalizes error objects into a consistent HTTP response structure.
421
+ * Ensures all errors include a standard set of fields such as status code,
422
+ * message, and optional metadata for client consumption.
423
+ */
424
+ export const httpErrorFormatter = (req, res, err)=> {
425
+ const defaultDetails = {
426
+ 400: "The request is malformed.",
427
+ 401: "Authentication is required.",
428
+ 403: "You do not have permission to perform this action.",
429
+ 404: "The requested resource was not found.",
430
+ 405: "The requested HTTP method is not allowed.",
431
+ 409: "The request conflicts with the current state of the resource.",
432
+ 415: "The request content type is not supported.",
433
+ 422: "One or more parameters are invalid.",
434
+ 429: "Too many requests have been made.",
435
+ 500: "An unexpected server error occurred.",
436
+ 502: "An invalid response was received from an upstream service.",
437
+ 503: "The service is temporarily unavailable.",
438
+ 504: "The request timed out."
439
+ }
440
+ const code = err?.data?.status || err?.code || 500
441
+
442
+ const errorBody = JSON.stringify({
443
+ type: err?.data?.type || err?.type || "about:blank",
444
+ title: err?.data?.title || err?.message,
445
+ status: code,
446
+ detail: err?.data?.detail || defaultDetails[code] || STATUS_CODES[code] || "Unknown error",
447
+ timestamp: err?.data?.timestamp || new Date().toISOString(),
448
+ requestID: err?.data?.requestID || req.$ctx?.requestID,
449
+ instance: err?.data?.instance || `${req.method} ${req.$ctx?.params?.req?.originalUrl}`,
450
+ errors: err?.data?.errors || []
451
+ })
452
+ res.setHeader("Content-Type", "application/json")
453
+ res.writeHead(code)
454
+ res.end(errorBody)
455
+
456
+ }