@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 CHANGED
@@ -1,171 +1,108 @@
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
8
 
9
- ## Size & Philosophy
9
+ It uses file conventions:
10
10
 
11
- This framework is intentionally minimal. The entire codebase:
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
- | 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 | — | — |
15
+ ## Size & Philosophy
18
16
 
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)
17
+ This framework is intentionally minimal and focused on SSR + islands only.
24
18
 
25
- Minimal framework overhead in the client bundle.
19
+ Current source size in this repo (`packages/ssr-core/src`):
26
20
 
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
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
- Use the libraries you already know. This framework just handles SSR and islands hydration.
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
- ## Features
30
+ - your island bundles
31
+ - Solid runtime from your app dependencies
32
+ - `seroval` deserialize runtime
33
+ - a tiny hydration import snippet
36
34
 
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
35
+ Framework overhead in the browser is intentionally small.
41
36
 
42
- ## Example
37
+ What is intentionally not included:
43
38
 
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.
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
- ## Installation
44
+ Use the libraries you already prefer. This package only handles SSR and islands hydration.
47
45
 
48
- Core dependencies (always required):
46
+ ## Features
49
47
 
50
- ```bash
51
- bun add @valentinkolb/ssr solid-js
52
- bun add -d @babel/core @babel/preset-typescript babel-preset-solid
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
- Plus one adapter depending on your framework:
56
+ ## Install
56
57
 
57
58
  ```bash
58
- # Bun native - no extra dependencies
59
+ bun add @valentinkolb/ssr solid-js
59
60
 
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.
67
+ ## Required TypeScript settings
68
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:
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
- ## Adapter Usage
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
- 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.
82
+ ### 1) Create config
155
83
 
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",
98
+ // For microfrontends mounted under /docs:
99
+ // basePath: "/docs",
165
100
  template: ({ body, scripts, title, description }) => `
166
- <!DOCTYPE html>
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
- Pages become async handler functions with access to the Hono context:
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
- ```typescript
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
- const app = new Hono()
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
- For development with watch mode:
261
-
262
- ```typescript
263
- // scripts/preload.ts
264
- import { plugin } from "./config";
165
+ ### 6) Run
265
166
 
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
- }
167
+ ```bash
168
+ NODE_ENV=development bun --watch --preload=./scripts/preload.ts src/server.ts
277
169
  ```
278
170
 
279
- ## Component Types
280
-
281
- ### Island Components (`*.island.tsx`)
171
+ ## Adapter imports
282
172
 
283
- Island components are server-rendered and then hydrated on the client. They should be used for interactive UI elements that need JavaScript.
173
+ - Bun: `@valentinkolb/ssr/bun`
174
+ - Hono: `@valentinkolb/ssr/hono`
175
+ - Elysia: `@valentinkolb/ssr/elysia`
284
176
 
285
- ```tsx
286
- // Sidebar.island.tsx
287
- import { createSignal } from "solid-js";
177
+ ## `createConfig` options
288
178
 
289
- export default function Sidebar() {
290
- const [open, setOpen] = createSignal(false);
291
- return <div>{open() ? "Open" : "Closed"}</div>;
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
- ### Client-Only Components (`*.client.tsx`)
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
- ```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
- ```
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
- ### Regular Components
196
+ ## Microfrontend mount example
317
197
 
318
- Standard Solid components that are only rendered on the server. No client-side JavaScript is shipped for these.
198
+ Use `basePath` when the SSR app is mounted under a sub-path:
319
199
 
320
- ```tsx
321
- // Header.tsx
322
- export default function Header() {
323
- return <header><h1>My Site</h1></header>;
324
- }
325
- ```
326
-
327
- ## Props Serialization
200
+ ```ts
201
+ // config.ts
202
+ export const { config, html } = createConfig({
203
+ basePath: "/docs",
204
+ });
328
205
 
329
- The framework uses [seroval](https://github.com/lxsmnsyc/seroval) for props serialization, which supports complex JavaScript types that JSON cannot handle:
206
+ // docs-app.ts
207
+ const docsApp = new Hono()
208
+ .route("/_ssr", routes(config))
209
+ .get("/", () => html(<DocsHome />));
330
210
 
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
- />
211
+ // host-app.ts
212
+ export default new Hono().route("/docs", docsApp);
340
213
  ```
341
214
 
342
- ## Custom HTML Template
215
+ With this setup, hydration chunks and dev endpoints are served from `/docs/_ssr/...`.
343
216
 
344
- You can pass additional options to your HTML template. All options are type safe!
217
+ ## Build for production
345
218
 
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
- });
219
+ ```ts
220
+ // scripts/build.ts
221
+ import { plugin } from "./config";
364
222
 
365
- // Usage
366
- await html(<Home />, {
367
- title: "Home Page", // type safe
368
- description: "Welcome to my site" // type safe
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
- ## How It Works
231
+ ## Hono `createSSRHandler` behavior
373
232
 
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
233
+ `createSSRHandler(html)` returns an `ssr()` helper that:
377
234
 
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.
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
- Babel is used since Solid only supports Babel for JSX transformation at the moment.
239
+ ## Dev mode tools
381
240
 
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
- ```
241
+ With `dev: true`, a small `[ssr]` overlay is injected.
406
242
 
407
- ## Dev Tools
243
+ It can:
408
244
 
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:
245
+ - auto-reload on server restart
246
+ - highlight island/client boundaries
247
+ - show source filenames for wrapped components
410
248
 
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
249
+ ## Limitations
419
250
 
420
- Adapters just need to serve files from the `_ssr` directory. See `src/adapter/utils.ts` for shared helpers:
251
+ - islands must use default export
252
+ - props must be serializable (via `seroval`)
253
+ - nested island/client imports are not supported
421
254
 
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
255
+ ## Local monorepo example
426
256
 
427
- Check the existing adapters (~30 lines each) for reference.
257
+ This repo includes a current example app:
428
258
 
429
- ## TypeScript Config
259
+ - `packages/ssr-example`
430
260
 
431
- Required tsconfig.json settings for SolidJS:
261
+ Run from workspace root:
432
262
 
433
- ```json
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.7.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",
@@ -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(dev);
32
+ const { dev, ssrPath } = config;
33
+ const ssrDir = getSsrDir(config);
34
34
 
35
35
  const devRoutes: Routes = dev
36
36
  ? {
37
- "/_ssr/_reload": () => createReloadResponse(),
38
- "/_ssr/_ping": () => new Response("ok"),
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
- "/_ssr/*.js": async (req) => {
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();
@@ -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 = "_ssr";
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("/_ssr/_reload");
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("/_ssr/_ping")
196
+ fetch(`${ssrPath}/_ping`)
195
197
  .then((r) => r.ok && location.reload())
196
198
  .catch(() => {});
197
199
  }, 300);
@@ -25,17 +25,17 @@ import {
25
25
  * ```
26
26
  */
27
27
  export const routes = (config: SsrConfig) => {
28
- const { dev } = config;
29
- const ssrDir = getSsrDir(dev);
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: "/_ssr",
35
+ prefix: ssrPath,
36
36
  headers: { "Cache-Control": getCacheHeaders(dev) },
37
37
  }),
38
38
  )
39
- .get("/_ssr/_reload", () => (dev ? createReloadResponse() : notFound()))
40
- .get("/_ssr/_ping", () => (dev ? new Response("ok") : notFound()));
39
+ .get(`${ssrPath}/_reload`, () => (dev ? createReloadResponse() : notFound()))
40
+ .get(`${ssrPath}/_ping`, () => (dev ? new Response("ok") : notFound()));
41
41
  };
@@ -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,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() (project root)
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 = (dev: boolean): string =>
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, 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
 
@@ -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: process.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 = hash(componentPath);
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
- // Check for duplicate filenames (same filename -> same hash -> collision)
54
- const filenameMap = new Map<string, string[]>();
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
- const filename = c.path.split("/").pop()!;
57
- if (!filenameMap.has(filename)) {
58
- filenameMap.set(filename, []);
111
+ if (!idMap.has(c.id)) {
112
+ idMap.set(c.id, []);
59
113
  }
60
- filenameMap.get(filename)!.push(c.path);
114
+ idMap.get(c.id)!.push(c);
61
115
  }
62
116
 
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
- }
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: /^[a-f0-9]{8}$/ }, (args) => ({
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
- // Workaround for Bun bundler bug: duplicate export statements in shared chunks
129
- // when splitting: true. Scan chunk files and deduplicate export lines.
130
- if (result.success) {
131
- const chunkGlob = new Glob("chunk-*.js");
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(process.cwd(), c.path);
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('/_ssr/'+e.dataset.id+'.js'));</script>`;
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
- // Component scripts
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
- path: args.path.startsWith(".") ? join(dirname(args.importer), args.path + ".tsx") : args.path + ".tsx",
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
  });
@@ -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
  }