bosia 0.8.5 → 0.8.7
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/cache.ts +7 -5
- package/src/core/client/App.svelte +45 -19
- package/src/core/client/router.svelte.ts +96 -4
- package/src/core/dev.ts +1 -1
- package/src/core/hooks.ts +30 -0
- package/src/core/html.ts +5 -3
- package/src/core/plugins/inspector/bun-plugin.ts +7 -36
- package/src/core/renderer.ts +13 -6
- package/src/core/routeTypes.ts +8 -0
- package/src/core/server.ts +8 -2
- package/src/lib/client.ts +3 -0
package/package.json
CHANGED
package/src/core/cache.ts
CHANGED
|
@@ -350,18 +350,20 @@ export function coalesceMiss(
|
|
|
350
350
|
|
|
351
351
|
export function serveCached(entry: CacheEntry, req: Request): Response {
|
|
352
352
|
const accept = req.headers.get("accept-encoding") ?? "";
|
|
353
|
+
// Base keys lowercased so lowercased extraHeaders (e.g. loader setHeaders)
|
|
354
|
+
// override them instead of getting comma-joined by Headers.
|
|
353
355
|
const headers: Record<string, string> = {
|
|
354
|
-
"
|
|
355
|
-
|
|
356
|
-
"
|
|
356
|
+
"content-type": entry.contentType,
|
|
357
|
+
vary: "Accept-Encoding",
|
|
358
|
+
"x-bosia-cache": "HIT",
|
|
357
359
|
...entry.extraHeaders,
|
|
358
360
|
};
|
|
359
361
|
if (entry.brotli && accept.includes("br")) {
|
|
360
|
-
headers["
|
|
362
|
+
headers["content-encoding"] = "br";
|
|
361
363
|
return new Response(entry.brotli, { status: entry.status, headers });
|
|
362
364
|
}
|
|
363
365
|
if (entry.gzip && accept.includes("gzip")) {
|
|
364
|
-
headers["
|
|
366
|
+
headers["content-encoding"] = "gzip";
|
|
365
367
|
return new Response(entry.gzip, { status: entry.status, headers });
|
|
366
368
|
}
|
|
367
369
|
return new Response(entry.raw, { status: entry.status, headers });
|
|
@@ -34,6 +34,24 @@
|
|
|
34
34
|
|
|
35
35
|
let PageComponent = $state<any>(ssrPageComponent);
|
|
36
36
|
let layoutComponents = $state<any[]>(ssrLayoutComponents ?? []);
|
|
37
|
+
// Mounted page instance — the two <PageComponent> render spots are mutually
|
|
38
|
+
// exclusive branches, so one ref suffices. Exposes `export const snapshot`.
|
|
39
|
+
let pageInstance = $state<any>(null);
|
|
40
|
+
if (!ssrMode) router.getPageSnapshot = () => pageInstance?.snapshot;
|
|
41
|
+
|
|
42
|
+
// Call the landed-on page's snapshot.restore() once it's mounted (post-tick).
|
|
43
|
+
function settleSnapshot() {
|
|
44
|
+
const snap = router.pendingSnapshot;
|
|
45
|
+
if (snap !== undefined)
|
|
46
|
+
tick().then(() => {
|
|
47
|
+
try {
|
|
48
|
+
pageInstance?.snapshot?.restore?.(snap);
|
|
49
|
+
} catch {
|
|
50
|
+
// user restore() threw — never break navigation
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
router.pendingSnapshot = undefined;
|
|
54
|
+
}
|
|
37
55
|
// Network protocol still ships `params` merged into pageData (see renderer.ts).
|
|
38
56
|
// Strip it at the component boundary so consumers receive `params` only via
|
|
39
57
|
// the dedicated prop, matching SvelteKit's surface.
|
|
@@ -63,6 +81,27 @@
|
|
|
63
81
|
let firstNav = true;
|
|
64
82
|
let navDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
|
65
83
|
|
|
84
|
+
// Scroll after a nav settles: forward navs go to top (or the URL hash);
|
|
85
|
+
// back/forward navs restore the position saved for that history entry.
|
|
86
|
+
// Runs after tick() so the destination page's real height is in the DOM.
|
|
87
|
+
// goto({ noScroll: true }) flips `router.suppressScroll` for one nav.
|
|
88
|
+
function settleScroll() {
|
|
89
|
+
if (router.isPush && !router.suppressScroll) {
|
|
90
|
+
const hash = window.location.hash;
|
|
91
|
+
tick().then(() => {
|
|
92
|
+
if (!scrollToHash(hash)) window.scrollTo(0, 0);
|
|
93
|
+
});
|
|
94
|
+
} else if (!router.isPush) {
|
|
95
|
+
const pos = router.pendingScroll;
|
|
96
|
+
// single post-tick restore; content that loads later (images
|
|
97
|
+
// without dimensions) can still shift it. Revisit only if reported.
|
|
98
|
+
if (pos) tick().then(() => window.scrollTo(pos.x, pos.y));
|
|
99
|
+
}
|
|
100
|
+
router.pendingScroll = null;
|
|
101
|
+
router.suppressScroll = false;
|
|
102
|
+
settleSnapshot();
|
|
103
|
+
}
|
|
104
|
+
|
|
66
105
|
$effect(() => {
|
|
67
106
|
if (ssrMode) return;
|
|
68
107
|
|
|
@@ -82,6 +121,8 @@
|
|
|
82
121
|
if (isFirst) {
|
|
83
122
|
currentLayoutPaths = (match.route as any).layoutPaths ?? [];
|
|
84
123
|
lastSettledPath = pathname;
|
|
124
|
+
// Restore-after-reload: router.init() staged this entry's snapshot.
|
|
125
|
+
settleSnapshot();
|
|
85
126
|
return; // Initial hydration — data already in SSR props, no fetch needed
|
|
86
127
|
}
|
|
87
128
|
|
|
@@ -247,13 +288,7 @@
|
|
|
247
288
|
appState.errorComponent = errMod.default;
|
|
248
289
|
appState.errorProps = { error: { status: errStatus, message: errMessage } };
|
|
249
290
|
appState.errorDepth = K;
|
|
250
|
-
|
|
251
|
-
const hash = window.location.hash;
|
|
252
|
-
tick().then(() => {
|
|
253
|
-
if (!scrollToHash(hash)) window.scrollTo(0, 0);
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
router.suppressScroll = false;
|
|
291
|
+
settleScroll();
|
|
257
292
|
settle({ url, params: match.params });
|
|
258
293
|
} catch {
|
|
259
294
|
window.location.href = path;
|
|
@@ -342,16 +377,7 @@
|
|
|
342
377
|
appState.errorProps = null;
|
|
343
378
|
appState.errorDepth = null;
|
|
344
379
|
|
|
345
|
-
|
|
346
|
-
// goto({ noScroll: true }) flips `router.suppressScroll` for one nav.
|
|
347
|
-
// If the destination URL has a hash, scroll to that element instead.
|
|
348
|
-
if (router.isPush && !router.suppressScroll) {
|
|
349
|
-
const hash = window.location.hash;
|
|
350
|
-
tick().then(() => {
|
|
351
|
-
if (!scrollToHash(hash)) window.scrollTo(0, 0);
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
router.suppressScroll = false;
|
|
380
|
+
settleScroll();
|
|
355
381
|
|
|
356
382
|
// Update document title and meta description from server metadata
|
|
357
383
|
if (result?.metadata) {
|
|
@@ -399,7 +425,7 @@
|
|
|
399
425
|
{:else if layoutComponents.length > 0}
|
|
400
426
|
{@render renderLayout(0, layoutComponents.length)}
|
|
401
427
|
{:else if PageComponent}
|
|
402
|
-
<PageComponent data={pageData} {params} form={formData} />
|
|
428
|
+
<PageComponent bind:this={pageInstance} data={pageData} {params} form={formData} />
|
|
403
429
|
{:else}
|
|
404
430
|
<p>Loading...</p>
|
|
405
431
|
{/if}
|
|
@@ -429,7 +455,7 @@
|
|
|
429
455
|
{#if ErrorComponent}
|
|
430
456
|
<ErrorComponent {...errorProps ?? {}} />
|
|
431
457
|
{:else if PageComponent}
|
|
432
|
-
<PageComponent data={pageData} {params} form={formData} />
|
|
458
|
+
<PageComponent bind:this={pageInstance} data={pageData} {params} form={formData} />
|
|
433
459
|
{:else}
|
|
434
460
|
<p>Loading...</p>
|
|
435
461
|
{/if}
|
|
@@ -16,6 +16,38 @@ function buildTarget(path: string): { url: URL; params: Record<string, string> }
|
|
|
16
16
|
return { url, params: match?.params ?? {} };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// ─── Scroll restoration ───
|
|
20
|
+
// Positions keyed by a per-entry index stamped into `history.state.bosiaIndex`.
|
|
21
|
+
// `scrollRestoration = "manual"` because the browser restores at popstate time,
|
|
22
|
+
// before the SPA has rendered the destination page — it clamps against the
|
|
23
|
+
// wrong document height. We restore ourselves after the nav settles (App.svelte).
|
|
24
|
+
// Persisted to sessionStorage on unload so reload / back-into-app still restores.
|
|
25
|
+
const SCROLL_KEY = "bosia:scroll";
|
|
26
|
+
let scrollPositions: Record<number, { x: number; y: number }> = {};
|
|
27
|
+
let historyIndex = 0;
|
|
28
|
+
|
|
29
|
+
function saveScroll() {
|
|
30
|
+
scrollPositions[historyIndex] = { x: window.scrollX, y: window.scrollY };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ─── Page snapshots ───
|
|
34
|
+
// `export const snapshot = { capture, restore }` in +page.svelte. Captured
|
|
35
|
+
// alongside scroll on every history mutation, keyed by the same bosiaIndex,
|
|
36
|
+
// restored post-tick by App.svelte. Values must be JSON-serializable — raw in
|
|
37
|
+
// memory, stringified only at the unload flush.
|
|
38
|
+
// page-only; layout snapshots need per-depth instance refs — add if asked.
|
|
39
|
+
const SNAPSHOT_KEY = "bosia:snapshot";
|
|
40
|
+
let pageSnapshots: Record<number, unknown> = {};
|
|
41
|
+
|
|
42
|
+
function savePageSnapshot() {
|
|
43
|
+
try {
|
|
44
|
+
const s = router.getPageSnapshot?.();
|
|
45
|
+
if (s?.capture) pageSnapshots[historyIndex] = s.capture();
|
|
46
|
+
} catch {
|
|
47
|
+
// user capture() threw — never break navigation
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
export function scrollToHash(hash: string): boolean {
|
|
20
52
|
if (typeof document === "undefined" || !hash) return false;
|
|
21
53
|
const raw = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
@@ -45,6 +77,14 @@ export const router = new (class Router {
|
|
|
45
77
|
lastNavType: NavType = "enter";
|
|
46
78
|
/** Set by `goto({ noScroll: true })`; consumed once by App.svelte after the next nav settles. */
|
|
47
79
|
suppressScroll = false;
|
|
80
|
+
/** Saved scroll position for the entry a popstate landed on; consumed once by App.svelte after the nav settles. */
|
|
81
|
+
pendingScroll: { x: number; y: number } | null = null;
|
|
82
|
+
/** Set by App.svelte; returns the mounted page's `export const snapshot` (if any) at capture time. */
|
|
83
|
+
getPageSnapshot:
|
|
84
|
+
| (() => { capture: () => unknown; restore: (v: any) => void } | undefined)
|
|
85
|
+
| null = null;
|
|
86
|
+
/** Captured snapshot for the entry a popstate/reload landed on; consumed once by App.svelte. `undefined` = nothing to restore. */
|
|
87
|
+
pendingSnapshot: unknown = undefined;
|
|
48
88
|
|
|
49
89
|
navigate(path: string, opts: { replace?: boolean; source?: NavType } = {}) {
|
|
50
90
|
if (this.currentRoute === path) return;
|
|
@@ -74,12 +114,17 @@ export const router = new (class Router {
|
|
|
74
114
|
|
|
75
115
|
this.lastNavType = navType;
|
|
76
116
|
this.isPush = true;
|
|
117
|
+
this.pendingScroll = null;
|
|
118
|
+
this.pendingSnapshot = undefined;
|
|
77
119
|
this.currentRoute = finalPath;
|
|
78
120
|
if (typeof history !== "undefined") {
|
|
79
121
|
if (opts.replace) {
|
|
80
|
-
history.replaceState({}, "", finalPath);
|
|
122
|
+
history.replaceState({ bosiaIndex: historyIndex }, "", finalPath);
|
|
81
123
|
} else {
|
|
82
|
-
|
|
124
|
+
saveScroll();
|
|
125
|
+
savePageSnapshot();
|
|
126
|
+
historyIndex++;
|
|
127
|
+
history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
|
|
83
128
|
}
|
|
84
129
|
}
|
|
85
130
|
}
|
|
@@ -87,6 +132,28 @@ export const router = new (class Router {
|
|
|
87
132
|
init() {
|
|
88
133
|
if (typeof window === "undefined") return;
|
|
89
134
|
|
|
135
|
+
history.scrollRestoration = "manual";
|
|
136
|
+
try {
|
|
137
|
+
scrollPositions = JSON.parse(sessionStorage.getItem(SCROLL_KEY) ?? "{}");
|
|
138
|
+
} catch {
|
|
139
|
+
scrollPositions = {};
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
pageSnapshots = JSON.parse(sessionStorage.getItem(SNAPSHOT_KEY) ?? "{}");
|
|
143
|
+
} catch {
|
|
144
|
+
pageSnapshots = {};
|
|
145
|
+
}
|
|
146
|
+
const stamped = history.state?.bosiaIndex != null;
|
|
147
|
+
historyIndex = stamped ? history.state.bosiaIndex : 0;
|
|
148
|
+
this.pendingSnapshot = stamped ? pageSnapshots[historyIndex] : undefined;
|
|
149
|
+
history.replaceState({ ...history.state, bosiaIndex: historyIndex }, "");
|
|
150
|
+
// Restore after a reload — manual mode means the browser won't. Only for
|
|
151
|
+
// entries we stamped before (a fresh visit has no bosiaIndex in state).
|
|
152
|
+
const initial = scrollPositions[historyIndex];
|
|
153
|
+
if (stamped && initial) {
|
|
154
|
+
requestAnimationFrame(() => window.scrollTo(initial.x, initial.y));
|
|
155
|
+
}
|
|
156
|
+
|
|
90
157
|
// Intercept <a> clicks for client-side navigation
|
|
91
158
|
window.addEventListener("click", (e) => {
|
|
92
159
|
// Let browser handle non-primary buttons, modifier-clicks, already-handled events
|
|
@@ -110,7 +177,10 @@ export const router = new (class Router {
|
|
|
110
177
|
e.preventDefault();
|
|
111
178
|
const finalPath = anchor.pathname + anchor.search + anchor.hash;
|
|
112
179
|
if (this.currentRoute !== finalPath) {
|
|
113
|
-
|
|
180
|
+
saveScroll();
|
|
181
|
+
savePageSnapshot();
|
|
182
|
+
historyIndex++;
|
|
183
|
+
history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
|
|
114
184
|
this.currentRoute = finalPath;
|
|
115
185
|
}
|
|
116
186
|
scrollToHash(anchor.hash);
|
|
@@ -122,8 +192,15 @@ export const router = new (class Router {
|
|
|
122
192
|
});
|
|
123
193
|
|
|
124
194
|
// Browser back/forward
|
|
125
|
-
window.addEventListener("popstate", () => {
|
|
195
|
+
window.addEventListener("popstate", (e) => {
|
|
126
196
|
const finalPath = window.location.pathname + window.location.search + window.location.hash;
|
|
197
|
+
// Save the position of the entry we're leaving (historyIndex is still
|
|
198
|
+
// the old entry's), then look up where the landed-on entry was.
|
|
199
|
+
saveScroll();
|
|
200
|
+
savePageSnapshot();
|
|
201
|
+
historyIndex = e.state?.bosiaIndex ?? 0;
|
|
202
|
+
this.pendingScroll = scrollPositions[historyIndex] ?? null;
|
|
203
|
+
this.pendingSnapshot = pageSnapshots[historyIndex];
|
|
127
204
|
// Fire beforeNavigate listeners; popstate can't be reliably cancelled
|
|
128
205
|
// (browser history already advanced), so we surface the event for
|
|
129
206
|
// observation only — `cancel()` is a no-op for this source.
|
|
@@ -147,6 +224,21 @@ export const router = new (class Router {
|
|
|
147
224
|
// listeners can warn-on-leave (return value ignored; cancellation here
|
|
148
225
|
// requires `beforeunload`, not in scope).
|
|
149
226
|
window.addEventListener("beforeunload", () => {
|
|
227
|
+
// beforeunload can be skipped on mobile page-discard; add a
|
|
228
|
+
// visibilitychange persist if restore-after-reload proves flaky there.
|
|
229
|
+
saveScroll();
|
|
230
|
+
savePageSnapshot();
|
|
231
|
+
try {
|
|
232
|
+
sessionStorage.setItem(SCROLL_KEY, JSON.stringify(scrollPositions));
|
|
233
|
+
} catch {
|
|
234
|
+
// sessionStorage unavailable (private mode / quota) — skip persistence.
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
// Separate try: a non-JSON-serializable snapshot must not kill scroll persistence.
|
|
238
|
+
sessionStorage.setItem(SNAPSHOT_KEY, JSON.stringify(pageSnapshots));
|
|
239
|
+
} catch {
|
|
240
|
+
// sessionStorage unavailable or snapshot not serializable — skip persistence.
|
|
241
|
+
}
|
|
150
242
|
const fromTarget = buildTarget(this.currentRoute);
|
|
151
243
|
const nav: Navigation = {
|
|
152
244
|
from: fromTarget,
|
package/src/core/dev.ts
CHANGED
|
@@ -265,7 +265,7 @@ let buildPending = false;
|
|
|
265
265
|
async function buildAndRestart(): Promise<boolean> {
|
|
266
266
|
if (building) {
|
|
267
267
|
buildPending = true;
|
|
268
|
-
//
|
|
268
|
+
// coalesced into the in-flight build. Managed mode fires one
|
|
269
269
|
// rebuild per turn so overlap is rare; report optimistic rather than a
|
|
270
270
|
// false build failure to the host.
|
|
271
271
|
return true;
|
package/src/core/hooks.ts
CHANGED
|
@@ -63,8 +63,38 @@ export type LoadEvent = {
|
|
|
63
63
|
* namespaced (e.g. `"app:user"`).
|
|
64
64
|
*/
|
|
65
65
|
depends: (...keys: string[]) => void;
|
|
66
|
+
/**
|
|
67
|
+
* Set response headers for this request. Headers accumulate across
|
|
68
|
+
* layout and page loaders and land on both the SSR HTML response and
|
|
69
|
+
* the client-navigation data response. Setting the same header twice
|
|
70
|
+
* throws, as does `set-cookie` (use the `cookies` API instead).
|
|
71
|
+
* No-op during prerendering (only bodies are written to disk).
|
|
72
|
+
*/
|
|
73
|
+
setHeaders: (headers: Record<string, string>) => void;
|
|
66
74
|
};
|
|
67
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Build a `setHeaders` closure over a shared accumulator. Keys are stored
|
|
78
|
+
* lowercased; duplicates (any casing) and `set-cookie` throw. Shared across
|
|
79
|
+
* all loaders of one request so cross-loader duplicates are caught too.
|
|
80
|
+
*/
|
|
81
|
+
export function makeSetHeaders(
|
|
82
|
+
acc: Record<string, string>,
|
|
83
|
+
): (headers: Record<string, string>) => void {
|
|
84
|
+
return (headers) => {
|
|
85
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
86
|
+
const k = key.toLowerCase();
|
|
87
|
+
if (k === "set-cookie") {
|
|
88
|
+
throw new Error('setHeaders() cannot set "set-cookie" — use the cookies API instead');
|
|
89
|
+
}
|
|
90
|
+
if (k in acc) {
|
|
91
|
+
throw new Error(`setHeaders() called twice for header "${k}"`);
|
|
92
|
+
}
|
|
93
|
+
acc[k] = value;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
68
98
|
/**
|
|
69
99
|
* Tracked dependencies captured for a single loader during one run.
|
|
70
100
|
* Shipped to the client so subsequent client-side navigations can
|
package/src/core/html.ts
CHANGED
|
@@ -373,9 +373,11 @@ export function compress(
|
|
|
373
373
|
status = 200,
|
|
374
374
|
extraHeaders?: Record<string, string>,
|
|
375
375
|
): Response {
|
|
376
|
+
// Base keys lowercased so lowercased extraHeaders (e.g. loader setHeaders)
|
|
377
|
+
// override them instead of getting comma-joined by Headers.
|
|
376
378
|
const headers: Record<string, string> = {
|
|
377
|
-
"
|
|
378
|
-
|
|
379
|
+
"content-type": contentType,
|
|
380
|
+
vary: "Accept-Encoding",
|
|
379
381
|
...extraHeaders,
|
|
380
382
|
};
|
|
381
383
|
const accept = req.headers.get("accept-encoding") ?? "";
|
|
@@ -385,7 +387,7 @@ export function compress(
|
|
|
385
387
|
if (!isDev && bytes.length > GZIP_MIN_BYTES && accept.includes("gzip")) {
|
|
386
388
|
return new Response(Bun.gzipSync(bytes), {
|
|
387
389
|
status,
|
|
388
|
-
headers: { ...headers, "
|
|
390
|
+
headers: { ...headers, "content-encoding": "gzip" },
|
|
389
391
|
});
|
|
390
392
|
}
|
|
391
393
|
return new Response(bytes, { status, headers });
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { parse, compile } from "svelte/compiler";
|
|
2
2
|
import MagicString from "magic-string";
|
|
3
|
-
import {
|
|
3
|
+
import { relative } from "node:path";
|
|
4
4
|
import type { BunPlugin } from "bun";
|
|
5
5
|
import { svelteMapCache } from "../../svelteCompiler.ts";
|
|
6
6
|
import { lineColFromOffset } from "../../sourceLoc.ts";
|
|
7
7
|
|
|
8
|
-
const VIRTUAL_NS = "bosia-inspector-css";
|
|
9
|
-
|
|
10
8
|
type AnyNode = {
|
|
11
9
|
type?: string;
|
|
12
10
|
name?: string;
|
|
@@ -124,7 +122,6 @@ const fnv = (s: string): string => {
|
|
|
124
122
|
export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPlugin {
|
|
125
123
|
const { cwd, target, dev } = opts;
|
|
126
124
|
const generate: "client" | "server" = target === "browser" ? "client" : "server";
|
|
127
|
-
const virtualCss = new Map<string, string>();
|
|
128
125
|
|
|
129
126
|
return {
|
|
130
127
|
name: "bosia-inspector",
|
|
@@ -139,7 +136,11 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
|
|
|
139
136
|
generate,
|
|
140
137
|
dev,
|
|
141
138
|
hmr: dev,
|
|
142
|
-
|
|
139
|
+
// Mirror the prod compiler (svelteCompiler.ts): client injects scoped
|
|
140
|
+
// CSS into the JS via `append_styles`, server discards it. No CSS
|
|
141
|
+
// chunks means Bun's `splitting:true` output-path collisions can't
|
|
142
|
+
// arise, so no runtime-injection workaround is needed.
|
|
143
|
+
css: generate === "client" ? "injected" : "external",
|
|
143
144
|
preserveWhitespace: dev,
|
|
144
145
|
preserveComments: dev,
|
|
145
146
|
cssHash: ({ css }) => `svelte-${fnv(css)}`,
|
|
@@ -164,39 +165,9 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
|
|
|
164
165
|
svelteMapCache.set(args.path, m);
|
|
165
166
|
}
|
|
166
167
|
|
|
167
|
-
|
|
168
|
-
// Inject component <style> blocks via runtime JS, not CSS chunks.
|
|
169
|
-
// Bun's `splitting: true` names CSS chunks after the importing JS
|
|
170
|
-
// chunk's `[name]`, not the virtual module's uid — so when several
|
|
171
|
-
// routes (e.g. multiple `+page.svelte`) transitively import the same
|
|
172
|
-
// styled component, each route emits its own CSS sidecar named
|
|
173
|
-
// `+page-<contentHash>.css`. Identical content → identical hash →
|
|
174
|
-
// "Multiple files share the same output path". Runtime injection
|
|
175
|
-
// avoids CSS chunking entirely.
|
|
176
|
-
if (result.css?.code?.trim() && generate !== "server") {
|
|
177
|
-
const safeBase = basename(args.path).replace(/\./g, "_");
|
|
178
|
-
const uid = `${safeBase}-${fnv(args.path)}-style.css`;
|
|
179
|
-
const virtualName = `${VIRTUAL_NS}:${uid}`;
|
|
180
|
-
virtualCss.set(virtualName, result.css.code);
|
|
181
|
-
js += `\nimport ${JSON.stringify(virtualName)};`;
|
|
182
|
-
}
|
|
183
|
-
|
|
168
|
+
const js = dev ? fixBindShadow(result.js.code) : result.js.code;
|
|
184
169
|
return { contents: js, loader: "ts" };
|
|
185
170
|
});
|
|
186
|
-
|
|
187
|
-
build.onResolve({ filter: new RegExp(`^${VIRTUAL_NS}:`) }, (args) => ({
|
|
188
|
-
path: args.path,
|
|
189
|
-
namespace: VIRTUAL_NS,
|
|
190
|
-
}));
|
|
191
|
-
|
|
192
|
-
build.onLoad({ filter: /.*/, namespace: VIRTUAL_NS }, (args) => {
|
|
193
|
-
const css = virtualCss.get(args.path) ?? "";
|
|
194
|
-
virtualCss.delete(args.path);
|
|
195
|
-
const contents = css
|
|
196
|
-
? `(()=>{const s=document.createElement('style');s.textContent=${JSON.stringify(css)};document.head.appendChild(s);})();`
|
|
197
|
-
: "";
|
|
198
|
-
return { contents, loader: "js" };
|
|
199
|
-
});
|
|
200
171
|
},
|
|
201
172
|
};
|
|
202
173
|
}
|
package/src/core/renderer.ts
CHANGED
|
@@ -3,6 +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 { makeSetHeaders } from "./hooks.ts";
|
|
6
7
|
import type { Cookies, LoaderDeps } from "./hooks.ts";
|
|
7
8
|
import { CSP_ENABLED } from "./csp.ts";
|
|
8
9
|
import {
|
|
@@ -333,6 +334,10 @@ export async function loadRouteData(
|
|
|
333
334
|
const layoutData: (Record<string, any> | null)[] = [];
|
|
334
335
|
const layoutDeps: (LoaderDeps | null)[] = [];
|
|
335
336
|
let parentData: Record<string, any> = {};
|
|
337
|
+
// Shared across layout + page loaders so cross-loader duplicate
|
|
338
|
+
// setHeaders() calls throw. Keys stored lowercased.
|
|
339
|
+
const loaderHeaders: Record<string, string> = {};
|
|
340
|
+
const setHeaders = makeSetHeaders(loaderHeaders);
|
|
336
341
|
|
|
337
342
|
// Run layout server loaders root → leaf, each gets parent() data
|
|
338
343
|
for (const ls of route.layoutServers) {
|
|
@@ -366,6 +371,7 @@ export async function loadRouteData(
|
|
|
366
371
|
fetch: trackedFetch(fetch, origin, deps),
|
|
367
372
|
metadata: null,
|
|
368
373
|
depends: makeDepends(deps),
|
|
374
|
+
setHeaders,
|
|
369
375
|
}),
|
|
370
376
|
LOAD_TIMEOUT,
|
|
371
377
|
`layout load (depth=${ls.depth}, ${url.pathname})`,
|
|
@@ -431,6 +437,7 @@ export async function loadRouteData(
|
|
|
431
437
|
fetch: trackedFetch(fetch, origin, deps),
|
|
432
438
|
metadata: metadataData,
|
|
433
439
|
depends: makeDepends(deps),
|
|
440
|
+
setHeaders,
|
|
434
441
|
}),
|
|
435
442
|
LOAD_TIMEOUT,
|
|
436
443
|
`page load (${url.pathname})`,
|
|
@@ -472,7 +479,7 @@ export async function loadRouteData(
|
|
|
472
479
|
// When pageData is skipped, the client merges its cached pageData with current
|
|
473
480
|
// route params separately, so we keep the `null` sentinel here.
|
|
474
481
|
const pageOut = pageData === null ? null : { ...pageData, params };
|
|
475
|
-
return { pageData: pageOut, layoutData, csr, ssr, pageDeps, layoutDeps };
|
|
482
|
+
return { pageData: pageOut, layoutData, csr, ssr, pageDeps, layoutDeps, loaderHeaders };
|
|
476
483
|
}
|
|
477
484
|
|
|
478
485
|
// ─── Metadata Loader ─────────────────────────────────────
|
|
@@ -690,7 +697,7 @@ export async function renderSSRStream(
|
|
|
690
697
|
appHtmlSegments,
|
|
691
698
|
);
|
|
692
699
|
return new Response(html, {
|
|
693
|
-
headers: { "
|
|
700
|
+
headers: { "content-type": "text/html; charset=utf-8", ...data.loaderHeaders },
|
|
694
701
|
});
|
|
695
702
|
}
|
|
696
703
|
|
|
@@ -773,7 +780,7 @@ export async function renderSSRStream(
|
|
|
773
780
|
brotli,
|
|
774
781
|
contentType: "text/html; charset=utf-8",
|
|
775
782
|
status: 200,
|
|
776
|
-
extraHeaders:
|
|
783
|
+
extraHeaders: data.loaderHeaders,
|
|
777
784
|
tags,
|
|
778
785
|
},
|
|
779
786
|
cookies,
|
|
@@ -812,7 +819,7 @@ export async function renderSSRStream(
|
|
|
812
819
|
});
|
|
813
820
|
|
|
814
821
|
return new Response(stream, {
|
|
815
|
-
headers: { "
|
|
822
|
+
headers: { "content-type": "text/html; charset=utf-8", ...data.loaderHeaders },
|
|
816
823
|
});
|
|
817
824
|
} finally {
|
|
818
825
|
releaseMiss?.();
|
|
@@ -894,7 +901,7 @@ export async function renderPageWithFormData(
|
|
|
894
901
|
undefined,
|
|
895
902
|
appHtmlSegments,
|
|
896
903
|
);
|
|
897
|
-
return compress(html, "text/html; charset=utf-8", req, status);
|
|
904
|
+
return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
|
|
898
905
|
}
|
|
899
906
|
|
|
900
907
|
const { body, head } = render(App, {
|
|
@@ -923,7 +930,7 @@ export async function renderPageWithFormData(
|
|
|
923
930
|
undefined,
|
|
924
931
|
appHtmlSegments,
|
|
925
932
|
);
|
|
926
|
-
return compress(html, "text/html; charset=utf-8", req, status);
|
|
933
|
+
return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
|
|
927
934
|
}
|
|
928
935
|
|
|
929
936
|
// ─── Error Page Renderer ──────────────────────────────────
|
package/src/core/routeTypes.ts
CHANGED
|
@@ -86,6 +86,14 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
86
86
|
lines.push(` * or +server.ts to opt the route out of the server response cache. */`);
|
|
87
87
|
lines.push(`export type CacheOption = false;`);
|
|
88
88
|
lines.push(``);
|
|
89
|
+
lines.push(
|
|
90
|
+
`/** \`export const snapshot: Snapshot<T>\` in +page.svelte — captured on nav-away,`,
|
|
91
|
+
);
|
|
92
|
+
lines.push(` * restored on back/forward. Values must be JSON-serializable. */`);
|
|
93
|
+
lines.push(
|
|
94
|
+
`export type Snapshot<T = any> = { capture: () => T; restore: (snapshot: T) => void };`,
|
|
95
|
+
);
|
|
96
|
+
lines.push(``);
|
|
89
97
|
lines.push(`type _LoadEvent = Omit<LoadEvent, 'params'> & { params: Params };`);
|
|
90
98
|
lines.push(`type _MetadataEvent = Omit<MetadataEvent, 'params'> & { params: Params };`);
|
|
91
99
|
lines.push(`type _RequestEvent = Omit<RequestEvent, 'params'> & { params: Params };`);
|
package/src/core/server.ts
CHANGED
|
@@ -314,12 +314,18 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
314
314
|
{ "Cache-Control": cc },
|
|
315
315
|
);
|
|
316
316
|
}
|
|
317
|
+
// loaderHeaders must not leak into the JSON body the client router consumes.
|
|
318
|
+
const { loaderHeaders = {}, ...payload } = result.data;
|
|
319
|
+
const extra: Record<string, string> = { "cache-control": cc, ...loaderHeaders };
|
|
320
|
+
// Privacy beats intent: cookie-derived responses stay private even if
|
|
321
|
+
// a loader set its own cache-control.
|
|
322
|
+
if (cookiesWereAccessed) extra["cache-control"] = cc;
|
|
317
323
|
return compress(
|
|
318
|
-
JSON.stringify({ ...
|
|
324
|
+
JSON.stringify({ ...payload, metadata: result.metadata }),
|
|
319
325
|
"application/json",
|
|
320
326
|
request,
|
|
321
327
|
200,
|
|
322
|
-
|
|
328
|
+
extra,
|
|
323
329
|
);
|
|
324
330
|
} catch (err) {
|
|
325
331
|
if (err instanceof Redirect) {
|
package/src/lib/client.ts
CHANGED
|
@@ -18,4 +18,7 @@ export {
|
|
|
18
18
|
invalidateAll,
|
|
19
19
|
} from "../core/client/navigation.ts";
|
|
20
20
|
export type { GotoOptions, Navigation, NavigationTarget } from "../core/client/navigation.ts";
|
|
21
|
+
/** `export const snapshot: Snapshot<T>` in +page.svelte — captured on nav-away,
|
|
22
|
+
* restored on back/forward. Values must be JSON-serializable. */
|
|
23
|
+
export type Snapshot<T = any> = { capture: () => T; restore: (snapshot: T) => void };
|
|
21
24
|
export { page } from "../core/client/page.svelte.ts";
|