bosia 0.8.6 → 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.6",
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.
@@ -75,12 +93,13 @@
75
93
  });
76
94
  } else if (!router.isPush) {
77
95
  const pos = router.pendingScroll;
78
- // ponytail: single post-tick restore; content that loads later (images
96
+ // single post-tick restore; content that loads later (images
79
97
  // without dimensions) can still shift it. Revisit only if reported.
80
98
  if (pos) tick().then(() => window.scrollTo(pos.x, pos.y));
81
99
  }
82
100
  router.pendingScroll = null;
83
101
  router.suppressScroll = false;
102
+ settleSnapshot();
84
103
  }
85
104
 
86
105
  $effect(() => {
@@ -102,6 +121,8 @@
102
121
  if (isFirst) {
103
122
  currentLayoutPaths = (match.route as any).layoutPaths ?? [];
104
123
  lastSettledPath = pathname;
124
+ // Restore-after-reload: router.init() staged this entry's snapshot.
125
+ settleSnapshot();
105
126
  return; // Initial hydration — data already in SSR props, no fetch needed
106
127
  }
107
128
 
@@ -404,7 +425,7 @@
404
425
  {:else if layoutComponents.length > 0}
405
426
  {@render renderLayout(0, layoutComponents.length)}
406
427
  {:else if PageComponent}
407
- <PageComponent data={pageData} {params} form={formData} />
428
+ <PageComponent bind:this={pageInstance} data={pageData} {params} form={formData} />
408
429
  {:else}
409
430
  <p>Loading...</p>
410
431
  {/if}
@@ -434,7 +455,7 @@
434
455
  {#if ErrorComponent}
435
456
  <ErrorComponent {...errorProps ?? {}} />
436
457
  {:else if PageComponent}
437
- <PageComponent data={pageData} {params} form={formData} />
458
+ <PageComponent bind:this={pageInstance} data={pageData} {params} form={formData} />
438
459
  {:else}
439
460
  <p>Loading...</p>
440
461
  {/if}
@@ -30,6 +30,24 @@ function saveScroll() {
30
30
  scrollPositions[historyIndex] = { x: window.scrollX, y: window.scrollY };
31
31
  }
32
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
+
33
51
  export function scrollToHash(hash: string): boolean {
34
52
  if (typeof document === "undefined" || !hash) return false;
35
53
  const raw = hash.startsWith("#") ? hash.slice(1) : hash;
@@ -61,6 +79,12 @@ export const router = new (class Router {
61
79
  suppressScroll = false;
62
80
  /** Saved scroll position for the entry a popstate landed on; consumed once by App.svelte after the nav settles. */
63
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;
64
88
 
65
89
  navigate(path: string, opts: { replace?: boolean; source?: NavType } = {}) {
66
90
  if (this.currentRoute === path) return;
@@ -91,12 +115,14 @@ export const router = new (class Router {
91
115
  this.lastNavType = navType;
92
116
  this.isPush = true;
93
117
  this.pendingScroll = null;
118
+ this.pendingSnapshot = undefined;
94
119
  this.currentRoute = finalPath;
95
120
  if (typeof history !== "undefined") {
96
121
  if (opts.replace) {
97
122
  history.replaceState({ bosiaIndex: historyIndex }, "", finalPath);
98
123
  } else {
99
124
  saveScroll();
125
+ savePageSnapshot();
100
126
  historyIndex++;
101
127
  history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
102
128
  }
@@ -112,8 +138,14 @@ export const router = new (class Router {
112
138
  } catch {
113
139
  scrollPositions = {};
114
140
  }
141
+ try {
142
+ pageSnapshots = JSON.parse(sessionStorage.getItem(SNAPSHOT_KEY) ?? "{}");
143
+ } catch {
144
+ pageSnapshots = {};
145
+ }
115
146
  const stamped = history.state?.bosiaIndex != null;
116
147
  historyIndex = stamped ? history.state.bosiaIndex : 0;
148
+ this.pendingSnapshot = stamped ? pageSnapshots[historyIndex] : undefined;
117
149
  history.replaceState({ ...history.state, bosiaIndex: historyIndex }, "");
118
150
  // Restore after a reload — manual mode means the browser won't. Only for
119
151
  // entries we stamped before (a fresh visit has no bosiaIndex in state).
@@ -146,6 +178,7 @@ export const router = new (class Router {
146
178
  const finalPath = anchor.pathname + anchor.search + anchor.hash;
147
179
  if (this.currentRoute !== finalPath) {
148
180
  saveScroll();
181
+ savePageSnapshot();
149
182
  historyIndex++;
150
183
  history.pushState({ bosiaIndex: historyIndex }, "", finalPath);
151
184
  this.currentRoute = finalPath;
@@ -164,8 +197,10 @@ export const router = new (class Router {
164
197
  // Save the position of the entry we're leaving (historyIndex is still
165
198
  // the old entry's), then look up where the landed-on entry was.
166
199
  saveScroll();
200
+ savePageSnapshot();
167
201
  historyIndex = e.state?.bosiaIndex ?? 0;
168
202
  this.pendingScroll = scrollPositions[historyIndex] ?? null;
203
+ this.pendingSnapshot = pageSnapshots[historyIndex];
169
204
  // Fire beforeNavigate listeners; popstate can't be reliably cancelled
170
205
  // (browser history already advanced), so we surface the event for
171
206
  // observation only — `cancel()` is a no-op for this source.
@@ -189,14 +224,21 @@ export const router = new (class Router {
189
224
  // listeners can warn-on-leave (return value ignored; cancellation here
190
225
  // requires `beforeunload`, not in scope).
191
226
  window.addEventListener("beforeunload", () => {
192
- // ponytail: beforeunload can be skipped on mobile page-discard; add a
227
+ // beforeunload can be skipped on mobile page-discard; add a
193
228
  // visibilitychange persist if restore-after-reload proves flaky there.
194
229
  saveScroll();
230
+ savePageSnapshot();
195
231
  try {
196
232
  sessionStorage.setItem(SCROLL_KEY, JSON.stringify(scrollPositions));
197
233
  } catch {
198
234
  // sessionStorage unavailable (private mode / quota) — skip persistence.
199
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
+ }
200
242
  const fromTarget = buildTarget(this.currentRoute);
201
243
  const nav: Navigation = {
202
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 });
@@ -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";