WIP - add R2 image server example

This commit is contained in:
syumai 2022-05-22 22:46:08 +09:00
parent 78134e5c74
commit 3981556bb3
12 changed files with 449 additions and 0 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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=

View File

@ -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<string, string> 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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,5 @@
package main
type R2Objects struct {
Objects []*R2Object
}

View File

@ -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)
}

View File

@ -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);
},
};

View File

@ -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"