naidejs 1.0.0 → 1.1.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 +120 -3
- package/bin/naide.js +4 -1
- package/examples/fullapp.naide +37 -0
- package/examples/fullapp.nx +37 -0
- package/package.json +5 -1
- package/src/generator.js +248 -17
- package/src/index.js +2 -2
- package/src/parser.js +252 -2
- package/src/runtime.js +267 -0
- package/src/tokens.js +16 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Two modes:
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
npm install -g
|
|
12
|
+
npm install -g naidejs
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
## Usage
|
|
@@ -29,6 +29,117 @@ naide -o app.js app.nx
|
|
|
29
29
|
naide --mid app.nx
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
## High-Level Features
|
|
33
|
+
|
|
34
|
+
NAIDE includes built-in declarations for common backend patterns. Zero dependencies — the runtime is bundled with the package.
|
|
35
|
+
|
|
36
|
+
### schema — Data models with validation
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
schema User:
|
|
40
|
+
id auto
|
|
41
|
+
name str required min(2) max(50)
|
|
42
|
+
email str required email unique
|
|
43
|
+
age int optional min(0) max(150)
|
|
44
|
+
role enum("admin", "user") default("user")
|
|
45
|
+
joined timestamp auto
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Types: `str`, `int`, `num`, `bool`, `auto` (UUID), `timestamp`, `enum(...)`
|
|
49
|
+
Modifiers: `required`, `optional`, `min(n)`, `max(n)`, `email`, `url`, `unique`, `auto`, `default(val)`
|
|
50
|
+
|
|
51
|
+
### crud — Auto-generate REST endpoints
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
server app port 3000:
|
|
55
|
+
crud "/api/users" User
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Generates GET (list + by ID), POST, PUT, DELETE routes with validation.
|
|
59
|
+
|
|
60
|
+
### auth — JWT authentication
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
server app port 3000:
|
|
64
|
+
auth JWT_SECRET:
|
|
65
|
+
protect "/api/*"
|
|
66
|
+
public "/api/auth/*"
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Built-in JWT sign/verify with no external dependencies.
|
|
70
|
+
|
|
71
|
+
### cors — CORS middleware
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
server app port 3000:
|
|
75
|
+
cors "*"
|
|
76
|
+
# or: cors ["localhost:3000", "myapp.com"]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### limit — Rate limiting
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
server app port 3000:
|
|
83
|
+
limit "/api/*" 100 "1m"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### env — Environment variables with validation
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
env:
|
|
90
|
+
PORT int default(3000)
|
|
91
|
+
JWT_SECRET str required
|
|
92
|
+
DB_URL str default("sqlite:data.db")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Variables become constants available throughout the file.
|
|
96
|
+
|
|
97
|
+
### every — Scheduled tasks
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
every "5m":
|
|
101
|
+
log "cleanup running"
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Intervals: `"30s"`, `"5m"`, `"1h"`, `"1d"`
|
|
105
|
+
|
|
106
|
+
### watch — React to events
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
watch User.create (event):
|
|
110
|
+
log "new user: {event.data.name}"
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Automatically connected to `crud` events.
|
|
114
|
+
|
|
115
|
+
### Full example
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
env:
|
|
119
|
+
PORT int default(3000)
|
|
120
|
+
JWT_SECRET str required
|
|
121
|
+
|
|
122
|
+
schema User:
|
|
123
|
+
id auto
|
|
124
|
+
name str required min(2) max(50)
|
|
125
|
+
email str required email
|
|
126
|
+
|
|
127
|
+
server app port PORT:
|
|
128
|
+
cors "*"
|
|
129
|
+
auth JWT_SECRET:
|
|
130
|
+
protect "/api/*"
|
|
131
|
+
public "/api/auth/*"
|
|
132
|
+
limit "/api/*" 100 "1m"
|
|
133
|
+
crud "/api/users" User
|
|
134
|
+
get "/health":
|
|
135
|
+
ret {status: "ok"}
|
|
136
|
+
|
|
137
|
+
watch User.create (event):
|
|
138
|
+
log "new user: {event.data.name}"
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This generates a complete Express API with validation, auth, CORS, rate limiting, and CRUD — from 20 lines.
|
|
142
|
+
|
|
32
143
|
## NAIDE syntax (.naide)
|
|
33
144
|
|
|
34
145
|
```python
|
|
@@ -37,7 +148,7 @@ str name = "World"
|
|
|
37
148
|
int count = 3
|
|
38
149
|
mut int counter = 0
|
|
39
150
|
|
|
40
|
-
# Functions
|
|
151
|
+
# Functions
|
|
41
152
|
fn greet(str who) -> str:
|
|
42
153
|
ret "Hello, {who}!"
|
|
43
154
|
|
|
@@ -118,8 +229,12 @@ f greet(s:who)s
|
|
|
118
229
|
@i<0..10
|
|
119
230
|
log i
|
|
120
231
|
|
|
121
|
-
-- Server
|
|
232
|
+
-- Server with high-level features
|
|
122
233
|
$app:3000
|
|
234
|
+
cors "*"
|
|
235
|
+
auth SECRET:
|
|
236
|
+
protect "/api/*"
|
|
237
|
+
crud "/api/users" User
|
|
123
238
|
G"/users"
|
|
124
239
|
>users
|
|
125
240
|
P"/users"(req,res)
|
|
@@ -156,6 +271,8 @@ $app:3000
|
|
|
156
271
|
|
|
157
272
|
Types: `s`=str `i`=int `n`=num `b`=bool `l`=list `m`=map `a`=any
|
|
158
273
|
|
|
274
|
+
High-level: `schema`, `crud`, `auth`, `cors`, `limit`, `env`, `every`, `watch` — same syntax in both modes.
|
|
275
|
+
|
|
159
276
|
## Why?
|
|
160
277
|
|
|
161
278
|
AI code generation speed depends on:
|
package/bin/naide.js
CHANGED
|
@@ -61,6 +61,8 @@ if (flags.help || files.length === 0) {
|
|
|
61
61
|
process.exit(0);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
const runtimeUrl = new URL('../src/runtime.js', import.meta.url).href;
|
|
65
|
+
|
|
64
66
|
for (const file of files) {
|
|
65
67
|
const filePath = resolve(file);
|
|
66
68
|
let source;
|
|
@@ -81,7 +83,8 @@ for (const file of files) {
|
|
|
81
83
|
continue;
|
|
82
84
|
}
|
|
83
85
|
|
|
84
|
-
const
|
|
86
|
+
const runtimePath = flags.emit || flags.output ? 'naidejs/runtime' : runtimeUrl;
|
|
87
|
+
const result = compile(source, { mode, runtimePath });
|
|
85
88
|
|
|
86
89
|
if (flags.tokens) {
|
|
87
90
|
console.log(JSON.stringify(result.tokens, null, 2));
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Full NAIDE app with high-level features
|
|
2
|
+
|
|
3
|
+
env:
|
|
4
|
+
PORT int default(3000)
|
|
5
|
+
JWT_SECRET str required
|
|
6
|
+
|
|
7
|
+
schema User:
|
|
8
|
+
id auto
|
|
9
|
+
name str required min(2) max(50)
|
|
10
|
+
email str required email
|
|
11
|
+
role str default("user")
|
|
12
|
+
|
|
13
|
+
schema Todo:
|
|
14
|
+
id auto
|
|
15
|
+
title str required min(1)
|
|
16
|
+
done bool default(false)
|
|
17
|
+
created timestamp auto
|
|
18
|
+
|
|
19
|
+
server app port PORT:
|
|
20
|
+
cors "*"
|
|
21
|
+
auth JWT_SECRET:
|
|
22
|
+
protect "/api/*"
|
|
23
|
+
public "/api/auth/*"
|
|
24
|
+
limit "/api/*" 100 "1m"
|
|
25
|
+
crud "/api/users" User
|
|
26
|
+
crud "/api/todos" Todo
|
|
27
|
+
get "/health":
|
|
28
|
+
ret {status: "ok"}
|
|
29
|
+
post "/api/auth/login" (req, res):
|
|
30
|
+
str email = req.body.email
|
|
31
|
+
ret {token: "jwt-token-here"}
|
|
32
|
+
|
|
33
|
+
every "30m":
|
|
34
|
+
log "cleanup running"
|
|
35
|
+
|
|
36
|
+
watch User.create (event):
|
|
37
|
+
log "new user: {event.data.name}"
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- Full NAIDE-X app with high-level features
|
|
2
|
+
|
|
3
|
+
env:
|
|
4
|
+
PORT int default(3000)
|
|
5
|
+
JWT_SECRET str required
|
|
6
|
+
|
|
7
|
+
schema User:
|
|
8
|
+
id auto
|
|
9
|
+
name str required min(2) max(50)
|
|
10
|
+
email str required email
|
|
11
|
+
role str default("user")
|
|
12
|
+
|
|
13
|
+
schema Todo:
|
|
14
|
+
id auto
|
|
15
|
+
title str required min(1)
|
|
16
|
+
done bool default(false)
|
|
17
|
+
created timestamp auto
|
|
18
|
+
|
|
19
|
+
$app:PORT
|
|
20
|
+
cors "*"
|
|
21
|
+
auth JWT_SECRET:
|
|
22
|
+
protect "/api/*"
|
|
23
|
+
public "/api/auth/*"
|
|
24
|
+
limit "/api/*" 100 "1m"
|
|
25
|
+
crud "/api/users" User
|
|
26
|
+
crud "/api/todos" Todo
|
|
27
|
+
G"/health"
|
|
28
|
+
>{status:"ok"}
|
|
29
|
+
P"/api/auth/login"(req,res)
|
|
30
|
+
s:email=req.body.email
|
|
31
|
+
>{token:"jwt-token-here"}
|
|
32
|
+
|
|
33
|
+
every "30m":
|
|
34
|
+
log"cleanup running"
|
|
35
|
+
|
|
36
|
+
watch User.create (event):
|
|
37
|
+
log"new user: {event.data.name}"
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "naidejs",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "NAIDE - Node AI Development Environment. A language optimized for AI code generation that transpiles to Node.js. Standard mode (~40% fewer tokens) and X mode (~80% fewer tokens).",
|
|
5
5
|
"main": "src/index.js",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.js",
|
|
8
|
+
"./runtime": "./src/runtime.js"
|
|
9
|
+
},
|
|
6
10
|
"bin": {
|
|
7
11
|
"naide": "bin/naide.js"
|
|
8
12
|
},
|
package/src/generator.js
CHANGED
|
@@ -1,13 +1,39 @@
|
|
|
1
1
|
export class Generator {
|
|
2
|
-
constructor() {
|
|
2
|
+
constructor(options = {}) {
|
|
3
3
|
this.indent = 0;
|
|
4
4
|
this.output = [];
|
|
5
5
|
this.usesExpress = false;
|
|
6
6
|
this.models = new Map();
|
|
7
|
+
this.runtimeImports = new Set();
|
|
8
|
+
this.schemas = new Map();
|
|
9
|
+
this.needsEventBus = false;
|
|
10
|
+
this.runtimePath = options.runtimePath || 'naidejs/runtime';
|
|
7
11
|
}
|
|
8
12
|
|
|
9
13
|
generate(ast) {
|
|
10
14
|
this.visitProgram(ast);
|
|
15
|
+
|
|
16
|
+
const preamble = [];
|
|
17
|
+
|
|
18
|
+
if (this.needsEventBus) {
|
|
19
|
+
this.runtimeImports.add('createEventBus');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (this.runtimeImports.size > 0) {
|
|
23
|
+
const imports = [...this.runtimeImports].join(', ');
|
|
24
|
+
preamble.push(`import { ${imports} } from '${this.runtimePath}';`);
|
|
25
|
+
preamble.push('');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (this.needsEventBus) {
|
|
29
|
+
preamble.push('const __eventBus = createEventBus();');
|
|
30
|
+
preamble.push('');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (preamble.length > 0) {
|
|
34
|
+
this.output.unshift(...preamble);
|
|
35
|
+
}
|
|
36
|
+
|
|
11
37
|
return this.output.join('\n');
|
|
12
38
|
}
|
|
13
39
|
|
|
@@ -51,6 +77,14 @@ export class Generator {
|
|
|
51
77
|
case 'ExprStatement': this.emit(this.expr(node.expression) + ';'); return;
|
|
52
78
|
case 'DbConnect': return this.visitDbConnect(node);
|
|
53
79
|
case 'AwaitAll': return this.visitAwaitAllStatement(node);
|
|
80
|
+
case 'SchemaDecl': return this.visitSchema(node);
|
|
81
|
+
case 'CrudDecl': return this.visitCrudTopLevel(node);
|
|
82
|
+
case 'AuthDecl': return this.visitAuthTopLevel(node);
|
|
83
|
+
case 'CorsDecl': return this.visitCorsTopLevel(node);
|
|
84
|
+
case 'LimitDecl': return this.visitLimitTopLevel(node);
|
|
85
|
+
case 'EnvDecl': return this.visitEnv(node);
|
|
86
|
+
case 'EveryDecl': return this.visitEvery(node);
|
|
87
|
+
case 'WatchDecl': return this.visitWatch(node);
|
|
54
88
|
default:
|
|
55
89
|
this.emit(`/* unknown: ${node.type} */`);
|
|
56
90
|
}
|
|
@@ -111,7 +145,6 @@ export class Generator {
|
|
|
111
145
|
const target = this.expr(node.target);
|
|
112
146
|
const value = this.expr(node.value);
|
|
113
147
|
|
|
114
|
-
// Destructuring assignment
|
|
115
148
|
if (node.target.type === 'Array') {
|
|
116
149
|
const names = node.target.elements.map(e => this.expr(e)).join(', ');
|
|
117
150
|
this.emit(`const [${names}] = ${value};`);
|
|
@@ -225,22 +258,27 @@ export class Generator {
|
|
|
225
258
|
this.emit(`${node.name}.use(express.json());`);
|
|
226
259
|
this.emitRaw('');
|
|
227
260
|
|
|
228
|
-
// Middleware
|
|
229
261
|
for (const mid of node.middleware) {
|
|
230
262
|
this.emit(`${node.name}.use(${this.generateMiddleware(mid)});`);
|
|
231
263
|
this.emitRaw('');
|
|
232
264
|
}
|
|
233
265
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
266
|
+
for (const child of node.routes) {
|
|
267
|
+
if (child.type === 'Route') {
|
|
268
|
+
this.visitRoute(node.name, child);
|
|
269
|
+
} else if (child.type === 'CrudDecl') {
|
|
270
|
+
this.visitCrud(node.name, child);
|
|
271
|
+
} else if (child.type === 'AuthDecl') {
|
|
272
|
+
this.visitAuth(node.name, child);
|
|
273
|
+
} else if (child.type === 'CorsDecl') {
|
|
274
|
+
this.visitCors(node.name, child);
|
|
275
|
+
} else if (child.type === 'LimitDecl') {
|
|
276
|
+
this.visitLimit(node.name, child);
|
|
238
277
|
} else {
|
|
239
|
-
this.visitStatement(
|
|
278
|
+
this.visitStatement(child);
|
|
240
279
|
}
|
|
241
280
|
}
|
|
242
281
|
|
|
243
|
-
// Listen
|
|
244
282
|
const port = node.port ? this.expr(node.port) : '3000';
|
|
245
283
|
this.emitRaw('');
|
|
246
284
|
this.emit(`${node.name}.listen(${port}, () => {`);
|
|
@@ -255,17 +293,14 @@ export class Generator {
|
|
|
255
293
|
const path = this.stringValue(route.path);
|
|
256
294
|
const params = route.params.length > 0 ? route.params.join(', ') : 'req, res';
|
|
257
295
|
|
|
258
|
-
// Check if body uses await
|
|
259
296
|
const needsAsync = this.bodyUsesAwait(route.body);
|
|
260
297
|
const asyncPrefix = needsAsync ? 'async ' : '';
|
|
261
298
|
|
|
262
299
|
this.emit(`${appName}.${method}(${path}, ${asyncPrefix}(${params}) => {`);
|
|
263
300
|
this.indent++;
|
|
264
301
|
|
|
265
|
-
// If params don't include res, inject it
|
|
266
302
|
const hasRes = params.includes('res');
|
|
267
303
|
|
|
268
|
-
// Transform body: last expression with ret becomes res.json
|
|
269
304
|
for (let i = 0; i < route.body.length; i++) {
|
|
270
305
|
const stmt = route.body[i];
|
|
271
306
|
if (stmt.type === 'Return' && stmt.value !== null) {
|
|
@@ -326,7 +361,6 @@ export class Generator {
|
|
|
326
361
|
this.emitRaw('');
|
|
327
362
|
}
|
|
328
363
|
|
|
329
|
-
// Methods
|
|
330
364
|
for (const method of node.methods) {
|
|
331
365
|
const async = method.isAsync ? 'async ' : '';
|
|
332
366
|
const params = method.params.map(p => {
|
|
@@ -353,8 +387,6 @@ export class Generator {
|
|
|
353
387
|
|
|
354
388
|
visitOn(node) {
|
|
355
389
|
const event = this.expr(node.event);
|
|
356
|
-
// Split event into object and event name
|
|
357
|
-
// e.g., process.exit -> process.on('exit', ...)
|
|
358
390
|
if (node.event.type === 'MemberAccess') {
|
|
359
391
|
const obj = this.expr(node.event.object);
|
|
360
392
|
const evt = node.event.property;
|
|
@@ -376,7 +408,6 @@ export class Generator {
|
|
|
376
408
|
|
|
377
409
|
visitThrow(node) {
|
|
378
410
|
const value = this.expr(node.value);
|
|
379
|
-
// If it's a string, wrap in Error
|
|
380
411
|
if (node.value.type === 'String') {
|
|
381
412
|
this.emit(`throw new Error(${value});`);
|
|
382
413
|
} else {
|
|
@@ -393,7 +424,207 @@ export class Generator {
|
|
|
393
424
|
this.emit(`await Promise.all([${exprs}]);`);
|
|
394
425
|
}
|
|
395
426
|
|
|
396
|
-
//
|
|
427
|
+
// ===== New high-level features =====
|
|
428
|
+
|
|
429
|
+
visitSchema(node) {
|
|
430
|
+
this.runtimeImports.add('createSchema');
|
|
431
|
+
this.runtimeImports.add('createStore');
|
|
432
|
+
|
|
433
|
+
this.emit(`const ${node.name}Schema = createSchema('${node.name}', {`);
|
|
434
|
+
this.indent++;
|
|
435
|
+
|
|
436
|
+
for (const field of node.fields) {
|
|
437
|
+
const props = [];
|
|
438
|
+
|
|
439
|
+
switch (field.type) {
|
|
440
|
+
case 'auto':
|
|
441
|
+
props.push("type: 'id'", 'auto: true');
|
|
442
|
+
break;
|
|
443
|
+
case 'timestamp':
|
|
444
|
+
props.push("type: 'timestamp'");
|
|
445
|
+
break;
|
|
446
|
+
case 'str':
|
|
447
|
+
props.push("type: 'string'");
|
|
448
|
+
break;
|
|
449
|
+
case 'int':
|
|
450
|
+
props.push("type: 'integer'");
|
|
451
|
+
break;
|
|
452
|
+
case 'num':
|
|
453
|
+
props.push("type: 'number'");
|
|
454
|
+
break;
|
|
455
|
+
case 'bool':
|
|
456
|
+
props.push("type: 'boolean'");
|
|
457
|
+
break;
|
|
458
|
+
case 'enum':
|
|
459
|
+
if (field.enumValues) {
|
|
460
|
+
const vals = field.enumValues.map(v => this.expr(v)).join(', ');
|
|
461
|
+
props.push("type: 'enum'", `values: [${vals}]`);
|
|
462
|
+
} else {
|
|
463
|
+
props.push("type: 'enum'");
|
|
464
|
+
}
|
|
465
|
+
break;
|
|
466
|
+
default:
|
|
467
|
+
props.push(`type: '${field.type}'`);
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
for (const mod of field.modifiers) {
|
|
472
|
+
switch (mod.name) {
|
|
473
|
+
case 'required': props.push('required: true'); break;
|
|
474
|
+
case 'optional': props.push('required: false'); break;
|
|
475
|
+
case 'unique': props.push('unique: true'); break;
|
|
476
|
+
case 'email': props.push('email: true'); break;
|
|
477
|
+
case 'url': props.push('url: true'); break;
|
|
478
|
+
case 'auto': props.push('auto: true'); break;
|
|
479
|
+
case 'min': props.push(`min: ${this.expr(mod.args[0])}`); break;
|
|
480
|
+
case 'max': props.push(`max: ${this.expr(mod.args[0])}`); break;
|
|
481
|
+
case 'default': props.push(`default: ${this.expr(mod.args[0])}`); break;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
this.emit(`${field.name}: { ${props.join(', ')} },`);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
this.indent--;
|
|
489
|
+
this.emit('});');
|
|
490
|
+
this.emit(`const ${node.name}Store = createStore(${node.name}Schema);`);
|
|
491
|
+
this.emitRaw('');
|
|
492
|
+
|
|
493
|
+
this.schemas.set(node.name, node);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
visitCrud(appName, node) {
|
|
497
|
+
this.runtimeImports.add('registerCrud');
|
|
498
|
+
this.needsEventBus = true;
|
|
499
|
+
|
|
500
|
+
const path = this.stringValue(node.path);
|
|
501
|
+
this.emit(`registerCrud(${appName}, ${path}, ${node.schemaName}Schema, ${node.schemaName}Store, __eventBus);`);
|
|
502
|
+
this.emitRaw('');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
visitCrudTopLevel(node) {
|
|
506
|
+
this.visitCrud('app', node);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
visitAuth(appName, node) {
|
|
510
|
+
this.runtimeImports.add('jwtAuth');
|
|
511
|
+
|
|
512
|
+
const secret = this.expr(node.secret);
|
|
513
|
+
const options = [];
|
|
514
|
+
if (node.publicPaths.length > 0) {
|
|
515
|
+
const paths = node.publicPaths.map(p => this.stringValue(p)).join(', ');
|
|
516
|
+
options.push(`public: [${paths}]`);
|
|
517
|
+
}
|
|
518
|
+
const optStr = options.length > 0 ? `, { ${options.join(', ')} }` : '';
|
|
519
|
+
|
|
520
|
+
if (node.protectedPaths.length > 0) {
|
|
521
|
+
const path = this.stringValue(node.protectedPaths[0]);
|
|
522
|
+
this.emit(`${appName}.use(${path}, jwtAuth(${secret}${optStr}));`);
|
|
523
|
+
} else {
|
|
524
|
+
this.emit(`${appName}.use(jwtAuth(${secret}${optStr}));`);
|
|
525
|
+
}
|
|
526
|
+
this.emitRaw('');
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
visitAuthTopLevel(node) {
|
|
530
|
+
this.visitAuth('app', node);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
visitCors(appName, node) {
|
|
534
|
+
this.runtimeImports.add('corsMiddleware');
|
|
535
|
+
|
|
536
|
+
const origins = this.expr(node.origins);
|
|
537
|
+
if (node.origins.type === 'String') {
|
|
538
|
+
this.emit(`${appName}.use(corsMiddleware([${origins}]));`);
|
|
539
|
+
} else {
|
|
540
|
+
this.emit(`${appName}.use(corsMiddleware(${origins}));`);
|
|
541
|
+
}
|
|
542
|
+
this.emitRaw('');
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
visitCorsTopLevel(node) {
|
|
546
|
+
this.visitCors('app', node);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
visitLimit(appName, node) {
|
|
550
|
+
this.runtimeImports.add('rateLimit');
|
|
551
|
+
|
|
552
|
+
const path = this.stringValue(node.path);
|
|
553
|
+
const max = this.expr(node.max);
|
|
554
|
+
const window = this.expr(node.window);
|
|
555
|
+
this.emit(`${appName}.use(${path}, rateLimit(${max}, ${window}));`);
|
|
556
|
+
this.emitRaw('');
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
visitLimitTopLevel(node) {
|
|
560
|
+
this.visitLimit('app', node);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
visitEnv(node) {
|
|
564
|
+
this.runtimeImports.add('loadEnv');
|
|
565
|
+
|
|
566
|
+
const names = node.vars.map(v => v.name);
|
|
567
|
+
this.emit(`const { ${names.join(', ')} } = loadEnv({`);
|
|
568
|
+
this.indent++;
|
|
569
|
+
|
|
570
|
+
for (const v of node.vars) {
|
|
571
|
+
const props = [];
|
|
572
|
+
|
|
573
|
+
switch (v.type) {
|
|
574
|
+
case 'str': props.push("type: 'string'"); break;
|
|
575
|
+
case 'int': props.push("type: 'integer'"); break;
|
|
576
|
+
case 'num': props.push("type: 'number'"); break;
|
|
577
|
+
case 'bool': props.push("type: 'boolean'"); break;
|
|
578
|
+
default: props.push(`type: '${v.type}'`); break;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
for (const mod of v.modifiers) {
|
|
582
|
+
switch (mod.name) {
|
|
583
|
+
case 'required': props.push('required: true'); break;
|
|
584
|
+
case 'default': props.push(`default: ${this.expr(mod.args[0])}`); break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
this.emit(`${v.name}: { ${props.join(', ')} },`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
this.indent--;
|
|
592
|
+
this.emit('});');
|
|
593
|
+
this.emitRaw('');
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
visitEvery(node) {
|
|
597
|
+
this.runtimeImports.add('scheduleEvery');
|
|
598
|
+
|
|
599
|
+
const interval = this.expr(node.interval);
|
|
600
|
+
const needsAsync = this.bodyUsesAwait(node.body);
|
|
601
|
+
const asyncPrefix = needsAsync ? 'async ' : '';
|
|
602
|
+
|
|
603
|
+
this.emit(`scheduleEvery(${interval}, ${asyncPrefix}() => {`);
|
|
604
|
+
this.indent++;
|
|
605
|
+
for (const stmt of node.body) this.visitStatement(stmt);
|
|
606
|
+
this.indent--;
|
|
607
|
+
this.emit('});');
|
|
608
|
+
this.emitRaw('');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
visitWatch(node) {
|
|
612
|
+
this.needsEventBus = true;
|
|
613
|
+
|
|
614
|
+
const params = node.params.length > 0 ? node.params.join(', ') : 'event';
|
|
615
|
+
const needsAsync = this.bodyUsesAwait(node.body);
|
|
616
|
+
const asyncPrefix = needsAsync ? 'async ' : '';
|
|
617
|
+
|
|
618
|
+
this.emit(`__eventBus.on('${node.eventName}', ${asyncPrefix}(${params}) => {`);
|
|
619
|
+
this.indent++;
|
|
620
|
+
for (const stmt of node.body) this.visitStatement(stmt);
|
|
621
|
+
this.indent--;
|
|
622
|
+
this.emit('});');
|
|
623
|
+
this.emitRaw('');
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ===== Expression generation =====
|
|
627
|
+
|
|
397
628
|
expr(node) {
|
|
398
629
|
if (!node) return 'undefined';
|
|
399
630
|
|
package/src/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { Parser } from './parser.js';
|
|
|
3
3
|
import { Generator } from './generator.js';
|
|
4
4
|
import { preprocess } from './preprocess.js';
|
|
5
5
|
|
|
6
|
-
export function compile(source, { mode = 'naide' } = {}) {
|
|
6
|
+
export function compile(source, { mode = 'naide', runtimePath } = {}) {
|
|
7
7
|
if (mode === 'x') {
|
|
8
8
|
source = preprocess(source);
|
|
9
9
|
}
|
|
@@ -11,7 +11,7 @@ export function compile(source, { mode = 'naide' } = {}) {
|
|
|
11
11
|
const tokens = lexer.tokenize();
|
|
12
12
|
const parser = new Parser(tokens);
|
|
13
13
|
const ast = parser.parse();
|
|
14
|
-
const generator = new Generator();
|
|
14
|
+
const generator = new Generator({ runtimePath });
|
|
15
15
|
const js = generator.generate(ast);
|
|
16
16
|
return { js, ast, tokens, naide: mode === 'x' ? source : null };
|
|
17
17
|
}
|
package/src/parser.js
CHANGED
|
@@ -91,7 +91,10 @@ export class Parser {
|
|
|
91
91
|
tok.type === T.LOG || tok.type === T.DB || tok.type === T.GET ||
|
|
92
92
|
tok.type === T.POST || tok.type === T.PUT || tok.type === T.DEL ||
|
|
93
93
|
tok.type === T.MATCH || tok.type === T.ON || tok.type === T.NEW ||
|
|
94
|
-
tok.type === T.FROM || tok.type === T.AS || tok.type === T.SELF
|
|
94
|
+
tok.type === T.FROM || tok.type === T.AS || tok.type === T.SELF ||
|
|
95
|
+
tok.type === T.SCHEMA || tok.type === T.CRUD || tok.type === T.AUTH ||
|
|
96
|
+
tok.type === T.CORS || tok.type === T.LIMIT || tok.type === T.ENV ||
|
|
97
|
+
tok.type === T.EVERY || tok.type === T.WATCH) {
|
|
95
98
|
return tok.value;
|
|
96
99
|
}
|
|
97
100
|
throw this.error(`Expected property name but got ${tok.type} ('${tok.value}')`, tok);
|
|
@@ -148,6 +151,28 @@ export class Parser {
|
|
|
148
151
|
case T.DB: return this.parseDbStatement();
|
|
149
152
|
case T.AWAIT: return this.parseAwaitStatement();
|
|
150
153
|
case T.AWAIT_ALL: return this.parseAwaitAll();
|
|
154
|
+
case T.SCHEMA: return this.parseSchema();
|
|
155
|
+
case T.CRUD:
|
|
156
|
+
if (this.peek(1).type === T.DOT) return this.parseExpressionStatement();
|
|
157
|
+
return this.parseCrud();
|
|
158
|
+
case T.AUTH:
|
|
159
|
+
if (this.peek(1).type === T.DOT) return this.parseExpressionStatement();
|
|
160
|
+
return this.parseAuth();
|
|
161
|
+
case T.CORS:
|
|
162
|
+
if (this.peek(1).type === T.DOT) return this.parseExpressionStatement();
|
|
163
|
+
return this.parseCors();
|
|
164
|
+
case T.LIMIT:
|
|
165
|
+
if (this.peek(1).type === T.DOT) return this.parseExpressionStatement();
|
|
166
|
+
return this.parseLimit();
|
|
167
|
+
case T.ENV:
|
|
168
|
+
if (this.peek(1).type === T.DOT) return this.parseExpressionStatement();
|
|
169
|
+
return this.parseEnv();
|
|
170
|
+
case T.EVERY:
|
|
171
|
+
if (this.peek(1).type === T.DOT) return this.parseExpressionStatement();
|
|
172
|
+
return this.parseEvery();
|
|
173
|
+
case T.WATCH:
|
|
174
|
+
if (this.peek(1).type === T.DOT) return this.parseExpressionStatement();
|
|
175
|
+
return this.parseWatch();
|
|
151
176
|
default:
|
|
152
177
|
if (TYPE_TOKENS.has(tok.type)) {
|
|
153
178
|
return this.parseTypedVariable();
|
|
@@ -497,8 +522,15 @@ export class Parser {
|
|
|
497
522
|
routes.push(this.parseRoute());
|
|
498
523
|
} else if (this.at(T.MID)) {
|
|
499
524
|
middleware.push(this.parseMiddleware());
|
|
525
|
+
} else if (this.at(T.CRUD)) {
|
|
526
|
+
routes.push(this.parseCrud());
|
|
527
|
+
} else if (this.at(T.AUTH)) {
|
|
528
|
+
routes.push(this.parseAuth());
|
|
529
|
+
} else if (this.at(T.CORS)) {
|
|
530
|
+
routes.push(this.parseCors());
|
|
531
|
+
} else if (this.at(T.LIMIT)) {
|
|
532
|
+
routes.push(this.parseLimit());
|
|
500
533
|
} else {
|
|
501
|
-
// generic statement in server block
|
|
502
534
|
routes.push(this.parseStatement());
|
|
503
535
|
}
|
|
504
536
|
this.skipNewlines();
|
|
@@ -894,6 +926,11 @@ export class Parser {
|
|
|
894
926
|
this.advance();
|
|
895
927
|
return new ASTNode('Identifier', { name: tok.value });
|
|
896
928
|
|
|
929
|
+
case T.SCHEMA: case T.CRUD: case T.AUTH: case T.CORS:
|
|
930
|
+
case T.LIMIT: case T.ENV: case T.EVERY: case T.WATCH:
|
|
931
|
+
this.advance();
|
|
932
|
+
return new ASTNode('Identifier', { name: tok.value });
|
|
933
|
+
|
|
897
934
|
case T.DB:
|
|
898
935
|
return this.parseDbExpression();
|
|
899
936
|
|
|
@@ -1076,4 +1113,217 @@ export class Parser {
|
|
|
1076
1113
|
const body = this.parseBlock();
|
|
1077
1114
|
return new ASTNode('Lambda', { params, returnType, body, isAsync });
|
|
1078
1115
|
}
|
|
1116
|
+
|
|
1117
|
+
// schema User:
|
|
1118
|
+
// id auto
|
|
1119
|
+
// name str required min(2) max(50)
|
|
1120
|
+
// email str required email unique
|
|
1121
|
+
parseSchema() {
|
|
1122
|
+
this.advance(); // schema
|
|
1123
|
+
const name = this.expect(T.IDENT).value;
|
|
1124
|
+
this.expect(T.COLON);
|
|
1125
|
+
this.skipNewlines();
|
|
1126
|
+
this.expect(T.INDENT);
|
|
1127
|
+
|
|
1128
|
+
const fields = [];
|
|
1129
|
+
this.skipNewlines();
|
|
1130
|
+
|
|
1131
|
+
while (!this.at(T.DEDENT) && !this.at(T.EOF)) {
|
|
1132
|
+
fields.push(this.parseSchemaField());
|
|
1133
|
+
this.skipNewlines();
|
|
1134
|
+
}
|
|
1135
|
+
this.match(T.DEDENT);
|
|
1136
|
+
|
|
1137
|
+
return new ASTNode('SchemaDecl', { name, fields });
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
parseSchemaField() {
|
|
1141
|
+
const name = this.expect(T.IDENT).value;
|
|
1142
|
+
|
|
1143
|
+
let fieldType;
|
|
1144
|
+
let enumValues = null;
|
|
1145
|
+
|
|
1146
|
+
if (TYPE_TOKENS.has(this.peek().type)) {
|
|
1147
|
+
fieldType = this.advance().value;
|
|
1148
|
+
} else if (this.at(T.IDENT)) {
|
|
1149
|
+
fieldType = this.advance().value;
|
|
1150
|
+
if (fieldType === 'enum' && this.at(T.LPAREN)) {
|
|
1151
|
+
this.advance();
|
|
1152
|
+
enumValues = [];
|
|
1153
|
+
while (!this.at(T.RPAREN) && !this.at(T.EOF)) {
|
|
1154
|
+
enumValues.push(this.parseExpression());
|
|
1155
|
+
this.match(T.COMMA);
|
|
1156
|
+
}
|
|
1157
|
+
this.expect(T.RPAREN);
|
|
1158
|
+
}
|
|
1159
|
+
} else {
|
|
1160
|
+
throw this.error('Expected type in schema field');
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const modifiers = [];
|
|
1164
|
+
while (!this.at(T.NEWLINE) && !this.at(T.DEDENT) && !this.at(T.EOF)) {
|
|
1165
|
+
if (this.at(T.IDENT) || this.at(T.IDENT)) {
|
|
1166
|
+
const modName = this.advance().value;
|
|
1167
|
+
if (this.at(T.LPAREN)) {
|
|
1168
|
+
this.advance();
|
|
1169
|
+
const args = [];
|
|
1170
|
+
while (!this.at(T.RPAREN) && !this.at(T.EOF)) {
|
|
1171
|
+
args.push(this.parseExpression());
|
|
1172
|
+
this.match(T.COMMA);
|
|
1173
|
+
}
|
|
1174
|
+
this.expect(T.RPAREN);
|
|
1175
|
+
modifiers.push({ name: modName, args });
|
|
1176
|
+
} else {
|
|
1177
|
+
modifiers.push({ name: modName, args: [] });
|
|
1178
|
+
}
|
|
1179
|
+
} else {
|
|
1180
|
+
break;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
return { name, type: fieldType, enumValues, modifiers };
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
// crud "/api/users" User
|
|
1188
|
+
parseCrud() {
|
|
1189
|
+
this.advance(); // crud
|
|
1190
|
+
const path = this.parseString();
|
|
1191
|
+
const schemaName = this.expect(T.IDENT).value;
|
|
1192
|
+
return new ASTNode('CrudDecl', { path, schemaName });
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// auth SECRET:
|
|
1196
|
+
// protect "/api/*"
|
|
1197
|
+
// public "/api/auth/*"
|
|
1198
|
+
parseAuth() {
|
|
1199
|
+
this.advance(); // auth
|
|
1200
|
+
const secret = this.parseExpression();
|
|
1201
|
+
|
|
1202
|
+
const protectedPaths = [];
|
|
1203
|
+
const publicPaths = [];
|
|
1204
|
+
|
|
1205
|
+
if (this.match(T.COLON)) {
|
|
1206
|
+
this.skipNewlines();
|
|
1207
|
+
this.expect(T.INDENT);
|
|
1208
|
+
this.skipNewlines();
|
|
1209
|
+
while (!this.at(T.DEDENT) && !this.at(T.EOF)) {
|
|
1210
|
+
if (this.at(T.IDENT) && this.peek().value === 'protect') {
|
|
1211
|
+
this.advance();
|
|
1212
|
+
protectedPaths.push(this.parseString());
|
|
1213
|
+
} else if (this.at(T.IDENT) && this.peek().value === 'public') {
|
|
1214
|
+
this.advance();
|
|
1215
|
+
publicPaths.push(this.parseString());
|
|
1216
|
+
} else {
|
|
1217
|
+
this.advance();
|
|
1218
|
+
}
|
|
1219
|
+
this.skipNewlines();
|
|
1220
|
+
}
|
|
1221
|
+
this.match(T.DEDENT);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
return new ASTNode('AuthDecl', { secret, protectedPaths, publicPaths });
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// cors "*"
|
|
1228
|
+
// cors ["origin1", "origin2"]
|
|
1229
|
+
parseCors() {
|
|
1230
|
+
this.advance(); // cors
|
|
1231
|
+
const origins = this.parseExpression();
|
|
1232
|
+
return new ASTNode('CorsDecl', { origins });
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
// limit "/api/*" 100 "1m"
|
|
1236
|
+
parseLimit() {
|
|
1237
|
+
this.advance(); // limit
|
|
1238
|
+
const path = this.parseString();
|
|
1239
|
+
const max = this.parseExpression();
|
|
1240
|
+
const window = this.parseExpression();
|
|
1241
|
+
return new ASTNode('LimitDecl', { path, max, window });
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// env:
|
|
1245
|
+
// PORT int default(3000)
|
|
1246
|
+
// JWT_SECRET str required
|
|
1247
|
+
parseEnv() {
|
|
1248
|
+
this.advance(); // env
|
|
1249
|
+
this.expect(T.COLON);
|
|
1250
|
+
this.skipNewlines();
|
|
1251
|
+
this.expect(T.INDENT);
|
|
1252
|
+
|
|
1253
|
+
const vars = [];
|
|
1254
|
+
this.skipNewlines();
|
|
1255
|
+
|
|
1256
|
+
while (!this.at(T.DEDENT) && !this.at(T.EOF)) {
|
|
1257
|
+
vars.push(this.parseEnvField());
|
|
1258
|
+
this.skipNewlines();
|
|
1259
|
+
}
|
|
1260
|
+
this.match(T.DEDENT);
|
|
1261
|
+
|
|
1262
|
+
return new ASTNode('EnvDecl', { vars });
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
parseEnvField() {
|
|
1266
|
+
const name = this.expect(T.IDENT).value;
|
|
1267
|
+
|
|
1268
|
+
let fieldType = 'string';
|
|
1269
|
+
if (TYPE_TOKENS.has(this.peek().type)) {
|
|
1270
|
+
fieldType = this.advance().value;
|
|
1271
|
+
} else if (this.at(T.IDENT) && ['string', 'number', 'integer', 'boolean'].includes(this.peek().value)) {
|
|
1272
|
+
fieldType = this.advance().value;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
const modifiers = [];
|
|
1276
|
+
while (!this.at(T.NEWLINE) && !this.at(T.DEDENT) && !this.at(T.EOF)) {
|
|
1277
|
+
if (this.at(T.IDENT)) {
|
|
1278
|
+
const modName = this.advance().value;
|
|
1279
|
+
if (this.at(T.LPAREN)) {
|
|
1280
|
+
this.advance();
|
|
1281
|
+
const args = [];
|
|
1282
|
+
while (!this.at(T.RPAREN) && !this.at(T.EOF)) {
|
|
1283
|
+
args.push(this.parseExpression());
|
|
1284
|
+
this.match(T.COMMA);
|
|
1285
|
+
}
|
|
1286
|
+
this.expect(T.RPAREN);
|
|
1287
|
+
modifiers.push({ name: modName, args });
|
|
1288
|
+
} else {
|
|
1289
|
+
modifiers.push({ name: modName, args: [] });
|
|
1290
|
+
}
|
|
1291
|
+
} else {
|
|
1292
|
+
break;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
return { name, type: fieldType, modifiers };
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// every "5m":
|
|
1300
|
+
// log "tick"
|
|
1301
|
+
parseEvery() {
|
|
1302
|
+
this.advance(); // every
|
|
1303
|
+
const interval = this.parseExpression();
|
|
1304
|
+
this.expect(T.COLON);
|
|
1305
|
+
const body = this.parseBlock();
|
|
1306
|
+
return new ASTNode('EveryDecl', { interval, body });
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// watch User.create (event):
|
|
1310
|
+
// log event
|
|
1311
|
+
parseWatch() {
|
|
1312
|
+
this.advance(); // watch
|
|
1313
|
+
let eventName = this.expect(T.IDENT).value;
|
|
1314
|
+
while (this.match(T.DOT)) {
|
|
1315
|
+
eventName += '.' + this.expectPropertyName();
|
|
1316
|
+
}
|
|
1317
|
+
let params = [];
|
|
1318
|
+
if (this.match(T.LPAREN)) {
|
|
1319
|
+
while (!this.at(T.RPAREN) && !this.at(T.EOF)) {
|
|
1320
|
+
params.push(this.expect(T.IDENT).value);
|
|
1321
|
+
this.match(T.COMMA);
|
|
1322
|
+
}
|
|
1323
|
+
this.expect(T.RPAREN);
|
|
1324
|
+
}
|
|
1325
|
+
this.expect(T.COLON);
|
|
1326
|
+
const body = this.parseBlock();
|
|
1327
|
+
return new ASTNode('WatchDecl', { eventName, params, body });
|
|
1328
|
+
}
|
|
1079
1329
|
}
|
package/src/runtime.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'crypto';
|
|
2
|
+
|
|
3
|
+
// ===== Schema + Validation =====
|
|
4
|
+
export function createSchema(name, fieldDefs) {
|
|
5
|
+
const schema = {
|
|
6
|
+
name,
|
|
7
|
+
fields: fieldDefs,
|
|
8
|
+
validate(data) {
|
|
9
|
+
const errors = [];
|
|
10
|
+
const result = {};
|
|
11
|
+
for (const [field, rules] of Object.entries(fieldDefs)) {
|
|
12
|
+
let val = data[field];
|
|
13
|
+
if (rules.auto && rules.type === 'id' && val === undefined) { result[field] = randomUUID(); continue; }
|
|
14
|
+
if (rules.auto && rules.type === 'timestamp' && val === undefined) { result[field] = new Date().toISOString(); continue; }
|
|
15
|
+
if (val === undefined && rules.default !== undefined) val = rules.default;
|
|
16
|
+
if (rules.required && (val === undefined || val === null || val === '')) { errors.push(`${field} is required`); continue; }
|
|
17
|
+
if (val === undefined || val === null) continue;
|
|
18
|
+
if (rules.type === 'string') {
|
|
19
|
+
if (typeof val !== 'string') { errors.push(`${field} must be a string`); continue; }
|
|
20
|
+
if (rules.min !== undefined && val.length < rules.min) errors.push(`${field} must be at least ${rules.min} characters`);
|
|
21
|
+
if (rules.max !== undefined && val.length > rules.max) errors.push(`${field} must be at most ${rules.max} characters`);
|
|
22
|
+
if (rules.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)) errors.push(`${field} must be a valid email`);
|
|
23
|
+
if (rules.url && !/^https?:\/\/.+/.test(val)) errors.push(`${field} must be a valid URL`);
|
|
24
|
+
if (rules.match && !rules.match.test(val)) errors.push(`${field} format is invalid`);
|
|
25
|
+
}
|
|
26
|
+
if (rules.type === 'number' || rules.type === 'integer') {
|
|
27
|
+
const n = Number(val);
|
|
28
|
+
if (isNaN(n)) { errors.push(`${field} must be a number`); continue; }
|
|
29
|
+
if (rules.type === 'integer' && !Number.isInteger(n)) errors.push(`${field} must be an integer`);
|
|
30
|
+
if (rules.min !== undefined && n < rules.min) errors.push(`${field} must be at least ${rules.min}`);
|
|
31
|
+
if (rules.max !== undefined && n > rules.max) errors.push(`${field} must be at most ${rules.max}`);
|
|
32
|
+
val = n;
|
|
33
|
+
}
|
|
34
|
+
if (rules.type === 'boolean') val = Boolean(val);
|
|
35
|
+
if (rules.type === 'enum' && !rules.values.includes(val)) errors.push(`${field} must be one of: ${rules.values.join(', ')}`);
|
|
36
|
+
result[field] = val;
|
|
37
|
+
}
|
|
38
|
+
return errors.length > 0 ? { valid: false, errors } : { valid: true, data: result };
|
|
39
|
+
},
|
|
40
|
+
defaults() {
|
|
41
|
+
const d = {};
|
|
42
|
+
for (const [field, rules] of Object.entries(fieldDefs)) {
|
|
43
|
+
if (rules.default !== undefined) d[field] = rules.default;
|
|
44
|
+
if (rules.auto && rules.type === 'id') d[field] = randomUUID();
|
|
45
|
+
if (rules.auto && rules.type === 'timestamp') d[field] = new Date().toISOString();
|
|
46
|
+
}
|
|
47
|
+
return d;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
return schema;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ===== In-Memory Store =====
|
|
54
|
+
export function createStore(schema) {
|
|
55
|
+
const items = new Map();
|
|
56
|
+
let _idField = null;
|
|
57
|
+
for (const [f, r] of Object.entries(schema.fields)) {
|
|
58
|
+
if (r.auto && r.type === 'id') { _idField = f; break; }
|
|
59
|
+
}
|
|
60
|
+
const idField = _idField || 'id';
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
getAll() { return [...items.values()]; },
|
|
64
|
+
getById(id) { return items.get(String(id)) || null; },
|
|
65
|
+
count() { return items.size; },
|
|
66
|
+
create(data) {
|
|
67
|
+
const { valid, errors, data: validated } = schema.validate(data);
|
|
68
|
+
if (!valid) return { error: errors };
|
|
69
|
+
const id = validated[idField] || randomUUID();
|
|
70
|
+
validated[idField] = id;
|
|
71
|
+
items.set(String(id), validated);
|
|
72
|
+
return validated;
|
|
73
|
+
},
|
|
74
|
+
update(id, data) {
|
|
75
|
+
const existing = items.get(String(id));
|
|
76
|
+
if (!existing) return null;
|
|
77
|
+
const merged = { ...existing, ...data, [idField]: existing[idField] };
|
|
78
|
+
items.set(String(id), merged);
|
|
79
|
+
return merged;
|
|
80
|
+
},
|
|
81
|
+
delete(id) {
|
|
82
|
+
return items.delete(String(id));
|
|
83
|
+
},
|
|
84
|
+
where(conditions) {
|
|
85
|
+
return [...items.values()].filter(item => {
|
|
86
|
+
for (const [k, v] of Object.entries(conditions)) {
|
|
87
|
+
if (item[k] !== v) return false;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
clear() { items.clear(); }
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ===== CRUD Route Registration =====
|
|
97
|
+
export function registerCrud(app, basePath, schema, store, eventBus) {
|
|
98
|
+
app.get(basePath, (_req, res) => {
|
|
99
|
+
res.json(store.getAll());
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
app.get(`${basePath}/:id`, (req, res) => {
|
|
103
|
+
const item = store.getById(req.params.id);
|
|
104
|
+
if (!item) return res.status(404).json({ error: `${schema.name} not found` });
|
|
105
|
+
res.json(item);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
app.post(basePath, (req, res) => {
|
|
109
|
+
const result = store.create(req.body);
|
|
110
|
+
if (result.error) return res.status(400).json({ errors: result.error });
|
|
111
|
+
if (eventBus) eventBus.emit(`${schema.name}.create`, result);
|
|
112
|
+
res.status(201).json(result);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
app.put(`${basePath}/:id`, (req, res) => {
|
|
116
|
+
const item = store.getById(req.params.id);
|
|
117
|
+
if (!item) return res.status(404).json({ error: `${schema.name} not found` });
|
|
118
|
+
const updated = store.update(req.params.id, req.body);
|
|
119
|
+
if (eventBus) eventBus.emit(`${schema.name}.update`, updated);
|
|
120
|
+
res.json(updated);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
app.delete(`${basePath}/:id`, (req, res) => {
|
|
124
|
+
const item = store.getById(req.params.id);
|
|
125
|
+
if (!item) return res.status(404).json({ error: `${schema.name} not found` });
|
|
126
|
+
store.delete(req.params.id);
|
|
127
|
+
if (eventBus) eventBus.emit(`${schema.name}.delete`, { id: req.params.id });
|
|
128
|
+
res.json({ deleted: true });
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ===== JWT Auth =====
|
|
133
|
+
function base64url(buf) {
|
|
134
|
+
return Buffer.from(buf).toString('base64url');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function jwtSign(payload, secret, expiresIn = '24h') {
|
|
138
|
+
const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }));
|
|
139
|
+
const ms = typeof expiresIn === 'number' ? expiresIn : parseMs(expiresIn);
|
|
140
|
+
const body = base64url(JSON.stringify({ ...payload, iat: Math.floor(Date.now() / 1000), exp: Math.floor((Date.now() + ms) / 1000) }));
|
|
141
|
+
const sig = createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url');
|
|
142
|
+
return `${header}.${body}.${sig}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function jwtVerify(token, secret) {
|
|
146
|
+
const parts = token.split('.');
|
|
147
|
+
if (parts.length !== 3) throw new Error('Invalid token');
|
|
148
|
+
const sig = createHmac('sha256', secret).update(`${parts[0]}.${parts[1]}`).digest('base64url');
|
|
149
|
+
const sigBuf = Buffer.from(sig);
|
|
150
|
+
const tokenSigBuf = Buffer.from(parts[2]);
|
|
151
|
+
if (sigBuf.length !== tokenSigBuf.length || !timingSafeEqual(sigBuf, tokenSigBuf)) throw new Error('Invalid signature');
|
|
152
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
153
|
+
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) throw new Error('Token expired');
|
|
154
|
+
return payload;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function jwtAuth(secret, options = {}) {
|
|
158
|
+
const publicPaths = (options.public || []).map(p => new RegExp('^' + p.replace(/\*/g, '.*') + '$'));
|
|
159
|
+
return (req, res, next) => {
|
|
160
|
+
const isPublic = publicPaths.some(re => re.test(req.path));
|
|
161
|
+
if (isPublic) return next();
|
|
162
|
+
const authHeader = req.headers.authorization;
|
|
163
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) return res.status(401).json({ error: 'No token provided' });
|
|
164
|
+
try {
|
|
165
|
+
req.user = jwtVerify(authHeader.slice(7), secret);
|
|
166
|
+
next();
|
|
167
|
+
} catch (e) {
|
|
168
|
+
res.status(403).json({ error: e.message });
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ===== CORS Middleware =====
|
|
174
|
+
export function corsMiddleware(origins = ['*']) {
|
|
175
|
+
const allowAll = origins.includes('*');
|
|
176
|
+
const originSet = new Set(origins.map(o => o.replace(/^https?:\/\//, '')));
|
|
177
|
+
return (req, res, next) => {
|
|
178
|
+
const reqOrigin = req.headers.origin || '';
|
|
179
|
+
const host = reqOrigin.replace(/^https?:\/\//, '');
|
|
180
|
+
if (allowAll || originSet.has(host)) {
|
|
181
|
+
res.setHeader('Access-Control-Allow-Origin', reqOrigin || '*');
|
|
182
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH,OPTIONS');
|
|
183
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
|
|
184
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
185
|
+
}
|
|
186
|
+
if (req.method === 'OPTIONS') return res.status(204).end();
|
|
187
|
+
next();
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ===== Rate Limiting =====
|
|
192
|
+
export function rateLimit(max, window = '1m') {
|
|
193
|
+
const windowMs = parseMs(window);
|
|
194
|
+
const hits = new Map();
|
|
195
|
+
setInterval(() => hits.clear(), windowMs);
|
|
196
|
+
return (req, res, next) => {
|
|
197
|
+
const key = req.ip;
|
|
198
|
+
const count = (hits.get(key) || 0) + 1;
|
|
199
|
+
hits.set(key, count);
|
|
200
|
+
res.setHeader('X-RateLimit-Limit', max);
|
|
201
|
+
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - count));
|
|
202
|
+
if (count > max) return res.status(429).json({ error: 'Too many requests' });
|
|
203
|
+
next();
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ===== Env =====
|
|
208
|
+
export function loadEnv(spec) {
|
|
209
|
+
const env = {};
|
|
210
|
+
for (const [key, rules] of Object.entries(spec)) {
|
|
211
|
+
let val = process.env[key];
|
|
212
|
+
if (val === undefined && rules.default !== undefined) val = String(rules.default);
|
|
213
|
+
if (rules.required && val === undefined) {
|
|
214
|
+
console.error(`[NAIDE] Missing required env var: ${key}`);
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
if (val !== undefined) {
|
|
218
|
+
if (rules.type === 'number' || rules.type === 'integer') val = Number(val);
|
|
219
|
+
if (rules.type === 'boolean') val = val === 'true' || val === '1';
|
|
220
|
+
}
|
|
221
|
+
env[key] = val;
|
|
222
|
+
}
|
|
223
|
+
return env;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ===== Cron / Scheduler =====
|
|
227
|
+
export function scheduleEvery(interval, fn) {
|
|
228
|
+
const ms = parseMs(interval);
|
|
229
|
+
const timer = setInterval(async () => {
|
|
230
|
+
try { await fn(); } catch (e) { console.error('[NAIDE Cron]', e.message); }
|
|
231
|
+
}, ms);
|
|
232
|
+
fn();
|
|
233
|
+
return timer;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ===== Event Bus (for watch) =====
|
|
237
|
+
export function createEventBus() {
|
|
238
|
+
const listeners = new Map();
|
|
239
|
+
return {
|
|
240
|
+
on(event, fn) {
|
|
241
|
+
if (!listeners.has(event)) listeners.set(event, []);
|
|
242
|
+
listeners.get(event).push(fn);
|
|
243
|
+
},
|
|
244
|
+
emit(event, data) {
|
|
245
|
+
const fns = listeners.get(event) || [];
|
|
246
|
+
for (const fn of fns) {
|
|
247
|
+
try { fn({ event, data, timestamp: new Date().toISOString() }); } catch (e) { console.error('[NAIDE Event]', e.message); }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ===== Helpers =====
|
|
254
|
+
function parseMs(str) {
|
|
255
|
+
if (typeof str === 'number') return str;
|
|
256
|
+
const m = str.match(/^(\d+)(ms|s|m|h|d)$/);
|
|
257
|
+
if (!m) return 60000;
|
|
258
|
+
const n = parseInt(m[1]);
|
|
259
|
+
switch (m[2]) {
|
|
260
|
+
case 'ms': return n;
|
|
261
|
+
case 's': return n * 1000;
|
|
262
|
+
case 'm': return n * 60000;
|
|
263
|
+
case 'h': return n * 3600000;
|
|
264
|
+
case 'd': return n * 86400000;
|
|
265
|
+
default: return 60000;
|
|
266
|
+
}
|
|
267
|
+
}
|
package/src/tokens.js
CHANGED
|
@@ -57,6 +57,14 @@ export const T = {
|
|
|
57
57
|
BREAK: 'BREAK',
|
|
58
58
|
CONTINUE: 'CONTINUE',
|
|
59
59
|
THROW: 'THROW',
|
|
60
|
+
SCHEMA: 'SCHEMA',
|
|
61
|
+
CRUD: 'CRUD',
|
|
62
|
+
AUTH: 'AUTH',
|
|
63
|
+
CORS: 'CORS',
|
|
64
|
+
LIMIT: 'LIMIT',
|
|
65
|
+
ENV: 'ENV',
|
|
66
|
+
EVERY: 'EVERY',
|
|
67
|
+
WATCH: 'WATCH',
|
|
60
68
|
|
|
61
69
|
// Operators
|
|
62
70
|
ASSIGN: 'ASSIGN',
|
|
@@ -142,6 +150,14 @@ export const KEYWORDS = {
|
|
|
142
150
|
'break': T.BREAK,
|
|
143
151
|
'continue': T.CONTINUE,
|
|
144
152
|
'throw': T.THROW,
|
|
153
|
+
'schema': T.SCHEMA,
|
|
154
|
+
'crud': T.CRUD,
|
|
155
|
+
'auth': T.AUTH,
|
|
156
|
+
'cors': T.CORS,
|
|
157
|
+
'limit': T.LIMIT,
|
|
158
|
+
'env': T.ENV,
|
|
159
|
+
'every': T.EVERY,
|
|
160
|
+
'watch': T.WATCH,
|
|
145
161
|
'true': T.BOOL,
|
|
146
162
|
'false': T.BOOL,
|
|
147
163
|
'null': T.NULL,
|