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.
- package/README.md +58 -1
- package/dist/client/client.config.d.ts +6 -0
- package/dist/client/client.config.js +38 -0
- package/dist/client/client.config.test.d.ts +2 -0
- package/dist/client/client.config.test.js +71 -0
- package/dist/client/client.connection.d.ts +29 -8
- package/dist/client/client.connection.js +232 -79
- package/dist/client/client.connection.test.d.ts +2 -0
- package/dist/client/client.connection.test.js +385 -0
- package/dist/client/client.d.ts +4 -4
- package/dist/client/client.frame.d.ts +31 -0
- package/dist/client/client.frame.js +168 -0
- package/dist/client/client.frame.test.d.ts +2 -0
- package/dist/client/client.frame.test.js +110 -0
- package/dist/client/client.js +35 -34
- package/dist/client/client.socket.d.ts +27 -6
- package/dist/client/client.socket.js +296 -44
- package/dist/client/client.socket.test.d.ts +2 -0
- package/dist/client/client.socket.test.js +539 -0
- package/dist/client/client.type.d.ts +17 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/e2e/tcp.consumer-group.e2e.js +6 -2
- package/dist/e2e/tcp.consumer-stream.e2e.js +5 -1
- package/dist/e2e/tcp.raw.e2e.js +14 -2
- package/dist/e2e/test-client.utils.js +2 -0
- package/dist/e2e/tls.system.e2e.js +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/stream/consumer-stream.js +5 -0
- package/dist/wire/command-set.d.ts +5 -5
- package/dist/wire/command-set.test.js +4 -2
- package/dist/wire/command.code.d.ts +3 -0
- package/dist/wire/command.code.js +4 -1
- package/dist/wire/command.utils.js +5 -1
- package/dist/wire/command.utils.test.d.ts +2 -0
- package/dist/wire/command.utils.test.js +35 -0
- package/dist/wire/consumer-group/delete-group.command.d.ts +4 -13
- package/dist/wire/consumer-group/delete-group.command.js +1 -1
- package/dist/wire/consumer-group/get-group.command.d.ts +4 -14
- package/dist/wire/consumer-group/group.utils.d.ts +6 -0
- package/dist/wire/consumer-group/index.d.ts +1 -0
- package/dist/wire/consumer-group/index.js +1 -0
- package/dist/wire/consumer-group/join-group.command.d.ts +4 -13
- package/dist/wire/consumer-group/join-group.command.js +1 -1
- package/dist/wire/consumer-group/leave-group.command.d.ts +4 -13
- package/dist/wire/consumer-group/leave-group.command.js +1 -1
- package/dist/wire/consumer-group/sync-group.command.d.ts +17 -0
- package/dist/wire/consumer-group/sync-group.command.js +45 -0
- package/dist/wire/consumer-group/sync-group.command.test.d.ts +2 -0
- package/dist/wire/consumer-group/sync-group.command.test.js +71 -0
- package/dist/wire/error.code.js +10 -22
- package/dist/wire/error.code.test.d.ts +2 -0
- package/dist/wire/error.code.test.js +33 -0
- package/dist/wire/error.utils.d.ts +6 -1
- package/dist/wire/error.utils.js +13 -2
- package/dist/wire/message/header.type.d.ts +1 -1
- package/dist/wire/message/poll-messages.command.d.ts +5 -3
- package/dist/wire/message/poll-messages.command.js +151 -2
- package/dist/wire/message/poll-messages.command.test.d.ts +2 -0
- package/dist/wire/message/poll-messages.command.test.js +301 -0
- package/dist/wire/offset/get-offset.command.d.ts +1 -1
- package/dist/wire/offset/offset.utils.d.ts +2 -2
- package/dist/wire/offset/offset.utils.js +2 -2
- package/dist/wire/offset/store-offset.command.d.ts +1 -1
- package/dist/wire/topic/topic.utils.js +2 -2
- package/dist/wire/topic/topic.utils.test.d.ts +2 -0
- package/dist/wire/topic/topic.utils.test.js +42 -0
- package/dist/wire/vsr/header.d.ts +108 -0
- package/dist/wire/vsr/header.js +121 -0
- package/dist/wire/vsr/header.test.d.ts +2 -0
- package/dist/wire/vsr/header.test.js +75 -0
- package/dist/wire/vsr/index.d.ts +18 -0
- package/dist/wire/vsr/index.js +120 -0
- package/dist/wire/vsr/namespace.d.ts +19 -0
- package/dist/wire/vsr/namespace.js +177 -0
- package/dist/wire/vsr/namespace.test.d.ts +2 -0
- package/dist/wire/vsr/namespace.test.js +133 -0
- package/dist/wire/vsr/operation.d.ts +65 -0
- package/dist/wire/vsr/operation.js +144 -0
- package/dist/wire/vsr/operation.test.d.ts +2 -0
- package/dist/wire/vsr/operation.test.js +92 -0
- package/dist/wire/vsr/register.d.ts +27 -0
- package/dist/wire/vsr/register.js +90 -0
- package/dist/wire/vsr/register.test.d.ts +2 -0
- package/dist/wire/vsr/register.test.js +67 -0
- package/dist/wire/vsr/reply.d.ts +32 -0
- package/dist/wire/vsr/reply.js +151 -0
- package/dist/wire/vsr/reply.test.d.ts +2 -0
- package/dist/wire/vsr/reply.test.js +120 -0
- package/dist/wire/vsr/session.d.ts +38 -0
- package/dist/wire/vsr/session.js +105 -0
- package/dist/wire/vsr/session.test.d.ts +2 -0
- package/dist/wire/vsr/session.test.js +60 -0
- package/dist/wire/vsr/vsr.test.d.ts +2 -0
- package/dist/wire/vsr/vsr.test.js +72 -0
- 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
|
|
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,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
|
-
/**
|
|
26
|
-
private
|
|
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<
|
|
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<
|
|
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):
|
|
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
|
-
*
|
|
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
|
|
74
|
+
* @returns Promise resolving after the delay
|
|
73
75
|
*/
|
|
74
|
-
function
|
|
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
|
-
/**
|
|
103
|
-
|
|
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.
|
|
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
|
-
*
|
|
123
|
-
*
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
149
|
+
this.emit('error', err);
|
|
136
150
|
});
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
169
|
+
if (!this.ending)
|
|
170
|
+
void this.reconnect().catch(() => undefined);
|
|
142
171
|
});
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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.
|
|
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.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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
|