@valentinkolb/ssr 0.6.2 → 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 +132 -351
- package/package.json +1 -1
- package/src/adapter/bun.ts +1 -1
- package/src/adapter/elysia.ts +1 -1
- package/src/adapter/hono.ts +1 -1
- package/src/adapter/utils.ts +4 -3
- package/src/build.ts +28 -21
- package/src/index.ts +37 -8
- 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,106 @@
|
|
|
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
|
+
|
|
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
|
|
17
|
+
This framework is intentionally minimal and focused on SSR + islands only.
|
|
12
18
|
|
|
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 | — | — |
|
|
19
|
+
Current source size in this repo (`packages/ssr-core/src`):
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
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
|
-
|
|
35
|
+
Framework overhead in the browser is intentionally small.
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
What is intentionally not included:
|
|
36
38
|
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
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
|
-
|
|
44
|
+
Use the libraries you already prefer. This package only handles SSR and islands hydration.
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
## Features
|
|
45
47
|
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
```
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
##
|
|
80
|
+
## Quick Start (Hono)
|
|
116
81
|
|
|
117
|
-
###
|
|
82
|
+
### 1) Create config
|
|
118
83
|
|
|
119
|
-
```
|
|
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/
|
|
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",
|
|
165
98
|
template: ({ body, scripts, title, description }) => `
|
|
166
|
-
<!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
163
|
+
### 6) Run
|
|
214
164
|
|
|
215
|
-
```
|
|
216
|
-
|
|
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
|
-
|
|
169
|
+
## Adapter imports
|
|
231
170
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
171
|
+
- Bun: `@valentinkolb/ssr/bun`
|
|
172
|
+
- Hono: `@valentinkolb/ssr/hono`
|
|
173
|
+
- Elysia: `@valentinkolb/ssr/elysia`
|
|
174
|
+
|
|
175
|
+
## `createConfig` options
|
|
237
176
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
+
## Build for production
|
|
247
193
|
|
|
248
|
-
```
|
|
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
|
-
|
|
206
|
+
## Hono `createSSRHandler` behavior
|
|
261
207
|
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
216
|
+
With `dev: true`, a small `[ssr]` overlay is injected.
|
|
317
217
|
|
|
318
|
-
|
|
218
|
+
It can:
|
|
319
219
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
226
|
+
- islands must use default export
|
|
227
|
+
- props must be serializable (via `seroval`)
|
|
228
|
+
- nested island/client imports are not supported
|
|
421
229
|
|
|
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
|
|
230
|
+
## Local monorepo example
|
|
426
231
|
|
|
427
|
-
|
|
232
|
+
This repo includes a current example app:
|
|
428
233
|
|
|
429
|
-
|
|
234
|
+
- `packages/ssr-example`
|
|
430
235
|
|
|
431
|
-
|
|
236
|
+
Run from workspace root:
|
|
432
237
|
|
|
433
|
-
```
|
|
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
package/src/adapter/bun.ts
CHANGED
package/src/adapter/elysia.ts
CHANGED
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,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()
|
|
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 = (
|
|
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
|
|
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,10 +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;
|
|
25
|
+
external?: string[];
|
|
23
26
|
}): Promise<void> => {
|
|
24
|
-
const { pattern, outdir, verbose, dev = false } = options;
|
|
27
|
+
const { pattern, outdir, cwd, verbose, dev = false, external } = options;
|
|
28
|
+
const resolvedCwd = resolve(cwd);
|
|
25
29
|
|
|
26
30
|
const totalStart = performance.now();
|
|
27
31
|
|
|
@@ -29,7 +33,7 @@ export const buildIslands = async (options: {
|
|
|
29
33
|
|
|
30
34
|
const scanStart = performance.now();
|
|
31
35
|
for await (const file of new Glob(pattern).scan({
|
|
32
|
-
cwd:
|
|
36
|
+
cwd: resolvedCwd,
|
|
33
37
|
absolute: true,
|
|
34
38
|
})) {
|
|
35
39
|
files.push(file);
|
|
@@ -43,29 +47,31 @@ export const buildIslands = async (options: {
|
|
|
43
47
|
|
|
44
48
|
// Build component metadata
|
|
45
49
|
const components = files.map((componentPath) => {
|
|
46
|
-
const id =
|
|
50
|
+
const id = islandIdFromFile(componentPath, resolvedCwd);
|
|
51
|
+
const key = toStableKey(componentPath, resolvedCwd);
|
|
47
52
|
const type = getComponentType(componentPath);
|
|
48
53
|
const selector = getSelector(type, id);
|
|
49
|
-
return { path: componentPath, id, type, selector };
|
|
54
|
+
return { path: componentPath, id, key, type, selector };
|
|
50
55
|
});
|
|
51
56
|
|
|
52
|
-
//
|
|
53
|
-
const
|
|
57
|
+
// Detect hash collisions and fail fast with actionable diagnostics
|
|
58
|
+
const idMap = new Map<string, typeof components>();
|
|
54
59
|
for (const c of components) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
filenameMap.set(filename, []);
|
|
60
|
+
if (!idMap.has(c.id)) {
|
|
61
|
+
idMap.set(c.id, []);
|
|
58
62
|
}
|
|
59
|
-
|
|
63
|
+
idMap.get(c.id)!.push(c);
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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}`);
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
// Build all islands together with code splitting
|
|
@@ -77,6 +83,7 @@ export const buildIslands = async (options: {
|
|
|
77
83
|
outdir,
|
|
78
84
|
naming: { entry: "[name].js", chunk: "chunk-[hash].js" },
|
|
79
85
|
target: "browser",
|
|
86
|
+
external,
|
|
80
87
|
minify: !dev,
|
|
81
88
|
splitting: true,
|
|
82
89
|
sourcemap: dev ? "inline" : "none",
|
|
@@ -85,7 +92,7 @@ export const buildIslands = async (options: {
|
|
|
85
92
|
name: "solid-islands",
|
|
86
93
|
setup(build) {
|
|
87
94
|
// Resolve component IDs as virtual entrypoints
|
|
88
|
-
build.onResolve({ filter:
|
|
95
|
+
build.onResolve({ filter: new RegExp(`^[a-f0-9]{${ISLAND_ID_LENGTH}}$`) }, (args) => ({
|
|
89
96
|
path: args.path,
|
|
90
97
|
namespace: "island",
|
|
91
98
|
}));
|
|
@@ -171,7 +178,7 @@ export const buildIslands = async (options: {
|
|
|
171
178
|
if (verbose) {
|
|
172
179
|
console.log(`Bundle: ${fmt(performance.now() - bundleStart)}`);
|
|
173
180
|
for (const c of components) {
|
|
174
|
-
const rel = relative(
|
|
181
|
+
const rel = relative(resolvedCwd, c.path);
|
|
175
182
|
const t = transformTimings?.get(c.path);
|
|
176
183
|
console.log(` ${rel} -> ${outdir}/${c.id}.js${t != null ? ` (transform: ${fmt(t)})` : ""}`);
|
|
177
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,10 @@ 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;
|
|
49
|
+
/** Modules to exclude from the island bundle (passed to Bun.build) */
|
|
50
|
+
external?: string[];
|
|
32
51
|
/** HTML template function (optional, has default) */
|
|
33
52
|
template?: (
|
|
34
53
|
ctx: {
|
|
@@ -41,6 +60,7 @@ export type SsrOptions<T extends object = object> = {
|
|
|
41
60
|
export type SsrConfig = {
|
|
42
61
|
dev: boolean;
|
|
43
62
|
verbose?: boolean;
|
|
63
|
+
rootDir?: string;
|
|
44
64
|
};
|
|
45
65
|
|
|
46
66
|
export type HtmlFn<T extends object> = (element: JSX.Element, options?: T) => Promise<Response>;
|
|
@@ -85,7 +105,8 @@ export type SsrResult<T extends object> = {
|
|
|
85
105
|
* ```
|
|
86
106
|
*/
|
|
87
107
|
export const createConfig = <T extends object = object>(options: SsrOptions<T> = {}): SsrResult<T> => {
|
|
88
|
-
const { dev = false, verbose, template } = options;
|
|
108
|
+
const { dev = false, verbose, external, template, rootDir: rootDirOption } = options;
|
|
109
|
+
const rootDir = resolve(rootDirOption ?? process.cwd());
|
|
89
110
|
|
|
90
111
|
// Default template if none provided
|
|
91
112
|
const htmlTemplate =
|
|
@@ -108,10 +129,13 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
108
129
|
const config: SsrConfig = {
|
|
109
130
|
dev,
|
|
110
131
|
verbose,
|
|
132
|
+
rootDir,
|
|
111
133
|
};
|
|
112
134
|
|
|
135
|
+
const buildVersion = getBuildVersion(dev);
|
|
136
|
+
|
|
113
137
|
// Hydration script - dynamically loads island/client bundles based on DOM
|
|
114
|
-
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>`;
|
|
115
139
|
|
|
116
140
|
// HTML renderer
|
|
117
141
|
const html: HtmlFn<T> = async (element, opts = {} as T) => {
|
|
@@ -146,7 +170,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
146
170
|
setup(build) {
|
|
147
171
|
// Determine output directory
|
|
148
172
|
const prodOutdir = build.config?.outdir;
|
|
149
|
-
const islandsOutdir = prodOutdir ? join(prodOutdir, "_ssr") : "_ssr";
|
|
173
|
+
const islandsOutdir = prodOutdir ? join(prodOutdir, "_ssr") : join(rootDir, "_ssr");
|
|
150
174
|
|
|
151
175
|
const ensureIslands = async () => {
|
|
152
176
|
if (islandsBuilt) return;
|
|
@@ -154,8 +178,10 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
154
178
|
await buildIslands({
|
|
155
179
|
pattern: COMPONENT_PATTERN,
|
|
156
180
|
outdir: islandsOutdir,
|
|
181
|
+
cwd: rootDir,
|
|
157
182
|
verbose: verbose ?? !dev,
|
|
158
183
|
dev,
|
|
184
|
+
external,
|
|
159
185
|
});
|
|
160
186
|
};
|
|
161
187
|
|
|
@@ -163,9 +189,12 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
163
189
|
build.onStart?.(ensureIslands);
|
|
164
190
|
|
|
165
191
|
// Handle .island and .client imports (without .tsx extension)
|
|
166
|
-
build.onResolve({ filter: /\.(island|client)$/ }, (args) =>
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
});
|
|
169
198
|
|
|
170
199
|
// Transform TSX/JSX files with Solid SSR
|
|
171
200
|
build.onLoad({ filter: /\.(tsx|jsx)$/ }, async ({ path }) => {
|
|
@@ -175,7 +204,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
175
204
|
// Issue: https://github.com/oven-sh/bun/issues/4689
|
|
176
205
|
const contents = await import(`${path}?`, { with: { type: "text" } });
|
|
177
206
|
return {
|
|
178
|
-
contents: await transform(contents.default, path, "ssr", dev),
|
|
207
|
+
contents: await transform(contents.default, path, "ssr", dev, rootDir),
|
|
179
208
|
loader: "js",
|
|
180
209
|
};
|
|
181
210
|
});
|
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
|
}
|