@k2b/ssr 0.12.0-rc.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.
@@ -0,0 +1,421 @@
1
+ // Dev mode live-reload client
2
+ if (!window.__ssr_reload) {
3
+ window.__ssr_reload = true;
4
+
5
+ (function () {
6
+ const ssrPath = globalThis.__SSR_CONFIG?.ssrPath || "/_ssr";
7
+
8
+ // Settings
9
+ const STORAGE_KEY = `_ssr:${ssrPath}`;
10
+ const defaults = {
11
+ autoReload: true,
12
+ highlightIslands: false,
13
+ highlightClients: false,
14
+ position: "bl",
15
+ };
16
+
17
+ const readStorage = (key) => {
18
+ try {
19
+ return localStorage.getItem(key);
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+ const writeStorage = (key, value) => {
25
+ try {
26
+ localStorage.setItem(key, value);
27
+ } catch {
28
+ // Dev tools still work when storage is unavailable.
29
+ }
30
+ };
31
+ const load = () => {
32
+ try {
33
+ return {
34
+ ...defaults,
35
+ ...JSON.parse(readStorage(STORAGE_KEY) || "{}"),
36
+ };
37
+ } catch {
38
+ return { ...defaults };
39
+ }
40
+ };
41
+ const save = (s) => writeStorage(STORAGE_KEY, JSON.stringify(s));
42
+ let settings = load();
43
+
44
+ // Highlight Styles
45
+ const style = document.head.appendChild(document.createElement("style"));
46
+
47
+ const highlightCSS = (tag, color) => `
48
+ ${tag} {
49
+ display: block !important;
50
+ box-shadow: 0 0 0 1px ${color} !important;
51
+ position: relative !important;
52
+ }
53
+ ${tag}::before {
54
+ content: attr(data-file);
55
+ position: absolute;
56
+ top: -17px;
57
+ left: -1px;
58
+ font-size: 10px;
59
+ font-family: monospace;
60
+ color: black;
61
+ background: ${color};
62
+ padding: 1px 4px;
63
+ white-space: nowrap;
64
+ }
65
+ `;
66
+
67
+ const updateStyles = () => {
68
+ style.textContent = [
69
+ settings.highlightIslands && highlightCSS("solid-island", "#22c55e"),
70
+ settings.highlightClients && highlightCSS("solid-client", "#3b82f6"),
71
+ ]
72
+ .filter(Boolean)
73
+ .join("");
74
+ };
75
+ updateStyles();
76
+
77
+ // Position
78
+ const positions = {
79
+ tl: { top: "8px", left: "8px" },
80
+ tr: { top: "8px", right: "8px" },
81
+ bl: { bottom: "8px", left: "8px" },
82
+ br: { bottom: "8px", right: "8px" },
83
+ };
84
+
85
+ const applyPosition = () => {
86
+ const reset = { top: "", bottom: "", left: "", right: "" };
87
+ const pos = positions[settings.position] ?? positions.bl;
88
+ Object.assign(badge.style, reset, pos);
89
+ Object.assign(
90
+ panel.style,
91
+ reset,
92
+ pos,
93
+ pos.top ? { top: "32px" } : { bottom: "32px" },
94
+ );
95
+ };
96
+
97
+ // UI
98
+ const islandCount = document.querySelectorAll("solid-island").length;
99
+ const clientCount = document.querySelectorAll("solid-client").length;
100
+
101
+ const el = (tag, props = {}, parent = document.body) =>
102
+ Object.assign(parent.appendChild(document.createElement(tag)), props);
103
+
104
+ const checkbox = (id, label, checked) => `
105
+ <label style="display:block;margin:4px 0;cursor:pointer">
106
+ <input type="checkbox" id="${id}" ${checked ? "checked" : ""}> ${label}
107
+ </label>
108
+ `;
109
+
110
+ const panel = el("div", {
111
+ innerHTML: `
112
+ <div style="margin-bottom:8px;font-weight:bold">SSR Dev Tools</div>
113
+ ${checkbox("_ssr_reload", "Auto reload", settings.autoReload)}
114
+ ${checkbox("_ssr_islands", `Highlight islands (${islandCount})`, settings.highlightIslands)}
115
+ ${checkbox("_ssr_clients", `Highlight clients (${clientCount})`, settings.highlightClients)}
116
+ <div style="margin-top:8px;border-top:1px solid #333;padding-top:8px">
117
+ <label style="color:#888">Position:
118
+ <select id="_ssr_pos" style="background:#222;color:#ccc;border:1px solid #444;padding:2px;margin-left:4px">
119
+ ${Object.keys(positions)
120
+ .map(
121
+ (p) =>
122
+ `<option value="${p}" ${settings.position === p ? "selected" : ""}>${p.toUpperCase()}</option>`,
123
+ )
124
+ .join("")}
125
+ </select>
126
+ </label>
127
+ </div>
128
+ `,
129
+ });
130
+ Object.assign(panel.style, {
131
+ fontFamily: "monospace",
132
+ fontSize: "12px",
133
+ color: "#ccc",
134
+ background: "#111",
135
+ padding: "12px",
136
+ border: "1px solid #333",
137
+ borderRadius: "4px",
138
+ position: "fixed",
139
+ zIndex: "9999",
140
+ display: "none",
141
+ });
142
+
143
+ const badge = el("div", {
144
+ innerText: "[ssr]",
145
+ onclick: () =>
146
+ (panel.style.display =
147
+ panel.style.display === "none" ? "block" : "none"),
148
+ });
149
+ Object.assign(badge.style, {
150
+ fontFamily: "monospace",
151
+ fontSize: "12px",
152
+ color: "#555",
153
+ position: "fixed",
154
+ zIndex: "9999",
155
+ cursor: "pointer",
156
+ });
157
+
158
+ applyPosition();
159
+
160
+ // Event Handlers
161
+ const bind = (id, key, onChange) => {
162
+ panel.querySelector(`#${id}`).onchange = (e) => {
163
+ settings[key] =
164
+ e.target.type === "checkbox" ? e.target.checked : e.target.value;
165
+ save(settings);
166
+ onChange?.();
167
+ };
168
+ };
169
+
170
+ bind("_ssr_islands", "highlightIslands", updateStyles);
171
+ bind("_ssr_clients", "highlightClients", updateStyles);
172
+ bind("_ssr_pos", "position", applyPosition);
173
+
174
+ // Live Reload (SSE)
175
+ const reloadId = globalThis.__SSR_CONFIG?.reloadId;
176
+ const reloadIdKey = `${STORAGE_KEY}:reload-id`;
177
+ const reloadLockName = `${STORAGE_KEY}:reload-lock`;
178
+ let es, retryTimer, retryController, animationInterval;
179
+ let lockController, releaseLock;
180
+ let retryAttempt = 0;
181
+ let retryGeneration = 0;
182
+ let ownsLock = false;
183
+ let pageActive = true;
184
+ let reloadRequested = false;
185
+ const spinFrames = ["[ / ]", "[ – ]", "[ \\ ]", "[ | ]"];
186
+ let spinIndex = 0;
187
+
188
+ // A fresh document already represents this server generation. Publishing
189
+ // it first wakes stale tabs without making the fresh document reload.
190
+ if (reloadId) writeStorage(reloadIdKey, reloadId);
191
+
192
+ const stopAnimation = () => {
193
+ clearInterval(animationInterval);
194
+ animationInterval = null;
195
+ };
196
+
197
+ const startAnimation = () => {
198
+ if (animationInterval) return;
199
+ spinIndex = 0;
200
+ animationInterval = setInterval(() => {
201
+ badge.innerText = spinFrames[spinIndex++ % spinFrames.length];
202
+ }, 150);
203
+ };
204
+
205
+ const shouldParticipate = () =>
206
+ pageActive &&
207
+ !reloadRequested &&
208
+ settings.autoReload &&
209
+ document.visibilityState === "visible";
210
+ const canConnect = () => shouldParticipate() && ownsLock;
211
+
212
+ const closeSource = () => {
213
+ const source = es;
214
+ es = null;
215
+ source?.close();
216
+ };
217
+
218
+ const cancelRetry = () => {
219
+ retryGeneration += 1;
220
+ clearTimeout(retryTimer);
221
+ retryTimer = null;
222
+ const controller = retryController;
223
+ retryController = null;
224
+ controller?.abort();
225
+ };
226
+
227
+ const stopConnection = () => {
228
+ closeSource();
229
+ cancelRetry();
230
+ retryAttempt = 0;
231
+ stopAnimation();
232
+ badge.innerText = "[ssr]";
233
+ };
234
+
235
+ const releaseLeadership = () => {
236
+ const pendingLock = lockController;
237
+ lockController = null;
238
+ pendingLock?.abort();
239
+
240
+ const release = releaseLock;
241
+ releaseLock = null;
242
+ ownsLock = false;
243
+ release?.();
244
+ stopConnection();
245
+ };
246
+
247
+ const requestReload = () => {
248
+ if (reloadRequested) return;
249
+ reloadRequested = true;
250
+ releaseLeadership();
251
+ location.reload();
252
+ };
253
+
254
+ const acceptReloadId = (nextReloadId) => {
255
+ if (!nextReloadId) return;
256
+ writeStorage(reloadIdKey, nextReloadId);
257
+ if (reloadId && nextReloadId !== reloadId) requestReload();
258
+ };
259
+
260
+ const retryDelay = () =>
261
+ Math.min(2_000, 300 * 2 ** Math.min(retryAttempt, 3));
262
+
263
+ const scheduleRetry = () => {
264
+ if (!canConnect() || retryTimer || retryController) return;
265
+
266
+ const generation = retryGeneration;
267
+ retryTimer = setTimeout(async () => {
268
+ retryTimer = null;
269
+ if (generation !== retryGeneration || !canConnect()) return;
270
+
271
+ const controller = new AbortController();
272
+ retryController = controller;
273
+ try {
274
+ const response = await fetch(`${ssrPath}/_ping`, {
275
+ cache: "no-store",
276
+ signal: controller.signal,
277
+ });
278
+ if (response.ok && canConnect()) {
279
+ acceptReloadId(response.headers.get("X-SSR-Reload-ID"));
280
+ if (!reloadRequested) requestReload();
281
+ return;
282
+ }
283
+ } catch {
284
+ // A stopped or unavailable dev server is expected during reload.
285
+ } finally {
286
+ if (retryController === controller) retryController = null;
287
+ }
288
+
289
+ if (generation !== retryGeneration || !canConnect()) return;
290
+ retryAttempt += 1;
291
+ scheduleRetry();
292
+ }, retryDelay());
293
+ };
294
+
295
+ const startRetry = () => {
296
+ if (!canConnect()) return;
297
+ startAnimation();
298
+ scheduleRetry();
299
+ };
300
+
301
+ const start = () => {
302
+ if (!canConnect() || es || retryTimer || retryController) return;
303
+
304
+ let source;
305
+ try {
306
+ source = new EventSource(`${ssrPath}/_reload`);
307
+ es = source;
308
+ stopAnimation();
309
+ badge.innerText = "[ssr]";
310
+ } catch {
311
+ startRetry();
312
+ return;
313
+ }
314
+
315
+ source.onopen = () => {
316
+ if (es !== source) return;
317
+ retryAttempt = 0;
318
+ stopAnimation();
319
+ badge.innerText = "[ssr]";
320
+ };
321
+
322
+ source.onmessage = (event) => {
323
+ if (es !== source) return;
324
+ acceptReloadId(event.data);
325
+ };
326
+
327
+ source.onerror = (event) => {
328
+ if (es !== source) return;
329
+ event.preventDefault();
330
+ closeSource();
331
+ retryAttempt = 0;
332
+ startRetry();
333
+ };
334
+ };
335
+
336
+ const requestLeadership = () => {
337
+ if (!shouldParticipate()) return;
338
+ if (ownsLock) {
339
+ start();
340
+ return;
341
+ }
342
+ if (lockController) return;
343
+
344
+ if (!navigator.locks?.request) {
345
+ ownsLock = true;
346
+ start();
347
+ return;
348
+ }
349
+
350
+ const controller = new AbortController();
351
+ lockController = controller;
352
+ void navigator.locks
353
+ .request(
354
+ reloadLockName,
355
+ { mode: "exclusive", signal: controller.signal },
356
+ async () => {
357
+ if (lockController === controller) lockController = null;
358
+ if (controller.signal.aborted || !shouldParticipate()) return;
359
+
360
+ ownsLock = true;
361
+ start();
362
+ await new Promise((resolve) => {
363
+ releaseLock = resolve;
364
+ });
365
+ releaseLock = null;
366
+ ownsLock = false;
367
+ stopConnection();
368
+ },
369
+ )
370
+ .catch((error) => {
371
+ if (lockController === controller) lockController = null;
372
+ if (error?.name === "AbortError" || !shouldParticipate()) return;
373
+
374
+ // If Web Locks is unavailable at runtime, retain per-tab behavior.
375
+ ownsLock = true;
376
+ start();
377
+ });
378
+ };
379
+
380
+ const syncConnection = () => {
381
+ const knownReloadId = readStorage(reloadIdKey);
382
+ if (
383
+ shouldParticipate() &&
384
+ reloadId &&
385
+ knownReloadId &&
386
+ knownReloadId !== reloadId
387
+ ) {
388
+ requestReload();
389
+ return;
390
+ }
391
+
392
+ if (shouldParticipate()) requestLeadership();
393
+ else releaseLeadership();
394
+ };
395
+
396
+ syncConnection();
397
+
398
+ panel.querySelector("#_ssr_reload").onchange = (e) => {
399
+ settings.autoReload = e.target.checked;
400
+ save(settings);
401
+ syncConnection();
402
+ };
403
+
404
+ document.addEventListener("visibilitychange", syncConnection);
405
+ window.addEventListener("storage", (event) => {
406
+ if (event.key !== reloadIdKey || !event.newValue) return;
407
+ if (reloadId && event.newValue !== reloadId && shouldParticipate()) {
408
+ requestReload();
409
+ }
410
+ });
411
+ window.addEventListener("pagehide", () => {
412
+ pageActive = false;
413
+ releaseLeadership();
414
+ });
415
+ window.addEventListener("pageshow", () => {
416
+ pageActive = true;
417
+ reloadRequested = false;
418
+ syncConnection();
419
+ });
420
+ })();
421
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Elysia adapter - provides Elysia plugin with SSR routes.
3
+ * Uses the shared BunFile-backed asset response path.
4
+ */
5
+ import { Elysia } from "elysia";
6
+ import type { SsrConfig } from "../index";
7
+ import {
8
+ createAssetResponse,
9
+ createPingResponse,
10
+ getSsrDir,
11
+ createReloadResponse,
12
+ notFound,
13
+ } from "./utils";
14
+
15
+ /**
16
+ * Creates Elysia plugin with SSR routes.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * import { routes } from "@k2b/ssr/elysia";
21
+ * new Elysia()
22
+ * .use(routes(config))
23
+ * .get("/", () => html(() => <Home />))
24
+ * .listen(3000);
25
+ * ```
26
+ */
27
+ export const routes = (config: SsrConfig) => {
28
+ const { dev, ssrPath } = config;
29
+ const ssrDir = getSsrDir(config);
30
+
31
+ return new Elysia({ name: "ssr" })
32
+ .get(`${ssrPath}/_reload`, ({ request }) =>
33
+ dev ? createReloadResponse(request.signal) : notFound(),
34
+ )
35
+ .get(`${ssrPath}/_ping`, () => (dev ? createPingResponse() : notFound()))
36
+ .get(`${ssrPath}/*`, ({ request, params }) =>
37
+ createAssetResponse(request, ssrDir, params["*"], dev),
38
+ );
39
+ };
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Hono adapter - provides Hono app to mount at /_ssr and SSR handler factory.
3
+ * Serves island chunks and dev tools endpoints.
4
+ */
5
+ import { Hono } from "hono";
6
+ import { createFactory } from "hono/factory";
7
+ import type { Context, Env, Handler, MiddlewareHandler, TypedResponse } from "hono";
8
+ import type { SsrConfig, HtmlFn, RenderFn } from "../index";
9
+ import { createAssetResponse, createPingResponse, getSsrDir, createReloadResponse } from "./utils";
10
+
11
+ // ============================================================================
12
+ // Types
13
+ // ============================================================================
14
+
15
+ /** Environment extension for page options in context */
16
+ type PageEnv<T extends object> = {
17
+ Variables: {
18
+ page: Partial<T>;
19
+ };
20
+ };
21
+
22
+ /** SSR handler return type - render function or Response (for redirects etc.) */
23
+ type SsrHandlerResult = RenderFn | Response | TypedResponse;
24
+
25
+ /** SSR handler function signature */
26
+ type SsrHandler<E extends Env, T extends object> = (
27
+ c: Context<E & PageEnv<T>>,
28
+ ) => SsrHandlerResult | Promise<SsrHandlerResult>;
29
+
30
+ /** Return type of ssr() - tuple of middlewares + handler for spread operator */
31
+ type SsrHandlers = [MiddlewareHandler, ...MiddlewareHandler[], Handler];
32
+
33
+ // ============================================================================
34
+ // createSSRHandler() - Factory for type-safe SSR page handlers
35
+ // ============================================================================
36
+
37
+ /**
38
+ * Creates an `ssr()` helper for type-safe JSX pages with Hono.
39
+ *
40
+ * Features:
41
+ * - Full compatibility with Hono middlewares/validators
42
+ * - Type-safe page options via `c.get("page")`
43
+ * - Return a render function or Response for redirects
44
+ * - No manual `html()` call needed
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * // config.ts
49
+ * import { createConfig } from "@k2b/ssr";
50
+ * import { createSSRHandler, routes } from "@k2b/ssr/hono";
51
+ *
52
+ * type PageOptions = { title?: string; description?: string };
53
+ *
54
+ * export const { config, plugin, html } = createConfig<PageOptions>({
55
+ * dev: process.env.NODE_ENV === "development",
56
+ * template: ({ body, scripts, title, description }) => `
57
+ * <!DOCTYPE html>
58
+ * <html>
59
+ * <head>
60
+ * <title>${title ?? "App"}</title>
61
+ * <meta name="description" content="${description ?? ""}">
62
+ * </head>
63
+ * <body>${body}${scripts}</body>
64
+ * </html>
65
+ * `,
66
+ * });
67
+ *
68
+ * export const ssr = createSSRHandler(html);
69
+ * export { routes };
70
+ * ```
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * // pages/room.tsx
75
+ * import { ssr } from "../config";
76
+ * import { zValidator } from "@hono/zod-validator";
77
+ * import { z } from "zod";
78
+ *
79
+ * const paramsSchema = z.object({ roomId: z.string() });
80
+ *
81
+ * export const roomPage = ssr(
82
+ * zValidator("param", paramsSchema),
83
+ * async (c) => {
84
+ * const { roomId } = c.req.valid("param");
85
+ * const room = await db.getRoom(roomId);
86
+ *
87
+ * c.get("page").title = room.name;
88
+ * c.get("page").description = `Welcome to ${room.name}`;
89
+ *
90
+ * return () => <RoomView room={room} />;
91
+ * }
92
+ * );
93
+ * ```
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * // app.ts
98
+ * import { Hono } from "hono";
99
+ * import { config, routes } from "./config";
100
+ * import { roomPage } from "./pages/room";
101
+ *
102
+ * const app = new Hono()
103
+ * .route("/_ssr", routes(config))
104
+ * .get("/room/:roomId", ...roomPage);
105
+ * ```
106
+ */
107
+ export const createSSRHandler = <T extends object>(html: HtmlFn<T>) => {
108
+ const factory = createFactory<PageEnv<T>>();
109
+
110
+ /**
111
+ * Creates a type-safe SSR page handler with optional middlewares.
112
+ *
113
+ * @param args - Middlewares followed by the final handler
114
+ * @returns Tuple of handlers to spread into Hono route: `...ssr(handler)`
115
+ */
116
+ return <E extends Env = Env>(...args: [...MiddlewareHandler<E>[], SsrHandler<E, T>]): SsrHandlers => {
117
+ // Extract middlewares and final handler
118
+ const middlewares = args.slice(0, -1) as MiddlewareHandler[];
119
+ const finalHandler = args[args.length - 1] as SsrHandler<E, T>;
120
+
121
+ // Middleware that initializes c.get("page") with empty object
122
+ const pageMiddleware = factory.createMiddleware(async (c, next) => {
123
+ c.set("page", {} as Partial<T>);
124
+ await next();
125
+ });
126
+
127
+ // Wrapped handler: render function → html(), Response → passthrough
128
+ const wrappedHandler: Handler = async (c) => {
129
+ const result = await finalHandler(c as Context<E & PageEnv<T>>);
130
+
131
+ // If handler returns Response (e.g., redirect), pass through
132
+ if (result instanceof Response) {
133
+ return result;
134
+ }
135
+
136
+ if (typeof result !== "function") {
137
+ throw new Error("[ssr] ssr() handlers must return a render function: return () => <Page />");
138
+ }
139
+
140
+ if (result.constructor.name === "AsyncFunction") {
141
+ throw new Error("[ssr] ssr() render functions must be synchronous: return () => <Page />");
142
+ }
143
+
144
+ return html(result as RenderFn, c.get("page") as T);
145
+ };
146
+
147
+ // Return tuple for spread: .get('/path', ...ssr(handler))
148
+ return [pageMiddleware, ...middlewares, wrappedHandler] as SsrHandlers;
149
+ };
150
+ };
151
+
152
+ // ============================================================================
153
+ // routes() - SSR routes for Hono
154
+ // ============================================================================
155
+
156
+ /**
157
+ * Creates Hono app with SSR routes.
158
+ *
159
+ * @example
160
+ * ```ts
161
+ * import { routes } from "@k2b/ssr/hono";
162
+ * const app = new Hono()
163
+ * .route("/_ssr", routes(config))
164
+ * .get("/", () => html(() => <Home />));
165
+ * ```
166
+ */
167
+ export const routes = (config: SsrConfig) => {
168
+ const { dev } = config;
169
+ const ssrDir = getSsrDir(config);
170
+
171
+ const app = new Hono();
172
+
173
+ // Dev mode endpoints
174
+ if (dev) {
175
+ app.get("/_reload", (c) => createReloadResponse(c.req.raw.signal));
176
+ app.get("/_ping", () => createPingResponse());
177
+ }
178
+
179
+ const serveAsset = (c: Context) => {
180
+ const filename = c.req.param("filename");
181
+ if (!filename) return c.notFound();
182
+
183
+ return createAssetResponse(c.req.raw, ssrDir, filename, dev);
184
+ };
185
+
186
+ app.get("/:filename{.+\\.js$}", serveAsset);
187
+ app.get("/:filename{.+\\.js\\.map$}", serveAsset);
188
+
189
+ return app;
190
+ };