bosia 1.2.6 → 1.2.7
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/backend.ts +34 -4
- package/src/core/basePath.ts +30 -0
- package/src/core/client/App.svelte +2 -2
- package/src/core/client/hydrate.ts +17 -9
- package/src/core/client/navigation.ts +1 -1
- package/src/core/client/prefetch.ts +9 -17
- package/src/core/client/router.svelte.ts +41 -22
- package/src/core/csrf.ts +32 -19
- package/src/core/dev.ts +11 -1
- package/src/core/devWsProxy.ts +144 -0
- package/src/core/env.ts +4 -0
- package/src/core/errors.ts +23 -6
- package/src/core/plugins/access-log.ts +2 -2
- package/src/core/proxy.ts +48 -0
- package/src/core/renderer.ts +1 -1
- package/src/core/routeTypes.ts +16 -1
- package/src/core/server.bun.ts +6 -1
- package/src/core/server.ts +122 -41
- package/src/core/server.workers.ts +2 -1
- package/src/core/websocket.ts +232 -0
- package/src/core/workersGuard.ts +18 -3
- package/src/lib/index.ts +1 -0
package/package.json
CHANGED
package/src/core/backend.ts
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
// Workers alike.
|
|
5
5
|
// Only the subset the framework and plugins use — not a general router.
|
|
6
6
|
|
|
7
|
+
import type { Server } from "bun";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
isUpgradeRequest,
|
|
11
|
+
isUpgraded,
|
|
12
|
+
runUpgrade,
|
|
13
|
+
websocketHandlers,
|
|
14
|
+
type WebSocketOptions,
|
|
15
|
+
} from "./websocket.ts";
|
|
16
|
+
|
|
7
17
|
/** Outbound response bag — merged into whatever the handler returns. */
|
|
8
18
|
export interface ResponseSet {
|
|
9
19
|
status?: number;
|
|
@@ -41,10 +51,14 @@ export type ErrorHook = (ctx: { request: Request; error: unknown; set: ResponseS
|
|
|
41
51
|
* `start`: `performance.now()` when the request entered `fetch`. Observe only:
|
|
42
52
|
* the return value is ignored and a throw or rejection is swallowed. `response`
|
|
43
53
|
* is the one the client gets — don't read its body or change its headers.
|
|
54
|
+
* `status` is the status the client got: 101 for a WebSocket upgrade, whose
|
|
55
|
+
* `response` is only a placeholder (a 101 Response can't be built).
|
|
44
56
|
*/
|
|
45
57
|
export type ResponseHook = (ctx: {
|
|
46
58
|
request: Request;
|
|
47
59
|
response: Response;
|
|
60
|
+
status: number;
|
|
61
|
+
upgraded: boolean;
|
|
48
62
|
start: number;
|
|
49
63
|
}) => void | Promise<unknown>;
|
|
50
64
|
|
|
@@ -52,6 +66,7 @@ export interface ServeOptions {
|
|
|
52
66
|
maxRequestBodySize?: number;
|
|
53
67
|
idleTimeout?: number;
|
|
54
68
|
reusePort?: boolean;
|
|
69
|
+
websocket?: WebSocketOptions;
|
|
55
70
|
}
|
|
56
71
|
|
|
57
72
|
type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "*";
|
|
@@ -150,8 +165,18 @@ export class BosiaApp {
|
|
|
150
165
|
return [...methods];
|
|
151
166
|
}
|
|
152
167
|
|
|
153
|
-
|
|
154
|
-
|
|
168
|
+
/** `undefined` after a WebSocket upgrade — Bun has already answered with 101.
|
|
169
|
+
* Only Bun passes `server`, so Workers never upgrades. */
|
|
170
|
+
fetch = (request: Request, server?: Server<any>): Promise<Response | undefined> =>
|
|
171
|
+
server && isUpgradeRequest(request)
|
|
172
|
+
? runUpgrade(request, server, () => this.handle(request))
|
|
173
|
+
: this.handle(request);
|
|
174
|
+
|
|
175
|
+
private handle = async (request: Request): Promise<Response | undefined> => {
|
|
176
|
+
if (this.responseHooks.length === 0) {
|
|
177
|
+
const response = await this.respond(request);
|
|
178
|
+
return isUpgraded() ? undefined : response;
|
|
179
|
+
}
|
|
155
180
|
const start = performance.now();
|
|
156
181
|
let response: Response;
|
|
157
182
|
try {
|
|
@@ -162,13 +187,15 @@ export class BosiaApp {
|
|
|
162
187
|
throw error;
|
|
163
188
|
}
|
|
164
189
|
this.observe(request, response, start);
|
|
165
|
-
return response;
|
|
190
|
+
return isUpgraded() ? undefined : response;
|
|
166
191
|
};
|
|
167
192
|
|
|
168
193
|
private observe(request: Request, response: Response, start: number): void {
|
|
194
|
+
const upgraded = isUpgraded();
|
|
195
|
+
const status = upgraded ? 101 : response.status;
|
|
169
196
|
for (const hook of this.responseHooks) {
|
|
170
197
|
try {
|
|
171
|
-
const out = hook({ request, response, start });
|
|
198
|
+
const out = hook({ request, response, status, upgraded, start });
|
|
172
199
|
// An async observer's rejection would be unhandled — fatal in prod.
|
|
173
200
|
if (out instanceof Promise) out.catch(() => {});
|
|
174
201
|
} catch {
|
|
@@ -227,6 +254,8 @@ export class BosiaApp {
|
|
|
227
254
|
response = "Method Not Allowed";
|
|
228
255
|
}
|
|
229
256
|
|
|
257
|
+
// Bun already sent the 101: nothing after this can change the answer.
|
|
258
|
+
if (isUpgraded()) return response as Response;
|
|
230
259
|
for (const hook of this.afterHooks) {
|
|
231
260
|
const out = await hook({ request, response, set });
|
|
232
261
|
if (out !== undefined) response = out;
|
|
@@ -243,6 +272,7 @@ export class BosiaApp {
|
|
|
243
272
|
maxRequestBodySize: serve.maxRequestBodySize,
|
|
244
273
|
idleTimeout: serve.idleTimeout,
|
|
245
274
|
reusePort: serve.reusePort ?? false,
|
|
275
|
+
websocket: { ...websocketHandlers, ...serve.websocket, sendPings: true },
|
|
246
276
|
});
|
|
247
277
|
callback?.();
|
|
248
278
|
return this;
|
package/src/core/basePath.ts
CHANGED
|
@@ -30,6 +30,36 @@ export function withBase(base: string, path: string): string {
|
|
|
30
30
|
return base + path;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Never a navigation target. Test a parsed `URL.protocol`, not the raw string: the
|
|
34
|
+
* parser drops tabs, newlines and leading control chars, so `java\tscript:` is one too. */
|
|
35
|
+
export const DANGEROUS_PROTOCOL = /^(javascript|data|vbscript):$/;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What the client router matches: the path the server would see. Every URL goes
|
|
39
|
+
* through the URL parser against `pageHref`, like a link: `?page=2`, `b`, `../x`
|
|
40
|
+
* resolve, `/a b` and `/café` get encoded (params match the server's), `/\host`
|
|
41
|
+
* is another origin. Another origin comes back as its full URL so the router leaves the app.
|
|
42
|
+
*/
|
|
43
|
+
export function toRouterPath(url: string, pageHref: string): string {
|
|
44
|
+
const u = new URL(url, pageHref);
|
|
45
|
+
if (DANGEROUS_PROTOCOL.test(u.protocol)) {
|
|
46
|
+
throw new Error(`navigation: dangerous scheme in URL "${url}". Use a path or an http(s) URL.`);
|
|
47
|
+
}
|
|
48
|
+
// Scheme + host, not `origin`: a `blob:` URL shares the page's origin, and the URL
|
|
49
|
+
// parser gives app shells (`capacitor://localhost`) origin "null" for every host.
|
|
50
|
+
// A path starting `//` (from `/.//evil.com`) would read as another host once used as
|
|
51
|
+
// a URL, so it stays a full URL: the router leaves for this origin's `//evil.com` page.
|
|
52
|
+
const page = new URL(pageHref);
|
|
53
|
+
// No host (`file:`) is never ours: every file on disk would count as one app.
|
|
54
|
+
const ours =
|
|
55
|
+
u.host !== "" &&
|
|
56
|
+
u.protocol === page.protocol &&
|
|
57
|
+
u.host === page.host &&
|
|
58
|
+
!u.pathname.startsWith("//");
|
|
59
|
+
// `capacitor://localhost` has an empty path: that's the root.
|
|
60
|
+
return ours ? (u.pathname || "/") + u.search + u.hash : u.href;
|
|
61
|
+
}
|
|
62
|
+
|
|
33
63
|
/**
|
|
34
64
|
* The in-app pathname for an incoming request, or `null` when the request is
|
|
35
65
|
* not ours to answer. `/sso` and `/sso/` both resolve to `/` so the root route
|
|
@@ -207,7 +207,7 @@
|
|
|
207
207
|
const currentTick = appState.invalidationTick; // still tracks reactively
|
|
208
208
|
|
|
209
209
|
const path = router.currentRoute;
|
|
210
|
-
const url = new URL(path, window.location.
|
|
210
|
+
const url = new URL(path, window.location.href);
|
|
211
211
|
const pathname = url.pathname;
|
|
212
212
|
const navKey = pathname + url.search;
|
|
213
213
|
const match = findMatch(clientRoutes, pathname);
|
|
@@ -227,7 +227,7 @@
|
|
|
227
227
|
const isFirst = firstNav;
|
|
228
228
|
firstNav = false;
|
|
229
229
|
if (isFirst) {
|
|
230
|
-
const hUrl = new URL(router.hydratedRoute, window.location.
|
|
230
|
+
const hUrl = new URL(router.hydratedRoute, window.location.href);
|
|
231
231
|
const hMatch = router.hydratedRoute === path ? match : findMatch(clientRoutes, hUrl.pathname);
|
|
232
232
|
currentLayoutPaths = (hMatch?.route as any)?.layoutPaths ?? [];
|
|
233
233
|
// The probes ran during hydration, before this effect. A title other than
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { hydrate, mount } from "svelte";
|
|
2
2
|
import App from "./App.svelte";
|
|
3
|
-
import { router } from "./router.svelte.ts";
|
|
3
|
+
import { locationPath, router } from "./router.svelte.ts";
|
|
4
4
|
import { initPrefetch } from "./prefetch.ts";
|
|
5
5
|
import { findMatch, compileRoutes, canonicalPathname } from "../matcher.ts";
|
|
6
6
|
import { clientRoutes } from "bosia:routes";
|
|
@@ -25,7 +25,8 @@ function readJsonScript<T>(id: string): T | null {
|
|
|
25
25
|
// ─── Hydration ────────────────────────────────────────────
|
|
26
26
|
|
|
27
27
|
async function main() {
|
|
28
|
-
|
|
28
|
+
// The pathname of the route the router will hold (`capacitor://localhost` → `/`).
|
|
29
|
+
const path = new URL(locationPath(), window.location.href).pathname;
|
|
29
30
|
|
|
30
31
|
router.init();
|
|
31
32
|
|
|
@@ -36,7 +37,8 @@ async function main() {
|
|
|
36
37
|
// Canonicalize trailing slash on initial mount — server already 308'd if
|
|
37
38
|
// SSR'd, but `ssr=false` shells and prerendered pages can land on the
|
|
38
39
|
// non-canonical URL. replaceState (no extra history entry).
|
|
39
|
-
|
|
40
|
+
// A `//x` path is skipped: replaceState would read it as host x and throw.
|
|
41
|
+
if (match && !path.startsWith("//")) {
|
|
40
42
|
const canonical = canonicalPathname(path, (match.route as any).trailingSlash ?? "never");
|
|
41
43
|
if (canonical !== null && typeof history !== "undefined") {
|
|
42
44
|
history.replaceState(
|
|
@@ -46,7 +48,7 @@ async function main() {
|
|
|
46
48
|
);
|
|
47
49
|
}
|
|
48
50
|
}
|
|
49
|
-
router.currentRoute =
|
|
51
|
+
router.currentRoute = locationPath();
|
|
50
52
|
// After canonicalizing, before any await: a click during the chunk loads below
|
|
51
53
|
// moves currentRoute, and App must still know which route the server rendered.
|
|
52
54
|
router.hydratedRoute = router.currentRoute;
|
|
@@ -84,7 +86,7 @@ async function main() {
|
|
|
84
86
|
if (match) {
|
|
85
87
|
// The server's route, not window.location: a click during the chunk loads
|
|
86
88
|
// above already moved the address bar on.
|
|
87
|
-
const url = new URL(router.hydratedRoute, window.location.
|
|
89
|
+
const url = new URL(router.hydratedRoute, window.location.href);
|
|
88
90
|
const ctx = liveContext(url.pathname, match.params, url);
|
|
89
91
|
const ssrPageDeps: LoaderDeps | null = (window as any).__BOSIA_PAGE_DEPS__ ?? null;
|
|
90
92
|
const ssrLayoutDeps: (LoaderDeps | null)[] = (window as any).__BOSIA_LAYOUT_DEPS__ ?? [];
|
|
@@ -169,10 +171,16 @@ if (typeof window !== "undefined" && process.env.NODE_ENV === "production") {
|
|
|
169
171
|
} catch {
|
|
170
172
|
// Storage may be unavailable (private mode) — best effort.
|
|
171
173
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
);
|
|
174
|
+
// Split on `&` and kept as text: searchParams would re-encode the query (breaks
|
|
175
|
+
// signed URLs), and a pattern could hit a `?_v=` inside a value (`?next=/a?_v=1`).
|
|
176
|
+
// The last deploy's `_v` goes, so they don't pile up.
|
|
177
|
+
const url = new URL(window.location.href);
|
|
178
|
+
const parts = url.search
|
|
179
|
+
.slice(1)
|
|
180
|
+
.split("&")
|
|
181
|
+
.filter((p) => p && !/^_v=\d+$/.test(p));
|
|
182
|
+
url.search = [...parts, `_v=${Date.now()}`].join("&");
|
|
183
|
+
window.location.replace(url.href);
|
|
176
184
|
});
|
|
177
185
|
}
|
|
178
186
|
} catch {
|
|
@@ -73,7 +73,7 @@ export function invalidate(target: InvalidateTarget): Promise<void> {
|
|
|
73
73
|
try {
|
|
74
74
|
// Normalize to an absolute URL — fetches the loader recorded are
|
|
75
75
|
// always absolute, so a relative `/api/foo` must be promoted here.
|
|
76
|
-
const abs = new URL(str, window.location.
|
|
76
|
+
const abs = new URL(str, window.location.href).href;
|
|
77
77
|
appState.dirty.urls.add(abs);
|
|
78
78
|
} catch {
|
|
79
79
|
appState.dirty.urls.add(str);
|
|
@@ -7,6 +7,7 @@ import { clientRoutes } from "bosia:routes";
|
|
|
7
7
|
import { appState } from "./appState.svelte.ts";
|
|
8
8
|
import { liveContext, shouldRerun } from "./loaderCache.ts";
|
|
9
9
|
import { base } from "./base.ts";
|
|
10
|
+
import { appLinkPath, locationPath } from "./router.svelte.ts";
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Build the `_invalidated` mask bits for a target path using the current
|
|
@@ -15,7 +16,7 @@ import { base } from "./base.ts";
|
|
|
15
16
|
* since they may be guards). Returns `null` when the route cannot be matched.
|
|
16
17
|
*/
|
|
17
18
|
export function buildMaskBits(path: string): string | null {
|
|
18
|
-
const url = new URL(path, window.location.
|
|
19
|
+
const url = new URL(path, window.location.href);
|
|
19
20
|
const pathname = url.pathname;
|
|
20
21
|
const match = findMatch(clientRoutes, pathname);
|
|
21
22
|
// Prerendered data is a fixed file: nothing to skip, and on Workers only a
|
|
@@ -47,7 +48,7 @@ export function buildMaskBits(path: string): string | null {
|
|
|
47
48
|
* fetch that follows an `invalidate()`, so the server skips its cached copy.
|
|
48
49
|
*/
|
|
49
50
|
export function dataUrl(path: string, invalidatedBits?: string, fresh = false): string {
|
|
50
|
-
const url = new URL(path, window.location.
|
|
51
|
+
const url = new URL(path, window.location.href);
|
|
51
52
|
let p = url.pathname.replace(/\/$/, "");
|
|
52
53
|
let qs = url.search;
|
|
53
54
|
if (invalidatedBits) {
|
|
@@ -79,15 +80,8 @@ function isJsonResponse(res: Response): boolean {
|
|
|
79
80
|
export async function readDataResponse(res: Response): Promise<any> {
|
|
80
81
|
// `fetch` follows redirects, so a hook's 303 arrives as the login page's HTML
|
|
81
82
|
// at status 200. `redirected` is the only surviving trace of the redirect.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
return {
|
|
85
|
-
redirect:
|
|
86
|
-
target.origin === window.location.origin
|
|
87
|
-
? target.pathname + target.search + target.hash
|
|
88
|
-
: target.href,
|
|
89
|
-
};
|
|
90
|
-
}
|
|
83
|
+
// router.navigate() turns it into a path (or leaves the app) via toRouterPath.
|
|
84
|
+
if (res.redirected) return { redirect: res.url };
|
|
91
85
|
if (isJsonResponse(res)) {
|
|
92
86
|
try {
|
|
93
87
|
return await res.json();
|
|
@@ -153,10 +147,10 @@ export function clearPrefetches(): void {
|
|
|
153
147
|
export async function prefetchPath(path: string): Promise<void> {
|
|
154
148
|
// Nothing to fetch for the page we're on. A click also focuses its link, so
|
|
155
149
|
// the focusin trigger fires ~100ms after the navigation it belongs to.
|
|
156
|
-
if (path ===
|
|
150
|
+
if (path === locationPath().split("#")[0]) return;
|
|
157
151
|
// Warm the route's +loading.svelte chunk alongside its data, so the skeleton
|
|
158
152
|
// paints instantly on click instead of cold-importing after the old page lingers.
|
|
159
|
-
const warmMatch = findMatch(clientRoutes, new URL(path, window.location.
|
|
153
|
+
const warmMatch = findMatch(clientRoutes, new URL(path, window.location.href).pathname);
|
|
160
154
|
// Also warm the page and layout chunks: a route not visited yet otherwise
|
|
161
155
|
// downloads its code only after the click. Errors are swallowed — a stale
|
|
162
156
|
// chunk is the click's to report, not the hover's (hydrate.ts reloads on it).
|
|
@@ -209,10 +203,8 @@ export async function prefetchPath(path: string): Promise<void> {
|
|
|
209
203
|
}
|
|
210
204
|
|
|
211
205
|
function getLinkHref(anchor: HTMLAnchorElement): string | null {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
if (anchor.hasAttribute("download")) return null;
|
|
215
|
-
return anchor.pathname + anchor.search;
|
|
206
|
+
// The click handler's own check; the cache is keyed without the hash.
|
|
207
|
+
return appLinkPath(anchor)?.split("#")[0] ?? null;
|
|
216
208
|
}
|
|
217
209
|
|
|
218
210
|
function observeViewportLinks(container: Element | Document = document) {
|
|
@@ -6,7 +6,7 @@ import { findMatch, canonicalPathname } from "../matcher.ts";
|
|
|
6
6
|
import { clientRoutes } from "bosia:routes";
|
|
7
7
|
import { fireBeforeNavigate, type Navigation } from "./navListeners.ts";
|
|
8
8
|
import { base } from "./base.ts";
|
|
9
|
-
import { withBase } from "../basePath.ts";
|
|
9
|
+
import { toRouterPath, withBase } from "../basePath.ts";
|
|
10
10
|
|
|
11
11
|
// Everything here is a real browser path, base and all. Under a BASE_PATH mount
|
|
12
12
|
// the generated `clientRoutes` carry the prefix too, so an anchor's href, the
|
|
@@ -20,9 +20,9 @@ export type NavResult = "started" | "same" | "cancelled" | "left";
|
|
|
20
20
|
|
|
21
21
|
function buildTarget(path: string): { url: URL; params: Record<string, string> } | null {
|
|
22
22
|
if (typeof window === "undefined") return null;
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
const
|
|
23
|
+
// From the URL, not a string split: the route may be a full URL (`http://host//x`).
|
|
24
|
+
const url = new URL(path, window.location.href);
|
|
25
|
+
const match = findMatch(clientRoutes, url.pathname);
|
|
26
26
|
return { url, params: match?.params ?? {} };
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -74,15 +74,36 @@ export function scrollToHash(hash: string): boolean {
|
|
|
74
74
|
return true;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** The address bar as a router path. Never raw `location.pathname`: a `//evil.com` path
|
|
78
|
+
* would read as a host wherever the route is used as a URL; toRouterPath keeps it a full URL. */
|
|
79
|
+
export function locationPath(): string {
|
|
80
|
+
return toRouterPath(window.location.href, window.location.href);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The router path an `<a>` goes to, or null when the browser should follow it: `target`,
|
|
84
|
+
* `download`, `rel="external"`, or anything toRouterPath doesn't call ours (another
|
|
85
|
+
* scheme or host, no host like `file:`, a `//x` path, a `javascript:` href).
|
|
86
|
+
* The click handler and hover prefetch both use it, so they can't disagree. */
|
|
87
|
+
export function appLinkPath(anchor: HTMLAnchorElement): string | null {
|
|
88
|
+
// No href (`<a role="button">`) is not a link: `anchor.href` "" would read as this page.
|
|
89
|
+
if (!anchor.hasAttribute("href")) return null;
|
|
90
|
+
if (anchor.target || anchor.hasAttribute("download")) return null;
|
|
91
|
+
// HTML splits `rel` on ASCII whitespace only, and ignores case.
|
|
92
|
+
if (/(^|[\t\n\f\r ])external([\t\n\f\r ]|$)/i.test(anchor.rel)) return null;
|
|
93
|
+
try {
|
|
94
|
+
const path = toRouterPath(anchor.href, window.location.href);
|
|
95
|
+
return path.startsWith("/") ? path : null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null; // a dangerous scheme: the browser handles it, as on any page
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
77
101
|
export const router = new (class Router {
|
|
78
|
-
currentRoute = $state(
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
);
|
|
83
|
-
/** Origin half of `page.url`. Empty on the server until the renderer seeds it
|
|
84
|
-
* per request — see `renderWithPageContext` in core/renderer.ts. */
|
|
85
|
-
origin = $state(typeof window !== "undefined" ? window.location.origin : "");
|
|
102
|
+
currentRoute = $state(typeof window !== "undefined" ? locationPath() : "/");
|
|
103
|
+
/** Base `page.url` resolves the route against. Empty on the server until the renderer
|
|
104
|
+
* seeds it per request — see `renderWithPageContext` in core/renderer.ts. The href on the
|
|
105
|
+
* client: `location.origin` can be "null" (app shell, sandboxed iframe), not a valid base. */
|
|
106
|
+
origin = $state(typeof window !== "undefined" ? window.location.href : "");
|
|
86
107
|
/** True when navigation was triggered by a link click / navigate() call, false on popstate (back/forward). */
|
|
87
108
|
isPush = $state(true);
|
|
88
109
|
/** Source of the most recent navigation — feeds the Navigation object passed to lifecycle hooks. */
|
|
@@ -103,6 +124,8 @@ export const router = new (class Router {
|
|
|
103
124
|
loading = false;
|
|
104
125
|
|
|
105
126
|
navigate(path: string, opts: { replace?: boolean; source?: NavType } = {}): NavResult {
|
|
127
|
+
// goto, use:enhance and loader redirects may hand over `?page=2` or a full URL.
|
|
128
|
+
path = toRouterPath(path, window.location.href);
|
|
106
129
|
if (this.currentRoute === path) return "same";
|
|
107
130
|
// Unknown route — let the server handle it (renders +error.svelte with 404)
|
|
108
131
|
const queryHash = path.slice(path.split("?")[0].split("#")[0].length);
|
|
@@ -201,19 +224,15 @@ export const router = new (class Router {
|
|
|
201
224
|
|
|
202
225
|
const anchor = (e.target as HTMLElement).closest("a");
|
|
203
226
|
if (!anchor) return;
|
|
204
|
-
|
|
205
|
-
if (
|
|
206
|
-
if (anchor.hasAttribute("download")) return;
|
|
207
|
-
if (anchor.rel.split(/\s+/).includes("external")) return;
|
|
208
|
-
if (anchor.protocol !== "https:" && anchor.protocol !== "http:") return;
|
|
227
|
+
const path = appLinkPath(anchor);
|
|
228
|
+
if (path === null) return;
|
|
209
229
|
|
|
210
230
|
// Same-page hash navigation: skip page reload, just update URL and scroll
|
|
211
231
|
// to the target element. Mirrors browser default for in-page anchors.
|
|
212
|
-
const samePage =
|
|
213
|
-
anchor.pathname === window.location.pathname && anchor.search === window.location.search;
|
|
232
|
+
const samePage = path.split("#")[0] === locationPath().split("#")[0];
|
|
214
233
|
if (samePage && anchor.hash) {
|
|
215
234
|
e.preventDefault();
|
|
216
|
-
const finalPath =
|
|
235
|
+
const finalPath = path;
|
|
217
236
|
if (this.currentRoute !== finalPath) {
|
|
218
237
|
saveScroll();
|
|
219
238
|
savePageSnapshot();
|
|
@@ -227,12 +246,12 @@ export const router = new (class Router {
|
|
|
227
246
|
}
|
|
228
247
|
|
|
229
248
|
e.preventDefault();
|
|
230
|
-
this.navigate(
|
|
249
|
+
this.navigate(path, { source: "link" });
|
|
231
250
|
});
|
|
232
251
|
|
|
233
252
|
// Browser back/forward
|
|
234
253
|
window.addEventListener("popstate", (e) => {
|
|
235
|
-
const finalPath =
|
|
254
|
+
const finalPath = locationPath();
|
|
236
255
|
// Save the position of the entry we're leaving (historyIndex is still
|
|
237
256
|
// the old entry's), then look up where the landed-on entry was.
|
|
238
257
|
saveScroll();
|
package/src/core/csrf.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// is treated as a cross-origin attack.
|
|
7
7
|
|
|
8
8
|
export interface CsrfConfig {
|
|
9
|
-
/** Whether to enforce origin
|
|
9
|
+
/** Whether to enforce the form-post origin check. WebSocket upgrades are always checked. Default: true. */
|
|
10
10
|
checkOrigin: boolean;
|
|
11
11
|
/** Additional origins to allow (e.g. CDN or mobile app origin). */
|
|
12
12
|
allowedOrigins?: string[];
|
|
@@ -39,7 +39,8 @@ function isPathExempt(pathname: string, patterns: string[]): boolean {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
/**
|
|
42
|
-
* Check whether a request passes CSRF validation.
|
|
42
|
+
* Check whether a request passes CSRF validation against `url.origin` — the
|
|
43
|
+
* public origin, which server.ts has already rebuilt from X-Forwarded-*.
|
|
43
44
|
* Returns `null` on success, or an error message string to reject with 403.
|
|
44
45
|
*/
|
|
45
46
|
export function checkCsrf(
|
|
@@ -51,23 +52,7 @@ export function checkCsrf(
|
|
|
51
52
|
if (SAFE_METHODS.has(request.method.toUpperCase())) return null;
|
|
52
53
|
if (config.exemptPaths && isPathExempt(url.pathname, config.exemptPaths)) return null;
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
// `X-Forwarded-*` headers are only trusted when `TRUST_PROXY=true`, since a
|
|
56
|
-
// directly-exposed server would otherwise let a client spoof its own origin
|
|
57
|
-
// via attacker-controlled forwarded headers. Behind a real reverse proxy
|
|
58
|
-
// (nginx, Caddy, Cloudflare) the operator opts in by setting the env.
|
|
59
|
-
const trustProxy = process.env.TRUST_PROXY === "true";
|
|
60
|
-
const forwardedHost = trustProxy ? request.headers.get("x-forwarded-host") : null;
|
|
61
|
-
const host = forwardedHost ?? request.headers.get("host");
|
|
62
|
-
const forwardedProto = trustProxy ? request.headers.get("x-forwarded-proto") : null;
|
|
63
|
-
const protocol = forwardedProto ?? url.protocol.replace(":", "");
|
|
64
|
-
const expectedOrigin = host ? `${protocol}://${host}` : url.origin;
|
|
65
|
-
|
|
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);
|
|
55
|
+
const isAllowed = (origin: string) => originAllowed(origin, url.origin, config);
|
|
71
56
|
|
|
72
57
|
// Check Origin header first (sent by all modern browsers on cross-origin requests)
|
|
73
58
|
const originHeader = request.headers.get("origin");
|
|
@@ -91,3 +76,31 @@ export function checkCsrf(
|
|
|
91
76
|
// Neither Origin nor Referer present — reject
|
|
92
77
|
return "Forbidden: missing Origin or Referer header on non-safe request";
|
|
93
78
|
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Origin check for a WebSocket upgrade — a GET, so checkCsrf skips it, yet a
|
|
82
|
+
* cross-site page could open a socket that carries the user's cookies.
|
|
83
|
+
* Browsers always send `Origin` on a WebSocket handshake; a request without one
|
|
84
|
+
* comes from a non-browser client (no ambient cookies to hijack) and passes.
|
|
85
|
+
* Returns `null` on success, or an error message string to reject with 403.
|
|
86
|
+
*/
|
|
87
|
+
export function checkUpgradeOrigin(
|
|
88
|
+
request: Request,
|
|
89
|
+
url: URL,
|
|
90
|
+
config: CsrfConfig = DEFAULT_CSRF_CONFIG,
|
|
91
|
+
): string | null {
|
|
92
|
+
// Not gated on checkOrigin: turning off the form check must not also open
|
|
93
|
+
// sockets to cross-site hijacking. CSRF_EXEMPT_PATHS is the way out.
|
|
94
|
+
if (config.exemptPaths && isPathExempt(url.pathname, config.exemptPaths)) return null;
|
|
95
|
+
const originHeader = request.headers.get("origin");
|
|
96
|
+
if (!originHeader || originAllowed(originHeader, url.origin, config)) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
return `Cross-origin WebSocket blocked: Origin "${originHeader}" is not allowed`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// The expected origin is per-request (url.origin), so no Set to precompute —
|
|
103
|
+
// a direct compare plus the tiny static allow-list.
|
|
104
|
+
function originAllowed(origin: string, expected: string, config: CsrfConfig): boolean {
|
|
105
|
+
return origin === expected || (config.allowedOrigins?.includes(origin) ?? false);
|
|
106
|
+
}
|
package/src/core/dev.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { loadEnv } from "./env.ts";
|
|
|
5
5
|
import { BOSIA_NODE_PATH } from "./paths.ts";
|
|
6
6
|
import { pidsOnPort } from "./port.ts";
|
|
7
7
|
import { affectsRouteManifest, shouldIgnoreForRebuild } from "./devWatch.ts";
|
|
8
|
+
import { closeProxiedSockets, proxyHandlers, proxyUpgrade } from "./devWsProxy.ts";
|
|
9
|
+
import { isUpgradeRequest } from "./websocket.ts";
|
|
8
10
|
|
|
9
11
|
// Dev always writes to .bosia/dev so a parallel `bun run build` (writing to ./dist)
|
|
10
12
|
// can't clobber the live preview. Hardcoded — BOSIA_OUT_DIR is a build-mode knob,
|
|
@@ -183,6 +185,9 @@ async function reapStaleAppServer(port: number) {
|
|
|
183
185
|
|
|
184
186
|
async function startAppServer() {
|
|
185
187
|
if (appProcess) {
|
|
188
|
+
// The app dies without closing its sockets (upstream would read 1006):
|
|
189
|
+
// tell proxied clients it's a restart so they reconnect to the new build.
|
|
190
|
+
closeProxiedSockets(1012, "Server restarting");
|
|
186
191
|
intentionalKill = true;
|
|
187
192
|
appProcess.kill("SIGTERM");
|
|
188
193
|
// Escalate to SIGKILL if the child won't stop — otherwise `await exited`
|
|
@@ -333,7 +338,8 @@ function scheduleBuild() {
|
|
|
333
338
|
const devServer = Bun.serve({
|
|
334
339
|
port: DEV_PORT,
|
|
335
340
|
idleTimeout: 255,
|
|
336
|
-
|
|
341
|
+
websocket: proxyHandlers,
|
|
342
|
+
async fetch(req, server) {
|
|
337
343
|
const url = new URL(req.url);
|
|
338
344
|
|
|
339
345
|
// SSE endpoint — owned by dev server, not the app
|
|
@@ -456,6 +462,10 @@ const devServer = Bun.serve({
|
|
|
456
462
|
// is fine — it's localhost.
|
|
457
463
|
forwardedHeaders.set("accept-encoding", "identity");
|
|
458
464
|
|
|
465
|
+
// WebSockets can't go through fetch(): connect to the app first, then
|
|
466
|
+
// upgrade the client (see devWsProxy.ts).
|
|
467
|
+
if (isUpgradeRequest(req)) return proxyUpgrade(req, server, target, forwardedHeaders);
|
|
468
|
+
|
|
459
469
|
// HMR-driven reloads can land on the proxy before the freshly-respawned
|
|
460
470
|
// inner has bound APP_PORT. Retry for a few seconds on idempotent HTML
|
|
461
471
|
// navigations so the browser doesn't get stuck rendering the 503 body.
|