bosia 1.1.4 → 1.1.6
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/ambient.d.ts +3 -0
- package/src/cli/add.ts +15 -12
- package/src/cli/block.ts +18 -16
- package/src/cli/feat.ts +31 -11
- package/src/cli/page.ts +18 -16
- package/src/cli/registry.ts +117 -20
- package/src/cli/theme.ts +7 -2
- package/src/core/backend.ts +8 -1
- package/src/core/build.ts +7 -2
- package/src/core/cache.ts +31 -6
- package/src/core/client/App.svelte +15 -26
- package/src/core/client/page.svelte.ts +15 -0
- package/src/core/client/prefetch.ts +55 -60
- package/src/core/dev.ts +11 -1
- package/src/core/etag.ts +13 -0
- package/src/core/hooks.ts +17 -0
- package/src/core/html.ts +109 -14
- package/src/core/plugins/inspector/bun-plugin.ts +31 -18
- package/src/core/renderer.ts +111 -79
- package/src/core/routeFile.ts +36 -20
- package/src/core/scanner.ts +2 -0
- package/src/core/server.ts +312 -103
- package/src/core/staticManifest.ts +60 -15
- package/src/core/svelteCompiler.ts +96 -27
- package/src/core/types.ts +4 -0
- package/templates/shop/.env.example +3 -0
- package/templates/shop/src/hooks.server.ts +12 -2
- package/templates/shop/src/routes/(private)/+layout.server.ts +2 -6
- package/templates/store/.env.example +3 -0
- package/templates/store/src/hooks.server.ts +12 -2
- package/templates/store/src/routes/(private)/+layout.server.ts +2 -6
package/src/core/server.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { apiRoutes, serverRoutes } from "bosia:routes";
|
|
|
6
6
|
import { loadPlugins } from "./config.ts";
|
|
7
7
|
import { readArtifact } from "./artifacts.ts";
|
|
8
8
|
import { getPlatform, warmingUp } from "./platform.ts";
|
|
9
|
-
import type { RouteManifest } from "./types.ts";
|
|
9
|
+
import type { RouteManifest, RouteMatch } from "./types.ts";
|
|
10
10
|
|
|
11
11
|
// Pre-compile route patterns into RegExp at startup (shared by renderer.ts via module reference)
|
|
12
12
|
compileRoutes(apiRoutes);
|
|
@@ -20,7 +20,18 @@ import type { CsrfConfig } from "./csrf.ts";
|
|
|
20
20
|
import { applyCorsVary, getCorsHeaders, handlePreflight } from "./cors.ts";
|
|
21
21
|
import type { CorsConfig } from "./cors.ts";
|
|
22
22
|
import { buildCspHeader, CSP_DIRECTIVES_TEMPLATE, CSP_ENABLED, generateNonce } from "./csp.ts";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
isDev,
|
|
25
|
+
compress,
|
|
26
|
+
compressBytes,
|
|
27
|
+
encodeForRequest,
|
|
28
|
+
isStaticPath,
|
|
29
|
+
distManifest,
|
|
30
|
+
PRECOMPRESSED,
|
|
31
|
+
type Encoded,
|
|
32
|
+
preloadLinkHeader,
|
|
33
|
+
compressionOn,
|
|
34
|
+
} from "./html.ts";
|
|
24
35
|
import { dev500WithPlugins } from "./dev-500.ts";
|
|
25
36
|
import { OUT_DIR } from "./paths.ts";
|
|
26
37
|
import { stripBase, withBase } from "./basePath.ts";
|
|
@@ -28,6 +39,7 @@ import { currentBase } from "./appBase.ts";
|
|
|
28
39
|
import {
|
|
29
40
|
buildPrerenderManifest,
|
|
30
41
|
buildStaticManifest,
|
|
42
|
+
findOutDirFile,
|
|
31
43
|
lookupStatic,
|
|
32
44
|
serveStatic,
|
|
33
45
|
} from "./staticManifest.ts";
|
|
@@ -39,6 +51,7 @@ import {
|
|
|
39
51
|
cacheGet,
|
|
40
52
|
cacheSet,
|
|
41
53
|
coalesceMiss,
|
|
54
|
+
collectTags,
|
|
42
55
|
computeCacheKey,
|
|
43
56
|
deferCacheWrite,
|
|
44
57
|
serveCached,
|
|
@@ -163,7 +176,7 @@ function isValidRoutePath(path: string, origin: string): boolean {
|
|
|
163
176
|
}
|
|
164
177
|
}
|
|
165
178
|
|
|
166
|
-
type DataRequest = { routeUrl: URL; invalidatedBits: string | null };
|
|
179
|
+
type DataRequest = { routeUrl: URL; invalidatedBits: string | null; fresh: boolean };
|
|
167
180
|
|
|
168
181
|
/**
|
|
169
182
|
* Decode `/__bosia/data/<route>.json` into the page URL it stands for.
|
|
@@ -188,14 +201,21 @@ function parseDataRequest(url: URL): DataRequest | "invalid" | null {
|
|
|
188
201
|
|
|
189
202
|
const routeUrl = new URL(routePathStr, url.origin);
|
|
190
203
|
let invalidatedBits: string | null = null;
|
|
204
|
+
let fresh = false;
|
|
191
205
|
for (const [key, val] of url.searchParams.entries()) {
|
|
192
206
|
if (key === "_invalidated") {
|
|
193
207
|
invalidatedBits = val;
|
|
194
208
|
continue;
|
|
195
209
|
}
|
|
210
|
+
// Set by the client router when the fetch follows an invalidate():
|
|
211
|
+
// skip the cached copy, as `?_invalidated` does for page HTML.
|
|
212
|
+
if (key === "_fresh") {
|
|
213
|
+
fresh = true;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
196
216
|
routeUrl.searchParams.append(key, val);
|
|
197
217
|
}
|
|
198
|
-
return { routeUrl, invalidatedBits };
|
|
218
|
+
return { routeUrl, invalidatedBits, fresh };
|
|
199
219
|
}
|
|
200
220
|
|
|
201
221
|
/**
|
|
@@ -211,9 +231,62 @@ function parseDataRequest(url: URL): DataRequest | "invalid" | null {
|
|
|
211
231
|
*/
|
|
212
232
|
const dataRequests = new WeakMap<Request, DataRequest>();
|
|
213
233
|
|
|
234
|
+
type RouteMatches = {
|
|
235
|
+
path: string;
|
|
236
|
+
pageOnly: boolean;
|
|
237
|
+
api: RouteMatch<(typeof apiRoutes)[number]> | null;
|
|
238
|
+
page: RouteMatch<(typeof serverRoutes)[number]> | null;
|
|
239
|
+
/** handleRequest already looked for a static file at `path` and found none. */
|
|
240
|
+
staticMiss?: boolean;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Match `path` in resolve()'s order: an API route first (it shadows static
|
|
245
|
+
* files), then a page unless the path is a static file. A data request only
|
|
246
|
+
* ever routes to a page.
|
|
247
|
+
*/
|
|
248
|
+
async function matchRoutes(path: string, pageOnly: boolean): Promise<RouteMatches> {
|
|
249
|
+
if (pageOnly) return { path, pageOnly, api: null, page: findMatch(serverRoutes, path) };
|
|
250
|
+
const apiMaybe = resolveApiMatch(apiRoutes, path);
|
|
251
|
+
const api = apiMaybe instanceof Promise ? await apiMaybe : apiMaybe;
|
|
252
|
+
const page = api || isStaticPath(path) ? null : findMatch(serverRoutes, path);
|
|
253
|
+
return { path, pageOnly, api, page };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* handleRequest matches before the hooks run (so they see `params`) and parks
|
|
258
|
+
* the result here, keyed like `dataRequests`. resolve() reuses it unless a hook
|
|
259
|
+
* rewrote the URL, in which case it matches again.
|
|
260
|
+
*/
|
|
261
|
+
const routeMatches = new WeakMap<Request, RouteMatches>();
|
|
262
|
+
|
|
263
|
+
function routesFor(
|
|
264
|
+
request: Request,
|
|
265
|
+
path: string,
|
|
266
|
+
pageOnly: boolean,
|
|
267
|
+
): RouteMatches | Promise<RouteMatches> {
|
|
268
|
+
const parked = routeMatches.get(request);
|
|
269
|
+
if (parked && parked.path === path && parked.pageOnly === pageOnly) return parked;
|
|
270
|
+
return matchRoutes(path, pageOnly);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const jsonEncoder = new TextEncoder();
|
|
274
|
+
|
|
275
|
+
/** A page route's `export const cache` flag: the build-time read when it has
|
|
276
|
+
* one, otherwise the compiled page module's own export (see renderer.ts). */
|
|
277
|
+
async function pageRouteCacheable(route: (typeof serverRoutes)[number]): Promise<boolean> {
|
|
278
|
+
const flag = (route as { cache?: boolean | null }).cache;
|
|
279
|
+
if (flag === true || flag === false) return flag;
|
|
280
|
+
return (await route.pageModule()).cache !== false;
|
|
281
|
+
}
|
|
282
|
+
|
|
214
283
|
/**
|
|
215
284
|
* Decode an `_invalidated` bitmask string. Char 0 = page, char i+1 = layout
|
|
216
|
-
* depth i
|
|
285
|
+
* depth i. Only '0' has a meaning; any other char counts as '1'.
|
|
286
|
+
* - Page '0': skip the page loader.
|
|
287
|
+
* - Layout '0': omit its data from the response. The loader still runs,
|
|
288
|
+
* since it may be a guard.
|
|
289
|
+
* Missing chars count as '1'. Chars past the route's layout count are ignored.
|
|
217
290
|
*/
|
|
218
291
|
function buildMaskFromBits(
|
|
219
292
|
bits: string,
|
|
@@ -243,6 +316,56 @@ function parseActionName(url: URL): string {
|
|
|
243
316
|
const staticManifest = isDev ? null : buildStaticManifest(OUT_DIR);
|
|
244
317
|
const prerenderManifest = isDev ? null : buildPrerenderManifest(OUT_DIR);
|
|
245
318
|
|
|
319
|
+
/** Serve `path` as a static file, or `null` when there is no such file. */
|
|
320
|
+
async function serveStaticFile(path: string, request: Request): Promise<Response | null> {
|
|
321
|
+
// Prod fast path: single Map lookup, no per-request stat calls.
|
|
322
|
+
if (staticManifest) {
|
|
323
|
+
const hit = lookupStatic(staticManifest, path);
|
|
324
|
+
return hit ? serveStatic(hit, request) : null;
|
|
325
|
+
}
|
|
326
|
+
// Dev: keep the per-request fallthrough so files dropped into `public/`
|
|
327
|
+
// mid-session are served without a restart. Decode once — filenames on
|
|
328
|
+
// disk are raw; safePath still runs after so traversal stays blocked.
|
|
329
|
+
let decodedPath: string;
|
|
330
|
+
try {
|
|
331
|
+
decodedPath = decodeURIComponent(path);
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
if (decodedPath.startsWith("/dist/client/")) {
|
|
336
|
+
const resolved = safePath(
|
|
337
|
+
`${OUT_DIR}/client`,
|
|
338
|
+
decodedPath.split("?")[0].slice("/dist/client".length),
|
|
339
|
+
);
|
|
340
|
+
if (resolved) {
|
|
341
|
+
const file = Bun.file(resolved);
|
|
342
|
+
if (await file.exists()) {
|
|
343
|
+
return new Response(file, { headers: { "Cache-Control": "no-cache" } });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return null;
|
|
347
|
+
}
|
|
348
|
+
const pubPath = safePath("./public", decodedPath);
|
|
349
|
+
if (pubPath) {
|
|
350
|
+
const pub = Bun.file(pubPath);
|
|
351
|
+
if (await pub.exists()) return new Response(pub);
|
|
352
|
+
}
|
|
353
|
+
// Same refusals as the prod manifest: never hand out the server bundle,
|
|
354
|
+
// the user's bundled hooks/config, or build metadata. Matched against the
|
|
355
|
+
// real directory listing, so no alternative spelling reaches those files.
|
|
356
|
+
const distPath = findOutDirFile(OUT_DIR, decodedPath);
|
|
357
|
+
if (distPath) {
|
|
358
|
+
const dist = Bun.file(distPath);
|
|
359
|
+
if (await dist.exists()) return new Response(dist);
|
|
360
|
+
}
|
|
361
|
+
const staticPath = safePath(`${OUT_DIR}/static`, decodedPath);
|
|
362
|
+
if (staticPath) {
|
|
363
|
+
const staticFile = Bun.file(staticPath);
|
|
364
|
+
if (await staticFile.exists()) return new Response(staticFile);
|
|
365
|
+
}
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
|
|
246
369
|
async function resolve(event: RequestEvent): Promise<Response> {
|
|
247
370
|
const { request, url, locals, cookies } = event;
|
|
248
371
|
const path = url.pathname;
|
|
@@ -262,11 +385,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
262
385
|
// the parse handleRequest parked before the hooks ran is what identifies it.
|
|
263
386
|
const dataReq = dataRequests.get(request);
|
|
264
387
|
if (dataReq) {
|
|
265
|
-
const { routeUrl, invalidatedBits } = dataReq;
|
|
388
|
+
const { routeUrl, invalidatedBits, fresh } = dataReq;
|
|
389
|
+
// INVARIANT: once set, releaseDataMiss must fire exactly once — see the
|
|
390
|
+
// API-route cache below for the same contract.
|
|
391
|
+
let releaseDataMiss: (() => void) | null = null;
|
|
266
392
|
try {
|
|
267
|
-
const pageMatch =
|
|
268
|
-
// Build mask from `?_invalidated=<bits>`
|
|
269
|
-
//
|
|
393
|
+
const pageMatch = (await routesFor(request, routeUrl.pathname, true)).page;
|
|
394
|
+
// Build mask from `?_invalidated=<bits>` — see buildMaskFromBits. A page
|
|
395
|
+
// '0' skips the page loader; a layout '0' still runs the loader (it may
|
|
396
|
+
// be a guard) and only leaves its data out. Absent → run and send all.
|
|
270
397
|
// Mask is sized to the total layout count (matching client `layoutIds`),
|
|
271
398
|
// not the count of layout servers, so depths without a server loader
|
|
272
399
|
// still occupy a bit position and stay aligned with the client.
|
|
@@ -276,32 +403,8 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
276
403
|
pageMatch?.route ? ((pageMatch.route as any).layoutModules?.length ?? 0) : 0,
|
|
277
404
|
)
|
|
278
405
|
: undefined;
|
|
279
|
-
// Client forwards each skipped layout layer's cached data as
|
|
280
|
-
// parentSnapshots (depth → data) so downstream loaders see real
|
|
281
|
-
// parent() data instead of {}. Perf hint only — never authoritative;
|
|
282
|
-
// authz must read locals. Guarded: undefined for GET / malformed body.
|
|
283
|
-
let parentSnapshots: Record<number, Record<string, any>> | undefined;
|
|
284
|
-
if (method !== "GET") {
|
|
285
|
-
try {
|
|
286
|
-
const body = await request.json();
|
|
287
|
-
if (body && typeof body === "object" && body.parentSnapshots) {
|
|
288
|
-
parentSnapshots = body.parentSnapshots as Record<number, Record<string, any>>;
|
|
289
|
-
}
|
|
290
|
-
} catch {
|
|
291
|
-
parentSnapshots = undefined;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
406
|
const runLoad = async () => {
|
|
295
|
-
const data = await loadRouteData(
|
|
296
|
-
routeUrl,
|
|
297
|
-
locals,
|
|
298
|
-
request,
|
|
299
|
-
cookies,
|
|
300
|
-
null,
|
|
301
|
-
pageMatch,
|
|
302
|
-
mask,
|
|
303
|
-
parentSnapshots,
|
|
304
|
-
);
|
|
407
|
+
const data = await loadRouteData(routeUrl, locals, request, cookies, null, pageMatch, mask);
|
|
305
408
|
|
|
306
409
|
let metadata = null;
|
|
307
410
|
if (pageMatch) {
|
|
@@ -332,6 +435,34 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
332
435
|
return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
|
|
333
436
|
};
|
|
334
437
|
|
|
438
|
+
// ── Response cache for client-navigation data ──
|
|
439
|
+
// Same contract as page HTML: keyed by URL + identity, plus the mask
|
|
440
|
+
// (different masks return different slots), tag/path invalidation, and
|
|
441
|
+
// no CSP. Only GETs use it (the client router only sends GETs); a
|
|
442
|
+
// `_fresh` fetch after invalidate() skips the read only.
|
|
443
|
+
const cacheKey =
|
|
444
|
+
computeCacheKey(routeUrl, request, cookies as CookieJar) +
|
|
445
|
+
`|data${invalidatedBits ? `|m=${invalidatedBits}` : ""}`;
|
|
446
|
+
const dataCacheable =
|
|
447
|
+
CACHE_ENABLED &&
|
|
448
|
+
!CSP_ENABLED &&
|
|
449
|
+
!warmingUp &&
|
|
450
|
+
method === "GET" &&
|
|
451
|
+
pageMatch !== null &&
|
|
452
|
+
(await pageRouteCacheable(pageMatch.route));
|
|
453
|
+
if (dataCacheable && !fresh) {
|
|
454
|
+
const hit = cacheGet(cacheKey);
|
|
455
|
+
if (hit) return serveCached(hit, request);
|
|
456
|
+
const gate = coalesceMiss(cacheKey);
|
|
457
|
+
if (gate.wait) {
|
|
458
|
+
await gate.wait;
|
|
459
|
+
const rehit = cacheGet(cacheKey);
|
|
460
|
+
if (rehit) return serveCached(rehit, request);
|
|
461
|
+
} else {
|
|
462
|
+
releaseDataMiss = gate.release;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
335
466
|
// Dedup concurrent identical requests. The key includes the CACHE_KEYS
|
|
336
467
|
// identity hash, so different users never share a loader result — same
|
|
337
468
|
// isolation contract as the response cache. The mask is part of the key
|
|
@@ -363,13 +494,53 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
363
494
|
// Privacy beats intent: cookie-derived responses stay private even if
|
|
364
495
|
// a loader set its own cache-control.
|
|
365
496
|
if (cookiesWereAccessed) extra["cache-control"] = cc;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
497
|
+
const body = jsonEncoder.encode(JSON.stringify({ ...payload, metadata: result.metadata }));
|
|
498
|
+
|
|
499
|
+
// A loader that said not to store its response is taken at its word.
|
|
500
|
+
const loaderCc = (loaderHeaders["cache-control"] ?? "").toLowerCase();
|
|
501
|
+
const loaderOptOut = /no-store|no-cache|private/.test(loaderCc);
|
|
502
|
+
let sent: Encoded | null | undefined;
|
|
503
|
+
if (
|
|
504
|
+
dataCacheable &&
|
|
505
|
+
!loaderOptOut &&
|
|
506
|
+
(cookies as CookieJar).outgoing.length === 0 &&
|
|
507
|
+
(CACHE_MAX_BODY_BYTES === 0 || body.length <= CACHE_MAX_BODY_BYTES)
|
|
508
|
+
) {
|
|
509
|
+
const tags = collectTags(result.data.layoutDeps ?? null, result.data.pageDeps ?? null);
|
|
510
|
+
const rel = releaseDataMiss;
|
|
511
|
+
releaseDataMiss = null;
|
|
512
|
+
// Encoded once at cache quality and shared by response and entry.
|
|
513
|
+
const encoded = encodeForRequest(body, request, "cache");
|
|
514
|
+
sent = encoded;
|
|
515
|
+
deferCacheWrite(() => {
|
|
516
|
+
try {
|
|
517
|
+
const { gzip, brotli } = buildCompressedVariants(
|
|
518
|
+
body,
|
|
519
|
+
encoded?.enc === "br"
|
|
520
|
+
? { brotli: encoded.encoded }
|
|
521
|
+
: encoded
|
|
522
|
+
? { gzip: encoded.encoded }
|
|
523
|
+
: {},
|
|
524
|
+
);
|
|
525
|
+
cacheSet(
|
|
526
|
+
cacheKey,
|
|
527
|
+
{
|
|
528
|
+
raw: body,
|
|
529
|
+
gzip,
|
|
530
|
+
brotli,
|
|
531
|
+
contentType: "application/json",
|
|
532
|
+
status: 200,
|
|
533
|
+
extraHeaders: extra,
|
|
534
|
+
tags,
|
|
535
|
+
},
|
|
536
|
+
cookies,
|
|
537
|
+
);
|
|
538
|
+
} finally {
|
|
539
|
+
rel?.();
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return compressBytes(body, "application/json", request, 200, extra, sent);
|
|
373
544
|
} catch (err) {
|
|
374
545
|
if (isRedirect(err)) {
|
|
375
546
|
return compress(
|
|
@@ -407,6 +578,8 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
407
578
|
});
|
|
408
579
|
}
|
|
409
580
|
return Response.json({ error: "Internal Server Error" }, { status: 500 });
|
|
581
|
+
} finally {
|
|
582
|
+
releaseDataMiss?.();
|
|
410
583
|
}
|
|
411
584
|
}
|
|
412
585
|
|
|
@@ -423,8 +596,8 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
423
596
|
// Matched BEFORE static fallthrough so explicit handlers shadow extension-
|
|
424
597
|
// based static detection (e.g. `/uploads/[...path]/+server.ts` can serve
|
|
425
598
|
// `.webp` URLs that would otherwise be intercepted by isStaticPath).
|
|
426
|
-
const
|
|
427
|
-
const apiMatch =
|
|
599
|
+
const routes = await routesFor(request, path, false);
|
|
600
|
+
const apiMatch = routes.api;
|
|
428
601
|
if (apiMatch) {
|
|
429
602
|
if (warmingUp) return new Response(null, { status: 404 });
|
|
430
603
|
// INVARIANT: once set, releaseApiMiss must fire exactly once — a missed
|
|
@@ -433,15 +606,16 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
433
606
|
let releaseApiMiss: (() => void) | null = null;
|
|
434
607
|
try {
|
|
435
608
|
const mod = await apiMatch.route.module();
|
|
436
|
-
|
|
609
|
+
// HEAD falls back to GET (an explicit HEAD export still wins). BosiaApp
|
|
610
|
+
// strips the body on the way out, for every route kind.
|
|
611
|
+
const handler = mod[method] ?? (method === "HEAD" ? mod.GET : undefined);
|
|
437
612
|
|
|
438
613
|
if (!handler) {
|
|
439
|
-
const allowed = Object.keys(mod)
|
|
440
|
-
|
|
441
|
-
.join(", ");
|
|
614
|
+
const allowed = Object.keys(mod).filter((k) => /^[A-Z]+$/.test(k));
|
|
615
|
+
if (allowed.includes("GET") && !allowed.includes("HEAD")) allowed.push("HEAD");
|
|
442
616
|
return Response.json(
|
|
443
617
|
{ error: `Method ${method} not allowed` },
|
|
444
|
-
{ status: 405, headers: { Allow: allowed } },
|
|
618
|
+
{ status: 405, headers: { Allow: allowed.join(", ") } },
|
|
445
619
|
);
|
|
446
620
|
}
|
|
447
621
|
|
|
@@ -450,15 +624,23 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
450
624
|
// ── Response cache for +server.ts GET handlers ──
|
|
451
625
|
// CSP is skipped because cached responses would ship with a stale
|
|
452
626
|
// nonce (see renderer.ts for the same gate). The cache key includes
|
|
453
|
-
// URL + identity so per-user responses stay isolated.
|
|
454
|
-
|
|
455
|
-
|
|
627
|
+
// URL + identity so per-user responses stay isolated. HEAD reads the GET
|
|
628
|
+
// entry but never coalesces or writes: it isn't a leader that fills it.
|
|
629
|
+
// An explicit HEAD export skips the cache so it always runs.
|
|
630
|
+
const apiCacheRead =
|
|
631
|
+
CACHE_ENABLED &&
|
|
632
|
+
!CSP_ENABLED &&
|
|
633
|
+
(mod as any).cache !== false &&
|
|
634
|
+
(method === "GET" || (method === "HEAD" && !mod.HEAD));
|
|
635
|
+
const apiCacheable = apiCacheRead && method === "GET";
|
|
456
636
|
let apiCacheKey: string | null = null;
|
|
457
|
-
if (
|
|
637
|
+
if (apiCacheRead) {
|
|
458
638
|
apiCacheKey = computeCacheKey(url, request, cookies as CookieJar);
|
|
459
639
|
if (!url.searchParams.has("_invalidated")) {
|
|
460
640
|
const hit = cacheGet(apiCacheKey);
|
|
461
641
|
if (hit) return serveCached(hit, request);
|
|
642
|
+
}
|
|
643
|
+
if (apiCacheable && !url.searchParams.has("_invalidated")) {
|
|
462
644
|
// Miss coalescing: first miss runs the handler; concurrent misses
|
|
463
645
|
// wait, re-check the cache, and on a still-miss run independently.
|
|
464
646
|
const gate = coalesceMiss(apiCacheKey);
|
|
@@ -475,6 +657,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
475
657
|
const handlerResult = await handler({
|
|
476
658
|
request,
|
|
477
659
|
params: apiMatch.params,
|
|
660
|
+
route: { id: apiMatch.route.id },
|
|
478
661
|
url,
|
|
479
662
|
locals,
|
|
480
663
|
cookies,
|
|
@@ -585,51 +768,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
585
768
|
}
|
|
586
769
|
|
|
587
770
|
// Static files — fallthrough after API routes so explicit handlers win.
|
|
771
|
+
// handleRequest already served hits before the hooks; this answers a URL a
|
|
772
|
+
// hook rewrote, and the 404 for a miss (without looking a second time).
|
|
588
773
|
if (isStaticPath(path)) {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
// Dev: keep the per-request fallthrough so files dropped into `public/`
|
|
596
|
-
// mid-session are served without a restart. Decode once — filenames on
|
|
597
|
-
// disk are raw; safePath still runs after so traversal stays blocked.
|
|
598
|
-
let decodedPath: string;
|
|
599
|
-
try {
|
|
600
|
-
decodedPath = decodeURIComponent(path);
|
|
601
|
-
} catch {
|
|
602
|
-
return new Response("Not Found", { status: 404 });
|
|
603
|
-
}
|
|
604
|
-
if (decodedPath.startsWith("/dist/client/")) {
|
|
605
|
-
const resolved = safePath(
|
|
606
|
-
`${OUT_DIR}/client`,
|
|
607
|
-
decodedPath.split("?")[0].slice("/dist/client".length),
|
|
608
|
-
);
|
|
609
|
-
if (resolved) {
|
|
610
|
-
const file = Bun.file(resolved);
|
|
611
|
-
if (await file.exists()) {
|
|
612
|
-
return new Response(file, { headers: { "Cache-Control": "no-cache" } });
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
return new Response("Not Found", { status: 404 });
|
|
616
|
-
}
|
|
617
|
-
const pubPath = safePath("./public", decodedPath);
|
|
618
|
-
if (pubPath) {
|
|
619
|
-
const pub = Bun.file(pubPath);
|
|
620
|
-
if (await pub.exists()) return new Response(pub);
|
|
621
|
-
}
|
|
622
|
-
const distPath = safePath(OUT_DIR, decodedPath);
|
|
623
|
-
if (distPath) {
|
|
624
|
-
const dist = Bun.file(distPath);
|
|
625
|
-
if (await dist.exists()) return new Response(dist);
|
|
626
|
-
}
|
|
627
|
-
const staticPath = safePath(`${OUT_DIR}/static`, decodedPath);
|
|
628
|
-
if (staticPath) {
|
|
629
|
-
const staticFile = Bun.file(staticPath);
|
|
630
|
-
if (await staticFile.exists()) return new Response(staticFile);
|
|
631
|
-
}
|
|
632
|
-
return new Response("Not Found", { status: 404 });
|
|
774
|
+
const parked = routeMatches.get(request);
|
|
775
|
+
const missed = parked?.staticMiss && parked.path === path;
|
|
776
|
+
return (
|
|
777
|
+
(missed ? null : await serveStaticFile(path, request)) ??
|
|
778
|
+
new Response("Not Found", { status: 404 })
|
|
779
|
+
);
|
|
633
780
|
}
|
|
634
781
|
|
|
635
782
|
// Prerendered pages — serve static HTML built at build time.
|
|
@@ -644,15 +791,19 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
644
791
|
const key = path === "/" ? "/" : path.replace(/\/$/, "");
|
|
645
792
|
const hit = prerenderManifest.get(key);
|
|
646
793
|
if (hit) {
|
|
794
|
+
// Stylesheets only: whether this page hydrates isn't known here, and a
|
|
795
|
+
// hint for a script it never runs is a wasted download.
|
|
796
|
+
const link = preloadLinkHeader(undefined, false);
|
|
647
797
|
return serveStatic(hit, request, {
|
|
648
798
|
"Content-Type": "text/html; charset=utf-8",
|
|
649
799
|
"Cache-Control": "public, max-age=3600",
|
|
800
|
+
...(link ? { Link: link } : {}),
|
|
650
801
|
});
|
|
651
802
|
}
|
|
652
803
|
}
|
|
653
804
|
|
|
654
|
-
//
|
|
655
|
-
const pageMatch =
|
|
805
|
+
// Matched once before the hooks; reused for trailing-slash, form-action, and SSR phases.
|
|
806
|
+
const pageMatch = routes.page;
|
|
656
807
|
|
|
657
808
|
// Trailing-slash canonicalization — 308 preserves method (form POSTs included)
|
|
658
809
|
if (pageMatch) {
|
|
@@ -677,6 +828,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
677
828
|
try {
|
|
678
829
|
const mod = await pageMatch.route.pageServer();
|
|
679
830
|
if (mod.actions && typeof mod.actions === "object") {
|
|
831
|
+
// Layout loaders gate route groups (`(private)/+layout.server.ts`
|
|
832
|
+
// redirecting anonymous visitors), so they run before any action:
|
|
833
|
+
// a POST must not reach code the same visitor could not GET.
|
|
834
|
+
// Their data is discarded; a redirect or error() they throw is
|
|
835
|
+
// handled by the catch below exactly like one from the action.
|
|
836
|
+
await loadRouteData(url, locals, request, cookies, null, pageMatch, {
|
|
837
|
+
page: false,
|
|
838
|
+
layouts: [],
|
|
839
|
+
});
|
|
680
840
|
const actionName = parseActionName(url);
|
|
681
841
|
const action = mod.actions[actionName];
|
|
682
842
|
if (!action) {
|
|
@@ -704,6 +864,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
704
864
|
}
|
|
705
865
|
|
|
706
866
|
event.params = pageMatch.params;
|
|
867
|
+
event.route = { id: pageMatch.route.id };
|
|
707
868
|
let result: any;
|
|
708
869
|
try {
|
|
709
870
|
result = await action(event);
|
|
@@ -971,11 +1132,38 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
971
1132
|
url: dataReq ? dataReq.routeUrl : url,
|
|
972
1133
|
locals: { nonce },
|
|
973
1134
|
params: {},
|
|
1135
|
+
route: { id: null },
|
|
974
1136
|
cookies: cookieJar,
|
|
975
1137
|
isDataRequest: dataReq !== null,
|
|
976
1138
|
platform: getPlatform(),
|
|
977
1139
|
};
|
|
978
|
-
|
|
1140
|
+
|
|
1141
|
+
// Static files are answered before the hooks: a session-lookup hook would
|
|
1142
|
+
// otherwise cost a DB query per chunk. A miss falls through to the hooks
|
|
1143
|
+
// and resolve(), which 404s. Prerendered pages are pages and stay behind
|
|
1144
|
+
// the hooks. Order mirrors resolve(): framework prefixes first (prod only,
|
|
1145
|
+
// unshadowable), then a user `+server.ts` wins over a same-path file.
|
|
1146
|
+
const path = event.url.pathname;
|
|
1147
|
+
let response: Response | null = null;
|
|
1148
|
+
let staticTried = false;
|
|
1149
|
+
if (!dataReq && staticManifest && (path.startsWith("/dist/") || path.startsWith("/__bosia/"))) {
|
|
1150
|
+
response = await serveStaticFile(path, request);
|
|
1151
|
+
staticTried = true;
|
|
1152
|
+
}
|
|
1153
|
+
if (!response) {
|
|
1154
|
+
const routes = await matchRoutes(path, dataReq !== null);
|
|
1155
|
+
routeMatches.set(request, routes);
|
|
1156
|
+
const matched = routes.api ?? routes.page;
|
|
1157
|
+
if (matched) {
|
|
1158
|
+
event.params = matched.params;
|
|
1159
|
+
event.route = { id: matched.route.id };
|
|
1160
|
+
}
|
|
1161
|
+
if (!dataReq && !routes.api && isStaticPath(path)) {
|
|
1162
|
+
if (!staticTried) response = await serveStaticFile(path, request);
|
|
1163
|
+
routes.staticMiss = !response;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
response ??=
|
|
979
1167
|
userHandle && !warmingUp ? await userHandle({ event, resolve }) : await resolve(event);
|
|
980
1168
|
|
|
981
1169
|
// A hook that short-circuits a data request with a redirect is answering
|
|
@@ -1000,7 +1188,24 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1000
1188
|
}
|
|
1001
1189
|
}
|
|
1002
1190
|
|
|
1003
|
-
|
|
1191
|
+
// Set on the response itself when its headers are mutable, which every
|
|
1192
|
+
// Response the framework builds has. Copying them and rebuilding the
|
|
1193
|
+
// Response cost a Headers copy plus a body re-wrap on every request.
|
|
1194
|
+
// Responses with immutable headers (`fetch()`, `Response.redirect()`)
|
|
1195
|
+
// still get the copy.
|
|
1196
|
+
// On Workers (compressionOn is false only there) an already-encoded body
|
|
1197
|
+
// is rebuilt as before, since the rebuild is what marks it PRECOMPRESSED.
|
|
1198
|
+
let mutable: Headers | null = null;
|
|
1199
|
+
if (compressionOn || !response.headers.has("content-encoding")) {
|
|
1200
|
+
try {
|
|
1201
|
+
response.headers.set("X-Content-Type-Options", SECURITY_HEADERS["X-Content-Type-Options"]!);
|
|
1202
|
+
mutable = response.headers;
|
|
1203
|
+
} catch {
|
|
1204
|
+
// immutable — copied below
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
const inPlace = mutable !== null;
|
|
1208
|
+
const headers = mutable ?? new Headers(response.headers);
|
|
1004
1209
|
// A handle can mark a response (e.g. a proxied embeddable preview) to opt
|
|
1005
1210
|
// out of the frame guard. Strip the internal marker so it never ships, and
|
|
1006
1211
|
// skip only X-Frame-Options for that response — other security headers stay.
|
|
@@ -1025,6 +1230,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1025
1230
|
}
|
|
1026
1231
|
// Apply any Set-Cookie headers accumulated during the request
|
|
1027
1232
|
for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
|
|
1233
|
+
if (inPlace) return response;
|
|
1028
1234
|
return new Response(response.body, {
|
|
1029
1235
|
// A Content-Encoding here means the body is already compressed (cache
|
|
1030
1236
|
// hit, compress()); rebuilding drops that flag and Workers would
|
|
@@ -1209,6 +1415,7 @@ function loadBuiltManifest(): RouteManifest {
|
|
|
1209
1415
|
return {
|
|
1210
1416
|
pages: serverRoutes.map((r: any) => ({
|
|
1211
1417
|
pattern: r.pattern,
|
|
1418
|
+
id: r.id,
|
|
1212
1419
|
page: "",
|
|
1213
1420
|
layouts: [],
|
|
1214
1421
|
pageServer: r.pageServer ? "" : null,
|
|
@@ -1219,7 +1426,7 @@ function loadBuiltManifest(): RouteManifest {
|
|
|
1219
1426
|
cache: r.cache ?? null,
|
|
1220
1427
|
prerender: false,
|
|
1221
1428
|
})),
|
|
1222
|
-
apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, server: "" })),
|
|
1429
|
+
apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, id: r.id, server: "" })),
|
|
1223
1430
|
errorPage: null,
|
|
1224
1431
|
};
|
|
1225
1432
|
}
|
|
@@ -1229,8 +1436,10 @@ export type CreateAppOptions = {
|
|
|
1229
1436
|
handle?: Handle | null;
|
|
1230
1437
|
};
|
|
1231
1438
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1439
|
+
// `url` is BosiaApp's own parse of `request.url`; handleRequest rewrites it in
|
|
1440
|
+
// place (BASE_PATH, X-Forwarded-*), which is safe because routing already ran.
|
|
1441
|
+
const frameworkHandler = ({ request, url }: { request: Request; url: URL }) =>
|
|
1442
|
+
handleRequest(request, url);
|
|
1234
1443
|
|
|
1235
1444
|
/** Build the backend app: plugins, the framework's catch-all routes, error handling. */
|
|
1236
1445
|
export async function createApp(options: CreateAppOptions = {}): Promise<BosiaApp> {
|