diff --git a/.eslintrc b/.eslintrc index f79c64b..a9af0c2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,14 +1,26 @@ { "root": true, - "extends": ["eslint:recommended"], + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], "env": { "node": true, "es6": true }, "parserOptions": { - "ecmaVersion": 8 + "sourceType": "module", + "project": "./tsconfig.json", + "ecmaVersion": 2020 }, "rules": { - "no-console": 0 + "prefer-const": 0, + "@typescript-eslint/no-explicit-any": 0, + "@typescript-eslint/no-empty-function": 0, + "@typescript-eslint/explicit-function-return-type": 0, + "@typescript-eslint/no-unused-vars": [2, {"argsIgnorePattern": "^_"}] } } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index afe797d..310d583 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [14.x, 16.x, 18.x] + node: [16, 18] name: Node v${{ matrix.node }} steps: - uses: actions/checkout@v3 @@ -17,7 +17,7 @@ jobs: with: node-version: ${{ matrix.node }} - run: sudo apt-get install -y redis-server - - run: yarn install - - run: yarn fmt-check - - run: yarn lint - - run: yarn test + - run: npm install + - run: npm run fmt-check + - run: npm run lint + - run: npm test diff --git a/.gitignore b/.gitignore index ad5de28..f40487b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ yarn-error.log package-lock.json yarn.lock .DS_Store +dump.rdb +pnpm-lock.yaml +dist diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..c467582 --- /dev/null +++ b/.npmignore @@ -0,0 +1,9 @@ +testdata +coverage +.nyc_output + +package-lock.json +yarn.lock +pnpm-lock.yaml + +dump.rdb diff --git a/.prettierrc b/.prettierrc index e052394..841edad 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,4 +1,5 @@ { - "tabWidth": 2, - "semi": false + "semi": false, + "bracketSpacing": false, + "plugins": ["prettier-plugin-organize-imports"] } diff --git a/index.js b/index.js deleted file mode 100644 index fc8176e..0000000 --- a/index.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = require("./lib/connect-redis") diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..51de30f --- /dev/null +++ b/index.ts @@ -0,0 +1,204 @@ +import {SessionData, Store} from "express-session" + +const noop = (_err?: unknown, _data?: any) => {} + +interface NormalizedRedisClient { + get(key: string): Promise + set(key: string, value: string, ttl?: number): Promise + expire(key: string, ttl: number): Promise + scanIterator(match: string, count: number): AsyncIterable + del(key: string[]): Promise + mget(key: string[]): Promise<(string | null)[]> +} + +interface Serializer { + parse(s: string): SessionData + stringify(s: SessionData): string +} + +interface RedisStoreOptions { + client: any + prefix?: string + scanCount?: number + serializer?: Serializer + ttl?: number + disableTTL?: boolean + disableTouch?: boolean +} + +class RedisStore extends Store { + client: NormalizedRedisClient + prefix: string + scanCount: number + serializer: Serializer + ttl: number + disableTTL: boolean + disableTouch: boolean + + constructor(opts: RedisStoreOptions) { + super() + 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 + } + })() + }, + } + } + + 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, this.serializer.parse(data)) + } catch (err) { + return cb(err) + } + } + + async set(sid: string, sess: SessionData, cb = noop) { + 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) + return cb() + } else { + return this.destroy(sid, cb) + } + } catch (err) { + return cb(err) + } + } + + async touch(sid: string, sess: SessionData, cb = noop) { + let key = this.prefix + sid + if (this.disableTouch || this.disableTTL) return cb() + try { + await this.client.expire(key, this._getTTL(sess)) + return cb() + } catch (err) { + return cb(err) + } + } + + async destroy(sid: string, cb = noop) { + let key = this.prefix + sid + try { + await this.client.del([key]) + return cb() + } catch (err) { + return cb(err) + } + } + + async clear(cb = noop) { + try { + let keys = await this._getAllKeys() + if (!keys.length) return cb() + await this.client.del(keys) + return cb() + } catch (err) { + return cb(err) + } + } + + async length(cb = noop) { + try { + let keys = await this._getAllKeys() + return cb(null, keys.length) + } catch (err) { + return cb(err) + } + } + + async ids(cb = noop) { + let len = this.prefix.length + try { + let keys = await this._getAllKeys() + return cb( + null, + keys.map((k) => k.substring(len)) + ) + } catch (err) { + return cb(err) + } + } + + async all(cb = noop) { + let len = this.prefix.length + try { + let keys = await this._getAllKeys() + if (keys.length === 0) return cb(null, []) + + 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 + sess.id = keys[idx].substring(len) + acc.push(sess) + return acc + }, [] as SessionData[]) + return cb(null, results) + } catch (err) { + return cb(err) + } + } + + private _getTTL(sess: SessionData) { + let ttl + if (sess && sess.cookie && sess.cookie.expires) { + let ms = Number(new Date(sess.cookie.expires)) - Date.now() + ttl = Math.ceil(ms / 1000) + } else { + ttl = this.ttl + } + return ttl + } + + private async _getAllKeys() { + let pattern = this.prefix + "*" + let keys = [] + for await (let key of this.client.scanIterator(pattern, this.scanCount)) { + keys.push(key) + } + return keys + } +} + +export default RedisStore diff --git a/index_test.ts b/index_test.ts new file mode 100644 index 0000000..483bf14 --- /dev/null +++ b/index_test.ts @@ -0,0 +1,129 @@ +import test from "blue-tape" +import {Cookie} from "express-session" +import {Redis} from "ioredis" +import {promisify} from "node:util" +import {createClient} from "redis" +import RedisStore from "./" +import * as redisSrv from "./testdata/server" + +test("setup", redisSrv.connect) + +test("defaults", async (t) => { + let client = createClient({url: `redis://localhost:${redisSrv.port}`}) + await client.connect() + + let store = new RedisStore({client}) + + t.ok(store.client, "stores client") + t.equal(store.prefix, "sess:", "defaults to sess:") + t.equal(store.ttl, 86400, "defaults to one day") + t.equal(store.scanCount, 100, "defaults SCAN count to 100") + t.equal(store.serializer, JSON, "defaults to JSON serialization") + t.equal(store.disableTouch, false, "defaults to having `touch` enabled") + t.equal(store.disableTTL, false, "defaults to having `ttl` enabled") + await client.disconnect() +}) + +test("redis", async (t) => { + let client = createClient({url: `redis://localhost:${redisSrv.port}`}) + await client.connect() + let store = new RedisStore({client}) + await lifecycleTest(store, client, t) + await client.disconnect() +}) + +test("ioredis", async (t) => { + let client = new Redis(`redis://localhost:${redisSrv.port}`) + let store = new RedisStore({client}) + await lifecycleTest(store, client, t) + client.disconnect() +}) + +test("teardown", redisSrv.disconnect) + +async function lifecycleTest( + store: RedisStore, + client: any, + t: test.Test +): Promise { + const P = (f: any) => promisify(f).bind(store) + let res = await P(store.clear)() + + let sess = {foo: "bar"} + await P(store.set)("123", sess) + + res = await P(store.get)("123") + t.same(res, sess, "store.get") + + let ttl = await client.ttl("sess:123") + t.ok(ttl >= 86399, "check one day ttl") + + ttl = 60 + let expires = new Date(Date.now() + ttl * 1000).toISOString() + await P(store.set)("456", {cookie: {expires}}) + ttl = await client.ttl("sess:456") + t.ok(ttl <= 60, "check expires ttl") + + ttl = 90 + let expires2 = new Date(Date.now() + ttl * 1000).toISOString() + await P(store.touch)("456", {cookie: {expires: expires2}}) + ttl = await client.ttl("sess:456") + t.ok(ttl > 60, "check expires ttl touch") + + res = await P(store.length)() + t.equal(res, 2, "stored two keys length") + + res = await P(store.ids)() + res.sort() + t.same(res, ["123", "456"], "stored two keys ids") + + res = await P(store.all)() + res.sort((a: any, b: any) => (a.id > b.id ? 1 : -1)) + t.same( + res, + [ + {id: "123", foo: "bar"}, + {id: "456", cookie: {expires}}, + ], + "stored two keys data" + ) + + await P(store.destroy)("456") + res = await P(store.length)() + t.equal(res, 1, "one key remains") + + res = await P(store.clear)() + + res = await P(store.length)() + t.equal(res, 0, "no keys remain") + + let count = 1000 + await load(store, count) + + res = await P(store.length)() + t.equal(res, count, "bulk count") + + await P(store.clear)() + res = await P(store.length)() + t.equal(res, 0, "bulk clear") + + expires = new Date(Date.now() + ttl * 1000).toISOString() // expires in the future + res = await P(store.set)("789", {cookie: {expires}}) + + res = await P(store.length)() + t.equal(res, 1, "one key exists (session 789)") + + expires = new Date(Date.now() - ttl * 1000).toISOString() // expires in the past + await P(store.set)("789", {cookie: {expires}}) + + res = await P(store.length)() + t.equal(res, 0, "no key remains and that includes session 789") +} + +async function load(store: RedisStore, count: number) { + let cookie = new Cookie() + for (let sid = 0; sid < count; sid++) { + cookie.expires = new Date(Date.now() + 1000) + await store.set("s" + sid, {cookie}) + } +} diff --git a/lib/connect-redis.js b/lib/connect-redis.js deleted file mode 100644 index 23ef69d..0000000 --- a/lib/connect-redis.js +++ /dev/null @@ -1,175 +0,0 @@ -/*! - * Connect - Redis - * Copyright(c) 2010-2020 TJ Holowaychuk - * MIT Licensed - */ - -module.exports = function (session) { - const Store = session.Store - - // All callbacks should have a noop if none provided for compatibility - // with the most Redis clients. - const noop = () => {} - - class RedisStore extends Store { - constructor(options = {}) { - super(options) - if (!options.client) { - throw new Error("A client must be directly provided to the RedisStore") - } - - this.prefix = options.prefix == null ? "sess:" : options.prefix - this.scanCount = Number(options.scanCount) || 100 - this.serializer = options.serializer || JSON - this.client = options.client - this.ttl = options.ttl || 86400 // One day in seconds. - this.disableTTL = options.disableTTL || false - this.disableTouch = options.disableTouch || false - } - - get(sid, cb = noop) { - let key = this.prefix + sid - - this.client.get(key, (err, data) => { - if (err) return cb(err) - if (!data) return cb() - - let result - try { - result = this.serializer.parse(data) - } catch (err) { - return cb(err) - } - return cb(null, result) - }) - } - - set(sid, sess, cb = noop) { - let args = [this.prefix + sid] - - let value - try { - value = this.serializer.stringify(sess) - } catch (er) { - return cb(er) - } - args.push(value) - - let ttl = 1 - if (!this.disableTTL) { - ttl = this._getTTL(sess) - args.push("EX", ttl) - } - - if (ttl > 0) { - this.client.set(args, cb) - } else { - // If the resulting TTL is negative we can delete / destroy the key - this.destroy(sid, cb) - } - } - - touch(sid, sess, cb = noop) { - if (this.disableTouch || this.disableTTL) return cb() - let key = this.prefix + sid - this.client.expire(key, this._getTTL(sess), (err, ret) => { - if (err) return cb(err) - if (ret !== 1) return cb(null, "EXPIRED") - cb(null, "OK") - }) - } - - destroy(sid, cb = noop) { - let key = this.prefix + sid - this.client.del(key, cb) - } - - clear(cb = noop) { - this._getAllKeys((err, keys) => { - if (err) return cb(err) - this.client.del(keys, cb) - }) - } - - length(cb = noop) { - this._getAllKeys((err, keys) => { - if (err) return cb(err) - return cb(null, keys.length) - }) - } - - ids(cb = noop) { - let prefixLen = this.prefix.length - - this._getAllKeys((err, keys) => { - if (err) return cb(err) - keys = keys.map((key) => key.substr(prefixLen)) - return cb(null, keys) - }) - } - - all(cb = noop) { - let prefixLen = this.prefix.length - - this._getAllKeys((err, keys) => { - if (err) return cb(err) - if (keys.length === 0) return cb(null, []) - - this.client.mget(keys, (err, sessions) => { - if (err) return cb(err) - - let result - try { - result = sessions.reduce((accum, data, index) => { - if (!data) return accum - data = this.serializer.parse(data) - data.id = keys[index].substr(prefixLen) - accum.push(data) - return accum - }, []) - } catch (e) { - err = e - } - return cb(err, result) - }) - }) - } - - _getTTL(sess) { - let ttl - if (sess && sess.cookie && sess.cookie.expires) { - let ms = Number(new Date(sess.cookie.expires)) - Date.now() - ttl = Math.ceil(ms / 1000) - } else { - ttl = this.ttl - } - return ttl - } - - _getAllKeys(cb = noop) { - let pattern = this.prefix + "*" - this._scanKeys({}, 0, pattern, this.scanCount, cb) - } - - _scanKeys(keys = {}, cursor, pattern, count, cb = noop) { - let args = [cursor, "match", pattern, "count", count] - this.client.scan(args, (err, data) => { - if (err) return cb(err) - - let [nextCursorId, scanKeys] = data - for (let key of scanKeys) { - keys[key] = true - } - - // This can be a string or a number. We check both. - if (Number(nextCursorId) !== 0) { - return this._scanKeys(keys, nextCursorId, pattern, count, cb) - } - - cb(null, Object.keys(keys)) - }) - } - } - - return RedisStore -} diff --git a/package.json b/package.json index 1e16f03..a7799e2 100644 --- a/package.json +++ b/package.json @@ -1,42 +1,57 @@ { "name": "connect-redis", "description": "Redis session store for Connect", - "version": "6.1.3", + "version": "7.0.0-rc.2", "author": "TJ Holowaychuk ", "contributors": [ "Marc Harter " ], "license": "MIT", - "main": "./index.js", + "main": "./dist/esm/index.js", + "exports": { + ".": { + "import": "./dist/esm/index.js", + "require": "./dist/cjs/index.js", + "default": "./dist/esm/index.js" + } + }, + "types": "./dist/esm/index.d.ts", + "scripts": { + "prepublishOnly": "rm -rf dist && tsc & tsc --project tsconfig.esm.json && echo '{\"type\":\"module\"}' > dist/esm/package.json", + "build": "npm run prepublishOnly", + "test": "nyc ts-node node_modules/blue-tape/bin/blue-tape \"**/*_test.ts\"", + "lint": "tsc --noemit && eslint --max-warnings 0 --ext ts testdata *.ts", + "fmt": "prettier --write .", + "fmt-check": "prettier --check ." + }, "repository": { "type": "git", "url": "git@github.com:tj/connect-redis.git" }, "devDependencies": { + "@types/blue-tape": "^0.1.33", + "@types/express-session": "^1.17.6", + "@types/node": "^18.13.0", + "@typescript-eslint/eslint-plugin": "^5.52.0", + "@typescript-eslint/parser": "^5.52.0", "blue-tape": "^1.0.0", - "eslint": "^7.4.0", - "eslint-config-prettier": "^8.3.0", - "express-session": "^1.17.0", - "ioredis-v4": "npm:ioredis@4", - "ioredis-v5": "npm:ioredis@5", - "nyc": "^15.0.1", - "prettier": "^2.0.5", - "redis-mock": "^0.56.3", - "redis-v3": "npm:redis@3", - "redis-v4": "npm:redis@4" + "eslint": "^8.34.0", + "eslint-config-prettier": "^8.6.0", + "express-session": "^1.17.3", + "ioredis": "^5.3.1", + "nyc": "^15.1.0", + "prettier": "^2.8.4", + "prettier-plugin-organize-imports": "^3.2.2", + "redis": "^4.6.4", + "ts-node": "^10.9.1", + "typescript": "^4.9.5" }, "engines": { - "node": ">=14" + "node": ">=16" }, "bugs": { "url": "https://github.com/tj/connect-redis/issues" }, - "scripts": { - "test": "nyc tape \"test/*-test.js\"", - "lint": "eslint index.js test lib", - "fmt": "prettier --write .", - "fmt-check": "prettier --check ." - }, "keywords": [ "connect", "redis", diff --git a/readme.md b/readme.md index 7a95ed4..b6977fa 100644 --- a/readme.md +++ b/readme.md @@ -1,71 +1,85 @@ ![Build Status](https://github.com/tj/connect-redis/workflows/build/badge.svg?branch=master) [![npm](https://img.shields.io/npm/v/connect-redis.svg)](https://npmjs.com/package/connect-redis) [![code-style](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://gitter.im/jlongster/prettier) ![Downloads](https://img.shields.io/npm/dm/connect-redis.svg) -**connect-redis** provides Redis session storage for Express. Requires Redis >= `2.0.0`. +**connect-redis** provides Redis session storage for Express. ## Installation -npm: +**connect-redis** requires `express-session` to installed and one of the following compatible Redis clients: + +- [`redis`][1] +- [`ioredis`][2] + +Install with `redis`: ```sh npm install redis connect-redis express-session ``` -Yarn: +Install with `ioredis`: ```sh -yarn add redis connect-redis express-session +npm install ioredis connect-redis express-session ``` -## API +## Importing + +**connect-redis** supports both CommonJS (`require`) and ESM (`import`) modules. + +Import using ESM/Typescript: ```js -const session = require("express-session") -let RedisStore = require("connect-redis")(session) +import RedisStore from "connect-redis" +``` -// redis@v4 -const { createClient } = require("redis") -let redisClient = createClient({ legacyMode: true }) -redisClient.connect().catch(console.error) +Require using CommonJS: -// redis@v3 -const { createClient } = require("redis") +```js +const RedisStore = require("connect-redis").default +``` + +## API + +Full setup using [`redis`][1] package: + +```js +import RedisStore from "connect-redis" +import session from "express-session" +import {createClient} from "redis" + +// Initialize client. let redisClient = createClient() +redisClient.connect().catch(console.error) -// ioredis@v4 and ioredis@v5 -const Redis = require("ioredis") -let redisClient = new Redis() +// Initialize store. +let redisStore = new RedisStore({ + client: redisClient, + prefix: "myapp:", +}) +// Initialize sesssion storage. app.use( session({ - store: new RedisStore({ client: redisClient }), - saveUninitialized: false, + store: redisStore, + resave: false, // required: force lightweight session keep alive (touch) + saveUninitialized: false, // recommended: only save session when data exists secret: "keyboard cat", - resave: false, }) ) ``` ### RedisStore(options) -The `RedisStore` requires an existing Redis client. Any clients compatible with the [`redis`][1] API will work. See `client` option for more details. - #### Options ##### client -An instance of [`redis`][1] or a `redis` compatible client. - -Known compatible and tested clients: - -- [redis][1] (v3, v4 with `legacyMode: true`) -- [ioredis](https://github.com/luin/ioredis) -- [redis-mock](https://github.com/yeahoffline/redis-mock) for testing. +An instance of [`redis`][1] or [`ioredis`][2]. ##### prefix Key prefix in Redis (default: `sess:`). -This prefix appends to whatever prefix you may have set on the `client` itself. +**Note**: This prefix appends to whatever prefix you may have set on the `client` itself. **Note**: You may need unique prefixes for different applications sharing the same Redis instance. This limits bulk commands exposed in `express-session` (like `length`, `all`, `keys`, and `clear`) to a single application's data. @@ -77,25 +91,27 @@ Otherwise, it will expire the session using the `ttl` option (default: `86400` s **Note**: The TTL is reset every time a user interacts with the server. You can disable this behavior in _some_ instances by using `disableTouch`. -**Note**: `express-session` does not update `expires` until the end of the request life cycle. Calling `session.save()` manually beforehand will have the previous value. +**Note**: `express-session` does not update `expires` until the end of the request life cycle. _Calling `session.save()` manually beforehand will have the previous value_. ##### disableTouch -Disables re-saving and resetting the TTL when using `touch` (default: `false`) +Disables resetting the TTL when using `touch` (default: `false`) The `express-session` package uses `touch` to signal to the store that the user has interacted with the session but hasn't changed anything in its data. Typically, this helps keep the users session alive if session changes are infrequent but you may want to disable it to cut down the extra calls or to prevent users from keeping sessions open too long. Also consider enabling if you store a lot of data on the session. -Ref: https://github.com/expressjs/session#storetouchsid-session-callback +Ref: ##### disableTTL Disables key expiration completely (default: `false`) -This option disables key expiration requiring the user to manually manage key cleanup outside of `connect-redis`. Only use if you know what you are doing and have an exceptional case where you need to manage your own expiration in Redis. Note this has no effect on `express-session` setting cookie expiration. +This option disables key expiration requiring the user to manually manage key cleanup outside of `connect-redis`. Only use if you know what you are doing and have an exceptional case where you need to manage your own expiration in Redis. + +**Note**: This has no effect on `express-session` setting cookie expiration. ##### serializer -The encoder/decoder to use when storing and retrieving session data from Redis (default: `JSON`). +Provide a custom encoder/decoder to use when storing and retrieving session data from Redis (default: `JSON.parse` and `JSON.stringify`). ```ts interface Serializer { @@ -118,7 +134,7 @@ client.on("error", console.error) #### How do I handle lost connections to Redis? -By default, the [`redis`][1] client will [auto-reconnect](https://github.com/mranney/node_redis#overloading) on lost connections. But requests may come in during that time. In Express, one way you can handle this scenario is including a "session check": +By default, the Redis client will [auto-reconnect](https://github.com/mranney/node_redis#overloading) on lost connections. But requests may come in during that time. In Express, one way you can handle this scenario is including a "session check": ```js app.use(session(/* setup session here */)) @@ -133,3 +149,4 @@ app.use(function (req, res, next) { If you want to retry, here is [another option](https://github.com/expressjs/session/issues/99#issuecomment-63853989). [1]: https://github.com/NodeRedis/node-redis +[2]: https://github.com/luin/ioredis diff --git a/test/connect-redis-test.js b/test/connect-redis-test.js deleted file mode 100644 index 7a36189..0000000 --- a/test/connect-redis-test.js +++ /dev/null @@ -1,185 +0,0 @@ -const test = require("blue-tape") -const redisSrv = require("../test/redis-server") -const session = require("express-session") -const redisV3 = require("redis-v3") -const redisV4 = require("redis-v4") -const ioRedisV4 = require("ioredis-v4") -const ioRedisV5 = require("ioredis-v5") -const redisMock = require("redis-mock") - -let RedisStore = require("../")(session) - -let p = - (ctx, method) => - (...args) => - new Promise((resolve, reject) => { - ctx[method](...args, (err, d) => { - if (err) reject(err) - resolve(d) - }) - }) - -test("setup", redisSrv.connect) - -test("defaults", async (t) => { - t.throws(() => new RedisStore(), "client is required") - - var client = redisV3.createClient(redisSrv.port, "localhost") - var store = new RedisStore({ client }) - - t.equal(store.client, client, "stores client") - t.equal(store.prefix, "sess:", "defaults to sess:") - t.equal(store.ttl, 86400, "defaults to one day") - t.equal(store.scanCount, 100, "defaults SCAN count to 100") - t.equal(store.serializer, JSON, "defaults to JSON serialization") - t.equal(store.disableTouch, false, "defaults to having `touch` enabled") - t.equal(store.disableTTL, false, "defaults to having `ttl` enabled") - client.end(false) -}) - -test("node_redis v3", async (t) => { - var client = redisV3.createClient(redisSrv.port, "localhost") - var store = new RedisStore({ client }) - await lifecycleTest(store, t) - client.end(false) -}) - -test("node_redis v4", async (t) => { - var client = redisV4.createClient({ - url: `redis://localhost:${redisSrv.port}`, - legacyMode: true, - }) - await client.connect() - var store = new RedisStore({ client }) - await lifecycleTest(store, t) - await client.disconnect() -}) - -test("ioredis 4", async (t) => { - var client = ioRedisV4.createClient(redisSrv.port, "localhost") - var store = new RedisStore({ client }) - await lifecycleTest(store, t) - client.disconnect() -}) - -test("ioredis 5", async (t) => { - var client = ioRedisV5.createClient(redisSrv.port, "localhost") - var store = new RedisStore({ client }) - await lifecycleTest(store, t) - client.disconnect() -}) - -test("redis-mock client", async (t) => { - var client = redisMock.createClient() - var store = new RedisStore({ client }) - await lifecycleTest(store, t) -}) - -test("teardown", redisSrv.disconnect) - -async function lifecycleTest(store, t) { - let res = await p(store, "set")("123", { foo: "bar" }) - t.equal(res, "OK", "set value") - - res = await p(store, "get")("123") - t.same(res, { foo: "bar" }, "get value") - - res = await p(store.client, "ttl")("sess:123") - t.ok(res >= 86399, "check one day ttl") - - let ttl = 60 - let expires = new Date(Date.now() + ttl * 1000).toISOString() - res = await p(store, "set")("456", { cookie: { expires } }) - t.equal(res, "OK", "set cookie expires") - - res = await p(store.client, "ttl")("sess:456") - t.ok(res <= 60, "check expires ttl") - - ttl = 90 - let newExpires = new Date(Date.now() + ttl * 1000).toISOString() - // note: cookie.expires will not be updated on redis (see https://github.com/tj/connect-redis/pull/285) - res = await p(store, "touch")("456", { cookie: { expires: newExpires } }) - t.equal(res, "OK", "set cookie expires touch") - - res = await p(store.client, "ttl")("sess:456") - t.ok(res > 60, "check expires ttl touch") - - res = await p(store, "length")() - t.equal(res, 2, "stored two keys length") - - res = await p(store, "ids")() - res.sort() - t.same(res, ["123", "456"], "stored two keys ids") - - res = await p(store, "all")() - res.sort((a, b) => (a.id > b.id ? 1 : -1)) - t.same( - res, - [ - { id: "123", foo: "bar" }, - { id: "456", cookie: { expires } }, - ], - "stored two keys data" - ) - - res = await p(store, "destroy")("456") - t.equal(res, 1, "destroyed one") - - res = await p(store, "length")() - t.equal(res, 1, "one key remains") - - res = await p(store, "clear")() - t.equal(res, 1, "cleared remaining key") - - res = await p(store, "length")() - t.equal(res, 0, "no key remains") - - let count = 1000 - await load(store, count) - - res = await p(store, "length")() - t.equal(res, count, "bulk count") - - res = await p(store, "clear")() - t.equal(res, count, "bulk clear") - - expires = new Date(Date.now() + ttl * 1000).toISOString() // expires in the future - res = await p(store, "set")("789", { cookie: { expires } }) - t.equal(res, "OK", "set value") - - res = await p(store, "length")() - t.equal(res, 1, "one key exists (session 789)") - - expires = new Date(Date.now() - ttl * 1000).toISOString() // expires in the past - res = await p(store, "set")("789", { cookie: { expires } }) - t.equal(res, 1, "returns 1 because destroy was invoked") - - res = await p(store, "length")() - t.equal(res, 0, "no key remains and that includes session 789") -} - -function load(store, count) { - return new Promise((resolve, reject) => { - let set = (sid) => { - store.set( - "s" + sid, - { - cookie: { expires: new Date(Date.now() + 1000) }, - data: "some data", - }, - (err) => { - if (err) { - return reject(err) - } - - if (sid === count) { - return resolve() - } - - set(sid + 1) - } - ) - } - set(1) - }) -} diff --git a/test/redis-server.js b/testdata/server.ts similarity index 60% rename from test/redis-server.js rename to testdata/server.ts index 6187309..f6c3b94 100644 --- a/test/redis-server.js +++ b/testdata/server.ts @@ -1,9 +1,10 @@ -const spawn = require("child_process").spawn -const port = (exports.port = 18543) -let redisSrv +import {ChildProcess, spawn} from "node:child_process" +let redisSrv: ChildProcess -exports.connect = () => - new Promise((resolve, reject) => { +export const port = "18543" + +export function connect() { + return new Promise((resolve, reject) => { redisSrv = spawn("redis-server", ["--port", port, "--loglevel", "notice"], { stdio: "inherit", }) @@ -14,8 +15,9 @@ exports.connect = () => setTimeout(resolve, 1500) }) +} -exports.disconnect = function () { +export function disconnect() { redisSrv.kill("SIGKILL") return Promise.resolve() } diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..f99918a --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "es2020", + "outDir": "./dist/esm" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..da1c546 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2020", + "module": "commonjs", + "isolatedModules": true, + "strict": true, + "noImplicitReturns": true, + "noUnusedLocals": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "declaration": true, + "outDir": "./dist/cjs", + "esModuleInterop": true, + "resolveJsonModule": true + }, + "exclude": ["node_modules", "dist"] +}