bosia 0.9.4 → 0.9.6
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/build.ts +12 -0
- package/src/core/client/App.svelte +10 -2
- package/src/core/client/prefetch.ts +49 -1
- package/src/core/componentCss.ts +54 -0
- package/src/core/errors.ts +24 -0
- package/src/core/hooks.ts +20 -0
- package/src/core/html.ts +19 -15
- package/src/core/plugins/inspector/bun-plugin.ts +12 -5
- package/src/core/renderer.ts +12 -12
- package/src/core/scanner.ts +10 -6
- package/src/core/server.ts +150 -37
- package/src/core/svelteCompiler.ts +13 -1
package/package.json
CHANGED
package/src/core/build.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { generateRoutesFile } from "./routeFile.ts";
|
|
|
7
7
|
import { generateRouteTypes, ensureRootDirs } from "./routeTypes.ts";
|
|
8
8
|
import { makeBosiaPlugin } from "./plugin.ts";
|
|
9
9
|
import { makeBosiaSvelteCompiler, svelteMapCache } from "./svelteCompiler.ts";
|
|
10
|
+
import { finalizeComponentCss } from "./componentCss.ts";
|
|
10
11
|
import { prerenderStaticRoutes, generateStaticSite } from "./prerender.ts";
|
|
11
12
|
import { loadEnv, classifyEnvVars } from "./env.ts";
|
|
12
13
|
import { generateEnvModules } from "./envCodegen.ts";
|
|
@@ -258,6 +259,17 @@ for (const output of clientResult.outputs) {
|
|
|
258
259
|
}
|
|
259
260
|
}
|
|
260
261
|
|
|
262
|
+
// Scoped component `<style>` blocks, harvested during the client compile and
|
|
263
|
+
// written as one stylesheet the head can link. Before this they rode inside the
|
|
264
|
+
// JS bundle, so every SSR'd page painted unstyled until hydration. Must land
|
|
265
|
+
// before the manifest write below: prerenderStaticRoutes() boots the built
|
|
266
|
+
// server, which reads manifest.json once at startup.
|
|
267
|
+
const componentCssFile = finalizeComponentCss(`${OUT_DIR}/client`);
|
|
268
|
+
if (componentCssFile) {
|
|
269
|
+
cssFiles.push(componentCssFile);
|
|
270
|
+
console.log(`✅ Component CSS built: ${OUT_DIR}/client/${componentCssFile}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
261
273
|
// Entry is always "index.js" due to naming: { entry: "index.[ext]" }
|
|
262
274
|
const serverEntry =
|
|
263
275
|
serverResult.outputs
|
|
@@ -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
|
|
|
@@ -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);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { rebaseCssUrls } from "./basePath.ts";
|
|
4
|
+
import { currentBase } from "./appBase.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scoped `<style>` blocks harvested from every `.svelte` file the client build
|
|
8
|
+
* touches, keyed by absolute source path so a recompile of the same file
|
|
9
|
+
* replaces its rules rather than appending a second copy.
|
|
10
|
+
*
|
|
11
|
+
* Why a hand-rolled collector rather than letting Bun emit the CSS: with
|
|
12
|
+
* `splitting: true` a `css: "external"` compile makes Bun write one CSS sidecar
|
|
13
|
+
* per dynamic-imported chunk, which is the "Multiple files share the same
|
|
14
|
+
* output path" failure that 0.4.4 fought (see `test/svelte-build.test.ts`).
|
|
15
|
+
* Collecting here keeps the client build's CSS-output count at zero — the
|
|
16
|
+
* invariant that test pins — while still producing a real stylesheet.
|
|
17
|
+
*/
|
|
18
|
+
const collected = new Map<string, string>();
|
|
19
|
+
|
|
20
|
+
export function collectComponentCss(filePath: string, css: string): void {
|
|
21
|
+
collected.set(filePath, css);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Concatenate everything collected into one content-hashed stylesheet in
|
|
26
|
+
* `clientDir`, and return its basename — or `null` when the app has no scoped
|
|
27
|
+
* styles at all, in which case nothing is written and nothing is linked.
|
|
28
|
+
*
|
|
29
|
+
* Mirrors `finalizeTailwindCss` (twHash.ts) deliberately: same rebase-then-hash
|
|
30
|
+
* order, same hash length, same `-<hash>.css` shape that staticManifest's
|
|
31
|
+
* HASHED_BASENAME rule reads as immutable.
|
|
32
|
+
*/
|
|
33
|
+
export function finalizeComponentCss(clientDir: string): string | null {
|
|
34
|
+
if (collected.size === 0) return null;
|
|
35
|
+
|
|
36
|
+
// Sorted by path: the bundler visits modules in whatever order resolution
|
|
37
|
+
// happens to take, and an unstable order means an unstable hash means every
|
|
38
|
+
// build busts a cache that did not need busting.
|
|
39
|
+
const css = [...collected.keys()]
|
|
40
|
+
.sort()
|
|
41
|
+
.map((k) => collected.get(k)!)
|
|
42
|
+
.join("\n");
|
|
43
|
+
|
|
44
|
+
// Rebase before hashing, so the hash describes the bytes actually served —
|
|
45
|
+
// same reasoning as twHash: a `url(/img/x.png)` inside a component's
|
|
46
|
+
// `<style>` resolves against the origin and would land outside the mount.
|
|
47
|
+
const base = currentBase();
|
|
48
|
+
const bytes = base ? rebaseCssUrls(base, css) : css;
|
|
49
|
+
|
|
50
|
+
const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex").slice(0, 10);
|
|
51
|
+
const name = `bosia-css-${hash}.css`;
|
|
52
|
+
writeFileSync(join(clientDir, name), bytes);
|
|
53
|
+
return name;
|
|
54
|
+
}
|
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/html.ts
CHANGED
|
@@ -56,6 +56,19 @@ function twCssLink(): string {
|
|
|
56
56
|
: `<link rel="stylesheet" href="${TW_CSS}${cacheBust}">`;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** The build-time component stylesheet (scoped `<style>` blocks, concatenated).
|
|
60
|
+
* Emitted AFTER `twCssLink()` on every path: these rules used to be appended to
|
|
61
|
+
* `document.head` at hydration, i.e. last, and the app stylesheets Tailwind
|
|
62
|
+
* inlines (`tokens.css`, `components.css`) are unlayered, so a tie between them
|
|
63
|
+
* is settled on source order. Linking before Tailwind would silently flip
|
|
64
|
+
* which one wins. Each entry carries its own indent and newline, so an app with
|
|
65
|
+
* no scoped styles at all contributes nothing rather than a blank line. */
|
|
66
|
+
function componentCssLinks(): string {
|
|
67
|
+
return (distManifest.css ?? [])
|
|
68
|
+
.map((f: string) => ` <link rel="stylesheet" href="${DIST}/${f}">\n`)
|
|
69
|
+
.join("");
|
|
70
|
+
}
|
|
71
|
+
|
|
59
72
|
/** Inline theme bootstrap — runs before paint to avoid FOUC. theme ∈ light|dark|system (missing = system). */
|
|
60
73
|
const THEME_INIT_JS =
|
|
61
74
|
"try{var t=localStorage.getItem('theme');" +
|
|
@@ -153,10 +166,6 @@ export function buildHtml(
|
|
|
153
166
|
body = rebaseHtmlAttrs(B, body);
|
|
154
167
|
head = rebaseHtmlAttrs(B, head);
|
|
155
168
|
|
|
156
|
-
const cssLinks = (distManifest.css ?? [])
|
|
157
|
-
.map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
|
|
158
|
-
.join("\n ");
|
|
159
|
-
|
|
160
169
|
// Metadata goes in before `head`: the first <title> in the document wins, and
|
|
161
170
|
// the streaming path already puts metadata() ahead of <svelte:head> content
|
|
162
171
|
// (which arrives later via buildHtmlTail). Same order = same winner on both paths.
|
|
@@ -210,8 +219,8 @@ export function buildHtml(
|
|
|
210
219
|
|
|
211
220
|
return (
|
|
212
221
|
headOpenInterpolated +
|
|
213
|
-
`\n ${faviconLine}${
|
|
214
|
-
|
|
222
|
+
`\n ${faviconLine}${twCssLink()}\n` +
|
|
223
|
+
componentCssLinks() +
|
|
215
224
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
216
225
|
` ${fallbackTitle}${metaTags}${head}` +
|
|
217
226
|
headCloseInterpolated +
|
|
@@ -229,9 +238,8 @@ export function buildHtml(
|
|
|
229
238
|
${fallbackTitle}
|
|
230
239
|
<link rel="icon" type="image/svg+xml" href="${FAVICON}">
|
|
231
240
|
${metaTags} ${head}
|
|
232
|
-
${cssLinks}
|
|
233
241
|
${twCssLink()}
|
|
234
|
-
<script${n}>${THEME_INIT_JS}</script>
|
|
242
|
+
${componentCssLinks()} <script${n}>${THEME_INIT_JS}</script>
|
|
235
243
|
</head>
|
|
236
244
|
<body>
|
|
237
245
|
<div id="app">${body}</div>${scripts}${bodyEnd}
|
|
@@ -249,10 +257,6 @@ export function buildHtmlShellOpen(
|
|
|
249
257
|
): string {
|
|
250
258
|
const key = safeLang(lang);
|
|
251
259
|
const n = nonceAttr(nonce);
|
|
252
|
-
const cssLinks = (distManifest.css ?? [])
|
|
253
|
-
.map((f: string) => `<link rel="stylesheet" href="${DIST}/${f}">`)
|
|
254
|
-
.join("\n ");
|
|
255
|
-
|
|
256
260
|
if (segments) {
|
|
257
261
|
const headOpenInterpolated = interpolateSegment(segments.headOpen, { lang: key, nonce });
|
|
258
262
|
const faviconLine = segments.hasCustomFavicon
|
|
@@ -260,8 +264,8 @@ export function buildHtmlShellOpen(
|
|
|
260
264
|
: ` <link rel="icon" type="image/svg+xml" href="${FAVICON}">\n`;
|
|
261
265
|
return (
|
|
262
266
|
headOpenInterpolated +
|
|
263
|
-
`\n ${faviconLine}${
|
|
264
|
-
|
|
267
|
+
`\n ${faviconLine}${twCssLink()}\n` +
|
|
268
|
+
componentCssLinks() +
|
|
265
269
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
266
270
|
` <link rel="modulepreload" href="${DIST}/${distManifest.entry}${cacheBust}">`
|
|
267
271
|
);
|
|
@@ -272,8 +276,8 @@ export function buildHtmlShellOpen(
|
|
|
272
276
|
` <meta charset="UTF-8">\n` +
|
|
273
277
|
` <meta name="viewport" content="width=device-width, initial-scale=1.0">\n` +
|
|
274
278
|
` <link rel="icon" type="image/svg+xml" href="${FAVICON}">\n` +
|
|
275
|
-
` ${cssLinks}\n` +
|
|
276
279
|
` ${twCssLink()}\n` +
|
|
280
|
+
componentCssLinks() +
|
|
277
281
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
278
282
|
` <link rel="modulepreload" href="${DIST}/${distManifest.entry}${cacheBust}">`
|
|
279
283
|
);
|
|
@@ -4,6 +4,7 @@ import { relative } from "node:path";
|
|
|
4
4
|
import type { BunPlugin } from "bun";
|
|
5
5
|
import { svelteMapCache } from "../../svelteCompiler.ts";
|
|
6
6
|
import { lineColFromOffset } from "../../sourceLoc.ts";
|
|
7
|
+
import { collectComponentCss } from "../../componentCss.ts";
|
|
7
8
|
|
|
8
9
|
type AnyNode = {
|
|
9
10
|
type?: string;
|
|
@@ -136,11 +137,12 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
|
|
|
136
137
|
generate,
|
|
137
138
|
dev,
|
|
138
139
|
hmr: dev,
|
|
139
|
-
// Mirror the prod compiler (svelteCompiler.ts):
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
|
|
140
|
+
// Mirror the prod compiler (svelteCompiler.ts): external on both
|
|
141
|
+
// targets, with the client's rules harvested into one stylesheet
|
|
142
|
+
// that the head links. This plugin registers ahead of the main
|
|
143
|
+
// compiler and its onLoad wins in dev, so letting the two drift
|
|
144
|
+
// here is how dev and prod stop agreeing about first paint.
|
|
145
|
+
css: "external",
|
|
144
146
|
preserveWhitespace: dev,
|
|
145
147
|
preserveComments: dev,
|
|
146
148
|
cssHash: ({ css }) => `svelte-${fnv(css)}`,
|
|
@@ -165,6 +167,11 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
|
|
|
165
167
|
svelteMapCache.set(args.path, m);
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
// Client only — see the same guard in svelteCompiler.ts.
|
|
171
|
+
if (generate === "client" && result.css?.code) {
|
|
172
|
+
collectComponentCss(args.path, result.css.code);
|
|
173
|
+
}
|
|
174
|
+
|
|
168
175
|
const js = dev ? fixBindShadow(result.js.code) : result.js.code;
|
|
169
176
|
return { contents: js, loader: "ts" };
|
|
170
177
|
});
|
package/src/core/renderer.ts
CHANGED
|
@@ -19,7 +19,7 @@ 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
25
|
import { router } from "./client/router.svelte.ts";
|
|
@@ -449,8 +449,8 @@ export async function loadRouteData(
|
|
|
449
449
|
layoutDeps[ls.depth] = emptyDeps();
|
|
450
450
|
}
|
|
451
451
|
} catch (err) {
|
|
452
|
-
if (err
|
|
453
|
-
if (err
|
|
452
|
+
if (isRedirect(err)) throw err;
|
|
453
|
+
if (isHttpError(err)) {
|
|
454
454
|
stampErrorContext(
|
|
455
455
|
err,
|
|
456
456
|
ls.depth,
|
|
@@ -513,8 +513,8 @@ export async function loadRouteData(
|
|
|
513
513
|
pageDeps = emptyDeps();
|
|
514
514
|
}
|
|
515
515
|
} catch (err) {
|
|
516
|
-
if (err
|
|
517
|
-
if (err
|
|
516
|
+
if (isRedirect(err)) throw err;
|
|
517
|
+
if (isHttpError(err)) {
|
|
518
518
|
stampErrorContext(
|
|
519
519
|
err,
|
|
520
520
|
route.layoutModules.length,
|
|
@@ -573,7 +573,7 @@ export async function loadMetadata(
|
|
|
573
573
|
} catch (err) {
|
|
574
574
|
// Control flow thrown from metadata() is intent, not failure — swallowing it
|
|
575
575
|
// here made every caller's Redirect/HttpError branch dead code.
|
|
576
|
-
if (err
|
|
576
|
+
if (isRedirect(err) || isHttpError(err)) throw err;
|
|
577
577
|
if (isDev) console.error("Metadata load error:", err);
|
|
578
578
|
else console.error("Metadata load error:", (err as Error).message ?? err);
|
|
579
579
|
if (isDev) reportDevErrorFromCatch(err);
|
|
@@ -651,10 +651,10 @@ export async function renderSSRStream(
|
|
|
651
651
|
try {
|
|
652
652
|
metadata = await loadMetadata(route, params, url, locals, cookies, req);
|
|
653
653
|
} catch (err) {
|
|
654
|
-
if (err
|
|
654
|
+
if (isRedirect(err)) {
|
|
655
655
|
return Response.redirect(err.location, err.status);
|
|
656
656
|
}
|
|
657
|
-
if (err
|
|
657
|
+
if (isHttpError(err)) {
|
|
658
658
|
return renderErrorPage(
|
|
659
659
|
err.status,
|
|
660
660
|
err.message,
|
|
@@ -690,8 +690,8 @@ export async function renderSSRStream(
|
|
|
690
690
|
]);
|
|
691
691
|
pageMod = pm;
|
|
692
692
|
} catch (err) {
|
|
693
|
-
if (err
|
|
694
|
-
if (err
|
|
693
|
+
if (isRedirect(err)) return Response.redirect(err.location, err.status);
|
|
694
|
+
if (isHttpError(err)) {
|
|
695
695
|
const e = err as HttpError & {
|
|
696
696
|
errorDepth?: number;
|
|
697
697
|
errorOrigin?: ErrorOrigin;
|
|
@@ -950,8 +950,8 @@ export async function renderPageWithFormData(
|
|
|
950
950
|
try {
|
|
951
951
|
metadata = await loadMetadata(route, params, url, locals, cookies, req);
|
|
952
952
|
} catch (err) {
|
|
953
|
-
if (err
|
|
954
|
-
if (err
|
|
953
|
+
if (isRedirect(err)) return Response.redirect(err.location, err.status);
|
|
954
|
+
if (isHttpError(err)) {
|
|
955
955
|
return renderErrorPage(
|
|
956
956
|
err.status,
|
|
957
957
|
err.message,
|
package/src/core/scanner.ts
CHANGED
|
@@ -65,6 +65,7 @@ export function scanRoutes(): RouteManifest {
|
|
|
65
65
|
layoutServerChain: { path: string; depth: number }[],
|
|
66
66
|
errorPageChain: { path: string; depth: number }[],
|
|
67
67
|
inheritedTrailingSlash: TrailingSlash,
|
|
68
|
+
inheritedLoading: string | null,
|
|
68
69
|
) {
|
|
69
70
|
const fullDir = join(ROUTES_DIR, dir);
|
|
70
71
|
if (!existsSync(fullDir)) return;
|
|
@@ -76,6 +77,12 @@ export function scanRoutes(): RouteManifest {
|
|
|
76
77
|
const currentLayoutServers = [...layoutServerChain];
|
|
77
78
|
const currentErrorPages = [...errorPageChain];
|
|
78
79
|
let currentTrailingSlash = inheritedTrailingSlash;
|
|
80
|
+
// Cascades to every page below, nearest ancestor winning — the same shape
|
|
81
|
+
// as the layout chain. Without this a section with 40 routes needed 40
|
|
82
|
+
// identical +loading.svelte files to cover its navigations.
|
|
83
|
+
const currentLoading = items.some((i) => i.isFile() && i.name === "+loading.svelte")
|
|
84
|
+
? join(dir, "+loading.svelte")
|
|
85
|
+
: inheritedLoading;
|
|
79
86
|
|
|
80
87
|
if (items.some((i) => i.isFile() && i.name === "+layout.svelte")) {
|
|
81
88
|
currentLayouts.push(join(dir, "+layout.svelte"));
|
|
@@ -112,10 +119,6 @@ export function scanRoutes(): RouteManifest {
|
|
|
112
119
|
? join(dir, "+page.server.ts")
|
|
113
120
|
: null;
|
|
114
121
|
|
|
115
|
-
const loadingFile = items.some((i) => i.isFile() && i.name === "+loading.svelte")
|
|
116
|
-
? join(dir, "+loading.svelte")
|
|
117
|
-
: null;
|
|
118
|
-
|
|
119
122
|
const pageTs = pageServerFile ? readTrailingSlash(join(ROUTES_DIR, pageServerFile)) : null;
|
|
120
123
|
const effectiveTs: TrailingSlash = pageTs ?? currentTrailingSlash;
|
|
121
124
|
|
|
@@ -125,7 +128,7 @@ export function scanRoutes(): RouteManifest {
|
|
|
125
128
|
page: pageFile,
|
|
126
129
|
layouts: [...currentLayouts],
|
|
127
130
|
pageServer: pageServerFile,
|
|
128
|
-
loading:
|
|
131
|
+
loading: currentLoading,
|
|
129
132
|
layoutServers: [...currentLayoutServers],
|
|
130
133
|
errorPages: [...currentErrorPages],
|
|
131
134
|
trailingSlash: effectiveTs,
|
|
@@ -149,11 +152,12 @@ export function scanRoutes(): RouteManifest {
|
|
|
149
152
|
currentLayoutServers,
|
|
150
153
|
currentErrorPages,
|
|
151
154
|
currentTrailingSlash,
|
|
155
|
+
currentLoading,
|
|
152
156
|
);
|
|
153
157
|
}
|
|
154
158
|
}
|
|
155
159
|
|
|
156
|
-
walk("", [], [], [], [], "never");
|
|
160
|
+
walk("", [], [], [], [], "never", null);
|
|
157
161
|
|
|
158
162
|
// Warn when a catch-all exists but no exact route covers its prefix.
|
|
159
163
|
// e.g. "/[...slug]" matches everything EXCEPT "/" (which needs its own +page.svelte).
|
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);
|
|
@@ -2,6 +2,7 @@ import { compile, compileModule } from "svelte/compiler";
|
|
|
2
2
|
import type { BunPlugin } from "bun";
|
|
3
3
|
|
|
4
4
|
import { auditSvelteSource } from "./svelteAudit.ts";
|
|
5
|
+
import { collectComponentCss } from "./componentCss.ts";
|
|
5
6
|
import { rebaseHtmlAttrs } from "./basePath.ts";
|
|
6
7
|
import { currentBase } from "./appBase.ts";
|
|
7
8
|
import { loadBosiaConfig } from "./config.ts";
|
|
@@ -110,7 +111,12 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
|
|
|
110
111
|
const source = await Bun.file(args.path).text();
|
|
111
112
|
const result = compile(rebaseSvelteMarkup(source), {
|
|
112
113
|
generate,
|
|
113
|
-
|
|
114
|
+
// External on both targets. The browser used to get "injected",
|
|
115
|
+
// which put every scoped rule inside the JS bundle — so an
|
|
116
|
+
// SSR'd page painted before its own layout CSS existed and
|
|
117
|
+
// snapped into place at hydration. `collectComponentCss` below
|
|
118
|
+
// gathers the rules into one stylesheet the head can link.
|
|
119
|
+
css: "external",
|
|
114
120
|
dev,
|
|
115
121
|
hmr: false,
|
|
116
122
|
cssHash: ({ css }) => `svelte-${svelteHash(css)}`,
|
|
@@ -119,6 +125,12 @@ export function makeBosiaSvelteCompiler(target: "browser" | "bun"): BunPlugin {
|
|
|
119
125
|
// rather than the legacy `html`. The audit walker assumes modern.
|
|
120
126
|
modernAst: true,
|
|
121
127
|
});
|
|
128
|
+
// Browser only: both plugin instances share module state and the
|
|
129
|
+
// client and server builds run concurrently, so collecting from
|
|
130
|
+
// each would emit every rule twice.
|
|
131
|
+
if (target === "browser" && result.css?.code) {
|
|
132
|
+
collectComponentCss(args.path, result.css.code);
|
|
133
|
+
}
|
|
122
134
|
const existing = auditInflight.get(args.path);
|
|
123
135
|
if (existing) {
|
|
124
136
|
await existing;
|