@chipmobilesdk/rn-ai 0.3.0 → 0.4.0
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 +26 -0
- package/README.md +60 -22
- package/package.json +1 -1
- package/skills/sdk-rn-ai/SKILL.md +35 -4
- package/src/config/behaviorConfig.ts +158 -37
- package/src/contract/gateway.ts +91 -0
- package/src/core/aiClient.ts +194 -96
- package/src/core/quotaRequest.ts +125 -0
- package/src/diagnostics/errors.ts +85 -23
- package/src/diagnostics/messageKeys.ts +3 -0
- package/src/index.ts +28 -1
- package/src/react/hooks.ts +86 -14
- package/src/transport/sseClient.ts +58 -11
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
/** Typed client errors (contracts/rn-ai-public-api.md). */
|
|
2
2
|
import { SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
|
|
3
3
|
|
|
4
|
+
import {
|
|
5
|
+
RETRY_AFTER_HEADER,
|
|
6
|
+
type GatewayErrorBody,
|
|
7
|
+
type GatewayErrorCode,
|
|
8
|
+
} from '../contract/gateway';
|
|
9
|
+
|
|
4
10
|
export type AiErrorCode =
|
|
5
11
|
| 'auth-invalid'
|
|
12
|
+
/** The session itself ended: the user has to sign in again. */
|
|
13
|
+
| 'session-expired'
|
|
6
14
|
| 'not-entitled'
|
|
7
15
|
| 'user-blocked'
|
|
8
16
|
| 'app-unknown'
|
|
@@ -11,31 +19,45 @@ export type AiErrorCode =
|
|
|
11
19
|
| 'payload-too-large'
|
|
12
20
|
| 'modality-not-allowed'
|
|
13
21
|
| 'config-unavailable'
|
|
22
|
+
/** The request never reached the gateway: no connection, a timeout, a dropped stream. */
|
|
23
|
+
| 'network'
|
|
14
24
|
| 'upstream-error'
|
|
15
25
|
| 'internal';
|
|
16
26
|
|
|
17
27
|
export interface AiErrorOptions {
|
|
18
28
|
retryAfterSeconds?: number | undefined;
|
|
19
|
-
/** Whether retrying can succeed. Defaults
|
|
29
|
+
/** Whether retrying can succeed. Defaults by code; see `RETRYABLE_BY_DEFAULT`. */
|
|
20
30
|
retryable?: boolean | undefined;
|
|
21
31
|
/** @deprecated Use `retryable`; accepted as its alias. */
|
|
22
32
|
recoverable?: boolean | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* The code the layer underneath reported — an rn-auth `AuthErrorCode`, or the
|
|
35
|
+
* gateway's own code. Kept so a screen can say *why* rather than only *what*.
|
|
36
|
+
*/
|
|
37
|
+
cause?: string | undefined;
|
|
23
38
|
}
|
|
24
39
|
|
|
40
|
+
/** Codes a retry can fix on its own. */
|
|
41
|
+
const RETRYABLE_BY_DEFAULT: ReadonlySet<AiErrorCode> = new Set<AiErrorCode>([
|
|
42
|
+
'rate-limited',
|
|
43
|
+
'upstream-busy',
|
|
44
|
+
'network',
|
|
45
|
+
]);
|
|
46
|
+
|
|
25
47
|
/** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-ai'`. */
|
|
26
48
|
export class AiError extends SdkError<AiErrorCode> {
|
|
27
49
|
readonly retryAfterSeconds?: number | undefined;
|
|
50
|
+
/** The underlying code, when one layer down had a name for the failure. */
|
|
51
|
+
readonly cause?: string | undefined;
|
|
28
52
|
|
|
29
53
|
constructor(code: AiErrorCode, message: string, options: AiErrorOptions = {}) {
|
|
30
54
|
super('rn-ai', code, {
|
|
31
55
|
message,
|
|
32
|
-
retryable:
|
|
33
|
-
options.retryable ??
|
|
34
|
-
options.recoverable ??
|
|
35
|
-
(code === 'rate-limited' || code === 'upstream-busy'),
|
|
56
|
+
retryable: options.retryable ?? options.recoverable ?? RETRYABLE_BY_DEFAULT.has(code),
|
|
36
57
|
});
|
|
37
58
|
this.name = 'AiError';
|
|
38
59
|
this.retryAfterSeconds = options.retryAfterSeconds;
|
|
60
|
+
this.cause = options.cause;
|
|
39
61
|
}
|
|
40
62
|
|
|
41
63
|
/** @deprecated Use `retryable`, the name every SDK package shares. */
|
|
@@ -48,42 +70,82 @@ export function isAiError(value: unknown): value is AiError {
|
|
|
48
70
|
return value instanceof AiError;
|
|
49
71
|
}
|
|
50
72
|
|
|
51
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Every gateway code, mapped. Typed as a total record, so a code added to the
|
|
75
|
+
* shared contract does not compile until the client says what it means (GW-13).
|
|
76
|
+
*/
|
|
77
|
+
const HTTP_CODE_MAP: Readonly<Record<GatewayErrorCode, AiErrorCode>> = {
|
|
78
|
+
BAD_REQUEST: 'internal',
|
|
52
79
|
AUTH_INVALID: 'auth-invalid',
|
|
53
80
|
NOT_ENTITLED: 'not-entitled',
|
|
54
81
|
USER_BLOCKED: 'user-blocked',
|
|
55
82
|
APP_UNKNOWN: 'app-unknown',
|
|
83
|
+
// The endpoint moved: the client's configuration is the stale part.
|
|
84
|
+
ENDPOINT_RETIRED: 'config-unavailable',
|
|
56
85
|
PAYLOAD_TOO_LARGE: 'payload-too-large',
|
|
57
86
|
MODALITY_NOT_ALLOWED: 'modality-not-allowed',
|
|
58
87
|
RATE_LIMITED: 'rate-limited',
|
|
59
88
|
UPSTREAM_BUSY: 'upstream-busy',
|
|
60
89
|
UPSTREAM_ERROR: 'upstream-error',
|
|
90
|
+
INTERNAL: 'internal',
|
|
61
91
|
};
|
|
62
92
|
|
|
93
|
+
function isGatewayCode(value: unknown): value is GatewayErrorCode {
|
|
94
|
+
return typeof value === 'string' && value in HTTP_CODE_MAP;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `Retry-After` in seconds, from either form the header may take. */
|
|
98
|
+
export function parseRetryAfter(value: string | null | undefined): number | undefined {
|
|
99
|
+
if (!value) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const seconds = Number(value);
|
|
103
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
104
|
+
return seconds;
|
|
105
|
+
}
|
|
106
|
+
const date = Date.parse(value);
|
|
107
|
+
if (Number.isFinite(date)) {
|
|
108
|
+
return Math.max(0, Math.round((date - Date.now()) / 1000));
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface HttpErrorContext {
|
|
114
|
+
/** The response headers, when the transport can read them. */
|
|
115
|
+
getHeader?: (name: string) => string | null | undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
63
118
|
/** Map a gateway error response (status + JSON body) to a typed AiError. */
|
|
64
|
-
export function errorFromHttp(
|
|
119
|
+
export function errorFromHttp(
|
|
120
|
+
status: number,
|
|
121
|
+
bodyText: string,
|
|
122
|
+
context: HttpErrorContext = {},
|
|
123
|
+
): AiError {
|
|
124
|
+
// Status 0 is not an answer at all: the request never arrived, or the
|
|
125
|
+
// connection dropped. That is a network failure a retry may well fix, not an
|
|
126
|
+
// upstream fault (AI-11).
|
|
127
|
+
if (status === 0) {
|
|
128
|
+
return new AiError(
|
|
129
|
+
'network',
|
|
130
|
+
bodyText === 'timeout' ? 'the request timed out' : 'no connection',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
65
134
|
let code: AiErrorCode = status >= 500 ? 'upstream-error' : 'internal';
|
|
66
135
|
let message = `gateway responded ${status}`;
|
|
67
|
-
let
|
|
136
|
+
let gatewayCode: GatewayErrorCode | undefined;
|
|
137
|
+
let retryAfterSeconds = parseRetryAfter(context.getHeader?.(RETRY_AFTER_HEADER));
|
|
68
138
|
try {
|
|
69
|
-
const parsed = JSON.parse(bodyText) as
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
code = HTTP_CODE_MAP[parsed.error.code];
|
|
139
|
+
const parsed = JSON.parse(bodyText) as Partial<GatewayErrorBody>;
|
|
140
|
+
if (isGatewayCode(parsed.error?.code)) {
|
|
141
|
+
gatewayCode = parsed.error.code;
|
|
142
|
+
code = HTTP_CODE_MAP[gatewayCode];
|
|
74
143
|
}
|
|
75
144
|
// The gateway's text can quote upstream detail; keep it readable but shaped.
|
|
76
145
|
if (parsed.error?.message) message = redactPatterns(parsed.error.message);
|
|
77
|
-
retryAfterSeconds = parsed.error?.retryAfterSeconds;
|
|
146
|
+
retryAfterSeconds = parsed.error?.retryAfterSeconds ?? retryAfterSeconds;
|
|
78
147
|
} catch {
|
|
79
|
-
|
|
80
|
-
code = 'upstream-error';
|
|
81
|
-
message = 'network failure';
|
|
82
|
-
}
|
|
148
|
+
// A body that is not the contract's: the status is all there is to go on.
|
|
83
149
|
}
|
|
84
|
-
|
|
85
|
-
return new AiError(code, message, {
|
|
86
|
-
retryAfterSeconds,
|
|
87
|
-
recoverable: status === 0 ? true : undefined,
|
|
88
|
-
});
|
|
150
|
+
return new AiError(code, message, { retryAfterSeconds, cause: gatewayCode });
|
|
89
151
|
}
|
|
@@ -27,6 +27,7 @@ export const aiMessageSet = defineMessages({
|
|
|
27
27
|
'ai.error.busy': 'The AI service is busy. Please try again shortly.',
|
|
28
28
|
'ai.error.unavailable': 'AI features are currently unavailable.',
|
|
29
29
|
'ai.error.signIn': 'Please sign in again to continue.',
|
|
30
|
+
'ai.error.offline': 'No connection. Check your network and try again.',
|
|
30
31
|
'ai.error.generic': 'Something went wrong. Please try again.',
|
|
31
32
|
},
|
|
32
33
|
});
|
|
@@ -45,6 +46,8 @@ export function aiMessage(key: AiMessageKey): string {
|
|
|
45
46
|
*/
|
|
46
47
|
const ERROR_MESSAGES: Record<AiErrorCode, AiMessageKey> = {
|
|
47
48
|
'auth-invalid': 'ai.error.signIn',
|
|
49
|
+
'session-expired': 'ai.error.signIn',
|
|
50
|
+
network: 'ai.error.offline',
|
|
48
51
|
'not-entitled': 'ai.error.premiumRequired',
|
|
49
52
|
'user-blocked': 'ai.error.blocked',
|
|
50
53
|
'app-unknown': 'ai.error.unavailable',
|
package/src/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type {
|
|
|
10
10
|
AiClientConfig,
|
|
11
11
|
AiBehaviorState,
|
|
12
12
|
TokenProvider,
|
|
13
|
+
AccessTokenRequestOptions,
|
|
13
14
|
ChatRequest,
|
|
14
15
|
ChatMessage,
|
|
15
16
|
ContentPart,
|
|
@@ -20,10 +21,36 @@ export type {
|
|
|
20
21
|
} from './core/aiClient';
|
|
21
22
|
|
|
22
23
|
export type { AppBehaviorConfig, AiConfigCache, BehaviorState } from './config/behaviorConfig';
|
|
24
|
+
export { configCacheFromKeyValue, SUPPORTED_SCHEMA_VERSION } from './config/behaviorConfig';
|
|
25
|
+
|
|
26
|
+
// The gateway contract, shared with the service: error codes, quota headers and
|
|
27
|
+
// stream events have one definition (GW-13).
|
|
28
|
+
export type {
|
|
29
|
+
GatewayErrorCode,
|
|
30
|
+
GatewayErrorBody,
|
|
31
|
+
GatewayQuota,
|
|
32
|
+
GatewayStreamEvent,
|
|
33
|
+
GatewayUsage,
|
|
34
|
+
QuotaHeaderName,
|
|
35
|
+
QuotaHeaderNames,
|
|
36
|
+
} from './contract/gateway';
|
|
37
|
+
export { GATEWAY_ERROR_STATUS, QUOTA_HEADERS, SSE_DONE_SENTINEL } from './contract/gateway';
|
|
23
38
|
|
|
24
39
|
export type { AiTimeouts } from './transport/timeouts';
|
|
40
|
+
export type {
|
|
41
|
+
SseRequest,
|
|
42
|
+
SseHandlers,
|
|
43
|
+
SseHandle,
|
|
44
|
+
SseResponseInfo,
|
|
45
|
+
StreamTransport,
|
|
46
|
+
} from './transport/sseClient';
|
|
25
47
|
export { useAiBehaviorState, useAiChat, useAiQuota } from './react/hooks';
|
|
26
|
-
export type {
|
|
48
|
+
export type {
|
|
49
|
+
AiChatStatus,
|
|
50
|
+
UseAiChatOptions,
|
|
51
|
+
UseAiChatResult,
|
|
52
|
+
UseAiQuotaResult,
|
|
53
|
+
} from './react/hooks';
|
|
27
54
|
|
|
28
55
|
export { AiError, isAiError } from './diagnostics/errors';
|
|
29
56
|
export type { AiErrorCode } from './diagnostics/errors';
|
package/src/react/hooks.ts
CHANGED
|
@@ -15,30 +15,74 @@ export interface UseAiChatResult {
|
|
|
15
15
|
send(request: ChatRequest): void;
|
|
16
16
|
cancel(): void;
|
|
17
17
|
status: AiChatStatus;
|
|
18
|
-
/** Accumulated streamed text
|
|
18
|
+
/** Accumulated streamed text, flushed on an interval rather than per token. */
|
|
19
19
|
text: string;
|
|
20
20
|
error?: AiError;
|
|
21
21
|
retryAt?: Date;
|
|
22
22
|
quota?: QuotaSnapshot;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
export
|
|
25
|
+
export interface UseAiChatOptions {
|
|
26
|
+
/**
|
|
27
|
+
* How often the accumulated text reaches the screen, in milliseconds.
|
|
28
|
+
*
|
|
29
|
+
* A model streams 30–100 tokens a second, and a render per token is 30–100
|
|
30
|
+
* renders of the whole transcript a second on the JS thread (PERF-12). At the
|
|
31
|
+
* default a long answer costs about 20 renders a second instead, and the last
|
|
32
|
+
* tokens are always flushed with the terminal state. `0` renders per token.
|
|
33
|
+
*/
|
|
34
|
+
flushIntervalMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const DEFAULT_FLUSH_MS = 50;
|
|
38
|
+
|
|
39
|
+
export function useAiChat(client: AiClient, options: UseAiChatOptions = {}): UseAiChatResult {
|
|
26
40
|
const [status, setStatus] = useState<AiChatStatus>('idle');
|
|
27
41
|
const [text, setText] = useState('');
|
|
28
42
|
const [error, setError] = useState<AiError | undefined>();
|
|
29
43
|
const [retryAt, setRetryAt] = useState<Date | undefined>();
|
|
30
|
-
const
|
|
44
|
+
const fromClient = useAiQuotaValue(client);
|
|
45
|
+
// The figure this chat was told, for a client of the app's own that keeps none.
|
|
46
|
+
const [reported, setReported] = useState<QuotaSnapshot | undefined>();
|
|
47
|
+
const quota = fromClient ?? reported;
|
|
31
48
|
const handleRef = useRef<ChatHandle | null>(null);
|
|
32
49
|
// Bumped by every send, cancel and unmount. A callback from an older request
|
|
33
50
|
// is dropped, so it can never overwrite the state of the one on screen —
|
|
34
51
|
// defence in depth behind the client's own cancel guard.
|
|
35
52
|
const requestRef = useRef(0);
|
|
36
53
|
|
|
54
|
+
// Deltas land here and reach React on a timer, so the transcript renders at a
|
|
55
|
+
// steady rate however fast the model answers.
|
|
56
|
+
const pending = useRef('');
|
|
57
|
+
const flushTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
58
|
+
const flushMs = options.flushIntervalMs ?? DEFAULT_FLUSH_MS;
|
|
59
|
+
|
|
60
|
+
const stopFlushing = useCallback(() => {
|
|
61
|
+
if (flushTimer.current !== undefined) {
|
|
62
|
+
clearTimeout(flushTimer.current);
|
|
63
|
+
flushTimer.current = undefined;
|
|
64
|
+
}
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
67
|
+
const flush = useCallback(() => {
|
|
68
|
+
flushTimer.current = undefined;
|
|
69
|
+
const buffered = pending.current;
|
|
70
|
+
if (buffered === '') {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
pending.current = '';
|
|
74
|
+
setText(previous => previous + buffered);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
useEffect(() => stopFlushing, [stopFlushing]);
|
|
78
|
+
|
|
37
79
|
const send = useCallback(
|
|
38
80
|
(request: ChatRequest) => {
|
|
39
81
|
handleRef.current?.cancel();
|
|
40
82
|
const id = ++requestRef.current;
|
|
41
83
|
const current = () => requestRef.current === id;
|
|
84
|
+
stopFlushing();
|
|
85
|
+
pending.current = '';
|
|
42
86
|
setText('');
|
|
43
87
|
setError(undefined);
|
|
44
88
|
setRetryAt(undefined);
|
|
@@ -46,16 +90,28 @@ export function useAiChat(client: AiClient): UseAiChatResult {
|
|
|
46
90
|
handleRef.current = client.chat(request, {
|
|
47
91
|
onDelta: delta => {
|
|
48
92
|
if (!current()) return;
|
|
49
|
-
|
|
50
|
-
|
|
93
|
+
pending.current += delta;
|
|
94
|
+
if (flushMs <= 0) {
|
|
95
|
+
flush();
|
|
96
|
+
} else if (flushTimer.current === undefined) {
|
|
97
|
+
flushTimer.current = setTimeout(flush, flushMs);
|
|
98
|
+
}
|
|
99
|
+
// Only the first delta is a state change; the rest are text.
|
|
100
|
+
setStatus(previous => (previous === 'streaming' ? previous : 'streaming'));
|
|
51
101
|
},
|
|
52
102
|
onDone: result => {
|
|
53
103
|
if (!current()) return;
|
|
54
|
-
|
|
104
|
+
stopFlushing();
|
|
105
|
+
flush();
|
|
106
|
+
if (result.quota) setReported(result.quota);
|
|
55
107
|
setStatus('done');
|
|
56
108
|
},
|
|
57
109
|
onError: aiError => {
|
|
58
110
|
if (!current()) return;
|
|
111
|
+
stopFlushing();
|
|
112
|
+
// Whatever arrived before the failure is kept: a half-answer is
|
|
113
|
+
// still what the user was reading.
|
|
114
|
+
flush();
|
|
59
115
|
setError(aiError);
|
|
60
116
|
if (aiError.code === 'rate-limited') {
|
|
61
117
|
setStatus('rate-limited');
|
|
@@ -68,14 +124,15 @@ export function useAiChat(client: AiClient): UseAiChatResult {
|
|
|
68
124
|
},
|
|
69
125
|
});
|
|
70
126
|
},
|
|
71
|
-
[client],
|
|
127
|
+
[client, flush, flushMs, stopFlushing],
|
|
72
128
|
);
|
|
73
129
|
|
|
74
130
|
const cancel = useCallback(() => {
|
|
75
131
|
requestRef.current += 1;
|
|
132
|
+
stopFlushing();
|
|
76
133
|
handleRef.current?.cancel();
|
|
77
134
|
setStatus('idle');
|
|
78
|
-
}, []);
|
|
135
|
+
}, [stopFlushing]);
|
|
79
136
|
|
|
80
137
|
useEffect(
|
|
81
138
|
() => () => {
|
|
@@ -94,8 +151,25 @@ export interface UseAiQuotaResult {
|
|
|
94
151
|
refresh(): void;
|
|
95
152
|
}
|
|
96
153
|
|
|
154
|
+
const noSubscription = () => () => undefined;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The quota the client last saw, from a chat's `done` event or from
|
|
158
|
+
* `getQuota()`. One source, so a figure on screen is not stale the moment a
|
|
159
|
+
* chat finishes (AI-13).
|
|
160
|
+
*/
|
|
161
|
+
function useAiQuotaValue(client: AiClient): QuotaSnapshot | undefined {
|
|
162
|
+
return useExternalStore(
|
|
163
|
+
client.subscribe ?? noSubscription,
|
|
164
|
+
client.lastQuota ?? (() => undefined),
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
97
168
|
export function useAiQuota(client: AiClient): UseAiQuotaResult {
|
|
98
|
-
const
|
|
169
|
+
const fromClient = useAiQuotaValue(client);
|
|
170
|
+
// A client of the app's own may not keep the last quota; then this is it.
|
|
171
|
+
const [fetched, setFetched] = useState<QuotaSnapshot | undefined>();
|
|
172
|
+
const quota = fromClient ?? fetched;
|
|
99
173
|
const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
|
|
100
174
|
const alive = useRef(true);
|
|
101
175
|
// Only the latest refresh may settle the state; an older, slower one that
|
|
@@ -110,7 +184,7 @@ export function useAiQuota(client: AiClient): UseAiQuotaResult {
|
|
|
110
184
|
.getQuota()
|
|
111
185
|
.then(snapshot => {
|
|
112
186
|
if (!current()) return;
|
|
113
|
-
|
|
187
|
+
setFetched(snapshot);
|
|
114
188
|
setStatus('ready');
|
|
115
189
|
})
|
|
116
190
|
.catch(() => {
|
|
@@ -126,7 +200,8 @@ export function useAiQuota(client: AiClient): UseAiQuotaResult {
|
|
|
126
200
|
};
|
|
127
201
|
}, [refresh]);
|
|
128
202
|
|
|
129
|
-
|
|
203
|
+
// A quota that arrived through a chat is an answer too: nothing is loading.
|
|
204
|
+
return { quota, status: status === 'loading' && quota ? 'ready' : status, refresh };
|
|
130
205
|
}
|
|
131
206
|
|
|
132
207
|
/**
|
|
@@ -136,6 +211,3 @@ export function useAiQuota(client: AiClient): UseAiQuotaResult {
|
|
|
136
211
|
export function useAiBehaviorState(client: AiClient): AiBehaviorState {
|
|
137
212
|
return useExternalStore(client.subscribe ?? noSubscription, client.getBehaviorState);
|
|
138
213
|
}
|
|
139
|
-
|
|
140
|
-
/** For a client without `subscribe`: its state is read once per render. */
|
|
141
|
-
const noSubscription = () => () => undefined;
|
|
@@ -22,20 +22,42 @@ export interface SseRequest {
|
|
|
22
22
|
idleTimeoutMs?: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
/** What the response said besides its body. Absent for a failure with no response. */
|
|
26
|
+
export interface SseResponseInfo {
|
|
27
|
+
/** A response header, or null. `Retry-After` is the one this package reads. */
|
|
28
|
+
getHeader(name: string): string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
export interface SseHandlers {
|
|
26
32
|
/** One call per `data:` event payload (raw string, already de-framed). */
|
|
27
33
|
onData(payload: string): void;
|
|
28
34
|
/** Stream closed normally (transport level; `[DONE]` handling is the caller's). */
|
|
29
35
|
onEnd(): void;
|
|
30
36
|
/** Transport or HTTP error. `status` is 0 for network failures. */
|
|
31
|
-
onError(status: number, bodyText: string): void;
|
|
37
|
+
onError(status: number, bodyText: string, response?: SseResponseInfo): void;
|
|
32
38
|
}
|
|
33
39
|
|
|
34
40
|
export interface SseHandle {
|
|
35
41
|
cancel(): void;
|
|
36
42
|
}
|
|
37
43
|
|
|
38
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* The transport port: how this package makes a streaming request.
|
|
46
|
+
*
|
|
47
|
+
* A published port rather than `typeof requestSse`, so the default reader's
|
|
48
|
+
* own signature is not part of the public API (AI-18): an app may inject its
|
|
49
|
+
* own transport, and this package may change how `requestSse` is written
|
|
50
|
+
* without that being a breaking change.
|
|
51
|
+
*/
|
|
52
|
+
export type StreamTransport = (request: SseRequest, handlers: SseHandlers) => SseHandle;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse complete `data:` events out of `text` starting at `fromIndex`.
|
|
56
|
+
*
|
|
57
|
+
* Both line endings are accepted. A proxy that normalises to CRLF — an API
|
|
58
|
+
* gateway or a corporate middlebox — used to make every event invisible until
|
|
59
|
+
* the stream closed, because only `\n\n` counted as a separator (AI-22).
|
|
60
|
+
*/
|
|
39
61
|
export function drainSseEvents(
|
|
40
62
|
text: string,
|
|
41
63
|
fromIndex: number,
|
|
@@ -43,15 +65,19 @@ export function drainSseEvents(
|
|
|
43
65
|
): number {
|
|
44
66
|
let cursor = fromIndex;
|
|
45
67
|
for (;;) {
|
|
46
|
-
const
|
|
47
|
-
if (
|
|
48
|
-
|
|
49
|
-
|
|
68
|
+
const separator = /\r\n\r\n|\n\n|\r\r/.exec(text.slice(cursor));
|
|
69
|
+
if (!separator) {
|
|
70
|
+
return cursor; // incomplete event stays buffered
|
|
71
|
+
}
|
|
72
|
+
const rawEvent = text.slice(cursor, cursor + separator.index);
|
|
73
|
+
cursor += separator.index + separator[0].length;
|
|
50
74
|
const dataLines = rawEvent
|
|
51
|
-
.split(
|
|
75
|
+
.split(/\r\n|\n|\r/)
|
|
52
76
|
.filter(line => line.startsWith('data:'))
|
|
53
77
|
.map(line => line.slice(5).trimStart());
|
|
54
|
-
if (dataLines.length > 0)
|
|
78
|
+
if (dataLines.length > 0) {
|
|
79
|
+
emit(dataLines.join('\n'));
|
|
80
|
+
}
|
|
55
81
|
}
|
|
56
82
|
}
|
|
57
83
|
|
|
@@ -71,6 +97,18 @@ export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandl
|
|
|
71
97
|
}
|
|
72
98
|
};
|
|
73
99
|
|
|
100
|
+
/** The response's own headers, for `Retry-After` on a 429 (AI-11). */
|
|
101
|
+
const response: SseResponseInfo = {
|
|
102
|
+
getHeader(name) {
|
|
103
|
+
try {
|
|
104
|
+
return xhr.getResponseHeader(name);
|
|
105
|
+
} catch {
|
|
106
|
+
// Headers are unavailable before they arrive, and on some polyfills.
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
|
|
74
112
|
const finish = (fn: () => void) => {
|
|
75
113
|
disarm();
|
|
76
114
|
if (!finished && !cancelled) {
|
|
@@ -96,10 +134,19 @@ export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandl
|
|
|
96
134
|
if (ms && !finished && !cancelled) stallTimer = setTimeout(stalled, ms);
|
|
97
135
|
};
|
|
98
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Drains whatever has arrived. `onprogress` and `readyState === 3` both fire
|
|
139
|
+
* for the same chunk, and which of the two fires at all has been platform
|
|
140
|
+
* dependent, so both stay registered; the length check makes the second call
|
|
141
|
+
* for one chunk free rather than another full `responseText` drain (AI-V1).
|
|
142
|
+
*/
|
|
143
|
+
let drained = -1;
|
|
99
144
|
const pump = () => {
|
|
100
145
|
if (xhr.status !== 200) return; // error path handled on load/error
|
|
101
|
-
arm(request.idleTimeoutMs);
|
|
102
146
|
const text: string = xhr.responseText ?? '';
|
|
147
|
+
if (text.length === drained) return;
|
|
148
|
+
drained = text.length;
|
|
149
|
+
arm(request.idleTimeoutMs);
|
|
103
150
|
seen = drainSseEvents(text, seen, handlers.onData);
|
|
104
151
|
};
|
|
105
152
|
|
|
@@ -112,7 +159,7 @@ export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandl
|
|
|
112
159
|
|
|
113
160
|
xhr.onprogress = pump;
|
|
114
161
|
xhr.onreadystatechange = () => {
|
|
115
|
-
if (xhr.readyState === 2) arm(request.
|
|
162
|
+
if (xhr.readyState === 2) arm(request.connectTimeoutMs); // HEADERS_RECEIVED
|
|
116
163
|
if (xhr.readyState === 3) pump(); // LOADING: incremental text
|
|
117
164
|
};
|
|
118
165
|
xhr.onload = () => {
|
|
@@ -120,7 +167,7 @@ export function requestSse(request: SseRequest, handlers: SseHandlers): SseHandl
|
|
|
120
167
|
pump();
|
|
121
168
|
finish(handlers.onEnd);
|
|
122
169
|
} else {
|
|
123
|
-
finish(() => handlers.onError(xhr.status, xhr.responseText ?? ''));
|
|
170
|
+
finish(() => handlers.onError(xhr.status, xhr.responseText ?? '', response));
|
|
124
171
|
}
|
|
125
172
|
};
|
|
126
173
|
xhr.onerror = () => finish(() => handlers.onError(0, 'network error'));
|