bosia 0.6.7 → 0.6.9
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 +15 -3
- package/src/core/client/router.svelte.ts +32 -0
- package/src/core/server.ts +23 -6
- package/src/core/staticManifest.ts +106 -0
- package/templates/default/README.md +0 -2
- package/templates/default/src/routes/(public)/about/+page.server.ts +0 -1
- package/templates/default/src/routes/(public)/about/+page.svelte +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosia",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing inspired by SvelteKit. No Node.js, no Vite, no adapters.",
|
|
6
6
|
"keywords": [
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import {
|
|
2
|
+
import { tick } from "svelte";
|
|
3
|
+
import { router, scrollToHash } from "./router.svelte.ts";
|
|
3
4
|
import { findMatch } from "../matcher.ts";
|
|
4
5
|
import { clientRoutes } from "bosia:routes";
|
|
5
6
|
import { consumePrefetch, prefetchCache, dataUrl } from "./prefetch.ts";
|
|
@@ -198,7 +199,12 @@
|
|
|
198
199
|
appState.errorComponent = errMod.default;
|
|
199
200
|
appState.errorProps = { error: { status: errStatus, message: errMessage } };
|
|
200
201
|
appState.errorDepth = K;
|
|
201
|
-
if (router.isPush && !router.suppressScroll)
|
|
202
|
+
if (router.isPush && !router.suppressScroll) {
|
|
203
|
+
const hash = window.location.hash;
|
|
204
|
+
tick().then(() => {
|
|
205
|
+
if (!scrollToHash(hash)) window.scrollTo(0, 0);
|
|
206
|
+
});
|
|
207
|
+
}
|
|
202
208
|
router.suppressScroll = false;
|
|
203
209
|
settle({ url, params: match.params });
|
|
204
210
|
} catch {
|
|
@@ -290,7 +296,13 @@
|
|
|
290
296
|
|
|
291
297
|
// Scroll to top on forward navigation (not on popstate/back-forward).
|
|
292
298
|
// goto({ noScroll: true }) flips `router.suppressScroll` for one nav.
|
|
293
|
-
|
|
299
|
+
// If the destination URL has a hash, scroll to that element instead.
|
|
300
|
+
if (router.isPush && !router.suppressScroll) {
|
|
301
|
+
const hash = window.location.hash;
|
|
302
|
+
tick().then(() => {
|
|
303
|
+
if (!scrollToHash(hash)) window.scrollTo(0, 0);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
294
306
|
router.suppressScroll = false;
|
|
295
307
|
|
|
296
308
|
// Update document title and meta description from server metadata
|
|
@@ -16,6 +16,22 @@ function buildTarget(path: string): { url: URL; params: Record<string, string> }
|
|
|
16
16
|
return { url, params: match?.params ?? {} };
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
export function scrollToHash(hash: string): boolean {
|
|
20
|
+
if (typeof document === "undefined" || !hash) return false;
|
|
21
|
+
const raw = hash.startsWith("#") ? hash.slice(1) : hash;
|
|
22
|
+
if (!raw) return false;
|
|
23
|
+
let id = raw;
|
|
24
|
+
try {
|
|
25
|
+
id = decodeURIComponent(raw);
|
|
26
|
+
} catch {
|
|
27
|
+
// Fallback to raw if URI sequence is malformed.
|
|
28
|
+
}
|
|
29
|
+
const el = document.getElementById(id) ?? document.getElementById(raw);
|
|
30
|
+
if (!el) return false;
|
|
31
|
+
el.scrollIntoView();
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
export const router = new (class Router {
|
|
20
36
|
currentRoute = $state(
|
|
21
37
|
typeof window !== "undefined"
|
|
@@ -89,6 +105,22 @@ export const router = new (class Router {
|
|
|
89
105
|
if (anchor.rel.split(/\s+/).includes("external")) return;
|
|
90
106
|
if (anchor.protocol !== "https:" && anchor.protocol !== "http:") return;
|
|
91
107
|
|
|
108
|
+
// Same-page hash navigation: skip page reload, just update URL and scroll
|
|
109
|
+
// to the target element. Mirrors browser default for in-page anchors.
|
|
110
|
+
const samePage =
|
|
111
|
+
anchor.pathname === window.location.pathname &&
|
|
112
|
+
anchor.search === window.location.search;
|
|
113
|
+
if (samePage && anchor.hash) {
|
|
114
|
+
e.preventDefault();
|
|
115
|
+
const finalPath = anchor.pathname + anchor.search + anchor.hash;
|
|
116
|
+
if (this.currentRoute !== finalPath) {
|
|
117
|
+
history.pushState({}, "", finalPath);
|
|
118
|
+
this.currentRoute = finalPath;
|
|
119
|
+
}
|
|
120
|
+
scrollToHash(anchor.hash);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
92
124
|
e.preventDefault();
|
|
93
125
|
this.navigate(anchor.pathname + anchor.search + anchor.hash, { source: "link" });
|
|
94
126
|
});
|
package/src/core/server.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } f
|
|
|
24
24
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
25
25
|
import { dev500WithPlugins } from "./dev-500.ts";
|
|
26
26
|
import { OUT_DIR } from "./paths.ts";
|
|
27
|
+
import { buildStaticManifest, lookupStatic } from "./staticManifest.ts";
|
|
27
28
|
import { dedup, dedupKey } from "./dedup.ts";
|
|
28
29
|
import {
|
|
29
30
|
CACHE_ENABLED,
|
|
@@ -170,6 +171,12 @@ function parseActionName(url: URL): string {
|
|
|
170
171
|
return "default";
|
|
171
172
|
}
|
|
172
173
|
|
|
174
|
+
// Prod: walk `dist/client`, `./public`, and `OUT_DIR` once at boot so static-asset
|
|
175
|
+
// requests cost a single Map lookup instead of up to 4 `Bun.file().exists()` syscalls.
|
|
176
|
+
// Dev keeps the per-request fallthrough so files dropped into `public/` mid-session
|
|
177
|
+
// are served without a restart (dev's watcher doesn't fire on `public/`).
|
|
178
|
+
const staticManifest = isDev ? null : buildStaticManifest(OUT_DIR);
|
|
179
|
+
|
|
173
180
|
async function resolve(event: RequestEvent): Promise<Response> {
|
|
174
181
|
const { request, url, locals, cookies } = event;
|
|
175
182
|
const path = url.pathname;
|
|
@@ -327,7 +334,21 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
327
334
|
|
|
328
335
|
// Static files
|
|
329
336
|
if (isStaticPath(path)) {
|
|
330
|
-
//
|
|
337
|
+
// Prod fast path: single Map lookup, no per-request stat calls.
|
|
338
|
+
if (staticManifest) {
|
|
339
|
+
const hit = lookupStatic(staticManifest, path);
|
|
340
|
+
if (hit) {
|
|
341
|
+
return new Response(
|
|
342
|
+
Bun.file(hit.absPath),
|
|
343
|
+
hit.cacheControl
|
|
344
|
+
? { headers: { "Cache-Control": hit.cacheControl } }
|
|
345
|
+
: undefined,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
return new Response("Not Found", { status: 404 });
|
|
349
|
+
}
|
|
350
|
+
// Dev: keep the per-request fallthrough so files dropped into `public/`
|
|
351
|
+
// mid-session are served without a restart.
|
|
331
352
|
if (path.startsWith("/dist/client/")) {
|
|
332
353
|
const resolved = safePath(
|
|
333
354
|
`${OUT_DIR}/client`,
|
|
@@ -336,11 +357,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
336
357
|
if (resolved) {
|
|
337
358
|
const file = Bun.file(resolved);
|
|
338
359
|
if (await file.exists()) {
|
|
339
|
-
|
|
340
|
-
const isHashed = /\-[a-z0-9]{8,}\.[a-z]+$/.test(filename);
|
|
341
|
-
const cacheControl =
|
|
342
|
-
!isDev && isHashed ? "public, max-age=31536000, immutable" : "no-cache";
|
|
343
|
-
return new Response(file, { headers: { "Cache-Control": cacheControl } });
|
|
360
|
+
return new Response(file, { headers: { "Cache-Control": "no-cache" } });
|
|
344
361
|
}
|
|
345
362
|
}
|
|
346
363
|
return new Response("Not Found", { status: 404 });
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
2
|
+
import { basename, join, resolve as resolvePath } from "path";
|
|
3
|
+
|
|
4
|
+
export type StaticEntry = { absPath: string; cacheControl?: string };
|
|
5
|
+
export type StaticManifest = Map<string, StaticEntry>;
|
|
6
|
+
|
|
7
|
+
const HASHED_BASENAME = /\-[a-z0-9]{8,}\.[a-z]+$/;
|
|
8
|
+
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
|
9
|
+
const DEFAULT_CACHE = "no-cache";
|
|
10
|
+
|
|
11
|
+
// Files/dirs at OUT_DIR root that the manifest must not surface — they're either
|
|
12
|
+
// build metadata or re-merges already covered by the per-root walks.
|
|
13
|
+
const OUT_DIR_SKIP_DIRS = new Set(["client", "static", "prerendered", "server"]);
|
|
14
|
+
const OUT_DIR_SKIP_FILES = new Set(["manifest.json", "route-manifest.json"]);
|
|
15
|
+
|
|
16
|
+
const RESERVED_PREFIX = "/__bosia/";
|
|
17
|
+
|
|
18
|
+
function* walk(dir: string, rel = ""): Generator<{ abs: string; rel: string }> {
|
|
19
|
+
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
|
20
|
+
try {
|
|
21
|
+
entries = readdirSync(dir, { withFileTypes: true, encoding: "utf8" }) as unknown as Array<{
|
|
22
|
+
name: string;
|
|
23
|
+
isDirectory(): boolean;
|
|
24
|
+
isFile(): boolean;
|
|
25
|
+
}>;
|
|
26
|
+
} catch {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
for (const ent of entries) {
|
|
30
|
+
const childAbs = join(dir, ent.name);
|
|
31
|
+
const childRel = rel ? `${rel}/${ent.name}` : ent.name;
|
|
32
|
+
if (ent.isDirectory()) {
|
|
33
|
+
yield* walk(childAbs, childRel);
|
|
34
|
+
} else if (ent.isFile()) {
|
|
35
|
+
yield { abs: childAbs, rel: childRel };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function addOnce(manifest: StaticManifest, key: string, entry: StaticEntry) {
|
|
41
|
+
if (key.startsWith(RESERVED_PREFIX)) return;
|
|
42
|
+
if (manifest.has(key)) return;
|
|
43
|
+
manifest.set(key, entry);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function buildStaticManifest(outDir: string): StaticManifest {
|
|
47
|
+
const manifest: StaticManifest = new Map();
|
|
48
|
+
const outAbs = resolvePath(outDir);
|
|
49
|
+
|
|
50
|
+
const clientRoot = join(outAbs, "client");
|
|
51
|
+
if (existsSync(clientRoot)) {
|
|
52
|
+
for (const { abs, rel } of walk(clientRoot)) {
|
|
53
|
+
const cacheControl = HASHED_BASENAME.test(basename(rel))
|
|
54
|
+
? IMMUTABLE_CACHE
|
|
55
|
+
: DEFAULT_CACHE;
|
|
56
|
+
addOnce(manifest, `/dist/client/${rel}`, { absPath: abs, cacheControl });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const publicRoot = resolvePath("./public");
|
|
61
|
+
if (existsSync(publicRoot)) {
|
|
62
|
+
for (const { abs, rel } of walk(publicRoot)) {
|
|
63
|
+
addOnce(manifest, `/${rel}`, { absPath: abs });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (existsSync(outAbs)) {
|
|
68
|
+
let rootEntries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
|
69
|
+
try {
|
|
70
|
+
rootEntries = readdirSync(outAbs, {
|
|
71
|
+
withFileTypes: true,
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
}) as unknown as Array<{ name: string; isDirectory(): boolean; isFile(): boolean }>;
|
|
74
|
+
} catch {
|
|
75
|
+
rootEntries = [];
|
|
76
|
+
}
|
|
77
|
+
for (const ent of rootEntries) {
|
|
78
|
+
if (ent.isDirectory()) {
|
|
79
|
+
if (OUT_DIR_SKIP_DIRS.has(ent.name)) continue;
|
|
80
|
+
const sub = join(outAbs, ent.name);
|
|
81
|
+
for (const { abs, rel } of walk(sub, ent.name)) {
|
|
82
|
+
addOnce(manifest, `/${rel}`, { absPath: abs });
|
|
83
|
+
}
|
|
84
|
+
} else if (ent.isFile()) {
|
|
85
|
+
if (OUT_DIR_SKIP_FILES.has(ent.name)) continue;
|
|
86
|
+
addOnce(manifest, `/${ent.name}`, { absPath: join(outAbs, ent.name) });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return manifest;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticEntry | null {
|
|
95
|
+
const key = urlPath.split("?")[0];
|
|
96
|
+
return manifest.get(key) ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Re-export for tests that want to confirm a file-on-disk exists at the entry.
|
|
100
|
+
export function entryFileExists(entry: StaticEntry): boolean {
|
|
101
|
+
try {
|
|
102
|
+
return statSync(entry.absPath).isFile();
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export const prerender = true;
|