index.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147
  1. import { Context, Service } from 'cordis'
  2. declare module 'cordis' {
  3. interface Context extends Pick<TimerService, 'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval'> {
  4. timer: TimerService
  5. }
  6. }
  7. type WithDispose<T> = T & { dispose: () => void }
  8. /** Disposable timer helpers mixed into Cordis contexts. */
  9. export class TimerService extends Service {
  10. constructor(ctx: Context) {
  11. super(ctx, 'timer')
  12. ctx.mixin('timer', ['timeout', 'interval', 'throttle', 'debounce', 'setTimeout', 'setInterval'])
  13. }
  14. /** @deprecated use `ctx.timeout()` instead */
  15. setTimeout(callback: () => void, delay: number) {
  16. return this.timeout(callback, delay)
  17. }
  18. /** @deprecated use `ctx.interval()` instead */
  19. setInterval(callback: () => void, delay: number) {
  20. return this.interval(callback, delay)
  21. }
  22. /** Run a callback once, or return a promise that resolves after `delay`. */
  23. timeout(callback: () => void, delay: number): () => void
  24. timeout(delay: number): Promise<void>
  25. timeout(...args: any[]): any {
  26. const callback = typeof args[0] === 'function' ? args.shift() : undefined
  27. const delay = args[0] as number
  28. if (callback) {
  29. const dispose = this.ctx.effect(() => {
  30. const timer = setTimeout(() => {
  31. dispose()
  32. callback()
  33. }, delay)
  34. return () => clearTimeout(timer)
  35. }, 'ctx.timeout()')
  36. return dispose
  37. } else {
  38. const { promise, resolve, reject } = Promise.withResolvers<void>()
  39. const dispose = this.ctx.effect(() => {
  40. const timer = setTimeout(resolve, delay)
  41. return () => {
  42. clearTimeout(timer)
  43. reject(new Error('Context has been disposed'))
  44. }
  45. }, 'ctx.timeout()')
  46. return promise.finally(dispose)
  47. }
  48. }
  49. /** Run a callback repeatedly, or return an async iterator of ticks. */
  50. interval(callback: () => void, delay: number): () => void
  51. interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>
  52. interval(...args: any[]): any {
  53. const callback = typeof args[0] === 'function' ? args.shift() : undefined
  54. const delay = args[0] as number
  55. if (callback) {
  56. return this.ctx.effect(() => {
  57. const timer = setInterval(callback, delay)
  58. return () => clearInterval(timer)
  59. }, 'ctx.interval()')
  60. } else {
  61. let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
  62. let nextTask: PromiseWithResolvers<IteratorResult<void>> | undefined
  63. const dispose = this.ctx.effect(() => {
  64. const timer = setInterval(() => {
  65. nextTask?.resolve({ done: false, value: undefined })
  66. }, delay)
  67. return () => {
  68. clearInterval(timer)
  69. if (done) return
  70. done = { kind: 'throw', reason: new Error('Context has been disposed') }
  71. nextTask?.reject(done.reason)
  72. }
  73. }, 'ctx.interval()')
  74. return {
  75. next: () => {
  76. if (!done) return (nextTask = Promise.withResolvers()).promise
  77. if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
  78. return Promise.reject(done.reason)
  79. },
  80. return: (value) => {
  81. if (!done) done = { kind: 'return', value }
  82. nextTask?.resolve({ done: true, value })
  83. dispose()
  84. return Promise.resolve({ done: true, value })
  85. },
  86. throw: (reason) => {
  87. if (!done) done = { kind: 'throw', reason }
  88. nextTask?.reject(reason)
  89. dispose()
  90. return Promise.resolve({ done: true, value: undefined })
  91. },
  92. [Symbol.asyncIterator]() {
  93. return this
  94. },
  95. } satisfies AsyncIterableIterator<void>
  96. }
  97. }
  98. private _schedule(label: string, trigger: (args: any[], isDisposed: boolean) => any, isDisposed = false) {
  99. let timer: number | NodeJS.Timeout | undefined
  100. const dispose = this.ctx.effect(() => () => {
  101. isDisposed = true
  102. clearTimeout(timer)
  103. }, label)
  104. const wrapper: any = (...args: any[]) => {
  105. clearTimeout(timer)
  106. timer = trigger(args, isDisposed)
  107. }
  108. wrapper.dispose = dispose
  109. return wrapper
  110. }
  111. /** Return a throttled function whose timer is disposed with the current fiber. */
  112. throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
  113. let lastCall = -Infinity
  114. const execute = (...args: any[]) => {
  115. lastCall = Date.now()
  116. callback(...args)
  117. }
  118. return this._schedule('ctx.throttle()', (args, isDisposed) => {
  119. const now = Date.now()
  120. const remaining = delay - now + lastCall
  121. if (remaining <= 0) {
  122. execute(...args)
  123. } else if (!isDisposed) {
  124. return setTimeout(execute, remaining, ...args)
  125. }
  126. }, noTrailing)
  127. }
  128. /** Return a debounced function whose timer is disposed with the current fiber. */
  129. debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
  130. return this._schedule('ctx.debounce()', (args, isDisposed) => {
  131. if (isDisposed) return
  132. return setTimeout(callback, delay, ...args)
  133. })
  134. }
  135. }
  136. export default TimerService