@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,190 @@
|
|
|
1
|
+
import { ComponentFn, Props } from "@pyreon/core";
|
|
2
|
+
import { RouteRecord } from "@pyreon/router";
|
|
3
|
+
|
|
4
|
+
//#region src/middleware.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* SSR middleware — simple request processing pipeline.
|
|
7
|
+
*
|
|
8
|
+
* Middleware runs before rendering. Return a Response to short-circuit
|
|
9
|
+
* (e.g. for redirects, auth checks, or static file serving).
|
|
10
|
+
* Return void / undefined to continue to the next middleware or rendering.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* const authMiddleware: Middleware = async (ctx) => {
|
|
14
|
+
* const token = ctx.req.headers.get("Authorization")
|
|
15
|
+
* if (!token) return new Response("Unauthorized", { status: 401 })
|
|
16
|
+
* ctx.locals.user = await verifyToken(token)
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* const handler = createHandler({
|
|
20
|
+
* App,
|
|
21
|
+
* routes,
|
|
22
|
+
* middleware: [authMiddleware],
|
|
23
|
+
* })
|
|
24
|
+
*/
|
|
25
|
+
interface MiddlewareContext {
|
|
26
|
+
/** The incoming request */
|
|
27
|
+
req: Request;
|
|
28
|
+
/** Parsed URL */
|
|
29
|
+
url: URL;
|
|
30
|
+
/** Pathname + search (passed to router) */
|
|
31
|
+
path: string;
|
|
32
|
+
/** Response headers — middleware can set custom headers */
|
|
33
|
+
headers: Headers;
|
|
34
|
+
/** Arbitrary per-request data shared between middleware and components */
|
|
35
|
+
locals: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Middleware function. Return a Response to short-circuit, or void to continue.
|
|
39
|
+
*/
|
|
40
|
+
type Middleware = (ctx: MiddlewareContext) => Response | void | Promise<Response | void>;
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/handler.d.ts
|
|
43
|
+
interface HandlerOptions {
|
|
44
|
+
/** Root application component */
|
|
45
|
+
App: ComponentFn;
|
|
46
|
+
/** Route definitions */
|
|
47
|
+
routes: RouteRecord[];
|
|
48
|
+
/**
|
|
49
|
+
* HTML template with placeholders:
|
|
50
|
+
* <!--pyreon-head--> — head tags (title, meta, link, etc.)
|
|
51
|
+
* <!--pyreon-app--> — rendered app HTML
|
|
52
|
+
* <!--pyreon-scripts--> — client entry + loader data
|
|
53
|
+
*
|
|
54
|
+
* Defaults to a minimal HTML5 template.
|
|
55
|
+
*/
|
|
56
|
+
template?: string;
|
|
57
|
+
/** Path to the client entry module (default: "/src/entry-client.ts") */
|
|
58
|
+
clientEntry?: string;
|
|
59
|
+
/** Middleware chain — runs before rendering */
|
|
60
|
+
middleware?: Middleware[];
|
|
61
|
+
/**
|
|
62
|
+
* Rendering mode:
|
|
63
|
+
* "string" (default) — full renderToString, complete HTML in one response
|
|
64
|
+
* "stream" — progressive streaming via renderToStream (Suspense out-of-order)
|
|
65
|
+
*/
|
|
66
|
+
mode?: "string" | "stream";
|
|
67
|
+
}
|
|
68
|
+
declare function createHandler(options: HandlerOptions): (req: Request) => Promise<Response>;
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/html.d.ts
|
|
71
|
+
/**
|
|
72
|
+
* HTML template processing for SSR/SSG.
|
|
73
|
+
*
|
|
74
|
+
* Templates use comment placeholders:
|
|
75
|
+
* <!--pyreon-head--> — replaced with <head> tags (title, meta, link, etc.)
|
|
76
|
+
* <!--pyreon-app--> — replaced with rendered application HTML
|
|
77
|
+
* <!--pyreon-scripts--> — replaced with client entry script + inline loader data
|
|
78
|
+
*/
|
|
79
|
+
declare const DEFAULT_TEMPLATE = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <!--pyreon-head-->\n</head>\n<body>\n <div id=\"app\"><!--pyreon-app--></div>\n <!--pyreon-scripts-->\n</body>\n</html>";
|
|
80
|
+
interface TemplateData {
|
|
81
|
+
head: string;
|
|
82
|
+
app: string;
|
|
83
|
+
scripts: string;
|
|
84
|
+
}
|
|
85
|
+
declare function processTemplate(template: string, data: TemplateData): string;
|
|
86
|
+
/**
|
|
87
|
+
* Build the script tags for client hydration.
|
|
88
|
+
*
|
|
89
|
+
* Emits:
|
|
90
|
+
* 1. Inline script with serialized loader data (if any)
|
|
91
|
+
* 2. Module script tag pointing to the client entry
|
|
92
|
+
*/
|
|
93
|
+
declare function buildScripts(clientEntry: string, loaderData: Record<string, unknown> | null): string;
|
|
94
|
+
//#endregion
|
|
95
|
+
//#region src/island.d.ts
|
|
96
|
+
type HydrationStrategy = "load" | "idle" | "visible" | "never" | `media(${string})`;
|
|
97
|
+
interface IslandOptions {
|
|
98
|
+
/** Unique name — must match the key in the client-side hydrateIslands() registry */
|
|
99
|
+
name: string;
|
|
100
|
+
/** When to hydrate on the client (default: "load") */
|
|
101
|
+
hydrate?: HydrationStrategy;
|
|
102
|
+
}
|
|
103
|
+
interface IslandMeta {
|
|
104
|
+
readonly __island: true;
|
|
105
|
+
readonly name: string;
|
|
106
|
+
readonly hydrate: HydrationStrategy;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Create an island component.
|
|
110
|
+
*
|
|
111
|
+
* Returns an async ComponentFn that:
|
|
112
|
+
* 1. Resolves the dynamic import
|
|
113
|
+
* 2. Renders the component to VNodes
|
|
114
|
+
* 3. Wraps the output in `<pyreon-island>` with serialized props + hydration strategy
|
|
115
|
+
*/
|
|
116
|
+
declare function island<P extends Props = Props>(loader: () => Promise<{
|
|
117
|
+
default: ComponentFn<P>;
|
|
118
|
+
} | ComponentFn<P>>, options: IslandOptions): ComponentFn<P> & IslandMeta;
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/ssg.d.ts
|
|
121
|
+
/**
|
|
122
|
+
* Static Site Generation — pre-render routes to HTML files at build time.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* // ssg.ts (run with: bun run ssg.ts)
|
|
126
|
+
* import { createHandler } from "@pyreon/server"
|
|
127
|
+
* import { prerender } from "@pyreon/server"
|
|
128
|
+
* import { App } from "./src/App"
|
|
129
|
+
* import { routes } from "./src/routes"
|
|
130
|
+
*
|
|
131
|
+
* const handler = createHandler({ App, routes })
|
|
132
|
+
*
|
|
133
|
+
* await prerender({
|
|
134
|
+
* handler,
|
|
135
|
+
* paths: ["/", "/about", "/blog", "/blog/hello-world"],
|
|
136
|
+
* outDir: "dist",
|
|
137
|
+
* })
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* // Dynamic paths from a CMS or filesystem
|
|
141
|
+
* await prerender({
|
|
142
|
+
* handler,
|
|
143
|
+
* paths: async () => {
|
|
144
|
+
* const posts = await fetchAllPosts()
|
|
145
|
+
* return ["/", "/about", ...posts.map(p => `/blog/${p.slug}`)]
|
|
146
|
+
* },
|
|
147
|
+
* outDir: "dist",
|
|
148
|
+
* })
|
|
149
|
+
*/
|
|
150
|
+
interface PrerenderOptions {
|
|
151
|
+
/** SSR handler created by createHandler() */
|
|
152
|
+
handler: (req: Request) => Promise<Response>;
|
|
153
|
+
/** Routes to pre-render — array of URL paths or async function that returns them */
|
|
154
|
+
paths: string[] | (() => string[] | Promise<string[]>);
|
|
155
|
+
/** Output directory for the generated HTML files */
|
|
156
|
+
outDir: string;
|
|
157
|
+
/** Origin for constructing full URLs (default: "http://localhost") */
|
|
158
|
+
origin?: string;
|
|
159
|
+
/**
|
|
160
|
+
* Called after each page is rendered — use for logging or progress tracking.
|
|
161
|
+
* Return false to skip writing this page.
|
|
162
|
+
*/
|
|
163
|
+
onPage?: (path: string, html: string) => void | boolean | Promise<void | boolean>;
|
|
164
|
+
}
|
|
165
|
+
interface PrerenderResult {
|
|
166
|
+
/** Number of pages generated */
|
|
167
|
+
pages: number;
|
|
168
|
+
/** Paths that failed to render */
|
|
169
|
+
errors: {
|
|
170
|
+
path: string;
|
|
171
|
+
error: unknown;
|
|
172
|
+
}[];
|
|
173
|
+
/** Total elapsed time in milliseconds */
|
|
174
|
+
elapsed: number;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Pre-render a list of routes to static HTML files.
|
|
178
|
+
*
|
|
179
|
+
* For each path:
|
|
180
|
+
* 1. Constructs a Request for the path
|
|
181
|
+
* 2. Calls the SSR handler to render to HTML
|
|
182
|
+
* 3. Writes the HTML to `outDir/<path>/index.html`
|
|
183
|
+
*
|
|
184
|
+
* The root path "/" becomes `outDir/index.html`.
|
|
185
|
+
* Paths like "/about" become `outDir/about/index.html`.
|
|
186
|
+
*/
|
|
187
|
+
declare function prerender(options: PrerenderOptions): Promise<PrerenderResult>;
|
|
188
|
+
//#endregion
|
|
189
|
+
export { DEFAULT_TEMPLATE, type HandlerOptions, type HydrationStrategy, type IslandMeta, type IslandOptions, type Middleware, type MiddlewareContext, type PrerenderOptions, type PrerenderResult, type TemplateData, buildScripts, createHandler, island, prerender, processTemplate };
|
|
190
|
+
//# sourceMappingURL=index2.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index2.d.ts","names":[],"sources":["../../src/middleware.ts","../../src/handler.ts","../../src/html.ts","../../src/island.ts","../../src/ssg.ts"],"mappings":";;;;;;;;AAqBA;;;;;;;;;;;;;;;;UAAiB,iBAAA;EAUP;EARR,GAAA,EAAK,OAAA;EAQS;EANd,GAAA,EAAK,GAAA;EAae;EAXpB,IAAA;EAW6B;EAT7B,OAAA,EAAS,OAAA;EASoE;EAP7E,MAAA,EAAQ,MAAA;AAAA;;;;KAOE,UAAA,IAAc,GAAA,EAAK,iBAAA,KAAsB,QAAA,UAAkB,OAAA,CAAQ,QAAA;;;UCE9D,cAAA;EDF8D;ECI7E,GAAA,EAAK,WAAA;EDJuE;ECM5E,MAAA,EAAQ,WAAA;EDNqB;;;;;;;;ECe7B,QAAA;;EAEA,WAAA;EAf6B;EAiB7B,UAAA,GAAa,UAAA;EAfR;;;;;EAqBL,IAAA;AAAA;AAAA,iBAGc,aAAA,CAAc,OAAA,EAAS,cAAA,IAAkB,GAAA,EAAK,OAAA,KAAY,OAAA,CAAQ,QAAA;;;;;;;AD7ClF;;;;cEZa,gBAAA;AAAA,UAaI,YAAA;EACf,IAAA;EACA,GAAA;EACA,OAAA;AAAA;AAAA,iBAGc,eAAA,CAAgB,QAAA,UAAkB,IAAA,EAAM,YAAA;;;;;;;;iBAcxC,YAAA,CACd,WAAA,UACA,UAAA,EAAY,MAAA;;;KCeF,iBAAA;AAAA,UAEK,aAAA;EFNf;EEQA,IAAA;EFNa;EEQb,OAAA,GAAU,iBAAA;AAAA;AAAA,UAGK,UAAA;EAAA,SACN,QAAA;EAAA,SACA,IAAA;EAAA,SACA,OAAA,EAAS,iBAAA;AAAA;;;;;;;;;iBAaJ,MAAA,WAAiB,KAAA,GAAQ,KAAA,CAAA,CACvC,MAAA,QAAc,OAAA;EAAU,OAAA,EAAS,WAAA,CAAY,CAAA;AAAA,IAAO,WAAA,CAAY,CAAA,IAChE,OAAA,EAAS,aAAA,GACR,WAAA,CAAY,CAAA,IAAK,UAAA;;;;;;;AHlEpB;;;;;;;;;;;;;;;;;;;;AAiBA;;;;;UILiB,gBAAA;EJKsD;EIHrE,OAAA,GAAU,GAAA,EAAK,OAAA,KAAY,OAAA,CAAQ,QAAA;EJGyC;EID5E,KAAA,+BAAoC,OAAA;EJCZ;EICxB,MAAA;EJDqE;EIGrE,MAAA;EJHqF;;;;EISrF,MAAA,IAAU,IAAA,UAAc,IAAA,8BAAkC,OAAA;AAAA;AAAA,UAG3C,eAAA;;EAEf,KAAA;EHRQ;EGUR,MAAA;IAAU,IAAA;IAAc,KAAA;EAAA;EHZnB;EGcL,OAAA;AAAA;;;;;;;;AHUF;;;;iBGIsB,SAAA,CAAU,OAAA,EAAS,gBAAA,GAAmB,OAAA,CAAQ,eAAA"}
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pyreon/server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "SSR handler, SSG prerender, and island architecture for Pyreon",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/pyreon/pyreon.git",
|
|
9
|
+
"directory": "packages/server"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/server#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/pyreon/pyreon/issues"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"lib",
|
|
17
|
+
"src",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"type": "module",
|
|
23
|
+
"main": "./lib/index.js",
|
|
24
|
+
"module": "./lib/index.js",
|
|
25
|
+
"types": "./lib/types/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"bun": "./src/index.ts",
|
|
29
|
+
"import": "./lib/index.js",
|
|
30
|
+
"types": "./lib/types/index.d.ts"
|
|
31
|
+
},
|
|
32
|
+
"./client": {
|
|
33
|
+
"bun": "./src/client.ts",
|
|
34
|
+
"import": "./lib/client.js",
|
|
35
|
+
"types": "./lib/types/client.d.ts"
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"build": "vl_rolldown_build",
|
|
40
|
+
"dev": "vl_rolldown_build-watch",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"prepublishOnly": "bun run build"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@pyreon/core": "workspace:*",
|
|
47
|
+
"@pyreon/head": "workspace:*",
|
|
48
|
+
"@pyreon/reactivity": "workspace:*",
|
|
49
|
+
"@pyreon/router": "workspace:*",
|
|
50
|
+
"@pyreon/runtime-dom": "workspace:*",
|
|
51
|
+
"@pyreon/runtime-server": "workspace:*"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side entry helpers for Pyreon SSR/SSG apps.
|
|
3
|
+
*
|
|
4
|
+
* ## Full app hydration
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* // entry-client.ts
|
|
8
|
+
* import { startClient } from "@pyreon/server/client"
|
|
9
|
+
* import { App } from "./App"
|
|
10
|
+
* import { routes } from "./routes"
|
|
11
|
+
*
|
|
12
|
+
* startClient({ App, routes })
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* ## Island hydration (partial)
|
|
16
|
+
*
|
|
17
|
+
* ```ts
|
|
18
|
+
* // entry-client.ts
|
|
19
|
+
* import { hydrateIslands } from "@pyreon/server/client"
|
|
20
|
+
*
|
|
21
|
+
* hydrateIslands({
|
|
22
|
+
* Counter: () => import("./Counter"),
|
|
23
|
+
* Search: () => import("./Search"),
|
|
24
|
+
* })
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { ComponentFn } from "@pyreon/core"
|
|
29
|
+
import { h } from "@pyreon/core"
|
|
30
|
+
import { createRouter, hydrateLoaderData, type RouteRecord, RouterProvider } from "@pyreon/router"
|
|
31
|
+
import { hydrateRoot, mount } from "@pyreon/runtime-dom"
|
|
32
|
+
import type { HydrationStrategy } from "./island"
|
|
33
|
+
|
|
34
|
+
// ─── Full app hydration ──────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
export interface StartClientOptions {
|
|
37
|
+
/** Root application component */
|
|
38
|
+
App: ComponentFn
|
|
39
|
+
/** Route definitions (same as server) */
|
|
40
|
+
routes: RouteRecord[]
|
|
41
|
+
/** CSS selector or element for the app container (default: "#app") */
|
|
42
|
+
container?: string | Element
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Hydrate a server-rendered Pyreon app on the client.
|
|
47
|
+
*
|
|
48
|
+
* Handles:
|
|
49
|
+
* - Router creation (history mode)
|
|
50
|
+
* - Loader data hydration from `window.__PYREON_LOADER_DATA__`
|
|
51
|
+
* - Hydration if container has SSR content, fresh mount otherwise
|
|
52
|
+
*
|
|
53
|
+
* Returns a cleanup function that unmounts the app.
|
|
54
|
+
*/
|
|
55
|
+
export function startClient(options: StartClientOptions): () => void {
|
|
56
|
+
const { App, routes, container = "#app" } = options
|
|
57
|
+
|
|
58
|
+
const el = typeof container === "string" ? document.querySelector(container) : container
|
|
59
|
+
|
|
60
|
+
if (!el) {
|
|
61
|
+
throw new Error(`[pyreon/client] Container "${container}" not found`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Create client-side router (history mode to match SSR)
|
|
65
|
+
const router = createRouter({ routes, mode: "history" })
|
|
66
|
+
|
|
67
|
+
// Hydrate loader data from SSR (avoids re-fetching on initial render)
|
|
68
|
+
const loaderData = (window as unknown as Record<string, unknown>).__PYREON_LOADER_DATA__
|
|
69
|
+
if (loaderData && typeof loaderData === "object") {
|
|
70
|
+
hydrateLoaderData(router as never, loaderData as Record<string, unknown>)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Build app tree
|
|
74
|
+
const app = h(RouterProvider, { router }, h(App, null))
|
|
75
|
+
|
|
76
|
+
// Hydrate if container has SSR content, mount fresh otherwise
|
|
77
|
+
if (el.childNodes.length > 0) {
|
|
78
|
+
return hydrateRoot(el, app)
|
|
79
|
+
}
|
|
80
|
+
return mount(app, el as HTMLElement)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Island hydration ────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
type IslandLoader = () => Promise<{ default: ComponentFn } | ComponentFn>
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Hydrate all `<pyreon-island>` elements on the page.
|
|
89
|
+
*
|
|
90
|
+
* Only loads JavaScript for components that are actually present in the HTML.
|
|
91
|
+
* Respects hydration strategies (load, idle, visible, media, never).
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* hydrateIslands({
|
|
95
|
+
* Counter: () => import("./Counter"),
|
|
96
|
+
* Search: () => import("./Search"),
|
|
97
|
+
* })
|
|
98
|
+
*/
|
|
99
|
+
/**
|
|
100
|
+
* Hydrate all `<pyreon-island>` elements on the page.
|
|
101
|
+
* Returns a cleanup function that disconnects any pending observers/listeners.
|
|
102
|
+
*/
|
|
103
|
+
export function hydrateIslands(registry: Record<string, IslandLoader>): () => void {
|
|
104
|
+
const islands = document.querySelectorAll("pyreon-island")
|
|
105
|
+
const cleanups: (() => void)[] = []
|
|
106
|
+
|
|
107
|
+
for (const el of islands) {
|
|
108
|
+
const componentId = el.getAttribute("data-component")
|
|
109
|
+
if (!componentId) continue
|
|
110
|
+
|
|
111
|
+
const loader = registry[componentId]
|
|
112
|
+
if (!loader) {
|
|
113
|
+
console.warn(`No loader registered for island "${componentId}"`)
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const strategy = (el.getAttribute("data-hydrate") ?? "load") as HydrationStrategy
|
|
118
|
+
const propsJson = el.getAttribute("data-props") ?? "{}"
|
|
119
|
+
|
|
120
|
+
const cleanup = scheduleHydration(el as HTMLElement, loader, propsJson, strategy)
|
|
121
|
+
if (cleanup) cleanups.push(cleanup)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return () => {
|
|
125
|
+
for (const fn of cleanups) fn()
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function scheduleHydration(
|
|
130
|
+
el: HTMLElement,
|
|
131
|
+
loader: IslandLoader,
|
|
132
|
+
propsJson: string,
|
|
133
|
+
strategy: HydrationStrategy,
|
|
134
|
+
): (() => void) | null {
|
|
135
|
+
let cancelled = false
|
|
136
|
+
const hydrate = () => {
|
|
137
|
+
if (!cancelled) hydrateIsland(el, loader, propsJson)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
switch (strategy) {
|
|
141
|
+
case "load":
|
|
142
|
+
hydrate()
|
|
143
|
+
return null
|
|
144
|
+
|
|
145
|
+
case "idle": {
|
|
146
|
+
if ("requestIdleCallback" in window) {
|
|
147
|
+
const id = requestIdleCallback(hydrate)
|
|
148
|
+
return () => {
|
|
149
|
+
cancelled = true
|
|
150
|
+
cancelIdleCallback(id)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const id = setTimeout(hydrate, 200)
|
|
154
|
+
return () => {
|
|
155
|
+
cancelled = true
|
|
156
|
+
clearTimeout(id)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case "visible":
|
|
161
|
+
return observeVisibility(el, hydrate)
|
|
162
|
+
|
|
163
|
+
case "never":
|
|
164
|
+
return null
|
|
165
|
+
|
|
166
|
+
default:
|
|
167
|
+
// media(query)
|
|
168
|
+
if (strategy.startsWith("media(")) {
|
|
169
|
+
const query = strategy.slice(6, -1)
|
|
170
|
+
const mql = window.matchMedia(query)
|
|
171
|
+
if (mql.matches) {
|
|
172
|
+
hydrate()
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
const onChange = (e: MediaQueryListEvent) => {
|
|
176
|
+
if (e.matches) {
|
|
177
|
+
mql.removeEventListener("change", onChange)
|
|
178
|
+
hydrate()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
mql.addEventListener("change", onChange)
|
|
182
|
+
return () => {
|
|
183
|
+
cancelled = true
|
|
184
|
+
mql.removeEventListener("change", onChange)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
hydrate()
|
|
188
|
+
return null
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function hydrateIsland(
|
|
193
|
+
el: HTMLElement,
|
|
194
|
+
loader: IslandLoader,
|
|
195
|
+
propsJson: string,
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
const name = el.getAttribute("data-component") ?? "unknown"
|
|
198
|
+
try {
|
|
199
|
+
let props: Record<string, unknown>
|
|
200
|
+
try {
|
|
201
|
+
props = JSON.parse(propsJson)
|
|
202
|
+
if (typeof props !== "object" || props === null || Array.isArray(props)) {
|
|
203
|
+
throw new TypeError("Expected object")
|
|
204
|
+
}
|
|
205
|
+
} catch (parseErr) {
|
|
206
|
+
console.error(`Invalid island props JSON for "${name}"`, parseErr)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const mod = await loader()
|
|
211
|
+
const Comp = typeof mod === "function" ? mod : mod.default
|
|
212
|
+
hydrateRoot(el, h(Comp, props))
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error(`Failed to hydrate island "${name}"`, err)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function observeVisibility(el: HTMLElement, callback: () => void): (() => void) | null {
|
|
219
|
+
if (!("IntersectionObserver" in window)) {
|
|
220
|
+
callback()
|
|
221
|
+
return null
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const observer = new IntersectionObserver(
|
|
225
|
+
(entries) => {
|
|
226
|
+
for (const entry of entries) {
|
|
227
|
+
if (entry.isIntersecting) {
|
|
228
|
+
observer.disconnect()
|
|
229
|
+
callback()
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
{ rootMargin: "200px" },
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
observer.observe(el)
|
|
238
|
+
return () => observer.disconnect()
|
|
239
|
+
}
|
package/src/handler.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSR request handler.
|
|
3
|
+
*
|
|
4
|
+
* Creates a Web-standard `(Request) => Promise<Response>` handler that:
|
|
5
|
+
* 1. Runs middleware (auth, redirects, headers, etc.)
|
|
6
|
+
* 2. Creates a per-request router with the matched URL
|
|
7
|
+
* 3. Prefetches loader data for matched routes
|
|
8
|
+
* 4. Renders the app to HTML with head tag collection
|
|
9
|
+
* 5. Injects everything into an HTML template
|
|
10
|
+
* 6. Returns a Response
|
|
11
|
+
*
|
|
12
|
+
* Compatible with Bun.serve, Deno.serve, Cloudflare Workers,
|
|
13
|
+
* Express (via adapter), and any Web-standard server.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import { createHandler } from "@pyreon/server"
|
|
17
|
+
*
|
|
18
|
+
* const handler = createHandler({
|
|
19
|
+
* App,
|
|
20
|
+
* routes,
|
|
21
|
+
* template: await Bun.file("index.html").text(),
|
|
22
|
+
* })
|
|
23
|
+
*
|
|
24
|
+
* Bun.serve({ fetch: handler })
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ComponentFn } from "@pyreon/core"
|
|
28
|
+
import { h } from "@pyreon/core"
|
|
29
|
+
import { renderWithHead } from "@pyreon/head"
|
|
30
|
+
import {
|
|
31
|
+
createRouter,
|
|
32
|
+
prefetchLoaderData,
|
|
33
|
+
type RouteRecord,
|
|
34
|
+
RouterProvider,
|
|
35
|
+
serializeLoaderData,
|
|
36
|
+
} from "@pyreon/router"
|
|
37
|
+
import { renderToStream, runWithRequestContext } from "@pyreon/runtime-server"
|
|
38
|
+
import { buildScripts, DEFAULT_TEMPLATE, processTemplate } from "./html"
|
|
39
|
+
import type { Middleware, MiddlewareContext } from "./middleware"
|
|
40
|
+
|
|
41
|
+
export interface HandlerOptions {
|
|
42
|
+
/** Root application component */
|
|
43
|
+
App: ComponentFn
|
|
44
|
+
/** Route definitions */
|
|
45
|
+
routes: RouteRecord[]
|
|
46
|
+
/**
|
|
47
|
+
* HTML template with placeholders:
|
|
48
|
+
* <!--pyreon-head--> — head tags (title, meta, link, etc.)
|
|
49
|
+
* <!--pyreon-app--> — rendered app HTML
|
|
50
|
+
* <!--pyreon-scripts--> — client entry + loader data
|
|
51
|
+
*
|
|
52
|
+
* Defaults to a minimal HTML5 template.
|
|
53
|
+
*/
|
|
54
|
+
template?: string
|
|
55
|
+
/** Path to the client entry module (default: "/src/entry-client.ts") */
|
|
56
|
+
clientEntry?: string
|
|
57
|
+
/** Middleware chain — runs before rendering */
|
|
58
|
+
middleware?: Middleware[]
|
|
59
|
+
/**
|
|
60
|
+
* Rendering mode:
|
|
61
|
+
* "string" (default) — full renderToString, complete HTML in one response
|
|
62
|
+
* "stream" — progressive streaming via renderToStream (Suspense out-of-order)
|
|
63
|
+
*/
|
|
64
|
+
mode?: "string" | "stream"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createHandler(options: HandlerOptions): (req: Request) => Promise<Response> {
|
|
68
|
+
const {
|
|
69
|
+
App,
|
|
70
|
+
routes,
|
|
71
|
+
template = DEFAULT_TEMPLATE,
|
|
72
|
+
clientEntry = "/src/entry-client.ts",
|
|
73
|
+
middleware = [],
|
|
74
|
+
mode = "string",
|
|
75
|
+
} = options
|
|
76
|
+
|
|
77
|
+
return async function handler(req: Request): Promise<Response> {
|
|
78
|
+
const url = new URL(req.url)
|
|
79
|
+
const path = url.pathname + url.search
|
|
80
|
+
|
|
81
|
+
// ── Middleware pipeline ────────────────────────────────────────────────────
|
|
82
|
+
const ctx: MiddlewareContext = {
|
|
83
|
+
req,
|
|
84
|
+
url,
|
|
85
|
+
path,
|
|
86
|
+
headers: new Headers({ "Content-Type": "text/html; charset=utf-8" }),
|
|
87
|
+
locals: {},
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const mw of middleware) {
|
|
91
|
+
const result = await mw(ctx)
|
|
92
|
+
if (result instanceof Response) return result
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── Per-request router ────────────────────────────────────────────────────
|
|
96
|
+
const router = createRouter({ routes, mode: "history", url: path })
|
|
97
|
+
|
|
98
|
+
return runWithRequestContext(async () => {
|
|
99
|
+
try {
|
|
100
|
+
// Pre-run loaders so data is available during render
|
|
101
|
+
await prefetchLoaderData(router as never, path)
|
|
102
|
+
|
|
103
|
+
// Build the VNode tree
|
|
104
|
+
const app = h(RouterProvider, { router }, h(App, null))
|
|
105
|
+
|
|
106
|
+
if (mode === "stream") {
|
|
107
|
+
return renderStreamResponse(app, router, template, clientEntry, ctx.headers)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── String mode (default) ─────────────────────────────────────────────
|
|
111
|
+
const { html: appHtml, head } = await renderWithHead(app)
|
|
112
|
+
const loaderData = serializeLoaderData(router as never)
|
|
113
|
+
const scripts = buildScripts(clientEntry, loaderData)
|
|
114
|
+
const fullHtml = processTemplate(template, { head, app: appHtml, scripts })
|
|
115
|
+
|
|
116
|
+
return new Response(fullHtml, { status: 200, headers: ctx.headers })
|
|
117
|
+
} catch (_err) {
|
|
118
|
+
return new Response("Internal Server Error", {
|
|
119
|
+
status: 500,
|
|
120
|
+
headers: { "Content-Type": "text/plain" },
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Streaming mode: shell is emitted immediately, app content streams progressively.
|
|
129
|
+
*
|
|
130
|
+
* Head tags from the initial synchronous render are included in the shell.
|
|
131
|
+
* Suspense boundaries resolve out-of-order via inline <template> + swap scripts.
|
|
132
|
+
*/
|
|
133
|
+
async function renderStreamResponse(
|
|
134
|
+
app: ReturnType<typeof h>,
|
|
135
|
+
router: ReturnType<typeof createRouter>,
|
|
136
|
+
template: string,
|
|
137
|
+
clientEntry: string,
|
|
138
|
+
extraHeaders: Headers,
|
|
139
|
+
): Promise<Response> {
|
|
140
|
+
const loaderData = serializeLoaderData(router as never)
|
|
141
|
+
const scripts = buildScripts(clientEntry, loaderData)
|
|
142
|
+
|
|
143
|
+
// Split template around <!--pyreon-app-->
|
|
144
|
+
const [beforeApp, afterApp] = template.split("<!--pyreon-app-->")
|
|
145
|
+
if (!beforeApp || afterApp === undefined) {
|
|
146
|
+
throw new Error("[pyreon/server] Template must contain <!--pyreon-app--> placeholder")
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Replace other placeholders in shell parts
|
|
150
|
+
const shellHead = beforeApp.replace("<!--pyreon-head-->", "")
|
|
151
|
+
const shellTail = afterApp.replace("<!--pyreon-scripts-->", scripts)
|
|
152
|
+
|
|
153
|
+
const appStream = renderToStream(app)
|
|
154
|
+
const reader = appStream.getReader()
|
|
155
|
+
|
|
156
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
157
|
+
async start(controller) {
|
|
158
|
+
const encoder = new TextEncoder()
|
|
159
|
+
const push = (s: string) => controller.enqueue(encoder.encode(s))
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
push(shellHead)
|
|
163
|
+
|
|
164
|
+
// Stream app content
|
|
165
|
+
let done = false
|
|
166
|
+
while (!done) {
|
|
167
|
+
const result = await reader.read()
|
|
168
|
+
done = result.done
|
|
169
|
+
if (result.value) push(result.value)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
push(shellTail)
|
|
173
|
+
} catch (_err) {
|
|
174
|
+
// Emit an inline error indicator — status code is already sent (200)
|
|
175
|
+
push(`<script>console.error("[pyreon/server] Stream render failed")</script>`)
|
|
176
|
+
push(shellTail)
|
|
177
|
+
} finally {
|
|
178
|
+
controller.close()
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return new Response(stream, {
|
|
184
|
+
status: 200,
|
|
185
|
+
headers: extraHeaders,
|
|
186
|
+
})
|
|
187
|
+
}
|