@pylonsync/functions 0.22.22 → 0.22.25
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/ndjson-lines.d.ts +17 -0
- package/dist/ssr-runtime.d.ts +14 -0
- package/package.json +1 -1
- package/src/ndjson-lines.test.ts +49 -0
- package/src/ndjson-lines.ts +52 -0
- package/src/runtime.ts +4 -13
- package/src/ssr-runtime.test.ts +31 -12
- package/src/ssr-runtime.ts +37 -28
|
@@ -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
|
+
};
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -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
|
@@ -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
|
|
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
|
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -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
|
|
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
|
|
591
|
-
expect(
|
|
592
|
-
|
|
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("
|
|
607
|
-
const
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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", () => {
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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 {
|