diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..842ba0d Binary files /dev/null and b/.DS_Store differ diff --git a/docs/code-wiki/01-overview-and-architecture.md b/docs/code-wiki/01-overview-and-architecture.md new file mode 100644 index 0000000..6bd2a7f --- /dev/null +++ b/docs/code-wiki/01-overview-and-architecture.md @@ -0,0 +1,159 @@ +# 项目总览与架构 + +## 1. 项目定位 + +`CloudflareSub` 是一个部署在 Cloudflare Workers 上的轻量订阅生成服务,主要解决“将原始节点批量替换为优选 IP / 域名,并输出不同客户端可直接导入的订阅链接”这一问题。 + +当前支持的节点协议: + +- `vmess` +- `vless` +- `trojan` + +当前支持的订阅输出格式: + +- Raw Base64 +- Clash YAML +- Surge 文本配置 + +## 2. 目标用户与使用场景 + +典型场景如下: + +- 用户已经有自建的 Xray / 3X-UI / VPS 节点。 +- 用户已经自行搜集 Cloudflare 优选 IP 或优选域名。 +- 用户希望把这些优选地址批量替换进节点。 +- 用户希望输出可被 V2rayN、Shadowrocket、Clash、Surge 等客户端直接导入的订阅链接。 + +## 3. 部署形态 + +项目采用 `Worker + Static Assets + Workers KV` 的组合部署方式。 + +### 3.1 运行面 + +- `src/worker.js` 作为唯一 Worker 入口。 +- `public/` 目录作为静态资源目录,由 `ASSETS` 绑定提供。 +- `SUB_STORE` 作为 KV Namespace,负责保存订阅记录与去重映射。 + +### 3.2 路由分工 + +- `POST /api/generate` + - 接收前端提交的节点链接和优选地址。 + - 解析并展开节点。 + - 写入 KV。 + - 返回可访问的短链订阅 URL。 +- `GET /sub/:id` + - 从 KV 读取订阅记录。 + - 根据 `target` 参数输出不同格式的订阅内容。 +- 其他路径 + - 交给 `env.ASSETS.fetch(request)`,返回前端页面与静态资源。 + +## 4. 目录结构 + +```text +cloudflaresub/ +├─ public/ +│ ├─ icons/ # 客户端图标与页面图标 +│ ├─ app.js # 前端交互逻辑 +│ ├─ index.html # 页面结构 +│ └─ styles.css # 页面样式 +├─ src/ +│ ├─ core.js # 通用解析/展开/渲染核心库 +│ └─ worker.js # Worker 入口与 API 路由 +├─ tests/ +│ └─ smoke.mjs # 冒烟测试 +├─ docs/ +│ └─ code-wiki/ # 本套 Wiki 文档 +├─ package.json # npm 脚本与依赖 +├─ README.md # 用户向说明文档 +└─ wrangler.toml # Worker 配置 +``` + +## 5. 高层架构图 + +```text +Browser + │ + │ 访问 / + ▼ +Cloudflare Worker + ├─ 非 /api/* 与 /sub/* 路由 -> ASSETS -> public/* + ├─ POST /api/generate + │ ├─ 解析节点 + │ ├─ 解析优选地址 + │ ├─ 批量展开节点 + │ ├─ 计算去重哈希 + │ └─ 写入 KV 并返回短链 + └─ GET /sub/:id + ├─ 校验访问 token + ├─ 读取 KV 记录 + └─ 渲染 raw / clash / surge + +Workers KV + ├─ sub: -> 实际订阅记录 + └─ dedup: -> 去重映射 +``` + +## 6. 核心业务流程 + +### 6.1 订阅生成流程 + +1. 用户在页面填写 `nodeLinks`、`preferredIps`、`namePrefix`、`keepOriginalHost`。 +2. 前端向 `POST /api/generate` 发送 JSON。 +3. Worker 解析原始节点。 +4. Worker 解析优选地址列表。 +5. Worker 将“基础节点”与“优选地址”做组合展开。 +6. Worker 计算归一化哈希,用于去重。 +7. 如 KV 中不存在对应去重键,则创建新的短 ID 并存储记录。 +8. Worker 返回自动识别、Raw、Clash、Surge 四类订阅 URL。 + +### 6.2 订阅获取流程 + +1. 客户端请求 `/sub/:id?target=&token=`。 +2. Worker 校验 `SUB_ACCESS_TOKEN`。 +3. Worker 从 `SUB_STORE` 中读取 `sub:`。 +4. Worker 根据 `target` 调用不同渲染函数。 +5. 返回对应格式的文本内容。 + +## 7. 配置入口 + +### 7.1 `wrangler.toml` + +关键配置如下: + +- `name = "cloudflaresub"` +- `main = "src/worker.js"` +- `compatibility_date = "2026-03-06"` +- `assets.directory = "./public"` +- `assets.binding = "ASSETS"` +- `assets.run_worker_first = ["/api/*", "/sub/*"]` + +该配置表明: + +- 默认请求由静态资源处理。 +- `/api/*` 和 `/sub/*` 会优先进入 Worker 逻辑。 + +### 7.2 `package.json` + +项目脚本非常精简: + +- `npm run dev`:本地开发 +- `npm run deploy`:部署 Worker +- `npm run check`:运行冒烟测试 + +## 8. 当前架构特点 + +### 优点 + +- 仓库小,理解成本低。 +- 运行链路清晰,职责集中。 +- 不依赖前端框架,部署简单。 +- 以 KV 保存订阅记录,适合轻量场景。 + +### 风险与现状 + +- `src/worker.js` 内部实现了一套解析和渲染逻辑。 +- `src/core.js` 又维护了另一套更通用、更完整的核心库实现。 +- `tests/smoke.mjs` 主要测试 `src/core.js`,不是 `src/worker.js`。 + +这意味着当前仓库在“运行逻辑”和“测试逻辑”之间存在重复实现,后续新增协议特性或导出格式时需要同步两边,否则可能出现行为不一致。 diff --git a/docs/code-wiki/02-modules-and-responsibilities.md b/docs/code-wiki/02-modules-and-responsibilities.md new file mode 100644 index 0000000..db79da6 --- /dev/null +++ b/docs/code-wiki/02-modules-and-responsibilities.md @@ -0,0 +1,299 @@ +# 模块与职责 + +## 1. 模块划分总览 + +项目可以拆成 4 个主要模块层: + +1. Worker 路由与运行时模块:`src/worker.js` +2. 通用核心库模块:`src/core.js` +3. 前端页面模块:`public/*` +4. 测试模块:`tests/smoke.mjs` + +## 2. Worker 路由与运行时模块 + +文件:`src/worker.js` + +这是线上实际运行的主模块,负责: + +- HTTP 路由分发 +- CORS 处理 +- 请求体解析 +- 节点解析与节点展开 +- 订阅内容导出 +- KV 去重与短链存储 +- 订阅访问令牌校验 +- 静态资源回退 + +### 2.1 内部职责分组 + +#### 响应封装 + +- `json(data, status)` + - 返回 JSON 响应。 + - 同时附加 CORS 头。 +- `text(body, status, contentType)` + - 返回文本响应。 + +#### 基础编解码 + +- `b64EncodeUtf8(str)` +- `b64DecodeUtf8(str)` +- `escapeYaml(str)` + +这些函数用于: + +- `vmess` 编码/解码 +- 原始订阅 Base64 输出 +- Clash YAML 转义 + +#### 输入解析 + +- `parsePreferredEndpoints(input)` + - 解析多行优选地址。 + - 支持 `host[:port][#remark]`。 +- `parseVmess(link)` + - 解析 `vmess://`。 +- `parseUrlLike(link, type)` + - 解析 `vless://` 与 `trojan://`。 +- `parseRawLinks(input)` + - 解析多行节点。 + - 支持直接粘贴 Base64 订阅文本。 + +#### 节点展开 + +- `buildNodes(baseNodes, preferredEndpoints, options)` + - 把基础节点与优选地址做笛卡尔展开。 + - 生成最终输出节点名。 + - 根据 `keepOriginalHost` 决定是否保留原 `host` / `sni`。 + +#### 节点序列化 + +- `encodeVmess(node)` +- `encodeVless(node)` +- `encodeTrojan(node)` +- `renderRaw(nodes)` +- `renderClash(nodes)` +- `renderSurge(nodes, baseUrl, accessToken)` + +#### 存储与去重 + +- `createShortId(length)` + - 生成随机短 ID。 +- `createUniqueShortId(env, tries)` + - 确保 KV 中不存在冲突。 +- `normalizeLines(value)` + - 对输入做排序与归一化。 +- `sha256Hex(input)` + - 生成去重哈希。 +- `buildDedupHash(body)` + - 基于输入内容生成稳定 hash。 + +#### 业务路由 + +- `handleGenerate(request, env, url)` + - 生成订阅记录。 +- `validateAccessToken(url, env)` + - 校验访问令牌。 +- `handleSub(url, env)` + - 返回对应格式的订阅内容。 + +#### Worker 入口 + +- `export default { fetch(request, env) { ... } }` + +### 2.2 该模块的边界 + +它不负责: + +- 前端页面渲染逻辑 +- 复杂状态管理 +- 数据库存储 +- 自动查找优选 IP + +它只负责“接收输入、生成可复用订阅结果、输出文本格式”。 + +## 3. 通用核心库模块 + +文件:`src/core.js` + +这是一个更适合复用与测试的纯函数库,特点是: + +- 以 `export function` 形式暴露能力 +- 职责拆分比 `worker.js` 更细 +- 支持更完整的协议字段 +- 支持 AES-GCM 载荷加解密 +- 支持 `json` 订阅导出 + +### 3.1 主要能力域 + +#### 输入标准化与识别 + +- `normalizeText` +- `splitCsvLike` +- `detectTarget` +- `buildShareUrls` + +#### 订阅加解密 + +- `ensureSecret` +- `encryptPayload` +- `decryptPayload` + +#### 节点与优选地址解析 + +- `parseNodeLinks` +- `parsePreferredEndpoints` +- `parseVmessUri` +- `parseVlessUri` +- `parseTrojanUri` +- `parseEndpoint` + +#### 节点展开与预览 + +- `expandNodes` +- `summarizeNodes` + +#### 导出渲染 + +- `renderSubscription` +- `renderRawSubscription` +- `renderClashSubscription` +- `renderSurgeSubscription` +- `renderNodeUri` +- `renderVmessUri` +- `renderVlessUri` +- `renderTrojanUri` + +### 3.2 与 `worker.js` 的关系 + +`src/core.js` 可以理解为“理想的纯业务层”,而 `src/worker.js` 是“当前实际运行的接入层 + 内嵌业务实现”。 + +当前现状: + +- `worker.js` 没有直接 import `core.js` +- 两者功能有明显重叠 +- `core.js` 的抽象更细、更适合扩展和测试 + +这表明仓库存在“尚未完成统一收敛”的代码结构。 + +## 4. 前端页面模块 + +文件: + +- `public/index.html` +- `public/app.js` +- `public/styles.css` +- `public/icons/*` + +### 4.1 `public/index.html` + +职责: + +- 组织页面结构 +- 提供表单输入区 +- 提供结果展示区 +- 提供二维码弹窗 +- 引入前端脚本和二维码库 + +页面核心区域: + +- Hero 介绍区 +- 节点与优选地址输入区 +- 结果链接区 +- 统计卡片区 +- 预览表格区 +- 二维码弹窗 + +### 4.2 `public/app.js` + +职责: + +- 绑定 DOM 事件 +- 提交 `/api/generate` +- 渲染 API 返回结果 +- 处理复制操作 +- 处理二维码弹窗 +- 对预览内容做基础 HTML 转义 + +主要交互流: + +1. 用户点击“填入演示数据”时填充示例。 +2. 用户点击“生成订阅”时发送请求。 +3. 成功后回填各类订阅链接和统计信息。 +4. 提供复制与二维码导入能力。 + +### 4.3 `public/styles.css` + +职责: + +- 定义整体深色主题 +- 定义响应式布局与卡片风格 +- 定义表单、按钮、结果卡片、表格和模态框样式 + +该文件属于纯表现层,没有业务逻辑。 + +## 5. 测试模块 + +文件:`tests/smoke.mjs` + +职责: + +- 验证 `src/core.js` 的关键能力可正常工作 +- 作为最低成本的功能回归检查 + +当前覆盖点: + +- 节点解析 +- 优选地址解析 +- 节点展开 +- Raw 导出 +- Clash 导出 +- Surge 导出 +- AES-GCM 加密与解密 + +### 5.1 测试边界 + +当前未覆盖: + +- `src/worker.js` 的路由行为 +- KV 写入与读取 +- `SUB_ACCESS_TOKEN` 校验分支 +- `env.ASSETS.fetch()` 静态资源回退 + +因此,它更像是“核心能力 smoke test”,不是完整集成测试。 + +## 6. 配置模块 + +### 6.1 `wrangler.toml` + +职责: + +- 指定 Worker 入口 +- 指定兼容日期 +- 打开 `workers_dev` +- 绑定静态资源目录 +- 让 `/api/*` 与 `/sub/*` 优先进入 Worker + +### 6.2 `package.json` + +职责: + +- 声明 `type: module` +- 提供开发、部署、检查脚本 +- 管理唯一开发依赖 `wrangler` + +## 7. 模块边界总结 + +| 模块 | 文件 | 核心职责 | 是否线上直接使用 | +| --- | --- | --- | --- | +| Worker 运行时 | `src/worker.js` | 路由、存储、导出、鉴权 | 是 | +| 核心库 | `src/core.js` | 纯函数解析/展开/渲染/加解密 | 否,当前主要用于测试 | +| 前端页面 | `public/*` | 表单交互、结果展示、二维码 | 是 | +| 测试 | `tests/smoke.mjs` | 核心库冒烟验证 | 否 | +| 配置 | `wrangler.toml` `package.json` | 部署与脚本 | 是 | + +## 8. 维护建议 + +- 如需新增协议字段,优先确认 `worker.js` 与 `core.js` 是否都要同步更新。 +- 如需提升测试可信度,建议新增对 `worker.js` 的集成测试。 +- 如需长期维护,建议把 `worker.js` 的解析/导出逻辑逐步收敛到 `core.js`。 diff --git a/docs/code-wiki/03-key-functions-and-call-chains.md b/docs/code-wiki/03-key-functions-and-call-chains.md new file mode 100644 index 0000000..7ff3be3 --- /dev/null +++ b/docs/code-wiki/03-key-functions-and-call-chains.md @@ -0,0 +1,453 @@ +# 关键函数与调用链 + +## 1. 说明 + +本项目几乎没有 class,核心实现以函数为主。因此本节重点说明“关键函数”而不是“关键类”。 + +## 2. `src/worker.js` 关键函数 + +## 2.1 接口入口 + +### `fetch(request, env)` + +作用: + +- 作为 Worker 的总入口 +- 处理 `OPTIONS` +- 把 `POST /api/generate` 路由到 `handleGenerate` +- 把 `GET /sub/:id` 路由到 `handleSub` +- 其他请求交给静态资源服务 + +调用链: + +```text +fetch +├─ OPTIONS -> CORS 响应 +├─ POST /api/generate -> handleGenerate +├─ GET /sub/:id -> handleSub +└─ other -> env.ASSETS.fetch(request) +``` + +### `handleGenerate(request, env, url)` + +作用: + +- 解析前端提交的数据 +- 调用节点解析与优选地址解析函数 +- 生成最终节点集合 +- 执行去重与短链存储 +- 返回所有订阅 URL 与预览信息 + +核心输入: + +- `body.nodeLinks` +- `body.preferredIps` +- `body.namePrefix` +- `body.keepOriginalHost` + +核心输出: + +- `shortId` +- `urls.auto` +- `urls.raw` +- `urls.clash` +- `urls.surge` +- `counts` +- `preview` +- `warnings` + +内部调用链: + +```text +handleGenerate +├─ request.json() +├─ parseRawLinks(body.nodeLinks) +├─ parsePreferredEndpoints(body.preferredIps) +├─ buildNodes(baseNodes, preferredEndpoints, options) +├─ buildDedupHash(body) +├─ env.SUB_STORE.get(dedupKey) +├─ createUniqueShortId(env) # 仅首次写入时 +├─ env.SUB_STORE.put(sub:) +└─ env.SUB_STORE.put(dedup:) +``` + +### `handleSub(url, env)` + +作用: + +- 校验访问令牌 +- 读取 KV 中的订阅记录 +- 根据 `target` 输出相应格式 + +分支逻辑: + +- `target=clash` -> `renderClash(nodes)` +- `target=surge` -> `renderSurge(nodes, ...)` +- 默认 -> `renderRaw(nodes)` + +调用链: + +```text +handleSub +├─ validateAccessToken(url, env) +├─ env.SUB_STORE.get(sub:) +├─ JSON.parse(raw) +└─ render by target +``` + +### `validateAccessToken(url, env)` + +作用: + +- 如果未配置 `SUB_ACCESS_TOKEN`,直接放行 +- 如果已配置,则要求请求带有正确的 `?token=...` + +它是项目订阅链接第二层访问保护的唯一入口。 + +## 2.2 解析与展开 + +### `parseRawLinks(input)` + +作用: + +- 解析多行节点链接 +- 支持 `vmess://`、`vless://`、`trojan://` +- 如果某一行本身是 Base64 订阅文本,则先解码再递归解析 + +特点: + +- 偏容错 +- 出现无法解析的内容时会跳过,而不是直接抛错中断 + +### `parsePreferredEndpoints(input)` + +作用: + +- 解析用户输入的优选地址列表 +- 把每一行转成: + +```js +{ + server: string, + port: number | undefined, + remark: string +} +``` + +支持格式: + +- `104.16.1.2` +- `104.16.1.2:2053` +- `cf.example.com` +- `cf.example.com:443` +- `104.16.1.2#HK` + +### `buildNodes(baseNodes, preferredEndpoints, options)` + +作用: + +- 对基础节点做批量展开 +- 把优选地址替换进 `server` +- 端口优先使用优选地址中显式指定的端口 +- 根据 `keepOriginalHost` 决定是否保留原始 `host` / `sni` +- 重新生成节点名称 + +这是真正完成“优选 IP 替换”的核心函数。 + +### `buildDedupHash(body)` + +作用: + +- 对输入做稳定归一化 +- 通过 SHA-256 生成去重 hash + +归一化字段包括: + +- `nodeLinks` +- `preferredIps` +- `namePrefix` +- `keepOriginalHost` + +只要这些输入完全一致,理论上就会命中相同去重键,从而复用旧短链。 + +## 2.3 导出渲染 + +### `renderRaw(nodes)` + +作用: + +- 把节点重新编码成 URI 列表 +- 用换行拼接 +- 最终整体做 Base64 编码 + +返回结果适用于: + +- V2rayN +- Shadowrocket +- 一些直接支持 Base64 订阅的客户端 + +### `renderClash(nodes)` + +作用: + +- 把节点转成 Clash YAML +- 输出 `proxies`、`proxy-groups`、`rules` + +特点: + +- 自动创建“自动选择”和“节点选择”两个分组 +- 对 WS 节点写入 `ws-opts` +- 对 TLS 节点写入 `servername` + +### `renderSurge(nodes, baseUrl, accessToken)` + +作用: + +- 输出 Surge 订阅文本 +- 只导出 `vmess` 和 `trojan` +- 在注释里写入 token 保护订阅地址 + +局限: + +- 当前不导出 `vless` +- 配置较为简化,偏向轻量可用而非覆盖全部参数 + +## 3. `src/core.js` 关键函数 + +`core.js` 是更纯粹的业务核心层,适合作为后续重构收敛目标。 + +## 3.1 入口级函数 + +### `parseNodeLinks(inputText)` + +作用: + +- 统一解析用户输入的节点文本 +- 先尝试展开原始 Base64 订阅 +- 对每一行节点逐个解析 +- 返回 `{ nodes, warnings, normalizedInput }` + +与 `worker.js` 中 `parseRawLinks()` 的差异: + +- `core.js` 版本会保留 `warnings` +- 会在完全解析失败时抛出明确错误 +- 节点结构更完整 + +### `parsePreferredEndpoints(inputText)` + +作用: + +- 支持换行、逗号、分号混合分隔 +- 自动去重 +- 返回 `{ endpoints, warnings }` + +与 `worker.js` 中同名逻辑相比,它的容错和用户提示更友好。 + +### `expandNodes(baseNodes, endpoints, options)` + +作用: + +- 对基础节点做批量展开 +- 在 `keepOriginalHost` 模式下保留原始 SNI / Host +- 在关闭该选项时尝试把 SNI / Host 替换为目标优选地址 +- 返回 `{ nodes, warnings }` + +这个版本比 `worker.js` 的 `buildNodes()` 更细致,尤其体现在: + +- 保留 `originalServer` +- 明确区分 `hostHeader` 与 `sni` +- 会在 Host/SNI 缺失时给出警告 + +### `renderSubscription(target, nodes, requestUrl)` + +作用: + +- 统一根据 `target` 分发输出 +- 支持 `raw` / `clash` / `surge` / `json` + +相比 `worker.js` 的导出逻辑,它更像一个统一门面函数。 + +## 3.2 协议级函数 + +### 解析函数 + +- `parseVmessUri(node)` +- `parseVlessUri(node)` +- `parseTrojanUri(node)` + +职责: + +- 把 URI 转成标准节点对象 +- 抽取协议字段、TLS 字段、网络字段和附加参数 + +### 渲染函数 + +- `renderVmessUri(node)` +- `renderVlessUri(node)` +- `renderTrojanUri(node)` + +职责: + +- 把标准节点对象重新编码为可导入 URI + +这组函数构成了“解析 -> 标准化 -> 再输出”的闭环。 + +## 3.3 安全相关函数 + +### `ensureSecret(secret)` + +作用: + +- 校验加密密钥是否存在且长度足够 + +### `encryptPayload(payload, secret)` + +作用: + +- 使用 AES-GCM 加密订阅载荷 + +### `decryptPayload(token, secret)` + +作用: + +- 解密并恢复原始订阅载荷 + +说明: + +- 这些函数在当前 `worker.js` 运行链路中没有直接使用。 +- 它们体现的是早期或备用的“令牌承载内容”方案。 +- 当前线上主方案已经改为“KV 短链 + 访问 token”。 + +## 4. 前端关键函数 + +文件:`public/app.js` + +### 提交处理函数 + +位置上表现为对 `form.addEventListener('submit', ...)` 的注册。 + +作用: + +- 从表单读取参数 +- 发起 `/api/generate` +- 接收返回 JSON +- 渲染订阅 URL、节点数量统计与前 20 条节点预览 +- 处理错误提示 + +### 复制逻辑 + +位于 `document.addEventListener('click', ...)` 中的 `data-copy-target` 分支。 + +作用: + +- 使用 `navigator.clipboard.writeText` +- 在失败时降级为 `document.execCommand('copy')` + +### 二维码逻辑 + +位于 `data-qrcode-target` 分支和 `closeQrDialog()` 中。 + +作用: + +- 调用页面中引入的 `QRCode` 库 +- 基于订阅 URL 生成二维码 +- 控制模态框打开和关闭 + +### `escapeHtml(value)` + +作用: + +- 对预览表格中的文本做 HTML 转义 +- 避免直接把 API 数据插入 DOM 时造成 HTML 注入 + +## 5. 关键数据结构 + +## 5.1 Worker 中的节点结构 + +`worker.js` 使用的是简化节点结构,典型字段如下: + +```js +{ + type, + name, + server, + port, + uuid, + password, + cipher, + network, + tls, + host, + path, + sni, + alpn, + fp, + flow +} +``` + +## 5.2 `core.js` 中的节点结构 + +`core.js` 节点模型更完整,除了上述字段外,还经常保留: + +```js +{ + originalServer, + hostHeader, + allowInsecure, + security, + alterId, + headerType, + serviceName, + authority, + endpointLabel, + endpointSource, + params +} +``` + +## 5.3 KV 存储结构 + +### 主记录 + +键: + +```text +sub: +``` + +值: + +```json +{ + "version": 1, + "createdAt": "2026-01-01T00:00:00.000Z", + "options": { + "namePrefix": "CF", + "keepOriginalHost": true + }, + "nodes": [] +} +``` + +### 去重映射 + +键: + +```text +dedup: +``` + +值: + +```text + +``` + +TTL 默认是 7 天。 + +## 6. 最重要的维护结论 + +- 如果你只关心线上行为,先看 `src/worker.js`。 +- 如果你只关心协议解析与导出能力,先看 `src/core.js`。 +- 如果你要做长期维护,应该以 `src/core.js` 为收敛中心,把 `worker.js` 逐步瘦身为“路由层 + 存储层”。 diff --git a/docs/code-wiki/04-dependencies-and-data-flow.md b/docs/code-wiki/04-dependencies-and-data-flow.md new file mode 100644 index 0000000..e191b9e --- /dev/null +++ b/docs/code-wiki/04-dependencies-and-data-flow.md @@ -0,0 +1,327 @@ +# 依赖关系与数据流 + +## 1. 依赖总览 + +项目依赖非常少,但运行时依赖强烈绑定 Cloudflare 平台能力。 + +## 1.1 `package.json` 依赖 + +唯一显式 npm 依赖: + +- `wrangler` + +用途: + +- 本地开发 +- Worker 部署 +- Worker 调试 + +这意味着项目本身几乎不依赖第三方业务库,核心逻辑主要由原生 JavaScript 和 Cloudflare Runtime API 构成。 + +## 1.2 平台依赖 + +### Cloudflare Workers Runtime + +项目直接使用: + +- `Request` +- `Response` +- `URL` +- `crypto.subtle` +- `crypto.getRandomValues` +- `atob` +- `btoa` + +### Workers KV + +绑定名: + +- `SUB_STORE` + +用途: + +- 保存订阅详情 +- 保存输入去重映射 + +### Static Assets Binding + +绑定名: + +- `ASSETS` + +用途: + +- 提供 `public/` 下的静态页面和资源文件 + +### Secret / Variable + +- `SUB_ACCESS_TOKEN` + +用途: + +- 保护 `/sub/:id` 的访问 + +## 1.3 浏览器端依赖 + +前端页面没有使用框架,但依赖: + +- 浏览器原生 DOM API +- `fetch` +- `navigator.clipboard` +- 第三方二维码脚本 `qrcodejs` + +二维码库引入方式是 CDN 脚本: + +```html + +``` + +这意味着前端构建成本低,但页面在二维码功能上依赖外部 CDN 可达性。 + +## 2. 模块依赖图 + +```text +public/index.html + ├─ 引入 public/styles.css + ├─ 引入 qrcodejs CDN + └─ 引入 public/app.js + +public/app.js + └─ 依赖 POST /api/generate 的返回结构 + +src/worker.js + ├─ 依赖 Cloudflare Runtime + ├─ 依赖 env.SUB_STORE + ├─ 依赖 env.ASSETS + └─ 依赖 env.SUB_ACCESS_TOKEN + +tests/smoke.mjs + └─ 依赖 src/core.js +``` + +## 3. 前后端契约 + +## 3.1 请求契约:`POST /api/generate` + +前端发送: + +```json +{ + "nodeLinks": "vmess://...\nvless://...", + "preferredIps": "104.16.1.2#HK\n104.17.2.3:2053#US", + "namePrefix": "CF", + "keepOriginalHost": true +} +``` + +字段语义: + +- `nodeLinks` + - 多行节点链接,支持直接粘贴 Base64 订阅内容。 +- `preferredIps` + - 多行优选地址,格式为 `host[:port][#remark]`。 +- `namePrefix` + - 展开后的节点备注前缀。 +- `keepOriginalHost` + - 是否保留原始 Host / SNI。 + +## 3.2 响应契约 + +Worker 返回: + +```json +{ + "ok": true, + "storage": "kv", + "deduplicated": true, + "shortId": "AbC123xYz9", + "urls": { + "auto": "https://example.workers.dev/sub/AbC123xYz9?token=...", + "raw": "https://example.workers.dev/sub/AbC123xYz9?target=raw&token=...", + "clash": "https://example.workers.dev/sub/AbC123xYz9?target=clash&token=...", + "surge": "https://example.workers.dev/sub/AbC123xYz9?target=surge&token=..." + }, + "counts": { + "inputNodes": 1, + "preferredEndpoints": 2, + "outputNodes": 2 + }, + "preview": [], + "warnings": [] +} +``` + +前端依赖这些字段来更新: + +- 订阅链接输入框 +- 节点数量统计卡片 +- 节点预览表格 +- 警告提示区域 + +## 4. 数据流详解 + +## 4.1 页面初始化 + +数据流: + +```text +Browser -> GET / -> Worker -> ASSETS -> public/index.html +index.html -> 加载 styles.css / qrcodejs / app.js +``` + +前端初始化时不主动请求业务数据,页面是静态渲染。 + +## 4.2 生成订阅的数据流 + +```text +用户输入 + -> app.js 读取表单 + -> fetch('/api/generate') + -> worker.js: handleGenerate + -> parseRawLinks + -> parsePreferredEndpoints + -> buildNodes + -> buildDedupHash + -> SUB_STORE.get(dedup:) + -> 如果不存在则: + -> createUniqueShortId + -> SUB_STORE.put(sub:) + -> SUB_STORE.put(dedup:) + -> 返回 urls / counts / preview / warnings + -> app.js 渲染页面 +``` + +## 4.3 拉取订阅的数据流 + +```text +客户端请求 /sub/:id?target=clash&token=... + -> worker.js: handleSub + -> validateAccessToken + -> SUB_STORE.get(sub:) + -> JSON.parse(record) + -> renderClash / renderSurge / renderRaw + -> 返回文本 +``` + +## 5. 数据持久化模型 + +项目只有 KV 持久化,没有数据库和对象存储。 + +### 5.1 为什么需要两个键 + +#### `sub:` + +用途: + +- 保存完整订阅记录 +- 供 `/sub/:id` 读取 + +#### `dedup:` + +用途: + +- 对同样的输入结果进行去重 +- 避免重复生成多个不同短链 + +### 5.2 TTL 设计 + +TTL 为 7 天,意味着: + +- 主记录 7 天后自动失效 +- 去重键也会同时过期 +- 过期后再次提交相同输入,会重新生成新短链 + +这种设计适合轻量、短周期、低成本场景,但不适合长期稳定订阅链接场景。 + +## 6. 参数在系统中的流转 + +## 6.1 `keepOriginalHost` + +这是整个项目最关键的业务选项之一。 + +流转过程: + +1. 前端读取 checkbox 状态。 +2. 通过 JSON 提交给 `/api/generate`。 +3. Worker 在 `buildNodes()` 中依据它决定: + - 是否保留原 Host / SNI。 + - 是否让优选 IP 只替换 `server`。 + +业务意义: + +- 对 Cloudflare CDN / TLS 场景来说,通常应保留原 Host / SNI。 +- 如果错误关闭,节点可能能连上 IP 但 TLS 握手失败。 + +## 6.2 `namePrefix` + +流转过程: + +1. 前端从输入框读取。 +2. Worker 在展开节点时拼进备注名。 +3. 导出到 Raw / Clash / Surge 时继承到最终节点名。 + +结果: + +- 用户可以识别一组由同一批优选地址生成的节点。 + +## 6.3 `preferredIps` + +流转过程: + +1. 输入文本被解析成端点数组。 +2. 每个端点与每个基础节点做笛卡尔组合。 +3. 优选端点上的端口优先覆盖原节点端口。 + +结果: + +- `N` 个基础节点与 `M` 个优选地址会生成 `N x M` 个输出节点。 + +## 7. `worker.js` 与 `core.js` 的依赖错位 + +这是当前仓库最值得注意的设计现状。 + +## 7.1 现状 + +- `worker.js` 自己维护解析、展开和导出逻辑。 +- `core.js` 也维护相近功能,但更完整。 +- `tests/smoke.mjs` 依赖的是 `core.js`。 + +## 7.2 直接影响 + +- 测试通过,不一定代表线上 Worker 行为完全一致。 +- 新增协议参数时,可能只改了 `core.js`,却遗漏 `worker.js`。 +- 文档维护时需要同时关注两套实现。 + +## 7.3 推荐演进方向 + +理想依赖关系应是: + +```text +worker.js + ├─ 路由 + ├─ 鉴权 + ├─ KV 读写 + └─ 调用 core.js + +core.js + ├─ 解析 + ├─ 展开 + ├─ 渲染 + └─ 安全工具 +``` + +这样可以让测试和线上逻辑使用同一套核心能力。 + +## 8. 外部依赖风险点 + +- Cloudflare KV 未绑定时,生成与读取订阅会失败。 +- `SUB_ACCESS_TOKEN` 设置后,外部客户端必须携带正确 token。 +- 二维码库依赖 CDN,若网络不可达则页面二维码功能失效。 +- `Surge` 导出能力只覆盖部分协议场景,不宜假设与 Clash 导出完全等价。 + +## 9. 依赖关系总结 + +- npm 依赖极少,复杂度主要来自业务逻辑而非包管理。 +- 运行时强依赖 Cloudflare 平台能力。 +- 前端与后端通过固定 JSON 契约耦合。 +- 测试与线上代码当前未完全共享同一实现,是架构上的主要维护风险点。 diff --git a/docs/code-wiki/05-run-test-and-deploy.md b/docs/code-wiki/05-run-test-and-deploy.md new file mode 100644 index 0000000..391b94c --- /dev/null +++ b/docs/code-wiki/05-run-test-and-deploy.md @@ -0,0 +1,267 @@ +# 运行、测试与部署 + +## 1. 环境要求 + +项目运行依赖: + +- Node.js +- npm +- Cloudflare Wrangler +- Cloudflare Workers +- Cloudflare Workers KV + +仓库本身没有复杂的本地构建链,仅使用 `wrangler` 作为开发与部署工具。 + +## 2. 安装依赖 + +在仓库根目录执行: + +```bash +npm install +``` + +安装后会得到 `wrangler`,用于本地调试与部署。 + +## 3. 本地开发 + +启动命令: + +```bash +npm run dev +``` + +对应脚本: + +```json +"dev": "wrangler dev" +``` + +本地开发时建议重点验证: + +- 页面能否正常打开 +- `POST /api/generate` 能否返回结果 +- 结果中的各类订阅链接是否可生成 + +## 4. 配置项说明 + +## 4.1 `wrangler.toml` + +项目关键配置如下: + +```toml +name = "cloudflaresub" +main = "src/worker.js" +compatibility_date = "2026-03-06" +workers_dev = true + +[assets] +directory = "./public" +binding = "ASSETS" +not_found_handling = "single-page-application" +run_worker_first = ["/api/*", "/sub/*"] +``` + +含义: + +- `main` + - Worker 入口文件。 +- `assets.directory` + - 静态页面目录。 +- `binding = "ASSETS"` + - 为 Worker 注入静态资源绑定。 +- `run_worker_first` + - 指定哪些路由优先进入 Worker 逻辑。 + +## 4.2 必需绑定 + +### `SUB_STORE` + +类型: + +- Cloudflare KV Namespace + +作用: + +- 保存订阅记录 +- 保存去重映射 + +如果缺失: + +- `/api/generate` 无法保存结果 +- `/sub/:id` 无法读取记录 + +## 4.3 可选但强烈建议的变量 + +### `SUB_ACCESS_TOKEN` + +类型: + +- Secret 或 Variable + +作用: + +- 为 `/sub/:id` 增加访问校验 + +行为规则: + +- 未设置时:任何人知道短链即可访问 +- 已设置时:请求必须带 `?token=` + +## 4.4 历史兼容项 + +代码注释和 `core.js` 中仍能看到与 `SUB_LINK_SECRET` 相关的痕迹,但当前主运行链路已经切换为: + +- `KV 短链` +- `SUB_ACCESS_TOKEN` 访问保护 + +因此当前实际部署不依赖 `SUB_LINK_SECRET`。 + +## 5. 测试方式 + +运行测试: + +```bash +npm run check +``` + +对应脚本: + +```json +"check": "node tests/smoke.mjs" +``` + +测试内容包括: + +- 节点解析 +- 优选地址解析 +- 节点展开 +- Raw 导出 +- Clash 导出 +- Surge 导出 +- AES-GCM 加解密 + +### 5.1 测试的局限性 + +当前测试不是完整集成测试,因此不会直接验证: + +- Worker 路由 +- KV 写入与读取 +- `SUB_ACCESS_TOKEN` 分支 +- 前端页面交互 + +如果后续改动集中在 `src/worker.js`,仅执行 `npm run check` 还不够,建议补充集成测试或手工验证。 + +## 6. 手工验证清单 + +## 6.1 页面验证 + +打开 Worker 根路径 `/`,确认: + +- 页面可正常加载 +- 图标与样式正常 +- 表单可填写 +- “填入演示数据”按钮有效 + +## 6.2 生成验证 + +填写测试数据后点击“生成订阅”,确认: + +- `auto` / `raw` / `clash` / `surge` 四类链接均生成 +- 节点统计数量正确 +- 节点预览表格有内容 +- 未配置 `SUB_ACCESS_TOKEN` 时会给出警告 + +## 6.3 订阅验证 + +分别请求: + +```bash +curl "https:///sub/?target=raw&token=" +curl "https:///sub/?target=clash&token=" +curl "https:///sub/?target=surge&token=" +``` + +确认: + +- `raw` 返回 Base64 文本 +- `clash` 返回 YAML +- `surge` 返回文本配置 + +## 7. 部署方式 + +## 7.1 命令行部署 + +执行: + +```bash +npm run deploy +``` + +对应脚本: + +```json +"deploy": "wrangler deploy" +``` + +部署前要确保: + +- 已登录 Cloudflare +- `SUB_STORE` 已创建并绑定 +- `SUB_ACCESS_TOKEN` 已按需配置 + +## 7.2 Dashboard 部署 + +README 中推荐使用 Cloudflare Dashboard 绑定 GitHub 仓库部署。 + +建议构建设置: + +- Framework preset:`None` +- Build command:留空 +- Build output directory:留空 + +原因: + +- 这是 Worker 项目,不是传统静态站点打包项目。 +- 入口由 `wrangler.toml` 和 Worker 配置决定。 + +## 8. 运行时常见问题 + +## 8.1 `/api/generate` 返回错误 + +优先检查: + +- 请求体是否是合法 JSON +- `nodeLinks` 是否至少包含一个有效节点 +- `preferredIps` 是否至少包含一个有效优选地址 +- `SUB_STORE` 是否已正确绑定 + +## 8.2 `/sub/:id` 返回 403 + +优先检查: + +- 是否已配置 `SUB_ACCESS_TOKEN` +- 请求 URL 是否带了 `?token=...` +- token 是否与配置值完全一致 + +## 8.3 `/sub/:id` 返回 404 + +可能原因: + +- `id` 不存在 +- 记录已过 TTL 自动过期 +- KV 绑定错环境或错命名空间 + +## 8.4 Surge 导入不符合预期 + +优先检查: + +- 当前节点是否为 `vmess` 或 `trojan` +- 是否误以为 `vless` 也会完整导出到 Surge +- 是否依赖了未覆盖的高级协议参数 + +## 9. 维护与演进建议 + +- 若计划增强线上稳定性,优先补充 `worker.js` 的集成测试。 +- 若计划长期演进协议能力,优先让 `worker.js` 复用 `core.js`。 +- 若计划支持长期固定订阅链接,可考虑把 TTL 设计改为可配置。 +- 若计划增强安全性,可重新评估 `core.js` 中的加密载荷方案是否需要恢复使用。 diff --git a/docs/code-wiki/README.md b/docs/code-wiki/README.md new file mode 100644 index 0000000..58e1660 --- /dev/null +++ b/docs/code-wiki/README.md @@ -0,0 +1,43 @@ +# Code Wiki + +本目录是 `CloudflareSub` 项目的结构化代码文档,面向首次接手仓库的开发者、维护者与二次开发者。 + +## 适用范围 + +- 项目整体架构与请求流转 +- 主要模块职责与边界 +- 关键函数说明 +- 依赖关系与数据模型 +- 本地运行、测试与部署方式 + +## 阅读顺序 + +1. [项目总览](./01-overview-and-architecture.md) +2. [模块与职责](./02-modules-and-responsibilities.md) +3. [关键函数与调用链](./03-key-functions-and-call-chains.md) +4. [依赖关系与数据流](./04-dependencies-and-data-flow.md) +5. [运行、测试与部署](./05-run-test-and-deploy.md) + +## 仓库一句话说明 + +这是一个运行在 Cloudflare Workers 上的“优选 IP 订阅生成器”: + +1. 前端收集原始节点与优选 IP / 域名。 +2. Worker 解析节点并按优选地址批量展开。 +3. 结果写入 Workers KV,并生成 `/sub/:id` 形式的短链订阅。 +4. 客户端按 `raw`、`clash`、`surge` 等格式拉取订阅内容。 + +## 关键入口 + +- Worker 入口:`src/worker.js` +- 核心库:`src/core.js` +- 前端页面:`public/index.html` +- 前端交互:`public/app.js` +- Worker 配置:`wrangler.toml` +- 冒烟测试:`tests/smoke.mjs` + +## 维护提示 + +- 当前“运行时代码”与“核心库代码”是两套相近实现:`src/worker.js` 未直接复用 `src/core.js`。 +- 测试主要覆盖 `src/core.js`,并不直接覆盖 `src/worker.js` 的 HTTP/KV 路由层。 +- 后续重构时,优先关注这两处实现的一致性,避免解析与导出行为漂移。 diff --git a/public/app.js b/public/app.js index 1532c4f..d9da6bf 100644 --- a/public/app.js +++ b/public/app.js @@ -57,7 +57,16 @@ form.addEventListener('submit', async (event) => { body: JSON.stringify(payload), }); - const data = await response.json(); + const contentType = response.headers.get('content-type') || ''; + let data; + + if (contentType.includes('application/json')) { + data = await response.json(); + } else { + const rawText = await response.text(); + throw new Error(rawText || '服务端返回了非 JSON 响应'); + } + if (!response.ok || !data.ok) { throw new Error(data.error || '生成失败'); } diff --git a/public/index.html b/public/index.html index 1566831..123741f 100644 --- a/public/index.html +++ b/public/index.html @@ -46,7 +46,7 @@

- +

- 支持 IPIP:端口域名域名:端口# 后面可写备注。 + 支持 IPIP:端口域名域名:端口# 后面可写备注;留空时将直接使用原始节点。

@@ -112,7 +111,7 @@

订阅结果

- 还没有生成订阅。请先填写节点链接和优选 IP,然后点击“生成订阅”。 + 还没有生成订阅。请先填写节点链接,再按需填写优选 IP,然后点击“生成订阅”。
@@ -268,4 +267,4 @@

订阅二维码

- \ No newline at end of file + diff --git a/src/core.js b/src/core.js index 53f5d06..efc2e24 100644 --- a/src/core.js +++ b/src/core.js @@ -259,17 +259,18 @@ export function renderClashSubscription(nodes) { }); lines.push('proxy-groups:'); - lines.push(' - name: "🚀 节点选择"'); + lines.push(' - name: "Proxy"'); lines.push(' type: select'); - lines.push(` proxies: ["♻️ 自动选择", ${proxyNames.map(yamlQuote).join(', ')}]`); - lines.push(' - name: "♻️ 自动选择"'); + lines.push(` proxies: ["Auto", ${proxyNames.map(yamlQuote).join(', ')}, "DIRECT"]`); + lines.push(' - name: "Auto"'); lines.push(' type: url-test'); lines.push(` url: ${yamlQuote(DEFAULT_TEST_URL)}`); lines.push(' interval: 300'); lines.push(' tolerance: 50'); lines.push(` proxies: [${proxyNames.map(yamlQuote).join(', ')}]`); lines.push('rules:'); - lines.push(' - MATCH,🚀 节点选择'); + lines.push(' - GEOIP,CN,DIRECT'); + lines.push(' - MATCH,Proxy'); return lines.join('\n') + '\n'; } diff --git a/src/worker.js b/src/worker.js index 302c515..ec54588 100644 --- a/src/worker.js +++ b/src/worker.js @@ -27,6 +27,22 @@ function text(body, status = 200, contentType = 'text/plain; charset=utf-8') { }); } +function ensureBindings(env) { + if (!env?.SUB_STORE || typeof env.SUB_STORE.get !== 'function' || typeof env.SUB_STORE.put !== 'function') { + throw new Error('Cloudflare KV 绑定 SUB_STORE 未配置,请在 Worker Settings -> Bindings 中绑定 KV namespace。'); + } + if (!env?.ASSETS || typeof env.ASSETS.fetch !== 'function') { + throw new Error('静态资源绑定 ASSETS 未配置,请确认 wrangler.toml 的 assets.binding 为 ASSETS,且 Cloudflare 已启用静态资源。'); + } +} + +function toErrorMessage(error, fallback = '服务器内部错误') { + if (error instanceof Error && error.message) { + return error.message; + } + return fallback; +} + function b64EncodeUtf8(str) { return btoa(unescape(encodeURIComponent(str))); } @@ -319,7 +335,7 @@ function renderClash(nodes) { ); const allGroupMembers = [ - ` - "自动选择"`, + ` - "Auto"`, ...proxyNames, ` - DIRECT`, ]; @@ -337,7 +353,7 @@ function renderClash(nodes) { ...(proxies.length ? proxies : []), ``, `proxy-groups:`, - ` - name: "自动选择"`, + ` - name: "Auto"`, ` type: url-test`, ` url: "http://www.gstatic.com/generate_204"`, ` interval: 300`, @@ -345,13 +361,14 @@ function renderClash(nodes) { ` proxies:`, ...autoGroupMembers, ``, - ` - name: "节点选择"`, + ` - name: "Proxy"`, ` type: select`, ` proxies:`, ...allGroupMembers, ``, `rules:`, - ` - MATCH,节点选择`, + ` - "GEOIP,CN,DIRECT"`, + ` - "MATCH,Proxy"`, ].join('\n'); } @@ -434,6 +451,8 @@ async function buildDedupHash(body) { } async function handleGenerate(request, env, url) { + ensureBindings(env); + let body; try { body = await request.json(); @@ -445,14 +464,13 @@ async function handleGenerate(request, env, url) { const preferredEndpoints = parsePreferredEndpoints(body.preferredIps || ''); if (!baseNodes.length) return json({ ok: false, error: '没有识别到可用节点' }, 400); - if (!preferredEndpoints.length) return json({ ok: false, error: '没有识别到可用优选地址' }, 400); const options = { namePrefix: body.namePrefix || '', keepOriginalHost: body.keepOriginalHost !== false, }; - const nodes = buildNodes(baseNodes, preferredEndpoints, options); + const nodes = preferredEndpoints.length ? buildNodes(baseNodes, preferredEndpoints, options) : baseNodes; const payload = { version: 1, @@ -512,7 +530,12 @@ async function handleGenerate(request, env, url) { host: node.host || '', sni: node.sni || '', })), - warnings: accessToken ? [] : ['未检测到 SUB_ACCESS_TOKEN,订阅链接将没有第二层访问保护。'], + warnings: [ + ...(preferredEndpoints.length + ? [] + : ['未填写优选 IP / 域名,已直接使用原始节点生成订阅。']), + ...(accessToken ? [] : ['未检测到 SUB_ACCESS_TOKEN,订阅链接将没有第二层访问保护。']), + ], }); } @@ -527,6 +550,8 @@ function validateAccessToken(url, env) { } async function handleSub(url, env) { + ensureBindings(env); + const tokenCheck = validateAccessToken(url, env); if (!tokenCheck.ok) return tokenCheck.response; @@ -557,24 +582,44 @@ export default { async fetch(request, env) { const url = new URL(request.url); - if (request.method === 'OPTIONS') { - return new Response(null, { - headers: { - 'access-control-allow-origin': '*', - 'access-control-allow-methods': 'GET,POST,OPTIONS', - 'access-control-allow-headers': 'content-type', - }, + try { + if (request.method === 'OPTIONS') { + return new Response(null, { + headers: { + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET,POST,OPTIONS', + 'access-control-allow-headers': 'content-type', + }, + }); + } + + if (request.method === 'POST' && url.pathname === '/api/generate') { + return await handleGenerate(request, env, url); + } + + if (request.method === 'GET' && url.pathname.startsWith('/sub/')) { + return await handleSub(url, env); + } + + ensureBindings(env); + return await env.ASSETS.fetch(request); + } catch (error) { + const message = toErrorMessage(error); + console.error('worker fetch error', { + path: url.pathname, + method: request.method, + message, }); - } - if (request.method === 'POST' && url.pathname === '/api/generate') { - return handleGenerate(request, env, url); - } + if (url.pathname.startsWith('/api/')) { + return json({ ok: false, error: message }, 500); + } - if (request.method === 'GET' && url.pathname.startsWith('/sub/')) { - return handleSub(url, env); - } + if (url.pathname.startsWith('/sub/')) { + return text(`Internal Error: ${message}`, 500); + } - return env.ASSETS.fetch(request); + return text(`Internal Error: ${message}`, 500); + } }, -}; \ No newline at end of file +}; diff --git a/tests/smoke.mjs b/tests/smoke.mjs index f6e1fcd..702cdd4 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -26,12 +26,16 @@ assert.equal(expanded.nodes[0].server, '104.16.1.2'); assert.equal(expanded.nodes[0].hostHeader, 'edge.example.com'); assert.equal(expanded.nodes[1].port, 2053); +const rawWithoutPreferredEndpoints = renderRawSubscription(nodes); +assert.ok(rawWithoutPreferredEndpoints.length > 10); + const raw = renderRawSubscription(expanded.nodes); assert.ok(raw.length > 10); const clash = renderClashSubscription(expanded.nodes); assert.match(clash, /proxies:/); assert.match(clash, /edge\.example\.com/); +assert.match(clash, /MATCH,Proxy/); const surge = renderSurgeSubscription(expanded.nodes, 'https://sub.example.com/sub/demo?target=surge'); assert.match(surge, /\[Proxy]/);