@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.
@@ -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
- export const useToast = createGlobalState(() => {
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
+ }
@@ -1,18 +1,23 @@
1
1
  import type { DehydratedState, QueryClientConfig, VueQueryPluginOptions } from '@tanstack/vue-query'
2
- import type { AnyTRPCRouter } from '@trpc/server'
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, TRPCClientError } from '@trpc/client'
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 toast = useToast()
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
- isTRPCClientError<AnyTRPCRouter>(error) &&
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
- toast.add({
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
- if (mutation.meta?.toast?.error) {
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
- vueQueryState.value = dehydrate(queryClient)
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
- hydrate(queryClient, vueQueryState.value)
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.36.1",
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.5.4",
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",