npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

frontdi

v2.0.0

Published

Dependency resolution library with weak shared cached objects and cascade cache invalidation.

Readme

frontdi

Deterministic dependency resolution with shared object identity, cascading invalidation, runtime cycle detection, and GC-aware lifecycle hooks

frontdi is a lightweight dependency-resolution library for building stable object graphs from async data sources.

It gives you:

  • ♻️ Shared object instances by key
  • Weakly-cached descriptors
  • 🧠 Automatic dependency graph tracking
  • 🔄 Cascading invalidation
  • 🚫 Runtime cycle detection
  • 🧩 Composable async resolvers
  • 📦 JSON-serializable keys
  • 🛠 fetch() + build() pipeline
  • 🧪 Works with provided data without calling fetch
  • 🗑 Garbage-collection lifecycle hooks

Why it matters

The core idea of frontdi:

As long as an object is not invalidated — the exact same instance is returned everywhere

If two parts of your app resolve:

userResolver.resolve({ key: 1 })

they receive the same descriptor and eventually the same object reference.

That means:

  • identity consistency
  • shared mutations
  • memoization-friendly behavior
  • stable references for UI/state systems
  • no accidental duplicate entities
const aDesc = userResolver.resolve({ key: 1 })

const a = await aDesc.res

const b = await userResolver.resolve({ key: 1 }).res

console.log(a === b) // true

After invalidation:

userResolver.invalidateKey(1)

const c = await userResolver.resolve({ key: 1 }).res

console.log(c === a) // false

This makes frontdi behave closer to an identity map + dependency graph than a simple async cache.


Installation

npm i frontdi

Quick Example

import { createResolver } from 'frontdi'

type Key = number

interface UserData {
  id: number
  username: string
  address: AddressData,
  companyId: number
}

class User {
  public invalidated: boolean = false
  constructor(
    public id: number,
    public username: string,
    public address: Address,
    public company: Company
  ) {}
}

const userResolver = createResolver<Key, UserData, User>({
  fetch: getUser,

  build: async ({ data, ctx, key, self }) => {
    // resolve dependencies using the SAME ctx

    const company = await companyResolver.resolve({
      key: data.companyId,
      ctx,
    }).res

    const address = await addressResolver.resolve({
      key,
      data: data.address,
      ctx,
    }).res

    const user = new User(
      data.id,
      data.username,
      address,
      company
    )

    const ref = new WeakRef(user)
    const unsubscribe = someSource.subscribe(user.id, (changes)=>{
      const user = ref.deref() // avoid strong refs to result/self/context
      if(user){
        //
      }
    })

    self.invalidated.then((u) => {
      unsubscribe()
      u.invalidated = true
    })

    self.garbageCollected.then(() => {
      unsubscribe()
      // triggered when descriptor becomes unreachable
      // cleanup may happen via invalidation OR GC
      //🚫user.invalidated = true; avoid strong refs to result/self/context
    })

    return user
  },
})

const user = await userResolver.resolve({ key: 1 }).res

const same = await userResolver.resolve({ key: 1 }).res

//uses cached value
console.log(user === same) // true

// user depends on company
companyResolver.invalidateKey(user.company.id)

const updated = await userResolver.resolve({ key: 1 }).res

console.log(updated === user) // false

Resolver lifecycle

Each resolver produces a Descriptor:

export interface Descriptor<T> {
  res: Promise<T>
  invalidated: Promise<T>
  invalidate: Invalidate
  garbageCollected: Promise<void>
}

descriptor.res

Resolves to the built object.

const user = await descriptor.res

descriptor.invalidated

Resolves after the descriptor is invalidated.

Useful for:

  • subscriptions
  • reactive systems
  • explicit resource disposal
  • dependency-driven rebuilds
descriptor.invalidated.then((target) => {
  console.log(target, 'descriptor invalidated')
})

descriptor.garbageCollected

Resolves when the descriptor is garbage collected.

Useful for:

  • weak-resource cleanup
  • cache-adjacent systems
  • diagnostics
  • non-critical disposal logic
descriptor.garbageCollected.then(() => {
  console.log('descriptor collected')
})

descriptor.invalidate()

Marks the descriptor as stale.

Important:

resolver.invalidate(key) is intended for cases where the underlying object became outdated because of external mutations or side effects. descriptor.invalidate() does not invalidate key always.

Examples:

  • websocket updates
  • manual object mutation
  • external store changes
  • server-side updates
  • invalidated subscriptions

It is NOT required for garbage collection.

If nothing references the descriptor anymore, it may still be collected naturally by the GC.


Resolver API

export interface Resolver<KEY, DATA, T extends object> {
  invalidateKey(key: KEY): void

  resolve(
    args: ResolveArgs<KEY, DATA>
  ): Descriptor<T>
}

Key system

Keys can be ANY JSON-serializable object

Examples:

type Key = number
type Key = {
  left: number
  right: number
}
type Key = {
  userId: number
  filters: {
    active: boolean
    page: number
  }
}

Internally, keys are normalized deterministically.

That means:

{ a: 1, b: 2 }

and

{ b: 2, a: 1 }

produce the same cache identity.


Important recommendation for arrays

If array order is NOT semantically important:

['b', 'a']

vs

['a', 'b']

should ideally be sorted before resolving.

Example:

const tags = [...inputTags].sort()

resolver.resolve({
  key: { tags }
})

Otherwise they are treated as different keys.


API

createResolver(rule)

function createResolver<KEY, DATA, T extends object>(
  rule: ClientRule<KEY, DATA, T>
): Resolver<KEY, DATA, T>

Rule definition

type ClientRule<KEY, DATA, T extends object> = {
  fetch: (key: KEY) => Promise<DATA> | DATA

  build: (
    info: BuildInfo<KEY, DATA, T>
  ) => Promise<T> | T
}

resolve(args)

resolve(
  args: ResolveArgs<KEY, DATA>
): Descriptor<T>
type ResolveArgs<KEY, DATA> = {
  key: KEY
  data?: DATA
  ctx?: IContext
}

Behavior

With data

resolver.resolve({
  key,
  data
})
  • fetch() is skipped
  • build() runs using provided data

Without data

resolver.resolve({
  key
})

Flow:

fetch(key)
   ↓
build(...)
   ↓
cached descriptor

Dependency tracking

Resolvers automatically build a dependency graph through shared ctx.

const user = userResolver.resolve({ key, ctx })

const posts = postsResolver.resolve({ key, ctx })

Dependencies are recorded during build().

This enables:

  • cascading invalidation
  • cycle detection
  • dependency-aware rebuilds

Cascading invalidation

If:

User -> Company -> Address

and Company is invalidated:

companyResolver.invalidateKey(key)

then dependent User descriptors are invalidated automatically.

This guarantees graph consistency.


Cycle detection

frontdi detects:

Self-reference

A -> A

Dependency cycles

A -> B -> A

In those cases:

await resolver.resolve(...).res

rejects with:

Cycle detected

Cache semantics

Cached by resolver + normalized key

Repeated calls:

resolver.resolve({ key })

return the SAME descriptor instance until invalidation.

const d1 = resolver.resolve({ key: 1 })

const d2 = resolver.resolve({ key: 1 })

console.log(d1 === d2) // true

Invalidating by key

invalidateKey(key)

resolver.invalidateKey(key)

Behavior:

  • invalidates cached descriptor by key
  • removes it from cache
  • cascades invalidation to dependents
  • next resolve() rebuilds fresh state

Example:

userResolver.invalidateKey(1)

Best practices

Always pass ctx inside build()

childResolver.resolve({
  key,
  ctx,
})

Without shared context, dependency tracking will not work.


Prefer deterministic keys

Good:

{
  page: 1,
  sort: 'desc'
}

Better with arrays:

{
  tags: [...tags].sort()
}

Use invalidation only for stale state

invalidate() and invalidateKey() are for rebuilding stale objects after external changes.

They are not lifecycle requirements for cleanup or memory release.

Garbage collection works independently.


Example architecture

User
 ├── Company
 ├── Address
 │     └── Geo
 └── Posts
       └── Comments

Each resolver composes others using shared ctx.

frontdi tracks the graph automatically.


Use cases

Perfect for:

  • frontend entity graphs
  • normalized async stores
  • SDK clients
  • reactive state systems
  • GraphQL-like composition
  • client-side repositories
  • dependency-aware caches
  • identity-mapped data layers

TypeScript notes

T extends object

is required because descriptors track object references internally.


License

MIT