@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.
@@ -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, 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, 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,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 toast = useToast()
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
- 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
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
- toast.add({
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
- 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
- })
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
- vueQueryState.value = dehydrate(queryClient)
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
- hydrate(queryClient, vueQueryState.value)
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.36.1",
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.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",