kreftjs 0.0.20-beta → 0.0.22-beta

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.
Files changed (64) hide show
  1. package/README.md +10 -1
  2. package/bin/artisan.js +4 -2
  3. package/bin/backend/cache.js +31 -5
  4. package/bin/backend/gc.js +193 -43
  5. package/bin/backend/http.js +6 -2
  6. package/bin/backend/index.js +6 -0
  7. package/bin/backend/mcp-client.js +133 -0
  8. package/bin/backend/mcp.js +17 -0
  9. package/bin/backend/model.js +15 -1
  10. package/bin/backend/shield.js +266 -0
  11. package/bin/backend/stream.js +74 -0
  12. package/bin/build.js +12 -5
  13. package/bin/db-api.js +9 -1
  14. package/bin/kreftjs.js +50 -12
  15. package/book/ar/16-virtual-list.md +131 -0
  16. package/book/ar/17-combobox.md +169 -0
  17. package/book/ar/18-tabs-offline.md +168 -0
  18. package/book/ar/19-wizard.md +196 -0
  19. package/book/ar/20-pipes-match-props.md +75 -0
  20. package/book/ar/README.md +12 -1
  21. package/book/kreft-book-ar.docx +0 -0
  22. package/docs/README.md +209 -13
  23. package/editors/sublime/Comments.tmPreferences +26 -0
  24. package/editors/sublime/Component.sublime-snippet +20 -0
  25. package/editors/sublime/Kreft.sublime-syntax +39 -0
  26. package/editors/sublime/LICENSE +95 -0
  27. package/editors/sublime/README.md +7 -0
  28. package/editors/sublime/action.sublime-snippet +10 -0
  29. package/editors/sublime/derive.sublime-snippet +8 -0
  30. package/editors/sublime/for.sublime-snippet +12 -0
  31. package/editors/sublime/k-case.sublime-snippet +8 -0
  32. package/editors/sublime/k-if.sublime-snippet +8 -0
  33. package/editors/sublime/k-match.sublime-snippet +12 -0
  34. package/editors/sublime/kreft-wait.sublime-snippet +12 -0
  35. package/editors/sublime/layout.sublime-snippet +8 -0
  36. package/editors/sublime/load.sublime-snippet +8 -0
  37. package/editors/sublime/on.sublime-snippet +10 -0
  38. package/editors/sublime/pipe.sublime-snippet +8 -0
  39. package/editors/sublime/prop-typed.sublime-snippet +8 -0
  40. package/editors/sublime/saved.sublime-snippet +8 -0
  41. package/editors/sublime/section.sublime-snippet +10 -0
  42. package/editors/sublime/state.sublime-snippet +8 -0
  43. package/editors/sublime/watch.sublime-snippet +10 -0
  44. package/editors/sublime/when.sublime-snippet +10 -0
  45. package/editors/sublime/yield.sublime-snippet +8 -0
  46. package/editors/vscode/snippets/kreftjs.json +143 -15
  47. package/editors/zed/LICENSE +95 -0
  48. package/editors/zed/README.md +17 -0
  49. package/editors/zed/extension.toml +11 -0
  50. package/editors/zed/languages/kreft/brackets.scm +4 -0
  51. package/editors/zed/languages/kreft/config.toml +17 -0
  52. package/editors/zed/languages/kreft/highlights.scm +18 -0
  53. package/editors/zed/languages/kreft/indents.scm +5 -0
  54. package/editors/zed/languages/kreft/injections.scm +14 -0
  55. package/editors/zed/languages/kreft/outline.scm +3 -0
  56. package/kreftjs.d.ts +1 -1
  57. package/kreftjs.js +219 -14
  58. package/package.json +3 -2
  59. package/server.d.ts +44 -0
  60. package/templates/app/backend/app/Auth/Session.js +8 -4
  61. package/templates/app/backend/app/Http/Middleware/Authenticate.js +7 -4
  62. package/templates/app/backend/database/seeders/DatabaseSeeder.js +4 -0
  63. package/templates/app/frontend/kreftjs.d.ts +1 -1
  64. package/templates/app/frontend/kreftjs.js +219 -14
package/README.md CHANGED
@@ -24,7 +24,7 @@ action add() { count++ }
24
24
  You need [Node.js](https://nodejs.org) 22.5 or newer.
25
25
 
26
26
  ```bash
27
- npm i -g kreftjs # once: makes the `kreft` command available everywhere
27
+ npm install -g kreftjs@latest # once: makes the `kreft` command available everywhere
28
28
  kreft new my-app # asks: JavaScript or TypeScript?
29
29
  cd my-app
30
30
  npm run dev
@@ -42,6 +42,15 @@ Open the address in the terminal (normally `http://localhost:8000`). A new proje
42
42
  | **Authentication** | Cookie sessions with CSRF protection, API tokens with permissions, device list, two-factor codes, email verification, account lockout. |
43
43
  | **Tooling** | `kreft dev` with hot reload, DevTools, an error monitor, `kreft build` (CSP-safe), prerendering, TypeScript, Vite and Electron support, a safer `kreft add`, an MCP server for AI agents. |
44
44
 
45
+ ## Put it online
46
+
47
+ ```bash
48
+ npm install kreftjs@latest # the newest version
49
+ npm run build # the finished site, ready to upload
50
+ ```
51
+
52
+ Upload what is inside the output folder (`dist/`; for this repository's own website, `dist/kreftjs-com`) to your host. The kreftjs.com steps, and releasing a new package version with `npm publish`, are in [Publish the website](docs/README.md#publish-the-website-kreftjscom).
53
+
45
54
  ## Desktop apps (Electron)
46
55
 
47
56
  ```bash
package/bin/artisan.js CHANGED
@@ -262,7 +262,9 @@ class ${name} {
262
262
  // POST /api/${plural(snake(model))}
263
263
  async store(req) {
264
264
  // const data = await req.validate({ title: 'required|max:200' })
265
- const data = req.all()
265
+ // only the columns listed in static fillable on the model are taken: never req.all(), or a visitor could send
266
+ // { "is_admin": true } and have it saved
267
+ const data = req.only(...(${model}.fillable || abort(500, 'Set static fillable on the ${model} model first (the columns a visitor may fill).')))
266
268
  // if (data.title && !data.slug) data.slug = await slug.unique(${model}, 'slug', data.title)
267
269
  return response(await ${model}.create(data), 201)
268
270
  }
@@ -276,7 +278,7 @@ class ${name} {
276
278
  async update(req, id) {
277
279
  const ${snake(model)} = await ${model}.findOrFail(id)
278
280
  // const data = await req.validate({ title: 'sometimes|required|max:200' })
279
- const data = req.all()
281
+ const data = req.only(...(${model}.fillable || abort(500, 'Set static fillable on the ${model} model first (the columns a visitor may fill).')))
280
282
  return ${snake(model)}.update(data)
281
283
  }
282
284
 
@@ -19,15 +19,41 @@ const { parseJson } = require('../safe-json.js');
19
19
  const nowSec = () => Math.floor(Date.now() / 1000);
20
20
  const expiry = (seconds) => (seconds == null ? 0 : nowSec() + Math.max(1, Math.floor(seconds instanceof Date ? (seconds - Date.now()) / 1000 : seconds)));
21
21
 
22
+ // In-process store with LRU eviction: at most CACHE_MEMORY_MAX entries (default 5000), and under
23
+ // memory pressure the memory manager (gc.js) trims the least recently used ones instead of flushing all.
22
24
  function memoryStore() {
23
- const map = new Map();
24
- return {
25
- async get(key) { const e = map.get(key); if (!e) return undefined; if (e.exp && e.exp <= nowSec()) { map.delete(key); return undefined; } return e.value; },
26
- async put(key, value, exp) { map.set(key, { value, exp }); },
27
- async add(key, value, exp) { if ((await this.get(key)) !== undefined) return false; map.set(key, { value, exp }); return true; },
25
+ const map = new Map(); // insertion order = recency: the first key is the least recently used
26
+ const max = Math.max(1, Number(process.env.CACHE_MEMORY_MAX) || 5000);
27
+ const set = (key, entry) => {
28
+ map.delete(key);
29
+ map.set(key, entry);
30
+ while (map.size > max) map.delete(map.keys().next().value);
31
+ };
32
+ const store = {
33
+ async get(key) {
34
+ const e = map.get(key);
35
+ if (!e) return undefined;
36
+ if (e.exp && e.exp <= nowSec()) { map.delete(key); return undefined; }
37
+ map.delete(key); map.set(key, e); // mark as recently used
38
+ return e.value;
39
+ },
40
+ async put(key, value, exp) { set(key, { value, exp }); },
41
+ async add(key, value, exp) { if ((await this.get(key)) !== undefined) return false; set(key, { value, exp }); return true; },
28
42
  async forget(key) { return map.delete(key); },
29
43
  async flush() { map.clear(); },
44
+ size: () => map.size,
45
+ /** Drops expired entries, then the least recently used `fraction` of what is left. Returns how many went. */
46
+ trim(fraction = 0.5) {
47
+ const before = map.size;
48
+ const now = nowSec();
49
+ for (const [k, e] of map) if (e.exp && e.exp <= now) map.delete(k);
50
+ let drop = Math.ceil(map.size * Math.min(1, Math.max(0, fraction)));
51
+ for (const k of map.keys()) { if (drop-- <= 0) break; map.delete(k); }
52
+ return before - map.size;
53
+ },
30
54
  };
55
+ try { require('./gc.js').onMemoryPressure('cache:memory', (fraction) => store.trim(fraction)); } catch (_) {}
56
+ return store;
31
57
  }
32
58
  function fileStore(dir) {
33
59
  const file = (key) => path.join(dir, crypto.createHash('sha1').update(key).digest('hex'));
package/bin/backend/gc.js CHANGED
@@ -1,69 +1,219 @@
1
1
  /*
2
- * Kreft Auto Garbage Collector & Memory Watchdog
2
+ * Kreft Memory Manager (Auto GC + watchdog)
3
3
  *
4
- * Keeps Kreft server instances blazing fast and lightweight, preventing V8 heap
5
- * bloating during continuous development and production traffic.
4
+ * - Adaptive GC: a cleanup runs when the heap passes the threshold OR when it keeps growing
5
+ * (trend over the last samples) while already past 60% of the threshold.
6
+ * - Smart eviction: caches register a handler (onMemoryPressure) and drop their least recently
7
+ * used entries, a fraction at a time, instead of being flushed whole.
8
+ * - Leak detection: after each real GC the heap "floor" is recorded; a floor that keeps rising
9
+ * across collections is reported as a suspected leak.
10
+ * - Alerts: onMemoryAlert(fn) is called when memory stays high despite cleaning, or a leak is suspected.
11
+ * - Report: getMemoryReport() returns samples, GC count, evictions, leak state and alerts.
12
+ *
13
+ * Env: AUTO_GC_THRESHOLD_MB (250), AUTO_GC_INTERVAL_MS (30000), AUTO_GC_GROWTH_MB_PER_MIN (20),
14
+ * AUTO_GC_ALERT_AFTER (3 checks), AUTO_GC_LEAK_RISE_MB (20). `--gc` / AUTO_GC=1 exposes global.gc.
6
15
  */
16
+ const MB = 1024 * 1024;
17
+ const HISTORY_MAX = 120;
18
+
7
19
  let gcTimer = null;
8
- let lastMemoryUsage = null;
20
+ let config = null;
21
+ let observer = null;
22
+
23
+ const history = [];
24
+ const pressureHandlers = new Map();
25
+ const alertListeners = new Set();
26
+ const floors = [];
27
+ const state = freshState();
28
+
29
+ function freshState() {
30
+ return {
31
+ startedAt: null, checks: 0, cleanups: 0, gcRuns: 0, v8Collections: 0, reclaimedMB: 0,
32
+ evicted: 0, overStreak: 0, alerts: [], lastAlertAt: 0, leak: { suspected: false, riseMB: 0, floorsMB: [] },
33
+ };
34
+ }
35
+
36
+ const round = (n) => +(n).toFixed(2);
37
+
38
+ /** Register a handler called under memory pressure: fn(fraction) → number of entries dropped. */
39
+ function onMemoryPressure(name, fn) {
40
+ pressureHandlers.set(name, fn);
41
+ return () => pressureHandlers.delete(name);
42
+ }
43
+
44
+ /** Be told when memory stays high after cleaning, or a leak looks likely. fn(alert) */
45
+ function onMemoryAlert(fn) {
46
+ alertListeners.add(fn);
47
+ return () => alertListeners.delete(fn);
48
+ }
49
+
50
+ // Slope of the heap in MB per minute over the most recent samples (least squares); null with too few samples.
51
+ function trendMBPerMin(n = 10) {
52
+ const pts = history.slice(-n);
53
+ if (pts.length < 4) return null;
54
+ const t0 = pts[0].t;
55
+ const xs = pts.map((p) => (p.t - t0) / 60000);
56
+ const ys = pts.map((p) => p.heapUsedMB);
57
+ const mx = xs.reduce((a, b) => a + b, 0) / xs.length;
58
+ const my = ys.reduce((a, b) => a + b, 0) / ys.length;
59
+ let num = 0;
60
+ let den = 0;
61
+ for (let i = 0; i < xs.length; i++) { num += (xs[i] - mx) * (ys[i] - my); den += (xs[i] - mx) ** 2; }
62
+ return den === 0 ? null : round(num / den);
63
+ }
64
+
65
+ function emitAlert(type, message, extra = {}) {
66
+ const now = Date.now();
67
+ if (now - state.lastAlertAt < config.alertCooldownMs) return;
68
+ state.lastAlertAt = now;
69
+ const alert = { type, message, at: new Date(now).toISOString(), ...getMemoryStats(), ...extra };
70
+ state.alerts.push(alert);
71
+ if (state.alerts.length > 20) state.alerts.shift();
72
+ for (const fn of alertListeners) { try { fn(alert); } catch (_) {} }
73
+ if (!alertListeners.size) console.warn(` [Kreft Memory] ${message}`);
74
+ }
75
+
76
+ function runCleanup(fraction) {
77
+ state.cleanups++;
78
+ for (const fn of pressureHandlers.values()) {
79
+ try { const n = Number(fn(fraction)); if (n > 0) state.evicted += n; } catch (_) {}
80
+ }
81
+ let gcRan = false;
82
+ if (typeof global.gc === 'function') { global.gc(); state.gcRuns++; gcRan = true; }
83
+ return gcRan;
84
+ }
85
+
86
+ function trackLeak() {
87
+ const f = floors.slice(-6);
88
+ const rising = f.length >= 4 && f.every((v, i) => i === 0 || v >= f[i - 1] - 0.5);
89
+ const rise = f.length ? f[f.length - 1] - f[0] : 0;
90
+ state.leak = { suspected: rising && rise >= config.leakRiseMB, riseMB: round(rise), floorsMB: f.map(round) };
91
+ if (state.leak.suspected) {
92
+ emitAlert('memory-leak', `Possible memory leak: the heap after GC rose ${round(rise)} MB over the last ${f.length} collections.`, { leak: state.leak });
93
+ }
94
+ }
95
+
96
+ /** One check (the timer calls this; exported so it can be driven directly). */
97
+ function checkMemory() {
98
+ const mem = process.memoryUsage();
99
+ const heapUsedMB = mem.heapUsed / MB;
100
+ history.push({ t: Date.now(), heapUsedMB: round(heapUsedMB), rssMB: round(mem.rss / MB) });
101
+ if (history.length > HISTORY_MAX) history.shift();
102
+ state.checks++;
103
+
104
+ const trend = trendMBPerMin();
105
+ const over = heapUsedMB > config.thresholdMB;
106
+ const growing = trend != null && trend > config.growthMBPerMin && heapUsedMB > config.thresholdMB * 0.6;
107
+ if (!over && !growing) { state.overStreak = 0; return { action: 'none', heapUsedMB: round(heapUsedMB), trend }; }
108
+
109
+ const before = heapUsedMB;
110
+ const gcRan = runCleanup(over ? 0.75 : 0.5);
111
+ const after = process.memoryUsage().heapUsed / MB;
112
+ if (gcRan) {
113
+ state.reclaimedMB = round(state.reclaimedMB + Math.max(0, before - after));
114
+ floors.push(after);
115
+ if (floors.length > 12) floors.shift();
116
+ trackLeak();
117
+ }
118
+ if (config.log) console.log(` [Kreft GC] ${over ? 'over threshold' : 'growing ' + trend + ' MB/min'}: ${round(before)} → ${round(after)} MB.`);
119
+
120
+ if (after > config.thresholdMB * 0.9) {
121
+ state.overStreak++;
122
+ if (state.overStreak >= config.alertAfter) {
123
+ emitAlert('memory-high', `Memory stays high after cleaning: ${round(after)} MB (threshold ${config.thresholdMB} MB) for ${state.overStreak} checks in a row.`, { overStreak: state.overStreak });
124
+ }
125
+ } else {
126
+ state.overStreak = 0;
127
+ }
128
+ return { action: over ? 'cleanup-threshold' : 'cleanup-trend', beforeMB: round(before), afterMB: round(after), trend };
129
+ }
9
130
 
10
131
  function startAutoGarbageCollector(options = {}) {
11
132
  if (gcTimer) return;
12
- const thresholdMB = Number(options.thresholdMB || process.env.AUTO_GC_THRESHOLD_MB) || 250;
13
- const intervalMs = Number(options.intervalMs || process.env.AUTO_GC_INTERVAL_MS) || 30000;
14
- const log = options.log || false;
15
-
16
- gcTimer = setInterval(() => {
17
- try {
18
- const mem = process.memoryUsage();
19
- lastMemoryUsage = mem;
20
- const heapUsedMB = mem.heapUsed / (1024 * 1024);
21
-
22
- if (heapUsedMB > thresholdMB) {
23
- // 1. Purge migrator file caches
24
- try {
25
- const migrator = require('./migrator.js');
26
- if (migrator && typeof migrator.forgetProjectFiles === 'function') {
27
- migrator.forgetProjectFiles(process.cwd());
28
- }
29
- } catch (_) {}
30
-
31
- // 2. Trigger V8 Garbage Collection if exposed (--expose-gc)
32
- if (typeof global.gc === 'function') {
33
- global.gc();
34
- if (log) {
35
- const after = process.memoryUsage();
36
- const reclaimedMB = (heapUsedMB - after.heapUsed / (1024 * 1024)).toFixed(1);
37
- console.log(` [Kreft GC] Auto-collected ${reclaimedMB} MB heap memory.`);
38
- }
39
- }
40
- }
41
- } catch (_) {}
42
- }, intervalMs);
133
+ config = {
134
+ thresholdMB: Number(options.thresholdMB || process.env.AUTO_GC_THRESHOLD_MB) || 250,
135
+ intervalMs: Number(options.intervalMs || process.env.AUTO_GC_INTERVAL_MS) || 30000,
136
+ growthMBPerMin: Number(options.growthMBPerMin || process.env.AUTO_GC_GROWTH_MB_PER_MIN) || 20,
137
+ alertAfter: Number(options.alertAfter || process.env.AUTO_GC_ALERT_AFTER) || 3,
138
+ leakRiseMB: Number(options.leakRiseMB || process.env.AUTO_GC_LEAK_RISE_MB) || 20,
139
+ alertCooldownMs: Number(options.alertCooldownMs) || 10 * 60 * 1000,
140
+ log: options.log || false,
141
+ };
142
+ Object.assign(state, freshState(), { startedAt: new Date().toISOString() });
143
+ history.length = 0;
144
+ floors.length = 0;
145
+
146
+ // Purge migrator file caches under pressure (always present)
147
+ onMemoryPressure('migrator', () => {
148
+ const migrator = require('./migrator.js');
149
+ if (migrator && typeof migrator.forgetProjectFiles === 'function') migrator.forgetProjectFiles(process.cwd());
150
+ return 0;
151
+ });
152
+
153
+ try {
154
+ const { PerformanceObserver } = require('perf_hooks');
155
+ observer = new PerformanceObserver((list) => { state.v8Collections += list.getEntries().length; });
156
+ observer.observe({ entryTypes: ['gc'] });
157
+ } catch (_) { observer = null; }
43
158
 
159
+ gcTimer = setInterval(() => { try { checkMemory(); } catch (_) {} }, config.intervalMs);
44
160
  if (gcTimer.unref) gcTimer.unref(); // Don't hold node process open
45
161
  }
46
162
 
47
163
  function stopAutoGarbageCollector() {
48
- if (gcTimer) {
49
- clearInterval(gcTimer);
50
- gcTimer = null;
51
- }
164
+ if (gcTimer) { clearInterval(gcTimer); gcTimer = null; }
165
+ if (observer) { try { observer.disconnect(); } catch (_) {} observer = null; }
166
+ pressureHandlers.delete('migrator');
52
167
  }
53
168
 
54
169
  function getMemoryStats() {
55
170
  const mem = process.memoryUsage();
56
171
  return {
57
- rssMB: +(mem.rss / (1024 * 1024)).toFixed(2),
58
- heapTotalMB: +(mem.heapTotal / (1024 * 1024)).toFixed(2),
59
- heapUsedMB: +(mem.heapUsed / (1024 * 1024)).toFixed(2),
60
- externalMB: +(mem.external / (1024 * 1024)).toFixed(2),
172
+ rssMB: +(mem.rss / MB).toFixed(2),
173
+ heapTotalMB: +(mem.heapTotal / MB).toFixed(2),
174
+ heapUsedMB: +(mem.heapUsed / MB).toFixed(2),
175
+ externalMB: +(mem.external / MB).toFixed(2),
61
176
  gcExposed: typeof global.gc === 'function',
62
177
  };
63
178
  }
64
179
 
180
+ /** Everything a dashboard or alert needs, as plain JSON. */
181
+ function getMemoryReport() {
182
+ return {
183
+ ...getMemoryStats(),
184
+ running: !!gcTimer,
185
+ config,
186
+ startedAt: state.startedAt,
187
+ checks: state.checks,
188
+ cleanups: state.cleanups,
189
+ gcRuns: state.gcRuns,
190
+ v8Collections: state.v8Collections,
191
+ reclaimedMB: state.reclaimedMB,
192
+ evictedEntries: state.evicted,
193
+ trendMBPerMin: trendMBPerMin(),
194
+ overStreak: state.overStreak,
195
+ leak: state.leak,
196
+ alerts: state.alerts.slice(),
197
+ history: history.slice(),
198
+ };
199
+ }
200
+
201
+ /** Writes a V8 heap snapshot (open it in Chrome DevTools → Memory) to find what is holding memory. Returns the file path. */
202
+ function writeHeapSnapshot(file) {
203
+ const path = require('path');
204
+ const dir = path.join(process.cwd(), 'storage', 'logs');
205
+ const target = file || path.join(dir, `heap-${Date.now()}.heapsnapshot`);
206
+ require('fs').mkdirSync(path.dirname(target), { recursive: true });
207
+ return require('v8').writeHeapSnapshot(target);
208
+ }
209
+
65
210
  module.exports = {
66
211
  startAutoGarbageCollector,
67
212
  stopAutoGarbageCollector,
68
213
  getMemoryStats,
214
+ getMemoryReport,
215
+ onMemoryPressure,
216
+ onMemoryAlert,
217
+ writeHeapSnapshot,
218
+ checkMemory,
69
219
  };
@@ -96,6 +96,8 @@ const cookieSecure = () => {
96
96
  function response(body, status = 200, headers = {}) { return new Response(body, status, headers); }
97
97
  response.json = (body, status = 200, headers = {}) => new Response(body, status, headers);
98
98
  response.noContent = () => new Response(undefined, 204);
99
+ // response.stream('/path/video.mp4') - sends a file with byte-range support (seeking, Safari); { type, download, headers }
100
+ response.stream = (file, opts = {}) => { const r = new Response(undefined, 200); r.streamFile = { file, ...opts }; return r; };
99
101
  const noContent = () => new Response(undefined, 204);
100
102
  const toResponse = (v) => (v instanceof Response ? v : v === undefined ? new Response(undefined, 204) : new Response(v, 200));
101
103
 
@@ -313,8 +315,10 @@ class Request {
313
315
  if (kind === 'unique' && ignore && ignore !== 'NULL') { sql += ` AND ${conn.q(idColumn)} != ?`; params.push(ignore); }
314
316
  const found = (await conn.query(sql + ' LIMIT 1', params)).length > 0;
315
317
  const label = (names && names[field]) || field.replace(/_/g, ' ');
316
- if (kind === 'unique' && found) (errors[field] = errors[field] || []).push(`The ${label} has already been taken.`);
317
- if (kind === 'exists' && !found) (errors[field] = errors[field] || []).push(`The selected ${label} is invalid.`);
318
+ // your own message for the rule: { 'title.unique': 'That title is already used.' }
319
+ const own = messages && (messages[`${field}.${kind}`] || messages[kind]);
320
+ if (kind === 'unique' && found) (errors[field] = errors[field] || []).push(own || `The ${label} has already been taken.`);
321
+ if (kind === 'exists' && !found) (errors[field] = errors[field] || []).push(own || `The selected ${label} is invalid.`);
318
322
  }
319
323
  }
320
324
  if (Object.keys(errors).length) throw new ValidationError(errors);
@@ -273,6 +273,10 @@ const SECURITY_HEADERS = {
273
273
  });
274
274
  const send = (res, r) => {
275
275
  if (res.headersSent || res.writableEnded) return;
276
+ if (r.streamFile) { // response.stream(file): byte ranges for video and audio
277
+ const { file, ...o } = r.streamFile;
278
+ return require('./stream.js').sendFile(res.req, res, file, { ...o, headers: { 'Cache-Control': 'no-store', ...SECURITY_HEADERS, ...r.headers, ...(o.headers || {}) } });
279
+ }
276
280
  const status = r.statusCode || 200;
277
281
  const noBody = status === 204 || status === 304 || r.body === undefined;
278
282
  const isHtml = typeof r.body === 'string' && (r.headers && r.headers['Content-Type'] && r.headers['Content-Type'].includes('text/html'));
@@ -449,6 +453,8 @@ module.exports = {
449
453
  QR: require('./qr.js').QR,
450
454
  ...require('./gc.js'),
451
455
  ...require('./mcp.js'),
456
+ ...require('./mcp-client.js'), // Mcp.connect(url), McpClient
457
+ ...require('./shield.js'), // Shield: SHIELD=true in .env limits and bans abusive addresses
452
458
  ...require('./config.js'), // config, Config, ConfigRepository
453
459
  ...require('./html.js'), // cleanHtml, sanitizeHtml, stripTags, escapeHtml, e, unescapeHtml, SafeHtml, html
454
460
  // for the kreftjs command
@@ -0,0 +1,133 @@
1
+ /*
2
+ * MCP client (Streamable HTTP): call tools on a remote MCP server from your app.
3
+ *
4
+ * const { Mcp } = require('kreftjs/server')
5
+ * const quran = await Mcp.connect('https://mcp.quran.ai')
6
+ * const tools = await quran.tools()
7
+ * const out = await quran.call(tools[0].name, { ... })
8
+ * await quran.close()
9
+ *
10
+ * Options: { headers: { Authorization: 'Bearer …' }, timeout: 30000, maxBytes: 10485760 }
11
+ *
12
+ * Security: only http(s) URLs; redirects are refused (a redirect could carry your Authorization header to another
13
+ * host); a response over maxBytes is cut off. Never pass a URL a visitor typed: the server would then fetch
14
+ * addresses of your choosing for them (SSRF), including your internal network. Use a fixed or allow-listed URL.
15
+ */
16
+ class McpClient {
17
+ constructor(url, options = {}) {
18
+ this.url = url;
19
+ this.headers = options.headers || {};
20
+ this.timeout = options.timeout || 30000;
21
+ this.maxBytes = options.maxBytes || 10 * 1024 * 1024;
22
+ let u;
23
+ try { u = new URL(url); } catch (_) { throw new Error('MCP: not a valid URL: ' + url); }
24
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('MCP: only http:// and https:// URLs are allowed');
25
+ this.sessionId = null;
26
+ this.server = null;
27
+ this._id = 0;
28
+ }
29
+
30
+ async _post(body) {
31
+ const headers = {
32
+ 'content-type': 'application/json',
33
+ accept: 'application/json, text/event-stream',
34
+ ...this.headers,
35
+ };
36
+ if (this.sessionId) headers['mcp-session-id'] = this.sessionId;
37
+ const res = await fetch(this.url, {
38
+ method: 'POST',
39
+ headers,
40
+ body: JSON.stringify(body),
41
+ signal: AbortSignal.timeout(this.timeout),
42
+ redirect: 'error',
43
+ });
44
+ const sid = res.headers.get('mcp-session-id');
45
+ if (sid) this.sessionId = sid;
46
+ if (!res.ok) throw new Error(`MCP ${this.url} answered ${res.status}: ${(await this._readText(res).catch(() => '')).slice(0, 200)}`);
47
+ if (body.id === undefined) return null; // notification
48
+ const text = await this._readText(res);
49
+ const type = res.headers.get('content-type') || '';
50
+ let msg = null;
51
+ if (type.includes('text/event-stream')) {
52
+ for (const block of text.split(/\r?\n\r?\n/)) {
53
+ const data = block.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trimStart()).join('\n');
54
+ if (!data) continue;
55
+ try {
56
+ const m = JSON.parse(data);
57
+ if (m.id === body.id) { msg = m; break; }
58
+ } catch { /* skip non-JSON events */ }
59
+ }
60
+ } else {
61
+ msg = JSON.parse(text);
62
+ }
63
+ if (!msg) throw new Error('MCP: no response for ' + body.method);
64
+ if (msg.error) throw new Error(`MCP ${body.method}: ${msg.error.message || JSON.stringify(msg.error)}`);
65
+ return msg.result;
66
+ }
67
+
68
+ // reads the body but stops at maxBytes, so a hostile or broken server cannot fill the memory
69
+ async _readText(res) {
70
+ const len = Number(res.headers.get('content-length'));
71
+ if (len > this.maxBytes) throw new Error(`MCP: the response is larger than ${this.maxBytes} bytes`);
72
+ if (!res.body) return '';
73
+ const chunks = [];
74
+ let size = 0;
75
+ for await (const chunk of res.body) {
76
+ size += chunk.length;
77
+ if (size > this.maxBytes) { try { await res.body.cancel(); } catch (_) {} throw new Error(`MCP: the response is larger than ${this.maxBytes} bytes`); }
78
+ chunks.push(chunk);
79
+ }
80
+ return Buffer.concat(chunks).toString('utf8');
81
+ }
82
+
83
+ rpc(method, params = {}) {
84
+ return this._post({ jsonrpc: '2.0', id: ++this._id, method, params });
85
+ }
86
+
87
+ async connect() {
88
+ const result = await this.rpc('initialize', {
89
+ protocolVersion: '2025-03-26',
90
+ capabilities: {},
91
+ clientInfo: { name: 'kreftjs', version: require('../../package.json').version },
92
+ });
93
+ this.server = result;
94
+ await this._post({ jsonrpc: '2.0', method: 'notifications/initialized' });
95
+ return this;
96
+ }
97
+
98
+ async tools() {
99
+ const all = [];
100
+ let cursor;
101
+ do {
102
+ const r = await this.rpc('tools/list', cursor ? { cursor } : {});
103
+ all.push(...(r.tools || []));
104
+ cursor = r.nextCursor;
105
+ } while (cursor);
106
+ return all;
107
+ }
108
+
109
+ /** Calls a tool. Returns structuredContent when present, else the joined text, else the raw result. */
110
+ async call(name, args = {}) {
111
+ const r = await this.rpc('tools/call', { name, arguments: args });
112
+ if (r.isError) throw new Error(`MCP tool ${name} failed: ${(r.content || []).map((c) => c.text).join(' ')}`);
113
+ if (r.structuredContent) return r.structuredContent;
114
+ const text = (r.content || []).filter((c) => c.type === 'text').map((c) => c.text).join('\n');
115
+ if (!text) return r;
116
+ try { return JSON.parse(text); } catch { return text; }
117
+ }
118
+
119
+ async resources() { return (await this.rpc('resources/list')).resources || []; }
120
+ async readResource(uri) { return this.rpc('resources/read', { uri }); }
121
+
122
+ async close() {
123
+ if (!this.sessionId) return;
124
+ try { await fetch(this.url, { method: 'DELETE', headers: { ...this.headers, 'mcp-session-id': this.sessionId }, signal: AbortSignal.timeout(5000), redirect: 'error' }); } catch { /* server may not support it */ }
125
+ this.sessionId = null;
126
+ }
127
+ }
128
+
129
+ const Mcp = {
130
+ connect: (url, options) => new McpClient(url, options).connect(),
131
+ };
132
+
133
+ module.exports = { McpClient, Mcp };
@@ -159,6 +159,23 @@ class McpServer {
159
159
  };
160
160
  });
161
161
 
162
+ // 3b. Tool: memory_report - GC runs, evictions, trend, leak suspicion, alerts, recent samples
163
+ this.registerTool('memory_report', {
164
+ description: 'Memory Manager report: heap trend, GC and cache-eviction counts, leak suspicion, recent alerts and samples.',
165
+ inputSchema: { type: 'object', properties: {} },
166
+ }, async () => ({
167
+ content: [{ type: 'text', text: JSON.stringify(require('./gc.js').getMemoryReport(), null, 2) }],
168
+ }));
169
+
170
+ // 3c. Tool: shield_status - the abuse shield: banned addresses, counters, limits
171
+ this.registerTool('shield_status', {
172
+ description: 'Kreft Shield (SHIELD=true): whether it is on, its limits, counters, event-loop lag and the currently banned addresses.',
173
+ inputSchema: { type: 'object', properties: {} },
174
+ }, async () => {
175
+ const { Shield } = require('./shield.js');
176
+ return { content: [{ type: 'text', text: JSON.stringify({ ...Shield.status(), bans: Shield.bans() }, null, 2) }] };
177
+ });
178
+
162
179
  // 4. Resource: kreft://routes
163
180
  const routesResource = async () => {
164
181
  const { Route } = require('./index.js');
@@ -255,6 +255,17 @@ class ModelBuilder extends Builder {
255
255
  async restore() { const M = this._model; const b = this.clone(); b._trashed = 'only'; return Builder.prototype.update.call(b, { [M.deletedAtColumn]: null }); }
256
256
  }
257
257
 
258
+ // A model with no `static fillable` lets create()/update() set every column but `id`. If the data came from a request,
259
+ // a visitor could send { "is_admin": true } or { "role": "admin" }. Say so once per model and column.
260
+ const PRIVILEGE_KEY_RE = /^(is_?admin|is_?super_?user|is_?staff|is_?owner|role|roles|role_?id|permissions?|remember_token|api_token|email_verified_at)$/i;
261
+ const warnedFill = new Set();
262
+ function warnPrivilegeFill(C, key) {
263
+ const id = (C.name || C.table) + '.' + key;
264
+ if (warnedFill.has(id)) return;
265
+ warnedFill.add(id);
266
+ console.warn(` [Kreft Security] ${C.name || C.table}.${key} is being mass-assigned and the model has no static fillable. If this value can come from a request, a visitor could set it themselves. List the allowed columns in static fillable (or add '${key}' to static guarded); use forceFill() where you really mean it.`);
267
+ }
268
+
258
269
  /* ------------------------------------------------------------------ the model */
259
270
  class Model {
260
271
  // ---- settings a model can change
@@ -415,7 +426,10 @@ class Model {
415
426
  for (const [k, v] of Object.entries(attributes)) {
416
427
  if (BAD_KEYS.has(k)) continue;
417
428
  const allowed = C.fillable ? C.fillable.includes(k) : !(C.guarded || []).includes(k) && !(C.guarded || []).includes('*');
418
- if (allowed) this.setAttribute(k, v);
429
+ if (allowed) {
430
+ if (!C.fillable && PRIVILEGE_KEY_RE.test(k)) warnPrivilegeFill(C, k);
431
+ this.setAttribute(k, v);
432
+ }
419
433
  }
420
434
  return this;
421
435
  }