@avtechno/sfr 1.0.18 → 2.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 +890 -45
- package/dist/index.mjs +20 -2
- package/dist/logger.mjs +9 -37
- package/dist/mq.mjs +46 -0
- package/dist/observability/index.mjs +143 -0
- package/dist/observability/logger.mjs +128 -0
- package/dist/observability/metrics.mjs +177 -0
- package/dist/observability/middleware/mq.mjs +156 -0
- package/dist/observability/middleware/rest.mjs +120 -0
- package/dist/observability/middleware/ws.mjs +135 -0
- package/dist/observability/tracer.mjs +163 -0
- package/dist/sfr-pipeline.mjs +412 -12
- package/dist/templates.mjs +8 -1
- package/dist/types/index.d.mts +8 -1
- package/dist/types/logger.d.mts +9 -3
- package/dist/types/mq.d.mts +19 -0
- package/dist/types/observability/index.d.mts +45 -0
- package/dist/types/observability/logger.d.mts +54 -0
- package/dist/types/observability/metrics.d.mts +74 -0
- package/dist/types/observability/middleware/mq.d.mts +46 -0
- package/dist/types/observability/middleware/rest.d.mts +33 -0
- package/dist/types/observability/middleware/ws.d.mts +35 -0
- package/dist/types/observability/tracer.d.mts +90 -0
- package/dist/types/sfr-pipeline.d.mts +42 -1
- package/dist/types/templates.d.mts +1 -6
- package/package.json +29 -4
- package/src/index.mts +66 -3
- package/src/logger.mts +16 -51
- package/src/mq.mts +49 -0
- package/src/observability/index.mts +184 -0
- package/src/observability/logger.mts +169 -0
- package/src/observability/metrics.mts +266 -0
- package/src/observability/middleware/mq.mts +187 -0
- package/src/observability/middleware/rest.mts +143 -0
- package/src/observability/middleware/ws.mts +162 -0
- package/src/observability/tracer.mts +205 -0
- package/src/sfr-pipeline.mts +468 -18
- package/src/templates.mts +14 -5
- package/src/types/index.d.ts +240 -16
- package/dist/example.mjs +0 -33
- package/dist/types/example.d.mts +0 -11
- package/src/example.mts +0 -35
package/dist/sfr-pipeline.mjs
CHANGED
|
@@ -3,7 +3,13 @@ import fs from "fs/promises";
|
|
|
3
3
|
import { template } from "./util.mjs";
|
|
4
4
|
import { BroadcastMQ, TargetedMQ } from "./mq.mjs";
|
|
5
5
|
import j2s from "joi-to-swagger";
|
|
6
|
-
import
|
|
6
|
+
import rateLimit from "express-rate-limit";
|
|
7
|
+
// Observability imports
|
|
8
|
+
import { init_observability, is_observability_enabled } from "./observability/index.mjs";
|
|
9
|
+
import { sfr_logger as logger } from "./observability/logger.mjs";
|
|
10
|
+
import { sfr_rest_telemetry } from "./observability/middleware/rest.mjs";
|
|
11
|
+
import { instrument_socket_io, wrap_ws_handler } from "./observability/middleware/ws.mjs";
|
|
12
|
+
import { wrap_mq_handler, record_mq_reject } from "./observability/middleware/mq.mjs";
|
|
7
13
|
const CWD = process.cwd();
|
|
8
14
|
const PATTERNS = ["Point-to-Point", "Request-Reply", "Fanout", "Direct", "Topic"];
|
|
9
15
|
const TARGETED_PATTERN = ["Point-to-Point", "Request-Reply"];
|
|
@@ -25,11 +31,44 @@ export class SFRPipeline {
|
|
|
25
31
|
handlers: {},
|
|
26
32
|
controllers: {}
|
|
27
33
|
},
|
|
34
|
+
ws: {
|
|
35
|
+
handlers: {},
|
|
36
|
+
controllers: {}
|
|
37
|
+
},
|
|
28
38
|
mq: {
|
|
29
39
|
handlers: {},
|
|
30
40
|
controllers: {}
|
|
31
41
|
},
|
|
32
42
|
};
|
|
43
|
+
/* Authentication middleware configuration */
|
|
44
|
+
static auth_config = {};
|
|
45
|
+
/* Rate limit configuration */
|
|
46
|
+
static rate_limit_config = {};
|
|
47
|
+
/* Observability configuration */
|
|
48
|
+
static observability_options = {};
|
|
49
|
+
/**
|
|
50
|
+
* Sets the authentication middleware for protected routes.
|
|
51
|
+
* @param middleware - The authentication middleware function
|
|
52
|
+
*/
|
|
53
|
+
static set_auth_middleware(middleware) {
|
|
54
|
+
SFRPipeline.auth_config.middleware = middleware;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Configures global rate limiting options for all routes.
|
|
58
|
+
* Can be overridden per-route using cfg.rate_limit in SFR files.
|
|
59
|
+
* @param config - Rate limit configuration options
|
|
60
|
+
*/
|
|
61
|
+
static set_rate_limit_config(config) {
|
|
62
|
+
SFRPipeline.rate_limit_config = config;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Configures observability options for tracing, metrics, and logging.
|
|
66
|
+
* Must be called before SFR initialization to take effect.
|
|
67
|
+
* @param options - Observability configuration options
|
|
68
|
+
*/
|
|
69
|
+
static set_observability_options(options) {
|
|
70
|
+
SFRPipeline.observability_options = options;
|
|
71
|
+
}
|
|
33
72
|
constructor(cfg, oas_cfg, comms) {
|
|
34
73
|
this.cfg = cfg;
|
|
35
74
|
this.oas_cfg = oas_cfg;
|
|
@@ -37,6 +76,18 @@ export class SFRPipeline {
|
|
|
37
76
|
}
|
|
38
77
|
async init(base_url) {
|
|
39
78
|
this.base_url = base_url;
|
|
79
|
+
// Initialize observability with service config from oas_cfg
|
|
80
|
+
if (SFRPipeline.observability_options.enabled !== false) {
|
|
81
|
+
init_observability(this.oas_cfg, SFRPipeline.observability_options);
|
|
82
|
+
}
|
|
83
|
+
// Add REST telemetry middleware if enabled
|
|
84
|
+
if (this.comms["REST"] && is_observability_enabled()) {
|
|
85
|
+
this.comms["REST"].use(sfr_rest_telemetry());
|
|
86
|
+
}
|
|
87
|
+
// Instrument Socket.IO if enabled
|
|
88
|
+
if (this.comms["WS"] && is_observability_enabled()) {
|
|
89
|
+
instrument_socket_io(this.comms["WS"]);
|
|
90
|
+
}
|
|
40
91
|
if (this.comms["MQ"]) {
|
|
41
92
|
//Create channels for each type of Communication Pattern
|
|
42
93
|
const channels = await Promise.all(PATTERNS.map(async (v) => {
|
|
@@ -52,6 +103,17 @@ export class SFRPipeline {
|
|
|
52
103
|
...SFRPipeline.injections.mq.handlers,
|
|
53
104
|
mq: this.pattern_channels
|
|
54
105
|
};
|
|
106
|
+
SFRPipeline.injections.ws.handlers = {
|
|
107
|
+
...SFRPipeline.injections.ws.handlers,
|
|
108
|
+
mq: this.pattern_channels
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// Inject Socket.IO server into WS handlers
|
|
112
|
+
if (this.comms["WS"]) {
|
|
113
|
+
SFRPipeline.injections.ws.handlers = {
|
|
114
|
+
...SFRPipeline.injections.ws.handlers,
|
|
115
|
+
io: this.comms["WS"]
|
|
116
|
+
};
|
|
55
117
|
}
|
|
56
118
|
//Declarations for each protocol in key/value pairs.
|
|
57
119
|
let protocols = await this.file_parsing();
|
|
@@ -110,6 +172,82 @@ export class SFRPipeline {
|
|
|
110
172
|
}
|
|
111
173
|
});
|
|
112
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Generates OpenAPI multipart/form-data schema for Multer validators.
|
|
177
|
+
* Since Multer middleware functions can't be easily inspected at runtime,
|
|
178
|
+
* this generates a generic file upload schema.
|
|
179
|
+
*/
|
|
180
|
+
generate_multer_openapi_schema(validator) {
|
|
181
|
+
// Multer middleware is a function, so we generate a generic multipart schema
|
|
182
|
+
// In the future, this could be enhanced to parse Multer configuration
|
|
183
|
+
return {
|
|
184
|
+
content: {
|
|
185
|
+
"multipart/form-data": {
|
|
186
|
+
schema: {
|
|
187
|
+
type: "object",
|
|
188
|
+
properties: {
|
|
189
|
+
file: {
|
|
190
|
+
type: "string",
|
|
191
|
+
format: "binary",
|
|
192
|
+
description: "File to upload"
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
encoding: {
|
|
197
|
+
file: {
|
|
198
|
+
contentType: "*/*"
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Gets rate limit configuration for a route, merging global defaults with route-specific config.
|
|
207
|
+
*/
|
|
208
|
+
get_rate_limit_config_for_route(cfg) {
|
|
209
|
+
if (cfg?.rate_limit === false) {
|
|
210
|
+
return null; // Explicitly disabled
|
|
211
|
+
}
|
|
212
|
+
const global_default = SFRPipeline.rate_limit_config.default;
|
|
213
|
+
const route_config = cfg?.rate_limit;
|
|
214
|
+
if (!global_default && !route_config) {
|
|
215
|
+
return null; // No rate limiting configured
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
max: 100,
|
|
219
|
+
windowMs: 60000,
|
|
220
|
+
...global_default,
|
|
221
|
+
...route_config
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Creates a rate limiter middleware based on configuration.
|
|
226
|
+
* Merges route-specific config with global defaults.
|
|
227
|
+
*/
|
|
228
|
+
create_rate_limiter(route_config) {
|
|
229
|
+
// If explicitly disabled, return null
|
|
230
|
+
if (route_config === false) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
// Merge route config with global default
|
|
234
|
+
const global_default = SFRPipeline.rate_limit_config.default;
|
|
235
|
+
const config = {
|
|
236
|
+
max: 100,
|
|
237
|
+
windowMs: 60000,
|
|
238
|
+
message: "Too many requests, please try again later.",
|
|
239
|
+
statusCode: 429,
|
|
240
|
+
standardHeaders: true,
|
|
241
|
+
legacyHeaders: false,
|
|
242
|
+
...global_default,
|
|
243
|
+
...route_config
|
|
244
|
+
};
|
|
245
|
+
// Only create rate limiter if max is greater than 0
|
|
246
|
+
if (config.max && config.max > 0) {
|
|
247
|
+
return rateLimit(config);
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
113
251
|
/* Fn Binding */
|
|
114
252
|
async bind_rest_fns(declaration) {
|
|
115
253
|
const comms = this.comms["REST"];
|
|
@@ -137,14 +275,26 @@ export class SFRPipeline {
|
|
|
137
275
|
const dir = `${base_dir}/${namespace}/${name}`;
|
|
138
276
|
const validator = validators[name];
|
|
139
277
|
const validator_type = typeof validator !== "function" ? "joi" : "multer";
|
|
278
|
+
// Apply rate limiting middleware if configured
|
|
279
|
+
const rate_limiter = this.create_rate_limiter(cfg.rate_limit);
|
|
280
|
+
if (rate_limiter) {
|
|
281
|
+
comms[method.toLowerCase()](dir, rate_limiter);
|
|
282
|
+
}
|
|
140
283
|
/* bind validators */
|
|
141
284
|
switch (validator_type) {
|
|
142
285
|
case "joi":
|
|
143
286
|
{
|
|
144
|
-
comms[method.toLowerCase()](dir, (req, res, next) => {
|
|
287
|
+
comms[method.toLowerCase()](dir, async (req, res, next) => {
|
|
145
288
|
let error = true;
|
|
146
|
-
|
|
147
|
-
|
|
289
|
+
// Authentication check for protected routes
|
|
290
|
+
if (!is_public && SFRPipeline.auth_config.middleware) {
|
|
291
|
+
const auth_result = await SFRPipeline.auth_config.middleware(req, res);
|
|
292
|
+
if (auth_result === false) {
|
|
293
|
+
return res.status(401).json({ error: "Unauthorized" });
|
|
294
|
+
}
|
|
295
|
+
if (typeof auth_result === "object" && auth_result.error) {
|
|
296
|
+
return res.status(auth_result.status || 401).json({ error: auth_result.error });
|
|
297
|
+
}
|
|
148
298
|
}
|
|
149
299
|
const validator_keys = Object.keys(validator);
|
|
150
300
|
if (validator_keys.length) {
|
|
@@ -177,6 +327,96 @@ export class SFRPipeline {
|
|
|
177
327
|
});
|
|
178
328
|
}
|
|
179
329
|
async bind_ws_fns(declaration) {
|
|
330
|
+
const io = this.comms["WS"];
|
|
331
|
+
if (!io)
|
|
332
|
+
throw new Error(`FN Binding failed: \nWS protocol lacks its associated SFRProtocols`);
|
|
333
|
+
Object.entries(declaration).forEach(([namespace, module]) => {
|
|
334
|
+
const { cfg, validators, handlers } = module.content;
|
|
335
|
+
// Determine the Socket.IO namespace to use
|
|
336
|
+
// Priority: cfg.namespace -> cfg.base_dir -> "/"
|
|
337
|
+
let ws_namespace = "/";
|
|
338
|
+
if (cfg.namespace)
|
|
339
|
+
ws_namespace = cfg.namespace.startsWith("/") ? cfg.namespace : `/${cfg.namespace}`;
|
|
340
|
+
else if (cfg.base_dir)
|
|
341
|
+
ws_namespace = cfg.base_dir.startsWith("/") ? cfg.base_dir : `/${cfg.base_dir}`;
|
|
342
|
+
logger.info(`[SFR WS ${module.name}] namespace: ${ws_namespace}`);
|
|
343
|
+
// Get or create the namespace
|
|
344
|
+
const nsp = io.of(ws_namespace);
|
|
345
|
+
// Handle connections to this namespace
|
|
346
|
+
nsp.on("connection", (socket) => {
|
|
347
|
+
logger.info(`[SFR WS] Client connected to ${ws_namespace}: ${socket.id}`);
|
|
348
|
+
// Register event handlers for this connection
|
|
349
|
+
Object.entries(handlers).forEach(([event, handler]) => {
|
|
350
|
+
// Skip if no matching validator
|
|
351
|
+
const validator = validators[event];
|
|
352
|
+
if (!validator)
|
|
353
|
+
return;
|
|
354
|
+
socket.on(event, async (data, ack) => {
|
|
355
|
+
// Validate incoming data if validator is a Joi schema (not Multer)
|
|
356
|
+
const validator_type = typeof validator !== "function" ? "joi" : "custom";
|
|
357
|
+
if (validator_type === "joi") {
|
|
358
|
+
const validator_keys = Object.keys(validator);
|
|
359
|
+
if (validator_keys.length) {
|
|
360
|
+
const error = template(validator, data || {});
|
|
361
|
+
if (error) {
|
|
362
|
+
// Send validation error back via acknowledgement or emit
|
|
363
|
+
if (ack) {
|
|
364
|
+
ack({ error });
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
socket.emit(`${event}:error`, { error });
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// Build context for handler
|
|
374
|
+
const ctx = {
|
|
375
|
+
io,
|
|
376
|
+
socket,
|
|
377
|
+
data: data || {},
|
|
378
|
+
ack
|
|
379
|
+
};
|
|
380
|
+
// Create the handler execution function
|
|
381
|
+
const execute_handler = async () => {
|
|
382
|
+
await handler.fn.call({
|
|
383
|
+
...module.content.controllers,
|
|
384
|
+
...SFRPipeline.injections.ws.handlers,
|
|
385
|
+
io,
|
|
386
|
+
socket
|
|
387
|
+
}, ctx);
|
|
388
|
+
};
|
|
389
|
+
try {
|
|
390
|
+
// Wrap with telemetry if observability is enabled
|
|
391
|
+
if (is_observability_enabled()) {
|
|
392
|
+
const instrumented = wrap_ws_handler(ws_namespace, event, execute_handler);
|
|
393
|
+
await instrumented();
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
await execute_handler();
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
logger.error(`[SFR WS] Error in handler ${event}`, {
|
|
401
|
+
error: err instanceof Error ? err.message : String(err),
|
|
402
|
+
event,
|
|
403
|
+
namespace: ws_namespace
|
|
404
|
+
});
|
|
405
|
+
if (ack) {
|
|
406
|
+
ack({ error: err instanceof Error ? err.message : "Unknown error" });
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
socket.emit(`${event}:error`, { error: err instanceof Error ? err.message : "Unknown error" });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
this.mount_data.ws++;
|
|
414
|
+
});
|
|
415
|
+
socket.on("disconnect", (reason) => {
|
|
416
|
+
logger.info(`[SFR WS] Client disconnected from ${ws_namespace}: ${socket.id} (${reason})`);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
});
|
|
180
420
|
}
|
|
181
421
|
async bind_mq_fns(declaration) {
|
|
182
422
|
const comms = this.comms["MQ"];
|
|
@@ -202,11 +442,16 @@ export class SFRPipeline {
|
|
|
202
442
|
const dir = `${base_dir}${namespace}/${name}`;
|
|
203
443
|
const validator = validators[name];
|
|
204
444
|
//const validator_type = typeof validator !== "function" ? "joi" : "multer"; // TODO: Dynamically update parameter content based on validator type.
|
|
205
|
-
|
|
445
|
+
// Create the base handler with validation
|
|
446
|
+
const base_handler = function (msg) {
|
|
206
447
|
const validator_keys = Object.keys(validator);
|
|
207
448
|
if (validator_keys.length) {
|
|
208
449
|
const error = template(validator, msg.content);
|
|
209
450
|
if (error) {
|
|
451
|
+
// Record rejection metrics
|
|
452
|
+
if (is_observability_enabled()) {
|
|
453
|
+
record_mq_reject(dir, pattern, false);
|
|
454
|
+
}
|
|
210
455
|
if (mq instanceof TargetedMQ) {
|
|
211
456
|
if (error) {
|
|
212
457
|
switch (pattern) {
|
|
@@ -224,6 +469,10 @@ export class SFRPipeline {
|
|
|
224
469
|
}
|
|
225
470
|
handler.fn(msg);
|
|
226
471
|
};
|
|
472
|
+
// Wrap with telemetry if observability is enabled
|
|
473
|
+
const validator_fn = is_observability_enabled()
|
|
474
|
+
? wrap_mq_handler(dir, pattern, base_handler)
|
|
475
|
+
: base_handler;
|
|
227
476
|
/* bind validators */
|
|
228
477
|
if (mq instanceof TargetedMQ) {
|
|
229
478
|
mq.consume(dir, {
|
|
@@ -248,8 +497,8 @@ export class SFRPipeline {
|
|
|
248
497
|
const documents = Object.entries(protocols).map(([protocol, declaration]) => {
|
|
249
498
|
switch (protocol) {
|
|
250
499
|
case "REST": return [protocol, this.generate_open_api_document(declaration)];
|
|
251
|
-
case "WS": return [protocol, this.
|
|
252
|
-
case "MQ": return [protocol, this.
|
|
500
|
+
case "WS": return [protocol, this.generate_ws_async_api_document(declaration)];
|
|
501
|
+
case "MQ": return [protocol, this.generate_mq_async_api_document(declaration)];
|
|
253
502
|
default: throw new Error(`Failed to generate SFR Spec: ${protocol} protocol is unknown.`);
|
|
254
503
|
}
|
|
255
504
|
});
|
|
@@ -290,29 +539,97 @@ export class SFRPipeline {
|
|
|
290
539
|
if (body.tags && Array.isArray(body.tags))
|
|
291
540
|
default_tags.push(...body.tags);
|
|
292
541
|
const operation_id = `${base_dir}${namespace}/${endpoint}`;
|
|
542
|
+
const is_public = Boolean(cfg.public);
|
|
543
|
+
const rate_limit_config = this.get_rate_limit_config_for_route(cfg);
|
|
293
544
|
const document = {
|
|
294
545
|
operationId: `${method.toUpperCase()}:${operation_id}`,
|
|
295
546
|
summary: body.summary || "",
|
|
296
547
|
description: body.description || "",
|
|
297
548
|
tags: default_tags,
|
|
298
|
-
public:
|
|
549
|
+
public: is_public,
|
|
299
550
|
responses: {
|
|
300
551
|
"200": {
|
|
301
552
|
description: "Successful operation",
|
|
302
553
|
content: { "application/json": { schema: { type: "object" } } }
|
|
303
554
|
},
|
|
304
555
|
"400": {
|
|
305
|
-
description: "
|
|
306
|
-
content: { "application/json": { schema: { type: "object" } } }
|
|
556
|
+
description: "Bad request - validation error",
|
|
557
|
+
content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } }
|
|
307
558
|
}
|
|
308
559
|
}
|
|
309
560
|
};
|
|
561
|
+
// Add authentication-related responses for protected routes
|
|
562
|
+
if (!is_public) {
|
|
563
|
+
document.responses["401"] = {
|
|
564
|
+
description: "Unauthorized - authentication required",
|
|
565
|
+
content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } }
|
|
566
|
+
};
|
|
567
|
+
document.responses["403"] = {
|
|
568
|
+
description: "Forbidden - insufficient permissions",
|
|
569
|
+
content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" } } } } }
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
// Add rate limiting response and headers if rate limiting is configured
|
|
573
|
+
if (rate_limit_config && rate_limit_config.max && rate_limit_config.max > 0) {
|
|
574
|
+
document.responses["429"] = {
|
|
575
|
+
description: "Too many requests - rate limit exceeded",
|
|
576
|
+
content: {
|
|
577
|
+
"application/json": {
|
|
578
|
+
schema: {
|
|
579
|
+
type: "object",
|
|
580
|
+
properties: {
|
|
581
|
+
error: { type: "string" },
|
|
582
|
+
message: { type: "string" }
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
},
|
|
587
|
+
headers: {}
|
|
588
|
+
};
|
|
589
|
+
// Add rate limit headers if standardHeaders is enabled
|
|
590
|
+
if (rate_limit_config.standardHeaders !== false) {
|
|
591
|
+
document.responses["429"].headers["RateLimit-Limit"] = {
|
|
592
|
+
description: "The maximum number of requests allowed in the current window",
|
|
593
|
+
schema: { type: "integer", example: rate_limit_config.max }
|
|
594
|
+
};
|
|
595
|
+
document.responses["429"].headers["RateLimit-Remaining"] = {
|
|
596
|
+
description: "The number of requests remaining in the current window",
|
|
597
|
+
schema: { type: "integer" }
|
|
598
|
+
};
|
|
599
|
+
document.responses["429"].headers["RateLimit-Reset"] = {
|
|
600
|
+
description: "The time at which the current rate limit window resets (Unix timestamp)",
|
|
601
|
+
schema: { type: "integer" }
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
// Add legacy headers if enabled
|
|
605
|
+
if (rate_limit_config.legacyHeaders) {
|
|
606
|
+
document.responses["429"].headers["X-RateLimit-Limit"] = {
|
|
607
|
+
description: "The maximum number of requests allowed in the current window (legacy)",
|
|
608
|
+
schema: { type: "integer", example: rate_limit_config.max }
|
|
609
|
+
};
|
|
610
|
+
document.responses["429"].headers["X-RateLimit-Remaining"] = {
|
|
611
|
+
description: "The number of requests remaining in the current window (legacy)",
|
|
612
|
+
schema: { type: "integer" }
|
|
613
|
+
};
|
|
614
|
+
document.responses["429"].headers["X-RateLimit-Reset"] = {
|
|
615
|
+
description: "The time at which the current rate limit window resets (legacy)",
|
|
616
|
+
schema: { type: "integer" }
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
// Add rate limit metadata to operation description
|
|
620
|
+
if (rate_limit_config.windowMs) {
|
|
621
|
+
const window_seconds = Math.floor(rate_limit_config.windowMs / 1000);
|
|
622
|
+
const rate_limit_note = `\n\n**Rate Limit:** ${rate_limit_config.max} requests per ${window_seconds} second${window_seconds !== 1 ? 's' : ''}`;
|
|
623
|
+
document.description = (document.description || "") + rate_limit_note;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
310
626
|
//Insert either a parameter or a requestBody according to the method type
|
|
311
627
|
if (validator_type === "joi") {
|
|
312
628
|
document[method === "GET" ? "parameters" : "requestBody"] = j2s(validator).swagger;
|
|
313
629
|
}
|
|
314
|
-
//
|
|
630
|
+
// Convert Multer validators to multipart/form-data OpenAPI schema
|
|
315
631
|
if (validator_type === "multer") {
|
|
632
|
+
document.requestBody = this.generate_multer_openapi_schema(validator);
|
|
316
633
|
}
|
|
317
634
|
//Create path if it does not exist.
|
|
318
635
|
if (!spec.paths[operation_id])
|
|
@@ -327,8 +644,91 @@ export class SFRPipeline {
|
|
|
327
644
|
spec.info.meta = Object.fromEntries(Object.entries(spec.info.meta).map(([k, v]) => [`x-${k}`, v]));
|
|
328
645
|
return spec;
|
|
329
646
|
}
|
|
647
|
+
// Method to generate an AsyncAPI document from a WSNamespaceDeclaration
|
|
648
|
+
generate_ws_async_api_document(declaration) {
|
|
649
|
+
const spec = {
|
|
650
|
+
asyncapi: '3.0.0',
|
|
651
|
+
info: Object.assign({}, this.oas_cfg),
|
|
652
|
+
servers: {
|
|
653
|
+
websocket: {
|
|
654
|
+
host: `localhost:${this.oas_cfg.meta?.port || 3000}`,
|
|
655
|
+
protocol: 'ws',
|
|
656
|
+
description: 'WebSocket server'
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
channels: {},
|
|
660
|
+
operations: {},
|
|
661
|
+
components: {
|
|
662
|
+
messages: {},
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
// Conform to AsyncAPI standards by appending "x-property" on each field under "meta"
|
|
666
|
+
if (spec.info.meta) {
|
|
667
|
+
spec.info.meta = Object.fromEntries(Object.entries(spec.info.meta).map(([k, v]) => [`x-${k}`, v]));
|
|
668
|
+
}
|
|
669
|
+
Object.entries(declaration).forEach(([namespace, module]) => {
|
|
670
|
+
const { cfg, validators, handlers } = module.content;
|
|
671
|
+
// Determine namespace path
|
|
672
|
+
let ws_namespace = "/";
|
|
673
|
+
if (cfg.namespace)
|
|
674
|
+
ws_namespace = cfg.namespace.startsWith("/") ? cfg.namespace : `/${cfg.namespace}`;
|
|
675
|
+
else if (cfg.base_dir)
|
|
676
|
+
ws_namespace = cfg.base_dir.startsWith("/") ? cfg.base_dir : `/${cfg.base_dir}`;
|
|
677
|
+
// Loop over all event handlers
|
|
678
|
+
Object.entries(handlers).forEach(([event, handler]) => {
|
|
679
|
+
const validator = validators[event];
|
|
680
|
+
if (!validator)
|
|
681
|
+
return;
|
|
682
|
+
const validator_type = typeof validator !== "function" ? "joi" : "custom";
|
|
683
|
+
const channel_id = `${ws_namespace === "/" ? "" : ws_namespace}/${event}`.replace(/^\/+/, "").replace(/\//g, "-") || event;
|
|
684
|
+
// Default tags for service classification
|
|
685
|
+
const default_tags = [
|
|
686
|
+
{ name: `sfr-namespace:${namespace}` },
|
|
687
|
+
{ name: `sfr-service:${this.oas_cfg.title || "unspecified"}` }
|
|
688
|
+
];
|
|
689
|
+
if (handler.tags && Array.isArray(handler.tags)) {
|
|
690
|
+
default_tags.push(...handler.tags.map(t => ({ name: t })));
|
|
691
|
+
}
|
|
692
|
+
const channel_document = {
|
|
693
|
+
address: ws_namespace === "/" ? `/${event}` : `${ws_namespace}/${event}`,
|
|
694
|
+
messages: {}
|
|
695
|
+
};
|
|
696
|
+
const operation_document = {
|
|
697
|
+
action: "receive",
|
|
698
|
+
summary: handler.summary || `Handle ${event} event`,
|
|
699
|
+
description: handler.description || "",
|
|
700
|
+
channel: { $ref: `#/channels/${channel_id}` },
|
|
701
|
+
tags: default_tags
|
|
702
|
+
};
|
|
703
|
+
// Generate message schema from Joi validator
|
|
704
|
+
if (validator_type === "joi") {
|
|
705
|
+
const message_id = `${channel_id}-message`;
|
|
706
|
+
channel_document.messages[message_id] = {
|
|
707
|
+
$ref: `#/components/messages/${message_id}`
|
|
708
|
+
};
|
|
709
|
+
spec.components.messages[message_id] = {
|
|
710
|
+
name: message_id,
|
|
711
|
+
contentType: "application/json",
|
|
712
|
+
payload: j2s(validator).swagger
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
spec.channels[channel_id] = channel_document;
|
|
716
|
+
spec.operations[`${channel_id}-receive`] = operation_document;
|
|
717
|
+
// Add send operation for events that can emit responses
|
|
718
|
+
const send_operation = {
|
|
719
|
+
action: "send",
|
|
720
|
+
summary: `Send ${event} response`,
|
|
721
|
+
description: `Response for ${event} event`,
|
|
722
|
+
channel: { $ref: `#/channels/${channel_id}` },
|
|
723
|
+
tags: default_tags
|
|
724
|
+
};
|
|
725
|
+
spec.operations[`${channel_id}-send`] = send_operation;
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
return spec;
|
|
729
|
+
}
|
|
330
730
|
// Method to generate an AsyncAPI document from an MQNamespaceDeclaration
|
|
331
|
-
|
|
731
|
+
generate_mq_async_api_document(declaration) {
|
|
332
732
|
// This will hold the final AsyncAPI Document.
|
|
333
733
|
const spec = {
|
|
334
734
|
asyncapi: '3.0.0',
|
package/dist/templates.mjs
CHANGED
|
@@ -23,7 +23,14 @@ export function WS(struct) {
|
|
|
23
23
|
const controllers = struct.controllers || {};
|
|
24
24
|
const handlers = struct.handlers || {};
|
|
25
25
|
const cfg = struct.cfg || {};
|
|
26
|
-
//
|
|
26
|
+
// Bind controller injections to each controller
|
|
27
|
+
Object.entries(controllers).map(([k, v]) => controllers[k] = v.bind({ ...SFRPipeline.injections.ws.controllers }));
|
|
28
|
+
// Bind handler injections and SFR controllers into each handler
|
|
29
|
+
Object.entries(handlers).forEach(([event, handler]) => {
|
|
30
|
+
/* @ts-ignore */
|
|
31
|
+
handlers[event] = { ...handler, fn: handler.fn.bind({ ...controllers, ...SFRPipeline.injections.ws.handlers }) };
|
|
32
|
+
});
|
|
33
|
+
/* @ts-ignore */
|
|
27
34
|
return { validators, controllers, handlers, cfg };
|
|
28
35
|
}
|
|
29
36
|
export function MQ(struct) {
|
package/dist/types/index.d.mts
CHANGED
|
@@ -4,4 +4,11 @@ import { MQLib, BroadcastMQ, TargetedMQ } from "./mq.mjs";
|
|
|
4
4
|
declare const _default: (cfg: ParserCFG, oas_cfg: OASConfig, connectors: SFRProtocols, base_url?: string) => Promise<ServiceManifest>;
|
|
5
5
|
export default _default;
|
|
6
6
|
declare function inject(injections: InjectedFacilities): void;
|
|
7
|
-
|
|
7
|
+
declare const set_auth_middleware: any;
|
|
8
|
+
declare const set_rate_limit_config: any;
|
|
9
|
+
declare const set_observability_options: any;
|
|
10
|
+
export { init_observability, shutdown_observability, is_observability_enabled, type ObservabilityOptions } from "./observability/index.mjs";
|
|
11
|
+
export { get_tracer, with_span, with_span_sync, get_trace_context, inject_trace_context, extract_trace_context, add_span_attributes, add_span_event, set_span_error, SpanKind } from "./observability/tracer.mjs";
|
|
12
|
+
export { get_sfr_metrics, get_meter, record_rest_request, record_ws_event, record_mq_message, type SFRMetrics } from "./observability/metrics.mjs";
|
|
13
|
+
export { sfr_logger, create_child_logger, init_logger, type LoggerConfig } from "./observability/logger.mjs";
|
|
14
|
+
export { REST, WS, MQ, MQLib, BroadcastMQ, TargetedMQ, inject, set_auth_middleware, set_rate_limit_config, set_observability_options };
|
package/dist/types/logger.d.mts
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* SFR Logger Module (Backward Compatibility)
|
|
3
|
+
*
|
|
4
|
+
* This module re-exports the new observability logger for backward compatibility.
|
|
5
|
+
* New code should import from "./observability/logger.mjs" directly.
|
|
6
|
+
*
|
|
7
|
+
* @deprecated Use imports from "./observability/logger.mjs" instead
|
|
8
|
+
*/
|
|
9
|
+
export { sfr_logger as logger, sfr_logger, create_child_logger, init_logger, get_logger, type LoggerConfig } from "./observability/logger.mjs";
|
package/dist/types/mq.d.mts
CHANGED
|
@@ -5,6 +5,7 @@ import { ChannelModel, Options, Channel, ConsumeMessage } from "amqplib";
|
|
|
5
5
|
export declare class MQLib {
|
|
6
6
|
private connection?;
|
|
7
7
|
private channel?;
|
|
8
|
+
private is_closing;
|
|
8
9
|
/**
|
|
9
10
|
* Initializes the connection and channel to the message queue.
|
|
10
11
|
*
|
|
@@ -26,6 +27,24 @@ export declare class MQLib {
|
|
|
26
27
|
* @returns The channel object to the message queue.
|
|
27
28
|
*/
|
|
28
29
|
get_channel(): Channel;
|
|
30
|
+
/**
|
|
31
|
+
* Gracefully disconnects from the message queue.
|
|
32
|
+
* Closes the channel first, then the connection.
|
|
33
|
+
*
|
|
34
|
+
* @param timeout - Optional timeout in ms to wait for in-flight messages (default: 5000)
|
|
35
|
+
* @example
|
|
36
|
+
* const mqLib = new MQLib();
|
|
37
|
+
* await mqLib.init("amqp://localhost");
|
|
38
|
+
* // ... do work ...
|
|
39
|
+
* await mqLib.disconnect();
|
|
40
|
+
*/
|
|
41
|
+
disconnect(timeout?: number): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* Checks if the connection is currently active.
|
|
44
|
+
*
|
|
45
|
+
* @returns true if connected, false otherwise
|
|
46
|
+
*/
|
|
47
|
+
is_connected(): boolean;
|
|
29
48
|
}
|
|
30
49
|
export declare class BaseMQ {
|
|
31
50
|
channel: Channel;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SFR Observability Module
|
|
3
|
+
*
|
|
4
|
+
* Provides OpenTelemetry-compatible tracing, metrics, and logging.
|
|
5
|
+
* Configuration is automatically extracted from SFR's OASConfig.
|
|
6
|
+
*/
|
|
7
|
+
export interface ObservabilityOptions {
|
|
8
|
+
/** Enable/disable observability (default: true) */
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/** OTLP endpoint URL (default: http://localhost:4318) */
|
|
11
|
+
otlp_endpoint?: string;
|
|
12
|
+
/** Enable auto-instrumentation for common libraries (default: true) */
|
|
13
|
+
auto_instrumentation?: boolean;
|
|
14
|
+
/** Sampling ratio 0.0 - 1.0 (default: 1.0) */
|
|
15
|
+
sampling_ratio?: number;
|
|
16
|
+
/** Log format: 'json' for production, 'pretty' for development */
|
|
17
|
+
log_format?: "json" | "pretty";
|
|
18
|
+
/** Additional resource attributes */
|
|
19
|
+
resource_attributes?: Record<string, string>;
|
|
20
|
+
/** Enable debug logging for OTel SDK */
|
|
21
|
+
debug?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Initializes the OpenTelemetry SDK with configuration derived from OASConfig.
|
|
25
|
+
* Must be called before SFR initialization for full instrumentation.
|
|
26
|
+
*
|
|
27
|
+
* @param oas_cfg - The OASConfig that will be passed to SFR
|
|
28
|
+
* @param options - Additional observability options
|
|
29
|
+
*/
|
|
30
|
+
export declare function init_observability(oas_cfg: OASConfig, options?: ObservabilityOptions): void;
|
|
31
|
+
/**
|
|
32
|
+
* Gracefully shuts down the OpenTelemetry SDK.
|
|
33
|
+
* Flushes all pending telemetry data before closing.
|
|
34
|
+
*/
|
|
35
|
+
export declare function shutdown_observability(): Promise<void>;
|
|
36
|
+
/**
|
|
37
|
+
* Checks if observability has been initialized.
|
|
38
|
+
*/
|
|
39
|
+
export declare function is_observability_enabled(): boolean;
|
|
40
|
+
export { get_tracer, with_span, get_trace_context, inject_trace_context } from "./tracer.mjs";
|
|
41
|
+
export { get_sfr_metrics, init_metrics, type SFRMetrics } from "./metrics.mjs";
|
|
42
|
+
export { sfr_logger, create_child_logger, init_logger } from "./logger.mjs";
|
|
43
|
+
export { sfr_rest_telemetry } from "./middleware/rest.mjs";
|
|
44
|
+
export { instrument_socket_io } from "./middleware/ws.mjs";
|
|
45
|
+
export { wrap_mq_handler, inject_mq_trace_context } from "./middleware/mq.mjs";
|