@pylonsync/functions 0.22.22 → 0.22.23

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.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Incremental NDJSON line splitter for the runner's stdin.
3
+ *
4
+ * Each chunk is scanned once for newlines. A line that spans many chunks is
5
+ * kept as a list of parts and joined once when its newline arrives. The old
6
+ * reader appended every chunk to one string and split the whole string on
7
+ * each read, which is quadratic in the line length: a 48MB frame (a large
8
+ * function argument, for example a deploy upload) arrived as ~750 64KB
9
+ * chunks and was rescanned on each one, so the runner stalled and the host's
10
+ * write failed with a broken pipe.
11
+ */
12
+ export declare function createLineSplitter(onLine: (line: string) => void): {
13
+ /** Feed one chunk of bytes from the stream. */
14
+ push(chunk: Uint8Array): void;
15
+ /** The stream ended: flush the decoder and emit a final unterminated line. */
16
+ end(): void;
17
+ };
@@ -320,6 +320,20 @@ export interface SsrMetadata {
320
320
  * there's no manual XSS handling. Returns null when there's nothing to emit.
321
321
  */
322
322
  export declare function renderMetadata(React: any, m: SsrMetadata | undefined): any;
323
+ /**
324
+ * Serialize `metadata.jsonLd` to `<script type="application/ld+json">` HTML
325
+ * for the head injection. This is a string, not React elements: React 19
326
+ * hoists <title>/<meta>/<link> out of the hydration tree, but not <script>.
327
+ * A script inside the server-only metadata fragment stayed in the body, the
328
+ * client (which renders the page alone) had nothing there, and hydration
329
+ * failed on every page with structured data. In <head> the tag is outside
330
+ * the tree React hydrates. It carries `data-pylon-meta` so the client
331
+ * swaps it on navigation with the other page tags.
332
+ *
333
+ * `<`, `>`, `&` become \uXXXX escapes, so a value can't close the script
334
+ * element. JSON.parse reads them back, so the data stays valid.
335
+ */
336
+ export declare function renderJsonLdHead(m: SsrMetadata | undefined): string;
323
337
  /**
324
338
  * Import a project-relative module, trying each common extension. A
325
339
  * production server bundle serves it from the bundle's module registry.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.22.22",
3
+ "version": "0.22.23",
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",
@@ -0,0 +1,49 @@
1
+ import { expect, test } from "bun:test";
2
+ import { createLineSplitter } from "./ndjson-lines";
3
+
4
+ const enc = new TextEncoder();
5
+
6
+ function collect(chunks: (string | Uint8Array)[]) {
7
+ const out: string[] = [];
8
+ const s = createLineSplitter((l) => out.push(l));
9
+ for (const c of chunks) s.push(typeof c === "string" ? enc.encode(c) : c);
10
+ s.end();
11
+ return out;
12
+ }
13
+
14
+ test("splits lines inside one chunk and across chunks", () => {
15
+ expect(collect(['{"a":1}\n{"b"', ':2}\n{"c":3}\n'])).toEqual(['{"a":1}', '{"b":2}', '{"c":3}']);
16
+ });
17
+
18
+ test("joins a line spread over many chunks", () => {
19
+ expect(collect(["ab", "cd", "ef", "\n"])).toEqual(["abcdef"]);
20
+ });
21
+
22
+ test("skips blank lines", () => {
23
+ expect(collect(["\n\n \nx\n\n"])).toEqual(["x"]);
24
+ });
25
+
26
+ test("emits an unterminated last line at end of stream", () => {
27
+ expect(collect(["a\nb"])).toEqual(["a", "b"]);
28
+ });
29
+
30
+ test("decodes a multi-byte character split across chunks", () => {
31
+ const bytes = enc.encode('{"s":"é✓"}\n');
32
+ const out = collect([bytes.slice(0, 7), bytes.slice(7, 9), bytes.slice(9)]);
33
+ expect(JSON.parse(out[0]).s).toBe("é✓");
34
+ });
35
+
36
+ test("a 48MB frame in 64KB chunks parses in linear time", () => {
37
+ const payload = "x".repeat(48 * 1024 * 1024);
38
+ const frame = enc.encode(`{"data":"${payload}"}\n`);
39
+ const lines: string[] = [];
40
+ const s = createLineSplitter((l) => lines.push(l));
41
+ const started = performance.now();
42
+ for (let i = 0; i < frame.length; i += 64 * 1024) s.push(frame.subarray(i, i + 64 * 1024));
43
+ s.end();
44
+ const ms = performance.now() - started;
45
+ expect(lines.length).toBe(1);
46
+ expect(lines[0].length).toBe(frame.length - 1);
47
+ // The old buffer.split reader took minutes on this input.
48
+ expect(ms).toBeLessThan(2000);
49
+ });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Incremental NDJSON line splitter for the runner's stdin.
3
+ *
4
+ * Each chunk is scanned once for newlines. A line that spans many chunks is
5
+ * kept as a list of parts and joined once when its newline arrives. The old
6
+ * reader appended every chunk to one string and split the whole string on
7
+ * each read, which is quadratic in the line length: a 48MB frame (a large
8
+ * function argument, for example a deploy upload) arrived as ~750 64KB
9
+ * chunks and was rescanned on each one, so the runner stalled and the host's
10
+ * write failed with a broken pipe.
11
+ */
12
+ export function createLineSplitter(onLine: (line: string) => void) {
13
+ const decoder = new TextDecoder();
14
+ let pending: string[] = [];
15
+
16
+ function emit(line: string) {
17
+ if (line.trim()) onLine(line);
18
+ }
19
+
20
+ function consume(text: string) {
21
+ let start = 0;
22
+ let nl = text.indexOf("\n");
23
+ while (nl !== -1) {
24
+ const tail = text.slice(start, nl);
25
+ if (pending.length) {
26
+ pending.push(tail);
27
+ emit(pending.join(""));
28
+ pending = [];
29
+ } else {
30
+ emit(tail);
31
+ }
32
+ start = nl + 1;
33
+ nl = text.indexOf("\n", start);
34
+ }
35
+ if (start < text.length) pending.push(text.slice(start));
36
+ }
37
+
38
+ return {
39
+ /** Feed one chunk of bytes from the stream. */
40
+ push(chunk: Uint8Array) {
41
+ consume(decoder.decode(chunk, { stream: true }));
42
+ },
43
+ /** The stream ended: flush the decoder and emit a final unterminated line. */
44
+ end() {
45
+ consume(decoder.decode());
46
+ if (pending.length) {
47
+ emit(pending.join(""));
48
+ pending = [];
49
+ }
50
+ },
51
+ };
52
+ }
package/src/runtime.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * needs to queue multiple RPCs per call_id.
16
16
  */
17
17
 
18
+ import { createLineSplitter } from "./ndjson-lines";
18
19
  import type {
19
20
  DbReader,
20
21
  DbWriter,
@@ -206,24 +207,14 @@ const RPC_TIMEOUT_MS = 60_000;
206
207
 
207
208
  async function readerLoop(): Promise<void> {
208
209
  const reader = Bun.stdin.stream().getReader();
209
- const decoder = new TextDecoder();
210
- let buffer = "";
210
+ const lines = createLineSplitter(dispatch);
211
211
 
212
212
  while (true) {
213
213
  const { done, value } = await reader.read();
214
214
  if (done) break;
215
-
216
- buffer += decoder.decode(value, { stream: true });
217
- const lines = buffer.split("\n");
218
- buffer = lines.pop() || "";
219
-
220
- for (const line of lines) {
221
- if (!line.trim()) continue;
222
- dispatch(line);
223
- }
215
+ lines.push(value);
224
216
  }
225
-
226
- if (buffer.trim()) dispatch(buffer);
217
+ lines.end();
227
218
 
228
219
  // stdin closed — the host is gone. Reject every pending RPC so awaiting
229
220
  // handlers unwind instead of hanging and keeping the Bun process alive
@@ -8,6 +8,7 @@ import {
8
8
  applyAutoIcons,
9
9
  applyAutoSocialImages,
10
10
  renderMetadata,
11
+ renderJsonLdHead,
11
12
  buildHydrationTail,
12
13
  errorDigest,
13
14
  resolveOrigin,
@@ -579,19 +580,29 @@ describe("renderMetadata head-tag marking (client-nav sync)", () => {
579
580
  expect(renderMetadata(fakeReact, {})).toBeNull();
580
581
  });
581
582
 
582
- test("emits JSON-LD as an escaped application/ld+json script", () => {
583
+ test("renderMetadata never emits a <script> (JSON-LD goes to the head blob)", () => {
584
+ // React 19 does not hoist <script> out of the hydration tree. A JSON-LD
585
+ // script in this server-only fragment stayed in the body and broke
586
+ // hydration on every page with structured data.
583
587
  const frag = renderMetadata(fakeReact, {
588
+ title: "T",
589
+ jsonLd: { "@type": "Organization", name: "Pylon" },
590
+ });
591
+ const scripts: any[] = frag.children.filter((k: any) => k.type === "script");
592
+ expect(scripts.length).toBe(0);
593
+ });
594
+
595
+ test("renderJsonLdHead emits an escaped application/ld+json script", () => {
596
+ const html = renderJsonLdHead({
584
597
  jsonLd: {
585
598
  "@context": "https://schema.org",
586
599
  "@type": "Organization",
587
600
  name: "Pylon </script><x>&y",
588
601
  },
589
602
  });
590
- const scripts: any[] = frag.children.filter((k: any) => k.type === "script");
591
- expect(scripts.length).toBe(1);
592
- expect(scripts[0].props.type).toBe("application/ld+json");
593
- expect(scripts[0].props["data-pylon-meta"]).toBe("");
594
- const body = scripts[0].children[0] as string;
603
+ const match = html.match(/^<script type="application\/ld\+json" data-pylon-meta>(.*)<\/script>$/s);
604
+ expect(match).not.toBeNull();
605
+ const body = match![1];
595
606
  // Breakout chars must be \u-escaped — the payload can't contain a literal
596
607
  // `</script>`, `<`, `>`, or `&`.
597
608
  expect(body).not.toContain("</script>");
@@ -603,12 +614,20 @@ describe("renderMetadata head-tag marking (client-nav sync)", () => {
603
614
  expect(parsed.name).toBe("Pylon </script><x>&y");
604
615
  });
605
616
 
606
- test("JSON-LD array emits one script per item", () => {
607
- const frag = renderMetadata(fakeReact, {
608
- jsonLd: [{ "@type": "A" }, { "@type": "B" }],
609
- });
610
- const scripts: any[] = frag.children.filter((k: any) => k.type === "script");
611
- expect(scripts.map((s) => JSON.parse(s.children[0])["@type"])).toEqual(["A", "B"]);
617
+ test("renderJsonLdHead emits one script per array item", () => {
618
+ const html = renderJsonLdHead({ jsonLd: [{ "@type": "A" }, { "@type": "B" }] });
619
+ const bodies = [...html.matchAll(/<script[^>]*>(.*?)<\/script>/gs)].map((m) => JSON.parse(m[1])["@type"]);
620
+ expect(bodies).toEqual(["A", "B"]);
621
+ });
622
+
623
+ test("renderJsonLdHead returns an empty string without jsonLd, and skips unserializable items", () => {
624
+ expect(renderJsonLdHead(undefined)).toBe("");
625
+ expect(renderJsonLdHead({ title: "x" })).toBe("");
626
+ const cyclic: any = { "@type": "Loop" };
627
+ cyclic.self = cyclic;
628
+ const html = renderJsonLdHead({ jsonLd: [cyclic, { "@type": "Ok" }] });
629
+ expect(html.match(/<script/g)?.length).toBe(1);
630
+ expect(html).toContain('"Ok"');
612
631
  });
613
632
 
614
633
  test("emits the extended SEO/social tags", () => {
@@ -692,37 +692,40 @@ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
692
692
  kids.push(el("link", a));
693
693
  }
694
694
  }
695
- if (m.jsonLd) {
696
- const items = Array.isArray(m.jsonLd) ? m.jsonLd : [m.jsonLd];
697
- items.forEach((item, i) => {
698
- let json: string;
699
- try {
700
- json = JSON.stringify(item);
701
- } catch {
702
- return; // unserializable (cycle) — skip rather than throw the render
703
- }
704
- if (!json) return;
705
- // Escape `<`, `>`, `&` to their \uXXXX JSON forms so the payload can't
706
- // break out of the <script> element (e.g. a value containing
707
- // `</script>`), no matter how the renderer treats raw-text children.
708
- // JSON.parse decodes these back, so the structured data stays valid. This
709
- // is why no dangerouslySetInnerHTML is needed: the text child is inert.
710
- const safe = json
711
- .replace(/</g, "\\u003c")
712
- .replace(/>/g, "\\u003e")
713
- .replace(/&/g, "\\u0026");
714
- kids.push(
715
- React.createElement(
716
- "script",
717
- { key: `ld${i}`, type: "application/ld+json", "data-pylon-meta": "" },
718
- safe,
719
- ),
720
- );
721
- });
722
- }
723
695
  return kids.length > 0 ? el(React.Fragment, null, ...kids) : null;
724
696
  }
725
697
 
698
+ /**
699
+ * Serialize `metadata.jsonLd` to `<script type="application/ld+json">` HTML
700
+ * for the head injection. This is a string, not React elements: React 19
701
+ * hoists <title>/<meta>/<link> out of the hydration tree, but not <script>.
702
+ * A script inside the server-only metadata fragment stayed in the body, the
703
+ * client (which renders the page alone) had nothing there, and hydration
704
+ * failed on every page with structured data. In <head> the tag is outside
705
+ * the tree React hydrates. It carries `data-pylon-meta` so the client
706
+ * swaps it on navigation with the other page tags.
707
+ *
708
+ * `<`, `>`, `&` become \uXXXX escapes, so a value can't close the script
709
+ * element. JSON.parse reads them back, so the data stays valid.
710
+ */
711
+ export function renderJsonLdHead(m: SsrMetadata | undefined): string {
712
+ if (!m?.jsonLd) return "";
713
+ const items = Array.isArray(m.jsonLd) ? m.jsonLd : [m.jsonLd];
714
+ let html = "";
715
+ for (const item of items) {
716
+ let json: string | undefined;
717
+ try {
718
+ json = JSON.stringify(item);
719
+ } catch {
720
+ continue; // unserializable (cycle) — skip rather than throw the render
721
+ }
722
+ if (!json) continue;
723
+ const safe = json.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026");
724
+ html += `<script type="application/ld+json" data-pylon-meta>${safe}</script>`;
725
+ }
726
+ return html;
727
+ }
728
+
726
729
  const MODULE_EXTS = [".tsx", ".ts", ".jsx", ".js"];
727
730
 
728
731
  /**
@@ -2400,6 +2403,8 @@ async function renderBoundaryToClient(
2400
2403
  },
2401
2404
  // Design render: no hydration tail, inlined CSS, `<base href>`.
2402
2405
  design?: { headers: Record<string, string | undefined> },
2406
+ // Page-metadata head HTML that is not React-rendered (JSON-LD).
2407
+ metaHeadHtml = "",
2403
2408
  ): Promise<void> {
2404
2409
  const stream: ReadableStream<Uint8Array> = await renderToReadableStream(tree, {
2405
2410
  onError(e: unknown) {
@@ -2442,6 +2447,7 @@ async function renderBoundaryToClient(
2442
2447
  // page is at least styled (static). (collectBoundaryHeadBlob emits fonts.)
2443
2448
  headBlob = await collectBoundaryHeadBlob();
2444
2449
  }
2450
+ headBlob += metaHeadHtml;
2445
2451
  // renderToReadableStream resolved without throwing → safe to commit the
2446
2452
  // head now, then drain the (already-rendered) shell, injecting CSS.
2447
2453
  send({ type: "response_start", call_id: callId, status, headers });
@@ -2584,6 +2590,7 @@ async function tryRenderBoundary(
2584
2590
  errorForClient,
2585
2591
  },
2586
2592
  opts.design,
2593
+ renderJsonLdHead(boundaryMeta),
2587
2594
  );
2588
2595
  return true;
2589
2596
  } catch (e) {
@@ -3850,6 +3857,7 @@ export async function handleRenderRoute(
3850
3857
  // 404 page is unstyled. Hydration stays disabled (handled below).
3851
3858
  headBlob += await collectBoundaryHeadBlob();
3852
3859
  }
3860
+ headBlob += renderJsonLdHead(metadata);
3853
3861
 
3854
3862
  // The host can dispatch a boundary module (`app/not-found` / `app/error`)
3855
3863
  // by name for an unmatched-URL 404. Boundaries render server-only — no
@@ -3884,6 +3892,7 @@ export async function handleRenderRoute(
3884
3892
  sendChunk(
3885
3893
  '<!DOCTYPE html><html><head><meta charset="utf-8">' +
3886
3894
  (await renderElementToString(renderToReadableStream, metaFragment)) +
3895
+ renderJsonLdHead(metadata) +
3887
3896
  "</head><body>",
3888
3897
  );
3889
3898
  } else {