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.
Files changed (44) hide show
  1. package/README.md +5 -6
  2. package/package.json +2 -1
  3. package/src/core/backend.ts +87 -3
  4. package/src/core/basePath.ts +30 -0
  5. package/src/core/cache.ts +378 -30
  6. package/src/core/client/App.svelte +19 -11
  7. package/src/core/client/hydrate.ts +17 -9
  8. package/src/core/client/navigation.ts +1 -1
  9. package/src/core/client/prefetch.ts +66 -41
  10. package/src/core/client/router.svelte.ts +41 -22
  11. package/src/core/cors.ts +17 -1
  12. package/src/core/csrf.ts +32 -19
  13. package/src/core/dedup.ts +51 -7
  14. package/src/core/dev.ts +11 -1
  15. package/src/core/devWsProxy.ts +144 -0
  16. package/src/core/env.ts +7 -0
  17. package/src/core/errors.ts +23 -6
  18. package/src/core/hooks.ts +1 -1
  19. package/src/core/html.ts +13 -1
  20. package/src/core/plugins/access-log.ts +128 -0
  21. package/src/core/prerender.ts +143 -6
  22. package/src/core/proxy.ts +48 -0
  23. package/src/core/renderer.ts +70 -29
  24. package/src/core/routeTypes.ts +16 -1
  25. package/src/core/server.bun.ts +6 -1
  26. package/src/core/server.ts +198 -77
  27. package/src/core/server.workers.ts +3 -2
  28. package/src/core/types/plugin.ts +2 -1
  29. package/src/core/websocket.ts +232 -0
  30. package/src/core/workersGuard.ts +18 -3
  31. package/src/lib/index.ts +1 -0
  32. package/templates/default/.env.example +20 -5
  33. package/templates/default/tsconfig.json +1 -1
  34. package/templates/demo/.env.example +20 -5
  35. package/templates/demo/src/hooks.server.ts +3 -12
  36. package/templates/demo/tsconfig.json +1 -1
  37. package/templates/shop/src/app.d.ts +0 -1
  38. package/templates/shop/src/hooks.server.ts +3 -11
  39. package/templates/shop/src/routes/+layout.server.ts +0 -1
  40. package/templates/shop/tsconfig.json +1 -1
  41. package/templates/store/src/app.d.ts +0 -1
  42. package/templates/store/src/hooks.server.ts +3 -11
  43. package/templates/store/src/routes/+layout.server.ts +0 -1
  44. 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.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();
@@ -109,29 +103,54 @@ function errorMessage(res: Response): string {
109
103
  return res.statusText || "Internal Server Error";
110
104
  }
111
105
 
112
- export const prefetchCache = new Map<string, { data: any; ts: number }>();
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 fetch deduplication
116
- const pending = new Set<string>();
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
- /** Returns cached prefetch data for a path and removes it from cache. */
119
- export function consumePrefetch(path: string): any | null {
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 === window.location.pathname + window.location.search) return;
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.origin).pathname);
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
- pending.add(path);
156
- try {
157
- // Send the same mask as a real client nav would so the server leaves out
158
- // layers the client already holds. Falls back to sending everything when
159
- // the route can't be matched (e.g. external/unknown URL).
160
- const maskBits = buildMaskBits(path) ?? undefined;
161
- const res = await fetch(dataUrl(path, maskBits));
162
- // `ok` alone would cache a guard's login page (200 after the redirect was
163
- // followed) as if it were this route's data.
164
- if (res.ok && !res.redirected && isJsonResponse(res)) {
165
- if (prefetchCache.size >= MAX_PREFETCH_ENTRIES) {
166
- const oldest = prefetchCache.keys().next().value;
167
- if (oldest !== undefined) prefetchCache.delete(oldest);
168
- }
169
- prefetchCache.set(path, { data: await res.json(), ts: Date.now() });
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
- } catch {
172
- // Silently ignore — prefetch is best-effort
173
- } finally {
174
- pending.delete(path);
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
- if (anchor.origin !== window.location.origin) return null;
180
- if (anchor.target) return null;
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
- 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();
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.set("Vary", "Origin");
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 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
+ }
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
- const inflight = new Map<string, Promise<unknown>>();
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
- /** Coalesce concurrent calls under `key`. `fn` runs at most once per inflight window. */
23
- export function dedup<T>(key: string, fn: () => Promise<T>): Promise<T> {
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) return existing as Promise<T>;
26
- const promise = fn().finally(() => inflight.delete(key));
27
- inflight.set(key, promise);
28
- return promise as Promise<T>;
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
- async fetch(req) {
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
  ]);