@greendrake/vue-api

Vue binding of @greendrake/rpc: loading-state wiring, response cache, error-UX pipeline, silent calls, live bindings to server push
Install
npm install @greendrake/vue-api
Latest Version:0.11.0
Vue:3
License:MIT
Last Updated:Oct 21, 2026
Links: GitHub  ·  npm
Maintainer: greendrake

@greendrake/vue-api

The Vue/Pinia binding of @greendrake/rpc. ApiClient adds loading-state wiring, a response cache, an error-UX pipeline (one blocking retry/acknowledge toast), offline parking and per-code error handlers to a typed method map; alongside it a connectivity store, a retry helper for plain fetches, and a component-scoped WebSocket push subscription.

Install

bun add @greendrake/vue-api vue pinia

vue (^3.5.34) and pinia (^3.0.4) are peers. The package ships TypeScript source and one Vue SFC with an SCSS style block as-is (no build step, no .d.ts): the consuming Vite + Vue 3 app compiles them, so it needs @vitejs/plugin-vue and a Sass implementation in its toolchain. @greendrake/rpc, @greendrake/ui (the toast, and its ok and cancel strings), @greendrake/util and @greendrake/vue-kit (the global loading state) install as dependencies.

The app's api.ts

One module declares the typed method map, builds the transport and exports the client; every store and component imports from it.

// src/api.ts
import { HttpTransport } from '@greendrake/rpc'
import { ApiClient } from '@greendrake/vue-api'
import type { Host, HostQuery, ListResult, Vlan } from './types'

export interface Methods {
    'host.list': (query: HostQuery) => ListResult<Host>
    'host.get': (id: number) => Host
    'host.update': (record: { id: number } & Partial<Host>) => Host
    'host.delete': (args: { id: number }) => void
    'vlan.list': () => Vlan[]
}

export const api = new ApiClient<Methods>({
    transport: new HttpTransport({
        url: __API_URL__,
        headers: () => ({ 'x-api-key': __API_KEY__ })
    })
})

The same with a WebSocket transport, in-band session auth and server push:

// src/api.ts
import { WebSocketPipe, WsTransport, tokenAuth } from '@greendrake/rpc'
import { ApiClient, bubbled, configureApiUX } from '@greendrake/vue-api'

export interface Methods {
    'taxonomy.tree': (args: { taxonomy: string }) => TaxonomyNode[]
    'thread.message.create': (args: { thread_id: string; body: string }) => { message: Message }
}

export interface Pushes {
    'thread.message': ThreadMessageEvent
}

export const transport = new WsTransport<Pushes>({ pipe: new WebSocketPipe({ url: __WS_URL__ }), auth: tokenAuth })

// This socket carries every call, so it is the app's connectivity oracle.
configureApiUX({ connectionSource: transport })

export const api = new ApiClient<Methods>({
    transport,
    locale: () => i18n.global.locale.value,
    cache: { ttl: 300, endpoints: { 'taxonomy.tree': true } },
    errorHandlers: {
        SESSION_EXPIRED: () => session.end(),
        ...bubbled(['INVALID_ARGUMENT'])
    }
})

Calls from components and stores:

import { ref } from 'vue'
import { api } from '@/api'

const host = await api.call('host.get', 42) // Host; the global spinner shows while pending

const saving = ref(false)
await api.with({ pending: saving }).call('host.update', { id: 42, name: 'nas' }) // this ref instead

const vlans = await api.with({ silent: true }).call('vlan.list') // no spinner, no toast; errors reject

call(method, ...args) takes a method's own arguments, as RpcClient.call does (@greendrake/rpc's Caller), and a method that takes none is called with none. with(options) is the calls made under CallOptions: a separate entry point, because nothing at runtime tells trailing options from a last argument. ApiCaller<M> is the two together, for a library typing the client it is handed.

ApiClient

ApiClientOptions:

  • transport — any @greendrake/rpc Transport.
  • cache?: CacheConfig — the @greendrake/rpc ResponseCache config. The endpoint list decides two things at once: a listed method is served from and stored in the cache and goes out without a nonce; every other method carries a nonce and is never cached. List only stateless, platform-static reads.
  • errorHandlers?: Record<string, (e: ApiError) => void> — keyed by the code of the ApiError a call fails with (a NotFoundError included), consulted before the toast. A handler that throws bubbles the error to the caller with its details intact, bypassing the toast; a handler that returns parks the call forever — its side effect (logout, redirect) supersedes the call's flow. bubbled(codes, as?) is the handlers bubbling each of codes, as as turns the error (the error itself by default).
  • locale?: () => string | undefined — the UI language tag put in every request envelope, evaluated per call.

CallOptions:

  • pending — undefined: the call holds a slot in @greendrake/vue-kit's useAppState loading counter (the global spinner). A Ref<boolean>: that scoped busy flag is set instead. false: no loading indication.
  • validate: (result) => boolean | Error — rejects a result the server calls success: a returned Error is thrown, false throws UnacceptableResultError. Feeds the same retry pipeline as a failure.
  • silent: true — no loading indication and no error UX; errors reject straight to the caller. For background calls and callers owning their own error presentation.
  • cache: false — reads past the cache and does not store the answer. Cannot opt an unlisted method in and leaves the nonce rule alone.

Cache access on the client: invalidateCache(method, ...args) (with args, that entry; without, the whole method), clearCache().

setDebugDelay(method, ms) — dev builds only: holds a call pending for ms so its loading state can be inspected; 0 clears. Inert in production.

Error-UX pipeline

A non-silent call that fails, and that no error handler claimed, raises the failure on useExceptionState(), which shows one blocking toast at a time (@greendrake/ui's showToast, variant: 'warning', blocking). The toast's kind is classifyException(e):

kind error toast
notfound NotFoundError acknowledge-only — asking again would ask for what is not there: "To the home page" when nothing usable is on screen yet, otherwise "OK" and the visitor stays put
network NetworkError (fetch failure, socket closed) Try again / Cancel
other everything else Try again / Cancel; also reported to the error sink and logged with console.warn

Retry re-runs the same call (the button reads "Trying..." until the retried attempt settles, and a repeat failure counts up in the message); Cancel rejects the call's promise with the original error. A call's success clears its own toast, never one raised by a concurrent call. A same-kind failure while a toast is showing re-drives the existing toast rather than stacking another.

useExceptionState(): state exception: ExceptionKind ('network' | 'notfound' | 'other' | false); action fail(e: Error | false, options?: FailOptions), where false clears the toast. FailOptions: retry?: () => Promise<void>, cancel?: () => void, onDismiss?: () => void (runs when a not-found's toast is acknowledged), reportContext?: Record<string, unknown> (attached to the error report). An app raises its own errors through the same store, e.g. fail(new NotFoundError()) from a route that resolved to nothing.

Boot wiring, configureApiUX({ errorSink?, goHome?, connectionSource? }):

  • errorSink: ErrorSink — (error, context?) => void; receives other-kind failures and abnormal WebSocket closes (WebSocketClosedError.isAbnormal) for reporting. Nothing is reported without one.
  • goHome — the not-found toast's home action; default location.assign('/'). getGoHome() returns the configured function for modules that navigate home outside a component.
  • connectionSource: ConnectionSource — { readonly connected: boolean; onConnectionChange(listener) }, read once when useConnectivity first materialises, so configure it before the store is first used. @greendrake/rpc's WsTransport satisfies it as-is.

apiUXHooks is { onRouter(router), onBootError(error), onRouteFetchError() }, for an app bootstrap that offers those hooks (@greendrake/vue-app's createSpaApp does): onRouter sets goHome to router.push('/') the moment the router exists, onBootError shows a failed boot through useExceptionState().fail, and onRouteFetchError abandons a navigation whose data fetch failed, quietly — the fetch went through the client, whose toast has already shown the failure. The router is typed structurally ({ push(to: string): unknown }), so this package takes no router dependency.

Strings

Every string this package shows is in one reactive apiMessages object (ApiMessages), built with @greendrake/ui's configurableMessages. configureApiMessages(overrides) merges overrides in; call it at boot and again on locale change. Keys and defaults:

  • the toast's: errorOther "Oops.. Sorry, something went wrong", errorNetwork "Looks like a network connectivity issue", errorNotFound "The requested page not found", tryAgain, trying "Trying...", toHomePage "To the home page". Its OK and Cancel are @greendrake/ui's ok and cancel.
  • the connection's (see useConnectionMessage): deviceOffline "You appear to be offline — waiting for your connection…", unreachable "Cannot reach the site — waiting for the connection…".

Offline parking

A non-silent call that fails with a NetworkError while useConnectivity().offline is true is parked instead of toasted, provided re-issuing it cannot double-apply: it carries no nonce (a cacheable read), or the transport rejected it before writing it (WebSocketClosedError with inFlight === false). A parked call releases its global loading slot — a scoped pending ref stays on, so the submitting control stays disabled — waits on whenOnline(), and re-runs once per reconnection. A nonce-carrying request that was on the wire when the socket went may have executed server-side with only its response lost, so it keeps the toast and the decision stays with the user.

Connectivity

useConnectivity() is a Pinia setup store:

  • offline — calls cannot currently succeed. Driven by the configured ConnectionSource; with none, by navigator.onLine and the window's online/offline events. With a source it starts false (not yet known), so the boot handshake does not flash offline surfaces.
  • confirmedOffline — offline and a reconnect attempt has failed too (the second consecutive false report). Surfaces that interrupt a working screen wait for this, so a socket that reconnects on its first try passes unremarked. Without a source the device signal confirms itself.
  • deviceOffline — the device's own claim. With a source it is never authoritative; it only picks wording.
  • whenOnline(): Promise<void> — resolves the moment calls can succeed; immediately when they already can.

useConnectionMessage() is the message an offline surface shows, as a computed string: apiMessages.deviceOffline while the device says it is offline, apiMessages.unreachable otherwise — "your connection is gone" and "the site cannot be reached" being different things to tell someone, and the device's claim the one thing telling them apart.

retryWhenOnline(attempt) re-runs attempt each time connectivity returns, for fetch sites that bypass ApiClient (static content, locale files) and would otherwise be a dead end after an offline miss. Only a NetworkError while offline is retried; anything else propagates on the first attempt.

import { fetchJson } from '@greendrake/util'
import { retryWhenOnline } from '@greendrake/vue-api'

const strings = await retryWhenOnline(() => fetchJson<Strings>(`/locales/${locale}.json`))

keepConnected<Methods>(transport, method) keeps a WsTransport (any Revivable: probe and pipe.reconnectNow) connected for the page's life and returns the teardown. The window's online event collapses a waiting reconnect's delay. While the page is visible — on becoming visible, then every 60 s — it reconnects a socket waiting out a backoff and probes with method, so a socket that died silently (a backgrounded app, an expired NAT mapping) is abandoned and reconnected. Nothing runs while the page is hidden. method is a key of the app's method map: a probe takes any answer, a refusal included, as proof of life, so a method the server lacks would report every socket alive.

keepConnected<Methods>(transport, 'app.ping')

Live updates

bindLive(transport, options) is @greendrake/rpc's liveBinding under this package's UX policy, and useLive is the component-scoped form that releases on unmount.

import { backgroundCall, useLive } from '@greendrake/vue-api'
import { api, transport } from '@/api'

useLive(transport, {
    events: {
        'thread.message': { when: pushed => pushed.thread_id === props.threadId }
    },
    resync: async () => {
        thread.value = await api.with(backgroundCall(true)).call('thread.get', props.threadId)
    }
})

The mechanism — relevance, apply versus resync, the reconnect re-read, coalescing, release — is documented in @greendrake/rpc. What this package adds is what happens around a resync:

  • It runs unseen. backgroundCall(background) is the one place that decides what that means: { silent: true } (and {} for a load somebody asked for), which bypasses both the loading counter and the error-UX pipeline. pending: false is not enough — it suppresses the spinner and still raises the blocking retry toast over a screen the user is reading.
  • Its failure neither interrupts nor vanishes. A NotFoundError is dropped (the entity went away while the socket was down; the next navigation says so), a NetworkError is dropped (the socket went again mid-read, and the binding's own reconnect rule re-reads when it returns), and anything else goes to the errorSink configured through configureApiUX — reported, never toasted.
  • It must read past a response cache the missed pushes would have invalidated: invalidate the entries the read would hit, then read, so the stale entry is repaired rather than stepped around.

A state stream needs none of this: the server greets the socket with the document, so there is no re-read to run and no failure of one to classify.

A document every socket keeps. keepDocument(transport, type, initial) keeps one for the page's life — initial made reactive, every frame of type applied to it by @greendrake/rpc's liveDocument — and returns it. Such a socket is sent the whole document only as it opens, so call it where the transport is made, before it connects, rather than in a component that may mount after:

export const transport = new WsTransport<Record<never, never>, Pushes>({ pipe })
export const cameras: Cameras = keepDocument(transport, 'cameras', {})
void transport.connect()

Topic memberships. holdTopics(transport, { join, leave }) holds a socket's topic memberships by key, for any topic subscription. hold(key) returns the release.

  • Counting. The first hold of a key joins and the last release of it leaves.
  • Swaps send nothing. A release is torn down only after the current render flush, so a component swapped for another holding the same key sends nothing.
  • Order. A key's calls run one at a time, in the order asked, so the server never runs a join and a leave out of order.
  • Reconnects. A dropped socket takes its memberships with it, so after each return from a drop every held key joins again, unless a join of it is still out. A join failing with a NetworkError is retried then; any other failure is the key's failure(key) until a join of it succeeds.
  • Leaving. A leave goes out only where there is a membership to leave: not where the key's last join was refused or lost, and not while the socket is down, when leaving would only open a new socket to leave a topic it was never in. A leave failing other than for the network goes to the error sink.
  • Plain calls. join and leave are plain calls over the transport, an RpcClient's, never an ApiClient's, whose parking and retry prompt would re-join a key nobody holds or hold a join out indefinitely.

A document a topic keeps. keepTopicDocuments(transport, { typeOf, empty, join, leave }) is holdTopics with the document of typeOf(key) kept for each membership. Its socket is sent the whole document as it joins, so it is held where it is shown, at any time, and the first hold opens the socket.

  • doc(key) is the held key's document, empty() until it is greeted, and undefined while not held — and while its join stands refused, nothing more coming to keep it current.
  • A membership's document is fresh, started after any earlier leave of the key has settled, so nothing sent before that leave reaches it.
const units = keepTopicDocuments(transport, {
    typeOf: (name: string) => `unit/${name}` as const,
    empty: () => ({}),
    join: name => client.call('unit.watch', { name }),
    leave: name => client.call('unit.unwatch', { name })
})
const release = units.hold('web') // units.doc('web'), units.failure('web')

backgroundCall also serves any loader told whether anybody asked for its load — a table re-reading after a push, say, which is where a panel's re-read actually calls the API:

const load = (args: ListArgs, background: boolean) => api.with(backgroundCall(background)).call('admin.thread.list', args)

Related Packages

@greendrake/vue-kit

Vue/Pinia building blocks: app loading state, media queries, collection-store fa...

1,613 0

Version History

Version Released Vue License
0.11.0 ^3.5.34 MIT
0.10.1 ^3.5.34 MIT
0.10.0 ^3.5.34 MIT
0.9.0 ^3.5.34 MIT
0.8.0 ^3.5.34 MIT