bosbun 0.0.1
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 +163 -0
- package/package.json +56 -0
- package/src/cli/add.ts +83 -0
- package/src/cli/build.ts +16 -0
- package/src/cli/create.ts +54 -0
- package/src/cli/dev.ts +14 -0
- package/src/cli/feat.ts +80 -0
- package/src/cli/index.ts +75 -0
- package/src/cli/start.ts +28 -0
- package/src/core/build.ts +157 -0
- package/src/core/client/App.svelte +147 -0
- package/src/core/client/hydrate.ts +78 -0
- package/src/core/client/router.svelte.ts +46 -0
- package/src/core/cookies.ts +52 -0
- package/src/core/cors.ts +60 -0
- package/src/core/csrf.ts +65 -0
- package/src/core/dev.ts +193 -0
- package/src/core/env.ts +135 -0
- package/src/core/envCodegen.ts +94 -0
- package/src/core/errors.ts +23 -0
- package/src/core/hooks.ts +74 -0
- package/src/core/html.ts +170 -0
- package/src/core/matcher.ts +85 -0
- package/src/core/plugin.ts +59 -0
- package/src/core/prerender.ts +79 -0
- package/src/core/renderer.ts +222 -0
- package/src/core/routeFile.ts +88 -0
- package/src/core/routeTypes.ts +95 -0
- package/src/core/scanner.ts +99 -0
- package/src/core/server.ts +320 -0
- package/src/core/types.ts +37 -0
- package/src/lib/index.ts +19 -0
- package/src/lib/utils.ts +24 -0
- package/templates/default/.env.example +34 -0
- package/templates/default/README.md +102 -0
- package/templates/default/package.json +21 -0
- package/templates/default/public/.gitkeep +0 -0
- package/templates/default/src/app.css +132 -0
- package/templates/default/src/app.d.ts +7 -0
- package/templates/default/src/lib/.gitkeep +0 -0
- package/templates/default/src/routes/+error.svelte +18 -0
- package/templates/default/src/routes/+layout.svelte +6 -0
- package/templates/default/src/routes/+page.svelte +36 -0
- package/templates/default/src/routes/about/+page.server.ts +1 -0
- package/templates/default/src/routes/about/+page.svelte +8 -0
- package/templates/default/tsconfig.json +22 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { SveltePlugin } from "bun-plugin-svelte";
|
|
2
|
+
import { writeFileSync, rmSync, mkdirSync } from "fs";
|
|
3
|
+
import { join, relative } from "path";
|
|
4
|
+
import { spawnSync } from "bun";
|
|
5
|
+
|
|
6
|
+
import { scanRoutes } from "./scanner.ts";
|
|
7
|
+
import { generateRoutesFile } from "./routeFile.ts";
|
|
8
|
+
import { generateRouteTypes, ensureRootDirs } from "./routeTypes.ts";
|
|
9
|
+
import { makeBuniaPlugin } from "./plugin.ts";
|
|
10
|
+
import { prerenderStaticRoutes } from "./prerender.ts";
|
|
11
|
+
import { loadEnv, classifyEnvVars } from "./env.ts";
|
|
12
|
+
import { generateEnvModules } from "./envCodegen.ts";
|
|
13
|
+
|
|
14
|
+
// Resolved from this file's location inside the bunia package
|
|
15
|
+
const CORE_DIR = import.meta.dir;
|
|
16
|
+
const BUNIA_NODE_MODULES = join(CORE_DIR, "..", "..", "node_modules");
|
|
17
|
+
|
|
18
|
+
// ─── Entry Point ─────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
const isProduction = process.env.NODE_ENV === "production";
|
|
21
|
+
|
|
22
|
+
console.log("🏗️ Starting Bunia build...\n");
|
|
23
|
+
|
|
24
|
+
// 0. Load .env files (before cleaning .bunia so loadEnv can set process.env early)
|
|
25
|
+
const envMode = isProduction ? "production" : "development";
|
|
26
|
+
const envVars = loadEnv(envMode);
|
|
27
|
+
const classifiedEnv = classifyEnvVars(envVars);
|
|
28
|
+
|
|
29
|
+
// 0b. Clean all generated output first
|
|
30
|
+
try { rmSync("./dist", { recursive: true, force: true }); } catch { }
|
|
31
|
+
try { rmSync("./.bunia", { recursive: true, force: true }); } catch { }
|
|
32
|
+
|
|
33
|
+
// 1. Scan routes
|
|
34
|
+
const manifest = scanRoutes();
|
|
35
|
+
console.log(`📂 Found ${manifest.pages.length} page route(s):`);
|
|
36
|
+
for (const r of manifest.pages) {
|
|
37
|
+
console.log(` ${r.pattern} → ${r.page}${r.pageServer ? " (server)" : ""}`);
|
|
38
|
+
}
|
|
39
|
+
if (manifest.apis.length > 0) {
|
|
40
|
+
console.log(`📡 Found ${manifest.apis.length} API route(s):`);
|
|
41
|
+
for (const r of manifest.apis) {
|
|
42
|
+
console.log(` ${r.pattern} → ${r.server}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2. Generate .bunia/routes.ts (single file replaces all old code generators)
|
|
47
|
+
generateRoutesFile(manifest);
|
|
48
|
+
|
|
49
|
+
// 2b. Generate .bunia/types/src/routes/**/$types.d.ts for IDE type inference
|
|
50
|
+
generateRouteTypes(manifest);
|
|
51
|
+
|
|
52
|
+
// 2c. Ensure tsconfig.json has rootDirs pointing at .bunia/types
|
|
53
|
+
ensureRootDirs();
|
|
54
|
+
|
|
55
|
+
// 2d. Generate .bunia/env.server.ts, .bunia/env.client.ts, .bunia/types/env.d.ts
|
|
56
|
+
generateEnvModules(classifiedEnv);
|
|
57
|
+
|
|
58
|
+
// 3. Build Tailwind CSS
|
|
59
|
+
console.log("\n🎨 Building Tailwind CSS...");
|
|
60
|
+
const tailwindBin = join(BUNIA_NODE_MODULES, ".bin", "tailwindcss");
|
|
61
|
+
const tailwindResult = spawnSync(
|
|
62
|
+
[tailwindBin, "-i", "./src/app.css", "-o", "./public/bunia-tw.css", ...(isProduction ? ["--minify"] : [])],
|
|
63
|
+
{
|
|
64
|
+
cwd: process.cwd(),
|
|
65
|
+
env: { ...process.env, NODE_PATH: BUNIA_NODE_MODULES },
|
|
66
|
+
},
|
|
67
|
+
);
|
|
68
|
+
if (tailwindResult.exitCode !== 0) {
|
|
69
|
+
console.error("❌ Tailwind CSS build failed:\n" + tailwindResult.stderr.toString());
|
|
70
|
+
process.exit(1);
|
|
71
|
+
}
|
|
72
|
+
console.log("✅ Tailwind CSS built: public/bunia-tw.css");
|
|
73
|
+
|
|
74
|
+
// Separate plugin instances per build target (bunia:env resolves differently)
|
|
75
|
+
const clientPlugin = makeBuniaPlugin("browser");
|
|
76
|
+
const serverPlugin = makeBuniaPlugin("bun");
|
|
77
|
+
|
|
78
|
+
// Build-time defines: inline PUBLIC_STATIC_* and STATIC_* vars
|
|
79
|
+
const staticDefines: Record<string, string> = {};
|
|
80
|
+
for (const [key, value] of Object.entries(classifiedEnv.publicStatic)) {
|
|
81
|
+
staticDefines[`import.meta.env.${key}`] = JSON.stringify(value);
|
|
82
|
+
}
|
|
83
|
+
for (const [key, value] of Object.entries(classifiedEnv.privateStatic)) {
|
|
84
|
+
staticDefines[`import.meta.env.${key}`] = JSON.stringify(value);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 5. Build client bundle
|
|
88
|
+
console.log("\n📦 Building client bundle...");
|
|
89
|
+
const clientResult = await Bun.build({
|
|
90
|
+
entrypoints: [join(CORE_DIR, "client", "hydrate.ts")],
|
|
91
|
+
outdir: "./dist/client",
|
|
92
|
+
target: "browser",
|
|
93
|
+
splitting: true,
|
|
94
|
+
naming: { chunk: "[name]-[hash].[ext]" },
|
|
95
|
+
minify: isProduction,
|
|
96
|
+
define: {
|
|
97
|
+
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
|
|
98
|
+
...staticDefines,
|
|
99
|
+
},
|
|
100
|
+
plugins: [clientPlugin, SveltePlugin()],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (!clientResult.success) {
|
|
104
|
+
console.error("❌ Client build failed:");
|
|
105
|
+
for (const msg of clientResult.logs) console.error(msg);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 6. Collect output files for dist/manifest.json
|
|
110
|
+
const jsFiles: string[] = [];
|
|
111
|
+
const cssFiles: string[] = [];
|
|
112
|
+
for (const output of clientResult.outputs) {
|
|
113
|
+
const rel = relative("./dist/client", output.path);
|
|
114
|
+
if (output.path.endsWith(".js")) jsFiles.push(rel);
|
|
115
|
+
if (output.path.endsWith(".css")) cssFiles.push(rel);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 7. Build server bundle (before writing manifest so we can record the entry)
|
|
119
|
+
console.log("\n📦 Building server bundle...");
|
|
120
|
+
const serverResult = await Bun.build({
|
|
121
|
+
entrypoints: [join(CORE_DIR, "server.ts")],
|
|
122
|
+
outdir: "./dist/server",
|
|
123
|
+
target: "bun",
|
|
124
|
+
splitting: true,
|
|
125
|
+
naming: { entry: "index.[ext]", chunk: "[name]-[hash].[ext]" },
|
|
126
|
+
minify: isProduction,
|
|
127
|
+
external: ["elysia", "@elysiajs/static"],
|
|
128
|
+
plugins: [serverPlugin, SveltePlugin()],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!serverResult.success) {
|
|
132
|
+
console.error("❌ Server build failed:");
|
|
133
|
+
for (const msg of serverResult.logs) console.error(msg);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Entry is always "index.js" due to naming: { entry: "index.[ext]" }
|
|
138
|
+
const serverEntry = serverResult.outputs
|
|
139
|
+
.find(o => o.path.endsWith("index.js"))
|
|
140
|
+
?.path.split("/").pop() ?? "index.js";
|
|
141
|
+
|
|
142
|
+
// 8. Write dist/manifest.json
|
|
143
|
+
mkdirSync("./dist", { recursive: true });
|
|
144
|
+
const distManifest = {
|
|
145
|
+
js: jsFiles,
|
|
146
|
+
css: cssFiles,
|
|
147
|
+
entry: jsFiles.find(f => f.startsWith("hydrate")) ?? jsFiles[0] ?? "hydrate.js",
|
|
148
|
+
serverEntry,
|
|
149
|
+
};
|
|
150
|
+
writeFileSync("./dist/manifest.json", JSON.stringify(distManifest, null, 2));
|
|
151
|
+
console.log(`✅ Client bundle: ${jsFiles.join(", ")}`);
|
|
152
|
+
console.log(`✅ Server entry: dist/server/${serverEntry}`);
|
|
153
|
+
|
|
154
|
+
// 9. Prerender static routes
|
|
155
|
+
await prerenderStaticRoutes(manifest);
|
|
156
|
+
|
|
157
|
+
console.log("\n🎉 Build complete!");
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { router } from "./router.svelte.ts";
|
|
3
|
+
import { findMatch } from "../matcher.ts";
|
|
4
|
+
import { clientRoutes } from "bunia:routes";
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
ssrMode = false,
|
|
8
|
+
ssrPageComponent = null,
|
|
9
|
+
ssrLayoutComponents = [],
|
|
10
|
+
ssrPageData = {},
|
|
11
|
+
ssrLayoutData = [],
|
|
12
|
+
}: {
|
|
13
|
+
ssrMode?: boolean;
|
|
14
|
+
ssrPageComponent?: any;
|
|
15
|
+
ssrLayoutComponents?: any[];
|
|
16
|
+
ssrPageData?: Record<string, any>;
|
|
17
|
+
ssrLayoutData?: Record<string, any>[];
|
|
18
|
+
} = $props();
|
|
19
|
+
|
|
20
|
+
let PageComponent = $state<any>(ssrPageComponent);
|
|
21
|
+
let layoutComponents = $state<any[]>(ssrLayoutComponents ?? []);
|
|
22
|
+
let pageData = $state<Record<string, any>>(ssrPageData ?? {});
|
|
23
|
+
let layoutData = $state<Record<string, any>[]>(ssrLayoutData ?? []);
|
|
24
|
+
// Kept separate to avoid a read→write cycle inside the $effect below
|
|
25
|
+
let routeParams = $state<Record<string, string>>(ssrPageData?.params ?? {});
|
|
26
|
+
let navigating = $state(false);
|
|
27
|
+
let navDone = $state(false);
|
|
28
|
+
// Skip bar on the very first effect run (initial hydration — data already present)
|
|
29
|
+
let firstNav = true;
|
|
30
|
+
let navDoneTimer: ReturnType<typeof setTimeout> | null = null;
|
|
31
|
+
|
|
32
|
+
$effect(() => {
|
|
33
|
+
if (ssrMode) return;
|
|
34
|
+
|
|
35
|
+
const path = router.currentRoute;
|
|
36
|
+
const match = findMatch(clientRoutes, path);
|
|
37
|
+
if (!match) return;
|
|
38
|
+
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
|
|
41
|
+
const isFirst = firstNav;
|
|
42
|
+
firstNav = false;
|
|
43
|
+
if (!isFirst) {
|
|
44
|
+
if (navDoneTimer) { clearTimeout(navDoneTimer); navDoneTimer = null; }
|
|
45
|
+
navDone = false;
|
|
46
|
+
navigating = true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Load components + data in parallel, then update state atomically
|
|
50
|
+
// to avoid a flash of stale/empty data before the fetch completes.
|
|
51
|
+
const dataFetch = match.route.hasServerData
|
|
52
|
+
? fetch(`/__bunia/data?path=${encodeURIComponent(path)}`).then(r => r.json()).catch(() => null)
|
|
53
|
+
: Promise.resolve(null);
|
|
54
|
+
|
|
55
|
+
Promise.all([
|
|
56
|
+
match.route.page(),
|
|
57
|
+
Promise.all(match.route.layouts.map((l: any) => l())),
|
|
58
|
+
dataFetch,
|
|
59
|
+
]).then(([pageMod, layoutMods, result]: [any, any[], any]) => {
|
|
60
|
+
if (cancelled) return;
|
|
61
|
+
navigating = false;
|
|
62
|
+
navDone = true;
|
|
63
|
+
navDoneTimer = setTimeout(() => { navDone = false; }, 400);
|
|
64
|
+
if (result?.redirect) {
|
|
65
|
+
router.navigate(result.redirect);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (result?.error) {
|
|
69
|
+
window.location.href = path;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
PageComponent = pageMod.default;
|
|
73
|
+
layoutComponents = layoutMods.map((m: any) => m.default);
|
|
74
|
+
pageData = result?.pageData ?? {};
|
|
75
|
+
layoutData = result?.layoutData ?? [];
|
|
76
|
+
routeParams = result?.pageData?.params ?? match.params;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return () => { cancelled = true; };
|
|
80
|
+
});
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<!--
|
|
84
|
+
Nested layout rendering:
|
|
85
|
+
layouts[0] wraps layouts[1] wraps ... wraps PageComponent
|
|
86
|
+
-->
|
|
87
|
+
|
|
88
|
+
{#if navigating}
|
|
89
|
+
<div class="bunia-bar loading"></div>
|
|
90
|
+
{:else if navDone}
|
|
91
|
+
<div class="bunia-bar done"></div>
|
|
92
|
+
{/if}
|
|
93
|
+
|
|
94
|
+
{#if layoutComponents.length > 0}
|
|
95
|
+
{@render renderLayout(0)}
|
|
96
|
+
{:else if PageComponent}
|
|
97
|
+
<PageComponent data={{ ...pageData, params: routeParams }} />
|
|
98
|
+
{:else}
|
|
99
|
+
<p>Loading...</p>
|
|
100
|
+
{/if}
|
|
101
|
+
|
|
102
|
+
{#snippet renderLayout(index: number)}
|
|
103
|
+
{@const Layout = layoutComponents[index]}
|
|
104
|
+
{@const data = layoutData[index] ?? {}}
|
|
105
|
+
|
|
106
|
+
{#if index < layoutComponents.length - 1}
|
|
107
|
+
<Layout {data}>
|
|
108
|
+
{@render renderLayout(index + 1)}
|
|
109
|
+
</Layout>
|
|
110
|
+
{:else}
|
|
111
|
+
<Layout {data}>
|
|
112
|
+
{#if PageComponent}
|
|
113
|
+
<PageComponent data={{ ...pageData, params: routeParams }} />
|
|
114
|
+
{:else}
|
|
115
|
+
<p>Loading...</p>
|
|
116
|
+
{/if}
|
|
117
|
+
</Layout>
|
|
118
|
+
{/if}
|
|
119
|
+
{/snippet}
|
|
120
|
+
|
|
121
|
+
<style>
|
|
122
|
+
.bunia-bar {
|
|
123
|
+
position: fixed;
|
|
124
|
+
top: 0;
|
|
125
|
+
left: 0;
|
|
126
|
+
height: 2px;
|
|
127
|
+
width: 100%;
|
|
128
|
+
background: var(--bunia-loading-color, #f73b27);
|
|
129
|
+
z-index: 9999;
|
|
130
|
+
pointer-events: none;
|
|
131
|
+
transform-origin: left center;
|
|
132
|
+
}
|
|
133
|
+
.bunia-bar.loading {
|
|
134
|
+
animation: bunia-load 8s cubic-bezier(0.02, 0.5, 0.5, 1) forwards;
|
|
135
|
+
}
|
|
136
|
+
.bunia-bar.done {
|
|
137
|
+
animation: bunia-done 0.35s ease forwards;
|
|
138
|
+
}
|
|
139
|
+
@keyframes bunia-load {
|
|
140
|
+
from { transform: scaleX(0); }
|
|
141
|
+
to { transform: scaleX(0.85); }
|
|
142
|
+
}
|
|
143
|
+
@keyframes bunia-done {
|
|
144
|
+
from { transform: scaleX(1); opacity: 1; }
|
|
145
|
+
to { transform: scaleX(1); opacity: 0; }
|
|
146
|
+
}
|
|
147
|
+
</style>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { hydrate } from "svelte";
|
|
2
|
+
import App from "./App.svelte";
|
|
3
|
+
import { router } from "./router.svelte.ts";
|
|
4
|
+
import { findMatch } from "../matcher.ts";
|
|
5
|
+
import { clientRoutes } from "bunia:routes";
|
|
6
|
+
|
|
7
|
+
// ─── Hydration ────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
async function main() {
|
|
10
|
+
const path = window.location.pathname;
|
|
11
|
+
|
|
12
|
+
router.init();
|
|
13
|
+
router.currentRoute = path;
|
|
14
|
+
|
|
15
|
+
// Resolve the current route so we can pre-load the components
|
|
16
|
+
// before handing off to App.svelte (avoids a flash of "Loading...")
|
|
17
|
+
const match = findMatch(clientRoutes, path);
|
|
18
|
+
|
|
19
|
+
let ssrPageComponent = null;
|
|
20
|
+
let ssrLayoutComponents: any[] = [];
|
|
21
|
+
|
|
22
|
+
if (match) {
|
|
23
|
+
const [pageMod, ...layoutMods] = await Promise.all([
|
|
24
|
+
match.route.page(),
|
|
25
|
+
...match.route.layouts.map(l => l()),
|
|
26
|
+
]);
|
|
27
|
+
ssrPageComponent = pageMod.default;
|
|
28
|
+
ssrLayoutComponents = layoutMods.map(m => m.default);
|
|
29
|
+
router.params = match.params;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
hydrate(App, {
|
|
33
|
+
target: document.getElementById("app")!,
|
|
34
|
+
props: {
|
|
35
|
+
ssrMode: false,
|
|
36
|
+
ssrPageComponent,
|
|
37
|
+
ssrLayoutComponents,
|
|
38
|
+
ssrPageData: (window as any).__BUNIA_PAGE_DATA__ ?? {},
|
|
39
|
+
ssrLayoutData: (window as any).__BUNIA_LAYOUT_DATA__ ?? [],
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
main();
|
|
45
|
+
|
|
46
|
+
// ─── Hot Reload (dev only) ────────────────────────────────
|
|
47
|
+
|
|
48
|
+
if (process.env.NODE_ENV !== "production") {
|
|
49
|
+
let connectedOnce = false;
|
|
50
|
+
let retryDelay = 1000;
|
|
51
|
+
|
|
52
|
+
function connectSSE() {
|
|
53
|
+
const es = new EventSource("/__bunia/sse");
|
|
54
|
+
|
|
55
|
+
es.addEventListener("reload", () => {
|
|
56
|
+
console.log("[Bunia] Reloading...");
|
|
57
|
+
window.location.reload();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
es.onopen = () => {
|
|
61
|
+
retryDelay = 1000;
|
|
62
|
+
if (connectedOnce) {
|
|
63
|
+
// Server came back up after a restart — reload immediately
|
|
64
|
+
window.location.reload();
|
|
65
|
+
}
|
|
66
|
+
connectedOnce = true;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
es.onerror = () => {
|
|
70
|
+
es.close();
|
|
71
|
+
console.log(`[Bunia] SSE disconnected. Retrying in ${retryDelay / 1000}s...`);
|
|
72
|
+
setTimeout(connectSSE, retryDelay);
|
|
73
|
+
retryDelay = Math.min(retryDelay + 1000, 5000);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
connectSSE();
|
|
78
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ─── Client Router ────────────────────────────────────────
|
|
2
|
+
// Svelte 5 rune-based reactive router.
|
|
3
|
+
// Singleton used by App.svelte and hydrate.ts.
|
|
4
|
+
|
|
5
|
+
import { findMatch } from "../matcher.ts";
|
|
6
|
+
import { clientRoutes } from "bunia:routes";
|
|
7
|
+
|
|
8
|
+
export const router = new class Router {
|
|
9
|
+
currentRoute = $state(typeof window !== "undefined" ? window.location.pathname : "/");
|
|
10
|
+
params = $state<Record<string, string>>({});
|
|
11
|
+
|
|
12
|
+
navigate(path: string) {
|
|
13
|
+
if (this.currentRoute === path) return;
|
|
14
|
+
// Unknown route — let the server handle it (renders +error.svelte with 404)
|
|
15
|
+
if (!findMatch(clientRoutes, path)) {
|
|
16
|
+
window.location.href = path;
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
this.currentRoute = path;
|
|
20
|
+
if (typeof history !== "undefined") {
|
|
21
|
+
history.pushState({}, "", path);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
init() {
|
|
26
|
+
if (typeof window === "undefined") return;
|
|
27
|
+
|
|
28
|
+
// Intercept <a> clicks for client-side navigation
|
|
29
|
+
window.addEventListener("click", (e) => {
|
|
30
|
+
const anchor = (e.target as HTMLElement).closest("a");
|
|
31
|
+
if (!anchor) return;
|
|
32
|
+
if (anchor.origin !== window.location.origin) return;
|
|
33
|
+
if (anchor.target) return;
|
|
34
|
+
if (anchor.hasAttribute("download")) return;
|
|
35
|
+
if (anchor.protocol !== "https:" && anchor.protocol !== "http:") return;
|
|
36
|
+
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
this.navigate(anchor.pathname + anchor.search + anchor.hash);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Browser back/forward
|
|
42
|
+
window.addEventListener("popstate", () => {
|
|
43
|
+
this.currentRoute = window.location.pathname + window.location.search + window.location.hash;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Cookies, CookieOptions } from "./hooks.ts";
|
|
2
|
+
|
|
3
|
+
// ─── Cookie Helpers ──────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
function parseCookies(header: string): Record<string, string> {
|
|
6
|
+
const result: Record<string, string> = {};
|
|
7
|
+
for (const pair of header.split(";")) {
|
|
8
|
+
const idx = pair.indexOf("=");
|
|
9
|
+
if (idx === -1) continue;
|
|
10
|
+
const name = pair.slice(0, idx).trim();
|
|
11
|
+
const value = pair.slice(idx + 1).trim();
|
|
12
|
+
if (name) result[name] = decodeURIComponent(value);
|
|
13
|
+
}
|
|
14
|
+
return result;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class CookieJar implements Cookies {
|
|
18
|
+
private _incoming: Record<string, string>;
|
|
19
|
+
private _outgoing: string[] = [];
|
|
20
|
+
|
|
21
|
+
constructor(cookieHeader: string) {
|
|
22
|
+
this._incoming = parseCookies(cookieHeader);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get(name: string): string | undefined {
|
|
26
|
+
return this._incoming[name];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
getAll(): Record<string, string> {
|
|
30
|
+
return { ...this._incoming };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
set(name: string, value: string, options?: CookieOptions): void {
|
|
34
|
+
let header = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
|
35
|
+
header += `; Path=${options?.path ?? "/"}`;
|
|
36
|
+
if (options?.domain) header += `; Domain=${options.domain}`;
|
|
37
|
+
if (options?.maxAge != null) header += `; Max-Age=${options.maxAge}`;
|
|
38
|
+
if (options?.expires) header += `; Expires=${options.expires.toUTCString()}`;
|
|
39
|
+
if (options?.httpOnly) header += "; HttpOnly";
|
|
40
|
+
if (options?.secure) header += "; Secure";
|
|
41
|
+
if (options?.sameSite) header += `; SameSite=${options.sameSite}`;
|
|
42
|
+
this._outgoing.push(header);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
delete(name: string, options?: Pick<CookieOptions, "path" | "domain">): void {
|
|
46
|
+
this.set(name, "", { path: options?.path, domain: options?.domain, maxAge: 0 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
get outgoing(): readonly string[] {
|
|
50
|
+
return this._outgoing;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/core/cors.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export interface CorsConfig {
|
|
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
|
+
}
|
|
15
|
+
|
|
16
|
+
const DEFAULT_METHODS = "GET, HEAD, PUT, PATCH, POST, DELETE";
|
|
17
|
+
const DEFAULT_HEADERS = "Content-Type, Authorization";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Returns CORS response headers if the request Origin is in the allowed list.
|
|
21
|
+
* Returns null if Origin is absent or not allowed.
|
|
22
|
+
*/
|
|
23
|
+
export function getCorsHeaders(request: Request, config: CorsConfig): Record<string, string> | null {
|
|
24
|
+
const origin = request.headers.get("origin");
|
|
25
|
+
if (!origin) return null;
|
|
26
|
+
|
|
27
|
+
const allowed = config.allowedOrigins.includes(origin);
|
|
28
|
+
if (!allowed) return null;
|
|
29
|
+
|
|
30
|
+
const headers: Record<string, string> = {
|
|
31
|
+
"Access-Control-Allow-Origin": origin,
|
|
32
|
+
"Vary": "Origin",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if (config.credentials) {
|
|
36
|
+
headers["Access-Control-Allow-Credentials"] = "true";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (config.exposedHeaders?.length) {
|
|
40
|
+
headers["Access-Control-Expose-Headers"] = config.exposedHeaders.join(", ");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return headers;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Handles OPTIONS preflight requests.
|
|
48
|
+
* Returns a 204 response with CORS headers, or null if the origin is not allowed.
|
|
49
|
+
*/
|
|
50
|
+
export function handlePreflight(request: Request, config: CorsConfig): Response | null {
|
|
51
|
+
const base = getCorsHeaders(request, config);
|
|
52
|
+
if (!base) return null;
|
|
53
|
+
|
|
54
|
+
const headers = new Headers(base as HeadersInit);
|
|
55
|
+
headers.set("Access-Control-Allow-Methods", config.allowedMethods?.join(", ") ?? DEFAULT_METHODS);
|
|
56
|
+
headers.set("Access-Control-Allow-Headers", config.allowedHeaders?.join(", ") ?? DEFAULT_HEADERS);
|
|
57
|
+
headers.set("Access-Control-Max-Age", String(config.maxAge ?? 86400));
|
|
58
|
+
|
|
59
|
+
return new Response(null, { status: 204, headers });
|
|
60
|
+
}
|
package/src/core/csrf.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// ─── CSRF Protection ──────────────────────────────────────
|
|
2
|
+
// Origin-based CSRF validation — same approach as SvelteKit.
|
|
3
|
+
// All non-safe (state-changing) requests must originate from
|
|
4
|
+
// the same origin as the server. Browsers always send `Origin`
|
|
5
|
+
// on cross-origin requests, so a missing or mismatched header
|
|
6
|
+
// is treated as a cross-origin attack.
|
|
7
|
+
|
|
8
|
+
export interface CsrfConfig {
|
|
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
|
+
}
|
|
14
|
+
|
|
15
|
+
export const DEFAULT_CSRF_CONFIG: CsrfConfig = {
|
|
16
|
+
checkOrigin: true,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check whether a request passes CSRF validation.
|
|
23
|
+
* Returns `null` on success, or an error message string to reject with 403.
|
|
24
|
+
*/
|
|
25
|
+
export function checkCsrf(
|
|
26
|
+
request: Request,
|
|
27
|
+
url: URL,
|
|
28
|
+
config: CsrfConfig = DEFAULT_CSRF_CONFIG,
|
|
29
|
+
): string | null {
|
|
30
|
+
if (!config.checkOrigin) return null;
|
|
31
|
+
if (SAFE_METHODS.has(request.method.toUpperCase())) return null;
|
|
32
|
+
|
|
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
|
+
|
|
42
|
+
const allowedOrigins = new Set([expectedOrigin, ...(config.allowedOrigins ?? [])]);
|
|
43
|
+
|
|
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
|
+
|
|
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
|
+
|
|
63
|
+
// Neither Origin nor Referer present — reject
|
|
64
|
+
return "Forbidden: missing Origin or Referer header on non-safe request";
|
|
65
|
+
}
|