@k2b/ssr 0.12.0 → 0.14.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
@@ -55,7 +55,7 @@ SPA routing.
55
55
  - Monorepo support via `rootDir`
56
56
  - Public path mounting via `basePath` for microfrontends
57
57
  - Stable file-path-based island IDs (collision-safe across workspace packages)
58
- - Production chunk cache busting (`/_ssr/*.js?v=<buildTimestamp>`)
58
+ - Production module cache busting (`/_ssr/<buildTimestamp>/*.js`)
59
59
  - Linked development source maps and validator-aware asset delivery
60
60
  - Stale generated island assets removed after successful builds
61
61
  - Visibility-aware development reload with cross-tab SSE coordination
@@ -309,6 +309,7 @@ createConfig({
309
309
  dev?: boolean; // default: false
310
310
  verbose?: boolean; // default: !dev
311
311
  rootDir?: string; // default: process.cwd()
312
+ componentRoots?: readonly string[]; // explicit island/client discovery directories
312
313
  basePath?: string; // default: "", example: "/docs"
313
314
  external?: string[]; // passed to Bun.build for island bundle
314
315
  devSourcemap?: "none" | "linked" | "inline"; // default: "linked"
@@ -319,10 +320,23 @@ createConfig({
319
320
  ### Notes
320
321
 
321
322
  - `rootDir` is important in monorepos where server entrypoint and island files live in different packages.
323
+ - `componentRoots` replaces the discovery scan with explicit directories. Relative paths resolve against `rootDir`; absolute paths support installed framework packages. Omit it to scan `rootDir`, or pass `[]` to scan nothing. Missing directories fail the build. Symlinks and overlapping roots are deduplicated by canonical path; nested `node_modules` and `.git` directories are skipped. Select an installed package directory explicitly to scan its components.
324
+ - Solid core, web, and store imports use the app's dependency and the selected build mode consistently, including components imported from installed packages.
325
+ - `rootDir` still controls component IDs and the development asset directory. All selected components share one browser build. Ordinary component libraries with browser/SSR exports (such as `@k2b/ui`) are resolved through imports and do not need discovery roots. Do not scan their examples or test fixtures.
326
+
327
+ ```ts
328
+ createConfig({
329
+ rootDir: workspaceRoot,
330
+ componentRoots: ["packages/my-app/src", frameworkSourceDir],
331
+ });
332
+ ```
333
+
334
+ Use the same configuration in development and production. Files outside `rootDir` retain the existing canonical absolute-path ID fallback; moving an external package can change its IDs. SSR wrappers and browser assets must come from the same build.
322
335
  - `basePath` moves SSR assets and dev endpoints under that prefix, e.g. `/docs/_ssr`.
323
336
  - Development builds emit linked source maps by default. Use `"inline"` only when a tool requires embedded maps, or `"none"` to disable them.
324
- - In production, hydration imports include a build timestamp query (`?v=...`) for cache busting.
325
- - All adapters stream island assets from `Bun.file`. Production assets and content-hashed development chunks are immutable; stable development entries and source maps use validators for inexpensive freshness checks.
337
+ - In production, all modules share a build timestamp directory (`/_ssr/<version>/<id>.js`). Relative lazy imports inherit that directory, so each module has one URL. Files stay flat on disk; adapters serve only the current build version.
338
+ - All adapters stream island assets from `Bun.file`. Production assets under the versioned path and content-hashed development chunks are immutable; stable development entries and source maps use validators for inexpensive freshness checks.
339
+ - Production adapters serve adjacent `.br` or `.gz` files when accepted by the request, preserving the original MIME type and varying caches by `Accept-Encoding`. Generate these siblings in the application build; the adapter does not compress responses at runtime. Development always serves the original file to avoid stale compressed copies.
326
340
 
327
341
  ## Microfrontend mount example
328
342
 
@@ -347,6 +361,12 @@ With this setup, hydration chunks and dev endpoints are served from `/docs/_ssr/
347
361
 
348
362
  ## Build for production
349
363
 
364
+ Set the environment before starting Bun so both the build configuration and bundled code use production mode:
365
+
366
+ ```bash
367
+ NODE_ENV=production bun scripts/build.ts
368
+ ```
369
+
350
370
  ```ts
351
371
  // scripts/build.ts
352
372
  import { plugin } from "./config";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/ssr",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Minimal SSR framework for SolidJS and Bun",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "devDependencies": {
39
39
  "@happy-dom/global-registrator": "^20.10.6",
40
- "@types/bun": "^1.3.14",
40
+ "@types/bun": "1.4.2",
41
41
  "elysia": "^1.4.29",
42
42
  "file-type": "^21.3.1",
43
43
  "hono": "^4.12.30",
@@ -5,6 +5,7 @@
5
5
  import type { SsrConfig } from "../index";
6
6
  import {
7
7
  createAssetResponse,
8
+ getAssetPrefix,
8
9
  createPingResponse,
9
10
  getSsrDir,
10
11
  createReloadResponse,
@@ -30,6 +31,7 @@ type Routes = Record<string, RouteHandler>;
30
31
  export const routes = (config: SsrConfig): Routes => {
31
32
  const { dev, ssrPath } = config;
32
33
  const ssrDir = getSsrDir(config);
34
+ const assetPath = ssrPath + getAssetPrefix(dev);
33
35
 
34
36
  const devRoutes: Routes = dev
35
37
  ? {
@@ -39,13 +41,13 @@ export const routes = (config: SsrConfig): Routes => {
39
41
  : {};
40
42
 
41
43
  const serveAsset: RouteHandler = (req) => {
42
- const filename = new URL(req.url).pathname.split("/").pop()!;
44
+ const filename = new URL(req.url).pathname.slice(assetPath.length + 1);
43
45
  return createAssetResponse(req, ssrDir, filename, dev);
44
46
  };
45
47
 
46
48
  return {
47
49
  ...devRoutes,
48
- [`${ssrPath}/*.js`]: serveAsset,
49
- [`${ssrPath}/*.js.map`]: serveAsset,
50
+ [`${assetPath}/*.js`]: serveAsset,
51
+ [`${assetPath}/*.js.map`]: serveAsset,
50
52
  };
51
53
  };
@@ -6,6 +6,7 @@ import { Elysia } from "elysia";
6
6
  import type { SsrConfig } from "../index";
7
7
  import {
8
8
  createAssetResponse,
9
+ getAssetPrefix,
9
10
  createPingResponse,
10
11
  getSsrDir,
11
12
  createReloadResponse,
@@ -27,13 +28,14 @@ import {
27
28
  export const routes = (config: SsrConfig) => {
28
29
  const { dev, ssrPath } = config;
29
30
  const ssrDir = getSsrDir(config);
31
+ const assetPath = ssrPath + getAssetPrefix(dev);
30
32
 
31
33
  return new Elysia({ name: "ssr" })
32
34
  .get(`${ssrPath}/_reload`, ({ request }) =>
33
35
  dev ? createReloadResponse(request.signal) : notFound(),
34
36
  )
35
37
  .get(`${ssrPath}/_ping`, () => (dev ? createPingResponse() : notFound()))
36
- .get(`${ssrPath}/*`, ({ request, params }) =>
38
+ .get(`${assetPath}/*`, ({ request, params }) =>
37
39
  createAssetResponse(request, ssrDir, params["*"], dev),
38
40
  );
39
41
  };
@@ -6,7 +6,7 @@ import { Hono } from "hono";
6
6
  import { createFactory } from "hono/factory";
7
7
  import type { Context, Env, Handler, MiddlewareHandler, TypedResponse } from "hono";
8
8
  import type { SsrConfig, HtmlFn, RenderFn } from "../index";
9
- import { createAssetResponse, createPingResponse, getSsrDir, createReloadResponse } from "./utils";
9
+ import { createAssetResponse, getAssetPrefix, createPingResponse, getSsrDir, createReloadResponse } from "./utils";
10
10
 
11
11
  // ============================================================================
12
12
  // Types
@@ -183,8 +183,9 @@ export const routes = (config: SsrConfig) => {
183
183
  return createAssetResponse(c.req.raw, ssrDir, filename, dev);
184
184
  };
185
185
 
186
- app.get("/:filename{.+\\.js$}", serveAsset);
187
- app.get("/:filename{.+\\.js\\.map$}", serveAsset);
186
+ const prefix = getAssetPrefix(dev);
187
+ app.get(`${prefix}/:filename{.+\\.js$}`, serveAsset);
188
+ app.get(`${prefix}/:filename{.+\\.js\\.map$}`, serveAsset);
188
189
 
189
190
  return app;
190
191
  };
@@ -3,6 +3,7 @@
3
3
  * SSE live reload, and security utilities.
4
4
  */
5
5
  import { dirname, join, resolve } from "path";
6
+ import { statSync } from "fs";
6
7
  import type { SsrConfig } from "../index";
7
8
 
8
9
  /**
@@ -34,9 +35,36 @@ export const toSsrPath = (basePath: string): string =>
34
35
  export const getSsrDir = (config: SsrConfig): string =>
35
36
  join(config.dev ? config.rootDir ?? process.cwd() : dirname(Bun.main), "_ssr");
36
37
 
38
+ // Keep one version for this server process, including when Bun.main has no mtime.
39
+ const buildVersion = (() => {
40
+ try {
41
+ return String(Math.floor(statSync(Bun.main).mtimeMs));
42
+ } catch {
43
+ return String(Date.now());
44
+ }
45
+ })();
46
+
47
+ /** Relative imports inherit a versioned directory, unlike a query string. */
48
+ export const getAssetPrefix = (dev: boolean): string => dev ? "" : `/${buildVersion}`;
49
+
37
50
  const HASHED_CHUNK = /^chunk-[a-z0-9]+\.js$/i;
38
51
  const ASSET_FILE = /^[a-z0-9._-]+\.js(?:\.map)?$/i;
39
52
 
53
+ const acceptedEncodings = (header: string | null): Array<"br" | "gzip" | "identity"> => {
54
+ const qualities = new Map<string, number>();
55
+ for (const part of header?.split(",") ?? []) {
56
+ const [name, ...parameters] = part.trim().toLowerCase().split(";");
57
+ if (!name) continue;
58
+ const q = parameters.map((parameter) => parameter.trim()).find((parameter) => parameter.startsWith("q="));
59
+ const quality = q === undefined ? 1 : Number(q.slice(2));
60
+ qualities.set(name, Number.isFinite(quality) && quality >= 0 && quality <= 1 ? quality : 0);
61
+ }
62
+ const quality = (encoding: string) => qualities.get(encoding) ??
63
+ (encoding === "identity" ? (qualities.get("*") === 0 ? 0 : 1) : qualities.get("*") ?? 0);
64
+ return (["br", "gzip", "identity"] as const).filter((encoding) => quality(encoding) > 0)
65
+ .sort((left, right) => quality(right) - quality(left));
66
+ };
67
+
40
68
  /**
41
69
  * Stable entry names can change during development. Content-hashed chunks
42
70
  * cannot, so the browser may retain them across page navigations.
@@ -85,9 +113,19 @@ export const createAssetResponse = async (
85
113
 
86
114
  const cacheControl = getCacheHeaders(dev, filename);
87
115
  if (!dev) {
88
- return new Response(file, {
89
- headers: { "Content-Type": contentType, "Cache-Control": cacheControl },
90
- });
116
+ for (const encoding of acceptedEncodings(request.headers.get("Accept-Encoding"))) {
117
+ const selected = encoding === "identity" ? file : Bun.file(`${path}${encoding === "br" ? ".br" : ".gz"}`);
118
+ if (!(await selected.exists())) continue;
119
+ const headers = new Headers({
120
+ "Content-Type": contentType,
121
+ "Content-Length": String(selected.size),
122
+ "Cache-Control": cacheControl,
123
+ Vary: "Accept-Encoding",
124
+ });
125
+ if (encoding !== "identity") headers.set("Content-Encoding", encoding);
126
+ return new Response(request.method === "HEAD" ? null : selected, { headers });
127
+ }
128
+ return new Response(null, { status: 406, headers: { Vary: "Accept-Encoding" } });
91
129
  }
92
130
 
93
131
  const lastModified = file.lastModified;
@@ -102,7 +140,7 @@ export const createAssetResponse = async (
102
140
  return new Response(null, { status: 304, headers: validatorHeaders });
103
141
  }
104
142
 
105
- return new Response(file, {
143
+ return new Response(request.method === "HEAD" ? null : file, {
106
144
  headers: { "Content-Type": contentType, ...validatorHeaders },
107
145
  });
108
146
  };
package/src/build.ts CHANGED
@@ -2,11 +2,12 @@
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, resolve } from "path";
5
+ import { join, relative, resolve } from "path";
6
6
  import { Glob } from "bun";
7
- import { unlink } from "fs/promises";
7
+ import { readdir, stat, unlink } from "fs/promises";
8
+ import { existsSync } from "fs";
8
9
  import { transform } from "./transform";
9
- import { ISLAND_ID_LENGTH, islandIdFromFile, toStableKey } from "./island-id";
10
+ import { ISLAND_ID_LENGTH, canonicalFilePath, islandIdFromFile, toStableKey } from "./island-id";
10
11
 
11
12
  type ComponentType = "island" | "client";
12
13
 
@@ -89,34 +90,48 @@ export const buildIslands = async (options: {
89
90
  pattern: string;
90
91
  outdir: string;
91
92
  cwd: string;
93
+ componentRoots?: readonly string[];
92
94
  verbose: boolean;
93
95
  dev?: boolean;
94
96
  devSourcemap?: DevSourcemap;
95
97
  external?: string[];
96
98
  }): Promise<void> => {
97
- const { pattern, outdir, cwd, verbose, dev = false, devSourcemap = "linked", external } = options;
99
+ const { pattern, outdir, cwd, componentRoots, verbose, dev = false, devSourcemap = "linked", external } = options;
98
100
  const resolvedCwd = resolve(cwd);
99
101
 
100
102
  const totalStart = performance.now();
101
103
 
102
- const files: string[] = [];
104
+ const files = new Set<string>();
103
105
 
104
106
  const scanStart = performance.now();
105
- for await (const file of new Glob(pattern).scan({
106
- cwd: resolvedCwd,
107
- absolute: true,
108
- })) {
109
- files.push(file);
107
+ const visited = new Set<string>();
108
+ const matcher = new Glob(pattern);
109
+ const scan = async (directory: string, scanRoot: string): Promise<void> => {
110
+ const canonical = canonicalFilePath(directory);
111
+ if (visited.has(canonical)) return;
112
+ visited.add(canonical);
113
+ for (const entry of await readdir(canonical, { withFileTypes: true })) {
114
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
115
+ const path = join(canonical, entry.name);
116
+ const info = entry.isSymbolicLink() ? await stat(path) : entry;
117
+ if (info.isDirectory()) await scan(path, scanRoot);
118
+ else if (info.isFile() && matcher.match(relative(scanRoot, path))) files.add(canonicalFilePath(path));
119
+ }
120
+ };
121
+ for (const root of componentRoots ?? [resolvedCwd]) {
122
+ const scanRoot = canonicalFilePath(resolve(resolvedCwd, root));
123
+ await scan(scanRoot, scanRoot);
110
124
  }
111
- if (verbose) console.log(`Scan: found ${files.length} file(s) in ${fmt(performance.now() - scanStart)}`);
125
+ if (verbose) console.log(`Scan: found ${files.size} file(s) in ${fmt(performance.now() - scanStart)}`);
112
126
 
113
- if (!files.length) {
127
+ if (!files.size) {
128
+ if (existsSync(outdir)) await removeStaleBuildAssets(outdir, []);
114
129
  if (verbose) console.log("No island/client files found.");
115
130
  return;
116
131
  }
117
132
 
118
133
  // Build component metadata
119
- const components = files.map((componentPath) => {
134
+ const components = [...files].map((componentPath) => {
120
135
  const id = islandIdFromFile(componentPath, resolvedCwd);
121
136
  const key = toStableKey(componentPath, resolvedCwd);
122
137
  const type = getComponentType(componentPath);
@@ -153,6 +168,7 @@ export const buildIslands = async (options: {
153
168
  outdir,
154
169
  naming: { entry: "[name].js", chunk: "chunk-[hash].js" },
155
170
  target: "browser",
171
+ conditions: ["browser", dev ? "development" : "production"],
156
172
  external,
157
173
  minify: !dev,
158
174
  splitting: true,
@@ -161,6 +177,15 @@ export const buildIslands = async (options: {
161
177
  {
162
178
  name: "solid-islands",
163
179
  setup(build) {
180
+ // Core, DOM and store must use the app's Solid copy and the same mode.
181
+ // Explicit exported entries avoid mixing a production core with
182
+ // development store/web modules from the host process conditions.
183
+ build.onResolve({ filter: /^solid-js(?:\/(?:web|store))?$/ }, (args) => {
184
+ const module = args.path === "solid-js" ? "solid" : args.path.slice("solid-js/".length);
185
+ const entry = `${args.path}/dist/${dev ? "dev" : module}.js`;
186
+ return { path: Bun.resolveSync(entry, resolvedCwd) };
187
+ });
188
+
164
189
  // Resolve component IDs as virtual entrypoints
165
190
  build.onResolve({ filter: new RegExp(`^[a-f0-9]{${ISLAND_ID_LENGTH}}$`) }, (args) => ({
166
191
  path: args.path,
@@ -220,7 +245,7 @@ export const buildIslands = async (options: {
220
245
  }
221
246
  }
222
247
  console.log(
223
- `Built ${files.length} component(s) to ${outdir}/ in ${fmt(performance.now() - totalStart)}${verbose ? " (total)" : ""}`,
248
+ `Built ${files.size} component(s) to ${outdir}/ in ${fmt(performance.now() - totalStart)}${verbose ? " (total)" : ""}`,
224
249
  );
225
250
 
226
251
  if (!result.success) {
package/src/index.ts CHANGED
@@ -7,12 +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";
11
10
  import { transform } from "./transform";
12
11
  import { buildIslands, type DevSourcemap } from "./build";
13
12
  import { join, dirname, resolve } from "path";
14
13
  import { resolveIslandImport } from "./island-resolve";
15
- import { getReloadId, normalizeBasePath, toSsrPath } from "./adapter/utils";
14
+ import { getAssetPrefix, getReloadId, normalizeBasePath, toSsrPath } from "./adapter/utils";
16
15
  // @ts-ignore - Bun text import
17
16
  import devClientCode from "./adapter/client.js" with { type: "text" };
18
17
 
@@ -23,19 +22,6 @@ import devClientCode from "./adapter/client.js" with { type: "text" };
23
22
  /** Glob pattern for island/client component files */
24
23
  const COMPONENT_PATTERN = "**/*.{island,client}.tsx";
25
24
 
26
- /**
27
- * Build version used for cache busting island script imports in production.
28
- * Uses server entrypoint mtime as a stable per-build version value.
29
- */
30
- const getBuildVersion = (dev: boolean): string => {
31
- if (dev) return "";
32
- try {
33
- return String(Math.floor(statSync(Bun.main).mtimeMs));
34
- } catch {
35
- return String(Date.now());
36
- }
37
- };
38
-
39
25
  // ============================================================================
40
26
  // Types
41
27
  // ============================================================================
@@ -47,6 +33,8 @@ export type SsrOptions<T extends object = object> = {
47
33
  verbose?: boolean;
48
34
  /** Project root for island discovery and dev _ssr assets (default: process.cwd()) */
49
35
  rootDir?: string;
36
+ /** Discovery roots relative to rootDir (absolute paths allowed). Replaces the default rootDir scan. */
37
+ componentRoots?: readonly string[];
50
38
  /** Public app mount path for SSR assets and dev endpoints (default: "") */
51
39
  basePath?: string;
52
40
  /** Modules to exclude from the island bundle (passed to Bun.build) */
@@ -121,6 +109,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
121
109
  devSourcemap = "linked",
122
110
  template,
123
111
  rootDir: rootDirOption,
112
+ componentRoots,
124
113
  basePath: basePathOption,
125
114
  } = options;
126
115
  const rootDir = resolve(rootDirOption ?? process.cwd());
@@ -153,13 +142,11 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
153
142
  ssrPath,
154
143
  };
155
144
 
156
- const buildVersion = getBuildVersion(dev);
157
-
158
145
  const islandDisplayStyle =
159
146
  "<style>solid-client,solid-island{display:contents}</style>";
160
147
 
161
148
  // Hydration script - dynamically loads island/client bundles based on DOM
162
- 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>`;
149
+ const hydrationScript = `<script type="module">const p=${JSON.stringify(ssrPath + getAssetPrefix(dev))};document.querySelectorAll('solid-island,solid-client').forEach(e=>import(p+'/'+e.dataset.id+'.js'));</script>`;
163
150
  const devConfigScript = `<script>globalThis.__SSR_CONFIG=${JSON.stringify({ ssrPath, reloadId: getReloadId() })}</script>`;
164
151
 
165
152
  // HTML renderer
@@ -208,6 +195,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
208
195
  pattern: COMPONENT_PATTERN,
209
196
  outdir: islandsOutdir,
210
197
  cwd: rootDir,
198
+ componentRoots,
211
199
  verbose: verbose ?? !dev,
212
200
  dev,
213
201
  devSourcemap,