Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,28 @@ const api = new CommsApi(tokenResponse.accessToken)
const user = await api.users.getSessionUser()
```

### Short-lived processes (CLIs, scripts)

On Node, the SDK keeps a connection pool alive across requests so HTTP/2
multiplexing and TLS reuse actually work. Long-running processes don't
need to think about it. **Short-lived processes should `await api.close()`
before exit**, otherwise Node's event loop waits ~4 seconds for idle
sockets to time out:

```typescript
const api = new CommsApi('YOUR_API_TOKEN')
try {
await api.users.getSessionUser()
} finally {
await api.close()
}
```

`api.close()` drains the process-global pool, so it also covers code
paths that only use the standalone OAuth helpers (`getAuthToken`,
`revokeAuthToken`, `registerClient`). Those flows can also import
`closeDefaultDispatcher` directly.

## Development

- `npm install`
Expand Down
12 changes: 12 additions & 0 deletions src/comms-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ThreadsClient } from './clients/threads-client'
import { UsersClient } from './clients/users-client'
import { WorkspaceUsersClient } from './clients/workspace-users-client'
import { WorkspacesClient } from './clients/workspaces-client'
import { closeDefaultDispatcher } from './transport/http-dispatcher'
import type { CustomFetch } from './types/http'

export type CommsApiOptions = {
Expand Down Expand Up @@ -70,4 +71,15 @@ export class CommsApi {
this.reactions = new ReactionsClient(clientConfig)
this.search = new SearchClient(clientConfig)
}

/**
* Drains the SDK's process-global connection pool. CLIs and scripts
* should `await api.close()` before exit so Node's event loop empties
* immediately instead of waiting ~4s on keep-alive. Affects every
* `CommsApi` and OAuth helper in the same process — it's a
* process-shutdown gesture, not an instance teardown. Browser-safe.
*/
async close(): Promise<void> {
Comment thread
amix marked this conversation as resolved.
await closeDefaultDispatcher()
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ export { WorkspaceUsersClient } from './clients/workspace-users-client'
export { WorkspacesClient } from './clients/workspaces-client'
export { CommsApi } from './comms-api'
export type { CommsApiOptions } from './comms-api'
export { closeDefaultDispatcher } from './transport/http-dispatcher'
export * from './types/index'
export * from './utils/index'
28 changes: 28 additions & 0 deletions src/transport/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createServer, type Server } from 'node:http'
import type { AddressInfo } from 'node:net'
import { gzipSync } from 'node:zlib'
import {
closeDefaultDispatcher,
getDefaultDispatcher,
resetDefaultDispatcherForTests,
suppressExperimentalWarningsSync,
Expand Down Expand Up @@ -138,6 +139,33 @@ describe('httpDispatcher', () => {
await new Promise<void>((resolve) => httpServer.close(() => resolve()))
}
})

it('close() invokes the dispatcher close and lets a new one be created', async () => {
Comment thread
amix marked this conversation as resolved.
const closeCalls: number[] = []

vi.doMock('undici', () => ({
EnvHttpProxyAgent: class {
compose() {
return this
}
async close() {
closeCalls.push(1)
}
},
interceptors: {
decompress: () => (dispatch: unknown) => dispatch,
},
}))

const first = await getDefaultDispatcher()
expect(first).toBeDefined()

await closeDefaultDispatcher()
expect(closeCalls).toHaveLength(1)

const second = await getDefaultDispatcher()
expect(second).not.toBe(first)
})
})

describe('suppressExperimentalWarningsSync', () => {
Expand Down
66 changes: 42 additions & 24 deletions src/transport/http-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,5 @@
import type { Dispatcher } from 'undici'

// Use effectively-disabled keep-alive so short-lived CLI processes do not stay
// open waiting on idle sockets. Undici requires positive values, so we use 1ms.
const keepAliveOptions = {
keepAliveTimeout: 1,
keepAliveMaxTimeout: 1,
}

let defaultDispatcher: Dispatcher | undefined
let defaultDispatcherPromise: Promise<Dispatcher | undefined> | undefined

Expand All @@ -31,6 +24,31 @@ export async function getDefaultDispatcher(): Promise<Dispatcher | undefined> {
return defaultDispatcherPromise
}

/**
* Drains the default dispatcher's connection pool. CLIs and scripts should
* `await` this before exit so Node's event loop empties immediately instead
* of waiting ~4s on keep-alive. No-op in the browser branch.
*/
export async function closeDefaultDispatcher(): Promise<void> {
// Clear the singleton *before* awaiting init, so any concurrent
// `getDefaultDispatcher()` after this point creates a fresh dispatcher
// instead of receiving a reference to the one we're about to close.
const initPromise = defaultDispatcherPromise
defaultDispatcher = undefined
defaultDispatcherPromise = undefined

if (!initPromise) return

try {
const dispatcher = await initPromise
if (dispatcher) {
await dispatcher.close()
}
} catch {
// init failed; nothing to close
}
}

export function resetDefaultDispatcherForTests(): void {
defaultDispatcher = undefined
defaultDispatcherPromise = undefined
Expand All @@ -50,33 +68,33 @@ async function createDefaultDispatcher(): Promise<Dispatcher | undefined> {
// branch, so undici is safe to load when we get here.
const { EnvHttpProxyAgent, interceptors } = await import('undici')

// Compose the response-decompression interceptor so gzip/deflate/br/zstd
// bodies are decoded before consumers parse them. Required on Node 24+:
// attaching any custom dispatcher to the global `fetch` strips the
// `content-encoding` header but does not actually decompress the body,
// so callers receive raw gzipped bytes and `JSON.parse` fails.
// `allowH2: true` opts into HTTP/2 via ALPN; undici falls back to h1.1
// when the server doesn't negotiate h2. Without this flag undici
// defaults to h1.1 even when the server supports h2.
//
// `interceptors.decompress()` decodes gzip/deflate/br/zstd bodies. On
// Node 24+, attaching any custom dispatcher to global `fetch` strips
// `content-encoding` without actually decompressing the body.
// See https://github.com/Doist/todoist-cli/issues/318.
const decompress = suppressExperimentalWarningsSync(() => interceptors.decompress())

return new EnvHttpProxyAgent(keepAliveOptions).compose(decompress)
//
// Both emit ExperimentalWarning on first use; suppressed during init.
return suppressExperimentalWarningsSync(() => {
const decompress = interceptors.decompress()
return new EnvHttpProxyAgent({ allowH2: true }).compose(decompress)
})
}

// undici emits an `ExperimentalWarning` the first time `interceptors.decompress()`
// runs. The interceptor is stable for our gzipped-JSON-over-HTTPS use case;
// silence the warning during dispatcher init only so it does not leak to every
// consumer's stderr on the first request.
// `suppressExperimentalWarningsSync` is exported for direct unit testing —
// the integration path through `getDefaultDispatcher()` can't reliably
// exercise it because both the dispatcher singleton and undici's internal
// `warningEmitted` flag are once-per-process.
//
// `fn` must be synchronous so the override covers a single critical section
// (microseconds) — no unrelated `ExperimentalWarning` from elsewhere can
// interleave and be lost. We suppress every `ExperimentalWarning` rather than
// pattern-matching the message text: the message wording is an undici
// implementation detail (not a stable API), and the suppression window is
// narrow enough that a coarse type filter is safe.
//
// Exported for direct unit testing — the integration path through
// `getDefaultDispatcher()` cannot reliably exercise the helper because both
// the dispatcher singleton and undici's internal `warningEmitted` flag are
// once-per-process.
export function suppressExperimentalWarningsSync<T>(fn: () => T): T {
const originalEmit = process.emitWarning
process.emitWarning = ((
Expand Down
Loading