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

@sylwellsoftware/glue

v0.10.0

Published

Platform-neutral reactive values, derived state, live queries, and causal tracing.

Readme

Glue

Glue is a small, platform-neutral reactive value and live-query library. Its current 0.x releases follow documented migration guidance. Fray uses it as its state/data-flow layer, but Glue does not depend on Fray, a DOM, or any UI framework. Its implementation and tests are strict TypeScript; the ESM build includes declarations and declaration maps.

Install with pnpm:

pnpm add @sylwellsoftware/glue

Glue is ESM-only. Core emitters, derived state, and diagnostics support Node 22+ and modern ESM runtimes without a DOM. LiveQuery needs AbortController, and RestQueryHandler needs Fetch and URL capabilities unless they are injected.

Why Glue

Most application values come from a service query, direct user input, or a calculation over those sources. Those values and their status are real state, but developers should not have to construct and synchronize a separate framework-shaped copy of them so that consumers can react.

Glue keeps each value at its natural boundary. A control can write an Emitter, a calculation can expose a DerivedEmitter, and a query can react to argument emitters while exposing its result, loading state, and error. A consumer reads the downstream value it needs without knowing whether it began as input, computation, or remote data.

For example, a table header may write a sort emitter. A LiveQuery uses that emitter as an argument, retrieves fresh rows, and emits the new result to the table. The developer declares this meaningful relationship; Glue handles propagation, current snapshots, cancellation, and stale-result protection. Mutation authority, domain rules, transport encoding, service construction, and ownership/disposal remain explicit application responsibilities.

Design model

Glue models values that stay current rather than requests that callers must manually rerun and redistribute. The same small read-side protocol applies to local state, computed state, query inputs, and asynchronous results:

interface ReadableEmitter<TValue, TError = unknown> {
    get(): TValue
    getFetchState(): FetchStateValue
    getError(): TError | null
    subscribe(listener: (notification: {
        value: TValue
        fetchState: FetchStateValue
        error: TError | null
        event: EventBubble<unknown> | null
    }) => void): () => void
}

That uniformity is the central design constraint. It lets a consumer bind to a current value without knowing whether the value is mutable, derived, or backed by asynchronous retrieval. Richer responsibilities remain separate:

| Concept | Responsibility | | --- | --- | | BaseEmitter | Synchronously readable value/snapshot, subscriptions, mapping, equality, diagnostics, and disposal | | Emitter | An explicitly writable leaf value | | DerivedEmitter | A cached value computed from one or more readable emitters | | QueryArg | A named query-input view over another emitter when a semantic name is useful | | LiveQuery | Reactive request timing, latest-request ownership, status/error state, and cached results | | LiveResult | Common read/dispose contract for remote and locally derived endpoint results | | QueryHandler | Non-reactive retrieval strategy over a plain named argument object | | RestQueryHandler | HTTP URL construction, wire serialization, Fetch execution, and JSON result retrieval | | QueryEndpoint | Immutable declaration that opens caller-owned queries through any handler | | RestEndpoint | Immutable REST query declaration with optional response parsing | | DerivedEndpoint | Immutable local projection declaration that opens caller-owned live results | | AsyncCommand | Abortable mutation lifecycle with explicit concurrency policy | | EventBubble / EventBus | Optional cause-and-effect diagnostics without owning application history |

The intended flow is explicit and one-directional:

Emitter(s) ──► DerivedEmitter(s) ──► named query arguments
                                           │
                                           ▼
                                      LiveQuery
                                           │ current plain values
                                           ▼
                                      QueryHandler
                                           │
                                           ▼
                           value + fetch state + error

This separation prevents several kinds of accidental coupling:

  • leaf values do not need to know that a distant consumer may use them in a request;
  • derived values express semantic computation rather than transport encoding;
  • LiveQuery decides when the current inputs require retrieval, while its handler decides how retrieval works;
  • REST-specific formats, base URLs, authentication wrappers, and response validation remain at application/adapter boundaries;
  • one live query can be mapped into multiple local views without multiplying network requests.

Glue deliberately favors explicit graphs over hidden tracking, proxy-created state, hooks, or a global store. If a value can be computed, prefer a DerivedEmitter to manually mirroring it. Use callbacks for commands and emitters for state that other objects need to read, combine, or observe.

Emitters

import {DerivedEmitter, Emitter, FetchState} from '@sylwellsoftware/glue'

const count = new Emitter(1, {purpose: 'count'})
const doubled = new DerivedEmitter([count], ([value]) => value * 2)

const unsubscribe = doubled.subscribe(({value, fetchState, error, event}) => {
  console.log({value, fetchState, error, event})
})

count.set(2)
unsubscribe()
doubled.dispose()

A local Emitter defaults to FetchState.Ready. setWithState() notifies when the value, fetch state, or error changes. Values use Object.is equality unless the equals option supplies a comparator. subscribe() emits the current snapshot by default; subscribe(listener, {emitCurrent: false}) and subscribeFutureValues(listener) observe only future snapshots. Unsubscribe functions and dispose() are idempotent.

Every notification has one shape:

{ value, fetchState, error, event }

DerivedEmitter passes source values—including null and undefined—to its compute function in source order. With no sources it computes once with [] and is ready. A thrown compute function produces error state. Source errors are a stable array of {sourceIndex, error} entries; a compute failure uses sourceIndex: null. State precedence is error > loading > initial > ready. Replacing sources releases every old subscription, and disposal releases the current ones.

emitter.map(fn) transforms the complete value. emitter.mapEach(fn) requires an array and transforms its non-nullish members.

Ownership, equality, and disposal

Emitters eagerly cache their current snapshot so reads are synchronous. Object.is is the default value equality rule; pass equals when the domain has a better equivalence relation. A derived emitter subscribes eagerly to its sources and releases those subscriptions when sources are replaced or the derived value is disposed.

The object that creates a long-lived emitter/query normally owns its disposal. Disposal is idempotent, prevents new subscriptions, and releases owned source subscriptions. A UI or service lifecycle should therefore dispose the graph it constructs rather than relying on garbage collection to sever active edges.

Query arguments

LiveQuery accepts a named record of any readable emitters, so wrapping every input in QueryArg is neither required nor desirable. Use QueryArg when a stable query-facing name and separately owned bridge clarify the boundary:

import {DerivedEmitter, Emitter, LiveQuery, QueryArg} from '@sylwellsoftware/glue'

const firstName = new Emitter('Ada')
const lastName = new Emitter('Lovelace')
const searchText = new DerivedEmitter(
    [firstName, lastName] as const,
    ([first, last]) => `${first} ${last}`,
)
const search = new QueryArg('search', searchText)
const users = new LiveQuery({handler, args: {search}})

Here the leaf emitters know nothing about querying, and the computation knows nothing about REST. Dispose users, search, and searchText at the lifetime boundary that created them.

Live queries

import {Emitter, LiveQuery, RestQueryHandler} from '@sylwellsoftware/glue'

const search = new Emitter('ada')
const handler = new RestQueryHandler({
  url: '/api/users',
  baseUrl: 'https://example.test/',
  fetch: globalThis.fetch,
})

const users = new LiveQuery({handler, args: {search}})

Arguments are a named record of emitters. By default construction fetches immediately and later argument changes refresh. The named execution policy makes other timing explicit:

| Policy | Initial behavior | Later behavior | | --- | --- | --- | | immediate | Fetch immediately. | Arguments and configured polling refresh automatically. | | deferred | Stay FetchState.Initial with no argument subscriptions, request, or poll until activate() (or a dormant refresh()). | Become an ordinary reactive query after the first activation attempt. | | explicit | Stay FetchState.Initial. | Fetch only through refresh()/retry(); never react to arguments and reject polling. |

const deferredUsers = new LiveQuery({
    handler,
    args: {search},
    execution: 'deferred',
})
await deferredUsers.activate('users view mounted')

Deferred activation is one-way, idempotent, and independent of whether the first request succeeds. Concurrent activators share the first request, which uses the current argument values. Subscribing to the query does not activate it. A later activate() does not reload; use refresh() for an intentional reload.

autoFetch: false remains a compatibility option with its historical narrow meaning: it skips only the constructor request while argument and polling triggers are already live. It cannot be combined with execution; new code should choose a named policy.

refresh() and retry() return the active request promise. A newer request aborts and supersedes the older request, and stale results cannot overwrite current state. abort() cancels without disposing; dispose() aborts the active request and releases argument/polling subscriptions.

By default the last successful value remains visible while refreshing and after a refresh error. Set keepPreviousValue: false to clear it while loading or in error state.

Polling is opt-in and waits one full interval before the first poll:

const pollingEnabled = new Emitter(true)
const intervalMs = new Emitter(5_000)
const users = new LiveQuery({
    handler,
    args: {search},
    polling: {enabled: pollingEnabled, intervalMs},
})

enabled and intervalMs may be constants or readable emitters. A changed control restarts the timer from that change. A tick is skipped while a request is active — including one waiting in retry backoff — and the next normal tick remains scheduled. Without a retry policy, errors do not cause an immediate retry or backoff, but polling continues while enabled. Disposal releases the timer and control subscriptions. Applications can feed page visibility or any other policy into enabled; Glue never reads the DOM. Tests and nonstandard runtimes may inject PollingScheduler.

Retry policies

LiveQuery and AsyncCommand accept an opt-in retry policy. The policy's presence is the opt-in: absent means a single attempt, and null explicitly disables a policy inherited from an endpoint declaration.

const users = new LiveQuery({
    handler,
    args: {search},
    retry: {
        maxAttempts: 3,
        delayMs: 500,
        backoff: 'exponential',
        maxDelayMs: 30_000,
        shouldRetry: (error) => error instanceof TypeError,
    },
})

maxAttempts counts the first attempt (default 3). backoff is 'fixed', 'exponential' (doubling, the default), or a custom (attempt, error) => milliseconds function whose result is used as-is — for example to honor a Retry-After value carried on the error. Built-in backoff is capped by maxDelayMs (default 30s) and jitter applies full jitter to the computed delay (default on). shouldRetry(error, attempt) decides whether a failed attempt is retried; the default retries any non-abort error. Glue never retries an AbortError from a handler.

A request stays in FetchState.Loading across attempts and settles Error only when attempts are exhausted or shouldRetry declines; each scheduled retry emits a trace event. Abort, disposal, and superseding requests cancel the pending retry timer. Retrying a non-idempotent AsyncCommand executor can apply a mutation more than once — pair it with shouldRetry. Tests may inject a scheduler with the same schedule/cancel shape as PollingScheduler. If shouldRetry, custom backoff, or the injected scheduler throws, Glue treats that as a terminal retry-infrastructure error rather than leaving the operation in Loading; AsyncCommand applies its normal mapError function.

The REST adapter accepts injected fetch, baseUrl, and serialize behavior. Its generic serializer omits undefined and empty arrays, encodes null as an empty value, repeats keys for arrays, JSON-encodes objects, and stringifies scalars. Application-specific table filter/sort formats belong in an injected serializer, not Glue. A result generic alone does not validate JSON. Supply parseResult(json: unknown) to decode or validate immediately after JSON parsing. A thrown parser error becomes the LiveQuery error, and Glue does not include the raw response body in its diagnostics.

Service endpoint declarations

Applications may group immutable endpoint declarations in ordinary service classes. Glue does not register, locate, construct, or cache services; the application chooses and constructs its service scope. Fray applications may expose those services through Fray's typed runtime ServiceScope; non-Fray applications use their own explicit composition. Every open() call creates a caller-owned result with independent arguments, request state, polling, and disposal. Endpoint query defaults and per-open() options accept the same execution policy as LiveQuery, and query.retry declares a shared retry default that per-open() options override or disable with null.

import {DerivedEndpoint, RestEndpoint} from '@sylwellsoftware/glue'

class MovieService {
    readonly movies = new RestEndpoint<{genre: string}, readonly Movie[]>({
        url: '/api/movies',
        parseResult: parseMovies,
    })

    readonly matchingMovies = new DerivedEndpoint<
        readonly Movie[],
        {genre: string},
        readonly Movie[]
    >({
        apply: (movies, {genre}) => movies.filter((movie) => movie.genre === genre),
    })
}

const service = new MovieService()
const remote = service.movies.open({genre})
const local = service.matchingMovies.open({source: cachedMovies, args: {genre}})

Both results implement LiveResult, so a UI that only reads value, fetch state, error, and subscriptions can accept either. LiveQuery additionally implements RefreshableLiveResult with refresh(), retry(), and abort(). The queryEndpoint, restEndpoint, and derivedEndpoint factory functions are equivalent construction frontends when an application prefers function declarations to new QueryEndpoint(), new RestEndpoint(), and new DerivedEndpoint().

For a query-like body protocol such as GraphQL, put a custom handler in a QueryEndpoint. The handler owns method, headers, authentication, body serialization, response checks, validation, and safe diagnostics:

import {QueryEndpoint} from '@sylwellsoftware/glue'

const projectStatus = new QueryEndpoint<{id: string}, ProjectStatus>({
    handler: {
        async fetch({id}, {signal} = {}) {
            const response = await fetch('/graphql', {
                method: 'POST',
                headers: {'content-type': 'application/json'},
                body: JSON.stringify({query: STATUS_QUERY, variables: {id}}),
                signal,
            })
            if (!response.ok) throw new Error(`HTTP ${response.status}`)
            return parseProjectStatus(await response.json())
        },
    },
})

Ordinary POST/PUT/PATCH/DELETE mutations belong in AsyncCommand executors, not auto-running query declarations.

Optional tracing

Tracing allocates no event when an emitter has trace: false, no diagnostic observer is subscribed, and no parent event is supplied. Subscribe with EventBus.subscribe(listener) to observe top-level events. Events have stable process-local IDs, timestamps, weak owner references where supported, and explicit parent/child causality. The bus retains neither event history nor owners; its unsubscribe function is idempotent.

Tracing follows the same design as data flow: mutation, derivation, query start, and query completion can retain explicit parent/child causality without turning diagnostics into a second execution system. Applications decide whether to retain, render, or export observed events.

Async commands

AsyncCommand is exported from Glue's package root. It is an abortable mutation lifecycle with explicit ignore, replace, and reject concurrency policies. It exposes the last result/error through the standard emitter snapshot and a read-only isRunning view, which stays true across retry attempts. It deliberately does not own batch progress, notifications, or UI behavior; retries are opt-in through the shared RetryPolicy contract. Executor completion alone determines command success. The application may then refresh affected queries; their failures remain in their own query snapshots:

const saved = await saveCommand.run(update)
if (saved !== undefined) {
    await Promise.all([users.refresh('save reconciled'), audit.refresh('save reconciled')])
}

Integration with Fray and other consumers

Glue's UI seam is intentionally just the readable/writable emitter protocol. A typical Fray path is:

browser event
    └──► Fray control writes an Emitter
              └──► DerivedEmitter computes shared/domain state
                        ├──► Fray renders a local view
                        └──► LiveQuery refreshes through a handler
                                      └──► Fray renders query snapshot state

Leaf controls should normally receive ordinary writable emitters, not QueryArg objects. The component or service that understands an aggregate interaction owns its derived value. The data-aware consumer owns the query bridge and watches the result it actually renders. This keeps UI components reusable for local state, static data, remote data, and tests.

Fray's theme and color selection is not a Glue feature. An application may store selected theme/color identifiers in ordinary Glue emitters when it wants observable or persistent selection state, but Fray and the browser remain responsible for CSS assets, stylesheet links, and rendering.

Nothing in this contract is Fray-specific: another UI framework, a CLI, a Node service, or a test can consume the same emitters and live queries.

Complete export groups

| Area | Public exports | | --- | --- | | Values | BaseEmitter, Emitter, DerivedEmitter, readable/snapshot/notification option and inference types | | State | FetchState, FetchStateValues, combineFetchStates | | Queries | QueryArg, LiveQuery, LiveResult, RefreshableLiveResult, polling and argument types | | Handlers | QueryHandler, RestQueryHandler, handler/fetch/URL/serializer/parser contracts | | Endpoints | QueryEndpoint, RestEndpoint, DerivedEndpoint, DerivedLiveResult, lowercase factory functions and option types | | Commands | AsyncCommand, AsyncCommandConcurrencyError, executor/context/concurrency option types | | Diagnostics | EventBubble, EventBus, EventOptions, EventListener, BubbleGraph | | Utilities | NonEmptyArray |

All runtime exports and public types are available from the package root. Glue does not expose implementation subpath entry points.

Local checks

pnpm --filter @sylwellsoftware/glue test
pnpm --filter @sylwellsoftware/glue typecheck
pnpm --filter @sylwellsoftware/glue build
pnpm --filter @sylwellsoftware/glue test:types:consumer

Glue intentionally does not own a DOM renderer, component lifecycle, application-specific query encoding, persistent event history, CommonJS build, or framework adapter. Fray consumes Glue as a peer; browser UI belongs there.

See the workspace overview, API surface, changelog, contribution guide, and security policy.