diff --git a/README.md b/README.md index 8c5e128..0fb36f6 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ - [x] Put - [x] Delete - [ ] Options for KV methods -* [ ] Cache API +* [x] Cache API * [ ] Durable Objects - [x] Calling stubs * [x] D1 (alpha) diff --git a/cloudflare/cache/client.go b/cloudflare/cache/client.go new file mode 100644 index 0000000..fb3b9b5 --- /dev/null +++ b/cloudflare/cache/client.go @@ -0,0 +1,45 @@ +package cache + +import ( + "syscall/js" + + "github.com/syumai/workers/internal/jsutil" +) + +var cache = jsutil.Global.Get("caches") + +// Cache +type Cache struct { + // instance - The object that Cache API belongs to. + instance js.Value +} + +// applyOptions applies client options. +func (c *Cache) applyOptions(opts []CacheOption) { + for _, opt := range opts { + opt(c) + } +} + +// CacheOption +type CacheOption func(*Cache) + +// WithNamespace +func WithNamespace(namespace string) CacheOption { + return func(c *Cache) { + v, err := jsutil.AwaitPromise(cache.Call("open", namespace)) + if err != nil { + panic("failed to open cache") + } + c.instance = v + } +} + +func New(opts ...CacheOption) *Cache { + c := &Cache{ + instance: cache.Get("default"), + } + c.applyOptions(opts) + + return c +} diff --git a/cloudflare/cache/method.go b/cloudflare/cache/method.go new file mode 100644 index 0000000..c64b767 --- /dev/null +++ b/cloudflare/cache/method.go @@ -0,0 +1,102 @@ +package cache + +import ( + "errors" + "net/http" + "syscall/js" + + "github.com/syumai/workers/internal/jshttp" + "github.com/syumai/workers/internal/jsutil" +) + +// toJSResponse converts *http.Response to JS Response +func toJSResponse(res *http.Response) js.Value { + status := res.StatusCode + if status == 0 { + status = http.StatusOK + } + respInit := jsutil.NewObject() + respInit.Set("status", status) + respInit.Set("statusText", http.StatusText(status)) + respInit.Set("headers", jshttp.ToJSHeader(res.Header)) + + readableStream := jsutil.ConvertReaderToReadableStream(res.Body) + + return jsutil.ResponseClass.New(readableStream, respInit) +} + +// Put attempts to add a response to the cache, using the given request as the key. +// Returns an error for the following conditions +// - the request passed is a method other than GET. +// - the response passed has a status of 206 Partial Content. +// - Cache-Control instructs not to cache or if the response is too large. +// docs: https://developers.cloudflare.com/workers/runtime-apis/cache/#put +func (c *Cache) Put(req *http.Request, res *http.Response) error { + _, err := jsutil.AwaitPromise(c.instance.Call("put", jshttp.ToJSRequest(req), toJSResponse(res))) + if err != nil { + return err + } + return nil +} + +// ErrCacheNotFound is returned when there is no matching cache. +var ErrCacheNotFound = errors.New("cache not found") + +// MatchOptions represents the options of the Match method. +type MatchOptions struct { + // IgnoreMethod - Consider the request method a GET regardless of its actual value. + IgnoreMethod bool +} + +// toJS converts MatchOptions to JS object. +func (opts *MatchOptions) toJS() js.Value { + if opts == nil { + return js.Undefined() + } + obj := jsutil.NewObject() + obj.Set("ignoreMethod", opts.IgnoreMethod) + return obj +} + +// Match returns the response object keyed to that request. +// docs: https://developers.cloudflare.com/workers/runtime-apis/cache/#match +func (c *Cache) Match(req *http.Request, opts *MatchOptions) (*http.Response, error) { + res, err := jsutil.AwaitPromise(c.instance.Call("match", jshttp.ToJSRequest(req), opts.toJS())) + if err != nil { + return nil, err + } + if res.IsUndefined() { + return nil, ErrCacheNotFound + } + return jshttp.ToResponse(res) +} + +// DeleteOptions represents the options of the Delete method. +type DeleteOptions struct { + // IgnoreMethod - Consider the request method a GET regardless of its actual value. + IgnoreMethod bool +} + +// toJS converts DeleteOptions to JS object. +func (opts *DeleteOptions) toJS() js.Value { + if opts == nil { + return js.Undefined() + } + obj := jsutil.NewObject() + obj.Set("ignoreMethod", opts.IgnoreMethod) + return obj +} + +// Delete removes the Response object from the cache. +// This method only purges content of the cache in the data center that the Worker was invoked. +// Returns ErrCacheNotFount if the response was not cached. +func (c *Cache) Delete(req *http.Request, opts *DeleteOptions) error { + res, err := jsutil.AwaitPromise(c.instance.Call("delete", jshttp.ToJSRequest(req), opts.toJS())) + if err != nil { + return err + } + if !res.Bool() { + return ErrCacheNotFound + } + return nil +} diff --git a/examples/cache/.gitignore b/examples/cache/.gitignore new file mode 100644 index 0000000..53c37a1 --- /dev/null +++ b/examples/cache/.gitignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/examples/cache/Makefile b/examples/cache/Makefile new file mode 100644 index 0000000..11a0197 --- /dev/null +++ b/examples/cache/Makefile @@ -0,0 +1,13 @@ +.PHONY: dev +dev: + wrangler dev + +.PHONY: build +build: + mkdir -p dist + #tinygo build -o ./dist/app.wasm -target wasm ./... + tinygo build -o ./dist/app.wasm -target wasm -no-debug ./... + +.PHONY: publish +publish: + wrangler publish diff --git a/examples/cache/README.md b/examples/cache/README.md new file mode 100644 index 0000000..baf1a1d --- /dev/null +++ b/examples/cache/README.md @@ -0,0 +1,20 @@ +# [Cache](https://developers.cloudflare.com/workers/runtime-apis/cache/) + +The Cache API allows fine grained control of reading and writing from the Cloudflare global network. + +### 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 +``` \ No newline at end of file diff --git a/examples/cache/go.mod b/examples/cache/go.mod new file mode 100644 index 0000000..a46309f --- /dev/null +++ b/examples/cache/go.mod @@ -0,0 +1,9 @@ +module github.com/syumai/cache + +go 1.18 + +require github.com/syumai/workers v0.0.0 + +require github.com/go-chi/chi/v5 v5.0.8 // indirect + +replace github.com/syumai/workers => ../../ diff --git a/examples/cache/go.sum b/examples/cache/go.sum new file mode 100644 index 0000000..0d8b047 --- /dev/null +++ b/examples/cache/go.sum @@ -0,0 +1,4 @@ +github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= +github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/syumai/workers v0.1.0 h1:z5QfQR2X+PCKzom7RodpI5J4D5YF7NT7Qwzb9AM9dgY= +github.com/syumai/workers v0.1.0/go.mod h1:alXIDhTyeTwSzh0ZgQ3cb9HQPyyYfIejupE4Z3efr14= diff --git a/examples/cache/main.go b/examples/cache/main.go new file mode 100644 index 0000000..f576a01 --- /dev/null +++ b/examples/cache/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "net/http" + "time" + + "github.com/syumai/workers" + "github.com/syumai/workers/cloudflare" + "github.com/syumai/workers/cloudflare/cache" +) + +type responseWriter struct { + http.ResponseWriter + StatusCode int + Body []byte +} + +func (rw *responseWriter) WriteHeader(statusCode int) { + rw.StatusCode = statusCode + rw.ResponseWriter.WriteHeader(statusCode) +} + +func (rw *responseWriter) Write(data []byte) (int, error) { + rw.Body = append(rw.Body, data...) + return rw.ResponseWriter.Write(data) +} + +func (rw *responseWriter) ToHTTPResponse() *http.Response { + return &http.Response{ + StatusCode: rw.StatusCode, + Header: rw.Header(), + Body: io.NopCloser(bytes.NewReader(rw.Body)), + } +} + +func handler(w http.ResponseWriter, req *http.Request) { + ctx := req.Context() + rw := responseWriter{ResponseWriter: w} + c := cache.New() + + // Find cache + res, _ := c.Match(req, nil) + if res != nil { + // Set the response status code + rw.WriteHeader(res.StatusCode) + // Set the response headers + for key, values := range res.Header { + for _, value := range values { + rw.Header().Add(key, value) + } + } + rw.Header().Add("X-Message", "cache from worker") + // Set the response body + io.Copy(rw.ResponseWriter, res.Body) + return + } + + // Responding + text := fmt.Sprintf("time:%v\n", time.Now().UnixMilli()) + rw.Header().Set("Cache-Control", "max-age=15") + rw.Write([]byte(text)) + + // Create cache + cloudflare.WaitUntil(ctx, func() { + err := c.Put(req, rw.ToHTTPResponse()) + if err != nil { + fmt.Println(err) + } + }) +} + +func main() { + workers.Serve(http.HandlerFunc(handler)) +} diff --git a/examples/cache/worker.mjs b/examples/cache/worker.mjs new file mode 100644 index 0000000..649ccf0 --- /dev/null +++ b/examples/cache/worker.mjs @@ -0,0 +1,22 @@ +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 }); + } +} diff --git a/examples/cache/wrangler.toml b/examples/cache/wrangler.toml new file mode 100644 index 0000000..f4ccd5c --- /dev/null +++ b/examples/cache/wrangler.toml @@ -0,0 +1,6 @@ +name = "cache" +main = "./worker.mjs" +compatibility_date = "2023-02-24" + +[build] +command = "make build" diff --git a/internal/jshttp/response.go b/internal/jshttp/response.go index e19fc7a..cc56023 100644 --- a/internal/jshttp/response.go +++ b/internal/jshttp/response.go @@ -43,6 +43,12 @@ func ToJSResponse(w *ResponseWriterBuffer) (js.Value, error) { respInit.Set("status", status) respInit.Set("statusText", http.StatusText(status)) respInit.Set("headers", ToJSHeader(w.Header())) + if status == http.StatusSwitchingProtocols || + status == http.StatusNoContent || + status == http.StatusResetContent || + status == http.StatusNotModified { + return jsutil.ResponseClass.New(jsutil.Null, respInit), nil + } readableStream := jsutil.ConvertReaderToReadableStream(w.Reader) return jsutil.ResponseClass.New(readableStream, respInit), nil } diff --git a/internal/jsutil/jsutil.go b/internal/jsutil/jsutil.go index ac21397..ef315b3 100644 --- a/internal/jsutil/jsutil.go +++ b/internal/jsutil/jsutil.go @@ -18,6 +18,7 @@ var ( ErrorClass = Global.Get("Error") ReadableStreamClass = Global.Get("ReadableStream") DateClass = Global.Get("Date") + Null = js.ValueOf(nil) ) func NewObject() js.Value {