bosia 0.4.6 → 0.5.1
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 +62 -5
- package/src/lib/client.ts +1 -0
- package/src/lib/index.ts +1 -0
- package/templates/default/bosia.config.ts +10 -0
- package/templates/demo/bosia.config.ts +10 -0
- package/templates/todo/bosia.config.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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>;
|