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