Merge pull request #6193 from deepseek-harness/chore/cordis-timer-1.1.4
chore(cordis): sync Timer 1.1.4 and reuse it in Client
This commit is contained in:
commit
23356d9c9c
23 files changed
+275
-257
No files matched your search
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.md
|
||||
/:
|
||||
en: a1e092dfacd2aed8
|
||||
zh: 838448e5bf0f9d93
|
||||
/timer-wrapper-return-types-and-disposal:
|
||||
en: 8b8de9b0fc2e3fc3
|
||||
zh: ffa20b7ffa7540ef
|
||||
/timer-wrapper-return-types-and-disposal/change:
|
||||
en: 3d607c2a17936a5a
|
||||
zh: 1c90b9bce38e4186
|
||||
/timer-wrapper-return-types-and-disposal/migration:
|
||||
en: 569f590747c713c5
|
||||
zh: f38f7af763209b94
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Timer wrappers return void in TypeScript and ignore calls after disposal."
|
||||
---
|
||||
|
||||
# Timer wrapper return types and disposal
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
The functions returned by `ctx.throttle()` and `ctx.debounce()` now declare a `void` return type. They previously inherited the callback's return type, although they already returned `undefined` at runtime. TypeScript code that uses the wrapper's return value or assigns it to the callback's type may need updating. Host and browser use the same `TimerService` implementation.
|
||||
|
||||
Calling `.dispose()` or disposing the owning fiber cancels pending work and makes later wrapper calls inert. A disposed throttle wrapper previously could still execute a leading call; debounce wrappers already ignored calls after disposal.
|
||||
|
||||
The `@deepseek-ai/dsh-cordis-client-runner/client` entry no longer exports `ClientTimerService`. The runner installs the shared `TimerService` from `@deepseek-ai/cordis-plugin-timer`.
|
||||
|
||||
## Migration
|
||||
|
||||
1. Let TypeScript infer the wrapper type, or declare its call signature with a `void` return type while retaining `.dispose()` where needed.
|
||||
2. Consume callback results inside the callback. Awaiting the wrapper does not wait for an asynchronous callback to finish.
|
||||
3. Move required work before disposal, or create a new wrapper in an active plugin.
|
||||
4. Remove imports of `ClientTimerService` from the runner. Consumer plugins declare `inject: ['timer']` and use `ctx.timer`; code that installs the service imports `TimerService` from `@deepseek-ai/cordis-plugin-timer`.
|
||||
5. Run your extension's type check and verify that calls after disposal neither invoke the callback nor schedule work.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Timer 包装函数的 TypeScript 返回类型为 void,释放后忽略后续调用。"
|
||||
---
|
||||
|
||||
# Timer 包装函数的返回类型与资源释放
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`ctx.throttle()` 和 `ctx.debounce()` 返回的函数现在声明 `void` 返回类型。此前这些函数继承回调的返回类型,但运行时已经返回 `undefined`。使用包装函数返回值或将包装函数赋给回调类型的 TypeScript 代码可能需要更新。Host 与浏览器使用同一份 `TimerService` 实现。
|
||||
|
||||
调用 `.dispose()` 或释放所属 fiber 会取消待执行的工作,并使后续包装函数调用不再执行。此前已释放的 throttle 包装函数仍可能执行前沿调用;debounce 包装函数在释放后已经会忽略调用。
|
||||
|
||||
`@deepseek-ai/dsh-cordis-client-runner/client` 入口不再导出 `ClientTimerService`。runner 安装的是 `@deepseek-ai/cordis-plugin-timer` 中的共享 `TimerService`。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 让 TypeScript 推断包装函数的类型,或将其调用签名声明为返回 `void`,并按需保留 `.dispose()`。
|
||||
2. 在回调内部使用回调结果。对包装函数使用 await 不会等待异步回调完成。
|
||||
3. 将必须执行的工作移到资源释放之前,或在活动插件中创建新的包装函数。
|
||||
4. 移除从 runner 导入 `ClientTimerService` 的代码。消费插件声明 `inject: ['timer']` 并使用 `ctx.timer`;负责安装服务的代码从 `@deepseek-ai/cordis-plugin-timer` 导入 `TimerService`。
|
||||
5. 运行扩展的类型检查,并验证释放后的调用既不会执行回调,也不会安排工作。
|
||||
@@ -74,7 +74,7 @@ export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|
|
||||
* identity to share — the framework itself is a requested module-table row
|
||||
* (external), while these are ordinary libraries a browser bundle inlines.
|
||||
*/
|
||||
const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/
|
||||
const VENDORED_LIBRARY = /^@deepseek-ai\/(?:(?:cosmokit|schemastery)(?:\/|$)|cordis-plugin-timer$)/
|
||||
|
||||
/** Generated descriptor/codec contribution with no shared runtime identity. */
|
||||
const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: df635b29eaef982c
|
||||
zh: ddaa4bd98359bd30
|
||||
/deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/source-map:
|
||||
en: cbd46fb5821cfcc7
|
||||
zh: 392e5cace2db6194
|
||||
en: 897f65050ac6c4eb
|
||||
zh: c856e88f15150b9c
|
||||
/deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/inspecting-slot-inputs:
|
||||
en: 33f14a7fff7835e6
|
||||
zh: 53838ac8d4a79600
|
||||
|
||||
@@ -55,6 +55,8 @@ The browser half is built on one principle: a dynamic package must ride the same
|
||||
|
||||
### Source map
|
||||
|
||||
The entry installs the vendored Cordis `TimerService`, shared with the Host, in the runner's context. The browser bundle inlines Timer and uses the module table's Cordis instance. Timer calls belong to the calling plugin's fiber; disposing it cancels pending work.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/client/index.ts`](src/client/index.ts) | Plugin entry: runner, orchestrator, inspect registry, forwarded-event subscription |
|
||||
@@ -64,7 +66,6 @@ The browser half is built on one principle: a dynamic package must ride the same
|
||||
| [`src/client/guard.ts`](src/client/guard.ts) | The whitelisting `ctx` façade for loaded browser halves |
|
||||
| [`src/client/inspect-registry.ts`](src/client/inspect-registry.ts) | Client Inspect Providers and the pending-query router |
|
||||
| [`src/client/providers.ts`](src/client/providers.ts) | First-party client Inspect Providers (slots, theme, events) |
|
||||
| [`src/client/timer.ts`](src/client/timer.ts) | The client timer service dynamic packages inject |
|
||||
|
||||
### Inspecting slot inputs
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ kind: "package-reference"
|
||||
|
||||
### 源码地图
|
||||
|
||||
入口在 runner 的上下文中安装与 Host 共用的 vendored Cordis `TimerService`。浏览器包内联 Timer,并使用模块表中的 Cordis 实例。定时任务归调用插件的 fiber 所有,释放该 fiber 会取消尚未执行的任务。
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/client/index.ts`](src/client/index.ts) | 插件入口:runner、编排器、inspect 注册表、转发事件订阅 |
|
||||
@@ -64,7 +66,6 @@ kind: "package-reference"
|
||||
| [`src/client/guard.ts`](src/client/guard.ts) | 已装载浏览器半收到的白名单 `ctx` façade |
|
||||
| [`src/client/inspect-registry.ts`](src/client/inspect-registry.ts) | Client Inspect Provider 与待答查询路由器 |
|
||||
| [`src/client/providers.ts`](src/client/providers.ts) | 第一方 client Inspect Provider(slots、theme、events) |
|
||||
| [`src/client/timer.ts`](src/client/timer.ts) | 动态包注入的 client 定时器服务 |
|
||||
|
||||
### 查询槽位输入
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
|
||||
"@deepseek-ai/cordis-plugin-timer": "workspace:~",
|
||||
"@deepseek-ai/dsh-api-remotes": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-modules": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:*",
|
||||
|
||||
@@ -319,12 +319,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
|
||||
signature: 'throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters<F>) => void) & { dispose: () => void }',
|
||||
description: 'Return a throttled function whose timer is disposed with the current fiber.',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
|
||||
signature: 'debounce<F extends (...args: any[]) => any>(callback: F, delay: number): ((...args: Parameters<F>) => void) & { dispose: () => void }',
|
||||
description: 'Return a debounced function whose timer is disposed with the current fiber.',
|
||||
parameters: [],
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { TimerService } from '@deepseek-ai/cordis-plugin-timer'
|
||||
import type {
|
||||
ApprovalRequestId, CordisDynamicPluginId, DynamicCordisInvokeResult,
|
||||
DynamicCordisInventoryRow,
|
||||
@@ -26,7 +27,6 @@ import { DynamicCordisPackageRunner } from './runtime.ts'
|
||||
import { CordisRunOrchestrator } from './orchestrator.ts'
|
||||
import { ClientCordisInspectRegistry, provideClientCordisInspect } from './inspect-registry.ts'
|
||||
import { clientInspectProviders } from './providers.ts'
|
||||
import { provideClientTimer } from './timer.ts'
|
||||
import type { CordisRunActivity, CordisRunFailure, CordisUserRunRequest } from './orchestrator.ts'
|
||||
import type { CordisObservable, DynamicCordisRenderFailure } from './runtime.ts'
|
||||
|
||||
@@ -49,7 +49,6 @@ export { DynamicCordisStyles, evaluateClientHalf, isDynamicCordisPlugin } from '
|
||||
export type { DynamicCordisClosureEnv, DynamicCordisEvaluatedPlugin } from './evaluator.ts'
|
||||
export { dynamicCordisContext } from './guard.ts'
|
||||
export type { DynamicCordisGuardEnv, DynamicCordisSlotLedgerRow } from './guard.ts'
|
||||
export { ClientTimerService } from './timer.ts'
|
||||
// Re-exported so consumers of the service face and the two events can name
|
||||
// their subjects without reaching into the wire contract themselves.
|
||||
export type {
|
||||
@@ -186,7 +185,7 @@ export const inject = ['loader', 'modules', 'slots', 'remote', 'remote.dynamicCo
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
provideClientTimer(ctx)
|
||||
new TimerService(ctx)
|
||||
const inspect = new ClientCordisInspectRegistry({
|
||||
sync: async (providers) => {
|
||||
const answered = await ctx.remote.dynamicCordisRunner.syncInspectManifest(providers)
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
/** Browser implementation of the Cordis timer Service. */
|
||||
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
/*
|
||||
* The browser Service preserves the vendored Host TimerService's erased callback tuples and arbitrary
|
||||
* async-iterator return and rejection values, so narrowing these positions would change the public API.
|
||||
*/
|
||||
/* oxlint-disable typescript/no-explicit-any -- Exact Host TimerService API compatibility; see above. */
|
||||
/* oxlint-disable typescript/no-unsafe-argument -- The erased callback tuples pass through unchanged. */
|
||||
/* oxlint-disable typescript/no-unsafe-assignment -- The erased callback tuples pass through unchanged. */
|
||||
/* oxlint-disable typescript/no-unsafe-member-access -- The returned wrapper retains its dispose property. */
|
||||
/* oxlint-disable typescript/no-unsafe-return -- The erased generic return values pass through unchanged. */
|
||||
/* oxlint-disable typescript/prefer-promise-reject-errors -- Async iterators preserve arbitrary throw reasons. */
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context extends Pick<ClientTimerService, 'interval' | 'timeout' | 'throttle' | 'debounce' | 'setTimeout' | 'setInterval'> {
|
||||
/** Browser timer Service used by the mixed-in Context helpers. */
|
||||
timer: ClientTimerService
|
||||
}
|
||||
}
|
||||
|
||||
type WithDispose<T> = T & { dispose: () => void }
|
||||
|
||||
// These `any` positions mirror the Host TimerService's overload erasure: generic callback tuples and async-iterator
|
||||
// return/rejection values must pass through without narrowing them to one caller's invocation.
|
||||
|
||||
/** Browser timer Service with the same public API as the Host Cordis TimerService. */
|
||||
export class ClientTimerService extends Service {
|
||||
/** Register the Service and mix its lifecycle-safe helpers onto Context. */
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'timer')
|
||||
ctx.mixin('timer', ['timeout', 'interval', 'throttle', 'debounce', 'setTimeout', 'setInterval'])
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback once through {@link timeout}.
|
||||
* @param callback - Work to run after the delay.
|
||||
* @param delay - Delay in milliseconds.
|
||||
* @returns Disposer that cancels the pending callback early.
|
||||
* @deprecated Use `ctx.timeout()` instead.
|
||||
*/
|
||||
setTimeout(callback: () => void, delay: number): () => void {
|
||||
return this.timeout(callback, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback repeatedly through {@link interval}.
|
||||
* @param callback - Work to run on each tick.
|
||||
* @param delay - Interval in milliseconds.
|
||||
* @returns Disposer that stops the interval early.
|
||||
* @deprecated Use `ctx.interval()` instead.
|
||||
*/
|
||||
setInterval(callback: () => void, delay: number): () => void {
|
||||
return this.interval(callback, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback once after a delay.
|
||||
* @param callback - work to run.
|
||||
* @param delay - delay in milliseconds.
|
||||
* @returns disposer that cancels the callback.
|
||||
*/
|
||||
timeout(callback: () => void, delay: number): () => void
|
||||
/**
|
||||
* Wait for a delay.
|
||||
* @param delay - delay in milliseconds.
|
||||
* @returns promise resolved after the delay.
|
||||
*/
|
||||
timeout(delay: number): Promise<void>
|
||||
timeout(...args: any[]): any {
|
||||
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
|
||||
const delay = args[0] as number
|
||||
if (callback !== undefined) {
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
void dispose()
|
||||
callback()
|
||||
}, delay)
|
||||
return () => { globalThis.clearTimeout(timer) }
|
||||
}, 'ctx.timeout()')
|
||||
return dispose
|
||||
}
|
||||
|
||||
const { promise, resolve, reject } = Promise.withResolvers<void>()
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const timer = globalThis.setTimeout(resolve, delay)
|
||||
return () => {
|
||||
globalThis.clearTimeout(timer)
|
||||
reject(new Error('Context has been disposed'))
|
||||
}
|
||||
}, 'ctx.timeout()')
|
||||
return promise.finally(() => { void dispose() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a callback repeatedly.
|
||||
* @param callback - work to run on each tick.
|
||||
* @param delay - interval in milliseconds.
|
||||
* @returns disposer that stops the interval.
|
||||
*/
|
||||
interval(callback: () => void, delay: number): () => void
|
||||
/**
|
||||
* Iterate over timer ticks.
|
||||
* @param delay - interval in milliseconds.
|
||||
* @returns async iterator of ticks.
|
||||
*/
|
||||
interval<R = any>(delay: number): AsyncIterableIterator<void, R, void>
|
||||
interval(...args: any[]): any {
|
||||
const callback = typeof args[0] === 'function' ? args.shift() as () => void : undefined
|
||||
const delay = args[0] as number
|
||||
if (callback !== undefined) {
|
||||
return this.ctx.effect(() => {
|
||||
const timer = globalThis.setInterval(callback, delay)
|
||||
return () => { globalThis.clearInterval(timer) }
|
||||
}, 'ctx.interval()')
|
||||
}
|
||||
|
||||
let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
|
||||
let nextTask: PromiseWithResolvers<IteratorResult<void>> | undefined
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const timer = globalThis.setInterval(() => {
|
||||
nextTask?.resolve({ done: false, value: undefined })
|
||||
}, delay)
|
||||
return () => {
|
||||
globalThis.clearInterval(timer)
|
||||
if (done !== undefined) return
|
||||
done = { kind: 'throw', reason: new Error('Context has been disposed') }
|
||||
nextTask?.reject(done.reason)
|
||||
}
|
||||
}, 'ctx.interval()')
|
||||
return {
|
||||
next: () => {
|
||||
if (done === undefined) return (nextTask = Promise.withResolvers()).promise
|
||||
if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
|
||||
return Promise.reject(done.reason)
|
||||
},
|
||||
return: (value: any) => {
|
||||
if (done === undefined) done = { kind: 'return', value }
|
||||
nextTask?.resolve({ done: true, value })
|
||||
void dispose()
|
||||
return Promise.resolve({ done: true, value })
|
||||
},
|
||||
throw: (reason: any) => {
|
||||
if (done === undefined) done = { kind: 'throw', reason }
|
||||
nextTask?.reject(reason)
|
||||
void dispose()
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
},
|
||||
[Symbol.asyncIterator]() {
|
||||
return this
|
||||
},
|
||||
} satisfies AsyncIterableIterator<void>
|
||||
}
|
||||
|
||||
/** Build a delayed wrapper whose pending callback belongs to the calling Fiber. */
|
||||
private schedule(label: string, trigger: (args: any[], disposed: boolean) => number | undefined, disposed = false): any {
|
||||
let timer: number | undefined
|
||||
const dispose = this.ctx.effect(() => () => {
|
||||
disposed = true
|
||||
globalThis.clearTimeout(timer)
|
||||
}, label)
|
||||
const wrapper: any = (...args: any[]): void => {
|
||||
globalThis.clearTimeout(timer)
|
||||
timer = trigger(args, disposed)
|
||||
}
|
||||
wrapper.dispose = dispose
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a throttled function whose timer is disposed with the calling Fiber.
|
||||
* @param callback - Function to throttle.
|
||||
* @param delay - Minimum interval between calls in milliseconds.
|
||||
* @param noTrailing - Whether to suppress a delayed trailing call.
|
||||
* @returns Throttled function with an early disposer.
|
||||
*/
|
||||
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
|
||||
let lastCall = -Infinity
|
||||
const execute = (...args: Parameters<F>): void => {
|
||||
lastCall = Date.now()
|
||||
callback(...args)
|
||||
}
|
||||
return this.schedule('ctx.throttle()', (args, disposed) => {
|
||||
const remaining = delay - Date.now() + lastCall
|
||||
if (remaining <= 0) {
|
||||
execute(...args as Parameters<F>)
|
||||
} else if (!disposed) {
|
||||
return globalThis.setTimeout(execute, remaining, ...args)
|
||||
}
|
||||
}, noTrailing)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a debounced function whose timer is disposed with the calling Fiber.
|
||||
* @param callback - Function to debounce.
|
||||
* @param delay - Quiet period in milliseconds.
|
||||
* @returns Debounced function with an early disposer.
|
||||
*/
|
||||
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
|
||||
return this.schedule('ctx.debounce()', (args, disposed) => {
|
||||
if (disposed) return
|
||||
return globalThis.setTimeout(callback, delay, ...args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the browser timer Service on one Client composition.
|
||||
* @param ctx - Client context that owns the Service and mixed-in helpers.
|
||||
* @returns Nothing after registering the Service.
|
||||
*/
|
||||
export function provideClientTimer(ctx: Context): void {
|
||||
new ClientTimerService(ctx)
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
/* oxlint-disable typescript/no-unsafe-assignment -- Vitest asymmetric matchers are typed as any. */
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { TimerService } from '@deepseek-ai/cordis-plugin-timer'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type {
|
||||
ApprovalRequestId, CordisDynamicPackageId, CordisDynamicPluginId, CordisDynamicPluginRunId,
|
||||
@@ -68,8 +69,7 @@ interface Bench {
|
||||
}
|
||||
|
||||
/** Mount the browser half over a module table and a loader standing on real fibers. */
|
||||
async function boot(): Promise<Bench> {
|
||||
const ctx = new Context()
|
||||
async function boot(ctx = new Context()): Promise<Bench> {
|
||||
await ctx.plugin(SlotRegistry)
|
||||
const factories = new Map<string, () => unknown>()
|
||||
const fibers = new Map<string, { fiber: unknown }>()
|
||||
@@ -194,6 +194,49 @@ async function boot(): Promise<Bench> {
|
||||
}
|
||||
|
||||
describe('browser half', () => {
|
||||
it('installs the shared Timer and disposes scheduled work with its consumer', async () => {
|
||||
const moduleLoader = Object.getOwnPropertyDescriptor(globalThis, '__ModuleLoader__')
|
||||
const ctx = new Context()
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const bench = await boot(ctx)
|
||||
expect(bench.ctx.timer).toBeInstanceOf(TimerService)
|
||||
const timeout = vi.fn()
|
||||
const interval = vi.fn()
|
||||
const delayed = vi.fn()
|
||||
const consumer = bench.ctx.plugin({
|
||||
inject: ['timer'],
|
||||
apply(ctx: Context) {
|
||||
ctx.timeout(timeout, 20)
|
||||
ctx.interval(interval, 20)
|
||||
ctx.debounce(delayed, 40)()
|
||||
},
|
||||
})
|
||||
await consumer
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
expect(timeout).toHaveBeenCalledOnce()
|
||||
expect(interval).toHaveBeenCalledOnce()
|
||||
expect(delayed).not.toHaveBeenCalled()
|
||||
await consumer.dispose()
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
expect(interval).toHaveBeenCalledOnce()
|
||||
expect(delayed).not.toHaveBeenCalled()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(bench.ctx.timer).toBeInstanceOf(TimerService)
|
||||
await bench.dispose()
|
||||
expect(bench.ctx.get('timer')).toBeUndefined()
|
||||
expect(bench.ctx.timeout).toBeUndefined()
|
||||
} finally {
|
||||
try {
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
if (moduleLoader) Object.defineProperty(globalThis, '__ModuleLoader__', moduleLoader)
|
||||
else Reflect.deleteProperty(globalThis, '__ModuleLoader__')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('provides the load engine as the page run-state face', async () => {
|
||||
const bench = await boot()
|
||||
expect(bench.ctx.dynamicCordisRunner.getSnapshot()).toEqual([])
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
|
||||
@@ -3391,12 +3391,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
|
||||
signature: 'throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters<F>) => void) & { dispose: () => void }',
|
||||
description: 'Return a throttled function whose timer is disposed with the current fiber.',
|
||||
parameters: [],
|
||||
},
|
||||
{
|
||||
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
|
||||
signature: 'debounce<F extends (...args: any[]) => any>(callback: F, delay: number): ((...args: Parameters<F>) => void) & { dispose: () => void }',
|
||||
description: 'Return a debounced function whose timer is disposed with the current fiber.',
|
||||
parameters: [],
|
||||
},
|
||||
|
||||
Generated
+3
@@ -9928,6 +9928,9 @@ importers:
|
||||
'@deepseek-ai/cordis-plugin-loader':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/loader
|
||||
'@deepseek-ai/cordis-plugin-timer':
|
||||
specifier: workspace:~
|
||||
version: link:../../../vendor/timer
|
||||
'@deepseek-ai/dsh-api-remotes':
|
||||
specifier: workspace:*
|
||||
version: link:../../api/remotes
|
||||
|
||||
@@ -234,6 +234,17 @@ describe('client bundle purity gate', () => {
|
||||
expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('inlines the browser-safe Timer entry while sharing Cordis through the module table', () => {
|
||||
const timer = '@deepseek-ai/cordis-plugin-timer'
|
||||
expect(resolveId(timer)).toBeNull()
|
||||
expect(() => resolveId(`${timer}/src/index.ts`)).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/cordis-plugin-hmr')).toThrow(/purity/)
|
||||
const config = clientConfigs(packageDir('extensions/cordis-client-runner'))[0]
|
||||
const deps = config?.deps as { neverBundle: (specifier: string) => boolean }
|
||||
expect(deps.neverBundle(timer)).toBe(false)
|
||||
expect(deps.neverBundle('@deepseek-ai/cordis')).toBe(true)
|
||||
})
|
||||
|
||||
it('lets exact generated Remote contributions inline without admitting their package implementation', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-goal/remote')).toBeNull()
|
||||
expect(() => resolveId('@deepseek-ai/dsh-goal')).toThrow(/purity/)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
/** Timer iterator ordering and cancellation on the vendored Cordis lifecycle. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Timer from '@deepseek-ai/cordis-plugin-timer'
|
||||
import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
|
||||
describe('Cordis Timer', () => {
|
||||
let ctx: Context
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.useFakeTimers()
|
||||
ctx = new Context()
|
||||
await ctx.plugin(Timer)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await ctx.fiber.dispose()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('completes concurrent interval reads in order, one per tick', async () => {
|
||||
const iterator = ctx.interval(10)
|
||||
const completed: number[] = []
|
||||
const reads = [0, 1, 2].map(index => iterator.next().then(
|
||||
(value) => { completed.push(index); return value },
|
||||
() => undefined,
|
||||
))
|
||||
for (let index = 0; index < reads.length; index++) {
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(completed).toEqual(Array.from({ length: index + 1 }, (_, i) => i))
|
||||
}
|
||||
expect(await Promise.all(reads)).toEqual(Array(3).fill({ done: false, value: undefined }))
|
||||
await iterator.return!()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it.each(['return', 'throw', 'dispose'] as const)('settles all pending interval reads on %s', async (operation) => {
|
||||
const iterator = ctx.interval<string>(10)
|
||||
const completed: PromiseSettledResult<IteratorResult<void, string>>[] = []
|
||||
for (let index = 0; index < 3; index++) {
|
||||
void iterator.next().then(
|
||||
value => completed.push({ status: 'fulfilled', value }),
|
||||
(reason: unknown) => completed.push({ status: 'rejected', reason }),
|
||||
)
|
||||
}
|
||||
const error = new Error('stopped')
|
||||
if (operation === 'return') await iterator.return!('finished')
|
||||
else if (operation === 'throw') await iterator.throw!(error)
|
||||
else await ctx.fiber.dispose()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(completed).toHaveLength(3)
|
||||
if (operation === 'return') {
|
||||
expect(completed).toEqual(Array(3).fill({ status: 'fulfilled', value: { done: true, value: 'finished' } }))
|
||||
await expect(iterator.next()).resolves.toEqual({ done: true, value: 'finished' })
|
||||
} else {
|
||||
const reason = operation === 'throw' ? error : new Error('Context has been disposed')
|
||||
expect(completed).toEqual(Array(3).fill({ status: 'rejected', reason }))
|
||||
await expect(iterator.next()).rejects.toThrow(reason.message)
|
||||
}
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it.each([false, true])('stops throttle calls and trailing work after disposal (noTrailing=%s)', async (noTrailing) => {
|
||||
const callback = vi.fn((value: string) => value.length)
|
||||
const throttled = ctx.throttle(callback, 10, noTrailing)
|
||||
expectTypeOf(throttled).parameters.toEqualTypeOf<[string]>()
|
||||
expectTypeOf(throttled).returns.toBeVoid()
|
||||
throttled('first')
|
||||
throttled('second')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(callback.mock.calls).toEqual(noTrailing ? [['first']] : [['first'], ['second']])
|
||||
throttled('before disposal')
|
||||
const calls = callback.mock.calls.length
|
||||
throttled.dispose()
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
throttled('after disposal')
|
||||
expect(callback).toHaveBeenCalledTimes(calls)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('cancels debounce work and makes both wrappers inert after owner disposal', async () => {
|
||||
const callback = vi.fn((value: string) => value.length)
|
||||
const debounced = ctx.debounce(callback, 10)
|
||||
const throttled = ctx.throttle(callback, 10)
|
||||
expectTypeOf(debounced).parameters.toEqualTypeOf<[string]>()
|
||||
expectTypeOf(debounced).returns.toBeVoid()
|
||||
debounced('first')
|
||||
debounced('last')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith('last')
|
||||
debounced('pending')
|
||||
await ctx.fiber.dispose()
|
||||
debounced('disposed')
|
||||
throttled('disposed')
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(callback).toHaveBeenCalledOnce()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -202,7 +202,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the API',
|
||||
sessionLogDownload: 'client-side browser download controller — packages/session-query/session-log-export/README.md owns the API',
|
||||
inputTriggers: 'client-side interface-typed browser service — packages/client/ui-input-trigger/README.md owns the API',
|
||||
timer: 'client-side dynamic-package timer service — packages/extensions/cordis-client-runner/README.md owns the API',
|
||||
slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
|
||||
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API',
|
||||
workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API',
|
||||
@@ -1018,11 +1017,11 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
||||
jsDoc: '/** Return an async iterator of timer ticks. */',
|
||||
},
|
||||
{
|
||||
signature: 'throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }',
|
||||
signature: 'throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters<F>) => void) & { dispose: () => void }',
|
||||
jsDoc: '/** Return a throttled function whose timer is disposed with the current fiber. */',
|
||||
},
|
||||
{
|
||||
signature: 'debounce<F extends (...args: any[]) => void>(callback: F, delay: number): F & { dispose: () => void }',
|
||||
signature: 'debounce<F extends (...args: any[]) => any>(callback: F, delay: number): ((...args: Parameters<F>) => void) & { dispose: () => void }',
|
||||
jsDoc: '/** Return a debounced function whose timer is disposed with the current fiber. */',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -261,7 +261,7 @@ const EXACT_EDITS: readonly ExactEdit[] = [
|
||||
* identity to share — the framework itself is a requested module-table row
|
||||
* (external), while these are ordinary libraries a browser bundle inlines.
|
||||
*/
|
||||
const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
|
||||
const VENDORED_LIBRARY = /^@deepseek-ai\\/(?:(?:cosmokit|schemastery)(?:\\/|$)|cordis-plugin-timer$)/
|
||||
|
||||
/** Generated descriptor/codec contribution with no shared runtime identity. */`,
|
||||
expect: 1,
|
||||
|
||||
Vendored
+2
-2
@@ -20,7 +20,7 @@ Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspac
|
||||
| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | 1.0.0-rc.5 | https://github.com/cordiverse/cordis (`packages/loader`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` |
|
||||
| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
|
||||
| `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
|
||||
| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.2 | https://github.com/deepseek-harness/cordis (`packages/timer`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
|
||||
| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.4 | https://github.com/cordiverse/cordis (`packages/timer`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` |
|
||||
| `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
|
||||
| `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/logger-console`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` |
|
||||
|
||||
@@ -38,7 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
|
||||
4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces.
|
||||
5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
|
||||
6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup.
|
||||
7. **Cordis and Loader JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
|
||||
7. **Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Timer retains its local class and helper summaries; its README is local documentation because the upstream package has none. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork.
|
||||
8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`.
|
||||
9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. Covered by `packages/boot/hmr/tests/watch-config.spec.ts`.
|
||||
10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`).
|
||||
|
||||
Vendored
+1
-1
@@ -4,7 +4,7 @@ Status: proposed
|
||||
|
||||
## Problem
|
||||
|
||||
The [vendor manifest](README.md) records Cordis core 4.0.0-rc.7, Loader 1.0.0-rc.5, Include 1.0.4, HMR 1.0.15 and Timer 1.1.2, with 24 local-modification entries. [Cordis v4.0.0-rc.11](https://github.com/cordiverse/cordis/releases/tag/v4.0.0-rc.11) incorporates some fixes and replaces other mechanisms. Updating every package and retiring every patch in one change would combine configuration, persistence, lifecycle and module-replacement decisions.
|
||||
The upgrade starts from the [vendored](README.md) baselines Cordis core 4.0.0-rc.7, Loader 1.0.0-rc.5, Include 1.0.4, HMR 1.0.15 and Timer 1.1.2, with 24 local-modification entries. [Cordis v4.0.0-rc.11](https://github.com/cordiverse/cordis/releases/tag/v4.0.0-rc.11) incorporates some fixes and replaces other mechanisms. Updating every package and retiring every patch in one change would combine configuration, persistence, lifecycle and module-replacement decisions.
|
||||
|
||||
DSH also has consumers that upstream Cordis does not own. Volatile configuration fields are stable references updated without restarting plugins; the settings UI reads their live values. A profile selects ordered plugin bundles and configuration overrides. [DSH HMR](../packages/boot/hmr/README.md) coordinates module replacement and profile changes through `ctx.hmr`. An entry's module namespace is its raw import result, retained before selecting `default` or `apply` so HMR can identify the module already loaded. These consumers must remain usable at every merge point.
|
||||
|
||||
|
||||
Vendored
+6
-4
@@ -18,8 +18,7 @@ const dispose = root.timeout(() => {
|
||||
dispose()
|
||||
```
|
||||
|
||||
Timer handles are registered on the current fiber, so they are cleared
|
||||
automatically when the plugin that created them is disposed.
|
||||
Timer handles are registered on the current fiber, so they are cleared automatically when the plugin that created them is disposed.
|
||||
|
||||
## API
|
||||
|
||||
@@ -32,5 +31,8 @@ automatically when the plugin that created them is disposed.
|
||||
| `ctx.throttle(callback, delay, noTrailing?)` | Return a throttled function with `.dispose()`. |
|
||||
| `ctx.debounce(callback, delay)` | Return a debounced function with `.dispose()`. |
|
||||
|
||||
`ctx.setTimeout()` and `ctx.setInterval()` are kept as deprecated aliases for
|
||||
`ctx.timeout()` and `ctx.interval()`.
|
||||
`ctx.setTimeout()` and `ctx.setInterval()` are kept as deprecated aliases for `ctx.timeout()` and `ctx.interval()`.
|
||||
|
||||
Concurrent `next()` calls on an interval iterator complete in request order, one per tick. Returning from or throwing into the iterator settles every pending read; disposing the owning fiber rejects every pending read. Ticks without a pending read are discarded.
|
||||
|
||||
Throttle and debounce wrappers return `void`, regardless of the callback return type. Calling `.dispose()` or disposing the owning fiber cancels pending work and makes later calls inert. `noTrailing` suppresses delayed throttle calls while retaining leading calls until disposal.
|
||||
Vendored
+22
-16
@@ -6,7 +6,7 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
type WithDispose<T> = T & { dispose: () => void }
|
||||
type Scheduled<F extends (...args: any[]) => any> = ((...args: Parameters<F>) => void) & { dispose: () => void }
|
||||
|
||||
/** Disposable timer helpers mixed into Cordis contexts. */
|
||||
export class TimerService extends Service {
|
||||
@@ -66,33 +66,37 @@ export class TimerService extends Service {
|
||||
}, 'ctx.interval()')
|
||||
} else {
|
||||
let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined
|
||||
let nextTask: PromiseWithResolvers<IteratorResult<void>> | undefined
|
||||
const nextTasks: PromiseWithResolvers<IteratorResult<void>>[] = []
|
||||
const dispose = this.ctx.effect(() => {
|
||||
const timer = setInterval(() => {
|
||||
nextTask?.resolve({ done: false, value: undefined })
|
||||
nextTasks.shift()?.resolve({ done: false, value: undefined })
|
||||
}, delay)
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
if (done) return
|
||||
done = { kind: 'throw', reason: new Error('Context has been disposed') }
|
||||
nextTask?.reject(done.reason)
|
||||
for (const task of nextTasks.splice(0)) task.reject(done.reason)
|
||||
}
|
||||
}, 'ctx.interval()')
|
||||
return {
|
||||
next: () => {
|
||||
if (!done) return (nextTask = Promise.withResolvers()).promise
|
||||
if (!done) {
|
||||
const task = Promise.withResolvers<IteratorResult<void>>()
|
||||
nextTasks.push(task)
|
||||
return task.promise
|
||||
}
|
||||
if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value })
|
||||
return Promise.reject(done.reason)
|
||||
},
|
||||
return: (value) => {
|
||||
if (!done) done = { kind: 'return', value }
|
||||
nextTask?.resolve({ done: true, value })
|
||||
for (const task of nextTasks.splice(0)) task.resolve({ done: true, value })
|
||||
dispose()
|
||||
return Promise.resolve({ done: true, value })
|
||||
},
|
||||
throw: (reason) => {
|
||||
if (!done) done = { kind: 'throw', reason }
|
||||
nextTask?.reject(reason)
|
||||
for (const task of nextTasks.splice(0)) task.reject(reason)
|
||||
dispose()
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
},
|
||||
@@ -103,42 +107,44 @@ export class TimerService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
private _schedule(label: string, trigger: (args: any[], isDisposed: boolean) => any, isDisposed = false) {
|
||||
private _schedule(label: string, trigger: (args: any[]) => number | NodeJS.Timeout | undefined) {
|
||||
let timer: number | NodeJS.Timeout | undefined
|
||||
let isDisposed = false
|
||||
const dispose = this.ctx.effect(() => () => {
|
||||
isDisposed = true
|
||||
clearTimeout(timer)
|
||||
timer = undefined
|
||||
}, label)
|
||||
const wrapper: any = (...args: any[]) => {
|
||||
if (isDisposed) return
|
||||
clearTimeout(timer)
|
||||
timer = trigger(args, isDisposed)
|
||||
timer = trigger(args)
|
||||
}
|
||||
wrapper.dispose = dispose
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/** Return a throttled function whose timer is disposed with the current fiber. */
|
||||
throttle<F extends (...args: any[]) => void>(callback: F, delay: number, noTrailing?: boolean): WithDispose<F> {
|
||||
throttle<F extends (...args: any[]) => any>(callback: F, delay: number, noTrailing?: boolean): Scheduled<F> {
|
||||
let lastCall = -Infinity
|
||||
const execute = (...args: any[]) => {
|
||||
lastCall = Date.now()
|
||||
callback(...args)
|
||||
}
|
||||
return this._schedule('ctx.throttle()', (args, isDisposed) => {
|
||||
return this._schedule('ctx.throttle()', (args) => {
|
||||
const now = Date.now()
|
||||
const remaining = delay - now + lastCall
|
||||
if (remaining <= 0) {
|
||||
execute(...args)
|
||||
} else if (!isDisposed) {
|
||||
} else if (!noTrailing) {
|
||||
return setTimeout(execute, remaining, ...args)
|
||||
}
|
||||
}, noTrailing)
|
||||
})
|
||||
}
|
||||
|
||||
/** Return a debounced function whose timer is disposed with the current fiber. */
|
||||
debounce<F extends (...args: any[]) => void>(callback: F, delay: number): WithDispose<F> {
|
||||
return this._schedule('ctx.debounce()', (args, isDisposed) => {
|
||||
if (isDisposed) return
|
||||
debounce<F extends (...args: any[]) => any>(callback: F, delay: number): Scheduled<F> {
|
||||
return this._schedule('ctx.debounce()', (args) => {
|
||||
return setTimeout(callback, delay, ...args)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user