apache-iggy 0.8.1-edge.2 → 0.8.1-edge.4

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 (97) hide show
  1. package/README.md +58 -1
  2. package/dist/client/client.config.d.ts +6 -0
  3. package/dist/client/client.config.js +38 -0
  4. package/dist/client/client.config.test.d.ts +2 -0
  5. package/dist/client/client.config.test.js +71 -0
  6. package/dist/client/client.connection.d.ts +29 -8
  7. package/dist/client/client.connection.js +232 -79
  8. package/dist/client/client.connection.test.d.ts +2 -0
  9. package/dist/client/client.connection.test.js +385 -0
  10. package/dist/client/client.d.ts +4 -4
  11. package/dist/client/client.frame.d.ts +31 -0
  12. package/dist/client/client.frame.js +168 -0
  13. package/dist/client/client.frame.test.d.ts +2 -0
  14. package/dist/client/client.frame.test.js +110 -0
  15. package/dist/client/client.js +35 -34
  16. package/dist/client/client.socket.d.ts +27 -6
  17. package/dist/client/client.socket.js +296 -44
  18. package/dist/client/client.socket.test.d.ts +2 -0
  19. package/dist/client/client.socket.test.js +539 -0
  20. package/dist/client/client.type.d.ts +17 -1
  21. package/dist/client/index.d.ts +1 -0
  22. package/dist/client/index.js +1 -0
  23. package/dist/e2e/tcp.consumer-group.e2e.js +6 -2
  24. package/dist/e2e/tcp.consumer-stream.e2e.js +5 -1
  25. package/dist/e2e/tcp.raw.e2e.js +14 -2
  26. package/dist/e2e/test-client.utils.js +2 -0
  27. package/dist/e2e/tls.system.e2e.js +4 -0
  28. package/dist/index.d.ts +3 -0
  29. package/dist/index.js +3 -0
  30. package/dist/stream/consumer-stream.js +5 -0
  31. package/dist/wire/command-set.d.ts +5 -5
  32. package/dist/wire/command-set.test.js +4 -2
  33. package/dist/wire/command.code.d.ts +3 -0
  34. package/dist/wire/command.code.js +4 -1
  35. package/dist/wire/command.utils.js +5 -1
  36. package/dist/wire/command.utils.test.d.ts +2 -0
  37. package/dist/wire/command.utils.test.js +35 -0
  38. package/dist/wire/consumer-group/delete-group.command.d.ts +4 -13
  39. package/dist/wire/consumer-group/delete-group.command.js +1 -1
  40. package/dist/wire/consumer-group/get-group.command.d.ts +4 -14
  41. package/dist/wire/consumer-group/group.utils.d.ts +6 -0
  42. package/dist/wire/consumer-group/index.d.ts +1 -0
  43. package/dist/wire/consumer-group/index.js +1 -0
  44. package/dist/wire/consumer-group/join-group.command.d.ts +4 -13
  45. package/dist/wire/consumer-group/join-group.command.js +1 -1
  46. package/dist/wire/consumer-group/leave-group.command.d.ts +4 -13
  47. package/dist/wire/consumer-group/leave-group.command.js +1 -1
  48. package/dist/wire/consumer-group/sync-group.command.d.ts +17 -0
  49. package/dist/wire/consumer-group/sync-group.command.js +45 -0
  50. package/dist/wire/consumer-group/sync-group.command.test.d.ts +2 -0
  51. package/dist/wire/consumer-group/sync-group.command.test.js +71 -0
  52. package/dist/wire/error.code.js +10 -22
  53. package/dist/wire/error.code.test.d.ts +2 -0
  54. package/dist/wire/error.code.test.js +33 -0
  55. package/dist/wire/error.utils.d.ts +6 -1
  56. package/dist/wire/error.utils.js +13 -2
  57. package/dist/wire/message/header.type.d.ts +1 -1
  58. package/dist/wire/message/poll-messages.command.d.ts +5 -3
  59. package/dist/wire/message/poll-messages.command.js +151 -2
  60. package/dist/wire/message/poll-messages.command.test.d.ts +2 -0
  61. package/dist/wire/message/poll-messages.command.test.js +301 -0
  62. package/dist/wire/offset/get-offset.command.d.ts +1 -1
  63. package/dist/wire/offset/offset.utils.d.ts +2 -2
  64. package/dist/wire/offset/offset.utils.js +2 -2
  65. package/dist/wire/offset/store-offset.command.d.ts +1 -1
  66. package/dist/wire/topic/topic.utils.js +2 -2
  67. package/dist/wire/topic/topic.utils.test.d.ts +2 -0
  68. package/dist/wire/topic/topic.utils.test.js +42 -0
  69. package/dist/wire/vsr/header.d.ts +108 -0
  70. package/dist/wire/vsr/header.js +121 -0
  71. package/dist/wire/vsr/header.test.d.ts +2 -0
  72. package/dist/wire/vsr/header.test.js +75 -0
  73. package/dist/wire/vsr/index.d.ts +18 -0
  74. package/dist/wire/vsr/index.js +120 -0
  75. package/dist/wire/vsr/namespace.d.ts +19 -0
  76. package/dist/wire/vsr/namespace.js +177 -0
  77. package/dist/wire/vsr/namespace.test.d.ts +2 -0
  78. package/dist/wire/vsr/namespace.test.js +133 -0
  79. package/dist/wire/vsr/operation.d.ts +65 -0
  80. package/dist/wire/vsr/operation.js +144 -0
  81. package/dist/wire/vsr/operation.test.d.ts +2 -0
  82. package/dist/wire/vsr/operation.test.js +92 -0
  83. package/dist/wire/vsr/register.d.ts +27 -0
  84. package/dist/wire/vsr/register.js +90 -0
  85. package/dist/wire/vsr/register.test.d.ts +2 -0
  86. package/dist/wire/vsr/register.test.js +67 -0
  87. package/dist/wire/vsr/reply.d.ts +32 -0
  88. package/dist/wire/vsr/reply.js +151 -0
  89. package/dist/wire/vsr/reply.test.d.ts +2 -0
  90. package/dist/wire/vsr/reply.test.js +120 -0
  91. package/dist/wire/vsr/session.d.ts +38 -0
  92. package/dist/wire/vsr/session.js +105 -0
  93. package/dist/wire/vsr/session.test.d.ts +2 -0
  94. package/dist/wire/vsr/session.test.js +60 -0
  95. package/dist/wire/vsr/vsr.test.d.ts +2 -0
  96. package/dist/wire/vsr/vsr.test.js +72 -0
  97. package/package.json +10 -8
package/README.md CHANGED
@@ -30,6 +30,62 @@ npm i --save apache-iggy
30
30
 
31
31
  ## basic usage
32
32
 
33
+ ### Response frame limit
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.
36
+
37
+ ### VSR framing
38
+
39
+ Classic framing remains the default. Select VSR explicitly when connecting to
40
+ an Iggy VSR server:
41
+
42
+ ```typescript
43
+ import { SimpleClient, getRawClient } from "apache-iggy";
44
+
45
+ const config = {
46
+ protocol: "vsr" as const,
47
+ transport: "TCP" as const,
48
+ options: { host: "127.0.0.1", port: 8090 },
49
+ credentials: { username: "iggy", password: "iggy" },
50
+ };
51
+ const client = new SimpleClient(getRawClient(config));
52
+ const stats = await client.system.getStats();
53
+ ```
54
+
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.
59
+
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.
61
+
62
+ VSR authentication translates the existing password and personal-access-token
63
+ login APIs into the register handshake required by the consensus protocol. A
64
+ disconnect or eviction invalidates the session, and later work must register a
65
+ new session. Transient not-committed responses retry the exact encoded request
66
+ within one bounded deadline. A disconnected mutation is never replayed under a
67
+ new session.
68
+
69
+ 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
+
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.
72
+
73
+ ```typescript
74
+ import { ResponseError } from "apache-iggy";
75
+
76
+ try {
77
+ await client.sendBinaryRequest(60_000, Buffer.from("opaque request"));
78
+ } catch (error) {
79
+ if (error instanceof ResponseError) {
80
+ console.error(error.commandCode, error.errorCode);
81
+ }
82
+ }
83
+ ```
84
+
85
+ The client includes its npm package version and the binary protocol crate
86
+ version in VSR registration. An incompatible server rejects registration with
87
+ a protocol-version error instead of accepting a mismatched wire contract.
88
+
33
89
  ```ts
34
90
  import { Client } from "apache-iggy";
35
91
 
@@ -60,7 +116,8 @@ npm run build
60
116
 
61
117
  ### test
62
118
 
63
- note: use env var `IGGY_TCP_ADDRESS="host:port"` to set server address for bdd and e2e tests.
119
+ note: use env var `IGGY_TCP_ADDRESS="host:port"` to set the server
120
+ address for bdd and e2e tests.
64
121
 
65
122
  #### unit tests
66
123
 
@@ -0,0 +1,6 @@
1
+ import type { ClientConfig, Protocol } from './client.type.js';
2
+ export declare const DEFAULT_MAX_RESPONSE_FRAME_SIZE: number;
3
+ export declare const normalizeClientConfig: (config: ClientConfig) => ClientConfig & {
4
+ protocol: Protocol;
5
+ };
6
+ //# sourceMappingURL=client.config.d.ts.map
@@ -0,0 +1,38 @@
1
+ // Licensed to the Apache Software Foundation (ASF) under one
2
+ // or more contributor license agreements. See the NOTICE file
3
+ // distributed with this work for additional information
4
+ // regarding copyright ownership. The ASF licenses this file
5
+ // to you under the Apache License, Version 2.0 (the
6
+ // "License"); you may not use this file except in compliance
7
+ // with the License. You may obtain a copy of the License at
8
+ //
9
+ // http://www.apache.org/licenses/LICENSE-2.0
10
+ //
11
+ // Unless required by applicable law or agreed to in writing,
12
+ // software distributed under the License is distributed on an
13
+ // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ // KIND, either express or implied. See the License for the
15
+ // specific language governing permissions and limitations
16
+ // under the License.
17
+ export const DEFAULT_MAX_RESPONSE_FRAME_SIZE = 64 * 1024 * 1024;
18
+ const isProtocol = (value) => value === 'classic' || value === 'vsr';
19
+ export const normalizeClientConfig = (config) => {
20
+ const protocol = config.protocol ?? 'classic';
21
+ if (!isProtocol(protocol))
22
+ throw new TypeError(`unsupported wire protocol: ${String(protocol)}`);
23
+ const maxResponseFrameSize = config.maxResponseFrameSize ?? DEFAULT_MAX_RESPONSE_FRAME_SIZE;
24
+ if (!Number.isSafeInteger(maxResponseFrameSize) ||
25
+ maxResponseFrameSize < 256)
26
+ 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))
29
+ throw new TypeError('VSR clients currently support exactly one pooled connection');
30
+ return {
31
+ ...config,
32
+ protocol,
33
+ options: { ...config.options },
34
+ maxResponseFrameSize,
35
+ ...(protocol === 'vsr' ? { poolSize: { min: 1, max: 1 } } : {})
36
+ };
37
+ };
38
+ //# sourceMappingURL=client.config.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=client.config.test.d.ts.map
@@ -0,0 +1,71 @@
1
+ // Licensed to the Apache Software Foundation (ASF) under one
2
+ // or more contributor license agreements. See the NOTICE file
3
+ // distributed with this work for additional information
4
+ // regarding copyright ownership. The ASF licenses this file
5
+ // to you under the Apache License, Version 2.0 (the
6
+ // "License"); you may not use this file except in compliance
7
+ // with the License. You may obtain a copy of the License at
8
+ //
9
+ // http://www.apache.org/licenses/LICENSE-2.0
10
+ //
11
+ // Unless required by applicable law or agreed to in writing,
12
+ // software distributed under the License is distributed on an
13
+ // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14
+ // KIND, either express or implied. See the License for the
15
+ // specific language governing permissions and limitations
16
+ // under the License.
17
+ import assert from 'node:assert/strict';
18
+ import { describe, it } from 'node:test';
19
+ import { DEFAULT_MAX_RESPONSE_FRAME_SIZE, normalizeClientConfig } from './client.config.js';
20
+ const config = () => ({
21
+ transport: 'TCP',
22
+ options: { host: '127.0.0.1', port: 8090 },
23
+ credentials: { username: 'iggy', password: 'iggy' }
24
+ });
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 });
33
+ assert.equal(normalized.maxResponseFrameSize, DEFAULT_MAX_RESPONSE_FRAME_SIZE);
34
+ });
35
+ it('restricts VSR to one pooled connection', () => {
36
+ const normalized = normalizeClientConfig({
37
+ ...config(),
38
+ protocol: 'vsr'
39
+ });
40
+ assert.deepEqual(normalized.poolSize, { min: 1, max: 1 });
41
+ assert.throws(() => normalizeClientConfig({
42
+ ...config(),
43
+ protocol: 'vsr',
44
+ poolSize: { max: 2 }
45
+ }), /exactly one pooled connection/);
46
+ });
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', () => {
54
+ const normalized = normalizeClientConfig({
55
+ ...config(),
56
+ protocol: 'vsr',
57
+ transport: 'TLS'
58
+ });
59
+ assert.equal(normalized.protocol, 'vsr');
60
+ assert.equal(normalized.transport, 'TLS');
61
+ assert.deepEqual(normalized.poolSize, { min: 1, max: 1 });
62
+ });
63
+ it('rejects unsafe response frame limits', () => {
64
+ for (const maxResponseFrameSize of [0, 255, 1.5, Number.MAX_VALUE])
65
+ assert.throws(() => normalizeClientConfig({
66
+ ...config(),
67
+ maxResponseFrameSize
68
+ }), /maxResponseFrameSize/);
69
+ });
70
+ });
71
+ //# sourceMappingURL=client.config.test.js.map
@@ -16,34 +16,54 @@ export declare class IggyConnection extends EventEmitter {
16
16
  connecting: boolean;
17
17
  /** Whether the connection is being intentionally closed */
18
18
  ending: boolean;
19
- /** Whether waiting for more data to complete a response */
20
- private waitingResponseEnd;
21
19
  /** Reconnection configuration */
22
20
  private reconnectOption;
23
21
  /** Number of reconnection attempts made */
24
22
  private reconnectCount;
25
- /** Buffer for incomplete response data */
26
- private readBuffers;
23
+ /** Shared promise for concurrent callers waiting on one connection attempt */
24
+ private connectPromise?;
25
+ /** Shared promise for callers waiting on automatic reconnection */
26
+ private reconnectPromise?;
27
+ /** Endpoint the client was configured with, kept across leader redirects */
28
+ private readonly seedOptions;
29
+ /** Incremental response frame decoder */
30
+ private responseDecoder;
27
31
  /**
28
32
  * Creates a new IggyConnection.
29
33
  *
30
34
  * @param config - Client configuration
31
35
  */
32
36
  constructor(config: ClientConfig);
37
+ /**
38
+ * Attaches the lifecycle listeners exactly once per socket instance.
39
+ * Attaching them in `connect()` would stack duplicate handlers whenever a
40
+ * failed attempt is retried on the same socket.
41
+ */
42
+ private _installSocket;
33
43
  /**
34
44
  * Establishes the connection to the server.
35
- * Sets up event handlers for data, errors, and disconnection.
36
45
  *
37
46
  * @returns Promise that resolves when connected
38
47
  */
39
- connect(): Promise<unknown>;
48
+ connect(): Promise<this>;
49
+ private _waitForConnection;
40
50
  /**
41
51
  * Attempts to reconnect to the server.
42
52
  * Respects maxRetries limit and emits error when exceeded.
43
53
  *
44
54
  * @param err - Optional error that triggered the reconnection
45
55
  */
46
- reconnect(err?: Error): Promise<boolean | undefined>;
56
+ reconnect(err?: Error): Promise<this | undefined>;
57
+ private _reconnectUntilConnected;
58
+ /**
59
+ * Alternates reconnect dials between the current endpoint and the
60
+ * configured seed. After a leader redirect the current endpoint may die
61
+ * with the leader, and the seed is the way back to the rest of the cluster.
62
+ */
63
+ private _reconnectTarget;
64
+ redirect(host: string, port: number): Promise<void>;
65
+ abort(): void;
66
+ isConnectedTo(host: string, port: number): boolean;
47
67
  /**
48
68
  * Destroys the connection and marks it as ending.
49
69
  */
@@ -66,6 +86,7 @@ export declare class IggyConnection extends EventEmitter {
66
86
  * @param payload - Command payload
67
87
  * @returns True if the write was successful
68
88
  */
69
- writeCommand(command: number, payload: Buffer): boolean;
89
+ writeCommand(command: number, payload: Buffer): void;
90
+ writeFrame(frame: Buffer): void;
70
91
  }
71
92
  //# sourceMappingURL=client.connection.d.ts.map
@@ -20,6 +20,10 @@ import { createConnection } from 'node:net';
20
20
  import { connect as TLSConnect } from 'node:tls';
21
21
  import { serializeCommand } from './client.utils.js';
22
22
  import { debug } from './client.debug.js';
23
+ import { DEFAULT_MAX_RESPONSE_FRAME_SIZE } from './client.config.js';
24
+ import { ProtocolFrameError, ResponseFrameDecoder } from './client.frame.js';
25
+ import { Command2, peekCommand } from '../wire/vsr/header.js';
26
+ import { evictionError } from '../wire/vsr/reply.js';
23
27
  /**
24
28
  * Creates a TCP socket connection.
25
29
  *
@@ -64,18 +68,14 @@ const DefaultReconnectOption = {
64
68
  maxRetries: 12
65
69
  };
66
70
  /**
67
- * Recreates a socket after a delay.
68
- * Used for reconnection attempts.
71
+ * Waits before a reconnection attempt.
69
72
  *
70
- * @param option - Client configuration
71
73
  * @param timer - Delay in milliseconds before recreating
72
- * @returns Promise resolving to a new socket
74
+ * @returns Promise resolving after the delay
73
75
  */
74
- function recreate(option, timer = 1000) {
76
+ function waitForReconnect(timer = 1000) {
75
77
  return new Promise((resolve) => {
76
- setTimeout(() => {
77
- resolve(getTransport(option));
78
- }, timer);
78
+ setTimeout(resolve, timer);
79
79
  });
80
80
  }
81
81
  /**
@@ -93,14 +93,18 @@ export class IggyConnection extends EventEmitter {
93
93
  connecting;
94
94
  /** Whether the connection is being intentionally closed */
95
95
  ending;
96
- /** Whether waiting for more data to complete a response */
97
- waitingResponseEnd;
98
96
  /** Reconnection configuration */
99
97
  reconnectOption;
100
98
  /** Number of reconnection attempts made */
101
99
  reconnectCount;
102
- /** Buffer for incomplete response data */
103
- readBuffers;
100
+ /** Shared promise for concurrent callers waiting on one connection attempt */
101
+ connectPromise;
102
+ /** Shared promise for callers waiting on automatic reconnection */
103
+ reconnectPromise;
104
+ /** Endpoint the client was configured with, kept across leader redirects */
105
+ seedOptions;
106
+ /** Incremental response frame decoder */
107
+ responseDecoder;
104
108
  /**
105
109
  * Creates a new IggyConnection.
106
110
  *
@@ -109,46 +113,112 @@ export class IggyConnection extends EventEmitter {
109
113
  constructor(config) {
110
114
  super();
111
115
  this.config = config;
112
- this.socket = getTransport(config);
113
116
  this.connected = false;
114
117
  this.connecting = false;
115
118
  this.ending = false;
116
- this.waitingResponseEnd = false;
117
119
  this.reconnectOption = { ...DefaultReconnectOption, ...config.reconnect };
120
+ this.seedOptions = { ...config.options };
118
121
  this.reconnectCount = 0;
119
- this.readBuffers = Buffer.allocUnsafe(0);
122
+ this.connectPromise = undefined;
123
+ this.reconnectPromise = undefined;
124
+ this.responseDecoder = new ResponseFrameDecoder(config.protocol ?? 'classic', config.maxResponseFrameSize ?? DEFAULT_MAX_RESPONSE_FRAME_SIZE);
125
+ this.socket = this._installSocket(getTransport(config));
120
126
  }
121
127
  /**
122
- * Establishes the connection to the server.
123
- * Sets up event handlers for data, errors, and disconnection.
124
- *
125
- * @returns Promise that resolves when connected
128
+ * Attaches the lifecycle listeners exactly once per socket instance.
129
+ * Attaching them in `connect()` would stack duplicate handlers whenever a
130
+ * failed attempt is retried on the same socket.
126
131
  */
127
- connect() {
128
- this.connecting = true;
129
- this.socket.on('data', this._onData.bind(this));
130
- this.socket.on('error', async (err) => {
132
+ _installSocket(socket) {
133
+ socket.on('data', (data) => {
134
+ if (this.socket !== socket)
135
+ return;
136
+ if (!Buffer.isBuffer(data)) {
137
+ this.emit('error', new ProtocolFrameError('socket returned text instead of binary data'));
138
+ socket.destroy();
139
+ return;
140
+ }
141
+ this._onData(data);
142
+ });
143
+ socket.on('error', (err) => {
144
+ if (this.socket !== socket)
145
+ return;
131
146
  debug('socket/error event', err, err.code, this.ending);
132
- // errors about disconnections should be ignored during disconnect
133
147
  if (this.ending && (err?.code === 'ECONNRESET' || err?.code === 'EPIPE'))
134
148
  return;
135
- this.reconnect(err);
149
+ this.emit('error', err);
136
150
  });
137
- this.socket.once('end', async (hadError) => {
138
- debug('socket/close#END event', hadError);
151
+ socket.once('connect', () => {
152
+ if (this.socket !== socket)
153
+ return;
154
+ debug('socket/connect event');
155
+ this.connected = true;
156
+ this.connecting = false;
157
+ this.reconnectCount = 0;
158
+ this.emit('connect');
159
+ });
160
+ socket.once('close', (hadError) => {
161
+ if (this.socket !== socket)
162
+ return;
163
+ debug('socket/close event', hadError);
139
164
  this.connected = false;
165
+ this.connecting = false;
166
+ this.connectPromise = undefined;
167
+ this._endResponseWait();
140
168
  this.emit('disconnected', hadError);
141
- this.reconnect();
169
+ if (!this.ending)
170
+ void this.reconnect().catch(() => undefined);
142
171
  });
143
- return new Promise((resolve /**, reject*/) => {
144
- this.socket.once('connect', () => {
145
- debug('socket/connect event');
146
- this.connected = true;
147
- this.connecting = false;
148
- this.reconnectCount = 0;
149
- this.emit('connect');
172
+ return socket;
173
+ }
174
+ /**
175
+ * Establishes the connection to the server.
176
+ *
177
+ * @returns Promise that resolves when connected
178
+ */
179
+ connect() {
180
+ if (this.ending)
181
+ return Promise.reject(new Error('connection is closed'));
182
+ if (this.connected)
183
+ return Promise.resolve(this);
184
+ if (this.reconnectPromise)
185
+ return this.reconnectPromise;
186
+ if (this.connectPromise)
187
+ return this.connectPromise;
188
+ if (this.socket.destroyed)
189
+ this.socket = this._installSocket(getTransport(this.config));
190
+ this.connecting = true;
191
+ const socket = this.socket;
192
+ const connectPromise = this._waitForConnection(socket);
193
+ this.connectPromise = connectPromise;
194
+ const clearConnectPromise = () => {
195
+ if (this.connectPromise === connectPromise)
196
+ this.connectPromise = undefined;
197
+ };
198
+ void connectPromise.then(clearConnectPromise, clearConnectPromise);
199
+ return connectPromise;
200
+ }
201
+ _waitForConnection(socket) {
202
+ return new Promise((resolve, reject) => {
203
+ const cleanup = () => {
204
+ socket.removeListener('connect', resolveConnect);
205
+ socket.removeListener('error', rejectConnect);
206
+ socket.removeListener('close', rejectClosed);
207
+ };
208
+ const rejectConnect = (error) => {
209
+ cleanup();
210
+ reject(error);
211
+ };
212
+ const rejectClosed = () => {
213
+ rejectConnect(new Error('connection closed before it was established'));
214
+ };
215
+ const resolveConnect = () => {
216
+ cleanup();
150
217
  resolve(this);
151
- });
218
+ };
219
+ socket.once('error', rejectConnect);
220
+ socket.once('close', rejectClosed);
221
+ socket.once('connect', resolveConnect);
152
222
  });
153
223
  }
154
224
  /**
@@ -158,35 +228,128 @@ export class IggyConnection extends EventEmitter {
158
228
  * @param err - Optional error that triggered the reconnection
159
229
  */
160
230
  async reconnect(err) {
231
+ if (this.ending || this.connected)
232
+ return;
233
+ if (this.reconnectPromise)
234
+ return this.reconnectPromise;
161
235
  const { enabled, interval, maxRetries } = this.reconnectOption;
162
236
  debug('reconnect# event/reconnect?', {
163
237
  reconnect: { enabled, interval, maxRetries },
164
238
  count: this.reconnectCount,
165
239
  lastError: err
166
240
  });
167
- if (!enabled || this.reconnectCount > maxRetries) {
168
- debug(`reconnect reached maxRetries of ${maxRetries}`, err);
169
- return this.emit('error', new Error(`reconnect maxRetries exceeded (count: ${this.reconnectCount})`, { cause: err }));
241
+ const reconnectPromise = this._reconnectUntilConnected(enabled, interval, maxRetries, err);
242
+ this.reconnectPromise = reconnectPromise;
243
+ try {
244
+ return await reconnectPromise;
245
+ }
246
+ catch (error) {
247
+ if (!this.ending)
248
+ this.emit('error', error);
249
+ return;
250
+ }
251
+ finally {
252
+ if (this.reconnectPromise === reconnectPromise)
253
+ this.reconnectPromise = undefined;
254
+ this.connecting = false;
170
255
  }
171
- /** recreate socket */
172
- this.connecting = true;
173
- this.reconnectCount += 1;
174
- this.socket = await recreate(this.config, interval);
175
- this.connect();
256
+ }
257
+ async _reconnectUntilConnected(enabled, interval, maxRetries, initialError) {
258
+ let lastError = initialError;
259
+ let expectedSocket = this.socket;
260
+ let attempt = 0;
261
+ while (enabled && this.reconnectCount < maxRetries) {
262
+ this.connecting = true;
263
+ this.reconnectCount += 1;
264
+ await waitForReconnect(interval);
265
+ if (this.ending)
266
+ throw new Error('connection is closed', { cause: lastError });
267
+ // A redirect may replace the socket at any point. Defer to the active
268
+ // connection instead of dialing the superseded endpoint.
269
+ if (this.connected || this.socket !== expectedSocket)
270
+ return this.connect();
271
+ const options = this._reconnectTarget(attempt);
272
+ attempt += 1;
273
+ const socket = this._installSocket(getTransport({ ...this.config, options }));
274
+ this.socket = socket;
275
+ expectedSocket = socket;
276
+ try {
277
+ await this._waitForConnection(socket);
278
+ if (this.socket !== socket)
279
+ return this.connect();
280
+ this.config.options = options;
281
+ return this;
282
+ }
283
+ catch (error) {
284
+ lastError = error instanceof Error
285
+ ? error
286
+ : new Error(String(error));
287
+ debug('reconnect attempt failed', lastError);
288
+ }
289
+ }
290
+ debug(`reconnect reached maxRetries of ${maxRetries}`, lastError);
291
+ throw new Error(`reconnect maxRetries exceeded (count: ${this.reconnectCount})`, { cause: lastError });
292
+ }
293
+ /**
294
+ * Alternates reconnect dials between the current endpoint and the
295
+ * configured seed. After a leader redirect the current endpoint may die
296
+ * with the leader, and the seed is the way back to the rest of the cluster.
297
+ */
298
+ _reconnectTarget(attempt) {
299
+ const current = this.config.options;
300
+ if (this.seedOptions.host === current.host &&
301
+ this.seedOptions.port === current.port)
302
+ return current;
303
+ return attempt % 2 === 0 ? current : this.seedOptions;
304
+ }
305
+ async redirect(host, port) {
306
+ const redirectedOptions = { ...this.config.options, host, port };
307
+ const redirectedConfig = {
308
+ ...this.config,
309
+ options: redirectedOptions
310
+ };
311
+ // Destroying the old socket settles any dial still waiting on it. Its
312
+ // lifecycle listeners stay attached but go inert once the socket is
313
+ // replaced below, so surface the drop to in-flight exchanges ourselves.
314
+ this.socket.destroy();
315
+ this.connected = false;
316
+ this.connecting = false;
317
+ this.connectPromise = undefined;
318
+ this.reconnectPromise = undefined;
319
+ this._endResponseWait();
320
+ this.socket = this._installSocket(getTransport(redirectedConfig));
321
+ this.emit('disconnected', false);
322
+ await this.connect();
323
+ this.config.options = redirectedOptions;
324
+ }
325
+ abort() {
326
+ this._endResponseWait();
327
+ this.socket.destroy();
328
+ }
329
+ isConnectedTo(host, port) {
330
+ const target = normalizeHost(host);
331
+ if (this.socket.remotePort === port &&
332
+ normalizeHost(this.socket.remoteAddress) === target)
333
+ return true;
334
+ // A roster may advertise a DNS name while the socket reports a resolved
335
+ // address; falling back to the configured endpoint avoids a redirect to
336
+ // the peer the client is already connected to.
337
+ return this.config.options.port === port &&
338
+ normalizeHost(this.config.options.host) === target;
176
339
  }
177
340
  /**
178
341
  * Destroys the connection and marks it as ending.
179
342
  */
180
343
  _destroy() {
181
344
  this.ending = true;
345
+ this._endResponseWait();
182
346
  this.socket.destroy();
183
347
  }
184
348
  /**
185
349
  * Clears the response buffer and resets the waiting state.
186
350
  */
187
351
  _endResponseWait() {
188
- this.readBuffers = Buffer.allocUnsafe(0);
189
- this.waitingResponseEnd = false;
352
+ this.responseDecoder.clear();
190
353
  }
191
354
  /**
192
355
  * Handles incoming data from the socket.
@@ -195,40 +358,21 @@ export class IggyConnection extends EventEmitter {
195
358
  * @param data - Incoming data buffer
196
359
  */
197
360
  _onData(data) {
198
- debug('ONDATA', typeof data, Buffer.isBuffer(data), data?.length, this.waitingResponseEnd);
199
- // Append new data to any buffered data
200
- if (this.waitingResponseEnd && this.readBuffers.length > 0) {
201
- data = Buffer.concat([this.readBuffers, data]);
202
- }
203
- // Keep processing while we have enough data
204
- let offset = 0;
205
- while (offset < data.length) {
206
- const remaining = data.length - offset;
207
- // Need at least 8 bytes for the header (4 bytes status + 4 bytes length)
208
- if (remaining < 8) {
209
- // Buffer the incomplete header and wait for more data
210
- this.waitingResponseEnd = true;
211
- this.readBuffers = data.subarray(offset);
212
- return;
361
+ debug('ONDATA', typeof data, Buffer.isBuffer(data), data?.length, this.responseDecoder.hasBufferedData);
362
+ try {
363
+ for (const response of this.responseDecoder.push(data)) {
364
+ if (this.config.protocol === 'vsr' &&
365
+ peekCommand(response) === Command2.Eviction)
366
+ this.emit('eviction', evictionError(response));
367
+ else
368
+ this.emit('response', response);
213
369
  }
214
- // Read the header
215
- const responseSize = data.readUInt32LE(offset + 4);
216
- const totalSize = 8 + responseSize;
217
- // Check if we have the complete response (header + payload)
218
- if (remaining < totalSize) {
219
- // Buffer the incomplete response and wait for more data
220
- this.waitingResponseEnd = true;
221
- this.readBuffers = data.subarray(offset);
222
- return;
223
- }
224
- // We have a complete response, extract it and emit
225
- const response = data.subarray(offset, offset + totalSize);
226
- this.emit('response', response);
227
- // Move to the next response
228
- offset += totalSize;
229
370
  }
230
- // All data processed, reset buffers
231
- this._endResponseWait();
371
+ catch (error) {
372
+ this._endResponseWait();
373
+ this.emit('error', error instanceof Error ? error : new Error(String(error)));
374
+ this.socket.destroy();
375
+ }
232
376
  }
233
377
  /**
234
378
  * Writes a command to the socket.
@@ -239,7 +383,16 @@ export class IggyConnection extends EventEmitter {
239
383
  */
240
384
  writeCommand(command, payload) {
241
385
  const cmd = serializeCommand(command, payload);
242
- return this.socket.write(cmd);
386
+ this.socket.write(cmd);
387
+ }
388
+ writeFrame(frame) {
389
+ this.socket.write(frame);
243
390
  }
244
391
  }
392
+ const normalizeHost = (host) => {
393
+ const normalized = (host ?? '').toLowerCase().replace(/^::ffff:/, '');
394
+ return normalized === 'localhost' || normalized === '::1'
395
+ ? '127.0.0.1'
396
+ : normalized;
397
+ };
245
398
  //# sourceMappingURL=client.connection.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=client.connection.test.d.ts.map