@kasperrt/smolter 0.3.0 → 0.3.2
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 +124 -0
- package/.generated/client/index.ts +9 -1
- 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 +10 -2
- package/.generated/vite/application/vite.ts +9 -3
- package/.generated/vite/deployment.ts +46 -0
- package/.generated/vite/index.ts +15 -6
- package/.generated/vite/preact.ts +5 -0
- package/.generated/vite/react.ts +5 -0
- package/.generated/vite/transport.ts +4 -1
- package/CHANGELOG.md +19 -0
- package/README.md +79 -11
- package/dist/client/deployment-check.d.ts +6 -0
- package/dist/client/deployment-check.js +51 -0
- package/dist/client/deployment.d.ts +14 -0
- package/dist/client/deployment.js +74 -0
- package/dist/client/index.d.ts +3 -0
- package/dist/client/index.js +3 -2
- 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 -7
- 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 +6 -2
- package/dist/vite/application/vite.d.ts +2 -1
- package/dist/vite/application/vite.js +7 -4
- package/dist/vite/deployment.d.ts +8 -0
- package/dist/vite/deployment.js +36 -0
- package/dist/vite/index.d.ts +2 -1
- package/dist/vite/index.js +15 -7
- package/dist/vite/preact.d.ts +1 -0
- package/dist/vite/preact.js +2 -1
- package/dist/vite/react.d.ts +1 -0
- package/dist/vite/react.js +2 -1
- package/dist/vite/transport.d.ts +1 -1
- package/dist/vite/transport.js +3 -2
- package/jsr.json +1 -1
- package/package.json +3 -3
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { NavigationRouter } from "../core/index.ts";
|
|
2
|
+
import { deploymentCheck } from "./deployment-check.ts";
|
|
3
|
+
|
|
4
|
+
/** Build identity and the fresh, non-cacheable manifest URL emitted by deploymentPlugin. */
|
|
5
|
+
export interface DeploymentOptions {
|
|
6
|
+
version: string;
|
|
7
|
+
manifestURL: string;
|
|
8
|
+
/** Maximum time to wait for the update check before continuing navigation. */
|
|
9
|
+
timeout?: number;
|
|
10
|
+
/** Override document navigation, for example to ask about unsaved changes. */
|
|
11
|
+
reload?: (destination: URL) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Check for deployment changes before navigation. Returns a cleanup function. */
|
|
15
|
+
export function watchDeployment(
|
|
16
|
+
router: NavigationRouter,
|
|
17
|
+
options: DeploymentOptions | null,
|
|
18
|
+
): () => void {
|
|
19
|
+
if (!options || typeof window === "undefined") return () => {};
|
|
20
|
+
|
|
21
|
+
const check = deploymentCheck(options);
|
|
22
|
+
const { changed, reload } = check;
|
|
23
|
+
const navigate = router.navigate;
|
|
24
|
+
const dispose = router.dispose;
|
|
25
|
+
let sequence = 0;
|
|
26
|
+
let stopped = false;
|
|
27
|
+
let replay = false;
|
|
28
|
+
let destination = new URL(location.href);
|
|
29
|
+
|
|
30
|
+
const guarded: NavigationRouter["navigate"] = async (href, operation) => {
|
|
31
|
+
const target = new URL(href, router.getSnapshot().url);
|
|
32
|
+
const currentURL = new URL(router.getSnapshot().url);
|
|
33
|
+
const current = ++sequence;
|
|
34
|
+
destination = target;
|
|
35
|
+
|
|
36
|
+
if (
|
|
37
|
+
target.origin === location.origin &&
|
|
38
|
+
(target.pathname !== currentURL.pathname ||
|
|
39
|
+
target.search !== currentURL.search) &&
|
|
40
|
+
(await changed())
|
|
41
|
+
) {
|
|
42
|
+
if (current !== sequence || stopped || (await reload(target))) return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (current === sequence && !stopped) await navigate(href, operation);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const pop = (event: PopStateEvent) => {
|
|
49
|
+
if (replay) return;
|
|
50
|
+
|
|
51
|
+
const target = new URL(location.href);
|
|
52
|
+
const previous = new URL(router.getSnapshot().url);
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
target.pathname === previous.pathname &&
|
|
56
|
+
target.search === previous.search
|
|
57
|
+
)
|
|
58
|
+
return;
|
|
59
|
+
|
|
60
|
+
event.stopImmediatePropagation();
|
|
61
|
+
const current = ++sequence;
|
|
62
|
+
destination = target;
|
|
63
|
+
void changed().then(async (updated) => {
|
|
64
|
+
if (stopped || current !== sequence) return;
|
|
65
|
+
if (updated && (await reload(target))) return;
|
|
66
|
+
|
|
67
|
+
replay = true;
|
|
68
|
+
window.dispatchEvent(
|
|
69
|
+
new PopStateEvent("popstate", { state: event.state }),
|
|
70
|
+
);
|
|
71
|
+
replay = false;
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const preloadError = () => {
|
|
76
|
+
if (router.getSnapshot().state === "submitting") return;
|
|
77
|
+
|
|
78
|
+
const current = sequence;
|
|
79
|
+
const target = destination;
|
|
80
|
+
void changed().then((updated) => {
|
|
81
|
+
if (updated && current === sequence && !stopped) return reload(target);
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function cleanup() {
|
|
86
|
+
stopped = true;
|
|
87
|
+
sequence++;
|
|
88
|
+
check.dispose();
|
|
89
|
+
window.removeEventListener("popstate", pop, true);
|
|
90
|
+
window.removeEventListener("vite:preloadError", preloadError);
|
|
91
|
+
|
|
92
|
+
if (router.navigate === guarded) router.navigate = navigate;
|
|
93
|
+
if (router.dispose === guardedDispose) router.dispose = dispose;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function guardedDispose() {
|
|
97
|
+
cleanup();
|
|
98
|
+
dispose();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
router.navigate = guarded;
|
|
102
|
+
router.dispose = guardedDispose;
|
|
103
|
+
window.addEventListener("popstate", pop, true);
|
|
104
|
+
window.addEventListener("vite:preloadError", preloadError);
|
|
105
|
+
|
|
106
|
+
return cleanup;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Recover an initial lazy-import failure before a router has mounted. Returns whether a reload was requested. */
|
|
110
|
+
export async function recoverDeployment(
|
|
111
|
+
options: DeploymentOptions | null,
|
|
112
|
+
destination?: URL,
|
|
113
|
+
): Promise<boolean> {
|
|
114
|
+
if (!options || typeof window === "undefined") return false;
|
|
115
|
+
|
|
116
|
+
const check = deploymentCheck(options);
|
|
117
|
+
|
|
118
|
+
return check
|
|
119
|
+
.changed()
|
|
120
|
+
.then((changed) =>
|
|
121
|
+
changed ? check.reload(destination ?? new URL(location.href)) : false,
|
|
122
|
+
)
|
|
123
|
+
.finally(check.dispose);
|
|
124
|
+
}
|
|
@@ -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,
|
|
@@ -83,3 +85,9 @@ export function serverRoute<M extends RouteModule = RouteModule>(
|
|
|
83
85
|
}),
|
|
84
86
|
} as Route<M>;
|
|
85
87
|
}
|
|
88
|
+
|
|
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
|
+
}
|