19 Commits

Author SHA1 Message Date
Nicolas Lepage
5f2b342f3a chore: v2.1.0 2025-01-19 11:16:06 +01:00
Nicolas Lepage
8e787fbf29 Merge pull request #17 from jphastings/compile-with-tinygo
TinyGo compatible packages
2025-01-19 11:03:24 +01:00
JP Hastings-Spital
ce6765e72a feat: Demonstrates TinyGo compatibility
Adds an example that demonstrates TinyGo compatibility, as well as using a server-side HTTP handler as a fallback.
2025-01-19 11:01:15 +01:00
Nico
d12a255cff implements io.Closer for readablestream.Reader 2024-12-30 00:51:31 +01:00
JP Hastings-Spital
a16a847b26 fix: TinyGo compatible packages
The net/http/httptest package isn't implemented in tinygo — this (minor) change swaps to creating the necessary http.Request struct directly (which needs an extra step or two).

This *is not* enough to get TinyGo _working_ as a compiler for this repo, but the compilation succeeds now.
2024-12-14 10:12:55 +00:00
Nicolas Lepage
3cf36c41e2 chore: v2.0.5 2024-12-09 23:12:31 +01:00
Nicolas Lepage
30a6ef67f9 fix: avoids closing cancelled readablestream 2024-12-09 23:11:17 +01:00
Nicolas Lepage
669f82020d chore: rebuilds hello-sse example 2024-12-09 22:46:00 +01:00
Nicolas Lepage
c94dcd965d chore: v2.0.4 2024-11-27 22:33:50 +01:00
Nicolas Lepage
2d786bdb14 fix: listen for ReadableStream cancellation 2024-11-27 22:32:19 +01:00
Nicolas Lepage
c561826125 chore: v2.0.3 2024-10-17 01:12:34 +02:00
Nicolas Lepage
897626b7d1 fix: Firefox does not have request.body ReadableStream 2024-10-17 01:11:41 +02:00
Nicolas Lepage
c93d379f20 feat: improves examples 2024-10-16 14:01:11 +02:00
Nicolas Lepage
98257b470a feat: allow using cache for wasm binary 2024-10-16 14:01:11 +02:00
Nicolas Lepage
b2bd8679fd fix: error management in readablestream writer close method 2024-10-16 13:19:16 +02:00
Nicolas Lepage
770d49a106 chore: v2.0.2 2024-10-14 23:30:16 +02:00
Nicolas Lepage
e8555180f7 fix: improves error management 2024-10-14 23:28:35 +02:00
Nicolas Lepage
8abad8cb77 fix: crash when request has no body 2024-10-14 23:28:21 +02:00
Nicolas Lepage
74cbaf89b5 fix: wrongful import path in v2.0.0 example 2024-10-14 22:56:59 +02:00
26 changed files with 357 additions and 63 deletions

View File

@@ -20,6 +20,7 @@
- [Hello example with state and keepalive](https://nlepage.github.io/go-wasm-http-server/hello-state-keepalive) ([sources](https://github.com/nlepage/go-wasm-http-server/tree/master/docs/hello-state-keepalive))
- [😺 Catption generator example](https://nlepage.github.io/catption/wasm) ([sources](https://github.com/nlepage/catption/tree/wasm))
- [Random password generator web server](https://nlepage.github.io/random-password-please/) ([sources](https://github.com/nlepage/random-password-please) forked from [jbarham/random-password-please](https://github.com/jbarham/random-password-please))
- [Server fallbacks, and compiling with TinyGo](https://nlepage.github.io/go-wasm-http-server/tinygo/) (runs locally; see [sources & readme](https://github.com/nlepage/go-wasm-http-server/tree/master/docs/tinygo#readme) for how to run this example)
## How?
@@ -39,6 +40,7 @@ The slides are available [here](https://nlepage.github.io/go-wasm-http-talk/).
`go-wasm-http-server` requires you to build your Go application to WebAssembly, so you need to make sure your code is compatible:
- no C bindings
- no System dependencies such as file system or network (database server for example)
- For smaller WASM blobs, your code may also benefit from being compatible with, and compiled by, [TinyGo](https://tinygo.org/docs/reference/lang-support/stdlib/). See the TinyGo specific details below.
## Usage
@@ -87,17 +89,37 @@ You may want to use build tags as shown above (or file name suffixes) in order t
Then build your WebAssembly binary:
```sh
# To compile with Go
GOOS=js GOARCH=wasm go build -o server.wasm .
# To compile with TinyGo, if your code is compatible
GOOS=js GOARCH=wasm tinygo build -o server.wasm .
```
### Step 2: Create ServiceWorker file
First, check the version of Go/TinyGo you compiled your wasm with:
```sh
$ go version
go version go1.23.4 darwin/arm64
# ^------^
$ tinygo version
tinygo version 0.35.0 darwin/arm64 (using go version go1.23.4 and LLVM version 18.1.2)
# ^----^
```
Create a ServiceWorker file with the following code:
📄 `sw.js`
```js
importScripts('https://cdn.jsdelivr.net/gh/golang/go@go1.18.4/misc/wasm/wasm_exec.js')
importScripts('https://cdn.jsdelivr.net/gh/nlepage/go-wasm-http-server@v2.0.0/sw.js')
// Note the 'go.1.23.4' below, that matches the version you just found:
importScripts('https://cdn.jsdelivr.net/gh/golang/go@go1.23.4/misc/wasm/wasm_exec.js')
// If you compiled with TinyGo then, similarly, use:
importScripts('https://cdn.jsdelivr.net/gh/tinygo-org/tinygo@0.35.0/targets/wasm_exec.js')
importScripts('https://cdn.jsdelivr.net/gh/nlepage/go-wasm-http-server@v2.1.0/sw.js')
registerWasmHTTPListener('path/to/server.wasm')
```

Binary file not shown.

View File

@@ -1,32 +1,57 @@
<!DOCTYPE html>
<html>
<head>
<title>go-wasm-http-server hello demo</title>
<title>go-wasm-http-server hello sse demo</title>
<script>
navigator.serviceWorker.register('sw.js')
.then(registration => {
const serviceWorker = registration.installing ?? registration.waiting ?? registration.active
if (serviceWorker.state === 'activated') {
startEventSource()
document.querySelector('#open-button').disabled = false
} else {
serviceWorker.addEventListener('statechange', e => {
if (e.target.state === 'activated') startEventSource()
if (e.target.state === 'activated') {
document.querySelector('#open-button').disabled = false
}
})
}
})
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('DOMContentLoaded', () => {
let es;
document.querySelector('#open-button').addEventListener('click', () => {
if (es && es.readyState === es.OPEN) return
es = new EventSource('api/events')
es.addEventListener('ping', (e) => {
const li = document.createElement('li')
li.textContent = `ping: data=${e.data}`
document.querySelector('ul').append(li)
})
})
document.querySelector('#close-button').addEventListener('click', () => {
if (!es) return
es.close()
})
document.querySelector('#clear-button').addEventListener('click', () => {
document.querySelector('ul').innerHTML = ''
})
window.addEventListener('unload', () => {
if (!es) return
es.close()
})
}
})
</script>
</head>
<body></body>
<body>
<p>
<button id="open-button" disabled>Open event source</button>
<button id="close-button">Close event source</button>
<button id="clear-button">Clear events</button>
</p>
<ul></ul>
</body>
</html>

View File

@@ -1,12 +1,14 @@
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@v2.0.0/sw.js')
importScripts('https://cdn.jsdelivr.net/gh/nlepage/go-wasm-http-server@v2.1.0/sw.js')
const wasm = 'api.wasm'
addEventListener('install', (event) => {
event.waitUntil(skipWaiting())
event.waitUntil(caches.open('examples').then((cache) => cache.add(wasm)))
})
addEventListener('activate', event => {
addEventListener('activate', (event) => {
event.waitUntil(clients.claim())
})
registerWasmHTTPListener('api.wasm', { base: 'api' })
registerWasmHTTPListener(wasm, { base: 'api' })

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<head>
<title>go-wasm-http-server hello with state demo</title>
<title>go-wasm-http-server hello with state and keepalive demo</title>
<script>
navigator.serviceWorker.register('sw.js').then(registration => {
const sw = registration.active ?? registration.installing ?? registration.waiting

View File

@@ -1,8 +1,10 @@
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@v2.0.0/sw.js')
importScripts('../sw.js')
const wasm = '../hello-state/api.wasm'
addEventListener('install', event => {
event.waitUntil(skipWaiting())
event.waitUntil(caches.open('hello-state').then((cache) => cache.add(wasm)))
})
addEventListener('activate', event => {
@@ -11,4 +13,4 @@ addEventListener('activate', event => {
addEventListener('message', () => {})
registerWasmHTTPListener('../hello-state/api.wasm', { base: 'api' })
registerWasmHTTPListener(wasm, { base: 'api' })

Binary file not shown.

View File

@@ -1,12 +1,14 @@
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@v2.0.0/sw.js')
importScripts('https://cdn.jsdelivr.net/gh/nlepage/go-wasm-http-server@v2.1.0/sw.js')
const wasm = 'api.wasm'
addEventListener('install', (event) => {
event.waitUntil(skipWaiting())
event.waitUntil(caches.open('examples').then((cache) => cache.add(wasm)))
})
addEventListener('activate', event => {
addEventListener('activate', (event) => {
event.waitUntil(clients.claim())
})
registerWasmHTTPListener('api.wasm', { base: 'api' })
registerWasmHTTPListener(wasm, { base: 'api' })

Binary file not shown.

View File

@@ -1,12 +1,14 @@
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@v2.0.0/sw.js')
importScripts('https://cdn.jsdelivr.net/gh/nlepage/go-wasm-http-server@v2.1.0/sw.js')
const wasm = 'api.wasm'
addEventListener('install', (event) => {
event.waitUntil(skipWaiting())
event.waitUntil(caches.open('examples').then((cache) => cache.add(wasm)))
})
addEventListener('activate', event => {
addEventListener('activate', (event) => {
event.waitUntil(clients.claim())
})
registerWasmHTTPListener('api.wasm', { base: 'api' })
registerWasmHTTPListener(wasm, { base: 'api' })

42
docs/tinygo/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Compiling with TinyGo
This example demonstrates that go-wasm-http-server can also be compiled with [TinyGo](https://www.tinygo.org), producing significantly smaller WASM blobs, though at the expense of [at least one known bug](https://github.com/tinygo-org/tinygo/issues/1140) and a [reduced standard library](https://tinygo.org/docs/reference/lang-support/stdlib/).
This example also demonstrates how the same code can be used for both server-side execution, and client-side execution in WASM (providing support for clients that cannot interpret WASM).
## Prerequisites
You'll need a version of [TinyGo installed](https://tinygo.org/getting-started/install/). (eg. `brew install tinygo-org/tools/tinygo`)
You'll need to make sure the first line of `sw.js` here has the same tinygo version number as your TinyGo version (this was v0.35.0 at time of writing).
## Build & run
Compile the WASM blob with TinyGo (this has been done for you for this example):
```bash
GOOS=js GOARCH=wasm tinygo build -o api.wasm .
```
Run the server (with Go, not TinyGo):
```bash
$ go run .
Server starting on http://127.0.0.1:<port>
```
## Important notes
You **must** use the TinyGo `wasm_exec.js`, specific to the version of TinyGo used to compile the WASM, in your `sw.js`. For example, if using the JSDelivr CDN:
```js
importScripts('https://cdn.jsdelivr.net/gh/tinygo-org/tinygo@0.35.0/targets/wasm_exec.js')
```
Note that the `0.35.0` within the path matches the TinyGo version used:
```sh
$ tinygo version
tinygo version 0.35.0 darwin/arm64 (using go version go1.23.4 and LLVM version 18.1.2)
# ^----^
```

BIN
docs/tinygo/api.wasm Normal file

Binary file not shown.

19
docs/tinygo/handlers.go Normal file
View File

@@ -0,0 +1,19 @@
package main
import (
"encoding/json"
"net/http"
"runtime"
)
func goRuntimeHandler(res http.ResponseWriter, req *http.Request) {
res.Header().Add("Content-Type", "application/json")
if err := json.NewEncoder(res).Encode(map[string]string{
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"compiler": runtime.Compiler,
"version": runtime.Version(),
}); err != nil {
panic(err)
}
}

44
docs/tinygo/index.html Normal file
View File

@@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<title>go-wasm-http-server tinygo demo</title>
<script>
const sw = navigator.serviceWorker
function attachServiceWorker() {
sw.register('sw.js')
.then(() => {
document.getElementById('wasm-status').innerText = "⚡️ Loaded - Will execute WASM locally"
})
.catch((err) => {
document.getElementById('wasm-status').innerText = "🛑 Error loading service worker — Check console"
console.error(err)
})
}
async function makeQuery() {
const res = await fetch('api/tiny', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
document.getElementById('output').innerText = await res.text()
}
</script>
</head>
<body>
<p>This example demonstrates that go-wasm-http-server can be compiled with <a href="https://www.tinygo.org">TinyGo</em>, producing significantly smaller WASM blobs, at the expense of <a href="https://github.com/tinygo-org/tinygo/issues/1140">at least one known bug</a>, and a <a href="https://tinygo.org/docs/reference/lang-support/stdlib/">reduced standard library</a>.</p>
<dl><dt>WASM HTTP Service Worker:</dt><dd id="wasm-status">☁️ Not loaded — will call server</dd></dl>
<ol>
<li><button onclick="makeQuery()">Call API</button></li>
<li><button onclick="attachServiceWorker()">Attach the service worker</button></li>
<li><span>Call the API again (Step 1)</span></li>
</ol>
<h3>Response:</h3>
<pre id="output"></pre>
</body>
</html>

34
docs/tinygo/server.go Normal file
View File

@@ -0,0 +1,34 @@
//go:build !wasm
// +build !wasm
package main
import (
"embed"
"fmt"
"log"
"net"
"net/http"
)
//go:embed *.html *.js *.wasm
var thisDir embed.FS
func main() {
// Serve all files in this directory statically
http.Handle("/", http.FileServer(http.FS(thisDir)))
// Note that this needs to be mounted at /api/tiny, rather than just /tiny (like in wasm.go)
// because the service worker mounts the WASM server at /api (at the end of sw.js)
http.HandleFunc("/api/tiny", goRuntimeHandler)
// Pick any available port. Note that ServiceWorkers _require_ localhost for non-SSL serving (so other LAN/WAN IPs will prevent the service worker from loading)
listener, err := net.Listen("tcp", ":0")
if err != nil {
log.Fatalf("Unable to claim a port to start server on: %v", err)
}
// Share the port being used & start
fmt.Printf("Server starting on http://127.0.0.1:%d\n", listener.Addr().(*net.TCPAddr).Port)
panic(http.Serve(listener, nil))
}

14
docs/tinygo/sw.js Normal file
View File

@@ -0,0 +1,14 @@
importScripts('https://cdn.jsdelivr.net/gh/tinygo-org/tinygo@0.35.0/targets/wasm_exec.js')
importScripts('https://cdn.jsdelivr.net/gh/nlepage/go-wasm-http-server@v2.1.0/sw.js')
const wasm = 'api.wasm'
addEventListener('install', (event) => {
event.waitUntil(caches.open('examples').then((cache) => cache.add(wasm)))
})
addEventListener('activate', (event) => {
event.waitUntil(clients.claim())
})
registerWasmHTTPListener(wasm, { base: 'api' })

18
docs/tinygo/wasm.go Normal file
View File

@@ -0,0 +1,18 @@
//go:build wasm
// +build wasm
package main
import (
"net/http"
wasmhttp "github.com/nlepage/go-wasm-http-server/v2"
)
func main() {
http.HandleFunc("/tiny", goRuntimeHandler)
wasmhttp.Serve(nil)
select {}
}

View File

@@ -5,7 +5,7 @@ import (
"fmt"
"net/http"
wasmhttp "github.com/nlepage/go-wasm-http-server/v2/v2"
wasmhttp "github.com/nlepage/go-wasm-http-server/v2"
)
// Demonstrates a simple hello JSON service.

3
go.sum
View File

@@ -4,3 +4,6 @@ github.com/nlepage/go-js-promise v1.0.0 h1:K7OmJ3+0BgWJ2LfXchg2sI6RDr7AW/KWR8182
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=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/tools v0.5.0/go.mod h1:N+Kgy78s5I24c24dU8OfWNEotWjutIs8SnJvn5IDq+k=

View File

@@ -14,7 +14,7 @@ type Reader struct {
off int
}
var _ io.Reader = (*Reader)(nil)
var _ io.ReadCloser = (*Reader)(nil)
func NewReader(r safejs.Value) *Reader {
return &Reader{
@@ -83,3 +83,14 @@ func (r *Reader) Read(p []byte) (int, error) {
return n, nil
}
func (r *Reader) Close() error {
p, err := r.value.Call("cancel")
if err != nil {
return err
}
_, err = promise.Await(safejs.Unsafe(p))
return err
}

View File

@@ -12,11 +12,14 @@ type Writer struct {
Value safejs.Value
controller safejs.Value
ctx context.Context
cancelled bool
}
var _ io.WriteCloser = (*Writer)(nil)
func NewWriter() (*Writer, error) {
var rs *Writer
var start safejs.Func
var controller safejs.Value
@@ -34,6 +37,7 @@ func NewWriter() (*Writer, error) {
cancel, err = safejs.FuncOf(func(_ safejs.Value, _ []safejs.Value) any {
defer cancel.Release()
rs.cancelled = true
cancelCtx()
return nil
})
@@ -54,14 +58,20 @@ func NewWriter() (*Writer, error) {
return nil, err
}
return &Writer{
rs = &Writer{
Value: value,
controller: controller,
ctx: ctx,
}, nil
}
return rs, nil
}
func (rs *Writer) Write(b []byte) (int, error) {
if rs.cancelled {
return 0, nil
}
chunk, err := jstype.Uint8Array.New(len(b)) // FIXME reuse same Uint8Array?
if err != nil {
return 0, err
@@ -78,8 +88,12 @@ func (rs *Writer) Write(b []byte) (int, error) {
}
func (rs *Writer) Close() error {
rs.controller.Call("close")
return nil
if rs.cancelled {
return nil
}
_, err := rs.controller.Call("close")
return err
}
func (rs *Writer) Context() context.Context {

View File

@@ -73,6 +73,14 @@ func (v Value) IndexString(i int) (string, error) {
return safejs.Value(sv).String()
}
func (v Value) IsNull() bool {
return safejs.Value(v).IsNull()
}
func (v Value) IsUndefined() bool {
return safejs.Value(v).IsUndefined()
}
func (v Value) New(args ...any) (Value, error) {
args = toJSValue(args).([]any)
r, err := safejs.Value(v).New(args...)

View File

@@ -1,10 +1,12 @@
package wasmhttp
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"syscall/js"
promise "github.com/nlepage/go-js-promise"
"github.com/nlepage/go-wasm-http-server/v2/internal/readablestream"
"github.com/nlepage/go-wasm-http-server/v2/internal/safejs"
)
@@ -13,31 +15,63 @@ import (
func Request(uvalue js.Value) (*http.Request, error) {
value := safejs.Safe(uvalue)
body, err := value.Get("body")
if err != nil {
return nil, err
}
r, err := body.Call("getReader")
if err != nil {
return nil, err
}
method, err := value.GetString("method")
if err != nil {
return nil, err
}
url, err := value.GetString("url")
rawURL, err := value.GetString("url")
if err != nil {
return nil, err
}
u, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
req := httptest.NewRequest(
method,
url,
readablestream.NewReader(r),
)
body, err := value.Get("body")
if err != nil {
return nil, err
}
var bodyReader io.ReadCloser
if !body.IsNull() {
// WORKAROUND: Firefox does not have request.body ReadableStream
if body.IsUndefined() {
blobp, err := value.Call("blob")
if err != nil {
return nil, err
}
blob, err := promise.Await(safejs.Unsafe(blobp))
if err != nil {
return nil, err
}
body, err = safejs.Safe(blob).Call("stream")
if err != nil {
return nil, err
}
}
r, err := body.Call("getReader")
if err != nil {
return nil, err
}
bodyReader = readablestream.NewReader(r)
}
req := &http.Request{
Method: method,
URL: u,
Body: bodyReader,
Header: make(http.Header),
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
}
headers, err := value.Get("headers")
if err != nil {

View File

@@ -88,7 +88,7 @@ func (r *response) WriteHeader(code int) {
checkWriteHeaderCode(code)
init, err := safejs.ValueOf(map[string]any{
"code": code,
"status": code,
"headers": r.headerValue(),
})
if err != nil {
@@ -136,6 +136,7 @@ func (r *response) Context() context.Context {
func (r *response) WriteError(str string) {
slog.Error(str)
if !r.wroteHeader {
r.Header().Set("Content-Type", "text/plain")
r.WriteHeader(500)
_, _ = r.WriteString(str)
}

View File

@@ -57,19 +57,13 @@ func Serve(handler http.Handler) (func(), error) {
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)
res.WriteError(fmt.Sprintf("%+v", r))
}
}()
req, err := Request(safejs.Unsafe(args[0]))
if err != nil {
res.WriteError(err.Error())
res.WriteError(fmt.Sprintf("%+v", err))
return
}

7
sw.js
View File

@@ -1,4 +1,4 @@
function registerWasmHTTPListener(wasm, { base, args = [] } = {}) {
function registerWasmHTTPListener(wasm, { base, cacheName, args = [] } = {}) {
let path = new URL(registration.scope).pathname
if (base && base !== '') path = `${trimEnd(path, '/')}/${trimStart(base, '/')}`
@@ -11,7 +11,10 @@ function registerWasmHTTPListener(wasm, { base, args = [] } = {}) {
const go = new Go()
go.argv = [wasm, ...args]
WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then(({ instance }) => go.run(instance))
const source = cacheName
? caches.open(cacheName).then((cache) => cache.match(wasm)).then((response) => response ?? fetch(wasm))
: caches.match(wasm).then(response => (response) ?? fetch(wasm))
WebAssembly.instantiateStreaming(source, go.importObject).then(({ instance }) => go.run(instance))
addEventListener('fetch', e => {
const { pathname } = new URL(e.request.url)