@pylonsync/functions 0.5.7 → 0.6.0

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.
@@ -368,7 +368,7 @@ isBoundary?: boolean): SsrMetadata | undefined;
368
368
  * and corrupt on an unrelated CSS change. A streaming decoder holds the
369
369
  * partial sequence back until the bytes that complete it arrive.
370
370
  */
371
- export declare function streamWithHeadInjection(reader: ReadableStreamDefaultReader<Uint8Array>, headBlob: string, sendChunk: (text: string) => void): Promise<void>;
371
+ export declare function streamWithHeadInjection(reader: ReadableStreamDefaultReader<Uint8Array>, headBlob: string, sendChunk: (text: string) => void, bodyBlob?: string): Promise<void>;
372
372
  /**
373
373
  * Dev-only tail chunk: the `__PYLON_DEV__` info blob (cache verdict, render
374
374
  * mode/timing, route) + the HUD bootstrap. Embedded after the page tail so the
@@ -384,6 +384,21 @@ export declare function streamWithHeadInjection(reader: ReadableStreamDefaultRea
384
384
  */
385
385
  export declare function escapeScriptJson(json: string): string;
386
386
  export declare function buildDevHudChunk(devInfo: Record<string, unknown>): string;
387
+ /**
388
+ * The free-tier attribution badge, injected before `</body>` when the control
389
+ * plane sets `PYLON_CLOUD_BADGE` (it does so only for hobby-plan projects, and
390
+ * the key is protected so an app cannot unset it for itself).
391
+ *
392
+ * Deliberately plain: one anchor, inline styles, no script, no webfont, no
393
+ * network request. It renders inside somebody else's app, so it must not fight
394
+ * their layout or cost their visitors a round trip. Everything it could
395
+ * inherit from the host page — font, line-height, box-sizing, text-transform —
396
+ * is stated rather than left to chance.
397
+ *
398
+ * Returns "" when the flag is unset: every paid project, and every self-hosted
399
+ * install.
400
+ */
401
+ export declare function buildCloudBadgeChunk(env?: Record<string, string | undefined>): string;
387
402
  export declare function cssHeadTag(css: string, prefix: string, routeOverride?: boolean): Promise<string>;
388
403
  /**
389
404
  * Build the hydration tail appended after React's stream EOFs: the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.5.7",
3
+ "version": "0.6.0",
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",
@@ -1401,11 +1401,43 @@ export async function streamWithHeadInjection(
1401
1401
  reader: ReadableStreamDefaultReader<Uint8Array>,
1402
1402
  headBlob: string,
1403
1403
  sendChunk: (text: string) => void,
1404
+ bodyBlob = "",
1404
1405
  ): Promise<void> {
1405
- let headInjected = headBlob.length === 0;
1406
+ // Injections in document order, each consumed the first time its marker is
1407
+ // seen. Once the list empties the reader is a straight pass-through, which
1408
+ // is every chunk after the <head> on a normal page.
1409
+ const pending: Array<{ marker: string; blob: string }> = [];
1410
+ if (headBlob.length > 0) pending.push({ marker: "</head>", blob: headBlob });
1411
+ if (bodyBlob.length > 0) pending.push({ marker: "</body>", blob: bodyBlob });
1406
1412
  let carry = "";
1407
- const HEAD_CLOSE = "</head>";
1408
1413
  const decoder = new TextDecoder("utf-8");
1414
+ const feed = (text: string): void => {
1415
+ let buf = carry + text;
1416
+ carry = "";
1417
+ while (pending.length > 0) {
1418
+ const next = pending[0];
1419
+ const idx = buf.indexOf(next.marker);
1420
+ if (idx < 0) break;
1421
+ sendChunk(buf.slice(0, idx));
1422
+ sendChunk(next.blob);
1423
+ sendChunk(next.marker);
1424
+ buf = buf.slice(idx + next.marker.length);
1425
+ pending.shift();
1426
+ }
1427
+ if (pending.length === 0) {
1428
+ if (buf) sendChunk(buf);
1429
+ return;
1430
+ }
1431
+ // Withhold len(marker) − 1 so a marker straddling a chunk boundary is
1432
+ // still matched once the next read arrives.
1433
+ const keep = pending[0].marker.length - 1;
1434
+ if (buf.length > keep) {
1435
+ sendChunk(buf.slice(0, buf.length - keep));
1436
+ carry = buf.slice(buf.length - keep);
1437
+ } else {
1438
+ carry = buf;
1439
+ }
1440
+ };
1409
1441
  for (;;) {
1410
1442
  const { value, done } = await reader.read();
1411
1443
  if (done) break;
@@ -1414,41 +1446,16 @@ export async function streamWithHeadInjection(
1414
1446
  // A chunk that ended mid-character decodes to "" — the bytes are held
1415
1447
  // in the decoder until the rest arrives. Nothing to emit yet.
1416
1448
  if (!text) continue;
1417
- if (!headInjected) {
1418
- const combined = carry + text;
1419
- const idx = combined.indexOf(HEAD_CLOSE);
1420
- if (idx >= 0) {
1421
- sendChunk(combined.slice(0, idx));
1422
- sendChunk(headBlob);
1423
- sendChunk(HEAD_CLOSE);
1424
- const after = combined.slice(idx + HEAD_CLOSE.length);
1425
- if (after) sendChunk(after);
1426
- headInjected = true;
1427
- carry = "";
1428
- } else {
1429
- const keep = HEAD_CLOSE.length - 1;
1430
- if (combined.length > keep) {
1431
- sendChunk(combined.slice(0, combined.length - keep));
1432
- carry = combined.slice(combined.length - keep);
1433
- } else {
1434
- carry = combined;
1435
- }
1436
- }
1437
- } else {
1438
- sendChunk(text);
1439
- }
1449
+ feed(text);
1440
1450
  }
1441
1451
  // Flush the decoder. A stream that ends mid-character is genuinely
1442
1452
  // truncated input, and this is where it becomes a replacement character
1443
1453
  // rather than silently vanishing.
1444
1454
  const tail = decoder.decode();
1445
- if (tail) {
1446
- if (!headInjected) {
1447
- carry += tail;
1448
- } else {
1449
- sendChunk(tail);
1450
- }
1451
- }
1455
+ if (tail) feed(tail);
1456
+ // A document with no `</body>` (fragment renders, truncated streams) leaves
1457
+ // its injection unconsumed. Emit the held-back bytes as-is rather than
1458
+ // forcing a badge into markup with nowhere to put it.
1452
1459
  if (carry) sendChunk(carry);
1453
1460
  }
1454
1461
 
@@ -1831,6 +1838,58 @@ export function buildDevHudChunk(devInfo: Record<string, unknown>): string {
1831
1838
  );
1832
1839
  }
1833
1840
 
1841
+ /**
1842
+ * The free-tier attribution badge, injected before `</body>` when the control
1843
+ * plane sets `PYLON_CLOUD_BADGE` (it does so only for hobby-plan projects, and
1844
+ * the key is protected so an app cannot unset it for itself).
1845
+ *
1846
+ * Deliberately plain: one anchor, inline styles, no script, no webfont, no
1847
+ * network request. It renders inside somebody else's app, so it must not fight
1848
+ * their layout or cost their visitors a round trip. Everything it could
1849
+ * inherit from the host page — font, line-height, box-sizing, text-transform —
1850
+ * is stated rather than left to chance.
1851
+ *
1852
+ * Returns "" when the flag is unset: every paid project, and every self-hosted
1853
+ * install.
1854
+ */
1855
+ export function buildCloudBadgeChunk(
1856
+ env: Record<string, string | undefined> = process.env,
1857
+ ): string {
1858
+ const flag = env.PYLON_CLOUD_BADGE?.trim().toLowerCase();
1859
+ if (!flag || flag === "0" || flag === "false" || flag === "no") return "";
1860
+ const href =
1861
+ "https://stack0.dev/?utm_source=stack0_badge&utm_medium=app&utm_campaign=hobby";
1862
+ const style = [
1863
+ "position:fixed",
1864
+ "right:12px",
1865
+ "bottom:12px",
1866
+ "z-index:2147483000",
1867
+ "display:inline-flex",
1868
+ "align-items:center",
1869
+ "gap:6px",
1870
+ "box-sizing:border-box",
1871
+ "margin:0",
1872
+ "padding:6px 10px",
1873
+ "border-radius:8px",
1874
+ "border:1px solid rgba(247,245,239,.14)",
1875
+ "background:#1a1814",
1876
+ "color:#f7f5ef",
1877
+ "font:500 12px/1 ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif",
1878
+ "letter-spacing:0",
1879
+ "text-transform:none",
1880
+ "text-decoration:none",
1881
+ "white-space:nowrap",
1882
+ "opacity:.92",
1883
+ ].join(";");
1884
+ const dot =
1885
+ "width:6px;height:6px;border-radius:50%;background:#3b5bdb;flex:none";
1886
+ return (
1887
+ `<a href="${href}" target="_blank" rel="noopener noreferrer"` +
1888
+ ` aria-label="Built on Stack0" data-pylon-badge style="${style}">` +
1889
+ `<span style="${dot}"></span>Built on Stack0</a>`
1890
+ );
1891
+ }
1892
+
1834
1893
  /**
1835
1894
  * Build the <head> blob for a boundary render: the union of every route's
1836
1895
  * stylesheet links from the client build manifest. Boundary modules aren't
@@ -2222,7 +2281,12 @@ async function renderBoundaryToClient(
2222
2281
  data: Buffer.from(text, "utf8").toString("base64"),
2223
2282
  });
2224
2283
  };
2225
- await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
2284
+ await streamWithHeadInjection(
2285
+ stream.getReader(),
2286
+ headBlob,
2287
+ sendChunk,
2288
+ buildCloudBadgeChunk(),
2289
+ );
2226
2290
  if (tail && manifestRoute) {
2227
2291
  const tailHtml = buildHydrationTail({
2228
2292
  component: tail.component,
@@ -3617,7 +3681,15 @@ export async function handleRenderRoute(
3617
3681
  "</head><body>",
3618
3682
  );
3619
3683
  } else {
3620
- await streamWithHeadInjection(stream.getReader(), headBlob, sendChunk);
3684
+ // navRender is skipped deliberately: it ships a data-only shell whose
3685
+ // markup the client throws away, so a badge there would never be seen
3686
+ // and would ride in every client-side navigation payload.
3687
+ await streamWithHeadInjection(
3688
+ stream.getReader(),
3689
+ headBlob,
3690
+ sendChunk,
3691
+ buildCloudBadgeChunk(),
3692
+ );
3621
3693
  }
3622
3694
 
3623
3695
  // #278: detect a late response.* mutation from a suspended subtree that the
@@ -14,7 +14,10 @@
14
14
  * server-only text corrupts with no warning at all.
15
15
  */
16
16
  import { describe, expect, test } from "bun:test";
17
- import { streamWithHeadInjection } from "./ssr-runtime";
17
+ import {
18
+ buildCloudBadgeChunk,
19
+ streamWithHeadInjection,
20
+ } from "./ssr-runtime";
18
21
 
19
22
  /** A reader over pre-split byte chunks — the shape React hands us. */
20
23
  function readerOf(chunks: Uint8Array[]): ReadableStreamDefaultReader<Uint8Array> {
@@ -32,9 +35,18 @@ function splitAt(text: string, at: number): Uint8Array[] {
32
35
  return [bytes.slice(0, at), bytes.slice(at)];
33
36
  }
34
37
 
35
- async function collect(chunks: Uint8Array[], headBlob = ""): Promise<string> {
38
+ async function collect(
39
+ chunks: Uint8Array[],
40
+ headBlob = "",
41
+ bodyBlob = "",
42
+ ): Promise<string> {
36
43
  const out: string[] = [];
37
- await streamWithHeadInjection(readerOf(chunks), headBlob, (t) => out.push(t));
44
+ await streamWithHeadInjection(
45
+ readerOf(chunks),
46
+ headBlob,
47
+ (t) => out.push(t),
48
+ bodyBlob,
49
+ );
38
50
  return out.join("");
39
51
  }
40
52
 
@@ -108,3 +120,80 @@ describe("streamWithHeadInjection — UTF-8 across chunk boundaries", () => {
108
120
  }
109
121
  });
110
122
  });
123
+
124
+ describe("streamWithHeadInjection — body injection", () => {
125
+ const doc = "<html><head><title>t</title></head><body><p>hi</p></body></html>";
126
+ const withBadge =
127
+ "<html><head><title>t</title></head><body><p>hi</p><i>B</i></body></html>";
128
+
129
+ test("the blob lands immediately before </body>", async () => {
130
+ const bytes = new TextEncoder().encode(doc);
131
+ expect(await collect([bytes], "", "<i>B</i>")).toBe(withBadge);
132
+ });
133
+
134
+ test("head and body blobs both land, in document order", async () => {
135
+ const bytes = new TextEncoder().encode(doc);
136
+ expect(await collect([bytes], "<meta>", "<i>B</i>")).toBe(
137
+ "<html><head><title>t</title><meta></head><body><p>hi</p><i>B</i></body></html>",
138
+ );
139
+ });
140
+
141
+ test("</body> split across every chunk boundary still matches", async () => {
142
+ // The carry buffer is the point: React splits wherever it likes, so a
143
+ // marker cut in half must not be missed (dropping the badge) or matched
144
+ // twice (duplicating it).
145
+ const bytes = new TextEncoder().encode(doc);
146
+ for (let at = 1; at < bytes.length; at++) {
147
+ const out = await collect(
148
+ [bytes.slice(0, at), bytes.slice(at)],
149
+ "",
150
+ "<i>B</i>",
151
+ );
152
+ expect(out).toBe(withBadge);
153
+ }
154
+ });
155
+
156
+ test("a document with no </body> passes through untouched", async () => {
157
+ // Fragment renders have no body close. Emitting the blob anyway would put
158
+ // a floating badge into markup meant to be embedded.
159
+ const frag = "<p>fragment</p>";
160
+ expect(await collect([new TextEncoder().encode(frag)], "", "<i>B</i>")).toBe(
161
+ frag,
162
+ );
163
+ });
164
+
165
+ test("an empty body blob leaves the stream byte-identical", async () => {
166
+ expect(await collect([new TextEncoder().encode(doc)])).toBe(doc);
167
+ });
168
+
169
+ test("only the first </body> is injected into", async () => {
170
+ const nested = "<body>a</body><body>b</body>";
171
+ expect(await collect([new TextEncoder().encode(nested)], "", "X")).toBe(
172
+ "<body>aX</body><body>b</body>",
173
+ );
174
+ });
175
+ });
176
+
177
+ describe("buildCloudBadgeChunk", () => {
178
+ test("returns nothing unless the control plane sets the flag", () => {
179
+ // Every paid project and every self-hosted install lands here.
180
+ expect(buildCloudBadgeChunk({})).toBe("");
181
+ expect(buildCloudBadgeChunk({ PYLON_CLOUD_BADGE: "" })).toBe("");
182
+ expect(buildCloudBadgeChunk({ PYLON_CLOUD_BADGE: "0" })).toBe("");
183
+ expect(buildCloudBadgeChunk({ PYLON_CLOUD_BADGE: "false" })).toBe("");
184
+ expect(buildCloudBadgeChunk({ PYLON_CLOUD_BADGE: "no" })).toBe("");
185
+ });
186
+
187
+ test("renders one self-contained anchor when enabled", () => {
188
+ const html = buildCloudBadgeChunk({ PYLON_CLOUD_BADGE: "1" });
189
+ expect(html).toContain("stack0.dev");
190
+ expect(html).toContain('aria-label="Built on Stack0"');
191
+ expect(html).toContain('rel="noopener noreferrer"');
192
+ // It renders inside someone else's app: no script, and nothing that costs
193
+ // their visitors a network round trip.
194
+ expect(html).not.toContain("<script");
195
+ expect(html).not.toContain("<link");
196
+ expect(html).not.toContain("@import");
197
+ expect(html.match(/<a /g)?.length).toBe(1);
198
+ });
199
+ });