@doist/todoist-sdk 10.4.1 → 10.5.1

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/README.md CHANGED
@@ -104,6 +104,27 @@ await revokeToken(args, {
104
104
  const { accessToken } = await getAuthToken(args, baseUrl)
105
105
  ```
106
106
 
107
+ When the authorization server issues a `refreshToken`, use `refreshAuthToken`
108
+ to obtain a new access token once the current one expires, without sending the
109
+ user back through the authorization flow:
110
+
111
+ ```typescript
112
+ const refreshed = await refreshAuthToken({
113
+ clientId: 'your-client-id',
114
+ clientSecret: 'your-client-secret',
115
+ refreshToken: storedRefreshToken,
116
+ })
117
+
118
+ // The server may rotate the refresh token — persist refreshed.refreshToken
119
+ // when present, otherwise keep using the previous one.
120
+ ```
121
+
122
+ > **Note:** Refresh tokens are only issued to OAuth apps that have them enabled
123
+ > (the default for new apps). Apps without them receive a long-lived access
124
+ > token, so `getAuthToken` returns no `refreshToken` and there is nothing to
125
+ > refresh. For public clients (`tokenEndpointAuthMethod: 'none'`), omit
126
+ > `clientSecret` when calling `refreshAuthToken`.
127
+
107
128
  ### Important Notes
108
129
 
109
130
  - All existing transforms (snake_case ↔ camelCase) work automatically with custom fetch
@@ -15,6 +15,7 @@ exports.TOKEN_ENDPOINT_AUTH_METHODS = exports.PERMISSIONS = void 0;
15
15
  exports.getAuthStateParameter = getAuthStateParameter;
16
16
  exports.getAuthorizationUrl = getAuthorizationUrl;
17
17
  exports.getAuthToken = getAuthToken;
18
+ exports.refreshAuthToken = refreshAuthToken;
18
19
  exports.revokeToken = revokeToken;
19
20
  exports.migratePersonalToken = migratePersonalToken;
20
21
  exports.registerClient = registerClient;
@@ -108,32 +109,74 @@ function getAuthorizationUrl({ clientId, permissions, state, baseUrl, }) {
108
109
  * @returns The access token response
109
110
  * @throws {@link TodoistRequestError} If the token exchange fails
110
111
  */
111
- async function getAuthToken(args, options) {
112
+ /**
113
+ * Posts a payload to the OAuth token endpoint and returns the token response,
114
+ * mapping any failure to a {@link TodoistRequestError} with `errorMessage`.
115
+ * Shared by {@link getAuthToken} and {@link refreshAuthToken}.
116
+ */
117
+ async function requestAuthToken(payload, errorMessage, options) {
112
118
  var _a;
113
- if (typeof options === 'string') {
114
- throw new TypeError('Passing baseUrl as a string is no longer supported. Use an options object instead: getAuthToken(args, { baseUrl })');
115
- }
116
- const baseUrl = options === null || options === void 0 ? void 0 : options.baseUrl;
117
- const customFetch = options === null || options === void 0 ? void 0 : options.customFetch;
118
119
  try {
119
120
  const response = await (0, http_client_1.request)({
120
121
  httpMethod: 'POST',
121
- baseUri: (0, endpoints_1.getAuthBaseUri)(baseUrl),
122
+ baseUri: (0, endpoints_1.getAuthBaseUri)(options === null || options === void 0 ? void 0 : options.baseUrl),
122
123
  relativePath: endpoints_1.ENDPOINT_GET_TOKEN,
123
124
  apiToken: undefined,
124
- payload: args,
125
- customFetch,
125
+ payload,
126
+ customFetch: options === null || options === void 0 ? void 0 : options.customFetch,
126
127
  });
127
128
  if (response.status !== 200 || !((_a = response.data) === null || _a === void 0 ? void 0 : _a.accessToken)) {
128
- throw new types_1.TodoistRequestError('Authentication token exchange failed.', response.status, response.data);
129
+ throw new types_1.TodoistRequestError(errorMessage, response.status, response.data);
129
130
  }
130
131
  return response.data;
131
132
  }
132
133
  catch (error) {
133
134
  // Re-throw with custom message for authentication failures
134
135
  const err = error;
135
- throw new types_1.TodoistRequestError('Authentication token exchange failed.', err.httpStatusCode, err.responseData);
136
+ throw new types_1.TodoistRequestError(errorMessage, err.httpStatusCode, err.responseData);
137
+ }
138
+ }
139
+ async function getAuthToken(args, options) {
140
+ if (typeof options === 'string') {
141
+ throw new TypeError('Passing baseUrl as a string is no longer supported. Use an options object instead: getAuthToken(args, { baseUrl })');
142
+ }
143
+ return requestAuthToken(Object.assign({}, args), 'Authentication token exchange failed.', options);
144
+ }
145
+ /**
146
+ * Exchanges a refresh token for a new access token using the OAuth2
147
+ * `refresh_token` grant.
148
+ *
149
+ * Only relevant for OAuth apps that have refresh tokens enabled (the default for
150
+ * new apps). Apps without them never receive a `refreshToken` from
151
+ * {@link getAuthToken}, so they have nothing to pass here — their access tokens
152
+ * are long-lived instead.
153
+ *
154
+ * Omit `clientSecret` for public clients registered with
155
+ * `tokenEndpointAuthMethod: 'none'`.
156
+ *
157
+ * @example
158
+ * ```typescript
159
+ * const { accessToken, refreshToken } = await refreshAuthToken({
160
+ * clientId: 'your-client-id',
161
+ * clientSecret: 'your-client-secret',
162
+ * refreshToken: storedRefreshToken,
163
+ * })
164
+ * ```
165
+ *
166
+ * @returns The refreshed token. The server rotates the refresh token, so the
167
+ * response carries a new `refreshToken` — persist it in place of the old one.
168
+ * @throws {@link TodoistRequestError} If the refresh fails
169
+ */
170
+ async function refreshAuthToken(args, options) {
171
+ const payload = {
172
+ clientId: args.clientId,
173
+ refreshToken: args.refreshToken,
174
+ grantType: 'refresh_token',
175
+ };
176
+ if (args.clientSecret !== undefined) {
177
+ payload.clientSecret = args.clientSecret;
136
178
  }
179
+ return requestAuthToken(payload, 'Authentication token refresh failed.', options);
137
180
  }
138
181
  /**
139
182
  * Revokes a token using the RFC 7009 OAuth 2.0 Token Revocation standard.
@@ -0,0 +1,17 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const vitest_1 = require("vitest");
4
+ // In production the transport uses undici's own `fetch` (paired with the
5
+ // dispatcher) so the request client and dispatcher stay on one undici version.
6
+ // MSW intercepts the *global* `fetch`, not that separate undici instance, so
7
+ // force the transport onto the global `fetch` for every test in the suite; MSW
8
+ // then intercepts requests as usual. The real undici transport is covered
9
+ // directly by `http-dispatcher.test.ts` and `fetch-with-retry.test.ts`, which
10
+ // opt out of this seam with `vi.unmock('./http-dispatcher')`.
11
+ //
12
+ // This lives in its own setup file (not `msw-setup.ts`) because a hoisted
13
+ // `vi.mock` in a module that also re-exports values corrupts those exports.
14
+ vitest_1.vi.mock('../transport/http-dispatcher', async (importOriginal) => {
15
+ const actual = await importOriginal();
16
+ return Object.assign(Object.assign({}, actual), { getDefaultTransport: vitest_1.vi.fn(async () => undefined) });
17
+ });
@@ -99,6 +99,7 @@ function convertResponseToCustomFetch(response) {
99
99
  * Performs a fetch request with retry logic and timeout support
100
100
  */
101
101
  async function fetchWithRetry(args) {
102
+ var _a;
102
103
  const { url, options = {}, retryConfig = {}, customFetch } = args;
103
104
  const config = Object.assign(Object.assign({}, DEFAULT_RETRY_CONFIG), retryConfig);
104
105
  const { timeout, signal: userSignal } = options, fetchOptions = __rest(options, ["timeout", "signal"]);
@@ -120,13 +121,24 @@ async function fetchWithRetry(args) {
120
121
  fetchResponse = await customFetch(url, Object.assign(Object.assign({}, fetchOptions), { signal: requestSignal, timeout }));
121
122
  }
122
123
  else {
123
- const dispatcher = await (0, http_dispatcher_1.getDefaultDispatcher)();
124
+ // Read the dispatcher and its paired `fetch` as one value so
125
+ // they can never be mismatched. On Node, `fetch` is undici's
126
+ // own — paired with the dispatcher — so the request client and
127
+ // dispatcher stay on one undici version; otherwise the
128
+ // `decompress` interceptor terminates gzip responses when the
129
+ // global `fetch`'s bundled undici differs. Browser/edge (and
130
+ // Bun) have no paired `fetch` and fall back to the global one.
131
+ const transport = await (0, http_dispatcher_1.getDefaultTransport)();
124
132
  const nativeFetchOptions = Object.assign(Object.assign({}, fetchOptions), { signal: requestSignal });
125
- if (dispatcher !== undefined) {
133
+ if ((transport === null || transport === void 0 ? void 0 : transport.dispatcher) !== undefined) {
126
134
  // @ts-expect-error - dispatcher is a valid option for Node.js fetch but not in the TS types
127
- nativeFetchOptions.dispatcher = dispatcher;
135
+ nativeFetchOptions.dispatcher = transport.dispatcher;
128
136
  }
129
- const nativeResponse = await fetch(url, nativeFetchOptions);
137
+ // undici's `fetch` and the global `fetch` are the same function
138
+ // at runtime but carry different (undici vs DOM) types. Call
139
+ // through the global signature, which matches the options above.
140
+ const fetchImpl = ((_a = transport === null || transport === void 0 ? void 0 : transport.fetch) !== null && _a !== void 0 ? _a : fetch);
141
+ const nativeResponse = await fetchImpl(url, nativeFetchOptions);
130
142
  fetchResponse = convertResponseToCustomFetch(nativeResponse);
131
143
  }
132
144
  // Check if the response is successful
@@ -148,13 +160,13 @@ async function fetchWithRetry(args) {
148
160
  try {
149
161
  responseData = responseText ? JSON.parse(responseText) : undefined;
150
162
  }
151
- catch (_a) {
163
+ catch (_b) {
152
164
  responseData = responseText;
153
165
  }
154
166
  error.data = responseData;
155
167
  error.response.data = responseData;
156
168
  }
157
- catch (_b) {
169
+ catch (_c) {
158
170
  // If we can't read the response body, that's OK
159
171
  }
160
172
  throw error;
@@ -165,7 +177,7 @@ async function fetchWithRetry(args) {
165
177
  try {
166
178
  data = responseText ? JSON.parse(responseText) : undefined;
167
179
  }
168
- catch (_c) {
180
+ catch (_d) {
169
181
  // If JSON parsing fails, return the raw text
170
182
  data = responseText;
171
183
  }
@@ -33,7 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getDefaultTransport = getDefaultTransport;
36
37
  exports.getDefaultDispatcher = getDefaultDispatcher;
38
+ exports.getDefaultFetch = getDefaultFetch;
37
39
  exports.resetDefaultDispatcherForTests = resetDefaultDispatcherForTests;
38
40
  exports.suppressExperimentalWarningsSync = suppressExperimentalWarningsSync;
39
41
  // Use effectively-disabled keep-alive so short-lived CLI processes do not stay
@@ -42,32 +44,68 @@ const KEEP_ALIVE_OPTIONS = {
42
44
  keepAliveTimeout: 1,
43
45
  keepAliveMaxTimeout: 1,
44
46
  };
45
- let defaultDispatcherPromise;
46
- function getDefaultDispatcher() {
47
+ let defaultTransport;
48
+ let defaultTransportPromise;
49
+ /**
50
+ * The default dispatcher and its paired `fetch`, as a single value so the two
51
+ * are always read consistently. Resolves to `undefined` outside Node (browser/
52
+ * edge), where callers use the global `fetch` with no dispatcher.
53
+ */
54
+ function getDefaultTransport() {
47
55
  if (!isNodeEnvironment()) {
48
56
  return Promise.resolve(undefined);
49
57
  }
50
- if (!defaultDispatcherPromise) {
51
- defaultDispatcherPromise = createDefaultDispatcher().catch((error) => {
52
- defaultDispatcherPromise = undefined;
58
+ if (!defaultTransportPromise) {
59
+ defaultTransportPromise = createDefaultTransport()
60
+ .then((transport) => {
61
+ defaultTransport = transport;
62
+ return transport;
63
+ })
64
+ .catch((error) => {
65
+ defaultTransport = undefined;
66
+ defaultTransportPromise = undefined;
53
67
  throw error;
54
68
  });
55
69
  }
56
- return defaultDispatcherPromise;
70
+ return defaultTransportPromise;
71
+ }
72
+ async function getDefaultDispatcher() {
73
+ var _a;
74
+ return (_a = (await getDefaultTransport())) === null || _a === void 0 ? void 0 : _a.dispatcher;
75
+ }
76
+ /**
77
+ * The `fetch` implementation that must be used with the default dispatcher.
78
+ * Returns undici's own `fetch` on the full-undici Node path, or `undefined`
79
+ * (meaning: use the global `fetch`) in the browser/edge/Bun paths.
80
+ *
81
+ * Node's global `fetch` is backed by whatever undici version ships inside that
82
+ * Node release (6.x on Node 22 … 8.x on Node 26). Our dispatcher — and its
83
+ * `decompress` interceptor — comes from the npm `undici` package, which is a
84
+ * different version. Handing an npm-undici dispatcher to a mismatched built-in
85
+ * client makes gzip responses fail mid-stream with `terminated`. Sourcing
86
+ * `fetch` from the same npm `undici` keeps the whole request path on one
87
+ * version and removes the split.
88
+ *
89
+ * Only meaningful after {@link getDefaultTransport} has resolved, which is the
90
+ * one place that populates it.
91
+ */
92
+ function getDefaultFetch() {
93
+ return defaultTransport === null || defaultTransport === void 0 ? void 0 : defaultTransport.fetch;
57
94
  }
58
95
  async function resetDefaultDispatcherForTests() {
59
- if (!defaultDispatcherPromise) {
96
+ if (!defaultTransportPromise) {
60
97
  return;
61
98
  }
62
- const dispatcherPromise = defaultDispatcherPromise;
63
- defaultDispatcherPromise = undefined;
64
- await dispatcherPromise.then((dispatcher) => dispatcher.close(), () => undefined);
99
+ const transportPromise = defaultTransportPromise;
100
+ defaultTransport = undefined;
101
+ defaultTransportPromise = undefined;
102
+ await transportPromise.then((transport) => transport.dispatcher.close(), () => undefined);
65
103
  }
66
- async function createDefaultDispatcher() {
104
+ async function createDefaultTransport() {
67
105
  // Dynamic import so non-Node consumers (browser, edge runtimes) don't pull
68
- // undici into their bundle. `getDefaultDispatcher` already short-circuits
106
+ // undici into their bundle. `getDefaultTransport` already short-circuits
69
107
  // outside Node, so this branch only runs when undici is safe to load.
70
- const { EnvHttpProxyAgent, interceptors } = await Promise.resolve().then(() => __importStar(require('undici')));
108
+ const { EnvHttpProxyAgent, interceptors, fetch: undiciFetch } = await Promise.resolve().then(() => __importStar(require('undici')));
71
109
  const agent = new EnvHttpProxyAgent(KEEP_ALIVE_OPTIONS);
72
110
  // Some runtimes report `process.versions.node` (so `isNodeEnvironment()`
73
111
  // passes) but ship only a partial undici: `interceptors.decompress` is
@@ -76,7 +114,9 @@ async function createDefaultDispatcher() {
76
114
  // gzip/deflate/br/zstd natively — so skip the interceptor instead of
77
115
  // crashing on the missing API.
78
116
  if (typeof interceptors.decompress !== 'function') {
79
- return agent;
117
+ // Bun: pair the agent with the global `fetch` (undefined), which
118
+ // decompresses natively.
119
+ return { dispatcher: agent, fetch: undefined };
80
120
  }
81
121
  // Compose the response-decompression interceptor so gzip/deflate/br/zstd
82
122
  // bodies are decoded before consumers parse them. Required on Node 24+:
@@ -85,7 +125,12 @@ async function createDefaultDispatcher() {
85
125
  // so callers receive raw gzipped bytes and `JSON.parse` fails.
86
126
  // See https://github.com/Doist/todoist-cli/issues/318.
87
127
  const decompress = suppressExperimentalWarningsSync(() => interceptors.decompress());
88
- return agent.compose(decompress);
128
+ // Pair undici's own `fetch` with this dispatcher so the request client and
129
+ // the dispatcher stay on one undici version (see `getDefaultFetch`). The
130
+ // global `fetch` is backed by a different, Node-bundled undici; mixing the
131
+ // two makes the decompress interceptor terminate gzip responses on some
132
+ // Node versions.
133
+ return { dispatcher: agent.compose(decompress), fetch: undiciFetch };
89
134
  }
90
135
  // undici emits an `ExperimentalWarning` the first time `interceptors.decompress()`
91
136
  // runs. The interceptor is stable for our gzipped-JSON-over-HTTPS use case;
@@ -99,32 +99,74 @@ export function getAuthorizationUrl({ clientId, permissions, state, baseUrl, })
99
99
  * @returns The access token response
100
100
  * @throws {@link TodoistRequestError} If the token exchange fails
101
101
  */
102
- export async function getAuthToken(args, options) {
102
+ /**
103
+ * Posts a payload to the OAuth token endpoint and returns the token response,
104
+ * mapping any failure to a {@link TodoistRequestError} with `errorMessage`.
105
+ * Shared by {@link getAuthToken} and {@link refreshAuthToken}.
106
+ */
107
+ async function requestAuthToken(payload, errorMessage, options) {
103
108
  var _a;
104
- if (typeof options === 'string') {
105
- throw new TypeError('Passing baseUrl as a string is no longer supported. Use an options object instead: getAuthToken(args, { baseUrl })');
106
- }
107
- const baseUrl = options === null || options === void 0 ? void 0 : options.baseUrl;
108
- const customFetch = options === null || options === void 0 ? void 0 : options.customFetch;
109
109
  try {
110
110
  const response = await request({
111
111
  httpMethod: 'POST',
112
- baseUri: getAuthBaseUri(baseUrl),
112
+ baseUri: getAuthBaseUri(options === null || options === void 0 ? void 0 : options.baseUrl),
113
113
  relativePath: ENDPOINT_GET_TOKEN,
114
114
  apiToken: undefined,
115
- payload: args,
116
- customFetch,
115
+ payload,
116
+ customFetch: options === null || options === void 0 ? void 0 : options.customFetch,
117
117
  });
118
118
  if (response.status !== 200 || !((_a = response.data) === null || _a === void 0 ? void 0 : _a.accessToken)) {
119
- throw new TodoistRequestError('Authentication token exchange failed.', response.status, response.data);
119
+ throw new TodoistRequestError(errorMessage, response.status, response.data);
120
120
  }
121
121
  return response.data;
122
122
  }
123
123
  catch (error) {
124
124
  // Re-throw with custom message for authentication failures
125
125
  const err = error;
126
- throw new TodoistRequestError('Authentication token exchange failed.', err.httpStatusCode, err.responseData);
126
+ throw new TodoistRequestError(errorMessage, err.httpStatusCode, err.responseData);
127
+ }
128
+ }
129
+ export async function getAuthToken(args, options) {
130
+ if (typeof options === 'string') {
131
+ throw new TypeError('Passing baseUrl as a string is no longer supported. Use an options object instead: getAuthToken(args, { baseUrl })');
132
+ }
133
+ return requestAuthToken(Object.assign({}, args), 'Authentication token exchange failed.', options);
134
+ }
135
+ /**
136
+ * Exchanges a refresh token for a new access token using the OAuth2
137
+ * `refresh_token` grant.
138
+ *
139
+ * Only relevant for OAuth apps that have refresh tokens enabled (the default for
140
+ * new apps). Apps without them never receive a `refreshToken` from
141
+ * {@link getAuthToken}, so they have nothing to pass here — their access tokens
142
+ * are long-lived instead.
143
+ *
144
+ * Omit `clientSecret` for public clients registered with
145
+ * `tokenEndpointAuthMethod: 'none'`.
146
+ *
147
+ * @example
148
+ * ```typescript
149
+ * const { accessToken, refreshToken } = await refreshAuthToken({
150
+ * clientId: 'your-client-id',
151
+ * clientSecret: 'your-client-secret',
152
+ * refreshToken: storedRefreshToken,
153
+ * })
154
+ * ```
155
+ *
156
+ * @returns The refreshed token. The server rotates the refresh token, so the
157
+ * response carries a new `refreshToken` — persist it in place of the old one.
158
+ * @throws {@link TodoistRequestError} If the refresh fails
159
+ */
160
+ export async function refreshAuthToken(args, options) {
161
+ const payload = {
162
+ clientId: args.clientId,
163
+ refreshToken: args.refreshToken,
164
+ grantType: 'refresh_token',
165
+ };
166
+ if (args.clientSecret !== undefined) {
167
+ payload.clientSecret = args.clientSecret;
127
168
  }
169
+ return requestAuthToken(payload, 'Authentication token refresh failed.', options);
128
170
  }
129
171
  /**
130
172
  * Revokes a token using the RFC 7009 OAuth 2.0 Token Revocation standard.
@@ -0,0 +1,15 @@
1
+ import { vi } from 'vitest';
2
+ // In production the transport uses undici's own `fetch` (paired with the
3
+ // dispatcher) so the request client and dispatcher stay on one undici version.
4
+ // MSW intercepts the *global* `fetch`, not that separate undici instance, so
5
+ // force the transport onto the global `fetch` for every test in the suite; MSW
6
+ // then intercepts requests as usual. The real undici transport is covered
7
+ // directly by `http-dispatcher.test.ts` and `fetch-with-retry.test.ts`, which
8
+ // opt out of this seam with `vi.unmock('./http-dispatcher')`.
9
+ //
10
+ // This lives in its own setup file (not `msw-setup.ts`) because a hoisted
11
+ // `vi.mock` in a module that also re-exports values corrupts those exports.
12
+ vi.mock('../transport/http-dispatcher', async (importOriginal) => {
13
+ const actual = await importOriginal();
14
+ return Object.assign(Object.assign({}, actual), { getDefaultTransport: vi.fn(async () => undefined) });
15
+ });
@@ -10,7 +10,7 @@ var __rest = (this && this.__rest) || function (s, e) {
10
10
  return t;
11
11
  };
12
12
  import { isNetworkError } from '../types/http.js';
13
- import { getDefaultDispatcher } from './http-dispatcher.js';
13
+ import { getDefaultTransport } from './http-dispatcher.js';
14
14
  /**
15
15
  * Default retry configuration matching the original axios-retry behavior
16
16
  */
@@ -96,6 +96,7 @@ function convertResponseToCustomFetch(response) {
96
96
  * Performs a fetch request with retry logic and timeout support
97
97
  */
98
98
  export async function fetchWithRetry(args) {
99
+ var _a;
99
100
  const { url, options = {}, retryConfig = {}, customFetch } = args;
100
101
  const config = Object.assign(Object.assign({}, DEFAULT_RETRY_CONFIG), retryConfig);
101
102
  const { timeout, signal: userSignal } = options, fetchOptions = __rest(options, ["timeout", "signal"]);
@@ -117,13 +118,24 @@ export async function fetchWithRetry(args) {
117
118
  fetchResponse = await customFetch(url, Object.assign(Object.assign({}, fetchOptions), { signal: requestSignal, timeout }));
118
119
  }
119
120
  else {
120
- const dispatcher = await getDefaultDispatcher();
121
+ // Read the dispatcher and its paired `fetch` as one value so
122
+ // they can never be mismatched. On Node, `fetch` is undici's
123
+ // own — paired with the dispatcher — so the request client and
124
+ // dispatcher stay on one undici version; otherwise the
125
+ // `decompress` interceptor terminates gzip responses when the
126
+ // global `fetch`'s bundled undici differs. Browser/edge (and
127
+ // Bun) have no paired `fetch` and fall back to the global one.
128
+ const transport = await getDefaultTransport();
121
129
  const nativeFetchOptions = Object.assign(Object.assign({}, fetchOptions), { signal: requestSignal });
122
- if (dispatcher !== undefined) {
130
+ if ((transport === null || transport === void 0 ? void 0 : transport.dispatcher) !== undefined) {
123
131
  // @ts-expect-error - dispatcher is a valid option for Node.js fetch but not in the TS types
124
- nativeFetchOptions.dispatcher = dispatcher;
132
+ nativeFetchOptions.dispatcher = transport.dispatcher;
125
133
  }
126
- const nativeResponse = await fetch(url, nativeFetchOptions);
134
+ // undici's `fetch` and the global `fetch` are the same function
135
+ // at runtime but carry different (undici vs DOM) types. Call
136
+ // through the global signature, which matches the options above.
137
+ const fetchImpl = ((_a = transport === null || transport === void 0 ? void 0 : transport.fetch) !== null && _a !== void 0 ? _a : fetch);
138
+ const nativeResponse = await fetchImpl(url, nativeFetchOptions);
127
139
  fetchResponse = convertResponseToCustomFetch(nativeResponse);
128
140
  }
129
141
  // Check if the response is successful
@@ -145,13 +157,13 @@ export async function fetchWithRetry(args) {
145
157
  try {
146
158
  responseData = responseText ? JSON.parse(responseText) : undefined;
147
159
  }
148
- catch (_a) {
160
+ catch (_b) {
149
161
  responseData = responseText;
150
162
  }
151
163
  error.data = responseData;
152
164
  error.response.data = responseData;
153
165
  }
154
- catch (_b) {
166
+ catch (_c) {
155
167
  // If we can't read the response body, that's OK
156
168
  }
157
169
  throw error;
@@ -162,7 +174,7 @@ export async function fetchWithRetry(args) {
162
174
  try {
163
175
  data = responseText ? JSON.parse(responseText) : undefined;
164
176
  }
165
- catch (_c) {
177
+ catch (_d) {
166
178
  // If JSON parsing fails, return the raw text
167
179
  data = responseText;
168
180
  }
@@ -4,32 +4,68 @@ const KEEP_ALIVE_OPTIONS = {
4
4
  keepAliveTimeout: 1,
5
5
  keepAliveMaxTimeout: 1,
6
6
  };
7
- let defaultDispatcherPromise;
8
- export function getDefaultDispatcher() {
7
+ let defaultTransport;
8
+ let defaultTransportPromise;
9
+ /**
10
+ * The default dispatcher and its paired `fetch`, as a single value so the two
11
+ * are always read consistently. Resolves to `undefined` outside Node (browser/
12
+ * edge), where callers use the global `fetch` with no dispatcher.
13
+ */
14
+ export function getDefaultTransport() {
9
15
  if (!isNodeEnvironment()) {
10
16
  return Promise.resolve(undefined);
11
17
  }
12
- if (!defaultDispatcherPromise) {
13
- defaultDispatcherPromise = createDefaultDispatcher().catch((error) => {
14
- defaultDispatcherPromise = undefined;
18
+ if (!defaultTransportPromise) {
19
+ defaultTransportPromise = createDefaultTransport()
20
+ .then((transport) => {
21
+ defaultTransport = transport;
22
+ return transport;
23
+ })
24
+ .catch((error) => {
25
+ defaultTransport = undefined;
26
+ defaultTransportPromise = undefined;
15
27
  throw error;
16
28
  });
17
29
  }
18
- return defaultDispatcherPromise;
30
+ return defaultTransportPromise;
31
+ }
32
+ export async function getDefaultDispatcher() {
33
+ var _a;
34
+ return (_a = (await getDefaultTransport())) === null || _a === void 0 ? void 0 : _a.dispatcher;
35
+ }
36
+ /**
37
+ * The `fetch` implementation that must be used with the default dispatcher.
38
+ * Returns undici's own `fetch` on the full-undici Node path, or `undefined`
39
+ * (meaning: use the global `fetch`) in the browser/edge/Bun paths.
40
+ *
41
+ * Node's global `fetch` is backed by whatever undici version ships inside that
42
+ * Node release (6.x on Node 22 … 8.x on Node 26). Our dispatcher — and its
43
+ * `decompress` interceptor — comes from the npm `undici` package, which is a
44
+ * different version. Handing an npm-undici dispatcher to a mismatched built-in
45
+ * client makes gzip responses fail mid-stream with `terminated`. Sourcing
46
+ * `fetch` from the same npm `undici` keeps the whole request path on one
47
+ * version and removes the split.
48
+ *
49
+ * Only meaningful after {@link getDefaultTransport} has resolved, which is the
50
+ * one place that populates it.
51
+ */
52
+ export function getDefaultFetch() {
53
+ return defaultTransport === null || defaultTransport === void 0 ? void 0 : defaultTransport.fetch;
19
54
  }
20
55
  export async function resetDefaultDispatcherForTests() {
21
- if (!defaultDispatcherPromise) {
56
+ if (!defaultTransportPromise) {
22
57
  return;
23
58
  }
24
- const dispatcherPromise = defaultDispatcherPromise;
25
- defaultDispatcherPromise = undefined;
26
- await dispatcherPromise.then((dispatcher) => dispatcher.close(), () => undefined);
59
+ const transportPromise = defaultTransportPromise;
60
+ defaultTransport = undefined;
61
+ defaultTransportPromise = undefined;
62
+ await transportPromise.then((transport) => transport.dispatcher.close(), () => undefined);
27
63
  }
28
- async function createDefaultDispatcher() {
64
+ async function createDefaultTransport() {
29
65
  // Dynamic import so non-Node consumers (browser, edge runtimes) don't pull
30
- // undici into their bundle. `getDefaultDispatcher` already short-circuits
66
+ // undici into their bundle. `getDefaultTransport` already short-circuits
31
67
  // outside Node, so this branch only runs when undici is safe to load.
32
- const { EnvHttpProxyAgent, interceptors } = await import('undici');
68
+ const { EnvHttpProxyAgent, interceptors, fetch: undiciFetch } = await import('undici');
33
69
  const agent = new EnvHttpProxyAgent(KEEP_ALIVE_OPTIONS);
34
70
  // Some runtimes report `process.versions.node` (so `isNodeEnvironment()`
35
71
  // passes) but ship only a partial undici: `interceptors.decompress` is
@@ -38,7 +74,9 @@ async function createDefaultDispatcher() {
38
74
  // gzip/deflate/br/zstd natively — so skip the interceptor instead of
39
75
  // crashing on the missing API.
40
76
  if (typeof interceptors.decompress !== 'function') {
41
- return agent;
77
+ // Bun: pair the agent with the global `fetch` (undefined), which
78
+ // decompresses natively.
79
+ return { dispatcher: agent, fetch: undefined };
42
80
  }
43
81
  // Compose the response-decompression interceptor so gzip/deflate/br/zstd
44
82
  // bodies are decoded before consumers parse them. Required on Node 24+:
@@ -47,7 +85,12 @@ async function createDefaultDispatcher() {
47
85
  // so callers receive raw gzipped bytes and `JSON.parse` fails.
48
86
  // See https://github.com/Doist/todoist-cli/issues/318.
49
87
  const decompress = suppressExperimentalWarningsSync(() => interceptors.decompress());
50
- return agent.compose(decompress);
88
+ // Pair undici's own `fetch` with this dispatcher so the request client and
89
+ // the dispatcher stay on one undici version (see `getDefaultFetch`). The
90
+ // global `fetch` is backed by a different, Node-bundled undici; mixing the
91
+ // two makes the decompress interceptor terminate gzip responses on some
92
+ // Node versions.
93
+ return { dispatcher: agent.compose(decompress), fetch: undiciFetch };
51
94
  }
52
95
  // undici emits an `ExperimentalWarning` the first time `interceptors.decompress()`
53
96
  // runs. The interceptor is stable for our gzipped-JSON-over-HTTPS use case;
@@ -74,6 +74,31 @@ export type AuthTokenRequestArgs = {
74
74
  export type AuthTokenResponse = {
75
75
  accessToken: string;
76
76
  tokenType: string;
77
+ /**
78
+ * Refresh token for use with {@link refreshAuthToken}.
79
+ *
80
+ * Only present when the OAuth app has refresh tokens enabled — the default
81
+ * for new apps, and immutable once on. Apps without it receive a long-lived
82
+ * access token and no refresh token.
83
+ */
84
+ refreshToken?: string;
85
+ /** Access token lifetime in seconds. */
86
+ expiresIn?: number;
87
+ /** Space-separated granted scopes, when returned. */
88
+ scope?: string;
89
+ };
90
+ /**
91
+ * Parameters required to exchange a refresh token for a new access token.
92
+ * @see https://developer.todoist.com/api/v1/#tag/Authorization/OAuth
93
+ */
94
+ export type RefreshTokenRequestArgs = {
95
+ clientId: string;
96
+ /**
97
+ * Client secret. Required for confidential clients; omit for public clients
98
+ * registered with `tokenEndpointAuthMethod: 'none'`.
99
+ */
100
+ clientSecret?: string;
101
+ refreshToken: string;
77
102
  };
78
103
  /**
79
104
  * Parameters required to revoke a token using RFC 7009 compliant endpoint.
@@ -148,22 +173,33 @@ export declare function getAuthorizationUrl({ clientId, permissions, state, base
148
173
  state: string;
149
174
  baseUrl?: string;
150
175
  }): string;
176
+ export declare function getAuthToken(args: AuthTokenRequestArgs, options?: AuthOptions): Promise<AuthTokenResponse>;
151
177
  /**
152
- * Exchanges an authorization code for an access token.
178
+ * Exchanges a refresh token for a new access token using the OAuth2
179
+ * `refresh_token` grant.
180
+ *
181
+ * Only relevant for OAuth apps that have refresh tokens enabled (the default for
182
+ * new apps). Apps without them never receive a `refreshToken` from
183
+ * {@link getAuthToken}, so they have nothing to pass here — their access tokens
184
+ * are long-lived instead.
185
+ *
186
+ * Omit `clientSecret` for public clients registered with
187
+ * `tokenEndpointAuthMethod: 'none'`.
153
188
  *
154
189
  * @example
155
190
  * ```typescript
156
- * const { accessToken } = await getAuthToken({
191
+ * const { accessToken, refreshToken } = await refreshAuthToken({
157
192
  * clientId: 'your-client-id',
158
193
  * clientSecret: 'your-client-secret',
159
- * code: authCode
194
+ * refreshToken: storedRefreshToken,
160
195
  * })
161
196
  * ```
162
197
  *
163
- * @returns The access token response
164
- * @throws {@link TodoistRequestError} If the token exchange fails
198
+ * @returns The refreshed token. The server rotates the refresh token, so the
199
+ * response carries a new `refreshToken` — persist it in place of the old one.
200
+ * @throws {@link TodoistRequestError} If the refresh fails
165
201
  */
166
- export declare function getAuthToken(args: AuthTokenRequestArgs, options?: AuthOptions): Promise<AuthTokenResponse>;
202
+ export declare function refreshAuthToken(args: RefreshTokenRequestArgs, options?: AuthOptions): Promise<AuthTokenResponse>;
167
203
  /**
168
204
  * Revokes a token using the RFC 7009 OAuth 2.0 Token Revocation standard.
169
205
  *
@@ -0,0 +1 @@
1
+ export {};
@@ -1,4 +1,40 @@
1
1
  import type { Dispatcher } from 'undici';
2
+ type UndiciFetch = typeof import('undici').fetch;
3
+ /**
4
+ * A dispatcher and the `fetch` that must be used with it. `fetch` is undici's
5
+ * own `fetch` on the full-undici Node path, or `undefined` (meaning: use the
6
+ * global `fetch`) on the Bun path. The two are cached together so callers can
7
+ * never observe a dispatcher paired with a mismatched `fetch` — see
8
+ * {@link getDefaultFetch} for why the pairing matters.
9
+ */
10
+ type DefaultTransport = {
11
+ dispatcher: Dispatcher;
12
+ fetch: UndiciFetch | undefined;
13
+ };
14
+ /**
15
+ * The default dispatcher and its paired `fetch`, as a single value so the two
16
+ * are always read consistently. Resolves to `undefined` outside Node (browser/
17
+ * edge), where callers use the global `fetch` with no dispatcher.
18
+ */
19
+ export declare function getDefaultTransport(): Promise<DefaultTransport | undefined>;
2
20
  export declare function getDefaultDispatcher(): Promise<Dispatcher | undefined>;
21
+ /**
22
+ * The `fetch` implementation that must be used with the default dispatcher.
23
+ * Returns undici's own `fetch` on the full-undici Node path, or `undefined`
24
+ * (meaning: use the global `fetch`) in the browser/edge/Bun paths.
25
+ *
26
+ * Node's global `fetch` is backed by whatever undici version ships inside that
27
+ * Node release (6.x on Node 22 … 8.x on Node 26). Our dispatcher — and its
28
+ * `decompress` interceptor — comes from the npm `undici` package, which is a
29
+ * different version. Handing an npm-undici dispatcher to a mismatched built-in
30
+ * client makes gzip responses fail mid-stream with `terminated`. Sourcing
31
+ * `fetch` from the same npm `undici` keeps the whole request path on one
32
+ * version and removes the split.
33
+ *
34
+ * Only meaningful after {@link getDefaultTransport} has resolved, which is the
35
+ * one place that populates it.
36
+ */
37
+ export declare function getDefaultFetch(): UndiciFetch | undefined;
3
38
  export declare function resetDefaultDispatcherForTests(): Promise<void>;
4
39
  export declare function suppressExperimentalWarningsSync<T>(fn: () => T): T;
40
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/todoist-sdk",
3
- "version": "10.4.1",
3
+ "version": "10.5.1",
4
4
  "description": "The official TypeScript SDK for the Todoist REST API.",
5
5
  "author": "Doist developers",
6
6
  "repository": "https://github.com/Doist/todoist-sdk-typescript",