bosia 0.8.11 → 0.8.13
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/apiResolver.ts +20 -11
- package/src/core/cookies.ts +10 -2
- package/src/core/csrf.ts +28 -3
- package/src/core/env.ts +1 -0
- package/src/core/html.ts +4 -1
- package/src/core/matcher.ts +21 -1
- package/src/core/renderer.ts +22 -5
- package/src/core/routeFile.ts +2 -0
- package/src/core/scanner.ts +22 -1
- package/src/core/server.ts +24 -1
- package/src/core/types.ts +8 -0
- package/templates/default/.env.example +6 -0
- package/templates/demo/.env.example +5 -0
package/package.json
CHANGED
package/src/core/apiResolver.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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);
|
package/src/core/cookies.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
@@ -10,6 +10,14 @@ export interface CsrfConfig {
|
|
|
10
10
|
checkOrigin: boolean;
|
|
11
11
|
/** Additional origins to allow (e.g. CDN or mobile app origin). */
|
|
12
12
|
allowedOrigins?: string[];
|
|
13
|
+
/**
|
|
14
|
+
* Request paths exempt from the origin check — for server-to-server webhooks
|
|
15
|
+
* that carry no Origin/Referer. Matched exact or on a path boundary
|
|
16
|
+
* ("/webhook" also covers "/webhook/…", but not "/webhooky"). Exempt routes
|
|
17
|
+
* bypass CSRF entirely, so they MUST authenticate the caller themselves
|
|
18
|
+
* (verify a webhook token/signature).
|
|
19
|
+
*/
|
|
20
|
+
exemptPaths?: string[];
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
const DEFAULT_CSRF_CONFIG: CsrfConfig = {
|
|
@@ -18,6 +26,18 @@ const DEFAULT_CSRF_CONFIG: CsrfConfig = {
|
|
|
18
26
|
|
|
19
27
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
20
28
|
|
|
29
|
+
// Exact match, or a prefix match on a path boundary so "/webhook" covers
|
|
30
|
+
// "/webhook/xendit" but never "/webhooky". Prefix-boundary, no globs — swap in a
|
|
31
|
+
// matcher if wildcard path segments are ever needed.
|
|
32
|
+
function isPathExempt(pathname: string, patterns: string[]): boolean {
|
|
33
|
+
for (const p of patterns) {
|
|
34
|
+
if (pathname === p) return true;
|
|
35
|
+
const prefix = p.endsWith("/") ? p : p + "/";
|
|
36
|
+
if (pathname.startsWith(prefix)) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
/**
|
|
22
42
|
* Check whether a request passes CSRF validation.
|
|
23
43
|
* Returns `null` on success, or an error message string to reject with 403.
|
|
@@ -29,6 +49,7 @@ export function checkCsrf(
|
|
|
29
49
|
): string | null {
|
|
30
50
|
if (!config.checkOrigin) return null;
|
|
31
51
|
if (SAFE_METHODS.has(request.method.toUpperCase())) return null;
|
|
52
|
+
if (config.exemptPaths && isPathExempt(url.pathname, config.exemptPaths)) return null;
|
|
32
53
|
|
|
33
54
|
// Derive the expected origin.
|
|
34
55
|
// `X-Forwarded-*` headers are only trusted when `TRUST_PROXY=true`, since a
|
|
@@ -42,12 +63,16 @@ export function checkCsrf(
|
|
|
42
63
|
const protocol = forwardedProto ?? url.protocol.replace(":", "");
|
|
43
64
|
const expectedOrigin = host ? `${protocol}://${host}` : url.origin;
|
|
44
65
|
|
|
45
|
-
|
|
66
|
+
// expectedOrigin is per-request (host-derived), so no Set to precompute — a
|
|
67
|
+
// direct compare plus the tiny static allow-list avoids a per-request alloc.
|
|
68
|
+
const extraOrigins = config.allowedOrigins;
|
|
69
|
+
const isAllowed = (origin: string) =>
|
|
70
|
+
origin === expectedOrigin || (extraOrigins ? extraOrigins.includes(origin) : false);
|
|
46
71
|
|
|
47
72
|
// Check Origin header first (sent by all modern browsers on cross-origin requests)
|
|
48
73
|
const originHeader = request.headers.get("origin");
|
|
49
74
|
if (originHeader) {
|
|
50
|
-
if (
|
|
75
|
+
if (isAllowed(originHeader)) return null;
|
|
51
76
|
return `Cross-origin request blocked: Origin "${originHeader}" is not allowed`;
|
|
52
77
|
}
|
|
53
78
|
|
|
@@ -56,7 +81,7 @@ export function checkCsrf(
|
|
|
56
81
|
if (refererHeader) {
|
|
57
82
|
try {
|
|
58
83
|
const refererOrigin = new URL(refererHeader).origin;
|
|
59
|
-
if (
|
|
84
|
+
if (isAllowed(refererOrigin)) return null;
|
|
60
85
|
return `Cross-origin request blocked: Referer "${refererHeader}" is not allowed`;
|
|
61
86
|
} catch {
|
|
62
87
|
return `Cross-origin request blocked: Referer header is malformed`;
|
package/src/core/env.ts
CHANGED
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 =
|
|
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")) {
|
package/src/core/matcher.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/core/renderer.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/core/routeFile.ts
CHANGED
|
@@ -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");
|
package/src/core/scanner.ts
CHANGED
|
@@ -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:
|
|
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
|
|
package/src/core/server.ts
CHANGED
|
@@ -113,10 +113,12 @@ function splitCsvEnv(key: string): string[] | undefined {
|
|
|
113
113
|
// ─── CSRF Config ─────────────────────────────────────────
|
|
114
114
|
|
|
115
115
|
const _csrfAllowedOrigins = splitCsvEnv("CSRF_ALLOWED_ORIGINS");
|
|
116
|
+
const _csrfExemptPaths = splitCsvEnv("CSRF_EXEMPT_PATHS");
|
|
116
117
|
|
|
117
118
|
const CSRF_CONFIG: CsrfConfig = {
|
|
118
119
|
checkOrigin: true,
|
|
119
120
|
allowedOrigins: _csrfAllowedOrigins,
|
|
121
|
+
exemptPaths: _csrfExemptPaths,
|
|
120
122
|
};
|
|
121
123
|
|
|
122
124
|
if (_csrfAllowedOrigins?.length) {
|
|
@@ -125,6 +127,11 @@ if (_csrfAllowedOrigins?.length) {
|
|
|
125
127
|
console.log("🛡️ CSRF: same-origin only");
|
|
126
128
|
}
|
|
127
129
|
|
|
130
|
+
if (_csrfExemptPaths?.length) {
|
|
131
|
+
// These paths skip the origin check — they must authenticate callers themselves.
|
|
132
|
+
console.warn(`⚠️ CSRF exempt paths (must self-authenticate): ${_csrfExemptPaths.join(", ")}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
128
135
|
// ─── CORS Config ──────────────────────────────────────────
|
|
129
136
|
|
|
130
137
|
const _corsAllowedOrigins = splitCsvEnv("CORS_ALLOWED_ORIGINS");
|
|
@@ -367,11 +374,26 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
367
374
|
}
|
|
368
375
|
}
|
|
369
376
|
|
|
377
|
+
// Framework-owned static prefixes (`/dist/…`, `/__bosia/…`) can't be shadowed
|
|
378
|
+
// by a user `+server.ts`, so serve them straight from the manifest before the
|
|
379
|
+
// API scan. A miss falls through to the normal path (which 404s). Keeps the
|
|
380
|
+
// api-before-static ordering intact for every user-facing path.
|
|
381
|
+
if (staticManifest && (path.startsWith("/dist/") || path.startsWith("/__bosia/"))) {
|
|
382
|
+
const hit = lookupStatic(staticManifest, path);
|
|
383
|
+
if (hit) {
|
|
384
|
+
return new Response(
|
|
385
|
+
Bun.file(hit.absPath),
|
|
386
|
+
hit.cacheControl ? { headers: { "Cache-Control": hit.cacheControl } } : undefined,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
370
391
|
// API routes (+server.ts) — resolve with `.json` alias preference.
|
|
371
392
|
// Matched BEFORE static fallthrough so explicit handlers shadow extension-
|
|
372
393
|
// based static detection (e.g. `/uploads/[...path]/+server.ts` can serve
|
|
373
394
|
// `.webp` URLs that would otherwise be intercepted by isStaticPath).
|
|
374
|
-
const
|
|
395
|
+
const apiMaybe = resolveApiMatch(apiRoutes, path);
|
|
396
|
+
const apiMatch = apiMaybe instanceof Promise ? await apiMaybe : apiMaybe;
|
|
375
397
|
if (apiMatch) {
|
|
376
398
|
// INVARIANT: once set, releaseApiMiss must fire exactly once — a missed
|
|
377
399
|
// release() hangs coalesced waiters for the process lifetime. The cache
|
|
@@ -1054,6 +1076,7 @@ function loadBuiltManifest(): RouteManifest {
|
|
|
1054
1076
|
layoutServers: [],
|
|
1055
1077
|
errorPages: [],
|
|
1056
1078
|
trailingSlash: r.trailingSlash,
|
|
1079
|
+
cache: r.cache ?? null,
|
|
1057
1080
|
})),
|
|
1058
1081
|
apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, server: "" })),
|
|
1059
1082
|
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 */
|
|
@@ -65,6 +65,12 @@ PUBLIC_STATIC_APP_NAME=My Bosia App
|
|
|
65
65
|
# Leave unset to allow same-origin requests only.
|
|
66
66
|
# CSRF_ALLOWED_ORIGINS=
|
|
67
67
|
|
|
68
|
+
# Comma-separated request paths exempt from the CSRF origin check — for
|
|
69
|
+
# server-to-server webhooks that send no Origin/Referer. Matched exact or on a
|
|
70
|
+
# path boundary ("/webhook" covers "/webhook/..." but not "/webhooky"). Exempt
|
|
71
|
+
# routes bypass CSRF, so they MUST verify the caller (webhook token/signature).
|
|
72
|
+
# CSRF_EXEMPT_PATHS=/webhook/xendit
|
|
73
|
+
|
|
68
74
|
# Comma-separated list of origins allowed to make cross-origin requests.
|
|
69
75
|
# Leave unset to disable CORS.
|
|
70
76
|
# CORS_ALLOWED_ORIGINS=
|
|
@@ -21,6 +21,11 @@ BODY_SIZE_LIMIT=512K
|
|
|
21
21
|
# Example: https://app.example.com, https://admin.example.com
|
|
22
22
|
CSRF_ALLOWED_ORIGINS=
|
|
23
23
|
|
|
24
|
+
# Comma-separated request paths exempt from the CSRF origin check — for
|
|
25
|
+
# server-to-server webhooks that send no Origin/Referer. Exempt routes MUST
|
|
26
|
+
# verify the caller themselves (webhook token/signature). Example: /webhook/xendit
|
|
27
|
+
CSRF_EXEMPT_PATHS=
|
|
28
|
+
|
|
24
29
|
# Comma-separated list of origins allowed to make cross-origin requests.
|
|
25
30
|
# Leave unset to disable CORS (browsers block cross-origin requests by default).
|
|
26
31
|
# Example: https://app.example.com, http://localhost:5173
|