@greendrake/vue-api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eugene Greendrake
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,187 @@
1
+ # @greendrake/vue-api
2
+
3
+ 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.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ bun add @greendrake/vue-api vue pinia
9
+ ```
10
+
11
+ `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 message strings), `@greendrake/util` and `@greendrake/vue-kit` (the global loading state) install as dependencies.
12
+
13
+ ## The app's `api.ts`
14
+
15
+ One module declares the typed method map, builds the transport and exports the client; every store and component imports from it.
16
+
17
+ ```ts
18
+ // src/api.ts
19
+ import { HttpTransport } from '@greendrake/rpc'
20
+ import { ApiClient } from '@greendrake/vue-api'
21
+ import type { Host, ListResult, PageArgs, Vlan } from './types'
22
+
23
+ export interface Methods {
24
+ 'host.list': (query: PageArgs) => ListResult<Host>
25
+ 'host.get': (id: number) => Host
26
+ 'host.update': (record: { id: number } & Partial<Host>) => Host
27
+ 'host.delete': (args: { id: number }) => void
28
+ 'vlan.list': (query: Record<string, never>) => Vlan[]
29
+ }
30
+
31
+ export const api = new ApiClient<Methods>({
32
+ transport: new HttpTransport({
33
+ url: __API_URL__,
34
+ headers: () => ({ 'x-api-key': __API_KEY__ })
35
+ })
36
+ })
37
+ ```
38
+
39
+ The same with a WebSocket transport, in-band session auth and server push:
40
+
41
+ ```ts
42
+ // src/api.ts
43
+ import { WebSocketPipe, WsTransport, type WsAuthStrategy } from '@greendrake/rpc'
44
+ import { ApiClient, configureApiUX } from '@greendrake/vue-api'
45
+
46
+ export interface Methods {
47
+ 'auth.token': (token: string) => void
48
+ 'auth.logout': () => void
49
+ 'taxonomy.tree': (args: { taxonomy: string }) => TaxonomyNode[]
50
+ 'thread.message.create': (args: { thread_id: string; body: string }) => { message: Message }
51
+ }
52
+
53
+ export interface Pushes {
54
+ 'thread.message': ThreadMessageEvent
55
+ }
56
+
57
+ const auth: WsAuthStrategy = {
58
+ authenticate: (send, token) => send({ method: 'auth.token', arguments: [token] }),
59
+ deauthenticate: send => send({ method: 'auth.logout' })
60
+ }
61
+
62
+ export const transport = new WsTransport<Pushes>({ pipe: new WebSocketPipe({ url: __WS_URL__ }), auth })
63
+
64
+ // This socket carries every call, so it is the app's connectivity oracle.
65
+ configureApiUX({ connectionSource: transport })
66
+
67
+ export const api = new ApiClient<Methods>({
68
+ transport,
69
+ locale: () => i18n.global.locale.value,
70
+ cache: { ttl: 300, endpoints: { 'taxonomy.tree': true } },
71
+ errorHandlers: {
72
+ UNAUTHENTICATED: () => session.end(),
73
+ INVALID_INPUT: e => {
74
+ throw e
75
+ }
76
+ }
77
+ })
78
+ ```
79
+
80
+ Calls from components and stores:
81
+
82
+ ```ts
83
+ import { ref } from 'vue'
84
+ import { api } from '@/api'
85
+
86
+ const host = await api.call('host.get', [42]) // Host; the global spinner shows while pending
87
+
88
+ const saving = ref(false)
89
+ await api.call('host.update', [{ id: 42, name: 'nas' }], { pending: saving }) // this ref instead
90
+
91
+ const vlans = await api.call('vlan.list', [{}], { silent: true }) // no spinner, no toast; errors reject
92
+ ```
93
+
94
+ `call(method, args, options?)` takes the arguments as a tuple (`Parameters<M[K]>`), unlike `RpcClient.call`'s variadic form.
95
+
96
+ ## ApiClient
97
+
98
+ `ApiClientOptions`:
99
+
100
+ - `transport` — any `@greendrake/rpc` `Transport`.
101
+ - `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.
102
+ - `errorHandlers?: Record<string, (e: Error) => void>` — keyed by server error code (`codeOf`), consulted before the toast. A handler that throws bubbles the error to the caller with its `ApiError.details` intact, bypassing the toast; a handler that returns parks the call forever — its side effect (logout, redirect) supersedes the call's flow.
103
+ - `locale?: () => string | undefined` — the UI language tag put in every request envelope, evaluated per call.
104
+
105
+ `CallOptions`:
106
+
107
+ - `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.
108
+ - `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.
109
+ - `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.
110
+ - `cache: false` — reads past the cache and does not store the answer. Cannot opt an unlisted method in and leaves the nonce rule alone.
111
+
112
+ Cache access on the client: `invalidateCache(method, ...args)` (with args, that entry; without, the whole method), `clearCache()`, `primeCache(method, args, data)`.
113
+
114
+ `setDebugDelay(method, ms)` — dev builds only: holds a call pending for `ms` so its loading state can be inspected; `0` clears. Inert in production.
115
+
116
+ ## Error-UX pipeline
117
+
118
+ 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'`, behind a full-screen overlay). The toast's kind is `classifyException(e)`:
119
+
120
+ | kind | error | toast |
121
+ | --- | --- | --- |
122
+ | `notfound` | `NotFoundError` | acknowledge-only: "To the home page" when nothing usable is on screen yet, otherwise "OK" and the visitor stays put |
123
+ | `oneoutcome` | any other `OneOutcomeError` | acknowledge-only, shows the error's own message |
124
+ | `network` | `NetworkError` (fetch failure, socket closed) | Try again / Cancel |
125
+ | `other` | everything else | Try again / Cancel; also reported to the error sink and logged with `console.warn` |
126
+
127
+ 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. The copy is `@greendrake/ui`'s `messages` (`errorNetwork`, `errorNotFound`, `errorOther`, `tryAgain`, `trying`, `cancel`, `ok`, `toHomePage`), overridable with `configureUiMessages`.
128
+
129
+ `useExceptionState()`: state `exception: ExceptionKind` (`'network' | 'notfound' | 'oneoutcome' | 'other' | false`); action `fail(e: Error | false, options?: FailOptions)`, where `false` clears the toast. `FailOptions`: `retry?: () => Promise<void>`, `cancel?: () => void`, `onDismiss?: () => void` (runs when an acknowledge-only 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.
130
+
131
+ Boot wiring, `configureApiUX({ errorSink?, goHome?, connectionSource? })`:
132
+
133
+ - `errorSink: ErrorSink` — `(error, context?) => void`; receives `other`-kind failures and abnormal WebSocket closes (`WebSocketClosedError.isAbnormal`) for reporting. Nothing is reported without one.
134
+ - `goHome` — the not-found toast's home action; default `location.assign('/')`. `@greendrake/vue-app` sets it to `router.push('/')`. `getGoHome()` returns the configured function for modules that navigate home outside a component.
135
+ - `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.
136
+
137
+ ### Offline parking
138
+
139
+ 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.
140
+
141
+ ## Connectivity
142
+
143
+ `useConnectivity()` is a Pinia setup store:
144
+
145
+ - `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.
146
+ - `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.
147
+ - `deviceOffline` — the device's own claim. With a source it is never authoritative; it only picks wording.
148
+ - `whenOnline(): Promise<void>` — resolves the moment calls can succeed; immediately when they already can.
149
+
150
+ `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.
151
+
152
+ ```ts
153
+ import { fetchJson } from '@greendrake/util'
154
+ import { retryWhenOnline } from '@greendrake/vue-api'
155
+
156
+ const strings = await retryWhenOnline(() => fetchJson<Strings>(`/locales/${locale}.json`))
157
+ ```
158
+
159
+ ## Live updates
160
+
161
+ `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.
162
+
163
+ ```ts
164
+ import { backgroundCall, useLive } from '@greendrake/vue-api'
165
+ import { api, transport } from '@/api'
166
+
167
+ useLive(transport, {
168
+ events: {
169
+ 'thread.message': { when: pushed => pushed.thread_id === props.threadId }
170
+ },
171
+ resync: async () => {
172
+ thread.value = await api.call('thread.get', [props.threadId], backgroundCall(true))
173
+ }
174
+ })
175
+ ```
176
+
177
+ 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`:
178
+
179
+ - It runs unseen. `backgroundCall(background)` is the one place that decides what that means: `{ silent: true }`, 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.
180
+ - 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.
181
+ - 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.
182
+
183
+ `backgroundCall` also serves a table's loader, which is where a panel's re-read actually calls the API — see `@greendrake/ui-data`'s loader contract:
184
+
185
+ ```ts
186
+ const loader = listLoader((args, background) => api.call('admin.thread.list', [args], backgroundCall(background)))
187
+ ```
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@greendrake/vue-api",
3
+ "version": "0.1.0",
4
+ "description": "Vue binding of @greendrake/rpc: loading-state wiring, response cache, error-UX pipeline, silent calls, live bindings to server push",
5
+ "license": "MIT",
6
+ "author": "Eugene Greendrake <eugene@greendrake.nz>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/greendrake/lib.git",
10
+ "directory": "vue-api"
11
+ },
12
+ "homepage": "https://github.com/greendrake/lib/tree/main/vue-api#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/greendrake/lib/issues"
15
+ },
16
+ "type": "module",
17
+ "main": "src/main.ts",
18
+ "types": "src/main.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./src/main.ts",
22
+ "default": "./src/main.ts"
23
+ }
24
+ },
25
+ "files": [
26
+ "src"
27
+ ],
28
+ "sideEffects": false,
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "scripts": {
33
+ "check": "vue-tsc --noEmit"
34
+ },
35
+ "prettier": "@greendrake/dev-config/prettier",
36
+ "dependencies": {
37
+ "@greendrake/rpc": "0.1.0",
38
+ "@greendrake/ui": "0.1.0",
39
+ "@greendrake/util": "0.1.0",
40
+ "@greendrake/vue-kit": "0.1.0"
41
+ },
42
+ "peerDependencies": {
43
+ "pinia": "^3.0.4",
44
+ "vue": "^3.5.34"
45
+ },
46
+ "devDependencies": {
47
+ "@greendrake/dev-config": "0.1.0",
48
+ "pinia": "^3.0.4",
49
+ "typescript": "6.0.3",
50
+ "vue": "^3.5.34",
51
+ "vue-tsc": "^2.2.0"
52
+ }
53
+ }
@@ -0,0 +1,299 @@
1
+ import { isRef, type Ref } from 'vue'
2
+ import { createDeferred, NetworkError, TimeOutPromise } from '@greendrake/util'
3
+ import { ResponseCache, WebSocketClosedError, codeOf, packRequest } from '@greendrake/rpc'
4
+ import type { CacheConfig, MethodMap, MethodMapConstraint, Transport } from '@greendrake/rpc'
5
+ import { useAppState } from '@greendrake/vue-kit'
6
+ import { useConnectivity } from './connectivity'
7
+ import { useExceptionState } from './exceptionState'
8
+
9
+ export class UnacceptableResultError extends Error {
10
+ constructor() {
11
+ super('Unacceptable result')
12
+ this.name = 'UnacceptableResultError'
13
+ }
14
+ }
15
+
16
+ export interface CallOptions {
17
+ // Loading indication: undefined → the global spinner counter; a Ref →
18
+ // that scoped busy flag; false → none (a button label is already
19
+ // communicating progress).
20
+ pending?: Ref<boolean> | false
21
+ // Reject results the server calls success but the caller cannot accept.
22
+ // Returning an Error throws it; returning false throws
23
+ // UnacceptableResultError. Feeds the same retry pipeline as failures.
24
+ validate?: (result: unknown) => boolean | Error
25
+ // Bypass loading indication AND the error-UX pipeline: errors reject
26
+ // straight to the caller. For background calls (token refresh) and callers
27
+ // owning their own error presentation.
28
+ silent?: boolean
29
+ // Refuse the response cache for this call: read past whatever is stored,
30
+ // and do not store the answer either. For the read whose whole promise is
31
+ // freshness on a method that is worth caching everywhere else — the same
32
+ // query serving a "what's here right now" surface and a search box. It
33
+ // says nothing about the method: an unlisted one cannot be opted in, and a
34
+ // listed one keeps its nonce-lessness (see `call`). See DATA_CACHING.md §4.
35
+ cache?: false
36
+ }
37
+
38
+ export interface ApiClientOptions {
39
+ transport: Transport
40
+ // Which methods may be answered from an in-browser response cache, and for
41
+ // how long. THE list drives two things at once — see `call` below: a listed
42
+ // method is served/stored from the cache and goes out nonce-less; every
43
+ // other method carries a nonce and is never cached. Only list stateless,
44
+ // platform-static reads — never per-user data that mutates in-session.
45
+ cache?: CacheConfig
46
+ // Per-error-code interception, keyed by code (the error's message). A
47
+ // handler that re-throws bubbles the error to the caller (bypassing the
48
+ // toast); a handler that returns parks the call forever — its flow is
49
+ // abandoned, superseded by the handler's side effect (logout, redirect).
50
+ errorHandlers?: Record<string, (e: Error) => void>
51
+ // The caller's UI language tag, evaluated per request. Server-resolved
52
+ // labels (taxonomy names, breadcrumbs, facet chips) come back in it, so a
53
+ // locale switch must take effect on the next call rather than at
54
+ // construction — and it scopes the response cache, which would otherwise
55
+ // answer a call made in one language with a reply written in another.
56
+ locale?: () => string | undefined
57
+ }
58
+
59
+ // The Vue-facing API client: typed calls with loading-state wiring, response
60
+ // caching, and the blocking-toast retry pipeline.
61
+ export class ApiClient<M extends MethodMapConstraint<M> = MethodMap> {
62
+ readonly transport: Transport
63
+ readonly #cache?: ResponseCache
64
+ readonly #errorHandlers?: Record<string, (e: Error) => void>
65
+ // Dev-only per-method artificial delays (ms), keyed by method name. See
66
+ // setDebugDelay / #debugDelay.
67
+ readonly #debugDelays = new Map<string, number>()
68
+ readonly #locale?: () => string | undefined
69
+
70
+ constructor(options: ApiClientOptions) {
71
+ this.transport = options.transport
72
+ // The locale scopes the cache as well as stamping the request: a
73
+ // response carries server-resolved labels in the language it was asked
74
+ // in, so it may only answer a later call asking in that same one.
75
+ this.#cache = options.cache && new ResponseCache(options.cache, options.locale)
76
+ this.#errorHandlers = options.errorHandlers
77
+ this.#locale = options.locale
78
+ }
79
+
80
+ // DEV DEBUGGING ONLY. Register an artificial delay (ms) for a method, held
81
+ // while the call is pending — so the pending/loading state (spinner, busy
82
+ // ref, disabled controls) can be inspected in devtools before the response
83
+ // lands. Set it from the console, e.g. `api.setDebugDelay('search.listings',
84
+ // 3000)`; pass 0 to clear. The delay is applied only in dev builds (see
85
+ // #debugDelay), so a stray registration is inert in production. A `read`
86
+ // served from cache is instant and bypasses the delay (no pending to see);
87
+ // opt out of the cache to delay it.
88
+ setDebugDelay(method: keyof M & string, ms: number): void {
89
+ if (ms > 0) {
90
+ this.#debugDelays.set(method, ms)
91
+ } else {
92
+ this.#debugDelays.delete(method)
93
+ }
94
+ }
95
+
96
+ // Node-safe DEV probe (mirrors rpc/protocol.ts): import.meta.env only exists
97
+ // under Vite, and is statically replaced so the delay is dead-code-stripped
98
+ // from production builds.
99
+ async #debugDelay(method: string): Promise<void> {
100
+ const ms = this.#debugDelays.get(method)
101
+ if (ms && (import.meta as ImportMeta & { env?: { DEV?: boolean } }).env?.DEV) {
102
+ await TimeOutPromise.wait(ms)
103
+ }
104
+ }
105
+
106
+ // The one way to invoke a method. Whether the request carries a nonce is
107
+ // not a per-call-site choice: it follows from the cache config, which is
108
+ // the single declaration of "this answer may be reused".
109
+ //
110
+ // listed in cache.endpoints → served from / stored in the cache, sent
111
+ // WITHOUT a nonce
112
+ // not listed → straight to the wire WITH a nonce
113
+ //
114
+ // Why that pairing, and why it is safe to let one list decide both: a
115
+ // nonce's only server-side effect is `SETNX` dedup (the server's dispatch
116
+ // rejects a repeat with DUPLICATE_REQUEST), and a fresh random nonce is
117
+ // minted per call, so on a read it buys nothing and costs one Redis write.
118
+ // Sending it is therefore harmless but pointless — which makes "cacheable"
119
+ // the only axis worth declaring. The protocol's rule that nonce-bearing
120
+ // requests are never cached is preserved by construction.
121
+ //
122
+ // The trade being made: a method that is idempotent but deliberately
123
+ // uncached — left off the list, not merely read past it by a call site —
124
+ // now pays that one Redis write per call. Revisit the split — declaring idempotency per method,
125
+ // separate from cacheability — if those writes ever start to matter, or if
126
+ // server-side response caching is built (there is none today, so nonce-less
127
+ // buys nothing extra), or if `Request.Abort` (which cancels by nonce) gets
128
+ // wired up on a cacheable method.
129
+ async call<K extends keyof M & string>(method: K, args: Parameters<M[K]>, options?: CallOptions): Promise<Awaited<ReturnType<M[K]>>> {
130
+ const cacheable = this.#cache?.ttlFor(method) !== false && this.#cache !== undefined
131
+ // Refusing the cache is about this answer, not about the method: it
132
+ // suppresses the lookup and the store, and deliberately does NOT touch
133
+ // the nonce. The protocol's rule runs one way — a nonce-bearing request
134
+ // is never cached — so a cacheable method read fresh stays nonce-less
135
+ // and keeps what nonce-lessness buys it, which is not only the Redis
136
+ // write it saves but offline parking: a nonce-less read dropped in
137
+ // flight waits out the outage instead of asking the user to retry it.
138
+ const useCache = cacheable && options?.cache !== false
139
+ if (useCache) {
140
+ const cached = this.#cache!.get(method, args)
141
+ if (cached.hit) {
142
+ return cached.data as Awaited<ReturnType<M[K]>>
143
+ }
144
+ }
145
+ return this.#run(!cacheable, useCache, method, args, options)
146
+ }
147
+
148
+ invalidateCache(method: keyof M & string, ...args: unknown[]): void {
149
+ this.#cache?.invalidate(method, ...args)
150
+ }
151
+
152
+ // Drop every cached response. What the session identity changing costs: a
153
+ // cached read was answered for whoever was signed in when it was made, and
154
+ // the audience-dependent ones (what a listing shows, what a search returns,
155
+ // what an account owns) do not survive that change even when their TTL has
156
+ // not run out.
157
+ clearCache(): void {
158
+ this.#cache?.clear()
159
+ }
160
+
161
+ // Seed the cache from a server push already carrying the full DTO a read
162
+ // would return. `args` must match the read's args to hit the same key.
163
+ primeCache(method: keyof M & string, args: unknown[], data: unknown): void {
164
+ this.#cache?.prime(method, args, data)
165
+ }
166
+
167
+ async #run<K extends keyof M & string>(nonce: boolean, store: boolean, method: K, args: Parameters<M[K]>, options?: CallOptions): Promise<Awaited<ReturnType<M[K]>>> {
168
+ const send = async (): Promise<Awaited<ReturnType<M[K]>>> => {
169
+ const result = await this.transport.send(packRequest(method, args, nonce, this.#locale?.()))
170
+ if (options?.validate) {
171
+ const verdict = options.validate(result)
172
+ if (verdict instanceof Error) {
173
+ throw verdict
174
+ }
175
+ if (!verdict) {
176
+ throw new UnacceptableResultError()
177
+ }
178
+ }
179
+ // Stores exactly the responses the config asked to be reusable,
180
+ // minus any the caller read fresh: those must not be left behind
181
+ // for the next reader either, or refusing the cache would amount to
182
+ // refreshing it. set() is a no-op for anything unlisted.
183
+ if (store) {
184
+ this.#cache?.set(method, args, result)
185
+ }
186
+ return result as Awaited<ReturnType<M[K]>>
187
+ }
188
+
189
+ if (options?.silent) {
190
+ return send()
191
+ }
192
+
193
+ const appState = useAppState()
194
+ const exceptionState = useExceptionState()
195
+ const connectivity = useConnectivity()
196
+ const pending = options?.pending
197
+ const switchLoading = (on: boolean): void => {
198
+ if (isRef(pending)) {
199
+ pending.value = on
200
+ } else if (pending === undefined) {
201
+ appState.setLoading(on)
202
+ }
203
+ }
204
+
205
+ let settleRetryAttempt: (() => void) | undefined
206
+ // Whether THIS call raised the exception currently on screen. A
207
+ // success only clears its own error toast — never one a concurrent
208
+ // call is showing (e.g. a background list refresh must not dismiss a
209
+ // navigation's "not found" toast it had nothing to do with).
210
+ let raisedException = false
211
+ // Drop this call's own toast once it no longer needs one: on success,
212
+ // and on parking to wait out an outage (which takes the failure off
213
+ // the user's hands entirely). Clearing the flag with it keeps the
214
+ // claim honest across a park — whatever appears on screen afterwards
215
+ // belongs to someone else until this call raises again.
216
+ const clearOwnException = (): void => {
217
+ if (raisedException && exceptionState.exception) {
218
+ exceptionState.fail(false)
219
+ }
220
+ raisedException = false
221
+ }
222
+ while (true) {
223
+ switchLoading(true)
224
+ try {
225
+ // Held inside the pending window so the delay prolongs the
226
+ // observable loading state (dev only; see #debugDelay).
227
+ await this.#debugDelay(method)
228
+ const result = await send()
229
+ switchLoading(false)
230
+ clearOwnException()
231
+ return result
232
+ } catch (e) {
233
+ settleRetryAttempt?.()
234
+ // Offline: wait for the connection instead of asking the user
235
+ // to. Each attempt re-runs send(), which mints a fresh nonce
236
+ // (see packRequest), so the server's SETNX dedup cannot span a
237
+ // retry — parking is only safe where re-issuing cannot
238
+ // double-apply. That holds when the request demonstrably never
239
+ // left this machine (the socket was already down, so the
240
+ // transport rejected before writing), and when it carries no
241
+ // nonce at all (a cacheable read; see call). A nonce-carrying
242
+ // request that was on the wire when the socket went may have
243
+ // executed with only its response lost, so whether to re-send
244
+ // it stays the user's decision and keeps the toast.
245
+ const neverSent = e instanceof WebSocketClosedError && !e.inFlight
246
+ if (e instanceof NetworkError && connectivity.offline && (!nonce || neverSent)) {
247
+ // Park OUTSIDE the loading window: holding the global
248
+ // counter for the whole outage would pin the spinner and
249
+ // wedge isFirstLoading (which flips when the counter
250
+ // empties). A scoped pending ref is the opposite case and
251
+ // stays on — the page remains interactive under the
252
+ // offline banner, and that busy flag is what keeps its
253
+ // control disabled so a form cannot queue duplicate
254
+ // submissions that all fire at once on reconnect.
255
+ if (pending === undefined) {
256
+ appState.setLoading(false)
257
+ }
258
+ clearOwnException()
259
+ await connectivity.whenOnline()
260
+ continue
261
+ }
262
+ switchLoading(false)
263
+ // Keyed by backend code, which for a *_NOT_FOUND lives on the
264
+ // error rather than in its message — see codeOf.
265
+ const handler = this.#errorHandlers?.[codeOf(e as Error)]
266
+ if (handler) {
267
+ // Pass the original error so a handler that re-throws (the
268
+ // bubble pattern) preserves its payload (ApiError.details).
269
+ handler(e as Error)
270
+ // A non-throwing handler's side effect (logout, redirect)
271
+ // supersedes this call's flow: park it forever rather than
272
+ // lie with an undefined result.
273
+ return new Promise<never>(() => {})
274
+ }
275
+ const decision = createDeferred<void>()
276
+ const retryAttempt = createDeferred<void>()
277
+ settleRetryAttempt = retryAttempt.resolve
278
+ exceptionState.fail(e as Error, {
279
+ // The toast's "Trying..." state lasts until the retried
280
+ // attempt settles (tracked via retryAttempt).
281
+ retry: () => {
282
+ decision.resolve()
283
+ return retryAttempt.promise
284
+ },
285
+ cancel: decision.reject,
286
+ // Identifies the failed RPC in error reports. Args are
287
+ // deliberately omitted — they can carry credentials/PII.
288
+ reportContext: { apiMethod: method }
289
+ })
290
+ raisedException = true
291
+ try {
292
+ await decision.promise
293
+ } catch {
294
+ throw e
295
+ }
296
+ }
297
+ }
298
+ }
299
+ }
@@ -0,0 +1,112 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
3
+ import { messages } from '@greendrake/ui'
4
+ import { getGoHome } from './uxConfig'
5
+ import type { ExceptionKind } from './exceptionState'
6
+
7
+ const props = defineProps<{
8
+ error: Error
9
+ kind: Exclude<ExceptionKind, false>
10
+ // Whether a usable page is already on screen (see exceptionState). It
11
+ // decides what a not-found can offer: somewhere to go, or just an
12
+ // acknowledgement.
13
+ canStayPut: boolean
14
+ hide: () => void
15
+ retry: () => Promise<void> | void
16
+ cancel: () => void
17
+ }>()
18
+
19
+ const counter = ref(1)
20
+ const isRetrying = ref(false)
21
+
22
+ const kindMessages = computed<Record<string, string>>(() => ({
23
+ other: messages.errorOther,
24
+ network: messages.errorNetwork,
25
+ notfound: messages.errorNotFound
26
+ }))
27
+
28
+ // A not-found reached from a page the visitor is already on leaves them there:
29
+ // the navigation never happened, what they were looking at is still behind the
30
+ // toast, and offering the home page would be sending them away from it. Only a
31
+ // visitor who arrived straight at the missing thing — a shared link opened
32
+ // cold, so nothing else is loaded — has anywhere to be sent.
33
+ const notFoundLeavesNowhere = computed(() => props.kind === 'notfound' && !props.canStayPut)
34
+
35
+ const button = computed(() => {
36
+ if (notFoundLeavesNowhere.value) {
37
+ return messages.toHomePage
38
+ }
39
+ if (props.kind === 'oneoutcome' || props.kind === 'notfound') {
40
+ return messages.ok
41
+ }
42
+ return isRetrying.value ? messages.trying : messages.tryAgain
43
+ })
44
+
45
+ // Retryable kinds (network/other) get a Cancel button; acknowledge-only kinds
46
+ // (notfound/oneoutcome) have a single outcome.
47
+ const isAcknowledgeOnly = computed(() => props.kind === 'notfound' || props.kind === 'oneoutcome')
48
+
49
+ const onClick = (): void => {
50
+ switch (props.kind) {
51
+ case 'notfound':
52
+ props.cancel()
53
+ if (notFoundLeavesNowhere.value) {
54
+ getGoHome()()
55
+ }
56
+ break
57
+ case 'oneoutcome':
58
+ props.cancel()
59
+ break
60
+ default: {
61
+ const retried = props.retry()
62
+ if (retried instanceof Promise) {
63
+ isRetrying.value = true
64
+ retried
65
+ .then(() => {
66
+ counter.value++
67
+ })
68
+ .finally(() => {
69
+ isRetrying.value = false
70
+ })
71
+ }
72
+ break
73
+ }
74
+ }
75
+ }
76
+
77
+ const message = computed(() => {
78
+ if (props.kind === 'oneoutcome') {
79
+ return props.error.message || messages.errorOther
80
+ }
81
+ return `${kindMessages.value[props.kind]}${counter.value > 1 ? ` (${counter.value})` : ''}`
82
+ })
83
+ </script>
84
+ <template>
85
+ <Teleport to="body">
86
+ <div class="BlockingOverlay" />
87
+ </Teleport>
88
+ <div class="ExceptionToast">
89
+ <div>{{ message }}</div>
90
+ <div class="ExceptionToast__buttons">
91
+ <button v-if="!isAcknowledgeOnly" @click.prevent="cancel">{{ messages.cancel }}</button>
92
+ <div />
93
+ <button :class="{ disabled: isRetrying }" @click.prevent="onClick">{{ button }}</button>
94
+ </div>
95
+ </div>
96
+ </template>
97
+ <style lang="scss" scoped>
98
+ .ExceptionToast {
99
+ &__buttons {
100
+ margin-top: 8px;
101
+ display: flex;
102
+ justify-content: space-between;
103
+ div {
104
+ flex-grow: 1;
105
+ }
106
+ button {
107
+ padding: 0 8px;
108
+ height: 32px;
109
+ }
110
+ }
111
+ }
112
+ </style>
@@ -0,0 +1,78 @@
1
+ import { ref } from 'vue'
2
+ import { defineStore } from 'pinia'
3
+ import { createDeferred } from '@greendrake/util'
4
+ import { getConnectionSource } from './uxConfig'
5
+
6
+ // Connectivity as the app's UX layer sees it, driven by the transport that
7
+ // actually carries the calls (see ConnectionSource) rather than by
8
+ // navigator.onLine — a captive portal, a dead tunnel or an unreachable backend
9
+ // all leave the device "online" while nothing can succeed.
10
+ //
11
+ // A setup store: the subscription below is a side effect that must run exactly
12
+ // once, when the store first materialises.
13
+ export const useConnectivity = defineStore('connectivity', () => {
14
+ const source = getConnectionSource()
15
+
16
+ // The device's own claim. Never authoritative — it only picks the wording
17
+ // ("you appear to be offline" vs "cannot reach the site"), and kicks a
18
+ // waiting reconnect early where the app wires that.
19
+ const deviceOffline = ref(!navigator.onLine)
20
+ // With a transport oracle, connectivity starts out "not yet known" rather
21
+ // than offline: the eager connect at boot has not had its chance yet, and
22
+ // flipping now would flash the offline surfaces over every start. Without
23
+ // one, the device signal is the whole story and is authoritative already.
24
+ const offline = ref(!source && deviceOffline.value)
25
+ // Offline *and* an attempt to come back has failed too. The surfaces that
26
+ // interrupt a working screen wait for this, so a socket that reconnects on
27
+ // its first try — a rolling deploy, a momentary drop — passes unremarked.
28
+ const confirmedOffline = ref(offline.value)
29
+ // Calls parked until connectivity returns. Plain array, not state: wiring,
30
+ // and settling them must not depend on anything reactive.
31
+ const waiters: Array<() => void> = []
32
+
33
+ const connected = (): void => {
34
+ offline.value = false
35
+ confirmedOffline.value = false
36
+ waiters.splice(0).forEach(resolve => resolve())
37
+ }
38
+
39
+ // What confirms an outage differs by source: the transport reports once
40
+ // per failed attempt, so the second consecutive report is the confirmation,
41
+ // while the device signal has no attempts to count and speaks for itself.
42
+ const disconnected = (confirmed: boolean): void => {
43
+ confirmedOffline.value = confirmed || offline.value
44
+ offline.value = true
45
+ }
46
+
47
+ addEventListener('online', () => {
48
+ deviceOffline.value = false
49
+ if (!source) {
50
+ connected()
51
+ }
52
+ })
53
+ addEventListener('offline', () => {
54
+ deviceOffline.value = true
55
+ if (!source) {
56
+ disconnected(true)
57
+ }
58
+ })
59
+ source?.onConnectionChange(up => (up ? connected() : disconnected(false)))
60
+
61
+ // Resolves the moment calls can succeed again — immediately when they
62
+ // already can. Event-driven: no polling, no timers.
63
+ const whenOnline = (): Promise<void> => {
64
+ if (!offline.value) {
65
+ return Promise.resolve()
66
+ }
67
+ const { promise, resolve } = createDeferred()
68
+ waiters.push(resolve)
69
+ return promise
70
+ }
71
+
72
+ return {
73
+ offline,
74
+ confirmedOffline,
75
+ deviceOffline,
76
+ whenOnline
77
+ }
78
+ })
@@ -0,0 +1,121 @@
1
+ import { h } from 'vue'
2
+ import { defineStore } from 'pinia'
3
+ import { NetworkError } from '@greendrake/util'
4
+ import { NotFoundError, OneOutcomeError, WebSocketClosedError } from '@greendrake/rpc'
5
+ import { showToast } from '@greendrake/ui'
6
+ import { useAppState } from '@greendrake/vue-kit'
7
+ import ExceptionToast from './ExceptionToast.vue'
8
+ import { getErrorSink } from './uxConfig'
9
+
10
+ export type ExceptionKind = 'network' | 'notfound' | 'oneoutcome' | 'other' | false
11
+
12
+ export const classifyException = (e: Error): Exclude<ExceptionKind, false> => {
13
+ if (e instanceof NotFoundError) return 'notfound'
14
+ if (e instanceof OneOutcomeError) return 'oneoutcome'
15
+ if (e instanceof NetworkError) return 'network'
16
+ return 'other'
17
+ }
18
+
19
+ interface ExceptionState {
20
+ exception: ExceptionKind
21
+ }
22
+
23
+ export interface FailOptions {
24
+ retry?: () => Promise<void>
25
+ cancel?: () => void
26
+ // Runs when the user acknowledges an acknowledge-only (notfound/
27
+ // oneoutcome) toast — the UI-side successor of the dismiss callback the
28
+ // old transport error type carried.
29
+ onDismiss?: () => void
30
+ // Attached to the error report (e.g. the failed RPC's method name).
31
+ reportContext?: Record<string, unknown>
32
+ }
33
+
34
+ // Non-reactive callback slots: wiring, not state. Refreshed on every fail()
35
+ // so a repeated same-kind failure re-drives the existing toast.
36
+ const handlers: {
37
+ hideToast?: () => void
38
+ retry?: () => Promise<void>
39
+ cancel?: () => void
40
+ onDismiss?: () => void
41
+ } = {}
42
+
43
+ // Global exception surface: one blocking toast at a time, with Retry/Cancel
44
+ // wired back to the failed call's deferred (see ApiClient). fail(false)
45
+ // clears it.
46
+ export const useExceptionState = defineStore('exceptionstate', {
47
+ state: (): ExceptionState => ({
48
+ exception: false
49
+ }),
50
+ actions: {
51
+ fail(e: Error | false, options?: FailOptions): void {
52
+ if (!e) {
53
+ if (this.exception !== false) {
54
+ this.exception = false
55
+ handlers.hideToast?.()
56
+ delete handlers.hideToast
57
+ }
58
+ return
59
+ }
60
+ const kind = classifyException(e)
61
+ // Report genuine unexpected failures. notfound/oneoutcome and
62
+ // ordinary network blips are expected operational conditions and
63
+ // would only be noise — but an *abnormal* WebSocket close is
64
+ // reported so server-side or idle-timeout drops stay diagnosable.
65
+ // Done before the same-kind dedup below so every distinct report
66
+ // fires even while a toast of the same kind is showing.
67
+ const sink = getErrorSink()
68
+ if (sink && (kind === 'other' || (e instanceof WebSocketClosedError && e.isAbnormal))) {
69
+ sink(e, options?.reportContext)
70
+ }
71
+ // Leave a trace whether or not a sink is configured, and in shipped
72
+ // builds as well as dev. This toast is the one surface saying a call
73
+ // failed unexpectedly, and on a device there are no devtools to open
74
+ // — but `logcat` and the Web Inspector both carry the console, so
75
+ // without this line an on-device failure is undiagnosable. `warn`
76
+ // for the reason the RPC layer picks it: the condition is handled
77
+ // and surfaced, and an error would fail the E2E fixtures' teardown
78
+ // guard on specs that provoke this toast deliberately.
79
+ // One preformatted string, not several arguments: the native
80
+ // WebView console bridge does not serialise them, so anything
81
+ // structured reaches `logcat` as a bare "[object Object]".
82
+ if (kind === 'other') {
83
+ const where = options?.reportContext ? ` ${JSON.stringify(options.reportContext)}` : ''
84
+ console.warn(`[exception]${where} ${e.name}: ${e.message}`)
85
+ }
86
+ handlers.retry = options?.retry
87
+ handlers.cancel = options?.cancel
88
+ handlers.onDismiss = options?.onDismiss
89
+ if (this.exception === kind) {
90
+ return
91
+ }
92
+ handlers.hideToast?.()
93
+ this.exception = kind
94
+ const { destroy } = showToast({
95
+ content: h(ExceptionToast, {
96
+ error: e,
97
+ kind,
98
+ // Whether there is a page to stay on. A committed
99
+ // navigation is exactly that: something usable is already
100
+ // on screen, so a not-found has somewhere to leave the
101
+ // visitor and needs offer them nothing. Read here rather
102
+ // than in the toast — this is a store action, so pinia is
103
+ // certainly active, and the answer cannot change while the
104
+ // toast blocks the app anyway.
105
+ canStayPut: useAppState().hasNavigated,
106
+ retry: () => handlers.retry?.(),
107
+ hide: () => destroy(),
108
+ cancel: () => {
109
+ destroy()
110
+ this.exception = false
111
+ handlers.cancel?.()
112
+ handlers.onDismiss?.()
113
+ }
114
+ }),
115
+ hideOnClick: false,
116
+ variant: 'warning'
117
+ })
118
+ handlers.hideToast = destroy
119
+ }
120
+ }
121
+ })
package/src/live.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { onUnmounted } from 'vue'
2
+ import { NetworkError } from '@greendrake/util'
3
+ import { NotFoundError, liveBinding } from '@greendrake/rpc'
4
+ import type { LiveOptions, PushMapConstraint, WsTransport } from '@greendrake/rpc'
5
+ import type { CallOptions } from './ApiClient'
6
+ import { getErrorSink } from './uxConfig'
7
+
8
+ // The call options a background read runs under, in one place so every resync
9
+ // and every background loader means the same thing by "background". `silent`
10
+ // is the option that matters: it bypasses the loading counter AND the
11
+ // error-UX pipeline, whereas `pending: false` suppresses only the spinner and
12
+ // would still put the blocking retry toast over a screen the user is reading.
13
+ export const backgroundCall = (background: boolean): CallOptions | undefined => (background ? { silent: true } : undefined)
14
+
15
+ // @greendrake/rpc's liveBinding under this package's UX policy: a resync runs
16
+ // unseen, and its failure neither interrupts the user nor vanishes.
17
+ //
18
+ // A resync must read in the background (see backgroundCall) and must not be
19
+ // served by a response cache the missed pushes would have invalidated —
20
+ // invalidate the entries its read would hit, then read, so the stale entry is
21
+ // repaired for the next reader rather than merely stepped around.
22
+ export const bindLive = <PEvents extends PushMapConstraint<PEvents>, PData extends PushMapConstraint<PData>>(transport: WsTransport<PEvents, PData>, options: LiveOptions<PEvents, PData>): (() => void) => {
23
+ const { resync } = options
24
+ if (!resync) return liveBinding(transport, options)
25
+ return liveBinding(transport, {
26
+ ...options,
27
+ resync: async () => {
28
+ try {
29
+ await resync()
30
+ } catch (e) {
31
+ // Gone while the socket was down: there is nothing to show and
32
+ // nothing to report — the next navigation says so.
33
+ if (e instanceof NotFoundError) return
34
+ // The socket went again mid-read (WebSocketClosedError is a
35
+ // NetworkError). The binding's own reconnect rule owns this
36
+ // one: it re-reads when the socket returns, so reporting it
37
+ // would file one entry per binding per blip.
38
+ if (e instanceof NetworkError) return
39
+ getErrorSink()?.(e as Error, { resync: true })
40
+ }
41
+ }
42
+ })
43
+ }
44
+
45
+ // Component-scoped live binding: releases with the component. The transport is
46
+ // passed explicitly — an HTTP-only app has none, which is a compile-time error
47
+ // here rather than a silent no-op.
48
+ export const useLive = <PEvents extends PushMapConstraint<PEvents>, PData extends PushMapConstraint<PData>>(transport: WsTransport<PEvents, PData>, options: LiveOptions<PEvents, PData>): void => {
49
+ onUnmounted(bindLive(transport, options))
50
+ }
package/src/main.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { ApiClient, UnacceptableResultError, type ApiClientOptions, type CallOptions } from './ApiClient'
2
+ export { useExceptionState, classifyException, type ExceptionKind, type FailOptions } from './exceptionState'
3
+ export { configureApiUX, getGoHome, type ConnectionSource, type ErrorSink } from './uxConfig'
4
+ export { useConnectivity } from './connectivity'
5
+ export { retryWhenOnline } from './retryWhenOnline'
6
+ export { backgroundCall, bindLive, useLive } from './live'
@@ -0,0 +1,29 @@
1
+ import { NetworkError } from '@greendrake/util'
2
+ import { useConnectivity } from './connectivity'
3
+
4
+ // Re-runs `attempt` each time connectivity returns, for as long as the app is
5
+ // offline. For the fetch sites that reach the network WITHOUT going through
6
+ // ApiClient — static content, the i18n locale file — whose failure would
7
+ // otherwise be a dead end: nothing re-drives them, so an offline miss leaves
8
+ // the surface permanently empty even after the connection is back.
9
+ //
10
+ // ApiClient keeps its own integrated version of this loop rather than calling
11
+ // here: its parking interleaves with the loading windows, the retry-toast
12
+ // deferral and the in-flight carve-out, none of which apply to a plain fetch.
13
+ //
14
+ // Only network failures while the oracle corroborates an outage are retried;
15
+ // everything else (a 404, a parse error, a network failure the oracle does not
16
+ // see) propagates on the first attempt.
17
+ export const retryWhenOnline = async <T>(attempt: () => Promise<T>): Promise<T> => {
18
+ const connectivity = useConnectivity()
19
+ while (true) {
20
+ try {
21
+ return await attempt()
22
+ } catch (e) {
23
+ if (!(e instanceof NetworkError) || !connectivity.offline) {
24
+ throw e
25
+ }
26
+ await connectivity.whenOnline()
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,41 @@
1
+ // App-level wiring for the error-UX pipeline, injected at boot. Inverted
2
+ // dependencies: this package never knows where error reports go or how the
3
+ // app navigates home.
4
+
5
+ export type ErrorSink = (error: Error, context?: Record<string, unknown>) => void
6
+
7
+ // The connectivity oracle useConnectivity reads. Supplied by the app because
8
+ // only it knows which transport carries its calls — @greendrake/rpc's
9
+ // WsTransport satisfies this shape as-is. An app that wires none falls back to
10
+ // navigator.onLine, which is all an HTTP-only client has to go on.
11
+ export interface ConnectionSource {
12
+ // Whether calls can currently succeed.
13
+ readonly connected: boolean
14
+ // Fires per underlying transition, NOT deduplicated: repeated `false`
15
+ // reports are consecutive failed attempts, which is what distinguishes a
16
+ // momentary drop from a sustained outage.
17
+ onConnectionChange(listener: (connected: boolean) => void): void
18
+ }
19
+
20
+ interface ApiUXConfig {
21
+ // Receives genuine unexpected failures (and abnormal WebSocket drops) for
22
+ // reporting — e.g. the visit subsystem's error channel.
23
+ errorSink?: ErrorSink
24
+ // Drives the not-found toast's "To the home page" action; typically
25
+ // router.push('/'). @greendrake/vue-app wires this automatically.
26
+ goHome?: () => void
27
+ // Read once, when useConnectivity is first instantiated.
28
+ connectionSource?: ConnectionSource
29
+ }
30
+
31
+ const config: ApiUXConfig = {}
32
+
33
+ export const configureApiUX = (overrides: ApiUXConfig): void => {
34
+ Object.assign(config, overrides)
35
+ }
36
+
37
+ export const getErrorSink = (): ErrorSink | undefined => config.errorSink
38
+
39
+ export const getConnectionSource = (): ConnectionSource | undefined => config.connectionSource
40
+
41
+ export const getGoHome = (): (() => void) => config.goHome ?? (() => location.assign('/'))