bosia 1.1.6 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/appHtml.ts +17 -0
- package/src/core/client/router.svelte.ts +1 -2
- package/src/core/html.ts +208 -29
- package/src/core/renderer.ts +133 -90
- package/src/core/server.ts +95 -80
- package/src/lib/utils.ts +1 -8
- package/templates/default/package.json +2 -2
- package/templates/demo/package.json +2 -2
- package/templates/shop/package.json +2 -2
- package/templates/shop/src/routes/(private)/dashboard/files/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/orders/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/orders/pending/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/orders/refunds/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/products/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/profile/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/settings/+page.svelte +9 -0
- package/templates/shop/src/routes/(private)/dashboard/users/+page.svelte +9 -0
- package/templates/store/package.json +2 -2
- package/templates/store/src/routes/(private)/dashboard/files/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/orders/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/orders/pending/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/orders/refunds/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/products/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/profile/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/settings/+page.svelte +9 -0
- package/templates/store/src/routes/(private)/dashboard/users/+page.svelte +9 -0
package/package.json
CHANGED
package/src/core/appHtml.ts
CHANGED
|
@@ -62,6 +62,23 @@ export function loadAppHtmlTemplate(cwd: string = process.cwd()): AppHtmlSegment
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// Comments and <script>/<style> bodies are matched only to be skipped; the
|
|
66
|
+
// first real `</head …>` or, when the optional </head> is left out, `<body`
|
|
67
|
+
// is where the head ends.
|
|
68
|
+
const HEAD_END_RE =
|
|
69
|
+
/<!--[\s\S]*?(?:-->|$)|<(script|style)\b[\s\S]*?(?:<\/\1\s*>|$)|<\/head\s*>|<body[\s>/]/gi;
|
|
70
|
+
|
|
71
|
+
/** Index in headClose (which runs on to %bosia.body%) where the head ends, or -1.
|
|
72
|
+
* Matches case-insensitively on the original string: lowercasing a copy can
|
|
73
|
+
* change its length (`İ`) and shift the index. */
|
|
74
|
+
export function findHeadEnd(headClose: string): number {
|
|
75
|
+
for (const m of headClose.matchAll(HEAD_END_RE)) {
|
|
76
|
+
if (m[0].startsWith("<!--") || m[1]) continue;
|
|
77
|
+
return m.index;
|
|
78
|
+
}
|
|
79
|
+
return -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
function replaceStaticPlaceholders(template: string): string {
|
|
66
83
|
// Replace %bosia.assets% with BOSIA_ASSETS_BASE env var
|
|
67
84
|
const assetsBase = process.env.BOSIA_ASSETS_BASE || "";
|
|
@@ -90,8 +90,7 @@ export const router = new (class Router {
|
|
|
90
90
|
pendingScroll: { x: number; y: number } | null = null;
|
|
91
91
|
/** Set by App.svelte; returns the mounted page's `export const snapshot` (if any) at capture time. */
|
|
92
92
|
getPageSnapshot:
|
|
93
|
-
|
|
94
|
-
| null = null;
|
|
93
|
+
(() => { capture: () => unknown; restore: (v: any) => void } | undefined) | null = null;
|
|
95
94
|
/** Captured snapshot for the entry a popstate/reload landed on; consumed once by App.svelte. `undefined` = nothing to restore. */
|
|
96
95
|
pendingSnapshot: unknown = undefined;
|
|
97
96
|
|
package/src/core/html.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { nonceAttr } from "./csp.ts";
|
|
|
5
5
|
import { rebaseHtmlAttrs } from "./basePath.ts";
|
|
6
6
|
import { currentBase } from "./appBase.ts";
|
|
7
7
|
import type { AppHtmlSegments } from "./appHtml.ts";
|
|
8
|
-
import { interpolateSegment } from "./appHtml.ts";
|
|
8
|
+
import { findHeadEnd, interpolateSegment } from "./appHtml.ts";
|
|
9
9
|
import type { Metadata } from "./hooks.ts";
|
|
10
10
|
|
|
11
11
|
// Workers compresses any body sent with a Content-Encoding header — again, if it
|
|
@@ -249,6 +249,8 @@ export function buildHtml(
|
|
|
249
249
|
segments?: AppHtmlSegments,
|
|
250
250
|
metadata?: Metadata | null,
|
|
251
251
|
pattern?: string,
|
|
252
|
+
headExtras?: string[],
|
|
253
|
+
fallbackTitle = DEFAULT_TITLE,
|
|
252
254
|
): string {
|
|
253
255
|
// An app writes <a href="/masuk">; under a base the browser has to be handed
|
|
254
256
|
// /sso/masuk or it walks off this app entirely. Only the rendered markup is
|
|
@@ -258,11 +260,11 @@ export function buildHtml(
|
|
|
258
260
|
head = rebaseHtmlAttrs(B, head);
|
|
259
261
|
|
|
260
262
|
// Metadata goes in before `head`: the first <title> in the document wins, and
|
|
261
|
-
// the
|
|
262
|
-
//
|
|
263
|
+
// buildMetadataChunk uses the same order on the streaming path, so both paths
|
|
264
|
+
// pick the same winner.
|
|
263
265
|
const metaTags = rebaseHtmlAttrs(B, metadataTags(metadata ?? null));
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
+
const extras = rebaseHtmlAttrs(B, headExtraTags(headExtras));
|
|
267
|
+
const fallback = titleFallback(segments, metadata, fallbackTitle, extras, head);
|
|
266
268
|
|
|
267
269
|
const n = nonceAttr(nonce);
|
|
268
270
|
const publicEnv = getPublicDynamicEnv();
|
|
@@ -307,7 +309,6 @@ export function buildHtml(
|
|
|
307
309
|
lang: safeKey,
|
|
308
310
|
nonce,
|
|
309
311
|
});
|
|
310
|
-
const headCloseInterpolated = interpolateSegment(segments.headClose, { nonce });
|
|
311
312
|
const tailInterpolated = interpolateSegment(segments.tail, { nonce });
|
|
312
313
|
const faviconLine = segments.hasCustomFavicon
|
|
313
314
|
? ""
|
|
@@ -319,8 +320,8 @@ export function buildHtml(
|
|
|
319
320
|
componentCssLinks() +
|
|
320
321
|
` <script${n}>${THEME_INIT_JS}</script>\n` +
|
|
321
322
|
preloads +
|
|
322
|
-
` ${
|
|
323
|
-
|
|
323
|
+
` ${metaTags}${extras}` +
|
|
324
|
+
closeHead(segments, lateHead(head, fallback), nonce) +
|
|
324
325
|
(body ? "" : `\n${SPINNER}`) +
|
|
325
326
|
`\n <div id="app">${body}</div>${scripts}${bodyEnd}` +
|
|
326
327
|
tailInterpolated
|
|
@@ -332,10 +333,8 @@ export function buildHtml(
|
|
|
332
333
|
<head>
|
|
333
334
|
<meta charset="UTF-8">
|
|
334
335
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
335
|
-
${fallbackTitle}
|
|
336
336
|
<link rel="icon" type="image/svg+xml" href="${FAVICON}">
|
|
337
|
-
${metaTags}
|
|
338
|
-
${twCssLink()}
|
|
337
|
+
${metaTags}${extras}${lateHead(head, fallback)} ${twCssLink()}
|
|
339
338
|
${componentCssLinks()} <script${n}>${THEME_INIT_JS}</script>
|
|
340
339
|
${preloads}</head>
|
|
341
340
|
<body>
|
|
@@ -424,29 +423,213 @@ export function metadataTags(metadata: Metadata | null): string {
|
|
|
424
423
|
return out;
|
|
425
424
|
}
|
|
426
425
|
|
|
426
|
+
/** app.html's headClose with `late` inserted just before its `</head>`, after any
|
|
427
|
+
* markup the app put after %bosia.head%, so <svelte:head> stays last in <head>
|
|
428
|
+
* and a page's styles and meta win over app.html's, as they did when it was
|
|
429
|
+
* injected client-side. No `</head>` in the segment → `late` goes first. */
|
|
430
|
+
function closeHead(segments: AppHtmlSegments, late: string, nonce?: string): string {
|
|
431
|
+
const { before, after } = headSplit(segments);
|
|
432
|
+
return (
|
|
433
|
+
(before ? interpolateSegment(before, { nonce }) : "") +
|
|
434
|
+
late +
|
|
435
|
+
interpolateSegment(after, { nonce })
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
type HeadSplit = { before: string; after: string; hasTitle: boolean };
|
|
440
|
+
|
|
441
|
+
// app.html never changes after it is parsed, so split it and check it for a
|
|
442
|
+
// title once per segments object instead of on every render. Kept in memory,
|
|
443
|
+
// not in dist/app-html.json, so it can't go stale against headClose.
|
|
444
|
+
const headSplits = new WeakMap<AppHtmlSegments, HeadSplit>();
|
|
445
|
+
|
|
446
|
+
function headSplit(segments: AppHtmlSegments): HeadSplit {
|
|
447
|
+
let split = headSplits.get(segments);
|
|
448
|
+
if (!split) {
|
|
449
|
+
const { headOpen, headClose } = segments;
|
|
450
|
+
const i = findHeadEnd(headClose);
|
|
451
|
+
const before = i < 0 ? "" : headClose.slice(0, i);
|
|
452
|
+
const after = i < 0 ? headClose : headClose.slice(i);
|
|
453
|
+
split = { before, after, hasTitle: hasTitle(headOpen) || hasTitle(before) };
|
|
454
|
+
headSplits.set(segments, split);
|
|
455
|
+
}
|
|
456
|
+
return split;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Whether a chunk of head markup already sets the document title. Matches a
|
|
460
|
+
* <title> with attributes too. Ignores one in a comment, an attribute value, a
|
|
461
|
+
* script or JSON-LD string, <style>, <noscript>, <template>, or an inline <svg>
|
|
462
|
+
* (which only labels the icon). */
|
|
463
|
+
export function hasTitle(html: string): boolean {
|
|
464
|
+
// A single forward scan, not a regex: a lazy `<script…</script>` match retries
|
|
465
|
+
// from every unclosed `<script` and goes quadratic on hostile {@html} output.
|
|
466
|
+
// Each tag is skipped whole, quotes included, so nothing inside an attribute
|
|
467
|
+
// looks like a tag. This is a heuristic, not a full HTML tokenizer.
|
|
468
|
+
let unclosed: Set<string> | undefined;
|
|
469
|
+
let i = 0;
|
|
470
|
+
while ((i = html.indexOf("<", i)) !== -1) {
|
|
471
|
+
if (html.startsWith("<!--", i)) {
|
|
472
|
+
i = commentEnd(html, i);
|
|
473
|
+
if (i === -1) return false; // unclosed comment runs to the end
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (!isAsciiAlpha(html.charCodeAt(i + 1))) {
|
|
477
|
+
i++;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const nameEnd = tagNameEnd(html, i + 1);
|
|
481
|
+
const tagEnd = openTagEnd(html, nameEnd);
|
|
482
|
+
if (tagEnd === -1) return false; // the rest is inside an unfinished tag
|
|
483
|
+
const name = nameEnd - i - 1 <= MAX_NAME ? html.slice(i + 1, nameEnd).toLowerCase() : "";
|
|
484
|
+
if (name === "title") return true;
|
|
485
|
+
i = tagEnd;
|
|
486
|
+
const close = SKIPPED_TAGS.get(name);
|
|
487
|
+
// Only foreign content like <svg/> honors the self-closing slash.
|
|
488
|
+
const selfClosed = name === "svg" && html.charCodeAt(tagEnd - 2) === 47;
|
|
489
|
+
if (!close || selfClosed || unclosed?.has(name)) continue;
|
|
490
|
+
// An opener with no closer is read as a plain tag, and remembered, so
|
|
491
|
+
// later openers of that tag don't search to the end again.
|
|
492
|
+
close.lastIndex = tagEnd;
|
|
493
|
+
const gt = close.exec(html) ? html.indexOf(">", close.lastIndex) : -1;
|
|
494
|
+
if (gt === -1) (unclosed ??= new Set()).add(name);
|
|
495
|
+
else i = gt + 1;
|
|
496
|
+
}
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** `</tag` followed by whitespace, `/` or `>`; the caller finds the `>` itself,
|
|
501
|
+
* since a `[^>]*>` here would rescan to the end for every candidate. */
|
|
502
|
+
const SKIPPED_TAGS = new Map(
|
|
503
|
+
["script", "style", "noscript", "template", "svg"].map((t) => [
|
|
504
|
+
t,
|
|
505
|
+
new RegExp(`</${t}(?=[\\t\\n\\f\\r />])`, "gi"),
|
|
506
|
+
]),
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
/** Longest tag name hasTitle needs to tell apart; longer names are never sliced. */
|
|
510
|
+
const MAX_NAME = Math.max("title".length, ...[...SKIPPED_TAGS.keys()].map((t) => t.length));
|
|
511
|
+
|
|
512
|
+
/** Index just past the comment opening at `start`, or -1 when it never closes.
|
|
513
|
+
* `<!-->` and `<!--->` are complete empty comments. */
|
|
514
|
+
function commentEnd(html: string, start: number): number {
|
|
515
|
+
if (html.startsWith(">", start + 4)) return start + 5;
|
|
516
|
+
if (html.startsWith("->", start + 4)) return start + 6;
|
|
517
|
+
const end = html.indexOf("-->", start + 4);
|
|
518
|
+
return end === -1 ? -1 : end + 3;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** End of the tag name starting at `start`: the first whitespace, `/` or `>`. */
|
|
522
|
+
function tagNameEnd(html: string, start: number): number {
|
|
523
|
+
let j = start;
|
|
524
|
+
while (j < html.length) {
|
|
525
|
+
const c = html.charCodeAt(j);
|
|
526
|
+
if (isHtmlSpace(c) || c === 47 || c === 62) break;
|
|
527
|
+
j++;
|
|
528
|
+
}
|
|
529
|
+
return j;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Index just past the `>` closing a start tag, skipping quoted attribute
|
|
533
|
+
* values, or -1 when the tag never closes. */
|
|
534
|
+
function openTagEnd(html: string, from: number): number {
|
|
535
|
+
for (let j = from; j < html.length; j++) {
|
|
536
|
+
const c = html.charCodeAt(j);
|
|
537
|
+
if (c === 62) return j + 1;
|
|
538
|
+
if (c !== 61) continue; // `=` starts a value
|
|
539
|
+
do j++;
|
|
540
|
+
while (j < html.length && isHtmlSpace(html.charCodeAt(j)));
|
|
541
|
+
const q = html.charCodeAt(j);
|
|
542
|
+
if (q === 34 || q === 39) {
|
|
543
|
+
j = html.indexOf(html[j]!, j + 1);
|
|
544
|
+
if (j === -1) return -1;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
// Unquoted: runs to whitespace or `>`, quotes and `=` included.
|
|
548
|
+
while (j < html.length && !isHtmlSpace(html.charCodeAt(j)) && html.charCodeAt(j) !== 62) j++;
|
|
549
|
+
j--;
|
|
550
|
+
}
|
|
551
|
+
return -1;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** HTML whitespace: tab, LF, FF, CR, space. Not \s, which also matches NBSP. */
|
|
555
|
+
function isHtmlSpace(c: number): boolean {
|
|
556
|
+
return c === 32 || c === 9 || c === 10 || c === 12 || c === 13;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function isAsciiAlpha(c: number): boolean {
|
|
560
|
+
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** Plugin `head` fragments, one per line, empty ones skipped. */
|
|
564
|
+
function headExtraTags(headExtras?: string[]): string {
|
|
565
|
+
let out = "";
|
|
566
|
+
for (const fragment of headExtras ?? []) {
|
|
567
|
+
if (fragment) out += ` ${fragment}\n`;
|
|
568
|
+
}
|
|
569
|
+
return out;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** The title a page gets when nothing else sets one. */
|
|
573
|
+
const DEFAULT_TITLE = "Bosia App";
|
|
574
|
+
|
|
575
|
+
/** `<title>{text}</title>` unless app.html or one of the head parts already
|
|
576
|
+
* has a title. Shared by buildHtml and buildMetadataChunk so both paths make
|
|
577
|
+
* the same call; each passes every part it writes into <head> (metadata,
|
|
578
|
+
* plugin extras, <svelte:head>). */
|
|
579
|
+
function titleFallback(
|
|
580
|
+
segments: AppHtmlSegments | undefined,
|
|
581
|
+
metadata: Metadata | null | undefined,
|
|
582
|
+
text: string,
|
|
583
|
+
...parts: string[]
|
|
584
|
+
): string {
|
|
585
|
+
if (metadata?.title || (segments && appHtmlHasTitle(segments))) return "";
|
|
586
|
+
return parts.some(hasTitle) ? "" : `<title>${escapeHtml(text)}</title>`;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Whether app.html sets its own <title> in its head. */
|
|
590
|
+
export function appHtmlHasTitle(segments: AppHtmlSegments): boolean {
|
|
591
|
+
return headSplit(segments).hasTitle;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** <svelte:head>, then the fallback title, for the very end of <head>. The
|
|
595
|
+
* fallback goes last so that if hasTitle ever misses a real <title>, the real
|
|
596
|
+
* one still comes first and wins; the cost is a spare tag, not a wrong title. */
|
|
597
|
+
function lateHead(head: string, fallbackTitle: string): string {
|
|
598
|
+
return (head ? ` ${head}\n` : "") + (fallbackTitle ? ` ${fallbackTitle}\n` : "");
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
export type MetadataChunkOptions = {
|
|
602
|
+
/** Plugin `head` fragments; they go right after the metadata() tags. */
|
|
603
|
+
headExtras?: string[];
|
|
604
|
+
segments?: AppHtmlSegments;
|
|
605
|
+
/** <svelte:head> output; it goes last, just before app.html's </head>. */
|
|
606
|
+
head?: string;
|
|
607
|
+
nonce?: string;
|
|
608
|
+
};
|
|
609
|
+
|
|
427
610
|
/** Chunk 2: metadata tags + close </head> + open <body> + spinner */
|
|
428
611
|
export function buildMetadataChunk(
|
|
429
612
|
metadata: Metadata | null,
|
|
430
|
-
headExtras
|
|
431
|
-
segments?: AppHtmlSegments,
|
|
613
|
+
{ headExtras, segments, head = "", nonce }: MetadataChunkOptions = {},
|
|
432
614
|
): string {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
615
|
+
// `head` goes in the real <head>, not in a script, so crawlers that don't run
|
|
616
|
+
// JS still see JSON-LD, icons and og tags. Same order as buildHtml: metadata,
|
|
617
|
+
// plugin extras, app.html's own head markup, then <svelte:head>.
|
|
618
|
+
const metaTags = metadataTags(metadata);
|
|
619
|
+
const extras = headExtraTags(headExtras);
|
|
620
|
+
const fallbackTitle = titleFallback(segments, metadata, DEFAULT_TITLE, extras, head);
|
|
621
|
+
let out = "\n" + metaTags + extras;
|
|
622
|
+
const late = lateHead(head, fallbackTitle);
|
|
440
623
|
|
|
441
624
|
if (segments) {
|
|
442
|
-
|
|
443
|
-
out += headCloseInterpolated + `\n${SPINNER}`;
|
|
625
|
+
out += closeHead(segments, late, nonce) + `\n${SPINNER}`;
|
|
444
626
|
} else {
|
|
445
|
-
out +=
|
|
627
|
+
out += `${late}</head>\n<body>\n${SPINNER}`;
|
|
446
628
|
}
|
|
447
629
|
|
|
448
|
-
//
|
|
449
|
-
//
|
|
630
|
+
// No loader data islands here, so it's safe to rebase wholesale. That picks up
|
|
631
|
+
// an app's own headExtras (a canonical link, an og:image on a local file) and
|
|
632
|
+
// <svelte:head> output, which buildHtml rebases the same way.
|
|
450
633
|
return rebaseHtmlAttrs(B, out);
|
|
451
634
|
}
|
|
452
635
|
|
|
@@ -464,7 +647,6 @@ export function escapeAttr(s: string): string {
|
|
|
464
647
|
|
|
465
648
|
export function buildHtmlTail(
|
|
466
649
|
body: string,
|
|
467
|
-
head: string,
|
|
468
650
|
pageData: any,
|
|
469
651
|
layoutData: any[],
|
|
470
652
|
csr: boolean,
|
|
@@ -478,13 +660,10 @@ export function buildHtmlTail(
|
|
|
478
660
|
): string {
|
|
479
661
|
// Same rebase as buildHtml — the streamed tail carries the identical markup.
|
|
480
662
|
body = rebaseHtmlAttrs(B, body);
|
|
481
|
-
head = rebaseHtmlAttrs(B, head);
|
|
482
663
|
|
|
483
664
|
const n = nonceAttr(nonce);
|
|
484
665
|
let out = `<script${n}>document.getElementById('__bs__').remove()</script>`;
|
|
485
666
|
out += `\n<div id="app">${body}</div>`;
|
|
486
|
-
if (head)
|
|
487
|
-
out += `\n<script${n}>document.head.insertAdjacentHTML('beforeend',${safeJsonStringify(head)})</script>`;
|
|
488
667
|
if (csr) {
|
|
489
668
|
out += baseScript(nonce);
|
|
490
669
|
const publicEnv = getPublicDynamicEnv();
|
package/src/core/renderer.ts
CHANGED
|
@@ -33,6 +33,8 @@ import {
|
|
|
33
33
|
compress,
|
|
34
34
|
compressBytes,
|
|
35
35
|
encodeForRequest,
|
|
36
|
+
appHtmlHasTitle,
|
|
37
|
+
hasTitle,
|
|
36
38
|
isDev,
|
|
37
39
|
withPreloadLink,
|
|
38
40
|
type Encoded,
|
|
@@ -358,6 +360,14 @@ function makeDepends(deps: LoaderDeps): (...keys: string[]) => void {
|
|
|
358
360
|
// Runs layout + page server loaders for a given URL.
|
|
359
361
|
// Used by both SSR and the /__bosia/data JSON endpoint.
|
|
360
362
|
//
|
|
363
|
+
// Every loader starts at once (SvelteKit's model). `parent()` is what orders
|
|
364
|
+
// them: it waits for the layers above and returns their merged data, so only a
|
|
365
|
+
// loader that asks for it waits. Results are still settled root → leaf, so the
|
|
366
|
+
// root-most failure decides the response, exactly as when they ran in turn.
|
|
367
|
+
// The cost: page code that runs before `await parent()` runs even when a layout
|
|
368
|
+
// guard redirects — its output never ships, but its side effects happen. Guard
|
|
369
|
+
// in hooks, or `await parent()` first.
|
|
370
|
+
//
|
|
361
371
|
// `mask` controls which layers' data the client data endpoint sends back:
|
|
362
372
|
// - undefined → send everything (SSR, first nav)
|
|
363
373
|
// - layouts[i] === false → the layout still RUNS, but its slot is emitted as
|
|
@@ -375,6 +385,8 @@ export type LoaderMask = {
|
|
|
375
385
|
layouts: boolean[];
|
|
376
386
|
};
|
|
377
387
|
|
|
388
|
+
type LayerResult = { data: Record<string, any>; deps: LoaderDeps };
|
|
389
|
+
|
|
378
390
|
export async function loadRouteData(
|
|
379
391
|
url: URL,
|
|
380
392
|
locals: Record<string, any>,
|
|
@@ -392,25 +404,61 @@ export async function loadRouteData(
|
|
|
392
404
|
const origin = url.origin;
|
|
393
405
|
const layoutData: (Record<string, any> | null)[] = [];
|
|
394
406
|
const layoutDeps: (LoaderDeps | null)[] = [];
|
|
395
|
-
let parentData: Record<string, any> = {};
|
|
396
407
|
// Shared across layout + page loaders so cross-loader duplicate
|
|
397
408
|
// setHeaders() calls throw. Keys stored lowercased.
|
|
398
409
|
const loaderHeaders: Record<string, string> = {};
|
|
399
410
|
const setHeaders = makeSetHeaders(loaderHeaders);
|
|
400
411
|
|
|
401
|
-
//
|
|
412
|
+
// Merged data of the first `n` layout layers. A fresh object per call, so
|
|
413
|
+
// loaders cannot mutate what the others see.
|
|
414
|
+
const layers: Promise<LayerResult>[] = [];
|
|
415
|
+
const parentOf = (n: number) => async () => {
|
|
416
|
+
const above = await Promise.all(layers.slice(0, n));
|
|
417
|
+
return Object.assign({}, ...above.map((l) => l.data));
|
|
418
|
+
};
|
|
419
|
+
|
|
402
420
|
for (const ls of route.layoutServers) {
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
try {
|
|
421
|
+
const parent = parentOf(layers.length);
|
|
422
|
+
const layer = (async (): Promise<LayerResult> => {
|
|
406
423
|
const mod = await ls.loader();
|
|
407
|
-
if (typeof mod.load
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
424
|
+
if (typeof mod.load !== "function" || warmingUp) return { data: {}, deps: emptyDeps() };
|
|
425
|
+
const deps = emptyDeps();
|
|
426
|
+
const data =
|
|
427
|
+
(await withTimeout(
|
|
428
|
+
mod.load({
|
|
429
|
+
params: trackedParams(params, deps),
|
|
430
|
+
route: { id: route.id },
|
|
431
|
+
url: trackedUrl(url, deps),
|
|
432
|
+
locals,
|
|
433
|
+
cookies: trackedCookies(cookies, deps),
|
|
434
|
+
parent,
|
|
435
|
+
fetch: trackedFetch(fetch, origin, deps),
|
|
436
|
+
metadata: null,
|
|
437
|
+
depends: makeDepends(deps),
|
|
438
|
+
platform: getPlatform(),
|
|
439
|
+
setHeaders,
|
|
440
|
+
}),
|
|
441
|
+
LOAD_TIMEOUT,
|
|
442
|
+
`layout load (depth=${ls.depth}, ${url.pathname})`,
|
|
443
|
+
)) ?? {};
|
|
444
|
+
return { data, deps };
|
|
445
|
+
})();
|
|
446
|
+
// Settled in order below; a rejection must not be reported as unhandled
|
|
447
|
+
// while an earlier layer is still being awaited.
|
|
448
|
+
layer.catch(() => {});
|
|
449
|
+
layers.push(layer);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const skipPage = mask && mask.page === false;
|
|
453
|
+
const pageP = route.pageServer
|
|
454
|
+
? (async () => {
|
|
455
|
+
const mod = await route.pageServer!();
|
|
456
|
+
const flags = { csr: mod.csr !== false, ssr: mod.ssr !== false };
|
|
457
|
+
if (skipPage) return { ...flags, data: null, deps: null };
|
|
458
|
+
if (typeof mod.load !== "function" || warmingUp)
|
|
459
|
+
return { ...flags, data: {}, deps: emptyDeps() };
|
|
412
460
|
const deps = emptyDeps();
|
|
413
|
-
const
|
|
461
|
+
const data =
|
|
414
462
|
(await withTimeout(
|
|
415
463
|
mod.load({
|
|
416
464
|
params: trackedParams(params, deps),
|
|
@@ -418,23 +466,35 @@ export async function loadRouteData(
|
|
|
418
466
|
url: trackedUrl(url, deps),
|
|
419
467
|
locals,
|
|
420
468
|
cookies: trackedCookies(cookies, deps),
|
|
421
|
-
parent,
|
|
469
|
+
parent: parentOf(layers.length),
|
|
422
470
|
fetch: trackedFetch(fetch, origin, deps),
|
|
423
|
-
metadata:
|
|
471
|
+
metadata: await metadataData,
|
|
424
472
|
depends: makeDepends(deps),
|
|
425
473
|
platform: getPlatform(),
|
|
426
474
|
setHeaders,
|
|
427
475
|
}),
|
|
428
476
|
LOAD_TIMEOUT,
|
|
429
|
-
`
|
|
477
|
+
`page load (${url.pathname})`,
|
|
430
478
|
)) ?? {};
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
479
|
+
return {
|
|
480
|
+
...flags,
|
|
481
|
+
data: data as Record<string, any> | null,
|
|
482
|
+
deps: deps as LoaderDeps | null,
|
|
483
|
+
};
|
|
484
|
+
})()
|
|
485
|
+
: null;
|
|
486
|
+
pageP?.catch(() => {});
|
|
487
|
+
|
|
488
|
+
// Settle root → leaf. The first failure wins, carrying the data of the
|
|
489
|
+
// layers above it — the same error a serial run would have produced.
|
|
490
|
+
for (let i = 0; i < route.layoutServers.length; i++) {
|
|
491
|
+
const ls = route.layoutServers[i]!;
|
|
492
|
+
// Runs regardless of the mask — see the trust note above loadRouteData.
|
|
493
|
+
const omit = mask && mask.layouts[ls.depth] === false;
|
|
494
|
+
try {
|
|
495
|
+
const { data, deps } = await layers[i]!;
|
|
496
|
+
layoutData[ls.depth] = omit ? null : data;
|
|
497
|
+
layoutDeps[ls.depth] = omit ? null : deps;
|
|
438
498
|
} catch (err) {
|
|
439
499
|
if (isRedirect(err)) throw err;
|
|
440
500
|
if (isHttpError(err)) {
|
|
@@ -460,47 +520,13 @@ export async function loadRouteData(
|
|
|
460
520
|
}
|
|
461
521
|
}
|
|
462
522
|
|
|
463
|
-
|
|
464
|
-
let
|
|
465
|
-
let pageDeps: LoaderDeps | null = null;
|
|
523
|
+
let pageData: Record<string, any> | null = {};
|
|
524
|
+
let pageDeps: LoaderDeps | null = emptyDeps();
|
|
466
525
|
let csr = true;
|
|
467
526
|
let ssr = true;
|
|
468
|
-
|
|
469
|
-
if (route.pageServer) {
|
|
527
|
+
if (pageP) {
|
|
470
528
|
try {
|
|
471
|
-
|
|
472
|
-
if (mod.csr === false) csr = false;
|
|
473
|
-
if (mod.ssr === false) ssr = false;
|
|
474
|
-
if (skipPage) {
|
|
475
|
-
pageData = null;
|
|
476
|
-
pageDeps = null;
|
|
477
|
-
} else if (typeof mod.load === "function" && !warmingUp) {
|
|
478
|
-
const snapshot = { ...parentData };
|
|
479
|
-
const parent = async () => snapshot;
|
|
480
|
-
const deps = emptyDeps();
|
|
481
|
-
pageData =
|
|
482
|
-
(await withTimeout(
|
|
483
|
-
mod.load({
|
|
484
|
-
params: trackedParams(params, deps),
|
|
485
|
-
route: { id: route.id },
|
|
486
|
-
url: trackedUrl(url, deps),
|
|
487
|
-
locals,
|
|
488
|
-
cookies: trackedCookies(cookies, deps),
|
|
489
|
-
parent,
|
|
490
|
-
fetch: trackedFetch(fetch, origin, deps),
|
|
491
|
-
metadata: await metadataData,
|
|
492
|
-
depends: makeDepends(deps),
|
|
493
|
-
platform: getPlatform(),
|
|
494
|
-
setHeaders,
|
|
495
|
-
}),
|
|
496
|
-
LOAD_TIMEOUT,
|
|
497
|
-
`page load (${url.pathname})`,
|
|
498
|
-
)) ?? {};
|
|
499
|
-
pageDeps = deps;
|
|
500
|
-
} else {
|
|
501
|
-
pageData = {};
|
|
502
|
-
pageDeps = emptyDeps();
|
|
503
|
-
}
|
|
529
|
+
({ data: pageData, deps: pageDeps, csr, ssr } = await pageP);
|
|
504
530
|
} catch (err) {
|
|
505
531
|
if (isRedirect(err)) throw err;
|
|
506
532
|
if (isHttpError(err)) {
|
|
@@ -524,9 +550,6 @@ export async function loadRouteData(
|
|
|
524
550
|
);
|
|
525
551
|
throw wrapped;
|
|
526
552
|
}
|
|
527
|
-
} else {
|
|
528
|
-
pageData = {};
|
|
529
|
-
pageDeps = emptyDeps();
|
|
530
553
|
}
|
|
531
554
|
|
|
532
555
|
// `params` are always attached to pageData for client-side router consumption.
|
|
@@ -655,8 +678,7 @@ export async function renderSSRStream(
|
|
|
655
678
|
// ── Pre-stream phase: metadata(), loaders, module imports and plugin
|
|
656
679
|
// fragments all start together, and all settle before committing to a 200,
|
|
657
680
|
// so a Redirect/HttpError from any of them still gets a proper response.
|
|
658
|
-
//
|
|
659
|
-
// waits for metadata.data (see loadRouteData).
|
|
681
|
+
// The page load() alone waits for metadata.data (see loadRouteData).
|
|
660
682
|
const metadataP = loadMetadata(route, params, url, locals, cookies, req).then(
|
|
661
683
|
(value) => ({ ok: true as const, value }),
|
|
662
684
|
(err: unknown) => ({ ok: false as const, err }),
|
|
@@ -795,9 +817,8 @@ export async function renderSSRStream(
|
|
|
795
817
|
}
|
|
796
818
|
const html =
|
|
797
819
|
buildHtmlShellOpen(metadata?.lang, nonce, appHtmlSegments, route.pattern) +
|
|
798
|
-
buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
|
|
820
|
+
buildMetadataChunk(metadata, { headExtras, segments: appHtmlSegments, nonce }) +
|
|
799
821
|
buildHtmlTail(
|
|
800
|
-
"",
|
|
801
822
|
"",
|
|
802
823
|
pageDataFull,
|
|
803
824
|
layoutDataFull,
|
|
@@ -866,10 +887,9 @@ export async function renderSSRStream(
|
|
|
866
887
|
appHtmlSegments,
|
|
867
888
|
data.csr ? route.pattern : undefined,
|
|
868
889
|
) +
|
|
869
|
-
buildMetadataChunk(metadata, headExtras, appHtmlSegments) +
|
|
890
|
+
buildMetadataChunk(metadata, { headExtras, segments: appHtmlSegments, head, nonce }) +
|
|
870
891
|
buildHtmlTail(
|
|
871
892
|
body,
|
|
872
|
-
head,
|
|
873
893
|
pageDataFull,
|
|
874
894
|
layoutDataFull,
|
|
875
895
|
data.csr,
|
|
@@ -972,11 +992,34 @@ export async function renderPageWithFormData(
|
|
|
972
992
|
|
|
973
993
|
const { route, params } = match;
|
|
974
994
|
|
|
975
|
-
//
|
|
995
|
+
// Same overlap as the GET path: metadata() and the loaders start together,
|
|
996
|
+
// and only the page load() waits for metadata.data. A metadata redirect or
|
|
997
|
+
// error() still answers first.
|
|
998
|
+
const metadataP = loadMetadata(route, params, url, locals, cookies, req).then(
|
|
999
|
+
(value) => ({ ok: true as const, value }),
|
|
1000
|
+
(err: unknown) => ({ ok: false as const, err }),
|
|
1001
|
+
);
|
|
1002
|
+
const loadP = Promise.all([
|
|
1003
|
+
loadRouteData(
|
|
1004
|
+
url,
|
|
1005
|
+
locals,
|
|
1006
|
+
req,
|
|
1007
|
+
cookies,
|
|
1008
|
+
metadataP.then((m) => (m.ok ? (m.value?.data ?? null) : null)),
|
|
1009
|
+
match,
|
|
1010
|
+
),
|
|
1011
|
+
route.pageModule(),
|
|
1012
|
+
Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
|
|
1013
|
+
]);
|
|
1014
|
+
// Settled by the early returns below without being read.
|
|
1015
|
+
loadP.catch(() => {});
|
|
1016
|
+
|
|
976
1017
|
let metadata: Metadata | null = null;
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1018
|
+
const meta = await metadataP;
|
|
1019
|
+
if (meta.ok) {
|
|
1020
|
+
metadata = meta.value;
|
|
1021
|
+
} else {
|
|
1022
|
+
const err = meta.err;
|
|
980
1023
|
if (isRedirect(err))
|
|
981
1024
|
return new Response(null, { status: err.status, headers: { Location: err.location } });
|
|
982
1025
|
if (isHttpError(err)) {
|
|
@@ -998,12 +1041,7 @@ export async function renderPageWithFormData(
|
|
|
998
1041
|
// Continue with null metadata — don't break the page for a metadata failure
|
|
999
1042
|
}
|
|
1000
1043
|
|
|
1001
|
-
|
|
1002
|
-
const [data, pageMod, layoutMods] = await Promise.all([
|
|
1003
|
-
loadRouteData(url, locals, req, cookies, metadata?.data ?? null, match),
|
|
1004
|
-
route.pageModule(),
|
|
1005
|
-
Promise.all(route.layoutModules.map((l: () => Promise<any>) => l())),
|
|
1006
|
-
]);
|
|
1044
|
+
const [data, pageMod, layoutMods] = await loadP;
|
|
1007
1045
|
|
|
1008
1046
|
if (!data)
|
|
1009
1047
|
return renderErrorPage(
|
|
@@ -1028,13 +1066,6 @@ export async function renderPageWithFormData(
|
|
|
1028
1066
|
pluginRenderFragments("head", renderCtx),
|
|
1029
1067
|
pluginRenderFragments("bodyEnd", renderCtx),
|
|
1030
1068
|
]);
|
|
1031
|
-
// buildHtml has no headExtras slot; fold them in ahead of the SSR head, which
|
|
1032
|
-
// is where buildMetadataChunk puts them on the streaming path.
|
|
1033
|
-
const headWithExtras = (ssrHead: string) => {
|
|
1034
|
-
const extras = headExtras.filter(Boolean);
|
|
1035
|
-
return extras.length ? `${extras.join("\n ")}\n ${ssrHead}` : ssrHead;
|
|
1036
|
-
};
|
|
1037
|
-
|
|
1038
1069
|
// Form-action re-render always runs every loader (no client mask).
|
|
1039
1070
|
const layoutDataFull = (data.layoutData as Record<string, any>[]).map((d) => d ?? {});
|
|
1040
1071
|
const pageDataFull = data.pageData ?? {};
|
|
@@ -1047,7 +1078,7 @@ export async function renderPageWithFormData(
|
|
|
1047
1078
|
}
|
|
1048
1079
|
const html = buildHtml(
|
|
1049
1080
|
"",
|
|
1050
|
-
|
|
1081
|
+
"",
|
|
1051
1082
|
pageDataFull,
|
|
1052
1083
|
layoutDataFull,
|
|
1053
1084
|
true,
|
|
@@ -1061,6 +1092,7 @@ export async function renderPageWithFormData(
|
|
|
1061
1092
|
appHtmlSegments,
|
|
1062
1093
|
metadata,
|
|
1063
1094
|
route.pattern,
|
|
1095
|
+
headExtras,
|
|
1064
1096
|
);
|
|
1065
1097
|
return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
|
|
1066
1098
|
}
|
|
@@ -1082,7 +1114,7 @@ export async function renderPageWithFormData(
|
|
|
1082
1114
|
|
|
1083
1115
|
const html = buildHtml(
|
|
1084
1116
|
body,
|
|
1085
|
-
|
|
1117
|
+
head,
|
|
1086
1118
|
pageDataFull,
|
|
1087
1119
|
layoutDataFull,
|
|
1088
1120
|
data.csr,
|
|
@@ -1096,6 +1128,7 @@ export async function renderPageWithFormData(
|
|
|
1096
1128
|
appHtmlSegments,
|
|
1097
1129
|
metadata,
|
|
1098
1130
|
route.pattern,
|
|
1131
|
+
headExtras,
|
|
1099
1132
|
);
|
|
1100
1133
|
return compress(html, "text/html; charset=utf-8", req, status, data.loaderHeaders);
|
|
1101
1134
|
}
|
|
@@ -1124,9 +1157,13 @@ export async function renderErrorPage(
|
|
|
1124
1157
|
// The route's own metadata() is deliberately NOT run here — the route may be
|
|
1125
1158
|
// what failed, and a 404 has no route at all. Synthesize a status title only
|
|
1126
1159
|
// when the error component didn't set one, so <svelte:head><title> in a custom
|
|
1127
|
-
// +error.svelte still wins.
|
|
1160
|
+
// +error.svelte still wins. It is the late fallback (after the component's
|
|
1161
|
+
// head, so a real title hasTitle misses still comes first), except when
|
|
1162
|
+
// app.html has its own <title>: that would come first, so the status title
|
|
1163
|
+
// takes the metadata slot ahead of it instead.
|
|
1164
|
+
const statusTitle = `${status} — ${message}`;
|
|
1128
1165
|
const errMeta = (head: string): Metadata | null =>
|
|
1129
|
-
|
|
1166
|
+
appHtmlHasTitle(appHtmlSegments) && !hasTitle(head) ? { title: statusTitle } : null;
|
|
1130
1167
|
|
|
1131
1168
|
// Inspector overlay and other plugin bodyEnd fragments must be injected
|
|
1132
1169
|
// on error pages too — otherwise SSE never connects and runtime errors
|
|
@@ -1135,7 +1172,7 @@ export async function renderErrorPage(
|
|
|
1135
1172
|
request: req,
|
|
1136
1173
|
url,
|
|
1137
1174
|
route: route ? { pattern: route.pattern } : { pattern: "" },
|
|
1138
|
-
metadata:
|
|
1175
|
+
metadata: { title: statusTitle },
|
|
1139
1176
|
};
|
|
1140
1177
|
const bodyEndExtras = await pluginRenderFragments("bodyEnd", renderCtx);
|
|
1141
1178
|
|
|
@@ -1192,6 +1229,9 @@ export async function renderErrorPage(
|
|
|
1192
1229
|
bodyEndExtras,
|
|
1193
1230
|
appHtmlSegments,
|
|
1194
1231
|
errMeta(head),
|
|
1232
|
+
undefined,
|
|
1233
|
+
undefined,
|
|
1234
|
+
statusTitle,
|
|
1195
1235
|
);
|
|
1196
1236
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
1197
1237
|
} catch (err) {
|
|
@@ -1230,6 +1270,9 @@ export async function renderErrorPage(
|
|
|
1230
1270
|
bodyEndExtras,
|
|
1231
1271
|
appHtmlSegments,
|
|
1232
1272
|
errMeta(head),
|
|
1273
|
+
undefined,
|
|
1274
|
+
undefined,
|
|
1275
|
+
statusTitle,
|
|
1233
1276
|
);
|
|
1234
1277
|
return compress(html, "text/html; charset=utf-8", req, status);
|
|
1235
1278
|
} catch (err) {
|
package/src/core/server.ts
CHANGED
|
@@ -404,33 +404,39 @@ async function resolve(event: RequestEvent): Promise<Response> {
|
|
|
404
404
|
)
|
|
405
405
|
: undefined;
|
|
406
406
|
const runLoad = async () => {
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
try {
|
|
412
|
-
const meta = await loadMetadata(
|
|
407
|
+
// metadata() runs alongside the loaders; the page load() gets its
|
|
408
|
+
// `data`, as on SSR. A failure stays non-fatal here.
|
|
409
|
+
const metaP = pageMatch
|
|
410
|
+
? loadMetadata(
|
|
413
411
|
pageMatch.route,
|
|
414
412
|
pageMatch.params,
|
|
415
413
|
routeUrl,
|
|
416
414
|
locals,
|
|
417
415
|
cookies,
|
|
418
416
|
request,
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
417
|
+
).catch(() => null)
|
|
418
|
+
: Promise.resolve(null);
|
|
419
|
+
const data = await loadRouteData(
|
|
420
|
+
routeUrl,
|
|
421
|
+
locals,
|
|
422
|
+
request,
|
|
423
|
+
cookies,
|
|
424
|
+
metaP.then((m) => m?.data ?? null),
|
|
425
|
+
pageMatch,
|
|
426
|
+
mask,
|
|
427
|
+
);
|
|
428
|
+
const meta = await metaP;
|
|
429
|
+
// Explicit whitelist, not a spread: `metadata.data` feeds load() on the
|
|
430
|
+
// server and may hold secrets — it must not reach the client.
|
|
431
|
+
const metadata = meta
|
|
432
|
+
? {
|
|
433
|
+
title: meta.title,
|
|
434
|
+
description: meta.description,
|
|
435
|
+
meta: meta.meta,
|
|
436
|
+
link: meta.link,
|
|
437
|
+
lang: meta.lang,
|
|
438
|
+
}
|
|
439
|
+
: null;
|
|
434
440
|
|
|
435
441
|
return { data, metadata, cookiesAccessed: (cookies as CookieJar).accessed };
|
|
436
442
|
};
|
|
@@ -1041,6 +1047,69 @@ if (_xfoDisabled) {
|
|
|
1041
1047
|
console.log("🪟 X-Frame-Options disabled (DISABLE_X_FRAME_OPTIONS=true)");
|
|
1042
1048
|
}
|
|
1043
1049
|
|
|
1050
|
+
// Security headers, CSP, CORS and the request's Set-Cookie headers, applied to
|
|
1051
|
+
// every response handleRequest hands back — resolved ones and the ones a hook
|
|
1052
|
+
// threw (redirect / error()).
|
|
1053
|
+
function finalizeResponse(
|
|
1054
|
+
response: Response,
|
|
1055
|
+
request: Request,
|
|
1056
|
+
nonce: string,
|
|
1057
|
+
cookieJar: CookieJar,
|
|
1058
|
+
): Response {
|
|
1059
|
+
// Set on the response itself when its headers are mutable, which every
|
|
1060
|
+
// Response the framework builds has. Copying them and rebuilding the
|
|
1061
|
+
// Response cost a Headers copy plus a body re-wrap on every request.
|
|
1062
|
+
// Responses with immutable headers (`fetch()`, `Response.redirect()`)
|
|
1063
|
+
// still get the copy.
|
|
1064
|
+
// On Workers (compressionOn is false only there) an already-encoded body
|
|
1065
|
+
// is rebuilt as before, since the rebuild is what marks it PRECOMPRESSED.
|
|
1066
|
+
let mutable: Headers | null = null;
|
|
1067
|
+
if (compressionOn || !response.headers.has("content-encoding")) {
|
|
1068
|
+
try {
|
|
1069
|
+
response.headers.set("X-Content-Type-Options", SECURITY_HEADERS["X-Content-Type-Options"]!);
|
|
1070
|
+
mutable = response.headers;
|
|
1071
|
+
} catch {
|
|
1072
|
+
// immutable — copied below
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
const inPlace = mutable !== null;
|
|
1076
|
+
const headers = mutable ?? new Headers(response.headers);
|
|
1077
|
+
// A handle can mark a response (e.g. a proxied embeddable preview) to opt
|
|
1078
|
+
// out of the frame guard. Strip the internal marker so it never ships, and
|
|
1079
|
+
// skip only X-Frame-Options for that response — other security headers stay.
|
|
1080
|
+
const skipFrameGuard = headers.has(NO_FRAME_GUARD_HEADER);
|
|
1081
|
+
headers.delete(NO_FRAME_GUARD_HEADER);
|
|
1082
|
+
for (const [k, v] of Object.entries(SECURITY_HEADERS)) {
|
|
1083
|
+
if (skipFrameGuard && k === "X-Frame-Options") continue;
|
|
1084
|
+
headers.set(k, v);
|
|
1085
|
+
}
|
|
1086
|
+
const cspHeader = buildCspHeader(nonce);
|
|
1087
|
+
if (cspHeader) headers.set("Content-Security-Policy", cspHeader);
|
|
1088
|
+
// Apply CORS headers for allowed origins. `Vary: Origin` is set whenever
|
|
1089
|
+
// CORS is configured — even on responses to non-allowed origins — so
|
|
1090
|
+
// downstream caches (CDNs, browser HTTP cache) key on the Origin header
|
|
1091
|
+
// instead of serving an Access-Control-Allow-Origin response across origins.
|
|
1092
|
+
if (CORS_CONFIG) {
|
|
1093
|
+
applyCorsVary(headers);
|
|
1094
|
+
const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
|
|
1095
|
+
if (corsHeaders) {
|
|
1096
|
+
for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
// Apply any Set-Cookie headers accumulated during the request
|
|
1100
|
+
for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
|
|
1101
|
+
if (inPlace) return response;
|
|
1102
|
+
return new Response(response.body, {
|
|
1103
|
+
// A Content-Encoding here means the body is already compressed (cache
|
|
1104
|
+
// hit, compress()); rebuilding drops that flag and Workers would
|
|
1105
|
+
// compress it again.
|
|
1106
|
+
...(headers.has("content-encoding") ? PRECOMPRESSED : {}),
|
|
1107
|
+
status: response.status,
|
|
1108
|
+
statusText: response.statusText,
|
|
1109
|
+
headers,
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1044
1113
|
async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
1045
1114
|
// Behind a trusted proxy the inbound `Host`/scheme is the proxy's inner hop
|
|
1046
1115
|
// (e.g. `localhost:PORT` over plain HTTP), so `url` built from `request.url`
|
|
@@ -1188,58 +1257,7 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1188
1257
|
}
|
|
1189
1258
|
}
|
|
1190
1259
|
|
|
1191
|
-
|
|
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);
|
|
1209
|
-
// A handle can mark a response (e.g. a proxied embeddable preview) to opt
|
|
1210
|
-
// out of the frame guard. Strip the internal marker so it never ships, and
|
|
1211
|
-
// skip only X-Frame-Options for that response — other security headers stay.
|
|
1212
|
-
const skipFrameGuard = headers.has(NO_FRAME_GUARD_HEADER);
|
|
1213
|
-
headers.delete(NO_FRAME_GUARD_HEADER);
|
|
1214
|
-
for (const [k, v] of Object.entries(SECURITY_HEADERS)) {
|
|
1215
|
-
if (skipFrameGuard && k === "X-Frame-Options") continue;
|
|
1216
|
-
headers.set(k, v);
|
|
1217
|
-
}
|
|
1218
|
-
const cspHeader = buildCspHeader(nonce);
|
|
1219
|
-
if (cspHeader) headers.set("Content-Security-Policy", cspHeader);
|
|
1220
|
-
// Apply CORS headers for allowed origins. `Vary: Origin` is set whenever
|
|
1221
|
-
// CORS is configured — even on responses to non-allowed origins — so
|
|
1222
|
-
// downstream caches (CDNs, browser HTTP cache) key on the Origin header
|
|
1223
|
-
// instead of serving an Access-Control-Allow-Origin response across origins.
|
|
1224
|
-
if (CORS_CONFIG) {
|
|
1225
|
-
applyCorsVary(headers);
|
|
1226
|
-
const corsHeaders = getCorsHeaders(request, CORS_CONFIG);
|
|
1227
|
-
if (corsHeaders) {
|
|
1228
|
-
for (const [k, v] of Object.entries(corsHeaders)) headers.set(k, v);
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
// Apply any Set-Cookie headers accumulated during the request
|
|
1232
|
-
for (const cookie of cookieJar.outgoing) headers.append("Set-Cookie", cookie);
|
|
1233
|
-
if (inPlace) return response;
|
|
1234
|
-
return new Response(response.body, {
|
|
1235
|
-
// A Content-Encoding here means the body is already compressed (cache
|
|
1236
|
-
// hit, compress()); rebuilding drops that flag and Workers would
|
|
1237
|
-
// compress it again.
|
|
1238
|
-
...(headers.has("content-encoding") ? PRECOMPRESSED : {}),
|
|
1239
|
-
status: response.status,
|
|
1240
|
-
statusText: response.statusText,
|
|
1241
|
-
headers,
|
|
1242
|
-
});
|
|
1260
|
+
return finalizeResponse(response, request, nonce, cookieJar);
|
|
1243
1261
|
} catch (err) {
|
|
1244
1262
|
// `throw redirect()` / `throw error()` from a hook lands here — the same
|
|
1245
1263
|
// escape hatch loaders have always had. Without these branches both fall
|
|
@@ -1273,13 +1291,10 @@ async function handleRequest(request: Request, url: URL): Promise<Response> {
|
|
|
1273
1291
|
undefined,
|
|
1274
1292
|
nonce,
|
|
1275
1293
|
);
|
|
1276
|
-
//
|
|
1277
|
-
// Set-Cookie
|
|
1278
|
-
//
|
|
1279
|
-
|
|
1280
|
-
for (const cookie of cookieJar.outgoing) out.headers.append("Set-Cookie", cookie);
|
|
1281
|
-
}
|
|
1282
|
-
return out;
|
|
1294
|
+
// The same finishing pass as a resolved response: security headers,
|
|
1295
|
+
// CSP, CORS, and the jar's Set-Cookie — a hook that expires the session
|
|
1296
|
+
// before throwing must not lose the cookie that does it.
|
|
1297
|
+
return cookieJar ? finalizeResponse(out, request, nonce, cookieJar) : out;
|
|
1283
1298
|
}
|
|
1284
1299
|
if (isDev) console.error("Unhandled request error:", err);
|
|
1285
1300
|
else console.error("Unhandled request error:", (err as Error).message ?? err);
|
package/src/lib/utils.ts
CHANGED
|
@@ -3,14 +3,7 @@ import { twMerge } from "tailwind-merge";
|
|
|
3
3
|
type ClassDictionary = Record<string, any>;
|
|
4
4
|
type ClassArray = ClassValue[];
|
|
5
5
|
type ClassValue =
|
|
6
|
-
|
|
|
7
|
-
| ClassDictionary
|
|
8
|
-
| string
|
|
9
|
-
| number
|
|
10
|
-
| bigint
|
|
11
|
-
| null
|
|
12
|
-
| boolean
|
|
13
|
-
| undefined;
|
|
6
|
+
ClassArray | ClassDictionary | string | number | bigint | null | boolean | undefined;
|
|
14
7
|
|
|
15
8
|
function clsx(...inputs: ClassValue[]): string {
|
|
16
9
|
let str = "";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: uploaded files (the file-upload feature). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Files</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Files</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: list orders and open their details (OrderService in src/features/shop). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Orders</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Orders</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: orders waiting to be paid or shipped. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Pending orders</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Pending orders</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: refund requests and refunded orders. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Refunds</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Refunds</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: list, create and edit products (ProductService in src/features/shop). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Products</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Products</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: the signed-in user's name, email and password. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Profile</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Profile</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: store settings. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Settings</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Settings</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: users and their roles (the auth and rbac features). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Users</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Users</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: uploaded files (the file-upload feature). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Files</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Files</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: list orders and open their details (OrderService in src/features/shop). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Orders</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Orders</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: orders waiting to be paid or shipped. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Pending orders</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Pending orders</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: refund requests and refunded orders. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Refunds</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Refunds</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: list, create and edit products (ProductService in src/features/shop). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Products</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Products</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: the signed-in user's name, email and password. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Profile</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Profile</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: store settings. -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Settings</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Settings</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<!-- EDIT THIS FILE: users and their roles (the auth and rbac features). -->
|
|
2
|
+
<svelte:head>
|
|
3
|
+
<title>Users</title>
|
|
4
|
+
</svelte:head>
|
|
5
|
+
|
|
6
|
+
<div class="flex flex-col gap-4">
|
|
7
|
+
<h1 class="text-2xl font-bold tracking-tight">Users</h1>
|
|
8
|
+
<p class="text-muted-foreground text-sm">Nothing here yet. Build this page when you need it.</p>
|
|
9
|
+
</div>
|