From 3981556bb3b95e416fef0fe7d14dd4d7232f41c6 Mon Sep 17 00:00:00 2001 From: syumai Date: Sun, 22 May 2022 22:46:08 +0900 Subject: [PATCH] WIP - add R2 image server example --- examples/r2-image-server/Makefile | 12 +++ examples/r2-image-server/README.md | 52 +++++++++ examples/r2-image-server/go.mod | 12 +++ examples/r2-image-server/go.sum | 4 + examples/r2-image-server/jsutil.go | 58 ++++++++++ examples/r2-image-server/main.go | 13 +++ examples/r2-image-server/r2bucket.go | 52 +++++++++ examples/r2-image-server/r2object.go | 75 +++++++++++++ examples/r2-image-server/r2objects.go | 5 + examples/r2-image-server/stream.go | 140 +++++++++++++++++++++++++ examples/r2-image-server/worker.mjs | 17 +++ examples/r2-image-server/wrangler.toml | 9 ++ 12 files changed, 449 insertions(+) create mode 100644 examples/r2-image-server/Makefile create mode 100644 examples/r2-image-server/README.md create mode 100644 examples/r2-image-server/go.mod create mode 100644 examples/r2-image-server/go.sum create mode 100644 examples/r2-image-server/jsutil.go create mode 100644 examples/r2-image-server/main.go create mode 100644 examples/r2-image-server/r2bucket.go create mode 100644 examples/r2-image-server/r2object.go create mode 100644 examples/r2-image-server/r2objects.go create mode 100644 examples/r2-image-server/stream.go create mode 100644 examples/r2-image-server/worker.mjs create mode 100644 examples/r2-image-server/wrangler.toml diff --git a/examples/r2-image-server/Makefile b/examples/r2-image-server/Makefile new file mode 100644 index 0000000..c9d7430 --- /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 ./main.go + +.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..ffea532 --- /dev/null +++ b/examples/r2-image-server/go.mod @@ -0,0 +1,12 @@ +module github.com/syumai/workers/examples/simple-json-server + +go 1.18 + +require ( + github.com/mailru/easyjson v0.7.7 + github.com/syumai/workers v0.0.0-00010101000000-000000000000 +) + +replace github.com/syumai/workers => ../../ + +require github.com/josharian/intern v1.0.0 // indirect diff --git a/examples/r2-image-server/go.sum b/examples/r2-image-server/go.sum new file mode 100644 index 0000000..7707cb6 --- /dev/null +++ b/examples/r2-image-server/go.sum @@ -0,0 +1,4 @@ +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= diff --git a/examples/r2-image-server/jsutil.go b/examples/r2-image-server/jsutil.go new file mode 100644 index 0000000..ac55c71 --- /dev/null +++ b/examples/r2-image-server/jsutil.go @@ -0,0 +1,58 @@ +package main + +import ( + "fmt" + "strconv" + "syscall/js" + "time" +) + +var ( + global = js.Global() + objectClass = global.Get("Object") +) + +// dateToTime converts JavaScript side's Data object into time.Time. +func dateToTime(v js.Value) (time.Time, error) { + milliStr := v.Call("getTime").Call("toString").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..a69c015 --- /dev/null +++ b/examples/r2-image-server/main.go @@ -0,0 +1,13 @@ +package main + +import ( + "net/http" + + "github.com/syumai/workers" + "github.com/syumai/workers/examples/simple-json-server/app" +) + +func main() { + http.HandleFunc("/hello", app.HelloHandler) + workers.Serve(nil) // use http.DefaultServeMux +} diff --git a/examples/r2-image-server/r2bucket.go b/examples/r2-image-server/r2bucket.go new file mode 100644 index 0000000..6f46400 --- /dev/null +++ b/examples/r2-image-server/r2bucket.go @@ -0,0 +1,52 @@ +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) + Get(key string) *R2Object + Put(key string, value io.Reader) + Delete(key string) + List() []*R2Object +} + +type r2Bucket struct { + instance js.Value +} + +var _ R2Bucket = &r2Bucket{} + +func (r *r2Bucket) Head(key string) { + panic("implement me") +} + +func (r *r2Bucket) Get(key string) *R2Object { + return nil +} + +func (r *r2Bucket) Put(key string, value io.Reader) { + panic("implement me") +} + +func (r *r2Bucket) Delete(key string) { + panic("implement me") +} + +func (r *r2Bucket) List() []*R2Object { + panic("implement me") +} + +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..730c894 --- /dev/null +++ b/examples/r2-image-server/r2object.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "io" + "syscall/js" + "time" +) + +type R2Object struct { + Key string + Version string + Size int + ETag string + HTTPETag string + Uploaded time.Time + HTTPMetadata R2HTTPMetadata + CustomMetadata map[string]string + Body io.Reader +} + +// TODO: implement +// - https://github.com/cloudflare/workers-types/blob/3012f263fb1239825e5f0061b267c8650d01b717/index.d.ts#L1106 +// func (o *R2Object) WriteHTTPMetadata(headers http.Header) { +// } + +// 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) + } + return &R2Object{ + 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: convertStreamReaderToReader(v.Get("body").Call("getReader")), + }, 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..b573837 --- /dev/null +++ b/examples/r2-image-server/r2objects.go @@ -0,0 +1,5 @@ +package main + +type R2Objects struct { + Objects []*R2Object +} 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..a0bb726 --- /dev/null +++ b/examples/r2-image-server/worker.mjs @@ -0,0 +1,17 @@ +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; +}); + +export default { + async fetch(req) { + await load; + return handleRequest(req); + }, +}; diff --git a/examples/r2-image-server/wrangler.toml b/examples/r2-image-server/wrangler.toml new file mode 100644 index 0000000..61638e6 --- /dev/null +++ b/examples/r2-image-server/wrangler.toml @@ -0,0 +1,9 @@ +name = "simple-json-server" +main = "./worker.mjs" +compatibility_date = "2022-05-13" +compatibility_flags = [ + "streams_enable_constructors" +] + +[build] +command = "make build"