feat: enable WASM execution environment

This commit is contained in:
2025-04-03 19:07:23 -04:00
parent 32bc34ceac
commit 4163ee2dd6
18 changed files with 338 additions and 19 deletions

View File

@@ -3,6 +3,7 @@ version: 2
project_name: motr
builds:
- id: motr
main: ./cmd/vault
binary: app
goos:
- js

View File

@@ -1,8 +1,12 @@
# https://taskfile.dev
# yaml-language-server: $schema=https://taskfile.dev/schema.json
version: "3"
silent: true
vars:
ROOT_DIR:
sh: git rev-parse --show-toplevel
tasks:
default:
cmds:
@@ -37,6 +41,7 @@ tasks:
build:go:
desc: Builds Go code
dir: "{{.ROOT_DIR}}"
env:
GOOS: js
GOARCH: wasm
@@ -46,10 +51,11 @@ tasks:
generates:
- bin/motr.wasm
cmds:
- go build -o bin/motr.wasm .
- go build -o bin/motr.wasm ./cmd/vault
build:docker:
desc: Builds Docker image
dir: "{{.ROOT_DIR}}"
env:
GOOS: js
GOARCH: wasm
@@ -63,6 +69,7 @@ tasks:
gen:templ:
internal: true
dir: "{{.ROOT_DIR}}"
sources:
- "**/*.templ"
generates:
@@ -72,19 +79,21 @@ tasks:
gen:sqlc:
internal: true
dir: "{{.ROOT_DIR}}"
sources:
- pkg/sink/query.sql
- pkg/sink/schema.sql
- "**/query.sql"
- "**/schema.sql"
generates:
- pkg/models/db.go
- pkg/models/querier.go
- pkg/models/models.go
- pkg/models/query.sql.go
- "**/db.go"
- "**/querier.go"
- "**/models.go"
- "**/query.sql.go"
cmds:
- sqlc generate
test:go:
internal: true
dir: "{{.ROOT_DIR}}"
sources:
- "**/*.go"
cmds:

View File

@@ -7,7 +7,7 @@ COMMIT := $(shell git log -1 --format='%H')
all: generate build
build:
@task -t .taskfile.dist.yml build
@task -t .taskfiles/Default.yml build
generate:
@task -t .taskfile.dist.yml gen
@task -t .taskfiles/Default.yml gen

View File

@@ -1,7 +1,7 @@
//go:build js && wasm
// +build js,wasm
package context
package main
import (
"encoding/base64"

View File

@@ -20,7 +20,7 @@ import (
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/onsonr/motr/internal/models"
sink "github.com/onsonr/motr/internal/sink"
vault "github.com/onsonr/motr/server"
"github.com/onsonr/motr/server"
)
var (
@@ -47,7 +47,7 @@ func main() {
log.Fatal(err)
return
}
e, err := vault.New(nil, dbq)
e, err := server.New(nil, dbq, WASMMiddleware)
if err != nil {
log.Fatal(err)
return

43
cmd/vault/context.go Normal file
View File

@@ -0,0 +1,43 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"encoding/base64"
"encoding/json"
"github.com/labstack/echo/v4"
)
func WASMMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Extract WASM context from headers
if wasmCtx := c.Request().Header.Get("X-Wasm-Context"); wasmCtx != "" {
if ctx, err := decodeWasmContext(wasmCtx); err == nil {
c.Set("wasm_context", ctx)
}
}
return next(c)
}
}
// // loadConfig loads the config from the given JSON string
//
// func loadConfig(configString string) (*motr.Config, error) {
// var config motr.Config
// err := json.Unmarshal([]byte(configString), &config)
// return &config, err
// }
//
// decodeWasmContext decodes the WASM context from a base64 encoded string
func decodeWasmContext(ctx string) (map[string]any, error) {
decoded, err := base64.StdEncoding.DecodeString(ctx)
if err != nil {
return nil, err
}
var ctxData map[string]any
err = json.Unmarshal(decoded, &ctxData)
return ctxData, err
}

28
cmd/vault/database.go Normal file
View File

@@ -0,0 +1,28 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"context"
"database/sql"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/onsonr/motr/internal/models"
sink "github.com/onsonr/motr/internal/sink"
)
// createDB initializes and returns a configured database connection
func createDB() (*models.Queries, error) {
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
return nil, err
}
// create tables
if _, err := db.ExecContext(context.Background(), sink.SchemaVaultSQL); err != nil {
return nil, err
}
return models.New(db), nil
}

47
cmd/vault/main.go Normal file
View File

@@ -0,0 +1,47 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"bytes"
"log"
"sync"
"syscall/js"
_ "github.com/ncruces/go-sqlite3/driver"
_ "github.com/ncruces/go-sqlite3/embed"
"github.com/onsonr/motr/server"
)
var (
// Global buffer pool to reduce allocations
bufferPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
// Cached JS globals
jsGlobal = js.Global()
jsUint8Array = jsGlobal.Get("Uint8Array")
jsResponse = jsGlobal.Get("Response")
jsPromise = jsGlobal.Get("Promise")
jsWasmHTTP = jsGlobal.Get("wasmhttp")
)
func main() {
// configString := "TODO"
// config, _ := loadConfig(configString)
dbq, err := createDB()
if err != nil {
log.Fatal(err)
return
}
e, err := server.New(nil, dbq, WASMMiddleware)
if err != nil {
log.Fatal(err)
return
}
serveFetch(e)
}

194
cmd/vault/serve.go Normal file
View File

@@ -0,0 +1,194 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"syscall/js"
)
// serveFetch serves HTTP requests with optimized handler management
func serveFetch(handler http.Handler) func() {
h := handler
if h == nil {
h = http.DefaultServeMux
}
// Optimize prefix handling
prefix := strings.TrimRight(jsWasmHTTP.Get("path").String(), "/")
if prefix != "" {
mux := http.NewServeMux()
mux.Handle(prefix+"/", http.StripPrefix(prefix, h))
h = mux
}
// Create request handler function
cb := js.FuncOf(func(_ js.Value, args []js.Value) interface{} {
promise, resolve, reject := newPromiseOptimized()
go handleRequest(h, args[1], resolve, reject)
return promise
})
jsWasmHTTP.Call("setHandler", cb)
return cb.Release
}
// handleRequest processes the request with panic recovery
func handleRequest(h http.Handler, jsReq js.Value, resolve, reject func(interface{})) {
defer func() {
if r := recover(); r != nil {
var errMsg string
if err, ok := r.(error); ok {
errMsg = fmt.Sprintf("wasmhttp: panic: %+v", err)
} else {
errMsg = fmt.Sprintf("wasmhttp: panic: %v", r)
}
reject(errMsg)
}
}()
recorder := newResponseRecorder()
h.ServeHTTP(recorder, buildRequest(jsReq))
resolve(recorder.jsResponse())
}
// buildRequest creates an http.Request from JS Request
func buildRequest(jsReq js.Value) *http.Request {
// Get request body
arrayBuffer, err := awaitPromiseOptimized(jsReq.Call("arrayBuffer"))
if err != nil {
panic(err)
}
// Create body buffer
jsBody := jsUint8Array.New(arrayBuffer)
bodyLen := jsBody.Get("length").Int()
body := make([]byte, bodyLen)
js.CopyBytesToGo(body, jsBody)
// Create request
req := httptest.NewRequest(
jsReq.Get("method").String(),
jsReq.Get("url").String(),
bytes.NewReader(body),
)
// Set headers efficiently
headers := jsReq.Get("headers")
headersIt := headers.Call("entries")
for {
entry := headersIt.Call("next")
if entry.Get("done").Bool() {
break
}
pair := entry.Get("value")
req.Header.Set(pair.Index(0).String(), pair.Index(1).String())
}
return req
}
// ResponseRecorder with optimized buffer handling
type ResponseRecorder struct {
*httptest.ResponseRecorder
buffer *bytes.Buffer
}
func newResponseRecorder() *ResponseRecorder {
return &ResponseRecorder{
ResponseRecorder: httptest.NewRecorder(),
buffer: bufferPool.Get().(*bytes.Buffer),
}
}
// jsResponse creates a JS Response with optimized memory usage
func (rr *ResponseRecorder) jsResponse() js.Value {
defer func() {
rr.buffer.Reset()
bufferPool.Put(rr.buffer)
}()
res := rr.Result()
defer res.Body.Close()
// Prepare response body
body := js.Undefined()
if res.ContentLength != 0 {
if _, err := io.Copy(rr.buffer, res.Body); err != nil {
panic(err)
}
bodyBytes := rr.buffer.Bytes()
body = jsUint8Array.New(len(bodyBytes))
js.CopyBytesToJS(body, bodyBytes)
}
// Prepare response init object
init := make(map[string]interface{}, 3)
if res.StatusCode != 0 {
init["status"] = res.StatusCode
}
if len(res.Header) > 0 {
headers := make(map[string]interface{}, len(res.Header))
for k, v := range res.Header {
if len(v) > 0 {
headers[k] = v[0]
}
}
init["headers"] = headers
}
return jsResponse.New(body, init)
}
// newPromiseOptimized creates a new JavaScript Promise with optimized callback handling
func newPromiseOptimized() (js.Value, func(interface{}), func(interface{})) {
var (
resolve func(interface{})
reject func(interface{})
promiseFunc = js.FuncOf(func(_ js.Value, args []js.Value) interface{} {
resolve = func(v interface{}) { args[0].Invoke(v) }
reject = func(v interface{}) { args[1].Invoke(v) }
return js.Undefined()
})
)
defer promiseFunc.Release()
return jsPromise.New(promiseFunc), resolve, reject
}
// awaitPromiseOptimized waits for Promise resolution with optimized channel handling
func awaitPromiseOptimized(promise js.Value) (js.Value, error) {
done := make(chan struct{})
var (
result js.Value
err error
)
thenFunc := js.FuncOf(func(_ js.Value, args []js.Value) interface{} {
result = args[0]
close(done)
return nil
})
defer thenFunc.Release()
catchFunc := js.FuncOf(func(_ js.Value, args []js.Value) interface{} {
err = js.Error{Value: args[0]}
close(done)
return nil
})
defer catchFunc.Release()
promise.Call("then", thenFunc).Call("catch", catchFunc)
<-done
return result, err
}

View File

@@ -1 +0,0 @@
package handlers

View File

@@ -1 +0,0 @@
package handlers

View File

@@ -5,15 +5,14 @@ package server
import (
"github.com/labstack/echo/v4"
echomiddleware "github.com/labstack/echo/v4/middleware"
"github.com/onsonr/motr/internal/context"
"github.com/onsonr/motr/internal/models"
"github.com/onsonr/motr/types"
"github.com/onsonr/motr/internal/types"
)
type Vault = *echo.Echo
// New returns a new Vault instance
func New(config *types.Config, dbq *models.Queries) (Vault, error) {
func New(config *types.Config, dbq *models.Queries, mdws ...echo.MiddlewareFunc) (Vault, error) {
e := echo.New()
// Override default behaviors
e.IPExtractor = echo.ExtractIPDirect()
@@ -22,7 +21,7 @@ func New(config *types.Config, dbq *models.Queries) (Vault, error) {
// Built-in middleware
e.Use(echomiddleware.Logger())
e.Use(echomiddleware.Recover())
e.Use(context.WASMMiddleware)
e.Use(mdws...)
registerRoutes(e)
return e, nil
}