@kasperrt/smolter 0.3.5 → 0.4.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/.generated/preact/client.tsx +15 -25
- package/.generated/preact/deferred-hydration.ts +87 -0
- package/.generated/preact/document.tsx +11 -2
- package/.generated/preact/root-context.ts +1 -0
- package/.generated/server/application.tsx +30 -18
- package/.generated/server/assets.ts +11 -3
- package/.generated/server/deferred-hydration.ts +89 -0
- package/.generated/server/progressive-document.ts +102 -0
- package/.generated/vite/application/lazy-files.ts +11 -1
- package/CHANGELOG.md +8 -0
- package/README.md +20 -6
- package/dist/preact/client.js +12 -10
- package/dist/preact/deferred-hydration.d.ts +12 -0
- package/dist/preact/deferred-hydration.js +64 -0
- package/dist/preact/document.js +3 -1
- package/dist/preact/root-context.d.ts +1 -0
- package/dist/server/application.d.ts +1 -0
- package/dist/server/application.js +21 -14
- package/dist/server/assets.d.ts +1 -0
- package/dist/server/assets.js +3 -1
- package/dist/server/deferred-hydration.d.ts +5 -0
- package/dist/server/deferred-hydration.js +59 -0
- package/dist/server/progressive-document.d.ts +7 -0
- package/dist/server/progressive-document.js +81 -0
- package/dist/vite/application/lazy-files.js +5 -1
- package/jsr.json +1 -1
- package/package.json +1 -1
|
@@ -11,6 +11,7 @@ import { connectNavigation } from "../core/navigation.ts";
|
|
|
11
11
|
import { retainRoute } from "../core/presentation.ts";
|
|
12
12
|
import { hydrate, render } from "preact";
|
|
13
13
|
import { flushSync } from "preact/compat";
|
|
14
|
+
import { readHydration } from "./deferred-hydration.ts";
|
|
14
15
|
import { attachRootData } from "./root-data.ts";
|
|
15
16
|
import type { AppRoute, Hydration, RootModule } from "./route-types.ts";
|
|
16
17
|
import { prepareRoutes, RouterApplication } from "./router.tsx";
|
|
@@ -46,25 +47,10 @@ export async function mountApplication(
|
|
|
46
47
|
|
|
47
48
|
routes = prepareRoutes(routes);
|
|
48
49
|
const element = document.getElementById("smolter-data");
|
|
49
|
-
const
|
|
50
|
-
?
|
|
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
|
-
})
|
|
50
|
+
const initial = element?.textContent
|
|
51
|
+
? readHydration(element.textContent)
|
|
67
52
|
: undefined;
|
|
53
|
+
const hydration: Hydration | undefined = initial?.hydration;
|
|
68
54
|
|
|
69
55
|
if (hydration?.snapshot.error)
|
|
70
56
|
hydration.snapshot.error = Object.assign(
|
|
@@ -79,6 +65,11 @@ export async function mountApplication(
|
|
|
79
65
|
: location.href,
|
|
80
66
|
...(hydration && { hydrationData: hydration.snapshot }),
|
|
81
67
|
});
|
|
68
|
+
const dispose = router.dispose;
|
|
69
|
+
router.dispose = () => {
|
|
70
|
+
initial?.dispose();
|
|
71
|
+
dispose();
|
|
72
|
+
};
|
|
82
73
|
watchDeployment(router, deployment);
|
|
83
74
|
if (hash) connectHashHistory(router);
|
|
84
75
|
|
|
@@ -98,19 +89,18 @@ export async function mountApplication(
|
|
|
98
89
|
if (hydration) {
|
|
99
90
|
// Explicitly unmanaged head nodes must not participate in positional hydration.
|
|
100
91
|
const preserved = Array.from(
|
|
101
|
-
document.
|
|
102
|
-
|
|
92
|
+
document.querySelectorAll(
|
|
93
|
+
"head > [data-smolter-preserve], body > [data-smolter-stream]",
|
|
94
|
+
),
|
|
95
|
+
(node) => ({ node, next: node.nextSibling, parent: node.parentNode! }),
|
|
103
96
|
);
|
|
104
97
|
for (const { node } of preserved) node.remove();
|
|
105
98
|
|
|
106
99
|
await Promise.resolve()
|
|
107
100
|
.then(() => hydrate(<RouterApplication {...props} document />, document))
|
|
108
101
|
.finally(() => {
|
|
109
|
-
for (const { node, next } of preserved.reverse())
|
|
110
|
-
|
|
111
|
-
node,
|
|
112
|
-
next?.parentNode === document.head ? next : null,
|
|
113
|
-
);
|
|
102
|
+
for (const { node, next, parent } of preserved.reverse())
|
|
103
|
+
parent.insertBefore(node, next?.parentNode === parent ? next : null);
|
|
114
104
|
});
|
|
115
105
|
|
|
116
106
|
return router;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
type Settlement = [number, unknown, string | null];
|
|
2
|
+
|
|
3
|
+
interface PendingValue {
|
|
4
|
+
promise: Promise<unknown>;
|
|
5
|
+
resolve: (value: unknown) => void;
|
|
6
|
+
reject: (reason: Error) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
declare global {
|
|
10
|
+
interface Window {
|
|
11
|
+
__smolterDeferred?: Settlement[];
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Restore promise identity before hydrating Suspense boundaries. */
|
|
16
|
+
export function readHydration(json: string) {
|
|
17
|
+
const pending = new Map<number, PendingValue>();
|
|
18
|
+
const queue = (window.__smolterDeferred ??= []);
|
|
19
|
+
|
|
20
|
+
function revive(_key: string, value: unknown): unknown {
|
|
21
|
+
if (!value || typeof value !== "object") return value;
|
|
22
|
+
if ("__smolterUndefined" in value) return undefined;
|
|
23
|
+
|
|
24
|
+
if (
|
|
25
|
+
"__smolterPending" in value &&
|
|
26
|
+
typeof value.__smolterPending === "number"
|
|
27
|
+
) {
|
|
28
|
+
const id = value.__smolterPending;
|
|
29
|
+
const existing = pending.get(id);
|
|
30
|
+
if (existing) return existing.promise;
|
|
31
|
+
|
|
32
|
+
let resolve!: PendingValue["resolve"];
|
|
33
|
+
let reject!: PendingValue["reject"];
|
|
34
|
+
const promise = new Promise((accept, fail) => {
|
|
35
|
+
resolve = accept;
|
|
36
|
+
reject = fail;
|
|
37
|
+
});
|
|
38
|
+
void promise.catch(() => {});
|
|
39
|
+
pending.set(id, { promise, resolve, reject });
|
|
40
|
+
|
|
41
|
+
return promise;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if ("__smolterRejected" in value) {
|
|
45
|
+
const failure = value.__smolterRejected as { message: string };
|
|
46
|
+
const rejected = Promise.reject(
|
|
47
|
+
Object.assign(new Error(failure.message), failure),
|
|
48
|
+
);
|
|
49
|
+
void rejected.catch(() => {});
|
|
50
|
+
|
|
51
|
+
return rejected;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const hydration = JSON.parse(json, revive);
|
|
58
|
+
const dispose = () => {
|
|
59
|
+
window.removeEventListener("DOMContentLoaded", dispose);
|
|
60
|
+
for (const target of pending.values())
|
|
61
|
+
target.reject(new Error("Hydration stream closed"));
|
|
62
|
+
pending.clear();
|
|
63
|
+
queue.push = () => 0;
|
|
64
|
+
};
|
|
65
|
+
const receive = (packet: Settlement) => {
|
|
66
|
+
const [id, value, error] = packet;
|
|
67
|
+
if (id === -1) return dispose();
|
|
68
|
+
|
|
69
|
+
const target = pending.get(id);
|
|
70
|
+
if (!target) return;
|
|
71
|
+
|
|
72
|
+
if (error !== null) target.reject(new Error(error));
|
|
73
|
+
if (error === null)
|
|
74
|
+
target.resolve(JSON.parse(JSON.stringify(value), revive));
|
|
75
|
+
};
|
|
76
|
+
const buffered = queue.splice(0);
|
|
77
|
+
queue.push = (...packets) => {
|
|
78
|
+
packets.forEach(receive);
|
|
79
|
+
return 0;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
window.addEventListener("DOMContentLoaded", dispose, { once: true });
|
|
83
|
+
buffered.forEach(receive);
|
|
84
|
+
if (document.readyState !== "loading") dispose();
|
|
85
|
+
|
|
86
|
+
return { hydration, dispose };
|
|
87
|
+
}
|
|
@@ -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, rootError, router, entry, streaming } =
|
|
75
|
+
const { root, rootError, router, entry, streaming, initialData } =
|
|
76
|
+
useApplication();
|
|
76
77
|
// The bootstrap payload describes this document's initial SSR snapshot.
|
|
77
78
|
// Navigation and revalidation update the router, not the hydration script.
|
|
78
79
|
const [json] = useState(() => {
|
|
80
|
+
if (initialData !== undefined) return initialData;
|
|
79
81
|
if (streaming) return "";
|
|
80
82
|
|
|
81
83
|
const current = router.getSnapshot();
|
|
@@ -105,7 +107,14 @@ export function Scripts({ nonce }: ScriptProps): import("preact").JSX.Element {
|
|
|
105
107
|
{...(nonce && { nonce })}
|
|
106
108
|
dangerouslySetInnerHTML={{ __html: json }}
|
|
107
109
|
/>
|
|
108
|
-
{entry &&
|
|
110
|
+
{entry && (
|
|
111
|
+
<script
|
|
112
|
+
type="module"
|
|
113
|
+
async={initialData !== undefined}
|
|
114
|
+
src={entry}
|
|
115
|
+
{...(nonce && { nonce })}
|
|
116
|
+
/>
|
|
117
|
+
)}
|
|
109
118
|
</Fragment>
|
|
110
119
|
);
|
|
111
120
|
}
|
|
@@ -10,11 +10,14 @@ 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
12
|
import { renderToReadableStream } from "preact-render-to-string/stream";
|
|
13
|
+
import { deferredHydration } from "./deferred-hydration.ts";
|
|
13
14
|
import { privateResponse } from "./private-response.ts";
|
|
15
|
+
import { progressiveDocument } from "./progressive-document.ts";
|
|
14
16
|
import { resolveHydration, streamDocument } from "./stream-document.ts";
|
|
15
17
|
|
|
16
18
|
export interface RenderApplicationOptions {
|
|
17
19
|
nonce?: string;
|
|
20
|
+
earlyHydration?: boolean;
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
export async function renderApplication(
|
|
@@ -120,6 +123,21 @@ export async function renderApplication(
|
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
const snapshot = router.getSnapshot();
|
|
126
|
+
const hydrationData = {
|
|
127
|
+
root,
|
|
128
|
+
snapshot: {
|
|
129
|
+
...snapshot,
|
|
130
|
+
error: snapshot.error && {
|
|
131
|
+
...snapshot.error,
|
|
132
|
+
name: snapshot.error.name,
|
|
133
|
+
message: snapshot.error.message,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
...(rootError && { rootError: true }),
|
|
137
|
+
};
|
|
138
|
+
const deferred = options.earlyHydration
|
|
139
|
+
? deferredHydration(hydrationData, options.nonce)
|
|
140
|
+
: undefined;
|
|
123
141
|
const stream = renderToReadableStream(
|
|
124
142
|
<RouterApplication
|
|
125
143
|
router={router}
|
|
@@ -129,22 +147,11 @@ export async function renderApplication(
|
|
|
129
147
|
entry={entry}
|
|
130
148
|
rootError={Boolean(rootError)}
|
|
131
149
|
streaming
|
|
150
|
+
initialData={deferred?.initial}
|
|
132
151
|
document
|
|
133
152
|
/>,
|
|
134
153
|
options,
|
|
135
154
|
);
|
|
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
155
|
// The renderer reports the same rejection through its stream reader.
|
|
149
156
|
void stream.allReady.catch(() => {});
|
|
150
157
|
const headers = new Headers(actionResponse?.headers);
|
|
@@ -154,12 +161,17 @@ export async function renderApplication(
|
|
|
154
161
|
headers.set("Cache-Control", "no-store");
|
|
155
162
|
|
|
156
163
|
return new Response(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
164
|
+
deferred
|
|
165
|
+
? progressiveDocument(stream, deferred.stream, {
|
|
166
|
+
signal: request.signal,
|
|
167
|
+
dispose: () => router.dispose(),
|
|
168
|
+
})
|
|
169
|
+
: streamDocument(stream, {
|
|
170
|
+
hydration: resolveHydration(hydrationData),
|
|
171
|
+
signal: request.signal,
|
|
172
|
+
nonce: options.nonce,
|
|
173
|
+
dispose: () => router.dispose(),
|
|
174
|
+
}),
|
|
163
175
|
{
|
|
164
176
|
status: snapshot.error
|
|
165
177
|
? Number("status" in snapshot.error ? snapshot.error.status : 500)
|
|
@@ -3,6 +3,7 @@ import { matchRoutes } from "../core/index.ts";
|
|
|
3
3
|
export interface RouteAssets {
|
|
4
4
|
path: string;
|
|
5
5
|
files: string[];
|
|
6
|
+
styles?: string[];
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
export function routePreloads(
|
|
@@ -13,7 +14,14 @@ export function routePreloads(
|
|
|
13
14
|
const match = matchRoutes(routes, pathname);
|
|
14
15
|
const route = match?.route as RouteAssets | undefined;
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const prefix = base.replace(/\/$/, "");
|
|
18
|
+
|
|
19
|
+
return [
|
|
20
|
+
...(route?.styles ?? []).map(
|
|
21
|
+
(file) => `<${prefix}/${file}>; rel=preload; as=style; crossorigin`,
|
|
22
|
+
),
|
|
23
|
+
...(route?.files ?? []).map(
|
|
24
|
+
(file) => `<${prefix}/${file}>; rel=modulepreload`,
|
|
25
|
+
),
|
|
26
|
+
].join(", ");
|
|
19
27
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { serializeData } from "../core/serialize.ts";
|
|
2
|
+
|
|
3
|
+
/** Encode deferred values without delaying the initial document snapshot. */
|
|
4
|
+
export function deferredHydration(value: unknown, nonce?: string) {
|
|
5
|
+
const promises = new Map<Promise<unknown>, number>();
|
|
6
|
+
let pending = 0;
|
|
7
|
+
let closed = false;
|
|
8
|
+
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const attribute = nonce
|
|
11
|
+
? ` nonce="${nonce.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<")}"`
|
|
12
|
+
: "";
|
|
13
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
14
|
+
start(value) {
|
|
15
|
+
controller = value;
|
|
16
|
+
},
|
|
17
|
+
cancel() {
|
|
18
|
+
closed = true;
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function encode(value: unknown): unknown {
|
|
23
|
+
if (value instanceof Promise) {
|
|
24
|
+
const existing = promises.get(value);
|
|
25
|
+
if (existing !== undefined) return { __smolterPending: existing };
|
|
26
|
+
|
|
27
|
+
const id = promises.size;
|
|
28
|
+
promises.set(value, id);
|
|
29
|
+
pending++;
|
|
30
|
+
void value
|
|
31
|
+
.then((result) => settle(id, encode(result)))
|
|
32
|
+
.catch((error: unknown) =>
|
|
33
|
+
settle(
|
|
34
|
+
id,
|
|
35
|
+
null,
|
|
36
|
+
error instanceof Error ? error.message : String(error),
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return { __smolterPending: id };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (Array.isArray(value)) return value.map(encode);
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
value &&
|
|
47
|
+
typeof value === "object" &&
|
|
48
|
+
Object.getPrototypeOf(value) === Object.prototype
|
|
49
|
+
)
|
|
50
|
+
return Object.fromEntries(
|
|
51
|
+
Object.entries(value).map(([key, field]) => [key, encode(field)]),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function settle(id: number, value: unknown, error?: string) {
|
|
58
|
+
if (closed) return;
|
|
59
|
+
|
|
60
|
+
const packet = serializeData([
|
|
61
|
+
id,
|
|
62
|
+
value === undefined ? { __smolterUndefined: true } : value,
|
|
63
|
+
error,
|
|
64
|
+
]);
|
|
65
|
+
controller.enqueue(
|
|
66
|
+
encoder.encode(
|
|
67
|
+
`<script${attribute}>(window.__smolterDeferred??=[]).push(${packet});document.currentScript.remove()</script>`,
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
pending--;
|
|
71
|
+
if (!pending) {
|
|
72
|
+
closed = true;
|
|
73
|
+
controller.enqueue(
|
|
74
|
+
encoder.encode(
|
|
75
|
+
`<script${attribute}>(window.__smolterDeferred??=[]).push([-1,null,null]);document.currentScript.remove()</script>`,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
controller.close();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const initial = serializeData(encode(value));
|
|
83
|
+
if (!pending) {
|
|
84
|
+
closed = true;
|
|
85
|
+
controller.close();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { initial, stream };
|
|
89
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
interface ProgressiveDocumentOptions {
|
|
2
|
+
signal: AbortSignal;
|
|
3
|
+
dispose: () => void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Interleave deferred settlements with complete HTML chunks, respecting backpressure. */
|
|
7
|
+
export function progressiveDocument(
|
|
8
|
+
source: ReadableStream<Uint8Array>,
|
|
9
|
+
settlements: ReadableStream<Uint8Array>,
|
|
10
|
+
options: ProgressiveDocumentOptions,
|
|
11
|
+
): ReadableStream<Uint8Array> {
|
|
12
|
+
const html = source.getReader();
|
|
13
|
+
const data = settlements.getReader();
|
|
14
|
+
const encoder = new TextEncoder();
|
|
15
|
+
const decoder = new TextDecoder();
|
|
16
|
+
let stopped = false;
|
|
17
|
+
let cancelled = false;
|
|
18
|
+
let first = true;
|
|
19
|
+
let suffix = "";
|
|
20
|
+
const readHTML = () =>
|
|
21
|
+
html.read().then(
|
|
22
|
+
(result) => ({ ...result, html: true }),
|
|
23
|
+
(error: unknown) => ({ error }),
|
|
24
|
+
);
|
|
25
|
+
const readData = () =>
|
|
26
|
+
data.read().then(
|
|
27
|
+
(result) => ({ ...result, html: false }),
|
|
28
|
+
(error: unknown) => ({ error }),
|
|
29
|
+
);
|
|
30
|
+
let nextHTML: ReturnType<typeof readHTML> | undefined;
|
|
31
|
+
let nextData: ReturnType<typeof readData> | undefined;
|
|
32
|
+
|
|
33
|
+
const stop = () => {
|
|
34
|
+
if (stopped) return;
|
|
35
|
+
|
|
36
|
+
stopped = true;
|
|
37
|
+
options.signal.removeEventListener("abort", stop);
|
|
38
|
+
options.dispose();
|
|
39
|
+
void html.cancel().catch(() => {});
|
|
40
|
+
void data.cancel().catch(() => {});
|
|
41
|
+
};
|
|
42
|
+
options.signal.addEventListener("abort", stop, { once: true });
|
|
43
|
+
if (options.signal.aborted) stop();
|
|
44
|
+
|
|
45
|
+
return new ReadableStream({
|
|
46
|
+
async pull(controller) {
|
|
47
|
+
const work = async () => {
|
|
48
|
+
if (first && !stopped) nextHTML = readHTML();
|
|
49
|
+
|
|
50
|
+
while (!stopped && (nextHTML || nextData)) {
|
|
51
|
+
const reads = [nextHTML, nextData].filter(
|
|
52
|
+
(value) => value !== undefined,
|
|
53
|
+
);
|
|
54
|
+
const result = await Promise.race(reads);
|
|
55
|
+
if (stopped) break;
|
|
56
|
+
if ("error" in result) return Promise.reject(result.error);
|
|
57
|
+
|
|
58
|
+
if (result.html) nextHTML = result.done ? undefined : readHTML();
|
|
59
|
+
if (!result.html) nextData = result.done ? undefined : readData();
|
|
60
|
+
if (first) nextData = readData();
|
|
61
|
+
if (result.done) {
|
|
62
|
+
first = false;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!result.html) {
|
|
67
|
+
controller.enqueue(result.value);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let chunk = decoder.decode(result.value, { stream: true });
|
|
72
|
+
if (chunk === "<div hidden>")
|
|
73
|
+
chunk = "<div hidden data-smolter-stream>";
|
|
74
|
+
if (first && !/^<!doctype/i.test(chunk))
|
|
75
|
+
chunk = `<!doctype html>${chunk}`;
|
|
76
|
+
first = false;
|
|
77
|
+
const closing = chunk.lastIndexOf("</body>");
|
|
78
|
+
if (closing !== -1) {
|
|
79
|
+
suffix = chunk.slice(closing);
|
|
80
|
+
chunk = chunk.slice(0, closing);
|
|
81
|
+
}
|
|
82
|
+
controller.enqueue(encoder.encode(chunk));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (cancelled) return;
|
|
87
|
+
if (!stopped) controller.enqueue(encoder.encode(suffix));
|
|
88
|
+
stop();
|
|
89
|
+
controller.close();
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
await work().catch((error: unknown) => {
|
|
93
|
+
stop();
|
|
94
|
+
controller.error(error);
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
cancel() {
|
|
98
|
+
cancelled = true;
|
|
99
|
+
stop();
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -3,6 +3,10 @@ import { resolve } from "node:path";
|
|
|
3
3
|
import { flatRoutes } from "../../files/index.ts";
|
|
4
4
|
import { type Plugin, transformWithOxc } from "vite";
|
|
5
5
|
|
|
6
|
+
interface ChunkMetadata {
|
|
7
|
+
importedCss?: Set<string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
6
10
|
// Reuse Smolter's file discovery and server-export stripping. Only the browser
|
|
7
11
|
// route manifest changes: matched routes load before their data is committed.
|
|
8
12
|
export function lazyFiles(
|
|
@@ -31,6 +35,7 @@ export function lazyFiles(
|
|
|
31
35
|
if (!path) continue;
|
|
32
36
|
|
|
33
37
|
const files = new Set<string>();
|
|
38
|
+
const styles = new Set<string>();
|
|
34
39
|
|
|
35
40
|
function visit(file: string) {
|
|
36
41
|
if (files.has(file)) return;
|
|
@@ -41,11 +46,16 @@ export function lazyFiles(
|
|
|
41
46
|
|
|
42
47
|
files.add(file);
|
|
43
48
|
|
|
49
|
+
const metadata = dependency.viteMetadata as ChunkMetadata | undefined;
|
|
50
|
+
for (const stylesheet of metadata?.importedCss ?? []) {
|
|
51
|
+
if (bundle[stylesheet]?.type === "asset") styles.add(stylesheet);
|
|
52
|
+
}
|
|
53
|
+
|
|
44
54
|
for (const imported of dependency.imports) visit(imported);
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
visit(chunk.fileName);
|
|
48
|
-
routes.push({ path, files: [...files] });
|
|
58
|
+
routes.push({ path, files: [...files], styles: [...styles] });
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
const paths = [...modules.values()];
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
- Add opt-in `earlyHydration: true` to Preact `renderApplication`: hydrate the shell before optional nested loader promises settle, using Preact Suspense and independently streamed values/errors. Default hydration timing is unchanged.
|
|
6
|
+
- Preserve shared promise identity, nonce-aware script serialization and the renderer's streaming container. Reject unfinished values when the document stream ends or the router is disposed; cancel server readers on disconnect.
|
|
7
|
+
- Add production regressions for interaction and input retention during a 1,500 ms delay, independent deferred boundaries, rejection, one-time mutation, navigation before settlement, injected head nodes and CSP nonces. These are fixture results, not Vibes performance or device sign-off.
|
|
8
|
+
- Include matched-route CSS in generated asset manifests and stylesheet preload hints using Vite’s anonymous CORS mode. Verify cross-origin preload reuse in Chromium. Keep transitive JavaScript dependencies discoverable and optional dynamic imports lazy. Preload hints do not apply styles or imply a measured latency improvement.
|
|
9
|
+
- Update SSR and loading documentation, including removal of stale claims that the Express adapter buffers responses.
|
|
10
|
+
|
|
3
11
|
## 0.3.5
|
|
4
12
|
|
|
5
13
|
- Preserve explicitly unmanaged head elements marked data-smolter-preserve during Preact document hydration, without matching them to application-owned nodes.
|
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# smolter _
|
|
2
2
|
|
|
3
3
|
<!-- bundlejs:start -->
|
|
4
|
-
[](https://bundlejs.com/?q=%40kasperrt%2Fsmolter%400.4.0%2Freact&treeshake=%5B*%5D&config=%7B%22compression%22%3A%22gzip%22%2C%22esbuild%22%3A%7B%22external%22%3A%5B%22react%22%2C%22react%2F*%22%5D%7D%7D&bundle)
|
|
5
|
+
[](https://bundlejs.com/?q=%40kasperrt%2Fsmolter%400.4.0%2Fpreact&treeshake=%5B*%5D&config=%7B%22compression%22%3A%22gzip%22%2C%22esbuild%22%3A%7B%22external%22%3A%5B%22preact%22%2C%22preact%2F*%22%5D%7D%7D&bundle)
|
|
6
6
|
<!-- bundlejs:end -->
|
|
7
7
|
|
|
8
8
|
A typed data router for React and Preact. **Zero runtime dependencies.**
|
|
@@ -607,8 +607,8 @@ a claim of identical features.
|
|
|
607
607
|
| `@kasperrt/smolter/preact` | 4842 |
|
|
608
608
|
| `@kasperrt/smolter/react + @kasperrt/smolter/react/client` | 4104 |
|
|
609
609
|
| `@kasperrt/smolter/preact + server transport` | 5950 |
|
|
610
|
-
| `@kasperrt/smolter/preact + client + document (application without lazy routes)` |
|
|
611
|
-
| `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` |
|
|
610
|
+
| `@kasperrt/smolter/preact + client + document (application without lazy routes)` | 8949 |
|
|
611
|
+
| `@kasperrt/smolter/preact + client + document + lazy-route (file-routed application)` | 9227 |
|
|
612
612
|
| `@kasperrt/smolter/react/data (optional resource API)` | 2962 |
|
|
613
613
|
| `React: common routing/data exports` | 2761 |
|
|
614
614
|
| `Preact: common routing/data exports` | 3046 |
|
|
@@ -748,14 +748,23 @@ import { mountApplication } from "@kasperrt/smolter/preact/client";
|
|
|
748
748
|
await mountApplication(routes, root, { deployment });
|
|
749
749
|
```
|
|
750
750
|
|
|
751
|
-
Preact SSR starts root and route loaders concurrently. Return nested promises and render them with `Await` inside `Suspense` to stream a shell before optional data resolves. The Node/Express adapter streams the response; hydration starts after the document stream finishes. Root-loader failures render the root error boundary. Vite development HTML transformation still buffers the document.
|
|
751
|
+
Preact SSR starts root and route loaders concurrently. Return nested promises and render them with `Await` inside `Suspense` to stream a shell before optional data resolves. The Node/Express adapter streams the response; hydration starts after the document stream finishes by default. Pass `{ earlyHydration: true }` to `renderApplication` to hydrate the shell while optional promises stream independently. Root-loader failures render the root error boundary. Vite development HTML transformation still buffers the document.
|
|
752
752
|
|
|
753
753
|
Successful action redirects refresh registered root data without replaying the mutation. Loader arguments expose `reason` to distinguish initial loads, navigation, revalidation and fetcher reads without comparing browser URLs.
|
|
754
754
|
|
|
755
755
|
|
|
756
756
|
### Document hydration and injected tags
|
|
757
757
|
|
|
758
|
-
Preact hydrates
|
|
758
|
+
Preact hydrates after document completion by default. For an interactive shell while optional data is pending:
|
|
759
|
+
|
|
760
|
+
```ts
|
|
761
|
+
return renderApplication(request, routes, root, entry, {
|
|
762
|
+
earlyHydration: true,
|
|
763
|
+
nonce,
|
|
764
|
+
});
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
Keep optional nested promises inside `Suspense` and `Await`. The browser entry runs asynchronously; each deferred result settles without rerunning the initial loader. Required top-level loader data still blocks the shell. Use production streaming hosting; Vite development HTML transformation buffers. Measure a successful interaction rather than treating TTFB or DOMContentLoaded as hydration completion.
|
|
759
768
|
|
|
760
769
|
Infrastructure that injects direct children of `<head>` can mark those external nodes with `data-smolter-preserve`. Smolter excludes them from positional hydration and restores the same nodes afterward. Do not also render them in the application layout. Unmarked HTML rewriting, body injection and changes to the hydration payload are not supported.
|
|
761
770
|
|
|
@@ -764,3 +773,8 @@ Infrastructure that injects direct children of `<head>` can mark those external
|
|
|
764
773
|
```
|
|
765
774
|
|
|
766
775
|
The generated route asset manifest follows static imports for each route and leaves optional dynamic imports lazy. Serve the matched route's preload hints with the correct asset base; do not preload every route to reduce a waterfall. Chunk counts and total emitted bytes alone do not establish initial-load latency.
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
### Matched-route asset hints
|
|
779
|
+
|
|
780
|
+
The file-router build emits `.vite/route-assets.json` with each route's static JavaScript dependency closure and its CSS in `styles`. `routePreloads` from `@kasperrt/smolter/utils/assets` produces modulepreload hints for JavaScript and `preload; as=style; crossorigin` hints for CSS (matching Vite’s anonymous CORS mode), respecting your CDN/base URL. Set these hints on the document response or send Early Hints from your host. Stylesheet preloads fetch files; they do not apply styles. Keep the application stylesheet links and Vite's CSS handling in place. Optional dynamic imports are excluded, and unrelated routes stay lazy.
|
package/dist/preact/client.js
CHANGED
|
@@ -8,6 +8,7 @@ import { RouterApplication, prepareRoutes } from "./router.js";
|
|
|
8
8
|
import { deserialize } from "../client/deserialize.js";
|
|
9
9
|
import { recoverDeployment, watchDeployment } from "../client/deployment.js";
|
|
10
10
|
import { serverRoute } from "../client/index.js";
|
|
11
|
+
import { readHydration } from "./deferred-hydration.js";
|
|
11
12
|
import { flushSync } from "preact/compat";
|
|
12
13
|
import { jsx } from "preact/jsx-runtime";
|
|
13
14
|
import { hydrate, render } from "preact";
|
|
@@ -23,19 +24,19 @@ async function mountApplication(routes, rootModule, options = false) {
|
|
|
23
24
|
});
|
|
24
25
|
routes = prepareRoutes(routes);
|
|
25
26
|
const element = document.getElementById("smolter-data");
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const error = Object.assign(new Error(value.__smolterRejected.message), value.__smolterRejected);
|
|
29
|
-
const rejected = Promise.reject(error);
|
|
30
|
-
rejected.catch(() => {});
|
|
31
|
-
return rejected;
|
|
32
|
-
}) : void 0;
|
|
27
|
+
const initial = element?.textContent ? readHydration(element.textContent) : void 0;
|
|
28
|
+
const hydration = initial?.hydration;
|
|
33
29
|
if (hydration?.snapshot.error) hydration.snapshot.error = Object.assign(new Error(hydration.snapshot.error.message), hydration.snapshot.error);
|
|
34
30
|
const router = createDataRouter(routes, {
|
|
35
31
|
browser: !hash,
|
|
36
32
|
url: hash ? new URL(location.hash.slice(1) || "/", location.origin).href : location.href,
|
|
37
33
|
...hydration && { hydrationData: hydration.snapshot }
|
|
38
34
|
});
|
|
35
|
+
const dispose = router.dispose;
|
|
36
|
+
router.dispose = () => {
|
|
37
|
+
initial?.dispose();
|
|
38
|
+
dispose();
|
|
39
|
+
};
|
|
39
40
|
watchDeployment(router, deployment);
|
|
40
41
|
if (hash) connectHashHistory(router);
|
|
41
42
|
retainRoute(router, flushSync);
|
|
@@ -50,16 +51,17 @@ async function mountApplication(routes, rootModule, options = false) {
|
|
|
50
51
|
rootError: hydration?.rootError
|
|
51
52
|
};
|
|
52
53
|
if (hydration) {
|
|
53
|
-
const preserved = Array.from(document.
|
|
54
|
+
const preserved = Array.from(document.querySelectorAll("head > [data-smolter-preserve], body > [data-smolter-stream]"), (node) => ({
|
|
54
55
|
node,
|
|
55
|
-
next: node.nextSibling
|
|
56
|
+
next: node.nextSibling,
|
|
57
|
+
parent: node.parentNode
|
|
56
58
|
}));
|
|
57
59
|
for (const { node } of preserved) node.remove();
|
|
58
60
|
await Promise.resolve().then(() => hydrate(/* @__PURE__ */ jsx(RouterApplication, {
|
|
59
61
|
...props,
|
|
60
62
|
document: true
|
|
61
63
|
}), document)).finally(() => {
|
|
62
|
-
for (const { node, next } of preserved.reverse())
|
|
64
|
+
for (const { node, next, parent } of preserved.reverse()) parent.insertBefore(node, next?.parentNode === parent ? next : null);
|
|
63
65
|
});
|
|
64
66
|
return router;
|
|
65
67
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type Settlement = [number, unknown, string | null];
|
|
2
|
+
declare global {
|
|
3
|
+
interface Window {
|
|
4
|
+
__smolterDeferred?: Settlement[];
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
/** Restore promise identity before hydrating Suspense boundaries. */
|
|
8
|
+
export declare function readHydration(json: string): {
|
|
9
|
+
hydration: any;
|
|
10
|
+
dispose: () => void;
|
|
11
|
+
};
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//#region .generated/preact/deferred-hydration.ts
|
|
2
|
+
/** Restore promise identity before hydrating Suspense boundaries. */
|
|
3
|
+
function readHydration(json) {
|
|
4
|
+
const pending = /* @__PURE__ */ new Map();
|
|
5
|
+
const queue = window.__smolterDeferred ??= [];
|
|
6
|
+
function revive(_key, value) {
|
|
7
|
+
if (!value || typeof value !== "object") return value;
|
|
8
|
+
if ("__smolterUndefined" in value) return void 0;
|
|
9
|
+
if ("__smolterPending" in value && typeof value.__smolterPending === "number") {
|
|
10
|
+
const id = value.__smolterPending;
|
|
11
|
+
const existing = pending.get(id);
|
|
12
|
+
if (existing) return existing.promise;
|
|
13
|
+
let resolve;
|
|
14
|
+
let reject;
|
|
15
|
+
const promise = new Promise((accept, fail) => {
|
|
16
|
+
resolve = accept;
|
|
17
|
+
reject = fail;
|
|
18
|
+
});
|
|
19
|
+
promise.catch(() => {});
|
|
20
|
+
pending.set(id, {
|
|
21
|
+
promise,
|
|
22
|
+
resolve,
|
|
23
|
+
reject
|
|
24
|
+
});
|
|
25
|
+
return promise;
|
|
26
|
+
}
|
|
27
|
+
if ("__smolterRejected" in value) {
|
|
28
|
+
const failure = value.__smolterRejected;
|
|
29
|
+
const rejected = Promise.reject(Object.assign(new Error(failure.message), failure));
|
|
30
|
+
rejected.catch(() => {});
|
|
31
|
+
return rejected;
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
const hydration = JSON.parse(json, revive);
|
|
36
|
+
const dispose = () => {
|
|
37
|
+
window.removeEventListener("DOMContentLoaded", dispose);
|
|
38
|
+
for (const target of pending.values()) target.reject(/* @__PURE__ */ new Error("Hydration stream closed"));
|
|
39
|
+
pending.clear();
|
|
40
|
+
queue.push = () => 0;
|
|
41
|
+
};
|
|
42
|
+
const receive = (packet) => {
|
|
43
|
+
const [id, value, error] = packet;
|
|
44
|
+
if (id === -1) return dispose();
|
|
45
|
+
const target = pending.get(id);
|
|
46
|
+
if (!target) return;
|
|
47
|
+
if (error !== null) target.reject(new Error(error));
|
|
48
|
+
if (error === null) target.resolve(JSON.parse(JSON.stringify(value), revive));
|
|
49
|
+
};
|
|
50
|
+
const buffered = queue.splice(0);
|
|
51
|
+
queue.push = (...packets) => {
|
|
52
|
+
packets.forEach(receive);
|
|
53
|
+
return 0;
|
|
54
|
+
};
|
|
55
|
+
window.addEventListener("DOMContentLoaded", dispose, { once: true });
|
|
56
|
+
buffered.forEach(receive);
|
|
57
|
+
if (document.readyState !== "loading") dispose();
|
|
58
|
+
return {
|
|
59
|
+
hydration,
|
|
60
|
+
dispose
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
export { readHydration };
|
package/dist/preact/document.js
CHANGED
|
@@ -44,8 +44,9 @@ function Links() {
|
|
|
44
44
|
return /* @__PURE__ */ jsx(Fragment$1, { children: rootModule?.links?.().map((props, index) => /* @__PURE__ */ jsx("link", { ...props }, index)) });
|
|
45
45
|
}
|
|
46
46
|
function Scripts({ nonce }) {
|
|
47
|
-
const { root, rootError, router, entry, streaming } = useApplication();
|
|
47
|
+
const { root, rootError, router, entry, streaming, initialData } = useApplication();
|
|
48
48
|
const [json] = useState(() => {
|
|
49
|
+
if (initialData !== void 0) return initialData;
|
|
49
50
|
if (streaming) return "";
|
|
50
51
|
const current = router.getSnapshot();
|
|
51
52
|
const snapshot = {
|
|
@@ -75,6 +76,7 @@ function Scripts({ nonce }) {
|
|
|
75
76
|
}),
|
|
76
77
|
entry && /* @__PURE__ */ jsx("script", {
|
|
77
78
|
type: "module",
|
|
79
|
+
async: initialData !== void 0,
|
|
78
80
|
src: entry,
|
|
79
81
|
...nonce && { nonce }
|
|
80
82
|
})
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import type { AppRoute, RootModule } from "../preact/route-types.ts";
|
|
3
3
|
export interface RenderApplicationOptions {
|
|
4
4
|
nonce?: string;
|
|
5
|
+
earlyHydration?: boolean;
|
|
5
6
|
}
|
|
6
7
|
export declare function renderApplication(request: Request, routes: AppRoute[], rootModule: RootModule, entry: string, options?: RenderApplicationOptions): Promise<Response>;
|
|
7
8
|
export declare function dispatchRoute(request: Request, routes: AppRoute[], document?: boolean): Promise<Response>;
|
|
@@ -5,6 +5,8 @@ import { normalizeResult, resolveData } from "../core/responses.js";
|
|
|
5
5
|
import { RouterApplication, prepareRoutes } from "../preact/router.js";
|
|
6
6
|
import { privateResponse } from "./private-response.js";
|
|
7
7
|
import { loadRouter } from "./index.js";
|
|
8
|
+
import { deferredHydration } from "./deferred-hydration.js";
|
|
9
|
+
import { progressiveDocument } from "./progressive-document.js";
|
|
8
10
|
import { resolveHydration, streamDocument } from "./stream-document.js";
|
|
9
11
|
import { jsx } from "preact/jsx-runtime";
|
|
10
12
|
import { renderToReadableStream } from "preact-render-to-string/stream";
|
|
@@ -68,17 +70,7 @@ async function renderApplication(request, routes, rootModule, entry, options = {
|
|
|
68
70
|
router = createDataRouter(router.routes, { hydrationData });
|
|
69
71
|
}
|
|
70
72
|
const snapshot = router.getSnapshot();
|
|
71
|
-
const
|
|
72
|
-
router,
|
|
73
|
-
routes,
|
|
74
|
-
root,
|
|
75
|
-
rootModule,
|
|
76
|
-
entry,
|
|
77
|
-
rootError: Boolean(rootError),
|
|
78
|
-
streaming: true,
|
|
79
|
-
document: true
|
|
80
|
-
}), options);
|
|
81
|
-
const hydration = resolveHydration({
|
|
73
|
+
const hydrationData = {
|
|
82
74
|
root,
|
|
83
75
|
snapshot: {
|
|
84
76
|
...snapshot,
|
|
@@ -89,15 +81,30 @@ async function renderApplication(request, routes, rootModule, entry, options = {
|
|
|
89
81
|
}
|
|
90
82
|
},
|
|
91
83
|
...rootError && { rootError: true }
|
|
92
|
-
}
|
|
84
|
+
};
|
|
85
|
+
const deferred = options.earlyHydration ? deferredHydration(hydrationData, options.nonce) : void 0;
|
|
86
|
+
const stream = renderToReadableStream(/* @__PURE__ */ jsx(RouterApplication, {
|
|
87
|
+
router,
|
|
88
|
+
routes,
|
|
89
|
+
root,
|
|
90
|
+
rootModule,
|
|
91
|
+
entry,
|
|
92
|
+
rootError: Boolean(rootError),
|
|
93
|
+
streaming: true,
|
|
94
|
+
initialData: deferred?.initial,
|
|
95
|
+
document: true
|
|
96
|
+
}), options);
|
|
93
97
|
stream.allReady.catch(() => {});
|
|
94
98
|
const headers = new Headers(actionResponse?.headers);
|
|
95
99
|
headers.delete("Content-Length");
|
|
96
100
|
headers.delete("Content-Encoding");
|
|
97
101
|
headers.set("Content-Type", "text/html; charset=utf-8");
|
|
98
102
|
headers.set("Cache-Control", "no-store");
|
|
99
|
-
return new Response(
|
|
100
|
-
|
|
103
|
+
return new Response(deferred ? progressiveDocument(stream, deferred.stream, {
|
|
104
|
+
signal: request.signal,
|
|
105
|
+
dispose: () => router.dispose()
|
|
106
|
+
}) : streamDocument(stream, {
|
|
107
|
+
hydration: resolveHydration(hydrationData),
|
|
101
108
|
signal: request.signal,
|
|
102
109
|
nonce: options.nonce,
|
|
103
110
|
dispose: () => router.dispose()
|
package/dist/server/assets.d.ts
CHANGED
package/dist/server/assets.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { matchRoutes } from "../core/match.js";
|
|
2
2
|
//#region .generated/server/assets.ts
|
|
3
3
|
function routePreloads(routes, pathname, base) {
|
|
4
|
-
|
|
4
|
+
const route = matchRoutes(routes, pathname)?.route;
|
|
5
|
+
const prefix = base.replace(/\/$/, "");
|
|
6
|
+
return [...(route?.styles ?? []).map((file) => `<${prefix}/${file}>; rel=preload; as=style; crossorigin`), ...(route?.files ?? []).map((file) => `<${prefix}/${file}>; rel=modulepreload`)].join(", ");
|
|
5
7
|
}
|
|
6
8
|
//#endregion
|
|
7
9
|
export { routePreloads };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { serializeData } from "../core/serialize.js";
|
|
2
|
+
//#region .generated/server/deferred-hydration.ts
|
|
3
|
+
/** Encode deferred values without delaying the initial document snapshot. */
|
|
4
|
+
function deferredHydration(value, nonce) {
|
|
5
|
+
const promises = /* @__PURE__ */ new Map();
|
|
6
|
+
let pending = 0;
|
|
7
|
+
let closed = false;
|
|
8
|
+
let controller;
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
const attribute = nonce ? ` nonce="${nonce.replaceAll("&", "&").replaceAll("\"", """).replaceAll("<", "<")}"` : "";
|
|
11
|
+
const stream = new ReadableStream({
|
|
12
|
+
start(value) {
|
|
13
|
+
controller = value;
|
|
14
|
+
},
|
|
15
|
+
cancel() {
|
|
16
|
+
closed = true;
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
function encode(value) {
|
|
20
|
+
if (value instanceof Promise) {
|
|
21
|
+
const existing = promises.get(value);
|
|
22
|
+
if (existing !== void 0) return { __smolterPending: existing };
|
|
23
|
+
const id = promises.size;
|
|
24
|
+
promises.set(value, id);
|
|
25
|
+
pending++;
|
|
26
|
+
value.then((result) => settle(id, encode(result))).catch((error) => settle(id, null, error instanceof Error ? error.message : String(error)));
|
|
27
|
+
return { __smolterPending: id };
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(value)) return value.map(encode);
|
|
30
|
+
if (value && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) return Object.fromEntries(Object.entries(value).map(([key, field]) => [key, encode(field)]));
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function settle(id, value, error) {
|
|
34
|
+
if (closed) return;
|
|
35
|
+
const packet = serializeData([
|
|
36
|
+
id,
|
|
37
|
+
value === void 0 ? { __smolterUndefined: true } : value,
|
|
38
|
+
error
|
|
39
|
+
]);
|
|
40
|
+
controller.enqueue(encoder.encode(`<script${attribute}>(window.__smolterDeferred??=[]).push(${packet});document.currentScript.remove()<\/script>`));
|
|
41
|
+
pending--;
|
|
42
|
+
if (!pending) {
|
|
43
|
+
closed = true;
|
|
44
|
+
controller.enqueue(encoder.encode(`<script${attribute}>(window.__smolterDeferred??=[]).push([-1,null,null]);document.currentScript.remove()<\/script>`));
|
|
45
|
+
controller.close();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const initial = serializeData(encode(value));
|
|
49
|
+
if (!pending) {
|
|
50
|
+
closed = true;
|
|
51
|
+
controller.close();
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
initial,
|
|
55
|
+
stream
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
export { deferredHydration };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
interface ProgressiveDocumentOptions {
|
|
2
|
+
signal: AbortSignal;
|
|
3
|
+
dispose: () => void;
|
|
4
|
+
}
|
|
5
|
+
/** Interleave deferred settlements with complete HTML chunks, respecting backpressure. */
|
|
6
|
+
export declare function progressiveDocument(source: ReadableStream<Uint8Array>, settlements: ReadableStream<Uint8Array>, options: ProgressiveDocumentOptions): ReadableStream<Uint8Array>;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
//#region .generated/server/progressive-document.ts
|
|
2
|
+
/** Interleave deferred settlements with complete HTML chunks, respecting backpressure. */
|
|
3
|
+
function progressiveDocument(source, settlements, options) {
|
|
4
|
+
const html = source.getReader();
|
|
5
|
+
const data = settlements.getReader();
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const decoder = new TextDecoder();
|
|
8
|
+
let stopped = false;
|
|
9
|
+
let cancelled = false;
|
|
10
|
+
let first = true;
|
|
11
|
+
let suffix = "";
|
|
12
|
+
const readHTML = () => html.read().then((result) => ({
|
|
13
|
+
...result,
|
|
14
|
+
html: true
|
|
15
|
+
}), (error) => ({ error }));
|
|
16
|
+
const readData = () => data.read().then((result) => ({
|
|
17
|
+
...result,
|
|
18
|
+
html: false
|
|
19
|
+
}), (error) => ({ error }));
|
|
20
|
+
let nextHTML;
|
|
21
|
+
let nextData;
|
|
22
|
+
const stop = () => {
|
|
23
|
+
if (stopped) return;
|
|
24
|
+
stopped = true;
|
|
25
|
+
options.signal.removeEventListener("abort", stop);
|
|
26
|
+
options.dispose();
|
|
27
|
+
html.cancel().catch(() => {});
|
|
28
|
+
data.cancel().catch(() => {});
|
|
29
|
+
};
|
|
30
|
+
options.signal.addEventListener("abort", stop, { once: true });
|
|
31
|
+
if (options.signal.aborted) stop();
|
|
32
|
+
return new ReadableStream({
|
|
33
|
+
async pull(controller) {
|
|
34
|
+
const work = async () => {
|
|
35
|
+
if (first && !stopped) nextHTML = readHTML();
|
|
36
|
+
while (!stopped && (nextHTML || nextData)) {
|
|
37
|
+
const reads = [nextHTML, nextData].filter((value) => value !== void 0);
|
|
38
|
+
const result = await Promise.race(reads);
|
|
39
|
+
if (stopped) break;
|
|
40
|
+
if ("error" in result) return Promise.reject(result.error);
|
|
41
|
+
if (result.html) nextHTML = result.done ? void 0 : readHTML();
|
|
42
|
+
if (!result.html) nextData = result.done ? void 0 : readData();
|
|
43
|
+
if (first) nextData = readData();
|
|
44
|
+
if (result.done) {
|
|
45
|
+
first = false;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (!result.html) {
|
|
49
|
+
controller.enqueue(result.value);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
let chunk = decoder.decode(result.value, { stream: true });
|
|
53
|
+
if (chunk === "<div hidden>") chunk = "<div hidden data-smolter-stream>";
|
|
54
|
+
if (first && !/^<!doctype/i.test(chunk)) chunk = `<!doctype html>${chunk}`;
|
|
55
|
+
first = false;
|
|
56
|
+
const closing = chunk.lastIndexOf("</body>");
|
|
57
|
+
if (closing !== -1) {
|
|
58
|
+
suffix = chunk.slice(closing);
|
|
59
|
+
chunk = chunk.slice(0, closing);
|
|
60
|
+
}
|
|
61
|
+
controller.enqueue(encoder.encode(chunk));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (cancelled) return;
|
|
65
|
+
if (!stopped) controller.enqueue(encoder.encode(suffix));
|
|
66
|
+
stop();
|
|
67
|
+
controller.close();
|
|
68
|
+
};
|
|
69
|
+
await work().catch((error) => {
|
|
70
|
+
stop();
|
|
71
|
+
controller.error(error);
|
|
72
|
+
});
|
|
73
|
+
},
|
|
74
|
+
cancel() {
|
|
75
|
+
cancelled = true;
|
|
76
|
+
stop();
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
//#endregion
|
|
81
|
+
export { progressiveDocument };
|
|
@@ -20,17 +20,21 @@ function lazyFiles(directory, dataEndpoint = "/_smolter") {
|
|
|
20
20
|
const path = modules.get(chunk.facadeModuleId);
|
|
21
21
|
if (!path) continue;
|
|
22
22
|
const files = /* @__PURE__ */ new Set();
|
|
23
|
+
const styles = /* @__PURE__ */ new Set();
|
|
23
24
|
function visit(file) {
|
|
24
25
|
if (files.has(file)) return;
|
|
25
26
|
const dependency = bundle[file];
|
|
26
27
|
if (dependency?.type !== "chunk") return;
|
|
27
28
|
files.add(file);
|
|
29
|
+
const metadata = dependency.viteMetadata;
|
|
30
|
+
for (const stylesheet of metadata?.importedCss ?? []) if (bundle[stylesheet]?.type === "asset") styles.add(stylesheet);
|
|
28
31
|
for (const imported of dependency.imports) visit(imported);
|
|
29
32
|
}
|
|
30
33
|
visit(chunk.fileName);
|
|
31
34
|
routes.push({
|
|
32
35
|
path,
|
|
33
|
-
files: [...files]
|
|
36
|
+
files: [...files],
|
|
37
|
+
styles: [...styles]
|
|
34
38
|
});
|
|
35
39
|
}
|
|
36
40
|
const paths = [...modules.values()];
|
package/jsr.json
CHANGED