@fudanda/client 0.0.0-stage → 1.20261015.1-beta.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.
@@ -0,0 +1,102 @@
1
+ import { fillPath } from '@fudanda/shared/core';
2
+ import { ClientError } from './errors.js';
3
+ /** An instance-local registered SSE operation. Reconnect begins with a server ready
4
+ * frame; the consumer must requery durable data instead of trusting token replay. */
5
+ export function subscribeOperation(client, op, options) {
6
+ if (op.kind !== 'sse' || op.method !== 'get')
7
+ throw Error('Expected a GET SSE contract');
8
+ const path = client.resolveOperationPath(op);
9
+ if (!path)
10
+ throw Error('SSE operation is not registered on this Client');
11
+ const url = fillPath(path, options.params);
12
+ const abort = new AbortController();
13
+ let reader;
14
+ const close = () => { abort.abort(); void reader?.cancel().catch(() => { }); };
15
+ options.signal?.addEventListener('abort', close, { once: true });
16
+ if (options.signal?.aborted)
17
+ close();
18
+ const done = (async () => {
19
+ let failures = 0;
20
+ while (!abort.signal.aborted) {
21
+ options.onConnection?.(failures ? 'reconnecting' : 'connecting');
22
+ try {
23
+ const response = await client.fetchRaw(url, { signal: abort.signal, headers: { Accept: 'text/event-stream' } });
24
+ if (response.status === 401) {
25
+ options.onUnauthorized?.();
26
+ return;
27
+ }
28
+ if ([403, 404].includes(response.status))
29
+ throw new ClientError(response.status, 'stream_denied', '无法订阅该运行');
30
+ if (!response.ok || !response.body || !response.headers.get('content-type')?.startsWith('text/event-stream'))
31
+ throw Error('Invalid SSE response');
32
+ reader = response.body.getReader();
33
+ options.onConnection?.('connected');
34
+ const decoder = new TextDecoder();
35
+ let buffer = '';
36
+ while (!abort.signal.aborted) {
37
+ const part = await reader.read();
38
+ if (part.done)
39
+ break;
40
+ buffer += decoder.decode(part.value, { stream: true });
41
+ // Normalize only complete frames: CRLF can be split across TCP chunks.
42
+ let match;
43
+ while ((match = /\r?\n\r?\n/.exec(buffer))) {
44
+ const frame = buffer.slice(0, match.index).replaceAll('\r\n', '\n');
45
+ buffer = buffer.slice(match.index + match[0].length);
46
+ if (frame.length > 512 * 1024)
47
+ throw Error('SSE frame exceeds limit');
48
+ let event = 'message';
49
+ const data = [];
50
+ for (const line of frame.split('\n')) {
51
+ if (line.startsWith('event:'))
52
+ event = line.slice(6).trim();
53
+ else if (line.startsWith('data:'))
54
+ data.push(line.slice(5).replace(/^ /, ''));
55
+ }
56
+ if (event === 'session-expired') {
57
+ options.onUnauthorized?.();
58
+ return;
59
+ }
60
+ if (event === 'denied')
61
+ throw new ClientError(403, 'stream_denied', '订阅权限已失效');
62
+ if (event === 'unavailable')
63
+ throw Error('Stream unavailable');
64
+ if (!data.length)
65
+ continue;
66
+ const value = options.parse(event, JSON.parse(data.join('\n')));
67
+ if (!abort.signal.aborted)
68
+ options.onEvent(value);
69
+ failures = 0;
70
+ if (event === 'done')
71
+ return;
72
+ }
73
+ if (buffer.length > 512 * 1024)
74
+ throw Error('SSE frame exceeds limit');
75
+ }
76
+ }
77
+ catch (error) {
78
+ if (abort.signal.aborted)
79
+ return;
80
+ options.onError?.(error);
81
+ if (error instanceof ClientError && [403, 404].includes(error.status))
82
+ return;
83
+ }
84
+ finally {
85
+ await reader?.cancel().catch(() => { });
86
+ reader?.releaseLock();
87
+ reader = undefined;
88
+ }
89
+ if (abort.signal.aborted)
90
+ return;
91
+ failures++;
92
+ options.onConnection?.('reconnecting');
93
+ await new Promise(resolve => {
94
+ const finish = () => { clearTimeout(timer); abort.signal.removeEventListener('abort', finish); resolve(); };
95
+ const timer = setTimeout(finish, Math.min(15000, Math.max(250, options.reconnectDelay ?? 750) * 2 ** Math.min(failures - 1, 4)));
96
+ abort.signal.addEventListener('abort', finish, { once: true });
97
+ });
98
+ }
99
+ })().finally(() => { options.signal?.removeEventListener('abort', close); options.onConnection?.('closed'); });
100
+ return { close, done };
101
+ }
102
+ //# sourceMappingURL=event-stream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-stream.js","sourceRoot":"","sources":["../src/event-stream.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,10 @@
1
+ export { Client, validateGoPath } from './client.js';
2
+ export type { ClientOptions, RequestOptions, ApiEnvelope, Method } from './client.js';
3
+ export { ClientError, ApiError } from './errors.js';
4
+ export { subscribe } from './subscriptions.js';
5
+ export { subscribeOperation } from './event-stream.js';
6
+ export type { EventStreamOptions } from './event-stream.js';
7
+ export type { ChangeEvent, SubscriptionOptions, Subscription } from './subscriptions.js';
8
+ export { call, callRaw, operationURL, foundationPath, foundationRequestBody, isFoundationOperation, toQueryString, unwrap } from './contracts.js';
9
+ export type { JsonClient, UrlInputOf } from './contracts.js';
10
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAClD,YAAY,EAAE,aAAa,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AACnF,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAC,kBAAkB,EAAC,MAAM,gBAAgB,CAAC;AAClD,YAAY,EAAC,kBAAkB,EAAC,MAAM,gBAAgB,CAAC;AACvD,YAAY,EAAE,WAAW,EAAE,mBAAmB,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AACtF,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC;AAC/I,YAAY,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { Client, validateGoPath } from './client.js';
2
+ export { ClientError, ApiError } from './errors.js';
3
+ export { subscribe } from './subscriptions.js';
4
+ export { subscribeOperation } from './event-stream.js';
5
+ export { call, callRaw, operationURL, foundationPath, foundationRequestBody, isFoundationOperation, toQueryString, unwrap } from './contracts.js';
6
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAElD,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACjD,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAC5C,OAAO,EAAC,kBAAkB,EAAC,MAAM,gBAAgB,CAAC;AAGlD,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,EAAE,MAAM,aAAa,CAAC"}
@@ -0,0 +1,22 @@
1
+ import type { Client } from './client.js';
2
+ export interface ChangeEvent {
3
+ resources: string[];
4
+ cursor: string;
5
+ requery: boolean;
6
+ }
7
+ export interface SubscriptionOptions {
8
+ signal?: AbortSignal;
9
+ onChange: (event: ChangeEvent) => void;
10
+ onError?: (error: unknown) => void;
11
+ onUnauthorized?: () => void;
12
+ /** Bounded reconnect delay, in milliseconds. */
13
+ reconnectDelay?: number;
14
+ }
15
+ export interface Subscription {
16
+ close(): void;
17
+ done: Promise<void>;
18
+ }
19
+ /** Fetch-based SSE works with both Cookie and restricted API Key clients.
20
+ * Cursor stays in memory; ready/reconnect always requests an authorized requery. */
21
+ export declare function subscribe(client: Client, options: SubscriptionOptions): Subscription;
22
+ //# sourceMappingURL=subscriptions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscriptions.d.ts","sourceRoot":"","sources":["../src/subscriptions.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEvC,MAAM,WAAW,WAAW;IAAG,SAAS,EAAE,MAAM,EAAE,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE;AACtF,MAAM,WAAW,mBAAmB;IAClC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,QAAQ,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACvC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,cAAc,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B,gDAAgD;IAChD,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AACD,MAAM,WAAW,YAAY;IAAG,KAAK,IAAI,IAAI,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,CAAA;CAAE;AAEpE;oFACoF;AACpF,wBAAgB,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,mBAAmB,GAAG,YAAY,CAuDpF"}
@@ -0,0 +1,93 @@
1
+ import { integrationContract } from '@fudanda/shared/integrations';
2
+ import { operationURL } from './contracts.js';
3
+ import { ClientError } from './errors.js';
4
+ /** Fetch-based SSE works with both Cookie and restricted API Key clients.
5
+ * Cursor stays in memory; ready/reconnect always requests an authorized requery. */
6
+ export function subscribe(client, options) {
7
+ const controller = new AbortController();
8
+ let activeReader;
9
+ const close = () => { controller.abort(); void activeReader?.cancel().catch(() => { }); };
10
+ options.signal?.addEventListener('abort', close, { once: true });
11
+ if (options.signal?.aborted)
12
+ close();
13
+ let cursor = '';
14
+ const done = (async () => {
15
+ while (!controller.signal.aborted) {
16
+ let reader;
17
+ try {
18
+ const response = await client.fetchRaw(operationURL(integrationContract.events), { signal: controller.signal, headers: { Accept: 'text/event-stream', ...(cursor ? { 'Last-Event-ID': cursor } : {}) } });
19
+ if (response.status === 401) {
20
+ options.onUnauthorized?.();
21
+ return;
22
+ }
23
+ if (response.status === 403)
24
+ throw new ClientError(403, 'subscription_denied', '没有订阅权限');
25
+ if (!response.ok || !response.body || !response.headers.get('content-type')?.startsWith('text/event-stream'))
26
+ throw new ClientError(response.status, 'subscription_unavailable', '订阅暂不可用');
27
+ reader = response.body.getReader();
28
+ activeReader = reader;
29
+ const decoder = new TextDecoder();
30
+ let buffer = '';
31
+ while (!controller.signal.aborted) {
32
+ const part = await reader.read();
33
+ if (part.done)
34
+ break;
35
+ buffer = (buffer + decoder.decode(part.value, { stream: true })).replace(/\r\n/g, '\n');
36
+ if (buffer.length > 65536)
37
+ throw new Error('SSE frame exceeds size limit');
38
+ let boundary;
39
+ while ((boundary = buffer.indexOf('\n\n')) >= 0) {
40
+ const frame = buffer.slice(0, boundary);
41
+ buffer = buffer.slice(boundary + 2);
42
+ let event = '';
43
+ let id = '';
44
+ const data = [];
45
+ for (const line of frame.split('\n')) {
46
+ if (line.startsWith('event:'))
47
+ event = line.slice(6).trim();
48
+ else if (line.startsWith('id:'))
49
+ id = line.slice(3).trim();
50
+ else if (line.startsWith('data:'))
51
+ data.push(line.slice(5).trimStart());
52
+ }
53
+ if (/^\d+$/.test(id))
54
+ cursor = id;
55
+ if (event === 'session-expired') {
56
+ options.onUnauthorized?.();
57
+ return;
58
+ }
59
+ if (event === 'unavailable')
60
+ throw new Error('Subscription unavailable');
61
+ if (event === 'change' || event === 'ready') {
62
+ const parsed = JSON.parse(data.join('\n') || '{}');
63
+ const resources = parsed && typeof parsed === 'object' && 'resources' in parsed && Array.isArray(parsed.resources) && parsed.resources.every(value => typeof value === 'string') ? parsed.resources : [];
64
+ if (!controller.signal.aborted)
65
+ options.onChange({ resources, cursor, requery: true });
66
+ }
67
+ }
68
+ }
69
+ }
70
+ catch (error) {
71
+ if (controller.signal.aborted)
72
+ return;
73
+ options.onError?.(error);
74
+ if (error instanceof ClientError && error.status === 403)
75
+ return;
76
+ }
77
+ finally {
78
+ await reader?.cancel().catch(() => { });
79
+ reader?.releaseLock();
80
+ activeReader = undefined;
81
+ }
82
+ if (controller.signal.aborted)
83
+ return;
84
+ await new Promise(resolve => {
85
+ const finished = () => { clearTimeout(timer); controller.signal.removeEventListener('abort', finished); resolve(); };
86
+ const timer = setTimeout(finished, Math.min(30000, Math.max(250, options.reconnectDelay ?? 1500)));
87
+ controller.signal.addEventListener('abort', finished, { once: true });
88
+ });
89
+ }
90
+ })().finally(() => { options.signal?.removeEventListener('abort', close); });
91
+ return { close, done };
92
+ }
93
+ //# sourceMappingURL=subscriptions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subscriptions.js","sourceRoot":"","sources":["../src/subscriptions.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AACnE,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAcvC;oFACoF;AACpF,MAAM,UAAU,SAAS,CAAC,MAAc,EAAE,OAA4B;IACpE,MAAM,UAAU,GAAG,IAAI,eAAe,EAAE,CAAC;IACzC,IAAI,YAAiE,CAAC;IACtE,MAAM,KAAK,GAAG,GAAG,EAAE,GAAG,UAAU,CAAC,KAAK,EAAE,CAAC,CAAC,KAAK,YAAY,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IACzF,OAAO,CAAC,MAAM,EAAE,gBAAgB,CAAC,OAAO,EAAE,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;IACjE,IAAI,OAAO,CAAC,MAAM,EAAE,OAAO;QAAE,KAAK,EAAE,CAAC;IACrC,IAAI,MAAM,GAAG,EAAE,CAAC;IAChB,MAAM,IAAI,GAAG,CAAC,KAAK,IAAI,EAAE;QACvB,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;YAClC,IAAI,MAA2D,CAAC;YAChE,IAAI,CAAC;gBACH,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,QAAQ,CAAC,YAAY,CAAC,mBAAmB,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,EAAE,UAAU,CAAC,MAAM,EAAE,OAAO,EAAE,EAAE,MAAM,EAAE,mBAAmB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,eAAe,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC;gBAC1M,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG,EAAE,CAAC;oBAAC,OAAO,CAAC,cAAc,EAAE,EAAE,CAAC;oBAAC,OAAO;gBAAC,CAAC;gBACpE,IAAI,QAAQ,CAAC,MAAM,KAAK,GAAG;oBAAE,MAAM,IAAI,WAAW,CAAC,GAAG,EAAE,qBAAqB,EAAE,QAAQ,CAAC,CAAC;gBACzF,IAAI,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,EAAE,UAAU,CAAC,mBAAmB,CAAC;oBAAE,MAAM,IAAI,WAAW,CAAC,QAAQ,CAAC,MAAM,EAAE,0BAA0B,EAAE,QAAQ,CAAC,CAAC;gBAC3L,MAAM,GAAG,QAAQ,CAAC,IAAI,CAAC,SAAS,EAAE,CAAC;gBACnC,YAAY,GAAG,MAAM,CAAC;gBACtB,MAAM,OAAO,GAAG,IAAI,WAAW,EAAE,CAAC;gBAAC,IAAI,MAAM,GAAG,EAAE,CAAC;gBACnD,OAAO,CAAC,UAAU,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;oBAClC,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;oBAAC,IAAI,IAAI,CAAC,IAAI;wBAAE,MAAM;oBACvD,MAAM,GAAG,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,IAAI,CAAC,CAAC;oBACxF,IAAI,MAAM,CAAC,MAAM,GAAG,KAAK;wBAAE,MAAM,IAAI,KAAK,CAAC,8BAA8B,CAAC,CAAC;oBAC3E,IAAI,QAAgB,CAAC;oBACrB,OAAO,CAAC,QAAQ,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC;wBAChD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,QAAQ,CAAC,CAAC;wBAAC,MAAM,GAAG,MAAM,CAAC,KAAK,CAAC,QAAQ,GAAG,CAAC,CAAC,CAAC;wBAC7E,IAAI,KAAK,GAAG,EAAE,CAAC;wBAAC,IAAI,EAAE,GAAG,EAAE,CAAC;wBAAC,MAAM,IAAI,GAAa,EAAE,CAAC;wBACvD,KAAK,MAAM,IAAI,IAAI,KAAK,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;4BACrC,IAAI,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC;gCAAE,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;iCACvD,IAAI,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC;gCAAE,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;iCACtD,IAAI,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC;gCAAE,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC;wBAC1E,CAAC;wBACD,IAAI,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;4BAAE,MAAM,GAAG,EAAE,CAAC;wBAClC,IAAI,KAAK,KAAK,iBAAiB,EAAE,CAAC;4BAAC,OAAO,CAAC,cAAc,EAAE,EAAE,CAAC;4BAAC,OAAO;wBAAC,CAAC;wBACxE,IAAI,KAAK,KAAK,aAAa;4BAAE,MAAM,IAAI,KAAK,CAAC,0BAA0B,CAAC,CAAC;wBACzE,IAAI,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,OAAO,EAAE,CAAC;4BAC5C,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,CAAC;4BAC5D,MAAM,SAAS,GAAG,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,WAAW,IAAI,MAAM,IAAI,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,SAAS,CAAC,IAAI,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,KAAK,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,SAAqB,CAAC,CAAC,CAAC,EAAE,CAAC;4BACrN,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC,OAAO;gCAAE,OAAO,CAAC,QAAQ,CAAC,EAAE,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC;wBACzF,CAAC;oBACH,CAAC;gBACH,CAAC;YACH,CAAC;YAAC,OAAO,KAAK,EAAE,CAAC;gBACf,IAAI,UAAU,CAAC,MAAM,CAAC,OAAO;oBAAE,OAAO;gBACtC,OAAO,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,CAAC;gBACzB,IAAI,KAAK,YAAY,WAAW,IAAI,KAAK,CAAC,MAAM,KAAK,GAAG;oBAAE,OAAO;YACnE,CAAC;oBAAS,CAAC;gBAAC,MAAM,MAAM,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAC;gBAAC,MAAM,EAAE,WAAW,EAAE,CAAC;gBAAC,YAAY,GAAG,SAAS,CAAC;YAAC,CAAC;YACtG,IAAI,UAAU,CAAC,MAAM,CAAC,OAAO;gBAAE,OAAO;YACtC,MAAM,IAAI,OAAO,CAAO,OAAO,CAAC,EAAE;gBAChC,MAAM,QAAQ,GAAG,GAAG,EAAE,GAAG,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,mBAAmB,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,CAAC;gBACrH,MAAM,KAAK,GAAG,UAAU,CAAC,QAAQ,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,cAAc,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC;gBACnG,UAAU,CAAC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC;YACxE,CAAC,CAAC,CAAC;QACL,CAAC;IACH,CAAC,CAAC,EAAE,CAAC,OAAO,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC,MAAM,EAAE,mBAAmB,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;IAC7E,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC;AACzB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@fudanda/client",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.20261015.1-beta.1",
4
+ "type": "module",
5
+ "sideEffects": false,
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "arcbase-source": "./src/index.ts",
11
+ "types": "./dist/index.d.ts",
12
+ "development": "./src/index.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "node ../../scripts/clean-package.mjs && tsc -p tsconfig.json && tsc-alias --resolve-full-paths",
19
+ "check:types": "tsc -p tsconfig.json --noEmit",
20
+ "test": "vitest run",
21
+ "lint": "eslint src --max-warnings 0"
22
+ },
23
+ "dependencies": {
24
+ "@fudanda/shared": "1.20261015.1-beta.1"
25
+ },
26
+ "devDependencies": {
27
+ "@eslint/js": "^10.0.1",
28
+ "eslint": "^10.12.0",
29
+ "typescript": "npm:@typescript/typescript6@^6.0.2",
30
+ "typescript-eslint": "^8.71.1",
31
+ "vitest": "^5.0.3",
32
+ "tsc-alias": "^1.9.7"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src",
37
+ "README.md",
38
+ "!src/**/*.test.ts",
39
+ "!src/**/*.test.tsx",
40
+ "!dist/.tsbuildinfo",
41
+ "LICENSE"
42
+ ],
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/fudanda/arcbase.git",
47
+ "directory": "packages/client"
48
+ },
49
+ "publishConfig": {
50
+ "registry": "https://registry.npmjs.org/",
51
+ "access": "public"
52
+ }
53
+ }
package/src/client.ts ADDED
@@ -0,0 +1,203 @@
1
+ import type { ApiResponse } from '@fudanda/shared/core';
2
+ import { ClientError } from './errors';
3
+ import type { AnyOperation } from '@fudanda/shared/core';
4
+
5
+ export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
6
+ export type ApiEnvelope<T> = ApiResponse<T> & { error?: string; requestId?: string; retryAfterSeconds?: number };
7
+ export interface RequestOptions {
8
+ signal?: AbortSignal | null;
9
+ headers?: HeadersInit;
10
+ anonymousWrite?: boolean;
11
+ }
12
+ export interface ClientOptions {
13
+ baseURL?: string;
14
+ credentials?: RequestCredentials;
15
+ transport?: typeof fetch;
16
+ xhrFactory?: () => XMLHttpRequest;
17
+ onUnauthorized?: () => void;
18
+ /** For scripts/server integrations. Never persist or expose keys in browser storage. */
19
+ apiKey?: string;
20
+ /** Explicit host contracts; built-in operations remain restricted to the release catalog. */
21
+ operations?: readonly AnyOperation[];
22
+ }
23
+
24
+ const loginPaths = ['/api/v1/auth/login', '/api/v1/auth/session-conflict/resolve', '/api/v1/auth/external/start', '/api/v1/auth/external/complete'];
25
+
26
+ export function validateGoPath(path: string): void {
27
+ if (!/^\/api\/v1\/[a-zA-Z0-9][a-zA-Z0-9_%./-]*(?:\?[a-zA-Z0-9%=&_.+-]*)?$/.test(path)
28
+ || path.includes('//')
29
+ || decodeURIComponent(path.split('?')[0]).includes('//')
30
+ || /(?:^|\/)\.{1,2}(?:\/|$)/.test(decodeURIComponent(path.split('?')[0]))
31
+ || !new URL(path, 'http://localhost').pathname.startsWith('/api/v1/')) {
32
+ throw new Error('Invalid Go API path');
33
+ }
34
+ }
35
+
36
+ /** Cookie/CSRF transport. UI, user state and query caches belong to the host. */
37
+ export class Client {
38
+ private csrfToken: string | null = null;
39
+ private readonly unauthorizedListeners = new Set<() => void>();
40
+ private readonly baseURL: string;
41
+ private readonly send: typeof fetch;
42
+ private readonly credentials: RequestCredentials;
43
+ private readonly operations = new Map<AnyOperation, string>();
44
+
45
+ constructor(private readonly options: ClientOptions = {}) {
46
+ this.baseURL = (options.baseURL ?? '').replace(/\/$/, '');
47
+ if (this.baseURL && (!/^https?:\/\//.test(this.baseURL) || new URL(this.baseURL).origin !== this.baseURL)) throw new Error('baseURL must be an HTTP origin');
48
+ this.send = options.transport ?? ((...args) => globalThis.fetch(...args));
49
+ if (options.apiKey && !/^arc_[a-f0-9]{64}$/.test(options.apiKey)) throw new Error('Invalid ArcBase API Key');
50
+ this.credentials = options.credentials ?? (options.apiKey ? 'omit' : 'same-origin');
51
+ const routes = new Set<string>();
52
+ for (const op of options.operations ?? []) {
53
+ validateGoPath(op.fullPath.replaceAll(/\{[a-zA-Z][a-zA-Z0-9_]*\}/g, '1'));
54
+ if (!op.fullPath.startsWith('/api/v1/extensions/')) throw new Error('Host operations must use /api/v1/extensions/');
55
+ const key = `${op.method} ${op.fullPath}`;
56
+ if (routes.has(key)) throw new Error('Duplicate host operation');
57
+ routes.add(key); this.operations.set(op, op.fullPath);
58
+ }
59
+ }
60
+
61
+ /** Registration is instance-local and requires the exact declared operation. */
62
+ resolveOperationPath(op: AnyOperation): string | undefined { return this.operations.get(op); }
63
+
64
+ setCsrfToken(value: string | null): void { this.csrfToken = value; }
65
+ clearSession(): void { this.csrfToken = null; }
66
+ /** Long-lived authenticated transports use the same expiry notifications as HTTP 401. */
67
+ expireSession(): void { this.received(401); }
68
+ sessionHeaders(): Record<string, string> { return this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {}; }
69
+
70
+ /** Hosts can observe expiry without replacing the caller's callback. */
71
+ subscribeUnauthorized(listener: () => void): () => void {
72
+ this.unauthorizedListeners.add(listener);
73
+ return () => { this.unauthorizedListeners.delete(listener); };
74
+ }
75
+
76
+ private headers(method: string, path: string, body: BodyInit | null | undefined, options: RequestOptions): Headers {
77
+ validateGoPath(path);
78
+ if (options.anonymousWrite && (method !== 'POST' || !loginPaths.includes(path))) throw new Error('Only origin-checked login actions may write without CSRF');
79
+ const write = !['GET', 'HEAD', 'OPTIONS'].includes(method);
80
+ if (write && !options.anonymousWrite && !this.csrfToken && !this.options.apiKey) throw new ClientError(401, 'session_missing', '登录状态尚未恢复');
81
+ const headers = new Headers(options.headers);
82
+ // Caller headers cannot supply a different identity or stale CSRF secret.
83
+ headers.delete('authorization');
84
+ headers.delete('cookie');
85
+ headers.delete('x-csrf-token');
86
+ if (this.options.apiKey) headers.set('Authorization', `Bearer ${this.options.apiKey}`);
87
+ if (body instanceof FormData) headers.delete('content-type');
88
+ else if (body !== undefined && body !== null && !headers.has('content-type')) headers.set('Content-Type', 'application/json');
89
+ if (write && !options.anonymousWrite && !this.options.apiKey) headers.set('X-CSRF-Token', this.csrfToken!);
90
+ return headers;
91
+ }
92
+
93
+ private received(status: number, anonymousWrite = false): void {
94
+ if (status === 401) {
95
+ this.clearSession();
96
+ if (!anonymousWrite) {
97
+ for (const listener of this.unauthorizedListeners) listener();
98
+ this.options.onUnauthorized?.();
99
+ }
100
+ }
101
+ }
102
+
103
+ async fetchRaw(path: string, options: RequestInit & RequestOptions = {}): Promise<Response> {
104
+ const { anonymousWrite, ...init } = options;
105
+ const method = (init.method ?? 'GET').toUpperCase();
106
+ const headers = this.headers(method, path, init.body, { ...options, anonymousWrite });
107
+ let response: Response;
108
+ try { response = await this.send(this.baseURL + path, { ...init, method, headers, credentials: this.credentials, cache: 'no-store' }); }
109
+ catch (error) {
110
+ if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') throw error;
111
+ throw new ClientError(0, 'network_error', '网络请求失败,请检查网络连接');
112
+ }
113
+ // A host transport may resolve after cancellation; ignore that stale result.
114
+ if (init.signal?.aborted) throw new DOMException('已取消', 'AbortError');
115
+ this.received(response.status, anonymousWrite);
116
+ return response;
117
+ }
118
+
119
+ private async envelope<T>(response: Response): Promise<ApiEnvelope<T>> {
120
+ const requestId = response.headers.get('X-Request-Id') ?? undefined;
121
+ let payload: unknown;
122
+ try { payload = await response.json(); }
123
+ catch { throw new ClientError(response.status, 'invalid_response', '服务端响应格式错误', requestId); }
124
+ if (!payload || typeof payload !== 'object' || !('code' in payload) || typeof payload.code !== 'number'
125
+ || !('message' in payload) || typeof payload.message !== 'string' || !('data' in payload)) throw new ClientError(response.status, 'invalid_response', '服务端响应格式错误', requestId);
126
+ const result = payload as ApiEnvelope<T>;
127
+ if (!response.ok && result.code === 0) throw new ClientError(response.status, 'invalid_response', 'HTTP 状态与响应结果不一致', requestId);
128
+ const retryAfterSeconds = Number(response.headers.get('Retry-After'));
129
+ return { ...result, ...(requestId ? { requestId } : {}), ...(retryAfterSeconds > 0 ? { retryAfterSeconds } : {}) };
130
+ }
131
+
132
+ async request<T>(method: Method, path: string, options: RequestOptions & { body?: unknown } = {}): Promise<ApiEnvelope<T>> {
133
+ const { body, ...rest } = options;
134
+ const raw = body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body);
135
+ return this.requestRaw<T>(path, { ...rest, method, body: raw });
136
+ }
137
+
138
+ async requestRaw<T>(path: string, options: RequestInit & RequestOptions = {}): Promise<ApiEnvelope<T>> {
139
+ return this.envelope<T>(await this.fetchRaw(path, options));
140
+ }
141
+
142
+ get<T>(path: string, options?: RequestOptions) { return this.request<T>('GET', path, options); }
143
+ post<T>(path: string, body?: unknown, options?: RequestOptions) { return this.request<T>('POST', path, { ...options, body, anonymousWrite: loginPaths.includes(path) }); }
144
+ put<T>(path: string, body?: unknown, options?: RequestOptions) { return this.request<T>('PUT', path, { ...options, body }); }
145
+ patch<T>(path: string, body?: unknown, options?: RequestOptions) { return this.request<T>('PATCH', path, { ...options, body }); }
146
+ delete<T>(path: string, body?: unknown, options?: RequestOptions) { return this.request<T>('DELETE', path, { ...options, body }); }
147
+
148
+ async readBlob(path: string, options: RequestInit & RequestOptions = {}): Promise<Blob> {
149
+ const response = await this.fetchRaw(path, options);
150
+ if (!response.ok) {
151
+ const result = await this.envelope<unknown>(response);
152
+ throw new ClientError(response.status, result.error ?? 'download_failed', result.message || '下载失败', result.requestId);
153
+ }
154
+ if (response.headers.get('Content-Type')?.includes('application/json') && !response.headers.has('Content-Disposition')) throw new ClientError(response.status, 'invalid_response', '文件响应格式错误');
155
+ return response.blob();
156
+ }
157
+
158
+ async postForm<T>(path: string, body: FormData, options: RequestOptions & { onProgress?: (percent: number) => void } = {}): Promise<ApiEnvelope<T>> {
159
+ if (!options.onProgress) return this.request<T>('POST', path, { ...options, body });
160
+ const headers = this.headers('POST', path, body, options);
161
+ if (options.signal?.aborted) throw new DOMException('已取消', 'AbortError');
162
+ const xhr = this.options.xhrFactory?.() ?? new XMLHttpRequest();
163
+ return new Promise((resolve, reject) => {
164
+ xhr.open('POST', this.baseURL + path);
165
+ xhr.withCredentials = this.credentials === 'include';
166
+ headers.forEach((value, name) => xhr.setRequestHeader(name, value));
167
+ const onProgress = (event: ProgressEvent) => {
168
+ if (event.lengthComputable) options.onProgress?.(Math.round(event.loaded / event.total * 100));
169
+ };
170
+ const onAbort = () => { xhr.abort(); reject(new DOMException('已取消', 'AbortError')); };
171
+ options.signal?.addEventListener('abort', onAbort, { once: true });
172
+ const cleanup = () => {
173
+ options.signal?.removeEventListener('abort', onAbort);
174
+ xhr.upload.removeEventListener('progress', onProgress);
175
+ xhr.removeEventListener('load', onLoad);
176
+ xhr.removeEventListener('error', onError);
177
+ xhr.removeEventListener('abort', onAborted);
178
+ xhr.removeEventListener('timeout', onTimeout);
179
+ };
180
+ const onLoad = () => {
181
+ cleanup();
182
+ this.received(xhr.status);
183
+ const responseHeaders = new Headers();
184
+ for (const name of ['Content-Type', 'X-Request-Id', 'Retry-After']) {
185
+ const value = xhr.getResponseHeader(name);
186
+ if (value) responseHeaders.set(name, value);
187
+ }
188
+ try { void this.envelope<T>(new Response(xhr.responseText, { status: xhr.status, headers: responseHeaders })).then(resolve, reject); }
189
+ catch (error) { reject(error); }
190
+ };
191
+ const onError = () => { cleanup(); reject(new ClientError(0, 'network_error', '网络请求失败,请检查网络连接')); };
192
+ const onAborted = () => { cleanup(); reject(new DOMException('已取消', 'AbortError')); };
193
+ const onTimeout = () => { cleanup(); reject(new ClientError(0, 'timeout', '上传超时')); };
194
+ xhr.upload.addEventListener('progress', onProgress);
195
+ xhr.addEventListener('load', onLoad);
196
+ xhr.addEventListener('error', onError);
197
+ xhr.addEventListener('abort', onAborted);
198
+ xhr.addEventListener('timeout', onTimeout);
199
+ try { xhr.send(body); }
200
+ catch { cleanup(); reject(new ClientError(0, 'network_error', '网络请求失败,请检查网络连接')); }
201
+ });
202
+ }
203
+ }
@@ -0,0 +1,90 @@
1
+ import { fillPath, type AnyOperation, type ApiResponse, type EmptyInput, type InputOf, type OutputOf, type ParamsSchema, type ShapeInput } from '@fudanda/shared/core';
2
+ import { foundationOperations } from '@fudanda/shared/foundation-operations';
3
+ import { foundationSettingsBody, foundationSettingsOperation } from '@fudanda/shared/settings/foundation';
4
+ import type { RequestOptions } from './client';
5
+ import { ApiError } from './errors';
6
+
7
+ export interface JsonClient {
8
+ resolveOperationPath?(op: AnyOperation): string | undefined;
9
+ get<T>(url: string, options?: RequestOptions): Promise<ApiResponse<T>>;
10
+ post<T>(url: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>>;
11
+ put<T>(url: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>>;
12
+ patch<T>(url: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>>;
13
+ delete<T>(url: string, body?: unknown, options?: RequestOptions): Promise<ApiResponse<T>>;
14
+ }
15
+
16
+ const paths = new Map(foundationOperations.map(([, op]) => [op.fullPath, op.fullPath.replace(/^\/api\/(?!v1\/)/, '/api/v1/')]));
17
+ export function isFoundationOperation(op: AnyOperation): boolean { return paths.has(op.fullPath); }
18
+ export function foundationPath(op: AnyOperation): string {
19
+ const path = paths.get(op.fullPath);
20
+ if (!path) throw new Error(`操作 ${op.basePath}:${op.name} 尚未接入 Go`);
21
+ return path;
22
+ }
23
+
24
+ export function foundationRequestBody(op: AnyOperation, body: unknown): unknown {
25
+ if (!op.body || body === undefined || body instanceof FormData) return body;
26
+ const parsed: unknown = op.body.parse(body);
27
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return parsed;
28
+ const partial = op.method === 'put' || op.method === 'patch';
29
+ const supplied = body && typeof body === 'object' ? body : {};
30
+ const removed = new Set(['tenantId', 'tenantCode', 'tenantViewId', 'viewingTenantId']);
31
+ return Object.fromEntries(Object.entries(parsed).filter(([key]) => !removed.has(key) && (!partial || Object.hasOwn(supplied, key))));
32
+ }
33
+
34
+ export function toQueryString(params: object): string {
35
+ const search = new URLSearchParams();
36
+ for (const [key, value] of Object.entries(params)) {
37
+ if (value !== undefined && value !== null && value !== '') search.set(key, String(value));
38
+ }
39
+ const query = search.toString();
40
+ return query ? `?${query}` : '';
41
+ }
42
+
43
+ type LooseInput = { params?: Record<string, unknown>; query?: object; headers?: Record<string, unknown>; body?: unknown };
44
+ type InputArgs<Op extends AnyOperation> = Record<never, never> extends InputOf<Op> ? [input?: InputOf<Op>] : [input: InputOf<Op>];
45
+ export type UrlInputOf<Op extends AnyOperation> =
46
+ (Op['params'] extends ParamsSchema ? { params: ShapeInput<Op['params']> } : EmptyInput) &
47
+ (Op['query'] extends ParamsSchema ? { query: ShapeInput<Op['query']> } : EmptyInput);
48
+ type UrlArgs<Op extends AnyOperation> = Record<never, never> extends UrlInputOf<Op> ? [input?: UrlInputOf<Op>] : [input: UrlInputOf<Op>];
49
+
50
+ export function operationURL<Op extends AnyOperation>(op: Op, ...args: UrlArgs<Op>): string {
51
+ const values = args[0] as LooseInput | undefined;
52
+ return fillPath(foundationPath(op), values?.params) + (values?.query ? toQueryString(values.query) : '');
53
+ }
54
+
55
+ /** Typed calls reuse shared schemas, including single-organization projections. */
56
+ export async function callRaw<Op extends AnyOperation>(client: JsonClient, op: Op, ...args: [...InputArgs<Op>, options?: RequestOptions]): Promise<ApiResponse<OutputOf<Op>>> {
57
+ if (op.kind !== 'json') throw new Error(`契约操作「${op.name}」为 ${op.kind} 响应,请使用文件通道`);
58
+ const input = args[0] as LooseInput | undefined;
59
+ const options = args[1] as RequestOptions | undefined;
60
+ const headers = new Headers(options?.headers);
61
+ for (const [key, value] of Object.entries(input?.headers ?? {})) {
62
+ if (value !== undefined && value !== null) headers.set(key, String(value));
63
+ }
64
+ const requestOptions = { ...options, headers };
65
+ const hostPath = client.resolveOperationPath?.(op);
66
+ const url = hostPath ? fillPath(hostPath, input?.params) + (input?.query ? toQueryString(input.query) : '') : operationURL(op, ...([input] as unknown as UrlArgs<Op>));
67
+ const projected = hostPath ? op : foundationSettingsOperation(op);
68
+ const body = hostPath ? hostRequestBody(op, input?.body) : foundationRequestBody(projected, foundationSettingsBody(op, input?.body));
69
+ const response = op.method === 'get'
70
+ ? await client.get<OutputOf<Op>>(url, requestOptions)
71
+ : await client[op.method]<OutputOf<Op>>(url, body, requestOptions);
72
+ return response.code === 0 ? { ...response, data: projected.response.parse(response.data) as OutputOf<Op> } : response;
73
+ }
74
+
75
+ function hostRequestBody(op: AnyOperation, body: unknown): unknown {
76
+ if (!op.body || body === undefined || body instanceof FormData) return body;
77
+ const parsed: unknown = op.body.parse(body);
78
+ if ((op.method === 'put' || op.method === 'patch') && parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
79
+ return Object.fromEntries(Object.entries(parsed).filter(([key]) => body && typeof body === 'object' && Object.hasOwn(body, key)));
80
+ }
81
+ return parsed;
82
+ }
83
+
84
+ export async function call<Op extends AnyOperation>(client: JsonClient, op: Op, ...args: [...InputArgs<Op>, options?: RequestOptions]): Promise<OutputOf<Op>> {
85
+ return unwrap(await callRaw(client, op, ...args));
86
+ }
87
+ export function unwrap<T>(response: ApiResponse<T>): T {
88
+ if (response.code !== 0) throw new ApiError(response.code, response.message);
89
+ return response.data;
90
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,18 @@
1
+ export class ClientError extends Error {
2
+ constructor(
3
+ readonly status: number,
4
+ readonly reason: string,
5
+ message: string,
6
+ readonly requestId?: string,
7
+ ) {
8
+ super(message);
9
+ this.name = 'ClientError';
10
+ }
11
+ }
12
+
13
+ export class ApiError extends Error {
14
+ constructor(readonly code: number, message: string) {
15
+ super(message || `请求失败(code=${code})`);
16
+ this.name = 'ApiError';
17
+ }
18
+ }