From bd1ab3360064a603be2dc18f9e4d6eeadbada804 Mon Sep 17 00:00:00 2001 From: syumai Date: Wed, 18 May 2022 00:04:37 +0900 Subject: [PATCH] Initial commit --- .gitignore | 1 + LICENSE.md | 19 + README.md | 47 ++ examples/assets/polyfill_performance.js | 36 ++ examples/assets/wasm_exec.js | 543 ++++++++++++++++++ examples/simple-json-server/Makefile | 12 + examples/simple-json-server/README.md | 52 ++ .../simple-json-server/app/app_easyjson.go | 152 +++++ examples/simple-json-server/app/hello.go | 38 ++ examples/simple-json-server/go.mod | 12 + examples/simple-json-server/go.sum | 4 + examples/simple-json-server/main.go | 13 + examples/simple-json-server/worker.mjs | 17 + examples/simple-json-server/wrangler.toml | 9 + go.mod | 3 + handler.go | 67 +++ jsutil.go | 32 ++ request.go | 57 ++ response.go | 29 + responsewriter.go | 28 + stream.go | 140 +++++ 21 files changed, 1311 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 examples/assets/polyfill_performance.js create mode 100644 examples/assets/wasm_exec.js create mode 100644 examples/simple-json-server/Makefile create mode 100644 examples/simple-json-server/README.md create mode 100644 examples/simple-json-server/app/app_easyjson.go create mode 100644 examples/simple-json-server/app/hello.go create mode 100644 examples/simple-json-server/go.mod create mode 100644 examples/simple-json-server/go.sum create mode 100644 examples/simple-json-server/main.go create mode 100644 examples/simple-json-server/worker.mjs create mode 100644 examples/simple-json-server/wrangler.toml create mode 100644 go.mod create mode 100644 handler.go create mode 100644 jsutil.go create mode 100644 request.go create mode 100644 response.go create mode 100644 responsewriter.go create mode 100644 stream.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53c37a1 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..de6a841 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,19 @@ +Copyright 2022-present [syumai](https://github.com/syumai/) + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..93595df --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# workers + +* `workers` is a package to run an HTTP server written in Go on [Cloudflare Workers](https://workers.cloudflare.com/). +* This package can easily serve *http.Handler* on Cloudflare Workers. + +## Features + +* [x] serve http.Handler +* [ ] environment variables (WIP) +* [ ] KV (WIP) +* [ ] R2 (WIP) + +## Installation + +``` +go get github.com/syumai/workers +``` + +## Usage + +implement your http.Handler and give it to `workers.Serve()`. + +```go +func main() { + var handler http.HandlerFunc = func (w http.ResponseWriter, req *http.Request) { ... } + workers.Serve(handler) +} +``` + +or just call `http.Handle` and `http.HandleFunc`, then invoke `workers.Serve()` with nil. + +```go +func main() { + http.HandleFunc("/hello", func (w http.ResponseWriter, req *http.Request) { ... }) + workers.Serve(nil) // if nil is given, http.DefaultMux is used. +} +``` + +for concrete examples, see `examples` directory. + +## License + +MIT + +## Author + +syumai diff --git a/examples/assets/polyfill_performance.js b/examples/assets/polyfill_performance.js new file mode 100644 index 0000000..e42e60e --- /dev/null +++ b/examples/assets/polyfill_performance.js @@ -0,0 +1,36 @@ +// @license http://opensource.org/licenses/MIT +// copyright Paul Irish 2015 + + +// Date.now() is supported everywhere except IE8. For IE8 we use the Date.now polyfill +// github.com/Financial-Times/polyfill-service/blob/master/polyfills/Date.now/polyfill.js +// as Safari 6 doesn't have support for NavigationTiming, we use a Date.now() timestamp for relative values + +// if you want values similar to what you'd get with real perf.now, place this towards the head of the page +// but in reality, you're just getting the delta between now() calls, so it's not terribly important where it's placed + + +(function(){ + + if ("performance" in globalThis == false) { + globalThis.performance = {}; + } + + Date.now = (Date.now || function () { // thanks IE8 + return new Date().getTime(); + }); + + if ("now" in globalThis.performance == false){ + + var nowOffset = Date.now(); + + if (performance.timing && performance.timing.navigationStart){ + nowOffset = performance.timing.navigationStart + } + + globalThis.performance.now = function now(){ + return Date.now() - nowOffset; + } + } + +})(); \ No newline at end of file diff --git a/examples/assets/wasm_exec.js b/examples/assets/wasm_exec.js new file mode 100644 index 0000000..3920013 --- /dev/null +++ b/examples/assets/wasm_exec.js @@ -0,0 +1,543 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +// +// This file has been modified for use by the TinyGo compiler. + +(() => { + // Map multiple JavaScript environments to a single common API, + // preferring web standards over Node.js API. + // + // Environments considered: + // - Browsers + // - Node.js + // - Electron + // - Parcel + + if (typeof global !== "undefined") { + // global already exists + } else if (typeof window !== "undefined") { + window.global = window; + } else if (typeof self !== "undefined") { + self.global = self; + } else { + throw new Error("cannot export Go (neither global, window nor self is defined)"); + } + + /* + if (!global.require && typeof require !== "undefined") { + global.require = require; + } + */ + + /* + if (!global.fs && global.require) { + global.fs = require("fs"); + } + */ + + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!global.fs) { + let outputBuf = ""; + global.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substr(0, nl)); + outputBuf = outputBuf.substr(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!global.process) { + global.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + /* + if (!global.crypto) { + const nodeCrypto = require("crypto"); + global.crypto = { + getRandomValues(b) { + nodeCrypto.randomFillSync(b); + }, + }; + } + */ + + if (!global.performance) { + global.performance = { + now() { + const [sec, nsec] = process.hrtime(); + return sec * 1000 + nsec / 1000000; + }, + }; + } + + /* + if (!global.TextEncoder) { + global.TextEncoder = require("util").TextEncoder; + } + + if (!global.TextDecoder) { + global.TextDecoder = require("util").TextDecoder; + } + */ + + // End of polyfills for common API. + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + var logLine = []; + + global.Go = class { + constructor() { + this._callbackTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const mem = () => { + // The buffer may change when requesting more memory. + return new DataView(this._inst.exports.memory.buffer); + } + + const setInt64 = (addr, v) => { + mem().setUint32(addr + 0, v, true); + mem().setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const getInt64 = (addr) => { + const low = mem().getUint32(addr + 0, true); + const high = mem().getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = mem().getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = mem().getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number") { + if (isNaN(v)) { + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 0, true); + return; + } + if (v === 0) { + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 1, true); + return; + } + mem().setFloat64(addr, v, true); + return; + } + + switch (v) { + case undefined: + mem().setFloat64(addr, 0, true); + return; + case null: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 2, true); + return; + case true: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 3, true); + return; + case false: + mem().setUint32(addr + 4, nanHead, true); + mem().setUint32(addr, 4, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 1; + switch (typeof v) { + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + mem().setUint32(addr + 4, nanHead | typeFlag, true); + mem().setUint32(addr, id, true); + } + + const loadSlice = (array, len, cap) => { + return new Uint8Array(this._inst.exports.memory.buffer, array, len); + } + + const loadSliceOfValues = (array, len, cap) => { + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (ptr, len) => { + return decoder.decode(new DataView(this._inst.exports.memory.buffer, ptr, len)); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + wasi_snapshot_preview1: { + // https://github.com/WebAssembly/WASI/blob/main/phases/snapshot/docs.md#fd_write + fd_write: function(fd, iovs_ptr, iovs_len, nwritten_ptr) { + let nwritten = 0; + if (fd == 1) { + for (let iovs_i=0; iovs_i 0, // dummy + fd_fdstat_get: () => 0, // dummy + fd_seek: () => 0, // dummy + "proc_exit": (code) => { + if (global.process) { + // Node.js + process.exit(code); + } else { + // Can't exit in a browser. + throw 'trying to exit with code ' + code; + } + }, + random_get: (bufPtr, bufLen) => { + crypto.getRandomValues(loadSlice(bufPtr, bufLen)); + return 0; + }, + }, + env: { + // func ticks() float64 + "runtime.ticks": () => { + return timeOrigin + performance.now(); + }, + + // func sleepTicks(timeout float64) + "runtime.sleepTicks": (timeout) => { + // Do not sleep, only reactivate scheduler after the given timeout. + setTimeout(this._inst.exports.go_scheduler, timeout); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + // Note: TinyGo does not support finalizers so this should never be + // called. + // console.error('syscall/js.finalizeRef not implemented'); + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (ret_ptr, value_ptr, value_len) => { + const s = loadString(value_ptr, value_len); + storeValue(ret_ptr, s); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (retval, v_addr, p_ptr, p_len) => { + let prop = loadString(p_ptr, p_len); + let value = loadValue(v_addr); + let result = Reflect.get(value, prop); + storeValue(retval, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (v_addr, p_ptr, p_len, x_addr) => { + const v = loadValue(v_addr); + const p = loadString(p_ptr, p_len); + const x = loadValue(x_addr); + Reflect.set(v, p, x); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (v_addr, p_ptr, p_len) => { + const v = loadValue(v_addr); + const p = loadString(p_ptr, p_len); + Reflect.deleteProperty(v, p); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (ret_addr, v_addr, i) => { + storeValue(ret_addr, Reflect.get(loadValue(v_addr), i)); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (v_addr, i, x_addr) => { + Reflect.set(loadValue(v_addr), i, loadValue(x_addr)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (ret_addr, v_addr, m_ptr, m_len, args_ptr, args_len, args_cap) => { + const v = loadValue(v_addr); + const name = loadString(m_ptr, m_len); + const args = loadSliceOfValues(args_ptr, args_len, args_cap); + try { + const m = Reflect.get(v, name); + storeValue(ret_addr, Reflect.apply(m, v, args)); + mem().setUint8(ret_addr + 8, 1); + } catch (err) { + storeValue(ret_addr, err); + mem().setUint8(ret_addr + 8, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (ret_addr, v_addr, args_ptr, args_len, args_cap) => { + try { + const v = loadValue(v_addr); + const args = loadSliceOfValues(args_ptr, args_len, args_cap); + storeValue(ret_addr, Reflect.apply(v, undefined, args)); + mem().setUint8(ret_addr + 8, 1); + } catch (err) { + storeValue(ret_addr, err); + mem().setUint8(ret_addr + 8, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (ret_addr, v_addr, args_ptr, args_len, args_cap) => { + const v = loadValue(v_addr); + const args = loadSliceOfValues(args_ptr, args_len, args_cap); + try { + storeValue(ret_addr, Reflect.construct(v, args)); + mem().setUint8(ret_addr + 8, 1); + } catch (err) { + storeValue(ret_addr, err); + mem().setUint8(ret_addr+ 8, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (v_addr) => { + return loadValue(v_addr).length; + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (ret_addr, v_addr) => { + const s = String(loadValue(v_addr)); + const str = encoder.encode(s); + storeValue(ret_addr, str); + setInt64(ret_addr + 8, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (v_addr, slice_ptr, slice_len, slice_cap) => { + const str = loadValue(v_addr); + loadSlice(slice_ptr, slice_len, slice_cap).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (v_addr, t_addr) => { + return loadValue(v_addr) instanceof loadValue(t_addr); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (ret_addr, dest_addr, dest_len, dest_cap, source_addr) => { + let num_bytes_copied_addr = ret_addr; + let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable + + const dst = loadSlice(dest_addr, dest_len); + const src = loadValue(source_addr); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + mem().setUint8(returned_status_addr, 0); // Return "not ok" status + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(num_bytes_copied_addr, toCopy.length); + mem().setUint8(returned_status_addr, 1); // Return "ok" status + }, + + // copyBytesToJS(dst ref, src []byte) (int, bool) + // Originally copied from upstream Go project, then modified: + // https://github.com/golang/go/blob/3f995c3f3b43033013013e6c7ccc93a9b1411ca9/misc/wasm/wasm_exec.js#L404-L416 + "syscall/js.copyBytesToJS": (ret_addr, dest_addr, source_addr, source_len, source_cap) => { + let num_bytes_copied_addr = ret_addr; + let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable + + const dst = loadValue(dest_addr); + const src = loadSlice(source_addr, source_len); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + mem().setUint8(returned_status_addr, 0); // Return "not ok" status + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(num_bytes_copied_addr, toCopy.length); + mem().setUint8(returned_status_addr, 1); // Return "ok" status + }, + } + }; + } + + async run(instance) { + this._inst = instance; + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + global, + this, + ]; + this._goRefCounts = []; // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map(); // mapping from JS values to reference ids + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + const mem = new DataView(this._inst.exports.memory.buffer) + + while (true) { + const callbackPromise = new Promise((resolve) => { + this._resolveCallbackPromise = () => { + if (this.exited) { + throw new Error("bad callback: Go program has already exited"); + } + setTimeout(resolve, 0); // make sure it is asynchronous + }; + }); + this._inst.exports._start(); + if (this.exited) { + break; + } + await callbackPromise; + } + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } + + if ( + global.require && + global.require.main === module && + global.process && + global.process.versions && + !global.process.versions.electron + ) { + if (process.argv.length != 3) { + console.error("usage: go_js_wasm_exec [wasm binary] [arguments]"); + process.exit(1); + } + + const go = new Go(); + WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then((result) => { + return go.run(result.instance); + }).catch((err) => { + console.error(err); + process.exit(1); + }); + } +})(); \ No newline at end of file diff --git a/examples/simple-json-server/Makefile b/examples/simple-json-server/Makefile new file mode 100644 index 0000000..c9d7430 --- /dev/null +++ b/examples/simple-json-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/simple-json-server/README.md b/examples/simple-json-server/README.md new file mode 100644 index 0000000..9bd46df --- /dev/null +++ b/examples/simple-json-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/simple-json-server/app/app_easyjson.go b/examples/simple-json-server/app/app_easyjson.go new file mode 100644 index 0000000..73b768e --- /dev/null +++ b/examples/simple-json-server/app/app_easyjson.go @@ -0,0 +1,152 @@ +// Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. + +package app + +import ( + json "encoding/json" + + easyjson "github.com/mailru/easyjson" + jlexer "github.com/mailru/easyjson/jlexer" + jwriter "github.com/mailru/easyjson/jwriter" +) + +// suppress unused package warning +var ( + _ *json.RawMessage + _ *jlexer.Lexer + _ *jwriter.Writer + _ easyjson.Marshaler +) + +func easyjsonD2c14bDecodeGithubComSyumaiWorkersPlaygroundTinygoApp(in *jlexer.Lexer, out *HelloResponse) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + if in.IsNull() { + in.Skip() + in.WantComma() + continue + } + switch key { + case "message": + out.Message = string(in.String()) + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonD2c14bEncodeGithubComSyumaiWorkersPlaygroundTinygoApp(out *jwriter.Writer, in HelloResponse) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"message\":" + out.RawString(prefix[1:]) + out.String(string(in.Message)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v HelloResponse) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonD2c14bEncodeGithubComSyumaiWorkersPlaygroundTinygoApp(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v HelloResponse) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonD2c14bEncodeGithubComSyumaiWorkersPlaygroundTinygoApp(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *HelloResponse) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonD2c14bDecodeGithubComSyumaiWorkersPlaygroundTinygoApp(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *HelloResponse) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonD2c14bDecodeGithubComSyumaiWorkersPlaygroundTinygoApp(l, v) +} +func easyjsonD2c14bDecodeGithubComSyumaiWorkersPlaygroundTinygoApp1(in *jlexer.Lexer, out *HelloRequest) { + isTopLevel := in.IsStart() + if in.IsNull() { + if isTopLevel { + in.Consumed() + } + in.Skip() + return + } + in.Delim('{') + for !in.IsDelim('}') { + key := in.UnsafeFieldName(false) + in.WantColon() + if in.IsNull() { + in.Skip() + in.WantComma() + continue + } + switch key { + case "name": + out.Name = string(in.String()) + default: + in.SkipRecursive() + } + in.WantComma() + } + in.Delim('}') + if isTopLevel { + in.Consumed() + } +} +func easyjsonD2c14bEncodeGithubComSyumaiWorkersPlaygroundTinygoApp1(out *jwriter.Writer, in HelloRequest) { + out.RawByte('{') + first := true + _ = first + { + const prefix string = ",\"name\":" + out.RawString(prefix[1:]) + out.String(string(in.Name)) + } + out.RawByte('}') +} + +// MarshalJSON supports json.Marshaler interface +func (v HelloRequest) MarshalJSON() ([]byte, error) { + w := jwriter.Writer{} + easyjsonD2c14bEncodeGithubComSyumaiWorkersPlaygroundTinygoApp1(&w, v) + return w.Buffer.BuildBytes(), w.Error +} + +// MarshalEasyJSON supports easyjson.Marshaler interface +func (v HelloRequest) MarshalEasyJSON(w *jwriter.Writer) { + easyjsonD2c14bEncodeGithubComSyumaiWorkersPlaygroundTinygoApp1(w, v) +} + +// UnmarshalJSON supports json.Unmarshaler interface +func (v *HelloRequest) UnmarshalJSON(data []byte) error { + r := jlexer.Lexer{Data: data} + easyjsonD2c14bDecodeGithubComSyumaiWorkersPlaygroundTinygoApp1(&r, v) + return r.Error() +} + +// UnmarshalEasyJSON supports easyjson.Unmarshaler interface +func (v *HelloRequest) UnmarshalEasyJSON(l *jlexer.Lexer) { + easyjsonD2c14bDecodeGithubComSyumaiWorkersPlaygroundTinygoApp1(l, v) +} diff --git a/examples/simple-json-server/app/hello.go b/examples/simple-json-server/app/hello.go new file mode 100644 index 0000000..8ae3ab0 --- /dev/null +++ b/examples/simple-json-server/app/hello.go @@ -0,0 +1,38 @@ +//go:generate easyjson . +package app + +import ( + "fmt" + "net/http" + "os" + + "github.com/mailru/easyjson" +) + +//easyjson:json +type HelloRequest struct { + Name string `json:"name"` +} + +//easyjson:json +type HelloResponse struct { + Message string `json:"message"` +} + +func HelloHandler(w http.ResponseWriter, req *http.Request) { + var helloReq HelloRequest + if err := easyjson.UnmarshalFromReader(req.Body, &helloReq); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte("request format is invalid")) + return + } + + w.Header().Set("Content-Type", "application/json") + msg := fmt.Sprintf("Hello, %s!", helloReq.Name) + helloRes := HelloResponse{Message: msg} + + if _, err := easyjson.MarshalToWriter(&helloRes, w); err != nil { + fmt.Fprintf(os.Stderr, "failed to encode response: %w\n", err) + } +} diff --git a/examples/simple-json-server/go.mod b/examples/simple-json-server/go.mod new file mode 100644 index 0000000..ffea532 --- /dev/null +++ b/examples/simple-json-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/simple-json-server/go.sum b/examples/simple-json-server/go.sum new file mode 100644 index 0000000..7707cb6 --- /dev/null +++ b/examples/simple-json-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/simple-json-server/main.go b/examples/simple-json-server/main.go new file mode 100644 index 0000000..a69c015 --- /dev/null +++ b/examples/simple-json-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/simple-json-server/worker.mjs b/examples/simple-json-server/worker.mjs new file mode 100644 index 0000000..a0bb726 --- /dev/null +++ b/examples/simple-json-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/simple-json-server/wrangler.toml b/examples/simple-json-server/wrangler.toml new file mode 100644 index 0000000..61638e6 --- /dev/null +++ b/examples/simple-json-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" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c357410 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/syumai/workers + +go 1.18 diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..bb354c8 --- /dev/null +++ b/handler.go @@ -0,0 +1,67 @@ +package workers + +import ( + "fmt" + "io" + "net/http" + "syscall/js" +) + +var httpHandler http.Handler + +func init() { + var handleRequestCallback js.Func + handleRequestCallback = js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 1 { + panic(fmt.Errorf("too many args given to handleRequest: %d", len(args))) + } + var cb js.Func + cb = js.FuncOf(func(_ js.Value, pArgs []js.Value) any { + defer cb.Release() + resolve := pArgs[0] + go func() { + res, err := handleRequest(args[0]) + if err != nil { + panic(err) + } + resolve.Invoke(res) + }() + return js.Undefined() + }) + return newPromise(cb) + }) + global.Set("handleRequest", handleRequestCallback) +} + +// handleRequest accepts a Request object and returns Response object. +func handleRequest(reqObj js.Value) (js.Value, error) { + if httpHandler == nil { + return js.Value{}, fmt.Errorf("Serve must be called before handleRequest.") + } + req, err := toRequest(reqObj) + if err != nil { + panic(err) + } + reader, writer := io.Pipe() + w := &responseWriterBuffer{ + header: http.Header{}, + statusCode: http.StatusOK, + PipeReader: reader, + PipeWriter: writer, + } + go func() { + defer writer.Close() + httpHandler.ServeHTTP(w, req) + }() + return w.toJSResponse() +} + +// Server serves http.Handler on Cloudflare Workers. +// if the given handler is nil, http.DefaultServeMux will be used. +func Serve(handler http.Handler) { + if handler == nil { + handler = http.DefaultServeMux + } + httpHandler = handler + select {} +} diff --git a/jsutil.go b/jsutil.go new file mode 100644 index 0000000..6bc5063 --- /dev/null +++ b/jsutil.go @@ -0,0 +1,32 @@ +package workers + +import "syscall/js" + +var ( + global = js.Global() + objectClass = global.Get("Object") + promiseClass = global.Get("Promise") + responseClass = global.Get("Response") + headersClass = global.Get("Headers") + arrayClass = global.Get("Array") + uint8ArrayClass = global.Get("Uint8Array") + errorClass = global.Get("Error") + readableStreamClass = global.Get("ReadableStream") +) + +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) +} + +// arrayFrom calls Array.from to given argument and returns result Array. +func arrayFrom(v js.Value) js.Value { + return arrayClass.Call("from", v) +} diff --git a/request.go b/request.go new file mode 100644 index 0000000..c770f5e --- /dev/null +++ b/request.go @@ -0,0 +1,57 @@ +package workers + +import ( + "io" + "net/http" + "net/url" + "strconv" + "strings" + "syscall/js" +) + +// toBody converts JavaScripts sides ReadableStream (can be null) to io.ReadCloser. +// * ReadableStream: https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream +func toBody(streamOrNull js.Value) io.ReadCloser { + if streamOrNull.IsNull() { + return nil + } + sr := streamOrNull.Call("getReader") + return io.NopCloser(convertStreamReaderToReader(sr)) +} + +// toHeader converts JavaScript sides Headers to http.Header. +// * Headers: https://developer.mozilla.org/ja/docs/Web/API/Headers +func toHeader(headers js.Value) http.Header { + entries := 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() + value := entry.Index(1).String() + h[key] = strings.Split(value, ",") + } + return h +} + +// toRequest converts JavaScript sides Request to *http.Request. +// * Request: https://developer.mozilla.org/ja/docs/Web/API/Request +func toRequest(req js.Value) (*http.Request, error) { + reqUrl, err := url.Parse(req.Get("url").String()) + if err != nil { + return nil, err + } + header := toHeader(req.Get("headers")) + + // ignore err + contentLength, _ := strconv.ParseInt(header.Get("Content-Length"), 10, 64) + return &http.Request{ + Method: req.Get("method").String(), + URL: reqUrl, + Header: header, + Body: toBody(req.Get("body")), + ContentLength: contentLength, + TransferEncoding: strings.Split(header.Get("Transfer-Encoding"), ","), + Host: header.Get("Host"), + }, nil +} diff --git a/response.go b/response.go new file mode 100644 index 0000000..d0f4153 --- /dev/null +++ b/response.go @@ -0,0 +1,29 @@ +package workers + +import ( + "io" + "net/http" + "syscall/js" +) + +func toJSHeader(header http.Header) js.Value { + h := headersClass.New() + for key, values := range header { + for _, value := range values { + h.Call("append", key, value) + } + } + return h +} + +func toJSResponse(body io.ReadCloser, status int, header http.Header) (js.Value, error) { + if status == 0 { + status = http.StatusOK + } + respInit := newObject() + respInit.Set("status", status) + respInit.Set("statusText", http.StatusText(status)) + respInit.Set("headers", toJSHeader(header)) + readableStream := convertReaderToReadableStream(body) + return responseClass.New(readableStream, respInit), nil +} diff --git a/responsewriter.go b/responsewriter.go new file mode 100644 index 0000000..6ceaa07 --- /dev/null +++ b/responsewriter.go @@ -0,0 +1,28 @@ +package workers + +import ( + "io" + "net/http" + "syscall/js" +) + +type responseWriterBuffer struct { + header http.Header + statusCode int + *io.PipeReader + *io.PipeWriter +} + +var _ http.ResponseWriter = &responseWriterBuffer{} + +func (w responseWriterBuffer) Header() http.Header { + return w.header +} + +func (w responseWriterBuffer) WriteHeader(statusCode int) { + w.statusCode = statusCode +} + +func (w responseWriterBuffer) toJSResponse() (js.Value, error) { + return toJSResponse(w.PipeReader, w.statusCode, w.header) +} diff --git a/stream.go b/stream.go new file mode 100644 index 0000000..c058a6a --- /dev/null +++ b/stream.go @@ -0,0 +1,140 @@ +package workers + +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) +}