// @ts-ignore dev // deno-lint-ignore no-process-global const __DEV__ = process.env.NODE_ENV !== "production"; export interface BunjaFn { (init: () => T): Bunja; use: BunjaUseFn; fork: BunjaForkFn; effect: BunjaEffectFn; } export const bunja: BunjaFn = bunjaFn; function bunjaFn(init: () => T): Bunja { return new Bunja(init); } bunjaFn.use = invalidUse as BunjaUseFn; bunjaFn.fork = invalidFork as BunjaForkFn; bunjaFn.effect = invalidEffect as BunjaEffectFn; export type BunjaUseFn = (dep: Dep) => T; export type BunjaForkFn = ( bunja: Bunja, scopeValuePairs: ScopeValuePair[], ) => T; export type BunjaEffectFn = (callback: BunjaEffectCallback) => void; export type BunjaEffectCallback = () => (() => void) | void; export function createScope(hash?: HashFn): Scope { return new Scope(hash); } export interface CreateBunjaStoreConfig { wrapInstance?: WrapInstanceFn; } export function createBunjaStore(config?: CreateBunjaStoreConfig): BunjaStore { const { wrapInstance = defaultWrapInstanceFn } = config ?? {}; const store = new BunjaStore(); store.wrapInstance = wrapInstance; return store; } export type Dep = Bunja | Scope; function invalidUse() { throw new Error( "`bunja.use` can only be used inside a bunja init function.", ); } function invalidFork() { throw new Error( "`bunja.fork` can only be used inside a bunja init function.", ); } function invalidEffect() { throw new Error( "`bunja.effect` can only be used inside a bunja init function.", ); } interface BunjaStoreGetContext { bunjaInstance: BunjaInstance; bunjaInstanceMap: BunjaInstanceMap; scopeInstanceMap: ScopeInstanceMap; } type BunjaInstanceMap = Map, BunjaInstance>; type ScopeInstanceMap = Map, ScopeInstance>; interface InternalState { bunjas: Record; scopes: Map, Map>; } interface BunjaBakingContext { currentBunja: Bunja; } export type WrapInstanceFn = (fn: (dispose: () => void) => T) => T; const defaultWrapInstanceFn: WrapInstanceFn = (fn) => fn(noop); export class BunjaStore { private static counter: number = 0; readonly id: string = String(BunjaStore.counter++); #bunjas: Record = {}; #scopes: Map, Map> = new Map(); #bakingContext: BunjaBakingContext | undefined; wrapInstance: WrapInstanceFn = defaultWrapInstanceFn; constructor() { if (__DEV__) { devtoolsGlobalHook.stores[this.id] = this; devtoolsGlobalHook.emit("storeCreated", { storeId: this.id }); } } get _internalState(): InternalState | undefined { if (__DEV__) return { bunjas: this.#bunjas, scopes: this.#scopes }; return undefined; } dispose(): void { for (const instance of Object.values(this.#bunjas)) instance.dispose(); for (const instanceMap of Object.values(this.#scopes)) { for (const instance of instanceMap.values()) instance.dispose(); } this.#bunjas = {}; this.#scopes = new Map(); if (__DEV__) { devtoolsGlobalHook.emit("storeDisposed", { storeId: this.id }); delete devtoolsGlobalHook.stores[this.id]; } } get(bunja: Bunja, readScope: ReadScope): BunjaStoreGetResult { const originalUse = bunjaFn.use; try { const { bunjaInstance, bunjaInstanceMap, scopeInstanceMap } = bunja.baked ? this.#getBaked(bunja, readScope) : this.#getUnbaked(bunja, readScope); const result: BunjaStoreGetResult = { value: bunjaInstance.value as T, mount: () => { bunjaInstanceMap.forEach((instance) => instance.add()); bunjaInstance.add(); scopeInstanceMap.forEach((instance) => instance.add()); const unmount = () => { bunjaInstanceMap.forEach((instance) => instance.sub()); bunjaInstance.sub(); scopeInstanceMap.forEach((instance) => instance.sub()); }; return unmount; }, deps: Array.from(scopeInstanceMap.values()).map(({ value }) => value), }; if (__DEV__) { result.bunjaInstance = bunjaInstance; devtoolsGlobalHook.emit("getCalled", { storeId: this.id, bunjaInstanceId: bunjaInstance.id, }); } return result; } finally { bunjaFn.use = originalUse; } } #getBaked(bunja: Bunja, readScope: ReadScope): BunjaStoreGetContext { const scopeInstanceMap = new Map( bunja.relatedScopes.map((scope) => [ scope, this.#getScopeInstance(scope, readScope(scope)), ]), ); const bunjaInstanceMap = new Map(); bunjaFn.use = (dep: Dep) => { if (dep instanceof Bunja) { return bunjaInstanceMap.get(dep as Bunja)!.value as T; } if (dep instanceof Scope) { return scopeInstanceMap.get(dep as Scope)!.value as T; } throw new Error("`bunja.use` can only be used with Bunja or Scope."); }; for (const relatedBunja of bunja.relatedBunjas) { bunjaInstanceMap.set( relatedBunja, this.#getBunjaInstance(relatedBunja, scopeInstanceMap), ); } const bunjaInstance = this.#getBunjaInstance(bunja, scopeInstanceMap); return { bunjaInstance, bunjaInstanceMap, scopeInstanceMap }; } #getUnbaked(bunja: Bunja, readScope: ReadScope): BunjaStoreGetContext { const bunjaInstanceMap: BunjaInstanceMap = new Map(); const scopeInstanceMap: ScopeInstanceMap = new Map(); function getUse, I extends { value: unknown }>( map: Map, addDep: (D: D) => void, getInstance: (dep: D) => I, ) { return ((dep) => { const d = dep as D; addDep(d); if (map.has(d)) return map.get(d)!.value as T; const instance = getInstance(d); map.set(d, instance); return instance.value as T; }) as (dep: Dep) => T; } const useScope = getUse( scopeInstanceMap, (dep) => this.#bakingContext!.currentBunja.addScope(dep), (dep) => this.#getScopeInstance(dep, readScope(dep)), ); const useBunja = getUse( bunjaInstanceMap, (dep) => this.#bakingContext!.currentBunja.addParent(dep), (dep) => { if (dep.baked) { for (const scope of dep.relatedScopes) useScope(scope); } return this.#getBunjaInstance(dep, scopeInstanceMap); }, ); bunjaFn.use = (dep: Dep) => { if (dep instanceof Bunja) return useBunja(dep) as T; if (dep instanceof Scope) return useScope(dep) as T; throw new Error("`bunja.use` can only be used with Bunja or Scope."); }; const originalBakingContext = this.#bakingContext; try { this.#bakingContext = { currentBunja: bunja }; const bunjaInstance = this.#getBunjaInstance(bunja, scopeInstanceMap); return { bunjaInstance, bunjaInstanceMap, scopeInstanceMap }; } finally { this.#bakingContext = originalBakingContext; } } #getBunjaInstance( bunja: Bunja, scopeInstanceMap: ScopeInstanceMap, ): BunjaInstance { const originalEffect = bunjaFn.effect; const originalFork = bunjaFn.fork; const prevBunja = this.#bakingContext?.currentBunja; try { const effects: BunjaEffectCallback[] = []; bunjaFn.effect = (callback: BunjaEffectCallback) => { effects.push(callback); }; bunjaFn.fork = (b, scopeValuePairs) => { const readScope = createReadScopeFn(scopeValuePairs, bunjaFn.use); const { value, mount } = this.get(b, readScope); bunjaFn.effect(mount); return value; }; if (this.#bakingContext) this.#bakingContext.currentBunja = bunja; if (bunja.baked) { const id = bunja.calcInstanceId(scopeInstanceMap); if (id in this.#bunjas) return this.#bunjas[id]; return this.wrapInstance((dispose) => { const value = bunja.init(); return this.#createBunjaInstance(id, value, effects, dispose); }); } else { return this.wrapInstance((dispose) => { const value = bunja.init(); bunja.bake(); const id = bunja.calcInstanceId(scopeInstanceMap); return this.#createBunjaInstance(id, value, effects, dispose); }); } } finally { bunjaFn.effect = originalEffect; bunjaFn.fork = originalFork; if (this.#bakingContext) this.#bakingContext.currentBunja = prevBunja!; } } #getScopeInstance(scope: Scope, value: unknown): ScopeInstance { const key = scope.hash(value); const instanceMap = this.#scopes.get(scope) ?? this.#scopes.set(scope, new Map()).get(scope)!; return instanceMap.get(key) ?? instanceMap.set( key, this.#createScopeInstance(scope, key, value, () => { instanceMap.delete(key); if (__DEV__) { devtoolsGlobalHook.emit("scopeInstanceUnmounted", { storeId: this.id, scope, key, }); } }), ).get(key)!; } #createBunjaInstance( id: string, value: unknown, effects: BunjaEffectCallback[], dispose: () => void, ): BunjaInstance { const effect = () => { const cleanups = effects .map((effect) => effect()) .filter(Boolean) as (() => void)[]; return () => cleanups.forEach((cleanup) => cleanup()); }; const bunjaInstance = new BunjaInstance(id, value, effect, () => { if (__DEV__) { devtoolsGlobalHook.emit("bunjaInstanceUnmounted", { storeId: this.id, bunjaInstanceId: id, }); } dispose(); delete this.#bunjas[id]; }); this.#bunjas[id] = bunjaInstance; if (__DEV__) { devtoolsGlobalHook.emit("bunjaInstanceMounted", { storeId: this.id, bunjaInstanceId: id, }); } return bunjaInstance; } #createScopeInstance( scope: Scope, key: unknown, value: unknown, dispose: () => void, ): ScopeInstance { if (__DEV__) { devtoolsGlobalHook.emit("scopeInstanceMounted", { storeId: this.id, scope, key, }); } return new ScopeInstance(value, dispose); } } export type ReadScope = (scope: Scope) => T; export function createReadScopeFn( scopeValuePairs: ScopeValuePair[], readScope: ReadScope, ): ReadScope { const map = new Map(scopeValuePairs); return (scope: Scope) => { if (map.has(scope as Scope)) { return map.get(scope as Scope) as T; } return readScope(scope); }; } export interface BunjaStoreGetResult { value: T; mount: () => () => void; deps: unknown[]; bunjaInstance?: BunjaInstance; } export function delayUnmount( mount: () => () => void, ms: number = 0, ): () => () => void { return () => { const unmount = mount(); return () => setTimeout(unmount, ms); }; } export class Bunja { private static counter: number = 0; readonly id: string = String(Bunja.counter++); debugLabel: string = ""; #phase: BunjaPhase = { baked: false, parents: new Set(), scopes: new Set() }; constructor(public init: () => T) {} get baked(): boolean { return this.#phase.baked; } get parents(): Bunja[] { if (this.#phase.baked) return this.#phase.parents; return Array.from(this.#phase.parents); } get relatedBunjas(): Bunja[] { if (!this.#phase.baked) throw new Error("Bunja is not baked yet."); return this.#phase.relatedBunjas; } get relatedScopes(): Scope[] { if (!this.#phase.baked) throw new Error("Bunja is not baked yet."); return this.#phase.relatedScopes; } addParent(bunja: Bunja): void { if (this.#phase.baked) return; this.#phase.parents.add(bunja); } addScope(scope: Scope): void { if (this.#phase.baked) return; this.#phase.scopes.add(scope); } bake(): void { if (this.#phase.baked) throw new Error("Bunja is already baked."); const scopes = this.#phase.scopes; const parents = this.parents; const relatedBunjas = toposort(parents); const relatedScopes = Array.from( new Set([ ...relatedBunjas.flatMap((bunja) => bunja.relatedScopes), ...scopes, ]), ); this.#phase = { baked: true, parents, relatedBunjas, relatedScopes }; } calcInstanceId(scopeInstanceMap: Map, ScopeInstance>): string { const scopeInstanceIds = this.relatedScopes.map( (scope) => scopeInstanceMap.get(scope)!.id, ); return `${this.id}:${scopeInstanceIds.join(",")}`; } toString(): string { const { id, debugLabel } = this; return `[Bunja:${id}${debugLabel && ` - ${debugLabel}`}]`; } } type BunjaPhase = BunjaPhaseUnbaked | BunjaPhaseBaked; interface BunjaPhaseUnbaked { readonly baked: false; readonly parents: Set>; readonly scopes: Set>; } interface BunjaPhaseBaked { readonly baked: true; readonly parents: Bunja[]; readonly relatedBunjas: Bunja[]; readonly relatedScopes: Scope[]; } export class Scope { private static counter: number = 0; readonly id: string = String(Scope.counter++); debugLabel: string = ""; constructor(public readonly hash: HashFn = Scope.identity) {} private static identity(x: T): T { return x; } bind(value: T): ScopeValuePair { return [this, value]; } toString(): string { const { id, debugLabel } = this; return `[Scope:${id}${debugLabel && ` - ${debugLabel}`}]`; } } export type HashFn = (value: T) => unknown; export type ScopeValuePair = [Scope, T]; abstract class RefCounter { #count: number = 0; abstract dispose(): void; add(): void { ++this.#count; } sub(): void { --this.#count; if (this.#count < 1) { this.dispose(); this.dispose = noop; } } } class BunjaInstance extends RefCounter { #cleanup: (() => void) | undefined; constructor( public readonly id: string, public readonly value: unknown, public readonly effect: BunjaEffectCallback, private readonly _dispose: () => void, ) { super(); } override dispose(): void { this.#cleanup?.(); this._dispose(); } override add(): void { this.#cleanup ??= this.effect() ?? noop; super.add(); } } class ScopeInstance extends RefCounter { private static counter: number = 0; readonly id: string = String(ScopeInstance.counter++); constructor( public readonly value: unknown, public readonly dispose: () => void, ) { super(); } } interface Toposortable { parents: Toposortable[]; } function toposort(nodes: T[]): T[] { const visited = new Set(); const result: T[] = []; function visit(current: T) { if (visited.has(current)) return; visited.add(current); for (const parent of current.parents) visit(parent as T); result.push(current); } for (const node of nodes) visit(node); return result; } const noop = () => {}; export interface BunjaDevtoolsGlobalHook { stores: Record; listeners: Record< BunjaDevtoolsEventType, Set<(event: any) => void> >; emit( type: T, event: BunjaDevtoolsEvent[T], ): void; on( type: T, listener: (event: BunjaDevtoolsEvent[T]) => void, ): () => void; } export interface BunjaDevtoolsEvent { storeCreated: { storeId: string }; storeDisposed: { storeId: string }; getCalled: { storeId: string; bunjaInstanceId: string }; bunjaInstanceMounted: { storeId: string; bunjaInstanceId: string }; bunjaInstanceUnmounted: { storeId: string; bunjaInstanceId: string }; scopeInstanceMounted: { storeId: string; scope: Scope; key: unknown; }; scopeInstanceUnmounted: { storeId: string; scope: Scope; key: unknown; }; } export type BunjaDevtoolsEventType = keyof BunjaDevtoolsEvent; let devtoolsGlobalHook: BunjaDevtoolsGlobalHook; if (__DEV__) { if ((globalThis as any).__BUNJA_DEVTOOLS_GLOBAL_HOOK__) { devtoolsGlobalHook = (globalThis as any).__BUNJA_DEVTOOLS_GLOBAL_HOOK__; } else { devtoolsGlobalHook = { stores: {}, listeners: { storeCreated: new Set(), storeDisposed: new Set(), getCalled: new Set(), bunjaInstanceMounted: new Set(), bunjaInstanceUnmounted: new Set(), scopeInstanceMounted: new Set(), scopeInstanceUnmounted: new Set(), }, emit: (type, event) => { for (const fn of devtoolsGlobalHook.listeners[type]) fn(event); }, on: (type, listener) => { devtoolsGlobalHook.listeners[type].add(listener); return () => devtoolsGlobalHook.listeners[type].delete(listener); }, }; (globalThis as any).__BUNJA_DEVTOOLS_GLOBAL_HOOK__ = devtoolsGlobalHook; } }