From e220ecc7b2507ec97aa19c162f5a931e0df5aaaa Mon Sep 17 00:00:00 2001 From: Christian Wilson Date: Sun, 2 Nov 2025 20:14:23 -0500 Subject: [PATCH 1/5] R2 implementation with demo --- .vscode/settings.json | 5 +- demos/r2-demo/.wrangler/deploy/config.json | 1 + demos/r2-demo/package.json | 21 ++ demos/r2-demo/src/index.ts | 327 ++++++++++++++++++ demos/r2-demo/vite.config.ts | 6 + demos/r2-demo/wrangler.jsonc | 18 + packages/file-storage/package.json | 9 +- .../file-storage/src/lib/r2-file-storage.ts | 115 ++++++ packages/file-storage/src/r2.ts | 1 + pnpm-lock.yaml | 282 ++++++++++++++- 10 files changed, 782 insertions(+), 3 deletions(-) create mode 100644 demos/r2-demo/.wrangler/deploy/config.json create mode 100644 demos/r2-demo/package.json create mode 100644 demos/r2-demo/src/index.ts create mode 100644 demos/r2-demo/vite.config.ts create mode 100644 demos/r2-demo/wrangler.jsonc create mode 100644 packages/file-storage/src/lib/r2-file-storage.ts create mode 100644 packages/file-storage/src/r2.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index be72144c746..cedecd94a73 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,8 @@ "parameters": ["--import", "tsx"] } ], - "typescript.tsdk": "./node_modules/typescript/lib" + "typescript.tsdk": "./node_modules/typescript/lib", + "cSpell.words": [ + "ssec" + ] } diff --git a/demos/r2-demo/.wrangler/deploy/config.json b/demos/r2-demo/.wrangler/deploy/config.json new file mode 100644 index 00000000000..2a37be24be9 --- /dev/null +++ b/demos/r2-demo/.wrangler/deploy/config.json @@ -0,0 +1 @@ +{"configPath":"..\\..\\dist\\cloudflare_vite_get_started\\wrangler.json","auxiliaryWorkers":[]} \ No newline at end of file diff --git a/demos/r2-demo/package.json b/demos/r2-demo/package.json new file mode 100644 index 00000000000..7ece379c007 --- /dev/null +++ b/demos/r2-demo/package.json @@ -0,0 +1,21 @@ +{ + "name": "cloudflare-vite-get-started", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "npm run build && vite preview", + "deploy": "npm run build && wrangler deploy" + }, + "dependencies": { + "@remix-run/file-storage": "workspace:*", + "@remix-run/form-data-parser": "workspace:*" + }, + "devDependencies": { + "@cloudflare/vite-plugin": "^1.13.18", + "vite": "^7.1.12", + "wrangler": "^4.45.3" + } +} diff --git a/demos/r2-demo/src/index.ts b/demos/r2-demo/src/index.ts new file mode 100644 index 00000000000..aabd90fccb2 --- /dev/null +++ b/demos/r2-demo/src/index.ts @@ -0,0 +1,327 @@ +import { R2FileStorage } from '@remix-run/file-storage/r2' + +export default { + async fetch(request: Request, env: any) { + const url = new URL(request.url) + const storage = new R2FileStorage(env.r2_demo) + + if (request.method === 'GET' && url.pathname === '/') { + const styles = ` + :root { color-scheme: light dark; } + body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 840px; margin: 40px auto; padding: 20px; } + header { display: flex; align-items: center; justify-content: space-between; } + h1 { font-size: 1.4rem; margin: 0 0 10px; } + .grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } + .card { border: 1px solid #ddd; border-radius: 10px; padding: 16px; } + label { font-weight: 600; font-size: .9rem; display: block; } + input[type="text"], input[type="file"], input[type="number"] { width: 100%; padding: 10px; margin: 8px 0 14px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 8px; } + button { background: #0b62d6; color: #fff; padding: 10px 14px; border: 0; border-radius: 8px; cursor: pointer; width: 100%; } + button:hover { filter: brightness(0.95); } + .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem; white-space: pre-wrap; } + .row { display: flex; gap: 8px; align-items: center; } + .row input[type="text"] { flex: 1; margin: 0; } + .row button { width: auto; } + small { color: #666; } + .options { margin: 20px 0; padding: 14px; background: #0b62d6; border-radius: 8px; border: 1px solid #e5e5e5; } + .options-title { font-weight: 600; font-size: .85rem; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; } + ul { list-style: none; padding: 0; margin: 0; } + li { margin-bottom: 10px; } + li:last-child { margin-bottom: 0; } + li label { font-size: .85rem; } + li input[type="text"] { margin: 6px 0 0; font-size: .9rem; } + .range-nested { margin-top: 10px; padding: 10px; background: rgba(255,255,255,0.1); border-radius: 6px; } + .range-nested li { margin-bottom: 8px; } + .range-nested li:last-child { margin-bottom: 0; } + .range-nested label { font-size: .8rem; opacity: 0.9; } + .range-nested input[type="text"] { padding: 8px; font-size: .85rem; } + ` + const html = ` + + + + R2 Demo — One Page + + + + +
+

R2 Demo

+ All-in-one page +
+ +
+
+

Upload

+
+ + + + + +
+ +
+ +
+

Get / View

+
+ + +
+
Optional Parameters
+
    +
  • + +
      +
    • + + +
    • +
    • + + +
    • +
    • + + +
    • +
    • + + +
    • +
    +
  • +
  • + +
      +
    • + + +
    • +
    • + + +
    • +
    • + + +
    • +
    +
  • +
  • + + +
  • +
+
+ +
+ Opens file in a new tab. +
+ +
+

Delete

+
+ + +
+ +
+ +
+

Has

+
+ + +
+ +
+ +
+

List

+
+ + + + + + + +
+

+    
+
+ + + +` + return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }) + } + + // Upload + if (url.pathname === '/put' && request.method === 'PUT') { + const form = await request.formData() + const key = form.get('key')?.toString() + const file = form.get('file') as File | null + if (!key || !file) return new Response('Missing key or file', { status: 400 }) + await storage.put(key, file) + return new Response('ok') + } + + // Delete + if (url.pathname === '/remove' && request.method === 'DELETE') { + const key = url.searchParams.get('key') + if (!key) return new Response('Missing key', { status: 400 }) + + try { + await storage.remove(key) + return new Response('ok') + } catch { + return new Response('File not found', { status: 404 }) + } + } + + // Has + if (url.pathname === '/has' && request.method === 'GET') { + const key = url.searchParams.get('key') + if (!key) { + return new Response('Missing key', { status: 400 }) + } + const exists = await storage.has(key) + return new Response(exists ? 'exists' : 'missing', { status: exists ? 200 : 404 }) + } + + // List + if (url.pathname === '/list' && request.method === 'GET') { + const limit = url.searchParams.get('limit') + const cursor = url.searchParams.get('cursor') + const prefix = url.searchParams.get('prefix') + const options: any = {} + if (limit) options.limit = parseInt(limit) + if (cursor) options.cursor = cursor + if (prefix) options.prefix = prefix + const result = await storage.list(options) + return new Response(JSON.stringify(result, null, 2), { headers: { 'Content-Type': 'application/json' } }) + } + + + // get + if (request.method === 'GET' && url.pathname !== '/') { + const key = decodeURIComponent(url.pathname.slice(1)) + let options: any = {} + + const etagMatch = url.searchParams.get('etag-match') + const etagNoneMatch = url.searchParams.get('etag-none-match') + const uploadedBefore = url.searchParams.get('uploaded-before') + const uploadedAfter = url.searchParams.get('uploaded-after') + + if (etagMatch || etagNoneMatch || uploadedBefore || uploadedAfter) { + options.onlyIf = {} + if (etagMatch) options.onlyIf.etagMatches = etagMatch // Changed: etagMatches (plural) + if (etagNoneMatch) options.onlyIf.etagDoesNotMatch = etagNoneMatch // Changed: etagDoesNotMatch + if (uploadedBefore) options.onlyIf.uploadedBefore = new Date(uploadedBefore) + if (uploadedAfter) options.onlyIf.uploadedAfter = new Date(uploadedAfter) + } + + + const rangeOffset = url.searchParams.get('range-offset') + const rangeLength = url.searchParams.get('range-length') + const rangeSuffix = url.searchParams.get('range-suffix') + + if (rangeSuffix) { + options.range = { suffix: parseInt(rangeSuffix) } + } else if (rangeOffset || rangeLength) { + options.range = {} + if (rangeOffset) options.range.offset = parseInt(rangeOffset) + if (rangeLength) options.range.length = parseInt(rangeLength) + } + + const ssecKey = url.searchParams.get('ssec-key') + if (ssecKey) { + options.ssecKey = ssecKey + } + + const file = await storage.get(key, options) + if (!file) return new Response('File not found', { status: 404 }) + return new Response(file, { + headers: { + 'Content-Type': file.type, + 'Content-Length': String(file.size), + }, + }) + } + + return new Response('Not found', { status: 404 }) + }, +} diff --git a/demos/r2-demo/vite.config.ts b/demos/r2-demo/vite.config.ts new file mode 100644 index 00000000000..500bc5b56af --- /dev/null +++ b/demos/r2-demo/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import { cloudflare } from "@cloudflare/vite-plugin"; + +export default defineConfig({ + plugins: [cloudflare()], +}); \ No newline at end of file diff --git a/demos/r2-demo/wrangler.jsonc b/demos/r2-demo/wrangler.jsonc new file mode 100644 index 00000000000..3d45d8bd230 --- /dev/null +++ b/demos/r2-demo/wrangler.jsonc @@ -0,0 +1,18 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "cloudflare-vite-get-started", + "compatibility_date": "2025-04-03", + "main": "./src/index.ts", + "r2_buckets": [ + { + "bucket_name": "r2-demo", + "binding": "r2_demo", + "remote": true + }, + { + "bucket_name": "r2-demo-local", + "binding": "r2_demo_local", + "remote": true + } + ], +} \ No newline at end of file diff --git a/packages/file-storage/package.json b/packages/file-storage/package.json index 0b197db4246..fba0e1bacba 100644 --- a/packages/file-storage/package.json +++ b/packages/file-storage/package.json @@ -22,6 +22,7 @@ ".": "./src/index.ts", "./local": "./src/local.ts", "./memory": "./src/memory.ts", + "./r2": "./src/r2.ts", "./package.json": "./package.json" }, "publishConfig": { @@ -38,6 +39,10 @@ "types": "./dist/memory.d.ts", "default": "./dist/memory.js" }, + "./r2": { + "types": "./dist/r2.d.ts", + "default": "./dist/r2.js" + }, "./package.json": "./package.json" } }, @@ -47,13 +52,15 @@ "devDependencies": { "@remix-run/form-data-parser": "workspace:^", "@types/node": "^24.6.0", - "esbuild": "^0.25.10" + "esbuild": "^0.25.10", + "@cloudflare/workers-types": "4.20251014.0" }, "scripts": { "build": "pnpm run clean && pnpm run build:types && pnpm run build:esm && pnpm run build:esm:local && pnpm run build:esm:memory", "build:esm": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", "build:esm:local": "esbuild src/local.ts --bundle --outfile=dist/local.js --format=esm --platform=node --sourcemap", "build:esm:memory": "esbuild src/memory.ts --bundle --outfile=dist/memory.js --format=esm --platform=neutral --sourcemap", + "build:esm:r2": "esbuild src/r2.ts --bundle --outfile=dist/r2.js --format=esm --platform=neutral --sourcemap", "build:types": "tsc --project tsconfig.build.json", "clean": "rm -rf dist", "prepublishOnly": "pnpm run build", diff --git a/packages/file-storage/src/lib/r2-file-storage.ts b/packages/file-storage/src/lib/r2-file-storage.ts new file mode 100644 index 00000000000..a6906a4fe1a --- /dev/null +++ b/packages/file-storage/src/lib/r2-file-storage.ts @@ -0,0 +1,115 @@ +import type { FileKey, FileMetadata, FileStorage, ListOptions, ListResult } from './file-storage.ts' +import type { R2Bucket, R2GetOptions, R2ListOptions, R2Object, R2ObjectBody, R2Objects, R2PutOptions } from '@cloudflare/workers-types' + +export class R2FileStorage implements FileStorage { + #r2: R2Bucket + + constructor(r2: R2Bucket) { + this.#r2 = r2 + console.log('package r2', this.#r2) + } + + async get(key: string, options?: R2GetOptions): Promise { + console.log('options', options); + + let object = await this.#r2.get(key, options) as R2ObjectBody; + console.log('object', object) + + if (object == null ) { + return null + } + + //this is not correct lol + if (!('body' in object) || !object.body) { + throw new Error('Etag Matches') + } + + let fileArray = await object.arrayBuffer() + // console.log('fileArray', fileArray) + console.log('object.key', object.key) + console.log('object.httpMetadata?.contentType', object.httpMetadata?.contentType) + console.log('object.uploaded.getTime()', object.uploaded.getTime()) + + return new File([fileArray], object.key, { + type: object.httpMetadata?.contentType, + lastModified: object.uploaded.getTime() + }) as File + } + + async put(key: string, file: File, options?: R2PutOptions): Promise { + let fileArray = await file.arrayBuffer() + await this.#r2.put(key, fileArray, { + httpMetadata: { + contentType: file.type + }, + customMetadata: { + lastModified: file.lastModified.toString(), + name: file.name, + size: file.size.toString() + }, + ...options + }) + + return new File([fileArray], file.name, { + type: file.type, + lastModified: file.lastModified + }) as File + } + + //struggling with this only returning undefined but says it would return void + async remove(key: string): Promise { + let object = await this.#r2.delete(key) + console.log('object', object) + + if (object === undefined) { + return; + } else { + throw new Error('File not found') + } + } + + async list(options?: T): Promise> { + let cursor: string | undefined; + let objects = await this.#r2.list(options) as R2Objects; + if (objects.truncated) { + cursor = objects.cursor; + } else { + cursor = undefined + } + + + return { + cursor: cursor, + files: objects.objects.map(objects => { + return { key: objects.key } as T extends { includeMetadata: true } ? FileMetadata : FileKey + }) + }; + } + + /* This is what i assume is the correct way to handle the has method, returns metadata only or null if not found*/ + async has(key: string): Promise { + let object = await this.#r2.head(key) as R2Object | null + if (object == null) { + return false + } + return true + } + + async set(key: string, file: File, options?: R2PutOptions): Promise { + let fileArray = await file.arrayBuffer() + await this.#r2.put(key, fileArray, { + httpMetadata: { + contentType: file.type + }, + customMetadata: { + lastModified: file.lastModified.toString(), + name: file.name, + size: file.size.toString() + }, + ...options + }) + return; + } + + +} diff --git a/packages/file-storage/src/r2.ts b/packages/file-storage/src/r2.ts new file mode 100644 index 00000000000..14d3d57b144 --- /dev/null +++ b/packages/file-storage/src/r2.ts @@ -0,0 +1 @@ +export { R2FileStorage } from './lib/r2-file-storage.ts' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef5c0a7b20a..0c462688447 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,25 @@ importers: specifier: ^4.20.6 version: 4.20.6 + demos/r2-demo: + dependencies: + '@remix-run/file-storage': + specifier: workspace:* + version: link:../../packages/file-storage + '@remix-run/form-data-parser': + specifier: workspace:* + version: link:../../packages/form-data-parser + devDependencies: + '@cloudflare/vite-plugin': + specifier: ^1.13.18 + version: 1.13.18(vite@7.1.12(@types/node@24.6.0)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.45.3(@cloudflare/workers-types@4.20251014.0)) + vite: + specifier: ^7.1.12 + version: 7.1.12(@types/node@24.6.0)(tsx@4.20.6) + wrangler: + specifier: ^4.45.3 + version: 4.45.3(@cloudflare/workers-types@4.20251014.0) + packages/fetch-proxy: dependencies: '@remix-run/headers': @@ -105,6 +124,9 @@ importers: specifier: workspace:^ version: link:../lazy-file devDependencies: + '@cloudflare/workers-types': + specifier: 4.20251014.0 + version: 4.20251014.0 '@remix-run/form-data-parser': specifier: workspace:^ version: link:../form-data-parser @@ -469,39 +491,87 @@ packages: workerd: optional: true + '@cloudflare/unenv-preset@2.7.8': + resolution: {integrity: sha512-Ky929MfHh+qPhwCapYrRPwPVHtA2Ioex/DbGZyskGyNRDe9Ru3WThYZivyNVaPy5ergQSgMs9OKrM9Ajtz9F6w==} + peerDependencies: + unenv: 2.0.0-rc.21 + workerd: ^1.20250927.0 + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/vite-plugin@1.13.18': + resolution: {integrity: sha512-1NO7oQWdO3hQMRAYIEd1yiGrd3hVvmLNeSHzTcpURMGh8LMbDGCPUsNWHdF6AoMGH3xC411o0FiwDh16nxWtqg==} + peerDependencies: + vite: ^6.1.0 || ^7.0.0 + wrangler: ^4.45.3 + '@cloudflare/workerd-darwin-64@1.20250604.0': resolution: {integrity: sha512-PI6AWAzhHg75KVhYkSWFBf3HKCHstpaKg4nrx6LYZaEvz0TaTz+JQpYU2fNAgGFmVsK5xEzwFTGh3DAVAKONPw==} engines: {node: '>=16'} cpu: [x64] os: [darwin] + '@cloudflare/workerd-darwin-64@1.20251011.0': + resolution: {integrity: sha512-0DirVP+Z82RtZLlK2B+VhLOkk+ShBqDYO/jhcRw4oVlp0TOvk3cOVZChrt3+y3NV8Y/PYgTEywzLKFSziK4wCg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20250604.0': resolution: {integrity: sha512-hOiZZSop7QRQgGERtTIy9eU5GvPpIsgE2/BDsUdHMl7OBZ7QLniqvgDzLNDzj0aTkCldm9Yl/Z+C7aUgRdOccw==} engines: {node: '>=16'} cpu: [arm64] os: [darwin] + '@cloudflare/workerd-darwin-arm64@1.20251011.0': + resolution: {integrity: sha512-1WuFBGwZd15p4xssGN/48OE2oqokIuc51YvHvyNivyV8IYnAs3G9bJNGWth1X7iMDPe4g44pZrKhRnISS2+5dA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + '@cloudflare/workerd-linux-64@1.20250604.0': resolution: {integrity: sha512-S0R9r7U4nv9qejYygQj01hArC4KUbQQ4u29rvegR0MGoXZY8AHIEuJxon0kE7r7aWFJxvl4W3tOH+5hwW51LYw==} engines: {node: '>=16'} cpu: [x64] os: [linux] + '@cloudflare/workerd-linux-64@1.20251011.0': + resolution: {integrity: sha512-BccMiBzFlWZyFghIw2szanmYJrJGBGHomw2y/GV6pYXChFzMGZkeCEMfmCyJj29xczZXxcZmUVJxNy4eJxO8QA==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + '@cloudflare/workerd-linux-arm64@1.20250604.0': resolution: {integrity: sha512-BTFU/rXpNy03wpeueI2P7q1vVjbg2V6mCyyFGqDqMn2gSVYXH1G0zFNolV13PQXa0HgaqM6oYnqtAxluqbA+kQ==} engines: {node: '>=16'} cpu: [arm64] os: [linux] + '@cloudflare/workerd-linux-arm64@1.20251011.0': + resolution: {integrity: sha512-79o/216lsbAbKEVDZYXR24ivEIE2ysDL9jvo0rDTkViLWju9dAp3CpyetglpJatbSi3uWBPKZBEOqN68zIjVsQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + '@cloudflare/workerd-windows-64@1.20250604.0': resolution: {integrity: sha512-tW/U9/qDmDZBeoEVcK5skb2uouVAMXMzt7o/uGvaIFLeZsQkOp4NBmvoQQd+nbOc7nVCJIwFoSMokd89AhzCkA==} engines: {node: '>=16'} cpu: [x64] os: [win32] + '@cloudflare/workerd-windows-64@1.20251011.0': + resolution: {integrity: sha512-RIXUQRchFdqEvaUqn1cXZXSKjpqMaSaVAkI5jNZ8XzAw/bw2bcdOVUtakrflgxDprltjFb0PTNtuss1FKtH9Jg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + '@cloudflare/workers-types@4.20240821.1': resolution: {integrity: sha512-icAkbnAqgVl6ef9lgLTom8na+kj2RBw2ViPAQ586hbdj0xZcnrjK7P46Eu08OU9D/lNDgN2sKU/sxhe2iK/gIg==} + '@cloudflare/workers-types@4.20251014.0': + resolution: {integrity: sha512-tEW98J/kOa0TdylIUOrLKRdwkUw0rvvYVlo+Ce0mqRH3c8kSoxLzUH9gfCvwLe0M89z1RkzFovSKAW2Nwtyn3w==} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -1199,6 +1269,15 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@poppinss/colors@4.1.5': + resolution: {integrity: sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw==} + + '@poppinss/dumper@0.6.4': + resolution: {integrity: sha512-iG0TIdqv8xJ3Lt9O8DrPRxw1MRLjNpoqiSGU03P/wNLP/s0ra0udPJ1J2Tx5M0J3H/cVyEgpbn8xUKRY9j59kQ==} + + '@poppinss/exception@1.2.2': + resolution: {integrity: sha512-m7bpKCD4QMlFCjA/nKTs23fuvoVFoA83brRKmObCUNmi/9tVu8Ve3w4YQAnJu4q3Tjf5fr685HYIC/IA2zHRSg==} + '@prettier/sync@0.5.5': resolution: {integrity: sha512-6BMtNr7aQhyNcGzmumkL0tgr1YQGfm9d7ZdmRpWqWuqpc9vZBind4xMe5NMiRECOhjuSiWHfBWLBnXkpeE90bw==} peerDependencies: @@ -1210,6 +1289,9 @@ packages: '@remix-run/events@0.0.0-experimental-remix-jam.5': resolution: {integrity: sha512-rbKP27/YkLZq0gbCy3QpwaHQzJg41IgbC//8SggPgMtWxZdxkcnkWmQGMLH0xwQcBlZ3yLtZRDQvL+ir4GAA6g==} + '@remix-run/node-fetch-server@0.8.1': + resolution: {integrity: sha512-J1dev372wtJqmqn9U/qbpbZxbJSQrogNN2+Qv1lKlpATpe/WQ9aCZfl/xSb9d2Rgh1IyLSvNxZAXPZxruO6Xig==} + '@remix-run/style@0.0.0-experimental-remix-jam.5': resolution: {integrity: sha512-XXCOWiY6lGqeJrGQ47JP5fFFuZXbBsFjBbTu46FNxsox1cny8ZPyZ7C674kX92UimGpOU+zv/lPOoU+ZWGim+w==} @@ -1323,6 +1405,13 @@ packages: cpu: [x64] os: [win32] + '@sindresorhus/is@7.1.1': + resolution: {integrity: sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.8': + resolution: {integrity: sha512-IGytNtnUnPIobIbOq5Y6LIlqiHNX+vnToQIS7lj6L5819C+rA8TXRDkkG8vePsiBOGcoW9R6i+dp2YBUKdB09Q==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1759,6 +1848,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -1849,6 +1942,9 @@ packages: end-of-stream@1.4.4: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.0: resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} engines: {node: '>= 0.4'} @@ -1962,6 +2058,9 @@ packages: exsolve@1.0.5: resolution: {integrity: sha512-pz5dvkYYKQ1AHVrgOzBKWeP4u4FRb3a6DNK2ucr0OoNwYIU4QWsJ+NM36LLzORT+z845MzKHHhpXiUF5nvQoJg==} + exsolve@1.0.7: + resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} @@ -2056,6 +2155,10 @@ packages: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} + get-port@7.1.0: + resolution: {integrity: sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==} + engines: {node: '>=16'} + get-source@2.0.12: resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} @@ -2238,6 +2341,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -2322,6 +2429,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + miniflare@4.20251011.1: + resolution: {integrity: sha512-Qbw1Z8HTYM1adWl6FAtzhrj34/6dPRDPwdYOx21dkae8a/EaxbMzRIPbb4HKVGMVvtqbK1FaRCgDLVLolNzGHg==} + engines: {node: '>=18.0.0'} + hasBin: true + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2768,6 +2880,10 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2894,9 +3010,16 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} + undici@7.14.0: + resolution: {integrity: sha512-Vqs8HTzjpQXZeXdpsfChQTlafcMQaaIwnGwLam1wudSSjlJeQ3bw1j+TLPePgrCnCpUXx7Ba5Pdpf5OBih62NQ==} + engines: {node: '>=20.18.1'} + unenv@2.0.0-rc.17: resolution: {integrity: sha512-B06u0wXkEd+o5gOCMl/ZHl5cfpYbDZKAT+HWTL+Hws6jWu7dCiqBBXXXzMFcFVJb8D4ytAnYmxJA83uwOQRSsg==} + unenv@2.0.0-rc.21: + resolution: {integrity: sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==} + universal-user-agent@7.0.2: resolution: {integrity: sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==} @@ -3017,6 +3140,11 @@ packages: engines: {node: '>=16'} hasBin: true + workerd@1.20251011.0: + resolution: {integrity: sha512-Dq35TLPEJAw7BuYQMkN3p9rge34zWMU2Gnd4DSJFeVqld4+DAO2aPG7+We2dNIAyM97S8Y9BmHulbQ00E0HC7Q==} + engines: {node: '>=16'} + hasBin: true + wrangler@4.20.0: resolution: {integrity: sha512-gxMLaSnYp3VLdGPZu4fc/9UlB7PnSVwni25v32NM9szG2yTt+gx5RunWzmoLplplIfEMkBuV3wA47vccNu7zcA==} engines: {node: '>=18.0.0'} @@ -3027,6 +3155,16 @@ packages: '@cloudflare/workers-types': optional: true + wrangler@4.45.3: + resolution: {integrity: sha512-0ddEA9t4HeBgSVTVTcqtBHl7Z5CorWZ8tGgTQCP5XuL+9E1TJRwS6t/zzG51Ruwjb17SZYCaLchoM8V629S8cw==} + engines: {node: '>=18.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20251011.0 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3094,9 +3232,15 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + youch@3.3.4: resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==} + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zod@3.22.3: resolution: {integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==} @@ -3143,23 +3287,63 @@ snapshots: optionalDependencies: workerd: 1.20250604.0 + '@cloudflare/unenv-preset@2.7.8(unenv@2.0.0-rc.21)(workerd@1.20251011.0)': + dependencies: + unenv: 2.0.0-rc.21 + optionalDependencies: + workerd: 1.20251011.0 + + '@cloudflare/vite-plugin@1.13.18(vite@7.1.12(@types/node@24.6.0)(tsx@4.20.6))(workerd@1.20251011.0)(wrangler@4.45.3(@cloudflare/workers-types@4.20251014.0))': + dependencies: + '@cloudflare/unenv-preset': 2.7.8(unenv@2.0.0-rc.21)(workerd@1.20251011.0) + '@remix-run/node-fetch-server': 0.8.1 + get-port: 7.1.0 + miniflare: 4.20251011.1 + picocolors: 1.1.1 + tinyglobby: 0.2.15 + unenv: 2.0.0-rc.21 + vite: 7.1.12(@types/node@24.6.0)(tsx@4.20.6) + wrangler: 4.45.3(@cloudflare/workers-types@4.20251014.0) + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - workerd + '@cloudflare/workerd-darwin-64@1.20250604.0': optional: true + '@cloudflare/workerd-darwin-64@1.20251011.0': + optional: true + '@cloudflare/workerd-darwin-arm64@1.20250604.0': optional: true + '@cloudflare/workerd-darwin-arm64@1.20251011.0': + optional: true + '@cloudflare/workerd-linux-64@1.20250604.0': optional: true + '@cloudflare/workerd-linux-64@1.20251011.0': + optional: true + '@cloudflare/workerd-linux-arm64@1.20250604.0': optional: true + '@cloudflare/workerd-linux-arm64@1.20251011.0': + optional: true + '@cloudflare/workerd-windows-64@1.20250604.0': optional: true + '@cloudflare/workerd-windows-64@1.20251011.0': + optional: true + '@cloudflare/workers-types@4.20240821.1': {} + '@cloudflare/workers-types@4.20251014.0': {} + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -3599,6 +3783,18 @@ snapshots: '@polka/url@1.0.0-next.29': {} + '@poppinss/colors@4.1.5': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.4': + dependencies: + '@poppinss/colors': 4.1.5 + '@sindresorhus/is': 7.1.1 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.2': {} + '@prettier/sync@0.5.5(prettier@3.5.3)': dependencies: make-synchronized: 0.4.2 @@ -3611,6 +3807,8 @@ snapshots: '@remix-run/events@0.0.0-experimental-remix-jam.5': {} + '@remix-run/node-fetch-server@0.8.1': {} + '@remix-run/style@0.0.0-experimental-remix-jam.5': {} '@rollup/rollup-android-arm-eabi@4.52.5': @@ -3679,6 +3877,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.52.5': optional: true + '@sindresorhus/is@7.1.1': {} + + '@speed-highlight/core@1.2.8': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.27.1 @@ -4211,6 +4413,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.0.2: {} + core-util-is@1.0.3: {} cross-spawn@7.0.5: @@ -4280,6 +4484,8 @@ snapshots: dependencies: once: 1.4.0 + error-stack-parser-es@1.0.5: {} + es-define-property@1.0.0: dependencies: get-intrinsic: 1.2.4 @@ -4513,6 +4719,8 @@ snapshots: exsolve@1.0.5: {} + exsolve@1.0.7: {} + fast-decode-uri-component@1.0.1: {} fast-deep-equal@3.1.3: {} @@ -4608,6 +4816,8 @@ snapshots: has-symbols: 1.0.3 hasown: 2.0.2 + get-port@7.1.0: {} + get-source@2.0.12: dependencies: data-uri-to-buffer: 2.0.2 @@ -4763,6 +4973,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@4.1.5: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -4837,6 +5049,24 @@ snapshots: - bufferutil - utf-8-validate + miniflare@4.20251011.1: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + acorn: 8.14.0 + acorn-walk: 8.3.2 + exit-hook: 2.2.1 + glob-to-regexp: 0.4.1 + sharp: 0.33.5 + stoppable: 1.1.0 + undici: 7.14.0 + workerd: 1.20251011.0 + ws: 8.18.0 + youch: 4.1.0-beta.10 + zod: 3.22.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -5315,6 +5545,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -5427,6 +5659,8 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 + undici@7.14.0: {} + unenv@2.0.0-rc.17: dependencies: defu: 6.1.4 @@ -5435,6 +5669,14 @@ snapshots: pathe: 2.0.3 ufo: 1.6.1 + unenv@2.0.0-rc.21: + dependencies: + defu: 6.1.4 + exsolve: 1.0.7 + ohash: 2.0.11 + pathe: 2.0.3 + ufo: 1.6.1 + universal-user-agent@7.0.2: {} unpipe@1.0.0: {} @@ -5477,7 +5719,7 @@ snapshots: vite@7.1.12(@types/node@24.6.0)(tsx@4.20.6): dependencies: - esbuild: 0.25.10 + esbuild: 0.25.11 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 @@ -5553,6 +5795,14 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20250604.0 '@cloudflare/workerd-windows-64': 1.20250604.0 + workerd@1.20251011.0: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20251011.0 + '@cloudflare/workerd-darwin-arm64': 1.20251011.0 + '@cloudflare/workerd-linux-64': 1.20251011.0 + '@cloudflare/workerd-linux-arm64': 1.20251011.0 + '@cloudflare/workerd-windows-64': 1.20251011.0 + wrangler@4.20.0(@cloudflare/workers-types@4.20240821.1): dependencies: '@cloudflare/kv-asset-handler': 0.4.0 @@ -5570,6 +5820,23 @@ snapshots: - bufferutil - utf-8-validate + wrangler@4.45.3(@cloudflare/workers-types@4.20251014.0): + dependencies: + '@cloudflare/kv-asset-handler': 0.4.0 + '@cloudflare/unenv-preset': 2.7.8(unenv@2.0.0-rc.21)(workerd@1.20251011.0) + blake3-wasm: 2.1.5 + esbuild: 0.25.4 + miniflare: 4.20251011.1 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.21 + workerd: 1.20251011.0 + optionalDependencies: + '@cloudflare/workers-types': 4.20251014.0 + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -5620,10 +5887,23 @@ snapshots: yocto-queue@0.1.0: {} + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.2 + error-stack-parser-es: 1.0.5 + youch@3.3.4: dependencies: cookie: 0.7.2 mustache: 4.2.0 stacktracey: 2.1.8 + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.5 + '@poppinss/dumper': 0.6.4 + '@speed-highlight/core': 1.2.8 + cookie: 1.0.2 + youch-core: 0.3.3 + zod@3.22.3: {} From a3575db26f816aa84cc27a252a5717d775f7f1de Mon Sep 17 00:00:00 2001 From: Christian Wilson Date: Mon, 3 Nov 2025 20:42:02 -0500 Subject: [PATCH 2/5] update the demo to reflect put and set properly, update the list options in demo, create README --- demos/r2-demo/README.md | 92 +++++++++ demos/r2-demo/src/index.ts | 184 +++++++++++++++++- .../file-storage/src/lib/r2-file-storage.ts | 10 +- 3 files changed, 277 insertions(+), 9 deletions(-) create mode 100644 demos/r2-demo/README.md diff --git a/demos/r2-demo/README.md b/demos/r2-demo/README.md new file mode 100644 index 00000000000..9f398626380 --- /dev/null +++ b/demos/r2-demo/README.md @@ -0,0 +1,92 @@ +# R2 Demo + +Demo of `@remix-run/file-storage` R2FileStorage API with Cloudflare Workers. + +## Prerequisites + +- [Cloudflare account](https://dash.cloudflare.com/sign-up) +- pnpm: `npx pnpm install -g pnpm` + +## Getting Started + +### 1. Install Dependencies + +```bash +pnpm install +``` + +### 2. Login to Cloudflare + +```bash +pnpm wrangler login +``` + +### 3. Create R2 Buckets + +```bash +pnpm wrangler r2 bucket create r2-demo +pnpm wrangler r2 bucket create r2-demo-local +``` + +### 4. Start Development Server + +```bash +pnpm dev +``` + +Open `http://localhost:5173` in your browser. + +## Deployment + +Build and deploy to Cloudflare Workers: + +```bash +pnpm deploy +``` + +## Demo Structure + +### `wrangler.jsonc` + +Configuration for Cloudflare Workers: + +- **R2 bucket bindings**: Maps R2 buckets to environment variables (`r2_demo`, `r2_demo_local`) +- **Compatibility date**: API version date +- **Main entry point**: Points to `src/index.ts` + +### `src/index.ts` + +Vibe Coded Single-page application with R2FileStorage operations: + +#### Initialization + +```typescript +const storage = new R2FileStorage(env.r2_demo) +``` + +#### API Endpoints +## +##### Follows the interface provided in **`file-storage.ts`** + +**`PUT /put`** - Upload file, returns uploaded file +- Storage class, SSEC encryption, checksums (MD5, SHA-1, SHA-256, SHA-384, SHA-512) + +**`PUT /set`** - Upload file, returns confirmation text +- Same options as `/put`, no file return + +**`GET /{key}`** - Retrieve file by key +- Query params: `etag-match`, `etag-none-match`, `uploaded-before`, `uploaded-after` +- Range support: `range-offset`, `range-length`, `range-suffix` +- SSEC decryption: `ssec-key` + +**`DELETE /remove?key=`** - Delete file by key + +**`GET /has?key=`** - Check if file exists + +**`GET /list?prefix=&limit=&cursor=`** - List files with pagination + + +### `vite.config.ts` + +Uses `@cloudflare/vite-plugin` for Workers development with local R2 simulation. + diff --git a/demos/r2-demo/src/index.ts b/demos/r2-demo/src/index.ts index aabd90fccb2..5ab18e18cbe 100644 --- a/demos/r2-demo/src/index.ts +++ b/demos/r2-demo/src/index.ts @@ -57,11 +57,91 @@ export default { + + + + +
+
Optional Parameters
+
    +
  • + +
      +
    • + + +
    • +
    • + + +
    • +
    • + + +
    • +
    • + + +
    • +
    • + + +
    • +
    +
  • +
+
+
+

Set

+
+ + + + + + + + +
+
Optional Parameters
+
    +
  • + +
      +
    • + + +
    • +
    • + + +
    • +
    • + + +
    • +
    • + + +
    • +
    • + + +
    • +
    +
  • +
+
+ +
+ +
+

Get / View

@@ -161,9 +241,59 @@ export default { const fd = new FormData() fd.append('key', $('up-key').value) fd.append('file', $('up-file').files[0]) + const storageClass = $('storage-class').value.trim() + const ssecKey = $('ssec-key-put').value.trim() + const md5 = $('checksum-md5').value.trim() + const sha1 = $('checksum-sha1').value.trim() + const sha256 = $('checksum-sha256').value.trim() + const sha384 = $('checksum-sha384').value.trim() + const sha512 = $('checksum-sha512').value.trim() + if (storageClass) fd.append('storage-class', storageClass) + if (ssecKey) fd.append('ssec-key-put', ssecKey) + if (md5) fd.append('checksum-md5', md5) + if (sha1) fd.append('checksum-sha1', sha1) + if (sha256) fd.append('checksum-sha256', sha256) + if (sha384) fd.append('checksum-sha384', sha384) + if (sha512) fd.append('checksum-sha512', sha512) const res = await fetch('/put', { method: 'PUT', body: fd }) - $('uploadMsg').textContent = res.ok ? 'Uploaded ✔︎' : await res.text() - if (res.ok) e.target.reset() + if (res.ok) { + const blob = await res.blob() + const url = URL.createObjectURL(blob) + window.open(url, '_blank') + $('uploadMsg').textContent = 'Uploaded ✔︎' + e.target.reset() + } else { + $('uploadMsg').textContent = await res.text() + } + }) + + // Set -> PUT /set + $('setForm').addEventListener('submit', async (e) => { + e.preventDefault() + const fd = new FormData() + fd.append('key', $('set-key').value) + fd.append('file', $('set-file').files[0]) + const storageClass = $('set-storage-class').value.trim() + const ssecKey = $('set-ssec-key').value.trim() + const md5 = $('set-checksum-md5').value.trim() + const sha1 = $('set-checksum-sha1').value.trim() + const sha256 = $('set-checksum-sha256').value.trim() + const sha384 = $('set-checksum-sha384').value.trim() + const sha512 = $('set-checksum-sha512').value.trim() + if (storageClass) fd.append('storage-class', storageClass) + if (ssecKey) fd.append('ssec-key-put', ssecKey) + if (md5) fd.append('checksum-md5', md5) + if (sha1) fd.append('checksum-sha1', sha1) + if (sha256) fd.append('checksum-sha256', sha256) + if (sha384) fd.append('checksum-sha384', sha384) + if (sha512) fd.append('checksum-sha512', sha512) + const res = await fetch('/set', { method: 'PUT', body: fd }) + if (res.ok) { + $('setMsg').textContent = await res.text() + e.target.reset() + } else { + $('setMsg').textContent = await res.text() + } }) // Get / View -> open /{key} with options @@ -235,8 +365,54 @@ export default { const key = form.get('key')?.toString() const file = form.get('file') as File | null if (!key || !file) return new Response('Missing key or file', { status: 400 }) - await storage.put(key, file) - return new Response('ok') + const options: any = {} + const storageClass = form.get('storage-class')?.toString() + const ssecKey = form.get('ssec-key-put')?.toString() + const md5 = form.get('checksum-md5')?.toString() + const sha1 = form.get('checksum-sha1')?.toString() + const sha256 = form.get('checksum-sha256')?.toString() + const sha384 = form.get('checksum-sha384')?.toString() + const sha512 = form.get('checksum-sha512')?.toString() + if (storageClass) options.storageClass = storageClass + if (ssecKey) options.ssecKey = ssecKey + if (md5) options.md5 = md5 + if (sha1) options.sha1 = sha1 + if (sha256) options.sha256 = sha256 + if (sha384) options.sha384 = sha384 + if (sha512) options.sha512 = sha512 + const uploadedFile = await storage.put(key, file, options) + return new Response(uploadedFile, { + headers: { + 'Content-Type': uploadedFile.type, + 'Content-Length': String(uploadedFile.size), + } + }) + } + + // Set + if (url.pathname === '/set' && request.method === 'PUT') { + const form = await request.formData() + const key = form.get('key')?.toString() + const file = form.get('file') as File | null + if (!key || !file) return new Response('Missing key or file', { status: 400 }) + const options: any = {} + const storageClass = form.get('storage-class')?.toString() + const ssecKey = form.get('ssec-key-put')?.toString() + const md5 = form.get('checksum-md5')?.toString() + const sha1 = form.get('checksum-sha1')?.toString() + const sha256 = form.get('checksum-sha256')?.toString() + const sha384 = form.get('checksum-sha384')?.toString() + const sha512 = form.get('checksum-sha512')?.toString() + if (storageClass) options.storageClass = storageClass + if (ssecKey) options.ssecKey = ssecKey + if (md5) options.md5 = md5 + if (sha1) options.sha1 = sha1 + if (sha256) options.sha256 = sha256 + if (sha384) options.sha384 = sha384 + if (sha512) options.sha512 = sha512 + const hasOptions = storageClass || ssecKey || md5 || sha1 || sha256 || sha384 || sha512 + await storage.set(key, file, hasOptions ? options : undefined) + return new Response('uploaded') } // Delete diff --git a/packages/file-storage/src/lib/r2-file-storage.ts b/packages/file-storage/src/lib/r2-file-storage.ts index a6906a4fe1a..d1459579ffb 100644 --- a/packages/file-storage/src/lib/r2-file-storage.ts +++ b/packages/file-storage/src/lib/r2-file-storage.ts @@ -38,7 +38,7 @@ export class R2FileStorage implements FileStorage { async put(key: string, file: File, options?: R2PutOptions): Promise { let fileArray = await file.arrayBuffer() - await this.#r2.put(key, fileArray, { + let object = await this.#r2.put(key, fileArray, { httpMetadata: { contentType: file.type }, @@ -48,11 +48,11 @@ export class R2FileStorage implements FileStorage { size: file.size.toString() }, ...options - }) + }) as R2Object - return new File([fileArray], file.name, { - type: file.type, - lastModified: file.lastModified + return new File([fileArray], object.key, { + type: object.httpMetadata?.contentType, + lastModified: object.uploaded.getTime() }) as File } From ec6d763843853fcebb1377bfea4733ef462df1ee Mon Sep 17 00:00:00 2001 From: Christian Wilson Date: Tue, 4 Nov 2025 18:51:16 -0500 Subject: [PATCH 3/5] update file, clean up comments adjust list --- .../file-storage/src/lib/r2-file-storage.ts | 66 ++++++++----------- 1 file changed, 29 insertions(+), 37 deletions(-) diff --git a/packages/file-storage/src/lib/r2-file-storage.ts b/packages/file-storage/src/lib/r2-file-storage.ts index d1459579ffb..94bb03e3e73 100644 --- a/packages/file-storage/src/lib/r2-file-storage.ts +++ b/packages/file-storage/src/lib/r2-file-storage.ts @@ -6,31 +6,18 @@ export class R2FileStorage implements FileStorage { constructor(r2: R2Bucket) { this.#r2 = r2 - console.log('package r2', this.#r2) } async get(key: string, options?: R2GetOptions): Promise { - console.log('options', options); - let object = await this.#r2.get(key, options) as R2ObjectBody; - console.log('object', object) if (object == null ) { return null } - //this is not correct lol - if (!('body' in object) || !object.body) { - throw new Error('Etag Matches') - } - let fileArray = await object.arrayBuffer() - // console.log('fileArray', fileArray) - console.log('object.key', object.key) - console.log('object.httpMetadata?.contentType', object.httpMetadata?.contentType) - console.log('object.uploaded.getTime()', object.uploaded.getTime()) - return new File([fileArray], object.key, { + return new File([fileArray], object.customMetadata?.name ?? object.key, { type: object.httpMetadata?.contentType, lastModified: object.uploaded.getTime() }) as File @@ -56,37 +43,41 @@ export class R2FileStorage implements FileStorage { }) as File } - //struggling with this only returning undefined but says it would return void async remove(key: string): Promise { - let object = await this.#r2.delete(key) - console.log('object', object) - - if (object === undefined) { - return; - } else { - throw new Error('File not found') - } + await this.#r2.delete(key) } + //The cloudflare R2ListOptions type is missing the include to check for presence of metadata or not. So did not include type async list(options?: T): Promise> { - let cursor: string | undefined; - let objects = await this.#r2.list(options) as R2Objects; - if (objects.truncated) { - cursor = objects.cursor; - } else { - cursor = undefined + let r2Options: any = { + limit: options?.limit, + prefix: options?.prefix, + cursor: options?.cursor, } - - + + if (options?.includeMetadata) { + r2Options.include = ['httpMetadata', 'customMetadata'] + } + + let objects = await this.#r2.list(r2Options) as R2Objects + return { - cursor: cursor, - files: objects.objects.map(objects => { - return { key: objects.key } as T extends { includeMetadata: true } ? FileMetadata : FileKey - }) - }; + cursor: objects.truncated ? objects.cursor : undefined, + files: objects.objects.map(obj => { + if (options?.includeMetadata) { + return { + key: obj.key, + lastModified: obj.uploaded.getTime(), + name: obj.customMetadata?.name ?? obj.key, + size: obj.size, + type: obj.httpMetadata?.contentType ?? '', + } as FileMetadata + } + return { key: obj.key } as FileKey + }) as any, + } } - /* This is what i assume is the correct way to handle the has method, returns metadata only or null if not found*/ async has(key: string): Promise { let object = await this.#r2.head(key) as R2Object | null if (object == null) { @@ -95,6 +86,7 @@ export class R2FileStorage implements FileStorage { return true } + async set(key: string, file: File, options?: R2PutOptions): Promise { let fileArray = await file.arrayBuffer() await this.#r2.put(key, fileArray, { From b3b0601a2876d04a754bfec03e85e51ac699e5df Mon Sep 17 00:00:00 2001 From: Christian Wilson Date: Tue, 4 Nov 2025 20:03:07 -0500 Subject: [PATCH 4/5] added test suite, checked that it works --- .../src/lib/r2-file-storage.test.ts | 235 ++++++++++++++++++ .../file-storage/src/lib/r2-file-storage.ts | 2 +- 2 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 packages/file-storage/src/lib/r2-file-storage.test.ts diff --git a/packages/file-storage/src/lib/r2-file-storage.test.ts b/packages/file-storage/src/lib/r2-file-storage.test.ts new file mode 100644 index 00000000000..ff1240d0275 --- /dev/null +++ b/packages/file-storage/src/lib/r2-file-storage.test.ts @@ -0,0 +1,235 @@ +import * as assert from 'node:assert/strict' +import { beforeEach, describe, it } from 'node:test' +import { parseFormData } from '@remix-run/form-data-parser' + +import { R2FileStorage } from './r2-file-storage.ts' +import type { R2Bucket, R2Objects } from '@cloudflare/workers-types' + +class MockR2Bucket implements Partial { + #storage = new Map() + + async get(key: string) { + let stored = this.#storage.get(key) + if (!stored) return null + + return { + key, + body: new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(stored.data)) + controller.close() + } + }), + arrayBuffer: async () => stored.data, + httpMetadata: stored.metadata.httpMetadata, + customMetadata: stored.metadata.customMetadata, + uploaded: stored.metadata.uploaded, + size: stored.data.byteLength, + } as any + } + + async put(key: string, value: ArrayBuffer, options?: any) { + this.#storage.set(key, { + data: value, + metadata: { + httpMetadata: options?.httpMetadata ?? {}, + customMetadata: options?.customMetadata ?? {}, + uploaded: new Date() + } + }) + + return { + key, + size: value.byteLength, + uploaded: new Date(), + httpMetadata: options?.httpMetadata, + customMetadata: options?.customMetadata, + } as any + } + + async delete(key: string) { + this.#storage.delete(key) + } + + async head(key: string) { + let stored = this.#storage.get(key) + if (!stored) return null + return { key, ...stored.metadata } as any + } + + async list(options?: any) { + let keys = Array.from(this.#storage.keys()) + + if (options?.prefix) { + keys = keys.filter(k => k.startsWith(options.prefix)) + } + + keys.sort() + + let startIndex = options?.cursor ? parseInt(options.cursor) : 0 + let limit = options?.limit ?? 1000 + let endIndex = Math.min(startIndex + limit, keys.length) + + let objects = keys.slice(startIndex, endIndex).map(key => { + let stored = this.#storage.get(key)! + let obj: any = { + key, + size: stored.data.byteLength, + uploaded: stored.metadata.uploaded, + } + + if (options?.include?.includes('httpMetadata')) { + obj.httpMetadata = stored.metadata.httpMetadata + } + if (options?.include?.includes('customMetadata')) { + obj.customMetadata = stored.metadata.customMetadata + } + + return obj + }) + + return { + objects, + truncated: endIndex < keys.length, + cursor: endIndex < keys.length && endIndex > startIndex ? endIndex.toString() : undefined, + delimitedPrefixes: [] + } as R2Objects + } +} + +describe('R2FileStorage', () => { + let mockBucket: MockR2Bucket + let storage: R2FileStorage + + beforeEach(() => { + mockBucket = new MockR2Bucket() + storage = new R2FileStorage(mockBucket as unknown as R2Bucket) + }) + + it('stores and retrieves files', async () => { + let lastModified = Date.now() + let file = new File(['Hello, world!'], 'hello.txt', { + type: 'text/plain', + lastModified, + }) + + await storage.set('hello', file) + + assert.ok(await storage.has('hello')) + + let retrieved = await storage.get('hello') + + assert.ok(retrieved) + assert.equal(retrieved.name, 'hello.txt') + assert.equal(retrieved.type, 'text/plain') + assert.equal(retrieved.lastModified, lastModified) + assert.equal(retrieved.size, 13) + + let text = await retrieved.text() + + assert.equal(text, 'Hello, world!') + + await storage.remove('hello') + + assert.ok(!(await storage.has('hello'))) + assert.equal(await storage.get('hello'), null) + }) + + it('lists files with pagination', async () => { + let allKeys = ['a', 'b', 'c', 'd', 'e'] + + await Promise.all( + allKeys.map((key) => + storage.set(key, new File([`Hello ${key}!`], `hello.txt`, { type: 'text/plain' })), + ), + ) + + let { cursor, files } = await storage.list() + assert.equal(cursor, undefined) + assert.equal(files.length, 5) + assert.deepEqual(files.map((f) => f.key).sort(), allKeys) + + let { cursor: cursor1, files: files1 } = await storage.list({ limit: 0 }) + assert.equal(cursor1, undefined) + assert.equal(files1.length, 0) + + let { cursor: cursor2, files: files2 } = await storage.list({ limit: 2 }) + assert.notEqual(cursor2, undefined) + assert.equal(files2.length, 2) + + let { cursor: cursor3, files: files3 } = await storage.list({ cursor: cursor2 }) + assert.equal(cursor3, undefined) + assert.equal(files3.length, 3) + + assert.deepEqual([...files2, ...files3].map((f) => f.key).sort(), allKeys) + }) + + it('lists files by key prefix', async () => { + let allKeys = ['a', 'b', 'b/c', 'c', 'd'] + + await Promise.all( + allKeys.map((key) => + storage.set(key, new File([`Hello ${key}!`], `hello.txt`, { type: 'text/plain' })), + ), + ) + + let { cursor, files } = await storage.list({ prefix: 'b' }) + assert.equal(cursor, undefined) + assert.equal(files.length, 2) + assert.deepEqual(files.map((f) => f.key).sort(), ['b', 'b/c']) + }) + + it('lists files with metadata', async () => { + let allKeys = ['a', 'b', 'c', 'd', 'e'] + + await Promise.all( + allKeys.map((key) => + storage.set(key, new File([`Hello ${key}!`], `hello.txt`, { type: 'text/plain' })), + ), + ) + + let { cursor, files } = await storage.list({ includeMetadata: true }) + assert.equal(cursor, undefined) + assert.equal(files.length, 5) + assert.deepEqual(files.map((f) => f.key).sort(), allKeys) + files.forEach((f) => assert.ok('lastModified' in f)) + files.forEach((f) => assert.ok('name' in f)) + files.forEach((f) => assert.ok('size' in f)) + files.forEach((f) => assert.ok('type' in f)) + }) + + describe('integration with form-data-parser', () => { + it('stores and lists file uploads', async () => { + let boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW' + let request = new Request('http://example.com', { + method: 'POST', + headers: { + 'Content-Type': `multipart/form-data; boundary=${boundary}`, + }, + body: [ + `--${boundary}`, + 'Content-Disposition: form-data; name="hello"; filename="hello.txt"', + 'Content-Type: text/plain', + '', + 'Hello, world!', + `--${boundary}--`, + ].join('\r\n'), + }) + + await parseFormData(request, async (file) => { + await storage.set('hello', file) + }) + + assert.ok(await storage.has('hello')) + + let { files } = await storage.list({ includeMetadata: true }) + + assert.equal(files.length, 1) + assert.equal(files[0].key, 'hello') + assert.equal(files[0].name, 'hello.txt') + assert.equal(files[0].size, 13) + assert.equal(files[0].type, 'text/plain') + assert.ok(files[0].lastModified) + }) + }) +}) diff --git a/packages/file-storage/src/lib/r2-file-storage.ts b/packages/file-storage/src/lib/r2-file-storage.ts index 94bb03e3e73..00b89c09183 100644 --- a/packages/file-storage/src/lib/r2-file-storage.ts +++ b/packages/file-storage/src/lib/r2-file-storage.ts @@ -19,7 +19,7 @@ export class R2FileStorage implements FileStorage { return new File([fileArray], object.customMetadata?.name ?? object.key, { type: object.httpMetadata?.contentType, - lastModified: object.uploaded.getTime() + lastModified: parseInt(object.customMetadata?.lastModified ?? object.uploaded.getTime().toString()) }) as File } From e7d6c0af6729a578d6a422e0684f6adddcd27f01 Mon Sep 17 00:00:00 2001 From: Christian Wilson Date: Tue, 4 Nov 2025 20:48:38 -0500 Subject: [PATCH 5/5] delete demo, update README as well as CHANGELOG inside of file-storage --- demos/r2-demo/.wrangler/deploy/config.json | 1 - demos/r2-demo/README.md | 92 ---- demos/r2-demo/package.json | 21 - demos/r2-demo/src/index.ts | 503 --------------------- demos/r2-demo/vite.config.ts | 6 - demos/r2-demo/wrangler.jsonc | 18 - packages/file-storage/CHANGELOG.md | 6 + packages/file-storage/README.md | 104 +++++ 8 files changed, 110 insertions(+), 641 deletions(-) delete mode 100644 demos/r2-demo/.wrangler/deploy/config.json delete mode 100644 demos/r2-demo/README.md delete mode 100644 demos/r2-demo/package.json delete mode 100644 demos/r2-demo/src/index.ts delete mode 100644 demos/r2-demo/vite.config.ts delete mode 100644 demos/r2-demo/wrangler.jsonc diff --git a/demos/r2-demo/.wrangler/deploy/config.json b/demos/r2-demo/.wrangler/deploy/config.json deleted file mode 100644 index 2a37be24be9..00000000000 --- a/demos/r2-demo/.wrangler/deploy/config.json +++ /dev/null @@ -1 +0,0 @@ -{"configPath":"..\\..\\dist\\cloudflare_vite_get_started\\wrangler.json","auxiliaryWorkers":[]} \ No newline at end of file diff --git a/demos/r2-demo/README.md b/demos/r2-demo/README.md deleted file mode 100644 index 9f398626380..00000000000 --- a/demos/r2-demo/README.md +++ /dev/null @@ -1,92 +0,0 @@ -# R2 Demo - -Demo of `@remix-run/file-storage` R2FileStorage API with Cloudflare Workers. - -## Prerequisites - -- [Cloudflare account](https://dash.cloudflare.com/sign-up) -- pnpm: `npx pnpm install -g pnpm` - -## Getting Started - -### 1. Install Dependencies - -```bash -pnpm install -``` - -### 2. Login to Cloudflare - -```bash -pnpm wrangler login -``` - -### 3. Create R2 Buckets - -```bash -pnpm wrangler r2 bucket create r2-demo -pnpm wrangler r2 bucket create r2-demo-local -``` - -### 4. Start Development Server - -```bash -pnpm dev -``` - -Open `http://localhost:5173` in your browser. - -## Deployment - -Build and deploy to Cloudflare Workers: - -```bash -pnpm deploy -``` - -## Demo Structure - -### `wrangler.jsonc` - -Configuration for Cloudflare Workers: - -- **R2 bucket bindings**: Maps R2 buckets to environment variables (`r2_demo`, `r2_demo_local`) -- **Compatibility date**: API version date -- **Main entry point**: Points to `src/index.ts` - -### `src/index.ts` - -Vibe Coded Single-page application with R2FileStorage operations: - -#### Initialization - -```typescript -const storage = new R2FileStorage(env.r2_demo) -``` - -#### API Endpoints -## -##### Follows the interface provided in **`file-storage.ts`** - -**`PUT /put`** - Upload file, returns uploaded file -- Storage class, SSEC encryption, checksums (MD5, SHA-1, SHA-256, SHA-384, SHA-512) - -**`PUT /set`** - Upload file, returns confirmation text -- Same options as `/put`, no file return - -**`GET /{key}`** - Retrieve file by key -- Query params: `etag-match`, `etag-none-match`, `uploaded-before`, `uploaded-after` -- Range support: `range-offset`, `range-length`, `range-suffix` -- SSEC decryption: `ssec-key` - -**`DELETE /remove?key=`** - Delete file by key - -**`GET /has?key=`** - Check if file exists - -**`GET /list?prefix=&limit=&cursor=`** - List files with pagination - - -### `vite.config.ts` - -Uses `@cloudflare/vite-plugin` for Workers development with local R2 simulation. - diff --git a/demos/r2-demo/package.json b/demos/r2-demo/package.json deleted file mode 100644 index 7ece379c007..00000000000 --- a/demos/r2-demo/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "cloudflare-vite-get-started", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "npm run build && vite preview", - "deploy": "npm run build && wrangler deploy" - }, - "dependencies": { - "@remix-run/file-storage": "workspace:*", - "@remix-run/form-data-parser": "workspace:*" - }, - "devDependencies": { - "@cloudflare/vite-plugin": "^1.13.18", - "vite": "^7.1.12", - "wrangler": "^4.45.3" - } -} diff --git a/demos/r2-demo/src/index.ts b/demos/r2-demo/src/index.ts deleted file mode 100644 index 5ab18e18cbe..00000000000 --- a/demos/r2-demo/src/index.ts +++ /dev/null @@ -1,503 +0,0 @@ -import { R2FileStorage } from '@remix-run/file-storage/r2' - -export default { - async fetch(request: Request, env: any) { - const url = new URL(request.url) - const storage = new R2FileStorage(env.r2_demo) - - if (request.method === 'GET' && url.pathname === '/') { - const styles = ` - :root { color-scheme: light dark; } - body { font-family: ui-sans-serif, system-ui, sans-serif; max-width: 840px; margin: 40px auto; padding: 20px; } - header { display: flex; align-items: center; justify-content: space-between; } - h1 { font-size: 1.4rem; margin: 0 0 10px; } - .grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); } - .card { border: 1px solid #ddd; border-radius: 10px; padding: 16px; } - label { font-weight: 600; font-size: .9rem; display: block; } - input[type="text"], input[type="file"], input[type="number"] { width: 100%; padding: 10px; margin: 8px 0 14px; box-sizing: border-box; border: 1px solid #ccc; border-radius: 8px; } - button { background: #0b62d6; color: #fff; padding: 10px 14px; border: 0; border-radius: 8px; cursor: pointer; width: 100%; } - button:hover { filter: brightness(0.95); } - .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9rem; white-space: pre-wrap; } - .row { display: flex; gap: 8px; align-items: center; } - .row input[type="text"] { flex: 1; margin: 0; } - .row button { width: auto; } - small { color: #666; } - .options { margin: 20px 0; padding: 14px; background: #0b62d6; border-radius: 8px; border: 1px solid #e5e5e5; } - .options-title { font-weight: 600; font-size: .85rem; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; } - ul { list-style: none; padding: 0; margin: 0; } - li { margin-bottom: 10px; } - li:last-child { margin-bottom: 0; } - li label { font-size: .85rem; } - li input[type="text"] { margin: 6px 0 0; font-size: .9rem; } - .range-nested { margin-top: 10px; padding: 10px; background: rgba(255,255,255,0.1); border-radius: 6px; } - .range-nested li { margin-bottom: 8px; } - .range-nested li:last-child { margin-bottom: 0; } - .range-nested label { font-size: .8rem; opacity: 0.9; } - .range-nested input[type="text"] { padding: 8px; font-size: .85rem; } - ` - const html = ` - - - - R2 Demo — One Page - - - - -
-

R2 Demo

- All-in-one page -
- -
-
-

Upload

- - - - - - - - - -
-
Optional Parameters
-
    -
  • - -
      -
    • - - -
    • -
    • - - -
    • -
    • - - -
    • -
    • - - -
    • -
    • - - -
    • -
    -
  • -
-
- - - -
- -
-

Set

-
- - - - - - - - -
-
Optional Parameters
-
    -
  • - -
      -
    • - - -
    • -
    • - - -
    • -
    • - - -
    • -
    • - - -
    • -
    • - - -
    • -
    -
  • -
-
- -
- -
- -
-

Get / View

-
- - -
-
Optional Parameters
-
    -
  • - -
      -
    • - - -
    • -
    • - - -
    • -
    • - - -
    • -
    • - - -
    • -
    -
  • -
  • - -
      -
    • - - -
    • -
    • - - -
    • -
    • - - -
    • -
    -
  • -
  • - - -
  • -
-
- -
- Opens file in a new tab. -
- -
-

Delete

-
- - -
- -
- -
-

Has

-
- - -
- -
- -
-

List

-
- - - - - - - -
-

-    
-
- - - -` - return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } }) - } - - // Upload - if (url.pathname === '/put' && request.method === 'PUT') { - const form = await request.formData() - const key = form.get('key')?.toString() - const file = form.get('file') as File | null - if (!key || !file) return new Response('Missing key or file', { status: 400 }) - const options: any = {} - const storageClass = form.get('storage-class')?.toString() - const ssecKey = form.get('ssec-key-put')?.toString() - const md5 = form.get('checksum-md5')?.toString() - const sha1 = form.get('checksum-sha1')?.toString() - const sha256 = form.get('checksum-sha256')?.toString() - const sha384 = form.get('checksum-sha384')?.toString() - const sha512 = form.get('checksum-sha512')?.toString() - if (storageClass) options.storageClass = storageClass - if (ssecKey) options.ssecKey = ssecKey - if (md5) options.md5 = md5 - if (sha1) options.sha1 = sha1 - if (sha256) options.sha256 = sha256 - if (sha384) options.sha384 = sha384 - if (sha512) options.sha512 = sha512 - const uploadedFile = await storage.put(key, file, options) - return new Response(uploadedFile, { - headers: { - 'Content-Type': uploadedFile.type, - 'Content-Length': String(uploadedFile.size), - } - }) - } - - // Set - if (url.pathname === '/set' && request.method === 'PUT') { - const form = await request.formData() - const key = form.get('key')?.toString() - const file = form.get('file') as File | null - if (!key || !file) return new Response('Missing key or file', { status: 400 }) - const options: any = {} - const storageClass = form.get('storage-class')?.toString() - const ssecKey = form.get('ssec-key-put')?.toString() - const md5 = form.get('checksum-md5')?.toString() - const sha1 = form.get('checksum-sha1')?.toString() - const sha256 = form.get('checksum-sha256')?.toString() - const sha384 = form.get('checksum-sha384')?.toString() - const sha512 = form.get('checksum-sha512')?.toString() - if (storageClass) options.storageClass = storageClass - if (ssecKey) options.ssecKey = ssecKey - if (md5) options.md5 = md5 - if (sha1) options.sha1 = sha1 - if (sha256) options.sha256 = sha256 - if (sha384) options.sha384 = sha384 - if (sha512) options.sha512 = sha512 - const hasOptions = storageClass || ssecKey || md5 || sha1 || sha256 || sha384 || sha512 - await storage.set(key, file, hasOptions ? options : undefined) - return new Response('uploaded') - } - - // Delete - if (url.pathname === '/remove' && request.method === 'DELETE') { - const key = url.searchParams.get('key') - if (!key) return new Response('Missing key', { status: 400 }) - - try { - await storage.remove(key) - return new Response('ok') - } catch { - return new Response('File not found', { status: 404 }) - } - } - - // Has - if (url.pathname === '/has' && request.method === 'GET') { - const key = url.searchParams.get('key') - if (!key) { - return new Response('Missing key', { status: 400 }) - } - const exists = await storage.has(key) - return new Response(exists ? 'exists' : 'missing', { status: exists ? 200 : 404 }) - } - - // List - if (url.pathname === '/list' && request.method === 'GET') { - const limit = url.searchParams.get('limit') - const cursor = url.searchParams.get('cursor') - const prefix = url.searchParams.get('prefix') - const options: any = {} - if (limit) options.limit = parseInt(limit) - if (cursor) options.cursor = cursor - if (prefix) options.prefix = prefix - const result = await storage.list(options) - return new Response(JSON.stringify(result, null, 2), { headers: { 'Content-Type': 'application/json' } }) - } - - - // get - if (request.method === 'GET' && url.pathname !== '/') { - const key = decodeURIComponent(url.pathname.slice(1)) - let options: any = {} - - const etagMatch = url.searchParams.get('etag-match') - const etagNoneMatch = url.searchParams.get('etag-none-match') - const uploadedBefore = url.searchParams.get('uploaded-before') - const uploadedAfter = url.searchParams.get('uploaded-after') - - if (etagMatch || etagNoneMatch || uploadedBefore || uploadedAfter) { - options.onlyIf = {} - if (etagMatch) options.onlyIf.etagMatches = etagMatch // Changed: etagMatches (plural) - if (etagNoneMatch) options.onlyIf.etagDoesNotMatch = etagNoneMatch // Changed: etagDoesNotMatch - if (uploadedBefore) options.onlyIf.uploadedBefore = new Date(uploadedBefore) - if (uploadedAfter) options.onlyIf.uploadedAfter = new Date(uploadedAfter) - } - - - const rangeOffset = url.searchParams.get('range-offset') - const rangeLength = url.searchParams.get('range-length') - const rangeSuffix = url.searchParams.get('range-suffix') - - if (rangeSuffix) { - options.range = { suffix: parseInt(rangeSuffix) } - } else if (rangeOffset || rangeLength) { - options.range = {} - if (rangeOffset) options.range.offset = parseInt(rangeOffset) - if (rangeLength) options.range.length = parseInt(rangeLength) - } - - const ssecKey = url.searchParams.get('ssec-key') - if (ssecKey) { - options.ssecKey = ssecKey - } - - const file = await storage.get(key, options) - if (!file) return new Response('File not found', { status: 404 }) - return new Response(file, { - headers: { - 'Content-Type': file.type, - 'Content-Length': String(file.size), - }, - }) - } - - return new Response('Not found', { status: 404 }) - }, -} diff --git a/demos/r2-demo/vite.config.ts b/demos/r2-demo/vite.config.ts deleted file mode 100644 index 500bc5b56af..00000000000 --- a/demos/r2-demo/vite.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from "vite"; -import { cloudflare } from "@cloudflare/vite-plugin"; - -export default defineConfig({ - plugins: [cloudflare()], -}); \ No newline at end of file diff --git a/demos/r2-demo/wrangler.jsonc b/demos/r2-demo/wrangler.jsonc deleted file mode 100644 index 3d45d8bd230..00000000000 --- a/demos/r2-demo/wrangler.jsonc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "$schema": "./node_modules/wrangler/config-schema.json", - "name": "cloudflare-vite-get-started", - "compatibility_date": "2025-04-03", - "main": "./src/index.ts", - "r2_buckets": [ - { - "bucket_name": "r2-demo", - "binding": "r2_demo", - "remote": true - }, - { - "bucket_name": "r2-demo-local", - "binding": "r2_demo_local", - "remote": true - } - ], -} \ No newline at end of file diff --git a/packages/file-storage/CHANGELOG.md b/packages/file-storage/CHANGELOG.md index 54a4cc5f0e5..89c309200ab 100644 --- a/packages/file-storage/CHANGELOG.md +++ b/packages/file-storage/CHANGELOG.md @@ -2,6 +2,12 @@ This is the changelog for [`file-storage`](https://github.com/remix-run/remix/tree/main/packages/file-storage). It follows [semantic versioning](https://semver.org/). +## Unreleased + +- Add support for Cloudflare R2 buckets through `r2-file-storage.ts`. +- Added tests for `r2-file-storage.ts` located in `r2-file-storage.test.ts`. +- Updated `README.MD` inside of `file-storage` with docs and examples pertaining to `R2FileStorage`. + ## v0.10.0 (2025-10-22) - BREAKING CHANGE: Removed CommonJS build. This package is now ESM-only. If you need to use this package in a CommonJS project, you will need to use dynamic `import()`. diff --git a/packages/file-storage/README.md b/packages/file-storage/README.md index e0f550b61d8..f857e8fae59 100644 --- a/packages/file-storage/README.md +++ b/packages/file-storage/README.md @@ -73,6 +73,110 @@ class CustomFileStorage implements FileStorage { } ``` +## Cloudflare R2 + +Use the `R2FileStorage` provides an adapter to store and retrieve files from a Cloudflare R2 bucket from Workers. It uses the `FileStorage` interface and types from Cloudflare. + +### Documentation +https://developers.cloudflare.com/r2/api/workers/workers-api-reference/#r2object-definition + +## USAGE + +```ts +import { R2FileStorage } from '@remix-run/file-storage/r2' + +// Pass the bucket that will be used +let storage = new R2FileStorage(env.MY_BUCKET) + +let file = new File(['hello world'], 'hello.txt', { type: 'text/plain' }) +let key = 'user123/hello.txt' + +// To check if R2 has file +await.storage.has(key) + +// To set a file in R2 +await storage.set(key,file) + +// To get a file from R2 +await storage.get(key) + + +// To upload file +let uploadedFile = await storage.put(key, file) + +// response will return a file +return new Response(uploadedFile, { + headers: { + 'Content-Type': uploadedFile.type, + 'Content-Length': String(uploadedFile.size), + } +}) + +// To delete a file +await storage.remove(key) +``` + +### Listing + +```ts +// Keys only +let a = await storage.list({ prefix: 'user123/' }) + +// Include metadata for each file +let b = await storage.list({ prefix: 'user123/', includeMetadata: true }) +// b.files: [{ key, lastModified, name, size, type }, ...] + +// Paginate with cursor +if (b.cursor !== undefined) { + let c = await storage.list({ cursor: b.cursor }) +} +``` + +## Options (R2) + +`R2FileStorage` supports Cloudflare R2 options on `get`, `list` and `set`/`put`. Refer to the Cloudflare documentation to learn what these options are. + +### Documentation +https://developers.cloudflare.com/r2/api/workers/workers-api-reference/#method-specific-types + + +### Examples +```ts +// Conditional + ranged GET +await storage.get(key, { + onlyIf: { + etagMatches: '"abc123"', + uploadedAfter: new Date(Date.now() - 60_000), + }, + range: { offset: 0, length: 1024 }, +}) + +// Checksums, encryption, and metadata on PUT/SET +await storage.set(key, file, { + httpMetadata: { contentType: file.type }, + sha256: new Uint8Array([/* ... */]), + storageCLass: 'InfrequentAccess' + customMetadata: { tag: 'docs' }, +}) +``` + +Notes: +- `set` merges default metadata with your options. Defaults include `httpMetadata.contentType` and `customMetadata` for `name`, `lastModified`, and `size`. Your provided `httpMetadata`/`customMetadata` override defaults if the same fields are set. +- `put` returns a new `File` whose `name` is the key. `get` returns a `File` whose `name` is the original filename (stored in metadata) when available. +- `list({ includeMetadata: true })` returns file metadata populated from R2 `httpMetadata`/`customMetadata`. + +### Environment + +`R2FileStorage` runs in Cloudflare Workers (or compatible environments) where an `R2Bucket` binding is available. For TypeScript, install `@cloudflare/workers-types` and declare your env binding: + +```ts +import type { R2Bucket } from '@cloudflare/workers-types' + +interface Env { + MY_BUCKET: R2Bucket +} +``` + ## Related Packages - [`form-data-parser`](https://github.com/remix-run/remix/tree/main/packages/form-data-parser) - Pairs well with this library for storing `FileUpload` objects received in `multipart/form-data` requests