imapflow 1.5.0 → 1.6.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.
Files changed (36) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +15 -0
  3. package/CLAUDE.md +1 -1
  4. package/lib/commands/idle.js +197 -104
  5. package/lib/commands/list.js +15 -7
  6. package/lib/commands/quota.js +3 -0
  7. package/lib/commands/select.js +5 -0
  8. package/lib/commands/status.js +4 -0
  9. package/lib/connection-deadline.js +98 -0
  10. package/lib/handler/imap-compiler.js +8 -5
  11. package/lib/handler/imap-stream.js +141 -50
  12. package/lib/handler/limits.js +43 -0
  13. package/lib/handler/token-parser.js +31 -1
  14. package/lib/imap-flow.d.ts +33 -5
  15. package/lib/imap-flow.js +575 -281
  16. package/lib/proxy-connection.js +393 -98
  17. package/lib/special-use.js +660 -51
  18. package/lib/tools.js +17 -0
  19. package/package.json +2 -2
  20. package/test/commands-branches-test.js +17 -1
  21. package/test/commands-integration-test.js +24 -2
  22. package/test/fixtures/fake-timers.js +115 -0
  23. package/test/handler-branches-test.js +0 -25
  24. package/test/idle-polling-test.js +349 -0
  25. package/test/imap-flow-compress-test.js +12 -0
  26. package/test/imap-flow-coverage-test.js +3 -3
  27. package/test/imap-flow-internals-test.js +23 -0
  28. package/test/imap-flow-proxy-paths-test.js +151 -0
  29. package/test/imap-flow-secure-test.js +159 -0
  30. package/test/imap-flow-server-test.js +149 -0
  31. package/test/parser-limits-test.js +274 -0
  32. package/test/proxy-connection-test.js +553 -442
  33. package/test/reliability-improvements-test.js +87 -0
  34. package/test/special-use-test.js +337 -0
  35. package/test/tag-correlation-test.js +333 -0
  36. package/test/timer-policy-test.js +214 -0
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "1.5.0"
2
+ ".": "1.6.0"
3
3
  }
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.6.0](https://github.com/postalsys/imapflow/compare/v1.5.0...v1.6.0) (2026-07-27)
4
+
5
+
6
+ ### Features
7
+
8
+ * expand localized special-use folder names from client localization catalogs ([e766c41](https://github.com/postalsys/imapflow/commit/e766c411a71bc2e6578017a9029050e5026ca9de))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * add missing localized special-use folder names and normalize to NFC ([1951438](https://github.com/postalsys/imapflow/commit/19514381a05d519ced1ab52716aa2256f7be169a))
14
+ * declare specialUseHints.archive and specialUseSource in the types ([a372a50](https://github.com/postalsys/imapflow/commit/a372a50a0365b202d1c8d144663f8280ee63214c))
15
+ * harden response parsing, tag correlation, IDLE polling and proxy connection setup ([21fe354](https://github.com/postalsys/imapflow/commit/21fe3541e9eff24060f1ef61f668fdfb5ae18115))
16
+ * match decorated folder names and normalize special use names to NFKC ([8f3bed6](https://github.com/postalsys/imapflow/commit/8f3bed6fcd0daca48ad4ab8446fc1581792fb754))
17
+
3
18
  ## [1.5.0](https://github.com/postalsys/imapflow/compare/v1.4.9...v1.5.0) (2026-07-23)
4
19
 
5
20
 
package/CLAUDE.md CHANGED
@@ -28,7 +28,7 @@ npm as `imapflow` and ships TypeScript type definitions.
28
28
  - **Module system**: CommonJS (see Packaging Constraints below)
29
29
  - **Testing**: Grunt + grunt-contrib-nodeunit, ESLint via grunt-eslint
30
30
  - **Lint/format**: ESLint (`eslint.config.js`, flat config) + Prettier
31
- - **Key dependencies**: `@zone-eu/mailsplit`, `libmime`, `libqp`, `libbase64`, `iconv-lite`, `encoding-japanese`, `nodemailer`, `pino`, `socks`
31
+ - **Key dependencies**: `@zone-eu/mailsplit`, `libmime`, `libqp`, `libbase64`, `iconv-lite`, `encoding-japanese`, `pino`, `socks`
32
32
 
33
33
  ## Development Commands
34
34
 
@@ -1,9 +1,32 @@
1
1
  'use strict';
2
2
 
3
- const { hasCapability } = require('../tools.js');
3
+ const { hasCapability, unrefTimer } = require('../tools.js');
4
4
 
5
5
  const NOOP_INTERVAL = 2 * 60 * 1000;
6
6
 
7
+ /**
8
+ * Marks the connection as idling on behalf of one session and returns a release function.
9
+ *
10
+ * Both IDLE modes use this so ownership of the shared `idling` flag is explicit: a session that
11
+ * finishes late (a poll that completes after cancellation, an IDLE that unwinds after a restart)
12
+ * cannot clear the flag of the session that has since taken over.
13
+ *
14
+ * @param {Object} connection - IMAP connection instance
15
+ * @returns {Function} Release function, safe to call more than once
16
+ */
17
+ function claimIdling(connection) {
18
+ let token = {};
19
+ connection._idleSession = token;
20
+ connection.idling = true;
21
+
22
+ return () => {
23
+ if (connection._idleSession === token) {
24
+ connection._idleSession = null;
25
+ connection.idling = false;
26
+ }
27
+ };
28
+ }
29
+
7
30
  /**
8
31
  * Runs a single IDLE session on the connection.
9
32
  *
@@ -16,9 +39,13 @@ async function runIdle(connection) {
16
39
  // Queue of promises waiting for IDLE to break. When another command needs to run,
17
40
  // it calls connection.preCheck() which queues a promise here and sends DONE to break IDLE.
18
41
  let preCheckWaitQueue = [];
19
- try {
20
- connection.idling = true;
21
42
 
43
+ // The preCheck function this session owns. Only this session may clear it from the
44
+ // connection, so a newer IDLE session that already installed its own is left alone.
45
+ let ownPreCheck = null;
46
+ let releaseIdling = claimIdling(connection);
47
+
48
+ try {
22
49
  // State flags for the IDLE lifecycle:
23
50
  // - doneRequested: someone wants to break IDLE (e.g., to run another command)
24
51
  // - doneSent: we've already sent the DONE command to server
@@ -42,8 +69,10 @@ async function runIdle(connection) {
42
69
  connection.write('DONE');
43
70
  doneSent = true;
44
71
 
45
- connection.idling = false;
46
- connection.preCheck = false; // unset itself
72
+ releaseIdling();
73
+ if (connection.preCheck === ownPreCheck) {
74
+ connection.preCheck = false; // unset itself
75
+ }
47
76
 
48
77
  while (preCheckWaitQueue.length) {
49
78
  let { resolve } = preCheckWaitQueue.shift();
@@ -75,6 +104,7 @@ async function runIdle(connection) {
75
104
  };
76
105
 
77
106
  // Register preCheck on the connection so other code (e.g., getMailboxLock) can break IDLE
107
+ ownPreCheck = connectionPreCheck;
78
108
  connection.preCheck = connectionPreCheck;
79
109
 
80
110
  response = await connection.exec('IDLE', false, {
@@ -95,37 +125,165 @@ async function runIdle(connection) {
95
125
  onSend: () => {}
96
126
  });
97
127
 
98
- // Clean up: unset preCheck and resolve any remaining waiters before processing the response.
99
- // Usually preCheck is already cleared by the DONE handler, but this handles edge cases.
100
- if (typeof connection.preCheck === 'function' && connection.preCheck === connectionPreCheck) {
101
- connection.log.trace({
102
- msg: 'Clearing pre-check function',
103
- lockId: connection.currentLock?.lockId,
104
- path: connection.mailbox && connection.mailbox.path,
105
- queued: preCheckWaitQueue.length,
106
- doneRequested,
107
- canEnd,
108
- doneSent
109
- });
110
- connection.preCheck = false;
111
- while (preCheckWaitQueue.length) {
112
- let { resolve } = preCheckWaitQueue.shift();
113
- resolve();
114
- }
115
- }
116
-
117
128
  response.next();
118
129
  return;
119
130
  } catch (err) {
120
- connection.preCheck = false;
121
- connection.idling = false;
122
-
123
131
  connection.log.warn({ err, cid: connection.id });
124
132
  while (preCheckWaitQueue.length) {
125
133
  let { reject } = preCheckWaitQueue.shift();
126
134
  reject(err);
127
135
  }
128
136
  return false;
137
+ } finally {
138
+ // Single ownership cleanup for every outcome: explicit break, tagged completion
139
+ // (including a server-terminated IDLE, where preCheck never ran), rejected command,
140
+ // parser failure and connection close. `idling` drives socket-timeout handling, so it
141
+ // must always describe reality rather than being left over from a previous state.
142
+ releaseIdling();
143
+ if (connection.preCheck === ownPreCheck) {
144
+ connection.preCheck = false;
145
+ }
146
+ while (preCheckWaitQueue.length) {
147
+ let { resolve } = preCheckWaitQueue.shift();
148
+ resolve();
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Runs one fallback poll. The real SELECT and STATUS commands are reused instead of replaying the
155
+ * saved wire arguments, so a poll applies exactly the same mailbox state transitions, events and
156
+ * failure handling as a caller-issued command. They go through connection.runInternal() rather
157
+ * than connection.run(), because run() awaits preCheck() - and the preCheck it would await is the
158
+ * one this very polling session installed, so the session would cancel itself.
159
+ *
160
+ * @param {Object} connection - IMAP connection instance
161
+ * @param {Object} session - Polling session state
162
+ * @returns {Promise<void>}
163
+ */
164
+ async function pollOnce(connection, session) {
165
+ let path = connection.mailbox && connection.mailbox.path;
166
+
167
+ switch (connection.missingIdleCommand) {
168
+ case 'SELECT':
169
+ connection.log.debug({ src: 'c', msg: `Running SELECT to detect changes in folder`, cid: connection.id });
170
+ await connection.runInternal('SELECT', path, { readOnly: session.selectCommand.command === 'EXAMINE' });
171
+ break;
172
+
173
+ case 'STATUS': {
174
+ connection.log.debug({ src: 'c', msg: `Running STATUS to detect changes in folder`, cid: connection.id });
175
+ // HIGHESTMODSEQ is filtered out again unless the server advertises CONDSTORE, so a
176
+ // CONDSTORE session keeps mailbox.highestModseq current without asking a plain
177
+ // server for an item it does not know.
178
+ let status = await connection.runInternal('STATUS', path, {
179
+ messages: true,
180
+ uidNext: true,
181
+ uidValidity: true,
182
+ unseen: true,
183
+ highestModseq: true
184
+ });
185
+ if (!status) {
186
+ let err = new Error('STATUS poll failed');
187
+ err.code = 'PollFailed';
188
+ throw err;
189
+ }
190
+ break;
191
+ }
192
+
193
+ case 'NOOP':
194
+ default: {
195
+ let response = await connection.exec('NOOP', false, { comment: 'IDLE not supported' });
196
+ response.next();
197
+ break;
198
+ }
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Polls the selected mailbox at a fixed interval, for servers without IDLE support.
204
+ *
205
+ * The loop is one explicitly identified session: cancellation is idempotent, is checked before
206
+ * a poll starts and again before the next timer is scheduled, and a poll that completes after
207
+ * cancellation can neither run again nor take ownership away from a newer IDLE session.
208
+ *
209
+ * @param {Object} connection - IMAP connection instance
210
+ * @param {number} [maxIdleTime] - Upper bound for the polling interval
211
+ * @returns {Promise<void>}
212
+ */
213
+ async function runPollingFallback(connection, maxIdleTime) {
214
+ if (!connection.currentSelectCommand) {
215
+ return;
216
+ }
217
+
218
+ let session = {
219
+ cancelled: false,
220
+ timer: null,
221
+ preCheck: null,
222
+ selectCommand: connection.currentSelectCommand
223
+ };
224
+
225
+ let interval = maxIdleTime ? Math.min(NOOP_INTERVAL, maxIdleTime) : NOOP_INTERVAL;
226
+ let releaseIdling = claimIdling(connection);
227
+
228
+ try {
229
+ await new Promise(resolve => {
230
+ // Idempotent cancellation. Never keyed off connection.preCheck, because a newer IDLE
231
+ // session may already own that property by the time an old poll settles.
232
+ const cancel = () => {
233
+ if (session.cancelled) {
234
+ return;
235
+ }
236
+ session.cancelled = true;
237
+ clearTimeout(session.timer);
238
+ session.timer = null;
239
+ resolve();
240
+ };
241
+
242
+ session.preCheck = async () => {
243
+ connection.log.debug({ src: 'c', msg: `breaking NOOP loop`, cid: connection.id });
244
+ cancel();
245
+ };
246
+ connection.preCheck = session.preCheck;
247
+
248
+ const runPoll = () => {
249
+ if (session.cancelled) {
250
+ return;
251
+ }
252
+
253
+ // The transport or the mailbox may be gone by the time the timer fires
254
+ if (!connection.socket || connection.socket.destroyed || connection.state !== connection.states.SELECTED || !connection.mailbox) {
255
+ return cancel();
256
+ }
257
+
258
+ pollOnce(connection, session)
259
+ .then(() => {
260
+ // Cancellation is re-checked here: the session may have been broken while
261
+ // this poll was in flight, and an orphaned poller must not schedule again.
262
+ if (session.cancelled) {
263
+ return;
264
+ }
265
+ session.timer = setTimeout(runPoll, interval);
266
+ // Background polling must not keep the process alive
267
+ unrefTimer(session.timer);
268
+ })
269
+ .catch(err => {
270
+ connection.log.warn({ err, cid: connection.id });
271
+ cancel();
272
+ });
273
+ };
274
+
275
+ connection.log.debug({ src: 'c', msg: `initiated NOOP loop`, cid: connection.id });
276
+ // Keep the immediate first poll
277
+ runPoll();
278
+ });
279
+ } finally {
280
+ session.cancelled = true;
281
+ clearTimeout(session.timer);
282
+ session.timer = null;
283
+ releaseIdling();
284
+ if (connection.preCheck === session.preCheck) {
285
+ connection.preCheck = false;
286
+ }
129
287
  }
130
288
  }
131
289
 
@@ -148,9 +306,11 @@ module.exports = async (connection, maxIdleTime) => {
148
306
  if (hasCapability(connection, 'IDLE')) {
149
307
  let idleTimer;
150
308
  let stillIdling = false;
151
- // IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts
152
- // to keep the connection alive (some servers drop long-running IDLEs).
153
- let runIdleLoop = async () => {
309
+ // IDLE loop: runs IDLE, and if maxIdleTime is reached, breaks and restarts to keep the
310
+ // connection alive (some servers drop long-running IDLEs). Iterative rather than
311
+ // recursive, so a long-lived idling connection does not retain one pending frame per
312
+ // restart.
313
+ for (;;) {
154
314
  if (maxIdleTime) {
155
315
  idleTimer = setTimeout(() => {
156
316
  if (connection.idling) {
@@ -162,86 +322,19 @@ module.exports = async (connection, maxIdleTime) => {
162
322
  }
163
323
  }
164
324
  }, maxIdleTime);
325
+ // Background IDLE restart timer must not keep the process alive
326
+ unrefTimer(idleTimer);
165
327
  }
166
328
  let resp = await runIdle(connection);
167
329
  clearTimeout(idleTimer);
168
- if (stillIdling) {
169
- stillIdling = false;
170
- return runIdleLoop();
330
+ if (!stillIdling) {
331
+ return resp;
171
332
  }
172
- return resp;
173
- };
174
- return runIdleLoop();
333
+ stillIdling = false;
334
+ }
175
335
  }
176
336
 
177
337
  // Fallback for servers without IDLE support: poll at regular intervals using
178
338
  // NOOP (default), STATUS, or SELECT depending on missingIdleCommand config.
179
- let idleTimer;
180
- return new Promise(resolve => {
181
- if (!connection.currentSelectCommand) {
182
- return resolve();
183
- }
184
-
185
- // Set up preCheck so other commands can break the polling loop
186
- connection.preCheck = async () => {
187
- connection.preCheck = false; // unset itself
188
- clearTimeout(idleTimer);
189
- connection.log.debug({ src: 'c', msg: `breaking NOOP loop` });
190
- connection.idling = false;
191
- resolve();
192
- };
193
-
194
- let selectCommand = connection.currentSelectCommand;
195
-
196
- // Run one polling check. The method used depends on configuration:
197
- // SELECT re-selects the mailbox (may detect changes), STATUS queries mailbox counters,
198
- // NOOP is the simplest but relies on server pushing untagged responses.
199
- let idleCheck = async () => {
200
- let response;
201
- switch (connection.missingIdleCommand) {
202
- case 'SELECT':
203
- // FIXME: somehow a loop occurs after some time of idling with SELECT
204
- connection.log.debug({ src: 'c', msg: `Running SELECT to detect changes in folder` });
205
- response = await connection.exec(selectCommand.command, selectCommand.arguments);
206
- break;
207
-
208
- case 'STATUS':
209
- {
210
- let statusArgs = [
211
- selectCommand.arguments[0],
212
- ['MESSAGES', 'UIDNEXT', 'UIDVALIDITY', 'UNSEEN'].map(key => ({ type: 'ATOM', value: key }))
213
- ];
214
- connection.log.debug({ src: 'c', msg: `Running STATUS to detect changes in folder` });
215
- response = await connection.exec('STATUS', statusArgs);
216
- }
217
- break;
218
-
219
- case 'NOOP':
220
- default:
221
- response = await connection.exec('NOOP', false, { comment: 'IDLE not supported' });
222
- break;
223
- }
224
- response.next();
225
- };
226
-
227
- let noopInterval = maxIdleTime ? Math.min(NOOP_INTERVAL, maxIdleTime) : NOOP_INTERVAL;
228
-
229
- let runLoop = () => {
230
- idleCheck()
231
- .then(() => {
232
- clearTimeout(idleTimer);
233
- idleTimer = setTimeout(runLoop, noopInterval);
234
- })
235
- .catch(err => {
236
- clearTimeout(idleTimer);
237
- connection.preCheck = false;
238
- connection.log.warn({ err, cid: connection.id });
239
- resolve();
240
- });
241
- };
242
-
243
- connection.log.debug({ src: 'c', msg: `initiated NOOP loop` });
244
- connection.idling = true;
245
- runLoop();
246
- });
339
+ return runPollingFallback(connection, maxIdleTime);
247
340
  };
@@ -23,9 +23,15 @@ module.exports = async (connection, reference, mailbox, options) => {
23
23
  // Used in the final sort to group special-use mailboxes at the top of the list.
24
24
  const FLAG_SORT_ORDER = ['\\Inbox', '\\Flagged', '\\Sent', '\\Drafts', '\\All', '\\Archive', '\\Junk', '\\Trash'];
25
25
  // Priority for how a special-use flag was determined: explicit user hint > server
26
- // extension flag (SPECIAL-USE/XLIST) > name-based guess. When multiple mailboxes
27
- // claim the same special-use type, the highest-priority source wins.
28
- const SOURCE_SORT_ORDER = ['user', 'extension', 'name'];
26
+ // extension flag (SPECIAL-USE/XLIST) > known localized name > relaxed name guess.
27
+ // When multiple mailboxes claim the same special-use type, the highest-priority
28
+ // source wins, so an exactly named folder beats one matched by a decorated or
29
+ // morphological variant of that name.
30
+ const SOURCE_SORT_ORDER = ['user', 'extension', 'name', 'name-guess'];
31
+ // "name-guess" is an internal precedence tier only. It is reported as "name" so
32
+ // that specialUseSource keeps its documented set of values for consumers.
33
+ const PUBLIC_SOURCE = { 'name-guess': 'name' };
34
+ const isNameSource = source => source === 'name' || source === 'name-guess';
29
35
 
30
36
  // Prefer XLIST (legacy Gmail extension) only if SPECIAL-USE (RFC 6154) is unavailable.
31
37
  // Both provide special-use flags, but SPECIAL-USE is the standardized approach.
@@ -210,10 +216,11 @@ module.exports = async (connection, reference, mailbox, options) => {
210
216
  entry
211
217
  );
212
218
 
213
- // A name-based guess for a \NonExistent phantom entry could win the
219
+ // A name-based match for a \NonExistent phantom entry could win the
214
220
  // special-use slot over the real folder - only server-provided flags
215
- // are trusted for nonexistent entries
216
- if (specialUseFlag && (flagSource !== 'name' || !entry.flags.has('\\NonExistent'))) {
221
+ // are trusted for nonexistent entries. Covers every name-derived
222
+ // source, exact and relaxed alike.
223
+ if (specialUseFlag && (!isNameSource(flagSource) || !entry.flags.has('\\NonExistent'))) {
217
224
  addSpecialUseMatch(entry, specialUseFlag, flagSource);
218
225
  }
219
226
 
@@ -511,8 +518,9 @@ module.exports = async (connection, reference, mailbox, options) => {
511
518
  });
512
519
 
513
520
  if (!sortedEntries[0].entry.specialUse) {
521
+ let source = sortedEntries[0].source;
514
522
  sortedEntries[0].entry.specialUse = type;
515
- sortedEntries[0].entry.specialUseSource = sortedEntries[0].source;
523
+ sortedEntries[0].entry.specialUseSource = PUBLIC_SOURCE[source] || source;
516
524
  }
517
525
  }
518
526
 
@@ -110,6 +110,9 @@ module.exports = async (connection, path) => {
110
110
  }
111
111
  }
112
112
  });
113
+ // Release the parser: without this the connection stalls, because the reader loop
114
+ // waits for the response to be handed back before parsing any further input.
115
+ response.next();
113
116
  }
114
117
 
115
118
  return map;
@@ -25,6 +25,11 @@ module.exports = async (connection, path, options) => {
25
25
 
26
26
  // Ensure we have folder metadata (delimiter, flags, specialUse) by running LIST if needed.
27
27
  // This is cached in connection.folders to avoid repeated LIST calls.
28
+ // Note: this uses run() rather than runInternal(), so it terminates a running IDLE first,
29
+ // which is what a caller-issued mailboxOpen() needs. Fallback polling reaches SELECT through
30
+ // runInternal(), and on a cache miss this LIST awaits the polling session's own preCheck and
31
+ // so cancels that session. Not a deadlock, and only reachable when the folder is uncached,
32
+ // but a polled SELECT of an unlisted folder ends the poll early.
28
33
  if (!connection.folders.has(path)) {
29
34
  let folders = await connection.run('LIST', '', path);
30
35
  if (!folders) {
@@ -132,6 +132,10 @@ module.exports = async (connection, path, query) => {
132
132
  // A NO response usually means the mailbox doesn't exist. Verify by
133
133
  // running LIST -- if no results, throw a clear NotFound error instead
134
134
  // of the generic IMAP error.
135
+ // Note: this uses run(), so when STATUS was dispatched by fallback polling through
136
+ // runInternal() the LIST awaits that polling session's own preCheck and cancels it.
137
+ // Not a deadlock, and only reachable when the server rejects the STATUS, but a polled
138
+ // STATUS of a missing folder ends the poll early.
135
139
  if (err.responseStatus === 'NO') {
136
140
  let folders = await connection.run('LIST', '', path, { listOnly: true });
137
141
  if (folders && !folders.length) {
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ // Default upper bound for establishing a usable transport, including DNS and proxy negotiation.
4
+ const CONNECT_TIMEOUT = 90 * 1000;
5
+
6
+ /**
7
+ * One deadline for an entire connection attempt.
8
+ *
9
+ * DNS resolution, proxy negotiation and the transport handshake all draw from the same budget, so
10
+ * a phase that stalls cannot extend the documented `connectionTimeout`. Every expiry - whether it
11
+ * comes from this deadline or is normalized from a dependency - is reported with the same
12
+ * `CONNECT_TIMEOUT` error shape, so callers do not need to know which phase was blocked.
13
+ */
14
+ class ConnectionDeadline {
15
+ /**
16
+ * @param {Number} [timeout] Configured connection timeout in milliseconds. Normalized once
17
+ * here; 0 and any other falsy or invalid value fall back to the 90 second default.
18
+ */
19
+ constructor(timeout) {
20
+ this.timeout = Number(timeout) || CONNECT_TIMEOUT;
21
+ this.startedAt = Date.now();
22
+ }
23
+
24
+ /**
25
+ * @returns {Number} Milliseconds left in the budget, never negative.
26
+ */
27
+ remaining() {
28
+ return Math.max(0, this.timeout - (Date.now() - this.startedAt));
29
+ }
30
+
31
+ /**
32
+ * @returns {Error} The shared `CONNECT_TIMEOUT` error.
33
+ */
34
+ error() {
35
+ let err = new Error('Failed to establish connection in required time');
36
+ err.code = 'CONNECT_TIMEOUT';
37
+ err.details = { connectionTimeout: this.timeout };
38
+ return err;
39
+ }
40
+
41
+ /**
42
+ * Maps a dependency's own expiry onto the shared `CONNECT_TIMEOUT` shape, so callers see one
43
+ * timeout error whichever layer noticed first. The original error is kept as `_err`. Anything
44
+ * that is not a timeout is returned unchanged.
45
+ *
46
+ * @param {Error} err Error raised by a dependency during a connection phase.
47
+ * @returns {Error} Either the normalized timeout error or the original error.
48
+ */
49
+ normalize(err) {
50
+ if (!err || err.code === 'CONNECT_TIMEOUT') {
51
+ return err;
52
+ }
53
+
54
+ // The `socks` client reports its own expiry as "Proxy connection timed out"
55
+ if (err.code !== 'ETIMEDOUT' && !/timed out/i.test(err.message || '')) {
56
+ return err;
57
+ }
58
+
59
+ let normalized = this.error();
60
+ normalized._err = err;
61
+ return normalized;
62
+ }
63
+
64
+ /**
65
+ * Throws before a phase is started if the budget is already used up, so no work is begun
66
+ * that could only ever time out.
67
+ */
68
+ check() {
69
+ if (!this.remaining()) {
70
+ throw this.error();
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Races a phase against the remaining budget. The timer is always cleared, so a completed
76
+ * phase never leaves a pending timer behind.
77
+ *
78
+ * @param {Promise} promise Phase to run under the deadline.
79
+ * @returns {Promise<*>} Resolves with the phase result, rejects with `CONNECT_TIMEOUT`.
80
+ */
81
+ async race(promise) {
82
+ this.check();
83
+
84
+ let timer = null;
85
+ try {
86
+ return await Promise.race([
87
+ promise,
88
+ new Promise((resolve, reject) => {
89
+ timer = setTimeout(() => reject(this.error()), this.remaining());
90
+ })
91
+ ]);
92
+ } finally {
93
+ clearTimeout(timer);
94
+ }
95
+ }
96
+ }
97
+
98
+ module.exports = { ConnectionDeadline, CONNECT_TIMEOUT };
@@ -65,11 +65,14 @@ module.exports = async (response, options) => {
65
65
  lastRespByte = String.fromCharCode(lastRespByte);
66
66
  }
67
67
 
68
- // Add a space separator unless:
69
- // - The previous token was a LITERAL (literal data is self-delimiting after CRLF)
70
- // - The last byte was '(', '<', or '[' (opening delimiters suppress the space)
71
- // - This is the first token (resp is empty)
72
- // - This is a sub-array element in a consecutive-list context (no space between adjacent lists)
68
+ // Add a space separator when:
69
+ // - The previous token was a LITERAL. Literal data ends exactly at its declared length, so
70
+ // a following token always needs an explicit separator, even though the last written byte
71
+ // is arbitrary literal content.
72
+ // - Otherwise: there is something written already (resp is not empty) and the last byte is
73
+ // not an opening delimiter ('(', '<' or '['), which suppresses the space.
74
+ // A sub-array element in a consecutive-list context never gets one (no space between
75
+ // adjacent lists).
73
76
  if (lastType === 'LITERAL' || (!['(', '<', '['].includes(lastRespByte) && resp.length)) {
74
77
  if (!options.subArray) {
75
78
  resp.push(formatRespEntry(' '));