bosia 0.9.2 → 0.9.4

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.9.2",
3
+ "version": "0.9.4",
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": [
@@ -394,18 +394,29 @@
394
394
 
395
395
  settleScroll();
396
396
 
397
- // Update document title and meta description from server metadata
398
- if (result?.metadata) {
399
- if (result.metadata.title) document.title = result.metadata.title;
400
- if (result.metadata.description) {
401
- let meta = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
402
- if (!meta) {
403
- meta = document.createElement("meta");
404
- meta.name = "description";
405
- document.head.appendChild(meta);
406
- }
407
- meta.content = result.metadata.description;
408
- }
397
+ // Re-emit the head from server metadata. `data-bosia-meta` (html.ts `OWNED`)
398
+ // marks exactly the tags metadata() owns, so headExtras, the framework's own
399
+ // static tags and <svelte:head> output are never touched. `result === null`
400
+ // with server data means the fetch failed — leave the head as it is; without
401
+ // server data there is no metadata() at all, so clearing matches SSR.
402
+ if (result || !match.route.hasServerData) {
403
+ const md = result?.metadata;
404
+ document.querySelectorAll("[data-bosia-meta]").forEach((el) => el.remove());
405
+ // A page that declares no title keeps the previous one rather than flashing
406
+ // the "Bosia App" fallback. Give metadata() a title if that matters.
407
+ if (md?.title) document.title = md.title;
408
+ if (md?.lang) document.documentElement.lang = md.lang;
409
+ const emit = (tag: "meta" | "link", attrs: Record<string, string | undefined>) => {
410
+ const el = document.createElement(tag);
411
+ for (const [k, v] of Object.entries(attrs)) if (v != null) el.setAttribute(k, v);
412
+ el.setAttribute("data-bosia-meta", "");
413
+ document.head.appendChild(el);
414
+ };
415
+ if (md?.description) emit("meta", { name: "description", content: md.description });
416
+ for (const m of md?.meta ?? [])
417
+ emit("meta", { name: m.name, property: m.property, content: m.content });
418
+ for (const l of md?.link ?? [])
419
+ emit("link", { rel: l.rel, href: l.href, hreflang: l.hreflang });
409
420
  }
410
421
 
411
422
  settle({ url, params: match.params });
@@ -59,7 +59,6 @@ async function main() {
59
59
  ]);
60
60
  ssrPageComponent = pageMod.default;
61
61
  ssrLayoutComponents = layoutMods.map((m) => m.default);
62
- router.params = match.params;
63
62
  }
64
63
 
65
64
  const ssrPageData = readJsonScript<Record<string, any>>("__bosia-page-data__") ?? {};
@@ -17,10 +17,11 @@ import { router } from "./router.svelte.ts";
17
17
  let paramsWarned = false;
18
18
 
19
19
  class Page {
20
- #url = $derived.by(() => {
21
- if (typeof window === "undefined") return new URL("http://localhost/");
22
- return new URL(router.currentRoute, window.location.origin);
23
- });
20
+ // Real on the server too: the renderer seeds `router.currentRoute`/`.origin`
21
+ // from the request immediately before each render (see `renderWithPageContext`
22
+ // in core/renderer.ts). The `localhost` fallback is now reachable only from a
23
+ // render that seeded nothing at all — a bare unit test, never a served request.
24
+ #url = $derived.by(() => new URL(router.currentRoute, router.origin || "http://localhost/"));
24
25
 
25
26
  get url() {
26
27
  return this.#url;
@@ -77,7 +77,9 @@ export const router = new (class Router {
77
77
  ? window.location.pathname + window.location.search + window.location.hash
78
78
  : "/",
79
79
  );
80
- params = $state<Record<string, string>>({});
80
+ /** Origin half of `page.url`. Empty on the server until the renderer seeds it
81
+ * per request — see `renderWithPageContext` in core/renderer.ts. */
82
+ origin = $state(typeof window !== "undefined" ? window.location.origin : "");
81
83
  /** True when navigation was triggered by a link click / navigate() call, false on popstate (back/forward). */
82
84
  isPush = $state(true);
83
85
  /** Source of the most recent navigation — feeds the Navigation object passed to lifecycle hooks. */
package/src/core/html.ts CHANGED
@@ -287,6 +287,11 @@ const SPINNER =
287
287
  `border-radius:50%;animation:__bs__ .8s linear infinite}` +
288
288
  `@keyframes __bs__{to{transform:rotate(360deg)}}</style><i></i></div>`;
289
289
 
290
+ /** Marks the tags `metadata()` owns, so the client router can replace exactly
291
+ * these on navigation and leave `headExtras`, the framework's own static tags
292
+ * and `<svelte:head>` output alone. Read by `client/App.svelte`. */
293
+ export const OWNED = "data-bosia-meta";
294
+
290
295
  /** The `metadata()` tags themselves, indented head-ready. Shared by the streaming
291
296
  * path (buildMetadataChunk) and the non-streaming one (buildHtml) so the two
292
297
  * renderers cannot drift on what `metadata()` emits. */
@@ -295,21 +300,21 @@ export function metadataTags(metadata: Metadata | null): string {
295
300
  let out = "";
296
301
  if (metadata.title) out += ` <title>${escapeHtml(metadata.title)}</title>\n`;
297
302
  if (metadata.description) {
298
- out += ` <meta name="description" content="${escapeAttr(metadata.description)}">\n`;
303
+ out += ` <meta name="description" content="${escapeAttr(metadata.description)}" ${OWNED}>\n`;
299
304
  }
300
305
  if (metadata.meta) {
301
306
  for (const m of metadata.meta) {
302
307
  const attrs = m.name
303
308
  ? `name="${escapeAttr(m.name)}"`
304
309
  : `property="${escapeAttr(m.property ?? "")}"`;
305
- out += ` <meta ${attrs} content="${escapeAttr(m.content)}">\n`;
310
+ out += ` <meta ${attrs} content="${escapeAttr(m.content)}" ${OWNED}>\n`;
306
311
  }
307
312
  }
308
313
  if (metadata.link) {
309
314
  for (const l of metadata.link) {
310
315
  let attrs = `rel="${escapeAttr(l.rel)}" href="${escapeAttr(l.href)}"`;
311
316
  if (l.hreflang) attrs += ` hreflang="${escapeAttr(l.hreflang)}"`;
312
- out += ` <link ${attrs}>\n`;
317
+ out += ` <link ${attrs} ${OWNED}>\n`;
313
318
  }
314
319
  }
315
320
  return out;
@@ -22,6 +22,10 @@ import type { CookieJar } from "./cookies.ts";
22
22
  import { HttpError, Redirect } from "./errors.ts";
23
23
  import { pickErrorPage, type ErrorOrigin } from "./errorMatch.ts";
24
24
  import App from "./client/App.svelte";
25
+ import { router } from "./client/router.svelte.ts";
26
+ import { appState } from "./client/appState.svelte.ts";
27
+ import { withBase } from "./basePath.ts";
28
+ import { currentBase } from "./appBase.ts";
25
29
  import {
26
30
  buildHtml,
27
31
  buildHtmlShellOpen,
@@ -41,6 +45,64 @@ import type { AppHtmlSegments } from "./appHtml.ts";
41
45
  // Shared, stateless — one instance instead of a fresh allocation per stream.
42
46
  const enc = new TextEncoder();
43
47
 
48
+ // ─── Per-request seed for `page` ──────────────────────────
49
+ // `page` (bosia/client) is imported directly by user components, so per-request
50
+ // values cannot reach it as props the way pageData/layoutData do — see the rule
51
+ // stated in App.svelte and appState.svelte.ts. The singletons it reads from are
52
+ // therefore seeded per request, which is a deliberate exception to that rule.
53
+ //
54
+ // This is safe only because bosia consumes render() synchronously: every call
55
+ // site destructures `{ body, head }` straight off the return value. That is
56
+ // bosia's own commitment, not svelte's guarantee — the declared type is
57
+ // `SyncRenderOutput & PromiseLike<SyncRenderOutput>` and svelte's server
58
+ // renderer has an await path. If async SSR is ever enabled, the existing
59
+ // destructuring and this seeding break together, and must be fixed together.
60
+ // Seeding lives in this wrapper so that stays true by construction: nothing can
61
+ // assign here and then await before rendering, which would make two concurrent
62
+ // requests render each other's URLs — a bug that reads as a flaky cache.
63
+ //
64
+ // `url` is app-space (server.ts strips BASE_PATH exactly once, at the top of
65
+ // handleRequest), but every value the client half writes to these fields is
66
+ // browser-space: hydrate.ts assigns raw window.location.pathname, and
67
+ // clientRoutes are generated with the prefix already in them. So the base goes
68
+ // back on before seeding, or a mounted app server-renders `/admin/audit` and
69
+ // hydration flips it to `/sso/admin/audit`.
70
+ //
71
+ // Assigning unconditionally is the point. Seeding only the success paths would
72
+ // let an error render inherit the previous request's URL — real cross-request
73
+ // leakage, strictly worse than a wrong-but-deterministic default.
74
+ //
75
+ // Two limits worth knowing before leaning on this:
76
+ //
77
+ // 1. The seed lands here, at render time — after every `load()` and
78
+ // `metadata()` has already run. `page` still holds the previous request's
79
+ // values throughout those, and the only reason that is not a live bug is
80
+ // that they live in `+page.server.ts` / `+layout.server.ts`, where
81
+ // `bosia/client` is not importable (see the header of lib/client.ts). Server
82
+ // loaders get the real URL as the `url` argument; they must never read
83
+ // `page`.
84
+ // 2. `url.origin` is the public origin only when TRUST_PROXY=true —
85
+ // server.ts rebuilds host/proto from X-Forwarded-* under that flag alone,
86
+ // because the headers are client-spoofable otherwise. Behind an untrusted
87
+ // proxy the seeded origin is the inner hop (http://localhost:PORT) while the
88
+ // browser hydrates with the public https:// origin, so `page.url.origin`
89
+ // disagrees across the boundary. `page.url.pathname` is unaffected.
90
+ //
91
+ // `render`'s own signature is conditional on the component's prop type, so it
92
+ // collapses to `never` behind a generic wrapper — hence `any` on both, matching
93
+ // how the call sites already type `pageMod.default` / `layoutMods`.
94
+ function renderWithPageContext(
95
+ component: any,
96
+ options: { props?: Record<string, any> },
97
+ url: URL,
98
+ params: Record<string, string> = {},
99
+ ) {
100
+ router.origin = url.origin;
101
+ router.currentRoute = withBase(currentBase(), url.pathname) + url.search + url.hash;
102
+ appState.routeParams = params;
103
+ return render(component, options as any);
104
+ }
105
+
44
106
  // Plugins are loaded once per process at module init via top-level await elsewhere
45
107
  // (server.ts), but renderer is also reachable from build/prerender contexts where
46
108
  // loadPlugins() may not have been called yet. The function is cached, so awaiting
@@ -725,15 +787,20 @@ export async function renderSSRStream(
725
787
  // with correct status code, instead of a bare <p> mixed into an already-flushed shell.
726
788
  let body: string, head: string;
727
789
  try {
728
- ({ body, head } = render(App, {
729
- props: {
730
- ssrMode: true,
731
- ssrPageComponent: pageMod.default,
732
- ssrLayoutComponents: layoutMods.map((m: any) => m.default),
733
- ssrPageData: pageDataFull,
734
- ssrLayoutData: layoutDataFull,
790
+ ({ body, head } = renderWithPageContext(
791
+ App,
792
+ {
793
+ props: {
794
+ ssrMode: true,
795
+ ssrPageComponent: pageMod.default,
796
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
797
+ ssrPageData: pageDataFull,
798
+ ssrLayoutData: layoutDataFull,
799
+ },
735
800
  },
736
- }));
801
+ url,
802
+ params,
803
+ ));
737
804
  } catch (err) {
738
805
  if (isDev) console.error("SSR render error:", err);
739
806
  else console.error("SSR render error:", (err as Error).message ?? err);
@@ -969,16 +1036,21 @@ export async function renderPageWithFormData(
969
1036
  return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
970
1037
  }
971
1038
 
972
- const { body, head } = render(App, {
973
- props: {
974
- ssrMode: true,
975
- ssrPageComponent: pageMod.default,
976
- ssrLayoutComponents: layoutMods.map((m: any) => m.default),
977
- ssrPageData: pageDataFull,
978
- ssrLayoutData: layoutDataFull,
979
- ssrFormData: formData,
1039
+ const { body, head } = renderWithPageContext(
1040
+ App,
1041
+ {
1042
+ props: {
1043
+ ssrMode: true,
1044
+ ssrPageComponent: pageMod.default,
1045
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
1046
+ ssrPageData: pageDataFull,
1047
+ ssrLayoutData: layoutDataFull,
1048
+ ssrFormData: formData,
1049
+ },
980
1050
  },
981
- });
1051
+ url,
1052
+ params,
1053
+ );
982
1054
 
983
1055
  const html = buildHtml(
984
1056
  body,
@@ -1038,6 +1110,12 @@ export async function renderErrorPage(
1038
1110
  };
1039
1111
  const bodyEndExtras = await pluginRenderFragments("bodyEnd", renderCtx);
1040
1112
 
1113
+ // The failing route's own params, so a nested +error.svelte under
1114
+ // /blog/[slug] still sees its `slug`. A 404 matches nothing and yields {},
1115
+ // which is also what has to be seeded rather than left holding the previous
1116
+ // request's values.
1117
+ const errorParams = findMatch(serverRoutes, url.pathname)?.params ?? {};
1118
+
1041
1119
  // 1. Nested boundary
1042
1120
  if (route && errorDepth !== undefined && route.errorPages?.length) {
1043
1121
  const origin = errorOrigin ?? "page";
@@ -1055,16 +1133,21 @@ export async function renderErrorPage(
1055
1133
  ]);
1056
1134
  const layoutData: Record<string, any>[] = [];
1057
1135
  for (let i = 0; i < K; i++) layoutData.push(partialLayoutData?.[i] ?? {});
1058
- const { body, head } = render(App, {
1059
- props: {
1060
- ssrMode: true,
1061
- ssrLayoutComponents: layoutMods.map((m: any) => m.default),
1062
- ssrLayoutData: layoutData,
1063
- ssrErrorComponent: errorMod.default,
1064
- ssrErrorProps: { error: { status, message } },
1065
- ssrErrorDepth: K,
1136
+ const { body, head } = renderWithPageContext(
1137
+ App,
1138
+ {
1139
+ props: {
1140
+ ssrMode: true,
1141
+ ssrLayoutComponents: layoutMods.map((m: any) => m.default),
1142
+ ssrLayoutData: layoutData,
1143
+ ssrErrorComponent: errorMod.default,
1144
+ ssrErrorProps: { error: { status, message } },
1145
+ ssrErrorDepth: K,
1146
+ },
1066
1147
  },
1067
- });
1148
+ url,
1149
+ errorParams,
1150
+ );
1068
1151
  // csr=false: no client hydration on the error page itself.
1069
1152
  const html = buildHtml(
1070
1153
  body,
@@ -1099,9 +1182,12 @@ export async function renderErrorPage(
1099
1182
  // Render the error component directly — NOT through App.svelte.
1100
1183
  // App.svelte remaps ssrPageData to a `data` prop, but +error.svelte
1101
1184
  // expects `error` as a direct prop: `let { error } = $props()`.
1102
- const { body, head } = render(mod.default, {
1103
- props: { error: { status, message } },
1104
- });
1185
+ const { body, head } = renderWithPageContext(
1186
+ mod.default,
1187
+ { props: { error: { status, message } } },
1188
+ url,
1189
+ errorParams,
1190
+ );
1105
1191
  const html = buildHtml(
1106
1192
  body,
1107
1193
  head,
@@ -303,7 +303,16 @@ async function resolve(event: RequestEvent): Promise<Response> {
303
303
  cookies,
304
304
  request,
305
305
  );
306
- if (meta) metadata = { title: meta.title, description: meta.description };
306
+ // Explicit whitelist, not a spread: `metadata.data` feeds load() on the
307
+ // server and may hold secrets — it must not reach the client.
308
+ if (meta)
309
+ metadata = {
310
+ title: meta.title,
311
+ description: meta.description,
312
+ meta: meta.meta,
313
+ link: meta.link,
314
+ lang: meta.lang,
315
+ };
307
316
  } catch {
308
317
  /* non-fatal */
309
318
  }