@falcondev-oss/nuxt-layers-base 0.36.1 → 0.37.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/app/composables/useToast.ts +17 -3
- package/app/utils/plugins.ts +133 -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, 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,98 @@ 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
|
+
function requestErrorToast(err: unknown): ToastOpts {
|
|
89
|
+
if (isNetworkError(err))
|
|
90
|
+
return { title: 'Keine Verbindung', description: 'Der Server ist nicht erreichbar.' }
|
|
91
|
+
|
|
92
|
+
if (!isTRPCClientError<AnyTRPCRouter>(err)) return { title: 'Unbekannter Fehler' }
|
|
93
|
+
|
|
94
|
+
if (errorData(err)?.code !== 'BAD_REQUEST') return { title: 'Anfrage-Fehler' }
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
title: 'Ungültige Eingabe',
|
|
98
|
+
description: isSchemaIssueList(err.message) ? 'Bitte Eingaben überprüfen.' : err.message,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** during SSR a toast would be serialized into the payload and pop up after hydration */
|
|
103
|
+
function toastAdd(opts: ToastOptions) {
|
|
104
|
+
if (import.meta.server) return
|
|
105
|
+
|
|
106
|
+
useToast().add({ duration: 5000, ...opts })
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** toasts a failed request, using `opts` if given, otherwise a generic message */
|
|
110
|
+
function toastRequestError(err: unknown, opts?: ToastOpts) {
|
|
111
|
+
toastAdd({
|
|
112
|
+
preset: 'error',
|
|
113
|
+
...(opts ?? requestErrorToast(err)),
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
44
117
|
export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
45
118
|
return {
|
|
46
119
|
name: 'vue-query',
|
|
47
120
|
setup(nuxt) {
|
|
48
|
-
const
|
|
49
|
-
const vueQueryState = useState<Partial<DehydratedState>>('vue-query')
|
|
121
|
+
const vueQueryState = useState<Partial<DehydratedState>>('vue-query', () => ({}))
|
|
50
122
|
|
|
51
123
|
const queryClient = new QueryClient(
|
|
52
124
|
defu<QueryClientConfig, QueryClientConfig[]>(opts?.queryClientOptions, {
|
|
53
125
|
defaultOptions: {
|
|
54
126
|
queries: {
|
|
55
127
|
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
|
|
128
|
+
if (!isRetryableError(error)) return false
|
|
129
|
+
|
|
65
130
|
return failureCount < 3
|
|
66
131
|
},
|
|
67
132
|
},
|
|
68
133
|
},
|
|
134
|
+
queryCache: new QueryCache({
|
|
135
|
+
onError(err, query) {
|
|
136
|
+
console.error(err)
|
|
137
|
+
|
|
138
|
+
toastRequestError(err, query.meta?.toast?.error)
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
69
141
|
mutationCache: new MutationCache({
|
|
70
142
|
onSuccess(_res, _input, _onMutateRes, mutation) {
|
|
71
143
|
if (mutation.meta?.toast?.success) {
|
|
72
|
-
|
|
144
|
+
toastAdd({
|
|
73
145
|
preset: 'success',
|
|
74
|
-
duration: 5000,
|
|
75
146
|
...mutation.meta.toast.success,
|
|
76
147
|
})
|
|
77
148
|
}
|
|
@@ -79,42 +150,32 @@ export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
|
79
150
|
onError(err, _input, __onMutateRes, mutation) {
|
|
80
151
|
console.error(err)
|
|
81
152
|
|
|
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
|
-
})
|
|
153
|
+
toastRequestError(err, mutation.meta?.toast?.error)
|
|
101
154
|
},
|
|
102
155
|
}),
|
|
103
156
|
}),
|
|
104
157
|
)
|
|
105
|
-
const options: VueQueryPluginOptions = { queryClient, ...opts?.vuePluginOptions }
|
|
106
158
|
|
|
159
|
+
const options: VueQueryPluginOptions = { queryClient, ...opts?.vuePluginOptions }
|
|
107
160
|
nuxt.vueApp.use(VueQueryPlugin, options)
|
|
108
161
|
|
|
109
162
|
if (import.meta.server) {
|
|
110
163
|
nuxt.hooks.hook('app:rendered', () => {
|
|
111
|
-
|
|
164
|
+
try {
|
|
165
|
+
vueQueryState.value = dehydrate(queryClient)
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error('[vue-query] dehydrating state failed:', err)
|
|
168
|
+
}
|
|
112
169
|
})
|
|
113
170
|
}
|
|
114
171
|
|
|
115
172
|
if (import.meta.client) {
|
|
116
173
|
nuxt.hooks.hook('app:created', () => {
|
|
117
|
-
|
|
174
|
+
try {
|
|
175
|
+
hydrate(queryClient, vueQueryState.value)
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error('[vue-query] hydrating state failed:', err)
|
|
178
|
+
}
|
|
118
179
|
})
|
|
119
180
|
}
|
|
120
181
|
|
|
@@ -138,8 +199,39 @@ export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
|
138
199
|
} satisfies ObjectPlugin
|
|
139
200
|
}
|
|
140
201
|
|
|
202
|
+
/**
|
|
203
|
+
* Toasts errors of requests that vue-query doesn't handle itself, i.e. plain
|
|
204
|
+
* `.query()` / `.mutate()` calls. Requests made through vue-query get their toast
|
|
205
|
+
* from the query/mutation cache instead.
|
|
206
|
+
*/
|
|
207
|
+
const toastRequestErrors: OperationLink<AnyTRPCRouter> = ({ op, next }) =>
|
|
208
|
+
observable((observer) => {
|
|
209
|
+
const subscription = next(op).subscribe({
|
|
210
|
+
next: (value) => observer.next(value),
|
|
211
|
+
complete: () => observer.complete(),
|
|
212
|
+
error(err) {
|
|
213
|
+
if (!op.context[vueQueryContext] && op.type !== 'subscription') {
|
|
214
|
+
console.error(err)
|
|
215
|
+
toastRequestError(err)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
observer.error(err)
|
|
219
|
+
},
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
return () => {
|
|
223
|
+
subscription.unsubscribe()
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
export const requestErrorToastLink: TRPCLink<AnyTRPCRouter> = () => toastRequestErrors
|
|
227
|
+
|
|
141
228
|
interface TrpcNuxtPluginOptions {
|
|
142
229
|
url: string
|
|
230
|
+
/**
|
|
231
|
+
* ofetch options passed to the HTTP links.
|
|
232
|
+
* @see https://github.com/unjs/ofetch
|
|
233
|
+
*/
|
|
234
|
+
fetchOptions?: FetchOptions
|
|
143
235
|
}
|
|
144
236
|
export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOptions) {
|
|
145
237
|
return {
|
|
@@ -153,6 +245,7 @@ export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOpt
|
|
|
153
245
|
queryClient,
|
|
154
246
|
trpc: {
|
|
155
247
|
links: [
|
|
248
|
+
requestErrorToastLink,
|
|
156
249
|
splitLink({
|
|
157
250
|
condition: (op) => op.type === 'subscription',
|
|
158
251
|
true: httpSubscriptionLink({
|
|
@@ -168,12 +261,14 @@ export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOpt
|
|
|
168
261
|
httpLink({
|
|
169
262
|
transformer: superjson,
|
|
170
263
|
url: opts.url,
|
|
264
|
+
fetchOptions: opts.fetchOptions,
|
|
171
265
|
}),
|
|
172
266
|
],
|
|
173
267
|
false: httpBatchLink({
|
|
174
268
|
transformer: superjson,
|
|
175
269
|
url: opts.url,
|
|
176
270
|
maxURLLength: 2000,
|
|
271
|
+
fetchOptions: opts.fetchOptions,
|
|
177
272
|
}),
|
|
178
273
|
}),
|
|
179
274
|
}),
|
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.0",
|
|
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",
|