@doist/comms-sdk 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/cjs/authentication.js +211 -0
- package/dist/cjs/clients/add-comment-helper.js +53 -0
- package/dist/cjs/clients/base-client.js +27 -0
- package/dist/cjs/clients/channels-client.js +83 -0
- package/dist/cjs/clients/comments-client.js +93 -0
- package/dist/cjs/clients/conversation-messages-client.js +87 -0
- package/dist/cjs/clients/conversations-client.js +103 -0
- package/dist/cjs/clients/groups-client.js +71 -0
- package/dist/cjs/clients/inbox-client.js +98 -0
- package/dist/cjs/clients/reactions-client.js +59 -0
- package/dist/cjs/clients/search-client.js +88 -0
- package/dist/cjs/clients/threads-client.js +135 -0
- package/dist/cjs/clients/users-client.js +199 -0
- package/dist/cjs/clients/workspace-users-client.js +140 -0
- package/dist/cjs/clients/workspaces-client.js +93 -0
- package/dist/cjs/comms-api.js +65 -0
- package/dist/cjs/consts/endpoints.js +27 -0
- package/dist/cjs/index.js +48 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/testUtils/msw-handlers.js +51 -0
- package/dist/cjs/testUtils/msw-setup.js +21 -0
- package/dist/cjs/testUtils/obsidian-fetch-adapter.js +53 -0
- package/dist/cjs/testUtils/test-defaults.js +102 -0
- package/dist/cjs/transport/fetch-with-retry.js +136 -0
- package/dist/cjs/transport/http-client.js +56 -0
- package/dist/cjs/transport/http-dispatcher.js +143 -0
- package/dist/cjs/types/entities.js +411 -0
- package/dist/cjs/types/enums.js +37 -0
- package/dist/cjs/types/errors.js +12 -0
- package/dist/cjs/types/http.js +4 -0
- package/dist/cjs/types/index.js +21 -0
- package/dist/cjs/types/requests.js +117 -0
- package/dist/cjs/utils/case-conversion.js +54 -0
- package/dist/cjs/utils/index.js +19 -0
- package/dist/cjs/utils/timestamp-conversion.js +49 -0
- package/dist/cjs/utils/url-helpers.js +131 -0
- package/dist/cjs/utils/uuidv7.js +174 -0
- package/dist/esm/authentication.js +203 -0
- package/dist/esm/clients/add-comment-helper.js +50 -0
- package/dist/esm/clients/base-client.js +23 -0
- package/dist/esm/clients/channels-client.js +79 -0
- package/dist/esm/clients/comments-client.js +89 -0
- package/dist/esm/clients/conversation-messages-client.js +83 -0
- package/dist/esm/clients/conversations-client.js +99 -0
- package/dist/esm/clients/groups-client.js +67 -0
- package/dist/esm/clients/inbox-client.js +94 -0
- package/dist/esm/clients/reactions-client.js +55 -0
- package/dist/esm/clients/search-client.js +84 -0
- package/dist/esm/clients/threads-client.js +131 -0
- package/dist/esm/clients/users-client.js +195 -0
- package/dist/esm/clients/workspace-users-client.js +136 -0
- package/dist/esm/clients/workspaces-client.js +89 -0
- package/dist/esm/comms-api.js +61 -0
- package/dist/esm/consts/endpoints.js +23 -0
- package/dist/esm/index.js +17 -0
- package/dist/esm/testUtils/msw-handlers.js +45 -0
- package/dist/esm/testUtils/msw-setup.js +18 -0
- package/dist/esm/testUtils/obsidian-fetch-adapter.js +50 -0
- package/dist/esm/testUtils/test-defaults.js +99 -0
- package/dist/esm/transport/fetch-with-retry.js +133 -0
- package/dist/esm/transport/http-client.js +51 -0
- package/dist/esm/transport/http-dispatcher.js +104 -0
- package/dist/esm/types/entities.js +408 -0
- package/dist/esm/types/enums.js +34 -0
- package/dist/esm/types/errors.js +8 -0
- package/dist/esm/types/http.js +1 -0
- package/dist/esm/types/index.js +5 -0
- package/dist/esm/types/requests.js +114 -0
- package/dist/esm/utils/case-conversion.js +47 -0
- package/dist/esm/utils/index.js +3 -0
- package/dist/esm/utils/timestamp-conversion.js +45 -0
- package/dist/esm/utils/url-helpers.js +112 -0
- package/dist/esm/utils/uuidv7.js +163 -0
- package/dist/types/authentication.d.ts +160 -0
- package/dist/types/clients/add-comment-helper.d.ts +12 -0
- package/dist/types/clients/base-client.d.ts +24 -0
- package/dist/types/clients/channels-client.d.ts +91 -0
- package/dist/types/clients/comments-client.d.ts +157 -0
- package/dist/types/clients/conversation-messages-client.d.ts +127 -0
- package/dist/types/clients/conversations-client.d.ts +206 -0
- package/dist/types/clients/groups-client.d.ts +55 -0
- package/dist/types/clients/inbox-client.d.ts +20 -0
- package/dist/types/clients/reactions-client.d.ts +19 -0
- package/dist/types/clients/search-client.d.ts +20 -0
- package/dist/types/clients/threads-client.d.ts +344 -0
- package/dist/types/clients/users-client.d.ts +123 -0
- package/dist/types/clients/workspace-users-client.d.ts +47 -0
- package/dist/types/clients/workspaces-client.d.ts +79 -0
- package/dist/types/comms-api.d.ts +59 -0
- package/dist/types/consts/endpoints.d.ts +19 -0
- package/dist/types/index.d.ts +18 -0
- package/dist/types/testUtils/msw-handlers.d.ts +28 -0
- package/dist/types/testUtils/msw-setup.d.ts +1 -0
- package/dist/types/testUtils/obsidian-fetch-adapter.d.ts +29 -0
- package/dist/types/testUtils/test-defaults.d.ts +16 -0
- package/dist/types/transport/fetch-with-retry.d.ts +4 -0
- package/dist/types/transport/http-client.d.ts +13 -0
- package/dist/types/transport/http-dispatcher.d.ts +10 -0
- package/dist/types/types/entities.d.ts +1288 -0
- package/dist/types/types/enums.d.ts +55 -0
- package/dist/types/types/errors.d.ts +6 -0
- package/dist/types/types/http.d.ts +54 -0
- package/dist/types/types/index.d.ts +5 -0
- package/dist/types/types/requests.d.ts +385 -0
- package/dist/types/utils/case-conversion.d.ts +8 -0
- package/dist/types/utils/index.d.ts +3 -0
- package/dist/types/utils/timestamp-conversion.d.ts +13 -0
- package/dist/types/utils/url-helpers.d.ts +88 -0
- package/dist/types/utils/uuidv7.d.ts +40 -0
- package/package.json +93 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { HttpResponse, http } from 'msw';
|
|
2
|
+
const BASE_URL = 'https://comms.todoist.com';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a successful API response with the given data
|
|
5
|
+
*/
|
|
6
|
+
export function createSuccessResponse(data) {
|
|
7
|
+
return HttpResponse.json(data, { status: 200 });
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Creates an error API response
|
|
11
|
+
*/
|
|
12
|
+
export function createErrorResponse(errorCode, errorMessage, status = 400) {
|
|
13
|
+
return HttpResponse.json({
|
|
14
|
+
error_code: errorCode,
|
|
15
|
+
error_string: errorMessage,
|
|
16
|
+
}, { status });
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Helper to create API endpoint URL
|
|
20
|
+
*/
|
|
21
|
+
export function apiUrl(path) {
|
|
22
|
+
// Remove leading slash if present
|
|
23
|
+
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
|
24
|
+
return `${BASE_URL}/${cleanPath}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Creates a handler for a GET endpoint
|
|
28
|
+
*/
|
|
29
|
+
export function createGetHandler(endpoint, responseData) {
|
|
30
|
+
return http.get(apiUrl(endpoint), () => {
|
|
31
|
+
return createSuccessResponse(responseData);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Creates a handler for a POST endpoint
|
|
36
|
+
*/
|
|
37
|
+
export function createPostHandler(endpoint, responseData) {
|
|
38
|
+
return http.post(apiUrl(endpoint), () => {
|
|
39
|
+
return createSuccessResponse(responseData);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Export http from msw for custom handlers
|
|
44
|
+
*/
|
|
45
|
+
export { http, HttpResponse };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { setupServer } from 'msw/node';
|
|
2
|
+
import { afterAll, afterEach, beforeAll } from 'vitest';
|
|
3
|
+
// Create MSW server instance
|
|
4
|
+
export const server = setupServer();
|
|
5
|
+
// Start server before all tests
|
|
6
|
+
beforeAll(() => {
|
|
7
|
+
// Only warn on unhandled requests instead of error, since transport/http-client.test.ts
|
|
8
|
+
// uses direct fetch mocking which bypasses MSW
|
|
9
|
+
server.listen({ onUnhandledRequest: 'warn' });
|
|
10
|
+
});
|
|
11
|
+
// Reset handlers after each test
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
server.resetHandlers();
|
|
14
|
+
});
|
|
15
|
+
// Clean up after all tests
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
server.close();
|
|
18
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a CustomFetch adapter for Obsidian's requestUrl API.
|
|
3
|
+
*
|
|
4
|
+
* This adapter bridges the gap between Obsidian's requestUrl interface and the
|
|
5
|
+
* standard fetch-like interface expected by the Comms API SDK.
|
|
6
|
+
*
|
|
7
|
+
* Key differences handled by this adapter:
|
|
8
|
+
* - Obsidian returns response data as properties (response.json, response.text)
|
|
9
|
+
* while the SDK expects methods (response.json(), response.text())
|
|
10
|
+
* - Obsidian's requestUrl bypasses CORS restrictions that would block standard fetch
|
|
11
|
+
* - Obsidian throws on HTTP errors by default; we set throw: false to handle manually
|
|
12
|
+
* - Obsidian doesn't provide statusText; we default to empty string
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* import { requestUrl } from 'obsidian'
|
|
17
|
+
* import { createObsidianFetchAdapter } from './obsidian-fetch-adapter.js'
|
|
18
|
+
*
|
|
19
|
+
* const api = new CommsApi('your-token', {
|
|
20
|
+
* customFetch: createObsidianFetchAdapter(requestUrl)
|
|
21
|
+
* })
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* @param requestUrl - The Obsidian requestUrl function
|
|
25
|
+
* @returns A CustomFetch function compatible with the Comms API SDK
|
|
26
|
+
*/
|
|
27
|
+
export function createObsidianFetchAdapter(requestUrl) {
|
|
28
|
+
return async (url, options) => {
|
|
29
|
+
// Build the request parameters in Obsidian's format
|
|
30
|
+
const requestParams = {
|
|
31
|
+
url,
|
|
32
|
+
method: options?.method || 'GET',
|
|
33
|
+
headers: options?.headers,
|
|
34
|
+
body: options?.body,
|
|
35
|
+
throw: false, // Don't throw on HTTP errors; let the SDK handle status codes
|
|
36
|
+
};
|
|
37
|
+
// Make the request using Obsidian's requestUrl
|
|
38
|
+
const response = await requestUrl(requestParams);
|
|
39
|
+
// Transform Obsidian's response format to match CustomFetchResponse interface
|
|
40
|
+
return {
|
|
41
|
+
ok: response.status >= 200 && response.status < 300,
|
|
42
|
+
status: response.status,
|
|
43
|
+
statusText: '', // Obsidian doesn't provide statusText
|
|
44
|
+
headers: response.headers,
|
|
45
|
+
// Wrap Obsidian's direct properties as methods returning promises
|
|
46
|
+
text: () => Promise.resolve(response.text),
|
|
47
|
+
json: () => Promise.resolve(response.json),
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export const TEST_API_TOKEN = 'test-api-token';
|
|
2
|
+
// Canonical test IDs — shaped so they pass SDK-side validation.
|
|
3
|
+
export const TEST_CHANNEL_ID = '7YpL3oZ4kZ9vP7Q1tR2sX3y';
|
|
4
|
+
export const TEST_THREAD_ID = '7YpL3oZ4kZ9vP7Q1tR2sX3z';
|
|
5
|
+
export const TEST_COMMENT_ID = '7YpL3oZ4kZ9vP7Q1tR2sX41';
|
|
6
|
+
export const TEST_CONVERSATION_ID = '7YpL3oZ4kZ9vP7Q1tR2sX42';
|
|
7
|
+
export const TEST_MESSAGE_ID = '7YpL3oZ4kZ9vP7Q1tR2sX43';
|
|
8
|
+
export const TEST_GROUP_ID = '7YpL3oZ4kZ9vP7Q1tR2sX44';
|
|
9
|
+
export const mockUser = {
|
|
10
|
+
id: 1,
|
|
11
|
+
email: 'test@example.com',
|
|
12
|
+
fullName: 'Test User',
|
|
13
|
+
shortName: 'TU',
|
|
14
|
+
timezone: 'America/New_York',
|
|
15
|
+
removed: false,
|
|
16
|
+
lang: 'en',
|
|
17
|
+
};
|
|
18
|
+
export const mockWorkspace = {
|
|
19
|
+
id: 1,
|
|
20
|
+
name: 'Test Workspace',
|
|
21
|
+
creator: 1,
|
|
22
|
+
created: new Date('2021-01-01T00:00:00Z'),
|
|
23
|
+
};
|
|
24
|
+
export const mockChannel = {
|
|
25
|
+
id: TEST_CHANNEL_ID,
|
|
26
|
+
name: 'general',
|
|
27
|
+
creator: 1,
|
|
28
|
+
public: true,
|
|
29
|
+
workspaceId: 1,
|
|
30
|
+
archived: false,
|
|
31
|
+
created: new Date('2021-01-01T00:00:00Z'),
|
|
32
|
+
version: 0,
|
|
33
|
+
url: `https://comms.todoist.com/a/1/ch/${TEST_CHANNEL_ID}/`,
|
|
34
|
+
};
|
|
35
|
+
export const mockThread = {
|
|
36
|
+
id: TEST_THREAD_ID,
|
|
37
|
+
title: 'Test Thread',
|
|
38
|
+
content: 'This is a test thread',
|
|
39
|
+
creator: 1,
|
|
40
|
+
channelId: TEST_CHANNEL_ID,
|
|
41
|
+
workspaceId: 1,
|
|
42
|
+
commentCount: 0,
|
|
43
|
+
lastUpdated: new Date('2021-01-01T00:00:00Z'),
|
|
44
|
+
pinned: false,
|
|
45
|
+
posted: new Date('2021-01-01T00:00:00Z'),
|
|
46
|
+
snippet: 'This is a test thread',
|
|
47
|
+
snippetCreator: 1,
|
|
48
|
+
isArchived: false,
|
|
49
|
+
url: `https://comms.todoist.com/a/1/ch/${TEST_CHANNEL_ID}/t/${TEST_THREAD_ID}/`,
|
|
50
|
+
};
|
|
51
|
+
export const mockGroup = {
|
|
52
|
+
id: TEST_GROUP_ID,
|
|
53
|
+
name: 'Test Group',
|
|
54
|
+
workspaceId: 1,
|
|
55
|
+
userIds: [1, 2, 3],
|
|
56
|
+
version: 0,
|
|
57
|
+
};
|
|
58
|
+
export const mockConversation = {
|
|
59
|
+
id: TEST_CONVERSATION_ID,
|
|
60
|
+
workspaceId: 1,
|
|
61
|
+
userIds: [1, 2],
|
|
62
|
+
messageCount: 1,
|
|
63
|
+
lastObjIndex: 0,
|
|
64
|
+
snippet: 'Hello there',
|
|
65
|
+
snippetCreators: [1],
|
|
66
|
+
lastActive: new Date('2021-01-01T00:00:00Z'),
|
|
67
|
+
archived: false,
|
|
68
|
+
created: new Date('2021-01-01T00:00:00Z'),
|
|
69
|
+
creator: 1,
|
|
70
|
+
url: `https://comms.todoist.com/a/1/msg/${TEST_CONVERSATION_ID}/`,
|
|
71
|
+
};
|
|
72
|
+
export const mockComment = {
|
|
73
|
+
id: TEST_COMMENT_ID,
|
|
74
|
+
content: 'This is a comment',
|
|
75
|
+
creator: 1,
|
|
76
|
+
threadId: TEST_THREAD_ID,
|
|
77
|
+
workspaceId: 1,
|
|
78
|
+
channelId: TEST_CHANNEL_ID,
|
|
79
|
+
posted: new Date('2021-01-01T00:00:00Z'),
|
|
80
|
+
url: `https://comms.todoist.com/a/1/ch/${TEST_CHANNEL_ID}/t/${TEST_THREAD_ID}/c/${TEST_COMMENT_ID}`,
|
|
81
|
+
};
|
|
82
|
+
export const mockWorkspaceUser = {
|
|
83
|
+
id: 1,
|
|
84
|
+
fullName: 'Test User',
|
|
85
|
+
email: 'test@example.com',
|
|
86
|
+
userType: 'USER',
|
|
87
|
+
shortName: 'TU',
|
|
88
|
+
firstName: 'Test',
|
|
89
|
+
imageId: null,
|
|
90
|
+
avatarUrls: null,
|
|
91
|
+
dateFormat: null,
|
|
92
|
+
removed: false,
|
|
93
|
+
restricted: null,
|
|
94
|
+
setupPending: null,
|
|
95
|
+
theme: null,
|
|
96
|
+
timeFormat: null,
|
|
97
|
+
timezone: 'America/New_York',
|
|
98
|
+
version: 1,
|
|
99
|
+
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { CommsRequestError } from '../types/errors.js';
|
|
2
|
+
import { camelCaseKeys } from '../utils/case-conversion.js';
|
|
3
|
+
import { transformTimestamps } from '../utils/timestamp-conversion.js';
|
|
4
|
+
import { getDefaultDispatcher } from './http-dispatcher.js';
|
|
5
|
+
export async function fetchWithRetry(url, options, maxRetries = 3, customFetch) {
|
|
6
|
+
let lastError;
|
|
7
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
8
|
+
let clearTimeoutFn;
|
|
9
|
+
try {
|
|
10
|
+
let requestSignal = options.signal || undefined;
|
|
11
|
+
if (options.timeout && options.timeout > 0) {
|
|
12
|
+
const timeoutResult = createTimeoutSignal(options.timeout, requestSignal);
|
|
13
|
+
requestSignal = timeoutResult.signal;
|
|
14
|
+
clearTimeoutFn = timeoutResult.clear;
|
|
15
|
+
}
|
|
16
|
+
const response = customFetch
|
|
17
|
+
? await customFetch(url, options)
|
|
18
|
+
: await fetchWithDefaultTransport(url, options, requestSignal);
|
|
19
|
+
const responseText = await response.text();
|
|
20
|
+
let responseData;
|
|
21
|
+
try {
|
|
22
|
+
responseData = responseText ? JSON.parse(responseText) : undefined;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
responseData = responseText;
|
|
26
|
+
}
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
throw new CommsRequestError(`Request failed with status ${response.status}`, response.status, responseData);
|
|
29
|
+
}
|
|
30
|
+
const camelCased = camelCaseKeys(responseData);
|
|
31
|
+
const transformed = transformTimestamps(camelCased);
|
|
32
|
+
if (clearTimeoutFn) {
|
|
33
|
+
clearTimeoutFn();
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
data: transformed,
|
|
37
|
+
status: response.status,
|
|
38
|
+
headers: response.headers,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
if (clearTimeoutFn) {
|
|
43
|
+
clearTimeoutFn();
|
|
44
|
+
}
|
|
45
|
+
lastError = error instanceof Error ? error : new Error('Unknown error');
|
|
46
|
+
if (attempt < maxRetries && isNetworkError(lastError)) {
|
|
47
|
+
const delay = getRetryDelay(attempt + 1);
|
|
48
|
+
if (delay > 0) {
|
|
49
|
+
await sleep(delay);
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (lastError instanceof CommsRequestError) {
|
|
57
|
+
throw lastError;
|
|
58
|
+
}
|
|
59
|
+
throw new CommsRequestError(lastError?.message ?? 'Request failed');
|
|
60
|
+
}
|
|
61
|
+
async function fetchWithDefaultTransport(url, options, signal) {
|
|
62
|
+
const dispatcher = await getDefaultDispatcher();
|
|
63
|
+
const response = dispatcher
|
|
64
|
+
? await fetch(url, {
|
|
65
|
+
...options,
|
|
66
|
+
signal,
|
|
67
|
+
// @ts-expect-error - dispatcher is valid for Node.js fetch but not in TS types
|
|
68
|
+
dispatcher,
|
|
69
|
+
})
|
|
70
|
+
: await fetch(url, {
|
|
71
|
+
...options,
|
|
72
|
+
signal,
|
|
73
|
+
});
|
|
74
|
+
return convertResponseToCustomFetch(response);
|
|
75
|
+
}
|
|
76
|
+
async function sleep(ms) {
|
|
77
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
78
|
+
}
|
|
79
|
+
const timeoutErrorName = 'TimeoutError';
|
|
80
|
+
function createTimeoutError(timeoutMs) {
|
|
81
|
+
const error = new Error(`Request timeout after ${timeoutMs}ms`);
|
|
82
|
+
error.name = timeoutErrorName;
|
|
83
|
+
return error;
|
|
84
|
+
}
|
|
85
|
+
function isNetworkError(error) {
|
|
86
|
+
return (error.name === 'TypeError' ||
|
|
87
|
+
error.name === timeoutErrorName ||
|
|
88
|
+
error.message.toLowerCase().includes('network'));
|
|
89
|
+
}
|
|
90
|
+
function getRetryDelay(retryCount) {
|
|
91
|
+
return retryCount === 1 ? 0 : 500;
|
|
92
|
+
}
|
|
93
|
+
function createTimeoutSignal(timeoutMs, existingSignal) {
|
|
94
|
+
const controller = new AbortController();
|
|
95
|
+
const timeoutId = setTimeout(() => {
|
|
96
|
+
controller.abort(createTimeoutError(timeoutMs));
|
|
97
|
+
}, timeoutMs);
|
|
98
|
+
let abortHandler;
|
|
99
|
+
function clear() {
|
|
100
|
+
clearTimeout(timeoutId);
|
|
101
|
+
if (existingSignal && abortHandler) {
|
|
102
|
+
existingSignal.removeEventListener('abort', abortHandler);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (existingSignal) {
|
|
106
|
+
if (existingSignal.aborted) {
|
|
107
|
+
clearTimeout(timeoutId);
|
|
108
|
+
controller.abort(existingSignal.reason);
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
abortHandler = () => {
|
|
112
|
+
clearTimeout(timeoutId);
|
|
113
|
+
controller.abort(existingSignal.reason);
|
|
114
|
+
};
|
|
115
|
+
existingSignal.addEventListener('abort', abortHandler, { once: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { signal: controller.signal, clear };
|
|
119
|
+
}
|
|
120
|
+
function convertResponseToCustomFetch(response) {
|
|
121
|
+
const headers = {};
|
|
122
|
+
response.headers.forEach((value, key) => {
|
|
123
|
+
headers[key] = value;
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
ok: response.ok,
|
|
127
|
+
status: response.status,
|
|
128
|
+
statusText: response.statusText,
|
|
129
|
+
headers,
|
|
130
|
+
text: () => response.clone().text(),
|
|
131
|
+
json: () => response.json(),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { snakeCaseKeys } from '../utils/case-conversion.js';
|
|
2
|
+
import { fetchWithRetry } from './fetch-with-retry.js';
|
|
3
|
+
export function paramsSerializer(params) {
|
|
4
|
+
const qs = new URLSearchParams();
|
|
5
|
+
Object.keys(params).forEach((key) => {
|
|
6
|
+
const value = params[key];
|
|
7
|
+
if (value != null) {
|
|
8
|
+
if (Array.isArray(value)) {
|
|
9
|
+
qs.append(key, value.join(','));
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
qs.append(key, String(value));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
return qs.toString();
|
|
17
|
+
}
|
|
18
|
+
const defaultHeaders = {
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
};
|
|
21
|
+
function getAuthHeader(apiToken) {
|
|
22
|
+
return `Bearer ${apiToken}`;
|
|
23
|
+
}
|
|
24
|
+
function getRequestConfiguration(baseURL, apiToken, requestId) {
|
|
25
|
+
const authHeader = apiToken ? { Authorization: getAuthHeader(apiToken) } : undefined;
|
|
26
|
+
const requestIdHeader = requestId ? { 'X-Request-Id': requestId } : undefined;
|
|
27
|
+
const headers = { ...defaultHeaders, ...authHeader, ...requestIdHeader };
|
|
28
|
+
return { baseURL, headers, timeout: 30000 };
|
|
29
|
+
}
|
|
30
|
+
export async function request(args) {
|
|
31
|
+
const { httpMethod, baseUri, relativePath, apiToken, payload, requestId, customFetch } = args;
|
|
32
|
+
const config = getRequestConfiguration(baseUri, apiToken, requestId);
|
|
33
|
+
const url = new URL(relativePath, config.baseURL).toString();
|
|
34
|
+
const options = {
|
|
35
|
+
method: httpMethod,
|
|
36
|
+
headers: config.headers,
|
|
37
|
+
timeout: config.timeout,
|
|
38
|
+
};
|
|
39
|
+
if (httpMethod === 'GET' && payload) {
|
|
40
|
+
const searchParams = paramsSerializer(snakeCaseKeys(payload));
|
|
41
|
+
const urlWithParams = searchParams ? `${url}?${searchParams}` : url;
|
|
42
|
+
return fetchWithRetry(urlWithParams, options, 3, customFetch);
|
|
43
|
+
}
|
|
44
|
+
if (payload && httpMethod !== 'GET') {
|
|
45
|
+
options.body = JSON.stringify(snakeCaseKeys(payload));
|
|
46
|
+
}
|
|
47
|
+
return fetchWithRetry(url, options, 3, customFetch);
|
|
48
|
+
}
|
|
49
|
+
export function isSuccess(response) {
|
|
50
|
+
return response.status >= 200 && response.status < 300;
|
|
51
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
let defaultDispatcher;
|
|
2
|
+
let defaultDispatcherPromise;
|
|
3
|
+
export async function getDefaultDispatcher() {
|
|
4
|
+
if (defaultDispatcher) {
|
|
5
|
+
return defaultDispatcher;
|
|
6
|
+
}
|
|
7
|
+
if (!defaultDispatcherPromise) {
|
|
8
|
+
defaultDispatcherPromise = createDefaultDispatcher()
|
|
9
|
+
.then((dispatcher) => {
|
|
10
|
+
defaultDispatcher = dispatcher;
|
|
11
|
+
return dispatcher;
|
|
12
|
+
})
|
|
13
|
+
.catch((error) => {
|
|
14
|
+
defaultDispatcher = undefined;
|
|
15
|
+
defaultDispatcherPromise = undefined;
|
|
16
|
+
throw error;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return defaultDispatcherPromise;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Drains the default dispatcher's connection pool. CLIs and scripts should
|
|
23
|
+
* `await` this before exit so Node's event loop empties immediately instead
|
|
24
|
+
* of waiting ~4s on keep-alive. No-op in the browser branch.
|
|
25
|
+
*/
|
|
26
|
+
export async function closeDefaultDispatcher() {
|
|
27
|
+
// Clear the singleton *before* awaiting init, so any concurrent
|
|
28
|
+
// `getDefaultDispatcher()` after this point creates a fresh dispatcher
|
|
29
|
+
// instead of receiving a reference to the one we're about to close.
|
|
30
|
+
const initPromise = defaultDispatcherPromise;
|
|
31
|
+
defaultDispatcher = undefined;
|
|
32
|
+
defaultDispatcherPromise = undefined;
|
|
33
|
+
if (!initPromise)
|
|
34
|
+
return;
|
|
35
|
+
try {
|
|
36
|
+
const dispatcher = await initPromise;
|
|
37
|
+
if (dispatcher) {
|
|
38
|
+
await dispatcher.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// init failed; nothing to close
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function resetDefaultDispatcherForTests() {
|
|
46
|
+
defaultDispatcher = undefined;
|
|
47
|
+
defaultDispatcherPromise = undefined;
|
|
48
|
+
}
|
|
49
|
+
function isNodeEnvironment() {
|
|
50
|
+
return typeof process !== 'undefined' && typeof process.versions?.node === 'string';
|
|
51
|
+
}
|
|
52
|
+
async function createDefaultDispatcher() {
|
|
53
|
+
if (!isNodeEnvironment()) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
// Dynamic import so non-Node consumers (browser, edge runtimes) don't pull
|
|
57
|
+
// undici into their bundle. `isNodeEnvironment()` above already gated this
|
|
58
|
+
// branch, so undici is safe to load when we get here.
|
|
59
|
+
const { EnvHttpProxyAgent, interceptors } = await import('undici');
|
|
60
|
+
// `allowH2: true` opts into HTTP/2 via ALPN; undici falls back to h1.1
|
|
61
|
+
// when the server doesn't negotiate h2. Without this flag undici
|
|
62
|
+
// defaults to h1.1 even when the server supports h2.
|
|
63
|
+
//
|
|
64
|
+
// `interceptors.decompress()` decodes gzip/deflate/br/zstd bodies. On
|
|
65
|
+
// Node 24+, attaching any custom dispatcher to global `fetch` strips
|
|
66
|
+
// `content-encoding` without actually decompressing the body.
|
|
67
|
+
// See https://github.com/Doist/todoist-cli/issues/318.
|
|
68
|
+
//
|
|
69
|
+
// Both emit ExperimentalWarning on first use; suppressed during init.
|
|
70
|
+
return suppressExperimentalWarningsSync(() => {
|
|
71
|
+
const decompress = interceptors.decompress();
|
|
72
|
+
return new EnvHttpProxyAgent({ allowH2: true }).compose(decompress);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
// `suppressExperimentalWarningsSync` is exported for direct unit testing —
|
|
76
|
+
// the integration path through `getDefaultDispatcher()` can't reliably
|
|
77
|
+
// exercise it because both the dispatcher singleton and undici's internal
|
|
78
|
+
// `warningEmitted` flag are once-per-process.
|
|
79
|
+
//
|
|
80
|
+
// `fn` must be synchronous so the override covers a single critical section
|
|
81
|
+
// (microseconds) — no unrelated `ExperimentalWarning` from elsewhere can
|
|
82
|
+
// interleave and be lost. We suppress every `ExperimentalWarning` rather than
|
|
83
|
+
// pattern-matching the message text: the message wording is an undici
|
|
84
|
+
// implementation detail (not a stable API), and the suppression window is
|
|
85
|
+
// narrow enough that a coarse type filter is safe.
|
|
86
|
+
export function suppressExperimentalWarningsSync(fn) {
|
|
87
|
+
const originalEmit = process.emitWarning;
|
|
88
|
+
process.emitWarning = ((warning, typeOrOptions, ...rest) => {
|
|
89
|
+
const type = typeof typeOrOptions === 'string'
|
|
90
|
+
? typeOrOptions
|
|
91
|
+
: typeof typeOrOptions === 'object' && typeOrOptions !== null
|
|
92
|
+
? typeOrOptions.type
|
|
93
|
+
: undefined;
|
|
94
|
+
if (type === 'ExperimentalWarning')
|
|
95
|
+
return;
|
|
96
|
+
originalEmit.call(process, warning, typeOrOptions, ...rest);
|
|
97
|
+
});
|
|
98
|
+
try {
|
|
99
|
+
return fn();
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
process.emitWarning = originalEmit;
|
|
103
|
+
}
|
|
104
|
+
}
|