gravity-run 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/cli/index.js +845 -0
- package/dist/index.d.ts +398 -0
- package/dist/index.js +212 -0
- package/dist/shared/chunk-b636e30q.js +274 -0
- package/package.json +83 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
BadRequestError,
|
|
4
|
+
ForbiddenError,
|
|
5
|
+
UnauthorizedError,
|
|
6
|
+
globalLogger,
|
|
7
|
+
loadConfig
|
|
8
|
+
} from "../shared/chunk-b636e30q.js";
|
|
9
|
+
|
|
10
|
+
// src/cli/index.ts
|
|
11
|
+
import { Command as Command5 } from "commander";
|
|
12
|
+
|
|
13
|
+
// src/cli/commands/build.ts
|
|
14
|
+
import { Command } from "commander";
|
|
15
|
+
var buildCommand = new Command("build").description("Build the project for production").action(async () => {
|
|
16
|
+
console.log("Building project...");
|
|
17
|
+
const proc = Bun.spawn([
|
|
18
|
+
"bun",
|
|
19
|
+
"build",
|
|
20
|
+
"./src/index.ts",
|
|
21
|
+
"--outdir",
|
|
22
|
+
"./dist",
|
|
23
|
+
"--target",
|
|
24
|
+
"bun"
|
|
25
|
+
], {
|
|
26
|
+
stdout: "inherit",
|
|
27
|
+
stderr: "inherit"
|
|
28
|
+
});
|
|
29
|
+
await proc.exited;
|
|
30
|
+
console.log("Build complete.");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// src/cli/commands/create-action.ts
|
|
34
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { Command as Command2 } from "commander";
|
|
37
|
+
import inquirer from "inquirer";
|
|
38
|
+
var createActionCommand = new Command2("create:action").description("Create a new action").action(async () => {
|
|
39
|
+
const answers = await inquirer.prompt([
|
|
40
|
+
{
|
|
41
|
+
type: "input",
|
|
42
|
+
name: "name",
|
|
43
|
+
message: "Action name (e.g. user/create):",
|
|
44
|
+
validate: (input) => input.length > 0
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
type: "checkbox",
|
|
48
|
+
name: "triggers",
|
|
49
|
+
message: "Select triggers:",
|
|
50
|
+
choices: ["api", "cron", "event", "agent"]
|
|
51
|
+
}
|
|
52
|
+
]);
|
|
53
|
+
const name = answers.name;
|
|
54
|
+
const parts = name.split("/");
|
|
55
|
+
const fileName = parts.pop() || "index";
|
|
56
|
+
const dir = parts.join("/");
|
|
57
|
+
const baseDir = "./src/actions";
|
|
58
|
+
const targetDir = join(baseDir, dir);
|
|
59
|
+
const targetFile = join(targetDir, `${fileName}.ts`);
|
|
60
|
+
await mkdir(targetDir, { recursive: true });
|
|
61
|
+
const content = `import { action } from 'basalt'
|
|
62
|
+
import { Type } from '@sinclair/typebox'
|
|
63
|
+
|
|
64
|
+
export default action({
|
|
65
|
+
name: '${name}',
|
|
66
|
+
description: 'Auto-generated action',
|
|
67
|
+
input: Type.Object({
|
|
68
|
+
// Define input schema
|
|
69
|
+
name: Type.String(),
|
|
70
|
+
}),
|
|
71
|
+
output: Type.Object({
|
|
72
|
+
result: Type.String(),
|
|
73
|
+
}),
|
|
74
|
+
triggers: [
|
|
75
|
+
${answers.triggers.map((t) => {
|
|
76
|
+
if (t === "api")
|
|
77
|
+
return ` { kind: 'api', method: 'POST', path: '/${name}' },`;
|
|
78
|
+
if (t === "cron")
|
|
79
|
+
return ` { kind: 'cron', schedule: '0 * * * *' },`;
|
|
80
|
+
if (t === "event")
|
|
81
|
+
return ` { kind: 'event', event: '${name.replace("/", ".")}' },`;
|
|
82
|
+
if (t === "agent")
|
|
83
|
+
return ` { kind: 'agent', tool: '${name.replace("/", "_")}', description: 'Execute ${name}' },`;
|
|
84
|
+
return "";
|
|
85
|
+
}).join(`
|
|
86
|
+
`)}
|
|
87
|
+
],
|
|
88
|
+
handler: async (input, ctx) => {
|
|
89
|
+
ctx.log.info('Executing ${name}', { input })
|
|
90
|
+
return {
|
|
91
|
+
result: \`Hello \${input.name}\`
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
})
|
|
95
|
+
`;
|
|
96
|
+
await writeFile(targetFile, content);
|
|
97
|
+
console.log(`✓ Created action at ${targetFile}`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// src/cli/commands/dev.ts
|
|
101
|
+
import { Command as Command3 } from "commander";
|
|
102
|
+
|
|
103
|
+
// src/runtime/dev.ts
|
|
104
|
+
import { watch } from "node:fs";
|
|
105
|
+
|
|
106
|
+
// src/codegen/types.ts
|
|
107
|
+
import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
|
|
108
|
+
import { join as join2 } from "node:path";
|
|
109
|
+
async function generateActionTypes(actions, outputDir = ".basalt") {
|
|
110
|
+
const typeMap = {};
|
|
111
|
+
for (const action of actions) {
|
|
112
|
+
const inputType = schemaToTypeString(action.config.input);
|
|
113
|
+
typeMap[action.config.name] = inputType;
|
|
114
|
+
}
|
|
115
|
+
const dts = `
|
|
116
|
+
// Auto-generated by Basalt - DO NOT EDIT
|
|
117
|
+
// Generated at ${new Date().toISOString()}
|
|
118
|
+
|
|
119
|
+
declare module 'basalt/types' {
|
|
120
|
+
export interface ActionRegistry {
|
|
121
|
+
${Object.entries(typeMap).map(([name, type]) => ` '${name}': ${type}`).join(`
|
|
122
|
+
`)}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export {}
|
|
127
|
+
`.trim();
|
|
128
|
+
await mkdir2(outputDir, { recursive: true });
|
|
129
|
+
await writeFile2(join2(outputDir, "types.d.ts"), dts, "utf-8");
|
|
130
|
+
}
|
|
131
|
+
function schemaToTypeString(schema) {
|
|
132
|
+
if (!schema)
|
|
133
|
+
return "any";
|
|
134
|
+
const kind = schema.type;
|
|
135
|
+
if (kind === "object") {
|
|
136
|
+
const props = schema.properties || {};
|
|
137
|
+
const entries = Object.entries(props).map(([key, val]) => {
|
|
138
|
+
return `${key}: ${schemaToTypeString(val)}`;
|
|
139
|
+
});
|
|
140
|
+
return `{ ${entries.join("; ")} }`;
|
|
141
|
+
}
|
|
142
|
+
if (kind === "string")
|
|
143
|
+
return "string";
|
|
144
|
+
if (kind === "number")
|
|
145
|
+
return "number";
|
|
146
|
+
if (kind === "boolean")
|
|
147
|
+
return "boolean";
|
|
148
|
+
if (kind === "array") {
|
|
149
|
+
const items = schema.items;
|
|
150
|
+
return `Array<${schemaToTypeString(items)}>`;
|
|
151
|
+
}
|
|
152
|
+
return "any";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/runtime/loader.ts
|
|
156
|
+
import { pathToFileURL } from "node:url";
|
|
157
|
+
async function loadActions(dir) {
|
|
158
|
+
const glob = new Bun.Glob(`${dir}/**/*.ts`);
|
|
159
|
+
const files = glob.scanSync();
|
|
160
|
+
const actions = [];
|
|
161
|
+
for (const file of files) {
|
|
162
|
+
const module = await import(pathToFileURL(file).href);
|
|
163
|
+
for (const exported of Object.values(module)) {
|
|
164
|
+
if (isAction(exported)) {
|
|
165
|
+
actions.push(exported);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return actions;
|
|
170
|
+
}
|
|
171
|
+
function isAction(obj) {
|
|
172
|
+
return typeof obj === "function" && "config" in obj && "handler" in obj && typeof obj.config === "object" && "name" in obj.config;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// src/runtime/executor.ts
|
|
176
|
+
import { Value } from "typebox/value";
|
|
177
|
+
class ActionExecutor {
|
|
178
|
+
baseContext;
|
|
179
|
+
constructor(baseContext) {
|
|
180
|
+
this.baseContext = baseContext;
|
|
181
|
+
}
|
|
182
|
+
async execute(action, triggerName, sourceData) {
|
|
183
|
+
const config = action.config;
|
|
184
|
+
const trigger = config.triggers?.find((t) => t.name === triggerName);
|
|
185
|
+
if (!trigger) {
|
|
186
|
+
throw new BadRequestError(`Trigger '${triggerName}' not found for action '${config.name}'`);
|
|
187
|
+
}
|
|
188
|
+
const actor = await this.resolveActor(trigger, sourceData);
|
|
189
|
+
const ctx = {
|
|
190
|
+
requestId: this.baseContext.requestId || `req-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
191
|
+
triggerName,
|
|
192
|
+
triggerType: trigger.kind,
|
|
193
|
+
actor,
|
|
194
|
+
logger: this.baseContext.logger || globalLogger,
|
|
195
|
+
db: this.baseContext.db,
|
|
196
|
+
storage: this.baseContext.storage,
|
|
197
|
+
enqueue: this.baseContext.enqueue || this.createEnqueueFn(),
|
|
198
|
+
schedule: this.baseContext.schedule || this.createScheduleFn(),
|
|
199
|
+
org: this.baseContext.org,
|
|
200
|
+
...this.baseContext
|
|
201
|
+
};
|
|
202
|
+
const logger = ctx.logger.child({
|
|
203
|
+
action: config.name,
|
|
204
|
+
requestId: ctx.requestId,
|
|
205
|
+
trigger: triggerName
|
|
206
|
+
});
|
|
207
|
+
logger.info("Action execution started", {
|
|
208
|
+
actor: actor.type,
|
|
209
|
+
triggerType: trigger.kind
|
|
210
|
+
});
|
|
211
|
+
try {
|
|
212
|
+
let input = sourceData;
|
|
213
|
+
if (trigger.map) {
|
|
214
|
+
input = await trigger.map(sourceData);
|
|
215
|
+
}
|
|
216
|
+
if (config.input && !Value.Check(config.input, input)) {
|
|
217
|
+
const errors = [...Value.Errors(config.input, input)];
|
|
218
|
+
throw new BadRequestError(`Validation Error: ${JSON.stringify(errors)}`);
|
|
219
|
+
}
|
|
220
|
+
await this.checkRateLimit(ctx, config);
|
|
221
|
+
if (config.access) {
|
|
222
|
+
await this.checkAccess(ctx, config.access);
|
|
223
|
+
}
|
|
224
|
+
await this.checkGuard(ctx, input, config);
|
|
225
|
+
const result = await action(input, ctx);
|
|
226
|
+
logger.info("Action execution completed");
|
|
227
|
+
return result;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
logger.error("Action execution failed", {
|
|
230
|
+
error: error.message,
|
|
231
|
+
stack: error.stack
|
|
232
|
+
});
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async resolveActor(trigger, sourceData) {
|
|
237
|
+
if (typeof trigger.actor === "function") {
|
|
238
|
+
return await trigger.actor(sourceData);
|
|
239
|
+
}
|
|
240
|
+
if (trigger.actor) {
|
|
241
|
+
return trigger.actor;
|
|
242
|
+
}
|
|
243
|
+
return { type: "public", id: "guest", roles: [] };
|
|
244
|
+
}
|
|
245
|
+
async checkRateLimit(ctx, config) {
|
|
246
|
+
if (!config.rateLimit)
|
|
247
|
+
return;
|
|
248
|
+
const rules = this.resolveRateLimitRules(config.rateLimit, ctx.triggerType);
|
|
249
|
+
if (rules.length === 0)
|
|
250
|
+
return;
|
|
251
|
+
for (const rule of rules) {}
|
|
252
|
+
}
|
|
253
|
+
resolveRateLimitRules(config, triggerType) {
|
|
254
|
+
if (!config)
|
|
255
|
+
return [];
|
|
256
|
+
let rules;
|
|
257
|
+
if ("limit" in config || Array.isArray(config)) {
|
|
258
|
+
rules = config;
|
|
259
|
+
} else {
|
|
260
|
+
const structured = config;
|
|
261
|
+
const specific = structured[triggerType];
|
|
262
|
+
const common = structured.common;
|
|
263
|
+
if (specific && common) {
|
|
264
|
+
const sRules = Array.isArray(specific) ? specific : [specific];
|
|
265
|
+
const cRules = Array.isArray(common) ? common : [common];
|
|
266
|
+
return [...cRules, ...sRules];
|
|
267
|
+
}
|
|
268
|
+
rules = specific || common;
|
|
269
|
+
}
|
|
270
|
+
if (!rules)
|
|
271
|
+
return [];
|
|
272
|
+
return Array.isArray(rules) ? rules : [rules];
|
|
273
|
+
}
|
|
274
|
+
async checkAccess(ctx, access) {
|
|
275
|
+
if (ctx.actor.type === "system")
|
|
276
|
+
return;
|
|
277
|
+
let config = { ...access.common };
|
|
278
|
+
if (ctx.triggerType === "api" && access.api) {
|
|
279
|
+
config = { ...config, ...access.api };
|
|
280
|
+
} else if (ctx.triggerType === "agent" && access.agent) {
|
|
281
|
+
config = { ...config, ...access.agent };
|
|
282
|
+
}
|
|
283
|
+
if (Object.keys(config).length === 0 && (access.authenticated !== undefined || access.roles)) {
|
|
284
|
+
config = {
|
|
285
|
+
authenticated: access.authenticated,
|
|
286
|
+
roles: access.roles
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
if (config.authenticated) {
|
|
290
|
+
if (ctx.actor.type === "public") {
|
|
291
|
+
throw new UnauthorizedError("Authentication required");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (config.roles && config.roles.length > 0) {
|
|
295
|
+
if (ctx.actor.type === "public") {
|
|
296
|
+
throw new UnauthorizedError("Authentication required");
|
|
297
|
+
}
|
|
298
|
+
const userRoles = ctx.actor.roles || [];
|
|
299
|
+
const hasRole = config.roles.some((role) => userRoles.includes(role));
|
|
300
|
+
if (!hasRole) {
|
|
301
|
+
throw new ForbiddenError(`User lacks required role(s): ${config.roles.join(", ")}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
async checkGuard(ctx, input, config) {
|
|
306
|
+
if (!config.guard)
|
|
307
|
+
return;
|
|
308
|
+
const guardFn = config.guard[ctx.triggerName] || config.guard[ctx.triggerType];
|
|
309
|
+
if (guardFn) {
|
|
310
|
+
const allowed = await guardFn(input, ctx);
|
|
311
|
+
if (!allowed) {
|
|
312
|
+
throw new ForbiddenError("Guard check failed");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
createEnqueueFn() {
|
|
317
|
+
return async (eventType, data) => {
|
|
318
|
+
globalLogger.warn("enqueue called but no event bus configured", {
|
|
319
|
+
eventType,
|
|
320
|
+
data
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
createScheduleFn() {
|
|
325
|
+
return async (actionName, input, runAt) => {
|
|
326
|
+
globalLogger.warn("schedule called but no scheduler configured", {
|
|
327
|
+
actionName,
|
|
328
|
+
runAt,
|
|
329
|
+
input
|
|
330
|
+
});
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// src/runtime/event-bus.ts
|
|
336
|
+
class EventBus {
|
|
337
|
+
subscriptions = [];
|
|
338
|
+
executor;
|
|
339
|
+
constructor(baseContext) {
|
|
340
|
+
this.executor = new ActionExecutor(baseContext);
|
|
341
|
+
}
|
|
342
|
+
subscribe(pattern, action, triggerName) {
|
|
343
|
+
this.subscriptions.push({ pattern, action, triggerName });
|
|
344
|
+
}
|
|
345
|
+
async publish(event) {
|
|
346
|
+
const matching = this.subscriptions.filter((sub) => this.matchPattern(sub.pattern, event.type));
|
|
347
|
+
if (matching.length === 0) {
|
|
348
|
+
globalLogger.debug("No subscribers for event", { eventType: event.type });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const results = await Promise.allSettled(matching.map(async (sub) => {
|
|
352
|
+
try {
|
|
353
|
+
return await this.executor.execute(sub.action, sub.triggerName, event);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
globalLogger.error("Event handler failed", {
|
|
356
|
+
event: event.type,
|
|
357
|
+
action: sub.action.config.name,
|
|
358
|
+
error: error.message
|
|
359
|
+
});
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
}));
|
|
363
|
+
const failed = results.filter((r) => r.status === "rejected").length;
|
|
364
|
+
const succeeded = results.filter((r) => r.status === "fulfilled").length;
|
|
365
|
+
globalLogger.info("Event published", {
|
|
366
|
+
event: event.type,
|
|
367
|
+
handlers: matching.length,
|
|
368
|
+
succeeded,
|
|
369
|
+
failed
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
matchPattern(pattern, eventType) {
|
|
373
|
+
const regex = new RegExp(`^${pattern.replace(/\*/g, ".*").replace(/\?/g, ".")}$`);
|
|
374
|
+
return regex.test(eventType);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/runtime/http.ts
|
|
379
|
+
class HttpServer {
|
|
380
|
+
config;
|
|
381
|
+
server;
|
|
382
|
+
router;
|
|
383
|
+
constructor(config, router) {
|
|
384
|
+
this.config = config;
|
|
385
|
+
this.router = router;
|
|
386
|
+
}
|
|
387
|
+
async start() {
|
|
388
|
+
const port = this.config.server?.port || 3000;
|
|
389
|
+
const host = this.config.server?.host || "localhost";
|
|
390
|
+
const corsConfig = this.config.server?.cors;
|
|
391
|
+
this.server = Bun.serve({
|
|
392
|
+
port,
|
|
393
|
+
hostname: host,
|
|
394
|
+
fetch: async (req) => {
|
|
395
|
+
const url = new URL(req.url);
|
|
396
|
+
if (req.method === "OPTIONS") {
|
|
397
|
+
return new Response(null, {
|
|
398
|
+
status: 204,
|
|
399
|
+
headers: getCorsHeaders(corsConfig)
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
const path = url.pathname;
|
|
404
|
+
const method = req.method;
|
|
405
|
+
let body;
|
|
406
|
+
if (["POST", "PUT", "PATCH"].includes(method)) {
|
|
407
|
+
const contentType = req.headers.get("content-type") || "";
|
|
408
|
+
if (contentType.includes("application/json")) {
|
|
409
|
+
body = await req.json();
|
|
410
|
+
} else if (contentType.includes("application/x-www-form-urlencoded")) {
|
|
411
|
+
const formData = await req.formData();
|
|
412
|
+
body = Object.fromEntries(formData);
|
|
413
|
+
} else if (contentType.includes("multipart/form-data")) {
|
|
414
|
+
const formData = await req.formData();
|
|
415
|
+
body = Object.fromEntries(formData);
|
|
416
|
+
} else {
|
|
417
|
+
body = await req.text();
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const query = {};
|
|
421
|
+
url.searchParams.forEach((value, key) => {
|
|
422
|
+
query[key] = value;
|
|
423
|
+
});
|
|
424
|
+
const headers = {};
|
|
425
|
+
req.headers.forEach((value, key) => {
|
|
426
|
+
headers[key] = value;
|
|
427
|
+
});
|
|
428
|
+
const apiReq = {
|
|
429
|
+
method,
|
|
430
|
+
path,
|
|
431
|
+
params: {},
|
|
432
|
+
query,
|
|
433
|
+
body,
|
|
434
|
+
headers,
|
|
435
|
+
raw: req
|
|
436
|
+
};
|
|
437
|
+
const result = await this.router.handle(apiReq);
|
|
438
|
+
return new Response(JSON.stringify(result), {
|
|
439
|
+
status: 200,
|
|
440
|
+
headers: {
|
|
441
|
+
"Content-Type": "application/json",
|
|
442
|
+
...getCorsHeaders(corsConfig)
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
} catch (error) {
|
|
446
|
+
globalLogger.error("HTTP request failed", {
|
|
447
|
+
error: error.message,
|
|
448
|
+
stack: error.stack
|
|
449
|
+
});
|
|
450
|
+
let status = 500;
|
|
451
|
+
let message = "Internal Server Error";
|
|
452
|
+
if (error.name === "BadRequestError") {
|
|
453
|
+
status = 400;
|
|
454
|
+
message = error.message;
|
|
455
|
+
} else if (error.name === "UnauthorizedError") {
|
|
456
|
+
status = 401;
|
|
457
|
+
message = error.message;
|
|
458
|
+
} else if (error.name === "ForbiddenError") {
|
|
459
|
+
status = 403;
|
|
460
|
+
message = error.message;
|
|
461
|
+
} else if (error.message.includes("Route not found")) {
|
|
462
|
+
status = 404;
|
|
463
|
+
message = error.message;
|
|
464
|
+
}
|
|
465
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
466
|
+
status,
|
|
467
|
+
headers: {
|
|
468
|
+
"Content-Type": "application/json",
|
|
469
|
+
...getCorsHeaders(corsConfig)
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
error(error) {
|
|
475
|
+
globalLogger.error("HTTP server error", {
|
|
476
|
+
error: error.message,
|
|
477
|
+
stack: error.stack
|
|
478
|
+
});
|
|
479
|
+
return new Response(JSON.stringify({ error: "Internal Server Error" }), {
|
|
480
|
+
status: 500,
|
|
481
|
+
headers: {
|
|
482
|
+
"Content-Type": "application/json"
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
globalLogger.info("HTTP server started", {
|
|
488
|
+
url: `http://${host}:${port}`
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
async stop() {
|
|
492
|
+
if (this.server) {
|
|
493
|
+
this.server.stop();
|
|
494
|
+
globalLogger.info("HTTP server stopped");
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
getUrl() {
|
|
498
|
+
if (!this.server)
|
|
499
|
+
return null;
|
|
500
|
+
return `http://${this.server.hostname}:${this.server.port}`;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function getCorsHeaders(corsConfig) {
|
|
504
|
+
if (!corsConfig)
|
|
505
|
+
return {};
|
|
506
|
+
const headers = {};
|
|
507
|
+
if (corsConfig.origin) {
|
|
508
|
+
const origin = Array.isArray(corsConfig.origin) ? corsConfig.origin.join(", ") : corsConfig.origin;
|
|
509
|
+
headers["Access-Control-Allow-Origin"] = origin;
|
|
510
|
+
}
|
|
511
|
+
if (corsConfig.credentials) {
|
|
512
|
+
headers["Access-Control-Allow-Credentials"] = "true";
|
|
513
|
+
}
|
|
514
|
+
headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS";
|
|
515
|
+
headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization";
|
|
516
|
+
return headers;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/runtime/mcp-server.ts
|
|
520
|
+
class McpServer {
|
|
521
|
+
context;
|
|
522
|
+
constructor(context) {
|
|
523
|
+
this.context = context;
|
|
524
|
+
}
|
|
525
|
+
register(action, trigger) {
|
|
526
|
+
globalLogger.info("Registering MCP tool", {
|
|
527
|
+
tool: trigger.tool,
|
|
528
|
+
action: action.config.name,
|
|
529
|
+
context: this.context
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
async start() {
|
|
533
|
+
globalLogger.info("MCP Server started");
|
|
534
|
+
}
|
|
535
|
+
async stop() {
|
|
536
|
+
globalLogger.info("MCP Server stopped");
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// src/runtime/router.ts
|
|
541
|
+
class Router {
|
|
542
|
+
routes = [];
|
|
543
|
+
executor;
|
|
544
|
+
constructor(baseContext) {
|
|
545
|
+
this.executor = new ActionExecutor(baseContext);
|
|
546
|
+
}
|
|
547
|
+
register(action, trigger) {
|
|
548
|
+
const { pattern, paramNames } = this.pathToRegex(trigger.path);
|
|
549
|
+
this.routes.push({
|
|
550
|
+
method: trigger.method,
|
|
551
|
+
pattern,
|
|
552
|
+
paramNames,
|
|
553
|
+
action,
|
|
554
|
+
trigger
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
async handle(req) {
|
|
558
|
+
const route = this.match(req.method, req.path);
|
|
559
|
+
if (!route) {
|
|
560
|
+
throw new Error(`Route not found: ${req.method} ${req.path}`);
|
|
561
|
+
}
|
|
562
|
+
const match = req.path.match(route.pattern);
|
|
563
|
+
const params = {};
|
|
564
|
+
if (match) {
|
|
565
|
+
route.paramNames.forEach((name, i) => {
|
|
566
|
+
params[name] = match[i + 1];
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
const fullReq = {
|
|
570
|
+
...req,
|
|
571
|
+
params
|
|
572
|
+
};
|
|
573
|
+
return await this.executor.execute(route.action, route.trigger.name, fullReq);
|
|
574
|
+
}
|
|
575
|
+
match(method, path) {
|
|
576
|
+
return this.routes.find((route) => route.method === method && route.pattern.test(path));
|
|
577
|
+
}
|
|
578
|
+
pathToRegex(path) {
|
|
579
|
+
const paramNames = [];
|
|
580
|
+
const pattern = path.replace(/:([^/]+)/g, (_, paramName) => {
|
|
581
|
+
paramNames.push(paramName);
|
|
582
|
+
return "([^/]+)";
|
|
583
|
+
});
|
|
584
|
+
return {
|
|
585
|
+
pattern: new RegExp(`^${pattern}$`),
|
|
586
|
+
paramNames
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
getRoutes() {
|
|
590
|
+
return this.routes.map((r) => ({
|
|
591
|
+
method: r.method,
|
|
592
|
+
path: r.trigger.path,
|
|
593
|
+
action: r.action.config.name
|
|
594
|
+
}));
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// src/runtime/scheduler.ts
|
|
599
|
+
import Baker from "cronbake";
|
|
600
|
+
class Scheduler {
|
|
601
|
+
baker = Baker.create();
|
|
602
|
+
executor;
|
|
603
|
+
constructor(baseContext) {
|
|
604
|
+
this.executor = new ActionExecutor(baseContext);
|
|
605
|
+
}
|
|
606
|
+
schedule(action, trigger) {
|
|
607
|
+
this.baker.add({
|
|
608
|
+
name: `${action.config.name}:${trigger.name || "cron"}`,
|
|
609
|
+
cron: trigger.schedule,
|
|
610
|
+
callback: async () => {
|
|
611
|
+
try {
|
|
612
|
+
globalLogger.info("Running scheduled action", {
|
|
613
|
+
action: action.config.name,
|
|
614
|
+
schedule: trigger.schedule
|
|
615
|
+
});
|
|
616
|
+
await this.executor.execute(action, trigger.name, {});
|
|
617
|
+
} catch (error) {
|
|
618
|
+
globalLogger.error("Scheduled action failed", {
|
|
619
|
+
action: action.config.name,
|
|
620
|
+
error: error.message,
|
|
621
|
+
stack: error.stack
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
start() {
|
|
628
|
+
this.baker.bakeAll();
|
|
629
|
+
globalLogger.info("Scheduler started");
|
|
630
|
+
}
|
|
631
|
+
stop() {
|
|
632
|
+
this.baker.stopAll();
|
|
633
|
+
globalLogger.info("Stopped all cron jobs");
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// src/runtime/server.ts
|
|
638
|
+
class BasaltServer {
|
|
639
|
+
config;
|
|
640
|
+
router;
|
|
641
|
+
eventBus;
|
|
642
|
+
scheduler;
|
|
643
|
+
mcpServer;
|
|
644
|
+
httpServer;
|
|
645
|
+
actions = [];
|
|
646
|
+
constructor(config) {
|
|
647
|
+
this.config = config;
|
|
648
|
+
const baseContext = this.buildBaseContext();
|
|
649
|
+
this.router = new Router(baseContext);
|
|
650
|
+
this.eventBus = new EventBus(baseContext);
|
|
651
|
+
this.scheduler = new Scheduler(baseContext);
|
|
652
|
+
this.httpServer = new HttpServer(this.config, this.router);
|
|
653
|
+
if (this.config.mcp?.enabled) {
|
|
654
|
+
this.mcpServer = new McpServer(baseContext);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
buildBaseContext() {
|
|
658
|
+
return {
|
|
659
|
+
logger: globalLogger,
|
|
660
|
+
db: undefined,
|
|
661
|
+
storage: undefined,
|
|
662
|
+
enqueue: async (eventType, data) => {
|
|
663
|
+
const event = {
|
|
664
|
+
id: `evt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
665
|
+
type: eventType,
|
|
666
|
+
data,
|
|
667
|
+
timestamp: new Date
|
|
668
|
+
};
|
|
669
|
+
await this.eventBus.publish(event);
|
|
670
|
+
},
|
|
671
|
+
schedule: async (when, action, input) => {
|
|
672
|
+
await this.scheduleAction(when, action, input);
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
async scheduleAction(when, action, input) {
|
|
677
|
+
const actionName = action.config.name;
|
|
678
|
+
if (typeof when === "number") {
|
|
679
|
+
const runAt = new Date(Date.now() + when * 1000);
|
|
680
|
+
globalLogger.info("Scheduling action", { actionName, runAt, input });
|
|
681
|
+
} else if (when instanceof Date) {
|
|
682
|
+
globalLogger.info("Scheduling action", { actionName, runAt: when, input });
|
|
683
|
+
} else if (typeof when === "string") {
|
|
684
|
+
globalLogger.info("Scheduling recurring action", {
|
|
685
|
+
actionName,
|
|
686
|
+
cron: when,
|
|
687
|
+
input
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
async initialize() {
|
|
692
|
+
this.actions = await loadActions(this.config.actionsDir || "./src/actions");
|
|
693
|
+
for (const action of this.actions) {
|
|
694
|
+
if (!action.config.triggers)
|
|
695
|
+
continue;
|
|
696
|
+
for (const trigger of action.config.triggers) {
|
|
697
|
+
switch (trigger.kind) {
|
|
698
|
+
case "api": {
|
|
699
|
+
const apiTrigger = trigger;
|
|
700
|
+
this.router.register(action, apiTrigger);
|
|
701
|
+
globalLogger.info("Registered API route", {
|
|
702
|
+
method: apiTrigger.method,
|
|
703
|
+
path: apiTrigger.path,
|
|
704
|
+
action: action.config.name
|
|
705
|
+
});
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
case "cron": {
|
|
709
|
+
const cronTrigger = trigger;
|
|
710
|
+
this.scheduler.schedule(action, cronTrigger);
|
|
711
|
+
globalLogger.info("Registered cron job", {
|
|
712
|
+
schedule: cronTrigger.schedule,
|
|
713
|
+
action: action.config.name
|
|
714
|
+
});
|
|
715
|
+
break;
|
|
716
|
+
}
|
|
717
|
+
case "event": {
|
|
718
|
+
const eventTrigger = trigger;
|
|
719
|
+
this.eventBus.subscribe(eventTrigger.event, action, eventTrigger.name);
|
|
720
|
+
globalLogger.info("Registered event handler", {
|
|
721
|
+
event: eventTrigger.event,
|
|
722
|
+
action: action.config.name
|
|
723
|
+
});
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
case "agent": {
|
|
727
|
+
const agentTrigger = trigger;
|
|
728
|
+
this.mcpServer?.register(action, agentTrigger);
|
|
729
|
+
globalLogger.info("Registered MCP tool", {
|
|
730
|
+
tool: agentTrigger.tool,
|
|
731
|
+
action: action.config.name
|
|
732
|
+
});
|
|
733
|
+
break;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
globalLogger.info("Basalt initialized", {
|
|
739
|
+
actions: this.actions.length,
|
|
740
|
+
routes: this.router.getRoutes().length
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
async start() {
|
|
744
|
+
await this.httpServer.start();
|
|
745
|
+
this.scheduler.start();
|
|
746
|
+
if (this.mcpServer) {
|
|
747
|
+
await this.mcpServer.start();
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
getRouter() {
|
|
751
|
+
return this.router;
|
|
752
|
+
}
|
|
753
|
+
getEventBus() {
|
|
754
|
+
return this.eventBus;
|
|
755
|
+
}
|
|
756
|
+
getActions() {
|
|
757
|
+
return this.actions;
|
|
758
|
+
}
|
|
759
|
+
getHttpUrl() {
|
|
760
|
+
return this.httpServer.getUrl();
|
|
761
|
+
}
|
|
762
|
+
async stop() {
|
|
763
|
+
await this.httpServer.stop();
|
|
764
|
+
this.scheduler.stop();
|
|
765
|
+
await this.mcpServer?.stop();
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// src/runtime/dev.ts
|
|
770
|
+
async function createDevServer(config) {
|
|
771
|
+
const server = new BasaltServer(config);
|
|
772
|
+
await server.initialize();
|
|
773
|
+
if (config.dev?.watch !== false) {
|
|
774
|
+
const actionsDir = config.actionsDir || "./src/actions";
|
|
775
|
+
const watcher = watch(actionsDir, { recursive: true }, async (eventType, filename) => {
|
|
776
|
+
if (!filename)
|
|
777
|
+
return;
|
|
778
|
+
if (!/\.(ts|js)$/.test(filename))
|
|
779
|
+
return;
|
|
780
|
+
globalLogger.info("Action file changed, regenerating...", {
|
|
781
|
+
filename,
|
|
782
|
+
eventType
|
|
783
|
+
});
|
|
784
|
+
try {
|
|
785
|
+
const actions = await loadActions(actionsDir);
|
|
786
|
+
await generateActionTypes(actions);
|
|
787
|
+
globalLogger.info("Types regenerated");
|
|
788
|
+
} catch (error) {
|
|
789
|
+
globalLogger.error("Failed to regenerate types", {
|
|
790
|
+
error: error.message
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
process.on("SIGINT", () => {
|
|
795
|
+
watcher.close();
|
|
796
|
+
server.stop();
|
|
797
|
+
process.exit(0);
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
return server;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// src/cli/commands/dev.ts
|
|
804
|
+
var devCommand = new Command3("dev").description("Start the development server").option("-p, --port <number>", "Port to listen on", parseInt).action(async (options) => {
|
|
805
|
+
console.log(`\uD83C\uDF0B Starting Basalt dev server...
|
|
806
|
+
`);
|
|
807
|
+
const config = await loadConfig();
|
|
808
|
+
if (options.port) {
|
|
809
|
+
config.server = { ...config.server, port: options.port };
|
|
810
|
+
}
|
|
811
|
+
const actions = await loadActions(config.actionsDir || "./src/actions");
|
|
812
|
+
console.log("✓ Loaded actions");
|
|
813
|
+
const server = await createDevServer(config);
|
|
814
|
+
await server.start();
|
|
815
|
+
const port = config.server?.port || 3000;
|
|
816
|
+
const host = config.server?.host || "localhost";
|
|
817
|
+
console.log(`
|
|
818
|
+
✓ Server ready
|
|
819
|
+
|
|
820
|
+
Local: http://${host}:${port}
|
|
821
|
+
Actions: ${actions.length}
|
|
822
|
+
Watch: ${config.dev?.watch !== false ? "enabled" : "disabled"}
|
|
823
|
+
|
|
824
|
+
Press Ctrl+C to stop
|
|
825
|
+
`);
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
// src/cli/commands/start.ts
|
|
829
|
+
import { Command as Command4 } from "commander";
|
|
830
|
+
var startCommand = new Command4("start").description("Start the production server").action(async () => {
|
|
831
|
+
console.log("Starting production server...");
|
|
832
|
+
const config = await loadConfig();
|
|
833
|
+
const server = new BasaltServer(config);
|
|
834
|
+
await server.initialize();
|
|
835
|
+
await server.start();
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
// src/cli/index.ts
|
|
839
|
+
var program = new Command5;
|
|
840
|
+
program.name("basalt").description("Basalt CLI").version("0.1.0");
|
|
841
|
+
program.addCommand(devCommand);
|
|
842
|
+
program.addCommand(buildCommand);
|
|
843
|
+
program.addCommand(startCommand);
|
|
844
|
+
program.addCommand(createActionCommand);
|
|
845
|
+
program.parse(process.argv);
|