@unboundcx/sdk 4.0.7 → 4.0.9

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/base.js CHANGED
@@ -297,6 +297,17 @@ export class BaseSDK {
297
297
  return true;
298
298
  }
299
299
 
300
+ // Streaming bodies (Node Readable or Web ReadableStream) — caller is
301
+ // expected to set an explicit content-type header alongside.
302
+ if (
303
+ typeof body === 'object' &&
304
+ body !== null &&
305
+ (typeof body.pipe === 'function' ||
306
+ typeof body.getReader === 'function')
307
+ ) {
308
+ return true;
309
+ }
310
+
300
311
  return false;
301
312
  }
302
313
 
@@ -353,9 +364,20 @@ export class BaseSDK {
353
364
  typeof Buffer !== 'undefined' &&
354
365
  Buffer.isBuffer &&
355
366
  Buffer.isBuffer(body);
367
+ const isStream =
368
+ body &&
369
+ typeof body === 'object' &&
370
+ (typeof body.pipe === 'function' ||
371
+ typeof body.getReader === 'function');
356
372
 
357
373
  if (isFormData || isBuffer) {
358
374
  options.body = body;
375
+ } else if (isStream) {
376
+ // Node 18+ native fetch (undici) requires duplex: 'half' for
377
+ // streaming request bodies. Without this the fetch throws
378
+ // synchronously before the first byte is sent.
379
+ options.body = body;
380
+ options.duplex = 'half';
359
381
  } else {
360
382
  options.body = JSON.stringify(body);
361
383
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboundcx/sdk",
3
- "version": "4.0.7",
3
+ "version": "4.0.9",
4
4
  "description": "Official JavaScript SDK for the Unbound API - A comprehensive toolkit for integrating with Unbound's communication, AI, and data management services",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -87,6 +87,69 @@ export class StorageService {
87
87
  return commonTypes[ext] || 'application/octet-stream';
88
88
  }
89
89
 
90
+ // Private helper to detect if a value is a Node Readable or Web ReadableStream
91
+ _isStreamLike(value) {
92
+ if (!value || typeof value !== 'object') return false;
93
+ if (typeof value.pipe === 'function') return true; // Node Readable
94
+ if (typeof value.getReader === 'function') return true; // Web ReadableStream
95
+ if (typeof value[Symbol.asyncIterator] === 'function') return true;
96
+ return false;
97
+ }
98
+
99
+ // Private helper to create a streaming multipart body for Node.js.
100
+ // Returns { body: ReadableStream, headers } for fetch() with duplex: 'half'.
101
+ // Byte-identical framing to _createNodeFormData — emitted in chunks so the full
102
+ // file never has to sit in memory.
103
+ _createNodeFormDataStream(fileStream, fileName, formFields) {
104
+ const boundary = `----formdata-${Date.now()}-${Math.random()
105
+ .toString(36)
106
+ .slice(2)}`;
107
+ const CRLF = '\r\n';
108
+ const contentType = this._getContentType(fileName);
109
+
110
+ const header =
111
+ `--${boundary}${CRLF}` +
112
+ `Content-Disposition: form-data; name="files"; filename="${
113
+ fileName || 'file'
114
+ }"${CRLF}` +
115
+ `Content-Type: ${contentType}${CRLF}${CRLF}`;
116
+
117
+ let tail = '';
118
+ for (const [name, value] of formFields) {
119
+ tail += `${CRLF}--${boundary}${CRLF}Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}${value}`;
120
+ }
121
+ tail += `${CRLF}--${boundary}--${CRLF}`;
122
+
123
+ const body = new ReadableStream({
124
+ async start(controller) {
125
+ try {
126
+ controller.enqueue(Buffer.from(header, 'utf8'));
127
+ for await (const chunk of fileStream) {
128
+ controller.enqueue(
129
+ Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
130
+ );
131
+ }
132
+ controller.enqueue(Buffer.from(tail, 'utf8'));
133
+ controller.close();
134
+ } catch (err) {
135
+ controller.error(err);
136
+ }
137
+ },
138
+ cancel(reason) {
139
+ if (fileStream && typeof fileStream.destroy === 'function') {
140
+ fileStream.destroy(reason);
141
+ }
142
+ },
143
+ });
144
+
145
+ return {
146
+ body,
147
+ headers: {
148
+ 'content-type': `multipart/form-data; boundary=${boundary}`,
149
+ },
150
+ };
151
+ }
152
+
90
153
  // Private helper to create FormData for Node.js environment
91
154
  _createNodeFormData(file, fileName, formFields) {
92
155
  const boundary = `----formdata-${Date.now()}-${Math.random().toString(36)}`;
@@ -234,7 +297,13 @@ export class StorageService {
234
297
  // Default behavior: Use fetch via sdk._fetch
235
298
  let formData, headers;
236
299
 
237
- if (isNode) {
300
+ if (isNode && this._isStreamLike(file)) {
301
+ // Streaming path — file is piped chunk-by-chunk, no full-buffer copy.
302
+ // Caller is responsible for creating a fresh stream per retry.
303
+ const result = this._createNodeFormDataStream(file, fileName, formFields);
304
+ formData = result.body;
305
+ headers = result.headers;
306
+ } else if (isNode) {
238
307
  const result = this._createNodeFormData(file, fileName, formFields);
239
308
  formData = result.formData;
240
309
  headers = result.headers;
@@ -381,7 +450,7 @@ Response:
381
450
  /**
382
451
  * Upload a file to storage with optional format conversion
383
452
  * @param {Object} config - Configuration object
384
- * @param {Object} config.file - File content (Buffer or File) - REQUIRED
453
+ * @param {Object} config.file - File content: Buffer, File, Node Readable stream, or Web ReadableStream. Streams upload chunk-by-chunk (no full-buffer copy) — recommended for files > ~100 MB. For retries, create a fresh stream per attempt. REQUIRED
385
454
  * @param {string} [config.classification='generic'] - File classification (e.g., 'fax', 'files', 'generic')
386
455
  * @param {string} [config.folder] - Folder path for organizing files
387
456
  * @param {string} [config.fileName] - Original file name