@zentjs/zentjs 0.0.2
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/LICENSE +28 -0
- package/README.md +1584 -0
- package/package.json +85 -0
- package/src/core/application.mjs +1039 -0
- package/src/core/context.mjs +33 -0
- package/src/errors/error-handler.mjs +88 -0
- package/src/errors/http-error.mjs +86 -0
- package/src/hooks/lifecycle.mjs +175 -0
- package/src/http/request.mjs +166 -0
- package/src/http/response.mjs +151 -0
- package/src/index.mjs +33 -0
- package/src/middleware/pipeline.mjs +77 -0
- package/src/plugins/body-parser.mjs +165 -0
- package/src/plugins/cors.mjs +183 -0
- package/src/plugins/manager.mjs +112 -0
- package/src/plugins/request-metrics.mjs +74 -0
- package/src/router/index.mjs +198 -0
- package/src/router/node.mjs +72 -0
- package/src/router/radix-tree.mjs +313 -0
- package/src/utils/http-status.mjs +31 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZentJS — Public API exports.
|
|
3
|
+
* @module zentjs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { Zent, zent } from './core/application.mjs';
|
|
7
|
+
export { Context } from './core/context.mjs';
|
|
8
|
+
export { ErrorHandler } from './errors/error-handler.mjs';
|
|
9
|
+
export {
|
|
10
|
+
BadRequestError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
ForbiddenError,
|
|
13
|
+
HttpError,
|
|
14
|
+
InternalServerError,
|
|
15
|
+
MethodNotAllowedError,
|
|
16
|
+
NotFoundError,
|
|
17
|
+
TooManyRequestsError,
|
|
18
|
+
UnauthorizedError,
|
|
19
|
+
UnprocessableEntityError,
|
|
20
|
+
} from './errors/http-error.mjs';
|
|
21
|
+
export { HOOK_PHASES, Lifecycle } from './hooks/lifecycle.mjs';
|
|
22
|
+
export { ZentRequest } from './http/request.mjs';
|
|
23
|
+
export { ZentResponse } from './http/response.mjs';
|
|
24
|
+
export { compose } from './middleware/pipeline.mjs';
|
|
25
|
+
export { bodyParser } from './plugins/body-parser.mjs';
|
|
26
|
+
export { cors } from './plugins/cors.mjs';
|
|
27
|
+
export { PluginManager } from './plugins/manager.mjs';
|
|
28
|
+
export {
|
|
29
|
+
requestMetrics,
|
|
30
|
+
requestMetricsPlugin,
|
|
31
|
+
} from './plugins/request-metrics.mjs';
|
|
32
|
+
export { Router } from './router/index.mjs';
|
|
33
|
+
export { HttpStatus, HttpStatusText } from './utils/http-status.mjs';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pipeline — Compõe middlewares no padrão Onion (Koa-style).
|
|
3
|
+
*
|
|
4
|
+
* Cada middleware tem a assinatura: async (ctx, next) => {}
|
|
5
|
+
* - ctx: Contexto da requisição (req, res, app, state)
|
|
6
|
+
* - next: Função que delega execução para o próximo middleware
|
|
7
|
+
*
|
|
8
|
+
* O compose retorna uma função (ctx) => Promise<void> que executa
|
|
9
|
+
* toda a cadeia na ordem, permitindo lógica "before" e "after" via await next().
|
|
10
|
+
*
|
|
11
|
+
* @module middleware/pipeline
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Compõe um array de middlewares em uma única função executável.
|
|
16
|
+
*
|
|
17
|
+
* @param {Array<(ctx: object, next: Function) => Promise<void>>} middlewares
|
|
18
|
+
* @returns {(ctx: object, next?: Function) => Promise<void>}
|
|
19
|
+
* @throws {TypeError} Se middlewares não for um array
|
|
20
|
+
* @throws {TypeError} Se algum elemento não for uma função
|
|
21
|
+
*/
|
|
22
|
+
export function compose(middlewares) {
|
|
23
|
+
if (!Array.isArray(middlewares)) {
|
|
24
|
+
throw new TypeError('middlewares must be an array');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (let i = 0; i < middlewares.length; i++) {
|
|
28
|
+
if (typeof middlewares[i] !== 'function') {
|
|
29
|
+
throw new TypeError(
|
|
30
|
+
`Middleware at index ${i} must be a function, got ${typeof middlewares[i]}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {object} ctx - Contexto da requisição
|
|
37
|
+
* @param {Function} [finalHandler] - Função final opcional (ex: route handler)
|
|
38
|
+
* @returns {Promise<void>}
|
|
39
|
+
*/
|
|
40
|
+
return function pipeline(ctx, finalHandler) {
|
|
41
|
+
let index = -1;
|
|
42
|
+
|
|
43
|
+
return dispatch(0);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Executa o middleware no índice `i`.
|
|
47
|
+
* @param {number} i
|
|
48
|
+
* @returns {Promise<void>}
|
|
49
|
+
*/
|
|
50
|
+
function dispatch(i) {
|
|
51
|
+
// Proteção contra chamada duplicada de next()
|
|
52
|
+
if (i <= index) {
|
|
53
|
+
return Promise.reject(
|
|
54
|
+
new Error('next() called multiple times in the same middleware')
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
index = i;
|
|
59
|
+
|
|
60
|
+
// Seleciona o middleware atual ou o finalHandler (último da cadeia)
|
|
61
|
+
const fn = i < middlewares.length ? middlewares[i] : finalHandler;
|
|
62
|
+
|
|
63
|
+
// Nenhuma função restante — cadeia termina
|
|
64
|
+
if (!fn) {
|
|
65
|
+
return Promise.resolve();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
// Chama fn(ctx, next) onde next() avança para dispatch(i + 1)
|
|
70
|
+
return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)));
|
|
71
|
+
} catch (err) {
|
|
72
|
+
// Captura erros síncronos lançados pelo middleware
|
|
73
|
+
return Promise.reject(err);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bodyParser — Middleware built-in para parsing do body da requisição.
|
|
3
|
+
*
|
|
4
|
+
* Suporta:
|
|
5
|
+
* - application/json
|
|
6
|
+
* - text/plain
|
|
7
|
+
* - application/x-www-form-urlencoded
|
|
8
|
+
*
|
|
9
|
+
* Não parseia automaticamente requests sem body (GET, HEAD, DELETE, OPTIONS).
|
|
10
|
+
* O body é populado em ctx.req.body após parsing.
|
|
11
|
+
*
|
|
12
|
+
* Decisão (ADR-007): Lazy body parsing — exige middleware explícito.
|
|
13
|
+
*
|
|
14
|
+
* @module plugins/body-parser
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { BadRequestError } from '../errors/http-error.mjs';
|
|
18
|
+
|
|
19
|
+
/** Métodos HTTP que tipicamente não possuem body */
|
|
20
|
+
const NO_BODY_METHODS = new Set(['GET', 'HEAD', 'DELETE', 'OPTIONS']);
|
|
21
|
+
|
|
22
|
+
/** Limite padrão de tamanho do body: 1 MB */
|
|
23
|
+
const DEFAULT_LIMIT = 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {object} BodyParserOptions
|
|
27
|
+
* @property {number} [limit=1048576] - Limite máximo do body em bytes
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @callback MiddlewareNext
|
|
32
|
+
* @returns {void|Promise<void>}
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @callback BodyParserMiddleware
|
|
37
|
+
* @param {import('../core/context.mjs').Context} ctx
|
|
38
|
+
* @param {MiddlewareNext} next
|
|
39
|
+
* @returns {Promise<void>}
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Lê o body bruto da requisição como Buffer.
|
|
44
|
+
* Suporta tanto streams reais (IncomingMessage) quanto
|
|
45
|
+
* objetos mockados pelo inject() (que possuem rawReq.body).
|
|
46
|
+
*
|
|
47
|
+
* @param {object} raw - IncomingMessage ou mock
|
|
48
|
+
* @param {number} limit - Limite máximo em bytes
|
|
49
|
+
* @returns {Promise<Buffer>}
|
|
50
|
+
* @throws {Error} Quando body excede o limite configurado (statusCode 413)
|
|
51
|
+
*/
|
|
52
|
+
function readRawBody(raw, limit) {
|
|
53
|
+
// inject() mock — body já é string, não é stream
|
|
54
|
+
if (raw.body !== undefined && raw.body !== null) {
|
|
55
|
+
const buf = Buffer.from(raw.body);
|
|
56
|
+
|
|
57
|
+
if (buf.length > limit) {
|
|
58
|
+
const error = new Error(`Body exceeds size limit of ${limit} bytes`);
|
|
59
|
+
error.statusCode = 413;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Promise.resolve(buf);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Stream real (node:http IncomingMessage)
|
|
67
|
+
return new Promise((resolve, reject) => {
|
|
68
|
+
const chunks = [];
|
|
69
|
+
let size = 0;
|
|
70
|
+
|
|
71
|
+
raw.on('data', (chunk) => {
|
|
72
|
+
size += chunk.length;
|
|
73
|
+
|
|
74
|
+
if (size > limit) {
|
|
75
|
+
raw.destroy();
|
|
76
|
+
const error = new Error(`Body exceeds size limit of ${limit} bytes`);
|
|
77
|
+
error.statusCode = 413;
|
|
78
|
+
reject(error);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
chunks.push(chunk);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
raw.on('end', () => {
|
|
86
|
+
resolve(Buffer.concat(chunks));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
raw.on('error', (err) => {
|
|
90
|
+
reject(err);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Parseia o body de acordo com o Content-Type.
|
|
97
|
+
*
|
|
98
|
+
* @param {Buffer} buffer - Body bruto
|
|
99
|
+
* @param {string} contentType - Valor do header Content-Type
|
|
100
|
+
* @returns {*} Body parseado (objeto, string ou raw)
|
|
101
|
+
* @throws {SyntaxError} Quando JSON for inválido
|
|
102
|
+
*/
|
|
103
|
+
function parseBody(buffer, contentType) {
|
|
104
|
+
const type = (contentType || '').toLowerCase();
|
|
105
|
+
|
|
106
|
+
if (type.includes('application/json')) {
|
|
107
|
+
const text = buffer.toString('utf-8');
|
|
108
|
+
|
|
109
|
+
if (text.length === 0) return {};
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(text);
|
|
113
|
+
} catch {
|
|
114
|
+
throw new BadRequestError('Invalid JSON body');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (type.includes('application/x-www-form-urlencoded')) {
|
|
119
|
+
const text = buffer.toString('utf-8');
|
|
120
|
+
return Object.fromEntries(new URLSearchParams(text));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (type.includes('text/')) {
|
|
124
|
+
return buffer.toString('utf-8');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Tipo desconhecido — retorna buffer como string
|
|
128
|
+
return buffer.toString('utf-8');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Cria o middleware bodyParser.
|
|
133
|
+
*
|
|
134
|
+
* @param {BodyParserOptions} [opts={}]
|
|
135
|
+
* @returns {BodyParserMiddleware} Middleware (ctx, next) => Promise
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* import { bodyParser } from 'zentjs';
|
|
139
|
+
*
|
|
140
|
+
* app.use(bodyParser());
|
|
141
|
+
* app.use(bodyParser({ limit: 512 * 1024 })); // 512 KB
|
|
142
|
+
*/
|
|
143
|
+
export function bodyParser(opts = {}) {
|
|
144
|
+
const limit = opts.limit ?? DEFAULT_LIMIT;
|
|
145
|
+
|
|
146
|
+
return async function bodyParserMiddleware(ctx, next) {
|
|
147
|
+
// Pula métodos sem body
|
|
148
|
+
if (NO_BODY_METHODS.has(ctx.req.method)) {
|
|
149
|
+
return next();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const contentType = ctx.req.get('content-type') || '';
|
|
153
|
+
|
|
154
|
+
// Sem content-type — pula parsing
|
|
155
|
+
if (!contentType) {
|
|
156
|
+
return next();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const buffer = await readRawBody(ctx.req.raw, limit);
|
|
160
|
+
|
|
161
|
+
ctx.req.body = parseBody(buffer, contentType);
|
|
162
|
+
|
|
163
|
+
return next();
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cors — Middleware built-in para Cross-Origin Resource Sharing (CORS).
|
|
3
|
+
*
|
|
4
|
+
* Suporta:
|
|
5
|
+
* - Preflight requests (OPTIONS)
|
|
6
|
+
* - Origens configuráveis (string, array, function, '*')
|
|
7
|
+
* - Methods, headers, credentials, maxAge, exposedHeaders
|
|
8
|
+
*
|
|
9
|
+
* Sem dependências externas.
|
|
10
|
+
*
|
|
11
|
+
* @module plugins/cors
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Opções padrão do CORS.
|
|
16
|
+
* @type {object}
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULTS = {
|
|
19
|
+
origin: '*',
|
|
20
|
+
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
|
|
21
|
+
allowedHeaders: null,
|
|
22
|
+
exposedHeaders: null,
|
|
23
|
+
credentials: false,
|
|
24
|
+
maxAge: null,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @callback CorsOriginResolver
|
|
29
|
+
* @param {string} requestOrigin
|
|
30
|
+
* @returns {string|false|Promise<string|false>}
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {object} CorsOptions
|
|
35
|
+
* @property {string|string[]|CorsOriginResolver|boolean} [origin='*']
|
|
36
|
+
* @property {string|string[]} [methods='GET,HEAD,PUT,PATCH,POST,DELETE']
|
|
37
|
+
* @property {string|string[]|null} [allowedHeaders=null]
|
|
38
|
+
* @property {string|string[]|null} [exposedHeaders=null]
|
|
39
|
+
* @property {boolean} [credentials=false]
|
|
40
|
+
* @property {number|null} [maxAge=null]
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @callback MiddlewareNext
|
|
45
|
+
* @returns {void|Promise<void>}
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @callback CorsMiddleware
|
|
50
|
+
* @param {import('../core/context.mjs').Context} ctx
|
|
51
|
+
* @param {MiddlewareNext} next
|
|
52
|
+
* @returns {Promise<void>}
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolve o valor de origin a partir da configuração.
|
|
57
|
+
*
|
|
58
|
+
* @param {string|string[]|CorsOriginResolver|boolean} origin - Config de origin
|
|
59
|
+
* @param {string} requestOrigin - Origin do request (header)
|
|
60
|
+
* @returns {Promise<string|false>} Header Access-Control-Allow-Origin ou false
|
|
61
|
+
*/
|
|
62
|
+
async function resolveOrigin(origin, requestOrigin) {
|
|
63
|
+
if (origin === true || origin === '*') {
|
|
64
|
+
return '*';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (origin === false) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof origin === 'string') {
|
|
72
|
+
return origin;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(origin)) {
|
|
76
|
+
return origin.includes(requestOrigin) ? requestOrigin : false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (typeof origin === 'function') {
|
|
80
|
+
return origin(requestOrigin);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Configura os headers de CORS na resposta.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} ctx - Contexto da requisição
|
|
90
|
+
* @param {object} opts - Opções CORS resolvidas
|
|
91
|
+
* @param {string} allowOrigin - Valor do Access-Control-Allow-Origin
|
|
92
|
+
*/
|
|
93
|
+
function setCorsHeaders(ctx, opts, allowOrigin) {
|
|
94
|
+
ctx.res.header('Access-Control-Allow-Origin', allowOrigin);
|
|
95
|
+
|
|
96
|
+
if (opts.credentials) {
|
|
97
|
+
ctx.res.header('Access-Control-Allow-Credentials', 'true');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (opts.exposedHeaders) {
|
|
101
|
+
const value = Array.isArray(opts.exposedHeaders)
|
|
102
|
+
? opts.exposedHeaders.join(', ')
|
|
103
|
+
: opts.exposedHeaders;
|
|
104
|
+
ctx.res.header('Access-Control-Expose-Headers', value);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Configura headers adicionais para preflight (OPTIONS).
|
|
110
|
+
*
|
|
111
|
+
* @param {object} ctx - Contexto da requisição
|
|
112
|
+
* @param {object} opts - Opções CORS resolvidas
|
|
113
|
+
*/
|
|
114
|
+
function setPreflightHeaders(ctx, opts) {
|
|
115
|
+
// Methods
|
|
116
|
+
const methods = Array.isArray(opts.methods)
|
|
117
|
+
? opts.methods.join(', ')
|
|
118
|
+
: opts.methods;
|
|
119
|
+
ctx.res.header('Access-Control-Allow-Methods', methods);
|
|
120
|
+
|
|
121
|
+
// Allowed Headers
|
|
122
|
+
if (opts.allowedHeaders) {
|
|
123
|
+
const headers = Array.isArray(opts.allowedHeaders)
|
|
124
|
+
? opts.allowedHeaders.join(', ')
|
|
125
|
+
: opts.allowedHeaders;
|
|
126
|
+
ctx.res.header('Access-Control-Allow-Headers', headers);
|
|
127
|
+
} else {
|
|
128
|
+
// Reflect request headers
|
|
129
|
+
const requestHeaders = ctx.req.get('access-control-request-headers');
|
|
130
|
+
if (requestHeaders) {
|
|
131
|
+
ctx.res.header('Access-Control-Allow-Headers', requestHeaders);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Max Age
|
|
136
|
+
if (opts.maxAge !== null && opts.maxAge !== undefined) {
|
|
137
|
+
ctx.res.header('Access-Control-Max-Age', String(opts.maxAge));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Cria o middleware CORS.
|
|
143
|
+
*
|
|
144
|
+
* @param {CorsOptions} [opts={}]
|
|
145
|
+
* @returns {CorsMiddleware} Middleware (ctx, next) => Promise
|
|
146
|
+
*
|
|
147
|
+
* @example
|
|
148
|
+
* import { cors } from 'zentjs';
|
|
149
|
+
*
|
|
150
|
+
* app.use(cors());
|
|
151
|
+
* app.use(cors({ origin: 'https://example.com', credentials: true }));
|
|
152
|
+
* app.use(cors({ origin: ['https://a.com', 'https://b.com'] }));
|
|
153
|
+
*/
|
|
154
|
+
export function cors(opts = {}) {
|
|
155
|
+
const config = { ...DEFAULTS, ...opts };
|
|
156
|
+
|
|
157
|
+
return async function corsMiddleware(ctx, next) {
|
|
158
|
+
const requestOrigin = ctx.req.get('origin') || '';
|
|
159
|
+
|
|
160
|
+
const allowOrigin = await resolveOrigin(config.origin, requestOrigin);
|
|
161
|
+
|
|
162
|
+
// Origin não permitida — prossegue sem headers CORS
|
|
163
|
+
if (allowOrigin === false) {
|
|
164
|
+
return next();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Configura headers base de CORS
|
|
168
|
+
setCorsHeaders(ctx, config, allowOrigin);
|
|
169
|
+
|
|
170
|
+
// Vary header para caches
|
|
171
|
+
if (allowOrigin !== '*') {
|
|
172
|
+
ctx.res.header('Vary', 'Origin');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Preflight (OPTIONS)
|
|
176
|
+
if (ctx.req.method === 'OPTIONS') {
|
|
177
|
+
setPreflightHeaders(ctx, config);
|
|
178
|
+
return ctx.res.empty(204);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return next();
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PluginManager — Gerencia registro e carregamento de plugins.
|
|
3
|
+
*
|
|
4
|
+
* Cada plugin é uma função assíncrona que recebe uma instância
|
|
5
|
+
* encapsulada do app e opções. Plugins podem registrar rotas,
|
|
6
|
+
* hooks, middlewares e decorators sem vazar para o escopo pai.
|
|
7
|
+
*
|
|
8
|
+
* Inspirado no sistema de plugins do Fastify.
|
|
9
|
+
*
|
|
10
|
+
* @module plugins/manager
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {object} PluginEntry
|
|
15
|
+
* @property {PluginFunction} fn - Função do plugin
|
|
16
|
+
* @property {object} opts - Opções passadas ao plugin
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @callback PluginFunction
|
|
21
|
+
* @param {object} app - Escopo encapsulado com API do app
|
|
22
|
+
* @param {object} opts - Opções recebidas no register
|
|
23
|
+
* @returns {void|Promise<void>}
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @callback CreateScopeFunction
|
|
28
|
+
* @param {object} opts - Opções do plugin atual
|
|
29
|
+
* @returns {object} Escopo encapsulado para o plugin
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Gerenciador de plugins com suporte a encapsulamento de escopo.
|
|
34
|
+
* Responsabilidade única: registrar, ordenar e carregar plugins.
|
|
35
|
+
*/
|
|
36
|
+
export class PluginManager {
|
|
37
|
+
/** @type {PluginEntry[]} */
|
|
38
|
+
#queue;
|
|
39
|
+
|
|
40
|
+
/** @type {boolean} */
|
|
41
|
+
#loaded;
|
|
42
|
+
|
|
43
|
+
constructor() {
|
|
44
|
+
this.#queue = [];
|
|
45
|
+
this.#loaded = false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Indica se os plugins já foram carregados.
|
|
50
|
+
* @returns {boolean}
|
|
51
|
+
*/
|
|
52
|
+
get loaded() {
|
|
53
|
+
return this.#loaded;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Registra um plugin para ser carregado posteriormente.
|
|
58
|
+
*
|
|
59
|
+
* @param {PluginFunction} fn - async (app, opts) => {}
|
|
60
|
+
* @param {object} [opts={}] - Opções do plugin (prefix, etc.)
|
|
61
|
+
* @throws {TypeError} Se fn não for uma função
|
|
62
|
+
* @throws {Error} Se plugins já foram carregados
|
|
63
|
+
*/
|
|
64
|
+
register(fn, opts = {}) {
|
|
65
|
+
if (typeof fn !== 'function') {
|
|
66
|
+
throw new TypeError(`Plugin must be a function, got ${typeof fn}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (this.#loaded) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
'Cannot register plugins after they have been loaded. ' +
|
|
72
|
+
'Call register() before listen().'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this.#queue.push({ fn, opts });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Carrega todos os plugins registrados sequencialmente.
|
|
81
|
+
* Cada plugin recebe uma instância encapsulada via `createScope`.
|
|
82
|
+
*
|
|
83
|
+
* @param {CreateScopeFunction} createScope - (opts) => encapsulatedApp
|
|
84
|
+
* @throws {TypeError} Se createScope não for uma função
|
|
85
|
+
* @throws {Error} Se já foi carregado anteriormente
|
|
86
|
+
*/
|
|
87
|
+
async load(createScope) {
|
|
88
|
+
if (typeof createScope !== 'function') {
|
|
89
|
+
throw new TypeError('createScope must be a function');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (this.#loaded) {
|
|
93
|
+
throw new Error('Plugins have already been loaded');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
for (const entry of this.#queue) {
|
|
97
|
+
const scopedApp = createScope(entry.opts);
|
|
98
|
+
|
|
99
|
+
await entry.fn(scopedApp, entry.opts);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
this.#loaded = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Retorna o número de plugins registrados.
|
|
107
|
+
* @returns {number}
|
|
108
|
+
*/
|
|
109
|
+
get size() {
|
|
110
|
+
return this.#queue.length;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* requestMetrics — Plugin de observabilidade mínima baseado em hooks.
|
|
3
|
+
*
|
|
4
|
+
* Registra hooks de onRequest/onResponse para capturar:
|
|
5
|
+
* - method
|
|
6
|
+
* - path
|
|
7
|
+
* - statusCode
|
|
8
|
+
* - durationMs
|
|
9
|
+
*
|
|
10
|
+
* @module plugins/request-metrics
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {object} RequestMetricRecord
|
|
15
|
+
* @property {string} method
|
|
16
|
+
* @property {string} path
|
|
17
|
+
* @property {number} statusCode
|
|
18
|
+
* @property {number} durationMs
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} RequestMetricsOptions
|
|
23
|
+
* @property {(record: RequestMetricRecord, ctx: object) => void | Promise<void>} [onRecord]
|
|
24
|
+
* @property {() => bigint} [clock]
|
|
25
|
+
* @property {string} [stateKey]
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Cria hooks para coletar métricas por requisição.
|
|
30
|
+
*
|
|
31
|
+
* @param {RequestMetricsOptions} [opts={}]
|
|
32
|
+
* @returns {{ onRequest: Function, onResponse: Function }}
|
|
33
|
+
*/
|
|
34
|
+
export function requestMetrics(opts = {}) {
|
|
35
|
+
const onRecord = opts.onRecord || (async () => {});
|
|
36
|
+
const clock = opts.clock || process.hrtime.bigint;
|
|
37
|
+
const stateKey = opts.stateKey || '__zent_request_metrics_start';
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
async onRequest(ctx) {
|
|
41
|
+
ctx.state[stateKey] = clock();
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async onResponse(ctx) {
|
|
45
|
+
const start = ctx.state[stateKey];
|
|
46
|
+
if (typeof start !== 'bigint') return;
|
|
47
|
+
|
|
48
|
+
const durationMs = Number(clock() - start) / 1_000_000;
|
|
49
|
+
|
|
50
|
+
const record = {
|
|
51
|
+
method: ctx.req.method,
|
|
52
|
+
path: ctx.req.path,
|
|
53
|
+
statusCode: ctx.res.statusCode,
|
|
54
|
+
durationMs,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
await onRecord(record, ctx);
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Cria plugin escopado para registrar hooks de requestMetrics.
|
|
64
|
+
* @param {RequestMetricsOptions} [opts={}]
|
|
65
|
+
* @returns {(app: object) => Promise<void>}
|
|
66
|
+
*/
|
|
67
|
+
export function requestMetricsPlugin(opts = {}) {
|
|
68
|
+
const hooks = requestMetrics(opts);
|
|
69
|
+
|
|
70
|
+
return async function registerRequestMetrics(app) {
|
|
71
|
+
app.addHook('onRequest', hooks.onRequest);
|
|
72
|
+
app.addHook('onResponse', hooks.onResponse);
|
|
73
|
+
};
|
|
74
|
+
}
|