@positronic/cloudflare 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/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@positronic/cloudflare",
3
+ "version": "0.0.2",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "description": "Cloudflare bindings for Positronic brains",
8
+ "type": "module",
9
+ "exports": {
10
+ ".": {
11
+ "node": {
12
+ "types": "./dist/types/node-index.d.ts",
13
+ "import": "./dist/src/node-index.js"
14
+ },
15
+ "default": {
16
+ "types": "./dist/types/index.d.ts",
17
+ "import": "./dist/src/index.js"
18
+ }
19
+ }
20
+ },
21
+ "main": "./dist/src/index.js",
22
+ "types": "./dist/types/index.d.ts",
23
+ "scripts": {
24
+ "tsc": "tsc --project tsconfig.json",
25
+ "swc": "swc src -d dist",
26
+ "build": "npm run tsc && npm run swc",
27
+ "clean": "rm -rf tsconfig.tsbuildinfo dist"
28
+ },
29
+ "dependencies": {
30
+ "@positronic/core": "^0.0.1",
31
+ "@positronic/spec": "^0.0.1",
32
+ "@positronic/template-new-project": "^0.0.1",
33
+ "aws4fetch": "^1.0.18",
34
+ "caz": "^2.0.0",
35
+ "cron-schedule": "^5.0.4",
36
+ "dotenv": "^16.0.3",
37
+ "hono": "^4.2.3",
38
+ "uuid": "^9.0.1"
39
+ },
40
+ "devDependencies": {
41
+ "@cloudflare/workers-types": "^4.20250415.0",
42
+ "@types/node": "^22.14.1",
43
+ "@types/uuid": "^9.0.8"
44
+ }
45
+ }
package/src/api.ts ADDED
@@ -0,0 +1,579 @@
1
+ import { Hono, type Context } from 'hono';
2
+ import { v4 as uuidv4 } from 'uuid';
3
+ import { AwsClient } from 'aws4fetch';
4
+ import { parseCronExpression } from 'cron-schedule';
5
+ import type { BrainRunnerDO } from './brain-runner-do.js';
6
+ import { getManifest } from './brain-runner-do.js';
7
+ import type { MonitorDO } from './monitor-do.js';
8
+ import type { ScheduleDO } from './schedule-do.js';
9
+ import type { R2Bucket, R2Object } from '@cloudflare/workers-types';
10
+ import { type ResourceEntry, RESOURCE_TYPES } from '@positronic/core';
11
+
12
+ type Bindings = {
13
+ BRAIN_RUNNER_DO: DurableObjectNamespace<BrainRunnerDO>;
14
+ MONITOR_DO: DurableObjectNamespace<MonitorDO>;
15
+ SCHEDULE_DO: DurableObjectNamespace<ScheduleDO>;
16
+ RESOURCES_BUCKET: R2Bucket;
17
+ NODE_ENV?: string;
18
+ R2_ACCESS_KEY_ID?: string;
19
+ R2_SECRET_ACCESS_KEY?: string;
20
+ R2_ACCOUNT_ID?: string;
21
+ R2_BUCKET_NAME?: string;
22
+ };
23
+
24
+ type CreateBrainRunRequest = {
25
+ brainName: string;
26
+ };
27
+
28
+ type CreateBrainRunResponse = {
29
+ brainRunId: string;
30
+ };
31
+
32
+ // Override ResourceEntry to make path optional for resources that aren't in version control
33
+ type R2Resource = Omit<ResourceEntry, 'path'> & {
34
+ path?: string;
35
+ size: number;
36
+ lastModified: string;
37
+ local: boolean;
38
+ };
39
+
40
+ const app = new Hono<{ Bindings: Bindings }>();
41
+
42
+ app.post('/brains/runs', async (context: Context) => {
43
+ const { brainName } = await context.req.json<CreateBrainRunRequest>();
44
+
45
+ if (!brainName) {
46
+ return context.json({ error: 'Missing brainName in request body' }, 400);
47
+ }
48
+
49
+ // Validate that the brain exists before starting it
50
+ const manifest = getManifest();
51
+ if (!manifest) {
52
+ return context.json({ error: 'Manifest not initialized' }, 500);
53
+ }
54
+
55
+ const brain = await manifest.import(brainName);
56
+ if (!brain) {
57
+ return context.json({ error: `Brain '${brainName}' not found` }, 404);
58
+ }
59
+
60
+ const brainRunId = uuidv4();
61
+ const namespace = context.env.BRAIN_RUNNER_DO;
62
+ const doId = namespace.idFromName(brainRunId);
63
+ const stub = namespace.get(doId);
64
+ await stub.start(brainName, brainRunId);
65
+ const response: CreateBrainRunResponse = {
66
+ brainRunId,
67
+ };
68
+ return context.json(response, 201);
69
+ });
70
+
71
+ app.post('/brains/runs/rerun', async (context: Context) => {
72
+ const { brainName, runId, startsAt, stopsAfter } = await context.req.json();
73
+
74
+ if (!brainName) {
75
+ return context.json({ error: 'Missing brainName in request body' }, 400);
76
+ }
77
+
78
+ // Validate that the brain exists
79
+ const manifest = getManifest();
80
+ if (!manifest) {
81
+ return context.json({ error: 'Manifest not initialized' }, 500);
82
+ }
83
+
84
+ const brain = await manifest.import(brainName);
85
+ if (!brain) {
86
+ return context.json({ error: `Brain '${brainName}' not found` }, 404);
87
+ }
88
+
89
+ // If runId is provided, validate it exists
90
+ if (runId) {
91
+ const monitorId = context.env.MONITOR_DO.idFromName('singleton');
92
+ const monitorStub = context.env.MONITOR_DO.get(monitorId);
93
+ const existingRun = await monitorStub.getLastEvent(runId);
94
+
95
+ if (!existingRun) {
96
+ return context.json({ error: `Brain run '${runId}' not found` }, 404);
97
+ }
98
+ }
99
+
100
+ // Create a new brain run with rerun parameters
101
+ const newBrainRunId = uuidv4();
102
+ const namespace = context.env.BRAIN_RUNNER_DO;
103
+ const doId = namespace.idFromName(newBrainRunId);
104
+ const stub = namespace.get(doId);
105
+
106
+ // Start the brain with rerun options
107
+ const rerunOptions = {
108
+ ...(runId && { originalRunId: runId }),
109
+ ...(startsAt !== undefined && { startsAt }),
110
+ ...(stopsAfter !== undefined && { stopsAfter }),
111
+ };
112
+
113
+ await stub.start(brainName, newBrainRunId, rerunOptions);
114
+
115
+ const response: CreateBrainRunResponse = {
116
+ brainRunId: newBrainRunId,
117
+ };
118
+ return context.json(response, 201);
119
+ });
120
+
121
+ app.get('/brains/runs/:runId/watch', async (context: Context) => {
122
+ const runId = context.req.param('runId');
123
+ const namespace = context.env.BRAIN_RUNNER_DO;
124
+ const doId = namespace.idFromName(runId);
125
+ const stub = namespace.get(doId);
126
+ const response = await stub.fetch(new Request(`http://do/watch`));
127
+ return response;
128
+ });
129
+
130
+ app.get('/brains/:brainName/history', async (context: Context) => {
131
+ const brainName = context.req.param('brainName');
132
+ const limit = Number(context.req.query('limit') || '10');
133
+
134
+ // Get the monitor singleton instance
135
+ const monitorId = context.env.MONITOR_DO.idFromName('singleton');
136
+ const monitorStub = context.env.MONITOR_DO.get(monitorId);
137
+
138
+ const runs = await monitorStub.history(brainName, limit);
139
+ return context.json({ runs });
140
+ });
141
+
142
+ app.get('/brains/:brainName/active-runs', async (context: Context) => {
143
+ const brainName = context.req.param('brainName');
144
+
145
+ // Get the monitor singleton instance
146
+ const monitorId = context.env.MONITOR_DO.idFromName('singleton');
147
+ const monitorStub = context.env.MONITOR_DO.get(monitorId);
148
+
149
+ const runs = await monitorStub.activeRuns(brainName);
150
+ return context.json({ runs });
151
+ });
152
+
153
+ app.get('/brains/watch', async (context: Context) => {
154
+ const monitorId = context.env.MONITOR_DO.idFromName('singleton');
155
+ const monitorStub = context.env.MONITOR_DO.get(monitorId);
156
+ const response = await monitorStub.fetch(new Request(`http://do/watch`));
157
+ return response;
158
+ });
159
+
160
+ app.get('/brains', async (context: Context) => {
161
+ const manifest = getManifest();
162
+
163
+ if (!manifest) {
164
+ return context.json({ error: 'Manifest not initialized' }, 500);
165
+ }
166
+
167
+ const brainNames = manifest.list();
168
+ const brains = await Promise.all(
169
+ brainNames.map(async (name) => {
170
+ const brain = await manifest.import(name);
171
+ if (!brain) {
172
+ return null;
173
+ }
174
+
175
+ return {
176
+ name,
177
+ title: brain.title,
178
+ description: `${brain.title} brain`, // Default description since property is private
179
+ };
180
+ })
181
+ );
182
+
183
+ // Filter out any null entries
184
+ const validBrains = brains.filter(brain => brain !== null);
185
+
186
+ return context.json({
187
+ brains: validBrains,
188
+ count: validBrains.length,
189
+ });
190
+ });
191
+
192
+ // Schedule endpoints
193
+
194
+ // Create a new schedule
195
+ app.post('/brains/schedules', async (context: Context) => {
196
+ try {
197
+ const body = await context.req.json();
198
+ const { brainName, cronExpression } = body;
199
+
200
+ if (!brainName) {
201
+ return context.json({ error: 'Missing required field "brainName"' }, 400);
202
+ }
203
+ if (!cronExpression) {
204
+ return context.json(
205
+ { error: 'Missing required field "cronExpression"' },
206
+ 400
207
+ );
208
+ }
209
+
210
+ // Validate cron expression before calling DO
211
+ try {
212
+ parseCronExpression(cronExpression);
213
+ } catch {
214
+ return context.json(
215
+ { error: `Invalid cron expression: ${cronExpression}` },
216
+ 400
217
+ );
218
+ }
219
+
220
+ // Get the schedule singleton instance
221
+ const scheduleId = context.env.SCHEDULE_DO.idFromName('singleton');
222
+ const scheduleStub = context.env.SCHEDULE_DO.get(scheduleId);
223
+
224
+ const schedule = await scheduleStub.createSchedule(
225
+ brainName,
226
+ cronExpression
227
+ );
228
+ return context.json(schedule, 201);
229
+ } catch (error) {
230
+ const errorMessage =
231
+ error instanceof Error ? error.message : 'Failed to create schedule';
232
+ return context.json({ error: errorMessage }, 400);
233
+ }
234
+ });
235
+
236
+ // List all schedules
237
+ app.get('/brains/schedules', async (context: Context) => {
238
+ const scheduleId = context.env.SCHEDULE_DO.idFromName('singleton');
239
+ const scheduleStub = context.env.SCHEDULE_DO.get(scheduleId);
240
+
241
+ const result = await scheduleStub.listSchedules();
242
+ return context.json(result);
243
+ });
244
+
245
+ // Get scheduled run history - MUST be before :scheduleId route
246
+ app.get('/brains/schedules/runs', async (context: Context) => {
247
+ const scheduleIdParam = context.req.query('scheduleId');
248
+ const limit = Number(context.req.query('limit') || '100');
249
+
250
+ const scheduleDoId = context.env.SCHEDULE_DO.idFromName('singleton');
251
+ const scheduleStub = context.env.SCHEDULE_DO.get(scheduleDoId);
252
+
253
+ const result = await scheduleStub.getAllRuns(scheduleIdParam, limit);
254
+ return context.json(result);
255
+ });
256
+
257
+ // Delete a schedule
258
+ app.delete('/brains/schedules/:scheduleId', async (context: Context) => {
259
+ const scheduleIdParam = context.req.param('scheduleId');
260
+
261
+ const scheduleDoId = context.env.SCHEDULE_DO.idFromName('singleton');
262
+ const scheduleStub = context.env.SCHEDULE_DO.get(scheduleDoId);
263
+
264
+ const deleted = await scheduleStub.deleteSchedule(scheduleIdParam);
265
+
266
+ if (!deleted) {
267
+ return context.json({ error: 'Schedule not found' }, 404);
268
+ }
269
+
270
+ return new Response(null, { status: 204 });
271
+ });
272
+
273
+ app.get('/brains/:brainName', async (context: Context) => {
274
+ const brainName = context.req.param('brainName');
275
+ const manifest = getManifest();
276
+
277
+ if (!manifest) {
278
+ return context.json({ error: 'Manifest not initialized' }, 500);
279
+ }
280
+
281
+ const brain = await manifest.import(brainName);
282
+ if (!brain) {
283
+ return context.json({ error: `Brain '${brainName}' not found` }, 404);
284
+ }
285
+
286
+ // Get the brain structure
287
+ const structure = brain.structure;
288
+
289
+ return context.json({
290
+ name: brainName,
291
+ title: structure.title,
292
+ description: structure.description || `${structure.title} brain`,
293
+ steps: structure.steps,
294
+ });
295
+ });
296
+
297
+ app.get('/resources', async (context: Context) => {
298
+ const bucket = context.env.RESOURCES_BUCKET;
299
+
300
+ try {
301
+ // List all objects in the bucket
302
+ // R2 returns up to 1000 objects by default
303
+ const listed = await bucket.list();
304
+
305
+ const resources = await Promise.all(
306
+ listed.objects.map(async (object: R2Object) => {
307
+ // Get the object to access its custom metadata
308
+ const r2Object = await bucket.head(object.key);
309
+
310
+ if (!r2Object) {
311
+ throw new Error(`Resource "${object.key}" not found`);
312
+ }
313
+
314
+ if (!r2Object.customMetadata?.type) {
315
+ throw new Error(
316
+ `Resource "${object.key}" is missing required metadata field "type"`
317
+ );
318
+ }
319
+
320
+ const resource: R2Resource = {
321
+ type: r2Object.customMetadata.type as (typeof RESOURCE_TYPES)[number],
322
+ ...(r2Object.customMetadata.path && {
323
+ path: r2Object.customMetadata.path,
324
+ }),
325
+ key: object.key,
326
+ size: object.size,
327
+ lastModified: object.uploaded.toISOString(),
328
+ local: r2Object.customMetadata.local === 'true', // R2 metadata is always strings
329
+ };
330
+
331
+ return resource;
332
+ })
333
+ );
334
+
335
+ return context.json({
336
+ resources,
337
+ truncated: listed.truncated,
338
+ count: resources.length,
339
+ });
340
+ } catch (error) {
341
+ const errorMessage =
342
+ error instanceof Error ? error.message : 'Unknown error occurred';
343
+ return context.json({ error: errorMessage }, 500);
344
+ }
345
+ });
346
+
347
+ app.get('/status', async (context: Context) => {
348
+ return context.json({ ready: true });
349
+ });
350
+
351
+ app.post('/resources', async (context: Context) => {
352
+ const bucket = context.env.RESOURCES_BUCKET;
353
+
354
+ const formData = await context.req.formData();
355
+
356
+ const file = formData.get('file') as File | null;
357
+ const type = formData.get('type') as string | null;
358
+ const path = formData.get('path') as string | null;
359
+ const key = formData.get('key') as string | null;
360
+ const local = formData.get('local') as string | null;
361
+
362
+ if (!file) {
363
+ return context.json({ error: 'Missing required field "file"' }, 400);
364
+ }
365
+
366
+ if (!type) {
367
+ return context.json({ error: 'Missing required field "type"' }, 400);
368
+ }
369
+
370
+ if (!RESOURCE_TYPES.includes(type as any)) {
371
+ return context.json(
372
+ { error: `Field "type" must be one of: ${RESOURCE_TYPES.join(', ')}` },
373
+ 400
374
+ );
375
+ }
376
+
377
+ // Either key or path must be provided
378
+ if (!key && !path) {
379
+ return context.json(
380
+ { error: 'Either "key" or "path" must be provided' },
381
+ 400
382
+ );
383
+ }
384
+
385
+ // Use key if provided, otherwise use path
386
+ const objectKey = key || path!;
387
+
388
+ try {
389
+ // Upload to R2 with custom metadata
390
+ const arrayBuffer = await file.arrayBuffer();
391
+ const uploadedObject = await bucket.put(objectKey, arrayBuffer, {
392
+ customMetadata: {
393
+ type,
394
+ ...(path && { path }),
395
+ local: local === 'true' ? 'true' : 'false', // R2 metadata must be strings
396
+ },
397
+ });
398
+
399
+ const resource: R2Resource = {
400
+ type: type as (typeof RESOURCE_TYPES)[number],
401
+ ...(path && { path }),
402
+ key: objectKey,
403
+ size: uploadedObject.size,
404
+ lastModified: uploadedObject.uploaded.toISOString(),
405
+ local: local === 'true',
406
+ };
407
+
408
+ return context.json(resource, 201);
409
+ } catch (error) {
410
+ const errorMessage =
411
+ error instanceof Error ? error.message : 'Failed to upload resource';
412
+ return context.json({ error: errorMessage }, 500);
413
+ }
414
+ });
415
+
416
+ // Delete a single resource by key
417
+ app.delete('/resources/:key', async (context: Context) => {
418
+ const bucket = context.env.RESOURCES_BUCKET;
419
+ const key = context.req.param('key');
420
+
421
+ // URL decode the key since it might contain slashes
422
+ const decodedKey = decodeURIComponent(key);
423
+
424
+ try {
425
+ // Delete the resource - R2 delete is idempotent, so it's safe to delete non-existent resources
426
+ await bucket.delete(decodedKey);
427
+ return new Response(null, { status: 204 });
428
+ } catch (error) {
429
+ console.error(`Failed to delete resource "${decodedKey}":`, error);
430
+ return context.json(
431
+ {
432
+ error: `Failed to delete resource: ${
433
+ error instanceof Error ? error.message : 'Unknown error'
434
+ }`,
435
+ },
436
+ 500
437
+ );
438
+ }
439
+ });
440
+
441
+ // Delete all resources (bulk delete) - only available in development mode
442
+ app.delete('/resources', async (context: Context) => {
443
+ // Check if we're in development mode
444
+ const isDevelopment = context.env.NODE_ENV === 'development';
445
+
446
+ if (!isDevelopment) {
447
+ return context.json(
448
+ { error: 'Bulk delete is only available in development mode' },
449
+ 403
450
+ );
451
+ }
452
+
453
+ const bucket = context.env.RESOURCES_BUCKET;
454
+
455
+ // List all objects
456
+ const listed = await bucket.list();
457
+ let deletedCount = 0;
458
+
459
+ // Delete each object
460
+ for (const object of listed.objects) {
461
+ await bucket.delete(object.key);
462
+ deletedCount++;
463
+ }
464
+
465
+ // Handle pagination if there are more than 1000 objects
466
+ let cursor = listed.cursor;
467
+ while (listed.truncated && cursor) {
468
+ const nextBatch = await bucket.list({ cursor });
469
+ for (const object of nextBatch.objects) {
470
+ await bucket.delete(object.key);
471
+ deletedCount++;
472
+ }
473
+ cursor = nextBatch.cursor;
474
+ }
475
+
476
+ return context.json({ deletedCount });
477
+ });
478
+
479
+ // Generate presigned URL for large file uploads
480
+ app.post('/resources/presigned-link', async (context: Context) => {
481
+ try {
482
+ const body = await context.req.json();
483
+ const { key, type, size } = body;
484
+
485
+ // Validate required fields
486
+ if (!key) {
487
+ return context.json({ error: 'Missing required field "key"' }, 400);
488
+ }
489
+ if (!type) {
490
+ return context.json({ error: 'Missing required field "type"' }, 400);
491
+ }
492
+ if (size === undefined || size === null) {
493
+ return context.json({ error: 'Missing required field "size"' }, 400);
494
+ }
495
+
496
+ // Validate type
497
+ if (type !== 'text' && type !== 'binary') {
498
+ return context.json(
499
+ { error: 'type must be either "text" or "binary"' },
500
+ 400
501
+ );
502
+ }
503
+
504
+ // Check if R2 credentials are configured
505
+ const {
506
+ R2_ACCESS_KEY_ID,
507
+ R2_SECRET_ACCESS_KEY,
508
+ R2_ACCOUNT_ID,
509
+ R2_BUCKET_NAME,
510
+ } = context.env;
511
+
512
+ if (
513
+ !R2_ACCESS_KEY_ID ||
514
+ !R2_SECRET_ACCESS_KEY ||
515
+ !R2_ACCOUNT_ID ||
516
+ !R2_BUCKET_NAME
517
+ ) {
518
+ return context.json(
519
+ {
520
+ error:
521
+ 'R2 credentials not configured. Please set R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ACCOUNT_ID, and R2_BUCKET_NAME environment variables.',
522
+ },
523
+ 400
524
+ );
525
+ }
526
+
527
+ // Create AWS client with R2 credentials
528
+ const client = new AwsClient({
529
+ accessKeyId: R2_ACCESS_KEY_ID,
530
+ secretAccessKey: R2_SECRET_ACCESS_KEY,
531
+ });
532
+
533
+ // Construct the R2 URL
534
+ const url = new URL(
535
+ `https://${R2_BUCKET_NAME}.${R2_ACCOUNT_ID}.r2.cloudflarestorage.com/${key}`
536
+ );
537
+
538
+ // Set expiration to 1 hour (3600 seconds)
539
+ const expiresIn = 3600;
540
+ url.searchParams.set('X-Amz-Expires', expiresIn.toString());
541
+
542
+ // Create a request to sign
543
+ const requestToSign = new Request(url, {
544
+ method: 'PUT',
545
+ headers: {
546
+ 'x-amz-meta-type': type,
547
+ 'x-amz-meta-local': 'false', // Manual uploads are not local
548
+ },
549
+ });
550
+
551
+ // Sign the request
552
+ const signedRequest = await client.sign(requestToSign, {
553
+ aws: {
554
+ signQuery: true,
555
+ service: 's3',
556
+ },
557
+ });
558
+
559
+ // Return the presigned URL
560
+ return context.json({
561
+ url: signedRequest.url,
562
+ method: 'PUT',
563
+ expiresIn,
564
+ });
565
+ } catch (error) {
566
+ console.error('Error generating presigned URL:', error);
567
+ return context.json(
568
+ {
569
+ error: `Failed to generate presigned URL: ${
570
+ error instanceof Error ? error.message : 'Unknown error'
571
+ }`,
572
+ },
573
+ 500
574
+ );
575
+ }
576
+ });
577
+
578
+
579
+ export default app;