bosia 1.2.5 → 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/README.md +5 -6
- package/package.json +2 -1
- package/src/core/backend.ts +87 -3
- package/src/core/basePath.ts +30 -0
- package/src/core/cache.ts +378 -30
- package/src/core/client/App.svelte +19 -11
- package/src/core/client/hydrate.ts +17 -9
- package/src/core/client/navigation.ts +1 -1
- package/src/core/client/prefetch.ts +66 -41
- package/src/core/client/router.svelte.ts +41 -22
- package/src/core/cors.ts +17 -1
- package/src/core/csrf.ts +32 -19
- package/src/core/dedup.ts +51 -7
- package/src/core/dev.ts +11 -1
- package/src/core/devWsProxy.ts +144 -0
- package/src/core/env.ts +7 -0
- package/src/core/errors.ts +23 -6
- package/src/core/hooks.ts +1 -1
- package/src/core/html.ts +13 -1
- package/src/core/plugins/access-log.ts +128 -0
- package/src/core/prerender.ts +143 -6
- package/src/core/proxy.ts +48 -0
- package/src/core/renderer.ts +70 -29
- package/src/core/routeTypes.ts +16 -1
- package/src/core/server.bun.ts +6 -1
- package/src/core/server.ts +198 -77
- package/src/core/server.workers.ts +3 -2
- package/src/core/types/plugin.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/templates/default/.env.example +20 -5
- package/templates/default/tsconfig.json +1 -1
- package/templates/demo/.env.example +20 -5
- package/templates/demo/src/hooks.server.ts +3 -12
- package/templates/demo/tsconfig.json +1 -1
- package/templates/shop/src/app.d.ts +0 -1
- package/templates/shop/src/hooks.server.ts +3 -11
- package/templates/shop/src/routes/+layout.server.ts +0 -1
- package/templates/shop/tsconfig.json +1 -1
- package/templates/store/src/app.d.ts +0 -1
- package/templates/store/src/hooks.server.ts +3 -11
- package/templates/store/src/routes/+layout.server.ts +0 -1
- package/templates/store/tsconfig.json +1 -1
|
@@ -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();
|
|
@@ -109,29 +103,54 @@ function errorMessage(res: Response): string {
|
|
|
109
103
|
return res.statusText || "Internal Server Error";
|
|
110
104
|
}
|
|
111
105
|
|
|
112
|
-
|
|
106
|
+
// `mask`: the `_invalidated` bits the prefetch sent (null = asked for every layer).
|
|
107
|
+
const prefetchCache = new Map<string, { data: any; ts: number; mask: string | null }>();
|
|
113
108
|
const MAX_PREFETCH_ENTRIES = 50;
|
|
114
109
|
|
|
115
|
-
// In-flight
|
|
116
|
-
|
|
110
|
+
// In-flight prefetches. A click that lands while its prefetch is still loading
|
|
111
|
+
// (a tap: touchstart prefetches ~100ms before the click) waits for that fetch
|
|
112
|
+
// instead of sending the same `/__bosia/data` request a second time. It gets
|
|
113
|
+
// whatever the response said (data, a redirect, an error), read like the
|
|
114
|
+
// router's own fetch; `null` only when the request itself failed.
|
|
115
|
+
// `clearPrefetches()` empties it on every navigation, so a prefetch that started
|
|
116
|
+
// before it neither fills the cache nor serves a later click.
|
|
117
|
+
const pending = new Map<string, { run: Promise<any | null>; mask: string | null }>();
|
|
117
118
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Prefetched data for a path, or the in-flight prefetch's promise (resolves to
|
|
121
|
+
* the data, or `null` when the prefetch can't be used — fetch it yourself).
|
|
122
|
+
* The entry is consumed: a second call gets `null`. `mask` is the navigation's
|
|
123
|
+
* own `_invalidated` bits: a prefetch built against the loader cache as it was
|
|
124
|
+
* at hover time may have left out a layer this navigation needs.
|
|
125
|
+
*/
|
|
126
|
+
export function consumePrefetch(path: string, mask: string): any | Promise<any | null> | null {
|
|
127
|
+
const inflight = pending.get(path);
|
|
128
|
+
if (inflight) {
|
|
129
|
+
pending.delete(path);
|
|
130
|
+
return inflight.mask === null || inflight.mask === mask ? inflight.run : null;
|
|
131
|
+
}
|
|
120
132
|
const entry = prefetchCache.get(path);
|
|
121
133
|
if (entry === undefined) return null;
|
|
122
134
|
prefetchCache.delete(path);
|
|
123
135
|
if (Date.now() - entry.ts > 30_000) return null;
|
|
136
|
+
if (entry.mask !== null && entry.mask !== mask) return null;
|
|
124
137
|
return entry.data;
|
|
125
138
|
}
|
|
126
139
|
|
|
140
|
+
/** Drops every prefetch, finished or in flight — call on navigation. */
|
|
141
|
+
export function clearPrefetches(): void {
|
|
142
|
+
prefetchCache.clear();
|
|
143
|
+
pending.clear();
|
|
144
|
+
}
|
|
145
|
+
|
|
127
146
|
/** Prefetches data for a path and stores in cache. No-op if already cached/in-flight. */
|
|
128
147
|
export async function prefetchPath(path: string): Promise<void> {
|
|
129
148
|
// Nothing to fetch for the page we're on. A click also focuses its link, so
|
|
130
149
|
// the focusin trigger fires ~100ms after the navigation it belongs to.
|
|
131
|
-
if (path ===
|
|
150
|
+
if (path === locationPath().split("#")[0]) return;
|
|
132
151
|
// Warm the route's +loading.svelte chunk alongside its data, so the skeleton
|
|
133
152
|
// paints instantly on click instead of cold-importing after the old page lingers.
|
|
134
|
-
const warmMatch = findMatch(clientRoutes, new URL(path, window.location.
|
|
153
|
+
const warmMatch = findMatch(clientRoutes, new URL(path, window.location.href).pathname);
|
|
135
154
|
// Also warm the page and layout chunks: a route not visited yet otherwise
|
|
136
155
|
// downloads its code only after the click. Errors are swallowed — a stale
|
|
137
156
|
// chunk is the click's to report, not the hover's (hydrate.ts reloads on it).
|
|
@@ -152,34 +171,40 @@ export async function prefetchPath(path: string): Promise<void> {
|
|
|
152
171
|
// A route without server data never fetches on click either.
|
|
153
172
|
if (!warmMatch?.route.hasServerData) return;
|
|
154
173
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
174
|
+
// Only a plain data response is kept for a later click; `ok` alone would
|
|
175
|
+
// cache a guard's login page (200 after the redirect was followed).
|
|
176
|
+
let storable = false;
|
|
177
|
+
// Send the same mask as a real client nav would so the server leaves out
|
|
178
|
+
// layers the client already holds. Falls back to sending everything when
|
|
179
|
+
// the route can't be matched (e.g. external/unknown URL).
|
|
180
|
+
const mask = buildMaskBits(path);
|
|
181
|
+
const run = (async (): Promise<any | null> => {
|
|
182
|
+
try {
|
|
183
|
+
const res = await fetch(dataUrl(path, mask ?? undefined));
|
|
184
|
+
storable = res.ok && !res.redirected && isJsonResponse(res);
|
|
185
|
+
return await readDataResponse(res);
|
|
186
|
+
} catch {
|
|
187
|
+
// Silently ignore — prefetch is best-effort
|
|
188
|
+
return null;
|
|
170
189
|
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
190
|
+
})();
|
|
191
|
+
pending.set(path, { run, mask });
|
|
192
|
+
const data = await run;
|
|
193
|
+
// Consumed by a click, or dropped by a navigation, while in flight.
|
|
194
|
+
if (pending.get(path)?.run !== run) return;
|
|
195
|
+
pending.delete(path);
|
|
196
|
+
// A guard's redirect is the session's answer now, not in 30s.
|
|
197
|
+
if (data === null || !storable || data.error || data.redirect) return;
|
|
198
|
+
if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
|
|
199
|
+
const oldest = prefetchCache.keys().next().value;
|
|
200
|
+
if (oldest !== undefined) prefetchCache.delete(oldest);
|
|
175
201
|
}
|
|
202
|
+
prefetchCache.set(path, { data, ts: Date.now(), mask });
|
|
176
203
|
}
|
|
177
204
|
|
|
178
205
|
function getLinkHref(anchor: HTMLAnchorElement): string | null {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (anchor.hasAttribute("download")) return null;
|
|
182
|
-
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;
|
|
183
208
|
}
|
|
184
209
|
|
|
185
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/cors.ts
CHANGED
|
@@ -29,7 +29,23 @@ function parseHeaderList(value: string): string[] {
|
|
|
29
29
|
* response with `Access-Control-Allow-Origin: X` to a different origin Y.
|
|
30
30
|
*/
|
|
31
31
|
export function applyCorsVary(headers: Headers): void {
|
|
32
|
-
headers
|
|
32
|
+
mergeVary(headers, "Origin");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Add `value` to the Vary header, keeping what is there: `set("Vary", "Origin")`
|
|
37
|
+
* dropped `Accept-Encoding`, so a CDN could hand a brotli body to a client that
|
|
38
|
+
* never asked for one.
|
|
39
|
+
*/
|
|
40
|
+
export function mergeVary(headers: Headers, value: string): void {
|
|
41
|
+
const current = headers.get("Vary");
|
|
42
|
+
if (!current) {
|
|
43
|
+
headers.set("Vary", value);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const parts = current.split(",").map((p) => p.trim().toLowerCase());
|
|
47
|
+
if (parts.includes("*") || parts.includes(value.toLowerCase())) return;
|
|
48
|
+
headers.set("Vary", `${current}, ${value}`);
|
|
33
49
|
}
|
|
34
50
|
|
|
35
51
|
/**
|
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/dedup.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
// different users never share a loader result. Custom session cookie names
|
|
9
9
|
// must be listed in CACHE_KEYS. See docs/guides/request-deduplication.md.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
type Inflight = { promise: Promise<unknown>; start: number };
|
|
12
|
+
const inflight = new Map<string, Inflight>();
|
|
12
13
|
|
|
13
14
|
/** Build a stable dedup key from a URL: normalized path + sorted query. */
|
|
14
15
|
export function dedupKey(url: URL): string {
|
|
@@ -19,11 +20,54 @@ export function dedupKey(url: URL): string {
|
|
|
19
20
|
return search ? `${path}?${search}` : path;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Coalesce concurrent calls under `key`. `fn` runs at most once per inflight
|
|
25
|
+
* window. With `maxAgeMs` > 0 a call is joined only until it is that old:
|
|
26
|
+
* one that never settles (a hung loader with LOAD_TIMEOUT=0) would otherwise
|
|
27
|
+
* take every later request for the key down with it. A call that started
|
|
28
|
+
* before `replaceStartedBefore` is replaced rather than joined (the caller
|
|
29
|
+
* knows it is stuck); later calls join the new one.
|
|
30
|
+
*/
|
|
31
|
+
export function dedup<T>(
|
|
32
|
+
key: string,
|
|
33
|
+
fn: () => Promise<T>,
|
|
34
|
+
maxAgeMs = 0,
|
|
35
|
+
replaceStartedBefore = -Infinity,
|
|
36
|
+
): Promise<T> {
|
|
24
37
|
const existing = inflight.get(key);
|
|
25
|
-
if (existing
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
38
|
+
if (existing && existing.start >= replaceStartedBefore) {
|
|
39
|
+
if (maxAgeMs <= 0) return existing.promise as Promise<T>;
|
|
40
|
+
const left = maxAgeMs - (performance.now() - existing.start);
|
|
41
|
+
if (left > 0) {
|
|
42
|
+
// Joined until the call reaches maxAgeMs; then the first joiner
|
|
43
|
+
// replaces it and the rest join the new call. One timer per joiner:
|
|
44
|
+
// on Workers a timer dies with the request that set it.
|
|
45
|
+
const joined = existing.promise as Promise<T>;
|
|
46
|
+
return new Promise<T>((resolve, reject) => {
|
|
47
|
+
const timer = setTimeout(() => {
|
|
48
|
+
const stuck = inflight.get(key) === existing;
|
|
49
|
+
dedup(key, fn, maxAgeMs, stuck ? Infinity : -Infinity).then(resolve, reject);
|
|
50
|
+
}, left);
|
|
51
|
+
(timer as { unref?: () => void }).unref?.();
|
|
52
|
+
joined.then(
|
|
53
|
+
(v) => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
resolve(v);
|
|
56
|
+
},
|
|
57
|
+
(e) => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
reject(e);
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const entry: Inflight = { promise: null as unknown as Promise<T>, start: performance.now() };
|
|
66
|
+
const promise = fn().finally(() => {
|
|
67
|
+
// Never drops a newer call that replaced a stale one.
|
|
68
|
+
if (inflight.get(key) === entry) inflight.delete(key);
|
|
69
|
+
});
|
|
70
|
+
entry.promise = promise;
|
|
71
|
+
inflight.set(key, entry);
|
|
72
|
+
return promise;
|
|
29
73
|
}
|
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.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// ─── Dev Proxy: WebSockets ────────────────────────────────
|
|
2
|
+
// The dev proxy forwards HTTP with fetch(), which can't carry an upgrade. This
|
|
3
|
+
// opens the app's socket first and upgrades the client only once the app has
|
|
4
|
+
// accepted, so a refusal reaches the client as a failed handshake — what a
|
|
5
|
+
// browser sees in prod too (it never learns the status). Then frames are piped
|
|
6
|
+
// both ways, type kept.
|
|
7
|
+
//
|
|
8
|
+
// Simple version: no backpressure across the proxy hop, frames sent during a
|
|
9
|
+
// restart are dropped, and cookies the app sets on its 101 don't reach the
|
|
10
|
+
// client (Bun's client WebSocket doesn't expose handshake headers). If that
|
|
11
|
+
// ever matters, buffer with a cap (close with 1013 past it) and do the
|
|
12
|
+
// upstream handshake over a raw socket to read its headers.
|
|
13
|
+
|
|
14
|
+
import type { Server, ServerWebSocket, WebSocketHandler } from "bun";
|
|
15
|
+
|
|
16
|
+
import { closeEach } from "./websocket.ts";
|
|
17
|
+
|
|
18
|
+
interface ProxyData {
|
|
19
|
+
upstream: WebSocket;
|
|
20
|
+
client: ServerWebSocket<ProxyData> | null;
|
|
21
|
+
/** Frames the app sent before the client socket opened. */
|
|
22
|
+
pending: (string | ArrayBuffer)[];
|
|
23
|
+
/** The app closed before the client socket opened. */
|
|
24
|
+
early: { code: number; reason: string } | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Handshake headers belong to each hop; everything else (cookie,
|
|
28
|
+
// authorization, origin, x-forwarded-*) goes through to the app.
|
|
29
|
+
const HOP_HEADERS = new Set([
|
|
30
|
+
"host",
|
|
31
|
+
"connection",
|
|
32
|
+
"upgrade",
|
|
33
|
+
"content-length",
|
|
34
|
+
"sec-websocket-key",
|
|
35
|
+
"sec-websocket-version",
|
|
36
|
+
"sec-websocket-extensions",
|
|
37
|
+
"sec-websocket-protocol",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const UPSTREAM_TIMEOUT_MS = 10_000;
|
|
41
|
+
|
|
42
|
+
const proxied = new Set<ServerWebSocket<ProxyData>>();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A close code `close()` accepts. 1005/1006/1015 only describe a close:
|
|
46
|
+
* 1005 (no code) becomes 1000, 1006/1015 (connection lost) become `lost`.
|
|
47
|
+
* Reserved or out-of-range codes become 1000; Bun's client close() throws on them.
|
|
48
|
+
*/
|
|
49
|
+
export function toSendableCode(code: number, lost: number): number {
|
|
50
|
+
if (code === 1006 || code === 1015) return lost;
|
|
51
|
+
const valid =
|
|
52
|
+
(code >= 1000 && code <= 1003) ||
|
|
53
|
+
(code >= 1007 && code <= 1014) ||
|
|
54
|
+
(code >= 3000 && code <= 4999);
|
|
55
|
+
return valid ? code : 1000;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Upgrade `req` through to `target` (an http:// URL on the app server).
|
|
60
|
+
* Returns undefined once upgraded, or the Response to send instead.
|
|
61
|
+
*/
|
|
62
|
+
export async function proxyUpgrade(
|
|
63
|
+
req: Request,
|
|
64
|
+
server: Server<ProxyData>,
|
|
65
|
+
target: URL,
|
|
66
|
+
inHeaders: Headers,
|
|
67
|
+
): Promise<Response | undefined> {
|
|
68
|
+
const headers: Record<string, string> = {};
|
|
69
|
+
for (const [k, v] of inHeaders) if (!HOP_HEADERS.has(k.toLowerCase())) headers[k] = v;
|
|
70
|
+
const protocols =
|
|
71
|
+
req.headers
|
|
72
|
+
.get("sec-websocket-protocol")
|
|
73
|
+
?.split(",")
|
|
74
|
+
.map((p) => p.trim())
|
|
75
|
+
.filter(Boolean) ?? [];
|
|
76
|
+
|
|
77
|
+
const url = new URL(target);
|
|
78
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
79
|
+
const upstream = new WebSocket(url.href, { headers, protocols } as any);
|
|
80
|
+
upstream.binaryType = "arraybuffer";
|
|
81
|
+
const data: ProxyData = { upstream, client: null, pending: [], early: null };
|
|
82
|
+
|
|
83
|
+
upstream.addEventListener("message", (e) => {
|
|
84
|
+
if (data.client) data.client.send(e.data);
|
|
85
|
+
else data.pending.push(e.data);
|
|
86
|
+
});
|
|
87
|
+
upstream.addEventListener("close", (e) => {
|
|
88
|
+
if (!data.client) data.early = { code: e.code, reason: e.reason };
|
|
89
|
+
else if (proxied.has(data.client)) data.client.close(toSendableCode(e.code, 1011), e.reason);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const opened = await new Promise<boolean>((resolve) => {
|
|
93
|
+
const timer = setTimeout(() => resolve(false), UPSTREAM_TIMEOUT_MS);
|
|
94
|
+
const done = (ok: boolean) => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
resolve(ok);
|
|
97
|
+
};
|
|
98
|
+
upstream.addEventListener("open", () => done(true), { once: true });
|
|
99
|
+
upstream.addEventListener("error", () => done(false), { once: true });
|
|
100
|
+
upstream.addEventListener("close", () => done(false), { once: true });
|
|
101
|
+
});
|
|
102
|
+
if (!opened) {
|
|
103
|
+
upstream.close();
|
|
104
|
+
return new Response("WebSocket upgrade refused by app", { status: 502 });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ok = server.upgrade(req, {
|
|
108
|
+
data,
|
|
109
|
+
headers: new Headers(upstream.protocol ? { "Sec-WebSocket-Protocol": upstream.protocol } : {}),
|
|
110
|
+
});
|
|
111
|
+
if (ok) return undefined;
|
|
112
|
+
upstream.close();
|
|
113
|
+
return new Response("Bad WebSocket handshake", { status: 400 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const proxyHandlers: WebSocketHandler<ProxyData> = {
|
|
117
|
+
// The app enforces WS_MAX_PAYLOAD (and closes with 1009, passed through);
|
|
118
|
+
// the proxy must not cut frames off at Bun's 16 MB default first.
|
|
119
|
+
maxPayloadLength: 2 ** 30,
|
|
120
|
+
open(ws) {
|
|
121
|
+
const data = ws.data;
|
|
122
|
+
data.client = ws;
|
|
123
|
+
proxied.add(ws);
|
|
124
|
+
for (const frame of data.pending) ws.send(frame);
|
|
125
|
+
data.pending = [];
|
|
126
|
+
if (data.early) ws.close(toSendableCode(data.early.code, 1011), data.early.reason);
|
|
127
|
+
},
|
|
128
|
+
message(ws, message) {
|
|
129
|
+
const { upstream } = ws.data;
|
|
130
|
+
if (upstream.readyState === WebSocket.OPEN) upstream.send(message);
|
|
131
|
+
},
|
|
132
|
+
close(ws, code, reason) {
|
|
133
|
+
proxied.delete(ws);
|
|
134
|
+
const { upstream } = ws.data;
|
|
135
|
+
if (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING) {
|
|
136
|
+
upstream.close(toSendableCode(code, 1001), reason);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Close every proxied client — 1012 on a rebuild, so clients reconnect to the new app. */
|
|
142
|
+
export function closeProxiedSockets(code: number, reason: string): void {
|
|
143
|
+
closeEach(proxied, code, reason);
|
|
144
|
+
}
|
package/src/core/env.ts
CHANGED
|
@@ -24,12 +24,19 @@ const FRAMEWORK_VARS = new Set([
|
|
|
24
24
|
"PRERENDER_CONCURRENCY",
|
|
25
25
|
"IDLE_TIMEOUT",
|
|
26
26
|
"MAX_INFLIGHT",
|
|
27
|
+
"WS_MAX_PAYLOAD",
|
|
28
|
+
"WS_IDLE_TIMEOUT",
|
|
29
|
+
"WS_BACKPRESSURE_LIMIT",
|
|
30
|
+
"WS_COMPRESS",
|
|
27
31
|
"TRUST_PROXY",
|
|
28
32
|
"CSP_DIRECTIVES",
|
|
29
33
|
"DISABLE_X_FRAME_OPTIONS",
|
|
30
34
|
"CACHE_KEYS",
|
|
31
35
|
"CACHE_MAX_ENTRIES",
|
|
32
36
|
"CACHE_MAX_BODY_BYTES",
|
|
37
|
+
"CACHE_MAX_BYTES",
|
|
38
|
+
"CACHE_WAIT_TIMEOUT",
|
|
39
|
+
"CACHE_IGNORE_PARAMS",
|
|
33
40
|
"ASSET_CACHE_MAX_BYTES",
|
|
34
41
|
"ASSET_CACHE_MAX_FILE_BYTES",
|
|
35
42
|
]);
|