Skip to content

Writing a Custom Addon

A package exports an addon-builder through a factory addon method: something that knows how to build an addon, matching IAddonBuilder<TAddon> from @time-provider/core:

ts
interface IAddonBuilder<TAddon extends IAddon<unknown> = IAddon<unknown>> {
  create(): TAddon;
}

.use(...) calls create() once, at Time-Provider creation time — not when composed — so an addon-builder can accumulate configuration between .use(...) and .create() (see Extending the builder chain below).

TAddon is what create() returns, matching IAddon<TDate>:

ts
interface IAddon<TDate> extends IDisposable {
  get runtime(): IRuntime<TDate>;
  applyToRuntime<TRuntime extends IRuntime<TDate>>(runtime: TRuntime): void;
}

The addon itself

applyToRuntime adds a new property to the runtime — typically via AddonHelper.extendRuntimeWithProperty from @time-provider/core. Declare the shape your addon contributes once, alongside the facade interface it points at, and export both:

ts
export interface IGreetingApi {
  greet(): string;
}

export type WithGreetingApi = {
  greeting: IGreetingApi;
};

Every built-in addon names that shape With<Something>ApiWithAnimationFrameApi, WithCronApi, WithEtaApi — because it is what consumers write when they need to name a Time-Provider with the addon composed in.

ts
import { AddonBase, AddonHelper, type IRuntime } from "@time-provider/core";

class Greeting<TDate> extends AddonBase<TDate> implements IGreetingApi, WithGreetingApi {
  #isDisposed = false;

  get greeting(): IGreetingApi {
    return this;
  }

  applyToRuntimeImpl(runtime: IRuntime<TDate>): void {
    AddonHelper.extendRuntimeWithProperty(runtime, "greeting", this);
  }

  dispose(): void {
    this.#isDisposed = true;
  }
  get isDisposed(): boolean {
    return this.#isDisposed;
  }
  [Symbol.dispose](): void {
    this.dispose();
  }

  greet(): string {
    return "hello";
  }
}

The helper defines the property as enumerable but non-writable and non-configurable, so nothing downstream can swap the facade out and a second addon claiming the same name fails rather than replacing the first.

Addons that schedule

If your addon schedules callbacks — anything handing back an IScheduledHandle — give the helper a dotted path instead of a bare name. It walks the facets first, so your addon sits beside timers, microtasks and the built-in cron, idle and animation rather than at the root:

ts
export type WithGreetingApi = {
  scheduler: { greeting: IGreetingApi };
};

applyToRuntimeImpl(runtime: IRuntime<TDate>): void {
  AddonHelper.extendRuntimeWithProperty(runtime, "scheduler.greeting", this);
}

Every segment but the last has to already exist on the runtime, so a path only ever extends a facet the core defines — or a facade another addon put there. That second case needs the optional flag:

ts
AddonHelper.extendRuntimeWithProperty(
  runtime,
  "compat.requestGreeting",
  this.greet.bind(this),
  this,
  true, // no-op when the compat addon isn't composed, instead of throwing
);

Declare such a member optional in your addon's public type (compat?: { ... }), because it is only there when both addons are composed — and the other addon has to be composed first, since the facade it owns must exist by the time yours is applied. This is how addon-animation-frame and addon-idle put requestAnimationFrame and requestIdleCallback on the compat facade.

Reaching back into the runtime goes through the same hierarchy: runtime.scheduler.timers for the timer primitives, runtime.clock for the clock. AddonBase resolves those once and caches them, as this.runtimeTimers, this.runtimeClock, this.runtimeMicrotasks and this.runtimePerformance, so a scheduling hot path does not walk that chain on every call.

Note the IRuntime<TDate> constraint: an addon is handed the runtime, not the narrower ITimeProvider facade a consumer holds. That's what gives it typed access to everything a runtime carries beyond the four public facades — the cron addon reads runtime.calendarScheme this way, which is how the same cron syntax describes whatever calendar the plugin uses. Both cron and ETA also build on runtime.scheduler, which is what makes their callbacks follow the clock strategy instead of the real event loop.

The addon-builder

Export a factory function returning a fresh addon-builder — not a shared singleton. Each call to .use(addon) then gets its own instance, so configuring one composition (see below) never leaks into another:

ts
import type { IAddonBuilder } from "@time-provider/core";

export function addon<TDate>(): IAddonBuilder<Greeting<TDate>> {
  return { create: () => new Greeting<TDate>() };
}
export default addon;

A plain object literal is enough when there's no configuration to hold, as shown above. @time-provider/addon-cron, @time-provider/addon-eta, and @time-provider/addon-compat instead extend AddonBuilderBase from @time-provider/core — a small base class for the common case of a create() that just instantiates the addon. Either shape composes the same way, with createTimeProvider.for(plugin).use(addon).

Two entry points

Like core itself, an addon-builder is split into a system half and a deterministic half (see Mental Model) — IAddonBuilder typed against ISystemAddon<TDate> on the system side, IDeterministicAddon<TDate> (from @time-provider/core/deterministic) on the deterministic one.

The two halves only need separate implementations when the behavior differs. An addon that reads time with runtime.clock.timestampNow() and schedules on runtime.scheduler already follows the clock strategy, so it can write the factory once, in a shared addon.ts, and re-export it under both entry points — the shape @time-provider/addon-cron and @time-provider/addon-eta use:

ts
// addon.ts
import type { IAddonBuilder } from "@time-provider/core";
import { Greeting } from "./greeting.ts";

export function addon<TDate>(): IAddonBuilder<Greeting<TDate>> {
  return { create: () => new Greeting<TDate>() };
}
ts
// index.ts
export { addon } from "./addon.ts";
export default addon;
ts
// deterministic.ts
export { addon } from "./addon.ts";
export default addon;

@time-provider/addon-animation-frame is the case that needs two: its system half hands work to the host's requestAnimationFrame, its deterministic half simulates frames against the runtime's own clock.

Extending the builder chain

Beyond create, an addon-builder's own enumerable properties are spliced onto the runtime-builder by .use(...). That is how @time-provider/addon-animation-frame contributes .withHostFramesRate(...): config methods called after .use(...) mutate the same addon-builder .use(...) stored, and create() — called later, at .create() time — reads that configuration:

ts
export interface IGreetingBuilderExtra {
  withGreetingName<TBuilder>(this: TBuilder, name: string): TBuilder;
}

// A closure, not a class field: `.use()` splices `withGreetingName` onto the runtime-builder
// chain, so it actually runs with the runtime-builder as `this`, not this addon-builder - a
// private class field wouldn't be reachable from there, but a closed-over variable still is.
export function addon<TDate>(): IAddonBuilder<Greeting<TDate>> & IGreetingBuilderExtra {
  let greetingName: string | undefined;
  return {
    withGreetingName<TBuilder>(this: TBuilder, name: string): TBuilder {
      greetingName = name;
      return this;
    },
    create(): Greeting<TDate> {
      return new Greeting<TDate>(greetingName);
    },
  };
}
ts
createTimeProvider.for(plugin).use(addon).withGreetingName("Ada").create();

.use(...) throws if a spliced-in name is already present on the builder, so a method that would shadow an existing builder member fails at composition instead of silently taking its place.

For a complete worked example, read the built-in addons' source: the @time-provider/addon-animation-frame source covers two different entry points and a configurable builder, and @time-provider/addon-cron the shared-factory shape.