diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.i18n.yaml index 05552c8917..7b44caf7b1 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.i18n.yaml @@ -9,8 +9,8 @@ en: 8b8de9b0fc2e3fc3 zh: ffa20b7ffa7540ef /timer-wrapper-return-types-and-disposal/change: - en: 978657a23b852a1e - zh: 1402cc87410cfc7a + en: 6ecd1dc8667a321d + zh: 150f620b06934133 /timer-wrapper-return-types-and-disposal/migration: en: c07c4d66ff9fd1ff zh: ecebc12b08a9be47 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.md index f76b1aeba7..fbed59a398 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.md @@ -9,7 +9,7 @@ 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. This applies to Host Timer and browser ClientTimerService. +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. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.zh.md index 15672e374e..2245e95119 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.zh.md @@ -9,7 +9,7 @@ description: "Timer 包装函数的 TypeScript 返回类型为 void,释放后 ## 变更 -`ctx.throttle()` 和 `ctx.debounce()` 返回的函数现在声明 `void` 返回类型。此前这些函数继承回调的返回类型,但运行时已经返回 `undefined`。使用包装函数返回值或将包装函数赋给回调类型的 TypeScript 代码可能需要更新。此变更适用于 Host Timer 和浏览器 ClientTimerService。 +`ctx.throttle()` 和 `ctx.debounce()` 返回的函数现在声明 `void` 返回类型。此前这些函数继承回调的返回类型,但运行时已经返回 `undefined`。使用包装函数返回值或将包装函数赋给回调类型的 TypeScript 代码可能需要更新。Host 与浏览器使用同一份 `TimerService` 实现。 调用 `.dispose()` 或释放所属 fiber 会取消待执行的工作,并使后续包装函数调用不再执行。此前已释放的 throttle 包装函数仍可能执行前沿调用;debounce 包装函数在释放后已经会忽略调用。 diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index 888519a890..d7c64ed4cc 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -70,7 +70,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$/ diff --git a/packages/extensions/cordis-client-runner/README.i18n.yaml b/packages/extensions/cordis-client-runner/README.i18n.yaml index fb20ce928e..d98d6aa7cd 100644 --- a/packages/extensions/cordis-client-runner/README.i18n.yaml +++ b/packages/extensions/cordis-client-runner/README.i18n.yaml @@ -33,8 +33,8 @@ en: df635b29eaef982c zh: ddaa4bd98359bd30 /deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/source-map: - en: f69f511aa85127f2 - zh: 651ea63d81bbd648 + en: 49412a5fd3800d54 + zh: 9ba50539d19c0e86 /deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/inspecting-slot-inputs: en: 33f14a7fff7835e6 zh: 53838ac8d4a79600 diff --git a/packages/extensions/cordis-client-runner/README.md b/packages/extensions/cordis-client-runner/README.md index f1e0ea134c..af33e2fd41 100644 --- a/packages/extensions/cordis-client-runner/README.md +++ b/packages/extensions/cordis-client-runner/README.md @@ -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. `ClientTimerService` is an export alias of `TimerService`. + | 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) | Browser timer Service matching the Host Timer API | ### Inspecting slot inputs diff --git a/packages/extensions/cordis-client-runner/README.zh.md b/packages/extensions/cordis-client-runner/README.zh.md index 1440caf92e..3c31c1a838 100644 --- a/packages/extensions/cordis-client-runner/README.zh.md +++ b/packages/extensions/cordis-client-runner/README.zh.md @@ -55,6 +55,8 @@ kind: "package-reference" ### 源码地图 +入口在 runner 的上下文中安装与 Host 共用的 vendored Cordis `TimerService`。浏览器包内联 Timer,并使用模块表中的 Cordis 实例。定时任务归调用插件的 fiber 所有,释放该 fiber 会取消尚未执行的任务。`ClientTimerService` 是 `TimerService` 的导出别名。 + | 文件 | 职责 | |---|---| | [`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) | 与 Host Timer API 一致的浏览器定时器 Service | ### 查询槽位输入 diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index fdfc292ef3..5de43fac9b 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -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:*", diff --git a/packages/extensions/cordis-client-runner/src/client/index.ts b/packages/extensions/cordis-client-runner/src/client/index.ts index 25fb48fe3d..623dde7d49 100644 --- a/packages/extensions/cordis-client-runner/src/client/index.ts +++ b/packages/extensions/cordis-client-runner/src/client/index.ts @@ -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,7 @@ 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' +export { TimerService as ClientTimerService } from '@deepseek-ai/cordis-plugin-timer' // 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 +186,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) diff --git a/packages/extensions/cordis-client-runner/src/client/timer.ts b/packages/extensions/cordis-client-runner/src/client/timer.ts deleted file mode 100644 index 2c87fb978e..0000000000 --- a/packages/extensions/cordis-client-runner/src/client/timer.ts +++ /dev/null @@ -1,222 +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 { - /** Browser timer Service used by the mixed-in Context helpers. */ - timer: ClientTimerService - } -} - -type Scheduled any> = ((...args: Parameters) => void) & { 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 - 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() - 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 Iterator completing concurrent reads in order, one per tick; disposal rejects all pending reads. - */ - interval(delay: number): AsyncIterableIterator - 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 - const nextTasks: PromiseWithResolvers>[] = [] - const dispose = this.ctx.effect(() => { - const timer = globalThis.setInterval(() => { - nextTasks.shift()?.resolve({ done: false, value: undefined }) - }, delay) - return () => { - globalThis.clearInterval(timer) - if (done !== undefined) return - done = { kind: 'throw', reason: new Error('Context has been disposed') } - for (const task of nextTasks.splice(0)) task.reject(done.reason) - } - }, 'ctx.interval()') - return { - next: () => { - if (done === undefined) { - const task = Promise.withResolvers>() - 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: any) => { - if (done === undefined) done = { kind: 'return', value } - for (const task of nextTasks.splice(0)) task.resolve({ done: true, value }) - void dispose() - return Promise.resolve({ done: true, value }) - }, - throw: (reason: any) => { - if (done === undefined) done = { kind: 'throw', reason } - for (const task of nextTasks.splice(0)) task.reject(reason) - void dispose() - return Promise.resolve({ done: true, value: undefined }) - }, - [Symbol.asyncIterator]() { - return this - }, - } satisfies AsyncIterableIterator - } - - /** Build a delayed wrapper whose pending callback belongs to the calling Fiber. */ - private schedule(label: string, trigger: (args: any[]) => number | undefined): any { - let timer: number | undefined - let disposed = false - const dispose = this.ctx.effect(() => () => { - disposed = true - globalThis.clearTimeout(timer) - timer = undefined - }, label) - const wrapper: any = (...args: any[]): void => { - if (disposed) return - globalThis.clearTimeout(timer) - timer = trigger(args) - } - 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 Void-returning wrapper; disposal cancels pending work and makes later calls inert. - */ - throttle any>(callback: F, delay: number, noTrailing?: boolean): Scheduled { - let lastCall = -Infinity - const execute = (...args: Parameters): void => { - lastCall = Date.now() - callback(...args) - } - return this.schedule('ctx.throttle()', (args) => { - const remaining = delay - Date.now() + lastCall - if (remaining <= 0) { - execute(...args as Parameters) - } else if (!noTrailing) { - return globalThis.setTimeout(execute, remaining, ...args) - } - }) - } - - /** - * Return a debounced function whose timer is disposed with the calling Fiber. - * @param callback - Function to debounce. - * @param delay - Quiet period in milliseconds. - * @returns Void-returning wrapper; disposal cancels pending work and makes later calls inert. - */ - debounce any>(callback: F, delay: number): Scheduled { - return this.schedule('ctx.debounce()', (args) => { - 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) -} diff --git a/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts index c43f65bc29..eca7bcc6c2 100644 --- a/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/plugin.client.spec.ts @@ -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 { - const ctx = new Context() +async function boot(ctx = new Context()): Promise { await ctx.plugin(SlotRegistry) const factories = new Map unknown>() const fibers = new Map() @@ -194,6 +194,50 @@ async function boot(): Promise { } 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) + expect(ClientHalf.ClientTimerService).toBe(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([]) diff --git a/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts b/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts deleted file mode 100644 index c672e9d9fe..0000000000 --- a/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** @vitest-environment jsdom */ -import { Context } from '@deepseek-ai/cordis' -import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' -import { ClientTimerService } from '../src/client/timer.ts' - -describe('client Timer', () => { - let ctx: Context - - beforeEach(async () => { - vi.useFakeTimers() - ctx = new Context() - await ctx.plugin(ClientTimerService) - }) - - afterEach(async () => { - try { - await ctx.fiber.dispose() - } finally { - vi.useRealTimers() - } - }) - - it('delivers concurrent reads in order and rejects the remaining reads on teardown', async () => { - const ticks = ctx.interval(20) - const outcomes: string[] = [] - for (let index = 0; index < 3; index++) { - void ticks.next().then(() => outcomes.push(`tick ${index}`), () => outcomes.push(`disposed ${index}`)) - } - await vi.advanceTimersByTimeAsync(20) - expect(outcomes).toEqual(['tick 0']) - await ctx.fiber.dispose() - expect(outcomes).toEqual(['tick 0', 'disposed 1', 'disposed 2']) - expect(vi.getTimerCount()).toBe(0) - }) - - it('keeps wrapper calls void and inert after disposal', async () => { - const work = vi.fn((value: string) => value.length) - const throttle = ctx.throttle(work, 20) - const debounce = ctx.debounce(work, 20) - expectTypeOf(throttle).returns.toBeVoid() - expectTypeOf(debounce).returns.toBeVoid() - throttle('leading') - throttle('trailing') - debounce('delayed') - throttle.dispose() - debounce.dispose() - await vi.advanceTimersByTimeAsync(40) - throttle('disposed') - debounce('disposed') - expect(work).toHaveBeenCalledExactlyOnceWith('leading') - expect(vi.getTimerCount()).toBe(0) - }) - - it('retains leading calls and suppresses trailing calls with noTrailing', async () => { - const work = vi.fn() - const throttle = ctx.throttle(work, 20, true) - throttle('first') - throttle('suppressed') - await vi.advanceTimersByTimeAsync(20) - expect(work).toHaveBeenCalledExactlyOnceWith('first') - throttle('next window') - expect(work.mock.calls).toEqual([['first'], ['next window']]) - throttle.dispose() - await vi.advanceTimersByTimeAsync(20) - throttle('disposed') - expect(work).toHaveBeenCalledTimes(2) - expect(vi.getTimerCount()).toBe(0) - }) -}) diff --git a/packages/extensions/cordis-client-runner/tsconfig.json b/packages/extensions/cordis-client-runner/tsconfig.json index cfeb3c7ac5..339d7d23b7 100644 --- a/packages/extensions/cordis-client-runner/tsconfig.json +++ b/packages/extensions/cordis-client-runner/tsconfig.json @@ -14,6 +14,9 @@ { "path": "../../../vendor/loader" }, + { + "path": "../../../vendor/timer" + }, { "path": "../../api/remotes/tsconfig.client.json" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 977eeaa9a8..09ea95dfa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9332,6 +9332,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 diff --git a/scripts/client-bundle-purity.spec.ts b/scripts/client-bundle-purity.spec.ts index 4ffd130b0a..6a8f3691f3 100644 --- a/scripts/client-bundle-purity.spec.ts +++ b/scripts/client-bundle-purity.spec.ts @@ -184,6 +184,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/) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 25bfc749c9..7a7cdd548a 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -200,7 +200,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { 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', diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts index e6292fb2a5..f3abe4844d 100644 --- a/scripts/rescope-vendor.ts +++ b/scripts/rescope-vendor.ts @@ -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,