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.
- package/README.md +15 -10
- package/dist/client/client.config.d.ts +2 -4
- package/dist/client/client.config.js +2 -8
- package/dist/client/client.config.test.js +5 -22
- package/dist/client/client.connection.d.ts +0 -8
- package/dist/client/client.connection.js +2 -15
- package/dist/client/client.connection.test.js +18 -17
- package/dist/client/client.frame.d.ts +2 -5
- package/dist/client/client.frame.js +12 -22
- package/dist/client/client.frame.test.js +38 -53
- package/dist/client/client.socket.d.ts +30 -6
- package/dist/client/client.socket.js +95 -40
- package/dist/client/client.socket.test.js +178 -22
- package/dist/client/client.type.d.ts +0 -6
- package/dist/client/client.utils.d.ts +0 -29
- package/dist/client/client.utils.js +0 -56
- package/dist/client/client.utils.test.js +9 -20
- package/dist/e2e/tcp.cluster.e2e.js +5 -16
- package/dist/e2e/tcp.consumer-group.e2e.js +1 -5
- package/dist/e2e/tcp.consumer-stream.e2e.js +1 -5
- package/dist/e2e/tcp.send-message.e2e.js +0 -11
- package/dist/e2e/test-client.utils.js +0 -2
- package/dist/e2e/tls.system.e2e.js +0 -4
- package/dist/wire/cluster/cluster.type.d.ts +2 -2
- package/dist/wire/command-set.test.js +0 -1
- package/dist/wire/message/header.type.d.ts +1 -1
- package/dist/wire/message/poll-messages.command.js +1 -2
- package/dist/wire/vsr/header.d.ts +13 -9
- package/dist/wire/vsr/header.js +13 -8
- package/dist/wire/vsr/header.test.js +0 -5
- package/dist/wire/vsr/index.js +0 -3
- package/dist/wire/vsr/vsr.test.js +0 -7
- package/package.json +3 -4
- package/dist/wire/vsr/namespace.d.ts +0 -19
- package/dist/wire/vsr/namespace.js +0 -177
- package/dist/wire/vsr/namespace.test.d.ts +0 -2
- 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
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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)`
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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('
|
|
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
|
|
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('
|
|
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.
|
|
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 (
|
|
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:
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
assert.
|
|
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
|
|
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(
|
|
104
|
-
malformed.writeUInt32LE(
|
|
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
|
|
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: (
|
|
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(
|
|
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
|
|
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
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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 = (
|
|
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 >=
|
|
36
|
+
while (buffer.length - offset >= HEADER_SIZE) {
|
|
43
37
|
const available = buffer.length - offset;
|
|
44
|
-
const declaredSize = declaredFrameSize(
|
|
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(
|
|
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 <
|
|
88
|
+
if (this.bufferedLength < HEADER_SIZE)
|
|
99
89
|
break;
|
|
100
|
-
this.expectedFrameSize = declaredFrameSize(this.
|
|
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
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
const
|
|
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(
|
|
35
|
+
const second = extractResponseFrames(Buffer.concat([first.remainder, frame.subarray(split)]), LIMIT);
|
|
55
36
|
assert.deepEqual(second.frames, [frame]);
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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(
|
|
61
|
+
assert.throws(() => extractResponseFrames(frame, LIMIT), ProtocolFrameError);
|
|
72
62
|
});
|
|
73
|
-
it('rejects an oversized
|
|
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(
|
|
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(
|
|
87
|
-
const first =
|
|
88
|
-
const 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(
|
|
98
|
-
decoder.push(vsrFrame(Buffer.from('body')).subarray(0,
|
|
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(
|
|
105
|
-
const header = Buffer.alloc(
|
|
106
|
-
header.writeUInt32LE(LIMIT,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
*
|