bosia 0.8.11 → 0.8.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "0.8.11",
3
+ "version": "0.8.12",
4
4
  "type": "module",
5
5
  "description": "A fast, batteries-included fullstack framework — SSR · Svelte 5 Runes · Bun · ElysiaJS. File-based routing No Node.js, no Vite, no adapters.",
6
6
  "keywords": [
@@ -16,20 +16,29 @@ interface ApiRouteLike {
16
16
  * the catch-all handler. Non-prerender bare-path matches fall through to the
17
17
  * literal `.json` path so legitimate `<segment>.json` routes still resolve.
18
18
  */
19
- export async function resolveApiMatch<T extends ApiRouteLike>(
19
+ export function resolveApiMatch<T extends ApiRouteLike>(
20
+ routes: T[],
21
+ path: string,
22
+ ): RouteMatch<T> | null | Promise<RouteMatch<T> | null> {
23
+ // Only `.json` needs the async alias probe (it may await a module to read
24
+ // `prerender`). Plain paths resolve synchronously so the common request
25
+ // never pays a microtask hop.
26
+ if (path.endsWith(".json")) return resolveJsonMatch(routes, path);
27
+ return findMatch(routes, path);
28
+ }
29
+
30
+ async function resolveJsonMatch<T extends ApiRouteLike>(
20
31
  routes: T[],
21
32
  path: string,
22
33
  ): Promise<RouteMatch<T> | null> {
23
- if (path.endsWith(".json")) {
24
- const bare = path.slice(0, -".json".length);
25
- const aliased = findMatch(routes, bare);
26
- if (aliased) {
27
- try {
28
- const mod = await aliased.route.module();
29
- if (mod.prerender === true) return aliased;
30
- } catch {
31
- /* fall through to literal-path match */
32
- }
34
+ const bare = path.slice(0, -".json".length);
35
+ const aliased = findMatch(routes, bare);
36
+ if (aliased) {
37
+ try {
38
+ const mod = await aliased.route.module();
39
+ if (mod.prerender === true) return aliased;
40
+ } catch {
41
+ /* fall through to literal-path match */
33
42
  }
34
43
  }
35
44
  return findMatch(routes, path);
@@ -54,7 +54,8 @@ function parseCookies(header: string): Record<string, string> {
54
54
  export class CookieJar implements Cookies {
55
55
  private static _warnedSecureOverHttp = false;
56
56
 
57
- private _incoming: Record<string, string>;
57
+ private _cookieHeader: string;
58
+ private _parsed: Record<string, string> | null = null;
58
59
  private _outgoing: string[] = [];
59
60
  private _defaults: CookieOptions;
60
61
  private _accessed = false;
@@ -62,13 +63,20 @@ export class CookieJar implements Cookies {
62
63
  private _isHttps: boolean;
63
64
 
64
65
  constructor(cookieHeader: string, isHttps = false) {
65
- this._incoming = parseCookies(cookieHeader);
66
+ // Defer parsing until first read — static-asset and health requests never
67
+ // touch cookies and shouldn't pay the split/decode.
68
+ this._cookieHeader = cookieHeader;
66
69
  this._isHttps = isHttps;
67
70
  // Browsers drop Secure cookies sent over HTTP — only default `secure` on
68
71
  // when the current request actually arrived over HTTPS.
69
72
  this._defaults = isHttps ? COOKIE_DEFAULTS : { ...COOKIE_DEFAULTS, secure: false };
70
73
  }
71
74
 
75
+ private get _incoming(): Record<string, string> {
76
+ if (this._parsed === null) this._parsed = parseCookies(this._cookieHeader);
77
+ return this._parsed;
78
+ }
79
+
72
80
  get(name: string): string | undefined {
73
81
  this._accessed = true;
74
82
  if (name in this._incoming) this._readNames.add(name);
package/src/core/csrf.ts CHANGED
@@ -42,12 +42,16 @@ export function checkCsrf(
42
42
  const protocol = forwardedProto ?? url.protocol.replace(":", "");
43
43
  const expectedOrigin = host ? `${protocol}://${host}` : url.origin;
44
44
 
45
- const allowedOrigins = new Set([expectedOrigin, ...(config.allowedOrigins ?? [])]);
45
+ // expectedOrigin is per-request (host-derived), so no Set to precompute — a
46
+ // direct compare plus the tiny static allow-list avoids a per-request alloc.
47
+ const extraOrigins = config.allowedOrigins;
48
+ const isAllowed = (origin: string) =>
49
+ origin === expectedOrigin || (extraOrigins ? extraOrigins.includes(origin) : false);
46
50
 
47
51
  // Check Origin header first (sent by all modern browsers on cross-origin requests)
48
52
  const originHeader = request.headers.get("origin");
49
53
  if (originHeader) {
50
- if (allowedOrigins.has(originHeader)) return null;
54
+ if (isAllowed(originHeader)) return null;
51
55
  return `Cross-origin request blocked: Origin "${originHeader}" is not allowed`;
52
56
  }
53
57
 
@@ -56,7 +60,7 @@ export function checkCsrf(
56
60
  if (refererHeader) {
57
61
  try {
58
62
  const refererOrigin = new URL(refererHeader).origin;
59
- if (allowedOrigins.has(refererOrigin)) return null;
63
+ if (isAllowed(refererOrigin)) return null;
60
64
  return `Cross-origin request blocked: Referer "${refererHeader}" is not allowed`;
61
65
  } catch {
62
66
  return `Cross-origin request blocked: Referer header is malformed`;
package/src/core/html.ts CHANGED
@@ -366,6 +366,9 @@ export function buildHtmlTail(
366
366
 
367
367
  const GZIP_MIN_BYTES = 2048;
368
368
 
369
+ // Shared, stateless — one instance instead of a fresh allocation per response.
370
+ const textEncoder = new TextEncoder();
371
+
369
372
  export function compress(
370
373
  body: string,
371
374
  contentType: string,
@@ -381,7 +384,7 @@ export function compress(
381
384
  ...extraHeaders,
382
385
  };
383
386
  const accept = req.headers.get("accept-encoding") ?? "";
384
- const bytes = new TextEncoder().encode(body);
387
+ const bytes = textEncoder.encode(body);
385
388
  // Skip compression in dev — the dev proxy's fetch() auto-decompresses gzip
386
389
  // responses but keeps the Content-Encoding header, causing ERR_CONTENT_DECODING_FAILED.
387
390
  if (!isDev && bytes.length > GZIP_MIN_BYTES && accept.includes("gzip")) {
@@ -63,6 +63,12 @@ function compilePattern(pattern: string): CompiledRoute {
63
63
  return { regex: new RegExp(regexStr), paramNames, isExact: false };
64
64
  }
65
65
 
66
+ // Exact routes bucketed by pathname, keyed by the routes-array identity so
67
+ // findMatch can O(1) Map.get before linearly scanning the dynamic/catch-all
68
+ // remainder. WeakMap keeps it tied to whichever array (server/api/client) was
69
+ // compiled; uncompiled arrays simply fall back to the full linear scan.
70
+ const exactMaps = new WeakMap<object, Map<string, unknown>>();
71
+
66
72
  /**
67
73
  * Pre-compile all route patterns in-place.
68
74
  * Mutates each route by adding a `_compiled` property.
@@ -71,9 +77,14 @@ function compilePattern(pattern: string): CompiledRoute {
71
77
  export function compileRoutes<T extends { pattern: string }>(
72
78
  routes: T[],
73
79
  ): (T & { _compiled: CompiledRoute })[] {
80
+ const exact = new Map<string, T>();
74
81
  for (const route of routes) {
75
- (route as any)._compiled = compilePattern(route.pattern);
82
+ const compiled = compilePattern(route.pattern);
83
+ (route as any)._compiled = compiled;
84
+ // Exact patterns contain no dynamic segments, so pattern === pathname.
85
+ if (compiled.isExact) exact.set(route.pattern, route);
76
86
  }
87
+ exactMaps.set(routes, exact);
77
88
  return routes as (T & { _compiled: CompiledRoute })[];
78
89
  }
79
90
 
@@ -166,8 +177,17 @@ export function findMatch<T extends { pattern: string }>(
166
177
  pathname = pathname.slice(0, -1);
167
178
  }
168
179
 
180
+ // Exact-match fast path: one Map.get instead of scanning every exact route.
181
+ const exact = exactMaps.get(routes) as Map<string, T> | undefined;
182
+ if (exact) {
183
+ const hit = exact.get(pathname);
184
+ if (hit) return { route: hit, params: {} };
185
+ }
186
+
169
187
  for (const route of routes) {
170
188
  const compiled = (route as any)._compiled as CompiledRoute | undefined;
189
+ // Exact routes already covered by the Map lookup above — skip them.
190
+ if (exact && compiled?.isExact) continue;
171
191
  const params = compiled
172
192
  ? matchCompiled(compiled, route.pattern, pathname)
173
193
  : matchPattern(route.pattern, pathname);
@@ -38,6 +38,9 @@ import type { BosiaPlugin, RenderContext } from "./types/plugin.ts";
38
38
  import { getAppHtmlSegments } from "./appHtml.ts";
39
39
  import type { AppHtmlSegments } from "./appHtml.ts";
40
40
 
41
+ // Shared, stateless — one instance instead of a fresh allocation per stream.
42
+ const enc = new TextEncoder();
43
+
41
44
  // Plugins are loaded once per process at module init via top-level await elsewhere
42
45
  // (server.ts), but renderer is also reachable from build/prerender contexts where
43
46
  // loadPlugins() may not have been called yet. The function is cached, so awaiting
@@ -533,14 +536,24 @@ export async function renderSSRStream(
533
536
  // render, compress). Key includes URL + identity hash (cookies/headers
534
537
  // from CACHE_KEYS), so per-user pages stay isolated. Routes opt out via
535
538
  // `export const cache = false`. See cache.ts and docs/guides/response-cache.md.
536
- const pageMod: any = await route.pageModule();
537
539
  const cacheBypass = url.searchParams.has("_invalidated");
540
+ // `route.cache` is the build-time static read of `export const cache` in
541
+ // +page.svelte, letting a cache hit skip the page-module import entirely:
542
+ // true → cacheable, false → opted out, null → import to read the real value.
543
+ let pageMod: any = null;
544
+ const staticCache = (route as any).cache as boolean | null | undefined;
545
+ let routeCacheable: boolean;
546
+ if (staticCache === true || staticCache === false) {
547
+ routeCacheable = staticCache;
548
+ } else {
549
+ pageMod = await route.pageModule();
550
+ routeCacheable = pageMod.cache !== false;
551
+ }
538
552
  // CSP is incompatible with response cache — the per-request nonce is baked
539
553
  // into the cached HTML but the CSP header is re-derived each request, so a
540
554
  // cached page would ship with a dead nonce and the browser would block its
541
555
  // inline scripts. Operators who turn on CSP_DIRECTIVES forfeit the cache.
542
- const cacheable =
543
- CACHE_ENABLED && !CSP_ENABLED && pageMod.cache !== false && req.method === "GET";
556
+ const cacheable = CACHE_ENABLED && !CSP_ENABLED && routeCacheable && req.method === "GET";
544
557
  let cacheKey: string | null = null;
545
558
  let releaseMiss: (() => void) | null = null;
546
559
  if (cacheable) {
@@ -602,10 +615,15 @@ export async function renderSSRStream(
602
615
  let layoutMods: any[];
603
616
 
604
617
  try {
605
- [data, layoutMods] = await Promise.all([
618
+ // pageMod is already loaded when the cache flag was unknown; otherwise
619
+ // fold its import into this parallel block instead of a serial await.
620
+ let pm: any;
621
+ [data, layoutMods, pm] = await Promise.all([
606
622
  loadRouteData(url, locals, req, cookies, metadataData, match),
607
623
  Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
624
+ pageMod ?? route.pageModule(),
608
625
  ]);
626
+ pageMod = pm;
609
627
  } catch (err) {
610
628
  if (err instanceof Redirect) return Response.redirect(err.location, err.status);
611
629
  if (err instanceof HttpError) {
@@ -655,7 +673,6 @@ export async function renderSSRStream(
655
673
  nonce,
656
674
  );
657
675
 
658
- const enc = new TextEncoder();
659
676
  const renderCtx: RenderContext = {
660
677
  request: req,
661
678
  url,
@@ -94,6 +94,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
94
94
  lines.push(" layoutServers: { loader: () => Promise<any>; depth: number }[];");
95
95
  lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
96
96
  lines.push(' trailingSlash: "never" | "always" | "ignore";');
97
+ lines.push(" cache: boolean | null;");
97
98
  lines.push("}> = [");
98
99
  for (const r of pages) {
99
100
  const layoutImports = r.layouts
@@ -121,6 +122,7 @@ export function generateRoutesFile(manifest: RouteManifest): void {
121
122
  lines.push(` layoutServers: [${layoutServerImports}],`);
122
123
  lines.push(` errorPages: [${errorPageImports}],`);
123
124
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
125
+ lines.push(` cache: ${JSON.stringify(r.cache ?? null)},`);
124
126
  lines.push(" },");
125
127
  }
126
128
  lines.push("];\n");
@@ -23,6 +23,25 @@ const ROUTES_DIR = "./src/routes";
23
23
  * regex. Static-string read only — runtime expressions return null. Build-time
24
24
  * scan avoids invoking server modules during the client bundle.
25
25
  */
26
+ /**
27
+ * Read `export const cache` from +page.svelte's `<script module>` at scan time.
28
+ * Conservative on purpose — the flag lets the renderer skip caching, so a wrong
29
+ * "cacheable" answer would leak a per-user page. Returns `true` ONLY when there
30
+ * is no `cache` export at all; a literal `= false` returns `false`; anything
31
+ * else (dynamic expression, unreadable file) returns `null` so the renderer
32
+ * imports the module and reads the real value.
33
+ */
34
+ function readPageCache(filePath: string): boolean | null {
35
+ try {
36
+ const src = readFileSync(filePath, "utf-8");
37
+ if (!/export\s+const\s+cache\b/.test(src)) return true;
38
+ if (/export\s+const\s+cache\s*(?::[^=]+)?=\s*false\b/.test(src)) return false;
39
+ return null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
26
45
  function readTrailingSlash(filePath: string): TrailingSlash | null {
27
46
  try {
28
47
  const src = readFileSync(filePath, "utf-8");
@@ -100,15 +119,17 @@ export function scanRoutes(): RouteManifest {
100
119
  const pageTs = pageServerFile ? readTrailingSlash(join(ROUTES_DIR, pageServerFile)) : null;
101
120
  const effectiveTs: TrailingSlash = pageTs ?? currentTrailingSlash;
102
121
 
122
+ const pageFile = join(dir, "+page.svelte");
103
123
  pages.push({
104
124
  pattern: toUrlPath(urlSegments),
105
- page: join(dir, "+page.svelte"),
125
+ page: pageFile,
106
126
  layouts: [...currentLayouts],
107
127
  pageServer: pageServerFile,
108
128
  loading: loadingFile,
109
129
  layoutServers: [...currentLayoutServers],
110
130
  errorPages: [...currentErrorPages],
111
131
  trailingSlash: effectiveTs,
132
+ cache: readPageCache(join(ROUTES_DIR, pageFile)),
112
133
  });
113
134
  }
114
135
 
@@ -367,11 +367,26 @@ async function resolve(event: RequestEvent): Promise<Response> {
367
367
  }
368
368
  }
369
369
 
370
+ // Framework-owned static prefixes (`/dist/…`, `/__bosia/…`) can't be shadowed
371
+ // by a user `+server.ts`, so serve them straight from the manifest before the
372
+ // API scan. A miss falls through to the normal path (which 404s). Keeps the
373
+ // api-before-static ordering intact for every user-facing path.
374
+ if (staticManifest && (path.startsWith("/dist/") || path.startsWith("/__bosia/"))) {
375
+ const hit = lookupStatic(staticManifest, path);
376
+ if (hit) {
377
+ return new Response(
378
+ Bun.file(hit.absPath),
379
+ hit.cacheControl ? { headers: { "Cache-Control": hit.cacheControl } } : undefined,
380
+ );
381
+ }
382
+ }
383
+
370
384
  // API routes (+server.ts) — resolve with `.json` alias preference.
371
385
  // Matched BEFORE static fallthrough so explicit handlers shadow extension-
372
386
  // based static detection (e.g. `/uploads/[...path]/+server.ts` can serve
373
387
  // `.webp` URLs that would otherwise be intercepted by isStaticPath).
374
- const apiMatch = await resolveApiMatch(apiRoutes, path);
388
+ const apiMaybe = resolveApiMatch(apiRoutes, path);
389
+ const apiMatch = apiMaybe instanceof Promise ? await apiMaybe : apiMaybe;
375
390
  if (apiMatch) {
376
391
  // INVARIANT: once set, releaseApiMiss must fire exactly once — a missed
377
392
  // release() hangs coalesced waiters for the process lifetime. The cache
@@ -1054,6 +1069,7 @@ function loadBuiltManifest(): RouteManifest {
1054
1069
  layoutServers: [],
1055
1070
  errorPages: [],
1056
1071
  trailingSlash: r.trailingSlash,
1072
+ cache: r.cache ?? null,
1057
1073
  })),
1058
1074
  apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, server: "" })),
1059
1075
  errorPage: null,
package/src/core/types.ts CHANGED
@@ -26,6 +26,14 @@ export interface PageRoute {
26
26
  errorPages: { path: string; depth: number }[];
27
27
  /** Effective trailing-slash mode (page wins over layout chain). Defaults to "never". */
28
28
  trailingSlash: TrailingSlash;
29
+ /**
30
+ * Build-time read of `export const cache` in +page.svelte, so the renderer can
31
+ * decide cacheability on a cache hit without importing the page module:
32
+ * `true` — no `cache` export → cacheable
33
+ * `false` — statically `cache = false` → opted out
34
+ * `null` — a `cache` export exists but isn't a literal `false` (dynamic) → import at runtime
35
+ */
36
+ cache: boolean | null;
29
37
  }
30
38
 
31
39
  /** An API route discovered from the file system */