@valentinkolb/ssr 0.0.1

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,290 @@
1
+ # @valentinkolb/SSR
2
+
3
+ A minimal server-side rendering framework for SolidJS and Bun with islands architecture.
4
+
5
+ ## Overview
6
+
7
+ This framework provides SSR capabilities for SolidJS applications using Bun's runtime. It follows the islands architecture pattern where you can selectively hydrate interactive components while keeping the rest of your page static HTML.
8
+
9
+ The entire framework is roughly 750 lines of code with zero runtime dependencies beyond Solid, seroval and your chosen web framework adapter.
10
+
11
+ ## Features
12
+
13
+ - **Minimal footprint**: Under 800 lines of core code and seroval as only dependency beside solid and your chosen web framework.
14
+ - **Islands architecture**: `*.island.tsx` for hydrated components, `*.client.tsx` for client-only - thats it
15
+ - **Framework agnostic**: Works with Bun's native server, **Elysia**, or **Hono**
16
+ - **Fast**: Built on Bun's runtime with optimized bundling
17
+ - **Dev experience**: Hot reload, source maps, and TypeScript support
18
+
19
+ ## Quick Start
20
+
21
+ Install the package:
22
+
23
+ ```bash
24
+ bun add @valentinkolb/ssr@jsr solid-js
25
+ ```
26
+
27
+ Create a configuration file (optional - has sensible defaults):
28
+
29
+ ```typescript
30
+ // config.ts
31
+ import { createConfig } from "@valentinkolb/ssr";
32
+
33
+ export const { config, plugin, html } = createConfig({
34
+ dev: process.env.NODE_ENV === "development",
35
+ });
36
+ ```
37
+
38
+ Create an interactive island component:
39
+
40
+ ```tsx
41
+ // components/Counter.island.tsx
42
+ import { createSignal } from "solid-js";
43
+
44
+ export default function Counter({ initialCount = 0 }) {
45
+ const [count, setCount] = createSignal(initialCount);
46
+
47
+ return (
48
+ <button onClick={() => setCount(count() + 1)}>
49
+ Count: {count()}
50
+ </button>
51
+ );
52
+ }
53
+ ```
54
+
55
+ Use it in a page:
56
+
57
+ ```tsx
58
+ // pages/Home.tsx
59
+ import Counter from "../components/Counter.island";
60
+
61
+ export default function Home() {
62
+ return (
63
+ <div>
64
+ <h1>My Page</h1>
65
+ <Counter initialCount={5} />
66
+ </div>
67
+ );
68
+ }
69
+ ```
70
+
71
+ ## Adapter Usage
72
+
73
+ ### Bun Native Server
74
+
75
+ ```typescript
76
+ import { Bun } from "bun";
77
+ import { routes } from "@valentinkolb/ssr/adapter/bun";
78
+ import { config, html } from "./config";
79
+ import Home from "./pages/Home";
80
+
81
+ Bun.serve({
82
+ port: 3000,
83
+ routes: {
84
+ ...routes(config),
85
+ "/": () => html(<Home />),
86
+ },
87
+ });
88
+ ```
89
+
90
+ ### Hono
91
+
92
+ ```typescript
93
+ import { Hono } from "hono";
94
+ import { routes } from "@valentinkolb/ssr/adapter/hono";
95
+ import { config, html } from "./config";
96
+ import Home from "./pages/Home";
97
+
98
+ const app = new Hono()
99
+ .route("/_ssr", routes(config))
100
+ .get("/", async (c) => {
101
+ const response = await html(<Home />);
102
+ return c.html(await response.text());
103
+ });
104
+
105
+ export default app;
106
+ ```
107
+
108
+ ### Elysia
109
+
110
+ ```typescript
111
+ import { Elysia } from "elysia";
112
+ import { routes } from "@valentinkolb/ssr/adapter/elysia";
113
+ import { config, html } from "./config";
114
+ import Home from "./pages/Home";
115
+
116
+ new Elysia()
117
+ .use(routes(config))
118
+ .get("/", () => html(<Home />))
119
+ .listen(3000);
120
+ ```
121
+
122
+ ## Build Configuration
123
+
124
+ Add the plugin to your build script:
125
+
126
+ ```typescript
127
+ // scripts/build.ts
128
+ import { plugin } from "./config";
129
+
130
+ await Bun.build({
131
+ entrypoints: ["src/server.tsx"],
132
+ outdir: "dist",
133
+ target: "bun",
134
+ plugins: [plugin()],
135
+ });
136
+ ```
137
+
138
+ For development with watch mode:
139
+
140
+ ```typescript
141
+ // scripts/preload.ts
142
+ import { plugin } from "./config";
143
+
144
+ Bun.plugin(plugin());
145
+ ```
146
+
147
+ ```json
148
+ {
149
+ "scripts": {
150
+ "dev": "bun --watch --preload=./scripts/preload.ts run src/server.tsx",
151
+ "build": "bun run scripts/build.ts",
152
+ "start": "bun run dist/server.js"
153
+ }
154
+ }
155
+ ```
156
+
157
+ ## Component Types
158
+
159
+ ### Island Components (`*.island.tsx`)
160
+
161
+ Island components are server-rendered and then hydrated on the client. They should be used for interactive UI elements that need JavaScript.
162
+
163
+ ```tsx
164
+ // Sidebar.island.tsx
165
+ import { createSignal } from "solid-js";
166
+
167
+ export default function Sidebar() {
168
+ const [open, setOpen] = createSignal(false);
169
+ return <div>{open() ? "Open" : "Closed"}</div>;
170
+ }
171
+ ```
172
+
173
+ ### Client-Only Components (`*.client.tsx`)
174
+
175
+ Client-only components are not rendered on the server. They render only in the browser, useful for components that depend on browser APIs.
176
+
177
+ ```tsx
178
+ // ThemeToggle.client.tsx
179
+ import { createSignal, onMount } from "solid-js";
180
+
181
+ export default function ThemeToggle() {
182
+ const [theme, setTheme] = createSignal("light");
183
+
184
+ onMount(() => {
185
+ setTheme(localStorage.getItem("theme") || "light");
186
+ });
187
+
188
+ return <button onClick={() => setTheme(theme() === "light" ? "dark" : "light")}>
189
+ {theme()}
190
+ </button>;
191
+ }
192
+ ```
193
+
194
+ ### Regular Components
195
+
196
+ Standard Solid components that are only rendered on the server. No client-side JavaScript is shipped for these.
197
+
198
+ ```tsx
199
+ // Header.tsx
200
+ export default function Header() {
201
+ return <header><h1>My Site</h1></header>;
202
+ }
203
+ ```
204
+
205
+ ## Props Serialization
206
+
207
+ The framework uses [seroval](https://github.com/lxsmnsyc/seroval) for props serialization, which supports complex JavaScript types that JSON cannot handle:
208
+
209
+ ```tsx
210
+ <Island
211
+ date={new Date()}
212
+ map={new Map([["key", "value"]])}
213
+ set={new Set([1, 2, 3])}
214
+ regex={/test/gi}
215
+ bigint={123n}
216
+ undefined={undefined}
217
+ />
218
+ ```
219
+
220
+ ## Custom HTML Template
221
+
222
+ You can pass additional options to your HTML template. All options are type safe!
223
+
224
+ ```typescript
225
+ type PageOptions = { title: string; description?: string };
226
+
227
+ const { html } = createConfig<PageOptions>({
228
+ template: ({
229
+ body, scripts, // must be provided and used for hydration
230
+ title, description // user defined options
231
+ }) => `
232
+ <!DOCTYPE html>
233
+ <html>
234
+ <head>
235
+ <title>${title}</title>
236
+ ${description ? `<meta name="description" content="${description}">` : ""}
237
+ </head>
238
+ <body>${body}${scripts}</body>
239
+ </html>
240
+ `,
241
+ });
242
+
243
+ // Usage
244
+ await html(<Home />, {
245
+ title: "Home Page", // type safe
246
+ description: "Welcome to my site" // type safe
247
+ });
248
+ ```
249
+
250
+ ## How It Works
251
+
252
+ 1. **Build time**: The framework discovers all `*.island.tsx` and `*.client.tsx` files in the project and bundles them separately for the browser
253
+ 2. **During SSR**: Normal components are rendered to HTML strings. Island/client components are wrapped in custom elements with data attributes containing their props
254
+ 3. **At the client**: Individual island bundles load and hydrate their corresponding DOM elements
255
+
256
+ The framework uses a Babel plugin to transform island imports into wrapped components during SSR. Props are serialized using seroval and embedded in data attributes. On the client, each island bundle deserializes its props and renders the component.
257
+
258
+ Babel is used since Solid only supports Babel for JSX transformation at the moment.
259
+
260
+ ## File Structure
261
+
262
+ ```
263
+ src/
264
+ ├── index.ts # Core SSR logic and createConfig()
265
+ ├── transform.ts # Babel plugin for island wrapping
266
+ ├── build.ts # Island bundling with code splitting
267
+ ├── bun.ts # Bun.serve() adapter
268
+ ├── elysia.ts # Elysia adapter
269
+ ├── hono.ts # Hono adapter
270
+ └── client.js # Dev mode auto-reload client
271
+ ```
272
+
273
+ ## Configuration Options
274
+
275
+ ```typescript
276
+ createConfig({
277
+ dev?: boolean; // Enable dev mode (default: false)
278
+ verbose?: boolean; // Enable verbose logging (default: !dev)
279
+ autoRefresh?: boolean; // Enable auto-reload in dev (default: true)
280
+ template?: (context) => string; // HTML template function (optional, has default)
281
+ })
282
+ ```
283
+
284
+ ## Contributing
285
+
286
+ Contributions are welcome! The codebase is intentionally minimal. Keep changes focused and avoid adding unnecessary complexity.
287
+
288
+ ## License
289
+
290
+ MIT
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "@valentinkolb/ssr",
3
+ "version": "0.0.1",
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
+ "./adapter/bun": "./src/adapter/bun.ts",
11
+ "./adapter/elysia": "./src/adapter/elysia.ts",
12
+ "./adapter/hono": "./src/adapter/hono.ts"
13
+ },
14
+ "scripts": {
15
+ "test": "bun test",
16
+ "build:example": "bun run example/build.ts",
17
+ "example": "bun --watch --preload=./example/preload.ts run example/server.tsx",
18
+ "dev": "bun run build:example && bun run example"
19
+ },
20
+ "peerDependencies": {
21
+ "solid-js": "^1.9.0",
22
+ "elysia": "^1.0.0",
23
+ "@elysiajs/static": "^1.0.0",
24
+ "hono": ""
25
+ },
26
+ "peerDependenciesMeta": {
27
+ "elysia": {
28
+ "optional": true
29
+ },
30
+ "@elysiajs/static": {
31
+ "optional": true
32
+ },
33
+ "hono": {
34
+ "optional": true
35
+ }
36
+ },
37
+ "dependencies": {
38
+ "seroval": "^1.0.0"
39
+ },
40
+ "devDependencies": {
41
+ "@babel/core": "^7.24.0",
42
+ "@babel/preset-typescript": "^7.24.0",
43
+ "@elysiajs/static": "^1.2.0",
44
+ "@types/babel__core": "^7.20.5",
45
+ "@types/bun": "latest",
46
+ "babel-preset-solid": "^1.8.0",
47
+ "elysia": "^1.2.0",
48
+ "hono": "^4.6.14",
49
+ "solid-js": "^1.9.0",
50
+ "typescript": "^5.0.0"
51
+ },
52
+ "license": "MIT",
53
+ "author": "Valentin Kolb",
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "https://github.com/valentinkolb/ssr"
57
+ },
58
+ "keywords": [
59
+ "solid",
60
+ "solidjs",
61
+ "ssr",
62
+ "bun",
63
+ "server-side-rendering",
64
+ "elysia",
65
+ "hono"
66
+ ],
67
+ "files": [
68
+ "src/**/*.ts",
69
+ "src/**/*.js",
70
+ "README.md",
71
+ "LICENSE"
72
+ ]
73
+ }
@@ -0,0 +1,105 @@
1
+ import { join, dirname } from "path";
2
+ import type { SsrConfig } from "../index";
3
+ // @ts-ignore - Bun text import
4
+ import devClientCode from "./client.js" with { type: "text" };
5
+
6
+ type RouteHandler = (req: Request) => Response | Promise<Response>;
7
+ type Routes = Record<string, RouteHandler>;
8
+
9
+ /**
10
+ * Creates routes for Bun.serve from SSR config.
11
+ * Only handles /_ssr/* routes for islands and dev tools.
12
+ * Static file serving should be handled by the user.
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { routes } from "@valentinkolb/ssr/bun";
17
+ * import { config, html } from "./config";
18
+ *
19
+ * serve({
20
+ * routes: {
21
+ * ...routes(config),
22
+ * "/": () => html(<Home />),
23
+ * // Handle static files yourself:
24
+ * "/public/*": (req) => {
25
+ * const path = new URL(req.url).pathname.replace("/public/", "");
26
+ * return new Response(Bun.file(`./public/${path}`));
27
+ * },
28
+ * },
29
+ * });
30
+ * ```
31
+ */
32
+ export const routes = (config: SsrConfig): Routes => {
33
+ const { dev, autoRefresh } = config;
34
+
35
+ return {
36
+ // Dev mode: SSE endpoint for live reload
37
+ "/_ssr/_reload": () => {
38
+ if (!dev || !autoRefresh) {
39
+ return new Response("Not found", { status: 404 });
40
+ }
41
+ return new Response(
42
+ new ReadableStream({
43
+ start(controller) {
44
+ controller.enqueue(new TextEncoder().encode(": connected\n\n"));
45
+ const interval = setInterval(() => {
46
+ try {
47
+ controller.enqueue(new TextEncoder().encode(": ping\n\n"));
48
+ } catch {
49
+ clearInterval(interval);
50
+ }
51
+ }, 5000);
52
+ },
53
+ }),
54
+ {
55
+ headers: {
56
+ "Content-Type": "text/event-stream",
57
+ "Cache-Control": "no-cache",
58
+ Connection: "keep-alive",
59
+ },
60
+ },
61
+ );
62
+ },
63
+
64
+ // Dev mode: Ping endpoint for reconnection check
65
+ "/_ssr/_ping": () => {
66
+ if (!dev || !autoRefresh) {
67
+ return new Response("Not found", { status: 404 });
68
+ }
69
+ return new Response("ok");
70
+ },
71
+
72
+ // Dev mode: Serve reload client script
73
+ "/_ssr/_client.js": () => {
74
+ if (!dev || !autoRefresh) {
75
+ return new Response("Not found", { status: 404 });
76
+ }
77
+ return new Response(devClientCode, {
78
+ headers: {
79
+ "Content-Type": "application/javascript",
80
+ "Cache-Control": "no-cache",
81
+ },
82
+ });
83
+ },
84
+
85
+ // Serve islands
86
+ "/_ssr/*.js": async (req) => {
87
+ const filename = new URL(req.url).pathname.split("/").pop()!;
88
+
89
+ const file = Bun.file(join(dev ? "." : Bun.main, "_ssr", filename));
90
+
91
+ if (!(await file.exists())) {
92
+ return new Response("Not found", { status: 404 });
93
+ }
94
+
95
+ return new Response(file, {
96
+ headers: {
97
+ "Content-Type": file.type,
98
+ "Cache-Control": dev
99
+ ? "no-cache"
100
+ : "public, max-age=31536000, immutable",
101
+ },
102
+ });
103
+ },
104
+ };
105
+ };
@@ -0,0 +1,75 @@
1
+ // Dev mode live-reload client
2
+ // Uses Server-Sent Events to detect server restart
3
+
4
+ if (!window.__ssr_reload) {
5
+ window.__ssr_reload = true;
6
+
7
+ // Tooltip
8
+ const tooltip = document.body.appendChild(
9
+ Object.assign(document.createElement("div"), {
10
+ innerHTML: `
11
+ auto refresh enabled
12
+ <br/><br/>
13
+ to disable, add to config
14
+ <br/>
15
+ { autoRefresh: false }
16
+ `,
17
+ }),
18
+ );
19
+ Object.assign(tooltip.style, {
20
+ fontFamily: "monospace",
21
+ fontSize: "12px",
22
+ color: "#888",
23
+ background: "#000",
24
+ padding: "8px",
25
+ border: "1px solid #333",
26
+ position: "fixed",
27
+ bottom: "28px",
28
+ left: "8px",
29
+ zIndex: "9999",
30
+ display: "none",
31
+ });
32
+
33
+ // Badge
34
+ const badge = document.body.appendChild(
35
+ Object.assign(document.createElement("div"), {
36
+ innerText: "[ssr]",
37
+ onmouseenter: () => (tooltip.style.display = "block"),
38
+ onmouseleave: () => (tooltip.style.display = "none"),
39
+ }),
40
+ );
41
+ Object.assign(badge.style, {
42
+ fontFamily: "monospace",
43
+ fontSize: "12px",
44
+ color: "#555",
45
+ position: "fixed",
46
+ bottom: "8px",
47
+ left: "8px",
48
+ zIndex: "9999",
49
+ cursor: "default",
50
+ });
51
+
52
+ const es = new EventSource("/_ssr/_reload");
53
+
54
+ es.onerror = () => {
55
+ es.close();
56
+ window.__ssr_reload = false;
57
+ badge.innerText = "[...]";
58
+
59
+ const check = setInterval(() => {
60
+ fetch("/_ssr/_ping")
61
+ .then(({ ok }) => {
62
+ if (!ok) return;
63
+ clearInterval(check);
64
+ location.reload();
65
+ })
66
+ .catch(() => {});
67
+ }, 300);
68
+ };
69
+
70
+ // Clean up on page unload (for bfcache)
71
+ window.addEventListener("pagehide", () => {
72
+ es.close();
73
+ window.__ssr_reload = false;
74
+ });
75
+ }
@@ -0,0 +1,96 @@
1
+ import { Elysia } from "elysia";
2
+ import { staticPlugin } from "@elysiajs/static";
3
+ import { join } from "path";
4
+ import type { SsrConfig } from "../index";
5
+ // @ts-ignore - Bun text import
6
+ import devClientCode from "./client.js" with { type: "text" };
7
+
8
+ /**
9
+ * Creates Elysia plugin with routes from SSR config.
10
+ * Handles /_ssr/* routes for islands and dev tools using @elysiajs/static.
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { Elysia } from "elysia";
15
+ * import { staticPlugin } from "@elysiajs/static";
16
+ * import { routes } from "@valentinkolb/ssr/elysia";
17
+ * import { config, html } from "./config";
18
+ *
19
+ * new Elysia()
20
+ * .use(routes(config))
21
+ * .use(staticPlugin({ assets: "./public", prefix: "/public" }))
22
+ * .get("/", () => html(<Home />))
23
+ * .listen(3000);
24
+ * ```
25
+ */
26
+ export const routes = (config: SsrConfig) => {
27
+ const { dev, autoRefresh } = config;
28
+
29
+ const ssrDir = join(dev ? "." : Bun.main, "_ssr");
30
+
31
+ return (
32
+ new Elysia({ name: "ssr" })
33
+ // Serve island chunks
34
+ .use(
35
+ staticPlugin({
36
+ assets: ssrDir,
37
+ prefix: "/_ssr",
38
+ headers: {
39
+ "Cache-Control": dev
40
+ ? "no-cache"
41
+ : "public, max-age=31536000, immutable",
42
+ },
43
+ }),
44
+ )
45
+
46
+ // Dev mode: SSE endpoint for live reload
47
+ .get("/_ssr/_reload", () => {
48
+ if (!dev || !autoRefresh) {
49
+ return new Response("Not found", { status: 404 });
50
+ }
51
+
52
+ return new Response(
53
+ new ReadableStream({
54
+ start(controller) {
55
+ controller.enqueue(new TextEncoder().encode(": connected\n\n"));
56
+ const interval = setInterval(() => {
57
+ try {
58
+ controller.enqueue(new TextEncoder().encode(": ping\n\n"));
59
+ } catch {
60
+ clearInterval(interval);
61
+ }
62
+ }, 5000);
63
+ },
64
+ }),
65
+ {
66
+ headers: {
67
+ "Content-Type": "text/event-stream",
68
+ "Cache-Control": "no-cache",
69
+ Connection: "keep-alive",
70
+ },
71
+ },
72
+ );
73
+ })
74
+
75
+ // Dev mode: Ping endpoint for reconnection check
76
+ .get("/_ssr/_ping", () => {
77
+ if (!dev || !autoRefresh) {
78
+ return new Response("Not found", { status: 404 });
79
+ }
80
+ return new Response("ok");
81
+ })
82
+
83
+ // Dev mode: Serve reload client script
84
+ .get("/_ssr/_client.js", () => {
85
+ if (!dev || !autoRefresh) {
86
+ return new Response("Not found", { status: 404 });
87
+ }
88
+ return new Response(devClientCode, {
89
+ headers: {
90
+ "Content-Type": "application/javascript",
91
+ "Cache-Control": "no-cache",
92
+ },
93
+ });
94
+ })
95
+ );
96
+ };
@@ -0,0 +1,102 @@
1
+ import { Hono } from "hono";
2
+ import { dirname, join } from "path";
3
+ import type { SsrConfig } from "../index";
4
+ // @ts-ignore - Bun text import
5
+ import devClientCode from "./client.js" with { type: "text" };
6
+
7
+ /**
8
+ * Creates Hono app with SSR routes.
9
+ * Handles /_ssr/* routes for islands and dev tools.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * import { Hono } from "hono";
14
+ * import { serveStatic } from "hono/bun";
15
+ * import { routes } from "@valentinkolb/ssr/hono";
16
+ * import { config, html } from "./config";
17
+ *
18
+ * const app = new Hono()
19
+ * .route("/_ssr", routes(config))
20
+ * .use("/public/*", serveStatic({ root: "./" }))
21
+ * .get("/", async (c) => {
22
+ * const response = await html(<Home />);
23
+ * return c.html(await response.text());
24
+ * });
25
+ *
26
+ * export default app;
27
+ * ```
28
+ */
29
+ export const routes = (config: SsrConfig) => {
30
+ const { dev, autoRefresh } = config;
31
+
32
+ const app = new Hono();
33
+
34
+ // Dev mode: Serve reload client script
35
+ app.get("/_client.js", (c) => {
36
+ if (!dev || !autoRefresh) {
37
+ return c.notFound();
38
+ }
39
+ return new Response(devClientCode, {
40
+ headers: {
41
+ "Content-Type": "application/javascript",
42
+ "Cache-Control": "no-cache",
43
+ },
44
+ });
45
+ });
46
+
47
+ // Dev mode: SSE endpoint for live reload
48
+ app.get("/_reload", (c) => {
49
+ if (!dev || !autoRefresh) {
50
+ return c.notFound();
51
+ }
52
+ return c.body(
53
+ new ReadableStream({
54
+ start(controller) {
55
+ controller.enqueue(new TextEncoder().encode(": connected\n\n"));
56
+ const interval = setInterval(() => {
57
+ try {
58
+ controller.enqueue(new TextEncoder().encode(": ping\n\n"));
59
+ } catch {
60
+ clearInterval(interval);
61
+ }
62
+ }, 5000);
63
+ },
64
+ }),
65
+ {
66
+ headers: {
67
+ "Content-Type": "text/event-stream",
68
+ "Cache-Control": "no-cache",
69
+ Connection: "keep-alive",
70
+ },
71
+ },
72
+ );
73
+ });
74
+
75
+ // Dev mode: Ping endpoint for reconnection check
76
+ app.get("/_ping", (c) => {
77
+ if (!dev || !autoRefresh) {
78
+ return c.notFound();
79
+ }
80
+ return c.text("ok");
81
+ });
82
+
83
+ // Serve all other files as island chunks (use :filename+ to capture filename with extension)
84
+ app.get("/:filename{.+\\.js$}", async (c) => {
85
+ const file = Bun.file(
86
+ join(dev ? "." : Bun.main, "_ssr", c.req.param("filename")),
87
+ );
88
+
89
+ if (!(await file.exists())) return c.notFound();
90
+
91
+ return c.body(await file.text(), {
92
+ headers: {
93
+ "Content-Type": file.type,
94
+ "Cache-Control": dev
95
+ ? "no-cache"
96
+ : "public, max-age=31536000, immutable",
97
+ },
98
+ });
99
+ });
100
+
101
+ return app;
102
+ };
package/src/build.ts ADDED
@@ -0,0 +1,107 @@
1
+ import { relative } from "path";
2
+ import { Glob } from "bun";
3
+ import { transform, hash } from "./transform";
4
+
5
+ type ComponentType = "island" | "client";
6
+
7
+ const getComponentType = (path: string): ComponentType =>
8
+ path.includes(".client.") ? "client" : "island";
9
+
10
+ const getSelector = (type: ComponentType, id: string) =>
11
+ type === "island"
12
+ ? `solid-island[data-id="${id}"]`
13
+ : `solid-client[data-id="${id}"]`;
14
+
15
+ export const buildIslands = async (options: {
16
+ pattern: string;
17
+ outdir: string;
18
+ verbose: boolean;
19
+ dev?: boolean;
20
+ }): Promise<void> => {
21
+ const { pattern, outdir, verbose, dev = false } = options;
22
+
23
+ const files: string[] = [];
24
+
25
+ for await (const file of new Glob(pattern).scan({
26
+ cwd: process.cwd(),
27
+ absolute: true,
28
+ })) {
29
+ files.push(file);
30
+ }
31
+
32
+ if (!files.length) {
33
+ if (verbose) console.log("No island/client files found.");
34
+ return;
35
+ }
36
+
37
+ // Build component metadata
38
+ const components = files.map((componentPath) => {
39
+ const id = hash(componentPath);
40
+ const type = getComponentType(componentPath);
41
+ const selector = getSelector(type, id);
42
+ return { path: componentPath, id, type, selector };
43
+ });
44
+
45
+ // Build all islands together with code splitting
46
+ // This ensures Solid is only bundled once as a shared chunk
47
+ const result = await Bun.build({
48
+ entrypoints: components.map((c) => c.id),
49
+ outdir,
50
+ naming: { entry: "[name].js", chunk: "chunk-[hash].js" },
51
+ target: "browser",
52
+ minify: !dev,
53
+ splitting: true,
54
+ sourcemap: dev ? "inline" : "none",
55
+ plugins: [
56
+ {
57
+ name: "solid-islands",
58
+ setup(build) {
59
+ // Resolve component IDs as virtual entrypoints
60
+ build.onResolve({ filter: /^[a-f0-9]{8}$/ }, (args) => ({
61
+ path: args.path,
62
+ namespace: "island",
63
+ }));
64
+
65
+ // Generate hydration code for each component
66
+ build.onLoad({ filter: /.*/, namespace: "island" }, (args) => {
67
+ const component = components.find((c) => c.id === args.path);
68
+ if (!component) {
69
+ return { contents: "", loader: "js" };
70
+ }
71
+
72
+ return {
73
+ contents: `import{render,createComponent}from"solid-js/web";import{deserialize}from"seroval";import C from"${component.path}";document.querySelectorAll('${component.selector}').forEach(e=>{e.innerHTML="";render(()=>createComponent(C,deserialize(e.dataset.props||"{}")),e)})`,
74
+ loader: "js",
75
+ };
76
+ });
77
+
78
+ // Transform TSX/JSX with Solid DOM mode
79
+ build.onLoad({ filter: /\.(tsx|jsx)$/ }, async ({ path }) => {
80
+ // Import with ? suffix to register file with bun --watch
81
+ // Issue: https://github.com/oven-sh/bun/issues/4689
82
+ const contents = await import(`${path}?`, {
83
+ with: { type: "text" },
84
+ });
85
+ return {
86
+ contents: await transform(contents.default, path, "dom"),
87
+ loader: "js",
88
+ };
89
+ });
90
+ },
91
+ },
92
+ ],
93
+ });
94
+
95
+ if (verbose) {
96
+ for (const c of components) {
97
+ const rel = relative(process.cwd(), c.path);
98
+ console.log(`${rel} -> ${outdir}/${c.id}.js`);
99
+ }
100
+ console.log(`Built ${files.length} component(s) to ${outdir}/`);
101
+ }
102
+
103
+ if (!result.success) {
104
+ console.error("Build failed:");
105
+ result.logs.forEach((m) => console.error(` ${m}`));
106
+ }
107
+ };
package/src/index.ts ADDED
@@ -0,0 +1,198 @@
1
+ import { renderToString } from "solid-js/web";
2
+ import type { JSX } from "solid-js";
3
+ import type { BunPlugin } from "bun";
4
+ import { transform } from "./transform";
5
+ import { buildIslands } from "./build";
6
+ import { join, dirname } from "path";
7
+
8
+ // ============================================================================
9
+ // Constants
10
+ // ============================================================================
11
+
12
+ /** Glob pattern for island/client component files */
13
+ const COMPONENT_PATTERN = "**/*.{island,client}.tsx";
14
+
15
+ // ============================================================================
16
+ // Types
17
+ // ============================================================================
18
+
19
+ export type SsrOptions<T extends object = object> = {
20
+ /** Enable dev mode (default: false) */
21
+ dev?: boolean;
22
+ /** Enable verbose logging (default: true in prod, false in dev) */
23
+ verbose?: boolean;
24
+ /** Enable auto page refresh in dev mode (default: true) */
25
+ autoRefresh?: boolean;
26
+ /** HTML template function (optional, has default) */
27
+ template?: (
28
+ ctx: {
29
+ body: string;
30
+ scripts: string;
31
+ } & T,
32
+ ) => string | Promise<string>;
33
+ };
34
+
35
+ export type SsrConfig = {
36
+ dev: boolean;
37
+ verbose?: boolean;
38
+ autoRefresh: boolean;
39
+ };
40
+
41
+ type HtmlFn<T extends object> = (
42
+ element: JSX.Element,
43
+ options?: T,
44
+ ) => Promise<Response>;
45
+
46
+ type PluginFn = () => BunPlugin;
47
+
48
+ export type SsrResult<T extends object> = {
49
+ config: SsrConfig;
50
+ plugin: PluginFn;
51
+ html: HtmlFn<T>;
52
+ };
53
+
54
+ // ============================================================================
55
+ // createConfig() - Create SSR configuration
56
+ // ============================================================================
57
+
58
+ /**
59
+ * Creates SSR configuration, html renderer, and build plugin.
60
+ *
61
+ * Components follow naming conventions:
62
+ * - `*.island.tsx` - SSR rendered + hydrated on client (interactive)
63
+ * - `*.client.tsx` - Client-only rendered (not SSR)
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * // config.ts
68
+ * import { createConfig } from "@valentinkolb/ssr";
69
+ *
70
+ * type PageOptions = { title?: string };
71
+ *
72
+ * export const { config, plugin, html } = createConfig<PageOptions>({
73
+ * dev: process.env.NODE_ENV === "development",
74
+ * template: ({ body, scripts, title }) => `
75
+ * <!DOCTYPE html>
76
+ * <html>
77
+ * <head><title>${title ?? "App"}</title></head>
78
+ * <body>${body}</body>
79
+ * ${scripts}
80
+ * </html>
81
+ * `,
82
+ * });
83
+ * ```
84
+ */
85
+ export const createConfig = <T extends object = object>(
86
+ options: SsrOptions<T> = {},
87
+ ): SsrResult<T> => {
88
+ const { dev = false, verbose, autoRefresh = true, template } = options;
89
+
90
+ // Default template if none provided
91
+ const htmlTemplate =
92
+ template ??
93
+ (({ body, scripts }) => `
94
+ <!DOCTYPE html>
95
+ <html>
96
+ <head>
97
+ <meta charset="utf-8">
98
+ <meta name="viewport" content="width=device-width, initial-scale=1">
99
+ </head>
100
+ <body>
101
+ ${body}
102
+ ${scripts}
103
+ </body>
104
+ </html>
105
+ `);
106
+
107
+ // Config object for routes adapters
108
+ const config: SsrConfig = {
109
+ dev,
110
+ verbose,
111
+ autoRefresh,
112
+ };
113
+
114
+ // HTML renderer
115
+ const html: HtmlFn<T> = async (element, opts = {} as T) => {
116
+ const body = renderToString(() => element);
117
+
118
+ // Extract island and client component IDs from rendered HTML
119
+ const islandIds = [
120
+ ...new Set(
121
+ [...body.matchAll(/<solid-(island|client) data-id="([^"]+)"/g)].map(
122
+ (m) => m[2],
123
+ ),
124
+ ),
125
+ ];
126
+
127
+ // Component scripts
128
+ let scripts = islandIds
129
+ .map((id) => `<script type="module" src="/_ssr/${id}.js"></script>`)
130
+ .join("\n");
131
+
132
+ // Add dev reload script in dev mode (if autoRefresh enabled)
133
+ if (dev && autoRefresh) {
134
+ scripts += `\n<script type="module" src="/_ssr/_client.js"></script>`;
135
+ }
136
+
137
+ const content = await htmlTemplate({
138
+ body,
139
+ scripts,
140
+ ...opts,
141
+ });
142
+
143
+ return new Response(content, {
144
+ headers: { "Content-Type": "text/html; charset=utf-8" },
145
+ });
146
+ };
147
+
148
+ // Build islands once per run
149
+ let islandsBuilt = false;
150
+
151
+ // Bun plugin for build/dev
152
+ const plugin: PluginFn = () => {
153
+ return {
154
+ name: "solid-ssr",
155
+ setup(build) {
156
+ // Determine output directory
157
+ const prodOutdir = build.config?.outdir;
158
+ const islandsOutdir = prodOutdir ? join(prodOutdir, "_ssr") : "_ssr";
159
+
160
+ const ensureIslands = async () => {
161
+ if (islandsBuilt) return;
162
+ islandsBuilt = true;
163
+ await buildIslands({
164
+ pattern: COMPONENT_PATTERN,
165
+ outdir: islandsOutdir,
166
+ verbose: verbose ?? !dev,
167
+ dev,
168
+ });
169
+ };
170
+
171
+ // Build islands on start (works for Bun.build)
172
+ build.onStart?.(ensureIslands);
173
+
174
+ // Handle .island and .client imports (without .tsx extension)
175
+ build.onResolve({ filter: /\.(island|client)$/ }, (args) => ({
176
+ path: args.path.startsWith(".")
177
+ ? join(dirname(args.importer), args.path + ".tsx")
178
+ : args.path + ".tsx",
179
+ }));
180
+
181
+ // Transform TSX/JSX files with Solid SSR
182
+ build.onLoad({ filter: /\.(tsx|jsx)$/ }, async ({ path }) => {
183
+ // Fallback for Bun.plugin (has no onStart)
184
+ await ensureIslands();
185
+ // Import with ? suffix to register file with bun --watch
186
+ // Issue: https://github.com/oven-sh/bun/issues/4689
187
+ const contents = await import(`${path}?`, { with: { type: "text" } });
188
+ return {
189
+ contents: await transform(contents.default, path, "ssr"),
190
+ loader: "js",
191
+ };
192
+ });
193
+ },
194
+ };
195
+ };
196
+
197
+ return { config, plugin, html };
198
+ };
@@ -0,0 +1,162 @@
1
+ import { transformAsync, types as t } from "@babel/core";
2
+ // @ts-ignore - no types are available for this package
3
+ import solidPreset from "babel-preset-solid";
4
+ // @ts-ignore - no types are available for this package
5
+ import tsPreset from "@babel/preset-typescript";
6
+ import { join, dirname } from "path";
7
+
8
+ // ============================================================================
9
+ // Helpers
10
+ // ============================================================================
11
+
12
+ export const hash = (s: string) =>
13
+ new Bun.CryptoHasher("md5").update(s).digest("hex").slice(0, 8);
14
+
15
+ // JSX AST helpers
16
+ const jsx = (tag: string, attrs: any[], children: any[] = []) =>
17
+ t.jsxElement(
18
+ t.jsxOpeningElement(t.jsxIdentifier(tag), attrs, children.length === 0),
19
+ children.length ? t.jsxClosingElement(t.jsxIdentifier(tag)) : null,
20
+ children,
21
+ children.length === 0,
22
+ );
23
+
24
+ const attr = (name: string, value: any) =>
25
+ t.jsxAttribute(
26
+ t.jsxIdentifier(name),
27
+ typeof value === "string"
28
+ ? t.stringLiteral(value)
29
+ : t.jsxExpressionContainer(value),
30
+ );
31
+
32
+ // ============================================================================
33
+ // Babel Plugin - Wraps island/client components
34
+ // ============================================================================
35
+
36
+ type ComponentType = "island" | "client";
37
+
38
+ const componentWrapperPlugin = (filename: string) => ({
39
+ visitor: {
40
+ Program(programPath: any) {
41
+ const componentImports = new Map<
42
+ string,
43
+ { path: string; type: ComponentType }
44
+ >();
45
+
46
+ // Inject seroval serialize helper at the top
47
+ programPath.node.body.unshift(
48
+ t.importDeclaration(
49
+ [
50
+ t.importSpecifier(
51
+ t.identifier("serialize"),
52
+ t.identifier("serialize"),
53
+ ),
54
+ ],
55
+ t.stringLiteral("seroval"),
56
+ ),
57
+ t.variableDeclaration("const", [
58
+ t.variableDeclarator(
59
+ t.identifier("__seroval_serialize"),
60
+ t.identifier("serialize"),
61
+ ),
62
+ ]),
63
+ );
64
+
65
+ programPath.traverse({
66
+ ImportDeclaration(path: any) {
67
+ const source: string = path.node.source.value;
68
+
69
+ let type: ComponentType | null = null;
70
+ if (source.includes(".island")) type = "island";
71
+ else if (source.includes(".client")) type = "client";
72
+ if (!type) return;
73
+
74
+ const spec = path.node.specifiers.find(
75
+ (s: any) => s.type === "ImportDefaultSpecifier",
76
+ );
77
+ if (!spec) return;
78
+
79
+ let absPath = source.startsWith(".")
80
+ ? join(dirname(filename), source)
81
+ : source;
82
+ if (!absPath.match(/\.(tsx|jsx|ts|js)$/)) absPath += ".tsx";
83
+
84
+ componentImports.set(spec.local.name, { path: absPath, type });
85
+ },
86
+
87
+ JSXElement(path: any) {
88
+ const name = path.node.openingElement.name.name;
89
+ const component = componentImports.get(name);
90
+ if (!component) return;
91
+
92
+ const id = hash(component.path);
93
+ const wrapperTag =
94
+ component.type === "island" ? "solid-island" : "solid-client";
95
+
96
+ const props = t.objectExpression(
97
+ path.node.openingElement.attributes
98
+ .filter((a: any) => a.type === "JSXAttribute")
99
+ .map((a: any) =>
100
+ t.objectProperty(
101
+ t.identifier(a.name.name),
102
+ a.value?.type === "JSXExpressionContainer"
103
+ ? a.value.expression
104
+ : a.value || t.booleanLiteral(true),
105
+ ),
106
+ ),
107
+ );
108
+
109
+ // For islands: wrap the component, for client: empty wrapper (no SSR)
110
+ const children = component.type === "island" ? [path.node] : [];
111
+
112
+ const wrapper = jsx(
113
+ wrapperTag,
114
+ [
115
+ attr("data-id", id),
116
+ attr(
117
+ "data-props",
118
+ t.callExpression(t.identifier("__seroval_serialize"), [props]),
119
+ ),
120
+ ],
121
+ children,
122
+ );
123
+
124
+ path.replaceWith(wrapper);
125
+ path.skip();
126
+ },
127
+ });
128
+ },
129
+ },
130
+ });
131
+
132
+ // ============================================================================
133
+ // Transform function
134
+ // ============================================================================
135
+
136
+ export const transform = async (
137
+ source: string,
138
+ filename: string,
139
+ mode: "ssr" | "dom",
140
+ ): Promise<string> => {
141
+ let code = source;
142
+
143
+ if (mode === "ssr") {
144
+ const result = await transformAsync(code, {
145
+ filename,
146
+ parserOpts: { plugins: ["jsx", "typescript"] },
147
+ plugins: [() => componentWrapperPlugin(filename)],
148
+ });
149
+ code = result?.code || code;
150
+ }
151
+
152
+ const result = await transformAsync(code, {
153
+ filename,
154
+ presets: [
155
+ [tsPreset, {}],
156
+ [solidPreset, { generate: mode, hydratable: false }],
157
+ ],
158
+ });
159
+
160
+ if (!result?.code) throw new Error(`Transform failed: ${filename}`);
161
+ return result.code;
162
+ };