Merge pull request #148 from syumai/move-r2-into-cloudflare-r2-package

move r2 features to cloudflare/r2 package
This commit is contained in:
syumai 2025-01-12 22:59:27 +09:00 committed by GitHub
commit d32d473c81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 337 additions and 303 deletions

View File

@ -8,7 +8,7 @@ import (
"strings"
"github.com/syumai/workers"
"github.com/syumai/workers/cloudflare"
"github.com/syumai/workers/cloudflare/r2"
)
// bucketName is R2 bucket name defined in wrangler.toml.
@ -23,8 +23,8 @@ func handleErr(w http.ResponseWriter, msg string, err error) {
type server struct{}
func (s *server) bucket() (*cloudflare.R2Bucket, error) {
return cloudflare.NewR2Bucket(bucketName)
func (s *server) bucket() (*r2.Bucket, error) {
return r2.NewBucket(bucketName)
}
func (s *server) post(w http.ResponseWriter, req *http.Request, key string) {
@ -45,8 +45,8 @@ func (s *server) post(w http.ResponseWriter, req *http.Request, key string) {
return
}
}
_, err = bucket.Put(key, req.Body, &cloudflare.R2PutOptions{
HTTPMetadata: cloudflare.R2HTTPMetadata{
_, err = bucket.Put(key, req.Body, &r2.PutOptions{
HTTPMetadata: r2.HTTPMetadata{
ContentType: req.Header.Get("Content-Type"),
},
CustomMetadata: map[string]string{"custom-key": "custom-value"},

View File

@ -8,7 +8,7 @@ import (
"strings"
"github.com/syumai/workers"
"github.com/syumai/workers/cloudflare"
"github.com/syumai/workers/cloudflare/r2"
)
// bucketName is R2 bucket name defined in wrangler.toml.
@ -23,7 +23,7 @@ func handleErr(w http.ResponseWriter, msg string, err error) {
// This example is based on implementation in syumai/workers-playground
// - https://github.com/syumai/workers-playground/blob/e32881648ccc055e3690a0d9c750a834261c333e/r2-image-viewer/src/index.ts#L30
func handler(w http.ResponseWriter, req *http.Request) {
bucket, err := cloudflare.NewR2Bucket(bucketName)
bucket, err := r2.NewBucket(bucketName)
if err != nil {
handleErr(w, "failed to get R2Bucket\n", err)
return

31
cloudflare/r2.go Normal file
View File

@ -0,0 +1,31 @@
package cloudflare
import (
"github.com/syumai/workers/cloudflare/r2"
)
// R2Bucket represents interface of Cloudflare Worker's R2 Bucket instance.
// Deprecated: use r2.Bucket instead.
type R2Bucket = r2.Bucket
// NewR2Bucket returns R2Bucket for given variable name.
// Deprecated: use r2.NewBucket instead.
func NewR2Bucket(varName string) (*R2Bucket, error) {
return r2.NewBucket(varName)
}
// R2PutOptions represents Cloudflare R2 put options.
// Deprecated: use r2.PutOptions instead.
type R2PutOptions = r2.PutOptions
// R2Object represents Cloudflare R2 object.
// Deprecated: use r2.Object instead.
type R2Object = r2.Object
// R2HTTPMetadata represents metadata of R2Object.
// Deprecated: use r2.HTTPMetadata instead.
type R2HTTPMetadata = r2.HTTPMetadata
// R2Objects represents Cloudflare R2 objects.
// Deprecated: use r2.Objects instead.
type R2Objects = r2.Objects

135
cloudflare/r2/bucket.go Normal file
View File

@ -0,0 +1,135 @@
package r2
import (
"fmt"
"io"
"syscall/js"
"github.com/syumai/workers/cloudflare/internal/cfruntimecontext"
"github.com/syumai/workers/internal/jsutil"
)
// Bucket represents interface of Cloudflare Worker's R2 Bucket instance.
// - https://developers.cloudflare.com/r2/runtime-apis/#bucket-method-definitions
// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1006
type Bucket struct {
instance js.Value
}
// NewBucket returns Bucket for given variable name.
// - variable name must be defined in wrangler.toml.
// - see example: https://github.com/syumai/workers/tree/main/_examples/r2-image-viewer
// - if the given variable name doesn't exist on runtime context, returns error.
// - This function panics when a runtime context is not found.
func NewBucket(varName string) (*Bucket, error) {
inst := cfruntimecontext.MustGetRuntimeContextEnv().Get(varName)
if inst.IsUndefined() {
return nil, fmt.Errorf("%s is undefined", varName)
}
return &Bucket{instance: inst}, nil
}
// Head returns the result of `head` call to Bucket.
// - Body field of *Object is always nil for Head call.
// - if the object for given key doesn't exist, returns nil.
// - if a network error happens, returns error.
func (r *Bucket) Head(key string) (*Object, error) {
p := r.instance.Call("head", key)
v, err := jsutil.AwaitPromise(p)
if err != nil {
return nil, err
}
if v.IsNull() {
return nil, nil
}
return toObject(v)
}
// Get returns the result of `get` call to Bucket.
// - 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) {
p := r.instance.Call("get", key)
v, err := jsutil.AwaitPromise(p)
if err != nil {
return nil, err
}
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
}
// 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) {
// fetch body cannot be ReadableStream. see: https://github.com/whatwg/fetch/issues/1438
b, err := io.ReadAll(value)
if err != nil {
return nil, err
}
defer value.Close()
ua := jsutil.NewUint8Array(len(b))
js.CopyBytesToJS(ua, b)
p := r.instance.Call("put", key, ua.Get("buffer"), opts.toJS())
v, err := jsutil.AwaitPromise(p)
if err != nil {
return nil, err
}
return toObject(v)
}
// Delete returns the result of `delete` call to Bucket.
// - if a network error happens, returns error.
func (r *Bucket) Delete(key string) error {
p := r.instance.Call("delete", key)
if _, err := jsutil.AwaitPromise(p); err != nil {
return err
}
return nil
}
// List returns the result of `list` call to Bucket.
// - if a network error happens, returns error.
func (r *Bucket) List() (*Objects, error) {
p := r.instance.Call("list")
v, err := jsutil.AwaitPromise(p)
if err != nil {
return nil, err
}
return toObjects(v)
}

120
cloudflare/r2/object.go Normal file
View File

@ -0,0 +1,120 @@
package r2
import (
"errors"
"fmt"
"io"
"syscall/js"
"time"
"github.com/syumai/workers/internal/jsutil"
)
// Object represents Cloudflare R2 object.
// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1094
type Object struct {
instance js.Value
Key string
Version string
Size int
ETag string
HTTPETag string
Uploaded time.Time
HTTPMetadata HTTPMetadata
CustomMetadata map[string]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) {
// }
func (o *Object) BodyUsed() (bool, error) {
v := o.instance.Get("bodyUsed")
if v.IsUndefined() {
return false, errors.New("bodyUsed doesn't exist for this Object")
}
return v.Bool(), nil
}
// 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) {
uploaded, err := jsutil.DateToTime(v.Get("uploaded"))
if err != nil {
return nil, fmt.Errorf("error converting uploaded: %w", err)
}
r2Meta, err := toHTTPMetadata(v.Get("httpMetadata"))
if err != nil {
return nil, fmt.Errorf("error converting httpMetadata: %w", err)
}
bodyVal := v.Get("body")
var body io.Reader
if !bodyVal.IsUndefined() {
body = jsutil.ConvertReadableStreamToReadCloser(v.Get("body"))
}
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(),
Uploaded: uploaded,
HTTPMetadata: r2Meta,
CustomMetadata: jsutil.StrRecordToMap(v.Get("customMetadata")),
Body: body,
}, nil
}
// HTTPMetadata represents metadata of Object.
// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1053
type HTTPMetadata struct {
ContentType string
ContentLanguage string
ContentDisposition string
ContentEncoding string
CacheControl string
CacheExpiry time.Time
}
func toHTTPMetadata(v js.Value) (HTTPMetadata, error) {
if v.IsUndefined() || v.IsNull() {
return HTTPMetadata{}, nil
}
cacheExpiry, err := jsutil.MaybeDate(v.Get("cacheExpiry"))
if err != nil {
return HTTPMetadata{}, fmt.Errorf("error converting cacheExpiry: %w", err)
}
return HTTPMetadata{
ContentType: jsutil.MaybeString(v.Get("contentType")),
ContentLanguage: jsutil.MaybeString(v.Get("contentLanguage")),
ContentDisposition: jsutil.MaybeString(v.Get("contentDisposition")),
ContentEncoding: jsutil.MaybeString(v.Get("contentEncoding")),
CacheControl: jsutil.MaybeString(v.Get("cacheControl")),
CacheExpiry: cacheExpiry,
}, nil
}
func (md *HTTPMetadata) toJS() js.Value {
obj := jsutil.NewObject()
kv := map[string]string{
"contentType": md.ContentType,
"contentLanguage": md.ContentLanguage,
"contentDisposition": md.ContentDisposition,
"contentEncoding": md.ContentEncoding,
"cacheControl": md.CacheControl,
}
for k, v := range kv {
if v != "" {
obj.Set(k, v)
}
}
if !md.CacheExpiry.IsZero() {
obj.Set("cacheExpiry", jsutil.TimeToDate(md.CacheExpiry))
}
return obj
}

44
cloudflare/r2/objects.go Normal file
View File

@ -0,0 +1,44 @@
package r2
import (
"fmt"
"syscall/js"
"github.com/syumai/workers/internal/jsutil"
)
// Objects represents Cloudflare R2 objects.
// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1121
type Objects struct {
Objects []*Object
Truncated bool
// Cursor indicates next cursor of Objects.
// - This becomes empty string if cursor doesn't exist.
Cursor string
DelimitedPrefixes []string
}
// toObjects converts JavaScript side's Objects to *Objects.
// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1121
func toObjects(v js.Value) (*Objects, error) {
objectsVal := v.Get("objects")
objects := make([]*Object, objectsVal.Length())
for i := 0; i < len(objects); i++ {
obj, err := toObject(objectsVal.Index(i))
if err != nil {
return nil, fmt.Errorf("error converting to Object: %w", err)
}
objects[i] = obj
}
prefixesVal := v.Get("delimitedPrefixes")
prefixes := make([]string, prefixesVal.Length())
for i := 0; i < len(prefixes); i++ {
prefixes[i] = prefixesVal.Index(i).String()
}
return &Objects{
Objects: objects,
Truncated: v.Get("truncated").Bool(),
Cursor: jsutil.MaybeString(v.Get("cursor")),
DelimitedPrefixes: prefixes,
}, nil
}

View File

@ -1,135 +1 @@
package cloudflare
import (
"fmt"
"io"
"syscall/js"
"github.com/syumai/workers/cloudflare/internal/cfruntimecontext"
"github.com/syumai/workers/internal/jsutil"
)
// R2Bucket represents interface of Cloudflare Worker's R2 Bucket instance.
// - https://developers.cloudflare.com/r2/runtime-apis/#bucket-method-definitions
// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1006
type R2Bucket struct {
instance js.Value
}
// NewR2Bucket returns R2Bucket for given variable name.
// - variable name must be defined in wrangler.toml.
// - see example: https://github.com/syumai/workers/tree/main/_examples/r2-image-viewer
// - if the given variable name doesn't exist on runtime context, returns error.
// - This function panics when a runtime context is not found.
func NewR2Bucket(varName string) (*R2Bucket, error) {
inst := cfruntimecontext.MustGetRuntimeContextEnv().Get(varName)
if inst.IsUndefined() {
return nil, fmt.Errorf("%s is undefined", varName)
}
return &R2Bucket{instance: inst}, nil
}
// Head returns the result of `head` call to R2Bucket.
// - Body field of *R2Object is always nil for Head call.
// - if the object for given key doesn't exist, returns nil.
// - if a network error happens, returns error.
func (r *R2Bucket) Head(key string) (*R2Object, error) {
p := r.instance.Call("head", key)
v, err := jsutil.AwaitPromise(p)
if err != nil {
return nil, err
}
if v.IsNull() {
return nil, nil
}
return toR2Object(v)
}
// Get returns the result of `get` call to R2Bucket.
// - if the object for given key doesn't exist, returns nil.
// - if a network error happens, returns error.
func (r *R2Bucket) Get(key string) (*R2Object, error) {
p := r.instance.Call("get", key)
v, err := jsutil.AwaitPromise(p)
if err != nil {
return nil, err
}
if v.IsNull() {
return nil, nil
}
return toR2Object(v)
}
// R2PutOptions represents Cloudflare R2 put options.
// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1128
type R2PutOptions struct {
HTTPMetadata R2HTTPMetadata
CustomMetadata map[string]string
MD5 string
}
func (opts *R2PutOptions) toJS() js.Value {
if opts == nil {
return js.Undefined()
}
obj := jsutil.NewObject()
if opts.HTTPMetadata != (R2HTTPMetadata{}) {
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
}
// Put returns the result of `put` call to R2Bucket.
// - This method copies all bytes into memory for implementation restriction.
// - Body field of *R2Object is always nil for Put call.
// - if a network error happens, returns error.
func (r *R2Bucket) Put(key string, value io.ReadCloser, opts *R2PutOptions) (*R2Object, error) {
// fetch body cannot be ReadableStream. see: https://github.com/whatwg/fetch/issues/1438
b, err := io.ReadAll(value)
if err != nil {
return nil, err
}
defer value.Close()
ua := jsutil.NewUint8Array(len(b))
js.CopyBytesToJS(ua, b)
p := r.instance.Call("put", key, ua.Get("buffer"), opts.toJS())
v, err := jsutil.AwaitPromise(p)
if err != nil {
return nil, err
}
return toR2Object(v)
}
// Delete returns the result of `delete` call to R2Bucket.
// - if a network error happens, returns error.
func (r *R2Bucket) Delete(key string) error {
p := r.instance.Call("delete", key)
if _, err := jsutil.AwaitPromise(p); err != nil {
return err
}
return nil
}
// List returns the result of `list` call to R2Bucket.
// - if a network error happens, returns error.
func (r *R2Bucket) List() (*R2Objects, error) {
p := r.instance.Call("list")
v, err := jsutil.AwaitPromise(p)
if err != nil {
return nil, err
}
return toR2Objects(v)
}

View File

@ -1,120 +1 @@
package cloudflare
import (
"errors"
"fmt"
"io"
"syscall/js"
"time"
"github.com/syumai/workers/internal/jsutil"
)
// R2Object represents Cloudflare R2 object.
// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1094
type R2Object struct {
instance js.Value
Key string
Version string
Size int
ETag string
HTTPETag string
Uploaded time.Time
HTTPMetadata R2HTTPMetadata
CustomMetadata map[string]string
// Body is a body of R2Object.
// 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 *R2Object) WriteHTTPMetadata(headers http.Header) {
// }
func (o *R2Object) BodyUsed() (bool, error) {
v := o.instance.Get("bodyUsed")
if v.IsUndefined() {
return false, errors.New("bodyUsed doesn't exist for this R2Object")
}
return v.Bool(), nil
}
// toR2Object converts JavaScript side's R2Object to *R2Object.
// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1094
func toR2Object(v js.Value) (*R2Object, error) {
uploaded, err := jsutil.DateToTime(v.Get("uploaded"))
if err != nil {
return nil, fmt.Errorf("error converting uploaded: %w", err)
}
r2Meta, err := toR2HTTPMetadata(v.Get("httpMetadata"))
if err != nil {
return nil, fmt.Errorf("error converting httpMetadata: %w", err)
}
bodyVal := v.Get("body")
var body io.Reader
if !bodyVal.IsUndefined() {
body = jsutil.ConvertReadableStreamToReadCloser(v.Get("body"))
}
return &R2Object{
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(),
Uploaded: uploaded,
HTTPMetadata: r2Meta,
CustomMetadata: jsutil.StrRecordToMap(v.Get("customMetadata")),
Body: body,
}, nil
}
// R2HTTPMetadata represents metadata of R2Object.
// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1053
type R2HTTPMetadata struct {
ContentType string
ContentLanguage string
ContentDisposition string
ContentEncoding string
CacheControl string
CacheExpiry time.Time
}
func toR2HTTPMetadata(v js.Value) (R2HTTPMetadata, error) {
if v.IsUndefined() || v.IsNull() {
return R2HTTPMetadata{}, nil
}
cacheExpiry, err := jsutil.MaybeDate(v.Get("cacheExpiry"))
if err != nil {
return R2HTTPMetadata{}, fmt.Errorf("error converting cacheExpiry: %w", err)
}
return R2HTTPMetadata{
ContentType: jsutil.MaybeString(v.Get("contentType")),
ContentLanguage: jsutil.MaybeString(v.Get("contentLanguage")),
ContentDisposition: jsutil.MaybeString(v.Get("contentDisposition")),
ContentEncoding: jsutil.MaybeString(v.Get("contentEncoding")),
CacheControl: jsutil.MaybeString(v.Get("cacheControl")),
CacheExpiry: cacheExpiry,
}, nil
}
func (md *R2HTTPMetadata) toJS() js.Value {
obj := jsutil.NewObject()
kv := map[string]string{
"contentType": md.ContentType,
"contentLanguage": md.ContentLanguage,
"contentDisposition": md.ContentDisposition,
"contentEncoding": md.ContentEncoding,
"cacheControl": md.CacheControl,
}
for k, v := range kv {
if v != "" {
obj.Set(k, v)
}
}
if !md.CacheExpiry.IsZero() {
obj.Set("cacheExpiry", jsutil.TimeToDate(md.CacheExpiry))
}
return obj
}

View File

@ -1,44 +1 @@
package cloudflare
import (
"fmt"
"syscall/js"
"github.com/syumai/workers/internal/jsutil"
)
// R2Objects represents Cloudflare R2 objects.
// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1121
type R2Objects struct {
Objects []*R2Object
Truncated bool
// Cursor indicates next cursor of R2Objects.
// - This becomes empty string if cursor doesn't exist.
Cursor string
DelimitedPrefixes []string
}
// toR2Objects converts JavaScript side's R2Objects to *R2Objects.
// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1121
func toR2Objects(v js.Value) (*R2Objects, error) {
objectsVal := v.Get("objects")
objects := make([]*R2Object, objectsVal.Length())
for i := 0; i < len(objects); i++ {
obj, err := toR2Object(objectsVal.Index(i))
if err != nil {
return nil, fmt.Errorf("error converting to R2Object: %w", err)
}
objects[i] = obj
}
prefixesVal := v.Get("delimitedPrefixes")
prefixes := make([]string, prefixesVal.Length())
for i := 0; i < len(prefixes); i++ {
prefixes[i] = prefixesVal.Index(i).String()
}
return &R2Objects{
Objects: objects,
Truncated: v.Get("truncated").Bool(),
Cursor: jsutil.MaybeString(v.Get("cursor")),
DelimitedPrefixes: prefixes,
}, nil
}