@valentinkolb/ssr 0.7.0 → 0.7.3

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 CHANGED
@@ -1,171 +1,106 @@
1
- # @valentinkolb/SSR
1
+ # @valentinkolb/ssr
2
2
 
3
- A minimal server-side rendering framework for SolidJS and Bun with islands architecture.
3
+ Minimal SSR + islands framework for SolidJS on Bun.
4
4
 
5
5
  ## Overview
6
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.
7
+ This library renders Solid components on the server and hydrates only interactive islands on the client.
8
+
9
+ It uses file conventions:
10
+
11
+ - `*.island.tsx`: SSR + hydrated on the client
12
+ - `*.client.tsx`: client-only (no SSR HTML content)
13
+ - `*.tsx`: server-only output
8
14
 
9
15
  ## Size & Philosophy
10
16
 
11
- This framework is intentionally minimal. The entire codebase:
17
+ This framework is intentionally minimal and focused on SSR + islands only.
12
18
 
13
- | Component | Lines | Raw | Gzipped |
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 | — | — |
19
+ Current source size in this repo (`packages/ssr-core/src`):
18
20
 
19
- **Important:** These sizes reflect the framework source code, which runs at build-time and on the server only. The browser receives:
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)
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 |
24
26
 
25
- Minimal framework overhead in the client bundle.
27
+ Important: these sizes describe framework source code that runs at build time and on the server.
28
+ The browser receives only:
26
29
 
27
- **What's not included** (by design):
28
- - No client-side routing
29
- - No state management
30
- - No CSS-in-JS
31
- - No build tool abstractions
30
+ - your island bundles
31
+ - Solid runtime from your app dependencies
32
+ - `seroval` deserialize runtime
33
+ - a tiny hydration import snippet
32
34
 
33
- Use the libraries you already know. This framework just handles SSR and islands hydration.
35
+ Framework overhead in the browser is intentionally small.
34
36
 
35
- ## Features
37
+ What is intentionally not included:
36
38
 
37
- - **Islands architecture**: `*.island.tsx` for hydrated components, `*.client.tsx` for client-only
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
39
+ - no client-side router
40
+ - no state management layer
41
+ - no CSS-in-JS abstraction
42
+ - no build tool wrapper around Bun
41
43
 
42
- ## Example
44
+ Use the libraries you already prefer. This package only handles SSR and islands hydration.
43
45
 
44
- See [github.com/valentinkolb/ssr-example](https://github.com/valentinkolb/ssr-example) for a complete working example with all three adapters, including Tailwind CSS integration.
46
+ ## Features
45
47
 
46
- ## Installation
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
+ - Stable file-path-based island IDs (collision-safe across workspace packages)
53
+ - Production chunk cache busting (`/_ssr/*.js?v=<buildTimestamp>`)
47
54
 
48
- Core dependencies (always required):
55
+ ## Install
49
56
 
50
57
  ```bash
51
58
  bun add @valentinkolb/ssr solid-js
52
59
  bun add -d @babel/core @babel/preset-typescript babel-preset-solid
53
- ```
54
-
55
- Plus one adapter depending on your framework:
56
60
 
57
- ```bash
58
- # Bun native - no extra dependencies
59
-
60
- # Hono
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
- > **Note:** Dependencies like `solid-js`, `hono`, and `elysia` are peer dependencies. This lets you control the exact versions in your project and avoids version conflicts.
68
-
69
- ## Quick Start
70
-
71
- Create a configuration file (optional - has sensible defaults):
72
-
73
- ```typescript
74
- // config.ts
75
- import { createConfig } from "@valentinkolb/ssr";
76
-
77
- export const { config, plugin, html } = createConfig({
78
- dev: process.env.NODE_ENV === "development",
79
- });
80
- ```
81
-
82
- Create an interactive island component:
67
+ ## Required TypeScript settings
83
68
 
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
- ## Adapter Usage
80
+ ## Quick Start (Hono)
116
81
 
117
- ### Bun Native Server
82
+ ### 1) Create config
118
83
 
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
153
-
154
- The Hono adapter also exports `createSSRHandler`, a factory that creates a type-safe `ssr()` helper for page components. This eliminates boilerplate and integrates with Hono's middleware/validator system.
155
-
156
- ```typescript
84
+ ```ts
157
85
  // config.ts
158
86
  import { createConfig } from "@valentinkolb/ssr";
159
- import { createSSRHandler, routes } from "@valentinkolb/ssr/adapter/hono";
87
+ import { createSSRHandler, routes } from "@valentinkolb/ssr/hono";
160
88
 
161
- type PageOptions = { title?: string; description?: string };
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",
165
98
  template: ({ body, scripts, title, description }) => `
166
- <!DOCTYPE html>
99
+ <!doctype html>
167
100
  <html>
168
101
  <head>
102
+ <meta charset="utf-8" />
103
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
169
104
  <title>${title ?? "App"}</title>
170
105
  ${description ? `<meta name="description" content="${description}">` : ""}
171
106
  </head>
@@ -178,7 +113,28 @@ export const ssr = createSSRHandler(html);
178
113
  export { routes };
179
114
  ```
180
115
 
181
- Pages become async handler functions with access to the Hono context:
116
+ ### 2) Register plugin in dev
117
+
118
+ ```ts
119
+ // scripts/preload.ts
120
+ import { plugin } from "../config";
121
+
122
+ Bun.plugin(plugin());
123
+ ```
124
+
125
+ ### 3) Create an island
126
+
127
+ ```tsx
128
+ // components/Counter.island.tsx
129
+ import { createSignal } from "solid-js";
130
+
131
+ export default function Counter({ initial = 0 }: { initial?: number }) {
132
+ const [count, setCount] = createSignal(initial);
133
+ return <button onClick={() => setCount((c) => c + 1)}>Count: {count()}</button>;
134
+ }
135
+ ```
136
+
137
+ ### 4) Create a page
182
138
 
183
139
  ```tsx
184
140
  // pages/Home.tsx
@@ -187,65 +143,55 @@ import Counter from "../components/Counter.island";
187
143
 
188
144
  export default ssr(async (c) => {
189
145
  c.get("page").title = "Home";
190
-
191
- return (
192
- <div>
193
- <h1>Welcome</h1>
194
- <Counter start={5} />
195
- </div>
196
- );
146
+ return <Counter initial={5} />;
197
147
  });
198
148
  ```
199
149
 
200
- ```typescript
150
+ ### 5) Wire server
151
+
152
+ ```ts
201
153
  // server.ts
202
154
  import { Hono } from "hono";
203
155
  import { config, routes } from "./config";
204
156
  import Home from "./pages/Home";
205
157
 
206
- const app = new Hono()
158
+ export default new Hono()
207
159
  .route("/_ssr", routes(config))
208
160
  .get("/", ...Home);
209
-
210
- export default app;
211
161
  ```
212
162
 
213
- The `ssr()` helper also supports Hono middlewares and validators as leading arguments:
163
+ ### 6) Run
214
164
 
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
- );
165
+ ```bash
166
+ NODE_ENV=development bun --watch --preload=./scripts/preload.ts src/server.ts
228
167
  ```
229
168
 
230
- ### Elysia
169
+ ## Adapter imports
231
170
 
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";
171
+ - Bun: `@valentinkolb/ssr/bun`
172
+ - Hono: `@valentinkolb/ssr/hono`
173
+ - Elysia: `@valentinkolb/ssr/elysia`
174
+
175
+ ## `createConfig` options
237
176
 
238
- new Elysia()
239
- .use(routes(config))
240
- .get("/", () => html(<Home />))
241
- .listen(3000);
177
+ ```ts
178
+ createConfig({
179
+ dev?: boolean; // default: false
180
+ verbose?: boolean; // default: !dev
181
+ rootDir?: string; // default: process.cwd()
182
+ external?: string[]; // passed to Bun.build for island bundle
183
+ template?: ({ body, scripts, ...custom }) => string | Promise<string>;
184
+ })
242
185
  ```
243
186
 
244
- ## Build Configuration
187
+ ### Notes
188
+
189
+ - `rootDir` is important in monorepos where server entrypoint and island files live in different packages.
190
+ - In production, hydration imports include a build timestamp query (`?v=...`) for cache busting.
245
191
 
246
- Add the plugin to your build script:
192
+ ## Build for production
247
193
 
248
- ```typescript
194
+ ```ts
249
195
  // scripts/build.ts
250
196
  import { plugin } from "./config";
251
197
 
@@ -257,203 +203,38 @@ await Bun.build({
257
203
  });
258
204
  ```
259
205
 
260
- For development with watch mode:
206
+ ## Hono `createSSRHandler` behavior
261
207
 
262
- ```typescript
263
- // scripts/preload.ts
264
- import { plugin } from "./config";
265
-
266
- Bun.plugin(plugin());
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
- }
277
- ```
278
-
279
- ## Component Types
280
-
281
- ### Island Components (`*.island.tsx`)
282
-
283
- Island components are server-rendered and then hydrated on the client. They should be used for interactive UI elements that need JavaScript.
284
-
285
- ```tsx
286
- // Sidebar.island.tsx
287
- import { createSignal } from "solid-js";
288
-
289
- export default function Sidebar() {
290
- const [open, setOpen] = createSignal(false);
291
- return <div>{open() ? "Open" : "Closed"}</div>;
292
- }
293
- ```
294
-
295
- ### Client-Only Components (`*.client.tsx`)
208
+ `createSSRHandler(html)` returns an `ssr()` helper that:
296
209
 
297
- Client-only components are not rendered on the server. They render only in the browser, useful for components that depend on browser APIs.
210
+ - initializes `c.get("page")` as typed page options
211
+ - accepts middlewares/validators before final handler
212
+ - lets handlers return either JSX or `Response`
298
213
 
299
- ```tsx
300
- // ThemeToggle.client.tsx
301
- import { createSignal, onMount } from "solid-js";
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
- ```
214
+ ## Dev mode tools
315
215
 
316
- ### Regular Components
216
+ With `dev: true`, a small `[ssr]` overlay is injected.
317
217
 
318
- Standard Solid components that are only rendered on the server. No client-side JavaScript is shipped for these.
218
+ It can:
319
219
 
320
- ```tsx
321
- // Header.tsx
322
- export default function Header() {
323
- return <header><h1>My Site</h1></header>;
324
- }
325
- ```
326
-
327
- ## Props Serialization
328
-
329
- The framework uses [seroval](https://github.com/lxsmnsyc/seroval) for props serialization, which supports complex JavaScript types that JSON cannot handle:
330
-
331
- ```tsx
332
- <Island
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
- />
340
- ```
220
+ - auto-reload on server restart
221
+ - highlight island/client boundaries
222
+ - show source filenames for wrapped components
341
223
 
342
- ## Custom HTML Template
343
-
344
- You can pass additional options to your HTML template. All options are type safe!
345
-
346
- ```typescript
347
- type PageOptions = { title: string; description?: string };
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
- });
364
-
365
- // Usage
366
- await html(<Home />, {
367
- title: "Home Page", // type safe
368
- description: "Welcome to my site" // type safe
369
- });
370
- ```
371
-
372
- ## How It Works
373
-
374
- 1. **Build time**: The framework discovers all `*.island.tsx` and `*.client.tsx` files in the project and bundles them separately for the browser
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
377
-
378
- 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.
379
-
380
- Babel is used since Solid only supports Babel for JSX transformation at the moment.
381
-
382
- ## File Structure
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
- ```
406
-
407
- ## Dev Tools
408
-
409
- In dev mode, a small `[ssr]` badge appears in the corner of the page. Click it to open the dev tools panel where you can:
410
-
411
- - Toggle auto-reload on/off
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
224
+ ## Limitations
419
225
 
420
- Adapters just need to serve files from the `_ssr` directory. See `src/adapter/utils.ts` for shared helpers:
226
+ - islands must use default export
227
+ - props must be serializable (via `seroval`)
228
+ - nested island/client imports are not supported
421
229
 
422
- - `getSsrDir(dev)` - Returns path to `_ssr` folder
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
230
+ ## Local monorepo example
426
231
 
427
- Check the existing adapters (~30 lines each) for reference.
232
+ This repo includes a current example app:
428
233
 
429
- ## TypeScript Config
234
+ - `packages/ssr-example`
430
235
 
431
- Required tsconfig.json settings for SolidJS:
236
+ Run from workspace root:
432
237
 
433
- ```json
434
- {
435
- "compilerOptions": {
436
- "lib": ["ESNext", "DOM"],
437
- "jsx": "preserve",
438
- "jsxImportSource": "solid-js",
439
- "moduleResolution": "bundler"
440
- }
441
- }
238
+ ```bash
239
+ bun run dev:example
442
240
  ```
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.7.0",
3
+ "version": "0.7.3",
4
4
  "description": "Minimal SSR framework for SolidJS and Bun",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -30,7 +30,7 @@ type Routes = Record<string, RouteHandler>;
30
30
  */
31
31
  export const routes = (config: SsrConfig): Routes => {
32
32
  const { dev } = config;
33
- const ssrDir = getSsrDir(dev);
33
+ const ssrDir = getSsrDir(config);
34
34
 
35
35
  const devRoutes: Routes = dev
36
36
  ? {
@@ -26,7 +26,7 @@ import {
26
26
  */
27
27
  export const routes = (config: SsrConfig) => {
28
28
  const { dev } = config;
29
- const ssrDir = getSsrDir(dev);
29
+ const ssrDir = getSsrDir(config);
30
30
 
31
31
  return new Elysia({ name: "ssr" })
32
32
  .use(
@@ -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(dev);
163
+ const ssrDir = getSsrDir(config);
164
164
 
165
165
  const app = new Hono();
166
166
 
@@ -3,14 +3,15 @@
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";
6
7
 
7
8
  /**
8
9
  * Get the _ssr directory path based on dev/prod mode.
9
- * Dev: uses process.cwd() (project root)
10
+ * Dev: uses config.rootDir (fallback process.cwd())
10
11
  * Prod: uses dirname(Bun.main) (next to compiled binary)
11
12
  */
12
- export const getSsrDir = (dev: boolean): string =>
13
- join(dev ? process.cwd() : dirname(Bun.main), "_ssr");
13
+ export const getSsrDir = (config: SsrConfig): string =>
14
+ join(config.dev ? config.rootDir ?? process.cwd() : dirname(Bun.main), "_ssr");
14
15
 
15
16
  /**
16
17
  * 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, hash } from "./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
 
@@ -18,11 +19,13 @@ const fmt = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math
18
19
  export const buildIslands = async (options: {
19
20
  pattern: string;
20
21
  outdir: string;
22
+ cwd: string;
21
23
  verbose: boolean;
22
24
  dev?: boolean;
23
25
  external?: string[];
24
26
  }): Promise<void> => {
25
- const { pattern, outdir, verbose, dev = false, external } = options;
27
+ const { pattern, outdir, cwd, verbose, dev = false, external } = options;
28
+ const resolvedCwd = resolve(cwd);
26
29
 
27
30
  const totalStart = performance.now();
28
31
 
@@ -30,7 +33,7 @@ export const buildIslands = async (options: {
30
33
 
31
34
  const scanStart = performance.now();
32
35
  for await (const file of new Glob(pattern).scan({
33
- cwd: process.cwd(),
36
+ cwd: resolvedCwd,
34
37
  absolute: true,
35
38
  })) {
36
39
  files.push(file);
@@ -44,29 +47,31 @@ export const buildIslands = async (options: {
44
47
 
45
48
  // Build component metadata
46
49
  const components = files.map((componentPath) => {
47
- const id = hash(componentPath);
50
+ const id = islandIdFromFile(componentPath, resolvedCwd);
51
+ const key = toStableKey(componentPath, resolvedCwd);
48
52
  const type = getComponentType(componentPath);
49
53
  const selector = getSelector(type, id);
50
- return { path: componentPath, id, type, selector };
54
+ return { path: componentPath, id, key, type, selector };
51
55
  });
52
56
 
53
- // Check for duplicate filenames (same filename -> same hash -> collision)
54
- const filenameMap = new Map<string, string[]>();
57
+ // Detect hash collisions and fail fast with actionable diagnostics
58
+ const idMap = new Map<string, typeof components>();
55
59
  for (const c of components) {
56
- const filename = c.path.split("/").pop()!;
57
- if (!filenameMap.has(filename)) {
58
- filenameMap.set(filename, []);
60
+ if (!idMap.has(c.id)) {
61
+ idMap.set(c.id, []);
59
62
  }
60
- filenameMap.get(filename)!.push(c.path);
63
+ idMap.get(c.id)!.push(c);
61
64
  }
62
65
 
63
- for (const [filename, paths] of filenameMap) {
64
- if (paths.length > 1) {
65
- console.warn(`[ssr] Warning: Multiple files with the same name detected: ${filename}`);
66
- console.warn(" Files:");
67
- paths.forEach((p) => console.warn(` - ${relative(process.cwd(), p)}`));
68
- console.warn(" This will cause hash collisions. Consider renaming these files.");
69
- }
66
+ const collisions = [...idMap.entries()].filter(([, items]) => items.length > 1);
67
+ if (collisions.length > 0) {
68
+ const details = collisions
69
+ .map(([id, items]) => {
70
+ const list = items.map((item) => ` - ${item.key} (${relative(resolvedCwd, item.path)})`).join("\n");
71
+ return ` ID ${id}:\n${list}`;
72
+ })
73
+ .join("\n");
74
+ throw new Error(`[ssr] Island ID collision detected. Rename files or adjust roots.\n${details}`);
70
75
  }
71
76
 
72
77
  // Build all islands together with code splitting
@@ -87,7 +92,7 @@ export const buildIslands = async (options: {
87
92
  name: "solid-islands",
88
93
  setup(build) {
89
94
  // Resolve component IDs as virtual entrypoints
90
- build.onResolve({ filter: /^[a-f0-9]{8}$/ }, (args) => ({
95
+ build.onResolve({ filter: new RegExp(`^[a-f0-9]{${ISLAND_ID_LENGTH}}$`) }, (args) => ({
91
96
  path: args.path,
92
97
  namespace: "island",
93
98
  }));
@@ -173,7 +178,7 @@ export const buildIslands = async (options: {
173
178
  if (verbose) {
174
179
  console.log(`Bundle: ${fmt(performance.now() - bundleStart)}`);
175
180
  for (const c of components) {
176
- const rel = relative(process.cwd(), c.path);
181
+ const rel = relative(resolvedCwd, c.path);
177
182
  const t = transformTimings?.get(c.path);
178
183
  console.log(` ${rel} -> ${outdir}/${c.id}.js${t != null ? ` (transform: ${fmt(t)})` : ""}`);
179
184
  }
package/src/index.ts CHANGED
@@ -7,9 +7,11 @@
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";
13
15
  // @ts-ignore - Bun text import
14
16
  import devClientCode from "./adapter/client.js" with { type: "text" };
15
17
 
@@ -20,6 +22,19 @@ import devClientCode from "./adapter/client.js" with { type: "text" };
20
22
  /** Glob pattern for island/client component files */
21
23
  const COMPONENT_PATTERN = "**/*.{island,client}.tsx";
22
24
 
25
+ /**
26
+ * Build version used for cache busting island script imports in production.
27
+ * Uses server entrypoint mtime as a stable per-build version value.
28
+ */
29
+ const getBuildVersion = (dev: boolean): string => {
30
+ if (dev) return "";
31
+ try {
32
+ return String(Math.floor(statSync(Bun.main).mtimeMs));
33
+ } catch {
34
+ return String(Date.now());
35
+ }
36
+ };
37
+
23
38
  // ============================================================================
24
39
  // Types
25
40
  // ============================================================================
@@ -29,6 +44,8 @@ export type SsrOptions<T extends object = object> = {
29
44
  dev?: boolean;
30
45
  /** Enable verbose logging (default: true in prod, false in dev) */
31
46
  verbose?: boolean;
47
+ /** Project root for island discovery and dev _ssr assets (default: process.cwd()) */
48
+ rootDir?: string;
32
49
  /** Modules to exclude from the island bundle (passed to Bun.build) */
33
50
  external?: string[];
34
51
  /** HTML template function (optional, has default) */
@@ -43,6 +60,7 @@ export type SsrOptions<T extends object = object> = {
43
60
  export type SsrConfig = {
44
61
  dev: boolean;
45
62
  verbose?: boolean;
63
+ rootDir?: string;
46
64
  };
47
65
 
48
66
  export type HtmlFn<T extends object> = (element: JSX.Element, options?: T) => Promise<Response>;
@@ -87,7 +105,8 @@ export type SsrResult<T extends object> = {
87
105
  * ```
88
106
  */
89
107
  export const createConfig = <T extends object = object>(options: SsrOptions<T> = {}): SsrResult<T> => {
90
- const { dev = false, verbose, external, template } = options;
108
+ const { dev = false, verbose, external, template, rootDir: rootDirOption } = options;
109
+ const rootDir = resolve(rootDirOption ?? process.cwd());
91
110
 
92
111
  // Default template if none provided
93
112
  const htmlTemplate =
@@ -110,10 +129,13 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
110
129
  const config: SsrConfig = {
111
130
  dev,
112
131
  verbose,
132
+ rootDir,
113
133
  };
114
134
 
135
+ const buildVersion = getBuildVersion(dev);
136
+
115
137
  // 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('/_ssr/'+e.dataset.id+'.js'));</script>`;
138
+ const hydrationScript = `<script type="module">const v=${JSON.stringify(buildVersion)};document.querySelectorAll('solid-island,solid-client').forEach(e=>import('/_ssr/'+e.dataset.id+'.js'+(v?'?v='+v:'')));</script>`;
117
139
 
118
140
  // HTML renderer
119
141
  const html: HtmlFn<T> = async (element, opts = {} as T) => {
@@ -148,7 +170,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
148
170
  setup(build) {
149
171
  // Determine output directory
150
172
  const prodOutdir = build.config?.outdir;
151
- const islandsOutdir = prodOutdir ? join(prodOutdir, "_ssr") : "_ssr";
173
+ const islandsOutdir = prodOutdir ? join(prodOutdir, "_ssr") : join(rootDir, "_ssr");
152
174
 
153
175
  const ensureIslands = async () => {
154
176
  if (islandsBuilt) return;
@@ -156,6 +178,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
156
178
  await buildIslands({
157
179
  pattern: COMPONENT_PATTERN,
158
180
  outdir: islandsOutdir,
181
+ cwd: rootDir,
159
182
  verbose: verbose ?? !dev,
160
183
  dev,
161
184
  external,
@@ -166,9 +189,12 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
166
189
  build.onStart?.(ensureIslands);
167
190
 
168
191
  // Handle .island and .client imports (without .tsx extension)
169
- build.onResolve({ filter: /\.(island|client)$/ }, (args) => ({
170
- path: args.path.startsWith(".") ? join(dirname(args.importer), args.path + ".tsx") : args.path + ".tsx",
171
- }));
192
+ build.onResolve({ filter: /\.(island|client)$/ }, (args) => {
193
+ const resolveDir = args.resolveDir || (args.importer ? dirname(args.importer) : rootDir);
194
+ return {
195
+ path: resolveIslandImport(args.path, resolveDir, args.importer || undefined),
196
+ };
197
+ });
172
198
 
173
199
  // Transform TSX/JSX files with Solid SSR
174
200
  build.onLoad({ filter: /\.(tsx|jsx)$/ }, async ({ path }) => {
@@ -178,7 +204,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
178
204
  // Issue: https://github.com/oven-sh/bun/issues/4689
179
205
  const contents = await import(`${path}?`, { with: { type: "text" } });
180
206
  return {
181
- contents: await transform(contents.default, path, "ssr", dev),
207
+ contents: await transform(contents.default, path, "ssr", dev, rootDir),
182
208
  loader: "js",
183
209
  };
184
210
  });
@@ -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 { join, dirname } from "path";
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
- let absPath = source.startsWith(".") ? join(dirname(filename), source) : source;
81
- if (!absPath.match(/\.(tsx|jsx|ts|js)$/)) absPath += ".tsx";
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: absPath, type });
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
  }