@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.
- package/LICENSE +21 -0
- package/README.md +402 -0
- package/package.json +69 -0
- package/src/adapter/bun.ts +51 -0
- package/src/adapter/client.js +421 -0
- package/src/adapter/elysia.ts +39 -0
- package/src/adapter/hono.ts +190 -0
- package/src/adapter/utils.ts +190 -0
- package/src/build.ts +230 -0
- package/src/index.ts +246 -0
- package/src/island-id.ts +78 -0
- package/src/island-resolve.ts +39 -0
- package/src/nav.ts +308 -0
- package/src/transform.ts +157 -0
|
@@ -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
|
+
};
|