@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.
Files changed (112) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/cjs/authentication.js +211 -0
  4. package/dist/cjs/clients/add-comment-helper.js +53 -0
  5. package/dist/cjs/clients/base-client.js +27 -0
  6. package/dist/cjs/clients/channels-client.js +83 -0
  7. package/dist/cjs/clients/comments-client.js +93 -0
  8. package/dist/cjs/clients/conversation-messages-client.js +87 -0
  9. package/dist/cjs/clients/conversations-client.js +103 -0
  10. package/dist/cjs/clients/groups-client.js +71 -0
  11. package/dist/cjs/clients/inbox-client.js +98 -0
  12. package/dist/cjs/clients/reactions-client.js +59 -0
  13. package/dist/cjs/clients/search-client.js +88 -0
  14. package/dist/cjs/clients/threads-client.js +135 -0
  15. package/dist/cjs/clients/users-client.js +199 -0
  16. package/dist/cjs/clients/workspace-users-client.js +140 -0
  17. package/dist/cjs/clients/workspaces-client.js +93 -0
  18. package/dist/cjs/comms-api.js +65 -0
  19. package/dist/cjs/consts/endpoints.js +27 -0
  20. package/dist/cjs/index.js +48 -0
  21. package/dist/cjs/package.json +1 -0
  22. package/dist/cjs/testUtils/msw-handlers.js +51 -0
  23. package/dist/cjs/testUtils/msw-setup.js +21 -0
  24. package/dist/cjs/testUtils/obsidian-fetch-adapter.js +53 -0
  25. package/dist/cjs/testUtils/test-defaults.js +102 -0
  26. package/dist/cjs/transport/fetch-with-retry.js +136 -0
  27. package/dist/cjs/transport/http-client.js +56 -0
  28. package/dist/cjs/transport/http-dispatcher.js +143 -0
  29. package/dist/cjs/types/entities.js +411 -0
  30. package/dist/cjs/types/enums.js +37 -0
  31. package/dist/cjs/types/errors.js +12 -0
  32. package/dist/cjs/types/http.js +4 -0
  33. package/dist/cjs/types/index.js +21 -0
  34. package/dist/cjs/types/requests.js +117 -0
  35. package/dist/cjs/utils/case-conversion.js +54 -0
  36. package/dist/cjs/utils/index.js +19 -0
  37. package/dist/cjs/utils/timestamp-conversion.js +49 -0
  38. package/dist/cjs/utils/url-helpers.js +131 -0
  39. package/dist/cjs/utils/uuidv7.js +174 -0
  40. package/dist/esm/authentication.js +203 -0
  41. package/dist/esm/clients/add-comment-helper.js +50 -0
  42. package/dist/esm/clients/base-client.js +23 -0
  43. package/dist/esm/clients/channels-client.js +79 -0
  44. package/dist/esm/clients/comments-client.js +89 -0
  45. package/dist/esm/clients/conversation-messages-client.js +83 -0
  46. package/dist/esm/clients/conversations-client.js +99 -0
  47. package/dist/esm/clients/groups-client.js +67 -0
  48. package/dist/esm/clients/inbox-client.js +94 -0
  49. package/dist/esm/clients/reactions-client.js +55 -0
  50. package/dist/esm/clients/search-client.js +84 -0
  51. package/dist/esm/clients/threads-client.js +131 -0
  52. package/dist/esm/clients/users-client.js +195 -0
  53. package/dist/esm/clients/workspace-users-client.js +136 -0
  54. package/dist/esm/clients/workspaces-client.js +89 -0
  55. package/dist/esm/comms-api.js +61 -0
  56. package/dist/esm/consts/endpoints.js +23 -0
  57. package/dist/esm/index.js +17 -0
  58. package/dist/esm/testUtils/msw-handlers.js +45 -0
  59. package/dist/esm/testUtils/msw-setup.js +18 -0
  60. package/dist/esm/testUtils/obsidian-fetch-adapter.js +50 -0
  61. package/dist/esm/testUtils/test-defaults.js +99 -0
  62. package/dist/esm/transport/fetch-with-retry.js +133 -0
  63. package/dist/esm/transport/http-client.js +51 -0
  64. package/dist/esm/transport/http-dispatcher.js +104 -0
  65. package/dist/esm/types/entities.js +408 -0
  66. package/dist/esm/types/enums.js +34 -0
  67. package/dist/esm/types/errors.js +8 -0
  68. package/dist/esm/types/http.js +1 -0
  69. package/dist/esm/types/index.js +5 -0
  70. package/dist/esm/types/requests.js +114 -0
  71. package/dist/esm/utils/case-conversion.js +47 -0
  72. package/dist/esm/utils/index.js +3 -0
  73. package/dist/esm/utils/timestamp-conversion.js +45 -0
  74. package/dist/esm/utils/url-helpers.js +112 -0
  75. package/dist/esm/utils/uuidv7.js +163 -0
  76. package/dist/types/authentication.d.ts +160 -0
  77. package/dist/types/clients/add-comment-helper.d.ts +12 -0
  78. package/dist/types/clients/base-client.d.ts +24 -0
  79. package/dist/types/clients/channels-client.d.ts +91 -0
  80. package/dist/types/clients/comments-client.d.ts +157 -0
  81. package/dist/types/clients/conversation-messages-client.d.ts +127 -0
  82. package/dist/types/clients/conversations-client.d.ts +206 -0
  83. package/dist/types/clients/groups-client.d.ts +55 -0
  84. package/dist/types/clients/inbox-client.d.ts +20 -0
  85. package/dist/types/clients/reactions-client.d.ts +19 -0
  86. package/dist/types/clients/search-client.d.ts +20 -0
  87. package/dist/types/clients/threads-client.d.ts +344 -0
  88. package/dist/types/clients/users-client.d.ts +123 -0
  89. package/dist/types/clients/workspace-users-client.d.ts +47 -0
  90. package/dist/types/clients/workspaces-client.d.ts +79 -0
  91. package/dist/types/comms-api.d.ts +59 -0
  92. package/dist/types/consts/endpoints.d.ts +19 -0
  93. package/dist/types/index.d.ts +18 -0
  94. package/dist/types/testUtils/msw-handlers.d.ts +28 -0
  95. package/dist/types/testUtils/msw-setup.d.ts +1 -0
  96. package/dist/types/testUtils/obsidian-fetch-adapter.d.ts +29 -0
  97. package/dist/types/testUtils/test-defaults.d.ts +16 -0
  98. package/dist/types/transport/fetch-with-retry.d.ts +4 -0
  99. package/dist/types/transport/http-client.d.ts +13 -0
  100. package/dist/types/transport/http-dispatcher.d.ts +10 -0
  101. package/dist/types/types/entities.d.ts +1288 -0
  102. package/dist/types/types/enums.d.ts +55 -0
  103. package/dist/types/types/errors.d.ts +6 -0
  104. package/dist/types/types/http.d.ts +54 -0
  105. package/dist/types/types/index.d.ts +5 -0
  106. package/dist/types/types/requests.d.ts +385 -0
  107. package/dist/types/utils/case-conversion.d.ts +8 -0
  108. package/dist/types/utils/index.d.ts +3 -0
  109. package/dist/types/utils/timestamp-conversion.d.ts +13 -0
  110. package/dist/types/utils/url-helpers.d.ts +88 -0
  111. package/dist/types/utils/uuidv7.d.ts +40 -0
  112. 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
+ }