@valentinkolb/ssr 0.9.0 → 0.10.1
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 +51 -2
- package/package.json +14 -17
- package/src/adapter/bun.ts +8 -17
- package/src/adapter/elysia.ts +6 -11
- package/src/adapter/hono.ts +5 -14
- package/src/adapter/utils.ts +72 -5
- package/src/build.ts +30 -6
- package/src/index.ts +13 -2
- package/src/nav.ts +229 -0
package/README.md
CHANGED
|
@@ -42,12 +42,16 @@ What is intentionally not included:
|
|
|
42
42
|
- no build tool wrapper around Bun
|
|
43
43
|
|
|
44
44
|
Use the libraries you already prefer. This package only handles SSR and islands hydration.
|
|
45
|
+
The optional `@valentinkolb/ssr/nav` subpath provides progressive anchor
|
|
46
|
+
enhancement for islands, but it still does not add route matching, loaders, or
|
|
47
|
+
SPA routing.
|
|
45
48
|
|
|
46
49
|
## Features
|
|
47
50
|
|
|
48
51
|
- Small SSR core with Bun-native build/plugin flow
|
|
49
52
|
- Adapters for Bun, Hono, and Elysia
|
|
50
53
|
- Type-safe Hono page helper via `createSSRHandler`
|
|
54
|
+
- Optional progressive navigation helpers via `@valentinkolb/ssr/nav`
|
|
51
55
|
- Monorepo support via `rootDir`
|
|
52
56
|
- Public path mounting via `basePath` for microfrontends
|
|
53
57
|
- Stable file-path-based island IDs (collision-safe across workspace packages)
|
|
@@ -61,7 +65,7 @@ bun add @valentinkolb/ssr solid-js
|
|
|
61
65
|
# choose adapter deps you need
|
|
62
66
|
bun add hono
|
|
63
67
|
# or
|
|
64
|
-
bun add elysia
|
|
68
|
+
bun add elysia
|
|
65
69
|
```
|
|
66
70
|
|
|
67
71
|
## Required TypeScript settings
|
|
@@ -174,6 +178,48 @@ NODE_ENV=development bun --watch --preload=./scripts/preload.ts src/server.ts
|
|
|
174
178
|
- Hono: `@valentinkolb/ssr/hono`
|
|
175
179
|
- Elysia: `@valentinkolb/ssr/elysia`
|
|
176
180
|
|
|
181
|
+
## Optional Navigation Helpers
|
|
182
|
+
|
|
183
|
+
`@valentinkolb/ssr/nav` is an opt-in browser helper for islands that want to
|
|
184
|
+
update URL history after they have already updated client state.
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
import { createSignal } from "solid-js";
|
|
188
|
+
import { Link, type LinkNavigateEvent } from "@valentinkolb/ssr/nav";
|
|
189
|
+
|
|
190
|
+
export default function Tabs() {
|
|
191
|
+
const [tab, setTab] = createSignal("alpha");
|
|
192
|
+
|
|
193
|
+
const openTab = (nav: LinkNavigateEvent) => {
|
|
194
|
+
const next = nav.url.searchParams.get("tab") ?? "alpha";
|
|
195
|
+
setTab(next);
|
|
196
|
+
nav.replaceWith(`/demo?tab=${next}`, { scroll: "preserve" });
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<Link href="/demo?tab=beta" scroll="preserve" onNavigate={openTab}>
|
|
201
|
+
Open beta
|
|
202
|
+
</Link>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`Link` renders a real `<a href>` during SSR. Enhanced clicks only run in the
|
|
208
|
+
browser for same-origin, left-click navigation without modifier keys. Without
|
|
209
|
+
`onNavigate`, `Link` calls `navigate()` directly and only updates browser
|
|
210
|
+
history. With `onNavigate`, the island owns data loading and state updates, then
|
|
211
|
+
calls `nav.push()`, `nav.replaceWith()`, or `nav.fallback()`.
|
|
212
|
+
|
|
213
|
+
Available exports:
|
|
214
|
+
|
|
215
|
+
- `Link`
|
|
216
|
+
- `navigate()`, `navigateTo()`, `documentNavigate()`, `refreshCurrentPath()`
|
|
217
|
+
- `captureScroll()`, `restoreScroll()`, `startViewTransition()`
|
|
218
|
+
- `LinkNavigateEvent`, `LinkProps`, `EnhancedNavigateOptions`, `NavigationScrollMode`, `ScrollSnapshot`
|
|
219
|
+
|
|
220
|
+
Use `data-scroll-preserve="stable-key"` on scroll containers that should keep
|
|
221
|
+
their scroll position across enhanced navigation.
|
|
222
|
+
|
|
177
223
|
## Rendering API
|
|
178
224
|
|
|
179
225
|
`html()` and Hono `ssr()` handlers expect a synchronous render function:
|
|
@@ -222,6 +268,7 @@ createConfig({
|
|
|
222
268
|
rootDir?: string; // default: process.cwd()
|
|
223
269
|
basePath?: string; // default: "", example: "/docs"
|
|
224
270
|
external?: string[]; // passed to Bun.build for island bundle
|
|
271
|
+
devSourcemap?: "none" | "linked" | "inline"; // default: "linked"
|
|
225
272
|
template?: ({ body, scripts, ...custom }) => string | Promise<string>;
|
|
226
273
|
})
|
|
227
274
|
```
|
|
@@ -230,7 +277,9 @@ createConfig({
|
|
|
230
277
|
|
|
231
278
|
- `rootDir` is important in monorepos where server entrypoint and island files live in different packages.
|
|
232
279
|
- `basePath` moves SSR assets and dev endpoints under that prefix, e.g. `/docs/_ssr`.
|
|
280
|
+
- Development builds emit linked source maps by default. Use `"inline"` only when a tool requires embedded maps, or `"none"` to disable them.
|
|
233
281
|
- In production, hydration imports include a build timestamp query (`?v=...`) for cache busting.
|
|
282
|
+
- 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.
|
|
234
283
|
|
|
235
284
|
## Microfrontend mount example
|
|
236
285
|
|
|
@@ -288,7 +337,7 @@ It can:
|
|
|
288
337
|
## Limitations
|
|
289
338
|
|
|
290
339
|
- islands must use default export
|
|
291
|
-
- props must be serializable
|
|
340
|
+
- props must be serializable via `seroval`; do not pass functions, callbacks, event handlers, Solid signals/stores, DOM nodes, or class instances as island/client props
|
|
292
341
|
- nested island/client imports are not supported
|
|
293
342
|
|
|
294
343
|
## Local monorepo example
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@valentinkolb/ssr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.1",
|
|
4
4
|
"description": "Minimal SSR framework for SolidJS and Bun",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
".": "./src/index.ts",
|
|
10
10
|
"./bun": "./src/adapter/bun.ts",
|
|
11
11
|
"./elysia": "./src/adapter/elysia.ts",
|
|
12
|
-
"./hono": "./src/adapter/hono.ts"
|
|
12
|
+
"./hono": "./src/adapter/hono.ts",
|
|
13
|
+
"./nav": "./src/nav.ts"
|
|
13
14
|
},
|
|
14
15
|
"scripts": {
|
|
15
16
|
"test": "bun test"
|
|
@@ -17,34 +18,30 @@
|
|
|
17
18
|
"peerDependencies": {
|
|
18
19
|
"solid-js": "^1.9.0",
|
|
19
20
|
"elysia": "^1.0.0",
|
|
20
|
-
"@elysiajs/static": "^1.0.0",
|
|
21
21
|
"hono": "^4.0.0"
|
|
22
22
|
},
|
|
23
23
|
"peerDependenciesMeta": {
|
|
24
24
|
"elysia": {
|
|
25
25
|
"optional": true
|
|
26
26
|
},
|
|
27
|
-
"@elysiajs/static": {
|
|
28
|
-
"optional": true
|
|
29
|
-
},
|
|
30
27
|
"hono": {
|
|
31
28
|
"optional": true
|
|
32
29
|
}
|
|
33
30
|
},
|
|
34
31
|
"dependencies": {
|
|
35
|
-
"@babel/core": "^7.
|
|
36
|
-
"@babel/preset-typescript": "^7.
|
|
37
|
-
"
|
|
38
|
-
"
|
|
32
|
+
"@babel/core": "^7.29.7",
|
|
33
|
+
"@babel/preset-typescript": "^7.29.7",
|
|
34
|
+
"@types/babel__core": "^7.20.5",
|
|
35
|
+
"babel-preset-solid": "^1.9.12",
|
|
36
|
+
"seroval": "^1.5.5"
|
|
39
37
|
},
|
|
40
38
|
"devDependencies": {
|
|
41
|
-
"@
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"typescript": "^5.0.0"
|
|
39
|
+
"@types/bun": "^1.3.14",
|
|
40
|
+
"elysia": "^1.4.29",
|
|
41
|
+
"file-type": "^21.3.1",
|
|
42
|
+
"hono": "^4.12.30",
|
|
43
|
+
"solid-js": "^1.9.14",
|
|
44
|
+
"typescript": "^5.9.3"
|
|
48
45
|
},
|
|
49
46
|
"license": "MIT",
|
|
50
47
|
"author": "Valentin Kolb",
|
package/src/adapter/bun.ts
CHANGED
|
@@ -4,11 +4,9 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { SsrConfig } from "../index";
|
|
6
6
|
import {
|
|
7
|
+
createAssetResponse,
|
|
7
8
|
getSsrDir,
|
|
8
|
-
getCacheHeaders,
|
|
9
9
|
createReloadResponse,
|
|
10
|
-
notFound,
|
|
11
|
-
safePath,
|
|
12
10
|
} from "./utils";
|
|
13
11
|
|
|
14
12
|
type RouteHandler = (req: Request) => Response | Promise<Response>;
|
|
@@ -39,21 +37,14 @@ export const routes = (config: SsrConfig): Routes => {
|
|
|
39
37
|
}
|
|
40
38
|
: {};
|
|
41
39
|
|
|
40
|
+
const serveAsset: RouteHandler = (req) => {
|
|
41
|
+
const filename = new URL(req.url).pathname.split("/").pop()!;
|
|
42
|
+
return createAssetResponse(req, ssrDir, filename, dev);
|
|
43
|
+
};
|
|
44
|
+
|
|
42
45
|
return {
|
|
43
46
|
...devRoutes,
|
|
44
|
-
|
|
45
|
-
[`${ssrPath}/*.js`]:
|
|
46
|
-
const filename = new URL(req.url).pathname.split("/").pop()!;
|
|
47
|
-
const path = safePath(ssrDir, filename);
|
|
48
|
-
if (!path) return notFound();
|
|
49
|
-
const file = Bun.file(path);
|
|
50
|
-
if (!(await file.exists())) return notFound();
|
|
51
|
-
return new Response(file, {
|
|
52
|
-
headers: {
|
|
53
|
-
"Content-Type": file.type,
|
|
54
|
-
"Cache-Control": getCacheHeaders(dev),
|
|
55
|
-
},
|
|
56
|
-
});
|
|
57
|
-
},
|
|
47
|
+
[`${ssrPath}/*.js`]: serveAsset,
|
|
48
|
+
[`${ssrPath}/*.js.map`]: serveAsset,
|
|
58
49
|
};
|
|
59
50
|
};
|
package/src/adapter/elysia.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Elysia adapter - provides Elysia plugin with SSR routes.
|
|
3
|
-
* Uses
|
|
3
|
+
* Uses the shared BunFile-backed asset response path.
|
|
4
4
|
*/
|
|
5
5
|
import { Elysia } from "elysia";
|
|
6
|
-
import { staticPlugin } from "@elysiajs/static";
|
|
7
6
|
import type { SsrConfig } from "../index";
|
|
8
7
|
import {
|
|
8
|
+
createAssetResponse,
|
|
9
9
|
getSsrDir,
|
|
10
|
-
getCacheHeaders,
|
|
11
10
|
createReloadResponse,
|
|
12
11
|
notFound,
|
|
13
12
|
} from "./utils";
|
|
@@ -29,13 +28,9 @@ export const routes = (config: SsrConfig) => {
|
|
|
29
28
|
const ssrDir = getSsrDir(config);
|
|
30
29
|
|
|
31
30
|
return new Elysia({ name: "ssr" })
|
|
32
|
-
.use(
|
|
33
|
-
staticPlugin({
|
|
34
|
-
assets: ssrDir,
|
|
35
|
-
prefix: ssrPath,
|
|
36
|
-
headers: { "Cache-Control": getCacheHeaders(dev) },
|
|
37
|
-
}),
|
|
38
|
-
)
|
|
39
31
|
.get(`${ssrPath}/_reload`, () => (dev ? createReloadResponse() : notFound()))
|
|
40
|
-
.get(`${ssrPath}/_ping`, () => (dev ? new Response("ok") : notFound()))
|
|
32
|
+
.get(`${ssrPath}/_ping`, () => (dev ? new Response("ok") : notFound()))
|
|
33
|
+
.get(`${ssrPath}/*`, ({ request, params }) =>
|
|
34
|
+
createAssetResponse(request, ssrDir, params["*"], dev),
|
|
35
|
+
);
|
|
41
36
|
};
|
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 {
|
|
9
|
+
import { createAssetResponse, getSsrDir, createReloadResponse } from "./utils";
|
|
10
10
|
|
|
11
11
|
// ============================================================================
|
|
12
12
|
// Types
|
|
@@ -176,19 +176,10 @@ export const routes = (config: SsrConfig) => {
|
|
|
176
176
|
app.get("/_ping", (c) => c.text("ok"));
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const file = Bun.file(path);
|
|
184
|
-
if (!(await file.exists())) return c.notFound();
|
|
185
|
-
return c.body(await file.arrayBuffer(), {
|
|
186
|
-
headers: {
|
|
187
|
-
"Content-Type": "application/javascript",
|
|
188
|
-
"Cache-Control": getCacheHeaders(dev),
|
|
189
|
-
},
|
|
190
|
-
});
|
|
191
|
-
});
|
|
179
|
+
const serveAsset = (c: Context) => createAssetResponse(c.req.raw, ssrDir, c.req.param("filename"), dev);
|
|
180
|
+
|
|
181
|
+
app.get("/:filename{.+\\.js$}", serveAsset);
|
|
182
|
+
app.get("/:filename{.+\\.js\\.map$}", serveAsset);
|
|
192
183
|
|
|
193
184
|
return app;
|
|
194
185
|
};
|
package/src/adapter/utils.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared utilities for SSR adapters - path helpers,
|
|
3
|
-
* SSE
|
|
2
|
+
* Shared utilities for SSR adapters - path helpers, asset responses,
|
|
3
|
+
* SSE live reload, and security utilities.
|
|
4
4
|
*/
|
|
5
5
|
import { dirname, join, resolve } from "path";
|
|
6
6
|
import type { SsrConfig } from "../index";
|
|
@@ -34,11 +34,78 @@ export const toSsrPath = (basePath: string): string =>
|
|
|
34
34
|
export const getSsrDir = (config: SsrConfig): string =>
|
|
35
35
|
join(config.dev ? config.rootDir ?? process.cwd() : dirname(Bun.main), "_ssr");
|
|
36
36
|
|
|
37
|
+
const HASHED_CHUNK = /^chunk-[a-z0-9]+\.js$/i;
|
|
38
|
+
const ASSET_FILE = /^[a-z0-9._-]+\.js(?:\.map)?$/i;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Stable entry names can change during development. Content-hashed chunks
|
|
42
|
+
* cannot, so the browser may retain them across page navigations.
|
|
43
|
+
*/
|
|
44
|
+
export const getCacheHeaders = (dev: boolean, filename = "") =>
|
|
45
|
+
!dev || HASHED_CHUNK.test(filename) ? "public, max-age=31536000, immutable" : "no-cache";
|
|
46
|
+
|
|
47
|
+
const matchesEtag = (header: string, etag: string): boolean => {
|
|
48
|
+
const normalized = etag.replace(/^W\//, "");
|
|
49
|
+
return header.split(",").some((candidate) => {
|
|
50
|
+
const value = candidate.trim();
|
|
51
|
+
return value === "*" || value.replace(/^W\//, "") === normalized;
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const isNotModified = (request: Request, etag: string, lastModified: number): boolean => {
|
|
56
|
+
const ifNoneMatch = request.headers.get("If-None-Match");
|
|
57
|
+
if (ifNoneMatch) return matchesEtag(ifNoneMatch, etag);
|
|
58
|
+
|
|
59
|
+
const ifModifiedSince = request.headers.get("If-Modified-Since");
|
|
60
|
+
if (!ifModifiedSince) return false;
|
|
61
|
+
const since = Date.parse(ifModifiedSince);
|
|
62
|
+
return Number.isFinite(since) && Math.floor(lastModified / 1000) <= Math.floor(since / 1000);
|
|
63
|
+
};
|
|
64
|
+
|
|
37
65
|
/**
|
|
38
|
-
*
|
|
66
|
+
* Serves generated island assets without eagerly buffering their contents.
|
|
67
|
+
* Dev validators make stable entries cheap to revalidate while preserving
|
|
68
|
+
* immediate rebuild visibility.
|
|
39
69
|
*/
|
|
40
|
-
export const
|
|
41
|
-
|
|
70
|
+
export const createAssetResponse = async (
|
|
71
|
+
request: Request,
|
|
72
|
+
directory: string,
|
|
73
|
+
filename: string,
|
|
74
|
+
dev: boolean,
|
|
75
|
+
): Promise<Response> => {
|
|
76
|
+
if (!ASSET_FILE.test(filename)) return notFound();
|
|
77
|
+
const path = safePath(directory, filename);
|
|
78
|
+
if (!path) return notFound();
|
|
79
|
+
|
|
80
|
+
const contentType = filename.endsWith(".map")
|
|
81
|
+
? "application/json; charset=utf-8"
|
|
82
|
+
: "application/javascript";
|
|
83
|
+
const file = Bun.file(path, { type: contentType });
|
|
84
|
+
if (!(await file.exists())) return notFound();
|
|
85
|
+
|
|
86
|
+
const cacheControl = getCacheHeaders(dev, filename);
|
|
87
|
+
if (!dev) {
|
|
88
|
+
return new Response(file, {
|
|
89
|
+
headers: { "Content-Type": contentType, "Cache-Control": cacheControl },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const lastModified = file.lastModified;
|
|
94
|
+
const etag = `W/"${file.size.toString(16)}-${Math.trunc(lastModified).toString(16)}"`;
|
|
95
|
+
const validatorHeaders = {
|
|
96
|
+
"Cache-Control": cacheControl,
|
|
97
|
+
ETag: etag,
|
|
98
|
+
"Last-Modified": new Date(lastModified).toUTCString(),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (isNotModified(request, etag, lastModified)) {
|
|
102
|
+
return new Response(null, { status: 304, headers: validatorHeaders });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return new Response(file, {
|
|
106
|
+
headers: { "Content-Type": contentType, ...validatorHeaders },
|
|
107
|
+
});
|
|
108
|
+
};
|
|
42
109
|
|
|
43
110
|
/**
|
|
44
111
|
* SSE headers for live reload
|
package/src/build.ts
CHANGED
|
@@ -4,17 +4,35 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { relative, resolve } from "path";
|
|
6
6
|
import { Glob } from "bun";
|
|
7
|
+
import { unlink } from "fs/promises";
|
|
7
8
|
import { transform } from "./transform";
|
|
8
9
|
import { ISLAND_ID_LENGTH, islandIdFromFile, toStableKey } from "./island-id";
|
|
9
10
|
|
|
10
11
|
type ComponentType = "island" | "client";
|
|
11
12
|
|
|
13
|
+
export type DevSourcemap = "none" | "linked" | "inline";
|
|
14
|
+
|
|
12
15
|
const getComponentType = (path: string): ComponentType => (path.includes(".client.") ? "client" : "island");
|
|
13
16
|
|
|
14
17
|
const getSelector = (type: ComponentType, id: string) =>
|
|
15
18
|
type === "island" ? `solid-island[data-id="${id}"]` : `solid-client[data-id="${id}"]`;
|
|
16
19
|
|
|
17
20
|
const fmt = (ms: number) => (ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${Math.round(ms)}ms`);
|
|
21
|
+
const GENERATED_ASSET = /^(?:[a-f0-9]{12}|chunk-[a-z0-9]+)\.js(?:\.map)?$/i;
|
|
22
|
+
|
|
23
|
+
const removeStaleBuildAssets = async (outdir: string, outputs: readonly Bun.BuildArtifact[]): Promise<number> => {
|
|
24
|
+
const current = new Set(outputs.map((output) => resolve(output.path)));
|
|
25
|
+
let removed = 0;
|
|
26
|
+
|
|
27
|
+
for await (const file of new Glob("*.js*").scan({ cwd: outdir, absolute: true })) {
|
|
28
|
+
const filename = file.slice(file.lastIndexOf("/") + 1);
|
|
29
|
+
if (!GENERATED_ASSET.test(filename) || current.has(resolve(file))) continue;
|
|
30
|
+
await unlink(file);
|
|
31
|
+
removed += 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return removed;
|
|
35
|
+
};
|
|
18
36
|
|
|
19
37
|
/**
|
|
20
38
|
* Workaround for Bun bundler bug: duplicate export statements in shared chunks
|
|
@@ -73,9 +91,10 @@ export const buildIslands = async (options: {
|
|
|
73
91
|
cwd: string;
|
|
74
92
|
verbose: boolean;
|
|
75
93
|
dev?: boolean;
|
|
94
|
+
devSourcemap?: DevSourcemap;
|
|
76
95
|
external?: string[];
|
|
77
96
|
}): Promise<void> => {
|
|
78
|
-
const { pattern, outdir, cwd, verbose, dev = false, external } = options;
|
|
97
|
+
const { pattern, outdir, cwd, verbose, dev = false, devSourcemap = "linked", external } = options;
|
|
79
98
|
const resolvedCwd = resolve(cwd);
|
|
80
99
|
|
|
81
100
|
const totalStart = performance.now();
|
|
@@ -137,7 +156,7 @@ export const buildIslands = async (options: {
|
|
|
137
156
|
external,
|
|
138
157
|
minify: !dev,
|
|
139
158
|
splitting: true,
|
|
140
|
-
sourcemap: dev ?
|
|
159
|
+
sourcemap: dev ? devSourcemap : "none",
|
|
141
160
|
plugins: [
|
|
142
161
|
{
|
|
143
162
|
name: "solid-islands",
|
|
@@ -181,10 +200,15 @@ export const buildIslands = async (options: {
|
|
|
181
200
|
],
|
|
182
201
|
});
|
|
183
202
|
|
|
184
|
-
if (result.success
|
|
185
|
-
await
|
|
186
|
-
|
|
187
|
-
|
|
203
|
+
if (result.success) {
|
|
204
|
+
const removed = await removeStaleBuildAssets(outdir, result.outputs);
|
|
205
|
+
if (verbose && removed > 0) console.log(`Removed ${removed} stale build asset(s).`);
|
|
206
|
+
|
|
207
|
+
if (!dev) {
|
|
208
|
+
await dedupeSharedChunkExports(outdir, verbose);
|
|
209
|
+
} else if (verbose) {
|
|
210
|
+
console.log("Skipped shared-chunk export rewrite in dev mode.");
|
|
211
|
+
}
|
|
188
212
|
}
|
|
189
213
|
|
|
190
214
|
if (verbose) {
|
package/src/index.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { JSX } from "solid-js";
|
|
|
9
9
|
import type { BunPlugin } from "bun";
|
|
10
10
|
import { statSync } from "fs";
|
|
11
11
|
import { transform } from "./transform";
|
|
12
|
-
import { buildIslands } from "./build";
|
|
12
|
+
import { buildIslands, type DevSourcemap } from "./build";
|
|
13
13
|
import { join, dirname, resolve } from "path";
|
|
14
14
|
import { resolveIslandImport } from "./island-resolve";
|
|
15
15
|
import { normalizeBasePath, toSsrPath } from "./adapter/utils";
|
|
@@ -51,6 +51,8 @@ export type SsrOptions<T extends object = object> = {
|
|
|
51
51
|
basePath?: string;
|
|
52
52
|
/** Modules to exclude from the island bundle (passed to Bun.build) */
|
|
53
53
|
external?: string[];
|
|
54
|
+
/** Development island sourcemaps (default: "linked") */
|
|
55
|
+
devSourcemap?: DevSourcemap;
|
|
54
56
|
/** HTML template function (optional, has default) */
|
|
55
57
|
template?: (
|
|
56
58
|
ctx: {
|
|
@@ -112,7 +114,15 @@ export type SsrResult<T extends object> = {
|
|
|
112
114
|
* ```
|
|
113
115
|
*/
|
|
114
116
|
export const createConfig = <T extends object = object>(options: SsrOptions<T> = {}): SsrResult<T> => {
|
|
115
|
-
const {
|
|
117
|
+
const {
|
|
118
|
+
dev = false,
|
|
119
|
+
verbose,
|
|
120
|
+
external,
|
|
121
|
+
devSourcemap = "linked",
|
|
122
|
+
template,
|
|
123
|
+
rootDir: rootDirOption,
|
|
124
|
+
basePath: basePathOption,
|
|
125
|
+
} = options;
|
|
116
126
|
const rootDir = resolve(rootDirOption ?? process.cwd());
|
|
117
127
|
const basePath = normalizeBasePath(basePathOption);
|
|
118
128
|
const ssrPath = toSsrPath(basePath);
|
|
@@ -200,6 +210,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
200
210
|
cwd: rootDir,
|
|
201
211
|
verbose: verbose ?? !dev,
|
|
202
212
|
dev,
|
|
213
|
+
devSourcemap,
|
|
203
214
|
external,
|
|
204
215
|
});
|
|
205
216
|
};
|
package/src/nav.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in browser navigation helpers for SSR islands.
|
|
3
|
+
*
|
|
4
|
+
* This is not a router. Links remain real anchors and apps decide whether an
|
|
5
|
+
* enhanced click can update client state before committing browser history.
|
|
6
|
+
*/
|
|
7
|
+
import type { JSX } from "solid-js";
|
|
8
|
+
import { createDynamic } from "solid-js/web";
|
|
9
|
+
|
|
10
|
+
type AnchorProps = JSX.AnchorHTMLAttributes<HTMLAnchorElement>;
|
|
11
|
+
|
|
12
|
+
export type NavigationScrollMode = "top" | "preserve" | "manual";
|
|
13
|
+
|
|
14
|
+
export type ScrollSnapshot = {
|
|
15
|
+
window: { x: number; y: number };
|
|
16
|
+
regions: Array<{
|
|
17
|
+
key: string;
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
}>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type EnhancedNavigateOptions = {
|
|
24
|
+
replace?: boolean;
|
|
25
|
+
scroll?: NavigationScrollMode;
|
|
26
|
+
scrollSnapshot?: ScrollSnapshot;
|
|
27
|
+
viewTransition?: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type LinkNavigateEvent = {
|
|
31
|
+
event: MouseEvent;
|
|
32
|
+
href: string;
|
|
33
|
+
url: URL;
|
|
34
|
+
replace: boolean;
|
|
35
|
+
scroll: NavigationScrollMode;
|
|
36
|
+
push: (href?: string, options?: EnhancedNavigateOptions) => void;
|
|
37
|
+
replaceWith: (href?: string, options?: Omit<EnhancedNavigateOptions, "replace">) => void;
|
|
38
|
+
fallback: (href?: string) => void;
|
|
39
|
+
scrollSnapshot: ScrollSnapshot;
|
|
40
|
+
captureScroll: (selector?: string) => ScrollSnapshot;
|
|
41
|
+
restoreScroll: typeof restoreScroll;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type LinkProps = Omit<AnchorProps, "href" | "onClick"> & {
|
|
45
|
+
href: string;
|
|
46
|
+
replace?: boolean;
|
|
47
|
+
scroll?: NavigationScrollMode;
|
|
48
|
+
onClick?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>;
|
|
49
|
+
onNavigate?: (event: LinkNavigateEvent) => void | Promise<void>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const SCROLL_PRESERVE_SELECTOR = "[data-scroll-preserve]";
|
|
53
|
+
|
|
54
|
+
type ViewTransitionDocument = Document & {
|
|
55
|
+
startViewTransition?: (callback: () => void | Promise<void>) => unknown;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns the current URL path + query, without the hash.
|
|
60
|
+
*/
|
|
61
|
+
export const currentPathWithQuery = (): string => {
|
|
62
|
+
const url = new URL(window.location.href);
|
|
63
|
+
return `${url.pathname}${url.search}`;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Navigates to the current path + query with a full document navigation.
|
|
68
|
+
*/
|
|
69
|
+
export const refreshCurrentPath = (): void => {
|
|
70
|
+
window.location.assign(currentPathWithQuery());
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Navigates with a full document navigation.
|
|
75
|
+
*/
|
|
76
|
+
export const navigateTo = (href: string): void => {
|
|
77
|
+
window.location.assign(href);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export const startViewTransition = (callback: () => void | Promise<void>): void => {
|
|
81
|
+
const doc = document as ViewTransitionDocument;
|
|
82
|
+
if (!doc.startViewTransition) {
|
|
83
|
+
void callback();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
doc.startViewTransition(callback);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const restoreRegionScroll = (snapshot: ScrollSnapshot): void => {
|
|
90
|
+
for (const region of snapshot.regions) {
|
|
91
|
+
const selector = `[data-scroll-preserve="${CSS.escape(region.key)}"]`;
|
|
92
|
+
const el = document.querySelector<HTMLElement>(selector);
|
|
93
|
+
if (!el) continue;
|
|
94
|
+
el.scrollLeft = region.x;
|
|
95
|
+
el.scrollTop = region.y;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Captures window scroll and keyed `[data-scroll-preserve]` regions.
|
|
101
|
+
*/
|
|
102
|
+
export const captureScroll = (selector = SCROLL_PRESERVE_SELECTOR): ScrollSnapshot => ({
|
|
103
|
+
window: { x: window.scrollX, y: window.scrollY },
|
|
104
|
+
regions: Array.from(document.querySelectorAll<HTMLElement>(selector))
|
|
105
|
+
.map((el) => ({
|
|
106
|
+
key: el.dataset.scrollPreserve ?? "",
|
|
107
|
+
x: el.scrollLeft,
|
|
108
|
+
y: el.scrollTop,
|
|
109
|
+
}))
|
|
110
|
+
.filter((region) => region.key.length > 0),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Restores a captured scroll snapshot.
|
|
115
|
+
*/
|
|
116
|
+
export const restoreScroll = (snapshot: ScrollSnapshot, options: { window?: boolean } = {}): void => {
|
|
117
|
+
restoreRegionScroll(snapshot);
|
|
118
|
+
if (options.window === false) return;
|
|
119
|
+
window.scrollTo(snapshot.window.x, snapshot.window.y);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Updates browser history without a document reload.
|
|
124
|
+
*
|
|
125
|
+
* Use this after the current island has already updated its UI or when it is
|
|
126
|
+
* intentionally preserving the current DOM. It does not match routes, load
|
|
127
|
+
* data, or re-render server pages.
|
|
128
|
+
*/
|
|
129
|
+
export const navigate = (href: string, options: EnhancedNavigateOptions = {}): void => {
|
|
130
|
+
const scroll = options.scroll ?? "top";
|
|
131
|
+
const snapshot = scroll === "manual" ? null : (options.scrollSnapshot ?? captureScroll());
|
|
132
|
+
const url = new URL(href, window.location.href);
|
|
133
|
+
const target = `${url.pathname}${url.search}${url.hash}`;
|
|
134
|
+
|
|
135
|
+
const commit = () => {
|
|
136
|
+
if (options.replace) window.history.replaceState(null, "", target);
|
|
137
|
+
else window.history.pushState(null, "", target);
|
|
138
|
+
|
|
139
|
+
if (!snapshot) return;
|
|
140
|
+
restoreRegionScroll(snapshot);
|
|
141
|
+
if (scroll === "preserve") {
|
|
142
|
+
window.scrollTo(snapshot.window.x, snapshot.window.y);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
window.scrollTo(0, 0);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (options.viewTransition === false) {
|
|
149
|
+
commit();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
startViewTransition(commit);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const documentNavigate = (href: string, options: { replace?: boolean } = {}): void => {
|
|
156
|
+
if (options.replace) window.location.replace(href);
|
|
157
|
+
else window.location.assign(href);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const shouldEnhanceClick = (event: MouseEvent, anchor: HTMLAnchorElement): boolean => {
|
|
161
|
+
if (event.defaultPrevented || event.button !== 0) return false;
|
|
162
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false;
|
|
163
|
+
if (anchor.target && anchor.target !== "_self") return false;
|
|
164
|
+
if (anchor.hasAttribute("download")) return false;
|
|
165
|
+
|
|
166
|
+
const url = new URL(anchor.href, window.location.href);
|
|
167
|
+
return url.origin === window.location.origin;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const callUserClick = (handler: LinkProps["onClick"], event: MouseEvent, anchor: HTMLAnchorElement): void => {
|
|
171
|
+
if (!handler) return;
|
|
172
|
+
if (typeof handler === "function") {
|
|
173
|
+
handler(event as MouseEvent & { currentTarget: HTMLAnchorElement; target: Element });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
(handler as unknown as EventListenerObject).handleEvent(event);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* SSR-safe anchor with opt-in progressive navigation.
|
|
181
|
+
*/
|
|
182
|
+
export function Link(props: LinkProps) {
|
|
183
|
+
const anchorProps = () => {
|
|
184
|
+
const { href: _href, replace: _replace, scroll: _scroll, onNavigate: _onNavigate, onClick: _onClick, ...rest } = props;
|
|
185
|
+
return rest;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const handleClick: JSX.EventHandler<HTMLAnchorElement, MouseEvent> = (event) => {
|
|
189
|
+
callUserClick(props.onClick, event, event.currentTarget);
|
|
190
|
+
if (!shouldEnhanceClick(event, event.currentTarget)) return;
|
|
191
|
+
|
|
192
|
+
const href = props.href;
|
|
193
|
+
const url = new URL(href, window.location.href);
|
|
194
|
+
const scroll = props.scroll ?? "top";
|
|
195
|
+
const replace = Boolean(props.replace);
|
|
196
|
+
const scrollSnapshot = captureScroll();
|
|
197
|
+
|
|
198
|
+
event.preventDefault();
|
|
199
|
+
|
|
200
|
+
if (!props.onNavigate) {
|
|
201
|
+
navigate(href, { replace, scroll, scrollSnapshot });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
startViewTransition(() =>
|
|
206
|
+
props.onNavigate!({
|
|
207
|
+
event,
|
|
208
|
+
href,
|
|
209
|
+
url,
|
|
210
|
+
replace,
|
|
211
|
+
scroll,
|
|
212
|
+
push: (nextHref = href, options = {}) =>
|
|
213
|
+
navigate(nextHref, { replace: false, scroll, scrollSnapshot, viewTransition: false, ...options }),
|
|
214
|
+
replaceWith: (nextHref = href, options = {}) =>
|
|
215
|
+
navigate(nextHref, { replace: true, scroll, scrollSnapshot, viewTransition: false, ...options }),
|
|
216
|
+
fallback: (nextHref = href) => documentNavigate(nextHref, { replace }),
|
|
217
|
+
scrollSnapshot,
|
|
218
|
+
captureScroll,
|
|
219
|
+
restoreScroll,
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return createDynamic(() => "a", {
|
|
225
|
+
...anchorProps(),
|
|
226
|
+
href: props.href,
|
|
227
|
+
onClick: handleClick,
|
|
228
|
+
});
|
|
229
|
+
}
|