@chipmobilesdk/rn-ai 0.2.0 → 0.3.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,143 +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
- /** 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
- }
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
+ }
@@ -1,53 +1,53 @@
1
- /**
2
- * Time limits for every network call the client makes (AI-04).
3
- *
4
- * Without them a stalled mobile connection left `useAiChat` in `loading` or
5
- * `streaming`, and `useAiQuota` in `loading`, until the user gave up.
6
- */
7
-
8
- export interface AiTimeouts {
9
- /** Request sent → response headers received, for the chat stream. */
10
- connectMs: number;
11
- /** Longest silence allowed between two chunks of the chat stream. */
12
- idleMs: number;
13
- /** Behavior-config fetch, including reading the body. */
14
- configMs: number;
15
- /** Quota fetch, including reading the body. */
16
- quotaMs: number;
17
- }
18
-
19
- /**
20
- * The gateway sends headers only once the model has answered, so `connectMs`
21
- * also covers time to first token on a large image prompt.
22
- */
23
- export const DEFAULT_AI_TIMEOUTS: AiTimeouts = {
24
- connectMs: 30_000,
25
- idleMs: 30_000,
26
- configMs: 10_000,
27
- quotaMs: 10_000,
28
- };
29
-
30
- /**
31
- * Runs `run` with an abort signal and rejects with `onTimeout()` after `ms`.
32
- * The race matters as much as the signal: an injected `fetchFn` (or an old
33
- * polyfill) may ignore the signal entirely.
34
- */
35
- export async function withTimeout<T>(
36
- ms: number,
37
- run: (signal: AbortSignal | undefined) => Promise<T>,
38
- onTimeout: () => Error,
39
- ): Promise<T> {
40
- const controller = typeof AbortController === 'undefined' ? undefined : new AbortController();
41
- let timer: ReturnType<typeof setTimeout> | undefined;
42
- const expired = new Promise<never>((_, reject) => {
43
- timer = setTimeout(() => {
44
- controller?.abort();
45
- reject(onTimeout());
46
- }, ms);
47
- });
48
- try {
49
- return await Promise.race([run(controller?.signal), expired]);
50
- } finally {
51
- clearTimeout(timer);
52
- }
53
- }
1
+ /**
2
+ * Time limits for every network call the client makes (AI-04).
3
+ *
4
+ * Without them a stalled mobile connection left `useAiChat` in `loading` or
5
+ * `streaming`, and `useAiQuota` in `loading`, until the user gave up.
6
+ */
7
+
8
+ export interface AiTimeouts {
9
+ /** Request sent → response headers received, for the chat stream. */
10
+ connectMs: number;
11
+ /** Longest silence allowed between two chunks of the chat stream. */
12
+ idleMs: number;
13
+ /** Behavior-config fetch, including reading the body. */
14
+ configMs: number;
15
+ /** Quota fetch, including reading the body. */
16
+ quotaMs: number;
17
+ }
18
+
19
+ /**
20
+ * The gateway sends headers only once the model has answered, so `connectMs`
21
+ * also covers time to first token on a large image prompt.
22
+ */
23
+ export const DEFAULT_AI_TIMEOUTS: AiTimeouts = {
24
+ connectMs: 30_000,
25
+ idleMs: 30_000,
26
+ configMs: 10_000,
27
+ quotaMs: 10_000,
28
+ };
29
+
30
+ /**
31
+ * Runs `run` with an abort signal and rejects with `onTimeout()` after `ms`.
32
+ * The race matters as much as the signal: an injected `fetchFn` (or an old
33
+ * polyfill) may ignore the signal entirely.
34
+ */
35
+ export async function withTimeout<T>(
36
+ ms: number,
37
+ run: (signal: AbortSignal | undefined) => Promise<T>,
38
+ onTimeout: () => Error,
39
+ ): Promise<T> {
40
+ const controller = typeof AbortController === 'undefined' ? undefined : new AbortController();
41
+ let timer: ReturnType<typeof setTimeout> | undefined;
42
+ const expired = new Promise<never>((_, reject) => {
43
+ timer = setTimeout(() => {
44
+ controller?.abort();
45
+ reject(onTimeout());
46
+ }, ms);
47
+ });
48
+ try {
49
+ return await Promise.race([run(controller?.signal), expired]);
50
+ } finally {
51
+ clearTimeout(timer);
52
+ }
53
+ }