@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.
@@ -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,21 +1,27 @@
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'
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 toast = useToast()
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
- isTRPCClientError<AnyTRPCRouter>(error) &&
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
- toast.add({
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
- if (mutation.meta?.toast?.error) {
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
- vueQueryState.value = dehydrate(queryClient)
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
- hydrate(queryClient, vueQueryState.value)
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.36.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.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",