aetherjs-router 1.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.
@@ -0,0 +1,660 @@
1
+ // src/path-compiler.js - Enhanced path compiler with query parameter support
2
+ // Custom path parameter parser with query parameter support
3
+ class PathParser {
4
+ constructor(options = {}) {
5
+ this.options = {
6
+ sensitive: false,
7
+ strict: false,
8
+ end: true,
9
+ delimiter: '/',
10
+ delimiters: './',
11
+ parseQuery: false, // New option to enable query parameter parsing
12
+ ...options
13
+ };
14
+ }
15
+
16
+ /**
17
+ * Parse path pattern, extract parameter names and regular expressions
18
+ * Now supports query parameter patterns like /users?id=:id&lang=:lang
19
+ * @param {string} path - Path pattern, e.g., '/users/:id' or '/users?id=:id'
20
+ * @returns {Object} Contains regular expression and parameter keys array
21
+ */
22
+ parse(path) {
23
+ // Separate path and query string
24
+ const [pathPart, queryPart] = path.split('?');
25
+
26
+ // Parse path portion
27
+ const pathResult = this._parsePath(pathPart);
28
+
29
+ // Parse query string portion if enabled
30
+ if (this.options.parseQuery && queryPart) {
31
+ const queryResult = this._parseQuery(queryPart);
32
+ return {
33
+ regex: pathResult.regex,
34
+ keys: [...pathResult.keys, ...queryResult.keys],
35
+ hasQuery: true,
36
+ queryPattern: queryPart
37
+ };
38
+ }
39
+
40
+ return {
41
+ ...pathResult,
42
+ hasQuery: false
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Parse the path portion (original implementation)
48
+ * @private
49
+ */
50
+ _parsePath(path) {
51
+ const keys = [];
52
+ let pattern = '';
53
+ let inParam = false;
54
+ let paramName = '';
55
+ let optional = false;
56
+ let patternPart = '';
57
+
58
+ // Escape regex special characters
59
+ const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
+
61
+ for (let i = 0; i < path.length; i++) {
62
+ const char = path[i];
63
+
64
+ if (char === ':' && !inParam) {
65
+ // Start parameter matching
66
+ inParam = true;
67
+ paramName = '';
68
+ patternPart = '';
69
+ continue;
70
+ }
71
+
72
+ if (inParam) {
73
+ if (char === '(') {
74
+ // Start custom pattern
75
+ let depth = 1;
76
+ i++;
77
+ while (i < path.length && depth > 0) {
78
+ if (path[i] === '(') depth++;
79
+ else if (path[i] === ')') depth--;
80
+ if (depth > 0) patternPart += path[i];
81
+ i++;
82
+ }
83
+ i--; // Step back one position
84
+ continue;
85
+ }
86
+
87
+ if (char === '?' && i === path.length - 1) {
88
+ // Optional parameter
89
+ optional = true;
90
+ continue;
91
+ }
92
+
93
+ if (char === this.options.delimiter || i === path.length - 1) {
94
+ // Parameter ends
95
+ if (i === path.length - 1) {
96
+ paramName += char;
97
+ }
98
+
99
+ // Add parameter key
100
+ keys.push({
101
+ name: paramName,
102
+ optional: optional,
103
+ pattern: patternPart || '[^\\/]+',
104
+ type: 'path' // Mark as path parameter
105
+ });
106
+
107
+ // Build regex part
108
+ const patternStr = patternPart || '[^\\/]+';
109
+ pattern += `(${patternStr})`;
110
+
111
+ // Reset state
112
+ inParam = false;
113
+ paramName = '';
114
+ optional = false;
115
+ patternPart = '';
116
+
117
+ if (i === path.length - 1 && char !== this.options.delimiter) {
118
+ break;
119
+ }
120
+ } else {
121
+ paramName += char;
122
+ }
123
+ } else {
124
+ // Regular characters
125
+ if (char === '*') {
126
+ // Wildcard matching
127
+ keys.push({
128
+ name: 'wildcard',
129
+ optional: false,
130
+ pattern: '.*',
131
+ type: 'path'
132
+ });
133
+ pattern += '(.*)';
134
+ } else if (char === '?') {
135
+ // Optional character
136
+ pattern += '.?';
137
+ } else if (char === '+') {
138
+ // One or more characters
139
+ pattern += '.+';
140
+ } else if ('()[]{}|^$'.includes(char)) {
141
+ // Regex special characters, need escaping
142
+ pattern += '\\' + char;
143
+ } else {
144
+ pattern += escapeRegex(char);
145
+ }
146
+ }
147
+ }
148
+
149
+ // Handle the last parameter
150
+ if (inParam && paramName) {
151
+ keys.push({
152
+ name: paramName,
153
+ optional: optional,
154
+ pattern: patternPart || '[^\\/]+',
155
+ type: 'path'
156
+ });
157
+ pattern += `(${patternPart || '[^\\/]+'})`;
158
+ }
159
+
160
+ // Build complete regular expression
161
+ let regexStr = pattern;
162
+ if (!this.options.strict) {
163
+ regexStr = regexStr.replace(/\\\//g, '[\\/]?');
164
+ }
165
+
166
+ if (this.options.end) {
167
+ regexStr += '$';
168
+ }
169
+
170
+ const flags = this.options.sensitive ? '' : 'i';
171
+ const regex = new RegExp('^' + regexStr, flags);
172
+
173
+ return { regex, keys };
174
+ }
175
+
176
+ /**
177
+ * Parse query string portion for query parameters
178
+ * Supports patterns like ?id=:id&lang=:lang
179
+ * @private
180
+ */
181
+ _parseQuery(queryString) {
182
+ const keys = [];
183
+ const params = new URLSearchParams(queryString);
184
+
185
+ for (const [key, value] of params) {
186
+ if (value.startsWith(':')) {
187
+ // Query parameter with pattern
188
+ const paramName = value.slice(1);
189
+ const isOptional = paramName.endsWith('?');
190
+ const cleanName = isOptional ? paramName.slice(0, -1) : paramName;
191
+
192
+ keys.push({
193
+ name: cleanName,
194
+ optional: isOptional,
195
+ pattern: '[^&]*', // Default pattern for query values
196
+ type: 'query',
197
+ queryKey: key
198
+ });
199
+ }
200
+ }
201
+
202
+ return { keys };
203
+ }
204
+
205
+ /**
206
+ * Extract parameters from full URL including query string
207
+ * @param {string} url - Full URL with query string
208
+ * @param {Object} compiled - Compiled route information
209
+ * @returns {Object} Extracted parameters
210
+ */
211
+ extractParams(url, compiled) {
212
+ const [path, queryString] = url.split('?');
213
+ const params = {};
214
+
215
+ // Extract path parameters
216
+ const pathMatch = compiled.regex.exec(path);
217
+ if (pathMatch) {
218
+ compiled.keys.forEach((key, index) => {
219
+ if (key.type === 'path' && pathMatch[index + 1] !== undefined) {
220
+ params[key.name] = decodeURIComponent(pathMatch[index + 1]);
221
+ }
222
+ });
223
+ }
224
+
225
+ // Extract query parameters
226
+ if (queryString && compiled.hasQuery) {
227
+ const queryParams = new URLSearchParams(queryString);
228
+ compiled.keys.forEach(key => {
229
+ if (key.type === 'query' && queryParams.has(key.queryKey)) {
230
+ params[key.name] = queryParams.get(key.queryKey);
231
+ }
232
+ });
233
+ }
234
+
235
+ return params;
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Factory function to create path compiler (with caching)
241
+ * @param {Object} options - Compiler options
242
+ * @returns {Object} Path compiler instance
243
+ */
244
+ export function createPathCompiler(options = {}) {
245
+ const cache = new Map();
246
+ const parser = new PathParser(options);
247
+
248
+ let hits = 0;
249
+ let misses = 0;
250
+
251
+ return {
252
+ /**
253
+ * Compile path pattern to regular expression
254
+ * Now supports query parameter patterns
255
+ * @param {string} path - Path pattern, e.g., '/users?id=:id&lang=:lang'
256
+ * @param {Object} options - Compilation options
257
+ * @returns {Object} Compiled regex and keys
258
+ */
259
+ compile(path, options = {}) {
260
+ const cacheKey = `${path}:${JSON.stringify(options)}`;
261
+
262
+ // Return cached result if available
263
+ if (cache.has(cacheKey)) {
264
+ hits++;
265
+ return cache.get(cacheKey);
266
+ }
267
+
268
+ misses++;
269
+
270
+ // Merge options
271
+ const mergedOptions = { ...parser.options, ...options };
272
+ const tempParser = new PathParser(mergedOptions);
273
+ const result = tempParser.parse(path);
274
+
275
+ // Cache result
276
+ cache.set(cacheKey, result);
277
+
278
+ return result;
279
+ },
280
+
281
+ /**
282
+ * Extract parameters from URL with query string support
283
+ * @param {string} url - Full URL
284
+ * @param {string} pattern - Path pattern
285
+ * @param {Object} options - Compilation options
286
+ * @returns {Object|null} Parameter object or null
287
+ */
288
+ extractParams(url, pattern, options = {}) {
289
+ const compiled = this.compile(pattern, options);
290
+ const parser = new PathParser({ ...this.options, ...options });
291
+ return parser.extractParams(url, compiled);
292
+ },
293
+
294
+ /**
295
+ * Clear compilation cache
296
+ */
297
+ clearCache() {
298
+ cache.clear();
299
+ hits = 0;
300
+ misses = 0;
301
+ },
302
+
303
+ /**
304
+ * Get cache statistics
305
+ * @returns {Object} Cache statistics
306
+ */
307
+ getCacheStats() {
308
+ return {
309
+ size: cache.size,
310
+ hits,
311
+ misses,
312
+ hitRate: hits + misses > 0 ? (hits / (hits + misses)) * 100 : 0
313
+ };
314
+ },
315
+
316
+ /**
317
+ * Precompile common path patterns
318
+ * @param {Array} patterns - Array of path patterns
319
+ */
320
+ precompile(patterns) {
321
+ patterns.forEach(pattern => {
322
+ this.compile(pattern);
323
+ });
324
+ }
325
+ };
326
+ }
327
+
328
+ /**
329
+ * Common path pattern regex generators
330
+ */
331
+ export const pathPatterns = {
332
+ // Numeric ID
333
+ id: '\\d+',
334
+
335
+ // Alphanumeric ID
336
+ slug: '[a-zA-Z0-9_-]+',
337
+
338
+ // UUID
339
+ uuid: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}',
340
+
341
+ // Filename
342
+ filename: '[^\\/]+',
343
+
344
+ // File extension
345
+ extension: '\\.[a-zA-Z0-9]+',
346
+
347
+ // Year-month-day
348
+ date: '\\d{4}-\\d{2}-\\d{2}',
349
+
350
+ // Version number
351
+ version: '\\d+(\\.\\d+)*',
352
+
353
+ // Query parameter patterns
354
+ query: {
355
+ id: '\\d+',
356
+ slug: '[a-zA-Z0-9_-]+',
357
+ uuid: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}',
358
+ page: '\\d+',
359
+ limit: '\\d+',
360
+ sort: '[a-zA-Z_]+',
361
+ order: '(asc|desc)'
362
+ }
363
+ };
364
+
365
+ /**
366
+ * Quick compilation function (using default compiler)
367
+ */
368
+ const defaultCompiler = createPathCompiler();
369
+
370
+ export function compilePath(path, options = {}) {
371
+ return defaultCompiler.compile(path, options);
372
+ }
373
+
374
+ /**
375
+ * Path compiler class (for type checking)
376
+ */
377
+ export class PathCompiler {
378
+ constructor(options = {}) {
379
+ this._impl = createPathCompiler(options);
380
+ }
381
+
382
+ compile(path, options = {}) {
383
+ return this._impl.compile(path, options);
384
+ }
385
+
386
+ extractParams(url, pattern, options = {}) {
387
+ return this._impl.extractParams(url, pattern, options);
388
+ }
389
+
390
+ clearCache() {
391
+ return this._impl.clearCache();
392
+ }
393
+
394
+ getCacheStats() {
395
+ return this._impl.getCacheStats();
396
+ }
397
+
398
+ precompile(patterns) {
399
+ return this._impl.precompile(patterns);
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Utility functions with query parameter support
405
+ */
406
+ export const pathUtils = {
407
+ /**
408
+ * Check if path matches pattern (including query parameters)
409
+ * @param {string} url - Full URL to check
410
+ * @param {string} pattern - Path pattern (may include query parameters)
411
+ * @param {Object} options - Compilation options
412
+ * @returns {boolean} Whether it matches
413
+ */
414
+ match(url, pattern, options = {}) {
415
+ const [path, queryString] = url.split('?');
416
+ const { regex } = compilePath(pattern, options);
417
+
418
+ // Check if pattern has query parameters
419
+ if (pattern.includes('?')) {
420
+ const [pathPattern] = pattern.split('?');
421
+ const pathRegex = compilePath(pathPattern, options).regex;
422
+
423
+ // Match path first
424
+ if (!pathRegex.test(path)) {
425
+ return false;
426
+ }
427
+
428
+ // If pattern has query parameters, check if they match
429
+ if (queryString) {
430
+ const queryParams = new URLSearchParams(queryString);
431
+ const patternParams = new URLSearchParams(pattern.split('?')[1]);
432
+
433
+ for (const [key, value] of patternParams) {
434
+ if (value.startsWith(':')) {
435
+ // This is a query parameter pattern, skip validation
436
+ continue;
437
+ }
438
+
439
+ // Static query parameter value must match
440
+ if (!queryParams.has(key) || queryParams.get(key) !== value) {
441
+ return false;
442
+ }
443
+ }
444
+ }
445
+
446
+ return true;
447
+ }
448
+
449
+ // Simple path matching
450
+ return regex.test(path);
451
+ },
452
+
453
+ /**
454
+ * Extract parameters from URL (including query parameters)
455
+ * @param {string} url - Full URL to parse
456
+ * @param {string} pattern - Path pattern (may include query parameters)
457
+ * @param {Object} options - Compilation options
458
+ * @returns {Object|null} Parameter object or null
459
+ */
460
+ extractParams(url, pattern, options = {}) {
461
+ const compiler = createPathCompiler({ ...options, parseQuery: true });
462
+ return compiler.extractParams(url, pattern);
463
+ },
464
+
465
+ /**
466
+ * Build URL with query parameters
467
+ * @param {string} pattern - Path pattern
468
+ * @param {Object} params - Parameter object (including query params)
469
+ * @returns {string} Built URL
470
+ */
471
+ buildUrl(pattern, params = {}) {
472
+ let [pathPart, queryPart] = pattern.split('?');
473
+
474
+ // Replace path parameters
475
+ pathPart = pathPart.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)($[^)]+$)?(\?)?/g, (match, paramName) => {
476
+ return params[paramName] !== undefined ? params[paramName] : match;
477
+ });
478
+
479
+ // Replace wildcards in path
480
+ pathPart = pathPart.replace(/\*/g, () => {
481
+ return params['wildcard'] !== undefined ? params['wildcard'] : '*';
482
+ });
483
+
484
+ // Build query string if pattern has query parameters
485
+ let queryString = '';
486
+ if (queryPart) {
487
+ const queryParams = new URLSearchParams(queryPart);
488
+ const resultParams = new URLSearchParams();
489
+
490
+ for (const [key, value] of queryParams) {
491
+ if (value.startsWith(':')) {
492
+ const paramName = value.slice(1).replace(/\?$/, '');
493
+ if (params[paramName] !== undefined) {
494
+ resultParams.set(key, params[paramName]);
495
+ } else if (!value.endsWith('?')) {
496
+ // Required query parameter missing
497
+ throw new Error(`Missing required query parameter: ${paramName}`);
498
+ }
499
+ } else {
500
+ resultParams.set(key, value);
501
+ }
502
+ }
503
+
504
+ queryString = resultParams.toString();
505
+ }
506
+
507
+ // Add additional query parameters not in pattern
508
+ const additionalParams = new URLSearchParams();
509
+ Object.keys(params).forEach(key => {
510
+ // Skip path parameters and wildcard
511
+ if (!pathPart.includes(`:${key}`) && key !== 'wildcard') {
512
+ // Check if this is a query parameter from pattern
513
+ const isPatternParam = queryPart && queryPart.includes(`:${key}`);
514
+ if (!isPatternParam) {
515
+ additionalParams.set(key, params[key]);
516
+ }
517
+ }
518
+ });
519
+
520
+ const additionalQuery = additionalParams.toString();
521
+
522
+ // Combine all query parameters
523
+ let finalQuery = '';
524
+ if (queryString && additionalQuery) {
525
+ finalQuery = `?${queryString}&${additionalQuery}`;
526
+ } else if (queryString) {
527
+ finalQuery = `?${queryString}`;
528
+ } else if (additionalQuery) {
529
+ finalQuery = `?${additionalQuery}`;
530
+ }
531
+
532
+ return pathPart + finalQuery;
533
+ },
534
+
535
+ /**
536
+ * Validate parameters (including query parameters)
537
+ * @param {Object} params - Parameter object
538
+ * @param {Object} validators - Validator object
539
+ * @returns {Object} Validation result
540
+ */
541
+ validateParams(params, validators) {
542
+ const errors = [];
543
+ const validated = {};
544
+
545
+ Object.keys(validators).forEach(key => {
546
+ const value = params[key];
547
+ const validator = validators[key];
548
+
549
+ if (validator.required && (value === undefined || value === null || value === '')) {
550
+ errors.push(`Parameter "${key}" is required`);
551
+ return;
552
+ }
553
+
554
+ if (value !== undefined && value !== null) {
555
+ // Type validation
556
+ if (validator.type) {
557
+ const type = typeof value;
558
+ if (validator.type === 'number' && isNaN(Number(value))) {
559
+ errors.push(`Parameter "${key}" must be a number`);
560
+ } else if (validator.type === 'integer' && !Number.isInteger(Number(value))) {
561
+ errors.push(`Parameter "${key}" must be an integer`);
562
+ } else if (validator.type === 'string' && typeof value !== 'string') {
563
+ errors.push(`Parameter "${key}" must be a string`);
564
+ } else if (validator.type === 'boolean' && typeof value !== 'boolean' && value !== 'true' && value !== 'false') {
565
+ errors.push(`Parameter "${key}" must be a boolean`);
566
+ }
567
+ }
568
+
569
+ // Pattern validation
570
+ if (validator.pattern && !new RegExp(validator.pattern).test(value)) {
571
+ errors.push(`Parameter "${key}" has invalid format`);
572
+ }
573
+
574
+ // Enum validation
575
+ if (validator.enum && !validator.enum.includes(value)) {
576
+ errors.push(`Parameter "${key}" must be one of: ${validator.enum.join(', ')}`);
577
+ }
578
+
579
+ // Range validation
580
+ if (validator.min !== undefined && Number(value) < validator.min) {
581
+ errors.push(`Parameter "${key}" cannot be less than ${validator.min}`);
582
+ }
583
+ if (validator.max !== undefined && Number(value) > validator.max) {
584
+ errors.push(`Parameter "${key}" cannot be greater than ${validator.max}`);
585
+ }
586
+
587
+ // Length validation
588
+ if (validator.minLength !== undefined && value.length < validator.minLength) {
589
+ errors.push(`Parameter "${key}" length cannot be less than ${validator.minLength}`);
590
+ }
591
+ if (validator.maxLength !== undefined && value.length > validator.maxLength) {
592
+ errors.push(`Parameter "${key}" length cannot be greater than ${validator.maxLength}`);
593
+ }
594
+
595
+ validated[key] = value;
596
+ }
597
+ });
598
+
599
+ return {
600
+ isValid: errors.length === 0,
601
+ errors,
602
+ validated
603
+ };
604
+ }
605
+ };
606
+
607
+ /**
608
+ * Example usage with query parameter support
609
+ */
610
+ export const examples = {
611
+ basic: () => {
612
+ const compiler = createPathCompiler({ parseQuery: true });
613
+
614
+ // Compile path pattern with query parameters
615
+ const result = compiler.compile('/users?id=:id&lang=:lang');
616
+ console.log('Compilation result:', result);
617
+
618
+ // Test matching with query parameters
619
+ const match = pathUtils.match('/users?id=123&lang=en', '/users?id=:id&lang=:lang');
620
+ console.log('Match /users?id=123&lang=en:', match);
621
+
622
+ // Extract parameters
623
+ const params = pathUtils.extractParams('/users?id=123&lang=en', '/users?id=:id&lang=:lang');
624
+ console.log('Extracted parameters:', params);
625
+ },
626
+
627
+ advanced: () => {
628
+ const compiler = createPathCompiler({ parseQuery: true });
629
+
630
+ // Complex patterns with query parameters
631
+ const patterns = [
632
+ '/users/:id', // Path parameter only
633
+ '/users?id=:id', // Query parameter only
634
+ '/users/:id?page=:page&limit=:limit', // Mixed path and query params
635
+ '/search?q=:query&page=:page?', // Optional query parameter
636
+ '/api/:version/users?sort=:sort&order=:order' // Versioned API with query params
637
+ ];
638
+
639
+ patterns.forEach(pattern => {
640
+ const { regex, keys } = compiler.compile(pattern);
641
+ console.log(`Pattern: ${pattern}`);
642
+ console.log(`Regex: ${regex}`);
643
+ console.log(`Parameters: ${keys.map(k => `${k.name} (${k.type})`).join(', ')}`);
644
+ console.log('---');
645
+ });
646
+ },
647
+
648
+ buildUrlExample: () => {
649
+ // Build URL with parameters
650
+ const url = pathUtils.buildUrl('/users/:id?page=:page&lang=:lang', {
651
+ id: '123',
652
+ page: '2',
653
+ lang: 'en',
654
+ extra: 'value' // Additional query parameter
655
+ });
656
+
657
+ console.log('Built URL:', url);
658
+ // Output: /users/123?page=2&lang=en&extra=value
659
+ }
660
+ };