bosia 1.2.6 → 1.2.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosia",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.4"
@@ -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
- fetch = async (request: Request): Promise<Response> => {
154
- if (this.responseHooks.length === 0) return this.respond(request);
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;
@@ -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
package/src/core/cache.ts CHANGED
@@ -275,7 +275,9 @@ export function computeIdentityHash(req: Request, cookies: Pick<CookieJar, "peek
275
275
  }
276
276
 
277
277
  export function computeCacheKey(url: URL, req: Request, cookies: Pick<CookieJar, "peek">): string {
278
- return `${dedupKey(url)}|i=${computeIdentityHash(req, cookies)}`;
278
+ // Origin is in the key: `url` follows the Host / X-Forwarded-* headers, and a
279
+ // page that renders `url.origin` must not be served to another host.
280
+ return `${dedupKey(url)}|o=${url.origin}|i=${computeIdentityHash(req, cookies)}`;
279
281
  }
280
282
 
281
283
  // ─── Tracking parameters (CACHE_IGNORE_PARAMS) ───────────
@@ -316,11 +318,19 @@ export function stripIgnoredParams(url: URL): URL {
316
318
  }
317
319
 
318
320
  /** Loaders that read the whole query string (`url.search`, `url.href`, iterating
319
- * `url.searchParams`): their output may depend on any param. */
320
- const wholeQueryDeps = new WeakSet<LoaderDeps>();
321
-
321
+ * `url.searchParams`): their output may depend on any param. Recorded as a `"*"`
322
+ * search param (like the `"*"` cookie) so the client router sees it too. A real
323
+ * `?*=` param read by name counts as a whole-query read, which only re-runs more. */
322
324
  export function markWholeQueryRead(deps: LoaderDeps): void {
323
- wholeQueryDeps.add(deps);
325
+ if (!deps.searchParams.includes("*")) deps.searchParams.push("*");
326
+ }
327
+
328
+ /** Server-only: the entry is query-aware (a left-out layout above read an ignored
329
+ * param), without telling the client router to re-run on every query change. */
330
+ const queryAwareDeps = new WeakSet<LoaderDeps>();
331
+
332
+ export function markQueryAware(deps: LoaderDeps): void {
333
+ queryAwareDeps.add(deps);
324
334
  }
325
335
 
326
336
  /** Whether any loader read an ignored param — its entry must not be served to a
@@ -330,16 +340,18 @@ export function depsReadIgnoredParams(
330
340
  pageDeps: LoaderDeps | null,
331
341
  ): boolean {
332
342
  const check = (d: LoaderDeps | null) =>
333
- !!d && (wholeQueryDeps.has(d) || d.searchParams.some(isIgnoredParam));
343
+ !!d && (queryAwareDeps.has(d) || d.searchParams.some((k) => k === "*" || isIgnoredParam(k)));
334
344
  if (check(pageDeps)) return true;
335
345
  return !!layoutDeps && layoutDeps.some(check);
336
346
  }
337
347
 
338
348
  /** Extract pathname portion of a cacheKey for path-based invalidation. */
339
349
  function pathOfKey(key: string): string {
350
+ // A path keeps a raw `|` (`/files/a|b`) but never a raw `?`; the query,
351
+ // origin and 0/1 mask never hold `?` or `|o=`. Storing the path on the
352
+ // entry would drop this parsing if keys grow free-text parts.
340
353
  const qIdx = key.indexOf("?");
341
- const pIdx = key.indexOf("|");
342
- const end = qIdx === -1 ? pIdx : Math.min(qIdx, pIdx);
354
+ const end = qIdx !== -1 ? qIdx : key.lastIndexOf("|o=");
343
355
  return end === -1 ? key : key.slice(0, end);
344
356
  }
345
357
 
@@ -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.origin);
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.origin);
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
- const path = window.location.pathname;
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
- if (match) {
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 = window.location.pathname + window.location.search + window.location.hash;
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.origin);
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
- const sep = window.location.search ? "&" : "?";
173
- window.location.replace(
174
- `${window.location.pathname}${window.location.search}${sep}_v=${Date.now()}${window.location.hash}`,
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 {
@@ -15,6 +15,8 @@ export type CacheSnapshot = {
15
15
  pathname: string;
16
16
  params: Record<string, string>;
17
17
  searchParams: Record<string, string | null>;
18
+ /** The full query, for loaders that read all of it (`"*"` in deps.searchParams). */
19
+ search: string;
18
20
  cookies: Record<string, string | undefined>;
19
21
  };
20
22
 
@@ -64,15 +66,21 @@ export function liveContext(
64
66
  };
65
67
  }
66
68
 
69
+ /** Every value of a query param, so `getAll`/`has(k, v)` see a change past the first. */
70
+ function paramValues(url: URL, k: string): string | null {
71
+ const all = url.searchParams.getAll(k);
72
+ return all.length ? JSON.stringify(all) : null;
73
+ }
74
+
67
75
  /** Capture only the values relevant to a loader's tracked deps. */
68
76
  export function captureSnapshot(deps: LoaderDeps, ctx: EvalContext): CacheSnapshot {
69
77
  const params: Record<string, string> = {};
70
78
  for (const k of deps.params) params[k] = ctx.params[k] ?? "";
71
79
  const searchParams: Record<string, string | null> = {};
72
- for (const k of deps.searchParams) searchParams[k] = ctx.url.searchParams.get(k);
80
+ for (const k of deps.searchParams) if (k !== "*") searchParams[k] = paramValues(ctx.url, k);
73
81
  const cookies: Record<string, string | undefined> = {};
74
82
  for (const k of deps.cookies) cookies[k] = ctx.cookies[k];
75
- return { pathname: ctx.pathname, params, searchParams, cookies };
83
+ return { pathname: ctx.pathname, params, searchParams, search: ctx.url.search, cookies };
76
84
  }
77
85
 
78
86
  export type DirtyState = {
@@ -108,7 +116,10 @@ export function shouldRerun(entry: CacheEntry, dirty: DirtyState, next: EvalCont
108
116
  }
109
117
  // Search param value changes
110
118
  for (const k of deps.searchParams) {
111
- if (snapshot.searchParams[k] !== next.url.searchParams.get(k)) return true;
119
+ if (k === "*") {
120
+ // Whole query read: any change re-runs (a reorder too, which is harmless).
121
+ if (snapshot.search !== next.url.search) return true;
122
+ } else if (snapshot.searchParams[k] !== paramValues(next.url, k)) return true;
112
123
  }
113
124
  // Cookie value changes
114
125
  for (const k of deps.cookies) {
@@ -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.origin).href;
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.origin);
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.origin);
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
- if (res.redirected) {
83
- const target = new URL(res.url, window.location.origin);
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 === window.location.pathname + window.location.search) return;
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.origin).pathname);
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
- if (anchor.origin !== window.location.origin) return null;
213
- if (anchor.target) return null;
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
- const pathname = path.split("?")[0].split("#")[0];
24
- const match = findMatch(clientRoutes, pathname);
25
- const url = new URL(path, window.location.origin);
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
- typeof window !== "undefined"
80
- ? window.location.pathname + window.location.search + window.location.hash
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
- if (anchor.origin !== window.location.origin) return;
205
- if (anchor.target) return;
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 = anchor.pathname + anchor.search + anchor.hash;
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(anchor.pathname + anchor.search + anchor.hash, { source: "link" });
249
+ this.navigate(path, { source: "link" });
231
250
  });
232
251
 
233
252
  // Browser back/forward
234
253
  window.addEventListener("popstate", (e) => {
235
- const finalPath = window.location.pathname + window.location.search + window.location.hash;
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();
@@ -6,16 +6,17 @@ import { currentBase } from "./appBase.ts";
6
6
  const UNSAFE_COOKIE_VALUE = /[;\r\n]/;
7
7
  /**
8
8
  * Accept both casings (matches SvelteKit/Express convention) and write the
9
- * canonical capitalized form into the Set-Cookie header.
9
+ * canonical capitalized form into the Set-Cookie header. A Map, so names like
10
+ * "constructor" are rejected rather than inherited.
10
11
  */
11
- const SAMESITE_NORMALIZE: Record<string, "Strict" | "Lax" | "None"> = {
12
- strict: "Strict",
13
- lax: "Lax",
14
- none: "None",
15
- Strict: "Strict",
16
- Lax: "Lax",
17
- None: "None",
18
- };
12
+ const SAMESITE_NORMALIZE = new Map<string, "Strict" | "Lax" | "None">([
13
+ ["strict", "Strict"],
14
+ ["lax", "Lax"],
15
+ ["none", "None"],
16
+ ["Strict", "Strict"],
17
+ ["Lax", "Lax"],
18
+ ["None", "None"],
19
+ ]);
19
20
 
20
21
  /**
21
22
  * RFC 6265 §4.1.1: cookie-name is an HTTP token (RFC 2616 §2.2).
@@ -37,13 +38,15 @@ const COOKIE_DEFAULTS: CookieOptions = {
37
38
  // ─── Cookie Helpers ──────────────────────────────────────
38
39
 
39
40
  function parseCookies(header: string): Record<string, string> {
40
- const result: Record<string, string> = {};
41
+ // No prototype, so `get("constructor")` is undefined. `__proto__` is dropped
42
+ // (as a plain `{}` did): kept, getAll() would hand serializers that key.
43
+ const result: Record<string, string> = Object.create(null);
41
44
  for (const pair of header.split(";")) {
42
45
  const idx = pair.indexOf("=");
43
46
  if (idx === -1) continue;
44
47
  const name = pair.slice(0, idx).trim();
45
48
  const value = pair.slice(idx + 1).trim();
46
- if (name) {
49
+ if (name && name !== "__proto__") {
47
50
  try {
48
51
  result[name] = decodeURIComponent(value);
49
52
  } catch {
@@ -144,7 +147,7 @@ export class CookieJar implements Cookies {
144
147
  if (opts.httpOnly) header += "; HttpOnly";
145
148
  if (opts.secure) header += "; Secure";
146
149
  if (opts.sameSite) {
147
- const canonical = SAMESITE_NORMALIZE[opts.sameSite as string];
150
+ const canonical = SAMESITE_NORMALIZE.get(opts.sameSite as string);
148
151
  if (!canonical) throw new Error(`Invalid cookie sameSite: ${opts.sameSite}`);
149
152
  header += `; SameSite=${canonical}`;
150
153
  }
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 checks. Default: true. */
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
- // Derive the expected origin.
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
+ }