From f0802487b3cf9058504b7579b5cb3dace0e88981 Mon Sep 17 00:00:00 2001 From: Logykk Date: Mon, 1 Jun 2026 22:19:43 +1200 Subject: [PATCH 01/12] refactor: isolate ssr client contract --- zen/ssr_client.go | 130 +------------------------------------ zen/ssr_client_test.go | 50 --------------- zen/ssr_process_client.go | 131 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 179 deletions(-) create mode 100644 zen/ssr_process_client.go diff --git a/zen/ssr_client.go b/zen/ssr_client.go index f6d022c..c6e7556 100644 --- a/zen/ssr_client.go +++ b/zen/ssr_client.go @@ -1,16 +1,6 @@ package zen -import ( - "bufio" - "context" - "encoding/json" - "errors" - "io" - "os/exec" - "strconv" - "sync" - "sync/atomic" -) +import "context" type ssrClient interface { Render(ctx context.Context, req ssrRequest) (ssrResponse, error) @@ -26,121 +16,3 @@ type ssrResponse struct { HTML string `json:"html"` Head string `json:"head"` } - -type processSSRClient struct { - cmd *exec.Cmd - stdin io.WriteCloser - scanner *bufio.Scanner - mu sync.Mutex - nextID atomic.Uint64 -} - -type workerMessage struct { - ID string `json:"id"` - Request ssrRequest `json:"request,omitempty"` - Result ssrResponse `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -func newProcessSSRClient(command []string) (*processSSRClient, error) { - if len(command) == 0 { - return nil, errors.New("zen: SSR command is empty") - } - - cmd := exec.Command(command[0], command[1:]...) - - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, err - } - - cmd.Stderr = io.Discard - - if err := cmd.Start(); err != nil { - return nil, err - } - - return &processSSRClient{ - cmd: cmd, - stdin: stdin, - scanner: bufio.NewScanner(stdout), - }, nil -} - -func (c *processSSRClient) Render(ctx context.Context, req ssrRequest) (ssrResponse, error) { - c.mu.Lock() - defer c.mu.Unlock() - - id := strconv.FormatUint(c.nextID.Add(1), 10) - - outgoing := workerMessage{ - ID: id, - Request: req, - } - - raw, err := json.Marshal(outgoing) - if err != nil { - return ssrResponse{}, err - } - - if _, err := c.stdin.Write(append(raw, '\n')); err != nil { - return ssrResponse{}, err - } - - type result struct { - msg workerMessage - err error - } - - done := make(chan result, 1) - - go func() { - if !c.scanner.Scan() { - if err := c.scanner.Err(); err != nil { - done <- result{err: err} - return - } - done <- result{err: io.EOF} - return - } - - var incoming workerMessage - if err := json.Unmarshal(c.scanner.Bytes(), &incoming); err != nil { - done <- result{err: err} - return - } - - done <- result{msg: incoming} - }() - - select { - case <-ctx.Done(): - return ssrResponse{}, ctx.Err() - case got := <-done: - if got.err != nil { - return ssrResponse{}, got.err - } - if got.msg.ID != id { - return ssrResponse{}, errors.New("zen: SSR worker returned mismatched response id") - } - if got.msg.Error != "" { - return ssrResponse{}, errors.New(got.msg.Error) - } - return got.msg.Result, nil - } -} - -func (c *processSSRClient) Close() error { - _ = c.stdin.Close() - - if c.cmd.Process != nil { - _ = c.cmd.Process.Kill() - } - - return c.cmd.Wait() -} diff --git a/zen/ssr_client_test.go b/zen/ssr_client_test.go index 5d07a22..14f773b 100644 --- a/zen/ssr_client_test.go +++ b/zen/ssr_client_test.go @@ -37,53 +37,3 @@ func TestSSRClientInterfaceCapturesRenderRequest(t *testing.T) { t.Fatalf("expected rendered html, got %q", res.HTML) } } - -func TestProcessSSRClientRendersThroughWorker(t *testing.T) { - client, err := newProcessSSRClient([]string{ - "node", - "../js/ssr-worker.mjs", - "--entry", - "../js/fixtures/entry-server-ok.mjs", - }) - if err != nil { - t.Fatalf("unexpected client error: %v", err) - } - defer client.Close() - - res, err := client.Render(context.Background(), ssrRequest{ - URL: "/", - Page: "Home", - Props: map[string]string{ - "title": "Hello", - }, - }) - if err != nil { - t.Fatalf("unexpected render error: %v", err) - } - - if res.HTML != `
Hello
` { - t.Fatalf("unexpected html: %s", res.HTML) - } -} - -func TestProcessSSRClientReturnsWorkerError(t *testing.T) { - client, err := newProcessSSRClient([]string{ - "node", - "../js/ssr-worker.mjs", - "--entry", - "../js/fixtures/entry-server-error.mjs", - }) - if err != nil { - t.Fatalf("unexpected client error: %v", err) - } - defer client.Close() - - _, err = client.Render(context.Background(), ssrRequest{ - URL: "/", - Page: "Home", - Props: map[string]string{}, - }) - if err == nil { - t.Fatal("expected render error") - } -} diff --git a/zen/ssr_process_client.go b/zen/ssr_process_client.go new file mode 100644 index 0000000..0dcb557 --- /dev/null +++ b/zen/ssr_process_client.go @@ -0,0 +1,131 @@ +package zen + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "os/exec" + "strconv" + "sync" + "sync/atomic" +) + +type processSSRClient struct { + cmd *exec.Cmd + stdin io.WriteCloser + scanner *bufio.Scanner + mu sync.Mutex + nextID atomic.Uint64 +} + +type workerMessage struct { + ID string `json:"id"` + Request ssrRequest `json:"request,omitempty"` + Result ssrResponse `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +func newProcessSSRClient(command []string) (*processSSRClient, error) { + if len(command) == 0 { + return nil, errors.New("zen: SSR command is empty") + } + + cmd := exec.Command(command[0], command[1:]...) + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + + cmd.Stderr = io.Discard + + if err := cmd.Start(); err != nil { + return nil, err + } + + return &processSSRClient{ + cmd: cmd, + stdin: stdin, + scanner: bufio.NewScanner(stdout), + }, nil +} + +func (c *processSSRClient) Render(ctx context.Context, req ssrRequest) (ssrResponse, error) { + c.mu.Lock() + defer c.mu.Unlock() + + id := strconv.FormatUint(c.nextID.Add(1), 10) + + outgoing := workerMessage{ + ID: id, + Request: req, + } + + raw, err := json.Marshal(outgoing) + if err != nil { + return ssrResponse{}, err + } + + if _, err := c.stdin.Write(append(raw, '\n')); err != nil { + return ssrResponse{}, err + } + + type result struct { + msg workerMessage + err error + } + + done := make(chan result, 1) + + go func() { + if !c.scanner.Scan() { + if err := c.scanner.Err(); err != nil { + done <- result{err: err} + return + } + done <- result{err: io.EOF} + return + } + + var incoming workerMessage + if err := json.Unmarshal(c.scanner.Bytes(), &incoming); err != nil { + done <- result{err: err} + return + } + + done <- result{msg: incoming} + }() + + select { + case <-ctx.Done(): + return ssrResponse{}, ctx.Err() + case got := <-done: + if got.err != nil { + return ssrResponse{}, got.err + } + if got.msg.ID != id { + return ssrResponse{}, errors.New("zen: SSR worker returned mismatched response id") + } + if got.msg.Error != "" { + return ssrResponse{}, errors.New(got.msg.Error) + } + return got.msg.Result, nil + } +} + +func (c *processSSRClient) Close() error { + _ = c.stdin.Close() + + if c.cmd.Process != nil { + _ = c.cmd.Process.Kill() + } + + return c.cmd.Wait() +} From c816dd29302401d6ce312dc7bdd1c7c45ce27295 Mon Sep 17 00:00:00 2001 From: Logykk Date: Mon, 1 Jun 2026 22:21:17 +1200 Subject: [PATCH 02/12] feat: add http ssr client --- zen/ssr_http_client.go | 96 +++++++++++++++++++++ zen/ssr_http_client_test.go | 168 ++++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 zen/ssr_http_client.go create mode 100644 zen/ssr_http_client_test.go diff --git a/zen/ssr_http_client.go b/zen/ssr_http_client.go new file mode 100644 index 0000000..60b070d --- /dev/null +++ b/zen/ssr_http_client.go @@ -0,0 +1,96 @@ +package zen + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" +) + +type httpSSRClientConfig struct { + RenderURL string + Timeout time.Duration +} + +type httpSSRClient struct { + renderURL string + client *http.Client +} + +type httpRendererErrorResponse struct { + Error httpRendererError `json:"error"` +} + +type httpRendererError struct { + Message string `json:"message"` + Stack string `json:"stack,omitempty"` +} + +func newHTTPSSRClient(config httpSSRClientConfig) *httpSSRClient { + timeout := config.Timeout + if timeout == 0 { + timeout = 5 * time.Second + } + + transport := &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + } + + return &httpSSRClient{ + renderURL: config.RenderURL, + client: &http.Client{ + Timeout: timeout, + Transport: transport, + }, + } +} + +func (c *httpSSRClient) Render(ctx context.Context, req ssrRequest) (ssrResponse, error) { + if c.renderURL == "" { + return ssrResponse{}, errors.New("zen: renderer RenderURL is required") + } + + raw, err := json.Marshal(req) + if err != nil { + return ssrResponse{}, err + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.renderURL, bytes.NewReader(raw)) + if err != nil { + return ssrResponse{}, err + } + + httpReq.Header.Set("content-type", "application/json") + httpReq.Header.Set("accept", "application/json") + + httpRes, err := c.client.Do(httpReq) + if err != nil { + return ssrResponse{}, err + } + defer httpRes.Body.Close() + + if httpRes.StatusCode < 200 || httpRes.StatusCode >= 300 { + var errorBody httpRendererErrorResponse + if err := json.NewDecoder(httpRes.Body).Decode(&errorBody); err != nil { + return ssrResponse{}, fmt.Errorf("zen renderer returned status %d", httpRes.StatusCode) + } + + if errorBody.Error.Message == "" { + return ssrResponse{}, fmt.Errorf("zen renderer returned status %d", httpRes.StatusCode) + } + + return ssrResponse{}, fmt.Errorf("zen renderer: %s", errorBody.Error.Message) + } + + var out ssrResponse + if err := json.NewDecoder(httpRes.Body).Decode(&out); err != nil { + return ssrResponse{}, err + } + + return out, nil +} diff --git a/zen/ssr_http_client_test.go b/zen/ssr_http_client_test.go new file mode 100644 index 0000000..63c1e10 --- /dev/null +++ b/zen/ssr_http_client_test.go @@ -0,0 +1,168 @@ +package zen + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestHTTPSSRClientRendersPage(t *testing.T) { + var received ssrRequest + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Fatalf("expected POST, got %s", r.Method) + } + + if r.URL.Path != "/__zen/render" { + t.Fatalf("expected /__zen/render, got %s", r.URL.Path) + } + + if err := json.NewDecoder(r.Body).Decode(&received); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(ssrResponse{ + HTML: "
Hello
", + Head: "Hello", + }) + })) + defer server.Close() + + client := newHTTPSSRClient(httpSSRClientConfig{ + RenderURL: server.URL + "/__zen/render", + Timeout: time.Second, + }) + + res, err := client.Render(context.Background(), ssrRequest{ + URL: "/", + Page: "Home", + Props: map[string]string{"title": "Hello"}, + }) + if err != nil { + t.Fatalf("unexpected render error: %v", err) + } + + if received.Page != "Home" { + t.Fatalf("expected page Home, got %q", received.Page) + } + + if res.HTML != "
Hello
" { + t.Fatalf("expected rendered html, got %q", res.HTML) + } + + if res.Head != "Hello" { + t.Fatalf("expected head html, got %q", res.Head) + } +} + +func TestHTTPSSRClientReturnsRendererError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + + _ = json.NewEncoder(w).Encode(httpRendererErrorResponse{ + Error: httpRendererError{ + Message: "Unknown page: Admin", + Stack: "Error: Unknown page: Admin", + }, + }) + })) + defer server.Close() + + client := newHTTPSSRClient(httpSSRClientConfig{ + RenderURL: server.URL, + Timeout: time.Second, + }) + + _, err := client.Render(context.Background(), ssrRequest{ + URL: "/admin", + Page: "Admin", + Props: map[string]string{}, + }) + if err == nil { + t.Fatal("expected render error") + } + + if !strings.Contains(err.Error(), "Unknown page: Admin") { + t.Fatalf("expected renderer error message, got %v", err) + } +} + +func TestHTTPSSRClientHandlesParallelRequests(t *testing.T) { + var count atomic.Int64 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count.Add(1) + + var req ssrRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("failed to decode request: %v", err) + } + + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(ssrResponse{ + HTML: "
" + req.Page + "
", + }) + })) + defer server.Close() + + client := newHTTPSSRClient(httpSSRClientConfig{ + RenderURL: server.URL, + Timeout: time.Second, + }) + + errs := make(chan error, 25) + + for i := 0; i < 25; i++ { + go func() { + _, err := client.Render(context.Background(), ssrRequest{ + URL: "/", + Page: "Home", + Props: map[string]string{}, + }) + errs <- err + }() + } + + for i := 0; i < 25; i++ { + if err := <-errs; err != nil { + t.Fatalf("parallel render failed: %v", err) + } + } + + if count.Load() != 25 { + t.Fatalf("expected 25 requests, got %d", count.Load()) + } +} + +func TestHTTPSSRClientRespectsContextTimeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := newHTTPSSRClient(httpSSRClientConfig{ + RenderURL: server.URL, + Timeout: time.Second, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + + _, err := client.Render(ctx, ssrRequest{ + URL: "/", + Page: "Home", + Props: map[string]string{}, + }) + if err == nil { + t.Fatal("expected timeout error") + } +} From 9d93370ac484a14e6f0c6ca9a730791720db133c Mon Sep 17 00:00:00 2001 From: Logykk Date: Mon, 1 Jun 2026 22:23:17 +1200 Subject: [PATCH 03/12] refactor: configure renderer over http --- zen/config.go | 31 +++++++++++++++++++++++---- zen/config_test.go | 53 ++++++++++++++++++++++++++++++++++++---------- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/zen/config.go b/zen/config.go index e08fce0..edb4c46 100644 --- a/zen/config.go +++ b/zen/config.go @@ -3,17 +3,21 @@ package zen import ( "errors" "strings" + "time" ) type Config struct { Dev bool - ViteURL string + ViteURL string + RenderURL string ClientDist string Manifest string SSRCommand []string + RenderTimeout time.Duration + AppElementID string DataElementID string DefaultTitle string @@ -23,15 +27,27 @@ func (c Config) withDefaults() Config { if c.ViteURL == "" && c.Dev { c.ViteURL = "http://localhost:5173" } + + if c.RenderURL == "" && c.Dev && c.ViteURL != "" { + c.RenderURL = strings.TrimRight(c.ViteURL, "/") + "/__zen/render" + } + if c.AppElementID == "" { c.AppElementID = "app" } + if c.DataElementID == "" { c.DataElementID = "__ZEN_DATA__" } + if c.DefaultTitle == "" { c.DefaultTitle = "Zen" } + + if c.RenderTimeout == 0 { + c.RenderTimeout = 5 * time.Second + } + return c } @@ -39,10 +55,19 @@ func (c Config) validate() error { if c.AppElementID == "" { return errors.New("zen: AppElementID is required") } + if c.DataElementID == "" { return errors.New("zen: DataElementID is required") } + if strings.TrimSpace(c.RenderURL) == "" { + return errors.New("zen: RenderURL is required") + } + + if c.RenderTimeout <= 0 { + return errors.New("zen: RenderTimeout must be greater than zero") + } + if c.Dev { if strings.TrimSpace(c.ViteURL) == "" { return errors.New("zen: ViteURL is required in dev mode") @@ -53,12 +78,10 @@ func (c Config) validate() error { if strings.TrimSpace(c.ClientDist) == "" { return errors.New("zen: ClientDist is required in production mode") } + if strings.TrimSpace(c.Manifest) == "" { return errors.New("zen: Manifest is required in production mode") } - if len(c.SSRCommand) == 0 { - return errors.New("zen: SSRCommand is required in production mode") - } return nil } diff --git a/zen/config_test.go b/zen/config_test.go index 8937670..ec56b60 100644 --- a/zen/config_test.go +++ b/zen/config_test.go @@ -1,6 +1,9 @@ package zen -import "testing" +import ( + "testing" + "time" +) func TestConfigWithDefaultsDev(t *testing.T) { cfg := Config{ @@ -12,17 +15,45 @@ func TestConfigWithDefaultsDev(t *testing.T) { if got.ViteURL != "http://localhost:5173" { t.Fatalf("expected default ViteURL, got %q", got.ViteURL) } + + if got.RenderURL != "http://localhost:5173/__zen/render" { + t.Fatalf("expected default RenderURL, got %q", got.RenderURL) + } + if got.AppElementID != "app" { t.Fatalf("expected app element id app, got %q", got.AppElementID) } + if got.DataElementID != "__ZEN_DATA__" { t.Fatalf("expected data element id __ZEN_DATA__, got %q", got.DataElementID) } + + if got.RenderTimeout != 5*time.Second { + t.Fatalf("expected render timeout 5s, got %s", got.RenderTimeout) + } +} + +func TestConfigWithDefaultsProduction(t *testing.T) { + cfg := Config{ + Dev: false, + RenderURL: "http://127.0.0.1:4174/__zen/render", + } + + got := cfg.withDefaults() + + if got.RenderURL != "http://127.0.0.1:4174/__zen/render" { + t.Fatalf("expected configured RenderURL, got %q", got.RenderURL) + } + + if got.RenderTimeout != 5*time.Second { + t.Fatalf("expected render timeout 5s, got %s", got.RenderTimeout) + } } func TestConfigValidateProductionRequiresPaths(t *testing.T) { cfg := Config{ - Dev: false, + Dev: false, + RenderURL: "http://127.0.0.1:4174/__zen/render", } err := cfg.withDefaults().validate() @@ -31,10 +62,11 @@ func TestConfigValidateProductionRequiresPaths(t *testing.T) { } } -func TestConfigValidateDevRequiresViteURL(t *testing.T) { +func TestConfigValidateRequiresRenderURL(t *testing.T) { cfg := Config{ - Dev: true, - ViteURL: " ", + Dev: true, + ViteURL: "http://localhost:5173", + RenderURL: " ", } err := cfg.withDefaults().validate() @@ -43,15 +75,14 @@ func TestConfigValidateDevRequiresViteURL(t *testing.T) { } } -func TestConfigValidateDevAllowsSSRCommand(t *testing.T) { +func TestConfigValidateDevRequiresViteURL(t *testing.T) { cfg := Config{ - Dev: true, - ViteURL: "http://localhost:5173", - SSRCommand: []string{"node", "js/ssr-worker.mjs", "--entry", "frontend/src/entry-server.tsx"}, + Dev: true, + ViteURL: " ", } err := cfg.withDefaults().validate() - if err != nil { - t.Fatalf("unexpected validation error: %v", err) + if err == nil { + t.Fatal("expected validation error") } } From bdef5e7e0914ba5d8b4eccad26d44a149ff1c98a Mon Sep 17 00:00:00 2001 From: Logykk Date: Mon, 1 Jun 2026 22:24:32 +1200 Subject: [PATCH 04/12] feat: use http ssr client in renderer --- zen/render.go | 29 ++++--------------- zen/render_test.go | 72 +++++++--------------------------------------- 2 files changed, 16 insertions(+), 85 deletions(-) diff --git a/zen/render.go b/zen/render.go index 2f4ca31..b277cb2 100644 --- a/zen/render.go +++ b/zen/render.go @@ -40,6 +40,10 @@ func New(config Config) (*Renderer, error) { r := &Renderer{ config: cfg, + ssr: newHTTPSSRClient(httpSSRClientConfig{ + RenderURL: cfg.RenderURL, + Timeout: cfg.RenderTimeout, + }), } if !cfg.Dev { @@ -50,14 +54,6 @@ func New(config Config) (*Renderer, error) { r.manifest = manifest } - if len(cfg.SSRCommand) > 0 { - client, err := newProcessSSRClient(cfg.SSRCommand) - if err != nil { - return nil, err - } - r.ssr = client - } - return r, nil } @@ -72,7 +68,7 @@ func (r *Renderer) Render(c fiber.Ctx, page string, props any, options ...Render } if r.ssr == nil { - return errors.New("zen: renderer has no SSR client; set Config.SSRCommand or inject an SSR client in tests") + return errors.New("zen: renderer has no SSR client; configure RenderURL or inject an SSR client in tests") } ctx := context.Background() @@ -127,19 +123,6 @@ func (r *Renderer) Render(c fiber.Ctx, page string, props any, options ...Render return c.Status(opts.Status).SendString(doc) } -type closeableSSRClient interface { - Close() error -} - func (r *Renderer) Close() error { - if r.ssr == nil { - return nil - } - - closeable, ok := r.ssr.(closeableSSRClient) - if !ok { - return nil - } - - return closeable.Close() + return nil } diff --git a/zen/render_test.go b/zen/render_test.go index bcc6197..f4ed31b 100644 --- a/zen/render_test.go +++ b/zen/render_test.go @@ -1,7 +1,6 @@ package zen import ( - "context" "os" "path/filepath" "strings" @@ -22,6 +21,14 @@ func TestNewRendererAppliesDefaults(t *testing.T) { if r.config.ViteURL != "http://localhost:5173" { t.Fatalf("expected default vite url, got %q", r.config.ViteURL) } + + if r.config.RenderURL != "http://localhost:5173/__zen/render" { + t.Fatalf("expected default render url, got %q", r.config.RenderURL) + } + + if r.ssr == nil { + t.Fatal("expected renderer to create ssr client") + } } func TestNewRendererRejectsInvalidProductionConfig(t *testing.T) { @@ -119,7 +126,7 @@ func TestRenderInjectsProductionManifestAssets(t *testing.T) { } } -func TestNewRendererCreatesProductionSSRClient(t *testing.T) { +func TestNewRendererCreatesProductionHTTPSSRClient(t *testing.T) { dir := t.TempDir() manifestPath := filepath.Join(dir, "manifest.json") @@ -134,14 +141,9 @@ func TestNewRendererCreatesProductionSSRClient(t *testing.T) { r, err := New(Config{ Dev: false, + RenderURL: "http://127.0.0.1:4174/__zen/render", ClientDist: dir, Manifest: manifestPath, - SSRCommand: []string{ - "node", - "../js/ssr-worker.mjs", - "--entry", - "../js/fixtures/entry-server-ok.mjs", - }, }) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -152,60 +154,6 @@ func TestNewRendererCreatesProductionSSRClient(t *testing.T) { } } -type closeTrackingSSRClient struct { - closed bool -} - -func (c *closeTrackingSSRClient) Render(ctx context.Context, req ssrRequest) (ssrResponse, error) { - return ssrResponse{HTML: ""}, nil -} - -func (c *closeTrackingSSRClient) Close() error { - c.closed = true - return nil -} - -func TestRendererCloseClosesSSRClient(t *testing.T) { - client := &closeTrackingSSRClient{} - - r := &Renderer{ - config: Config{ - Dev: true, - }, - ssr: client, - } - - err := r.Close() - if err != nil { - t.Fatalf("unexpected close error: %v", err) - } - - if !client.closed { - t.Fatal("expected SSR client to be closed") - } -} - -func TestNewRendererCreatesDevSSRClientWhenCommandProvided(t *testing.T) { - r, err := New(Config{ - Dev: true, - ViteURL: "http://localhost:5173", - SSRCommand: []string{ - "node", - "../js/ssr-worker.mjs", - "--entry", - "../js/fixtures/entry-server-ok.mjs", - }, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - defer r.Close() - - if r.ssr == nil { - t.Fatal("expected dev ssr client when SSRCommand is provided") - } -} - func TestRenderReturnsErrorWhenSSRClientMissing(t *testing.T) { r := &Renderer{ config: Config{ From c9ef0f86d98612407a106226e9803846b206dcbe Mon Sep 17 00:00:00 2001 From: Logykk Date: Mon, 1 Jun 2026 22:25:20 +1200 Subject: [PATCH 05/12] feat: add shared node renderer helpers --- js/renderer-shared.mjs | 45 ++++++++++++++++++++++ js/renderer-shared.test.mjs | 75 +++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 js/renderer-shared.mjs create mode 100644 js/renderer-shared.test.mjs diff --git a/js/renderer-shared.mjs b/js/renderer-shared.mjs new file mode 100644 index 0000000..8e1b431 --- /dev/null +++ b/js/renderer-shared.mjs @@ -0,0 +1,45 @@ +export async function readJSON(req) { + let body = ""; + + for await (const chunk of req) { + body += chunk.toString("utf8"); + } + + if (!body.trim()) { + return {}; + } + + return JSON.parse(body); +} + +export function writeJSON(res, status, value) { + res.statusCode = status; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(value)); +} + +export function writeRendererError(res, status, error, options = {}) { + const includeStack = Boolean(options.includeStack); + + writeJSON(res, status, { + error: { + message: error && error.message ? error.message : String(error), + stack: includeStack && error && error.stack ? error.stack : "" + } + }); +} + +export function createHealthResponse(mode) { + return { + ok: true, + mode + }; +} + +export function isRenderRequest(req) { + return req.method === "POST" && req.url === "/__zen/render"; +} + +export function isHealthRequest(req) { + return req.method === "GET" && req.url === "/__zen/health"; +} diff --git a/js/renderer-shared.test.mjs b/js/renderer-shared.test.mjs new file mode 100644 index 0000000..2ceccc6 --- /dev/null +++ b/js/renderer-shared.test.mjs @@ -0,0 +1,75 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { Readable } from "node:stream"; +import { + readJSON, + writeJSON, + writeRendererError, + createHealthResponse +} from "./renderer-shared.mjs"; + +function mockResponse() { + return { + statusCode: 0, + headers: {}, + body: "", + setHeader(name, value) { + this.headers[name.toLowerCase()] = value; + }, + end(value) { + this.body = value; + } + }; +} + +test("readJSON parses request body", async () => { + const req = Readable.from([ + JSON.stringify({ + page: "Home", + props: { + title: "Hello" + } + }) + ]); + + const got = await readJSON(req); + + assert.equal(got.page, "Home"); + assert.equal(got.props.title, "Hello"); +}); + +test("writeJSON writes JSON response", () => { + const res = mockResponse(); + + writeJSON(res, 201, { + ok: true + }); + + assert.equal(res.statusCode, 201); + assert.equal(res.headers["content-type"], "application/json"); + assert.equal(res.body, '{"ok":true}'); +}); + +test("writeRendererError writes structured error", () => { + const res = mockResponse(); + const error = new Error("render failed"); + + writeRendererError(res, 500, error, { + includeStack: true + }); + + const body = JSON.parse(res.body); + + assert.equal(res.statusCode, 500); + assert.equal(body.error.message, "render failed"); + assert.match(body.error.stack, /render failed/); +}); + +test("createHealthResponse includes mode", () => { + const got = createHealthResponse("dev"); + + assert.deepEqual(got, { + ok: true, + mode: "dev" + }); +}); From fe22568acb17877726f45ee793aab4adb74b5083 Mon Sep 17 00:00:00 2001 From: Logykk Date: Mon, 1 Jun 2026 22:44:53 +1200 Subject: [PATCH 06/12] feat: add production http renderer --- js/prod-renderer.mjs | 93 ++++++++++++++++++++++++++++ js/prod-renderer.test.mjs | 123 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 js/prod-renderer.mjs create mode 100644 js/prod-renderer.test.mjs diff --git a/js/prod-renderer.mjs b/js/prod-renderer.mjs new file mode 100644 index 0000000..b1a680f --- /dev/null +++ b/js/prod-renderer.mjs @@ -0,0 +1,93 @@ +import http from "node:http"; +import { pathToFileURL } from "node:url"; +import { + createHealthResponse, + isHealthRequest, + isRenderRequest, + readJSON, + writeJSON, + writeRendererError +} from "./renderer-shared.mjs"; + +function parseArgs(argv) { + const args = { + host: "127.0.0.1", + port: 4174, + entry: "" + }; + + for (let i = 0; i < argv.length; i++) { + const item = argv[i]; + + if (item === "--entry") { + args.entry = argv[++i] ?? ""; + continue; + } + + if (item === "--host") { + args.host = argv[++i] ?? "127.0.0.1"; + continue; + } + + if (item === "--port") { + args.port = Number(argv[++i] ?? "4174"); + continue; + } + } + + if (!args.entry) { + throw new Error("missing required --entry argument"); + } + + if (!Number.isInteger(args.port) || args.port <= 0) { + throw new Error("port must be a positive integer"); + } + + return args; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const entryURL = pathToFileURL(args.entry).href; + const mod = await import(entryURL); + + if (typeof mod.render !== "function") { + throw new Error("SSR entry must export render(request)"); + } + + const server = http.createServer(async (req, res) => { + if (isHealthRequest(req)) { + writeJSON(res, 200, createHealthResponse("production")); + return; + } + + if (isRenderRequest(req)) { + try { + const body = await readJSON(req); + const result = await mod.render(body); + writeJSON(res, 200, result); + } catch (error) { + writeRendererError(res, 500, error, { + includeStack: process.env.NODE_ENV !== "production" + }); + } + + return; + } + + writeJSON(res, 404, { + error: { + message: "not found" + } + }); + }); + + server.listen(args.port, args.host, () => { + process.stdout.write(`Zen production renderer listening on http://${args.host}:${args.port}\n`); + }); +} + +main().catch((error) => { + process.stderr.write((error && error.stack ? error.stack : String(error)) + "\n"); + process.exit(1); +}); diff --git a/js/prod-renderer.test.mjs b/js/prod-renderer.test.mjs new file mode 100644 index 0000000..d815f07 --- /dev/null +++ b/js/prod-renderer.test.mjs @@ -0,0 +1,123 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const serverPath = join(here, "prod-renderer.mjs"); +const okEntry = join(here, "fixtures", "entry-server-ok.mjs"); +const errorEntry = join(here, "fixtures", "entry-server-error.mjs"); + +function startRenderer(entry, port) { + return spawn(process.execPath, [ + serverPath, + "--entry", + entry, + "--host", + "127.0.0.1", + "--port", + String(port) + ], { + stdio: ["ignore", "pipe", "pipe"] + }); +} + +async function waitForHealth(port) { + const url = `http://127.0.0.1:${port}/__zen/health`; + + for (let i = 0; i < 50; i++) { + try { + const res = await fetch(url); + if (res.ok) { + return; + } + } catch { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + + throw new Error(`renderer did not become healthy on port ${port}`); +} + +test("prod renderer health endpoint works", async () => { + const port = 4771; + const child = startRenderer(okEntry, port); + + try { + await waitForHealth(port); + + const res = await fetch(`http://127.0.0.1:${port}/__zen/health`); + const body = await res.json(); + + assert.equal(res.status, 200); + assert.deepEqual(body, { + ok: true, + mode: "production" + }); + } finally { + child.kill(); + await once(child, "exit"); + } +}); + +test("prod renderer renders request", async () => { + const port = 4772; + const child = startRenderer(okEntry, port); + + try { + await waitForHealth(port); + + const res = await fetch(`http://127.0.0.1:${port}/__zen/render`, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ + url: "/", + page: "Home", + props: { + title: "Hello" + } + }) + }); + + const body = await res.json(); + + assert.equal(res.status, 200); + assert.equal(body.html, `
Hello
`); + } finally { + child.kill(); + await once(child, "exit"); + } +}); + +test("prod renderer returns structured render errors", async () => { + const port = 4773; + const child = startRenderer(errorEntry, port); + + try { + await waitForHealth(port); + + const res = await fetch(`http://127.0.0.1:${port}/__zen/render`, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ + url: "/", + page: "Home", + props: {} + }) + }); + + const body = await res.json(); + + assert.equal(res.status, 500); + assert.equal(body.error.message, "fixture render failed"); + } finally { + child.kill(); + await once(child, "exit"); + } +}); From 2ab75aca6038aa2025b1a7274ff55ecad1bb2368 Mon Sep 17 00:00:00 2001 From: Logykk Date: Mon, 1 Jun 2026 22:47:51 +1200 Subject: [PATCH 07/12] feat: add vite dev http renderer --- js/dev-renderer.mjs | 102 +++++++++++++++++++++++++++++++++++++++ js/dev-renderer.test.mjs | 101 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 js/dev-renderer.mjs create mode 100644 js/dev-renderer.test.mjs diff --git a/js/dev-renderer.mjs b/js/dev-renderer.mjs new file mode 100644 index 0000000..cf69c37 --- /dev/null +++ b/js/dev-renderer.mjs @@ -0,0 +1,102 @@ +import http from "node:http"; +import { createServer as createViteServer } from "vite"; +import { + createHealthResponse, + isHealthRequest, + isRenderRequest, + readJSON, + writeJSON, + writeRendererError +} from "./renderer-shared.mjs"; + +function parseArgs(argv) { + const args = { + root: process.cwd(), + entry: "/src/entry-server.tsx", + host: "127.0.0.1", + port: 5173 + }; + + for (let i = 0; i < argv.length; i++) { + const item = argv[i]; + + if (item === "--root") { + args.root = argv[++i] ?? process.cwd(); + continue; + } + + if (item === "--entry") { + args.entry = argv[++i] ?? "/src/entry-server.tsx"; + continue; + } + + if (item === "--host") { + args.host = argv[++i] ?? "127.0.0.1"; + continue; + } + + if (item === "--port") { + args.port = Number(argv[++i] ?? "5173"); + continue; + } + } + + if (!Number.isInteger(args.port) || args.port <= 0) { + throw new Error("port must be a positive integer"); + } + + return args; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + + const vite = await createViteServer({ + root: args.root, + server: { + hmr: false, + middlewareMode: true + }, + appType: "custom" + }); + + const server = http.createServer(async (req, res) => { + if (isHealthRequest(req)) { + writeJSON(res, 200, createHealthResponse("dev")); + return; + } + + if (isRenderRequest(req)) { + try { + const body = await readJSON(req); + const mod = await vite.ssrLoadModule(args.entry); + + if (typeof mod.render !== "function") { + throw new Error("SSR entry must export render(request)"); + } + + const result = await mod.render(body); + writeJSON(res, 200, result); + } catch (error) { + vite.ssrFixStacktrace(error); + + writeRendererError(res, 500, error, { + includeStack: true + }); + } + + return; + } + + vite.middlewares(req, res); + }); + + server.listen(args.port, args.host, () => { + process.stdout.write(`Zen dev renderer listening on http://${args.host}:${args.port}\n`); + }); +} + +main().catch((error) => { + process.stderr.write((error && error.stack ? error.stack : String(error)) + "\n"); + process.exit(1); +}); diff --git a/js/dev-renderer.test.mjs b/js/dev-renderer.test.mjs new file mode 100644 index 0000000..5bf89e1 --- /dev/null +++ b/js/dev-renderer.test.mjs @@ -0,0 +1,101 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const serverPath = join(here, "dev-renderer.mjs"); + +async function createViteFixture() { + const root = await mkdtemp(join(tmpdir(), "zen-vite-fixture-")); + const src = join(root, "src"); + + await mkdir(src, { + recursive: true + }); + + await writeFile(join(root, "package.json"), JSON.stringify({ + type: "module", + dependencies: { + vite: "^7.0.0" + } + })); + + await writeFile(join(src, "entry-server.js"), ` + export async function render(request) { + return { + html: '
' + request.props.title + '
', + head: '' + }; + } + `); + + return root; +} + +async function waitForHealth(port) { + const url = `http://127.0.0.1:${port}/__zen/health`; + + for (let i = 0; i < 50; i++) { + try { + const res = await fetch(url); + if (res.ok) { + return; + } + } catch { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + + throw new Error(`renderer did not become healthy on port ${port}`); +} + +test("dev renderer renders through vite", async () => { + const root = await createViteFixture(); + const port = 4781; + + const child = spawn(process.execPath, [ + serverPath, + "--root", + root, + "--entry", + "/src/entry-server.js", + "--host", + "127.0.0.1", + "--port", + String(port) + ], { + stdio: ["ignore", "pipe", "pipe"] + }); + + try { + await waitForHealth(port); + + const res = await fetch(`http://127.0.0.1:${port}/__zen/render`, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ + url: "/", + page: "Home", + props: { + title: "Hello" + } + }) + }); + + const body = await res.json(); + + assert.equal(res.status, 200); + assert.equal(body.html, `
Hello
`); + } finally { + child.kill(); + await once(child, "exit"); + } +}); From 9de3fcfc3ca720113149b0a3bd2f8a025f5a9b3f Mon Sep 17 00:00:00 2001 From: Logykk Date: Mon, 1 Jun 2026 22:49:02 +1200 Subject: [PATCH 08/12] example: use http renderer service --- examples/basic/main.go | 30 +++++++++++------------------- examples/basic/package.json | 6 ++++-- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/examples/basic/main.go b/examples/basic/main.go index 23fa154..8106b27 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -3,6 +3,7 @@ package main import ( "log" "os" + "time" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/logger" @@ -16,27 +17,18 @@ func main() { dev := os.Getenv("ZEN_ENV") != "production" - ssrCommand := []string{ - "node", - "./frontend/ssr-worker-dev.mjs", + cfg := zen.Config{ + Dev: dev, + ViteURL: "http://localhost:5173", + RenderURL: "http://localhost:5173/__zen/render", + ClientDist: "./frontend/dist/client", + Manifest: "./frontend/dist/client/.vite/manifest.json", + DefaultTitle: "Zen Basic Example", + RenderTimeout: 5 * time.Second, } if !dev { - ssrCommand = []string{ - "node", - "../../js/ssr-worker.mjs", - "--entry", - "./frontend/dist/server/entry-server.js", - } - } - - cfg := zen.Config{ - Dev: dev, - ViteURL: "http://localhost:5173", - SSRCommand: ssrCommand, - ClientDist: "./frontend/dist/client", - Manifest: "./frontend/dist/client/.vite/manifest.json", - DefaultTitle: "Zen Basic Example", + cfg.RenderURL = "http://127.0.0.1:4174/__zen/render" } renderer, err := zen.New(cfg) @@ -52,7 +44,7 @@ func main() { app.Get("/", func(c fiber.Ctx) error { return renderer.Render(c, "Home", map[string]any{ "title": "Zen Basic Example", - "body": "Fiber route, Preact page, Vite build. No ceremony.", + "body": "Fiber route, Preact page, Vite renderer. No pipe slop.", }, zen.WithTitle("Home")) }) diff --git a/examples/basic/package.json b/examples/basic/package.json index 1043bbf..713f00d 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -3,8 +3,10 @@ "private": true, "type": "module", "scripts": { - "dev": "pnpm --dir frontend dev & go run .", + "dev": "pnpm dev:renderer & go run .", + "dev:renderer": "node ../../js/dev-renderer.mjs --root ./frontend --entry /src/entry-server.tsx --host 127.0.0.1 --port 5173", "build": "pnpm --dir frontend build && go build -o ./bin/basic .", - "start": "ZEN_ENV=production ./bin/basic" + "start": "ZEN_ENV=production ./bin/basic", + "start:renderer": "node ../../js/prod-renderer.mjs --entry ./frontend/dist/server/entry-server.js --host 127.0.0.1 --port 4174" } } From 078627507493d01efee79dbcee0bb8f9dcee1ee1 Mon Sep 17 00:00:00 2001 From: Logykk Date: Mon, 1 Jun 2026 22:50:16 +1200 Subject: [PATCH 09/12] refactor: remove stdio renderer bridge --- examples/basic/frontend/ssr-worker-dev.mjs | 90 -------------- js/ssr-worker.mjs | 82 ------------- js/ssr-worker.test.mjs | 75 ------------ zen/config.go | 1 - zen/ssr_process_client.go | 131 --------------------- 5 files changed, 379 deletions(-) delete mode 100644 examples/basic/frontend/ssr-worker-dev.mjs delete mode 100644 js/ssr-worker.mjs delete mode 100644 js/ssr-worker.test.mjs delete mode 100644 zen/ssr_process_client.go diff --git a/examples/basic/frontend/ssr-worker-dev.mjs b/examples/basic/frontend/ssr-worker-dev.mjs deleted file mode 100644 index 75accea..0000000 --- a/examples/basic/frontend/ssr-worker-dev.mjs +++ /dev/null @@ -1,90 +0,0 @@ -import fs from "node:fs"; -import { dirname } from "node:path"; -import { fileURLToPath } from "node:url"; - -const here = dirname(fileURLToPath(import.meta.url)); - -let vite; - -async function getVite() { - if (!vite) { - const { createServer } = await import("vite"); - - vite = await createServer({ - root: here, - server: { - hmr: false, - middlewareMode: true - }, - appType: "custom" - }); - } - - return vite; -} - -async function handleLine(line) { - if (!line.trim()) { - return; - } - - let incoming; - - try { - incoming = JSON.parse(line); - } catch (error) { - process.stdout.write(JSON.stringify({ - id: null, - error: `invalid json: ${error.message}` - }) + "\n"); - return; - } - - try { - const server = await getVite(); - const mod = await server.ssrLoadModule("/src/entry-server.tsx"); - - if (typeof mod.render !== "function") { - throw new Error("SSR entry must export render(request)"); - } - - const result = await mod.render(incoming.request); - - process.stdout.write(JSON.stringify({ - id: incoming.id, - result - }) + "\n"); - } catch (error) { - if (vite) { - vite.ssrFixStacktrace(error); - } - - process.stdout.write(JSON.stringify({ - id: incoming.id, - error: error && error.stack ? error.stack : String(error) - }) + "\n"); - } -} - -let buffer = ""; -const chunk = Buffer.alloc(64 * 1024); - -for (;;) { - const bytesRead = fs.readSync(0, chunk, 0, chunk.length, null); - if (bytesRead === 0) { - break; - } - - buffer += chunk.subarray(0, bytesRead).toString("utf8"); - - for (;;) { - const index = buffer.indexOf("\n"); - if (index === -1) { - break; - } - - const line = buffer.slice(0, index); - buffer = buffer.slice(index + 1); - await handleLine(line); - } -} diff --git a/js/ssr-worker.mjs b/js/ssr-worker.mjs deleted file mode 100644 index aa1aa9c..0000000 --- a/js/ssr-worker.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import fs from "node:fs"; -import { pathToFileURL } from "node:url"; - -function parseArgs(argv) { - const entryIndex = argv.indexOf("--entry"); - if (entryIndex === -1 || !argv[entryIndex + 1]) { - throw new Error("missing required --entry argument"); - } - - return { - entry: argv[entryIndex + 1] - }; -} - -async function main() { - const args = parseArgs(process.argv.slice(2)); - const entryURL = pathToFileURL(args.entry).href; - const mod = await import(entryURL); - - if (typeof mod.render !== "function") { - throw new Error("SSR entry must export render(request)"); - } - - async function handleLine(line) { - if (!line.trim()) { - return; - } - - let incoming; - - try { - incoming = JSON.parse(line); - } catch (error) { - process.stdout.write(JSON.stringify({ - id: null, - error: `invalid json: ${error.message}` - }) + "\n"); - return; - } - - try { - const result = await mod.render(incoming.request); - process.stdout.write(JSON.stringify({ - id: incoming.id, - result - }) + "\n"); - } catch (error) { - process.stdout.write(JSON.stringify({ - id: incoming.id, - error: error && error.stack ? error.stack : String(error) - }) + "\n"); - } - } - - let buffer = ""; - const chunk = Buffer.alloc(64 * 1024); - - for (;;) { - const bytesRead = fs.readSync(0, chunk, 0, chunk.length, null); - if (bytesRead === 0) { - break; - } - - buffer += chunk.subarray(0, bytesRead).toString("utf8"); - - for (;;) { - const index = buffer.indexOf("\n"); - if (index === -1) { - break; - } - - const line = buffer.slice(0, index); - buffer = buffer.slice(index + 1); - await handleLine(line); - } - } -} - -main().catch((error) => { - process.stderr.write((error && error.stack ? error.stack : String(error)) + "\n"); - process.exit(1); -}); diff --git a/js/ssr-worker.test.mjs b/js/ssr-worker.test.mjs deleted file mode 100644 index 267bd70..0000000 --- a/js/ssr-worker.test.mjs +++ /dev/null @@ -1,75 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { once } from "node:events"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -const here = dirname(fileURLToPath(import.meta.url)); -const workerPath = join(here, "ssr-worker.mjs"); -const okEntry = join(here, "fixtures", "entry-server-ok.mjs"); -const errorEntry = join(here, "fixtures", "entry-server-error.mjs"); - -function startWorker(entry) { - return spawn(process.execPath, [workerPath, "--entry", entry], { - stdio: ["pipe", "pipe", "pipe"] - }); -} - -async function readLine(stream) { - let buffer = ""; - for await (const chunk of stream) { - buffer += chunk.toString("utf8"); - const index = buffer.indexOf("\n"); - if (index !== -1) { - return buffer.slice(0, index); - } - } - throw new Error("stream ended before line"); -} - -test("worker renders one request", async () => { - const child = startWorker(okEntry); - - child.stdin.write(JSON.stringify({ - id: "1", - request: { - url: "/", - page: "Home", - props: { - title: "Hello" - } - } - }) + "\n"); - - const line = await readLine(child.stdout); - const message = JSON.parse(line); - - assert.equal(message.id, "1"); - assert.equal(message.result.html, `
Hello
`); - - child.kill(); - await once(child, "exit"); -}); - -test("worker reports render errors", async () => { - const child = startWorker(errorEntry); - - child.stdin.write(JSON.stringify({ - id: "2", - request: { - url: "/", - page: "Home", - props: {} - } - }) + "\n"); - - const line = await readLine(child.stdout); - const message = JSON.parse(line); - - assert.equal(message.id, "2"); - assert.match(message.error, /fixture render failed/); - - child.kill(); - await once(child, "exit"); -}); diff --git a/zen/config.go b/zen/config.go index edb4c46..d23e94c 100644 --- a/zen/config.go +++ b/zen/config.go @@ -14,7 +14,6 @@ type Config struct { ClientDist string Manifest string - SSRCommand []string RenderTimeout time.Duration diff --git a/zen/ssr_process_client.go b/zen/ssr_process_client.go deleted file mode 100644 index 0dcb557..0000000 --- a/zen/ssr_process_client.go +++ /dev/null @@ -1,131 +0,0 @@ -package zen - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "io" - "os/exec" - "strconv" - "sync" - "sync/atomic" -) - -type processSSRClient struct { - cmd *exec.Cmd - stdin io.WriteCloser - scanner *bufio.Scanner - mu sync.Mutex - nextID atomic.Uint64 -} - -type workerMessage struct { - ID string `json:"id"` - Request ssrRequest `json:"request,omitempty"` - Result ssrResponse `json:"result,omitempty"` - Error string `json:"error,omitempty"` -} - -func newProcessSSRClient(command []string) (*processSSRClient, error) { - if len(command) == 0 { - return nil, errors.New("zen: SSR command is empty") - } - - cmd := exec.Command(command[0], command[1:]...) - - stdin, err := cmd.StdinPipe() - if err != nil { - return nil, err - } - - stdout, err := cmd.StdoutPipe() - if err != nil { - return nil, err - } - - cmd.Stderr = io.Discard - - if err := cmd.Start(); err != nil { - return nil, err - } - - return &processSSRClient{ - cmd: cmd, - stdin: stdin, - scanner: bufio.NewScanner(stdout), - }, nil -} - -func (c *processSSRClient) Render(ctx context.Context, req ssrRequest) (ssrResponse, error) { - c.mu.Lock() - defer c.mu.Unlock() - - id := strconv.FormatUint(c.nextID.Add(1), 10) - - outgoing := workerMessage{ - ID: id, - Request: req, - } - - raw, err := json.Marshal(outgoing) - if err != nil { - return ssrResponse{}, err - } - - if _, err := c.stdin.Write(append(raw, '\n')); err != nil { - return ssrResponse{}, err - } - - type result struct { - msg workerMessage - err error - } - - done := make(chan result, 1) - - go func() { - if !c.scanner.Scan() { - if err := c.scanner.Err(); err != nil { - done <- result{err: err} - return - } - done <- result{err: io.EOF} - return - } - - var incoming workerMessage - if err := json.Unmarshal(c.scanner.Bytes(), &incoming); err != nil { - done <- result{err: err} - return - } - - done <- result{msg: incoming} - }() - - select { - case <-ctx.Done(): - return ssrResponse{}, ctx.Err() - case got := <-done: - if got.err != nil { - return ssrResponse{}, got.err - } - if got.msg.ID != id { - return ssrResponse{}, errors.New("zen: SSR worker returned mismatched response id") - } - if got.msg.Error != "" { - return ssrResponse{}, errors.New(got.msg.Error) - } - return got.msg.Result, nil - } -} - -func (c *processSSRClient) Close() error { - _ = c.stdin.Close() - - if c.cmd.Process != nil { - _ = c.cmd.Process.Kill() - } - - return c.cmd.Wait() -} From a54be12e1d8282648c3abb6ef8aeb654ed3570fb Mon Sep 17 00:00:00 2001 From: Logykk Date: Mon, 1 Jun 2026 22:51:07 +1200 Subject: [PATCH 10/12] test: cover renderer http failure through fiber --- zen/render_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/zen/render_test.go b/zen/render_test.go index f4ed31b..84691b2 100644 --- a/zen/render_test.go +++ b/zen/render_test.go @@ -1,10 +1,14 @@ package zen import ( + "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" "testing" + "time" "github.com/gofiber/fiber/v3" "github.com/zenith/zen/internal/testutil" @@ -177,3 +181,43 @@ func TestRenderReturnsErrorWhenSSRClientMissing(t *testing.T) { t.Fatal("expected non-200 status when renderer has no ssr client") } } + +func TestRenderReturnsRendererHTTPErrorThroughFiber(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + + _ = json.NewEncoder(w).Encode(httpRendererErrorResponse{ + Error: httpRendererError{ + Message: "renderer exploded", + }, + }) + })) + defer server.Close() + + r := &Renderer{ + config: Config{ + Dev: true, + ViteURL: "http://localhost:5173", + RenderURL: server.URL, + AppElementID: "app", + DataElementID: "__ZEN_DATA__", + DefaultTitle: "Zen", + }, + ssr: newHTTPSSRClient(httpSSRClientConfig{ + RenderURL: server.URL, + Timeout: time.Second, + }), + } + + app := fiber.New() + app.Get("/", func(c fiber.Ctx) error { + return r.Render(c, "Home", map[string]string{}) + }) + + res := testutil.PerformRequest(t, app, "GET", "/", "") + + if res.StatusCode == fiber.StatusOK { + t.Fatal("expected non-200 response") + } +} From 2d6a4265b044497356d3dca54c98c466a2128298 Mon Sep 17 00:00:00 2001 From: Logykk Date: Mon, 1 Jun 2026 22:52:41 +1200 Subject: [PATCH 11/12] test: validate http renderer migration From fab3b1841056b20fefe701b3a070b5096bf07420 Mon Sep 17 00:00:00 2001 From: Logykk Date: Mon, 1 Jun 2026 23:03:30 +1200 Subject: [PATCH 12/12] test: smoke test http renderer migration