diskeyval 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/example.ts ADDED
@@ -0,0 +1,672 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { execFileSync, spawnSync } from 'node:child_process';
4
+ import net from 'node:net';
5
+ import readline from 'node:readline';
6
+ import diskeyval, { isNotLeaderError, type ClusterPeer } from './lib/index.ts';
7
+
8
+ type CliOptions = {
9
+ port: number;
10
+ connectPorts: number[];
11
+ host: string;
12
+ };
13
+
14
+ type SetGetRequest = {
15
+ op: 'set' | 'get';
16
+ key: string;
17
+ value?: unknown;
18
+ };
19
+
20
+ type JoinRequest = {
21
+ op: 'join';
22
+ peer: {
23
+ nodeId: string;
24
+ host: string;
25
+ port: number;
26
+ };
27
+ };
28
+
29
+ type CommandRequest = SetGetRequest | JoinRequest;
30
+
31
+ type CommandResponse = {
32
+ ok: boolean;
33
+ value?: unknown;
34
+ error?: string;
35
+ leaderId?: string | null;
36
+ };
37
+
38
+ function parseArgs(argv: string[]): CliOptions {
39
+ let port: number | null = null;
40
+ const connectPorts: number[] = [];
41
+ let host = '127.0.0.1';
42
+
43
+ for (let index = 0; index < argv.length; index += 1) {
44
+ const arg = argv[index];
45
+
46
+ if (arg === '--port') {
47
+ const value = argv[index + 1];
48
+ if (!value) {
49
+ throw new Error('Missing value for --port');
50
+ }
51
+ port = Number(value);
52
+ index += 1;
53
+ continue;
54
+ }
55
+
56
+ if (arg === '--connect') {
57
+ const value = argv[index + 1];
58
+ if (!value) {
59
+ throw new Error('Missing value for --connect');
60
+ }
61
+ const parsed = Number(value);
62
+ if (!Number.isInteger(parsed) || parsed <= 0) {
63
+ throw new Error(`Invalid --connect port: ${value}`);
64
+ }
65
+ connectPorts.push(parsed);
66
+ index += 1;
67
+ continue;
68
+ }
69
+
70
+ if (arg === '--host') {
71
+ const value = argv[index + 1];
72
+ if (!value) {
73
+ throw new Error('Missing value for --host');
74
+ }
75
+ host = value;
76
+ index += 1;
77
+ continue;
78
+ }
79
+
80
+ if (arg === '--help' || arg === '-h') {
81
+ printUsage();
82
+ process.exit(0);
83
+ }
84
+
85
+ throw new Error(`Unknown argument: ${arg}`);
86
+ }
87
+
88
+ if (!port || !Number.isInteger(port) || port <= 0) {
89
+ throw new Error('You must provide a valid --port <number>');
90
+ }
91
+
92
+ return { port, connectPorts, host };
93
+ }
94
+
95
+ function printUsage(): void {
96
+ console.log('Usage:');
97
+ console.log(' node example.ts --port 6001');
98
+ console.log(' node example.ts --port 6002 --connect 6001');
99
+ console.log(' node example.ts --port 6003 --connect 6002 # dynamic join');
100
+ console.log('');
101
+ console.log('Optional flags:');
102
+ console.log(' --host 127.0.0.1');
103
+ }
104
+
105
+ function ensureOpenSsl(): void {
106
+ const check = spawnSync('openssl', ['version'], { stdio: 'pipe' });
107
+ if (check.status !== 0) {
108
+ throw new Error('openssl is required for example.ts (not found in PATH)');
109
+ }
110
+ }
111
+
112
+ function runOpenSsl(args: string[], cwd: string): void {
113
+ execFileSync('openssl', args, { cwd, stdio: 'pipe' });
114
+ }
115
+
116
+ function ensureCa(baseDir: string): { caKeyPath: string; caCertPath: string } {
117
+ const caKeyPath = join(baseDir, 'ca.key.pem');
118
+ const caCertPath = join(baseDir, 'ca.cert.pem');
119
+
120
+ if (existsSync(caKeyPath) && existsSync(caCertPath)) {
121
+ return { caKeyPath, caCertPath };
122
+ }
123
+
124
+ runOpenSsl(['genrsa', '-out', caKeyPath, '2048'], baseDir);
125
+ runOpenSsl(
126
+ [
127
+ 'req',
128
+ '-x509',
129
+ '-new',
130
+ '-nodes',
131
+ '-key',
132
+ caKeyPath,
133
+ '-sha256',
134
+ '-days',
135
+ '365',
136
+ '-out',
137
+ caCertPath,
138
+ '-subj',
139
+ '/CN=diskeyval-example-ca'
140
+ ],
141
+ baseDir
142
+ );
143
+
144
+ return { caKeyPath, caCertPath };
145
+ }
146
+
147
+ function ensureNodeCert(
148
+ baseDir: string,
149
+ caKeyPath: string,
150
+ caCertPath: string,
151
+ nodeId: string
152
+ ): { certPem: string; keyPem: string; caPem: string } {
153
+ const nodeDir = join(baseDir, nodeId);
154
+ mkdirSync(nodeDir, { recursive: true });
155
+
156
+ const keyPath = join(nodeDir, 'node.key.pem');
157
+ const csrPath = join(nodeDir, 'node.csr.pem');
158
+ const certPath = join(nodeDir, 'node.cert.pem');
159
+ const configPath = join(nodeDir, 'openssl.cnf');
160
+
161
+ if (!existsSync(keyPath) || !existsSync(certPath)) {
162
+ writeFileSync(
163
+ configPath,
164
+ [
165
+ '[req]',
166
+ 'prompt = no',
167
+ 'distinguished_name = dn',
168
+ 'req_extensions = req_ext',
169
+ '',
170
+ '[dn]',
171
+ `CN = ${nodeId}`,
172
+ '',
173
+ '[req_ext]',
174
+ `subjectAltName = DNS:${nodeId},URI:spiffe://diskeyval/${nodeId}`,
175
+ 'extendedKeyUsage = serverAuth,clientAuth',
176
+ 'keyUsage = digitalSignature,keyEncipherment'
177
+ ].join('\n'),
178
+ 'utf8'
179
+ );
180
+
181
+ runOpenSsl(['genrsa', '-out', keyPath, '2048'], nodeDir);
182
+ runOpenSsl(['req', '-new', '-key', keyPath, '-out', csrPath, '-config', configPath], nodeDir);
183
+ runOpenSsl(
184
+ [
185
+ 'x509',
186
+ '-req',
187
+ '-in',
188
+ csrPath,
189
+ '-CA',
190
+ caCertPath,
191
+ '-CAkey',
192
+ caKeyPath,
193
+ '-CAcreateserial',
194
+ '-out',
195
+ certPath,
196
+ '-days',
197
+ '365',
198
+ '-sha256',
199
+ '-extfile',
200
+ configPath,
201
+ '-extensions',
202
+ 'req_ext'
203
+ ],
204
+ nodeDir
205
+ );
206
+ }
207
+
208
+ return {
209
+ certPem: readFileSync(certPath, 'utf8'),
210
+ keyPem: readFileSync(keyPath, 'utf8'),
211
+ caPem: readFileSync(caCertPath, 'utf8')
212
+ };
213
+ }
214
+
215
+ function leaderPortFromNodeId(nodeId: string | null): number | null {
216
+ if (!nodeId) {
217
+ return null;
218
+ }
219
+ const match = /^node-(\d+)$/.exec(nodeId);
220
+ if (!match) {
221
+ return null;
222
+ }
223
+ return Number(match[1]);
224
+ }
225
+
226
+ async function sendControlRequest(
227
+ host: string,
228
+ port: number,
229
+ request: CommandRequest
230
+ ): Promise<CommandResponse> {
231
+ return new Promise<CommandResponse>((resolve, reject) => {
232
+ const socket = net.createConnection({ host, port: port + 10000 }, () => {
233
+ socket.write(`${JSON.stringify(request)}\n`);
234
+ });
235
+
236
+ let buffer = '';
237
+
238
+ socket.on('data', (chunk) => {
239
+ buffer += chunk.toString('utf8');
240
+ const newline = buffer.indexOf('\n');
241
+ if (newline < 0) {
242
+ return;
243
+ }
244
+
245
+ const line = buffer.slice(0, newline);
246
+ socket.end();
247
+
248
+ try {
249
+ resolve(JSON.parse(line) as CommandResponse);
250
+ } catch (error) {
251
+ reject(error);
252
+ }
253
+ });
254
+
255
+ socket.on('error', (error) => {
256
+ reject(error);
257
+ });
258
+
259
+ socket.on('end', () => {
260
+ if (buffer.includes('\n')) {
261
+ return;
262
+ }
263
+ reject(new Error('control connection closed without response'));
264
+ });
265
+ });
266
+ }
267
+
268
+ function mergedPeers(current: ClusterPeer[], incoming: ClusterPeer): ClusterPeer[] {
269
+ const next = new Map<string, ClusterPeer>();
270
+ for (const peer of current) {
271
+ next.set(peer.nodeId, peer);
272
+ }
273
+ next.set(incoming.nodeId, incoming);
274
+ return Array.from(next.values()).sort((a, b) => a.nodeId.localeCompare(b.nodeId));
275
+ }
276
+
277
+ function sleep(ms: number): Promise<void> {
278
+ return new Promise((resolve) => setTimeout(resolve, ms));
279
+ }
280
+
281
+ async function main(): Promise<void> {
282
+ try {
283
+ const options = parseArgs(process.argv.slice(2));
284
+ ensureOpenSsl();
285
+
286
+ const projectDir = process.cwd();
287
+ const runtimeDir = join(projectDir, '.diskeyval-example');
288
+ const certsDir = join(runtimeDir, 'certs');
289
+ const dataDir = join(runtimeDir, 'data');
290
+ mkdirSync(certsDir, { recursive: true });
291
+ mkdirSync(dataDir, { recursive: true });
292
+
293
+ const nodeId = `node-${options.port}`;
294
+ const { caKeyPath, caCertPath } = ensureCa(certsDir);
295
+ const tlsMaterial = ensureNodeCert(certsDir, caKeyPath, caCertPath, nodeId);
296
+
297
+ const peers = options.connectPorts.map((connectPort) => ({
298
+ nodeId: `node-${connectPort}`,
299
+ host: options.host,
300
+ port: connectPort
301
+ }));
302
+
303
+ const node = diskeyval({
304
+ nodeId,
305
+ host: options.host,
306
+ port: options.port,
307
+ peers,
308
+ tls: {
309
+ cert: tlsMaterial.certPem,
310
+ key: tlsMaterial.keyPem,
311
+ ca: tlsMaterial.caPem
312
+ },
313
+ persistence: {
314
+ dir: dataDir,
315
+ compactEvery: 16
316
+ },
317
+ electionTimeoutMs: 300,
318
+ heartbeatMs: 80,
319
+ proposalTimeoutMs: 2_000,
320
+ rpcTimeoutMs: 2_000
321
+ });
322
+
323
+ node.on('leader', ({ nodeId: leaderId }) => {
324
+ console.log(`[leader] ${leaderId ?? 'none'}`);
325
+ });
326
+
327
+ node.on('change', ({ key, value }) => {
328
+ console.log(`[change] ${key}=${JSON.stringify(value)}`);
329
+ });
330
+
331
+ await node.start();
332
+
333
+ const executeLocally = async (request: CommandRequest): Promise<CommandResponse> => {
334
+ try {
335
+ if (request.op === 'set') {
336
+ await node.set(request.key, request.value);
337
+ return { ok: true };
338
+ }
339
+
340
+ if (request.op === 'get') {
341
+ const value = await node.get(request.key);
342
+ return { ok: true, value };
343
+ }
344
+
345
+ const leaderId = node.leaderId();
346
+ if (!node.isLeader()) {
347
+ return {
348
+ ok: false,
349
+ error: 'not leader',
350
+ leaderId
351
+ };
352
+ }
353
+
354
+ const currentPeers = node.getPeers();
355
+ await node.reconfigure(mergedPeers(currentPeers, request.peer));
356
+ return { ok: true };
357
+ } catch (error) {
358
+ if (isNotLeaderError(error)) {
359
+ return {
360
+ ok: false,
361
+ error: 'not leader',
362
+ leaderId: error.leaderId
363
+ };
364
+ }
365
+
366
+ return {
367
+ ok: false,
368
+ error: error instanceof Error ? error.message : String(error)
369
+ };
370
+ }
371
+ };
372
+
373
+ const executeWithForwarding = async (request: CommandRequest): Promise<CommandResponse> => {
374
+ let attemptedLeaderPort: number | null = null;
375
+ let discoveredLeaderPort: number | null = null;
376
+
377
+ for (let attempt = 0; attempt < 4; attempt += 1) {
378
+ const local = await executeLocally(request);
379
+ if (local.ok) {
380
+ return local;
381
+ }
382
+
383
+ if (local.error !== 'not leader') {
384
+ return local;
385
+ }
386
+
387
+ const candidatePorts = new Set<number>();
388
+
389
+ const hintedLeader = leaderPortFromNodeId(local.leaderId ?? node.leaderId());
390
+ if (hintedLeader && hintedLeader !== options.port) {
391
+ candidatePorts.add(hintedLeader);
392
+ }
393
+ if (discoveredLeaderPort && discoveredLeaderPort !== options.port) {
394
+ candidatePorts.add(discoveredLeaderPort);
395
+ }
396
+
397
+ for (const connectPort of options.connectPorts) {
398
+ if (connectPort !== options.port) {
399
+ candidatePorts.add(connectPort);
400
+ }
401
+ }
402
+
403
+ if (candidatePorts.size === 0) {
404
+ return local;
405
+ }
406
+
407
+ let sawNetworkError: string | null = null;
408
+
409
+ for (const candidatePort of candidatePorts) {
410
+ if (candidatePort === attemptedLeaderPort) {
411
+ continue;
412
+ }
413
+ attemptedLeaderPort = candidatePort;
414
+
415
+ try {
416
+ const forwarded = await sendControlRequest(options.host, candidatePort, request);
417
+ if (forwarded.ok) {
418
+ return forwarded;
419
+ }
420
+
421
+ if (forwarded.error === 'not leader') {
422
+ const forwardedLeader = leaderPortFromNodeId(forwarded.leaderId ?? null);
423
+ if (forwardedLeader && forwardedLeader !== options.port) {
424
+ discoveredLeaderPort = forwardedLeader;
425
+ }
426
+ continue;
427
+ }
428
+
429
+ return forwarded;
430
+ } catch (error) {
431
+ sawNetworkError = error instanceof Error ? error.message : String(error);
432
+ }
433
+ }
434
+
435
+ if (sawNetworkError) {
436
+ return {
437
+ ok: false,
438
+ error: sawNetworkError
439
+ };
440
+ }
441
+ }
442
+
443
+ return {
444
+ ok: false,
445
+ error: 'unable to forward request to leader'
446
+ };
447
+ };
448
+
449
+ const controlServer = net.createServer((socket) => {
450
+ let buffer = '';
451
+
452
+ socket.on('data', async (chunk) => {
453
+ buffer += chunk.toString('utf8');
454
+ const newline = buffer.indexOf('\n');
455
+ if (newline < 0) {
456
+ return;
457
+ }
458
+
459
+ const line = buffer.slice(0, newline);
460
+ buffer = buffer.slice(newline + 1);
461
+
462
+ let request: CommandRequest;
463
+ try {
464
+ request = JSON.parse(line) as CommandRequest;
465
+ } catch {
466
+ socket.write(`${JSON.stringify({ ok: false, error: 'invalid request json' })}\n`);
467
+ socket.end();
468
+ return;
469
+ }
470
+
471
+ const response = await executeWithForwarding(request);
472
+ socket.write(`${JSON.stringify(response)}\n`);
473
+ socket.end();
474
+ });
475
+ });
476
+
477
+ await new Promise<void>((resolve, reject) => {
478
+ controlServer.once('error', reject);
479
+ controlServer.listen(options.port + 10000, options.host, () => {
480
+ controlServer.off('error', reject);
481
+ resolve();
482
+ });
483
+ });
484
+
485
+ console.log(`node ${nodeId} listening on ${options.host}:${options.port}`);
486
+ if (peers.length > 0) {
487
+ console.log(
488
+ `configured peers: ${peers.map((peer) => `${peer.nodeId}@${peer.host}:${peer.port}`).join(', ')}`
489
+ );
490
+ } else {
491
+ console.log('configured peers: none');
492
+ }
493
+
494
+ if (peers.length === 0) {
495
+ await node.forceElection();
496
+ }
497
+
498
+ let stopJoinLoop = false;
499
+ const joinTask =
500
+ options.connectPorts.length > 0
501
+ ? (async () => {
502
+ let joined = false;
503
+ let attempt = 0;
504
+ let backoffMs = 250;
505
+ let lastJoinError = 'failed to join cluster';
506
+
507
+ while (!stopJoinLoop && !joined) {
508
+ attempt += 1;
509
+ const joinResponse = await executeWithForwarding({
510
+ op: 'join',
511
+ peer: {
512
+ nodeId,
513
+ host: options.host,
514
+ port: options.port
515
+ }
516
+ });
517
+
518
+ if (joinResponse.ok) {
519
+ joined = true;
520
+ console.log('[join] cluster membership updated');
521
+ break;
522
+ }
523
+
524
+ lastJoinError = joinResponse.error ?? lastJoinError;
525
+ if (attempt === 1 || attempt % 10 === 0) {
526
+ console.log(`[join] waiting for leader/configuration (${lastJoinError})`);
527
+ }
528
+
529
+ await sleep(backoffMs);
530
+ backoffMs = Math.min(2_000, Math.floor(backoffMs * 1.5));
531
+ }
532
+ })()
533
+ : Promise.resolve();
534
+
535
+ console.log('commands: set <key> <json>, get <key>, getlocal <key>, state, peers, leader, metrics, help, quit');
536
+
537
+ const rl = readline.createInterface({
538
+ input: process.stdin,
539
+ output: process.stdout,
540
+ terminal: true
541
+ });
542
+
543
+ const shutdown = async (): Promise<void> => {
544
+ stopJoinLoop = true;
545
+ rl.close();
546
+ await joinTask;
547
+ await new Promise<void>((resolve) => controlServer.close(() => resolve()));
548
+ await node.end();
549
+ process.exit(0);
550
+ };
551
+
552
+ process.on('SIGINT', () => {
553
+ void shutdown();
554
+ });
555
+
556
+ rl.on('line', async (line) => {
557
+ const trimmed = line.trim();
558
+ if (!trimmed) {
559
+ return;
560
+ }
561
+
562
+ try {
563
+ if (trimmed === 'quit' || trimmed === 'exit') {
564
+ await shutdown();
565
+ return;
566
+ }
567
+
568
+ if (trimmed === 'help') {
569
+ console.log('set <key> <json>');
570
+ console.log('get <key>');
571
+ console.log('getlocal <key>');
572
+ console.log('state');
573
+ console.log('peers');
574
+ console.log('leader');
575
+ console.log('metrics');
576
+ console.log('quit');
577
+ return;
578
+ }
579
+
580
+ if (trimmed === 'state') {
581
+ console.log(JSON.stringify(node.state, null, 2));
582
+ return;
583
+ }
584
+
585
+ if (trimmed === 'peers') {
586
+ console.log(JSON.stringify(node.getPeers(), null, 2));
587
+ return;
588
+ }
589
+
590
+ if (trimmed === 'leader') {
591
+ console.log(node.leaderId() ?? 'none');
592
+ return;
593
+ }
594
+
595
+ if (trimmed === 'metrics') {
596
+ console.log(JSON.stringify(node.getMetrics(), null, 2));
597
+ return;
598
+ }
599
+
600
+ if (trimmed.startsWith('set ')) {
601
+ const [_, key, ...valueParts] = trimmed.split(' ');
602
+ const raw = valueParts.join(' ').trim();
603
+ if (!key || !raw) {
604
+ console.log('usage: set <key> <json>');
605
+ return;
606
+ }
607
+
608
+ const value = JSON.parse(raw);
609
+ const response = await executeWithForwarding({ op: 'set', key, value });
610
+ if (!response.ok) {
611
+ if (response.error === 'not leader') {
612
+ console.log(`not leader (leader: ${response.leaderId ?? 'unknown'})`);
613
+ } else {
614
+ console.log(response.error ?? 'set failed');
615
+ }
616
+ return;
617
+ }
618
+
619
+ console.log('ok');
620
+ return;
621
+ }
622
+
623
+ if (trimmed.startsWith('get ')) {
624
+ const [_, key] = trimmed.split(' ');
625
+ if (!key) {
626
+ console.log('usage: get <key>');
627
+ return;
628
+ }
629
+
630
+ const response = await executeWithForwarding({ op: 'get', key });
631
+ if (!response.ok) {
632
+ if (response.error === 'not leader') {
633
+ console.log(`not leader (leader: ${response.leaderId ?? 'unknown'})`);
634
+ } else {
635
+ console.log(response.error ?? 'get failed');
636
+ }
637
+ return;
638
+ }
639
+
640
+ console.log(JSON.stringify(response.value));
641
+ return;
642
+ }
643
+
644
+ if (trimmed.startsWith('getlocal ')) {
645
+ const [_, key] = trimmed.split(' ');
646
+ if (!key) {
647
+ console.log('usage: getlocal <key>');
648
+ return;
649
+ }
650
+
651
+ console.log(JSON.stringify(node.state[key]));
652
+ return;
653
+ }
654
+
655
+ console.log(`unknown command: ${trimmed}`);
656
+ } catch (error) {
657
+ if (error instanceof SyntaxError) {
658
+ console.log('invalid json value for set command');
659
+ return;
660
+ }
661
+
662
+ console.error(error instanceof Error ? error.message : String(error));
663
+ }
664
+ });
665
+ } catch (error) {
666
+ console.error(error instanceof Error ? error.message : String(error));
667
+ printUsage();
668
+ process.exit(1);
669
+ }
670
+ }
671
+
672
+ void main();