├── src ├── express │ ├── index.ts │ ├── stalier.middleware.ts │ └── stalier.middleware.spec.ts ├── common │ ├── types.ts │ ├── constants.ts │ └── utils.ts ├── index.ts ├── nestjs │ ├── stalier.constants.ts │ ├── index.ts │ ├── stalier.decorators.ts │ ├── stalier.interfaces.ts │ ├── stalier.interceptor.ts │ ├── stalier.module.ts │ └── stalier.interceptor.spec.ts ├── stalier.types.ts ├── utils.ts ├── stalier.ts └── stalier.spec.ts ├── docs └── logo.png ├── examples └── nestjs │ ├── tsconfig.build.json │ ├── nest-cli.json │ ├── test │ ├── jest-e2e.json │ └── app.e2e-spec.ts │ ├── src │ ├── main.ts │ ├── app.controller.ts │ ├── app.service.ts │ ├── app.module.ts │ └── app.controller.spec.ts │ ├── tsconfig.json │ ├── package.json │ └── README.md ├── renovate.json ├── .prettierrc ├── .eslintrc ├── tsconfig.build.json ├── tsconfig.json ├── .npmignore ├── jest.config.ts ├── .github ├── stale.yml └── workflows │ ├── ci.yml │ └── publish.yml ├── LICENSE ├── package.json ├── .gitignore └── README.md /src/express/index.ts: -------------------------------------------------------------------------------- 1 | export * from './stalier.middleware'; 2 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecyrbe/stalier/HEAD/docs/logo.png -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | 3 | export type KeyGenFn = (req: Request) => string; 4 | -------------------------------------------------------------------------------- /examples/nestjs/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { StalierOptions, StalierResult } from './stalier.types'; 2 | export * from './stalier'; 3 | export * from './express'; 4 | export * from './nestjs'; 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "semi": true, 4 | "bracketSpacing": true, 5 | "arrowParens": "avoid", 6 | "singleQuote": true, 7 | "trailingComma": "all" 8 | } 9 | -------------------------------------------------------------------------------- /src/common/constants.ts: -------------------------------------------------------------------------------- 1 | export const STALIER_HEADER_KEY = 'X-Stalier-Cache-Control'; 2 | export const MATCH_HEADER = /s-maxage=([0-9]+)(\s*,\s*(stale-while-revalidate=([0-9]+)))?/; 3 | -------------------------------------------------------------------------------- /src/nestjs/stalier.constants.ts: -------------------------------------------------------------------------------- 1 | export const STALIER_CACHE_KEY_GEN = 'STALIER_CACHE_KEY_GEN'; 2 | export const STALIER_CACHE_MANAGER = 'STALIER_CACHE_MANAGER'; 3 | export const STALIER_OPTIONS = 'STALIER_OPTIONS'; 4 | -------------------------------------------------------------------------------- /src/nestjs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './stalier.constants'; 2 | export * from './stalier.decorators'; 3 | export * from './stalier.interceptor'; 4 | export * from './stalier.interfaces'; 5 | export * from './stalier.module'; 6 | -------------------------------------------------------------------------------- /examples/nestjs/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/nestjs/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /examples/nestjs/src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | 4 | async function bootstrap() { 5 | const app = await NestFactory.create(AppModule); 6 | 7 | await app.listen(3000); 8 | } 9 | bootstrap(); 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "extends": [ 6 | "eslint:recommended", 7 | "plugin:@typescript-eslint/eslint-recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "prettier" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | import { KeyGenFn } from './types'; 2 | 3 | /** 4 | * default cache key generator 5 | */ 6 | export const defaultKeyGenerator = 7 | (name: string): KeyGenFn => 8 | req => { 9 | return `${name}-${req.method}-${req.originalUrl.replace(/[._~:/?#[\]@!$&'()*+,;=]/g, '')}`; 10 | }; 11 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "sourceMap": false 5 | }, 6 | "exclude": [ 7 | "node_modules", 8 | "lib", 9 | "examples", 10 | "**/*.config.ts", 11 | "**/*.spec.ts", 12 | "**/*.test.ts", 13 | "**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /examples/nestjs/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | import { UseStalierInterceptor } from 'stalier'; 4 | 5 | @UseStalierInterceptor() 6 | @Controller() 7 | export class AppController { 8 | constructor(private readonly appService: AppService) {} 9 | 10 | @Get('/hello') 11 | getHello() { 12 | return this.appService.getHello(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/nestjs/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | async waitfor(seconds: number) { 6 | return new Promise(resolve => { 7 | setTimeout(() => { 8 | resolve(); 9 | }, seconds * 1000); 10 | }); 11 | } 12 | 13 | async getHello() { 14 | await this.waitfor(5); 15 | 16 | return { 17 | hello: 'world', 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // ts config for es 2021 3 | "compilerOptions": { 4 | "target": "ES2019", 5 | "module": "CommonJS", 6 | "moduleResolution": "Node", 7 | "lib": ["ES2019"], 8 | "outDir": "./lib", 9 | "alwaysStrict": true, 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "sourceMap": true, 13 | "declaration": true, 14 | "experimentalDecorators": true 15 | }, 16 | "exclude": ["node_modules", "lib"] 17 | } 18 | -------------------------------------------------------------------------------- /examples/nestjs/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | import { StalierModule } from 'stalier'; 5 | 6 | @Module({ 7 | imports: [ 8 | StalierModule.forRoot({ 9 | appName: 'nestjs-example', 10 | isGlobal: true, 11 | cacheOptions: { 12 | store: 'memory', 13 | max: 1000, 14 | }, 15 | }), 16 | ], 17 | controllers: [AppController], 18 | providers: [AppService], 19 | }) 20 | export class AppModule {} 21 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | !/lib 3 | /node_modules 4 | /src 5 | /examples 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # ENV 39 | .env 40 | -------------------------------------------------------------------------------- /examples/nestjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from '@jest/types'; 2 | 3 | // Objet synchrone 4 | const config: Config.InitialOptions = { 5 | verbose: true, 6 | moduleFileExtensions: ['ts', 'js', 'json', 'node'], 7 | rootDir: './src', 8 | testRegex: '.(spec|test).tsx?$', 9 | transform: { 10 | '^.+\\.tsx?$': 'ts-jest', 11 | }, 12 | coveragePathIgnorePatterns: ['/node_modules/', '\\.module\\.ts'], 13 | coverageDirectory: './coverage', 14 | coverageThreshold: { 15 | global: { 16 | branches: 80, 17 | functions: 85, 18 | lines: 85, 19 | statements: 85, 20 | }, 21 | }, 22 | testEnvironment: 'node', 23 | }; 24 | export default config; 25 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | daysUntilStale: 30 2 | # Number of days of inactivity before a stale issue is closed 3 | daysUntilClose: 7 4 | # Issues with these labels will never be considered stale 5 | exemptLabels: 6 | - pinned 7 | - security 8 | # Label to use when marking an issue as stale 9 | staleLabel: wontfix 10 | # Comment to post when marking an issue as stale. Set to `false` to disable 11 | markComment: > 12 | This issue has been automatically marked as stale because it has not had 13 | recent activity. It will be closed if no further activity occurs. Thank you 14 | for your contributions. 15 | # Comment to post when closing a stale issue. Set to `false` to disable 16 | closeComment: false 17 | -------------------------------------------------------------------------------- /examples/nestjs/src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | import { AppService } from './app.service'; 4 | 5 | describe('AppController', () => { 6 | let appController: AppController; 7 | 8 | beforeEach(async () => { 9 | const app: TestingModule = await Test.createTestingModule({ 10 | controllers: [AppController], 11 | providers: [AppService], 12 | }).compile(); 13 | 14 | appController = app.get(AppController); 15 | }); 16 | 17 | describe('root', () => { 18 | it('should return "Hello World!"', () => { 19 | expect(appController.getHello()).toBe('Hello World!'); 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/stalier.types.ts: -------------------------------------------------------------------------------- 1 | type StalierCacheValue = { 2 | updatedCount: number; 3 | lastUpdated: number; 4 | value: unknown; 5 | }; 6 | 7 | interface StalierCacheClient { 8 | get(key: string): Promise; 9 | set(key: string, value: StalierCacheValue): Promise; 10 | } 11 | 12 | interface StalierWarningLogger { 13 | warn(message: string): void; 14 | } 15 | 16 | export type StalierOptions = { 17 | maxAge?: number; // in seconds 18 | staleWhileRevalidate?: number; // in seconds 19 | logger?: StalierWarningLogger; 20 | cacheKey: string | (() => string); 21 | cacheClient: StalierCacheClient; 22 | }; 23 | 24 | export type StalierResult = { 25 | data: T; 26 | status: 'HIT' | 'MISS' | 'STALE' | 'NO_CACHE'; 27 | }; 28 | -------------------------------------------------------------------------------- /examples/nestjs/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/nestjs/stalier.decorators.ts: -------------------------------------------------------------------------------- 1 | import { UseInterceptors, SetMetadata } from '@nestjs/common'; 2 | import { Request } from 'express'; 3 | import { defaultKeyGenerator } from '../common/utils'; 4 | import { STALIER_CACHE_KEY_GEN } from './stalier.constants'; 5 | import { StalierInterceptor } from './stalier.interceptor'; 6 | 7 | export const UseStalierInterceptor = () => UseInterceptors(StalierInterceptor); 8 | 9 | export const CacheKey = (key: string) => SetMetadata(STALIER_CACHE_KEY_GEN, () => key); 10 | export const CacheKeyGen = (cacheKeyFn: (req: Request) => string) => SetMetadata(STALIER_CACHE_KEY_GEN, cacheKeyFn); 11 | export const CacheKeyUser = (userCacheKeyFn: (req: Request) => string) => 12 | SetMetadata(STALIER_CACHE_KEY_GEN, (req: Request) => defaultKeyGenerator(userCacheKeyFn(req))(req)); 13 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { StalierOptions } from './stalier.types'; 2 | 3 | export function warn(logger: StalierOptions['logger'], err: unknown, key: string) { 4 | if (err instanceof Error) { 5 | logger?.warn(`Error updating cache for key ${key}: ${err.message}`); 6 | } else { 7 | logger?.warn(`Error updating cache for key ${key}`); 8 | } 9 | } 10 | 11 | export function isCacheFresh( 12 | cached: { updatedCount: number; lastUpdated: number; value: unknown }, 13 | maxAge: number, 14 | now: number, 15 | ) { 16 | return cached.lastUpdated + maxAge * 1000 > now; 17 | } 18 | 19 | export function isCacheStale( 20 | cached: { updatedCount: number; lastUpdated: number; value: unknown }, 21 | maxAge: number, 22 | staleWhileRevalidate: number, 23 | now: number, 24 | ) { 25 | return cached.lastUpdated + (maxAge + staleWhileRevalidate) * 1000 > now; 26 | } 27 | -------------------------------------------------------------------------------- /src/nestjs/stalier.interfaces.ts: -------------------------------------------------------------------------------- 1 | import { CacheStore, CacheStoreFactory } from '@nestjs/common'; 2 | import { KeyGenFn } from '../common/types'; 3 | 4 | export interface StalierCacheManagerOptions { 5 | /** 6 | * cache-manager store to use 7 | */ 8 | store: 'memory' | 'none' | CacheStore | CacheStoreFactory; 9 | /** 10 | * maximum number of items to store in the cache - only for memory cache 11 | */ 12 | max?: number; 13 | /** 14 | * time to live in seconds - if not set no ttl is set by default 15 | */ 16 | ttl?: number; 17 | } 18 | 19 | export interface StalierModuleOptions { 20 | /** 21 | * name of the app - used to generate cache key 22 | */ 23 | appName: string; 24 | /** 25 | * function to generate cache key from a request 26 | */ 27 | cacheKeyGen?: KeyGenFn; 28 | /** 29 | * options for cache-manager 30 | */ 31 | cacheOptions: StalierCacheManagerOptions | StalierCacheManagerOptions[]; 32 | /** 33 | * if true, stalier cache will be global and shared across all modules 34 | */ 35 | isGlobal?: boolean; 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 ecyrbe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | paths: 10 | - src/** 11 | - yarn.lock 12 | - package.json 13 | - tsconfig.json 14 | pull_request: 15 | branches: [main] 16 | paths: 17 | - src/** 18 | - yarn.lock 19 | - package.json 20 | - tsconfig.json 21 | 22 | jobs: 23 | build: 24 | name: Build 25 | runs-on: ubuntu-latest 26 | strategy: 27 | matrix: 28 | node-version: [14.x, 16.x, 18.x] 29 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | - name: Use Node.js ${{ matrix.node-version }} 34 | uses: actions/setup-node@v4 35 | with: 36 | node-version: ${{ matrix.node-version }} 37 | cache: 'yarn' 38 | - run: yarn install 39 | - run: yarn build 40 | - run: yarn test 41 | analyze: 42 | name: Analyze 43 | runs-on: ubuntu-latest 44 | permissions: 45 | actions: read 46 | contents: read 47 | security-events: write 48 | strategy: 49 | fail-fast: false 50 | matrix: 51 | language: ['javascript'] 52 | steps: 53 | - name: Checkout repository 54 | uses: actions/checkout@v4 55 | - name: Initialize CodeQL 56 | uses: github/codeql-action/init@v2 57 | with: 58 | languages: ${{ matrix.language }} 59 | - name: Perform CodeQL Analysis 60 | uses: github/codeql-action/analyze@v2 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stalier", 3 | "version": "1.0.4", 4 | "description": "Stalier", 5 | "homepage": "https://github.com/ecyrbe/stalier", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/ecyrbe/stalier.git" 9 | }, 10 | "main": "lib/index.js", 11 | "typings": "lib/index.d.ts", 12 | "files": [ 13 | "lib" 14 | ], 15 | "author": { 16 | "name": "ecyrbe", 17 | "email": "ecyrbe@gmail.com" 18 | }, 19 | "license": "MIT", 20 | "keywords": [ 21 | "stale-while-revalidate", 22 | "cache", 23 | "express", 24 | "nestjs" 25 | ], 26 | "scripts": { 27 | "prebuild": "rimraf lib", 28 | "build": "tsc -p tsconfig.build.json", 29 | "test": "jest --coverage" 30 | }, 31 | "peerDependencies": { 32 | "@nestjs/common": "9.x", 33 | "@nestjs/core": "9.x", 34 | "@nestjs/platform-express": "9.x", 35 | "cache-manager": "4.x", 36 | "express": "4.x", 37 | "rxjs": "7.x" 38 | }, 39 | "devDependencies": { 40 | "@nestjs/common": "9.4.3", 41 | "@nestjs/core": "9.4.3", 42 | "@nestjs/platform-express": "9.4.3", 43 | "@nestjs/testing": "9.4.3", 44 | "@types/cache-manager": "4.0.3", 45 | "@types/express": "4.17.17", 46 | "@types/jest": "29.5.5", 47 | "@types/node": "20.8.9", 48 | "@types/supertest": "2.0.12", 49 | "@typescript-eslint/eslint-plugin": "5.62.0", 50 | "@typescript-eslint/parser": "5.62.0", 51 | "cache-manager": "4.1.0", 52 | "eslint": "8.49.0", 53 | "eslint-config-prettier": "9.0.0", 54 | "express": "4.18.2", 55 | "jest": "29.7.0", 56 | "prettier": "3.0.3", 57 | "reflect-metadata": "0.1.13", 58 | "rxjs": "7.8.1", 59 | "supertest": "6.3.3", 60 | "ts-jest": "29.1.1", 61 | "ts-node": "10.9.1", 62 | "typescript": "5.2.2" 63 | }, 64 | "dependencies": {} 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # publish on npm when there is a new version tag 2 | 3 | name: publish 4 | on: 5 | push: 6 | tags: [v*] 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [16.x] 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v4 18 | with: 19 | registry-url: "https://registry.npmjs.org" 20 | node-version: ${{ matrix.node-version }} 21 | cache: "yarn" 22 | - name: install dependencies 23 | run: yarn install 24 | - name: build 25 | run: yarn build 26 | - name: run test 27 | run: yarn test 28 | - name: publish to npm 29 | run: yarn publish --access public 30 | env: 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 32 | - name: generate changelog 33 | uses: orhun/git-cliff-action@v2 34 | id: cliff 35 | with: 36 | args: --latest --strip footer 37 | env: 38 | OUTPUT: CHANGES.md 39 | - name: save changelog 40 | id: changelog 41 | shell: bash 42 | run: | 43 | changelog=$(cat ${{ steps.cliff.outputs.changelog }}) 44 | changelog="${changelog//'%'/'%25'}" 45 | changelog="${changelog//$'\n'/'%0A'}" 46 | changelog="${changelog//$'\r'/'%0D'}" 47 | echo "::set-output name=changelog::$changelog" 48 | - name: create release 49 | id: release 50 | uses: actions/create-release@v1 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | with: 54 | tag_name: ${{ github.ref }} 55 | release_name: Release ${{ github.ref }} 56 | body: ${{ steps.changelog.outputs.changelog }} 57 | draft: false 58 | prerelease: false 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | lib 85 | 86 | # Gatsby files 87 | .cache/ 88 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 89 | # https://nextjs.org/blog/next-9-1#public-directory-support 90 | # public 91 | 92 | # vuepress build output 93 | .vuepress/dist 94 | 95 | # Serverless directories 96 | .serverless/ 97 | 98 | # FuseBox cache 99 | .fusebox/ 100 | 101 | # DynamoDB Local files 102 | .dynamodb/ 103 | 104 | # TernJS port file 105 | .tern-port 106 | -------------------------------------------------------------------------------- /examples/nestjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "build": "nest build", 10 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 11 | "start": "nest start", 12 | "start:dev": "nest start --watch", 13 | "start:debug": "nest start --debug --watch", 14 | "start:prod": "node dist/main", 15 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 16 | "test": "jest", 17 | "test:watch": "jest --watch", 18 | "test:cov": "jest --coverage", 19 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 20 | "test:e2e": "jest --config ./test/jest-e2e.json" 21 | }, 22 | "dependencies": { 23 | "@nestjs/common": "^9.0.0", 24 | "@nestjs/core": "^9.0.0", 25 | "@nestjs/platform-express": "^9.0.0", 26 | "cache-manager": "4.1.0", 27 | "reflect-metadata": "^0.1.13", 28 | "rxjs": "^7.2.0", 29 | "stalier": "^1.0.4" 30 | }, 31 | "devDependencies": { 32 | "@nestjs/cli": "^9.0.0", 33 | "@nestjs/schematics": "^9.0.0", 34 | "@nestjs/testing": "^9.0.0", 35 | "@types/express": "^4.17.13", 36 | "@types/jest": "29.5.1", 37 | "@types/node": "18.16.12", 38 | "@types/supertest": "^2.0.11", 39 | "@typescript-eslint/eslint-plugin": "^5.0.0", 40 | "@typescript-eslint/parser": "^5.0.0", 41 | "eslint": "^8.0.1", 42 | "eslint-config-prettier": "^8.3.0", 43 | "eslint-plugin-prettier": "^4.0.0", 44 | "jest": "29.5.0", 45 | "prettier": "^2.3.2", 46 | "source-map-support": "^0.5.20", 47 | "supertest": "^6.1.3", 48 | "ts-jest": "29.1.0", 49 | "ts-loader": "^9.2.3", 50 | "ts-node": "^10.0.0", 51 | "tsconfig-paths": "4.2.0", 52 | "typescript": "^5.0.0" 53 | }, 54 | "jest": { 55 | "moduleFileExtensions": [ 56 | "js", 57 | "json", 58 | "ts" 59 | ], 60 | "rootDir": "src", 61 | "testRegex": ".*\\.spec\\.ts$", 62 | "transform": { 63 | "^.+\\.(t|j)s$": "ts-jest" 64 | }, 65 | "collectCoverageFrom": [ 66 | "**/*.(t|j)s" 67 | ], 68 | "coverageDirectory": "../coverage", 69 | "testEnvironment": "node" 70 | } 71 | } -------------------------------------------------------------------------------- /src/nestjs/stalier.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Inject, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { Request, Response } from 'express'; 4 | import { lastValueFrom, of } from 'rxjs'; 5 | import { Cache } from 'cache-manager'; 6 | import { withStaleWhileRevalidate } from '../stalier'; 7 | import { MATCH_HEADER, STALIER_HEADER_KEY } from '../common/constants'; 8 | import { STALIER_CACHE_KEY_GEN, STALIER_OPTIONS, STALIER_CACHE_MANAGER } from './stalier.constants'; 9 | import { defaultKeyGenerator } from '../common/utils'; 10 | import { KeyGenFn } from '../common/types'; 11 | import { StalierModuleOptions } from './stalier.interfaces'; 12 | 13 | @Injectable() 14 | export class StalierInterceptor implements NestInterceptor { 15 | constructor( 16 | @Inject(STALIER_CACHE_MANAGER) private readonly cacheManager: Cache, 17 | @Inject(STALIER_OPTIONS) private readonly options: StalierModuleOptions, 18 | @Inject('Reflector') private readonly reflector: Reflector, 19 | ) {} 20 | 21 | intercept(context: ExecutionContext, next: CallHandler) { 22 | const request = context.switchToHttp().getRequest(); 23 | const response = context.switchToHttp().getResponse(); 24 | if (!['GET', 'POST'].includes(request.method)) { 25 | return next.handle(); 26 | } 27 | const cacheControl = request.get(STALIER_HEADER_KEY); 28 | if (cacheControl) { 29 | const matched = cacheControl.match(MATCH_HEADER); 30 | if (matched) { 31 | const maxAge = parseInt(matched[1]); 32 | const staleWhileRevalidate = matched[4] ? parseInt(matched[4]) : 0; 33 | 34 | const keyGen = this.getKeyGen(context); 35 | const result = withStaleWhileRevalidate(() => lastValueFrom(next.handle()), { 36 | cacheKey: keyGen(request), 37 | cacheClient: this.cacheManager, 38 | maxAge, 39 | staleWhileRevalidate, 40 | }); 41 | return result.then(result => { 42 | response.set('X-Cache-Status', result.status); 43 | return of(result.data); 44 | }); 45 | } 46 | response.set('X-Cache-Status', 'NO_CACHE'); 47 | } 48 | return next.handle(); 49 | } 50 | 51 | protected getKeyGen(context: ExecutionContext): KeyGenFn { 52 | const keyGen = 53 | this.reflector.get(STALIER_CACHE_KEY_GEN, context.getHandler()) || this.options.cacheKeyGen; 54 | const appName = this.options.appName || `${context.getClass().name}-${context.getHandler().name}`; 55 | if (keyGen) { 56 | return req => `${appName}-${keyGen(req)}`; 57 | } 58 | return defaultKeyGenerator(appName); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/nestjs/stalier.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, DynamicModule, Provider, ConfigurableModuleAsyncOptions } from '@nestjs/common'; 2 | import { caching, multiCaching, StoreConfig } from 'cache-manager'; 3 | import { STALIER_CACHE_MANAGER, STALIER_OPTIONS } from './stalier.constants'; 4 | import { StalierModuleOptions } from './stalier.interfaces'; 5 | 6 | function createCacheProvider(): Provider { 7 | return { 8 | provide: STALIER_CACHE_MANAGER, 9 | useFactory: (options: StalierModuleOptions) => { 10 | const cacheOptions = options.cacheOptions; 11 | if (Array.isArray(cacheOptions)) { 12 | return multiCaching(cacheOptions.map(o => caching(o as StoreConfig))); 13 | } 14 | return caching(cacheOptions as StoreConfig); 15 | }, 16 | inject: [STALIER_OPTIONS], 17 | }; 18 | } 19 | 20 | function createSyncOptionsProvider(options: StalierModuleOptions): Provider { 21 | return { 22 | provide: STALIER_OPTIONS, 23 | useValue: options, 24 | }; 25 | } 26 | function createAsyncOptionsProvider(options: ConfigurableModuleAsyncOptions): Provider { 27 | const useFactory = options.useFactory; 28 | if (useFactory) { 29 | return { 30 | provide: STALIER_OPTIONS, 31 | useFactory, 32 | inject: options.inject || [], 33 | }; 34 | } 35 | throw new Error('Stalier useFactory is required when using async forRootAsync'); 36 | } 37 | 38 | @Module({}) 39 | export class StalierModule { 40 | /** 41 | * create a StalierModule syncronously 42 | * @param options - StalierModuleOptions 43 | * @returns - StalierModule 44 | * @example 45 | * ```ts 46 | * { 47 | * imports: [ 48 | * StalierModule.forRoot({ 49 | * appName: 'my-app', 50 | * cacheKeyGen: (key: string) => key, 51 | * cacheOptions: { 52 | * store: 'memory', 53 | * max: 1000, 54 | * ttl: 60, 55 | * }, 56 | * isGlobal: true, 57 | * }), 58 | * ], 59 | * } 60 | * ``` 61 | */ 62 | static forRoot(options: StalierModuleOptions): DynamicModule { 63 | const optionsProvider = createSyncOptionsProvider(options); 64 | const cacheProvider = createCacheProvider(); 65 | return { 66 | module: StalierModule, 67 | global: Boolean(options.isGlobal), 68 | providers: [optionsProvider, cacheProvider], 69 | exports: [optionsProvider, cacheProvider], 70 | }; 71 | } 72 | /** 73 | * create a StalierModule asyncronously 74 | * @param options - AsyncStalierModuleOptions 75 | * @returns - StalierModule 76 | * @example 77 | * ```ts 78 | * { 79 | * imports: [ 80 | * StalierModule.forRootAsync({ 81 | * useFactory: async (configService: ConfigService) => configService.getStalierOptions(), 82 | * inject: [ConfigService], 83 | * }), 84 | * ], 85 | * } 86 | * ``` 87 | */ 88 | static forRootAsync( 89 | options: Omit< 90 | ConfigurableModuleAsyncOptions, 91 | 'useExisting' | 'useClass' | 'provideInjectionTokensFrom' | 'imports' 92 | > & { isGlobal?: boolean }, 93 | ): DynamicModule { 94 | const optionsProvider = createAsyncOptionsProvider(options); 95 | const cacheProvider = createCacheProvider(); 96 | return { 97 | module: StalierModule, 98 | global: Boolean(options.isGlobal), 99 | providers: [optionsProvider, cacheProvider], 100 | exports: [optionsProvider, cacheProvider], 101 | }; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/express/stalier.middleware.ts: -------------------------------------------------------------------------------- 1 | import type { RequestHandler, Request } from 'express'; 2 | import type { OutgoingHttpHeaders } from 'http'; 3 | import { MATCH_HEADER, STALIER_HEADER_KEY } from '../common/constants'; 4 | import { defaultKeyGenerator } from '../common/utils'; 5 | import { withStaleWhileRevalidate } from '../stalier'; 6 | import { StalierOptions } from '../stalier.types'; 7 | 8 | export type StalierMiddlewareOptions = { 9 | /** 10 | * name of the upstream application 11 | */ 12 | appName: string; 13 | /** 14 | * client to use for caching 15 | */ 16 | cacheClient: StalierOptions['cacheClient']; 17 | /** 18 | * function to generate a cache key per request 19 | * Use a custom one to handle per user caching 20 | * @default `--` 21 | */ 22 | cacheKeyGen?: (req: Request) => string; 23 | /** 24 | * logger to use for logging 25 | * @default `console` 26 | */ 27 | logger?: StalierOptions['logger']; 28 | }; 29 | 30 | /** 31 | * middleware to cache responses 32 | * @param options - options for stalier 33 | */ 34 | export const stalier: (options: StalierMiddlewareOptions) => RequestHandler = options => (req, res, next) => { 35 | if (!['GET', 'POST'].includes(req.method)) { 36 | return next(); 37 | } 38 | const { cacheClient, cacheKeyGen = defaultKeyGenerator(options.appName), logger = console } = options; 39 | const cacheControl = req.get(STALIER_HEADER_KEY); 40 | if (cacheControl) { 41 | const matched = cacheControl.match(MATCH_HEADER); 42 | if (matched) { 43 | const maxAge = parseInt(matched[1]); 44 | const staleWhileRevalidate = matched[4] ? parseInt(matched[4]) : 0; 45 | const send = res.send.bind(res); 46 | const freshResult = new Promise<{ data: Buffer | string; statusCode: number; headers: OutgoingHttpHeaders }>( 47 | (resolve, reject) => { 48 | res.send = function (data) { 49 | if (this.statusCode >= 200 && this.statusCode <= 300) { 50 | resolve({ data, statusCode: this.statusCode, headers: this.getHeaders() }); 51 | } else { 52 | reject({ data, statusCode: this.statusCode, headers: this.getHeaders() }); 53 | } 54 | return this; 55 | }; 56 | }, 57 | ); 58 | const result = withStaleWhileRevalidate( 59 | () => { 60 | next(); 61 | return freshResult; 62 | }, 63 | { 64 | maxAge, 65 | staleWhileRevalidate, 66 | cacheKey: cacheKeyGen(req), 67 | cacheClient, 68 | logger, 69 | }, 70 | ); 71 | return result 72 | .then(({ data, status }) => { 73 | res.status(data.statusCode); 74 | if (data.headers) { 75 | res.set(data.headers); 76 | } 77 | res.set('X-Cache-Status', status); 78 | send(data.data); 79 | }) 80 | .catch(reason => { 81 | if (reason.data && reason.statusCode) { 82 | res.status(reason.statusCode); 83 | if (reason.headers) { 84 | res.set(reason.headers); 85 | } 86 | res.set('X-Cache-Status', 'NO_CACHE'); 87 | send(reason.data); 88 | } else { 89 | res.status(500); 90 | res.set('X-Cache-Status', 'NO_CACHE'); 91 | send(JSON.stringify({ error: 'unexpected error while processing cache' })); 92 | } 93 | }); 94 | } 95 | res.set('X-Cache-Status', 'NO_CACHE'); 96 | } 97 | next(); 98 | }; 99 | -------------------------------------------------------------------------------- /examples/nestjs/README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ yarn install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ yarn run start 40 | 41 | # watch mode 42 | $ yarn run start:dev 43 | 44 | # production mode 45 | $ yarn run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ yarn run test 53 | 54 | # e2e tests 55 | $ yarn run test:e2e 56 | 57 | # test coverage 58 | $ yarn run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /src/stalier.ts: -------------------------------------------------------------------------------- 1 | import type { StalierOptions, StalierResult } from './stalier.types'; 2 | import { isCacheFresh, isCacheStale, warn } from './utils'; 3 | 4 | /** 5 | * function to convert cache key to string 6 | * @param cacheKey - key or function that returns a key to use for cache 7 | * @returns string to use as cache key 8 | */ 9 | function asKeyString(cacheKey: string | (() => string)) { 10 | if (typeof cacheKey === 'string') { 11 | return cacheKey; 12 | } 13 | return cacheKey(); 14 | } 15 | 16 | /** 17 | * async function to set cache without blocking called 18 | * @param key - key to use for cache 19 | * @param updatedCount - number of times cache has been updated 20 | * @param value - value to cache 21 | * @param now - current time in milliseconds 22 | * @param options - options for stale while revalidate 23 | */ 24 | async function setCache(key: string, updatedCount: number, value: T, now: number, options: StalierOptions) { 25 | try { 26 | await options.cacheClient.set(key, { 27 | updatedCount, 28 | lastUpdated: now, 29 | value, 30 | }); 31 | } catch (err) { 32 | // failure to set to cache is not a critical error 33 | warn(options.logger, err, key); 34 | } 35 | } 36 | 37 | /** 38 | * function to revalidate cache in the background 39 | * @param fn - function to cache result from 40 | * @param count - number of times fn has been called 41 | * @param now - current time in milliseconds 42 | * @param options - options for stale while revalidate 43 | */ 44 | async function revalidateCache(fn: () => Promise, count: number, now: number, options: StalierOptions) { 45 | const { cacheKey, logger = console } = options; 46 | const key = asKeyString(cacheKey); 47 | try { 48 | const result = await fn(); 49 | setCache(key, count, result, now, options); 50 | } catch (err) { 51 | warn(logger, err, key); 52 | } 53 | } 54 | 55 | /** 56 | * stale while revalidate function 57 | * @example 58 | * const { data, status } = await withStaleWhileRevalidate(fn, { 59 | * maxAge: 1, 60 | * staleWhileRevalidate: 999, 61 | * cacheKey: 'cacheKey', 62 | * cacheProvider: redisClient, 63 | * }); 64 | * @param fn - function to cache result from 65 | * @param options - options for stale while revalidate 66 | * @returns result of fn either from cache or from fn 67 | * @throws error only if fn throws error 68 | */ 69 | export async function withStaleWhileRevalidate( 70 | fn: () => Promise, 71 | options: StalierOptions, 72 | ): Promise> { 73 | const { maxAge = 0, staleWhileRevalidate = 0, logger = console, cacheKey, cacheClient } = options; 74 | let updatedCount = 0; 75 | if (maxAge || staleWhileRevalidate) { 76 | const key = asKeyString(cacheKey); 77 | try { 78 | const cached = await cacheClient.get(key); 79 | if (cached) { 80 | const now = Date.now(); 81 | if (isCacheFresh(cached, maxAge, now)) { 82 | return { data: cached.value as T, status: 'HIT' }; 83 | } 84 | updatedCount = cached.updatedCount + 1; 85 | if (isCacheStale(cached, maxAge, staleWhileRevalidate, now)) { 86 | // revalidate asynchronously to avoid blocking the caller. 87 | revalidateCache(fn, updatedCount, now, options); 88 | return { data: cached.value as T, status: 'STALE' }; 89 | } 90 | } 91 | } catch (err) { 92 | // failure to get from cache is the same as a cache MISS 93 | warn(logger, err, key); 94 | } 95 | 96 | const result = await fn(); 97 | // set cache asynchronously to avoid blocking the caller 98 | setCache(key, updatedCount, result, Date.now(), options); 99 | return { data: result, status: 'MISS' }; 100 | } 101 | return { data: await fn(), status: 'NO_CACHE' }; 102 | } 103 | -------------------------------------------------------------------------------- /src/stalier.spec.ts: -------------------------------------------------------------------------------- 1 | import { withStaleWhileRevalidate } from './stalier'; 2 | 3 | // in memory cache provider 4 | const fakeCacheProvider = { 5 | get: jest.fn(), 6 | set: jest.fn(), 7 | }; 8 | 9 | describe('stalier', () => { 10 | beforeAll(() => { 11 | jest.useFakeTimers({ now: Date.now() }); 12 | }); 13 | afterAll(() => { 14 | jest.useRealTimers(); 15 | }); 16 | afterEach(() => { 17 | jest.clearAllMocks(); 18 | }); 19 | it('should exist', () => { 20 | expect(withStaleWhileRevalidate).toBeDefined(); 21 | }); 22 | it('should return result of fn with no maxAge and no staleWileRevalidate', async () => { 23 | const fn = async () => 'result'; 24 | const result = await withStaleWhileRevalidate(fn, { cacheKey: 'cacheKey', cacheClient: fakeCacheProvider }); 25 | expect(result).toStrictEqual({ data: 'result', status: 'NO_CACHE' }); 26 | }); 27 | it('should return result of fn with maxAge and no staleWileRevalidate and nothing is in cache', async () => { 28 | const fn = async () => 'result'; 29 | const result = await withStaleWhileRevalidate(fn, { 30 | maxAge: 1, 31 | cacheKey: 'cacheKey', 32 | cacheClient: fakeCacheProvider, 33 | }); 34 | expect(result).toStrictEqual({ data: 'result', status: 'MISS' }); 35 | }); 36 | it('should return result of fn with maxAge and staleWileRevalidate and nothing is in cache', async () => { 37 | const fn = async () => 'result'; 38 | const result = await withStaleWhileRevalidate(fn, { 39 | maxAge: 1, 40 | staleWhileRevalidate: 1, 41 | cacheKey: 'cacheKey', 42 | cacheClient: fakeCacheProvider, 43 | }); 44 | expect(result).toStrictEqual({ data: 'result', status: 'MISS' }); 45 | }); 46 | it('should return cachedvalue with maxAge and staleWileRevalidate and something is in cache', async () => { 47 | const fn = async () => 'result'; 48 | fakeCacheProvider.get.mockReturnValue({ 49 | updatedCount: 0, 50 | lastUpdated: Date.now() - 500, 51 | value: 'cachedValue', 52 | }); 53 | const result = await withStaleWhileRevalidate(fn, { 54 | maxAge: 1, 55 | staleWhileRevalidate: 1, 56 | cacheKey: 'cacheKey', 57 | cacheClient: fakeCacheProvider, 58 | }); 59 | expect(result).toStrictEqual({ data: 'cachedValue', status: 'HIT' }); 60 | expect(fakeCacheProvider.set).not.toHaveBeenCalled(); 61 | }); 62 | it('should return cachedvalue with function key and with maxAge and staleWileRevalidate and something is in cache', async () => { 63 | const fn = async () => 'result'; 64 | fakeCacheProvider.get.mockReturnValue({ 65 | updatedCount: 0, 66 | lastUpdated: Date.now() - 500, 67 | value: 'cachedValue', 68 | }); 69 | const result = await withStaleWhileRevalidate(fn, { 70 | maxAge: 1, 71 | staleWhileRevalidate: 1, 72 | cacheKey: () => 'cacheKey', 73 | cacheClient: fakeCacheProvider, 74 | }); 75 | expect(result).toStrictEqual({ data: 'cachedValue', status: 'HIT' }); 76 | expect(fakeCacheProvider.set).not.toHaveBeenCalled(); 77 | }); 78 | 79 | it('should return stale cachedvalue with maxAge and staleWileRevalidate and something is in cache', async () => { 80 | const fn = async () => 'result'; 81 | fakeCacheProvider.get.mockReturnValue({ 82 | updatedCount: 2, 83 | lastUpdated: Date.now() - 1001, 84 | value: 'cachedValue', 85 | }); 86 | const result = await withStaleWhileRevalidate(fn, { 87 | maxAge: 1, 88 | staleWhileRevalidate: 1, 89 | cacheKey: 'cacheKey', 90 | cacheClient: fakeCacheProvider, 91 | }); 92 | expect(result).toStrictEqual({ data: 'cachedValue', status: 'STALE' }); 93 | expect(fakeCacheProvider.set).toHaveBeenCalledTimes(1); 94 | expect(fakeCacheProvider.set).toHaveBeenCalledWith('cacheKey', { 95 | updatedCount: 3, 96 | lastUpdated: expect.anything(), 97 | value: 'result', 98 | }); 99 | }); 100 | 101 | it('should return fn with maxAge and staleWileRevalidate expired and something is in cache', async () => { 102 | const fn = async () => 'result'; 103 | fakeCacheProvider.get.mockReturnValue({ 104 | updatedCount: 1, 105 | lastUpdated: Date.now() - 2001, 106 | value: 'cachedValue', 107 | }); 108 | const result = await withStaleWhileRevalidate(fn, { 109 | maxAge: 1, 110 | staleWhileRevalidate: 1, 111 | cacheKey: 'cacheKey', 112 | cacheClient: fakeCacheProvider, 113 | }); 114 | expect(result).toStrictEqual({ data: 'result', status: 'MISS' }); 115 | expect(fakeCacheProvider.set).toHaveBeenCalledTimes(1); 116 | expect(fakeCacheProvider.set).toHaveBeenCalledWith('cacheKey', { 117 | updatedCount: 2, 118 | lastUpdated: expect.anything(), 119 | value: 'result', 120 | }); 121 | }); 122 | 123 | it('should return cachedValue if setting cache returns an error', async () => { 124 | const fn = async () => 'result'; 125 | fakeCacheProvider.get.mockReturnValue({ 126 | updatedCount: 1, 127 | lastUpdated: Date.now() - 1001, 128 | value: 'cachedValue', 129 | }); 130 | fakeCacheProvider.set.mockImplementation(() => { 131 | throw new Error('error'); 132 | }); 133 | const result = await withStaleWhileRevalidate(fn, { 134 | maxAge: 1, 135 | staleWhileRevalidate: 1, 136 | cacheKey: 'cacheKey', 137 | cacheClient: fakeCacheProvider, 138 | }); 139 | expect(result).toStrictEqual({ data: 'cachedValue', status: 'STALE' }); 140 | }); 141 | 142 | it('should return fn if cache returns an error', async () => { 143 | const fn = async () => 'result'; 144 | fakeCacheProvider.get.mockImplementation(() => { 145 | throw new Error('error'); 146 | }); 147 | fakeCacheProvider.set.mockImplementation(() => { 148 | throw new Error('error'); 149 | }); 150 | const result = await withStaleWhileRevalidate(fn, { 151 | maxAge: 1, 152 | staleWhileRevalidate: 1, 153 | cacheKey: 'cacheKey', 154 | cacheClient: fakeCacheProvider, 155 | }); 156 | expect(result).toStrictEqual({ data: 'result', status: 'MISS' }); 157 | }); 158 | it('should return fn if cache returns error not instance of Error', async () => { 159 | const fn = async () => 'result'; 160 | fakeCacheProvider.get.mockImplementation(() => { 161 | throw 'error'; 162 | }); 163 | fakeCacheProvider.set.mockImplementation(() => { 164 | throw 'error'; 165 | }); 166 | const result = await withStaleWhileRevalidate(fn, { 167 | maxAge: 1, 168 | staleWhileRevalidate: 1, 169 | cacheKey: 'cacheKey', 170 | cacheClient: fakeCacheProvider, 171 | }); 172 | expect(result).toStrictEqual({ data: 'result', status: 'MISS' }); 173 | }); 174 | 175 | it('should return cachedValue if setting cache returns an error not instance of Error', async () => { 176 | const fn = async () => 'result'; 177 | fakeCacheProvider.get.mockReturnValue({ 178 | updatedCount: 1, 179 | lastUpdated: Date.now() - 1001, 180 | value: 'cachedValue', 181 | }); 182 | fakeCacheProvider.set.mockImplementation(() => { 183 | throw 'error'; 184 | }); 185 | const result = await withStaleWhileRevalidate(fn, { 186 | maxAge: 1, 187 | staleWhileRevalidate: 1, 188 | cacheKey: 'cacheKey', 189 | cacheClient: fakeCacheProvider, 190 | }); 191 | expect(result).toStrictEqual({ data: 'cachedValue', status: 'STALE' }); 192 | }); 193 | }); 194 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Stalier logo 4 | 5 |

6 |

7 | Stalier is a stale-while-revalidate middleware for your backend 8 |

9 | 10 |

11 | 12 | langue typescript 13 | 14 | 15 | npm 16 | 17 | 18 | GitHub 19 | 20 | GitHub Workflow Status 21 |

22 | 23 | Stalier is cache strategy middleware controled by your frontend by using `x-stalier-cache-control` header. 24 | This means that instead of your backend sending `Cache-Control` header to your browser for your browser to cache the returned data, your frontend will send the header `X-Stalier-Cache-Control` to your backend for it to cache the returned data from your source of truth. 25 | 26 | - It is an advanced middleware with support for stale-while-revalidate strategy. 27 | - Stalier will act as a proxy that caches the response if it sees a `X-Stalier-Cache-Control` header. 28 | - Since it's embedded in your backend, it's much more efficient than using a separate proxy. 29 | - It implements part of RFC7234 and RFC5861 but on the backend. It does not use `cache-control` since the cache is controlled by the frontend. 30 | - If you want both your browser and backend to cache the responses, you can use `x-stalier-cache-control` for requests and `cache-control` for responses at the same time. 31 | 32 | **Table of contents** 33 | - [Install](#install) 34 | - [Usage : backend](#usage--backend) 35 | - [Express](#express) 36 | - [Stalier Options](#stalier-options) 37 | - [Handle per user caching](#handle-per-user-caching) 38 | - [NestJS](#nestjs) 39 | - [Create a Cache instance for StalierInterceptor](#create-a-cache-instance-for-stalierinterceptor) 40 | - [Options for StalierModule](#options-for-staliermodule) 41 | - [StalierInterceptor](#stalierinterceptor) 42 | - [Usage : frontend](#usage--frontend) 43 | - [Example](#example) 44 | 45 | ## Install 46 | 47 | ```bash 48 | npm install stalier 49 | ``` 50 | 51 | or 52 | 53 | ```bash 54 | yarn add stalier 55 | ``` 56 | ## Usage : backend 57 | 58 | ### Express 59 | 60 | Stalier do not provide a cache on it's own, nor does it provide a logger. But you provide them both in the constructor. 61 | 62 | ```js 63 | import express from 'express'; 64 | import cacheManager from 'cache-manager'; 65 | import redisStore from 'cache-manager-ioredis'; 66 | 67 | import { stalier } from 'stalier'; 68 | 69 | const redisCache = cacheManager.caching({ store: redisStore }); 70 | 71 | // Create a new express app 72 | const app = express(); 73 | // add stalier middleware 74 | app.use(stalier({ appName: 'test', cacheClient: redisCache })); 75 | ``` 76 | 77 | #### Stalier Options 78 | 79 | ```typescript 80 | type StalierMiddlewareOptions = { 81 | /** 82 | * name of the upstream application 83 | */ 84 | appName: string; 85 | /** 86 | * client to use for caching 87 | * should have an async `get` method and `set` method 88 | */ 89 | cacheClient: CacheClient; 90 | /** 91 | * function to generate a cache key per request 92 | * Use a custom one to handle per user caching 93 | * @default `--` 94 | */ 95 | cacheKeyGen?: (req: Request) => string; 96 | /** 97 | * logger to use for logging 98 | * should have a log, warn and error method that takes a message parameter 99 | * @default `console` 100 | */ 101 | logger?: Logger; 102 | }; 103 | ``` 104 | 105 | #### Handle per user caching 106 | 107 | ```js 108 | import express from 'express'; 109 | import cacheManager from 'cache-manager'; 110 | import redisStore from 'cache-manager-ioredis'; 111 | import jsonwebtoken from 'jsonwebtoken'; 112 | import { stalier } from 'stalier'; 113 | 114 | // your user middleware 115 | import { userMiddleware } from './userMiddleware'; 116 | 117 | const redisCache = cacheManager.caching({ store: redisStore }); 118 | 119 | const appName = 'test'; 120 | // cache key per user request that extracts the user email from your user middleware 121 | const cacheKeyGen = (req: Request) => { 122 | if(req.user) { 123 | return `${appName}-${req.method}-${req.path}-${req.user.id}`; 124 | } 125 | return `${appName}-${req.method}-${req.path}`; 126 | } 127 | 128 | app.use(userMiddleware); 129 | app.use(stalier({ 130 | appName, 131 | cacheClient: redisCache, 132 | cacheKeyGen, 133 | })); 134 | ``` 135 | 136 | ### NestJS 137 | 138 | Stalier uses the `NestJsInterceptor` to intercept the request and response, and uses the Cache Module to cache the response. 139 | 140 | #### Create a Cache instance for StalierInterceptor 141 | 142 | Stalier module allows you to instanciate a Cache with the `cache-manager` library. The cacheOptions can take a single option or an array of options to use multicache strategy from `cache-manager`. 143 | Stalier module as two ways of instanciating a cache: 144 | - `forRoot` - with options known at compile time 145 | - `forRootAsync` - with options known at runtime, usually loaded from a config service 146 | 147 | As the name suggests, your should only load Stalier module once in your application. 148 | 149 | ```typescript 150 | import { Controller, Get } from '@nestjs/common'; 151 | import { UseStalierInterceptor, StalierModule, UseCacheKeyGen } from 'stalier'; 152 | 153 | import { ConfigModule, ConfigService } from '@nestjs/config'; 154 | 155 | @Module({ 156 | imports: [ 157 | ConfigModule.forRoot({ isGlobal: true }), 158 | StalierModule.forRootAsync({ 159 | useFactory: (configService: ConfigService) => ({ 160 | appName: 'test', 161 | cacheOptions: configService.get('CACHE_OPTIONS'), 162 | isGlobal: true, 163 | }), 164 | inject: [ConfigService], 165 | }), 166 | ], 167 | controllers: [MyAppController], 168 | }) 169 | ``` 170 | 171 | #### Options for StalierModule 172 | 173 | ```typescript 174 | 175 | interface StalierModuleOptions { 176 | /** 177 | * name of the app - used to generate cache key 178 | */ 179 | appName: string; 180 | /** 181 | * function to generate cache key from a request 182 | */ 183 | cacheKeyGen?: KeyGenFn; 184 | /** 185 | * options for cache-manager 186 | */ 187 | cacheOptions: StalierCacheManagerOptions | StalierCacheManagerOptions[]; 188 | /** 189 | * if true, stalier cache will be global and shared across all modules 190 | */ 191 | isGlobal?: boolean; 192 | } 193 | interface StalierCacheManagerOptions { 194 | /** 195 | * cache-manager store to use 196 | */ 197 | store: 'memory' | 'none' | CacheStore | CacheStoreFactory; 198 | /** 199 | * maximum number of items to store in the cache - only for memory cache 200 | */ 201 | max?: number; 202 | /** 203 | * time to live in seconds - if not set no ttl is set by default 204 | */ 205 | ttl?: number; 206 | } 207 | ``` 208 | 209 | #### StalierInterceptor 210 | 211 | You can load StalierInterceptor Globally (see useGlobalInterceptors) or per Controller (see useInterceptors). 212 | 213 | ```typescript 214 | @UseStalierInterceptor() 215 | @Controller() 216 | class MyAppController { 217 | @Get('/default-key') 218 | getDefaultKey() { 219 | return { hello: 'world' }; 220 | } 221 | 222 | // per user caching 223 | @CacheKeyUser((req) => req.user.id) 224 | @Get('/custom-key') 225 | getCustomKey() { 226 | return { hello: 'world' }; 227 | } 228 | } 229 | ``` 230 | 231 | By default, stalier interceptor will use the request path as the cache key. 232 | To handle per user caching, you can use the `CacheKeyUser` decorator. You can apply it per controller or per method. 233 | To apply a static key, use the `CacheKey` decorator. Or to have fine grained control, you can use the `CacheKeyGen` decorator. 234 | ## Usage : frontend 235 | 236 | Stalier is using the `x-stalier-cache-control` header to control the cache behaviour within your frontend. 237 | it supports the following params: 238 | 239 | - s-maxage: time in seconds indicating the maximum time the response should be cached. A value of 0 means no caching. 240 | - stale-while-revalidate: time in seconds indicating the time the response should be cached while revalidating. A value of 0 means no window for revalidation and only use cached content. 241 | 242 | ### Example 243 | 244 | If you want a content to be cached for 10 seconds and have a revalidation window of 50 seconds, you can use the following headers: 245 | 246 | ```http 247 | GET /content HTTP/1.1 248 | x-stalier-cache-control: s-maxage=10, stale-while-revalidate=50 249 | ``` 250 | 251 | if it's not present, it will be cached for 10 seconds, and get back the fresh content with the following headers: 252 | 253 | ```http 254 | HTTP/1.1 200 OK 255 | x-cache-status: MISS 256 | ``` 257 | 258 | Requesting another time the same content within 10 seconds will return the cached content with the following headers: 259 | 260 | ```http 261 | HTTP/1.1 200 OK 262 | x-cache-status: HIT 263 | ``` 264 | 265 | Requesting another time the same content within 50 seconds will return the cached content and try to refresh the cache in the background and return following headers: 266 | 267 | ```http 268 | HTTP/1.1 200 OK 269 | x-cache-status: STALE 270 | ``` 271 | 272 | Another call will then return the refreshed content with the following headers: 273 | 274 | ```http 275 | HTTP/1.1 200 OK 276 | x-cache-status: HIT 277 | ``` 278 | -------------------------------------------------------------------------------- /src/express/stalier.middleware.spec.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import request from 'supertest'; 3 | import { stalier } from './stalier.middleware'; 4 | import { STALIER_HEADER_KEY } from '../common/constants'; 5 | 6 | const fakeCache = { 7 | get: jest.fn(), 8 | set: jest.fn(), 9 | }; 10 | 11 | const errorInterceptor: express.ErrorRequestHandler = (err, req, res, next) => { 12 | return res.status(500).json({ error: err.message }); 13 | }; 14 | 15 | describe('stalier-express', () => { 16 | // setup a server with stalier middleware 17 | let app: express.Express; 18 | 19 | beforeAll(() => { 20 | app = express(); 21 | app.use(stalier({ appName: 'test', cacheClient: fakeCache })); 22 | app.get('/string', async (req, res) => { 23 | await new Promise(resolve => setTimeout(resolve, Math.floor(Math.random() * 100))); 24 | res.send('hello'); 25 | }); 26 | app.get('/object', async (req, res) => { 27 | await new Promise(resolve => setTimeout(resolve, Math.floor(Math.random() * 100))); 28 | res.json({ hello: 'world' }); 29 | }); 30 | app.get('/error', async (req, res, next) => { 31 | next(new Error('backend error')); 32 | }); 33 | app.use(errorInterceptor); 34 | }); 35 | 36 | afterEach(() => { 37 | jest.clearAllMocks(); 38 | }); 39 | 40 | it('should exist', () => { 41 | expect(stalier).toBeDefined(); 42 | }); 43 | 44 | it('should return hello with no maxAge and no staleWileRevalidate', async () => { 45 | const result = await request(app).get('/string'); 46 | expect(result.statusCode).toBe(200); 47 | expect(result.headers['content-type']).toMatch('text/html'); 48 | expect(result.headers['x-cache-status']).toBeUndefined(); 49 | expect(result.text).toBe('hello'); 50 | }); 51 | it('should return object with no maxAge and no staleWileRevalidate', async () => { 52 | const result = await request(app).get('/object'); 53 | expect(result.statusCode).toBe(200); 54 | expect(result.headers['content-type']).toMatch('application/json'); 55 | expect(result.headers['x-cache-status']).toBeUndefined(); 56 | expect(result.body).toEqual({ hello: 'world' }); 57 | }); 58 | 59 | it('should return hello with maxAge=0 and no staleWileRevalidate', async () => { 60 | const result = await request(app).get('/string').set(STALIER_HEADER_KEY, 's-maxage=0'); 61 | expect(result.statusCode).toBe(200); 62 | expect(result.headers['content-type']).toMatch('text/html'); 63 | expect(result.headers['x-cache-status']).toEqual('NO_CACHE'); 64 | expect(result.text).toBe('hello'); 65 | }); 66 | 67 | it('should return object with maxAge=0 and no staleWileRevalidate', async () => { 68 | const result = await request(app).get('/object').set(STALIER_HEADER_KEY, 's-maxage=0'); 69 | expect(result.statusCode).toBe(200); 70 | expect(result.headers['content-type']).toMatch('application/json'); 71 | expect(result.headers['x-cache-status']).toEqual('NO_CACHE'); 72 | expect(result.body).toEqual({ hello: 'world' }); 73 | }); 74 | 75 | it('should return hello with maxAge=0 and staleWileRevalidate=0', async () => { 76 | const result = await request(app).get('/string').set(STALIER_HEADER_KEY, 's-maxage=0, stale-while-revalidate=0'); 77 | expect(result.statusCode).toBe(200); 78 | expect(result.headers['content-type']).toMatch('text/html'); 79 | expect(result.headers['x-cache-status']).toEqual('NO_CACHE'); 80 | expect(result.text).toBe('hello'); 81 | }); 82 | 83 | it('should return object with maxAge=0 and staleWileRevalidate=0', async () => { 84 | const result = await request(app).get('/object').set(STALIER_HEADER_KEY, 's-maxage=0, stale-while-revalidate=0'); 85 | expect(result.statusCode).toBe(200); 86 | expect(result.headers['content-type']).toMatch('application/json'); 87 | expect(result.headers['x-cache-status']).toEqual('NO_CACHE'); 88 | expect(result.body).toEqual({ hello: 'world' }); 89 | }); 90 | 91 | it('should not cache errors', async () => { 92 | fakeCache.get.mockReturnValue({ 93 | updatedCount: 0, 94 | lastUpdated: Date.now() - 2000, 95 | value: { 96 | data: 'cachedValue', 97 | statusCode: 200, 98 | headers: { 'content-type': 'text/html' }, 99 | }, 100 | }); 101 | const result = await request(app).get('/error').set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 102 | expect(result.statusCode).toBe(500); 103 | expect(result.headers['content-type']).toMatch('application/json'); 104 | expect(result.headers['x-cache-status']).toEqual('NO_CACHE'); 105 | expect(fakeCache.set).not.toHaveBeenCalled(); 106 | expect(result.body).toEqual({ error: 'backend error' }); 107 | }); 108 | 109 | it('should return cachedValue with maxAge=1', async () => { 110 | fakeCache.get.mockReturnValue({ 111 | updatedCount: 0, 112 | lastUpdated: Date.now() - 500, 113 | value: { 114 | data: 'cachedValue', 115 | statusCode: 200, 116 | headers: { 'content-type': 'text/html' }, 117 | }, 118 | }); 119 | 120 | const result = await request(app).get('/string').set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=0'); 121 | expect(result.statusCode).toBe(200); 122 | expect(result.headers['content-type']).toMatch('text/html'); 123 | expect(result.headers['x-cache-status']).toEqual('HIT'); 124 | expect(result.text).toBe('cachedValue'); 125 | }); 126 | 127 | it('should return cachedValue object with maxAge=1', async () => { 128 | let count = 0; 129 | fakeCache.get.mockImplementation(() => ({ 130 | updatedCount: count++, 131 | lastUpdated: Date.now() - 500, 132 | value: { 133 | data: JSON.stringify({ hello: 'cachedValue' }), 134 | statusCode: 200, 135 | headers: { 'content-type': 'application/json' }, 136 | }, 137 | })); 138 | 139 | const req = request(app); 140 | const promises = []; 141 | for (let i = 0; i < 10; i++) { 142 | promises.push(req.get('/object').set(STALIER_HEADER_KEY, `s-maxage=1, stale-while-revalidate=0`)); 143 | } 144 | const results = await Promise.all(promises); 145 | expect(results.length).toBe(10); 146 | expect(fakeCache.get().updatedCount).toBe(10); 147 | results.forEach(result => { 148 | expect(result.statusCode).toBe(200); 149 | expect(result.headers['content-type']).toMatch('application/json'); 150 | expect(result.headers['x-cache-status']).toEqual('HIT'); 151 | expect(result.body).toEqual({ hello: 'cachedValue' }); 152 | }); 153 | }); 154 | 155 | it('should return cachedValue object with maxAge=1 and stale-while-revalidate=1', async () => { 156 | let count = 0; 157 | fakeCache.get.mockImplementation(() => ({ 158 | updatedCount: count++, 159 | lastUpdated: Date.now() - 1001, 160 | value: { 161 | data: JSON.stringify({ hello: count }), 162 | statusCode: 200, 163 | headers: { 'content-type': 'application/json' }, 164 | }, 165 | })); 166 | 167 | const req = request(app); 168 | const promises = []; 169 | for (let i = 0; i < 10; i++) { 170 | promises.push(req.get('/object').set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1')); 171 | } 172 | const results = await Promise.all(promises); 173 | expect(results.length).toBe(10); 174 | expect(fakeCache.get().updatedCount).toBe(10); 175 | let sum = 0; 176 | results.forEach(result => { 177 | expect(result.statusCode).toBe(200); 178 | expect(result.headers['content-type']).toMatch('application/json'); 179 | expect(result.headers['x-cache-status']).toEqual('STALE'); 180 | sum += result.body.hello; 181 | }); 182 | expect(sum).toBe(55); 183 | }); 184 | 185 | it('should return hello with maxAge=1 and outdated cache', async () => { 186 | fakeCache.get.mockReturnValue({ 187 | updatedCount: 0, 188 | lastUpdated: Date.now() - 1001, 189 | value: { 190 | data: 'cachedValue', 191 | statusCode: 200, 192 | headers: { 'content-type': 'text/html' }, 193 | }, 194 | }); 195 | 196 | const result = await request(app).get('/string').set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=0'); 197 | expect(result.statusCode).toBe(200); 198 | expect(result.headers['content-type']).toMatch('text/html'); 199 | expect(result.headers['x-cache-status']).toEqual('MISS'); 200 | expect(result.text).toBe('hello'); 201 | }); 202 | 203 | it('should return staled cachedValue with maxAge=1 and stale-while-revalidate=1', async () => { 204 | fakeCache.get.mockReturnValue({ 205 | updatedCount: 0, 206 | lastUpdated: Date.now() - 1001, 207 | value: { 208 | data: 'cachedValue', 209 | statusCode: 200, 210 | headers: { 'content-type': 'text/html' }, 211 | }, 212 | }); 213 | 214 | const result = await request(app).get('/string').set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 215 | expect(result.statusCode).toBe(200); 216 | expect(result.headers['content-type']).toMatch('text/html'); 217 | expect(result.headers['x-cache-status']).toEqual('STALE'); 218 | expect(result.text).toBe('cachedValue'); 219 | }); 220 | 221 | it('should return staled cachedValue object with maxAge=1 and stale-while-revalidate=1', async () => { 222 | fakeCache.get.mockReturnValue({ 223 | updatedCount: 0, 224 | lastUpdated: Date.now() - 1001, 225 | value: { 226 | data: JSON.stringify({ hello: 'cachedValue' }), 227 | statusCode: 200, 228 | headers: { 'content-type': 'application/json' }, 229 | }, 230 | }); 231 | 232 | const result = await request(app).get('/object').set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 233 | expect(result.statusCode).toBe(200); 234 | expect(result.headers['content-type']).toMatch('application/json'); 235 | expect(result.headers['x-cache-status']).toEqual('STALE'); 236 | expect(result.body).toEqual({ hello: 'cachedValue' }); 237 | }); 238 | 239 | it('should return missed hello with maxAge=1 and stale-while-revalidate=1 with outdated cache', async () => { 240 | fakeCache.get.mockReturnValue({ 241 | updatedCount: 0, 242 | lastUpdated: Date.now() - 2001, 243 | value: { 244 | data: 'cachedValue', 245 | statusCode: 200, 246 | headers: { 'content-type': 'text/html' }, 247 | }, 248 | }); 249 | 250 | const result = await request(app).get('/string').set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 251 | expect(result.statusCode).toBe(200); 252 | expect(result.headers['content-type']).toMatch('text/html'); 253 | expect(result.headers['x-cache-status']).toEqual('MISS'); 254 | expect(result.text).toBe('hello'); 255 | }); 256 | 257 | it('should return missed cachedValue with maxAge=1 and stale-while-revalidate=1 with error cache', async () => { 258 | fakeCache.get.mockImplementation(() => { 259 | throw new Error('error get'); 260 | }); 261 | fakeCache.set.mockImplementation(() => { 262 | throw new Error('error set'); 263 | }); 264 | const result = await request(app).get('/string').set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 265 | expect(result.statusCode).toBe(200); 266 | expect(result.headers['content-type']).toMatch('text/html'); 267 | expect(result.headers['x-cache-status']).toEqual('MISS'); 268 | expect(result.text).toBe('hello'); 269 | }); 270 | 271 | it('should return missed cachedValue object with maxAge=1 and stale-while-revalidate=1 with error cache', async () => { 272 | fakeCache.get.mockImplementation(() => { 273 | throw new Error('error get'); 274 | }); 275 | fakeCache.set.mockImplementation(() => { 276 | throw new Error('error set'); 277 | }); 278 | const result = await request(app).get('/object').set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 279 | expect(result.statusCode).toBe(200); 280 | expect(result.headers['content-type']).toMatch('application/json'); 281 | expect(result.headers['x-cache-status']).toEqual('MISS'); 282 | expect(result.body).toEqual({ hello: 'world' }); 283 | }); 284 | }); 285 | -------------------------------------------------------------------------------- /src/nestjs/stalier.interceptor.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | Controller, 4 | ExecutionContext, 5 | Get, 6 | INestApplication, 7 | Injectable, 8 | UseGuards, 9 | } from '@nestjs/common'; 10 | import { Reflector } from '@nestjs/core'; 11 | import { Test } from '@nestjs/testing'; 12 | import { Cache } from 'cache-manager'; 13 | import request from 'supertest'; 14 | import { STALIER_HEADER_KEY } from '../common/constants'; 15 | import { STALIER_CACHE_MANAGER } from './stalier.constants'; 16 | import { UseStalierInterceptor, CacheKeyUser } from './stalier.decorators'; 17 | import { StalierModule } from './stalier.module'; 18 | 19 | @Injectable() 20 | export class UserGuard implements CanActivate { 21 | constructor(private reflector: Reflector) {} 22 | 23 | canActivate(context: ExecutionContext): boolean { 24 | const request = context.switchToHttp().getRequest(); 25 | request.user = { name: 'test' }; 26 | return true; 27 | } 28 | } 29 | 30 | // declare a fake controller to test the interceptor 31 | @UseStalierInterceptor() 32 | @Controller() 33 | class TestController { 34 | @Get('/string') 35 | getString() { 36 | return 'hello'; 37 | } 38 | 39 | @Get('/object') 40 | getObject() { 41 | return { hello: 'world' }; 42 | } 43 | 44 | @UseGuards(UserGuard) 45 | @CacheKeyUser((req: any) => req.user.name) 46 | @Get('/custom-key') 47 | getCustomKey() { 48 | return 'hello'; 49 | } 50 | 51 | @Get('/error') 52 | getError() { 53 | throw new Error('backend error'); 54 | } 55 | } 56 | 57 | const fakeCache = { 58 | get: jest.fn(), 59 | set: jest.fn(), 60 | del: jest.fn(), 61 | reset: jest.fn(), 62 | }; 63 | 64 | describe('StalierInterceptor', () => { 65 | let app: INestApplication; 66 | let controller: TestController; 67 | let cache: Cache; 68 | 69 | beforeAll(async () => { 70 | const module = await Test.createTestingModule({ 71 | imports: [ 72 | StalierModule.forRootAsync({ 73 | useFactory: () => ({ 74 | appName: 'test', 75 | cacheOptions: { 76 | store: 'memory', 77 | max: 10000, 78 | }, 79 | }), 80 | inject: [], 81 | }), 82 | ], 83 | controllers: [TestController], 84 | }) 85 | .overrideProvider(STALIER_CACHE_MANAGER) 86 | .useValue(fakeCache) 87 | .compile(); 88 | 89 | app = module.createNestApplication(); 90 | await app.init(); 91 | 92 | controller = module.get(TestController); 93 | cache = module.get(STALIER_CACHE_MANAGER); 94 | }); 95 | 96 | afterEach(() => { 97 | jest.clearAllMocks(); 98 | cache.reset(); 99 | }); 100 | 101 | afterAll(async () => { 102 | await app.close(); 103 | }); 104 | 105 | it('should be defined', () => { 106 | expect(controller).toBeDefined(); 107 | }); 108 | 109 | it('should return hello with no maxAge and no staleWileRevalidate', async () => { 110 | const response = await request(app.getHttpServer()).get('/string'); 111 | expect(response.statusCode).toBe(200); 112 | expect(response.text).toBe('hello'); 113 | expect(response.headers['content-type']).toMatch('text/html'); 114 | expect(response.headers['x-cache-status']).toBeUndefined(); 115 | expect(fakeCache.get).toHaveBeenCalledTimes(0); 116 | expect(fakeCache.set).toHaveBeenCalledTimes(0); 117 | }); 118 | 119 | it('should return object with no maxAge and no staleWileRevalidate', async () => { 120 | const response = await request(app.getHttpServer()).get('/object'); 121 | expect(response.statusCode).toBe(200); 122 | expect(response.body).toEqual({ hello: 'world' }); 123 | expect(response.headers['content-type']).toMatch('application/json'); 124 | expect(response.headers['x-cache-status']).toBeUndefined(); 125 | expect(fakeCache.get).toHaveBeenCalledTimes(0); 126 | expect(fakeCache.set).toHaveBeenCalledTimes(0); 127 | }); 128 | 129 | it('should return hello with maxAge=0 and no staleWileRevalidate', async () => { 130 | const result = await request(app.getHttpServer()).get('/string').set(STALIER_HEADER_KEY, 's-maxage=0'); 131 | expect(result.statusCode).toBe(200); 132 | expect(result.headers['content-type']).toMatch('text/html'); 133 | expect(result.headers['x-cache-status']).toEqual('NO_CACHE'); 134 | expect(result.text).toBe('hello'); 135 | }); 136 | 137 | it('should return object with maxAge=0 and no staleWileRevalidate', async () => { 138 | const result = await request(app.getHttpServer()).get('/object').set(STALIER_HEADER_KEY, 's-maxage=0'); 139 | expect(result.statusCode).toBe(200); 140 | expect(result.headers['content-type']).toMatch('application/json'); 141 | expect(result.headers['x-cache-status']).toEqual('NO_CACHE'); 142 | expect(result.body).toEqual({ hello: 'world' }); 143 | }); 144 | 145 | it('should return hello with maxAge=0 and staleWileRevalidate=0', async () => { 146 | const result = await request(app.getHttpServer()) 147 | .get('/string') 148 | .set(STALIER_HEADER_KEY, 's-maxage=0, stale-while-revalidate=0'); 149 | expect(result.statusCode).toBe(200); 150 | expect(result.headers['content-type']).toMatch('text/html'); 151 | expect(result.headers['x-cache-status']).toEqual('NO_CACHE'); 152 | expect(result.text).toBe('hello'); 153 | }); 154 | 155 | it('should return object with maxAge=0 and staleWileRevalidate=0', async () => { 156 | const result = await request(app.getHttpServer()) 157 | .get('/object') 158 | .set(STALIER_HEADER_KEY, 's-maxage=0, stale-while-revalidate=0'); 159 | expect(result.statusCode).toBe(200); 160 | expect(result.headers['content-type']).toMatch('application/json'); 161 | expect(result.headers['x-cache-status']).toEqual('NO_CACHE'); 162 | expect(result.body).toEqual({ hello: 'world' }); 163 | }); 164 | 165 | it('should not cache with invalid header', async () => { 166 | const result = await request(app.getHttpServer()) 167 | .get('/object') 168 | .set(STALIER_HEADER_KEY, 'maxage=1, stale-revalidate=1'); 169 | expect(result.statusCode).toBe(200); 170 | expect(result.headers['content-type']).toMatch('application/json'); 171 | expect(result.headers['x-cache-status']).toEqual('NO_CACHE'); 172 | expect(result.body).toEqual({ hello: 'world' }); 173 | expect(fakeCache.set).not.toHaveBeenCalled(); 174 | }); 175 | 176 | it('should not cache errors', async () => { 177 | fakeCache.get.mockReturnValue({ 178 | updatedCount: 0, 179 | lastUpdated: Date.now() - 2000, 180 | value: 'cachedValue', 181 | }); 182 | const result = await request(app.getHttpServer()) 183 | .get('/error') 184 | .set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 185 | expect(result.statusCode).toBe(500); 186 | expect(result.headers['content-type']).toMatch('application/json'); 187 | expect(result.headers['x-cache-status']).toBeUndefined(); 188 | expect(fakeCache.set).not.toHaveBeenCalled(); 189 | expect(result.body).toEqual({ message: 'Internal server error', statusCode: 500 }); 190 | }); 191 | 192 | it('should return cachedValue with maxAge=1', async () => { 193 | fakeCache.get.mockReturnValue({ 194 | updatedCount: 0, 195 | lastUpdated: Date.now() - 500, 196 | value: 'cachedValue', 197 | }); 198 | const result = await request(app.getHttpServer()) 199 | .get('/string') 200 | .set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=0'); 201 | expect(result.statusCode).toBe(200); 202 | expect(result.headers['content-type']).toMatch('text/html'); 203 | expect(result.headers['x-cache-status']).toEqual('HIT'); 204 | expect(result.text).toBe('cachedValue'); 205 | }); 206 | 207 | it('should return cachedValue object with maxAge=1', async () => { 208 | let count = 0; 209 | fakeCache.get.mockImplementation(() => ({ 210 | updatedCount: count++, 211 | lastUpdated: Date.now() - 500, 212 | value: { hello: 'cachedValue' }, 213 | })); 214 | 215 | const req = request(app.getHttpServer()); 216 | const promises = []; 217 | for (let i = 0; i < 10; i++) { 218 | promises.push(req.get('/object').set(STALIER_HEADER_KEY, `s-maxage=1, stale-while-revalidate=0`)); 219 | } 220 | const results = await Promise.all(promises); 221 | expect(results.length).toBe(10); 222 | expect(fakeCache.get().updatedCount).toBe(10); 223 | results.forEach(result => { 224 | expect(result.statusCode).toBe(200); 225 | expect(result.headers['content-type']).toMatch('application/json'); 226 | expect(result.headers['x-cache-status']).toEqual('HIT'); 227 | expect(result.body).toEqual({ hello: 'cachedValue' }); 228 | }); 229 | }); 230 | 231 | it('should return cachedValue object with maxAge=1 and stale-while-revalidate=1', async () => { 232 | let count = 0; 233 | fakeCache.get.mockImplementation(() => ({ 234 | updatedCount: count++, 235 | lastUpdated: Date.now() - 1001, 236 | value: { hello: count }, 237 | })); 238 | 239 | const req = request(app.getHttpServer()); 240 | const promises = []; 241 | for (let i = 0; i < 10; i++) { 242 | promises.push(req.get('/object').set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1')); 243 | } 244 | const results = await Promise.all(promises); 245 | expect(results.length).toBe(10); 246 | expect(fakeCache.get().updatedCount).toBe(10); 247 | let sum = 0; 248 | results.forEach(result => { 249 | expect(result.statusCode).toBe(200); 250 | expect(result.headers['content-type']).toMatch('application/json'); 251 | expect(result.headers['x-cache-status']).toEqual('STALE'); 252 | sum += result.body.hello; 253 | }); 254 | expect(sum).toBe(55); 255 | }); 256 | 257 | it('should return hello with maxAge=1 and outdated cache', async () => { 258 | fakeCache.get.mockReturnValue({ 259 | updatedCount: 0, 260 | lastUpdated: Date.now() - 1001, 261 | value: 'cachedValue', 262 | }); 263 | const promise = new Promise(resolve => { 264 | fakeCache.set.mockImplementation(async (...args) => { 265 | resolve(args); 266 | }); 267 | }); 268 | const result = await request(app.getHttpServer()) 269 | .get('/string') 270 | .set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=0'); 271 | expect(result.statusCode).toBe(200); 272 | expect(result.headers['content-type']).toMatch('text/html'); 273 | expect(result.headers['x-cache-status']).toEqual('MISS'); 274 | expect(result.text).toBe('hello'); 275 | const resultSet = await promise; 276 | expect(resultSet).toEqual([ 277 | 'test-GET-string', 278 | { 279 | lastUpdated: expect.anything(), 280 | updatedCount: 1, 281 | value: 'hello', 282 | }, 283 | ]); 284 | }); 285 | 286 | it('should return staled cachedValue with maxAge=1 and stale-while-revalidate=1', async () => { 287 | fakeCache.get.mockReturnValue({ 288 | updatedCount: 0, 289 | lastUpdated: Date.now() - 1001, 290 | value: 'cachedValue', 291 | }); 292 | const promise = new Promise(resolve => { 293 | fakeCache.set.mockImplementation(async (...args) => { 294 | resolve(args); 295 | }); 296 | }); 297 | 298 | const result = await request(app.getHttpServer()) 299 | .get('/string') 300 | .set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 301 | expect(result.statusCode).toBe(200); 302 | expect(result.headers['content-type']).toMatch('text/html'); 303 | expect(result.headers['x-cache-status']).toEqual('STALE'); 304 | expect(result.text).toBe('cachedValue'); 305 | const resultSet = await promise; 306 | expect(resultSet).toEqual([ 307 | 'test-GET-string', 308 | { 309 | lastUpdated: expect.anything(), 310 | updatedCount: 1, 311 | value: 'hello', 312 | }, 313 | ]); 314 | }); 315 | 316 | it('should return staled cachedValue with maxAge=1 and stale-while-revalidate=1 and custom key gen', async () => { 317 | fakeCache.get.mockReturnValue({ 318 | updatedCount: 0, 319 | lastUpdated: Date.now() - 1001, 320 | value: 'cachedValue', 321 | }); 322 | const promise = new Promise(resolve => { 323 | fakeCache.set.mockImplementation(async (...args) => { 324 | resolve(args); 325 | }); 326 | }); 327 | 328 | const result = await request(app.getHttpServer()) 329 | .get('/custom-key') 330 | .set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 331 | expect(result.statusCode).toBe(200); 332 | expect(result.headers['content-type']).toMatch('text/html'); 333 | expect(result.headers['x-cache-status']).toEqual('STALE'); 334 | expect(result.text).toBe('cachedValue'); 335 | const resultSet = await promise; 336 | expect(resultSet).toEqual([ 337 | 'test-test-GET-custom-key', 338 | { 339 | lastUpdated: expect.anything(), 340 | updatedCount: 1, 341 | value: 'hello', 342 | }, 343 | ]); 344 | }); 345 | 346 | it('should return staled cachedValue object with maxAge=1 and stale-while-revalidate=1', async () => { 347 | fakeCache.get.mockReturnValue({ 348 | updatedCount: 0, 349 | lastUpdated: Date.now() - 1001, 350 | value: { hello: 'cachedValue' }, 351 | }); 352 | const promise = new Promise(resolve => { 353 | fakeCache.set.mockImplementation(async (...args) => { 354 | resolve(args); 355 | }); 356 | }); 357 | 358 | const result = await request(app.getHttpServer()) 359 | .get('/object') 360 | .set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 361 | expect(result.statusCode).toBe(200); 362 | expect(result.headers['content-type']).toMatch('application/json'); 363 | expect(result.headers['x-cache-status']).toEqual('STALE'); 364 | expect(result.body).toEqual({ hello: 'cachedValue' }); 365 | const resultSet = await promise; 366 | expect(resultSet).toEqual([ 367 | 'test-GET-object', 368 | { 369 | lastUpdated: expect.anything(), 370 | updatedCount: 1, 371 | value: { hello: 'world' }, 372 | }, 373 | ]); 374 | }); 375 | 376 | it('should return missed hello with maxAge=1 and stale-while-revalidate=1 with outdated cache', async () => { 377 | fakeCache.get.mockReturnValue({ 378 | updatedCount: 0, 379 | lastUpdated: Date.now() - 2001, 380 | value: 'cachedValue', 381 | }); 382 | const promise = new Promise(resolve => { 383 | fakeCache.set.mockImplementation(async (...args) => { 384 | resolve(args); 385 | }); 386 | }); 387 | 388 | const result = await request(app.getHttpServer()) 389 | .get('/string') 390 | .set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 391 | expect(result.statusCode).toBe(200); 392 | expect(result.headers['content-type']).toMatch('text/html'); 393 | expect(result.headers['x-cache-status']).toEqual('MISS'); 394 | expect(result.text).toBe('hello'); 395 | const resultSet = await promise; 396 | expect(resultSet).toEqual([ 397 | 'test-GET-string', 398 | { 399 | lastUpdated: expect.anything(), 400 | updatedCount: 1, 401 | value: 'hello', 402 | }, 403 | ]); 404 | }); 405 | 406 | it('should return missed cachedValue with maxAge=1 and stale-while-revalidate=1 with error cache', async () => { 407 | fakeCache.get.mockImplementation(() => { 408 | throw new Error('error get'); 409 | }); 410 | fakeCache.set.mockImplementation(() => { 411 | throw new Error('error set'); 412 | }); 413 | const result = await request(app.getHttpServer()) 414 | .get('/string') 415 | .set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 416 | expect(result.statusCode).toBe(200); 417 | expect(result.headers['content-type']).toMatch('text/html'); 418 | expect(result.headers['x-cache-status']).toEqual('MISS'); 419 | expect(result.text).toBe('hello'); 420 | }); 421 | 422 | it('should return missed cachedValue object with maxAge=1 and stale-while-revalidate=1 with error cache', async () => { 423 | fakeCache.get.mockImplementation(() => { 424 | throw new Error('error get'); 425 | }); 426 | fakeCache.set.mockImplementation(() => { 427 | throw new Error('error set'); 428 | }); 429 | const result = await request(app.getHttpServer()) 430 | .get('/object') 431 | .set(STALIER_HEADER_KEY, 's-maxage=1, stale-while-revalidate=1'); 432 | expect(result.statusCode).toBe(200); 433 | expect(result.headers['content-type']).toMatch('application/json'); 434 | expect(result.headers['x-cache-status']).toEqual('MISS'); 435 | expect(result.body).toEqual({ hello: 'world' }); 436 | }); 437 | }); 438 | --------------------------------------------------------------------------------