diff --git a/_examples/r2-multipart-upload/.gitignore b/_examples/r2-multipart-upload/.gitignore new file mode 100644 index 00000000..aee7b7e0 --- /dev/null +++ b/_examples/r2-multipart-upload/.gitignore @@ -0,0 +1,3 @@ +build +node_modules +.wrangler diff --git a/_examples/r2-multipart-upload/Makefile b/_examples/r2-multipart-upload/Makefile new file mode 100644 index 00000000..db3ae666 --- /dev/null +++ b/_examples/r2-multipart-upload/Makefile @@ -0,0 +1,16 @@ +.PHONY: dev +dev: + wrangler dev + +.PHONY: build +build: + go run ../../cmd/workers-assets-gen -mode=go + GOOS=js GOARCH=wasm go build -o ./build/app.wasm ./... + +.PHONY: create-bucket +create-bucket: + wrangler r2 bucket create r2-multipart-upload + +.PHONY: deploy +deploy: + wrangler deploy diff --git a/_examples/r2-multipart-upload/README.md b/_examples/r2-multipart-upload/README.md new file mode 100644 index 00000000..1a1eecdb --- /dev/null +++ b/_examples/r2-multipart-upload/README.md @@ -0,0 +1,171 @@ +# R2 Multipart Upload Example + +This example demonstrates how to use the R2 multipart upload API with Cloudflare Workers. + +## Features + +- Initiate multipart uploads +- Upload individual parts +- Complete multipart uploads +- Abort multipart uploads +- Regular PUT/GET operations for comparison + +## API Endpoints + +### Multipart Upload Endpoints + +1. **Initiate Multipart Upload** + ``` + POST /multipart/initiate?key= + ``` + Returns: + ```json + { + "uploadId": "string", + "key": "string" + } + ``` + +2. **Upload Part** + ``` + PUT /multipart/upload?key=&uploadId=&partNumber= + Body: + ``` + Returns: + ```json + { + "partNumber": 1, + "etag": "string" + } + ``` + +3. **Complete Multipart Upload** + ``` + POST /multipart/complete?key=&uploadId= + Body: { + "parts": [ + { + "partNumber": 1, + "etag": "string" + } + ] + } + ``` + Returns: Object metadata + +4. **Abort Multipart Upload** + ``` + DELETE /multipart/abort?key=&uploadId= + ``` + +### Regular Object Operations + +1. **Upload Object** + ``` + PUT / + Body: + ``` + +2. **Download Object** + ``` + GET / + ``` + +## Usage Example + +### Using curl for multipart upload: + +```bash +# 1. Initiate multipart upload +RESPONSE=$(curl -X POST "https://your-worker.workers.dev/multipart/initiate?key=large-file.bin") +UPLOAD_ID=$(echo $RESPONSE | jq -r '.uploadId') + +# 2. Upload parts (split your file into parts first) +# For example, split a file into 10MB parts: +split -b 10m large-file.bin part- + +# Upload each part +curl -X PUT \ + --data-binary @part-aa \ + "https://your-worker.workers.dev/multipart/upload?key=large-file.bin&uploadId=$UPLOAD_ID&partNumber=1" \ + > part1.json + +curl -X PUT \ + --data-binary @part-ab \ + "https://your-worker.workers.dev/multipart/upload?key=large-file.bin&uploadId=$UPLOAD_ID&partNumber=2" \ + > part2.json + +# 3. Complete the upload +PARTS=$(jq -s '[.[] | {partNumber: .partNumber, etag: .etag}]' part*.json) +curl -X POST \ + -H "Content-Type: application/json" \ + --data "{\"parts\": $PARTS}" \ + "https://your-worker.workers.dev/multipart/complete?key=large-file.bin&uploadId=$UPLOAD_ID" + +# 4. Download the uploaded file +curl "https://your-worker.workers.dev/large-file.bin" -o downloaded-file.bin +``` + +### Using JavaScript: + +```javascript +// Example multipart upload client +async function multipartUpload(url, key, file) { + const PART_SIZE = 10 * 1024 * 1024; // 10MB + + // 1. Initiate multipart upload + const initResponse = await fetch(`${url}/multipart/initiate?key=${key}`, { + method: 'POST' + }); + const { uploadId } = await initResponse.json(); + + // 2. Upload parts + const parts = []; + const totalParts = Math.ceil(file.size / PART_SIZE); + + for (let i = 0; i < totalParts; i++) { + const start = i * PART_SIZE; + const end = Math.min(start + PART_SIZE, file.size); + const part = file.slice(start, end); + + const partResponse = await fetch( + `${url}/multipart/upload?key=${key}&uploadId=${uploadId}&partNumber=${i + 1}`, + { + method: 'PUT', + body: part + } + ); + + const partData = await partResponse.json(); + parts.push(partData); + + console.log(`Uploaded part ${i + 1}/${totalParts}`); + } + + // 3. Complete upload + const completeResponse = await fetch( + `${url}/multipart/complete?key=${key}&uploadId=${uploadId}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ parts }) + } + ); + + return await completeResponse.json(); +} +``` + +## Setup + +1. Copy this example directory +2. Update `wrangler.toml` with your R2 bucket configuration +3. Install dependencies: `go mod tidy` +4. Deploy: `make deploy` + +## Notes + +- Minimum part size is 5MB (except for the last part) +- Multipart uploads are automatically aborted after 7 days if not completed +- Parts can be uploaded in parallel for better performance +- Each part receives an ETag that must be provided when completing the upload diff --git a/_examples/r2-multipart-upload/go.mod b/_examples/r2-multipart-upload/go.mod new file mode 100644 index 00000000..88f71113 --- /dev/null +++ b/_examples/r2-multipart-upload/go.mod @@ -0,0 +1,7 @@ +module github.com/syumai/workers/_examples/r2-multipart-upload + +go 1.24 + +require github.com/syumai/workers v0.0.0-20240513123456-abcdef123456 + +replace github.com/syumai/workers => ../../ diff --git a/_examples/r2-multipart-upload/main.go b/_examples/r2-multipart-upload/main.go new file mode 100644 index 00000000..35837a4f --- /dev/null +++ b/_examples/r2-multipart-upload/main.go @@ -0,0 +1,314 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strconv" + "strings" + + "github.com/syumai/workers" + "github.com/syumai/workers/cloudflare/r2" +) + +// bucketName is R2 bucket name defined in wrangler.toml. +const bucketName = "BUCKET" + +// Constants for multipart upload +const ( + // 5MB - minimum size for multipart upload parts (except last part) + MinPartSize = 5 * 1024 * 1024 + // 10MB - default part size + DefaultPartSize = 10 * 1024 * 1024 +) + +type server struct{} + +func (s *server) bucket() (*r2.Bucket, error) { + return r2.NewBucket(bucketName) +} + +func handleErr(w http.ResponseWriter, msg string, err error) { + log.Printf("Error: %s - %v", msg, err) + w.WriteHeader(http.StatusInternalServerError) + w.Header().Set("Content-Type", "text/plain") + fmt.Fprintf(w, "%s: %v", msg, err) +} + +func handleJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +// InitiateMultipartUploadResponse represents the response for initiating a multipart upload +type InitiateMultipartUploadResponse struct { + UploadID string `json:"uploadId"` + Key string `json:"key"` +} + +// UploadPartResponse represents the response for uploading a part +type UploadPartResponse struct { + PartNumber int `json:"partNumber"` + ETag string `json:"etag"` +} + +// CompleteMultipartUploadRequest represents the request to complete a multipart upload +type CompleteMultipartUploadRequest struct { + Parts []r2.R2UploadedPart `json:"parts"` +} + +// Handle POST /multipart/initiate?key= +func (s *server) initiateMultipartUpload(w http.ResponseWriter, req *http.Request) { + key := req.URL.Query().Get("key") + if key == "" { + http.Error(w, "key parameter is required", http.StatusBadRequest) + return + } + + bucket, err := s.bucket() + if err != nil { + handleErr(w, "failed to initialize bucket", err) + return + } + + // Create multipart upload with metadata + multipartUpload, err := bucket.CreateMultipartUpload(key, &r2.R2MultipartOptions{ + HTTPMetadata: r2.HTTPMetadata{ + ContentType: "application/octet-stream", + }, + CustomMetadata: map[string]string{ + "uploaded-via": "multipart-api", + }, + }) + if err != nil { + handleErr(w, "failed to create multipart upload", err) + return + } + + handleJSON(w, http.StatusOK, InitiateMultipartUploadResponse{ + UploadID: multipartUpload.UploadID(), + Key: key, + }) +} + +// Handle PUT /multipart/upload?key=&uploadId=&partNumber= +func (s *server) uploadPart(w http.ResponseWriter, req *http.Request) { + key := req.URL.Query().Get("key") + uploadID := req.URL.Query().Get("uploadId") + partNumberStr := req.URL.Query().Get("partNumber") + + if key == "" || uploadID == "" || partNumberStr == "" { + http.Error(w, "key, uploadId, and partNumber parameters are required", http.StatusBadRequest) + return + } + + partNumber, err := strconv.Atoi(partNumberStr) + if err != nil || partNumber < 1 { + http.Error(w, "invalid partNumber", http.StatusBadRequest) + return + } + + bucket, err := s.bucket() + if err != nil { + handleErr(w, "failed to initialize bucket", err) + return + } + + // Resume the multipart upload + multipartUpload := bucket.ResumeMultipartUpload(key, uploadID) + + // Read the part data + partData, err := io.ReadAll(req.Body) + if err != nil { + handleErr(w, "failed to read part data", err) + return + } + defer req.Body.Close() + + // Upload the part + uploadedPart, err := multipartUpload.UploadPart(partNumber, partData) + if err != nil { + handleErr(w, "failed to upload part", err) + return + } + + handleJSON(w, http.StatusOK, UploadPartResponse{ + PartNumber: uploadedPart.PartNumber, + ETag: uploadedPart.ETag, + }) +} + +// Handle POST /multipart/complete?key=&uploadId= +func (s *server) completeMultipartUpload(w http.ResponseWriter, req *http.Request) { + key := req.URL.Query().Get("key") + uploadID := req.URL.Query().Get("uploadId") + + if key == "" || uploadID == "" { + http.Error(w, "key and uploadId parameters are required", http.StatusBadRequest) + return + } + + var completeReq CompleteMultipartUploadRequest + if err := json.NewDecoder(req.Body).Decode(&completeReq); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + bucket, err := s.bucket() + if err != nil { + handleErr(w, "failed to initialize bucket", err) + return + } + + // Resume the multipart upload + multipartUpload := bucket.ResumeMultipartUpload(key, uploadID) + + // Complete the multipart upload + object, err := multipartUpload.Complete(completeReq.Parts) + if err != nil { + handleErr(w, "failed to complete multipart upload", err) + return + } + + // Return the completed object info + handleJSON(w, http.StatusOK, map[string]interface{}{ + "key": object.Key, + "size": object.Size, + "etag": object.ETag, + "httpEtag": object.HTTPETag, + "uploaded": object.Uploaded, + "storageClass": object.StorageClass, + }) +} + +// Handle DELETE /multipart/abort?key=&uploadId= +func (s *server) abortMultipartUpload(w http.ResponseWriter, req *http.Request) { + key := req.URL.Query().Get("key") + uploadID := req.URL.Query().Get("uploadId") + + if key == "" || uploadID == "" { + http.Error(w, "key and uploadId parameters are required", http.StatusBadRequest) + return + } + + bucket, err := s.bucket() + if err != nil { + handleErr(w, "failed to initialize bucket", err) + return + } + + // Resume the multipart upload + multipartUpload := bucket.ResumeMultipartUpload(key, uploadID) + + // Abort the multipart upload + if err := multipartUpload.Abort(); err != nil { + handleErr(w, "failed to abort multipart upload", err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// Handle GET / - download object +func (s *server) getObject(w http.ResponseWriter, key string) { + bucket, err := s.bucket() + if err != nil { + handleErr(w, "failed to initialize bucket", err) + return + } + + obj, err := bucket.Get(key) + if err != nil { + handleErr(w, "failed to get object", err) + return + } + if obj == nil { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(fmt.Sprintf("object not found: %s", key))) + return + } + + // Set headers + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("ETag", fmt.Sprintf("W/%s", obj.HTTPETag)) + contentType := "application/octet-stream" + if obj.HTTPMetadata.ContentType != "" { + contentType = obj.HTTPMetadata.ContentType + } + w.Header().Set("Content-Type", contentType) + + // Copy object body to response + io.Copy(w, obj.Body) +} + +// Handle PUT / - simple upload (non-multipart) +func (s *server) putObject(w http.ResponseWriter, req *http.Request, key string) { + bucket, err := s.bucket() + if err != nil { + handleErr(w, "failed to initialize bucket", err) + return + } + + _, err = bucket.Put(key, req.Body, &r2.R2PutOptions{ + HTTPMetadata: r2.HTTPMetadata{ + ContentType: req.Header.Get("Content-Type"), + }, + }) + if err != nil { + handleErr(w, "failed to put object", err) + return + } + + w.WriteHeader(http.StatusCreated) + w.Write([]byte("successfully uploaded object")) +} + +func (s *server) ServeHTTP(w http.ResponseWriter, req *http.Request) { + path := req.URL.Path + + // Handle multipart upload endpoints + if strings.HasPrefix(path, "/multipart/") { + switch { + case path == "/multipart/initiate" && req.Method == "POST": + s.initiateMultipartUpload(w, req) + return + case path == "/multipart/upload" && req.Method == "PUT": + s.uploadPart(w, req) + return + case path == "/multipart/complete" && req.Method == "POST": + s.completeMultipartUpload(w, req) + return + case path == "/multipart/abort" && req.Method == "DELETE": + s.abortMultipartUpload(w, req) + return + } + } + + // Handle regular object operations + key := strings.TrimPrefix(path, "/") + if key == "" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("key is required")) + return + } + + switch req.Method { + case "GET": + s.getObject(w, key) + return + case "PUT": + s.putObject(w, req, key) + return + default: + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write([]byte("method not allowed")) + return + } +} + +func main() { + workers.Serve(&server{}) +} diff --git a/_examples/r2-multipart-upload/wrangler.toml b/_examples/r2-multipart-upload/wrangler.toml new file mode 100644 index 00000000..45c1cadd --- /dev/null +++ b/_examples/r2-multipart-upload/wrangler.toml @@ -0,0 +1,11 @@ +name = "r2-multipart-upload" +main = "./build/worker.mjs" +compatibility_date = "2022-05-13" + +[[r2_buckets]] +binding = "BUCKET" +bucket_name = "my-bucket" +preview_bucket_name = "my-bucket-dev" + +[build] +command = "make build" diff --git a/cloudflare/r2.go b/cloudflare/r2.go index 453c713d..8cbbea00 100644 --- a/cloudflare/r2.go +++ b/cloudflare/r2.go @@ -15,8 +15,8 @@ func NewR2Bucket(varName string) (*R2Bucket, error) { } // R2PutOptions represents Cloudflare R2 put options. -// Deprecated: use r2.PutOptions instead. -type R2PutOptions = r2.PutOptions +// Deprecated: use r2.R2PutOptions instead. +type R2PutOptions = r2.R2PutOptions // R2Object represents Cloudflare R2 object. // Deprecated: use r2.Object instead. diff --git a/cloudflare/r2/bucket.go b/cloudflare/r2/bucket.go index 50e932ac..b20effa9 100644 --- a/cloudflare/r2/bucket.go +++ b/cloudflare/r2/bucket.go @@ -46,9 +46,10 @@ func (r *Bucket) Head(key string) (*Object, error) { } // Get returns the result of `get` call to Bucket. +// - Returns ObjectBody which includes the object's body. // - if the object for given key doesn't exist, returns nil. // - if a network error happens, returns error. -func (r *Bucket) Get(key string) (*Object, error) { +func (r *Bucket) Get(key string) (*ObjectBody, error) { p := r.instance.Call("get", key) v, err := jsutil.AwaitPromise(p) if err != nil { @@ -57,46 +58,14 @@ func (r *Bucket) Get(key string) (*Object, error) { if v.IsNull() { return nil, nil } - return toObject(v) -} - -// PutOptions represents Cloudflare R2 put options. -// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1128 -type PutOptions struct { - HTTPMetadata HTTPMetadata - CustomMetadata map[string]string - MD5 string -} - -func (opts *PutOptions) toJS() js.Value { - if opts == nil { - return js.Undefined() - } - obj := jsutil.NewObject() - if opts.HTTPMetadata != (HTTPMetadata{}) { - obj.Set("httpMetadata", opts.HTTPMetadata.toJS()) - } - if opts.CustomMetadata != nil { - // convert map[string]string to map[string]any. - // This makes the map convertible to JS. - // see: https://pkg.go.dev/syscall/js#ValueOf - customMeta := make(map[string]any, len(opts.CustomMetadata)) - for k, v := range opts.CustomMetadata { - customMeta[k] = v - } - obj.Set("customMetadata", customMeta) - } - if opts.MD5 != "" { - obj.Set("md5", opts.MD5) - } - return obj + return toObjectBody(v) } // Put returns the result of `put` call to Bucket. // - This method copies all bytes into memory for implementation restriction. // - Body field of *Object is always nil for Put call. // - if a network error happens, returns error. -func (r *Bucket) Put(key string, value io.ReadCloser, opts *PutOptions) (*Object, error) { +func (r *Bucket) Put(key string, value io.ReadCloser, opts *R2PutOptions) (*Object, error) { // fetch body cannot be ReadableStream. see: https://github.com/whatwg/fetch/issues/1438 b, err := io.ReadAll(value) if err != nil { @@ -133,3 +102,28 @@ func (r *Bucket) List() (*Objects, error) { } return toObjects(v) } + +// CreateMultipartUpload creates a multipart upload. +// - Returns Promise which resolves to an R2MultipartUpload object representing the newly +// created multipart upload. Once the multipart upload has been created, the multipart +// upload can be immediately interacted with globally, either through the Workers API, or +// through the S3 API. +// - if a network error happens, returns error. +func (r *Bucket) CreateMultipartUpload(key string, options *R2MultipartOptions) (*R2MultipartUpload, error) { + p := r.instance.Call("createMultipartUpload", key, options.toJS()) + v, err := jsutil.AwaitPromise(p) + if err != nil { + return nil, err + } + return &R2MultipartUpload{instance: v}, nil +} + +// ResumeMultipartUpload returns an object representing a multipart upload with the given key and uploadId. +// - The resumeMultipartUpload operation does not perform any checks to ensure the +// validity of the uploadId, nor does it verify the existence of a corresponding active +// multipart upload. This is done to minimize latency before being able to call subsequent +// operations on the R2MultipartUpload object. +func (r *Bucket) ResumeMultipartUpload(key string, uploadId string) *R2MultipartUpload { + v := r.instance.Call("resumeMultipartUpload", key, uploadId) + return &R2MultipartUpload{instance: v} +} diff --git a/cloudflare/r2/multipart.go b/cloudflare/r2/multipart.go new file mode 100644 index 00000000..4ab21ed0 --- /dev/null +++ b/cloudflare/r2/multipart.go @@ -0,0 +1,122 @@ +package r2 + +import ( + "syscall/js" + + "github.com/syumai/workers/internal/jsutil" +) + +// R2MultipartOptions represents options for creating a multipart upload. +// According to the docs, R2MultipartOptions includes: +// - httpMetadata (optional) +// - customMetadata (optional) +// - storageClass (optional) +// - ssecKey (optional) +type R2MultipartOptions struct { + HTTPMetadata HTTPMetadata `json:"httpMetadata,omitempty"` + CustomMetadata map[string]string `json:"customMetadata,omitempty"` + StorageClass string `json:"storageClass,omitempty"` + SSECKey string `json:"ssecKey,omitempty"` +} + +func (opts *R2MultipartOptions) toJS() js.Value { + if opts == nil { + return js.Undefined() + } + obj := jsutil.NewObject() + if opts.HTTPMetadata != (HTTPMetadata{}) { + obj.Set("httpMetadata", opts.HTTPMetadata.toJS()) + } + if opts.CustomMetadata != nil { + // convert map[string]string to map[string]any. + // This makes the map convertible to JS. + // see: https://pkg.go.dev/syscall/js#ValueOf + customMeta := make(map[string]any, len(opts.CustomMetadata)) + for k, v := range opts.CustomMetadata { + customMeta[k] = v + } + obj.Set("customMetadata", customMeta) + } + if opts.StorageClass != "" { + obj.Set("storageClass", opts.StorageClass) + } + if opts.SSECKey != "" { + obj.Set("ssecKey", opts.SSECKey) + } + return obj +} + +// R2MultipartUpload represents an ongoing multipart upload. +type R2MultipartUpload struct { + instance js.Value +} + +// UploadID returns the upload ID of this multipart upload. +func (m *R2MultipartUpload) UploadID() string { + return m.instance.Get("uploadId").String() +} + +// Key returns the key of this multipart upload. +func (m *R2MultipartUpload) Key() string { + return m.instance.Get("key").String() +} + +// UploadPart uploads a part of the multipart upload. +// According to the docs: uploadPart(partNumber: number, value: ReadableStream | ArrayBuffer | ArrayBufferView | string | Blob, options?: R2MultipartOptions): Promise +func (m *R2MultipartUpload) UploadPart(partNumber int, data []byte, options ...*R2MultipartOptions) (*R2UploadedPart, error) { + ua := jsutil.NewUint8Array(len(data)) + js.CopyBytesToJS(ua, data) + + var p js.Value + if len(options) > 0 && options[0] != nil { + // Pass options if provided + p = m.instance.Call("uploadPart", partNumber, ua.Get("buffer"), options[0].toJS()) + } else { + // Call without options + p = m.instance.Call("uploadPart", partNumber, ua.Get("buffer")) + } + + v, err := jsutil.AwaitPromise(p) + if err != nil { + return nil, err + } + + // The returned object should have partNumber and etag fields + return &R2UploadedPart{ + PartNumber: jsutil.MaybeInt(v.Get("partNumber")), + ETag: jsutil.MaybeString(v.Get("etag")), + }, nil +} + +// R2UploadedPart represents a successfully uploaded part. +type R2UploadedPart struct { + PartNumber int `json:"partNumber"` + ETag string `json:"etag"` +} + +// Complete completes the multipart upload with the given parts. +func (m *R2MultipartUpload) Complete(parts []R2UploadedPart) (*Object, error) { + // Convert parts to JavaScript array + jsArray := jsutil.NewArray(len(parts)) + for i, part := range parts { + partObj := jsutil.NewObject() + partObj.Set("partNumber", part.PartNumber) + partObj.Set("etag", part.ETag) + jsArray.SetIndex(i, partObj) + } + + p := m.instance.Call("complete", jsArray) + v, err := jsutil.AwaitPromise(p) + if err != nil { + return nil, err + } + + return toObject(v) +} + +// Abort aborts the multipart upload. +func (m *R2MultipartUpload) Abort() error { + p := m.instance.Call("abort") + _, err := jsutil.AwaitPromise(p) + return err +} diff --git a/cloudflare/r2/object.go b/cloudflare/r2/object.go index 18d50453..b6ae3178 100644 --- a/cloudflare/r2/object.go +++ b/cloudflare/r2/object.go @@ -4,12 +4,29 @@ import ( "errors" "fmt" "io" + "net/http" "syscall/js" "time" "github.com/syumai/workers/internal/jsutil" ) +// R2Range represents the range of bytes returned for a request. +type R2Range struct { + Offset int + Length int + Suffix int +} + +// R2Checksums represents checksums for an R2 object. +type R2Checksums struct { + MD5 []byte + SHA1 []byte + SHA256 []byte + SHA384 []byte + SHA512 []byte +} + // Object represents Cloudflare R2 object. // - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1094 type Object struct { @@ -22,15 +39,36 @@ type Object struct { Uploaded time.Time HTTPMetadata HTTPMetadata CustomMetadata map[string]string + Range *R2Range + Checksums *R2Checksums + StorageClass string // 'Standard' | 'InfrequentAccess' + SSECKeyMD5 string // Body is a body of Object. // This value is nil for the result of the `Head` or `Put` method. Body io.Reader } -// TODO: implement -// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1106 -// func (o *Object) WriteHTTPMetadata(headers http.Header) { -// } +// WriteHTTPMetadata writes the HTTP metadata from the object to the given headers. +func (o *Object) WriteHTTPMetadata(headers http.Header) { + if o.HTTPMetadata.ContentType != "" { + headers.Set("Content-Type", o.HTTPMetadata.ContentType) + } + if o.HTTPMetadata.ContentLanguage != "" { + headers.Set("Content-Language", o.HTTPMetadata.ContentLanguage) + } + if o.HTTPMetadata.ContentDisposition != "" { + headers.Set("Content-Disposition", o.HTTPMetadata.ContentDisposition) + } + if o.HTTPMetadata.ContentEncoding != "" { + headers.Set("Content-Encoding", o.HTTPMetadata.ContentEncoding) + } + if o.HTTPMetadata.CacheControl != "" { + headers.Set("Cache-Control", o.HTTPMetadata.CacheControl) + } + if !o.HTTPMetadata.CacheExpiry.IsZero() { + headers.Set("Expires", o.HTTPMetadata.CacheExpiry.Format(http.TimeFormat)) + } +} func (o *Object) BodyUsed() (bool, error) { v := o.instance.Get("bodyUsed") @@ -43,6 +81,9 @@ func (o *Object) BodyUsed() (bool, error) { // toObject converts JavaScript side's Object to *Object. // - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1094 func toObject(v js.Value) (*Object, error) { + if v.IsUndefined() || v.IsNull() { + return nil, fmt.Errorf("object is undefined or null") + } uploaded, err := jsutil.DateToTime(v.Get("uploaded")) if err != nil { return nil, fmt.Errorf("error converting uploaded: %w", err) @@ -56,16 +97,54 @@ func toObject(v js.Value) (*Object, error) { if !bodyVal.IsUndefined() { body = jsutil.ConvertReadableStreamToReadCloser(v.Get("body")) } + + // Parse range if present + var r2Range *R2Range + rangeVal := v.Get("range") + if !rangeVal.IsUndefined() && !rangeVal.IsNull() { + r2Range = &R2Range{ + Offset: jsutil.MaybeInt(rangeVal.Get("offset")), + Length: jsutil.MaybeInt(rangeVal.Get("length")), + Suffix: jsutil.MaybeInt(rangeVal.Get("suffix")), + } + } + + // Parse checksums if present + var checksums *R2Checksums + checksumsVal := v.Get("checksums") + if !checksumsVal.IsUndefined() && !checksumsVal.IsNull() { + checksums = &R2Checksums{} + if md5 := checksumsVal.Get("md5"); !md5.IsUndefined() { + checksums.MD5 = jsutil.ArrayBufferToBytes(md5) + } + if sha1 := checksumsVal.Get("sha1"); !sha1.IsUndefined() { + checksums.SHA1 = jsutil.ArrayBufferToBytes(sha1) + } + if sha256 := checksumsVal.Get("sha256"); !sha256.IsUndefined() { + checksums.SHA256 = jsutil.ArrayBufferToBytes(sha256) + } + if sha384 := checksumsVal.Get("sha384"); !sha384.IsUndefined() { + checksums.SHA384 = jsutil.ArrayBufferToBytes(sha384) + } + if sha512 := checksumsVal.Get("sha512"); !sha512.IsUndefined() { + checksums.SHA512 = jsutil.ArrayBufferToBytes(sha512) + } + } + return &Object{ instance: v, - Key: v.Get("key").String(), - Version: v.Get("version").String(), - Size: v.Get("size").Int(), - ETag: v.Get("etag").String(), - HTTPETag: v.Get("httpEtag").String(), + Key: jsutil.MaybeString(v.Get("key")), + Version: jsutil.MaybeString(v.Get("version")), + Size: jsutil.MaybeInt(v.Get("size")), + ETag: jsutil.MaybeString(v.Get("etag")), + HTTPETag: jsutil.MaybeString(v.Get("httpEtag")), Uploaded: uploaded, HTTPMetadata: r2Meta, CustomMetadata: jsutil.StrRecordToMap(v.Get("customMetadata")), + Range: r2Range, + Checksums: checksums, + StorageClass: jsutil.MaybeString(v.Get("storageClass")), + SSECKeyMD5: jsutil.MaybeString(v.Get("ssecKeyMd5")), Body: body, }, nil } diff --git a/cloudflare/r2/objectbody.go b/cloudflare/r2/objectbody.go new file mode 100644 index 00000000..87a06d1b --- /dev/null +++ b/cloudflare/r2/objectbody.go @@ -0,0 +1,79 @@ +package r2 + +import ( + "encoding/json" + "io" + "syscall/js" + + "github.com/syumai/workers/internal/jsutil" +) + +// ObjectBody represents an object's metadata combined with its body. +// It is returned when you GET an object from an R2 bucket. +// ObjectBody extends Object with additional methods for reading the body. +type ObjectBody struct { + *Object +} + +// ArrayBuffer returns the body as an ArrayBuffer. +func (o *ObjectBody) ArrayBuffer() ([]byte, error) { + if o.instance.IsUndefined() { + return nil, ErrObjectBodyNotAvailable + } + + p := o.instance.Call("arrayBuffer") + v, err := jsutil.AwaitPromise(p) + if err != nil { + return nil, err + } + + return jsutil.ArrayBufferToBytes(v), nil +} + +// Text returns the body as a string. +func (o *ObjectBody) Text() (string, error) { + if o.instance.IsUndefined() { + return "", ErrObjectBodyNotAvailable + } + + p := o.instance.Call("text") + v, err := jsutil.AwaitPromise(p) + if err != nil { + return "", err + } + + return v.String(), nil +} + +// JSON decodes the body as JSON into the provided value. +func (o *ObjectBody) JSON(v any) error { + text, err := o.Text() + if err != nil { + return err + } + + return json.Unmarshal([]byte(text), v) +} + +// Blob returns the body as a Blob (JavaScript value). +func (o *ObjectBody) Blob() (js.Value, error) { + if o.instance.IsUndefined() { + return js.Undefined(), ErrObjectBodyNotAvailable + } + + p := o.instance.Call("blob") + return jsutil.AwaitPromise(p) +} + +// toObjectBody converts a JavaScript value to an ObjectBody. +func toObjectBody(v js.Value) (*ObjectBody, error) { + obj, err := toObject(v) + if err != nil { + return nil, err + } + + return &ObjectBody{Object: obj}, nil +} + +// ErrObjectBodyNotAvailable is returned when trying to access body methods on an object without a body. +var ErrObjectBodyNotAvailable = io.EOF diff --git a/cloudflare/r2/options.go b/cloudflare/r2/options.go new file mode 100644 index 00000000..c9845608 --- /dev/null +++ b/cloudflare/r2/options.go @@ -0,0 +1,207 @@ +package r2 + +import ( + "syscall/js" + "time" + + "github.com/syumai/workers/internal/jsutil" +) + +// R2GetOptions represents options for the get operation. +type R2GetOptions struct { + OnlyIf *R2Conditional + Range *R2Range + SSECKey string +} + +// R2Conditional represents conditional headers for R2 operations. +type R2Conditional struct { + EtagMatches string + EtagDoesNotMatch string + UploadedBefore *time.Time + UploadedAfter *time.Time +} + +// toJS converts R2Conditional to JavaScript value. +func (c *R2Conditional) toJS() js.Value { + if c == nil { + return js.Undefined() + } + obj := jsutil.NewObject() + if c.EtagMatches != "" { + obj.Set("etagMatches", c.EtagMatches) + } + if c.EtagDoesNotMatch != "" { + obj.Set("etagDoesNotMatch", c.EtagDoesNotMatch) + } + if c.UploadedBefore != nil { + obj.Set("uploadedBefore", jsutil.TimeToDate(*c.UploadedBefore)) + } + if c.UploadedAfter != nil { + obj.Set("uploadedAfter", jsutil.TimeToDate(*c.UploadedAfter)) + } + return obj +} + +// Update PutOptions to include all fields from documentation +type R2PutOptions struct { + OnlyIf *R2Conditional + HTTPMetadata HTTPMetadata + CustomMetadata map[string]string + MD5 string + SHA1 string + SHA256 string + SHA384 string + SHA512 string + StorageClass string // 'Standard' | 'InfrequentAccess' + SSECKey string +} + +func (opts *R2PutOptions) toJS() js.Value { + if opts == nil { + return js.Undefined() + } + obj := jsutil.NewObject() + + if opts.OnlyIf != nil { + obj.Set("onlyIf", opts.OnlyIf.toJS()) + } + + if opts.HTTPMetadata != (HTTPMetadata{}) { + obj.Set("httpMetadata", opts.HTTPMetadata.toJS()) + } + + if opts.CustomMetadata != nil { + // convert map[string]string to map[string]any. + customMeta := make(map[string]any, len(opts.CustomMetadata)) + for k, v := range opts.CustomMetadata { + customMeta[k] = v + } + obj.Set("customMetadata", customMeta) + } + + // Checksums - only one can be specified at a time + switch { + case opts.MD5 != "": + obj.Set("md5", opts.MD5) + case opts.SHA1 != "": + obj.Set("sha1", opts.SHA1) + case opts.SHA256 != "": + obj.Set("sha256", opts.SHA256) + case opts.SHA384 != "": + obj.Set("sha384", opts.SHA384) + case opts.SHA512 != "": + obj.Set("sha512", opts.SHA512) + } + + if opts.StorageClass != "" { + obj.Set("storageClass", opts.StorageClass) + } + + if opts.SSECKey != "" { + obj.Set("ssecKey", opts.SSECKey) + } + + return obj +} + +// R2ListOptions represents options for listing R2 objects. +type R2ListOptions struct { + Limit int + Prefix string + Cursor string + Delimiter string + Include []string // Can include "httpMetadata" and/or "customMetadata" +} + +func (opts *R2ListOptions) toJS() js.Value { + if opts == nil { + return js.Undefined() + } + obj := jsutil.NewObject() + + if opts.Limit > 0 { + obj.Set("limit", opts.Limit) + } + + if opts.Prefix != "" { + obj.Set("prefix", opts.Prefix) + } + + if opts.Cursor != "" { + obj.Set("cursor", opts.Cursor) + } + + if opts.Delimiter != "" { + obj.Set("delimiter", opts.Delimiter) + } + + if len(opts.Include) > 0 { + jsArray := jsutil.NewArray(len(opts.Include)) + for i, inc := range opts.Include { + jsArray.SetIndex(i, inc) + } + obj.Set("include", jsArray) + } + + return obj +} + +// GetWithOptions returns the result of `get` call to Bucket with options. +func (r *Bucket) GetWithOptions(key string, options *R2GetOptions) (*ObjectBody, error) { + var p js.Value + if options != nil { + optObj := jsutil.NewObject() + + if options.OnlyIf != nil { + optObj.Set("onlyIf", options.OnlyIf.toJS()) + } + + if options.Range != nil { + rangeObj := jsutil.NewObject() + if options.Range.Offset > 0 { + rangeObj.Set("offset", options.Range.Offset) + } + if options.Range.Length > 0 { + rangeObj.Set("length", options.Range.Length) + } + if options.Range.Suffix > 0 { + rangeObj.Set("suffix", options.Range.Suffix) + } + optObj.Set("range", rangeObj) + } + + if options.SSECKey != "" { + optObj.Set("ssecKey", options.SSECKey) + } + + p = r.instance.Call("get", key, optObj) + } else { + p = r.instance.Call("get", key) + } + + v, err := jsutil.AwaitPromise(p) + if err != nil { + return nil, err + } + if v.IsNull() { + return nil, nil + } + return toObjectBody(v) +} + +// ListWithOptions returns the result of `list` call to Bucket with options. +func (r *Bucket) ListWithOptions(options *R2ListOptions) (*Objects, error) { + var p js.Value + if options != nil { + p = r.instance.Call("list", options.toJS()) + } else { + p = r.instance.Call("list") + } + + v, err := jsutil.AwaitPromise(p) + if err != nil { + return nil, err + } + return toObjects(v) +} diff --git a/internal/jsutil/jsutil.go b/internal/jsutil/jsutil.go index 17424ef7..06068c6c 100644 --- a/internal/jsutil/jsutil.go +++ b/internal/jsutil/jsutil.go @@ -132,3 +132,20 @@ func DateToTime(v js.Value) (time.Time, error) { func TimeToDate(t time.Time) js.Value { return DateClass.New(t.UnixMilli()) } + +// ArrayBufferToBytes converts JavaScript ArrayBuffer to Go []byte. +func ArrayBufferToBytes(v js.Value) []byte { + if v.IsUndefined() || v.IsNull() { + return nil + } + + // Create a Uint8Array view of the ArrayBuffer + uint8Array := Uint8ArrayClass.New(v) + length := uint8Array.Get("length").Int() + + // Create Go byte slice and copy data + result := make([]byte, length) + js.CopyBytesToGo(result, uint8Array) + + return result +}