@pylonsync/functions 0.3.263 → 0.3.265

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.263",
3
+ "version": "0.3.265",
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/index.ts CHANGED
@@ -25,7 +25,15 @@ export { slugifyName, availableSlug } from "./slugify";
25
25
  // SSR page response controller — pages/layouts receive `response` in
26
26
  // props (response.setStatus / redirect / notFound / setHeader / setCookie).
27
27
  // Type-only; the runtime instance is injected per-render by the SSR adapter.
28
- export type { SsrResponse, SsrCookieOptions, SsrMetadata } from "./ssr-runtime";
28
+ export type {
29
+ SsrResponse,
30
+ SsrCookieOptions,
31
+ SsrMetadata,
32
+ Sitemap,
33
+ SitemapEntry,
34
+ Robots,
35
+ RobotsRule,
36
+ } from "./ssr-runtime";
29
37
  export type {
30
38
  QueryCtx,
31
39
  MutationCtx,
@@ -81,6 +81,7 @@ declare const Bun: {
81
81
  format?: "esm" | "iife";
82
82
  minify?: boolean;
83
83
  sourcemap?: "none" | "inline" | "external";
84
+ define?: Record<string, string>;
84
85
  external?: string[];
85
86
  splitting?: boolean;
86
87
  naming?:
@@ -871,6 +872,20 @@ async function _doBuildInner(
871
872
  minify: true,
872
873
  sourcemap: "none",
873
874
  splitting: true,
875
+ // The browser has no `process`. React, next-themes, sonner, and most
876
+ // npm UI deps reference `process.env.NODE_ENV` (and migrated Next code
877
+ // may reference other `process.env.*`), so without this the very first
878
+ // such reference throws `ReferenceError: process is not defined` during
879
+ // hydration — React then unmounts the tree and the page renders blank.
880
+ // Statically replace `process.env.NODE_ENV` with the build mode and
881
+ // collapse any other `process.env.*` to an empty object (→ undefined),
882
+ // so no `process` reference survives into the browser bundle.
883
+ define: {
884
+ "process.env.NODE_ENV": JSON.stringify(
885
+ process.env.NODE_ENV === "development" ? "development" : "production",
886
+ ),
887
+ "process.env": "({})",
888
+ },
874
889
  naming: {
875
890
  entry: "[name]-[hash].js",
876
891
  chunk: "chunks/[name]-[hash].js",
@@ -1303,10 +1303,180 @@ export function diffCommittedResponse(
1303
1303
  return null;
1304
1304
  }
1305
1305
 
1306
+ // ===========================================================================
1307
+ // Data-route file conventions: app/sitemap.ts → /sitemap.xml,
1308
+ // app/robots.ts → /robots.txt (Next.js-style). @pylonsync/sdk discovers these
1309
+ // (routes with kind "sitemap"/"robots") so the host routes /sitemap.xml +
1310
+ // /robots.txt through the SSR RPC; here we detect them by the module basename,
1311
+ // call the default export (awaiting if async — so it can hit the DB / enumerate
1312
+ // dynamic pages), serialize the return to XML / plain text, and stream it with
1313
+ // the right content-type. No React render.
1314
+ // ===========================================================================
1315
+
1316
+ /** One URL entry in a sitemap (mirrors Next's `MetadataRoute.Sitemap[number]`). */
1317
+ export interface SitemapEntry {
1318
+ url: string;
1319
+ lastModified?: string | Date;
1320
+ changeFrequency?:
1321
+ | "always"
1322
+ | "hourly"
1323
+ | "daily"
1324
+ | "weekly"
1325
+ | "monthly"
1326
+ | "yearly"
1327
+ | "never";
1328
+ priority?: number;
1329
+ /** hreflang alternates, e.g. `{ languages: { "en-US": "https://…/en" } }`. */
1330
+ alternates?: { languages?: Record<string, string> };
1331
+ }
1332
+
1333
+ /** Return type of a default export in `app/sitemap.ts`. */
1334
+ export type Sitemap = SitemapEntry[];
1335
+
1336
+ export interface RobotsRule {
1337
+ userAgent?: string | string[];
1338
+ allow?: string | string[];
1339
+ disallow?: string | string[];
1340
+ crawlDelay?: number;
1341
+ }
1342
+
1343
+ /** Return type of a default export in `app/robots.ts`. */
1344
+ export interface Robots {
1345
+ rules: RobotsRule | RobotsRule[];
1346
+ sitemap?: string | string[];
1347
+ host?: string;
1348
+ }
1349
+
1350
+ /** Serialize sitemap entries to a sitemaps.org 0.9 XML document. */
1351
+ export function serializeSitemap(entries: Sitemap | undefined): string {
1352
+ const list = Array.isArray(entries) ? entries : [];
1353
+ const esc = (s: unknown): string =>
1354
+ String(s)
1355
+ .replace(/&/g, "&amp;")
1356
+ .replace(/</g, "&lt;")
1357
+ .replace(/>/g, "&gt;")
1358
+ .replace(/"/g, "&quot;")
1359
+ .replace(/'/g, "&apos;");
1360
+ const iso = (d: string | Date): string =>
1361
+ d instanceof Date ? d.toISOString() : String(d);
1362
+ const needsXhtml = list.some(
1363
+ (e) =>
1364
+ e?.alternates?.languages &&
1365
+ Object.keys(e.alternates.languages).length > 0,
1366
+ );
1367
+ const ns =
1368
+ 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"' +
1369
+ (needsXhtml ? ' xmlns:xhtml="http://www.w3.org/1999/xhtml"' : "");
1370
+ const body = list
1371
+ .filter((e) => e && typeof e.url === "string")
1372
+ .map((e) => {
1373
+ let u = `<url><loc>${esc(e.url)}</loc>`;
1374
+ if (e.lastModified != null)
1375
+ u += `<lastmod>${esc(iso(e.lastModified))}</lastmod>`;
1376
+ if (e.changeFrequency) u += `<changefreq>${esc(e.changeFrequency)}</changefreq>`;
1377
+ if (e.priority != null) u += `<priority>${esc(e.priority)}</priority>`;
1378
+ const langs = e.alternates?.languages;
1379
+ if (langs) {
1380
+ for (const [lang, href] of Object.entries(langs)) {
1381
+ u += `<xhtml:link rel="alternate" hreflang="${esc(lang)}" href="${esc(href)}"/>`;
1382
+ }
1383
+ }
1384
+ return `${u}</url>`;
1385
+ })
1386
+ .join("");
1387
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset ${ns}>${body}</urlset>\n`;
1388
+ }
1389
+
1390
+ /** Serialize a robots config to robots.txt text. */
1391
+ export function serializeRobots(robots: Robots | undefined): string {
1392
+ const arr = (v: string | string[] | undefined): string[] =>
1393
+ v == null ? [] : Array.isArray(v) ? v : [v];
1394
+ const lines: string[] = [];
1395
+ const rules = robots
1396
+ ? Array.isArray(robots.rules)
1397
+ ? robots.rules
1398
+ : [robots.rules]
1399
+ : [];
1400
+ for (const rule of rules) {
1401
+ if (!rule) continue;
1402
+ const uas = arr(rule.userAgent);
1403
+ for (const ua of uas.length ? uas : ["*"]) lines.push(`User-agent: ${ua}`);
1404
+ for (const a of arr(rule.allow)) lines.push(`Allow: ${a}`);
1405
+ for (const d of arr(rule.disallow)) lines.push(`Disallow: ${d}`);
1406
+ if (rule.crawlDelay != null) lines.push(`Crawl-delay: ${rule.crawlDelay}`);
1407
+ lines.push("");
1408
+ }
1409
+ for (const sm of arr(robots?.sitemap)) lines.push(`Sitemap: ${sm}`);
1410
+ if (robots?.host) lines.push(`Host: ${robots.host}`);
1411
+ return `${lines.join("\n").replace(/\n*$/, "")}\n`;
1412
+ }
1413
+
1414
+ /**
1415
+ * Import app/sitemap or app/robots, run its default export, serialize the
1416
+ * result, and stream it back with the right content-type. Errors surface as a
1417
+ * 500 with a short plain-text message (so a broken sitemap doesn't wedge the
1418
+ * runner). 1-hour cache — sitemaps/robots change rarely; tune via a CDN.
1419
+ */
1420
+ export async function handleDataRoute(
1421
+ msg: RenderRouteMessage,
1422
+ kind: "sitemap" | "robots",
1423
+ send: Send,
1424
+ ): Promise<void> {
1425
+ const emit = (status: number, contentType: string, body: string): void => {
1426
+ send({
1427
+ type: "response_start",
1428
+ call_id: msg.call_id,
1429
+ status,
1430
+ headers:
1431
+ status === 200
1432
+ ? { "content-type": contentType, "cache-control": "public, max-age=3600" }
1433
+ : { "content-type": contentType },
1434
+ });
1435
+ send({
1436
+ type: "render_chunk",
1437
+ call_id: msg.call_id,
1438
+ data: Buffer.from(body, "utf8").toString("base64"),
1439
+ });
1440
+ send({ type: "render_done", call_id: msg.call_id });
1441
+ };
1442
+ try {
1443
+ const cwd = process.cwd();
1444
+ const mod = await importModule(cwd, msg.component);
1445
+ const exp = mod.default ?? mod[kind];
1446
+ const data = typeof exp === "function" ? await exp() : exp;
1447
+ if (kind === "sitemap") {
1448
+ emit(200, "application/xml; charset=utf-8", serializeSitemap(data as Sitemap));
1449
+ } else {
1450
+ emit(200, "text/plain; charset=utf-8", serializeRobots(data as Robots));
1451
+ }
1452
+ } catch (e: any) {
1453
+ emit(
1454
+ 500,
1455
+ "text/plain; charset=utf-8",
1456
+ `pylon: failed to generate ${kind} (${msg.component}): ${e?.message ?? String(e)}\n`,
1457
+ );
1458
+ }
1459
+ }
1460
+
1306
1461
  export async function handleRenderRoute(
1307
1462
  msg: RenderRouteMessage,
1308
1463
  send: Send,
1309
1464
  ): Promise<void> {
1465
+ // Data-route conventions short-circuit the React render path: app/sitemap →
1466
+ // /sitemap.xml (XML), app/robots → /robots.txt (text). Detected by the module
1467
+ // basename — the SDK only registers these routes for app/sitemap.* /
1468
+ // app/robots.*, so the basename is exactly "sitemap"/"robots" (a real page
1469
+ // component always ends in "/page"). Mirrors the not-found/error basename
1470
+ // check used for boundaries.
1471
+ const dataKind: "sitemap" | "robots" | null = /(^|[\\/])sitemap$/.test(
1472
+ msg.component,
1473
+ )
1474
+ ? "sitemap"
1475
+ : /(^|[\\/])robots$/.test(msg.component)
1476
+ ? "robots"
1477
+ : null;
1478
+ if (dataKind) return handleDataRoute(msg, dataKind, send);
1479
+
1310
1480
  // Declared OUTSIDE the try so the catch can read page-set status/
1311
1481
  // cookies when turning a redirect()/notFound() throw into a response.
1312
1482
  const responseState: ResponseState = {
@@ -0,0 +1,219 @@
1
+ // Tests for the app/sitemap.ts → /sitemap.xml and app/robots.ts → /robots.txt
2
+ // data-route conventions: the pure serializers (where escaping / shape bugs
3
+ // live) plus handleDataRoute end-to-end (import a temp module, serialize, emit
4
+ // the response_start/chunk/done protocol with the right content-type).
5
+
6
+ import { afterEach, describe, expect, test } from "bun:test";
7
+ import * as fs from "node:fs";
8
+ import * as os from "node:os";
9
+ import * as path from "node:path";
10
+ import {
11
+ handleDataRoute,
12
+ serializeRobots,
13
+ serializeSitemap,
14
+ type RenderRouteMessage,
15
+ } from "./ssr-runtime";
16
+
17
+ describe("serializeSitemap", () => {
18
+ test("empty / undefined → valid empty urlset", () => {
19
+ for (const v of [[], undefined as any]) {
20
+ const xml = serializeSitemap(v);
21
+ expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
22
+ expect(xml).toContain(
23
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
24
+ );
25
+ expect(xml).toContain("</urlset>");
26
+ }
27
+ });
28
+
29
+ test("emits loc + optional fields in order", () => {
30
+ const xml = serializeSitemap([
31
+ {
32
+ url: "https://x.com/blog",
33
+ lastModified: "2026-01-02",
34
+ changeFrequency: "weekly",
35
+ priority: 0.8,
36
+ },
37
+ ]);
38
+ expect(xml).toContain(
39
+ "<url><loc>https://x.com/blog</loc><lastmod>2026-01-02</lastmod><changefreq>weekly</changefreq><priority>0.8</priority></url>",
40
+ );
41
+ });
42
+
43
+ test("Date lastModified is ISO-serialized", () => {
44
+ const d = new Date("2026-06-12T10:00:00.000Z");
45
+ const xml = serializeSitemap([{ url: "https://x.com/", lastModified: d }]);
46
+ expect(xml).toContain("<lastmod>2026-06-12T10:00:00.000Z</lastmod>");
47
+ });
48
+
49
+ test("XML-escapes loc and values", () => {
50
+ const xml = serializeSitemap([
51
+ { url: "https://x.com/s?a=1&b=2<c>'\"" },
52
+ ]);
53
+ expect(xml).toContain(
54
+ "<loc>https://x.com/s?a=1&amp;b=2&lt;c&gt;&apos;&quot;</loc>",
55
+ );
56
+ // raw unescaped specials must not leak into the document body
57
+ expect(xml.includes("a=1&b=2")).toBe(false);
58
+ });
59
+
60
+ test("hreflang alternates add the xhtml namespace + link tags", () => {
61
+ const xml = serializeSitemap([
62
+ {
63
+ url: "https://x.com/en",
64
+ alternates: {
65
+ languages: { "en-US": "https://x.com/en", "es-ES": "https://x.com/es" },
66
+ },
67
+ },
68
+ ]);
69
+ expect(xml).toContain('xmlns:xhtml="http://www.w3.org/1999/xhtml"');
70
+ expect(xml).toContain(
71
+ '<xhtml:link rel="alternate" hreflang="en-US" href="https://x.com/en"/>',
72
+ );
73
+ expect(xml).toContain('hreflang="es-ES"');
74
+ });
75
+
76
+ test("no xhtml namespace when no alternates present", () => {
77
+ const xml = serializeSitemap([{ url: "https://x.com/" }]);
78
+ expect(xml.includes("xmlns:xhtml")).toBe(false);
79
+ });
80
+
81
+ test("skips entries without a string url", () => {
82
+ const xml = serializeSitemap([
83
+ { url: "https://x.com/ok" },
84
+ { url: undefined as any },
85
+ null as any,
86
+ ]);
87
+ expect((xml.match(/<url>/g) || []).length).toBe(1);
88
+ });
89
+ });
90
+
91
+ describe("serializeRobots", () => {
92
+ test("single rule with default user-agent", () => {
93
+ const txt = serializeRobots({ rules: { allow: "/", disallow: "/admin" } });
94
+ expect(txt).toContain("User-agent: *");
95
+ expect(txt).toContain("Allow: /");
96
+ expect(txt).toContain("Disallow: /admin");
97
+ expect(txt.endsWith("\n")).toBe(true);
98
+ });
99
+
100
+ test("array userAgent + array allow/disallow + crawlDelay", () => {
101
+ const txt = serializeRobots({
102
+ rules: {
103
+ userAgent: ["Googlebot", "Bingbot"],
104
+ disallow: ["/a", "/b"],
105
+ crawlDelay: 5,
106
+ },
107
+ });
108
+ expect(txt).toContain("User-agent: Googlebot");
109
+ expect(txt).toContain("User-agent: Bingbot");
110
+ expect(txt).toContain("Disallow: /a");
111
+ expect(txt).toContain("Disallow: /b");
112
+ expect(txt).toContain("Crawl-delay: 5");
113
+ });
114
+
115
+ test("multiple rules, sitemap (array) + host", () => {
116
+ const txt = serializeRobots({
117
+ rules: [
118
+ { userAgent: "*", disallow: "/private" },
119
+ { userAgent: "BadBot", disallow: "/" },
120
+ ],
121
+ sitemap: ["https://x.com/sitemap.xml", "https://x.com/news.xml"],
122
+ host: "https://x.com",
123
+ });
124
+ expect(txt).toContain("User-agent: BadBot");
125
+ expect(txt).toContain("Sitemap: https://x.com/sitemap.xml");
126
+ expect(txt).toContain("Sitemap: https://x.com/news.xml");
127
+ expect(txt).toContain("Host: https://x.com");
128
+ });
129
+
130
+ test("undefined robots → just a trailing newline", () => {
131
+ expect(serializeRobots(undefined)).toBe("\n");
132
+ });
133
+ });
134
+
135
+ describe("handleDataRoute (end-to-end import + serialize)", () => {
136
+ const tmpdirs: string[] = [];
137
+ const prevCwd = process.cwd();
138
+ afterEach(() => {
139
+ process.chdir(prevCwd);
140
+ for (const d of tmpdirs.splice(0)) {
141
+ try {
142
+ fs.rmSync(d, { recursive: true, force: true });
143
+ } catch {
144
+ /* best effort */
145
+ }
146
+ }
147
+ });
148
+
149
+ function fixture(file: string, src: string): void {
150
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-data-route-"));
151
+ tmpdirs.push(dir);
152
+ fs.mkdirSync(path.join(dir, "app"), { recursive: true });
153
+ fs.writeFileSync(path.join(dir, "app", file), src);
154
+ process.chdir(dir);
155
+ }
156
+
157
+ const collect = async (
158
+ component: string,
159
+ kind: "sitemap" | "robots",
160
+ ): Promise<any[]> => {
161
+ const sent: any[] = [];
162
+ const msg = { component, call_id: "c1" } as unknown as RenderRouteMessage;
163
+ await handleDataRoute(msg, kind, (m) => sent.push(m));
164
+ return sent;
165
+ };
166
+
167
+ test("async app/sitemap.ts → 200 application/xml with the rendered urls", async () => {
168
+ fixture(
169
+ "sitemap.ts",
170
+ `export default async function sitemap() {
171
+ return [{ url: "https://x.com/", priority: 1.0, changeFrequency: "daily" }];
172
+ }`,
173
+ );
174
+ const sent = await collect("app/sitemap", "sitemap");
175
+ const start = sent.find((m) => m.type === "response_start");
176
+ const chunk = sent.find((m) => m.type === "render_chunk");
177
+ expect(start.status).toBe(200);
178
+ expect(start.headers["content-type"]).toContain("application/xml");
179
+ expect(start.headers["cache-control"]).toBe("public, max-age=3600");
180
+ const body = Buffer.from(chunk.data, "base64").toString("utf8");
181
+ expect(body).toContain("<loc>https://x.com/</loc>");
182
+ expect(body).toContain("<priority>1</priority>");
183
+ expect(sent.some((m) => m.type === "render_done")).toBe(true);
184
+ });
185
+
186
+ test("app/robots.ts → 200 text/plain", async () => {
187
+ fixture(
188
+ "robots.ts",
189
+ `export default function robots() {
190
+ return { rules: { userAgent: "*", allow: "/", disallow: "/dashboard" }, sitemap: "https://x.com/sitemap.xml" };
191
+ }`,
192
+ );
193
+ const sent = await collect("app/robots", "robots");
194
+ const start = sent.find((m) => m.type === "response_start");
195
+ const body = Buffer.from(
196
+ sent.find((m) => m.type === "render_chunk").data,
197
+ "base64",
198
+ ).toString("utf8");
199
+ expect(start.headers["content-type"]).toContain("text/plain");
200
+ expect(body).toContain("Disallow: /dashboard");
201
+ expect(body).toContain("Sitemap: https://x.com/sitemap.xml");
202
+ });
203
+
204
+ test("a throwing sitemap surfaces as a 500 (does not wedge the runner)", async () => {
205
+ fixture(
206
+ "sitemap.ts",
207
+ `export default function sitemap() { throw new Error("boom"); }`,
208
+ );
209
+ const sent = await collect("app/sitemap", "sitemap");
210
+ const start = sent.find((m) => m.type === "response_start");
211
+ expect(start.status).toBe(500);
212
+ const body = Buffer.from(
213
+ sent.find((m) => m.type === "render_chunk").data,
214
+ "base64",
215
+ ).toString("utf8");
216
+ expect(body).toContain("boom");
217
+ expect(sent.some((m) => m.type === "render_done")).toBe(true);
218
+ });
219
+ });