- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock; remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports) - tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions), test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types - tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl - server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting; drop @hono/node-server, tsx, esbuild build.mjs - logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests - relative imports get explicit extensions (sites/mp-sync paths left for follow-up) - crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas - single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml - docs: README, runbook, ADR-001 superseded, plan §1 validation row Co-Authored-By: Claude Fable 5.1 <[email protected]>
207 lines
7.0 KiB
TypeScript
207 lines
7.0 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { type StartedServer, startHttpServer } from "./server.ts";
|
|
|
|
export interface MockFeedItem {
|
|
guid: string;
|
|
title: string;
|
|
link?: string;
|
|
enclosureUrl?: string;
|
|
size?: number;
|
|
pubDate?: Date;
|
|
categories?: string[];
|
|
description?: string;
|
|
}
|
|
|
|
export interface MockFailure {
|
|
status: number;
|
|
retryAfter?: string;
|
|
body?: string;
|
|
}
|
|
|
|
export interface MockRssOptions {
|
|
format?: "rss" | "atom";
|
|
items?: MockFeedItem[];
|
|
/** 访问需带的 passkey(query ?passkey=);缺失或错误返回 403 */
|
|
passkey?: string;
|
|
/** Feed 路径,默认 /rss.xml */
|
|
path?: string;
|
|
title?: string;
|
|
}
|
|
|
|
export interface MockRssServer extends StartedServer {
|
|
/** 带 passkey(若配置)的完整 Feed URL */
|
|
feedUrl: string;
|
|
items: MockFeedItem[];
|
|
setItems(items: MockFeedItem[]): void;
|
|
/** 新条目放到最前(RSS 惯例新→旧) */
|
|
addItem(item: MockFeedItem): void;
|
|
/** 同 GUID 修改内容(标题修正/促销等) */
|
|
updateItem(guid: string, patch: Partial<MockFeedItem>): void;
|
|
/** 置顶:把条目挪到最前 */
|
|
pinItem(guid: string): void;
|
|
/** 打乱顺序(确定性:按 seed 洗牌) */
|
|
shuffle(seed?: number): void;
|
|
/** 接下来 n 次请求返回失败(如 503 + Retry-After) */
|
|
failNext(n: number, failure: MockFailure): void;
|
|
/** 直接返回原始 XML(用于 DTD/XXE/超大响应等恶意样本);传 null 恢复正常 */
|
|
setRawBody(xml: string | null): void;
|
|
/** 是否支持 ETag/Last-Modified 条件请求(默认 true) */
|
|
setConditional(enabled: boolean): void;
|
|
/** 统计:返回 304 的次数 */
|
|
readonly notModifiedCount: number;
|
|
}
|
|
|
|
function esc(s: string): string {
|
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
export function renderRss(title: string, items: MockFeedItem[]): string {
|
|
const body = items
|
|
.map((it) => {
|
|
const parts = [
|
|
`<title>${esc(it.title)}</title>`,
|
|
`<guid isPermaLink="false">${esc(it.guid)}</guid>`,
|
|
it.link ? `<link>${esc(it.link)}</link>` : "",
|
|
it.enclosureUrl
|
|
? `<enclosure url="${esc(it.enclosureUrl)}" length="${it.size ?? 0}" type="application/x-bittorrent"/>`
|
|
: "",
|
|
it.pubDate ? `<pubDate>${it.pubDate.toUTCString()}</pubDate>` : "",
|
|
...(it.categories ?? []).map((c) => `<category>${esc(c)}</category>`),
|
|
it.description ? `<description><![CDATA[${it.description}]]></description>` : "",
|
|
];
|
|
return `<item>${parts.join("")}</item>`;
|
|
})
|
|
.join("\n");
|
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0"><channel><title>${esc(title)}</title>\n${body}\n</channel></rss>`;
|
|
}
|
|
|
|
export function renderAtom(title: string, items: MockFeedItem[]): string {
|
|
const body = items
|
|
.map((it) => {
|
|
const parts = [
|
|
`<id>${esc(it.guid)}</id>`,
|
|
`<title>${esc(it.title)}</title>`,
|
|
it.link ? `<link rel="alternate" href="${esc(it.link)}"/>` : "",
|
|
it.enclosureUrl ? `<link rel="enclosure" href="${esc(it.enclosureUrl)}" length="${it.size ?? 0}"/>` : "",
|
|
it.pubDate ? `<published>${it.pubDate.toISOString()}</published>` : "",
|
|
...(it.categories ?? []).map((c) => `<category term="${esc(c)}"/>`),
|
|
it.description ? `<summary>${esc(it.description)}</summary>` : "",
|
|
];
|
|
return `<entry>${parts.join("")}</entry>`;
|
|
})
|
|
.join("\n");
|
|
return `<?xml version="1.0" encoding="utf-8"?>\n<feed xmlns="http://www.w3.org/2005/Atom"><title>${esc(title)}</title>\n${body}\n</feed>`;
|
|
}
|
|
|
|
/** 可编程 mock RSS/Atom 服务器:ETag/Last-Modified/304、乱序/置顶/更新、5xx + Retry-After。 */
|
|
export async function createMockRssServer(opts: MockRssOptions = {}): Promise<MockRssServer> {
|
|
const path = opts.path ?? "/rss.xml";
|
|
const title = opts.title ?? "Mock Feed";
|
|
let items = [...(opts.items ?? [])];
|
|
let version = 1;
|
|
let lastModified = new Date(Date.UTC(2026, 8, 1));
|
|
const failures: MockFailure[] = [];
|
|
let raw: string | null = null;
|
|
let conditional = true;
|
|
let notModified = 0;
|
|
|
|
const bump = () => {
|
|
version++;
|
|
lastModified = new Date(lastModified.getTime() + 60_000);
|
|
};
|
|
|
|
const server = await startHttpServer((req, res) => {
|
|
const url = new URL(req.url ?? "/", "http://mock");
|
|
if (url.pathname !== path) {
|
|
res.writeHead(404).end("not found");
|
|
return;
|
|
}
|
|
if (opts.passkey && url.searchParams.get("passkey") !== opts.passkey) {
|
|
res.writeHead(403, { "content-type": "text/plain" }).end("invalid passkey");
|
|
return;
|
|
}
|
|
const failure = failures.shift();
|
|
if (failure) {
|
|
const headers: Record<string, string> = { "content-type": "text/plain" };
|
|
if (failure.retryAfter !== undefined) headers["retry-after"] = failure.retryAfter;
|
|
res.writeHead(failure.status, headers).end(failure.body ?? `mock failure ${failure.status}`);
|
|
return;
|
|
}
|
|
const body = raw ?? (opts.format === "atom" ? renderAtom(title, items) : renderRss(title, items));
|
|
const etag = `"${createHash("sha1").update(body).digest("hex").slice(0, 16)}-${version}"`;
|
|
const lm = lastModified.toUTCString();
|
|
if (conditional) {
|
|
const inm = req.headers["if-none-match"];
|
|
const ims = req.headers["if-modified-since"];
|
|
if ((inm && inm === etag) || (!inm && ims && Date.parse(ims) >= lastModified.getTime())) {
|
|
notModified++;
|
|
res.writeHead(304, { etag, "last-modified": lm }).end();
|
|
return;
|
|
}
|
|
}
|
|
const headers: Record<string, string> = {
|
|
"content-type":
|
|
opts.format === "atom" ? "application/atom+xml; charset=utf-8" : "application/rss+xml; charset=utf-8",
|
|
};
|
|
if (conditional) {
|
|
headers.etag = etag;
|
|
headers["last-modified"] = lm;
|
|
}
|
|
res.writeHead(200, headers).end(body);
|
|
});
|
|
|
|
const feedUrl = `${server.baseUrl}${path}${opts.passkey ? `?passkey=${encodeURIComponent(opts.passkey)}` : ""}`;
|
|
return {
|
|
...server,
|
|
feedUrl,
|
|
get items() {
|
|
return items;
|
|
},
|
|
setItems(next) {
|
|
items = [...next];
|
|
bump();
|
|
},
|
|
addItem(item) {
|
|
items = [item, ...items];
|
|
bump();
|
|
},
|
|
updateItem(guid, patch) {
|
|
items = items.map((it) => (it.guid === guid ? { ...it, ...patch, guid } : it));
|
|
bump();
|
|
},
|
|
pinItem(guid) {
|
|
const it = items.find((i) => i.guid === guid);
|
|
if (!it) return;
|
|
items = [it, ...items.filter((i) => i.guid !== guid)];
|
|
bump();
|
|
},
|
|
shuffle(seed = 42) {
|
|
let s = seed;
|
|
const rnd = () => {
|
|
s = (s * 1103515245 + 12345) % 2147483648;
|
|
return s / 2147483648;
|
|
};
|
|
const arr = [...items];
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(rnd() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j]!, arr[i]!];
|
|
}
|
|
items = arr;
|
|
bump();
|
|
},
|
|
failNext(n, failure) {
|
|
for (let i = 0; i < n; i++) failures.push(failure);
|
|
},
|
|
setRawBody(xml) {
|
|
raw = xml;
|
|
bump();
|
|
},
|
|
setConditional(enabled) {
|
|
conditional = enabled;
|
|
},
|
|
get notModifiedCount() {
|
|
return notModified;
|
|
},
|
|
};
|
|
}
|