@chr33s/solarflare 0.0.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/package.json +52 -0
- package/readme.md +183 -0
- package/src/ast.ts +316 -0
- package/src/build.bundle-client.ts +404 -0
- package/src/build.bundle-server.ts +131 -0
- package/src/build.bundle.ts +48 -0
- package/src/build.emit-manifests.ts +25 -0
- package/src/build.hmr-entry.ts +88 -0
- package/src/build.scan.ts +182 -0
- package/src/build.ts +227 -0
- package/src/build.validate.ts +63 -0
- package/src/client.hmr.ts +78 -0
- package/src/client.styles.ts +68 -0
- package/src/client.ts +190 -0
- package/src/codemod.ts +688 -0
- package/src/console-forward.ts +254 -0
- package/src/critical-css.ts +103 -0
- package/src/devtools-json.ts +52 -0
- package/src/diff-dom-streaming.ts +406 -0
- package/src/early-flush.ts +125 -0
- package/src/early-hints.ts +83 -0
- package/src/fetch.ts +44 -0
- package/src/fs.ts +11 -0
- package/src/head.ts +876 -0
- package/src/hmr.ts +647 -0
- package/src/hydration.ts +238 -0
- package/src/manifest.runtime.ts +25 -0
- package/src/manifest.ts +23 -0
- package/src/paths.ts +96 -0
- package/src/render-priority.ts +69 -0
- package/src/route-cache.ts +163 -0
- package/src/router-deferred.ts +85 -0
- package/src/router-stream.ts +65 -0
- package/src/router.ts +535 -0
- package/src/runtime.ts +32 -0
- package/src/serialize.ts +38 -0
- package/src/server.hmr.ts +67 -0
- package/src/server.styles.ts +42 -0
- package/src/server.ts +480 -0
- package/src/solarflare.d.ts +101 -0
- package/src/speculation-rules.ts +171 -0
- package/src/store.ts +78 -0
- package/src/stream-assets.ts +135 -0
- package/src/stylesheets.ts +222 -0
- package/src/worker.config.ts +243 -0
- package/src/worker.ts +542 -0
- package/tsconfig.json +21 -0
package/src/hydration.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { serializeToString, parseFromString } from "./serialize.ts";
|
|
2
|
+
import { serializeHeadState, hydrateHeadState } from "./head.ts";
|
|
3
|
+
import { initStore, setPathname, params, serverData, pathname } from "./store.ts";
|
|
4
|
+
|
|
5
|
+
/** Store island ID. */
|
|
6
|
+
const STORE_ISLAND_ID = "sf-store";
|
|
7
|
+
|
|
8
|
+
/** Head island ID. */
|
|
9
|
+
const HEAD_ISLAND_ID = "sf-head";
|
|
10
|
+
|
|
11
|
+
/** Gets the default data island ID for a component tag. */
|
|
12
|
+
export function getComponentDataIslandId(tag: string) {
|
|
13
|
+
return `${tag}-data`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Gets the deferred data island ID for a component tag and key. */
|
|
17
|
+
export function getDeferredIslandId(tag: string, key: string) {
|
|
18
|
+
return `${tag}-deferred-${key}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Gets the deferred hydration script ID for a component tag and key. */
|
|
22
|
+
export function getHydrateScriptId(tag: string, key: string) {
|
|
23
|
+
return `${tag}-hydrate-${key}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Gets the deferred data island prefix for a component tag. */
|
|
27
|
+
export function getDeferredIslandPrefix(tag: string) {
|
|
28
|
+
return `${tag}-deferred-`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Gets the hydration script prefix for a component tag. */
|
|
32
|
+
export function getHydrateScriptPrefix(tag: string) {
|
|
33
|
+
return `${tag}-hydrate-`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Serializes data to a script tag for progressive hydration.
|
|
38
|
+
* Includes a stable id attribute for diff-dom-streaming replacements.
|
|
39
|
+
*/
|
|
40
|
+
export async function serializeDataIsland(id: string, data: unknown) {
|
|
41
|
+
const serialized = await serializeToString(data);
|
|
42
|
+
return /* html */ `<script type="application/json" id="${id}" data-island="${id}">${serialized}</script>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Extracts and parses data from a data island script tag. */
|
|
46
|
+
export async function extractDataIsland<T = unknown>(id: string) {
|
|
47
|
+
if (typeof document === "undefined") return null;
|
|
48
|
+
|
|
49
|
+
const script = document.querySelector(`script[data-island="${CSS.escape(id)}"]`);
|
|
50
|
+
if (!script?.textContent) return null;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
return await parseFromString<T>(script.textContent);
|
|
54
|
+
} catch {
|
|
55
|
+
console.error(`[solarflare] Failed to parse data island "${id}"`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Serializes store state for client hydration. */
|
|
61
|
+
export async function serializeStoreForHydration() {
|
|
62
|
+
const state = {
|
|
63
|
+
params: params.value,
|
|
64
|
+
serverData: serverData.value.data,
|
|
65
|
+
pathname: pathname.value,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return serializeDataIsland(STORE_ISLAND_ID, state);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Serializes head state for client hydration. */
|
|
72
|
+
export async function serializeHeadForHydration() {
|
|
73
|
+
return /* html */ `<script type="application/json" data-island="${HEAD_ISLAND_ID}">${serializeHeadState()}</script>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Hydrates store from serialized state (client-side). */
|
|
77
|
+
export async function hydrateStore() {
|
|
78
|
+
if (typeof document === "undefined") return;
|
|
79
|
+
|
|
80
|
+
const state = await extractDataIsland<{
|
|
81
|
+
params: Record<string, string>;
|
|
82
|
+
serverData: unknown;
|
|
83
|
+
pathname: string;
|
|
84
|
+
}>(STORE_ISLAND_ID);
|
|
85
|
+
|
|
86
|
+
if (!state) return;
|
|
87
|
+
|
|
88
|
+
initStore({
|
|
89
|
+
params: state.params,
|
|
90
|
+
serverData: state.serverData,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
setPathname(state.pathname);
|
|
94
|
+
|
|
95
|
+
const script = document.querySelector(`script[data-island="${CSS.escape(STORE_ISLAND_ID)}"]`);
|
|
96
|
+
script?.remove();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Hydrates head state from serialized data island (client-side). */
|
|
100
|
+
export async function hydrateHead() {
|
|
101
|
+
if (typeof document === "undefined") return;
|
|
102
|
+
|
|
103
|
+
const script = document.querySelector(`script[data-island="${CSS.escape(HEAD_ISLAND_ID)}"]`);
|
|
104
|
+
if (!script?.textContent) return;
|
|
105
|
+
|
|
106
|
+
hydrateHeadState(script.textContent);
|
|
107
|
+
|
|
108
|
+
script.remove();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Navigation mode flag - when true, don't remove data island scripts during hydration. */
|
|
112
|
+
let navigationMode = false;
|
|
113
|
+
|
|
114
|
+
/** Sets navigation mode (called by router during client-side navigation). */
|
|
115
|
+
export function setNavigationMode(active: boolean) {
|
|
116
|
+
navigationMode = active;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Hydrates a component when its data island arrives.
|
|
121
|
+
* Removes island scripts unless navigation mode is active.
|
|
122
|
+
*/
|
|
123
|
+
export async function hydrateComponent(tag: string, dataIslandId?: string) {
|
|
124
|
+
if (typeof document === "undefined") return;
|
|
125
|
+
|
|
126
|
+
const element = document.querySelector(tag) as HTMLElement & {
|
|
127
|
+
_sfDeferred?: Record<string, unknown>;
|
|
128
|
+
_vdom?: unknown;
|
|
129
|
+
};
|
|
130
|
+
if (!element) return;
|
|
131
|
+
|
|
132
|
+
const islandId = dataIslandId ?? getComponentDataIslandId(tag);
|
|
133
|
+
const data = await extractDataIsland<Record<string, unknown>>(islandId);
|
|
134
|
+
|
|
135
|
+
if (data && typeof data === "object") {
|
|
136
|
+
element.removeAttribute("data-loading");
|
|
137
|
+
element._sfDeferred = { ...element._sfDeferred, ...data };
|
|
138
|
+
|
|
139
|
+
if (!navigationMode) {
|
|
140
|
+
const scripts = document.querySelectorAll(`script[data-island="${CSS.escape(islandId)}"]`);
|
|
141
|
+
scripts.forEach((script) => script.remove());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
element.dispatchEvent(
|
|
145
|
+
new CustomEvent("sf:hydrate", {
|
|
146
|
+
detail: data,
|
|
147
|
+
bubbles: true,
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Module-level hydration state. */
|
|
154
|
+
let hydrationReady = false;
|
|
155
|
+
const hydrationQueue: Array<{ tag: string; id: string; attempts: number }> = [];
|
|
156
|
+
let eventListenerAttached = false;
|
|
157
|
+
let processingQueue = false;
|
|
158
|
+
const MAX_HYDRATION_RETRIES = 50;
|
|
159
|
+
const HYDRATION_RETRY_DELAY_MS = 50;
|
|
160
|
+
|
|
161
|
+
/** Handles hydration queue events. */
|
|
162
|
+
function handleQueueHydrateEvent(e: Event) {
|
|
163
|
+
const { tag, id } = (e as CustomEvent<{ tag: string; id: string }>).detail;
|
|
164
|
+
queueHydration(tag, id);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Processes the hydration queue sequentially. */
|
|
168
|
+
async function processHydrationQueue() {
|
|
169
|
+
if (processingQueue) return;
|
|
170
|
+
processingQueue = true;
|
|
171
|
+
|
|
172
|
+
while (hydrationQueue.length > 0) {
|
|
173
|
+
const item = hydrationQueue.shift();
|
|
174
|
+
if (!item) continue;
|
|
175
|
+
|
|
176
|
+
const { tag, id: dataIslandId, attempts } = item;
|
|
177
|
+
// Skip stale entries for elements no longer in the DOM
|
|
178
|
+
if (!document.querySelector(tag)) {
|
|
179
|
+
if (attempts < MAX_HYDRATION_RETRIES) {
|
|
180
|
+
hydrationQueue.push({ tag, id: dataIslandId, attempts: attempts + 1 });
|
|
181
|
+
}
|
|
182
|
+
await new Promise((resolve) => setTimeout(resolve, HYDRATION_RETRY_DELAY_MS));
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
await hydrateComponent(tag, dataIslandId);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
console.error("[solarflare] hydrateComponent error:", err);
|
|
190
|
+
}
|
|
191
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
processingQueue = false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Queues a hydration call. */
|
|
198
|
+
export function queueHydration(tag: string, dataIslandId: string) {
|
|
199
|
+
hydrationQueue.push({ tag, id: dataIslandId, attempts: 0 });
|
|
200
|
+
if (hydrationReady) {
|
|
201
|
+
void processHydrationQueue();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Checks if hydration coordinator is initialized. */
|
|
206
|
+
export function isHydrationReady() {
|
|
207
|
+
return hydrationReady;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Initializes the hydration coordinator. */
|
|
211
|
+
export function initHydrationCoordinator() {
|
|
212
|
+
if (typeof document === "undefined") return;
|
|
213
|
+
|
|
214
|
+
// Attach event listener for streaming SSR hydration triggers (only once)
|
|
215
|
+
if (!eventListenerAttached) {
|
|
216
|
+
document.addEventListener("sf:queue-hydrate", handleQueueHydrateEvent);
|
|
217
|
+
eventListenerAttached = true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (hydrationReady) return;
|
|
221
|
+
|
|
222
|
+
hydrationReady = true;
|
|
223
|
+
void processHydrationQueue();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Cleans up the hydration coordinator (call on app unmount/navigation). */
|
|
227
|
+
export function cleanupHydrationCoordinator() {
|
|
228
|
+
if (typeof document === "undefined") return;
|
|
229
|
+
|
|
230
|
+
if (eventListenerAttached) {
|
|
231
|
+
document.removeEventListener("sf:queue-hydrate", handleQueueHydrateEvent);
|
|
232
|
+
eventListenerAttached = false;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
hydrationReady = false;
|
|
236
|
+
processingQueue = false;
|
|
237
|
+
hydrationQueue.length = 0;
|
|
238
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ChunkManifest } from "./manifest.ts";
|
|
2
|
+
import type { ModuleMap } from "./server.ts";
|
|
3
|
+
// @ts-ignore - Generated at build time, aliased by bundler
|
|
4
|
+
import modules from ".modules.generated";
|
|
5
|
+
// @ts-ignore - Generated at build time, aliased by bundler
|
|
6
|
+
import chunkManifest from ".chunks.generated.json";
|
|
7
|
+
|
|
8
|
+
export const typedModules = modules as ModuleMap;
|
|
9
|
+
|
|
10
|
+
export const manifest = chunkManifest as ChunkManifest;
|
|
11
|
+
|
|
12
|
+
/** Gets the script path for a route from the chunk manifest. */
|
|
13
|
+
export function getScriptPath(tag: string) {
|
|
14
|
+
return manifest.tags[tag];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Gets stylesheets for a route pattern from the chunk manifest. */
|
|
18
|
+
export function getStylesheets(pattern: string) {
|
|
19
|
+
return manifest.styles[pattern] ?? [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Gets dev mode scripts from the chunk manifest. */
|
|
23
|
+
export function getDevScripts() {
|
|
24
|
+
return manifest.devScripts;
|
|
25
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Route entry from build-time manifest. */
|
|
2
|
+
export interface RouteManifestEntry {
|
|
3
|
+
pattern: string;
|
|
4
|
+
tag: string;
|
|
5
|
+
chunk?: string;
|
|
6
|
+
styles?: string[];
|
|
7
|
+
type: "client" | "server";
|
|
8
|
+
params: string[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Build-time routes manifest. */
|
|
12
|
+
export interface RoutesManifest {
|
|
13
|
+
routes: RouteManifestEntry[];
|
|
14
|
+
base?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Chunk manifest mapping routes to assets. */
|
|
18
|
+
export interface ChunkManifest {
|
|
19
|
+
chunks: Record<string, string>;
|
|
20
|
+
tags: Record<string, string>;
|
|
21
|
+
styles: Record<string, string[]>;
|
|
22
|
+
devScripts?: string[];
|
|
23
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Module kind based on file naming convention. */
|
|
2
|
+
export type ModuleKind = "server" | "client" | "layout" | "error" | "unknown";
|
|
3
|
+
|
|
4
|
+
/** Parsed path information with validated metadata. */
|
|
5
|
+
export interface ParsedPath {
|
|
6
|
+
original: string;
|
|
7
|
+
normalized: string;
|
|
8
|
+
kind: ModuleKind;
|
|
9
|
+
segments: string[];
|
|
10
|
+
params: string[];
|
|
11
|
+
isIndex: boolean;
|
|
12
|
+
isPrivate: boolean;
|
|
13
|
+
pattern: string;
|
|
14
|
+
tag: string;
|
|
15
|
+
specificity: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Determines module kind from file path. */
|
|
19
|
+
function getModuleKind(filePath: string): ModuleKind {
|
|
20
|
+
if (filePath.includes(".server.")) return "server";
|
|
21
|
+
if (filePath.includes(".client.")) return "client";
|
|
22
|
+
if (filePath.includes("_layout.")) return "layout";
|
|
23
|
+
if (filePath.includes("_error.")) return "error";
|
|
24
|
+
return "unknown";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Parses a file path into structured metadata. */
|
|
28
|
+
export function parsePath(filePath: string): ParsedPath {
|
|
29
|
+
const normalized = filePath.replace(/^\.\//, "").replace(/^.*\/app\//, "");
|
|
30
|
+
const kind = getModuleKind(normalized);
|
|
31
|
+
const isPrivate = normalized.includes("/_") || normalized.startsWith("_");
|
|
32
|
+
|
|
33
|
+
const withoutExt = normalized.replace(/\.(client|server)\.tsx?$/, "").replace(/\.tsx?$/, "");
|
|
34
|
+
|
|
35
|
+
const segments = withoutExt.split("/").filter(Boolean);
|
|
36
|
+
|
|
37
|
+
const params: string[] = [];
|
|
38
|
+
for (const segment of segments) {
|
|
39
|
+
const match = segment.match(/^\$(.+)$/);
|
|
40
|
+
if (match) {
|
|
41
|
+
params.push(match[1]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isIndex = withoutExt === "index" || withoutExt.endsWith("/index") || withoutExt === "";
|
|
46
|
+
|
|
47
|
+
const pattern =
|
|
48
|
+
"/" +
|
|
49
|
+
withoutExt
|
|
50
|
+
.replace(/\/index$/, "")
|
|
51
|
+
.replace(/^index$/, "")
|
|
52
|
+
.replace(/\$([^/]+)/g, ":$1") || "";
|
|
53
|
+
|
|
54
|
+
const tag =
|
|
55
|
+
"sf-" +
|
|
56
|
+
withoutExt
|
|
57
|
+
.replace(/\//g, "-")
|
|
58
|
+
.replace(/\$/g, "")
|
|
59
|
+
.replace(/^index$/, "root")
|
|
60
|
+
.replace(/-index$/, "")
|
|
61
|
+
.toLowerCase() || "sf-root";
|
|
62
|
+
|
|
63
|
+
const staticSegments = segments.filter((s) => !s.startsWith("$")).length;
|
|
64
|
+
const dynamicSegments = segments.filter((s) => s.startsWith("$")).length;
|
|
65
|
+
const specificity =
|
|
66
|
+
staticSegments * 2 + dynamicSegments + (pattern === "/" ? 0 : segments.length);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
original: filePath,
|
|
70
|
+
normalized,
|
|
71
|
+
kind,
|
|
72
|
+
segments,
|
|
73
|
+
params,
|
|
74
|
+
isIndex,
|
|
75
|
+
isPrivate,
|
|
76
|
+
pattern,
|
|
77
|
+
tag,
|
|
78
|
+
specificity,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Finds a paired module path given a client or server path. */
|
|
83
|
+
export function findPairedModulePath(
|
|
84
|
+
path: string,
|
|
85
|
+
modules: { client: Record<string, unknown>; server: Record<string, unknown> },
|
|
86
|
+
) {
|
|
87
|
+
if (path.includes(".client.")) {
|
|
88
|
+
const serverPath = path.replace(".client.", ".server.");
|
|
89
|
+
return serverPath in modules.server ? serverPath : null;
|
|
90
|
+
}
|
|
91
|
+
if (path.includes(".server.")) {
|
|
92
|
+
const clientPath = path.replace(".server.", ".client.");
|
|
93
|
+
return clientPath in modules.client ? clientPath : null;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type VNode, h, Fragment } from "preact";
|
|
2
|
+
|
|
3
|
+
/** Priority levels for content rendering. */
|
|
4
|
+
export type RenderPriority = "critical" | "high" | "normal" | "low" | "idle";
|
|
5
|
+
|
|
6
|
+
/** Creates a deferred rendering boundary. */
|
|
7
|
+
export function Deferred(props: {
|
|
8
|
+
priority?: RenderPriority;
|
|
9
|
+
fallback?: VNode;
|
|
10
|
+
children: VNode;
|
|
11
|
+
}): VNode {
|
|
12
|
+
const { priority = "normal", fallback, children } = props;
|
|
13
|
+
|
|
14
|
+
// On server, we render a placeholder that gets replaced
|
|
15
|
+
if (typeof window === "undefined") {
|
|
16
|
+
const id = `sf-deferred-${Math.random().toString(36).slice(2, 9)}`;
|
|
17
|
+
|
|
18
|
+
return h(Fragment, null, [
|
|
19
|
+
h(
|
|
20
|
+
"sf-deferred",
|
|
21
|
+
{
|
|
22
|
+
id,
|
|
23
|
+
"data-priority": priority,
|
|
24
|
+
style: { display: "contents" },
|
|
25
|
+
},
|
|
26
|
+
fallback ?? h("div", { class: "sf-loading" }),
|
|
27
|
+
),
|
|
28
|
+
h("template", {
|
|
29
|
+
"data-sf-deferred": id,
|
|
30
|
+
dangerouslySetInnerHTML: { __html: `<!--SF: DEFERRED:${id}-->` },
|
|
31
|
+
}),
|
|
32
|
+
]);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return children;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Skeleton loader component. */
|
|
39
|
+
export function Skeleton(props: {
|
|
40
|
+
width?: string;
|
|
41
|
+
height?: string;
|
|
42
|
+
variant?: "text" | "rect" | "circle";
|
|
43
|
+
count?: number;
|
|
44
|
+
}): VNode {
|
|
45
|
+
const { width = "100%", height = "1em", variant = "text", count = 1 } = props;
|
|
46
|
+
|
|
47
|
+
const style = {
|
|
48
|
+
width,
|
|
49
|
+
height,
|
|
50
|
+
backgroundColor: "#e0e0e0",
|
|
51
|
+
borderRadius: variant === "circle" ? "50%" : variant === "text" ? "4px" : "0",
|
|
52
|
+
animation: "sf-skeleton-pulse 1.5s ease-in-out infinite",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const items = Array.from({ length: count }, (_, i) =>
|
|
56
|
+
h("div", { key: i, class: "sf-skeleton", style }),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return h(Fragment, null, items);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Injects skeleton animation CSS. */
|
|
63
|
+
export const SKELETON_CSS = /* css */ `
|
|
64
|
+
@keyframes sf-skeleton-pulse {
|
|
65
|
+
0%, 100% { opacity: 1; }
|
|
66
|
+
50% { opacity: 0.5; }
|
|
67
|
+
}
|
|
68
|
+
. sf-loading { min-height: 100px; }
|
|
69
|
+
`;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/** Cache configuration per route. */
|
|
2
|
+
export interface RouteCacheConfig {
|
|
3
|
+
maxAge: number;
|
|
4
|
+
staleWhileRevalidate?: number;
|
|
5
|
+
keyGenerator?: (request: Request, params: Record<string, string>) => string;
|
|
6
|
+
cacheAuthenticated?: boolean;
|
|
7
|
+
vary?: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Default cache configs by route type. */
|
|
11
|
+
export const DEFAULT_CACHE_CONFIGS: Record<string, RouteCacheConfig> = {
|
|
12
|
+
static: {
|
|
13
|
+
maxAge: 3600,
|
|
14
|
+
staleWhileRevalidate: 86400,
|
|
15
|
+
},
|
|
16
|
+
dynamic: {
|
|
17
|
+
maxAge: 60,
|
|
18
|
+
staleWhileRevalidate: 300,
|
|
19
|
+
},
|
|
20
|
+
private: {
|
|
21
|
+
maxAge: 0,
|
|
22
|
+
cacheAuthenticated: false,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** Generates cache control header value. */
|
|
27
|
+
export function generateCacheControl(config: RouteCacheConfig, isPrivate: boolean) {
|
|
28
|
+
const directives: string[] = [];
|
|
29
|
+
|
|
30
|
+
if (isPrivate || !config.cacheAuthenticated) {
|
|
31
|
+
directives.push("private");
|
|
32
|
+
} else {
|
|
33
|
+
directives.push("public");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (config.maxAge > 0) {
|
|
37
|
+
directives.push(`max-age=${config.maxAge}`);
|
|
38
|
+
} else {
|
|
39
|
+
directives.push("no-cache");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (config.staleWhileRevalidate) {
|
|
43
|
+
directives.push(`stale-while-revalidate=${config.staleWhileRevalidate}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return directives.join(", ");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Response cache with Cloudflare Cache API (or in-memory fallback). */
|
|
50
|
+
export class ResponseCache {
|
|
51
|
+
#cache?: Cache;
|
|
52
|
+
#memory = new Map<string, { response: Response; expires: number }>();
|
|
53
|
+
#maxSize: number;
|
|
54
|
+
|
|
55
|
+
constructor(maxSize = 100) {
|
|
56
|
+
this.#maxSize = maxSize;
|
|
57
|
+
if (typeof caches !== "undefined") {
|
|
58
|
+
this.#cache = (caches as any).default;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async get(key: string) {
|
|
63
|
+
if (this.#cache) {
|
|
64
|
+
const res = await this.#cache.match(this.#toRequest(key));
|
|
65
|
+
return res ?? null;
|
|
66
|
+
}
|
|
67
|
+
const entry = this.#memory.get(key);
|
|
68
|
+
if (!entry) return null;
|
|
69
|
+
if (Date.now() > entry.expires) {
|
|
70
|
+
this.#memory.delete(key);
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return entry.response.clone();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async set(key: string, response: Response, maxAge: number) {
|
|
77
|
+
if (this.#cache) {
|
|
78
|
+
const headers = new Headers(response.headers);
|
|
79
|
+
headers.set("Cache-Control", `public, max-age=${maxAge}`);
|
|
80
|
+
await this.#cache.put(
|
|
81
|
+
this.#toRequest(key),
|
|
82
|
+
new Response(response.clone().body, {
|
|
83
|
+
status: response.status,
|
|
84
|
+
statusText: response.statusText,
|
|
85
|
+
headers,
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
while (this.#memory.size >= this.#maxSize) {
|
|
91
|
+
const firstKey = this.#memory.keys().next().value;
|
|
92
|
+
if (firstKey) this.#memory.delete(firstKey);
|
|
93
|
+
}
|
|
94
|
+
this.#memory.set(key, {
|
|
95
|
+
response: response.clone(),
|
|
96
|
+
expires: Date.now() + maxAge * 1000,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#toRequest(key: string) {
|
|
101
|
+
return new Request(`https://cache.local/${encodeURIComponent(key)}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
static generateKey(request: Request, params: Record<string, string>) {
|
|
105
|
+
const url = new URL(request.url);
|
|
106
|
+
const sortedParams = Object.entries(params)
|
|
107
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
108
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
109
|
+
.join("&");
|
|
110
|
+
return `${url.pathname}?${sortedParams}`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Cache-aware request handler wrapper */
|
|
115
|
+
export async function withCache(
|
|
116
|
+
request: Request,
|
|
117
|
+
params: Record<string, string>,
|
|
118
|
+
config: RouteCacheConfig,
|
|
119
|
+
handler: () => Promise<Response>,
|
|
120
|
+
cache: ResponseCache,
|
|
121
|
+
) {
|
|
122
|
+
const hasAuth = request.headers.has("Authorization") || request.headers.has("Cookie");
|
|
123
|
+
if (hasAuth && !config.cacheAuthenticated) {
|
|
124
|
+
return handler();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const key = config.keyGenerator
|
|
128
|
+
? config.keyGenerator(request, params)
|
|
129
|
+
: ResponseCache.generateKey(request, params);
|
|
130
|
+
|
|
131
|
+
const cached = await cache.get(key);
|
|
132
|
+
if (cached) {
|
|
133
|
+
const headers = new Headers(cached.headers);
|
|
134
|
+
headers.set("X-Cache", "HIT");
|
|
135
|
+
return new Response(cached.body, {
|
|
136
|
+
status: cached.status,
|
|
137
|
+
statusText: cached.statusText,
|
|
138
|
+
headers,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const response = await handler();
|
|
143
|
+
|
|
144
|
+
// Only cache successful responses
|
|
145
|
+
if (response.ok && config.maxAge > 0) {
|
|
146
|
+
await cache.set(key, response, config.maxAge);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Add cache headers
|
|
150
|
+
const headers = new Headers(response.headers);
|
|
151
|
+
headers.set("Cache-Control", generateCacheControl(config, hasAuth));
|
|
152
|
+
headers.set("X-Cache", "MISS");
|
|
153
|
+
|
|
154
|
+
if (config.vary?.length) {
|
|
155
|
+
headers.set("Vary", config.vary.join(", "));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return new Response(response.body, {
|
|
159
|
+
status: response.status,
|
|
160
|
+
statusText: response.statusText,
|
|
161
|
+
headers,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { getDeferredIslandPrefix, getHydrateScriptPrefix } from "./hydration.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Handles deferred hydration script and island insertion during streamed navigation.
|
|
5
|
+
*/
|
|
6
|
+
export function handleDeferredHydrationNode(
|
|
7
|
+
entryTag: string,
|
|
8
|
+
processedScripts: Set<string>,
|
|
9
|
+
node: Element,
|
|
10
|
+
) {
|
|
11
|
+
const hydratePrefix = getHydrateScriptPrefix(entryTag);
|
|
12
|
+
const islandPrefix = getDeferredIslandPrefix(entryTag);
|
|
13
|
+
|
|
14
|
+
const processHydrationScript = (script: HTMLScriptElement) => {
|
|
15
|
+
const scriptId = script.id;
|
|
16
|
+
if (!scriptId || !scriptId.startsWith(hydratePrefix)) return;
|
|
17
|
+
if (processedScripts.has(scriptId)) return;
|
|
18
|
+
processedScripts.add(scriptId);
|
|
19
|
+
|
|
20
|
+
// Parse the hydration detail from the script content
|
|
21
|
+
// Format: detail:{"tag":"sf-root","id":"sf-root-deferred-defer-xxx"}
|
|
22
|
+
const scriptContent = script.textContent;
|
|
23
|
+
if (scriptContent) {
|
|
24
|
+
const match = scriptContent.match(/detail:(\{[^}]+\})/);
|
|
25
|
+
if (match) {
|
|
26
|
+
try {
|
|
27
|
+
const detail = JSON.parse(match[1]) as { tag: string; id: string };
|
|
28
|
+
document.dispatchEvent(new CustomEvent("sf:queue-hydrate", { detail }));
|
|
29
|
+
return;
|
|
30
|
+
} catch {
|
|
31
|
+
// Fallback: script will execute naturally when inserted
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const processDataIsland = (script: HTMLScriptElement) => {
|
|
38
|
+
const id = script.getAttribute("data-island");
|
|
39
|
+
if (!id || !id.startsWith(islandPrefix)) return;
|
|
40
|
+
const islandKey = `island:${id}`;
|
|
41
|
+
if (processedScripts.has(islandKey)) return;
|
|
42
|
+
processedScripts.add(islandKey);
|
|
43
|
+
document.dispatchEvent(
|
|
44
|
+
new CustomEvent("sf:queue-hydrate", {
|
|
45
|
+
detail: { tag: entryTag, id },
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
if (node.tagName === "SCRIPT") {
|
|
51
|
+
const script = node as HTMLScriptElement;
|
|
52
|
+
processHydrationScript(script);
|
|
53
|
+
processDataIsland(script);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Also scan descendants in case a container node was inserted
|
|
58
|
+
const scripts = node.querySelectorAll("script");
|
|
59
|
+
for (const script of scripts) {
|
|
60
|
+
processHydrationScript(script as HTMLScriptElement);
|
|
61
|
+
processDataIsland(script as HTMLScriptElement);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function dedupeDeferredScripts(entryTag: string) {
|
|
66
|
+
const islandPrefix = getDeferredIslandPrefix(entryTag);
|
|
67
|
+
const hydratePrefix = getHydrateScriptPrefix(entryTag);
|
|
68
|
+
const scripts = document.querySelectorAll(
|
|
69
|
+
`script[data-island^="${islandPrefix}"], script[id^="${hydratePrefix}"]`,
|
|
70
|
+
);
|
|
71
|
+
const seen = new Map<string, HTMLScriptElement>();
|
|
72
|
+
|
|
73
|
+
for (const script of scripts) {
|
|
74
|
+
const dataIsland = script.getAttribute("data-island");
|
|
75
|
+
const key = dataIsland ? `island:${dataIsland}` : script.id ? `hydrate:${script.id}` : null;
|
|
76
|
+
|
|
77
|
+
if (!key) continue;
|
|
78
|
+
|
|
79
|
+
const previous = seen.get(key);
|
|
80
|
+
if (previous) {
|
|
81
|
+
previous.remove();
|
|
82
|
+
}
|
|
83
|
+
seen.set(key, script as HTMLScriptElement);
|
|
84
|
+
}
|
|
85
|
+
}
|