@lynx-go/ui-kit 0.0.0-stage → 0.1.1

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 CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@lynx-go/ui-kit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Headless core for data-dense console UIs — Query / Command / Subscription over TanStack engines",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./src/react/index.ts",
14
+ "default": "./src/react/index.ts"
15
+ },
16
+ "./kernel": {
17
+ "types": "./src/kernel/index.ts",
18
+ "default": "./src/kernel/index.ts"
19
+ },
20
+ "./schema": {
21
+ "types": "./src/schema/index.ts",
22
+ "default": "./src/schema/index.ts"
23
+ },
24
+ "./adapters": {
25
+ "types": "./src/adapters/index.ts",
26
+ "default": "./src/adapters/index.ts"
27
+ },
28
+ "./react": {
29
+ "types": "./src/react/index.ts",
30
+ "default": "./src/react/index.ts"
31
+ }
32
+ },
33
+ "files": [
34
+ "src",
35
+ "dist"
36
+ ],
37
+ "dependencies": {
38
+ "effect": "~4.0.6"
39
+ },
40
+ "peerDependencies": {
41
+ "react": ">=19",
42
+ "@tanstack/react-query": ">=5.100",
43
+ "@tanstack/react-router": ">=1.120",
44
+ "@tanstack/react-form": ">=1.30"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "@tanstack/react-router": {
48
+ "optional": true
49
+ },
50
+ "@tanstack/react-query": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "devDependencies": {
55
+ "@tanstack/react-query": "^5.104.5",
56
+ "@tanstack/react-router": "^1.170.46",
57
+ "@types/react": "^19.3.0",
58
+ "react": "^19.3.1",
59
+ "typescript": "~5.9.3",
60
+ "vitest": "^5.0.5",
61
+ "@tanstack/react-form": "^1.33.5"
62
+ },
63
+ "scripts": {
64
+ "typecheck": "tsc -p tsconfig.json --noEmit",
65
+ "test": "vitest run",
66
+ "build": "tsup"
67
+ }
6
68
  }
@@ -0,0 +1,20 @@
1
+ import type { CollectionQuery, ResourceScope, Segment } from "../kernel";
2
+ import { evaluateQuery } from "../kernel";
3
+
4
+ export { decodeClientCursor, encodeClientCursor, evaluateQuery } from "../kernel";
5
+
6
+ /**
7
+ * clientCollection —— 客户端求值方言(求值策略,非概念)。
8
+ *
9
+ * 整取 fetchAll 后本地求值谓词/排序/窗口,伪造不透明游标(offset 编进 token),
10
+ * 兑现"游标进 URL、历史即分页栈"。fetchAll 的取数上限由消费方持有
11
+ * (fleetly 现状:projects=100 / apps=200 / deployments=50 参差,适配器常量)。
12
+ */
13
+ export function clientCollection<T>(
14
+ fetchAll: (ctx: ResourceScope, signal?: AbortSignal) => Promise<ReadonlyArray<T>>,
15
+ ): (query: CollectionQuery, ctx: ResourceScope, signal?: AbortSignal) => Promise<Segment<T>> {
16
+ return async (query, ctx, signal) => {
17
+ const rows = await fetchAll(ctx, signal);
18
+ return evaluateQuery(query, rows);
19
+ };
20
+ }
@@ -0,0 +1,217 @@
1
+ import { Data, Effect } from "effect";
2
+ import { type ApiError, isApiError } from "../kernel";
3
+
4
+ /**
5
+ * 缝 1 —— http 管线(Effect 住在缝里,Promise 站在边界上)。
6
+ *
7
+ * - 网络断层 = typed failure(HttpFailure/NetworkFailure → ApiError),defect 只留程序错误;
8
+ * - runApi 是唯一 Effect→Promise 边界:mapError 归一为 ApiError,defect 包裹上报,interrupt 放行;
9
+ * - 401 闭环:管线内先走 onUnauthorized(单飞由实现方保证),仍失败才以 ApiError.auth 出界。
10
+ */
11
+
12
+ export class HttpFailure extends Data.TaggedError("HttpFailure")<{
13
+ readonly status: number;
14
+ readonly body?: unknown;
15
+ }> {}
16
+
17
+ export class NetworkFailure extends Data.TaggedError("NetworkFailure")<{
18
+ readonly cause?: unknown;
19
+ }> {}
20
+
21
+ type AdapterFailure = HttpFailure | NetworkFailure;
22
+
23
+ function extractMessage(body: unknown): string | undefined {
24
+ if (typeof body === "object" && body !== null) {
25
+ const record = body as Record<string, unknown>;
26
+ if (typeof record.message === "string") return record.message;
27
+ if (typeof record.error === "object" && record.error !== null) {
28
+ const inner = (record.error as Record<string, unknown>).message;
29
+ if (typeof inner === "string") return inner;
30
+ }
31
+ }
32
+ return undefined;
33
+ }
34
+
35
+ function toApiError(failure: AdapterFailure): ApiError {
36
+ switch (failure._tag) {
37
+ case "NetworkFailure":
38
+ return { kind: "network", ...(failure.cause !== undefined ? { cause: failure.cause } : {}) };
39
+ case "HttpFailure": {
40
+ const { status, body } = failure;
41
+ if (status === 401) return { kind: "auth" };
42
+ if (status === 403) return { kind: "forbidden" };
43
+ if (status === 404) return { kind: "not-found" };
44
+ return {
45
+ kind: "server",
46
+ status,
47
+ ...(extractMessage(body) ? { message: extractMessage(body) } : {}),
48
+ };
49
+ }
50
+ }
51
+ }
52
+
53
+ /** 唯一边界:所有 Effect→Promise 只经它。 */
54
+ export function runApi<A>(eff: Effect.Effect<A, AdapterFailure>): Promise<A> {
55
+ return Effect.runPromise(Effect.mapError(eff, toApiError)).catch(
56
+ (err: unknown) =>
57
+ new Promise<A>((_resolve, reject) => {
58
+ if (isApiError(err)) {
59
+ reject(err);
60
+ return;
61
+ }
62
+ // defect(程序错误)——归一为可观测的包装,不混入 ApiError 封闭集
63
+ reject(new Error("fleet-ui: unexpected defect in adapter pipeline", { cause: err }));
64
+ }),
65
+ );
66
+ }
67
+
68
+ export interface HttpOptions {
69
+ baseUrl?: string;
70
+ /** 401 handler 注册点(工厂参数,非全局 provider)。签名符合"清凭证+清缓存+跳登录"。 */
71
+ onUnauthorized?: () => void | Promise<void>;
72
+ }
73
+
74
+ export type QueryParams = Record<string, string | number | boolean | ReadonlyArray<string>>;
75
+
76
+ export interface HttpRequest {
77
+ params?: QueryParams;
78
+ headers?: Record<string, string>;
79
+ signal?: AbortSignal;
80
+ /** 运行时解码(Effect Schema transform 接入点;缺省直通 as T) */
81
+ decode?: (raw: unknown) => unknown;
82
+ }
83
+
84
+ function buildUrl(baseUrl: string | undefined, path: string, params?: QueryParams): string {
85
+ const url = baseUrl ? `${baseUrl}${path}` : path;
86
+ if (!params) return url;
87
+ const search = new URLSearchParams();
88
+ for (const [key, value] of Object.entries(params)) {
89
+ if (value === undefined) continue;
90
+ if (Array.isArray(value)) {
91
+ for (const item of value) search.append(key, item); // repeated key=a&key=b(gateway 方言)
92
+ } else {
93
+ search.append(key, String(value));
94
+ }
95
+ }
96
+ const qs = search.toString();
97
+ return qs ? `${url}?${qs}` : url;
98
+ }
99
+
100
+ export function createHttp(options: HttpOptions = {}) {
101
+ function requestJson(
102
+ path: string,
103
+ init: RequestInit & { params?: QueryParams },
104
+ ): Effect.Effect<unknown, AdapterFailure> {
105
+ const url = buildUrl(options.baseUrl, path, init.params);
106
+ return Effect.tryPromise({
107
+ // 注意 arity 陷阱:单参数 signal(v4 按函数 length 探测)
108
+ try: (signal) => fetch(url, { ...init, signal: init.signal ?? signal }),
109
+ catch: (cause) => new NetworkFailure({ cause }),
110
+ }).pipe(
111
+ Effect.flatMap((response) =>
112
+ Effect.suspend((): Effect.Effect<unknown, AdapterFailure> => {
113
+ if (!response.ok) {
114
+ return readBody(response).pipe(
115
+ Effect.flatMap((body) => {
116
+ const failure: AdapterFailure = new HttpFailure({
117
+ status: response.status,
118
+ ...(body !== undefined ? { body } : {}),
119
+ });
120
+ if (response.status === 401 && options.onUnauthorized) {
121
+ // 401 闭环:管线内先行 handler,仍失败才以 failure 出界
122
+ return Effect.tryPromise({
123
+ try: async () => {
124
+ await options.onUnauthorized?.();
125
+ },
126
+ catch: () => failure,
127
+ }).pipe(Effect.flatMap(() => Effect.fail(failure)));
128
+ }
129
+ return Effect.fail(failure);
130
+ }),
131
+ );
132
+ }
133
+ return Effect.tryPromise({
134
+ try: () => response.json(),
135
+ catch: () =>
136
+ new HttpFailure({
137
+ status: response.status,
138
+ body: { message: `fleet-ui: response is not JSON (${path})` },
139
+ }) as AdapterFailure,
140
+ });
141
+ }),
142
+ ),
143
+ );
144
+ }
145
+
146
+ function readBody(response: Response): Effect.Effect<unknown, AdapterFailure> {
147
+ return Effect.tryPromise({
148
+ try: () => response.json(),
149
+ catch: () => undefined,
150
+ }).pipe(Effect.orElseSucceed(() => undefined));
151
+ }
152
+
153
+ async function request<T>(
154
+ method: string,
155
+ path: string,
156
+ req: HttpRequest & { body?: unknown },
157
+ ): Promise<T> {
158
+ const headers: Record<string, string> = { ...req.headers };
159
+ let body: BodyInit | undefined;
160
+ if (req.body !== undefined) {
161
+ headers["Content-Type"] ??= "application/json";
162
+ body = JSON.stringify(req.body);
163
+ }
164
+ const raw = await runApi(
165
+ requestJson(path, {
166
+ method,
167
+ headers,
168
+ ...(body !== undefined ? { body } : {}),
169
+ ...(req.params ? { params: req.params } : {}),
170
+ ...(req.signal ? { signal: req.signal } : {}),
171
+ }),
172
+ );
173
+ return (req.decode ? req.decode(raw) : raw) as T;
174
+ }
175
+
176
+ return {
177
+ get: <T>(path: string, req: HttpRequest = {}) => request<T>("GET", path, req),
178
+ post: <T>(path: string, body: unknown, req: HttpRequest = {}) =>
179
+ request<T>("POST", path, { ...req, body }),
180
+ put: <T>(path: string, body: unknown, req: HttpRequest = {}) =>
181
+ request<T>("PUT", path, { ...req, body }),
182
+ del: <T>(path: string, req: HttpRequest = {}) => request<T>("DELETE", path, req),
183
+ /** raw body 命令(tar 裸上传 / FormData 分片)+ raw response 逃生口(Blob 下载不过 decode)。 */
184
+ async sendRaw<T>(
185
+ path: string,
186
+ body: BodyInit,
187
+ contentType?: string,
188
+ req: HttpRequest = {},
189
+ ): Promise<T> {
190
+ const headers = { ...req.headers, ...(contentType ? { "Content-Type": contentType } : {}) };
191
+ const raw = await runApi(
192
+ requestJson(path, {
193
+ method: "POST",
194
+ headers,
195
+ body,
196
+ ...(req.signal ? { signal: req.signal } : {}),
197
+ }),
198
+ );
199
+ return (req.decode ? req.decode(raw) : raw) as T;
200
+ },
201
+ /** 非 JSON 响应面(Blob 下载/文件预览)——完全绕过 decode。 */
202
+ async getBlob(path: string, req: HttpRequest = {}): Promise<Blob> {
203
+ const url = buildUrl(options.baseUrl, path, req.params);
204
+ const response = await fetch(url, {
205
+ headers: req.headers,
206
+ ...(req.signal ? { signal: req.signal } : {}),
207
+ });
208
+ if (!response.ok) {
209
+ const failure = toApiError(new HttpFailure({ status: response.status }));
210
+ throw failure;
211
+ }
212
+ return response.blob();
213
+ },
214
+ };
215
+ }
216
+
217
+ export type Http = ReturnType<typeof createHttp>;
@@ -0,0 +1,45 @@
1
+ export {
2
+ clientCollection,
3
+ decodeClientCursor,
4
+ encodeClientCursor,
5
+ evaluateQuery,
6
+ } from "./evaluate";
7
+ export {
8
+ createHttp,
9
+ type Http,
10
+ HttpFailure,
11
+ type HttpOptions,
12
+ type HttpRequest,
13
+ NetworkFailure,
14
+ type QueryParams,
15
+ runApi,
16
+ } from "./http";
17
+ export {
18
+ type MockCollection,
19
+ type MockCollectionOptions,
20
+ type MockDialect,
21
+ type MockHandle,
22
+ mockCollection,
23
+ mockFetchAll,
24
+ } from "./mock";
25
+ export {
26
+ type MockTransport,
27
+ type MockTransportHandle,
28
+ type MockTransportOptions,
29
+ mockSeqTransport,
30
+ mockTransport,
31
+ } from "./mock-transport";
32
+ export {
33
+ createSubscriptionEngine,
34
+ type SubscriptionEngine,
35
+ type SubscriptionEngineOptions,
36
+ type SubscriptionHandle,
37
+ type SubscriptionState,
38
+ } from "./subscription";
39
+ export {
40
+ type NdjsonTransportOptions,
41
+ ndjsonTransport,
42
+ type SseMessage,
43
+ type SseTransportOptions,
44
+ sseTransport,
45
+ } from "./transport";
@@ -0,0 +1,179 @@
1
+ import type { ApiError, Cursor, FrameSource, Transport } from "../kernel";
2
+
3
+ /**
4
+ * mockTransport —— Transport 契约的内存实现(测试工具 + demo 数据源)。
5
+ *
6
+ * 命令式控制(emit/interrupt/close/failNext)作用于**当前连接**(最近一次 open);
7
+ * 控制落在已被引擎 abort 的死连接上时静默丢弃——现实语义:重连间隙发出的帧就是丢了。
8
+ *
9
+ * 自动模式(demo events 流):每次 open 按 intervalMs 递增 seq 生成帧;
10
+ * 引擎带游标重开时从 seqOfCursor(cursor)+1 续发(backlog→follow 的续订方言)。
11
+ */
12
+
13
+ export interface MockTransportHandle<F> {
14
+ /** 向当前连接递送一帧 */
15
+ emit(frame: F): void;
16
+ /** 当前连接以未知错误中断(引擎按 backoffMs 重连) */
17
+ interrupt(): void;
18
+ /** 当前连接正常收流,上报 closeReason(终态分诊入口) */
19
+ close(reason: FrameSource<F>["closeReason"]): void;
20
+ gap(): void;
21
+ end(): void;
22
+ /** 下一次 open 立即抛出该 ApiError(auth 闭环/不可重试失败测试) */
23
+ failNext(error: ApiError): void;
24
+ /** open 调用记录(含游标快照,供断言重连/续订行为) */
25
+ readonly opens: ReadonlyArray<{ cursor: Cursor | null }>;
26
+ }
27
+
28
+ export interface MockTransportOptions<F> {
29
+ /** 帧游标投影(供引擎恢复点;缺省不产生游标) */
30
+ cursorOf?: (frame: F) => Cursor | undefined;
31
+ /** 自动生成:按 seq 递增(手动 emit 与自动生成可并存) */
32
+ auto?: {
33
+ intervalMs: number;
34
+ frame: (seq: number) => F;
35
+ /** 起始 seq(无游标开流时) */
36
+ startSeq?: number;
37
+ /** 由游标解析续发起点;缺省 parseInt */
38
+ seqOfCursor?: (cursor: Cursor) => number;
39
+ };
40
+ }
41
+
42
+ type MockStep<F> =
43
+ | { kind: "frame"; frame: F }
44
+ | { kind: "close"; reason: FrameSource<F>["closeReason"] }
45
+ | { kind: "fail"; error: unknown };
46
+
47
+ export type MockTransport<F> = Transport<F> & MockTransportHandle<F>;
48
+
49
+ class MockAbortError extends Error {
50
+ constructor() {
51
+ super("mock: aborted");
52
+ this.name = "AbortError";
53
+ }
54
+ }
55
+
56
+ export function mockTransport<F>(options: MockTransportOptions<F> = {}): MockTransport<F> {
57
+ const opens: { cursor: Cursor | null }[] = [];
58
+ const failQueue: ApiError[] = [];
59
+
60
+ interface Conn {
61
+ alive: boolean;
62
+ reason: FrameSource<F>["closeReason"];
63
+ steps: MockStep<F>[];
64
+ waiters: Array<() => void>;
65
+ timer: ReturnType<typeof setInterval> | null;
66
+ detach(): void;
67
+ }
68
+ let active: Conn | null = null;
69
+
70
+ /** 直接投递到指定连接(绕过 alive 检查——abort 也要能唤醒等待者) */
71
+ function post(conn: Conn, step: MockStep<F>): void {
72
+ conn.steps.push(step);
73
+ conn.waiters.shift()?.();
74
+ }
75
+
76
+ /** 面向句柄控制:只投给活着的当前连接 */
77
+ function send(step: MockStep<F>): void {
78
+ if (active?.alive) post(active, step);
79
+ }
80
+
81
+ const handle: MockTransportHandle<F> = {
82
+ emit: (frame) => send({ kind: "frame", frame }),
83
+ interrupt: () => send({ kind: "fail", error: new TypeError("mock: connection reset") }),
84
+ close: (reason) => send({ kind: "close", reason }),
85
+ gap: () => handle.close("gapped"),
86
+ end: () => handle.close("ended"),
87
+ failNext: (error) => {
88
+ failQueue.push(error);
89
+ },
90
+ get opens() {
91
+ return opens;
92
+ },
93
+ };
94
+
95
+ return {
96
+ ...handle,
97
+ open(cursor: Cursor | null, signal: AbortSignal): FrameSource<F> {
98
+ opens.push({ cursor });
99
+ const failure = failQueue.shift();
100
+ if (failure) throw failure;
101
+
102
+ const auto = options.auto;
103
+ const conn: Conn = {
104
+ alive: true,
105
+ reason: "transport",
106
+ steps: [],
107
+ waiters: [],
108
+ timer: null,
109
+ detach() {
110
+ conn.alive = false;
111
+ if (conn.timer) clearInterval(conn.timer);
112
+ signal.removeEventListener("abort", abort);
113
+ // 唤醒所有等待者:它们会看到 steps 空 + 已死 → done(防提前 return 挂起)
114
+ for (const wake of conn.waiters.splice(0)) wake();
115
+ },
116
+ };
117
+ active = conn;
118
+
119
+ const abort = () => {
120
+ if (!conn.alive) return;
121
+ conn.detach();
122
+ post(conn, { kind: "fail", error: new MockAbortError() });
123
+ };
124
+ signal.addEventListener("abort", abort, { once: true });
125
+
126
+ if (auto) {
127
+ const seqOfCursor = auto.seqOfCursor ?? ((c: Cursor) => Number.parseInt(c, 10));
128
+ let seq = cursor === null ? (auto.startSeq ?? 0) : seqOfCursor(cursor) + 1;
129
+ conn.timer = setInterval(() => {
130
+ if (!conn.alive) return;
131
+ send({ kind: "frame", frame: auto.frame(seq) });
132
+ seq += 1;
133
+ }, auto.intervalMs);
134
+ }
135
+
136
+ const iterator: AsyncIterator<F> = {
137
+ async next(): Promise<IteratorResult<F>> {
138
+ while (conn.steps.length === 0 && conn.alive) {
139
+ await new Promise<void>((resolve) => {
140
+ conn.waiters.push(resolve);
141
+ });
142
+ }
143
+ const step = conn.steps.shift();
144
+ if (step === undefined) return { done: true, value: undefined };
145
+ if (step.kind === "frame") return { done: false, value: step.frame };
146
+ conn.detach();
147
+ if (step.kind === "close") {
148
+ conn.reason = step.reason;
149
+ return { done: true, value: undefined };
150
+ }
151
+ throw step.error;
152
+ },
153
+ async return(): Promise<IteratorResult<F>> {
154
+ conn.detach();
155
+ return { done: true, value: undefined };
156
+ },
157
+ };
158
+
159
+ return {
160
+ [Symbol.asyncIterator]: () => iterator,
161
+ get closeReason() {
162
+ return conn.reason;
163
+ },
164
+ };
165
+ },
166
+ };
167
+ }
168
+
169
+ /** 数字帧的便捷构造(测试:seq 即帧即游标)。 */
170
+ export function mockSeqTransport(
171
+ options: { intervalMs?: number; startSeq?: number } = {},
172
+ ): MockTransport<number> {
173
+ return mockTransport<number>({
174
+ cursorOf: (frame) => String(frame),
175
+ auto: options.intervalMs
176
+ ? { intervalMs: options.intervalMs, frame: (seq) => seq, startSeq: options.startSeq ?? 0 }
177
+ : undefined,
178
+ });
179
+ }