bosia 1.1.4 → 1.1.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/ambient.d.ts +3 -0
- package/src/cli/add.ts +15 -12
- package/src/cli/block.ts +18 -16
- package/src/cli/feat.ts +31 -11
- package/src/cli/page.ts +18 -16
- package/src/cli/registry.ts +117 -20
- package/src/cli/theme.ts +7 -2
- package/src/core/backend.ts +8 -1
- package/src/core/build.ts +7 -2
- package/src/core/cache.ts +28 -6
- package/src/core/client/App.svelte +15 -26
- package/src/core/client/page.svelte.ts +15 -0
- package/src/core/client/prefetch.ts +55 -60
- package/src/core/dev.ts +11 -1
- package/src/core/etag.ts +13 -0
- package/src/core/hooks.ts +17 -0
- package/src/core/html.ts +109 -14
- package/src/core/plugins/inspector/bun-plugin.ts +31 -18
- package/src/core/renderer.ts +99 -76
- package/src/core/routeFile.ts +36 -20
- package/src/core/scanner.ts +2 -0
- package/src/core/server.ts +294 -94
- package/src/core/staticManifest.ts +60 -15
- package/src/core/svelteCompiler.ts +96 -27
- package/src/core/types.ts +4 -0
- package/templates/shop/.env.example +3 -0
- package/templates/shop/src/hooks.server.ts +12 -2
- package/templates/shop/src/routes/(private)/+layout.server.ts +2 -6
- package/templates/store/.env.example +3 -0
- package/templates/store/src/hooks.server.ts +12 -2
- package/templates/store/src/routes/(private)/+layout.server.ts +2 -6
|
@@ -8,8 +8,14 @@
|
|
|
8
8
|
// App.svelte), mirroring the modern SvelteKit `$app/state` direction. Route
|
|
9
9
|
// components should destructure `params` from `$props()`. `page.params` was
|
|
10
10
|
// removed in 1.1.0.
|
|
11
|
+
//
|
|
12
|
+
// `page.route.id` is the matched route's folder path with groups kept
|
|
13
|
+
// (`/(private)/dashboard/[id]`), the same id hooks and loaders get. `null` when
|
|
14
|
+
// no route matches, e.g. on the 404 page.
|
|
11
15
|
|
|
12
16
|
import { router } from "./router.svelte.ts";
|
|
17
|
+
import { findMatch } from "../matcher.ts";
|
|
18
|
+
import { clientRoutes } from "bosia:routes";
|
|
13
19
|
|
|
14
20
|
class Page {
|
|
15
21
|
// Real on the server too: the renderer seeds `router.currentRoute`/`.origin`
|
|
@@ -18,12 +24,21 @@ class Page {
|
|
|
18
24
|
// render that seeded nothing at all — a bare unit test, never a served request.
|
|
19
25
|
#url = $derived.by(() => new URL(router.currentRoute, router.origin || "http://localhost/"));
|
|
20
26
|
|
|
27
|
+
// clientRoutes carry the BASE_PATH prefix, and so does `router.currentRoute`
|
|
28
|
+
// (the renderer puts it back before SSR), so the pathname matches as-is.
|
|
29
|
+
#route = $derived.by(() => ({
|
|
30
|
+
id: findMatch(clientRoutes, this.#url.pathname)?.route.id ?? null,
|
|
31
|
+
}));
|
|
32
|
+
|
|
21
33
|
get url() {
|
|
22
34
|
return this.#url;
|
|
23
35
|
}
|
|
24
36
|
get pathname() {
|
|
25
37
|
return this.#url.pathname;
|
|
26
38
|
}
|
|
39
|
+
get route() {
|
|
40
|
+
return this.#route;
|
|
41
|
+
}
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
export const page = new Page();
|
|
@@ -10,8 +10,9 @@ import { base } from "./base.ts";
|
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Build the `_invalidated` mask bits for a target path using the current
|
|
13
|
-
* client loader cache. Char 0 = page, char i+1 = layout depth i; '1' =
|
|
14
|
-
* '0' =
|
|
13
|
+
* client loader cache. Char 0 = page, char i+1 = layout depth i; '1' = send,
|
|
14
|
+
* '0' = the client already holds it (the server still runs layout loaders,
|
|
15
|
+
* since they may be guards). Returns `null` when the route cannot be matched.
|
|
15
16
|
*/
|
|
16
17
|
export function buildMaskBits(path: string): string | null {
|
|
17
18
|
const url = new URL(path, window.location.origin);
|
|
@@ -42,37 +43,10 @@ export function buildMaskBits(path: string): string | null {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* layout depth whose mask bit is '0' (skipped) and whose cached entry exists,
|
|
48
|
-
* forward that layer's data so server-side downstream loaders see real
|
|
49
|
-
* `parent()` data instead of `{}`. Returns `{}` when nothing to carry.
|
|
50
|
-
*
|
|
51
|
-
* Client-supplied perf hint only — the server never trusts it for authz.
|
|
46
|
+
* Builds the `/__bosia/data/…` URL for a given client path. `fresh` marks a
|
|
47
|
+
* fetch that follows an `invalidate()`, so the server skips its cached copy.
|
|
52
48
|
*/
|
|
53
|
-
export function
|
|
54
|
-
path: string,
|
|
55
|
-
maskBits: string,
|
|
56
|
-
): Record<number, Record<string, any>> {
|
|
57
|
-
const snapshots: Record<number, Record<string, any>> = {};
|
|
58
|
-
const url = new URL(path, window.location.origin);
|
|
59
|
-
const match = findMatch(clientRoutes, url.pathname);
|
|
60
|
-
if (!match) return snapshots;
|
|
61
|
-
const layoutIds = (match.route as any).layoutIds as (string | null)[];
|
|
62
|
-
|
|
63
|
-
layoutIds.forEach((id, depth) => {
|
|
64
|
-
// maskBits char 0 = page, char depth+1 = layout depth. '0' = skipped.
|
|
65
|
-
if (maskBits[depth + 1] !== "0") return;
|
|
66
|
-
if (id === null) return;
|
|
67
|
-
const entry = appState.loaderCache.layouts[id];
|
|
68
|
-
if (entry) snapshots[depth] = entry.data;
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
return snapshots;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Builds the `/__bosia/data/…` URL for a given client path. */
|
|
75
|
-
export function dataUrl(path: string, invalidatedBits?: string): string {
|
|
49
|
+
export function dataUrl(path: string, invalidatedBits?: string, fresh = false): string {
|
|
76
50
|
const url = new URL(path, window.location.origin);
|
|
77
51
|
let p = url.pathname.replace(/\/$/, "");
|
|
78
52
|
let qs = url.search;
|
|
@@ -80,6 +54,7 @@ export function dataUrl(path: string, invalidatedBits?: string): string {
|
|
|
80
54
|
const sep = qs ? "&" : "?";
|
|
81
55
|
qs = `${qs}${sep}_invalidated=${invalidatedBits}`;
|
|
82
56
|
}
|
|
57
|
+
if (fresh) qs = `${qs}${qs ? "&" : "?"}_fresh=1`;
|
|
83
58
|
// The one place a path is still taken apart, and it is URL construction rather
|
|
84
59
|
// than navigation: the data endpoint is `<base>/__bosia/data` + the *app* path,
|
|
85
60
|
// so the mount prefix moves from the front of the route to the front of the
|
|
@@ -151,35 +126,39 @@ export function consumePrefetch(path: string): any | null {
|
|
|
151
126
|
|
|
152
127
|
/** Prefetches data for a path and stores in cache. No-op if already cached/in-flight. */
|
|
153
128
|
export async function prefetchPath(path: string): Promise<void> {
|
|
129
|
+
// Nothing to fetch for the page we're on. A click also focuses its link, so
|
|
130
|
+
// the focusin trigger fires ~100ms after the navigation it belongs to.
|
|
131
|
+
if (path === window.location.pathname + window.location.search) return;
|
|
154
132
|
// Warm the route's +loading.svelte chunk alongside its data, so the skeleton
|
|
155
133
|
// paints instantly on click instead of cold-importing after the old page lingers.
|
|
156
134
|
const warmMatch = findMatch(clientRoutes, new URL(path, window.location.origin).pathname);
|
|
157
|
-
|
|
135
|
+
// Also warm the page and layout chunks: a route not visited yet otherwise
|
|
136
|
+
// downloads its code only after the click. Errors are swallowed — a stale
|
|
137
|
+
// chunk is the click's to report, not the hover's (hydrate.ts reloads on it).
|
|
138
|
+
if (warmMatch) {
|
|
139
|
+
const route = warmMatch.route as typeof warmMatch.route & {
|
|
140
|
+
loading?: (() => Promise<unknown>) | null;
|
|
141
|
+
};
|
|
142
|
+
for (const load of [route.loading, route.page, ...route.layouts]) {
|
|
143
|
+
load?.().catch(() => {});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
158
146
|
|
|
159
147
|
const existing = prefetchCache.get(path);
|
|
160
148
|
if (existing && Date.now() - existing.ts <= 30_000) return;
|
|
161
149
|
if (existing) prefetchCache.delete(path);
|
|
162
150
|
if (pending.has(path)) return;
|
|
163
151
|
|
|
152
|
+
// A route without server data never fetches on click either.
|
|
153
|
+
if (!warmMatch?.route.hasServerData) return;
|
|
154
|
+
|
|
164
155
|
pending.add(path);
|
|
165
156
|
try {
|
|
166
|
-
// Send the same mask as a real client nav would so the server
|
|
167
|
-
//
|
|
168
|
-
//
|
|
157
|
+
// Send the same mask as a real client nav would so the server leaves out
|
|
158
|
+
// layers the client already holds. Falls back to sending everything when
|
|
159
|
+
// the route can't be matched (e.g. external/unknown URL).
|
|
169
160
|
const maskBits = buildMaskBits(path) ?? undefined;
|
|
170
|
-
|
|
171
|
-
// is computed with real parent() data, not {}. POST only when there's
|
|
172
|
-
// something to carry — keeps the no-skip case a cacheable/dedupable GET.
|
|
173
|
-
const snapshots = maskBits ? buildParentSnapshots(path, maskBits) : {};
|
|
174
|
-
const init: RequestInit =
|
|
175
|
-
Object.keys(snapshots).length > 0
|
|
176
|
-
? {
|
|
177
|
-
method: "POST",
|
|
178
|
-
headers: { "Content-Type": "application/json" },
|
|
179
|
-
body: JSON.stringify({ parentSnapshots: snapshots }),
|
|
180
|
-
}
|
|
181
|
-
: {};
|
|
182
|
-
const res = await fetch(dataUrl(path, maskBits), init);
|
|
161
|
+
const res = await fetch(dataUrl(path, maskBits));
|
|
183
162
|
// `ok` alone would cache a guard's login page (200 after the redirect was
|
|
184
163
|
// followed) as if it were this route's data.
|
|
185
164
|
if (res.ok && !res.redirected && isJsonResponse(res)) {
|
|
@@ -228,23 +207,39 @@ function observeViewportLinks(container: Element | Document = document) {
|
|
|
228
207
|
return observer;
|
|
229
208
|
}
|
|
230
209
|
|
|
210
|
+
/** The link a hover/touch/focus event is on, when it sits under `data-bosia-preload="hover"`. */
|
|
211
|
+
function hoverLinkHref(target: EventTarget | null): string | null {
|
|
212
|
+
if (!(target instanceof Element)) return null;
|
|
213
|
+
// Early exit: skip if no [data-bosia-preload="hover"] ancestor exists
|
|
214
|
+
const preloadEl = target.closest("[data-bosia-preload]");
|
|
215
|
+
if (!preloadEl || preloadEl.getAttribute("data-bosia-preload") !== "hover") return null;
|
|
216
|
+
const anchor = target.closest("a") as HTMLAnchorElement | null;
|
|
217
|
+
return anchor ? getLinkHref(anchor) : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
231
220
|
export function initPrefetch(): void {
|
|
232
|
-
// ── Hover strategy (event delegation,
|
|
221
|
+
// ── Hover strategy (event delegation, 100ms debounce) ────
|
|
233
222
|
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
|
|
234
223
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
// Early exit: skip if no [data-bosia-preload="hover"] ancestor exists
|
|
238
|
-
const preloadEl = e.target.closest("[data-bosia-preload]");
|
|
239
|
-
if (!preloadEl || preloadEl.getAttribute("data-bosia-preload") !== "hover") return;
|
|
240
|
-
const anchor = e.target.closest("a") as HTMLAnchorElement | null;
|
|
241
|
-
if (!anchor) return;
|
|
242
|
-
const href = getLinkHref(anchor);
|
|
224
|
+
const onIntent = (e: Event) => {
|
|
225
|
+
const href = hoverLinkHref(e.target);
|
|
243
226
|
if (!href) return;
|
|
244
|
-
|
|
245
227
|
if (hoverTimer) clearTimeout(hoverTimer);
|
|
246
228
|
hoverTimer = setTimeout(() => prefetchPath(href), 100);
|
|
247
|
-
}
|
|
229
|
+
};
|
|
230
|
+
document.addEventListener("mouseover", onIntent);
|
|
231
|
+
// Keyboard users tab onto a link before pressing Enter — same signal.
|
|
232
|
+
document.addEventListener("focusin", onIntent);
|
|
233
|
+
// Touch screens have no hover; a touch is followed by the click within
|
|
234
|
+
// ~100ms or not at all, so start right away instead of debouncing.
|
|
235
|
+
document.addEventListener(
|
|
236
|
+
"touchstart",
|
|
237
|
+
(e) => {
|
|
238
|
+
const href = hoverLinkHref(e.target);
|
|
239
|
+
if (href) prefetchPath(href);
|
|
240
|
+
},
|
|
241
|
+
{ passive: true },
|
|
242
|
+
);
|
|
248
243
|
|
|
249
244
|
document.addEventListener("mouseout", () => {
|
|
250
245
|
if (hoverTimer) {
|
package/src/core/dev.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn, type Subprocess } from "bun";
|
|
2
|
-
import { readdirSync, statSync, watch, type Dirent } from "fs";
|
|
2
|
+
import { readdirSync, rmSync, statSync, watch, type Dirent } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { loadEnv } from "./env.ts";
|
|
5
5
|
import { BOSIA_NODE_PATH } from "./paths.ts";
|
|
@@ -12,6 +12,12 @@ import { affectsRouteManifest, shouldIgnoreForRebuild } from "./devWatch.ts";
|
|
|
12
12
|
// but dev.ts itself never reads it.
|
|
13
13
|
const DEV_OUT_DIR = ".bosia/dev";
|
|
14
14
|
|
|
15
|
+
// Compiled-component cache shared by every rebuild in this session (see
|
|
16
|
+
// svelteCompiler.ts). Outside DEV_OUT_DIR, which each build clears. Emptied
|
|
17
|
+
// here, once per `bosia dev`, so it only ever holds this session's entries.
|
|
18
|
+
const SVELTE_CACHE_DIR = ".bosia/cache/svelte";
|
|
19
|
+
rmSync(SVELTE_CACHE_DIR, { recursive: true, force: true });
|
|
20
|
+
|
|
15
21
|
// Snapshot pure shell env BEFORE any loadEnv call pollutes process.env.
|
|
16
22
|
// On `.env*` change we restore from this snapshot, then re-run loadEnv,
|
|
17
23
|
// so removed/renamed keys no longer linger in the dev process.
|
|
@@ -142,6 +148,10 @@ async function runBuild(): Promise<boolean> {
|
|
|
142
148
|
...process.env,
|
|
143
149
|
BOSIA_OUT_DIR: DEV_OUT_DIR,
|
|
144
150
|
BOSIA_SKIP_ROUTE_SCAN: skipScan ? "1" : "0",
|
|
151
|
+
// The dev server never serves prerendered pages or dist/static, so the
|
|
152
|
+
// build skips producing them (~25% of a rebuild on the demo).
|
|
153
|
+
BOSIA_DEV_BUILD: "1",
|
|
154
|
+
BOSIA_SVELTE_CACHE_DIR: SVELTE_CACHE_DIR,
|
|
145
155
|
},
|
|
146
156
|
});
|
|
147
157
|
return (await proc.exited) === 0;
|
package/src/core/etag.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// ─── ETag matching ───────────────────────────────────────
|
|
2
|
+
// Shared by static files (staticManifest.ts) and cached responses (cache.ts).
|
|
3
|
+
// No runtime imports: cache.ts also runs on Cloudflare Workers.
|
|
4
|
+
|
|
5
|
+
/** True when an `If-None-Match` header names `etag` (or `*`). Weak tags match too. */
|
|
6
|
+
export function matchesEtag(header: string | null, etag: string): boolean {
|
|
7
|
+
if (!header) return false;
|
|
8
|
+
for (const raw of header.split(",")) {
|
|
9
|
+
const t = raw.trim();
|
|
10
|
+
if (t === "*" || t === etag || (t.startsWith("W/") && t.slice(2) === etag)) return true;
|
|
11
|
+
}
|
|
12
|
+
return false;
|
|
13
|
+
}
|
package/src/core/hooks.ts
CHANGED
|
@@ -58,6 +58,19 @@ export type RequestEvent = {
|
|
|
58
58
|
*/
|
|
59
59
|
locals: Record<string, any> & { nonce?: string };
|
|
60
60
|
params: Record<string, string>;
|
|
61
|
+
/**
|
|
62
|
+
* The matched route. `id` is its folder under `src/routes` with groups kept,
|
|
63
|
+
* e.g. `"/(private)/dashboard/[id]"`; `null` when nothing matched. Set before
|
|
64
|
+
* `handle` runs, for page, client-navigation data, form and API requests
|
|
65
|
+
* alike, so one check guards a whole group:
|
|
66
|
+
*
|
|
67
|
+
* ```ts
|
|
68
|
+
* if (event.route.id?.startsWith("/(private)") && !event.locals.user) {
|
|
69
|
+
* throw redirect(303, "/login");
|
|
70
|
+
* }
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
route: { id: string | null };
|
|
61
74
|
cookies: Cookies;
|
|
62
75
|
/**
|
|
63
76
|
* True when the client router is fetching this page's loader data for a
|
|
@@ -79,6 +92,8 @@ export type RequestEvent = {
|
|
|
79
92
|
export type LoadEvent = {
|
|
80
93
|
url: URL;
|
|
81
94
|
params: Record<string, string>;
|
|
95
|
+
/** The route being rendered; see `RequestEvent.route`. */
|
|
96
|
+
route: { id: string };
|
|
82
97
|
locals: Record<string, any>;
|
|
83
98
|
cookies: Cookies;
|
|
84
99
|
fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
@@ -169,6 +184,8 @@ export type Handle = (input: {
|
|
|
169
184
|
|
|
170
185
|
export type MetadataEvent = {
|
|
171
186
|
params: Record<string, string>;
|
|
187
|
+
/** The route being rendered; see `RequestEvent.route`. */
|
|
188
|
+
route: { id: string };
|
|
172
189
|
url: URL;
|
|
173
190
|
locals: Record<string, any>;
|
|
174
191
|
cookies: Cookies;
|
package/src/core/html.ts
CHANGED
|
@@ -62,18 +62,84 @@ export function baseScript(nonce?: string): string {
|
|
|
62
62
|
* no buster (same reason as ENTRY). Unknown pattern or an older dist/ without
|
|
63
63
|
* the `preload` field → nothing, and the page loads as it did before. */
|
|
64
64
|
export function routePreloadLinks(pattern?: string): string {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
const preload = distManifest.preload;
|
|
66
|
+
if (!pattern || !preload) return "";
|
|
67
|
+
let byPattern = preloadLinks.get(preload);
|
|
68
|
+
if (!byPattern) preloadLinks.set(preload, (byPattern = new Map()));
|
|
69
|
+
let links = byPattern.get(pattern);
|
|
70
|
+
if (links === undefined) {
|
|
71
|
+
links = (preload[pattern] ?? [])
|
|
72
|
+
.map((f) => `\n <link rel="modulepreload" href="${DIST}/${f}">`)
|
|
73
|
+
.join("");
|
|
74
|
+
byPattern.set(pattern, links);
|
|
75
|
+
}
|
|
76
|
+
return links;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// One header value per route pattern (plus "" for no pattern), built on first
|
|
80
|
+
// use. Bounded by the number of routes.
|
|
81
|
+
const linkHeaders = new Map<string, string>();
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `Link` response header naming the stylesheets and, when the page hydrates,
|
|
85
|
+
* the client entry and the route's chunks. The document lists the same files,
|
|
86
|
+
* but a CDN that sends 103 Early Hints (Cloudflare) reads them off this header
|
|
87
|
+
* and lets the browser start downloading while the loaders still run.
|
|
88
|
+
* Prod only — dev URLs carry a cache buster and nothing sits in front.
|
|
89
|
+
*/
|
|
90
|
+
export function preloadLinkHeader(pattern: string | undefined, csr: boolean): string | null {
|
|
91
|
+
if (isDev) return null;
|
|
92
|
+
const key = `${csr ? "1" : "0"}${pattern ?? ""}`;
|
|
93
|
+
let value = linkHeaders.get(key);
|
|
94
|
+
if (value === undefined) {
|
|
95
|
+
const tw = distManifest.tw ? `${DIST}/${distManifest.tw}` : TW_CSS;
|
|
96
|
+
const parts = [tw, ...(distManifest.css ?? []).map((f) => `${DIST}/${f}`)].map(
|
|
97
|
+
(href) => `<${href}>; rel=preload; as=style`,
|
|
98
|
+
);
|
|
99
|
+
if (csr) {
|
|
100
|
+
const scripts = [
|
|
101
|
+
ENTRY,
|
|
102
|
+
...(distManifest.preload?.[pattern ?? ""] ?? []).map((f) => `${DIST}/${f}`),
|
|
103
|
+
];
|
|
104
|
+
for (const href of scripts) parts.push(`<${href}>; rel=modulepreload`);
|
|
105
|
+
}
|
|
106
|
+
value = parts.join(", ");
|
|
107
|
+
linkHeaders.set(key, value);
|
|
108
|
+
}
|
|
109
|
+
return value;
|
|
69
110
|
}
|
|
70
111
|
|
|
112
|
+
/** `extra` with the preload `Link` header added — after any `link` a loader set. */
|
|
113
|
+
export function withPreloadLink(
|
|
114
|
+
extra: Record<string, string> | undefined,
|
|
115
|
+
pattern: string | undefined,
|
|
116
|
+
csr: boolean,
|
|
117
|
+
): Record<string, string> | undefined {
|
|
118
|
+
const link = preloadLinkHeader(pattern, csr);
|
|
119
|
+
if (!link) return extra;
|
|
120
|
+
const own = extra?.["link"];
|
|
121
|
+
return { ...extra, link: own ? `${own}, ${link}` : link };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The head fragments below are the same on every request, so each is built
|
|
125
|
+
// once and reused for as long as the manifest field it reads stays the same
|
|
126
|
+
// object (tests swap them out).
|
|
127
|
+
const preloadLinks = new WeakMap<object, Map<string, string>>();
|
|
128
|
+
|
|
71
129
|
/** Tailwind stylesheet link. Content-hashed name needs no cache buster — the
|
|
72
130
|
* hash IS the buster. Fallback keeps older dist/ artifacts (no `tw` field) styled. */
|
|
131
|
+
let twLink: { tw: string | undefined; html: string } | null = null;
|
|
73
132
|
function twCssLink(): string {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
133
|
+
const tw = distManifest.tw;
|
|
134
|
+
if (!twLink || twLink.tw !== tw) {
|
|
135
|
+
twLink = {
|
|
136
|
+
tw,
|
|
137
|
+
html: tw
|
|
138
|
+
? `<link rel="stylesheet" href="${DIST}/${tw}">`
|
|
139
|
+
: `<link rel="stylesheet" href="${TW_CSS}${cacheBust}">`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return twLink.html;
|
|
77
143
|
}
|
|
78
144
|
|
|
79
145
|
/** The build-time component stylesheet (scoped `<style>` blocks, concatenated).
|
|
@@ -83,10 +149,18 @@ function twCssLink(): string {
|
|
|
83
149
|
* is settled on source order. Linking before Tailwind would silently flip
|
|
84
150
|
* which one wins. Each entry carries its own indent and newline, so an app with
|
|
85
151
|
* no scoped styles at all contributes nothing rather than a blank line. */
|
|
152
|
+
let cssLinks: { css: string[] | undefined; html: string } | null = null;
|
|
86
153
|
function componentCssLinks(): string {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
154
|
+
const css = distManifest.css;
|
|
155
|
+
if (!cssLinks || cssLinks.css !== css) {
|
|
156
|
+
cssLinks = {
|
|
157
|
+
css,
|
|
158
|
+
html: (css ?? [])
|
|
159
|
+
.map((f: string) => ` <link rel="stylesheet" href="${DIST}/${f}">\n`)
|
|
160
|
+
.join(""),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return cssLinks.html;
|
|
90
164
|
}
|
|
91
165
|
|
|
92
166
|
/** Inline theme bootstrap — runs before paint to avoid FOUC. theme ∈ light|dark|system (missing = system). */
|
|
@@ -464,17 +538,28 @@ export function disableCompression(): void {
|
|
|
464
538
|
// Shared, stateless — one instance instead of a fresh allocation per response.
|
|
465
539
|
const textEncoder = new TextEncoder();
|
|
466
540
|
|
|
467
|
-
|
|
541
|
+
/** Encodings stored ahead of time — cache entries and build-time static files. */
|
|
542
|
+
export type StoredEncoding = "br" | "gzip";
|
|
543
|
+
export type Encoding = StoredEncoding | "zstd";
|
|
468
544
|
|
|
469
|
-
/** Best encoding the client accepts — brotli over gzip, null for identity.
|
|
545
|
+
/** Best stored encoding the client accepts — brotli over gzip, null for identity.
|
|
470
546
|
* A substring check, not q-value parsing: no browser sends `br;q=0`. */
|
|
471
|
-
export function pickEncoding(accept: string | null):
|
|
547
|
+
export function pickEncoding(accept: string | null): StoredEncoding | null {
|
|
472
548
|
if (!accept) return null;
|
|
473
549
|
if (accept.includes("br")) return "br";
|
|
474
550
|
if (accept.includes("gzip")) return "gzip";
|
|
475
551
|
return null;
|
|
476
552
|
}
|
|
477
553
|
|
|
554
|
+
/** Best encoding for a body compressed on the spot. zstd comes first: at
|
|
555
|
+
* level 3 it matches brotli q3's size on an HTML page in about half the CPU
|
|
556
|
+
* (55µs vs 95µs for 7KB). Current Chrome and Firefox send it; everyone else
|
|
557
|
+
* falls back to `pickEncoding`. */
|
|
558
|
+
export function pickRequestEncoding(accept: string | null): Encoding | null {
|
|
559
|
+
if (accept?.includes("zstd")) return "zstd";
|
|
560
|
+
return pickEncoding(accept);
|
|
561
|
+
}
|
|
562
|
+
|
|
478
563
|
// Per-request brotli runs once per response, so it favors speed: q3 is ~2x
|
|
479
564
|
// faster than q5 for ~5–25% bigger output. Cached variants are built once and
|
|
480
565
|
// served many times, so they take q5. 11 (the default) would block the event
|
|
@@ -485,6 +570,10 @@ const BROTLI = {
|
|
|
485
570
|
cache: { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 5 } },
|
|
486
571
|
};
|
|
487
572
|
|
|
573
|
+
// Bun's native gzip is ~40% faster than node:zlib's at the same level and
|
|
574
|
+
// size. Workers has no `Bun`, but never compresses (see compressionOn).
|
|
575
|
+
const hasBun = typeof Bun !== "undefined";
|
|
576
|
+
|
|
488
577
|
/** The one place runtime compression quality is set — cache.ts builds its
|
|
489
578
|
* stored variants through here too. gzip keeps zlib's default level. */
|
|
490
579
|
export function encodeBytes(
|
|
@@ -492,6 +581,9 @@ export function encodeBytes(
|
|
|
492
581
|
enc: Encoding,
|
|
493
582
|
quality: Quality = "request",
|
|
494
583
|
): Uint8Array<ArrayBuffer> {
|
|
584
|
+
if (enc === "zstd") return Bun.zstdCompressSync(bytes, { level: 3 }) as Uint8Array<ArrayBuffer>;
|
|
585
|
+
if (enc === "gzip" && hasBun)
|
|
586
|
+
return Bun.gzipSync(bytes as Uint8Array<ArrayBuffer>) as Uint8Array<ArrayBuffer>;
|
|
495
587
|
const out = enc === "br" ? brotliCompressSync(bytes, BROTLI[quality]) : gzipSync(bytes);
|
|
496
588
|
return new Uint8Array(out) as Uint8Array<ArrayBuffer>;
|
|
497
589
|
}
|
|
@@ -504,7 +596,10 @@ export function encodeForRequest(
|
|
|
504
596
|
req: Request,
|
|
505
597
|
quality: Quality = "request",
|
|
506
598
|
): Encoded | null {
|
|
507
|
-
const
|
|
599
|
+
const accept = req.headers.get("accept-encoding");
|
|
600
|
+
// A cache-quality body is also stored in the cache entry, which only keeps
|
|
601
|
+
// brotli and gzip copies — so it never picks zstd.
|
|
602
|
+
const enc = quality === "cache" ? pickEncoding(accept) : pickRequestEncoding(accept);
|
|
508
603
|
// Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
|
|
509
604
|
// responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
|
|
510
605
|
if (!compressionOn || isDev || !enc || bytes.length <= GZIP_MIN_BYTES) return null;
|
|
@@ -2,7 +2,7 @@ import { parse, compile } from "svelte/compiler";
|
|
|
2
2
|
import MagicString from "magic-string";
|
|
3
3
|
import { relative } from "node:path";
|
|
4
4
|
import type { BunPlugin } from "bun";
|
|
5
|
-
import { svelteMapCache } from "../../svelteCompiler.ts";
|
|
5
|
+
import { cachedCompile, svelteMapCache } from "../../svelteCompiler.ts";
|
|
6
6
|
import { lineColFromOffset } from "../../sourceLoc.ts";
|
|
7
7
|
import { toPosix } from "../../paths.ts";
|
|
8
8
|
import { collectComponentCss } from "../../componentCss.ts";
|
|
@@ -133,21 +133,35 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
|
|
|
133
133
|
const rel = toPosix(relative(cwd, args.path));
|
|
134
134
|
const transformed = injectLocs(source, rel);
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
dev,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
136
|
+
// Reused across `bosia dev` rebuilds when nothing it depends on changed
|
|
137
|
+
// (see cachedCompile in svelteCompiler.ts).
|
|
138
|
+
const { value: result } = await cachedCompile(
|
|
139
|
+
["inspector", args.path, generate, dev, transformed],
|
|
140
|
+
() => {
|
|
141
|
+
const out = compile(transformed, {
|
|
142
|
+
filename: args.path,
|
|
143
|
+
generate,
|
|
144
|
+
dev,
|
|
145
|
+
hmr: dev,
|
|
146
|
+
// Mirror the prod compiler (svelteCompiler.ts): external on both
|
|
147
|
+
// targets, with the client's rules harvested into one stylesheet
|
|
148
|
+
// that the head links. This plugin registers ahead of the main
|
|
149
|
+
// compiler and its onLoad wins in dev, so letting the two drift
|
|
150
|
+
// here is how dev and prod stop agreeing about first paint.
|
|
151
|
+
css: "external",
|
|
152
|
+
preserveWhitespace: dev,
|
|
153
|
+
preserveComments: dev,
|
|
154
|
+
cssHash: ({ css }) => `svelte-${fnv(css)}`,
|
|
155
|
+
});
|
|
156
|
+
return {
|
|
157
|
+
js: {
|
|
158
|
+
code: out.js.code,
|
|
159
|
+
map: typeof out.js.map === "string" ? JSON.parse(out.js.map) : out.js.map,
|
|
160
|
+
},
|
|
161
|
+
css: out.css ? { code: out.css.code } : null,
|
|
162
|
+
};
|
|
163
|
+
},
|
|
164
|
+
);
|
|
151
165
|
|
|
152
166
|
// Store Svelte's compile-step map. Bun's bundler doesn't chain
|
|
153
167
|
// onLoad sourcemaps, so the final bundle map resolves stack frames
|
|
@@ -164,8 +178,7 @@ export function createInspectorBunPlugin(opts: InspectorBunPluginOptions): BunPl
|
|
|
164
178
|
// line numbers differ. The resolver translates browser-side stack
|
|
165
179
|
// frames (delivered via SSE), which run client code.
|
|
166
180
|
if (dev && generate === "client" && result.js.map) {
|
|
167
|
-
|
|
168
|
-
svelteMapCache.set(args.path, m);
|
|
181
|
+
svelteMapCache.set(args.path, result.js.map);
|
|
169
182
|
}
|
|
170
183
|
|
|
171
184
|
// Client only — see the same guard in svelteCompiler.ts.
|