Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support of Redis JSON #421

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
name: build

on:
push:
branches:
- master
pull_request:
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-20.04
strategy:
matrix:
node: [18, 20, 22]
Expand All @@ -16,7 +18,13 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
- run: sudo apt-get install -y redis-server

- run: sudo apt-get install lsb-release curl gpg
- run: curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
- run: sudo chmod 644 /usr/share/keyrings/redis-archive-keyring.gpg
- run: echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
- run: sudo apt-get update
- run: sudo apt-get install redis-stack-server
- run: npm install
- run: npm run fmt-check
- run: npm run lint
Expand Down
135 changes: 97 additions & 38 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,21 @@ const noop = (_err?: unknown, _data?: any) => {}

interface NormalizedRedisClient {
get(key: string): Promise<string | null>
set(key: string, value: string, ttl?: number): Promise<string | null>

set(key: string, value: any, ttl?: number): Promise<string | null>

expire(key: string, ttl: number): Promise<number | boolean>

scanIterator(match: string, count: number): AsyncIterable<string>

del(key: string[]): Promise<number>

mget(key: string[]): Promise<(string | null)[]>
}

interface Serializer {
parse(s: string): SessionData | Promise<SessionData>

stringify(s: SessionData): string
}

Expand All @@ -24,6 +30,7 @@ interface RedisStoreOptions {
ttl?: number | {(sess: SessionData): number}
disableTTL?: boolean
disableTouch?: boolean
useRedisJson?: boolean
}

export class RedisStore extends Store {
Expand All @@ -34,56 +41,36 @@ export class RedisStore extends Store {
ttl: number | {(sess: SessionData): number}
disableTTL: boolean
disableTouch: boolean
useRedisJson: boolean
isRedis: boolean

constructor(opts: RedisStoreOptions) {
super()
this.isRedis = "scanIterator" in opts.client

this.prefix = opts.prefix == null ? "sess:" : opts.prefix
this.scanCount = opts.scanCount || 100
this.serializer = opts.serializer || JSON
this.ttl = opts.ttl || 86400 // One day in seconds.
this.disableTTL = opts.disableTTL || false
this.disableTouch = opts.disableTouch || false
this.client = this.normalizeClient(opts.client)
}

// Create a redis and ioredis compatible client
private normalizeClient(client: any): NormalizedRedisClient {
let isRedis = "scanIterator" in client
return {
get: (key) => client.get(key),
set: (key, val, ttl) => {
if (ttl) {
return isRedis
? client.set(key, val, {EX: ttl})
: client.set(key, val, "EX", ttl)
}
return client.set(key, val)
},
del: (key) => client.del(key),
expire: (key, ttl) => client.expire(key, ttl),
mget: (keys) => (isRedis ? client.mGet(keys) : client.mget(keys)),
scanIterator: (match, count) => {
if (isRedis) return client.scanIterator({MATCH: match, COUNT: count})

// ioredis impl.
return (async function* () {
let [c, xs] = await client.scan("0", "MATCH", match, "COUNT", count)
for (let key of xs) yield key
while (c !== "0") {
;[c, xs] = await client.scan(c, "MATCH", match, "COUNT", count)
for (let key of xs) yield key
}
})()
},
}
this.useRedisJson = opts.useRedisJson ?? false
}

async get(sid: string, cb = noop) {
let key = this.prefix + sid
try {
let data = await this.client.get(key)
if (!data) return cb()
return cb(null, await this.serializer.parse(data))

const parsedData: any = this.useRedisJson
? this.isRedis
? data
: ((await this.serializer.parse(data)) as any)?.[0]
: await this.serializer.parse(data)

return cb(null, parsedData)
} catch (err) {
return cb(err)
}
Expand All @@ -93,10 +80,9 @@ export class RedisStore extends Store {
let key = this.prefix + sid
let ttl = this._getTTL(sess)
try {
let val = this.serializer.stringify(sess)
if (ttl > 0) {
if (this.disableTTL) await this.client.set(key, val)
else await this.client.set(key, val, ttl)
if (this.disableTTL) await this.client.set(key, sess)
else await this.client.set(key, sess, ttl)
return cb()
} else {
return this.destroy(sid, cb)
Expand Down Expand Up @@ -169,7 +155,11 @@ export class RedisStore extends Store {
let data = await this.client.mget(keys)
let results = data.reduce((acc, raw, idx) => {
if (!raw) return acc
let sess = this.serializer.parse(raw) as any
let sess = this.useRedisJson
? this.isRedis
? raw?.[0]
: (this.serializer.parse(raw) as any)?.[0]
: (this.serializer.parse(raw) as any)
sess.id = keys[idx].substring(len)
acc.push(sess)
return acc
Expand All @@ -180,6 +170,75 @@ export class RedisStore extends Store {
}
}

// Create a redis and ioredis compatible client
private normalizeClient(client: any): NormalizedRedisClient {
let isRedis = "scanIterator" in client
return {
get: (key) =>
this.useRedisJson
? isRedis
? client.json.get(key, "$")
: client.call("JSON.GET", key, "$")
: client.get(key),
set: (key, sess, ttl) => {
let val =
this.useRedisJson && this.isRedis
? sess
: this.serializer.stringify(sess)

if (ttl) {
if (isRedis) {
if (this.useRedisJson) {
const multi = client.multi()
multi.json.set(key, "$", val)
multi.expire(key, ttl)
return multi.exec()
}
return client.set(key, val, {EX: ttl})
} else {
if (this.useRedisJson) {
const pipeline = client.pipeline()
pipeline.call("JSON.SET", key, "$", val)
pipeline.expire(key, ttl)
return pipeline.exec()
}
return client.set(key, val, "EX", ttl)
}
}
if (this.useRedisJson) {
return isRedis
? client.json.set(key, "$", val)
: client.call("JSON.SET", key, "$", val)
}
return client.set(key, val)
},
del: (key) => client.del(key),
expire: (key, ttl) => client.expire(key, ttl),
mget: (keys) => {
if (this.useRedisJson) {
return isRedis
? client.json.mGet(keys, "$")
: client.call("JSON.MGET", ...keys, "$")
} else {
return isRedis ? client.mGet(keys) : client.mget(keys)
}
},
scanIterator: (match, count) => {
if (isRedis) return client.scanIterator({MATCH: match, COUNT: count})

// ioredis impl.
return (async function* () {
let [c, xs] = await client.scan("0", "MATCH", match, "COUNT", count)
for (let key of xs) yield key
while (c !== "0") {
;[c, xs] = await client.scan(c, "MATCH", match, "COUNT", count)
for (let key of xs) yield key
}
})()
},
}
}

private _getTTL(sess: SessionData) {
if (typeof this.ttl === "function") {
return this.ttl(sess)
Expand Down
38 changes: 31 additions & 7 deletions index_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,18 @@ import {expect, test} from "vitest"
import {RedisStore} from "./"
import * as redisSrv from "./testdata/server"

test("setup", async () => {
await redisSrv.connect()
})
let redisPort: string = redisSrv.port

if (!process.env.USER_LOCAL_REDIS) {
test("setup", async () => {
await redisSrv.connect()
})
} else {
redisPort = "6379"
}

test("defaults", async () => {
let client = createClient({url: `redis://localhost:${redisSrv.port}`})
let client = createClient({url: `redis://localhost:${redisPort}`})
await client.connect()

let store = new RedisStore({client})
Expand All @@ -23,25 +29,43 @@ test("defaults", async () => {
expect(store.serializer).toBe(JSON)
expect(store.disableTouch).toBe(false)
expect(store.disableTTL).toBe(false)
expect(store.useRedisJson).toBe(false)
await client.disconnect()
})

test("redis", async () => {
let client = createClient({url: `redis://localhost:${redisSrv.port}`})
let client = createClient({url: `redis://localhost:${redisPort}`})
await client.connect()
let store = new RedisStore({client})
await lifecycleTest(store, client)
await client.disconnect()
})

test("ioredis", async () => {
let client = new Redis(`redis://localhost:${redisSrv.port}`)
let client = new Redis(`redis://localhost:${redisPort}`)
let store = new RedisStore({client})
await lifecycleTest(store, client)
client.disconnect()
})

test("teardown", redisSrv.disconnect)
test("redis with json", async () => {
let client = createClient({url: `redis://localhost:${redisPort}`})
await client.connect()
let store = new RedisStore({client, useRedisJson: true})
await lifecycleTest(store, client)
await client.disconnect()
})

test("ioredis with json", async () => {
let client = new Redis(`redis://localhost:${redisPort}`)
let store = new RedisStore({client, useRedisJson: true})
await lifecycleTest(store, client)
client.disconnect()
})

if (!process.env.USER_LOCAL_REDIS) {
test("teardown", redisSrv.disconnect)
}

async function lifecycleTest(store: RedisStore, client: any): Promise<void> {
const P = (f: any) => promisify(f).bind(store)
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "connect-redis",
"description": "Redis session store for Connect",
"version": "8.0.1",
"version": "8.0.2",
"author": "TJ Holowaychuk <tj@vision-media.ca>",
"contributors": [
"Marc Harter <wavded@gmail.com>"
Expand All @@ -27,6 +27,7 @@
"prepublishOnly": "vite build",
"build": "vite build",
"test": "vitest run --silent --coverage",
"test local Redis": "USER_LOCAL_REDIS=1 vitest run --silent --coverage",
"lint": "tsc --noemit && eslint --max-warnings 0 testdata *.ts",
"fmt": "prettier --write .",
"fmt-check": "prettier --check ."
Expand All @@ -45,8 +46,8 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"express-session": "^1.18.1",
"ioredis": "^5.4.1",
"prettier": "^3.4.1",
"ioredis": "^5.5.0",
"prettier": "^3.5.0",
"prettier-plugin-organize-imports": "^4.1.0",
"redis": "^4.7.0",
"ts-node": "^10.9.2",
Expand Down
7 changes: 7 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,10 @@ Value used for _count_ parameter in [Redis `SCAN` command](https://redis.io/comm

[1]: https://github.com/NodeRedis/node-redis
[2]: https://github.com/luin/ioredis

#### useRedisJson

Enable support of JSON for Redis (default: `false`)
The session data will be stored as JSON objects in DB.

Your Redis server must have the [RedisJSON plugin](https://redis.io/docs/latest/develop/data-types/json/) enabled.
11 changes: 8 additions & 3 deletions testdata/server.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import {ChildProcess, spawn} from "node:child_process"

let redisSrv: ChildProcess

export const port = "18543"

export function connect() {
return new Promise((resolve, reject) => {
redisSrv = spawn("redis-server", ["--port", port, "--loglevel", "notice"], {
stdio: "inherit",
})
redisSrv = spawn(
"redis-stack-server",
["--port", port, "--loglevel", "notice"],
{
stdio: "inherit",
},
)

redisSrv.on("error", function (err) {
reject(new Error("Error caught spawning the server:" + err.message))
Expand Down
Loading