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/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" } } 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"); + } +}); 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"); + } +}); 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" + }); +}); 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 e08fce0..d23e94c 100644 --- a/zen/config.go +++ b/zen/config.go @@ -3,16 +3,19 @@ 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 @@ -23,15 +26,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 +54,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 +77,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") } } 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..84691b2 100644 --- a/zen/render_test.go +++ b/zen/render_test.go @@ -1,11 +1,14 @@ package zen import ( - "context" + "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" "testing" + "time" "github.com/gofiber/fiber/v3" "github.com/zenith/zen/internal/testutil" @@ -22,6 +25,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 +130,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 +145,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,70 +158,56 @@ 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{} - +func TestRenderReturnsErrorWhenSSRClientMissing(t *testing.T) { r := &Renderer{ config: Config{ - Dev: true, + Dev: true, + ViteURL: "http://localhost:5173", + AppElementID: "app", + DataElementID: "__ZEN_DATA__", + DefaultTitle: "Zen", }, - ssr: client, + ssr: nil, } - err := r.Close() - if err != nil { - t.Fatalf("unexpected close error: %v", err) - } + 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 !client.closed { - t.Fatal("expected SSR client to be closed") + if res.StatusCode == fiber.StatusOK { + t.Fatal("expected non-200 status when renderer has no ssr client") } } -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() +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) - if r.ssr == nil { - t.Fatal("expected dev ssr client when SSRCommand is provided") - } -} + _ = json.NewEncoder(w).Encode(httpRendererErrorResponse{ + Error: httpRendererError{ + Message: "renderer exploded", + }, + }) + })) + defer server.Close() -func TestRenderReturnsErrorWhenSSRClientMissing(t *testing.T) { r := &Renderer{ config: Config{ Dev: true, ViteURL: "http://localhost:5173", + RenderURL: server.URL, AppElementID: "app", DataElementID: "__ZEN_DATA__", DefaultTitle: "Zen", }, - ssr: nil, + ssr: newHTTPSSRClient(httpSSRClientConfig{ + RenderURL: server.URL, + Timeout: time.Second, + }), } app := fiber.New() @@ -226,6 +218,6 @@ func TestRenderReturnsErrorWhenSSRClientMissing(t *testing.T) { res := testutil.PerformRequest(t, app, "GET", "/", "") if res.StatusCode == fiber.StatusOK { - t.Fatal("expected non-200 status when renderer has no ssr client") + t.Fatal("expected non-200 response") } } 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_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") + } +}