@inferencesh/sdk 0.5.10 → 0.5.11
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/dist/api/apps.d.ts +4 -0
- package/dist/api/apps.js +6 -0
- package/dist/http/index.d.ts +1 -0
- package/dist/http/index.js +1 -0
- package/dist/http/streamable.d.ts +67 -0
- package/dist/http/streamable.js +195 -0
- package/dist/http/streamable.test.d.ts +4 -0
- package/dist/http/streamable.test.js +410 -0
- package/dist/streamable.integration.test.d.ts +11 -0
- package/dist/streamable.integration.test.js +150 -0
- package/package.json +1 -1
package/dist/api/apps.d.ts
CHANGED
|
@@ -58,5 +58,9 @@ export declare class AppsAPI {
|
|
|
58
58
|
* Save app license
|
|
59
59
|
*/
|
|
60
60
|
saveLicense(appId: string, license: string): Promise<LicenseRecord>;
|
|
61
|
+
/**
|
|
62
|
+
* Set the current (active) version of an app
|
|
63
|
+
*/
|
|
64
|
+
setCurrentVersion(appId: string, versionId: string): Promise<App>;
|
|
61
65
|
}
|
|
62
66
|
export declare function createAppsAPI(http: HttpClient): AppsAPI;
|
package/dist/api/apps.js
CHANGED
|
@@ -83,6 +83,12 @@ export class AppsAPI {
|
|
|
83
83
|
async saveLicense(appId, license) {
|
|
84
84
|
return this.http.request('post', `/apps/${appId}/license`, { data: { license } });
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Set the current (active) version of an app
|
|
88
|
+
*/
|
|
89
|
+
async setCurrentVersion(appId, versionId) {
|
|
90
|
+
return this.http.request('post', `/apps/${appId}/current-version`, { data: { version_id: versionId } });
|
|
91
|
+
}
|
|
86
92
|
}
|
|
87
93
|
export function createAppsAPI(http) {
|
|
88
94
|
return new AppsAPI(http);
|
package/dist/http/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { HttpClient, HttpClientConfig, ErrorHandler, createHttpClient } from './client';
|
|
2
2
|
export { StreamManager, StreamManagerOptions, PartialDataWrapper } from './stream';
|
|
3
|
+
export { streamable, streamableRaw, StreamableManager, StreamableOptions, StreamableMessage, StreamableManagerOptions } from './streamable';
|
|
3
4
|
export { InferenceError, RequirementsNotMetException } from './errors';
|
package/dist/http/index.js
CHANGED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streamable HTTP client using fetch + ReadableStream.
|
|
3
|
+
* This is the NDJSON alternative to SSE/EventSource that avoids
|
|
4
|
+
* the browser's ~6 connection limit per domain.
|
|
5
|
+
*/
|
|
6
|
+
export interface StreamableOptions {
|
|
7
|
+
/** Additional headers to include */
|
|
8
|
+
headers?: Record<string, string>;
|
|
9
|
+
/** Request body (will be JSON stringified) */
|
|
10
|
+
body?: unknown;
|
|
11
|
+
/** HTTP method (defaults to GET, or POST if body is provided) */
|
|
12
|
+
method?: 'GET' | 'POST';
|
|
13
|
+
/** AbortSignal for cancellation */
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
/** Skip heartbeat messages (default: true) */
|
|
16
|
+
skipHeartbeats?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface StreamableMessage<T = unknown> {
|
|
19
|
+
/** Optional event type */
|
|
20
|
+
event?: string;
|
|
21
|
+
/** Data payload */
|
|
22
|
+
data?: T;
|
|
23
|
+
/** Updated fields (for partial updates) */
|
|
24
|
+
fields?: string[];
|
|
25
|
+
/** Message type (e.g., "heartbeat") */
|
|
26
|
+
type?: string;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Stream NDJSON from an HTTP endpoint using fetch + ReadableStream.
|
|
30
|
+
* Yields parsed JSON objects, automatically filtering heartbeats.
|
|
31
|
+
*/
|
|
32
|
+
export declare function streamable<T = unknown>(url: string, options?: StreamableOptions): AsyncGenerator<T>;
|
|
33
|
+
/**
|
|
34
|
+
* StreamableManager provides a callback-based API similar to StreamManager
|
|
35
|
+
* but uses fetch + ReadableStream instead of EventSource.
|
|
36
|
+
*/
|
|
37
|
+
export interface StreamableManagerOptions<T> {
|
|
38
|
+
/** URL to connect to */
|
|
39
|
+
url: string;
|
|
40
|
+
/** Additional headers */
|
|
41
|
+
headers?: Record<string, string>;
|
|
42
|
+
/** Request body */
|
|
43
|
+
body?: unknown;
|
|
44
|
+
/** Called for each message */
|
|
45
|
+
onData?: (data: T) => void;
|
|
46
|
+
/** Called for partial updates with fields list */
|
|
47
|
+
onPartialData?: (data: T, fields: string[]) => void;
|
|
48
|
+
/** Called on error */
|
|
49
|
+
onError?: (error: Error) => void;
|
|
50
|
+
/** Called when stream starts */
|
|
51
|
+
onStart?: () => void;
|
|
52
|
+
/** Called when stream ends */
|
|
53
|
+
onEnd?: () => void;
|
|
54
|
+
}
|
|
55
|
+
export declare class StreamableManager<T> {
|
|
56
|
+
private options;
|
|
57
|
+
private abortController;
|
|
58
|
+
private isRunning;
|
|
59
|
+
constructor(options: StreamableManagerOptions<T>);
|
|
60
|
+
start(): Promise<void>;
|
|
61
|
+
stop(): void;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Raw streamable that yields the full message including wrapper.
|
|
65
|
+
* Use this when you need access to event type or fields.
|
|
66
|
+
*/
|
|
67
|
+
export declare function streamableRaw<T = unknown>(url: string, options?: StreamableOptions): AsyncGenerator<StreamableMessage<T> | T>;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streamable HTTP client using fetch + ReadableStream.
|
|
3
|
+
* This is the NDJSON alternative to SSE/EventSource that avoids
|
|
4
|
+
* the browser's ~6 connection limit per domain.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Stream NDJSON from an HTTP endpoint using fetch + ReadableStream.
|
|
8
|
+
* Yields parsed JSON objects, automatically filtering heartbeats.
|
|
9
|
+
*/
|
|
10
|
+
export async function* streamable(url, options = {}) {
|
|
11
|
+
const { headers = {}, body, method = body ? 'POST' : 'GET', signal, skipHeartbeats = true, } = options;
|
|
12
|
+
const requestHeaders = {
|
|
13
|
+
Accept: 'application/x-ndjson',
|
|
14
|
+
...headers,
|
|
15
|
+
};
|
|
16
|
+
if (body) {
|
|
17
|
+
requestHeaders['Content-Type'] = 'application/json';
|
|
18
|
+
}
|
|
19
|
+
const res = await fetch(url, {
|
|
20
|
+
method,
|
|
21
|
+
headers: requestHeaders,
|
|
22
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
23
|
+
signal,
|
|
24
|
+
});
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
const text = await res.text();
|
|
27
|
+
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
28
|
+
}
|
|
29
|
+
if (!res.body) {
|
|
30
|
+
throw new Error('No response body');
|
|
31
|
+
}
|
|
32
|
+
const reader = res.body.getReader();
|
|
33
|
+
const decoder = new TextDecoder();
|
|
34
|
+
let buffer = '';
|
|
35
|
+
try {
|
|
36
|
+
while (true) {
|
|
37
|
+
const { done, value } = await reader.read();
|
|
38
|
+
if (done)
|
|
39
|
+
break;
|
|
40
|
+
buffer += decoder.decode(value, { stream: true });
|
|
41
|
+
const lines = buffer.split('\n');
|
|
42
|
+
buffer = lines.pop();
|
|
43
|
+
for (const line of lines) {
|
|
44
|
+
if (!line.trim())
|
|
45
|
+
continue;
|
|
46
|
+
const parsed = JSON.parse(line);
|
|
47
|
+
// Skip heartbeats
|
|
48
|
+
if (skipHeartbeats && parsed.type === 'heartbeat') {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
// If it's a wrapped message with event/data/fields, yield the data
|
|
52
|
+
// Otherwise yield the entire parsed object
|
|
53
|
+
if ('data' in parsed && parsed.data !== undefined) {
|
|
54
|
+
yield parsed.data;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
yield parsed;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Handle remaining buffer
|
|
62
|
+
if (buffer.trim()) {
|
|
63
|
+
const parsed = JSON.parse(buffer);
|
|
64
|
+
if (!(skipHeartbeats && parsed.type === 'heartbeat')) {
|
|
65
|
+
if ('data' in parsed && parsed.data !== undefined) {
|
|
66
|
+
yield parsed.data;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
yield parsed;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
reader.releaseLock();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export class StreamableManager {
|
|
79
|
+
constructor(options) {
|
|
80
|
+
this.abortController = null;
|
|
81
|
+
this.isRunning = false;
|
|
82
|
+
this.options = options;
|
|
83
|
+
}
|
|
84
|
+
async start() {
|
|
85
|
+
if (this.isRunning)
|
|
86
|
+
return;
|
|
87
|
+
this.isRunning = true;
|
|
88
|
+
this.abortController = new AbortController();
|
|
89
|
+
try {
|
|
90
|
+
this.options.onStart?.();
|
|
91
|
+
for await (const message of streamableRaw(this.options.url, {
|
|
92
|
+
headers: this.options.headers,
|
|
93
|
+
body: this.options.body,
|
|
94
|
+
signal: this.abortController.signal,
|
|
95
|
+
})) {
|
|
96
|
+
if (!this.isRunning)
|
|
97
|
+
break;
|
|
98
|
+
// Check if it's a partial update
|
|
99
|
+
if (typeof message === 'object' &&
|
|
100
|
+
message !== null &&
|
|
101
|
+
'data' in message &&
|
|
102
|
+
'fields' in message &&
|
|
103
|
+
Array.isArray(message.fields)) {
|
|
104
|
+
const wrapper = message;
|
|
105
|
+
if (this.options.onPartialData && wrapper.data !== undefined) {
|
|
106
|
+
this.options.onPartialData(wrapper.data, wrapper.fields);
|
|
107
|
+
}
|
|
108
|
+
else if (wrapper.data !== undefined) {
|
|
109
|
+
this.options.onData?.(wrapper.data);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
this.options.onData?.(message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
119
|
+
// Intentional stop, not an error
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
this.options.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
this.isRunning = false;
|
|
127
|
+
this.options.onEnd?.();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
stop() {
|
|
131
|
+
this.isRunning = false;
|
|
132
|
+
this.abortController?.abort();
|
|
133
|
+
this.abortController = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Raw streamable that yields the full message including wrapper.
|
|
138
|
+
* Use this when you need access to event type or fields.
|
|
139
|
+
*/
|
|
140
|
+
export async function* streamableRaw(url, options = {}) {
|
|
141
|
+
const { headers = {}, body, method = body ? 'POST' : 'GET', signal, skipHeartbeats = true, } = options;
|
|
142
|
+
const requestHeaders = {
|
|
143
|
+
Accept: 'application/x-ndjson',
|
|
144
|
+
...headers,
|
|
145
|
+
};
|
|
146
|
+
if (body) {
|
|
147
|
+
requestHeaders['Content-Type'] = 'application/json';
|
|
148
|
+
}
|
|
149
|
+
const res = await fetch(url, {
|
|
150
|
+
method,
|
|
151
|
+
headers: requestHeaders,
|
|
152
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
153
|
+
signal,
|
|
154
|
+
});
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
const text = await res.text();
|
|
157
|
+
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
158
|
+
}
|
|
159
|
+
if (!res.body) {
|
|
160
|
+
throw new Error('No response body');
|
|
161
|
+
}
|
|
162
|
+
const reader = res.body.getReader();
|
|
163
|
+
const decoder = new TextDecoder();
|
|
164
|
+
let buffer = '';
|
|
165
|
+
try {
|
|
166
|
+
while (true) {
|
|
167
|
+
const { done, value } = await reader.read();
|
|
168
|
+
if (done)
|
|
169
|
+
break;
|
|
170
|
+
buffer += decoder.decode(value, { stream: true });
|
|
171
|
+
const lines = buffer.split('\n');
|
|
172
|
+
buffer = lines.pop();
|
|
173
|
+
for (const line of lines) {
|
|
174
|
+
if (!line.trim())
|
|
175
|
+
continue;
|
|
176
|
+
const parsed = JSON.parse(line);
|
|
177
|
+
// Skip heartbeats
|
|
178
|
+
if (skipHeartbeats && parsed.type === 'heartbeat') {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
yield parsed;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Handle remaining buffer
|
|
185
|
+
if (buffer.trim()) {
|
|
186
|
+
const parsed = JSON.parse(buffer);
|
|
187
|
+
if (!(skipHeartbeats && parsed.type === 'heartbeat')) {
|
|
188
|
+
yield parsed;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
reader.releaseLock();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for streamable HTTP client
|
|
3
|
+
*/
|
|
4
|
+
import { streamable, streamableRaw, StreamableManager } from './streamable';
|
|
5
|
+
// Mock fetch for unit tests
|
|
6
|
+
const mockFetch = (chunks) => {
|
|
7
|
+
let chunkIndex = 0;
|
|
8
|
+
const mockReader = {
|
|
9
|
+
read: jest.fn().mockImplementation(async () => {
|
|
10
|
+
if (chunkIndex >= chunks.length) {
|
|
11
|
+
return { done: true, value: undefined };
|
|
12
|
+
}
|
|
13
|
+
const chunk = chunks[chunkIndex++];
|
|
14
|
+
return { done: false, value: new TextEncoder().encode(chunk) };
|
|
15
|
+
}),
|
|
16
|
+
releaseLock: jest.fn(),
|
|
17
|
+
};
|
|
18
|
+
return jest.fn().mockResolvedValue({
|
|
19
|
+
ok: true,
|
|
20
|
+
status: 200,
|
|
21
|
+
body: {
|
|
22
|
+
getReader: () => mockReader,
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
describe('streamable', () => {
|
|
27
|
+
const originalFetch = global.fetch;
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
global.fetch = originalFetch;
|
|
30
|
+
});
|
|
31
|
+
it('should parse NDJSON lines', async () => {
|
|
32
|
+
global.fetch = mockFetch([
|
|
33
|
+
'{"id":1,"name":"first"}\n',
|
|
34
|
+
'{"id":2,"name":"second"}\n',
|
|
35
|
+
]);
|
|
36
|
+
const results = [];
|
|
37
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
38
|
+
results.push(item);
|
|
39
|
+
}
|
|
40
|
+
expect(results).toHaveLength(2);
|
|
41
|
+
expect(results[0]).toEqual({ id: 1, name: 'first' });
|
|
42
|
+
expect(results[1]).toEqual({ id: 2, name: 'second' });
|
|
43
|
+
});
|
|
44
|
+
it('should skip heartbeats by default', async () => {
|
|
45
|
+
global.fetch = mockFetch([
|
|
46
|
+
'{"id":1}\n',
|
|
47
|
+
'{"type":"heartbeat"}\n',
|
|
48
|
+
'{"id":2}\n',
|
|
49
|
+
]);
|
|
50
|
+
const results = [];
|
|
51
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
52
|
+
results.push(item);
|
|
53
|
+
}
|
|
54
|
+
expect(results).toHaveLength(2);
|
|
55
|
+
expect(results[0]).toEqual({ id: 1 });
|
|
56
|
+
expect(results[1]).toEqual({ id: 2 });
|
|
57
|
+
});
|
|
58
|
+
it('should include heartbeats when skipHeartbeats is false', async () => {
|
|
59
|
+
global.fetch = mockFetch([
|
|
60
|
+
'{"id":1}\n',
|
|
61
|
+
'{"type":"heartbeat"}\n',
|
|
62
|
+
'{"id":2}\n',
|
|
63
|
+
]);
|
|
64
|
+
const results = [];
|
|
65
|
+
for await (const item of streamable('http://test.com/stream', { skipHeartbeats: false })) {
|
|
66
|
+
results.push(item);
|
|
67
|
+
}
|
|
68
|
+
expect(results).toHaveLength(3);
|
|
69
|
+
});
|
|
70
|
+
it('should unwrap data from wrapped messages', async () => {
|
|
71
|
+
global.fetch = mockFetch([
|
|
72
|
+
'{"data":{"id":1},"fields":["id"]}\n',
|
|
73
|
+
'{"event":"update","data":{"id":2}}\n',
|
|
74
|
+
]);
|
|
75
|
+
const results = [];
|
|
76
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
77
|
+
results.push(item);
|
|
78
|
+
}
|
|
79
|
+
expect(results).toHaveLength(2);
|
|
80
|
+
expect(results[0]).toEqual({ id: 1 });
|
|
81
|
+
expect(results[1]).toEqual({ id: 2 });
|
|
82
|
+
});
|
|
83
|
+
it('should handle chunked data across multiple reads', async () => {
|
|
84
|
+
global.fetch = mockFetch([
|
|
85
|
+
'{"id":1}\n{"id":', // First chunk ends mid-JSON
|
|
86
|
+
'2}\n', // Second chunk completes it
|
|
87
|
+
]);
|
|
88
|
+
const results = [];
|
|
89
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
90
|
+
results.push(item);
|
|
91
|
+
}
|
|
92
|
+
expect(results).toHaveLength(2);
|
|
93
|
+
expect(results[0]).toEqual({ id: 1 });
|
|
94
|
+
expect(results[1]).toEqual({ id: 2 });
|
|
95
|
+
});
|
|
96
|
+
it('should handle empty lines', async () => {
|
|
97
|
+
global.fetch = mockFetch([
|
|
98
|
+
'{"id":1}\n',
|
|
99
|
+
'\n',
|
|
100
|
+
'{"id":2}\n',
|
|
101
|
+
'\n\n',
|
|
102
|
+
]);
|
|
103
|
+
const results = [];
|
|
104
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
105
|
+
results.push(item);
|
|
106
|
+
}
|
|
107
|
+
expect(results).toHaveLength(2);
|
|
108
|
+
});
|
|
109
|
+
it('should set correct headers', async () => {
|
|
110
|
+
const mockFetchFn = mockFetch(['{"ok":true}\n']);
|
|
111
|
+
global.fetch = mockFetchFn;
|
|
112
|
+
const results = [];
|
|
113
|
+
for await (const item of streamable('http://test.com/stream', {
|
|
114
|
+
headers: { 'Authorization': 'Bearer token' },
|
|
115
|
+
})) {
|
|
116
|
+
results.push(item);
|
|
117
|
+
}
|
|
118
|
+
expect(mockFetchFn).toHaveBeenCalledWith('http://test.com/stream', expect.objectContaining({
|
|
119
|
+
method: 'GET',
|
|
120
|
+
headers: expect.objectContaining({
|
|
121
|
+
'Accept': 'application/x-ndjson',
|
|
122
|
+
'Authorization': 'Bearer token',
|
|
123
|
+
}),
|
|
124
|
+
}));
|
|
125
|
+
});
|
|
126
|
+
it('should use POST when body is provided', async () => {
|
|
127
|
+
const mockFetchFn = mockFetch(['{"ok":true}\n']);
|
|
128
|
+
global.fetch = mockFetchFn;
|
|
129
|
+
const results = [];
|
|
130
|
+
for await (const item of streamable('http://test.com/stream', {
|
|
131
|
+
body: { query: 'test' },
|
|
132
|
+
})) {
|
|
133
|
+
results.push(item);
|
|
134
|
+
}
|
|
135
|
+
expect(mockFetchFn).toHaveBeenCalledWith('http://test.com/stream', expect.objectContaining({
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: expect.objectContaining({
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
}),
|
|
140
|
+
body: JSON.stringify({ query: 'test' }),
|
|
141
|
+
}));
|
|
142
|
+
});
|
|
143
|
+
it('should throw on HTTP error', async () => {
|
|
144
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
145
|
+
ok: false,
|
|
146
|
+
status: 401,
|
|
147
|
+
text: async () => 'Unauthorized',
|
|
148
|
+
});
|
|
149
|
+
await expect(async () => {
|
|
150
|
+
for await (const _ of streamable('http://test.com/stream')) {
|
|
151
|
+
// consume
|
|
152
|
+
}
|
|
153
|
+
}).rejects.toThrow('HTTP 401: Unauthorized');
|
|
154
|
+
});
|
|
155
|
+
// Edge cases for chunking/buffering
|
|
156
|
+
describe('chunking edge cases', () => {
|
|
157
|
+
it('should handle message split across many chunks', async () => {
|
|
158
|
+
// Split a single JSON object into 5 tiny chunks
|
|
159
|
+
global.fetch = mockFetch([
|
|
160
|
+
'{"id":',
|
|
161
|
+
'123,',
|
|
162
|
+
'"name"',
|
|
163
|
+
':"test',
|
|
164
|
+
'"}\n',
|
|
165
|
+
]);
|
|
166
|
+
const results = [];
|
|
167
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
168
|
+
results.push(item);
|
|
169
|
+
}
|
|
170
|
+
expect(results).toHaveLength(1);
|
|
171
|
+
expect(results[0]).toEqual({ id: 123, name: 'test' });
|
|
172
|
+
});
|
|
173
|
+
it('should handle single-byte chunks', async () => {
|
|
174
|
+
const json = '{"id":1}\n';
|
|
175
|
+
const chunks = json.split('').map(c => c); // One char per chunk
|
|
176
|
+
global.fetch = mockFetch(chunks);
|
|
177
|
+
const results = [];
|
|
178
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
179
|
+
results.push(item);
|
|
180
|
+
}
|
|
181
|
+
expect(results).toHaveLength(1);
|
|
182
|
+
expect(results[0]).toEqual({ id: 1 });
|
|
183
|
+
});
|
|
184
|
+
it('should handle newline exactly at chunk boundary', async () => {
|
|
185
|
+
global.fetch = mockFetch([
|
|
186
|
+
'{"id":1}',
|
|
187
|
+
'\n',
|
|
188
|
+
'{"id":2}\n',
|
|
189
|
+
]);
|
|
190
|
+
const results = [];
|
|
191
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
192
|
+
results.push(item);
|
|
193
|
+
}
|
|
194
|
+
expect(results).toHaveLength(2);
|
|
195
|
+
});
|
|
196
|
+
it('should handle multiple messages in single chunk', async () => {
|
|
197
|
+
global.fetch = mockFetch([
|
|
198
|
+
'{"id":1}\n{"id":2}\n{"id":3}\n',
|
|
199
|
+
]);
|
|
200
|
+
const results = [];
|
|
201
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
202
|
+
results.push(item);
|
|
203
|
+
}
|
|
204
|
+
expect(results).toHaveLength(3);
|
|
205
|
+
});
|
|
206
|
+
it('should handle empty chunks', async () => {
|
|
207
|
+
global.fetch = mockFetch([
|
|
208
|
+
'{"id":1}\n',
|
|
209
|
+
'',
|
|
210
|
+
'',
|
|
211
|
+
'{"id":2}\n',
|
|
212
|
+
]);
|
|
213
|
+
const results = [];
|
|
214
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
215
|
+
results.push(item);
|
|
216
|
+
}
|
|
217
|
+
expect(results).toHaveLength(2);
|
|
218
|
+
});
|
|
219
|
+
it('should handle trailing data without final newline', async () => {
|
|
220
|
+
global.fetch = mockFetch([
|
|
221
|
+
'{"id":1}\n',
|
|
222
|
+
'{"id":2}', // No trailing newline
|
|
223
|
+
]);
|
|
224
|
+
const results = [];
|
|
225
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
226
|
+
results.push(item);
|
|
227
|
+
}
|
|
228
|
+
expect(results).toHaveLength(2);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
describe('unicode handling', () => {
|
|
232
|
+
it('should handle unicode in messages', async () => {
|
|
233
|
+
global.fetch = mockFetch([
|
|
234
|
+
'{"text":"Hello 世界 🎉"}\n',
|
|
235
|
+
]);
|
|
236
|
+
const results = [];
|
|
237
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
238
|
+
results.push(item);
|
|
239
|
+
}
|
|
240
|
+
expect(results[0]).toEqual({ text: 'Hello 世界 🎉' });
|
|
241
|
+
});
|
|
242
|
+
it('should handle unicode split across chunks', async () => {
|
|
243
|
+
// 世 is 3 bytes in UTF-8: E4 B8 96
|
|
244
|
+
// Split it across chunks
|
|
245
|
+
const encoder = new TextEncoder();
|
|
246
|
+
const fullText = '{"text":"世"}\n';
|
|
247
|
+
const bytes = encoder.encode(fullText);
|
|
248
|
+
// Create mock that returns raw bytes split mid-character
|
|
249
|
+
let chunkIndex = 0;
|
|
250
|
+
const chunks = [
|
|
251
|
+
bytes.slice(0, 10), // '{"text":"' + first byte of 世
|
|
252
|
+
bytes.slice(10), // rest of 世 + '"}\n'
|
|
253
|
+
];
|
|
254
|
+
const mockReader = {
|
|
255
|
+
read: jest.fn().mockImplementation(async () => {
|
|
256
|
+
if (chunkIndex >= chunks.length) {
|
|
257
|
+
return { done: true, value: undefined };
|
|
258
|
+
}
|
|
259
|
+
return { done: false, value: chunks[chunkIndex++] };
|
|
260
|
+
}),
|
|
261
|
+
releaseLock: jest.fn(),
|
|
262
|
+
};
|
|
263
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
264
|
+
ok: true,
|
|
265
|
+
body: { getReader: () => mockReader },
|
|
266
|
+
});
|
|
267
|
+
const results = [];
|
|
268
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
269
|
+
results.push(item);
|
|
270
|
+
}
|
|
271
|
+
expect(results[0]).toEqual({ text: '世' });
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
describe('error handling', () => {
|
|
275
|
+
it('should handle invalid JSON gracefully', async () => {
|
|
276
|
+
global.fetch = mockFetch([
|
|
277
|
+
'{"id":1}\n',
|
|
278
|
+
'not valid json\n',
|
|
279
|
+
'{"id":2}\n',
|
|
280
|
+
]);
|
|
281
|
+
const results = [];
|
|
282
|
+
await expect(async () => {
|
|
283
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
284
|
+
results.push(item);
|
|
285
|
+
}
|
|
286
|
+
}).rejects.toThrow(); // Should throw on invalid JSON
|
|
287
|
+
// First valid message should have been processed
|
|
288
|
+
expect(results).toHaveLength(1);
|
|
289
|
+
});
|
|
290
|
+
it('should handle network error mid-stream', async () => {
|
|
291
|
+
let readCount = 0;
|
|
292
|
+
const mockReader = {
|
|
293
|
+
read: jest.fn().mockImplementation(async () => {
|
|
294
|
+
readCount++;
|
|
295
|
+
if (readCount === 1) {
|
|
296
|
+
return { done: false, value: new TextEncoder().encode('{"id":1}\n') };
|
|
297
|
+
}
|
|
298
|
+
throw new Error('Network error');
|
|
299
|
+
}),
|
|
300
|
+
releaseLock: jest.fn(),
|
|
301
|
+
};
|
|
302
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
303
|
+
ok: true,
|
|
304
|
+
body: { getReader: () => mockReader },
|
|
305
|
+
});
|
|
306
|
+
const results = [];
|
|
307
|
+
await expect(async () => {
|
|
308
|
+
for await (const item of streamable('http://test.com/stream')) {
|
|
309
|
+
results.push(item);
|
|
310
|
+
}
|
|
311
|
+
}).rejects.toThrow('Network error');
|
|
312
|
+
expect(results).toHaveLength(1);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
describe('streamableRaw', () => {
|
|
317
|
+
const originalFetch = global.fetch;
|
|
318
|
+
afterEach(() => {
|
|
319
|
+
global.fetch = originalFetch;
|
|
320
|
+
});
|
|
321
|
+
it('should preserve event and fields in raw mode', async () => {
|
|
322
|
+
global.fetch = mockFetch([
|
|
323
|
+
'{"event":"update","data":{"id":1},"fields":["id"]}\n',
|
|
324
|
+
]);
|
|
325
|
+
const results = [];
|
|
326
|
+
for await (const item of streamableRaw('http://test.com/stream')) {
|
|
327
|
+
results.push(item);
|
|
328
|
+
}
|
|
329
|
+
expect(results).toHaveLength(1);
|
|
330
|
+
expect(results[0]).toEqual({
|
|
331
|
+
event: 'update',
|
|
332
|
+
data: { id: 1 },
|
|
333
|
+
fields: ['id'],
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
describe('StreamableManager', () => {
|
|
338
|
+
const originalFetch = global.fetch;
|
|
339
|
+
afterEach(() => {
|
|
340
|
+
global.fetch = originalFetch;
|
|
341
|
+
});
|
|
342
|
+
it('should call onData for each message', async () => {
|
|
343
|
+
global.fetch = mockFetch([
|
|
344
|
+
'{"id":1}\n',
|
|
345
|
+
'{"id":2}\n',
|
|
346
|
+
]);
|
|
347
|
+
const messages = [];
|
|
348
|
+
const manager = new StreamableManager({
|
|
349
|
+
url: 'http://test.com/stream',
|
|
350
|
+
onData: (data) => messages.push(data),
|
|
351
|
+
});
|
|
352
|
+
await manager.start();
|
|
353
|
+
expect(messages).toHaveLength(2);
|
|
354
|
+
expect(messages[0]).toEqual({ id: 1 });
|
|
355
|
+
expect(messages[1]).toEqual({ id: 2 });
|
|
356
|
+
});
|
|
357
|
+
it('should call onPartialData for partial updates', async () => {
|
|
358
|
+
global.fetch = mockFetch([
|
|
359
|
+
'{"data":{"id":1},"fields":["id"]}\n',
|
|
360
|
+
]);
|
|
361
|
+
const partials = [];
|
|
362
|
+
const manager = new StreamableManager({
|
|
363
|
+
url: 'http://test.com/stream',
|
|
364
|
+
onPartialData: (data, fields) => partials.push({ data, fields }),
|
|
365
|
+
});
|
|
366
|
+
await manager.start();
|
|
367
|
+
expect(partials).toHaveLength(1);
|
|
368
|
+
expect(partials[0]).toEqual({ data: { id: 1 }, fields: ['id'] });
|
|
369
|
+
});
|
|
370
|
+
it('should call lifecycle callbacks', async () => {
|
|
371
|
+
global.fetch = mockFetch(['{"ok":true}\n']);
|
|
372
|
+
const events = [];
|
|
373
|
+
const manager = new StreamableManager({
|
|
374
|
+
url: 'http://test.com/stream',
|
|
375
|
+
onStart: () => events.push('start'),
|
|
376
|
+
onEnd: () => events.push('end'),
|
|
377
|
+
onData: () => events.push('data'),
|
|
378
|
+
});
|
|
379
|
+
await manager.start();
|
|
380
|
+
expect(events).toEqual(['start', 'data', 'end']);
|
|
381
|
+
});
|
|
382
|
+
it('should handle stop()', async () => {
|
|
383
|
+
let readCount = 0;
|
|
384
|
+
const mockReader = {
|
|
385
|
+
read: jest.fn().mockImplementation(async () => {
|
|
386
|
+
readCount++;
|
|
387
|
+
if (readCount > 10) {
|
|
388
|
+
return { done: true, value: undefined };
|
|
389
|
+
}
|
|
390
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
391
|
+
return { done: false, value: new TextEncoder().encode('{"id":1}\n') };
|
|
392
|
+
}),
|
|
393
|
+
releaseLock: jest.fn(),
|
|
394
|
+
};
|
|
395
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
396
|
+
ok: true,
|
|
397
|
+
body: { getReader: () => mockReader },
|
|
398
|
+
});
|
|
399
|
+
const manager = new StreamableManager({
|
|
400
|
+
url: 'http://test.com/stream',
|
|
401
|
+
onData: () => { },
|
|
402
|
+
});
|
|
403
|
+
// Start and immediately stop
|
|
404
|
+
const startPromise = manager.start();
|
|
405
|
+
setTimeout(() => manager.stop(), 25);
|
|
406
|
+
await startPromise;
|
|
407
|
+
// Should have stopped before reading all 10
|
|
408
|
+
expect(readCount).toBeLessThan(10);
|
|
409
|
+
});
|
|
410
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for streamable HTTP client
|
|
3
|
+
*
|
|
4
|
+
* Tests NDJSON streaming against the real API.
|
|
5
|
+
* Run with: INFERENCE_API_KEY=xxx npm run test:integration
|
|
6
|
+
*
|
|
7
|
+
* Note: Format negotiation tests may be skipped if server doesn't support NDJSON yet.
|
|
8
|
+
*
|
|
9
|
+
* @jest-environment node
|
|
10
|
+
*/
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for streamable HTTP client
|
|
3
|
+
*
|
|
4
|
+
* Tests NDJSON streaming against the real API.
|
|
5
|
+
* Run with: INFERENCE_API_KEY=xxx npm run test:integration
|
|
6
|
+
*
|
|
7
|
+
* Note: Format negotiation tests may be skipped if server doesn't support NDJSON yet.
|
|
8
|
+
*
|
|
9
|
+
* @jest-environment node
|
|
10
|
+
*/
|
|
11
|
+
import { streamable, StreamableManager } from './http/streamable';
|
|
12
|
+
import { Inference, TaskStatusCompleted, TaskStatusFailed, TaskStatusCancelled } from './index';
|
|
13
|
+
const isTerminalStatus = (status) => status === TaskStatusCompleted || status === TaskStatusFailed || status === TaskStatusCancelled;
|
|
14
|
+
const API_KEY = process.env.INFERENCE_API_KEY;
|
|
15
|
+
const BASE_URL = process.env.INFERENCE_BASE_URL || 'https://api.inference.sh';
|
|
16
|
+
// Simple test app that completes quickly
|
|
17
|
+
const TEST_APP = 'infsh/text-templating@53bk0yzk';
|
|
18
|
+
const describeIfApiKey = API_KEY ? describe : describe.skip;
|
|
19
|
+
describeIfApiKey('Streamable Integration Tests', () => {
|
|
20
|
+
let client;
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
client = new Inference({
|
|
23
|
+
apiKey: API_KEY,
|
|
24
|
+
baseUrl: BASE_URL,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe('Format Negotiation', () => {
|
|
28
|
+
it('should negotiate format based on Accept header', async () => {
|
|
29
|
+
// Create a task using the SDK
|
|
30
|
+
const task = await client.run({ app: TEST_APP, input: { template: 'Format {1}!', strings: ['Test'] } }, { wait: false });
|
|
31
|
+
// Test NDJSON request
|
|
32
|
+
const ndjsonRes = await fetch(`${BASE_URL}/tasks/${task.id}/stream`, {
|
|
33
|
+
headers: {
|
|
34
|
+
'Authorization': `Bearer ${API_KEY}`,
|
|
35
|
+
'Accept': 'application/x-ndjson',
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
expect(ndjsonRes.ok).toBe(true);
|
|
39
|
+
const ndjsonContentType = ndjsonRes.headers.get('content-type');
|
|
40
|
+
// Test SSE request
|
|
41
|
+
const sseRes = await fetch(`${BASE_URL}/tasks/${task.id}/stream`, {
|
|
42
|
+
headers: {
|
|
43
|
+
'Authorization': `Bearer ${API_KEY}`,
|
|
44
|
+
'Accept': 'text/event-stream',
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
expect(sseRes.ok).toBe(true);
|
|
48
|
+
const sseContentType = sseRes.headers.get('content-type');
|
|
49
|
+
// Log what we got for debugging
|
|
50
|
+
console.log('NDJSON request Content-Type:', ndjsonContentType);
|
|
51
|
+
console.log('SSE request Content-Type:', sseContentType);
|
|
52
|
+
// At minimum, SSE should work
|
|
53
|
+
expect(sseContentType).toContain('text/event-stream');
|
|
54
|
+
// If NDJSON is supported, it should return NDJSON content-type
|
|
55
|
+
// Otherwise it falls back to SSE (server not yet updated)
|
|
56
|
+
const supportsNDJSON = ndjsonContentType?.includes('application/x-ndjson');
|
|
57
|
+
if (supportsNDJSON) {
|
|
58
|
+
console.log('✓ Server supports NDJSON format negotiation');
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
console.log('⚠ Server does not yet support NDJSON, falling back to SSE');
|
|
62
|
+
}
|
|
63
|
+
// Clean up - consume the streams
|
|
64
|
+
for (const res of [ndjsonRes, sseRes]) {
|
|
65
|
+
const reader = res.body?.getReader();
|
|
66
|
+
if (reader) {
|
|
67
|
+
while (true) {
|
|
68
|
+
const { done } = await reader.read();
|
|
69
|
+
if (done)
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}, 60000);
|
|
75
|
+
});
|
|
76
|
+
describe('streamable() with NDJSON server', () => {
|
|
77
|
+
// These tests require the server to support NDJSON
|
|
78
|
+
// Skip if server doesn't support it yet
|
|
79
|
+
it('should stream task updates when server supports NDJSON', async () => {
|
|
80
|
+
// Create a task using the SDK (fire and forget)
|
|
81
|
+
const task = await client.run({ app: TEST_APP, input: { template: 'Hello {1}!', strings: ['Streamable'] } }, { wait: false });
|
|
82
|
+
expect(task.id).toBeDefined();
|
|
83
|
+
// Stream updates using streamable - will request NDJSON format
|
|
84
|
+
const updates = [];
|
|
85
|
+
try {
|
|
86
|
+
for await (const update of streamable(`${BASE_URL}/tasks/${task.id}/stream`, {
|
|
87
|
+
headers: { 'Authorization': `Bearer ${API_KEY}` },
|
|
88
|
+
})) {
|
|
89
|
+
updates.push(update);
|
|
90
|
+
// Stop when task reaches terminal status
|
|
91
|
+
if (isTerminalStatus(update.status))
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
// If server doesn't support NDJSON, streamable will fail to parse SSE
|
|
97
|
+
console.log('⚠ Skipping - server returned non-NDJSON:', err.message);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
expect(updates.length).toBeGreaterThan(0);
|
|
101
|
+
// Last update should be terminal
|
|
102
|
+
const lastUpdate = updates[updates.length - 1];
|
|
103
|
+
expect(isTerminalStatus(lastUpdate.status)).toBe(true);
|
|
104
|
+
}, 60000);
|
|
105
|
+
});
|
|
106
|
+
describe('StreamableManager with NDJSON server', () => {
|
|
107
|
+
it('should receive updates via callbacks when server supports NDJSON', async () => {
|
|
108
|
+
// Create a task using the SDK
|
|
109
|
+
const task = await client.run({ app: TEST_APP, input: { template: 'Manager {1}!', strings: ['Test'] } }, { wait: false });
|
|
110
|
+
const updates = [];
|
|
111
|
+
let started = false;
|
|
112
|
+
let ended = false;
|
|
113
|
+
let error = null;
|
|
114
|
+
const manager = new StreamableManager({
|
|
115
|
+
url: `${BASE_URL}/tasks/${task.id}/stream`,
|
|
116
|
+
headers: { 'Authorization': `Bearer ${API_KEY}` },
|
|
117
|
+
onStart: () => { started = true; },
|
|
118
|
+
onEnd: () => { ended = true; },
|
|
119
|
+
onData: (data) => {
|
|
120
|
+
updates.push(data);
|
|
121
|
+
// Stop when terminal
|
|
122
|
+
if (isTerminalStatus(data.status)) {
|
|
123
|
+
manager.stop();
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
onError: (err) => {
|
|
127
|
+
error = err;
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
await manager.start();
|
|
131
|
+
// If server doesn't support NDJSON, we'll get a parse error
|
|
132
|
+
if (error) {
|
|
133
|
+
console.log('⚠ Skipping StreamableManager test - server returned non-NDJSON');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
expect(started).toBe(true);
|
|
137
|
+
expect(ended).toBe(true);
|
|
138
|
+
expect(updates.length).toBeGreaterThan(0);
|
|
139
|
+
}, 60000);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
// Ensure Jest doesn't complain when API key is not set
|
|
143
|
+
describe('Streamable Integration Setup', () => {
|
|
144
|
+
it('checks for API key', () => {
|
|
145
|
+
if (!API_KEY) {
|
|
146
|
+
console.log('⚠️ Skipping streamable integration tests - INFERENCE_API_KEY not set');
|
|
147
|
+
}
|
|
148
|
+
expect(true).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
});
|