@iasiv5/dsh-quota-watch 0.1.29 → 0.1.30
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/README.md +1 -1
- package/lib/client.js +15 -1
- package/package.json +1 -1
- package/src/client.mjs +36 -1
- package/src/host.mjs +61 -9
- package/src/shared.mjs +4 -0
package/README.md
CHANGED
|
@@ -48,7 +48,7 @@ Web 与 Desktop profile 均已实测适配。按 DSH 插件管理器提示完成
|
|
|
48
48
|
|
|
49
49
|
## 配置
|
|
50
50
|
|
|
51
|
-
本插件不再包含任何自动用卡配置。宿主首次启动时会一次性把历史版本保存的自动用卡开关**持久覆写为关闭**(即使设置文件不存在也会写入全关覆盖),因此回退到旧版本也不会让旧授权复活;覆写失败仅报告回退风险,新宿主本身没有任何自动消费路径。可配置项仅剩 profile patch 中的 `pollIntervalSec`(默认 60
|
|
51
|
+
本插件不再包含任何自动用卡配置。宿主首次启动时会一次性把历史版本保存的自动用卡开关**持久覆写为关闭**(即使设置文件不存在也会写入全关覆盖),因此回退到旧版本也不会让旧授权复活;覆写失败仅报告回退风险,新宿主本身没有任何自动消费路径。可配置项仅剩 profile patch 中的 `pollIntervalSec`(默认 60 秒,只读额度轮询节奏;无任何插件路由请求持续 2 个轮询周期时宿主暂停计划轮询——空闲暂停,下一次请求立即恢复,先答缓存、后台补一轮新数据;暂停不算刷新失败、不标记数据过期)。
|
|
52
52
|
|
|
53
53
|
## 重置卡与手动用卡
|
|
54
54
|
|
package/lib/client.js
CHANGED
|
@@ -1579,7 +1579,21 @@ function mountQuotaCard({
|
|
|
1579
1579
|
if (!disposed) place();
|
|
1580
1580
|
});
|
|
1581
1581
|
};
|
|
1582
|
-
const
|
|
1582
|
+
const SEAT_MARKERS = ["sidebarCol", "footArea"];
|
|
1583
|
+
const seatValid = () => currentFoot !== null && currentFoot.isConnected && container.parentElement === currentFoot && currentFoot.firstElementChild === container;
|
|
1584
|
+
const nodeTouchesSeatDiscovery = (node) => {
|
|
1585
|
+
if (node?.nodeType !== 1) return false;
|
|
1586
|
+
const cls = node.getAttribute("class") ?? "";
|
|
1587
|
+
if (SEAT_MARKERS.some((marker) => cls.includes(marker))) return true;
|
|
1588
|
+
return node.querySelector('[class*="sidebarCol"], [class*="footArea"]') != null;
|
|
1589
|
+
};
|
|
1590
|
+
const batchTouchesSeatDiscovery = (records) => records.some((record) => {
|
|
1591
|
+
const touched = [...record.addedNodes ?? [], ...record.removedNodes ?? []];
|
|
1592
|
+
return touched.some(nodeTouchesSeatDiscovery);
|
|
1593
|
+
});
|
|
1594
|
+
const observer = new win.MutationObserver((records) => {
|
|
1595
|
+
const noLiveFoot = currentFoot === null || !currentFoot.isConnected;
|
|
1596
|
+
if (!batchTouchesSeatDiscovery(records) && (seatValid() || noLiveFoot)) return;
|
|
1583
1597
|
schedulePlace();
|
|
1584
1598
|
});
|
|
1585
1599
|
observer.observe(doc.body, { childList: true, subtree: true });
|
package/package.json
CHANGED
package/src/client.mjs
CHANGED
|
@@ -1710,7 +1710,42 @@ export function mountQuotaCard({
|
|
|
1710
1710
|
})
|
|
1711
1711
|
}
|
|
1712
1712
|
|
|
1713
|
-
|
|
1713
|
+
// Seat-gate (2026-10-15 perf): the body observer sees EVERY childList batch
|
|
1714
|
+
// in the whole app — during chat streaming that is a near-continuous stream —
|
|
1715
|
+
// and each scheduled place() used to open with an O(document) discovery scan
|
|
1716
|
+
// (`[class*="sidebarCol"]` cannot use a class index). The gate skips the
|
|
1717
|
+
// schedule entirely when this batch cannot change the seating:
|
|
1718
|
+
// • Seat valid (card still first child of a connected foot): only a batch
|
|
1719
|
+
// carrying a sidebarCol/footArea node — added OR removed, the element
|
|
1720
|
+
// itself or anywhere inside it — can change WHICH foot is the doc-order
|
|
1721
|
+
// first one or create a competing one. No such node ⇒ place() would be a
|
|
1722
|
+
// measured no-op.
|
|
1723
|
+
// • No live foot (null, or stale after the sidebar was torn down): a seat
|
|
1724
|
+
// can only appear through a sidebar-introducing node, and class-only
|
|
1725
|
+
// flips were already invisible to this childList-only observer.
|
|
1726
|
+
// A displacement inside a live foot fails seatValid() and re-arms place().
|
|
1727
|
+
// Invariant for the ResizeObserver rebind in place(): any batch that could
|
|
1728
|
+
// change which element IS the foot necessarily carries a marker node, so the
|
|
1729
|
+
// gate never masks a real foot swap (jsdom has no ResizeObserver; this
|
|
1730
|
+
// invariant is argued here, not exercised by tests). Marker-carrying batches
|
|
1731
|
+
// that change nothing still pay one discovery scan — the false-positive cost.
|
|
1732
|
+
const SEAT_MARKERS = ['sidebarCol', 'footArea']
|
|
1733
|
+
const seatValid = () => currentFoot !== null && currentFoot.isConnected
|
|
1734
|
+
&& container.parentElement === currentFoot
|
|
1735
|
+
&& currentFoot.firstElementChild === container
|
|
1736
|
+
const nodeTouchesSeatDiscovery = (node) => {
|
|
1737
|
+
if (node?.nodeType !== 1) return false
|
|
1738
|
+
const cls = node.getAttribute('class') ?? ''
|
|
1739
|
+
if (SEAT_MARKERS.some((marker) => cls.includes(marker))) return true
|
|
1740
|
+
return node.querySelector('[class*="sidebarCol"], [class*="footArea"]') != null
|
|
1741
|
+
}
|
|
1742
|
+
const batchTouchesSeatDiscovery = (records) => records.some((record) => {
|
|
1743
|
+
const touched = [...(record.addedNodes ?? []), ...(record.removedNodes ?? [])]
|
|
1744
|
+
return touched.some(nodeTouchesSeatDiscovery)
|
|
1745
|
+
})
|
|
1746
|
+
const observer = new win.MutationObserver((records) => {
|
|
1747
|
+
const noLiveFoot = currentFoot === null || !currentFoot.isConnected
|
|
1748
|
+
if (!batchTouchesSeatDiscovery(records) && (seatValid() || noLiveFoot)) return
|
|
1714
1749
|
schedulePlace()
|
|
1715
1750
|
})
|
|
1716
1751
|
observer.observe(doc.body, { childList: true, subtree: true })
|
package/src/host.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { retireLegacyAutoResetSettings } from './core/legacy-auto-settings.mjs'
|
|
|
17
17
|
import { createManualOutcomeJournal } from './core/manual-outcome.mjs'
|
|
18
18
|
import {
|
|
19
19
|
DEFAULT_POLL_INTERVAL_SEC,
|
|
20
|
+
IDLE_AFTER_POLL_INTERVALS,
|
|
20
21
|
MAX_POLL_INTERVAL_SEC,
|
|
21
22
|
MIN_POLL_INTERVAL_SEC,
|
|
22
23
|
PROBE_TIMEOUT_MS,
|
|
@@ -128,6 +129,15 @@ function requestAllowed(ctx, req, res) {
|
|
|
128
129
|
return false
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
/** The access fence plus the idle-pause watcher stamp: any allowed request
|
|
133
|
+
* proves a browser is watching, so it feeds lastClientSeenAt. The call is
|
|
134
|
+
* defensive because route tests may pass minimal service doubles. */
|
|
135
|
+
function requestAllowedAndSeen(ctx, req, res, service) {
|
|
136
|
+
if (!requestAllowed(ctx, req, res)) return false
|
|
137
|
+
service?.noteClientSeen?.()
|
|
138
|
+
return true
|
|
139
|
+
}
|
|
140
|
+
|
|
131
141
|
/**
|
|
132
142
|
* Where the legacy automatic-use switches persisted. The start()-time one-shot
|
|
133
143
|
* retirement keeps this file overwritten all-off, so a rollback to an old
|
|
@@ -171,6 +181,20 @@ export function createQuotaService(ctx, options = {}) {
|
|
|
171
181
|
let coldAttempts = 0
|
|
172
182
|
let legacyRetirement
|
|
173
183
|
|
|
184
|
+
// --- Idle polling pause -----------------------------------------------------
|
|
185
|
+
// The scheduled poll serves browsers only. Once idleAfterMs passes with no
|
|
186
|
+
// request to any plugin route (every allowed route hit stamps
|
|
187
|
+
// lastClientSeenAt), timer fires skip the round entirely; the next client
|
|
188
|
+
// request resumes with a stale-while-revalidate kick. start() stamps once so
|
|
189
|
+
// the boot round always runs (the first client GET then meets data, not
|
|
190
|
+
// 'loading'). An undefined stamp (service built but never started, as in
|
|
191
|
+
// tests) never counts as idle, so direct refresh() calls behave as before.
|
|
192
|
+
const idleAfterMs = options.idleAfterMs ?? pollIntervalSec * 1000 * IDLE_AFTER_POLL_INTERVALS
|
|
193
|
+
let lastClientSeenAt
|
|
194
|
+
let idleActive = false
|
|
195
|
+
const idleNow = () => lastClientSeenAt !== undefined && now() - lastClientSeenAt >= idleAfterMs
|
|
196
|
+
const snapshotStale = () => updatedAt === undefined || now() - updatedAt >= pollIntervalSec * 1000
|
|
197
|
+
|
|
174
198
|
/** The usage slice a browser may see: old values survive only while the failure episode is short. */
|
|
175
199
|
function usageView(usage) {
|
|
176
200
|
if (usage === undefined) return undefined
|
|
@@ -444,6 +468,17 @@ export function createQuotaService(ctx, options = {}) {
|
|
|
444
468
|
// --- Reset cards -----------------------------------------------------------
|
|
445
469
|
|
|
446
470
|
function refresh(forceCards = false) {
|
|
471
|
+
// Idle pause: a timer-driven round with no recent client serves the cache
|
|
472
|
+
// and skips every upstream probe. A forced refresh is exempt, and any
|
|
473
|
+
// client-driven path has just stamped lastClientSeenAt, so it never lands
|
|
474
|
+
// here (one check covers both callers).
|
|
475
|
+
if (forceCards !== true && idleNow()) {
|
|
476
|
+
if (!idleActive) {
|
|
477
|
+
idleActive = true
|
|
478
|
+
logAuto('info', `quota-watch: idle — no client request for ${Math.round(idleAfterMs / 1000)}s, scheduled poll paused`)
|
|
479
|
+
}
|
|
480
|
+
return Promise.resolve(view())
|
|
481
|
+
}
|
|
447
482
|
if (pollPromise !== undefined) {
|
|
448
483
|
// A manual refresh must still re-list cards when it overlaps a scheduled
|
|
449
484
|
// round whose ten-minute gate skipped the list.
|
|
@@ -796,6 +831,19 @@ export function createQuotaService(ctx, options = {}) {
|
|
|
796
831
|
}
|
|
797
832
|
}
|
|
798
833
|
|
|
834
|
+
/** Any allowed route hit: stamp the watcher clock, and — if the snapshot
|
|
835
|
+
* went stale during a pause — kick a background round. Stale-while-
|
|
836
|
+
* revalidate: the caller serves the cached view immediately and never waits
|
|
837
|
+
* for the kick. Returns the kicked round's promise for tests. */
|
|
838
|
+
function noteClientSeen() {
|
|
839
|
+
const resuming = idleActive
|
|
840
|
+
lastClientSeenAt = now()
|
|
841
|
+
idleActive = false
|
|
842
|
+
if (resuming) logAuto('info', 'quota-watch: client request — scheduled poll resumed')
|
|
843
|
+
if (disposed || pollPromise !== undefined || !snapshotStale()) return undefined
|
|
844
|
+
return refresh()
|
|
845
|
+
}
|
|
846
|
+
|
|
799
847
|
function schedule(delayMs) {
|
|
800
848
|
if (disposed) return
|
|
801
849
|
timer = setTimeout(async () => {
|
|
@@ -825,6 +873,7 @@ export function createQuotaService(ctx, options = {}) {
|
|
|
825
873
|
return {
|
|
826
874
|
overview: view,
|
|
827
875
|
refresh,
|
|
876
|
+
noteClientSeen,
|
|
828
877
|
listResetCards,
|
|
829
878
|
useResetCard,
|
|
830
879
|
acknowledgeManualOutcome,
|
|
@@ -842,6 +891,9 @@ export function createQuotaService(ctx, options = {}) {
|
|
|
842
891
|
} else {
|
|
843
892
|
logAuto('warn', `quota-watch: could not overwrite the legacy auto-reset settings (${legacyRetirement.error}); a rollback to an old version could revive its switches, but this host has no automatic spending path`)
|
|
844
893
|
}
|
|
894
|
+
// Boot counts as watched for the first idle window: the initial round
|
|
895
|
+
// always runs, so the first client GET meets data instead of 'loading'.
|
|
896
|
+
lastClientSeenAt = now()
|
|
845
897
|
// Routes register synchronously in apply() before start(), so probing
|
|
846
898
|
// immediately is safe; the legacy 2s delay only padded the cold start.
|
|
847
899
|
schedule(0)
|
|
@@ -861,7 +913,7 @@ export function makeOverviewRoute(ctx, service) {
|
|
|
861
913
|
kind: 'exact',
|
|
862
914
|
path: ROUTES.overview,
|
|
863
915
|
handler: (req, res) => {
|
|
864
|
-
if (!
|
|
916
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'GET')) return
|
|
865
917
|
responseJson(res, 200, service.overview())
|
|
866
918
|
},
|
|
867
919
|
}
|
|
@@ -872,7 +924,7 @@ export function makeRefreshRoute(ctx, service) {
|
|
|
872
924
|
kind: 'exact',
|
|
873
925
|
path: ROUTES.refresh,
|
|
874
926
|
handler: async (req, res) => {
|
|
875
|
-
if (!
|
|
927
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
|
|
876
928
|
try {
|
|
877
929
|
await service.refresh(true)
|
|
878
930
|
responseJson(res, 200, service.overview())
|
|
@@ -889,7 +941,7 @@ export function makeResetCardsRoute(ctx, service) {
|
|
|
889
941
|
kind: 'exact',
|
|
890
942
|
path: ROUTES.resetCards,
|
|
891
943
|
handler: async (req, res) => {
|
|
892
|
-
if (!
|
|
944
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'GET')) return
|
|
893
945
|
try {
|
|
894
946
|
const result = await service.listResetCards()
|
|
895
947
|
responseJson(res, result.ok ? 200 : 502, result)
|
|
@@ -912,7 +964,7 @@ export function makeResetUseRoute(ctx, service) {
|
|
|
912
964
|
kind: 'exact',
|
|
913
965
|
path: ROUTES.resetUse,
|
|
914
966
|
handler: async (req, res) => {
|
|
915
|
-
if (!
|
|
967
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
|
|
916
968
|
const payload = await readJsonBody(req)
|
|
917
969
|
if (payload === undefined) {
|
|
918
970
|
responseJson(res, 400, { ok: false, error: 'invalid-body', message: '请求体不是合法 JSON' })
|
|
@@ -948,7 +1000,7 @@ export function makeResetAcknowledgeRoute(ctx, service) {
|
|
|
948
1000
|
kind: 'exact',
|
|
949
1001
|
path: ROUTES.resetAcknowledge,
|
|
950
1002
|
handler: async (req, res) => {
|
|
951
|
-
if (!
|
|
1003
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
|
|
952
1004
|
const payload = await readJsonBody(req)
|
|
953
1005
|
if (payload === undefined) {
|
|
954
1006
|
responseJson(res, 400, { ok: false, error: 'invalid-body', message: '请求体不是合法 JSON' })
|
|
@@ -975,7 +1027,7 @@ export function makeResetReceiptRoute(ctx, service) {
|
|
|
975
1027
|
kind: 'exact',
|
|
976
1028
|
path: ROUTES.resetReceipt,
|
|
977
1029
|
handler: async (req, res) => {
|
|
978
|
-
if (!
|
|
1030
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
|
|
979
1031
|
const payload = await readJsonBody(req)
|
|
980
1032
|
if (payload === undefined) {
|
|
981
1033
|
responseJson(res, 400, { ok: false, error: 'invalid-body', message: '请求体不是合法 JSON' })
|
|
@@ -1000,12 +1052,12 @@ const RETIRED_AUTO_RESET_PATH = '/api/dsh-quota-watch/auto-reset'
|
|
|
1000
1052
|
* The path is a private host constant precisely so it never re-enters the
|
|
1001
1053
|
* shared client contract in src/shared.mjs.
|
|
1002
1054
|
*/
|
|
1003
|
-
export function makeRetiredAutoResetRoute(ctx) {
|
|
1055
|
+
export function makeRetiredAutoResetRoute(ctx, service) {
|
|
1004
1056
|
return {
|
|
1005
1057
|
kind: 'exact',
|
|
1006
1058
|
path: RETIRED_AUTO_RESET_PATH,
|
|
1007
1059
|
handler: async (req, res) => {
|
|
1008
|
-
if (!
|
|
1060
|
+
if (!requestAllowedAndSeen(ctx, req, res, service) || !methodAllowed(req, res, 'POST')) return
|
|
1009
1061
|
responseJson(res, 410, { ok: false, error: 'retired', message: '自动用卡功能已退役;重置卡请在面板手动确认使用' })
|
|
1010
1062
|
},
|
|
1011
1063
|
}
|
|
@@ -1039,7 +1091,7 @@ export function apply(ctx, config = {}) {
|
|
|
1039
1091
|
ctx.webServer.register(makeResetUseRoute(ctx, service)),
|
|
1040
1092
|
ctx.webServer.register(makeResetAcknowledgeRoute(ctx, service)),
|
|
1041
1093
|
ctx.webServer.register(makeResetReceiptRoute(ctx, service)),
|
|
1042
|
-
ctx.webServer.register(makeRetiredAutoResetRoute(ctx)),
|
|
1094
|
+
ctx.webServer.register(makeRetiredAutoResetRoute(ctx, service)),
|
|
1043
1095
|
]
|
|
1044
1096
|
service.start()
|
|
1045
1097
|
ctx.effect(() => () => {
|
package/src/shared.mjs
CHANGED
|
@@ -31,3 +31,7 @@ export const MIN_POLL_INTERVAL_SEC = 30
|
|
|
31
31
|
export const MAX_POLL_INTERVAL_SEC = 3600
|
|
32
32
|
export const PROBE_TIMEOUT_MS = 10_000
|
|
33
33
|
export const CLIENT_POLL_INTERVAL_MS = 30_000
|
|
34
|
+
// Idle polling pause: a scheduled round is skipped once this many poll
|
|
35
|
+
// intervals have passed without any request to a plugin route (every allowed
|
|
36
|
+
// route hit stamps the watcher clock).
|
|
37
|
+
export const IDLE_AFTER_POLL_INTERVALS = 2
|