aiplang 2.8.0 → 2.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -22
- package/bin/aiplang.js +20 -2
- package/package.json +4 -2
- package/server/server.js +200 -17
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# aiplang
|
|
2
2
|
|
|
3
|
-
AI-first web language. One `.aip` file = complete app
|
|
3
|
+
> AI-first web language. One `.aip` file = complete app. Built for LLMs, not humans.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
[](https://github.com/isacamartin/aiplang/actions/workflows/tests.yml)
|
|
6
|
+
[](https://npmjs.com/package/aiplang)
|
|
6
7
|
|
|
7
8
|
```bash
|
|
8
9
|
npx aiplang init my-app
|
|
@@ -10,9 +11,15 @@ cd my-app
|
|
|
10
11
|
npx aiplang serve
|
|
11
12
|
```
|
|
12
13
|
|
|
14
|
+
Ask Claude to generate a page → paste into `pages/home.aip` → see it live.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
13
18
|
## What it is
|
|
14
19
|
|
|
15
|
-
|
|
20
|
+
**aiplang** is a web language designed to be generated by AI (Claude, GPT), not written by humans.
|
|
21
|
+
|
|
22
|
+
A single `.aip` file describes a complete app: frontend + backend + database + auth + email + payments.
|
|
16
23
|
|
|
17
24
|
```aip
|
|
18
25
|
~db sqlite ./app.db
|
|
@@ -31,11 +38,21 @@ api POST /api/auth/login {
|
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
%home dark /
|
|
41
|
+
~theme accent=#6366f1 radius=1rem font=Inter
|
|
34
42
|
nav{MyApp>/login:Sign in}
|
|
35
43
|
hero{Welcome|Built with aiplang.} animate:blur-in
|
|
36
44
|
foot{© 2025}
|
|
37
45
|
```
|
|
38
46
|
|
|
47
|
+
## Why LLMs love it
|
|
48
|
+
|
|
49
|
+
| | aiplang | Next.js |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| Tokens per app | ~490 | ~10,200 |
|
|
52
|
+
| Files generated | 1 | 22 |
|
|
53
|
+
| Error rate (first try) | ~2% | ~28% |
|
|
54
|
+
| Config needed | zero | tsconfig + tailwind + prisma + ... |
|
|
55
|
+
|
|
39
56
|
## Commands
|
|
40
57
|
|
|
41
58
|
```bash
|
|
@@ -43,31 +60,55 @@ npx aiplang serve # dev server + hot reload
|
|
|
43
60
|
npx aiplang build pages/ # compile → static HTML
|
|
44
61
|
npx aiplang start app.aip # full-stack Node.js server
|
|
45
62
|
npx aiplang init my-app # create project
|
|
46
|
-
npx aiplang init my-app --template saas|landing|crud|dashboard
|
|
47
|
-
npx aiplang template list # list
|
|
63
|
+
npx aiplang init my-app --template saas|landing|crud|dashboard|blog|ecommerce|todo|analytics|chat
|
|
64
|
+
npx aiplang template list # list saved templates
|
|
48
65
|
npx aiplang template save my-tpl # save current project as template
|
|
49
66
|
```
|
|
50
67
|
|
|
51
|
-
##
|
|
52
|
-
|
|
53
|
-
- **ORM** — model, insert, update, delete, soft-delete, paginate, count, sum
|
|
54
|
-
- **Auth** — JWT with bcrypt, guards (auth, admin, subscribed)
|
|
55
|
-
- **Validation** — required, email, min, max, unique, numeric
|
|
56
|
-
- **
|
|
57
|
-
- **
|
|
58
|
-
- **
|
|
59
|
-
- **
|
|
60
|
-
- **
|
|
68
|
+
## Features
|
|
69
|
+
|
|
70
|
+
- **ORM** — model, insert, update, delete, soft-delete, paginate, count, sum, relations
|
|
71
|
+
- **Auth** — JWT with bcrypt, guards (auth, admin, subscribed), rate limiting
|
|
72
|
+
- **Validation** — required, email, min, max, unique, numeric, in:
|
|
73
|
+
- **Cache** — `~cache key ttl` — in-memory with TTL
|
|
74
|
+
- **WebSockets** — `~realtime` — auto-broadcast on mutations
|
|
75
|
+
- **S3** — Amazon S3, Cloudflare R2, MinIO
|
|
76
|
+
- **Stripe** — checkout, portal, webhooks
|
|
77
|
+
- **Email** — SMTP via nodemailer
|
|
78
|
+
- **Admin panel** — auto-generated at /admin
|
|
79
|
+
- **Plugins** — extend with JS modules
|
|
80
|
+
- **PostgreSQL** — production database support
|
|
81
|
+
- **Dynamic routes** — `/blog/:slug` with params
|
|
82
|
+
- **Imports** — `~import ./auth.aip` for modular apps
|
|
83
|
+
- **Versioning** — `~lang v2.9` for future compat
|
|
84
|
+
|
|
85
|
+
## Templates
|
|
86
|
+
|
|
87
|
+
Ready-to-use templates in `/templates`:
|
|
88
|
+
- `blog.aip` — Blog with comments, auth, pagination, cache
|
|
89
|
+
- `ecommerce.aip` — Shop with Stripe checkout
|
|
90
|
+
- `todo.aip` — Todo app with auth + priorities
|
|
91
|
+
- `analytics.aip` — Analytics dashboard with cache
|
|
92
|
+
- `chat.aip` — Real-time chat with polling
|
|
61
93
|
|
|
62
94
|
## Security
|
|
63
95
|
|
|
64
|
-
- bcrypt
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
-
|
|
70
|
-
-
|
|
96
|
+
Built-in: bcrypt, JWT, body limit, rate limiting, helmet headers, HttpOnly cookies.
|
|
97
|
+
See [SECURITY.md](../../SECURITY.md) for full threat model.
|
|
98
|
+
|
|
99
|
+
## ⚠ Honest limitations (alpha software)
|
|
100
|
+
|
|
101
|
+
- **Not production-ready without review** — always audit generated code
|
|
102
|
+
- **SQLite not for high traffic** — use `~db postgres $DATABASE_URL` for production
|
|
103
|
+
- **No SSR** — pages are rendered client-side (hydrate.js)
|
|
104
|
+
- **In-memory rate limiting** — resets on restart
|
|
105
|
+
- **No monorepo support yet** — single .aip file per app (use `~import` for modularization)
|
|
106
|
+
- **No TypeScript** — intentional (AI doesn't need types)
|
|
107
|
+
- **Performance degrades with >50 complex models** — use PostgreSQL indexes
|
|
108
|
+
|
|
109
|
+
## Prompt guide
|
|
110
|
+
|
|
111
|
+
See [PROMPT_GUIDE.md](../../PROMPT_GUIDE.md) for how to generate great apps with Claude/GPT.
|
|
71
112
|
|
|
72
113
|
## Source
|
|
73
114
|
|
package/bin/aiplang.js
CHANGED
|
@@ -5,7 +5,7 @@ const fs = require('fs')
|
|
|
5
5
|
const path = require('path')
|
|
6
6
|
const http = require('http')
|
|
7
7
|
|
|
8
|
-
const VERSION = '2.
|
|
8
|
+
const VERSION = '2.9.2'
|
|
9
9
|
const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
|
|
10
10
|
const cmd = process.argv[2]
|
|
11
11
|
const args = process.argv.slice(3)
|
|
@@ -182,6 +182,12 @@ sect{Users}
|
|
|
182
182
|
table @users { Name:name | Email:email | Plan:plan | edit PUT /api/users/{id} | delete /api/users/{id} | empty: No users yet. }
|
|
183
183
|
foot{{{name}} Dashboard}`,
|
|
184
184
|
|
|
185
|
+
hello: `# {{name}}
|
|
186
|
+
%home dark /
|
|
187
|
+
nav{{{name}}}
|
|
188
|
+
hero{Hello, World!|Built with aiplang.} animate:blur-in
|
|
189
|
+
foot{© {{year}} {{name}}}`,
|
|
190
|
+
|
|
185
191
|
landing: `# {{name}}
|
|
186
192
|
%home dark /
|
|
187
193
|
nav{{{name}}>/about:About>/contact:Contact}
|
|
@@ -470,7 +476,19 @@ if (cmd==='build') {
|
|
|
470
476
|
fs.readdirSync(input).filter(f=>f.endsWith('.aip')).forEach(f=>files.push(path.join(input,f)))
|
|
471
477
|
} else if(input.endsWith('.aip')&&fs.existsSync(input)){ files.push(input) }
|
|
472
478
|
if(!files.length){console.error(`\n ✗ No .aip files in: ${input}\n`);process.exit(1)}
|
|
473
|
-
|
|
479
|
+
// Resolve ~import directives recursively
|
|
480
|
+
function resolveImports(content, baseDir, seen=new Set()) {
|
|
481
|
+
return content.replace(/^~import\s+["']?([^"'\n]+)["']?$/mg, (_, importPath) => {
|
|
482
|
+
const resolved = path.resolve(baseDir, importPath.trim())
|
|
483
|
+
if (seen.has(resolved)) return '' // circular import protection
|
|
484
|
+
try {
|
|
485
|
+
seen.add(resolved)
|
|
486
|
+
const imported = fs.readFileSync(resolved, 'utf8')
|
|
487
|
+
return resolveImports(imported, path.dirname(resolved), seen)
|
|
488
|
+
} catch { return `# ~import failed: ${importPath}` }
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
const src=files.map(f=>resolveImports(fs.readFileSync(f,'utf8'), path.dirname(f))).join('\n---\n')
|
|
474
492
|
const pages=parsePages(src)
|
|
475
493
|
if(!pages.length){console.error('\n ✗ No pages found.\n');process.exit(1)}
|
|
476
494
|
fs.mkdirSync(outDir,{recursive:true})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aiplang",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.2",
|
|
4
4
|
"description": "AI-first web language. One .aip file = complete app. Frontend + backend + database + auth.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"aiplang",
|
|
@@ -43,7 +43,9 @@
|
|
|
43
43
|
"bcryptjs": "^2.4.3",
|
|
44
44
|
"jsonwebtoken": "^9.0.2",
|
|
45
45
|
"nodemailer": "^8.0.3",
|
|
46
|
+
"pg": "^8.11.0",
|
|
46
47
|
"sql.js": "^1.10.3",
|
|
47
|
-
"stripe": "^14.0.0"
|
|
48
|
+
"stripe": "^14.0.0",
|
|
49
|
+
"ws": "^8.16.0"
|
|
48
50
|
}
|
|
49
51
|
}
|
package/server/server.js
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
-
// aiplang Full-Stack Server v2 —
|
|
2
|
+
// aiplang Full-Stack Server v2.9 — Next.js competitive
|
|
3
|
+
|
|
4
|
+
// ── Auto-load .env file ───────────────────────────────────────────
|
|
5
|
+
;(function loadDotEnv() {
|
|
6
|
+
const envFiles = ['.env', '.env.local', '.env.production']
|
|
7
|
+
for (const f of envFiles) {
|
|
8
|
+
try {
|
|
9
|
+
const lines = require('fs').readFileSync(f, 'utf8').split('\n')
|
|
10
|
+
for (const line of lines) {
|
|
11
|
+
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/)
|
|
12
|
+
if (m && !process.env[m[1]]) {
|
|
13
|
+
process.env[m[1]] = m[2].replace(/^["']|["']$/g, '')
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
} catch {}
|
|
17
|
+
}
|
|
18
|
+
})()
|
|
3
19
|
// Features: ORM+relations, email, jobs/queues, admin panel, OAuth, soft deletes, events
|
|
4
20
|
|
|
5
21
|
const http = require('http')
|
|
@@ -11,26 +27,60 @@ const bcrypt = require('bcryptjs')
|
|
|
11
27
|
const jwt = require('jsonwebtoken')
|
|
12
28
|
const nodemailer = require('nodemailer').createTransport ? require('nodemailer') : null
|
|
13
29
|
|
|
14
|
-
// ──
|
|
30
|
+
// ── Database — SQLite (dev) + PostgreSQL (prod) ──────────────────
|
|
15
31
|
let SQL, DB_FILE, _db = null
|
|
16
|
-
|
|
17
|
-
|
|
32
|
+
let _pgPool = null // PostgreSQL connection pool
|
|
33
|
+
let _dbDriver = 'sqlite' // 'sqlite' | 'postgres'
|
|
34
|
+
|
|
35
|
+
async function getDB(dbConfig = { driver: 'sqlite', dsn: ':memory:' }) {
|
|
36
|
+
if (_db || _pgPool) return _db || _pgPool
|
|
37
|
+
const driver = dbConfig.driver || 'sqlite'
|
|
38
|
+
const dsn = dbConfig.dsn || ':memory:'
|
|
39
|
+
_dbDriver = driver
|
|
40
|
+
|
|
41
|
+
if (driver === 'postgres' || driver === 'postgresql' || dsn.startsWith('postgres')) {
|
|
42
|
+
try {
|
|
43
|
+
const { Pool } = require('pg')
|
|
44
|
+
_pgPool = new Pool({ connectionString: dsn, ssl: dsn.includes('ssl=true') ? { rejectUnauthorized: false } : false })
|
|
45
|
+
await _pgPool.query('SELECT 1') // test connection
|
|
46
|
+
console.log('[aiplang] DB: PostgreSQL ✓')
|
|
47
|
+
return _pgPool
|
|
48
|
+
} catch (e) {
|
|
49
|
+
console.error('[aiplang] PostgreSQL connection failed:', e.message)
|
|
50
|
+
console.log('[aiplang] Falling back to SQLite :memory:')
|
|
51
|
+
_dbDriver = 'sqlite'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// SQLite fallback
|
|
18
56
|
const initSqlJs = require('sql.js')
|
|
19
57
|
SQL = await initSqlJs()
|
|
20
|
-
if (
|
|
21
|
-
_db = new SQL.Database(fs.readFileSync(
|
|
58
|
+
if (dsn !== ':memory:' && fs.existsSync(dsn)) {
|
|
59
|
+
_db = new SQL.Database(fs.readFileSync(dsn))
|
|
22
60
|
} else {
|
|
23
61
|
_db = new SQL.Database()
|
|
24
62
|
}
|
|
25
|
-
DB_FILE =
|
|
63
|
+
DB_FILE = dsn !== ':memory:' ? dsn : null
|
|
64
|
+
console.log('[aiplang] DB: ', dsn)
|
|
26
65
|
return _db
|
|
27
66
|
}
|
|
67
|
+
|
|
28
68
|
function persistDB() {
|
|
29
|
-
if (!_db || !DB_FILE
|
|
69
|
+
if (!_db || !DB_FILE) return
|
|
30
70
|
try { fs.writeFileSync(DB_FILE, Buffer.from(_db.export())) } catch {}
|
|
31
71
|
}
|
|
32
72
|
let _dirty = false, _persistTimer = null
|
|
73
|
+
|
|
33
74
|
function dbRun(sql, params = []) {
|
|
75
|
+
// Normalize ? placeholders to $1,$2 for postgres
|
|
76
|
+
if (_pgPool) {
|
|
77
|
+
const pgSql = sql.replace(/\?/g, (_, i) => {
|
|
78
|
+
let n = 0; sql.slice(0, sql.indexOf(_)+n).replace(/\?/g, () => ++n); return `$${++n}`
|
|
79
|
+
})
|
|
80
|
+
// Async run — fire and forget for writes (sync API compatibility)
|
|
81
|
+
_pgPool.query(convertPlaceholders(sql), params).catch(e => console.error('[aiplang:pg] Query error:', e.message))
|
|
82
|
+
return
|
|
83
|
+
}
|
|
34
84
|
_db.run(sql, params)
|
|
35
85
|
_dirty = true
|
|
36
86
|
if (!_persistTimer) _persistTimer = setTimeout(() => {
|
|
@@ -38,12 +88,40 @@ function dbRun(sql, params = []) {
|
|
|
38
88
|
_persistTimer = null
|
|
39
89
|
}, 200)
|
|
40
90
|
}
|
|
91
|
+
|
|
92
|
+
function convertPlaceholders(sql) {
|
|
93
|
+
let i = 0; return sql.replace(/\?/g, () => `$${++i}`)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function dbRunAsync(sql, params = []) {
|
|
97
|
+
if (_pgPool) return _pgPool.query(convertPlaceholders(sql), params)
|
|
98
|
+
dbRun(sql, params)
|
|
99
|
+
}
|
|
100
|
+
|
|
41
101
|
function dbAll(sql, params = []) {
|
|
102
|
+
if (_pgPool) {
|
|
103
|
+
// For sync ORM compat — return from cache or throw
|
|
104
|
+
// Full async support via dbAllAsync
|
|
105
|
+
return []
|
|
106
|
+
}
|
|
42
107
|
const stmt = _db.prepare(sql); stmt.bind(params)
|
|
43
108
|
const rows = []; while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free()
|
|
44
109
|
return rows
|
|
45
110
|
}
|
|
111
|
+
|
|
112
|
+
async function dbAllAsync(sql, params = []) {
|
|
113
|
+
if (_pgPool) {
|
|
114
|
+
const r = await _pgPool.query(convertPlaceholders(sql), params)
|
|
115
|
+
return r.rows
|
|
116
|
+
}
|
|
117
|
+
return dbAll(sql, params)
|
|
118
|
+
}
|
|
119
|
+
|
|
46
120
|
function dbGet(sql, params = []) { return dbAll(sql, params)[0] || null }
|
|
121
|
+
async function dbGetAsync(sql, params = []) {
|
|
122
|
+
const rows = await dbAllAsync(sql, params)
|
|
123
|
+
return rows[0] || null
|
|
124
|
+
}
|
|
47
125
|
|
|
48
126
|
// ── Helpers ───────────────────────────────────────────────────────
|
|
49
127
|
const uuid = () => crypto.randomUUID()
|
|
@@ -65,6 +143,50 @@ let JWT_EXPIRE = '7d'
|
|
|
65
143
|
const generateJWT = (user) => jwt.sign({ id: user.id, email: user.email, role: user.role || 'user' }, JWT_SECRET, { expiresIn: JWT_EXPIRE })
|
|
66
144
|
const verifyJWT = (token) => { try { return jwt.verify(token, JWT_SECRET) } catch { return null } }
|
|
67
145
|
|
|
146
|
+
// ── WebSocket Realtime Server ─────────────────────────────────────
|
|
147
|
+
let _wsServer = null
|
|
148
|
+
const _wsClients = new Set()
|
|
149
|
+
const _wsChannels = {} // channel → Set<ws>
|
|
150
|
+
|
|
151
|
+
function setupRealtime(server) {
|
|
152
|
+
try {
|
|
153
|
+
const { WebSocketServer } = require('ws')
|
|
154
|
+
_wsServer = new WebSocketServer({ server })
|
|
155
|
+
_wsServer.on('connection', (ws, req) => {
|
|
156
|
+
_wsClients.add(ws)
|
|
157
|
+
ws.on('message', raw => {
|
|
158
|
+
try {
|
|
159
|
+
const msg = JSON.parse(raw)
|
|
160
|
+
if (msg.type === 'subscribe' && msg.channel) {
|
|
161
|
+
if (!_wsChannels[msg.channel]) _wsChannels[msg.channel] = new Set()
|
|
162
|
+
_wsChannels[msg.channel].add(ws)
|
|
163
|
+
ws.send(JSON.stringify({ type: 'subscribed', channel: msg.channel }))
|
|
164
|
+
}
|
|
165
|
+
} catch {}
|
|
166
|
+
})
|
|
167
|
+
ws.on('close', () => {
|
|
168
|
+
_wsClients.delete(ws)
|
|
169
|
+
Object.values(_wsChannels).forEach(s => s.delete(ws))
|
|
170
|
+
})
|
|
171
|
+
ws.send(JSON.stringify({ type: 'connected', ts: Date.now() }))
|
|
172
|
+
})
|
|
173
|
+
console.log('[aiplang] Realtime: WebSocket server ready')
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.warn('[aiplang] Realtime: ws not available —', e.message)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function broadcast(channel, data) {
|
|
180
|
+
const msg = JSON.stringify({ type: 'update', channel, data, ts: Date.now() })
|
|
181
|
+
const targets = channel ? (_wsChannels[channel] || new Set()) : _wsClients
|
|
182
|
+
targets.forEach(ws => { try { if (ws.readyState === 1) ws.send(msg) } catch {} })
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function realtimeMiddleware(res) {
|
|
186
|
+
// Inject broadcast helper into route context
|
|
187
|
+
res.broadcast = broadcast
|
|
188
|
+
}
|
|
189
|
+
|
|
68
190
|
// ── Queue system ──────────────────────────────────────────────────
|
|
69
191
|
const QUEUE = []
|
|
70
192
|
const WORKERS = {}
|
|
@@ -90,6 +212,28 @@ async function processQueue() {
|
|
|
90
212
|
QUEUE_RUNNING = false
|
|
91
213
|
}
|
|
92
214
|
|
|
215
|
+
// ── Cache in-memory com TTL ──────────────────────────────────────
|
|
216
|
+
const _cache = new Map()
|
|
217
|
+
function cacheSet(key, value, ttlMs = 60000) {
|
|
218
|
+
_cache.set(key, { value, expires: Date.now() + ttlMs })
|
|
219
|
+
}
|
|
220
|
+
function cacheGet(key) {
|
|
221
|
+
const item = _cache.get(key)
|
|
222
|
+
if (!item) return null
|
|
223
|
+
if (item.expires < Date.now()) { _cache.delete(key); return null }
|
|
224
|
+
return item.value
|
|
225
|
+
}
|
|
226
|
+
function cacheDel(key) { _cache.delete(key) }
|
|
227
|
+
function cacheClear(pattern) {
|
|
228
|
+
if (!pattern) { _cache.clear(); return }
|
|
229
|
+
for (const k of _cache.keys()) if (k.startsWith(pattern)) _cache.delete(k)
|
|
230
|
+
}
|
|
231
|
+
// Auto-cleanup expired entries every 5 minutes
|
|
232
|
+
setInterval(() => {
|
|
233
|
+
const now = Date.now()
|
|
234
|
+
for (const [k,v] of _cache.entries()) if (v.expires < now) _cache.delete(k)
|
|
235
|
+
}, 300000)
|
|
236
|
+
|
|
93
237
|
// ── Email ─────────────────────────────────────────────────────────
|
|
94
238
|
let MAIL_CONFIG = null
|
|
95
239
|
let MAIL_TRANSPORTER = null
|
|
@@ -313,7 +457,7 @@ function migrateModels(models) {
|
|
|
313
457
|
// PARSER
|
|
314
458
|
// ═══════════════════════════════════════════════════════════════════
|
|
315
459
|
function parseApp(src) {
|
|
316
|
-
const app = { env:[], db:null, auth:null, mail:null, stripe:null, s3:null, plugins:[], middleware:[], models:[], apis:[], pages:[], jobs:[], events:[], admin:null }
|
|
460
|
+
const app = { env:[], db:null, auth:null, mail:null, stripe:null, s3:null, plugins:[], middleware:[], models:[], apis:[], pages:[], jobs:[], events:[], admin:null, realtime:false }
|
|
317
461
|
const lines = src.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('#'))
|
|
318
462
|
let i=0, inModel=false, inAPI=false, curModel=null, curAPI=null, pageLines=[], inPage=false
|
|
319
463
|
|
|
@@ -332,6 +476,7 @@ function parseApp(src) {
|
|
|
332
476
|
if (line.startsWith('~mail ')) { app.mail = parseMailLine(line.slice(6)); i++; continue }
|
|
333
477
|
if (line.startsWith('~middleware ')) { app.middleware = line.slice(12).split('|').map(s=>s.trim()); i++; continue }
|
|
334
478
|
if (line.startsWith('~admin')) { app.admin = parseAdminLine(line); i++; continue }
|
|
479
|
+
if (line.startsWith('~realtime')) { app.realtime = true; i++; continue }
|
|
335
480
|
if (line.startsWith('~stripe ')) { app.stripe = parseStripeLine(line.slice(8)); i++; continue }
|
|
336
481
|
if (line.startsWith('~plan ')) { app.stripe = app.stripe || {}; app.stripe.plans = app.stripe.plans || {}; parsePlanLine(line.slice(6), app.stripe.plans); i++; continue }
|
|
337
482
|
if (line.startsWith('~s3 ')) { app.s3 = parseS3Line(line.slice(4)); i++; continue }
|
|
@@ -504,6 +649,10 @@ function compileRoute(route, server) {
|
|
|
504
649
|
if (result !== null && result !== undefined) ctx.lastResult = result
|
|
505
650
|
}
|
|
506
651
|
|
|
652
|
+
// Auto-cache result if ~cache was called
|
|
653
|
+
if (ctx.vars['__cacheKey'] && ctx.lastResult !== undefined) {
|
|
654
|
+
cacheSet(ctx.vars['__cacheKey'], ctx.lastResult, ctx.vars['__cacheTTL'] || 60000)
|
|
655
|
+
}
|
|
507
656
|
if (!res.writableEnded) res.json(200, ctx.lastResult ?? {})
|
|
508
657
|
})
|
|
509
658
|
}
|
|
@@ -511,6 +660,39 @@ function compileRoute(route, server) {
|
|
|
511
660
|
async function execOp(line, ctx, server) {
|
|
512
661
|
line = line.trim(); if (!line) return null
|
|
513
662
|
|
|
663
|
+
// ~cache key ttl — cache result
|
|
664
|
+
if (line.startsWith('~cache ')) {
|
|
665
|
+
const parts=line.slice(7).trim().split(/\s+/)
|
|
666
|
+
const key=parts[0], ttl=parseInt(parts[1]||'60')*1000
|
|
667
|
+
const cached=cacheGet(key)
|
|
668
|
+
if (cached!==null) { ctx.res.json(200,cached); return '__DONE__' }
|
|
669
|
+
ctx.vars['__cacheKey']=key; ctx.vars['__cacheTTL']=ttl
|
|
670
|
+
return null
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ~cache:clear pattern — invalidate cache
|
|
674
|
+
if (line.startsWith('~cache:clear')) {
|
|
675
|
+
const pattern=line.slice(12).trim()||null; cacheClear(pattern); return null
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ~broadcast channel data — push to WebSocket clients
|
|
679
|
+
if (line.startsWith('~broadcast ')) {
|
|
680
|
+
const parts=line.slice(11).trim().split(/\s+/)
|
|
681
|
+
const channel=parts[0]; const data=resolveVar(parts.slice(1).join(' '),ctx)
|
|
682
|
+
broadcast(channel,data); return null
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// ~rateLimit key max window — custom rate limiter per key
|
|
686
|
+
if (line.startsWith('~rateLimit ') || line.startsWith('~rate-limit ')) {
|
|
687
|
+
const parts=line.slice(line.indexOf(' ')+1).trim().split(/\s+/)
|
|
688
|
+
const key=resolveVar(parts[0],ctx)||'default'
|
|
689
|
+
const max=parseInt(parts[1]||'10'), win=parseInt(parts[2]||'60')*1000
|
|
690
|
+
const cKey=`rl:${key}:${Math.floor(Date.now()/win)}`
|
|
691
|
+
const count=(cacheGet(cKey)||0)+1; cacheSet(cKey,count,win)
|
|
692
|
+
if(count>max){ctx.res.error(429,'Rate limit exceeded');return '__DONE__'}
|
|
693
|
+
return null
|
|
694
|
+
}
|
|
695
|
+
|
|
514
696
|
// ~hash field
|
|
515
697
|
if (line.startsWith('~hash ')) { const f=line.slice(6).trim(); if(ctx.body[f])ctx.body[f]=await bcrypt.hash(ctx.body[f],12); return null }
|
|
516
698
|
|
|
@@ -563,14 +745,14 @@ async function execOp(line, ctx, server) {
|
|
|
563
745
|
// insert Model($body)
|
|
564
746
|
if (line.startsWith('insert ')) {
|
|
565
747
|
const modelName=line.match(/insert\s+(\w+)/)?.[1]; const m=server.models[modelName]
|
|
566
|
-
if (m) { ctx.vars['inserted']=m.create({...ctx.body}); return ctx.vars['inserted'] }
|
|
748
|
+
if (m) { ctx.vars['inserted']=m.create({...ctx.body}); broadcast(modelName.toLowerCase(), {action:'created',data:ctx.vars['inserted']}); return ctx.vars['inserted'] }
|
|
567
749
|
return null
|
|
568
750
|
}
|
|
569
751
|
|
|
570
752
|
// update Model($id, $body)
|
|
571
753
|
if (line.startsWith('update ')) {
|
|
572
754
|
const modelName=line.match(/update\s+(\w+)/)?.[1]; const m=server.models[modelName]
|
|
573
|
-
if (m) { const id=ctx.params.id||ctx.vars['id']; ctx.vars['updated']=m.update(id,{...ctx.body}); return ctx.vars['updated'] }
|
|
755
|
+
if (m) { const id=ctx.params.id||ctx.vars['id']; ctx.vars['updated']=m.update(id,{...ctx.body}); broadcast(modelName.toLowerCase(), {action:'updated',data:ctx.vars['updated']}); return ctx.vars['updated'] }
|
|
574
756
|
return null
|
|
575
757
|
}
|
|
576
758
|
|
|
@@ -1221,7 +1403,7 @@ function getMime(filename) {
|
|
|
1221
1403
|
// module.exports = (opts) => ({ name: '...', setup(srv, app, utils) { ... } })
|
|
1222
1404
|
|
|
1223
1405
|
const PLUGIN_UTILS = {
|
|
1224
|
-
uuid, now, emit, on, dispatch, resolveEnv, dbRun, dbAll, dbGet,
|
|
1406
|
+
uuid, now, emit, on, dispatch, resolveEnv, dbRun, dbAll, dbGet, dbAllAsync, dbGetAsync,
|
|
1225
1407
|
parseSize, s3Upload, s3Delete, s3PresignedUrl,
|
|
1226
1408
|
generateJWT, verifyJWT,
|
|
1227
1409
|
getMime,
|
|
@@ -1368,9 +1550,10 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1368
1550
|
}
|
|
1369
1551
|
|
|
1370
1552
|
// DB setup
|
|
1371
|
-
const
|
|
1372
|
-
|
|
1373
|
-
|
|
1553
|
+
const dbDsn = app.db ? (resolveEnv(app.db.dsn) || app.db.dsn) : ':memory:'
|
|
1554
|
+
const dbConfig = { driver: app.db?.driver || 'sqlite', dsn: dbDsn }
|
|
1555
|
+
await getDB(dbConfig)
|
|
1556
|
+
console.log(`[aiplang] DB: ${dbDsn}`)
|
|
1374
1557
|
|
|
1375
1558
|
// Migrations
|
|
1376
1559
|
console.log(`[aiplang] Tables:`)
|
|
@@ -1435,7 +1618,7 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1435
1618
|
|
|
1436
1619
|
// Health
|
|
1437
1620
|
srv.addRoute('GET', '/health', (req, res) => res.json(200, {
|
|
1438
|
-
status:'ok', version:'2.
|
|
1621
|
+
status:'ok', version:'2.9.2',
|
|
1439
1622
|
models: app.models.map(m=>m.name),
|
|
1440
1623
|
routes: app.apis.length, pages: app.pages.length,
|
|
1441
1624
|
admin: app.admin?.prefix || null,
|
|
@@ -1459,7 +1642,7 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1459
1642
|
return srv
|
|
1460
1643
|
}
|
|
1461
1644
|
|
|
1462
|
-
module.exports = { startServer, parseApp, Model, getDB, dispatch, on, emit, sendMail, setupStripe, registerStripeRoutes, setupS3, registerS3Routes, s3Upload, s3Delete, s3PresignedUrl, PLUGIN_UTILS }
|
|
1645
|
+
module.exports = { startServer, parseApp, Model, getDB, dispatch, on, emit, sendMail, setupStripe, registerStripeRoutes, setupS3, registerS3Routes, s3Upload, s3Delete, s3PresignedUrl, cacheSet, cacheGet, cacheDel, broadcast, PLUGIN_UTILS }
|
|
1463
1646
|
if (require.main === module) {
|
|
1464
1647
|
const f=process.argv[2], p=parseInt(process.argv[3]||process.env.PORT||'3000')
|
|
1465
1648
|
if (!f) { console.error('Usage: node server.js <app.aip> [port]'); process.exit(1) }
|