@mzzsfy/dsh-cron-board 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 ADDED
@@ -0,0 +1,55 @@
1
+ # @mzzsfy/dsh-cron-board
2
+
3
+ 轻量级青龙面板 dsh 插件:环境变量集中管理 + 定时任务(cron)调度,执行体支持两类——本地脚本(dsh 宿主进程内 spawn)与 dsh 会话任务(定时在指定时间段向 dsh 会话发起任务,支持「每次新建会话」或「固定同一会话」)。
4
+
5
+ 设计文档见仓库 `docs/progress/feat-cron-board.md`(架构 / 数据模型 / API 契约 / BDD 场景 / 里程碑)。
6
+
7
+ ## 功能
8
+
9
+ - **看板**:任务卡片列表(状态色/下次触发相对时间/启停开关/立即运行/编辑/删除),新建编辑模态支持 shell 与会话两类任务、cron 预设与服务端实时预览(人话摘要 + 下三次触发)
10
+ - **环境变量**:集中管理,值打码展示,多值(同名展开笛卡尔积,组合数超出 20 截断并告警),dotenv 文本导入(预览/追加/覆盖)与导出
11
+ - **会话任务**:fresh 每次新建会话;pinned 固定会话(忙时跳过,会话丢失自动重建并回写);允许时段(支持跨零点)外按策略跳过或顺延
12
+ - **调度**:croner 解析,停机期间错过不补跑(misfire 即跳过),孤儿运行恢复,全局与任务级并发闸门,超时收尾
13
+ - **日志**:每任务每运行独立日志文件,面板查看与清空,按任务保留条数裁剪(按文件修改时间)
14
+
15
+ ## 路由
16
+
17
+ 前缀 `/api/cron-board`(envs / jobs / runs / status / cron/preview),REST 风格,中文业务错误透传。
18
+
19
+ ## 界面挂载
20
+
21
+ 三形态自适应,运行时自动切换:
22
+
23
+ | 场景 | 形态 |
24
+ |---|---|
25
+ | 未安装 dsh-better-sidebar | 主界面:原生侧栏「定时任务」入口 + 中心列看板(激活时隐藏会话区,面板互斥) |
26
+ | 已安装且当前会话打开了「定时任务」页签 | 扩展槽:better-sidebar tab 承载看板,主界面形态拆除 |
27
+ | 已安装但页签未打开(未开过 / 被用户关闭 / 类型在 side card 设置中被禁用) | 主界面形态接管 |
28
+
29
+ 页签开关实时联动(subscribeState),无需刷新;扩展槽缺失时主界面形态即唯一入口。
30
+
31
+ ## 配置
32
+
33
+ 设置页「定时任务」分区(`cron-board` 命名空间),四项均即时生效:
34
+
35
+ | 配置项 | 说明 | 默认值 |
36
+ |---|---|---|
37
+ | `tickSeconds` | 调度轮询周期(秒,最小 5),变更后自动按新周期重建定时器 | 30 |
38
+ | `maxConcurrent` | 全局同时运行的执行单元上限 | 2 |
39
+ | `logKeepPerJob` | 每任务保留的运行日志份数(超出按文件修改时间裁剪) | 200 |
40
+ | `maskEnvInPrompt` | 会话任务投递文本中环境变量值打码 | 关 |
41
+
42
+ ## 依赖
43
+
44
+ - `croner`:cron 表达式解析与触发点计算
45
+ - 宿主服务:settings / timer(软依赖)、webServer、sessionController(硬依赖,cordis 门控——缺失时插件整体不激活,含脚本任务)
46
+
47
+ ## 开发
48
+
49
+ ```sh
50
+ node scripts/dev-link.mjs dsh-cron-board # 挂载工作副本到 profile
51
+ cd packages/dsh-cron-board
52
+ node --test "test/*.test.mjs" # 测试
53
+ ```
54
+
55
+ 注意:插件新增进入 profile bundles(`dsh plugin add`)后需 dsh 重启一次完成首装载;此后工作副本改动经 dev-link HMR 热重载。
@@ -0,0 +1,5 @@
1
+ # dsh-cron-board 打包补丁:将定时任务面板插件以单条目注入 profile 根。由 `dsh plugin add @mzzsfy/dsh-cron-board` 应用;
2
+ # 条目 id 指向本插件宿主半区,/api/cron-board/* 路由由此宿主条目 provide,client 半区随宿主条目进入客户端模块表。
3
+ - insert:
4
+ - id: cron-board
5
+ name: '@mzzsfy/dsh-cron-board'
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@mzzsfy/dsh-cron-board",
3
+ "description": "轻量级青龙面板:环境变量集中管理与定时任务调度,执行体支持本地脚本与 dsh 会话任务",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./client": "./src/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "dsh": {
13
+ "bundle": {
14
+ "patch": "./cordis.patch.yml"
15
+ },
16
+ "client": {
17
+ "platform": "web"
18
+ }
19
+ },
20
+ "scripts": {
21
+ "test": "node --test \"test/*.test.mjs\"",
22
+ "npmPublish": "npm publish --access public --provenance=false --registry=https://registry.npmjs.org"
23
+ },
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/mzzsfy/dsh-plugin.git"
28
+ },
29
+ "engines": {
30
+ "node": ">=22"
31
+ },
32
+ "dependencies": {
33
+ "croner": "^10.0.1"
34
+ },
35
+ "peerDependencies": {
36
+ "@deepseek-ai/schemastery": ">=3.18.0"
37
+ },
38
+ "files": [
39
+ "src",
40
+ "test",
41
+ "cordis.patch.yml",
42
+ "README.md"
43
+ ]
44
+ }
package/src/api.mjs ADDED
@@ -0,0 +1,434 @@
1
+ // api:REST 路由 /api/cron-board/*。宿主 webServer 仅支持 exact/prefix 注册,
2
+ // 本插件注册一条 prefix,handler 内按 method + 路径段表驱动分发(:id 路径段捕获);
3
+ // 错误语义对齐仓内惯例:业务错误中文透传,系统级错误收敛固定文案并落服务端日志。
4
+
5
+ import { assertValidSchedule, nextRunAtOf, nextRunsOf, summarizeCron } from './cron.mjs'
6
+ import { DEFAULT_MAX_EXPANSION, DEFAULT_TIMEOUT_MS } from './config.mjs'
7
+ import { maskValue } from './env-text.mjs'
8
+
9
+ const ROUTE_PREFIX = '/api/cron-board'
10
+ const BODY_MAX_BYTES = 64 * 1024
11
+
12
+ export const MESSAGES = {
13
+ notFound: '接口不存在',
14
+ methodNotAllowed: '请求方法不支持',
15
+ badJsonBody: '请求体不是合法 JSON',
16
+ bodyTooLarge: '请求体超过上限',
17
+ nameRequired: '变量名不能为空',
18
+ jobNameRequired: '任务名称不能为空',
19
+ commandRequired: '任务命令不能为空',
20
+ badKind: '任务类型不合法',
21
+ badSessionMode: '会话模式仅支持 fresh 或 pinned',
22
+ badOnMiss: '窗口外策略仅支持 skip 或 defer',
23
+ windowPairRequired: '窗口起止时间必须成对填写',
24
+ badWindowFormat: '窗口时间格式须为 HH:mm',
25
+ jobNotFound: '任务不存在',
26
+ runNotFound: '运行记录不存在',
27
+ systemError: '操作失败(系统级错误,详见服务端日志)',
28
+ }
29
+
30
+ function sendJson(res, status, payload) {
31
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
32
+ res.end(JSON.stringify(payload))
33
+ }
34
+
35
+ function respondError(res, error, logSystem) {
36
+ const isSystem = Boolean(error && typeof error.code === 'string' && error.code !== '')
37
+ if (isSystem && logSystem) logSystem('[cron-board] ' + String(error && error.stack || error))
38
+ const message = isSystem ? MESSAGES.systemError : (error && error.message ? error.message : String(error))
39
+ sendJson(res, 400, { error: message })
40
+ }
41
+
42
+ function readBody(req) {
43
+ return new Promise((resolve, reject) => {
44
+ let size = 0
45
+ const chunks = []
46
+ req.on('data', (chunk) => {
47
+ size += chunk.length
48
+ if (size > BODY_MAX_BYTES) {
49
+ reject(new Error(MESSAGES.bodyTooLarge))
50
+ req.destroy()
51
+ return
52
+ }
53
+ chunks.push(chunk)
54
+ })
55
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
56
+ req.on('error', reject)
57
+ })
58
+ }
59
+
60
+ async function readJsonBody(req) {
61
+ let body
62
+ try {
63
+ body = JSON.parse(await readBody(req))
64
+ } catch (error) {
65
+ if (error && error.message === MESSAGES.bodyTooLarge) throw error
66
+ throw new Error(MESSAGES.badJsonBody)
67
+ }
68
+ return body && typeof body === 'object' ? body : {}
69
+ }
70
+
71
+ // 多值归一:values 数组(客户端契约)按行折叠进 value 存储形态(单行 + multi 标志,
72
+ // 展开时拆行);数组缺失或非多值回退原 value 字符串语义
73
+ function normalizeEnvValue(body, multi) {
74
+ if (multi && Array.isArray(body.values)) {
75
+ return body.values
76
+ .map((item) => String(item).trim())
77
+ .filter((line) => line !== '')
78
+ .join('\n')
79
+ }
80
+ return typeof body.value === 'string' ? body.value : ''
81
+ }
82
+
83
+ // 导入文本解析:每行 NAME=value 或 NAME=value #备注;空行跳过,无 = 记非法
84
+ export function parseEnvText(text) {
85
+ const rows = []
86
+ let invalid = 0
87
+ for (const rawLine of String(text).split(/\r?\n/)) {
88
+ const line = rawLine.trim()
89
+ if (line === '') continue
90
+ const eq = line.indexOf('=')
91
+ if (eq <= 0) {
92
+ invalid++
93
+ continue
94
+ }
95
+ const name = line.slice(0, eq).trim()
96
+ if (name === '') {
97
+ invalid++
98
+ continue
99
+ }
100
+ let rest = line.slice(eq + 1)
101
+ let remarks = ''
102
+ const hash = rest.indexOf(' #')
103
+ if (hash >= 0) {
104
+ remarks = rest.slice(hash + 2).trim()
105
+ rest = rest.slice(0, hash)
106
+ }
107
+ rows.push({ name, value: rest, remarks })
108
+ }
109
+ return { rows, invalid }
110
+ }
111
+
112
+ // 任务字段校验与规范化:非法即业务错误;返回可落库字段集
113
+ const WINDOW_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/
114
+
115
+ // 会话任务专有字段(设计 §3.2 job.session 子对象);窗口 start/end 须成对且为 HH:mm
116
+ function normalizeSession(body) {
117
+ if (body.kind !== 'session') return undefined
118
+ const raw = body.session && typeof body.session === 'object' ? body.session : {}
119
+ if (raw.mode !== undefined && raw.mode !== 'fresh' && raw.mode !== 'pinned') throw new Error(MESSAGES.badSessionMode)
120
+ if (raw.onMiss !== undefined && raw.onMiss !== 'skip' && raw.onMiss !== 'defer') throw new Error(MESSAGES.badOnMiss)
121
+ const windowStart = raw.windowStart ? String(raw.windowStart).trim() : ''
122
+ const windowEnd = raw.windowEnd ? String(raw.windowEnd).trim() : ''
123
+ if (windowStart === '' !== (windowEnd === '')) throw new Error(MESSAGES.windowPairRequired)
124
+ for (const window of [windowStart, windowEnd]) {
125
+ if (window !== '' && !WINDOW_PATTERN.test(window)) throw new Error(MESSAGES.badWindowFormat)
126
+ }
127
+ return {
128
+ mode: raw.mode === 'pinned' ? 'pinned' : 'fresh',
129
+ pinnedSessionId: typeof raw.pinnedSessionId === 'string' ? raw.pinnedSessionId.trim() : '',
130
+ windowStart,
131
+ windowEnd,
132
+ onMiss: raw.onMiss === 'defer' ? 'defer' : 'skip',
133
+ }
134
+ }
135
+
136
+ function normalizeJob(body) {
137
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
138
+ if (name === '') throw new Error(MESSAGES.jobNameRequired)
139
+ const kind = body.kind === 'session' ? 'session' : 'shell'
140
+ if (body.kind !== 'shell' && body.kind !== 'session') throw new Error(MESSAGES.badKind)
141
+ const command = typeof body.command === 'string' ? body.command.trim() : ''
142
+ if (kind === 'shell' && command === '') throw new Error(MESSAGES.commandRequired)
143
+ const schedule = typeof body.schedule === 'string' ? body.schedule.trim() : ''
144
+ assertValidSchedule(schedule)
145
+ const timeoutMs = Number.isInteger(body.timeoutMs) && body.timeoutMs > 0 ? body.timeoutMs : DEFAULT_TIMEOUT_MS
146
+ const normalized = {
147
+ name,
148
+ kind,
149
+ command,
150
+ prompt: typeof body.prompt === 'string' ? body.prompt : '',
151
+ workdir: typeof body.workdir === 'string' ? body.workdir.trim() : '',
152
+ schedule,
153
+ timeoutMs,
154
+ enabled: body.enabled !== false,
155
+ session: normalizeSession(body),
156
+ }
157
+ if (normalized.session === undefined) delete normalized.session
158
+ // 任务级并发上限:合法正整数才落库,缺省走全局闸门(executor min 规则)
159
+ if (Number.isInteger(body.concurrency) && body.concurrency > 0) normalized.concurrency = body.concurrency
160
+ // nextRunAt:调用方可显式指定(导入任务保留原状态 / 测试注入到期时刻),缺省按 schedule 计算
161
+ normalized.nextRunAt = typeof body.nextRunAt === 'number' && Number.isFinite(body.nextRunAt)
162
+ ? body.nextRunAt
163
+ : nextRunAtOf(schedule)
164
+ return normalized
165
+ }
166
+
167
+ export function createApi({ store, logger, executor, scheduler, periodic, logSystem }) {
168
+
169
+ const routes = [
170
+ {
171
+ method: 'GET',
172
+ segments: ['envs'],
173
+ handler: async ({ res }) => {
174
+ const items = store.envs.list().map((row) => ({ ...row, value: maskValue(row.value) }))
175
+ sendJson(res, 200, { items })
176
+ },
177
+ },
178
+ {
179
+ // 固定段 export 须先于 :id 捕获注册,否则被当作 id 吞掉
180
+ method: 'GET',
181
+ segments: ['envs', 'export'],
182
+ handler: async ({ res, url }) => {
183
+ const includeDisabled = url.searchParams.get('includeDisabled') === '1'
184
+ const rows = store.envs.list().filter((row) => includeDisabled || row.enabled)
185
+ const text = rows.map((row) => row.name + '=' + row.value + (row.remarks ? ' #' + row.remarks : '')).join('\n') + (rows.length > 0 ? '\n' : '')
186
+ sendJson(res, 200, { text })
187
+ },
188
+ },
189
+ {
190
+ method: 'GET',
191
+ segments: ['envs', ':id'],
192
+ handler: async ({ res, params }) => {
193
+ const row = store.envs.get(params.id)
194
+ if (!row) throw new Error('变量不存在:' + params.id)
195
+ sendJson(res, 200, row)
196
+ },
197
+ },
198
+ {
199
+ method: 'POST',
200
+ segments: ['envs'],
201
+ handler: async ({ req, res }) => {
202
+ const body = await readJsonBody(req)
203
+ const name = typeof body.name === 'string' ? body.name.trim() : ''
204
+ if (name === '') throw new Error(MESSAGES.nameRequired)
205
+ const multi = body.multi === true
206
+ const row = await store.envs.create({
207
+ name,
208
+ value: normalizeEnvValue(body, multi),
209
+ remarks: typeof body.remarks === 'string' ? body.remarks : '',
210
+ enabled: body.enabled !== false,
211
+ multi,
212
+ })
213
+ sendJson(res, 200, row)
214
+ },
215
+ },
216
+ {
217
+ method: 'PATCH',
218
+ segments: ['envs', ':id'],
219
+ handler: async ({ req, res, params }) => {
220
+ const body = await readJsonBody(req)
221
+ const patch = {}
222
+ if (body.name !== undefined) {
223
+ const name = String(body.name).trim()
224
+ if (name === '') throw new Error(MESSAGES.nameRequired)
225
+ patch.name = name
226
+ }
227
+ for (const field of ['value', 'remarks']) {
228
+ if (body[field] !== undefined) patch[field] = String(body[field])
229
+ }
230
+ if (body.enabled !== undefined) patch.enabled = Boolean(body.enabled)
231
+ if (body.multi !== undefined) patch.multi = body.multi === true
232
+ // values 数组折叠与 POST 同前提:仅 multi===true 时采纳(client 契约恒带 multi)
233
+ if (body.multi === true && Array.isArray(body.values)) patch.value = normalizeEnvValue(body, true)
234
+ const row = await store.envs.update(params.id, patch)
235
+ if (!row) throw new Error('变量不存在:' + params.id)
236
+ sendJson(res, 200, row)
237
+ },
238
+ },
239
+ {
240
+ method: 'DELETE',
241
+ segments: ['envs', ':id'],
242
+ handler: async ({ res, params }) => {
243
+ const removed = await store.envs.remove(params.id)
244
+ if (!removed) throw new Error('变量不存在:' + params.id)
245
+ sendJson(res, 200, { ok: true })
246
+ },
247
+ },
248
+ {
249
+ // 环境变量文本导入:apply!==true 即预览(返回解析行数组),apply=true 落库
250
+ // overwrite=true 先清空再导入,缺省追加;与 client ImportForm 契约一一对应
251
+ method: 'POST',
252
+ segments: ['envs', 'import'],
253
+ handler: async ({ req, res }) => {
254
+ const body = await readJsonBody(req)
255
+ const { rows, invalid } = parseEnvText(body.text)
256
+ if (body.apply !== true) {
257
+ sendJson(res, 200, { parsed: rows, invalid })
258
+ return
259
+ }
260
+ const mode = body.overwrite === true ? 'overwrite' : 'append'
261
+ if (mode === 'overwrite') {
262
+ for (const row of [...store.envs.list()]) {
263
+ await store.envs.remove(row.id)
264
+ }
265
+ }
266
+ for (const row of rows) {
267
+ await store.envs.create({ name: row.name, value: row.value, remarks: row.remarks, enabled: true })
268
+ }
269
+ sendJson(res, 200, { ok: true, imported: rows.length, mode })
270
+ },
271
+ },
272
+ {
273
+ method: 'GET',
274
+ segments: ['status'],
275
+ handler: async ({ res }) => {
276
+ sendJson(res, 200, {
277
+ timerRunning: Boolean(periodic && periodic.running),
278
+ timerReason: periodic && periodic.reason ? periodic.reason : null,
279
+ scheduler: scheduler ? scheduler.status() : { active: 0, queued: 0 },
280
+ nextAt: scheduler && scheduler.nextRunAt ? scheduler.nextRunAt() : null,
281
+ })
282
+ },
283
+ },
284
+ {
285
+ // 编辑表单实时预览:croner 服务端解析,前端不引解析依赖(定案 §6.2)
286
+ method: 'POST',
287
+ segments: ['cron', 'preview'],
288
+ handler: async ({ req, res }) => {
289
+ const body = await readJsonBody(req)
290
+ const schedule = typeof body.schedule === 'string' ? body.schedule.trim() : ''
291
+ assertValidSchedule(schedule)
292
+ sendJson(res, 200, { summary: summarizeCron(schedule), nextAt: nextRunsOf(schedule, 3) })
293
+ },
294
+ },
295
+ {
296
+ method: 'GET',
297
+ segments: ['jobs'],
298
+ handler: async ({ res }) => {
299
+ // summary 人话摘要随行返回,前端不引解析依赖
300
+ const items = store.jobs.list().map((job) => ({ ...job, summary: summarizeCron(job.schedule) }))
301
+ sendJson(res, 200, { items })
302
+ },
303
+ },
304
+ {
305
+ method: 'POST',
306
+ segments: ['jobs'],
307
+ handler: async ({ req, res }) => {
308
+ const body = await readJsonBody(req)
309
+ const fields = normalizeJob(body)
310
+ const row = await store.jobs.create(fields)
311
+ sendJson(res, 200, row)
312
+ },
313
+ },
314
+ {
315
+ method: 'PATCH',
316
+ segments: ['jobs', ':id'],
317
+ handler: async ({ req, res, params }) => {
318
+ const body = await readJsonBody(req)
319
+ const existing = store.jobs.get(params.id)
320
+ if (!existing) throw new Error(MESSAGES.jobNotFound)
321
+ const fields = normalizeJob({ ...existing, ...body })
322
+ const row = await store.jobs.update(params.id, fields)
323
+ sendJson(res, 200, row)
324
+ },
325
+ },
326
+ {
327
+ method: 'DELETE',
328
+ segments: ['jobs', ':id'],
329
+ handler: async ({ res, params }) => {
330
+ const removed = await store.jobs.remove(params.id)
331
+ if (!removed) throw new Error(MESSAGES.jobNotFound)
332
+ await logger.removeJob(params.id)
333
+ sendJson(res, 200, { ok: true })
334
+ },
335
+ },
336
+ {
337
+ method: 'POST',
338
+ segments: ['jobs', ':id', 'run'],
339
+ handler: async ({ res, params }) => {
340
+ const job = store.jobs.get(params.id)
341
+ if (!job) throw new Error(MESSAGES.jobNotFound)
342
+ // 预占记录同步返回 runIds,执行经执行链闸门后台推进(结果经运行记录与日志呈现)
343
+ const runIds = await executor.dispatch(job, 'manual')
344
+ sendJson(res, 200, { runIds })
345
+ },
346
+ },
347
+ {
348
+ method: 'POST',
349
+ segments: ['jobs', ':id', 'toggle'],
350
+ handler: async ({ res, params }) => {
351
+ const job = store.jobs.get(params.id)
352
+ if (!job) throw new Error(MESSAGES.jobNotFound)
353
+ const row = await store.jobs.update(params.id, { enabled: !job.enabled })
354
+ sendJson(res, 200, row)
355
+ },
356
+ },
357
+ {
358
+ method: 'GET',
359
+ segments: ['runs'],
360
+ handler: async ({ res, url }) => {
361
+ const jobId = url.searchParams.get('jobId')
362
+ const rows = store.runs.list(jobId).slice().reverse()
363
+ sendJson(res, 200, { items: rows })
364
+ },
365
+ },
366
+ {
367
+ method: 'GET',
368
+ segments: ['runs', ':runId', 'log'],
369
+ handler: async ({ res, params, url }) => {
370
+ const record = store.runs.get(params.runId)
371
+ if (!record) throw new Error(MESSAGES.runNotFound)
372
+ const tailParam = url.searchParams.get('tail')
373
+ const tailLines = tailParam === null ? undefined : Math.max(1, Number(tailParam) || 1)
374
+ const text = await logger.read(record.jobId, params.runId, { tailLines })
375
+ sendJson(res, 200, { text })
376
+ },
377
+ },
378
+ {
379
+ method: 'DELETE',
380
+ segments: ['runs', ':runId', 'log'],
381
+ handler: async ({ res, params }) => {
382
+ const record = store.runs.get(params.runId)
383
+ if (!record) throw new Error(MESSAGES.runNotFound)
384
+ await logger.clear(record.jobId, params.runId)
385
+ sendJson(res, 200, { ok: true })
386
+ },
387
+ },
388
+ ]
389
+
390
+ // 路径段匹配:逐段比对,: 开头段捕获参数;返回 null 不匹配
391
+ function matchRoute(method, segments) {
392
+ for (const route of routes) {
393
+ if (route.method !== method) continue
394
+ if (route.segments.length !== segments.length) continue
395
+ const params = {}
396
+ let matched = true
397
+ for (let i = 0; i < route.segments.length; i++) {
398
+ const pattern = route.segments[i]
399
+ if (pattern.startsWith(':')) {
400
+ params[pattern.slice(1)] = decodeURIComponent(segments[i])
401
+ } else if (pattern !== segments[i]) {
402
+ matched = false
403
+ break
404
+ }
405
+ }
406
+ if (matched) return { route, params }
407
+ }
408
+ return null
409
+ }
410
+
411
+ return {
412
+ async handle(req, res) {
413
+ const url = new URL(req.url, 'http://localhost')
414
+ const pathname = url.pathname.startsWith(ROUTE_PREFIX + '/')
415
+ ? url.pathname.slice(ROUTE_PREFIX.length)
416
+ : url.pathname === ROUTE_PREFIX ? '/' : null
417
+ if (pathname === null) {
418
+ sendJson(res, 404, { error: MESSAGES.notFound })
419
+ return
420
+ }
421
+ const segments = pathname.split('/').filter(Boolean)
422
+ try {
423
+ const matched = matchRoute(req.method, segments)
424
+ if (!matched) {
425
+ sendJson(res, 404, { error: MESSAGES.notFound })
426
+ return
427
+ }
428
+ await matched.route.handler({ req, res, url, params: matched.params })
429
+ } catch (error) {
430
+ respondError(res, error, logSystem)
431
+ }
432
+ },
433
+ }
434
+ }