bosia 0.8.12 → 0.8.14
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 +18 -3
- package/src/core/csrf.ts +21 -0
- package/src/core/env.ts +1 -0
- package/src/core/prerender.ts +68 -66
- package/src/core/server.ts +7 -0
- package/templates/default/.env.example +6 -0
- package/templates/demo/.env.example +5 -0
package/package.json
CHANGED
|
@@ -77,6 +77,8 @@
|
|
|
77
77
|
let showLoading = $state(false);
|
|
78
78
|
let currentLayoutPaths: string[] = [];
|
|
79
79
|
let lastSettledPath = "";
|
|
80
|
+
let lastNavKey = ""; // pathname + search of the last processed nav
|
|
81
|
+
let lastTick = 0; // invalidationTick value last processed
|
|
80
82
|
// Skip bar on the very first effect run (initial hydration — data already present)
|
|
81
83
|
let firstNav = true;
|
|
82
84
|
let navDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -107,10 +109,12 @@
|
|
|
107
109
|
|
|
108
110
|
// Subscribe to `invalidationTick` so `invalidate()` can wake the effect
|
|
109
111
|
// without a URL change.
|
|
110
|
-
|
|
112
|
+
const currentTick = appState.invalidationTick; // still tracks reactively
|
|
111
113
|
|
|
112
114
|
const path = router.currentRoute;
|
|
113
|
-
const
|
|
115
|
+
const url = new URL(path, window.location.origin);
|
|
116
|
+
const pathname = url.pathname;
|
|
117
|
+
const navKey = pathname + url.search;
|
|
114
118
|
const match = findMatch(clientRoutes, pathname);
|
|
115
119
|
if (!match) return;
|
|
116
120
|
|
|
@@ -121,11 +125,22 @@
|
|
|
121
125
|
if (isFirst) {
|
|
122
126
|
currentLayoutPaths = (match.route as any).layoutPaths ?? [];
|
|
123
127
|
lastSettledPath = pathname;
|
|
128
|
+
lastNavKey = navKey;
|
|
129
|
+
lastTick = currentTick;
|
|
124
130
|
// Restore-after-reload: router.init() staged this entry's snapshot.
|
|
125
131
|
settleSnapshot();
|
|
126
132
|
return; // Initial hydration — data already in SSR props, no fetch needed
|
|
127
133
|
}
|
|
128
134
|
|
|
135
|
+
// Hash-only change (same pathname+search, no invalidation): scroll, don't
|
|
136
|
+
// refetch. A static host can't answer the parent-snapshot POST a refetch may
|
|
137
|
+
// issue, which would otherwise render the error page.
|
|
138
|
+
if (navKey === lastNavKey && currentTick === lastTick) {
|
|
139
|
+
settleScroll();
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
lastTick = currentTick;
|
|
143
|
+
|
|
129
144
|
appState.form = null;
|
|
130
145
|
if (navDoneTimer) {
|
|
131
146
|
clearTimeout(navDoneTimer);
|
|
@@ -160,7 +175,6 @@
|
|
|
160
175
|
// For each layout depth + the page, compare the cached entry (if any)
|
|
161
176
|
// against the live URL/params and the dirty set. If everything is
|
|
162
177
|
// cacheable, skip the fetch entirely.
|
|
163
|
-
const url = new URL(path, window.location.origin);
|
|
164
178
|
const ctx = liveContext(pathname, match.params, url);
|
|
165
179
|
const layoutIds = (match.route as any).layoutIds as (string | null)[];
|
|
166
180
|
const pageId = (match.route as any).pageId as string | null;
|
|
@@ -243,6 +257,7 @@
|
|
|
243
257
|
LoadingComponent = null;
|
|
244
258
|
currentLayoutPaths = destLayoutPaths;
|
|
245
259
|
lastSettledPath = pathname;
|
|
260
|
+
lastNavKey = navKey;
|
|
246
261
|
navDoneTimer = setTimeout(() => {
|
|
247
262
|
navDone = false;
|
|
248
263
|
}, 400);
|
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
|
package/src/core/env.ts
CHANGED
package/src/core/prerender.ts
CHANGED
|
@@ -27,6 +27,7 @@ export function getEphemeralPort(): Promise<number> {
|
|
|
27
27
|
const CORE_DIR = import.meta.dir;
|
|
28
28
|
|
|
29
29
|
const PRERENDER_TIMEOUT = Number(process.env.PRERENDER_TIMEOUT) || 5_000; // 5s default
|
|
30
|
+
const PRERENDER_CONCURRENCY = Number(process.env.PRERENDER_CONCURRENCY) || 6;
|
|
30
31
|
|
|
31
32
|
// ─── Prerendering ─────────────────────────────────────────
|
|
32
33
|
|
|
@@ -97,79 +98,62 @@ export function prerenderApiOutPath(routePath: string): string {
|
|
|
97
98
|
return `${OUT_DIR}/prerendered${routePath.replace(/\/$/, "")}.json`;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/** Dynamic route → import module, call entries(), expand to concrete targets. */
|
|
102
|
+
async function expandDynamicRoute(
|
|
103
|
+
pattern: string,
|
|
104
|
+
filePath: string,
|
|
105
|
+
kind: "page" | "api",
|
|
106
|
+
ts: TrailingSlash,
|
|
107
|
+
): Promise<PrerenderTarget[]> {
|
|
108
|
+
try {
|
|
109
|
+
const mod = await import(join(process.cwd(), filePath));
|
|
110
|
+
if (typeof mod.entries !== "function") {
|
|
111
|
+
console.warn(` ⚠️ ${pattern} has prerender=true but no entries() export — skipped`);
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
const entryList: Record<string, string>[] = await mod.entries();
|
|
115
|
+
return entryList.map((entry) => ({
|
|
116
|
+
path: substituteParams(pattern, entry),
|
|
117
|
+
kind,
|
|
118
|
+
trailingSlash: ts,
|
|
119
|
+
}));
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error(` ❌ Failed to resolve entries() for ${pattern}:`, err);
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
100
126
|
async function detectPrerenderRoutes(manifest: RouteManifest): Promise<PrerenderTarget[]> {
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
if (!route.pageServer) continue;
|
|
127
|
+
const pageTasks = manifest.pages.map(async (route): Promise<PrerenderTarget[]> => {
|
|
128
|
+
if (!route.pageServer) return [];
|
|
104
129
|
const filePath = join("src", "routes", route.pageServer);
|
|
105
130
|
const content = await Bun.file(filePath).text();
|
|
106
|
-
if (!/export\s+const\s+prerender\s*=\s*true/.test(content))
|
|
131
|
+
if (!/export\s+const\s+prerender\s*=\s*true/.test(content)) return [];
|
|
107
132
|
if (/export\s+const\s+ssr\s*=\s*false/.test(content)) {
|
|
108
133
|
console.warn(
|
|
109
134
|
` ⚠️ ${route.pattern} has prerender=true && ssr=false — contradictory, skipped`,
|
|
110
135
|
);
|
|
111
|
-
|
|
136
|
+
return [];
|
|
112
137
|
}
|
|
113
|
-
|
|
114
138
|
const ts = route.trailingSlash;
|
|
139
|
+
if (route.pattern.includes("[")) return expandDynamicRoute(route.pattern, filePath, "page", ts);
|
|
140
|
+
return [{ path: route.pattern, kind: "page", trailingSlash: ts }];
|
|
141
|
+
});
|
|
115
142
|
|
|
116
|
-
|
|
117
|
-
// Dynamic route — import module and call entries() to get param values
|
|
118
|
-
try {
|
|
119
|
-
const mod = await import(join(process.cwd(), filePath));
|
|
120
|
-
if (typeof mod.entries !== "function") {
|
|
121
|
-
console.warn(
|
|
122
|
-
` ⚠️ ${route.pattern} has prerender=true but no entries() export — skipped`,
|
|
123
|
-
);
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
const entryList: Record<string, string>[] = await mod.entries();
|
|
127
|
-
for (const entry of entryList) {
|
|
128
|
-
targets.push({
|
|
129
|
-
path: substituteParams(route.pattern, entry),
|
|
130
|
-
kind: "page",
|
|
131
|
-
trailingSlash: ts,
|
|
132
|
-
});
|
|
133
|
-
}
|
|
134
|
-
} catch (err) {
|
|
135
|
-
console.error(` ❌ Failed to resolve entries() for ${route.pattern}:`, err);
|
|
136
|
-
}
|
|
137
|
-
} else {
|
|
138
|
-
targets.push({ path: route.pattern, kind: "page", trailingSlash: ts });
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
for (const route of manifest.apis) {
|
|
143
|
+
const apiTasks = manifest.apis.map(async (route): Promise<PrerenderTarget[]> => {
|
|
143
144
|
const filePath = join("src", "routes", route.server);
|
|
144
145
|
const content = await Bun.file(filePath).text();
|
|
145
|
-
if (!/export\s+const\s+prerender\s*=\s*true/.test(content))
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (typeof mod.entries !== "function") {
|
|
151
|
-
console.warn(
|
|
152
|
-
` ⚠️ ${route.pattern} has prerender=true but no entries() export — skipped`,
|
|
153
|
-
);
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
const entryList: Record<string, string>[] = await mod.entries();
|
|
157
|
-
for (const entry of entryList) {
|
|
158
|
-
targets.push({
|
|
159
|
-
path: substituteParams(route.pattern, entry),
|
|
160
|
-
kind: "api",
|
|
161
|
-
trailingSlash: "never",
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
} catch (err) {
|
|
165
|
-
console.error(` ❌ Failed to resolve entries() for ${route.pattern}:`, err);
|
|
166
|
-
}
|
|
167
|
-
} else {
|
|
168
|
-
targets.push({ path: route.pattern, kind: "api", trailingSlash: "never" });
|
|
169
|
-
}
|
|
170
|
-
}
|
|
146
|
+
if (!/export\s+const\s+prerender\s*=\s*true/.test(content)) return [];
|
|
147
|
+
if (route.pattern.includes("["))
|
|
148
|
+
return expandDynamicRoute(route.pattern, filePath, "api", "never");
|
|
149
|
+
return [{ path: route.pattern, kind: "api", trailingSlash: "never" }];
|
|
150
|
+
});
|
|
171
151
|
|
|
172
|
-
|
|
152
|
+
// Unbounded Promise.all over all routes; each task holds only small text reads
|
|
153
|
+
// and route metadata, so RAM stays trivial. Chunk it if route counts ever hit
|
|
154
|
+
// tens of thousands (open file descriptors would be the first limit).
|
|
155
|
+
const results = await Promise.all([...pageTasks, ...apiTasks]);
|
|
156
|
+
return results.flat();
|
|
173
157
|
}
|
|
174
158
|
|
|
175
159
|
export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<void> {
|
|
@@ -198,11 +182,12 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
198
182
|
for (const sig of signals) process.once(sig, onSignal);
|
|
199
183
|
|
|
200
184
|
try {
|
|
201
|
-
// Poll /_health until ready (max 10s)
|
|
185
|
+
// Poll /_health until ready (max 10s). Check first, sleep only on failure —
|
|
186
|
+
// avoids a guaranteed floor when the server is already up.
|
|
202
187
|
const base = `http://localhost:${port}`;
|
|
203
188
|
let ready = false;
|
|
204
|
-
|
|
205
|
-
|
|
189
|
+
const deadline = Date.now() + 10_000;
|
|
190
|
+
while (Date.now() < deadline) {
|
|
206
191
|
try {
|
|
207
192
|
const res = await fetch(`${base}/_health`);
|
|
208
193
|
if (res.ok) {
|
|
@@ -212,6 +197,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
212
197
|
} catch {
|
|
213
198
|
/* not ready yet */
|
|
214
199
|
}
|
|
200
|
+
await Bun.sleep(50);
|
|
215
201
|
}
|
|
216
202
|
|
|
217
203
|
if (!ready) {
|
|
@@ -221,7 +207,11 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
221
207
|
|
|
222
208
|
mkdirSync(`${OUT_DIR}/prerendered`, { recursive: true });
|
|
223
209
|
|
|
224
|
-
|
|
210
|
+
const prerenderOne = async ({
|
|
211
|
+
path: routePath,
|
|
212
|
+
kind,
|
|
213
|
+
trailingSlash: ts,
|
|
214
|
+
}: PrerenderTarget): Promise<void> => {
|
|
225
215
|
try {
|
|
226
216
|
if (kind === "api") {
|
|
227
217
|
// APIs: fetch the bare route URL, write body to `<path>.json`.
|
|
@@ -233,7 +223,7 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
233
223
|
mkdirSync(outPath.substring(0, outPath.lastIndexOf("/")), { recursive: true });
|
|
234
224
|
writeFileSync(outPath, body);
|
|
235
225
|
console.log(` ✅ ${routePath} → ${outPath}`);
|
|
236
|
-
|
|
226
|
+
return;
|
|
237
227
|
}
|
|
238
228
|
|
|
239
229
|
// Hit the canonical URL so the server doesn't 308 us mid-prerender
|
|
@@ -278,7 +268,19 @@ export async function prerenderStaticRoutes(manifest: RouteManifest): Promise<vo
|
|
|
278
268
|
console.error(` ❌ Failed to prerender ${routePath}:`, err);
|
|
279
269
|
}
|
|
280
270
|
}
|
|
281
|
-
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// Bounded worker pool: N workers pull from a shared index until targets drain.
|
|
274
|
+
let next = 0;
|
|
275
|
+
const worker = async () => {
|
|
276
|
+
while (next < targets.length) {
|
|
277
|
+
const target = targets[next++];
|
|
278
|
+
if (target) await prerenderOne(target);
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
await Promise.all(
|
|
282
|
+
Array.from({ length: Math.min(PRERENDER_CONCURRENCY, targets.length) }, worker),
|
|
283
|
+
);
|
|
282
284
|
|
|
283
285
|
console.log("✅ Prerendering complete");
|
|
284
286
|
} finally {
|
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");
|
|
@@ -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
|