naidejs 1.2.0 → 1.4.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 +382 -229
- package/SPEC-X.nx +23 -1
- package/SPEC.naide +56 -0
- package/bin/naide.js +99 -2
- package/package.json +1 -1
- package/src/generator.js +199 -0
- package/src/index.js +26 -8
- package/src/lexer.js +17 -4
- package/src/parser.js +216 -24
- package/src/preprocess.js +3 -0
- package/src/runtime.js +258 -0
- package/src/tokens.js +24 -0
package/src/parser.js
CHANGED
|
@@ -61,7 +61,16 @@ export class Parser {
|
|
|
61
61
|
expect(type) {
|
|
62
62
|
const tok = this.advance();
|
|
63
63
|
if (tok.type !== type) {
|
|
64
|
-
|
|
64
|
+
const hints = {
|
|
65
|
+
COLON: "Missing ':' — blocks (if, fn, server, etc.) need a colon at the end",
|
|
66
|
+
INDENT: "Expected an indented block — check your indentation (use 2 spaces)",
|
|
67
|
+
RPAREN: "Missing closing ')'",
|
|
68
|
+
RBRACKET: "Missing closing ']'",
|
|
69
|
+
RBRACE: "Missing closing '}'",
|
|
70
|
+
IDENT: "Expected an identifier (variable/function name)",
|
|
71
|
+
};
|
|
72
|
+
const hint = hints[type] ? `\n Hint: ${hints[type]}` : '';
|
|
73
|
+
throw this.error(`Expected ${type} but got ${tok.type} ('${tok.value}')${hint}`, tok);
|
|
65
74
|
}
|
|
66
75
|
return tok;
|
|
67
76
|
}
|
|
@@ -81,18 +90,28 @@ export class Parser {
|
|
|
81
90
|
return types.includes(this.peek().type);
|
|
82
91
|
}
|
|
83
92
|
|
|
93
|
+
isIdentLike(type) {
|
|
94
|
+
return type === T.IDENT || TYPE_TOKENS.has(type) ||
|
|
95
|
+
type === T.LOG || type === T.DB || type === T.GET ||
|
|
96
|
+
type === T.POST || type === T.PUT || type === T.DEL ||
|
|
97
|
+
type === T.MATCH || type === T.ON || type === T.NEW ||
|
|
98
|
+
type === T.FROM || type === T.AS || type === T.SELF ||
|
|
99
|
+
type === T.SCHEMA || type === T.CRUD || type === T.AUTH ||
|
|
100
|
+
type === T.CORS || type === T.LIMIT || type === T.ENV ||
|
|
101
|
+
type === T.EVERY || type === T.WATCH || type === T.STATIC ||
|
|
102
|
+
type === T.WS || type === T.GROUP || type === T.COOKIE ||
|
|
103
|
+
type === T.NOT || type === T.AND || type === T.OR ||
|
|
104
|
+
type === T.IN || type === T.BREAK || type === T.CONTINUE ||
|
|
105
|
+
type === T.THROW || type === T.MUT || type === T.PUB ||
|
|
106
|
+
type === T.UPLOAD || type === T.SESSION || type === T.VIEW ||
|
|
107
|
+
type === T.SSE || type === T.CACHE || type === T.PATCH || type === T.MID ||
|
|
108
|
+
type === T.VALIDATE || type === T.TEST || type === T.ASSERT ||
|
|
109
|
+
type === T.QUEUE || type === T.JOB || type === T.OPENAPI;
|
|
110
|
+
}
|
|
111
|
+
|
|
84
112
|
expectPropertyName() {
|
|
85
113
|
const tok = this.advance();
|
|
86
|
-
if (
|
|
87
|
-
tok.type === T.LOG || tok.type === T.DB || tok.type === T.GET ||
|
|
88
|
-
tok.type === T.POST || tok.type === T.PUT || tok.type === T.DEL ||
|
|
89
|
-
tok.type === T.MATCH || tok.type === T.ON || tok.type === T.NEW ||
|
|
90
|
-
tok.type === T.FROM || tok.type === T.AS || tok.type === T.SELF ||
|
|
91
|
-
tok.type === T.SCHEMA || tok.type === T.CRUD || tok.type === T.AUTH ||
|
|
92
|
-
tok.type === T.CORS || tok.type === T.LIMIT || tok.type === T.ENV ||
|
|
93
|
-
tok.type === T.EVERY || tok.type === T.WATCH ||
|
|
94
|
-
tok.type === T.STATIC || tok.type === T.WS ||
|
|
95
|
-
tok.type === T.GROUP || tok.type === T.COOKIE) {
|
|
114
|
+
if (this.isIdentLike(tok.type)) {
|
|
96
115
|
return tok.value;
|
|
97
116
|
}
|
|
98
117
|
throw this.error(`Expected property name but got ${tok.type} ('${tok.value}')`, tok);
|
|
@@ -186,6 +205,18 @@ export class Parser {
|
|
|
186
205
|
if (this.peek(1).type === T.DOT) return this.parseExpressionStatement();
|
|
187
206
|
this.advance();
|
|
188
207
|
return new ASTNode('CookieDecl');
|
|
208
|
+
case T.VALIDATE:
|
|
209
|
+
if (this.peek(1).type === T.DOT) return this.parseExpressionStatement();
|
|
210
|
+
return this.parseValidate();
|
|
211
|
+
case T.TEST:
|
|
212
|
+
if (this.peek(1).type === T.DOT) return this.parseExpressionStatement();
|
|
213
|
+
return this.parseTest();
|
|
214
|
+
case T.ASSERT:
|
|
215
|
+
if (this.peek(1).type === T.DOT) return this.parseExpressionStatement();
|
|
216
|
+
return this.parseAssert();
|
|
217
|
+
case T.QUEUE:
|
|
218
|
+
if (this.peek(1).type === T.DOT) return this.parseExpressionStatement();
|
|
219
|
+
return this.parseQueue();
|
|
189
220
|
default:
|
|
190
221
|
if (TYPE_TOKENS.has(tok.type)) {
|
|
191
222
|
return this.parseTypedVariable();
|
|
@@ -237,7 +268,11 @@ export class Parser {
|
|
|
237
268
|
|
|
238
269
|
parseFunction(isAsync, isPublic) {
|
|
239
270
|
this.advance(); // fn or fn.async
|
|
240
|
-
const
|
|
271
|
+
const tok = this.advance();
|
|
272
|
+
if (!this.isIdentLike(tok.type)) {
|
|
273
|
+
throw this.error(`Expected function name but got ${tok.type} ('${tok.value}')`, tok);
|
|
274
|
+
}
|
|
275
|
+
const name = tok.value;
|
|
241
276
|
this.expect(T.LPAREN);
|
|
242
277
|
const params = this.parseFnParams();
|
|
243
278
|
this.expect(T.RPAREN);
|
|
@@ -328,6 +363,14 @@ export class Parser {
|
|
|
328
363
|
const body = this.parseExpression();
|
|
329
364
|
return new ASTNode('ReturnStatus', { method, statusCode, body });
|
|
330
365
|
}
|
|
366
|
+
if (method === 'render') {
|
|
367
|
+
const template = this.parseExpression();
|
|
368
|
+
let data = null;
|
|
369
|
+
if (!this.at(T.NEWLINE) && !this.at(T.EOF) && !this.at(T.DEDENT)) {
|
|
370
|
+
data = this.parseExpression();
|
|
371
|
+
}
|
|
372
|
+
return new ASTNode('ReturnRender', { template, data });
|
|
373
|
+
}
|
|
331
374
|
const value = this.parseExpression();
|
|
332
375
|
return new ASTNode('ReturnMethod', { method, value });
|
|
333
376
|
}
|
|
@@ -499,10 +542,15 @@ export class Parser {
|
|
|
499
542
|
this.skipNewlines();
|
|
500
543
|
|
|
501
544
|
while (!this.at(T.DEDENT) && !this.at(T.EOF)) {
|
|
502
|
-
if (this.atAny(T.GET, T.POST, T.PUT, T.DEL)) {
|
|
545
|
+
if (this.atAny(T.GET, T.POST, T.PUT, T.DEL, T.PATCH)) {
|
|
503
546
|
routes.push(this.parseRoute());
|
|
504
547
|
} else if (this.at(T.MID)) {
|
|
505
|
-
|
|
548
|
+
const mid = this.parseMiddleware();
|
|
549
|
+
if (mid.type === 'MiddlewareRef') {
|
|
550
|
+
routes.push(mid);
|
|
551
|
+
} else {
|
|
552
|
+
middleware.push(mid);
|
|
553
|
+
}
|
|
506
554
|
} else if (this.at(T.CRUD)) {
|
|
507
555
|
routes.push(this.parseCrud());
|
|
508
556
|
} else if (this.at(T.AUTH)) {
|
|
@@ -520,6 +568,22 @@ export class Parser {
|
|
|
520
568
|
} else if (this.at(T.COOKIE)) {
|
|
521
569
|
this.advance();
|
|
522
570
|
routes.push(new ASTNode('CookieDecl'));
|
|
571
|
+
} else if (this.at(T.UPLOAD)) {
|
|
572
|
+
routes.push(this.parseUpload());
|
|
573
|
+
} else if (this.at(T.SESSION)) {
|
|
574
|
+
routes.push(this.parseSession());
|
|
575
|
+
} else if (this.at(T.VIEW)) {
|
|
576
|
+
routes.push(this.parseView());
|
|
577
|
+
} else if (this.at(T.SSE)) {
|
|
578
|
+
routes.push(this.parseSse());
|
|
579
|
+
} else if (this.at(T.CACHE)) {
|
|
580
|
+
routes.push(this.parseCache());
|
|
581
|
+
} else if (this.at(T.VALIDATE)) {
|
|
582
|
+
routes.push(this.parseValidate());
|
|
583
|
+
} else if (this.at(T.OPENAPI)) {
|
|
584
|
+
routes.push(this.parseOpenapi());
|
|
585
|
+
} else if (this.at(T.QUEUE)) {
|
|
586
|
+
routes.push(this.parseQueue());
|
|
523
587
|
} else if (this.at(T.IDENT) && this.peek().value === 'error') {
|
|
524
588
|
routes.push(this.parseErrorHandler());
|
|
525
589
|
} else {
|
|
@@ -559,16 +623,23 @@ export class Parser {
|
|
|
559
623
|
parseMiddleware() {
|
|
560
624
|
this.advance(); // mid
|
|
561
625
|
const name = this.expect(T.IDENT).value;
|
|
562
|
-
this.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
626
|
+
if (this.at(T.LPAREN)) {
|
|
627
|
+
this.advance();
|
|
628
|
+
const params = [];
|
|
629
|
+
while (!this.at(T.RPAREN) && !this.at(T.EOF)) {
|
|
630
|
+
params.push(this.expect(T.IDENT).value);
|
|
631
|
+
this.match(T.COMMA);
|
|
632
|
+
}
|
|
633
|
+
this.expect(T.RPAREN);
|
|
634
|
+
this.expect(T.COLON);
|
|
635
|
+
const body = this.parseBlock();
|
|
636
|
+
return new ASTNode('Middleware', { name, params, body });
|
|
567
637
|
}
|
|
568
|
-
|
|
569
|
-
this.
|
|
570
|
-
|
|
571
|
-
|
|
638
|
+
let path = null;
|
|
639
|
+
if (this.at(T.STRING)) {
|
|
640
|
+
path = this.parseString();
|
|
641
|
+
}
|
|
642
|
+
return new ASTNode('MiddlewareRef', { name, path });
|
|
572
643
|
}
|
|
573
644
|
|
|
574
645
|
parseModel(isPublic = false) {
|
|
@@ -726,6 +797,18 @@ export class Parser {
|
|
|
726
797
|
}
|
|
727
798
|
|
|
728
799
|
parseTernary() {
|
|
800
|
+
if (this.at(T.IF)) {
|
|
801
|
+
const savedPos = this.pos;
|
|
802
|
+
this.advance();
|
|
803
|
+
const condition = this.parseNullish();
|
|
804
|
+
if (this.match(T.THEN)) {
|
|
805
|
+
const consequent = this.parseNullish();
|
|
806
|
+
this.expect(T.ELSE);
|
|
807
|
+
const alternate = this.parseNullish();
|
|
808
|
+
return new ASTNode('Ternary', { condition, consequent, alternate });
|
|
809
|
+
}
|
|
810
|
+
this.pos = savedPos;
|
|
811
|
+
}
|
|
729
812
|
let expr = this.parseNullish();
|
|
730
813
|
if (this.at(T.IF)) {
|
|
731
814
|
const savedPos = this.pos;
|
|
@@ -906,6 +989,11 @@ export class Parser {
|
|
|
906
989
|
case T.SCHEMA: case T.CRUD: case T.AUTH: case T.CORS:
|
|
907
990
|
case T.LIMIT: case T.ENV: case T.EVERY: case T.WATCH:
|
|
908
991
|
case T.STATIC: case T.WS: case T.GROUP: case T.COOKIE:
|
|
992
|
+
case T.UPLOAD: case T.SESSION: case T.VIEW: case T.SSE:
|
|
993
|
+
case T.CACHE: case T.PATCH: case T.MID:
|
|
994
|
+
case T.VALIDATE: case T.TEST: case T.ASSERT:
|
|
995
|
+
case T.QUEUE: case T.JOB: case T.OPENAPI:
|
|
996
|
+
case T.FROM: case T.AS: case T.IN:
|
|
909
997
|
this.advance();
|
|
910
998
|
return new ASTNode('Identifier', { name: tok.value });
|
|
911
999
|
|
|
@@ -1344,7 +1432,7 @@ export class Parser {
|
|
|
1344
1432
|
this.skipNewlines();
|
|
1345
1433
|
|
|
1346
1434
|
while (!this.at(T.DEDENT) && !this.at(T.EOF)) {
|
|
1347
|
-
if (this.atAny(T.GET, T.POST, T.PUT, T.DEL)) {
|
|
1435
|
+
if (this.atAny(T.GET, T.POST, T.PUT, T.DEL, T.PATCH)) {
|
|
1348
1436
|
routes.push(this.parseRoute());
|
|
1349
1437
|
} else if (this.at(T.MID)) {
|
|
1350
1438
|
routes.push(this.parseMiddleware());
|
|
@@ -1379,4 +1467,108 @@ export class Parser {
|
|
|
1379
1467
|
const body = this.parseBlock();
|
|
1380
1468
|
return new ASTNode('ErrorHandler', { params, body });
|
|
1381
1469
|
}
|
|
1470
|
+
|
|
1471
|
+
parseUpload() {
|
|
1472
|
+
this.advance(); // upload
|
|
1473
|
+
const path = this.parseString();
|
|
1474
|
+
const fieldName = this.parseString();
|
|
1475
|
+
let params = [];
|
|
1476
|
+
if (this.match(T.LPAREN)) {
|
|
1477
|
+
while (!this.at(T.RPAREN) && !this.at(T.EOF)) {
|
|
1478
|
+
params.push(this.expect(T.IDENT).value);
|
|
1479
|
+
this.match(T.COMMA);
|
|
1480
|
+
}
|
|
1481
|
+
this.expect(T.RPAREN);
|
|
1482
|
+
}
|
|
1483
|
+
this.expect(T.COLON);
|
|
1484
|
+
const body = this.parseBlock();
|
|
1485
|
+
return new ASTNode('UploadDecl', { path, fieldName, params, body });
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
parseSession() {
|
|
1489
|
+
this.advance(); // session
|
|
1490
|
+
const secret = this.parseExpression();
|
|
1491
|
+
return new ASTNode('SessionDecl', { secret });
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
parseView() {
|
|
1495
|
+
this.advance(); // view
|
|
1496
|
+
const dir = this.parseString();
|
|
1497
|
+
return new ASTNode('ViewDecl', { dir });
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
parseSse() {
|
|
1501
|
+
this.advance(); // sse
|
|
1502
|
+
const path = this.parseString();
|
|
1503
|
+
return new ASTNode('SseDecl', { path });
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
parseCache() {
|
|
1507
|
+
this.advance(); // cache
|
|
1508
|
+
const path = this.parseString();
|
|
1509
|
+
const duration = this.parseExpression();
|
|
1510
|
+
return new ASTNode('CacheDecl', { path, duration });
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
parseValidate() {
|
|
1514
|
+
this.advance(); // validate
|
|
1515
|
+
const path = this.parseString();
|
|
1516
|
+
const schemaName = this.expect(T.IDENT).value;
|
|
1517
|
+
return new ASTNode('ValidateDecl', { path, schemaName });
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
parseTest() {
|
|
1521
|
+
this.advance(); // test
|
|
1522
|
+
const name = this.parseExpression();
|
|
1523
|
+
this.expect(T.COLON);
|
|
1524
|
+
const body = this.parseBlock();
|
|
1525
|
+
return new ASTNode('TestDecl', { name, body });
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
parseAssert() {
|
|
1529
|
+
this.advance(); // assert
|
|
1530
|
+
const expr = this.parseExpression();
|
|
1531
|
+
return new ASTNode('AssertStmt', { expr });
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
parseQueue() {
|
|
1535
|
+
this.advance(); // queue
|
|
1536
|
+
const name = this.expect(T.IDENT).value;
|
|
1537
|
+
this.expect(T.COLON);
|
|
1538
|
+
this.skipNewlines();
|
|
1539
|
+
this.expect(T.INDENT);
|
|
1540
|
+
|
|
1541
|
+
const jobs = [];
|
|
1542
|
+
this.skipNewlines();
|
|
1543
|
+
|
|
1544
|
+
while (!this.at(T.DEDENT) && !this.at(T.EOF)) {
|
|
1545
|
+
if (this.at(T.JOB)) {
|
|
1546
|
+
this.advance();
|
|
1547
|
+
const jobName = this.parseString();
|
|
1548
|
+
let params = [];
|
|
1549
|
+
if (this.match(T.LPAREN)) {
|
|
1550
|
+
while (!this.at(T.RPAREN) && !this.at(T.EOF)) {
|
|
1551
|
+
params.push(this.expect(T.IDENT).value);
|
|
1552
|
+
this.match(T.COMMA);
|
|
1553
|
+
}
|
|
1554
|
+
this.expect(T.RPAREN);
|
|
1555
|
+
}
|
|
1556
|
+
this.expect(T.COLON);
|
|
1557
|
+
const body = this.parseBlock();
|
|
1558
|
+
jobs.push({ name: jobName, params, body });
|
|
1559
|
+
} else {
|
|
1560
|
+
this.advance();
|
|
1561
|
+
}
|
|
1562
|
+
this.skipNewlines();
|
|
1563
|
+
}
|
|
1564
|
+
this.match(T.DEDENT);
|
|
1565
|
+
|
|
1566
|
+
return new ASTNode('QueueDecl', { name, jobs });
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
parseOpenapi() {
|
|
1570
|
+
this.advance(); // openapi
|
|
1571
|
+
const path = this.parseString();
|
|
1572
|
+
return new ASTNode('OpenapiDecl', { path });
|
|
1573
|
+
}
|
|
1382
1574
|
}
|
package/src/preprocess.js
CHANGED
|
@@ -115,6 +115,8 @@ export function preprocess(source) {
|
|
|
115
115
|
out = 'ret.text ' + transformContent(rest.slice(3));
|
|
116
116
|
} else if (rest.startsWith('.f ')) {
|
|
117
117
|
out = 'ret.file ' + transformContent(rest.slice(3));
|
|
118
|
+
} else if (rest.startsWith('.v ')) {
|
|
119
|
+
out = 'ret.render ' + transformContent(rest.slice(3));
|
|
118
120
|
} else {
|
|
119
121
|
out = rest ? 'ret ' + transformContent(rest) : 'ret';
|
|
120
122
|
}
|
|
@@ -179,6 +181,7 @@ export function preprocess(source) {
|
|
|
179
181
|
else if (first === 'P' && second === '"') { out = transformRoute('post', line.slice(1)); }
|
|
180
182
|
else if (first === 'U' && second === '"') { out = transformRoute('put', line.slice(1)); }
|
|
181
183
|
else if (first === 'D' && second === '"') { out = transformRoute('del', line.slice(1)); }
|
|
184
|
+
else if (first === 'X' && second === '"') { out = transformRoute('patch', line.slice(1)); }
|
|
182
185
|
// ^ model
|
|
183
186
|
else if (first === '^') {
|
|
184
187
|
const rest = line.slice(1).trim();
|
package/src/runtime.js
CHANGED
|
@@ -408,6 +408,264 @@ export function cookieParser() {
|
|
|
408
408
|
};
|
|
409
409
|
}
|
|
410
410
|
|
|
411
|
+
// ===== Session Middleware (zero-dep, cookie-based) =====
|
|
412
|
+
export function sessionMiddleware(secret) {
|
|
413
|
+
const store = new Map();
|
|
414
|
+
const maxAge = 86400000;
|
|
415
|
+
return (req, res, next) => {
|
|
416
|
+
const cookies = req.headers.cookie || '';
|
|
417
|
+
const sidMatch = cookies.match(/(?:^|;\s*)naide_sid=([^;]+)/);
|
|
418
|
+
let sid = sidMatch ? sidMatch[1] : null;
|
|
419
|
+
if (!sid || !store.has(sid)) {
|
|
420
|
+
sid = randomUUID();
|
|
421
|
+
store.set(sid, {});
|
|
422
|
+
}
|
|
423
|
+
req.session = store.get(sid);
|
|
424
|
+
req.sessionId = sid;
|
|
425
|
+
req.session.destroy = () => { store.delete(sid); };
|
|
426
|
+
const origWriteHead = res.writeHead;
|
|
427
|
+
res.writeHead = function(...args) {
|
|
428
|
+
res.setHeader('Set-Cookie', `naide_sid=${sid}; HttpOnly; Path=/; Max-Age=${maxAge / 1000}; SameSite=Lax`);
|
|
429
|
+
origWriteHead.apply(res, args);
|
|
430
|
+
};
|
|
431
|
+
for (const [id, data] of store) {
|
|
432
|
+
if (data.__ts && Date.now() - data.__ts > maxAge) store.delete(id);
|
|
433
|
+
}
|
|
434
|
+
req.session.__ts = Date.now();
|
|
435
|
+
next();
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ===== Template Renderer (zero-dep) =====
|
|
440
|
+
export function createRenderer(viewsDir) {
|
|
441
|
+
return (name, data = {}) => {
|
|
442
|
+
const filePath = viewsDir.replace(/\/$/, '') + '/' + name + (name.includes('.') ? '' : '.html');
|
|
443
|
+
let template = readFileSync(filePath, 'utf-8');
|
|
444
|
+
template = template.replace(/\{\{\s*each\s+(\w+)\s+in\s+(\w+)\s*\}\}([\s\S]*?)\{\{\s*\/each\s*\}\}/g, (_, item, list, block) => {
|
|
445
|
+
const arr = data[list] || [];
|
|
446
|
+
return arr.map(val => block.replace(new RegExp(`\\{\\{\\s*${item}\\b[^}]*\\}\\}`, 'g'), m => {
|
|
447
|
+
const prop = m.match(/\{\{\s*\w+\.(\w+)\s*\}\}/);
|
|
448
|
+
return prop ? String(val[prop[1]] ?? '') : String(val ?? '');
|
|
449
|
+
})).join('');
|
|
450
|
+
});
|
|
451
|
+
template = template.replace(/\{\{\s*if\s+(\w+)\s*\}\}([\s\S]*?)\{\{\s*\/if\s*\}\}/g, (_, key, block) => data[key] ? block : '');
|
|
452
|
+
for (const [key, value] of Object.entries(data)) {
|
|
453
|
+
template = template.replace(new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, 'g'), String(value ?? ''));
|
|
454
|
+
}
|
|
455
|
+
return template;
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ===== Cache Middleware (zero-dep) =====
|
|
460
|
+
export function cacheMiddleware(duration) {
|
|
461
|
+
const maxAge = parseMs(duration);
|
|
462
|
+
const cache = new Map();
|
|
463
|
+
return (req, res, next) => {
|
|
464
|
+
if (req.method !== 'GET') return next();
|
|
465
|
+
const key = req.originalUrl || req.url;
|
|
466
|
+
const cached = cache.get(key);
|
|
467
|
+
if (cached && Date.now() - cached.time < maxAge) {
|
|
468
|
+
res.setHeader('X-Cache', 'HIT');
|
|
469
|
+
const ct = cached.contentType || 'application/json';
|
|
470
|
+
res.setHeader('Content-Type', ct);
|
|
471
|
+
return res.end(cached.body);
|
|
472
|
+
}
|
|
473
|
+
const origEnd = res.end;
|
|
474
|
+
const origJson = res.json ? res.json.bind(res) : null;
|
|
475
|
+
if (origJson) {
|
|
476
|
+
res.json = (data) => {
|
|
477
|
+
cache.set(key, { body: JSON.stringify(data), contentType: 'application/json', time: Date.now() });
|
|
478
|
+
res.setHeader('X-Cache', 'MISS');
|
|
479
|
+
origJson(data);
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
const origSend = res.send ? res.send.bind(res) : null;
|
|
483
|
+
if (origSend) {
|
|
484
|
+
res.send = (body) => {
|
|
485
|
+
cache.set(key, { body: String(body), contentType: res.getHeader('content-type'), time: Date.now() });
|
|
486
|
+
res.setHeader('X-Cache', 'MISS');
|
|
487
|
+
origSend(body);
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
next();
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ===== Upload Middleware (zero-dep multipart parser) =====
|
|
495
|
+
export function uploadMiddleware(fieldName, opts = {}) {
|
|
496
|
+
const maxSize = opts.maxSize || 10 * 1024 * 1024;
|
|
497
|
+
return (req, res, next) => {
|
|
498
|
+
const contentType = req.headers['content-type'] || '';
|
|
499
|
+
if (!contentType.startsWith('multipart/form-data')) return next();
|
|
500
|
+
const boundaryMatch = contentType.match(/boundary=(?:"([^"]+)"|([^\s;]+))/);
|
|
501
|
+
if (!boundaryMatch) return next();
|
|
502
|
+
const boundary = boundaryMatch[1] || boundaryMatch[2];
|
|
503
|
+
const chunks = [];
|
|
504
|
+
let size = 0;
|
|
505
|
+
req.on('data', chunk => {
|
|
506
|
+
size += chunk.length;
|
|
507
|
+
if (size > maxSize) { req.destroy(); return res.status(413).json({ error: 'File too large' }); }
|
|
508
|
+
chunks.push(chunk);
|
|
509
|
+
});
|
|
510
|
+
req.on('end', () => {
|
|
511
|
+
try {
|
|
512
|
+
const buffer = Buffer.concat(chunks);
|
|
513
|
+
const { files, fields } = _parseMultipart(buffer, boundary);
|
|
514
|
+
req.files = files;
|
|
515
|
+
req.file = files[fieldName] || files[Object.keys(files)[0]] || null;
|
|
516
|
+
if (!req.body) req.body = {};
|
|
517
|
+
Object.assign(req.body, fields);
|
|
518
|
+
next();
|
|
519
|
+
} catch { res.status(400).json({ error: 'Invalid multipart data' }); }
|
|
520
|
+
});
|
|
521
|
+
req.on('error', () => res.status(400).json({ error: 'Upload failed' }));
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function _parseMultipart(buffer, boundary) {
|
|
526
|
+
const files = {}, fields = {};
|
|
527
|
+
const delim = Buffer.from(`--${boundary}`);
|
|
528
|
+
let start = buffer.indexOf(delim);
|
|
529
|
+
if (start === -1) return { files, fields };
|
|
530
|
+
start += delim.length + 2;
|
|
531
|
+
const endDelim = Buffer.from(`--${boundary}--`);
|
|
532
|
+
while (start < buffer.length) {
|
|
533
|
+
let end = buffer.indexOf(delim, start);
|
|
534
|
+
if (end === -1) break;
|
|
535
|
+
const part = buffer.slice(start, end - 2);
|
|
536
|
+
const headerEnd = part.indexOf('\r\n\r\n');
|
|
537
|
+
if (headerEnd !== -1) {
|
|
538
|
+
const header = part.slice(0, headerEnd).toString('utf-8');
|
|
539
|
+
const body = part.slice(headerEnd + 4);
|
|
540
|
+
const nameMatch = header.match(/name="([^"]+)"/);
|
|
541
|
+
if (nameMatch) {
|
|
542
|
+
const filenameMatch = header.match(/filename="([^"]+)"/);
|
|
543
|
+
if (filenameMatch) {
|
|
544
|
+
const ctMatch = header.match(/Content-Type:\s*(.+)/i);
|
|
545
|
+
files[nameMatch[1]] = { filename: filenameMatch[1], contentType: ctMatch ? ctMatch[1].trim() : 'application/octet-stream', data: body, size: body.length };
|
|
546
|
+
} else {
|
|
547
|
+
fields[nameMatch[1]] = body.toString('utf-8');
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
start = end + delim.length;
|
|
552
|
+
if (buffer.slice(end, end + endDelim.length).equals(endDelim)) break;
|
|
553
|
+
start += 2;
|
|
554
|
+
}
|
|
555
|
+
return { files, fields };
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// ===== SSE Manager (zero-dep) =====
|
|
559
|
+
export function createSseManager() {
|
|
560
|
+
const clients = new Set();
|
|
561
|
+
return {
|
|
562
|
+
handler() {
|
|
563
|
+
return (req, res) => {
|
|
564
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*' });
|
|
565
|
+
res.write(':\n\n');
|
|
566
|
+
const client = { res, id: randomUUID() };
|
|
567
|
+
clients.add(client);
|
|
568
|
+
req.on('close', () => clients.delete(client));
|
|
569
|
+
};
|
|
570
|
+
},
|
|
571
|
+
send(data, event) {
|
|
572
|
+
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
|
573
|
+
for (const client of clients) {
|
|
574
|
+
if (event) client.res.write(`event: ${event}\n`);
|
|
575
|
+
client.res.write(`data: ${payload}\n\n`);
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
broadcast(data, event) { this.send(data, event); },
|
|
579
|
+
get count() { return clients.size; }
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ===== Validate Middleware =====
|
|
584
|
+
export function validateMiddleware(schema) {
|
|
585
|
+
return (req, res, next) => {
|
|
586
|
+
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
|
|
587
|
+
const result = schema.validate(req.body || {});
|
|
588
|
+
if (!result.valid) {
|
|
589
|
+
return res.status(400).json({ errors: result.errors });
|
|
590
|
+
}
|
|
591
|
+
req.body = result.data;
|
|
592
|
+
}
|
|
593
|
+
next();
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ===== Job Queue (in-memory async) =====
|
|
598
|
+
export function createQueue() {
|
|
599
|
+
const handlers = new Map();
|
|
600
|
+
const pending = [];
|
|
601
|
+
let running = false;
|
|
602
|
+
|
|
603
|
+
async function process() {
|
|
604
|
+
if (running) return;
|
|
605
|
+
running = true;
|
|
606
|
+
while (pending.length > 0) {
|
|
607
|
+
const { name, data, resolve, reject } = pending.shift();
|
|
608
|
+
const handler = handlers.get(name);
|
|
609
|
+
if (handler) {
|
|
610
|
+
try { await handler(data); resolve(); } catch (e) { console.error('[NAIDE Queue]', e.message); reject(e); }
|
|
611
|
+
} else {
|
|
612
|
+
reject(new Error(`No handler for job: ${name}`));
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
running = false;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
register(name, fn) { handlers.set(name, fn); },
|
|
620
|
+
add(name, data) {
|
|
621
|
+
return new Promise((resolve, reject) => {
|
|
622
|
+
pending.push({ name, data, resolve, reject });
|
|
623
|
+
process();
|
|
624
|
+
});
|
|
625
|
+
},
|
|
626
|
+
get size() { return pending.length; }
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ===== OpenAPI Spec Builder =====
|
|
631
|
+
export function buildOpenApiSpec(schemas) {
|
|
632
|
+
const spec = {
|
|
633
|
+
openapi: '3.1.0',
|
|
634
|
+
info: { title: 'API', version: '1.0.0' },
|
|
635
|
+
paths: {},
|
|
636
|
+
components: { schemas: {} }
|
|
637
|
+
};
|
|
638
|
+
for (const schema of schemas) {
|
|
639
|
+
const properties = {};
|
|
640
|
+
const required = [];
|
|
641
|
+
for (const [fieldName, fieldDef] of Object.entries(schema.fields)) {
|
|
642
|
+
const prop = {};
|
|
643
|
+
switch (fieldDef.type) {
|
|
644
|
+
case 'id': prop.type = 'string'; prop.format = 'uuid'; break;
|
|
645
|
+
case 'string': prop.type = 'string'; break;
|
|
646
|
+
case 'integer': prop.type = 'integer'; break;
|
|
647
|
+
case 'number': prop.type = 'number'; break;
|
|
648
|
+
case 'boolean': prop.type = 'boolean'; break;
|
|
649
|
+
case 'timestamp': prop.type = 'string'; prop.format = 'date-time'; break;
|
|
650
|
+
case 'enum':
|
|
651
|
+
prop.type = 'string';
|
|
652
|
+
if (fieldDef.values) prop.enum = fieldDef.values;
|
|
653
|
+
break;
|
|
654
|
+
default: prop.type = 'string';
|
|
655
|
+
}
|
|
656
|
+
if (fieldDef.min !== undefined) prop.minimum = fieldDef.min;
|
|
657
|
+
if (fieldDef.max !== undefined) prop.maximum = fieldDef.max;
|
|
658
|
+
if (fieldDef.email) prop.format = 'email';
|
|
659
|
+
if (fieldDef.url) prop.format = 'uri';
|
|
660
|
+
properties[fieldName] = prop;
|
|
661
|
+
if (fieldDef.required) required.push(fieldName);
|
|
662
|
+
}
|
|
663
|
+
spec.components.schemas[schema.name] = { type: 'object', properties };
|
|
664
|
+
if (required.length > 0) spec.components.schemas[schema.name].required = required;
|
|
665
|
+
}
|
|
666
|
+
return spec;
|
|
667
|
+
}
|
|
668
|
+
|
|
411
669
|
// ===== Helpers =====
|
|
412
670
|
function parseMs(str) {
|
|
413
671
|
if (typeof str === 'number') return str;
|
package/src/tokens.js
CHANGED
|
@@ -69,6 +69,18 @@ export const T = {
|
|
|
69
69
|
WS: 'WS',
|
|
70
70
|
GROUP: 'GROUP',
|
|
71
71
|
COOKIE: 'COOKIE',
|
|
72
|
+
UPLOAD: 'UPLOAD',
|
|
73
|
+
SESSION: 'SESSION',
|
|
74
|
+
VIEW: 'VIEW',
|
|
75
|
+
SSE: 'SSE',
|
|
76
|
+
CACHE: 'CACHE',
|
|
77
|
+
PATCH: 'PATCH',
|
|
78
|
+
VALIDATE: 'VALIDATE',
|
|
79
|
+
TEST: 'TEST',
|
|
80
|
+
ASSERT: 'ASSERT',
|
|
81
|
+
QUEUE: 'QUEUE',
|
|
82
|
+
JOB: 'JOB',
|
|
83
|
+
OPENAPI: 'OPENAPI',
|
|
72
84
|
|
|
73
85
|
// Operators
|
|
74
86
|
ASSIGN: 'ASSIGN',
|
|
@@ -166,6 +178,18 @@ export const KEYWORDS = {
|
|
|
166
178
|
'ws': T.WS,
|
|
167
179
|
'group': T.GROUP,
|
|
168
180
|
'cookie': T.COOKIE,
|
|
181
|
+
'upload': T.UPLOAD,
|
|
182
|
+
'session': T.SESSION,
|
|
183
|
+
'view': T.VIEW,
|
|
184
|
+
'sse': T.SSE,
|
|
185
|
+
'cache': T.CACHE,
|
|
186
|
+
'patch': T.PATCH,
|
|
187
|
+
'validate': T.VALIDATE,
|
|
188
|
+
'test': T.TEST,
|
|
189
|
+
'assert': T.ASSERT,
|
|
190
|
+
'queue': T.QUEUE,
|
|
191
|
+
'job': T.JOB,
|
|
192
|
+
'openapi': T.OPENAPI,
|
|
169
193
|
'true': T.BOOL,
|
|
170
194
|
'false': T.BOOL,
|
|
171
195
|
'null': T.NULL,
|