diff --git a/examples/r2-image-server/Makefile b/examples/r2-image-server/Makefile new file mode 100644 index 0000000..320ddc3 --- /dev/null +++ b/examples/r2-image-server/Makefile @@ -0,0 +1,12 @@ +.PHONY: dev +dev: + wrangler dev + +.PHONY: build +build: + mkdir -p dist + tinygo build -o ./dist/app.wasm -target wasm ./... + +.PHONY: publish +publish: + wrangler publish diff --git a/examples/r2-image-server/README.md b/examples/r2-image-server/README.md new file mode 100644 index 0000000..9bd46df --- /dev/null +++ b/examples/r2-image-server/README.md @@ -0,0 +1,52 @@ +# simple-json-server + +* A simple HTTP JSON server implemented in Go and compiled with tinygo. + +## Example + +* https://simple-json-server.syumai.workers.dev + +### Request + +``` +curl --location --request POST 'https://simple-json-server.syumai.workers.dev/hello' \ +--header 'Content-Type: application/json' \ +--data-raw '{ + "name": "syumai" +}' +``` + +### Response + +```json +{ + "message": "Hello, syumai!" +} +``` + +## Development + +### Requirements + +This project requires these tools to be installed globally. + +* wrangler +* tinygo +* [easyjson](https://github.com/mailru/easyjson) + - `go install github.com/mailru/easyjson/...@latest` + +### Commands + +``` +make dev # run dev server +make build # build Go Wasm binary +make publish # publish worker +``` + +## Author + +syumai + +## License + +MIT diff --git a/examples/r2-image-server/go.mod b/examples/r2-image-server/go.mod new file mode 100644 index 0000000..9926cb2 --- /dev/null +++ b/examples/r2-image-server/go.mod @@ -0,0 +1,7 @@ +module github.com/syumai/workers/examples/r2-image-server + +go 1.18 + +require github.com/syumai/workers v0.0.0-00010101000000-000000000000 + +replace github.com/syumai/workers => ../../ diff --git a/examples/r2-image-server/go.sum b/examples/r2-image-server/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/examples/r2-image-server/jsutil.go b/examples/r2-image-server/jsutil.go new file mode 100644 index 0000000..d80a77a --- /dev/null +++ b/examples/r2-image-server/jsutil.go @@ -0,0 +1,100 @@ +package main + +import ( + "fmt" + "strconv" + "syscall/js" + "time" +) + +var ( + global = js.Global() + objectClass = global.Get("Object") + promiseClass = global.Get("Promise") + uint8ArrayClass = global.Get("Uint8Array") + errorClass = global.Get("Error") + readableStreamClass = global.Get("ReadableStream") + stringClass = global.Get("String") +) + +func newObject() js.Value { + return objectClass.New() +} + +func newUint8Array(size int) js.Value { + return uint8ArrayClass.New(size) +} + +func newPromise(fn js.Func) js.Value { + return promiseClass.New(fn) +} + +func awaitPromise(promiseVal js.Value) (js.Value, error) { + resultCh := make(chan js.Value) + errCh := make(chan error) + var then, catch js.Func + then = js.FuncOf(func(_ js.Value, args []js.Value) any { + defer then.Release() + result := args[0] + resultCh <- result + return js.Undefined() + }) + catch = js.FuncOf(func(_ js.Value, args []js.Value) any { + defer catch.Release() + result := args[0] + errCh <- fmt.Errorf("failed on promise: %s", result.Call("toString").String()) + return js.Undefined() + }) + promiseVal.Call("then", then).Call("catch", catch) + select { + case result := <-resultCh: + return result, nil + case err := <-errCh: + return js.Value{}, err + } +} + +// dateToTime converts JavaScript side's Data object into time.Time. +func dateToTime(v js.Value) (time.Time, error) { + milliStr := stringClass.Invoke(v.Call("getTime")).String() + milli, err := strconv.ParseInt(milliStr, 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("failed to convert Date to time.Time: %w", err) + } + return time.UnixMilli(milli), nil +} + +// strRecordToMap converts JavaScript side's Record into map[string]string. +func strRecordToMap(v js.Value) map[string]string { + entries := objectClass.Call("entries", v) + entriesLen := entries.Get("length").Int() + result := make(map[string]string, entriesLen) + for i := 0; i < entriesLen; i++ { + entry := entries.Index(i) + key := entry.Index(0).String() + value := entry.Index(1).String() + result[key] = value + } + return result +} + +// maybeString returns string value of given JavaScript value or returns nil if the value is undefined. +func maybeString(v js.Value) *string { + if v.IsUndefined() { + return nil + } + s := v.String() + return &s +} + +// maybeDate returns time.Time value of given JavaScript Date value or returns nil if the value is undefined. +func maybeDate(v js.Value) (*time.Time, error) { + if v.IsUndefined() { + return nil, nil + } + d, err := dateToTime(v) + if err != nil { + return nil, err + } + return &d, nil +} diff --git a/examples/r2-image-server/main.go b/examples/r2-image-server/main.go new file mode 100644 index 0000000..d825e6e --- /dev/null +++ b/examples/r2-image-server/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "io" + "log" + "net/http" + "strings" + + "github.com/syumai/workers" +) + +// bucketName is R2 bucket name defined in wrangler.toml. +const bucketName = "BUCKET" + +func handleErr(w http.ResponseWriter, msg string, err error) { + log.Println(err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(msg)) +} + +// 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 := NewR2Bucket(bucketName) + if err != nil { + handleErr(w, "failed to get R2Bucket\n", err) + return + } + imgPath := strings.TrimPrefix(req.URL.Path, "/") + imgObj, err := bucket.Get(imgPath) + if err != nil { + handleErr(w, "failed to get R2Object\n", err) + return + } + if imgObj == nil { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(fmt.Sprintf("image not found: %s", imgPath))) + return + } + w.Header().Set("Cache-Control", "public, max-age=14400") + w.Header().Set("ETag", fmt.Sprintf("W/%s", imgObj.HTTPETag)) + contentType := "application/octet-stream" + if imgObj.HTTPMetadata.ContentType != nil { + contentType = *imgObj.HTTPMetadata.ContentType + } + w.Header().Set("Content-Type", contentType) + io.Copy(w, imgObj.Body) +} + +func main() { + workers.Serve(http.HandlerFunc(handler)) +} diff --git a/examples/r2-image-server/r2bucket.go b/examples/r2-image-server/r2bucket.go new file mode 100644 index 0000000..96511b0 --- /dev/null +++ b/examples/r2-image-server/r2bucket.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "io" + "syscall/js" +) + +// 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 interface { + Head(key string) (*R2Object, error) + Get(key string) (*R2Object, error) + Put(key string, value io.Reader) error + Delete(key string) error + List() (*R2Objects, error) +} + +type r2Bucket struct { + instance js.Value +} + +var _ R2Bucket = &r2Bucket{} + +func (r *r2Bucket) Head(key string) (*R2Object, error) { + p := r.instance.Call("head", key) + v, err := awaitPromise(p) + if err != nil { + return nil, err + } + if v.IsNull() { + return nil, nil + } + return toR2Object(v) +} + +func (r *r2Bucket) Get(key string) (*R2Object, error) { + p := r.instance.Call("get", key) + v, err := awaitPromise(p) + if err != nil { + return nil, err + } + fmt.Println(v) + if v.IsNull() { + return nil, nil + } + return toR2Object(v) +} + +func (r *r2Bucket) Put(key string, value io.Reader) error { + panic("implement me") +} + +func (r *r2Bucket) Delete(key string) error { + panic("implement me") +} + +func (r *r2Bucket) List() (*R2Objects, error) { + p := r.instance.Call("list") + v, err := awaitPromise(p) + if err != nil { + return nil, err + } + return toR2Objects(v) +} + +func NewR2Bucket(varName string) (R2Bucket, error) { + inst := js.Global().Get(varName) + if inst.IsUndefined() { + return nil, fmt.Errorf("%s is undefined", varName) + } + return &r2Bucket{instance: inst}, nil +} diff --git a/examples/r2-image-server/r2object.go b/examples/r2-image-server/r2object.go new file mode 100644 index 0000000..26e97b1 --- /dev/null +++ b/examples/r2-image-server/r2object.go @@ -0,0 +1,95 @@ +package main + +import ( + "errors" + "fmt" + "io" + "syscall/js" + "time" +) + +// R2Object represents JavaScript side's R2Object. +// * 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 becomes nil when `Head` method of R2Bucket is called. + 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 := 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 = convertStreamReaderToReader(v.Get("body").Call("getReader")) + } + 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: strRecordToMap(v.Get("customMetadata")), + Body: body, + }, nil +} + +// R2HTTPMetadata represents metadata of R2 Object. +// * 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) { + cacheExpiry, err := maybeDate(v.Get("cacheExpiry")) + if err != nil { + return R2HTTPMetadata{}, fmt.Errorf("error converting cacheExpiry: %w", err) + } + return R2HTTPMetadata{ + ContentType: maybeString(v.Get("contentType")), + ContentLanguage: maybeString(v.Get("contentLanguage")), + ContentDisposition: maybeString(v.Get("contentDisposition")), + ContentEncoding: maybeString(v.Get("contentEncoding")), + CacheControl: maybeString(v.Get("cacheControl")), + CacheExpiry: cacheExpiry, + }, nil +} diff --git a/examples/r2-image-server/r2objects.go b/examples/r2-image-server/r2objects.go new file mode 100644 index 0000000..f6bac6b --- /dev/null +++ b/examples/r2-image-server/r2objects.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "syscall/js" +) + +// R2Objects represents JavaScript side's R2Objects. +// * https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1121 +type R2Objects struct { + Objects []*R2Object + Truncated bool + 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 := objectsVal.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: objectsVal.Get("truncated").Bool(), + Cursor: maybeString(objectsVal.Get("cursor")), + DelimitedPrefixes: prefixes, + }, nil +} diff --git a/examples/r2-image-server/stream.go b/examples/r2-image-server/stream.go new file mode 100644 index 0000000..fa0a525 --- /dev/null +++ b/examples/r2-image-server/stream.go @@ -0,0 +1,140 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "syscall/js" +) + +// streamReaderToReader implements io.Reader sourced from ReadableStreamDefaultReader. +// * ReadableStreamDefaultReader: https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader +// * This implementation is based on: https://deno.land/std@0.139.0/streams/conversion.ts#L76 +type streamReaderToReader struct { + buf bytes.Buffer + streamReader js.Value +} + +// Read reads bytes from ReadableStreamDefaultReader. +func (sr *streamReaderToReader) Read(p []byte) (n int, err error) { + if sr.buf.Len() == 0 { + promise := sr.streamReader.Call("read") + resultCh := make(chan js.Value) + errCh := make(chan error) + var then, catch js.Func + then = js.FuncOf(func(_ js.Value, args []js.Value) any { + defer then.Release() + result := args[0] + if result.Get("done").Bool() { + errCh <- io.EOF + return js.Undefined() + } + resultCh <- result.Get("value") + return js.Undefined() + }) + catch = js.FuncOf(func(_ js.Value, args []js.Value) any { + defer catch.Release() + result := args[0] + errCh <- fmt.Errorf("JavaScript error on read: %s", result.Call("toString").String()) + return js.Undefined() + }) + promise.Call("then", then).Call("catch", catch) + select { + case result := <-resultCh: + chunk := make([]byte, result.Get("byteLength").Int()) + _ = js.CopyBytesToGo(chunk, result) + // The length written is always the same as the length of chunk, so it can be discarded. + // - https://pkg.go.dev/bytes#Buffer.Write + _, err := sr.buf.Write(chunk) + if err != nil { + return 0, err + } + case err := <-errCh: + return 0, err + } + } + return sr.buf.Read(p) +} + +// convertStreamReaderToReader converts ReadableStreamDefaultReader to io.Reader. +func convertStreamReaderToReader(sr js.Value) io.Reader { + return &streamReaderToReader{ + streamReader: sr, + } +} + +// readerToReadableStream implements ReadableStream sourced from io.ReadCloser. +// * ReadableStream: https://developer.mozilla.org/docs/Web/API/ReadableStream +// * This implementation is based on: https://deno.land/std@0.139.0/streams/conversion.ts#L230 +type readerToReadableStream struct { + reader io.ReadCloser + chunkBuf []byte +} + +// Pull implements ReadableStream's pull method. +// * https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/ReadableStream#pull +func (rs *readerToReadableStream) Pull(controller js.Value) error { + n, err := rs.reader.Read(rs.chunkBuf) + if err == io.EOF { + if err := rs.reader.Close(); err != nil { + return err + } + controller.Call("close") + return nil + } + if err != nil { + jsErr := errorClass.New(err.Error()) + controller.Call("error", jsErr) + if err := rs.reader.Close(); err != nil { + return err + } + return err + } + ua := newUint8Array(n) + _ = js.CopyBytesToJS(ua, rs.chunkBuf[:n]) + controller.Call("enqueue", ua) + return nil +} + +// Cancel implements ReadableStream's cancel method. +// * https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/ReadableStream#cancel +func (rs *readerToReadableStream) Cancel() error { + return rs.reader.Close() +} + +// https://deno.land/std@0.139.0/streams/conversion.ts#L5 +const defaultChunkSize = 16_640 + +// convertReaderToReadableStream converts io.ReadCloser to ReadableStream. +func convertReaderToReadableStream(reader io.ReadCloser) js.Value { + stream := &readerToReadableStream{ + reader: reader, + chunkBuf: make([]byte, defaultChunkSize), + } + rsInit := newObject() + rsInit.Set("pull", js.FuncOf(func(_ js.Value, args []js.Value) any { + var cb js.Func + cb = js.FuncOf(func(this js.Value, pArgs []js.Value) any { + defer cb.Release() + resolve := pArgs[0] + reject := pArgs[1] + controller := args[0] + err := stream.Pull(controller) + if err != nil { + reject.Invoke(errorClass.New(err.Error())) + return js.Undefined() + } + resolve.Invoke() + return js.Undefined() + }) + return newPromise(cb) + })) + rsInit.Set("cancel", js.FuncOf(func(js.Value, []js.Value) any { + err := stream.Cancel() + if err != nil { + panic(err) + } + return js.Undefined() + })) + return readableStreamClass.New(rsInit) +} diff --git a/examples/r2-image-server/worker.mjs b/examples/r2-image-server/worker.mjs new file mode 100644 index 0000000..06ce572 --- /dev/null +++ b/examples/r2-image-server/worker.mjs @@ -0,0 +1,19 @@ +import "../assets/polyfill_performance.js"; +import "../assets/wasm_exec.js"; +import mod from "./dist/app.wasm"; + +const go = new Go(); + +const load = WebAssembly.instantiate(mod, go.importObject).then((instance) => { + go.run(instance); + return instance; +}); + +async function processRequest(event) { + const req = event.request; + await load; + console.log("finished loading"); + return handleRequest(req); +} + +addEventListener("fetch", (event) => event.respondWith(processRequest(event))) diff --git a/examples/r2-image-server/wrangler.toml b/examples/r2-image-server/wrangler.toml new file mode 100644 index 0000000..b4ab471 --- /dev/null +++ b/examples/r2-image-server/wrangler.toml @@ -0,0 +1,14 @@ +name = "r2-image-server" +main = "./worker.mjs" +compatibility_date = "2022-05-13" +compatibility_flags = [ + "streams_enable_constructors" +] + +[build] +command = "make build" + +[[r2_buckets]] +binding = 'BUCKET' +bucket_name = 'r2-image-viewer' +preview_bucket_name = 'r2-image-viewer-dev' \ No newline at end of file