@pylonsync/functions 0.3.264 → 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 +1 -1
- package/src/index.ts +9 -1
- package/src/ssr-client-bundler.ts +15 -0
- package/src/ssr-runtime.ts +170 -0
- package/src/ssr-sitemap.test.ts +219 -0
package/package.json
CHANGED
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 {
|
|
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",
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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, "&")
|
|
1356
|
+
.replace(/</g, "<")
|
|
1357
|
+
.replace(/>/g, ">")
|
|
1358
|
+
.replace(/"/g, """)
|
|
1359
|
+
.replace(/'/g, "'");
|
|
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&b=2<c>'"</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
|
+
});
|