@lynx-go/ui-kit 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/package.json +66 -4
- package/src/adapters/evaluate.ts +20 -0
- package/src/adapters/http.ts +217 -0
- package/src/adapters/index.ts +45 -0
- package/src/adapters/mock-transport.ts +179 -0
- package/src/adapters/mock.ts +173 -0
- package/src/adapters/subscription.ts +336 -0
- package/src/adapters/transport.ts +262 -0
- package/src/kernel/algebra.ts +153 -0
- package/src/kernel/index.ts +4 -0
- package/src/kernel/keys.ts +99 -0
- package/src/kernel/resource.ts +181 -0
- package/src/kernel/types.ts +128 -0
- package/src/react/bindings.ts +231 -0
- package/src/react/command.ts +122 -0
- package/src/react/index.ts +46 -0
- package/src/react/invalidate.ts +81 -0
- package/src/react/options.ts +102 -0
- package/src/react/permissions.ts +39 -0
- package/src/react/preferences.ts +66 -0
- package/src/react/subscription.ts +123 -0
- package/src/schema/codec.ts +290 -0
- package/src/schema/index.ts +9 -0
- package/README.md +0 -3
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { ApiError, CollectionQuery, ResourceScope, Segment } from "../kernel";
|
|
2
|
+
import { decodeClientCursor, encodeClientCursor, evaluateQuery, isApiError } from "../kernel";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* mockResource —— Resource/Transport 契约的内存实现。
|
|
6
|
+
* 双职责:测试工具(延迟/失败注入/三态)+ demo 数据源(双方言演示)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type MockFetchAll<T> = ((
|
|
10
|
+
ctx: ResourceScope,
|
|
11
|
+
signal?: AbortSignal,
|
|
12
|
+
) => Promise<ReadonlyArray<T>>) &
|
|
13
|
+
MockHandle<T>;
|
|
14
|
+
|
|
15
|
+
export interface MockHandle<T> {
|
|
16
|
+
/** 当前行快照(demo 命令闭包用:create/delete 基于现值改写) */
|
|
17
|
+
rows(): ReadonlyArray<T>;
|
|
18
|
+
/** 替换全部行(demo 动态数据 / 测试重置) */
|
|
19
|
+
replaceRows(rows: ReadonlyArray<T>): void;
|
|
20
|
+
/** 下一次 collection/record/batch 调用按给定失败 reject(三态与闭环测试) */
|
|
21
|
+
failNext(error: ApiError): void;
|
|
22
|
+
/** 当前行数 */
|
|
23
|
+
readonly count: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type MockDialect = "keyset" | "offset";
|
|
27
|
+
|
|
28
|
+
export interface MockCollectionOptions {
|
|
29
|
+
dialect?: MockDialect;
|
|
30
|
+
latencyMs?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function delay(ms: number): Promise<void> {
|
|
34
|
+
return ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function maybeFail<T>(
|
|
38
|
+
handle: { takeFailure(): ApiError | undefined },
|
|
39
|
+
work: () => Promise<T>,
|
|
40
|
+
): Promise<T> {
|
|
41
|
+
const failure = handle.takeFailure();
|
|
42
|
+
if (failure) throw failure;
|
|
43
|
+
return work();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class MockHandleImpl<T> implements MockHandle<T> {
|
|
47
|
+
failureQueue: ApiError[] = [];
|
|
48
|
+
private store: T[];
|
|
49
|
+
constructor(rows: ReadonlyArray<T>) {
|
|
50
|
+
this.store = [...rows];
|
|
51
|
+
}
|
|
52
|
+
rows(): ReadonlyArray<T> {
|
|
53
|
+
return [...this.store];
|
|
54
|
+
}
|
|
55
|
+
replaceRows(rows: ReadonlyArray<T>): void {
|
|
56
|
+
this.store = [...rows];
|
|
57
|
+
}
|
|
58
|
+
failNext(error: ApiError): void {
|
|
59
|
+
this.failureQueue.push(error);
|
|
60
|
+
}
|
|
61
|
+
takeFailure(): ApiError | undefined {
|
|
62
|
+
return this.failureQueue.shift();
|
|
63
|
+
}
|
|
64
|
+
get count(): number {
|
|
65
|
+
return this.store.length;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface MockCollection<T> {
|
|
70
|
+
collection: (
|
|
71
|
+
query: CollectionQuery,
|
|
72
|
+
ctx: ResourceScope,
|
|
73
|
+
signal?: AbortSignal,
|
|
74
|
+
) => Promise<Segment<T>>;
|
|
75
|
+
record: (id: string, ctx: ResourceScope) => Promise<T>;
|
|
76
|
+
batch: (ids: ReadonlyArray<string>, ctx: ResourceScope) => Promise<ReadonlyArray<T>>;
|
|
77
|
+
handle: MockHandle<T>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 服务端方言模拟(keyset / offset)。谓词与排序照常求值(模拟服务端过滤),
|
|
82
|
+
* 分页按方言切片:keyset 发不透明 token(空页停发),offset 附带 total。
|
|
83
|
+
*/
|
|
84
|
+
export function mockCollection<T extends { id: string }>(
|
|
85
|
+
initialRows: ReadonlyArray<T>,
|
|
86
|
+
options: MockCollectionOptions = {},
|
|
87
|
+
): MockCollection<T> {
|
|
88
|
+
const handle = new MockHandleImpl(initialRows);
|
|
89
|
+
const { dialect = "offset", latencyMs = 0 } = options;
|
|
90
|
+
|
|
91
|
+
const collection = async (query: CollectionQuery): Promise<Segment<T>> => {
|
|
92
|
+
return maybeFail(handle, async () => {
|
|
93
|
+
await delay(latencyMs);
|
|
94
|
+
// 先全量求值谓词/排序(模拟服务端),再按方言切窗口
|
|
95
|
+
const w = query.window;
|
|
96
|
+
let offset: number;
|
|
97
|
+
if (w.cursor !== undefined) {
|
|
98
|
+
const decoded = decodeClientCursor(w.cursor);
|
|
99
|
+
offset = decoded ?? 0;
|
|
100
|
+
} else {
|
|
101
|
+
offset = w.range?.offset ?? 0;
|
|
102
|
+
}
|
|
103
|
+
const serverQuery: CollectionQuery =
|
|
104
|
+
dialect === "keyset" || w.cursor !== undefined
|
|
105
|
+
? { ...query, window: { size: w.size, cursor: encodeClientCursor(offset) } }
|
|
106
|
+
: { ...query, window: { size: w.size } };
|
|
107
|
+
const segment = evaluateQuery(serverQuery, handle.rows());
|
|
108
|
+
const out: Segment<T> = { rows: segment.rows };
|
|
109
|
+
if (dialect === "offset") out.total = segment.total ?? handle.rows().length;
|
|
110
|
+
else if (segment.rows.length === w.size && offset + w.size < handle.rows().length)
|
|
111
|
+
out.nextCursor = encodeClientCursor(offset + w.size);
|
|
112
|
+
return out;
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const record = async (id: string): Promise<T> => {
|
|
117
|
+
return maybeFail(handle, async () => {
|
|
118
|
+
await delay(latencyMs);
|
|
119
|
+
const row = handle.rows().find((r) => r.id === id);
|
|
120
|
+
if (!row) throw { kind: "not-found" } satisfies ApiError;
|
|
121
|
+
return row;
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const batch = async (ids: ReadonlyArray<string>): Promise<ReadonlyArray<T>> => {
|
|
126
|
+
return maybeFail(handle, async () => {
|
|
127
|
+
await delay(latencyMs);
|
|
128
|
+
const byId = new Map(handle.rows().map((r) => [r.id, r]));
|
|
129
|
+
return ids.flatMap((id) => {
|
|
130
|
+
const row = byId.get(id);
|
|
131
|
+
return row ? [row] : [];
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
collection,
|
|
138
|
+
record,
|
|
139
|
+
batch,
|
|
140
|
+
handle: {
|
|
141
|
+
rows: () => handle.rows(),
|
|
142
|
+
replaceRows: (rows) => handle.replaceRows(rows),
|
|
143
|
+
failNext: (error) => handle.failNext(error),
|
|
144
|
+
get count() {
|
|
145
|
+
return handle.count;
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 整取数据源(clientCollection 的 fetchAll)。 */
|
|
152
|
+
export function mockFetchAll<T>(
|
|
153
|
+
initialRows: ReadonlyArray<T>,
|
|
154
|
+
options: { latencyMs?: number } = {},
|
|
155
|
+
): MockFetchAll<T> {
|
|
156
|
+
const handle = new MockHandleImpl([...initialRows]);
|
|
157
|
+
const fn = async (): Promise<ReadonlyArray<T>> => {
|
|
158
|
+
const failure = handle.takeFailure();
|
|
159
|
+
if (failure) throw failure;
|
|
160
|
+
await delay(options.latencyMs ?? 0);
|
|
161
|
+
return handle.rows();
|
|
162
|
+
};
|
|
163
|
+
return Object.assign(fn, {
|
|
164
|
+
rows: () => handle.rows(),
|
|
165
|
+
replaceRows: (rows: ReadonlyArray<T>) => handle.replaceRows(rows),
|
|
166
|
+
failNext: (error: ApiError) => handle.failNext(error),
|
|
167
|
+
get count() {
|
|
168
|
+
return handle.count;
|
|
169
|
+
},
|
|
170
|
+
}) as MockFetchAll<T>;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export { isApiError };
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
import { Effect, Queue, Stream } from "effect";
|
|
2
|
+
import type {
|
|
3
|
+
ApiError,
|
|
4
|
+
Cursor,
|
|
5
|
+
FrameSource,
|
|
6
|
+
SubscriptionPolicy,
|
|
7
|
+
SubscriptionStatus,
|
|
8
|
+
Transport,
|
|
9
|
+
} from "../kernel";
|
|
10
|
+
import { isApiError, isRetryable } from "../kernel";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 缝 3 —— 订阅引擎(design.md §5.3;零 React,Promise 站边界)。
|
|
14
|
+
*
|
|
15
|
+
* 管线:Stream.fromAsyncIterable(transport.open(cursor, signal))
|
|
16
|
+
* → tap(游标推进 / 退避归零 / onFrame)
|
|
17
|
+
* → Queue.offer(Queue.sliding(policy.buffer)) ←—— 环形缓冲(超限丢最旧)
|
|
18
|
+
* → 泵纤维 Stream.fromQueue → 快照镜像 + 通知(useSyncExternalStore 的外部存储)
|
|
19
|
+
*
|
|
20
|
+
* 与 §5.3 原案的两处实现期裁决(已回写 design.md):
|
|
21
|
+
* 1. 退避不用 Stream.retry/Schedule——schedule 状态无法在"连接恢复递送帧"时归零
|
|
22
|
+
* (fleetly 语义:每次断线从 backoffMs[0] 重新计数),用显式索引驱动;
|
|
23
|
+
* 2. 连接侧不用 Stream.runIntoQueue——它会在流完成时以 Cause.Done 终结队列,
|
|
24
|
+
* 重连需要复用同一环形缓冲,改用 runForEach + Queue.offer。
|
|
25
|
+
*
|
|
26
|
+
* 终态分诊(§2.3):closeReason 归 transport——引擎只消费 reason。
|
|
27
|
+
* - 迭代器正常结束 → 按 closeReason 分诊:ended/gapped 停机待命,auth/transport → error;
|
|
28
|
+
* - 迭代器抛错 → isApiError 且不可重试 → error(401 闭环在订阅侧的落点);
|
|
29
|
+
* 可重试(network/server)或未知错误 → 传输中断 → 退避重连,耗尽 → stalled;
|
|
30
|
+
* - 引擎主动 abort(pause/stop/restart/resync)→ AbortError 与传输错误严格区分
|
|
31
|
+
* (代数递增接管:任何句柄操作先 bump generation,旧驱动环静默退出)。
|
|
32
|
+
*
|
|
33
|
+
* 状态映射(SubscriptionStatus 闭合集的实现期裁决):
|
|
34
|
+
* - "reconnecting" 覆盖一切连接建立中(含首连与退避等待);"live" = open 成功
|
|
35
|
+
* (安静但健康的流不失真);未启动/已 stop 的句柄读 status 得 "reconnecting"/"ended"。
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export interface SubscriptionState {
|
|
39
|
+
readonly status: SubscriptionStatus;
|
|
40
|
+
readonly error: ApiError | null;
|
|
41
|
+
readonly resyncs: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** kernel §2.3 的命令式句柄(引擎实现;useSubscription 直接透传)。 */
|
|
45
|
+
export interface SubscriptionHandle<F> {
|
|
46
|
+
readonly frames: ReadonlyArray<F>;
|
|
47
|
+
readonly status: SubscriptionStatus;
|
|
48
|
+
readonly error: ApiError | null;
|
|
49
|
+
readonly resyncs: number;
|
|
50
|
+
pause(): void;
|
|
51
|
+
resume(): void;
|
|
52
|
+
/** 手动重连(fleetly "Reconnect stream" / torchwood 手动重连);重置退避预算 */
|
|
53
|
+
restart(): void;
|
|
54
|
+
/** gapped 后重锚:清缓冲 + 从新锚点重开(缺省锚点 = 活尾 null);resyncs++ 供黄条渲染 */
|
|
55
|
+
resync(anchor?: Cursor | null): void;
|
|
56
|
+
clear(): void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SubscriptionEngine<F> extends SubscriptionHandle<F> {
|
|
60
|
+
start(): void;
|
|
61
|
+
/** 终结:断开连接 + 关队列 + 泵退出;status → ended(引擎侧收流,干净终态) */
|
|
62
|
+
stop(): void;
|
|
63
|
+
/** 帧回调(quickstart 命令式消费 / invalidateOnFrame 组合的落点);返回退订函数 */
|
|
64
|
+
onFrame(callback: (frame: F) => void): () => void;
|
|
65
|
+
/** uSES 状态存储接线(帧到达不触发状态订阅者重渲染) */
|
|
66
|
+
subscribeStatus(listener: () => void): () => void;
|
|
67
|
+
getState(): SubscriptionState;
|
|
68
|
+
/** uSES 帧存储接线(LogViewer/EventFeed 等帧消费块专用) */
|
|
69
|
+
subscribeFrames(listener: () => void): () => void;
|
|
70
|
+
getFrames(): ReadonlyArray<F>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface SubscriptionEngineOptions<F> {
|
|
74
|
+
transport: Transport<F>;
|
|
75
|
+
/** 初始锚点(backlog→follow 交接点;null/缺省 = 活尾) */
|
|
76
|
+
cursor?: Cursor | null;
|
|
77
|
+
/** 帧游标投影——重连/续订的恢复点;日志流无游标语义可不传 */
|
|
78
|
+
getCursor?: (frame: F) => Cursor | undefined;
|
|
79
|
+
policy?: SubscriptionPolicy;
|
|
80
|
+
onFrame?: (frame: F) => void;
|
|
81
|
+
/** 默认 true:创建即开(useSubscription 挂载即订);命令式消费可关 */
|
|
82
|
+
autoStart?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sleep(ms: number): Promise<void> {
|
|
86
|
+
return Effect.runPromise(Effect.sleep(ms));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createSubscriptionEngine<F>(
|
|
90
|
+
options: SubscriptionEngineOptions<F>,
|
|
91
|
+
): SubscriptionEngine<F> {
|
|
92
|
+
const transport = options.transport;
|
|
93
|
+
const backoffMs: ReadonlyArray<number> = options.policy?.retry?.backoffMs ?? [];
|
|
94
|
+
const capacity = Math.max(1, Math.floor(options.policy?.buffer ?? 200));
|
|
95
|
+
|
|
96
|
+
// —— 外部存储(uSES):快照缓存,变更即重建引用 ——
|
|
97
|
+
let state: SubscriptionState = { status: "reconnecting", error: null, resyncs: 0 };
|
|
98
|
+
let frames: F[] = [];
|
|
99
|
+
let framesCache: ReadonlyArray<F> | null = null;
|
|
100
|
+
const stateListeners = new Set<() => void>();
|
|
101
|
+
const frameListeners = new Set<() => void>();
|
|
102
|
+
const frameCallbacks = new Set<(frame: F) => void>();
|
|
103
|
+
|
|
104
|
+
// —— 驱动状态(退避状态与 transport 实例分离:每代驱动独立,StrictMode 双挂载幂等) ——
|
|
105
|
+
let generation = 0;
|
|
106
|
+
let running = false;
|
|
107
|
+
let attempt = 0; // 已消耗的退避档位;帧递送归零
|
|
108
|
+
let cursor: Cursor | null = options.cursor ?? null;
|
|
109
|
+
let conn: AbortController | null = null;
|
|
110
|
+
let queue: Queue.Queue<F> | null = null;
|
|
111
|
+
|
|
112
|
+
function patchStatus(status: SubscriptionStatus, error: ApiError | null): void {
|
|
113
|
+
if (state.status === status && state.error === error) return;
|
|
114
|
+
state = { status, error, resyncs: state.resyncs };
|
|
115
|
+
for (const listener of stateListeners) listener();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function appendFrame(frame: F): void {
|
|
119
|
+
frames.push(frame);
|
|
120
|
+
if (frames.length > capacity) frames.splice(0, frames.length - capacity);
|
|
121
|
+
framesCache = null; // 读时重建快照(引用稳定到下一次变更)
|
|
122
|
+
for (const listener of frameListeners) listener();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function onDeliver(frame: F): void {
|
|
126
|
+
const next = options.getCursor?.(frame);
|
|
127
|
+
if (next !== undefined) cursor = next;
|
|
128
|
+
attempt = 0; // 帧恢复 → 退避从 backoffMs[0] 重新计数
|
|
129
|
+
options.onFrame?.(frame);
|
|
130
|
+
for (const callback of frameCallbacks) callback(frame);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 终态分诊:迭代器正常结束,closeReason 归 transport——四种 reason 皆为驱动停机。 */
|
|
134
|
+
function triageClose(reason: FrameSource<F>["closeReason"]): void {
|
|
135
|
+
if (reason === "ended") {
|
|
136
|
+
patchStatus("ended", null);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (reason === "gapped") {
|
|
140
|
+
patchStatus("gapped", null); // 页面监听 gapped → resync()(绑定不自动重跑 backlog)
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (reason === "auth") {
|
|
144
|
+
patchStatus("error", { kind: "auth" }); // 401 闭环:订阅通道 auth 失败走全局 handler
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
patchStatus("error", { kind: "network" });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 失败分诊:返回 true = 已定终态(驱动停机);false = 传输中断(退避重连)。 */
|
|
151
|
+
function triageFailure(error: unknown): boolean {
|
|
152
|
+
if (isApiError(error)) {
|
|
153
|
+
if (isRetryable(error)) return false;
|
|
154
|
+
patchStatus("error", error);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return false; // 未知错误(含传输层 TypeError)按传输中断处理
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function ensureQueue(gen: number): Promise<Queue.Queue<F> | null> {
|
|
161
|
+
if (queue !== null) return queue;
|
|
162
|
+
const q = await Effect.runPromise(Queue.sliding<F>(capacity));
|
|
163
|
+
if (gen !== generation) {
|
|
164
|
+
void Effect.runPromise(Queue.shutdown(q)); // boot 途中被接管(StrictMode 快速卸载)
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
queue = q;
|
|
168
|
+
// 泵纤维:环形缓冲 → 快照镜像 + 帧通知;队列 shutdown 即自然退出
|
|
169
|
+
void Effect.runPromise(
|
|
170
|
+
Stream.runForEach(Stream.fromQueue(q), (frame) => Effect.sync(() => appendFrame(frame))),
|
|
171
|
+
).catch(() => undefined);
|
|
172
|
+
return q;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function drive(gen: number): Promise<void> {
|
|
176
|
+
const q = await ensureQueue(gen);
|
|
177
|
+
if (q === null || gen !== generation) return;
|
|
178
|
+
|
|
179
|
+
while (gen === generation) {
|
|
180
|
+
const controller = new AbortController();
|
|
181
|
+
conn = controller;
|
|
182
|
+
const connGen = gen; // tap/offer 的代数哨兵:接管后的在途帧不再入缓冲
|
|
183
|
+
patchStatus("reconnecting", null);
|
|
184
|
+
|
|
185
|
+
let source: FrameSource<F> | null = null;
|
|
186
|
+
let completed = false;
|
|
187
|
+
let failure: unknown;
|
|
188
|
+
try {
|
|
189
|
+
source = transport.open(cursor, controller.signal);
|
|
190
|
+
// live = 连接建立(open 成功)。安静但健康的流(documents changes 类静流)
|
|
191
|
+
// 不该因"暂无新帧"显示 reconnecting;open 同步抛错走失败分诊。
|
|
192
|
+
patchStatus("live", null);
|
|
193
|
+
await Effect.runPromise(
|
|
194
|
+
Stream.runForEach(
|
|
195
|
+
Stream.tap(
|
|
196
|
+
Stream.fromAsyncIterable(source, (error: unknown) => error),
|
|
197
|
+
(frame) =>
|
|
198
|
+
Effect.sync(() => {
|
|
199
|
+
if (connGen === generation) onDeliver(frame);
|
|
200
|
+
}),
|
|
201
|
+
),
|
|
202
|
+
(frame) => (connGen === generation ? Queue.offer(q, frame) : Effect.succeed(false)),
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
completed = true;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
failure = error;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (gen !== generation) return; // 已被 pause/stop/restart/resync 接管
|
|
211
|
+
|
|
212
|
+
if (conn === controller) conn = null;
|
|
213
|
+
if (completed) {
|
|
214
|
+
triageClose(source?.closeReason ?? "transport"); // 四种 closeReason 皆为终态
|
|
215
|
+
running = false;
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (triageFailure(failure)) {
|
|
219
|
+
running = false;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 传输中断 → 退避重连;预算耗尽 → stalled(轮询兜底是消费方组合,§2.3)
|
|
224
|
+
if (attempt >= backoffMs.length) {
|
|
225
|
+
patchStatus("stalled", null);
|
|
226
|
+
running = false;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const delay = backoffMs[attempt]!;
|
|
230
|
+
attempt += 1;
|
|
231
|
+
patchStatus("reconnecting", null);
|
|
232
|
+
await sleep(delay);
|
|
233
|
+
if (gen !== generation) return;
|
|
234
|
+
if (transport.beforeReconnect) {
|
|
235
|
+
try {
|
|
236
|
+
await transport.beforeReconnect(); // torchwood realtime"先 refresh 再重订"落点
|
|
237
|
+
} catch {
|
|
238
|
+
// refresh 失败不阻断重订——服务端仍 401 则在下一轮以 auth error 出界
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (gen !== generation) return;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function takeover(): number {
|
|
246
|
+
generation += 1;
|
|
247
|
+
conn?.abort(); // 引擎主动 abort:AbortError 与传输错误由此区分(代数接管)
|
|
248
|
+
conn = null;
|
|
249
|
+
return generation;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function start(): void {
|
|
253
|
+
if (running) return;
|
|
254
|
+
running = true;
|
|
255
|
+
attempt = 0;
|
|
256
|
+
void drive(takeover());
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (options.autoStart !== false) start();
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
get frames() {
|
|
263
|
+
if (framesCache === null) framesCache = [...frames];
|
|
264
|
+
return framesCache;
|
|
265
|
+
},
|
|
266
|
+
get status() {
|
|
267
|
+
return state.status;
|
|
268
|
+
},
|
|
269
|
+
get error() {
|
|
270
|
+
return state.error;
|
|
271
|
+
},
|
|
272
|
+
get resyncs() {
|
|
273
|
+
return state.resyncs;
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
start,
|
|
277
|
+
stop() {
|
|
278
|
+
running = false;
|
|
279
|
+
takeover();
|
|
280
|
+
const q = queue;
|
|
281
|
+
queue = null;
|
|
282
|
+
if (q) void Effect.runPromise(Queue.shutdown(q)); // 泵随队列终结自然退出
|
|
283
|
+
patchStatus("ended", null);
|
|
284
|
+
},
|
|
285
|
+
pause() {
|
|
286
|
+
if (!running) return;
|
|
287
|
+
running = false;
|
|
288
|
+
takeover();
|
|
289
|
+
patchStatus("paused", null); // 从游标续订:cursor 保留在闭包
|
|
290
|
+
},
|
|
291
|
+
resume() {
|
|
292
|
+
if (running) return;
|
|
293
|
+
start();
|
|
294
|
+
},
|
|
295
|
+
restart() {
|
|
296
|
+
running = true;
|
|
297
|
+
attempt = 0; // 手动重连重置退避预算
|
|
298
|
+
void drive(takeover());
|
|
299
|
+
},
|
|
300
|
+
resync(anchor) {
|
|
301
|
+
frames = [];
|
|
302
|
+
framesCache = null;
|
|
303
|
+
cursor = anchor === undefined ? null : anchor;
|
|
304
|
+
state = { status: state.status, error: null, resyncs: state.resyncs + 1 };
|
|
305
|
+
for (const listener of stateListeners) listener();
|
|
306
|
+
running = true;
|
|
307
|
+
attempt = 0;
|
|
308
|
+
void drive(takeover());
|
|
309
|
+
},
|
|
310
|
+
clear() {
|
|
311
|
+
frames = [];
|
|
312
|
+
framesCache = null;
|
|
313
|
+
for (const listener of frameListeners) listener();
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
onFrame(callback) {
|
|
317
|
+
frameCallbacks.add(callback);
|
|
318
|
+
return () => frameCallbacks.delete(callback);
|
|
319
|
+
},
|
|
320
|
+
subscribeStatus(listener) {
|
|
321
|
+
stateListeners.add(listener);
|
|
322
|
+
return () => stateListeners.delete(listener);
|
|
323
|
+
},
|
|
324
|
+
getState() {
|
|
325
|
+
return state;
|
|
326
|
+
},
|
|
327
|
+
subscribeFrames(listener) {
|
|
328
|
+
frameListeners.add(listener);
|
|
329
|
+
return () => frameListeners.delete(listener);
|
|
330
|
+
},
|
|
331
|
+
getFrames() {
|
|
332
|
+
if (framesCache === null) framesCache = [...frames];
|
|
333
|
+
return framesCache;
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
}
|