bosia 0.9.3 → 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.3",
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": [
@@ -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. */
@@ -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,