bosia 0.2.2 → 0.3.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/README.md +39 -39
- package/package.json +56 -53
- package/src/ambient.d.ts +31 -0
- package/src/cli/add.ts +120 -114
- package/src/cli/build.ts +10 -10
- package/src/cli/create.ts +142 -137
- package/src/cli/dev.ts +8 -8
- package/src/cli/feat.ts +291 -132
- package/src/cli/index.ts +51 -42
- package/src/cli/registry.ts +136 -115
- package/src/cli/start.ts +17 -17
- package/src/cli/test.ts +25 -0
- package/src/core/build.ts +72 -56
- package/src/core/client/App.svelte +177 -153
- package/src/core/client/appState.svelte.ts +57 -0
- package/src/core/client/enhance.ts +112 -0
- package/src/core/client/hydrate.ts +97 -65
- package/src/core/client/prefetch.ts +101 -94
- package/src/core/client/router.svelte.ts +64 -51
- package/src/core/cookies.ts +70 -66
- package/src/core/cors.ts +44 -35
- package/src/core/csrf.ts +38 -38
- package/src/core/dedup.ts +17 -17
- package/src/core/dev.ts +165 -168
- package/src/core/env.ts +155 -128
- package/src/core/envCodegen.ts +73 -73
- package/src/core/errors.ts +48 -49
- package/src/core/hooks.ts +50 -50
- package/src/core/html.ts +192 -139
- package/src/core/matcher.ts +130 -121
- package/src/core/paths.ts +8 -10
- package/src/core/plugin.ts +113 -107
- package/src/core/prerender.ts +191 -118
- package/src/core/renderer.ts +359 -265
- package/src/core/routeFile.ts +140 -127
- package/src/core/routeTypes.ts +144 -83
- package/src/core/scanner.ts +125 -95
- package/src/core/server.ts +543 -370
- package/src/core/types.ts +25 -20
- package/src/lib/client.ts +12 -0
- package/src/lib/index.ts +8 -8
- package/src/lib/utils.ts +44 -30
- package/templates/default/.prettierignore +5 -0
- package/templates/default/.prettierrc.json +9 -0
- package/templates/default/README.md +5 -5
- package/templates/default/package.json +22 -18
- package/templates/default/src/app.css +80 -80
- package/templates/default/src/app.d.ts +3 -3
- package/templates/default/src/routes/+error.svelte +7 -10
- package/templates/default/src/routes/+layout.svelte +2 -2
- package/templates/default/src/routes/+page.svelte +31 -29
- package/templates/default/src/routes/about/+page.svelte +3 -3
- package/templates/default/tsconfig.json +20 -20
- package/templates/demo/.prettierignore +5 -0
- package/templates/demo/.prettierrc.json +9 -0
- package/templates/demo/README.md +9 -9
- package/templates/demo/package.json +22 -17
- package/templates/demo/src/app.css +80 -80
- package/templates/demo/src/app.d.ts +3 -3
- package/templates/demo/src/hooks.server.ts +9 -9
- package/templates/demo/src/routes/(public)/+layout.svelte +45 -23
- package/templates/demo/src/routes/(public)/+page.svelte +96 -67
- package/templates/demo/src/routes/(public)/about/+page.svelte +13 -25
- package/templates/demo/src/routes/(public)/all/[...catchall]/+page.svelte +24 -28
- package/templates/demo/src/routes/(public)/blog/+page.svelte +55 -46
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.server.ts +36 -38
- package/templates/demo/src/routes/(public)/blog/[slug]/+page.svelte +60 -42
- package/templates/demo/src/routes/+error.svelte +10 -7
- package/templates/demo/src/routes/+layout.server.ts +4 -4
- package/templates/demo/src/routes/+layout.svelte +2 -2
- package/templates/demo/src/routes/actions-test/+page.server.ts +16 -16
- package/templates/demo/src/routes/actions-test/+page.svelte +49 -49
- package/templates/demo/src/routes/api/hello/+server.ts +25 -25
- package/templates/demo/tsconfig.json +20 -20
- package/templates/todo/.prettierignore +5 -0
- package/templates/todo/.prettierrc.json +9 -0
- package/templates/todo/README.md +9 -9
- package/templates/todo/package.json +22 -17
- package/templates/todo/src/app.css +80 -80
- package/templates/todo/src/app.d.ts +7 -7
- package/templates/todo/src/hooks.server.ts +9 -9
- package/templates/todo/src/routes/+error.svelte +10 -7
- package/templates/todo/src/routes/+layout.server.ts +4 -4
- package/templates/todo/src/routes/+layout.svelte +2 -2
- package/templates/todo/src/routes/+page.svelte +44 -44
- package/templates/todo/template.json +1 -1
- package/templates/todo/tsconfig.json +20 -20
package/src/core/cookies.ts
CHANGED
|
@@ -14,85 +14,89 @@ const VALID_COOKIE_NAME = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/;
|
|
|
14
14
|
// ─── Cookie Defaults ─────────────────────────────────────
|
|
15
15
|
/** Secure defaults matching SvelteKit conventions. */
|
|
16
16
|
const COOKIE_DEFAULTS: CookieOptions = {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
path: "/",
|
|
18
|
+
httpOnly: true,
|
|
19
|
+
secure: true,
|
|
20
|
+
sameSite: "Lax",
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
// ─── Cookie Helpers ──────────────────────────────────────
|
|
24
24
|
|
|
25
25
|
function parseCookies(header: string): Record<string, string> {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
26
|
+
const result: Record<string, string> = {};
|
|
27
|
+
for (const pair of header.split(";")) {
|
|
28
|
+
const idx = pair.indexOf("=");
|
|
29
|
+
if (idx === -1) continue;
|
|
30
|
+
const name = pair.slice(0, idx).trim();
|
|
31
|
+
const value = pair.slice(idx + 1).trim();
|
|
32
|
+
if (name) {
|
|
33
|
+
try {
|
|
34
|
+
result[name] = decodeURIComponent(value);
|
|
35
|
+
} catch {
|
|
36
|
+
result[name] = value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
export class CookieJar implements Cookies {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
private _incoming: Record<string, string>;
|
|
45
|
+
private _outgoing: string[] = [];
|
|
46
|
+
private _defaults: CookieOptions;
|
|
47
|
+
private _accessed = false;
|
|
45
48
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
: COOKIE_DEFAULTS;
|
|
52
|
-
}
|
|
49
|
+
constructor(cookieHeader: string, dev = false) {
|
|
50
|
+
this._incoming = parseCookies(cookieHeader);
|
|
51
|
+
// In dev mode, omit Secure — browsers reject Secure cookies over http://localhost
|
|
52
|
+
this._defaults = dev ? { ...COOKIE_DEFAULTS, secure: false } : COOKIE_DEFAULTS;
|
|
53
|
+
}
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
get(name: string): string | undefined {
|
|
56
|
+
this._accessed = true;
|
|
57
|
+
return this._incoming[name];
|
|
58
|
+
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
getAll(): Record<string, string> {
|
|
61
|
+
this._accessed = true;
|
|
62
|
+
return { ...this._incoming };
|
|
63
|
+
}
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
get accessed(): boolean {
|
|
66
|
+
return this._accessed;
|
|
67
|
+
}
|
|
67
68
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
69
|
+
set(name: string, value: string, options?: CookieOptions): void {
|
|
70
|
+
if (!VALID_COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`);
|
|
71
|
+
const opts = { ...this._defaults, ...options };
|
|
72
|
+
let header = `${name}=${encodeURIComponent(value)}`;
|
|
73
|
+
if (opts.path) {
|
|
74
|
+
if (UNSAFE_COOKIE_VALUE.test(opts.path))
|
|
75
|
+
throw new Error(`Invalid cookie path: ${opts.path}`);
|
|
76
|
+
header += `; Path=${opts.path}`;
|
|
77
|
+
}
|
|
78
|
+
if (opts.domain) {
|
|
79
|
+
if (UNSAFE_COOKIE_VALUE.test(opts.domain))
|
|
80
|
+
throw new Error(`Invalid cookie domain: ${opts.domain}`);
|
|
81
|
+
header += `; Domain=${opts.domain}`;
|
|
82
|
+
}
|
|
83
|
+
if (opts.maxAge != null) header += `; Max-Age=${opts.maxAge}`;
|
|
84
|
+
if (opts.expires) header += `; Expires=${opts.expires.toUTCString()}`;
|
|
85
|
+
if (opts.httpOnly) header += "; HttpOnly";
|
|
86
|
+
if (opts.secure) header += "; Secure";
|
|
87
|
+
if (opts.sameSite) {
|
|
88
|
+
if (!VALID_SAMESITE.has(opts.sameSite))
|
|
89
|
+
throw new Error(`Invalid cookie sameSite: ${opts.sameSite}`);
|
|
90
|
+
header += `; SameSite=${opts.sameSite}`;
|
|
91
|
+
}
|
|
92
|
+
this._outgoing.push(header);
|
|
93
|
+
}
|
|
90
94
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
95
|
+
delete(name: string, options?: Pick<CookieOptions, "path" | "domain">): void {
|
|
96
|
+
this.set(name, "", { ...options, maxAge: 0 });
|
|
97
|
+
}
|
|
94
98
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
get outgoing(): readonly string[] {
|
|
100
|
+
return this._outgoing;
|
|
101
|
+
}
|
|
98
102
|
}
|
package/src/core/cors.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
export interface CorsConfig {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
2
|
+
/** Origins allowed to make cross-origin requests (e.g. ["https://app.example.com"]) */
|
|
3
|
+
allowedOrigins: string[];
|
|
4
|
+
/** HTTP methods to allow. Default: GET, HEAD, PUT, PATCH, POST, DELETE */
|
|
5
|
+
allowedMethods?: string[];
|
|
6
|
+
/** Request headers to allow. Default: Content-Type, Authorization */
|
|
7
|
+
allowedHeaders?: string[];
|
|
8
|
+
/** Response headers to expose to the browser. Default: none */
|
|
9
|
+
exposedHeaders?: string[];
|
|
10
|
+
/** Whether to allow cookies/auth credentials. Default: false */
|
|
11
|
+
credentials?: boolean;
|
|
12
|
+
/** Preflight cache duration in seconds. Default: 86400 (24h) */
|
|
13
|
+
maxAge?: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
const DEFAULT_METHODS = "GET, HEAD, PUT, PATCH, POST, DELETE";
|
|
@@ -20,27 +20,30 @@ const DEFAULT_HEADERS = "Content-Type, Authorization";
|
|
|
20
20
|
* Returns CORS response headers if the request Origin is in the allowed list.
|
|
21
21
|
* Returns null if Origin is absent or not allowed.
|
|
22
22
|
*/
|
|
23
|
-
export function getCorsHeaders(
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
export function getCorsHeaders(
|
|
24
|
+
request: Request,
|
|
25
|
+
config: CorsConfig,
|
|
26
|
+
): Record<string, string> | null {
|
|
27
|
+
const origin = request.headers.get("origin");
|
|
28
|
+
if (!origin) return null;
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
const allowed = config.allowedOrigins.includes(origin);
|
|
31
|
+
if (!allowed) return null;
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const headers: Record<string, string> = {
|
|
34
|
+
"Access-Control-Allow-Origin": origin,
|
|
35
|
+
Vary: "Origin",
|
|
36
|
+
};
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
if (config.credentials) {
|
|
39
|
+
headers["Access-Control-Allow-Credentials"] = "true";
|
|
40
|
+
}
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
if (config.exposedHeaders?.length) {
|
|
43
|
+
headers["Access-Control-Expose-Headers"] = config.exposedHeaders.join(", ");
|
|
44
|
+
}
|
|
42
45
|
|
|
43
|
-
|
|
46
|
+
return headers;
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/**
|
|
@@ -48,13 +51,19 @@ export function getCorsHeaders(request: Request, config: CorsConfig): Record<str
|
|
|
48
51
|
* Returns a 204 response with CORS headers, or null if the origin is not allowed.
|
|
49
52
|
*/
|
|
50
53
|
export function handlePreflight(request: Request, config: CorsConfig): Response | null {
|
|
51
|
-
|
|
52
|
-
|
|
54
|
+
const base = getCorsHeaders(request, config);
|
|
55
|
+
if (!base) return null;
|
|
53
56
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
57
|
+
const headers = new Headers(base as HeadersInit);
|
|
58
|
+
headers.set(
|
|
59
|
+
"Access-Control-Allow-Methods",
|
|
60
|
+
config.allowedMethods?.join(", ") ?? DEFAULT_METHODS,
|
|
61
|
+
);
|
|
62
|
+
headers.set(
|
|
63
|
+
"Access-Control-Allow-Headers",
|
|
64
|
+
config.allowedHeaders?.join(", ") ?? DEFAULT_HEADERS,
|
|
65
|
+
);
|
|
66
|
+
headers.set("Access-Control-Max-Age", String(config.maxAge ?? 86400));
|
|
58
67
|
|
|
59
|
-
|
|
68
|
+
return new Response(null, { status: 204, headers });
|
|
60
69
|
}
|
package/src/core/csrf.ts
CHANGED
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
// is treated as a cross-origin attack.
|
|
7
7
|
|
|
8
8
|
export interface CsrfConfig {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
/** Whether to enforce origin checks. Default: true. */
|
|
10
|
+
checkOrigin: boolean;
|
|
11
|
+
/** Additional origins to allow (e.g. CDN or mobile app origin). */
|
|
12
|
+
allowedOrigins?: string[];
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
const DEFAULT_CSRF_CONFIG: CsrfConfig = {
|
|
16
|
-
|
|
16
|
+
checkOrigin: true,
|
|
17
17
|
};
|
|
18
18
|
|
|
19
19
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
@@ -23,43 +23,43 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
|
23
23
|
* Returns `null` on success, or an error message string to reject with 403.
|
|
24
24
|
*/
|
|
25
25
|
export function checkCsrf(
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
request: Request,
|
|
27
|
+
url: URL,
|
|
28
|
+
config: CsrfConfig = DEFAULT_CSRF_CONFIG,
|
|
29
29
|
): string | null {
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
if (!config.checkOrigin) return null;
|
|
31
|
+
if (SAFE_METHODS.has(request.method.toUpperCase())) return null;
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
// Derive the expected origin.
|
|
34
|
+
// In dev, the browser hits the proxy on DEV_PORT (e.g. localhost:9000)
|
|
35
|
+
// while the Elysia server sees url.origin as localhost:9001.
|
|
36
|
+
// X-Forwarded-Host / Host headers reflect the actual host the client used.
|
|
37
|
+
const forwardedHost = request.headers.get("x-forwarded-host");
|
|
38
|
+
const host = forwardedHost ?? request.headers.get("host");
|
|
39
|
+
const protocol = request.headers.get("x-forwarded-proto") ?? url.protocol.replace(":", "");
|
|
40
|
+
const expectedOrigin = host ? `${protocol}://${host}` : url.origin;
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
const allowedOrigins = new Set([expectedOrigin, ...(config.allowedOrigins ?? [])]);
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
44
|
+
// Check Origin header first (sent by all modern browsers on cross-origin requests)
|
|
45
|
+
const originHeader = request.headers.get("origin");
|
|
46
|
+
if (originHeader) {
|
|
47
|
+
if (allowedOrigins.has(originHeader)) return null;
|
|
48
|
+
return `Cross-origin request blocked: Origin "${originHeader}" is not allowed`;
|
|
49
|
+
}
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
51
|
+
// Fall back to Referer (older browsers, some same-origin form posts)
|
|
52
|
+
const refererHeader = request.headers.get("referer");
|
|
53
|
+
if (refererHeader) {
|
|
54
|
+
try {
|
|
55
|
+
const refererOrigin = new URL(refererHeader).origin;
|
|
56
|
+
if (allowedOrigins.has(refererOrigin)) return null;
|
|
57
|
+
return `Cross-origin request blocked: Referer "${refererHeader}" is not allowed`;
|
|
58
|
+
} catch {
|
|
59
|
+
return `Cross-origin request blocked: Referer header is malformed`;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// Neither Origin nor Referer present — reject
|
|
64
|
+
return "Forbidden: missing Origin or Referer header on non-safe request";
|
|
65
65
|
}
|
package/src/core/dedup.ts
CHANGED
|
@@ -7,26 +7,26 @@ const AUTH_COOKIE_RE = /(?:^|;\s*)authorization=([^;]*)/i;
|
|
|
7
7
|
|
|
8
8
|
/** Build dedup key from route URL + request identity. Sort search params for consistency. */
|
|
9
9
|
export function dedupKey(url: URL, request: Request): string {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
let path = url.pathname;
|
|
11
|
+
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
|
12
|
+
const sorted = new URLSearchParams([...url.searchParams.entries()].sort());
|
|
13
|
+
const search = sorted.toString();
|
|
14
|
+
const base = search ? `${path}?${search}` : path;
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
const authHeader = request.headers.get("authorization") ?? "";
|
|
17
|
+
const cookieHeader = request.headers.get("cookie") ?? "";
|
|
18
|
+
const match = cookieHeader.match(AUTH_COOKIE_RE);
|
|
19
|
+
const authCookie = match?.[1] ?? "";
|
|
20
|
+
const identity = authHeader || authCookie;
|
|
21
|
+
if (!identity) return base;
|
|
22
|
+
return `${base}|${Bun.hash(identity).toString(36)}`;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/** Run `fn` with dedup. Concurrent calls with same key share the in-flight promise. */
|
|
26
26
|
export function dedup<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
const existing = inflight.get(key);
|
|
28
|
+
if (existing) return existing;
|
|
29
|
+
const promise = fn().finally(() => inflight.delete(key));
|
|
30
|
+
inflight.set(key, promise);
|
|
31
|
+
return promise;
|
|
32
32
|
}
|