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/src/core/dedup.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  // Settled responses are NOT cached — once the promise resolves, the entry is
5
5
  // dropped, so the next request runs the loader again.
6
6
  //
7
- // The caller's key includes the CACHE_KEYS identity hash (see cache.ts), so
8
- // different users never share a loader result. Custom session cookie names
7
+ // The caller's key (computeCacheKey in cache.ts) adds the request origin and
8
+ // the CACHE_KEYS identity hash, so different hosts and users never share a
9
+ // loader result. Custom session cookie names
9
10
  // must be listed in CACHE_KEYS. See docs/guides/request-deduplication.md.
10
11
 
11
12
  type Inflight = { promise: Promise<unknown>; start: number };
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,6 +24,10 @@ 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",
@@ -1,7 +1,7 @@
1
1
  // ─── Error / Redirect Helpers ────────────────────────────
2
2
  // Throw these from load() functions; the server catches and handles them.
3
3
 
4
- import { withBase } from "./basePath.ts";
4
+ import { DANGEROUS_PROTOCOL, withBase } from "./basePath.ts";
5
5
  import { currentBase } from "./appBase.ts";
6
6
 
7
7
  // Identity across bundle boundaries. `dist/hooks.server.js` keeps "bosia"
@@ -59,17 +59,26 @@ export class Redirect {
59
59
  (HttpError.prototype as any)[HTTP_ERROR_BRAND] = true;
60
60
  (Redirect.prototype as any)[REDIRECT_BRAND] = true;
61
61
 
62
- const DANGEROUS_SCHEMES = /^(javascript|data|vbscript):/i;
62
+ // Any parseable origin works as the base: only "did it stay on it?" is asked.
63
+ const SELF = "http://bosia.invalid";
63
64
 
64
65
  function validateRedirectLocation(location: string, options?: RedirectOptions): void {
65
66
  const trimmed = location.trim();
67
+ // Parsed like the browser will parse the Location: `java\tscript:` is javascript:,
68
+ // `htt\tps://evil.com` and `/\\evil.com` are another origin.
69
+ let url: URL;
70
+ try {
71
+ url = new URL(trimmed, SELF);
72
+ } catch {
73
+ throw new Error(`redirect(): invalid URL "${location}".`);
74
+ }
66
75
 
67
76
  // Dangerous schemes are rejected even when `allowExternal: true` —
68
77
  // `javascript:` / `data:` / `vbscript:` are never legitimate redirect targets.
69
- if (DANGEROUS_SCHEMES.test(trimmed)) {
78
+ if (DANGEROUS_PROTOCOL.test(url.protocol)) {
70
79
  throw new Error(
71
80
  `redirect(): dangerous scheme in URL "${location}". ` +
72
- `Only relative paths and same-origin URLs are allowed.`,
81
+ `Use a relative path or an http(s) URL with { allowExternal: true }.`,
73
82
  );
74
83
  }
75
84
 
@@ -83,8 +92,16 @@ function validateRedirectLocation(location: string, options?: RedirectOptions):
83
92
  );
84
93
  }
85
94
 
86
- // Allow relative paths (no scheme)
87
- if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(trimmed)) return;
95
+ // Relative only when it isn't a URL on its own: `http:evil.com` resolves against
96
+ // SELF as a path, but on an https page the browser reads it as http://evil.com.
97
+ // try/new URL, not URL.canParse: this file ships to browsers without it (Safari < 17).
98
+ let absolute = true;
99
+ try {
100
+ new URL(trimmed);
101
+ } catch {
102
+ absolute = false;
103
+ }
104
+ if (!absolute && url.origin === SELF) return;
88
105
 
89
106
  // It's an absolute URL — reject external origins
90
107
  throw new Error(
package/src/core/hooks.ts CHANGED
@@ -155,7 +155,7 @@ export type LoaderDeps = {
155
155
  urls: string[];
156
156
  /** Route params the loader read (`params.X`). */
157
157
  params: string[];
158
- /** Search params the loader read (`url.searchParams.get(X)` / `.has(X)`). */
158
+ /** Search params the loader read (`url.searchParams.get(X)` / `.has(X)`); `"*"` = the whole query. */
159
159
  searchParams: string[];
160
160
  /** Cookies the loader read (`cookies.get(X)`). */
161
161
  cookies: string[];
@@ -111,11 +111,11 @@ export function accessLog(options: AccessLogOptions = {}): BosiaPlugin {
111
111
  before(app) {
112
112
  // onResponse swallows a throw here (a broken filter/format/sink),
113
113
  // so logging can never change or fail a response.
114
- return app.onResponse(({ request, response, start }) => {
114
+ return app.onResponse(({ request, status, start }) => {
115
115
  log({
116
116
  method: request.method,
117
117
  path: pathOf(request.url),
118
- status: response.status,
118
+ status,
119
119
  ms: performance.now() - start,
120
120
  request,
121
121
  });
@@ -0,0 +1,49 @@
1
+ // Behind a reverse proxy the inbound `Host`/scheme is the proxy's inner hop
2
+ // (e.g. `localhost:PORT` over plain HTTP). X-Forwarded-Host/-Proto carry the
3
+ // public origin, but any client can send them, so they count only when the
4
+ // operator sets TRUST_PROXY=true.
5
+
6
+ /**
7
+ * First value of an `X-Forwarded-*` header: each proxy in a chain appends its
8
+ * own ("https, http"), and the first is the client-facing one.
9
+ */
10
+ function firstForwarded(request: Request, name: string): string | undefined {
11
+ return request.headers.get(name)?.split(",")[0]?.trim() || undefined;
12
+ }
13
+
14
+ // A Map, not an object: "constructor" or "__proto__" must read as unknown.
15
+ const SCHEMES = new Map([
16
+ ["http", "http:"],
17
+ ["https", "https:"],
18
+ // Traefik sends ws/wss on upgrades; the origin a browser sends is http(s).
19
+ ["ws", "http:"],
20
+ ["wss", "https:"],
21
+ ]);
22
+
23
+ /**
24
+ * Point `url` at the public origin from X-Forwarded-Host/-Proto, in place, so
25
+ * `event.url`, redirects and the CSRF/WebSocket origin checks all see it.
26
+ * Does nothing unless `trustProxy`. A malformed host leaves `url` as it was,
27
+ * scheme included; an unknown scheme is ignored.
28
+ */
29
+ export function applyForwarded(request: Request, url: URL, trustProxy: boolean): void {
30
+ if (!trustProxy) return;
31
+ const proto = SCHEMES.get(firstForwarded(request, "x-forwarded-proto")?.toLowerCase() ?? "");
32
+ const host = firstForwarded(request, "x-forwarded-host");
33
+ let parsed: URL | undefined;
34
+ if (host) {
35
+ // The parser would quietly keep part of `evil.com@app.com` or `app.com/x`.
36
+ if (/[\s/?#@\\]/.test(host)) return;
37
+ try {
38
+ parsed = new URL(`${proto ?? url.protocol}//${host}`);
39
+ } catch {
40
+ return;
41
+ }
42
+ }
43
+ if (proto) url.protocol = proto;
44
+ if (parsed) {
45
+ // hostname + port, not `host`: setting `host` without a port keeps the inner one.
46
+ url.hostname = parsed.hostname;
47
+ url.port = parsed.port;
48
+ }
49
+ }
@@ -16,6 +16,7 @@ import {
16
16
  collectTags,
17
17
  depsReadIgnoredParams,
18
18
  markWholeQueryRead,
19
+ markQueryAware,
19
20
  LOAD_TIMEOUT,
20
21
  METADATA_TIMEOUT,
21
22
  stripIgnoredParams,
@@ -76,7 +77,7 @@ const enc = new TextEncoder();
76
77
  //
77
78
  // `url` is app-space (server.ts strips BASE_PATH exactly once, at the top of
78
79
  // handleRequest), but every value the client half writes to these fields is
79
- // browser-space: hydrate.ts assigns raw window.location.pathname, and
80
+ // browser-space: hydrate.ts assigns locationPath() (the address bar), and
80
81
  // clientRoutes are generated with the prefix already in them. So the base goes
81
82
  // back on before seeding, or a mounted app server-renders `/admin/audit` and
82
83
  // hydration flips it to `/sso/admin/audit`.
@@ -246,6 +247,25 @@ function stampErrorContext(
246
247
  e.partialLayoutData ??= [...partialLayoutData];
247
248
  }
248
249
 
250
+ // A module that fails to import isn't cached; the loader path imports it
251
+ // again and turns the failure into the error page.
252
+ const uncached = () => ({ cache: false });
253
+
254
+ /** A page route's `export const cache`: `false` in either +page.svelte or
255
+ * +page.server.ts opts it out. Read from the real modules each time, so no
256
+ * export form or later `cache = false` is missed. Cheap because the generated
257
+ * `once()` wrapper (routeFile.ts) hands back the settled import promise. */
258
+ export async function routeCacheFlag(route: {
259
+ pageModule: () => Promise<any>;
260
+ pageServer: (() => Promise<any>) | null;
261
+ }): Promise<boolean> {
262
+ const [pageMod, serverMod] = await Promise.all([
263
+ route.pageModule().catch(uncached),
264
+ route.pageServer?.().catch(uncached),
265
+ ]);
266
+ return pageMod.cache !== false && serverMod?.cache !== false;
267
+ }
268
+
249
269
  // ─── Per-Loader Dependency Tracking ──────────────────────
250
270
  // Wraps `params`, `url`, `cookies`, and `fetch` with proxies/closures
251
271
  // that record every key read during a single loader run. The client
@@ -274,7 +294,16 @@ function trackedParams(params: Record<string, string>, deps: LoaderDeps): Record
274
294
  });
275
295
  }
276
296
 
277
- const URL_TRACKED_PROPS = new Set(["pathname", "origin", "hash", "href", "host", "hostname"]);
297
+ const URL_TRACKED_PROPS = new Set([
298
+ "pathname",
299
+ "origin",
300
+ "hash",
301
+ "href",
302
+ "host",
303
+ "hostname",
304
+ "toString",
305
+ "toJSON",
306
+ ]);
278
307
 
279
308
  function trackedUrl(url: URL, deps: LoaderDeps): URL {
280
309
  const trackedSearch = new Proxy(url.searchParams, {
@@ -303,6 +332,10 @@ function trackedUrl(url: URL, deps: LoaderDeps): URL {
303
332
  markWholeQueryRead(deps);
304
333
  return target.search;
305
334
  }
335
+ // Known cost: `new URL("/api/x", url)` reads `toString` too, so a loader that
336
+ // only needs the origin re-runs on every query change. Accepted: a full-URL
337
+ // read can depend on the query, and stale data is worse than an extra run.
338
+ // Use `url.origin` in such loaders to keep them cached.
306
339
  if (prop === "href" || prop === "toString" || prop === "toJSON") {
307
340
  markWholeQueryRead(deps);
308
341
  }
@@ -549,7 +582,7 @@ export async function loadRouteData(
549
582
  // A left-out layout still feeds the layers below it via parent(): its
550
583
  // ignored-param reads count for every one that is sent.
551
584
  if (omit && depsReadIgnoredParams(null, deps)) omittedReadIgnored = true;
552
- else if (!omit && omittedReadIgnored) markWholeQueryRead(deps);
585
+ else if (!omit && omittedReadIgnored) markQueryAware(deps);
553
586
  } catch (err) {
554
587
  // Loaders below this one already started; dev warns about the guard.
555
588
  const thrown = isDev ? guardLabel(err) : null;
@@ -616,7 +649,7 @@ export async function loadRouteData(
616
649
  }
617
650
  }
618
651
 
619
- if (omittedReadIgnored && pageDeps) markWholeQueryRead(pageDeps);
652
+ if (omittedReadIgnored && pageDeps) markQueryAware(pageDeps);
620
653
 
621
654
  // `params` are always attached to pageData for client-side router consumption.
622
655
  // When pageData is skipped, the client merges its cached pageData with current
@@ -701,18 +734,6 @@ export async function renderSSRStream(
701
734
  // from CACHE_KEYS), so per-user pages stay isolated. Routes opt out via
702
735
  // `export const cache = false`. See cache.ts and docs/guides/response-cache.md.
703
736
  const cacheBypass = url.searchParams.has("_invalidated");
704
- // `route.cache` is the build-time static read of `export const cache` in
705
- // +page.svelte, letting a cache hit skip the page-module import entirely:
706
- // true → cacheable, false → opted out, null → import to read the real value.
707
- let pageMod: any = null;
708
- const staticCache = (route as any).cache as boolean | null | undefined;
709
- let routeCacheable: boolean;
710
- if (staticCache === true || staticCache === false) {
711
- routeCacheable = staticCache;
712
- } else {
713
- pageMod = await route.pageModule();
714
- routeCacheable = pageMod.cache !== false;
715
- }
716
737
  // CSP is incompatible with response cache — the per-request nonce is baked
717
738
  // into the cached HTML but the CSP header is re-derived each request, so a
718
739
  // cached page would ship with a dead nonce and the browser would block its
@@ -723,8 +744,8 @@ export async function renderSSRStream(
723
744
  CACHE_ENABLED &&
724
745
  !CSP_ENABLED &&
725
746
  !warmingUp &&
726
- routeCacheable &&
727
- (req.method === "GET" || req.method === "HEAD");
747
+ (req.method === "GET" || req.method === "HEAD") &&
748
+ (await routeCacheFlag(route));
728
749
  const cacheable = cacheRead && req.method === "GET";
729
750
  let cacheKey: string | null = null;
730
751
  let releaseMiss: (() => void) | null = null;
@@ -779,8 +800,7 @@ export async function renderSSRStream(
779
800
  match,
780
801
  ),
781
802
  Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
782
- // pageMod is already loaded when the cache flag was unknown.
783
- pageMod ?? route.pageModule(),
803
+ route.pageModule(),
784
804
  ]);
785
805
  // Settled by the early returns below without being read.
786
806
  dataP.catch(() => {});
@@ -830,11 +850,10 @@ export async function renderSSRStream(
830
850
 
831
851
  let data: Awaited<ReturnType<typeof loadRouteData>>;
832
852
  let layoutMods: any[];
853
+ let pageMod: any;
833
854
 
834
855
  try {
835
- let pm: any;
836
- [data, layoutMods, pm] = await dataP;
837
- pageMod = pm;
856
+ [data, layoutMods, pageMod] = await dataP;
838
857
  } catch (err) {
839
858
  if (isRedirect(err))
840
859
  return new Response(null, { status: err.status, headers: { Location: err.location } });
@@ -129,7 +129,6 @@ export function generateRoutesFile(manifest: RouteManifest, defaultTitle: string
129
129
  lines.push(" layoutServers: { loader: () => Promise<any>; depth: number }[];");
130
130
  lines.push(" errorPages: { loader: () => Promise<any>; depth: number }[];");
131
131
  lines.push(' trailingSlash: "never" | "always" | "ignore";');
132
- lines.push(" cache: boolean | null;");
133
132
  lines.push("}> = [");
134
133
  for (const r of pages) {
135
134
  const layoutImports = r.layouts.map((l) => serverImport(l)).join(", ");
@@ -148,7 +147,6 @@ export function generateRoutesFile(manifest: RouteManifest, defaultTitle: string
148
147
  lines.push(` layoutServers: [${layoutServerImports}],`);
149
148
  lines.push(` errorPages: [${errorPageImports}],`);
150
149
  lines.push(` trailingSlash: ${JSON.stringify(r.trailingSlash)},`);
151
- lines.push(` cache: ${JSON.stringify(r.cache ?? null)},`);
152
150
  lines.push(" },");
153
151
  }
154
152
  lines.push("];\n");
@@ -55,6 +55,12 @@ export function generateRouteTypes(manifest: RouteManifest): void {
55
55
  }
56
56
  }
57
57
 
58
+ // +server.ts dirs get Params and WebSocketHandler too.
59
+ for (const api of manifest.apis) {
60
+ const apiDir = routeDirOf(api.server);
61
+ if (!dirs.has(apiDir)) dirs.set(apiDir, {});
62
+ }
63
+
58
64
  if (manifest.errorPage) {
59
65
  if (!dirs.has(".")) dirs.set(".", {});
60
66
  dirs.get(".")!.hasErrorPage = true;
@@ -78,12 +84,15 @@ export function generateRouteTypes(manifest: RouteManifest): void {
78
84
  : `{ ${params.map((p) => `${PARAM_NAME.test(p) ? p : JSON.stringify(p)}: string`).join("; ")} }`;
79
85
 
80
86
  const lines: string[] = ["// AUTO-GENERATED by bosia — do not edit\n"];
81
- lines.push(`import type { LoadEvent, MetadataEvent, RequestEvent, Metadata } from 'bosia';`);
87
+ lines.push(
88
+ `import type { LoadEvent, MetadataEvent, RequestEvent, Metadata, WebSocketHandler as _WebSocketHandler } from 'bosia';`,
89
+ );
82
90
  lines.push(``);
83
91
  lines.push(`export type Params = ${paramsType};`);
84
92
  lines.push(``);
85
- lines.push(`/** Set \`export const cache: CacheOption = false;\` in +page(.server).ts`);
86
- lines.push(` * or +server.ts to opt the route out of the server response cache. */`);
93
+ lines.push(`/** Set \`export const cache: CacheOption = false;\` in +page.server.ts, the`);
94
+ lines.push(` * <script module> of +page.svelte, or +server.ts to opt the route out of`);
95
+ lines.push(` * the server response cache. Layouts don't read it. */`);
87
96
  lines.push(`export type CacheOption = false;`);
88
97
  lines.push(``);
89
98
  lines.push(
@@ -97,6 +106,13 @@ export function generateRouteTypes(manifest: RouteManifest): void {
97
106
  lines.push(`type _LoadEvent = Omit<LoadEvent, 'params'> & { params: Params };`);
98
107
  lines.push(`type _MetadataEvent = Omit<MetadataEvent, 'params'> & { params: Params };`);
99
108
  lines.push(`type _RequestEvent = Omit<RequestEvent, 'params'> & { params: Params };`);
109
+ lines.push(``);
110
+ lines.push(
111
+ `/** \`export const websocket: WebSocketHandler<Data>\` in +server.ts (Bun only). */`,
112
+ );
113
+ lines.push(
114
+ `export type WebSocketHandler<T extends object = {}> = _WebSocketHandler<T, _RequestEvent>;`,
115
+ );
100
116
 
101
117
  if (info.pageServer) {
102
118
  lines.push(``);
@@ -19,21 +19,6 @@ import { segmentName, segmentRank } from "./matcher.ts";
19
19
 
20
20
  const ROUTES_DIR = "./src/routes";
21
21
 
22
- /**
23
- * Read `export const cache` from +page.svelte's `<script module>` at scan time.
24
- * Conservative on purpose — the flag lets the renderer skip caching, so a wrong
25
- * "cacheable" answer would leak a per-user page. Returns `true` ONLY when there
26
- * is no `cache` export at all; a literal `= false` returns `false`; anything
27
- * else (dynamic expression, unreadable file) returns `null` so the renderer
28
- * imports the module and reads the real value.
29
- */
30
- function pageCache(src: string | null): boolean | null {
31
- if (src === null) return null;
32
- if (!/export\s+const\s+cache\b/.test(src)) return true;
33
- if (/export\s+const\s+cache\s*(?::[^=]+)?=\s*false\b/.test(src)) return false;
34
- return null;
35
- }
36
-
37
22
  /** Literal `export const prerender = true` in a server module's source. */
38
23
  export function wantsPrerender(src: string): boolean {
39
24
  return /export\s+const\s+prerender\s*=\s*true/.test(src);
@@ -154,7 +139,6 @@ export function scanRoutes(): RouteManifest {
154
139
  const effectiveTs: TrailingSlash = pageTs ?? currentTrailingSlash;
155
140
 
156
141
  const pageFile = rel("+page.svelte");
157
- const pageSrc = readSource(join(ROUTES_DIR, pageFile));
158
142
  pages.push({
159
143
  pattern: toUrlPath(urlSegments),
160
144
  id: `/${dir}`,
@@ -165,7 +149,6 @@ export function scanRoutes(): RouteManifest {
165
149
  layoutServers: [...currentLayoutServers],
166
150
  errorPages: [...currentErrorPages],
167
151
  trailingSlash: effectiveTs,
168
- cache: pageCache(pageSrc),
169
152
  prerender: prerenderOf(pageServerSrc),
170
153
  });
171
154
  }
@@ -107,8 +107,13 @@ async function shutdown() {
107
107
  console.log("✅ All requests drained");
108
108
  }
109
109
 
110
- app.stop(true).then(() => process.exit(0));
111
110
  setTimeout(() => process.exit(1), 2_000);
111
+ // inFlight reaches 0 just before Bun writes the last responses, so stop
112
+ // gracefully first, briefly: an open stream (SSE) never finishes by itself,
113
+ // and a socket that never acks its 1001 shouldn't hold the exit either.
114
+ if (left === 0) await Promise.race([app.stop(), Bun.sleep(500)]);
115
+ await app.stop(true);
116
+ process.exit(0);
112
117
  }
113
118
 
114
119
  process.on("SIGTERM", shutdown);