[FEATURE] Use ReadableStream for Response (#15)

* feat: uses ReadableStream for Response

* chore: rebuilds other examples
This commit is contained in:
Nicolas Lepage 2024-10-14 09:14:50 +02:00 committed by GitHub
parent 163b49702b
commit 5ec4a8d7e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 644 additions and 93 deletions

34
docs/hello-sse/api.go Normal file
View File

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

BIN
docs/hello-sse/api.wasm Executable file

Binary file not shown.

32
docs/hello-sse/index.html Normal file
View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html>
<head>
<title>go-wasm-http-server hello demo</title>
<script>
navigator.serviceWorker.register('sw.js')
.then(registration => {
const serviceWorker = registration.installing ?? registration.waiting ?? registration.active
if (serviceWorker.state === 'activated') {
startEventSource()
} else {
serviceWorker.addEventListener('statechange', e => {
if (e.target.state === 'activated') startEventSource()
})
}
})
function startEventSource() {
const es = new EventSource('/api/events')
es.addEventListener('ping', (e) => {
const p = document.createElement('p')
p.textContent = `ping: data=${e.data}`
document.body.append(p)
})
window.addEventListener('unload', () => {
es.close()
})
}
</script>
</head>
<body></body>
</html>

12
docs/hello-sse/sw.js Normal file
View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

View File

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

8
go.mod
View File

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

4
go.sum
View File

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

13
internal/jstype/types.go Normal file
View File

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

View File

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

11
internal/safejs/bytes.go Normal file
View File

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

26
internal/safejs/func.go Normal file
View File

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

103
internal/safejs/value.go Normal file
View File

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

View File

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

View File

@ -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)
ab, err := r.Call("arrayBuffer")
if err != nil {
return nil, err
}
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(
r.Get("method").String(),
r.Get("url").String(),
bytes.NewBuffer(body),
method,
url,
bytes.NewReader(b),
)
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())
headers, err := r.Get("headers")
if err != nil {
return nil, err
}
return req
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
}

174
response.go Normal file
View File

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

View File

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

View File

@ -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() {
cancel()
}()
defer func() {
if err := res.Close(); err != nil {
panic(err)
}
}()
defer func() {
if r := recover(); r != nil {
var errStr string
if err, ok := r.(error); ok {
reject(fmt.Sprintf("wasmhttp: panic: %+v\n", err))
errStr = err.Error()
} else {
reject(fmt.Sprintf("wasmhttp: panic: %v\n", r))
errStr = fmt.Sprintf("%s", r)
}
res.WriteError(errStr)
}
}()
var res = NewResponseRecorder()
req, err := Request(safejs.Unsafe(args[0]))
if err != nil {
res.WriteError(err.Error())
return
}
h.ServeHTTP(res, Request(args[0]))
req = req.WithContext(ctx)
resolve(res.JSResponse())
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
}