@k2b/ssr 0.13.1 → 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
@@ -334,8 +334,9 @@ createConfig({
334
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.
335
335
  - `basePath` moves SSR assets and dev endpoints under that prefix, e.g. `/docs/_ssr`.
336
336
  - Development builds emit linked source maps by default. Use `"inline"` only when a tool requires embedded maps, or `"none"` to disable them.
337
- - In production, hydration imports include a build timestamp query (`?v=...`) for cache busting.
338
- - 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.
339
340
 
340
341
  ## Microfrontend mount example
341
342
 
@@ -360,6 +361,12 @@ With this setup, hydration chunks and dev endpoints are served from `/docs/_ssr/
360
361
 
361
362
  ## Build for production
362
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
+
363
370
  ```ts
364
371
  // scripts/build.ts
365
372
  import { plugin } from "./config";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@k2b/ssr",
3
- "version": "0.13.1",
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/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
  // ============================================================================
@@ -156,13 +142,11 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
156
142
  ssrPath,
157
143
  };
158
144
 
159
- const buildVersion = getBuildVersion(dev);
160
-
161
145
  const islandDisplayStyle =
162
146
  "<style>solid-client,solid-island{display:contents}</style>";
163
147
 
164
148
  // Hydration script - dynamically loads island/client bundles based on DOM
165
- 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>`;
166
150
  const devConfigScript = `<script>globalThis.__SSR_CONFIG=${JSON.stringify({ ssrPath, reloadId: getReloadId() })}</script>`;
167
151
 
168
152
  // HTML renderer