@valentinkolb/ssr 0.7.3 → 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 +26 -1
- package/package.json +4 -4
- package/src/adapter/bun.ts +4 -4
- package/src/adapter/client.js +7 -5
- package/src/adapter/elysia.ts +4 -4
- package/src/adapter/utils.ts +21 -0
- package/src/build.ts +55 -43
- package/src/index.ts +18 -5
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>
|
|
@@ -179,6 +181,7 @@ createConfig({
|
|
|
179
181
|
dev?: boolean; // default: false
|
|
180
182
|
verbose?: boolean; // default: !dev
|
|
181
183
|
rootDir?: string; // default: process.cwd()
|
|
184
|
+
basePath?: string; // default: "", example: "/docs"
|
|
182
185
|
external?: string[]; // passed to Bun.build for island bundle
|
|
183
186
|
template?: ({ body, scripts, ...custom }) => string | Promise<string>;
|
|
184
187
|
})
|
|
@@ -187,8 +190,30 @@ createConfig({
|
|
|
187
190
|
### Notes
|
|
188
191
|
|
|
189
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`.
|
|
190
194
|
- In production, hydration imports include a build timestamp query (`?v=...`) for cache busting.
|
|
191
195
|
|
|
196
|
+
## Microfrontend mount example
|
|
197
|
+
|
|
198
|
+
Use `basePath` when the SSR app is mounted under a sub-path:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
// config.ts
|
|
202
|
+
export const { config, html } = createConfig({
|
|
203
|
+
basePath: "/docs",
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// docs-app.ts
|
|
207
|
+
const docsApp = new Hono()
|
|
208
|
+
.route("/_ssr", routes(config))
|
|
209
|
+
.get("/", () => html(<DocsHome />));
|
|
210
|
+
|
|
211
|
+
// host-app.ts
|
|
212
|
+
export default new Hono().route("/docs", docsApp);
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
With this setup, hydration chunks and dev endpoints are served from `/docs/_ssr/...`.
|
|
216
|
+
|
|
192
217
|
## Build for production
|
|
193
218
|
|
|
194
219
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valentinkolb/ssr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Minimal SSR framework for SolidJS and Bun",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -32,15 +32,15 @@
|
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@babel/core": "^7.24.0",
|
|
36
|
+
"@babel/preset-typescript": "^7.24.0",
|
|
37
|
+
"babel-preset-solid": "^1.8.0",
|
|
35
38
|
"seroval": "^1.0.0"
|
|
36
39
|
},
|
|
37
40
|
"devDependencies": {
|
|
38
|
-
"@babel/core": "^7.24.0",
|
|
39
|
-
"@babel/preset-typescript": "^7.24.0",
|
|
40
41
|
"@elysiajs/static": "^1.2.0",
|
|
41
42
|
"@types/babel__core": "^7.20.5",
|
|
42
43
|
"@types/bun": "latest",
|
|
43
|
-
"babel-preset-solid": "^1.8.0",
|
|
44
44
|
"elysia": "^1.2.0",
|
|
45
45
|
"hono": "^4.6.14",
|
|
46
46
|
"solid-js": "^1.9.0",
|
package/src/adapter/bun.ts
CHANGED
|
@@ -29,20 +29,20 @@ type Routes = Record<string, RouteHandler>;
|
|
|
29
29
|
* ```
|
|
30
30
|
*/
|
|
31
31
|
export const routes = (config: SsrConfig): Routes => {
|
|
32
|
-
const { dev } = config;
|
|
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
|
@@ -25,17 +25,17 @@ import {
|
|
|
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/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,6 +64,8 @@ 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
71
|
export type HtmlFn<T extends object> = (element: JSX.Element, options?: T) => Promise<Response>;
|
|
@@ -105,8 +110,10 @@ export type SsrResult<T extends object> = {
|
|
|
105
110
|
* ```
|
|
106
111
|
*/
|
|
107
112
|
export const createConfig = <T extends object = object>(options: SsrOptions<T> = {}): SsrResult<T> => {
|
|
108
|
-
const { dev = false, verbose, external, template, rootDir: rootDirOption } = options;
|
|
113
|
+
const { dev = false, verbose, external, template, rootDir: rootDirOption, basePath: basePathOption } = options;
|
|
109
114
|
const rootDir = resolve(rootDirOption ?? process.cwd());
|
|
115
|
+
const basePath = normalizeBasePath(basePathOption);
|
|
116
|
+
const ssrPath = toSsrPath(basePath);
|
|
110
117
|
|
|
111
118
|
// Default template if none provided
|
|
112
119
|
const htmlTemplate =
|
|
@@ -130,23 +137,29 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
130
137
|
dev,
|
|
131
138
|
verbose,
|
|
132
139
|
rootDir,
|
|
140
|
+
basePath,
|
|
141
|
+
ssrPath,
|
|
133
142
|
};
|
|
134
143
|
|
|
135
144
|
const buildVersion = getBuildVersion(dev);
|
|
136
145
|
|
|
146
|
+
const islandDisplayStyle =
|
|
147
|
+
"<style>solid-client,solid-island{display:contents}</style>";
|
|
148
|
+
|
|
137
149
|
// 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('/
|
|
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>`;
|
|
139
152
|
|
|
140
153
|
// HTML renderer
|
|
141
154
|
const html: HtmlFn<T> = async (element, opts = {} as T) => {
|
|
142
155
|
const body = renderToString(() => element);
|
|
143
156
|
|
|
144
|
-
//
|
|
145
|
-
let scripts = hydrationScript
|
|
157
|
+
// Framework-injected assets
|
|
158
|
+
let scripts = `${islandDisplayStyle}\n${hydrationScript}`;
|
|
146
159
|
|
|
147
160
|
// Add dev tools script in dev mode (inlined)
|
|
148
161
|
if (dev) {
|
|
149
|
-
scripts += `\n<script type="module">${devClientCode}</script>`;
|
|
162
|
+
scripts += `\n${devConfigScript}\n<script type="module">${devClientCode}</script>`;
|
|
150
163
|
}
|
|
151
164
|
|
|
152
165
|
const content = await htmlTemplate({
|