animir / node-rate-limiter-flexible

Atomic counters and rate limiting tools. Limit resource access at any scale.
ISC License
3.07k stars 162 forks source link

Typescript implementation example #149

Closed thiagoolsilva closed 2 years ago

thiagoolsilva commented 2 years ago

Hi, I'm trying to create a new class that extends the RateLimiterStoreAbstract, but after extending it I got the below error. What I am doing wrong? Could you give me a typescript sample to implement it or provide a RateLimiterS3?

Class extends value undefined is not a constructor or null

animir commented 2 years ago

@thiagoolsilva Hi, could you provide your code?

Also, could you describe, what would you like to achieve with the new RateLimiterS3 class?

thiagoolsilva commented 2 years ago

Hi @animir thanks for your reply. I'd like to create a new rateLimiter using the same idea of RateLimiterRedis saving data to AWS S3 bucket. Is it possible to do it?

animir commented 2 years ago

@thiagoolsilva This is interesting idea. How would you implement atomic upsert for incrementing counter there on S3?

Could you provide the code you already developed to get that error message?

thiagoolsilva commented 2 years ago

Hi @animir I don't have enough code implementation to share with you. I only created a random class and extended the class RateLimiterStoreAbstract to get the error reported. I've observed in the documentation that I must implement the following methods, but in a typescript context, it did not appear to me.

_getRateLimiterRes parses raw data from store to RateLimiterRes object. _upsert must be atomic. it inserts or updates value by key and returns raw data. it must support forceExpire mode to overwrite key expiration time. _get returns raw data by key or null if there is no key. _delete deletes all key related data and returns true on deleted, false if key is not found.

animir commented 2 years ago

@thiagoolsilva Hi, typescript definitions may be not complete enough.

You found the list of required methods. Do you have an idea how would you implement those for S3?

animir commented 2 years ago

@thiagoolsilva Hey, I am closing this, feel free to re-open, if you are going to work on this issue or you have any questions.

armstrong-pv commented 1 year ago

Hi, I'm trying to do something similar, i.e. extend RateLimiterStoreAbstract so that it can rate limit using Cloudflare durable objects. The code for my class is below.

export class CloudflareDurableObjectsRateLimiterStore extends RateLimiterStoreAbstract {
  protected _doClient: CloudflareDurableObjectsRateLimiterClient
  protected _getGraphQlOperation = /(?<=\s)(\w+)(?=\s|\()/g

  constructor(opts: IRateLimiterStoreOptions & { ctx: AppContext }) {
    super(opts)
    // We don't have the GraphQl schema info yet, as we are pre-parse,
    // so just extract either the query or mutation name directly from the context
    const match = this._getGraphQlOperation.exec(opts.ctx.params?.query ?? "")
    const key = CloudflareDurableObjectsRateLimiterStore.keyGenerator(null, null, null, opts.ctx, { fieldName: match?.[1] ?? "generic" })
    const id = opts.ctx.RATE_LIMITER.idFromName(key)
    this._doClient = opts.ctx.RATE_LIMITER.get(id) as unknown as CloudflareDurableObjectsRateLimiterClient
  }

  static keyGenerator(directiveArgs: any, source: any, args: any, context: any, info: any): string {
    let key = `site.com-${context.jwt?.sub ?? context.clientIp ?? 'noid'}-${info.fieldName}`
    console.log(key)
    return key
  }

  /**
   * Have to be implemented
   * Resolve with object used for {@link _getRateLimiterRes} to generate {@link RateLimiterRes}
   *
   * @param {string} rlKey
   * @param {number} points
   * @param {number} msDuration
   * @param {boolean} forceExpire
   * @param {Object} options
   * @abstract
   *
   * @return Promise<Object>
   */
  async _upsert(rlKey: any, points: any, msDuration: any, forceExpire = false, options = {}) {
    await this._doClient.upsert<RateLimitStorage>(rlKey, { points: points, expiry: nowUnixMs() + msDuration })
  }
 /**
   * Have to be implemented in every limiter
   * Resolve with raw result from Store OR null if rlKey is not set
   * or Reject with error
   *
   * @param rlKey
   * @param {Object} options
   * @private
   *
   * @return Promise<any>
   */
  async _get(rlKey: any, options = {}) { // eslint-disable-line no-unused-vars
    let result = await this._doClient.get<RateLimitStorage>(rlKey)
    if (!result) {
      return null
    }
    return result.points
  }

  /**
   * Have to be implemented
   * Resolve with true OR false if rlKey doesn't exist
   * or Reject with error
   *
   * @param rlKey
   * @param {Object} options
   * @private
   *
   * @return Promise<any>
   */
  async _delete(rlKey: any, options = {}) { // eslint-disable-line no-unused-vars
    return await this._doClient.delete(rlKey)
  }
}  

And within the context of using GraphQl, this is how I try to create the class in my initial testing, using the graphql-rate-limit-directive package. Note that storeClient is passed as an empty object, but this is OK as it's actually created in the constructor of CloudflareDurableObjectsRateLimiterStore.

    const { rateLimitDirectiveTransformer } = rateLimitDirective({
      keyGenerator: (directiveArgs, source, args, context, info) => {
        return CloudflareDurableObjectsRateLimiterStore.keyGenerator(directiveArgs, source, args, context as AppContext, info)
      },
      limiterClass: CloudflareDurableObjectsRateLimiterStore,
      limiterOptions: { storeClient: {}, ctx }
    })
    return rateLimitDirectiveTransformer(this._builder.toSchema({}))

At runtime, I simply get back: Uncaught TypeError: Class extends value undefined is not a constructor or null I know creating the CloudflareDurableObjectsRateLimiterStore is the issue, as if I replace it with the RateLimiterMemory class, all is good. It just doesn't seem to be able to find / import the RateLimiterStoreAbstract class at runtime. If I change my CloudflareDurableObjectsRateLimiterStore not to extend the RateLimiterStoreAbstract class, it starts working. Is there anything I should be looking at in terms of bundling / transpiling?

armstrong-pv commented 1 year ago

I also checked using dpdm that I don't have any circular references in my imports.

animir commented 1 year ago

@armstrong-pv Hi, do you import RateLimiterStoreAbstract class? In Redis limiter it is imported like const RateLimiterStoreAbstract = require('./RateLimiterStoreAbstract');

It is a little confusing as there is another definition of RateLimiterStoreAbstract in https://github.com/animir/node-rate-limiter-flexible/blob/683e37f414d7fe2ee0762de0ccec3119b027f140/lib/index.d.ts#L207 Wonder if TypeScript relies on definition and not on implementation.

armstrong-pv commented 1 year ago

Thanks for replying. I asked a much wiser head than me when it comes to the typescript ecosystem, and got it working with this approach. There are still some quirks but I can live with them. At compile time, it can't find any extended variables / methods from RateLimiterStoreAbstract, but I only needed to refer to this.points

import _RateLimiterStoreAbstract from "rate-limiter-flexible/lib/RateLimiterStoreAbstract"
const RateLimiterStoreAbstract = _RateLimiterStoreAbstract

export class CloudflareDurableObjectsRateLimiterStore extends RateLimiterStoreAbstract {
....
}

For anyone who is interested in the full rate limiter, here it is. Durable objects can be quite expensive, so it uses the Cloudflare caching API once the limit for a key has been reached. Therefore you only get charged for non-rate limited DO operations. Shout out to: @OUltimoCoder / https://github.com/OultimoCoder/cloudflare-planetscale-hono-boilerplate/blob/main/src/durable-objects/rateLimiter.do.ts for figuring out the hard stuff. It's working for GraphQl integration currently, but can be easily adapted,

import { TimeIntervalType, addInterval, nowUnixMs } from './date-time'
import { Environment } from '../../bindings'
import { AppContext } from '../types/context.types'
import { IRateLimiterStoreOptions, RateLimiterRes } from 'rate-limiter-flexible'
// @ts-ignore
import _RateLimiterStoreAbstract from "rate-limiter-flexible/lib/RateLimiterStoreAbstract"
const RateLimiterStoreAbstract = _RateLimiterStoreAbstract

const domainAddress = 'http://yourdomainname.com'

type RateLimitStorage = {
  points: number
  expiry: number
}

export class CloudflareDurableObjectsRateLimiterClient {
  constructor(private _state: DurableObjectState, private _env: Environment['Bindings']) {
  }

  async fetch(request: Request): Promise<Response> {
    // Always schedule a cleanup
    this.scheduleCleanup()
    const url = new URL(request.url)
    const keyName = url.searchParams.get('key')
    let response = null
    if (request.method === 'POST') {
      response = await this.upsert(
        keyName ?? 'generic', 
        await request.json(), 
        parseInt(url.searchParams.get('total') ?? '1000000'),
        url.searchParams.get('forceExpire') === 'true'
      )
    } else if (request.method === 'GET') {
      response = await this.get(keyName)
    } else if (request.method === 'DELETE') {
      await this.delete(keyName)
    } else {
      return new Response(null, { status: 400 })
    }

    return new Response(response ? JSON.stringify(response) : null, { status: 200 })
  }

  async upsert(key: string, value: RateLimitStorage, totalPoints: number, forceExpire = false) {
    let currentLimiter = await this.get(key)
    let newLimiter: RateLimitStorage & { msBeforeNext?: number }
    if (!currentLimiter || currentLimiter.expiry <= nowUnixMs() || forceExpire) {
      newLimiter = value
    } else {
      newLimiter = {
        points: currentLimiter.points + value.points,
        expiry: value.expiry
      }
    }
    if (newLimiter.points <= totalPoints) {
      await this._state.storage.put(key, newLimiter)
    } else {
      // If we've exceeded the total points, no point in incurring the write storage cost
      newLimiter.expiry = currentLimiter?.expiry ?? newLimiter.expiry
      newLimiter.msBeforeNext = newLimiter.expiry - nowUnixMs()
    }

    return newLimiter
  }

  async get(key: any): Promise<RateLimitStorage | null> {
    let result = await this._state.storage.get(key)
    if (!result) {
      return null
    }
    return result as RateLimitStorage
  }

  async list() {
    return await this._state.storage.list()
  }

  async delete(rlKey: any) {
    return await this._state.storage.delete(rlKey)
  }

  async setAlarm(interval: TimeIntervalType, duration: number) {
    const alarm = await this._state.storage.getAlarm()
    if (!alarm) {
      this._state.storage.setAlarm(addInterval(new Date(), interval, duration))
    }
  }

  async alarm() {
    await this.performCleanup()
  }

  async scheduleCleanup() {
    await this.setAlarm(TimeIntervalType.minute, 15)
  }

  async performCleanup() {
    const values = await this._state.storage.list<RateLimitStorage>()
    for await (const [key, value] of values) {
      if (value.expiry <= nowUnixMs()) {
        await this.delete(key)
      }
    }
    this.setAlarm(TimeIntervalType.minute, 15)
  }
}

export class CloudflareDurableObjectsRateLimiterStore extends RateLimiterStoreAbstract {
  protected _getGraphQlOperation = /(?<=\s)(\w+)(?=\s|\()/g
  protected _context: AppContext
  protected _execContext: ExecutionContext
  protected _graphQlOperation: string
  // @ts-ignore The points property is there, but not discovered correctly at compile time
  private _maxPointsForKey = this.points

  constructor(opts: IRateLimiterStoreOptions & { ctx: AppContext, execContext: ExecutionContext }) {
    super(opts)
    // We don't have the GraphQl schema info yet, as we are pre-parse,
    // so just extract either the query or mutation name directly from the context
    const match = this._getGraphQlOperation.exec(opts.ctx.params?.query ?? "")
    this._graphQlOperation = match?.[1] ?? 'no-op'
    this._context = opts.ctx
    this._execContext = opts.execContext
  }

  private getRateLimitClient(): DurableObjectStub {
    const key = CloudflareDurableObjectsRateLimiterStore.keyGenerator(null, null, null, this._context, { fieldName: this._graphQlOperation })
    const id = this._context.RATE_LIMITER.idFromName(key)
    return this._context.RATE_LIMITER.get(id)
  }

  static keyGenerator(directiveArgs: any, source: any, args: any, context: any, info: any): string {
    let key = `${domainAddress}/${context.jwt?.sub ?? context.clientIp ?? 'noid'}/${info.fieldName}`
    //console.log(key)
    return key
  }

  async _upsert(rlKey: any, points: any, msDuration: any, forceExpire = false, options = {}) {
    const cache = await caches.open('rate-limiter')
    // Seems to be added automatically by the rate limiter package and makes the key invalid
    // as a caching URL
    rlKey = rlKey.replace('rateLimit:', '')
    const cached = await cache.match(rlKey)
    // Always try the cache first. If we retrieve it, it avoids all of the DO
    // costs until the next rate limit interval
    if (cached) {
      let res = await cached.json<RateLimitStorage>()
      if (res.expiry > nowUnixMs()) {
        return { points: this._maxPointsForKey + 1, expiry: res.expiry, msBeforeNext: res.expiry - nowUnixMs() }
      } else {
        this._context
        // Only wait for cache deletion to complete after the client has received response
        this._execContext.waitUntil(cache.delete(rlKey))
      }
    }
    const client = this.getRateLimitClient()
    const result = await client.fetch(`${domainAddress}?key=${rlKey}&total=${this.points}&forceExpire=${forceExpire}`, { method: 'POST', body: JSON.stringify({ points: points, expiry: nowUnixMs() + msDuration }) });
    const rateLimitRecord = await result.json<RateLimitStorage>()
    if (rateLimitRecord.points >= this._maxPointsForKey && !cached) {
      const remainingSeconds = Math.floor((rateLimitRecord.expiry - nowUnixMs()) / 1000)
      this._execContext.waitUntil(
        // Only wait for cache put to complete after the client has received response
        cache.put(rlKey, new Response(JSON.stringify(rateLimitRecord), { 
          headers: { 
            'expires': `${new Date(rateLimitRecord.expiry).toUTCString()}`,
            'cache-control': `public, max-age=${remainingSeconds}, s-maxage=${remainingSeconds}, must-revalidate`
          }
        }))
      )
    }
    return rateLimitRecord
  }

  async _get(rlKey: any, options = {}) { // eslint-disable-line no-unused-vars
    const client = this.getRateLimitClient()
    let result = await client.fetch(`${domainAddress}?key=${rlKey}`, { method: 'GET' });
    if (!result) {
      return null
    }
    return (await result.json() as RateLimitStorage).points
  }

  async _delete(rlKey: any, options = {}) { // eslint-disable-line no-unused-vars
    const client = this.getRateLimitClient()
    const result = await client.fetch(`${domainAddress}?key=${rlKey}`, { method: 'DELETE' });
    return result.status === 200
  }

  _getRateLimiterRes(rlKey: string, changedPoints: number, result: any) { // eslint-disable-line no-unused-vars
    const consumedPoints = parseInt(result.points)
    const isFirstInDuration = result.points === changedPoints
    const remainingPoints = Math.max(this._maxPointsForKey - consumedPoints, 0)
    const msBeforeNext = result.msBeforeNext

    const res = new RateLimiterRes(remainingPoints, msBeforeNext, consumedPoints, isFirstInDuration)
    return res
  }
}