timer.ts 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. /** Browser implementation of the Cordis timer Service. */
  2. import { Service } from '@deepseek-ai/cordis'
  3. import type { Context } from '@deepseek-ai/cordis'
  4. /*
  5. * The browser Service preserves the vendored Host TimerService's erased callback tuples and arbitrary
  6. * async-iterator return and rejection values, so narrowing these positions would change the public API.
  7. */
  8. /* oxlint-disable typescript/no-explicit-any -- Exact Host TimerService API compatibility; see above. */
  9. /* oxlint-disable typescript/no-unsafe-argument -- The erased callback tuples pass through unchanged. */
  10. /* oxlint-disable typescript/no-unsafe-assignment -- The erased callback tuples pass through unchanged. */
  11. /* oxlint-disable typescript/no-unsafe-member-access -- The returned wrapper retains its dispose property. */
  12. /* oxlint-disable typescript/no-unsafe-return -- The erased generic return values pass through unchanged. */
  13. /* oxlint-disable typescript/prefer-promise-reject-errors -- Async iterators preserve arbitrary throw reasons. */
  14. declare module '@deepseek-ai/cordis' {
  15. interface Context extends Pick<ClientTimerService, 'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval'> {
  16. /** Browser timer Service used by the mixed-in Context helpers. */
  17. timer: ClientTimerService
  18. }
  19. }
  20. type WithDispose<T> = T & { dispose: () => void }
  21. // These `any` positions mirror the Host TimerService's overload erasure: generic callback tuples and async-iterator
  22. // return/rejection values must pass through without narrowing them to one caller's invocation.
  23. /** Browser timer Service with the same public API as the Host Cordis TimerService. */
  24. export class ClientTimerService extends Service {
  25. /** Register the Service and mix its lifecycle-safe helpers onto Context. */
  26. constructor(ctx: Context) {
  27. super(ctx, 'timer')
  28. ctx.mixin('timer', ['timeout', 'interval', 'throttle', 'debounce', 'setTimeout', 'setInterval'])
  29. }
  30. /**
  31. * Run a callback once through {@link timeout}.
  32. * @param callback - Work to run after the delay.
  33. * @param delay - Delay in milliseconds.
  34. * @returns Disposer that cancels the pending callback early.
  35. * @deprecated Use `ctx.timeout()` instead.
  36. */
  37. setTimeout(callback: () => void, delay: number): () => void {
  38. return this.timeout(callback, delay)
  39. }
  40. /**
  41. * Run a callback repeatedly through {@link interval}.
  42. * @param callback - Work to run on each tick.
  43. * @param delay - Interval in milliseconds.
  44. * @returns Disposer that stops the interval early.
  45. * @deprecated Use `ctx.interval()` instead.
  46. */
  47. setInterval(callback: () => void, delay: number): () => void {
  48. return this.interval(callback, delay)
  49. }
  50. /**
  51. * Run a callback once after a delay.
  52. * @param callback - work to run.
  53. * @param delay - delay in milliseconds.
  54. * @returns disposer that cancels the callback.
  55. */
  56. timeout(callback: () => void, delay: number): () => void
  57. /**
  58. * Wait for a delay.
  59. * @param delay - delay in milliseconds.
  60. * @returns promise resolved after the delay.
  61. */
  62. timeout(delay: number): Promise<void>
  63. timeout(...args: any[]): any {
  64. const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
  65. const delay = args[0] as number
  66. if (callback !== undefined) {
  67. const dispose = this.ctx.effect(() => {
  68. const timer = globalThis.setTimeout(() => {
  69. void dispose()
  70. callback()
  71. }, delay)
  72. return () => { globalThis.clearTimeout(timer) }
  73. }, 'ctx.timeout()')
  74. return dispose
  75. }
  76. const { promise, resolve, reject } = Promise.withResolvers<void>()
  77. const dispose = this.ctx.effect(() => {
  78. const timer = globalThis.setTimeout(resolve, delay)
  79. return () => {
  80. globalThis.clearTimeout(timer)
  81. reject(new Error('Context has been disposed'))
  82. }
  83. }, 'ctx.timeout()')
  84. return promise.finally(() => { void dispose() })
  85. }
  86. /**
  87. * Run a callback repeatedly.
  88. * @param callback - work to run on each tick.
  89. * @param delay - interval in milliseconds.
  90. * @returns disposer that stops the interval.
  91. */
  92. interval(callback: () => void, delay: number): () => void
  93. /**
  94. * Iterate over timer ticks.
  95. * @param delay - interval in milliseconds.
  96. * @returns async iterator of ticks.
  97. */
  98. interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>
  99. interval(...args: any[]): any {
  100. const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
  101. const delay = args[0] as number
  102. if (callback !== undefined) {
  103. return this.ctx.effect(() => {
  104. const timer = globalThis.setInterval(callback, delay)
  105. return () => { globalThis.clearInterval(timer) }
  106. }, 'ctx.interval()')
  107. }
  108. let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
  109. let nextTask: PromiseWithResolvers<IteratorResult<void>> | undefined
  110. const dispose = this.ctx.effect(() => {
  111. const timer = globalThis.setInterval(() => {
  112. nextTask?.resolve({ done: false, value: undefined })
  113. }, delay)
  114. return () => {
  115. globalThis.clearInterval(timer)
  116. if (done !== undefined) return
  117. done = { kind: 'throw', reason: new Error('Context has been disposed') }
  118. nextTask?.reject(done.reason)
  119. }
  120. }, 'ctx.interval()')
  121. return {
  122. next: () => {
  123. if (done === undefined) return (nextTask = Promise.withResolvers()).promise
  124. if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
  125. return Promise.reject(done.reason)
  126. },
  127. return: (value: any) => {
  128. if (done === undefined) done = { kind: 'return', value }
  129. nextTask?.resolve({ done: true, value })
  130. void dispose()
  131. return Promise.resolve({ done: true, value })
  132. },
  133. throw: (reason: any) => {
  134. if (done === undefined) done = { kind: 'throw', reason }
  135. nextTask?.reject(reason)
  136. void dispose()
  137. return Promise.resolve({ done: true, value: undefined })
  138. },
  139. [Symbol.asyncIterator]() {
  140. return this
  141. },
  142. } satisfies AsyncIterableIterator<void>
  143. }
  144. /** Build a delayed wrapper whose pending callback belongs to the calling Fiber. */
  145. private schedule(label: string, trigger: (args: any[], disposed: boolean) => number | undefined, disposed = false): any {
  146. let timer: number | undefined
  147. const dispose = this.ctx.effect(() => () => {
  148. disposed = true
  149. globalThis.clearTimeout(timer)
  150. }, label)
  151. const wrapper: any = (...args: any[]): void => {
  152. globalThis.clearTimeout(timer)
  153. timer = trigger(args, disposed)
  154. }
  155. wrapper.dispose = dispose
  156. return wrapper
  157. }
  158. /**
  159. * Return a throttled function whose timer is disposed with the calling Fiber.
  160. * @param callback - Function to throttle.
  161. * @param delay - Minimum interval between calls in milliseconds.
  162. * @param noTrailing - Whether to suppress a delayed trailing call.
  163. * @returns Throttled function with an early disposer.
  164. */
  165. throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
  166. let lastCall = -Infinity
  167. const execute = (...args: Parameters<F>): void => {
  168. lastCall = Date.now()
  169. callback(...args)
  170. }
  171. return this.schedule('ctx.throttle()', (args, disposed) => {
  172. const remaining = delay - Date.now() + lastCall
  173. if (remaining <= 0) {
  174. execute(...args as Parameters<F>)
  175. } else if (!disposed) {
  176. return globalThis.setTimeout(execute, remaining, ...args)
  177. }
  178. }, noTrailing)
  179. }
  180. /**
  181. * Return a debounced function whose timer is disposed with the calling Fiber.
  182. * @param callback - Function to debounce.
  183. * @param delay - Quiet period in milliseconds.
  184. * @returns Debounced function with an early disposer.
  185. */
  186. debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
  187. return this.schedule('ctx.debounce()', (args, disposed) => {
  188. if (disposed) return
  189. return globalThis.setTimeout(callback, delay, ...args)
  190. })
  191. }
  192. }
  193. /**
  194. * Install the browser timer Service on one Client composition.
  195. * @param ctx - Client context that owns the Service and mixed-in helpers.
  196. * @returns Nothing after registering the Service.
  197. */
  198. export function provideClientTimer(ctx: Context): void {
  199. new ClientTimerService(ctx)
  200. }