bosia 1.2.6 → 1.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/backend.ts +34 -4
- package/src/core/basePath.ts +30 -0
- package/src/core/client/App.svelte +2 -2
- package/src/core/client/hydrate.ts +17 -9
- 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/csrf.ts +32 -19
- 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/plugins/access-log.ts +2 -2
- package/src/core/proxy.ts +48 -0
- package/src/core/renderer.ts +1 -1
- package/src/core/routeTypes.ts +16 -1
- package/src/core/server.bun.ts +6 -1
- package/src/core/server.ts +122 -41
- package/src/core/server.workers.ts +2 -1
- package/src/core/websocket.ts +232 -0
- package/src/core/workersGuard.ts +18 -3
- package/src/lib/index.ts +1 -0
|
@@ -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(
|
|
@@ -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,48 @@
|
|
|
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
|
+
const SCHEMES: Record<string, string> = {
|
|
15
|
+
http: "http:",
|
|
16
|
+
https: "https:",
|
|
17
|
+
// Traefik sends ws/wss on upgrades; the origin a browser sends is http(s).
|
|
18
|
+
ws: "http:",
|
|
19
|
+
wss: "https:",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Point `url` at the public origin from X-Forwarded-Host/-Proto, in place, so
|
|
24
|
+
* `event.url`, redirects and the CSRF/WebSocket origin checks all see it.
|
|
25
|
+
* Does nothing unless `trustProxy`. A malformed host leaves `url` as it was,
|
|
26
|
+
* scheme included; an unknown scheme is ignored.
|
|
27
|
+
*/
|
|
28
|
+
export function applyForwarded(request: Request, url: URL, trustProxy: boolean): void {
|
|
29
|
+
if (!trustProxy) return;
|
|
30
|
+
const proto = SCHEMES[firstForwarded(request, "x-forwarded-proto")?.toLowerCase() ?? ""];
|
|
31
|
+
const host = firstForwarded(request, "x-forwarded-host");
|
|
32
|
+
let parsed: URL | undefined;
|
|
33
|
+
if (host) {
|
|
34
|
+
// The parser would quietly keep part of `evil.com@app.com` or `app.com/x`.
|
|
35
|
+
if (/[\s/?#@\\]/.test(host)) return;
|
|
36
|
+
try {
|
|
37
|
+
parsed = new URL(`${proto ?? url.protocol}//${host}`);
|
|
38
|
+
} catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (proto) url.protocol = proto;
|
|
43
|
+
if (parsed) {
|
|
44
|
+
// hostname + port, not `host`: setting `host` without a port keeps the inner one.
|
|
45
|
+
url.hostname = parsed.hostname;
|
|
46
|
+
url.port = parsed.port;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/core/renderer.ts
CHANGED
|
@@ -76,7 +76,7 @@ const enc = new TextEncoder();
|
|
|
76
76
|
//
|
|
77
77
|
// `url` is app-space (server.ts strips BASE_PATH exactly once, at the top of
|
|
78
78
|
// handleRequest), but every value the client half writes to these fields is
|
|
79
|
-
// browser-space: hydrate.ts assigns
|
|
79
|
+
// browser-space: hydrate.ts assigns locationPath() (the address bar), and
|
|
80
80
|
// clientRoutes are generated with the prefix already in them. So the base goes
|
|
81
81
|
// back on before seeding, or a mounted app server-renders `/admin/audit` and
|
|
82
82
|
// hydration flips it to `/sso/admin/audit`.
|
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,7 +84,9 @@ 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(``);
|
|
@@ -97,6 +105,13 @@ export function generateRouteTypes(manifest: RouteManifest): void {
|
|
|
97
105
|
lines.push(`type _LoadEvent = Omit<LoadEvent, 'params'> & { params: Params };`);
|
|
98
106
|
lines.push(`type _MetadataEvent = Omit<MetadataEvent, 'params'> & { params: Params };`);
|
|
99
107
|
lines.push(`type _RequestEvent = Omit<RequestEvent, 'params'> & { params: Params };`);
|
|
108
|
+
lines.push(``);
|
|
109
|
+
lines.push(
|
|
110
|
+
`/** \`export const websocket: WebSocketHandler<Data>\` in +server.ts (Bun only). */`,
|
|
111
|
+
);
|
|
112
|
+
lines.push(
|
|
113
|
+
`export type WebSocketHandler<T extends object = {}> = _WebSocketHandler<T, _RequestEvent>;`,
|
|
114
|
+
);
|
|
100
115
|
|
|
101
116
|
if (info.pageServer) {
|
|
102
117
|
lines.push(``);
|
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);
|
package/src/core/server.ts
CHANGED
|
@@ -16,7 +16,8 @@ import { HttpError, Redirect, ActionFailure, isHttpError, isRedirect } from "./e
|
|
|
16
16
|
import { CookieJar } from "./cookies.ts";
|
|
17
17
|
import { safePath } from "./safePath.ts";
|
|
18
18
|
import { HEALTH_PATH, isFrameworkPath, isStaticPath } from "./staticPath.ts";
|
|
19
|
-
import { checkCsrf } from "./csrf.ts";
|
|
19
|
+
import { checkCsrf, checkUpgradeOrigin } from "./csrf.ts";
|
|
20
|
+
import { applyForwarded } from "./proxy.ts";
|
|
20
21
|
import type { CsrfConfig } from "./csrf.ts";
|
|
21
22
|
import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
|
|
22
23
|
import type { CorsConfig } from "./cors.ts";
|
|
@@ -74,6 +75,15 @@ import {
|
|
|
74
75
|
renderPageWithFormData,
|
|
75
76
|
} from "./renderer.ts";
|
|
76
77
|
import { getServerTime } from "../lib/utils.ts";
|
|
78
|
+
import {
|
|
79
|
+
closeAllSockets,
|
|
80
|
+
isUpgradeRequest,
|
|
81
|
+
isUpgraded,
|
|
82
|
+
closeUpgraded,
|
|
83
|
+
upgradeRequest,
|
|
84
|
+
type WebSocketHandler,
|
|
85
|
+
type WebSocketOptions,
|
|
86
|
+
} from "./websocket.ts";
|
|
77
87
|
|
|
78
88
|
// ─── User Hooks ──────────────────────────────────────────
|
|
79
89
|
// Set by createApp(). Each runtime entry finds `src/hooks.server.ts` its own
|
|
@@ -375,6 +385,11 @@ async function serveStaticFile(path: string, request: Request): Promise<Response
|
|
|
375
385
|
return null;
|
|
376
386
|
}
|
|
377
387
|
|
|
388
|
+
// Not Response.redirect(): it rejects relative URLs on Workers.
|
|
389
|
+
function redirectResponse(r: Redirect): Response {
|
|
390
|
+
return new Response(null, { status: r.status, headers: { Location: r.location } });
|
|
391
|
+
}
|
|
392
|
+
|
|
378
393
|
async function resolve(event: RequestEvent): Promise<Response> {
|
|
379
394
|
const { request, url, locals, cookies } = event;
|
|
380
395
|
const path = url.pathname;
|
|
@@ -650,12 +665,42 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
650
665
|
let releaseApiMiss: (() => void) | null = null;
|
|
651
666
|
try {
|
|
652
667
|
const mod = await apiMatch.route.module();
|
|
668
|
+
|
|
669
|
+
// WebSocket upgrade — before the response cache, which must never see it.
|
|
670
|
+
const ws = (mod as { websocket?: WebSocketHandler<any, any> }).websocket;
|
|
671
|
+
if (ws && isUpgradeRequest(request)) {
|
|
672
|
+
event.params = apiMatch.params;
|
|
673
|
+
const result = ws.upgrade ? await ws.upgrade(event) : undefined;
|
|
674
|
+
if (result instanceof Response) return result;
|
|
675
|
+
if (isRedirect(result)) {
|
|
676
|
+
return redirectResponse(result);
|
|
677
|
+
}
|
|
678
|
+
if (result !== undefined && (result === null || typeof result !== "object")) {
|
|
679
|
+
throw new Error(
|
|
680
|
+
`websocket.upgrade() in ${apiMatch.route.id} must return an object, a Response or nothing`,
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
// Cookies a hook set before resolve() ride on the 101; later ones are lost.
|
|
684
|
+
const headers = new Headers();
|
|
685
|
+
for (const cookie of (cookies as CookieJar).outgoing) headers.append("Set-Cookie", cookie);
|
|
686
|
+
return upgradeRequest(ws, result ?? {}, headers);
|
|
687
|
+
}
|
|
688
|
+
|
|
653
689
|
// HEAD falls back to GET (an explicit HEAD export still wins). BosiaApp
|
|
654
690
|
// strips the body on the way out, for every route kind.
|
|
655
691
|
const handler = mod[method] ?? (method === "HEAD" ? mod.GET : undefined);
|
|
656
692
|
|
|
657
693
|
if (!handler) {
|
|
694
|
+
// A websocket-only route asked over plain HTTP.
|
|
695
|
+
if (ws && (method === "GET" || method === "HEAD")) {
|
|
696
|
+
return new Response("Upgrade Required", {
|
|
697
|
+
status: 426,
|
|
698
|
+
headers: { Upgrade: "websocket", Connection: "Upgrade" },
|
|
699
|
+
});
|
|
700
|
+
}
|
|
658
701
|
const allowed = Object.keys(mod).filter((k) => /^[A-Z]+$/.test(k));
|
|
702
|
+
// A websocket route takes GET (the upgrade's method); HEAD answers 426.
|
|
703
|
+
if (ws && !allowed.includes("GET")) allowed.push("GET");
|
|
659
704
|
if (allowed.includes("GET") && !allowed.includes("HEAD")) allowed.push("HEAD");
|
|
660
705
|
return Response.json(
|
|
661
706
|
{ error: `Method ${method} not allowed` },
|
|
@@ -709,10 +754,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
709
754
|
|
|
710
755
|
// Redirect returned (not thrown) — convert to a 303 Response.
|
|
711
756
|
if (isRedirect(handlerResult)) {
|
|
712
|
-
return
|
|
713
|
-
status: handlerResult.status,
|
|
714
|
-
headers: { Location: handlerResult.location },
|
|
715
|
-
});
|
|
757
|
+
return redirectResponse(handlerResult);
|
|
716
758
|
}
|
|
717
759
|
|
|
718
760
|
const response = handlerResult as Response;
|
|
@@ -784,10 +826,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
784
826
|
// `throw redirect(303, "/")` from a +server.ts handler — turn it into
|
|
785
827
|
// a real 303 instead of a 500. Mirrors the page-action handler below.
|
|
786
828
|
if (isRedirect(err)) {
|
|
787
|
-
return
|
|
788
|
-
status: err.status,
|
|
789
|
-
headers: { Location: err.location },
|
|
790
|
-
});
|
|
829
|
+
return redirectResponse(err);
|
|
791
830
|
}
|
|
792
831
|
if (isHttpError(err)) {
|
|
793
832
|
return Response.json({ error: err.message }, { status: err.status });
|
|
@@ -1071,9 +1110,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
1071
1110
|
// (preview/proxy hubs, design tools, etc.). Other security headers stay on.
|
|
1072
1111
|
const _xfoDisabled = process.env.DISABLE_X_FRAME_OPTIONS === "true";
|
|
1073
1112
|
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1076
|
-
//
|
|
1113
|
+
// Behind a reverse proxy, rebuild the request url from X-Forwarded-Host/-Proto
|
|
1114
|
+
// (applyForwarded): event.url, redirects, the CSRF/WebSocket origin checks and
|
|
1115
|
+
// the `Secure` cookie flag all follow it. Off by default — the headers are
|
|
1116
|
+
// spoofable from any client that talks directly to the app.
|
|
1077
1117
|
const TRUST_PROXY = process.env.TRUST_PROXY === "true";
|
|
1078
1118
|
|
|
1079
1119
|
const SECURITY_HEADERS: Record<string, string> = {
|
|
@@ -1153,19 +1193,7 @@ function finalizeResponse(
|
|
|
1153
1193
|
}
|
|
1154
1194
|
|
|
1155
1195
|
async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
1156
|
-
|
|
1157
|
-
// (e.g. `localhost:PORT` over plain HTTP), so `url` built from `request.url`
|
|
1158
|
-
// misreports the public origin. Rebuild it from `X-Forwarded-Host`/`-Proto`
|
|
1159
|
-
// so `event.url` — and every absolute redirect, canonical URL, and
|
|
1160
|
-
// `url.origin` the app derives — points at the public-facing origin instead
|
|
1161
|
-
// of localhost. Gated on TRUST_PROXY since these headers are client-spoofable
|
|
1162
|
-
// when no proxy strips them.
|
|
1163
|
-
if (TRUST_PROXY) {
|
|
1164
|
-
const fwdHost = request.headers.get("x-forwarded-host");
|
|
1165
|
-
if (fwdHost) url.host = fwdHost;
|
|
1166
|
-
const fwdProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
|
1167
|
-
if (fwdProto) url.protocol = `${fwdProto}:`;
|
|
1168
|
-
}
|
|
1196
|
+
applyForwarded(request, url, TRUST_PROXY);
|
|
1169
1197
|
|
|
1170
1198
|
// Mounted under BASE_PATH? Everything downstream — hooks, the router, the
|
|
1171
1199
|
// /_health and /__bosia tests below — works in app space, so the prefix comes
|
|
@@ -1178,6 +1206,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1178
1206
|
url.pathname = appPath;
|
|
1179
1207
|
}
|
|
1180
1208
|
|
|
1209
|
+
// No page lives at a `//x` path: anything that echoes it as a URL (the trailing-slash
|
|
1210
|
+
// 308, `page.url`, the client router) would read it as host x. Collapse the slashes.
|
|
1211
|
+
if (url.pathname.startsWith("//")) {
|
|
1212
|
+
return new Response(null, {
|
|
1213
|
+
status: 308,
|
|
1214
|
+
// `base +`, not withBase: withBase skips a path already under base, so `/sso//sso/x` would lose it.
|
|
1215
|
+
headers: { Location: base + url.pathname.replace(/^\/+/, "/") + url.search },
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1181
1219
|
// Bun.serve enforces maxRequestBodySize itself; Workers has no such knob, so
|
|
1182
1220
|
// check the declared length here. A chunked upload without Content-Length
|
|
1183
1221
|
// falls back to the host's own request cap (Cloudflare: 100MB on free).
|
|
@@ -1221,11 +1259,18 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1221
1259
|
console.warn(`[CSRF] Blocked request: ${csrfError}`);
|
|
1222
1260
|
return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
|
|
1223
1261
|
}
|
|
1262
|
+
// A WebSocket upgrade is a GET, so checkCsrf skips it. Checked here, before
|
|
1263
|
+
// the hooks, so a cross-site attempt never reaches a session lookup.
|
|
1264
|
+
const upgradeError = isUpgradeRequest(request)
|
|
1265
|
+
? checkUpgradeOrigin(request, url, CSRF_CONFIG)
|
|
1266
|
+
: null;
|
|
1267
|
+
if (upgradeError) {
|
|
1268
|
+
console.warn(`[CSRF] Blocked WebSocket upgrade: ${upgradeError}`);
|
|
1269
|
+
return Response.json({ error: "Forbidden", message: upgradeError }, { status: 403 });
|
|
1270
|
+
}
|
|
1224
1271
|
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
url.protocol === "https:";
|
|
1228
|
-
cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isHttps);
|
|
1272
|
+
// url already carries the forwarded scheme when TRUST_PROXY is on.
|
|
1273
|
+
cookieJar = new CookieJar(request.headers.get("cookie") ?? "", url.protocol === "https:");
|
|
1229
1274
|
nonce = CSP_ENABLED ? generateNonce() : "";
|
|
1230
1275
|
|
|
1231
1276
|
// Decode the data endpoint before the hooks, not inside resolve(): a guard
|
|
@@ -1277,12 +1322,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1277
1322
|
response ??=
|
|
1278
1323
|
userHandle && !warmingUp ? await userHandle({ event, resolve }) : await resolve(event);
|
|
1279
1324
|
|
|
1325
|
+
// Bun already answered with 101; there is no response left to finish.
|
|
1326
|
+
if (isUpgraded()) return response;
|
|
1327
|
+
|
|
1280
1328
|
// A hook that short-circuits a data request with a redirect is answering
|
|
1281
1329
|
// the client router, which speaks JSON — an unconverted 3xx is followed by
|
|
1282
1330
|
// `fetch` and the router receives the redirect target's HTML instead.
|
|
1283
1331
|
// `Location` is copied verbatim: `redirect()` already rebased it through
|
|
1284
1332
|
// `withBase()`, and a raw `Response.redirect` under a BASE_PATH carries the
|
|
1285
1333
|
// base by hand, so rebasing here would double the prefix on both.
|
|
1334
|
+
|
|
1286
1335
|
if (dataReq && response.status >= 300 && response.status < 400) {
|
|
1287
1336
|
const location = response.headers.get("location");
|
|
1288
1337
|
if (location) {
|
|
@@ -1301,6 +1350,13 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1301
1350
|
|
|
1302
1351
|
return finalizeResponse(response, request, nonce, cookieJar);
|
|
1303
1352
|
} catch (err) {
|
|
1353
|
+
// Bun already sent the 101, so there is no 500 to send: close the socket
|
|
1354
|
+
// with 1011, the way a handler error does.
|
|
1355
|
+
if (isUpgraded()) {
|
|
1356
|
+
console.error("Hook error after the WebSocket upgrade (socket closed with 1011):", err);
|
|
1357
|
+
closeUpgraded();
|
|
1358
|
+
return new Response(null);
|
|
1359
|
+
}
|
|
1304
1360
|
// `throw redirect()` / `throw error()` from a hook lands here — the same
|
|
1305
1361
|
// escape hatch loaders have always had. Without these branches both fall
|
|
1306
1362
|
// through to the 500 below, which is why the docs could only ever suggest
|
|
@@ -1311,8 +1367,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1311
1367
|
? // Shape-identical to the loader conversion below, so the
|
|
1312
1368
|
// router has one payload contract regardless of who redirected.
|
|
1313
1369
|
Response.json({ redirect: err.location, status: err.status })
|
|
1314
|
-
:
|
|
1315
|
-
new Response(null, { status: err.status, headers: { Location: err.location } })
|
|
1370
|
+
: redirectResponse(err)
|
|
1316
1371
|
: dataReq
|
|
1317
1372
|
? Response.json(
|
|
1318
1373
|
{
|
|
@@ -1379,11 +1434,11 @@ function parseCorsMaxAge(value?: string): number | undefined {
|
|
|
1379
1434
|
// Format: "512K", "1M", "1G", plain bytes, or "Infinity".
|
|
1380
1435
|
// Default: 512K (matches SvelteKit).
|
|
1381
1436
|
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
if (value === "Infinity") return 0;
|
|
1437
|
+
// "Infinity" → 0, Bun's "no limit". `key` names the variable in the error.
|
|
1438
|
+
function parseSize(key: string, value: string): number {
|
|
1439
|
+
if (value === "Infinity") return 0;
|
|
1385
1440
|
const match = value.match(/^(\d+(?:\.\d+)?)\s*([KMG]?)$/i);
|
|
1386
|
-
if (!match) throw new Error(`Invalid
|
|
1441
|
+
if (!match) throw new Error(`Invalid ${key}: "${value}"`);
|
|
1387
1442
|
const num = parseFloat(match[1]);
|
|
1388
1443
|
const unit = match[2].toUpperCase();
|
|
1389
1444
|
if (unit === "K") return Math.floor(num * 1024);
|
|
@@ -1392,7 +1447,9 @@ function parseBodySizeLimit(value?: string): number {
|
|
|
1392
1447
|
return Math.floor(num);
|
|
1393
1448
|
}
|
|
1394
1449
|
|
|
1395
|
-
const BODY_SIZE_LIMIT =
|
|
1450
|
+
const BODY_SIZE_LIMIT = process.env.BODY_SIZE_LIMIT
|
|
1451
|
+
? parseSize("BODY_SIZE_LIMIT", process.env.BODY_SIZE_LIMIT)
|
|
1452
|
+
: 512 * 1024;
|
|
1396
1453
|
|
|
1397
1454
|
if (BODY_SIZE_LIMIT === 0) {
|
|
1398
1455
|
console.log("📦 Body size limit: none");
|
|
@@ -1406,18 +1463,39 @@ if (BODY_SIZE_LIMIT === 0) {
|
|
|
1406
1463
|
// Raise when API routes hold streaming responses with long gaps
|
|
1407
1464
|
// between chunks (e.g. AI tool calls that shell out and wait).
|
|
1408
1465
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1466
|
+
// Whole seconds, 0..max. parseInt-lenient ("10s" reads as 10), as it always was.
|
|
1467
|
+
function parseSeconds(key: string, value: string | undefined, fallback: number, max: number) {
|
|
1468
|
+
if (!value) return fallback;
|
|
1411
1469
|
const n = parseInt(value, 10);
|
|
1412
|
-
if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid
|
|
1413
|
-
if (n >
|
|
1470
|
+
if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid ${key}: "${value}"`);
|
|
1471
|
+
if (n > max) throw new Error(`Invalid ${key}: "${value}" (max ${max})`);
|
|
1414
1472
|
return n;
|
|
1415
1473
|
}
|
|
1416
1474
|
|
|
1417
|
-
const IDLE_TIMEOUT =
|
|
1475
|
+
const IDLE_TIMEOUT = parseSeconds("IDLE_TIMEOUT", process.env.IDLE_TIMEOUT, 10, 255);
|
|
1418
1476
|
|
|
1419
1477
|
console.log(`⏱ Idle timeout: ${IDLE_TIMEOUT}s`);
|
|
1420
1478
|
|
|
1479
|
+
// ─── WebSocket Limits ─────────────────────────────────────
|
|
1480
|
+
// Parsed once at startup. Sizes take the BODY_SIZE_LIMIT format ("16M").
|
|
1481
|
+
// WS_IDLE_TIMEOUT is seconds without a frame before Bun closes the socket; Bun
|
|
1482
|
+
// pings idle sockets on its own, so a live peer never hits it.
|
|
1483
|
+
|
|
1484
|
+
// Bun has no "no limit" for these, so 0 and "Infinity" are refused.
|
|
1485
|
+
function parseWsSize(key: string, value?: string): number {
|
|
1486
|
+
if (!value) return 16 * 1024 * 1024;
|
|
1487
|
+
const n = parseSize(key, value);
|
|
1488
|
+
if (n === 0) throw new Error(`Invalid ${key}: "${value}" (must be above 0)`);
|
|
1489
|
+
return n;
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
const WEBSOCKET_OPTIONS: WebSocketOptions = {
|
|
1493
|
+
maxPayloadLength: parseWsSize("WS_MAX_PAYLOAD", process.env.WS_MAX_PAYLOAD),
|
|
1494
|
+
idleTimeout: parseSeconds("WS_IDLE_TIMEOUT", process.env.WS_IDLE_TIMEOUT, 120, 960),
|
|
1495
|
+
backpressureLimit: parseWsSize("WS_BACKPRESSURE_LIMIT", process.env.WS_BACKPRESSURE_LIMIT),
|
|
1496
|
+
perMessageDeflate: process.env.WS_COMPRESS === "true",
|
|
1497
|
+
};
|
|
1498
|
+
|
|
1421
1499
|
// ─── Concurrency Ceiling ──────────────────────────────────
|
|
1422
1500
|
// Soft cap on in-flight requests, parsed from MAX_INFLIGHT env var.
|
|
1423
1501
|
// Default is unlimited so existing apps see no behavior change. When set,
|
|
@@ -1452,6 +1530,8 @@ let drainResolve: (() => void) | null = null;
|
|
|
1452
1530
|
/** Stop taking new requests; resolves once in-flight ones have finished. */
|
|
1453
1531
|
export function beginShutdown(): Promise<void> {
|
|
1454
1532
|
shuttingDown = true;
|
|
1533
|
+
// 1001 "going away": clients reconnect, to this server's replacement.
|
|
1534
|
+
closeAllSockets();
|
|
1455
1535
|
if (inFlight === 0) return Promise.resolve();
|
|
1456
1536
|
return new Promise<void>((r) => {
|
|
1457
1537
|
drainResolve = r;
|
|
@@ -1516,6 +1596,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise<BosiaAp
|
|
|
1516
1596
|
// splits traffic between two different builds. Opt in only for deliberate
|
|
1517
1597
|
// N-worker clustering.
|
|
1518
1598
|
reusePort: process.env.BOSIA_REUSE_PORT === "1",
|
|
1599
|
+
websocket: WEBSOCKET_OPTIONS,
|
|
1519
1600
|
},
|
|
1520
1601
|
});
|
|
1521
1602
|
|
|
@@ -41,6 +41,7 @@ export default {
|
|
|
41
41
|
fetch(request: Request, env: PlatformEnv): Response | Promise<Response> {
|
|
42
42
|
// Bindings are one object per isolate — capture on the first request.
|
|
43
43
|
if (!getPlatform()) setPlatform({ env });
|
|
44
|
-
|
|
44
|
+
// No `server` passed, so nothing is ever upgraded and a Response always comes back.
|
|
45
|
+
return app.fetch(request) as Promise<Response>;
|
|
45
46
|
},
|
|
46
47
|
};
|