@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.246",
3
+ "version": "0.3.247",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
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
- message: err?.message || String(err),
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
- if (push) {
432
- history.pushState({ component: data.component }, "", url.pathname + url.search);
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
- const stylesName = `styles-${hash.toString(36)}.css`;
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
@@ -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
+ });
@@ -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
- const el = React.createElement;
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
- const { serverData: _sd, response: _resp, ...serializableProps } = props;
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
- const json = JSON.stringify(hydrationPayload).replaceAll("<", "\\u003c");
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: err?.message ?? String(err),
1344
+ message:
1345
+ devMode && err?.stack ? String(err.stack) : err?.message ?? String(err),
1307
1346
  });
1308
1347
  }
1309
1348
  }