@falcondev-oss/nuxt-layers-base 0.36.0 → 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 +134 -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,21 +1,27 @@
|
|
|
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'
|
|
24
|
+
import { useState } from '#app'
|
|
19
25
|
|
|
20
26
|
interface VueQueryNuxtPluginOptions {
|
|
21
27
|
queryClientOptions?: QueryClientConfig
|
|
@@ -28,6 +34,11 @@ interface ToastOpts {
|
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export interface CustomMeta {
|
|
37
|
+
queryMeta: {
|
|
38
|
+
toast?: {
|
|
39
|
+
error?: ToastOpts
|
|
40
|
+
}
|
|
41
|
+
}
|
|
31
42
|
mutationMeta: {
|
|
32
43
|
toast?: {
|
|
33
44
|
success?: ToastOpts
|
|
@@ -40,37 +51,98 @@ declare module '@tanstack/vue-query' {
|
|
|
40
51
|
interface Register extends CustomMeta {}
|
|
41
52
|
}
|
|
42
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
|
+
|
|
43
117
|
export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
44
118
|
return {
|
|
45
119
|
name: 'vue-query',
|
|
46
120
|
setup(nuxt) {
|
|
47
|
-
const
|
|
48
|
-
const vueQueryState = useState<Partial<DehydratedState>>('vue-query')
|
|
121
|
+
const vueQueryState = useState<Partial<DehydratedState>>('vue-query', () => ({}))
|
|
49
122
|
|
|
50
123
|
const queryClient = new QueryClient(
|
|
51
124
|
defu<QueryClientConfig, QueryClientConfig[]>(opts?.queryClientOptions, {
|
|
52
125
|
defaultOptions: {
|
|
53
126
|
queries: {
|
|
54
127
|
retry(failureCount, error) {
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
error.data &&
|
|
58
|
-
// eslint-disable-next-line ts/no-unsafe-member-access
|
|
59
|
-
error.data.httpStatus >= 400 &&
|
|
60
|
-
// eslint-disable-next-line ts/no-unsafe-member-access
|
|
61
|
-
error.data.httpStatus < 500
|
|
62
|
-
)
|
|
63
|
-
return false
|
|
128
|
+
if (!isRetryableError(error)) return false
|
|
129
|
+
|
|
64
130
|
return failureCount < 3
|
|
65
131
|
},
|
|
66
132
|
},
|
|
67
133
|
},
|
|
134
|
+
queryCache: new QueryCache({
|
|
135
|
+
onError(err, query) {
|
|
136
|
+
console.error(err)
|
|
137
|
+
|
|
138
|
+
toastRequestError(err, query.meta?.toast?.error)
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
68
141
|
mutationCache: new MutationCache({
|
|
69
142
|
onSuccess(_res, _input, _onMutateRes, mutation) {
|
|
70
143
|
if (mutation.meta?.toast?.success) {
|
|
71
|
-
|
|
144
|
+
toastAdd({
|
|
72
145
|
preset: 'success',
|
|
73
|
-
duration: 5000,
|
|
74
146
|
...mutation.meta.toast.success,
|
|
75
147
|
})
|
|
76
148
|
}
|
|
@@ -78,42 +150,32 @@ export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
|
78
150
|
onError(err, _input, __onMutateRes, mutation) {
|
|
79
151
|
console.error(err)
|
|
80
152
|
|
|
81
|
-
|
|
82
|
-
toast.add({
|
|
83
|
-
preset: 'error',
|
|
84
|
-
duration: 5000,
|
|
85
|
-
...mutation.meta.toast.error,
|
|
86
|
-
})
|
|
87
|
-
} else if (err instanceof TRPCClientError)
|
|
88
|
-
toast.add({
|
|
89
|
-
preset: 'error',
|
|
90
|
-
title: 'Request Error',
|
|
91
|
-
description: err.message,
|
|
92
|
-
duration: 5000,
|
|
93
|
-
})
|
|
94
|
-
else
|
|
95
|
-
toast.add({
|
|
96
|
-
preset: 'error',
|
|
97
|
-
title: 'An unknown error occurred',
|
|
98
|
-
duration: 5000,
|
|
99
|
-
})
|
|
153
|
+
toastRequestError(err, mutation.meta?.toast?.error)
|
|
100
154
|
},
|
|
101
155
|
}),
|
|
102
156
|
}),
|
|
103
157
|
)
|
|
104
|
-
const options: VueQueryPluginOptions = { queryClient, ...opts?.vuePluginOptions }
|
|
105
158
|
|
|
159
|
+
const options: VueQueryPluginOptions = { queryClient, ...opts?.vuePluginOptions }
|
|
106
160
|
nuxt.vueApp.use(VueQueryPlugin, options)
|
|
107
161
|
|
|
108
162
|
if (import.meta.server) {
|
|
109
163
|
nuxt.hooks.hook('app:rendered', () => {
|
|
110
|
-
|
|
164
|
+
try {
|
|
165
|
+
vueQueryState.value = dehydrate(queryClient)
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error('[vue-query] dehydrating state failed:', err)
|
|
168
|
+
}
|
|
111
169
|
})
|
|
112
170
|
}
|
|
113
171
|
|
|
114
172
|
if (import.meta.client) {
|
|
115
173
|
nuxt.hooks.hook('app:created', () => {
|
|
116
|
-
|
|
174
|
+
try {
|
|
175
|
+
hydrate(queryClient, vueQueryState.value)
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error('[vue-query] hydrating state failed:', err)
|
|
178
|
+
}
|
|
117
179
|
})
|
|
118
180
|
}
|
|
119
181
|
|
|
@@ -137,8 +199,39 @@ export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
|
137
199
|
} satisfies ObjectPlugin
|
|
138
200
|
}
|
|
139
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
|
+
|
|
140
228
|
interface TrpcNuxtPluginOptions {
|
|
141
229
|
url: string
|
|
230
|
+
/**
|
|
231
|
+
* ofetch options passed to the HTTP links.
|
|
232
|
+
* @see https://github.com/unjs/ofetch
|
|
233
|
+
*/
|
|
234
|
+
fetchOptions?: FetchOptions
|
|
142
235
|
}
|
|
143
236
|
export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOptions) {
|
|
144
237
|
return {
|
|
@@ -152,6 +245,7 @@ export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOpt
|
|
|
152
245
|
queryClient,
|
|
153
246
|
trpc: {
|
|
154
247
|
links: [
|
|
248
|
+
requestErrorToastLink,
|
|
155
249
|
splitLink({
|
|
156
250
|
condition: (op) => op.type === 'subscription',
|
|
157
251
|
true: httpSubscriptionLink({
|
|
@@ -167,12 +261,14 @@ export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOpt
|
|
|
167
261
|
httpLink({
|
|
168
262
|
transformer: superjson,
|
|
169
263
|
url: opts.url,
|
|
264
|
+
fetchOptions: opts.fetchOptions,
|
|
170
265
|
}),
|
|
171
266
|
],
|
|
172
267
|
false: httpBatchLink({
|
|
173
268
|
transformer: superjson,
|
|
174
269
|
url: opts.url,
|
|
175
270
|
maxURLLength: 2000,
|
|
271
|
+
fetchOptions: opts.fetchOptions,
|
|
176
272
|
}),
|
|
177
273
|
}),
|
|
178
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",
|