bosia 0.4.4 → 0.5.0
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/cli/add.ts +4 -2
- package/src/cli/block.ts +94 -0
- package/src/cli/fonts.ts +61 -0
- package/src/cli/index.ts +19 -6
- package/src/cli/theme.ts +88 -0
- package/src/core/client/App.svelte +121 -5
- package/src/core/client/appState.svelte.ts +24 -37
- package/src/core/client/enhance.ts +6 -2
- package/src/core/client/hydrate.ts +51 -3
- package/src/core/client/loaderCache.ts +127 -0
- package/src/core/client/navigation.ts +59 -0
- package/src/core/client/prefetch.ts +48 -3
- package/src/core/cors.ts +57 -11
- package/src/core/csp.ts +47 -0
- package/src/core/csrf.ts +8 -5
- package/src/core/dev.ts +14 -2
- package/src/core/errors.ts +4 -3
- package/src/core/hooks.ts +37 -1
- package/src/core/html.ts +68 -26
- package/src/core/prerender.ts +11 -0
- package/src/core/renderer.ts +346 -35
- package/src/core/routeFile.ts +26 -0
- package/src/core/safePath.ts +14 -0
- package/src/core/server.ts +103 -15
- package/src/lib/client.ts +1 -0
- package/src/lib/index.ts +1 -0
package/src/core/server.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Elysia } from "elysia";
|
|
2
2
|
|
|
3
3
|
import { existsSync, readFileSync } from "fs";
|
|
4
|
-
import { join
|
|
4
|
+
import { join } from "path";
|
|
5
5
|
|
|
6
6
|
import { findMatch, compileRoutes, canonicalPathname } from "./matcher.ts";
|
|
7
7
|
import { apiRoutes, serverRoutes } from "bosia:routes";
|
|
@@ -14,10 +14,12 @@ compileRoutes(serverRoutes);
|
|
|
14
14
|
import type { Handle, RequestEvent } from "./hooks.ts";
|
|
15
15
|
import { HttpError, Redirect, ActionFailure } from "./errors.ts";
|
|
16
16
|
import { CookieJar } from "./cookies.ts";
|
|
17
|
+
import { safePath } from "./safePath.ts";
|
|
17
18
|
import { checkCsrf } from "./csrf.ts";
|
|
18
19
|
import type { CsrfConfig } from "./csrf.ts";
|
|
19
|
-
import { getCorsHeaders, handlePreflight } from "./cors.ts";
|
|
20
|
+
import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
|
|
20
21
|
import type { CorsConfig } from "./cors.ts";
|
|
22
|
+
import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } from "./csp.ts";
|
|
21
23
|
import { isDev, compress, isStaticPath } from "./html.ts";
|
|
22
24
|
import { dedup, dedupKey } from "./dedup.ts";
|
|
23
25
|
import {
|
|
@@ -93,6 +95,12 @@ if (_corsAllowedOrigins?.length) {
|
|
|
93
95
|
console.log(`🌐 CORS allowed origins: ${_corsAllowedOrigins.join(", ")}`);
|
|
94
96
|
}
|
|
95
97
|
|
|
98
|
+
// ─── CSP Config ──────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
if (CSP_DIRECTIVES_TEMPLATE) {
|
|
101
|
+
console.log(`🔒 CSP: opt-in header active`);
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
// ─── Core Request Resolver ────────────────────────────────
|
|
97
105
|
// This is the inner handler that hooks wrap around.
|
|
98
106
|
|
|
@@ -104,11 +112,21 @@ function isValidRoutePath(path: string, origin: string): boolean {
|
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
114
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
115
|
+
/**
|
|
116
|
+
* Decode an `_invalidated` bitmask string. Char 0 = page, char i+1 = layout
|
|
117
|
+
* depth i, '1' = run, '0' = skip. Missing/extra chars default to run.
|
|
118
|
+
*/
|
|
119
|
+
function buildMaskFromBits(
|
|
120
|
+
bits: string,
|
|
121
|
+
layoutCount: number,
|
|
122
|
+
): { page: boolean; layouts: boolean[] } {
|
|
123
|
+
const page = bits[0] !== "0";
|
|
124
|
+
const layouts: boolean[] = [];
|
|
125
|
+
for (let i = 0; i < layoutCount; i++) {
|
|
126
|
+
const c = bits[i + 1];
|
|
127
|
+
layouts.push(c !== "0");
|
|
128
|
+
}
|
|
129
|
+
return { page, layouts };
|
|
112
130
|
}
|
|
113
131
|
|
|
114
132
|
/** Extract action name from URL searchParams — `?/login` → "login", no slash key → "default". */
|
|
@@ -145,15 +163,41 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
145
163
|
return Response.json({ error: "Invalid path", status: 400 }, { status: 400 });
|
|
146
164
|
}
|
|
147
165
|
const routeUrl = new URL(routePathStr, url.origin);
|
|
166
|
+
let invalidatedBits: string | null = null;
|
|
148
167
|
for (const [key, val] of url.searchParams.entries()) {
|
|
168
|
+
if (key === "_invalidated") {
|
|
169
|
+
invalidatedBits = val;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
149
172
|
routeUrl.searchParams.append(key, val);
|
|
150
173
|
}
|
|
151
174
|
// Rewrite event.url so logging middleware sees the real page path, not /__bosia/data
|
|
152
175
|
event.url = routeUrl;
|
|
153
176
|
try {
|
|
154
177
|
const pageMatch = findMatch(serverRoutes, routeUrl.pathname);
|
|
178
|
+
// Build mask from `?_invalidated=<bits>` where char 0 = page,
|
|
179
|
+
// char i+1 = layout depth i, '1' = run, '0' = skip. Absent → run all.
|
|
180
|
+
// Mask is sized to the total layout count (matching client `layoutIds`),
|
|
181
|
+
// not the count of layout servers, so depths without a server loader
|
|
182
|
+
// still occupy a bit position and stay aligned with the client.
|
|
183
|
+
const mask = invalidatedBits
|
|
184
|
+
? buildMaskFromBits(
|
|
185
|
+
invalidatedBits,
|
|
186
|
+
pageMatch?.route
|
|
187
|
+
? ((pageMatch.route as any).layoutModules?.length ?? 0)
|
|
188
|
+
: 0,
|
|
189
|
+
)
|
|
190
|
+
: undefined;
|
|
155
191
|
const runLoad = async () => {
|
|
156
|
-
const data = await loadRouteData(
|
|
192
|
+
const data = await loadRouteData(
|
|
193
|
+
routeUrl,
|
|
194
|
+
locals,
|
|
195
|
+
request,
|
|
196
|
+
cookies,
|
|
197
|
+
null,
|
|
198
|
+
pageMatch,
|
|
199
|
+
mask,
|
|
200
|
+
);
|
|
157
201
|
|
|
158
202
|
let metadata = null;
|
|
159
203
|
if (pageMatch) {
|
|
@@ -175,12 +219,17 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
175
219
|
return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
|
|
176
220
|
};
|
|
177
221
|
|
|
178
|
-
// Dedup public routes by URL
|
|
179
|
-
// skip the cache to prevent cross-user data leaks.
|
|
222
|
+
// Dedup public routes by URL + mask. `(private)` scope routes (per-user
|
|
223
|
+
// content) skip the cache to prevent cross-user data leaks. The mask is
|
|
224
|
+
// part of the key so concurrent requests for the same URL with different
|
|
225
|
+
// invalidation patterns don't collapse onto each other. See dedup.ts.
|
|
226
|
+
const dedupK = invalidatedBits
|
|
227
|
+
? `${dedupKey(routeUrl)}|m=${invalidatedBits}`
|
|
228
|
+
: dedupKey(routeUrl);
|
|
180
229
|
const result =
|
|
181
230
|
pageMatch?.route.scope === "private"
|
|
182
231
|
? await runLoad()
|
|
183
|
-
: await dedup(
|
|
232
|
+
: await dedup(dedupK, runLoad);
|
|
184
233
|
|
|
185
234
|
const cookiesWereAccessed = (cookies as CookieJar).accessed || result.cookiesAccessed;
|
|
186
235
|
const cc = cookiesWereAccessed
|
|
@@ -359,6 +408,11 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
359
408
|
`Action "${actionName}" not found`,
|
|
360
409
|
url,
|
|
361
410
|
request,
|
|
411
|
+
undefined,
|
|
412
|
+
undefined,
|
|
413
|
+
undefined,
|
|
414
|
+
undefined,
|
|
415
|
+
locals.nonce,
|
|
362
416
|
);
|
|
363
417
|
}
|
|
364
418
|
|
|
@@ -387,7 +441,17 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
387
441
|
{ status: err.status },
|
|
388
442
|
);
|
|
389
443
|
}
|
|
390
|
-
return renderErrorPage(
|
|
444
|
+
return renderErrorPage(
|
|
445
|
+
err.status,
|
|
446
|
+
err.message,
|
|
447
|
+
url,
|
|
448
|
+
request,
|
|
449
|
+
undefined,
|
|
450
|
+
undefined,
|
|
451
|
+
undefined,
|
|
452
|
+
undefined,
|
|
453
|
+
locals.nonce,
|
|
454
|
+
);
|
|
391
455
|
}
|
|
392
456
|
throw err;
|
|
393
457
|
}
|
|
@@ -482,7 +546,18 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
482
546
|
|
|
483
547
|
// SSR pages (+page.svelte) — streaming by default
|
|
484
548
|
const streamResponse = await renderSSRStream(url, locals, request, cookies, pageMatch);
|
|
485
|
-
if (!streamResponse)
|
|
549
|
+
if (!streamResponse)
|
|
550
|
+
return renderErrorPage(
|
|
551
|
+
404,
|
|
552
|
+
"Not Found",
|
|
553
|
+
url,
|
|
554
|
+
request,
|
|
555
|
+
undefined,
|
|
556
|
+
undefined,
|
|
557
|
+
undefined,
|
|
558
|
+
undefined,
|
|
559
|
+
locals.nonce,
|
|
560
|
+
);
|
|
486
561
|
return streamResponse;
|
|
487
562
|
}
|
|
488
563
|
|
|
@@ -518,13 +593,26 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
518
593
|
}
|
|
519
594
|
|
|
520
595
|
const cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isDev);
|
|
521
|
-
const
|
|
596
|
+
const nonce = CSP_ENABLED ? generateNonce() : "";
|
|
597
|
+
const event: RequestEvent = {
|
|
598
|
+
request,
|
|
599
|
+
url,
|
|
600
|
+
locals: { nonce },
|
|
601
|
+
params: {},
|
|
602
|
+
cookies: cookieJar,
|
|
603
|
+
};
|
|
522
604
|
const response = userHandle ? await userHandle({ event, resolve }) : await resolve(event);
|
|
523
605
|
|
|
524
606
|
const headers = new Headers(response.headers);
|
|
525
607
|
for (const [k, v] of Object.entries(SECURITY_HEADERS)) headers.set(k, v);
|
|
526
|
-
|
|
608
|
+
const cspHeader = buildCspHeader(nonce);
|
|
609
|
+
if (cspHeader) headers.set("Content-Security-Policy", cspHeader);
|
|
610
|
+
// Apply CORS headers for allowed origins. `Vary: Origin` is set whenever
|
|
611
|
+
// CORS is configured — even on responses to non-allowed origins — so
|
|
612
|
+
// downstream caches (CDNs, browser HTTP cache) key on the Origin header
|
|
613
|
+
// instead of serving an Access-Control-Allow-Origin response across origins.
|
|
527
614
|
if (CORS_CONFIG) {
|
|
615
|
+
applyCorsVary(headers);
|
|
528
616
|
const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
|
|
529
617
|
if (corsHeaders) {
|
|
530
618
|
for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
|
package/src/lib/client.ts
CHANGED