bosia 1.2.5 → 1.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -6
- package/package.json +2 -1
- package/src/core/backend.ts +87 -3
- package/src/core/basePath.ts +30 -0
- package/src/core/cache.ts +378 -30
- package/src/core/client/App.svelte +19 -11
- package/src/core/client/hydrate.ts +17 -9
- package/src/core/client/navigation.ts +1 -1
- package/src/core/client/prefetch.ts +66 -41
- package/src/core/client/router.svelte.ts +41 -22
- package/src/core/cors.ts +17 -1
- package/src/core/csrf.ts +32 -19
- package/src/core/dedup.ts +51 -7
- package/src/core/dev.ts +11 -1
- package/src/core/devWsProxy.ts +144 -0
- package/src/core/env.ts +7 -0
- package/src/core/errors.ts +23 -6
- package/src/core/hooks.ts +1 -1
- package/src/core/html.ts +13 -1
- package/src/core/plugins/access-log.ts +128 -0
- package/src/core/prerender.ts +143 -6
- package/src/core/proxy.ts +48 -0
- package/src/core/renderer.ts +70 -29
- package/src/core/routeTypes.ts +16 -1
- package/src/core/server.bun.ts +6 -1
- package/src/core/server.ts +198 -77
- package/src/core/server.workers.ts +3 -2
- package/src/core/types/plugin.ts +2 -1
- package/src/core/websocket.ts +232 -0
- package/src/core/workersGuard.ts +18 -3
- package/src/lib/index.ts +1 -0
- package/templates/default/.env.example +20 -5
- package/templates/default/tsconfig.json +1 -1
- package/templates/demo/.env.example +20 -5
- package/templates/demo/src/hooks.server.ts +3 -12
- package/templates/demo/tsconfig.json +1 -1
- package/templates/shop/src/app.d.ts +0 -1
- package/templates/shop/src/hooks.server.ts +3 -11
- package/templates/shop/src/routes/+layout.server.ts +0 -1
- package/templates/shop/tsconfig.json +1 -1
- package/templates/store/src/app.d.ts +0 -1
- package/templates/store/src/hooks.server.ts +3 -11
- package/templates/store/src/routes/+layout.server.ts +0 -1
- package/templates/store/tsconfig.json +1 -1
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";
|
|
@@ -46,26 +47,43 @@ import {
|
|
|
46
47
|
import { dedup } from "./dedup.ts";
|
|
47
48
|
import {
|
|
48
49
|
CACHE_ENABLED,
|
|
49
|
-
|
|
50
|
+
CACHE_WAIT_TIMEOUT,
|
|
51
|
+
bodyFitsCache,
|
|
50
52
|
buildCompressedVariants,
|
|
51
53
|
cacheGet,
|
|
52
54
|
cacheSet,
|
|
53
|
-
|
|
55
|
+
claimMiss,
|
|
54
56
|
collectTags,
|
|
57
|
+
depsReadIgnoredParams,
|
|
58
|
+
stripIgnoredParams,
|
|
55
59
|
computeCacheKey,
|
|
56
60
|
deferCacheWrite,
|
|
61
|
+
isNoStoreCacheControl,
|
|
62
|
+
readBodyForCache,
|
|
57
63
|
serveCached,
|
|
64
|
+
varyBlocksCache,
|
|
58
65
|
warnUncoveredCookies,
|
|
59
66
|
} from "./cache.ts";
|
|
60
67
|
import { reportDevErrorFromCatch } from "./devErrorReport.ts";
|
|
61
68
|
import {
|
|
62
69
|
loadRouteData,
|
|
70
|
+
emptyDeps,
|
|
63
71
|
loadMetadata,
|
|
72
|
+
metadataFailed,
|
|
64
73
|
renderSSRStream,
|
|
65
74
|
renderErrorPage,
|
|
66
75
|
renderPageWithFormData,
|
|
67
76
|
} from "./renderer.ts";
|
|
68
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";
|
|
69
87
|
|
|
70
88
|
// ─── User Hooks ──────────────────────────────────────────
|
|
71
89
|
// Set by createApp(). Each runtime entry finds `src/hooks.server.ts` its own
|
|
@@ -78,13 +96,14 @@ let userHandle: Handle | null = null;
|
|
|
78
96
|
// Headers that must not be baked into a cache entry. Content-Length is
|
|
79
97
|
// recomputed by Bun, content-encoding/transfer-encoding depend on the chosen
|
|
80
98
|
// variant, and security/CORS/Set-Cookie headers are applied by handleRequest.
|
|
99
|
+
// A handler's own Vary is kept: serveCached adds Accept-Encoding to it, so a
|
|
100
|
+
// hit varies on the same headers as the miss did.
|
|
81
101
|
const NON_CACHEABLE_HEADERS = new Set([
|
|
82
102
|
"content-length",
|
|
83
103
|
"content-encoding",
|
|
84
104
|
"transfer-encoding",
|
|
85
105
|
"content-type",
|
|
86
106
|
"set-cookie",
|
|
87
|
-
"vary",
|
|
88
107
|
"x-bosia-cache",
|
|
89
108
|
]);
|
|
90
109
|
|
|
@@ -366,6 +385,11 @@ async function serveStaticFile(path: string, request: Request): Promise<Response
|
|
|
366
385
|
return null;
|
|
367
386
|
}
|
|
368
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
|
+
|
|
369
393
|
async function resolve(event: RequestEvent): Promise<Response> {
|
|
370
394
|
const { request, url, locals, cookies } = event;
|
|
371
395
|
const path = url.pathname;
|
|
@@ -406,6 +430,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
406
430
|
const runLoad = async () => {
|
|
407
431
|
// metadata() runs alongside the loaders; the page load() gets its
|
|
408
432
|
// `data`, as on SSR. A failure stays non-fatal here.
|
|
433
|
+
const metaDeps = emptyDeps();
|
|
409
434
|
const metaP = pageMatch
|
|
410
435
|
? loadMetadata(
|
|
411
436
|
pageMatch.route,
|
|
@@ -414,6 +439,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
414
439
|
locals,
|
|
415
440
|
cookies,
|
|
416
441
|
request,
|
|
442
|
+
metaDeps,
|
|
417
443
|
).catch(() => null)
|
|
418
444
|
: Promise.resolve(null);
|
|
419
445
|
const data = await loadRouteData(
|
|
@@ -438,7 +464,12 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
438
464
|
}
|
|
439
465
|
: null;
|
|
440
466
|
|
|
441
|
-
return {
|
|
467
|
+
return {
|
|
468
|
+
data,
|
|
469
|
+
metadata,
|
|
470
|
+
metaDeps,
|
|
471
|
+
cookiesAccessed: (cookies as CookieJar).accessed,
|
|
472
|
+
};
|
|
442
473
|
};
|
|
443
474
|
|
|
444
475
|
// ── Response cache for client-navigation data ──
|
|
@@ -446,9 +477,11 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
446
477
|
// (different masks return different slots), tag/path invalidation, and
|
|
447
478
|
// no CSP. Only GETs use it (the client router only sends GETs); a
|
|
448
479
|
// `_fresh` fetch after invalidate() skips the read only.
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
480
|
+
// Set when this request replaced a stuck leader: its dedup() call,
|
|
481
|
+
// started right after that gate, is replaced rather than joined.
|
|
482
|
+
let replaceDedupBefore = -Infinity;
|
|
483
|
+
const keySuffix = `|data${invalidatedBits ? `|m=${invalidatedBits}` : ""}`;
|
|
484
|
+
const cacheKey = computeCacheKey(routeUrl, request, cookies as CookieJar) + keySuffix;
|
|
452
485
|
const dataCacheable =
|
|
453
486
|
CACHE_ENABLED &&
|
|
454
487
|
!CSP_ENABLED &&
|
|
@@ -457,15 +490,27 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
457
490
|
pageMatch !== null &&
|
|
458
491
|
(await pageRouteCacheable(pageMatch.route));
|
|
459
492
|
if (dataCacheable && !fresh) {
|
|
493
|
+
// Tracking params (CACHE_IGNORE_PARAMS): the plain URL's entry
|
|
494
|
+
// first, then this URL's own, as before — see cache.ts.
|
|
495
|
+
const plainUrl = stripIgnoredParams(routeUrl);
|
|
496
|
+
if (plainUrl !== routeUrl) {
|
|
497
|
+
const plain = cacheGet(
|
|
498
|
+
computeCacheKey(plainUrl, request, cookies as CookieJar) + keySuffix,
|
|
499
|
+
);
|
|
500
|
+
if (plain && !plain.queryAware) return serveCached(plain, request);
|
|
501
|
+
}
|
|
460
502
|
const hit = cacheGet(cacheKey);
|
|
461
503
|
if (hit) return serveCached(hit, request);
|
|
462
|
-
const
|
|
463
|
-
if (
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
504
|
+
const claim = await claimMiss(cacheKey, request.signal);
|
|
505
|
+
if (claim.hit) return serveCached(claim.hit, request);
|
|
506
|
+
if (claim.aborted) return new Response(null, { status: 499 });
|
|
507
|
+
if (claim.lead) {
|
|
508
|
+
releaseDataMiss = claim.lead.release;
|
|
509
|
+
// It replaced a stuck leader: start a new loader run, which
|
|
510
|
+
// later requests join, instead of joining the stuck one (a
|
|
511
|
+
// call that began within 1s of the stuck gate, not a later
|
|
512
|
+
// healthy one).
|
|
513
|
+
if (claim.lead.stale) replaceDedupBefore = claim.lead.staleStart + 1000;
|
|
469
514
|
}
|
|
470
515
|
}
|
|
471
516
|
|
|
@@ -477,7 +522,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
477
522
|
const dedupK =
|
|
478
523
|
computeCacheKey(routeUrl, request, cookies as CookieJar) +
|
|
479
524
|
(invalidatedBits ? `|m=${invalidatedBits}` : "");
|
|
480
|
-
const result = await dedup(dedupK, runLoad);
|
|
525
|
+
const result = await dedup(dedupK, runLoad, CACHE_WAIT_TIMEOUT, replaceDedupBefore);
|
|
481
526
|
// Identity only covers CACHE_KEYS — warn if a loader read a session
|
|
482
527
|
// cookie outside that list (dedup could then mix users' results).
|
|
483
528
|
warnUncoveredCookies(cookies);
|
|
@@ -503,16 +548,23 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
503
548
|
const body = jsonEncoder.encode(JSON.stringify({ ...payload, metadata: result.metadata }));
|
|
504
549
|
|
|
505
550
|
// A loader that said not to store its response is taken at its word.
|
|
506
|
-
const
|
|
507
|
-
|
|
551
|
+
const loaderOptOut =
|
|
552
|
+
isNoStoreCacheControl(loaderHeaders["cache-control"]) ||
|
|
553
|
+
varyBlocksCache(loaderHeaders["vary"]) ||
|
|
554
|
+
// Failed or timed out: don't keep a copy without its title/meta.
|
|
555
|
+
metadataFailed(result.metaDeps);
|
|
508
556
|
let sent: Encoded | null | undefined;
|
|
509
557
|
if (
|
|
510
558
|
dataCacheable &&
|
|
511
559
|
!loaderOptOut &&
|
|
512
560
|
(cookies as CookieJar).outgoing.length === 0 &&
|
|
513
|
-
(
|
|
561
|
+
bodyFitsCache(body.length)
|
|
514
562
|
) {
|
|
515
563
|
const tags = collectTags(result.data.layoutDeps ?? null, result.data.pageDeps ?? null);
|
|
564
|
+
const queryAware = depsReadIgnoredParams(
|
|
565
|
+
[...(result.data.layoutDeps ?? []), result.metaDeps],
|
|
566
|
+
result.data.pageDeps ?? null,
|
|
567
|
+
);
|
|
516
568
|
const rel = releaseDataMiss;
|
|
517
569
|
releaseDataMiss = null;
|
|
518
570
|
// Encoded once at cache quality and shared by response and entry.
|
|
@@ -538,6 +590,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
538
590
|
status: 200,
|
|
539
591
|
extraHeaders: extra,
|
|
540
592
|
tags,
|
|
593
|
+
queryAware,
|
|
541
594
|
},
|
|
542
595
|
cookies,
|
|
543
596
|
);
|
|
@@ -612,12 +665,42 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
612
665
|
let releaseApiMiss: (() => void) | null = null;
|
|
613
666
|
try {
|
|
614
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
|
+
|
|
615
689
|
// HEAD falls back to GET (an explicit HEAD export still wins). BosiaApp
|
|
616
690
|
// strips the body on the way out, for every route kind.
|
|
617
691
|
const handler = mod[method] ?? (method === "HEAD" ? mod.GET : undefined);
|
|
618
692
|
|
|
619
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
|
+
}
|
|
620
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");
|
|
621
704
|
if (allowed.includes("GET") && !allowed.includes("HEAD")) allowed.push("HEAD");
|
|
622
705
|
return Response.json(
|
|
623
706
|
{ error: `Method ${method} not allowed` },
|
|
@@ -649,14 +732,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
649
732
|
if (apiCacheable && !url.searchParams.has("_invalidated")) {
|
|
650
733
|
// Miss coalescing: first miss runs the handler; concurrent misses
|
|
651
734
|
// wait, re-check the cache, and on a still-miss run independently.
|
|
652
|
-
const
|
|
653
|
-
if (
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
if (rehit) return serveCached(rehit, request);
|
|
657
|
-
} else {
|
|
658
|
-
releaseApiMiss = gate.release;
|
|
659
|
-
}
|
|
735
|
+
const claim = await claimMiss(apiCacheKey, request.signal);
|
|
736
|
+
if (claim.hit) return serveCached(claim.hit, request);
|
|
737
|
+
if (claim.aborted) return new Response(null, { status: 499 });
|
|
738
|
+
if (claim.lead) releaseApiMiss = claim.lead.release;
|
|
660
739
|
}
|
|
661
740
|
}
|
|
662
741
|
|
|
@@ -675,10 +754,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
675
754
|
|
|
676
755
|
// Redirect returned (not thrown) — convert to a 303 Response.
|
|
677
756
|
if (isRedirect(handlerResult)) {
|
|
678
|
-
return
|
|
679
|
-
status: handlerResult.status,
|
|
680
|
-
headers: { Location: handlerResult.location },
|
|
681
|
-
});
|
|
757
|
+
return redirectResponse(handlerResult);
|
|
682
758
|
}
|
|
683
759
|
|
|
684
760
|
const response = handlerResult as Response;
|
|
@@ -690,11 +766,12 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
690
766
|
|
|
691
767
|
// Respect handler opt-out via Cache-Control. Standard HTTP semantics:
|
|
692
768
|
// no-store / private / no-cache all signal "don't reuse this response".
|
|
693
|
-
|
|
769
|
+
// A Vary the key can't honor, or a body the handler already encoded
|
|
770
|
+
// (the entry would replay it without its Content-Encoding), isn't reused.
|
|
694
771
|
const noStore =
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
772
|
+
isNoStoreCacheControl(response.headers.get("cache-control")) ||
|
|
773
|
+
varyBlocksCache(response.headers.get("vary")) ||
|
|
774
|
+
response.headers.has("content-encoding");
|
|
698
775
|
|
|
699
776
|
if (
|
|
700
777
|
apiCacheable &&
|
|
@@ -702,7 +779,9 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
702
779
|
response.status === 200 &&
|
|
703
780
|
!isEventStream &&
|
|
704
781
|
!noStore &&
|
|
705
|
-
(cookies as CookieJar).outgoing.length === 0
|
|
782
|
+
(cookies as CookieJar).outgoing.length === 0 &&
|
|
783
|
+
// A declared size that can't fit: skip the copy and the read entirely.
|
|
784
|
+
bodyFitsCache(parseInt(response.headers.get("content-length") ?? "", 10) || 0)
|
|
706
785
|
) {
|
|
707
786
|
const cloned = response.clone();
|
|
708
787
|
const extraHeaders = captureCacheableHeaders(response.headers);
|
|
@@ -714,10 +793,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
714
793
|
releaseApiMiss = null;
|
|
715
794
|
deferCacheWrite(async () => {
|
|
716
795
|
try {
|
|
717
|
-
|
|
718
|
-
//
|
|
719
|
-
|
|
720
|
-
if (
|
|
796
|
+
// Stops reading once the body can't fit, so an oversized body
|
|
797
|
+
// never sits in memory or pays compression; cacheSet re-checks.
|
|
798
|
+
const buf = await readBodyForCache(cloned);
|
|
799
|
+
if (buf === null) return;
|
|
721
800
|
const { gzip, brotli } = buildCompressedVariants(buf);
|
|
722
801
|
// API endpoints have no LoaderDeps in v0.6 — invalidation is
|
|
723
802
|
// URL/prefix only. See ROADMAP for deferred tag support.
|
|
@@ -747,10 +826,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
747
826
|
// `throw redirect(303, "/")` from a +server.ts handler — turn it into
|
|
748
827
|
// a real 303 instead of a 500. Mirrors the page-action handler below.
|
|
749
828
|
if (isRedirect(err)) {
|
|
750
|
-
return
|
|
751
|
-
status: err.status,
|
|
752
|
-
headers: { Location: err.location },
|
|
753
|
-
});
|
|
829
|
+
return redirectResponse(err);
|
|
754
830
|
}
|
|
755
831
|
if (isHttpError(err)) {
|
|
756
832
|
return Response.json({ error: err.message }, { status: err.status });
|
|
@@ -1034,9 +1110,10 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
1034
1110
|
// (preview/proxy hubs, design tools, etc.). Other security headers stay on.
|
|
1035
1111
|
const _xfoDisabled = process.env.DISABLE_X_FRAME_OPTIONS === "true";
|
|
1036
1112
|
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
//
|
|
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.
|
|
1040
1117
|
const TRUST_PROXY = process.env.TRUST_PROXY === "true";
|
|
1041
1118
|
|
|
1042
1119
|
const SECURITY_HEADERS: Record<string, string> = {
|
|
@@ -1095,7 +1172,10 @@ function finalizeResponse(
|
|
|
1095
1172
|
applyCorsVary(headers);
|
|
1096
1173
|
const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
|
|
1097
1174
|
if (corsHeaders) {
|
|
1098
|
-
|
|
1175
|
+
// Vary: Origin is already merged in above; set() would drop Accept-Encoding.
|
|
1176
|
+
for (const [k, v] of Object.entries(corsHeaders)) {
|
|
1177
|
+
if (k.toLowerCase() !== "vary") headers.set(k, v);
|
|
1178
|
+
}
|
|
1099
1179
|
}
|
|
1100
1180
|
}
|
|
1101
1181
|
// Apply any Set-Cookie headers accumulated during the request
|
|
@@ -1113,19 +1193,7 @@ function finalizeResponse(
|
|
|
1113
1193
|
}
|
|
1114
1194
|
|
|
1115
1195
|
async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
1116
|
-
|
|
1117
|
-
// (e.g. `localhost:PORT` over plain HTTP), so `url` built from `request.url`
|
|
1118
|
-
// misreports the public origin. Rebuild it from `X-Forwarded-Host`/`-Proto`
|
|
1119
|
-
// so `event.url` — and every absolute redirect, canonical URL, and
|
|
1120
|
-
// `url.origin` the app derives — points at the public-facing origin instead
|
|
1121
|
-
// of localhost. Gated on TRUST_PROXY since these headers are client-spoofable
|
|
1122
|
-
// when no proxy strips them.
|
|
1123
|
-
if (TRUST_PROXY) {
|
|
1124
|
-
const fwdHost = request.headers.get("x-forwarded-host");
|
|
1125
|
-
if (fwdHost) url.host = fwdHost;
|
|
1126
|
-
const fwdProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
|
1127
|
-
if (fwdProto) url.protocol = `${fwdProto}:`;
|
|
1128
|
-
}
|
|
1196
|
+
applyForwarded(request, url, TRUST_PROXY);
|
|
1129
1197
|
|
|
1130
1198
|
// Mounted under BASE_PATH? Everything downstream — hooks, the router, the
|
|
1131
1199
|
// /_health and /__bosia tests below — works in app space, so the prefix comes
|
|
@@ -1138,6 +1206,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1138
1206
|
url.pathname = appPath;
|
|
1139
1207
|
}
|
|
1140
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
|
+
|
|
1141
1219
|
// Bun.serve enforces maxRequestBodySize itself; Workers has no such knob, so
|
|
1142
1220
|
// check the declared length here. A chunked upload without Content-Length
|
|
1143
1221
|
// falls back to the host's own request cap (Cloudflare: 100MB on free).
|
|
@@ -1181,11 +1259,18 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1181
1259
|
console.warn(`[CSRF] Blocked request: ${csrfError}`);
|
|
1182
1260
|
return Response.json({ error: "Forbidden", message: csrfError }, { status: 403 });
|
|
1183
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
|
+
}
|
|
1184
1271
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
url.protocol === "https:";
|
|
1188
|
-
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:");
|
|
1189
1274
|
nonce = CSP_ENABLED ? generateNonce() : "";
|
|
1190
1275
|
|
|
1191
1276
|
// Decode the data endpoint before the hooks, not inside resolve(): a guard
|
|
@@ -1237,12 +1322,16 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1237
1322
|
response ??=
|
|
1238
1323
|
userHandle && !warmingUp ? await userHandle({ event, resolve }) : await resolve(event);
|
|
1239
1324
|
|
|
1325
|
+
// Bun already answered with 101; there is no response left to finish.
|
|
1326
|
+
if (isUpgraded()) return response;
|
|
1327
|
+
|
|
1240
1328
|
// A hook that short-circuits a data request with a redirect is answering
|
|
1241
1329
|
// the client router, which speaks JSON — an unconverted 3xx is followed by
|
|
1242
1330
|
// `fetch` and the router receives the redirect target's HTML instead.
|
|
1243
1331
|
// `Location` is copied verbatim: `redirect()` already rebased it through
|
|
1244
1332
|
// `withBase()`, and a raw `Response.redirect` under a BASE_PATH carries the
|
|
1245
1333
|
// base by hand, so rebasing here would double the prefix on both.
|
|
1334
|
+
|
|
1246
1335
|
if (dataReq && response.status >= 300 && response.status < 400) {
|
|
1247
1336
|
const location = response.headers.get("location");
|
|
1248
1337
|
if (location) {
|
|
@@ -1261,6 +1350,13 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1261
1350
|
|
|
1262
1351
|
return finalizeResponse(response, request, nonce, cookieJar);
|
|
1263
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
|
+
}
|
|
1264
1360
|
// `throw redirect()` / `throw error()` from a hook lands here — the same
|
|
1265
1361
|
// escape hatch loaders have always had. Without these branches both fall
|
|
1266
1362
|
// through to the 500 below, which is why the docs could only ever suggest
|
|
@@ -1271,8 +1367,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1271
1367
|
? // Shape-identical to the loader conversion below, so the
|
|
1272
1368
|
// router has one payload contract regardless of who redirected.
|
|
1273
1369
|
Response.json({ redirect: err.location, status: err.status })
|
|
1274
|
-
:
|
|
1275
|
-
new Response(null, { status: err.status, headers: { Location: err.location } })
|
|
1370
|
+
: redirectResponse(err)
|
|
1276
1371
|
: dataReq
|
|
1277
1372
|
? Response.json(
|
|
1278
1373
|
{
|
|
@@ -1339,11 +1434,11 @@ function parseCorsMaxAge(value?: string): number | undefined {
|
|
|
1339
1434
|
// Format: "512K", "1M", "1G", plain bytes, or "Infinity".
|
|
1340
1435
|
// Default: 512K (matches SvelteKit).
|
|
1341
1436
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
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;
|
|
1345
1440
|
const match = value.match(/^(\d+(?:\.\d+)?)\s*([KMG]?)$/i);
|
|
1346
|
-
if (!match) throw new Error(`Invalid
|
|
1441
|
+
if (!match) throw new Error(`Invalid ${key}: "${value}"`);
|
|
1347
1442
|
const num = parseFloat(match[1]);
|
|
1348
1443
|
const unit = match[2].toUpperCase();
|
|
1349
1444
|
if (unit === "K") return Math.floor(num * 1024);
|
|
@@ -1352,7 +1447,9 @@ function parseBodySizeLimit(value?: string): number {
|
|
|
1352
1447
|
return Math.floor(num);
|
|
1353
1448
|
}
|
|
1354
1449
|
|
|
1355
|
-
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;
|
|
1356
1453
|
|
|
1357
1454
|
if (BODY_SIZE_LIMIT === 0) {
|
|
1358
1455
|
console.log("📦 Body size limit: none");
|
|
@@ -1366,18 +1463,39 @@ if (BODY_SIZE_LIMIT === 0) {
|
|
|
1366
1463
|
// Raise when API routes hold streaming responses with long gaps
|
|
1367
1464
|
// between chunks (e.g. AI tool calls that shell out and wait).
|
|
1368
1465
|
|
|
1369
|
-
|
|
1370
|
-
|
|
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;
|
|
1371
1469
|
const n = parseInt(value, 10);
|
|
1372
|
-
if (!Number.isFinite(n) || n < 0) throw new Error(`Invalid
|
|
1373
|
-
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})`);
|
|
1374
1472
|
return n;
|
|
1375
1473
|
}
|
|
1376
1474
|
|
|
1377
|
-
const IDLE_TIMEOUT =
|
|
1475
|
+
const IDLE_TIMEOUT = parseSeconds("IDLE_TIMEOUT", process.env.IDLE_TIMEOUT, 10, 255);
|
|
1378
1476
|
|
|
1379
1477
|
console.log(`⏱ Idle timeout: ${IDLE_TIMEOUT}s`);
|
|
1380
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
|
+
|
|
1381
1499
|
// ─── Concurrency Ceiling ──────────────────────────────────
|
|
1382
1500
|
// Soft cap on in-flight requests, parsed from MAX_INFLIGHT env var.
|
|
1383
1501
|
// Default is unlimited so existing apps see no behavior change. When set,
|
|
@@ -1412,6 +1530,8 @@ let drainResolve: (() => void) | null = null;
|
|
|
1412
1530
|
/** Stop taking new requests; resolves once in-flight ones have finished. */
|
|
1413
1531
|
export function beginShutdown(): Promise<void> {
|
|
1414
1532
|
shuttingDown = true;
|
|
1533
|
+
// 1001 "going away": clients reconnect, to this server's replacement.
|
|
1534
|
+
closeAllSockets();
|
|
1415
1535
|
if (inFlight === 0) return Promise.resolve();
|
|
1416
1536
|
return new Promise<void>((r) => {
|
|
1417
1537
|
drainResolve = r;
|
|
@@ -1476,6 +1596,7 @@ export async function createApp(options: CreateAppOptions = {}): Promise<BosiaAp
|
|
|
1476
1596
|
// splits traffic between two different builds. Opt in only for deliberate
|
|
1477
1597
|
// N-worker clustering.
|
|
1478
1598
|
reusePort: process.env.BOSIA_REUSE_PORT === "1",
|
|
1599
|
+
websocket: WEBSOCKET_OPTIONS,
|
|
1479
1600
|
},
|
|
1480
1601
|
});
|
|
1481
1602
|
|
|
@@ -29,7 +29,7 @@ const quiet = { log: console.log, warn: console.warn, error: console.error };
|
|
|
29
29
|
console.log = console.warn = console.error = () => {};
|
|
30
30
|
setWarmingUp(true);
|
|
31
31
|
try {
|
|
32
|
-
await app.
|
|
32
|
+
await app.fetchUnobserved(new Request("http://localhost/"));
|
|
33
33
|
} catch {
|
|
34
34
|
// best effort
|
|
35
35
|
} finally {
|
|
@@ -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/plugin.ts
CHANGED
|
@@ -35,7 +35,8 @@ export interface BosiaPlugin {
|
|
|
35
35
|
|
|
36
36
|
/**
|
|
37
37
|
* Mount points around the framework's HTTP backend — Bosia's own small,
|
|
38
|
-
* Elysia-shaped app (`get/post/…`, `onRequest`, `onAfterHandle`, `onError
|
|
38
|
+
* Elysia-shaped app (`get/post/…`, `onRequest`, `onAfterHandle`, `onError`,
|
|
39
|
+
* and observe-only `onResponse` for logging/metrics on the final response).
|
|
39
40
|
*/
|
|
40
41
|
backend?: {
|
|
41
42
|
/** Runs before framework middleware/routes — can register routes that bypass Bosia. */
|