@chipmobilesdk/rn-ai 0.2.1 → 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.
@@ -1,6 +1,16 @@
1
1
  /** Typed client errors (contracts/rn-ai-public-api.md). */
2
+ import { SdkError, redactPatterns } from '@chipmobilesdk/rn-core';
3
+
4
+ import {
5
+ RETRY_AFTER_HEADER,
6
+ type GatewayErrorBody,
7
+ type GatewayErrorCode,
8
+ } from '../contract/gateway';
9
+
2
10
  export type AiErrorCode =
3
11
  | 'auth-invalid'
12
+ /** The session itself ended: the user has to sign in again. */
13
+ | 'session-expired'
4
14
  | 'not-entitled'
5
15
  | 'user-blocked'
6
16
  | 'app-unknown'
@@ -9,24 +19,50 @@ export type AiErrorCode =
9
19
  | 'payload-too-large'
10
20
  | 'modality-not-allowed'
11
21
  | 'config-unavailable'
22
+ /** The request never reached the gateway: no connection, a timeout, a dropped stream. */
23
+ | 'network'
12
24
  | 'upstream-error'
13
25
  | 'internal';
14
26
 
15
- export class AiError extends Error {
16
- readonly code: AiErrorCode;
17
- readonly retryAfterSeconds?: number;
18
- readonly recoverable: boolean;
27
+ export interface AiErrorOptions {
28
+ retryAfterSeconds?: number | undefined;
29
+ /** Whether retrying can succeed. Defaults by code; see `RETRYABLE_BY_DEFAULT`. */
30
+ retryable?: boolean | undefined;
31
+ /** @deprecated Use `retryable`; accepted as its alias. */
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;
38
+ }
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
+
47
+ /** The package's error; an `SdkError` (`@chipmobilesdk/rn-core`) with `pkg: 'rn-ai'`. */
48
+ export class AiError extends SdkError<AiErrorCode> {
49
+ readonly retryAfterSeconds?: number | undefined;
50
+ /** The underlying code, when one layer down had a name for the failure. */
51
+ readonly cause?: string | undefined;
19
52
 
20
- constructor(
21
- code: AiErrorCode,
22
- message: string,
23
- options: { retryAfterSeconds?: number; recoverable?: boolean } = {},
24
- ) {
25
- super(message);
53
+ constructor(code: AiErrorCode, message: string, options: AiErrorOptions = {}) {
54
+ super('rn-ai', code, {
55
+ message,
56
+ retryable: options.retryable ?? options.recoverable ?? RETRYABLE_BY_DEFAULT.has(code),
57
+ });
26
58
  this.name = 'AiError';
27
- this.code = code;
28
59
  this.retryAfterSeconds = options.retryAfterSeconds;
29
- this.recoverable = options.recoverable ?? (code === 'rate-limited' || code === 'upstream-busy');
60
+ this.cause = options.cause;
61
+ }
62
+
63
+ /** @deprecated Use `retryable`, the name every SDK package shares. */
64
+ get recoverable(): boolean {
65
+ return this.retryable;
30
66
  }
31
67
  }
32
68
 
@@ -34,41 +70,82 @@ export function isAiError(value: unknown): value is AiError {
34
70
  return value instanceof AiError;
35
71
  }
36
72
 
37
- const HTTP_CODE_MAP: Record<string, AiErrorCode> = {
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',
38
79
  AUTH_INVALID: 'auth-invalid',
39
80
  NOT_ENTITLED: 'not-entitled',
40
81
  USER_BLOCKED: 'user-blocked',
41
82
  APP_UNKNOWN: 'app-unknown',
83
+ // The endpoint moved: the client's configuration is the stale part.
84
+ ENDPOINT_RETIRED: 'config-unavailable',
42
85
  PAYLOAD_TOO_LARGE: 'payload-too-large',
43
86
  MODALITY_NOT_ALLOWED: 'modality-not-allowed',
44
87
  RATE_LIMITED: 'rate-limited',
45
88
  UPSTREAM_BUSY: 'upstream-busy',
46
89
  UPSTREAM_ERROR: 'upstream-error',
90
+ INTERNAL: 'internal',
47
91
  };
48
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
+
49
118
  /** Map a gateway error response (status + JSON body) to a typed AiError. */
50
- export function errorFromHttp(status: number, bodyText: string): AiError {
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
+
51
134
  let code: AiErrorCode = status >= 500 ? 'upstream-error' : 'internal';
52
135
  let message = `gateway responded ${status}`;
53
- let retryAfterSeconds: number | undefined;
136
+ let gatewayCode: GatewayErrorCode | undefined;
137
+ let retryAfterSeconds = parseRetryAfter(context.getHeader?.(RETRY_AFTER_HEADER));
54
138
  try {
55
- const parsed = JSON.parse(bodyText) as {
56
- error?: { code?: string; message?: string; retryAfterSeconds?: number };
57
- };
58
- if (parsed.error?.code && HTTP_CODE_MAP[parsed.error.code]) {
59
- 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];
60
143
  }
61
- if (parsed.error?.message) message = parsed.error.message;
62
- retryAfterSeconds = parsed.error?.retryAfterSeconds;
144
+ // The gateway's text can quote upstream detail; keep it readable but shaped.
145
+ if (parsed.error?.message) message = redactPatterns(parsed.error.message);
146
+ retryAfterSeconds = parsed.error?.retryAfterSeconds ?? retryAfterSeconds;
63
147
  } catch {
64
- if (status === 0) {
65
- code = 'upstream-error';
66
- message = 'network failure';
67
- }
148
+ // A body that is not the contract's: the status is all there is to go on.
68
149
  }
69
- // Status 0 is a dropped connection, which a retry may well fix.
70
- return new AiError(code, message, {
71
- retryAfterSeconds,
72
- recoverable: status === 0 ? true : undefined,
73
- });
150
+ return new AiError(code, message, { retryAfterSeconds, cause: gatewayCode });
74
151
  }
@@ -1,27 +1,66 @@
1
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.
2
+ * User-facing copy keys (UX-002/003) and their English defaults, as an rn-core
3
+ * message catalog: apps translate them through rn-i18n
4
+ * (`runtimeEntriesFromCatalog(aiMessages)`) or override them directly.
4
5
  */
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;
6
+ import { defineMessages } from '@chipmobilesdk/rn-core';
7
+
8
+ import type { AiErrorCode } from './errors';
9
+ import type { AiError } from './errors';
10
+
11
+ export const aiMessageSet = defineMessages({
12
+ user: {
13
+ 'ai.state.loading': 'Thinking…',
14
+ 'ai.state.streaming': 'Answering…',
15
+ // Neutral copy: the app names its own feature (AI-16).
16
+ 'ai.input.placeholder': 'Type a message…',
17
+ 'ai.action.send': 'Send',
18
+ 'ai.action.cancel': 'Cancel',
19
+ 'ai.action.retry': 'Try again',
20
+ 'ai.action.retryAt': 'You can try again at {time}',
21
+ 'ai.quota.remaining': '{count} requests left today',
22
+ 'ai.error.rateLimited': 'You have reached your usage limit.',
23
+ 'ai.error.premiumRequired': 'AI features require a premium subscription for this app.',
24
+ 'ai.error.blocked': 'AI access is suspended for this account.',
25
+ 'ai.error.payloadTooLarge': 'That attachment is too large to send.',
26
+ 'ai.error.modality': 'This kind of content is not supported here.',
27
+ 'ai.error.busy': 'The AI service is busy. Please try again shortly.',
28
+ 'ai.error.unavailable': 'AI features are currently unavailable.',
29
+ 'ai.error.signIn': 'Please sign in again to continue.',
30
+ 'ai.error.offline': 'No connection. Check your network and try again.',
31
+ 'ai.error.generic': 'Something went wrong. Please try again.',
32
+ },
33
+ });
34
+
35
+ export const aiMessages = aiMessageSet.defaults;
22
36
 
23
37
  export type AiMessageKey = keyof typeof aiMessages;
24
38
 
25
39
  export function aiMessage(key: AiMessageKey): string {
26
40
  return aiMessages[key];
27
41
  }
42
+
43
+ /**
44
+ * The copy for each error code. A `Record` over the whole code union, so adding a
45
+ * code without choosing its copy fails to compile (AI-16).
46
+ */
47
+ const ERROR_MESSAGES: Record<AiErrorCode, AiMessageKey> = {
48
+ 'auth-invalid': 'ai.error.signIn',
49
+ 'session-expired': 'ai.error.signIn',
50
+ network: 'ai.error.offline',
51
+ 'not-entitled': 'ai.error.premiumRequired',
52
+ 'user-blocked': 'ai.error.blocked',
53
+ 'app-unknown': 'ai.error.unavailable',
54
+ 'rate-limited': 'ai.error.rateLimited',
55
+ 'upstream-busy': 'ai.error.busy',
56
+ 'payload-too-large': 'ai.error.payloadTooLarge',
57
+ 'modality-not-allowed': 'ai.error.modality',
58
+ 'config-unavailable': 'ai.error.unavailable',
59
+ 'upstream-error': 'ai.error.generic',
60
+ internal: 'ai.error.generic',
61
+ };
62
+
63
+ /** The message key to show a user for an error. */
64
+ export function messageKeyForError(error: AiError): AiMessageKey {
65
+ return ERROR_MESSAGES[error.code];
66
+ }
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,12 +21,38 @@ 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';
25
- export { useAiChat, useAiQuota } from './react/hooks';
26
- export type { AiChatStatus, UseAiChatResult, UseAiQuotaResult } from './react/hooks';
40
+ export type {
41
+ SseRequest,
42
+ SseHandlers,
43
+ SseHandle,
44
+ SseResponseInfo,
45
+ StreamTransport,
46
+ } from './transport/sseClient';
47
+ export { useAiBehaviorState, useAiChat, useAiQuota } from './react/hooks';
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';
30
- export { aiMessages, aiMessage } from './diagnostics/messageKeys';
57
+ export { aiMessage, aiMessageSet, aiMessages, messageKeyForError } from './diagnostics/messageKeys';
31
58
  export type { AiMessageKey } from './diagnostics/messageKeys';
@@ -1,5 +1,12 @@
1
+ import { useExternalStore } from '@chipmobilesdk/rn-core/react';
1
2
  import { useCallback, useEffect, useRef, useState } from 'react';
2
- import type { AiClient, ChatHandle, ChatRequest, QuotaSnapshot } from '../core/aiClient';
3
+ import type {
4
+ AiBehaviorState,
5
+ AiClient,
6
+ ChatHandle,
7
+ ChatRequest,
8
+ QuotaSnapshot,
9
+ } from '../core/aiClient';
3
10
  import type { AiError } from '../diagnostics/errors';
4
11
 
5
12
  export type AiChatStatus = 'idle' | 'loading' | 'streaming' | 'done' | 'error' | 'rate-limited';
@@ -8,30 +15,74 @@ export interface UseAiChatResult {
8
15
  send(request: ChatRequest): void;
9
16
  cancel(): void;
10
17
  status: AiChatStatus;
11
- /** Accumulated streamed text (appends incrementally — PERF-002). */
18
+ /** Accumulated streamed text, flushed on an interval rather than per token. */
12
19
  text: string;
13
20
  error?: AiError;
14
21
  retryAt?: Date;
15
22
  quota?: QuotaSnapshot;
16
23
  }
17
24
 
18
- export function useAiChat(client: AiClient): UseAiChatResult {
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 {
19
40
  const [status, setStatus] = useState<AiChatStatus>('idle');
20
41
  const [text, setText] = useState('');
21
42
  const [error, setError] = useState<AiError | undefined>();
22
43
  const [retryAt, setRetryAt] = useState<Date | undefined>();
23
- const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
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;
24
48
  const handleRef = useRef<ChatHandle | null>(null);
25
49
  // Bumped by every send, cancel and unmount. A callback from an older request
26
50
  // is dropped, so it can never overwrite the state of the one on screen —
27
51
  // defence in depth behind the client's own cancel guard.
28
52
  const requestRef = useRef(0);
29
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
+
30
79
  const send = useCallback(
31
80
  (request: ChatRequest) => {
32
81
  handleRef.current?.cancel();
33
82
  const id = ++requestRef.current;
34
83
  const current = () => requestRef.current === id;
84
+ stopFlushing();
85
+ pending.current = '';
35
86
  setText('');
36
87
  setError(undefined);
37
88
  setRetryAt(undefined);
@@ -39,16 +90,28 @@ export function useAiChat(client: AiClient): UseAiChatResult {
39
90
  handleRef.current = client.chat(request, {
40
91
  onDelta: delta => {
41
92
  if (!current()) return;
42
- setStatus('streaming');
43
- setText(previous => previous + delta);
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'));
44
101
  },
45
102
  onDone: result => {
46
103
  if (!current()) return;
47
- if (result.quota) setQuota(result.quota);
104
+ stopFlushing();
105
+ flush();
106
+ if (result.quota) setReported(result.quota);
48
107
  setStatus('done');
49
108
  },
50
109
  onError: aiError => {
51
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();
52
115
  setError(aiError);
53
116
  if (aiError.code === 'rate-limited') {
54
117
  setStatus('rate-limited');
@@ -61,14 +124,15 @@ export function useAiChat(client: AiClient): UseAiChatResult {
61
124
  },
62
125
  });
63
126
  },
64
- [client],
127
+ [client, flush, flushMs, stopFlushing],
65
128
  );
66
129
 
67
130
  const cancel = useCallback(() => {
68
131
  requestRef.current += 1;
132
+ stopFlushing();
69
133
  handleRef.current?.cancel();
70
134
  setStatus('idle');
71
- }, []);
135
+ }, [stopFlushing]);
72
136
 
73
137
  useEffect(
74
138
  () => () => {
@@ -87,8 +151,25 @@ export interface UseAiQuotaResult {
87
151
  refresh(): void;
88
152
  }
89
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
+
90
168
  export function useAiQuota(client: AiClient): UseAiQuotaResult {
91
- const [quota, setQuota] = useState<QuotaSnapshot | undefined>();
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;
92
173
  const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading');
93
174
  const alive = useRef(true);
94
175
  // Only the latest refresh may settle the state; an older, slower one that
@@ -103,7 +184,7 @@ export function useAiQuota(client: AiClient): UseAiQuotaResult {
103
184
  .getQuota()
104
185
  .then(snapshot => {
105
186
  if (!current()) return;
106
- setQuota(snapshot);
187
+ setFetched(snapshot);
107
188
  setStatus('ready');
108
189
  })
109
190
  .catch(() => {
@@ -119,5 +200,14 @@ export function useAiQuota(client: AiClient): UseAiQuotaResult {
119
200
  };
120
201
  }, [refresh]);
121
202
 
122
- return { quota, status, refresh };
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 };
205
+ }
206
+
207
+ /**
208
+ * The client's behavior state (config source and whether AI is enabled), live:
209
+ * re-renders when the kill switch flips or the config state moves (AI-09).
210
+ */
211
+ export function useAiBehaviorState(client: AiClient): AiBehaviorState {
212
+ return useExternalStore(client.subscribe ?? noSubscription, client.getBehaviorState);
123
213
  }
@@ -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
- /** Parse complete `data:` events out of `text` starting at `fromIndex`. */
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 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;
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('\n')
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) emit(dataLines.join('\n'));
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.idleTimeoutMs); // HEADERS_RECEIVED
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'));