chadstart 1.0.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/.dockerignore +10 -0
- package/.env.example +46 -0
- package/.github/workflows/browser-test.yml +34 -0
- package/.github/workflows/docker-publish.yml +54 -0
- package/.github/workflows/docs.yml +31 -0
- package/.github/workflows/npm-chadstart.yml +27 -0
- package/.github/workflows/npm-sdk.yml +38 -0
- package/.github/workflows/test.yml +85 -0
- package/.weblate +9 -0
- package/Dockerfile +23 -0
- package/README.md +348 -0
- package/admin/index.html +2802 -0
- package/admin/login.html +207 -0
- package/chadstart.example.yml +416 -0
- package/chadstart.schema.json +367 -0
- package/chadstart.yaml +53 -0
- package/cli/cli.js +295 -0
- package/core/api-generator.js +606 -0
- package/core/auth.js +298 -0
- package/core/db.js +384 -0
- package/core/entity-engine.js +166 -0
- package/core/error-reporter.js +132 -0
- package/core/file-storage.js +97 -0
- package/core/functions-engine.js +353 -0
- package/core/openapi.js +171 -0
- package/core/plugin-loader.js +92 -0
- package/core/realtime.js +93 -0
- package/core/schema-validator.js +50 -0
- package/core/seeder.js +231 -0
- package/core/telemetry.js +119 -0
- package/core/upload.js +372 -0
- package/core/workers/php_worker.php +19 -0
- package/core/workers/python_worker.py +33 -0
- package/core/workers/ruby_worker.rb +21 -0
- package/core/yaml-loader.js +64 -0
- package/demo/chadstart.yaml +178 -0
- package/demo/docker-compose.yml +31 -0
- package/demo/functions/greet.go +39 -0
- package/demo/functions/hello.cpp +18 -0
- package/demo/functions/hello.py +13 -0
- package/demo/functions/hello.rb +10 -0
- package/demo/functions/onTodoCreated.js +13 -0
- package/demo/functions/ping.sh +13 -0
- package/demo/functions/stats.js +22 -0
- package/demo/public/index.html +522 -0
- package/docker-compose.yml +17 -0
- package/docs/access-policies.md +155 -0
- package/docs/admin-ui.md +29 -0
- package/docs/angular.md +69 -0
- package/docs/astro.md +71 -0
- package/docs/auth.md +160 -0
- package/docs/cli.md +56 -0
- package/docs/config.md +127 -0
- package/docs/crud.md +627 -0
- package/docs/deploy.md +113 -0
- package/docs/docker.md +59 -0
- package/docs/entities.md +385 -0
- package/docs/functions.md +196 -0
- package/docs/getting-started.md +79 -0
- package/docs/groups.md +85 -0
- package/docs/index.md +5 -0
- package/docs/llm-rules.md +81 -0
- package/docs/middlewares.md +78 -0
- package/docs/overrides/home.html +350 -0
- package/docs/plugins.md +59 -0
- package/docs/react.md +75 -0
- package/docs/realtime.md +43 -0
- package/docs/s3-storage.md +40 -0
- package/docs/security.md +23 -0
- package/docs/stylesheets/extra.css +375 -0
- package/docs/svelte.md +71 -0
- package/docs/telemetry.md +97 -0
- package/docs/upload.md +168 -0
- package/docs/validation.md +115 -0
- package/docs/vue.md +86 -0
- package/docs/webhooks.md +87 -0
- package/index.js +11 -0
- package/locales/en/admin.json +169 -0
- package/mkdocs.yml +82 -0
- package/package.json +65 -0
- package/playwright.config.js +24 -0
- package/public/.gitkeep +0 -0
- package/sdk/README.md +284 -0
- package/sdk/package.json +39 -0
- package/sdk/scripts/build.js +58 -0
- package/sdk/src/index.js +368 -0
- package/sdk/test/sdk.test.cjs +340 -0
- package/sdk/types/index.d.ts +217 -0
- package/server/express-server.js +734 -0
- package/test/access-policies.test.js +96 -0
- package/test/ai.test.js +81 -0
- package/test/api-keys.test.js +361 -0
- package/test/auth.test.js +122 -0
- package/test/browser/admin-ui.spec.js +127 -0
- package/test/browser/global-setup.js +71 -0
- package/test/browser/global-teardown.js +11 -0
- package/test/db.test.js +227 -0
- package/test/entity-engine.test.js +193 -0
- package/test/error-reporter.test.js +140 -0
- package/test/functions-engine.test.js +240 -0
- package/test/groups.test.js +212 -0
- package/test/hot-reload.test.js +153 -0
- package/test/i18n.test.js +173 -0
- package/test/middleware.test.js +76 -0
- package/test/openapi.test.js +67 -0
- package/test/schema-validator.test.js +83 -0
- package/test/sdk.test.js +90 -0
- package/test/seeder.test.js +279 -0
- package/test/settings.test.js +109 -0
- package/test/telemetry.test.js +254 -0
- package/test/test.js +17 -0
- package/test/upload.test.js +265 -0
- package/test/validation.test.js +96 -0
- package/test/yaml-loader.test.js +93 -0
- package/utils/logger.js +24 -0
package/core/upload.js
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const logger = require('../utils/logger');
|
|
6
|
+
|
|
7
|
+
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
8
|
+
|
|
9
|
+
/** Returns a month folder string like "Mar2026". */
|
|
10
|
+
function getMonthFolder(date) {
|
|
11
|
+
const d = date || new Date();
|
|
12
|
+
return `${MONTH_NAMES[d.getMonth()]}${d.getFullYear()}`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Returns the configured base URL (defaults to http://localhost:<port>). */
|
|
16
|
+
function getBaseUrl(core) {
|
|
17
|
+
return process.env.BASE_URL || `http://localhost:${core.port}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Returns true when all required S3 env vars are set. */
|
|
21
|
+
function isS3Configured() {
|
|
22
|
+
return !!(
|
|
23
|
+
process.env.S3_BUCKET &&
|
|
24
|
+
process.env.S3_ENDPOINT &&
|
|
25
|
+
process.env.S3_REGION &&
|
|
26
|
+
process.env.S3_ACCESS_KEY_ID &&
|
|
27
|
+
process.env.S3_SECRET_ACCESS_KEY
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Sanitizes a filename to prevent path traversal and special chars. */
|
|
32
|
+
function sanitizeFilename(filename) {
|
|
33
|
+
return path.basename(filename)
|
|
34
|
+
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
35
|
+
.replace(/^\.+/, '_');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Generates a short random unique prefix for filenames. */
|
|
39
|
+
function generateUniquePrefix() {
|
|
40
|
+
return (
|
|
41
|
+
Math.random().toString(36).slice(2, 10) +
|
|
42
|
+
Math.random().toString(36).slice(2, 10)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Saves a buffer to a local directory and returns the full file path. */
|
|
47
|
+
function saveLocally(buffer, dir, filename) {
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
const dest = path.join(dir, filename);
|
|
50
|
+
fs.writeFileSync(dest, buffer);
|
|
51
|
+
return dest;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Uploads a buffer to S3 and returns the public URL.
|
|
56
|
+
* The path is optionally prefixed by S3_FOLDER_PREFIX.
|
|
57
|
+
*/
|
|
58
|
+
async function uploadToS3(buffer, key, contentType) {
|
|
59
|
+
const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
60
|
+
const folderPrefix = process.env.S3_FOLDER_PREFIX
|
|
61
|
+
? `${process.env.S3_FOLDER_PREFIX}/`
|
|
62
|
+
: '';
|
|
63
|
+
const fullKey = `${folderPrefix}${key}`;
|
|
64
|
+
|
|
65
|
+
const client = new S3Client({
|
|
66
|
+
region: process.env.S3_REGION,
|
|
67
|
+
endpoint: process.env.S3_ENDPOINT,
|
|
68
|
+
credentials: {
|
|
69
|
+
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
|
70
|
+
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
|
71
|
+
},
|
|
72
|
+
forcePathStyle: true,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await client.send(
|
|
76
|
+
new PutObjectCommand({
|
|
77
|
+
Bucket: process.env.S3_BUCKET,
|
|
78
|
+
Key: fullKey,
|
|
79
|
+
Body: buffer,
|
|
80
|
+
ContentType: contentType,
|
|
81
|
+
})
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return `${process.env.S3_ENDPOINT}/${process.env.S3_BUCKET}/${fullKey}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns image processing options for the given entity + property.
|
|
89
|
+
*
|
|
90
|
+
* Defaults:
|
|
91
|
+
* - compress: true (convert to JPEG at quality 80; disable with `options.compress: false`)
|
|
92
|
+
* - quality: 80 (JPEG quality 1-100; override with `options.quality`)
|
|
93
|
+
* - sizes: null (no resizing; enable with `options.sizes: { name: [w, h], ... }`)
|
|
94
|
+
*/
|
|
95
|
+
function getImageOptions(core, entity, property) {
|
|
96
|
+
const entityDef = Object.values(core.entities || {}).find(
|
|
97
|
+
(e) =>
|
|
98
|
+
e.slug === entity ||
|
|
99
|
+
e.tableName === entity ||
|
|
100
|
+
e.name === entity
|
|
101
|
+
);
|
|
102
|
+
const propDef = entityDef && entityDef.properties.find((p) => p.name === property);
|
|
103
|
+
const opts = (propDef && propDef.options) || {};
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
compress: opts.compress !== false,
|
|
107
|
+
quality: typeof opts.quality === 'number' ? opts.quality : 80,
|
|
108
|
+
sizes: opts.sizes || null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Register upload routes:
|
|
114
|
+
* POST /api/upload/file — upload any file
|
|
115
|
+
* POST /api/upload/image — upload + resize a PNG/JPG image
|
|
116
|
+
*/
|
|
117
|
+
function registerUploadRoutes(app, core) {
|
|
118
|
+
const Busboy = getBusboy();
|
|
119
|
+
|
|
120
|
+
// ── POST /api/upload/file ───────────────────────────────────────────────────
|
|
121
|
+
app.post('/api/upload/file', (req, res) => {
|
|
122
|
+
const contentType = req.headers['content-type'] || '';
|
|
123
|
+
if (!contentType.includes('multipart/form-data')) {
|
|
124
|
+
return res.status(400).json({ error: 'Expected multipart/form-data' });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const bb = Busboy({ headers: req.headers });
|
|
128
|
+
const fields = {};
|
|
129
|
+
let fileBuffer = null;
|
|
130
|
+
let fileInfo = null;
|
|
131
|
+
|
|
132
|
+
bb.on('field', (name, value) => {
|
|
133
|
+
fields[name] = value;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
bb.on('file', (_fieldname, stream, info) => {
|
|
137
|
+
const chunks = [];
|
|
138
|
+
stream.on('data', (chunk) => chunks.push(chunk));
|
|
139
|
+
stream.on('end', () => {
|
|
140
|
+
fileBuffer = Buffer.concat(chunks);
|
|
141
|
+
fileInfo = info;
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
bb.on('finish', async () => {
|
|
146
|
+
try {
|
|
147
|
+
if (!fileBuffer || !fileInfo || !fileInfo.filename) {
|
|
148
|
+
return res.status(400).json({ error: 'No file provided' });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const { entity, property } = fields;
|
|
152
|
+
if (!entity || !property) {
|
|
153
|
+
return res.status(400).json({ error: 'Missing entity or property fields' });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const safeName = sanitizeFilename(fileInfo.filename);
|
|
157
|
+
const prefix = generateUniquePrefix();
|
|
158
|
+
const finalName = `${prefix}-${safeName}`;
|
|
159
|
+
const monthFolder = getMonthFolder();
|
|
160
|
+
const relPath = `storage/${entity}/${property}/${monthFolder}/${finalName}`;
|
|
161
|
+
|
|
162
|
+
let url;
|
|
163
|
+
if (isS3Configured()) {
|
|
164
|
+
url = await uploadToS3(
|
|
165
|
+
fileBuffer,
|
|
166
|
+
relPath,
|
|
167
|
+
fileInfo.mimeType || 'application/octet-stream'
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
const publicFolder = (core.public && core.public.folder) || './public';
|
|
171
|
+
const dir = path.resolve(
|
|
172
|
+
publicFolder,
|
|
173
|
+
'storage',
|
|
174
|
+
entity,
|
|
175
|
+
property,
|
|
176
|
+
monthFolder
|
|
177
|
+
);
|
|
178
|
+
saveLocally(fileBuffer, dir, finalName);
|
|
179
|
+
url = `${getBaseUrl(core)}/storage/${entity}/${property}/${monthFolder}/${finalName}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
res.json({ path: url });
|
|
183
|
+
} catch (err) {
|
|
184
|
+
logger.error('File upload error', err.message);
|
|
185
|
+
res.status(500).json({ error: err.message });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
bb.on('error', (err) => {
|
|
190
|
+
logger.error('Upload parse error', err.message);
|
|
191
|
+
res.status(500).json({ error: err.message });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
req.pipe(bb);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// ── POST /api/upload/image ──────────────────────────────────────────────────
|
|
198
|
+
app.post('/api/upload/image', (req, res) => {
|
|
199
|
+
const contentType = req.headers['content-type'] || '';
|
|
200
|
+
if (!contentType.includes('multipart/form-data')) {
|
|
201
|
+
return res.status(400).json({ error: 'Expected multipart/form-data' });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const bb = Busboy({ headers: req.headers });
|
|
205
|
+
const fields = {};
|
|
206
|
+
let imageBuffer = null;
|
|
207
|
+
let imageInfo = null;
|
|
208
|
+
|
|
209
|
+
bb.on('field', (name, value) => {
|
|
210
|
+
fields[name] = value;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
bb.on('file', (_fieldname, stream, info) => {
|
|
214
|
+
const chunks = [];
|
|
215
|
+
stream.on('data', (chunk) => chunks.push(chunk));
|
|
216
|
+
stream.on('end', () => {
|
|
217
|
+
imageBuffer = Buffer.concat(chunks);
|
|
218
|
+
imageInfo = info;
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
bb.on('finish', async () => {
|
|
223
|
+
try {
|
|
224
|
+
if (!imageBuffer || !imageInfo || !imageInfo.filename) {
|
|
225
|
+
return res.status(400).json({ error: 'No image provided' });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Validate PNG/JPG only
|
|
229
|
+
const mime = (imageInfo.mimeType || '').toLowerCase();
|
|
230
|
+
const ext = path.extname(imageInfo.filename).toLowerCase();
|
|
231
|
+
const validMime = mime === 'image/png' || mime === 'image/jpeg';
|
|
232
|
+
const validExt = ext === '.png' || ext === '.jpg' || ext === '.jpeg';
|
|
233
|
+
if (!validMime && !validExt) {
|
|
234
|
+
return res
|
|
235
|
+
.status(400)
|
|
236
|
+
.json({ error: 'Only PNG and JPG images are accepted' });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const { entity, property } = fields;
|
|
240
|
+
if (!entity || !property) {
|
|
241
|
+
return res
|
|
242
|
+
.status(400)
|
|
243
|
+
.json({ error: 'Missing entity or property fields' });
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { compress, quality, sizes } = getImageOptions(core, entity, property);
|
|
247
|
+
const prefix = generateUniquePrefix();
|
|
248
|
+
const monthFolder = getMonthFolder();
|
|
249
|
+
const sharp = getSharp();
|
|
250
|
+
|
|
251
|
+
if (sizes) {
|
|
252
|
+
// ── Resize mode: one output file per configured size ──────────────────
|
|
253
|
+
const result = {};
|
|
254
|
+
|
|
255
|
+
for (const [sizeName, dims] of Object.entries(sizes)) {
|
|
256
|
+
const [width, height] = dims;
|
|
257
|
+
const filename = `${prefix}-${sizeName}.jpg`;
|
|
258
|
+
let pipeline = sharp(imageBuffer).resize(width, height, { fit: 'cover' });
|
|
259
|
+
pipeline = compress
|
|
260
|
+
? pipeline.jpeg({ quality })
|
|
261
|
+
: pipeline.jpeg({ quality: 100 });
|
|
262
|
+
const processed = await pipeline.toBuffer();
|
|
263
|
+
|
|
264
|
+
if (isS3Configured()) {
|
|
265
|
+
const key = `storage/${entity}/${property}/${monthFolder}/${filename}`;
|
|
266
|
+
result[sizeName] = await uploadToS3(processed, key, 'image/jpeg');
|
|
267
|
+
} else {
|
|
268
|
+
const publicFolder = (core.public && core.public.folder) || './public';
|
|
269
|
+
const dir = path.resolve(
|
|
270
|
+
publicFolder,
|
|
271
|
+
'storage',
|
|
272
|
+
entity,
|
|
273
|
+
property,
|
|
274
|
+
monthFolder
|
|
275
|
+
);
|
|
276
|
+
saveLocally(processed, dir, filename);
|
|
277
|
+
result[sizeName] =
|
|
278
|
+
`${getBaseUrl(core)}/storage/${entity}/${property}/${monthFolder}/${filename}`;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return res.json(result);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ── No-resize mode: single output file ───────────────────────────────
|
|
286
|
+
let processedBuffer;
|
|
287
|
+
let outputMime;
|
|
288
|
+
let finalName;
|
|
289
|
+
|
|
290
|
+
if (compress) {
|
|
291
|
+
// Convert to JPEG with lossy compression
|
|
292
|
+
processedBuffer = await sharp(imageBuffer).jpeg({ quality }).toBuffer();
|
|
293
|
+
outputMime = 'image/jpeg';
|
|
294
|
+
const baseName = path.basename(
|
|
295
|
+
sanitizeFilename(imageInfo.filename),
|
|
296
|
+
path.extname(imageInfo.filename)
|
|
297
|
+
);
|
|
298
|
+
finalName = `${prefix}-${baseName}.jpg`;
|
|
299
|
+
} else {
|
|
300
|
+
// Keep original bytes untouched
|
|
301
|
+
processedBuffer = imageBuffer;
|
|
302
|
+
outputMime = imageInfo.mimeType || 'image/octet-stream';
|
|
303
|
+
finalName = `${prefix}-${sanitizeFilename(imageInfo.filename)}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let url;
|
|
307
|
+
if (isS3Configured()) {
|
|
308
|
+
const key = `storage/${entity}/${property}/${monthFolder}/${finalName}`;
|
|
309
|
+
url = await uploadToS3(processedBuffer, key, outputMime);
|
|
310
|
+
} else {
|
|
311
|
+
const publicFolder = (core.public && core.public.folder) || './public';
|
|
312
|
+
const dir = path.resolve(
|
|
313
|
+
publicFolder,
|
|
314
|
+
'storage',
|
|
315
|
+
entity,
|
|
316
|
+
property,
|
|
317
|
+
monthFolder
|
|
318
|
+
);
|
|
319
|
+
saveLocally(processedBuffer, dir, finalName);
|
|
320
|
+
url = `${getBaseUrl(core)}/storage/${entity}/${property}/${monthFolder}/${finalName}`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
res.json({ path: url });
|
|
324
|
+
} catch (err) {
|
|
325
|
+
logger.error('Image upload error', err.message);
|
|
326
|
+
res.status(500).json({ error: err.message });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
bb.on('error', (err) => {
|
|
331
|
+
logger.error('Upload parse error', err.message);
|
|
332
|
+
res.status(500).json({ error: err.message });
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
req.pipe(bb);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
logger.info(
|
|
339
|
+
' Registered upload routes at /api/upload/file and /api/upload/image'
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function getBusboy() {
|
|
344
|
+
try {
|
|
345
|
+
return require('busboy');
|
|
346
|
+
} catch {
|
|
347
|
+
throw new Error(
|
|
348
|
+
'busboy is required for file uploads. Install it with: npm install busboy'
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function getSharp() {
|
|
354
|
+
try {
|
|
355
|
+
return require('sharp');
|
|
356
|
+
} catch {
|
|
357
|
+
throw new Error(
|
|
358
|
+
'sharp is required for image uploads. Install it with: npm install sharp'
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
module.exports = {
|
|
364
|
+
registerUploadRoutes,
|
|
365
|
+
getBaseUrl,
|
|
366
|
+
getMonthFolder,
|
|
367
|
+
isS3Configured,
|
|
368
|
+
sanitizeFilename,
|
|
369
|
+
generateUniquePrefix,
|
|
370
|
+
saveLocally,
|
|
371
|
+
getImageOptions,
|
|
372
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
// ChadStart PHP runtime worker.
|
|
3
|
+
// Protocol: {"id": N, "entry": "/path/to/fn.php", "event": {...}, "ctx": {...}}
|
|
4
|
+
|
|
5
|
+
while (($line = fgets(STDIN)) !== false) {
|
|
6
|
+
$line = trim($line);
|
|
7
|
+
if ($line === '') continue;
|
|
8
|
+
$req = json_decode($line, true);
|
|
9
|
+
try {
|
|
10
|
+
require_once $req['entry'];
|
|
11
|
+
$event = $req['event'] ?? [];
|
|
12
|
+
$ctx = $req['ctx'] ?? [];
|
|
13
|
+
$result = function_exists('handler') ? handler($event, $ctx) : null;
|
|
14
|
+
echo json_encode(['id' => $req['id'], 'result' => $result]) . "\n";
|
|
15
|
+
} catch (Throwable $e) {
|
|
16
|
+
echo json_encode(['id' => $req['id'], 'error' => $e->getMessage()]) . "\n";
|
|
17
|
+
}
|
|
18
|
+
fflush(STDOUT);
|
|
19
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ChadStart Python runtime worker.
|
|
3
|
+
Reads newline-delimited JSON requests from stdin, invokes the function, writes JSON result to stdout.
|
|
4
|
+
Protocol: {"id": N, "entry": "/path/to/fn.py", "event": {...}, "ctx": {...}}
|
|
5
|
+
Response: {"id": N, "result": ...} or {"id": N, "error": "message"}
|
|
6
|
+
"""
|
|
7
|
+
import sys, json, importlib.util, asyncio, os
|
|
8
|
+
|
|
9
|
+
def load_module(entry):
|
|
10
|
+
spec = importlib.util.spec_from_file_location("fn", entry)
|
|
11
|
+
mod = importlib.util.module_from_spec(spec)
|
|
12
|
+
spec.loader.exec_module(mod)
|
|
13
|
+
return mod
|
|
14
|
+
|
|
15
|
+
def run(mod, event, ctx):
|
|
16
|
+
fn = getattr(mod, 'handler', None) or getattr(mod, 'default', None)
|
|
17
|
+
if fn is None:
|
|
18
|
+
raise RuntimeError("No handler or default export found")
|
|
19
|
+
if asyncio.iscoroutinefunction(fn):
|
|
20
|
+
return asyncio.run(fn(event, ctx))
|
|
21
|
+
return fn(event, ctx)
|
|
22
|
+
|
|
23
|
+
for line in sys.stdin:
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if not line:
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
req = json.loads(line)
|
|
29
|
+
mod = load_module(req["entry"])
|
|
30
|
+
result = run(mod, req.get("event", {}), req.get("ctx", {}))
|
|
31
|
+
print(json.dumps({"id": req["id"], "result": result}), flush=True)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
print(json.dumps({"id": req.get("id"), "error": str(e)}), flush=True)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# ChadStart Ruby runtime worker.
|
|
3
|
+
# Protocol: {"id": N, "entry": "/path/to/fn.rb", "event": {...}, "ctx": {...}}
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
$stdout.sync = true
|
|
7
|
+
|
|
8
|
+
$stdin.each_line do |line|
|
|
9
|
+
line.strip!
|
|
10
|
+
next if line.empty?
|
|
11
|
+
begin
|
|
12
|
+
req = JSON.parse(line)
|
|
13
|
+
load req['entry']
|
|
14
|
+
event = req['event'] || {}
|
|
15
|
+
ctx = req['ctx'] || {}
|
|
16
|
+
result = defined?(handler) ? handler(event, ctx) : (defined?(default) ? method(:default).call(event, ctx) : nil)
|
|
17
|
+
puts JSON.generate({ id: req['id'], result: result })
|
|
18
|
+
rescue => e
|
|
19
|
+
puts JSON.generate({ id: req['id'], error: e.message })
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const YAML = require('yaml');
|
|
6
|
+
const logger = require('../utils/logger');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Load and parse the chadstart.yaml file.
|
|
10
|
+
* Returns the raw parsed object.
|
|
11
|
+
*/
|
|
12
|
+
function loadYaml(filePath) {
|
|
13
|
+
const resolved = path.resolve(filePath);
|
|
14
|
+
if (!fs.existsSync(resolved)) {
|
|
15
|
+
throw new Error(`YAML config not found: ${resolved}`);
|
|
16
|
+
}
|
|
17
|
+
const raw = fs.readFileSync(resolved, 'utf8');
|
|
18
|
+
const parsed = YAML.parse(raw);
|
|
19
|
+
logger.debug('Loaded YAML from', resolved);
|
|
20
|
+
return parsed;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Save an updated config object back to a YAML file.
|
|
25
|
+
* Uses yaml's Document API so that comments in unchanged top-level sections
|
|
26
|
+
* are preserved as much as possible.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} filePath Path to the YAML file.
|
|
29
|
+
* @param {object} config Plain-JS config object (already validated).
|
|
30
|
+
*/
|
|
31
|
+
function saveYaml(filePath, config) {
|
|
32
|
+
const resolved = path.resolve(filePath);
|
|
33
|
+
|
|
34
|
+
let doc;
|
|
35
|
+
if (fs.existsSync(resolved)) {
|
|
36
|
+
// Parse into a live Document to keep comments / blank lines on unchanged nodes
|
|
37
|
+
const raw = fs.readFileSync(resolved, 'utf8');
|
|
38
|
+
doc = YAML.parseDocument(raw);
|
|
39
|
+
|
|
40
|
+
const existing = doc.toJS() || {};
|
|
41
|
+
const existingKeys = Object.keys(existing);
|
|
42
|
+
const newKeys = Object.keys(config);
|
|
43
|
+
|
|
44
|
+
// Update or add every key from the incoming config
|
|
45
|
+
for (const key of newKeys) {
|
|
46
|
+
doc.set(key, config[key]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Remove top-level keys that are no longer present
|
|
50
|
+
for (const key of existingKeys) {
|
|
51
|
+
if (!newKeys.includes(key)) {
|
|
52
|
+
doc.delete(key);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
// Create a fresh Document when the file does not yet exist
|
|
57
|
+
doc = new YAML.Document(config);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fs.writeFileSync(resolved, doc.toString(), 'utf8');
|
|
61
|
+
logger.debug('Saved YAML to', resolved);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { loadYaml, saveYaml };
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# demo/chadstart.yaml
|
|
2
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
3
|
+
# ChadStart Todo App — demonstration of a complete chadstart.yaml config.
|
|
4
|
+
# Shows: entities, access policies, realtime, CRUD, and functions in every
|
|
5
|
+
# supported runtime (js, bash, python, go, ruby, c++).
|
|
6
|
+
#
|
|
7
|
+
# To run locally:
|
|
8
|
+
# cd demo
|
|
9
|
+
# CHADSTART_CONFIG=./chadstart.yaml node ../index.js
|
|
10
|
+
#
|
|
11
|
+
# To run with Docker Compose:
|
|
12
|
+
# cd demo
|
|
13
|
+
# docker compose up --build
|
|
14
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
name: Todo App
|
|
17
|
+
port: 3000
|
|
18
|
+
database: ./data/todo.db
|
|
19
|
+
|
|
20
|
+
entities:
|
|
21
|
+
User:
|
|
22
|
+
authenticable: true
|
|
23
|
+
mainProp: name
|
|
24
|
+
seedCount: 5
|
|
25
|
+
properties:
|
|
26
|
+
- name
|
|
27
|
+
- { name: email, type: email }
|
|
28
|
+
- { name: password, type: password }
|
|
29
|
+
policies:
|
|
30
|
+
create:
|
|
31
|
+
- access: public # Anyone can sign up
|
|
32
|
+
read:
|
|
33
|
+
- access: restricted
|
|
34
|
+
allow: User
|
|
35
|
+
condition: self # Users can only read their own profile
|
|
36
|
+
update:
|
|
37
|
+
- access: restricted
|
|
38
|
+
allow: User
|
|
39
|
+
condition: self
|
|
40
|
+
delete:
|
|
41
|
+
- access: admin
|
|
42
|
+
signup:
|
|
43
|
+
- access: public
|
|
44
|
+
validation:
|
|
45
|
+
name:
|
|
46
|
+
required: true
|
|
47
|
+
isNotEmpty: true
|
|
48
|
+
minLength: 2
|
|
49
|
+
maxLength: 100
|
|
50
|
+
email:
|
|
51
|
+
required: true
|
|
52
|
+
isEmail: true
|
|
53
|
+
|
|
54
|
+
Todo:
|
|
55
|
+
mainProp: title
|
|
56
|
+
seedCount: 20
|
|
57
|
+
belongsTo:
|
|
58
|
+
- User
|
|
59
|
+
properties:
|
|
60
|
+
- title
|
|
61
|
+
- { name: description, type: text }
|
|
62
|
+
- { name: completed, type: boolean, default: false }
|
|
63
|
+
- { name: priority, type: choice, options: { values: [low, medium, high] } }
|
|
64
|
+
- { name: dueDate, type: date }
|
|
65
|
+
policies:
|
|
66
|
+
create:
|
|
67
|
+
- { access: restricted, allow: User, condition: self } # Set owner on create
|
|
68
|
+
read:
|
|
69
|
+
- { access: restricted, allow: User, condition: self }
|
|
70
|
+
update:
|
|
71
|
+
- { access: restricted, allow: User, condition: self }
|
|
72
|
+
delete:
|
|
73
|
+
- { access: restricted, allow: User, condition: self }
|
|
74
|
+
validation:
|
|
75
|
+
title:
|
|
76
|
+
required: true
|
|
77
|
+
isNotEmpty: true
|
|
78
|
+
maxLength: 255
|
|
79
|
+
priority:
|
|
80
|
+
isOptional: true
|
|
81
|
+
isIn: [low, medium, high]
|
|
82
|
+
|
|
83
|
+
public:
|
|
84
|
+
folder: ./public
|
|
85
|
+
|
|
86
|
+
functions:
|
|
87
|
+
# JS (default runtime) — returns per-status todo counts.
|
|
88
|
+
stats:
|
|
89
|
+
runtime: js
|
|
90
|
+
function: stats.js
|
|
91
|
+
description: Returns todo statistics (total, completed, pending).
|
|
92
|
+
triggers:
|
|
93
|
+
- type: http
|
|
94
|
+
method: GET
|
|
95
|
+
path: /api/fn/stats
|
|
96
|
+
policies:
|
|
97
|
+
- access: public
|
|
98
|
+
|
|
99
|
+
# Bash — simple health/ping endpoint.
|
|
100
|
+
ping:
|
|
101
|
+
runtime: bash
|
|
102
|
+
function: ping.sh
|
|
103
|
+
description: Health check / ping — returns pong.
|
|
104
|
+
triggers:
|
|
105
|
+
- type: http
|
|
106
|
+
method: GET
|
|
107
|
+
path: /api/fn/ping
|
|
108
|
+
policies:
|
|
109
|
+
- access: public
|
|
110
|
+
- type: cron
|
|
111
|
+
schedule: "@hourly" # Log a heartbeat every hour
|
|
112
|
+
|
|
113
|
+
# Python — greets the caller by name.
|
|
114
|
+
greetPython:
|
|
115
|
+
runtime: python
|
|
116
|
+
function: hello.py
|
|
117
|
+
description: Returns a greeting from the Python runtime.
|
|
118
|
+
triggers:
|
|
119
|
+
- type: http
|
|
120
|
+
method: GET
|
|
121
|
+
path: /api/fn/greet/python
|
|
122
|
+
policies:
|
|
123
|
+
- access: public
|
|
124
|
+
|
|
125
|
+
# Go — greets the caller from the Go runtime.
|
|
126
|
+
greetGo:
|
|
127
|
+
runtime: go
|
|
128
|
+
function: greet.go
|
|
129
|
+
description: Returns a greeting from the Go runtime.
|
|
130
|
+
triggers:
|
|
131
|
+
- type: http
|
|
132
|
+
method: GET
|
|
133
|
+
path: /api/fn/greet/go
|
|
134
|
+
policies:
|
|
135
|
+
- access: public
|
|
136
|
+
|
|
137
|
+
# Ruby — greets the caller from the Ruby runtime.
|
|
138
|
+
greetRuby:
|
|
139
|
+
runtime: ruby
|
|
140
|
+
function: hello.rb
|
|
141
|
+
description: Returns a greeting from the Ruby runtime.
|
|
142
|
+
triggers:
|
|
143
|
+
- type: http
|
|
144
|
+
method: GET
|
|
145
|
+
path: /api/fn/greet/ruby
|
|
146
|
+
policies:
|
|
147
|
+
- access: public
|
|
148
|
+
|
|
149
|
+
# C++ — greets the caller from the C++ runtime.
|
|
150
|
+
greetCpp:
|
|
151
|
+
runtime: c++
|
|
152
|
+
function: hello.cpp
|
|
153
|
+
description: Returns a greeting from the C++ runtime.
|
|
154
|
+
triggers:
|
|
155
|
+
- type: http
|
|
156
|
+
method: GET
|
|
157
|
+
path: /api/fn/greet/cpp
|
|
158
|
+
policies:
|
|
159
|
+
- access: public
|
|
160
|
+
|
|
161
|
+
# Event-driven — fires when a new todo is created (via the event bus).
|
|
162
|
+
onTodoCreated:
|
|
163
|
+
runtime: js
|
|
164
|
+
function: onTodoCreated.js
|
|
165
|
+
description: Runs after a new Todo is created (event trigger demo).
|
|
166
|
+
triggers:
|
|
167
|
+
- type: event
|
|
168
|
+
name: todo.created
|
|
169
|
+
|
|
170
|
+
files:
|
|
171
|
+
uploads:
|
|
172
|
+
path: ./uploads
|
|
173
|
+
public: true
|
|
174
|
+
|
|
175
|
+
rateLimits:
|
|
176
|
+
- name: default
|
|
177
|
+
limit: 200
|
|
178
|
+
ttl: 60000
|