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/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";
|
|
@@ -72,8 +73,18 @@ import {
|
|
|
72
73
|
renderSSRStream,
|
|
73
74
|
renderErrorPage,
|
|
74
75
|
renderPageWithFormData,
|
|
76
|
+
routeCacheFlag,
|
|
75
77
|
} from "./renderer.ts";
|
|
76
78
|
import { getServerTime } from "../lib/utils.ts";
|
|
79
|
+
import {
|
|
80
|
+
closeAllSockets,
|
|
81
|
+
isUpgradeRequest,
|
|
82
|
+
isUpgraded,
|
|
83
|
+
closeUpgraded,
|
|
84
|
+
upgradeRequest,
|
|
85
|
+
type WebSocketHandler,
|
|
86
|
+
type WebSocketOptions,
|
|
87
|
+
} from "./websocket.ts";
|
|
77
88
|
|
|
78
89
|
// ─── User Hooks ──────────────────────────────────────────
|
|
79
90
|
// Set by createApp(). Each runtime entry finds `src/hooks.server.ts` its own
|
|
@@ -213,7 +224,7 @@ function parseDataRequest(url: URL): DataRequest | "invalid" | null {
|
|
|
213
224
|
let fresh = false;
|
|
214
225
|
for (const [key, val] of url.searchParams.entries()) {
|
|
215
226
|
if (key === "_invalidated") {
|
|
216
|
-
invalidatedBits = val;
|
|
227
|
+
invalidatedBits = val; // last wins: the client appends its mask after the page's query
|
|
217
228
|
continue;
|
|
218
229
|
}
|
|
219
230
|
// Set by the client router when the fetch follows an invalidate():
|
|
@@ -224,6 +235,11 @@ function parseDataRequest(url: URL): DataRequest | "invalid" | null {
|
|
|
224
235
|
}
|
|
225
236
|
routeUrl.searchParams.append(key, val);
|
|
226
237
|
}
|
|
238
|
+
// Only a 0/1 mask counts: it goes into the cache key, where a stray `?` or
|
|
239
|
+
// `|o=` would file the entry under the wrong path for invalidate(). Anything
|
|
240
|
+
// else runs and sends every loader, as having no mask does. A page's own 0/1
|
|
241
|
+
// `?_invalidated=` with no client mask after it is still read as the mask.
|
|
242
|
+
if (invalidatedBits !== null && !/^[01]*$/.test(invalidatedBits)) invalidatedBits = null;
|
|
227
243
|
return { routeUrl, invalidatedBits, fresh };
|
|
228
244
|
}
|
|
229
245
|
|
|
@@ -281,17 +297,9 @@ function routesFor(
|
|
|
281
297
|
|
|
282
298
|
const jsonEncoder = new TextEncoder();
|
|
283
299
|
|
|
284
|
-
/** A page route's `export const cache` flag: the build-time read when it has
|
|
285
|
-
* one, otherwise the compiled page module's own export (see renderer.ts). */
|
|
286
|
-
async function pageRouteCacheable(route: (typeof serverRoutes)[number]): Promise<boolean> {
|
|
287
|
-
const flag = (route as { cache?: boolean | null }).cache;
|
|
288
|
-
if (flag === true || flag === false) return flag;
|
|
289
|
-
return (await route.pageModule()).cache !== false;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
300
|
/**
|
|
293
301
|
* Decode an `_invalidated` bitmask string. Char 0 = page, char i+1 = layout
|
|
294
|
-
* depth i. Only
|
|
302
|
+
* depth i. Only 0/1 reach here: parseDataRequest drops any other mask.
|
|
295
303
|
* - Page '0': skip the page loader.
|
|
296
304
|
* - Layout '0': omit its data from the response. The loader still runs,
|
|
297
305
|
* since it may be a guard.
|
|
@@ -375,6 +383,11 @@ async function serveStaticFile(path: string, request: Request): Promise<Response
|
|
|
375
383
|
return null;
|
|
376
384
|
}
|
|
377
385
|
|
|
386
|
+
// Not Response.redirect(): it rejects relative URLs on Workers.
|
|
387
|
+
function redirectResponse(r: Redirect): Response {
|
|
388
|
+
return new Response(null, { status: r.status, headers: { Location: r.location } });
|
|
389
|
+
}
|
|
390
|
+
|
|
378
391
|
async function resolve(event: RequestEvent): Promise<Response> {
|
|
379
392
|
const { request, url, locals, cookies } = event;
|
|
380
393
|
const path = url.pathname;
|
|
@@ -473,7 +486,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
473
486
|
!warmingUp &&
|
|
474
487
|
method === "GET" &&
|
|
475
488
|
pageMatch !== null &&
|
|
476
|
-
(await
|
|
489
|
+
(await routeCacheFlag(pageMatch.route));
|
|
477
490
|
if (dataCacheable && !fresh) {
|
|
478
491
|
// Tracking params (CACHE_IGNORE_PARAMS): the plain URL's entry
|
|
479
492
|
// first, then this URL's own, as before — see cache.ts.
|
|
@@ -650,12 +663,42 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
650
663
|
let releaseApiMiss: (() => void) | null = null;
|
|
651
664
|
try {
|
|
652
665
|
const mod = await apiMatch.route.module();
|
|
666
|
+
|
|
667
|
+
// WebSocket upgrade — before the response cache, which must never see it.
|
|
668
|
+
const ws = (mod as { websocket?: WebSocketHandler<any, any> }).websocket;
|
|
669
|
+
if (ws && isUpgradeRequest(request)) {
|
|
670
|
+
event.params = apiMatch.params;
|
|
671
|
+
const result = ws.upgrade ? await ws.upgrade(event) : undefined;
|
|
672
|
+
if (result instanceof Response) return result;
|
|
673
|
+
if (isRedirect(result)) {
|
|
674
|
+
return redirectResponse(result);
|
|
675
|
+
}
|
|
676
|
+
if (result !== undefined && (result === null || typeof result !== "object")) {
|
|
677
|
+
throw new Error(
|
|
678
|
+
`websocket.upgrade() in ${apiMatch.route.id} must return an object, a Response or nothing`,
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
// Cookies a hook set before resolve() ride on the 101; later ones are lost.
|
|
682
|
+
const headers = new Headers();
|
|
683
|
+
for (const cookie of (cookies as CookieJar).outgoing) headers.append("Set-Cookie", cookie);
|
|
684
|
+
return upgradeRequest(ws, result ?? {}, headers);
|
|
685
|
+
}
|
|
686
|
+
|
|
653
687
|
// HEAD falls back to GET (an explicit HEAD export still wins). BosiaApp
|
|
654
688
|
// strips the body on the way out, for every route kind.
|
|
655
689
|
const handler = mod[method] ?? (method === "HEAD" ? mod.GET : undefined);
|
|
656
690
|
|
|
657
691
|
if (!handler) {
|
|
692
|
+
// A websocket-only route asked over plain HTTP.
|
|
693
|
+
if (ws && (method === "GET" || method === "HEAD")) {
|
|
694
|
+
return new Response("Upgrade Required", {
|
|
695
|
+
status: 426,
|
|
696
|
+
headers: { Upgrade: "websocket", Connection: "Upgrade" },
|
|
697
|
+
});
|
|
698
|
+
}
|
|
658
699
|
const allowed = Object.keys(mod).filter((k) => /^[A-Z]+$/.test(k));
|
|
700
|
+
// A websocket route takes GET (the upgrade's method); HEAD answers 426.
|
|
701
|
+
if (ws && !allowed.includes("GET")) allowed.push("GET");
|
|
659
702
|
if (allowed.includes("GET") && !allowed.includes("HEAD")) allowed.push("HEAD");
|
|
660
703
|
return Response.json(
|
|
661
704
|
{ error: `Method ${method} not allowed` },
|
|
@@ -709,10 +752,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
709
752
|
|
|
710
753
|
// Redirect returned (not thrown) — convert to a 303 Response.
|
|
711
754
|
if (isRedirect(handlerResult)) {
|
|
712
|
-
return
|
|
713
|
-
status: handlerResult.status,
|
|
714
|
-
headers: { Location: handlerResult.location },
|
|
715
|
-
});
|
|
755
|
+
return redirectResponse(handlerResult);
|
|
716
756
|
}
|
|
717
757
|
|
|
718
758
|
const response = handlerResult as Response;
|
|
@@ -784,10 +824,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
784
824
|
// `throw redirect(303, "/")` from a +server.ts handler — turn it into
|
|
785
825
|
// a real 303 instead of a 500. Mirrors the page-action handler below.
|
|
786
826
|
if (isRedirect(err)) {
|
|
787
|
-
return
|
|
788
|
-
status: err.status,
|
|
789
|
-
headers: { Location: err.location },
|
|
790
|
-
});
|
|
827
|
+
return redirectResponse(err);
|
|
791
828
|
}
|
|
792
829
|
if (isHttpError(err)) {
|
|
793
830
|
return Response.json({ error: err.message }, { status: err.status });
|
|
@@ -1071,9 +1108,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
1071
1108
|
// (preview/proxy hubs, design tools, etc.). Other security headers stay on.
|
|
1072
1109
|
const _xfoDisabled = process.env.DISABLE_X_FRAME_OPTIONS === "true";
|
|
1073
1110
|
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1076
|
-
//
|
|
1111
|
+
// Behind a reverse proxy, rebuild the request url from X-Forwarded-Host/-Proto
|
|
1112
|
+
// (applyForwarded): event.url, redirects, the CSRF/WebSocket origin checks and
|
|
1113
|
+
// the `Secure` cookie flag all follow it. Off by default — the headers are
|
|
1114
|
+
// spoofable from any client that talks directly to the app.
|
|
1077
1115
|
const TRUST_PROXY = process.env.TRUST_PROXY === "true";
|
|
1078
1116
|
|
|
1079
1117
|
const SECURITY_HEADERS: Record<string, string> = {
|
|
@@ -1153,19 +1191,7 @@ function finalizeResponse(
|
|
|
1153
1191
|
}
|
|
1154
1192
|
|
|
1155
1193
|
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
|
-
}
|
|
1194
|
+
applyForwarded(request, url, TRUST_PROXY);
|
|
1169
1195
|
|
|
1170
1196
|
// Mounted under BASE_PATH? Everything downstream — hooks, the router, the
|
|
1171
1197
|
// /_health and /__bosia tests below — works in app space, so the prefix comes
|
|
@@ -1178,6 +1204,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1178
1204
|
url.pathname = appPath;
|
|
1179
1205
|
}
|
|
1180
1206
|
|
|
1207
|
+
// No page lives at a `//x` path: anything that echoes it as a URL (the trailing-slash
|
|
1208
|
+
// 308, `page.url`, the client router) would read it as host x. Collapse the slashes.
|
|
1209
|
+
if (url.pathname.startsWith("//")) {
|
|
1210
|
+
return new Response(null, {
|
|
1211
|
+
status: 308,
|
|
1212
|
+
// `base +`, not withBase: withBase skips a path already under base, so `/sso//sso/x` would lose it.
|
|
1213
|
+
headers: { Location: base + url.pathname.replace(/^\/+/, "/") + url.search },
|
|
1214
|
+
});
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1181
1217
|
// Bun.serve enforces maxRequestBodySize itself; Workers has no such knob, so
|
|
1182
1218
|
// check the declared length here. A chunked upload without Content-Length
|
|
1183
1219
|
// falls back to the host's own request cap (Cloudflare: 100MB on free).
|
|
@@ -1221,11 +1257,18 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1221
1257
|
console.warn(`[CSRF] Blocked request: ${csrfError}`);
|
|
1222
1258
|
return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
|
|
1223
1259
|
}
|
|
1260
|
+
// A WebSocket upgrade is a GET, so checkCsrf skips it. Checked here, before
|
|
1261
|
+
// the hooks, so a cross-site attempt never reaches a session lookup.
|
|
1262
|
+
const upgradeError = isUpgradeRequest(request)
|
|
1263
|
+
? checkUpgradeOrigin(request, url, CSRF_CONFIG)
|
|
1264
|
+
: null;
|
|
1265
|
+
if (upgradeError) {
|
|
1266
|
+
console.warn(`[CSRF] Blocked WebSocket upgrade: ${upgradeError}`);
|
|
1267
|
+
return Response.json({ error: "Forbidden", message: upgradeError }, { status: 403 });
|
|
1268
|
+
}
|
|
1224
1269
|
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
url.protocol === "https:";
|
|
1228
|
-
cookieJar = new CookieJar(request.headers.get("cookie") ?? "", isHttps);
|
|
1270
|
+
// url already carries the forwarded scheme when TRUST_PROXY is on.
|
|
1271
|
+
cookieJar = new CookieJar(request.headers.get("cookie") ?? "", url.protocol === "https:");
|
|
1229
1272
|
nonce = CSP_ENABLED ? generateNonce() : "";
|
|
1230
1273
|
|
|
1231
1274
|
// Decode the data endpoint before the hooks, not inside resolve(): a guard
|
|
@@ -1277,12 +1320,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1277
1320
|
response ??=
|
|
1278
1321
|
userHandle && !warmingUp ? await userHandle({ event, resolve }) : await resolve(event);
|
|
1279
1322
|
|
|
1323
|
+
// Bun already answered with 101; there is no response left to finish.
|
|
1324
|
+
if (isUpgraded()) return response;
|
|
1325
|
+
|
|
1280
1326
|
// A hook that short-circuits a data request with a redirect is answering
|
|
1281
1327
|
// the client router, which speaks JSON — an unconverted 3xx is followed by
|
|
1282
1328
|
// `fetch` and the router receives the redirect target's HTML instead.
|
|
1283
1329
|
// `Location` is copied verbatim: `redirect()` already rebased it through
|
|
1284
1330
|
// `withBase()`, and a raw `Response.redirect` under a BASE_PATH carries the
|
|
1285
1331
|
// base by hand, so rebasing here would double the prefix on both.
|
|
1332
|
+
|
|
1286
1333
|
if (dataReq && response.status >= 300 && response.status < 400) {
|
|
1287
1334
|
const location = response.headers.get("location");
|
|
1288
1335
|
if (location) {
|
|
@@ -1301,6 +1348,13 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1301
1348
|
|
|
1302
1349
|
return finalizeResponse(response, request, nonce, cookieJar);
|
|
1303
1350
|
} catch (err) {
|
|
1351
|
+
// Bun already sent the 101, so there is no 500 to send: close the socket
|
|
1352
|
+
// with 1011, the way a handler error does.
|
|
1353
|
+
if (isUpgraded()) {
|
|
1354
|
+
console.error("Hook error after the WebSocket upgrade (socket closed with 1011):", err);
|
|
1355
|
+
closeUpgraded();
|
|
1356
|
+
return new Response(null);
|
|
1357
|
+
}
|
|
1304
1358
|
// `throw redirect()` / `throw error()` from a hook lands here — the same
|
|
1305
1359
|
// escape hatch loaders have always had. Without these branches both fall
|
|
1306
1360
|
// through to the 500 below, which is why the docs could only ever suggest
|
|
@@ -1311,8 +1365,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1311
1365
|
? // Shape-identical to the loader conversion below, so the
|
|
1312
1366
|
// router has one payload contract regardless of who redirected.
|
|
1313
1367
|
Response.json({ redirect: err.location, status: err.status })
|
|
1314
|
-
:
|
|
1315
|
-
new Response(null, { status: err.status, headers: { Location: err.location } })
|
|
1368
|
+
: redirectResponse(err)
|
|
1316
1369
|
: dataReq
|
|
1317
1370
|
? Response.json(
|
|
1318
1371
|
{
|
|
@@ -1379,11 +1432,11 @@ function parseCorsMaxAge(value?: string): number | undefined {
|
|
|
1379
1432
|
// Format: "512K", "1M", "1G", plain bytes, or "Infinity".
|
|
1380
1433
|
// Default: 512K (matches SvelteKit).
|
|
1381
1434
|
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
if (value === "Infinity") return 0;
|
|
1435
|
+
// "Infinity" → 0, Bun's "no limit". `key` names the variable in the error.
|
|
1436
|
+
function parseSize(key: string, value: string): number {
|
|
1437
|
+
if (value === "Infinity") return 0;
|
|
1385
1438
|
const match = value.match(/^(\d+(?:\.\d+)?)\s*([KMG]?)$/i);
|
|
1386
|
-
if (!match) throw new Error(`Invalid
|
|
1439
|
+
if (!match) throw new Error(`Invalid ${key}: "${value}"`);
|
|
1387
1440
|
const num = parseFloat(match[1]);
|
|
1388
1441
|
const unit = match[2].toUpperCase();
|
|
1389
1442
|
if (unit === "K") return Math.floor(num * 1024);
|
|
@@ -1392,7 +1445,9 @@ function parseBodySizeLimit(value?: string): number {
|
|
|
1392
1445
|
return Math.floor(num);
|
|
1393
1446
|
}
|
|
1394
1447
|
|
|
1395
|
-
const BODY_SIZE_LIMIT =
|
|
1448
|
+
const BODY_SIZE_LIMIT = process.env.BODY_SIZE_LIMIT
|
|
1449
|
+
? parseSize("BODY_SIZE_LIMIT", process.env.BODY_SIZE_LIMIT)
|
|
1450
|
+
: 512 * 1024;
|
|
1396
1451
|
|
|
1397
1452
|
if (BODY_SIZE_LIMIT === 0) {
|
|
1398
1453
|
console.log("📦 Body size limit: none");
|
|
@@ -1406,18 +1461,39 @@ if (BODY_SIZE_LIMIT === 0) {
|
|
|
1406
1461
|
// Raise when API routes hold streaming responses with long gaps
|
|
1407
1462
|
// between chunks (e.g. AI tool calls that shell out and wait).
|
|
1408
1463
|
|
|
1409
|
-
|
|
1410
|
-
|
|
1464
|
+
// Whole seconds, 0..max. parseInt-lenient ("10s" reads as 10), as it always was.
|
|
1465
|
+
function parseSeconds(key: string, value: string | undefined, fallback: number, max: number) {
|
|
1466
|
+
if (!value) return fallback;
|
|
1411
1467
|
const n = parseInt(value, 10);
|
|
1412
|
-
if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid
|
|
1413
|
-
if (n >
|
|
1468
|
+
if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid ${key}: "${value}"`);
|
|
1469
|
+
if (n > max) throw new Error(`Invalid ${key}: "${value}" (max ${max})`);
|
|
1414
1470
|
return n;
|
|
1415
1471
|
}
|
|
1416
1472
|
|
|
1417
|
-
const IDLE_TIMEOUT =
|
|
1473
|
+
const IDLE_TIMEOUT = parseSeconds("IDLE_TIMEOUT", process.env.IDLE_TIMEOUT, 10, 255);
|
|
1418
1474
|
|
|
1419
1475
|
console.log(`⏱ Idle timeout: ${IDLE_TIMEOUT}s`);
|
|
1420
1476
|
|
|
1477
|
+
// ─── WebSocket Limits ─────────────────────────────────────
|
|
1478
|
+
// Parsed once at startup. Sizes take the BODY_SIZE_LIMIT format ("16M").
|
|
1479
|
+
// WS_IDLE_TIMEOUT is seconds without a frame before Bun closes the socket; Bun
|
|
1480
|
+
// pings idle sockets on its own, so a live peer never hits it.
|
|
1481
|
+
|
|
1482
|
+
// Bun has no "no limit" for these, so 0 and "Infinity" are refused.
|
|
1483
|
+
function parseWsSize(key: string, value?: string): number {
|
|
1484
|
+
if (!value) return 16 * 1024 * 1024;
|
|
1485
|
+
const n = parseSize(key, value);
|
|
1486
|
+
if (n === 0) throw new Error(`Invalid ${key}: "${value}" (must be above 0)`);
|
|
1487
|
+
return n;
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const WEBSOCKET_OPTIONS: WebSocketOptions = {
|
|
1491
|
+
maxPayloadLength: parseWsSize("WS_MAX_PAYLOAD", process.env.WS_MAX_PAYLOAD),
|
|
1492
|
+
idleTimeout: parseSeconds("WS_IDLE_TIMEOUT", process.env.WS_IDLE_TIMEOUT, 120, 960),
|
|
1493
|
+
backpressureLimit: parseWsSize("WS_BACKPRESSURE_LIMIT", process.env.WS_BACKPRESSURE_LIMIT),
|
|
1494
|
+
perMessageDeflate: process.env.WS_COMPRESS === "true",
|
|
1495
|
+
};
|
|
1496
|
+
|
|
1421
1497
|
// ─── Concurrency Ceiling ──────────────────────────────────
|
|
1422
1498
|
// Soft cap on in-flight requests, parsed from MAX_INFLIGHT env var.
|
|
1423
1499
|
// Default is unlimited so existing apps see no behavior change. When set,
|
|
@@ -1452,6 +1528,8 @@ let drainResolve: (() => void) | null = null;
|
|
|
1452
1528
|
/** Stop taking new requests; resolves once in-flight ones have finished. */
|
|
1453
1529
|
export function beginShutdown(): Promise<void> {
|
|
1454
1530
|
shuttingDown = true;
|
|
1531
|
+
// 1001 "going away": clients reconnect, to this server's replacement.
|
|
1532
|
+
closeAllSockets();
|
|
1455
1533
|
if (inFlight === 0) return Promise.resolve();
|
|
1456
1534
|
return new Promise<void>((r) => {
|
|
1457
1535
|
drainResolve = r;
|
|
@@ -1480,7 +1558,6 @@ function loadBuiltManifest(): RouteManifest {
|
|
|
1480
1558
|
layoutServers: [],
|
|
1481
1559
|
errorPages: [],
|
|
1482
1560
|
trailingSlash: r.trailingSlash,
|
|
1483
|
-
cache: r.cache ?? null,
|
|
1484
1561
|
prerender: false,
|
|
1485
1562
|
})),
|
|
1486
1563
|
apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, id: r.id, server: "" })),
|
|
@@ -1516,6 +1593,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise<BosiaAp
|
|
|
1516
1593
|
// splits traffic between two different builds. Opt in only for deliberate
|
|
1517
1594
|
// N-worker clustering.
|
|
1518
1595
|
reusePort: process.env.BOSIA_REUSE_PORT === "1",
|
|
1596
|
+
websocket: WEBSOCKET_OPTIONS,
|
|
1519
1597
|
},
|
|
1520
1598
|
});
|
|
1521
1599
|
|
|
@@ -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
|
};
|
package/src/core/types.ts
CHANGED
|
@@ -28,14 +28,6 @@ export interface PageRoute {
|
|
|
28
28
|
errorPages: { path: string; depth: number }[];
|
|
29
29
|
/** Effective trailing-slash mode (page wins over layout chain). Defaults to "never". */
|
|
30
30
|
trailingSlash: TrailingSlash;
|
|
31
|
-
/**
|
|
32
|
-
* Build-time read of `export const cache` in +page.svelte, so the renderer can
|
|
33
|
-
* decide cacheability on a cache hit without importing the page module:
|
|
34
|
-
* `true` — no `cache` export → cacheable
|
|
35
|
-
* `false` — statically `cache = false` → opted out
|
|
36
|
-
* `null` — a `cache` export exists but isn't a literal `false` (dynamic) → import at runtime
|
|
37
|
-
*/
|
|
38
|
-
cache: boolean | null;
|
|
39
31
|
/** `export const prerender = true` in +page.server.ts — its data is a static file at runtime. */
|
|
40
32
|
prerender: boolean;
|
|
41
33
|
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// ─── WebSockets (Bun only) ────────────────────────────────
|
|
2
|
+
// A `+server.ts` exports `websocket = { upgrade?, open, message, close, … }`.
|
|
3
|
+
// resolve() runs `upgrade()` after the hooks, then hands the request to Bun's
|
|
4
|
+
// `server.upgrade()`. Bun.serve takes one websocket handler for the whole
|
|
5
|
+
// server, so the upgrade carries the route's handlers in an envelope; open()
|
|
6
|
+
// keeps them per socket and sets `ws.data` to what upgrade() returned.
|
|
7
|
+
//
|
|
8
|
+
// Subprotocols are not selectable yet: `upgrade()` returns only data. To add
|
|
9
|
+
// it, let the export declare `protocols: string[]` and send the first one the
|
|
10
|
+
// client offers as `Sec-WebSocket-Protocol` in server.upgrade's headers. The
|
|
11
|
+
// dev proxy already passes it through.
|
|
12
|
+
|
|
13
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
14
|
+
|
|
15
|
+
import type { Server, ServerWebSocket, WebSocketHandler as BunWebSocketHandler } from "bun";
|
|
16
|
+
|
|
17
|
+
import type { RequestEvent } from "./hooks.ts";
|
|
18
|
+
|
|
19
|
+
/** What a `+server.ts` exports as `websocket`. `ws` is Bun's ServerWebSocket. */
|
|
20
|
+
export interface WebSocketHandler<T extends object = {}, E = RequestEvent> {
|
|
21
|
+
/**
|
|
22
|
+
* Runs after `hooks.server.ts`, so `event.locals` is set. Return the
|
|
23
|
+
* connection's `ws.data` (a fresh object per connection), or a Response to
|
|
24
|
+
* refuse. A thrown `error()` / `redirect()` answers like an API handler.
|
|
25
|
+
*/
|
|
26
|
+
upgrade?(event: E): T | Response | void | Promise<T | Response | void>;
|
|
27
|
+
open?(ws: ServerWebSocket<T>): unknown;
|
|
28
|
+
/** `string` for text frames, `Buffer` for binary frames. */
|
|
29
|
+
message?(ws: ServerWebSocket<T>, message: string | Buffer): unknown;
|
|
30
|
+
/** The socket can take more data again after `send()` returned -1. */
|
|
31
|
+
drain?(ws: ServerWebSocket<T>): unknown;
|
|
32
|
+
close?(ws: ServerWebSocket<T>, code: number, reason: string): unknown;
|
|
33
|
+
ping?(ws: ServerWebSocket<T>, data: Buffer): unknown;
|
|
34
|
+
pong?(ws: ServerWebSocket<T>, data: Buffer): unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface WebSocketOptions {
|
|
38
|
+
maxPayloadLength: number;
|
|
39
|
+
idleTimeout: number;
|
|
40
|
+
backpressureLimit: number;
|
|
41
|
+
perMessageDeflate: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type Socket = ServerWebSocket<any>;
|
|
45
|
+
|
|
46
|
+
/** What server.upgrade() carries until open() unwraps it. */
|
|
47
|
+
class Envelope {
|
|
48
|
+
constructor(
|
|
49
|
+
readonly handler: WebSocketHandler<any, any>,
|
|
50
|
+
readonly data: object,
|
|
51
|
+
) {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Per socket, not on ws.data: one object returned by two routes (a cached
|
|
55
|
+
// session user, say) must not carry either route's handlers.
|
|
56
|
+
const routeOf = new WeakMap<Socket, WebSocketHandler<any, any>>();
|
|
57
|
+
|
|
58
|
+
// Sec-WebSocket-Key too: a stray Upgrade header on a plain GET (a proxy, a
|
|
59
|
+
// scanner) must reach the GET handler, not run upgrade() and then 400.
|
|
60
|
+
export function isUpgradeRequest(request: Request): boolean {
|
|
61
|
+
return (
|
|
62
|
+
request.method === "GET" &&
|
|
63
|
+
request.headers.get("upgrade")?.toLowerCase() === "websocket" &&
|
|
64
|
+
request.headers.has("sec-websocket-key")
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// One per upgrade request, set by BosiaApp.fetch. It holds the Request Bun gave
|
|
69
|
+
// (server.upgrade() needs that one, and a hook may pass resolve() a new one)
|
|
70
|
+
// and whether the upgrade happened — a per-request flag, so a hook that
|
|
71
|
+
// rebuilds or mutates the returned Response can't hide or leak it.
|
|
72
|
+
interface UpgradeContext {
|
|
73
|
+
request?: Request;
|
|
74
|
+
server?: Server<any>;
|
|
75
|
+
upgraded: boolean;
|
|
76
|
+
/** Set by open(), which Bun runs inside server.upgrade(). */
|
|
77
|
+
socket?: Socket;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const upgrades = new AsyncLocalStorage<UpgradeContext>();
|
|
81
|
+
|
|
82
|
+
export async function runUpgrade<T>(
|
|
83
|
+
request: Request,
|
|
84
|
+
server: Server<any>,
|
|
85
|
+
fn: () => Promise<T>,
|
|
86
|
+
): Promise<T> {
|
|
87
|
+
const ctx: UpgradeContext = { request, server, upgraded: false };
|
|
88
|
+
try {
|
|
89
|
+
return await upgrades.run(ctx, fn);
|
|
90
|
+
} finally {
|
|
91
|
+
// A timer or pool started during this request keeps the context: it must
|
|
92
|
+
// not hold the Request, nor see isUpgraded() in its own later fetches.
|
|
93
|
+
ctx.request = ctx.server = ctx.socket = undefined;
|
|
94
|
+
ctx.upgraded = false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** True once this request has been upgraded: Bun already sent the 101. */
|
|
99
|
+
export function isUpgraded(): boolean {
|
|
100
|
+
return upgrades.getStore()?.upgraded === true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A hook threw after the 101: close the socket, as a handler error would. */
|
|
104
|
+
export function closeUpgraded(): void {
|
|
105
|
+
const ws = upgrades.getStore()?.socket;
|
|
106
|
+
if (ws) closeEach([ws], 1011, "Internal error");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let closing = false;
|
|
110
|
+
|
|
111
|
+
// Bun answers without a protocol, so a browser that asked for one fails the
|
|
112
|
+
// handshake with no error on the server. Warn once per route, not per connect.
|
|
113
|
+
const warnedProtocol = new WeakSet<WebSocketHandler<any, any>>();
|
|
114
|
+
|
|
115
|
+
function warnProtocol(handler: WebSocketHandler<any, any>, request: Request): void {
|
|
116
|
+
const asked = request.headers.get("sec-websocket-protocol");
|
|
117
|
+
if (!asked || warnedProtocol.has(handler)) return;
|
|
118
|
+
warnedProtocol.add(handler);
|
|
119
|
+
console.warn(
|
|
120
|
+
`[WebSocket] ${new URL(request.url).pathname}: the client asked for Sec-WebSocket-Protocol ` +
|
|
121
|
+
`"${asked}", but Bosia can't pick one yet. Browsers fail this handshake; drop the ` +
|
|
122
|
+
`protocols argument from new WebSocket().`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Upgrade the current request with `data` as `ws.data`. Returns a placeholder
|
|
128
|
+
* Response (Bun ignores it; isUpgraded() is the signal), or the Response to
|
|
129
|
+
* send instead (bad handshake, a runtime without WebSockets).
|
|
130
|
+
*/
|
|
131
|
+
export function upgradeRequest(
|
|
132
|
+
handler: WebSocketHandler<any, any>,
|
|
133
|
+
data: object,
|
|
134
|
+
headers: Headers,
|
|
135
|
+
): Response {
|
|
136
|
+
const ctx = upgrades.getStore();
|
|
137
|
+
if (!ctx?.request || !ctx.server) {
|
|
138
|
+
return new Response("WebSockets are not supported here", { status: 501 });
|
|
139
|
+
}
|
|
140
|
+
warnProtocol(handler, ctx.request);
|
|
141
|
+
if (!ctx.server.upgrade(ctx.request, { data: new Envelope(handler, data) as any, headers })) {
|
|
142
|
+
return new Response("Bad WebSocket handshake", { status: 400 });
|
|
143
|
+
}
|
|
144
|
+
ctx.upgraded = true;
|
|
145
|
+
return new Response(null, { status: 200 });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ─── Shared Bun handlers ─────────────────────────────────
|
|
149
|
+
|
|
150
|
+
const sockets = new Set<Socket>();
|
|
151
|
+
|
|
152
|
+
function fail(ws: Socket, err: unknown): void {
|
|
153
|
+
console.error("WebSocket handler error:", err);
|
|
154
|
+
closeEach([ws], 1011, "Internal error");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A user handler's throw or rejection closes that socket with 1011; it must
|
|
158
|
+
// never reach the prod `uncaughtException` handler, which exits the process.
|
|
159
|
+
//
|
|
160
|
+
// Bun runs open() inside server.upgrade(), so in the upgrade request's async
|
|
161
|
+
// context; exit() it, so a timer or callback started in open() doesn't hold
|
|
162
|
+
// that request or see isUpgraded() in its own in-process fetches. (Other
|
|
163
|
+
// events run outside it; exit() is then a no-op.)
|
|
164
|
+
function run(ws: Socket, name: Exclude<keyof WebSocketHandler, "upgrade">, args: unknown[]): void {
|
|
165
|
+
upgrades.exit(() => call(ws, name, args));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function call(ws: Socket, name: Exclude<keyof WebSocketHandler, "upgrade">, args: unknown[]): void {
|
|
169
|
+
const handler = routeOf.get(ws);
|
|
170
|
+
const fn = handler?.[name] as ((...a: unknown[]) => unknown) | undefined;
|
|
171
|
+
if (!fn) return;
|
|
172
|
+
try {
|
|
173
|
+
const out = fn.call(handler, ws, ...args);
|
|
174
|
+
if (out instanceof Promise) out.catch((err) => fail(ws, err));
|
|
175
|
+
} catch (err) {
|
|
176
|
+
fail(ws, err);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const websocketHandlers: BunWebSocketHandler<any> = {
|
|
181
|
+
open(ws) {
|
|
182
|
+
// The very object upgrade() returned becomes ws.data (an app may keep it
|
|
183
|
+
// in a registry).
|
|
184
|
+
const envelope = ws.data as Envelope;
|
|
185
|
+
routeOf.set(ws, envelope.handler);
|
|
186
|
+
ws.data = envelope.data;
|
|
187
|
+
sockets.add(ws);
|
|
188
|
+
const ctx = upgrades.getStore();
|
|
189
|
+
if (ctx) ctx.socket = ws;
|
|
190
|
+
run(ws, "open", []);
|
|
191
|
+
// An upgrade still in its async upgrade() during the shutdown sweep opens
|
|
192
|
+
// after it: the app has seen open() and gets close(), so it can clean up.
|
|
193
|
+
if (closing) ws.close(1001, "Server shutting down");
|
|
194
|
+
},
|
|
195
|
+
message(ws, message) {
|
|
196
|
+
run(ws, "message", [message]);
|
|
197
|
+
},
|
|
198
|
+
drain(ws) {
|
|
199
|
+
run(ws, "drain", []);
|
|
200
|
+
},
|
|
201
|
+
close(ws, code, reason) {
|
|
202
|
+
sockets.delete(ws);
|
|
203
|
+
run(ws, "close", [code, reason]);
|
|
204
|
+
},
|
|
205
|
+
ping(ws, data) {
|
|
206
|
+
run(ws, "ping", [data]);
|
|
207
|
+
},
|
|
208
|
+
pong(ws, data) {
|
|
209
|
+
run(ws, "pong", [data]);
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/** Shutdown: close every open socket with 1001 (clients reconnect elsewhere),
|
|
214
|
+
* and any that opens from here on. */
|
|
215
|
+
export function closeAllSockets(): void {
|
|
216
|
+
closing = true;
|
|
217
|
+
closeEach(sockets, 1001, "Server shutting down");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function closeEach(
|
|
221
|
+
list: Iterable<ServerWebSocket<any>>,
|
|
222
|
+
code: number,
|
|
223
|
+
reason: string,
|
|
224
|
+
): void {
|
|
225
|
+
for (const ws of [...list]) {
|
|
226
|
+
try {
|
|
227
|
+
ws.close(code, reason);
|
|
228
|
+
} catch {
|
|
229
|
+
/* already closed */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
package/src/core/workersGuard.ts
CHANGED
|
@@ -19,6 +19,7 @@ export interface WorkersGuardHit {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
const SERVER_FILE = /(^|\/)(hooks\.server\.(ts|js)|\+server\.(ts|js)|\+[^/]*\.server\.(ts|js))$/;
|
|
22
|
+
const API_FILE = /(^|\/)\+server\.(ts|js)$/;
|
|
22
23
|
const LIB_SERVER = /^lib\/server\/.*\.(ts|js)$/;
|
|
23
24
|
const FS_MODULES = new Set(["fs", "node:fs", "fs/promises", "node:fs/promises"]);
|
|
24
25
|
|
|
@@ -54,13 +55,27 @@ export function findWorkersIncompatible(srcDir = "./src"): WorkersGuardHit[] {
|
|
|
54
55
|
const lines = source.split("\n");
|
|
55
56
|
|
|
56
57
|
let imports: { path: string }[] = [];
|
|
58
|
+
let exports: string[] = [];
|
|
57
59
|
try {
|
|
58
|
-
imports = new Bun.Transpiler({
|
|
59
|
-
|
|
60
|
-
).
|
|
60
|
+
({ imports, exports } = new Bun.Transpiler({
|
|
61
|
+
loader: rel.endsWith(".js") ? "js" : "ts",
|
|
62
|
+
}).scan(source));
|
|
61
63
|
} catch {
|
|
62
64
|
continue; // syntax error — the real build reports it better
|
|
63
65
|
}
|
|
66
|
+
// Any export form (`const`, `let`, `export { websocket }`) shows up here.
|
|
67
|
+
if (API_FILE.test(rel) && exports.includes("websocket")) {
|
|
68
|
+
const i = Math.max(
|
|
69
|
+
0,
|
|
70
|
+
lines.findIndex((l) => /\bwebsocket\b/.test(l) && /\bexport\b/.test(l)),
|
|
71
|
+
);
|
|
72
|
+
hits.push({
|
|
73
|
+
file,
|
|
74
|
+
line: i + 1,
|
|
75
|
+
col: 1,
|
|
76
|
+
message: "exports `websocket` — WebSockets are Bun-only for now",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
64
79
|
for (const { path } of imports) {
|
|
65
80
|
const message = badImport(path);
|
|
66
81
|
if (!message) continue;
|
package/src/lib/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export type {
|
|
|
34
34
|
PlatformEnv,
|
|
35
35
|
} from "../core/hooks.ts";
|
|
36
36
|
export type { CsrfConfig } from "../core/csrf.ts";
|
|
37
|
+
export type { WebSocketHandler } from "../core/websocket.ts";
|
|
37
38
|
export type { CorsConfig } from "../core/cors.ts";
|
|
38
39
|
export { defineConfig } from "../core/types/plugin.ts";
|
|
39
40
|
export type {
|