@k2b/ssr 0.13.1 → 0.15.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 +83 -7
- 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/build.ts +3 -2
- package/src/index.ts +8 -18
- package/src/mount.ts +68 -0
- package/src/nav.ts +6 -15
package/README.md
CHANGED
|
@@ -55,10 +55,11 @@ 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
|
|
62
|
+
- Default error boundary per island/client instance with a retry fallback
|
|
62
63
|
|
|
63
64
|
## Install
|
|
64
65
|
|
|
@@ -233,9 +234,9 @@ export default function Tabs() {
|
|
|
233
234
|
|
|
234
235
|
`Link` renders a real `<a href>` during SSR. Enhanced clicks only run in the
|
|
235
236
|
browser for same-origin, left-click navigation without modifier keys. Without
|
|
236
|
-
`onNavigate`,
|
|
237
|
-
|
|
238
|
-
calls `nav.push()`, `nav.replaceWith()`, or `nav.fallback()`.
|
|
237
|
+
`onNavigate`, the anchor keeps native document navigation and ignores `replace`
|
|
238
|
+
and `scroll`. With `onNavigate`, the island owns data loading and state updates,
|
|
239
|
+
then calls `nav.push()`, `nav.replaceWith()`, or `nav.fallback()`.
|
|
239
240
|
|
|
240
241
|
Use `listenPopState()` whenever `nav.push()` represents client state. Browser
|
|
241
242
|
Back/Forward changes history but cannot infer how an island maps the URL back to
|
|
@@ -246,7 +247,7 @@ Navigation behavior:
|
|
|
246
247
|
|
|
247
248
|
- reactive anchor props remain reactive after `Link` renders
|
|
248
249
|
- same-document hash links retain native target scrolling unless `onNavigate`
|
|
249
|
-
|
|
250
|
+
explicitly takes ownership
|
|
250
251
|
- relative URLs follow `document.baseURI`
|
|
251
252
|
- cross-origin `navigate()` calls use full document navigation
|
|
252
253
|
- replace navigation preserves existing `history.state` unless `state` is set
|
|
@@ -263,6 +264,72 @@ Available exports:
|
|
|
263
264
|
Use `data-scroll-preserve="stable-key"` on scroll containers that should keep
|
|
264
265
|
their scroll position across enhanced navigation.
|
|
265
266
|
|
|
267
|
+
## Error handling in islands
|
|
268
|
+
|
|
269
|
+
Every island and client component instance is mounted inside its own error
|
|
270
|
+
boundary. No setup is needed. If a component throws while it mounts, while its
|
|
271
|
+
props are deserialized, or later in a reactive update, only that instance is
|
|
272
|
+
replaced with a fallback. Other instances and other islands keep working, also
|
|
273
|
+
when they are updated by the same signal write.
|
|
274
|
+
|
|
275
|
+
The default fallback is plain, unstyled markup:
|
|
276
|
+
|
|
277
|
+
```html
|
|
278
|
+
<div role="alert" data-ssr-error>
|
|
279
|
+
This part of the page could not be displayed. <button type="button">Try again</button>
|
|
280
|
+
</div>
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**Try again** remounts the component with fresh state from the same
|
|
284
|
+
`data-props`. Style the fallback through `[data-ssr-error]`.
|
|
285
|
+
|
|
286
|
+
Each caught error dispatches a bubbling, cancelable `ssr:island-error` event on
|
|
287
|
+
the `<solid-island>` or `<solid-client>` element. Its `detail` contains
|
|
288
|
+
`error`, the component `id`, and `reset()`. Unless a listener calls
|
|
289
|
+
`preventDefault()`, the error is then passed to `reportError()`, so it appears in
|
|
290
|
+
the console and in the window `error` event like an uncaught error. Call
|
|
291
|
+
`preventDefault()` only when you report the error yourself; the fallback is
|
|
292
|
+
still shown.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
addEventListener("ssr:island-error", (event) => {
|
|
296
|
+
if (!(event instanceof CustomEvent)) return;
|
|
297
|
+
const { error, id } = event.detail;
|
|
298
|
+
myReporter.capture(error, { island: id });
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
});
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
To replace the default fallback, for example to localize it, set
|
|
304
|
+
`errorFallback` to a module whose default export receives `{ error, reset }`:
|
|
305
|
+
|
|
306
|
+
```tsx
|
|
307
|
+
// src/IslandError.tsx
|
|
308
|
+
import type { IslandErrorProps } from "@k2b/ssr";
|
|
309
|
+
|
|
310
|
+
export default function IslandError(props: IslandErrorProps) {
|
|
311
|
+
return (
|
|
312
|
+
<p role="alert" class="island-error">
|
|
313
|
+
Dieser Bereich konnte nicht angezeigt werden.
|
|
314
|
+
<button type="button" onClick={props.reset}>Erneut versuchen</button>
|
|
315
|
+
</p>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
createConfig({ errorFallback: "./src/IslandError.tsx" });
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The option is optional and only changes presentation. If the custom fallback
|
|
325
|
+
throws, the default fallback is shown and its error is reported too.
|
|
326
|
+
|
|
327
|
+
Error boundaries inside your components are closer to the error and still take
|
|
328
|
+
precedence. Errors thrown directly in event handlers or in async code outside a
|
|
329
|
+
Solid computation stay ordinary uncaught browser errors. Errors in reactive
|
|
330
|
+
updates no longer propagate to the code that wrote the signal; that code now
|
|
331
|
+
sees the island fallback instead.
|
|
332
|
+
|
|
266
333
|
## Rendering API
|
|
267
334
|
|
|
268
335
|
`html()` and Hono `ssr()` handlers expect a synchronous render function:
|
|
@@ -313,6 +380,7 @@ createConfig({
|
|
|
313
380
|
basePath?: string; // default: "", example: "/docs"
|
|
314
381
|
external?: string[]; // passed to Bun.build for island bundle
|
|
315
382
|
devSourcemap?: "none" | "linked" | "inline"; // default: "linked"
|
|
383
|
+
errorFallback?: string; // optional island error fallback module, relative to rootDir
|
|
316
384
|
template?: ({ body, scripts, ...custom }) => string | Promise<string>;
|
|
317
385
|
})
|
|
318
386
|
```
|
|
@@ -332,10 +400,12 @@ createConfig({
|
|
|
332
400
|
```
|
|
333
401
|
|
|
334
402
|
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.
|
|
403
|
+
- `errorFallback` replaces the presentation of the default island error boundary. Islands are protected without it; see [Error handling in islands](#error-handling-in-islands).
|
|
335
404
|
- `basePath` moves SSR assets and dev endpoints under that prefix, e.g. `/docs/_ssr`.
|
|
336
405
|
- 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.
|
|
406
|
+
- 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.
|
|
407
|
+
- 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.
|
|
408
|
+
- 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
409
|
|
|
340
410
|
## Microfrontend mount example
|
|
341
411
|
|
|
@@ -360,6 +430,12 @@ With this setup, hydration chunks and dev endpoints are served from `/docs/_ssr/
|
|
|
360
430
|
|
|
361
431
|
## Build for production
|
|
362
432
|
|
|
433
|
+
Set the environment before starting Bun so both the build configuration and bundled code use production mode:
|
|
434
|
+
|
|
435
|
+
```bash
|
|
436
|
+
NODE_ENV=production bun scripts/build.ts
|
|
437
|
+
```
|
|
438
|
+
|
|
363
439
|
```ts
|
|
364
440
|
// scripts/build.ts
|
|
365
441
|
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.15.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/build.ts
CHANGED
|
@@ -95,8 +95,9 @@ export const buildIslands = async (options: {
|
|
|
95
95
|
dev?: boolean;
|
|
96
96
|
devSourcemap?: DevSourcemap;
|
|
97
97
|
external?: string[];
|
|
98
|
+
errorFallback?: string;
|
|
98
99
|
}): Promise<void> => {
|
|
99
|
-
const { pattern, outdir, cwd, componentRoots, verbose, dev = false, devSourcemap = "linked", external } = options;
|
|
100
|
+
const { pattern, outdir, cwd, componentRoots, verbose, dev = false, devSourcemap = "linked", external, errorFallback } = options;
|
|
100
101
|
const resolvedCwd = resolve(cwd);
|
|
101
102
|
|
|
102
103
|
const totalStart = performance.now();
|
|
@@ -200,7 +201,7 @@ export const buildIslands = async (options: {
|
|
|
200
201
|
}
|
|
201
202
|
|
|
202
203
|
return {
|
|
203
|
-
contents: `import{
|
|
204
|
+
contents: `import{mount}from${JSON.stringify(join(import.meta.dir, "mount.ts"))};import C from${JSON.stringify(component.path)};${errorFallback ? `import F from${JSON.stringify(errorFallback)};` : ""}mount(C,${JSON.stringify(component.selector)}${errorFallback ? ",F" : ""})`,
|
|
204
205
|
loader: "js",
|
|
205
206
|
};
|
|
206
207
|
});
|
package/src/index.ts
CHANGED
|
@@ -7,15 +7,16 @@
|
|
|
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
|
|
|
18
|
+
export type { IslandErrorProps } from "./mount";
|
|
19
|
+
|
|
19
20
|
// ============================================================================
|
|
20
21
|
// Constants
|
|
21
22
|
// ============================================================================
|
|
@@ -23,19 +24,6 @@ import devClientCode from "./adapter/client.js" with { type: "text" };
|
|
|
23
24
|
/** Glob pattern for island/client component files */
|
|
24
25
|
const COMPONENT_PATTERN = "**/*.{island,client}.tsx";
|
|
25
26
|
|
|
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
27
|
// ============================================================================
|
|
40
28
|
// Types
|
|
41
29
|
// ============================================================================
|
|
@@ -55,6 +43,8 @@ export type SsrOptions<T extends object = object> = {
|
|
|
55
43
|
external?: string[];
|
|
56
44
|
/** Development island sourcemaps (default: "linked") */
|
|
57
45
|
devSourcemap?: DevSourcemap;
|
|
46
|
+
/** Path relative to rootDir (or absolute) to a default-exported Solid component receiving IslandErrorProps. */
|
|
47
|
+
errorFallback?: string;
|
|
58
48
|
/** HTML template function (optional, has default) */
|
|
59
49
|
template?: (
|
|
60
50
|
ctx: {
|
|
@@ -121,6 +111,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
121
111
|
verbose,
|
|
122
112
|
external,
|
|
123
113
|
devSourcemap = "linked",
|
|
114
|
+
errorFallback,
|
|
124
115
|
template,
|
|
125
116
|
rootDir: rootDirOption,
|
|
126
117
|
componentRoots,
|
|
@@ -156,13 +147,11 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
156
147
|
ssrPath,
|
|
157
148
|
};
|
|
158
149
|
|
|
159
|
-
const buildVersion = getBuildVersion(dev);
|
|
160
|
-
|
|
161
150
|
const islandDisplayStyle =
|
|
162
151
|
"<style>solid-client,solid-island{display:contents}</style>";
|
|
163
152
|
|
|
164
153
|
// Hydration script - dynamically loads island/client bundles based on DOM
|
|
165
|
-
const hydrationScript = `<script type="module">const p=${JSON.stringify(ssrPath
|
|
154
|
+
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
155
|
const devConfigScript = `<script>globalThis.__SSR_CONFIG=${JSON.stringify({ ssrPath, reloadId: getReloadId() })}</script>`;
|
|
167
156
|
|
|
168
157
|
// HTML renderer
|
|
@@ -216,6 +205,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
|
|
|
216
205
|
dev,
|
|
217
206
|
devSourcemap,
|
|
218
207
|
external,
|
|
208
|
+
errorFallback: errorFallback ? resolve(rootDir, errorFallback) : undefined,
|
|
219
209
|
});
|
|
220
210
|
};
|
|
221
211
|
|
package/src/mount.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { Component } from "solid-js";
|
|
2
|
+
import { createComponent, ErrorBoundary, render } from "solid-js/web";
|
|
3
|
+
import { deserialize } from "seroval";
|
|
4
|
+
|
|
5
|
+
/** Props received by a configured island error fallback component. */
|
|
6
|
+
export type IslandErrorProps = {
|
|
7
|
+
error: unknown;
|
|
8
|
+
reset: () => void;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const defaultFallback = (reset: () => void): HTMLDivElement => {
|
|
12
|
+
const alert = document.createElement("div");
|
|
13
|
+
alert.setAttribute("role", "alert");
|
|
14
|
+
alert.setAttribute("data-ssr-error", "");
|
|
15
|
+
alert.append("This part of the page could not be displayed. ");
|
|
16
|
+
const button = document.createElement("button");
|
|
17
|
+
button.type = "button";
|
|
18
|
+
button.textContent = "Try again";
|
|
19
|
+
button.addEventListener("click", reset);
|
|
20
|
+
alert.append(button);
|
|
21
|
+
return alert;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const report = (element: HTMLElement, error: unknown, reset: () => void): void => {
|
|
25
|
+
const event = new CustomEvent("ssr:island-error", {
|
|
26
|
+
bubbles: true,
|
|
27
|
+
cancelable: true,
|
|
28
|
+
detail: { error, id: element.dataset.id, reset },
|
|
29
|
+
});
|
|
30
|
+
if (element.dispatchEvent(event)) globalThis.reportError(error);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Mount each island/client instance in its own error boundary. */
|
|
34
|
+
export const mount = <Props extends object>(
|
|
35
|
+
Component: Component<Props>,
|
|
36
|
+
selector: string,
|
|
37
|
+
Fallback?: Component<IslandErrorProps>,
|
|
38
|
+
): void => {
|
|
39
|
+
document.querySelectorAll<HTMLElement>(selector).forEach((element) => {
|
|
40
|
+
const mountElement = (): void => {
|
|
41
|
+
try {
|
|
42
|
+
element.innerHTML = "";
|
|
43
|
+
render(() => createComponent(ErrorBoundary, {
|
|
44
|
+
fallback: (error: unknown, reset: () => void) => {
|
|
45
|
+
report(element, error, reset);
|
|
46
|
+
if (!Fallback) return defaultFallback(reset);
|
|
47
|
+
return createComponent(ErrorBoundary, {
|
|
48
|
+
fallback: (fallbackError: unknown) => {
|
|
49
|
+
globalThis.reportError(fallbackError);
|
|
50
|
+
return defaultFallback(reset);
|
|
51
|
+
},
|
|
52
|
+
get children() {
|
|
53
|
+
return createComponent(Fallback, { error, reset });
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
get children() {
|
|
58
|
+
return createComponent(Component, deserialize<Props>(element.dataset.props || "{}"));
|
|
59
|
+
},
|
|
60
|
+
}), element);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
report(element, error, mountElement);
|
|
63
|
+
element.replaceChildren(defaultFallback(mountElement));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
mountElement();
|
|
67
|
+
});
|
|
68
|
+
};
|
package/src/nav.ts
CHANGED
|
@@ -50,9 +50,12 @@ export type LinkNavigateEvent = {
|
|
|
50
50
|
|
|
51
51
|
export type LinkProps = Omit<AnchorProps, "href" | "onClick"> & {
|
|
52
52
|
href: string;
|
|
53
|
+
/** Default history mode for enhanced clicks. Ignored without `onNavigate`. */
|
|
53
54
|
replace?: boolean;
|
|
55
|
+
/** Default scroll mode for enhanced clicks. Ignored without `onNavigate`. */
|
|
54
56
|
scroll?: NavigationScrollMode;
|
|
55
57
|
onClick?: JSX.EventHandlerUnion<HTMLAnchorElement, MouseEvent>;
|
|
58
|
+
/** Enables enhanced same-origin clicks. Without it, `Link` is a native anchor. */
|
|
56
59
|
onNavigate?: (event: LinkNavigateEvent) => void | Promise<void>;
|
|
57
60
|
};
|
|
58
61
|
|
|
@@ -200,11 +203,6 @@ const shouldEnhanceClick = (event: MouseEvent, anchor: HTMLAnchorElement): boole
|
|
|
200
203
|
return url.origin === window.location.origin;
|
|
201
204
|
};
|
|
202
205
|
|
|
203
|
-
const isSameDocumentHash = (url: URL): boolean => {
|
|
204
|
-
const current = new URL(window.location.href);
|
|
205
|
-
return url.hash.length > 0 && url.pathname === current.pathname && url.search === current.search;
|
|
206
|
-
};
|
|
207
|
-
|
|
208
206
|
const callUserClick = (handler: LinkProps["onClick"], event: MouseEvent, anchor: HTMLAnchorElement): void => {
|
|
209
207
|
if (!handler) return;
|
|
210
208
|
const typedEvent = event as MouseEvent & { currentTarget: HTMLAnchorElement; target: Element };
|
|
@@ -227,30 +225,23 @@ export function Link(props: LinkProps) {
|
|
|
227
225
|
|
|
228
226
|
const handleClick: JSX.EventHandler<HTMLAnchorElement, MouseEvent> = (event) => {
|
|
229
227
|
callUserClick(local.onClick, event, event.currentTarget);
|
|
228
|
+
const onNavigate = local.onNavigate;
|
|
229
|
+
if (!onNavigate) return;
|
|
230
230
|
if (!shouldEnhanceClick(event, event.currentTarget)) return;
|
|
231
231
|
|
|
232
232
|
const href = local.href;
|
|
233
233
|
const url = new URL(event.currentTarget.href);
|
|
234
234
|
|
|
235
|
-
// Preserve native target scrolling unless the application explicitly owns
|
|
236
|
-
// this hash navigation through onNavigate or a scroll option.
|
|
237
|
-
if (!local.onNavigate && local.scroll === undefined && isSameDocumentHash(url)) return;
|
|
238
|
-
|
|
239
235
|
const scroll = local.scroll ?? "top";
|
|
240
236
|
const replace = Boolean(local.replace);
|
|
241
237
|
const scrollSnapshot = captureScroll();
|
|
242
238
|
|
|
243
239
|
event.preventDefault();
|
|
244
240
|
|
|
245
|
-
if (!local.onNavigate) {
|
|
246
|
-
navigate(url.href, { replace, scroll, scrollSnapshot });
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
241
|
let navigationOutcome: "none" | "history" | "document" = "none";
|
|
251
242
|
const runNavigation = async () => {
|
|
252
243
|
try {
|
|
253
|
-
await
|
|
244
|
+
await onNavigate({
|
|
254
245
|
event,
|
|
255
246
|
href,
|
|
256
247
|
url,
|