dsh-m 0.9.76 → 0.9.78

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/CHANGELOG.md CHANGED
@@ -8,6 +8,12 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
8
8
 
9
9
  ## 中文
10
10
 
11
+ ### 0.9.78 性能:收藏 stale 检查零网络 + 市场页零增量提交守卫(钢人 C3)
12
+
13
+ - **问题(钢人报告 C3 节)**:收藏 stale 判定只需要「id 还在不在目录」的成员布尔,却穿透富 market interface 的探测段——每次收藏区访问/切换(800ms debounce 合并后)仍是 N 条收藏 × full 查询,含每命中条 npm/github latest **网络扇出**且产物全部丢弃;叠加 toggle 后 refreshViews 四连响应无条件 `setData`——数据层精心维持的引用稳定性一次 setState 即蒸发。
14
+ - **修复**:`lookupLive` 补 `probeMode:"cache-only"`(ADR-0013 第一段模式,零网络零探测;existence = items 成员判定,语义零变化);`fetchPage` 增设**零增量提交守卫**——新增 `marketResponseEquivalent` 纯函数(JSON 值语义递归比较,ZoneChips setGeom 同款纪律),内容等价则跳过 `setData`(dataRef/loading 语义不动)。报告可选项「客户端 stale TTL 缓存」明确不做:C1 物化后 cache-only 查询已是内存级。
15
+ - **验证**:1389 项测试全绿(执行基线 1382 + 新增 7)、typecheck 零错误;真机差分冒烟 5 组用例(npm 主清单/自身/社区条目/不存在 id):cache-only 与 full 的 id 集合与 total 逐项一致(红点语义不变的充分条件),cache-only ~30ms vs full 冷跑 ~250ms(零探测指纹);实施计划经独立评审两轮闭环(F1-1 计划基线勘误)。
16
+
11
17
  ### 0.9.76 修复:三项展示层退役与吸顶跳动根治——「对话」徽章/「版本无关」徽章退役 + 分类行滚动锚定自激断路
12
18
 
13
19
  - **① 「对话」来源徽章退役(主人实拍:界面全点按钮却全员误标「对话」)**:`meta.session` 是 0.7.0 终审·新伤1 的**泵所有权标记**(restore 跳过 / replaceAll 不回卷 / persist 时剥离),GUI 的 `runOp` 与对话区镜像两路无条件同打同一标——它区分不了来源,且重载后徽章整体蒸发。修复:opRowVm 删 session 派生、行渲染删徽章、CSS 与 i18n 键退役;`meta.session` 本职不动(数据模型零变更),来源区分留待未来 `meta.origin` 承载。
@@ -559,6 +565,12 @@ The full release history of dsh-m, maintained bilingually: **Chinese first, Engl
559
565
 
560
566
  ## English
561
567
 
568
+ ### Performance in 0.9.78 — favorites stale checks go cache-only: zero-network favorites access + zero-increment commit guard (steelman C3)
569
+
570
+ - **Problem (steelman report, card C3)**: the favorites stale verdict only needs a "is this id still in the catalog" membership boolean, yet it pierces the rich market interface's probe segment - every favorites visit/toggle (after the 800ms debounce merge) still fires N favorites × full queries including a per-hit npm/github latest **network fan-out** whose products are entirely discarded. On top of that, the unconditional `setData` after refreshViews' four-in-a-row responses (the post-toggle norm) evaporates the reference stability the data layer carefully maintains.
571
+ - **Fix**: `lookupLive` now passes `probeMode:"cache-only"` (ADR-0013 first-phase mode: zero network, zero probing; existence = items membership, semantics unchanged); `fetchPage` gains a **zero-increment commit guard** - a new `marketResponseEquivalent` pure function (recursive JSON-value comparison, ZoneChips setGeom discipline) skips `setData` when content is equivalent (dataRef/loading semantics untouched). The report's optional "client-side stale TTL cache" is explicitly not done: after C1 materialization, cache-only queries are already memory-grade.
572
+ - **Verification**: 1389 tests green (execution baseline 1382 + 7 added), typecheck clean; live-instance differential smoke across 5 cases (npm primary / self / community entry / nonexistent id): id sets and totals identical between cache-only and full (the sufficient condition for unchanged red-dot semantics), cache-only ~30ms vs cold full ~250ms (the zero-probe fingerprint); the implementation plan was closed over two independent review rounds (F1-1 plan-baseline correction).
573
+
562
574
  ### Fix in 0.9.76 — three presentation-layer retirements and sticky-chips jitter fixed at the root: "Chat" badge / "Version-independent" badge retired + scroll-anchoring self-excitation cut on the category row
563
575
 
564
576
  - **① "Chat" source badge retired (owner screenshot: every row flagged despite all-UI clicks)**: `meta.session` is the **pump-ownership marker** from the 0.7.0 final review (restore skip / replaceAll no-rollback / stripped on persist) — both the GUI `runOp` path and the chat-mirror path set it unconditionally, so it cannot distinguish origins, and the badge vanishes entirely after a page reload. Fix: opRowVm drops the session derivation; the row badge, its CSS and i18n keys retire; `meta.session` keeps its day job (zero data-model change); a future `meta.origin` would be the honest carrier for source attribution.
package/lib/client.js CHANGED
@@ -34,6 +34,7 @@ __export(market_state_exports, {
34
34
  countBeyondRows: () => countBeyondRows,
35
35
  createZoneState: () => createZoneState,
36
36
  marketNotice: () => marketNotice,
37
+ marketResponseEquivalent: () => marketResponseEquivalent,
37
38
  mergeLatestFields: () => mergeLatestFields,
38
39
  normalizeMarketQuery: () => normalizeMarketQuery,
39
40
  normalizeMarketResponse: () => normalizeMarketResponse,
@@ -235,6 +236,25 @@ function mergeLatestFields(nextItems, prevItems) {
235
236
  });
236
237
  return touched ? out : nextItems;
237
238
  }
239
+ function marketResponseEquivalent(a, b) {
240
+ if (a === b) return true;
241
+ if (!a || !b || typeof a !== "object" || typeof b !== "object") return false;
242
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
243
+ if (Array.isArray(a)) {
244
+ if (a.length !== b.length) return false;
245
+ for (let i = 0; i < a.length; i++) {
246
+ if (!marketResponseEquivalent(a[i], b[i])) return false;
247
+ }
248
+ return true;
249
+ }
250
+ const ka = Object.keys(a).filter((k) => a[k] !== void 0);
251
+ const kb = Object.keys(b).filter((k) => b[k] !== void 0);
252
+ if (ka.length !== kb.length) return false;
253
+ for (const k of ka) {
254
+ if (!Object.prototype.hasOwnProperty.call(b, k) || !marketResponseEquivalent(a[k], b[k])) return false;
255
+ }
256
+ return true;
257
+ }
238
258
  function chipRows(offsets) {
239
259
  if (!Array.isArray(offsets)) return 0;
240
260
  let rows = 0;
@@ -1866,7 +1886,7 @@ var h = React.createElement;
1866
1886
  var { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } = React;
1867
1887
  var PLUGIN_ID = "dsh-m";
1868
1888
  var API = "/dshm";
1869
- var { DEFAULT_PAGE_SIZE: DEFAULT_PAGE_SIZE2, MARKET_PAGE_SIZES: MARKET_PAGE_SIZES2, pageItems: pageItems2, createZoneState: createZoneState2, normalizeMarketQuery: normalizeMarketQuery2, resetPageOnFilterChange: resetPageOnFilterChange2, normalizeMarketResponse: normalizeMarketResponse2, registryNotice: registryNotice2, zoneChips: zoneChips2, marketNotice: marketNotice2, searchSourceOf: searchSourceOf2, mergeLatestFields: mergeLatestFields2, chipRows: chipRows2, countBeyondRows: countBeyondRows2, clipTopOf: clipTopOf2, visibleRowTop: visibleRowTop2, autoExpandDecision: autoExpandDecision2 } = (init_market_state(), __toCommonJS(market_state_exports));
1889
+ var { DEFAULT_PAGE_SIZE: DEFAULT_PAGE_SIZE2, MARKET_PAGE_SIZES: MARKET_PAGE_SIZES2, pageItems: pageItems2, createZoneState: createZoneState2, normalizeMarketQuery: normalizeMarketQuery2, resetPageOnFilterChange: resetPageOnFilterChange2, normalizeMarketResponse: normalizeMarketResponse2, registryNotice: registryNotice2, zoneChips: zoneChips2, marketNotice: marketNotice2, searchSourceOf: searchSourceOf2, mergeLatestFields: mergeLatestFields2, chipRows: chipRows2, countBeyondRows: countBeyondRows2, clipTopOf: clipTopOf2, visibleRowTop: visibleRowTop2, autoExpandDecision: autoExpandDecision2, marketResponseEquivalent: marketResponseEquivalent2 } = (init_market_state(), __toCommonJS(market_state_exports));
1870
1890
  var { readMarketSnapshot: readMarketSnapshot2, writeMarketSnapshot: writeMarketSnapshot2, isDefaultFirstPageQuery: isDefaultFirstPageQuery2 } = (init_market_snapshot(), __toCommonJS(market_snapshot_exports));
1871
1891
  var { backdropCloseHandlers: backdropCloseHandlers2 } = (init_backdrop(), __toCommonJS(backdrop_exports));
1872
1892
  var { lbStep: lbStep2, lbNeighbors: lbNeighbors2, swipeDir: swipeDir2 } = (init_lightbox(), __toCommonJS(lightbox_exports));
@@ -2944,11 +2964,12 @@ function useMarketData(zone = "community") {
2944
2964
  return api("market", params, ac.signal).then((raw) => {
2945
2965
  if (genRef.current !== gen || ac.signal.aborted) return;
2946
2966
  const next = normalizeMarketResponse2(raw);
2947
- const prevItems = dataRef.current && Array.isArray(dataRef.current.items) ? dataRef.current.items : null;
2967
+ const prevData = dataRef.current;
2968
+ const prevItems = prevData && Array.isArray(prevData.items) ? prevData.items : null;
2948
2969
  const mergedItems = mergeLatestFields2(next.items, prevItems);
2949
2970
  const merged = mergedItems === next.items ? next : { ...next, items: mergedItems };
2950
2971
  dataRef.current = merged;
2951
- setData(merged);
2972
+ if (!marketResponseEquivalent2(prevData, merged)) setData(merged);
2952
2973
  setLoading(false);
2953
2974
  if (probeMode === "cache-only" && next.latestComplete === false) {
2954
2975
  return fetchPage(nextQuery, force, true, "full");
@@ -4730,7 +4751,7 @@ function FavoriteZone({ favorites, onOpen }) {
4730
4751
  void (async () => {
4731
4752
  const lookupLive = async (fav) => {
4732
4753
  try {
4733
- const res = await api("market", { query: fav.id, source: "all", limit: 8 });
4754
+ const res = await api("market", { query: fav.id, source: "all", limit: 8, probeMode: "cache-only" });
4734
4755
  const items = res && Array.isArray(res.items) ? res.items : [];
4735
4756
  return items.some((x) => x && x.id === fav.id);
4736
4757
  } catch {
@@ -1,5 +1,13 @@
1
1
  import { fetchTextLimited } from './httpx.js';
2
2
  const MANIFEST_HOST = 'raw.githubusercontent.com';
3
+ /**
4
+ * 0.9.77 兜底腿(桌面机 raw 被墙实录):raw.githubusercontent.com 直连在部分网络下
5
+ * TLS 握手即被 reset,而 registry 清单自身早有 default-raw → default-jsdelivr 降级链、
6
+ * 社区目录有 jsdelivr/npmmirror/unpkg 三路由——截图 manifest 是全市场唯一单源抓取点,
7
+ * 症状为「列表/README/安装全正常,唯独精选详情无截图」。此处补齐同款第二腿:
8
+ * jsDelivr 的 GitHub 镜像,仅当 raw 抓取层失败(含 404/超时/host guard 拒绝)时启用。
9
+ */
10
+ const MIRROR_HOST = 'cdn.jsdelivr.net';
3
11
  const MANIFEST_FILE = 'screenshots.json';
4
12
  const MAX_MANIFEST_BYTES = 64 * 1024;
5
13
  const MAX_SCREENSHOTS = 8;
@@ -9,7 +17,8 @@ const OWNER_MAX = 39;
9
17
  const REPO_MAX = 100;
10
18
  const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
11
19
  const CONTROL_RE = /[\u0000-\u001f\u007f]/;
12
- function manifestUrl(github) {
20
+ /** owner/repo 校验与归一(GITHUB_REPO_RE 与 registry 同款);非法输入返回 null(零抓取)。 */
21
+ function repoSlug(github) {
13
22
  const value = typeof github === 'string' ? github.trim() : '';
14
23
  if (!GITHUB_REPO_RE.test(value))
15
24
  return null;
@@ -18,9 +27,25 @@ function manifestUrl(github) {
18
27
  const repo = value.slice(slash + 1);
19
28
  if (owner.length > OWNER_MAX || repo.length > REPO_MAX || repo === '.' || repo === '..')
20
29
  return null;
21
- return `https://${MANIFEST_HOST}/${owner}/${repo}/HEAD/${MANIFEST_FILE}`;
30
+ return `${owner}/${repo}`;
31
+ }
32
+ /**
33
+ * raw 规范形(/HEAD/ = 默认分支)。它同时是 parseManifest 的路径解析基址:无论哪条腿供数,
34
+ * 返回的图片 URL 一律落在 *.githubusercontent.com——恰好通过客户端 safeScreenshots 图床白名单
35
+ * (main.jsx 只放行 github.com / *.githubusercontent.com),镜像腿若原样回 jsdelivr URL 会被
36
+ * 客户端整体过滤、白修。
37
+ */
38
+ function rawManifestUrl(slug) {
39
+ return `https://${MANIFEST_HOST}/${slug}/HEAD/${MANIFEST_FILE}`;
22
40
  }
23
- function screenshotUrl(manifest, rawPath) {
41
+ /**
42
+ * jsDelivr 镜像形(@HEAD 与 raw /HEAD/ 同义,默认分支,实测可用)。镜像存在 CDN 缓存滞后
43
+ * (registry default-jsdelivr 同款取舍,可用 purge.jsdelivr.net 手动清缓存),截图属低频变更元数据,可接受。
44
+ */
45
+ function mirrorManifestUrl(slug) {
46
+ return `https://${MIRROR_HOST}/gh/${slug}@HEAD/${MANIFEST_FILE}`;
47
+ }
48
+ function screenshotUrl(baseUrl, rawPath) {
24
49
  if (typeof rawPath !== 'string')
25
50
  return null;
26
51
  const path = rawPath.trim();
@@ -35,10 +60,10 @@ function screenshotUrl(manifest, rawPath) {
35
60
  const segments = path.split('/');
36
61
  if (segments.some((part) => part === '' || part === '.' || part === '..'))
37
62
  return null;
38
- const base = new URL('.', manifest);
63
+ const base = new URL('.', baseUrl);
39
64
  return new URL(segments.map((part) => encodeURIComponent(part)).join('/'), base).toString();
40
65
  }
41
- function parseManifest(text, manifestUrl) {
66
+ function parseManifest(text, baseUrl) {
42
67
  let parsed;
43
68
  try {
44
69
  parsed = JSON.parse(text);
@@ -50,14 +75,14 @@ function parseManifest(text, manifestUrl) {
50
75
  return [];
51
76
  const result = [];
52
77
  for (const path of parsed) {
53
- const url = screenshotUrl(manifestUrl, path);
78
+ const url = screenshotUrl(baseUrl, path);
54
79
  if (!url)
55
80
  return [];
56
81
  result.push(url);
57
82
  }
58
83
  return result;
59
84
  }
60
- function assertManifestHost(raw, isRedirectHop) {
85
+ function assertManifestHost(raw, isRedirectHop, allowedHost) {
61
86
  let url;
62
87
  try {
63
88
  url = new URL(raw);
@@ -65,14 +90,31 @@ function assertManifestHost(raw, isRedirectHop) {
65
90
  catch {
66
91
  throw new Error(isRedirectHop ? 'screenshot manifest redirect URL is invalid' : 'invalid screenshot manifest request URL');
67
92
  }
68
- if (url.protocol !== 'https:' || url.hostname !== MANIFEST_HOST || url.username || url.password || url.port) {
93
+ if (url.protocol !== 'https:' || url.hostname !== allowedHost || url.username || url.password || url.port) {
69
94
  throw new Error(isRedirectHop
70
- ? 'screenshot manifest redirects must remain on raw.githubusercontent.com'
71
- : 'screenshot manifest request URL must be https://raw.githubusercontent.com');
95
+ ? `screenshot manifest redirects must remain on ${allowedHost}`
96
+ : `screenshot manifest request URL must be https://${allowedHost}`);
72
97
  }
73
98
  }
99
+ /** 单腿抓取:每腿独立逐跳 host guard(初始跳 + 重定向都不许离开本腿源),5s / 64KB 上限逐腿独立生效。 */
100
+ async function fetchManifestLeg(fetchText, url, allowedHost) {
101
+ let hop = 0;
102
+ return fetchText(url, {
103
+ timeoutMs: 5_000,
104
+ maxBytes: MAX_MANIFEST_BYTES,
105
+ headers: { accept: 'application/json' },
106
+ onRequest: (hopUrl) => {
107
+ // 逐跳 host guard;错误文案区分初始跳与重定向跳,避免调试误导(评审建议落盘)。
108
+ const isRedirectHop = hop > 0;
109
+ hop += 1;
110
+ assertManifestHost(hopUrl, isRedirectHop, allowedHost);
111
+ },
112
+ });
113
+ }
74
114
  /**
75
115
  * Read an author-owned root screenshots.json without consulting Community Catalog.
116
+ * 两腿链:raw 抓取层失败 → jsDelivr 镜像;两腿全败 → 空图库。解析层的「空」是权威结果
117
+ * (两腿读的是同一 repo 文件,镜像不会有不同内容),不再触发兜底。
76
118
  * All network access is bounded, and a rejected/empty manifest degrades to no gallery.
77
119
  */
78
120
  export function createPrimaryScreenshotReader(options = {}) {
@@ -81,10 +123,11 @@ export function createPrimaryScreenshotReader(options = {}) {
81
123
  const cache = new Map();
82
124
  const flights = new Map();
83
125
  return async (github) => {
84
- const url = manifestUrl(github);
85
- if (!url)
126
+ const slug = repoSlug(github);
127
+ if (!slug)
86
128
  return [];
87
- const key = url.toLowerCase();
129
+ const rawUrl = rawManifestUrl(slug);
130
+ const key = rawUrl.toLowerCase();
88
131
  const timestamp = now();
89
132
  for (const [cachedKey, value] of cache) {
90
133
  if (value.expiresAt <= timestamp)
@@ -98,24 +141,20 @@ export function createPrimaryScreenshotReader(options = {}) {
98
141
  return [...(await pending)];
99
142
  const flight = (async () => {
100
143
  let screenshots = [];
101
- let hop = 0;
102
144
  try {
103
- const text = await fetchText(url, {
104
- timeoutMs: 5_000,
105
- maxBytes: MAX_MANIFEST_BYTES,
106
- headers: { accept: 'application/json' },
107
- onRequest: (hopUrl) => {
108
- // 逐跳 host guard;错误文案区分初始跳与重定向跳,避免调试误导(评审建议落盘)。
109
- const isRedirectHop = hop > 0;
110
- hop += 1;
111
- assertManifestHost(hopUrl, isRedirectHop);
112
- },
113
- });
114
- screenshots = parseManifest(text, url);
145
+ const text = await fetchManifestLeg(fetchText, rawUrl, MANIFEST_HOST);
146
+ screenshots = parseManifest(text, rawUrl);
115
147
  }
116
148
  catch {
117
- // Images are optional metadata: a missing/unavailable manifest must not block detail/install.
118
- screenshots = [];
149
+ try {
150
+ const mirrorUrl = mirrorManifestUrl(slug);
151
+ const text = await fetchManifestLeg(fetchText, mirrorUrl, MIRROR_HOST);
152
+ screenshots = parseManifest(text, rawUrl);
153
+ }
154
+ catch {
155
+ // Images are optional metadata: a missing/unavailable manifest must not block detail/install.
156
+ screenshots = [];
157
+ }
119
158
  }
120
159
  cache.set(key, {
121
160
  screenshots,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-m",
3
- "version": "0.9.76",
3
+ "version": "0.9.78",
4
4
  "description": "可自定义收录清单的 DSH 插件市场:收录、安装、卸载、升级一站完成,附 agent 工具与 dshm CLI。",
5
5
  "license": "MIT",
6
6
  "type": "module",