From 04e25d22707d40b36f16531536937f7755159838 Mon Sep 17 00:00:00 2001 From: Andras Karasz Date: Tue, 21 Feb 2023 18:46:22 +0100 Subject: [PATCH] support durable object stubs --- README.md | 3 +- cloudflare/dostub.go | 133 ++++++++++++++++++ examples/durable-object-counter/.gitignore | 1 + examples/durable-object-counter/Makefile | 12 ++ examples/durable-object-counter/README.md | 40 ++++++ examples/durable-object-counter/go.mod | 7 + examples/durable-object-counter/go.sum | 0 examples/durable-object-counter/main.go | 41 ++++++ examples/durable-object-counter/worker.mjs | 63 +++++++++ examples/durable-object-counter/wrangler.toml | 16 +++ internal/jsutil/jsutil.go | 1 + 11 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 cloudflare/dostub.go create mode 100644 examples/durable-object-counter/.gitignore create mode 100644 examples/durable-object-counter/Makefile create mode 100644 examples/durable-object-counter/README.md create mode 100644 examples/durable-object-counter/go.mod create mode 100644 examples/durable-object-counter/go.sum create mode 100644 examples/durable-object-counter/main.go create mode 100644 examples/durable-object-counter/worker.mjs create mode 100644 examples/durable-object-counter/wrangler.toml diff --git a/README.md b/README.md index 3cd2c62..d4b6f88 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - [ ] Options for KV methods * [ ] Cache API * [ ] Durable Objects + - [x] Calling stubs * [ ] D1 * [x] Environment variables @@ -77,4 +78,4 @@ MIT ## Author -syumai +syumai, akarasz diff --git a/cloudflare/dostub.go b/cloudflare/dostub.go new file mode 100644 index 0000000..a6a7167 --- /dev/null +++ b/cloudflare/dostub.go @@ -0,0 +1,133 @@ +package cloudflare + +import ( + "context" + "io" + "fmt" + "net/http" + "strconv" + "strings" + "syscall/js" + + "github.com/syumai/workers/internal/jsutil" +) + +// DurableObjectNamespace represents the namespace of the durable object. +type DurableObjectNamespace struct { + instance js.Value +} + +// NewDurableObjectNamespace returns the namespace for the `varName` binding. +// +// This binding must be defined in the `wrangler.toml` file. The method will +// return an `error` when there is no binding defined by `varName`. +func NewDurableObjectNamespace(ctx context.Context, varName string) (*DurableObjectNamespace, error) { + inst := getRuntimeContextEnv(ctx).Get(varName) + if inst.IsUndefined() { + return nil, fmt.Errorf("%s is undefined", varName) + } + return &DurableObjectNamespace{instance: inst}, nil +} + +// IdFromName returns a `DurableObjectId` for the given `name`. +// +// https://developers.cloudflare.com/workers/runtime-apis/durable-objects/#deriving-ids-from-names +func (ns *DurableObjectNamespace) IdFromName(name string) *DurableObjectId { + id := ns.instance.Call("idFromName", name) + return &DurableObjectId{val: id} +} + +// Get obtains the durable object stub for `id`. +// +// https://developers.cloudflare.com/workers/runtime-apis/durable-objects/#obtaining-an-object-stub +func (ns *DurableObjectNamespace) Get(id *DurableObjectId) (*DurableObjectStub, error) { + if id == nil || id.val.IsUndefined() { + return nil, fmt.Errorf("invalid UniqueGlobalId") + } + stub := ns.instance.Call("get", id.val) + return &DurableObjectStub{val: stub}, nil +} + +// DurableObjectId represents an identifier for a durable object. +type DurableObjectId struct { + val js.Value +} + +// DurableObjectStub represents the stub to communicate with the durable object. +type DurableObjectStub struct { + val js.Value +} + +// Fetch calls the durable objects `fetch()` method. +// +// https://developers.cloudflare.com/workers/runtime-apis/durable-objects/#sending-http-requests +func (s *DurableObjectStub) Fetch(req *http.Request) (*http.Response, error) { + jsReq := toJSRequest(req) + + promise := s.val.Call("fetch", jsReq) + jsRes, err := jsutil.AwaitPromise(promise) + if err != nil { + return nil, err + } + + return toResponse(jsRes) +} + +// copied from workers#request.go +func toHeader(headers js.Value) http.Header { + entries := jsutil.ArrayFrom(headers.Call("entries")) + headerLen := entries.Length() + h := http.Header{} + for i := 0; i < headerLen; i++ { + entry := entries.Index(i) + key := entry.Index(0).String() + values := entry.Index(1).String() + for _, value := range strings.Split(values, ",") { + h.Add(key, value) + } + } + return h +} + +// copied from workers#response.go +func toJSHeader(header http.Header) js.Value { + h := jsutil.HeadersClass.New() + for key, values := range header { + for _, value := range values { + h.Call("append", key, value) + } + } + return h +} + +func toJSRequest(req *http.Request) js.Value { + jsReqOptions := jsutil.NewObject() + jsReqOptions.Set("method", req.Method) + jsReqOptions.Set("headers", toJSHeader(req.Header)) + jsReqBody := js.Undefined() + if req.Body != nil { + jsReqBody = jsutil.ConvertReaderToReadableStream(req.Body) + } + jsReqOptions.Set("body", jsReqBody) + jsReq := jsutil.RequestClass.New(req.URL.String(), jsReqOptions) + return jsReq +} + +func toResponse(res js.Value) (*http.Response, error) { + status := res.Get("status").Int() + promise := res.Call("text") + body, err := jsutil.AwaitPromise(promise) + if err != nil { + return nil, err + } + header := toHeader(res.Get("headers")) + contentLength, _ := strconv.ParseInt(header.Get("Content-Length"), 10, 64) + + return &http.Response{ + Status: strconv.Itoa(status) + " " + res.Get("statusText").String(), + StatusCode: status, + Header: header, + Body: io.NopCloser(strings.NewReader(body.String())), + ContentLength: contentLength, + }, nil +} \ No newline at end of file diff --git a/examples/durable-object-counter/.gitignore b/examples/durable-object-counter/.gitignore new file mode 100644 index 0000000..53c37a1 --- /dev/null +++ b/examples/durable-object-counter/.gitignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/examples/durable-object-counter/Makefile b/examples/durable-object-counter/Makefile new file mode 100644 index 0000000..320ddc3 --- /dev/null +++ b/examples/durable-object-counter/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/durable-object-counter/README.md b/examples/durable-object-counter/README.md new file mode 100644 index 0000000..7f589e5 --- /dev/null +++ b/examples/durable-object-counter/README.md @@ -0,0 +1,40 @@ +# durable object counter + +This app is an exmaple of using a stub to access a direct object. The example +is based on the [cloudflare/durable-object-template](https://github.com/cloudflare/durable-objects-template) +repository. + +_The durable object is written in js; only the stub is called from go!_ + +## Demo + +After `make publish` the trigger is `http://durable-object-counter.YOUR-DOMAIN.workers.dev` + +* https://durable-object-counter.YOUR-DOMAIN.workers.dev/ +* https://durable-object-counter.YOUR-DOMAIN.workers.dev/increment +* https://durable-object-counter.YOUR-DOMAIN.workers.dev/decrement + +## Development + +### Requirements + +This project requires these tools to be installed globally. + +* wrangler +* tinygo + +### Commands + +``` +make dev # run dev server +make build # build Go Wasm binary +make publish # publish worker +``` + +## Author + +akarasz + +## License + +MIT diff --git a/examples/durable-object-counter/go.mod b/examples/durable-object-counter/go.mod new file mode 100644 index 0000000..c2c4fb5 --- /dev/null +++ b/examples/durable-object-counter/go.mod @@ -0,0 +1,7 @@ +module github.com/syumai/workers/examples/durable-object-counter + +go 1.18 + +require github.com/syumai/workers v0.0.0 + +replace github.com/syumai/workers => ../../ \ No newline at end of file diff --git a/examples/durable-object-counter/go.sum b/examples/durable-object-counter/go.sum new file mode 100644 index 0000000..e69de29 diff --git a/examples/durable-object-counter/main.go b/examples/durable-object-counter/main.go new file mode 100644 index 0000000..336e2fa --- /dev/null +++ b/examples/durable-object-counter/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "io" + "net/http" + + "github.com/syumai/workers" + "github.com/syumai/workers/cloudflare" +) + +func main() { + workers.Serve(&MyHandler{}) +} + +type MyHandler struct {} + +func (_ *MyHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { + COUNTER, err := cloudflare.NewDurableObjectNamespace(req.Context(), "COUNTER") + if err != nil { + panic(err) + } + + id := COUNTER.IdFromName("A") + obj, err := COUNTER.Get(id) + if err != nil { + panic(err) + } + + res, err := obj.Fetch(req) + if err != nil { + panic(err) + } + + count, err := io.ReadAll(res.Body) + if err != nil { + panic(err) + } + + w.Write([]byte("Durable object 'A' count: " + string(count))) +} + diff --git a/examples/durable-object-counter/worker.mjs b/examples/durable-object-counter/worker.mjs new file mode 100644 index 0000000..bb96bc3 --- /dev/null +++ b/examples/durable-object-counter/worker.mjs @@ -0,0 +1,63 @@ +import "../assets/polyfill_performance.js"; +import "../assets/wasm_exec.js"; +import mod from "./dist/app.wasm"; + +const go = new Go(); + +const readyPromise = new Promise((resolve) => { + globalThis.ready = resolve; +}); + +const load = WebAssembly.instantiate(mod, go.importObject).then((instance) => { + go.run(instance); + return instance; +}); + +export default { + async fetch(req, env, ctx) { + await load; + await readyPromise; + return handleRequest(req, { env, ctx }); + } +} + +// Durable Object + +export class Counter { + constructor(state, env) { + this.state = state; + } + + // Handle HTTP requests from clients. + async fetch(request) { + // Apply requested action. + let url = new URL(request.url); + + // Durable Object storage is automatically cached in-memory, so reading the + // same key every request is fast. (That said, you could also store the + // value in a class member if you prefer.) + let value = await this.state.storage.get("value") || 0; + + switch (url.pathname) { + case "/increment": + ++value; + break; + case "/decrement": + --value; + break; + case "/": + // Just serve the current value. + break; + default: + return new Response("Not found", {status: 404}); + } + + // We don't have to worry about a concurrent request having modified the + // value in storage because "input gates" will automatically protect against + // unwanted concurrency. So, read-modify-write is safe. For more details, + // see: https://blog.cloudflare.com/durable-objects-easy-fast-correct-choose-three/ + await this.state.storage.put("value", value); + + return new Response(value); + } +} diff --git a/examples/durable-object-counter/wrangler.toml b/examples/durable-object-counter/wrangler.toml new file mode 100644 index 0000000..9116eee --- /dev/null +++ b/examples/durable-object-counter/wrangler.toml @@ -0,0 +1,16 @@ +name = "durable-object-counter" +main = "./worker.mjs" +compatibility_date = "2022-05-13" +compatibility_flags = [ + "streams_enable_constructors" +] + +[build] +command = "make build" + +[durable_objects] +bindings = [{name = "COUNTER", class_name = "Counter"}] + +[[migrations]] +tag = "v1" # Should be unique for each entry +new_classes = ["Counter"] \ No newline at end of file diff --git a/internal/jsutil/jsutil.go b/internal/jsutil/jsutil.go index c76bff3..ac21397 100644 --- a/internal/jsutil/jsutil.go +++ b/internal/jsutil/jsutil.go @@ -10,6 +10,7 @@ var ( Global = js.Global() ObjectClass = Global.Get("Object") PromiseClass = Global.Get("Promise") + RequestClass = Global.Get("Request") ResponseClass = Global.Get("Response") HeadersClass = Global.Get("Headers") ArrayClass = Global.Get("Array")