@1e0zj/dsh-plugin-mall 0.4.3 → 0.4.4
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/github.js +101 -2
- package/src/index.js +71 -9
package/package.json
CHANGED
package/src/github.js
CHANGED
|
@@ -64,7 +64,29 @@ async function requestJson(path, { apiBase, token, signal }) {
|
|
|
64
64
|
}
|
|
65
65
|
const remaining = response.headers.get("x-ratelimit-remaining");
|
|
66
66
|
const resetAt = response.headers.get("x-ratelimit-reset");
|
|
67
|
-
|
|
67
|
+
// 读体阶段的三种失败必须分开,不能一把 catch 成 undefined:
|
|
68
|
+
// 调用方取消(响应头已到、body 读到一半浏览器断开)→ AbortError 上抛;
|
|
69
|
+
// 内部超时打断读体 → 与请求阶段超时同待遇,退避后重试;
|
|
70
|
+
// 响应体根本不是 JSON(镜像的 HTML 错误页等)→ 维持 undefined,交给
|
|
71
|
+
// 下面的状态码分支。
|
|
72
|
+
// 此前这里是无差别 .catch(() => undefined):mid-body 取消被吞掉,200 响应
|
|
73
|
+
// 返回 undefined,search 拿到的是 TypeError 而不是取消;4xx 路径则把一次
|
|
74
|
+
// 取消谎报成 not found。
|
|
75
|
+
let body;
|
|
76
|
+
try {
|
|
77
|
+
body = await response.json();
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error?.name === "AbortError" && signal?.aborted) throw error;
|
|
80
|
+
// 读体超时只在成功响应上重试。4xx 的结论在读体之前就已确定
|
|
81
|
+
// (见上方契约:4xx 判定式失败,从不重试)——404 有 404 的报法、
|
|
82
|
+
// 403 限流有专用诊断,读不出 body 不改变任何一件事;重试三次
|
|
83
|
+
// 只会把正确答案换成一句超时。
|
|
84
|
+
if (error?.name === "TimeoutError" && response.ok) {
|
|
85
|
+
lastError = new Error(`GitHub API response body timed out after ${REQUEST_TIMEOUT / 1000}s (attempt ${attempt + 1})`);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
body = undefined;
|
|
89
|
+
}
|
|
68
90
|
if (response.status === 403 && remaining === "0" && resetAt !== null) {
|
|
69
91
|
const reset = new Date(Number(resetAt) * 1000).toISOString();
|
|
70
92
|
throw new Error(`GitHub API rate limit exceeded; resets at ${reset} (UTC). Set GITHUB_TOKEN or DSH_MARKET_GITHUB_TOKEN for a higher limit.`);
|
|
@@ -618,6 +640,9 @@ export async function repoInfo({ repo, apiBase, token, signal }) {
|
|
|
618
640
|
try {
|
|
619
641
|
meta = await requestJson(`/repos/${trimmed}`, { apiBase, token, signal });
|
|
620
642
|
} catch (error) {
|
|
643
|
+
// 取消不是「仓库不存在」:包装会把这个信号翻译成业务错误,调用方据此
|
|
644
|
+
// 报告一个不存在的网络故障。取消原样上抛。
|
|
645
|
+
if (error?.name === "AbortError") throw error;
|
|
621
646
|
throw new Error(`market_info: repository ${trimmed} not found on GitHub (${error.message})`);
|
|
622
647
|
}
|
|
623
648
|
let packageJson;
|
|
@@ -626,7 +651,8 @@ export async function repoInfo({ repo, apiBase, token, signal }) {
|
|
|
626
651
|
if (typeof contents.content === "string") {
|
|
627
652
|
packageJson = JSON.parse(Buffer.from(contents.content, "base64").toString("utf8"));
|
|
628
653
|
}
|
|
629
|
-
} catch {
|
|
654
|
+
} catch (error) {
|
|
655
|
+
if (error?.name === "AbortError") throw error; // 取消不是「没有 package.json」
|
|
630
656
|
packageJson = undefined; // no package.json at the repo root
|
|
631
657
|
}
|
|
632
658
|
return {
|
|
@@ -728,6 +754,79 @@ if (process.argv[1]?.endsWith("github.js") && process.argv.includes("--self-test
|
|
|
728
754
|
}
|
|
729
755
|
if (clampFailed > 0) process.exit(1);
|
|
730
756
|
}
|
|
757
|
+
// requestJson 读体阶段的三种失败。打桩 fetch——离线、确定性;abort 在
|
|
758
|
+
// json() 内部发生(不是调用前预 abort),逼近「响应头已到、body 读一半
|
|
759
|
+
// 浏览器断开」的真实时序。
|
|
760
|
+
{
|
|
761
|
+
const realFetch = globalThis.fetch;
|
|
762
|
+
const makeError = (name) => { const error = new Error("synthetic"); error.name = name; return error; };
|
|
763
|
+
const fakeResponse = ({ status, headers, json }) => ({
|
|
764
|
+
ok: status < 400,
|
|
765
|
+
status,
|
|
766
|
+
statusText: status === 404 ? "Not Found" : status === 403 ? "Forbidden" : "OK",
|
|
767
|
+
headers: headers ?? new Headers(),
|
|
768
|
+
json,
|
|
769
|
+
});
|
|
770
|
+
let failed = 0;
|
|
771
|
+
try {
|
|
772
|
+
// 1) mid-body 取消:json() 里才 abort。修复前 .catch(()=>undefined)
|
|
773
|
+
// 把它吞掉——200 变 undefined(search 得 TypeError)、4xx 谎报 not found。
|
|
774
|
+
{
|
|
775
|
+
const controller = new AbortController();
|
|
776
|
+
let threw;
|
|
777
|
+
globalThis.fetch = async () => fakeResponse({
|
|
778
|
+
status: 200,
|
|
779
|
+
json: async () => { controller.abort(); throw makeError("AbortError"); },
|
|
780
|
+
});
|
|
781
|
+
try {
|
|
782
|
+
await requestJson("/repos/owner/repo", { signal: controller.signal });
|
|
783
|
+
} catch (error) { threw = error; }
|
|
784
|
+
if (threw?.name === "AbortError") console.log(" PASS 读体期间取消 → AbortError 上抛(不吞成 undefined)");
|
|
785
|
+
else { failed++; console.log(` FAIL 读体期间取消应抛 AbortError,实得 ${threw ? `${threw.name}: ${threw.message}` : "未抛(body 变 undefined)"}`); }
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// 2) 成功响应的读体超时 → 记一次失败,退避重试(重试轮换成成功)。
|
|
789
|
+
{
|
|
790
|
+
let attempts = 0;
|
|
791
|
+
globalThis.fetch = async () => fakeResponse({
|
|
792
|
+
status: 200,
|
|
793
|
+
json: async () => { attempts++; if (attempts === 1) throw makeError("TimeoutError"); return { ok: true }; },
|
|
794
|
+
});
|
|
795
|
+
const retried = await requestJson("/repos/owner/repo", {});
|
|
796
|
+
if (retried?.ok === true && attempts === 2) console.log(" PASS 读体超时(200)→ 记一次失败并重试成功");
|
|
797
|
+
else { failed++; console.log(` FAIL 读体超时(200)应重试一次,实得 attempts=${attempts} result=${JSON.stringify(retried)}`); }
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// 3) 4xx 的结论在读体之前就已确定,读体超时绝不重试——重试只会把
|
|
801
|
+
// 正确的 404 换成一句超时。一次调用、报 404 的报法。
|
|
802
|
+
{
|
|
803
|
+
let calls = 0;
|
|
804
|
+
globalThis.fetch = async () => { calls++; return fakeResponse({ status: 404, json: async () => { throw makeError("TimeoutError"); } }); };
|
|
805
|
+
let threw;
|
|
806
|
+
try {
|
|
807
|
+
await requestJson("/repos/owner/absent", {});
|
|
808
|
+
} catch (error) { threw = error; }
|
|
809
|
+
if (calls === 1 && /GitHub API 404/.test(threw?.message ?? "")) console.log(" PASS 读体超时(404)→ 不重试,保留 404 结论");
|
|
810
|
+
else { failed++; console.log(` FAIL 404+读体超时应一次调用报 404,实得 calls=${calls} error=${threw?.message}`); }
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// 4) 403 限流同理:专用诊断靠响应头(不依赖 body),必须一次到位。
|
|
814
|
+
{
|
|
815
|
+
let calls = 0;
|
|
816
|
+
const limited = new Headers({ "x-ratelimit-remaining": "0", "x-ratelimit-reset": "4102444800" });
|
|
817
|
+
globalThis.fetch = async () => { calls++; return fakeResponse({ status: 403, headers: limited, json: async () => { throw makeError("TimeoutError"); } }); };
|
|
818
|
+
let threw;
|
|
819
|
+
try {
|
|
820
|
+
await requestJson("/search/repositories", {});
|
|
821
|
+
} catch (error) { threw = error; }
|
|
822
|
+
if (calls === 1 && /rate limit exceeded/.test(threw?.message ?? "")) console.log(" PASS 读体超时(403 限流)→ 不重试,保留限流诊断");
|
|
823
|
+
else { failed++; console.log(` FAIL 403+读体超时应一次调用给限流诊断,实得 calls=${calls} error=${threw?.message}`); }
|
|
824
|
+
}
|
|
825
|
+
} finally {
|
|
826
|
+
globalThis.fetch = realFetch;
|
|
827
|
+
}
|
|
828
|
+
if (failed > 0) process.exit(1);
|
|
829
|
+
}
|
|
731
830
|
if (process.argv.includes("--offline")) process.exit(0);
|
|
732
831
|
const apiBase = "https://api.github.com";
|
|
733
832
|
const result = await searchPlugins({ query: "", perPage: 3, apiBase });
|
package/src/index.js
CHANGED
|
@@ -1771,7 +1771,19 @@ function rpcFail(error) {
|
|
|
1771
1771
|
/**
|
|
1772
1772
|
* Dispatch one /market RPC endpoint.
|
|
1773
1773
|
*/
|
|
1774
|
-
|
|
1774
|
+
/**
|
|
1775
|
+
* `signal` is the RPC carrier's AbortSignal: the Host aborts it when the
|
|
1776
|
+
* browser side drops the connection mid-request (fetch aborted, tab closed,
|
|
1777
|
+
* network gone) — see docs/dsh-notes.md「issue #7」. Per the official posture
|
|
1778
|
+
* (tools.zh.md: "Async work must observe or forward exec.signal"), every
|
|
1779
|
+
* branch that awaits NETWORK work inside the request lifetime forwards it, so
|
|
1780
|
+
* a closed page stops the Host-side fetches instead of running them to the
|
|
1781
|
+
* end for a response nobody will read. Branches that only start a job and
|
|
1782
|
+
* return its id deliberately do NOT wire it: the job must outlive the request
|
|
1783
|
+
* (same reason the agent producer does not consume exec.signal), its kill
|
|
1784
|
+
* path is the job panel's cancel.
|
|
1785
|
+
*/
|
|
1786
|
+
async function rpcDispatch(ctx, endpoint, payload, config, token, tracker, signal) {
|
|
1775
1787
|
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, npmRegistry = "", rawSources = [] } = config;
|
|
1776
1788
|
switch (endpoint) {
|
|
1777
1789
|
case "search": {
|
|
@@ -1784,11 +1796,12 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1784
1796
|
minStars: payload?.minStars,
|
|
1785
1797
|
apiBase,
|
|
1786
1798
|
token,
|
|
1799
|
+
signal,
|
|
1787
1800
|
});
|
|
1788
1801
|
return rpcOk(result);
|
|
1789
1802
|
}
|
|
1790
1803
|
case "verify": {
|
|
1791
|
-
const result = await verifyPlugins({ repos: payload?.repos, sources: rawSources });
|
|
1804
|
+
const result = await verifyPlugins({ repos: payload?.repos, sources: rawSources, signal });
|
|
1792
1805
|
return rpcOk(result);
|
|
1793
1806
|
}
|
|
1794
1807
|
case "compat": {
|
|
@@ -1805,7 +1818,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1805
1818
|
const repos = [...new Set((Array.isArray(payload?.repos) ? payload.repos : []).map(String)
|
|
1806
1819
|
.filter((repo) => /^[^@/\s][^/\s]*\/[^/\s]+$/.test(repo) && !repo.includes("..")))].slice(0, 30);
|
|
1807
1820
|
if (repos.length === 0) return rpcOk({ results: {} });
|
|
1808
|
-
await verifyPlugins({ repos, sources: rawSources }); // populates the manifest cache
|
|
1821
|
+
await verifyPlugins({ repos, sources: rawSources, signal }); // populates the manifest cache
|
|
1809
1822
|
let fingerprint;
|
|
1810
1823
|
const results = {};
|
|
1811
1824
|
await mapLimit(repos, NETWORK_CONCURRENCY, async (repo) => {
|
|
@@ -1817,7 +1830,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1817
1830
|
try {
|
|
1818
1831
|
let patchText;
|
|
1819
1832
|
if (typeof manifest.dsh?.bundle?.patch === "string") {
|
|
1820
|
-
patchText = await fetchRawFile(repo, manifest.dsh.bundle.patch, { sources: rawSources });
|
|
1833
|
+
patchText = await fetchRawFile(repo, manifest.dsh.bundle.patch, { sources: rawSources, signal });
|
|
1821
1834
|
}
|
|
1822
1835
|
fingerprint ??= computeProfileFingerprint(profileDir);
|
|
1823
1836
|
const cacheKey = `${fingerprint}::${repo}`;
|
|
@@ -1833,7 +1846,10 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1833
1846
|
};
|
|
1834
1847
|
compatCacheSet(cacheKey, entry);
|
|
1835
1848
|
results[repo] = entry;
|
|
1836
|
-
} catch {
|
|
1849
|
+
} catch (error) {
|
|
1850
|
+
// 取消必须先于兜底放行:客户端已经断开,把 AbortError 吞成
|
|
1851
|
+
// "unknown" 会让剩下的仓库继续被逐个扫完——为一份没人读的响应。
|
|
1852
|
+
if (isAbortError(error)) throw error;
|
|
1837
1853
|
results[repo] = { state: "unknown", summary: "兼容性检查失败" };
|
|
1838
1854
|
}
|
|
1839
1855
|
});
|
|
@@ -1852,7 +1868,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1852
1868
|
const results = {};
|
|
1853
1869
|
await mapLimit(deps, NETWORK_CONCURRENCY, async (dep) => {
|
|
1854
1870
|
if (dep.kind === "missing") { results[dep.name] = { latest: null }; return; }
|
|
1855
|
-
const info = await npmPackageInfo(dep.name, { registry });
|
|
1871
|
+
const info = await npmPackageInfo(dep.name, { registry, signal });
|
|
1856
1872
|
results[dep.name] = info === null
|
|
1857
1873
|
? { latest: null }
|
|
1858
1874
|
: { latest: info.latest, hasUpdate: compareVersions(info.latest, dep.version) > 0 };
|
|
@@ -1860,7 +1876,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1860
1876
|
return rpcOk(results);
|
|
1861
1877
|
}
|
|
1862
1878
|
case "info": {
|
|
1863
|
-
const result = await repoInfo({ repo: payload?.repo, apiBase, token });
|
|
1879
|
+
const result = await repoInfo({ repo: payload?.repo, apiBase, token, signal });
|
|
1864
1880
|
return rpcOk(result);
|
|
1865
1881
|
}
|
|
1866
1882
|
case "installed": {
|
|
@@ -1947,7 +1963,10 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1947
1963
|
}
|
|
1948
1964
|
try {
|
|
1949
1965
|
const registry = await registryFor(profile, npmRegistry);
|
|
1950
|
-
|
|
1966
|
+
// registryFor 刻意不接 signal(缓存的是 promise,一次取消会污染整条
|
|
1967
|
+
// 缓存——见 createInstallJobProducer 的注释);它之后的联网步骤接。
|
|
1968
|
+
const resolved = await preferNpmSpec({ spec, registry, sources: rawSources, signal });
|
|
1969
|
+
signal?.throwIfAborted(); // 断连发生在解析完成与建 job 之间:一个 job 都不要建
|
|
1951
1970
|
const jobId = tracker.startCustom({
|
|
1952
1971
|
kind: "dsh-plugin-preflight",
|
|
1953
1972
|
label: `preflight ${resolved}`,
|
|
@@ -1983,6 +2002,9 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
1983
2002
|
});
|
|
1984
2003
|
return rpcOk({ jobId, profile, spec: resolved });
|
|
1985
2004
|
} catch (error) {
|
|
2005
|
+
// 取消不是业务失败:客户端已断开,rpcFail 的响应没人读,还会在
|
|
2006
|
+
// 存活的重连页面上被当成市场故障渲染出来。
|
|
2007
|
+
if (isAbortError(error)) throw error;
|
|
1986
2008
|
return rpcFail(error);
|
|
1987
2009
|
}
|
|
1988
2010
|
}
|
|
@@ -2232,9 +2254,16 @@ function registerRpcChannel(ctx, config, token) {
|
|
|
2232
2254
|
const tracker = createJobTracker();
|
|
2233
2255
|
ctx.inject(["connection"], (connectionCtx) => {
|
|
2234
2256
|
connectionCtx.connection.rpc.handle("/market", async (endpoint, payload, signal) => {
|
|
2257
|
+
// signal 是 carrier 的取消信号:浏览器断开(关页/断网/主动 abort)时
|
|
2258
|
+
// Host 侧 abort(见 docs/dsh-notes.md「issue #7」)。此前在这里被扔掉,
|
|
2259
|
+
// 查询类 RPC 在页面关掉后照跑到底。
|
|
2235
2260
|
try {
|
|
2236
|
-
return await rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker);
|
|
2261
|
+
return await rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker, signal);
|
|
2237
2262
|
} catch (error) {
|
|
2263
|
+
// 取消归类为「输给取消」而非业务错误:不打错误日志(每次关页都会
|
|
2264
|
+
// 触发一次,纯噪音),也不转 rpcFail——响应早已无人读,转了只会在
|
|
2265
|
+
// 存活的重连页面上被渲染成市场故障。
|
|
2266
|
+
if (isAbortError(error)) throw error;
|
|
2238
2267
|
console.error(`[dsh-plugin-mall] /market/${String(endpoint)} failed:`, error);
|
|
2239
2268
|
return rpcFail(error);
|
|
2240
2269
|
}
|
|
@@ -3714,6 +3743,39 @@ export async function runSelfTests() {
|
|
|
3714
3743
|
`extras=${JSON.stringify(integrationDelta.snapshot?.extras)?.slice(0, 120)}`);
|
|
3715
3744
|
}
|
|
3716
3745
|
|
|
3746
|
+
// ── 12d. 查询类 RPC 响应 carrier 取消(issue #7)────────────────────────
|
|
3747
|
+
//
|
|
3748
|
+
// carrier signal 在 Host 收到浏览器断连时 abort。用预 abort 的 signal 验
|
|
3749
|
+
// 接线:fetch 在发起任何网络请求前立刻拒绝。若某个分支没接(收了没传),
|
|
3750
|
+
// 离线环境里它会真去连网——要么慢超时要么返回正常结果,断言随之下沉。
|
|
3751
|
+
{
|
|
3752
|
+
const cfg = { apiBase: "https://api.github.com", npmRegistry: "https://registry.npmjs.org", rawSources: [] };
|
|
3753
|
+
const assertAborts = async (label, endpoint, rpcPayload) => {
|
|
3754
|
+
try {
|
|
3755
|
+
const value = await rpcDispatch(null, endpoint, rpcPayload, cfg, undefined, createJobTracker(), AbortSignal.abort());
|
|
3756
|
+
check(label, false, `返回了 ${JSON.stringify(value).slice(0, 60)}——signal 没接进 ${endpoint}`);
|
|
3757
|
+
} catch (error) {
|
|
3758
|
+
check(label, isAbortError(error), `抛了 ${error?.name}: ${String(error?.message).slice(0, 60)}`);
|
|
3759
|
+
}
|
|
3760
|
+
};
|
|
3761
|
+
await assertAborts("search 预取消 → AbortError(不发任何请求)", "search", { query: "x" });
|
|
3762
|
+
await assertAborts("verify 预取消 → AbortError", "verify", { repos: ["owner/repo"] });
|
|
3763
|
+
await assertAborts("info 预取消 → AbortError(不被包装成 not found)", "info", { repo: "owner/repo" });
|
|
3764
|
+
|
|
3765
|
+
// preflight:断连发生在解析与建 job 之间——一个 job 都不许建,否则
|
|
3766
|
+
// 面板上会冒出一个注定无人认领的孤儿任务。
|
|
3767
|
+
const abortTracker = createJobTracker();
|
|
3768
|
+
const abortSession = `sess_${"a".repeat(32)}`; // 合法 nonce 形状
|
|
3769
|
+
try {
|
|
3770
|
+
const preflightValue = await rpcDispatch(null, "preflight", { session: abortSession, spec: "github:owner/repo" }, cfg, undefined, abortTracker, AbortSignal.abort());
|
|
3771
|
+
check("preflight 预取消 → AbortError 且不建 job", false, `没有抛,返回 ${JSON.stringify(preflightValue).slice(0, 200)}`);
|
|
3772
|
+
} catch (error) {
|
|
3773
|
+
check("preflight 预取消 → AbortError 且不建 job",
|
|
3774
|
+
isAbortError(error) && abortTracker.list(abortSession).length === 0,
|
|
3775
|
+
`${error?.name} jobs=${abortTracker.list(abortSession).length}`);
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3717
3779
|
// ── 13. market_install:整条链跑在 job 里(issue #8)────────────────────
|
|
3718
3780
|
// 原来 registry 查询 → 防抢注解析 → 隔离预检全在 ctx.jobs.start() 之前 await,
|
|
3719
3781
|
// 于是几十秒里没有 job id、没有日志、job_kill 够不着,而工具描述写的是
|