@pylonsync/functions 0.22.59 → 0.22.61
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/dist/offline-sw.d.ts +2 -0
- package/dist/ssr-client-bundler.d.ts +28 -1
- package/dist/ssr-client-nav.d.ts +7 -0
- package/dist/ssr-page-tree.d.ts +1 -1
- package/dist/ssr-route-assets.d.ts +20 -1
- package/dist/ssr-runtime.d.ts +29 -9
- package/dist/types.d.ts +18 -0
- package/package.json +2 -2
- package/src/offline-sw.test.ts +36 -1
- package/src/offline-sw.ts +26 -1
- package/src/runtime-db.test.ts +12 -0
- package/src/runtime.ts +22 -0
- package/src/ssr-client-bundler.ts +159 -59
- package/src/ssr-client-nav-runtime.fixture.ts +168 -8
- package/src/ssr-client-nav-runtime.test.ts +68 -4
- package/src/ssr-client-nav.test.ts +7 -0
- package/src/ssr-client-nav.ts +42 -1
- package/src/ssr-metadata.test.ts +251 -0
- package/src/ssr-offline-shell.test.ts +26 -0
- package/src/ssr-page-tree.ts +5 -8
- package/src/ssr-route-assets.ts +24 -3
- package/src/ssr-runtime.ts +217 -42
- package/src/ssr-static-graph.test.ts +35 -0
- package/src/types.ts +20 -0
package/src/ssr-runtime.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { withMetaSlot } from "./ssr-page-tree";
|
|
11
11
|
import { moduleKey, serverBundle } from "./server-bundle";
|
|
12
12
|
import {
|
|
13
|
+
modulepreloadFiles,
|
|
13
14
|
routeAssetFiles,
|
|
14
15
|
routeScriptFiles,
|
|
15
16
|
type RouteAssetFiles,
|
|
@@ -494,11 +495,11 @@ export function finalizeHeaders(
|
|
|
494
495
|
* (static) or `export async function generateMetadata(props)` (dynamic,
|
|
495
496
|
* e.g. param-derived titles). Kept flat — no deep nesting beyond og/twitter.
|
|
496
497
|
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
498
|
+
* A layout can export the same; the page's values win (`mergeMetadata`).
|
|
499
|
+
* The resulting <title>/<meta>/<link> are written into <head> by the server,
|
|
500
|
+
* whatever the layouts render. A tag from the metadata replaces a tag the
|
|
501
|
+
* root layout writes in its own <head> for the same title, `name`,
|
|
502
|
+
* `property` or canonical link (`dropReplacedHeadTags`).
|
|
502
503
|
*/
|
|
503
504
|
/** A single OpenGraph image (for `openGraph.images` — multiple images). */
|
|
504
505
|
export interface OgImage {
|
|
@@ -586,9 +587,9 @@ export interface SsrMetadata {
|
|
|
586
587
|
|
|
587
588
|
/**
|
|
588
589
|
* Build a React fragment of <title>/<meta>/<link> from a page's metadata.
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
*
|
|
590
|
+
* The server renders it to HTML on its own and splices it in before
|
|
591
|
+
* </head>. React escapes all text/attrs, so there's no manual XSS
|
|
592
|
+
* handling. Returns null when there's nothing to emit.
|
|
592
593
|
*/
|
|
593
594
|
export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
|
|
594
595
|
if (!m) return null;
|
|
@@ -732,6 +733,136 @@ export function renderJsonLdHead(m: SsrMetadata | undefined): string {
|
|
|
732
733
|
return html;
|
|
733
734
|
}
|
|
734
735
|
|
|
736
|
+
/**
|
|
737
|
+
* The metadata a route renders with: each layout's, root first, then the
|
|
738
|
+
* page's. A later value replaces an earlier one. `openGraph`, `twitter`,
|
|
739
|
+
* `icons` and `alternates` merge field by field. A new `openGraph.image` or
|
|
740
|
+
* `images` drops the earlier image fields, and a new `twitter.image` drops
|
|
741
|
+
* the earlier `twitter.imageAlt`: they describe the other image.
|
|
742
|
+
*/
|
|
743
|
+
export function mergeMetadata(
|
|
744
|
+
parent: SsrMetadata | undefined,
|
|
745
|
+
child: SsrMetadata | undefined,
|
|
746
|
+
): SsrMetadata | undefined {
|
|
747
|
+
if (!parent) return child;
|
|
748
|
+
if (!child) return parent;
|
|
749
|
+
const out: SsrMetadata = { ...parent, ...child };
|
|
750
|
+
if (parent.openGraph && child.openGraph) {
|
|
751
|
+
const base: NonNullable<SsrMetadata["openGraph"]> = { ...parent.openGraph };
|
|
752
|
+
if (child.openGraph.image !== undefined || child.openGraph.images !== undefined) {
|
|
753
|
+
delete base.image;
|
|
754
|
+
delete base.imageSecureUrl;
|
|
755
|
+
delete base.imageType;
|
|
756
|
+
delete base.imageWidth;
|
|
757
|
+
delete base.imageHeight;
|
|
758
|
+
delete base.imageAlt;
|
|
759
|
+
delete base.images;
|
|
760
|
+
}
|
|
761
|
+
out.openGraph = { ...base, ...child.openGraph };
|
|
762
|
+
}
|
|
763
|
+
if (parent.twitter && child.twitter) {
|
|
764
|
+
const base: NonNullable<SsrMetadata["twitter"]> = { ...parent.twitter };
|
|
765
|
+
if (child.twitter.image !== undefined) delete base.imageAlt;
|
|
766
|
+
out.twitter = { ...base, ...child.twitter };
|
|
767
|
+
}
|
|
768
|
+
if (parent.icons && child.icons) out.icons = { ...parent.icons, ...child.icons };
|
|
769
|
+
if (parent.alternates && child.alternates) {
|
|
770
|
+
out.alternates = { ...parent.alternates, ...child.alternates };
|
|
771
|
+
}
|
|
772
|
+
return out;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* The merged `metadata` / `generateMetadata` of a route's layouts, root
|
|
777
|
+
* first (see `mergeMetadata`). A layout module that fails to import is
|
|
778
|
+
* skipped here: the layout render reports that failure.
|
|
779
|
+
*/
|
|
780
|
+
async function resolveLayoutMetadata(
|
|
781
|
+
cwd: string,
|
|
782
|
+
layouts: string[] | undefined,
|
|
783
|
+
props: any,
|
|
784
|
+
): Promise<SsrMetadata | undefined> {
|
|
785
|
+
let merged: SsrMetadata | undefined;
|
|
786
|
+
for (const layoutPath of layouts ?? []) {
|
|
787
|
+
let lMod: any;
|
|
788
|
+
try {
|
|
789
|
+
lMod = await importModule(cwd, layoutPath);
|
|
790
|
+
} catch {
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
const own: SsrMetadata | undefined =
|
|
794
|
+
typeof lMod.generateMetadata === "function"
|
|
795
|
+
? await lMod.generateMetadata(props)
|
|
796
|
+
: lMod.metadata;
|
|
797
|
+
merged = mergeMetadata(merged, own);
|
|
798
|
+
}
|
|
799
|
+
return merged;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// One tag in a document head: a <script>/<style> block (matched so tags
|
|
803
|
+
// written inside it are skipped), a <title> element, or a <meta>/<link> tag.
|
|
804
|
+
// Quoted attribute values may hold `>`.
|
|
805
|
+
const HEAD_TAG_RE =
|
|
806
|
+
/<(script|style)\b[\s\S]*?<\/\1\s*>|<title\b[^>]*>[\s\S]*?<\/title\s*>|<(?:meta|link)\b(?:[^>"']|"[^"]*"|'[^']*')*>/gi;
|
|
807
|
+
const HEAD_ATTR_RE = /\s([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
|
|
808
|
+
|
|
809
|
+
/** What a head tag sets: `title`, `name:<name>`, `property:<property>` or
|
|
810
|
+
* `canonical`. Null for a tag that page metadata never replaces. */
|
|
811
|
+
function headTagKey(tag: string): string | null {
|
|
812
|
+
const start = tag.slice(0, 6).toLowerCase();
|
|
813
|
+
if (start.startsWith("<title")) return "title";
|
|
814
|
+
const isMeta = start.startsWith("<meta");
|
|
815
|
+
if (!isMeta && !start.startsWith("<link")) return null;
|
|
816
|
+
const attrs: Record<string, string> = {};
|
|
817
|
+
for (const m of tag.matchAll(HEAD_ATTR_RE)) {
|
|
818
|
+
attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? "";
|
|
819
|
+
}
|
|
820
|
+
if (!isMeta) return attrs.rel?.toLowerCase() === "canonical" ? "canonical" : null;
|
|
821
|
+
if (attrs.property) return `property:${attrs.property.toLowerCase()}`;
|
|
822
|
+
if (attrs.name) return `name:${attrs.name.toLowerCase()}`;
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** The keys (see `headTagKey`) that the tags in `html` set. */
|
|
827
|
+
export function headTagKeys(html: string): Set<string> {
|
|
828
|
+
const keys = new Set<string>();
|
|
829
|
+
for (const m of html.matchAll(HEAD_TAG_RE)) {
|
|
830
|
+
const key = headTagKey(m[0]);
|
|
831
|
+
if (key) keys.add(key);
|
|
832
|
+
}
|
|
833
|
+
return keys;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Remove from `head` (the document up to `</head>`) the tags that the page
|
|
838
|
+
* metadata replaces: a <title>, a <meta> with the same `name` or `property`,
|
|
839
|
+
* and a canonical link. A new `og:image` or `twitter:image` also removes the
|
|
840
|
+
* old image's `og:image:*` or `twitter:image:*` tags. So a layout can write
|
|
841
|
+
* default tags in its own <head>, and a page's metadata replaces them.
|
|
842
|
+
*/
|
|
843
|
+
export function dropReplacedHeadTags(head: string, replacing: Set<string>): string {
|
|
844
|
+
if (replacing.size === 0) return head;
|
|
845
|
+
const replaced = (key: string): boolean => {
|
|
846
|
+
if (replacing.has(key)) return true;
|
|
847
|
+
for (const image of ["property:og:image", "name:twitter:image"]) {
|
|
848
|
+
if (key.startsWith(`${image}:`) && replacing.has(image)) return true;
|
|
849
|
+
}
|
|
850
|
+
return false;
|
|
851
|
+
};
|
|
852
|
+
return head.replace(HEAD_TAG_RE, (tag) => {
|
|
853
|
+
const key = headTagKey(tag);
|
|
854
|
+
return key && replaced(key) ? "" : tag;
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** The first <title> element in `html`, or "". */
|
|
859
|
+
export function firstTitleTag(html: string): string {
|
|
860
|
+
for (const m of html.matchAll(HEAD_TAG_RE)) {
|
|
861
|
+
if (headTagKey(m[0]) === "title") return m[0];
|
|
862
|
+
}
|
|
863
|
+
return "";
|
|
864
|
+
}
|
|
865
|
+
|
|
735
866
|
const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
|
|
736
867
|
|
|
737
868
|
/**
|
|
@@ -1508,12 +1639,16 @@ export async function streamWithHeadInjection(
|
|
|
1508
1639
|
headBlob: string,
|
|
1509
1640
|
sendChunk: (text: string) => void,
|
|
1510
1641
|
bodyBlob = "",
|
|
1642
|
+
rewriteHead?: (head: string) => string,
|
|
1511
1643
|
): Promise<void> {
|
|
1512
1644
|
// Injections in document order, each consumed the first time its marker is
|
|
1513
1645
|
// seen. Once the list empties the reader is a straight pass-through, which
|
|
1514
|
-
// is every chunk after the <head> on a normal page.
|
|
1515
|
-
|
|
1516
|
-
|
|
1646
|
+
// is every chunk after the <head> on a normal page. With `rewriteHead`, the
|
|
1647
|
+
// document up to `</head>` is held and passed through it before it is sent.
|
|
1648
|
+
const pending: Array<{ marker: string; blob: string; rewrite?: (text: string) => string }> = [];
|
|
1649
|
+
if (headBlob.length > 0 || rewriteHead) {
|
|
1650
|
+
pending.push({ marker: "</head>", blob: headBlob, rewrite: rewriteHead });
|
|
1651
|
+
}
|
|
1517
1652
|
if (bodyBlob.length > 0) pending.push({ marker: "</body>", blob: bodyBlob });
|
|
1518
1653
|
let carry = "";
|
|
1519
1654
|
const decoder = new TextDecoder("utf-8");
|
|
@@ -1524,7 +1659,8 @@ export async function streamWithHeadInjection(
|
|
|
1524
1659
|
const next = pending[0];
|
|
1525
1660
|
const idx = buf.indexOf(next.marker);
|
|
1526
1661
|
if (idx < 0) break;
|
|
1527
|
-
|
|
1662
|
+
const before = buf.slice(0, idx);
|
|
1663
|
+
sendChunk(next.rewrite ? next.rewrite(before) : before);
|
|
1528
1664
|
sendChunk(next.blob);
|
|
1529
1665
|
sendChunk(next.marker);
|
|
1530
1666
|
buf = buf.slice(idx + next.marker.length);
|
|
@@ -1534,6 +1670,10 @@ export async function streamWithHeadInjection(
|
|
|
1534
1670
|
if (buf) sendChunk(buf);
|
|
1535
1671
|
return;
|
|
1536
1672
|
}
|
|
1673
|
+
if (pending[0].rewrite) {
|
|
1674
|
+
carry = buf;
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1537
1677
|
// Withhold len(marker) − 1 so a marker straddling a chunk boundary is
|
|
1538
1678
|
// still matched once the next read arrives.
|
|
1539
1679
|
const keep = pending[0].marker.length - 1;
|
|
@@ -2435,7 +2575,8 @@ async function renderBoundaryToClient(
|
|
|
2435
2575
|
},
|
|
2436
2576
|
// Design render: no hydration tail, inlined CSS, `<base href>`.
|
|
2437
2577
|
design?: { headers: Record<string, string | undefined> },
|
|
2438
|
-
//
|
|
2578
|
+
// The boundary's metadata as head HTML (see `renderMetadata`). It
|
|
2579
|
+
// replaces the layout's own tags for the same keys.
|
|
2439
2580
|
metaHeadHtml = "",
|
|
2440
2581
|
): Promise<void> {
|
|
2441
2582
|
const stream: ReadableStream<Uint8Array> = await renderToReadableStream(tree, {
|
|
@@ -2471,7 +2612,7 @@ async function renderBoundaryToClient(
|
|
|
2471
2612
|
for (const css of manifestRoute.css) {
|
|
2472
2613
|
headBlob += await cssHeadTag(css, publicPrefix);
|
|
2473
2614
|
}
|
|
2474
|
-
for (const chunk of manifestRoute
|
|
2615
|
+
for (const chunk of modulepreloadFiles(manifestRoute)) {
|
|
2475
2616
|
headBlob += `<link rel="modulepreload"${co} href="${publicPrefix}${chunk}">`;
|
|
2476
2617
|
}
|
|
2477
2618
|
} else {
|
|
@@ -2491,11 +2632,15 @@ async function renderBoundaryToClient(
|
|
|
2491
2632
|
data: Buffer.from(text, "utf8").toString("base64"),
|
|
2492
2633
|
});
|
|
2493
2634
|
};
|
|
2635
|
+
const replacedHeadTags = headTagKeys(metaHeadHtml);
|
|
2494
2636
|
await streamWithHeadInjection(
|
|
2495
2637
|
stream.getReader(),
|
|
2496
2638
|
headBlob,
|
|
2497
2639
|
sendChunk,
|
|
2498
2640
|
design ? "" : buildCloudBadgeChunk(),
|
|
2641
|
+
replacedHeadTags.size > 0
|
|
2642
|
+
? (head) => dropReplacedHeadTags(head, replacedHeadTags)
|
|
2643
|
+
: undefined,
|
|
2499
2644
|
);
|
|
2500
2645
|
if (tail && manifestRoute && !design) {
|
|
2501
2646
|
const tailHtml = buildHydrationTail({
|
|
@@ -2562,21 +2707,24 @@ async function tryRenderBoundary(
|
|
|
2562
2707
|
};
|
|
2563
2708
|
compProps = { ...props, error: errorForClient, reset: () => {} };
|
|
2564
2709
|
}
|
|
2565
|
-
// A boundary's own `metadata` / `generateMetadata`, same as a page's
|
|
2566
|
-
// Without this a not-found.tsx rendered no
|
|
2567
|
-
// the framework's built-in 404 body
|
|
2568
|
-
//
|
|
2569
|
-
//
|
|
2570
|
-
// <title>/<meta> into the <head> a layout renders.
|
|
2710
|
+
// A boundary's own `metadata` / `generateMetadata`, same as a page's,
|
|
2711
|
+
// over its layouts' metadata. Without this a not-found.tsx rendered no
|
|
2712
|
+
// <title> at all — and because the framework's built-in 404 body
|
|
2713
|
+
// supplies one, taking over the boundary silently LOST the title rather
|
|
2714
|
+
// than replacing it, leaving the tab showing the raw URL.
|
|
2571
2715
|
let boundaryMeta: SsrMetadata | undefined = mod.metadata;
|
|
2572
|
-
|
|
2573
|
-
|
|
2716
|
+
try {
|
|
2717
|
+
if (typeof mod.generateMetadata === "function") {
|
|
2574
2718
|
boundaryMeta = await mod.generateMetadata(compProps);
|
|
2575
|
-
} catch {
|
|
2576
|
-
// A boundary is already the failure path; don't fail it again over
|
|
2577
|
-
// its title.
|
|
2578
|
-
boundaryMeta = mod.metadata;
|
|
2579
2719
|
}
|
|
2720
|
+
boundaryMeta = mergeMetadata(
|
|
2721
|
+
await resolveLayoutMetadata(cwd, boundaryLayouts, compProps),
|
|
2722
|
+
boundaryMeta,
|
|
2723
|
+
);
|
|
2724
|
+
} catch {
|
|
2725
|
+
// A boundary is already the failure path; don't fail it again over
|
|
2726
|
+
// its title.
|
|
2727
|
+
boundaryMeta = mod.metadata;
|
|
2580
2728
|
}
|
|
2581
2729
|
// The same file conventions a page gets, resolved against the BOUNDARY's
|
|
2582
2730
|
// own directory: a colocated opengraph-image / icon next to not-found.tsx
|
|
@@ -2592,13 +2740,16 @@ async function tryRenderBoundary(
|
|
|
2592
2740
|
true,
|
|
2593
2741
|
);
|
|
2594
2742
|
boundaryMeta = applyAutoIcons(rel, boundaryMeta);
|
|
2595
|
-
|
|
2743
|
+
// In <head>, outside the tree, as for a page (see handleRenderRoute).
|
|
2744
|
+
const boundaryMetaHtml =
|
|
2745
|
+
(await renderElementToString(renderToReadableStream, renderMetadata(React, boundaryMeta))) +
|
|
2746
|
+
renderJsonLdHead(boundaryMeta);
|
|
2596
2747
|
// The same metadata slot a page gets: the browser hydrates a boundary
|
|
2597
2748
|
// with the page's tree shape (ssr-page-tree.ts).
|
|
2598
2749
|
let tree = withMetaSlot(
|
|
2599
2750
|
React.createElement,
|
|
2600
2751
|
React.Fragment,
|
|
2601
|
-
|
|
2752
|
+
null,
|
|
2602
2753
|
React.createElement(Comp, compProps),
|
|
2603
2754
|
);
|
|
2604
2755
|
tree = await buildLayoutTree(cwd, tree, boundaryLayouts, compProps, React);
|
|
@@ -2621,7 +2772,7 @@ async function tryRenderBoundary(
|
|
|
2621
2772
|
errorForClient,
|
|
2622
2773
|
},
|
|
2623
2774
|
opts.design,
|
|
2624
|
-
|
|
2775
|
+
boundaryMetaHtml,
|
|
2625
2776
|
);
|
|
2626
2777
|
return true;
|
|
2627
2778
|
} catch (e) {
|
|
@@ -3573,13 +3724,15 @@ export async function handleRenderRoute(
|
|
|
3573
3724
|
: null;
|
|
3574
3725
|
|
|
3575
3726
|
// SEO metadata: static `export const metadata` or dynamic
|
|
3576
|
-
// `export async function generateMetadata(props)
|
|
3577
|
-
//
|
|
3578
|
-
// data belongs in the
|
|
3727
|
+
// `export async function generateMetadata(props)`, from each layout and
|
|
3728
|
+
// then the page (page values win). Awaited before the first byte, so keep
|
|
3729
|
+
// it to cheap derivations (params → title); heavy data belongs in the
|
|
3730
|
+
// page body behind <Suspense>.
|
|
3579
3731
|
let metadata: SsrMetadata | undefined = mod.metadata;
|
|
3580
3732
|
if (typeof mod.generateMetadata === "function") {
|
|
3581
3733
|
metadata = await mod.generateMetadata(props);
|
|
3582
3734
|
}
|
|
3735
|
+
metadata = mergeMetadata(await resolveLayoutMetadata(cwd, msg.layouts, props), metadata);
|
|
3583
3736
|
// File conventions: auto-wire <meta og:image>/<twitter:image> from a
|
|
3584
3737
|
// colocated opengraph-image.* / twitter-image.*, and <link rel="icon">
|
|
3585
3738
|
// from icon.* / apple-icon.* / favicon.ico — unless the page set them.
|
|
@@ -3594,7 +3747,14 @@ export async function handleRenderRoute(
|
|
|
3594
3747
|
componentIsBoundary,
|
|
3595
3748
|
);
|
|
3596
3749
|
metadata = applyAutoIcons(msg.component, metadata);
|
|
3597
|
-
|
|
3750
|
+
// The metadata goes into <head> as HTML, outside the React tree. Inside
|
|
3751
|
+
// the tree it rendered only when every layout rendered its children: a
|
|
3752
|
+
// layout that shows a sign-in or loading screen on the server (an auth
|
|
3753
|
+
// gate decided in the browser) dropped the page's tags.
|
|
3754
|
+
const metaHeadHtml =
|
|
3755
|
+
(await renderElementToString(renderToReadableStream, renderMetadata(React, metadata))) +
|
|
3756
|
+
renderJsonLdHead(metadata);
|
|
3757
|
+
const replacedHeadTags = headTagKeys(metaHeadHtml);
|
|
3598
3758
|
|
|
3599
3759
|
// Client-side navigation: the browser already has the runtime, the layouts
|
|
3600
3760
|
// and the styles, and re-renders the page from `__PYLON_DATA__`. Measured
|
|
@@ -3660,10 +3820,9 @@ export async function handleRenderRoute(
|
|
|
3660
3820
|
// root: start with the page component as `tree`, then for each
|
|
3661
3821
|
// layout (innermost first) wrap it as the new tree. Result is
|
|
3662
3822
|
// the outermost layout containing all nested layouts down to
|
|
3663
|
-
// the page. The
|
|
3664
|
-
//
|
|
3665
|
-
|
|
3666
|
-
let tree: any = withMetaSlot(React.createElement, React.Fragment, metaFragment, pageLeaf);
|
|
3823
|
+
// the page. The page sits in the same metadata slot as in the browser
|
|
3824
|
+
// (ssr-page-tree.ts), empty on both sides, so useId values match.
|
|
3825
|
+
let tree: any = withMetaSlot(React.createElement, React.Fragment, null, pageLeaf);
|
|
3667
3826
|
tree = await buildLayoutTree(cwd, tree, msg.layouts, props, React);
|
|
3668
3827
|
const element = tree;
|
|
3669
3828
|
// Tracks whether the HTTP head (response_start) has actually gone out. A
|
|
@@ -3984,7 +4143,7 @@ export async function handleRenderRoute(
|
|
|
3984
4143
|
// 404 page is unstyled. Hydration stays disabled (handled below).
|
|
3985
4144
|
headBlob += await collectBoundaryHeadBlob();
|
|
3986
4145
|
}
|
|
3987
|
-
headBlob +=
|
|
4146
|
+
headBlob += metaHeadHtml;
|
|
3988
4147
|
|
|
3989
4148
|
// The host can dispatch a boundary module (`app/not-found` / `app/error`)
|
|
3990
4149
|
// by name for an unmatched-URL 404. Boundaries render server-only — no
|
|
@@ -4008,18 +4167,31 @@ export async function handleRenderRoute(
|
|
|
4008
4167
|
// Drain React's output and drop it. The render is what resolves the
|
|
4009
4168
|
// serverData reads that fill `ssrData`; the markup it produces is the
|
|
4010
4169
|
// part the client rebuilds for itself.
|
|
4170
|
+
// The head is kept for the layout's own <title>.
|
|
4011
4171
|
const reader = stream.getReader();
|
|
4172
|
+
const decoder = new TextDecoder("utf-8");
|
|
4173
|
+
let head = "";
|
|
4174
|
+
let headDone = false;
|
|
4012
4175
|
for (;;) {
|
|
4013
|
-
const { done } = await reader.read();
|
|
4176
|
+
const { done, value } = await reader.read();
|
|
4014
4177
|
if (done) break;
|
|
4178
|
+
if (headDone || !value) continue;
|
|
4179
|
+
head += decoder.decode(value, { stream: true });
|
|
4180
|
+
const end = head.indexOf("</head>");
|
|
4181
|
+
if (end >= 0) {
|
|
4182
|
+
head = head.slice(0, end);
|
|
4183
|
+
headDone = true;
|
|
4184
|
+
}
|
|
4015
4185
|
}
|
|
4016
4186
|
// A minimal document, so the client's existing parse path works
|
|
4017
4187
|
// unchanged: it reads document.title, copies [data-pylon-meta], and
|
|
4018
|
-
// pulls __PYLON_DATA__ — all of which this carries.
|
|
4188
|
+
// pulls __PYLON_DATA__ — all of which this carries. A page with no
|
|
4189
|
+
// title of its own gets the layout's, as on a full page load.
|
|
4190
|
+
const layoutTitle = replacedHeadTags.has("title") || !headDone ? "" : firstTitleTag(head);
|
|
4019
4191
|
sendChunk(
|
|
4020
4192
|
'<!DOCTYPE html><html><head><meta charset="utf-8">' +
|
|
4021
|
-
|
|
4022
|
-
|
|
4193
|
+
layoutTitle +
|
|
4194
|
+
metaHeadHtml +
|
|
4023
4195
|
"</head><body>",
|
|
4024
4196
|
);
|
|
4025
4197
|
} else {
|
|
@@ -4031,6 +4203,9 @@ export async function handleRenderRoute(
|
|
|
4031
4203
|
headBlob,
|
|
4032
4204
|
sendChunk,
|
|
4033
4205
|
designRender ? "" : buildCloudBadgeChunk(),
|
|
4206
|
+
replacedHeadTags.size > 0
|
|
4207
|
+
? (head) => dropReplacedHeadTags(head, replacedHeadTags)
|
|
4208
|
+
: undefined,
|
|
4034
4209
|
);
|
|
4035
4210
|
}
|
|
4036
4211
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// The route's modulepreload set is its whole static import graph, including
|
|
2
|
+
// chunk-to-chunk imports (a chunk imports its siblings as "./x.js").
|
|
3
|
+
|
|
4
|
+
import { afterEach, expect, test } from "bun:test";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { staticImportGraph } from "./ssr-client-bundler";
|
|
9
|
+
|
|
10
|
+
let dir = "";
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
if (dir) fs.rmSync(dir, { recursive: true, force: true });
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("follows chunk-to-chunk imports, not dynamic ones", () => {
|
|
16
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-graph-"));
|
|
17
|
+
fs.mkdirSync(path.join(dir, "chunks"));
|
|
18
|
+
const write = (f: string, body: string) => fs.writeFileSync(path.join(dir, f), body);
|
|
19
|
+
write("client-entry-home-aaaa1111.js", `import{a}from"./chunks/a-11111111.js";import"./chunks/b-22222222.js";a();`);
|
|
20
|
+
write("chunks/a-11111111.js", `import{c as d}from"./c-33333333.js";export const a=()=>d();`);
|
|
21
|
+
write("chunks/b-22222222.js", `import"./c-33333333.js";`);
|
|
22
|
+
write("chunks/c-33333333.js", `import"./d-44444444.js";export const c=1;const later=()=>import("./lazy-55555555.js");`);
|
|
23
|
+
write("chunks/d-44444444.js", `export const d=1;`);
|
|
24
|
+
write("chunks/lazy-55555555.js", `export const lazy=1;`);
|
|
25
|
+
const chunks = new Set(
|
|
26
|
+
["a-11111111", "b-22222222", "c-33333333", "d-44444444", "lazy-55555555"].map((c) => `chunks/${c}.js`),
|
|
27
|
+
);
|
|
28
|
+
const graph = staticImportGraph(fs, path, dir, path.join(dir, "client-entry-home-aaaa1111.js"), chunks);
|
|
29
|
+
expect([...graph]).toEqual([
|
|
30
|
+
"chunks/a-11111111.js",
|
|
31
|
+
"chunks/b-22222222.js",
|
|
32
|
+
"chunks/c-33333333.js",
|
|
33
|
+
"chunks/d-44444444.js",
|
|
34
|
+
]);
|
|
35
|
+
});
|
package/src/types.ts
CHANGED
|
@@ -284,6 +284,26 @@ export interface DbWriter extends DbReader {
|
|
|
284
284
|
data: Record<string, unknown>
|
|
285
285
|
): Promise<boolean>;
|
|
286
286
|
|
|
287
|
+
/**
|
|
288
|
+
* Insert many rows of one entity. Returns their IDs, in order. Same
|
|
289
|
+
* result as calling `insert` per row, with fewer database round trips:
|
|
290
|
+
* use it for imports and other loops that write many rows. When a row
|
|
291
|
+
* fails, the call throws; let the error end the mutation so it rolls
|
|
292
|
+
* back.
|
|
293
|
+
*/
|
|
294
|
+
insertMany(entity: string, rows: Record<string, unknown>[]): Promise<string[]>;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Update many rows of one entity. Returns, per row and in order, true if
|
|
298
|
+
* the row existed. Same result as calling `update` per row, with fewer
|
|
299
|
+
* database round trips. When a row fails, the call throws; let the error
|
|
300
|
+
* end the mutation so it rolls back.
|
|
301
|
+
*/
|
|
302
|
+
updateMany(
|
|
303
|
+
entity: string,
|
|
304
|
+
rows: { id: string; data: Record<string, unknown> }[]
|
|
305
|
+
): Promise<boolean[]>;
|
|
306
|
+
|
|
287
307
|
/** Delete a row by ID. Returns true if the row existed. */
|
|
288
308
|
delete(entity: string, id: string): Promise<boolean>;
|
|
289
309
|
|