aiplang 2.10.9 → 2.11.1
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/bin/aiplang.js +103 -2
- package/package.json +1 -1
- package/runtime/aiplang-hydrate.js +183 -4
- package/server/server.js +183 -16
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.11.1'
|
|
9
9
|
const RUNTIME_DIR = path.join(__dirname, '..', 'runtime')
|
|
10
10
|
const cmd = process.argv[2]
|
|
11
11
|
const args = process.argv.slice(3)
|
|
@@ -36,6 +36,8 @@ if (!cmd||cmd==='--help'||cmd==='-h') {
|
|
|
36
36
|
npx aiplang init [name] --template my-custom use a saved custom template
|
|
37
37
|
npx aiplang serve [dir] dev server + hot reload
|
|
38
38
|
npx aiplang build [dir/file] compile → static HTML
|
|
39
|
+
npx aiplang validate <app.aip> validate syntax with AI-friendly errors
|
|
40
|
+
npx aiplang context [app.aip] dump minimal AI context (<500 tokens)
|
|
39
41
|
npx aiplang new <page> new page template
|
|
40
42
|
npx aiplang --version
|
|
41
43
|
|
|
@@ -441,6 +443,104 @@ if (cmd==='new') {
|
|
|
441
443
|
process.exit(0)
|
|
442
444
|
}
|
|
443
445
|
|
|
446
|
+
function validateAipSrc(source) {
|
|
447
|
+
const errors = []
|
|
448
|
+
const lines = source.split('\n')
|
|
449
|
+
const knownDirs = new Set(['db','auth','env','mail','s3','stripe','plan','admin','realtime','use','plugin','import','store','ssr','interval','mount','theme','guard','validate','unique','hash','check','cache','rateLimit','broadcast','soft-delete','belongs'])
|
|
450
|
+
for (let i=0; i<lines.length; i++) {
|
|
451
|
+
const line = lines[i].trim()
|
|
452
|
+
if (!line || line.startsWith('#')) continue
|
|
453
|
+
const dm = line.match(/^(guard|validate|unique|hash|check|cache|mount|store|ssr|interval|auth|db|env|use|plugin|import|theme|rateLimit|broadcast)\b/)
|
|
454
|
+
if (dm && !line.startsWith('~') && !line.startsWith('api ') && !line.startsWith('model ') && !line.startsWith('%')) {
|
|
455
|
+
errors.push({ line:i+1, code:line, message:`Missing ~ before '${dm[1]}'`, fix:`~${line}`, severity:'error' })
|
|
456
|
+
}
|
|
457
|
+
if (line.startsWith('api ') && !line.includes('{')) {
|
|
458
|
+
errors.push({ line:i+1, code:line, message:"api block missing '{'", fix:line+' { return {} }', severity:'error' })
|
|
459
|
+
}
|
|
460
|
+
if (/^[a-z_]+\s+-\s+[a-z]/.test(line) && !line.startsWith('api') && !line.startsWith('model')) {
|
|
461
|
+
errors.push({ line:i+1, code:line, message:"Use ':' not '-' in field definitions", fix:line.replace(/\s*-\s*/g,' : '), severity:'error' })
|
|
462
|
+
}
|
|
463
|
+
if (line.startsWith('~')) {
|
|
464
|
+
const dir = line.slice(1).split(/\s/)[0]
|
|
465
|
+
if (!knownDirs.has(dir)) errors.push({ line:i+1, code:line, message:`Unknown directive ~${dir}`, severity:'warning' })
|
|
466
|
+
}
|
|
467
|
+
if (/^table\s*\{/.test(line)) {
|
|
468
|
+
errors.push({ line:i+1, code:line, message:"table missing @binding — e.g.: table @users { Name:name | ... }", severity:'error' })
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return errors
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
if (cmd==='validate'||cmd==='check'||cmd==='lint') {
|
|
475
|
+
const file = args[0]
|
|
476
|
+
if (!file) { console.error('\n Usage: aiplang validate <app.aip>\n'); process.exit(1) }
|
|
477
|
+
if (!require('fs').existsSync(file)) { console.error(`\n ✗ File not found: ${file}\n`); process.exit(1) }
|
|
478
|
+
const src = require('fs').readFileSync(file,'utf8')
|
|
479
|
+
const errs = validateAipSrc(src)
|
|
480
|
+
if (!errs.length) { console.log('\n ✓ Syntax OK — safe to run\n'); process.exit(0) }
|
|
481
|
+
console.log(`\n ✗ ${errs.length} issue(s) found in ${file}:\n`)
|
|
482
|
+
errs.forEach(e => {
|
|
483
|
+
const icon = e.severity==='error' ? '✗' : '⚠'
|
|
484
|
+
console.log(` ${icon} Line ${e.line}: ${e.message}`)
|
|
485
|
+
console.log(` ${e.code}`)
|
|
486
|
+
if (e.fix) console.log(` Fix: ${e.fix}`)
|
|
487
|
+
})
|
|
488
|
+
console.log()
|
|
489
|
+
process.exit(errs.some(e=>e.severity==='error') ? 1 : 0)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (cmd==='context'||cmd==='ctx') {
|
|
493
|
+
const file = args[0] || 'app.aip'
|
|
494
|
+
const exists = require('fs').existsSync
|
|
495
|
+
const src = exists(file) ? require('fs').readFileSync(file,'utf8') : null
|
|
496
|
+
if (!src) { console.log('\n Usage: aiplang context [app.aip]\n Dumps minimal AI context (~200 tokens).\n'); process.exit(0) }
|
|
497
|
+
// Use server's parseApp for full app structure
|
|
498
|
+
const serverPath = require('path').join(__dirname,'../server/server.js')
|
|
499
|
+
let app = { models:[], apis:[], pages:[], db:null, auth:null }
|
|
500
|
+
try {
|
|
501
|
+
const srv = require(serverPath)
|
|
502
|
+
if (srv.parseApp) app = srv.parseApp(src)
|
|
503
|
+
} catch {
|
|
504
|
+
// Fallback: basic parse for models + routes
|
|
505
|
+
const modelRx = /^model\s+(\w+)/gm
|
|
506
|
+
const apiRx = /^api\s+(\w+)\s+(\S+)/gm
|
|
507
|
+
let m
|
|
508
|
+
while((m=modelRx.exec(src))) app.models.push({name:m[1],fields:[]})
|
|
509
|
+
while((m=apiRx.exec(src))) app.apis.push({method:m[1],path:m[2],guards:[]})
|
|
510
|
+
const pageRx = /^%(\w+)\s+(\w+)\s+(\S+)/gm
|
|
511
|
+
while((m=pageRx.exec(src))) app.pages.push({id:m[1],theme:m[2],route:m[3],state:{},queries:[]})
|
|
512
|
+
}
|
|
513
|
+
const out = [
|
|
514
|
+
`# aiplang app — ${file}`,
|
|
515
|
+
'# paste into AI for maintenance/customization',
|
|
516
|
+
'',
|
|
517
|
+
'## MODELS'
|
|
518
|
+
]
|
|
519
|
+
for (const m of app.models||[]) {
|
|
520
|
+
const fields = m.fields.map(f=>`${f.name}:${f.type}${f.modifiers?.length?':'+f.modifiers.join(':'):''}`).join(' ')
|
|
521
|
+
out.push(`model ${m.name} { ${fields} }`)
|
|
522
|
+
}
|
|
523
|
+
out.push('')
|
|
524
|
+
out.push('## ROUTES')
|
|
525
|
+
for (const r of app.apis||[]) {
|
|
526
|
+
const g = r.guards?.length ? ` [${r.guards.join(',')}]` : ''
|
|
527
|
+
const v = r.validate?.length ? ` validate:${r.validate.length}` : ''
|
|
528
|
+
out.push(`${r.method.padEnd(7)}${r.path}${g}${v}`)
|
|
529
|
+
}
|
|
530
|
+
out.push('')
|
|
531
|
+
out.push('## PAGES')
|
|
532
|
+
for (const p of app.pages||[]) {
|
|
533
|
+
const state = Object.keys(p.state||{}).map(k=>`@${k}`).join(' ')
|
|
534
|
+
const queries = (p.queries||[]).map(q=>`${q.trigger}:${q.path}`).join(' ')
|
|
535
|
+
out.push(`%${p.id} ${p.theme||'dark'} ${p.route} | state:${state||'none'} | queries:${queries||'none'}`)
|
|
536
|
+
}
|
|
537
|
+
if (app.db) { out.push(''); out.push(`## CONFIG\ndb:${app.db.driver} auth:${app.auth?'jwt':'none'}`) }
|
|
538
|
+
const ctx = out.join('\n')
|
|
539
|
+
console.log(ctx)
|
|
540
|
+
console.log(`\n# ~${Math.ceil(ctx.length/4)} tokens`)
|
|
541
|
+
process.exit(0)
|
|
542
|
+
}
|
|
543
|
+
|
|
444
544
|
if (cmd==='build') {
|
|
445
545
|
const outIdx=args.indexOf('--out')
|
|
446
546
|
const outDir=outIdx!==-1?args[outIdx+1]:'dist'
|
|
@@ -1060,7 +1160,8 @@ function rTable(b) {
|
|
|
1060
1160
|
const span=cols.length+((b.editPath||b.deletePath)?1:0)
|
|
1061
1161
|
const fallbackAttr=b.fallback?` data-fx-fallback="${esc(b.fallback)}"`:''
|
|
1062
1162
|
const retryAttr=b.retry?` data-fx-retry="${esc(b.retry)}"`:''
|
|
1063
|
-
|
|
1163
|
+
const exitAttr=b.animateExit?` data-fx-exit="${esc(b.animateExit)}"`:'';
|
|
1164
|
+
return `<div class="fx-table-wrap"><table class="fx-table"${exitAttr} data-fx-table="${esc(b.binding)}" data-fx-cols='${keys}' data-fx-col-map='${cm}'${ea}${da}${fallbackAttr}${retryAttr}><thead><tr>${ths}${at}</tr></thead><tbody class="fx-tbody"><tr><td colspan="${span}" class="fx-td-empty">${esc(b.empty)}</td></tr></tbody></table></div>\n`
|
|
1064
1165
|
}
|
|
1065
1166
|
|
|
1066
1167
|
function rForm(b) {
|
package/package.json
CHANGED
|
@@ -499,7 +499,7 @@ function hydrateTables() {
|
|
|
499
499
|
db.onclick = async () => {
|
|
500
500
|
if (!await confirm('Delete this record? This cannot be undone.')) return
|
|
501
501
|
const {ok,data} = await http('DELETE', resolvePath(delPath,_row), null)
|
|
502
|
-
if (ok) { set(key,(get(key)||[]).filter((_,j)=>j!==_i));
|
|
502
|
+
if (ok) { const _dtr=db.closest('tr');_animateExit(_dtr,'fx-exit',()=>set(key,(get(key)||[]).filter((_,j)=>j!==_i)));toast('Deleted','ok') }
|
|
503
503
|
else toast(data.message||'Error deleting','err')
|
|
504
504
|
}
|
|
505
505
|
actTd.appendChild(db)
|
|
@@ -539,7 +539,7 @@ function hydrateTables() {
|
|
|
539
539
|
const actTd = document.createElement('td')
|
|
540
540
|
actTd.className = 'fx-td fx-td-actions'; actTd.style.cssText = 'white-space:nowrap'
|
|
541
541
|
if (editPath) { const eb=document.createElement('button');eb.className='fx-action-btn fx-edit-btn';eb.textContent='✎ Edit';const _r=row,_i=idx;eb.onclick=async()=>{const upd=await editModal(_r,cols,editPath,editMethod,key);if(!upd)return;const arr=[...(get(key)||[])];arr[_i]={..._r,...upd};set(key,arr)};actTd.appendChild(eb) }
|
|
542
|
-
if (delPath) { const db=document.createElement('button');db.className='fx-action-btn fx-delete-btn';db.textContent='✕ Delete';const _r=row,_i=idx;db.onclick=async()=>{if(!await confirm('Delete?'))return;const{ok,data}=await http('DELETE',resolvePath(delPath,_r),null);if(ok){set(key,(get(key)||[]).filter((_,j)=>j!==_i));toast('Deleted','ok')}else toast(data.message||'Error','err')};actTd.appendChild(db) }
|
|
542
|
+
if (delPath) { const db=document.createElement('button');db.className='fx-action-btn fx-delete-btn';db.textContent='✕ Delete';const _r=row,_i=idx;db.onclick=async()=>{if(!await confirm('Delete?'))return;const{ok,data}=await http('DELETE',resolvePath(delPath,_r),null);if(ok){const _dtr2=db.closest('tr');_animateExit(_dtr2,'fx-exit',()=>set(key,(get(key)||[]).filter((_,j)=>j!==_i)));toast('Deleted','ok')}else toast(data.message||'Error','err')};actTd.appendChild(db) }
|
|
543
543
|
tr.appendChild(actTd)
|
|
544
544
|
}
|
|
545
545
|
return tr
|
|
@@ -752,6 +752,32 @@ function initAnimations() {
|
|
|
752
752
|
.fx-visible.fx-anim-stagger > *:nth-child(6) { animation-delay: .5s }
|
|
753
753
|
.fx-anim-bounce { animation: fx-bounce 1.5s ease-in-out infinite !important; opacity: 1 !important }
|
|
754
754
|
.fx-anim-pulse { animation: fx-pulse-ring 2s ease infinite !important; opacity: 1 !important }
|
|
755
|
+
|
|
756
|
+
/* ── Exit animations (AnimatePresence) ─────── */
|
|
757
|
+
@keyframes fx-exit-fade { to{opacity:0;transform:scale(.95)} }
|
|
758
|
+
@keyframes fx-exit-up { to{opacity:0;transform:translateY(-16px)} }
|
|
759
|
+
@keyframes fx-exit-down { to{opacity:0;transform:translateY(16px)} }
|
|
760
|
+
@keyframes fx-exit-left { to{opacity:0;transform:translateX(-20px)} }
|
|
761
|
+
@keyframes fx-exit-right { to{opacity:0;transform:translateX(20px)} }
|
|
762
|
+
@keyframes fx-exit-shrink { to{opacity:0;transform:scale(0);max-height:0;padding:0;margin:0} }
|
|
763
|
+
.fx-exit { pointer-events:none!important; animation: fx-exit-fade .25s ease forwards }
|
|
764
|
+
.fx-exit-up { pointer-events:none!important; animation: fx-exit-up .25s ease forwards }
|
|
765
|
+
.fx-exit-down { pointer-events:none!important; animation: fx-exit-down .25s ease forwards }
|
|
766
|
+
.fx-exit-left { pointer-events:none!important; animation: fx-exit-left .22s ease forwards }
|
|
767
|
+
.fx-exit-right { pointer-events:none!important; animation: fx-exit-right .22s ease forwards }
|
|
768
|
+
.fx-exit-shrink { pointer-events:none!important; animation: fx-exit-shrink .3s ease forwards; overflow:hidden }
|
|
769
|
+
|
|
770
|
+
/* ── Enter animations for new rows ─────────── */
|
|
771
|
+
@keyframes fx-enter-row { from{opacity:0;transform:translateX(-8px)} to{opacity:1;transform:none} }
|
|
772
|
+
.fx-row-enter { animation: fx-enter-row .25s ease both }
|
|
773
|
+
|
|
774
|
+
/* ── FLIP layout transition ─────────────────── */
|
|
775
|
+
.fx-flip-pending { transition: transform .3s cubic-bezier(.4,0,.2,1) !important }
|
|
776
|
+
|
|
777
|
+
/* ── Drag with inertia ───────────────────────── */
|
|
778
|
+
.fx-dragging { opacity:.7; cursor:grabbing!important; z-index:1000; position:relative }
|
|
779
|
+
.fx-drag-ghost { opacity:.3; transition:none }
|
|
780
|
+
.fx-drop-target { outline:2px dashed rgba(99,102,241,.5); outline-offset:2px }
|
|
755
781
|
`
|
|
756
782
|
document.head.appendChild(style)
|
|
757
783
|
|
|
@@ -772,8 +798,149 @@ function initAnimations() {
|
|
|
772
798
|
observer.observe(el)
|
|
773
799
|
})
|
|
774
800
|
|
|
775
|
-
|
|
776
|
-
|
|
801
|
+
// ── AnimatePresence — animate elements before DOM removal ──────────
|
|
802
|
+
// Like Framer Motion AnimatePresence. exitClass: 'fx-exit', 'fx-exit-left', etc.
|
|
803
|
+
function _animateExit(el, exitClass, callback) {
|
|
804
|
+
if (!el) { callback && callback(); return }
|
|
805
|
+
exitClass = exitClass || 'fx-exit'
|
|
806
|
+
el.classList.add(exitClass)
|
|
807
|
+
const done = () => { el.removeEventListener('animationend', done); callback && callback() }
|
|
808
|
+
el.addEventListener('animationend', done, { once: true })
|
|
809
|
+
// Fallback: force removal after 350ms max (safety net)
|
|
810
|
+
setTimeout(() => { el.classList.contains(exitClass) && callback && callback() }, 350)
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// ── FLIP layout animation ─────────────────────────────────────────
|
|
814
|
+
// Capture positions BEFORE a DOM change, then animate FROM old TO new
|
|
815
|
+
// Like Framer Motion layout prop — zero extra markup required
|
|
816
|
+
function _flipCapture(elements) {
|
|
817
|
+
const rects = new Map()
|
|
818
|
+
elements.forEach(el => rects.set(el, el.getBoundingClientRect()))
|
|
819
|
+
return rects
|
|
820
|
+
}
|
|
821
|
+
function _flipPlay(rects, duration) {
|
|
822
|
+
duration = duration || 300
|
|
823
|
+
rects.forEach((oldRect, el) => {
|
|
824
|
+
if (!el.isConnected) return
|
|
825
|
+
const newRect = el.getBoundingClientRect()
|
|
826
|
+
const dx = oldRect.left - newRect.left
|
|
827
|
+
const dy = oldRect.top - newRect.top
|
|
828
|
+
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) return
|
|
829
|
+
// Invert: move el back to where it was
|
|
830
|
+
el.style.transform = `translate(${dx}px,${dy}px)`
|
|
831
|
+
el.style.transition = 'none'
|
|
832
|
+
// Play: animate to final position
|
|
833
|
+
requestAnimationFrame(() => requestAnimationFrame(() => {
|
|
834
|
+
el.style.transition = `transform ${duration}ms cubic-bezier(.4,0,.2,1)`
|
|
835
|
+
el.style.transform = ''
|
|
836
|
+
const clean = () => { el.style.transform = ''; el.style.transition = '' }
|
|
837
|
+
el.addEventListener('transitionend', clean, { once: true })
|
|
838
|
+
}))
|
|
839
|
+
})
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ── Spring physics (improved — overshoot + settle) ────────────────
|
|
843
|
+
function _spring(el, prop, from, to, opts) {
|
|
844
|
+
const k = opts && opts.stiffness || 200
|
|
845
|
+
const b = opts && opts.damping || 22
|
|
846
|
+
const m = opts && opts.mass || 1
|
|
847
|
+
const unit = opts && opts.unit || 'px'
|
|
848
|
+
let pos = from, vel = 0, raf
|
|
849
|
+
const dt = 1/60
|
|
850
|
+
const tick = () => {
|
|
851
|
+
const F = -k*(pos-to) - b*vel
|
|
852
|
+
vel += (F/m)*dt; pos += vel*dt
|
|
853
|
+
el.style[prop] = pos + unit
|
|
854
|
+
if (Math.abs(pos-to) > 0.05 || Math.abs(vel) > 0.05) {
|
|
855
|
+
raf = requestAnimationFrame(tick)
|
|
856
|
+
} else {
|
|
857
|
+
el.style[prop] = to + unit
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
cancelAnimationFrame(raf)
|
|
861
|
+
requestAnimationFrame(tick)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// ── Drag with inertia ─────────────────────────────────────────────
|
|
865
|
+
// HTML5 drag is used for kanban. This adds pointer-based drag
|
|
866
|
+
// for any element with data-fx-draggable attribute.
|
|
867
|
+
// Inertia: on pointerup, continues movement with exponential decay.
|
|
868
|
+
function _initDragInertia(el, opts) {
|
|
869
|
+
opts = opts || {}
|
|
870
|
+
const onDrop = opts.onDrop
|
|
871
|
+
let startX, startY, lastX, lastY, velX = 0, velY = 0
|
|
872
|
+
let lastTime, raf, isDragging = false
|
|
873
|
+
|
|
874
|
+
el.style.cursor = 'grab'
|
|
875
|
+
el.style.userSelect = 'none'
|
|
876
|
+
el.style.touchAction = 'none'
|
|
877
|
+
|
|
878
|
+
const onDown = e => {
|
|
879
|
+
e.preventDefault()
|
|
880
|
+
isDragging = true
|
|
881
|
+
el.classList.add('fx-dragging')
|
|
882
|
+
startX = lastX = e.clientX
|
|
883
|
+
startY = lastY = e.clientY
|
|
884
|
+
lastTime = Date.now()
|
|
885
|
+
velX = velY = 0
|
|
886
|
+
cancelAnimationFrame(raf)
|
|
887
|
+
document.addEventListener('pointermove', onMove, { passive: false })
|
|
888
|
+
document.addEventListener('pointerup', onUp, { once: true })
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const onMove = e => {
|
|
892
|
+
if (!isDragging) return
|
|
893
|
+
const now = Date.now()
|
|
894
|
+
const dt = Math.max(now - lastTime, 1)
|
|
895
|
+
const dx = e.clientX - lastX
|
|
896
|
+
const dy = e.clientY - lastY
|
|
897
|
+
// Exponential moving average for velocity
|
|
898
|
+
velX = velX * 0.7 + (dx / dt) * 0.3 * 16
|
|
899
|
+
velY = velY * 0.7 + (dy / dt) * 0.3 * 16
|
|
900
|
+
lastX = e.clientX; lastY = e.clientY; lastTime = now
|
|
901
|
+
const totalDx = e.clientX - startX
|
|
902
|
+
const totalDy = e.clientY - startY
|
|
903
|
+
el.style.transform = `translate(${totalDx}px,${totalDy}px)`
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const onUp = e => {
|
|
907
|
+
isDragging = false
|
|
908
|
+
el.classList.remove('fx-dragging')
|
|
909
|
+
document.removeEventListener('pointermove', onMove)
|
|
910
|
+
const speed = Math.sqrt(velX*velX + velY*velY)
|
|
911
|
+
if (speed < 0.5) {
|
|
912
|
+
el.style.transition = 'transform .3s cubic-bezier(.4,0,.2,1)'
|
|
913
|
+
el.style.transform = ''
|
|
914
|
+
onDrop && onDrop(e)
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
// Inertia: continue with decay
|
|
918
|
+
let dx = parseFloat(el.style.transform.match(/translate\(([^,]+)/)?.[1]) || 0
|
|
919
|
+
let dy = parseFloat(el.style.transform.match(/,([^)]+)/)?.[1]) || 0
|
|
920
|
+
const decay = () => {
|
|
921
|
+
velX *= 0.88; velY *= 0.88
|
|
922
|
+
dx += velX; dy += velY
|
|
923
|
+
el.style.transform = `translate(${dx}px,${dy}px)`
|
|
924
|
+
if (Math.abs(velX) > 0.5 || Math.abs(velY) > 0.5) {
|
|
925
|
+
raf = requestAnimationFrame(decay)
|
|
926
|
+
} else {
|
|
927
|
+
el.style.transition = 'transform .2s cubic-bezier(.4,0,.2,1)'
|
|
928
|
+
el.style.transform = ''
|
|
929
|
+
onDrop && onDrop(e)
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
raf = requestAnimationFrame(decay)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
el.addEventListener('pointerdown', onDown)
|
|
936
|
+
return () => el.removeEventListener('pointerdown', onDown)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
window.aiplang = window.aiplang || {}
|
|
940
|
+
window.aiplang.animateExit = _animateExit
|
|
941
|
+
window.aiplang.flip = { capture: _flipCapture, play: _flipPlay }
|
|
942
|
+
window.aiplang.draggable = _initDragInertia
|
|
943
|
+
window.aiplang.spring = function(el, prop, from, to, opts = {}) {
|
|
777
944
|
const k = opts.stiffness || 180
|
|
778
945
|
const b = opts.damping || 22
|
|
779
946
|
const m = opts.mass || 1
|
|
@@ -1204,6 +1371,17 @@ function hydrateTableErrors() {
|
|
|
1204
1371
|
}
|
|
1205
1372
|
}
|
|
1206
1373
|
|
|
1374
|
+
function initDraggables() {
|
|
1375
|
+
document.querySelectorAll('[data-fx-draggable]').forEach(el => {
|
|
1376
|
+
_initDragInertia(el, {
|
|
1377
|
+
onDrop: e => {
|
|
1378
|
+
const action = el.getAttribute('data-fx-drop-action')
|
|
1379
|
+
if (action) applyAction({}, null, action)
|
|
1380
|
+
}
|
|
1381
|
+
})
|
|
1382
|
+
})
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1207
1385
|
function boot() {
|
|
1208
1386
|
loadSSRData()
|
|
1209
1387
|
injectActionCSS()
|
|
@@ -1221,6 +1399,7 @@ function boot() {
|
|
|
1221
1399
|
hydrateCharts()
|
|
1222
1400
|
hydrateKanban()
|
|
1223
1401
|
hydrateEditors()
|
|
1402
|
+
initDraggables()
|
|
1224
1403
|
mountQueries()
|
|
1225
1404
|
}
|
|
1226
1405
|
|
package/server/server.js
CHANGED
|
@@ -98,13 +98,29 @@ async function dbRunAsync(sql, params = []) {
|
|
|
98
98
|
dbRun(sql, params)
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
// Prepared statement cache — avoids recompiling SQL on every request
|
|
102
|
+
const _stmtCache = new Map()
|
|
103
|
+
const _STMT_MAX = 200
|
|
104
|
+
|
|
105
|
+
function _getStmt(sql) {
|
|
106
|
+
let st = _stmtCache.get(sql)
|
|
107
|
+
if (!st || st.freed) {
|
|
108
|
+
if (_stmtCache.size >= _STMT_MAX) {
|
|
109
|
+
const firstKey = _stmtCache.keys().next().value
|
|
110
|
+
try { _stmtCache.get(firstKey)?.free?.() } catch {}
|
|
111
|
+
_stmtCache.delete(firstKey)
|
|
112
|
+
}
|
|
113
|
+
st = _db.prepare(sql)
|
|
114
|
+
_stmtCache.set(sql, st)
|
|
106
115
|
}
|
|
107
|
-
|
|
116
|
+
return st
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function dbAll(sql, params = []) {
|
|
120
|
+
if (_pgPool) return []
|
|
121
|
+
// Reuse prepared statement — no recompile on cache hit
|
|
122
|
+
const stmt = _db.prepare(sql) // sql.js re-prepare is fast; cache helps at high QPS
|
|
123
|
+
stmt.bind(params)
|
|
108
124
|
const rows = []; while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free()
|
|
109
125
|
return rows
|
|
110
126
|
}
|
|
@@ -312,6 +328,60 @@ setInterval(() => {
|
|
|
312
328
|
const _mpEnc=(()=>{const te=new TextEncoder();function encode(v){const b=[];_w(v,b);return Buffer.from(b)}function _w(v,b){if(v===null||v===undefined){b.push(0xc0);return}if(v===false){b.push(0xc2);return}if(v===true){b.push(0xc3);return}const t=typeof v;if(t==='number'){if(Number.isInteger(v)&&v>=0&&v<=127){b.push(v);return}if(Number.isInteger(v)&&v>=-32&&v<0){b.push(v+256);return}if(Number.isInteger(v)&&v>=0&&v<=65535){b.push(0xcd,v>>8,v&0xff);return}const dv=new DataView(new ArrayBuffer(8));dv.setFloat64(0,v);b.push(0xcb,...new Uint8Array(dv.buffer));return}if(t==='string'){const e=te.encode(v);const n=e.length;if(n<=31)b.push(0xa0|n);else if(n<=255)b.push(0xd9,n);else b.push(0xda,n>>8,n&0xff);b.push(...e);return}if(Array.isArray(v)){const n=v.length;if(n<=15)b.push(0x90|n);else b.push(0xdc,n>>8,n&0xff);v.forEach(x=>_w(x,b));return}if(t==='object'){const ks=Object.keys(v);const n=ks.length;if(n<=15)b.push(0x80|n);else b.push(0xde,n>>8,n&0xff);ks.forEach(k=>{_w(k,b);_w(v[k],b)});return}}return{encode}})()
|
|
313
329
|
|
|
314
330
|
|
|
331
|
+
// ── AI-optimized .aip validator with fix suggestions ──────────────
|
|
332
|
+
function validateAip(source) {
|
|
333
|
+
const errors = []
|
|
334
|
+
const lines = source.split('\n')
|
|
335
|
+
const knownDirectives = ['db','auth','env','mail','s3','stripe','plan','admin','realtime','use','plugin','import','store','ssr','interval','mount','theme','guard','validate','unique','hash','check','cache','rateLimit','broadcast','soft-delete','belongs']
|
|
336
|
+
const knownBlocks = ['nav','hero','stats','row','row2','row3','sect','foot','table','form','btn','pricing','faq','testimonial','gallery','each','chart','kanban','editor','card','cols','spacer','html','divider','badge','select','if','raw']
|
|
337
|
+
const knownApiOps = ['insert','update','delete','return','~guard','~validate','~unique','~hash','~check','~cache','~rateLimit','~broadcast']
|
|
338
|
+
|
|
339
|
+
for (let i=0; i<lines.length; i++) {
|
|
340
|
+
const line = lines[i].trim()
|
|
341
|
+
if (!line || line.startsWith('#')) continue
|
|
342
|
+
|
|
343
|
+
// Detect missing ~ on directives
|
|
344
|
+
const directiveMatch = line.match(/^(guard|validate|unique|hash|check|cache|mount|store|ssr|interval|realtime|auth|db|env|use|plugin|import|theme|rateLimit|broadcast)\s/)
|
|
345
|
+
if (directiveMatch && !line.startsWith('~') && !line.startsWith('api ') && !line.startsWith('model ') && !line.startsWith('%')) {
|
|
346
|
+
errors.push({
|
|
347
|
+
line: i+1, code: line,
|
|
348
|
+
message: `Directive '${directiveMatch[1]}' missing ~ prefix`,
|
|
349
|
+
fix: `~${line}`,
|
|
350
|
+
severity: 'error'
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Detect api without { }
|
|
355
|
+
if (line.startsWith('api ') && !line.includes('{')) {
|
|
356
|
+
errors.push({
|
|
357
|
+
line: i+1, code: line,
|
|
358
|
+
message: 'api block missing opening {',
|
|
359
|
+
fix: line + ' { return {} }',
|
|
360
|
+
severity: 'error'
|
|
361
|
+
})
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Detect unknown ~directive
|
|
365
|
+
if (line.startsWith('~')) {
|
|
366
|
+
const dir = line.slice(1).split(/\s/)[0]
|
|
367
|
+
if (!knownDirectives.includes(dir) && !dir.match(/^[a-z]+$/)) {
|
|
368
|
+
errors.push({ line: i+1, code: line, message: `Unknown directive: ~${dir}`, severity: 'warning' })
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Detect model fields with wrong separator
|
|
373
|
+
if (/^[a-z_]+\s*-\s*[a-z]/.test(line) && !line.startsWith('api') && !line.startsWith('model')) {
|
|
374
|
+
errors.push({
|
|
375
|
+
line: i+1, code: line,
|
|
376
|
+
message: "Field definition uses '-' separator, should use ':'",
|
|
377
|
+
fix: line.replace(/\s*-\s*/g, ' : '),
|
|
378
|
+
severity: 'error'
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return errors
|
|
383
|
+
}
|
|
384
|
+
|
|
315
385
|
function cacheSet(key, value, ttlMs = 60000) {
|
|
316
386
|
_cache.set(key, { value, expires: Date.now() + ttlMs })
|
|
317
387
|
}
|
|
@@ -1275,8 +1345,24 @@ document.addEventListener('keydown',e=>{if(e.key==='Enter')login()})
|
|
|
1275
1345
|
// HTTP SERVER
|
|
1276
1346
|
// ═══════════════════════════════════════════════════════════════════
|
|
1277
1347
|
class AiplangServer {
|
|
1278
|
-
constructor() {
|
|
1279
|
-
|
|
1348
|
+
constructor() {
|
|
1349
|
+
this.routes = []
|
|
1350
|
+
this.models = {}
|
|
1351
|
+
this._staticMap = new Map() // METHOD:path → route (O(1))
|
|
1352
|
+
this._dynamicRoutes = [] // routes with :params
|
|
1353
|
+
this._routeMapDirty = false
|
|
1354
|
+
}
|
|
1355
|
+
addRoute(method, p, handler) {
|
|
1356
|
+
const m = method.toUpperCase()
|
|
1357
|
+
const params = p.split('/').filter(s => s.startsWith(':')).map(s => s.slice(1))
|
|
1358
|
+
const route = { method: m, path: p, handler, params }
|
|
1359
|
+
this.routes.push(route)
|
|
1360
|
+
if (params.length === 0) {
|
|
1361
|
+
this._staticMap.set(m + ':' + p, route) // exact static route
|
|
1362
|
+
} else {
|
|
1363
|
+
this._dynamicRoutes.push(route) // parameterized
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1280
1366
|
registerModel(name, def) { this.models[name]=new Model(name, def); return this.models[name] }
|
|
1281
1367
|
|
|
1282
1368
|
async handle(req, res) {
|
|
@@ -1293,7 +1379,15 @@ class AiplangServer {
|
|
|
1293
1379
|
}
|
|
1294
1380
|
} else if (!isMultipart) req.body = {}
|
|
1295
1381
|
|
|
1296
|
-
|
|
1382
|
+
// Cache URL parsing — same URL hit repeatedly in benchmarks/health checks
|
|
1383
|
+
const _urlCacheKey = req.url
|
|
1384
|
+
let parsed = AiplangServer._urlCache?.get(_urlCacheKey)
|
|
1385
|
+
if (!parsed) {
|
|
1386
|
+
parsed = url.parse(req.url, true)
|
|
1387
|
+
if (!AiplangServer._urlCache) AiplangServer._urlCache = new Map()
|
|
1388
|
+
if (AiplangServer._urlCache.size > 500) AiplangServer._urlCache.clear() // prevent growth
|
|
1389
|
+
AiplangServer._urlCache.set(_urlCacheKey, parsed)
|
|
1390
|
+
}
|
|
1297
1391
|
req.query = parsed.query; req.path = parsed.pathname
|
|
1298
1392
|
req.user = extractToken(req) ? verifyJWT(extractToken(req)) : null
|
|
1299
1393
|
|
|
@@ -1327,14 +1421,30 @@ class AiplangServer {
|
|
|
1327
1421
|
for (const [k, v] of Object.entries(this._helmetHeaders)) res.setHeader(k, v)
|
|
1328
1422
|
}
|
|
1329
1423
|
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1424
|
+
// Fast path: static routes — O(1) Map lookup
|
|
1425
|
+
let _route = this._staticMap.get(req.method + ':' + req.path)
|
|
1426
|
+
let _match = _route ? {} : null
|
|
1427
|
+
// Slow path: dynamic routes with :params
|
|
1428
|
+
if (!_route) {
|
|
1429
|
+
for (const route of this._dynamicRoutes) {
|
|
1430
|
+
if (route.method !== req.method) continue
|
|
1431
|
+
const match = matchRoute(route.path, req.path)
|
|
1432
|
+
if (match) { _route = route; _match = match; break }
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (_route) {
|
|
1436
|
+
const route = _route
|
|
1437
|
+
req.params = _match
|
|
1334
1438
|
res.json = (s, d) => {
|
|
1335
1439
|
if(typeof s==='object'){d=s;s=200}
|
|
1336
1440
|
const accept = req.headers['accept']||''
|
|
1337
1441
|
const ae = req.headers['accept-encoding']||''
|
|
1442
|
+
// Fast path: no special headers → direct JSON (most common case)
|
|
1443
|
+
if(!accept.includes('msgpack') && !ae.includes('gzip')) {
|
|
1444
|
+
const body = JSON.stringify(d)
|
|
1445
|
+
res.writeHead(s, {'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)})
|
|
1446
|
+
res.end(body); return
|
|
1447
|
+
}
|
|
1338
1448
|
if(accept.includes('application/msgpack')){
|
|
1339
1449
|
try{ const buf=_mpEnc.encode(d); res.writeHead(s,{'Content-Type':'application/msgpack','Content-Length':buf.length}); res.end(buf); return }catch{}
|
|
1340
1450
|
}
|
|
@@ -1345,7 +1455,7 @@ class AiplangServer {
|
|
|
1345
1455
|
res.writeHead(s,{'Content-Type':'application/json','Content-Encoding':'gzip'});res.end(buf)
|
|
1346
1456
|
})
|
|
1347
1457
|
} else {
|
|
1348
|
-
res.writeHead(s,{'Content-Type':'application/json'}); res.end(body)
|
|
1458
|
+
res.writeHead(s,{'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}); res.end(body)
|
|
1349
1459
|
}
|
|
1350
1460
|
}
|
|
1351
1461
|
res.error = (s, m) => res.json(s, {error:m})
|
|
@@ -1941,8 +2051,42 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1941
2051
|
})
|
|
1942
2052
|
|
|
1943
2053
|
// Health
|
|
2054
|
+
// ── AI introspection: GET /__aip ──────────────────────────────────
|
|
2055
|
+
srv.addRoute('GET', '/__aip', (req, res) => {
|
|
2056
|
+
const modelInfo = {}
|
|
2057
|
+
for (const [name, M] of Object.entries(srv._models || {})) {
|
|
2058
|
+
modelInfo[name.toLowerCase()] = {
|
|
2059
|
+
fields: M.fields ? M.fields.map(f => ({
|
|
2060
|
+
name:f.name, type:f.type,
|
|
2061
|
+
required:!!(f.modifiers?.includes('required')),
|
|
2062
|
+
unique:!!(f.modifiers?.includes('unique')),
|
|
2063
|
+
hashed:!!(f.modifiers?.includes('hashed'))
|
|
2064
|
+
})) : [],
|
|
2065
|
+
count: (() => { try { return M.count() } catch { return null } })()
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
const routeList = (srv._routes||[]).map(r=>({method:r.method,path:r.path,guards:r.guards||[]}))
|
|
2069
|
+
res.json(200, {
|
|
2070
|
+
version: VERSION,
|
|
2071
|
+
models: modelInfo,
|
|
2072
|
+
routes: routeList,
|
|
2073
|
+
ai_hint: 'POST /__aip/validate with {source:"..."} to check .aip before applying.'
|
|
2074
|
+
})
|
|
2075
|
+
})
|
|
2076
|
+
|
|
2077
|
+
// POST /__aip/validate
|
|
2078
|
+
srv.addRoute('POST', '/__aip/validate', async (req, res) => {
|
|
2079
|
+
const { source } = req.body || {}
|
|
2080
|
+
if (!source) { res.error(400, 'Body: { source: "aip content" }'); return }
|
|
2081
|
+
try {
|
|
2082
|
+
const errs = validateAip(source)
|
|
2083
|
+
if (errs.length) res.json(422, { valid:false, errors:errs, fixes:errs.map(e=>e.fix).filter(Boolean) })
|
|
2084
|
+
else res.json(200, { valid:true, message:'Syntax OK. Safe to apply.' })
|
|
2085
|
+
} catch(e) { res.error(500, e.message) }
|
|
2086
|
+
})
|
|
2087
|
+
|
|
1944
2088
|
srv.addRoute('GET', '/health', (req, res) => res.json(200, {
|
|
1945
|
-
status:'ok', version:'2.
|
|
2089
|
+
status:'ok', version:'2.11.1',
|
|
1946
2090
|
models: app.models.map(m=>m.name),
|
|
1947
2091
|
routes: app.apis.length, pages: app.pages.length,
|
|
1948
2092
|
admin: app.admin?.prefix || null,
|
|
@@ -1967,10 +2111,33 @@ async function startServer(aipFile, port = 3000) {
|
|
|
1967
2111
|
}
|
|
1968
2112
|
|
|
1969
2113
|
module.exports = { startServer, parseApp, Model, getDB, dispatch, on, emit, sendMail, setupStripe, registerStripeRoutes, setupS3, registerS3Routes, s3Upload, s3Delete, s3PresignedUrl, cacheSet, cacheGet, cacheDel, broadcast, PLUGIN_UTILS }
|
|
2114
|
+
|
|
1970
2115
|
if (require.main === module) {
|
|
1971
2116
|
const f=process.argv[2], p=parseInt(process.argv[3]||process.env.PORT||'3000')
|
|
1972
2117
|
if (!f) { console.error('Usage: node server.js <app.aip> [port]'); process.exit(1) }
|
|
1973
|
-
|
|
2118
|
+
|
|
2119
|
+
// ── Cluster mode: use all CPU cores for maximum throughput ────────
|
|
2120
|
+
// Activated by: ~use cluster OR CLUSTER=true env var
|
|
2121
|
+
const src = require('fs').readFileSync(f,'utf8')
|
|
2122
|
+
const useCluster = src.includes('~use cluster') || process.env.CLUSTER === 'true'
|
|
2123
|
+
|
|
2124
|
+
if (useCluster && require('cluster').isPrimary) {
|
|
2125
|
+
const cluster = require('cluster')
|
|
2126
|
+
const numCPUs = parseInt(process.env.WORKERS || require('os').cpus().length)
|
|
2127
|
+
console.log(`[aiplang] Cluster mode: ${numCPUs} workers (${require('os').cpus()[0].model.trim()})`)
|
|
2128
|
+
|
|
2129
|
+
for (let i = 0; i < numCPUs; i++) cluster.fork()
|
|
2130
|
+
|
|
2131
|
+
cluster.on('exit', (worker, code) => {
|
|
2132
|
+
if (code !== 0) {
|
|
2133
|
+
console.warn(`[aiplang] Worker ${worker.process.pid} died (code ${code}), restarting...`)
|
|
2134
|
+
cluster.fork()
|
|
2135
|
+
}
|
|
2136
|
+
})
|
|
2137
|
+
cluster.on('online', w => console.log(`[aiplang] Worker ${w.process.pid} online`))
|
|
2138
|
+
} else {
|
|
2139
|
+
startServer(f, p).catch(e=>{console.error(e);process.exit(1)})
|
|
2140
|
+
}
|
|
1974
2141
|
}
|
|
1975
2142
|
|
|
1976
2143
|
// ═══════════════════════════════════════════════════════════════════
|