@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.
@@ -0,0 +1,198 @@
1
+ import { RadixTree } from './radix-tree.mjs';
2
+
3
+ const HTTP_METHODS = [
4
+ 'GET',
5
+ 'POST',
6
+ 'PUT',
7
+ 'PATCH',
8
+ 'DELETE',
9
+ 'HEAD',
10
+ 'OPTIONS',
11
+ ];
12
+
13
+ /**
14
+ * Router público.
15
+ * Responsabilidade única: API de conveniência para registro e busca de rotas.
16
+ * Delega toda a lógica de armazenamento/lookup para a RadixTree.
17
+ */
18
+ export class Router {
19
+ /** @type {RadixTree} */
20
+ #tree;
21
+
22
+ /**
23
+ * @param {object} [opts]
24
+ * @param {boolean} [opts.ignoreTrailingSlash=true]
25
+ * @param {boolean} [opts.caseSensitive=false]
26
+ */
27
+ constructor(opts = {}) {
28
+ this.#tree = new RadixTree(opts);
29
+ }
30
+
31
+ /**
32
+ * Registra uma rota completa.
33
+ * @param {object} definition
34
+ * @param {string} definition.method - HTTP method
35
+ * @param {string} definition.path
36
+ * @param {Function} definition.handler
37
+ * @param {Function[]} [definition.middlewares]
38
+ * @param {object} [definition.hooks]
39
+ */
40
+ route({ method, path, handler, middlewares = [], hooks = {}, ...meta }) {
41
+ this.#tree.add(method.toUpperCase(), path, {
42
+ handler,
43
+ middlewares,
44
+ hooks,
45
+ ...meta,
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Busca rota pelo método e path.
51
+ * @param {string} method
52
+ * @param {string} path
53
+ * @returns {{ route: object, params: Record<string, string> }}
54
+ */
55
+ find(method, path) {
56
+ return this.#tree.find(method, path);
57
+ }
58
+
59
+ /**
60
+ * Registra uma rota para todos os métodos HTTP.
61
+ * @param {string} path
62
+ * @param {Function} handler
63
+ * @param {object} [opts]
64
+ */
65
+ all(path, handler, opts = {}) {
66
+ for (const method of HTTP_METHODS) {
67
+ this.route({ method, path, handler, ...opts });
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Agrupa rotas sob um prefixo com middlewares/hooks compartilhados.
73
+ * @param {string} prefix - Prefixo do grupo (ex: '/api/v1')
74
+ * @param {object} [opts] - Opções do grupo
75
+ * @param {Function[]} [opts.middlewares] - Middlewares do grupo
76
+ * @param {object} [opts.hooks] - Hooks do grupo
77
+ * @param {Function} callback - Recebe um RouteGroup para registrar rotas
78
+ */
79
+ group(prefix, ...args) {
80
+ const opts = typeof args[0] === 'function' ? {} : args.shift() || {};
81
+ const callback = args[0];
82
+
83
+ const routeGroup = new RouteGroup(this, prefix, opts);
84
+ callback(routeGroup);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Proxy leve para registro de rotas dentro de um grupo.
90
+ * Responsabilidade única: prefixar paths e mesclar middlewares/hooks do grupo.
91
+ */
92
+ class RouteGroup {
93
+ /** @type {Router} */
94
+ #router;
95
+
96
+ /** @type {string} */
97
+ #prefix;
98
+
99
+ /** @type {Function[]} */
100
+ #middlewares;
101
+
102
+ /** @type {object} */
103
+ #hooks;
104
+
105
+ /**
106
+ * @param {Router} router
107
+ * @param {string} prefix
108
+ * @param {object} opts
109
+ */
110
+ constructor(router, prefix, opts = {}) {
111
+ this.#router = router;
112
+ this.#prefix = prefix.replace(/\/+$/, '');
113
+ this.#middlewares = opts.middlewares || [];
114
+ this.#hooks = opts.hooks || {};
115
+ }
116
+
117
+ /**
118
+ * Registra rota no grupo, mesclando prefixo + middlewares + hooks.
119
+ * @param {object} definition
120
+ */
121
+ route({ method, path, handler, middlewares = [], hooks = {} }) {
122
+ this.#router.route({
123
+ method,
124
+ path: this.#prefix + (path === '/' ? '' : path),
125
+ handler,
126
+ middlewares: [...this.#middlewares, ...middlewares],
127
+ hooks: this.#mergeHooks(this.#hooks, hooks),
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Registra rota para todos os métodos HTTP.
133
+ * @param {string} path
134
+ * @param {Function} handler
135
+ * @param {object} [opts]
136
+ */
137
+ all(path, handler, opts = {}) {
138
+ for (const method of HTTP_METHODS) {
139
+ this.route({ method, path, handler, ...opts });
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Sub-grupo dentro do grupo atual.
145
+ * @param {string} prefix
146
+ * @param {...any} args
147
+ */
148
+ group(prefix, ...args) {
149
+ const opts = typeof args[0] === 'function' ? {} : args.shift() || {};
150
+ const callback = args[0];
151
+
152
+ const fullPrefix = this.#prefix + prefix.replace(/\/+$/, '');
153
+ const mergedOpts = {
154
+ middlewares: [...this.#middlewares, ...(opts.middlewares || [])],
155
+ hooks: this.#mergeHooks(this.#hooks, opts.hooks || {}),
156
+ };
157
+
158
+ const subGroup = new RouteGroup(this.#router, fullPrefix, mergedOpts);
159
+ callback(subGroup);
160
+ }
161
+
162
+ /**
163
+ * Mescla hooks de dois escopos. Hooks do filho são executados após os do pai.
164
+ * @param {object} parent
165
+ * @param {object} child
166
+ * @returns {object}
167
+ */
168
+ #mergeHooks(parent, child) {
169
+ const merged = { ...parent };
170
+ for (const [key, fns] of Object.entries(child)) {
171
+ merged[key] = [
172
+ ...(merged[key] || []),
173
+ ...(Array.isArray(fns) ? fns : [fns]),
174
+ ];
175
+ }
176
+ return merged;
177
+ }
178
+ }
179
+
180
+ // Gera métodos de conveniência: router.get(), router.post(), etc.
181
+ for (const method of HTTP_METHODS) {
182
+ /**
183
+ * @param {string} path
184
+ * @param {Function} handler
185
+ * @param {object} [opts] - { middlewares?, hooks? }
186
+ */
187
+ Router.prototype[method.toLowerCase()] = function (path, handler, opts = {}) {
188
+ this.route({ method, path, handler, ...opts });
189
+ };
190
+
191
+ RouteGroup.prototype[method.toLowerCase()] = function (
192
+ path,
193
+ handler,
194
+ opts = {}
195
+ ) {
196
+ this.route({ method, path, handler, ...opts });
197
+ };
198
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Nó da Radix Tree.
3
+ * Responsabilidade única: armazenar um fragmento do path e seus filhos/handlers.
4
+ */
5
+ export class Node {
6
+ /** @type {string} Fragmento do path */
7
+ prefix;
8
+
9
+ /** @type {Map<string, Node>} Filhos indexados pelo primeiro char */
10
+ children;
11
+
12
+ /** @type {Node | null} Filho de parâmetro (:param) */
13
+ paramChild;
14
+
15
+ /** @type {string | null} Nome do parâmetro (ex: 'id') */
16
+ paramName;
17
+
18
+ /** @type {Node | null} Filho wildcard (*) */
19
+ wildcardChild;
20
+
21
+ /** @type {string | null} Nome do wildcard */
22
+ wildcardName;
23
+
24
+ /** @type {Map<string, object>} Handlers por método HTTP */
25
+ handlers;
26
+
27
+ /**
28
+ * @param {string} [prefix='']
29
+ */
30
+ constructor(prefix = '') {
31
+ this.prefix = prefix;
32
+ this.children = new Map();
33
+ this.paramChild = null;
34
+ this.paramName = null;
35
+ this.wildcardChild = null;
36
+ this.wildcardName = null;
37
+ this.handlers = new Map();
38
+ }
39
+
40
+ /**
41
+ * Adiciona um handler para um método HTTP.
42
+ * @param {string} method
43
+ * @param {object} route - { handler, hooks, middlewares }
44
+ */
45
+ addHandler(method, route) {
46
+ this.handlers.set(method, route);
47
+ }
48
+
49
+ /**
50
+ * Busca o handler para um método HTTP.
51
+ * @param {string} method
52
+ * @returns {object | undefined}
53
+ */
54
+ getHandler(method) {
55
+ return this.handlers.get(method);
56
+ }
57
+
58
+ /**
59
+ * @returns {boolean} Nó possui pelo menos um handler registrado
60
+ */
61
+ get hasHandlers() {
62
+ return this.handlers.size > 0;
63
+ }
64
+
65
+ /**
66
+ * Retorna os métodos HTTP registrados neste nó.
67
+ * @returns {string[]}
68
+ */
69
+ get allowedMethods() {
70
+ return [...this.handlers.keys()];
71
+ }
72
+ }
@@ -0,0 +1,313 @@
1
+ import { MethodNotAllowedError, NotFoundError } from '../errors/http-error.mjs';
2
+ import { Node } from './node.mjs';
3
+
4
+ /**
5
+ * Radix Tree (Patricia Trie) para roteamento HTTP.
6
+ * Responsabilidade única: inserir e buscar rotas por path.
7
+ * Complexidade de lookup: O(k) onde k = comprimento do path.
8
+ */
9
+ export class RadixTree {
10
+ /** @type {Node} */
11
+ #root;
12
+
13
+ /** @type {boolean} */
14
+ #ignoreTrailingSlash;
15
+
16
+ /** @type {boolean} */
17
+ #caseSensitive;
18
+
19
+ /**
20
+ * @param {object} [opts]
21
+ * @param {boolean} [opts.ignoreTrailingSlash=true]
22
+ * @param {boolean} [opts.caseSensitive=false]
23
+ */
24
+ constructor(opts = {}) {
25
+ this.#root = new Node();
26
+ this.#ignoreTrailingSlash = opts.ignoreTrailingSlash ?? true;
27
+ this.#caseSensitive = opts.caseSensitive ?? false;
28
+ }
29
+
30
+ /**
31
+ * Concatena segmentos a partir de um índice sem criar slice intermediário.
32
+ * @param {string[]} segments
33
+ * @param {number} startIndex
34
+ * @returns {string}
35
+ */
36
+ #joinSegmentsFrom(segments, startIndex) {
37
+ if (startIndex >= segments.length) return '';
38
+
39
+ let result = segments[startIndex];
40
+ for (let i = startIndex + 1; i < segments.length; i++) {
41
+ result += `/${segments[i]}`;
42
+ }
43
+
44
+ return result;
45
+ }
46
+
47
+ /**
48
+ * Registra uma rota na árvore.
49
+ * @param {string} method - HTTP method (GET, POST, etc.)
50
+ * @param {string} path - Route path (ex: /users/:id/posts)
51
+ * @param {object} route - { handler, hooks?, middlewares? }
52
+ */
53
+ add(method, path, route) {
54
+ const segments = this.#splitPath(this.#normalizePath(path));
55
+ let current = this.#root;
56
+
57
+ for (const segment of segments) {
58
+ if (segment.startsWith(':')) {
59
+ // Segmento de parâmetro
60
+ const paramName = segment.slice(1);
61
+
62
+ if (!current.paramChild) {
63
+ current.paramChild = new Node(segment);
64
+ current.paramChild.paramName = paramName;
65
+ }
66
+
67
+ current = current.paramChild;
68
+ } else if (segment.startsWith('*')) {
69
+ // Segmento wildcard
70
+ const wildcardName = segment.slice(1) || 'wildcard';
71
+
72
+ if (!current.wildcardChild) {
73
+ current.wildcardChild = new Node(segment);
74
+ current.wildcardChild.wildcardName = wildcardName;
75
+ }
76
+
77
+ current = current.wildcardChild;
78
+ break; // Wildcard consome o resto do path
79
+ } else {
80
+ // Segmento estático
81
+ current = this.#insertStatic(current, segment);
82
+ }
83
+ }
84
+
85
+ current.addHandler(method, route);
86
+ }
87
+
88
+ /**
89
+ * Busca uma rota na árvore.
90
+ * @param {string} method - HTTP method
91
+ * @param {string} path - Request path
92
+ * @returns {{ route: object, params: Record<string, string> }}
93
+ * @throws {NotFoundError} Rota não encontrada
94
+ * @throws {MethodNotAllowedError} Método não permitido
95
+ */
96
+ find(method, path) {
97
+ const normalizedPath = this.#normalizePath(path);
98
+ const segments = this.#splitPath(normalizedPath);
99
+ const params = {};
100
+
101
+ const node = this.#search(this.#root, segments, 0, params);
102
+
103
+ if (!node) {
104
+ throw new NotFoundError(`Route not found: ${method} ${path}`);
105
+ }
106
+
107
+ const route = node.getHandler(method);
108
+
109
+ if (!route) {
110
+ const error = new MethodNotAllowedError(
111
+ `Method ${method} not allowed for ${path}`
112
+ );
113
+ error.allowedMethods = node.allowedMethods;
114
+ throw error;
115
+ }
116
+
117
+ return { route, params };
118
+ }
119
+
120
+ /**
121
+ * Busca recursiva na árvore.
122
+ * @param {Node} node
123
+ * @param {string[]} segments
124
+ * @param {number} index
125
+ * @param {Record<string, string>} params
126
+ * @returns {Node | null}
127
+ */
128
+ #search(node, segments, index, params) {
129
+ // Todos os segmentos consumidos — retorna o nó se tem handlers
130
+ if (index === segments.length) {
131
+ return node.hasHandlers ? node : null;
132
+ }
133
+
134
+ const segment = segments[index];
135
+
136
+ // 1. Tenta match estático (prioridade mais alta)
137
+ const staticChild = this.#findStaticChild(node, segment);
138
+ if (staticChild) {
139
+ const result = this.#search(staticChild, segments, index + 1, params);
140
+ if (result) return result;
141
+ }
142
+
143
+ // 2. Tenta match por parâmetro
144
+ if (node.paramChild) {
145
+ params[node.paramChild.paramName] = segment;
146
+ const result = this.#search(node.paramChild, segments, index + 1, params);
147
+ if (result) return result;
148
+ delete params[node.paramChild.paramName]; // Backtrack
149
+ }
150
+
151
+ // 3. Tenta match wildcard (prioridade mais baixa)
152
+ if (node.wildcardChild) {
153
+ params[node.wildcardChild.wildcardName] = this.#joinSegmentsFrom(
154
+ segments,
155
+ index
156
+ );
157
+ return node.wildcardChild;
158
+ }
159
+
160
+ return null;
161
+ }
162
+
163
+ /**
164
+ * Insere um segmento estático na árvore, dividindo nós quando necessário.
165
+ * @param {Node} parent
166
+ * @param {string} segment
167
+ * @returns {Node}
168
+ */
169
+ #insertStatic(parent, segment) {
170
+ const key = this.#segmentKey(segment);
171
+ const existing = parent.children.get(key);
172
+
173
+ if (!existing) {
174
+ const child = new Node(segment);
175
+ parent.children.set(key, child);
176
+ return child;
177
+ }
178
+
179
+ const existingPrefix = this.#caseSensitive
180
+ ? existing.prefix
181
+ : existing.prefix.toLowerCase();
182
+ const newSegment = this.#caseSensitive ? segment : segment.toLowerCase();
183
+
184
+ // Calcula o prefixo comum
185
+ const commonLen = this.#commonPrefixLength(existingPrefix, newSegment);
186
+
187
+ // Prefixo idêntico — avança para o nó existente
188
+ if (commonLen === existing.prefix.length && commonLen === segment.length) {
189
+ return existing;
190
+ }
191
+
192
+ // O segmento existente é um prefixo do novo — continua na subárvore
193
+ if (commonLen === existing.prefix.length) {
194
+ const remainder = segment.slice(commonLen);
195
+ return this.#insertStatic(existing, remainder);
196
+ }
197
+
198
+ // Split: dividir o nó existente
199
+ const splitNode = new Node(existing.prefix.slice(0, commonLen));
200
+
201
+ // O antigo nó vira filho do split
202
+ existing.prefix = existing.prefix.slice(commonLen);
203
+ const existingNewKey = this.#segmentKey(existing.prefix);
204
+ splitNode.children.set(existingNewKey, existing);
205
+
206
+ // Atualiza o parent para apontar pro split
207
+ parent.children.set(key, splitNode);
208
+
209
+ // Se o segmento novo é exatamente o prefixo comum, o splitNode é o destino
210
+ if (commonLen === segment.length) {
211
+ return splitNode;
212
+ }
213
+
214
+ // Cria novo nó para o restante do segmento
215
+ const remainder = segment.slice(commonLen);
216
+ const newChild = new Node(remainder);
217
+ const newKey = this.#segmentKey(remainder);
218
+ splitNode.children.set(newKey, newChild);
219
+
220
+ return newChild;
221
+ }
222
+
223
+ /**
224
+ * Busca um filho estático que corresponda ao segmento.
225
+ * @param {Node} node
226
+ * @param {string} segment
227
+ * @returns {Node | null}
228
+ */
229
+ #findStaticChild(node, segment) {
230
+ const key = this.#segmentKey(segment);
231
+ const child = node.children.get(key);
232
+
233
+ if (!child) return null;
234
+
235
+ const childPrefix = this.#caseSensitive
236
+ ? child.prefix
237
+ : child.prefix.toLowerCase();
238
+ const target = this.#caseSensitive ? segment : segment.toLowerCase();
239
+
240
+ if (target === childPrefix) {
241
+ return child;
242
+ }
243
+
244
+ // O prefix do filho é início do segmento — continua descendo
245
+ if (target.startsWith(childPrefix)) {
246
+ const remainder = segment.slice(child.prefix.length);
247
+ return this.#findStaticChild(child, remainder);
248
+ }
249
+
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * Calcula a chave de indexação para um segmento.
255
+ * @param {string} segment
256
+ * @returns {string}
257
+ */
258
+ #segmentKey(segment) {
259
+ if (segment === '') return '';
260
+ return this.#caseSensitive ? segment[0] : segment[0].toLowerCase();
261
+ }
262
+
263
+ /**
264
+ * Calcula o comprimento do prefixo comum entre duas strings.
265
+ * @param {string} a
266
+ * @param {string} b
267
+ * @returns {number}
268
+ */
269
+ #commonPrefixLength(a, b) {
270
+ const len = Math.min(a.length, b.length);
271
+ let i = 0;
272
+ while (i < len && a[i] === b[i]) i++;
273
+ return i;
274
+ }
275
+
276
+ /**
277
+ * Normaliza o path — remove trailing slash, garante leading slash.
278
+ * @param {string} path
279
+ * @returns {string}
280
+ */
281
+ #normalizePath(path) {
282
+ if (!path || path === '/') return '/';
283
+
284
+ let normalized = path.startsWith('/') ? path : '/' + path;
285
+
286
+ if (this.#ignoreTrailingSlash && normalized.length > 1) {
287
+ normalized = normalized.replace(/\/+$/, '');
288
+ }
289
+
290
+ return normalized;
291
+ }
292
+
293
+ /**
294
+ * Divide o path em segmentos.
295
+ * Preserva trailing slash como segmento vazio quando ignoreTrailingSlash é false.
296
+ * @param {string} path
297
+ * @returns {string[]}
298
+ */
299
+ #splitPath(path) {
300
+ if (path === '/') return [];
301
+ // Remove o leading '/' e faz split
302
+ const parts = path.slice(1).split('/');
303
+ // Remove trailing empty segment apenas quando trailing slash é ignorada
304
+ if (
305
+ this.#ignoreTrailingSlash &&
306
+ parts.length > 0 &&
307
+ parts[parts.length - 1] === ''
308
+ ) {
309
+ parts.pop();
310
+ }
311
+ return parts;
312
+ }
313
+ }
@@ -0,0 +1,31 @@
1
+ export const HttpStatus = {
2
+ OK: 200,
3
+ CREATED: 201,
4
+ NO_CONTENT: 204,
5
+ FOUND: 302,
6
+ BAD_REQUEST: 400,
7
+ UNAUTHORIZED: 401,
8
+ FORBIDDEN: 403,
9
+ NOT_FOUND: 404,
10
+ METHOD_NOT_ALLOWED: 405,
11
+ CONFLICT: 409,
12
+ UNPROCESSABLE_ENTITY: 422,
13
+ TOO_MANY_REQUESTS: 429,
14
+ INTERNAL_SERVER_ERROR: 500,
15
+ };
16
+
17
+ export const HttpStatusText = {
18
+ [HttpStatus.OK]: 'OK',
19
+ [HttpStatus.CREATED]: 'Created',
20
+ [HttpStatus.NO_CONTENT]: 'No Content',
21
+ [HttpStatus.FOUND]: 'Found',
22
+ [HttpStatus.BAD_REQUEST]: 'Bad Request',
23
+ [HttpStatus.UNAUTHORIZED]: 'Unauthorized',
24
+ [HttpStatus.FORBIDDEN]: 'Forbidden',
25
+ [HttpStatus.NOT_FOUND]: 'Not Found',
26
+ [HttpStatus.METHOD_NOT_ALLOWED]: 'Method Not Allowed',
27
+ [HttpStatus.CONFLICT]: 'Conflict',
28
+ [HttpStatus.UNPROCESSABLE_ENTITY]: 'Unprocessable Entity',
29
+ [HttpStatus.TOO_MANY_REQUESTS]: 'Too Many Requests',
30
+ [HttpStatus.INTERNAL_SERVER_ERROR]: 'Internal Server Error',
31
+ };