@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/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
+ }