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 +12 -0
- package/lib/client.js +25 -4
- package/lib/core/primary-screenshots.js +67 -28
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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('.',
|
|
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,
|
|
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(
|
|
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 !==
|
|
93
|
+
if (url.protocol !== 'https:' || url.hostname !== allowedHost || url.username || url.password || url.port) {
|
|
69
94
|
throw new Error(isRedirectHop
|
|
70
|
-
?
|
|
71
|
-
:
|
|
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
|
|
85
|
-
if (!
|
|
126
|
+
const slug = repoSlug(github);
|
|
127
|
+
if (!slug)
|
|
86
128
|
return [];
|
|
87
|
-
const
|
|
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
|
|
104
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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,
|