@aiao/rxdb-adapter-http 0.0.26

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 (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +269 -0
  3. package/dist/RxDBAdapterHttp.d.ts +251 -0
  4. package/dist/RxDBAdapterHttp.d.ts.map +1 -0
  5. package/dist/change-feed.d.ts +66 -0
  6. package/dist/change-feed.d.ts.map +1 -0
  7. package/dist/chunking.d.ts +29 -0
  8. package/dist/chunking.d.ts.map +1 -0
  9. package/dist/conditional-cache.d.ts +175 -0
  10. package/dist/conditional-cache.d.ts.map +1 -0
  11. package/dist/config.d.ts +30 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/errors.d.ts +215 -0
  14. package/dist/errors.d.ts.map +1 -0
  15. package/dist/handler-contract.d.ts +41 -0
  16. package/dist/handler-contract.d.ts.map +1 -0
  17. package/dist/http.interface.d.ts +519 -0
  18. package/dist/http.interface.d.ts.map +1 -0
  19. package/dist/index.d.ts +25 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +864 -0
  22. package/dist/metadata.d.ts +15 -0
  23. package/dist/metadata.d.ts.map +1 -0
  24. package/dist/pagination.d.ts +30 -0
  25. package/dist/pagination.d.ts.map +1 -0
  26. package/dist/rest.d.ts +94 -0
  27. package/dist/rest.d.ts.map +1 -0
  28. package/dist/transport.d.ts +174 -0
  29. package/dist/transport.d.ts.map +1 -0
  30. package/package.json +64 -0
  31. package/src/RxDBAdapterHttp.ts +786 -0
  32. package/src/change-feed.ts +470 -0
  33. package/src/chunking.ts +68 -0
  34. package/src/conditional-cache.ts +271 -0
  35. package/src/config.ts +83 -0
  36. package/src/errors.ts +295 -0
  37. package/src/handler-contract.ts +77 -0
  38. package/src/http.interface.ts +554 -0
  39. package/src/index.ts +25 -0
  40. package/src/metadata.ts +94 -0
  41. package/src/pagination.ts +184 -0
  42. package/src/rest.ts +476 -0
  43. package/src/transport.ts +699 -0
@@ -0,0 +1,699 @@
1
+ /**
2
+ * @packageDocumentation
3
+ * HTTP transport:**适配器**发请求的唯一实现点(US-212 AC#12、#13、#16、#34)。
4
+ *
5
+ * @remarks
6
+ * handler 只产出 {@link HttpRequestSpec} 与解析响应体,拿不到 `Response`,因此
7
+ * 四条契约只能在这里落地:auth hook 在请求前调用、非 2xx 携带数字 `status`、
8
+ * 传输失败转成 core 的 `NetworkOfflineError`、超时与主动断开产生**可区分**的错误。
9
+ *
10
+ * 阶段 A **不提供** transport 覆盖点(不能换 axios / ky)。留一个可选 `transport`
11
+ * 参数,上面这四句的担保人立刻变成两个,而其中一个不在本包控制之下。
12
+ */
13
+
14
+ import { NetworkOfflineError } from '@aiao/rxdb';
15
+ import { ConditionalRequestCache, requestFingerprint } from './conditional-cache.js';
16
+ import {
17
+ HttpAdapterError,
18
+ HttpDisconnectedError,
19
+ HttpInvalidResponseError,
20
+ HttpRequestBuildError,
21
+ HttpResponseError
22
+ } from './errors.js';
23
+ import type {
24
+ HttpAuthHook,
25
+ HttpEtagUnreadableHook,
26
+ HttpEtagUnreadableReport,
27
+ HttpRequestSpec
28
+ } from './http.interface.js';
29
+
30
+ /** {@link HttpTransport} 的构造参数。 */
31
+ export interface HttpTransportOptions {
32
+ /** 相对 URL 的基地址 */
33
+ baseUrl: string;
34
+ /** 单个请求的超时上限(毫秒),已由 `resolveHttpConfig` 校验为 finite 正整数 */
35
+ requestTimeoutMs: number;
36
+ /** 适配器持有的断开信号,`disconnect()` 时 abort */
37
+ disconnectSignal: AbortSignal;
38
+ auth?: HttpAuthHook;
39
+ /** 附加到所有请求的 header */
40
+ headers?: Record<string, string>;
41
+ /**
42
+ * 条件请求配置;**缺席即禁用**,此时行为与阶段 A 逐字相同(US-212 AC#28)。
43
+ *
44
+ * @remarks
45
+ * 诊断回调挂在**这个对象里**而不是与它并列,US-215 AC#4「开关关着时回调不触发」
46
+ * 因此由结构本身担保:开关关着时整个 `conditional` 不存在,回调无处可取,
47
+ * 不需要在触发点上再判一次开关状态。
48
+ */
49
+ conditional?: { maxEntries: number; onEtagUnreadable?: HttpEtagUnreadableHook };
50
+ /**
51
+ * 每次**实际发出**的请求的结局:成功传 `null`,失败传已 `classify` 过的错误。
52
+ *
53
+ * @remarks
54
+ * 供 local-first 的可达性判定使用,接到 `ReachabilityMonitor.report` 上。
55
+ * 缺席即不上报(非浏览器宿主、独立使用 transport 的场景)。
56
+ *
57
+ * **传的是分类后的错误**:`fetch` 在 node/undici 下抛的裸 `TypeError('fetch failed')`
58
+ * 一条 `isNetworkError` 判据都不命中,原样上报等于永远判不出离线。
59
+ *
60
+ * 请求没发出去的失败(auth hook 抛错、header 非法、body 不可序列化)**不上报**——
61
+ * 那些是本地问题,与远端够不够得着无关,见 {@link HttpTransport.#prepare}。
62
+ */
63
+ reportResult?: (error: unknown | null) => void;
64
+ }
65
+
66
+ /**
67
+ * 条件请求路径上,诊断载荷需要而 {@link PreparedRequest} 里没有的两条事实。
68
+ */
69
+ interface ConditionalCallContext {
70
+ /** 操作名,必属 {@link CONDITIONAL_OPERATIONS} */
71
+ operation: string;
72
+ /** 实体名;两个条件操作都由实体驱动,恒有值 */
73
+ entityName?: string;
74
+ }
75
+
76
+ /**
77
+ * 参与条件缓存的操作名。
78
+ *
79
+ * @remarks
80
+ * 只有这两个是**幂等读**:AC#28 的原文就是「重复 `fetchMetadata` / `findByIds`」。
81
+ * `create` / `update` / `version` 同样走 {@link HttpTransport.sendJson},但缓存它们
82
+ * 没有意义(写没有可复用的表示,`version` 一次连接只问一次)。
83
+ *
84
+ * 用操作名而不是 HTTP 方法来判:本包文档里 `onFetchMetadata` 的范例就用 `POST`
85
+ * 递 `RuleGroup`(查询条件放不进 query string 是 JSON 查询 API 的常态),
86
+ * 按 `GET` 过滤会让最主要的用法一条都命不中。
87
+ *
88
+ * 名字对不上的后果是**退回阶段 A 行为**,不是错误结果——这条 fail-safe 由
89
+ * `transport.spec.ts`「写入口与 version 不参与条件缓存」冻结。
90
+ */
91
+ const CONDITIONAL_OPERATIONS: ReadonlySet<string> = new Set(['fetchMetadata', 'findByIds']);
92
+
93
+ /**
94
+ * 组织诊断文案:只陈述事实与两种可能,**不判定成因**(US-215 D1)。
95
+ *
96
+ * @remarks
97
+ * 客户端在 `headers.get('etag') === null` 上分不开「远端没发」与「跨源没暴露」,
98
+ * 写死其中一种在另一半情况下就是一句假话,会把人送去改一个本来就对的服务端。
99
+ * 所以文案给的是**两条待查线索加一个可观测量**,判断留给拿得到部署拓扑的人。
100
+ *
101
+ * **文案是英文**,与本包其余运行期消息一致:诊断串会进日志聚合与 issue,
102
+ * 混一句中文就让检索按语言分成两半。文档注释不在此列,它们不出现在运行期。
103
+ *
104
+ * @param report - 除 `message` 外的全部事实
105
+ */
106
+ const describeEtagUnreadable = (report: Omit<HttpEtagUnreadableReport, 'message'>): string => {
107
+ const subject = report.entityName === undefined ? report.operation : `${report.entityName} ${report.operation}`;
108
+ return (
109
+ `${subject}: conditionalRequests is enabled, but no ETag is readable on this 200 response ` +
110
+ `(${report.url}, Response.type=${report.responseType}); ` +
111
+ `neither this request nor later ones with the same fingerprint will send If-None-Match. ` +
112
+ `Two possibilities, indistinguishable from the client: ` +
113
+ `(1) the server did not send an ETag; ` +
114
+ `(2) the server sent one, but a cross-origin response did not list it in Access-Control-Expose-Headers. ` +
115
+ `A cross-origin response reports Response.type 'cors' in the browser, which is a clue; ` +
116
+ `if your server logs show an ETag that is unreadable here, check (2) first.`
117
+ );
118
+ };
119
+
120
+ /**
121
+ * 拼接请求 URL。
122
+ *
123
+ * @remarks
124
+ * 不用 `new URL(path, base)`:`new URL('items', 'https://x/v1')` 得到 `https://x/items`,
125
+ * 悄悄吃掉 `/v1`,只有 base 带尾斜杠才符合直觉。这里显式按段拼,四种斜杠组合同一结果。
126
+ */
127
+ const joinUrl = (baseUrl: string, url: string): string => {
128
+ if (/^https?:\/\//i.test(url)) {
129
+ return url;
130
+ }
131
+ return `${baseUrl.replace(/\/+$/, '')}/${url.replace(/^\/+/, '')}`;
132
+ };
133
+
134
+ /** 取错误的可读消息,非 Error 值也要有输出 */
135
+ const describeError = (error: unknown): string => (error instanceof Error ? error.message : String(error));
136
+
137
+ /**
138
+ * 读完并丢弃不打算解析的响应体。
139
+ *
140
+ * @remarks
141
+ * 「不解析」不等于「不消费」:node/undici 下未消费的 body 会把底层 socket 一直挂着,
142
+ * 直到该 `Response` 被 GC 才归还连接池。表现是高频调用后请求开始排队,全程没有任何报错,
143
+ * 所以只能在**每一条**不读 body 的返回路径上显式收口——`sendVoid` 的 2xx、
144
+ * `isTableExisted` 的 2xx / 404、条件请求的 304 三处。
145
+ *
146
+ * `cancel()` 的失败吞掉:连接已经出问题时它会 reject,而此时调用方要的那个结果
147
+ * (删除成功 / 表存在)已经由状态码给出了,为一次清理动作把它翻成失败是本末倒置。
148
+ */
149
+ const discardBody = async (response: Response): Promise<void> => {
150
+ await response.body?.cancel().catch(() => undefined);
151
+ };
152
+
153
+ /**
154
+ * 读非 2xx 的响应体当错误详情,读不出来就算了——**除非是 `disconnect()` 打断的**。
155
+ *
156
+ * @remarks
157
+ * 两种读失败必须分开,因为「状态码是不是仍算数」的答案相反:
158
+ *
159
+ * - **连接中途断 / 超时**:`409` 是远端给出的真实回答,连接当时是通的。丢掉详情、
160
+ * 保留 `HttpResponseError(409)` 才对——判成离线会让 `offlineFallback` 把一次业务
161
+ * 冲突静默换成陈旧缓存。这条由「拿到状态码的响应即使 body 读不完」一例冻结。
162
+ * - **`disconnect()`**:调用方自己叫停,`HttpResponseError` 就是把「我取消了」报成
163
+ * 「服务端出错了」,两者的处置完全相反。`.catch(() => undefined)` 一把吞掉时,
164
+ * {@link HttpTransport.classify} 本来分得清,只是永远收不到那个错误。
165
+ *
166
+ * 所以判据是**断开信号**而不是错误的形状:超时与断开的 `AbortError` 长得一模一样。
167
+ *
168
+ * @param response - 已拿到状态码的非 2xx 响应
169
+ * @param disconnectSignal - 适配器的断开信号
170
+ * @returns 响应体文本;读失败且非主动断开时为 `undefined`
171
+ */
172
+ export const readErrorBody = async (response: Response, disconnectSignal: AbortSignal): Promise<string | undefined> => {
173
+ try {
174
+ return await response.text();
175
+ } catch (error) {
176
+ if (disconnectSignal.aborted) {
177
+ throw error;
178
+ }
179
+ return undefined;
180
+ }
181
+ };
182
+
183
+ /**
184
+ * 非 2xx 即抛,错误里带**数字** `status`。
185
+ *
186
+ * @remarks
187
+ * 那个数字是 `isNetworkError` 第 2 条判据的命中点:拿到状态码说明连接是通的,
188
+ * 于是 401 / 409 / 422 自动不会被 `offlineFallback` 吞成缓存命中。
189
+ */
190
+ const assertOk = async (response: Response, url: string, disconnectSignal: AbortSignal): Promise<void> => {
191
+ if (response.ok) {
192
+ return;
193
+ }
194
+ throw new HttpResponseError(response.status, url, await readErrorBody(response, disconnectSignal));
195
+ };
196
+
197
+ /**
198
+ * 序列化请求体。
199
+ *
200
+ * @remarks
201
+ * 单一出口:发出去的字节与条件请求的指纹必须**同源**。两处各 `JSON.stringify` 一次迟早
202
+ * 分叉,而分叉在这里的表现是「指纹换了」——缓存永远不命中,且没有任何报错说明为什么。
203
+ *
204
+ * 失败包成 {@link HttpRequestBuildError} 而不是放它裸奔:`JSON.stringify` 遇 bigint /
205
+ * 循环引用抛的是 `TypeError`,与 `fetch` 传输失败**完全同型**。裸抛出去只要落进
206
+ * `classify()` 就会变成 `NetworkOfflineError`,被 `offlineFallback` 静默换成陈旧缓存。
207
+ *
208
+ * `JSON.stringify` 对函数 / symbol 返回 `undefined` 也算失败:静默发出一个「声明了
209
+ * `content-type: application/json` 却没有 body」的请求,只会在远端表现为莫名其妙的 400。
210
+ */
211
+ const serializeBody = (body: unknown, operation: string): string | undefined => {
212
+ if (body === undefined) {
213
+ return undefined;
214
+ }
215
+ let serialized: string | undefined;
216
+ try {
217
+ serialized = JSON.stringify(body);
218
+ } catch (error) {
219
+ throw new HttpRequestBuildError('body', operation, describeError(error));
220
+ }
221
+ if (serialized === undefined) {
222
+ throw new HttpRequestBuildError('body', operation, `JSON.stringify() returned undefined for ${typeof body}`);
223
+ }
224
+ return serialized;
225
+ };
226
+
227
+ /**
228
+ * 按 fetch 规范不得携带请求体的方法。
229
+ *
230
+ * @remarks
231
+ * 判据取自 `Request` 构造器本身,不是 RFC 的语义建议:这两个方法带 body 时
232
+ * `fetch` **同步抛** `TypeError('Request with GET/HEAD method cannot have body.')`。
233
+ * 其余方法(`DELETE` 含在内)fetch 一律放行,本包不替接入方否决。
234
+ */
235
+ const BODYLESS_METHODS: ReadonlySet<string> = new Set(['GET', 'HEAD']);
236
+
237
+ /**
238
+ * 方法与请求体必须相容。
239
+ *
240
+ * @remarks
241
+ * 不拦的话,`fetch` 抛的是一个裸 `TypeError`——与传输失败**完全同型**。它落进
242
+ * {@link HttpTransport.classify} 会被包成 `NetworkOfflineError`,于是一次
243
+ * 「模板配错了方法」被报成「远端够不着」,`offlineFallback` 静默换上陈旧缓存,
244
+ * 同时 `reportResult` 把可达性面板翻成离线。这正是 {@link HttpRequestBuildError}
245
+ * 要从传输失败里摘出来的那一类。
246
+ *
247
+ * 大小写归一后再判:`spec.method` 的类型是 `HttpMethod`,但 handler 是接入方的代码,
248
+ * 运行时给一个 `'get'` 不会有任何东西拦住它,而 `fetch` 会把它规范化成 `GET` 再抛。
249
+ */
250
+ const assertMethodAllowsBody = (method: string, body: string | undefined, operation: string): void => {
251
+ if (body === undefined || !BODYLESS_METHODS.has(method.toUpperCase())) {
252
+ return;
253
+ }
254
+ throw new HttpRequestBuildError('method', operation, `${method.toUpperCase()} requests cannot carry a body`);
255
+ };
256
+
257
+ /**
258
+ * 拼出来的地址必须解析得了。
259
+ *
260
+ * @remarks
261
+ * 与上面同一条理由,只是入口不同:`baseUrl: 'api'` 拼出 `api/items`,`fetch` 抛
262
+ * `TypeError('Failed to parse URL from api/items')`,一样被读成离线。构造期的
263
+ * `assertBaseUrl` 只查非空——它拦不住这条,因为**浏览器里相对 `baseUrl` 是合法的**,
264
+ * 一刀切会砍掉同源部署这种最常见的用法。
265
+ *
266
+ * 所以判在这里,且以 `location` 为基准兜第二次:有 `location` 的宿主(浏览器)里
267
+ * `/api/items` 解析得了,没有的宿主(node)里解析不了——两边的答案各自都正确。
268
+ */
269
+ const assertResolvableUrl = (url: string, operation: string): void => {
270
+ if (URL.canParse(url) || (globalThis.location !== undefined && URL.canParse(url, globalThis.location.href))) {
271
+ return;
272
+ }
273
+ throw new HttpRequestBuildError('url', operation, `"${url}" is not a parsable URL`);
274
+ };
275
+
276
+ /**
277
+ * 按 HTTP 语义(大小写不敏感)把一层 header 叠到目标上,后来者覆盖。
278
+ *
279
+ * @remarks
280
+ * 全部小写化再写入:`Headers` 最终也会小写化,提前对齐就不会出现「同一个 header
281
+ * 以两种拼写共存」的中间态。少了这一步,覆盖会静默退化成 RFC 7230 的字段合并
282
+ * (`旧, 新`),而认证 header 上那正是「旧凭据没被换掉」。
283
+ */
284
+ const mergeHeaders = (target: Record<string, string>, source?: Record<string, string>): Record<string, string> => {
285
+ for (const [name, value] of Object.entries(source ?? {})) {
286
+ target[name.toLowerCase()] = value;
287
+ }
288
+ return target;
289
+ };
290
+
291
+ /**
292
+ * 借 `Headers` 的解析器校验 header,返回**普通对象**。
293
+ *
294
+ * @remarks
295
+ * 不自己写 RFC 7230 的 token / field-value 规则:多一份实现就多一处会和 `fetch` 分叉的
296
+ * 判定。返回 `Record` 而不是 `Headers` 实例,是因为 wire 上原本就是普通对象,换类型会
297
+ * 让「header 优先级」那组断言换一种写法却测不到新东西。
298
+ *
299
+ * 非法 header 必须在这里就地失败:auth hook 拼出带 CRLF 的 token 时,把请求发出去的
300
+ * 后果是 `fetch` 抛 `TypeError`——又一次和传输失败同型,又一次被当成离线。
301
+ */
302
+ const validateHeaders = (headers: Record<string, string>, operation: string): Record<string, string> => {
303
+ try {
304
+ return Object.fromEntries(new Headers(headers));
305
+ } catch (error) {
306
+ throw new HttpRequestBuildError('headers', operation, describeError(error));
307
+ }
308
+ };
309
+
310
+ /** 解码 2xx 响应体;代理返回 `200` + HTML 错误页时给出带 URL 与状态码的错误 */
311
+ const decodeJson = async (response: Response, url: string): Promise<unknown> => {
312
+ const text = await response.text();
313
+ try {
314
+ return JSON.parse(text) as unknown;
315
+ } catch (error) {
316
+ throw new HttpInvalidResponseError(response.status, url, describeError(error));
317
+ }
318
+ };
319
+
320
+ /**
321
+ * 已构造完毕、可以直接上线的请求。
322
+ *
323
+ * @remarks
324
+ * 存在的意义是把「构造」与「发送」切开:构造在超时窗口**之外**(它不涉及网络,
325
+ * 失败是本地问题),发送在窗口**之内**。切开之后,请求体只序列化一次——条件请求的
326
+ * 指纹与真正发出去的字节读的是同一个 {@link PreparedRequest.body},从结构上不可能分叉。
327
+ */
328
+ interface PreparedRequest {
329
+ /** 已与 baseUrl 拼接完成的绝对地址 */
330
+ url: string;
331
+ method: string;
332
+ /** 已过 `Headers` 解析器校验的 header */
333
+ headers: Record<string, string>;
334
+ /**
335
+ * handler 给的那一组 header,名字已折成小写。
336
+ *
337
+ * @remarks
338
+ * 与 {@link PreparedRequest.headers} **分开留一份**,因为条件请求的指纹只认这一组:
339
+ * 另外两组(适配器级静态配置、auth hook)一个恒定、一个每次轮换,进指纹分别是
340
+ * 白加前缀和全量失效。分组理由见 `conditional-cache.ts` 的模块头。
341
+ *
342
+ * 合完之后再想拆回来是拆不出的——三组叠加后同名键只剩一个值。
343
+ */
344
+ variant: Record<string, string>;
345
+ /** 已序列化的请求体;无 body 时为 `undefined` */
346
+ body?: string;
347
+ }
348
+
349
+ /**
350
+ * 执行 handler 产出的请求描述。
351
+ *
352
+ * @remarks
353
+ * 一次请求一个 `AbortController`:它同时接超时定时器与适配器的断开信号,
354
+ * 但**记录是谁先触发的**——两者都表现为 `AbortError`,而语义相反。
355
+ * 超时可降级(远端暂时够不着),断开不可降级(是调用方自己叫停的)。
356
+ */
357
+ export class HttpTransport {
358
+ /** 条件请求的响应缓存;未启用时为 `undefined`,整条 AC#28 路径随之不存在 */
359
+ readonly #conditional?: ConditionalRequestCache;
360
+
361
+ constructor(private readonly options: HttpTransportOptions) {
362
+ this.#conditional = options.conditional && new ConditionalRequestCache(options.conditional.maxEntries);
363
+ }
364
+
365
+ /**
366
+ * 发请求并解码 JSON 响应体,非 2xx 抛错。
367
+ *
368
+ * @remarks
369
+ * 启用条件请求且 `operation` 属于 {@link CONDITIONAL_OPERATIONS} 时,本方法额外做两件事
370
+ * (US-212 AC#28):带上 `if-none-match`、把 304 还原成上次 200 的解析结果。
371
+ * 未启用时这两条都不发生,行为与阶段 A 逐字相同。
372
+ *
373
+ * @param spec - handler 产出的请求描述
374
+ * @param operation - 出错时写进错误的操作名,如 `fetchMetadata`
375
+ * @param entityName - 仅用于诊断载荷(US-215)。**不影响任何判定**:传不传都不改变
376
+ * 是否走条件缓存,免得漏传一个诊断参数变成静默丢缓存——那正是本参数要治的病
377
+ * @returns 已 JSON 解码的响应体
378
+ * @throws HttpResponseError 响应状态非 2xx(带数字 `status`)。**未请求校验却收到 304
379
+ * 也走这里**——静默当成空集会让整表判成孤儿
380
+ * @throws HttpInvalidResponseError 状态 2xx 但响应体不是合法 JSON
381
+ * @throws HttpRequestBuildError 请求体不可序列化,或 header 非法(请求不发出)
382
+ * @throws NetworkOfflineError 传输失败或单请求超时
383
+ * @throws HttpDisconnectedError 请求被 `disconnect()` 取消
384
+ */
385
+ async sendJson(spec: HttpRequestSpec, operation: string, entityName?: string): Promise<unknown> {
386
+ const prepared = await this.#prepare(spec, operation);
387
+ const cache = this.#conditional;
388
+ if (!cache || !CONDITIONAL_OPERATIONS.has(operation)) {
389
+ return this.#sendJsonDirect(prepared, operation);
390
+ }
391
+ // 指纹读的就是要发出去的那份字节,不再单独 stringify 一次
392
+ const key = requestFingerprint(prepared.method, prepared.url, prepared.body, prepared.variant);
393
+ return cache.singleFlight(key, () => this.#sendJsonConditional(cache, key, prepared, { operation, entityName }));
394
+ }
395
+
396
+ /**
397
+ * 清空条件请求缓存。
398
+ *
399
+ * @remarks
400
+ * 由适配器的 `disconnect()` 调用(AC#28 的「`disconnect()` 清空」)。未启用条件请求时
401
+ * 是 no-op——没有缓存可清,不是错误。
402
+ */
403
+ clearConditionalCache(): void {
404
+ this.#conditional?.clear();
405
+ }
406
+
407
+ /**
408
+ * 发请求、只判状态码,**不碰响应体**。
409
+ *
410
+ * @remarks
411
+ * 给 `delete` duck 用:core 的 duck 签名返回 `Observable<void>`,没有可解析的东西,
412
+ * 而 `204 No Content` 的空体喂给 {@link sendJson} 会当场变成「响应体不是合法 JSON」——
413
+ * 一次成功的删除被报成协议错误。
414
+ *
415
+ * 不解析但要**读完**:理由见 {@link discardBody}。非 2xx 那一支由 `assertOk` 的
416
+ * `response.text()` 顺带消费掉,只有成功路径需要显式收口。
417
+ *
418
+ * @param spec - handler 产出的请求描述
419
+ * @param operation - 出错时写进错误的操作名
420
+ * @throws HttpResponseError 响应状态非 2xx(带数字 `status`)
421
+ * @throws HttpRequestBuildError 请求体不可序列化,或 header 非法(请求不发出)
422
+ * @throws NetworkOfflineError 传输失败或单请求超时
423
+ * @throws HttpDisconnectedError 请求被 `disconnect()` 取消
424
+ */
425
+ async sendVoid(spec: HttpRequestSpec, operation: string): Promise<void> {
426
+ await this.execute(spec, operation, async response => {
427
+ await assertOk(response, this.resolveUrl(spec), this.options.disconnectSignal);
428
+ await discardBody(response);
429
+ });
430
+ }
431
+
432
+ /**
433
+ * 把请求描述里的相对 URL 拼成最终地址。
434
+ *
435
+ * @remarks
436
+ * 给 `isTableExisted` 用:它按状态码自己分流,抛错时要能报出和其他路径**一致**的 URL。
437
+ * 让调用方自己拼一遍,两处规则迟早分叉。
438
+ */
439
+ resolveUrl(spec: HttpRequestSpec): string {
440
+ return joinUrl(this.options.baseUrl, spec.url);
441
+ }
442
+
443
+ /**
444
+ * 发请求,并在**超时窗口内**消费响应。
445
+ *
446
+ * @remarks
447
+ * 给 `isTableExisted` 用:它要把 404 读成「表不存在」而不是失败,
448
+ * 所以判定必须在拿得到 `status` 的这一层做,不能交给会抛错的 {@link sendJson}。
449
+ *
450
+ * 交出的是 `consume` 回调而不是 `Response`:**响应体也必须罩在同一个 deadline 下**。
451
+ * 详见 {@link HttpTransport.#send}。
452
+ *
453
+ * @param spec - handler 产出的请求描述
454
+ * @param operation - 出错时写进错误的操作名
455
+ * @param consume - 在超时窗口内消费 `Response`;抛出的 {@link HttpAdapterError} 原样透出
456
+ * @returns `consume` 的返回值
457
+ * @throws HttpRequestBuildError 请求体不可序列化,或 header 非法(请求不发出)
458
+ * @throws NetworkOfflineError 传输失败或单请求超时
459
+ * @throws HttpDisconnectedError 请求被 `disconnect()` 取消
460
+ */
461
+ async execute<T>(spec: HttpRequestSpec, operation: string, consume: (response: Response) => Promise<T>): Promise<T> {
462
+ return this.#send(await this.#prepare(spec, operation), operation, consume);
463
+ }
464
+
465
+ /**
466
+ * 合并 header,**auth hook 优先级最高**。
467
+ *
468
+ * @remarks
469
+ * 顺序:适配器默认 → `spec.headers` → auth hook。auth 排最后是因为它是唯一
470
+ * 有正确性含义的一组——被 handler 的静态 header 覆盖掉就是发出一个未认证的请求。
471
+ *
472
+ * 合并必须**大小写不敏感**(见 {@link mergeHeaders}),否则「排最后」根本不等于「覆盖」:
473
+ * 静态配置写 `Authorization`、auth hook 返回 `authorization`,`Object.assign` 按字面键
474
+ * 当成两个 header 全留下,`Headers` 再按 RFC 把同名字段合并成 `旧, 新`——
475
+ * 请求带着一个过期凭据和一个新凭据一起上线,且没有任何一步报错。
476
+ */
477
+ private async buildHeaders(spec: HttpRequestSpec): Promise<Record<string, string>> {
478
+ const headers: Record<string, string> = mergeHeaders({}, this.options.headers);
479
+ if (spec.body !== undefined) {
480
+ headers['content-type'] = 'application/json';
481
+ }
482
+ mergeHeaders(headers, spec.headers);
483
+ if (this.options.auth) {
484
+ mergeHeaders(headers, await this.options.auth());
485
+ }
486
+ return headers;
487
+ }
488
+
489
+ /**
490
+ * 把 fetch 的失败归类成三种可判别的错误。
491
+ *
492
+ * @remarks
493
+ * 主动断开与超时都表现为 `AbortError`,靠 `timedOut` 标志分流,而不是靠错误对象——
494
+ * 二者的 `AbortError` 长得一模一样。分错的代价不对称:断开被当成超时会让「用户
495
+ * 叫停」静默变成「读缓存返回」。
496
+ *
497
+ * 传输失败一律包成 core 的 `NetworkOfflineError`,不是自定义类:`isNetworkError`
498
+ * 的第 1 条判据是 `instanceof NetworkOfflineError`,包成别的它认不出,
499
+ * 而 node/undici 的失败消息 `fetch failed` 又不命中第 5 条的正则——两头落空。
500
+ */
501
+ private classify(error: unknown, ctx: { operation: string; url: string; timedOut: boolean }): Error {
502
+ // 已经判别过的错误原样放行:`consume` 现在跑在 try 内,`assertOk` 的
503
+ // `HttpResponseError(409)` 会路过这里。再包一层就是把「远端说不行」降级成
504
+ // 「远端够不着」,`offlineFallback` 随即把一次业务冲突换成一份陈旧缓存。
505
+ if (error instanceof HttpAdapterError) {
506
+ return error;
507
+ }
508
+ if (ctx.timedOut) {
509
+ return new NetworkOfflineError(
510
+ new Error(`HTTP request to ${ctx.url} exceeded requestTimeoutMs=${this.options.requestTimeoutMs}`)
511
+ );
512
+ }
513
+ if (this.options.disconnectSignal.aborted) {
514
+ return new HttpDisconnectedError(ctx.operation);
515
+ }
516
+ return new NetworkOfflineError(error instanceof Error ? error : new Error(describeError(error)));
517
+ }
518
+
519
+ /**
520
+ * 构造请求:拼 URL、跑 auth hook、校验 header、序列化 body、判方法与 URL 是否可用。
521
+ *
522
+ * @remarks
523
+ * 整段刻意留在超时窗口**之外**,因为它一个字节都不上网——这里的失败全是本地问题
524
+ * (token 刷新失败、配置里的 header 非法、要发的数据带 bigint),
525
+ * 用「远端够不着」的错误去描述它们,只会把排查方向整个引偏。
526
+ *
527
+ * 断开检查放最前:已经 `disconnect()` 了就别再去打扰 auth hook。
528
+ */
529
+ async #prepare(spec: HttpRequestSpec, operation: string): Promise<PreparedRequest> {
530
+ if (this.options.disconnectSignal.aborted) {
531
+ throw new HttpDisconnectedError(operation);
532
+ }
533
+ // auth 在 fetch 之前:hook 抛错则请求不发出,且错误原样上抛不被包装——
534
+ // 包成 NetworkOfflineError 会让 token 过期被 offlineFallback 吞成缓存命中
535
+ const headers = await this.buildHeaders(spec);
536
+ const url = joinUrl(this.options.baseUrl, spec.url);
537
+ const body = serializeBody(spec.body, operation);
538
+ assertResolvableUrl(url, operation);
539
+ assertMethodAllowsBody(spec.method, body, operation);
540
+ return {
541
+ url,
542
+ method: spec.method,
543
+ headers: validateHeaders(headers, operation),
544
+ // 走 `mergeHeaders` 而不是直接收下 `spec.headers`:大小写折叠只能有一个实现,
545
+ // 否则 `X-Tenant` 与 `x-tenant` 发出去是同一个 header 却落在两个键上
546
+ variant: mergeHeaders({}, spec.headers),
547
+ body
548
+ };
549
+ }
550
+
551
+ /**
552
+ * 发送已构造好的请求,并在同一个 deadline 下消费响应。
553
+ *
554
+ * @remarks
555
+ * **`consume` 在 `try` 内、`clearTimeout` 之前跑,这是本方法存在的全部理由。**
556
+ * `fetch` 只 resolve 到 header 为止;此时就清掉定时器、把 `Response` 交出去,
557
+ * 剩下的 `text()` / `cancel()` 便再无任何时限——远端回一个状态行然后把 body 挂住
558
+ * (slow loris,也可能只是链路半死),调用方的 Promise 就永不 settle。
559
+ * AC#34 的「防挂起」于是只在 header 段成立,body 段完全裸奔。
560
+ *
561
+ * 同理,此时的 `disconnect()` 会让 body 流抛裸 `AbortError`:它绕过 {@link classify},
562
+ * `isNetworkError` 判 false,「断开一律 `HttpDisconnectedError`」的契约随之失守。
563
+ *
564
+ * **可达性上报也只在这里**(`reportResult`):本方法是全包唯一碰 `fetch` 的地方,
565
+ * 报在这一层,读、写、`version`、`isTableExisted` 一次覆盖齐。报在各个 duck 上要抄
566
+ * 七八遍,漏一处就是一条恢复不了的路径 —— 一个只靠 `fetchMetadata` 活着的只读页面,
567
+ * 那条路不报,网恢复了面板也一直显示离线。
568
+ */
569
+ async #send<T>(request: PreparedRequest, operation: string, consume: (response: Response) => Promise<T>): Promise<T> {
570
+ const { disconnectSignal, requestTimeoutMs, reportResult } = this.options;
571
+ const timeoutController = new AbortController();
572
+ let timedOut = false;
573
+ const timer = setTimeout(() => {
574
+ timedOut = true;
575
+ timeoutController.abort();
576
+ }, requestTimeoutMs);
577
+ // 用 AbortSignal.any 而不是手动挂 abort 监听:上面 await 过 auth hook,
578
+ // 这中间落下的 disconnect() 会让手挂的监听器永远等不到事件,请求一直悬着。
579
+ // any() 对**已经 abort** 的输入信号即刻生效,正好覆盖这个窗口。
580
+ const signal = AbortSignal.any([disconnectSignal, timeoutController.signal]);
581
+ try {
582
+ const response = await fetch(request.url, {
583
+ method: request.method,
584
+ headers: request.headers,
585
+ body: request.body,
586
+ signal
587
+ });
588
+ const value = await consume(response);
589
+ reportResult?.(null);
590
+ return value;
591
+ } catch (error) {
592
+ const classified = this.classify(error, { operation, url: request.url, timedOut });
593
+ reportResult?.(classified);
594
+ throw classified;
595
+ } finally {
596
+ clearTimeout(timer);
597
+ }
598
+ }
599
+
600
+ /** 阶段 A 的原始路径:发、判状态、解码,不碰缓存 */
601
+ async #sendJsonDirect(prepared: PreparedRequest, operation: string): Promise<unknown> {
602
+ return this.#send(prepared, operation, async response => {
603
+ await assertOk(response, prepared.url, this.options.disconnectSignal);
604
+ return decodeJson(response, prepared.url);
605
+ });
606
+ }
607
+
608
+ /**
609
+ * 条件请求路径:带 ETag 去问,按 304 / 200 两分支收口。
610
+ *
611
+ * @remarks
612
+ * **条目在发请求前就地捕获,之后不再回查缓存。** 回查会引入一个真实的空洞:并发的
613
+ * 另一条链路可能在这期间把条目挤出去(LRU 有界),于是 304 找不到可还原的 body。
614
+ * 捕获后 304 永远有对应的值,与缓存的后续变动无关。
615
+ *
616
+ * `cached` 为空时**不发** `if-none-match`,所以此时的 304 只能是远端不合协议——
617
+ * 交给 `assertOk` 抛 `HttpResponseError(304)`,绝不还原成空集。
618
+ *
619
+ * 304 走 `takeValue()` 取**副本**:返回缓存自己那份对象,上游任何一次就地改行都会
620
+ * 让之后每次 304 都带着被改过的数据回来(详见 `conditional-cache.ts` 类头「隔离」)。
621
+ */
622
+ async #sendJsonConditional(
623
+ cache: ConditionalRequestCache,
624
+ key: string,
625
+ prepared: PreparedRequest,
626
+ ctx: ConditionalCallContext
627
+ ): Promise<unknown> {
628
+ const cached = cache.get(key);
629
+ // ETag 来自远端响应头,已过一遍 Headers 解析器,不需要再校验一次
630
+ const request = cached ? { ...prepared, headers: { ...prepared.headers, 'if-none-match': cached.etag } } : prepared;
631
+ return this.#send(request, ctx.operation, async response => {
632
+ if (cached && response.status === 304) {
633
+ // 304 按 RFC 无 body,但代理与打桩实现都可能带一个,仍要收口
634
+ await discardBody(response);
635
+ return cached.takeValue();
636
+ }
637
+ await assertOk(response, prepared.url, this.options.disconnectSignal);
638
+ return this.#cacheAndReturn(cache, key, prepared.url, response, ctx);
639
+ });
640
+ }
641
+
642
+ /**
643
+ * 收下 200 的解析结果,顺带维护 ETag 条目。
644
+ *
645
+ * @remarks
646
+ * 从 {@link #sendJsonConditional} 里拆出来纯为压嵌套:`consume` 回调本身已经占掉一层,
647
+ * 内联写下去 `if (etag === null)` 就是第四层。
648
+ */
649
+ async #cacheAndReturn(
650
+ cache: ConditionalRequestCache,
651
+ key: string,
652
+ url: string,
653
+ response: Response,
654
+ ctx: ConditionalCallContext
655
+ ): Promise<unknown> {
656
+ const value = await decodeJson(response, url);
657
+ const etag = response.headers.get('etag');
658
+ if (etag === null) {
659
+ // 远端停发 ETag:留着旧条目就是拿一个再也换不到 304 的令牌去问,
660
+ // 每次都白搭一个请求头,且下一次 200 会被误判成「内容变了」
661
+ cache.delete(key);
662
+ this.#reportEtagUnreadable(cache, key, url, response, ctx);
663
+ return value;
664
+ }
665
+ cache.set(key, { etag, value });
666
+ return value;
667
+ }
668
+
669
+ /**
670
+ * 把「条件请求开着却读不到 ETag」这件事交给诊断回调(US-215)。
671
+ *
672
+ * @remarks
673
+ * **三道闸按代价从小到大排**:没配回调就什么都不做(AC#3);同一指纹只报第一次
674
+ * (AC#5,去重表随 `disconnect()` 清空);文案在确定要报之后才拼——它是纯字符串拼接,
675
+ * 没配回调的部署不该为此付钱。
676
+ *
677
+ * **回调抛错被吞掉。** 本方法在数据路径上:此刻 `value` 已经解析完毕、就等着返回给
678
+ * 调用方,让一个诊断回调的异常把一次成功的查询变成失败,比它要报告的问题严重得多。
679
+ * 吞掉的异常也没地方转发——正在报告的就是「本包没有输出通道」这件事本身。
680
+ */
681
+ #reportEtagUnreadable(
682
+ cache: ConditionalRequestCache,
683
+ key: string,
684
+ url: string,
685
+ response: Response,
686
+ ctx: ConditionalCallContext
687
+ ): void {
688
+ const hook = this.options.conditional?.onEtagUnreadable;
689
+ if (!hook || !cache.markEtagUnreadable(key)) {
690
+ return;
691
+ }
692
+ const facts = { operation: ctx.operation, entityName: ctx.entityName, url, responseType: response.type };
693
+ try {
694
+ hook({ ...facts, message: describeEtagUnreadable(facts) });
695
+ } catch {
696
+ /* 诊断通道自己失败时没有第二条通道可以报告这次失败,见方法头 */
697
+ }
698
+ }
699
+ }