@mzzsfy/dsh-keep-working 0.0.0-stage → 0.1.0
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 +84 -2
- package/cordis.patch.yml +4 -0
- package/package.json +49 -4
- package/src/client.js +366 -0
- package/src/closure.mjs +182 -0
- package/src/config.mjs +103 -0
- package/src/engine.mjs +240 -0
- package/src/failure.mjs +84 -0
- package/src/index.js +259 -0
- package/src/offline.mjs +69 -0
- package/src/route-gate.mjs +29 -0
- package/src/route-guard.mjs +35 -0
- package/src/scan.mjs +425 -0
- package/src/session-kind.mjs +7 -0
- package/src/settings.mjs +59 -0
- package/test/client-id.test.mjs +34 -0
- package/test/client-panel.test.mjs +101 -0
- package/test/closure.test.mjs +302 -0
- package/test/config.test.mjs +24 -0
- package/test/engine.test.mjs +436 -0
- package/test/failure.test.mjs +81 -0
- package/test/offline.test.mjs +85 -0
- package/test/prompt-section.test.mjs +25 -0
- package/test/route-gate.test.mjs +57 -0
- package/test/route-guard.test.mjs +51 -0
- package/test/route.test.mjs +210 -0
- package/test/scan.test.mjs +685 -0
- package/test/settings.test.mjs +52 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 持续工作:AI 未声明结案就结束回合时,补一条用户消息把它拉回。
|
|
3
|
+
*
|
|
4
|
+
* 装配层只做四件事:注入提示词规则、挂上恢复引擎、冷启动扫描存储会话、
|
|
5
|
+
* 暴露设置面板。判定与调度分别住在 closure / engine / scan。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import z from '@deepseek-ai/schemastery'
|
|
9
|
+
|
|
10
|
+
import { DEFAULTS, resolveConfig } from './config.mjs'
|
|
11
|
+
import { createEngine } from './engine.mjs'
|
|
12
|
+
import { probeConnectivity } from './offline.mjs'
|
|
13
|
+
import { guardWrite } from './route-guard.mjs'
|
|
14
|
+
import { withHostGate } from './route-gate.mjs'
|
|
15
|
+
import { scanSettled, scanStoredSessions, trace } from './scan.mjs'
|
|
16
|
+
import { NAMESPACE, PANEL_FIELDS, panelDefaults, readSettings, writeSettings } from './settings.mjs'
|
|
17
|
+
|
|
18
|
+
export const name = 'keep-working'
|
|
19
|
+
|
|
20
|
+
/** cordis FiberState.ACTIVE;卸载后微任务可能才跑到,需按数值判定。 */
|
|
21
|
+
const FIBER_STATE_ACTIVE = 2
|
|
22
|
+
|
|
23
|
+
/** 缺 agents 或 systemPrompt 时干净禁用:不注册任何服务与提示词。 */
|
|
24
|
+
export const inject = ['agents', 'systemPrompt']
|
|
25
|
+
|
|
26
|
+
export const SECTION_NAME = 'keep-working:closure'
|
|
27
|
+
|
|
28
|
+
export const PROMPT_TEXT =
|
|
29
|
+
'结束必须显式提交:没改文件末行追加 (TASK_DONE);任务阻塞末行追加 (TASK_BLOCKED: 原因);改过文件必须最后只使用 present;禁止同时present和(TASK_DONE);present 的时机以本段为准,交付产物本身就是 present 的用途,不受"不要只为列出源文件调用 present"约束'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 结案段落必须排在宿主的文件引用建议之后:那段写着"不要只为列出源文件调
|
|
33
|
+
* present",结案规则压在它后面才不会被读成例外。
|
|
34
|
+
*/
|
|
35
|
+
export function buildSection(systemPrompt) {
|
|
36
|
+
return {
|
|
37
|
+
name: SECTION_NAME,
|
|
38
|
+
order: systemPrompt.getSectionOrder('DELIVERABLE_FILE_REFERENCES') + 1,
|
|
39
|
+
text: PROMPT_TEXT,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 冷启动等宿主服务就绪再扫,避免与 profile 装配抢时序。 */
|
|
44
|
+
const SCAN_DELAY_MS = 5_000
|
|
45
|
+
|
|
46
|
+
/** 宿主服务尚未就绪时按此间隔重试;工作区注册表通常在数秒内完成异步加载。 */
|
|
47
|
+
const SCAN_RETRY_MS = 1_000
|
|
48
|
+
|
|
49
|
+
/** 面板请求体上限,防止畸形请求撑爆内存。 */
|
|
50
|
+
const BODY_LIMIT_BYTES = 64 * 1024
|
|
51
|
+
|
|
52
|
+
export const Config = z
|
|
53
|
+
.object({
|
|
54
|
+
graceMs: z.number().default(DEFAULTS.graceMs).volatile(),
|
|
55
|
+
cooldownMs: z.number().default(DEFAULTS.cooldownMs).volatile(),
|
|
56
|
+
maxConsecutive: z.number().default(DEFAULTS.maxConsecutive).volatile(),
|
|
57
|
+
backoffFactor: z.number().default(DEFAULTS.backoffFactor).volatile(),
|
|
58
|
+
backoffMaxMs: z.number().default(DEFAULTS.backoffMaxMs).volatile(),
|
|
59
|
+
scanOnBoot: z.boolean().default(DEFAULTS.scanOnBoot).volatile(),
|
|
60
|
+
scanLimit: z.number().default(DEFAULTS.scanLimit).volatile(),
|
|
61
|
+
freshMs: z.number().default(DEFAULTS.freshMs).volatile(),
|
|
62
|
+
resumeGoals: z.boolean().default(DEFAULTS.resumeGoals).volatile(),
|
|
63
|
+
resumePausedGoals: z.boolean().default(DEFAULTS.resumePausedGoals).volatile(),
|
|
64
|
+
resumeSilentTurns: z.boolean().default(DEFAULTS.resumeSilentTurns).volatile(),
|
|
65
|
+
pauseWhenOffline: z.boolean().default(DEFAULTS.pauseWhenOffline).volatile(),
|
|
66
|
+
offlineCheckMs: z.number().default(DEFAULTS.offlineCheckMs).volatile(),
|
|
67
|
+
classify: z.boolean().default(DEFAULTS.classify).volatile(),
|
|
68
|
+
retryableErrorPatterns: z.string().default(DEFAULTS.retryableErrorPatterns).volatile(),
|
|
69
|
+
paused: z.boolean().default(DEFAULTS.paused).volatile(),
|
|
70
|
+
continueText: z.string().default(DEFAULTS.continueText).volatile(),
|
|
71
|
+
continueTextMaxTokens: z.string().default(DEFAULTS.continueTextMaxTokens).volatile(),
|
|
72
|
+
continueTextSilent: z.string().default(DEFAULTS.continueTextSilent).volatile(),
|
|
73
|
+
continueTextInterrupted: z.string().default(DEFAULTS.continueTextInterrupted).volatile(),
|
|
74
|
+
closureText: z.string().default(DEFAULTS.closureText).volatile(),
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
function sendJson(res, status, payload) {
|
|
78
|
+
res.statusCode = status
|
|
79
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
80
|
+
res.end(JSON.stringify(payload))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function readJsonBody(req) {
|
|
84
|
+
const chunks = []
|
|
85
|
+
let size = 0
|
|
86
|
+
for await (const chunk of req) {
|
|
87
|
+
size += chunk.length
|
|
88
|
+
if (size > BODY_LIMIT_BYTES) throw new Error('请求体过大')
|
|
89
|
+
chunks.push(chunk)
|
|
90
|
+
}
|
|
91
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
92
|
+
return text === '' ? {} : JSON.parse(text)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function apply(ctx, config) {
|
|
96
|
+
let createUserMessage
|
|
97
|
+
try {
|
|
98
|
+
;({ createUserMessage } = await import('@deepseek-ai/dsh-llm'))
|
|
99
|
+
} catch (error) {
|
|
100
|
+
ctx.logger.warn(
|
|
101
|
+
`[keep-working] 消息构造器不可用,插件停用: ${error instanceof Error ? error.message : String(error)}`,
|
|
102
|
+
)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let runtime
|
|
107
|
+
// 本进程已裁决过的存储会话,跨扫描轮次去重
|
|
108
|
+
const seenSessions = new Set()
|
|
109
|
+
|
|
110
|
+
const stopRuntime = () => {
|
|
111
|
+
if (runtime === undefined) return
|
|
112
|
+
if (runtime.scanTimer !== undefined) clearTimeout(runtime.scanTimer)
|
|
113
|
+
runtime.engine.dispose()
|
|
114
|
+
runtime.section?.()
|
|
115
|
+
runtime = undefined
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const startRuntime = () => {
|
|
119
|
+
stopRuntime()
|
|
120
|
+
const resolved = resolveConfig(config, readSettings(ctx))
|
|
121
|
+
|
|
122
|
+
const promptApi = ctx.get('systemPrompt')
|
|
123
|
+
const canRegisterSection =
|
|
124
|
+
typeof promptApi?.section === 'function' && typeof promptApi?.getSectionOrder === 'function'
|
|
125
|
+
const section = canRegisterSection ? promptApi.section(buildSection(promptApi)) : undefined
|
|
126
|
+
if (!canRegisterSection) {
|
|
127
|
+
ctx.logger?.warn?.('[keep-working] 宿主缺少 prompt 段落接口,仅恢复不注入结案规则')
|
|
128
|
+
}
|
|
129
|
+
const engine = createEngine({
|
|
130
|
+
ctx,
|
|
131
|
+
config: resolved,
|
|
132
|
+
createUserMessage,
|
|
133
|
+
probe: resolved.pauseWhenOffline ? probeConnectivity : undefined,
|
|
134
|
+
})
|
|
135
|
+
engine.attach()
|
|
136
|
+
|
|
137
|
+
const state = { section, engine, scanTimer: undefined }
|
|
138
|
+
runtime = state
|
|
139
|
+
|
|
140
|
+
const scanDeadline = Date.now() + resolved.freshMs
|
|
141
|
+
let scanFailureWarned = false
|
|
142
|
+
const runScan = async () => {
|
|
143
|
+
try {
|
|
144
|
+
const result = await scanStoredSessions({ ctx, config: resolved, createUserMessage, seen: seenSessions })
|
|
145
|
+
if (scanSettled(result)) {
|
|
146
|
+
trace(`扫描收工(source=${result.source ?? 'none'}, listed=${result.listed})`)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
} catch (error) {
|
|
150
|
+
trace(`扫描异常 ${error instanceof Error ? error.message : String(error)}`)
|
|
151
|
+
// 重试间隔是秒级,按轮告警会刷屏;同一轮重试窗口里只报第一次
|
|
152
|
+
if (!scanFailureWarned) {
|
|
153
|
+
scanFailureWarned = true
|
|
154
|
+
ctx.logger?.warn?.(
|
|
155
|
+
`[keep-working] 启动扫描失败: ${error instanceof Error ? error.message : String(error)}`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (Date.now() >= scanDeadline || runtime !== state) return
|
|
160
|
+
state.scanTimer = setTimeout(runScan, SCAN_RETRY_MS)
|
|
161
|
+
state.scanTimer.unref?.()
|
|
162
|
+
}
|
|
163
|
+
if (resolved.scanOnBoot && !resolved.paused) {
|
|
164
|
+
state.scanTimer = setTimeout(runScan, SCAN_DELAY_MS)
|
|
165
|
+
state.scanTimer.unref?.()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
startRuntime()
|
|
170
|
+
|
|
171
|
+
let rebuildQueued = false
|
|
172
|
+
const scheduleRebuild = () => {
|
|
173
|
+
if (rebuildQueued) return
|
|
174
|
+
rebuildQueued = true
|
|
175
|
+
// 事件由设置服务在 update 内部同步发出,直接重建会在服务栈里重入 describe
|
|
176
|
+
queueMicrotask(() => {
|
|
177
|
+
rebuildQueued = false
|
|
178
|
+
if (ctx.fiber?.state !== FIBER_STATE_ACTIVE) return
|
|
179
|
+
try {
|
|
180
|
+
startRuntime()
|
|
181
|
+
} catch (error) {
|
|
182
|
+
// 渲染期异常不能冒成未捕获异常拖垮宿主,保留告警并停下自动恢复
|
|
183
|
+
ctx.logger?.warn?.(`[keep-working] 设置变更后重建失败: ${error instanceof Error ? error.message : String(error)}`)
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
ctx.effect(() => {
|
|
188
|
+
const offVolatile = ctx.on('loader/volatile-update', scheduleRebuild)
|
|
189
|
+
return () => offVolatile?.()
|
|
190
|
+
}, 'keep-working volatile reload')
|
|
191
|
+
|
|
192
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
193
|
+
// 面板字段由本插件自绘,关掉宿主按 schema 自动生成的表单
|
|
194
|
+
settingsCtx.effect(
|
|
195
|
+
() => settingsCtx.settings?.configure?.({ auto: false }, ctx.fiber),
|
|
196
|
+
'keep-working settings presentation',
|
|
197
|
+
)
|
|
198
|
+
// 设置写入后 cordis Config 的 volatile 字段随之更新,重建运行期以采用新值
|
|
199
|
+
// 部分宿主版本只发 settings 事件而不发 volatile-update,两条路都接,微任务合批
|
|
200
|
+
settingsCtx.effect(() => {
|
|
201
|
+
const offSettings = settingsCtx.on('settings/document-updated', (namespace) => {
|
|
202
|
+
if (namespace === NAMESPACE) scheduleRebuild()
|
|
203
|
+
})
|
|
204
|
+
return () => offSettings?.()
|
|
205
|
+
}, 'keep-working settings reload')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
ctx.inject(['webServer'], (serverCtx) => {
|
|
209
|
+
serverCtx.effect(
|
|
210
|
+
() =>
|
|
211
|
+
serverCtx.webServer.register({
|
|
212
|
+
kind: 'exact',
|
|
213
|
+
path: '/api/keep-working/config',
|
|
214
|
+
handler: withHostGate(serverCtx, async (req, res) => {
|
|
215
|
+
if (req.method === 'GET') {
|
|
216
|
+
sendJson(res, 200, { ok: true, value: resolveConfig(config, readSettings(ctx)), defaults: panelDefaults() })
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
if (req.method !== 'POST') {
|
|
220
|
+
sendJson(res, 405, { ok: false, error: 'method not allowed' })
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
if (guardWrite(req, res, sendJson)) return
|
|
224
|
+
let patch
|
|
225
|
+
try {
|
|
226
|
+
patch = await readJsonBody(req)
|
|
227
|
+
} catch (error) {
|
|
228
|
+
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
232
|
+
sendJson(res, 400, { ok: false, error: '请求体必须是对象' })
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
const allowed = new Set(PANEL_FIELDS)
|
|
236
|
+
const clean = Object.fromEntries(
|
|
237
|
+
Object.entries(patch).filter(([key, value]) => allowed.has(key) && value !== undefined),
|
|
238
|
+
)
|
|
239
|
+
try {
|
|
240
|
+
if (!(await writeSettings(ctx, clean))) {
|
|
241
|
+
sendJson(res, 503, { ok: false, error: '设置服务不可用' })
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
} catch (error) {
|
|
245
|
+
// 宿主拒绝写入:修订冲突可重试,条目缺失说明插件没被当成可配置项
|
|
246
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
247
|
+
const status = error?.code === 'SETTINGS_CONFLICT' ? 409 : 400
|
|
248
|
+
sendJson(res, status, { ok: false, error: message })
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
sendJson(res, 200, { ok: true, value: resolveConfig(config, readSettings(ctx)) })
|
|
252
|
+
}),
|
|
253
|
+
}),
|
|
254
|
+
'keep-working config route',
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
ctx.effect(() => () => stopRuntime(), 'keep-working lifecycle')
|
|
259
|
+
}
|
package/src/offline.mjs
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 断网判定:断网时续跑必然再失败一次,先挂起等联网可省掉一轮额度。
|
|
3
|
+
*
|
|
4
|
+
* 只做 DNS 解析,不建连接、不发请求。走 fake-ip / TUN 代理时解析总会成功,
|
|
5
|
+
* 结果是放行,与关闭本功能等价,不会误挂起。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import dns from 'node:dns'
|
|
9
|
+
import os from 'node:os'
|
|
10
|
+
|
|
11
|
+
export const PROBE_HOSTS = ['api.deepseek.com', 'www.baidu.com', 'one.one.one.one']
|
|
12
|
+
export const PROBE_TIMEOUT_MS = 3_000
|
|
13
|
+
|
|
14
|
+
/** 任一非内部接口即视为有网卡;读不到网卡信息时放行。 */
|
|
15
|
+
export function hasExternalInterface(read = () => os.networkInterfaces()) {
|
|
16
|
+
try {
|
|
17
|
+
const interfaces = read() ?? {}
|
|
18
|
+
return Object.values(interfaces).some(
|
|
19
|
+
(entries) => Array.isArray(entries) && entries.some((entry) => entry?.internal === false),
|
|
20
|
+
)
|
|
21
|
+
} catch {
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 显式代理下本地 DNS 可能本就不可用,只看网卡,避免永久误判。 */
|
|
27
|
+
export function usesExplicitProxy(env = process.env) {
|
|
28
|
+
return [env.HTTPS_PROXY, env.https_proxy, env.HTTP_PROXY, env.http_proxy, env.ALL_PROXY, env.all_proxy]
|
|
29
|
+
.some((value) => typeof value === 'string' && value.trim() !== '')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lookupWithin(host, timeoutMs, lookup) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const timer = setTimeout(() => reject(new Error(`dns lookup timed out: ${host}`)), timeoutMs)
|
|
35
|
+
timer.unref?.()
|
|
36
|
+
lookup(host).then(
|
|
37
|
+
(value) => {
|
|
38
|
+
clearTimeout(timer)
|
|
39
|
+
resolve(value)
|
|
40
|
+
},
|
|
41
|
+
(error) => {
|
|
42
|
+
clearTimeout(timer)
|
|
43
|
+
reject(error)
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 构造探针;依赖可注入,便于测试。结果只可能是 true / false,永不 reject。 */
|
|
50
|
+
export function createConnectivityProbe({
|
|
51
|
+
lookup = dns.promises.lookup,
|
|
52
|
+
interfaces = os.networkInterfaces,
|
|
53
|
+
env = process.env,
|
|
54
|
+
hosts = PROBE_HOSTS,
|
|
55
|
+
timeoutMs = PROBE_TIMEOUT_MS,
|
|
56
|
+
} = {}) {
|
|
57
|
+
return async () => {
|
|
58
|
+
if (!hasExternalInterface(interfaces)) return false
|
|
59
|
+
if (usesExplicitProxy(env)) return true
|
|
60
|
+
try {
|
|
61
|
+
await Promise.any(hosts.map((host) => lookupWithin(host, timeoutMs, lookup)))
|
|
62
|
+
return true
|
|
63
|
+
} catch {
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const probeConnectivity = createConnectivityProbe()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主闸门复用。宿主把认证挂在它自己的 `/api` prefix 路由里,而 responder 选择是
|
|
3
|
+
* 「exact 先、最长 prefix 后」——插件注册的 `/api/<pkg>` 更长,先命中,`connection.admit`
|
|
4
|
+
* 从不执行。所以插件路由必须自己回问一次宿主闸门,否则无 cookie 的调用直接进业务面。
|
|
5
|
+
*
|
|
6
|
+
* 服务缺失(非 web 传输、旧宿主)时放行:与原行为一致,不新增自有认证层。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function rejectUnadmitted(ctx, req, res) {
|
|
10
|
+
let rejection
|
|
11
|
+
try {
|
|
12
|
+
const connection = typeof ctx?.get === 'function' ? ctx.get('connection') : undefined
|
|
13
|
+
rejection = connection?.requestRejection?.(req)
|
|
14
|
+
} catch {
|
|
15
|
+
// 闸门自身异常不放行:宁可拒绝,不把故障当认证通过
|
|
16
|
+
rejection = 403
|
|
17
|
+
}
|
|
18
|
+
if (rejection === undefined) return false
|
|
19
|
+
res.writeHead(rejection, { 'content-type': 'text/plain; charset=utf-8' })
|
|
20
|
+
res.end(rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function withHostGate(ctx, handler) {
|
|
25
|
+
return async (req, res) => {
|
|
26
|
+
if (rejectUnadmitted(ctx, req, res)) return
|
|
27
|
+
await handler(req, res)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 写路由守卫(与全仓同构):浏览器跨站表单可发 text/plain 且载荷为合法 JSON,
|
|
3
|
+
* 仅方法守卫不足以阻断 drive-by 写,补同源比对与 JSON 内容类型双校验。
|
|
4
|
+
*
|
|
5
|
+
* 两条放行口径与 dsh-session-manager 一致:
|
|
6
|
+
* - 无 Origin 的非浏览器调用(本地 CLI/脚本)放行
|
|
7
|
+
* - 完全不带 content-type 的调用放行
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export function rejectCrossOrigin(req, res, sendJson) {
|
|
11
|
+
const origin = req.headers ? req.headers.origin : undefined
|
|
12
|
+
if (!origin) return false
|
|
13
|
+
let sameOrigin = false
|
|
14
|
+
try {
|
|
15
|
+
const host = req.headers.host
|
|
16
|
+
sameOrigin = typeof host === 'string' && host.length > 0 && new URL(origin).host === host.toLowerCase()
|
|
17
|
+
} catch {
|
|
18
|
+
sameOrigin = false
|
|
19
|
+
}
|
|
20
|
+
if (sameOrigin) return false
|
|
21
|
+
sendJson(res, 403, { ok: false, error: '跨源请求被拒绝' })
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function rejectNonJson(req, res, sendJson) {
|
|
26
|
+
const type = req.headers ? req.headers['content-type'] : undefined
|
|
27
|
+
if (typeof type !== 'string' || type.trim() === '') return false
|
|
28
|
+
if (type.split(';')[0].trim().toLowerCase() === 'application/json') return false
|
|
29
|
+
sendJson(res, 415, { ok: false, error: '仅接受 application/json 请求体' })
|
|
30
|
+
return true
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function guardWrite(req, res, sendJson) {
|
|
34
|
+
return rejectCrossOrigin(req, res, sendJson) || rejectNonJson(req, res, sendJson)
|
|
35
|
+
}
|