@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 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.7.3",
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;
32
+ const { dev, ssrPath } = config;
33
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;
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: "/_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
  };
@@ -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
- // Workaround for Bun bundler bug: duplicate export statements in shared chunks
134
- // when splitting: true. Scan chunk files and deduplicate export lines.
135
- if (result.success) {
136
- const chunkGlob = new Glob("chunk-*.js");
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('/_ssr/'+e.dataset.id+'.js'+(v?'?v='+v:'')));</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>`;
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
- // Component scripts
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({