@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 +21 -0
- package/README.md +290 -0
- package/package.json +73 -0
- package/src/adapter/bun.ts +105 -0
- package/src/adapter/client.js +75 -0
- package/src/adapter/elysia.ts +96 -0
- package/src/adapter/hono.ts +102 -0
- package/src/build.ts +107 -0
- package/src/index.ts +198 -0
- package/src/transform.ts +162 -0
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
|
+
};
|
package/src/transform.ts
ADDED
|
@@ -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
|
+
};
|