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
|
@@ -17,10 +17,11 @@ import { router } from "./router.svelte.ts";
|
|
|
17
17
|
let paramsWarned = false;
|
|
18
18
|
|
|
19
19
|
class Page {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
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/renderer.ts
CHANGED
|
@@ -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 } =
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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 } =
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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 } =
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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 } =
|
|
1103
|
-
|
|
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,
|