@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/src/html.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * HTML template processing for SSR/SSG.
3
+ *
4
+ * Templates use comment placeholders:
5
+ * <!--pyreon-head--> — replaced with <head> tags (title, meta, link, etc.)
6
+ * <!--pyreon-app--> — replaced with rendered application HTML
7
+ * <!--pyreon-scripts--> — replaced with client entry script + inline loader data
8
+ */
9
+
10
+ export const DEFAULT_TEMPLATE = `<!DOCTYPE html>
11
+ <html lang="en">
12
+ <head>
13
+ <meta charset="UTF-8">
14
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
15
+ <!--pyreon-head-->
16
+ </head>
17
+ <body>
18
+ <div id="app"><!--pyreon-app--></div>
19
+ <!--pyreon-scripts-->
20
+ </body>
21
+ </html>`
22
+
23
+ export interface TemplateData {
24
+ head: string
25
+ app: string
26
+ scripts: string
27
+ }
28
+
29
+ export function processTemplate(template: string, data: TemplateData): string {
30
+ return template
31
+ .replace("<!--pyreon-head-->", data.head)
32
+ .replace("<!--pyreon-app-->", data.app)
33
+ .replace("<!--pyreon-scripts-->", data.scripts)
34
+ }
35
+
36
+ /**
37
+ * Build the script tags for client hydration.
38
+ *
39
+ * Emits:
40
+ * 1. Inline script with serialized loader data (if any)
41
+ * 2. Module script tag pointing to the client entry
42
+ */
43
+ export function buildScripts(
44
+ clientEntry: string,
45
+ loaderData: Record<string, unknown> | null,
46
+ ): string {
47
+ const parts: string[] = []
48
+
49
+ if (loaderData && Object.keys(loaderData).length > 0) {
50
+ // Escape </script> inside JSON to prevent premature tag close
51
+ const json = JSON.stringify(loaderData).replace(/<\//g, "<\\/")
52
+ parts.push(`<script>window.__PYREON_LOADER_DATA__=${json}</script>`)
53
+ }
54
+
55
+ parts.push(`<script type="module" src="${clientEntry}"></script>`)
56
+
57
+ return parts.join("\n ")
58
+ }
package/src/index.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @pyreon/server — SSR, SSG, and island architecture for Pyreon.
3
+ *
4
+ * Server-side:
5
+ * import { createHandler, prerender, island } from "@pyreon/server"
6
+ *
7
+ * Client-side (tree-shakeable, separate entry):
8
+ * import { startClient, hydrateIslands } from "@pyreon/server/client"
9
+ *
10
+ * ## Quick start — SSR
11
+ *
12
+ * ```ts
13
+ * // server.ts
14
+ * import { createHandler } from "@pyreon/server"
15
+ * import { App } from "./App"
16
+ * import { routes } from "./routes"
17
+ *
18
+ * const handler = createHandler({
19
+ * App,
20
+ * routes,
21
+ * template: await Bun.file("index.html").text(),
22
+ * })
23
+ *
24
+ * Bun.serve({ fetch: handler, port: 3000 })
25
+ * ```
26
+ *
27
+ * ## Quick start — SSG
28
+ *
29
+ * ```ts
30
+ * // build.ts
31
+ * import { createHandler, prerender } from "@pyreon/server"
32
+ *
33
+ * const handler = createHandler({ App, routes })
34
+ * const result = await prerender({
35
+ * handler,
36
+ * paths: ["/", "/about", "/blog"],
37
+ * outDir: "dist",
38
+ * })
39
+ * console.log(`Generated ${result.pages} pages in ${result.elapsed}ms`)
40
+ * ```
41
+ *
42
+ * ## Quick start — Islands
43
+ *
44
+ * ```tsx
45
+ * // Server
46
+ * import { island } from "@pyreon/server"
47
+ * const Counter = island(() => import("./Counter"), { name: "Counter" })
48
+ *
49
+ * // Client (entry-client.ts)
50
+ * import { hydrateIslands } from "@pyreon/server/client"
51
+ * hydrateIslands({ Counter: () => import("./Counter") })
52
+ * ```
53
+ */
54
+
55
+ export type { HandlerOptions } from "./handler"
56
+ // SSR handler
57
+ export { createHandler } from "./handler"
58
+ export type { TemplateData } from "./html"
59
+ // HTML template
60
+ export { buildScripts, DEFAULT_TEMPLATE, processTemplate } from "./html"
61
+ export type { HydrationStrategy, IslandMeta, IslandOptions } from "./island"
62
+ // Islands
63
+ export { island } from "./island"
64
+
65
+ // Middleware
66
+ export type { Middleware, MiddlewareContext } from "./middleware"
67
+ export type { PrerenderOptions, PrerenderResult } from "./ssg"
68
+ // SSG
69
+ export { prerender } from "./ssg"
package/src/island.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Island architecture — partial hydration for content-heavy sites.
3
+ *
4
+ * Islands are interactive components embedded in otherwise-static HTML.
5
+ * Only island components ship JavaScript to the client — the rest of the
6
+ * page stays as zero-JS server-rendered HTML.
7
+ *
8
+ * ## Server side
9
+ *
10
+ * `island()` wraps an async component import and returns a ComponentFn.
11
+ * During SSR, it renders the component output inside a `<pyreon-island>` element
12
+ * with serialized props, so the client knows what to hydrate.
13
+ *
14
+ * ```tsx
15
+ * import { island } from "@pyreon/server"
16
+ *
17
+ * const Counter = island(() => import("./Counter"), { name: "Counter" })
18
+ * const Search = island(() => import("./Search"), { name: "Search" })
19
+ *
20
+ * function Page() {
21
+ * return <div>
22
+ * <h1>Static heading (no JS)</h1>
23
+ * <Counter initial={5} /> // hydrated on client
24
+ * <p>Static paragraph</p>
25
+ * <Search /> // hydrated on client
26
+ * </div>
27
+ * }
28
+ * ```
29
+ *
30
+ * ## Client side
31
+ *
32
+ * Use `hydrateIslands()` from `@pyreon/server/client` to hydrate all islands
33
+ * on the page. Only the island components' JavaScript is loaded.
34
+ *
35
+ * ```ts
36
+ * // entry-client.ts (island mode)
37
+ * import { hydrateIslands } from "@pyreon/server/client"
38
+ *
39
+ * hydrateIslands({
40
+ * Counter: () => import("./Counter"),
41
+ * Search: () => import("./Search"),
42
+ * })
43
+ * ```
44
+ *
45
+ * ## Hydration strategies
46
+ *
47
+ * Control when an island hydrates via the `hydrate` option:
48
+ * - "load" (default) — hydrate immediately on page load
49
+ * - "idle" — hydrate when the browser is idle (requestIdleCallback)
50
+ * - "visible" — hydrate when the island scrolls into the viewport
51
+ * - "media(query)" — hydrate when a media query matches
52
+ * - "never" — never hydrate (render-only, no client JS)
53
+ */
54
+
55
+ import type { ComponentFn, Props, VNode } from "@pyreon/core"
56
+ import { h } from "@pyreon/core"
57
+
58
+ // ─── Types ───────────────────────────────────────────────────────────────────
59
+
60
+ export type HydrationStrategy = "load" | "idle" | "visible" | "never" | `media(${string})`
61
+
62
+ export interface IslandOptions {
63
+ /** Unique name — must match the key in the client-side hydrateIslands() registry */
64
+ name: string
65
+ /** When to hydrate on the client (default: "load") */
66
+ hydrate?: HydrationStrategy
67
+ }
68
+
69
+ export interface IslandMeta {
70
+ readonly __island: true
71
+ readonly name: string
72
+ readonly hydrate: HydrationStrategy
73
+ }
74
+
75
+ // ─── Server-side island factory ──────────────────────────────────────────────
76
+
77
+ /**
78
+ * Create an island component.
79
+ *
80
+ * Returns an async ComponentFn that:
81
+ * 1. Resolves the dynamic import
82
+ * 2. Renders the component to VNodes
83
+ * 3. Wraps the output in `<pyreon-island>` with serialized props + hydration strategy
84
+ */
85
+ export function island<P extends Props = Props>(
86
+ loader: () => Promise<{ default: ComponentFn<P> } | ComponentFn<P>>,
87
+ options: IslandOptions,
88
+ ): ComponentFn<P> & IslandMeta {
89
+ const { name, hydrate = "load" } = options
90
+
91
+ const IslandWrapper = async function IslandWrapper(props: P): Promise<VNode | null> {
92
+ const mod = await loader()
93
+ const Comp = typeof mod === "function" ? mod : mod.default
94
+ const serializedProps = serializeIslandProps(props)
95
+
96
+ return h(
97
+ "pyreon-island",
98
+ {
99
+ "data-component": name,
100
+ "data-props": serializedProps,
101
+ "data-hydrate": hydrate,
102
+ },
103
+ h(Comp, props),
104
+ )
105
+ }
106
+
107
+ // Attach metadata so the Vite plugin can detect islands for code-splitting
108
+ const wrapper = IslandWrapper as unknown as ComponentFn<P> & IslandMeta
109
+ Object.defineProperties(wrapper, {
110
+ __island: { value: true, enumerable: true },
111
+ name: { value: name, enumerable: true, writable: false, configurable: true },
112
+ hydrate: { value: hydrate, enumerable: true },
113
+ })
114
+
115
+ return wrapper
116
+ }
117
+
118
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
119
+
120
+ /**
121
+ * Serialize component props to a JSON string for embedding in HTML attributes.
122
+ * Strips non-serializable values (functions, symbols, children).
123
+ */
124
+ function serializeIslandProps(props: Record<string, unknown>): string {
125
+ const clean: Record<string, unknown> = {}
126
+ for (const [key, value] of Object.entries(props)) {
127
+ // Skip non-serializable or internal props
128
+ if (key === "children") continue
129
+ if (typeof value === "function") continue
130
+ if (typeof value === "symbol") continue
131
+ if (value === undefined) continue
132
+ clean[key] = value
133
+ }
134
+ // The SSR renderer's renderProp() already applies escapeHtml() to attribute
135
+ // values, so the JSON is safe to embed in HTML attributes without double-escaping.
136
+ return JSON.stringify(clean)
137
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * SSR middleware — simple request processing pipeline.
3
+ *
4
+ * Middleware runs before rendering. Return a Response to short-circuit
5
+ * (e.g. for redirects, auth checks, or static file serving).
6
+ * Return void / undefined to continue to the next middleware or rendering.
7
+ *
8
+ * @example
9
+ * const authMiddleware: Middleware = async (ctx) => {
10
+ * const token = ctx.req.headers.get("Authorization")
11
+ * if (!token) return new Response("Unauthorized", { status: 401 })
12
+ * ctx.locals.user = await verifyToken(token)
13
+ * }
14
+ *
15
+ * const handler = createHandler({
16
+ * App,
17
+ * routes,
18
+ * middleware: [authMiddleware],
19
+ * })
20
+ */
21
+
22
+ export interface MiddlewareContext {
23
+ /** The incoming request */
24
+ req: Request
25
+ /** Parsed URL */
26
+ url: URL
27
+ /** Pathname + search (passed to router) */
28
+ path: string
29
+ /** Response headers — middleware can set custom headers */
30
+ headers: Headers
31
+ /** Arbitrary per-request data shared between middleware and components */
32
+ locals: Record<string, unknown>
33
+ }
34
+
35
+ /**
36
+ * Middleware function. Return a Response to short-circuit, or void to continue.
37
+ */
38
+ // biome-ignore lint/suspicious/noConfusingVoidType: void is intentional — callers may return void
39
+ export type Middleware = (ctx: MiddlewareContext) => Response | void | Promise<Response | void>
package/src/ssg.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Static Site Generation — pre-render routes to HTML files at build time.
3
+ *
4
+ * @example
5
+ * // ssg.ts (run with: bun run ssg.ts)
6
+ * import { createHandler } from "@pyreon/server"
7
+ * import { prerender } from "@pyreon/server"
8
+ * import { App } from "./src/App"
9
+ * import { routes } from "./src/routes"
10
+ *
11
+ * const handler = createHandler({ App, routes })
12
+ *
13
+ * await prerender({
14
+ * handler,
15
+ * paths: ["/", "/about", "/blog", "/blog/hello-world"],
16
+ * outDir: "dist",
17
+ * })
18
+ *
19
+ * @example
20
+ * // Dynamic paths from a CMS or filesystem
21
+ * await prerender({
22
+ * handler,
23
+ * paths: async () => {
24
+ * const posts = await fetchAllPosts()
25
+ * return ["/", "/about", ...posts.map(p => `/blog/${p.slug}`)]
26
+ * },
27
+ * outDir: "dist",
28
+ * })
29
+ */
30
+
31
+ import { mkdir, writeFile } from "node:fs/promises"
32
+ import { dirname, join, resolve } from "node:path"
33
+
34
+ export interface PrerenderOptions {
35
+ /** SSR handler created by createHandler() */
36
+ handler: (req: Request) => Promise<Response>
37
+ /** Routes to pre-render — array of URL paths or async function that returns them */
38
+ paths: string[] | (() => string[] | Promise<string[]>)
39
+ /** Output directory for the generated HTML files */
40
+ outDir: string
41
+ /** Origin for constructing full URLs (default: "http://localhost") */
42
+ origin?: string
43
+ /**
44
+ * Called after each page is rendered — use for logging or progress tracking.
45
+ * Return false to skip writing this page.
46
+ */
47
+ // biome-ignore lint/suspicious/noConfusingVoidType: void is intentional
48
+ onPage?: (path: string, html: string) => void | boolean | Promise<void | boolean>
49
+ }
50
+
51
+ export interface PrerenderResult {
52
+ /** Number of pages generated */
53
+ pages: number
54
+ /** Paths that failed to render */
55
+ errors: { path: string; error: unknown }[]
56
+ /** Total elapsed time in milliseconds */
57
+ elapsed: number
58
+ }
59
+
60
+ /**
61
+ * Pre-render a list of routes to static HTML files.
62
+ *
63
+ * For each path:
64
+ * 1. Constructs a Request for the path
65
+ * 2. Calls the SSR handler to render to HTML
66
+ * 3. Writes the HTML to `outDir/<path>/index.html`
67
+ *
68
+ * The root path "/" becomes `outDir/index.html`.
69
+ * Paths like "/about" become `outDir/about/index.html`.
70
+ */
71
+ export async function prerender(options: PrerenderOptions): Promise<PrerenderResult> {
72
+ const { handler, outDir, origin = "http://localhost", onPage } = options
73
+
74
+ const start = Date.now()
75
+
76
+ // Resolve paths (may be async)
77
+ const paths = typeof options.paths === "function" ? await options.paths() : options.paths
78
+
79
+ let pages = 0
80
+ const errors: PrerenderResult["errors"] = []
81
+
82
+ async function renderPage(path: string): Promise<void> {
83
+ const url = new URL(path, origin)
84
+ const req = new Request(url.href)
85
+ const res = await Promise.race([
86
+ handler(req),
87
+ new Promise<never>((_, reject) =>
88
+ setTimeout(() => reject(new Error(`Prerender timeout for "${path}" (30s)`)), 30_000),
89
+ ),
90
+ ])
91
+
92
+ if (!res.ok) {
93
+ errors.push({ path, error: new Error(`HTTP ${res.status}`) })
94
+ return
95
+ }
96
+
97
+ const html = await res.text()
98
+
99
+ if (onPage) {
100
+ const result = await onPage(path, html)
101
+ if (result === false) return
102
+ }
103
+
104
+ const filePath = resolveOutputPath(outDir, path)
105
+
106
+ const resolvedOut = resolve(outDir)
107
+ if (!resolve(filePath).startsWith(resolvedOut)) {
108
+ errors.push({ path, error: new Error(`Path traversal detected: "${path}"`) })
109
+ return
110
+ }
111
+
112
+ await mkdir(dirname(filePath), { recursive: true })
113
+ await writeFile(filePath, html, "utf-8")
114
+ pages++
115
+ }
116
+
117
+ // Process paths concurrently (batch of 10 to avoid overwhelming)
118
+ const BATCH_SIZE = 10
119
+ for (let i = 0; i < paths.length; i += BATCH_SIZE) {
120
+ const batch = paths.slice(i, i + BATCH_SIZE)
121
+ await Promise.all(
122
+ batch.map(async (path) => {
123
+ try {
124
+ await renderPage(path)
125
+ } catch (error) {
126
+ errors.push({ path, error })
127
+ }
128
+ }),
129
+ )
130
+ }
131
+
132
+ return {
133
+ pages,
134
+ errors,
135
+ elapsed: Date.now() - start,
136
+ }
137
+ }
138
+
139
+ function resolveOutputPath(outDir: string, path: string): string {
140
+ if (path === "/") return join(outDir, "index.html")
141
+ if (path.endsWith(".html")) return join(outDir, path)
142
+ return join(outDir, path, "index.html")
143
+ }