@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Valentin Kolb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,402 @@
1
+ # @k2b/ssr
2
+
3
+ Minimal SSR + islands framework for SolidJS on Bun.
4
+
5
+ ## Overview
6
+
7
+ This library renders Solid components on the server and hydrates only interactive islands on the client.
8
+
9
+ It uses file conventions:
10
+
11
+ - `*.island.tsx`: SSR + hydrated on the client
12
+ - `*.client.tsx`: client-only (no SSR HTML content)
13
+ - `*.tsx`: server-only output
14
+
15
+ ## Size & Philosophy
16
+
17
+ This framework is intentionally minimal and focused on SSR + islands only.
18
+
19
+ Current source size in this repo (`packages/ssr-core/src`):
20
+
21
+ | Component | Lines | Raw | Gzipped |
22
+ | --- | ---: | ---: | ---: |
23
+ | Core (`index`, `transform`, `build`, island ID + resolver) | ~750 | 26.2 KB | 8.3 KB |
24
+ | Dev client (overlay + reload, dev only) | ~421 | 11.9 KB | 3.5 KB |
25
+ | Adapters (`bun`, `hono`, `elysia`, shared utils) | ~465 | 14.4 KB | 4.8 KB |
26
+
27
+ Important: these sizes describe framework source code that runs at build time and on the server.
28
+ The browser receives only:
29
+
30
+ - your island bundles
31
+ - Solid runtime from your app dependencies
32
+ - `seroval` deserialize runtime
33
+ - a tiny hydration import snippet
34
+
35
+ Framework overhead in the browser is intentionally small.
36
+
37
+ What is intentionally not included:
38
+
39
+ - no client-side router
40
+ - no state management layer
41
+ - no CSS-in-JS abstraction
42
+ - no build tool wrapper around Bun
43
+
44
+ Use the libraries you already prefer. This package only handles SSR and islands hydration.
45
+ The optional `@k2b/ssr/nav` subpath provides progressive anchor
46
+ enhancement for islands, but it still does not add route matching, loaders, or
47
+ SPA routing.
48
+
49
+ ## Features
50
+
51
+ - Small SSR core with Bun-native build/plugin flow
52
+ - Adapters for Bun, Hono, and Elysia
53
+ - Type-safe Hono page helper via `createSSRHandler`
54
+ - Optional progressive navigation helpers via `@k2b/ssr/nav`
55
+ - Monorepo support via `rootDir`
56
+ - Public path mounting via `basePath` for microfrontends
57
+ - Stable file-path-based island IDs (collision-safe across workspace packages)
58
+ - Production chunk cache busting (`/_ssr/*.js?v=<buildTimestamp>`)
59
+ - Linked development source maps and validator-aware asset delivery
60
+ - Stale generated island assets removed after successful builds
61
+ - Visibility-aware development reload with cross-tab SSE coordination
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ bun add @k2b/ssr solid-js
67
+
68
+ # choose adapter deps you need
69
+ bun add hono
70
+ # or
71
+ bun add elysia
72
+ ```
73
+
74
+ ### Package scope migration
75
+
76
+ `@k2b/ssr` is the maintained successor to `@valentinkolb/ssr`. There are no
77
+ framework API changes in the scope migration. Replace the dependency and all
78
+ root or subpath imports:
79
+
80
+ ```ts
81
+ // Before
82
+ import { createConfig } from "@valentinkolb/ssr";
83
+ import { routes } from "@valentinkolb/ssr/hono";
84
+
85
+ // After
86
+ import { createConfig } from "@k2b/ssr";
87
+ import { routes } from "@k2b/ssr/hono";
88
+ ```
89
+
90
+ ## Required TypeScript settings
91
+
92
+ ```json
93
+ {
94
+ "compilerOptions": {
95
+ "lib": ["ESNext", "DOM"],
96
+ "jsx": "preserve",
97
+ "jsxImportSource": "solid-js",
98
+ "moduleResolution": "bundler"
99
+ }
100
+ }
101
+ ```
102
+
103
+ ## Quick Start (Hono)
104
+
105
+ ### 1) Create config
106
+
107
+ ```ts
108
+ // config.ts
109
+ import { createConfig } from "@k2b/ssr";
110
+ import { createSSRHandler, routes } from "@k2b/ssr/hono";
111
+
112
+ type PageOptions = {
113
+ title?: string;
114
+ description?: string;
115
+ };
116
+
117
+ export const { config, plugin, html } = createConfig<PageOptions>({
118
+ dev: process.env.NODE_ENV === "development",
119
+ // For monorepos with separated packages:
120
+ // rootDir: "/path/to/workspace-root",
121
+ // For microfrontends mounted under /docs:
122
+ // basePath: "/docs",
123
+ template: ({ body, scripts, title, description }) => `
124
+ <!doctype html>
125
+ <html>
126
+ <head>
127
+ <meta charset="utf-8" />
128
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
129
+ <title>${title ?? "App"}</title>
130
+ ${description ? `<meta name="description" content="${description}">` : ""}
131
+ </head>
132
+ <body>${body}${scripts}</body>
133
+ </html>
134
+ `,
135
+ });
136
+
137
+ export const ssr = createSSRHandler(html);
138
+ export { routes };
139
+ ```
140
+
141
+ ### 2) Register plugin in dev
142
+
143
+ ```ts
144
+ // scripts/preload.ts
145
+ import { plugin } from "../config";
146
+
147
+ Bun.plugin(plugin());
148
+ ```
149
+
150
+ ### 3) Create an island
151
+
152
+ ```tsx
153
+ // components/Counter.island.tsx
154
+ import { createSignal } from "solid-js";
155
+
156
+ export default function Counter({ initial = 0 }: { initial?: number }) {
157
+ const [count, setCount] = createSignal(initial);
158
+ return <button onClick={() => setCount((c) => c + 1)}>Count: {count()}</button>;
159
+ }
160
+ ```
161
+
162
+ ### 4) Create a page
163
+
164
+ ```tsx
165
+ // pages/Home.tsx
166
+ import { ssr } from "../config";
167
+ import Counter from "../components/Counter.island";
168
+
169
+ export default ssr(async (c) => {
170
+ c.get("page").title = "Home";
171
+ return () => <Counter initial={5} />;
172
+ });
173
+ ```
174
+
175
+ ### 5) Wire server
176
+
177
+ ```ts
178
+ // server.ts
179
+ import { Hono } from "hono";
180
+ import { config, routes } from "./config";
181
+ import Home from "./pages/Home";
182
+
183
+ export default new Hono()
184
+ .route("/_ssr", routes(config))
185
+ .get("/", ...Home);
186
+ ```
187
+
188
+ ### 6) Run
189
+
190
+ ```bash
191
+ NODE_ENV=development bun --watch --preload=./scripts/preload.ts src/server.ts
192
+ ```
193
+
194
+ ## Adapter imports
195
+
196
+ - Bun: `@k2b/ssr/bun`
197
+ - Hono: `@k2b/ssr/hono`
198
+ - Elysia: `@k2b/ssr/elysia`
199
+
200
+ ## Optional Navigation Helpers
201
+
202
+ `@k2b/ssr/nav` is an opt-in browser helper for islands that want to
203
+ update URL history after they have already updated client state.
204
+
205
+ ```tsx
206
+ import { createSignal, onCleanup, onMount } from "solid-js";
207
+ import { Link, listenPopState, type LinkNavigateEvent } from "@k2b/ssr/nav";
208
+
209
+ export default function Tabs() {
210
+ const [tab, setTab] = createSignal("alpha");
211
+
212
+ onMount(() => {
213
+ onCleanup(
214
+ listenPopState(({ url }) => {
215
+ setTab(url.searchParams.get("tab") ?? "alpha");
216
+ }),
217
+ );
218
+ });
219
+
220
+ const openTab = (nav: LinkNavigateEvent) => {
221
+ const next = nav.url.searchParams.get("tab") ?? "alpha";
222
+ setTab(next);
223
+ nav.push(`/demo?tab=${next}`, { scroll: "preserve", state: { tab: next } });
224
+ };
225
+
226
+ return (
227
+ <Link href="/demo?tab=beta" scroll="preserve" onNavigate={openTab}>
228
+ Open beta
229
+ </Link>
230
+ );
231
+ }
232
+ ```
233
+
234
+ `Link` renders a real `<a href>` during SSR. Enhanced clicks only run in the
235
+ browser for same-origin, left-click navigation without modifier keys. Without
236
+ `onNavigate`, `Link` calls `navigate()` directly and only updates browser
237
+ history. With `onNavigate`, the island owns data loading and state updates, then
238
+ calls `nav.push()`, `nav.replaceWith()`, or `nav.fallback()`.
239
+
240
+ Use `listenPopState()` whenever `nav.push()` represents client state. Browser
241
+ Back/Forward changes history but cannot infer how an island maps the URL back to
242
+ signals or stores. The helper reports the current `URL`, native `PopStateEvent`,
243
+ and history state without adding route matching or data loading.
244
+
245
+ Navigation behavior:
246
+
247
+ - reactive anchor props remain reactive after `Link` renders
248
+ - same-document hash links retain native target scrolling unless `onNavigate`
249
+ or `scroll` explicitly takes ownership
250
+ - relative URLs follow `document.baseURI`
251
+ - cross-origin `navigate()` calls use full document navigation
252
+ - replace navigation preserves existing `history.state` unless `state` is set
253
+ - rejected async `onNavigate` callbacks log the error and fall back to a full
254
+ document navigation
255
+
256
+ Available exports:
257
+
258
+ - `Link`
259
+ - `navigate()`, `navigateTo()`, `documentNavigate()`, `currentPathWithQuery()`, `refreshCurrentPath()`
260
+ - `captureScroll()`, `restoreScroll()`, `listenPopState()`, `startViewTransition()`
261
+ - `LinkNavigateEvent`, `LinkProps`, `EnhancedNavigateOptions`, `NavigationScrollMode`, `PopStateNavigationEvent`, `ScrollSnapshot`
262
+
263
+ Use `data-scroll-preserve="stable-key"` on scroll containers that should keep
264
+ their scroll position across enhanced navigation.
265
+
266
+ ## Rendering API
267
+
268
+ `html()` and Hono `ssr()` handlers expect a synchronous render function:
269
+
270
+ ```tsx
271
+ export default ssr(async (c) => {
272
+ const data = await loadData();
273
+ c.get("page").title = data.title;
274
+
275
+ return () => <Page data={data} />;
276
+ });
277
+
278
+ app.get("/", () => html(() => <Page />));
279
+ ```
280
+
281
+ Do async work in the handler before returning the render function. Do not make the render function itself `async`; Solid SSR expects synchronous JSX evaluation.
282
+
283
+ ### v0.9.0 migration
284
+
285
+ This is a breaking change in v0.9.0. In v0.8.x and earlier, examples often returned already-created JSX:
286
+
287
+ ```tsx
288
+ // v0.8.x and earlier
289
+ export default ssr(async () => <Page />);
290
+
291
+ app.get("/", () => html(<Page />));
292
+ ```
293
+
294
+ In v0.9.0, wrap JSX creation in a render function:
295
+
296
+ ```tsx
297
+ // v0.9.0+
298
+ export default ssr(async () => () => <Page />);
299
+
300
+ app.get("/", () => html(() => <Page />));
301
+ ```
302
+
303
+ This ensures Solid primitives such as `createUniqueId()` run inside `renderToString()`, where the SSR context exists.
304
+
305
+ ## `createConfig` options
306
+
307
+ ```ts
308
+ createConfig({
309
+ dev?: boolean; // default: false
310
+ verbose?: boolean; // default: !dev
311
+ rootDir?: string; // default: process.cwd()
312
+ basePath?: string; // default: "", example: "/docs"
313
+ external?: string[]; // passed to Bun.build for island bundle
314
+ devSourcemap?: "none" | "linked" | "inline"; // default: "linked"
315
+ template?: ({ body, scripts, ...custom }) => string | Promise<string>;
316
+ })
317
+ ```
318
+
319
+ ### Notes
320
+
321
+ - `rootDir` is important in monorepos where server entrypoint and island files live in different packages.
322
+ - `basePath` moves SSR assets and dev endpoints under that prefix, e.g. `/docs/_ssr`.
323
+ - Development builds emit linked source maps by default. Use `"inline"` only when a tool requires embedded maps, or `"none"` to disable them.
324
+ - In production, hydration imports include a build timestamp query (`?v=...`) for cache busting.
325
+ - All adapters stream island assets from `Bun.file`. Production assets and content-hashed development chunks are immutable; stable development entries and source maps use validators for inexpensive freshness checks.
326
+
327
+ ## Microfrontend mount example
328
+
329
+ Use `basePath` when the SSR app is mounted under a sub-path:
330
+
331
+ ```ts
332
+ // config.ts
333
+ export const { config, html } = createConfig({
334
+ basePath: "/docs",
335
+ });
336
+
337
+ // docs-app.ts
338
+ const docsApp = new Hono()
339
+ .route("/_ssr", routes(config))
340
+ .get("/", () => html(() => <DocsHome />));
341
+
342
+ // host-app.ts
343
+ export default new Hono().route("/docs", docsApp);
344
+ ```
345
+
346
+ With this setup, hydration chunks and dev endpoints are served from `/docs/_ssr/...`.
347
+
348
+ ## Build for production
349
+
350
+ ```ts
351
+ // scripts/build.ts
352
+ import { plugin } from "./config";
353
+
354
+ await Bun.build({
355
+ entrypoints: ["src/server.tsx"],
356
+ outdir: "dist",
357
+ target: "bun",
358
+ plugins: [plugin()],
359
+ });
360
+ ```
361
+
362
+ ## Hono `createSSRHandler` behavior
363
+
364
+ `createSSRHandler(html)` returns an `ssr()` helper that:
365
+
366
+ - initializes `c.get("page")` as typed page options
367
+ - accepts middlewares/validators before final handler
368
+ - lets handlers return either a synchronous render function or `Response`
369
+
370
+ ## Dev mode tools
371
+
372
+ With `dev: true`, a small `[ssr]` overlay is injected.
373
+
374
+ It can:
375
+
376
+ - auto-reload on server restart
377
+ - highlight island/client boundaries
378
+ - show source filenames for wrapped components
379
+
380
+ In browsers with Web Locks support, auto-reload elects one visible tab per
381
+ origin and SSR path to hold the SSE connection. Hidden tabs suspend reload work,
382
+ leadership transfers automatically, and cached pages resume safely after a
383
+ back-forward cache restore. Browsers without Web Locks retain visibility-scoped
384
+ per-tab connections as a compatibility fallback.
385
+
386
+ ## Limitations
387
+
388
+ - islands must use default export
389
+ - props must be serializable via `seroval`; do not pass functions, callbacks, event handlers, Solid signals/stores, DOM nodes, or class instances as island/client props
390
+ - nested island/client imports are not supported
391
+
392
+ ## Local monorepo example
393
+
394
+ This repo includes a current example app:
395
+
396
+ - `packages/ssr-example`
397
+
398
+ Run from workspace root:
399
+
400
+ ```bash
401
+ bun run dev:example
402
+ ```
package/package.json ADDED
@@ -0,0 +1,69 @@
1
+ {
2
+ "name": "@k2b/ssr",
3
+ "version": "0.12.0-rc.0",
4
+ "description": "Minimal SSR framework for SolidJS and Bun",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "types": "src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts",
10
+ "./bun": "./src/adapter/bun.ts",
11
+ "./elysia": "./src/adapter/elysia.ts",
12
+ "./hono": "./src/adapter/hono.ts",
13
+ "./nav": "./src/nav.ts"
14
+ },
15
+ "scripts": {
16
+ "test": "bunx tsc -p test/tsconfig.json && bun test test/unit && bun test --conditions=browser --preload ./test/browser/setup.ts test/browser"
17
+ },
18
+ "peerDependencies": {
19
+ "elysia": "^1.0.0",
20
+ "hono": "^4.0.0",
21
+ "solid-js": "^1.9.0"
22
+ },
23
+ "peerDependenciesMeta": {
24
+ "elysia": {
25
+ "optional": true
26
+ },
27
+ "hono": {
28
+ "optional": true
29
+ }
30
+ },
31
+ "dependencies": {
32
+ "@babel/core": "^7.29.7",
33
+ "@babel/preset-typescript": "^7.29.7",
34
+ "@types/babel__core": "^7.20.5",
35
+ "babel-preset-solid": "^1.9.12",
36
+ "seroval": "^1.5.5"
37
+ },
38
+ "devDependencies": {
39
+ "@happy-dom/global-registrator": "^20.10.6",
40
+ "@types/bun": "^1.3.14",
41
+ "elysia": "^1.4.29",
42
+ "file-type": "^21.3.1",
43
+ "hono": "^4.12.30",
44
+ "solid-js": "^1.9.14",
45
+ "typescript": "^5.9.3"
46
+ },
47
+ "license": "MIT",
48
+ "author": "Valentin Kolb",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://github.com/k2b-dev/ssr.git"
52
+ },
53
+ "keywords": [
54
+ "solid",
55
+ "solidjs",
56
+ "ssr",
57
+ "bun",
58
+ "server-side-rendering",
59
+ "islands",
60
+ "elysia",
61
+ "hono"
62
+ ],
63
+ "files": [
64
+ "src/**/*.ts",
65
+ "src/**/*.js",
66
+ "README.md",
67
+ "LICENSE"
68
+ ]
69
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Bun.serve() adapter - provides routes object for native Bun server.
3
+ * Serves island chunks from _ssr and dev tools endpoints.
4
+ */
5
+ import type { SsrConfig } from "../index";
6
+ import {
7
+ createAssetResponse,
8
+ createPingResponse,
9
+ getSsrDir,
10
+ createReloadResponse,
11
+ } from "./utils";
12
+
13
+ type RouteHandler = (req: Request) => Response | Promise<Response>;
14
+ type Routes = Record<string, RouteHandler>;
15
+
16
+ /**
17
+ * Creates routes for Bun.serve from SSR config.
18
+ *
19
+ * @example
20
+ * ```ts
21
+ * import { routes } from "@k2b/ssr/bun";
22
+ * serve({
23
+ * routes: {
24
+ * ...routes(config),
25
+ * "/": () => html(() => <Home />),
26
+ * },
27
+ * });
28
+ * ```
29
+ */
30
+ export const routes = (config: SsrConfig): Routes => {
31
+ const { dev, ssrPath } = config;
32
+ const ssrDir = getSsrDir(config);
33
+
34
+ const devRoutes: Routes = dev
35
+ ? {
36
+ [`${ssrPath}/_reload`]: (req) => createReloadResponse(req.signal),
37
+ [`${ssrPath}/_ping`]: () => createPingResponse(),
38
+ }
39
+ : {};
40
+
41
+ const serveAsset: RouteHandler = (req) => {
42
+ const filename = new URL(req.url).pathname.split("/").pop()!;
43
+ return createAssetResponse(req, ssrDir, filename, dev);
44
+ };
45
+
46
+ return {
47
+ ...devRoutes,
48
+ [`${ssrPath}/*.js`]: serveAsset,
49
+ [`${ssrPath}/*.js.map`]: serveAsset,
50
+ };
51
+ };