bosia 1.1.4 → 1.1.5
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 +28 -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 +99 -76
- package/src/core/routeFile.ts +36 -20
- package/src/core/scanner.ts +2 -0
- package/src/core/server.ts +294 -94
- 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
|
|
@@ -475,6 +648,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
475
648
|
const handlerResult = await handler({
|
|
476
649
|
request,
|
|
477
650
|
params: apiMatch.params,
|
|
651
|
+
route: { id: apiMatch.route.id },
|
|
478
652
|
url,
|
|
479
653
|
locals,
|
|
480
654
|
cookies,
|
|
@@ -585,51 +759,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
585
759
|
}
|
|
586
760
|
|
|
587
761
|
// Static files — fallthrough after API routes so explicit handlers win.
|
|
762
|
+
// handleRequest already served hits before the hooks; this answers a URL a
|
|
763
|
+
// hook rewrote, and the 404 for a miss (without looking a second time).
|
|
588
764
|
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 });
|
|
765
|
+
const parked = routeMatches.get(request);
|
|
766
|
+
const missed = parked?.staticMiss && parked.path === path;
|
|
767
|
+
return (
|
|
768
|
+
(missed ? null : await serveStaticFile(path, request)) ??
|
|
769
|
+
new Response("Not Found", { status: 404 })
|
|
770
|
+
);
|
|
633
771
|
}
|
|
634
772
|
|
|
635
773
|
// Prerendered pages — serve static HTML built at build time.
|
|
@@ -644,15 +782,19 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
644
782
|
const key = path === "/" ? "/" : path.replace(/\/$/, "");
|
|
645
783
|
const hit = prerenderManifest.get(key);
|
|
646
784
|
if (hit) {
|
|
785
|
+
// Stylesheets only: whether this page hydrates isn't known here, and a
|
|
786
|
+
// hint for a script it never runs is a wasted download.
|
|
787
|
+
const link = preloadLinkHeader(undefined, false);
|
|
647
788
|
return serveStatic(hit, request, {
|
|
648
789
|
"Content-Type": "text/html; charset=utf-8",
|
|
649
790
|
"Cache-Control": "public, max-age=3600",
|
|
791
|
+
...(link ? { Link: link } : {}),
|
|
650
792
|
});
|
|
651
793
|
}
|
|
652
794
|
}
|
|
653
795
|
|
|
654
|
-
//
|
|
655
|
-
const pageMatch =
|
|
796
|
+
// Matched once before the hooks; reused for trailing-slash, form-action, and SSR phases.
|
|
797
|
+
const pageMatch = routes.page;
|
|
656
798
|
|
|
657
799
|
// Trailing-slash canonicalization — 308 preserves method (form POSTs included)
|
|
658
800
|
if (pageMatch) {
|
|
@@ -677,6 +819,15 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
677
819
|
try {
|
|
678
820
|
const mod = await pageMatch.route.pageServer();
|
|
679
821
|
if (mod.actions && typeof mod.actions === "object") {
|
|
822
|
+
// Layout loaders gate route groups (`(private)/+layout.server.ts`
|
|
823
|
+
// redirecting anonymous visitors), so they run before any action:
|
|
824
|
+
// a POST must not reach code the same visitor could not GET.
|
|
825
|
+
// Their data is discarded; a redirect or error() they throw is
|
|
826
|
+
// handled by the catch below exactly like one from the action.
|
|
827
|
+
await loadRouteData(url, locals, request, cookies, null, pageMatch, {
|
|
828
|
+
page: false,
|
|
829
|
+
layouts: [],
|
|
830
|
+
});
|
|
680
831
|
const actionName = parseActionName(url);
|
|
681
832
|
const action = mod.actions[actionName];
|
|
682
833
|
if (!action) {
|
|
@@ -704,6 +855,7 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
704
855
|
}
|
|
705
856
|
|
|
706
857
|
event.params = pageMatch.params;
|
|
858
|
+
event.route = { id: pageMatch.route.id };
|
|
707
859
|
let result: any;
|
|
708
860
|
try {
|
|
709
861
|
result = await action(event);
|
|
@@ -971,11 +1123,38 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
971
1123
|
url: dataReq ? dataReq.routeUrl : url,
|
|
972
1124
|
locals: { nonce },
|
|
973
1125
|
params: {},
|
|
1126
|
+
route: { id: null },
|
|
974
1127
|
cookies: cookieJar,
|
|
975
1128
|
isDataRequest: dataReq !== null,
|
|
976
1129
|
platform: getPlatform(),
|
|
977
1130
|
};
|
|
978
|
-
|
|
1131
|
+
|
|
1132
|
+
// Static files are answered before the hooks: a session-lookup hook would
|
|
1133
|
+
// otherwise cost a DB query per chunk. A miss falls through to the hooks
|
|
1134
|
+
// and resolve(), which 404s. Prerendered pages are pages and stay behind
|
|
1135
|
+
// the hooks. Order mirrors resolve(): framework prefixes first (prod only,
|
|
1136
|
+
// unshadowable), then a user `+server.ts` wins over a same-path file.
|
|
1137
|
+
const path = event.url.pathname;
|
|
1138
|
+
let response: Response | null = null;
|
|
1139
|
+
let staticTried = false;
|
|
1140
|
+
if (!dataReq && staticManifest && (path.startsWith("/dist/") || path.startsWith("/__bosia/"))) {
|
|
1141
|
+
response = await serveStaticFile(path, request);
|
|
1142
|
+
staticTried = true;
|
|
1143
|
+
}
|
|
1144
|
+
if (!response) {
|
|
1145
|
+
const routes = await matchRoutes(path, dataReq !== null);
|
|
1146
|
+
routeMatches.set(request, routes);
|
|
1147
|
+
const matched = routes.api ?? routes.page;
|
|
1148
|
+
if (matched) {
|
|
1149
|
+
event.params = matched.params;
|
|
1150
|
+
event.route = { id: matched.route.id };
|
|
1151
|
+
}
|
|
1152
|
+
if (!dataReq && !routes.api && isStaticPath(path)) {
|
|
1153
|
+
if (!staticTried) response = await serveStaticFile(path, request);
|
|
1154
|
+
routes.staticMiss = !response;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
response ??=
|
|
979
1158
|
userHandle && !warmingUp ? await userHandle({ event, resolve }) : await resolve(event);
|
|
980
1159
|
|
|
981
1160
|
// A hook that short-circuits a data request with a redirect is answering
|
|
@@ -1000,7 +1179,24 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1000
1179
|
}
|
|
1001
1180
|
}
|
|
1002
1181
|
|
|
1003
|
-
|
|
1182
|
+
// Set on the response itself when its headers are mutable, which every
|
|
1183
|
+
// Response the framework builds has. Copying them and rebuilding the
|
|
1184
|
+
// Response cost a Headers copy plus a body re-wrap on every request.
|
|
1185
|
+
// Responses with immutable headers (`fetch()`, `Response.redirect()`)
|
|
1186
|
+
// still get the copy.
|
|
1187
|
+
// On Workers (compressionOn is false only there) an already-encoded body
|
|
1188
|
+
// is rebuilt as before, since the rebuild is what marks it PRECOMPRESSED.
|
|
1189
|
+
let mutable: Headers | null = null;
|
|
1190
|
+
if (compressionOn || !response.headers.has("content-encoding")) {
|
|
1191
|
+
try {
|
|
1192
|
+
response.headers.set("X-Content-Type-Options", SECURITY_HEADERS["X-Content-Type-Options"]!);
|
|
1193
|
+
mutable = response.headers;
|
|
1194
|
+
} catch {
|
|
1195
|
+
// immutable — copied below
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const inPlace = mutable !== null;
|
|
1199
|
+
const headers = mutable ?? new Headers(response.headers);
|
|
1004
1200
|
// A handle can mark a response (e.g. a proxied embeddable preview) to opt
|
|
1005
1201
|
// out of the frame guard. Strip the internal marker so it never ships, and
|
|
1006
1202
|
// skip only X-Frame-Options for that response — other security headers stay.
|
|
@@ -1025,6 +1221,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1025
1221
|
}
|
|
1026
1222
|
// Apply any Set-Cookie headers accumulated during the request
|
|
1027
1223
|
for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
|
|
1224
|
+
if (inPlace) return response;
|
|
1028
1225
|
return new Response(response.body, {
|
|
1029
1226
|
// A Content-Encoding here means the body is already compressed (cache
|
|
1030
1227
|
// hit, compress()); rebuilding drops that flag and Workers would
|
|
@@ -1209,6 +1406,7 @@ function loadBuiltManifest(): RouteManifest {
|
|
|
1209
1406
|
return {
|
|
1210
1407
|
pages: serverRoutes.map((r: any) => ({
|
|
1211
1408
|
pattern: r.pattern,
|
|
1409
|
+
id: r.id,
|
|
1212
1410
|
page: "",
|
|
1213
1411
|
layouts: [],
|
|
1214
1412
|
pageServer: r.pageServer ? "" : null,
|
|
@@ -1219,7 +1417,7 @@ function loadBuiltManifest(): RouteManifest {
|
|
|
1219
1417
|
cache: r.cache ?? null,
|
|
1220
1418
|
prerender: false,
|
|
1221
1419
|
})),
|
|
1222
|
-
apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, server: "" })),
|
|
1420
|
+
apis: apiRoutes.map((r: any) => ({ pattern: r.pattern, id: r.id, server: "" })),
|
|
1223
1421
|
errorPage: null,
|
|
1224
1422
|
};
|
|
1225
1423
|
}
|
|
@@ -1229,8 +1427,10 @@ export type CreateAppOptions = {
|
|
|
1229
1427
|
handle?: Handle | null;
|
|
1230
1428
|
};
|
|
1231
1429
|
|
|
1232
|
-
|
|
1233
|
-
|
|
1430
|
+
// `url` is BosiaApp's own parse of `request.url`; handleRequest rewrites it in
|
|
1431
|
+
// place (BASE_PATH, X-Forwarded-*), which is safe because routing already ran.
|
|
1432
|
+
const frameworkHandler = ({ request, url }: { request: Request; url: URL }) =>
|
|
1433
|
+
handleRequest(request, url);
|
|
1234
1434
|
|
|
1235
1435
|
/** Build the backend app: plugins, the framework's catch-all routes, error handling. */
|
|
1236
1436
|
export async function createApp(options: CreateAppOptions = {}): Promise<BosiaApp> {
|
|
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, statSync } from "fs";
|
|
|
2
2
|
import { basename, join, resolve as resolvePath } from "path";
|
|
3
3
|
import { PRECOMPRESSED, pickEncoding } from "./html.ts";
|
|
4
4
|
import { AssetCache, assetCache } from "./assetCache.ts";
|
|
5
|
+
import { matchesEtag } from "./etag.ts";
|
|
5
6
|
|
|
6
7
|
/** `br`/`gz`: absolute paths of build-time precompressed siblings (precompress.ts). */
|
|
7
8
|
export type StaticEntry = { absPath: string; cacheControl?: string; br?: string; gz?: string };
|
|
@@ -11,10 +12,63 @@ const HASHED_BASENAME = /\-[a-z0-9]{8,}\.[a-z]+$/;
|
|
|
11
12
|
const IMMUTABLE_CACHE = "public, max-age=31536000, immutable";
|
|
12
13
|
const DEFAULT_CACHE = "no-cache";
|
|
13
14
|
|
|
14
|
-
// Files/dirs at OUT_DIR root that
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
// Files/dirs at OUT_DIR root that must never be served — server code, build
|
|
16
|
+
// metadata, or re-merges already covered by the per-root walks. `server/` and
|
|
17
|
+
// `worker/` are server bundles; `hooks.server.js` and `bosia.config.js` are the
|
|
18
|
+
// user's own server code bundled for the runtime (with their dev `.map`s).
|
|
19
|
+
const OUT_DIR_SKIP_DIRS = new Set(["client", "static", "prerendered", "server", "worker"]);
|
|
20
|
+
const OUT_DIR_SKIP_FILES = new Set([
|
|
21
|
+
"manifest.json",
|
|
22
|
+
"route-manifest.json",
|
|
23
|
+
"app-html.json",
|
|
24
|
+
"svelte-maps.json",
|
|
25
|
+
"hooks.server.js",
|
|
26
|
+
"hooks.server.js.map",
|
|
27
|
+
"bosia.config.js",
|
|
28
|
+
"bosia.config.js.map",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether `rel` (a path relative to OUT_DIR, `/`-separated, no leading slash)
|
|
33
|
+
* is build output the server must not hand to a browser. Shared by the prod
|
|
34
|
+
* manifest and the dev per-request fallthrough so both refuse the same files.
|
|
35
|
+
*/
|
|
36
|
+
export function isPrivateOutPath(rel: string): boolean {
|
|
37
|
+
const slash = rel.indexOf("/");
|
|
38
|
+
return slash === -1 ? OUT_DIR_SKIP_FILES.has(rel) : OUT_DIR_SKIP_DIRS.has(rel.slice(0, slash));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Dev-mode lookup of a user file at the OUT_DIR root (the prod manifest covers
|
|
43
|
+
* this in production). `urlPath` is the decoded request path. Each segment must
|
|
44
|
+
* equal, exactly, a name `readdir` returns, and the joined real names must not
|
|
45
|
+
* be private. Matching the filesystem's own names instead of comparing request
|
|
46
|
+
* strings means no other spelling of a private file can get through: a
|
|
47
|
+
* different case, a trailing dot or space (Windows), an NTFS `::$DATA` stream,
|
|
48
|
+
* an 8.3 short name, or a Unicode case-folding twin like `ſerver` (macOS).
|
|
49
|
+
* Returns the absolute path, or null.
|
|
50
|
+
*/
|
|
51
|
+
export function findOutDirFile(outDir: string, urlPath: string): string | null {
|
|
52
|
+
const segments = urlPath.split("/").filter(Boolean);
|
|
53
|
+
if (segments.length === 0) return null;
|
|
54
|
+
if (isPrivateOutPath(segments.join("/"))) return null;
|
|
55
|
+
let dir = resolvePath(outDir);
|
|
56
|
+
for (let i = 0; i < segments.length; i++) {
|
|
57
|
+
let names: string[];
|
|
58
|
+
try {
|
|
59
|
+
names = readdirSync(dir);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (!names.includes(segments[i]!)) return null;
|
|
64
|
+
dir = join(dir, segments[i]!);
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
return statSync(dir).isFile() ? dir : null;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
18
72
|
|
|
19
73
|
const RESERVED_PREFIX = "/__bosia/";
|
|
20
74
|
|
|
@@ -94,13 +148,13 @@ export function buildStaticManifest(outDir: string): StaticManifest {
|
|
|
94
148
|
}
|
|
95
149
|
for (const ent of rootEntries) {
|
|
96
150
|
if (ent.isDirectory()) {
|
|
97
|
-
if (
|
|
151
|
+
if (isPrivateOutPath(`${ent.name}/`)) continue;
|
|
98
152
|
const sub = join(outAbs, ent.name);
|
|
99
153
|
for (const { abs, rel } of walk(sub, ent.name)) {
|
|
100
154
|
addOnce(manifest, `/${rel}`, { absPath: abs });
|
|
101
155
|
}
|
|
102
156
|
} else if (ent.isFile()) {
|
|
103
|
-
if (
|
|
157
|
+
if (isPrivateOutPath(ent.name)) continue;
|
|
104
158
|
addOnce(manifest, `/${ent.name}`, { absPath: join(outAbs, ent.name) });
|
|
105
159
|
}
|
|
106
160
|
}
|
|
@@ -215,15 +269,6 @@ function etagFor(path: string, encoding: string | undefined): string | null {
|
|
|
215
269
|
return tag;
|
|
216
270
|
}
|
|
217
271
|
|
|
218
|
-
function matchesEtag(header: string | null, etag: string): boolean {
|
|
219
|
-
if (!header) return false;
|
|
220
|
-
for (const raw of header.split(",")) {
|
|
221
|
-
const t = raw.trim();
|
|
222
|
-
if (t === "*" || t === etag || (t.startsWith("W/") && t.slice(2) === etag)) return true;
|
|
223
|
-
}
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
272
|
export function lookupStatic(manifest: StaticManifest, urlPath: string): StaticEntry | null {
|
|
228
273
|
const raw = urlPath.split("?")[0];
|
|
229
274
|
// Manifest keys are raw filenames; URLs arrive percent-encoded.
|