diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 9bcdb46..0000000 --- a/.eslintrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "extends": [ - "eslint-config-egg/typescript", - "eslint-config-egg/lib/rules/enforce-node-prefix" - ] -} diff --git a/.gitignore b/.gitignore index 388e6c8..5310f24 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ coverage .tshy* .eslintcache dist +/tsconfig.tsbuildinfo diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..4b8cf86 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,60 @@ +// Reuse legacy eslint-config-egg via FlatCompat on ESLint v9 flat config +import { FlatCompat } from '@eslint/eslintrc'; +import js from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import nodePlugin from 'eslint-plugin-node'; + +const compat = new FlatCompat({ baseDirectory: import.meta.dirname }); + +export default [ + js.configs.recommended, + // TypeScript recommendations (no type-checking for speed) + ...tseslint.configs.recommended, + // Bring in the Egg preset (legacy) so behavior stays identical + ...compat.extends('eslint-config-egg'), + // Project-specific tweaks + { + ignores: [ 'dist/**', 'node_modules/**' ], + }, + { + files: [ 'src/**/*.ts', 'test/**/*.ts' ], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + parser: tseslint.parser, + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: (() => { + // Disable all node/* rules to avoid ESLint v9 incompatibilities from legacy plugin versions pulled by eslint-config-egg + const disabled = Object.fromEntries( + Object.keys(nodePlugin.rules ?? {}).map(ruleName => [ `node/${ruleName}`, 'off' ]) + ); + // Align with previous ESLint 8 behavior from eslint-config-egg in this repo + Object.assign(disabled, { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'no-unused-vars': 'off', + 'comma-dangle': 'off', + 'no-prototype-builtins': 'off', + 'no-useless-catch': 'off', + }); + return disabled; + })(), + }, + // Relax rules specifically for tests to mirror prior setup + { + files: [ 'test/**/*.ts' ], + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-expressions': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'no-unused-vars': 'off', + }, + }, +]; + + diff --git a/package.json b/package.json index 808789e..d3470d3 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { - "name": "koa-session", + "name": "@bp/koa-session", "description": "Koa cookie session middleware with external store support", "repository": { "type": "git", "url": "git@github.com:koajs/session.git" }, - "version": "7.0.2", + "version": "8.0.0", "keywords": [ "koa", "middleware", @@ -13,29 +13,29 @@ "cookie" ], "devDependencies": { - "@arethetypeswrong/cli": "^0.17.1", + "@arethetypeswrong/cli": "^0.18.2", "@eggjs/bin": "7", "@eggjs/supertest": "8", - "@eggjs/tsconfig": "1", - "@types/crc": "^3.8.3", - "@types/koa": "^2.15.0", + "@eggjs/tsconfig": "3", + "@types/koa": "^3.0.0", "@types/mocha": "10", - "@types/node": "22", - "eslint": "8", + "@types/node": "24", + "eslint": "9", "eslint-config-egg": "14", - "koa": "2", + "koa": "3", "mm": "4", "rimraf": "6", "snap-shot-it": "^7.9.10", "tshy": "3", "tshy-after": "1", - "typescript": "5" + "typescript": "5", + "typescript-eslint": "^8.46.1" }, "license": "MIT", "dependencies": { - "crc": "^3.8.0", + "crc": "^4.3.2", "is-type-of": "^2.2.0", - "zod": "^3.24.1" + "zod": "^4.1.12" }, "engines": { "node": ">= 18.19.0" @@ -70,6 +70,9 @@ }, "./package.json": "./package.json" }, + "publishConfig": { + "registry": "https://code.tks.eu/api/v4/projects/173/packages/npm/" + }, "files": [ "dist", "src" diff --git a/src/context.ts b/src/context.ts index c31a751..cd9e85b 100644 --- a/src/context.ts +++ b/src/context.ts @@ -18,7 +18,9 @@ export class ContextSession { prevHash?: number; /** - * context session constructor + * Context session constructor. + * @param {any} ctx Koa context instance for the current request lifecycle + * @param {SessionOptions} opts Resolved session options applied to this context */ constructor(ctx: any, opts: SessionOptions) { this.ctx = ctx; @@ -28,8 +30,8 @@ export class ContextSession { } /** - * internal logic of `ctx.session` - * @return {Session} session object + * Internal logic of `ctx.session` getter. + * @return {Session|null} Session object when present, otherwise null */ get(): Session | null { // already retrieved @@ -43,8 +45,8 @@ export class ContextSession { } /** - * internal logic of `ctx.session=` - * @param {Object} val session object + * Internal logic of `ctx.session=` setter. + * @param {Record|null} val Session data object to set, or null to clear session */ set(val: Record | null) { if (val === null) { @@ -60,10 +62,9 @@ export class ContextSession { } /** - * init session from external store - * will be called in the front of session middleware - * + * Initialize session from external store; invoked at middleware entry. * @public + * @return {Promise} */ async initFromExternal() { @@ -101,8 +102,9 @@ export class ContextSession { } /** - * init session from cookie + * Initialize session from cookie. * @private + * @return {void} */ initFromCookie() { debug('init from cookie'); @@ -153,10 +155,11 @@ export class ContextSession { } /** - * verify session(expired or custom verification) - * @param {Object} sessionData session data - * @param {Object} [key] session externalKey(optional) + * Verify session payload (expiry or custom validation). + * @param {Record} sessionData Parsed session payload from store or cookie + * @param {string} [key] Optional external session key when using a store * @private + * @return {boolean} True if session is valid, false otherwise */ protected valid(sessionData: Record, key?: string) { const ctx = this.ctx; @@ -182,9 +185,11 @@ export class ContextSession { } /** - * @param {String} event event name - * @param {Object} data event data + * Emit session lifecycle events on the Koa app. + * @param {string} event Event name (e.g. "expired", "invalid", "missed") + * @param {unknown} data Arbitrary event payload * @private + * @return {void} */ emit(event: string, data: unknown) { setImmediate(() => { @@ -193,10 +198,10 @@ export class ContextSession { } /** - * create a new session and attach to ctx.sess - * - * @param {Object} [sessionData] session data - * @param {String} [externalKey] session external key + * Create a new session and attach to the context. + * @param {Record} [sessionData] Optional session data to initialize with + * @param {string} [externalKey] Optional external session key (for store-backed sessions) + * @return {void} */ protected create(sessionData?: Record, externalKey?: string) { debug('create session with data: %j, externalKey: %s', sessionData, externalKey); @@ -208,6 +213,10 @@ export class ContextSession { /** * Commit the session changes or removal. + * @param {{save?: boolean, regenerate?: boolean}} [root0] Options for commit behavior + * @param {boolean} [root0.save] Force save the session regardless of change detection + * @param {boolean} [root0.regenerate] Regenerate external key (store-backed) before saving + * @return {Promise} */ async commit({ save = false, regenerate = false } = {}) { const session = this.session; @@ -283,8 +292,9 @@ export class ContextSession { } /** - * remove session + * Remove the current session (cookie and external store if applicable). * @private + * @return {Promise} */ async remove() { // Override the default options so that we can properly expire the session cookies @@ -304,8 +314,10 @@ export class ContextSession { } /** - * save session + * Persist session to cookie or external store. + * @param {boolean} changed Whether the session content changed since last save * @private + * @return {Promise} */ async save(changed: boolean) { const opts = this.opts; diff --git a/src/index.ts b/src/index.ts index 74ec11a..ec77904 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,16 +75,22 @@ export const SessionOptions = z.object({ * get the external key * `(ctx) => string` */ - get: z.function() - .args(z.any()) - .returns(z.string()), + get: z.function( + { + input: z.tuple([ z.any() ]), + output: z.string(), + } + ), /** * set the external key * `(ctx, key) => void` */ - set: z.function() - .args(z.any(), z.string()) - .returns(z.void()), + set: z.function( + { + input: z.tuple([ z.any(), z.string() ]), + output: z.void(), + } + ), }).optional(), /** * session storage is dependent on your external store @@ -94,23 +100,32 @@ export const SessionOptions = z.object({ * get session data by key * `(key, maxAge, { rolling, ctx }) => sessionData | Promise` */ - get: z.function() - .args(z.string(), z.number(), z.object({ rolling: z.boolean(), ctx: z.any() })) - .returns(z.promise(z.any())), + get: z.function( + { + input: z.tuple([ z.string(), z.number(), z.object({ rolling: z.boolean(), ctx: z.any() }) ]), + output: z.promise(z.any()), + } + ), /** * set session data for key, with a `maxAge` (in ms) * `(key, sess, maxAge, { rolling, changed, ctx }) => void | Promise` */ - set: z.function() - .args(z.string(), z.any(), z.number(), z.object({ rolling: z.boolean(), changed: z.boolean(), ctx: z.any() })) - .returns(z.promise(z.void())), + set: z.function( + { + input: z.tuple([ z.string(), z.any(), z.number(), z.object({ rolling: z.boolean(), changed: z.boolean(), ctx: z.any() }) ]), + output: z.promise(z.void()), + } + ), /** * destroy session data for key * `(key, { ctx })=> void | Promise` */ - destroy: z.function() - .args(z.string(), z.object({ ctx: z.any() })) - .returns(z.promise(z.void())), + destroy: z.function( + { + input: z.tuple([ z.string(), z.object({ ctx: z.any() }) ]), + output: z.promise(z.void()), + } + ), }).optional(), /** * If your session store requires data or utilities from context, `opts.ContextStore` is also supported. @@ -118,23 +133,32 @@ export const SessionOptions = z.object({ * `new ContextStore(ctx)` will be executed on every request. */ ContextStore: z.any().optional(), - encode: z.function() - .args(z.any()) - .returns(z.string()) + encode: z.function( + { + input: z.tuple([ z.any() ]), + output: z.string(), + } + ) .optional() .default(() => util.encode), - decode: z.function() - .args(z.string()) - .returns(z.any()) + decode: z.function( + { + input: z.tuple([ z.string() ]), + output: z.any(), + } + ) .default(() => util.decode), /** * If you want to generate a new session id, you can use `genid` option to customize it. * Default is a function that uses `randomUUID()`. * `(ctx) => string` */ - genid: z.function() - .args(z.any()) - .returns(z.string()) + genid: z.function( + { + input: z.tuple([ z.any() ]), + output: z.string(), + } + ) .optional(), /** * If you want to prefix the session id, you can use `prefix` option to customize it. @@ -145,17 +169,23 @@ export const SessionOptions = z.object({ * valid session value before use it * `(ctx, sessionData) => boolean` */ - valid: z.function() - .args(z.any(), z.any()) - .returns(z.any()) + valid: z.function( + { + input: z.tuple([ z.any(), z.any() ]), + output: z.any(), + } + ) .optional(), /** * hook before save session * `(ctx, sessionModel) => void` */ - beforeSave: z.function() - .args(z.any(), z.any()) - .returns(z.void()) + beforeSave: z.function( + { + input: z.tuple([ z.any(), z.any() ]), + output: z.void(), + } + ) .optional(), }); @@ -167,13 +197,13 @@ export type CreateSessionOptions = Partial; type Middleware = (ctx: any, next: any) => Promise; /** - * Initialize session middleware with `opts`: + * Initialize session middleware. * * - `key` session cookie name ["koa.sess"] * - all other options are passed as cookie options * - * @param {Object} [opts] session options - * @param {Application} app koa application instance + * @param {CreateSessionOptions} [opts] Optional session options object + * @param {any} app Koa application instance whose context will be extended * @public */ export function createSession(opts: CreateSessionOptions, app: any): Middleware; @@ -228,7 +258,8 @@ export function createSession(opts: CreateSessionOptions | any, app: any): Middl export default createSession; /** - * format and check session options + * Format and validate session options. + * @param {SessionOptions} opts Mutable session options object to normalize */ function formatOptions(opts: SessionOptions) { // defaults @@ -271,10 +302,10 @@ function formatOptions(opts: SessionOptions) { } /** - * extend context prototype, add session properties + * Extend Koa context prototype with session helpers. * - * @param {Object} context koa's context prototype - * @param {Object} opts session options + * @param {object} context Koa context prototype to decorate + * @param {SessionOptions} opts Resolved session options bound to the context */ function extendContext(context: object, opts: SessionOptions) { if (context.hasOwnProperty(GET_CONTEXT_SESSION)) { diff --git a/src/util.ts b/src/util.ts index 39f0806..ac6daab 100644 --- a/src/util.ts +++ b/src/util.ts @@ -2,8 +2,10 @@ import crc from 'crc'; export default { /** - * Decode the base64 cookie value to an object + * Decode a base64-encoded cookie value into a JSON object. + * @param {string} base64String Base64-encoded string produced by {@link encode} * @private + * @return {Record} Parsed session data object */ decode(base64String: string): Record { const body = Buffer.from(base64String, 'base64').toString('utf8'); @@ -12,12 +14,19 @@ export default { }, /** - * Encode an object into a base64-encoded JSON string + * Encode an object into a base64-encoded JSON string. + * @param {Record} data Serializable session payload + * @return {string} Base64-encoded JSON string */ encode(data: Record) { return Buffer.from(JSON.stringify(data)).toString('base64'); }, + /** + * Compute CRC32 hash of a JSON-serialized payload. + * @param {Record} data Serializable payload to hash + * @return {number} CRC32 hash value + */ hash(data: Record) { return crc.crc32(JSON.stringify(data)); }, diff --git a/test/contextstore.test.ts b/test/contextstore.test.ts index e77f295..583862b 100644 --- a/test/contextstore.test.ts +++ b/test/contextstore.test.ts @@ -12,7 +12,7 @@ function App(options: CreateSessionOptions = {}) { const app = new Koa(); app.keys = [ 'a', 'b' ]; options.ContextStore = ContextStore; - options.genid = ctx => { + options.genid = (ctx: any) => { const sid = Date.now() + '_suffix'; ctx.state.sid = sid; return sid; @@ -607,10 +607,10 @@ describe('Koa Session External Context Store', () => { app.keys = [ 'a', 'b' ]; app.use(session({ - valid(ctx, sess) { + valid(ctx: any, sess: any) { return ctx.cookies.get('uid') === sess.uid; }, - beforeSave(ctx, sess) { + beforeSave(ctx: any, sess: any) { sess.uid = ctx.cookies.get('uid'); }, ContextStore, diff --git a/test/cookie.test.ts b/test/cookie.test.ts index add102e..bfeb608 100644 --- a/test/cookie.test.ts +++ b/test/cookie.test.ts @@ -762,10 +762,10 @@ describe('Koa Session Cookie', () => { app.keys = [ 'a', 'b' ]; app.use(session({ - valid(ctx, sess) { + valid(ctx: any, sess: any) { return ctx.cookies.get('uid') === sess.uid; }, - beforeSave(ctx, sess) { + beforeSave(ctx: any, sess: any) { sess.uid = ctx.cookies.get('uid'); }, }, app)); diff --git a/test/externalkey.test.ts b/test/externalkey.test.ts index ef69845..994112d 100644 --- a/test/externalkey.test.ts +++ b/test/externalkey.test.ts @@ -12,8 +12,8 @@ function App(options: CreateSessionOptions = {}) { app.keys = [ 'a', 'b' ]; options.store = store; options.externalKey = options.externalKey ?? { - get: ctx => ctx.get(TOKEN_KEY), - set: (ctx, value) => ctx.set(TOKEN_KEY, value), + get: (ctx: any) => ctx.get(TOKEN_KEY), + set: (ctx: any, value: any) => ctx.set(TOKEN_KEY, value), }; app.use(session(options, app)); return app; @@ -28,7 +28,7 @@ describe('Koa Session External Key', () => { }); }, err => { assert(err instanceof ZodError); - assert.match(err.message, /externalKey/); + assert.match(err.message, /expected function, received undefined/); return true; }); }); diff --git a/test/store.test.ts b/test/store.test.ts index 3f300ab..0e179fd 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -598,10 +598,10 @@ describe('Koa Session External Store', () => { app.keys = [ 'a', 'b' ]; app.use(session({ - valid(ctx, sess) { + valid(ctx: any, sess: any) { return ctx.cookies.get('uid') === sess.uid; }, - beforeSave(ctx, sess) { + beforeSave(ctx: any, sess: any) { sess.uid = ctx.cookies.get('uid'); }, store, diff --git a/tsconfig.json b/tsconfig.json index ff41b73..abc3222 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "noImplicitAny": true, "target": "ES2022", "module": "NodeNext", - "moduleResolution": "NodeNext" + "moduleResolution": "NodeNext", + "verbatimModuleSyntax": false } }