@valentinkolb/ssr 0.7.0 → 0.8.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/README.md +156 -350
- package/package.json +4 -4
- package/src/adapter/bun.ts +5 -5
- package/src/adapter/client.js +7 -5
- package/src/adapter/elysia.ts +5 -5
- package/src/adapter/hono.ts +1 -1
- package/src/adapter/utils.ts +25 -3
- package/src/build.ts +81 -64
- package/src/index.ts +50 -11
- package/src/island-id.ts +78 -0
- package/src/island-resolve.ts +39 -0
- package/src/transform.ts +14 -15
package/README.md
CHANGED
|
@@ -1,171 +1,108 @@
|
|
|
1
|
-
# @valentinkolb/
|
|
1
|
+
# @valentinkolb/ssr
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Minimal SSR + islands framework for SolidJS on Bun.
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
This
|
|
7
|
+
This library renders Solid components on the server and hydrates only interactive islands on the client.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
It uses file conventions:
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
- `*.island.tsx`: SSR + hydrated on the client
|
|
12
|
+
- `*.client.tsx`: client-only (no SSR HTML content)
|
|
13
|
+
- `*.tsx`: server-only output
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|-----------|-------|-----|---------|
|
|
15
|
-
| Core (index, transform, build) | ~490 | 15 KB | 4.7 KB |
|
|
16
|
-
| Client hydration (dev only) | ~200 | 6 KB | 2 KB |
|
|
17
|
-
| Adapters | ~50 each | — | — |
|
|
15
|
+
## Size & Philosophy
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
- Your island components
|
|
21
|
-
- SolidJS runtime (~7 KB gzipped)
|
|
22
|
-
- seroval's `deserialize` function (~2 KB gzipped)
|
|
23
|
-
- A tiny hydration snippet (~150 bytes per island)
|
|
17
|
+
This framework is intentionally minimal and focused on SSR + islands only.
|
|
24
18
|
|
|
25
|
-
|
|
19
|
+
Current source size in this repo (`packages/ssr-core/src`):
|
|
26
20
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
21
|
+
| Component | Lines | Raw | Gzipped |
|
|
22
|
+
| --- | ---: | ---: | ---: |
|
|
23
|
+
| Core (`index`, `transform`, `build`, island ID + resolver) | ~689 | 23.8 KB | 7.2 KB |
|
|
24
|
+
| Dev client (overlay + reload, dev only) | ~211 | 6.1 KB | 2.0 KB |
|
|
25
|
+
| Adapters (`bun`, `hono`, `elysia`, shared utils) | ~355 | 10.4 KB | 3.3 KB |
|
|
32
26
|
|
|
33
|
-
|
|
27
|
+
Important: these sizes describe framework source code that runs at build time and on the server.
|
|
28
|
+
The browser receives only:
|
|
34
29
|
|
|
35
|
-
|
|
30
|
+
- your island bundles
|
|
31
|
+
- Solid runtime from your app dependencies
|
|
32
|
+
- `seroval` deserialize runtime
|
|
33
|
+
- a tiny hydration import snippet
|
|
36
34
|
|
|
37
|
-
|
|
38
|
-
- **Framework agnostic**: Works with Bun's native server, **Elysia**, or **Hono** (easy to [write your own adapter](#writing-your-own-adapter))
|
|
39
|
-
- **Fast**: Built on Bun's runtime with optimized bundling
|
|
40
|
-
- **Dev experience**: Hot reload, source maps, and TypeScript support
|
|
35
|
+
Framework overhead in the browser is intentionally small.
|
|
41
36
|
|
|
42
|
-
|
|
37
|
+
What is intentionally not included:
|
|
43
38
|
|
|
44
|
-
|
|
39
|
+
- no client-side router
|
|
40
|
+
- no state management layer
|
|
41
|
+
- no CSS-in-JS abstraction
|
|
42
|
+
- no build tool wrapper around Bun
|
|
45
43
|
|
|
46
|
-
|
|
44
|
+
Use the libraries you already prefer. This package only handles SSR and islands hydration.
|
|
47
45
|
|
|
48
|
-
|
|
46
|
+
## Features
|
|
49
47
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
48
|
+
- Small SSR core with Bun-native build/plugin flow
|
|
49
|
+
- Adapters for Bun, Hono, and Elysia
|
|
50
|
+
- Type-safe Hono page helper via `createSSRHandler`
|
|
51
|
+
- Monorepo support via `rootDir`
|
|
52
|
+
- Public path mounting via `basePath` for microfrontends
|
|
53
|
+
- Stable file-path-based island IDs (collision-safe across workspace packages)
|
|
54
|
+
- Production chunk cache busting (`/_ssr/*.js?v=<buildTimestamp>`)
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
## Install
|
|
56
57
|
|
|
57
58
|
```bash
|
|
58
|
-
|
|
59
|
+
bun add @valentinkolb/ssr solid-js
|
|
59
60
|
|
|
60
|
-
#
|
|
61
|
+
# choose adapter deps you need
|
|
61
62
|
bun add hono
|
|
62
|
-
|
|
63
|
-
# Elysia
|
|
63
|
+
# or
|
|
64
64
|
bun add elysia @elysiajs/static
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
## Required TypeScript settings
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
export const { config, plugin, html } = createConfig({
|
|
78
|
-
dev: process.env.NODE_ENV === "development",
|
|
79
|
-
});
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
Create an interactive island component:
|
|
83
|
-
|
|
84
|
-
```tsx
|
|
85
|
-
// components/Counter.island.tsx
|
|
86
|
-
import { createSignal } from "solid-js";
|
|
87
|
-
|
|
88
|
-
export default function Counter({ initialCount = 0 }) {
|
|
89
|
-
const [count, setCount] = createSignal(initialCount);
|
|
90
|
-
|
|
91
|
-
return (
|
|
92
|
-
<button onClick={() => setCount(count() + 1)}>
|
|
93
|
-
Count: {count()}
|
|
94
|
-
</button>
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
Use it in a page:
|
|
100
|
-
|
|
101
|
-
```tsx
|
|
102
|
-
// pages/Home.tsx
|
|
103
|
-
import Counter from "../components/Counter.island";
|
|
104
|
-
|
|
105
|
-
export default function Home() {
|
|
106
|
-
return (
|
|
107
|
-
<div>
|
|
108
|
-
<h1>My Page</h1>
|
|
109
|
-
<Counter initialCount={5} />
|
|
110
|
-
</div>
|
|
111
|
-
);
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"compilerOptions": {
|
|
72
|
+
"lib": ["ESNext", "DOM"],
|
|
73
|
+
"jsx": "preserve",
|
|
74
|
+
"jsxImportSource": "solid-js",
|
|
75
|
+
"moduleResolution": "bundler"
|
|
76
|
+
}
|
|
112
77
|
}
|
|
113
78
|
```
|
|
114
79
|
|
|
115
|
-
##
|
|
116
|
-
|
|
117
|
-
### Bun Native Server
|
|
118
|
-
|
|
119
|
-
```typescript
|
|
120
|
-
import { Bun } from "bun";
|
|
121
|
-
import { routes } from "@valentinkolb/ssr/adapter/bun";
|
|
122
|
-
import { config, html } from "./config";
|
|
123
|
-
import Home from "./pages/Home";
|
|
124
|
-
|
|
125
|
-
Bun.serve({
|
|
126
|
-
port: 3000,
|
|
127
|
-
routes: {
|
|
128
|
-
...routes(config),
|
|
129
|
-
"/": () => html(<Home />),
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
### Hono
|
|
135
|
-
|
|
136
|
-
```typescript
|
|
137
|
-
import { Hono } from "hono";
|
|
138
|
-
import { routes } from "@valentinkolb/ssr/adapter/hono";
|
|
139
|
-
import { config, html } from "./config";
|
|
140
|
-
import Home from "./pages/Home";
|
|
141
|
-
|
|
142
|
-
const app = new Hono()
|
|
143
|
-
.route("/_ssr", routes(config))
|
|
144
|
-
.get("/", async (c) => {
|
|
145
|
-
const response = await html(<Home />);
|
|
146
|
-
return c.html(await response.text());
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
export default app;
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
#### Hono SSR Helper
|
|
80
|
+
## Quick Start (Hono)
|
|
153
81
|
|
|
154
|
-
|
|
82
|
+
### 1) Create config
|
|
155
83
|
|
|
156
|
-
```
|
|
84
|
+
```ts
|
|
157
85
|
// config.ts
|
|
158
86
|
import { createConfig } from "@valentinkolb/ssr";
|
|
159
|
-
import { createSSRHandler, routes } from "@valentinkolb/ssr/
|
|
87
|
+
import { createSSRHandler, routes } from "@valentinkolb/ssr/hono";
|
|
160
88
|
|
|
161
|
-
type PageOptions = {
|
|
89
|
+
type PageOptions = {
|
|
90
|
+
title?: string;
|
|
91
|
+
description?: string;
|
|
92
|
+
};
|
|
162
93
|
|
|
163
94
|
export const { config, plugin, html } = createConfig<PageOptions>({
|
|
164
95
|
dev: process.env.NODE_ENV === "development",
|
|
96
|
+
// For monorepos with separated packages:
|
|
97
|
+
// rootDir: "/path/to/workspace-root",
|
|
98
|
+
// For microfrontends mounted under /docs:
|
|
99
|
+
// basePath: "/docs",
|
|
165
100
|
template: ({ body, scripts, title, description }) => `
|
|
166
|
-
<!
|
|
101
|
+
<!doctype html>
|
|
167
102
|
<html>
|
|
168
103
|
<head>
|
|
104
|
+
<meta charset="utf-8" />
|
|
105
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
169
106
|
<title>${title ?? "App"}</title>
|
|
170
107
|
${description ? `<meta name="description" content="${description}">` : ""}
|
|
171
108
|
</head>
|
|
@@ -178,7 +115,28 @@ export const ssr = createSSRHandler(html);
|
|
|
178
115
|
export { routes };
|
|
179
116
|
```
|
|
180
117
|
|
|
181
|
-
|
|
118
|
+
### 2) Register plugin in dev
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// scripts/preload.ts
|
|
122
|
+
import { plugin } from "../config";
|
|
123
|
+
|
|
124
|
+
Bun.plugin(plugin());
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 3) Create an island
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
// components/Counter.island.tsx
|
|
131
|
+
import { createSignal } from "solid-js";
|
|
132
|
+
|
|
133
|
+
export default function Counter({ initial = 0 }: { initial?: number }) {
|
|
134
|
+
const [count, setCount] = createSignal(initial);
|
|
135
|
+
return <button onClick={() => setCount((c) => c + 1)}>Count: {count()}</button>;
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### 4) Create a page
|
|
182
140
|
|
|
183
141
|
```tsx
|
|
184
142
|
// pages/Home.tsx
|
|
@@ -187,273 +145,121 @@ import Counter from "../components/Counter.island";
|
|
|
187
145
|
|
|
188
146
|
export default ssr(async (c) => {
|
|
189
147
|
c.get("page").title = "Home";
|
|
190
|
-
|
|
191
|
-
return (
|
|
192
|
-
<div>
|
|
193
|
-
<h1>Welcome</h1>
|
|
194
|
-
<Counter start={5} />
|
|
195
|
-
</div>
|
|
196
|
-
);
|
|
148
|
+
return <Counter initial={5} />;
|
|
197
149
|
});
|
|
198
150
|
```
|
|
199
151
|
|
|
200
|
-
|
|
152
|
+
### 5) Wire server
|
|
153
|
+
|
|
154
|
+
```ts
|
|
201
155
|
// server.ts
|
|
202
156
|
import { Hono } from "hono";
|
|
203
157
|
import { config, routes } from "./config";
|
|
204
158
|
import Home from "./pages/Home";
|
|
205
159
|
|
|
206
|
-
|
|
160
|
+
export default new Hono()
|
|
207
161
|
.route("/_ssr", routes(config))
|
|
208
162
|
.get("/", ...Home);
|
|
209
|
-
|
|
210
|
-
export default app;
|
|
211
|
-
```
|
|
212
|
-
|
|
213
|
-
The `ssr()` helper also supports Hono middlewares and validators as leading arguments:
|
|
214
|
-
|
|
215
|
-
```tsx
|
|
216
|
-
import { ssr } from "../config";
|
|
217
|
-
import { zValidator } from "@hono/zod-validator";
|
|
218
|
-
import { z } from "zod";
|
|
219
|
-
|
|
220
|
-
export default ssr(
|
|
221
|
-
zValidator("param", z.object({ id: z.string() })),
|
|
222
|
-
async (c) => {
|
|
223
|
-
const { id } = c.req.valid("param");
|
|
224
|
-
c.get("page").title = `Item ${id}`;
|
|
225
|
-
return <ItemView id={id} />;
|
|
226
|
-
}
|
|
227
|
-
);
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
### Elysia
|
|
231
|
-
|
|
232
|
-
```typescript
|
|
233
|
-
import { Elysia } from "elysia";
|
|
234
|
-
import { routes } from "@valentinkolb/ssr/adapter/elysia";
|
|
235
|
-
import { config, html } from "./config";
|
|
236
|
-
import Home from "./pages/Home";
|
|
237
|
-
|
|
238
|
-
new Elysia()
|
|
239
|
-
.use(routes(config))
|
|
240
|
-
.get("/", () => html(<Home />))
|
|
241
|
-
.listen(3000);
|
|
242
|
-
```
|
|
243
|
-
|
|
244
|
-
## Build Configuration
|
|
245
|
-
|
|
246
|
-
Add the plugin to your build script:
|
|
247
|
-
|
|
248
|
-
```typescript
|
|
249
|
-
// scripts/build.ts
|
|
250
|
-
import { plugin } from "./config";
|
|
251
|
-
|
|
252
|
-
await Bun.build({
|
|
253
|
-
entrypoints: ["src/server.tsx"],
|
|
254
|
-
outdir: "dist",
|
|
255
|
-
target: "bun",
|
|
256
|
-
plugins: [plugin()],
|
|
257
|
-
});
|
|
258
163
|
```
|
|
259
164
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
```typescript
|
|
263
|
-
// scripts/preload.ts
|
|
264
|
-
import { plugin } from "./config";
|
|
165
|
+
### 6) Run
|
|
265
166
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
```json
|
|
270
|
-
{
|
|
271
|
-
"scripts": {
|
|
272
|
-
"dev": "bun --watch --preload=./scripts/preload.ts run src/server.tsx",
|
|
273
|
-
"build": "bun run scripts/build.ts",
|
|
274
|
-
"start": "bun run dist/server.js"
|
|
275
|
-
}
|
|
276
|
-
}
|
|
167
|
+
```bash
|
|
168
|
+
NODE_ENV=development bun --watch --preload=./scripts/preload.ts src/server.ts
|
|
277
169
|
```
|
|
278
170
|
|
|
279
|
-
##
|
|
280
|
-
|
|
281
|
-
### Island Components (`*.island.tsx`)
|
|
171
|
+
## Adapter imports
|
|
282
172
|
|
|
283
|
-
|
|
173
|
+
- Bun: `@valentinkolb/ssr/bun`
|
|
174
|
+
- Hono: `@valentinkolb/ssr/hono`
|
|
175
|
+
- Elysia: `@valentinkolb/ssr/elysia`
|
|
284
176
|
|
|
285
|
-
|
|
286
|
-
// Sidebar.island.tsx
|
|
287
|
-
import { createSignal } from "solid-js";
|
|
177
|
+
## `createConfig` options
|
|
288
178
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
179
|
+
```ts
|
|
180
|
+
createConfig({
|
|
181
|
+
dev?: boolean; // default: false
|
|
182
|
+
verbose?: boolean; // default: !dev
|
|
183
|
+
rootDir?: string; // default: process.cwd()
|
|
184
|
+
basePath?: string; // default: "", example: "/docs"
|
|
185
|
+
external?: string[]; // passed to Bun.build for island bundle
|
|
186
|
+
template?: ({ body, scripts, ...custom }) => string | Promise<string>;
|
|
187
|
+
})
|
|
293
188
|
```
|
|
294
189
|
|
|
295
|
-
###
|
|
296
|
-
|
|
297
|
-
Client-only components are not rendered on the server. They render only in the browser, useful for components that depend on browser APIs.
|
|
190
|
+
### Notes
|
|
298
191
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
export default function ThemeToggle() {
|
|
304
|
-
const [theme, setTheme] = createSignal("light");
|
|
305
|
-
|
|
306
|
-
onMount(() => {
|
|
307
|
-
setTheme(localStorage.getItem("theme") || "light");
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
return <button onClick={() => setTheme(theme() === "light" ? "dark" : "light")}>
|
|
311
|
-
{theme()}
|
|
312
|
-
</button>;
|
|
313
|
-
}
|
|
314
|
-
```
|
|
192
|
+
- `rootDir` is important in monorepos where server entrypoint and island files live in different packages.
|
|
193
|
+
- `basePath` moves SSR assets and dev endpoints under that prefix, e.g. `/docs/_ssr`.
|
|
194
|
+
- In production, hydration imports include a build timestamp query (`?v=...`) for cache busting.
|
|
315
195
|
|
|
316
|
-
|
|
196
|
+
## Microfrontend mount example
|
|
317
197
|
|
|
318
|
-
|
|
198
|
+
Use `basePath` when the SSR app is mounted under a sub-path:
|
|
319
199
|
|
|
320
|
-
```
|
|
321
|
-
//
|
|
322
|
-
export
|
|
323
|
-
|
|
324
|
-
}
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
## Props Serialization
|
|
200
|
+
```ts
|
|
201
|
+
// config.ts
|
|
202
|
+
export const { config, html } = createConfig({
|
|
203
|
+
basePath: "/docs",
|
|
204
|
+
});
|
|
328
205
|
|
|
329
|
-
|
|
206
|
+
// docs-app.ts
|
|
207
|
+
const docsApp = new Hono()
|
|
208
|
+
.route("/_ssr", routes(config))
|
|
209
|
+
.get("/", () => html(<DocsHome />));
|
|
330
210
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
date={new Date()}
|
|
334
|
-
map={new Map([["key", "value"]])}
|
|
335
|
-
set={new Set([1, 2, 3])}
|
|
336
|
-
regex={/test/gi}
|
|
337
|
-
bigint={123n}
|
|
338
|
-
undefined={undefined}
|
|
339
|
-
/>
|
|
211
|
+
// host-app.ts
|
|
212
|
+
export default new Hono().route("/docs", docsApp);
|
|
340
213
|
```
|
|
341
214
|
|
|
342
|
-
|
|
215
|
+
With this setup, hydration chunks and dev endpoints are served from `/docs/_ssr/...`.
|
|
343
216
|
|
|
344
|
-
|
|
217
|
+
## Build for production
|
|
345
218
|
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const { html } = createConfig<PageOptions>({
|
|
350
|
-
template: ({
|
|
351
|
-
body, scripts, // must be provided and used for hydration
|
|
352
|
-
title, description // user defined options
|
|
353
|
-
}) => `
|
|
354
|
-
<!DOCTYPE html>
|
|
355
|
-
<html>
|
|
356
|
-
<head>
|
|
357
|
-
<title>${title}</title>
|
|
358
|
-
${description ? `<meta name="description" content="${description}">` : ""}
|
|
359
|
-
</head>
|
|
360
|
-
<body>${body}${scripts}</body>
|
|
361
|
-
</html>
|
|
362
|
-
`,
|
|
363
|
-
});
|
|
219
|
+
```ts
|
|
220
|
+
// scripts/build.ts
|
|
221
|
+
import { plugin } from "./config";
|
|
364
222
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
223
|
+
await Bun.build({
|
|
224
|
+
entrypoints: ["src/server.tsx"],
|
|
225
|
+
outdir: "dist",
|
|
226
|
+
target: "bun",
|
|
227
|
+
plugins: [plugin()],
|
|
369
228
|
});
|
|
370
229
|
```
|
|
371
230
|
|
|
372
|
-
##
|
|
231
|
+
## Hono `createSSRHandler` behavior
|
|
373
232
|
|
|
374
|
-
|
|
375
|
-
2. **During SSR**: Normal components are rendered to HTML strings. Island/client components are wrapped in custom elements with data attributes containing their props
|
|
376
|
-
3. **At the client**: Individual island bundles load and hydrate their corresponding DOM elements
|
|
233
|
+
`createSSRHandler(html)` returns an `ssr()` helper that:
|
|
377
234
|
|
|
378
|
-
|
|
235
|
+
- initializes `c.get("page")` as typed page options
|
|
236
|
+
- accepts middlewares/validators before final handler
|
|
237
|
+
- lets handlers return either JSX or `Response`
|
|
379
238
|
|
|
380
|
-
|
|
239
|
+
## Dev mode tools
|
|
381
240
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
```
|
|
385
|
-
src/
|
|
386
|
-
├── index.ts # Core SSR logic and createConfig()
|
|
387
|
-
├── transform.ts # Babel plugin for island wrapping
|
|
388
|
-
├── build.ts # Island bundling with code splitting
|
|
389
|
-
└── adapter/
|
|
390
|
-
├── bun.ts # Bun.serve() adapter
|
|
391
|
-
├── elysia.ts # Elysia adapter
|
|
392
|
-
├── hono.ts # Hono adapter
|
|
393
|
-
├── client.js # Dev mode client (reload + dev tools)
|
|
394
|
-
└── utils.ts # Shared adapter utilities
|
|
395
|
-
```
|
|
396
|
-
|
|
397
|
-
## Configuration Options
|
|
398
|
-
|
|
399
|
-
```typescript
|
|
400
|
-
createConfig({
|
|
401
|
-
dev?: boolean; // Enable dev mode (default: false)
|
|
402
|
-
verbose?: boolean; // Enable verbose logging (default: !dev)
|
|
403
|
-
template?: (context) => string; // HTML template function (optional, has default)
|
|
404
|
-
})
|
|
405
|
-
```
|
|
241
|
+
With `dev: true`, a small `[ssr]` overlay is injected.
|
|
406
242
|
|
|
407
|
-
|
|
243
|
+
It can:
|
|
408
244
|
|
|
409
|
-
|
|
245
|
+
- auto-reload on server restart
|
|
246
|
+
- highlight island/client boundaries
|
|
247
|
+
- show source filenames for wrapped components
|
|
410
248
|
|
|
411
|
-
|
|
412
|
-
- Highlight island components (green border)
|
|
413
|
-
- Highlight client components (blue border)
|
|
414
|
-
- Move the panel to any corner
|
|
415
|
-
|
|
416
|
-
Settings are persisted in localStorage.
|
|
417
|
-
|
|
418
|
-
## Writing Your Own Adapter
|
|
249
|
+
## Limitations
|
|
419
250
|
|
|
420
|
-
|
|
251
|
+
- islands must use default export
|
|
252
|
+
- props must be serializable (via `seroval`)
|
|
253
|
+
- nested island/client imports are not supported
|
|
421
254
|
|
|
422
|
-
|
|
423
|
-
- `getCacheHeaders(dev)` - Cache headers (immutable in prod, no-cache in dev)
|
|
424
|
-
- `createReloadResponse()` - SSE stream for hot reload
|
|
425
|
-
- `safePath(base, filename)` - Prevents path traversal attacks
|
|
255
|
+
## Local monorepo example
|
|
426
256
|
|
|
427
|
-
|
|
257
|
+
This repo includes a current example app:
|
|
428
258
|
|
|
429
|
-
|
|
259
|
+
- `packages/ssr-example`
|
|
430
260
|
|
|
431
|
-
|
|
261
|
+
Run from workspace root:
|
|
432
262
|
|
|
433
|
-
```
|
|
434
|
-
|
|
435
|
-
"compilerOptions": {
|
|
436
|
-
"lib": ["ESNext", "DOM"],
|
|
437
|
-
"jsx": "preserve",
|
|
438
|
-
"jsxImportSource": "solid-js",
|
|
439
|
-
"moduleResolution": "bundler"
|
|
440
|
-
}
|
|
441
|
-
}
|
|
263
|
+
```bash
|
|
264
|
+
bun run dev:example
|
|
442
265
|
```
|
|
443
|
-
|
|
444
|
-
See the [example project](https://github.com/valentinkolb/ssr-example) for a full recommended config.
|
|
445
|
-
|
|
446
|
-
## Limitations
|
|
447
|
-
|
|
448
|
-
- **Islands must have default export**: `export default function MyIsland() {}`
|
|
449
|
-
- **Props must be serializable**: seroval supports Date, Map, Set, RegExp, BigInt, but not functions or class instances
|
|
450
|
-
- **No shared state between islands**: Each island hydrates independently. Use URL params, localStorage, or a global store for cross-island communication
|
|
451
|
-
- **No nested islands/clients**: An island cannot import another island or client component. This is not needed anyway - once a component is an island, its entire subtree is hydrated. Just use regular components inside islands.
|
|
452
|
-
|
|
453
|
-
## Contributing
|
|
454
|
-
|
|
455
|
-
Contributions are welcome! The codebase is intentionally minimal. Keep changes focused and avoid adding unnecessary complexity.
|
|
456
|
-
|
|
457
|
-
## License
|
|
458
|
-
|
|
459
|
-
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valentinkolb/ssr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Minimal SSR framework for SolidJS and Bun",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -32,15 +32,15 @@
|
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@babel/core": "^7.24.0",
|
|
36
|
+
"@babel/preset-typescript": "^7.24.0",
|
|
37
|
+
"babel-preset-solid": "^1.8.0",
|
|
35
38
|
"seroval": "^1.0.0"
|
|
36
39
|
},
|
|
37
40
|
"devDependencies": {
|
|
38
|
-
"@babel/core": "^7.24.0",
|
|
39
|
-
"@babel/preset-typescript": "^7.24.0",
|
|
40
41
|
"@elysiajs/static": "^1.2.0",
|
|
41
42
|
"@types/babel__core": "^7.20.5",
|
|
42
43
|
"@types/bun": "latest",
|
|
43
|
-
"babel-preset-solid": "^1.8.0",
|
|
44
44
|
"elysia": "^1.2.0",
|
|
45
45
|
"hono": "^4.6.14",
|
|
46
46
|
"solid-js": "^1.9.0",
|
package/src/adapter/bun.ts
CHANGED
|
@@ -29,20 +29,20 @@ type Routes = Record<string, RouteHandler>;
|
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
31
|
export const routes = (config: SsrConfig): Routes => {
|
|
32
|
-
const { dev } = config;
|
|
33
|
-
const ssrDir = getSsrDir(
|
|
32
|
+
const { dev, ssrPath } = config;
|
|
33
|
+
const ssrDir = getSsrDir(config);
|
|
34
34
|
|
|
35
35
|
const devRoutes: Routes = dev
|
|
36
36
|
? {
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
[`${ssrPath}/_reload`]: () => createReloadResponse(),
|
|
38
|
+
[`${ssrPath}/_ping`]: () => new Response("ok"),
|
|
39
39
|
}
|
|
40
40
|
: {};
|
|
41
41
|
|
|
42
42
|
return {
|
|
43
43
|
...devRoutes,
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
[`${ssrPath}/*.js`]: async (req) => {
|
|
46
46
|
const filename = new URL(req.url).pathname.split("/").pop()!;
|
|
47
47
|
const path = safePath(ssrDir, filename);
|
|
48
48
|
if (!path) return notFound();
|
package/src/adapter/client.js
CHANGED
|
@@ -3,8 +3,10 @@ if (!window.__ssr_reload) {
|
|
|
3
3
|
window.__ssr_reload = true;
|
|
4
4
|
|
|
5
5
|
(function () {
|
|
6
|
+
const ssrPath = globalThis.__SSR_CONFIG?.ssrPath || "/_ssr";
|
|
7
|
+
|
|
6
8
|
// Settings
|
|
7
|
-
const STORAGE_KEY =
|
|
9
|
+
const STORAGE_KEY = `_ssr:${ssrPath}`;
|
|
8
10
|
const defaults = {
|
|
9
11
|
autoReload: true,
|
|
10
12
|
highlightIslands: false,
|
|
@@ -24,9 +26,9 @@ if (!window.__ssr_reload) {
|
|
|
24
26
|
|
|
25
27
|
const highlightCSS = (tag, color) => `
|
|
26
28
|
${tag} {
|
|
27
|
-
display: block;
|
|
29
|
+
display: block !important;
|
|
28
30
|
box-shadow: 0 0 0 1px ${color} !important;
|
|
29
|
-
position: relative;
|
|
31
|
+
position: relative !important;
|
|
30
32
|
}
|
|
31
33
|
${tag}::before {
|
|
32
34
|
content: attr(data-file);
|
|
@@ -178,7 +180,7 @@ if (!window.__ssr_reload) {
|
|
|
178
180
|
const start = () => {
|
|
179
181
|
if (es) return;
|
|
180
182
|
try {
|
|
181
|
-
es = new EventSource(
|
|
183
|
+
es = new EventSource(`${ssrPath}/_reload`);
|
|
182
184
|
stopAnimation();
|
|
183
185
|
badge.innerText = "[ssr]";
|
|
184
186
|
} catch {
|
|
@@ -191,7 +193,7 @@ if (!window.__ssr_reload) {
|
|
|
191
193
|
startAnimation();
|
|
192
194
|
if (!settings.autoReload) return;
|
|
193
195
|
reconnectInterval = setInterval(() => {
|
|
194
|
-
fetch(
|
|
196
|
+
fetch(`${ssrPath}/_ping`)
|
|
195
197
|
.then((r) => r.ok && location.reload())
|
|
196
198
|
.catch(() => {});
|
|
197
199
|
}, 300);
|
package/src/adapter/elysia.ts
CHANGED
|
@@ -25,17 +25,17 @@ import {
|
|
|
25
25
|
* ```
|
|
26
26
|
*/
|
|
27
27
|
export const routes = (config: SsrConfig) => {
|
|
28
|
-
const { dev } = config;
|
|
29
|
-
const ssrDir = getSsrDir(
|
|
28
|
+
const { dev, ssrPath } = config;
|
|
29
|
+
const ssrDir = getSsrDir(config);
|
|
30
30
|
|
|
31
31
|
return new Elysia({ name: "ssr" })
|
|
32
32
|
.use(
|
|
33
33
|
staticPlugin({
|
|
34
34
|
assets: ssrDir,
|
|
35
|
-
prefix:
|
|
35
|
+
prefix: ssrPath,
|
|
36
36
|
headers: { "Cache-Control": getCacheHeaders(dev) },
|
|
37
37
|
}),
|
|
38
38
|
)
|
|
39
|
-
.get(
|
|
40
|
-
.get(
|
|
39
|
+
.get(`${ssrPath}/_reload`, () => (dev ? createReloadResponse() : notFound()))
|
|
40
|
+
.get(`${ssrPath}/_ping`, () => (dev ? new Response("ok") : notFound()));
|
|
41
41
|
};
|
package/src/adapter/hono.ts
CHANGED
|
@@ -160,7 +160,7 @@ export const createSSRHandler = <T extends object>(html: HtmlFn<T>) => {
|
|
|
160
160
|
*/
|
|
161
161
|
export const routes = (config: SsrConfig) => {
|
|
162
162
|
const { dev } = config;
|
|
163
|
-
const ssrDir = getSsrDir(
|
|
163
|
+
const ssrDir = getSsrDir(config);
|
|
164
164
|
|
|
165
165
|
const app = new Hono();
|
|
166
166
|
|
package/src/adapter/utils.ts
CHANGED
|
@@ -3,14 +3,36 @@
|
|
|
3
3
|
* SSE stream for hot reload, and security utilities.
|
|
4
4
|
*/
|
|
5
5
|
import { dirname, join, resolve } from "path";
|
|
6
|
+
import type { SsrConfig } from "../index";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Normalize a public app base path.
|
|
10
|
+
* - undefined, "", "/" => ""
|
|
11
|
+
* - "/docs/" => "/docs"
|
|
12
|
+
* - values without leading slash are rejected
|
|
13
|
+
*/
|
|
14
|
+
export const normalizeBasePath = (input?: string): string => {
|
|
15
|
+
const value = input?.trim() ?? "";
|
|
16
|
+
if (!value || value === "/") return "";
|
|
17
|
+
if (!value.startsWith("/")) {
|
|
18
|
+
throw new Error(`[ssr] basePath must start with "/" or be empty. Received: ${JSON.stringify(input)}`);
|
|
19
|
+
}
|
|
20
|
+
return value.replace(/\/+$/, "");
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Public HTTP path prefix used for SSR assets/endpoints.
|
|
25
|
+
*/
|
|
26
|
+
export const toSsrPath = (basePath: string): string =>
|
|
27
|
+
basePath ? `${basePath}/_ssr` : "/_ssr";
|
|
6
28
|
|
|
7
29
|
/**
|
|
8
30
|
* Get the _ssr directory path based on dev/prod mode.
|
|
9
|
-
* Dev: uses process.cwd()
|
|
31
|
+
* Dev: uses config.rootDir (fallback process.cwd())
|
|
10
32
|
* Prod: uses dirname(Bun.main) (next to compiled binary)
|
|
11
33
|
*/
|
|
12
|
-
export const getSsrDir = (
|
|
13
|
-
join(dev ? process.cwd() : dirname(Bun.main), "_ssr");
|
|
34
|
+
export const getSsrDir = (config: SsrConfig): string =>
|
|
35
|
+
join(config.dev ? config.rootDir ?? process.cwd() : dirname(Bun.main), "_ssr");
|
|
14
36
|
|
|
15
37
|
/**
|
|
16
38
|
* Cache headers for static assets
|
package/src/build.ts
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Island bundler - discovers *.island.tsx and *.client.tsx files,
|
|
3
3
|
* transforms them for the browser, and outputs chunks to _ssr directory.
|
|
4
4
|
*/
|
|
5
|
-
import { relative } from "path";
|
|
5
|
+
import { relative, resolve } from "path";
|
|
6
6
|
import { Glob } from "bun";
|
|
7
|
-
import { transform
|
|
7
|
+
import { transform } from "./transform";
|
|
8
|
+
import { ISLAND_ID_LENGTH, islandIdFromFile, toStableKey } from "./island-id";
|
|
8
9
|
|
|
9
10
|
type ComponentType = "island" | "client";
|
|
10
11
|
|
|
@@ -15,14 +16,67 @@ const getSelector = (type: ComponentType, id: string) =>
|
|
|
15
16
|
|
|
16
17
|
const fmt = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`);
|
|
17
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Workaround for Bun bundler bug: duplicate export statements in shared chunks
|
|
21
|
+
* when splitting=true. We only run this in production because rewriting chunk
|
|
22
|
+
* source in dev can affect evaluation order in cyclic-sensitive modules.
|
|
23
|
+
*/
|
|
24
|
+
export const dedupeSharedChunkExports = async (outdir: string, verbose: boolean): Promise<number> => {
|
|
25
|
+
let fixedChunks = 0;
|
|
26
|
+
const chunkGlob = new Glob("chunk-*.js");
|
|
27
|
+
for await (const chunkFile of chunkGlob.scan({ cwd: outdir, absolute: true })) {
|
|
28
|
+
const src = await Bun.file(chunkFile).text();
|
|
29
|
+
// Collect all export blocks and deduplicate.
|
|
30
|
+
const exportRegex = /^export\s*\{[^}]*\}\s*;?\s*$/gm;
|
|
31
|
+
const exportBlocks = [...src.matchAll(exportRegex)].map((m) => m[0]);
|
|
32
|
+
if (exportBlocks.length > 1) {
|
|
33
|
+
// Parse all exported names from all blocks.
|
|
34
|
+
const seenNames = new Set<string>();
|
|
35
|
+
const uniqueBlocks: string[] = [];
|
|
36
|
+
for (const block of exportBlocks) {
|
|
37
|
+
const names =
|
|
38
|
+
block
|
|
39
|
+
.match(/\{([^}]*)\}/)?.[1]
|
|
40
|
+
?.split(",")
|
|
41
|
+
.map((n) => n.trim())
|
|
42
|
+
.filter(Boolean) ?? [];
|
|
43
|
+
const newNames = names.filter((n) => !seenNames.has(n));
|
|
44
|
+
if (newNames.length > 0) {
|
|
45
|
+
uniqueBlocks.push(`export { ${newNames.join(", ")} };`);
|
|
46
|
+
newNames.forEach((n) => seenNames.add(n));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Replace all export blocks with deduplicated ones.
|
|
50
|
+
let fixed = src;
|
|
51
|
+
for (const block of exportBlocks) {
|
|
52
|
+
fixed = fixed.replace(block, "");
|
|
53
|
+
}
|
|
54
|
+
// Append single combined export before any debugId comment.
|
|
55
|
+
const debugIdIdx = fixed.lastIndexOf("//# debugId=");
|
|
56
|
+
const combined = uniqueBlocks.join("\n") + "\n";
|
|
57
|
+
if (debugIdIdx !== -1) {
|
|
58
|
+
fixed = fixed.slice(0, debugIdIdx) + combined + fixed.slice(debugIdIdx);
|
|
59
|
+
} else {
|
|
60
|
+
fixed = fixed.trimEnd() + "\n" + combined;
|
|
61
|
+
}
|
|
62
|
+
await Bun.write(chunkFile, fixed);
|
|
63
|
+
fixedChunks += 1;
|
|
64
|
+
if (verbose) console.log(` Fixed duplicate exports in ${chunkFile.split("/").pop()}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return fixedChunks;
|
|
68
|
+
};
|
|
69
|
+
|
|
18
70
|
export const buildIslands = async (options: {
|
|
19
71
|
pattern: string;
|
|
20
72
|
outdir: string;
|
|
73
|
+
cwd: string;
|
|
21
74
|
verbose: boolean;
|
|
22
75
|
dev?: boolean;
|
|
23
76
|
external?: string[];
|
|
24
77
|
}): Promise<void> => {
|
|
25
|
-
const { pattern, outdir, verbose, dev = false, external } = options;
|
|
78
|
+
const { pattern, outdir, cwd, verbose, dev = false, external } = options;
|
|
79
|
+
const resolvedCwd = resolve(cwd);
|
|
26
80
|
|
|
27
81
|
const totalStart = performance.now();
|
|
28
82
|
|
|
@@ -30,7 +84,7 @@ export const buildIslands = async (options: {
|
|
|
30
84
|
|
|
31
85
|
const scanStart = performance.now();
|
|
32
86
|
for await (const file of new Glob(pattern).scan({
|
|
33
|
-
cwd:
|
|
87
|
+
cwd: resolvedCwd,
|
|
34
88
|
absolute: true,
|
|
35
89
|
})) {
|
|
36
90
|
files.push(file);
|
|
@@ -44,29 +98,31 @@ export const buildIslands = async (options: {
|
|
|
44
98
|
|
|
45
99
|
// Build component metadata
|
|
46
100
|
const components = files.map((componentPath) => {
|
|
47
|
-
const id =
|
|
101
|
+
const id = islandIdFromFile(componentPath, resolvedCwd);
|
|
102
|
+
const key = toStableKey(componentPath, resolvedCwd);
|
|
48
103
|
const type = getComponentType(componentPath);
|
|
49
104
|
const selector = getSelector(type, id);
|
|
50
|
-
return { path: componentPath, id, type, selector };
|
|
105
|
+
return { path: componentPath, id, key, type, selector };
|
|
51
106
|
});
|
|
52
107
|
|
|
53
|
-
//
|
|
54
|
-
const
|
|
108
|
+
// Detect hash collisions and fail fast with actionable diagnostics
|
|
109
|
+
const idMap = new Map<string, typeof components>();
|
|
55
110
|
for (const c of components) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
filenameMap.set(filename, []);
|
|
111
|
+
if (!idMap.has(c.id)) {
|
|
112
|
+
idMap.set(c.id, []);
|
|
59
113
|
}
|
|
60
|
-
|
|
114
|
+
idMap.get(c.id)!.push(c);
|
|
61
115
|
}
|
|
62
116
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
117
|
+
const collisions = [...idMap.entries()].filter(([, items]) => items.length > 1);
|
|
118
|
+
if (collisions.length > 0) {
|
|
119
|
+
const details = collisions
|
|
120
|
+
.map(([id, items]) => {
|
|
121
|
+
const list = items.map((item) => ` - ${item.key} (${relative(resolvedCwd, item.path)})`).join("\n");
|
|
122
|
+
return ` ID ${id}:\n${list}`;
|
|
123
|
+
})
|
|
124
|
+
.join("\n");
|
|
125
|
+
throw new Error(`[ssr] Island ID collision detected. Rename files or adjust roots.\n${details}`);
|
|
70
126
|
}
|
|
71
127
|
|
|
72
128
|
// Build all islands together with code splitting
|
|
@@ -87,7 +143,7 @@ export const buildIslands = async (options: {
|
|
|
87
143
|
name: "solid-islands",
|
|
88
144
|
setup(build) {
|
|
89
145
|
// Resolve component IDs as virtual entrypoints
|
|
90
|
-
build.onResolve({ filter:
|
|
146
|
+
build.onResolve({ filter: new RegExp(`^[a-f0-9]{${ISLAND_ID_LENGTH}}$`) }, (args) => ({
|
|
91
147
|
path: args.path,
|
|
92
148
|
namespace: "island",
|
|
93
149
|
}));
|
|
@@ -125,55 +181,16 @@ export const buildIslands = async (options: {
|
|
|
125
181
|
],
|
|
126
182
|
});
|
|
127
183
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (result.success) {
|
|
131
|
-
|
|
132
|
-
for await (const chunkFile of chunkGlob.scan({ cwd: outdir, absolute: true })) {
|
|
133
|
-
const src = await Bun.file(chunkFile).text();
|
|
134
|
-
// Collect all export blocks and deduplicate
|
|
135
|
-
const exportRegex = /^export\s*\{[^}]*\}\s*;?\s*$/gm;
|
|
136
|
-
const exportBlocks = [...src.matchAll(exportRegex)].map((m) => m[0]);
|
|
137
|
-
if (exportBlocks.length > 1) {
|
|
138
|
-
// Parse all exported names from all blocks
|
|
139
|
-
const seenNames = new Set<string>();
|
|
140
|
-
const uniqueBlocks: string[] = [];
|
|
141
|
-
for (const block of exportBlocks) {
|
|
142
|
-
const names =
|
|
143
|
-
block
|
|
144
|
-
.match(/\{([^}]*)\}/)?.[1]
|
|
145
|
-
?.split(",")
|
|
146
|
-
.map((n) => n.trim())
|
|
147
|
-
.filter(Boolean) ?? [];
|
|
148
|
-
const newNames = names.filter((n) => !seenNames.has(n));
|
|
149
|
-
if (newNames.length > 0) {
|
|
150
|
-
uniqueBlocks.push(`export { ${newNames.join(", ")} };`);
|
|
151
|
-
newNames.forEach((n) => seenNames.add(n));
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
// Replace all export blocks with deduplicated ones
|
|
155
|
-
let fixed = src;
|
|
156
|
-
for (const block of exportBlocks) {
|
|
157
|
-
fixed = fixed.replace(block, "");
|
|
158
|
-
}
|
|
159
|
-
// Append single combined export before any debugId comment
|
|
160
|
-
const debugIdIdx = fixed.lastIndexOf("//# debugId=");
|
|
161
|
-
const combined = uniqueBlocks.join("\n") + "\n";
|
|
162
|
-
if (debugIdIdx !== -1) {
|
|
163
|
-
fixed = fixed.slice(0, debugIdIdx) + combined + fixed.slice(debugIdIdx);
|
|
164
|
-
} else {
|
|
165
|
-
fixed = fixed.trimEnd() + "\n" + combined;
|
|
166
|
-
}
|
|
167
|
-
await Bun.write(chunkFile, fixed);
|
|
168
|
-
if (verbose) console.log(` Fixed duplicate exports in ${chunkFile.split("/").pop()}`);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
184
|
+
if (result.success && !dev) {
|
|
185
|
+
await dedupeSharedChunkExports(outdir, verbose);
|
|
186
|
+
} else if (result.success && dev && verbose) {
|
|
187
|
+
console.log("Skipped shared-chunk export rewrite in dev mode.");
|
|
171
188
|
}
|
|
172
189
|
|
|
173
190
|
if (verbose) {
|
|
174
191
|
console.log(`Bundle: ${fmt(performance.now() - bundleStart)}`);
|
|
175
192
|
for (const c of components) {
|
|
176
|
-
const rel = relative(
|
|
193
|
+
const rel = relative(resolvedCwd, c.path);
|
|
177
194
|
const t = transformTimings?.get(c.path);
|
|
178
195
|
console.log(` ${rel} -> ${outdir}/${c.id}.js${t != null ? ` (transform: ${fmt(t)})` : ""}`);
|
|
179
196
|
}
|
package/src/index.ts
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
import { renderToString } from "solid-js/web";
|
|
8
8
|
import type { JSX } from "solid-js";
|
|
9
9
|
import type { BunPlugin } from "bun";
|
|
10
|
+
import { statSync } from "fs";
|
|
10
11
|
import { transform } from "./transform";
|
|
11
12
|
import { buildIslands } from "./build";
|
|
12
|
-
import { join, dirname } from "path";
|
|
13
|
+
import { join, dirname, resolve } from "path";
|
|
14
|
+
import { resolveIslandImport } from "./island-resolve";
|
|
15
|
+
import { normalizeBasePath, toSsrPath } from "./adapter/utils";
|
|
13
16
|
// @ts-ignore - Bun text import
|
|
14
17
|
import devClientCode from "./adapter/client.js" with { type: "text" };
|
|
15
18
|
|
|
@@ -20,6 +23,19 @@ import devClientCode from "./adapter/client.js" with { type: "text" };
|
|
|
20
23
|
/** Glob pattern for island/client component files */
|
|
21
24
|
const COMPONENT_PATTERN = "**/*.{island,client}.tsx";
|
|
22
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Build version used for cache busting island script imports in production.
|
|
28
|
+
* Uses server entrypoint mtime as a stable per-build version value.
|
|
29
|
+
*/
|
|
30
|
+
const getBuildVersion = (dev: boolean): string => {
|
|
31
|
+
if (dev) return "";
|
|
32
|
+
try {
|
|
33
|
+
return String(Math.floor(statSync(Bun.main).mtimeMs));
|
|
34
|
+
} catch {
|
|
35
|
+
return String(Date.now());
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
23
39
|
// ============================================================================
|
|
24
40
|
// Types
|
|
25
41
|
// ============================================================================
|
|
@@ -29,6 +45,10 @@ export type SsrOptions<T extends object = object> = {
|
|
|
29
45
|
dev?: boolean;
|
|
30
46
|
/** Enable verbose logging (default: true in prod, false in dev) */
|
|
31
47
|
verbose?: boolean;
|
|
48
|
+
/** Project root for island discovery and dev _ssr assets (default: process.cwd()) */
|
|
49
|
+
rootDir?: string;
|
|
50
|
+
/** Public app mount path for SSR assets and dev endpoints (default: "") */
|
|
51
|
+
basePath?: string;
|
|
32
52
|
/** Modules to exclude from the island bundle (passed to Bun.build) */
|
|
33
53
|
external?: string[];
|
|
34
54
|
/** HTML template function (optional, has default) */
|
|
@@ -43,6 +63,9 @@ export type SsrOptions<T extends object = object> = {
|
|
|
43
63
|
export type SsrConfig = {
|
|
44
64
|
dev: boolean;
|
|
45
65
|
verbose?: boolean;
|
|
66
|
+
rootDir?: string;
|
|
67
|
+
basePath: string;
|
|
68
|
+
ssrPath: string;
|
|
46
69
|
};
|
|
47
70
|
|
|
48
71
|
export type HtmlFn<T extends object> = (element: JSX.Element, options?: T) => Promise<Response>;
|
|
@@ -87,7 +110,10 @@ export type SsrResult<T extends object> = {
|
|
|
87
110
|
* ```
|
|
88
111
|
*/
|
|
89
112
|
export const createConfig = <T extends object = object>(options: SsrOptions<T> = {}): SsrResult<T> => {
|
|
90
|
-
const { dev = false, verbose, external, template } = options;
|
|
113
|
+
const { dev = false, verbose, external, template, rootDir: rootDirOption, basePath: basePathOption } = options;
|
|
114
|
+
const rootDir = resolve(rootDirOption ?? process.cwd());
|
|
115
|
+
const basePath = normalizeBasePath(basePathOption);
|
|
116
|
+
const ssrPath = toSsrPath(basePath);
|
|
91
117
|
|
|
92
118
|
// Default template if none provided
|
|
93
119
|
const htmlTemplate =
|
|
@@ -110,21 +136,30 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
110
136
|
const config: SsrConfig = {
|
|
111
137
|
dev,
|
|
112
138
|
verbose,
|
|
139
|
+
rootDir,
|
|
140
|
+
basePath,
|
|
141
|
+
ssrPath,
|
|
113
142
|
};
|
|
114
143
|
|
|
144
|
+
const buildVersion = getBuildVersion(dev);
|
|
145
|
+
|
|
146
|
+
const islandDisplayStyle =
|
|
147
|
+
"<style>solid-client,solid-island{display:contents}</style>";
|
|
148
|
+
|
|
115
149
|
// Hydration script - dynamically loads island/client bundles based on DOM
|
|
116
|
-
const hydrationScript = `<script type="module">document.querySelectorAll('solid-island,solid-client').forEach(e=>import('/
|
|
150
|
+
const hydrationScript = `<script type="module">const p=${JSON.stringify(ssrPath)};const v=${JSON.stringify(buildVersion)};document.querySelectorAll('solid-island,solid-client').forEach(e=>import(p+'/'+e.dataset.id+'.js'+(v?'?v='+v:'')));</script>`;
|
|
151
|
+
const devConfigScript = `<script>globalThis.__SSR_CONFIG=${JSON.stringify({ ssrPath })}</script>`;
|
|
117
152
|
|
|
118
153
|
// HTML renderer
|
|
119
154
|
const html: HtmlFn<T> = async (element, opts = {} as T) => {
|
|
120
155
|
const body = renderToString(() => element);
|
|
121
156
|
|
|
122
|
-
//
|
|
123
|
-
let scripts = hydrationScript
|
|
157
|
+
// Framework-injected assets
|
|
158
|
+
let scripts = `${islandDisplayStyle}\n${hydrationScript}`;
|
|
124
159
|
|
|
125
160
|
// Add dev tools script in dev mode (inlined)
|
|
126
161
|
if (dev) {
|
|
127
|
-
scripts += `\n<script type="module">${devClientCode}</script>`;
|
|
162
|
+
scripts += `\n${devConfigScript}\n<script type="module">${devClientCode}</script>`;
|
|
128
163
|
}
|
|
129
164
|
|
|
130
165
|
const content = await htmlTemplate({
|
|
@@ -148,7 +183,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
148
183
|
setup(build) {
|
|
149
184
|
// Determine output directory
|
|
150
185
|
const prodOutdir = build.config?.outdir;
|
|
151
|
-
const islandsOutdir = prodOutdir ? join(prodOutdir, "_ssr") : "_ssr";
|
|
186
|
+
const islandsOutdir = prodOutdir ? join(prodOutdir, "_ssr") : join(rootDir, "_ssr");
|
|
152
187
|
|
|
153
188
|
const ensureIslands = async () => {
|
|
154
189
|
if (islandsBuilt) return;
|
|
@@ -156,6 +191,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
156
191
|
await buildIslands({
|
|
157
192
|
pattern: COMPONENT_PATTERN,
|
|
158
193
|
outdir: islandsOutdir,
|
|
194
|
+
cwd: rootDir,
|
|
159
195
|
verbose: verbose ?? !dev,
|
|
160
196
|
dev,
|
|
161
197
|
external,
|
|
@@ -166,9 +202,12 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
166
202
|
build.onStart?.(ensureIslands);
|
|
167
203
|
|
|
168
204
|
// Handle .island and .client imports (without .tsx extension)
|
|
169
|
-
build.onResolve({ filter: /\.(island|client)$/ }, (args) =>
|
|
170
|
-
|
|
171
|
-
|
|
205
|
+
build.onResolve({ filter: /\.(island|client)$/ }, (args) => {
|
|
206
|
+
const resolveDir = args.resolveDir || (args.importer ? dirname(args.importer) : rootDir);
|
|
207
|
+
return {
|
|
208
|
+
path: resolveIslandImport(args.path, resolveDir, args.importer || undefined),
|
|
209
|
+
};
|
|
210
|
+
});
|
|
172
211
|
|
|
173
212
|
// Transform TSX/JSX files with Solid SSR
|
|
174
213
|
build.onLoad({ filter: /\.(tsx|jsx)$/ }, async ({ path }) => {
|
|
@@ -178,7 +217,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
178
217
|
// Issue: https://github.com/oven-sh/bun/issues/4689
|
|
179
218
|
const contents = await import(`${path}?`, { with: { type: "text" } });
|
|
180
219
|
return {
|
|
181
|
-
contents: await transform(contents.default, path, "ssr", dev),
|
|
220
|
+
contents: await transform(contents.default, path, "ssr", dev, rootDir),
|
|
182
221
|
loader: "js",
|
|
183
222
|
};
|
|
184
223
|
});
|
package/src/island-id.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable island ID generation based on canonical file paths.
|
|
3
|
+
* IDs are deterministic for file-based imports and robust in monorepos.
|
|
4
|
+
*/
|
|
5
|
+
import { realpathSync } from "fs";
|
|
6
|
+
import { isAbsolute, relative, resolve } from "path";
|
|
7
|
+
|
|
8
|
+
/** Length of hashed island IDs used in data-id and generated chunk filenames */
|
|
9
|
+
export const ISLAND_ID_LENGTH = 12;
|
|
10
|
+
|
|
11
|
+
const canonicalFileCache = new Map<string, string>();
|
|
12
|
+
const canonicalRootCache = new Map<string, string>();
|
|
13
|
+
|
|
14
|
+
const toPosixPath = (value: string): string => value.replace(/\\/g, "/");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Short deterministic hash helper used for island IDs.
|
|
18
|
+
*/
|
|
19
|
+
export const hash = (value: string): string =>
|
|
20
|
+
new Bun.CryptoHasher("md5").update(value).digest("hex").slice(0, ISLAND_ID_LENGTH);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Canonicalize file path for stable hashing.
|
|
24
|
+
* Falls back to resolve() when realpath cannot be resolved.
|
|
25
|
+
*/
|
|
26
|
+
export const canonicalFilePath = (file: string): string => {
|
|
27
|
+
const absolutePath = resolve(file);
|
|
28
|
+
const cached = canonicalFileCache.get(absolutePath);
|
|
29
|
+
if (cached) return cached;
|
|
30
|
+
|
|
31
|
+
let canonical = absolutePath;
|
|
32
|
+
try {
|
|
33
|
+
canonical = realpathSync(absolutePath);
|
|
34
|
+
} catch {
|
|
35
|
+
// Keep resolved absolute path when the file does not exist yet.
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const normalized = toPosixPath(canonical);
|
|
39
|
+
canonicalFileCache.set(absolutePath, normalized);
|
|
40
|
+
return normalized;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const canonicalRootPath = (rootDir: string): string => {
|
|
44
|
+
const absolutePath = resolve(rootDir);
|
|
45
|
+
const cached = canonicalRootCache.get(absolutePath);
|
|
46
|
+
if (cached) return cached;
|
|
47
|
+
|
|
48
|
+
let canonical = absolutePath;
|
|
49
|
+
try {
|
|
50
|
+
canonical = realpathSync(absolutePath);
|
|
51
|
+
} catch {
|
|
52
|
+
// Keep resolved absolute path when the directory does not exist yet.
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const normalized = toPosixPath(canonical);
|
|
56
|
+
canonicalRootCache.set(absolutePath, normalized);
|
|
57
|
+
return normalized;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Convert file path to a stable key:
|
|
62
|
+
* - relative to rootDir when inside rootDir
|
|
63
|
+
* - otherwise canonical absolute path
|
|
64
|
+
*/
|
|
65
|
+
export const toStableKey = (file: string, rootDir: string): string => {
|
|
66
|
+
const canonicalFile = canonicalFilePath(file);
|
|
67
|
+
const root = canonicalRootPath(rootDir);
|
|
68
|
+
const rel = toPosixPath(relative(root, canonicalFile));
|
|
69
|
+
const isWithinRoot = !isAbsolute(rel) && rel !== ".." && !rel.startsWith("../");
|
|
70
|
+
|
|
71
|
+
return isWithinRoot ? rel : canonicalFile;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build final stable island ID from canonical path key.
|
|
76
|
+
*/
|
|
77
|
+
export const islandIdFromFile = (file: string, rootDir: string): string =>
|
|
78
|
+
hash(toStableKey(file, rootDir));
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { dirname, isAbsolute, resolve } from "path";
|
|
2
|
+
|
|
3
|
+
const hasScriptExtension = (value: string): boolean => /\.(tsx|jsx|ts|js)$/.test(value);
|
|
4
|
+
|
|
5
|
+
export const withDefaultIslandExtension = (specifier: string): string =>
|
|
6
|
+
hasScriptExtension(specifier) ? specifier : `${specifier}.tsx`;
|
|
7
|
+
|
|
8
|
+
const formatResolveError = (specifier: string, resolveDir: string, importer?: string, reason?: string): Error => {
|
|
9
|
+
const importerHint = importer ? ` from "${importer}"` : "";
|
|
10
|
+
const detail = reason ? `\nReason: ${reason}` : "";
|
|
11
|
+
return new Error(
|
|
12
|
+
`[ssr] Failed to resolve island/client import "${specifier}"${importerHint} using resolveDir "${resolveDir}".` +
|
|
13
|
+
`\nCheck your tsconfig paths/baseUrl (for aliases) or use a relative/absolute file import.${detail}`,
|
|
14
|
+
);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const resolveIslandImport = (specifier: string, resolveDir: string, importer?: string): string => {
|
|
18
|
+
const normalizedSpecifier = withDefaultIslandExtension(specifier);
|
|
19
|
+
const normalizedResolveDir = resolveDir || (importer ? dirname(importer) : process.cwd());
|
|
20
|
+
|
|
21
|
+
if (specifier.startsWith(".")) {
|
|
22
|
+
return resolve(normalizedResolveDir, normalizedSpecifier);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (isAbsolute(specifier)) {
|
|
26
|
+
return resolve(normalizedSpecifier);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const resolved = Bun.resolveSync(normalizedSpecifier, normalizedResolveDir);
|
|
31
|
+
if (!isAbsolute(resolved)) {
|
|
32
|
+
throw formatResolveError(specifier, normalizedResolveDir, importer, `resolved to non-absolute path "${resolved}"`);
|
|
33
|
+
}
|
|
34
|
+
return resolved;
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
37
|
+
throw formatResolveError(specifier, normalizedResolveDir, importer, reason);
|
|
38
|
+
}
|
|
39
|
+
};
|
package/src/transform.ts
CHANGED
|
@@ -7,18 +7,16 @@ import { transformAsync, types as t } from "@babel/core";
|
|
|
7
7
|
import solidPreset from "babel-preset-solid";
|
|
8
8
|
// @ts-ignore - no types are available for this package
|
|
9
9
|
import tsPreset from "@babel/preset-typescript";
|
|
10
|
-
import {
|
|
10
|
+
import { dirname } from "path";
|
|
11
|
+
import { islandIdFromFile } from "./island-id";
|
|
12
|
+
import { resolveIslandImport } from "./island-resolve";
|
|
13
|
+
|
|
14
|
+
export { hash } from "./island-id";
|
|
11
15
|
|
|
12
16
|
// ============================================================================
|
|
13
17
|
// Helpers
|
|
14
18
|
// ============================================================================
|
|
15
19
|
|
|
16
|
-
export const hash = (s: string) => {
|
|
17
|
-
// Extract filename from path to ensure consistent hashing regardless of import aliases
|
|
18
|
-
const filename = s.split("/").pop() || s;
|
|
19
|
-
return new Bun.CryptoHasher("md5").update(filename).digest("hex").slice(0, 8);
|
|
20
|
-
};
|
|
21
|
-
|
|
22
20
|
// JSX AST helpers
|
|
23
21
|
const jsx = (tag: string, attrs: any[], children: any[] = []) =>
|
|
24
22
|
t.jsxElement(
|
|
@@ -43,13 +41,13 @@ type ComponentType = "island" | "client";
|
|
|
43
41
|
const getComponentType = (path: string): ComponentType | null =>
|
|
44
42
|
path.includes(".island") ? "island" : path.includes(".client") ? "client" : null;
|
|
45
43
|
|
|
46
|
-
const componentWrapperPlugin = (filename: string, dev: boolean) => {
|
|
44
|
+
const componentWrapperPlugin = (filename: string, rootDir: string, dev: boolean) => {
|
|
47
45
|
const parentType = getComponentType(filename);
|
|
48
46
|
|
|
49
47
|
return {
|
|
50
48
|
visitor: {
|
|
51
49
|
Program(programPath: any) {
|
|
52
|
-
const componentImports = new Map<string, { path: string; type: ComponentType }>();
|
|
50
|
+
const componentImports = new Map<string, { path: string; type: ComponentType; id: string }>();
|
|
53
51
|
|
|
54
52
|
// Inject seroval serialize helper at the top
|
|
55
53
|
programPath.node.body.unshift(
|
|
@@ -77,10 +75,11 @@ const componentWrapperPlugin = (filename: string, dev: boolean) => {
|
|
|
77
75
|
const spec = path.node.specifiers.find((s: any) => s.type === "ImportDefaultSpecifier");
|
|
78
76
|
if (!spec) return;
|
|
79
77
|
|
|
80
|
-
|
|
81
|
-
|
|
78
|
+
const resolvedPath = resolveIslandImport(source, dirname(filename), filename);
|
|
79
|
+
const componentPath = resolvedPath;
|
|
80
|
+
const id = islandIdFromFile(resolvedPath, rootDir);
|
|
82
81
|
|
|
83
|
-
componentImports.set(spec.local.name, { path:
|
|
82
|
+
componentImports.set(spec.local.name, { path: componentPath, type, id });
|
|
84
83
|
},
|
|
85
84
|
|
|
86
85
|
JSXElement(path: any) {
|
|
@@ -88,7 +87,6 @@ const componentWrapperPlugin = (filename: string, dev: boolean) => {
|
|
|
88
87
|
const component = componentImports.get(name);
|
|
89
88
|
if (!component) return;
|
|
90
89
|
|
|
91
|
-
const id = hash(component.path);
|
|
92
90
|
const wrapperTag = component.type === "island" ? "solid-island" : "solid-client";
|
|
93
91
|
|
|
94
92
|
const props = t.objectExpression(
|
|
@@ -109,7 +107,7 @@ const componentWrapperPlugin = (filename: string, dev: boolean) => {
|
|
|
109
107
|
const file = component.path.split("/").pop() || "";
|
|
110
108
|
|
|
111
109
|
const attrs = [
|
|
112
|
-
attr("data-id", id),
|
|
110
|
+
attr("data-id", component.id),
|
|
113
111
|
attr("data-props", t.callExpression(t.identifier("__seroval_serialize"), [props])),
|
|
114
112
|
];
|
|
115
113
|
|
|
@@ -138,6 +136,7 @@ export const transform = async (
|
|
|
138
136
|
filename: string,
|
|
139
137
|
mode: "ssr" | "dom",
|
|
140
138
|
dev: boolean = false,
|
|
139
|
+
rootDir: string = process.cwd(),
|
|
141
140
|
): Promise<string> => {
|
|
142
141
|
let code = source;
|
|
143
142
|
|
|
@@ -145,7 +144,7 @@ export const transform = async (
|
|
|
145
144
|
const result = await transformAsync(code, {
|
|
146
145
|
filename,
|
|
147
146
|
parserOpts: { plugins: ["jsx", "typescript"] },
|
|
148
|
-
plugins: [() => componentWrapperPlugin(filename, dev)],
|
|
147
|
+
plugins: [() => componentWrapperPlugin(filename, rootDir, dev)],
|
|
149
148
|
});
|
|
150
149
|
code = result?.code || code;
|
|
151
150
|
}
|