@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 +21 -0
- package/README.md +187 -0
- package/package.json +53 -0
- package/src/ApiClient.ts +299 -0
- package/src/ExceptionToast.vue +112 -0
- package/src/connectivity.ts +78 -0
- package/src/exceptionState.ts +121 -0
- package/src/live.ts +50 -0
- package/src/main.ts +6 -0
- package/src/retryWhenOnline.ts +29 -0
- package/src/uxConfig.ts +41 -0
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
|
+
}
|
package/src/ApiClient.ts
ADDED
|
@@ -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
|
+
}
|
package/src/uxConfig.ts
ADDED
|
@@ -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('/'))
|