dsh-session-guard 4.1.11 → 4.1.16
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.ja.md +29 -0
- package/CHANGELOG.ko.md +29 -0
- package/CHANGELOG.md +83 -0
- package/dsh.plugin.json +1 -1
- package/lib/client.js +239 -95
- package/lib/client.js.map +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +41 -29
- package/src/client/settings-bridge.ts +202 -0
- package/src/client/settings-card.tsx +9 -3
- package/src/index.js +16 -0
- package/src/settings-bridge.js +141 -0
- package/src/client/shell-overlay.tsx +0 -69
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BridgeDocHandle — 设置卡双轨数据源的桥轨(M4/T20-b)。
|
|
3
|
+
*
|
|
4
|
+
* ≤0.1.5(settingsScope fiber 永不解 / 0.1.0-0.1.1 无 client 服务)时,
|
|
5
|
+
* 配置卡经本句柄走自家 webServer 桥(describe/mutate)读写宿主 settings 服务。
|
|
6
|
+
* 与原生 SettingsScope(scope-face.ts)同构:getSnapshot/subscribe/set/unset。
|
|
7
|
+
* 读 = POST /api/<ns>/settings/describe;写 = POST …/mutate(ops 转发宿主服务),
|
|
8
|
+
* 成功即用回填描述符刷新快照并通知订阅者(无本地第二份状态——权威值始终在宿主)。
|
|
9
|
+
*
|
|
10
|
+
* 与 perm-gate src/client/bridge-scope.ts(T13b/T20-b 泛化模板)的差异:
|
|
11
|
+
* ① 快照形状对齐本仓 SettingsScope(status: loading/ready/unavailable + mode);
|
|
12
|
+
* ② describe 未就绪(settings-unavailable/ns-not-registered)时有限重试
|
|
13
|
+
* (宿主 settings 服务解析晚于卡挂载的窗口),重试耗尽才落 'unavailable'。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 与 server 侧 settingsBridgeRoutesFor(src/settings-bridge.js)必须逐字符一致。 */
|
|
17
|
+
function settingsBridgeRoutesFor(ns: string): { describe: string; mutate: string } {
|
|
18
|
+
return { describe: `/api/${ns}/settings/describe`, mutate: `/api/${ns}/settings/mutate` }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Snapshot<T> {
|
|
22
|
+
status: 'loading' | 'ready' | 'unavailable'
|
|
23
|
+
value: T | undefined
|
|
24
|
+
revision: number | undefined
|
|
25
|
+
writable: boolean
|
|
26
|
+
base: unknown
|
|
27
|
+
user: unknown
|
|
28
|
+
mode: 'bridge'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Descriptor {
|
|
32
|
+
value?: unknown
|
|
33
|
+
revision?: number
|
|
34
|
+
[k: string]: unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface BridgeBody {
|
|
38
|
+
ok?: boolean
|
|
39
|
+
code?: string
|
|
40
|
+
message?: string
|
|
41
|
+
value?: { descriptor?: Descriptor; writable?: boolean }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface MutateOp {
|
|
45
|
+
op: 'set' | 'unset'
|
|
46
|
+
path: string[]
|
|
47
|
+
value?: unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ≤0.1.5 宿主 describe 的 descriptor.value 是「每字段 → {}」骨架(0.1.0/0.1.5
|
|
52
|
+
* 实测:value={enabled:{},…,officialProviders:{}},真值拆在 base(合并默认层)
|
|
53
|
+
* 与 user(用户层))——直接吞 value 会让卡拿到 officialProviders={},
|
|
54
|
+
* `?? []` 不兜底({} 非 nullish)→ ListRow values.join 崩,整族 tab 槽殉葬。
|
|
55
|
+
* 判特征:对象且每个自有值都是空 plain 对象(guard 字段是标量/数组,不会全空)。
|
|
56
|
+
* 命中即以 {...base, ...user} 重组有效值(guard 配置全是顶层标量/数组,浅合并即语义)。
|
|
57
|
+
*/
|
|
58
|
+
function isFieldSkeleton(v: unknown): v is Record<string, Record<string, never>> {
|
|
59
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) return false
|
|
60
|
+
const values = Object.values(v as Record<string, unknown>)
|
|
61
|
+
return values.length > 0
|
|
62
|
+
&& values.every((x) => x !== null && typeof x === 'object' && !Array.isArray(x) && Object.keys(x as object).length === 0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function plainObject(v: unknown): Record<string, unknown> {
|
|
66
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? { ...(v as Record<string, unknown>) } : {}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** descriptor → 卡面可用 plain 值:正常代际用 value;骨架代际用 base+user 浅合并。 */
|
|
70
|
+
function descriptorPlainValue(d: Descriptor): unknown {
|
|
71
|
+
if (!isFieldSkeleton(d.value)) return d.value
|
|
72
|
+
return { ...plainObject(d.base), ...plainObject(d.user) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** describe 不可就绪码:宿主 settings 未解析/未注册——值得重试的瞬态。 */
|
|
76
|
+
const RETRYABLE_CODES = new Set(['settings-unavailable', 'ns-not-registered'])
|
|
77
|
+
const RETRY_DELAY_MS = 1500
|
|
78
|
+
const RETRY_MAX = 4
|
|
79
|
+
|
|
80
|
+
export class BridgeDocHandle<T> {
|
|
81
|
+
private snapshot: Snapshot<T>
|
|
82
|
+
private listeners = new Set<() => void>()
|
|
83
|
+
private revision: number | undefined
|
|
84
|
+
private retriesLeft = RETRY_MAX
|
|
85
|
+
private inflight: Promise<void> | undefined
|
|
86
|
+
private readonly ns: string
|
|
87
|
+
private readonly routes: { describe: string; mutate: string }
|
|
88
|
+
|
|
89
|
+
constructor(ns: string) {
|
|
90
|
+
this.ns = ns
|
|
91
|
+
this.routes = settingsBridgeRoutesFor(ns)
|
|
92
|
+
this.snapshot = {
|
|
93
|
+
status: 'loading',
|
|
94
|
+
value: undefined,
|
|
95
|
+
revision: undefined,
|
|
96
|
+
writable: true,
|
|
97
|
+
base: undefined,
|
|
98
|
+
user: undefined,
|
|
99
|
+
mode: 'bridge',
|
|
100
|
+
}
|
|
101
|
+
void this.refresh()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
getSnapshot(): Snapshot<T> {
|
|
105
|
+
return this.snapshot
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
subscribe(listener: () => void): () => void {
|
|
109
|
+
this.listeners.add(listener)
|
|
110
|
+
return () => {
|
|
111
|
+
this.listeners.delete(listener)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async set(field: string, value: unknown): Promise<boolean> {
|
|
116
|
+
return this.mutate([{ op: 'set', path: [field], value }])
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async unset(field: string): Promise<boolean> {
|
|
120
|
+
return this.mutate([{ op: 'unset', path: [field] }])
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private notify(): void {
|
|
124
|
+
for (const l of [...this.listeners]) l()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private applyDescriptor(d: Descriptor, writable: boolean): void {
|
|
128
|
+
this.revision = typeof d.revision === 'number' ? d.revision : undefined
|
|
129
|
+
this.snapshot = {
|
|
130
|
+
status: 'ready',
|
|
131
|
+
value: descriptorPlainValue(d) as T | undefined,
|
|
132
|
+
revision: this.revision,
|
|
133
|
+
writable,
|
|
134
|
+
base: undefined,
|
|
135
|
+
user: undefined,
|
|
136
|
+
mode: 'bridge',
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async request(path: string, body?: unknown): Promise<BridgeBody> {
|
|
141
|
+
const response = await fetch(path, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers: { 'content-type': 'application/json' },
|
|
144
|
+
body: JSON.stringify(body ?? {}),
|
|
145
|
+
})
|
|
146
|
+
if (!response.ok) throw new Error(`bridge ${path} HTTP ${response.status}`)
|
|
147
|
+
return (await response.json()) as BridgeBody
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** describe 拉取;瞬态未就绪码在重试预算内延迟重拉,耗尽才落 unavailable。 */
|
|
151
|
+
refresh(): Promise<void> {
|
|
152
|
+
if (this.inflight !== undefined) return this.inflight
|
|
153
|
+
this.inflight = (async () => {
|
|
154
|
+
try {
|
|
155
|
+
const body = await this.request(this.routes.describe)
|
|
156
|
+
if (body.ok && body.value?.descriptor !== undefined) {
|
|
157
|
+
this.retriesLeft = RETRY_MAX
|
|
158
|
+
this.applyDescriptor(body.value.descriptor, body.value.writable !== false)
|
|
159
|
+
} else if (body.code !== undefined && RETRYABLE_CODES.has(body.code) && this.retriesLeft > 0) {
|
|
160
|
+
this.retriesLeft -= 1
|
|
161
|
+
this.inflight = undefined
|
|
162
|
+
setTimeout(() => {
|
|
163
|
+
void this.refresh()
|
|
164
|
+
}, RETRY_DELAY_MS)
|
|
165
|
+
return
|
|
166
|
+
} else {
|
|
167
|
+
this.snapshot = { ...this.snapshot, status: 'unavailable', writable: false }
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// 网络异常:按瞬态处理,预算内重试
|
|
171
|
+
if (this.retriesLeft > 0) {
|
|
172
|
+
this.retriesLeft -= 1
|
|
173
|
+
this.inflight = undefined
|
|
174
|
+
setTimeout(() => {
|
|
175
|
+
void this.refresh()
|
|
176
|
+
}, RETRY_DELAY_MS)
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
this.snapshot = { ...this.snapshot, status: 'unavailable', writable: false }
|
|
180
|
+
}
|
|
181
|
+
// 重试路径已在上面清过 inflight(setTimeout 后由 refresh() 重建);
|
|
182
|
+
// 终态路径在此清挂起标记并广播。重试等待期 notify 无害(快照未变)。
|
|
183
|
+
this.inflight = undefined
|
|
184
|
+
this.notify()
|
|
185
|
+
})()
|
|
186
|
+
return this.inflight
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private async mutate(ops: MutateOp[]): Promise<boolean> {
|
|
190
|
+
const body = await this.request(this.routes.mutate, { ops, expectedRevision: this.revision })
|
|
191
|
+
if (body.ok && body.value?.descriptor !== undefined) {
|
|
192
|
+
this.applyDescriptor(body.value.descriptor, this.snapshot.writable)
|
|
193
|
+
this.notify()
|
|
194
|
+
return true
|
|
195
|
+
}
|
|
196
|
+
if (body.code === 'settings-conflict') {
|
|
197
|
+
await this.refresh() // 乐观并发失败:拉权威值,让下次写入带新 revision
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
throw new Error(`bridge mutate failed: ${body.code ?? 'unknown'} ${body.message ?? ''}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -223,6 +223,12 @@ function SelectRow<T extends string>(props: {
|
|
|
223
223
|
)
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
/** 卡面字段消毒:宿主 describe 形态异常(≤0.1.5 骨架残留等)时槽崩会殉葬全族
|
|
227
|
+
* tab——ListRow 的 values 非 nullish 非数组(如 {})会让 `?? []` 失守,逐卡硬护栏。 */
|
|
228
|
+
function toList(v: unknown): string[] {
|
|
229
|
+
return Array.isArray(v) ? v.map(String) : []
|
|
230
|
+
}
|
|
231
|
+
|
|
226
232
|
/** 插件配置卡片主体:可展开的 <li> + header 按钮 + pill switch 字段体。 */
|
|
227
233
|
export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
|
|
228
234
|
const snapshot = useSyncExternalStore(
|
|
@@ -303,7 +309,7 @@ export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
|
|
|
303
309
|
<ListRow
|
|
304
310
|
label="追加官方 provider id"
|
|
305
311
|
description="精确匹配,优先级最高(逗号分隔)"
|
|
306
|
-
values={value.officialProviders
|
|
312
|
+
values={toList(value.officialProviders)}
|
|
307
313
|
placeholder="deepseek-official"
|
|
308
314
|
disabled={readonly}
|
|
309
315
|
onCommit={(next) => { void scope.set('officialProviders', next) }}
|
|
@@ -311,7 +317,7 @@ export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
|
|
|
311
317
|
<ListRow
|
|
312
318
|
label="官方端点名单"
|
|
313
319
|
description="baseURL 归一化后的 host(逗号分隔)"
|
|
314
|
-
values={value.officialBaseURLs
|
|
320
|
+
values={toList(value.officialBaseURLs)}
|
|
315
321
|
placeholder="api.deepseek.com"
|
|
316
322
|
disabled={readonly}
|
|
317
323
|
onCommit={(next) => { void scope.set('officialBaseURLs', next) }}
|
|
@@ -319,7 +325,7 @@ export function SessionGuardCard({ scope }: SettingsCardProps): JSX.Element {
|
|
|
319
325
|
<SelectRow<'hold' | 'error'>
|
|
320
326
|
label="拦截方式"
|
|
321
327
|
description="挂起等待不报错;或报错并记入延后队列"
|
|
322
|
-
value={value.deferredMode
|
|
328
|
+
value={value.deferredMode === 'error' ? 'error' : 'hold'}
|
|
323
329
|
options={[
|
|
324
330
|
{ value: 'hold', label: '挂起等待(退峰自动放行)' },
|
|
325
331
|
{ value: 'error', label: '报错并延后(退峰续跑)' },
|
package/src/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { detectTaskControl } from './detect.js'
|
|
|
24
24
|
import { makeIsRoot } from './request-guard.js'
|
|
25
25
|
import { createWiring } from './wiring.js'
|
|
26
26
|
import { NS, DEFAULT_SETTINGS, SettingsSchema } from './settings.js'
|
|
27
|
+
import { registerSettingsBridgeRoutes } from './settings-bridge.js'
|
|
27
28
|
|
|
28
29
|
// 0.1.7:声明式设置 —— volatile 字段即自动表单。
|
|
29
30
|
export const Config = SettingsSchema
|
|
@@ -66,10 +67,13 @@ export function apply(ctx, config = {}) {
|
|
|
66
67
|
// 否则 client 配置卡报「设置命名空间不可用」。两条 legacy 路径按服务能力
|
|
67
68
|
// 自动分派:0.1.2/0.1.5 = 服务实例 installSection;0.1.0/0.1.1 = 模块级
|
|
68
69
|
// installSettingsSection + settingsNamespace。fail-open:失败仅告警。
|
|
70
|
+
/** 宿主 settings 服务活引用(桥的惰性 getter 用;apply 期注入解到即填)。 */
|
|
71
|
+
let settingsSvcRef
|
|
69
72
|
try {
|
|
70
73
|
ctx.inject(['settings'], (sctx) => {
|
|
71
74
|
const service = sctx.settings
|
|
72
75
|
if (service === undefined || service === null) return
|
|
76
|
+
settingsSvcRef = service
|
|
73
77
|
const hooks = {
|
|
74
78
|
setSource: (source) => {
|
|
75
79
|
configSource = typeof source === 'function' ? source() : source
|
|
@@ -460,4 +464,16 @@ export function apply(ctx, config = {}) {
|
|
|
460
464
|
}
|
|
461
465
|
}, 'session-guard: routes')
|
|
462
466
|
}
|
|
467
|
+
|
|
468
|
+
// ── settings webServer 桥(M4/T20-b 泛化):describe/mutate 两端点 ──────
|
|
469
|
+
// 双轨数据源的桥轨:≤0.1.5(settingsScope 死路/无服务)时 client
|
|
470
|
+
// BridgeDocHandle 经此读写宿主 settings 服务。getSettingsSvc 惰性取
|
|
471
|
+
// (cordis ctx.get 永不 throw)——宿主 settings 解析早晚不影响注册。
|
|
472
|
+
// 置于 prefix 路由之后(routes[0] = /session-guard 前缀,契约与测试锚定)。
|
|
473
|
+
// fail-open:注册失败 error 告警(对齐 L474 风格),不炸插件加载。
|
|
474
|
+
try {
|
|
475
|
+
registerSettingsBridgeRoutes(ctx.webServer, () => settingsSvcRef, NS)
|
|
476
|
+
} catch (error) {
|
|
477
|
+
ctx.logger?.error?.(`[session-guard] settings bridge registration failed: ${String(error && error.message || error)}`)
|
|
478
|
+
}
|
|
463
479
|
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-guard — settings webServer 桥(M4/T20-b 泛化)。
|
|
3
|
+
*
|
|
4
|
+
* 设置卡双轨数据源的桥轨:≤0.1.5(settingsScope fiber 永不解 / 0.1.0-0.1.1 无
|
|
5
|
+
* client 服务)时,client BridgeDocHandle 经 describe/mutate 两端点读写宿主
|
|
6
|
+
* settings 服务。桥只是 HTTP 皮:读 = svc.describe 取自家 ns 描述符;写 =
|
|
7
|
+
* svc.mutate 转 ops 落宿主服务(不落第二份状态);mutate 成功即 describe 回填,
|
|
8
|
+
* 带 expectedRevision 乐观并发。守卫:POST-only + redactSecrets + 256KB。
|
|
9
|
+
* 语义与 perm-gate src/bridge.ts(5260fe6 泛化)逐字对齐,本仓为 JS 形态。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** 一个 settings ns 的桥路由对——client 侧(src/client/settings-bridge.ts)必须同式派生(测试钉死一致)。 */
|
|
13
|
+
function settingsBridgeRoutesFor(ns) {
|
|
14
|
+
return { describe: `/api/${ns}/settings/describe`, mutate: `/api/${ns}/settings/mutate` }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readJsonBody(req, limit = 256 * 1024) {
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
const chunks = []
|
|
20
|
+
let size = 0
|
|
21
|
+
req.on('data', (chunk) => {
|
|
22
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
23
|
+
size += buf.length
|
|
24
|
+
if (size > limit) {
|
|
25
|
+
reject(new Error('bridge body too large'))
|
|
26
|
+
if (typeof req.resume === 'function') req.resume()
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
chunks.push(buf)
|
|
30
|
+
})
|
|
31
|
+
req.on('end', () => {
|
|
32
|
+
const raw = Buffer.concat(chunks).toString('utf8')
|
|
33
|
+
if (raw === '') {
|
|
34
|
+
resolve({})
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
resolve(JSON.parse(raw))
|
|
39
|
+
} catch (e) {
|
|
40
|
+
reject(e instanceof Error ? e : new Error(String(e)))
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
req.on('error', reject)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sendJson(res, code, body) {
|
|
48
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
|
|
49
|
+
res.end(JSON.stringify(body))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isConflict(e) {
|
|
53
|
+
return e instanceof Error && /conflict|revision/i.test(e.message)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function describeNs(svc, ns) {
|
|
57
|
+
return svc.describe({ redactSecrets: true }).find((d) => d.ns === ns)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 注册 describe/mutate 两端点(kind:'exact')。
|
|
62
|
+
* @param {unknown} server 宿主 webServer 服务(须有 register(kind,path,handler))
|
|
63
|
+
* @param {() => unknown} getSettingsSvc 惰性取宿主 settings 服务(cordis ctx.get 永不 throw,可安全常取)
|
|
64
|
+
* @param {string} ns 自家 settings 命名空间
|
|
65
|
+
* @returns {Array<() => void>} 注销函数数组
|
|
66
|
+
*/
|
|
67
|
+
export function registerSettingsBridgeRoutes(server, getSettingsSvc, ns) {
|
|
68
|
+
if (typeof server !== 'object' || server === null) return []
|
|
69
|
+
const ws = typeof server.register === 'function' ? server : undefined
|
|
70
|
+
if (ws === undefined) return []
|
|
71
|
+
const offs = []
|
|
72
|
+
const routes = settingsBridgeRoutesFor(ns)
|
|
73
|
+
|
|
74
|
+
offs.push(ws.register({
|
|
75
|
+
kind: 'exact',
|
|
76
|
+
path: routes.describe,
|
|
77
|
+
handler: (req, res) => {
|
|
78
|
+
if (req.method !== 'POST') {
|
|
79
|
+
sendJson(res, 405, { ok: false, code: 'method-not-allowed' })
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const svc = getSettingsSvc()
|
|
83
|
+
if (svc === undefined || svc === null || typeof svc.describe !== 'function') {
|
|
84
|
+
sendJson(res, 200, { ok: false, code: 'settings-unavailable', message: 'settings service not resolved yet' })
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const descriptor = describeNs(svc, ns)
|
|
89
|
+
if (descriptor === undefined) {
|
|
90
|
+
sendJson(res, 200, { ok: false, code: 'ns-not-registered', message: `settings namespace "${ns}" is not registered` })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
sendJson(res, 200, { ok: true, value: { descriptor, writable: svc.writable !== false } })
|
|
94
|
+
} catch (e) {
|
|
95
|
+
sendJson(res, 200, { ok: false, code: 'internal', message: e instanceof Error ? e.message : String(e) })
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
offs.push(ws.register({
|
|
101
|
+
kind: 'exact',
|
|
102
|
+
path: routes.mutate,
|
|
103
|
+
handler: (req, res) => {
|
|
104
|
+
if (req.method !== 'POST') {
|
|
105
|
+
sendJson(res, 405, { ok: false, code: 'method-not-allowed' })
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
const svc = getSettingsSvc()
|
|
109
|
+
if (svc === undefined || svc === null || typeof svc.mutate !== 'function') {
|
|
110
|
+
sendJson(res, 200, { ok: false, code: 'settings-unavailable', message: 'settings service mutate not available' })
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
readJsonBody(req).then((body) => {
|
|
114
|
+
const ops = body.ops
|
|
115
|
+
const valid = Array.isArray(ops) && ops.every((op) => op !== null && typeof op === 'object' && (op.op === 'set' || op.op === 'unset'))
|
|
116
|
+
if (!valid) {
|
|
117
|
+
sendJson(res, 200, { ok: false, code: 'settings-rejected', message: 'malformed bridge mutate ops' })
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
const expectedRevision = typeof body.expectedRevision === 'number' ? body.expectedRevision : undefined
|
|
121
|
+
svc.mutate(ns, ops, expectedRevision).then(() => {
|
|
122
|
+
// mutate 成功即 describe 回填(写路径归口宿主服务,无第二份状态)
|
|
123
|
+
const descriptor = describeNs(svc, ns)
|
|
124
|
+
if (descriptor === undefined) {
|
|
125
|
+
sendJson(res, 200, { ok: false, code: 'internal', message: `namespace "${ns}" disposed after mutate` })
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
sendJson(res, 200, { ok: true, value: { descriptor } })
|
|
129
|
+
}).catch((e) => {
|
|
130
|
+
sendJson(res, 200, isConflict(e)
|
|
131
|
+
? { ok: false, code: 'settings-conflict', message: e instanceof Error ? e.message : String(e) }
|
|
132
|
+
: { ok: false, code: 'internal', message: e instanceof Error ? e.message : String(e) })
|
|
133
|
+
})
|
|
134
|
+
}).catch((e) => {
|
|
135
|
+
sendJson(res, 200, { ok: false, code: 'bad-request', message: e instanceof Error ? e.message : String(e) })
|
|
136
|
+
})
|
|
137
|
+
},
|
|
138
|
+
}))
|
|
139
|
+
|
|
140
|
+
return offs
|
|
141
|
+
}
|
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-session-guard — 无设置页宿主(0.1.0/0.1.1)的兜底配置入口。
|
|
3
|
-
*
|
|
4
|
-
* 壳级 `shell.overlay` 席位渲染:右下角固定齿轮钮,点开固定定位浮层承载
|
|
5
|
-
* SessionGuardCard(page 形态,默认展开)。配置存储全在 host 侧(settings
|
|
6
|
-
* 服务),浮层与宿主设置页解耦——壳再老也不影响读写。有设置页的宿主上
|
|
7
|
-
* 该齿轮与行配置卡/家族 tab 并存,作为全局快捷入口。
|
|
8
|
-
*/
|
|
9
|
-
import { useState } from 'react'
|
|
10
|
-
import { SessionGuardCard } from './settings-card'
|
|
11
|
-
import type { SessionGuardConfig } from './settings-card'
|
|
12
|
-
import type { SettingsScope } from './scope-face'
|
|
13
|
-
|
|
14
|
-
export function ShellSettingsEntry(props: {
|
|
15
|
-
scope: SettingsScope<SessionGuardConfig>
|
|
16
|
-
}): JSX.Element {
|
|
17
|
-
const [open, setOpen] = useState(false)
|
|
18
|
-
return (
|
|
19
|
-
<div style={{ position: 'fixed', right: 16, bottom: 64, zIndex: 10000 }}>
|
|
20
|
-
{open && (
|
|
21
|
-
<div
|
|
22
|
-
onClick={() => setOpen(false)}
|
|
23
|
-
style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,.45)', zIndex: 10000 }}
|
|
24
|
-
/>
|
|
25
|
-
)}
|
|
26
|
-
{open && (
|
|
27
|
-
<div
|
|
28
|
-
style={{
|
|
29
|
-
position: 'fixed',
|
|
30
|
-
top: '6vh',
|
|
31
|
-
left: '50%',
|
|
32
|
-
transform: 'translateX(-50%)',
|
|
33
|
-
width: 'min(760px, 92vw)',
|
|
34
|
-
maxHeight: '84vh',
|
|
35
|
-
overflowY: 'auto',
|
|
36
|
-
zIndex: 10001,
|
|
37
|
-
}}
|
|
38
|
-
>
|
|
39
|
-
<button
|
|
40
|
-
type="button"
|
|
41
|
-
onClick={() => setOpen(false)}
|
|
42
|
-
style={{ position: 'sticky', top: 6, float: 'right', marginRight: 8, zIndex: 2 }}
|
|
43
|
-
>
|
|
44
|
-
✕ 关闭
|
|
45
|
-
</button>
|
|
46
|
-
<SessionGuardCard scope={props.scope} />
|
|
47
|
-
</div>
|
|
48
|
-
)}
|
|
49
|
-
<button
|
|
50
|
-
type="button"
|
|
51
|
-
title="会话守护设置"
|
|
52
|
-
onClick={() => setOpen(state => !state)}
|
|
53
|
-
style={{
|
|
54
|
-
width: 40,
|
|
55
|
-
height: 40,
|
|
56
|
-
borderRadius: '50%',
|
|
57
|
-
border: '1px solid rgba(127,127,127,.4)',
|
|
58
|
-
background: 'rgba(30,30,32,.85)',
|
|
59
|
-
color: 'inherit',
|
|
60
|
-
cursor: 'pointer',
|
|
61
|
-
fontSize: 16,
|
|
62
|
-
boxShadow: '0 4px 16px rgba(0,0,0,.35)',
|
|
63
|
-
}}
|
|
64
|
-
>
|
|
65
|
-
🛡
|
|
66
|
-
</button>
|
|
67
|
-
</div>
|
|
68
|
-
)
|
|
69
|
-
}
|