@falcondev-oss/nuxt-layers-base 0.36.1 → 0.37.1
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/app/composables/useToast.ts +17 -3
- package/app/utils/plugins.ts +143 -38
- package/package.json +2 -2
|
@@ -16,11 +16,11 @@ const presets = {
|
|
|
16
16
|
},
|
|
17
17
|
} as const satisfies Record<string, Partial<Toast>>
|
|
18
18
|
|
|
19
|
-
interface ToastOptions extends Partial<Toast> {
|
|
19
|
+
export interface ToastOptions extends Partial<Toast> {
|
|
20
20
|
preset?: keyof typeof presets
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
function createToast() {
|
|
24
24
|
const toast = useNuxtUiToast()
|
|
25
25
|
|
|
26
26
|
return {
|
|
@@ -37,4 +37,18 @@ export const useToast = createGlobalState(() => {
|
|
|
37
37
|
)
|
|
38
38
|
},
|
|
39
39
|
}
|
|
40
|
-
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let clientToast: ReturnType<typeof createToast> | undefined
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cached on the client only, since `useNuxtUiToast()` needs Nuxt's async context that
|
|
46
|
+
* cache callbacks have left, while caching it on the server would hand one request's
|
|
47
|
+
* instance to every later request.
|
|
48
|
+
*/
|
|
49
|
+
export function useToast() {
|
|
50
|
+
if (import.meta.server) return createToast()
|
|
51
|
+
|
|
52
|
+
// eslint-disable-next-line unicorn/no-top-level-assignment-in-function
|
|
53
|
+
return (clientToast ??= createToast())
|
|
54
|
+
}
|
package/app/utils/plugins.ts
CHANGED
|
@@ -1,18 +1,23 @@
|
|
|
1
1
|
import type { DehydratedState, QueryClientConfig, VueQueryPluginOptions } from '@tanstack/vue-query'
|
|
2
|
-
import type {
|
|
2
|
+
import type { OperationLink, TRPCClientError, TRPCLink } from '@trpc/client'
|
|
3
|
+
import type { AnyTRPCRouter, TRPC_ERROR_CODE_KEY, TRPCDefaultErrorData } from '@trpc/server'
|
|
4
|
+
import type { FetchOptions } from 'ofetch'
|
|
3
5
|
import type { ObjectPlugin } from '#app'
|
|
6
|
+
import type { ToastOptions } from '../composables/useToast'
|
|
4
7
|
import { typedFormDataLink } from '@falcondev-oss/trpc-typed-form-data/client'
|
|
5
|
-
import { createTRPCVueQueryClient } from '@falcondev-oss/trpc-vue-query'
|
|
8
|
+
import { createTRPCVueQueryClient, vueQueryContext } from '@falcondev-oss/trpc-vue-query'
|
|
6
9
|
import {
|
|
7
10
|
dehydrate,
|
|
8
11
|
hydrate,
|
|
9
12
|
MutationCache,
|
|
13
|
+
QueryCache,
|
|
10
14
|
QueryClient,
|
|
11
15
|
useIsFetching,
|
|
12
16
|
useQueryClient,
|
|
13
17
|
VueQueryPlugin,
|
|
14
18
|
} from '@tanstack/vue-query'
|
|
15
|
-
import { httpSubscriptionLink, isTRPCClientError, splitLink
|
|
19
|
+
import { httpSubscriptionLink, isTRPCClientError, splitLink } from '@trpc/client'
|
|
20
|
+
import { observable } from '@trpc/server/observable'
|
|
16
21
|
import defu from 'defu'
|
|
17
22
|
import superjson from 'superjson'
|
|
18
23
|
import { httpBatchLink, httpLink } from 'trpc-nuxt/client'
|
|
@@ -29,6 +34,11 @@ interface ToastOpts {
|
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
export interface CustomMeta {
|
|
37
|
+
queryMeta: {
|
|
38
|
+
toast?: {
|
|
39
|
+
error?: ToastOpts
|
|
40
|
+
}
|
|
41
|
+
}
|
|
32
42
|
mutationMeta: {
|
|
33
43
|
toast?: {
|
|
34
44
|
success?: ToastOpts
|
|
@@ -41,37 +51,108 @@ declare module '@tanstack/vue-query' {
|
|
|
41
51
|
interface Register extends CustomMeta {}
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
/** a request that never got a response, i.e. `fetch` itself failed (offline, DNS, CORS, …) */
|
|
55
|
+
function isNetworkError(err: unknown) {
|
|
56
|
+
return isTRPCClientError<AnyTRPCRouter>(err) && !err.meta?.response
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `AnyTRPCRouter` widens the error shape to `any` */
|
|
60
|
+
function errorData(err: TRPCClientError<AnyTRPCRouter>) {
|
|
61
|
+
return err.data as TRPCDefaultErrorData | undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const retryableHttpStatuses = new Set([408, 425, 429, 502, 503, 504])
|
|
65
|
+
|
|
66
|
+
function isRetryableError(err: unknown) {
|
|
67
|
+
// unknown errors are usually a bug in the query fn, which a retry won't fix
|
|
68
|
+
if (!isTRPCClientError<AnyTRPCRouter>(err)) return false
|
|
69
|
+
|
|
70
|
+
return isNetworkError(err) || retryableHttpStatuses.has(errorData(err)?.httpStatus ?? 0)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** zod reports its issues as a JSON-encoded list in the error message */
|
|
74
|
+
function isSchemaIssueList(message: string) {
|
|
75
|
+
if (!message.startsWith('[')) return false
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const issues = JSON.parse(message) as unknown[]
|
|
79
|
+
return (
|
|
80
|
+
issues.length > 0 &&
|
|
81
|
+
issues.every((issue) => typeof (issue as { message?: unknown }).message === 'string')
|
|
82
|
+
)
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const errorTitles: Partial<Record<TRPC_ERROR_CODE_KEY, string>> = {
|
|
89
|
+
BAD_REQUEST: 'Ungültige Eingabe',
|
|
90
|
+
UNAUTHORIZED: 'Nicht angemeldet',
|
|
91
|
+
FORBIDDEN: 'Kein Zugriff',
|
|
92
|
+
NOT_FOUND: 'Nicht vorhanden',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function requestErrorToast(err: unknown): ToastOpts {
|
|
96
|
+
if (isNetworkError(err))
|
|
97
|
+
return { title: 'Keine Verbindung', description: 'Der Server ist nicht erreichbar.' }
|
|
98
|
+
|
|
99
|
+
if (!isTRPCClientError<AnyTRPCRouter>(err)) return { title: 'Unbekannter Fehler' }
|
|
100
|
+
|
|
101
|
+
// internal errors leak implementation details and mean nothing to the user
|
|
102
|
+
if (errorData(err)?.httpStatus === 500) return { title: 'Server-Fehler' }
|
|
103
|
+
|
|
104
|
+
const code = errorData(err)?.code
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
title: (code && errorTitles[code]) ?? 'Anfrage-Fehler',
|
|
108
|
+
description: isSchemaIssueList(err.message) ? 'Bitte Eingaben überprüfen.' : err.message,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** during SSR a toast would be serialized into the payload and pop up after hydration */
|
|
113
|
+
function toastAdd(opts: ToastOptions) {
|
|
114
|
+
if (import.meta.server) return
|
|
115
|
+
|
|
116
|
+
useToast().add({ duration: 5000, ...opts })
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** toasts a failed request, using `opts` if given, otherwise a generic message */
|
|
120
|
+
function toastRequestError(err: unknown, opts?: ToastOpts) {
|
|
121
|
+
toastAdd({
|
|
122
|
+
preset: 'error',
|
|
123
|
+
...(opts ?? requestErrorToast(err)),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
44
127
|
export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
45
128
|
return {
|
|
46
129
|
name: 'vue-query',
|
|
47
130
|
setup(nuxt) {
|
|
48
|
-
const
|
|
49
|
-
const vueQueryState = useState<Partial<DehydratedState>>('vue-query')
|
|
131
|
+
const vueQueryState = useState<Partial<DehydratedState>>('vue-query', () => ({}))
|
|
50
132
|
|
|
51
133
|
const queryClient = new QueryClient(
|
|
52
134
|
defu<QueryClientConfig, QueryClientConfig[]>(opts?.queryClientOptions, {
|
|
53
135
|
defaultOptions: {
|
|
54
136
|
queries: {
|
|
55
137
|
retry(failureCount, error) {
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
error.data &&
|
|
59
|
-
// eslint-disable-next-line ts/no-unsafe-member-access
|
|
60
|
-
error.data.httpStatus >= 400 &&
|
|
61
|
-
// eslint-disable-next-line ts/no-unsafe-member-access
|
|
62
|
-
error.data.httpStatus < 500
|
|
63
|
-
)
|
|
64
|
-
return false
|
|
138
|
+
if (!isRetryableError(error)) return false
|
|
139
|
+
|
|
65
140
|
return failureCount < 3
|
|
66
141
|
},
|
|
67
142
|
},
|
|
68
143
|
},
|
|
144
|
+
queryCache: new QueryCache({
|
|
145
|
+
onError(err, query) {
|
|
146
|
+
console.error(err)
|
|
147
|
+
|
|
148
|
+
toastRequestError(err, query.meta?.toast?.error)
|
|
149
|
+
},
|
|
150
|
+
}),
|
|
69
151
|
mutationCache: new MutationCache({
|
|
70
152
|
onSuccess(_res, _input, _onMutateRes, mutation) {
|
|
71
153
|
if (mutation.meta?.toast?.success) {
|
|
72
|
-
|
|
154
|
+
toastAdd({
|
|
73
155
|
preset: 'success',
|
|
74
|
-
duration: 5000,
|
|
75
156
|
...mutation.meta.toast.success,
|
|
76
157
|
})
|
|
77
158
|
}
|
|
@@ -79,42 +160,32 @@ export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
|
79
160
|
onError(err, _input, __onMutateRes, mutation) {
|
|
80
161
|
console.error(err)
|
|
81
162
|
|
|
82
|
-
|
|
83
|
-
toast.add({
|
|
84
|
-
preset: 'error',
|
|
85
|
-
duration: 5000,
|
|
86
|
-
...mutation.meta.toast.error,
|
|
87
|
-
})
|
|
88
|
-
} else if (err instanceof TRPCClientError)
|
|
89
|
-
toast.add({
|
|
90
|
-
preset: 'error',
|
|
91
|
-
title: 'Request Error',
|
|
92
|
-
description: err.message,
|
|
93
|
-
duration: 5000,
|
|
94
|
-
})
|
|
95
|
-
else
|
|
96
|
-
toast.add({
|
|
97
|
-
preset: 'error',
|
|
98
|
-
title: 'An unknown error occurred',
|
|
99
|
-
duration: 5000,
|
|
100
|
-
})
|
|
163
|
+
toastRequestError(err, mutation.meta?.toast?.error)
|
|
101
164
|
},
|
|
102
165
|
}),
|
|
103
166
|
}),
|
|
104
167
|
)
|
|
105
|
-
const options: VueQueryPluginOptions = { queryClient, ...opts?.vuePluginOptions }
|
|
106
168
|
|
|
169
|
+
const options: VueQueryPluginOptions = { queryClient, ...opts?.vuePluginOptions }
|
|
107
170
|
nuxt.vueApp.use(VueQueryPlugin, options)
|
|
108
171
|
|
|
109
172
|
if (import.meta.server) {
|
|
110
173
|
nuxt.hooks.hook('app:rendered', () => {
|
|
111
|
-
|
|
174
|
+
try {
|
|
175
|
+
vueQueryState.value = dehydrate(queryClient)
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error('[vue-query] dehydrating state failed:', err)
|
|
178
|
+
}
|
|
112
179
|
})
|
|
113
180
|
}
|
|
114
181
|
|
|
115
182
|
if (import.meta.client) {
|
|
116
183
|
nuxt.hooks.hook('app:created', () => {
|
|
117
|
-
|
|
184
|
+
try {
|
|
185
|
+
hydrate(queryClient, vueQueryState.value)
|
|
186
|
+
} catch (err) {
|
|
187
|
+
console.error('[vue-query] hydrating state failed:', err)
|
|
188
|
+
}
|
|
118
189
|
})
|
|
119
190
|
}
|
|
120
191
|
|
|
@@ -138,8 +209,39 @@ export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
|
138
209
|
} satisfies ObjectPlugin
|
|
139
210
|
}
|
|
140
211
|
|
|
212
|
+
/**
|
|
213
|
+
* Toasts errors of requests that vue-query doesn't handle itself, i.e. plain
|
|
214
|
+
* `.query()` / `.mutate()` calls. Requests made through vue-query get their toast
|
|
215
|
+
* from the query/mutation cache instead.
|
|
216
|
+
*/
|
|
217
|
+
const toastRequestErrors: OperationLink<AnyTRPCRouter> = ({ op, next }) =>
|
|
218
|
+
observable((observer) => {
|
|
219
|
+
const subscription = next(op).subscribe({
|
|
220
|
+
next: (value) => observer.next(value),
|
|
221
|
+
complete: () => observer.complete(),
|
|
222
|
+
error(err) {
|
|
223
|
+
if (!op.context[vueQueryContext] && op.type !== 'subscription') {
|
|
224
|
+
console.error(err)
|
|
225
|
+
toastRequestError(err)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
observer.error(err)
|
|
229
|
+
},
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
return () => {
|
|
233
|
+
subscription.unsubscribe()
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
export const requestErrorToastLink: TRPCLink<AnyTRPCRouter> = () => toastRequestErrors
|
|
237
|
+
|
|
141
238
|
interface TrpcNuxtPluginOptions {
|
|
142
239
|
url: string
|
|
240
|
+
/**
|
|
241
|
+
* ofetch options passed to the HTTP links.
|
|
242
|
+
* @see https://github.com/unjs/ofetch
|
|
243
|
+
*/
|
|
244
|
+
fetchOptions?: FetchOptions
|
|
143
245
|
}
|
|
144
246
|
export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOptions) {
|
|
145
247
|
return {
|
|
@@ -153,6 +255,7 @@ export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOpt
|
|
|
153
255
|
queryClient,
|
|
154
256
|
trpc: {
|
|
155
257
|
links: [
|
|
258
|
+
requestErrorToastLink,
|
|
156
259
|
splitLink({
|
|
157
260
|
condition: (op) => op.type === 'subscription',
|
|
158
261
|
true: httpSubscriptionLink({
|
|
@@ -168,12 +271,14 @@ export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOpt
|
|
|
168
271
|
httpLink({
|
|
169
272
|
transformer: superjson,
|
|
170
273
|
url: opts.url,
|
|
274
|
+
fetchOptions: opts.fetchOptions,
|
|
171
275
|
}),
|
|
172
276
|
],
|
|
173
277
|
false: httpBatchLink({
|
|
174
278
|
transformer: superjson,
|
|
175
279
|
url: opts.url,
|
|
176
280
|
maxURLLength: 2000,
|
|
281
|
+
fetchOptions: opts.fetchOptions,
|
|
177
282
|
}),
|
|
178
283
|
}),
|
|
179
284
|
}),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@falcondev-oss/nuxt-layers-base",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.37.1",
|
|
5
5
|
"description": "Nuxt layer with lots of useful helpers and @nuxt/ui components",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@falcondev-oss/trpc-typed-form-data": "^0.5.3",
|
|
36
|
-
"@falcondev-oss/trpc-vue-query": "^0.
|
|
36
|
+
"@falcondev-oss/trpc-vue-query": "^0.6.1",
|
|
37
37
|
"@iconify-json/lucide": "^1.2.125",
|
|
38
38
|
"@nuxt/icon": "^2.5.0",
|
|
39
39
|
"@nuxtjs/color-mode": "^4.0.1",
|