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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.8.5",
3
+ "version": "0.8.7",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
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
- "Content-Type": entry.contentType,
355
- Vary: "Accept-Encoding",
356
- "X-Bosia-Cache": "HIT",
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["Content-Encoding"] = "br";
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["Content-Encoding"] = "gzip";
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
- if (router.isPush && !router.suppressScroll) {
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
- // Scroll to top on forward navigation (not on popstate/back-forward).
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
- history.pushState({}, "", finalPath);
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
- history.pushState({}, "", finalPath);
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
- // ponytail: coalesced into the in-flight build. Managed mode fires one
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
- "Content-Type": contentType,
378
- Vary: "Accept-Encoding",
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, "Content-Encoding": "gzip" },
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 { basename, relative } from "node:path";
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
- css: "external",
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
- let js = dev ? fixBindShadow(result.js.code) : result.js.code;
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
  }
@@ -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: { "Content-Type": "text/html; charset=utf-8" },
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: { "Content-Type": "text/html; charset=utf-8" },
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 ──────────────────────────────────
@@ -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 };`);
@@ -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({ ...result.data, metadata: result.metadata }),
324
+ JSON.stringify({ ...payload, metadata: result.metadata }),
319
325
  "application/json",
320
326
  request,
321
327
  200,
322
- { "Cache-Control": cc },
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";