@pylonsync/functions 0.3.246 → 0.3.247
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/runtime.ts +7 -1
- package/src/ssr-client-bundler.ts +39 -3
- package/src/ssr-runtime.test.ts +47 -1
- package/src/ssr-runtime.ts +44 -5
package/package.json
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -223,11 +223,17 @@ function dispatch(line: string): void {
|
|
|
223
223
|
),
|
|
224
224
|
)
|
|
225
225
|
.catch((err) => {
|
|
226
|
+
const devMode =
|
|
227
|
+
process.env.PYLON_DEV_MODE === "1" ||
|
|
228
|
+
process.env.PYLON_DEV_MODE === "true";
|
|
226
229
|
send({
|
|
227
230
|
type: "error",
|
|
228
231
|
call_id: (msg as unknown as { call_id: string }).call_id,
|
|
229
232
|
code: "SSR_RUNTIME_CRASH",
|
|
230
|
-
|
|
233
|
+
// Dev: full stack for the host's error overlay. Prod: message only.
|
|
234
|
+
message:
|
|
235
|
+
(devMode && err?.stack ? String(err.stack) : err?.message) ||
|
|
236
|
+
String(err),
|
|
231
237
|
});
|
|
232
238
|
});
|
|
233
239
|
} else if (msg.type === "bundle_client") {
|
|
@@ -382,6 +382,27 @@ export function hydrate(component, Page, Layouts) {
|
|
|
382
382
|
// only need to populate the cache (already done above).
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
// Swap the page's SEO/social <head> tags on a client-side navigation.
|
|
386
|
+
// The SSR runtime marks every page-metadata <meta>/<link> with
|
|
387
|
+
// data-pylon-meta; we drop the current set and import the incoming page's
|
|
388
|
+
// set, so description / canonical / og:* / twitter:* / icons track the new
|
|
389
|
+
// route. The layout's charset/viewport and Pylon's injected stylesheet
|
|
390
|
+
// links carry no marker, so they survive untouched (no FOUC). The page
|
|
391
|
+
// component never renders these tags on the client, so React doesn't own
|
|
392
|
+
// them — this manual swap can't fight hydration.
|
|
393
|
+
function syncHeadMeta(doc) {
|
|
394
|
+
const head = document.head;
|
|
395
|
+
if (!head) return;
|
|
396
|
+
const current = head.querySelectorAll("[data-pylon-meta]");
|
|
397
|
+
for (let i = 0; i < current.length; i++) current[i].remove();
|
|
398
|
+
const incoming = doc.head
|
|
399
|
+
? doc.head.querySelectorAll("[data-pylon-meta]")
|
|
400
|
+
: [];
|
|
401
|
+
for (let i = 0; i < incoming.length; i++) {
|
|
402
|
+
head.appendChild(document.importNode(incoming[i], true));
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
385
406
|
async function navigate(href, opts) {
|
|
386
407
|
const push = !opts || opts.push !== false;
|
|
387
408
|
const url = new URL(href, location.href);
|
|
@@ -426,11 +447,19 @@ async function navigate(href, opts) {
|
|
|
426
447
|
return;
|
|
427
448
|
}
|
|
428
449
|
document.title = doc.title || document.title;
|
|
450
|
+
syncHeadMeta(doc);
|
|
429
451
|
const tree = buildTree(route.Page, route.Layouts, withClientProps(data));
|
|
430
452
|
activeRoot.render(tree);
|
|
431
|
-
|
|
432
|
-
|
|
453
|
+
const target = url.pathname + url.search;
|
|
454
|
+
if (opts && opts.replace) {
|
|
455
|
+
history.replaceState({ component: data.component }, "", target);
|
|
456
|
+
} else if (push) {
|
|
457
|
+
history.pushState({ component: data.component }, "", target);
|
|
433
458
|
}
|
|
459
|
+
// Notify the router hooks (useSearchParams / usePathname) so deep children
|
|
460
|
+
// re-read location after a Link click or a router.push(). popstate already
|
|
461
|
+
// covers back/forward, but pushState/replaceState fire no event.
|
|
462
|
+
window.dispatchEvent(new Event("pylon:navigation"));
|
|
434
463
|
// After a successful nav, scroll to top (Next.js default).
|
|
435
464
|
window.scrollTo(0, 0);
|
|
436
465
|
}
|
|
@@ -635,7 +664,14 @@ async function buildTailwind(
|
|
|
635
664
|
// Mix in the discovered routes so adding/removing pages changes
|
|
636
665
|
// the hash (Tailwind v4 auto-discovers `@source` paths; we still
|
|
637
666
|
// want the cache to bust on layout changes).
|
|
638
|
-
|
|
667
|
+
//
|
|
668
|
+
// Pad the base36 hash to 8 chars: the runtime's `is_hashed_name`
|
|
669
|
+
// (frontend.rs) only sends `Cache-Control: immutable` for hashes ≥8
|
|
670
|
+
// chars (Bun's JS chunk convention). A 32-bit base36 hash is ≤7 chars,
|
|
671
|
+
// so WITHOUT the pad the content-hashed CSS was served `no-cache` —
|
|
672
|
+
// browsers + any CDN refetched it on every page load (defeats the cache
|
|
673
|
+
// and, behind Cloudflare, needlessly wakes an autostopped origin).
|
|
674
|
+
const stylesName = `styles-${hash.toString(36).padStart(8, "0")}.css`;
|
|
639
675
|
const outPath = path.join(outdir, stylesName);
|
|
640
676
|
|
|
641
677
|
// Spawn the CLI. Bun is already running; reuse it as the
|
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -4,7 +4,21 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
|
-
import { applyAutoIcons, applyAutoSocialImages } from "./ssr-runtime";
|
|
7
|
+
import { applyAutoIcons, applyAutoSocialImages, renderMetadata } from "./ssr-runtime";
|
|
8
|
+
|
|
9
|
+
// `react` isn't a dependency of @pylonsync/functions — the SSR runtime
|
|
10
|
+
// imports it dynamically from the host project at render time. For unit
|
|
11
|
+
// tests we hand renderMetadata a fake React that records each created
|
|
12
|
+
// element's shape, which is all renderMetadata touches.
|
|
13
|
+
const FAKE_FRAGMENT = Symbol("Fragment");
|
|
14
|
+
const fakeReact = {
|
|
15
|
+
Fragment: FAKE_FRAGMENT,
|
|
16
|
+
createElement: (type: any, props: any, ...children: any[]) => ({
|
|
17
|
+
type,
|
|
18
|
+
props: props ?? {},
|
|
19
|
+
children,
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
8
22
|
|
|
9
23
|
// A minimal PNG: 8-byte signature + an IHDR chunk carrying width/height.
|
|
10
24
|
// `readSocialImageMeta` only reads the first 32 bytes, so a full valid
|
|
@@ -180,3 +194,35 @@ describe("icon / apple-icon / favicon file convention", () => {
|
|
|
180
194
|
expect(applyAutoIcons("app/page", input)).toEqual(input);
|
|
181
195
|
});
|
|
182
196
|
});
|
|
197
|
+
|
|
198
|
+
describe("renderMetadata head-tag marking (client-nav sync)", () => {
|
|
199
|
+
test("every <meta>/<link> carries data-pylon-meta; <title> does not", () => {
|
|
200
|
+
const frag = renderMetadata(fakeReact, {
|
|
201
|
+
title: "Hello",
|
|
202
|
+
description: "A page",
|
|
203
|
+
canonical: "https://x.test/p",
|
|
204
|
+
openGraph: { title: "OG", image: "https://x.test/og.png" },
|
|
205
|
+
twitter: { card: "summary" },
|
|
206
|
+
icons: { icon: { url: "/icon.png" } },
|
|
207
|
+
});
|
|
208
|
+
expect(frag.type).toBe(FAKE_FRAGMENT);
|
|
209
|
+
const kids: any[] = frag.children;
|
|
210
|
+
const metaLink = kids.filter((k) => k.type === "meta" || k.type === "link");
|
|
211
|
+
const titles = kids.filter((k) => k.type === "title");
|
|
212
|
+
expect(metaLink.length).toBeGreaterThan(0);
|
|
213
|
+
// The marker is what the client runtime swaps on navigation — without it,
|
|
214
|
+
// SEO/social tags go stale on client-side nav. Every meta/link must carry
|
|
215
|
+
// it; <title> must NOT (the client syncs document.title separately).
|
|
216
|
+
for (const el of metaLink) {
|
|
217
|
+
expect(el.props["data-pylon-meta"]).toBe("");
|
|
218
|
+
}
|
|
219
|
+
expect(titles.length).toBe(1);
|
|
220
|
+
expect(titles[0].props["data-pylon-meta"]).toBeUndefined();
|
|
221
|
+
expect(titles[0].children).toEqual(["Hello"]);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("returns null when there's nothing to emit", () => {
|
|
225
|
+
expect(renderMetadata(fakeReact, undefined)).toBeNull();
|
|
226
|
+
expect(renderMetadata(fakeReact, {})).toBeNull();
|
|
227
|
+
});
|
|
228
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -306,9 +306,19 @@ export interface SsrMetadata {
|
|
|
306
306
|
* host's </head> splice preserves them. React escapes all text/attrs, so
|
|
307
307
|
* there's no manual XSS handling. Returns null when there's nothing to emit.
|
|
308
308
|
*/
|
|
309
|
-
function renderMetadata(React: any, m: SsrMetadata | undefined): any {
|
|
309
|
+
export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
|
|
310
310
|
if (!m) return null;
|
|
311
|
-
|
|
311
|
+
// Mark every emitted <meta>/<link> with `data-pylon-meta` so the client
|
|
312
|
+
// runtime can swap exactly these tags on a client-side navigation — and
|
|
313
|
+
// leave the layout's charset/viewport and Pylon's injected stylesheet
|
|
314
|
+
// links untouched. <title> is excluded (the client syncs document.title
|
|
315
|
+
// directly). The metadata fragment is server-only (the client renders the
|
|
316
|
+
// page component alone), so React on the client never owns these nodes;
|
|
317
|
+
// this manual marking is what makes the nav-time swap safe.
|
|
318
|
+
const el = (type: any, props: any, ...children: any[]) =>
|
|
319
|
+
type === "meta" || type === "link"
|
|
320
|
+
? React.createElement(type, { "data-pylon-meta": "", ...props }, ...children)
|
|
321
|
+
: React.createElement(type, props, ...children);
|
|
312
322
|
const kids: any[] = [];
|
|
313
323
|
if (m.title != null) kids.push(el("title", { key: "t" }, m.title));
|
|
314
324
|
if (m.description != null) {
|
|
@@ -1191,14 +1201,37 @@ export async function handleRenderRoute(
|
|
|
1191
1201
|
// hydration: `serverData` (an RPC handle — the client rebuilds its
|
|
1192
1202
|
// own from `ssrData`) and `response` (a server-only controller — the
|
|
1193
1203
|
// client gets a no-op). Their resolved data rides along in `ssrData`.
|
|
1194
|
-
|
|
1204
|
+
//
|
|
1205
|
+
// SECURITY: also strip the request `headers` + `cookies`. They were
|
|
1206
|
+
// passed to the page for SERVER-side reads, but serializing them into
|
|
1207
|
+
// the page HTML exposes the request's `Cookie` (the HttpOnly session
|
|
1208
|
+
// token), `Authorization`, and client IP to any client-side JS —
|
|
1209
|
+
// defeating HttpOnly and handing a same-page XSS an exfil target. The
|
|
1210
|
+
// client gets empty maps (shape preserved so `props.headers`/`.cookies`
|
|
1211
|
+
// aren't `undefined`); a page that must surface a request value to the
|
|
1212
|
+
// browser should pass it explicitly via a prop or `serverData`.
|
|
1213
|
+
const {
|
|
1214
|
+
serverData: _sd,
|
|
1215
|
+
response: _resp,
|
|
1216
|
+
headers: _h,
|
|
1217
|
+
cookies: _c,
|
|
1218
|
+
...restProps
|
|
1219
|
+
} = props;
|
|
1220
|
+
const serializableProps = { ...restProps, headers: {}, cookies: {} };
|
|
1195
1221
|
const hydrationPayload = {
|
|
1196
1222
|
component: msg.component,
|
|
1197
1223
|
layouts: msg.layouts ?? [],
|
|
1198
1224
|
props: serializableProps,
|
|
1199
1225
|
ssrData: ssrValueCache,
|
|
1200
1226
|
};
|
|
1201
|
-
|
|
1227
|
+
// Escape `<` (closes the </script> breakout) AND the U+2028/U+2029 line
|
|
1228
|
+
// separators — valid in JSON but statement terminators in JS, so they'd
|
|
1229
|
+
// break the page if the blob were ever read as executable JS rather than
|
|
1230
|
+
// application/json. Defense-in-depth.
|
|
1231
|
+
const json = JSON.stringify(hydrationPayload)
|
|
1232
|
+
.replaceAll("<", "\\u003c")
|
|
1233
|
+
.replaceAll("
", "\\u2028")
|
|
1234
|
+
.replaceAll("
", "\\u2029");
|
|
1202
1235
|
|
|
1203
1236
|
let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
|
|
1204
1237
|
if (preloadManifestRoute) {
|
|
@@ -1299,11 +1332,17 @@ export async function handleRenderRoute(
|
|
|
1299
1332
|
) {
|
|
1300
1333
|
return;
|
|
1301
1334
|
}
|
|
1335
|
+
// In dev, send the full stack as the message so the host can paint a
|
|
1336
|
+
// useful error overlay instead of an opaque 500. In prod, send only the
|
|
1337
|
+
// message (the host shows a generic page; the stack stays in logs).
|
|
1338
|
+
const devMode =
|
|
1339
|
+
process.env.PYLON_DEV_MODE === "1" || process.env.PYLON_DEV_MODE === "true";
|
|
1302
1340
|
send({
|
|
1303
1341
|
type: "error",
|
|
1304
1342
|
call_id: msg.call_id,
|
|
1305
1343
|
code: err?.code ?? "SSR_RENDER_FAILED",
|
|
1306
|
-
message:
|
|
1344
|
+
message:
|
|
1345
|
+
devMode && err?.stack ? String(err.stack) : err?.message ?? String(err),
|
|
1307
1346
|
});
|
|
1308
1347
|
}
|
|
1309
1348
|
}
|