apache-iggy 0.9.0-edge.1 → 0.10.0-edge.2

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 (37) hide show
  1. package/README.md +15 -10
  2. package/dist/client/client.config.d.ts +2 -4
  3. package/dist/client/client.config.js +2 -8
  4. package/dist/client/client.config.test.js +5 -22
  5. package/dist/client/client.connection.d.ts +0 -8
  6. package/dist/client/client.connection.js +2 -15
  7. package/dist/client/client.connection.test.js +18 -17
  8. package/dist/client/client.frame.d.ts +2 -5
  9. package/dist/client/client.frame.js +12 -22
  10. package/dist/client/client.frame.test.js +38 -53
  11. package/dist/client/client.socket.d.ts +30 -6
  12. package/dist/client/client.socket.js +95 -40
  13. package/dist/client/client.socket.test.js +178 -22
  14. package/dist/client/client.type.d.ts +0 -6
  15. package/dist/client/client.utils.d.ts +0 -29
  16. package/dist/client/client.utils.js +0 -56
  17. package/dist/client/client.utils.test.js +9 -20
  18. package/dist/e2e/tcp.cluster.e2e.js +5 -16
  19. package/dist/e2e/tcp.consumer-group.e2e.js +1 -5
  20. package/dist/e2e/tcp.consumer-stream.e2e.js +1 -5
  21. package/dist/e2e/tcp.send-message.e2e.js +0 -11
  22. package/dist/e2e/test-client.utils.js +0 -2
  23. package/dist/e2e/tls.system.e2e.js +0 -4
  24. package/dist/wire/cluster/cluster.type.d.ts +2 -2
  25. package/dist/wire/command-set.test.js +0 -1
  26. package/dist/wire/message/header.type.d.ts +1 -1
  27. package/dist/wire/message/poll-messages.command.js +1 -2
  28. package/dist/wire/vsr/header.d.ts +13 -9
  29. package/dist/wire/vsr/header.js +13 -8
  30. package/dist/wire/vsr/header.test.js +0 -5
  31. package/dist/wire/vsr/index.js +0 -3
  32. package/dist/wire/vsr/vsr.test.js +0 -7
  33. package/package.json +3 -4
  34. package/dist/wire/vsr/namespace.d.ts +0 -19
  35. package/dist/wire/vsr/namespace.js +0 -177
  36. package/dist/wire/vsr/namespace.test.d.ts +0 -2
  37. package/dist/wire/vsr/namespace.test.js +0 -133
package/README.md CHANGED
@@ -32,18 +32,17 @@ npm i --save apache-iggy
32
32
 
33
33
  ### Response frame limit
34
34
 
35
- **Compatibility note:** response frames larger than `maxResponseFrameSize` (default 64 MiB) are now rejected and close the connection under both framing modes. This is a behavior change for existing classic-framing clients. Raise the limit in the client configuration when polling very large batches.
35
+ **Compatibility note:** response frames larger than `maxResponseFrameSize` (default 64 MiB) are rejected and close the connection. Raise the limit in the client configuration when polling very large batches.
36
36
 
37
37
  ### VSR framing
38
38
 
39
- Classic framing remains the default. Select VSR explicitly when connecting to
40
- an Iggy VSR server:
39
+ The SDK speaks the VSR wire protocol exclusively and requires an Iggy VSR
40
+ server:
41
41
 
42
42
  ```typescript
43
43
  import { SimpleClient, getRawClient } from "apache-iggy";
44
44
 
45
45
  const config = {
46
- protocol: "vsr" as const,
47
46
  transport: "TCP" as const,
48
47
  options: { host: "127.0.0.1", port: 8090 },
49
48
  credentials: { username: "iggy", password: "iggy" },
@@ -52,12 +51,18 @@ const client = new SimpleClient(getRawClient(config));
52
51
  const stats = await client.system.getStats();
53
52
  ```
54
53
 
55
- VSR is a runtime protocol choice in Node.js, not a build feature. Codes absent
56
- from the SDK command table use `Operation::NonReplicated` and carry the command
57
- code in the request header's reserved field. The server remains authoritative
58
- for classifying or rejecting extension commands.
54
+ Codes absent from the SDK command table use `Operation::NonReplicated` and
55
+ carry the command code in the request header's reserved field. The server
56
+ remains authoritative for classifying or rejecting extension commands.
59
57
 
60
- The same npm package supports both framing modes over TCP and TLS. VSR restricts `Client` to one pooled connection because authentication, request sequencing, and consumer-group assignments belong to one consensus session. Configurations requesting more than one pooled connection fail before a socket is opened.
58
+ Sends must use explicit `Partitioning.PartitionId` partitioning: the client
59
+ routes each request to a partition-scoped namespace, so broker-side balancing
60
+ (`Partitioning.Balanced`) and key hashing (`Partitioning.MessageKey`) are
61
+ rejected before the request is sent.
62
+ <!-- TODO(hubcio): Balanced and MessageKey partitioning to be implemented;
63
+ not decided yet whether it'll be on server side or client side. -->
64
+
65
+ VSR works over TCP and TLS. It restricts `Client` to one pooled connection because authentication, request sequencing, and consumer-group assignments belong to one consensus session. Configurations requesting more than one pooled connection fail before a socket is opened.
61
66
 
62
67
  VSR authentication translates the existing password and personal-access-token
63
68
  login APIs into the register handshake required by the consensus protocol. A
@@ -68,7 +73,7 @@ new session.
68
73
 
69
74
  When the server's `[heartbeat]` eviction is enabled, configure the client's `heartbeatInterval` below the server heartbeat interval. Client heartbeats are disabled when `heartbeatInterval` is unset.
70
75
 
71
- `sendBinaryRequest(code, payload)` has the same signature under classic and VSR framing. Known replicated commands use their registered operation, while unknown codes reach the server as non-replicated requests and are rejected by servers that do not register them. Classic request bytes remain unchanged.
76
+ `sendBinaryRequest(code, payload)` sends an arbitrary command code. Known replicated commands use their registered operation, while unknown codes reach the server as non-replicated requests and are rejected by servers that do not register them.
72
77
 
73
78
  ```typescript
74
79
  import { ResponseError } from "apache-iggy";
@@ -1,6 +1,4 @@
1
- import type { ClientConfig, Protocol } from './client.type.js';
1
+ import type { ClientConfig } from './client.type.js';
2
2
  export declare const DEFAULT_MAX_RESPONSE_FRAME_SIZE: number;
3
- export declare const normalizeClientConfig: (config: ClientConfig) => ClientConfig & {
4
- protocol: Protocol;
5
- };
3
+ export declare const normalizeClientConfig: (config: ClientConfig) => ClientConfig;
6
4
  //# sourceMappingURL=client.config.d.ts.map
@@ -15,24 +15,18 @@
15
15
  // specific language governing permissions and limitations
16
16
  // under the License.
17
17
  export const DEFAULT_MAX_RESPONSE_FRAME_SIZE = 64 * 1024 * 1024;
18
- const isProtocol = (value) => value === 'classic' || value === 'vsr';
19
18
  export const normalizeClientConfig = (config) => {
20
- const protocol = config.protocol ?? 'classic';
21
- if (!isProtocol(protocol))
22
- throw new TypeError(`unsupported wire protocol: ${String(protocol)}`);
23
19
  const maxResponseFrameSize = config.maxResponseFrameSize ?? DEFAULT_MAX_RESPONSE_FRAME_SIZE;
24
20
  if (!Number.isSafeInteger(maxResponseFrameSize) ||
25
21
  maxResponseFrameSize < 256)
26
22
  throw new TypeError('maxResponseFrameSize must be a safe integer of at least 256 bytes');
27
- if (protocol === 'vsr' &&
28
- ((config.poolSize?.min ?? 1) > 1 || (config.poolSize?.max ?? 1) > 1))
23
+ if ((config.poolSize?.min ?? 1) > 1 || (config.poolSize?.max ?? 1) > 1)
29
24
  throw new TypeError('VSR clients currently support exactly one pooled connection');
30
25
  return {
31
26
  ...config,
32
- protocol,
33
27
  options: { ...config.options },
34
28
  maxResponseFrameSize,
35
- ...(protocol === 'vsr' ? { poolSize: { min: 1, max: 1 } } : {})
29
+ poolSize: { min: 1, max: 1 }
36
30
  };
37
31
  };
38
32
  //# sourceMappingURL=client.config.js.map
@@ -23,40 +23,23 @@ const config = () => ({
23
23
  credentials: { username: 'iggy', password: 'iggy' }
24
24
  });
25
25
  describe('normalizeClientConfig', () => {
26
- it('defaults to classic without changing classic pool sizing', () => {
27
- const normalized = normalizeClientConfig({
28
- ...config(),
29
- poolSize: { min: 2, max: 4 }
30
- });
31
- assert.equal(normalized.protocol, 'classic');
32
- assert.deepEqual(normalized.poolSize, { min: 2, max: 4 });
26
+ it('applies the default response frame limit', () => {
27
+ const normalized = normalizeClientConfig(config());
33
28
  assert.equal(normalized.maxResponseFrameSize, DEFAULT_MAX_RESPONSE_FRAME_SIZE);
34
29
  });
35
- it('restricts VSR to one pooled connection', () => {
36
- const normalized = normalizeClientConfig({
37
- ...config(),
38
- protocol: 'vsr'
39
- });
30
+ it('restricts the client to one pooled connection', () => {
31
+ const normalized = normalizeClientConfig(config());
40
32
  assert.deepEqual(normalized.poolSize, { min: 1, max: 1 });
41
33
  assert.throws(() => normalizeClientConfig({
42
34
  ...config(),
43
- protocol: 'vsr',
44
35
  poolSize: { max: 2 }
45
36
  }), /exactly one pooled connection/);
46
37
  });
47
- it('rejects invalid protocols before opening a socket', () => {
48
- assert.throws(() => normalizeClientConfig({
49
- ...config(),
50
- protocol: 'auto'
51
- }), /unsupported wire protocol/);
52
- });
53
- it('supports VSR over TLS', () => {
38
+ it('supports TLS transport', () => {
54
39
  const normalized = normalizeClientConfig({
55
40
  ...config(),
56
- protocol: 'vsr',
57
41
  transport: 'TLS'
58
42
  });
59
- assert.equal(normalized.protocol, 'vsr');
60
43
  assert.equal(normalized.transport, 'TLS');
61
44
  assert.deepEqual(normalized.poolSize, { min: 1, max: 1 });
62
45
  });
@@ -79,14 +79,6 @@ export declare class IggyConnection extends EventEmitter {
79
79
  * @param data - Incoming data buffer
80
80
  */
81
81
  _onData(data: Buffer): void;
82
- /**
83
- * Writes a command to the socket.
84
- *
85
- * @param command - Command code
86
- * @param payload - Command payload
87
- * @returns True if the write was successful
88
- */
89
- writeCommand(command: number, payload: Buffer): void;
90
82
  writeFrame(frame: Buffer): void;
91
83
  }
92
84
  //# sourceMappingURL=client.connection.d.ts.map
@@ -18,7 +18,6 @@
18
18
  import { EventEmitter } from 'node:events';
19
19
  import { createConnection } from 'node:net';
20
20
  import { connect as TLSConnect } from 'node:tls';
21
- import { serializeCommand } from './client.utils.js';
22
21
  import { debug } from './client.debug.js';
23
22
  import { DEFAULT_MAX_RESPONSE_FRAME_SIZE } from './client.config.js';
24
23
  import { ProtocolFrameError, ResponseFrameDecoder } from './client.frame.js';
@@ -121,7 +120,7 @@ export class IggyConnection extends EventEmitter {
121
120
  this.reconnectCount = 0;
122
121
  this.connectPromise = undefined;
123
122
  this.reconnectPromise = undefined;
124
- this.responseDecoder = new ResponseFrameDecoder(config.protocol ?? 'classic', config.maxResponseFrameSize ?? DEFAULT_MAX_RESPONSE_FRAME_SIZE);
123
+ this.responseDecoder = new ResponseFrameDecoder(config.maxResponseFrameSize ?? DEFAULT_MAX_RESPONSE_FRAME_SIZE);
125
124
  this.socket = this._installSocket(getTransport(config));
126
125
  }
127
126
  /**
@@ -361,8 +360,7 @@ export class IggyConnection extends EventEmitter {
361
360
  debug('ONDATA', typeof data, Buffer.isBuffer(data), data?.length, this.responseDecoder.hasBufferedData);
362
361
  try {
363
362
  for (const response of this.responseDecoder.push(data)) {
364
- if (this.config.protocol === 'vsr' &&
365
- peekCommand(response) === Command2.Eviction)
363
+ if (peekCommand(response) === Command2.Eviction)
366
364
  this.emit('eviction', evictionError(response));
367
365
  else
368
366
  this.emit('response', response);
@@ -374,17 +372,6 @@ export class IggyConnection extends EventEmitter {
374
372
  this.socket.destroy();
375
373
  }
376
374
  }
377
- /**
378
- * Writes a command to the socket.
379
- *
380
- * @param command - Command code
381
- * @param payload - Command payload
382
- * @returns True if the write was successful
383
- */
384
- writeCommand(command, payload) {
385
- const cmd = serializeCommand(command, payload);
386
- this.socket.write(cmd);
387
- }
388
375
  writeFrame(frame) {
389
376
  this.socket.write(frame);
390
377
  }
@@ -21,6 +21,8 @@ import { createServer, } from 'node:net';
21
21
  import { describe, it } from 'node:test';
22
22
  import { ProtocolFrameError } from './client.frame.js';
23
23
  import { IggyConnection } from './client.connection.js';
24
+ import { Command2, HEADER_SIZE, REPLY_OFFSET } from '../wire/vsr/header.js';
25
+ const FRAME_LIMIT = 2 * HEADER_SIZE;
24
26
  const startServer = async () => {
25
27
  const server = createServer();
26
28
  server.listen(0, '127.0.0.1');
@@ -28,7 +30,6 @@ const startServer = async () => {
28
30
  return server;
29
31
  };
30
32
  const connectionConfig = (server) => ({
31
- protocol: 'classic',
32
33
  transport: 'TCP',
33
34
  options: {
34
35
  host: '127.0.0.1',
@@ -36,8 +37,15 @@ const connectionConfig = (server) => ({
36
37
  },
37
38
  credentials: { username: 'iggy', password: 'iggy' },
38
39
  reconnect: { enabled: false, interval: 0, maxRetries: 0 },
39
- maxResponseFrameSize: 256
40
+ maxResponseFrameSize: FRAME_LIMIT
40
41
  });
42
+ const replyFrame = (body) => {
43
+ const frame = Buffer.alloc(HEADER_SIZE + body.length);
44
+ frame.writeUInt32LE(frame.length, REPLY_OFFSET.size);
45
+ frame.writeUInt8(Command2.Reply, REPLY_OFFSET.command);
46
+ body.copy(frame, HEADER_SIZE);
47
+ return frame;
48
+ };
41
49
  const closeConnection = async (connection, server) => {
42
50
  connection._destroy();
43
51
  if (!connection.socket.destroyed)
@@ -59,7 +67,7 @@ describe('IggyConnection', () => {
59
67
  await closeConnection(connection, server);
60
68
  }
61
69
  });
62
- it('shares connection attempts, recognizes endpoints, and writes commands', async () => {
70
+ it('shares connection attempts, recognizes endpoints, and writes frames', async () => {
63
71
  const server = await startServer();
64
72
  const received = new Promise((resolve) => {
65
73
  server.once('connection', (socket) => {
@@ -78,10 +86,9 @@ describe('IggyConnection', () => {
78
86
  host: 'broker.example'
79
87
  };
80
88
  assert.equal(connection.isConnectedTo('broker.example', server.address().port), true);
81
- connection.writeCommand(1, Buffer.from('payload'));
82
- const command = await received;
83
- assert.equal(command.readUInt32LE(4), 1);
84
- assert.deepEqual(command.subarray(8), Buffer.from('payload'));
89
+ const frame = replyFrame(Buffer.from('payload'));
90
+ connection.writeFrame(frame);
91
+ assert.deepEqual(await received, frame);
85
92
  }
86
93
  finally {
87
94
  await closeConnection(connection, server);
@@ -92,16 +99,13 @@ describe('IggyConnection', () => {
92
99
  const connection = new IggyConnection(connectionConfig(server));
93
100
  try {
94
101
  await connection.connect();
95
- const body = Buffer.from('response');
96
- const frame = Buffer.alloc(8 + body.length);
97
- frame.writeUInt32LE(body.length, 4);
98
- body.copy(frame, 8);
102
+ const frame = replyFrame(Buffer.from('response'));
99
103
  const response = once(connection, 'response');
100
104
  connection._onData(frame.subarray(0, 6));
101
105
  connection._onData(frame.subarray(6));
102
106
  assert.deepEqual((await response)[0], frame);
103
- const malformed = Buffer.alloc(8);
104
- malformed.writeUInt32LE(256, 4);
107
+ const malformed = replyFrame(Buffer.alloc(0));
108
+ malformed.writeUInt32LE(FRAME_LIMIT + 1, REPLY_OFFSET.size);
105
109
  const error = once(connection, 'error');
106
110
  connection._onData(malformed);
107
111
  assert.ok((await error)[0] instanceof ProtocolFrameError);
@@ -178,10 +182,7 @@ describe('IggyConnection', () => {
178
182
  assert.equal(await first, connection);
179
183
  assert.equal(connection.connected, true);
180
184
  assert.equal(connections, 2);
181
- const body = Buffer.from('fresh');
182
- const frame = Buffer.alloc(8 + body.length);
183
- frame.writeUInt32LE(body.length, 4);
184
- body.copy(frame, 8);
185
+ const frame = replyFrame(Buffer.from('fresh'));
185
186
  const response = once(connection, 'response');
186
187
  oldSocket.emit('data', Buffer.alloc(8));
187
188
  connection._onData(frame);
@@ -1,4 +1,3 @@
1
- import type { Protocol } from './client.type.js';
2
1
  export declare class ProtocolFrameError extends Error {
3
2
  constructor(message: string);
4
3
  }
@@ -6,21 +5,19 @@ export type ExtractedFrames = {
6
5
  frames: Buffer[];
7
6
  remainder: Buffer;
8
7
  };
9
- export declare const extractResponseFrames: (protocol: Protocol, buffer: Buffer, maximumFrameSize: number) => ExtractedFrames;
8
+ export declare const extractResponseFrames: (buffer: Buffer, maximumFrameSize: number) => ExtractedFrames;
10
9
  /**
11
10
  * Incrementally extracts response frames without repeatedly copying an
12
11
  * incomplete frame as new socket chunks arrive.
13
12
  */
14
13
  export declare class ResponseFrameDecoder {
15
- private readonly protocol;
16
14
  private readonly maximumFrameSize;
17
- private readonly headerSize;
18
15
  private chunks;
19
16
  private chunkIndex;
20
17
  private chunkOffset;
21
18
  private bufferedLength;
22
19
  private expectedFrameSize?;
23
- constructor(protocol: Protocol, maximumFrameSize: number);
20
+ constructor(maximumFrameSize: number);
24
21
  get hasBufferedData(): boolean;
25
22
  clear(): void;
26
23
  push(data: Buffer): Buffer[];
@@ -14,34 +14,28 @@
14
14
  // KIND, either express or implied. See the License for the
15
15
  // specific language governing permissions and limitations
16
16
  // under the License.
17
- import { HEADER_SIZE as VSR_HEADER_SIZE, readSize as readVsrSize } from '../wire/vsr/header.js';
18
- const CLASSIC_HEADER_SIZE = 8;
17
+ import { HEADER_SIZE, readSize } from '../wire/vsr/header.js';
19
18
  export class ProtocolFrameError extends Error {
20
19
  constructor(message) {
21
20
  super(message);
22
21
  this.name = 'ProtocolFrameError';
23
22
  }
24
23
  }
25
- const headerSizeFor = (protocol) => protocol === 'vsr' ? VSR_HEADER_SIZE : CLASSIC_HEADER_SIZE;
26
- const declaredFrameSize = (protocol, header, maximumFrameSize) => {
27
- const headerSize = headerSizeFor(protocol);
28
- const declaredSize = protocol === 'vsr'
29
- ? readVsrSize(header)
30
- : CLASSIC_HEADER_SIZE + header.readUInt32LE(4);
31
- if (declaredSize < headerSize)
32
- throw new ProtocolFrameError(`declared ${protocol} frame size ${declaredSize} is below header size`);
24
+ const declaredFrameSize = (header, maximumFrameSize) => {
25
+ const declaredSize = readSize(header);
26
+ if (declaredSize < HEADER_SIZE)
27
+ throw new ProtocolFrameError(`declared frame size ${declaredSize} is below header size`);
33
28
  if (declaredSize > maximumFrameSize)
34
- throw new ProtocolFrameError(`declared ${protocol} frame size ${declaredSize} exceeds ` +
29
+ throw new ProtocolFrameError(`declared frame size ${declaredSize} exceeds ` +
35
30
  `the ${maximumFrameSize} byte limit`);
36
31
  return declaredSize;
37
32
  };
38
- export const extractResponseFrames = (protocol, buffer, maximumFrameSize) => {
39
- const headerSize = headerSizeFor(protocol);
33
+ export const extractResponseFrames = (buffer, maximumFrameSize) => {
40
34
  const frames = [];
41
35
  let offset = 0;
42
- while (buffer.length - offset >= headerSize) {
36
+ while (buffer.length - offset >= HEADER_SIZE) {
43
37
  const available = buffer.length - offset;
44
- const declaredSize = declaredFrameSize(protocol, buffer.subarray(offset, offset + headerSize), maximumFrameSize);
38
+ const declaredSize = declaredFrameSize(buffer.subarray(offset, offset + HEADER_SIZE), maximumFrameSize);
45
39
  if (available < declaredSize)
46
40
  break;
47
41
  frames.push(buffer.subarray(offset, offset + declaredSize));
@@ -59,18 +53,14 @@ export const extractResponseFrames = (protocol, buffer, maximumFrameSize) => {
59
53
  * incomplete frame as new socket chunks arrive.
60
54
  */
61
55
  export class ResponseFrameDecoder {
62
- protocol;
63
56
  maximumFrameSize;
64
- headerSize;
65
57
  chunks;
66
58
  chunkIndex;
67
59
  chunkOffset;
68
60
  bufferedLength;
69
61
  expectedFrameSize;
70
- constructor(protocol, maximumFrameSize) {
71
- this.protocol = protocol;
62
+ constructor(maximumFrameSize) {
72
63
  this.maximumFrameSize = maximumFrameSize;
73
- this.headerSize = headerSizeFor(protocol);
74
64
  this.chunks = [];
75
65
  this.chunkIndex = 0;
76
66
  this.chunkOffset = 0;
@@ -95,9 +85,9 @@ export class ResponseFrameDecoder {
95
85
  const frames = [];
96
86
  while (true) {
97
87
  if (this.expectedFrameSize === undefined) {
98
- if (this.bufferedLength < this.headerSize)
88
+ if (this.bufferedLength < HEADER_SIZE)
99
89
  break;
100
- this.expectedFrameSize = declaredFrameSize(this.protocol, this.peek(this.headerSize), this.maximumFrameSize);
90
+ this.expectedFrameSize = declaredFrameSize(this.peek(HEADER_SIZE), this.maximumFrameSize);
101
91
  }
102
92
  if (this.bufferedLength < this.expectedFrameSize)
103
93
  break;
@@ -19,12 +19,6 @@ import { describe, it } from 'node:test';
19
19
  import { extractResponseFrames, ProtocolFrameError, ResponseFrameDecoder } from './client.frame.js';
20
20
  import { Command2, HEADER_SIZE, REPLY_OFFSET } from '../wire/vsr/header.js';
21
21
  const LIMIT = 1024;
22
- const classicFrame = (body) => {
23
- const frame = Buffer.alloc(8 + body.length);
24
- frame.writeUInt32LE(body.length, 4);
25
- body.copy(frame, 8);
26
- return frame;
27
- };
28
22
  const vsrFrame = (body) => {
29
23
  const frame = Buffer.alloc(HEADER_SIZE + body.length);
30
24
  frame.writeUInt32LE(frame.length, REPLY_OFFSET.size);
@@ -33,59 +27,50 @@ const vsrFrame = (body) => {
33
27
  return frame;
34
28
  };
35
29
  describe('extractResponseFrames', () => {
36
- for (const protocol of ['classic', 'vsr']) {
37
- const makeFrame = protocol === 'classic' ? classicFrame : vsrFrame;
38
- it(`buffers ${protocol} headers split at every boundary`, () => {
39
- const frame = makeFrame(Buffer.from('payload'));
40
- const headerSize = protocol === 'classic' ? 8 : HEADER_SIZE;
41
- for (let split = 0; split < headerSize; split += 1) {
42
- const first = extractResponseFrames(protocol, frame.subarray(0, split), LIMIT);
43
- assert.equal(first.frames.length, 0);
44
- const second = extractResponseFrames(protocol, Buffer.concat([first.remainder, frame.subarray(split)]), LIMIT);
45
- assert.deepEqual(second.frames, [frame]);
46
- assert.equal(second.remainder.length, 0);
47
- }
48
- });
49
- it(`buffers a fragmented ${protocol} body`, () => {
50
- const frame = makeFrame(Buffer.from('payload'));
51
- const split = frame.length - 2;
52
- const first = extractResponseFrames(protocol, frame.subarray(0, split), LIMIT);
30
+ it('buffers headers split at every boundary', () => {
31
+ const frame = vsrFrame(Buffer.from('payload'));
32
+ for (let split = 0; split < HEADER_SIZE; split += 1) {
33
+ const first = extractResponseFrames(frame.subarray(0, split), LIMIT);
53
34
  assert.equal(first.frames.length, 0);
54
- const second = extractResponseFrames(protocol, Buffer.concat([first.remainder, frame.subarray(split)]), LIMIT);
35
+ const second = extractResponseFrames(Buffer.concat([first.remainder, frame.subarray(split)]), LIMIT);
55
36
  assert.deepEqual(second.frames, [frame]);
56
- });
57
- it(`extracts coalesced ${protocol} frames and a partial tail`, () => {
58
- const first = makeFrame(Buffer.from('one'));
59
- const second = makeFrame(Buffer.from('two'));
60
- const third = makeFrame(Buffer.from('three'));
61
- const input = Buffer.concat([first, second, third.subarray(0, 3)]);
62
- const extracted = extractResponseFrames(protocol, input, LIMIT);
63
- assert.deepEqual(extracted.frames, [first, second]);
64
- assert.deepEqual(extracted.remainder, third.subarray(0, 3));
65
- assert.equal(extracted.remainder.buffer, input.buffer);
66
- });
67
- }
68
- it('rejects a VSR size below the header', () => {
37
+ assert.equal(second.remainder.length, 0);
38
+ }
39
+ });
40
+ it('buffers a fragmented body', () => {
41
+ const frame = vsrFrame(Buffer.from('payload'));
42
+ const split = frame.length - 2;
43
+ const first = extractResponseFrames(frame.subarray(0, split), LIMIT);
44
+ assert.equal(first.frames.length, 0);
45
+ const second = extractResponseFrames(Buffer.concat([first.remainder, frame.subarray(split)]), LIMIT);
46
+ assert.deepEqual(second.frames, [frame]);
47
+ });
48
+ it('extracts coalesced frames and a partial tail', () => {
49
+ const first = vsrFrame(Buffer.from('one'));
50
+ const second = vsrFrame(Buffer.from('two'));
51
+ const third = vsrFrame(Buffer.from('three'));
52
+ const input = Buffer.concat([first, second, third.subarray(0, 3)]);
53
+ const extracted = extractResponseFrames(input, LIMIT);
54
+ assert.deepEqual(extracted.frames, [first, second]);
55
+ assert.deepEqual(extracted.remainder, third.subarray(0, 3));
56
+ assert.equal(extracted.remainder.buffer, input.buffer);
57
+ });
58
+ it('rejects a size below the header', () => {
69
59
  const frame = vsrFrame(Buffer.alloc(0));
70
60
  frame.writeUInt32LE(0, REPLY_OFFSET.size);
71
- assert.throws(() => extractResponseFrames('vsr', frame, LIMIT), ProtocolFrameError);
61
+ assert.throws(() => extractResponseFrames(frame, LIMIT), ProtocolFrameError);
72
62
  });
73
- it('rejects an oversized VSR frame before buffering its body', () => {
63
+ it('rejects an oversized frame before buffering its body', () => {
74
64
  const header = vsrFrame(Buffer.alloc(0));
75
65
  header.writeUInt32LE(LIMIT + 1, REPLY_OFFSET.size);
76
- assert.throws(() => extractResponseFrames('vsr', header, LIMIT), ProtocolFrameError);
77
- });
78
- it('rejects an oversized classic frame before buffering its body', () => {
79
- const header = classicFrame(Buffer.alloc(0));
80
- header.writeUInt32LE(LIMIT, 4);
81
- assert.throws(() => extractResponseFrames('classic', header, LIMIT), ProtocolFrameError);
66
+ assert.throws(() => extractResponseFrames(header, LIMIT), ProtocolFrameError);
82
67
  });
83
68
  });
84
69
  describe('ResponseFrameDecoder', () => {
85
70
  it('decodes bytewise input without losing coalesced frames', () => {
86
- const decoder = new ResponseFrameDecoder('classic', LIMIT);
87
- const first = classicFrame(Buffer.from('first'));
88
- const second = classicFrame(Buffer.from('second'));
71
+ const decoder = new ResponseFrameDecoder(LIMIT);
72
+ const first = vsrFrame(Buffer.from('first'));
73
+ const second = vsrFrame(Buffer.from('second'));
89
74
  const input = Buffer.concat([first, second]);
90
75
  const frames = [];
91
76
  for (const byte of input)
@@ -94,16 +79,16 @@ describe('ResponseFrameDecoder', () => {
94
79
  assert.equal(decoder.hasBufferedData, false);
95
80
  });
96
81
  it('clears a partial frame', () => {
97
- const decoder = new ResponseFrameDecoder('vsr', LIMIT);
98
- decoder.push(vsrFrame(Buffer.from('body')).subarray(0, 100));
82
+ const decoder = new ResponseFrameDecoder(LIMIT);
83
+ decoder.push(vsrFrame(Buffer.from('body')).subarray(0, HEADER_SIZE - 2));
99
84
  assert.equal(decoder.hasBufferedData, true);
100
85
  decoder.clear();
101
86
  assert.equal(decoder.hasBufferedData, false);
102
87
  });
103
88
  it('rejects an oversized frame as soon as its header is complete', () => {
104
- const decoder = new ResponseFrameDecoder('classic', LIMIT);
105
- const header = Buffer.alloc(8);
106
- header.writeUInt32LE(LIMIT, 4);
89
+ const decoder = new ResponseFrameDecoder(LIMIT);
90
+ const header = vsrFrame(Buffer.alloc(0));
91
+ header.writeUInt32LE(LIMIT + 1, REPLY_OFFSET.size);
107
92
  assert.throws(() => decoder.push(header), ProtocolFrameError);
108
93
  });
109
94
  });
@@ -1,5 +1,5 @@
1
1
  import { EventEmitter } from 'node:events';
2
- import type { ClientConfig, ClientCredentials, CommandResponse, PasswordCredentials, Protocol, RawClient, SendCommandOptions, TokenCredentials } from '../client/client.type.js';
2
+ import type { ClientConfig, ClientCredentials, CommandResponse, PasswordCredentials, RawClient, SendCommandOptions, TokenCredentials } from '../client/client.type.js';
3
3
  export declare class VsrResponseTimeoutError extends Error {
4
4
  constructor(timeout: number);
5
5
  }
@@ -8,8 +8,6 @@ export declare class VsrResponseTimeoutError extends Error {
8
8
  * Implements command queuing, authentication, and heartbeat functionality.
9
9
  */
10
10
  export declare class CommandResponseStream extends EventEmitter {
11
- /** Server wire protocol used by this connection */
12
- readonly protocol: Protocol;
13
11
  /** Client configuration */
14
12
  private options;
15
13
  /** Underlying connection to the server */
@@ -20,6 +18,12 @@ export declare class CommandResponseStream extends EventEmitter {
20
18
  private vsrSession;
21
19
  /** Shared authentication attempt for concurrent callers */
22
20
  private authenticationPromise?;
21
+ /** Whether a login is already being moved to the leader */
22
+ private settlingLeader;
23
+ /** How long a leaderless roster is polled before settling in place */
24
+ private leaderlessWaitBudget;
25
+ /** Delay between roster reads while the cluster elects */
26
+ private leaderlessPollInterval;
23
27
  /** Calls that have acquired this stream but have not fully settled */
24
28
  private pendingSubmissions;
25
29
  /** Whether the stream is currently processing a command */
@@ -44,7 +48,7 @@ export declare class CommandResponseStream extends EventEmitter {
44
48
  _init(): void;
45
49
  /**
46
50
  * Sends a command to the server.
47
- * Automatically handles connection and authentication if needed.
51
+ * Automatically handles connection, authentication and leader settlement.
48
52
  *
49
53
  * @param command - Command code to send
50
54
  * @param payload - Command payload buffer
@@ -69,11 +73,31 @@ export declare class CommandResponseStream extends EventEmitter {
69
73
  */
70
74
  _processNext(command: number, payload: Buffer, handleResp?: boolean): Promise<CommandResponse>;
71
75
  private _processVsrLogin;
72
- private _processClassic;
73
76
  private _processVsr;
74
77
  private _exchange;
75
78
  private isUnloggedCommand;
76
- private _ensureVsrLeader;
79
+ /**
80
+ * Moves a freshly authenticated session to the cluster leader.
81
+ *
82
+ * Only the leader accepts replicated commands, and the roster read is
83
+ * auth-gated, so the topology cannot be inspected before a login binds a
84
+ * session. The redirect drops that session along with the socket, so the
85
+ * login is replayed on the leader and its answer supersedes the one from the
86
+ * node the client dialed. Leadership can move between the roster read and
87
+ * the replay, so each freshly bound hop rechecks the roster under a bounded
88
+ * redirect budget.
89
+ *
90
+ * @returns The leader's login response, or undefined when the client stays
91
+ */
92
+ private _settleOnLeader;
93
+ /**
94
+ * Reads the cluster roster and picks the endpoint to settle on.
95
+ *
96
+ * Best effort: an unreadable roster, `Unauthenticated` included (the session
97
+ * died between the login and this read), keeps the client on its current
98
+ * node instead of failing a login that already succeeded.
99
+ */
100
+ private _readLeaderEndpoint;
77
101
  /**
78
102
  * Fails all queued commands with the given error.
79
103
  *