bosia 1.2.6 → 1.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/backend.ts +34 -4
- package/src/core/basePath.ts +30 -0
- package/src/core/cache.ts +20 -8
- package/src/core/client/App.svelte +2 -2
- package/src/core/client/hydrate.ts +17 -9
- package/src/core/client/loaderCache.ts +14 -3
- package/src/core/client/navigation.ts +1 -1
- package/src/core/client/prefetch.ts +9 -17
- package/src/core/client/router.svelte.ts +41 -22
- package/src/core/cookies.ts +15 -12
- package/src/core/csrf.ts +32 -19
- package/src/core/dedup.ts +3 -2
- package/src/core/dev.ts +11 -1
- package/src/core/devWsProxy.ts +144 -0
- package/src/core/env.ts +4 -0
- package/src/core/errors.ts +23 -6
- package/src/core/hooks.ts +1 -1
- package/src/core/plugins/access-log.ts +2 -2
- package/src/core/proxy.ts +49 -0
- package/src/core/renderer.ts +42 -23
- package/src/core/routeFile.ts +0 -2
- package/src/core/routeTypes.ts +19 -3
- package/src/core/scanner.ts +0 -17
- package/src/core/server.bun.ts +6 -1
- package/src/core/server.ts +131 -53
- package/src/core/server.workers.ts +2 -1
- package/src/core/types.ts +0 -8
- package/src/core/websocket.ts +232 -0
- package/src/core/workersGuard.ts +18 -3
- package/src/lib/index.ts +1 -0
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
|
|
8
|
-
//
|
|
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
|
-
|
|
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",
|
package/src/core/errors.ts
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
78
|
+
if (DANGEROUS_PROTOCOL.test(url.protocol)) {
|
|
70
79
|
throw new Error(
|
|
71
80
|
`redirect(): dangerous scheme in URL "${location}". ` +
|
|
72
|
-
`
|
|
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
|
-
//
|
|
87
|
-
|
|
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,
|
|
114
|
+
return app.onResponse(({ request, status, start }) => {
|
|
115
115
|
log({
|
|
116
116
|
method: request.method,
|
|
117
117
|
path: pathOf(request.url),
|
|
118
|
-
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
|
+
}
|
package/src/core/renderer.ts
CHANGED
|
@@ -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
|
|
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([
|
|
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)
|
|
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)
|
|
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
|
-
|
|
727
|
-
(
|
|
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
|
-
|
|
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
|
-
|
|
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 } });
|
package/src/core/routeFile.ts
CHANGED
|
@@ -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");
|
package/src/core/routeTypes.ts
CHANGED
|
@@ -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(
|
|
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
|
|
86
|
-
lines.push(` * or +server.ts to opt the route out of
|
|
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(``);
|
package/src/core/scanner.ts
CHANGED
|
@@ -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
|
}
|
package/src/core/server.bun.ts
CHANGED
|
@@ -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);
|