@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 +10 -3
- package/package.json +2 -2
- package/src/adapter/bun.ts +5 -3
- package/src/adapter/elysia.ts +3 -1
- package/src/adapter/hono.ts +4 -3
- package/src/adapter/utils.ts +42 -4
- package/src/index.ts +2 -18
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
|
|
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,
|
|
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.
|
|
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": "
|
|
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",
|
package/src/adapter/bun.ts
CHANGED
|
@@ -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.
|
|
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
|
-
[`${
|
|
49
|
-
[`${
|
|
50
|
+
[`${assetPath}/*.js`]: serveAsset,
|
|
51
|
+
[`${assetPath}/*.js.map`]: serveAsset,
|
|
50
52
|
};
|
|
51
53
|
};
|
package/src/adapter/elysia.ts
CHANGED
|
@@ -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(`${
|
|
38
|
+
.get(`${assetPath}/*`, ({ request, params }) =>
|
|
37
39
|
createAssetResponse(request, ssrDir, params["*"], dev),
|
|
38
40
|
);
|
|
39
41
|
};
|
package/src/adapter/hono.ts
CHANGED
|
@@ -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
|
-
|
|
187
|
-
app.get(
|
|
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
|
};
|
package/src/adapter/utils.ts
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
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
|
|
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
|