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 +1 -1
- package/src/core/client/App.svelte +23 -12
- package/src/core/client/hydrate.ts +0 -1
- package/src/core/client/page.svelte.ts +5 -4
- package/src/core/client/router.svelte.ts +3 -1
- package/src/core/html.ts +8 -3
- package/src/core/renderer.ts +115 -29
- package/src/core/server.ts +10 -1
package/package.json
CHANGED
|
@@ -394,18 +394,29 @@
|
|
|
394
394
|
|
|
395
395
|
settleScroll();
|
|
396
396
|
|
|
397
|
-
//
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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 });
|
|
@@ -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/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;
|
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,
|
package/src/core/server.ts
CHANGED
|
@@ -303,7 +303,16 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
303
303
|
cookies,
|
|
304
304
|
request,
|
|
305
305
|
);
|
|
306
|
-
|
|
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
|
}
|