diff --git a/docs/hello-sse/api.go b/docs/hello-sse/api.go new file mode 100644 index 0000000..6e94306 --- /dev/null +++ b/docs/hello-sse/api.go @@ -0,0 +1,34 @@ +package main + +import ( + "net/http" + "time" + + "github.com/tmaxmax/go-sse" + + wasmhttp "github.com/nlepage/go-wasm-http-server" +) + +func main() { + s := &sse.Server{} + t, _ := sse.NewType("ping") + + go func() { + m := &sse.Message{ + Type: t, + } + m.AppendData("Hello world") + + for range time.Tick(time.Second) { + _ = s.Publish(m) + } + }() + + http.Handle("/events", s) + + if _, err := wasmhttp.Serve(nil); err != nil { + panic(err) + } + + select {} +} diff --git a/docs/hello-sse/api.wasm b/docs/hello-sse/api.wasm new file mode 100755 index 0000000..dbd4ba5 Binary files /dev/null and b/docs/hello-sse/api.wasm differ diff --git a/docs/hello-sse/index.html b/docs/hello-sse/index.html new file mode 100644 index 0000000..c81fb1a --- /dev/null +++ b/docs/hello-sse/index.html @@ -0,0 +1,32 @@ + + + + go-wasm-http-server hello demo + + + + diff --git a/docs/hello-sse/sw.js b/docs/hello-sse/sw.js new file mode 100644 index 0000000..df8ff57 --- /dev/null +++ b/docs/hello-sse/sw.js @@ -0,0 +1,12 @@ +importScripts('https://cdn.jsdelivr.net/gh/golang/go@go1.23.1/misc/wasm/wasm_exec.js') +importScripts('https://cdn.jsdelivr.net/gh/nlepage/go-wasm-http-server@v1.1.0/sw.js') + +addEventListener('install', (event) => { + event.waitUntil(skipWaiting()) +}) + +addEventListener('activate', event => { + event.waitUntil(clients.claim()) +}) + +registerWasmHTTPListener('api.wasm', { base: 'api' }) diff --git a/docs/hello-state/api.wasm b/docs/hello-state/api.wasm index 7863ef1..e1cbdc5 100755 Binary files a/docs/hello-state/api.wasm and b/docs/hello-state/api.wasm differ diff --git a/docs/hello-state/sw.js b/docs/hello-state/sw.js index 696e874..3371bbc 100644 --- a/docs/hello-state/sw.js +++ b/docs/hello-state/sw.js @@ -1,4 +1,4 @@ -importScripts('https://cdn.jsdelivr.net/gh/golang/go@go1.18.4/misc/wasm/wasm_exec.js') +importScripts('https://cdn.jsdelivr.net/gh/golang/go@go1.23.1/misc/wasm/wasm_exec.js') importScripts('https://cdn.jsdelivr.net/gh/nlepage/go-wasm-http-server@v1.1.0/sw.js') addEventListener('install', (event) => { diff --git a/docs/hello/api.wasm b/docs/hello/api.wasm index 2b20a58..d21eaf1 100755 Binary files a/docs/hello/api.wasm and b/docs/hello/api.wasm differ diff --git a/docs/hello/sw.js b/docs/hello/sw.js index 026f6fe..df8ff57 100644 --- a/docs/hello/sw.js +++ b/docs/hello/sw.js @@ -1,4 +1,4 @@ -importScripts('https://cdn.jsdelivr.net/gh/golang/go@go1.18.4/misc/wasm/wasm_exec.js') +importScripts('https://cdn.jsdelivr.net/gh/golang/go@go1.23.1/misc/wasm/wasm_exec.js') importScripts('https://cdn.jsdelivr.net/gh/nlepage/go-wasm-http-server@v1.1.0/sw.js') addEventListener('install', (event) => { diff --git a/example_json_test.go b/example_json_test.go index 7e7d5f9..15b9149 100644 --- a/example_json_test.go +++ b/example_json_test.go @@ -23,7 +23,11 @@ func Example_json() { } }) - defer wasmhttp.Serve(nil)() + release, err := wasmhttp.Serve(nil) + if err != nil { + panic(err) + } + defer release() // Wait for webpage event or use empty select{} } diff --git a/go.mod b/go.mod index 27a18c5..4309103 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,9 @@ module github.com/nlepage/go-wasm-http-server -go 1.13 +go 1.18 -require github.com/nlepage/go-js-promise v1.0.0 +require ( + github.com/hack-pad/safejs v0.1.1 + github.com/nlepage/go-js-promise v1.0.0 + github.com/tmaxmax/go-sse v0.8.0 +) diff --git a/go.sum b/go.sum index 291bfdb..b491b36 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,6 @@ +github.com/hack-pad/safejs v0.1.1 h1:d5qPO0iQ7h2oVtpzGnLExE+Wn9AtytxIfltcS2b9KD8= +github.com/hack-pad/safejs v0.1.1/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio= github.com/nlepage/go-js-promise v1.0.0 h1:K7OmJ3+0BgWJ2LfXchg2sI6RDr7AW/KWR8182epFwGQ= github.com/nlepage/go-js-promise v1.0.0/go.mod h1:bdOP0wObXu34euibyK39K1hoBCtlgTKXGc56AGflaRo= +github.com/tmaxmax/go-sse v0.8.0 h1:pPpTgyyi1r7vG2o6icebnpGEh3ebcnBXqDWkb7aTofs= +github.com/tmaxmax/go-sse v0.8.0/go.mod h1:HLoxqxdH+7oSUItjtnpxjzJedfr/+Rrm/dNWBcTxJFM= diff --git a/internal/jstype/types.go b/internal/jstype/types.go new file mode 100644 index 0000000..5077176 --- /dev/null +++ b/internal/jstype/types.go @@ -0,0 +1,13 @@ +package jstype + +import ( + "syscall/js" + + "github.com/nlepage/go-wasm-http-server/internal/safejs" +) + +var ( + ReadableStream = safejs.Safe(js.Global().Get("ReadableStream")) + Response = safejs.Safe(js.Global().Get("Response")) + Uint8Array = safejs.Safe(js.Global().Get("Uint8Array")) +) diff --git a/internal/readablestream/readable_stream.go b/internal/readablestream/readable_stream.go new file mode 100644 index 0000000..6b84fc5 --- /dev/null +++ b/internal/readablestream/readable_stream.go @@ -0,0 +1,87 @@ +package readablestream + +import ( + "context" + "io" + + "github.com/nlepage/go-wasm-http-server/internal/jstype" + "github.com/nlepage/go-wasm-http-server/internal/safejs" +) + +type Writer struct { + Value safejs.Value + controller safejs.Value + ctx context.Context +} + +var _ io.WriteCloser = (*Writer)(nil) + +func NewWriter() (*Writer, error) { + var start safejs.Func + var controller safejs.Value + + start, err := safejs.FuncOf(func(_ safejs.Value, args []safejs.Value) any { + defer start.Release() + controller = args[0] + return nil + }) + if err != nil { + return nil, err + } + + var cancel safejs.Func + ctx, cancelCtx := context.WithCancel(context.Background()) + + cancel, err = safejs.FuncOf(func(_ safejs.Value, _ []safejs.Value) any { + defer cancel.Release() + cancelCtx() + return nil + }) + if err != nil { + return nil, err + } + + source, err := safejs.ValueOf(map[string]any{ + "start": safejs.Unsafe(start.Value()), + "cancel": safejs.Unsafe(cancel.Value()), + }) + if err != nil { + return nil, err + } + + value, err := jstype.ReadableStream.New(source) + if err != nil { + return nil, err + } + + return &Writer{ + Value: value, + controller: controller, + ctx: ctx, + }, nil +} + +func (rs *Writer) Write(b []byte) (int, error) { + chunk, err := jstype.Uint8Array.New(len(b)) // FIXME reuse same Uint8Array? + if err != nil { + return 0, err + } + + n, err := safejs.CopyBytesToJS(chunk, b) + if err != nil { + return 0, err + } + + _, err = rs.controller.Call("enqueue", chunk) + + return n, err +} + +func (rs *Writer) Close() error { + rs.controller.Call("close") + return nil +} + +func (rs *Writer) Context() context.Context { + return rs.ctx +} diff --git a/internal/safejs/bytes.go b/internal/safejs/bytes.go new file mode 100644 index 0000000..f6087d5 --- /dev/null +++ b/internal/safejs/bytes.go @@ -0,0 +1,11 @@ +package safejs + +import "github.com/hack-pad/safejs" + +func CopyBytesToGo(dst []byte, src Value) (int, error) { + return safejs.CopyBytesToGo(dst, safejs.Value(src)) +} + +func CopyBytesToJS(dst Value, src []byte) (int, error) { + return safejs.CopyBytesToJS(safejs.Value(dst), src) +} diff --git a/internal/safejs/func.go b/internal/safejs/func.go new file mode 100644 index 0000000..dc0b141 --- /dev/null +++ b/internal/safejs/func.go @@ -0,0 +1,26 @@ +package safejs + +import ( + "github.com/hack-pad/safejs" +) + +type Func safejs.Func + +func FuncOf(fn func(this Value, args []Value) any) (Func, error) { + r, err := safejs.FuncOf(func(this safejs.Value, args []safejs.Value) any { + args2 := make([]Value, len(args)) + for i, v := range args { + args2[i] = Value(v) + } + return fn(Value(this), []Value(args2)) + }) + return Func(r), err +} + +func (f Func) Release() { + safejs.Func(f).Release() +} + +func (f Func) Value() Value { + return Value(safejs.Func(f).Value()) +} diff --git a/internal/safejs/value.go b/internal/safejs/value.go new file mode 100644 index 0000000..26fd389 --- /dev/null +++ b/internal/safejs/value.go @@ -0,0 +1,103 @@ +package safejs + +import ( + "syscall/js" + + "github.com/hack-pad/safejs" +) + +type Value safejs.Value + +func Safe(v js.Value) Value { + return Value(safejs.Safe(v)) +} + +func Unsafe(v Value) js.Value { + return safejs.Unsafe(safejs.Value(v)) +} + +func ValueOf(value any) (Value, error) { + v, err := safejs.ValueOf(value) + return Value(v), err +} + +func (v Value) Call(m string, args ...any) (Value, error) { + args = toJSValue(args).([]any) + r, err := safejs.Value(v).Call(m, args...) + return Value(r), err +} + +func (v Value) Get(p string) (Value, error) { + r, err := safejs.Value(v).Get(p) + return Value(r), err +} + +func (v Value) GetBool(p string) (bool, error) { + bv, err := v.Get(p) + if err != nil { + return false, err + } + + return safejs.Value(bv).Bool() +} + +func (v Value) GetInt(p string) (int, error) { + iv, err := v.Get(p) + if err != nil { + return 0, err + } + + return safejs.Value(iv).Int() +} + +func (v Value) GetString(p string) (string, error) { + sv, err := v.Get(p) + if err != nil { + return "", err + } + + return safejs.Value(sv).String() +} + +func (v Value) Index(i int) (Value, error) { + r, err := safejs.Value(v).Index(i) + return Value(r), err +} + +func (v Value) IndexString(i int) (string, error) { + sv, err := v.Index(i) + if err != nil { + return "", err + } + + return safejs.Value(sv).String() +} + +func (v Value) New(args ...any) (Value, error) { + args = toJSValue(args).([]any) + r, err := safejs.Value(v).New(args...) + return Value(r), err +} + +func toJSValue(jsValue any) any { + switch value := jsValue.(type) { + case Value: + return safejs.Value(value) + case Func: + return safejs.Func(value) + case map[string]any: + newValue := make(map[string]any) + for mapKey, mapValue := range value { + newValue[mapKey] = toJSValue(mapValue) + } + return newValue + case []any: + newValue := make([]any, len(value)) + for i, arg := range value { + newValue[i] = toJSValue(arg) + } + return newValue + default: + return jsValue + } +} diff --git a/package.go b/package.go index 8c3c29a..aaf4c75 100644 --- a/package.go +++ b/package.go @@ -1,4 +1,2 @@ // Package wasmhttp (github.com/nlepage/go-wasm-http-server) allows to create a WebAssembly Go HTTP Server embedded in a ServiceWorker. -// -// It is a subset of the full solution, a full usage is available on the github repository: https://github.com/nlepage/go-wasm-http-server package wasmhttp diff --git a/request.go b/request.go index b86a0df..b653bc3 100644 --- a/request.go +++ b/request.go @@ -7,29 +7,91 @@ import ( "syscall/js" promise "github.com/nlepage/go-js-promise" + + "github.com/nlepage/go-wasm-http-server/internal/jstype" + "github.com/nlepage/go-wasm-http-server/internal/safejs" ) // Request builds and returns the equivalent http.Request -func Request(r js.Value) *http.Request { - jsBody := js.Global().Get("Uint8Array").New(promise.Await(r.Call("arrayBuffer"))) - body := make([]byte, jsBody.Get("length").Int()) - js.CopyBytesToGo(body, jsBody) +func Request(ur js.Value) (*http.Request, error) { + r := safejs.Safe(ur) - req := httptest.NewRequest( - r.Get("method").String(), - r.Get("url").String(), - bytes.NewBuffer(body), - ) - - headersIt := r.Get("headers").Call("entries") - for { - e := headersIt.Call("next") - if e.Get("done").Bool() { - break - } - v := e.Get("value") - req.Header.Set(v.Index(0).String(), v.Index(1).String()) + ab, err := r.Call("arrayBuffer") + if err != nil { + return nil, err } - return req + u8a, err := jstype.Uint8Array.New(promise.Await(safejs.Unsafe(ab))) + if err != nil { + return nil, err + } + + l, err := u8a.GetInt("length") + if err != nil { + return nil, err + } + + b := make([]byte, l) + + _, err = safejs.CopyBytesToGo(b, u8a) + if err != nil { + return nil, err + } + + method, err := r.GetString("method") + if err != nil { + return nil, err + } + + url, err := r.GetString("url") + if err != nil { + return nil, err + } + + req := httptest.NewRequest( + method, + url, + bytes.NewReader(b), + ) + + headers, err := r.Get("headers") + if err != nil { + return nil, err + } + + headersIt, err := headers.Call("entries") + for { + e, err := headersIt.Call("next") + if err != nil { + return nil, err + } + + done, err := e.GetBool("done") + if err != nil { + return nil, err + } + + if done { + break + } + + v, err := e.Get("value") + if err != nil { + return nil, err + } + + key, err := v.IndexString(0) + if err != nil { + return nil, err + } + + value, err := v.IndexString(1) + if err != nil { + return nil, err + } + + req.Header.Set(key, value) + } + + return req, nil } diff --git a/response.go b/response.go new file mode 100644 index 0000000..70b538c --- /dev/null +++ b/response.go @@ -0,0 +1,174 @@ +package wasmhttp + +import ( + "bufio" + "context" + "fmt" + "io" + "log/slog" + "net/http" + "syscall/js" + + promise "github.com/nlepage/go-js-promise" + + "github.com/nlepage/go-wasm-http-server/internal/jstype" + "github.com/nlepage/go-wasm-http-server/internal/readablestream" + "github.com/nlepage/go-wasm-http-server/internal/safejs" +) + +type Response interface { + http.ResponseWriter + io.StringWriter + http.Flusher + io.Closer + Context() context.Context + WriteError(string) + JSValue() js.Value +} + +type response struct { + header http.Header + wroteHeader bool + + promise js.Value + resolve func(any) + + rs *readablestream.Writer + body *bufio.Writer +} + +func NewResponse() (Response, error) { + rs, err := readablestream.NewWriter() + if err != nil { + return nil, err + } + + promise, resolve, _ := promise.New() + + return &response{ + promise: promise, + resolve: resolve, + + rs: rs, + body: bufio.NewWriter(rs), + }, nil +} + +var _ Response = (*response)(nil) + +// Header implements [http.ResponseWriter]. +func (r *response) Header() http.Header { + if r.header == nil { + r.header = make(http.Header) + } + return r.header +} + +func (r *response) headerValue() map[string]any { + h := r.Header() + hh := make(map[string]any, len(h)+1) + for k := range h { + hh[k] = h.Get(k) + } + return hh +} + +// Write implements http.ResponseWriter. +func (r *response) Write(buf []byte) (int, error) { + r.writeHeader(buf, "") + return r.body.Write(buf) +} + +// WriteHeader implements [http.ResponseWriter]. +func (r *response) WriteHeader(code int) { + if r.wroteHeader { + return + } + + checkWriteHeaderCode(code) + + init, err := safejs.ValueOf(map[string]any{ + "code": code, + "headers": r.headerValue(), + }) + if err != nil { + panic(err) + } + + res, err := jstype.Response.New(r.rs.Value, init) + if err != nil { + panic(err) + } + + r.wroteHeader = true + + r.resolve(safejs.Unsafe(res)) +} + +// WriteString implements [io.StringWriter]. +func (r *response) WriteString(str string) (int, error) { + r.writeHeader(nil, str) + return r.body.WriteString(str) +} + +// Flush implements [http.Flusher] +func (r *response) Flush() { + if !r.wroteHeader { + r.WriteHeader(200) + } + if err := r.body.Flush(); err != nil { + panic(err) + } +} + +// Close implements [io.Closer] +func (r *response) Close() error { + if err := r.body.Flush(); err != nil { + return err + } + return r.rs.Close() +} + +func (r *response) Context() context.Context { + return r.rs.Context() +} + +func (r *response) WriteError(str string) { + slog.Error(str) + if !r.wroteHeader { + r.WriteHeader(500) + _, _ = r.WriteString(str) + } +} + +func (r *response) JSValue() js.Value { + return r.promise +} + +func (r *response) writeHeader(b []byte, str string) { + if r.wroteHeader { + return + } + + m := r.Header() + + _, hasType := m["Content-Type"] + hasTE := m.Get("Transfer-Encoding") != "" + if !hasType && !hasTE { + if b == nil { + if len(str) > 512 { + str = str[:512] + } + b = []byte(str) + } + m.Set("Content-Type", http.DetectContentType(b)) + } + + r.WriteHeader(200) +} + +func checkWriteHeaderCode(code int) { + if code < 100 || code > 999 { + panic(fmt.Sprintf("invalid WriteHeader code %v", code)) + } +} diff --git a/response_recorder.go b/response_recorder.go deleted file mode 100644 index 4f2fb40..0000000 --- a/response_recorder.go +++ /dev/null @@ -1,48 +0,0 @@ -package wasmhttp - -import ( - "io/ioutil" - "net/http/httptest" - "syscall/js" -) - -// ResponseRecorder uses httptest.ResponseRecorder to build a JS Response -type ResponseRecorder struct { - *httptest.ResponseRecorder -} - -// NewResponseRecorder returns a new ResponseRecorder -func NewResponseRecorder() ResponseRecorder { - return ResponseRecorder{httptest.NewRecorder()} -} - -// JSResponse builds and returns the equivalent JS Response -func (rr ResponseRecorder) JSResponse() js.Value { - var res = rr.Result() - - var body js.Value = js.Undefined() - if res.ContentLength != 0 { - var b, err = ioutil.ReadAll(res.Body) - if err != nil { - panic(err) - } - body = js.Global().Get("Uint8Array").New(len(b)) - js.CopyBytesToJS(body, b) - } - - var init = make(map[string]interface{}, 2) - - if res.StatusCode != 0 { - init["status"] = res.StatusCode - } - - if len(res.Header) != 0 { - var headers = make(map[string]interface{}, len(res.Header)) - for k := range res.Header { - headers[k] = res.Header.Get(k) - } - init["headers"] = headers - } - - return js.Global().Get("Response").New(body, init) -} diff --git a/serve.go b/serve.go index 253588f..2c5488b 100644 --- a/serve.go +++ b/serve.go @@ -1,57 +1,92 @@ package wasmhttp import ( + "context" "fmt" "net/http" "strings" "syscall/js" - promise "github.com/nlepage/go-js-promise" + "github.com/nlepage/go-wasm-http-server/internal/safejs" +) + +var ( + wasmhttp = safejs.Safe(js.Global().Get("wasmhttp")) ) // Serve serves HTTP requests using handler or http.DefaultServeMux if handler is nil. -func Serve(handler http.Handler) func() { - var h = handler +func Serve(handler http.Handler) (func(), error) { + h := handler if h == nil { h = http.DefaultServeMux } - var prefix = js.Global().Get("wasmhttp").Get("path").String() + prefix, err := wasmhttp.GetString("path") + if err != nil { + return nil, err + } + for strings.HasSuffix(prefix, "/") { prefix = strings.TrimSuffix(prefix, "/") } if prefix != "" { - var mux = http.NewServeMux() + mux := http.NewServeMux() mux.Handle(prefix+"/", http.StripPrefix(prefix, h)) h = mux } - var cb = js.FuncOf(func(_ js.Value, args []js.Value) interface{} { - var resPromise, resolve, reject = promise.New() + handlerValue, err := safejs.FuncOf(func(_ safejs.Value, args []safejs.Value) interface{} { + res, err := NewResponse() + if err != nil { + panic(err) + } go func() { + ctx, cancel := context.WithCancel(res.Context()) + defer func() { - if r := recover(); r != nil { - if err, ok := r.(error); ok { - reject(fmt.Sprintf("wasmhttp: panic: %+v\n", err)) - } else { - reject(fmt.Sprintf("wasmhttp: panic: %v\n", r)) - } + cancel() + }() + + defer func() { + if err := res.Close(); err != nil { + panic(err) } }() - var res = NewResponseRecorder() + defer func() { + if r := recover(); r != nil { + var errStr string + if err, ok := r.(error); ok { + errStr = err.Error() + } else { + errStr = fmt.Sprintf("%s", r) + } + res.WriteError(errStr) + } + }() - h.ServeHTTP(res, Request(args[0])) + req, err := Request(safejs.Unsafe(args[0])) + if err != nil { + res.WriteError(err.Error()) + return + } - resolve(res.JSResponse()) + req = req.WithContext(ctx) + + h.ServeHTTP(res, req) }() - return resPromise + return res.JSValue() }) + if err != nil { + return nil, err + } - js.Global().Get("wasmhttp").Call("setHandler", cb) + if _, err = wasmhttp.Call("setHandler", handlerValue); err != nil { + return nil, err + } - return cb.Release + return handlerValue.Release, nil }