@valentinkolb/ssr 0.7.3 → 0.9.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 +67 -3
- package/package.json +4 -4
- package/src/adapter/bun.ts +5 -5
- package/src/adapter/client.js +7 -5
- package/src/adapter/elysia.ts +5 -5
- package/src/adapter/hono.ts +16 -10
- package/src/adapter/utils.ts +21 -0
- package/src/build.ts +55 -43
- package/src/index.ts +27 -8
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ Use the libraries you already prefer. This package only handles SSR and islands
|
|
|
49
49
|
- Adapters for Bun, Hono, and Elysia
|
|
50
50
|
- Type-safe Hono page helper via `createSSRHandler`
|
|
51
51
|
- Monorepo support via `rootDir`
|
|
52
|
+
- Public path mounting via `basePath` for microfrontends
|
|
52
53
|
- Stable file-path-based island IDs (collision-safe across workspace packages)
|
|
53
54
|
- Production chunk cache busting (`/_ssr/*.js?v=<buildTimestamp>`)
|
|
54
55
|
|
|
@@ -56,7 +57,6 @@ Use the libraries you already prefer. This package only handles SSR and islands
|
|
|
56
57
|
|
|
57
58
|
```bash
|
|
58
59
|
bun add @valentinkolb/ssr solid-js
|
|
59
|
-
bun add -d @babel/core @babel/preset-typescript babel-preset-solid
|
|
60
60
|
|
|
61
61
|
# choose adapter deps you need
|
|
62
62
|
bun add hono
|
|
@@ -95,6 +95,8 @@ export const { config, plugin, html } = createConfig<PageOptions>({
|
|
|
95
95
|
dev: process.env.NODE_ENV === "development",
|
|
96
96
|
// For monorepos with separated packages:
|
|
97
97
|
// rootDir: "/path/to/workspace-root",
|
|
98
|
+
// For microfrontends mounted under /docs:
|
|
99
|
+
// basePath: "/docs",
|
|
98
100
|
template: ({ body, scripts, title, description }) => `
|
|
99
101
|
<!doctype html>
|
|
100
102
|
<html>
|
|
@@ -143,7 +145,7 @@ import Counter from "../components/Counter.island";
|
|
|
143
145
|
|
|
144
146
|
export default ssr(async (c) => {
|
|
145
147
|
c.get("page").title = "Home";
|
|
146
|
-
return <Counter initial={5} />;
|
|
148
|
+
return () => <Counter initial={5} />;
|
|
147
149
|
});
|
|
148
150
|
```
|
|
149
151
|
|
|
@@ -172,6 +174,45 @@ NODE_ENV=development bun --watch --preload=./scripts/preload.ts src/server.ts
|
|
|
172
174
|
- Hono: `@valentinkolb/ssr/hono`
|
|
173
175
|
- Elysia: `@valentinkolb/ssr/elysia`
|
|
174
176
|
|
|
177
|
+
## Rendering API
|
|
178
|
+
|
|
179
|
+
`html()` and Hono `ssr()` handlers expect a synchronous render function:
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
export default ssr(async (c) => {
|
|
183
|
+
const data = await loadData();
|
|
184
|
+
c.get("page").title = data.title;
|
|
185
|
+
|
|
186
|
+
return () => <Page data={data} />;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
app.get("/", () => html(() => <Page />));
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Do async work in the handler before returning the render function. Do not make the render function itself `async`; Solid SSR expects synchronous JSX evaluation.
|
|
193
|
+
|
|
194
|
+
### v0.9.0 migration
|
|
195
|
+
|
|
196
|
+
This is a breaking change in v0.9.0. In v0.8.x and earlier, examples often returned already-created JSX:
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
// v0.8.x and earlier
|
|
200
|
+
export default ssr(async () => <Page />);
|
|
201
|
+
|
|
202
|
+
app.get("/", () => html(<Page />));
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
In v0.9.0, wrap JSX creation in a render function:
|
|
206
|
+
|
|
207
|
+
```tsx
|
|
208
|
+
// v0.9.0+
|
|
209
|
+
export default ssr(async () => () => <Page />);
|
|
210
|
+
|
|
211
|
+
app.get("/", () => html(() => <Page />));
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
This ensures Solid primitives such as `createUniqueId()` run inside `renderToString()`, where the SSR context exists.
|
|
215
|
+
|
|
175
216
|
## `createConfig` options
|
|
176
217
|
|
|
177
218
|
```ts
|
|
@@ -179,6 +220,7 @@ createConfig({
|
|
|
179
220
|
dev?: boolean; // default: false
|
|
180
221
|
verbose?: boolean; // default: !dev
|
|
181
222
|
rootDir?: string; // default: process.cwd()
|
|
223
|
+
basePath?: string; // default: "", example: "/docs"
|
|
182
224
|
external?: string[]; // passed to Bun.build for island bundle
|
|
183
225
|
template?: ({ body, scripts, ...custom }) => string | Promise<string>;
|
|
184
226
|
})
|
|
@@ -187,8 +229,30 @@ createConfig({
|
|
|
187
229
|
### Notes
|
|
188
230
|
|
|
189
231
|
- `rootDir` is important in monorepos where server entrypoint and island files live in different packages.
|
|
232
|
+
- `basePath` moves SSR assets and dev endpoints under that prefix, e.g. `/docs/_ssr`.
|
|
190
233
|
- In production, hydration imports include a build timestamp query (`?v=...`) for cache busting.
|
|
191
234
|
|
|
235
|
+
## Microfrontend mount example
|
|
236
|
+
|
|
237
|
+
Use `basePath` when the SSR app is mounted under a sub-path:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
// config.ts
|
|
241
|
+
export const { config, html } = createConfig({
|
|
242
|
+
basePath: "/docs",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// docs-app.ts
|
|
246
|
+
const docsApp = new Hono()
|
|
247
|
+
.route("/_ssr", routes(config))
|
|
248
|
+
.get("/", () => html(() => <DocsHome />));
|
|
249
|
+
|
|
250
|
+
// host-app.ts
|
|
251
|
+
export default new Hono().route("/docs", docsApp);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
With this setup, hydration chunks and dev endpoints are served from `/docs/_ssr/...`.
|
|
255
|
+
|
|
192
256
|
## Build for production
|
|
193
257
|
|
|
194
258
|
```ts
|
|
@@ -209,7 +273,7 @@ await Bun.build({
|
|
|
209
273
|
|
|
210
274
|
- initializes `c.get("page")` as typed page options
|
|
211
275
|
- accepts middlewares/validators before final handler
|
|
212
|
-
- lets handlers return either
|
|
276
|
+
- lets handlers return either a synchronous render function or `Response`
|
|
213
277
|
|
|
214
278
|
## Dev mode tools
|
|
215
279
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valentinkolb/ssr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Minimal SSR framework for SolidJS and Bun",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -32,15 +32,15 @@
|
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@babel/core": "^7.24.0",
|
|
36
|
+
"@babel/preset-typescript": "^7.24.0",
|
|
37
|
+
"babel-preset-solid": "^1.8.0",
|
|
35
38
|
"seroval": "^1.0.0"
|
|
36
39
|
},
|
|
37
40
|
"devDependencies": {
|
|
38
|
-
"@babel/core": "^7.24.0",
|
|
39
|
-
"@babel/preset-typescript": "^7.24.0",
|
|
40
41
|
"@elysiajs/static": "^1.2.0",
|
|
41
42
|
"@types/babel__core": "^7.20.5",
|
|
42
43
|
"@types/bun": "latest",
|
|
43
|
-
"babel-preset-solid": "^1.8.0",
|
|
44
44
|
"elysia": "^1.2.0",
|
|
45
45
|
"hono": "^4.6.14",
|
|
46
46
|
"solid-js": "^1.9.0",
|
package/src/adapter/bun.ts
CHANGED
|
@@ -23,26 +23,26 @@ type Routes = Record<string, RouteHandler>;
|
|
|
23
23
|
* serve({
|
|
24
24
|
* routes: {
|
|
25
25
|
* ...routes(config),
|
|
26
|
-
* "/": () => html(<Home />),
|
|
26
|
+
* "/": () => html(() => <Home />),
|
|
27
27
|
* },
|
|
28
28
|
* });
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
31
|
export const routes = (config: SsrConfig): Routes => {
|
|
32
|
-
const { dev } = config;
|
|
32
|
+
const { dev, ssrPath } = config;
|
|
33
33
|
const ssrDir = getSsrDir(config);
|
|
34
34
|
|
|
35
35
|
const devRoutes: Routes = dev
|
|
36
36
|
? {
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
[`${ssrPath}/_reload`]: () => createReloadResponse(),
|
|
38
|
+
[`${ssrPath}/_ping`]: () => new Response("ok"),
|
|
39
39
|
}
|
|
40
40
|
: {};
|
|
41
41
|
|
|
42
42
|
return {
|
|
43
43
|
...devRoutes,
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
[`${ssrPath}/*.js`]: async (req) => {
|
|
46
46
|
const filename = new URL(req.url).pathname.split("/").pop()!;
|
|
47
47
|
const path = safePath(ssrDir, filename);
|
|
48
48
|
if (!path) return notFound();
|
package/src/adapter/client.js
CHANGED
|
@@ -3,8 +3,10 @@ if (!window.__ssr_reload) {
|
|
|
3
3
|
window.__ssr_reload = true;
|
|
4
4
|
|
|
5
5
|
(function () {
|
|
6
|
+
const ssrPath = globalThis.__SSR_CONFIG?.ssrPath || "/_ssr";
|
|
7
|
+
|
|
6
8
|
// Settings
|
|
7
|
-
const STORAGE_KEY =
|
|
9
|
+
const STORAGE_KEY = `_ssr:${ssrPath}`;
|
|
8
10
|
const defaults = {
|
|
9
11
|
autoReload: true,
|
|
10
12
|
highlightIslands: false,
|
|
@@ -24,9 +26,9 @@ if (!window.__ssr_reload) {
|
|
|
24
26
|
|
|
25
27
|
const highlightCSS = (tag, color) => `
|
|
26
28
|
${tag} {
|
|
27
|
-
display: block;
|
|
29
|
+
display: block !important;
|
|
28
30
|
box-shadow: 0 0 0 1px ${color} !important;
|
|
29
|
-
position: relative;
|
|
31
|
+
position: relative !important;
|
|
30
32
|
}
|
|
31
33
|
${tag}::before {
|
|
32
34
|
content: attr(data-file);
|
|
@@ -178,7 +180,7 @@ if (!window.__ssr_reload) {
|
|
|
178
180
|
const start = () => {
|
|
179
181
|
if (es) return;
|
|
180
182
|
try {
|
|
181
|
-
es = new EventSource(
|
|
183
|
+
es = new EventSource(`${ssrPath}/_reload`);
|
|
182
184
|
stopAnimation();
|
|
183
185
|
badge.innerText = "[ssr]";
|
|
184
186
|
} catch {
|
|
@@ -191,7 +193,7 @@ if (!window.__ssr_reload) {
|
|
|
191
193
|
startAnimation();
|
|
192
194
|
if (!settings.autoReload) return;
|
|
193
195
|
reconnectInterval = setInterval(() => {
|
|
194
|
-
fetch(
|
|
196
|
+
fetch(`${ssrPath}/_ping`)
|
|
195
197
|
.then((r) => r.ok && location.reload())
|
|
196
198
|
.catch(() => {});
|
|
197
199
|
}, 300);
|
package/src/adapter/elysia.ts
CHANGED
|
@@ -20,22 +20,22 @@ import {
|
|
|
20
20
|
* import { routes } from "@valentinkolb/ssr/adapter/elysia";
|
|
21
21
|
* new Elysia()
|
|
22
22
|
* .use(routes(config))
|
|
23
|
-
* .get("/", () => html(<Home />))
|
|
23
|
+
* .get("/", () => html(() => <Home />))
|
|
24
24
|
* .listen(3000);
|
|
25
25
|
* ```
|
|
26
26
|
*/
|
|
27
27
|
export const routes = (config: SsrConfig) => {
|
|
28
|
-
const { dev } = config;
|
|
28
|
+
const { dev, ssrPath } = config;
|
|
29
29
|
const ssrDir = getSsrDir(config);
|
|
30
30
|
|
|
31
31
|
return new Elysia({ name: "ssr" })
|
|
32
32
|
.use(
|
|
33
33
|
staticPlugin({
|
|
34
34
|
assets: ssrDir,
|
|
35
|
-
prefix:
|
|
35
|
+
prefix: ssrPath,
|
|
36
36
|
headers: { "Cache-Control": getCacheHeaders(dev) },
|
|
37
37
|
}),
|
|
38
38
|
)
|
|
39
|
-
.get(
|
|
40
|
-
.get(
|
|
39
|
+
.get(`${ssrPath}/_reload`, () => (dev ? createReloadResponse() : notFound()))
|
|
40
|
+
.get(`${ssrPath}/_ping`, () => (dev ? new Response("ok") : notFound()));
|
|
41
41
|
};
|
package/src/adapter/hono.ts
CHANGED
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
import { Hono } from "hono";
|
|
6
6
|
import { createFactory } from "hono/factory";
|
|
7
7
|
import type { Context, Env, Handler, MiddlewareHandler, TypedResponse } from "hono";
|
|
8
|
-
import type {
|
|
9
|
-
import type { SsrConfig, HtmlFn } from "../index";
|
|
8
|
+
import type { SsrConfig, HtmlFn, RenderFn } from "../index";
|
|
10
9
|
import { getSsrDir, getCacheHeaders, createReloadResponse, safePath } from "./utils";
|
|
11
10
|
|
|
12
11
|
// ============================================================================
|
|
@@ -20,8 +19,8 @@ type PageEnv<T extends object> = {
|
|
|
20
19
|
};
|
|
21
20
|
};
|
|
22
21
|
|
|
23
|
-
/** SSR handler return type -
|
|
24
|
-
type SsrHandlerResult =
|
|
22
|
+
/** SSR handler return type - render function or Response (for redirects etc.) */
|
|
23
|
+
type SsrHandlerResult = RenderFn | Response | TypedResponse;
|
|
25
24
|
|
|
26
25
|
/** SSR handler function signature */
|
|
27
26
|
type SsrHandler<E extends Env, T extends object> = (
|
|
@@ -41,7 +40,7 @@ type SsrHandlers = [MiddlewareHandler, ...MiddlewareHandler[], Handler];
|
|
|
41
40
|
* Features:
|
|
42
41
|
* - Full compatibility with Hono middlewares/validators
|
|
43
42
|
* - Type-safe page options via `c.get("page")`
|
|
44
|
-
* - Return
|
|
43
|
+
* - Return a render function or Response for redirects
|
|
45
44
|
* - No manual `html()` call needed
|
|
46
45
|
*
|
|
47
46
|
* @example
|
|
@@ -88,7 +87,7 @@ type SsrHandlers = [MiddlewareHandler, ...MiddlewareHandler[], Handler];
|
|
|
88
87
|
* c.get("page").title = room.name;
|
|
89
88
|
* c.get("page").description = `Welcome to ${room.name}`;
|
|
90
89
|
*
|
|
91
|
-
* return <RoomView room={room} />;
|
|
90
|
+
* return () => <RoomView room={room} />;
|
|
92
91
|
* }
|
|
93
92
|
* );
|
|
94
93
|
* ```
|
|
@@ -125,7 +124,7 @@ export const createSSRHandler = <T extends object>(html: HtmlFn<T>) => {
|
|
|
125
124
|
await next();
|
|
126
125
|
});
|
|
127
126
|
|
|
128
|
-
// Wrapped handler:
|
|
127
|
+
// Wrapped handler: render function → html(), Response → passthrough
|
|
129
128
|
const wrappedHandler: Handler = async (c) => {
|
|
130
129
|
const result = await finalHandler(c as Context<E & PageEnv<T>>);
|
|
131
130
|
|
|
@@ -134,8 +133,15 @@ export const createSSRHandler = <T extends object>(html: HtmlFn<T>) => {
|
|
|
134
133
|
return result;
|
|
135
134
|
}
|
|
136
135
|
|
|
137
|
-
|
|
138
|
-
|
|
136
|
+
if (typeof result !== "function") {
|
|
137
|
+
throw new Error("[ssr] ssr() handlers must return a render function: return () => <Page />");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (result.constructor.name === "AsyncFunction") {
|
|
141
|
+
throw new Error("[ssr] ssr() render functions must be synchronous: return () => <Page />");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return html(result as RenderFn, c.get("page") as T);
|
|
139
145
|
};
|
|
140
146
|
|
|
141
147
|
// Return tuple for spread: .get('/path', ...ssr(handler))
|
|
@@ -155,7 +161,7 @@ export const createSSRHandler = <T extends object>(html: HtmlFn<T>) => {
|
|
|
155
161
|
* import { routes } from "@valentinkolb/ssr/adapter/hono";
|
|
156
162
|
* const app = new Hono()
|
|
157
163
|
* .route("/_ssr", routes(config))
|
|
158
|
-
* .get("/", () => html(<Home />));
|
|
164
|
+
* .get("/", () => html(() => <Home />));
|
|
159
165
|
* ```
|
|
160
166
|
*/
|
|
161
167
|
export const routes = (config: SsrConfig) => {
|
package/src/adapter/utils.ts
CHANGED
|
@@ -5,6 +5,27 @@
|
|
|
5
5
|
import { dirname, join, resolve } from "path";
|
|
6
6
|
import type { SsrConfig } from "../index";
|
|
7
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";
|
|
28
|
+
|
|
8
29
|
/**
|
|
9
30
|
* Get the _ssr directory path based on dev/prod mode.
|
|
10
31
|
* Dev: uses config.rootDir (fallback process.cwd())
|
package/src/build.ts
CHANGED
|
@@ -16,6 +16,57 @@ const getSelector = (type: ComponentType, id: string) =>
|
|
|
16
16
|
|
|
17
17
|
const fmt = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`);
|
|
18
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
|
+
|
|
19
70
|
export const buildIslands = async (options: {
|
|
20
71
|
pattern: string;
|
|
21
72
|
outdir: string;
|
|
@@ -130,49 +181,10 @@ export const buildIslands = async (options: {
|
|
|
130
181
|
],
|
|
131
182
|
});
|
|
132
183
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (result.success) {
|
|
136
|
-
|
|
137
|
-
for await (const chunkFile of chunkGlob.scan({ cwd: outdir, absolute: true })) {
|
|
138
|
-
const src = await Bun.file(chunkFile).text();
|
|
139
|
-
// Collect all export blocks and deduplicate
|
|
140
|
-
const exportRegex = /^export\s*\{[^}]*\}\s*;?\s*$/gm;
|
|
141
|
-
const exportBlocks = [...src.matchAll(exportRegex)].map((m) => m[0]);
|
|
142
|
-
if (exportBlocks.length > 1) {
|
|
143
|
-
// Parse all exported names from all blocks
|
|
144
|
-
const seenNames = new Set<string>();
|
|
145
|
-
const uniqueBlocks: string[] = [];
|
|
146
|
-
for (const block of exportBlocks) {
|
|
147
|
-
const names =
|
|
148
|
-
block
|
|
149
|
-
.match(/\{([^}]*)\}/)?.[1]
|
|
150
|
-
?.split(",")
|
|
151
|
-
.map((n) => n.trim())
|
|
152
|
-
.filter(Boolean) ?? [];
|
|
153
|
-
const newNames = names.filter((n) => !seenNames.has(n));
|
|
154
|
-
if (newNames.length > 0) {
|
|
155
|
-
uniqueBlocks.push(`export { ${newNames.join(", ")} };`);
|
|
156
|
-
newNames.forEach((n) => seenNames.add(n));
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
// Replace all export blocks with deduplicated ones
|
|
160
|
-
let fixed = src;
|
|
161
|
-
for (const block of exportBlocks) {
|
|
162
|
-
fixed = fixed.replace(block, "");
|
|
163
|
-
}
|
|
164
|
-
// Append single combined export before any debugId comment
|
|
165
|
-
const debugIdIdx = fixed.lastIndexOf("//# debugId=");
|
|
166
|
-
const combined = uniqueBlocks.join("\n") + "\n";
|
|
167
|
-
if (debugIdIdx !== -1) {
|
|
168
|
-
fixed = fixed.slice(0, debugIdIdx) + combined + fixed.slice(debugIdIdx);
|
|
169
|
-
} else {
|
|
170
|
-
fixed = fixed.trimEnd() + "\n" + combined;
|
|
171
|
-
}
|
|
172
|
-
await Bun.write(chunkFile, fixed);
|
|
173
|
-
if (verbose) console.log(` Fixed duplicate exports in ${chunkFile.split("/").pop()}`);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
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.");
|
|
176
188
|
}
|
|
177
189
|
|
|
178
190
|
if (verbose) {
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { transform } from "./transform";
|
|
|
12
12
|
import { buildIslands } from "./build";
|
|
13
13
|
import { join, dirname, resolve } from "path";
|
|
14
14
|
import { resolveIslandImport } from "./island-resolve";
|
|
15
|
+
import { normalizeBasePath, toSsrPath } from "./adapter/utils";
|
|
15
16
|
// @ts-ignore - Bun text import
|
|
16
17
|
import devClientCode from "./adapter/client.js" with { type: "text" };
|
|
17
18
|
|
|
@@ -46,6 +47,8 @@ export type SsrOptions<T extends object = object> = {
|
|
|
46
47
|
verbose?: boolean;
|
|
47
48
|
/** Project root for island discovery and dev _ssr assets (default: process.cwd()) */
|
|
48
49
|
rootDir?: string;
|
|
50
|
+
/** Public app mount path for SSR assets and dev endpoints (default: "") */
|
|
51
|
+
basePath?: string;
|
|
49
52
|
/** Modules to exclude from the island bundle (passed to Bun.build) */
|
|
50
53
|
external?: string[];
|
|
51
54
|
/** HTML template function (optional, has default) */
|
|
@@ -61,9 +64,13 @@ export type SsrConfig = {
|
|
|
61
64
|
dev: boolean;
|
|
62
65
|
verbose?: boolean;
|
|
63
66
|
rootDir?: string;
|
|
67
|
+
basePath: string;
|
|
68
|
+
ssrPath: string;
|
|
64
69
|
};
|
|
65
70
|
|
|
66
|
-
export type
|
|
71
|
+
export type RenderFn = () => JSX.Element;
|
|
72
|
+
|
|
73
|
+
export type HtmlFn<T extends object> = (render: RenderFn, options?: T) => Promise<Response>;
|
|
67
74
|
|
|
68
75
|
type PluginFn = () => BunPlugin;
|
|
69
76
|
|
|
@@ -105,8 +112,10 @@ export type SsrResult<T extends object> = {
|
|
|
105
112
|
* ```
|
|
106
113
|
*/
|
|
107
114
|
export const createConfig = <T extends object = object>(options: SsrOptions<T> = {}): SsrResult<T> => {
|
|
108
|
-
const { dev = false, verbose, external, template, rootDir: rootDirOption } = options;
|
|
115
|
+
const { dev = false, verbose, external, template, rootDir: rootDirOption, basePath: basePathOption } = options;
|
|
109
116
|
const rootDir = resolve(rootDirOption ?? process.cwd());
|
|
117
|
+
const basePath = normalizeBasePath(basePathOption);
|
|
118
|
+
const ssrPath = toSsrPath(basePath);
|
|
110
119
|
|
|
111
120
|
// Default template if none provided
|
|
112
121
|
const htmlTemplate =
|
|
@@ -130,23 +139,33 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
130
139
|
dev,
|
|
131
140
|
verbose,
|
|
132
141
|
rootDir,
|
|
142
|
+
basePath,
|
|
143
|
+
ssrPath,
|
|
133
144
|
};
|
|
134
145
|
|
|
135
146
|
const buildVersion = getBuildVersion(dev);
|
|
136
147
|
|
|
148
|
+
const islandDisplayStyle =
|
|
149
|
+
"<style>solid-client,solid-island{display:contents}</style>";
|
|
150
|
+
|
|
137
151
|
// Hydration script - dynamically loads island/client bundles based on DOM
|
|
138
|
-
const hydrationScript = `<script type="module">const v=${JSON.stringify(buildVersion)};document.querySelectorAll('solid-island,solid-client').forEach(e=>import('/
|
|
152
|
+
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>`;
|
|
153
|
+
const devConfigScript = `<script>globalThis.__SSR_CONFIG=${JSON.stringify({ ssrPath })}</script>`;
|
|
139
154
|
|
|
140
155
|
// HTML renderer
|
|
141
|
-
const html: HtmlFn<T> = async (
|
|
142
|
-
|
|
156
|
+
const html: HtmlFn<T> = async (render, opts = {} as T) => {
|
|
157
|
+
if (render.constructor.name === "AsyncFunction") {
|
|
158
|
+
throw new Error("[ssr] html() expects a synchronous render function: html(() => <Page />)");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const body = renderToString(render);
|
|
143
162
|
|
|
144
|
-
//
|
|
145
|
-
let scripts = hydrationScript
|
|
163
|
+
// Framework-injected assets
|
|
164
|
+
let scripts = `${islandDisplayStyle}\n${hydrationScript}`;
|
|
146
165
|
|
|
147
166
|
// Add dev tools script in dev mode (inlined)
|
|
148
167
|
if (dev) {
|
|
149
|
-
scripts += `\n<script type="module">${devClientCode}</script>`;
|
|
168
|
+
scripts += `\n${devConfigScript}\n<script type="module">${devClientCode}</script>`;
|
|
150
169
|
}
|
|
151
170
|
|
|
152
171
|
const content = await htmlTemplate({
|