memcache 0.2.0 → 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/src/node.ts ADDED
@@ -0,0 +1,488 @@
1
+ import { createConnection, type Socket } from "node:net";
2
+ import { Hookified } from "hookified";
3
+
4
+ export interface MemcacheNodeOptions {
5
+ timeout?: number;
6
+ keepAlive?: boolean;
7
+ keepAliveDelay?: number;
8
+ weight?: number;
9
+ }
10
+
11
+ export interface CommandOptions {
12
+ isMultiline?: boolean;
13
+ isStats?: boolean;
14
+ requestedKeys?: string[];
15
+ }
16
+
17
+ export interface MemcacheStats {
18
+ [key: string]: string;
19
+ }
20
+
21
+ export type CommandQueueItem = {
22
+ command: string;
23
+ // biome-ignore lint/suspicious/noExplicitAny: expected
24
+ resolve: (value: any) => void;
25
+ // biome-ignore lint/suspicious/noExplicitAny: expected
26
+ reject: (reason?: any) => void;
27
+ isMultiline?: boolean;
28
+ isStats?: boolean;
29
+ requestedKeys?: string[];
30
+ foundKeys?: string[];
31
+ };
32
+
33
+ /**
34
+ * MemcacheNode represents a single memcache server connection.
35
+ * It handles the socket connection, command queue, and protocol parsing for one node.
36
+ */
37
+ export class MemcacheNode extends Hookified {
38
+ private _host: string;
39
+ private _port: number;
40
+ private _socket: Socket | undefined = undefined;
41
+ private _timeout: number;
42
+ private _keepAlive: boolean;
43
+ private _keepAliveDelay: number;
44
+ private _weight: number;
45
+ private _connected: boolean = false;
46
+ private _commandQueue: CommandQueueItem[] = [];
47
+ private _buffer: string = "";
48
+ private _currentCommand: CommandQueueItem | undefined = undefined;
49
+ private _multilineData: string[] = [];
50
+ private _pendingValueBytes: number = 0;
51
+
52
+ constructor(host: string, port: number, options?: MemcacheNodeOptions) {
53
+ super();
54
+ this._host = host;
55
+ this._port = port;
56
+ this._timeout = options?.timeout || 5000;
57
+ this._keepAlive = options?.keepAlive !== false;
58
+ this._keepAliveDelay = options?.keepAliveDelay || 1000;
59
+ this._weight = options?.weight || 1;
60
+ }
61
+
62
+ /**
63
+ * Get the host of this node
64
+ */
65
+ public get host(): string {
66
+ return this._host;
67
+ }
68
+
69
+ /**
70
+ * Get the port of this node
71
+ */
72
+ public get port(): number {
73
+ return this._port;
74
+ }
75
+
76
+ /**
77
+ * Get the unique identifier for this node (host:port format)
78
+ */
79
+ public get id(): string {
80
+ return this._port === 0 ? this._host : `${this._host}:${this._port}`;
81
+ }
82
+
83
+ /**
84
+ * Get the full uri like memcache://localhost:11211
85
+ */
86
+ public get uri(): string {
87
+ return `memcache://${this.id}`;
88
+ }
89
+
90
+ /**
91
+ * Get the socket connection
92
+ */
93
+ public get socket(): Socket | undefined {
94
+ return this._socket;
95
+ }
96
+
97
+ /**
98
+ * Get the weight of this node (used for consistent hashing distribution)
99
+ */
100
+ public get weight(): number {
101
+ return this._weight;
102
+ }
103
+
104
+ /**
105
+ * Set the weight of this node (used for consistent hashing distribution)
106
+ */
107
+ public set weight(value: number) {
108
+ this._weight = value;
109
+ }
110
+
111
+ /**
112
+ * Get the keepAlive setting for this node
113
+ */
114
+ public get keepAlive(): boolean {
115
+ return this._keepAlive;
116
+ }
117
+
118
+ /**
119
+ * Set the keepAlive setting for this node
120
+ */
121
+ public set keepAlive(value: boolean) {
122
+ this._keepAlive = value;
123
+ }
124
+
125
+ /**
126
+ * Get the keepAliveDelay setting for this node
127
+ */
128
+ public get keepAliveDelay(): number {
129
+ return this._keepAliveDelay;
130
+ }
131
+
132
+ /**
133
+ * Set the keepAliveDelay setting for this node
134
+ */
135
+ public set keepAliveDelay(value: number) {
136
+ this._keepAliveDelay = value;
137
+ }
138
+
139
+ /**
140
+ * Get the command queue
141
+ */
142
+ public get commandQueue(): CommandQueueItem[] {
143
+ return this._commandQueue;
144
+ }
145
+
146
+ /**
147
+ * Connect to the memcache server
148
+ */
149
+ public async connect(): Promise<void> {
150
+ return new Promise((resolve, reject) => {
151
+ if (this._connected) {
152
+ resolve();
153
+ return;
154
+ }
155
+
156
+ this._socket = createConnection({
157
+ host: this._host,
158
+ port: this._port,
159
+ keepAlive: this._keepAlive,
160
+ keepAliveInitialDelay: this._keepAliveDelay,
161
+ });
162
+
163
+ this._socket.setTimeout(this._timeout);
164
+ this._socket.setEncoding("utf8");
165
+
166
+ this._socket.on("connect", () => {
167
+ this._connected = true;
168
+ this.emit("connect");
169
+ resolve();
170
+ });
171
+
172
+ this._socket.on("data", (data: string) => {
173
+ this.handleData(data);
174
+ });
175
+
176
+ this._socket.on("error", (error: Error) => {
177
+ this.emit("error", error);
178
+ if (!this._connected) {
179
+ /* v8 ignore next -- @preserve */
180
+ reject(error);
181
+ }
182
+ });
183
+
184
+ this._socket.on("close", () => {
185
+ this._connected = false;
186
+ this.emit("close");
187
+ this.rejectPendingCommands(new Error("Connection closed"));
188
+ });
189
+
190
+ this._socket.on("timeout", () => {
191
+ this.emit("timeout");
192
+ this._socket?.destroy();
193
+ reject(new Error("Connection timeout"));
194
+ });
195
+ });
196
+ }
197
+
198
+ /**
199
+ * Disconnect from the memcache server
200
+ */
201
+ public async disconnect(): Promise<void> {
202
+ /* v8 ignore next -- @preserve */
203
+ if (this._socket) {
204
+ this._socket.destroy();
205
+ this._socket = undefined;
206
+ this._connected = false;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Reconnect to the memcache server by disconnecting and connecting again
212
+ */
213
+ public async reconnect(): Promise<void> {
214
+ // First disconnect if currently connected
215
+ if (this._connected || this._socket) {
216
+ await this.disconnect();
217
+ // Clear any pending commands with a reconnection error
218
+ this.rejectPendingCommands(
219
+ new Error("Connection reset for reconnection"),
220
+ );
221
+ // Clear the buffer and current command state
222
+ this._buffer = "";
223
+ this._currentCommand = undefined;
224
+ this._multilineData = [];
225
+ this._pendingValueBytes = 0;
226
+ }
227
+
228
+ // Now establish a fresh connection
229
+ await this.connect();
230
+ }
231
+
232
+ /**
233
+ * Gracefully quit the connection (send quit command then disconnect)
234
+ */
235
+ public async quit(): Promise<void> {
236
+ /* v8 ignore next -- @preserve */
237
+ if (this._connected && this._socket) {
238
+ try {
239
+ await this.command("quit");
240
+ // biome-ignore lint/correctness/noUnusedVariables: expected
241
+ } catch (error) {
242
+ // Ignore errors from quit command as the server closes the connection
243
+ }
244
+ await this.disconnect();
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Check if connected to the memcache server
250
+ */
251
+ public isConnected(): boolean {
252
+ return this._connected;
253
+ }
254
+
255
+ /**
256
+ * Send a generic command to the memcache server
257
+ * @param cmd The command string to send (without trailing \r\n)
258
+ * @param options Command options for response parsing
259
+ */
260
+ public async command(
261
+ cmd: string,
262
+ options?: CommandOptions,
263
+ // biome-ignore lint/suspicious/noExplicitAny: expected
264
+ ): Promise<any> {
265
+ if (!this._connected || !this._socket) {
266
+ throw new Error(`Not connected to memcache server ${this.id}`);
267
+ }
268
+
269
+ return new Promise((resolve, reject) => {
270
+ this._commandQueue.push({
271
+ command: cmd,
272
+ resolve,
273
+ reject,
274
+ isMultiline: options?.isMultiline,
275
+ isStats: options?.isStats,
276
+ requestedKeys: options?.requestedKeys,
277
+ });
278
+ // biome-ignore lint/style/noNonNullAssertion: socket is checked
279
+ this._socket!.write(`${cmd}\r\n`);
280
+ });
281
+ }
282
+
283
+ private handleData(data: string): void {
284
+ this._buffer += data;
285
+
286
+ while (true) {
287
+ // If we're waiting for value data, try to read it first
288
+ if (this._pendingValueBytes > 0) {
289
+ if (this._buffer.length >= this._pendingValueBytes + 2) {
290
+ const value = this._buffer.substring(0, this._pendingValueBytes);
291
+ this._buffer = this._buffer.substring(this._pendingValueBytes + 2);
292
+ this._multilineData.push(value);
293
+ this._pendingValueBytes = 0;
294
+ } else {
295
+ // Not enough data yet, wait for more
296
+ break;
297
+ }
298
+ }
299
+
300
+ const lineEnd = this._buffer.indexOf("\r\n");
301
+ if (lineEnd === -1) break;
302
+
303
+ const line = this._buffer.substring(0, lineEnd);
304
+ this._buffer = this._buffer.substring(lineEnd + 2);
305
+
306
+ this.processLine(line);
307
+ }
308
+ }
309
+
310
+ private processLine(line: string): void {
311
+ if (!this._currentCommand) {
312
+ this._currentCommand = this._commandQueue.shift();
313
+ if (!this._currentCommand) return;
314
+ }
315
+
316
+ if (this._currentCommand.isStats) {
317
+ if (line === "END") {
318
+ const stats: MemcacheStats = {};
319
+ for (const statLine of this._multilineData) {
320
+ const [, key, value] = statLine.split(" ");
321
+ /* v8 ignore next -- @preserve */
322
+ if (key && value) {
323
+ stats[key] = value;
324
+ }
325
+ }
326
+ this._currentCommand.resolve(stats);
327
+ this._multilineData = [];
328
+ this._currentCommand = undefined;
329
+ return;
330
+ }
331
+
332
+ if (line.startsWith("STAT ")) {
333
+ this._multilineData.push(line);
334
+ return;
335
+ }
336
+
337
+ if (
338
+ line.startsWith("ERROR") ||
339
+ line.startsWith("CLIENT_ERROR") ||
340
+ line.startsWith("SERVER_ERROR")
341
+ ) {
342
+ this._currentCommand.reject(new Error(line));
343
+ this._currentCommand = undefined;
344
+ return;
345
+ }
346
+
347
+ return;
348
+ }
349
+
350
+ if (this._currentCommand.isMultiline) {
351
+ // Track found keys only if requestedKeys is provided
352
+ if (
353
+ this._currentCommand.requestedKeys &&
354
+ !this._currentCommand.foundKeys
355
+ ) {
356
+ this._currentCommand.foundKeys = [];
357
+ }
358
+
359
+ if (line.startsWith("VALUE ")) {
360
+ const parts = line.split(" ");
361
+ const key = parts[1];
362
+ const bytes = parseInt(parts[3], 10);
363
+ if (this._currentCommand.requestedKeys) {
364
+ this._currentCommand.foundKeys?.push(key);
365
+ }
366
+ // Set pending bytes so handleData will read the value
367
+ this._pendingValueBytes = bytes;
368
+ } else if (line === "END") {
369
+ let result:
370
+ | string[]
371
+ | { values: string[] | undefined; foundKeys: string[] }
372
+ | undefined;
373
+
374
+ // If requestedKeys is present, return object with keys and values
375
+ if (
376
+ this._currentCommand.requestedKeys &&
377
+ this._currentCommand.foundKeys
378
+ ) {
379
+ result = {
380
+ values:
381
+ this._multilineData.length > 0 ? this._multilineData : undefined,
382
+ foundKeys: this._currentCommand.foundKeys,
383
+ };
384
+ } else {
385
+ result =
386
+ this._multilineData.length > 0 ? this._multilineData : undefined;
387
+ }
388
+
389
+ // Emit hit/miss events if we have requested keys
390
+ /* v8 ignore next -- @preserve */
391
+ if (
392
+ this._currentCommand.requestedKeys &&
393
+ this._currentCommand.foundKeys
394
+ ) {
395
+ const foundKeys = this._currentCommand.foundKeys;
396
+ for (let i = 0; i < foundKeys.length; i++) {
397
+ this.emit("hit", foundKeys[i], this._multilineData[i]);
398
+ }
399
+
400
+ // Emit miss events for keys that weren't found
401
+ const missedKeys = this._currentCommand.requestedKeys.filter(
402
+ (key) => !foundKeys.includes(key),
403
+ );
404
+ for (const key of missedKeys) {
405
+ this.emit("miss", key);
406
+ }
407
+ }
408
+
409
+ this._currentCommand.resolve(result);
410
+ this._multilineData = [];
411
+ this._currentCommand = undefined;
412
+ } else if (
413
+ line.startsWith("ERROR") ||
414
+ line.startsWith("CLIENT_ERROR") ||
415
+ line.startsWith("SERVER_ERROR")
416
+ ) {
417
+ this._currentCommand.reject(new Error(line));
418
+ this._multilineData = [];
419
+ this._currentCommand = undefined;
420
+ }
421
+ } else {
422
+ if (
423
+ line === "STORED" ||
424
+ line === "DELETED" ||
425
+ line === "OK" ||
426
+ line === "TOUCHED" ||
427
+ line === "EXISTS" ||
428
+ line === "NOT_FOUND"
429
+ ) {
430
+ this._currentCommand.resolve(line);
431
+ } else if (line === "NOT_STORED") {
432
+ this._currentCommand.resolve(false);
433
+ } else if (
434
+ line.startsWith("ERROR") ||
435
+ line.startsWith("CLIENT_ERROR") ||
436
+ line.startsWith("SERVER_ERROR")
437
+ ) {
438
+ this._currentCommand.reject(new Error(line));
439
+ } else if (/^\d+$/.test(line)) {
440
+ this._currentCommand.resolve(parseInt(line, 10));
441
+ } else {
442
+ this._currentCommand.resolve(line);
443
+ }
444
+ this._currentCommand = undefined;
445
+ }
446
+ }
447
+
448
+ private rejectPendingCommands(error: Error): void {
449
+ if (this._currentCommand) {
450
+ /* v8 ignore next -- @preserve */
451
+ this._currentCommand.reject(error);
452
+ /* v8 ignore next -- @preserve */
453
+ this._currentCommand = undefined;
454
+ }
455
+ while (this._commandQueue.length > 0) {
456
+ const cmd = this._commandQueue.shift();
457
+ /* v8 ignore next -- @preserve */
458
+ if (cmd) {
459
+ cmd.reject(error);
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Factory function to create a new MemcacheNode instance.
467
+ * @param host - The hostname or IP address of the memcache server
468
+ * @param port - The port number of the memcache server
469
+ * @param options - Optional configuration for the node
470
+ * @returns A new MemcacheNode instance
471
+ *
472
+ * @example
473
+ * ```typescript
474
+ * const node = createNode('localhost', 11211, {
475
+ * timeout: 5000,
476
+ * keepAlive: true,
477
+ * weight: 1
478
+ * });
479
+ * await node.connect();
480
+ * ```
481
+ */
482
+ export function createNode(
483
+ host: string,
484
+ port: number,
485
+ options?: MemcacheNodeOptions,
486
+ ): MemcacheNode {
487
+ return new MemcacheNode(host, port, options);
488
+ }