@kasperrt/smolter 0.3.1 → 0.3.3
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/.generated/client/deployment-check.ts +70 -0
- package/.generated/client/deployment.ts +24 -59
- package/.generated/client/index.ts +8 -2
- package/.generated/core/responses.ts +8 -3
- package/.generated/core/router.ts +32 -0
- package/.generated/core/types.ts +2 -0
- package/.generated/preact/client.tsx +48 -6
- package/.generated/preact/document.tsx +4 -2
- package/.generated/preact/lazy-route.ts +2 -0
- package/.generated/preact/root-context.ts +2 -0
- package/.generated/preact/route-types.ts +1 -0
- package/.generated/preact/router.tsx +9 -3
- package/.generated/server/application.tsx +83 -19
- package/.generated/server/handler.ts +8 -2
- package/.generated/server/http.ts +24 -2
- package/.generated/server/index.ts +17 -5
- package/.generated/server/preact.ts +1 -0
- package/.generated/server/private-response.ts +11 -0
- package/.generated/server/stream-document.ts +121 -0
- package/.generated/vite/application/lazy-files.ts +8 -2
- package/.generated/vite/application/vite.ts +9 -3
- package/.generated/vite/index.ts +4 -2
- package/.generated/vite/transport.ts +4 -1
- package/CHANGELOG.md +15 -0
- package/README.md +29 -11
- package/dist/client/deployment-check.d.ts +6 -0
- package/dist/client/deployment-check.js +51 -0
- package/dist/client/deployment.d.ts +2 -0
- package/dist/client/deployment.js +12 -40
- package/dist/client/index.d.ts +3 -1
- package/dist/client/index.js +3 -3
- package/dist/core/responses.d.ts +1 -1
- package/dist/core/responses.js +3 -3
- package/dist/core/router.js +16 -0
- package/dist/core/types.d.ts +2 -0
- package/dist/preact/client.d.ts +6 -1
- package/dist/preact/client.js +27 -8
- package/dist/preact/document.js +4 -2
- package/dist/preact/lazy-route.d.ts +1 -0
- package/dist/preact/lazy-route.js +2 -1
- package/dist/preact/root-context.d.ts +2 -0
- package/dist/preact/route-types.d.ts +1 -0
- package/dist/preact/router.d.ts +1 -1
- package/dist/preact/router.js +3 -3
- package/dist/server/application.d.ts +4 -1
- package/dist/server/application.js +47 -13
- package/dist/server/handler.d.ts +3 -1
- package/dist/server/handler.js +5 -3
- package/dist/server/http.d.ts +5 -1
- package/dist/server/http.js +10 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +12 -2
- package/dist/server/preact.d.ts +1 -1
- package/dist/server/private-response.d.ts +2 -0
- package/dist/server/private-response.js +13 -0
- package/dist/server/stream-document.d.ts +9 -0
- package/dist/server/stream-document.js +78 -0
- package/dist/vite/application/lazy-files.d.ts +1 -1
- package/dist/vite/application/lazy-files.js +5 -2
- package/dist/vite/application/vite.d.ts +2 -1
- package/dist/vite/application/vite.js +7 -4
- package/dist/vite/index.d.ts +2 -1
- package/dist/vite/index.js +6 -3
- package/dist/vite/transport.d.ts +1 -1
- package/dist/vite/transport.js +3 -2
- package/jsr.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { DeploymentOptions } from "./deployment.ts";
|
|
2
|
+
|
|
3
|
+
export function deploymentCheck(options: DeploymentOptions) {
|
|
4
|
+
const { version, manifestURL, timeout = 2000 } = options;
|
|
5
|
+
const manifest = new URL(manifestURL, location.href);
|
|
6
|
+
const key = `smolter:deployment:${manifest.href}:${version}`;
|
|
7
|
+
let stopped = false;
|
|
8
|
+
let pending: Promise<boolean> | undefined;
|
|
9
|
+
let controller: AbortController | undefined;
|
|
10
|
+
function changed() {
|
|
11
|
+
if (pending) return pending;
|
|
12
|
+
|
|
13
|
+
controller = new AbortController();
|
|
14
|
+
const timer = setTimeout(() => controller?.abort(), timeout);
|
|
15
|
+
pending = fetch(manifest, { cache: "no-store", signal: controller.signal })
|
|
16
|
+
.then(async (response) => {
|
|
17
|
+
if (!response.ok) return false;
|
|
18
|
+
|
|
19
|
+
const data: unknown = await response.json();
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
typeof data === "object" &&
|
|
23
|
+
data !== null &&
|
|
24
|
+
"version" in data &&
|
|
25
|
+
typeof data.version === "string" &&
|
|
26
|
+
data.version.length > 0 &&
|
|
27
|
+
data.version !== version
|
|
28
|
+
);
|
|
29
|
+
})
|
|
30
|
+
.catch(() => false)
|
|
31
|
+
.finally(() => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
pending = undefined;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return pending;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function reload(target: URL) {
|
|
40
|
+
// If storage is unavailable, do not risk reloading the same cached document forever.
|
|
41
|
+
const allowed = await Promise.resolve()
|
|
42
|
+
.then(() => {
|
|
43
|
+
if (sessionStorage.getItem(key)) return false;
|
|
44
|
+
|
|
45
|
+
sessionStorage.setItem(key, "1");
|
|
46
|
+
|
|
47
|
+
return true;
|
|
48
|
+
})
|
|
49
|
+
.catch(() => false);
|
|
50
|
+
|
|
51
|
+
if (allowed && !stopped) {
|
|
52
|
+
if (options?.reload) options.reload(target);
|
|
53
|
+
if (!options?.reload) {
|
|
54
|
+
if (target.href === location.href) location.reload();
|
|
55
|
+
if (target.href !== location.href) location.assign(target.href);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return allowed && !stopped;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
changed,
|
|
64
|
+
reload,
|
|
65
|
+
dispose() {
|
|
66
|
+
stopped = true;
|
|
67
|
+
controller?.abort();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NavigationRouter } from "../core/index.ts";
|
|
2
|
+
import { deploymentCheck } from "./deployment-check.ts";
|
|
2
3
|
|
|
3
4
|
/** Build identity and the fresh, non-cacheable manifest URL emitted by deploymentPlugin. */
|
|
4
5
|
export interface DeploymentOptions {
|
|
@@ -17,69 +18,14 @@ export function watchDeployment(
|
|
|
17
18
|
): () => void {
|
|
18
19
|
if (!options || typeof window === "undefined") return () => {};
|
|
19
20
|
|
|
20
|
-
const
|
|
21
|
+
const check = deploymentCheck(options);
|
|
22
|
+
const { changed, reload } = check;
|
|
21
23
|
const navigate = router.navigate;
|
|
22
24
|
const dispose = router.dispose;
|
|
23
|
-
const manifest = new URL(manifestURL, location.href);
|
|
24
|
-
const key = `smolter:deployment:${manifest.href}:${version}`;
|
|
25
25
|
let sequence = 0;
|
|
26
26
|
let stopped = false;
|
|
27
27
|
let replay = false;
|
|
28
28
|
let destination = new URL(location.href);
|
|
29
|
-
let pending: Promise<boolean> | undefined;
|
|
30
|
-
let controller: AbortController | undefined;
|
|
31
|
-
|
|
32
|
-
function changed() {
|
|
33
|
-
if (pending) return pending;
|
|
34
|
-
|
|
35
|
-
controller = new AbortController();
|
|
36
|
-
const timer = setTimeout(() => controller?.abort(), timeout);
|
|
37
|
-
pending = fetch(manifest, { cache: "no-store", signal: controller.signal })
|
|
38
|
-
.then(async (response) => {
|
|
39
|
-
if (!response.ok) return false;
|
|
40
|
-
|
|
41
|
-
const data: unknown = await response.json();
|
|
42
|
-
|
|
43
|
-
return (
|
|
44
|
-
typeof data === "object" &&
|
|
45
|
-
data !== null &&
|
|
46
|
-
"version" in data &&
|
|
47
|
-
typeof data.version === "string" &&
|
|
48
|
-
data.version.length > 0 &&
|
|
49
|
-
data.version !== version
|
|
50
|
-
);
|
|
51
|
-
})
|
|
52
|
-
.catch(() => false)
|
|
53
|
-
.finally(() => {
|
|
54
|
-
clearTimeout(timer);
|
|
55
|
-
pending = undefined;
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
return pending;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
async function reload(target: URL) {
|
|
62
|
-
// If storage is unavailable, do not risk reloading the same cached document forever.
|
|
63
|
-
const allowed = await Promise.resolve()
|
|
64
|
-
.then(() => {
|
|
65
|
-
if (sessionStorage.getItem(key)) return false;
|
|
66
|
-
|
|
67
|
-
sessionStorage.setItem(key, "1");
|
|
68
|
-
|
|
69
|
-
return true;
|
|
70
|
-
})
|
|
71
|
-
.catch(() => false);
|
|
72
|
-
|
|
73
|
-
if (allowed && !stopped) {
|
|
74
|
-
if (options?.reload) options.reload(target);
|
|
75
|
-
if (!options?.reload) {
|
|
76
|
-
if (target.href === location.href) location.reload();
|
|
77
|
-
if (target.href !== location.href) location.assign(target.href);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return allowed && !stopped;
|
|
82
|
-
}
|
|
83
29
|
|
|
84
30
|
const guarded: NavigationRouter["navigate"] = async (href, operation) => {
|
|
85
31
|
const target = new URL(href, router.getSnapshot().url);
|
|
@@ -127,7 +73,9 @@ export function watchDeployment(
|
|
|
127
73
|
};
|
|
128
74
|
|
|
129
75
|
const preloadError = () => {
|
|
130
|
-
|
|
76
|
+
// Intent prefetch is speculative: it must not reload the current page or
|
|
77
|
+
// consume the recovery marker before a click records its destination.
|
|
78
|
+
if (router.getSnapshot().state !== "loading") return;
|
|
131
79
|
|
|
132
80
|
const current = sequence;
|
|
133
81
|
const target = destination;
|
|
@@ -139,7 +87,7 @@ export function watchDeployment(
|
|
|
139
87
|
function cleanup() {
|
|
140
88
|
stopped = true;
|
|
141
89
|
sequence++;
|
|
142
|
-
|
|
90
|
+
check.dispose();
|
|
143
91
|
window.removeEventListener("popstate", pop, true);
|
|
144
92
|
window.removeEventListener("vite:preloadError", preloadError);
|
|
145
93
|
|
|
@@ -159,3 +107,20 @@ export function watchDeployment(
|
|
|
159
107
|
|
|
160
108
|
return cleanup;
|
|
161
109
|
}
|
|
110
|
+
|
|
111
|
+
/** Recover an initial lazy-import failure before a router has mounted. Returns whether a reload was requested. */
|
|
112
|
+
export async function recoverDeployment(
|
|
113
|
+
options: DeploymentOptions | null,
|
|
114
|
+
destination?: URL,
|
|
115
|
+
): Promise<boolean> {
|
|
116
|
+
if (!options || typeof window === "undefined") return false;
|
|
117
|
+
|
|
118
|
+
const check = deploymentCheck(options);
|
|
119
|
+
|
|
120
|
+
return check
|
|
121
|
+
.changed()
|
|
122
|
+
.then((changed) =>
|
|
123
|
+
changed ? check.reload(destination ?? new URL(location.href)) : false,
|
|
124
|
+
)
|
|
125
|
+
.finally(check.dispose);
|
|
126
|
+
}
|
|
@@ -29,6 +29,8 @@ export interface ClientRouteModule {
|
|
|
29
29
|
export interface ServerHandlers {
|
|
30
30
|
loader?: boolean;
|
|
31
31
|
action?: boolean;
|
|
32
|
+
/** Application-scoped HTTP transport path. Defaults to /_smolter. */
|
|
33
|
+
dataEndpoint?: string;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/** Browser transport for server routes. File-router builds generate this call. */
|
|
@@ -40,7 +42,7 @@ export function serverRoute<M extends RouteModule = RouteModule>(
|
|
|
40
42
|
const remote = async ({ request, signal }: DataArgs) => {
|
|
41
43
|
const url = new URL(request.url);
|
|
42
44
|
const response = await fetch(
|
|
43
|
-
|
|
45
|
+
`${handlers.dataEndpoint ?? "/_smolter"}?url=${encodeURIComponent(url.pathname + url.search)}`,
|
|
44
46
|
{
|
|
45
47
|
method: request.method,
|
|
46
48
|
signal,
|
|
@@ -84,4 +86,8 @@ export function serverRoute<M extends RouteModule = RouteModule>(
|
|
|
84
86
|
} as Route<M>;
|
|
85
87
|
}
|
|
86
88
|
|
|
87
|
-
export {
|
|
89
|
+
export {
|
|
90
|
+
type DeploymentOptions,
|
|
91
|
+
recoverDeployment,
|
|
92
|
+
watchDeployment,
|
|
93
|
+
} from "./deployment.ts";
|
|
@@ -33,7 +33,10 @@ export function isRouteErrorResponse(value: unknown): value is RouteError {
|
|
|
33
33
|
return value instanceof Error && "status" in value;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
export async function normalizeResult(
|
|
36
|
+
export async function normalizeResult(
|
|
37
|
+
value: unknown,
|
|
38
|
+
resolveDeferred = true,
|
|
39
|
+
): Promise<unknown> {
|
|
37
40
|
if (value && typeof value === "object" && "__routeError" in value) {
|
|
38
41
|
const error = value.__routeError as {
|
|
39
42
|
message: string;
|
|
@@ -57,9 +60,11 @@ export async function normalizeResult(value: unknown): Promise<unknown> {
|
|
|
57
60
|
}
|
|
58
61
|
|
|
59
62
|
if (value && typeof value === "object" && "__routeData" in value)
|
|
60
|
-
return
|
|
63
|
+
return resolveDeferred
|
|
64
|
+
? resolveData((value as DataResponse<unknown>).value)
|
|
65
|
+
: (value as DataResponse<unknown>).value;
|
|
61
66
|
|
|
62
|
-
return resolveData(value);
|
|
67
|
+
return resolveDeferred ? resolveData(value) : value;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
// Smolter transfers JSON snapshots. Resolve deferred fields before serialization.
|
|
@@ -130,6 +130,15 @@ export function createDataRouter(
|
|
|
130
130
|
if (!fn) return body && Error("No action");
|
|
131
131
|
|
|
132
132
|
return (fn as DataFunction)({
|
|
133
|
+
reason: body
|
|
134
|
+
? "action"
|
|
135
|
+
: navigation !== undefined
|
|
136
|
+
? "navigation"
|
|
137
|
+
: local
|
|
138
|
+
? "fetcher"
|
|
139
|
+
: previous.loaded
|
|
140
|
+
? "revalidation"
|
|
141
|
+
: "initial",
|
|
133
142
|
request: new Request(url, {
|
|
134
143
|
signal,
|
|
135
144
|
method: body && method,
|
|
@@ -154,6 +163,29 @@ export function createDataRouter(
|
|
|
154
163
|
redirects - 1,
|
|
155
164
|
);
|
|
156
165
|
|
|
166
|
+
if (body) {
|
|
167
|
+
const destination = new URL(main.getSnapshot().url);
|
|
168
|
+
const args: RevalidationArgs = {
|
|
169
|
+
url: destination,
|
|
170
|
+
currentUrl: destination,
|
|
171
|
+
nextUrl: destination,
|
|
172
|
+
defaultShouldRevalidate: true,
|
|
173
|
+
reason: "action",
|
|
174
|
+
formMethod: method,
|
|
175
|
+
formAction: url!.href,
|
|
176
|
+
formData: body,
|
|
177
|
+
actionResult: result.data,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// The destination loader just ran. Refresh root data and other fetchers
|
|
181
|
+
// without loading that page twice or replaying the mutation.
|
|
182
|
+
await Promise.all(
|
|
183
|
+
[...revalidators]
|
|
184
|
+
.filter((task) => task !== main)
|
|
185
|
+
.map((task) => task.revalidate(args)),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
157
189
|
if (!signal.aborted) {
|
|
158
190
|
submission = undefined;
|
|
159
191
|
emit("idle");
|
package/.generated/core/types.ts
CHANGED
|
@@ -8,6 +8,8 @@ export type Params = Record<string, string>;
|
|
|
8
8
|
|
|
9
9
|
/** Request, path parameters, and cancellation signal passed to a data handler. */
|
|
10
10
|
export interface DataArgs {
|
|
11
|
+
/** Why this handler ran; independent of when browser history is committed. */
|
|
12
|
+
reason?: "initial" | "navigation" | "revalidation" | "fetcher" | "action";
|
|
11
13
|
request: Request;
|
|
12
14
|
params: Params;
|
|
13
15
|
signal: AbortSignal;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/** Browser mounting and hydration for Preact applications. @module */
|
|
2
2
|
/** @jsxImportSource preact */
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
type DeploymentOptions,
|
|
6
|
+
recoverDeployment,
|
|
7
|
+
watchDeployment,
|
|
8
|
+
} from "../client/index.ts";
|
|
4
9
|
import { createDataRouter, matchRoutes, type Router } from "../core/index.ts";
|
|
5
10
|
import { connectNavigation } from "../core/navigation.ts";
|
|
6
11
|
import { retainRoute } from "../core/presentation.ts";
|
|
@@ -13,11 +18,18 @@ import { prepareRoutes, RouterApplication } from "./router.tsx";
|
|
|
13
18
|
export * from "../client/index.ts";
|
|
14
19
|
export { configureApplication } from "../core/environment.ts";
|
|
15
20
|
|
|
21
|
+
export interface MountApplicationOptions {
|
|
22
|
+
hash?: boolean;
|
|
23
|
+
deployment?: DeploymentOptions | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
export async function mountApplication(
|
|
17
27
|
routes: AppRoute[],
|
|
18
28
|
rootModule?: RootModule,
|
|
19
|
-
|
|
29
|
+
options: boolean | MountApplicationOptions = false,
|
|
20
30
|
): Promise<Router> {
|
|
31
|
+
const { hash = false, deployment = null } =
|
|
32
|
+
typeof options === "boolean" ? { hash: options } : options;
|
|
21
33
|
const initialURL = new URL(
|
|
22
34
|
hash ? location.hash.slice(1) || "/" : location.href,
|
|
23
35
|
location.origin,
|
|
@@ -27,12 +39,31 @@ export async function mountApplication(
|
|
|
27
39
|
| AppRoute
|
|
28
40
|
| undefined;
|
|
29
41
|
|
|
30
|
-
await initialRoute?.preload?.()
|
|
42
|
+
await initialRoute?.preload?.().catch(async (error: unknown) => {
|
|
43
|
+
await recoverDeployment(deployment, initialURL);
|
|
44
|
+
throw error;
|
|
45
|
+
});
|
|
31
46
|
|
|
32
47
|
routes = prepareRoutes(routes);
|
|
33
48
|
const element = document.getElementById("smolter-data");
|
|
34
49
|
const hydration: Hydration | undefined = element?.textContent
|
|
35
|
-
? JSON.parse(element.textContent)
|
|
50
|
+
? JSON.parse(element.textContent, (_key, value) => {
|
|
51
|
+
if (
|
|
52
|
+
!value ||
|
|
53
|
+
typeof value !== "object" ||
|
|
54
|
+
!("__smolterRejected" in value)
|
|
55
|
+
)
|
|
56
|
+
return value;
|
|
57
|
+
|
|
58
|
+
const error = Object.assign(
|
|
59
|
+
new Error(value.__smolterRejected.message),
|
|
60
|
+
value.__smolterRejected,
|
|
61
|
+
);
|
|
62
|
+
const rejected = Promise.reject(error);
|
|
63
|
+
void rejected.catch(() => {});
|
|
64
|
+
|
|
65
|
+
return rejected;
|
|
66
|
+
})
|
|
36
67
|
: undefined;
|
|
37
68
|
|
|
38
69
|
if (hydration?.snapshot.error)
|
|
@@ -48,10 +79,11 @@ export async function mountApplication(
|
|
|
48
79
|
: location.href,
|
|
49
80
|
...(hydration && { hydrationData: hydration.snapshot }),
|
|
50
81
|
});
|
|
51
|
-
|
|
52
|
-
|
|
82
|
+
watchDeployment(router, deployment);
|
|
53
83
|
if (hash) connectHashHistory(router);
|
|
54
84
|
|
|
85
|
+
retainRoute(router, flushSync);
|
|
86
|
+
|
|
55
87
|
connectNavigation(router);
|
|
56
88
|
const rootStore = attachRootData(router, hydration?.root, rootModule);
|
|
57
89
|
const props = {
|
|
@@ -60,6 +92,7 @@ export async function mountApplication(
|
|
|
60
92
|
root: hydration?.root,
|
|
61
93
|
rootModule,
|
|
62
94
|
rootStore,
|
|
95
|
+
rootError: hydration?.rootError,
|
|
63
96
|
};
|
|
64
97
|
|
|
65
98
|
if (hydration) {
|
|
@@ -76,17 +109,26 @@ export async function mountApplication(
|
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
function connectHashHistory(router: Router) {
|
|
112
|
+
const read = router.getSnapshot;
|
|
79
113
|
const navigate = router.navigate;
|
|
80
114
|
router.navigate = async (url, options) => {
|
|
81
115
|
const target = new URL(url, router.getSnapshot().url);
|
|
82
|
-
const hash = `#${target.pathname}${target.search}`;
|
|
116
|
+
const hash = `#${target.pathname}${target.search}${target.hash}`;
|
|
83
117
|
history[options?.replace ? "replaceState" : "pushState"](null, "", hash);
|
|
84
118
|
await navigate(url, options);
|
|
85
119
|
};
|
|
120
|
+
const unsubscribe = router.subscribe(() => {
|
|
121
|
+
const target = new URL(read().url);
|
|
122
|
+
const hash = `#${target.pathname}${target.search}${target.hash}`;
|
|
123
|
+
|
|
124
|
+
// Redirects commit inside the router rather than through navigate().
|
|
125
|
+
if (location.hash !== hash) history.replaceState(history.state, "", hash);
|
|
126
|
+
});
|
|
86
127
|
const onHashChange = () => void navigate(location.hash.slice(1) || "/");
|
|
87
128
|
window.addEventListener("hashchange", onHashChange);
|
|
88
129
|
const dispose = router.dispose;
|
|
89
130
|
router.dispose = () => {
|
|
131
|
+
unsubscribe();
|
|
90
132
|
window.removeEventListener("hashchange", onHashChange);
|
|
91
133
|
dispose();
|
|
92
134
|
};
|
|
@@ -72,10 +72,12 @@ export interface ScriptProps {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
export function Scripts({ nonce }: ScriptProps): import("preact").JSX.Element {
|
|
75
|
-
const { root, router, entry } = useApplication();
|
|
75
|
+
const { root, rootError, router, entry, streaming } = useApplication();
|
|
76
76
|
// The bootstrap payload describes this document's initial SSR snapshot.
|
|
77
77
|
// Navigation and revalidation update the router, not the hydration script.
|
|
78
78
|
const [json] = useState(() => {
|
|
79
|
+
if (streaming) return "";
|
|
80
|
+
|
|
79
81
|
const current = router.getSnapshot();
|
|
80
82
|
const snapshot = {
|
|
81
83
|
...current,
|
|
@@ -86,7 +88,7 @@ export function Scripts({ nonce }: ScriptProps): import("preact").JSX.Element {
|
|
|
86
88
|
},
|
|
87
89
|
};
|
|
88
90
|
|
|
89
|
-
return serializeData({ root, snapshot });
|
|
91
|
+
return serializeData({ root, snapshot, ...(rootError && { rootError }) });
|
|
90
92
|
});
|
|
91
93
|
|
|
92
94
|
return (
|
|
@@ -8,6 +8,7 @@ import { useSyncExternalStore } from "./runtime.ts";
|
|
|
8
8
|
|
|
9
9
|
export interface LazyRouteOptions {
|
|
10
10
|
path: string;
|
|
11
|
+
dataEndpoint?: string;
|
|
11
12
|
id?: string;
|
|
12
13
|
loader?: boolean;
|
|
13
14
|
action?: boolean;
|
|
@@ -48,6 +49,7 @@ export function lazyRoute(
|
|
|
48
49
|
module = serverRoute(options.path, loaded, {
|
|
49
50
|
loader: options.loader,
|
|
50
51
|
action: options.action,
|
|
52
|
+
dataEndpoint: options.dataEndpoint,
|
|
51
53
|
});
|
|
52
54
|
})
|
|
53
55
|
.catch((error: unknown) => {
|
|
@@ -51,7 +51,7 @@ export function RouterApplication(
|
|
|
51
51
|
);
|
|
52
52
|
const route = props.routes.find((item) => item.path === snapshot.path);
|
|
53
53
|
const Root =
|
|
54
|
-
snapshot.error && !route?.ErrorBoundary
|
|
54
|
+
snapshot.error && (props.rootError || !route?.ErrorBoundary)
|
|
55
55
|
? (props.rootModule?.ErrorBoundary ?? Outlet)
|
|
56
56
|
: (props.rootModule?.default ?? Outlet);
|
|
57
57
|
const Layout = props.rootModule?.Layout;
|
|
@@ -66,7 +66,10 @@ export function RouterApplication(
|
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
export function prepareRoutes(
|
|
69
|
+
export function prepareRoutes(
|
|
70
|
+
routes: AppRoute[],
|
|
71
|
+
preserveDeferred = false,
|
|
72
|
+
): AppRoute[] {
|
|
70
73
|
return routes.map((route) => {
|
|
71
74
|
const wrapped: AppRoute = {
|
|
72
75
|
...route,
|
|
@@ -99,7 +102,10 @@ export function prepareRoutes(routes: AppRoute[]): AppRoute[] {
|
|
|
99
102
|
wrapped[key] = (args) =>
|
|
100
103
|
Promise.resolve()
|
|
101
104
|
.then(() => handler(args))
|
|
102
|
-
.then(
|
|
105
|
+
.then(
|
|
106
|
+
(value) => normalizeResult(value, !preserveDeferred),
|
|
107
|
+
(error) => normalizeResult(error, !preserveDeferred),
|
|
108
|
+
);
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
return wrapped;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** @jsxImportSource preact */
|
|
2
2
|
|
|
3
|
-
import { createDataRouter, matchRoutes } from "../core/index.ts";
|
|
3
|
+
import { createDataRouter, matchRoutes, safeWrapAsync } from "../core/index.ts";
|
|
4
4
|
import {
|
|
5
5
|
type DataResponse,
|
|
6
6
|
normalizeResult,
|
|
@@ -9,13 +9,20 @@ import {
|
|
|
9
9
|
import type { AppRoute, RootModule } from "../preact/route-types.ts";
|
|
10
10
|
import { prepareRoutes, RouterApplication } from "../preact/router.tsx";
|
|
11
11
|
import { loadRouter } from "./index.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { renderToReadableStream } from "preact-render-to-string/stream";
|
|
13
|
+
import { privateResponse } from "./private-response.ts";
|
|
14
|
+
import { resolveHydration, streamDocument } from "./stream-document.ts";
|
|
15
|
+
|
|
16
|
+
export interface RenderApplicationOptions {
|
|
17
|
+
nonce?: string;
|
|
18
|
+
}
|
|
13
19
|
|
|
14
20
|
export async function renderApplication(
|
|
15
21
|
request: Request,
|
|
16
22
|
routes: AppRoute[],
|
|
17
23
|
rootModule: RootModule,
|
|
18
24
|
entry: string,
|
|
25
|
+
options: RenderApplicationOptions = {},
|
|
19
26
|
): Promise<Response> {
|
|
20
27
|
routes = routes.map((route) => ({
|
|
21
28
|
...route,
|
|
@@ -41,12 +48,20 @@ export async function renderApplication(
|
|
|
41
48
|
actionData = await normalizeResult(await actionResponse.json());
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
51
|
+
const rootResult = safeWrapAsync(() =>
|
|
52
|
+
Promise.resolve()
|
|
53
|
+
.then(() =>
|
|
54
|
+
rootModule.loader?.({ request, params: {}, signal: request.signal }),
|
|
55
|
+
)
|
|
56
|
+
.then(
|
|
57
|
+
(value) => normalizeResult(value, false),
|
|
58
|
+
(error: unknown) =>
|
|
59
|
+
error instanceof Response
|
|
60
|
+
? normalizeResult(error, false)
|
|
61
|
+
: error instanceof Error
|
|
62
|
+
? error
|
|
63
|
+
: new Error(String(error), { cause: error }),
|
|
64
|
+
),
|
|
50
65
|
);
|
|
51
66
|
let redirectResponse: Response | undefined;
|
|
52
67
|
const prepared = prepareRoutes(
|
|
@@ -54,6 +69,7 @@ export async function renderApplication(
|
|
|
54
69
|
...route,
|
|
55
70
|
ErrorBoundary: route.ErrorBoundary ?? rootModule.ErrorBoundary,
|
|
56
71
|
})),
|
|
72
|
+
true,
|
|
57
73
|
).map((route) => {
|
|
58
74
|
if (!route.loader) return route;
|
|
59
75
|
|
|
@@ -71,7 +87,20 @@ export async function renderApplication(
|
|
|
71
87
|
},
|
|
72
88
|
};
|
|
73
89
|
});
|
|
74
|
-
|
|
90
|
+
const [rootState, loadedRouter] = await Promise.all([
|
|
91
|
+
rootResult,
|
|
92
|
+
loadRouter(prepared, request),
|
|
93
|
+
]);
|
|
94
|
+
let router = loadedRouter;
|
|
95
|
+
const rootError =
|
|
96
|
+
rootState.error ??
|
|
97
|
+
(rootState.data instanceof Error ? rootState.data : undefined);
|
|
98
|
+
const root = rootError ? undefined : rootState.data;
|
|
99
|
+
|
|
100
|
+
if (root instanceof Response && root.headers.has("Location")) {
|
|
101
|
+
router.dispose();
|
|
102
|
+
return root;
|
|
103
|
+
}
|
|
75
104
|
|
|
76
105
|
if (redirectResponse) {
|
|
77
106
|
router.dispose();
|
|
@@ -79,43 +108,68 @@ export async function renderApplication(
|
|
|
79
108
|
return redirectResponse;
|
|
80
109
|
}
|
|
81
110
|
|
|
82
|
-
if (actionResponse) {
|
|
111
|
+
if (actionResponse || rootError) {
|
|
83
112
|
const hydrationData = {
|
|
84
113
|
...router.getSnapshot(),
|
|
85
114
|
actionData: actionData instanceof Error ? undefined : actionData,
|
|
86
115
|
...(actionData instanceof Error && { error: actionData }),
|
|
116
|
+
...(rootError && { error: rootError }),
|
|
87
117
|
};
|
|
88
118
|
router.dispose();
|
|
89
119
|
router = createDataRouter(router.routes, { hydrationData });
|
|
90
120
|
}
|
|
91
121
|
|
|
92
122
|
const snapshot = router.getSnapshot();
|
|
93
|
-
const
|
|
123
|
+
const stream = renderToReadableStream(
|
|
94
124
|
<RouterApplication
|
|
95
125
|
router={router}
|
|
96
126
|
routes={routes}
|
|
97
127
|
root={root}
|
|
98
128
|
rootModule={rootModule}
|
|
99
129
|
entry={entry}
|
|
130
|
+
rootError={Boolean(rootError)}
|
|
131
|
+
streaming
|
|
100
132
|
document
|
|
101
133
|
/>,
|
|
134
|
+
options,
|
|
102
135
|
);
|
|
103
|
-
|
|
136
|
+
const hydration = resolveHydration({
|
|
137
|
+
root,
|
|
138
|
+
snapshot: {
|
|
139
|
+
...snapshot,
|
|
140
|
+
error: snapshot.error && {
|
|
141
|
+
...snapshot.error,
|
|
142
|
+
name: snapshot.error.name,
|
|
143
|
+
message: snapshot.error.message,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
...(rootError && { rootError: true }),
|
|
147
|
+
});
|
|
148
|
+
// The renderer reports the same rejection through its stream reader.
|
|
149
|
+
void stream.allReady.catch(() => {});
|
|
104
150
|
const headers = new Headers(actionResponse?.headers);
|
|
105
151
|
headers.delete("Content-Length");
|
|
106
152
|
headers.delete("Content-Encoding");
|
|
107
153
|
headers.set("Content-Type", "text/html; charset=utf-8");
|
|
108
154
|
headers.set("Cache-Control", "no-store");
|
|
109
155
|
|
|
110
|
-
return new Response(
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
:
|
|
114
|
-
|
|
115
|
-
|
|
156
|
+
return new Response(
|
|
157
|
+
streamDocument(stream, {
|
|
158
|
+
hydration,
|
|
159
|
+
signal: request.signal,
|
|
160
|
+
nonce: options.nonce,
|
|
161
|
+
dispose: () => router.dispose(),
|
|
162
|
+
}),
|
|
163
|
+
{
|
|
164
|
+
status: snapshot.error
|
|
165
|
+
? Number("status" in snapshot.error ? snapshot.error.status : 500)
|
|
166
|
+
: (actionResponse?.status ?? 200),
|
|
167
|
+
headers,
|
|
168
|
+
},
|
|
169
|
+
);
|
|
116
170
|
}
|
|
117
171
|
|
|
118
|
-
|
|
172
|
+
async function dispatch(
|
|
119
173
|
request: Request,
|
|
120
174
|
routes: AppRoute[],
|
|
121
175
|
document = false,
|
|
@@ -183,3 +237,13 @@ export async function dispatchRoute(
|
|
|
183
237
|
|
|
184
238
|
return Response.json((await normalizeResult(value)) ?? null);
|
|
185
239
|
}
|
|
240
|
+
|
|
241
|
+
export async function dispatchRoute(
|
|
242
|
+
request: Request,
|
|
243
|
+
routes: AppRoute[],
|
|
244
|
+
document = false,
|
|
245
|
+
): Promise<Response> {
|
|
246
|
+
const response = await dispatch(request, routes, document);
|
|
247
|
+
|
|
248
|
+
return document ? response : privateResponse(response);
|
|
249
|
+
}
|