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