@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 +21 -0
- package/dist/cjs/authentication.js +54 -11
- package/dist/cjs/test-utils/transport-seam.js +17 -0
- package/dist/cjs/transport/fetch-with-retry.js +19 -7
- package/dist/cjs/transport/http-dispatcher.js +60 -15
- package/dist/esm/authentication.js +53 -11
- package/dist/esm/test-utils/transport-seam.js +15 -0
- package/dist/esm/transport/fetch-with-retry.js +20 -8
- package/dist/esm/transport/http-dispatcher.js +58 -15
- package/dist/types/authentication.d.ts +42 -6
- package/dist/types/test-utils/transport-seam.d.ts +1 -0
- package/dist/types/transport/http-dispatcher.d.ts +36 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
163
|
+
catch (_b) {
|
|
152
164
|
responseData = responseText;
|
|
153
165
|
}
|
|
154
166
|
error.data = responseData;
|
|
155
167
|
error.response.data = responseData;
|
|
156
168
|
}
|
|
157
|
-
catch (
|
|
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 (
|
|
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
|
|
46
|
-
|
|
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 (!
|
|
51
|
-
|
|
52
|
-
|
|
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
|
|
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 (!
|
|
96
|
+
if (!defaultTransportPromise) {
|
|
60
97
|
return;
|
|
61
98
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
99
|
+
const transportPromise = defaultTransportPromise;
|
|
100
|
+
defaultTransport = undefined;
|
|
101
|
+
defaultTransportPromise = undefined;
|
|
102
|
+
await transportPromise.then((transport) => transport.dispatcher.close(), () => undefined);
|
|
65
103
|
}
|
|
66
|
-
async function
|
|
104
|
+
async function createDefaultTransport() {
|
|
67
105
|
// Dynamic import so non-Node consumers (browser, edge runtimes) don't pull
|
|
68
|
-
// undici into their bundle. `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
160
|
+
catch (_b) {
|
|
149
161
|
responseData = responseText;
|
|
150
162
|
}
|
|
151
163
|
error.data = responseData;
|
|
152
164
|
error.response.data = responseData;
|
|
153
165
|
}
|
|
154
|
-
catch (
|
|
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 (
|
|
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
|
|
8
|
-
|
|
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 (!
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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 (!
|
|
56
|
+
if (!defaultTransportPromise) {
|
|
22
57
|
return;
|
|
23
58
|
}
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
59
|
+
const transportPromise = defaultTransportPromise;
|
|
60
|
+
defaultTransport = undefined;
|
|
61
|
+
defaultTransportPromise = undefined;
|
|
62
|
+
await transportPromise.then((transport) => transport.dispatcher.close(), () => undefined);
|
|
27
63
|
}
|
|
28
|
-
async function
|
|
64
|
+
async function createDefaultTransport() {
|
|
29
65
|
// Dynamic import so non-Node consumers (browser, edge runtimes) don't pull
|
|
30
|
-
// undici into their bundle. `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
191
|
+
* const { accessToken, refreshToken } = await refreshAuthToken({
|
|
157
192
|
* clientId: 'your-client-id',
|
|
158
193
|
* clientSecret: 'your-client-secret',
|
|
159
|
-
*
|
|
194
|
+
* refreshToken: storedRefreshToken,
|
|
160
195
|
* })
|
|
161
196
|
* ```
|
|
162
197
|
*
|
|
163
|
-
* @returns The
|
|
164
|
-
*
|
|
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
|
|
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