From bba2e92009341ef18d2086dfdd1d454129a7370d Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 24 Jul 2026 11:52:09 +0100 Subject: [PATCH] feat(display/http): Angular SSR through a resident engine/ts context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve an Angular app with server-side rendering via the Go-native CoreTS engine — Angular's non-Node SSR contract (RequestHandlerFunction: (Request) => Response | null | Promise) with no Node.js sidecar. - engine/ts: add Engine.Load + persistent Context.Invoke (load a bundle once, call an exported function per request), retained module state, JSON arg/result marshalling, lifecycle cleanup, and Web Request/Response bridging (web.go). - display/http/framework: shared Loader/Renderer/Response boundary so framework// (react, …) slot in later behind one contract. - display/http/framework/angular: the adapter — loads reqHandler once, translates the HTTP request (host, headers, body) to a Web Request, maps the returned Response (status, repeated headers, body, null → 404). - display/http: WithFramework mount option, static assets + legacy renderer fallback preserved. - Acceptance: server_example_test.go proves retained state across requests and null → 404 when consumed as a real caller. Co-Authored-By: Virgil --- go/display/http/framework/angular/server.go | 195 ++++++++ .../framework/angular/server_example_test.go | 327 +++++++++++++ .../http/framework/angular/server_test.go | 178 +++++++ go/display/http/framework/framework.go | 33 ++ .../http/framework/framework_example_test.go | 30 ++ go/display/http/framework/framework_test.go | 52 ++ go/display/http/http.go | 53 +- go/display/http/http_example_test.go | 4 + go/display/http/http_test.go | 66 +++ go/engine/ts/context.go | 453 ++++++++++++++++++ go/engine/ts/context_example_test.go | 13 + go/engine/ts/context_test.go | 189 ++++++++ go/engine/ts/engine.go | 73 ++- go/engine/ts/engine_example_test.go | 4 + go/engine/ts/engine_test.go | 83 +++- go/engine/ts/web.go | 41 ++ go/engine/ts/web_example_test.go | 27 ++ go/engine/ts/web_test.go | 37 ++ 18 files changed, 1822 insertions(+), 36 deletions(-) create mode 100644 go/display/http/framework/angular/server.go create mode 100644 go/display/http/framework/angular/server_example_test.go create mode 100644 go/display/http/framework/angular/server_test.go create mode 100644 go/display/http/framework/framework.go create mode 100644 go/display/http/framework/framework_example_test.go create mode 100644 go/display/http/framework/framework_test.go create mode 100644 go/engine/ts/context.go create mode 100644 go/engine/ts/context_example_test.go create mode 100644 go/engine/ts/context_test.go create mode 100644 go/engine/ts/web.go create mode 100644 go/engine/ts/web_example_test.go create mode 100644 go/engine/ts/web_test.go diff --git a/go/display/http/framework/angular/server.go b/go/display/http/framework/angular/server.go new file mode 100644 index 0000000..f775dbb --- /dev/null +++ b/go/display/http/framework/angular/server.go @@ -0,0 +1,195 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +// Package angular adapts Angular's non-Node RequestHandlerFunction SSR +// contract to the Go HTTP display. +package angular + +import ( + nethttp "net/http" + + core "dappco.re/go" + "dappco.re/go/render/display/http/framework" + "dappco.re/go/render/engine/ts" +) + +const requestHandlerExport = "reqHandler" + +type module interface { + Invoke(core.Context, string, any, ...any) error + Close() error +} + +type renderer struct { + mu core.Mutex + module module + closed bool + closeErr error +} + +type requestBody struct { + reader core.Reader +} + +// New loads serverBundle once into a resident CoreTS context and returns an +// Angular RequestHandlerFunction renderer. +func New(ctx core.Context, loader framework.Loader, serverBundle string) (framework.Renderer, error) { + if ctx == nil { + return nil, core.E("angular.New", "context is nil", nil) + } + if loader == nil { + return nil, core.E("angular.New", "CoreTS loader is nil", nil) + } + serverBundle = core.Trim(serverBundle) + if serverBundle == "" { + return nil, core.E("angular.New", "server bundle is required", nil) + } + + context, err := loader.Load(ctx, serverBundle) + if err != nil { + return nil, core.E("angular.New", "load Angular server bundle", err) + } + if context == nil { + return nil, core.E("angular.New", "load Angular server bundle returned no context", nil) + } + return &renderer{module: context}, nil +} + +func (r *renderer) Render(ctx core.Context, request *nethttp.Request) (*framework.Response, error) { + if r == nil { + return nil, core.E("angular.renderer.Render", "renderer is nil", nil) + } + if ctx == nil { + return nil, core.E("angular.renderer.Render", "context is nil", nil) + } + if request == nil { + return nil, core.E("angular.renderer.Render", "request is nil", nil) + } + + r.mu.Lock() + defer r.mu.Unlock() + if r.closed || r.module == nil { + return nil, core.E("angular.renderer.Render", "renderer is closed", nil) + } + + webRequest, err := webRequestFromHTTP(request) + if err != nil { + return nil, err + } + var webResponse *ts.WebResponse + if err := r.module.Invoke(ctx, requestHandlerExport, &webResponse, webRequest); err != nil { + return nil, core.E("angular.renderer.Render", "invoke Angular request handler", err) + } + if webResponse == nil { + return nil, nil + } + + header := make(nethttp.Header) + for _, value := range webResponse.Headers { + header.Add(value[0], value[1]) + } + return &framework.Response{ + Status: webResponse.Status, + Header: header, + Body: append([]byte(nil), webResponse.Body...), + }, nil +} + +func (r *renderer) Close() error { + if r == nil { + return core.E("angular.renderer.Close", "renderer is nil", nil) + } + + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return r.closeErr + } + r.closed = true + if r.module == nil { + r.closeErr = core.E("angular.renderer.Close", "resident module is nil", nil) + return r.closeErr + } + if err := r.module.Close(); err != nil { + r.closeErr = core.E("angular.renderer.Close", "close resident Angular module", err) + } + r.module = nil + return r.closeErr +} + +func webRequestFromHTTP(request *nethttp.Request) (ts.WebRequest, error) { + if request.URL == nil { + return ts.WebRequest{}, core.E("angular.webRequestFromHTTP", "request URL is nil", nil) + } + + requestURL := request.URL.String() + if !request.URL.IsAbs() { + host := core.Trim(request.Host) + if host == "" { + return ts.WebRequest{}, core.E("angular.webRequestFromHTTP", "request host is required", nil) + } + scheme := "http" + if request.TLS != nil { + scheme = "https" + } + requestURL = core.Concat(scheme, "://", host, request.URL.RequestURI()) + } + + headers := make([][2]string, 0, len(request.Header)+1) + hasHost := false + for name, values := range request.Header { + if core.EqualFold(name, "Host") { + hasHost = true + } + for _, value := range values { + headers = append(headers, [2]string{name, value}) + } + } + if !hasHost && request.Host != "" { + headers = append(headers, [2]string{"Host", request.Host}) + } + + body, err := readRequestBody(request) + if err != nil { + return ts.WebRequest{}, err + } + return ts.WebRequest{ + URL: requestURL, + Method: request.Method, + Headers: headers, + Body: body, + }, nil +} + +func readRequestBody(request *nethttp.Request) ([]byte, error) { + if request.Body == nil { + return nil, nil + } + readResult := core.ReadAll(request.Body) + if !readResult.OK { + return nil, core.E("angular.readRequestBody", "read request body", resultError(readResult)) + } + content, ok := readResult.Value.(string) + if !ok { + return nil, core.E("angular.readRequestBody", "request body is not text-compatible bytes", nil) + } + body := append([]byte(nil), core.AsBytes(content)...) + request.Body = &requestBody{reader: core.NewBufferReader(body)} + return body, nil +} + +func (b *requestBody) Read(buffer []byte) (int, error) { + return b.reader.Read(buffer) +} + +func (b *requestBody) Close() error { + return nil +} + +func resultError(result core.Result) error { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("angular", result.Error(), nil) +} diff --git a/go/display/http/framework/angular/server_example_test.go b/go/display/http/framework/angular/server_example_test.go new file mode 100644 index 0000000..9a494d1 --- /dev/null +++ b/go/display/http/framework/angular/server_example_test.go @@ -0,0 +1,327 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package angular_test + +import ( + nethttp "net/http" + "net/http/httptest" + "testing" + + core "dappco.re/go" + httpdisplay "dappco.re/go/render/display/http" + "dappco.re/go/render/display/http/framework/angular" + tsengine "dappco.re/go/render/engine/ts" + corets "dappco.re/go/ts" +) + +func ExampleNew() { + engine, cleanup := exampleEngine() + defer cleanup() + + serverBundle := inlineModule(` + let requests = 0; + export async function reqHandler(request) { + const path = new URL(request.url).pathname; + if (path === "/unhandled") return null; + requests += 1; + return new Response( + "
" + requests + " " + request.method + " " + path + "
", + { + status: 201, + headers: { + "content-type": "text/html; charset=utf-8", + "x-request-count": String(requests), + }, + }, + ); + } + `) + app, err := angular.New(core.Background(), engine, serverBundle) + if err != nil { + panic(err) + } + defer func() { + if err := app.Close(); err != nil { + panic(err) + } + }() + + handler := httpdisplay.Handler(nil, "", httpdisplay.WithFramework(app)) + for _, target := range []string{ + "https://example.test/first", + "https://example.test/second", + "https://example.test/unhandled", + } { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(nethttp.MethodGet, target, nil) + handler.ServeHTTP(recorder, request) + core.Println( + recorder.Code, + recorder.Header().Get("X-Request-Count"), + core.Trim(recorder.Body.String()), + ) + } + + // Output: + // 201 1
1 GET /first
+ // 201 2
2 GET /second
+ // 404 404 page not found +} + +func TestAngularExampleSidecar(t *testing.T) { + if core.Env("DENO_SOCKET") == "" { + return + } + if err := runExampleSidecar(); err != nil { + t.Fatal(err) + } +} + +type fixtureRPCRequest struct { + ID uint64 `json:"id"` + Method string `json:"method"` + Code string `json:"code"` + EntryPoint string `json:"entry_point"` + Permissions struct { + Net []string `json:"net"` + } `json:"permissions"` +} + +type fixtureCallRequest struct { + ID uint64 `json:"id"` + Export string `json:"export"` + Args []core.RawMessage `json:"args"` +} + +func exampleEngine() (*tsengine.Engine, func()) { + tempResult := core.MkdirTemp("", "go-render-angular-example-") + if !tempResult.OK { + panic(resultError(tempResult)) + } + tempDir := tempResult.Value.(string) + + executableResult := core.Executable() + if !executableResult.OK { + panic(resultError(executableResult)) + } + engine, err := tsengine.New(corets.Options{ + DenoPath: executableResult.Value.(string), + SocketPath: core.PathJoin(tempDir, "core.sock"), + DenoSocketPath: core.PathJoin(tempDir, "deno.sock"), + StoreDBPath: ":memory:", + SidecarArgs: []string{"-test.run=^TestAngularExampleSidecar$"}, + }) + if err != nil { + if removeResult := core.RemoveAll(tempDir); !removeResult.OK { + panic(resultError(removeResult)) + } + panic(err) + } + return engine, func() { + if err := engine.Close(); err != nil { + panic(err) + } + if removeResult := core.RemoveAll(tempDir); !removeResult.OK { + panic(resultError(removeResult)) + } + } +} + +func runExampleSidecar() error { + socketPath := core.Env("DENO_SOCKET") + core.Remove(socketPath) + listenResult := core.NetListen("unix", socketPath) + if !listenResult.OK { + return core.E("angular.example.sidecar", "listen on CoreTS socket", resultError(listenResult)) + } + listener := listenResult.Value.(core.Listener) + defer listener.Close() + + connection, err := listener.Accept() + if err != nil { + return core.E("angular.example.sidecar", "accept CoreTS connection", err) + } + defer connection.Close() + return serveExampleRPC(connection) +} + +func serveExampleRPC(connection core.Conn) error { + reader := core.NewBufReader(connection) + bridges := make(map[string]core.Conn) + for { + line, err := reader.ReadString('\n') + if err != nil { + return nil + } + var request fixtureRPCRequest + if result := core.JSONUnmarshalString(line, &request); !result.OK { + return core.E("angular.example.sidecar", "decode CoreTS request", resultError(result)) + } + + result := map[string]any{"ok": true} + switch request.Method { + case "Ping": + case "LoadModule": + bridge, loadErr := loadExampleModule(request) + if loadErr != nil { + result["ok"] = false + result["error"] = loadErr.Error() + } else { + bridges[request.Code] = bridge + } + case "UnloadModule": + if bridge := bridges[request.Code]; bridge != nil { + if closeErr := bridge.Close(); closeErr != nil { + result["ok"] = false + } + delete(bridges, request.Code) + } + case "ModuleStatus": + result["code"] = request.Code + if bridges[request.Code] == nil { + result["status"] = "STOPPED" + } else { + result["status"] = "RUNNING" + } + default: + result["ok"] = false + result["error"] = "unknown CoreTS method" + } + if err := writeRPCResult(connection, request.ID, result); err != nil { + return err + } + } +} + +func loadExampleModule(request fixtureRPCRequest) (core.Conn, error) { + if len(request.Permissions.Net) != 1 { + return nil, core.E("angular.example.sidecar", "resident module needs one call address", nil) + } + if err := validateExampleWrapper(request.EntryPoint); err != nil { + return nil, err + } + dialResult := core.NetDial("tcp", request.Permissions.Net[0]) + if !dialResult.OK { + return nil, core.E("angular.example.sidecar", "connect resident bridge", resultError(dialResult)) + } + connection := dialResult.Value.(core.Conn) + if result := core.WriteString(connection, "{\"ready\":true}\n"); !result.OK { + connection.Close() + return nil, core.E("angular.example.sidecar", "write resident ready frame", resultError(result)) + } + go serveExampleCalls(connection) + return connection, nil +} + +func validateExampleWrapper(entryPoint string) error { + parseResult := core.URLParse(entryPoint) + if !parseResult.OK { + return core.E("angular.example.sidecar", "parse resident wrapper URL", resultError(parseResult)) + } + wrapperURL := parseResult.Value.(*core.URL) + if !core.HasSuffix(wrapperURL.Path, "context.js") { + return core.E("angular.example.sidecar", "resident wrapper is not JavaScript", nil) + } + readResult := core.ReadFile(wrapperURL.Path) + if !readResult.OK { + return core.E("angular.example.sidecar", "read resident wrapper", resultError(readResult)) + } + wrapper := core.AsString(readResult.Value.([]byte)) + if !core.Contains(wrapper, "residentModule = await import(") || + !core.Contains(wrapper, "new Request") || + !core.Contains(wrapper, "value instanceof Response") { + return core.E("angular.example.sidecar", "resident wrapper lacks Web request bridge", nil) + } + return nil +} + +func serveExampleCalls(connection core.Conn) { + reader := core.NewBufReader(connection) + requests := 0 + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + var request fixtureCallRequest + if result := core.JSONUnmarshalString(line, &request); !result.OK { + return + } + response := map[string]any{ + "id": request.ID, + "ok": true, + } + if request.Export != "reqHandler" || len(request.Args) != 1 { + response["ok"] = false + response["error"] = "reqHandler export is required" + } else { + response["value"] = exampleAngularResponse(request.Args[0], &requests) + } + result := core.JSONMarshal(response) + if !result.OK { + return + } + if writeResult := core.WriteString(connection, core.AsString(result.Value.([]byte))+"\n"); !writeResult.OK { + return + } + } +} + +func exampleAngularResponse(raw core.RawMessage, requests *int) any { + var request struct { + Type string `json:"__go_render_type"` + URL string `json:"url"` + Method string `json:"method"` + } + if result := core.JSONUnmarshal(raw, &request); !result.OK || request.Type != "Request" { + return nil + } + parseResult := core.URLParse(request.URL) + if !parseResult.OK { + return nil + } + path := parseResult.Value.(*core.URL).Path + if path == "/unhandled" { + return nil + } + *requests++ + count := core.Itoa(*requests) + return map[string]any{ + "status": nethttp.StatusCreated, + "statusText": "Created", + "headers": [][2]string{ + {"content-type", "text/html; charset=utf-8"}, + {"x-request-count", count}, + }, + "body": []byte("
" + count + " " + request.Method + " " + path + "
"), + } +} + +func writeRPCResult(connection core.Conn, id uint64, result map[string]any) error { + responseResult := core.JSONMarshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "result": result, + }) + if !responseResult.OK { + return core.E("angular.example.sidecar", "encode CoreTS response", resultError(responseResult)) + } + writeResult := core.WriteString(connection, core.AsString(responseResult.Value.([]byte))+"\n") + if !writeResult.OK { + return core.E("angular.example.sidecar", "write CoreTS response", resultError(writeResult)) + } + return nil +} + +func inlineModule(source string) string { + return "data:application/javascript;base64," + core.Base64Encode(core.AsBytes(source)) +} + +func resultError(result core.Result) error { + if err, ok := result.Value.(error); ok { + return err + } + return core.E("angular.example", result.Error(), nil) +} diff --git a/go/display/http/framework/angular/server_test.go b/go/display/http/framework/angular/server_test.go new file mode 100644 index 0000000..3f0bb7f --- /dev/null +++ b/go/display/http/framework/angular/server_test.go @@ -0,0 +1,178 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package angular + +import ( + nethttp "net/http" + "net/http/httptest" + "testing" + + core "dappco.re/go" + "dappco.re/go/render/engine/ts" +) + +type loaderStub struct { + context *ts.Context + err error + entry string + calls int +} + +func (s *loaderStub) Load(_ core.Context, entry string) (*ts.Context, error) { + s.calls++ + s.entry = entry + return s.context, s.err +} + +type moduleStub struct { + response *ts.WebResponse + err error + export string + request ts.WebRequest + calls int + closed int +} + +func (s *moduleStub) Invoke(_ core.Context, export string, result any, args ...any) error { + s.calls++ + s.export = export + if len(args) > 0 { + s.request = args[0].(ts.WebRequest) + } + if s.err != nil { + return s.err + } + target := result.(**ts.WebResponse) + *target = s.response + return nil +} + +func (s *moduleStub) Close() error { + s.closed++ + return nil +} + +func TestServer_New_Good(t *testing.T) { + loader := &loaderStub{context: &ts.Context{}} + adapter, err := New(core.Background(), loader, "server.mjs") + core.AssertNoError(t, err) + core.AssertNotNil(t, adapter) + core.AssertEqual(t, 1, loader.calls) + core.AssertEqual(t, "server.mjs", loader.entry) + core.AssertNoError(t, adapter.Close()) +} + +func TestServer_New_Bad(t *testing.T) { + loader := &loaderStub{err: core.E("test.load", "failed", nil)} + adapter, err := New(core.Background(), loader, "broken.mjs") + core.AssertNil(t, adapter) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "load Angular server bundle") +} + +func TestServer_New_Ugly(t *testing.T) { + loader := &loaderStub{} + adapter, err := New(core.Background(), loader, " ") + core.AssertNil(t, adapter) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "bundle is required") + core.AssertEqual(t, 0, loader.calls) +} + +func TestServer_Render_Good(t *testing.T) { + module := &moduleStub{ + response: &ts.WebResponse{ + Status: nethttp.StatusCreated, + StatusText: "Created", + Headers: [][2]string{ + {"content-type", "text/html; charset=utf-8"}, + {"set-cookie", "first=one"}, + {"set-cookie", "second=two"}, + }, + Body: []byte("
Angular SSR
"), + }, + } + adapter := &renderer{module: module} + request := httptest.NewRequest( + nethttp.MethodPost, + "https://example.test/account?tab=profile", + core.NewBufferString("payload"), + ) + request.Header.Add("X-Trace", "one") + request.Header.Add("X-Trace", "two") + + response, err := adapter.Render(core.Background(), request) + + core.AssertNoError(t, err) + core.AssertEqual(t, nethttp.StatusCreated, response.Status) + core.AssertEqual(t, "
Angular SSR
", core.AsString(response.Body)) + core.AssertEqual(t, []string{"first=one", "second=two"}, response.Header.Values("Set-Cookie")) + core.AssertEqual(t, "reqHandler", module.export) + core.AssertEqual(t, "https://example.test/account?tab=profile", module.request.URL) + core.AssertEqual(t, nethttp.MethodPost, module.request.Method) + core.AssertEqual(t, []byte("payload"), module.request.Body) + core.AssertEqual(t, []string{"one", "two"}, webHeaderValues(module.request.Headers, "X-Trace")) + core.AssertEqual(t, []string{"example.test"}, webHeaderValues(module.request.Headers, "Host")) + + restored := core.ReadAll(request.Body) + core.RequireTrue(t, restored.OK, restored.Error()) + core.AssertEqual(t, "payload", restored.Value) +} + +func TestServer_Render_Bad(t *testing.T) { + module := &moduleStub{err: core.E("test.invoke", "failed", nil)} + adapter := &renderer{module: module} + request := httptest.NewRequest(nethttp.MethodGet, "https://example.test/", nil) + + response, err := adapter.Render(core.Background(), request) + + core.AssertNil(t, response) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "invoke Angular request handler") +} + +func TestServer_Render_Ugly(t *testing.T) { + module := &moduleStub{} + adapter := &renderer{module: module} + request := httptest.NewRequest(nethttp.MethodGet, "https://example.test/unhandled", nil) + + response, err := adapter.Render(core.Background(), request) + + core.AssertNoError(t, err) + core.AssertNil(t, response) + core.AssertEqual(t, 1, module.calls) +} + +func TestServer_Close_Good(t *testing.T) { + module := &moduleStub{} + adapter := &renderer{module: module} + core.AssertNoError(t, adapter.Close()) + core.AssertEqual(t, 1, module.closed) +} + +func TestServer_Close_Bad(t *testing.T) { + var adapter *renderer + err := adapter.Close() + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "renderer is nil") +} + +func TestServer_Close_Ugly(t *testing.T) { + module := &moduleStub{} + adapter := &renderer{module: module} + core.AssertNoError(t, adapter.Close()) + core.AssertNoError(t, adapter.Close()) + core.AssertEqual(t, 1, module.closed) +} + +func webHeaderValues(headers [][2]string, name string) []string { + values := make([]string, 0) + for _, header := range headers { + if core.EqualFold(header[0], name) { + values = append(values, header[1]) + } + } + return values +} diff --git a/go/display/http/framework/framework.go b/go/display/http/framework/framework.go new file mode 100644 index 0000000..16b84aa --- /dev/null +++ b/go/display/http/framework/framework.go @@ -0,0 +1,33 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +// Package framework defines the HTTP-neutral boundary implemented by +// server-rendering framework adapters. +package framework + +import ( + nethttp "net/http" + + core "dappco.re/go" + "dappco.re/go/render/engine/ts" +) + +// Loader creates resident CoreTS contexts for framework server bundles. +type Loader interface { + Load(core.Context, string) (*ts.Context, error) +} + +// Renderer translates an HTTP request through a server-rendering framework. +// A nil response means that the framework did not handle the request. +type Renderer interface { + Render(core.Context, *nethttp.Request) (*Response, error) + Close() error +} + +// Response is a framework-rendered HTTP response. +type Response struct { + Status int + Header nethttp.Header + Body []byte +} diff --git a/go/display/http/framework/framework_example_test.go b/go/display/http/framework/framework_example_test.go new file mode 100644 index 0000000..a1605cc --- /dev/null +++ b/go/display/http/framework/framework_example_test.go @@ -0,0 +1,30 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package framework + +import ( + core "dappco.re/go" +) + +func ExampleLoader() { + var loader Loader + core.Println(loader == nil) + // Output: true +} + +func ExampleRenderer() { + var renderer Renderer + core.Println(renderer == nil) + // Output: true +} + +func ExampleResponse() { + response := Response{ + Status: 200, + Body: []byte("
rendered
"), + } + core.Println(response.Status, core.AsString(response.Body)) + // Output: 200
rendered
+} diff --git a/go/display/http/framework/framework_test.go b/go/display/http/framework/framework_test.go new file mode 100644 index 0000000..3e95c80 --- /dev/null +++ b/go/display/http/framework/framework_test.go @@ -0,0 +1,52 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package framework + +import ( + nethttp "net/http" + "testing" + + core "dappco.re/go" + "dappco.re/go/render/engine/ts" +) + +type loaderContract struct{} + +func (loaderContract) Load(core.Context, string) (*ts.Context, error) { + return &ts.Context{}, nil +} + +type rendererContract struct{} + +func (rendererContract) Render(core.Context, *nethttp.Request) (*Response, error) { + return &Response{Status: nethttp.StatusNoContent}, nil +} + +func (rendererContract) Close() error { + return nil +} + +func TestFramework_Contracts(t *testing.T) { + var loader Loader = loaderContract{} + var renderer Renderer = rendererContract{} + context, err := loader.Load(core.Background(), "server.mjs") + core.AssertNoError(t, err) + core.AssertNotNil(t, context) + response, err := renderer.Render(core.Background(), &nethttp.Request{}) + core.AssertNoError(t, err) + core.AssertEqual(t, nethttp.StatusNoContent, response.Status) + core.AssertNoError(t, renderer.Close()) +} + +func TestFramework_Response(t *testing.T) { + response := Response{ + Status: nethttp.StatusAccepted, + Header: nethttp.Header{"X-Framework": {"Angular"}}, + Body: []byte("
accepted
"), + } + core.AssertEqual(t, nethttp.StatusAccepted, response.Status) + core.AssertEqual(t, "Angular", response.Header.Get("X-Framework")) + core.AssertEqual(t, []byte("
accepted
"), response.Body) +} diff --git a/go/display/http/http.go b/go/display/http/http.go index 35842ec..d19f965 100644 --- a/go/display/http/http.go +++ b/go/display/http/http.go @@ -8,6 +8,7 @@ import ( nethttp "net/http" core "dappco.re/go" + "dappco.re/go/render/display/http/framework" tsengine "dappco.re/go/render/engine/ts" ) @@ -15,7 +16,8 @@ import ( type Option func(*handlerOptions) type handlerOptions struct { - entry string + entry string + framework framework.Renderer } // WithEntry selects the TypeScript or JavaScript SSR entry module. @@ -25,6 +27,13 @@ func WithEntry(entry string) Option { } } +// WithFramework mounts a resident server-rendering framework adapter. +func WithFramework(renderer framework.Renderer) Option { + return func(options *handlerOptions) { + options.framework = renderer + } +} + // Handler returns an HTTP handler that serves files found under assetsDir and // server-renders every other request through engine. // @@ -48,6 +57,21 @@ func Handler(engine tsengine.Renderer, assetsDir string, opts ...Option) nethttp assets.ServeHTTP(writer, request) return } + if options.framework != nil { + response, err := options.framework.Render(request.Context(), request) + if err != nil { + nethttp.Error(writer, "framework render failed", nethttp.StatusInternalServerError) + return + } + if response != nil { + writeFrameworkResponse(writer, request, response) + return + } + if engine == nil && core.Trim(options.entry) == "" { + nethttp.NotFound(writer, request) + return + } + } if engine == nil { nethttp.Error(writer, "render engine unavailable", nethttp.StatusInternalServerError) return @@ -77,6 +101,33 @@ func Handler(engine tsengine.Renderer, assetsDir string, opts ...Option) nethttp }) } +func writeFrameworkResponse( + writer nethttp.ResponseWriter, + request *nethttp.Request, + response *framework.Response, +) { + status := response.Status + if status == 0 { + status = nethttp.StatusOK + } + if status < 100 || status > 999 { + nethttp.Error(writer, "framework response invalid", nethttp.StatusInternalServerError) + return + } + for name, values := range response.Header { + for _, value := range values { + writer.Header().Add(name, value) + } + } + writer.WriteHeader(status) + if request.Method == nethttp.MethodHead { + return + } + if _, err := writer.Write(response.Body); err != nil { + core.Warn("Framework HTTP response write failed", "err", err) + } +} + func isAssetRequest(assetsDir string, request *nethttp.Request) bool { if request.Method != nethttp.MethodGet && request.Method != nethttp.MethodHead { return false diff --git a/go/display/http/http_example_test.go b/go/display/http/http_example_test.go index bae2c70..a09de59 100644 --- a/go/display/http/http_example_test.go +++ b/go/display/http/http_example_test.go @@ -11,3 +11,7 @@ func ExampleHandler() { func ExampleWithEntry() { _ = WithEntry("server.ts") } + +func ExampleWithFramework() { + _ = WithFramework +} diff --git a/go/display/http/http_test.go b/go/display/http/http_test.go index ed91276..23e9a85 100644 --- a/go/display/http/http_test.go +++ b/go/display/http/http_test.go @@ -10,6 +10,7 @@ import ( "testing" core "dappco.re/go" + "dappco.re/go/render/display/http/framework" ) type renderStub struct { @@ -20,6 +21,21 @@ type renderStub struct { input any } +type frameworkStub struct { + response *framework.Response + err error + calls int +} + +func (s *frameworkStub) Render(_ core.Context, _ *nethttp.Request) (*framework.Response, error) { + s.calls++ + return s.response, s.err +} + +func (s *frameworkStub) Close() error { + return nil +} + func (s *renderStub) Render(_ core.Context, entry string, input any) ([]byte, error) { s.calls++ s.entry = entry @@ -110,3 +126,53 @@ func TestHttp_WithEntry_Ugly(t *testing.T) { core.AssertEqual(t, "second.ts", renderer.entry) core.AssertEqual(t, "last wins", recorder.Body.String()) } + +func TestHttp_WithFramework_Good(t *testing.T) { + renderer := &frameworkStub{ + response: &framework.Response{ + Status: nethttp.StatusCreated, + Header: nethttp.Header{ + "Content-Type": {"text/html; charset=utf-8"}, + "Set-Cookie": {"first=one", "second=two"}, + "X-Rendered": {"Angular"}, + }, + Body: []byte("
Angular SSR
"), + }, + } + handler := Handler(nil, "", WithFramework(renderer)) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(nethttp.MethodGet, "https://example.test/account", nil) + + handler.ServeHTTP(recorder, request) + + core.AssertEqual(t, nethttp.StatusCreated, recorder.Code) + core.AssertEqual(t, "
Angular SSR
", recorder.Body.String()) + core.AssertEqual(t, []string{"first=one", "second=two"}, recorder.Header().Values("Set-Cookie")) + core.AssertEqual(t, "Angular", recorder.Header().Get("X-Rendered")) + core.AssertEqual(t, 1, renderer.calls) +} + +func TestHttp_WithFramework_Bad(t *testing.T) { + renderer := &frameworkStub{err: core.E("test.framework", "render failed", nil)} + handler := Handler(nil, "", WithFramework(renderer)) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(nethttp.MethodGet, "https://example.test/", nil) + + handler.ServeHTTP(recorder, request) + + core.AssertEqual(t, nethttp.StatusInternalServerError, recorder.Code) + core.AssertContains(t, recorder.Body.String(), "framework render failed") + core.AssertEqual(t, 1, renderer.calls) +} + +func TestHttp_WithFramework_Ugly(t *testing.T) { + renderer := &frameworkStub{} + handler := Handler(nil, "", WithFramework(renderer)) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(nethttp.MethodGet, "https://example.test/unhandled", nil) + + handler.ServeHTTP(recorder, request) + + core.AssertEqual(t, nethttp.StatusNotFound, recorder.Code) + core.AssertEqual(t, 1, renderer.calls) +} diff --git a/go/engine/ts/context.go b/go/engine/ts/context.go new file mode 100644 index 0000000..60f950e --- /dev/null +++ b/go/engine/ts/context.go @@ -0,0 +1,453 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package ts + +import ( + core "dappco.re/go" + corets "dappco.re/go/ts" +) + +const contextConnectTimeout = 10 * core.Second + +// Context is a resident CoreTS module whose exported functions can be invoked +// repeatedly without re-importing the module. +type Context struct { + callMu core.Mutex + stateMu core.RWMutex + service *corets.Service + conn core.Conn + reader *core.BufReader + identity string + workspace string + nextID uint64 + closed bool + closeErr error +} + +type contextRequest struct { + ID uint64 `json:"id"` + Export string `json:"export"` + Args []any `json:"args"` +} + +type contextResponse struct { + ID uint64 `json:"id"` + OK bool `json:"ok"` + Value core.RawMessage `json:"value"` + Error string `json:"error"` +} + +type contextReady struct { + Ready bool `json:"ready"` + Error string `json:"error"` +} + +// Invoke calls an exported function in the resident module. Arguments are +// serialised to JSON and result, when non-nil, receives the decoded result. +func (c *Context) Invoke(ctx core.Context, export string, result any, args ...any) error { + if c == nil { + return core.E("ts.Context.Invoke", "context is nil", nil) + } + if ctx == nil { + return core.E("ts.Context.Invoke", "call context is nil", nil) + } + if err := ctx.Err(); err != nil { + return core.E("ts.Context.Invoke", "call context is done", err) + } + export = core.Trim(export) + if export == "" { + return core.E("ts.Context.Invoke", "export is required", nil) + } + + c.callMu.Lock() + defer c.callMu.Unlock() + + c.stateMu.Lock() + if c.closed || c.conn == nil || c.reader == nil { + c.stateMu.Unlock() + return core.E("ts.Context.Invoke", "context is closed", nil) + } + connection := c.conn + reader := c.reader + c.nextID++ + requestID := c.nextID + c.stateMu.Unlock() + + requestResult := core.JSONMarshal(contextRequest{ + ID: requestID, + Export: export, + Args: contextArguments(args), + }) + if !requestResult.OK { + return core.E("ts.Context.Invoke", "serialise call", resultError(requestResult)) + } + writeResult := core.WriteString(connection, core.AsString(requestResult.Value.([]byte))+"\n") + if !writeResult.OK { + return core.E("ts.Context.Invoke", "write call", resultError(writeResult)) + } + + line, err := reader.ReadString('\n') + if err != nil { + return core.E("ts.Context.Invoke", "read call result", err) + } + if err := ctx.Err(); err != nil { + return core.E("ts.Context.Invoke", "call context is done", err) + } + + var response contextResponse + responseResult := core.JSONUnmarshalString(line, &response) + if !responseResult.OK { + return core.E("ts.Context.Invoke", "decode call result", resultError(responseResult)) + } + if response.ID != requestID { + return core.E("ts.Context.Invoke", "call result identity does not match", nil) + } + if !response.OK { + message := core.Trim(response.Error) + if message == "" { + message = "export invocation failed" + } + return core.E("ts.Context.Invoke", message, nil) + } + if result == nil { + return nil + } + decodeResult := core.JSONUnmarshal(response.Value, result) + if !decodeResult.OK { + return core.E("ts.Context.Invoke", "decode exported result", resultError(decodeResult)) + } + return nil +} + +// Close unloads the resident module and releases its call connection and +// isolated workspace. It is safe to call Close more than once. +func (c *Context) Close() error { + if c == nil { + return core.E("ts.Context.Close", "context is nil", nil) + } + return c.close() +} + +func (c *Context) close() error { + c.stateMu.Lock() + if c.closed { + err := c.closeErr + c.stateMu.Unlock() + return err + } + c.closed = true + connection := c.conn + service := c.service + identity := c.identity + workspace := c.workspace + c.conn = nil + c.reader = nil + c.service = nil + c.workspace = "" + c.stateMu.Unlock() + + var closeErr error + if connection != nil { + if err := connection.Close(); err != nil { + closeErr = core.E("ts.Context.Close", "close call connection", err) + } + } + if service != nil && identity != "" { + response, err := service.UnloadModule(identity) + if err != nil { + closeErr = core.ErrorJoin( + closeErr, + core.E("ts.Context.Close", "unload resident module", err), + ) + } else if response == nil || !response.Ok { + closeErr = core.ErrorJoin( + closeErr, + core.E("ts.Context.Close", "resident module did not unload", nil), + ) + } + } + if workspace != "" { + removeResult := core.RemoveAll(workspace) + if !removeResult.OK { + closeErr = core.ErrorJoin( + closeErr, + core.E("ts.Context.Close", "remove resident workspace", resultError(removeResult)), + ) + } + } + + c.stateMu.Lock() + c.closeErr = closeErr + c.stateMu.Unlock() + return closeErr +} + +func (e *Engine) loadContext(ctx core.Context, entry string) (_ *Context, returnErr error) { + if e == nil { + return nil, core.E("ts.Engine.Load", "engine is nil", nil) + } + if ctx == nil { + return nil, core.E("ts.Engine.Load", "context is nil", nil) + } + if err := ctx.Err(); err != nil { + return nil, core.E("ts.Engine.Load", "load context is done", err) + } + + e.mu.Lock() + defer e.mu.Unlock() + + if e.closed || e.service == nil { + return nil, core.E("ts.Engine.Load", "engine is closed", nil) + } + + entryPoint, entryReads, err := e.entryPoint("ts.Engine.Load", entry) + if err != nil { + return nil, err + } + randomResult := core.RandomString(16) + if !randomResult.OK { + return nil, core.E("ts.Engine.Load", "create context identity", resultError(randomResult)) + } + identity := contextModulePrefix + randomResult.Value.(string) + workspaceResult := core.MkdirTemp(e.tempDir, identity+"-") + if !workspaceResult.OK { + return nil, core.E("ts.Engine.Load", "create resident workspace", resultError(workspaceResult)) + } + workspace := workspaceResult.Value.(string) + + listenResult := core.NetListen("tcp", "127.0.0.1:0") + if !listenResult.OK { + removeResult := core.RemoveAll(workspace) + if !removeResult.OK { + return nil, core.E("ts.Engine.Load", "clean up after call listener failure", resultError(removeResult)) + } + return nil, core.E("ts.Engine.Load", "listen for resident calls", resultError(listenResult)) + } + listener := listenResult.Value.(core.Listener) + moduleLoaded := false + keepWorkspace := false + defer func() { + if closeErr := listener.Close(); closeErr != nil && returnErr == nil { + returnErr = core.E("ts.Engine.Load", "close resident call listener", closeErr) + } + if keepWorkspace { + return + } + if moduleLoaded { + if response, unloadErr := e.service.UnloadModule(identity); unloadErr != nil { + core.Warn("TypeScript resident module cleanup failed", "module", identity, "err", unloadErr) + } else if response == nil || !response.Ok { + core.Warn("TypeScript resident module did not unload during cleanup", "module", identity) + } + } + if removeResult := core.RemoveAll(workspace); !removeResult.OK { + core.Warn("TypeScript resident workspace cleanup failed", "path", workspace, "err", resultError(removeResult)) + } + }() + + tcpAddress, ok := listener.Addr().(*core.TCPAddr) + if !ok { + return nil, core.E("ts.Engine.Load", "resident call listener is not TCP", nil) + } + wrapper, err := contextWrapper(entryPoint, tcpAddress.IP.String(), tcpAddress.Port) + if err != nil { + return nil, err + } + wrapperPath := core.PathJoin(workspace, "context.js") + writeResult := core.WriteFile(wrapperPath, core.AsBytes(wrapper), 0o600) + if !writeResult.OK { + return nil, core.E("ts.Engine.Load", "write resident wrapper", resultError(writeResult)) + } + + address := listener.Addr().String() + loadResponse, err := e.service.LoadModule(identity, fileURL(wrapperPath), corets.ModulePermissions{ + Read: append([]string{workspace}, entryReads...), + Net: []string{address}, + }) + if err != nil { + return nil, core.E("ts.Engine.Load", "load resident module", err) + } + if loadResponse == nil || !loadResponse.Ok { + message := "TypeScript resident module was rejected" + if loadResponse != nil && loadResponse.Error != "" { + message = loadResponse.Error + } + return nil, core.E("ts.Engine.Load", message, nil) + } + moduleLoaded = true + + deadline := core.Now().Add(contextConnectTimeout) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { + deadline = contextDeadline + } + if tcpListener, ok := listener.(*core.TCPListener); ok { + if err := tcpListener.SetDeadline(deadline); err != nil { + return nil, core.E("ts.Engine.Load", "set resident connection deadline", err) + } + } + connection, err := listener.Accept() + if err != nil { + return nil, core.E("ts.Engine.Load", "accept resident call connection", err) + } + reader := core.NewBufReader(connection) + line, err := reader.ReadString('\n') + if err != nil { + if closeErr := connection.Close(); closeErr != nil { + core.Warn("TypeScript resident connection cleanup failed", "err", closeErr) + } + return nil, core.E("ts.Engine.Load", "read resident ready frame", err) + } + var ready contextReady + readyResult := core.JSONUnmarshalString(line, &ready) + if !readyResult.OK { + if closeErr := connection.Close(); closeErr != nil { + core.Warn("TypeScript resident connection cleanup failed", "err", closeErr) + } + return nil, core.E("ts.Engine.Load", "decode resident ready frame", resultError(readyResult)) + } + if !ready.Ready { + if closeErr := connection.Close(); closeErr != nil { + core.Warn("TypeScript resident connection cleanup failed", "err", closeErr) + } + message := core.Trim(ready.Error) + if message == "" { + message = "resident module did not become ready" + } + return nil, core.E("ts.Engine.Load", message, nil) + } + + resident := &Context{ + service: e.service, + conn: connection, + reader: reader, + identity: identity, + workspace: workspace, + } + if e.contexts == nil { + e.contexts = make(map[*Context]struct{}) + } + e.contexts[resident] = struct{}{} + keepWorkspace = true + return resident, nil +} + +func contextWrapper(entryPoint, hostname string, port int) (string, error) { + entryJSON, err := marshalJSON("ts.Engine.Load", "serialise module entry", entryPoint) + if err != nil { + return "", err + } + hostnameJSON, err := marshalJSON("ts.Engine.Load", "serialise call hostname", hostname) + if err != nil { + return "", err + } + + return core.Concat( + "// Generated by dappco.re/go/render/engine/ts.\n", + "let residentModule;\n", + "let residentConnection;\n", + "const residentEncoder = new TextEncoder();\n", + "const residentDecoder = new TextDecoder();\n", + "export async function init() {\n", + " residentModule = await import(", entryJSON, ");\n", + " residentConnection = await Deno.connect({ transport: \"tcp\", hostname: ", hostnameJSON, ", port: ", core.Itoa(port), " });\n", + " await writeResidentFrame({ ready: true });\n", + " void serveResidentFrames().catch(() => {});\n", + "}\n", + "async function serveResidentFrames() {\n", + " let pending = \"\";\n", + " const buffer = new Uint8Array(64 * 1024);\n", + " for (;;) {\n", + " const count = await residentConnection.read(buffer);\n", + " if (count === null) return;\n", + " pending += residentDecoder.decode(buffer.subarray(0, count), { stream: true });\n", + " for (;;) {\n", + " const newline = pending.indexOf(\"\\n\");\n", + " if (newline < 0) break;\n", + " const line = pending.slice(0, newline);\n", + " pending = pending.slice(newline + 1);\n", + " if (line.length === 0) continue;\n", + " await dispatchResidentCall(line);\n", + " }\n", + " }\n", + "}\n", + "async function dispatchResidentCall(line) {\n", + " let request;\n", + " try {\n", + " request = JSON.parse(line);\n", + " const callable = residentModule[request.export];\n", + " if (typeof callable !== \"function\") throw new Error(\"export is not callable: \" + request.export);\n", + " const args = (request.args ?? []).map(decodeResidentArgument);\n", + " const value = await callable(...args);\n", + " await writeResidentFrame({ id: request.id, ok: true, value: await encodeResidentValue(value) });\n", + " } catch (error) {\n", + " await writeResidentFrame({\n", + " id: request?.id ?? 0,\n", + " ok: false,\n", + " error: error instanceof Error ? error.message : String(error),\n", + " });\n", + " }\n", + "}\n", + "function decodeResidentArgument(value) {\n", + " if (!value || value.__go_render_type !== \"Request\") return value;\n", + " const method = String(value.method || \"GET\").toUpperCase();\n", + " const init = { method, headers: new Headers(value.headers ?? []) };\n", + " if (value.body && method !== \"GET\" && method !== \"HEAD\") init.body = residentBase64ToBytes(value.body);\n", + " return new Request(value.url, init);\n", + "}\n", + "async function encodeResidentValue(value) {\n", + " if (!(value instanceof Response)) return value === undefined ? null : value;\n", + " const headers = [];\n", + " const hasSetCookie = typeof value.headers.getSetCookie === \"function\";\n", + " for (const header of value.headers.entries()) {\n", + " if (header[0].toLowerCase() !== \"set-cookie\" || !hasSetCookie) headers.push(header);\n", + " }\n", + " if (hasSetCookie) {\n", + " for (const cookie of value.headers.getSetCookie()) headers.push([\"set-cookie\", cookie]);\n", + " }\n", + " const body = new Uint8Array(await value.arrayBuffer());\n", + " return { status: value.status, statusText: value.statusText, headers, body: residentBytesToBase64(body) };\n", + "}\n", + "function residentBase64ToBytes(value) {\n", + " const binary = atob(value);\n", + " const bytes = new Uint8Array(binary.length);\n", + " for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);\n", + " return bytes;\n", + "}\n", + "function residentBytesToBase64(bytes) {\n", + " const chunks = [];\n", + " const chunkSize = 0x6000;\n", + " for (let offset = 0; offset < bytes.length; offset += chunkSize) {\n", + " let binary = \"\";\n", + " for (const byte of bytes.subarray(offset, offset + chunkSize)) binary += String.fromCharCode(byte);\n", + " chunks.push(btoa(binary));\n", + " }\n", + " return chunks.join(\"\");\n", + "}\n", + "async function writeResidentFrame(value) {\n", + " const data = residentEncoder.encode(JSON.stringify(value) + \"\\n\");\n", + " let offset = 0;\n", + " while (offset < data.length) offset += await residentConnection.write(data.subarray(offset));\n", + "}\n", + ), nil +} + +func contextArguments(args []any) []any { + encoded := make([]any, len(args)) + for index, argument := range args { + switch request := argument.(type) { + case WebRequest: + encoded[index] = webRequestValue(request) + case *WebRequest: + if request == nil { + encoded[index] = nil + } else { + encoded[index] = webRequestValue(*request) + } + default: + encoded[index] = argument + } + } + return encoded +} diff --git a/go/engine/ts/context_example_test.go b/go/engine/ts/context_example_test.go new file mode 100644 index 0000000..a1f947f --- /dev/null +++ b/go/engine/ts/context_example_test.go @@ -0,0 +1,13 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package ts + +func ExampleContext_Invoke() { + _ = (*Context).Invoke +} + +func ExampleContext_Close() { + _ = (*Context).Close +} diff --git a/go/engine/ts/context_test.go b/go/engine/ts/context_test.go new file mode 100644 index 0000000..411bf39 --- /dev/null +++ b/go/engine/ts/context_test.go @@ -0,0 +1,189 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package ts + +import ( + "testing" + + core "dappco.re/go" +) + +func TestContext_Invoke_Good(t *testing.T) { + context := fixtureContext(t) + defer func() { core.AssertNoError(t, context.Close()) }() + + var output int + err := context.Invoke(core.Background(), "increment", &output, 2) + core.AssertNoError(t, err) + core.AssertEqual(t, 2, output) +} + +func TestContext_Invoke_Bad(t *testing.T) { + context := fixtureContext(t) + defer func() { core.AssertNoError(t, context.Close()) }() + + var output int + err := context.Invoke(core.Background(), "missing", &output) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "export") +} + +func TestContext_Invoke_Ugly(t *testing.T) { + context := fixtureContext(t) + defer func() { core.AssertNoError(t, context.Close()) }() + + var first int + var second int + core.AssertNoError(t, context.Invoke(core.Background(), "increment", &first, 3)) + core.AssertNoError(t, context.Invoke(core.Background(), "increment", &second, 4)) + core.AssertEqual(t, 3, first) + core.AssertEqual(t, 7, second) +} + +func TestContext_Invoke_WebRequest(t *testing.T) { + context := fixtureContext(t) + defer func() { core.AssertNoError(t, context.Close()) }() + + var response WebResponse + err := context.Invoke(core.Background(), "inspectRequest", &response, WebRequest{ + URL: "https://example.test/account?tab=profile", + Method: "POST", + Headers: [][2]string{ + {"Content-Type", "text/plain"}, + {"X-Trace", "one"}, + {"X-Trace", "two"}, + }, + Body: []byte("hello"), + }) + core.AssertNoError(t, err) + core.AssertEqual(t, 202, response.Status) + core.AssertEqual(t, "Accepted", response.StatusText) + core.AssertEqual(t, [][2]string{{"content-type", "text/html"}, {"set-cookie", "one=1"}, {"set-cookie", "two=2"}}, response.Headers) + core.AssertEqual(t, []byte("
POST /account hello
"), response.Body) +} + +func TestContext_Close_Good(t *testing.T) { + context := fixtureContext(t) + core.AssertNoError(t, context.Close()) + + var output int + err := context.Invoke(core.Background(), "increment", &output, 1) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "closed") +} + +func TestContext_Close_Bad(t *testing.T) { + var context *Context + err := context.Close() + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "context is nil") +} + +func TestContext_Close_Ugly(t *testing.T) { + context := fixtureContext(t) + core.AssertNoError(t, context.Close()) + core.AssertNoError(t, context.Close()) +} + +func TestContext_contextWrapper_WebValues(t *testing.T) { + wrapper, err := contextWrapper( + "data:application/javascript,export%20function%20reqHandler(){}", + "127.0.0.1", + 8080, + ) + core.AssertNoError(t, err) + core.AssertContains(t, wrapper, "new Request") + core.AssertContains(t, wrapper, "value instanceof Response") + core.AssertContains(t, wrapper, "getSetCookie") +} + +func fixtureContext(t *testing.T) *Context { + t.Helper() + + client, server := core.NetPipe() + go serveFixtureContext(server) + + return &Context{ + conn: client, + reader: core.NewBufReader(client), + } +} + +func serveFixtureContext(connection core.Conn) { + defer connection.Close() + + reader := core.NewBufReader(connection) + count := 0 + for { + line, err := reader.ReadString('\n') + if err != nil { + return + } + + var request struct { + ID uint64 `json:"id"` + Export string `json:"export"` + Args []core.RawMessage `json:"args"` + } + if result := core.JSONUnmarshalString(line, &request); !result.OK { + return + } + + response := map[string]any{ + "id": request.ID, + "ok": true, + } + switch request.Export { + case "increment": + if len(request.Args) > 0 { + var by int + if result := core.JSONUnmarshal(request.Args[0], &by); !result.OK { + return + } + count += by + } + response["value"] = count + case "inspectRequest": + var input struct { + Type string `json:"__go_render_type"` + URL string `json:"url"` + Method string `json:"method"` + Body []byte `json:"body"` + } + if len(request.Args) == 0 { + return + } + if result := core.JSONUnmarshal(request.Args[0], &input); !result.OK { + return + } + if input.Type != "Request" || input.URL != "https://example.test/account?tab=profile" { + response["ok"] = false + response["error"] = "Web Request was not encoded" + break + } + response["value"] = map[string]any{ + "status": 202, + "statusText": "Accepted", + "headers": [][2]string{ + {"content-type", "text/html"}, + {"set-cookie", "one=1"}, + {"set-cookie", "two=2"}, + }, + "body": []byte("
" + input.Method + " /account " + core.AsString(input.Body) + "
"), + } + default: + response["ok"] = false + response["error"] = "export is not callable" + } + + result := core.JSONMarshal(response) + if !result.OK { + return + } + if write := core.WriteString(connection, core.AsString(result.Value.([]byte))+"\n"); !write.OK { + return + } + } +} diff --git a/go/engine/ts/engine.go b/go/engine/ts/engine.go index b2db43d..65b9d1f 100644 --- a/go/engine/ts/engine.go +++ b/go/engine/ts/engine.go @@ -10,8 +10,9 @@ import ( ) const ( - renderModulePrefix = "go-render-" - renderGroupPrefix = "go-render-ts-" + contextModulePrefix = "go-render-context-" + renderModulePrefix = "go-render-" + renderGroupPrefix = "go-render-ts-" ) // Renderer is the content-producing contract shared by the server-side @@ -22,12 +23,13 @@ type Renderer interface { // Engine owns a CoreTS service and its managed Deno sidecar. type Engine struct { - mu core.RWMutex - app *core.Core - service *corets.Service - tempDir string - appRoot string - closed bool + mu core.RWMutex + app *core.Core + service *corets.Service + tempDir string + appRoot string + contexts map[*Context]struct{} + closed bool } // New starts a CoreTS service and its configured Deno sidecar. @@ -87,13 +89,20 @@ func New(opts corets.Options) (*Engine, error) { } return &Engine{ - app: app, - service: service, - tempDir: tempDir, - appRoot: canonicalPath(opts.AppRoot), + app: app, + service: service, + tempDir: tempDir, + appRoot: canonicalPath(opts.AppRoot), + contexts: make(map[*Context]struct{}), }, nil } +// Load imports entry once into a resident CoreTS context. The returned context +// can invoke exported functions repeatedly until it or the engine is closed. +func (e *Engine) Load(ctx core.Context, entry string) (*Context, error) { + return e.loadContext(ctx, entry) +} + // Render loads a permission-scoped wrapper module in CoreTS, invokes the // entry's render export with input, and returns its HTML or byte output. // @@ -119,7 +128,7 @@ func (e *Engine) Render(ctx core.Context, entry string, input any) ([]byte, erro return nil, core.E("ts.Engine.Render", "engine is closed", nil) } - entryPoint, entryReads, err := e.entryPoint(entry) + entryPoint, entryReads, err := e.entryPoint("ts.Engine.Render", entry) if err != nil { return nil, err } @@ -208,16 +217,28 @@ func (e *Engine) Close() error { e.closed = true var closeErr error + for context := range e.contexts { + if err := context.close(); err != nil { + closeErr = core.ErrorJoin(closeErr, err) + } + } + e.contexts = nil if e.app != nil { shutdownResult := e.app.ServiceShutdown(core.Background()) if !shutdownResult.OK { - closeErr = core.E("ts.Engine.Close", "stop CoreTS service", resultError(shutdownResult)) + closeErr = core.ErrorJoin( + closeErr, + core.E("ts.Engine.Close", "stop CoreTS service", resultError(shutdownResult)), + ) } } if e.tempDir != "" { removeResult := core.RemoveAll(e.tempDir) - if !removeResult.OK && closeErr == nil { - closeErr = core.E("ts.Engine.Close", "remove render workspace", resultError(removeResult)) + if !removeResult.OK { + closeErr = core.ErrorJoin( + closeErr, + core.E("ts.Engine.Close", "remove render workspace", resultError(removeResult)), + ) } } @@ -227,10 +248,10 @@ func (e *Engine) Close() error { return closeErr } -func (e *Engine) entryPoint(entry string) (string, []string, error) { +func (e *Engine) entryPoint(operation, entry string) (string, []string, error) { entry = core.Trim(entry) if entry == "" { - return "", nil, core.E("ts.Engine.Render", "entry is required", nil) + return "", nil, core.E(operation, "entry is required", nil) } if core.HasPrefix(entry, "data:") { return entry, nil, nil @@ -240,43 +261,43 @@ func (e *Engine) entryPoint(entry string) (string, []string, error) { if core.HasPrefix(entry, "file:") { parseResult := core.URLParse(entry) if !parseResult.OK { - return "", nil, core.E("ts.Engine.Render", "parse file entry URL", resultError(parseResult)) + return "", nil, core.E(operation, "parse file entry URL", resultError(parseResult)) } parsed := parseResult.Value.(*core.URL) if parsed.Scheme != "file" || (parsed.Host != "" && parsed.Host != "localhost") { - return "", nil, core.E("ts.Engine.Render", "entry must be a local file URL", nil) + return "", nil, core.E(operation, "entry must be a local file URL", nil) } path = parsed.Path } else if !core.PathIsAbs(entry) { parseResult := core.URLParse(entry) if parseResult.OK && parseResult.Value.(*core.URL).Scheme != "" { - return "", nil, core.E("ts.Engine.Render", "remote module entries are not permitted", nil) + return "", nil, core.E(operation, "remote module entries are not permitted", nil) } } absoluteResult := core.PathAbs(path) if !absoluteResult.OK { - return "", nil, core.E("ts.Engine.Render", "resolve module entry", resultError(absoluteResult)) + return "", nil, core.E(operation, "resolve module entry", resultError(absoluteResult)) } absolute := absoluteResult.Value.(string) resolvedResult := core.PathEvalSymlinks(absolute) if !resolvedResult.OK { - return "", nil, core.E("ts.Engine.Render", "resolve module entry symlinks", resultError(resolvedResult)) + return "", nil, core.E(operation, "resolve module entry symlinks", resultError(resolvedResult)) } resolved := resolvedResult.Value.(string) statResult := core.Stat(resolved) if !statResult.OK { - return "", nil, core.E("ts.Engine.Render", "stat module entry", resultError(statResult)) + return "", nil, core.E(operation, "stat module entry", resultError(statResult)) } if statResult.Value.(core.FsFileInfo).IsDir() { - return "", nil, core.E("ts.Engine.Render", "module entry is a directory", nil) + return "", nil, core.E(operation, "module entry is a directory", nil) } readRoot := core.PathDir(resolved) if e.appRoot != "" { if !corets.CheckPath(resolved, []string{e.appRoot}) { - return "", nil, core.E("ts.Engine.Render", "module entry is outside CoreTS AppRoot", nil) + return "", nil, core.E(operation, "module entry is outside CoreTS AppRoot", nil) } readRoot = e.appRoot } diff --git a/go/engine/ts/engine_example_test.go b/go/engine/ts/engine_example_test.go index a45377b..39840cd 100644 --- a/go/engine/ts/engine_example_test.go +++ b/go/engine/ts/engine_example_test.go @@ -12,6 +12,10 @@ func ExampleEngine_Render() { _ = (*Engine).Render } +func ExampleEngine_Load() { + _ = (*Engine).Load +} + func ExampleEngine_Close() { _ = (*Engine).Close } diff --git a/go/engine/ts/engine_test.go b/go/engine/ts/engine_test.go index 56eb47e..79e739e 100644 --- a/go/engine/ts/engine_test.go +++ b/go/engine/ts/engine_test.go @@ -69,6 +69,55 @@ func TestEngine_Render_Ugly(t *testing.T) { core.AssertEqual(t, []byte{0, 1, 2, 255}, output) } +func TestEngine_Load_Good(t *testing.T) { + engine := newSmokeEngine(t) + defer func() { core.AssertNoError(t, engine.Close()) }() + + module, err := engine.Load(core.Background(), inlineModule(` + let count = 0; + export function increment(by: number) { + count += by; + return count; + } + `)) + core.RequireNoError(t, err) + defer func() { core.AssertNoError(t, module.Close()) }() + + var first int + core.AssertNoError(t, module.Invoke(core.Background(), "increment", &first, 2)) + core.AssertEqual(t, 2, first) +} + +func TestEngine_Load_Bad(t *testing.T) { + engine := &Engine{closed: true} + module, err := engine.Load(core.Background(), inlineModule(`export const ready = true;`)) + core.AssertNil(t, module) + core.AssertError(t, err) + core.AssertContains(t, err.Error(), "closed") +} + +func TestEngine_Load_Ugly(t *testing.T) { + engine := newSmokeEngine(t) + defer func() { core.AssertNoError(t, engine.Close()) }() + + module, err := engine.Load(core.Background(), inlineModule(` + let calls = 0; + export function next() { + calls += 1; + return calls; + } + `)) + core.RequireNoError(t, err) + defer func() { core.AssertNoError(t, module.Close()) }() + + var first int + var second int + core.AssertNoError(t, module.Invoke(core.Background(), "next", &first)) + core.AssertNoError(t, module.Invoke(core.Background(), "next", &second)) + core.AssertEqual(t, 1, first) + core.AssertEqual(t, 2, second) +} + func TestEngine_Close_Good(t *testing.T) { engine := newSmokeEngine(t) sidecar := engine.service.Sidecar() @@ -156,6 +205,7 @@ try { await Deno.remove(socketPath); } catch (error) { const listener = Deno.listen({ transport: "unix", path: socketPath }); const encoder = new TextEncoder(); const decoder = new TextDecoder(); +const workers = new Map(); for await (const connection of listener) { void serve(connection); @@ -200,9 +250,12 @@ async function dispatch(request: Record) { (request.permissions ?? {}) as Record, ); case "UnloadModule": - return { ok: true }; + return unloadModule(String(request.code ?? "")); case "ModuleStatus": - return { code: String(request.code ?? ""), status: "RUNNING" }; + return { + code: String(request.code ?? ""), + status: workers.has(String(request.code ?? "")) ? "RUNNING" : "STOPPED", + }; default: throw new Error("unknown method: " + request.method); } @@ -243,14 +296,26 @@ self.onmessage = async (event) => { }, }, }); - try { - return await new Promise((resolve) => { - worker.onmessage = (event) => resolve(event.data); - worker.onerror = (event) => resolve({ ok: false, error: event.message }); - worker.postMessage(entryPoint); - }); - } finally { + const result = await new Promise((resolve) => { + worker.onmessage = (event) => resolve(event.data); + worker.onerror = (event) => resolve({ ok: false, error: event.message }); + worker.postMessage(entryPoint); + }); + if (result.ok) { + const previous = workers.get(code); + if (previous) previous.terminate(); + workers.set(code, worker); + } else { worker.terminate(); } + return result; +} + +function unloadModule(code: string) { + const worker = workers.get(code); + if (!worker) return { ok: true }; + worker.terminate(); + workers.delete(code); + return { ok: true }; } ` diff --git a/go/engine/ts/web.go b/go/engine/ts/web.go new file mode 100644 index 0000000..1f1b9f6 --- /dev/null +++ b/go/engine/ts/web.go @@ -0,0 +1,41 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package ts + +// WebRequest is the serialisable form of a Web API Request argument. Invoke +// revives it into a genuine Request inside the resident TypeScript context. +type WebRequest struct { + URL string `json:"url"` + Method string `json:"method"` + Headers [][2]string `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` +} + +// WebResponse is the serialisable result of a Web API Response returned by an +// exported TypeScript function. +type WebResponse struct { + Status int `json:"status"` + StatusText string `json:"statusText"` + Headers [][2]string `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` +} + +type webRequest struct { + Type string `json:"__go_render_type"` + URL string `json:"url"` + Method string `json:"method"` + Headers [][2]string `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` +} + +func webRequestValue(request WebRequest) webRequest { + return webRequest{ + Type: "Request", + URL: request.URL, + Method: request.Method, + Headers: request.Headers, + Body: request.Body, + } +} diff --git a/go/engine/ts/web_example_test.go b/go/engine/ts/web_example_test.go new file mode 100644 index 0000000..554a789 --- /dev/null +++ b/go/engine/ts/web_example_test.go @@ -0,0 +1,27 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package ts + +import ( + core "dappco.re/go" +) + +func ExampleWebRequest() { + request := WebRequest{ + URL: "https://example.test/account", + Method: "GET", + } + core.Println(request.Method, request.URL) + // Output: GET https://example.test/account +} + +func ExampleWebResponse() { + response := WebResponse{ + Status: 200, + Body: []byte("
ready
"), + } + core.Println(response.Status, core.AsString(response.Body)) + // Output: 200
ready
+} diff --git a/go/engine/ts/web_test.go b/go/engine/ts/web_test.go new file mode 100644 index 0000000..746b13a --- /dev/null +++ b/go/engine/ts/web_test.go @@ -0,0 +1,37 @@ +//go:build !js + +// SPDX-Licence-Identifier: EUPL-1.2 + +package ts + +import ( + "testing" + + core "dappco.re/go" +) + +func TestWeb_WebRequest(t *testing.T) { + request := WebRequest{ + URL: "https://example.test/", + Method: "POST", + Headers: [][2]string{{"content-type", "text/plain"}}, + Body: []byte("request"), + } + core.AssertEqual(t, "https://example.test/", request.URL) + core.AssertEqual(t, "POST", request.Method) + core.AssertEqual(t, [][2]string{{"content-type", "text/plain"}}, request.Headers) + core.AssertEqual(t, []byte("request"), request.Body) +} + +func TestWeb_WebResponse(t *testing.T) { + response := WebResponse{ + Status: 202, + StatusText: "Accepted", + Headers: [][2]string{{"content-type", "text/html"}}, + Body: []byte("
accepted
"), + } + core.AssertEqual(t, 202, response.Status) + core.AssertEqual(t, "Accepted", response.StatusText) + core.AssertEqual(t, [][2]string{{"content-type", "text/html"}}, response.Headers) + core.AssertEqual(t, []byte("
accepted
"), response.Body) +}