bosia 0.4.6 → 0.5.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/package.json +1 -1
- package/src/core/client/App.svelte +121 -5
- package/src/core/client/appState.svelte.ts +24 -37
- package/src/core/client/enhance.ts +6 -2
- package/src/core/client/hydrate.ts +51 -3
- package/src/core/client/loaderCache.ts +127 -0
- package/src/core/client/navigation.ts +59 -0
- package/src/core/client/prefetch.ts +48 -3
- package/src/core/hooks.ts +27 -0
- package/src/core/html.ts +49 -8
- package/src/core/renderer.ts +235 -28
- package/src/core/routeFile.ts +26 -0
- package/src/core/server.ts +52 -4
- package/src/lib/client.ts +1 -0
- package/src/lib/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
|
|
6
6
|
"keywords": [
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { findMatch } from "../matcher.ts";
|
|
4
4
|
import { clientRoutes } from "bosia:routes";
|
|
5
5
|
import { consumePrefetch, prefetchCache, dataUrl } from "./prefetch.ts";
|
|
6
|
-
import { appState } from "./appState.svelte.ts";
|
|
6
|
+
import { appState, clearDirty } from "./appState.svelte.ts";
|
|
7
|
+
import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
|
|
7
8
|
import { pickErrorPage } from "../errorMatch.ts";
|
|
8
9
|
|
|
9
10
|
let {
|
|
@@ -49,6 +50,10 @@
|
|
|
49
50
|
$effect(() => {
|
|
50
51
|
if (ssrMode) return;
|
|
51
52
|
|
|
53
|
+
// Subscribe to `invalidationTick` so `invalidate()` can wake the effect
|
|
54
|
+
// without a URL change.
|
|
55
|
+
void appState.invalidationTick;
|
|
56
|
+
|
|
52
57
|
const path = router.currentRoute;
|
|
53
58
|
const pathname = path.split("?")[0].split("#")[0];
|
|
54
59
|
const match = findMatch(clientRoutes, pathname);
|
|
@@ -68,6 +73,43 @@
|
|
|
68
73
|
navDone = false;
|
|
69
74
|
navigating = true;
|
|
70
75
|
|
|
76
|
+
// ─── Loader cache: decide which loaders need to re-run ─────────────
|
|
77
|
+
// For each layout depth + the page, compare the cached entry (if any)
|
|
78
|
+
// against the live URL/params and the dirty set. If everything is
|
|
79
|
+
// cacheable, skip the fetch entirely.
|
|
80
|
+
const url = new URL(path, window.location.origin);
|
|
81
|
+
const ctx = liveContext(pathname, match.params, url);
|
|
82
|
+
const layoutIds = (match.route as any).layoutIds as (string | null)[];
|
|
83
|
+
const pageId = (match.route as any).pageId as string | null;
|
|
84
|
+
|
|
85
|
+
const layoutRunFlags: boolean[] = layoutIds.map((id) => {
|
|
86
|
+
if (id === null) return false; // no server loader at this depth
|
|
87
|
+
const entry = appState.loaderCache.layouts[id];
|
|
88
|
+
if (!entry) return true; // never loaded → must run
|
|
89
|
+
return shouldRerun(entry, appState.dirty, ctx);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let pageRun = false;
|
|
93
|
+
if (pageId !== null) {
|
|
94
|
+
const entry = appState.loaderCache.page;
|
|
95
|
+
if (!entry || entry.nodeId !== pageId) {
|
|
96
|
+
pageRun = true;
|
|
97
|
+
} else {
|
|
98
|
+
pageRun = shouldRerun(entry, appState.dirty, ctx);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Build `_invalidated=<bits>` — char 0 = page, char i+1 = layouts[i].
|
|
103
|
+
// '1' = run, '0' = skip. Always sent so the server honors cached layers.
|
|
104
|
+
// We always issue the fetch (even when all loaders skip) so page-level
|
|
105
|
+
// metadata stays fresh on every navigation; only the loaders flagged in
|
|
106
|
+
// the mask actually run server-side.
|
|
107
|
+
const maskBits =
|
|
108
|
+
(pageRun ? "1" : "0") + layoutRunFlags.map((b) => (b ? "1" : "0")).join("");
|
|
109
|
+
|
|
110
|
+
// Clear dirty set now — we've baked it into the mask.
|
|
111
|
+
clearDirty();
|
|
112
|
+
|
|
71
113
|
// Load components + data in parallel, then update state atomically
|
|
72
114
|
// to avoid a flash of stale/empty data before the fetch completes.
|
|
73
115
|
const cached = match.route.hasServerData ? consumePrefetch(path) : null;
|
|
@@ -75,7 +117,7 @@
|
|
|
75
117
|
const dataFetch = cached
|
|
76
118
|
? Promise.resolve(cached)
|
|
77
119
|
: match.route.hasServerData
|
|
78
|
-
? fetch(dataUrl(path))
|
|
120
|
+
? fetch(dataUrl(path, maskBits))
|
|
79
121
|
.then((r) => r.json())
|
|
80
122
|
.catch(() => null)
|
|
81
123
|
: Promise.resolve(null);
|
|
@@ -143,9 +185,83 @@
|
|
|
143
185
|
}
|
|
144
186
|
PageComponent = pageMod.default;
|
|
145
187
|
layoutComponents = layoutMods.map((m: any) => m.default);
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
188
|
+
|
|
189
|
+
// Merge sparse server response with the existing client cache.
|
|
190
|
+
// Slots the server returned null for were intentionally skipped — we
|
|
191
|
+
// must pull their data from the cache to keep rendering correct.
|
|
192
|
+
const respLayoutData: (Record<string, any> | null)[] = result?.layoutData ?? [];
|
|
193
|
+
const respLayoutDeps: (any | null)[] = result?.layoutDeps ?? [];
|
|
194
|
+
const respPageData: Record<string, any> | null | undefined = result?.pageData;
|
|
195
|
+
const respPageDeps: any = result?.pageDeps ?? null;
|
|
196
|
+
|
|
197
|
+
const mergedLayoutData: Record<string, any>[] = [];
|
|
198
|
+
for (let i = 0; i < layoutIds.length; i++) {
|
|
199
|
+
const id = layoutIds[i];
|
|
200
|
+
if (id === null) {
|
|
201
|
+
mergedLayoutData.push({});
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (respLayoutData[i] !== null && respLayoutData[i] !== undefined) {
|
|
205
|
+
const entry: CacheEntry = {
|
|
206
|
+
nodeId: id,
|
|
207
|
+
data: respLayoutData[i] as Record<string, any>,
|
|
208
|
+
deps: respLayoutDeps[i] ?? {
|
|
209
|
+
keys: [],
|
|
210
|
+
urls: [],
|
|
211
|
+
params: [],
|
|
212
|
+
searchParams: [],
|
|
213
|
+
cookies: [],
|
|
214
|
+
uses_url: false,
|
|
215
|
+
},
|
|
216
|
+
snapshot: captureSnapshot(
|
|
217
|
+
respLayoutDeps[i] ?? {
|
|
218
|
+
keys: [],
|
|
219
|
+
urls: [],
|
|
220
|
+
params: [],
|
|
221
|
+
searchParams: [],
|
|
222
|
+
cookies: [],
|
|
223
|
+
uses_url: false,
|
|
224
|
+
},
|
|
225
|
+
ctx,
|
|
226
|
+
),
|
|
227
|
+
};
|
|
228
|
+
appState.loaderCache.layouts[id] = entry;
|
|
229
|
+
mergedLayoutData.push(entry.data);
|
|
230
|
+
} else {
|
|
231
|
+
const cachedEntry = appState.loaderCache.layouts[id];
|
|
232
|
+
mergedLayoutData.push(cachedEntry?.data ?? {});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let mergedPageData: Record<string, any>;
|
|
237
|
+
if (respPageData !== null && respPageData !== undefined) {
|
|
238
|
+
const deps = respPageDeps ?? {
|
|
239
|
+
keys: [],
|
|
240
|
+
urls: [],
|
|
241
|
+
params: [],
|
|
242
|
+
searchParams: [],
|
|
243
|
+
cookies: [],
|
|
244
|
+
uses_url: false,
|
|
245
|
+
};
|
|
246
|
+
const entry: CacheEntry = {
|
|
247
|
+
nodeId: pageId ?? "",
|
|
248
|
+
data: respPageData,
|
|
249
|
+
deps,
|
|
250
|
+
snapshot: captureSnapshot(deps, ctx),
|
|
251
|
+
};
|
|
252
|
+
if (pageId !== null) appState.loaderCache.page = entry;
|
|
253
|
+
mergedPageData = respPageData;
|
|
254
|
+
} else if (appState.loaderCache.page && appState.loaderCache.page.nodeId === pageId) {
|
|
255
|
+
mergedPageData = appState.loaderCache.page.data;
|
|
256
|
+
} else {
|
|
257
|
+
mergedPageData = {};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Always overlay current match.params — cached pageData carries the
|
|
261
|
+
// stale params from when the loader ran, so trust the live match.
|
|
262
|
+
appState.pageData = { ...mergedPageData, params: match.params };
|
|
263
|
+
appState.layoutData = mergedLayoutData;
|
|
264
|
+
appState.routeParams = match.params;
|
|
149
265
|
// Successful navigation — clear any prior error state.
|
|
150
266
|
appState.errorComponent = null;
|
|
151
267
|
appState.errorProps = null;
|
|
@@ -7,8 +7,7 @@
|
|
|
7
7
|
// `ssrMode` and reads from `ssrXxx` props directly during SSR,
|
|
8
8
|
// so concurrent requests don't share these cells.
|
|
9
9
|
|
|
10
|
-
import {
|
|
11
|
-
import { router } from "./router.svelte.ts";
|
|
10
|
+
import type { CacheEntry, DirtyState } from "./loaderCache.ts";
|
|
12
11
|
|
|
13
12
|
class AppState {
|
|
14
13
|
pageData = $state<Record<string, any>>({});
|
|
@@ -21,43 +20,31 @@ class AppState {
|
|
|
21
20
|
errorComponent = $state<any>(null);
|
|
22
21
|
errorProps = $state<{ error: { status: number; message: string } } | null>(null);
|
|
23
22
|
errorDepth = $state<number | null>(null);
|
|
23
|
+
// Loader cache — keyed by stable id (codegen-emitted server file path).
|
|
24
|
+
// Mirrors the most recent successful run of each layout/page server loader.
|
|
25
|
+
// Wiped on hard refresh, never persisted.
|
|
26
|
+
loaderCache: { page: CacheEntry | null; layouts: Record<string, CacheEntry> } = {
|
|
27
|
+
page: null,
|
|
28
|
+
layouts: {},
|
|
29
|
+
};
|
|
30
|
+
// Pending invalidations consumed by the next data fetch. Mutated by
|
|
31
|
+
// `invalidate()` / `invalidateAll()` and cleared after the request fires.
|
|
32
|
+
dirty: DirtyState = {
|
|
33
|
+
all: false,
|
|
34
|
+
keys: new Set<string>(),
|
|
35
|
+
urls: new Set<string>(),
|
|
36
|
+
urlMatchers: [],
|
|
37
|
+
};
|
|
38
|
+
// Bumped by `invalidate*()` to wake the App.svelte nav effect, so calling
|
|
39
|
+
// `invalidate("k")` re-runs the loader pipeline without changing the URL.
|
|
40
|
+
invalidationTick = $state(0);
|
|
24
41
|
}
|
|
25
42
|
|
|
26
43
|
export const appState = new AppState();
|
|
27
44
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
export async function refreshData(path: string): Promise<void> {
|
|
34
|
-
try {
|
|
35
|
-
const res = await fetch(dataUrl(path));
|
|
36
|
-
if (!res.ok) return;
|
|
37
|
-
const result = await res.json();
|
|
38
|
-
if (result?.redirect) {
|
|
39
|
-
router.navigate(result.redirect);
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
if (result?.error) return;
|
|
43
|
-
appState.pageData = result?.pageData ?? {};
|
|
44
|
-
appState.layoutData = result?.layoutData ?? [];
|
|
45
|
-
appState.routeParams = result?.pageData?.params ?? appState.routeParams;
|
|
46
|
-
if (result?.metadata) {
|
|
47
|
-
if (result.metadata.title) document.title = result.metadata.title;
|
|
48
|
-
if (result.metadata.description) {
|
|
49
|
-
let meta = document.querySelector(
|
|
50
|
-
'meta[name="description"]',
|
|
51
|
-
) as HTMLMetaElement | null;
|
|
52
|
-
if (!meta) {
|
|
53
|
-
meta = document.createElement("meta");
|
|
54
|
-
meta.name = "description";
|
|
55
|
-
document.head.appendChild(meta);
|
|
56
|
-
}
|
|
57
|
-
meta.content = result.metadata.description;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
} catch {
|
|
61
|
-
// best-effort — silently swallow
|
|
62
|
-
}
|
|
45
|
+
export function clearDirty(): void {
|
|
46
|
+
appState.dirty.all = false;
|
|
47
|
+
appState.dirty.keys.clear();
|
|
48
|
+
appState.dirty.urls.clear();
|
|
49
|
+
appState.dirty.urlMatchers = [];
|
|
63
50
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// full page reload. Falls back to native form submission when JS is
|
|
6
6
|
// disabled because nothing is wired up until this action runs.
|
|
7
7
|
|
|
8
|
-
import { appState
|
|
8
|
+
import { appState } from "./appState.svelte.ts";
|
|
9
9
|
import { router } from "./router.svelte.ts";
|
|
10
10
|
|
|
11
11
|
export type ActionResult =
|
|
@@ -53,7 +53,11 @@ async function applyResult(
|
|
|
53
53
|
appState.form = result.data;
|
|
54
54
|
if (reset) form.reset();
|
|
55
55
|
if (invalidateAll) {
|
|
56
|
-
|
|
56
|
+
// Form actions invalidate the page loader only by default — layouts
|
|
57
|
+
// stay cached. Loaders that need to react to a mutation should call
|
|
58
|
+
// `invalidate("app:key")` from the action's submit handler.
|
|
59
|
+
appState.loaderCache.page = null;
|
|
60
|
+
appState.invalidationTick = appState.invalidationTick + 1;
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
|
|
@@ -5,10 +5,22 @@ import { initPrefetch } from "./prefetch.ts";
|
|
|
5
5
|
import { findMatch, compileRoutes, canonicalPathname } from "../matcher.ts";
|
|
6
6
|
import { clientRoutes } from "bosia:routes";
|
|
7
7
|
import { appState } from "./appState.svelte.ts";
|
|
8
|
+
import { captureSnapshot, liveContext, type CacheEntry } from "./loaderCache.ts";
|
|
9
|
+
import type { LoaderDeps } from "../hooks.ts";
|
|
8
10
|
|
|
9
11
|
// Pre-compile route patterns into RegExp at startup (shared by App.svelte and router via module reference)
|
|
10
12
|
compileRoutes(clientRoutes);
|
|
11
13
|
|
|
14
|
+
function readJsonScript<T>(id: string): T | null {
|
|
15
|
+
const el = document.getElementById(id);
|
|
16
|
+
if (!el) return null;
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(el.textContent ?? "null") as T;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
12
24
|
// ─── Hydration ────────────────────────────────────────────
|
|
13
25
|
|
|
14
26
|
async function main() {
|
|
@@ -49,9 +61,9 @@ async function main() {
|
|
|
49
61
|
router.params = match.params;
|
|
50
62
|
}
|
|
51
63
|
|
|
52
|
-
const ssrPageData =
|
|
53
|
-
const ssrLayoutData =
|
|
54
|
-
const ssrFormData =
|
|
64
|
+
const ssrPageData = readJsonScript<Record<string, any>>("__bosia-page-data__") ?? {};
|
|
65
|
+
const ssrLayoutData = readJsonScript<Record<string, any>[]>("__bosia-layout-data__") ?? [];
|
|
66
|
+
const ssrFormData = readJsonScript<Record<string, any>>("__bosia-form-data__");
|
|
55
67
|
|
|
56
68
|
// Seed shared client state so `use:enhance` and other helpers
|
|
57
69
|
// start from the same values App.svelte renders during hydration.
|
|
@@ -60,6 +72,42 @@ async function main() {
|
|
|
60
72
|
appState.routeParams = ssrPageData?.params ?? match?.params ?? {};
|
|
61
73
|
appState.form = ssrFormData;
|
|
62
74
|
|
|
75
|
+
// Seed the loader cache from window globals emitted server-side so the
|
|
76
|
+
// next client navigation can decide which loaders to skip without an
|
|
77
|
+
// extra fetch round-trip.
|
|
78
|
+
if (match) {
|
|
79
|
+
const url = new URL(window.location.href);
|
|
80
|
+
const ctx = liveContext(window.location.pathname, match.params, url);
|
|
81
|
+
const ssrPageDeps: LoaderDeps | null = (window as any).__BOSIA_PAGE_DEPS__ ?? null;
|
|
82
|
+
const ssrLayoutDeps: (LoaderDeps | null)[] = (window as any).__BOSIA_LAYOUT_DEPS__ ?? [];
|
|
83
|
+
const pageId = (match.route as any).pageId as string | null;
|
|
84
|
+
const layoutIds = (match.route as any).layoutIds as (string | null)[];
|
|
85
|
+
|
|
86
|
+
if (pageId !== null && ssrPageDeps && ssrPageData) {
|
|
87
|
+
const entry: CacheEntry = {
|
|
88
|
+
nodeId: pageId,
|
|
89
|
+
data: ssrPageData,
|
|
90
|
+
deps: ssrPageDeps,
|
|
91
|
+
snapshot: captureSnapshot(ssrPageDeps, ctx),
|
|
92
|
+
};
|
|
93
|
+
appState.loaderCache.page = entry;
|
|
94
|
+
}
|
|
95
|
+
for (let i = 0; i < layoutIds.length; i++) {
|
|
96
|
+
const id = layoutIds[i];
|
|
97
|
+
if (id === null) continue;
|
|
98
|
+
const deps = ssrLayoutDeps[i];
|
|
99
|
+
const data = ssrLayoutData[i];
|
|
100
|
+
if (!deps || !data) continue;
|
|
101
|
+
const entry: CacheEntry = {
|
|
102
|
+
nodeId: id,
|
|
103
|
+
data,
|
|
104
|
+
deps,
|
|
105
|
+
snapshot: captureSnapshot(deps, ctx),
|
|
106
|
+
};
|
|
107
|
+
appState.loaderCache.layouts[id] = entry;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
63
111
|
const target = document.getElementById("app")!;
|
|
64
112
|
const props = {
|
|
65
113
|
ssrMode: false,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// ─── Client-Side Loader Cache ─────────────────────────────
|
|
2
|
+
// Stores the result of each server loader run by stable id (the
|
|
3
|
+
// +page.server.ts / +layout.server.ts path emitted by codegen) along
|
|
4
|
+
// with the LoaderDeps record captured server-side. On each client
|
|
5
|
+
// navigation we use the cache + a "dirty" set populated by the
|
|
6
|
+
// `invalidate()` API to decide which loaders need to re-run, sending
|
|
7
|
+
// the decision as an `_invalidated=<bits>` query param so the server
|
|
8
|
+
// can skip the loaders that haven't conceptually changed.
|
|
9
|
+
//
|
|
10
|
+
// Cache lives in browser memory only — wiped on hard refresh.
|
|
11
|
+
|
|
12
|
+
import type { LoaderDeps } from "../hooks.ts";
|
|
13
|
+
|
|
14
|
+
export type CacheSnapshot = {
|
|
15
|
+
pathname: string;
|
|
16
|
+
params: Record<string, string>;
|
|
17
|
+
searchParams: Record<string, string | null>;
|
|
18
|
+
cookies: Record<string, string | undefined>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type CacheEntry = {
|
|
22
|
+
nodeId: string;
|
|
23
|
+
data: Record<string, any>;
|
|
24
|
+
deps: LoaderDeps;
|
|
25
|
+
snapshot: CacheSnapshot;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type EvalContext = {
|
|
29
|
+
pathname: string;
|
|
30
|
+
params: Record<string, string>;
|
|
31
|
+
url: URL;
|
|
32
|
+
cookies: Record<string, string | undefined>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function readDocumentCookies(): Record<string, string> {
|
|
36
|
+
const out: Record<string, string> = {};
|
|
37
|
+
if (typeof document === "undefined") return out;
|
|
38
|
+
for (const pair of document.cookie.split(";")) {
|
|
39
|
+
const idx = pair.indexOf("=");
|
|
40
|
+
if (idx === -1) continue;
|
|
41
|
+
const name = pair.slice(0, idx).trim();
|
|
42
|
+
const value = pair.slice(idx + 1).trim();
|
|
43
|
+
if (name) {
|
|
44
|
+
try {
|
|
45
|
+
out[name] = decodeURIComponent(value);
|
|
46
|
+
} catch {
|
|
47
|
+
out[name] = value;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function liveContext(
|
|
55
|
+
pathname: string,
|
|
56
|
+
params: Record<string, string>,
|
|
57
|
+
url: URL,
|
|
58
|
+
): EvalContext {
|
|
59
|
+
return {
|
|
60
|
+
pathname,
|
|
61
|
+
params,
|
|
62
|
+
url,
|
|
63
|
+
cookies: readDocumentCookies(),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Capture only the values relevant to a loader's tracked deps. */
|
|
68
|
+
export function captureSnapshot(deps: LoaderDeps, ctx: EvalContext): CacheSnapshot {
|
|
69
|
+
const params: Record<string, string> = {};
|
|
70
|
+
for (const k of deps.params) params[k] = ctx.params[k] ?? "";
|
|
71
|
+
const searchParams: Record<string, string | null> = {};
|
|
72
|
+
for (const k of deps.searchParams) searchParams[k] = ctx.url.searchParams.get(k);
|
|
73
|
+
const cookies: Record<string, string | undefined> = {};
|
|
74
|
+
for (const k of deps.cookies) cookies[k] = ctx.cookies[k];
|
|
75
|
+
return { pathname: ctx.pathname, params, searchParams, cookies };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type DirtyState = {
|
|
79
|
+
all: boolean;
|
|
80
|
+
keys: Set<string>;
|
|
81
|
+
urls: Set<string>;
|
|
82
|
+
urlMatchers: Array<(u: URL) => boolean>;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function shouldRerun(entry: CacheEntry, dirty: DirtyState, next: EvalContext): boolean {
|
|
86
|
+
if (dirty.all) return true;
|
|
87
|
+
const { deps, snapshot } = entry;
|
|
88
|
+
// Dirty keys
|
|
89
|
+
for (const k of deps.keys) {
|
|
90
|
+
if (dirty.keys.has(k)) return true;
|
|
91
|
+
}
|
|
92
|
+
// Dirty URLs (string match or predicate match)
|
|
93
|
+
if (deps.urls.length > 0) {
|
|
94
|
+
for (const u of deps.urls) {
|
|
95
|
+
if (dirty.urls.has(u)) return true;
|
|
96
|
+
for (const fn of dirty.urlMatchers) {
|
|
97
|
+
try {
|
|
98
|
+
if (fn(new URL(u))) return true;
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore malformed URL deps
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Param value changes
|
|
106
|
+
for (const k of deps.params) {
|
|
107
|
+
if (snapshot.params[k] !== (next.params[k] ?? "")) return true;
|
|
108
|
+
}
|
|
109
|
+
// Search param value changes
|
|
110
|
+
for (const k of deps.searchParams) {
|
|
111
|
+
if (snapshot.searchParams[k] !== next.url.searchParams.get(k)) return true;
|
|
112
|
+
}
|
|
113
|
+
// Cookie value changes
|
|
114
|
+
for (const k of deps.cookies) {
|
|
115
|
+
if (k === "*") {
|
|
116
|
+
// Broad cookie read — re-run on any cookie change. Detect by
|
|
117
|
+
// comparing the full incoming jar against the captured slice.
|
|
118
|
+
// In practice an empty captured slice will never equal the live
|
|
119
|
+
// jar once any cookie exists, so we re-run whenever cookies do.
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
if (snapshot.cookies[k] !== next.cookies[k]) return true;
|
|
123
|
+
}
|
|
124
|
+
// Pathname change for loaders that read the URL
|
|
125
|
+
if (deps.uses_url && snapshot.pathname !== next.pathname) return true;
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// ─── Public Invalidation API ──────────────────────────────
|
|
2
|
+
// Counterpart to SvelteKit's `invalidate()` / `invalidateAll()`. Marks
|
|
3
|
+
// loader cache entries dirty and triggers the App.svelte nav effect to
|
|
4
|
+
// re-run only the loaders whose dependencies were invalidated.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// import { invalidate, invalidateAll } from "bosia/client";
|
|
8
|
+
// await invalidate("app:user");
|
|
9
|
+
// await invalidate("/api/posts");
|
|
10
|
+
// await invalidate((url) => url.pathname.startsWith("/api/"));
|
|
11
|
+
// await invalidateAll();
|
|
12
|
+
|
|
13
|
+
import { appState } from "./appState.svelte.ts";
|
|
14
|
+
|
|
15
|
+
type InvalidateTarget = string | URL | ((url: URL) => boolean);
|
|
16
|
+
|
|
17
|
+
function bumpTick() {
|
|
18
|
+
appState.invalidationTick = appState.invalidationTick + 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Mark a dependency as invalid; the next navigation (and any in-progress
|
|
23
|
+
* navigation that hasn't started its fetch yet) will re-run loaders that
|
|
24
|
+
* declared a matching `depends()` key, fetched a matching URL, or — when
|
|
25
|
+
* given a predicate — fetched any URL the predicate returns true for.
|
|
26
|
+
*
|
|
27
|
+
* Returns a promise that resolves after the nav effect has flushed.
|
|
28
|
+
*/
|
|
29
|
+
export function invalidate(target: InvalidateTarget): Promise<void> {
|
|
30
|
+
if (typeof target === "function") {
|
|
31
|
+
appState.dirty.urlMatchers.push(target);
|
|
32
|
+
} else {
|
|
33
|
+
const str = typeof target === "string" ? target : target.href;
|
|
34
|
+
const isUrl = str.startsWith("/") || str.includes("://");
|
|
35
|
+
if (isUrl) {
|
|
36
|
+
try {
|
|
37
|
+
// Normalize to an absolute URL — fetches the loader recorded are
|
|
38
|
+
// always absolute, so a relative `/api/foo` must be promoted here.
|
|
39
|
+
const abs = new URL(str, window.location.origin).href;
|
|
40
|
+
appState.dirty.urls.add(abs);
|
|
41
|
+
} catch {
|
|
42
|
+
appState.dirty.urls.add(str);
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
appState.dirty.keys.add(str);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
bumpTick();
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mark every loader as invalid. Next navigation re-runs all of them.
|
|
54
|
+
*/
|
|
55
|
+
export function invalidateAll(): Promise<void> {
|
|
56
|
+
appState.dirty.all = true;
|
|
57
|
+
bumpTick();
|
|
58
|
+
return Promise.resolve();
|
|
59
|
+
}
|
|
@@ -2,11 +2,52 @@
|
|
|
2
2
|
// Supports `data-bosia-preload="hover"` and `data-bosia-preload="viewport"`
|
|
3
3
|
// on <a> elements or their ancestors.
|
|
4
4
|
|
|
5
|
+
import { findMatch } from "../matcher.ts";
|
|
6
|
+
import { clientRoutes } from "bosia:routes";
|
|
7
|
+
import { appState } from "./appState.svelte.ts";
|
|
8
|
+
import { liveContext, shouldRerun } from "./loaderCache.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Build the `_invalidated` mask bits for a target path using the current
|
|
12
|
+
* client loader cache. Char 0 = page, char i+1 = layout depth i; '1' = run,
|
|
13
|
+
* '0' = skip. Returns `null` when the route cannot be matched.
|
|
14
|
+
*/
|
|
15
|
+
export function buildMaskBits(path: string): string | null {
|
|
16
|
+
const url = new URL(path, window.location.origin);
|
|
17
|
+
const pathname = url.pathname;
|
|
18
|
+
const match = findMatch(clientRoutes, pathname);
|
|
19
|
+
if (!match) return null;
|
|
20
|
+
const ctx = liveContext(pathname, match.params, url);
|
|
21
|
+
const layoutIds = (match.route as any).layoutIds as (string | null)[];
|
|
22
|
+
const pageId = (match.route as any).pageId as string | null;
|
|
23
|
+
|
|
24
|
+
const layoutRunFlags = layoutIds.map((id) => {
|
|
25
|
+
if (id === null) return false;
|
|
26
|
+
const entry = appState.loaderCache.layouts[id];
|
|
27
|
+
if (!entry) return true;
|
|
28
|
+
return shouldRerun(entry, appState.dirty, ctx);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
let pageRun = false;
|
|
32
|
+
if (pageId !== null) {
|
|
33
|
+
const entry = appState.loaderCache.page;
|
|
34
|
+
if (!entry || entry.nodeId !== pageId) pageRun = true;
|
|
35
|
+
else pageRun = shouldRerun(entry, appState.dirty, ctx);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return (pageRun ? "1" : "0") + layoutRunFlags.map((b) => (b ? "1" : "0")).join("");
|
|
39
|
+
}
|
|
40
|
+
|
|
5
41
|
/** Builds the `/__bosia/data/…` URL for a given client path. */
|
|
6
|
-
export function dataUrl(path: string): string {
|
|
42
|
+
export function dataUrl(path: string, invalidatedBits?: string): string {
|
|
7
43
|
const url = new URL(path, window.location.origin);
|
|
8
44
|
let p = url.pathname.replace(/\/$/, "");
|
|
9
|
-
|
|
45
|
+
let qs = url.search;
|
|
46
|
+
if (invalidatedBits) {
|
|
47
|
+
const sep = qs ? "&" : "?";
|
|
48
|
+
qs = `${qs}${sep}_invalidated=${invalidatedBits}`;
|
|
49
|
+
}
|
|
50
|
+
return `/__bosia/data${p || "/index"}.json${qs}`;
|
|
10
51
|
}
|
|
11
52
|
|
|
12
53
|
export const prefetchCache = new Map<string, { data: any; ts: number }>();
|
|
@@ -33,7 +74,11 @@ export async function prefetchPath(path: string): Promise<void> {
|
|
|
33
74
|
|
|
34
75
|
pending.add(path);
|
|
35
76
|
try {
|
|
36
|
-
|
|
77
|
+
// Send the same mask as a real client nav would so the server can skip
|
|
78
|
+
// loaders whose tracked inputs haven't changed. Falls back to running
|
|
79
|
+
// everything when the route can't be matched (e.g. external/unknown URL).
|
|
80
|
+
const maskBits = buildMaskBits(path) ?? undefined;
|
|
81
|
+
const res = await fetch(dataUrl(path, maskBits));
|
|
37
82
|
if (res.ok) {
|
|
38
83
|
if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
|
|
39
84
|
const oldest = prefetchCache.keys().next().value;
|
package/src/core/hooks.ts
CHANGED
|
@@ -56,6 +56,33 @@ export type LoadEvent = {
|
|
|
56
56
|
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
57
57
|
parent: () => Promise<Record<string, any>>;
|
|
58
58
|
metadata: Record<string, any> | null;
|
|
59
|
+
/**
|
|
60
|
+
* Declare custom dependency keys for this loader. The client cache
|
|
61
|
+
* will re-run the loader when `invalidate(key)` is called with any
|
|
62
|
+
* of these keys. Keys are arbitrary strings, but conventionally
|
|
63
|
+
* namespaced (e.g. `"app:user"`).
|
|
64
|
+
*/
|
|
65
|
+
depends: (...keys: string[]) => void;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Tracked dependencies captured for a single loader during one run.
|
|
70
|
+
* Shipped to the client so subsequent client-side navigations can
|
|
71
|
+
* decide whether to re-run the loader.
|
|
72
|
+
*/
|
|
73
|
+
export type LoaderDeps = {
|
|
74
|
+
/** `depends(...keys)` declarations. */
|
|
75
|
+
keys: string[];
|
|
76
|
+
/** Absolute URLs passed to the loader's `fetch()`. */
|
|
77
|
+
urls: string[];
|
|
78
|
+
/** Route params the loader read (`params.X`). */
|
|
79
|
+
params: string[];
|
|
80
|
+
/** Search params the loader read (`url.searchParams.get(X)` / `.has(X)`). */
|
|
81
|
+
searchParams: string[];
|
|
82
|
+
/** Cookies the loader read (`cookies.get(X)`). */
|
|
83
|
+
cookies: string[];
|
|
84
|
+
/** True if the loader read `url.pathname`/`url.origin`/`url.hash`/`url.href`. */
|
|
85
|
+
uses_url: boolean;
|
|
59
86
|
};
|
|
60
87
|
|
|
61
88
|
export type ResolveFunction = (event: RequestEvent) => MaybePromise<Response>;
|
package/src/core/html.ts
CHANGED
|
@@ -37,6 +37,22 @@ export function safeJsonStringify(data: unknown): string {
|
|
|
37
37
|
return json.replace(/[<>&\u2028\u2029]/g, (c) => map[c]);
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
const SCRIPT_HAZARD_RE = /<(\/script|!--)/gi;
|
|
41
|
+
|
|
42
|
+
/** Escapes JSON for safe embedding inside <script type="application/json"> blocks.
|
|
43
|
+
* Blocks premature </script> and <!-- (HTML script-data escape state) without
|
|
44
|
+
* the JS-context overhead of safeJsonStringify. */
|
|
45
|
+
export function safeJsonForScript(data: unknown): string {
|
|
46
|
+
let json: string;
|
|
47
|
+
try {
|
|
48
|
+
json = JSON.stringify(data);
|
|
49
|
+
} catch {
|
|
50
|
+
console.error("safeJsonForScript: failed to serialize data (circular reference?)");
|
|
51
|
+
json = "null";
|
|
52
|
+
}
|
|
53
|
+
return json.replace(SCRIPT_HAZARD_RE, "\\u003c$1");
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
// ─── Public Env Injection ─────────────────────────────────
|
|
41
57
|
|
|
42
58
|
/**
|
|
@@ -78,6 +94,8 @@ export function buildHtml(
|
|
|
78
94
|
lang?: string,
|
|
79
95
|
ssr = true,
|
|
80
96
|
nonce?: string,
|
|
97
|
+
pageDeps: any = null,
|
|
98
|
+
layoutDeps: any[] | null = null,
|
|
81
99
|
): string {
|
|
82
100
|
const cssLinks = (distManifest.css ?? [])
|
|
83
101
|
.map((f: string) => `<link rel="stylesheet" href="/dist/client/${f}">`)
|
|
@@ -92,12 +110,26 @@ export function buildHtml(
|
|
|
92
110
|
? `\n <script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`
|
|
93
111
|
: "";
|
|
94
112
|
|
|
95
|
-
const formScript =
|
|
96
|
-
formData != null ? `window.__BOSIA_FORM_DATA__=${safeJsonStringify(formData)};` : "";
|
|
97
113
|
const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
|
|
98
114
|
|
|
115
|
+
const depsScript =
|
|
116
|
+
pageDeps !== null || layoutDeps !== null
|
|
117
|
+
? `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`
|
|
118
|
+
: "";
|
|
119
|
+
|
|
120
|
+
const sysScript =
|
|
121
|
+
ssrFlag || depsScript ? `\n <script${n}>${ssrFlag}${depsScript}</script>` : "";
|
|
122
|
+
|
|
123
|
+
const dataIslands = csr
|
|
124
|
+
? `\n <script${n} type="application/json" id="__bosia-page-data__">${safeJsonForScript(pageData)}</script>` +
|
|
125
|
+
`\n <script${n} type="application/json" id="__bosia-layout-data__">${safeJsonForScript(layoutData)}</script>` +
|
|
126
|
+
(formData != null
|
|
127
|
+
? `\n <script${n} type="application/json" id="__bosia-form-data__">${safeJsonForScript(formData)}</script>`
|
|
128
|
+
: "")
|
|
129
|
+
: "";
|
|
130
|
+
|
|
99
131
|
const scripts = csr
|
|
100
|
-
? `${envScript}
|
|
132
|
+
? `${envScript}${dataIslands}${sysScript}\n <script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`
|
|
101
133
|
: isDev
|
|
102
134
|
? `\n <script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`
|
|
103
135
|
: "";
|
|
@@ -208,6 +240,8 @@ export function buildHtmlTail(
|
|
|
208
240
|
ssr = true,
|
|
209
241
|
bodyEndExtras?: string[],
|
|
210
242
|
nonce?: string,
|
|
243
|
+
pageDeps: any = null,
|
|
244
|
+
layoutDeps: any[] | null = null,
|
|
211
245
|
): string {
|
|
212
246
|
const n = nonceAttr(nonce);
|
|
213
247
|
let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
|
|
@@ -219,12 +253,19 @@ export function buildHtmlTail(
|
|
|
219
253
|
if (Object.keys(publicEnv).length > 0) {
|
|
220
254
|
out += `\n<script${n}>window.__BOSIA_ENV__=${safeJsonStringify(publicEnv)};</script>`;
|
|
221
255
|
}
|
|
222
|
-
|
|
223
|
-
|
|
256
|
+
out += `\n<script${n} type="application/json" id="__bosia-page-data__">${safeJsonForScript(pageData)}</script>`;
|
|
257
|
+
out += `\n<script${n} type="application/json" id="__bosia-layout-data__">${safeJsonForScript(layoutData)}</script>`;
|
|
258
|
+
if (formData != null) {
|
|
259
|
+
out += `\n<script${n} type="application/json" id="__bosia-form-data__">${safeJsonForScript(formData)}</script>`;
|
|
260
|
+
}
|
|
224
261
|
const ssrFlag = ssr ? "" : "window.__BOSIA_SSR__=false;";
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
262
|
+
const depsInject =
|
|
263
|
+
pageDeps !== null || layoutDeps !== null
|
|
264
|
+
? `window.__BOSIA_PAGE_DEPS__=${safeJsonStringify(pageDeps)};window.__BOSIA_LAYOUT_DEPS__=${safeJsonStringify(layoutDeps ?? [])};`
|
|
265
|
+
: "";
|
|
266
|
+
if (ssrFlag || depsInject) {
|
|
267
|
+
out += `\n<script${n}>${ssrFlag}${depsInject}</script>`;
|
|
268
|
+
}
|
|
228
269
|
out += `\n<script${n} type="module" src="/dist/client/${distManifest.entry}${cacheBust}"></script>`;
|
|
229
270
|
} else if (isDev) {
|
|
230
271
|
out += `\n<script${n}>!function r(){var e=new EventSource("/__bosia/sse");e.addEventListener("reload",()=>location.reload());e.onopen=()=>r._ok||(r._ok=1);e.onerror=()=>{e.close();setTimeout(r,2000)}}()</script>`;
|
package/src/core/renderer.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { render } from "svelte/server";
|
|
|
3
3
|
import { findMatch } from "./matcher.ts";
|
|
4
4
|
import { serverRoutes, errorPage } from "bosia:routes";
|
|
5
5
|
import type { RouteMatch } from "./types.ts";
|
|
6
|
-
import type { Cookies } from "./hooks.ts";
|
|
6
|
+
import type { Cookies, LoaderDeps } from "./hooks.ts";
|
|
7
7
|
import { CSP_ENABLED } from "./csp.ts";
|
|
8
8
|
import { HttpError, Redirect } from "./errors.ts";
|
|
9
9
|
import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
|
|
@@ -158,9 +158,134 @@ function stampErrorContext(
|
|
|
158
158
|
e.partialLayoutData ??= [...partialLayoutData];
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// ─── Per-Loader Dependency Tracking ──────────────────────
|
|
162
|
+
// Wraps `params`, `url`, `cookies`, and `fetch` with proxies/closures
|
|
163
|
+
// that record every key read during a single loader run. The client
|
|
164
|
+
// cache uses these records to skip re-runs on subsequent navigations
|
|
165
|
+
// when none of the tracked inputs changed.
|
|
166
|
+
|
|
167
|
+
function emptyDeps(): LoaderDeps {
|
|
168
|
+
return {
|
|
169
|
+
keys: [],
|
|
170
|
+
urls: [],
|
|
171
|
+
params: [],
|
|
172
|
+
searchParams: [],
|
|
173
|
+
cookies: [],
|
|
174
|
+
uses_url: false,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function trackedParams(params: Record<string, string>, deps: LoaderDeps): Record<string, string> {
|
|
179
|
+
return new Proxy(params, {
|
|
180
|
+
get(target, prop) {
|
|
181
|
+
if (typeof prop === "string") {
|
|
182
|
+
if (!deps.params.includes(prop)) deps.params.push(prop);
|
|
183
|
+
}
|
|
184
|
+
return Reflect.get(target, prop);
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const URL_TRACKED_PROPS = new Set(["pathname", "origin", "hash", "href", "host", "hostname"]);
|
|
190
|
+
|
|
191
|
+
function trackedUrl(url: URL, deps: LoaderDeps): URL {
|
|
192
|
+
const trackedSearch = new Proxy(url.searchParams, {
|
|
193
|
+
get(target, prop) {
|
|
194
|
+
if (prop === "get" || prop === "has" || prop === "getAll") {
|
|
195
|
+
return (key: string) => {
|
|
196
|
+
if (typeof key === "string" && !deps.searchParams.includes(key)) {
|
|
197
|
+
deps.searchParams.push(key);
|
|
198
|
+
}
|
|
199
|
+
return (target as any)[prop](key);
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const value = Reflect.get(target, prop);
|
|
203
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
return new Proxy(url, {
|
|
207
|
+
get(target, prop) {
|
|
208
|
+
if (prop === "searchParams") return trackedSearch;
|
|
209
|
+
if (prop === "search") {
|
|
210
|
+
// Whole search string read → treat as broad searchParams dep
|
|
211
|
+
deps.uses_url = true;
|
|
212
|
+
return target.search;
|
|
213
|
+
}
|
|
214
|
+
if (typeof prop === "string" && URL_TRACKED_PROPS.has(prop)) {
|
|
215
|
+
deps.uses_url = true;
|
|
216
|
+
}
|
|
217
|
+
const value = Reflect.get(target, prop);
|
|
218
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function trackedCookies(cookies: Cookies, deps: LoaderDeps): Cookies {
|
|
224
|
+
return {
|
|
225
|
+
get(name: string) {
|
|
226
|
+
if (!deps.cookies.includes(name)) deps.cookies.push(name);
|
|
227
|
+
return cookies.get(name);
|
|
228
|
+
},
|
|
229
|
+
getAll() {
|
|
230
|
+
// Broad read — treat as wildcard; record empty marker so any cookie
|
|
231
|
+
// invalidation triggers re-run. Use a sentinel "*" to mean "all".
|
|
232
|
+
if (!deps.cookies.includes("*")) deps.cookies.push("*");
|
|
233
|
+
return cookies.getAll();
|
|
234
|
+
},
|
|
235
|
+
set(name, value, options) {
|
|
236
|
+
cookies.set(name, value, options);
|
|
237
|
+
},
|
|
238
|
+
delete(name, options) {
|
|
239
|
+
cookies.delete(name, options);
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function trackedFetch(
|
|
245
|
+
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>,
|
|
246
|
+
origin: string,
|
|
247
|
+
deps: LoaderDeps,
|
|
248
|
+
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
|
249
|
+
return (input, init) => {
|
|
250
|
+
let href: string | null = null;
|
|
251
|
+
try {
|
|
252
|
+
if (typeof input === "string") href = new URL(input, origin).href;
|
|
253
|
+
else if (input instanceof URL) href = input.href;
|
|
254
|
+
else href = new URL(input.url, origin).href;
|
|
255
|
+
} catch {
|
|
256
|
+
href = null;
|
|
257
|
+
}
|
|
258
|
+
if (href && !deps.urls.includes(href)) deps.urls.push(href);
|
|
259
|
+
return fetch(input, init);
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function makeDepends(deps: LoaderDeps): (...keys: string[]) => void {
|
|
264
|
+
return (...keys: string[]) => {
|
|
265
|
+
for (const k of keys) {
|
|
266
|
+
if (typeof k === "string" && !deps.keys.includes(k)) deps.keys.push(k);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
161
271
|
// ─── Route Data Loader ───────────────────────────────────
|
|
162
272
|
// Runs layout + page server loaders for a given URL.
|
|
163
273
|
// Used by both SSR and the /__bosia/data JSON endpoint.
|
|
274
|
+
//
|
|
275
|
+
// `mask` controls selective re-runs from the client data endpoint:
|
|
276
|
+
// - undefined → run everything (SSR, first nav)
|
|
277
|
+
// - layouts[i] === true → run that layout; false → skip, emit null
|
|
278
|
+
// - page === true → run page; false → skip, emit null
|
|
279
|
+
// When skipped, the parent() chain still receives the *combined parent
|
|
280
|
+
// data* contributed by previously-cached layers, which the client
|
|
281
|
+
// reconstructs and forwards in the request body. For now (initial
|
|
282
|
+
// implementation), skipped loaders contribute `{}` to parent and the
|
|
283
|
+
// response slot is `null`; the client merges with its cached data.
|
|
284
|
+
|
|
285
|
+
export type LoaderMask = {
|
|
286
|
+
page: boolean;
|
|
287
|
+
layouts: boolean[];
|
|
288
|
+
};
|
|
164
289
|
|
|
165
290
|
export async function loadRouteData(
|
|
166
291
|
url: URL,
|
|
@@ -169,89 +294,155 @@ export async function loadRouteData(
|
|
|
169
294
|
cookies: Cookies,
|
|
170
295
|
metadataData: Record<string, any> | null = null,
|
|
171
296
|
match?: RouteMatch<(typeof serverRoutes)[number]> | null,
|
|
297
|
+
mask?: LoaderMask,
|
|
172
298
|
) {
|
|
173
299
|
match ??= findMatch(serverRoutes, url.pathname);
|
|
174
300
|
if (!match) return null;
|
|
175
301
|
|
|
176
302
|
const { route, params } = match;
|
|
177
303
|
const fetch = makeFetch(req, url);
|
|
178
|
-
const
|
|
304
|
+
const origin = url.origin;
|
|
305
|
+
const layoutData: (Record<string, any> | null)[] = [];
|
|
306
|
+
const layoutDeps: (LoaderDeps | null)[] = [];
|
|
179
307
|
let parentData: Record<string, any> = {};
|
|
180
308
|
|
|
181
309
|
// Run layout server loaders root → leaf, each gets parent() data
|
|
182
310
|
for (const ls of route.layoutServers) {
|
|
311
|
+
const skip = mask && mask.layouts[ls.depth] === false;
|
|
183
312
|
try {
|
|
313
|
+
if (skip) {
|
|
314
|
+
layoutData[ls.depth] = null;
|
|
315
|
+
layoutDeps[ls.depth] = null;
|
|
316
|
+
// Skipped layers contribute {} to the parent chain. The client
|
|
317
|
+
// already has their data and renders it from cache, so dependent
|
|
318
|
+
// loaders that DO re-run will see stale parent() data here. This
|
|
319
|
+
// is the same trade-off SvelteKit makes; loaders that need fresh
|
|
320
|
+
// upstream data should call `depends()` on a shared key.
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
184
323
|
const mod = await ls.loader();
|
|
185
324
|
if (typeof mod.load === "function") {
|
|
186
325
|
// Snapshot per layer so loaders cannot mutate the shared accumulator,
|
|
187
326
|
// preserving the same isolation semantics as the previous merge-on-call code.
|
|
188
327
|
const snapshot = { ...parentData };
|
|
189
328
|
const parent = async () => snapshot;
|
|
329
|
+
const deps = emptyDeps();
|
|
190
330
|
const result =
|
|
191
331
|
(await withTimeout(
|
|
192
|
-
mod.load({
|
|
332
|
+
mod.load({
|
|
333
|
+
params: trackedParams(params, deps),
|
|
334
|
+
url: trackedUrl(url, deps),
|
|
335
|
+
locals,
|
|
336
|
+
cookies: trackedCookies(cookies, deps),
|
|
337
|
+
parent,
|
|
338
|
+
fetch: trackedFetch(fetch, origin, deps),
|
|
339
|
+
metadata: null,
|
|
340
|
+
depends: makeDepends(deps),
|
|
341
|
+
}),
|
|
193
342
|
LOAD_TIMEOUT,
|
|
194
343
|
`layout load (depth=${ls.depth}, ${url.pathname})`,
|
|
195
344
|
)) ?? {};
|
|
196
345
|
layoutData[ls.depth] = result;
|
|
346
|
+
layoutDeps[ls.depth] = deps;
|
|
197
347
|
parentData = { ...parentData, ...result };
|
|
348
|
+
} else {
|
|
349
|
+
layoutData[ls.depth] = {};
|
|
350
|
+
layoutDeps[ls.depth] = emptyDeps();
|
|
198
351
|
}
|
|
199
352
|
} catch (err) {
|
|
200
353
|
if (err instanceof Redirect) throw err;
|
|
201
354
|
if (err instanceof HttpError) {
|
|
202
|
-
stampErrorContext(
|
|
355
|
+
stampErrorContext(
|
|
356
|
+
err,
|
|
357
|
+
ls.depth,
|
|
358
|
+
"layout",
|
|
359
|
+
layoutData.map((d) => d ?? {}),
|
|
360
|
+
);
|
|
203
361
|
throw err;
|
|
204
362
|
}
|
|
205
363
|
if (isDev) console.error("Layout server load error:", err);
|
|
206
364
|
else console.error("Layout server load error:", (err as Error).message ?? err);
|
|
207
365
|
const wrapped = new HttpError(500, "Internal Server Error");
|
|
208
|
-
stampErrorContext(
|
|
366
|
+
stampErrorContext(
|
|
367
|
+
wrapped,
|
|
368
|
+
ls.depth,
|
|
369
|
+
"layout",
|
|
370
|
+
layoutData.map((d) => d ?? {}),
|
|
371
|
+
);
|
|
209
372
|
throw wrapped;
|
|
210
373
|
}
|
|
211
374
|
}
|
|
212
375
|
|
|
213
376
|
// Run page server loader
|
|
214
|
-
let pageData: Record<string, any> =
|
|
377
|
+
let pageData: Record<string, any> | null = null;
|
|
378
|
+
let pageDeps: LoaderDeps | null = null;
|
|
215
379
|
let csr = true;
|
|
216
380
|
let ssr = true;
|
|
381
|
+
const skipPage = mask && mask.page === false;
|
|
217
382
|
if (route.pageServer) {
|
|
218
383
|
try {
|
|
219
384
|
const mod = await route.pageServer();
|
|
220
385
|
if (mod.csr === false) csr = false;
|
|
221
386
|
if (mod.ssr === false) ssr = false;
|
|
222
|
-
if (
|
|
387
|
+
if (skipPage) {
|
|
388
|
+
pageData = null;
|
|
389
|
+
pageDeps = null;
|
|
390
|
+
} else if (typeof mod.load === "function") {
|
|
223
391
|
const snapshot = { ...parentData };
|
|
224
392
|
const parent = async () => snapshot;
|
|
393
|
+
const deps = emptyDeps();
|
|
225
394
|
pageData =
|
|
226
395
|
(await withTimeout(
|
|
227
396
|
mod.load({
|
|
228
|
-
params,
|
|
229
|
-
url,
|
|
397
|
+
params: trackedParams(params, deps),
|
|
398
|
+
url: trackedUrl(url, deps),
|
|
230
399
|
locals,
|
|
231
|
-
cookies,
|
|
400
|
+
cookies: trackedCookies(cookies, deps),
|
|
232
401
|
parent,
|
|
233
|
-
fetch,
|
|
402
|
+
fetch: trackedFetch(fetch, origin, deps),
|
|
234
403
|
metadata: metadataData,
|
|
404
|
+
depends: makeDepends(deps),
|
|
235
405
|
}),
|
|
236
406
|
LOAD_TIMEOUT,
|
|
237
407
|
`page load (${url.pathname})`,
|
|
238
408
|
)) ?? {};
|
|
409
|
+
pageDeps = deps;
|
|
410
|
+
} else {
|
|
411
|
+
pageData = {};
|
|
412
|
+
pageDeps = emptyDeps();
|
|
239
413
|
}
|
|
240
414
|
} catch (err) {
|
|
241
415
|
if (err instanceof Redirect) throw err;
|
|
242
416
|
if (err instanceof HttpError) {
|
|
243
|
-
stampErrorContext(
|
|
417
|
+
stampErrorContext(
|
|
418
|
+
err,
|
|
419
|
+
route.layoutModules.length,
|
|
420
|
+
"page",
|
|
421
|
+
layoutData.map((d) => d ?? {}),
|
|
422
|
+
);
|
|
244
423
|
throw err;
|
|
245
424
|
}
|
|
246
425
|
if (isDev) console.error("Page server load error:", err);
|
|
247
426
|
else console.error("Page server load error:", (err as Error).message ?? err);
|
|
248
427
|
const wrapped = new HttpError(500, "Internal Server Error");
|
|
249
|
-
stampErrorContext(
|
|
428
|
+
stampErrorContext(
|
|
429
|
+
wrapped,
|
|
430
|
+
route.layoutModules.length,
|
|
431
|
+
"page",
|
|
432
|
+
layoutData.map((d) => d ?? {}),
|
|
433
|
+
);
|
|
250
434
|
throw wrapped;
|
|
251
435
|
}
|
|
436
|
+
} else {
|
|
437
|
+
pageData = {};
|
|
438
|
+
pageDeps = emptyDeps();
|
|
252
439
|
}
|
|
253
440
|
|
|
254
|
-
|
|
441
|
+
// `params` are always attached to pageData for client-side router consumption.
|
|
442
|
+
// When pageData is skipped, the client merges its cached pageData with current
|
|
443
|
+
// route params separately, so we keep the `null` sentinel here.
|
|
444
|
+
const pageOut = pageData === null ? null : { ...pageData, params };
|
|
445
|
+
return { pageData: pageOut, layoutData, csr, ssr, pageDeps, layoutDeps };
|
|
255
446
|
}
|
|
256
447
|
|
|
257
448
|
// ─── Metadata Loader ─────────────────────────────────────
|
|
@@ -399,6 +590,10 @@ export async function renderSSRStream(
|
|
|
399
590
|
pluginRenderFragments("bodyEnd", renderCtx),
|
|
400
591
|
]);
|
|
401
592
|
|
|
593
|
+
// SSR always runs every loader, so coerce types from the optional sparse shape.
|
|
594
|
+
const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
|
|
595
|
+
const pageDataFull = data.pageData ?? {};
|
|
596
|
+
|
|
402
597
|
// ssr=false → no render() needed; ship shell + hydration as a single response.
|
|
403
598
|
// ssr=false && csr=false is meaningless (nothing renders) — force csr=true.
|
|
404
599
|
if (!data.ssr) {
|
|
@@ -413,13 +608,15 @@ export async function renderSSRStream(
|
|
|
413
608
|
buildHtmlTail(
|
|
414
609
|
"",
|
|
415
610
|
"",
|
|
416
|
-
|
|
417
|
-
|
|
611
|
+
pageDataFull,
|
|
612
|
+
layoutDataFull,
|
|
418
613
|
true,
|
|
419
614
|
null,
|
|
420
615
|
false,
|
|
421
616
|
bodyEndExtras,
|
|
422
617
|
nonce,
|
|
618
|
+
data.pageDeps,
|
|
619
|
+
data.layoutDeps,
|
|
423
620
|
);
|
|
424
621
|
return new Response(html, {
|
|
425
622
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
@@ -435,8 +632,8 @@ export async function renderSSRStream(
|
|
|
435
632
|
ssrMode: true,
|
|
436
633
|
ssrPageComponent: pageMod.default,
|
|
437
634
|
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
438
|
-
ssrPageData:
|
|
439
|
-
ssrLayoutData:
|
|
635
|
+
ssrPageData: pageDataFull,
|
|
636
|
+
ssrLayoutData: layoutDataFull,
|
|
440
637
|
},
|
|
441
638
|
}));
|
|
442
639
|
} catch (err) {
|
|
@@ -451,7 +648,7 @@ export async function renderSSRStream(
|
|
|
451
648
|
route,
|
|
452
649
|
route.layoutModules.length,
|
|
453
650
|
"page",
|
|
454
|
-
|
|
651
|
+
layoutDataFull,
|
|
455
652
|
nonce,
|
|
456
653
|
);
|
|
457
654
|
}
|
|
@@ -464,13 +661,15 @@ export async function renderSSRStream(
|
|
|
464
661
|
buildHtmlTail(
|
|
465
662
|
body,
|
|
466
663
|
head,
|
|
467
|
-
|
|
468
|
-
|
|
664
|
+
pageDataFull,
|
|
665
|
+
layoutDataFull,
|
|
469
666
|
data.csr,
|
|
470
667
|
null,
|
|
471
668
|
true,
|
|
472
669
|
bodyEndExtras,
|
|
473
670
|
nonce,
|
|
671
|
+
data.pageDeps,
|
|
672
|
+
data.layoutDeps,
|
|
474
673
|
),
|
|
475
674
|
),
|
|
476
675
|
];
|
|
@@ -556,6 +755,10 @@ export async function renderPageWithFormData(
|
|
|
556
755
|
nonce,
|
|
557
756
|
);
|
|
558
757
|
|
|
758
|
+
// Form-action re-render always runs every loader (no client mask).
|
|
759
|
+
const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
|
|
760
|
+
const pageDataFull = data.pageData ?? {};
|
|
761
|
+
|
|
559
762
|
if (!data.ssr) {
|
|
560
763
|
if (!data.csr && isDev) {
|
|
561
764
|
console.warn(
|
|
@@ -565,13 +768,15 @@ export async function renderPageWithFormData(
|
|
|
565
768
|
const html = buildHtml(
|
|
566
769
|
"",
|
|
567
770
|
"",
|
|
568
|
-
|
|
569
|
-
|
|
771
|
+
pageDataFull,
|
|
772
|
+
layoutDataFull,
|
|
570
773
|
true,
|
|
571
774
|
formData,
|
|
572
775
|
undefined,
|
|
573
776
|
false,
|
|
574
777
|
nonce,
|
|
778
|
+
data.pageDeps,
|
|
779
|
+
data.layoutDeps,
|
|
575
780
|
);
|
|
576
781
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
577
782
|
}
|
|
@@ -581,8 +786,8 @@ export async function renderPageWithFormData(
|
|
|
581
786
|
ssrMode: true,
|
|
582
787
|
ssrPageComponent: pageMod.default,
|
|
583
788
|
ssrLayoutComponents: layoutMods.map((m: any) => m.default),
|
|
584
|
-
ssrPageData:
|
|
585
|
-
ssrLayoutData:
|
|
789
|
+
ssrPageData: pageDataFull,
|
|
790
|
+
ssrLayoutData: layoutDataFull,
|
|
586
791
|
ssrFormData: formData,
|
|
587
792
|
},
|
|
588
793
|
});
|
|
@@ -590,13 +795,15 @@ export async function renderPageWithFormData(
|
|
|
590
795
|
const html = buildHtml(
|
|
591
796
|
body,
|
|
592
797
|
head,
|
|
593
|
-
|
|
594
|
-
|
|
798
|
+
pageDataFull,
|
|
799
|
+
layoutDataFull,
|
|
595
800
|
data.csr,
|
|
596
801
|
formData,
|
|
597
802
|
undefined,
|
|
598
803
|
true,
|
|
599
804
|
nonce,
|
|
805
|
+
data.pageDeps,
|
|
806
|
+
data.layoutDeps,
|
|
600
807
|
);
|
|
601
808
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
602
809
|
}
|
|
@@ -615,7 +822,7 @@ export async function renderErrorPage(
|
|
|
615
822
|
route?: any,
|
|
616
823
|
errorDepth?: number,
|
|
617
824
|
errorOrigin?: ErrorOrigin,
|
|
618
|
-
partialLayoutData?: Record<string, any>[],
|
|
825
|
+
partialLayoutData?: (Record<string, any> | null)[],
|
|
619
826
|
nonce?: string,
|
|
620
827
|
): Promise<Response> {
|
|
621
828
|
// Strip the nonce from emitted scripts when CSP is off — the attribute
|
package/src/core/routeFile.ts
CHANGED
|
@@ -40,6 +40,11 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
40
40
|
lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
|
|
41
41
|
lines.push(" hasServerData: boolean;");
|
|
42
42
|
lines.push(' trailingSlash: "never" | "always" | "ignore";');
|
|
43
|
+
// Stable ids per node, used by the client loader cache to detect whether
|
|
44
|
+
// a layout/page at a given depth is the same as last nav's (skip) or new
|
|
45
|
+
// (re-run). One id per layout in order, plus an optional page id.
|
|
46
|
+
lines.push(" pageId: string | null;");
|
|
47
|
+
lines.push(" layoutIds: (string | null)[];");
|
|
43
48
|
lines.push("}> = [");
|
|
44
49
|
for (const r of pages) {
|
|
45
50
|
const layoutImports = r.layouts
|
|
@@ -52,6 +57,15 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
52
57
|
)
|
|
53
58
|
.join(", ");
|
|
54
59
|
const hasServerData = !!(r.pageServer || r.layoutServers.length > 0);
|
|
60
|
+
const pageId = r.pageServer ? JSON.stringify(r.pageServer) : "null";
|
|
61
|
+
// Map +layout.server.ts paths to their layout depth, leaving nulls for
|
|
62
|
+
// layouts that have no server loader. Length matches layouts.length so
|
|
63
|
+
// client cache can index by depth.
|
|
64
|
+
const layoutIdByDepth: (string | null)[] = new Array(r.layouts.length).fill(null);
|
|
65
|
+
for (const ls of r.layoutServers) layoutIdByDepth[ls.depth] = ls.path;
|
|
66
|
+
const layoutIds = layoutIdByDepth
|
|
67
|
+
.map((id) => (id === null ? "null" : JSON.stringify(id)))
|
|
68
|
+
.join(", ");
|
|
55
69
|
lines.push(" {");
|
|
56
70
|
lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
|
|
57
71
|
lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
|
|
@@ -59,6 +73,8 @@ export function generateRoutesFile(manifest: RouteManifest): void {
|
|
|
59
73
|
lines.push(` errorPages: [${errorPageImports}],`);
|
|
60
74
|
lines.push(` hasServerData: ${hasServerData},`);
|
|
61
75
|
lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
|
|
76
|
+
lines.push(` pageId: ${pageId},`);
|
|
77
|
+
lines.push(` layoutIds: [${layoutIds}],`);
|
|
62
78
|
lines.push(" },");
|
|
63
79
|
}
|
|
64
80
|
lines.push("];\n");
|
|
@@ -157,6 +173,8 @@ function generateClientRoutesFile(
|
|
|
157
173
|
lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
|
|
158
174
|
lines.push(" hasServerData: boolean;");
|
|
159
175
|
lines.push(' trailingSlash: "never" | "always" | "ignore";');
|
|
176
|
+
lines.push(" pageId: string | null;");
|
|
177
|
+
lines.push(" layoutIds: (string | null)[];");
|
|
160
178
|
lines.push("}> = [");
|
|
161
179
|
for (const r of pages) {
|
|
162
180
|
const layoutImports = r.layouts
|
|
@@ -169,6 +187,12 @@ function generateClientRoutesFile(
|
|
|
169
187
|
)
|
|
170
188
|
.join(", ");
|
|
171
189
|
const hasServerData = !!(r.pageServer || r.layoutServers.length > 0);
|
|
190
|
+
const pageId = r.pageServer ? JSON.stringify(r.pageServer) : "null";
|
|
191
|
+
const layoutIdByDepth: (string | null)[] = new Array(r.layouts.length).fill(null);
|
|
192
|
+
for (const ls of r.layoutServers) layoutIdByDepth[ls.depth] = ls.path;
|
|
193
|
+
const layoutIds = layoutIdByDepth
|
|
194
|
+
.map((id) => (id === null ? "null" : JSON.stringify(id)))
|
|
195
|
+
.join(", ");
|
|
172
196
|
lines.push(" {");
|
|
173
197
|
lines.push(` pattern: ${JSON.stringify(r.pattern)},`);
|
|
174
198
|
lines.push(` page: () => import(${JSON.stringify(toImportPath(r.page))}),`);
|
|
@@ -176,6 +200,8 @@ function generateClientRoutesFile(
|
|
|
176
200
|
lines.push(` errorPages: [${errorPageImports}],`);
|
|
177
201
|
lines.push(` hasServerData: ${hasServerData},`);
|
|
178
202
|
lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
|
|
203
|
+
lines.push(` pageId: ${pageId},`);
|
|
204
|
+
lines.push(` layoutIds: [${layoutIds}],`);
|
|
179
205
|
lines.push(" },");
|
|
180
206
|
}
|
|
181
207
|
lines.push("];\n");
|
package/src/core/server.ts
CHANGED
|
@@ -112,6 +112,23 @@ function isValidRoutePath(path: string, origin: string): boolean {
|
|
|
112
112
|
}
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Decode an `_invalidated` bitmask string. Char 0 = page, char i+1 = layout
|
|
117
|
+
* depth i, '1' = run, '0' = skip. Missing/extra chars default to run.
|
|
118
|
+
*/
|
|
119
|
+
function buildMaskFromBits(
|
|
120
|
+
bits: string,
|
|
121
|
+
layoutCount: number,
|
|
122
|
+
): { page: boolean; layouts: boolean[] } {
|
|
123
|
+
const page = bits[0] !== "0";
|
|
124
|
+
const layouts: boolean[] = [];
|
|
125
|
+
for (let i = 0; i < layoutCount; i++) {
|
|
126
|
+
const c = bits[i + 1];
|
|
127
|
+
layouts.push(c !== "0");
|
|
128
|
+
}
|
|
129
|
+
return { page, layouts };
|
|
130
|
+
}
|
|
131
|
+
|
|
115
132
|
/** Extract action name from URL searchParams — `?/login` → "login", no slash key → "default". */
|
|
116
133
|
function parseActionName(url: URL): string {
|
|
117
134
|
for (const key of url.searchParams.keys()) {
|
|
@@ -146,15 +163,41 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
146
163
|
return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
|
|
147
164
|
}
|
|
148
165
|
const routeUrl = new URL(routePathStr, url.origin);
|
|
166
|
+
let invalidatedBits: string | null = null;
|
|
149
167
|
for (const [key, val] of url.searchParams.entries()) {
|
|
168
|
+
if (key === "_invalidated") {
|
|
169
|
+
invalidatedBits = val;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
150
172
|
routeUrl.searchParams.append(key, val);
|
|
151
173
|
}
|
|
152
174
|
// Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
|
|
153
175
|
event.url = routeUrl;
|
|
154
176
|
try {
|
|
155
177
|
const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
|
|
178
|
+
// Build mask from `?_invalidated=<bits>` where char 0 = page,
|
|
179
|
+
// char i+1 = layout depth i, '1' = run, '0' = skip. Absent → run all.
|
|
180
|
+
// Mask is sized to the total layout count (matching client `layoutIds`),
|
|
181
|
+
// not the count of layout servers, so depths without a server loader
|
|
182
|
+
// still occupy a bit position and stay aligned with the client.
|
|
183
|
+
const mask = invalidatedBits
|
|
184
|
+
? buildMaskFromBits(
|
|
185
|
+
invalidatedBits,
|
|
186
|
+
pageMatch?.route
|
|
187
|
+
? ((pageMatch.route as any).layoutModules?.length ?? 0)
|
|
188
|
+
: 0,
|
|
189
|
+
)
|
|
190
|
+
: undefined;
|
|
156
191
|
const runLoad = async () => {
|
|
157
|
-
const data = await loadRouteData(
|
|
192
|
+
const data = await loadRouteData(
|
|
193
|
+
routeUrl,
|
|
194
|
+
locals,
|
|
195
|
+
request,
|
|
196
|
+
cookies,
|
|
197
|
+
null,
|
|
198
|
+
pageMatch,
|
|
199
|
+
mask,
|
|
200
|
+
);
|
|
158
201
|
|
|
159
202
|
let metadata = null;
|
|
160
203
|
if (pageMatch) {
|
|
@@ -176,12 +219,17 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
176
219
|
return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
|
|
177
220
|
};
|
|
178
221
|
|
|
179
|
-
// Dedup public routes by URL
|
|
180
|
-
// skip the cache to prevent cross-user data leaks.
|
|
222
|
+
// Dedup public routes by URL + mask. `(private)` scope routes (per-user
|
|
223
|
+
// content) skip the cache to prevent cross-user data leaks. The mask is
|
|
224
|
+
// part of the key so concurrent requests for the same URL with different
|
|
225
|
+
// invalidation patterns don't collapse onto each other. See dedup.ts.
|
|
226
|
+
const dedupK = invalidatedBits
|
|
227
|
+
? `${dedupKey(routeUrl)}|m=${invalidatedBits}`
|
|
228
|
+
: dedupKey(routeUrl);
|
|
181
229
|
const result =
|
|
182
230
|
pageMatch?.route.scope === "private"
|
|
183
231
|
? await runLoad()
|
|
184
|
-
: await dedup(
|
|
232
|
+
: await dedup(dedupK, runLoad);
|
|
185
233
|
|
|
186
234
|
const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
|
|
187
235
|
const cc = cookiesWereAccessed
|
package/src/lib/client.ts
CHANGED