bosia 0.9.3 → 0.9.5
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 +10 -2
- package/src/core/client/hydrate.ts +0 -1
- package/src/core/client/page.svelte.ts +5 -4
- package/src/core/client/prefetch.ts +49 -1
- package/src/core/client/router.svelte.ts +3 -1
- package/src/core/errors.ts +24 -0
- package/src/core/hooks.ts +20 -0
- package/src/core/renderer.ts +127 -41
- package/src/core/server.ts +150 -37
package/package.json
CHANGED
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
import { router, scrollToHash } from "./router.svelte.ts";
|
|
4
4
|
import { findMatch } from "../matcher.ts";
|
|
5
5
|
import { clientRoutes } from "bosia:routes";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
consumePrefetch,
|
|
8
|
+
prefetchCache,
|
|
9
|
+
dataUrl,
|
|
10
|
+
buildParentSnapshots,
|
|
11
|
+
readDataResponse,
|
|
12
|
+
} from "./prefetch.ts";
|
|
7
13
|
import { appState, clearDirty } from "./appState.svelte.ts";
|
|
8
14
|
import { captureSnapshot, liveContext, shouldRerun, type CacheEntry } from "./loaderCache.ts";
|
|
9
15
|
import { pickErrorPage } from "../errorMatch.ts";
|
|
@@ -226,7 +232,9 @@
|
|
|
226
232
|
? Promise.resolve(cached)
|
|
227
233
|
: match.route.hasServerData
|
|
228
234
|
? fetch(dataUrl(path, maskBits), dataInit)
|
|
229
|
-
.then(
|
|
235
|
+
.then(readDataResponse)
|
|
236
|
+
// Only a failed request reaches here now — offline, DNS, aborted.
|
|
237
|
+
// A response that arrived is read for what it says, not discarded.
|
|
230
238
|
.catch(() => null)
|
|
231
239
|
: Promise.resolve(null);
|
|
232
240
|
|
|
@@ -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;
|
|
@@ -86,6 +86,52 @@ export function dataUrl(path: string, invalidatedBits?: string): string {
|
|
|
86
86
|
return `${base}/__bosia/data${p || "/index"}.json${qs}`;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/** True when the body is JSON we can parse — not a redirect target's HTML. */
|
|
90
|
+
function isJsonResponse(res: Response): boolean {
|
|
91
|
+
return (res.headers.get("content-type") ?? "").includes("application/json");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read a `/__bosia/data/…` response into the payload the router consumes.
|
|
96
|
+
*
|
|
97
|
+
* Anything that is not JSON used to collapse to `null`, and `null` means "the
|
|
98
|
+
* loader crashed" one branch later — so a hook redirecting an unauthenticated
|
|
99
|
+
* visitor to /login rendered a 500 that no server ever sent. The response says
|
|
100
|
+
* exactly what happened; this reads it instead of discarding it.
|
|
101
|
+
*/
|
|
102
|
+
export async function readDataResponse(res: Response): Promise<any> {
|
|
103
|
+
// `fetch` follows redirects, so a hook's 303 arrives as the login page's HTML
|
|
104
|
+
// at status 200. `redirected` is the only surviving trace of the redirect.
|
|
105
|
+
if (res.redirected) {
|
|
106
|
+
const target = new URL(res.url, window.location.origin);
|
|
107
|
+
return {
|
|
108
|
+
redirect:
|
|
109
|
+
target.origin === window.location.origin
|
|
110
|
+
? target.pathname + target.search + target.hash
|
|
111
|
+
: target.href,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (isJsonResponse(res)) {
|
|
115
|
+
try {
|
|
116
|
+
return await res.json();
|
|
117
|
+
} catch {
|
|
118
|
+
// Claimed JSON, wasn't — a truncated or proxy-mangled body.
|
|
119
|
+
return { error: { status: errorStatus(res), message: errorMessage(res) } };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// A non-JSON body the router can't use: a hook answering with text/plain 404,
|
|
123
|
+
// an HTML error page from a proxy. Report the status the server actually sent.
|
|
124
|
+
return { error: { status: errorStatus(res), message: errorMessage(res) } };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function errorStatus(res: Response): number {
|
|
128
|
+
return res.status >= 400 ? res.status : 500;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function errorMessage(res: Response): string {
|
|
132
|
+
return res.statusText || "Internal Server Error";
|
|
133
|
+
}
|
|
134
|
+
|
|
89
135
|
export const prefetchCache = new Map<string, { data: any; ts: number }>();
|
|
90
136
|
const MAX_PREFETCH_ENTRIES = 50;
|
|
91
137
|
|
|
@@ -132,7 +178,9 @@ export async function prefetchPath(path: string): Promise<void> {
|
|
|
132
178
|
}
|
|
133
179
|
: {};
|
|
134
180
|
const res = await fetch(dataUrl(path, maskBits), init);
|
|
135
|
-
|
|
181
|
+
// `ok` alone would cache a guard's login page (200 after the redirect was
|
|
182
|
+
// followed) as if it were this route's data.
|
|
183
|
+
if (res.ok && !res.redirected && isJsonResponse(res)) {
|
|
136
184
|
if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
|
|
137
185
|
const oldest = prefetchCache.keys().next().value;
|
|
138
186
|
if (oldest !== undefined) prefetchCache.delete(oldest);
|
|
@@ -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/errors.ts
CHANGED
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
import { withBase } from "./basePath.ts";
|
|
5
5
|
import { currentBase } from "./appBase.ts";
|
|
6
6
|
|
|
7
|
+
// Identity across bundle boundaries. `dist/hooks.server.js` keeps "bosia"
|
|
8
|
+
// external (build.ts BOSIA_RUNTIME_EXTERNALS), so a hook's `redirect()` builds
|
|
9
|
+
// its Redirect from the app's node_modules while the server bundle carries its
|
|
10
|
+
// own copy of this file. Two class objects, one `instanceof` — always false, so
|
|
11
|
+
// a hook throwing redirect() or error() fell through to a 500 no matter how many
|
|
12
|
+
// catch branches were added. `Symbol.for` lives in a process-wide registry, so
|
|
13
|
+
// the brand is the same value in both copies. Use isRedirect()/isHttpError()
|
|
14
|
+
// rather than `instanceof` for anything that can cross that boundary.
|
|
15
|
+
export const REDIRECT_BRAND = Symbol.for("bosia.Redirect");
|
|
16
|
+
export const HTTP_ERROR_BRAND = Symbol.for("bosia.HttpError");
|
|
17
|
+
|
|
7
18
|
export class HttpError extends Error {
|
|
8
19
|
constructor(
|
|
9
20
|
public status: number,
|
|
@@ -14,6 +25,14 @@ export class HttpError extends Error {
|
|
|
14
25
|
}
|
|
15
26
|
}
|
|
16
27
|
|
|
28
|
+
export function isHttpError(err: unknown): err is HttpError {
|
|
29
|
+
return typeof err === "object" && err !== null && (err as any)[HTTP_ERROR_BRAND] === true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isRedirect(err: unknown): err is Redirect {
|
|
33
|
+
return typeof err === "object" && err !== null && (err as any)[REDIRECT_BRAND] === true;
|
|
34
|
+
}
|
|
35
|
+
|
|
17
36
|
export interface RedirectOptions {
|
|
18
37
|
/** Set to `true` to allow redirects to external origins (e.g. OAuth providers). */
|
|
19
38
|
allowExternal?: boolean;
|
|
@@ -35,6 +54,11 @@ export class Redirect {
|
|
|
35
54
|
}
|
|
36
55
|
}
|
|
37
56
|
|
|
57
|
+
// Stamped on the prototypes rather than declared as class fields: a plain
|
|
58
|
+
// assignment needs no `unique symbol` gymnastics and covers subclasses too.
|
|
59
|
+
(HttpError.prototype as any)[HTTP_ERROR_BRAND] = true;
|
|
60
|
+
(Redirect.prototype as any)[REDIRECT_BRAND] = true;
|
|
61
|
+
|
|
38
62
|
const DANGEROUS_SCHEMES = /^(javascript|data|vbscript):/i;
|
|
39
63
|
|
|
40
64
|
function validateRedirectLocation(location: string, options?: RedirectOptions): void {
|
package/src/core/hooks.ts
CHANGED
|
@@ -46,6 +46,16 @@ export type RequestEvent = {
|
|
|
46
46
|
locals: Record<string, any> & { nonce?: string };
|
|
47
47
|
params: Record<string, string>;
|
|
48
48
|
cookies: Cookies;
|
|
49
|
+
/**
|
|
50
|
+
* True when the client router is fetching this page's loader data for a
|
|
51
|
+
* client-side navigation instead of the browser loading the page itself.
|
|
52
|
+
*
|
|
53
|
+
* `url` is the page URL either way — a guard never has to know which kind of
|
|
54
|
+
* request it is looking at. This is here for the cases that genuinely differ
|
|
55
|
+
* (skipping work that only matters for a full document render), not for
|
|
56
|
+
* authorization: a check that runs on one kind and not the other is a hole.
|
|
57
|
+
*/
|
|
58
|
+
isDataRequest: boolean;
|
|
49
59
|
};
|
|
50
60
|
|
|
51
61
|
export type LoadEvent = {
|
|
@@ -117,6 +127,16 @@ export type LoaderDeps = {
|
|
|
117
127
|
|
|
118
128
|
export type ResolveFunction = (event: RequestEvent) => MaybePromise<Response>;
|
|
119
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Middleware wrapping every request. Mutate `event.locals`, short-circuit with
|
|
132
|
+
* a `Response` / `throw redirect()` / `throw error()`, or call `resolve(event)`
|
|
133
|
+
* to continue.
|
|
134
|
+
*
|
|
135
|
+
* Pass on the `event.request` you were given. The framework keys per-request
|
|
136
|
+
* state off that exact `Request` instance, so handing `resolve()` an event
|
|
137
|
+
* carrying a freshly constructed `Request` detaches it from that state and a
|
|
138
|
+
* client-navigation data fetch comes back as page HTML.
|
|
139
|
+
*/
|
|
120
140
|
export type Handle = (input: {
|
|
121
141
|
event: RequestEvent;
|
|
122
142
|
resolve: ResolveFunction;
|
package/src/core/renderer.ts
CHANGED
|
@@ -19,9 +19,13 @@ import {
|
|
|
19
19
|
serveCached,
|
|
20
20
|
} from "./cache.ts";
|
|
21
21
|
import type { CookieJar } from "./cookies.ts";
|
|
22
|
-
import { HttpError, Redirect } from "./errors.ts";
|
|
22
|
+
import { HttpError, Redirect, isHttpError, isRedirect } 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
|
|
@@ -387,8 +449,8 @@ export async function loadRouteData(
|
|
|
387
449
|
layoutDeps[ls.depth] = emptyDeps();
|
|
388
450
|
}
|
|
389
451
|
} catch (err) {
|
|
390
|
-
if (err
|
|
391
|
-
if (err
|
|
452
|
+
if (isRedirect(err)) throw err;
|
|
453
|
+
if (isHttpError(err)) {
|
|
392
454
|
stampErrorContext(
|
|
393
455
|
err,
|
|
394
456
|
ls.depth,
|
|
@@ -451,8 +513,8 @@ export async function loadRouteData(
|
|
|
451
513
|
pageDeps = emptyDeps();
|
|
452
514
|
}
|
|
453
515
|
} catch (err) {
|
|
454
|
-
if (err
|
|
455
|
-
if (err
|
|
516
|
+
if (isRedirect(err)) throw err;
|
|
517
|
+
if (isHttpError(err)) {
|
|
456
518
|
stampErrorContext(
|
|
457
519
|
err,
|
|
458
520
|
route.layoutModules.length,
|
|
@@ -511,7 +573,7 @@ export async function loadMetadata(
|
|
|
511
573
|
} catch (err) {
|
|
512
574
|
// Control flow thrown from metadata() is intent, not failure — swallowing it
|
|
513
575
|
// here made every caller's Redirect/HttpError branch dead code.
|
|
514
|
-
if (err
|
|
576
|
+
if (isRedirect(err) || isHttpError(err)) throw err;
|
|
515
577
|
if (isDev) console.error("Metadata load error:", err);
|
|
516
578
|
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
517
579
|
if (isDev) reportDevErrorFromCatch(err);
|
|
@@ -589,10 +651,10 @@ export async function renderSSRStream(
|
|
|
589
651
|
try {
|
|
590
652
|
metadata = await loadMetadata(route, params, url, locals, cookies, req);
|
|
591
653
|
} catch (err) {
|
|
592
|
-
if (err
|
|
654
|
+
if (isRedirect(err)) {
|
|
593
655
|
return Response.redirect(err.location, err.status);
|
|
594
656
|
}
|
|
595
|
-
if (err
|
|
657
|
+
if (isHttpError(err)) {
|
|
596
658
|
return renderErrorPage(
|
|
597
659
|
err.status,
|
|
598
660
|
err.message,
|
|
@@ -628,8 +690,8 @@ export async function renderSSRStream(
|
|
|
628
690
|
]);
|
|
629
691
|
pageMod = pm;
|
|
630
692
|
} catch (err) {
|
|
631
|
-
if (err
|
|
632
|
-
if (err
|
|
693
|
+
if (isRedirect(err)) return Response.redirect(err.location, err.status);
|
|
694
|
+
if (isHttpError(err)) {
|
|
633
695
|
const e = err as HttpError & {
|
|
634
696
|
errorDepth?: number;
|
|
635
697
|
errorOrigin?: ErrorOrigin;
|
|
@@ -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);
|
|
@@ -883,8 +950,8 @@ export async function renderPageWithFormData(
|
|
|
883
950
|
try {
|
|
884
951
|
metadata = await loadMetadata(route, params, url, locals, cookies, req);
|
|
885
952
|
} catch (err) {
|
|
886
|
-
if (err
|
|
887
|
-
if (err
|
|
953
|
+
if (isRedirect(err)) return Response.redirect(err.location, err.status);
|
|
954
|
+
if (isHttpError(err)) {
|
|
888
955
|
return renderErrorPage(
|
|
889
956
|
err.status,
|
|
890
957
|
err.message,
|
|
@@ -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
|
@@ -13,7 +13,7 @@ import type { RouteManifest } from "./types.ts";
|
|
|
13
13
|
compileRoutes(apiRoutes);
|
|
14
14
|
compileRoutes(serverRoutes);
|
|
15
15
|
import { NO_FRAME_GUARD_HEADER, type Handle, type RequestEvent } from "./hooks.ts";
|
|
16
|
-
import { HttpError, Redirect, ActionFailure } from "./errors.ts";
|
|
16
|
+
import { HttpError, Redirect, ActionFailure, isHttpError, isRedirect } from "./errors.ts";
|
|
17
17
|
import { CookieJar } from "./cookies.ts";
|
|
18
18
|
import { safePath } from "./safePath.ts";
|
|
19
19
|
import { checkCsrf } from "./csrf.ts";
|
|
@@ -184,6 +184,54 @@ function isValidRoutePath(path: string, origin: string): boolean {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
type DataRequest = { routeUrl: URL; invalidatedBits: string | null };
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Decode `/__bosia/data/<route>.json` into the page URL it stands for.
|
|
191
|
+
* `null` = not a data request, `"invalid"` = 400.
|
|
192
|
+
*
|
|
193
|
+
* Called before the hooks run, not inside `resolve()`, so `event.url` is the
|
|
194
|
+
* page the visitor asked for no matter how the request arrived. A guard reading
|
|
195
|
+
* `event.url.pathname` sees `/admin` for a link click and for an address-bar
|
|
196
|
+
* load alike; when it only saw the transport path on one of them, the loaders
|
|
197
|
+
* ran unguarded for every client navigation.
|
|
198
|
+
*/
|
|
199
|
+
function parseDataRequest(url: URL): DataRequest | "invalid" | null {
|
|
200
|
+
if (!url.pathname.startsWith("/__bosia/data/")) return null;
|
|
201
|
+
|
|
202
|
+
const routePathStr =
|
|
203
|
+
url.pathname
|
|
204
|
+
.slice("/__bosia/data".length)
|
|
205
|
+
.replace(/\.json$/, "")
|
|
206
|
+
.replace(/^\/index$/, "/") || "/";
|
|
207
|
+
|
|
208
|
+
if (!isValidRoutePath(routePathStr, url.origin)) return "invalid";
|
|
209
|
+
|
|
210
|
+
const routeUrl = new URL(routePathStr, url.origin);
|
|
211
|
+
let invalidatedBits: string | null = null;
|
|
212
|
+
for (const [key, val] of url.searchParams.entries()) {
|
|
213
|
+
if (key === "_invalidated") {
|
|
214
|
+
invalidatedBits = val;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
routeUrl.searchParams.append(key, val);
|
|
218
|
+
}
|
|
219
|
+
return { routeUrl, invalidatedBits };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Per-request parse, parked here because `resolve()` can no longer recover it:
|
|
224
|
+
* `event.url` is the page URL by then, and hooks call `resolve(event)`
|
|
225
|
+
* themselves so there is no parameter to thread it through. `event.locals` is
|
|
226
|
+
* user scratch space and off limits for framework state.
|
|
227
|
+
*
|
|
228
|
+
* Keyed on the incoming `Request`, which is the one object that stays identical
|
|
229
|
+
* across the whole chain. A hook that swaps in a fabricated `Request` detaches
|
|
230
|
+
* its event from this record — documented on `Handle`, pinned by
|
|
231
|
+
* `test/hooks-redirect.test.ts`.
|
|
232
|
+
*/
|
|
233
|
+
const dataRequests = new WeakMap<Request, DataRequest>();
|
|
234
|
+
|
|
187
235
|
/**
|
|
188
236
|
* Decode an `_invalidated` bitmask string. Char 0 = page, char i+1 = layout
|
|
189
237
|
* depth i, '1' = run, '0' = skip. Missing/extra chars default to run.
|
|
@@ -230,28 +278,12 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
230
278
|
return Response.json({ status: "ok", timestamp, timezone });
|
|
231
279
|
}
|
|
232
280
|
|
|
233
|
-
// Data endpoint — returns server loader data as JSON for client-side navigation
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
.replace(/^\/index$/, "/") || "/";
|
|
240
|
-
|
|
241
|
-
if (!isValidRoutePath(routePathStr, url.origin)) {
|
|
242
|
-
return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
|
|
243
|
-
}
|
|
244
|
-
const routeUrl = new URL(routePathStr, url.origin);
|
|
245
|
-
let invalidatedBits: string | null = null;
|
|
246
|
-
for (const [key, val] of url.searchParams.entries()) {
|
|
247
|
-
if (key === "_invalidated") {
|
|
248
|
-
invalidatedBits = val;
|
|
249
|
-
continue;
|
|
250
|
-
}
|
|
251
|
-
routeUrl.searchParams.append(key, val);
|
|
252
|
-
}
|
|
253
|
-
// Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
|
|
254
|
-
event.url = routeUrl;
|
|
281
|
+
// Data endpoint — returns server loader data as JSON for client-side navigation.
|
|
282
|
+
// The URL no longer says so (it is the page URL, for the hooks' benefit), so
|
|
283
|
+
// the parse handleRequest parked before the hooks ran is what identifies it.
|
|
284
|
+
const dataReq = dataRequests.get(request);
|
|
285
|
+
if (dataReq) {
|
|
286
|
+
const { routeUrl, invalidatedBits } = dataReq;
|
|
255
287
|
try {
|
|
256
288
|
const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
|
|
257
289
|
// Build mask from `?_invalidated=<bits>` where char 0 = page,
|
|
@@ -360,14 +392,14 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
360
392
|
extra,
|
|
361
393
|
);
|
|
362
394
|
} catch (err) {
|
|
363
|
-
if (err
|
|
395
|
+
if (isRedirect(err)) {
|
|
364
396
|
return compress(
|
|
365
397
|
JSON.stringify({ redirect: err.location, status: err.status }),
|
|
366
398
|
"application/json",
|
|
367
399
|
request,
|
|
368
400
|
);
|
|
369
401
|
}
|
|
370
|
-
if (err
|
|
402
|
+
if (isHttpError(err)) {
|
|
371
403
|
const e = err as HttpError & {
|
|
372
404
|
errorDepth?: number;
|
|
373
405
|
errorOrigin?: "page" | "layout";
|
|
@@ -471,10 +503,13 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
471
503
|
url,
|
|
472
504
|
locals,
|
|
473
505
|
cookies,
|
|
506
|
+
// An API route is reached through its own URL, never through the
|
|
507
|
+
// client router's data endpoint.
|
|
508
|
+
isDataRequest: false,
|
|
474
509
|
});
|
|
475
510
|
|
|
476
511
|
// Redirect returned (not thrown) — convert to a 303 Response.
|
|
477
|
-
if (handlerResult
|
|
512
|
+
if (isRedirect(handlerResult)) {
|
|
478
513
|
return new Response(null, {
|
|
479
514
|
status: handlerResult.status,
|
|
480
515
|
headers: { Location: handlerResult.location },
|
|
@@ -546,13 +581,13 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
546
581
|
} catch (err) {
|
|
547
582
|
// `throw redirect(303, "/")` from a +server.ts handler — turn it into
|
|
548
583
|
// a real 303 instead of a 500. Mirrors the page-action handler below.
|
|
549
|
-
if (err
|
|
584
|
+
if (isRedirect(err)) {
|
|
550
585
|
return new Response(null, {
|
|
551
586
|
status: err.status,
|
|
552
587
|
headers: { Location: err.location },
|
|
553
588
|
});
|
|
554
589
|
}
|
|
555
|
-
if (err
|
|
590
|
+
if (isHttpError(err)) {
|
|
556
591
|
return Response.json({ error: err.message }, { status: err.status });
|
|
557
592
|
}
|
|
558
593
|
if (isDev) console.error("API route error:", err);
|
|
@@ -704,7 +739,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
704
739
|
try {
|
|
705
740
|
result = await action(event);
|
|
706
741
|
} catch (err) {
|
|
707
|
-
if (err
|
|
742
|
+
if (isRedirect(err)) {
|
|
708
743
|
if (isEnhanced) {
|
|
709
744
|
return Response.json({
|
|
710
745
|
type: "redirect",
|
|
@@ -717,7 +752,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
717
752
|
headers: { Location: err.location },
|
|
718
753
|
});
|
|
719
754
|
}
|
|
720
|
-
if (err
|
|
755
|
+
if (isHttpError(err)) {
|
|
721
756
|
if (isEnhanced) {
|
|
722
757
|
return Response.json(
|
|
723
758
|
{ type: "error", status: err.status, message: err.message },
|
|
@@ -740,7 +775,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
740
775
|
}
|
|
741
776
|
|
|
742
777
|
// Redirect returned (not thrown)
|
|
743
|
-
if (result
|
|
778
|
+
if (isRedirect(result)) {
|
|
744
779
|
if (isEnhanced) {
|
|
745
780
|
return Response.json({
|
|
746
781
|
type: "redirect",
|
|
@@ -792,7 +827,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
792
827
|
);
|
|
793
828
|
}
|
|
794
829
|
} catch (err) {
|
|
795
|
-
if (err
|
|
830
|
+
if (isRedirect(err)) {
|
|
796
831
|
if (isEnhanced) {
|
|
797
832
|
return Response.json({
|
|
798
833
|
type: "redirect",
|
|
@@ -805,7 +840,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
805
840
|
headers: { Location: err.location },
|
|
806
841
|
});
|
|
807
842
|
}
|
|
808
|
-
if (err
|
|
843
|
+
if (isHttpError(err)) {
|
|
809
844
|
if (isEnhanced) {
|
|
810
845
|
return Response.json(
|
|
811
846
|
{ type: "error", status: err.status, message: err.message },
|
|
@@ -921,6 +956,11 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
921
956
|
}
|
|
922
957
|
|
|
923
958
|
inFlight++;
|
|
959
|
+
// Hoisted so the catch below can tell a data request from a page request and
|
|
960
|
+
// reuse the same nonce when it renders an error page.
|
|
961
|
+
let dataReq: DataRequest | null = null;
|
|
962
|
+
let nonce = "";
|
|
963
|
+
let cookieJar: CookieJar | null = null;
|
|
924
964
|
try {
|
|
925
965
|
// Handle CORS preflight before CSRF check (OPTIONS is CSRF-exempt)
|
|
926
966
|
if (CORS_CONFIG && request.method === "OPTIONS") {
|
|
@@ -937,16 +977,50 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
937
977
|
const isHttps =
|
|
938
978
|
(TRUST_PROXY && request.headers.get("x-forwarded-proto") === "https") ||
|
|
939
979
|
url.protocol === "https:";
|
|
940
|
-
|
|
941
|
-
|
|
980
|
+
cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isHttps);
|
|
981
|
+
nonce = CSP_ENABLED ? generateNonce() : "";
|
|
982
|
+
|
|
983
|
+
// Decode the data endpoint before the hooks, not inside resolve(): a guard
|
|
984
|
+
// runs *before* `await resolve(event)`, so a rewrite in there reaches
|
|
985
|
+
// logging middleware and never reaches the check that gates the route.
|
|
986
|
+
const parsed = parseDataRequest(url);
|
|
987
|
+
if (parsed === "invalid") {
|
|
988
|
+
return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
|
|
989
|
+
}
|
|
990
|
+
dataReq = parsed;
|
|
991
|
+
if (dataReq) dataRequests.set(request, dataReq);
|
|
992
|
+
|
|
942
993
|
const event: RequestEvent = {
|
|
943
994
|
request,
|
|
944
|
-
url,
|
|
995
|
+
url: dataReq ? dataReq.routeUrl : url,
|
|
945
996
|
locals: { nonce },
|
|
946
997
|
params: {},
|
|
947
998
|
cookies: cookieJar,
|
|
999
|
+
isDataRequest: dataReq !== null,
|
|
948
1000
|
};
|
|
949
|
-
|
|
1001
|
+
let response = userHandle ? await userHandle({ event, resolve }) : await resolve(event);
|
|
1002
|
+
|
|
1003
|
+
// A hook that short-circuits a data request with a redirect is answering
|
|
1004
|
+
// the client router, which speaks JSON — an unconverted 3xx is followed by
|
|
1005
|
+
// `fetch` and the router receives the redirect target's HTML instead.
|
|
1006
|
+
// `Location` is copied verbatim: `redirect()` already rebased it through
|
|
1007
|
+
// `withBase()`, and a raw `Response.redirect` under a BASE_PATH carries the
|
|
1008
|
+
// base by hand, so rebasing here would double the prefix on both.
|
|
1009
|
+
if (dataReq && response.status >= 300 && response.status < 400) {
|
|
1010
|
+
const location = response.headers.get("location");
|
|
1011
|
+
if (location) {
|
|
1012
|
+
const carried = new Headers(response.headers);
|
|
1013
|
+
carried.delete("location");
|
|
1014
|
+
carried.delete("content-type");
|
|
1015
|
+
carried.delete("content-length");
|
|
1016
|
+
carried.delete("content-encoding");
|
|
1017
|
+
carried.set("content-type", "application/json");
|
|
1018
|
+
response = new Response(JSON.stringify({ redirect: location, status: response.status }), {
|
|
1019
|
+
status: 200,
|
|
1020
|
+
headers: carried,
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
950
1024
|
|
|
951
1025
|
const headers = new Headers(response.headers);
|
|
952
1026
|
// A handle can mark a response (e.g. a proxied embeddable preview) to opt
|
|
@@ -979,6 +1053,45 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
979
1053
|
headers,
|
|
980
1054
|
});
|
|
981
1055
|
} catch (err) {
|
|
1056
|
+
// `throw redirect()` / `throw error()` from a hook lands here — the same
|
|
1057
|
+
// escape hatch loaders have always had. Without these branches both fall
|
|
1058
|
+
// through to the 500 below, which is why the docs could only ever suggest
|
|
1059
|
+
// returning a raw `Response.redirect`.
|
|
1060
|
+
if (isRedirect(err) || isHttpError(err)) {
|
|
1061
|
+
const out = isRedirect(err)
|
|
1062
|
+
? dataReq
|
|
1063
|
+
? // Shape-identical to the loader conversion below, so the
|
|
1064
|
+
// router has one payload contract regardless of who redirected.
|
|
1065
|
+
Response.json({ redirect: err.location, status: err.status })
|
|
1066
|
+
: Response.redirect(err.location, err.status)
|
|
1067
|
+
: dataReq
|
|
1068
|
+
? Response.json(
|
|
1069
|
+
{
|
|
1070
|
+
error: { status: err.status, message: err.message },
|
|
1071
|
+
errorDepth: null,
|
|
1072
|
+
errorOrigin: null,
|
|
1073
|
+
},
|
|
1074
|
+
{ status: err.status },
|
|
1075
|
+
)
|
|
1076
|
+
: await renderErrorPage(
|
|
1077
|
+
err.status,
|
|
1078
|
+
err.message,
|
|
1079
|
+
url,
|
|
1080
|
+
request,
|
|
1081
|
+
undefined,
|
|
1082
|
+
undefined,
|
|
1083
|
+
undefined,
|
|
1084
|
+
undefined,
|
|
1085
|
+
nonce,
|
|
1086
|
+
);
|
|
1087
|
+
// A hook that expires the session before throwing must not lose the
|
|
1088
|
+
// Set-Cookie that does it — `Response.redirect` builds a fresh Response,
|
|
1089
|
+
// so the jar is re-applied by hand here.
|
|
1090
|
+
if (cookieJar) {
|
|
1091
|
+
for (const cookie of cookieJar.outgoing) out.headers.append("Set-Cookie", cookie);
|
|
1092
|
+
}
|
|
1093
|
+
return out;
|
|
1094
|
+
}
|
|
982
1095
|
if (isDev) console.error("Unhandled request error:", err);
|
|
983
1096
|
else console.error("Unhandled request error:", (err as Error).message ?? err);
|
|
984
1097
|
if (isDev) reportDevErrorFromCatch(err);
|