@fudanda/client 1.20261015.1-beta.1 → 2.20261024.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.
package/src/goa.ts ADDED
@@ -0,0 +1,343 @@
1
+ import type { GoaInputOf, GoaOperation, GoaOutputOf } from '@fudanda/shared/core';
2
+ import { readSSE, SSEProtocolError, waitForReconnect } from './goa-stream';
3
+
4
+ export interface GoaClientOptions {
5
+ baseURL?: string;
6
+ credentials?: RequestCredentials;
7
+ transport?: typeof fetch;
8
+ xhrFactory?: () => XMLHttpRequest;
9
+ apiKey?: string;
10
+ csrfToken?: string;
11
+ onUnauthorized?: () => void;
12
+ }
13
+
14
+ export interface GoaRequestOptions {
15
+ signal?: AbortSignal | null;
16
+ headers?: HeadersInit;
17
+ }
18
+
19
+ export interface GoaUploadOptions extends GoaRequestOptions {
20
+ onProgress?: (percent: number) => void;
21
+ }
22
+
23
+ export type GoaConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'closed';
24
+ export interface GoaStreamOptions<Data> extends GoaRequestOptions {
25
+ onEvent: (event: Data) => void;
26
+ onConnection?: (state: GoaConnectionState) => void;
27
+ onError?: (error: Error) => void;
28
+ reconnectDelay?: number;
29
+ }
30
+ export interface GoaSubscription { close(): void; done: Promise<void> }
31
+
32
+ export class GoaError extends Error {
33
+ readonly name = 'GoaError';
34
+ errorId?: string;
35
+ requestId?: string;
36
+ temporary = false;
37
+ timeout = false;
38
+ fault = false;
39
+ details?: unknown;
40
+
41
+ constructor(readonly status: number, readonly reason: string, message: string) { super(message); }
42
+ }
43
+
44
+ type Arguments<Operation extends GoaOperation> = undefined extends GoaInputOf<Operation>
45
+ ? [input?: GoaInputOf<Operation>, options?: GoaRequestOptions]
46
+ : [input: GoaInputOf<Operation>, options?: GoaRequestOptions];
47
+ type UploadArguments<Operation extends GoaOperation> = [input: GoaInputOf<Operation>, options?: GoaUploadOptions];
48
+
49
+ type Values = { params?: Record<string, unknown>; query?: Record<string, unknown>; headers?: Record<string, unknown>; body?: unknown };
50
+
51
+ export interface GoaResponse<Data> { data: Data; status: number; headers: Headers }
52
+
53
+ function pathFor(operation: GoaOperation, input: Values): string {
54
+ if (!operation.path.startsWith('/api/v1/') || /[?#\\\s]/.test(operation.path) || operation.path.split('/').some(part => part === '.' || part === '..')) throw new Error('Invalid generated API path');
55
+ const path = operation.path.replace(/\{(\*?\w+)\}/g, (_match, name: string) => {
56
+ const wildcard = name.startsWith('*');
57
+ const value = input.params?.[wildcard ? name.slice(1) : name];
58
+ if (value === undefined || value === null) throw new Error(`Missing path parameter: ${name}`);
59
+ if (!wildcard) return encodeURIComponent(String(value));
60
+ const parts = String(value).split('/');
61
+ if (parts.some(part => part === '.' || part === '..')) throw new Error('Invalid wildcard path');
62
+ return parts.map(encodeURIComponent).join('/');
63
+ });
64
+ const query = new URLSearchParams();
65
+ for (const [key, value] of Object.entries(input.query ?? {})) {
66
+ if (value === undefined || value === null) continue;
67
+ for (const item of Array.isArray(value) ? value : [value]) query.append(key, String(item));
68
+ }
69
+ return path + (query.size ? '?' + query.toString() : '');
70
+ }
71
+
72
+ /** Native Goa HTTP client. Generated schemas own request and response shapes. */
73
+ export class GoaClient {
74
+ private csrfToken: string;
75
+ private sessionRevision = 0;
76
+ private readonly responseSessions = new WeakMap<Response, number>();
77
+ private readonly unauthorizedListeners = new Set<() => void>();
78
+
79
+ constructor(private readonly options: GoaClientOptions = {}) {
80
+ const baseURL = options.baseURL ?? '';
81
+ if (baseURL && (!/^https?:\/\//.test(baseURL) || new URL(baseURL).origin !== baseURL.replace(/\/$/, ''))) throw new Error('baseURL must be an HTTP origin');
82
+ if (options.apiKey && !/^arc_[a-f0-9]{64}$/.test(options.apiKey)) throw new Error('Invalid ArcBase API Key');
83
+ this.csrfToken = options.csrfToken ?? '';
84
+ }
85
+
86
+ setCSRFToken(token: string): void { if (token !== this.csrfToken) { this.csrfToken = token; this.sessionRevision++; } }
87
+ clearSession(): void { this.csrfToken = ''; this.sessionRevision++; }
88
+ sessionHeaders(): Record<string,string> { return this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {}; }
89
+ subscribeUnauthorized(listener: () => void): () => void {
90
+ this.unauthorizedListeners.add(listener);
91
+ return () => { this.unauthorizedListeners.delete(listener); };
92
+ }
93
+ expireSession(): void {
94
+ this.clearSession();
95
+ for (const listener of this.unauthorizedListeners) listener();
96
+ this.options.onUnauthorized?.();
97
+ }
98
+
99
+ call<Operation extends GoaOperation>(operation: Operation, ...args: Arguments<Operation>): Promise<GoaOutputOf<Operation>> {
100
+ return this.execute(operation, args[0], args[1]).then(response => response.data);
101
+ }
102
+
103
+ callWithResponse<Operation extends GoaOperation>(operation: Operation, ...args: Arguments<Operation>): Promise<GoaResponse<GoaOutputOf<Operation>>> {
104
+ return this.execute(operation, args[0], args[1]);
105
+ }
106
+
107
+ upload<Operation extends GoaOperation & { readonly kind: 'multipart' }>(operation: Operation, ...args: UploadArguments<Operation>): Promise<GoaOutputOf<Operation>> {
108
+ return this.execute(operation, args[0], args[1], 'multipart').then(response => response.data);
109
+ }
110
+
111
+ /** Observe a generated GET stream; closing it never cancels domain execution. */
112
+ stream<Operation extends GoaOperation & { readonly kind: 'sse' }>(operation: Operation, input: GoaInputOf<Operation>, options: GoaStreamOptions<GoaOutputOf<Operation>>): GoaSubscription {
113
+ if (operation.kind !== 'sse' || operation.method !== 'GET') throw new Error('Expected a GET SSE operation');
114
+ const values = operation.input.parse(input) as Values;
115
+ const controller = new AbortController();
116
+ const streamRevision = this.sessionRevision;
117
+ const abort = () => controller.abort(options.signal?.reason);
118
+ options.signal?.addEventListener('abort', abort, { once: true });
119
+ if (options.signal?.aborted) abort();
120
+ const close = () => controller.abort();
121
+ const done = (async () => {
122
+ const initialHeaders = new Headers();
123
+ for (const [key, value] of Object.entries(values.headers ?? {})) if (value !== undefined && value !== null) initialHeaders.set(key, String(value));
124
+ let cursor = initialHeaders.get('Last-Event-ID');
125
+ let delay = Math.min(15000, Math.max(250, options.reconnectDelay ?? 1000));
126
+ let attempts = 0;
127
+ try {
128
+ while (!controller.signal.aborted) {
129
+ if (operation.access.kind !== 'public' && this.sessionRevision !== streamRevision) return;
130
+ options.onConnection?.(attempts ? 'reconnecting' : 'connecting');
131
+ try {
132
+ const resumed = { ...values, headers: { ...values.headers } };
133
+ if (operation.sse?.requestIdField && cursor !== null) resumed.headers['Last-Event-ID'] = cursor;
134
+ if (!operation.input.safeParse(resumed).success) throw new GoaError(400, 'invalid_response', '服务端续传标识不符合契约');
135
+ const response = await this.fetch(operation, resumed, { ...options, signal: controller.signal });
136
+ if (!response.ok) throw await this.error(operation, response);
137
+ await this.requireCurrentSession(operation, response);
138
+ const schema = operation.responses[response.status];
139
+ if (!schema || !response.body || response.headers.get('Content-Type')?.split(';')[0].trim().toLowerCase() !== 'text/event-stream') {
140
+ await response.body?.cancel();
141
+ throw new GoaError(response.status, 'invalid_response', '服务端流式响应不符合契约');
142
+ }
143
+ options.onConnection?.('connected');
144
+ attempts = 0;
145
+ for await (const frame of readSSE(response.body, controller.signal)) {
146
+ if (operation.access.kind !== 'public' && this.sessionRevision !== streamRevision) return;
147
+ if (frame.id !== undefined) cursor = frame.id;
148
+ if (frame.retry !== undefined) delay = Math.min(15000, Math.max(250, frame.retry));
149
+ if (frame.data === undefined) continue;
150
+ if (frame.event === 'session-expired') {
151
+ if (this.responseSessions.get(response) === this.sessionRevision) this.expireSession();
152
+ return;
153
+ }
154
+ if (frame.event === 'denied') throw new GoaError(403, 'stream_denied', '订阅权限已失效');
155
+ let raw: unknown;
156
+ try { raw = JSON.parse(frame.data); }
157
+ catch { throw new GoaError(response.status, 'invalid_response', '服务端流式事件格式错误'); }
158
+ const wire = operation.sse;
159
+ if (wire?.dataField) raw = { [wire.dataField]: raw };
160
+ if (wire?.idField || wire?.eventField || wire?.retryField) {
161
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new GoaError(response.status, 'invalid_response', '服务端流式事件格式错误');
162
+ const mapped: Record<string, unknown> = { ...raw };
163
+ if (wire.idField) mapped[wire.idField] = cursor ?? '';
164
+ if (wire.eventField) mapped[wire.eventField] = frame.event;
165
+ if (wire.retryField && frame.retry !== undefined) mapped[wire.retryField] = frame.retry;
166
+ raw = mapped;
167
+ }
168
+ const parsed = schema.safeParse(raw);
169
+ if (!parsed.success) throw new GoaError(response.status, 'invalid_response', '服务端流式事件不符合契约');
170
+ options.onEvent(parsed.data as GoaOutputOf<Operation>);
171
+ if (frame.event === 'done') return;
172
+ }
173
+ } catch (error) {
174
+ if (controller.signal.aborted || error instanceof DOMException && error.name === 'AbortError') return;
175
+ options.onError?.(error instanceof Error ? error : new Error('流式连接中断'));
176
+ if (error instanceof SSEProtocolError || error instanceof GoaError && (error.reason === 'invalid_response' || error.status < 500 && error.status !== 429)) return;
177
+ }
178
+ attempts++;
179
+ await waitForReconnect(Math.min(15000, delay * 2 ** Math.min(attempts - 1, 4)), controller.signal);
180
+ }
181
+ } finally {
182
+ options.signal?.removeEventListener('abort', abort);
183
+ options.onConnection?.('closed');
184
+ }
185
+ })();
186
+ return { close, done };
187
+ }
188
+
189
+ /** The caller consumes or cancels the returned body; the transport never buffers files. */
190
+ async download<Operation extends GoaOperation & { readonly kind: 'file' | 'csv' }>(operation: Operation, ...args: Arguments<Operation>): Promise<Response> {
191
+ if (operation.kind !== 'file' && operation.kind !== 'csv') throw new Error(`Operation ${operation.id} is not a download`);
192
+ const values = operation.input.parse(args[0]) as Values;
193
+ const response = await this.fetch(operation, values, args[1] ?? {});
194
+ if (!response.ok) throw await this.error(operation, response);
195
+ await this.requireCurrentSession(operation, response);
196
+ const declared = operation.mediaTypes?.[response.status] ?? [];
197
+ const contentType = response.headers.get('Content-Type')?.split(';')[0].trim().toLowerCase();
198
+ const matchesMediaType = declared.some(mediaType => {
199
+ const expected = mediaType.split(';')[0].trim().toLowerCase();
200
+ if (!contentType || !/^[^\s/;]+\/[^\s/;]+$/.test(contentType)) return false;
201
+ return expected === contentType || expected === '*/*' || expected.endsWith('/*') && contentType.startsWith(expected.slice(0, -1));
202
+ });
203
+ if (!operation.responses[response.status] || !matchesMediaType) {
204
+ await response.body?.cancel();
205
+ throw new GoaError(response.status, 'invalid_response', '文件响应不符合契约');
206
+ }
207
+ return response;
208
+ }
209
+
210
+ private async execute<Operation extends GoaOperation>(operation: Operation, input: unknown, options: GoaUploadOptions = {}, channel: 'json' | 'multipart' = 'json'): Promise<GoaResponse<GoaOutputOf<Operation>>> {
211
+ if (operation.kind !== channel) throw new Error(`Operation ${operation.id} requires the ${operation.kind} channel`);
212
+ const values = operation.input.parse(input) as Values;
213
+ const response = await this.fetch(operation, values, options);
214
+ if (!response.ok) throw await this.error(operation, response);
215
+ await this.requireCurrentSession(operation, response);
216
+ const schema = operation.responses[response.status];
217
+ if (!schema) throw new GoaError(response.status, 'unexpected_status', '服务端返回了未声明的状态');
218
+ let raw: unknown;
219
+ if (response.status !== 204) {
220
+ try { raw = await response.json(); } catch { throw new GoaError(response.status, 'invalid_response', '服务端响应格式错误'); }
221
+ }
222
+ const parsed = schema.safeParse(raw);
223
+ if (!parsed.success) throw new GoaError(response.status, 'invalid_response', '服务端响应不符合契约');
224
+ await this.requireCurrentSession(operation, response);
225
+ return { data: parsed.data as GoaOutputOf<Operation>, status: response.status, headers: response.headers };
226
+ }
227
+
228
+ private async requireCurrentSession(operation: GoaOperation, response: Response): Promise<void> {
229
+ if (operation.access.kind === 'public' || this.options.apiKey || this.responseSessions.get(response) === this.sessionRevision) return;
230
+ if (!response.bodyUsed) await response.body?.cancel();
231
+ throw new DOMException('会话已变更,已丢弃旧请求结果', 'AbortError');
232
+ }
233
+
234
+ private async fetch(operation: GoaOperation, input: Values, options: GoaUploadOptions): Promise<Response> {
235
+ options.signal?.throwIfAborted();
236
+ const sessionRevision = this.sessionRevision;
237
+ const headers = new Headers(options.headers);
238
+ for (const [key, value] of Object.entries(input.headers ?? {})) if (value !== undefined && value !== null) headers.set(key, String(value));
239
+ headers.delete('Authorization');
240
+ headers.delete('Cookie');
241
+ headers.delete('X-CSRF-Token');
242
+ headers.set('Accept', operation.kind === 'file' || operation.kind === 'csv'
243
+ ? [...new Set(Object.values(operation.mediaTypes ?? {}).flat())].join(', ')
244
+ : operation.kind === 'sse' ? 'text/event-stream' : 'application/json');
245
+ if (this.options.apiKey) {
246
+ if (!operation.apiKey.allowed) throw new GoaError(403, 'key_route_denied', '此接口需要浏览器会话');
247
+ headers.set('Authorization', 'Bearer ' + this.options.apiKey);
248
+ } else if (!['GET', 'HEAD', 'OPTIONS'].includes(operation.method) && operation.access.kind !== 'public') {
249
+ if (!this.csrfToken) throw new GoaError(401, 'session_missing', '登录状态尚未恢复');
250
+ headers.set('X-CSRF-Token', this.csrfToken);
251
+ }
252
+ let body: BodyInit | undefined;
253
+ if (input.body !== undefined) {
254
+ if (operation.kind === 'multipart') {
255
+ headers.delete('Content-Type');
256
+ const form = new FormData();
257
+ for (const [name, value] of Object.entries(input.body as Record<string, unknown>)) {
258
+ if (value === undefined) continue;
259
+ for (const item of Array.isArray(value) ? value : [value]) {
260
+ if (item instanceof Blob) form.append(name, item);
261
+ else if (typeof item === 'string' || typeof item === 'number' || typeof item === 'boolean') form.append(name, String(item));
262
+ else throw new Error(`Unsupported multipart field: ${name}`);
263
+ }
264
+ }
265
+ body = form;
266
+ }
267
+ else { headers.set('Content-Type', 'application/json'); body = JSON.stringify(input.body); }
268
+ }
269
+ const url = (this.options.baseURL ?? '').replace(/\/$/, '') + pathFor(operation, input);
270
+ const init: RequestInit = { method: operation.method, credentials: this.options.credentials ?? (this.options.apiKey ? 'omit' : 'include'), headers, body, signal: options.signal, cache: 'no-store' };
271
+ const response = operation.kind === 'multipart' && options.onProgress
272
+ ? await this.sendUpload(url, init, options.onProgress)
273
+ : await (this.options.transport ?? globalThis.fetch)(url, init);
274
+ this.responseSessions.set(response, sessionRevision);
275
+ if (options.signal?.aborted) {
276
+ await response.body?.cancel();
277
+ options.signal.throwIfAborted();
278
+ }
279
+ return response;
280
+ }
281
+
282
+ private sendUpload(url: string, init: RequestInit, progress: (percent: number) => void): Promise<Response> {
283
+ const xhr = this.options.xhrFactory?.() ?? new XMLHttpRequest();
284
+ return new Promise((resolve, reject) => {
285
+ xhr.open(init.method!, url);
286
+ const target = new URL(url, globalThis.location?.href ?? 'http://localhost');
287
+ xhr.withCredentials = init.credentials === 'include' || init.credentials === 'same-origin' && target.origin === globalThis.location?.origin;
288
+ new Headers(init.headers).forEach((value, name) => xhr.setRequestHeader(name, value));
289
+ const cleanup = () => {
290
+ init.signal?.removeEventListener('abort', abort);
291
+ xhr.upload.removeEventListener('progress', report);
292
+ xhr.removeEventListener('load', load);
293
+ xhr.removeEventListener('error', failed);
294
+ xhr.removeEventListener('abort', aborted);
295
+ xhr.removeEventListener('timeout', timeout);
296
+ };
297
+ const report = (event: ProgressEvent) => { if (event.lengthComputable && event.total > 0) progress(Math.min(100, Math.round(event.loaded / event.total * 100))); };
298
+ const aborted = () => { cleanup(); reject(init.signal?.reason ?? new DOMException('已取消', 'AbortError')); };
299
+ const abort = () => { cleanup(); xhr.abort(); aborted(); };
300
+ const failed = () => { cleanup(); reject(new GoaError(0, 'network_error', '上传失败,请检查网络连接')); };
301
+ const timeout = () => { cleanup(); reject(new GoaError(0, 'timeout', '上传超时')); };
302
+ const load = () => {
303
+ cleanup();
304
+ const headers = new Headers();
305
+ for (const name of ['Content-Type', 'X-Request-Id', 'Retry-After']) {
306
+ const value = xhr.getResponseHeader(name);
307
+ if (value) headers.set(name, value);
308
+ }
309
+ try { resolve(new Response(xhr.status === 204 ? null : xhr.responseText, { status: xhr.status, headers })); }
310
+ catch (error) { reject(error); }
311
+ };
312
+ init.signal?.addEventListener('abort', abort, { once: true });
313
+ xhr.upload.addEventListener('progress', report);
314
+ xhr.addEventListener('load', load);
315
+ xhr.addEventListener('error', failed);
316
+ xhr.addEventListener('abort', aborted);
317
+ xhr.addEventListener('timeout', timeout);
318
+ if (init.signal?.aborted) { abort(); return; }
319
+ try { xhr.send(init.body as FormData); } catch { failed(); }
320
+ });
321
+ }
322
+
323
+ private async error(operation: GoaOperation, response: Response): Promise<GoaError> {
324
+ if (response.status === 401 && operation.access.kind !== 'public' && this.responseSessions.get(response) === this.sessionRevision) this.expireSession();
325
+ let value: unknown;
326
+ try { value = await response.json(); } catch { return new GoaError(response.status, 'invalid_response', '服务端错误响应格式错误'); }
327
+ const schema = operation.errors[response.status];
328
+ if (schema) {
329
+ const parsed = schema.safeParse(value);
330
+ if (!parsed.success) return new GoaError(response.status, 'invalid_response', '服务端错误响应不符合契约');
331
+ value = parsed.data;
332
+ }
333
+ if (!value || typeof value !== 'object' || !('name' in value) || typeof value.name !== 'string' || !('message' in value) || typeof value.message !== 'string') return new GoaError(response.status, 'invalid_response', '服务端错误响应格式错误');
334
+ const error = new GoaError(response.status, value.name, value.message);
335
+ error.requestId = response.headers.get('X-Request-Id') ?? undefined;
336
+ if ('id' in value && typeof value.id === 'string') error.errorId = value.id;
337
+ if ('temporary' in value && typeof value.temporary === 'boolean') error.temporary = value.temporary;
338
+ if ('timeout' in value && typeof value.timeout === 'boolean') error.timeout = value.timeout;
339
+ if ('fault' in value && typeof value.fault === 'boolean') error.fault = value.fault;
340
+ error.details = value;
341
+ return error;
342
+ }
343
+ }
package/src/index.ts CHANGED
@@ -1,9 +1,2 @@
1
- export { Client, validateGoPath } from './client';
2
- export type { ClientOptions, RequestOptions, ApiEnvelope, Method } from './client';
3
- export { ClientError, ApiError } from './errors';
4
- export { subscribe } from './subscriptions';
5
- export {subscribeOperation} from './event-stream';
6
- export type {EventStreamOptions} from './event-stream';
7
- export type { ChangeEvent, SubscriptionOptions, Subscription } from './subscriptions';
8
- export { call, callRaw, operationURL, foundationPath, foundationRequestBody, isFoundationOperation, toQueryString, unwrap } from './contracts';
9
- export type { JsonClient, UrlInputOf } from './contracts';
1
+ export { GoaClient, GoaError } from './goa';
2
+ export type { GoaClientOptions, GoaRequestOptions, GoaUploadOptions, GoaResponse, GoaStreamOptions, GoaSubscription, GoaConnectionState } from './goa';
package/dist/client.d.ts DELETED
@@ -1,63 +0,0 @@
1
- import type { ApiResponse } from '@fudanda/shared/core';
2
- import type { AnyOperation } from '@fudanda/shared/core';
3
- export type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
4
- export type ApiEnvelope<T> = ApiResponse<T> & {
5
- error?: string;
6
- requestId?: string;
7
- retryAfterSeconds?: number;
8
- };
9
- export interface RequestOptions {
10
- signal?: AbortSignal | null;
11
- headers?: HeadersInit;
12
- anonymousWrite?: boolean;
13
- }
14
- export interface ClientOptions {
15
- baseURL?: string;
16
- credentials?: RequestCredentials;
17
- transport?: typeof fetch;
18
- xhrFactory?: () => XMLHttpRequest;
19
- onUnauthorized?: () => void;
20
- /** For scripts/server integrations. Never persist or expose keys in browser storage. */
21
- apiKey?: string;
22
- /** Explicit host contracts; built-in operations remain restricted to the release catalog. */
23
- operations?: readonly AnyOperation[];
24
- }
25
- export declare function validateGoPath(path: string): void;
26
- /** Cookie/CSRF transport. UI, user state and query caches belong to the host. */
27
- export declare class Client {
28
- private readonly options;
29
- private csrfToken;
30
- private readonly unauthorizedListeners;
31
- private readonly baseURL;
32
- private readonly send;
33
- private readonly credentials;
34
- private readonly operations;
35
- constructor(options?: ClientOptions);
36
- /** Registration is instance-local and requires the exact declared operation. */
37
- resolveOperationPath(op: AnyOperation): string | undefined;
38
- setCsrfToken(value: string | null): void;
39
- clearSession(): void;
40
- /** Long-lived authenticated transports use the same expiry notifications as HTTP 401. */
41
- expireSession(): void;
42
- sessionHeaders(): Record<string, string>;
43
- /** Hosts can observe expiry without replacing the caller's callback. */
44
- subscribeUnauthorized(listener: () => void): () => void;
45
- private headers;
46
- private received;
47
- fetchRaw(path: string, options?: RequestInit & RequestOptions): Promise<Response>;
48
- private envelope;
49
- request<T>(method: Method, path: string, options?: RequestOptions & {
50
- body?: unknown;
51
- }): Promise<ApiEnvelope<T>>;
52
- requestRaw<T>(path: string, options?: RequestInit & RequestOptions): Promise<ApiEnvelope<T>>;
53
- get<T>(path: string, options?: RequestOptions): Promise<ApiEnvelope<T>>;
54
- post<T>(path: string, body?: unknown, options?: RequestOptions): Promise<ApiEnvelope<T>>;
55
- put<T>(path: string, body?: unknown, options?: RequestOptions): Promise<ApiEnvelope<T>>;
56
- patch<T>(path: string, body?: unknown, options?: RequestOptions): Promise<ApiEnvelope<T>>;
57
- delete<T>(path: string, body?: unknown, options?: RequestOptions): Promise<ApiEnvelope<T>>;
58
- readBlob(path: string, options?: RequestInit & RequestOptions): Promise<Blob>;
59
- postForm<T>(path: string, body: FormData, options?: RequestOptions & {
60
- onProgress?: (percent: number) => void;
61
- }): Promise<ApiEnvelope<T>>;
62
- }
63
- //# sourceMappingURL=client.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"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"}
package/dist/client.js DELETED
@@ -1,205 +0,0 @@
1
- import { ClientError } from './errors.js';
2
- const loginPaths = ['/api/v1/auth/login', '/api/v1/auth/session-conflict/resolve', '/api/v1/auth/external/start', '/api/v1/auth/external/complete'];
3
- export function validateGoPath(path) {
4
- if (!/^\/api\/v1\/[a-zA-Z0-9][a-zA-Z0-9_%./-]*(?:\?[a-zA-Z0-9%=&_.+-]*)?$/.test(path)
5
- || path.includes('//')
6
- || decodeURIComponent(path.split('?')[0]).includes('//')
7
- || /(?:^|\/)\.{1,2}(?:\/|$)/.test(decodeURIComponent(path.split('?')[0]))
8
- || !new URL(path, 'http://localhost').pathname.startsWith('/api/v1/')) {
9
- throw new Error('Invalid Go API path');
10
- }
11
- }
12
- /** Cookie/CSRF transport. UI, user state and query caches belong to the host. */
13
- export class Client {
14
- options;
15
- csrfToken = null;
16
- unauthorizedListeners = new Set();
17
- baseURL;
18
- send;
19
- credentials;
20
- operations = new Map();
21
- constructor(options = {}) {
22
- this.options = options;
23
- this.baseURL = (options.baseURL ?? '').replace(/\/$/, '');
24
- if (this.baseURL && (!/^https?:\/\//.test(this.baseURL) || new URL(this.baseURL).origin !== this.baseURL))
25
- throw new Error('baseURL must be an HTTP origin');
26
- this.send = options.transport ?? ((...args) => globalThis.fetch(...args));
27
- if (options.apiKey && !/^arc_[a-f0-9]{64}$/.test(options.apiKey))
28
- throw new Error('Invalid ArcBase API Key');
29
- this.credentials = options.credentials ?? (options.apiKey ? 'omit' : 'same-origin');
30
- const routes = new Set();
31
- for (const op of options.operations ?? []) {
32
- validateGoPath(op.fullPath.replaceAll(/\{[a-zA-Z][a-zA-Z0-9_]*\}/g, '1'));
33
- if (!op.fullPath.startsWith('/api/v1/extensions/'))
34
- throw new Error('Host operations must use /api/v1/extensions/');
35
- const key = `${op.method} ${op.fullPath}`;
36
- if (routes.has(key))
37
- throw new Error('Duplicate host operation');
38
- routes.add(key);
39
- this.operations.set(op, op.fullPath);
40
- }
41
- }
42
- /** Registration is instance-local and requires the exact declared operation. */
43
- resolveOperationPath(op) { return this.operations.get(op); }
44
- setCsrfToken(value) { this.csrfToken = value; }
45
- clearSession() { this.csrfToken = null; }
46
- /** Long-lived authenticated transports use the same expiry notifications as HTTP 401. */
47
- expireSession() { this.received(401); }
48
- sessionHeaders() { return this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {}; }
49
- /** Hosts can observe expiry without replacing the caller's callback. */
50
- subscribeUnauthorized(listener) {
51
- this.unauthorizedListeners.add(listener);
52
- return () => { this.unauthorizedListeners.delete(listener); };
53
- }
54
- headers(method, path, body, options) {
55
- validateGoPath(path);
56
- if (options.anonymousWrite && (method !== 'POST' || !loginPaths.includes(path)))
57
- throw new Error('Only origin-checked login actions may write without CSRF');
58
- const write = !['GET', 'HEAD', 'OPTIONS'].includes(method);
59
- if (write && !options.anonymousWrite && !this.csrfToken && !this.options.apiKey)
60
- throw new ClientError(401, 'session_missing', '登录状态尚未恢复');
61
- const headers = new Headers(options.headers);
62
- // Caller headers cannot supply a different identity or stale CSRF secret.
63
- headers.delete('authorization');
64
- headers.delete('cookie');
65
- headers.delete('x-csrf-token');
66
- if (this.options.apiKey)
67
- headers.set('Authorization', `Bearer ${this.options.apiKey}`);
68
- if (body instanceof FormData)
69
- headers.delete('content-type');
70
- else if (body !== undefined && body !== null && !headers.has('content-type'))
71
- headers.set('Content-Type', 'application/json');
72
- if (write && !options.anonymousWrite && !this.options.apiKey)
73
- headers.set('X-CSRF-Token', this.csrfToken);
74
- return headers;
75
- }
76
- received(status, anonymousWrite = false) {
77
- if (status === 401) {
78
- this.clearSession();
79
- if (!anonymousWrite) {
80
- for (const listener of this.unauthorizedListeners)
81
- listener();
82
- this.options.onUnauthorized?.();
83
- }
84
- }
85
- }
86
- async fetchRaw(path, options = {}) {
87
- const { anonymousWrite, ...init } = options;
88
- const method = (init.method ?? 'GET').toUpperCase();
89
- const headers = this.headers(method, path, init.body, { ...options, anonymousWrite });
90
- let response;
91
- try {
92
- response = await this.send(this.baseURL + path, { ...init, method, headers, credentials: this.credentials, cache: 'no-store' });
93
- }
94
- catch (error) {
95
- if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError')
96
- throw error;
97
- throw new ClientError(0, 'network_error', '网络请求失败,请检查网络连接');
98
- }
99
- // A host transport may resolve after cancellation; ignore that stale result.
100
- if (init.signal?.aborted)
101
- throw new DOMException('已取消', 'AbortError');
102
- this.received(response.status, anonymousWrite);
103
- return response;
104
- }
105
- async envelope(response) {
106
- const requestId = response.headers.get('X-Request-Id') ?? undefined;
107
- let payload;
108
- try {
109
- payload = await response.json();
110
- }
111
- catch {
112
- throw new ClientError(response.status, 'invalid_response', '服务端响应格式错误', requestId);
113
- }
114
- if (!payload || typeof payload !== 'object' || !('code' in payload) || typeof payload.code !== 'number'
115
- || !('message' in payload) || typeof payload.message !== 'string' || !('data' in payload))
116
- throw new ClientError(response.status, 'invalid_response', '服务端响应格式错误', requestId);
117
- const result = payload;
118
- if (!response.ok && result.code === 0)
119
- throw new ClientError(response.status, 'invalid_response', 'HTTP 状态与响应结果不一致', requestId);
120
- const retryAfterSeconds = Number(response.headers.get('Retry-After'));
121
- return { ...result, ...(requestId ? { requestId } : {}), ...(retryAfterSeconds > 0 ? { retryAfterSeconds } : {}) };
122
- }
123
- async request(method, path, options = {}) {
124
- const { body, ...rest } = options;
125
- const raw = body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body);
126
- return this.requestRaw(path, { ...rest, method, body: raw });
127
- }
128
- async requestRaw(path, options = {}) {
129
- return this.envelope(await this.fetchRaw(path, options));
130
- }
131
- get(path, options) { return this.request('GET', path, options); }
132
- post(path, body, options) { return this.request('POST', path, { ...options, body, anonymousWrite: loginPaths.includes(path) }); }
133
- put(path, body, options) { return this.request('PUT', path, { ...options, body }); }
134
- patch(path, body, options) { return this.request('PATCH', path, { ...options, body }); }
135
- delete(path, body, options) { return this.request('DELETE', path, { ...options, body }); }
136
- async readBlob(path, options = {}) {
137
- const response = await this.fetchRaw(path, options);
138
- if (!response.ok) {
139
- const result = await this.envelope(response);
140
- throw new ClientError(response.status, result.error ?? 'download_failed', result.message || '下载失败', result.requestId);
141
- }
142
- if (response.headers.get('Content-Type')?.includes('application/json') && !response.headers.has('Content-Disposition'))
143
- throw new ClientError(response.status, 'invalid_response', '文件响应格式错误');
144
- return response.blob();
145
- }
146
- async postForm(path, body, options = {}) {
147
- if (!options.onProgress)
148
- return this.request('POST', path, { ...options, body });
149
- const headers = this.headers('POST', path, body, options);
150
- if (options.signal?.aborted)
151
- throw new DOMException('已取消', 'AbortError');
152
- const xhr = this.options.xhrFactory?.() ?? new XMLHttpRequest();
153
- return new Promise((resolve, reject) => {
154
- xhr.open('POST', this.baseURL + path);
155
- xhr.withCredentials = this.credentials === 'include';
156
- headers.forEach((value, name) => xhr.setRequestHeader(name, value));
157
- const onProgress = (event) => {
158
- if (event.lengthComputable)
159
- options.onProgress?.(Math.round(event.loaded / event.total * 100));
160
- };
161
- const onAbort = () => { xhr.abort(); reject(new DOMException('已取消', 'AbortError')); };
162
- options.signal?.addEventListener('abort', onAbort, { once: true });
163
- const cleanup = () => {
164
- options.signal?.removeEventListener('abort', onAbort);
165
- xhr.upload.removeEventListener('progress', onProgress);
166
- xhr.removeEventListener('load', onLoad);
167
- xhr.removeEventListener('error', onError);
168
- xhr.removeEventListener('abort', onAborted);
169
- xhr.removeEventListener('timeout', onTimeout);
170
- };
171
- const onLoad = () => {
172
- cleanup();
173
- this.received(xhr.status);
174
- const responseHeaders = new Headers();
175
- for (const name of ['Content-Type', 'X-Request-Id', 'Retry-After']) {
176
- const value = xhr.getResponseHeader(name);
177
- if (value)
178
- responseHeaders.set(name, value);
179
- }
180
- try {
181
- void this.envelope(new Response(xhr.responseText, { status: xhr.status, headers: responseHeaders })).then(resolve, reject);
182
- }
183
- catch (error) {
184
- reject(error);
185
- }
186
- };
187
- const onError = () => { cleanup(); reject(new ClientError(0, 'network_error', '网络请求失败,请检查网络连接')); };
188
- const onAborted = () => { cleanup(); reject(new DOMException('已取消', 'AbortError')); };
189
- const onTimeout = () => { cleanup(); reject(new ClientError(0, 'timeout', '上传超时')); };
190
- xhr.upload.addEventListener('progress', onProgress);
191
- xhr.addEventListener('load', onLoad);
192
- xhr.addEventListener('error', onError);
193
- xhr.addEventListener('abort', onAborted);
194
- xhr.addEventListener('timeout', onTimeout);
195
- try {
196
- xhr.send(body);
197
- }
198
- catch {
199
- cleanup();
200
- reject(new ClientError(0, 'network_error', '网络请求失败,请检查网络连接'));
201
- }
202
- });
203
- }
204
- }
205
- //# sourceMappingURL=client.js.map