@positronic/cloudflare 0.0.3 → 0.0.5
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/dist/src/api.js +1270 -0
- package/dist/src/brain-runner-do.js +654 -0
- package/dist/src/dev-server.js +1357 -0
- package/{src/index.ts → dist/src/index.js} +1 -6
- package/dist/src/manifest.js +278 -0
- package/dist/src/monitor-do.js +408 -0
- package/{src/node-index.ts → dist/src/node-index.js} +3 -7
- package/dist/src/r2-loader.js +207 -0
- package/dist/src/schedule-do.js +705 -0
- package/dist/src/sqlite-adapter.js +69 -0
- package/dist/types/api.d.ts +21 -0
- package/dist/types/api.d.ts.map +1 -0
- package/dist/types/brain-runner-do.d.ts +25 -0
- package/dist/types/brain-runner-do.d.ts.map +1 -0
- package/dist/types/dev-server.d.ts +45 -0
- package/dist/types/dev-server.d.ts.map +1 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/manifest.d.ts +11 -0
- package/dist/types/manifest.d.ts.map +1 -0
- package/dist/types/monitor-do.d.ts +16 -0
- package/dist/types/monitor-do.d.ts.map +1 -0
- package/dist/types/node-index.d.ts +10 -0
- package/dist/types/node-index.d.ts.map +1 -0
- package/dist/types/r2-loader.d.ts +10 -0
- package/dist/types/r2-loader.d.ts.map +1 -0
- package/dist/types/schedule-do.d.ts +47 -0
- package/dist/types/schedule-do.d.ts.map +1 -0
- package/dist/types/sqlite-adapter.d.ts +10 -0
- package/dist/types/sqlite-adapter.d.ts.map +1 -0
- package/package.json +5 -1
- package/src/api.ts +0 -579
- package/src/brain-runner-do.ts +0 -309
- package/src/dev-server.ts +0 -776
- package/src/manifest.ts +0 -69
- package/src/monitor-do.ts +0 -268
- package/src/r2-loader.ts +0 -27
- package/src/schedule-do.ts +0 -377
- package/src/sqlite-adapter.ts +0 -50
- package/test-project/package-lock.json +0 -3010
- package/test-project/package.json +0 -21
- package/test-project/src/index.ts +0 -70
- package/test-project/src/runner.ts +0 -24
- package/test-project/tests/api.test.ts +0 -1005
- package/test-project/tests/r2loader.test.ts +0 -73
- package/test-project/tests/resources-api.test.ts +0 -671
- package/test-project/tests/spec.test.ts +0 -135
- package/test-project/tests/tsconfig.json +0 -7
- package/test-project/tsconfig.json +0 -20
- package/test-project/vitest.config.ts +0 -12
- package/test-project/wrangler.jsonc +0 -53
- package/tsconfig.json +0 -11
package/src/api.ts
DELETED
|
@@ -1,579 +0,0 @@
|
|
|
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;
|