diff --git a/README.md b/README.md index a4968d3..2e55963 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,13 @@ For Valkey and Redis DB compatibility look [here](https://github.com/valkey-io/v Add it to your project with `register` and you are done! +### Upgrade notes + +- Mixed mode registrations are rejected within the same Fastify instance tree. You cannot mix one default instance and namespaced instances under the same root Fastify instance. +- `clientMode` is validated when provided. Only `'standalone'` and `'cluster'` are accepted; with an existing `client`, the option is validated but does not change the supplied client's mode. +- If `namespace` is provided, it must be a non-empty string. +- TypeScript: `fastify.valkey` is typed as a union (`ValkeyClient | FastifyValkeyNamespacedInstance`). Depending on your usage, you may need to narrow or cast before calling root client methods (for example, `.get`) or namespace properties. + ### Create a new Valkey Client The `options` that you pass to `register` will be passed to the Valkey client. @@ -42,13 +49,20 @@ fastify.register(fastifyValkey, { credentials: {username: "user1", password: "password"}, useTLS: true }) + +// OR create a managed cluster client +fastify.register(fastifyValkey, { + clientMode: 'cluster', + addresses: [{ host: '127.0.0.1', port: 6379 }], + periodicChecks: 'enabledDefaultConfigs' +}) ``` ### Accessing the Valkey Client Once you have registered your plugin, you can access the Valkey client via `fastify.valkey`. -The client is automatically closed when the fastify instance is closed. +Clients created by this plugin are automatically closed when the fastify instance is closed. ```js import Fastify from 'fastify' @@ -60,16 +74,14 @@ fastify.register(fastifyValkey, { addresses: [{ host: '127.0.0.1', port: 6379 }], }) -fastify.post('/foo', (request, reply) => { - fastify.valkey.set(request.body.key, request.body.value, (err) => { - reply.send(err || { status: 'ok' }) - }) +fastify.post('/foo', async (request, reply) => { + await fastify.valkey.set(request.body.key, request.body.value) + reply.send({ status: 'ok' }) }) -fastify.get('/foo', (request, reply) => { - fastify.valkey.get(request.query.key, (err, val) => { - reply.send(err || val) - }) +fastify.get('/foo', async (request, reply) => { + const val = await fastify.valkey.get(request.query.key) + reply.send(val) }) try { @@ -158,29 +170,25 @@ fastify }) // Here we will use the `hello` named instance -fastify.post('/hello', (request, reply) => { - fastify.valkey['hello'].set(request.body.key, request.body.value, (err) => { - reply.send(err || { status: 'ok' }) - }) +fastify.post('/hello', async (request, reply) => { + await fastify.valkey['hello'].set(request.body.key, request.body.value) + reply.send({ status: 'ok' }) }) -fastify.get('/hello', (request, reply) => { - fastify.valkey.hello.get(request.query.key, (err, val) => { - reply.send(err || val) - }) +fastify.get('/hello', async (request, reply) => { + const val = await fastify.valkey.hello.get(request.query.key) + reply.send(val) }) // Here we will use the `world` named instance -fastify.post('/world', (request, reply) => { - fastify.valkey.world.set(request.body.key, request.body.value, (err) => { - reply.send(err || { status: 'ok' }) - }) +fastify.post('/world', async (request, reply) => { + await fastify.valkey.world.set(request.body.key, request.body.value) + reply.send({ status: 'ok' }) }) -fastify.get('/world', (request, reply) => { - fastify.valkey['world'].get(request.query.key, (err, val) => { - reply.send(err || val) - }) +fastify.get('/world', async (request, reply) => { + const val = await fastify.valkey['world'].get(request.query.key) + reply.send(val) }) try { @@ -191,6 +199,59 @@ try { } ``` +### Comprehensive standalone configuration example + +```js +import Fastify from 'fastify' +import fastifyValkey from '@fastify/valkey-glide' +import { + Decoder, + GlideClientConfiguration, + ProtocolVersion +} from '@valkey/valkey-glide' + +const fastify = Fastify() + +fastify.register(fastifyValkey, { + addresses: [ + { host: '127.0.0.1', port: 6379 }, + { host: '127.0.0.2', port: 6379 } + ], + databaseId: 1, + useTLS: true, + credentials: { username: 'user1', password: 'password' }, + requestTimeout: 5000, + protocol: ProtocolVersion.RESP3, + clientName: 'fastify-valkey-main', + readFrom: 'preferReplica', + clientAz: 'us-east-1a', + defaultDecoder: Decoder.String, + inflightRequestsLimit: 1000, + lazyConnect: false, + connectionBackoff: { + numberOfRetries: 5, + factor: 500, + exponentBase: 2, + jitterPercent: 20 + }, + advancedConfiguration: { + connectionTimeout: 1000, + tlsAdvancedConfiguration: { + insecure: false + } + }, + pubsubSubscriptions: { + channelsAndPatterns: { + [GlideClientConfiguration.PubSubChannelModes.Exact]: new Set(['updates']) + }, + callback: (message, context) => { + console.log('pubsub message', message, context) + }, + context: { source: 'fastify' } + } +}) +``` + ## License Licensed under [MIT](./LICENSE). diff --git a/index.js b/index.js index 5c9993f..005c5ea 100644 --- a/index.js +++ b/index.js @@ -1,16 +1,30 @@ 'use strict' const fp = require('fastify-plugin') -const { GlideClient } = require('@valkey/valkey-glide') +const { GlideClient, GlideClusterClient } = require('@valkey/valkey-glide') + +const NAMESPACE_CONTAINER_MARKER = Symbol('fastify.valkey.namespace.container') async function fastifyValkey (fastify, options) { const { namespace, closeClient = false, ...valkeyOptions } = options + if (namespace !== undefined && (typeof namespace !== 'string' || namespace.length === 0)) { + throw new Error('Invalid namespace. Expected a non-empty string when namespace is provided') + } + + if (valkeyOptions.clientMode !== undefined && valkeyOptions.clientMode !== 'standalone' && valkeyOptions.clientMode !== 'cluster') { + throw new Error("Invalid clientMode. Expected 'standalone' or 'cluster'") + } + let client = options.client || null if (namespace) { if (!fastify.valkey) { - fastify.decorate('valkey', Object.create(null)) + const namespaceContainer = Object.create(null) + namespaceContainer[NAMESPACE_CONTAINER_MARKER] = true + fastify.decorate('valkey', namespaceContainer) + } else if (!fastify.valkey[NAMESPACE_CONTAINER_MARKER]) { + throw new Error('@fastify/valkey-glide has already been registered') } if (fastify.valkey[namespace]) { throw new Error(`Valkey '${namespace}' instance namespace has already been registered`) @@ -23,7 +37,7 @@ async function fastifyValkey (fastify, options) { fastify.valkey[namespace] = client } else { if (fastify.valkey) { - throw new Error('@fastify/valkey has already been registered') + throw new Error('@fastify/valkey-glide has already been registered') } const close = (fastify) => { fastify.valkey.close() } @@ -40,7 +54,13 @@ async function setupClient (fastify, client, closeClient, valkeyOptions, closeIn fastify.addHook('onClose', closeInstance) } } else { - client = await GlideClient.createClient(valkeyOptions) + const { clientMode = 'standalone', ...options } = valkeyOptions + + if (clientMode === 'cluster') { + client = await GlideClusterClient.createClient(options) + } else { + client = await GlideClient.createClient(options) + } fastify.addHook('onClose', closeInstance) } diff --git a/package.json b/package.json index a5c1349..521d96b 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "scripts": { "lint": "eslint", "lint:fix": "eslint --fix", - "redis": "docker run -p 6379:6379 --rm redis:7.2", - "valkey": "docker run -p 6379:6379 --rm valkey/valkey:latest", + "redis": "docker run -p 6379:6379 --name valkey-glide-redis -d --rm redis:latest", + "valkey": "docker run -p 6380:6379 --name valkey-glide-valkey -d --rm valkey/valkey:latest", "test": "npm run unit && npm run typescript", "typescript": "tstyche", "unit": "c8 --100 node --test" @@ -58,7 +58,7 @@ }, "dependencies": { "fastify-plugin": "^5.0.0", - "@valkey/valkey-glide": "^2.0.1" + "@valkey/valkey-glide": "^2.0.0" }, "publishConfig": { "access": "public" diff --git a/test/index.test.js b/test/index.test.js index d41984f..a7a9489 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -161,7 +161,7 @@ test('Client should be close if closeClient is enabled', async (t) => { try { await valkey.get('closeClient enabled key1') t.fail('Client should not work after being closed') - } catch (err) { + } catch { t.assert.ok('Should throw error when using closed client') } }) @@ -198,9 +198,9 @@ test('Client should be close if closeClient is enabled, namespace', async (t) => await fastify.close() try { - await valkey.close_client_enabled.get('closeClient enabled namespace key1') + await valkey.get('closeClient enabled namespace key1') t.fail('Client should not work after being closed') - } catch (err) { + } catch { t.assert.ok('Should throw error when using closed client') } }) @@ -240,7 +240,64 @@ test('Should throw when trying to register multiple instances without giving a n addresses: [{ host: '127.0.0.1', port: 6379 }], }) - await t.assert.rejects(fastify.ready(), new Error('@fastify/valkey has already been registered')) + await t.assert.rejects(fastify.ready(), new Error('@fastify/valkey-glide has already been registered')) +}) + +test('Should throw when namespace is not a non-empty string', async (t) => { + t.plan(5) + + const invalidNamespaces = ['', 0, true, {}, null] + + for (const invalidNamespace of invalidNamespaces) { + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(fastifyValkey, { + addresses: [{ host: '127.0.0.1', port: 6379 }], + namespace: invalidNamespace + }) + + await t.assert.rejects( + fastify.ready(), + new Error('Invalid namespace. Expected a non-empty string when namespace is provided') + ) + } +}) + +test('Should throw when trying to register a namespaced instance after a default instance in the same context', async (t) => { + t.plan(1) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify + .register(fastifyValkey, { + addresses: [{ host: '127.0.0.1', port: 6379 }] + }) + .register(fastifyValkey, { + addresses: [{ host: '127.0.0.1', port: 6379 }], + namespace: 'mixed_mode' + }) + + await t.assert.rejects(fastify.ready(), new Error('@fastify/valkey-glide has already been registered')) +}) + +test('Should throw when trying to register a default instance after a namespaced instance in the same context', async (t) => { + t.plan(1) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify + .register(fastifyValkey, { + addresses: [{ host: '127.0.0.1', port: 6379 }], + namespace: 'mixed_mode' + }) + .register(fastifyValkey, { + addresses: [{ host: '127.0.0.1', port: 6379 }] + }) + + await t.assert.rejects(fastify.ready(), new Error('@fastify/valkey-glide has already been registered')) }) test('Should not throw within different contexts with same namespace', async (t) => { @@ -249,7 +306,7 @@ test('Should not throw within different contexts with same namespace', async (t) const fastify = Fastify() t.after(() => fastify.close()) - fastify.register(function (instance, _options, next) { + fastify.register((instance, _options, next) => { instance.register(fastifyValkey, { addresses: [{ host: '127.0.0.1', port: 6379 }], namespace: 'same namespace' @@ -257,7 +314,7 @@ test('Should not throw within different contexts with same namespace', async (t) next() }) - fastify.register(function (instance, _options, next) { + fastify.register((instance, _options, next) => { instance .register(fastifyValkey, { addresses: [{ host: '127.0.0.1', port: 6379 }], @@ -290,7 +347,63 @@ test('Should throw when trying to connect on an invalid host', async (t) => { await t.assert.rejects(fastify.ready()) }) -test('Should be able to register multiple namespaced @fastify/valkey instances', async t => { +test('Should create a cluster client when clientMode is cluster', async (t) => { + t.plan(3) + + const fastify = Fastify() + const { GlideClusterClient } = require('@valkey/valkey-glide') + const fakeClient = { + close () {} + } + + t.mock.method(GlideClusterClient, 'createClient', async (options) => { + t.assert.deepStrictEqual(options.addresses, [{ host: '127.0.0.1', port: 6379 }]) + return fakeClient + }) + + t.after(async () => { + await fastify.close() + }) + + fastify.register(fastifyValkey, { + clientMode: 'cluster', + addresses: [{ host: '127.0.0.1', port: 6379 }] + }) + + await fastify.ready() + t.assert.ok(fastify.valkey) + t.assert.strictEqual(fastify.valkey, fakeClient) +}) + +test('Should throw when clientMode is invalid', async (t) => { + t.plan(1) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(fastifyValkey, { + clientMode: 'invalid_mode', + addresses: [{ host: '127.0.0.1', port: 6379 }] + }) + + await t.assert.rejects(fastify.ready(), new Error("Invalid clientMode. Expected 'standalone' or 'cluster'")) +}) + +test('Should throw when clientMode is invalid with an existing client', async (t) => { + t.plan(1) + + const fastify = Fastify() + t.after(() => fastify.close()) + + fastify.register(fastifyValkey, { + client: { close () {} }, + clientMode: 'invalid_mode' + }) + + await t.assert.rejects(fastify.ready(), new Error("Invalid clientMode. Expected 'standalone' or 'cluster'")) +}) + +test('Should be able to register multiple namespaced @fastify/valkey-glide instances', async t => { t.plan(3) const fastify = Fastify() @@ -312,7 +425,7 @@ test('Should be able to register multiple namespaced @fastify/valkey instances', t.assert.ok(fastify.valkey.multiple_namespace2) }) -test('Should throw when @fastify/valkey is initialized with an option that makes valkey throw', { skip: process.platform === 'darwin' }, async (t) => { +test('Should throw when @fastify/valkey-glide is initialized with an option that makes valkey throw', { skip: process.platform === 'darwin' }, async (t) => { t.plan(1) const fastify = Fastify() @@ -323,7 +436,7 @@ test('Should throw when @fastify/valkey is initialized with an option that makes await t.assert.rejects(fastify.ready()) }) -test('Should throw when @fastify/valkey is initialized with a namespace and an option that makes valkey throw', { skip: process.platform === 'darwin' }, async (t) => { +test('Should throw when @fastify/valkey-glide is initialized with a namespace and an option that makes valkey throw', { skip: process.platform === 'darwin' }, async (t) => { t.plan(1) const fastify = Fastify({ pluginTimeout: 20000 }) diff --git a/types/index.d.ts b/types/index.d.ts index 95c3aa2..39d9c54 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,5 +1,5 @@ -import { FastifyPluginCallback } from 'fastify' -import { GlideClient, GlideClusterClient, GlideClientConfiguration, } from '@valkey/valkey-glide' +import type { FastifyPluginCallback } from 'fastify' +import type { GlideClient, GlideClusterClient, GlideClientConfiguration, GlideClusterClientConfiguration, } from '@valkey/valkey-glide' type FastifyValkeyPluginType = FastifyPluginCallback @@ -17,7 +17,7 @@ declare namespace fastifyValkey { [namespace: string]: ValkeyClient; } - export type FastifyValkey = FastifyValkeyNamespacedInstance & ValkeyClient + export type FastifyValkey = ValkeyClient | FastifyValkeyNamespacedInstance export type FastifyValkeyPluginOptions = { @@ -29,7 +29,11 @@ declare namespace fastifyValkey { closeClient?: boolean; } | ({ namespace?: string; - } & GlideClientConfiguration) + clientMode?: 'standalone'; + } & GlideClientConfiguration) | ({ + namespace?: string; + clientMode: 'cluster'; + } & GlideClusterClientConfiguration) export const fastifyValkey: FastifyValkeyPluginType export { fastifyValkey as default } } diff --git a/types/index.tst.ts b/types/index.tst.ts index 547919a..90f4e04 100644 --- a/types/index.tst.ts +++ b/types/index.tst.ts @@ -1,7 +1,9 @@ -import Fastify, { FastifyInstance } from 'fastify' -import { GlideClient, GlideClusterClient } from '@valkey/valkey-glide' +import Fastify from 'fastify' +import type { FastifyInstance } from 'fastify' +import type { GlideClient, GlideClusterClient } from '@valkey/valkey-glide' import { expect } from 'tstyche' -import fastifyValkey, { FastifyValkey, FastifyValkeyPluginOptions, FastifyValkeyNamespacedInstance, } from '.' +import fastifyValkey from '.' +import type { FastifyValkey, FastifyValkeyPluginOptions, FastifyValkeyNamespacedInstance, } from '.' const app:FastifyInstance = Fastify() const valkey = {} as GlideClient @@ -19,10 +21,20 @@ app.register(fastifyValkey, { addresses: [{ host: '127.0.0.1', port: 6379 }] }) +app.register(fastifyValkey, { + clientMode: 'cluster', + addresses: [{ host: '127.0.0.1', port: 6379 }] +}) + expect().type.toBeAssignableFrom({ client: valkeyCluster }) +expect().type.toBeAssignableFrom({ + clientMode: 'cluster' as const, + addresses: [{ host: '127.0.0.1', port: 6379 }] +}) + expect().type.not.toBeAssignableFrom({ namespace: 'three', unknownOption: 'this should trigger a typescript error' @@ -30,12 +42,13 @@ expect().type.not.toBeAssignableFrom({ // Plugin property available app.after(() => { - expect(app.valkey).type.toBeAssignableTo() expect(app.valkey).type.toBe() - expect(app.valkey).type.toBeAssignableTo() - expect(app.valkey.one).type.toBe< + expect(app.valkey).type.toBeAssignableTo() + + const namespacedValkey = app.valkey as FastifyValkeyNamespacedInstance + expect(namespacedValkey.one).type.toBe< GlideClient | GlideClusterClient | undefined >() - expect(app.valkey.two).type.toBe() + expect(namespacedValkey.two).type.toBe() })