@ontrails/cloudflare 0.2.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.
@@ -0,0 +1,736 @@
1
+ /**
2
+ * Cloudflare R2 blob/object resource for Trails.
3
+ *
4
+ * `cloudflareR2` authors an ordinary resource definition for an R2 bucket
5
+ * binding. Trails can store and fetch object bytes through the standard
6
+ * resource accessor, then return `BlobRef` values when HTTP/MCP surfaces should
7
+ * render the fetched object as binary output.
8
+ */
9
+
10
+ import {
11
+ InternalError,
12
+ Result,
13
+ ValidationError,
14
+ createBlobRef,
15
+ resource,
16
+ } from '@ontrails/core';
17
+ import type { BlobRef, Resource } from '@ontrails/core';
18
+
19
+ import { registerEnvBinding } from '../env.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Binding shape
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export type CloudflareR2StorageClass = 'Standard' | 'InfrequentAccess';
26
+
27
+ export type CloudflareR2PutBody =
28
+ | ArrayBuffer
29
+ | ArrayBufferView
30
+ | Blob
31
+ | ReadableStream<Uint8Array>
32
+ | string
33
+ | null;
34
+
35
+ export interface CloudflareR2HttpMetadata {
36
+ readonly cacheControl?: string | undefined;
37
+ readonly cacheExpiry?: Date | undefined;
38
+ readonly contentDisposition?: string | undefined;
39
+ readonly contentEncoding?: string | undefined;
40
+ readonly contentLanguage?: string | undefined;
41
+ readonly contentType?: string | undefined;
42
+ }
43
+
44
+ export interface CloudflareR2Conditional {
45
+ readonly etagDoesNotMatch?: string | undefined;
46
+ readonly etagMatches?: string | undefined;
47
+ readonly uploadedAfter?: Date | undefined;
48
+ readonly uploadedBefore?: Date | undefined;
49
+ }
50
+
51
+ export type CloudflareR2Range =
52
+ | {
53
+ readonly length?: number | undefined;
54
+ readonly offset: number;
55
+ readonly suffix?: never;
56
+ }
57
+ | {
58
+ readonly length: number;
59
+ readonly offset?: number | undefined;
60
+ readonly suffix?: never;
61
+ }
62
+ | {
63
+ readonly length?: never;
64
+ readonly offset?: never;
65
+ readonly suffix: number;
66
+ };
67
+
68
+ export interface CloudflareR2GetOptions {
69
+ readonly onlyIf?: CloudflareR2Conditional | Headers | undefined;
70
+ readonly range?: CloudflareR2Range | undefined;
71
+ /** Accepted by the memory mock but enforced only by real R2 bindings. */
72
+ readonly ssecKey?: ArrayBuffer | string | undefined;
73
+ }
74
+
75
+ export interface CloudflareR2PutOptions {
76
+ readonly customMetadata?: Readonly<Record<string, string>> | undefined;
77
+ readonly httpMetadata?: CloudflareR2HttpMetadata | Headers | undefined;
78
+ readonly md5?: ArrayBuffer | string | undefined;
79
+ readonly onlyIf?: CloudflareR2Conditional | Headers | undefined;
80
+ readonly sha1?: ArrayBuffer | string | undefined;
81
+ readonly sha256?: ArrayBuffer | string | undefined;
82
+ readonly sha384?: ArrayBuffer | string | undefined;
83
+ readonly sha512?: ArrayBuffer | string | undefined;
84
+ /** Accepted by the memory mock but enforced only by real R2 bindings. */
85
+ readonly ssecKey?: ArrayBuffer | string | undefined;
86
+ readonly storageClass?: CloudflareR2StorageClass | undefined;
87
+ }
88
+
89
+ export interface CloudflareR2ListOptions {
90
+ readonly cursor?: string | undefined;
91
+ readonly delimiter?: string | undefined;
92
+ readonly include?: readonly ('customMetadata' | 'httpMetadata')[] | undefined;
93
+ readonly limit?: number | undefined;
94
+ readonly prefix?: string | undefined;
95
+ readonly startAfter?: string | undefined;
96
+ }
97
+
98
+ export interface CloudflareR2Object {
99
+ readonly customMetadata: Readonly<Record<string, string>>;
100
+ readonly etag: string;
101
+ readonly httpEtag: string;
102
+ readonly httpMetadata: CloudflareR2HttpMetadata;
103
+ readonly key: string;
104
+ readonly range?: CloudflareR2Range | undefined;
105
+ readonly ssecKeyMd5?: string | undefined;
106
+ readonly size: number;
107
+ readonly storageClass?: CloudflareR2StorageClass | undefined;
108
+ readonly uploaded: Date;
109
+ readonly version: string;
110
+ writeHttpMetadata(headers: Headers): void;
111
+ }
112
+
113
+ export interface CloudflareR2ListedObject extends Omit<
114
+ CloudflareR2Object,
115
+ 'customMetadata' | 'httpMetadata'
116
+ > {
117
+ readonly customMetadata?: Readonly<Record<string, string>> | undefined;
118
+ readonly httpMetadata?: CloudflareR2HttpMetadata | undefined;
119
+ }
120
+
121
+ export interface CloudflareR2ObjectBody extends CloudflareR2Object {
122
+ readonly body: ReadableStream<Uint8Array>;
123
+ readonly bodyUsed: boolean;
124
+ arrayBuffer(): Promise<ArrayBuffer>;
125
+ blob(): Promise<Blob>;
126
+ json<T = unknown>(): Promise<T>;
127
+ text(): Promise<string>;
128
+ }
129
+
130
+ export interface CloudflareR2Objects {
131
+ readonly cursor?: string | undefined;
132
+ readonly delimitedPrefixes?: readonly string[] | undefined;
133
+ readonly objects: readonly CloudflareR2ListedObject[];
134
+ readonly truncated: boolean;
135
+ }
136
+
137
+ /**
138
+ * Structural subset of a Cloudflare R2 bucket binding.
139
+ */
140
+ export interface CloudflareR2Bucket {
141
+ delete(key: string | readonly string[]): Promise<void>;
142
+ get(
143
+ key: string,
144
+ options?: CloudflareR2GetOptions
145
+ ): Promise<CloudflareR2ObjectBody | CloudflareR2Object | null>;
146
+ head(key: string): Promise<CloudflareR2Object | null>;
147
+ list(options?: CloudflareR2ListOptions): Promise<CloudflareR2Objects>;
148
+ put(
149
+ key: string,
150
+ value: CloudflareR2PutBody,
151
+ options?: CloudflareR2PutOptions
152
+ ): Promise<CloudflareR2Object | null>;
153
+ }
154
+
155
+ export interface MemoryCloudflareR2Bucket extends CloudflareR2Bucket {
156
+ clear(): void;
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // In-memory mock
161
+ // ---------------------------------------------------------------------------
162
+
163
+ interface MemoryR2Entry {
164
+ readonly bytes: Uint8Array;
165
+ readonly customMetadata: Readonly<Record<string, string>>;
166
+ readonly etag: string;
167
+ readonly httpMetadata: CloudflareR2HttpMetadata;
168
+ readonly key: string;
169
+ readonly storageClass?: CloudflareR2StorageClass | undefined;
170
+ readonly uploaded: Date;
171
+ readonly version: string;
172
+ }
173
+
174
+ const DEFAULT_LIST_LIMIT = 1000;
175
+ const MAX_LIST_LIMIT = 1000;
176
+ const MAX_MULTI_DELETE_KEYS = 1000;
177
+ const DEFAULT_BLOB_MIME_TYPE = 'application/octet-stream';
178
+
179
+ const encoder = new TextEncoder();
180
+ const decoder = new TextDecoder();
181
+
182
+ const isBlobValue = (value: unknown): value is Blob =>
183
+ typeof Blob !== 'undefined' && value instanceof Blob;
184
+
185
+ const isHeadersValue = (value: unknown): value is Headers =>
186
+ typeof Headers !== 'undefined' && value instanceof Headers;
187
+
188
+ const copyBytes = (bytes: Uint8Array): Uint8Array => {
189
+ const copy = new Uint8Array(bytes.byteLength);
190
+ copy.set(bytes);
191
+ return copy;
192
+ };
193
+
194
+ const readStream = async (
195
+ stream: ReadableStream<Uint8Array>
196
+ ): Promise<Uint8Array> => {
197
+ const reader = stream.getReader();
198
+ const chunks: Uint8Array[] = [];
199
+ let size = 0;
200
+ while (true) {
201
+ const read = await reader.read();
202
+ if (read.done) {
203
+ break;
204
+ }
205
+ chunks.push(read.value);
206
+ size += read.value.byteLength;
207
+ }
208
+ const bytes = new Uint8Array(size);
209
+ let offset = 0;
210
+ for (const chunk of chunks) {
211
+ bytes.set(chunk, offset);
212
+ offset += chunk.byteLength;
213
+ }
214
+ return bytes;
215
+ };
216
+
217
+ const normalizePutBody = async (
218
+ value: CloudflareR2PutBody
219
+ ): Promise<Uint8Array> => {
220
+ if (value === null) {
221
+ return new Uint8Array();
222
+ }
223
+ if (typeof value === 'string') {
224
+ return encoder.encode(value);
225
+ }
226
+ if (isBlobValue(value)) {
227
+ return new Uint8Array(await value.arrayBuffer());
228
+ }
229
+ if (value instanceof ArrayBuffer) {
230
+ return copyBytes(new Uint8Array(value));
231
+ }
232
+ if (ArrayBuffer.isView(value)) {
233
+ return copyBytes(
234
+ new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
235
+ );
236
+ }
237
+ return readStream(value);
238
+ };
239
+
240
+ const normalizeHttpMetadata = (
241
+ metadata: CloudflareR2PutOptions['httpMetadata']
242
+ ): CloudflareR2HttpMetadata => {
243
+ if (metadata === undefined) {
244
+ return {};
245
+ }
246
+ if (isHeadersValue(metadata)) {
247
+ const normalized: {
248
+ cacheControl?: string;
249
+ cacheExpiry?: Date;
250
+ contentDisposition?: string;
251
+ contentEncoding?: string;
252
+ contentLanguage?: string;
253
+ contentType?: string;
254
+ } = {};
255
+ const cacheControl = metadata.get('cache-control');
256
+ const contentDisposition = metadata.get('content-disposition');
257
+ const contentEncoding = metadata.get('content-encoding');
258
+ const contentLanguage = metadata.get('content-language');
259
+ const contentType = metadata.get('content-type');
260
+ const expires = metadata.get('expires');
261
+ if (cacheControl !== null) {
262
+ normalized.cacheControl = cacheControl;
263
+ }
264
+ if (contentDisposition !== null) {
265
+ normalized.contentDisposition = contentDisposition;
266
+ }
267
+ if (contentEncoding !== null) {
268
+ normalized.contentEncoding = contentEncoding;
269
+ }
270
+ if (contentLanguage !== null) {
271
+ normalized.contentLanguage = contentLanguage;
272
+ }
273
+ if (contentType !== null) {
274
+ normalized.contentType = contentType;
275
+ }
276
+ if (expires !== null) {
277
+ normalized.cacheExpiry = new Date(expires);
278
+ }
279
+ return normalized;
280
+ }
281
+ return { ...metadata };
282
+ };
283
+
284
+ const writeHttpMetadata = (
285
+ headers: Headers,
286
+ metadata: CloudflareR2HttpMetadata
287
+ ): void => {
288
+ if (metadata.cacheControl !== undefined) {
289
+ headers.set('Cache-Control', metadata.cacheControl);
290
+ }
291
+ if (metadata.cacheExpiry !== undefined) {
292
+ headers.set('Expires', metadata.cacheExpiry.toUTCString());
293
+ }
294
+ if (metadata.contentDisposition !== undefined) {
295
+ headers.set('Content-Disposition', metadata.contentDisposition);
296
+ }
297
+ if (metadata.contentEncoding !== undefined) {
298
+ headers.set('Content-Encoding', metadata.contentEncoding);
299
+ }
300
+ if (metadata.contentLanguage !== undefined) {
301
+ headers.set('Content-Language', metadata.contentLanguage);
302
+ }
303
+ if (metadata.contentType !== undefined) {
304
+ headers.set('Content-Type', metadata.contentType);
305
+ }
306
+ };
307
+
308
+ const createEtag = (bytes: Uint8Array, version: number): string => {
309
+ let checksum = 0;
310
+ for (const [index, byte] of bytes.entries()) {
311
+ checksum = (checksum + byte * (index + 1)) % Number.MAX_SAFE_INTEGER;
312
+ }
313
+ return `${version.toString(16)}-${bytes.byteLength.toString(16)}-${checksum.toString(16)}`;
314
+ };
315
+
316
+ const objectMetadata = (entry: MemoryR2Entry): CloudflareR2Object => ({
317
+ customMetadata: Object.freeze({ ...entry.customMetadata }),
318
+ etag: entry.etag,
319
+ httpEtag: `"${entry.etag}"`,
320
+ httpMetadata: { ...entry.httpMetadata },
321
+ key: entry.key,
322
+ size: entry.bytes.byteLength,
323
+ ...(entry.storageClass === undefined
324
+ ? {}
325
+ : { storageClass: entry.storageClass }),
326
+ uploaded: new Date(entry.uploaded),
327
+ version: entry.version,
328
+ writeHttpMetadata(headers) {
329
+ writeHttpMetadata(headers, entry.httpMetadata);
330
+ },
331
+ });
332
+
333
+ const listedObjectMetadata = (
334
+ entry: MemoryR2Entry,
335
+ include: CloudflareR2ListOptions['include']
336
+ ): CloudflareR2ListedObject => {
337
+ const includeCustomMetadata = include?.includes('customMetadata') ?? false;
338
+ const includeHttpMetadata = include?.includes('httpMetadata') ?? false;
339
+ return {
340
+ etag: entry.etag,
341
+ httpEtag: `"${entry.etag}"`,
342
+ key: entry.key,
343
+ size: entry.bytes.byteLength,
344
+ ...(entry.storageClass === undefined
345
+ ? {}
346
+ : { storageClass: entry.storageClass }),
347
+ uploaded: new Date(entry.uploaded),
348
+ version: entry.version,
349
+ writeHttpMetadata(headers) {
350
+ if (includeHttpMetadata) {
351
+ writeHttpMetadata(headers, entry.httpMetadata);
352
+ }
353
+ },
354
+ ...(includeCustomMetadata
355
+ ? { customMetadata: Object.freeze({ ...entry.customMetadata }) }
356
+ : {}),
357
+ ...(includeHttpMetadata ? { httpMetadata: { ...entry.httpMetadata } } : {}),
358
+ };
359
+ };
360
+
361
+ const bodyConsumedError = (): TypeError =>
362
+ new TypeError('R2 object body has already been consumed');
363
+
364
+ const objectBody = (entry: MemoryR2Entry): CloudflareR2ObjectBody => {
365
+ let bodyUsed = false;
366
+ const consume = (): Uint8Array => {
367
+ if (bodyUsed) {
368
+ throw bodyConsumedError();
369
+ }
370
+ bodyUsed = true;
371
+ return copyBytes(entry.bytes);
372
+ };
373
+ const body = new ReadableStream<Uint8Array>(
374
+ {
375
+ pull(controller) {
376
+ try {
377
+ controller.enqueue(consume());
378
+ controller.close();
379
+ } catch (error) {
380
+ controller.error(error);
381
+ }
382
+ },
383
+ },
384
+ { highWaterMark: 0 }
385
+ );
386
+ const arrayBuffer = async (): Promise<ArrayBuffer> => {
387
+ const bytes = consume();
388
+ return bytes.buffer as ArrayBuffer;
389
+ };
390
+ const text = async (): Promise<string> =>
391
+ decoder.decode(new Uint8Array(await arrayBuffer()));
392
+ return {
393
+ ...objectMetadata(entry),
394
+ arrayBuffer,
395
+ blob: async () => {
396
+ const bytes = consume();
397
+ return new Blob([bytes.buffer as ArrayBuffer], {
398
+ type: entry.httpMetadata.contentType ?? DEFAULT_BLOB_MIME_TYPE,
399
+ });
400
+ },
401
+ body,
402
+ get bodyUsed() {
403
+ return bodyUsed;
404
+ },
405
+ json: async <T = unknown>() => JSON.parse(await text()) as T,
406
+ text,
407
+ };
408
+ };
409
+
410
+ type MemoryR2ListItem =
411
+ | { readonly kind: 'object'; readonly key: string }
412
+ | { readonly kind: 'prefix'; readonly key: string };
413
+
414
+ const compareR2Keys = (left: string, right: string): number => {
415
+ const leftBytes = encoder.encode(left);
416
+ const rightBytes = encoder.encode(right);
417
+ const length = Math.min(leftBytes.length, rightBytes.length);
418
+ for (let index = 0; index < length; index += 1) {
419
+ const difference = (leftBytes[index] ?? 0) - (rightBytes[index] ?? 0);
420
+ if (difference !== 0) {
421
+ return difference;
422
+ }
423
+ }
424
+ return leftBytes.length - rightBytes.length;
425
+ };
426
+
427
+ const compareListItemKinds = (
428
+ left: MemoryR2ListItem['kind'],
429
+ right: MemoryR2ListItem['kind']
430
+ ): number => {
431
+ if (left === right) {
432
+ return 0;
433
+ }
434
+ return left === 'object' ? -1 : 1;
435
+ };
436
+
437
+ const compareListItems = (
438
+ left: MemoryR2ListItem,
439
+ right: MemoryR2ListItem
440
+ ): number =>
441
+ compareR2Keys(left.key, right.key) ||
442
+ compareListItemKinds(left.kind, right.kind);
443
+
444
+ const listItems = (
445
+ entries: ReadonlyMap<string, MemoryR2Entry>,
446
+ options: CloudflareR2ListOptions | undefined
447
+ ): readonly MemoryR2ListItem[] => {
448
+ const prefix = options?.prefix ?? '';
449
+ const delimiter = options?.delimiter;
450
+ const prefixes = new Set<string>();
451
+ const items: MemoryR2ListItem[] = [];
452
+ for (const name of entries.keys()) {
453
+ if (!name.startsWith(prefix)) {
454
+ continue;
455
+ }
456
+ if (delimiter !== undefined && delimiter.length > 0) {
457
+ const remainder = name.slice(prefix.length);
458
+ const delimiterIndex = remainder.indexOf(delimiter);
459
+ if (delimiterIndex !== -1) {
460
+ const delimitedPrefix = name.slice(0, prefix.length + delimiterIndex);
461
+ if (!prefixes.has(delimitedPrefix)) {
462
+ prefixes.add(delimitedPrefix);
463
+ items.push({ key: delimitedPrefix, kind: 'prefix' });
464
+ }
465
+ continue;
466
+ }
467
+ }
468
+ items.push({ key: name, kind: 'object' });
469
+ }
470
+ return items.toSorted(compareListItems);
471
+ };
472
+
473
+ /**
474
+ * Create an in-memory R2 bucket binding.
475
+ *
476
+ * This is the mock behind `cloudflareR2`, exported for tests that want a
477
+ * bucket without a Workers runtime.
478
+ *
479
+ * @example
480
+ * ```ts
481
+ * import { createMemoryR2 } from '@ontrails/cloudflare/r2';
482
+ *
483
+ * const bucket = createMemoryR2();
484
+ * await bucket.put('notes.txt', 'hello', {
485
+ * httpMetadata: { contentType: 'text/plain' },
486
+ * });
487
+ * await (await bucket.get('notes.txt'))?.text(); // 'hello'
488
+ * ```
489
+ */
490
+ export const createMemoryR2 = (): MemoryCloudflareR2Bucket => {
491
+ const entries = new Map<string, MemoryR2Entry>();
492
+ const cursorPositions = new Map<string, MemoryR2ListItem>();
493
+ let version = 0;
494
+
495
+ return {
496
+ clear() {
497
+ entries.clear();
498
+ cursorPositions.clear();
499
+ },
500
+ delete: (key) => {
501
+ const keys = Array.isArray(key) ? key : [key];
502
+ if (keys.length > MAX_MULTI_DELETE_KEYS) {
503
+ return Promise.reject(
504
+ new ValidationError(
505
+ `Cloudflare R2 deletes accept at most ${String(MAX_MULTI_DELETE_KEYS)} keys per call; received ${String(keys.length)}. Chunk larger deletes before invoking the bucket.`
506
+ )
507
+ );
508
+ }
509
+ for (const name of keys) {
510
+ entries.delete(name);
511
+ }
512
+ return Promise.resolve();
513
+ },
514
+ get: (key) => {
515
+ const entry = entries.get(key);
516
+ return Promise.resolve(entry === undefined ? null : objectBody(entry));
517
+ },
518
+ head: (key) => {
519
+ const entry = entries.get(key);
520
+ return Promise.resolve(
521
+ entry === undefined ? null : objectMetadata(entry)
522
+ );
523
+ },
524
+ list: (options) => {
525
+ const limit = Math.min(
526
+ MAX_LIST_LIMIT,
527
+ Math.max(1, options?.limit ?? DEFAULT_LIST_LIMIT)
528
+ );
529
+ const items = listItems(entries, options);
530
+ const decodedCursor =
531
+ options?.cursor === undefined
532
+ ? undefined
533
+ : cursorPositions.get(options.cursor);
534
+ const startIndex = items.findIndex((item) => {
535
+ if (decodedCursor !== undefined) {
536
+ return compareListItems(item, decodedCursor) > 0;
537
+ }
538
+ const keyLowerBound = options?.cursor ?? options?.startAfter;
539
+ return (
540
+ keyLowerBound === undefined ||
541
+ compareR2Keys(item.key, keyLowerBound) > 0
542
+ );
543
+ });
544
+ const pageStart = startIndex === -1 ? items.length : startIndex;
545
+ const page = items.slice(pageStart, pageStart + limit);
546
+ const truncated = pageStart + page.length < items.length;
547
+ const lastItem = page.at(-1);
548
+ const cursor =
549
+ truncated && lastItem !== undefined ? crypto.randomUUID() : undefined;
550
+ if (cursor !== undefined && lastItem !== undefined) {
551
+ cursorPositions.set(cursor, lastItem);
552
+ }
553
+ return Promise.resolve({
554
+ ...(cursor === undefined ? {} : { cursor }),
555
+ delimitedPrefixes: page.flatMap((item) =>
556
+ item.kind === 'prefix' ? [item.key] : []
557
+ ),
558
+ objects: page.flatMap((item) => {
559
+ if (item.kind === 'prefix') {
560
+ return [];
561
+ }
562
+ const entry = entries.get(item.key);
563
+ return entry === undefined
564
+ ? []
565
+ : [listedObjectMetadata(entry, options?.include)];
566
+ }),
567
+ truncated,
568
+ });
569
+ },
570
+ put: async (key, value, options) => {
571
+ version += 1;
572
+ const bytes = await normalizePutBody(value);
573
+ const etag = createEtag(bytes, version);
574
+ const entry: MemoryR2Entry = {
575
+ bytes,
576
+ customMetadata: Object.freeze({ ...options?.customMetadata }),
577
+ etag,
578
+ httpMetadata: normalizeHttpMetadata(options?.httpMetadata),
579
+ key,
580
+ ...(options?.storageClass === undefined
581
+ ? {}
582
+ : { storageClass: options.storageClass }),
583
+ uploaded: new Date(),
584
+ version: String(version),
585
+ };
586
+ entries.set(key, entry);
587
+ return objectMetadata(entry);
588
+ },
589
+ };
590
+ };
591
+
592
+ // ---------------------------------------------------------------------------
593
+ // BlobRef bridge
594
+ // ---------------------------------------------------------------------------
595
+
596
+ /** Options for {@link r2ObjectToBlobRef}. */
597
+ export interface R2ObjectToBlobRefOptions {
598
+ readonly mimeType?: string | undefined;
599
+ readonly name?: string | undefined;
600
+ }
601
+
602
+ const returnedObjectSize = (object: CloudflareR2ObjectBody): number => {
603
+ const { range } = object;
604
+ if (range === undefined) {
605
+ return object.size;
606
+ }
607
+ if (range.length !== undefined) {
608
+ const offset = range.offset ?? 0;
609
+ return Math.min(
610
+ Math.max(0, range.length),
611
+ Math.max(0, object.size - offset)
612
+ );
613
+ }
614
+ if (range.suffix !== undefined) {
615
+ return Math.min(Math.max(0, range.suffix), object.size);
616
+ }
617
+ return Math.max(0, object.size - (range.offset ?? 0));
618
+ };
619
+
620
+ /**
621
+ * Convert a fetched R2 object body into a core BlobRef.
622
+ *
623
+ * @example
624
+ * ```ts
625
+ * import { r2ObjectToBlobRef } from '@ontrails/cloudflare/r2';
626
+ *
627
+ * const object = await bucket.get('report.pdf');
628
+ * if (object !== null && 'body' in object) {
629
+ * return Result.ok(r2ObjectToBlobRef(object));
630
+ * }
631
+ * ```
632
+ */
633
+ export const r2ObjectToBlobRef = (
634
+ object: CloudflareR2ObjectBody,
635
+ options: R2ObjectToBlobRefOptions = {}
636
+ ): BlobRef =>
637
+ createBlobRef({
638
+ data: object.body,
639
+ mimeType:
640
+ options.mimeType ??
641
+ object.httpMetadata.contentType ??
642
+ DEFAULT_BLOB_MIME_TYPE,
643
+ name: options.name ?? object.key,
644
+ size: returnedObjectSize(object),
645
+ });
646
+
647
+ // ---------------------------------------------------------------------------
648
+ // Resource factory
649
+ // ---------------------------------------------------------------------------
650
+
651
+ /** Options for {@link cloudflareR2}. */
652
+ export interface CloudflareR2Options {
653
+ /** The wrangler binding name (an `r2_buckets` entry's `binding`). */
654
+ readonly binding: string;
655
+ readonly description?: string | undefined;
656
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
657
+ }
658
+
659
+ const isR2BucketBinding = (value: unknown): value is CloudflareR2Bucket => {
660
+ if (typeof value !== 'object' || value === null) {
661
+ return false;
662
+ }
663
+ const candidate = value as Partial<Record<keyof CloudflareR2Bucket, unknown>>;
664
+ return (
665
+ typeof candidate.get === 'function' &&
666
+ typeof candidate.put === 'function' &&
667
+ typeof candidate.delete === 'function' &&
668
+ typeof candidate.head === 'function' &&
669
+ typeof candidate.list === 'function'
670
+ );
671
+ };
672
+
673
+ /**
674
+ * Author a Trails resource wrapping a Cloudflare R2 bucket binding.
675
+ *
676
+ * The real R2 binding arrives through the Workers env bridge. The resource
677
+ * mock stores object bytes in memory so trails work in `testAll`.
678
+ *
679
+ * @example
680
+ * ```ts
681
+ * import { NotFoundError, Result, blobRefSchema, trail } from '@ontrails/core';
682
+ * import { cloudflareR2, r2ObjectToBlobRef } from '@ontrails/cloudflare/r2';
683
+ * import { z } from 'zod';
684
+ *
685
+ * const assets = cloudflareR2('assets', { binding: 'ASSETS' });
686
+ *
687
+ * const readAsset = trail('asset.read', {
688
+ * implementation: async (input, ctx) => {
689
+ * const object = await assets.from(ctx).get(input.key);
690
+ * if (object === null || !('body' in object)) {
691
+ * return Result.err(new NotFoundError(`Asset "${input.key}" not found`));
692
+ * }
693
+ * return Result.ok(r2ObjectToBlobRef(object));
694
+ * },
695
+ * input: z.object({ key: z.string() }),
696
+ * output: blobRefSchema,
697
+ * resources: [assets],
698
+ * });
699
+ * ```
700
+ */
701
+ export const cloudflareR2 = (
702
+ id: string,
703
+ options: CloudflareR2Options
704
+ ): Resource<CloudflareR2Bucket> => {
705
+ const definition = resource<CloudflareR2Bucket>(id, {
706
+ create: () =>
707
+ Result.err(
708
+ new InternalError(
709
+ `Resource "${id}" wraps Cloudflare R2 binding "${options.binding}", which only exists on a Workers env. Serve the topo with createWorkersHandler from @ontrails/cloudflare/workers, or rely on the in-memory mock in tests.`,
710
+ { context: { binding: options.binding, resourceId: id } }
711
+ )
712
+ ),
713
+ description:
714
+ options.description ??
715
+ `Cloudflare R2 bucket bound to "${options.binding}"`,
716
+ meta: {
717
+ ...options.meta,
718
+ 'cloudflare.binding': options.binding,
719
+ 'cloudflare.service': 'r2',
720
+ },
721
+ mock: () => createMemoryR2(),
722
+ });
723
+ registerEnvBinding(definition, {
724
+ binding: options.binding,
725
+ fromEnv: (value) =>
726
+ isR2BucketBinding(value)
727
+ ? Result.ok(value)
728
+ : Result.err(
729
+ new InternalError(
730
+ `Worker env binding "${options.binding}" for resource "${id}" is not an R2 bucket. Check the r2_buckets entry in your wrangler configuration.`,
731
+ { context: { binding: options.binding, resourceId: id } }
732
+ )
733
+ ),
734
+ });
735
+ return definition;
736
+ };