@doist/comms-sdk 0.7.0 → 0.8.0

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.
@@ -3,38 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.addCommentRequest = addCommentRequest;
4
4
  const endpoints_1 = require("../consts/endpoints");
5
5
  const http_client_1 = require("../transport/http-client");
6
- const enums_1 = require("../types/enums");
7
6
  const uuidv7_1 = require("../utils/uuidv7");
8
- const SENTINEL_GROUP_IDS = new Set(Object.values(enums_1.NOTIFY_AUDIENCE_GROUP_IDS));
9
- function isNotifyAudience(value) {
10
- return typeof value === 'string' && enums_1.NOTIFY_AUDIENCES.includes(value);
11
- }
12
- function collectMarkerOffenses(field, values) {
13
- if (!values)
14
- return null;
15
- const offending = values.filter((id) => SENTINEL_GROUP_IDS.has(id));
16
- return offending.length > 0 ? { field, offending } : null;
17
- }
18
- function applyNotifyAudience(params) {
19
- const offenses = [
20
- collectMarkerOffenses('groups', params.groups ?? undefined),
21
- collectMarkerOffenses('directGroupMentions', params.directGroupMentions ?? undefined),
22
- ].filter((o) => o !== null);
23
- if (offenses.length > 0) {
24
- const details = offenses
25
- .map(({ field, offending }) => `\`${field}\` contains ${offending.join(', ')}`)
26
- .join('; ');
27
- throw new Error(`Reserved broadcast marker IDs found: ${details}. Pass these via \`notifyAudience\` on createComment / closeThread / reopenThread (e.g. \`notifyAudience: 'channel'\` for EVERYONE) instead of populating \`groups\` / \`directGroupMentions\` directly.`);
28
- }
29
- if (params.notifyAudience == null)
30
- return params;
31
- if (!isNotifyAudience(params.notifyAudience)) {
32
- throw new Error(`Invalid \`notifyAudience\` value "${String(params.notifyAudience)}". Expected one of: ${enums_1.NOTIFY_AUDIENCES.join(', ')}.`);
33
- }
34
- const sentinel = enums_1.NOTIFY_AUDIENCE_GROUP_IDS[params.notifyAudience];
35
- const { notifyAudience: _stripped, groups, ...rest } = params;
36
- return { ...rest, groups: [...(groups ?? []), sentinel] };
37
- }
7
+ const notify_audience_1 = require("./notify-audience");
38
8
  /**
39
9
  * Internal helper that powers `comments.createComment`,
40
10
  * `threads.closeThread`, and `threads.reopenThread`.
@@ -53,7 +23,7 @@ function applyNotifyAudience(params) {
53
23
  * @returns The parsed {@link Comment} returned by the API.
54
24
  */
55
25
  function addCommentRequest(context, params, options) {
56
- const normalized = applyNotifyAudience(params);
26
+ const normalized = (0, notify_audience_1.applyNotifyAudience)(params);
57
27
  const withId = { ...normalized, id: (0, uuidv7_1.resolveCreateId)(normalized.id) };
58
28
  const payload = options?.threadAction
59
29
  ? { ...withId, threadAction: options.threadAction }
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applyNotifyAudience = applyNotifyAudience;
4
+ const enums_1 = require("../types/enums");
5
+ const SENTINEL_GROUP_IDS = new Set(Object.values(enums_1.NOTIFY_AUDIENCE_GROUP_IDS));
6
+ function isNotifyAudience(value) {
7
+ return typeof value === 'string' && enums_1.NOTIFY_AUDIENCES.includes(value);
8
+ }
9
+ function collectMarkerOffenses(field, values) {
10
+ if (!values)
11
+ return null;
12
+ const offending = values.filter((id) => SENTINEL_GROUP_IDS.has(id));
13
+ return offending.length > 0 ? { field, offending } : null;
14
+ }
15
+ /**
16
+ * Normalizes a `notifyAudience` flag into a sentinel `groups` entry
17
+ * (`EVERYONE` / `EVERYONE_IN_THREAD`) and rejects callers who put those
18
+ * reserved markers directly into `groups` / `directGroupMentions`.
19
+ *
20
+ * When `notifyAudience` is set it is stripped and its sentinel is appended to
21
+ * `groups`; the backend only understands the marker in `groups`. When it is
22
+ * unset the params are returned unchanged, preserving any explicit
23
+ * `groups` / `directGroupMentions` (including a `null`) on the wire.
24
+ */
25
+ function applyNotifyAudience(params) {
26
+ const offenses = [
27
+ collectMarkerOffenses('groups', params.groups ?? undefined),
28
+ collectMarkerOffenses('directGroupMentions', params.directGroupMentions ?? undefined),
29
+ ].filter((o) => o !== null);
30
+ if (offenses.length > 0) {
31
+ const details = offenses
32
+ .map(({ field, offending }) => `\`${field}\` contains ${offending.join(', ')}`)
33
+ .join('; ');
34
+ throw new Error(`Reserved broadcast marker IDs found: ${details}. Pass these via \`notifyAudience\` (e.g. \`notifyAudience: 'channel'\` for EVERYONE) instead of populating \`groups\` / \`directGroupMentions\` directly.`);
35
+ }
36
+ if (params.notifyAudience == null) {
37
+ return params;
38
+ }
39
+ if (!isNotifyAudience(params.notifyAudience)) {
40
+ throw new Error(`Invalid \`notifyAudience\` value "${String(params.notifyAudience)}". Expected one of: ${enums_1.NOTIFY_AUDIENCES.join(', ')}.`);
41
+ }
42
+ const sentinel = enums_1.NOTIFY_AUDIENCE_GROUP_IDS[params.notifyAudience];
43
+ const { notifyAudience: _stripped, groups, ...rest } = params;
44
+ return { ...rest, groups: [...(groups ?? []), sentinel] };
45
+ }
@@ -8,6 +8,7 @@ const entities_1 = require("../types/entities");
8
8
  const uuidv7_1 = require("../utils/uuidv7");
9
9
  const add_comment_helper_1 = require("./add-comment-helper");
10
10
  const base_client_1 = require("./base-client");
11
+ const notify_audience_1 = require("./notify-audience");
11
12
  exports.ThreadListSchema = zod_1.z.array(entities_1.ThreadSchema);
12
13
  const GetUnreadResponseSchema = zod_1.z.object({
13
14
  data: zod_1.z.array(entities_1.UnreadThreadSchema),
@@ -95,6 +96,11 @@ class ThreadsClient extends base_client_1.BaseClient {
95
96
  * @param args.content - The thread content.
96
97
  * @param args.recipients - Optional array of user IDs to notify.
97
98
  * @param args.groups - Optional array of custom group IDs to notify.
99
+ * @param args.notifyAudience - Optional broader audience to notify in addition to
100
+ * `recipients` and `groups`. `'channel'` notifies everyone in the channel;
101
+ * `'thread'` notifies everyone who has interacted with the thread — but a
102
+ * brand-new thread has no interactions yet, so the backend discards
103
+ * `'thread'` at create time. It is only meaningful on replies (`createComment`).
98
104
  * @param args.attachments - Optional array of {@link Attachment}s (from `attachments.upload`).
99
105
  * @returns The created thread object.
100
106
  *
@@ -104,11 +110,13 @@ class ThreadsClient extends base_client_1.BaseClient {
104
110
  * channelId: '7YpL3oZ4kZ9vP7Q1tR2sX44',
105
111
  * title: 'New Feature Discussion',
106
112
  * content: 'Let\'s discuss the new feature...',
113
+ * notifyAudience: 'channel', // tag "Everyone in channel"
107
114
  * })
108
115
  * ```
109
116
  */
110
117
  createThread(args) {
111
- return this.simple('POST', 'add', { ...args, id: (0, uuidv7_1.resolveCreateId)(args.id) }, this.threadSchema);
118
+ const normalized = (0, notify_audience_1.applyNotifyAudience)(args);
119
+ return this.simple('POST', 'add', { ...normalized, id: (0, uuidv7_1.resolveCreateId)(args.id) }, this.threadSchema);
112
120
  }
113
121
  /**
114
122
  * Partial update of an existing thread.
@@ -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 —
@@ -42,6 +42,7 @@ exports.CreateThreadArgsSchema = zod_1.z.object({
42
42
  id: zod_1.z.string().optional(),
43
43
  recipients: zod_1.z.array(zod_1.z.number()).nullable().optional(),
44
44
  groups: zod_1.z.array(zod_1.z.string()).nullable().optional(),
45
+ notifyAudience: zod_1.z.enum(enums_1.NOTIFY_AUDIENCES).nullable().optional(),
45
46
  attachments: zod_1.z.array(entities_1.RequestAttachmentSchema).nullable().optional(),
46
47
  });
47
48
  exports.UpdateThreadArgsSchema = zod_1.z.object({
@@ -1,37 +1,7 @@
1
1
  import { ENDPOINT_COMMENTS } from '../consts/endpoints.js';
2
2
  import { request } from '../transport/http-client.js';
3
- import { NOTIFY_AUDIENCE_GROUP_IDS, NOTIFY_AUDIENCES } from '../types/enums.js';
4
3
  import { resolveCreateId } from '../utils/uuidv7.js';
5
- const SENTINEL_GROUP_IDS = new Set(Object.values(NOTIFY_AUDIENCE_GROUP_IDS));
6
- function isNotifyAudience(value) {
7
- return typeof value === 'string' && NOTIFY_AUDIENCES.includes(value);
8
- }
9
- function collectMarkerOffenses(field, values) {
10
- if (!values)
11
- return null;
12
- const offending = values.filter((id) => SENTINEL_GROUP_IDS.has(id));
13
- return offending.length > 0 ? { field, offending } : null;
14
- }
15
- function applyNotifyAudience(params) {
16
- const offenses = [
17
- collectMarkerOffenses('groups', params.groups ?? undefined),
18
- collectMarkerOffenses('directGroupMentions', params.directGroupMentions ?? undefined),
19
- ].filter((o) => o !== null);
20
- if (offenses.length > 0) {
21
- const details = offenses
22
- .map(({ field, offending }) => `\`${field}\` contains ${offending.join(', ')}`)
23
- .join('; ');
24
- throw new Error(`Reserved broadcast marker IDs found: ${details}. Pass these via \`notifyAudience\` on createComment / closeThread / reopenThread (e.g. \`notifyAudience: 'channel'\` for EVERYONE) instead of populating \`groups\` / \`directGroupMentions\` directly.`);
25
- }
26
- if (params.notifyAudience == null)
27
- return params;
28
- if (!isNotifyAudience(params.notifyAudience)) {
29
- throw new Error(`Invalid \`notifyAudience\` value "${String(params.notifyAudience)}". Expected one of: ${NOTIFY_AUDIENCES.join(', ')}.`);
30
- }
31
- const sentinel = NOTIFY_AUDIENCE_GROUP_IDS[params.notifyAudience];
32
- const { notifyAudience: _stripped, groups, ...rest } = params;
33
- return { ...rest, groups: [...(groups ?? []), sentinel] };
34
- }
4
+ import { applyNotifyAudience } from './notify-audience.js';
35
5
  /**
36
6
  * Internal helper that powers `comments.createComment`,
37
7
  * `threads.closeThread`, and `threads.reopenThread`.
@@ -0,0 +1,42 @@
1
+ import { NOTIFY_AUDIENCE_GROUP_IDS, NOTIFY_AUDIENCES } from '../types/enums.js';
2
+ const SENTINEL_GROUP_IDS = new Set(Object.values(NOTIFY_AUDIENCE_GROUP_IDS));
3
+ function isNotifyAudience(value) {
4
+ return typeof value === 'string' && NOTIFY_AUDIENCES.includes(value);
5
+ }
6
+ function collectMarkerOffenses(field, values) {
7
+ if (!values)
8
+ return null;
9
+ const offending = values.filter((id) => SENTINEL_GROUP_IDS.has(id));
10
+ return offending.length > 0 ? { field, offending } : null;
11
+ }
12
+ /**
13
+ * Normalizes a `notifyAudience` flag into a sentinel `groups` entry
14
+ * (`EVERYONE` / `EVERYONE_IN_THREAD`) and rejects callers who put those
15
+ * reserved markers directly into `groups` / `directGroupMentions`.
16
+ *
17
+ * When `notifyAudience` is set it is stripped and its sentinel is appended to
18
+ * `groups`; the backend only understands the marker in `groups`. When it is
19
+ * unset the params are returned unchanged, preserving any explicit
20
+ * `groups` / `directGroupMentions` (including a `null`) on the wire.
21
+ */
22
+ export function applyNotifyAudience(params) {
23
+ const offenses = [
24
+ collectMarkerOffenses('groups', params.groups ?? undefined),
25
+ collectMarkerOffenses('directGroupMentions', params.directGroupMentions ?? undefined),
26
+ ].filter((o) => o !== null);
27
+ if (offenses.length > 0) {
28
+ const details = offenses
29
+ .map(({ field, offending }) => `\`${field}\` contains ${offending.join(', ')}`)
30
+ .join('; ');
31
+ throw new Error(`Reserved broadcast marker IDs found: ${details}. Pass these via \`notifyAudience\` (e.g. \`notifyAudience: 'channel'\` for EVERYONE) instead of populating \`groups\` / \`directGroupMentions\` directly.`);
32
+ }
33
+ if (params.notifyAudience == null) {
34
+ return params;
35
+ }
36
+ if (!isNotifyAudience(params.notifyAudience)) {
37
+ throw new Error(`Invalid \`notifyAudience\` value "${String(params.notifyAudience)}". Expected one of: ${NOTIFY_AUDIENCES.join(', ')}.`);
38
+ }
39
+ const sentinel = NOTIFY_AUDIENCE_GROUP_IDS[params.notifyAudience];
40
+ const { notifyAudience: _stripped, groups, ...rest } = params;
41
+ return { ...rest, groups: [...(groups ?? []), sentinel] };
42
+ }
@@ -5,6 +5,7 @@ import { CommentSchema, createCommentSchema, createThreadSchema, StatusOkSchema,
5
5
  import { resolveCreateId } from '../utils/uuidv7.js';
6
6
  import { addCommentRequest } from './add-comment-helper.js';
7
7
  import { BaseClient } from './base-client.js';
8
+ import { applyNotifyAudience } from './notify-audience.js';
8
9
  export const ThreadListSchema = z.array(ThreadSchema);
9
10
  const GetUnreadResponseSchema = z.object({
10
11
  data: z.array(UnreadThreadSchema),
@@ -92,6 +93,11 @@ export class ThreadsClient extends BaseClient {
92
93
  * @param args.content - The thread content.
93
94
  * @param args.recipients - Optional array of user IDs to notify.
94
95
  * @param args.groups - Optional array of custom group IDs to notify.
96
+ * @param args.notifyAudience - Optional broader audience to notify in addition to
97
+ * `recipients` and `groups`. `'channel'` notifies everyone in the channel;
98
+ * `'thread'` notifies everyone who has interacted with the thread — but a
99
+ * brand-new thread has no interactions yet, so the backend discards
100
+ * `'thread'` at create time. It is only meaningful on replies (`createComment`).
95
101
  * @param args.attachments - Optional array of {@link Attachment}s (from `attachments.upload`).
96
102
  * @returns The created thread object.
97
103
  *
@@ -101,11 +107,13 @@ export class ThreadsClient extends BaseClient {
101
107
  * channelId: '7YpL3oZ4kZ9vP7Q1tR2sX44',
102
108
  * title: 'New Feature Discussion',
103
109
  * content: 'Let\'s discuss the new feature...',
110
+ * notifyAudience: 'channel', // tag "Everyone in channel"
104
111
  * })
105
112
  * ```
106
113
  */
107
114
  createThread(args) {
108
- return this.simple('POST', 'add', { ...args, id: resolveCreateId(args.id) }, this.threadSchema);
115
+ const normalized = applyNotifyAudience(args);
116
+ return this.simple('POST', 'add', { ...normalized, id: resolveCreateId(args.id) }, this.threadSchema);
109
117
  }
110
118
  /**
111
119
  * Partial update of an existing thread.
@@ -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 —
@@ -39,6 +39,7 @@ export const CreateThreadArgsSchema = z.object({
39
39
  id: z.string().optional(),
40
40
  recipients: z.array(z.number()).nullable().optional(),
41
41
  groups: z.array(z.string()).nullable().optional(),
42
+ notifyAudience: z.enum(NOTIFY_AUDIENCES).nullable().optional(),
42
43
  attachments: z.array(RequestAttachmentSchema).nullable().optional(),
43
44
  });
44
45
  export const UpdateThreadArgsSchema = z.object({
@@ -0,0 +1,22 @@
1
+ import { type NotifyAudience } from '../types/enums.js';
2
+ /**
3
+ * Params bearing the group-targeting fields that can carry a `notifyAudience`.
4
+ * Shared by the comment-creating endpoints and thread creation.
5
+ */
6
+ type NotifyAudienceParams = {
7
+ groups?: string[] | null;
8
+ directGroupMentions?: string[] | null;
9
+ notifyAudience?: NotifyAudience | null;
10
+ };
11
+ /**
12
+ * Normalizes a `notifyAudience` flag into a sentinel `groups` entry
13
+ * (`EVERYONE` / `EVERYONE_IN_THREAD`) and rejects callers who put those
14
+ * reserved markers directly into `groups` / `directGroupMentions`.
15
+ *
16
+ * When `notifyAudience` is set it is stripped and its sentinel is appended to
17
+ * `groups`; the backend only understands the marker in `groups`. When it is
18
+ * unset the params are returned unchanged, preserving any explicit
19
+ * `groups` / `directGroupMentions` (including a `null`) on the wire.
20
+ */
21
+ export declare function applyNotifyAudience<T extends NotifyAudienceParams>(params: T): Omit<T, 'notifyAudience'>;
22
+ export {};
@@ -336,6 +336,11 @@ export declare class ThreadsClient extends BaseClient {
336
336
  * @param args.content - The thread content.
337
337
  * @param args.recipients - Optional array of user IDs to notify.
338
338
  * @param args.groups - Optional array of custom group IDs to notify.
339
+ * @param args.notifyAudience - Optional broader audience to notify in addition to
340
+ * `recipients` and `groups`. `'channel'` notifies everyone in the channel;
341
+ * `'thread'` notifies everyone who has interacted with the thread — but a
342
+ * brand-new thread has no interactions yet, so the backend discards
343
+ * `'thread'` at create time. It is only meaningful on replies (`createComment`).
339
344
  * @param args.attachments - Optional array of {@link Attachment}s (from `attachments.upload`).
340
345
  * @returns The created thread object.
341
346
  *
@@ -345,6 +350,7 @@ export declare class ThreadsClient extends BaseClient {
345
350
  * channelId: '7YpL3oZ4kZ9vP7Q1tR2sX44',
346
351
  * title: 'New Feature Discussion',
347
352
  * content: 'Let\'s discuss the new feature...',
353
+ * notifyAudience: 'channel', // tag "Everyone in channel"
348
354
  * })
349
355
  * ```
350
356
  */
@@ -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 {};
@@ -40,6 +40,10 @@ export declare const CreateThreadArgsSchema: z.ZodObject<{
40
40
  id: z.ZodOptional<z.ZodString>;
41
41
  recipients: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodNumber>>>;
42
42
  groups: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodString>>>;
43
+ notifyAudience: z.ZodOptional<z.ZodNullable<z.ZodEnum<{
44
+ channel: "channel";
45
+ thread: "thread";
46
+ }>>>;
43
47
  attachments: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
44
48
  attachmentId: z.ZodString;
45
49
  urlType: z.ZodString;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@doist/comms-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
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/",