@pyreon/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +91 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +294 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +311 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +190 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +53 -0
- package/src/client.ts +239 -0
- package/src/handler.ts +187 -0
- package/src/html.ts +58 -0
- package/src/index.ts +69 -0
- package/src/island.ts +137 -0
- package/src/middleware.ts +39 -0
- package/src/ssg.ts +143 -0
- package/src/tests/client.test.ts +577 -0
- package/src/tests/server.test.ts +635 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import type { ComponentFn, VNode } from "@pyreon/core"
|
|
2
|
+
import { h } from "@pyreon/core"
|
|
3
|
+
import { createHandler } from "../handler"
|
|
4
|
+
import { buildScripts, DEFAULT_TEMPLATE, processTemplate } from "../html"
|
|
5
|
+
import { island } from "../island"
|
|
6
|
+
import type { Middleware } from "../middleware"
|
|
7
|
+
import { prerender } from "../ssg"
|
|
8
|
+
|
|
9
|
+
// ─── HTML template ───────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe("HTML template", () => {
|
|
12
|
+
test("processTemplate replaces all placeholders", () => {
|
|
13
|
+
const result = processTemplate(DEFAULT_TEMPLATE, {
|
|
14
|
+
head: "<title>Test</title>",
|
|
15
|
+
app: "<div>Hello</div>",
|
|
16
|
+
scripts: '<script type="module" src="/app.js"></script>',
|
|
17
|
+
})
|
|
18
|
+
expect(result).toContain("<title>Test</title>")
|
|
19
|
+
expect(result).toContain("<div>Hello</div>")
|
|
20
|
+
expect(result).toContain('src="/app.js"')
|
|
21
|
+
expect(result).not.toContain("<!--pyreon-head-->")
|
|
22
|
+
expect(result).not.toContain("<!--pyreon-app-->")
|
|
23
|
+
expect(result).not.toContain("<!--pyreon-scripts-->")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("buildScripts emits loader data + client entry", () => {
|
|
27
|
+
const scripts = buildScripts("/entry.js", { users: [{ id: 1 }] })
|
|
28
|
+
expect(scripts).toContain("window.__PYREON_LOADER_DATA__=")
|
|
29
|
+
expect(scripts).toContain('"users"')
|
|
30
|
+
expect(scripts).toContain('src="/entry.js"')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test("buildScripts escapes </script> in JSON", () => {
|
|
34
|
+
const scripts = buildScripts("/entry.js", { html: "</script><script>alert(1)" })
|
|
35
|
+
expect(scripts).not.toContain("</script><script>")
|
|
36
|
+
expect(scripts).toContain("<\\/script>")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test("buildScripts omits inline data when no loaders", () => {
|
|
40
|
+
const scripts = buildScripts("/entry.js", {})
|
|
41
|
+
expect(scripts).not.toContain("__PYREON_LOADER_DATA__")
|
|
42
|
+
expect(scripts).toContain('src="/entry.js"')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("buildScripts with null loaderData only emits client entry", () => {
|
|
46
|
+
const scripts = buildScripts("/entry.js", null)
|
|
47
|
+
expect(scripts).not.toContain("__PYREON_LOADER_DATA__")
|
|
48
|
+
expect(scripts).toContain('src="/entry.js"')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("processTemplate works with custom template string", () => {
|
|
52
|
+
const tpl = "<head><!--pyreon-head--></head><main><!--pyreon-app--></main><!--pyreon-scripts-->"
|
|
53
|
+
const result = processTemplate(tpl, { head: "<title>X</title>", app: "APP", scripts: "JS" })
|
|
54
|
+
expect(result).toBe("<head><title>X</title></head><main>APP</main>JS")
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("DEFAULT_TEMPLATE contains all three placeholders", () => {
|
|
58
|
+
expect(DEFAULT_TEMPLATE).toContain("<!--pyreon-head-->")
|
|
59
|
+
expect(DEFAULT_TEMPLATE).toContain("<!--pyreon-app-->")
|
|
60
|
+
expect(DEFAULT_TEMPLATE).toContain("<!--pyreon-scripts-->")
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// ─── SSR Handler ─────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
describe("createHandler", () => {
|
|
67
|
+
const Home: ComponentFn = () => h("h1", null, "Home")
|
|
68
|
+
const About: ComponentFn = () => h("h1", null, "About")
|
|
69
|
+
const routes = [
|
|
70
|
+
{ path: "/", component: Home },
|
|
71
|
+
{ path: "/about", component: About },
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
test("renders home page", async () => {
|
|
75
|
+
const handler = createHandler({ App: Home, routes })
|
|
76
|
+
const res = await handler(new Request("http://localhost/"))
|
|
77
|
+
const html = await res.text()
|
|
78
|
+
expect(res.status).toBe(200)
|
|
79
|
+
expect(res.headers.get("Content-Type")).toContain("text/html")
|
|
80
|
+
expect(html).toContain("<h1>Home</h1>")
|
|
81
|
+
expect(html).toContain("<!DOCTYPE html>")
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test("renders about page with correct route", async () => {
|
|
85
|
+
const App: ComponentFn = () => h("main", null, "app")
|
|
86
|
+
const handler = createHandler({ App, routes })
|
|
87
|
+
const res = await handler(new Request("http://localhost/about"))
|
|
88
|
+
expect(res.status).toBe(200)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test("uses custom template", async () => {
|
|
92
|
+
const template =
|
|
93
|
+
"<html><!--pyreon-head--><body><!--pyreon-app--><!--pyreon-scripts--></body></html>"
|
|
94
|
+
const handler = createHandler({ App: Home, routes, template })
|
|
95
|
+
const res = await handler(new Request("http://localhost/"))
|
|
96
|
+
const html = await res.text()
|
|
97
|
+
expect(html).toContain("<html>")
|
|
98
|
+
expect(html).toContain("<h1>Home</h1>")
|
|
99
|
+
expect(html).not.toContain("<!--pyreon-app-->")
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test("includes client entry script", async () => {
|
|
103
|
+
const handler = createHandler({ App: Home, routes, clientEntry: "/dist/client.js" })
|
|
104
|
+
const res = await handler(new Request("http://localhost/"))
|
|
105
|
+
const html = await res.text()
|
|
106
|
+
expect(html).toContain('src="/dist/client.js"')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test("serializes loader data into HTML", async () => {
|
|
110
|
+
const WithLoader: ComponentFn = () => h("div", null, "loaded")
|
|
111
|
+
const loaderRoutes = [
|
|
112
|
+
{
|
|
113
|
+
path: "/",
|
|
114
|
+
component: WithLoader,
|
|
115
|
+
loader: async () => ({ items: [1, 2, 3] }),
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
const handler = createHandler({ App: WithLoader, routes: loaderRoutes })
|
|
119
|
+
const res = await handler(new Request("http://localhost/"))
|
|
120
|
+
const html = await res.text()
|
|
121
|
+
expect(html).toContain("__PYREON_LOADER_DATA__")
|
|
122
|
+
expect(html).toContain('"items"')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test("returns 500 on render error", async () => {
|
|
126
|
+
const BrokenApp: ComponentFn = () => {
|
|
127
|
+
throw new Error("boom")
|
|
128
|
+
}
|
|
129
|
+
const handler = createHandler({ App: BrokenApp, routes })
|
|
130
|
+
const res = await handler(new Request("http://localhost/"))
|
|
131
|
+
expect(res.status).toBe(500)
|
|
132
|
+
expect(await res.text()).toBe("Internal Server Error")
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("handles URL with query string", async () => {
|
|
136
|
+
const handler = createHandler({ App: Home, routes })
|
|
137
|
+
const res = await handler(new Request("http://localhost/?foo=bar&baz=1"))
|
|
138
|
+
expect(res.status).toBe(200)
|
|
139
|
+
const html = await res.text()
|
|
140
|
+
expect(html).toContain("<h1>Home</h1>")
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// ─── Stream mode ──────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe("createHandler — stream mode", () => {
|
|
147
|
+
const Home: ComponentFn = () => h("h1", null, "Streamed")
|
|
148
|
+
const routes = [{ path: "/", component: Home }]
|
|
149
|
+
|
|
150
|
+
test("returns a streaming response", async () => {
|
|
151
|
+
const handler = createHandler({ App: Home, routes, mode: "stream" })
|
|
152
|
+
const res = await handler(new Request("http://localhost/"))
|
|
153
|
+
expect(res.status).toBe(200)
|
|
154
|
+
expect(res.headers.get("Content-Type")).toContain("text/html")
|
|
155
|
+
const html = await res.text()
|
|
156
|
+
expect(html).toContain("<h1>Streamed</h1>")
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test("stream mode uses default template placeholders", async () => {
|
|
160
|
+
const handler = createHandler({ App: Home, routes, mode: "stream" })
|
|
161
|
+
const res = await handler(new Request("http://localhost/"))
|
|
162
|
+
const html = await res.text()
|
|
163
|
+
// Should contain the template shell
|
|
164
|
+
expect(html).toContain("<!DOCTYPE html>")
|
|
165
|
+
expect(html).toContain("</html>")
|
|
166
|
+
// Script should be present
|
|
167
|
+
expect(html).toContain('src="/src/entry-client.ts"')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test("stream mode with custom template", async () => {
|
|
171
|
+
const template =
|
|
172
|
+
"<html><!--pyreon-head--><body><!--pyreon-app--><!--pyreon-scripts--></body></html>"
|
|
173
|
+
const handler = createHandler({ App: Home, routes, mode: "stream", template })
|
|
174
|
+
const res = await handler(new Request("http://localhost/"))
|
|
175
|
+
const html = await res.text()
|
|
176
|
+
expect(html).toContain("<h1>Streamed</h1>")
|
|
177
|
+
expect(html).toContain("</body></html>")
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test("stream mode with custom client entry", async () => {
|
|
181
|
+
const handler = createHandler({
|
|
182
|
+
App: Home,
|
|
183
|
+
routes,
|
|
184
|
+
mode: "stream",
|
|
185
|
+
clientEntry: "/dist/app.js",
|
|
186
|
+
})
|
|
187
|
+
const res = await handler(new Request("http://localhost/"))
|
|
188
|
+
const html = await res.text()
|
|
189
|
+
expect(html).toContain('src="/dist/app.js"')
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test("stream mode template without <!--pyreon-app--> throws", async () => {
|
|
193
|
+
const badTemplate = "<html><!--pyreon-head--><!--pyreon-scripts--></html>"
|
|
194
|
+
const handler = createHandler({ App: Home, routes, mode: "stream", template: badTemplate })
|
|
195
|
+
// The stream rendering should throw because template has no <!--pyreon-app-->
|
|
196
|
+
await expect(handler(new Request("http://localhost/"))).rejects.toThrow(
|
|
197
|
+
"Template must contain <!--pyreon-app-->",
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test("stream mode includes middleware-set headers", async () => {
|
|
202
|
+
const mw: Middleware = (ctx) => {
|
|
203
|
+
ctx.headers.set("X-Custom", "test-value")
|
|
204
|
+
}
|
|
205
|
+
const handler = createHandler({ App: Home, routes, mode: "stream", middleware: [mw] })
|
|
206
|
+
const res = await handler(new Request("http://localhost/"))
|
|
207
|
+
expect(res.headers.get("X-Custom")).toBe("test-value")
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test("stream mode middleware can short-circuit", async () => {
|
|
211
|
+
const mw: Middleware = () => new Response("blocked", { status: 403 })
|
|
212
|
+
const handler = createHandler({ App: Home, routes, mode: "stream", middleware: [mw] })
|
|
213
|
+
const res = await handler(new Request("http://localhost/"))
|
|
214
|
+
expect(res.status).toBe(403)
|
|
215
|
+
expect(await res.text()).toBe("blocked")
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// ─── Middleware ───────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
describe("middleware", () => {
|
|
222
|
+
const App: ComponentFn = () => h("div", null, "app")
|
|
223
|
+
const routes = [{ path: "/", component: App }]
|
|
224
|
+
|
|
225
|
+
test("middleware can short-circuit with a Response", async () => {
|
|
226
|
+
const authMiddleware: Middleware = (ctx) => {
|
|
227
|
+
if (!ctx.req.headers.get("Authorization")) {
|
|
228
|
+
return new Response("Unauthorized", { status: 401 })
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const handler = createHandler({ App, routes, middleware: [authMiddleware] })
|
|
232
|
+
|
|
233
|
+
const noAuth = await handler(new Request("http://localhost/"))
|
|
234
|
+
expect(noAuth.status).toBe(401)
|
|
235
|
+
|
|
236
|
+
const withAuth = await handler(
|
|
237
|
+
new Request("http://localhost/", { headers: { Authorization: "Bearer token" } }),
|
|
238
|
+
)
|
|
239
|
+
expect(withAuth.status).toBe(200)
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
test("middleware can set custom headers", async () => {
|
|
243
|
+
const cacheMiddleware: Middleware = (ctx) => {
|
|
244
|
+
ctx.headers.set("Cache-Control", "max-age=3600")
|
|
245
|
+
}
|
|
246
|
+
const handler = createHandler({ App, routes, middleware: [cacheMiddleware] })
|
|
247
|
+
const res = await handler(new Request("http://localhost/"))
|
|
248
|
+
expect(res.headers.get("Cache-Control")).toBe("max-age=3600")
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
test("middleware chain runs in order", async () => {
|
|
252
|
+
const order: number[] = []
|
|
253
|
+
const mw1: Middleware = () => {
|
|
254
|
+
order.push(1)
|
|
255
|
+
}
|
|
256
|
+
const mw2: Middleware = () => {
|
|
257
|
+
order.push(2)
|
|
258
|
+
}
|
|
259
|
+
const mw3: Middleware = () => {
|
|
260
|
+
order.push(3)
|
|
261
|
+
}
|
|
262
|
+
const handler = createHandler({ App, routes, middleware: [mw1, mw2, mw3] })
|
|
263
|
+
await handler(new Request("http://localhost/"))
|
|
264
|
+
expect(order).toEqual([1, 2, 3])
|
|
265
|
+
})
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
// ─── Stream mode error handling (handler.ts lines 175-178) ──────────────────
|
|
269
|
+
|
|
270
|
+
describe("createHandler — stream mode error in rendering", () => {
|
|
271
|
+
test("stream mode handles render error gracefully", async () => {
|
|
272
|
+
let callCount = 0
|
|
273
|
+
const BrokenApp: ComponentFn = () => {
|
|
274
|
+
callCount++
|
|
275
|
+
if (callCount > 0) throw new Error("render boom")
|
|
276
|
+
return h("div", null, "ok")
|
|
277
|
+
}
|
|
278
|
+
const routes = [{ path: "/", component: BrokenApp }]
|
|
279
|
+
const handler = createHandler({ App: BrokenApp, routes, mode: "stream" })
|
|
280
|
+
// The stream mode should catch errors and emit an error script
|
|
281
|
+
// Since renderToStream might throw synchronously, the handler might throw
|
|
282
|
+
// or return a response depending on when the error occurs
|
|
283
|
+
try {
|
|
284
|
+
const res = await handler(new Request("http://localhost/"))
|
|
285
|
+
const _html = await res.text()
|
|
286
|
+
// If it returns a response, check it's still a valid response
|
|
287
|
+
expect(res.status).toBeDefined()
|
|
288
|
+
} catch {
|
|
289
|
+
// If it throws, that's also acceptable (error propagation)
|
|
290
|
+
}
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// ─── Middleware type exports ─────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
describe("middleware types", () => {
|
|
297
|
+
test("MiddlewareContext and Middleware types are importable", async () => {
|
|
298
|
+
const mod = await import("../middleware")
|
|
299
|
+
// Just verify the module can be imported — it's pure types
|
|
300
|
+
expect(mod).toBeDefined()
|
|
301
|
+
})
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
// ─── Islands ─────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
describe("island", () => {
|
|
307
|
+
test("island() returns a function with island metadata", () => {
|
|
308
|
+
const Counter = island(() => Promise.resolve({ default: () => h("div", null, "0") }), {
|
|
309
|
+
name: "Counter",
|
|
310
|
+
})
|
|
311
|
+
expect(typeof Counter).toBe("function")
|
|
312
|
+
expect((Counter as unknown as { __island: boolean }).__island).toBe(true)
|
|
313
|
+
expect(Counter.name).toBe("Counter")
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
test("island() renders with <pyreon-island> wrapper during SSR", async () => {
|
|
317
|
+
const Inner: ComponentFn = (props) =>
|
|
318
|
+
h("button", null, `Count: ${(props as Record<string, unknown>).initial}`)
|
|
319
|
+
const Counter = island<{ initial: number }>(() => Promise.resolve({ default: Inner }), {
|
|
320
|
+
name: "Counter",
|
|
321
|
+
hydrate: "idle",
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
// Simulate SSR by calling the async component
|
|
325
|
+
const vnode = await (Counter as unknown as (props: { initial: number }) => Promise<VNode>)({
|
|
326
|
+
initial: 5,
|
|
327
|
+
})
|
|
328
|
+
expect(vnode).not.toBeNull()
|
|
329
|
+
// The wrapper should be a pyreon-island element
|
|
330
|
+
expect(vnode.type).toBe("pyreon-island")
|
|
331
|
+
expect(vnode.props["data-component"]).toBe("Counter")
|
|
332
|
+
expect(vnode.props["data-hydrate"]).toBe("idle")
|
|
333
|
+
const parsedProps = JSON.parse(vnode.props["data-props"] as string)
|
|
334
|
+
expect(parsedProps.initial).toBe(5)
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
test("island() strips non-serializable props", async () => {
|
|
338
|
+
const Inner: ComponentFn = () => h("div", null)
|
|
339
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: "Widget" })
|
|
340
|
+
|
|
341
|
+
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
|
|
342
|
+
label: "hello",
|
|
343
|
+
onClick: () => {},
|
|
344
|
+
sym: Symbol("test"),
|
|
345
|
+
nested: { a: 1 },
|
|
346
|
+
})
|
|
347
|
+
const parsedProps = JSON.parse(vnode.props["data-props"] as string)
|
|
348
|
+
expect(parsedProps.label).toBe("hello")
|
|
349
|
+
expect(parsedProps.onClick).toBeUndefined()
|
|
350
|
+
expect(parsedProps.sym).toBeUndefined()
|
|
351
|
+
expect(parsedProps.nested).toEqual({ a: 1 })
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
test("island() strips children prop from serialized props", async () => {
|
|
355
|
+
const Inner: ComponentFn = () => h("div", null)
|
|
356
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: "Widget" })
|
|
357
|
+
|
|
358
|
+
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
|
|
359
|
+
title: "test",
|
|
360
|
+
children: h("span", null, "child"),
|
|
361
|
+
})
|
|
362
|
+
const parsedProps = JSON.parse(vnode.props["data-props"] as string)
|
|
363
|
+
expect(parsedProps.title).toBe("test")
|
|
364
|
+
expect(parsedProps.children).toBeUndefined()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test("island() strips undefined values from serialized props", async () => {
|
|
368
|
+
const Inner: ComponentFn = () => h("div", null)
|
|
369
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: "Widget" })
|
|
370
|
+
|
|
371
|
+
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)({
|
|
372
|
+
present: "yes",
|
|
373
|
+
missing: undefined,
|
|
374
|
+
})
|
|
375
|
+
const parsedProps = JSON.parse(vnode.props["data-props"] as string)
|
|
376
|
+
expect(parsedProps.present).toBe("yes")
|
|
377
|
+
expect("missing" in parsedProps).toBe(false)
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
test("island() resolves direct function module (not { default })", async () => {
|
|
381
|
+
const Inner: ComponentFn = () => h("span", null, "direct")
|
|
382
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: "Direct" })
|
|
383
|
+
|
|
384
|
+
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)(
|
|
385
|
+
{},
|
|
386
|
+
)
|
|
387
|
+
expect(vnode.type).toBe("pyreon-island")
|
|
388
|
+
expect(vnode.props["data-component"]).toBe("Direct")
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
test("island() defaults hydrate to 'load'", () => {
|
|
392
|
+
const Inner: ComponentFn = () => h("div", null)
|
|
393
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: "NoHydrate" })
|
|
394
|
+
expect((Widget as unknown as { hydrate: string }).hydrate).toBe("load")
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
test("island() metadata properties are non-writable", () => {
|
|
398
|
+
const Inner: ComponentFn = () => h("div", null)
|
|
399
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), {
|
|
400
|
+
name: "Frozen",
|
|
401
|
+
hydrate: "visible",
|
|
402
|
+
})
|
|
403
|
+
const meta = Widget as unknown as { __island: boolean; hydrate: string }
|
|
404
|
+
expect(meta.__island).toBe(true)
|
|
405
|
+
expect(meta.hydrate).toBe("visible")
|
|
406
|
+
})
|
|
407
|
+
|
|
408
|
+
test("island() serializes empty props as empty object", async () => {
|
|
409
|
+
const Inner: ComponentFn = () => h("div", null)
|
|
410
|
+
const Widget = island(() => Promise.resolve({ default: Inner }), { name: "Empty" })
|
|
411
|
+
|
|
412
|
+
const vnode = await (Widget as unknown as (props: Record<string, unknown>) => Promise<VNode>)(
|
|
413
|
+
{},
|
|
414
|
+
)
|
|
415
|
+
expect(vnode.props["data-props"]).toBe("{}")
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
// ─── SSG ─────────────────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
describe("prerender", () => {
|
|
422
|
+
test("generates HTML files for given paths", async () => {
|
|
423
|
+
const Home: ComponentFn = () => h("h1", null, "Home")
|
|
424
|
+
const About: ComponentFn = () => h("h1", null, "About")
|
|
425
|
+
const routes = [
|
|
426
|
+
{ path: "/", component: Home },
|
|
427
|
+
{ path: "/about", component: About },
|
|
428
|
+
]
|
|
429
|
+
const handler = createHandler({ App: Home, routes })
|
|
430
|
+
|
|
431
|
+
const _written: Record<string, string> = {}
|
|
432
|
+
const tmpDir = `/tmp/pyreon-ssg-test-${Date.now()}`
|
|
433
|
+
|
|
434
|
+
const result = await prerender({
|
|
435
|
+
handler,
|
|
436
|
+
paths: ["/", "/about"],
|
|
437
|
+
outDir: tmpDir,
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
expect(result.pages).toBe(2)
|
|
441
|
+
expect(result.errors).toHaveLength(0)
|
|
442
|
+
expect(result.elapsed).toBeGreaterThanOrEqual(0)
|
|
443
|
+
|
|
444
|
+
// Verify files exist
|
|
445
|
+
const { readFile, rm } = await import("node:fs/promises")
|
|
446
|
+
const indexHtml = await readFile(`${tmpDir}/index.html`, "utf-8")
|
|
447
|
+
expect(indexHtml).toContain("<h1>Home</h1>")
|
|
448
|
+
|
|
449
|
+
const aboutStat = await import("node:fs").then((fs) =>
|
|
450
|
+
fs.existsSync(`${tmpDir}/about/index.html`),
|
|
451
|
+
)
|
|
452
|
+
expect(aboutStat).toBe(true)
|
|
453
|
+
|
|
454
|
+
// Cleanup
|
|
455
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
test("onPage callback can skip pages", async () => {
|
|
459
|
+
const App: ComponentFn = () => h("div", null)
|
|
460
|
+
const handler = createHandler({ App, routes: [{ path: "/", component: App }] })
|
|
461
|
+
|
|
462
|
+
const tmpDir = `/tmp/pyreon-ssg-skip-${Date.now()}`
|
|
463
|
+
const result = await prerender({
|
|
464
|
+
handler,
|
|
465
|
+
paths: ["/"],
|
|
466
|
+
outDir: tmpDir,
|
|
467
|
+
onPage: () => false, // skip all pages
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
expect(result.pages).toBe(0)
|
|
471
|
+
|
|
472
|
+
const { rm } = await import("node:fs/promises")
|
|
473
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
test("paths can be an async function", async () => {
|
|
477
|
+
const App: ComponentFn = () => h("div", null)
|
|
478
|
+
const handler = createHandler({ App, routes: [{ path: "/", component: App }] })
|
|
479
|
+
|
|
480
|
+
const tmpDir = `/tmp/pyreon-ssg-async-${Date.now()}`
|
|
481
|
+
const result = await prerender({
|
|
482
|
+
handler,
|
|
483
|
+
paths: async () => ["/"],
|
|
484
|
+
outDir: tmpDir,
|
|
485
|
+
})
|
|
486
|
+
|
|
487
|
+
expect(result.pages).toBe(1)
|
|
488
|
+
|
|
489
|
+
const { rm } = await import("node:fs/promises")
|
|
490
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
test("records errors for non-ok responses", async () => {
|
|
494
|
+
// Handler that returns 404 for /missing
|
|
495
|
+
const handler = async (req: Request) => {
|
|
496
|
+
const url = new URL(req.url)
|
|
497
|
+
if (url.pathname === "/missing") {
|
|
498
|
+
return new Response("Not Found", { status: 404 })
|
|
499
|
+
}
|
|
500
|
+
return new Response("<html>OK</html>", { status: 200 })
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const tmpDir = `/tmp/pyreon-ssg-errors-${Date.now()}`
|
|
504
|
+
const result = await prerender({
|
|
505
|
+
handler,
|
|
506
|
+
paths: ["/", "/missing"],
|
|
507
|
+
outDir: tmpDir,
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
expect(result.pages).toBe(1)
|
|
511
|
+
expect(result.errors).toHaveLength(1)
|
|
512
|
+
expect(result.errors[0]?.path).toBe("/missing")
|
|
513
|
+
|
|
514
|
+
const { rm } = await import("node:fs/promises")
|
|
515
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
test("records errors when handler throws", async () => {
|
|
519
|
+
const handler = async (_req: Request) => {
|
|
520
|
+
throw new Error("handler exploded")
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const tmpDir = `/tmp/pyreon-ssg-throw-${Date.now()}`
|
|
524
|
+
const result = await prerender({
|
|
525
|
+
handler,
|
|
526
|
+
paths: ["/"],
|
|
527
|
+
outDir: tmpDir,
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
expect(result.pages).toBe(0)
|
|
531
|
+
expect(result.errors).toHaveLength(1)
|
|
532
|
+
expect(result.errors[0]?.path).toBe("/")
|
|
533
|
+
expect(result.errors[0]?.error).toBeInstanceOf(Error)
|
|
534
|
+
|
|
535
|
+
const { rm } = await import("node:fs/promises")
|
|
536
|
+
await rm(tmpDir, { recursive: true, force: true }).catch(() => {})
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
test("handles .html path suffix", async () => {
|
|
540
|
+
const handler = async (_req: Request) => new Response("<html>page</html>", { status: 200 })
|
|
541
|
+
|
|
542
|
+
const tmpDir = `/tmp/pyreon-ssg-html-${Date.now()}`
|
|
543
|
+
const result = await prerender({
|
|
544
|
+
handler,
|
|
545
|
+
paths: ["/custom.html"],
|
|
546
|
+
outDir: tmpDir,
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
expect(result.pages).toBe(1)
|
|
550
|
+
|
|
551
|
+
const { readFile, rm } = await import("node:fs/promises")
|
|
552
|
+
const content = await readFile(`${tmpDir}/custom.html`, "utf-8")
|
|
553
|
+
expect(content).toBe("<html>page</html>")
|
|
554
|
+
|
|
555
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
test("onPage callback receives path and html", async () => {
|
|
559
|
+
const handler = async (_req: Request) => new Response("<html>content</html>", { status: 200 })
|
|
560
|
+
|
|
561
|
+
const received: { path: string; html: string }[] = []
|
|
562
|
+
const tmpDir = `/tmp/pyreon-ssg-onpage-${Date.now()}`
|
|
563
|
+
await prerender({
|
|
564
|
+
handler,
|
|
565
|
+
paths: ["/", "/about"],
|
|
566
|
+
outDir: tmpDir,
|
|
567
|
+
onPage: (path, html) => {
|
|
568
|
+
received.push({ path, html })
|
|
569
|
+
},
|
|
570
|
+
})
|
|
571
|
+
|
|
572
|
+
expect(received).toHaveLength(2)
|
|
573
|
+
expect(received.some((r) => r.path === "/")).toBe(true)
|
|
574
|
+
expect(received.some((r) => r.path === "/about")).toBe(true)
|
|
575
|
+
expect(received[0]?.html).toBe("<html>content</html>")
|
|
576
|
+
|
|
577
|
+
const { rm } = await import("node:fs/promises")
|
|
578
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
test("uses custom origin", async () => {
|
|
582
|
+
let receivedUrl = ""
|
|
583
|
+
const handler = async (req: Request) => {
|
|
584
|
+
receivedUrl = req.url
|
|
585
|
+
return new Response("<html></html>", { status: 200 })
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const tmpDir = `/tmp/pyreon-ssg-origin-${Date.now()}`
|
|
589
|
+
await prerender({
|
|
590
|
+
handler,
|
|
591
|
+
paths: ["/test"],
|
|
592
|
+
outDir: tmpDir,
|
|
593
|
+
origin: "https://example.com",
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
expect(receivedUrl).toBe("https://example.com/test")
|
|
597
|
+
|
|
598
|
+
const { rm } = await import("node:fs/promises")
|
|
599
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
test("paths as sync function", async () => {
|
|
603
|
+
const handler = async (_req: Request) => new Response("<html></html>", { status: 200 })
|
|
604
|
+
|
|
605
|
+
const tmpDir = `/tmp/pyreon-ssg-sync-fn-${Date.now()}`
|
|
606
|
+
const result = await prerender({
|
|
607
|
+
handler,
|
|
608
|
+
paths: () => ["/a", "/b"],
|
|
609
|
+
outDir: tmpDir,
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
expect(result.pages).toBe(2)
|
|
613
|
+
|
|
614
|
+
const { rm } = await import("node:fs/promises")
|
|
615
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
616
|
+
})
|
|
617
|
+
|
|
618
|
+
test("batches more than 10 paths", async () => {
|
|
619
|
+
const handler = async (_req: Request) => new Response("<html>ok</html>", { status: 200 })
|
|
620
|
+
|
|
621
|
+
const paths = Array.from({ length: 15 }, (_, i) => `/page-${i}`)
|
|
622
|
+
const tmpDir = `/tmp/pyreon-ssg-batch-${Date.now()}`
|
|
623
|
+
const result = await prerender({
|
|
624
|
+
handler,
|
|
625
|
+
paths,
|
|
626
|
+
outDir: tmpDir,
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
expect(result.pages).toBe(15)
|
|
630
|
+
expect(result.errors).toHaveLength(0)
|
|
631
|
+
|
|
632
|
+
const { rm } = await import("node:fs/promises")
|
|
633
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
634
|
+
})
|
|
635
|
+
})
|