@vida-global/core 1.4.5 → 1.4.6

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/AGENTS.md ADDED
@@ -0,0 +1,13 @@
1
+ # vida-core Guide
2
+
3
+ Use this guide to understand and write code for the vida-core repo.
4
+
5
+ # Style and conventions
6
+ @agents/style.md
7
+ @agents/server.md
8
+ @agents/db.md
9
+ @agents/apis.md
10
+ @agents/testing.md
11
+
12
+
13
+ # Helpers for common use cases
@@ -1,2 +1,33 @@
1
- ## Logger ##
2
- A standard logger to be used across all Vida applications. Currently, a simple implementation of `pino`, supports the full `pino` API.
1
+ # Logger
2
+ A simple logger that support multiple scopes and log levels. Set the `LOG_LEVEL` environment variable to determine what log levels are output.
3
+
4
+ ## Scopes
5
+ ```
6
+ logger.addScope('foo');
7
+ logger.addScope('bar');
8
+
9
+ logger.foo.debug('my log');
10
+ // [2026-04-30 16:16:12.797 -0700][DEBUG][FOO] my log
11
+
12
+ logger.bar.verbose('my log');
13
+ // [2026-04-30 16:16:12.797 -0700][VERBOSE][BAR] my log
14
+
15
+ logger.bar.info('my log');
16
+ // [2026-04-30 16:16:12.797 -0700][INFO][BAR] my log
17
+
18
+ logger.bar.warn('my log');
19
+ // [2026-04-30 16:16:12.797 -0700][WARN][BAR] my log
20
+
21
+ logger.bar.error('my log');
22
+ // [2026-04-30 16:16:12.797 -0700][ERROR][BAR] my log
23
+ ```
24
+
25
+ ## IDs
26
+ ```
27
+ logger.addScope('http');
28
+ const requestId = 123;
29
+ const requestLogger = logger.http.createChild(requestId);
30
+
31
+ requestLogger.debug('my log');
32
+ // [2026-04-30 16:16:12.797 -0700][DEBUG][HTTP][123] my log
33
+ ``
@@ -1,25 +1,104 @@
1
- const pino = require('pino');
2
-
3
- const config = {
4
- level: process.env.LOG_LEVEL || 'info',
5
- customLevels: {
6
- test: 1000
7
- }
8
- };
9
-
10
- if (!process.env.ENV_VERCEL) {
11
- config.transport = {
12
- target: 'pino-pretty',
13
- options: {
14
- colorize: true,
15
- ignore: 'pid,hostname',
16
- translateTime: 'SYS:standard',
17
- messageFormat: '{msg}',
1
+ const winston = require('winston');
2
+ const { colorize, combine, printf, timestamp } = winston.format;
3
+
4
+
5
+ class Logger {
6
+ #id;
7
+ #logger;
8
+ #scope;
9
+
10
+
11
+ constructor(scope, id) {
12
+ this.#id = id;
13
+ this.#scope = scope || 'API';
14
+ this.#logger = winston.createLogger(this.loggerOptions);
15
+ }
16
+
17
+
18
+ get scope() { return this.#scope; }
19
+ get id() { return this.#id; }
20
+
21
+
22
+ get loggerOptions() {
23
+ const format = combine(...this.formatters);
24
+ const level = this.level;
25
+ const transports = this.transports;
26
+
27
+ return { format, level, transports };
28
+ }
29
+
30
+
31
+ get formatters() {
32
+ const colors = {
33
+ debug: 'magenta',
34
+ verbose: 'green',
35
+ info: 'cyan',
36
+ warn: 'yellow',
37
+ error: 'red',
38
+ }
39
+ const messageFormatter = winston.format(this.messageFormatter.bind(this));
40
+ return [
41
+ timestamp({format: 'YYYY-MM-DD HH:mm:ss.SSS ZZ'}),
42
+ messageFormatter(),
43
+ colorize({all: true, colors}),
44
+ printf(({ message }) => message)
45
+ ];
46
+ }
47
+
48
+
49
+ messageFormatter(info) {
50
+ const level = info.level.toUpperCase()
51
+
52
+ let prefix = `[${info.timestamp}][${level}][${this.scope.toUpperCase()}]`;
53
+ if (this.id) prefix = `${prefix}[${this.id}]`;
54
+
55
+ info.message = `${prefix} ${info.message}`;
56
+ return info;
57
+ }
58
+
59
+
60
+ get level() {
61
+ return process.env.LOG_LEVEL || 'info';
62
+ }
63
+
64
+
65
+ get transports() {
66
+ const transports = [ new winston.transports.Console() ];
67
+ return transports;
68
+ }
69
+
70
+
71
+ addScope(scope) {
72
+ const existing = this[scope];
73
+ if (existing && !existing instanceof Logger) {
74
+ throw `Property already exists at ${scope}`;
18
75
  }
19
- };
76
+
77
+ const logger = new Logger(scope, this.id);
78
+ Object.defineProperty(this, scope, { get: () => logger });
79
+ }
80
+
81
+
82
+ createChild(id) {
83
+ return new Logger(this.scope, id);
84
+ }
85
+
86
+
87
+ debug(msg) { this.#logger.debug(msg) }
88
+ error(msg) { this.#logger.error(msg) }
89
+ info(msg) { this.#logger.info(msg) }
90
+ silly(msg) { this.#logger.silly(msg) }
91
+ verbose(msg) { this.#logger.verbose(msg) }
92
+ warn(msg) { this.#logger.warn(msg) }
93
+
94
+ get _winstonLogger() {
95
+ this.warn('Directly accessing the underlying winston logger should be avoided unless necessary');
96
+ return this.#logger
97
+ }
20
98
  }
21
99
 
22
- const logger = pino(config);
100
+
101
+ const logger = new Logger();
23
102
 
24
103
 
25
- module.exports = { logger };
104
+ module.exports = { logger, Logger };
@@ -0,0 +1,39 @@
1
+ const { logger } = require('./index');
2
+ const expressWinston = require('express-winston');
3
+
4
+
5
+ const msgFormatter = (req, res) => {
6
+ let msg;
7
+ if (req.controller) {
8
+ msg = `${req.controller.constructor.name}#${req.action}`;
9
+ } else {
10
+ msg = res.statusCode >= 500 ? 'request errored' : 'request completed';
11
+ }
12
+
13
+ const headerPcs = res._header.split("\n").map(h => h.split(/:(.*)/));
14
+ const headers = Object.fromEntries(headerPcs);
15
+ const responseTime = headers['X-Response-Time'];
16
+
17
+ const requestDetails = `req: ${req.method} ${req.url.split('?')[0]} for ${req.ip}`;
18
+ const responseDetails = `res: statusCode=${res.statusCode} responseTime=${responseTime}`;
19
+
20
+ msg = `${msg}\n ${requestDetails}\n ${responseDetails}`;
21
+ if (res.error) {
22
+ msg = `${msg}\n${res.error.stack}`;
23
+ }
24
+
25
+ return msg;
26
+ };
27
+
28
+
29
+ logger.addScope('http');
30
+ const middleware = expressWinston.logger({
31
+ level: 'debug',
32
+ msg: msgFormatter,
33
+ winstonInstance: logger.http._winstonLogger,
34
+ });
35
+
36
+
37
+ module.exports = {
38
+ middleware
39
+ }
@@ -1,13 +1,13 @@
1
1
  const { ControllerImporter } = require('./controllerImporter');
2
2
  const express = require('express');
3
- const httpLogger = require('pino-http')
4
3
  const { logger } = require('../logger');
4
+ const loggingMiddleware = require('../logger/serverMiddleware');
5
5
  const mustacheExpress = require('mustache-express');
6
- const { pino } = require('pino');
7
6
  const responseTime = require('response-time');
8
7
  const IoServer = require("socket.io")
9
8
  const { Server } = require('http');
10
9
  const { SystemController } = require('./systemController');
10
+ const requestID = require( 'express-request-id');
11
11
 
12
12
 
13
13
  class VidaServer {
@@ -31,7 +31,7 @@ class VidaServer {
31
31
  await this.registerControllers();
32
32
 
33
33
  this.#httpServer.listen(this.#port, this.#host, () => {
34
- this.logger.info(`Server is running on port ${this.#port}`);
34
+ logger.info(`Server is running on port ${this.#port}`);
35
35
  if (callback) callback();
36
36
  });
37
37
  }
@@ -39,7 +39,7 @@ class VidaServer {
39
39
 
40
40
  get host() { return this.#host; }
41
41
  get port() { return this.#port; }
42
- get logger() { return logger; }
42
+ get logger() { return logger.http; }
43
43
 
44
44
 
45
45
  /***********************************************************************************************
@@ -72,11 +72,12 @@ class VidaServer {
72
72
  setupMiddleware() {
73
73
  this.use(this.jsonParsingMiddleware);
74
74
  this.use(this.octetStreamParsingMiddleware);
75
- this.use(responseTime())
75
+ this.use(responseTime({suffix: true}))
76
76
  this.use(express.static('public'))
77
77
  this.use(this.loggingMiddleware);
78
78
  this.use('/static', express.static(this.staticFilesDirectory));
79
79
  this.use(this.connectionAbortedMiddleware);
80
+ this.use(requestID());
80
81
 
81
82
  this.#expressServer.engine('html', mustacheExpress());
82
83
  }
@@ -105,15 +106,7 @@ class VidaServer {
105
106
 
106
107
 
107
108
  get loggingMiddleware() {
108
- return httpLogger({
109
- logger: this.middlewareLogger,
110
- customLogLevel: this.requestLogLevel.bind(this),
111
- customSuccessMessage: this.requestLogMessage.bind(this),
112
- customErrorMessage: this.requestLogMessage.bind(this),
113
- customErrorObject: this.requestLogDetails.bind(this),
114
- customSuccessObject: this.requestLogDetails.bind(this),
115
- wrapSerializers: false,
116
- })
109
+ return loggingMiddleware.middleware
117
110
  }
118
111
 
119
112
 
@@ -135,53 +128,6 @@ class VidaServer {
135
128
  use() { this.#expressServer.use(...arguments); }
136
129
 
137
130
 
138
- /***********************************************************************************************
139
- * LOGGING
140
- ***********************************************************************************************/
141
- requestLogDetails(req, res, err) {
142
- const details = {
143
- req: `${req.method} ${req.url}`,
144
- res: `statusCode=${res.statusCode}, responseTime=${res.get('X-Response-Time')}`,
145
- };
146
-
147
- if (res.statusCode >= 500 && res.error) {
148
- details.error = this.requestLogErrorDetails(req, res, res.error);
149
- }
150
-
151
- return details;
152
- }
153
-
154
-
155
- requestLogErrorDetails(req, res, err) {
156
- if (typeof err == 'string') return {message: err};
157
- return {
158
- type: err.constructor.name,
159
- message: err.message,
160
- stack: err.stack
161
- }
162
- }
163
-
164
-
165
- requestLogMessage(req, res) {
166
- const prefix = '[VidaServer]';
167
- if (req.controller) {
168
- return `${prefix} ${req.controller.constructor.name}#${req.action}`;
169
- }
170
-
171
- return `${prefix} ${res.status >= 500 ? 'request errored' : 'request completed'}`;
172
- }
173
-
174
-
175
- requestLogLevel(req, res) {
176
- return 'debug';
177
- }
178
-
179
-
180
- get middlewareLogger() {
181
- return logger;
182
- }
183
-
184
-
185
131
  /***********************************************************************************************
186
132
  * SETTINGS
187
133
  ***********************************************************************************************/
@@ -234,7 +180,7 @@ class VidaServer {
234
180
  const method = action.method.toLowerCase();
235
181
  const requestHandler = this.requestHandler(action.action, controllerCls)
236
182
  if (process.env.NODE_ENV != 'test') {
237
- logger.info(`ROUTE: ${method.toUpperCase().padEnd(6)} ${action.path}`);
183
+ logger.verbose(`ROUTE: ${method.toUpperCase().padEnd(6)} ${action.path}`);
238
184
  }
239
185
  this['_'+method](action.path, requestHandler);
240
186
  }
@@ -1,6 +1,7 @@
1
1
  const { logger } = require('../logger');
2
2
  const { camelize, singularize } = require('inflection');
3
3
  const Errors = require('./errors');
4
+ const nodeUtil = require('util');
4
5
 
5
6
 
6
7
  const AUTH_CALLBACK_NAME = 'authenticateRequest';
@@ -12,10 +13,13 @@ class VidaServerController {
12
13
  #beforeCallbacks = [];
13
14
  #beforeCallbacksToSkip = [];
14
15
  #callbacksSetUp = false;
16
+ #isStreaming = false;
17
+ #logger;
15
18
  #params;
16
19
  #rendered = false;
17
20
  #request;
18
21
  #response;
22
+ #streamAbortController;
19
23
 
20
24
 
21
25
  constructor(request, response) {
@@ -41,16 +45,36 @@ class VidaServerController {
41
45
  return structuredClone(this.#params);
42
46
  }
43
47
 
48
+ get requestId() { return this._request.id; }
44
49
  get requestHeaders() { return structuredClone(this._request.headers || {}); }
45
50
  get responseHeaders() { return this._response.headers; };
46
51
  get requestBody() { return this._request.body; }
52
+ get requestMethod() { return this._request.method; }
53
+ get requestIp() { return this._request.ip; }
54
+ get url() { return this._request.originalUrl; }
47
55
  get contentType() { return this.requestHeaders['content-type']; }
48
- get logger() { return logger; }
49
- get rendered() { return this.#rendered }
50
- markRendered() { this.#rendered = true; }
56
+ get userAgent() { return this.requestHeaders['user-agent']; }
51
57
 
52
- get statusCode() { return this._response.statusCode; }
53
- set statusCode(_status) { this._response.statusCode = _status; }
58
+ get statusCode() { return this._response.statusCode; }
59
+ set statusCode(_status) { this._response.statusCode = _status; }
60
+
61
+
62
+ get logger() {
63
+ if (!this.#logger) {
64
+ this.#logger = logger.http.createChild(this.requestId);
65
+ }
66
+ return this.#logger;
67
+ }
68
+
69
+
70
+ get bearerToken() {
71
+ const auth = this.requestHeaders.authorization;
72
+ if (!auth) return null;
73
+
74
+ const match = /^\s*bearer\s+(.+)$/i.exec(auth);
75
+ if (!match) return null;
76
+ return match[1].trim();
77
+ }
54
78
 
55
79
 
56
80
  #processRequestData(key) {
@@ -148,6 +172,9 @@ class VidaServerController {
148
172
  * RESPONSE RENDERING
149
173
  ***********************************************************************************************/
150
174
  async render(body, options={}) {
175
+ if (this.rendered) return;
176
+ if (this.#isStreaming) return;
177
+
151
178
  if (typeof body == 'string') {
152
179
  this._response.send(body);
153
180
  } else {
@@ -159,7 +186,7 @@ class VidaServerController {
159
186
  body = this.formatJSONBody(body, errors, options);
160
187
  this._response.json(body);
161
188
  }
162
- this.#rendered = true;
189
+ this.markRendered();
163
190
  }
164
191
 
165
192
 
@@ -196,6 +223,8 @@ class VidaServerController {
196
223
  if (body === undefined) return null;
197
224
  if (!body || typeof body != 'object') return body;
198
225
 
226
+ if (nodeUtil.types.isProxy(body)) body = {...body};
227
+
199
228
  body = structuredClone(body);
200
229
 
201
230
  if (body.toApiResponse) {
@@ -324,6 +353,77 @@ class VidaServerController {
324
353
  }
325
354
 
326
355
 
356
+ setHeader(header, value) {
357
+ this.#response.setHeader(header, value);
358
+ }
359
+
360
+
361
+ flushHeaders() {
362
+ this.#response.flushHeaders();
363
+ }
364
+
365
+
366
+ get rendered() { return this.#rendered; }
367
+ markRendered() { this.#rendered = true; }
368
+ get writableEnded() { return this.#response.writableEnded; }
369
+
370
+
371
+ /***********************************************************************************************
372
+ * STREAM
373
+ ***********************************************************************************************/
374
+ async streamSseResponse(handler, errorHandler, abortController=null) {
375
+ if (this.#isStreaming) throw new StreamInProgressError();
376
+ this.#isStreaming = true;
377
+
378
+ let closeHandler;
379
+ if (abortController) {
380
+ this.#streamAbortController = abortController;
381
+ closeHandler = () => abortController.abort();
382
+ this.#request.on('close', closeHandler);
383
+ }
384
+
385
+ this.#setSseHeaders();
386
+ this.markRendered();
387
+ try {
388
+ await handler.bind(this)();
389
+ } catch(err) {
390
+ await errorHandler(err);
391
+ throw err;
392
+ } finally {
393
+ this.writeStreamEvent(null, '[DONE]');
394
+ this.#response.end();
395
+
396
+ this.#isStreaming = false;
397
+ this.#streamAbortController = null;
398
+
399
+ if (closeHandler) this.#request.off('close', closeHandler);
400
+ }
401
+ }
402
+
403
+
404
+ #setSseHeaders() {
405
+ this.statusCode = 200;
406
+ this.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
407
+ this.setHeader('Cache-Control', 'no-cache');
408
+ this.setHeader('Connection', 'keep-alive');
409
+ this.setHeader('X-Accel-Buffering', 'no');
410
+ this.flushHeaders();
411
+ }
412
+
413
+
414
+ writeStreamEvent(evt, data) {
415
+ if (!this.#isStreaming) throw new NoActiveStreamError();
416
+ if (this.#streamAbortController?.signal?.aborted || this.writableEnded) return;
417
+
418
+ if (evt) {
419
+ this.#response.write(`event: ${evt}\n`);
420
+ }
421
+
422
+ const msg = `data: ${data}\n\n`;
423
+ this.#response.write(msg);
424
+ }
425
+
426
+
327
427
  /***********************************************************************************************
328
428
  * HELPERS
329
429
  ***********************************************************************************************/
@@ -531,6 +631,12 @@ class VidaServerController {
531
631
  }
532
632
 
533
633
 
634
+ validateIsBoolean(value, bool) {
635
+ if (typeof value == 'boolean' && value === bool) return;
636
+ return `must be ${bool}`;
637
+ }
638
+
639
+
534
640
  async validateFunction(value, fnc) {
535
641
  const error = await fnc.call(this, value);
536
642
  if (error) return error;
@@ -670,15 +776,31 @@ class VidaServerController {
670
776
  static authenticationDocumentation(actionName) {
671
777
  return {apiKeyAuth: []};
672
778
  }
779
+
780
+
781
+ /***********************************************************************************************
782
+ * SECURITY
783
+ ***********************************************************************************************/
784
+ secureCompare(a, b) {
785
+ const aBuf = Buffer.from(a || '', 'utf8');
786
+ const bBuf = Buffer.from(b || '', 'utf8');
787
+ if (aBuf.length !== bBuf.length) return false;
788
+ return crypto.timingSafeEqual(aBuf, bBuf);
789
+ }
673
790
  }
674
791
 
675
792
 
793
+ class StreamInProgressError extends Error {}
794
+ class NoActiveStreamError extends Error {}
795
+
676
796
  const errorClasses = Object.values(Errors).filter(cls => cls == Errors.ServerError || cls.prototype instanceof Errors.AbstractServerError);
677
797
  VidaServerController.prototype.Errors = Object.fromEntries(errorClasses.map(cls => {
678
798
  return [cls.name.replace('Error', ''), cls]
679
799
  }));
680
800
 
681
801
 
682
- module.exports = {
802
+ module.exports = {
803
+ NoActiveStreamError,
804
+ StreamInProgressError,
683
805
  VidaServerController
684
806
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vida-global/core",
3
- "version": "1.4.5",
3
+ "version": "1.4.6",
4
4
  "description": "Core libraries for supporting Vida development",
5
5
  "author": "",
6
6
  "license": "ISC",
@@ -20,17 +20,17 @@
20
20
  "dependencies": {
21
21
  "commander": "^13.1.0",
22
22
  "express": "^4.21.2",
23
+ "express-request-id": "1.4.1",
24
+ "express-winston": "^4.0.0",
23
25
  "mustache-express": "^1.2.8",
24
26
  "pg": "^8.16.3",
25
- "pino": "^9.6.0",
26
- "pino-http": "^10.4.0",
27
- "pino-pretty": "^13.0.0",
28
27
  "redis": "^5.0.0",
29
28
  "response-time": "^2.3.3",
30
29
  "sequelize": "^6.37.7",
31
30
  "sequelize-cli": "^6.6.3",
32
31
  "socket.io": "^4.4.0",
33
- "@vida-global/release": "^1.0.0"
32
+ "@vida-global/release": "^1.0.0",
33
+ "winston": "^3.0.0"
34
34
  },
35
35
  "devDependencies": {
36
36
  "jest": "^29.0.0",