@chipmobilesdk/rn-ai 0.1.1 → 0.2.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/CHANGELOG.md +71 -20
- package/README.md +99 -82
- package/package.json +36 -43
- package/skills/sdk-rn-ai/SKILL.md +1 -2
- package/src/config/behaviorConfig.ts +200 -183
- package/src/core/aiClient.ts +278 -230
- package/src/diagnostics/errors.ts +74 -71
- package/src/diagnostics/messageKeys.ts +27 -27
- package/src/index.ts +31 -34
- package/src/react/hooks.ts +123 -113
- package/src/transport/sseClient.ts +143 -106
- package/src/transport/timeouts.ts +53 -0
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* User-facing copy keys (UX-002/003), mirroring the rn-auth diagnostics
|
|
3
|
-
* pattern: apps may localize via rn-i18n; these are the defaults.
|
|
4
|
-
*/
|
|
5
|
-
export const aiMessages = {
|
|
6
|
-
'ai.state.loading': 'Thinking…',
|
|
7
|
-
'ai.state.streaming': 'Answering…',
|
|
8
|
-
'ai.input.placeholder': 'Ask about your scan…',
|
|
9
|
-
'ai.action.send': 'Send',
|
|
10
|
-
'ai.action.cancel': 'Cancel',
|
|
11
|
-
'ai.action.retry': 'Try again',
|
|
12
|
-
'ai.action.retryAt': 'You can try again at {time}',
|
|
13
|
-
'ai.quota.remaining': '{count} requests left today',
|
|
14
|
-
'ai.error.rateLimited': 'You have reached your usage limit.',
|
|
15
|
-
'ai.error.premiumRequired': 'AI features require a premium subscription for this app.',
|
|
16
|
-
'ai.error.blocked': 'AI access is suspended for this account.',
|
|
17
|
-
'ai.error.payloadTooLarge': 'That attachment is too large to send.',
|
|
18
|
-
'ai.error.modality': 'This kind of content is not supported here.',
|
|
19
|
-
'ai.error.busy': 'The AI service is busy. Please try again shortly.',
|
|
20
|
-
'ai.error.unavailable': 'AI features are currently unavailable.',
|
|
21
|
-
} as const;
|
|
22
|
-
|
|
23
|
-
export type AiMessageKey = keyof typeof aiMessages;
|
|
24
|
-
|
|
25
|
-
export function aiMessage(key: AiMessageKey): string {
|
|
26
|
-
return aiMessages[key];
|
|
27
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* User-facing copy keys (UX-002/003), mirroring the rn-auth diagnostics
|
|
3
|
+
* pattern: apps may localize via rn-i18n; these are the defaults.
|
|
4
|
+
*/
|
|
5
|
+
export const aiMessages = {
|
|
6
|
+
'ai.state.loading': 'Thinking…',
|
|
7
|
+
'ai.state.streaming': 'Answering…',
|
|
8
|
+
'ai.input.placeholder': 'Ask about your scan…',
|
|
9
|
+
'ai.action.send': 'Send',
|
|
10
|
+
'ai.action.cancel': 'Cancel',
|
|
11
|
+
'ai.action.retry': 'Try again',
|
|
12
|
+
'ai.action.retryAt': 'You can try again at {time}',
|
|
13
|
+
'ai.quota.remaining': '{count} requests left today',
|
|
14
|
+
'ai.error.rateLimited': 'You have reached your usage limit.',
|
|
15
|
+
'ai.error.premiumRequired': 'AI features require a premium subscription for this app.',
|
|
16
|
+
'ai.error.blocked': 'AI access is suspended for this account.',
|
|
17
|
+
'ai.error.payloadTooLarge': 'That attachment is too large to send.',
|
|
18
|
+
'ai.error.modality': 'This kind of content is not supported here.',
|
|
19
|
+
'ai.error.busy': 'The AI service is busy. Please try again shortly.',
|
|
20
|
+
'ai.error.unavailable': 'AI features are currently unavailable.',
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type AiMessageKey = keyof typeof aiMessages;
|
|
24
|
+
|
|
25
|
+
export function aiMessage(key: AiMessageKey): string {
|
|
26
|
+
return aiMessages[key];
|
|
27
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,34 +1,31 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @chipmobilesdk/rn-ai — AI gateway client for ChipMobileSdk-family React
|
|
3
|
-
* Native apps (feature 005). Single public entrypoint (LIB-003); modules
|
|
4
|
-
* under `config/`, `transport/`, `core/`, `react/`, and `diagnostics/` are
|
|
5
|
-
* private. Contract: specs/005-azure-ai-gateway/contracts/rn-ai-public-api.md
|
|
6
|
-
*/
|
|
7
|
-
export { createAiClient } from './core/aiClient';
|
|
8
|
-
export type {
|
|
9
|
-
AiClient,
|
|
10
|
-
AiClientConfig,
|
|
11
|
-
AiBehaviorState,
|
|
12
|
-
TokenProvider,
|
|
13
|
-
ChatRequest,
|
|
14
|
-
ChatMessage,
|
|
15
|
-
ContentPart,
|
|
16
|
-
ChatStreamHandlers,
|
|
17
|
-
ChatHandle,
|
|
18
|
-
Usage,
|
|
19
|
-
QuotaSnapshot,
|
|
20
|
-
} from './core/aiClient';
|
|
21
|
-
|
|
22
|
-
export type {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} from './
|
|
27
|
-
|
|
28
|
-
export {
|
|
29
|
-
export type {
|
|
30
|
-
|
|
31
|
-
export {
|
|
32
|
-
export type { AiErrorCode } from './diagnostics/errors';
|
|
33
|
-
export { aiMessages, aiMessage } from './diagnostics/messageKeys';
|
|
34
|
-
export type { AiMessageKey } from './diagnostics/messageKeys';
|
|
1
|
+
/**
|
|
2
|
+
* @chipmobilesdk/rn-ai — AI gateway client for ChipMobileSdk-family React
|
|
3
|
+
* Native apps (feature 005). Single public entrypoint (LIB-003); modules
|
|
4
|
+
* under `config/`, `transport/`, `core/`, `react/`, and `diagnostics/` are
|
|
5
|
+
* private. Contract: specs/005-azure-ai-gateway/contracts/rn-ai-public-api.md
|
|
6
|
+
*/
|
|
7
|
+
export { createAiClient } from './core/aiClient';
|
|
8
|
+
export type {
|
|
9
|
+
AiClient,
|
|
10
|
+
AiClientConfig,
|
|
11
|
+
AiBehaviorState,
|
|
12
|
+
TokenProvider,
|
|
13
|
+
ChatRequest,
|
|
14
|
+
ChatMessage,
|
|
15
|
+
ContentPart,
|
|
16
|
+
ChatStreamHandlers,
|
|
17
|
+
ChatHandle,
|
|
18
|
+
Usage,
|
|
19
|
+
QuotaSnapshot,
|
|
20
|
+
} from './core/aiClient';
|
|
21
|
+
|
|
22
|
+
export type { AppBehaviorConfig, AiConfigCache, BehaviorState } from './config/behaviorConfig';
|
|
23
|
+
|
|
24
|
+
export type { AiTimeouts } from './transport/timeouts';
|
|
25
|
+
export { useAiChat, useAiQuota } from './react/hooks';
|
|
26
|
+
export type { AiChatStatus, UseAiChatResult, UseAiQuotaResult } from './react/hooks';
|
|
27
|
+
|
|
28
|
+
export { AiError, isAiError } from './diagnostics/errors';
|
|
29
|
+
export type { AiErrorCode } from './diagnostics/errors';
|
|
30
|
+
export { aiMessages, aiMessage } from './diagnostics/messageKeys';
|
|
31
|
+
export type { AiMessageKey } from './diagnostics/messageKeys';
|
package/src/react/hooks.ts
CHANGED
|
@@ -1,113 +1,123 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import type {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
export
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
handleRef.current
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
status
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { AiClient, ChatHandle, ChatRequest, QuotaSnapshot } from '../core/aiClient';
|
|
3
|
+
import type { AiError } from '../diagnostics/errors';
|
|
4
|
+
|
|
5
|
+
export type AiChatStatus = 'idle' | 'loading' | 'streaming' | 'done' | 'error' | 'rate-limited';
|
|
6
|
+
|
|
7
|
+
export interface UseAiChatResult {
|
|
8
|
+
send(request: ChatRequest): void;
|
|
9
|
+
cancel(): void;
|
|
10
|
+
status: AiChatStatus;
|
|
11
|
+
/** Accumulated streamed text (appends incrementally — PERF-002). */
|
|
12
|
+
text: string;
|
|
13
|
+
error?: AiError;
|
|
14
|
+
retryAt?: Date;
|
|
15
|
+
quota?: QuotaSnapshot;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function useAiChat(client: AiClient): UseAiChatResult {
|
|
19
|
+
const [status, setStatus] = useState<AiChatStatus>('idle');
|
|
20
|
+
const [text, setText] = useState('');
|
|
21
|
+
const [error, setError] = useState<AiError | undefined>();
|
|
22
|
+
const [retryAt, setRetryAt] = useState<Date | undefined>();
|
|
23
|
+
const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
|
|
24
|
+
const handleRef = useRef<ChatHandle | null>(null);
|
|
25
|
+
// Bumped by every send, cancel and unmount. A callback from an older request
|
|
26
|
+
// is dropped, so it can never overwrite the state of the one on screen —
|
|
27
|
+
// defence in depth behind the client's own cancel guard.
|
|
28
|
+
const requestRef = useRef(0);
|
|
29
|
+
|
|
30
|
+
const send = useCallback(
|
|
31
|
+
(request: ChatRequest) => {
|
|
32
|
+
handleRef.current?.cancel();
|
|
33
|
+
const id = ++requestRef.current;
|
|
34
|
+
const current = () => requestRef.current === id;
|
|
35
|
+
setText('');
|
|
36
|
+
setError(undefined);
|
|
37
|
+
setRetryAt(undefined);
|
|
38
|
+
setStatus('loading');
|
|
39
|
+
handleRef.current = client.chat(request, {
|
|
40
|
+
onDelta: delta => {
|
|
41
|
+
if (!current()) return;
|
|
42
|
+
setStatus('streaming');
|
|
43
|
+
setText(previous => previous + delta);
|
|
44
|
+
},
|
|
45
|
+
onDone: result => {
|
|
46
|
+
if (!current()) return;
|
|
47
|
+
if (result.quota) setQuota(result.quota);
|
|
48
|
+
setStatus('done');
|
|
49
|
+
},
|
|
50
|
+
onError: aiError => {
|
|
51
|
+
if (!current()) return;
|
|
52
|
+
setError(aiError);
|
|
53
|
+
if (aiError.code === 'rate-limited') {
|
|
54
|
+
setStatus('rate-limited');
|
|
55
|
+
if (aiError.retryAfterSeconds !== undefined) {
|
|
56
|
+
setRetryAt(new Date(Date.now() + aiError.retryAfterSeconds * 1000));
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
setStatus('error');
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
[client],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const cancel = useCallback(() => {
|
|
68
|
+
requestRef.current += 1;
|
|
69
|
+
handleRef.current?.cancel();
|
|
70
|
+
setStatus('idle');
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
useEffect(
|
|
74
|
+
() => () => {
|
|
75
|
+
requestRef.current += 1;
|
|
76
|
+
handleRef.current?.cancel();
|
|
77
|
+
},
|
|
78
|
+
[],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
return { send, cancel, status, text, error, retryAt, quota };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface UseAiQuotaResult {
|
|
85
|
+
quota?: QuotaSnapshot;
|
|
86
|
+
status: 'loading' | 'ready' | 'error';
|
|
87
|
+
refresh(): void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function useAiQuota(client: AiClient): UseAiQuotaResult {
|
|
91
|
+
const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
|
|
92
|
+
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
|
93
|
+
const alive = useRef(true);
|
|
94
|
+
// Only the latest refresh may settle the state; an older, slower one that
|
|
95
|
+
// lands afterwards is stale.
|
|
96
|
+
const latest = useRef(0);
|
|
97
|
+
|
|
98
|
+
const refresh = useCallback(() => {
|
|
99
|
+
const id = ++latest.current;
|
|
100
|
+
const current = () => alive.current && latest.current === id;
|
|
101
|
+
setStatus('loading');
|
|
102
|
+
client
|
|
103
|
+
.getQuota()
|
|
104
|
+
.then(snapshot => {
|
|
105
|
+
if (!current()) return;
|
|
106
|
+
setQuota(snapshot);
|
|
107
|
+
setStatus('ready');
|
|
108
|
+
})
|
|
109
|
+
.catch(() => {
|
|
110
|
+
if (current()) setStatus('error');
|
|
111
|
+
});
|
|
112
|
+
}, [client]);
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
alive.current = true;
|
|
116
|
+
refresh();
|
|
117
|
+
return () => {
|
|
118
|
+
alive.current = false;
|
|
119
|
+
};
|
|
120
|
+
}, [refresh]);
|
|
121
|
+
|
|
122
|
+
return { quota, status, refresh };
|
|
123
|
+
}
|
|
@@ -1,106 +1,143 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal SSE reader over XMLHttpRequest (research R2).
|
|
3
|
-
*
|
|
4
|
-
* React Native's fetch does not reliably expose ReadableStream on Hermes, so
|
|
5
|
-
* we use XHR incremental text delivery (readyState LOADING + onprogress) —
|
|
6
|
-
* the same mechanism community SSE libraries use — with zero dependencies.
|
|
7
|
-
* The gateway never compresses SSE responses (contract), which keeps
|
|
8
|
-
* incremental delivery intact on both platforms.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export interface SseRequest {
|
|
12
|
-
url: string;
|
|
13
|
-
method?: 'GET' | 'POST';
|
|
14
|
-
headers?: Record<string, string>;
|
|
15
|
-
/** JSON string body for POST requests. */
|
|
16
|
-
body?: string;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
export
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SSE reader over XMLHttpRequest (research R2).
|
|
3
|
+
*
|
|
4
|
+
* React Native's fetch does not reliably expose ReadableStream on Hermes, so
|
|
5
|
+
* we use XHR incremental text delivery (readyState LOADING + onprogress) —
|
|
6
|
+
* the same mechanism community SSE libraries use — with zero dependencies.
|
|
7
|
+
* The gateway never compresses SSE responses (contract), which keeps
|
|
8
|
+
* incremental delivery intact on both platforms.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface SseRequest {
|
|
12
|
+
url: string;
|
|
13
|
+
method?: 'GET' | 'POST';
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
/** JSON string body for POST requests. */
|
|
16
|
+
body?: string;
|
|
17
|
+
/** Hard limit on the whole request. Rarely right for a stream; prefer the two below. */
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
/** Fails with `onError(0, 'timeout')` if response headers have not arrived by then. */
|
|
20
|
+
connectTimeoutMs?: number;
|
|
21
|
+
/** Fails with `onError(0, 'timeout')` when no chunk arrives for this long. Reset per chunk. */
|
|
22
|
+
idleTimeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SseHandlers {
|
|
26
|
+
/** One call per `data:` event payload (raw string, already de-framed). */
|
|
27
|
+
onData(payload: string): void;
|
|
28
|
+
/** Stream closed normally (transport level; `[DONE]` handling is the caller's). */
|
|
29
|
+
onEnd(): void;
|
|
30
|
+
/** Transport or HTTP error. `status` is 0 for network failures. */
|
|
31
|
+
onError(status: number, bodyText: string): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SseHandle {
|
|
35
|
+
cancel(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Parse complete `data:` events out of `text` starting at `fromIndex`. */
|
|
39
|
+
export function drainSseEvents(
|
|
40
|
+
text: string,
|
|
41
|
+
fromIndex: number,
|
|
42
|
+
emit: (payload: string) => void,
|
|
43
|
+
): number {
|
|
44
|
+
let cursor = fromIndex;
|
|
45
|
+
for (;;) {
|
|
46
|
+
const sep = text.indexOf('\n\n', cursor);
|
|
47
|
+
if (sep === -1) return cursor; // incomplete event stays buffered
|
|
48
|
+
const rawEvent = text.slice(cursor, sep);
|
|
49
|
+
cursor = sep + 2;
|
|
50
|
+
const dataLines = rawEvent
|
|
51
|
+
.split('\n')
|
|
52
|
+
.filter(line => line.startsWith('data:'))
|
|
53
|
+
.map(line => line.slice(5).trimStart());
|
|
54
|
+
if (dataLines.length > 0) emit(dataLines.join('\n'));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandle {
|
|
59
|
+
const xhr = new XMLHttpRequest();
|
|
60
|
+
let seen = 0;
|
|
61
|
+
let finished = false;
|
|
62
|
+
let cancelled = false;
|
|
63
|
+
|
|
64
|
+
// One timer, re-armed as the request progresses: the connect limit until
|
|
65
|
+
// headers arrive, then the idle limit, restarted by every chunk.
|
|
66
|
+
let stallTimer: ReturnType<typeof setTimeout> | undefined;
|
|
67
|
+
const disarm = () => {
|
|
68
|
+
if (stallTimer !== undefined) {
|
|
69
|
+
clearTimeout(stallTimer);
|
|
70
|
+
stallTimer = undefined;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const finish = (fn: () => void) => {
|
|
75
|
+
disarm();
|
|
76
|
+
if (!finished && !cancelled) {
|
|
77
|
+
finished = true;
|
|
78
|
+
fn();
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const stalled = () => {
|
|
83
|
+
stallTimer = undefined;
|
|
84
|
+
if (finished || cancelled) return;
|
|
85
|
+
finished = true;
|
|
86
|
+
try {
|
|
87
|
+
xhr.abort();
|
|
88
|
+
} catch {
|
|
89
|
+
// already settled
|
|
90
|
+
}
|
|
91
|
+
handlers.onError(0, 'timeout');
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const arm = (ms: number | undefined) => {
|
|
95
|
+
disarm();
|
|
96
|
+
if (ms && !finished && !cancelled) stallTimer = setTimeout(stalled, ms);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const pump = () => {
|
|
100
|
+
if (xhr.status !== 200) return; // error path handled on load/error
|
|
101
|
+
arm(request.idleTimeoutMs);
|
|
102
|
+
const text: string = xhr.responseText ?? '';
|
|
103
|
+
seen = drainSseEvents(text, seen, handlers.onData);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
xhr.open(request.method ?? 'POST', request.url, true);
|
|
107
|
+
for (const [k, v] of Object.entries(request.headers ?? {})) {
|
|
108
|
+
xhr.setRequestHeader(k, v);
|
|
109
|
+
}
|
|
110
|
+
xhr.setRequestHeader('Accept', 'text/event-stream');
|
|
111
|
+
if (request.timeoutMs) xhr.timeout = request.timeoutMs;
|
|
112
|
+
|
|
113
|
+
xhr.onprogress = pump;
|
|
114
|
+
xhr.onreadystatechange = () => {
|
|
115
|
+
if (xhr.readyState === 2) arm(request.idleTimeoutMs); // HEADERS_RECEIVED
|
|
116
|
+
if (xhr.readyState === 3) pump(); // LOADING: incremental text
|
|
117
|
+
};
|
|
118
|
+
xhr.onload = () => {
|
|
119
|
+
if (xhr.status === 200) {
|
|
120
|
+
pump();
|
|
121
|
+
finish(handlers.onEnd);
|
|
122
|
+
} else {
|
|
123
|
+
finish(() => handlers.onError(xhr.status, xhr.responseText ?? ''));
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
xhr.onerror = () => finish(() => handlers.onError(0, 'network error'));
|
|
127
|
+
xhr.ontimeout = () => finish(() => handlers.onError(0, 'timeout'));
|
|
128
|
+
|
|
129
|
+
xhr.send(request.body);
|
|
130
|
+
arm(request.connectTimeoutMs);
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
cancel() {
|
|
134
|
+
cancelled = true;
|
|
135
|
+
disarm();
|
|
136
|
+
try {
|
|
137
|
+
xhr.abort();
|
|
138
|
+
} catch {
|
|
139
|
+
// aborting a settled XHR is a no-op
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|