@doist/todoist-sdk 14.0.2 → 14.2.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.
Files changed (32) hide show
  1. package/README.md +46 -1
  2. package/dist/cjs/clients/view-options-client.js +25 -0
  3. package/dist/cjs/index.js +2 -1
  4. package/dist/cjs/todoist-api.js +30 -0
  5. package/dist/cjs/transport/http-dispatcher.js +25 -0
  6. package/dist/cjs/types/sync/resources/view-options.js +13 -4
  7. package/dist/cjs/utils/index.js +2 -0
  8. package/dist/cjs/utils/multipart-upload.js +171 -57
  9. package/dist/cjs/utils/task-sorting.js +281 -0
  10. package/dist/cjs/utils/view-options.js +19 -0
  11. package/dist/esm/clients/view-options-client.js +21 -0
  12. package/dist/esm/index.js +1 -1
  13. package/dist/esm/todoist-api.js +30 -0
  14. package/dist/esm/transport/http-dispatcher.js +25 -0
  15. package/dist/esm/types/sync/resources/view-options.js +12 -3
  16. package/dist/esm/utils/index.js +2 -0
  17. package/dist/esm/utils/multipart-upload.js +171 -57
  18. package/dist/esm/utils/task-sorting.js +277 -0
  19. package/dist/esm/utils/view-options.js +15 -0
  20. package/dist/types/clients/view-options-client.d.ts +8 -0
  21. package/dist/types/index.d.ts +2 -1
  22. package/dist/types/todoist-api.d.ts +24 -1
  23. package/dist/types/transport/http-dispatcher.d.ts +26 -1
  24. package/dist/types/types/sync/commands/project-view-options.d.ts +1 -4
  25. package/dist/types/types/sync/commands/view-options.d.ts +2 -1
  26. package/dist/types/types/sync/resources/view-options.d.ts +37 -7
  27. package/dist/types/types/sync/response.d.ts +24 -5
  28. package/dist/types/utils/index.d.ts +2 -0
  29. package/dist/types/utils/task-sorting.d.ts +38 -0
  30. package/dist/types/utils/validators.d.ts +24 -6
  31. package/dist/types/utils/view-options.d.ts +11 -0
  32. package/package.json +2 -3
package/README.md CHANGED
@@ -125,12 +125,57 @@ const refreshed = await refreshAuthToken({
125
125
  > refresh. For public clients (`tokenEndpointAuthMethod: 'none'`), omit
126
126
  > `clientSecret` when calling `refreshAuthToken`.
127
127
 
128
+ ### Keeping Proxy Support and Response Decompression
129
+
130
+ On Node, the SDK's default transport reads the proxy environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`) and decodes compressed response bodies. Supplying a `customFetch` replaces that transport, so a custom implementation that still wants both can borrow it with `getDefaultTransport`:
131
+
132
+ ```typescript
133
+ import { type CustomFetch, getDefaultTransport, TodoistApi } from '@doist/todoist-sdk'
134
+
135
+ const customFetch: CustomFetch = async (url, options) => {
136
+ const transport = await getDefaultTransport()
137
+ // undici's `fetch` and the global one are the same call at runtime but
138
+ // carry different types, so settle on one signature.
139
+ const fetchImpl = (transport?.fetch ?? fetch) as typeof fetch
140
+
141
+ const response = await fetchImpl(url, {
142
+ ...options,
143
+ headers: {
144
+ ...(options?.headers as Record<string, string> | undefined),
145
+ 'x-my-header': 'value',
146
+ },
147
+ // `dispatcher` is a Node fetch option that the DOM types don't declare.
148
+ dispatcher: transport?.dispatcher,
149
+ } as RequestInit)
150
+
151
+ // `CustomFetchResponse` wants plain-object headers, not a `Headers`.
152
+ const headers: Record<string, string> = {}
153
+ response.headers.forEach((value, key) => {
154
+ headers[key] = value
155
+ })
156
+
157
+ return {
158
+ ok: response.ok,
159
+ status: response.status,
160
+ statusText: response.statusText,
161
+ headers,
162
+ text: () => response.text(),
163
+ json: () => response.json(),
164
+ arrayBuffer: () => response.arrayBuffer(),
165
+ }
166
+ }
167
+
168
+ const api = new TodoistApi('YOURTOKEN', { customFetch })
169
+ ```
170
+
171
+ `getDefaultTransport` returns the dispatcher and the `fetch` it must be used with as a single value, and both halves have to be used together. The dispatcher decompresses the response body itself, so pairing it with a `fetch` from a different undici build — including the runtime's global `fetch` on Node 26 — decodes the body twice and the request fails mid-stream with `terminated`. A `fetch` of `undefined` means the global `fetch` is the correct partner; the whole value is `undefined` outside Node, where no dispatcher applies.
172
+
128
173
  ### Important Notes
129
174
 
130
175
  - All existing transforms (snake_case ↔ camelCase) work automatically with custom fetch
131
176
  - Retry logic and error handling are preserved
132
177
  - File uploads work with custom fetch implementations
133
- - The custom fetch function should handle FormData for multipart uploads
178
+ - File upload bodies are a `Blob`, or a `ReadableStream` when uploading from a stream — a custom fetch must pass them through unchanged
134
179
  - Timeout parameter is optional and up to your custom implementation
135
180
 
136
181
  ## Development and Testing
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ViewOptionsClient = void 0;
4
+ const sync_request_1 = require("../transport/sync-request");
5
+ const sync_helpers_1 = require("../utils/sync-helpers");
6
+ const view_options_1 = require("../utils/view-options");
7
+ const base_client_1 = require("./base-client");
8
+ /** Internal sub-client for saved view-option reads and writes. */
9
+ class ViewOptionsClient extends base_client_1.BaseClient {
10
+ async getViewOptions() {
11
+ var _a;
12
+ const response = await (0, sync_request_1.performSyncRequest)(this.syncContext, {
13
+ resourceTypes: ['view_options'],
14
+ syncToken: '*',
15
+ });
16
+ return ((_a = response.viewOptions) !== null && _a !== void 0 ? _a : []).filter(view_options_1.isActiveViewOption);
17
+ }
18
+ async setViewOptions(args, requestId) {
19
+ await (0, sync_request_1.performSyncRequest)(this.syncContext, { commands: [(0, sync_helpers_1.createCommand)('view_options_set', args)] }, { requestId, hasSyncCommands: true });
20
+ }
21
+ async deleteViewOptions(args, requestId) {
22
+ await (0, sync_request_1.performSyncRequest)(this.syncContext, { commands: [(0, sync_helpers_1.createCommand)('view_options_delete', args)] }, { requestId, hasSyncCommands: true });
23
+ }
24
+ }
25
+ exports.ViewOptionsClient = ViewOptionsClient;
package/dist/cjs/index.js CHANGED
@@ -14,10 +14,11 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.getDefaultDispatcher = void 0;
17
+ exports.getDefaultTransport = exports.getDefaultDispatcher = void 0;
18
18
  __exportStar(require("./todoist-api"), exports);
19
19
  __exportStar(require("./authentication"), exports);
20
20
  __exportStar(require("./types"), exports);
21
21
  __exportStar(require("./utils"), exports);
22
22
  var http_dispatcher_1 = require("./transport/http-dispatcher");
23
23
  Object.defineProperty(exports, "getDefaultDispatcher", { enumerable: true, get: function () { return http_dispatcher_1.getDefaultDispatcher; } });
24
+ Object.defineProperty(exports, "getDefaultTransport", { enumerable: true, get: function () { return http_dispatcher_1.getDefaultTransport; } });
@@ -19,6 +19,7 @@ const task_client_1 = require("./clients/task-client");
19
19
  const template_client_1 = require("./clients/template-client");
20
20
  const ui_extension_client_1 = require("./clients/ui-extension-client");
21
21
  const upload_client_1 = require("./clients/upload-client");
22
+ const view_options_client_1 = require("./clients/view-options-client");
22
23
  const workspace_client_1 = require("./clients/workspace-client");
23
24
  const endpoints_1 = require("./consts/endpoints");
24
25
  const http_client_1 = require("./transport/http-client");
@@ -65,6 +66,7 @@ class TodoistApi {
65
66
  this.billingClient = new billing_client_1.BillingClient(clientDeps);
66
67
  this.appClient = new app_client_1.AppClient(clientDeps);
67
68
  this.uiExtensionClient = new ui_extension_client_1.UiExtensionClient(clientDeps);
69
+ this.viewOptionsClient = new view_options_client_1.ViewOptionsClient(clientDeps);
68
70
  }
69
71
  /**
70
72
  * Executes a raw Sync API request.
@@ -98,6 +100,34 @@ class TodoistApi {
98
100
  customFetch: this.customFetch,
99
101
  }, syncRequest, { requestId, hasSyncCommands: Boolean((_a = syncRequest.commands) === null || _a === void 0 ? void 0 : _a.length) });
100
102
  }
103
+ /**
104
+ * Retrieves the authenticated user's active saved view options.
105
+ *
106
+ * @returns A promise that resolves to the active saved view options.
107
+ */
108
+ async getViewOptions() {
109
+ return this.viewOptionsClient.getViewOptions();
110
+ }
111
+ /**
112
+ * Sets saved options for a view.
113
+ *
114
+ * @param args - The saved view options to set.
115
+ * @param requestId - Optional custom identifier for the request.
116
+ * @returns A promise that resolves when the view options are saved.
117
+ */
118
+ async setViewOptions(args, requestId) {
119
+ return this.viewOptionsClient.setViewOptions(args, requestId);
120
+ }
121
+ /**
122
+ * Deletes the saved options for a view.
123
+ *
124
+ * @param args - The view whose saved options to delete.
125
+ * @param requestId - Optional custom identifier for the request.
126
+ * @returns A promise that resolves when the view options are deleted.
127
+ */
128
+ async deleteViewOptions(args, requestId) {
129
+ return this.viewOptionsClient.deleteViewOptions(args, requestId);
130
+ }
101
131
  /**
102
132
  * Retrieves information about the authenticated user.
103
133
  *
@@ -50,6 +50,23 @@ let defaultTransportPromise;
50
50
  * The default dispatcher and its paired `fetch`, as a single value so the two
51
51
  * are always read consistently. Resolves to `undefined` outside Node (browser/
52
52
  * edge), where callers use the global `fetch` with no dispatcher.
53
+ *
54
+ * Use this when supplying a `customFetch` that should still honour the proxy
55
+ * environment variables (`HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`) and decode
56
+ * compressed responses the way the SDK does by default.
57
+ *
58
+ * Both halves must be used together. `dispatcher` decompresses the response
59
+ * body itself, so a `fetch` from a different undici build will try to decode
60
+ * it a second time and the request fails mid-stream with `terminated`. A
61
+ * `fetch` of `undefined` means the runtime's global `fetch` is the correct
62
+ * partner for this dispatcher.
63
+ *
64
+ * @example
65
+ * ```ts
66
+ * const transport = await getDefaultTransport()
67
+ * const fetchImpl = transport?.fetch ?? fetch
68
+ * const response = await fetchImpl(url, { ...options, dispatcher: transport?.dispatcher })
69
+ * ```
53
70
  */
54
71
  function getDefaultTransport() {
55
72
  if (!isNodeEnvironment()) {
@@ -69,6 +86,14 @@ function getDefaultTransport() {
69
86
  }
70
87
  return defaultTransportPromise;
71
88
  }
89
+ /**
90
+ * The default dispatcher on its own.
91
+ *
92
+ * @deprecated Use {@link getDefaultTransport} instead, which returns this
93
+ * dispatcher together with the `fetch` it must be used with. Attaching this
94
+ * dispatcher to the runtime's global `fetch` decodes compressed responses
95
+ * twice on Node 26 and the request fails with `terminated`.
96
+ */
72
97
  async function getDefaultDispatcher() {
73
98
  var _a;
74
99
  return (_a = (await getDefaultTransport())) === null || _a === void 0 ? void 0 : _a.dispatcher;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ProjectViewOptionsDefaultsSchema = exports.ViewOptionsSchema = exports.CalendarSettingsSchema = exports.CALENDAR_LAYOUTS = exports.SortOrderSchema = exports.SORT_ORDERS = exports.SortedBySchema = exports.SORTED_BY_OPTIONS = exports.GroupedBySchema = exports.GROUPED_BY_OPTIONS = exports.ViewModeSchema = exports.VIEW_MODES = exports.ViewTypeSchema = exports.VIEW_TYPES = void 0;
3
+ exports.ProjectViewOptionsDefaultsSchema = exports.ViewOptionsSchema = exports.CalendarSettingsSchema = exports.CALENDAR_COLORS = exports.CALENDAR_LAYOUTS = exports.SortOrderSchema = exports.SORT_ORDERS = exports.SortedBySchema = exports.SORTED_BY_OPTIONS = exports.GroupedBySchema = exports.GROUPED_BY_OPTIONS = exports.ViewModeSchema = exports.VIEW_MODES = exports.ViewTypeSchema = exports.VIEW_TYPES = void 0;
4
4
  const zod_1 = require("zod");
5
5
  /** Available view types. */
6
6
  exports.VIEW_TYPES = [
@@ -52,18 +52,27 @@ exports.SORT_ORDERS = ['ASC', 'DESC'];
52
52
  exports.SortOrderSchema = zod_1.z.enum(exports.SORT_ORDERS).nullable();
53
53
  /** Available calendar layout modes. */
54
54
  exports.CALENDAR_LAYOUTS = ['WEEK', 'MONTH'];
55
+ /** Available task color sources for calendar views. */
56
+ exports.CALENDAR_COLORS = ['PRIORITY', 'LABEL', 'PROJECT'];
55
57
  exports.CalendarSettingsSchema = zod_1.z.looseObject({
56
- layout: zod_1.z.enum(exports.CALENDAR_LAYOUTS).optional(),
58
+ layout: zod_1.z.enum(exports.CALENDAR_LAYOUTS).nullable().optional(),
59
+ visibleDayCount: zod_1.z
60
+ .union([zod_1.z.literal(1), zod_1.z.literal(3), zod_1.z.literal(7)])
61
+ .nullable()
62
+ .optional(),
63
+ color: zod_1.z.enum(exports.CALENDAR_COLORS).nullable().optional(),
57
64
  });
58
65
  exports.ViewOptionsSchema = zod_1.z.looseObject({
59
66
  viewType: exports.ViewTypeSchema,
60
- objectId: zod_1.z.string().optional(),
67
+ objectId: zod_1.z.string().nullable().optional(),
61
68
  groupedBy: exports.GroupedBySchema.optional(),
62
69
  filteredBy: zod_1.z.string().nullable().optional(),
63
- viewMode: exports.ViewModeSchema.optional(),
70
+ viewMode: exports.ViewModeSchema.nullable().optional(),
64
71
  showCompletedTasks: zod_1.z.boolean().optional(),
65
72
  sortedBy: exports.SortedBySchema.optional(),
66
73
  sortOrder: exports.SortOrderSchema.optional(),
74
+ calendarSettings: exports.CalendarSettingsSchema.nullable().optional(),
75
+ isDeleted: zod_1.z.boolean().optional(),
67
76
  });
68
77
  exports.ProjectViewOptionsDefaultsSchema = zod_1.z.looseObject({
69
78
  projectId: zod_1.z.string(),
@@ -24,6 +24,8 @@ Object.defineProperty(exports, "getSectionUrl", { enumerable: true, get: functio
24
24
  var sync_helpers_1 = require("./sync-helpers");
25
25
  Object.defineProperty(exports, "createCommand", { enumerable: true, get: function () { return sync_helpers_1.createCommand; } });
26
26
  __exportStar(require("./project-helpers"), exports);
27
+ __exportStar(require("./task-sorting"), exports);
28
+ __exportStar(require("./view-options"), exports);
27
29
  var webhook_parser_1 = require("./webhook-parser");
28
30
  Object.defineProperty(exports, "parseWebhookPayload", { enumerable: true, get: function () { return webhook_parser_1.parseWebhookPayload; } });
29
31
  Object.defineProperty(exports, "verifyWebhookSignature", { enumerable: true, get: function () { return webhook_parser_1.verifyWebhookSignature; } });
@@ -32,8 +32,29 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __await = (this && this.__await) || function (v) { return this instanceof __await ? (this.v = v, this) : new __await(v); }
36
+ var __asyncValues = (this && this.__asyncValues) || function (o) {
37
+ if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
38
+ var m = o[Symbol.asyncIterator], i;
39
+ return m ? m.call(o) : (o = typeof __values === "function" ? __values(o) : o[Symbol.iterator](), i = {}, verb("next"), verb("throw"), verb("return"), i[Symbol.asyncIterator] = function () { return this; }, i);
40
+ function verb(n) { i[n] = o[n] && function (v) { return new Promise(function (resolve, reject) { v = o[n](v), settle(resolve, reject, v.done, v.value); }); }; }
41
+ function settle(resolve, reject, d, v) { Promise.resolve(v).then(function(v) { resolve({ value: v, done: d }); }, reject); }
42
+ };
43
+ var __asyncGenerator = (this && this.__asyncGenerator) || function (thisArg, _arguments, generator) {
44
+ if (!Symbol.asyncIterator) throw new TypeError("Symbol.asyncIterator is not defined.");
45
+ var g = generator.apply(thisArg, _arguments || []), i, q = [];
46
+ return i = Object.create((typeof AsyncIterator === "function" ? AsyncIterator : Object).prototype), verb("next"), verb("throw"), verb("return", awaitReturn), i[Symbol.asyncIterator] = function () { return this; }, i;
47
+ function awaitReturn(f) { return function (v) { return Promise.resolve(v).then(f, reject); }; }
48
+ function verb(n, f) { if (g[n]) { i[n] = function (v) { return new Promise(function (a, b) { q.push([n, v, a, b]) > 1 || resume(n, v); }); }; if (f) i[n] = f(i[n]); } }
49
+ function resume(n, v) { try { step(g[n](v)); } catch (e) { settle(q[0][3], e); } }
50
+ function step(r) { r.value instanceof __await ? Promise.resolve(r.value.v).then(fulfill, reject) : settle(q[0][2], r); }
51
+ function fulfill(value) { resume("next", value); }
52
+ function reject(value) { resume("throw", value); }
53
+ function settle(f, v) { if (f(v), q.shift(), q.length) resume(q[0][0], q[0][1]); }
54
+ };
35
55
  Object.defineProperty(exports, "__esModule", { value: true });
36
56
  exports.uploadMultipartFile = uploadMultipartFile;
57
+ const uuid_1 = require("uuid");
37
58
  const fetch_with_retry_1 = require("../transport/fetch-with-retry");
38
59
  const case_conversion_1 = require("./case-conversion");
39
60
  /**
@@ -59,6 +80,116 @@ function getContentTypeFromFileName(fileName) {
59
80
  return 'application/octet-stream';
60
81
  }
61
82
  }
83
+ /**
84
+ * Escapes a value for use inside a quoted `Content-Disposition` parameter,
85
+ * where a raw quote would end the value early and a raw CR or LF would end the
86
+ * header. Note that a lone CR needs escaping too, not just CRLF pairs.
87
+ *
88
+ * This is the same escaping the platform's own `FormData` encoder applies, so
89
+ * servers see exactly what they would from any browser or from undici.
90
+ */
91
+ function escapeDispositionValue(value) {
92
+ return value.replace(/"/g, '%22').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
93
+ }
94
+ /**
95
+ * Lays out a `multipart/form-data` payload around a single file part, as the
96
+ * text that goes before and after the file content. Keeping the file content
97
+ * out of it lets the same layout serve both a `Blob` body and a streamed one.
98
+ */
99
+ function layOutMultipart(args) {
100
+ const { fileName, fileContentType, additionalFields } = args;
101
+ const boundary = `----todoist-sdk-${(0, uuid_1.v4)()}`;
102
+ const head = `--${boundary}\r\n` +
103
+ `Content-Disposition: form-data; name="file"; filename="${escapeDispositionValue(fileName)}"\r\n` +
104
+ `Content-Type: ${fileContentType}\r\n\r\n`;
105
+ let tail = '\r\n';
106
+ for (const [key, value] of Object.entries(additionalFields)) {
107
+ if (value !== undefined && value !== null) {
108
+ tail +=
109
+ `--${boundary}\r\n` +
110
+ `Content-Disposition: form-data; name="${escapeDispositionValue(key)}"\r\n\r\n` +
111
+ `${value.toString()}\r\n`;
112
+ }
113
+ }
114
+ tail += `--${boundary}--\r\n`;
115
+ return { boundary, head, tail };
116
+ }
117
+ /**
118
+ * Wraps a Node readable stream as a `multipart/form-data` request body.
119
+ *
120
+ * The result is a web `ReadableStream`, which both the runtime's `fetch` and
121
+ * undici's own accept — unlike a Node stream or a `FormData`, either of which
122
+ * only works with one particular client. The file is never buffered, so the
123
+ * request is sent with `Transfer-Encoding: chunked`.
124
+ */
125
+ function streamMultipartBody(file, parts) {
126
+ const encoder = new TextEncoder();
127
+ function chunks() {
128
+ return __asyncGenerator(this, arguments, function* chunks_1() {
129
+ var _a, e_1, _b, _c;
130
+ yield yield __await(encoder.encode(parts.head));
131
+ try {
132
+ for (var _d = true, file_1 = __asyncValues(file), file_1_1; file_1_1 = yield __await(file_1.next()), _a = file_1_1.done, !_a; _d = true) {
133
+ _c = file_1_1.value;
134
+ _d = false;
135
+ const chunk = _c;
136
+ yield yield __await(typeof chunk === 'string' ? encoder.encode(chunk) : new Uint8Array(chunk));
137
+ }
138
+ }
139
+ catch (e_1_1) { e_1 = { error: e_1_1 }; }
140
+ finally {
141
+ try {
142
+ if (!_d && !_a && (_b = file_1.return)) yield __await(_b.call(file_1));
143
+ }
144
+ finally { if (e_1) throw e_1.error; }
145
+ }
146
+ yield yield __await(encoder.encode(parts.tail));
147
+ });
148
+ }
149
+ const iterator = chunks();
150
+ // Driven by hand rather than with `ReadableStream.from`, which is absent
151
+ // from this project's DOM lib types and is still missing in some browsers,
152
+ // or `Readable.toWeb`, which would drag a Node-only import into a module
153
+ // that browsers also load.
154
+ return new ReadableStream({
155
+ async pull(controller) {
156
+ const { value, done } = await iterator.next();
157
+ if (done) {
158
+ controller.close();
159
+ return;
160
+ }
161
+ controller.enqueue(value);
162
+ },
163
+ async cancel(reason) {
164
+ var _a;
165
+ await ((_a = iterator.return) === null || _a === void 0 ? void 0 : _a.call(iterator, reason));
166
+ },
167
+ });
168
+ }
169
+ /**
170
+ * Builds the request body for a multipart upload.
171
+ *
172
+ * Deliberately avoids `FormData`. A `FormData` body is only encoded by the
173
+ * `fetch` that owns that `FormData` class — undici brands its own — so a body
174
+ * built from the global `FormData` and sent through undici's `fetch`, or the
175
+ * reverse, is stringified to the literal `"[object FormData]"` and the upload
176
+ * silently carries no file. The `form-data` package fares worse: neither
177
+ * `fetch` accepts it as a body at all. `Blob` and `ReadableStream` carry no
178
+ * such brand and work with whichever `fetch` dispatches the request,
179
+ * including a caller-supplied `customFetch`.
180
+ */
181
+ function buildMultipartBody(args) {
182
+ const { file, fileName, fileContentType, additionalFields } = args;
183
+ const parts = layOutMultipart({ fileName, fileContentType, additionalFields });
184
+ const contentType = `multipart/form-data; boundary=${parts.boundary}`;
185
+ if (file instanceof Blob) {
186
+ // A Blob keeps its length, so the request gets a Content-Length and
187
+ // stays replayable if it has to be retried. File-backed Blobs (from
188
+ // `fs.openAsBlob`) stay lazy here, so nothing is read into memory.
189
+ return { body: new Blob([parts.head, file, parts.tail]), contentType, isStreamed: false };
190
+ }
191
+ return { body: streamMultipartBody(file, parts), contentType, isStreamed: true };
192
+ }
62
193
  /**
63
194
  * Uploads a file using multipart/form-data.
64
195
  *
@@ -111,69 +242,52 @@ async function uploadMultipartFile(args) {
111
242
  if (requestId) {
112
243
  headers['X-Request-Id'] = requestId;
113
244
  }
245
+ // Resolve the file to something every `fetch` accepts as a body part: a
246
+ // Blob where the size is known up front, or a Node stream that gets piped
247
+ // through untouched.
248
+ let filePart;
249
+ let resolvedFileName;
114
250
  if (file instanceof Blob) {
115
- // Browser path: use native FormData
116
- const form = new globalThis.FormData();
117
- const resolvedFileName = fileName || (file instanceof File ? file.name : undefined) || 'upload';
118
- form.append('file', file, resolvedFileName);
119
- for (const [key, value] of Object.entries(additionalFields)) {
120
- if (value !== undefined && value !== null) {
121
- form.append(key, value.toString());
122
- }
251
+ filePart = file;
252
+ resolvedFileName = fileName || (file instanceof File ? file.name : undefined) || 'upload';
253
+ }
254
+ else if (typeof file === 'string') {
255
+ // File path: Node-only, so import lazily to keep browser bundles clean.
256
+ const [fsModule, pathModule] = await Promise.all([Promise.resolve().then(() => __importStar(require('fs'))), Promise.resolve().then(() => __importStar(require('path')))]);
257
+ resolvedFileName = fileName || pathModule.basename(file);
258
+ // `openAsBlob` reads the file lazily, so a large upload is not pulled
259
+ // into memory, and the resulting body still has a known length.
260
+ filePart = await fsModule.openAsBlob(file);
261
+ }
262
+ else if (Buffer.isBuffer(file)) {
263
+ if (!fileName) {
264
+ throw new Error('fileName is required when uploading from a Buffer');
123
265
  }
124
- // Don't set Content-Type — let fetch set it with the correct multipart boundary
125
- body = form;
266
+ resolvedFileName = fileName;
267
+ filePart = new Blob([
268
+ new Uint8Array(file.buffer, file.byteOffset, file.byteLength),
269
+ ]);
126
270
  }
127
271
  else {
128
- // Node path: dynamically import Node-only modules
129
- const [FormDataModule, fsModule, pathModule] = await Promise.all([
130
- Promise.resolve().then(() => __importStar(require('form-data'))),
131
- Promise.resolve().then(() => __importStar(require('fs'))),
132
- Promise.resolve().then(() => __importStar(require('path'))),
133
- ]);
134
- const FormData = FormDataModule.default;
135
- const form = new FormData();
136
- if (typeof file === 'string') {
137
- // File path - create read stream
138
- const resolvedFileName = fileName || pathModule.basename(file);
139
- form.append('file', fsModule.createReadStream(file), resolvedFileName);
140
- }
141
- else if (Buffer.isBuffer(file)) {
142
- // Buffer - require fileName
143
- if (!fileName) {
144
- throw new Error('fileName is required when uploading from a Buffer');
145
- }
146
- const contentType = getContentTypeFromFileName(fileName);
147
- form.append('file', file, {
148
- filename: fileName,
149
- contentType: contentType,
150
- });
151
- }
152
- else {
153
- // Stream - require fileName
154
- if (!fileName) {
155
- throw new Error('fileName is required when uploading from a stream');
156
- }
157
- form.append('file', file, fileName);
272
+ if (!fileName) {
273
+ throw new Error('fileName is required when uploading from a stream');
158
274
  }
159
- for (const [key, value] of Object.entries(additionalFields)) {
160
- if (value !== undefined && value !== null) {
161
- form.append(key, value.toString());
162
- }
163
- }
164
- Object.assign(headers, form.getHeaders());
165
- body = form;
275
+ resolvedFileName = fileName;
276
+ filePart = file;
166
277
  }
167
- // Make the request using fetch
168
- const response = await (0, fetch_with_retry_1.fetchWithRetry)({
169
- url,
170
- options: {
171
- method: 'POST',
172
- body,
173
- headers,
174
- timeout: 30000, // 30 second timeout for file uploads
175
- },
176
- customFetch,
278
+ const multipart = buildMultipartBody({
279
+ file: filePart,
280
+ fileName: resolvedFileName,
281
+ fileContentType: (filePart instanceof Blob ? filePart.type : undefined) ||
282
+ getContentTypeFromFileName(resolvedFileName),
283
+ additionalFields,
177
284
  });
285
+ // The boundary is ours, so Content-Type has to be set explicitly — `fetch`
286
+ // only fills it in for a `FormData` body.
287
+ headers['Content-Type'] = multipart.contentType;
288
+ body = multipart.body;
289
+ // Make the request using fetch
290
+ const response = await (0, fetch_with_retry_1.fetchWithRetry)(Object.assign(Object.assign({ url, options: Object.assign({ method: 'POST', body,
291
+ headers, timeout: 30000 }, (multipart.isStreamed ? { duplex: 'half' } : {})) }, (multipart.isStreamed ? { retryConfig: { retries: 0 } } : {})), { customFetch }));
178
292
  return (0, case_conversion_1.camelCaseKeys)(response.data);
179
293
  }