@doist/comms-sdk 0.6.1 → 0.7.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/dist/cjs/index.js CHANGED
@@ -16,6 +16,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.closeDefaultDispatcher = exports.CommsApi = exports.WorkspacesClient = exports.WorkspaceUsersClient = exports.UsersClient = exports.ThreadsClient = exports.SearchClient = exports.ReactionsClient = exports.InboxClient = exports.HooksClient = exports.GroupsClient = exports.ConversationsClient = exports.ConversationMessagesClient = exports.CommentsClient = exports.ChannelsClient = exports.AttachmentsClient = void 0;
18
18
  __exportStar(require("./authentication"), exports);
19
+ __exportStar(require("./migration"), exports);
19
20
  var attachments_client_1 = require("./clients/attachments-client");
20
21
  Object.defineProperty(exports, "AttachmentsClient", { enumerable: true, get: function () { return attachments_client_1.AttachmentsClient; } });
21
22
  var channels_client_1 = require("./clients/channels-client");
@@ -0,0 +1,96 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.fetchNewCommsUrl = fetchNewCommsUrl;
4
+ exports.fetchNewCommsUrls = fetchNewCommsUrls;
5
+ const http_client_1 = require("./transport/http-client");
6
+ const errors_1 = require("./types/errors");
7
+ /**
8
+ * Default origin of the Twist migration backend.
9
+ *
10
+ * The `fetch_new_url` endpoint lives on Twist (`twist.com`) and authenticates
11
+ * with a **Twist** token, not a Comms one. This is why the migration helpers are
12
+ * standalone functions rather than methods on {@link CommsApi} (which is
13
+ * constructed with a Comms token).
14
+ */
15
+ const DEFAULT_TWIST_BASE_URL = 'https://twist.com';
16
+ function getTwistMigrationUrl(baseUrl) {
17
+ const base = baseUrl ?? DEFAULT_TWIST_BASE_URL;
18
+ const trimmed = base.endsWith('/') ? base.slice(0, -1) : base;
19
+ return `${trimmed}/api/comms_migration/fetch_new_url`;
20
+ }
21
+ /**
22
+ * Translates a Twist URL to its equivalent Comms URL using Twist's
23
+ * `comms_migration/fetch_new_url` endpoint.
24
+ *
25
+ * Authenticates with a **Twist** token (external to the Comms SDK), so this is a
26
+ * standalone helper rather than a method on {@link CommsApi}.
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * const newUrl = await fetchNewCommsUrl({
31
+ * oldUrl: 'https://twist.com/a/123/ch/456/t/789',
32
+ * twistToken: process.env.TWIST_AUTH_TOKEN,
33
+ * })
34
+ * ```
35
+ *
36
+ * @returns The equivalent Comms URL.
37
+ * @throws {@link CommsRequestError} If the request fails. URLs that cannot be
38
+ * migrated surface as a thrown error too: `400` with `responseData.error.code`
39
+ * of `'invalid_url'`, or `404` with `'not_imported'`.
40
+ */
41
+ async function fetchNewCommsUrl(args, options) {
42
+ const response = await (0, http_client_1.request)({
43
+ httpMethod: 'POST',
44
+ baseUri: getTwistMigrationUrl(options?.baseUrl),
45
+ relativePath: '',
46
+ apiToken: args.twistToken,
47
+ payload: { oldUrl: args.oldUrl },
48
+ customFetch: options?.customFetch,
49
+ });
50
+ const newUrl = response.data?.newUrl;
51
+ if (!newUrl) {
52
+ throw new errors_1.CommsRequestError('Migration response did not contain a new URL.', response.status, response.data);
53
+ }
54
+ return newUrl;
55
+ }
56
+ /**
57
+ * Translates multiple Twist URLs to their equivalent Comms URLs.
58
+ *
59
+ * URLs are processed **sequentially** to stay friendly to the rate-sensitive
60
+ * migration endpoint. A failure on one URL does not abort the run: each result
61
+ * carries either a `newUrl` (success) or a `CommsRequestError` (failure), in the
62
+ * same order as the input.
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * const results = await fetchNewCommsUrls({
67
+ * oldUrls: ['https://twist.com/a/1/ch/2/t/3', 'https://twist.com/bad'],
68
+ * twistToken: process.env.TWIST_AUTH_TOKEN,
69
+ * })
70
+ * for (const result of results) {
71
+ * if (result.newUrl) console.log(`${result.oldUrl} -> ${result.newUrl}`)
72
+ * else console.warn(`${result.oldUrl} failed`, result.error)
73
+ * }
74
+ * ```
75
+ *
76
+ * @returns One {@link MigrationResult} per input URL, in input order.
77
+ * @throws Re-throws any error that is not a {@link CommsRequestError}.
78
+ */
79
+ async function fetchNewCommsUrls(args, options) {
80
+ const results = [];
81
+ for (const oldUrl of args.oldUrls) {
82
+ try {
83
+ const newUrl = await fetchNewCommsUrl({ oldUrl, twistToken: args.twistToken }, options);
84
+ results.push({ oldUrl, newUrl });
85
+ }
86
+ catch (error) {
87
+ if (error instanceof errors_1.CommsRequestError) {
88
+ results.push({ oldUrl, error });
89
+ }
90
+ else {
91
+ throw error;
92
+ }
93
+ }
94
+ }
95
+ return results;
96
+ }
@@ -3,6 +3,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.server = void 0;
4
4
  const node_1 = require("msw/node");
5
5
  const vitest_1 = require("vitest");
6
+ // In production the transport uses undici's own `fetch` (paired with the
7
+ // dispatcher) so the request client and dispatcher stay on one undici version.
8
+ // MSW intercepts the *global* `fetch`, not that separate undici instance, so
9
+ // force the transport onto the global `fetch` for every test in the suite;
10
+ // MSW then intercepts requests as usual. The real undici transport is covered
11
+ // directly by `http-dispatcher.test.ts` and `fetch-with-retry.test.ts`, which
12
+ // opt out of this seam.
13
+ vitest_1.vi.mock('../transport/http-dispatcher', async (importOriginal) => {
14
+ const actual = await importOriginal();
15
+ return {
16
+ ...actual,
17
+ getDefaultTransport: vitest_1.vi.fn(async () => undefined),
18
+ };
19
+ });
6
20
  // Create MSW server instance
7
21
  exports.server = (0, node_1.setupServer)();
8
22
  // Start server before all tests
@@ -62,15 +62,25 @@ async function fetchWithRetry(url, options, maxRetries = 3, customFetch) {
62
62
  throw new errors_1.CommsRequestError(lastError?.message ?? 'Request failed');
63
63
  }
64
64
  async function fetchWithDefaultTransport(url, options, signal) {
65
- const dispatcher = await (0, http_dispatcher_1.getDefaultDispatcher)();
66
- const response = dispatcher
67
- ? await fetch(url, {
65
+ // Read the dispatcher and its paired `fetch` as one value so they can never
66
+ // be mismatched. On Node, `fetch` is undici's own — paired with the
67
+ // dispatcher — so the request client and dispatcher stay on one undici
68
+ // version; otherwise the `decompress` interceptor terminates gzip responses
69
+ // when the global `fetch`'s bundled undici differs. Browser/edge (and Bun)
70
+ // have no paired `fetch` and fall back to the global one.
71
+ const transport = await (0, http_dispatcher_1.getDefaultTransport)();
72
+ // undici's `fetch` and the global `fetch` are the same function at runtime
73
+ // but carry different (undici vs DOM) `RequestInit`/`Response` types. Call
74
+ // through the global signature, which matches the global-typed `options`.
75
+ const fetchImpl = (transport?.fetch ?? fetch);
76
+ const response = transport?.dispatcher
77
+ ? await fetchImpl(url, {
68
78
  ...options,
69
79
  signal,
70
80
  // @ts-expect-error - dispatcher is valid for Node.js fetch but not in TS types
71
- dispatcher,
81
+ dispatcher: transport.dispatcher,
72
82
  })
73
- : await fetch(url, {
83
+ : await fetchImpl(url, {
74
84
  ...options,
75
85
  signal,
76
86
  });
@@ -33,29 +33,39 @@ 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;
37
38
  exports.closeDefaultDispatcher = closeDefaultDispatcher;
38
39
  exports.resetDefaultDispatcherForTests = resetDefaultDispatcherForTests;
40
+ exports.getDefaultFetch = getDefaultFetch;
39
41
  exports.suppressExperimentalWarningsSync = suppressExperimentalWarningsSync;
40
- let defaultDispatcher;
41
- let defaultDispatcherPromise;
42
- async function getDefaultDispatcher() {
43
- if (defaultDispatcher) {
44
- return defaultDispatcher;
42
+ let defaultTransport;
43
+ let defaultTransportPromise;
44
+ /**
45
+ * The default dispatcher and its paired `fetch`, as a single value so the two
46
+ * are always read consistently. Resolves to `undefined` outside Node (browser/
47
+ * edge), where callers use the global `fetch` with no dispatcher.
48
+ */
49
+ async function getDefaultTransport() {
50
+ if (defaultTransport) {
51
+ return defaultTransport;
45
52
  }
46
- if (!defaultDispatcherPromise) {
47
- defaultDispatcherPromise = createDefaultDispatcher()
48
- .then((dispatcher) => {
49
- defaultDispatcher = dispatcher;
50
- return dispatcher;
53
+ if (!defaultTransportPromise) {
54
+ defaultTransportPromise = createDefaultTransport()
55
+ .then((transport) => {
56
+ defaultTransport = transport;
57
+ return transport;
51
58
  })
52
59
  .catch((error) => {
53
- defaultDispatcher = undefined;
54
- defaultDispatcherPromise = undefined;
60
+ defaultTransport = undefined;
61
+ defaultTransportPromise = undefined;
55
62
  throw error;
56
63
  });
57
64
  }
58
- return defaultDispatcherPromise;
65
+ return defaultTransportPromise;
66
+ }
67
+ async function getDefaultDispatcher() {
68
+ return (await getDefaultTransport())?.dispatcher;
59
69
  }
60
70
  /**
61
71
  * Drains the default dispatcher's connection pool. CLIs and scripts should
@@ -64,17 +74,17 @@ async function getDefaultDispatcher() {
64
74
  */
65
75
  async function closeDefaultDispatcher() {
66
76
  // Clear the singleton *before* awaiting init, so any concurrent
67
- // `getDefaultDispatcher()` after this point creates a fresh dispatcher
77
+ // `getDefaultTransport()` after this point creates a fresh dispatcher
68
78
  // instead of receiving a reference to the one we're about to close.
69
- const initPromise = defaultDispatcherPromise;
70
- defaultDispatcher = undefined;
71
- defaultDispatcherPromise = undefined;
79
+ const initPromise = defaultTransportPromise;
80
+ defaultTransport = undefined;
81
+ defaultTransportPromise = undefined;
72
82
  if (!initPromise)
73
83
  return;
74
84
  try {
75
- const dispatcher = await initPromise;
76
- if (dispatcher) {
77
- await dispatcher.close();
85
+ const transport = await initPromise;
86
+ if (transport) {
87
+ await transport.dispatcher.close();
78
88
  }
79
89
  }
80
90
  catch {
@@ -82,20 +92,39 @@ async function closeDefaultDispatcher() {
82
92
  }
83
93
  }
84
94
  function resetDefaultDispatcherForTests() {
85
- defaultDispatcher = undefined;
86
- defaultDispatcherPromise = undefined;
95
+ defaultTransport = undefined;
96
+ defaultTransportPromise = undefined;
97
+ }
98
+ /**
99
+ * The `fetch` implementation that must be used with the default dispatcher.
100
+ * Returns undici's own `fetch` on the full-undici Node path, or `undefined`
101
+ * (meaning: use the global `fetch`) in the browser/edge/Bun paths.
102
+ *
103
+ * Node's global `fetch` is backed by whatever undici version ships inside that
104
+ * Node release (6.x on Node 22 … 8.x on Node 26). Our dispatcher — and its
105
+ * `decompress` interceptor — comes from the npm `undici` package, which is a
106
+ * different version. Handing an npm-undici dispatcher to a mismatched built-in
107
+ * client makes gzip responses fail mid-stream with `terminated`. Sourcing
108
+ * `fetch` from the same npm `undici` keeps the whole request path on one
109
+ * version and removes the split.
110
+ *
111
+ * Only meaningful after {@link getDefaultTransport} has resolved, which is the
112
+ * one place that populates it.
113
+ */
114
+ function getDefaultFetch() {
115
+ return defaultTransport?.fetch;
87
116
  }
88
117
  function isNodeEnvironment() {
89
118
  return typeof process !== 'undefined' && typeof process.versions?.node === 'string';
90
119
  }
91
- async function createDefaultDispatcher() {
120
+ async function createDefaultTransport() {
92
121
  if (!isNodeEnvironment()) {
93
122
  return undefined;
94
123
  }
95
124
  // Dynamic import so non-Node consumers (browser, edge runtimes) don't pull
96
125
  // undici into their bundle. `isNodeEnvironment()` above already gated this
97
126
  // branch, so undici is safe to load when we get here.
98
- const { EnvHttpProxyAgent, interceptors } = await Promise.resolve().then(() => __importStar(require('undici')));
127
+ const { EnvHttpProxyAgent, interceptors, fetch: undiciFetch } = await Promise.resolve().then(() => __importStar(require('undici')));
99
128
  // `EnvHttpProxyAgent` (with `allowH2`) and `interceptors.decompress()` both
100
129
  // emit an ExperimentalWarning on first use, so build the whole dispatcher
101
130
  // inside the suppression block.
@@ -112,14 +141,21 @@ async function createDefaultDispatcher() {
112
141
  // crashing on the missing API. Optional chaining also guards a runtime
113
142
  // that omits the `interceptors` export entirely.
114
143
  if (typeof interceptors?.decompress !== 'function') {
115
- return agent;
144
+ // Bun: pair the agent with the global `fetch` (undefined), which
145
+ // decompresses natively.
146
+ return { dispatcher: agent, fetch: undefined };
116
147
  }
117
148
  // `interceptors.decompress()` decodes gzip/deflate/br/zstd bodies. On
118
149
  // Node 24+, attaching any custom dispatcher to global `fetch` strips
119
150
  // `content-encoding` without actually decompressing the body.
120
151
  // See https://github.com/Doist/todoist-cli/issues/318.
121
152
  const decompress = interceptors.decompress();
122
- return agent.compose(decompress);
153
+ // Pair undici's own `fetch` with this dispatcher so the request client
154
+ // and the dispatcher stay on one undici version (see `getDefaultFetch`).
155
+ // The global `fetch` is backed by a different, Node-bundled undici;
156
+ // mixing the two makes the decompress interceptor terminate gzip
157
+ // responses on some Node versions.
158
+ return { dispatcher: agent.compose(decompress), fetch: undiciFetch };
123
159
  });
124
160
  }
125
161
  // `suppressExperimentalWarningsSync` is exported for direct unit testing —
package/dist/esm/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './authentication.js';
2
+ export * from './migration.js';
2
3
  export { AttachmentsClient } from './clients/attachments-client.js';
3
4
  export { ChannelsClient } from './clients/channels-client.js';
4
5
  export { CommentsClient } from './clients/comments-client.js';
@@ -0,0 +1,92 @@
1
+ import { request } from './transport/http-client.js';
2
+ import { CommsRequestError } from './types/errors.js';
3
+ /**
4
+ * Default origin of the Twist migration backend.
5
+ *
6
+ * The `fetch_new_url` endpoint lives on Twist (`twist.com`) and authenticates
7
+ * with a **Twist** token, not a Comms one. This is why the migration helpers are
8
+ * standalone functions rather than methods on {@link CommsApi} (which is
9
+ * constructed with a Comms token).
10
+ */
11
+ const DEFAULT_TWIST_BASE_URL = 'https://twist.com';
12
+ function getTwistMigrationUrl(baseUrl) {
13
+ const base = baseUrl ?? DEFAULT_TWIST_BASE_URL;
14
+ const trimmed = base.endsWith('/') ? base.slice(0, -1) : base;
15
+ return `${trimmed}/api/comms_migration/fetch_new_url`;
16
+ }
17
+ /**
18
+ * Translates a Twist URL to its equivalent Comms URL using Twist's
19
+ * `comms_migration/fetch_new_url` endpoint.
20
+ *
21
+ * Authenticates with a **Twist** token (external to the Comms SDK), so this is a
22
+ * standalone helper rather than a method on {@link CommsApi}.
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * const newUrl = await fetchNewCommsUrl({
27
+ * oldUrl: 'https://twist.com/a/123/ch/456/t/789',
28
+ * twistToken: process.env.TWIST_AUTH_TOKEN,
29
+ * })
30
+ * ```
31
+ *
32
+ * @returns The equivalent Comms URL.
33
+ * @throws {@link CommsRequestError} If the request fails. URLs that cannot be
34
+ * migrated surface as a thrown error too: `400` with `responseData.error.code`
35
+ * of `'invalid_url'`, or `404` with `'not_imported'`.
36
+ */
37
+ export async function fetchNewCommsUrl(args, options) {
38
+ const response = await request({
39
+ httpMethod: 'POST',
40
+ baseUri: getTwistMigrationUrl(options?.baseUrl),
41
+ relativePath: '',
42
+ apiToken: args.twistToken,
43
+ payload: { oldUrl: args.oldUrl },
44
+ customFetch: options?.customFetch,
45
+ });
46
+ const newUrl = response.data?.newUrl;
47
+ if (!newUrl) {
48
+ throw new CommsRequestError('Migration response did not contain a new URL.', response.status, response.data);
49
+ }
50
+ return newUrl;
51
+ }
52
+ /**
53
+ * Translates multiple Twist URLs to their equivalent Comms URLs.
54
+ *
55
+ * URLs are processed **sequentially** to stay friendly to the rate-sensitive
56
+ * migration endpoint. A failure on one URL does not abort the run: each result
57
+ * carries either a `newUrl` (success) or a `CommsRequestError` (failure), in the
58
+ * same order as the input.
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * const results = await fetchNewCommsUrls({
63
+ * oldUrls: ['https://twist.com/a/1/ch/2/t/3', 'https://twist.com/bad'],
64
+ * twistToken: process.env.TWIST_AUTH_TOKEN,
65
+ * })
66
+ * for (const result of results) {
67
+ * if (result.newUrl) console.log(`${result.oldUrl} -> ${result.newUrl}`)
68
+ * else console.warn(`${result.oldUrl} failed`, result.error)
69
+ * }
70
+ * ```
71
+ *
72
+ * @returns One {@link MigrationResult} per input URL, in input order.
73
+ * @throws Re-throws any error that is not a {@link CommsRequestError}.
74
+ */
75
+ export async function fetchNewCommsUrls(args, options) {
76
+ const results = [];
77
+ for (const oldUrl of args.oldUrls) {
78
+ try {
79
+ const newUrl = await fetchNewCommsUrl({ oldUrl, twistToken: args.twistToken }, options);
80
+ results.push({ oldUrl, newUrl });
81
+ }
82
+ catch (error) {
83
+ if (error instanceof CommsRequestError) {
84
+ results.push({ oldUrl, error });
85
+ }
86
+ else {
87
+ throw error;
88
+ }
89
+ }
90
+ }
91
+ return results;
92
+ }
@@ -1,5 +1,19 @@
1
1
  import { setupServer } from 'msw/node';
2
- import { afterAll, afterEach, beforeAll } from 'vitest';
2
+ import { afterAll, afterEach, beforeAll, vi } from 'vitest';
3
+ // In production the transport uses undici's own `fetch` (paired with the
4
+ // dispatcher) so the request client and dispatcher stay on one undici version.
5
+ // MSW intercepts the *global* `fetch`, not that separate undici instance, so
6
+ // force the transport onto the global `fetch` for every test in the suite;
7
+ // MSW then intercepts requests as usual. The real undici transport is covered
8
+ // directly by `http-dispatcher.test.ts` and `fetch-with-retry.test.ts`, which
9
+ // opt out of this seam.
10
+ vi.mock('../transport/http-dispatcher', async (importOriginal) => {
11
+ const actual = await importOriginal();
12
+ return {
13
+ ...actual,
14
+ getDefaultTransport: vi.fn(async () => undefined),
15
+ };
16
+ });
3
17
  // Create MSW server instance
4
18
  export const server = setupServer();
5
19
  // Start server before all tests
@@ -1,7 +1,7 @@
1
1
  import { CommsRequestError } from '../types/errors.js';
2
2
  import { camelCaseKeys } from '../utils/case-conversion.js';
3
3
  import { transformTimestamps } from '../utils/timestamp-conversion.js';
4
- import { getDefaultDispatcher } from './http-dispatcher.js';
4
+ import { getDefaultTransport } from './http-dispatcher.js';
5
5
  export async function fetchWithRetry(url, options, maxRetries = 3, customFetch) {
6
6
  let lastError;
7
7
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -59,15 +59,25 @@ export async function fetchWithRetry(url, options, maxRetries = 3, customFetch)
59
59
  throw new CommsRequestError(lastError?.message ?? 'Request failed');
60
60
  }
61
61
  async function fetchWithDefaultTransport(url, options, signal) {
62
- const dispatcher = await getDefaultDispatcher();
63
- const response = dispatcher
64
- ? await fetch(url, {
62
+ // Read the dispatcher and its paired `fetch` as one value so they can never
63
+ // be mismatched. On Node, `fetch` is undici's own — paired with the
64
+ // dispatcher — so the request client and dispatcher stay on one undici
65
+ // version; otherwise the `decompress` interceptor terminates gzip responses
66
+ // when the global `fetch`'s bundled undici differs. Browser/edge (and Bun)
67
+ // have no paired `fetch` and fall back to the global one.
68
+ const transport = await getDefaultTransport();
69
+ // undici's `fetch` and the global `fetch` are the same function at runtime
70
+ // but carry different (undici vs DOM) `RequestInit`/`Response` types. Call
71
+ // through the global signature, which matches the global-typed `options`.
72
+ const fetchImpl = (transport?.fetch ?? fetch);
73
+ const response = transport?.dispatcher
74
+ ? await fetchImpl(url, {
65
75
  ...options,
66
76
  signal,
67
77
  // @ts-expect-error - dispatcher is valid for Node.js fetch but not in TS types
68
- dispatcher,
78
+ dispatcher: transport.dispatcher,
69
79
  })
70
- : await fetch(url, {
80
+ : await fetchImpl(url, {
71
81
  ...options,
72
82
  signal,
73
83
  });
@@ -1,22 +1,30 @@
1
- let defaultDispatcher;
2
- let defaultDispatcherPromise;
3
- export async function getDefaultDispatcher() {
4
- if (defaultDispatcher) {
5
- return defaultDispatcher;
1
+ let defaultTransport;
2
+ let defaultTransportPromise;
3
+ /**
4
+ * The default dispatcher and its paired `fetch`, as a single value so the two
5
+ * are always read consistently. Resolves to `undefined` outside Node (browser/
6
+ * edge), where callers use the global `fetch` with no dispatcher.
7
+ */
8
+ export async function getDefaultTransport() {
9
+ if (defaultTransport) {
10
+ return defaultTransport;
6
11
  }
7
- if (!defaultDispatcherPromise) {
8
- defaultDispatcherPromise = createDefaultDispatcher()
9
- .then((dispatcher) => {
10
- defaultDispatcher = dispatcher;
11
- return dispatcher;
12
+ if (!defaultTransportPromise) {
13
+ defaultTransportPromise = createDefaultTransport()
14
+ .then((transport) => {
15
+ defaultTransport = transport;
16
+ return transport;
12
17
  })
13
18
  .catch((error) => {
14
- defaultDispatcher = undefined;
15
- defaultDispatcherPromise = undefined;
19
+ defaultTransport = undefined;
20
+ defaultTransportPromise = undefined;
16
21
  throw error;
17
22
  });
18
23
  }
19
- return defaultDispatcherPromise;
24
+ return defaultTransportPromise;
25
+ }
26
+ export async function getDefaultDispatcher() {
27
+ return (await getDefaultTransport())?.dispatcher;
20
28
  }
21
29
  /**
22
30
  * Drains the default dispatcher's connection pool. CLIs and scripts should
@@ -25,17 +33,17 @@ export async function getDefaultDispatcher() {
25
33
  */
26
34
  export async function closeDefaultDispatcher() {
27
35
  // Clear the singleton *before* awaiting init, so any concurrent
28
- // `getDefaultDispatcher()` after this point creates a fresh dispatcher
36
+ // `getDefaultTransport()` after this point creates a fresh dispatcher
29
37
  // instead of receiving a reference to the one we're about to close.
30
- const initPromise = defaultDispatcherPromise;
31
- defaultDispatcher = undefined;
32
- defaultDispatcherPromise = undefined;
38
+ const initPromise = defaultTransportPromise;
39
+ defaultTransport = undefined;
40
+ defaultTransportPromise = undefined;
33
41
  if (!initPromise)
34
42
  return;
35
43
  try {
36
- const dispatcher = await initPromise;
37
- if (dispatcher) {
38
- await dispatcher.close();
44
+ const transport = await initPromise;
45
+ if (transport) {
46
+ await transport.dispatcher.close();
39
47
  }
40
48
  }
41
49
  catch {
@@ -43,20 +51,39 @@ export async function closeDefaultDispatcher() {
43
51
  }
44
52
  }
45
53
  export function resetDefaultDispatcherForTests() {
46
- defaultDispatcher = undefined;
47
- defaultDispatcherPromise = undefined;
54
+ defaultTransport = undefined;
55
+ defaultTransportPromise = undefined;
56
+ }
57
+ /**
58
+ * The `fetch` implementation that must be used with the default dispatcher.
59
+ * Returns undici's own `fetch` on the full-undici Node path, or `undefined`
60
+ * (meaning: use the global `fetch`) in the browser/edge/Bun paths.
61
+ *
62
+ * Node's global `fetch` is backed by whatever undici version ships inside that
63
+ * Node release (6.x on Node 22 … 8.x on Node 26). Our dispatcher — and its
64
+ * `decompress` interceptor — comes from the npm `undici` package, which is a
65
+ * different version. Handing an npm-undici dispatcher to a mismatched built-in
66
+ * client makes gzip responses fail mid-stream with `terminated`. Sourcing
67
+ * `fetch` from the same npm `undici` keeps the whole request path on one
68
+ * version and removes the split.
69
+ *
70
+ * Only meaningful after {@link getDefaultTransport} has resolved, which is the
71
+ * one place that populates it.
72
+ */
73
+ export function getDefaultFetch() {
74
+ return defaultTransport?.fetch;
48
75
  }
49
76
  function isNodeEnvironment() {
50
77
  return typeof process !== 'undefined' && typeof process.versions?.node === 'string';
51
78
  }
52
- async function createDefaultDispatcher() {
79
+ async function createDefaultTransport() {
53
80
  if (!isNodeEnvironment()) {
54
81
  return undefined;
55
82
  }
56
83
  // Dynamic import so non-Node consumers (browser, edge runtimes) don't pull
57
84
  // undici into their bundle. `isNodeEnvironment()` above already gated this
58
85
  // branch, so undici is safe to load when we get here.
59
- const { EnvHttpProxyAgent, interceptors } = await import('undici');
86
+ const { EnvHttpProxyAgent, interceptors, fetch: undiciFetch } = await import('undici');
60
87
  // `EnvHttpProxyAgent` (with `allowH2`) and `interceptors.decompress()` both
61
88
  // emit an ExperimentalWarning on first use, so build the whole dispatcher
62
89
  // inside the suppression block.
@@ -73,14 +100,21 @@ async function createDefaultDispatcher() {
73
100
  // crashing on the missing API. Optional chaining also guards a runtime
74
101
  // that omits the `interceptors` export entirely.
75
102
  if (typeof interceptors?.decompress !== 'function') {
76
- return agent;
103
+ // Bun: pair the agent with the global `fetch` (undefined), which
104
+ // decompresses natively.
105
+ return { dispatcher: agent, fetch: undefined };
77
106
  }
78
107
  // `interceptors.decompress()` decodes gzip/deflate/br/zstd bodies. On
79
108
  // Node 24+, attaching any custom dispatcher to global `fetch` strips
80
109
  // `content-encoding` without actually decompressing the body.
81
110
  // See https://github.com/Doist/todoist-cli/issues/318.
82
111
  const decompress = interceptors.decompress();
83
- return agent.compose(decompress);
112
+ // Pair undici's own `fetch` with this dispatcher so the request client
113
+ // and the dispatcher stay on one undici version (see `getDefaultFetch`).
114
+ // The global `fetch` is backed by a different, Node-bundled undici;
115
+ // mixing the two makes the decompress interceptor terminate gzip
116
+ // responses on some Node versions.
117
+ return { dispatcher: agent.compose(decompress), fetch: undiciFetch };
84
118
  });
85
119
  }
86
120
  // `suppressExperimentalWarningsSync` is exported for direct unit testing —
@@ -1,4 +1,5 @@
1
1
  export * from './authentication.js';
2
+ export * from './migration.js';
2
3
  export { AttachmentsClient } from './clients/attachments-client.js';
3
4
  export { ChannelsClient } from './clients/channels-client.js';
4
5
  export { CommentsClient } from './clients/comments-client.js';
@@ -0,0 +1,78 @@
1
+ import { CommsRequestError } from './types/errors.js';
2
+ import type { CustomFetch } from './types/http.js';
3
+ export type MigrationOptions = {
4
+ /** Optional custom base URL for the Twist migration endpoint (testing/self-host). */
5
+ baseUrl?: string;
6
+ /** Optional custom fetch implementation for cross-platform compatibility. */
7
+ customFetch?: CustomFetch;
8
+ };
9
+ export type FetchNewCommsUrlArgs = {
10
+ /** A `twist.com` URL to translate to its Comms equivalent. */
11
+ oldUrl: string;
12
+ /** A Twist (not Comms) auth token. */
13
+ twistToken: string;
14
+ };
15
+ export type FetchNewCommsUrlsArgs = {
16
+ /** The `twist.com` URLs to translate to their Comms equivalents. */
17
+ oldUrls: string[];
18
+ /** A Twist (not Comms) auth token. */
19
+ twistToken: string;
20
+ };
21
+ /**
22
+ * Result of translating a single URL in a batch migration. Discriminated by the
23
+ * presence of `newUrl` (success) or `error` (failure).
24
+ */
25
+ export type MigrationResult = {
26
+ oldUrl: string;
27
+ newUrl: string;
28
+ error?: undefined;
29
+ } | {
30
+ oldUrl: string;
31
+ newUrl?: undefined;
32
+ error: CommsRequestError;
33
+ };
34
+ /**
35
+ * Translates a Twist URL to its equivalent Comms URL using Twist's
36
+ * `comms_migration/fetch_new_url` endpoint.
37
+ *
38
+ * Authenticates with a **Twist** token (external to the Comms SDK), so this is a
39
+ * standalone helper rather than a method on {@link CommsApi}.
40
+ *
41
+ * @example
42
+ * ```typescript
43
+ * const newUrl = await fetchNewCommsUrl({
44
+ * oldUrl: 'https://twist.com/a/123/ch/456/t/789',
45
+ * twistToken: process.env.TWIST_AUTH_TOKEN,
46
+ * })
47
+ * ```
48
+ *
49
+ * @returns The equivalent Comms URL.
50
+ * @throws {@link CommsRequestError} If the request fails. URLs that cannot be
51
+ * migrated surface as a thrown error too: `400` with `responseData.error.code`
52
+ * of `'invalid_url'`, or `404` with `'not_imported'`.
53
+ */
54
+ export declare function fetchNewCommsUrl(args: FetchNewCommsUrlArgs, options?: MigrationOptions): Promise<string>;
55
+ /**
56
+ * Translates multiple Twist URLs to their equivalent Comms URLs.
57
+ *
58
+ * URLs are processed **sequentially** to stay friendly to the rate-sensitive
59
+ * migration endpoint. A failure on one URL does not abort the run: each result
60
+ * carries either a `newUrl` (success) or a `CommsRequestError` (failure), in the
61
+ * same order as the input.
62
+ *
63
+ * @example
64
+ * ```typescript
65
+ * const results = await fetchNewCommsUrls({
66
+ * oldUrls: ['https://twist.com/a/1/ch/2/t/3', 'https://twist.com/bad'],
67
+ * twistToken: process.env.TWIST_AUTH_TOKEN,
68
+ * })
69
+ * for (const result of results) {
70
+ * if (result.newUrl) console.log(`${result.oldUrl} -> ${result.newUrl}`)
71
+ * else console.warn(`${result.oldUrl} failed`, result.error)
72
+ * }
73
+ * ```
74
+ *
75
+ * @returns One {@link MigrationResult} per input URL, in input order.
76
+ * @throws Re-throws any error that is not a {@link CommsRequestError}.
77
+ */
78
+ export declare function fetchNewCommsUrls(args: FetchNewCommsUrlsArgs, options?: MigrationOptions): Promise<MigrationResult[]>;
@@ -1,4 +1,22 @@
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>;
3
21
  /**
4
22
  * Drains the default dispatcher's connection pool. CLIs and scripts should
@@ -7,4 +25,22 @@ export declare function getDefaultDispatcher(): Promise<Dispatcher | undefined>;
7
25
  */
8
26
  export declare function closeDefaultDispatcher(): Promise<void>;
9
27
  export declare function resetDefaultDispatcherForTests(): void;
28
+ /**
29
+ * The `fetch` implementation that must be used with the default dispatcher.
30
+ * Returns undici's own `fetch` on the full-undici Node path, or `undefined`
31
+ * (meaning: use the global `fetch`) in the browser/edge/Bun paths.
32
+ *
33
+ * Node's global `fetch` is backed by whatever undici version ships inside that
34
+ * Node release (6.x on Node 22 … 8.x on Node 26). Our dispatcher — and its
35
+ * `decompress` interceptor — comes from the npm `undici` package, which is a
36
+ * different version. Handing an npm-undici dispatcher to a mismatched built-in
37
+ * client makes gzip responses fail mid-stream with `terminated`. Sourcing
38
+ * `fetch` from the same npm `undici` keeps the whole request path on one
39
+ * version and removes the split.
40
+ *
41
+ * Only meaningful after {@link getDefaultTransport} has resolved, which is the
42
+ * one place that populates it.
43
+ */
44
+ export declare function getDefaultFetch(): UndiciFetch | undefined;
10
45
  export declare function suppressExperimentalWarningsSync<T>(fn: () => T): T;
46
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/comms-sdk",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
4
4
  "description": "A TypeScript wrapper for the Comms REST API.",
5
5
  "author": "Doist developers",
6
6
  "homepage": "https://doist.github.io/comms-sdk-typescript/",