diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0bd8375..1865b0a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -3,6 +3,7 @@ version: 2 project_name: motr builds: - id: motr + main: ./cmd/vault binary: app goos: - js diff --git a/.taskfile.dist.yml b/.taskfiles/Default.yml similarity index 73% rename from .taskfile.dist.yml rename to .taskfiles/Default.yml index c102075..031a796 100644 --- a/.taskfile.dist.yml +++ b/.taskfiles/Default.yml @@ -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: diff --git a/Makefile b/Makefile index 09d9dd6..ab3f37a 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/internal/context/wasm.go b/cmd/proxy/context.go similarity index 97% rename from internal/context/wasm.go rename to cmd/proxy/context.go index d469220..5110e92 100644 --- a/internal/context/wasm.go +++ b/cmd/proxy/context.go @@ -1,7 +1,7 @@ //go:build js && wasm // +build js,wasm -package context +package main import ( "encoding/base64" diff --git a/main.go b/cmd/proxy/main.go similarity index 98% rename from main.go rename to cmd/proxy/main.go index 78618c2..f3757bc 100644 --- a/main.go +++ b/cmd/proxy/main.go @@ -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 diff --git a/cmd/vault/context.go b/cmd/vault/context.go new file mode 100644 index 0000000..c4808aa --- /dev/null +++ b/cmd/vault/context.go @@ -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 +} diff --git a/cmd/vault/database.go b/cmd/vault/database.go new file mode 100644 index 0000000..73adc07 --- /dev/null +++ b/cmd/vault/database.go @@ -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 +} diff --git a/cmd/vault/main.go b/cmd/vault/main.go new file mode 100644 index 0000000..6045c1e --- /dev/null +++ b/cmd/vault/main.go @@ -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) +} diff --git a/cmd/vault/serve.go b/cmd/vault/serve.go new file mode 100644 index 0000000..d8cfe19 --- /dev/null +++ b/cmd/vault/serve.go @@ -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 +} diff --git a/handlers/authorize_handler.go b/handlers/account_handler.go similarity index 100% rename from handlers/authorize_handler.go rename to handlers/account_handler.go diff --git a/handlers/confirm_handler.go b/handlers/authorization_handler.go similarity index 100% rename from handlers/confirm_handler.go rename to handlers/authorization_handler.go diff --git a/handlers/current_handler.go b/handlers/credential_handler.go similarity index 100% rename from handlers/current_handler.go rename to handlers/credential_handler.go diff --git a/handlers/login_handler.go b/handlers/login_handler.go deleted file mode 100644 index 5ac8282..0000000 --- a/handlers/login_handler.go +++ /dev/null @@ -1 +0,0 @@ -package handlers diff --git a/handlers/errors_handler.go b/handlers/profile_handler.go similarity index 100% rename from handlers/errors_handler.go rename to handlers/profile_handler.go diff --git a/handlers/register_handler.go b/handlers/register_handler.go deleted file mode 100644 index 5ac8282..0000000 --- a/handlers/register_handler.go +++ /dev/null @@ -1 +0,0 @@ -package handlers diff --git a/types/config.go b/internal/types/config.go similarity index 100% rename from types/config.go rename to internal/types/config.go diff --git a/types/web_manifest.go b/internal/types/web_manifest.go similarity index 100% rename from types/web_manifest.go rename to internal/types/web_manifest.go diff --git a/server/server.go b/server/server.go index b7f2066..a2a08ce 100644 --- a/server/server.go +++ b/server/server.go @@ -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 }