@yunzai-ng/core 0.1.1 → 0.3.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.
Files changed (45) hide show
  1. package/dist/http/client.d.ts.map +1 -1
  2. package/dist/http/client.js +39 -3
  3. package/dist/http/client.js.map +1 -1
  4. package/dist/index.d.ts +1 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/platform/paths.d.ts +10 -0
  9. package/dist/platform/paths.d.ts.map +1 -1
  10. package/dist/platform/paths.js +50 -7
  11. package/dist/platform/paths.js.map +1 -1
  12. package/dist/plugin/context.d.ts +3 -0
  13. package/dist/plugin/context.d.ts.map +1 -1
  14. package/dist/plugin/context.js +18 -2
  15. package/dist/plugin/context.js.map +1 -1
  16. package/dist/plugin/market.d.ts +119 -8
  17. package/dist/plugin/market.d.ts.map +1 -1
  18. package/dist/plugin/market.js +183 -22
  19. package/dist/plugin/market.js.map +1 -1
  20. package/dist/plugin/pm.d.ts +82 -0
  21. package/dist/plugin/pm.d.ts.map +1 -0
  22. package/dist/plugin/pm.js +129 -0
  23. package/dist/plugin/pm.js.map +1 -0
  24. package/dist/server/api.d.ts.map +1 -1
  25. package/dist/server/api.js +54 -5
  26. package/dist/server/api.js.map +1 -1
  27. package/dist/server/browse.d.ts.map +1 -1
  28. package/dist/server/browse.js +10 -2
  29. package/dist/server/browse.js.map +1 -1
  30. package/package.json +2 -2
  31. package/src/http/client.test.ts +75 -1
  32. package/src/http/client.ts +40 -3
  33. package/src/index.ts +1 -0
  34. package/src/platform/paths.test.ts +166 -0
  35. package/src/platform/paths.ts +52 -7
  36. package/src/plugin/context.test.ts +29 -1
  37. package/src/plugin/context.ts +18 -2
  38. package/src/plugin/market.test.ts +375 -2
  39. package/src/plugin/market.ts +278 -25
  40. package/src/plugin/pm.test.ts +73 -0
  41. package/src/plugin/pm.ts +163 -0
  42. package/src/server/api.test.ts +72 -2
  43. package/src/server/api.ts +65 -5
  44. package/src/server/browse.test.ts +18 -10
  45. package/src/server/browse.ts +11 -2
@@ -15,7 +15,7 @@ import { readFile, rm } from "node:fs/promises"
15
15
  import { tmpdir } from "node:os"
16
16
  import { join } from "node:path"
17
17
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"
18
- import { AbortError } from "../util/defer.js"
18
+ import { AbortError, sleep } from "../util/defer.js"
19
19
  import { HttpError, createHttpClient, sanitizeHeaders, sanitizeUrl, type ManagedHttpClient } from "./client.js"
20
20
 
21
21
  /** 服务器实例 */
@@ -566,3 +566,77 @@ describe("脱敏", () => {
566
566
  expect(err.url).not.toContain("ABCDEFGHIJKLMNOP")
567
567
  })
568
568
  })
569
+
570
+ describe("close 不被在途请求拖住", () => {
571
+ // 用 3 秒而非线上那个 15 秒:被测的是「close 不等在途请求」,请求超时具体多长与此无关,
572
+ // 而用例本身要等它最终落地(见下),15 秒会让这一个用例占满 CI 的耐心
573
+ it(
574
+ "在途请求远未超时,close 仍在宽限内返回",
575
+ async () => {
576
+ const http = makeClient()
577
+ // 服务端永不响应。undici 的 close() 是优雅关闭,会一直等在途请求跑完 —— 若不掐断,
578
+ // 停机就要陪着这个请求等满它的超时(国内网络下拉 GitHub 索引正是此情形)
579
+ const pending = http.get(`${base}/slow`, { timeout: 3000 })
580
+ // 等 socket 真的建立起来,否则关的是一个还没有在途请求的空池子,用例就测不到东西
581
+ await sleep(80)
582
+
583
+ const started = Date.now()
584
+ await http.close()
585
+ const spent = Date.now() - started
586
+
587
+ // 宽限 500ms,留足余量判定;关键是它远小于那个 3 秒
588
+ expect(spent).toBeLessThan(1500)
589
+
590
+ // destroy() 释放的是连接池,它并不代拒那个已经发出的 Promise —— 后者仍按自己的超时落地。
591
+ // 此处等它结束只为不把一个悬空的 Promise 留给下一个用例,不是在断言时序
592
+ await expect(pending).rejects.toThrow()
593
+ },
594
+ 15_000
595
+ )
596
+ })
597
+
598
+ describe("默认信号", () => {
599
+ it("extend 带的信号一触发,经它发出的请求随之中止", async () => {
600
+ const http = makeClient()
601
+ const abort = new AbortController()
602
+ // 插件上下文正是这样拿到自己的 http:extend({ signal: 卸载信号 })
603
+ const scoped = http.extend({ signal: abort.signal })
604
+ const pending = scoped.get(`${base}/slow`, { timeout: 5000 })
605
+ setTimeout(() => abort.abort(), 30)
606
+
607
+ await expect(pending).rejects.toBeInstanceOf(AbortError)
608
+ })
609
+
610
+ it("默认信号与单次请求的信号是并集,任一触发即中止", async () => {
611
+ const http = makeClient()
612
+ const outer = new AbortController()
613
+ const scoped = http.extend({ signal: outer.signal })
614
+ const inner = new AbortController()
615
+ // 只触发单次请求那一个:合并不能把调用方自己的信号吃掉
616
+ const pending = scoped.get(`${base}/slow`, { signal: inner.signal, timeout: 5000 })
617
+ setTimeout(() => inner.abort(), 30)
618
+
619
+ await expect(pending).rejects.toBeInstanceOf(AbortError)
620
+ })
621
+
622
+ it("默认信号已经中止时,请求不再发出", async () => {
623
+ const http = makeClient()
624
+ const abort = new AbortController()
625
+ abort.abort()
626
+ const scoped = http.extend({ signal: abort.signal })
627
+
628
+ // 插件已卸载之后才被调用的代码路径:不该再打出一个注定没人接收的请求
629
+ await expect(scoped.get(`${base}/json`)).rejects.toBeInstanceOf(AbortError)
630
+ })
631
+
632
+ it("根客户端不受派生客户端的信号影响", async () => {
633
+ const http = makeClient()
634
+ const abort = new AbortController()
635
+ const scoped = http.extend({ signal: abort.signal })
636
+ abort.abort()
637
+
638
+ // 一个插件被卸载不能让内核自己的请求跟着废掉 —— 两者共享连接池,但信号必须各自独立
639
+ await expect(scoped.get(`${base}/json`)).rejects.toBeInstanceOf(AbortError)
640
+ await expect(http.get(`${base}/json`)).resolves.toMatchObject({ ok: true })
641
+ })
642
+ })
@@ -53,6 +53,13 @@ const RETRY_STATUSES: readonly number[] = [408, 429, 500, 502, 503, 504]
53
53
  /** 重试等待的上限(毫秒):`Retry-After` 说等一小时也不真等 */
54
54
  const MAX_RETRY_DELAY = 30_000
55
55
 
56
+ /**
57
+ * `close()` 等待在途请求的宽限(毫秒)
58
+ *
59
+ * 取一个小值:够正在收尾的响应写完,又不至于让使用者在停机时干等。
60
+ */
61
+ const CLOSE_GRACE_MS = 500
62
+
56
63
  /** 查询串里需要打码的键(小写比对) */
57
64
  const SECRET_QUERY_KEYS: readonly string[] = [
58
65
  "authkey",
@@ -208,6 +215,22 @@ export function sanitizeHeaders(headers: Record<string, string>): Record<string,
208
215
  return safe
209
216
  }
210
217
 
218
+ /**
219
+ * 合并两个中止信号
220
+ *
221
+ * 用于把「客户端默认信号」(如插件的卸载信号)与「单次请求的信号」并成一个:
222
+ * 任一触发即中止。两者都缺省时返回 undefined,此时不给 undici 传 signal。
223
+ * @param a 一个信号
224
+ * @param b 另一个信号
225
+ * @returns 合并后的信号;都没有时 undefined
226
+ */
227
+ function mergeSignals(a: AbortSignal | undefined, b: AbortSignal | undefined): AbortSignal | undefined {
228
+ const list = [a, b].filter((s): s is AbortSignal => s !== undefined)
229
+ if (list.length === 0) return undefined
230
+ if (list.length === 1) return list[0]
231
+ return AbortSignal.any(list)
232
+ }
233
+
211
234
  /**
212
235
  * 归一化重试选项
213
236
  * @param input 数字(次数)或完整选项
@@ -530,7 +553,7 @@ export function createHttpClient(opts: HttpClientOptions = {}): ManagedHttpClien
530
553
  proxy: proxyChoice,
531
554
  redirections: follow ? MAX_REDIRECTIONS : 0,
532
555
  throwOnError: options.throwOnError ?? true,
533
- signal: options.signal
556
+ signal: mergeSignals(defaults.signal, options.signal)
534
557
  }
535
558
  }
536
559
 
@@ -758,9 +781,23 @@ export function createHttpClient(opts: HttpClientOptions = {}): ManagedHttpClien
758
781
  close: async (): Promise<void> => {
759
782
  closed = true
760
783
  dispatchers.clear()
761
- const closing = [...agents.values()].map(agent => agent.close().catch(() => undefined))
784
+ const pool = [...agents.values()]
762
785
  agents.clear()
763
- await Promise.all(closing)
786
+
787
+ // 先给一小段宽限让正在收尾的请求写完,到点未完的一律掐断。
788
+ // undici 的 close() 是优雅关闭 —— 它会一直等在途请求跑完,于是一个 15 秒超时的
789
+ // 请求能把停机拖满 15 秒。停机走到这一步时插件已卸载、服务器已关闭,还在途的
790
+ // 必然是没人接收结果的孤儿请求,等它们没有意义。
791
+ const graceful = Promise.all(pool.map(agent => agent.close().catch(() => undefined)))
792
+ const timer = new Promise<"timeout">(res => {
793
+ const t = setTimeout(() => res("timeout"), CLOSE_GRACE_MS)
794
+ // unref:这个定时器自己不该成为进程退不出的原因
795
+ t.unref()
796
+ })
797
+ if ((await Promise.race([graceful.then(() => "done" as const), timer])) === "timeout") {
798
+ logger?.debug(`连接池未在 ${CLOSE_GRACE_MS}ms 内关闭,中止在途请求`)
799
+ await Promise.all(pool.map(agent => agent.destroy().catch(() => undefined)))
800
+ }
764
801
  }
765
802
  }
766
803
  return client
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export * from "./plugin/events.js"
29
29
  export * from "./plugin/services.js"
30
30
  export * from "./plugin/discover.js"
31
31
  export * from "./plugin/market.js"
32
+ export * from "./plugin/pm.js"
32
33
  export * from "./plugin/tar.js"
33
34
 
34
35
  /* ────────────────────────────── 消息 ────────────────────────────── */
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 模块职责:主目录探测顺序的测试
3
+ * 依赖方向:测试文件,依赖 platform/paths.ts
4
+ * 生命周期:每个用例一个临时目录,用后即弃
5
+ * 注意事项:本文件固定的是「数据放在哪里」这一条 —— 它错了的表现不是报错,而是
6
+ * 使用者的配置、账号与历史记录**看起来凭空消失**(实则仍在旧位置)。
7
+ * 因此这里既覆盖「新装落在当前目录」,也覆盖「既有安装不被静默搬家」。
8
+ *
9
+ * 用 `process.chdir` 而非注入一个 cwd 参数:被测对象正是「读取进程工作目录」
10
+ * 这一行为,参数化会把它测成一个纯函数,那样恰好绕过了要测的东西。
11
+ * vitest 的 forks 池令每个测试文件独占一个进程,chdir 不会波及其他文件。
12
+ */
13
+ import { mkdtemp, mkdir, writeFile } from "node:fs/promises"
14
+ import { tmpdir } from "node:os"
15
+ import { join, resolve } from "node:path"
16
+ import { afterEach, beforeEach, describe, expect, it } from "vitest"
17
+ import { legacyInstance, resolvePaths } from "./paths.js"
18
+
19
+ /** 进入用例前的工作目录与环境变量,用后还原 */
20
+ let origin: string
21
+ let env: NodeJS.ProcessEnv
22
+ /** 本用例的临时根目录 */
23
+ let base: string
24
+
25
+ /**
26
+ * 推出 `defaultHome()` 在本平台会给出的位置
27
+ *
28
+ * 仅 Windows 与 Linux 可经环境变量改写;macOS 固定在 `~/Library`,无从在测试中挪动,
29
+ * 故相应用例在该平台上跳过。
30
+ * @param root 充当 `LOCALAPPDATA` / `XDG_DATA_HOME` 的目录
31
+ * @returns 系统默认位置;本平台无法改写时 undefined
32
+ */
33
+ function legacyHomeFor(root: string): string | undefined {
34
+ if (process.platform === "win32") return join(root, "YunzaiNG")
35
+ if (process.platform === "linux") return join(root, "yunzai-ng")
36
+ return undefined
37
+ }
38
+
39
+ beforeEach(async () => {
40
+ origin = process.cwd()
41
+ env = { ...process.env }
42
+ base = await mkdtemp(join(tmpdir(), "yzng-paths-"))
43
+ // 三者都会参与探测,逐一清掉,否则用例会读到开发机上真实的取值
44
+ delete process.env["YZNG_HOME"]
45
+ delete process.env["LOCALAPPDATA"]
46
+ delete process.env["XDG_DATA_HOME"]
47
+ })
48
+
49
+ afterEach(() => {
50
+ process.chdir(origin)
51
+ process.env = env
52
+ })
53
+
54
+ describe("主目录探测", () => {
55
+ it("什么都不给时落在当前目录", async () => {
56
+ const dir = join(base, "新装")
57
+ await mkdir(dir)
58
+ process.chdir(dir)
59
+
60
+ // resolve 而非直接比较:macOS 的 /var 是 /private/var 的符号链接,
61
+ // mkdtemp 给出前者而 cwd 给出后者
62
+ expect(resolvePaths().home).toBe(resolve(process.cwd()))
63
+ expect(resolvePaths().config).toBe(join(resolve(process.cwd()), "config"))
64
+ })
65
+
66
+ it("显式参数为相对路径时按当前目录解析", async () => {
67
+ process.chdir(base)
68
+ const got = resolvePaths({ home: "./实例甲" }).home
69
+
70
+ expect(got).toBe(join(resolve(base), "实例甲"))
71
+ })
72
+
73
+ it("YZNG_HOME 压过当前目录", async () => {
74
+ const other = join(base, "另一处")
75
+ await mkdir(other)
76
+ process.chdir(base)
77
+ process.env["YZNG_HOME"] = other
78
+
79
+ expect(resolvePaths().home).toBe(resolve(other))
80
+ })
81
+
82
+ it("显式参数压过 YZNG_HOME", async () => {
83
+ const a = join(base, "参数")
84
+ const b = join(base, "环境变量")
85
+ await mkdir(a)
86
+ await mkdir(b)
87
+ process.chdir(base)
88
+ process.env["YZNG_HOME"] = b
89
+
90
+ expect(resolvePaths({ home: a }).home).toBe(resolve(a))
91
+ })
92
+
93
+ it("便携标记压过当前目录", async () => {
94
+ const install = join(base, "便携根")
95
+ // runtime 传 <install>/node_modules/@yunzai-ng/core,与真实安装的层级一致:
96
+ // findPortableRoot 自该处上溯三级才回到 install
97
+ const runtime = join(install, "node_modules", "@yunzai-ng", "core")
98
+ await mkdir(runtime, { recursive: true })
99
+ await writeFile(join(install, ".portable"), "")
100
+ const cwd = join(base, "别处")
101
+ await mkdir(cwd)
102
+ process.chdir(cwd)
103
+
104
+ expect(resolvePaths({ runtime }).home).toBe(resolve(install))
105
+ })
106
+ })
107
+
108
+ describe("旧默认位置上的实例", () => {
109
+ /** 在 `dir` 下造出一个看起来已在用的实例 */
110
+ const makeInstance = async (dir: string): Promise<void> => {
111
+ await mkdir(join(dir, "config"), { recursive: true })
112
+ await writeFile(join(dir, "config", "core.yaml"), "server:\n port: 2536\n")
113
+ }
114
+
115
+ /**
116
+ * 把系统默认位置指到临时目录下
117
+ * @returns 系统默认位置;本平台无法改写时 undefined
118
+ */
119
+ const pointLegacyAt = (): string | undefined => {
120
+ const root = join(base, "系统默认位置")
121
+ const legacy = legacyHomeFor(root)
122
+ if (legacy === undefined) return undefined
123
+ if (process.platform === "win32") process.env["LOCALAPPDATA"] = root
124
+ else process.env["XDG_DATA_HOME"] = root
125
+ return legacy
126
+ }
127
+
128
+ it.skipIf(legacyHomeFor("x") === undefined)("旧位置有实例也不影响主目录 —— 不做静默回落", async () => {
129
+ const legacy = pointLegacyAt()
130
+ if (legacy === undefined) return
131
+ await makeInstance(legacy)
132
+ const cwd = join(base, "空目录")
133
+ await mkdir(cwd)
134
+ process.chdir(cwd)
135
+
136
+ // 回落会让「默认在当前目录」在任何装过旧版的机器上都不成立
137
+ expect(resolvePaths().home).toBe(resolve(cwd))
138
+ })
139
+
140
+ it.skipIf(legacyHomeFor("x") === undefined)("legacyInstance 报出旧实例,供 CLI 提示", async () => {
141
+ const legacy = pointLegacyAt()
142
+ if (legacy === undefined) return
143
+ await makeInstance(legacy)
144
+ const cwd = join(base, "新家")
145
+ await mkdir(cwd)
146
+
147
+ expect(legacyInstance(cwd)).toBe(legacy)
148
+ })
149
+
150
+ it.skipIf(legacyHomeFor("x") === undefined)("旧位置没有实例时不提示", async () => {
151
+ const legacy = pointLegacyAt()
152
+ if (legacy === undefined) return
153
+ // 目录存在但没有 config/:这是"随手建过一个空目录",不是一个实例
154
+ await mkdir(legacy, { recursive: true })
155
+
156
+ expect(legacyInstance(join(base, "新家"))).toBeUndefined()
157
+ })
158
+
159
+ it.skipIf(legacyHomeFor("x") === undefined)("本次用的就是旧位置时不提示 —— 没有可搬的家", async () => {
160
+ const legacy = pointLegacyAt()
161
+ if (legacy === undefined) return
162
+ await makeInstance(legacy)
163
+
164
+ expect(legacyInstance(legacy)).toBeUndefined()
165
+ })
166
+ })
@@ -2,13 +2,18 @@
2
2
  * 模块职责:确定并创建运行时目录布局
3
3
  * 依赖方向:依赖 util/fs、platform/detect
4
4
  * 生命周期:启动时解析一次,之后只读
5
- * 注意事项:全程不使用 `process.cwd()` —— Windows 服务、开机自启或 pm2 启动时
6
- * 工作目录并非项目目录。启动时把八个目录一次性定死成绝对路径,
7
- * 其余模块只准通过 `app.paths` 取。
5
+ * 注意事项:优先级:显式参数 > `YZNG_HOME` > 便携模式标记 > 当前目录。
6
+ * 启动时把八个目录一次性定死成绝对路径,其余模块只准通过 `app.paths` 取。
8
7
  *
9
- * 优先级:显式参数 > `YZNG_HOME` 环境变量 > 便携模式标记 > 系统默认位置。
10
- * 便携模式(安装目录下有 `.portable` 文件)让 ZIP 解压即用、
11
- * 换机拷走整个文件夹就能迁移 —— Windows 用户最常见的诉求。
8
+ * **默认落在当前目录**:解压或克隆到一个文件夹、在其中 `yzng init`,数据就在
9
+ * 眼前,拷走整个文件夹即完成迁移。代价是本模块必须读 `process.cwd()`,而以
10
+ * Windows 服务、开机自启或 pm2 启动时工作目录并非项目目录 —— 那些场景必须
11
+ * 显式给出 `YZNG_HOME` 或启动器的工作目录,否则数据会落在启动器所在之处。
12
+ *
13
+ * 0.1.1 及更早的默认位置是系统目录(Windows 的 `%LOCALAPPDATA%\YunzaiNG` 等)。
14
+ * 此处**不**为其保留静默回落 —— 那会使「默认在当前目录」在任何装过旧版的机器上
15
+ * 都不成立。改由 {@link legacyInstance} 把旧实例报给 CLI 显式提示:换目录应当是
16
+ * 使用者看得见的一步,而不是内核悄悄替他挑一个。
12
17
  */
13
18
  import { homedir } from "node:os"
14
19
  import { dirname, isAbsolute, join, resolve } from "node:path"
@@ -63,6 +68,8 @@ function findPortableRoot(runtimeDir: string): string | undefined {
63
68
 
64
69
  /**
65
70
  * 系统默认的数据根目录
71
+ *
72
+ * 已不再是缺省取值,仅由 {@link legacyInstance} 用于查找旧实例,见文件头第 3 条。
66
73
  * @returns 绝对路径
67
74
  */
68
75
  function defaultHome(): string {
@@ -85,6 +92,44 @@ function defaultHome(): string {
85
92
  return join(home, APP_DIR_NAME_UNIX)
86
93
  }
87
94
 
95
+ /**
96
+ * 判断一个目录里是否已有实例
97
+ *
98
+ * 以 `config/` 是否存在为准:`ensurePaths` 建的七个目录中只有它必定装着文件
99
+ * (`ConfigStore` 会把缺省配置落盘),而空的 `data/`、`logs/` 无从区分
100
+ * 「一个实例」与「随手建的空目录」。
101
+ * @param dir 待判断的目录
102
+ * @returns 是否已有实例
103
+ */
104
+ function hasInstance(dir: string): boolean {
105
+ return existsSync(join(dir, "config"))
106
+ }
107
+
108
+ /**
109
+ * 未显式指定、也无便携标记时的主目录
110
+ *
111
+ * 见文件头第 2、3 条。
112
+ * @returns 绝对路径
113
+ */
114
+ function autoHome(): string {
115
+ return process.cwd()
116
+ }
117
+
118
+ /**
119
+ * 0.1.1 及更早的默认位置上是否还留着一个实例
120
+ *
121
+ * 供 CLI 提示使用:默认位置自 0.2.0 起改为当前目录,装过旧版的机器上那个实例仍在原处,
122
+ * 而使用者多半以为「配置全没了」。内核只负责报出位置,是否搬家由使用者决定 ——
123
+ * 自动迁移会在两个目录都有内容时无从判断该以谁为准。
124
+ * @param home 本次实际使用的主目录
125
+ * @returns 旧实例所在目录;不存在、或恰好就是本次所用的目录时 undefined
126
+ */
127
+ export function legacyInstance(home: string): string | undefined {
128
+ const legacy = defaultHome()
129
+ if (resolve(legacy) === resolve(home)) return undefined
130
+ return hasInstance(legacy) ? legacy : undefined
131
+ }
132
+
88
133
  /**
89
134
  * 解析目录布局
90
135
  *
@@ -97,7 +142,7 @@ export function resolvePaths(opts: ResolvePathsOptions = {}): RuntimePaths {
97
142
  const runtime = opts.runtime ? resolve(opts.runtime) : detectRuntimeDir()
98
143
 
99
144
  const explicit = opts.home ?? process.env.YZNG_HOME
100
- const home = explicit ? resolve(process.cwd(), explicit) : (findPortableRoot(runtime) ?? defaultHome())
145
+ const home = explicit ? resolve(process.cwd(), explicit) : (findPortableRoot(runtime) ?? autoHome())
101
146
 
102
147
  return Object.freeze({
103
148
  home,
@@ -19,7 +19,7 @@ import type {
19
19
  } from "@yunzai-ng/types"
20
20
  import { createEventBus } from "./events.js"
21
21
  import { createServiceRegistry } from "./services.js"
22
- import { createPluginContext } from "./context.js"
22
+ import { createPluginContext, splitRenderArgs } from "./context.js"
23
23
  import { DisposalRegistry } from "../util/dispose.js"
24
24
  import { fakeAppView, fakeHttp, fakeLogger, recordingHooks } from "../testing/fake.js"
25
25
 
@@ -318,6 +318,34 @@ describe("ctx.render", () => {
318
318
 
319
319
  await expect(ctx.render("t")).rejects.toThrow(/没有产出图片/)
320
320
  })
321
+
322
+ it("页面声明的 tailwind 传到渲染请求上", async () => {
323
+ const { ctx, recorded } = setup()
324
+ await ctx.render({ name: "card", html: "<html></html>", tailwind: true })
325
+ expect(recorded.renders[0]).toMatchObject({ template: "card", html: "<html></html>", tailwind: true })
326
+ })
327
+ })
328
+
329
+ describe("splitRenderArgs", () => {
330
+ it("字符串通路不带 html 与 tailwind", () => {
331
+ expect(splitRenderArgs("t", { uid: 1 })).toEqual({ template: "t", data: { uid: 1 }, opts: {} })
332
+ })
333
+
334
+ it("页面未声明 tailwind 时不写入该键", () => {
335
+ // 显式的 undefined 会盖掉渲染器一侧的缺省判定,故必须是"键不存在"而非"值为 undefined"
336
+ const call = splitRenderArgs({ name: "p", html: "<i>" })
337
+ expect("tailwind" in call.opts).toBe(false)
338
+ })
339
+
340
+ it("页面声明 false 时同样传下去", () => {
341
+ // false 是一次明确的"不要编译",不可与未声明混为一谈
342
+ expect(splitRenderArgs({ name: "p", html: "<i>", tailwind: false }).opts).toEqual({ tailwind: false })
343
+ })
344
+
345
+ it("调用点覆盖页面声明", () => {
346
+ const call = splitRenderArgs({ name: "p", html: "<i>", tailwind: true }, { tailwind: false })
347
+ expect(call.opts).toEqual({ tailwind: false })
348
+ })
321
349
  })
322
350
 
323
351
  describe("ctx.sql", () => {
@@ -276,6 +276,9 @@ export interface RenderCall {
276
276
  * `render()` 有两种形态:`render(page, opts)` 与 `render(template, data, opts)`。
277
277
  * 以第一参是否为字符串判别 —— `RenderablePage` 是对象,二者不可能混淆。
278
278
  * 判别只做这一处,两个调用点(`ctx.render` 与 `e.render`)共用同一份语义。
279
+ *
280
+ * 页面上的 `tailwind` 声明在此并入选项,且**调用点显式给出者优先**:声明是模板的常态,
281
+ * 而调用点是对某一次渲染的临时覆盖,后者压过前者才合乎「越局部越优先」。
279
282
  * @param first 页面或模板相对路径
280
283
  * @param second 模板数据(字符串通路)或渲染选项(TSX 通路)
281
284
  * @param third 渲染选项(字符串通路)
@@ -289,7 +292,15 @@ export function splitRenderArgs(
289
292
  if (typeof first === "string") {
290
293
  return { template: first, data: (second as Record<string, unknown> | undefined) ?? {}, opts: third ?? {} }
291
294
  }
292
- return { template: first.name, data: {}, html: first.html, opts: (second as RenderOptions | undefined) ?? {} }
295
+ const given = (second as RenderOptions | undefined) ?? {}
296
+ return {
297
+ template: first.name,
298
+ data: {},
299
+ html: first.html,
300
+ // 展开顺序即优先级:页面声明在前,调用点在后覆盖之。
301
+ // 页面未声明时不写入该键 —— 显式的 undefined 会盖掉渲染器一侧的缺省判定
302
+ opts: first.tailwind === undefined ? given : { tailwind: first.tailwind, ...given }
303
+ }
293
304
  }
294
305
 
295
306
  /**
@@ -346,7 +357,12 @@ class Context implements PluginContext<unknown> {
346
357
  this.kv = deps.kv
347
358
  this.config = deps.config
348
359
  this.app = deps.app
349
- this.http = deps.http
360
+ // 把卸载信号并进 HTTP 客户端的默认值:插件卸载后它发出的请求随即中止,
361
+ // 不必依赖每个插件作者都记得手动传 `ctx.signal`。忘记传就漏一个,
362
+ // 而漏掉的表现是「插件已卸载,它的请求还在跑」——
363
+ // 停机时那些孤儿请求还会把关闭连接池的一步拖满整个超时。
364
+ // extend() 与根客户端共享连接池,只是多一层默认值,没有额外开销。
365
+ this.http = deps.http.extend({ signal: deps.signal })
350
366
  this.signal = deps.signal
351
367
  }
352
368