@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.
- package/LICENSE +21 -0
- package/README.md +70 -2
- package/dist/client.d.ts +63 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +205 -0
- package/dist/client.js.map +1 -0
- package/dist/contracts.d.ts +28 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +75 -0
- package/dist/contracts.js.map +1 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +21 -0
- package/dist/errors.js.map +1 -0
- package/dist/event-stream.d.ts +17 -0
- package/dist/event-stream.d.ts.map +1 -0
- package/dist/event-stream.js +102 -0
- package/dist/event-stream.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/subscriptions.d.ts +22 -0
- package/dist/subscriptions.d.ts.map +1 -0
- package/dist/subscriptions.js +93 -0
- package/dist/subscriptions.js.map +1 -0
- package/package.json +51 -4
- package/src/client.ts +203 -0
- package/src/contracts.ts +90 -0
- package/src/errors.ts +18 -0
- package/src/event-stream.ts +74 -0
- package/src/index.ts +9 -0
- package/src/subscriptions.ts +74 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { AnyOperation } from '@fudanda/shared/core';
|
|
2
|
+
import { fillPath } from '@fudanda/shared/core';
|
|
3
|
+
import { ClientError } from './errors';
|
|
4
|
+
import type { Client } from './client';
|
|
5
|
+
import type { Subscription } from './subscriptions';
|
|
6
|
+
|
|
7
|
+
export interface EventStreamOptions<T> {
|
|
8
|
+
params: Record<string,string|number>;
|
|
9
|
+
signal?: AbortSignal;
|
|
10
|
+
parse: (event:string, data:unknown)=>T;
|
|
11
|
+
onEvent: (event:T)=>void;
|
|
12
|
+
onConnection?: (state:'connecting'|'connected'|'reconnecting'|'closed')=>void;
|
|
13
|
+
onError?: (error:unknown)=>void;
|
|
14
|
+
onUnauthorized?: ()=>void;
|
|
15
|
+
reconnectDelay?: number;
|
|
16
|
+
}
|
|
17
|
+
/** An instance-local registered SSE operation. Reconnect begins with a server ready
|
|
18
|
+
* frame; the consumer must requery durable data instead of trusting token replay. */
|
|
19
|
+
export function subscribeOperation<T>(client:Client, op:AnyOperation, options:EventStreamOptions<T>):Subscription {
|
|
20
|
+
if(op.kind!=='sse'||op.method!=='get')throw Error('Expected a GET SSE contract');
|
|
21
|
+
const path=client.resolveOperationPath(op);
|
|
22
|
+
if(!path)throw Error('SSE operation is not registered on this Client');
|
|
23
|
+
const url=fillPath(path,options.params);
|
|
24
|
+
const abort=new AbortController(); let reader:ReadableStreamDefaultReader<Uint8Array>|undefined;
|
|
25
|
+
const close=()=>{abort.abort();void reader?.cancel().catch(()=>{});};
|
|
26
|
+
options.signal?.addEventListener('abort',close,{once:true});if(options.signal?.aborted)close();
|
|
27
|
+
const done=(async()=>{
|
|
28
|
+
let failures=0;
|
|
29
|
+
while(!abort.signal.aborted){
|
|
30
|
+
options.onConnection?.(failures?'reconnecting':'connecting');
|
|
31
|
+
try{
|
|
32
|
+
const response=await client.fetchRaw(url,{signal:abort.signal,headers:{Accept:'text/event-stream'}});
|
|
33
|
+
if(response.status===401){options.onUnauthorized?.();return;}
|
|
34
|
+
if([403,404].includes(response.status))throw new ClientError(response.status,'stream_denied','无法订阅该运行');
|
|
35
|
+
if(!response.ok||!response.body||!response.headers.get('content-type')?.startsWith('text/event-stream'))throw Error('Invalid SSE response');
|
|
36
|
+
reader=response.body.getReader();options.onConnection?.('connected');
|
|
37
|
+
const decoder=new TextDecoder();let buffer='';
|
|
38
|
+
while(!abort.signal.aborted){
|
|
39
|
+
const part=await reader.read();if(part.done)break;
|
|
40
|
+
buffer+=decoder.decode(part.value,{stream:true});
|
|
41
|
+
// Normalize only complete frames: CRLF can be split across TCP chunks.
|
|
42
|
+
let match:RegExpExecArray|null;
|
|
43
|
+
while((match=/\r?\n\r?\n/.exec(buffer))){
|
|
44
|
+
const frame=buffer.slice(0,match.index).replaceAll('\r\n','\n');buffer=buffer.slice(match.index+match[0].length);
|
|
45
|
+
if(frame.length>512*1024)throw Error('SSE frame exceeds limit');
|
|
46
|
+
let event='message';const data:string[]=[];
|
|
47
|
+
for(const line of frame.split('\n')){if(line.startsWith('event:'))event=line.slice(6).trim();else if(line.startsWith('data:'))data.push(line.slice(5).replace(/^ /,''));}
|
|
48
|
+
if(event==='session-expired'){options.onUnauthorized?.();return;}
|
|
49
|
+
if(event==='denied')throw new ClientError(403,'stream_denied','订阅权限已失效');
|
|
50
|
+
if(event==='unavailable')throw Error('Stream unavailable');
|
|
51
|
+
if(!data.length)continue;
|
|
52
|
+
const value=options.parse(event,JSON.parse(data.join('\n')));
|
|
53
|
+
if(!abort.signal.aborted)options.onEvent(value);
|
|
54
|
+
failures=0;
|
|
55
|
+
if(event==='done')return;
|
|
56
|
+
}
|
|
57
|
+
if(buffer.length>512*1024)throw Error('SSE frame exceeds limit');
|
|
58
|
+
}
|
|
59
|
+
}catch(error){
|
|
60
|
+
if(abort.signal.aborted)return;
|
|
61
|
+
options.onError?.(error);
|
|
62
|
+
if(error instanceof ClientError&&[403,404].includes(error.status))return;
|
|
63
|
+
}finally{await reader?.cancel().catch(()=>{});reader?.releaseLock();reader=undefined;}
|
|
64
|
+
if(abort.signal.aborted)return;
|
|
65
|
+
failures++;options.onConnection?.('reconnecting');
|
|
66
|
+
await new Promise<void>(resolve=>{
|
|
67
|
+
const finish=()=>{clearTimeout(timer);abort.signal.removeEventListener('abort',finish);resolve();};
|
|
68
|
+
const timer=setTimeout(finish,Math.min(15000,Math.max(250,options.reconnectDelay??750)*2**Math.min(failures-1,4)));
|
|
69
|
+
abort.signal.addEventListener('abort',finish,{once:true});
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
})().finally(()=>{options.signal?.removeEventListener('abort',close);options.onConnection?.('closed');});
|
|
73
|
+
return {close,done};
|
|
74
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
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';
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { integrationContract } from '@fudanda/shared/integrations';
|
|
2
|
+
import { operationURL } from './contracts';
|
|
3
|
+
import { ClientError } from './errors';
|
|
4
|
+
import type { Client } from './client';
|
|
5
|
+
|
|
6
|
+
export interface ChangeEvent { resources: string[]; cursor: string; requery: boolean }
|
|
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 { close(): void; done: Promise<void> }
|
|
16
|
+
|
|
17
|
+
/** Fetch-based SSE works with both Cookie and restricted API Key clients.
|
|
18
|
+
* Cursor stays in memory; ready/reconnect always requests an authorized requery. */
|
|
19
|
+
export function subscribe(client: Client, options: SubscriptionOptions): Subscription {
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
let activeReader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
22
|
+
const close = () => { controller.abort(); void activeReader?.cancel().catch(() => {}); };
|
|
23
|
+
options.signal?.addEventListener('abort', close, { once: true });
|
|
24
|
+
if (options.signal?.aborted) close();
|
|
25
|
+
let cursor = '';
|
|
26
|
+
const done = (async () => {
|
|
27
|
+
while (!controller.signal.aborted) {
|
|
28
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
29
|
+
try {
|
|
30
|
+
const response = await client.fetchRaw(operationURL(integrationContract.events), { signal: controller.signal, headers: { Accept: 'text/event-stream', ...(cursor ? { 'Last-Event-ID': cursor } : {}) } });
|
|
31
|
+
if (response.status === 401) { options.onUnauthorized?.(); return; }
|
|
32
|
+
if (response.status === 403) throw new ClientError(403, 'subscription_denied', '没有订阅权限');
|
|
33
|
+
if (!response.ok || !response.body || !response.headers.get('content-type')?.startsWith('text/event-stream')) throw new ClientError(response.status, 'subscription_unavailable', '订阅暂不可用');
|
|
34
|
+
reader = response.body.getReader();
|
|
35
|
+
activeReader = reader;
|
|
36
|
+
const decoder = new TextDecoder(); let buffer = '';
|
|
37
|
+
while (!controller.signal.aborted) {
|
|
38
|
+
const part = await reader.read(); if (part.done) break;
|
|
39
|
+
buffer = (buffer + decoder.decode(part.value, { stream: true })).replace(/\r\n/g, '\n');
|
|
40
|
+
if (buffer.length > 65536) throw new Error('SSE frame exceeds size limit');
|
|
41
|
+
let boundary: number;
|
|
42
|
+
while ((boundary = buffer.indexOf('\n\n')) >= 0) {
|
|
43
|
+
const frame = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2);
|
|
44
|
+
let event = ''; let id = ''; const data: string[] = [];
|
|
45
|
+
for (const line of frame.split('\n')) {
|
|
46
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
47
|
+
else if (line.startsWith('id:')) id = line.slice(3).trim();
|
|
48
|
+
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
|
|
49
|
+
}
|
|
50
|
+
if (/^\d+$/.test(id)) cursor = id;
|
|
51
|
+
if (event === 'session-expired') { options.onUnauthorized?.(); return; }
|
|
52
|
+
if (event === 'unavailable') throw new Error('Subscription unavailable');
|
|
53
|
+
if (event === 'change' || event === 'ready') {
|
|
54
|
+
const parsed: unknown = JSON.parse(data.join('\n') || '{}');
|
|
55
|
+
const resources = parsed && typeof parsed === 'object' && 'resources' in parsed && Array.isArray(parsed.resources) && parsed.resources.every(value => typeof value === 'string') ? parsed.resources as string[] : [];
|
|
56
|
+
if (!controller.signal.aborted) options.onChange({ resources, cursor, requery: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (controller.signal.aborted) return;
|
|
62
|
+
options.onError?.(error);
|
|
63
|
+
if (error instanceof ClientError && error.status === 403) return;
|
|
64
|
+
} finally { await reader?.cancel().catch(() => {}); reader?.releaseLock(); activeReader = undefined; }
|
|
65
|
+
if (controller.signal.aborted) return;
|
|
66
|
+
await new Promise<void>(resolve => {
|
|
67
|
+
const finished = () => { clearTimeout(timer); controller.signal.removeEventListener('abort', finished); resolve(); };
|
|
68
|
+
const timer = setTimeout(finished, Math.min(30000, Math.max(250, options.reconnectDelay ?? 1500)));
|
|
69
|
+
controller.signal.addEventListener('abort', finished, { once: true });
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
})().finally(() => { options.signal?.removeEventListener('abort', close); });
|
|
73
|
+
return { close, done };
|
|
74
|
+
}
|