@tamyla/clodo-framework 4.3.4 → 4.4.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/CHANGELOG.md +15 -0
- package/README.md +34 -8
- package/dist/utilities/ai/client.js +276 -0
- package/dist/utilities/ai/index.js +6 -0
- package/dist/utilities/analytics/index.js +6 -0
- package/dist/utilities/analytics/writer.js +226 -0
- package/dist/utilities/bindings/client.js +283 -0
- package/dist/utilities/bindings/index.js +6 -0
- package/dist/utilities/cache/index.js +9 -0
- package/dist/utilities/cache/leaderboard.js +52 -0
- package/dist/utilities/cache/rate-limiter.js +57 -0
- package/dist/utilities/cache/session.js +69 -0
- package/dist/utilities/cache/upstash.js +200 -0
- package/dist/utilities/durable-objects/base.js +200 -0
- package/dist/utilities/durable-objects/counter.js +117 -0
- package/dist/utilities/durable-objects/index.js +10 -0
- package/dist/utilities/durable-objects/rate-limiter.js +80 -0
- package/dist/utilities/durable-objects/session-store.js +126 -0
- package/dist/utilities/durable-objects/websocket-room.js +223 -0
- package/dist/utilities/email/handler.js +359 -0
- package/dist/utilities/email/index.js +6 -0
- package/dist/utilities/index.js +65 -0
- package/dist/utilities/kv/index.js +6 -0
- package/dist/utilities/kv/storage.js +268 -0
- package/dist/utilities/queues/consumer.js +188 -0
- package/dist/utilities/queues/index.js +7 -0
- package/dist/utilities/queues/producer.js +74 -0
- package/dist/utilities/scheduled/handler.js +276 -0
- package/dist/utilities/scheduled/index.js +6 -0
- package/dist/utilities/storage/index.js +6 -0
- package/dist/utilities/storage/r2.js +314 -0
- package/dist/utilities/vectorize/index.js +6 -0
- package/dist/utilities/vectorize/store.js +273 -0
- package/package.json +21 -3
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled/Cron Job Utilities
|
|
3
|
+
* Helpers for Cloudflare Workers Cron Triggers
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* import { ScheduledHandler, CronJob } from '@tamyla/clodo-framework/utilities/scheduled';
|
|
7
|
+
*
|
|
8
|
+
* const handler = new ScheduledHandler()
|
|
9
|
+
* .register('0 * * * *', new CleanupJob())
|
|
10
|
+
* .register('0 0 * * *', new DailyReportJob())
|
|
11
|
+
* .register('* /5 * * * *', async (event, env) => {
|
|
12
|
+
* // Run every 5 minutes
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* export default {
|
|
16
|
+
* scheduled: (event, env, ctx) => handler.handle(event, env, ctx)
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Base class for cron jobs
|
|
22
|
+
*/
|
|
23
|
+
export class CronJob {
|
|
24
|
+
/**
|
|
25
|
+
* Execute the job
|
|
26
|
+
* @param {ScheduledEvent} event - Scheduled event
|
|
27
|
+
* @param {Object} env - Environment bindings
|
|
28
|
+
* @param {ExecutionContext} ctx - Execution context
|
|
29
|
+
*/
|
|
30
|
+
async execute(event, env, ctx) {
|
|
31
|
+
throw new Error('execute() must be implemented');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if job should run
|
|
36
|
+
* @param {ScheduledEvent} event
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
shouldRun(event) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Called before execution
|
|
45
|
+
*/
|
|
46
|
+
async beforeExecute(event, env) {}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Called after successful execution
|
|
50
|
+
*/
|
|
51
|
+
async afterExecute(event, env, result) {}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Called on error
|
|
55
|
+
*/
|
|
56
|
+
async onError(event, env, error) {
|
|
57
|
+
console.error(`Job error: ${error.message}`);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Scheduled event handler with job registration
|
|
64
|
+
*/
|
|
65
|
+
export class ScheduledHandler {
|
|
66
|
+
constructor() {
|
|
67
|
+
this.jobs = new Map();
|
|
68
|
+
this.defaultJob = null;
|
|
69
|
+
this.middleware = [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Register a job for a cron pattern
|
|
74
|
+
* @param {string} cron - Cron pattern (e.g., '0 * * * *')
|
|
75
|
+
* @param {CronJob|Function} job - Job instance or function
|
|
76
|
+
*/
|
|
77
|
+
register(cron, job) {
|
|
78
|
+
if (typeof job === 'function') {
|
|
79
|
+
// Wrap function in CronJob
|
|
80
|
+
const fn = job;
|
|
81
|
+
job = new class extends CronJob {
|
|
82
|
+
async execute(event, env, ctx) {
|
|
83
|
+
return fn(event, env, ctx);
|
|
84
|
+
}
|
|
85
|
+
}();
|
|
86
|
+
}
|
|
87
|
+
this.jobs.set(cron, job);
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Set default job for unmatched cron patterns
|
|
93
|
+
*/
|
|
94
|
+
setDefault(job) {
|
|
95
|
+
if (typeof job === 'function') {
|
|
96
|
+
const fn = job;
|
|
97
|
+
job = new class extends CronJob {
|
|
98
|
+
async execute(event, env, ctx) {
|
|
99
|
+
return fn(event, env, ctx);
|
|
100
|
+
}
|
|
101
|
+
}();
|
|
102
|
+
}
|
|
103
|
+
this.defaultJob = job;
|
|
104
|
+
return this;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Add middleware
|
|
109
|
+
* @param {Function} fn - Middleware function (event, env, next) => Promise
|
|
110
|
+
*/
|
|
111
|
+
use(fn) {
|
|
112
|
+
this.middleware.push(fn);
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Handle scheduled event
|
|
118
|
+
* @param {ScheduledEvent} event
|
|
119
|
+
* @param {Object} env
|
|
120
|
+
* @param {ExecutionContext} ctx
|
|
121
|
+
*/
|
|
122
|
+
async handle(event, env, ctx) {
|
|
123
|
+
const job = this.jobs.get(event.cron) || this.defaultJob;
|
|
124
|
+
if (!job) {
|
|
125
|
+
console.warn(`No job registered for cron: ${event.cron}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!job.shouldRun(event)) {
|
|
129
|
+
console.log(`Job skipped for cron: ${event.cron}`);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Run middleware chain
|
|
134
|
+
const runWithMiddleware = async () => {
|
|
135
|
+
let index = 0;
|
|
136
|
+
const next = async () => {
|
|
137
|
+
if (index < this.middleware.length) {
|
|
138
|
+
const middleware = this.middleware[index++];
|
|
139
|
+
return middleware(event, env, next);
|
|
140
|
+
}
|
|
141
|
+
return this._executeJob(job, event, env, ctx);
|
|
142
|
+
};
|
|
143
|
+
return next();
|
|
144
|
+
};
|
|
145
|
+
return runWithMiddleware();
|
|
146
|
+
}
|
|
147
|
+
async _executeJob(job, event, env, ctx) {
|
|
148
|
+
try {
|
|
149
|
+
await job.beforeExecute(event, env);
|
|
150
|
+
const result = await job.execute(event, env, ctx);
|
|
151
|
+
await job.afterExecute(event, env, result);
|
|
152
|
+
return result;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return job.onError(event, env, error);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Job scheduler for dynamic job management
|
|
161
|
+
*/
|
|
162
|
+
export class JobScheduler {
|
|
163
|
+
constructor(kvNamespace) {
|
|
164
|
+
this.kv = kvNamespace;
|
|
165
|
+
this.prefix = 'jobs:';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Schedule a job for future execution
|
|
170
|
+
* @param {string} jobId - Unique job ID
|
|
171
|
+
* @param {Object} jobData - Job data
|
|
172
|
+
* @param {Date|number} runAt - When to run (Date or timestamp)
|
|
173
|
+
*/
|
|
174
|
+
async schedule(jobId, jobData, runAt) {
|
|
175
|
+
const timestamp = runAt instanceof Date ? runAt.getTime() : runAt;
|
|
176
|
+
await this.kv.put(`${this.prefix}${timestamp}:${jobId}`, JSON.stringify({
|
|
177
|
+
id: jobId,
|
|
178
|
+
data: jobData,
|
|
179
|
+
scheduledAt: Date.now(),
|
|
180
|
+
runAt: timestamp
|
|
181
|
+
}), {
|
|
182
|
+
expirationTtl: Math.ceil((timestamp - Date.now()) / 1000) + 86400
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get jobs ready to run
|
|
188
|
+
* @returns {Promise<Array>}
|
|
189
|
+
*/
|
|
190
|
+
async getReadyJobs() {
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
const jobs = [];
|
|
193
|
+
const {
|
|
194
|
+
keys
|
|
195
|
+
} = await this.kv.list({
|
|
196
|
+
prefix: this.prefix
|
|
197
|
+
});
|
|
198
|
+
for (const key of keys) {
|
|
199
|
+
const [, timestampAndId] = key.name.split(this.prefix);
|
|
200
|
+
const [timestamp] = timestampAndId.split(':');
|
|
201
|
+
if (parseInt(timestamp) <= now) {
|
|
202
|
+
const jobData = await this.kv.get(key.name, {
|
|
203
|
+
type: 'json'
|
|
204
|
+
});
|
|
205
|
+
if (jobData) {
|
|
206
|
+
jobs.push(jobData);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return jobs;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Mark job as completed
|
|
215
|
+
*/
|
|
216
|
+
async complete(jobId, timestamp) {
|
|
217
|
+
await this.kv.delete(`${this.prefix}${timestamp}:${jobId}`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Reschedule a job
|
|
222
|
+
*/
|
|
223
|
+
async reschedule(jobId, currentTimestamp, newRunAt) {
|
|
224
|
+
const key = `${this.prefix}${currentTimestamp}:${jobId}`;
|
|
225
|
+
const jobData = await this.kv.get(key, {
|
|
226
|
+
type: 'json'
|
|
227
|
+
});
|
|
228
|
+
if (jobData) {
|
|
229
|
+
await this.kv.delete(key);
|
|
230
|
+
await this.schedule(jobId, jobData.data, newRunAt);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Registry for tracking scheduled jobs
|
|
237
|
+
*/
|
|
238
|
+
export class ScheduledJobRegistry {
|
|
239
|
+
constructor() {
|
|
240
|
+
this.jobs = new Map();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Define a named job
|
|
245
|
+
*/
|
|
246
|
+
define(name, JobClass) {
|
|
247
|
+
this.jobs.set(name, JobClass);
|
|
248
|
+
return this;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Get a job class by name
|
|
253
|
+
*/
|
|
254
|
+
get(name) {
|
|
255
|
+
return this.jobs.get(name);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Create a job instance
|
|
260
|
+
*/
|
|
261
|
+
create(name, ...args) {
|
|
262
|
+
const JobClass = this.get(name);
|
|
263
|
+
if (!JobClass) {
|
|
264
|
+
throw new Error(`Unknown job: ${name}`);
|
|
265
|
+
}
|
|
266
|
+
return new JobClass(...args);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* List all registered jobs
|
|
271
|
+
*/
|
|
272
|
+
list() {
|
|
273
|
+
return Array.from(this.jobs.keys());
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
export default ScheduledHandler;
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R2 Storage Utilities
|
|
3
|
+
* Provides convenient methods for working with Cloudflare R2 buckets
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* import { R2Storage } from '@tamyla/clodo-framework/utilities/storage';
|
|
7
|
+
*
|
|
8
|
+
* export default {
|
|
9
|
+
* async fetch(request, env) {
|
|
10
|
+
* const storage = new R2Storage(env.BUCKET);
|
|
11
|
+
*
|
|
12
|
+
* // Upload a file
|
|
13
|
+
* await storage.upload('images/photo.jpg', imageData, {
|
|
14
|
+
* contentType: 'image/jpeg',
|
|
15
|
+
* metadata: { uploadedBy: 'user-123' }
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* // Get a file
|
|
19
|
+
* const file = await storage.get('images/photo.jpg');
|
|
20
|
+
*
|
|
21
|
+
* // List files
|
|
22
|
+
* const files = await storage.list('images/', { limit: 100 });
|
|
23
|
+
* }
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* R2 Storage wrapper class
|
|
29
|
+
*/
|
|
30
|
+
export class R2Storage {
|
|
31
|
+
/**
|
|
32
|
+
* @param {R2Bucket} bucket - The R2 bucket binding
|
|
33
|
+
*/
|
|
34
|
+
constructor(bucket) {
|
|
35
|
+
if (!bucket) {
|
|
36
|
+
throw new Error('R2 bucket binding is required');
|
|
37
|
+
}
|
|
38
|
+
this.bucket = bucket;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Upload a file to R2
|
|
43
|
+
* @param {string} key - Object key (path)
|
|
44
|
+
* @param {ReadableStream|ArrayBuffer|string|Blob} data - File data
|
|
45
|
+
* @param {Object} options - Upload options
|
|
46
|
+
* @param {string} options.contentType - MIME type
|
|
47
|
+
* @param {Object} options.metadata - Custom metadata
|
|
48
|
+
* @param {string} options.cacheControl - Cache-Control header
|
|
49
|
+
* @returns {Promise<R2Object>}
|
|
50
|
+
*/
|
|
51
|
+
async upload(key, data, options = {}) {
|
|
52
|
+
const httpMetadata = {};
|
|
53
|
+
if (options.contentType) {
|
|
54
|
+
httpMetadata.contentType = options.contentType;
|
|
55
|
+
}
|
|
56
|
+
if (options.cacheControl) {
|
|
57
|
+
httpMetadata.cacheControl = options.cacheControl;
|
|
58
|
+
}
|
|
59
|
+
if (options.contentDisposition) {
|
|
60
|
+
httpMetadata.contentDisposition = options.contentDisposition;
|
|
61
|
+
}
|
|
62
|
+
if (options.contentEncoding) {
|
|
63
|
+
httpMetadata.contentEncoding = options.contentEncoding;
|
|
64
|
+
}
|
|
65
|
+
if (options.contentLanguage) {
|
|
66
|
+
httpMetadata.contentLanguage = options.contentLanguage;
|
|
67
|
+
}
|
|
68
|
+
return this.bucket.put(key, data, {
|
|
69
|
+
httpMetadata,
|
|
70
|
+
customMetadata: options.metadata || {}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get a file from R2
|
|
76
|
+
* @param {string} key - Object key
|
|
77
|
+
* @returns {Promise<R2ObjectBody|null>}
|
|
78
|
+
*/
|
|
79
|
+
async get(key) {
|
|
80
|
+
return this.bucket.get(key);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Get only object metadata (head)
|
|
85
|
+
* @param {string} key - Object key
|
|
86
|
+
* @returns {Promise<R2Object|null>}
|
|
87
|
+
*/
|
|
88
|
+
async head(key) {
|
|
89
|
+
return this.bucket.head(key);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Delete a file from R2
|
|
94
|
+
* @param {string} key - Object key
|
|
95
|
+
* @returns {Promise<void>}
|
|
96
|
+
*/
|
|
97
|
+
async delete(key) {
|
|
98
|
+
return this.bucket.delete(key);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Delete multiple files
|
|
103
|
+
* @param {string[]} keys - Array of object keys
|
|
104
|
+
* @returns {Promise<void>}
|
|
105
|
+
*/
|
|
106
|
+
async deleteMany(keys) {
|
|
107
|
+
return this.bucket.delete(keys);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* List objects in the bucket
|
|
112
|
+
* @param {string} prefix - Key prefix to filter by
|
|
113
|
+
* @param {Object} options - List options
|
|
114
|
+
* @param {number} options.limit - Maximum number of results
|
|
115
|
+
* @param {string} options.cursor - Pagination cursor
|
|
116
|
+
* @param {string} options.delimiter - Delimiter for hierarchy
|
|
117
|
+
* @returns {Promise<R2Objects>}
|
|
118
|
+
*/
|
|
119
|
+
async list(prefix = '', options = {}) {
|
|
120
|
+
return this.bucket.list({
|
|
121
|
+
prefix,
|
|
122
|
+
limit: options.limit || 1000,
|
|
123
|
+
cursor: options.cursor,
|
|
124
|
+
delimiter: options.delimiter,
|
|
125
|
+
include: options.include || ['httpMetadata', 'customMetadata']
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* List all objects (handles pagination automatically)
|
|
131
|
+
* @param {string} prefix - Key prefix to filter by
|
|
132
|
+
* @returns {AsyncGenerator<R2Object>}
|
|
133
|
+
*/
|
|
134
|
+
async *listAll(prefix = '') {
|
|
135
|
+
let cursor;
|
|
136
|
+
do {
|
|
137
|
+
const result = await this.list(prefix, {
|
|
138
|
+
cursor
|
|
139
|
+
});
|
|
140
|
+
for (const object of result.objects) {
|
|
141
|
+
yield object;
|
|
142
|
+
}
|
|
143
|
+
cursor = result.truncated ? result.cursor : null;
|
|
144
|
+
} while (cursor);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if an object exists
|
|
149
|
+
* @param {string} key - Object key
|
|
150
|
+
* @returns {Promise<boolean>}
|
|
151
|
+
*/
|
|
152
|
+
async exists(key) {
|
|
153
|
+
const head = await this.head(key);
|
|
154
|
+
return head !== null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Copy an object within the bucket
|
|
159
|
+
* @param {string} sourceKey - Source object key
|
|
160
|
+
* @param {string} destKey - Destination object key
|
|
161
|
+
* @returns {Promise<R2Object>}
|
|
162
|
+
*/
|
|
163
|
+
async copy(sourceKey, destKey) {
|
|
164
|
+
const source = await this.get(sourceKey);
|
|
165
|
+
if (!source) {
|
|
166
|
+
throw new Error(`Source object not found: ${sourceKey}`);
|
|
167
|
+
}
|
|
168
|
+
return this.upload(destKey, source.body, {
|
|
169
|
+
contentType: source.httpMetadata?.contentType,
|
|
170
|
+
metadata: source.customMetadata
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Move an object within the bucket
|
|
176
|
+
* @param {string} sourceKey - Source object key
|
|
177
|
+
* @param {string} destKey - Destination object key
|
|
178
|
+
* @returns {Promise<R2Object>}
|
|
179
|
+
*/
|
|
180
|
+
async move(sourceKey, destKey) {
|
|
181
|
+
const result = await this.copy(sourceKey, destKey);
|
|
182
|
+
await this.delete(sourceKey);
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Get a signed URL info for temporary access
|
|
188
|
+
* Note: R2 doesn't have built-in signed URLs, this returns info for custom implementation
|
|
189
|
+
* @param {string} key - Object key
|
|
190
|
+
* @param {Object} options - Signing options
|
|
191
|
+
* @returns {Object} URL info object
|
|
192
|
+
*/
|
|
193
|
+
createSignedUrlInfo(key, options = {}) {
|
|
194
|
+
const expiresIn = options.expiresIn || 3600;
|
|
195
|
+
const expiresAt = Date.now() + expiresIn * 1000;
|
|
196
|
+
return {
|
|
197
|
+
key,
|
|
198
|
+
bucket: this.bucket,
|
|
199
|
+
expiresAt,
|
|
200
|
+
expiresIn,
|
|
201
|
+
// Implement custom signing logic in your application
|
|
202
|
+
// This is a placeholder for the signing parameters
|
|
203
|
+
signatureParams: {
|
|
204
|
+
key,
|
|
205
|
+
expires: expiresAt,
|
|
206
|
+
...options
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Handle file upload from multipart form data
|
|
214
|
+
* @param {Request} request - Incoming request
|
|
215
|
+
* @param {R2Storage} storage - R2Storage instance
|
|
216
|
+
* @param {Object} options - Upload options
|
|
217
|
+
* @returns {Promise<Object>} Upload result
|
|
218
|
+
*/
|
|
219
|
+
export async function handleFileUpload(request, storage, options = {}) {
|
|
220
|
+
const {
|
|
221
|
+
fieldName = 'file',
|
|
222
|
+
keyPrefix = '',
|
|
223
|
+
maxSize = 10 * 1024 * 1024,
|
|
224
|
+
// 10MB default
|
|
225
|
+
allowedTypes = null,
|
|
226
|
+
generateKey = null
|
|
227
|
+
} = options;
|
|
228
|
+
const formData = await request.formData();
|
|
229
|
+
const file = formData.get(fieldName);
|
|
230
|
+
if (!file || !(file instanceof File)) {
|
|
231
|
+
throw new Error('No file provided');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Check file size
|
|
235
|
+
if (file.size > maxSize) {
|
|
236
|
+
throw new Error(`File too large. Maximum size is ${maxSize} bytes`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Check content type
|
|
240
|
+
if (allowedTypes && !allowedTypes.includes(file.type)) {
|
|
241
|
+
throw new Error(`File type not allowed: ${file.type}`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Generate key
|
|
245
|
+
const key = generateKey ? generateKey(file) : `${keyPrefix}${Date.now()}-${file.name}`;
|
|
246
|
+
|
|
247
|
+
// Upload
|
|
248
|
+
const result = await storage.upload(key, file, {
|
|
249
|
+
contentType: file.type,
|
|
250
|
+
metadata: {
|
|
251
|
+
originalName: file.name,
|
|
252
|
+
uploadedAt: new Date().toISOString()
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
return {
|
|
256
|
+
key,
|
|
257
|
+
size: file.size,
|
|
258
|
+
type: file.type,
|
|
259
|
+
name: file.name,
|
|
260
|
+
etag: result.etag
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Serve a file from R2 with proper headers
|
|
266
|
+
* @param {R2Storage} storage - R2Storage instance
|
|
267
|
+
* @param {string} key - Object key
|
|
268
|
+
* @param {Request} request - Original request (for range headers)
|
|
269
|
+
* @returns {Promise<Response>}
|
|
270
|
+
*/
|
|
271
|
+
export async function serveFile(storage, key, request) {
|
|
272
|
+
const object = await storage.get(key);
|
|
273
|
+
if (!object) {
|
|
274
|
+
return new Response('Not Found', {
|
|
275
|
+
status: 404
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const headers = new Headers();
|
|
279
|
+
|
|
280
|
+
// Set content type
|
|
281
|
+
if (object.httpMetadata?.contentType) {
|
|
282
|
+
headers.set('Content-Type', object.httpMetadata.contentType);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Set cache control
|
|
286
|
+
if (object.httpMetadata?.cacheControl) {
|
|
287
|
+
headers.set('Cache-Control', object.httpMetadata.cacheControl);
|
|
288
|
+
} else {
|
|
289
|
+
headers.set('Cache-Control', 'public, max-age=31536000');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Set ETag
|
|
293
|
+
headers.set('ETag', object.etag);
|
|
294
|
+
|
|
295
|
+
// Set content length
|
|
296
|
+
headers.set('Content-Length', object.size.toString());
|
|
297
|
+
|
|
298
|
+
// Handle range requests
|
|
299
|
+
const range = request.headers.get('Range');
|
|
300
|
+
if (range) {
|
|
301
|
+
const [start, end] = range.replace('bytes=', '').split('-').map(Number);
|
|
302
|
+
const actualEnd = end || object.size - 1;
|
|
303
|
+
headers.set('Content-Range', `bytes ${start}-${actualEnd}/${object.size}`);
|
|
304
|
+
headers.set('Content-Length', (actualEnd - start + 1).toString());
|
|
305
|
+
return new Response(object.body, {
|
|
306
|
+
status: 206,
|
|
307
|
+
headers
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
return new Response(object.body, {
|
|
311
|
+
headers
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
export default R2Storage;
|