@falcondev-oss/nuxt-layers-base 0.38.0 → 0.38.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.
- package/app/assets/css/base.css +4 -0
- package/app/assets/css/theme.css +1 -0
- package/app/assets/css/variants.css +3 -0
- package/app/utils/plugins/helper/request-error.ts +82 -0
- package/app/utils/plugins/index.ts +3 -0
- package/app/utils/plugins/trpc.ts +120 -0
- package/app/utils/plugins/vue-query.ts +131 -0
- package/nuxt.config.ts +9 -0
- package/package.json +1 -1
- package/app/utils/plugins.ts +0 -295
package/app/assets/css/base.css
CHANGED
package/app/assets/css/theme.css
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
--color-brand-secondary-900: color-mix(in oklch, var(--color-brand-secondary), black 55%);
|
|
24
24
|
--color-brand-secondary-950: color-mix(in oklch, var(--color-brand-secondary), black 64%);
|
|
25
25
|
|
|
26
|
+
/* force usage of Nuxt UI gray classes */
|
|
26
27
|
--color-slate-*: initial;
|
|
27
28
|
--color-gray-*: initial;
|
|
28
29
|
--color-zinc-*: initial;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { TRPCClientError } from '@trpc/client'
|
|
2
|
+
import type { AnyTRPCRouter, TRPC_ERROR_CODE_KEY, TRPCDefaultErrorData } from '@trpc/server'
|
|
3
|
+
import type { ToastOptions } from '../../../composables/useToast'
|
|
4
|
+
import { isTRPCClientError } from '@trpc/client'
|
|
5
|
+
|
|
6
|
+
export interface ToastOpts {
|
|
7
|
+
title?: string
|
|
8
|
+
description?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** a request that never got a response, i.e. `fetch` itself failed (offline, DNS, CORS, …) */
|
|
12
|
+
function isNetworkError(err: unknown) {
|
|
13
|
+
return isTRPCClientError<AnyTRPCRouter>(err) && !err.meta?.response
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `AnyTRPCRouter` widens the error shape to `any` */
|
|
17
|
+
function errorData(err: TRPCClientError<AnyTRPCRouter>) {
|
|
18
|
+
return err.data as TRPCDefaultErrorData | undefined
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const retryableHttpStatuses = new Set([408, 425, 429, 502, 503, 504])
|
|
22
|
+
|
|
23
|
+
export function isRetryableError(err: unknown) {
|
|
24
|
+
// unknown errors are usually a bug in the query fn, which a retry won't fix
|
|
25
|
+
if (!isTRPCClientError<AnyTRPCRouter>(err)) return false
|
|
26
|
+
|
|
27
|
+
return isNetworkError(err) || retryableHttpStatuses.has(errorData(err)?.httpStatus ?? 0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** zod reports its issues as a JSON-encoded list in the error message */
|
|
31
|
+
function isSchemaIssueList(message: string) {
|
|
32
|
+
if (!message.startsWith('[')) return false
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const issues = JSON.parse(message) as unknown[]
|
|
36
|
+
return (
|
|
37
|
+
issues.length > 0 &&
|
|
38
|
+
issues.every((issue) => typeof (issue as { message?: unknown }).message === 'string')
|
|
39
|
+
)
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const errorTitles: Partial<Record<TRPC_ERROR_CODE_KEY, string>> = {
|
|
46
|
+
BAD_REQUEST: 'Ungültige Eingabe',
|
|
47
|
+
UNAUTHORIZED: 'Nicht angemeldet',
|
|
48
|
+
FORBIDDEN: 'Keine Berechtigung',
|
|
49
|
+
NOT_FOUND: 'Nicht vorhanden',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requestErrorToast(err: unknown): ToastOpts {
|
|
53
|
+
if (isNetworkError(err))
|
|
54
|
+
return { title: 'Keine Verbindung', description: 'Der Server ist nicht erreichbar.' }
|
|
55
|
+
|
|
56
|
+
if (!isTRPCClientError<AnyTRPCRouter>(err)) return { title: 'Unbekannter Fehler' }
|
|
57
|
+
|
|
58
|
+
// internal errors leak implementation details and mean nothing to the user
|
|
59
|
+
if (errorData(err)?.httpStatus === 500) return { title: 'Server-Fehler' }
|
|
60
|
+
|
|
61
|
+
const code = errorData(err)?.code
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
title: (code && errorTitles[code]) ?? 'Anfrage-Fehler',
|
|
65
|
+
description: isSchemaIssueList(err.message) ? 'Bitte Eingaben überprüfen.' : err.message,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** during SSR a toast would be serialized into the payload and pop up after hydration */
|
|
70
|
+
export function toastAdd(opts: ToastOptions) {
|
|
71
|
+
if (import.meta.server) return
|
|
72
|
+
|
|
73
|
+
useToast().add({ duration: 5000, ...opts })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** toasts a failed request, using `opts` if given, otherwise a generic message */
|
|
77
|
+
export function toastRequestError(err: unknown, opts?: ToastOpts) {
|
|
78
|
+
toastAdd({
|
|
79
|
+
preset: 'error',
|
|
80
|
+
...(opts ?? requestErrorToast(err)),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { OperationLink, TRPCLink } from '@trpc/client'
|
|
2
|
+
import type { AnyTRPCRouter } from '@trpc/server'
|
|
3
|
+
import type { FetchOptions } from 'ofetch'
|
|
4
|
+
import type { ObjectPlugin } from '#app'
|
|
5
|
+
import { typedFormDataLink } from '@falcondev-oss/trpc-typed-form-data/client'
|
|
6
|
+
import { createTRPCVueQueryClient, vueQueryContext } from '@falcondev-oss/trpc-vue-query'
|
|
7
|
+
import { useQueryClient } from '@tanstack/vue-query'
|
|
8
|
+
import { httpSubscriptionLink, splitLink } from '@trpc/client'
|
|
9
|
+
import { observable } from '@trpc/server/observable'
|
|
10
|
+
import superjsonDefault from 'superjson'
|
|
11
|
+
import { httpBatchLink, httpLink } from 'trpc-nuxt/client'
|
|
12
|
+
import { toastRequestError } from './helper/request-error'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Toasts errors of requests that vue-query doesn't handle itself, i.e. plain
|
|
16
|
+
* `.query()` / `.mutate()` calls. Requests made through vue-query get their toast
|
|
17
|
+
* from the query/mutation cache instead.
|
|
18
|
+
*/
|
|
19
|
+
const toastRequestErrors: OperationLink<AnyTRPCRouter> = ({ op, next }) =>
|
|
20
|
+
observable((observer) => {
|
|
21
|
+
const subscription = next(op).subscribe({
|
|
22
|
+
next: (value) => observer.next(value),
|
|
23
|
+
complete: () => observer.complete(),
|
|
24
|
+
error(err) {
|
|
25
|
+
if (!op.context[vueQueryContext] && op.type !== 'subscription') {
|
|
26
|
+
console.error(err)
|
|
27
|
+
toastRequestError(err)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
observer.error(err)
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return () => {
|
|
35
|
+
subscription.unsubscribe()
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
export const requestErrorToastLink: TRPCLink<AnyTRPCRouter> = () => toastRequestErrors
|
|
39
|
+
|
|
40
|
+
interface TrpcNuxtPluginOptions {
|
|
41
|
+
url: string
|
|
42
|
+
/**
|
|
43
|
+
* ofetch options passed to the HTTP links.
|
|
44
|
+
* @see https://github.com/unjs/ofetch
|
|
45
|
+
*/
|
|
46
|
+
fetchOptions?: FetchOptions
|
|
47
|
+
/**
|
|
48
|
+
* ofetch options for queries, merged over `fetchOptions`.
|
|
49
|
+
*/
|
|
50
|
+
queryFetchOptions?: FetchOptions
|
|
51
|
+
/**
|
|
52
|
+
* ofetch options for mutations, merged over `fetchOptions`.
|
|
53
|
+
*/
|
|
54
|
+
mutationFetchOptions?: FetchOptions
|
|
55
|
+
/**
|
|
56
|
+
* Custom superjson instance, e.g. one with registered custom transformers.
|
|
57
|
+
* @default superjson
|
|
58
|
+
*/
|
|
59
|
+
transformer?: typeof superjsonDefault
|
|
60
|
+
/**
|
|
61
|
+
* Batching options passed to the `httpBatchLink`.
|
|
62
|
+
*/
|
|
63
|
+
batchOptions?: {
|
|
64
|
+
maxURLLength?: number
|
|
65
|
+
maxItems?: number
|
|
66
|
+
methodOverride?: 'POST'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOptions) {
|
|
70
|
+
return {
|
|
71
|
+
name: 'trpc',
|
|
72
|
+
// eslint-disable-next-line ts/no-unsafe-assignment
|
|
73
|
+
dependsOn: ['vue-query'] as any,
|
|
74
|
+
setup() {
|
|
75
|
+
const queryClient = useQueryClient()
|
|
76
|
+
const headers = useRequestHeaders()
|
|
77
|
+
const superjson = opts.transformer ?? superjsonDefault
|
|
78
|
+
|
|
79
|
+
const trpc = createTRPCVueQueryClient<Router>({
|
|
80
|
+
queryClient,
|
|
81
|
+
trpc: {
|
|
82
|
+
links: [
|
|
83
|
+
requestErrorToastLink,
|
|
84
|
+
typedFormDataLink<AnyTRPCRouter>({ transformer: superjson }),
|
|
85
|
+
splitLink({
|
|
86
|
+
condition: (op) => op.type === 'subscription',
|
|
87
|
+
true: httpSubscriptionLink({
|
|
88
|
+
url: opts.url,
|
|
89
|
+
transformer: superjson,
|
|
90
|
+
}),
|
|
91
|
+
false: splitLink({
|
|
92
|
+
condition: (op) => op.type === 'mutation',
|
|
93
|
+
true: httpLink({
|
|
94
|
+
transformer: superjson,
|
|
95
|
+
url: opts.url,
|
|
96
|
+
headers,
|
|
97
|
+
fetchOptions: { ...opts.fetchOptions, ...opts.mutationFetchOptions },
|
|
98
|
+
}),
|
|
99
|
+
false: httpBatchLink({
|
|
100
|
+
transformer: superjson,
|
|
101
|
+
url: opts.url,
|
|
102
|
+
headers,
|
|
103
|
+
maxURLLength: 2000,
|
|
104
|
+
...opts.batchOptions,
|
|
105
|
+
fetchOptions: { ...opts.fetchOptions, ...opts.queryFetchOptions },
|
|
106
|
+
}),
|
|
107
|
+
}),
|
|
108
|
+
}),
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
provide: {
|
|
115
|
+
trpc,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
} satisfies ObjectPlugin
|
|
120
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
DefaultOptions,
|
|
3
|
+
DehydratedState,
|
|
4
|
+
QueryClientConfig,
|
|
5
|
+
VueQueryPluginOptions,
|
|
6
|
+
} from '@tanstack/vue-query'
|
|
7
|
+
import type { ObjectPlugin } from '#app'
|
|
8
|
+
import type { ToastOpts } from './helper/request-error'
|
|
9
|
+
import {
|
|
10
|
+
dehydrate,
|
|
11
|
+
hydrate,
|
|
12
|
+
MutationCache,
|
|
13
|
+
QueryCache,
|
|
14
|
+
QueryClient,
|
|
15
|
+
useIsFetching,
|
|
16
|
+
VueQueryPlugin,
|
|
17
|
+
} from '@tanstack/vue-query'
|
|
18
|
+
import defu from 'defu'
|
|
19
|
+
import { useState } from '#app'
|
|
20
|
+
import { isRetryableError, toastAdd, toastRequestError } from './helper/request-error'
|
|
21
|
+
|
|
22
|
+
interface VueQueryNuxtPluginOptions {
|
|
23
|
+
queryDefaultOptions?: DefaultOptions
|
|
24
|
+
vuePluginOptions?: VueQueryPluginOptions
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface CustomMeta {
|
|
28
|
+
queryMeta: {
|
|
29
|
+
toast?: {
|
|
30
|
+
error?: ToastOpts
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
mutationMeta: {
|
|
34
|
+
toast?: {
|
|
35
|
+
success?: ToastOpts
|
|
36
|
+
error?: ToastOpts
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
declare module '@tanstack/vue-query' {
|
|
42
|
+
interface Register extends CustomMeta {}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
46
|
+
return {
|
|
47
|
+
name: 'vue-query',
|
|
48
|
+
setup(nuxt) {
|
|
49
|
+
const vueQueryState = useState<Partial<DehydratedState>>('vue-query', () => ({}))
|
|
50
|
+
|
|
51
|
+
const queryClient = new QueryClient(
|
|
52
|
+
defu<QueryClientConfig, QueryClientConfig[]>(
|
|
53
|
+
{ defaultOptions: opts?.queryDefaultOptions },
|
|
54
|
+
{
|
|
55
|
+
defaultOptions: {
|
|
56
|
+
queries: {
|
|
57
|
+
retry(failureCount, error) {
|
|
58
|
+
if (!isRetryableError(error)) return false
|
|
59
|
+
|
|
60
|
+
return failureCount < 3
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
queryCache: new QueryCache({
|
|
65
|
+
onError(err, query) {
|
|
66
|
+
console.error(err)
|
|
67
|
+
|
|
68
|
+
toastRequestError(err, query.meta?.toast?.error)
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
mutationCache: new MutationCache({
|
|
72
|
+
onSuccess(_res, _input, _onMutateRes, mutation) {
|
|
73
|
+
if (mutation.meta?.toast?.success) {
|
|
74
|
+
toastAdd({
|
|
75
|
+
preset: 'success',
|
|
76
|
+
...mutation.meta.toast.success,
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
onError(err, _input, __onMutateRes, mutation) {
|
|
81
|
+
console.error(err)
|
|
82
|
+
|
|
83
|
+
toastRequestError(err, mutation.meta?.toast?.error)
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const options: VueQueryPluginOptions = { queryClient, ...opts?.vuePluginOptions }
|
|
91
|
+
nuxt.vueApp.use(VueQueryPlugin, options)
|
|
92
|
+
|
|
93
|
+
if (import.meta.server) {
|
|
94
|
+
nuxt.hooks.hook('app:rendered', () => {
|
|
95
|
+
try {
|
|
96
|
+
vueQueryState.value = dehydrate(queryClient)
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('[vue-query] dehydrating state failed:', err)
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (import.meta.client) {
|
|
104
|
+
nuxt.hooks.hook('app:created', () => {
|
|
105
|
+
try {
|
|
106
|
+
hydrate(queryClient, vueQueryState.value)
|
|
107
|
+
} catch (err) {
|
|
108
|
+
console.error('[vue-query] hydrating state failed:', err)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// global nuxt loading indicator
|
|
114
|
+
const loadingIndicator = useLoadingIndicator()
|
|
115
|
+
const isFetching = useIsFetching()
|
|
116
|
+
|
|
117
|
+
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
118
|
+
watch(isFetching, () => {
|
|
119
|
+
if (isFetching.value > 0) {
|
|
120
|
+
loadingIndicator.start()
|
|
121
|
+
timeout = setTimeout(() => {
|
|
122
|
+
loadingIndicator.set(0)
|
|
123
|
+
}, 300)
|
|
124
|
+
} else {
|
|
125
|
+
if (timeout) clearTimeout(timeout)
|
|
126
|
+
loadingIndicator.finish()
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
},
|
|
130
|
+
} satisfies ObjectPlugin
|
|
131
|
+
}
|
package/nuxt.config.ts
CHANGED
|
@@ -44,6 +44,11 @@ export default defineNuxtConfig({
|
|
|
44
44
|
},
|
|
45
45
|
},
|
|
46
46
|
imports: {
|
|
47
|
+
dirs: [
|
|
48
|
+
// layer configs resolve `~` against the consuming project, so use absolute paths
|
|
49
|
+
path.join(currentDir, './app/types'),
|
|
50
|
+
path.join(currentDir, './app/utils/*/index.ts'),
|
|
51
|
+
],
|
|
47
52
|
imports: [
|
|
48
53
|
{
|
|
49
54
|
from: '@falcondev-oss/form-vue',
|
|
@@ -60,6 +65,10 @@ export default defineNuxtConfig({
|
|
|
60
65
|
},
|
|
61
66
|
],
|
|
62
67
|
},
|
|
68
|
+
sourcemap: {
|
|
69
|
+
client: 'hidden',
|
|
70
|
+
server: true,
|
|
71
|
+
},
|
|
63
72
|
|
|
64
73
|
// runtime
|
|
65
74
|
experimental: {
|
package/package.json
CHANGED
package/app/utils/plugins.ts
DELETED
|
@@ -1,295 +0,0 @@
|
|
|
1
|
-
import type { DehydratedState, QueryClientConfig, VueQueryPluginOptions } from '@tanstack/vue-query'
|
|
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'
|
|
5
|
-
import type { ObjectPlugin } from '#app'
|
|
6
|
-
import type { ToastOptions } from '../composables/useToast'
|
|
7
|
-
import { typedFormDataLink } from '@falcondev-oss/trpc-typed-form-data/client'
|
|
8
|
-
import { createTRPCVueQueryClient, vueQueryContext } from '@falcondev-oss/trpc-vue-query'
|
|
9
|
-
import {
|
|
10
|
-
dehydrate,
|
|
11
|
-
hydrate,
|
|
12
|
-
MutationCache,
|
|
13
|
-
QueryCache,
|
|
14
|
-
QueryClient,
|
|
15
|
-
useIsFetching,
|
|
16
|
-
useQueryClient,
|
|
17
|
-
VueQueryPlugin,
|
|
18
|
-
} from '@tanstack/vue-query'
|
|
19
|
-
import { httpSubscriptionLink, isTRPCClientError, splitLink } from '@trpc/client'
|
|
20
|
-
import { observable } from '@trpc/server/observable'
|
|
21
|
-
import defu from 'defu'
|
|
22
|
-
import superjson from 'superjson'
|
|
23
|
-
import { httpBatchLink, httpLink } from 'trpc-nuxt/client'
|
|
24
|
-
import { useState } from '#app'
|
|
25
|
-
|
|
26
|
-
interface VueQueryNuxtPluginOptions {
|
|
27
|
-
queryClientOptions?: QueryClientConfig
|
|
28
|
-
vuePluginOptions?: VueQueryPluginOptions
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface ToastOpts {
|
|
32
|
-
title?: string
|
|
33
|
-
description?: string
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface CustomMeta {
|
|
37
|
-
queryMeta: {
|
|
38
|
-
toast?: {
|
|
39
|
-
error?: ToastOpts
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
mutationMeta: {
|
|
43
|
-
toast?: {
|
|
44
|
-
success?: ToastOpts
|
|
45
|
-
error?: ToastOpts
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
declare module '@tanstack/vue-query' {
|
|
51
|
-
interface Register extends CustomMeta {}
|
|
52
|
-
}
|
|
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: 'Keine Berechtigung',
|
|
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
|
-
|
|
127
|
-
export function vueQueryPlugin(opts?: VueQueryNuxtPluginOptions) {
|
|
128
|
-
return {
|
|
129
|
-
name: 'vue-query',
|
|
130
|
-
setup(nuxt) {
|
|
131
|
-
const vueQueryState = useState<Partial<DehydratedState>>('vue-query', () => ({}))
|
|
132
|
-
|
|
133
|
-
const queryClient = new QueryClient(
|
|
134
|
-
defu<QueryClientConfig, QueryClientConfig[]>(opts?.queryClientOptions, {
|
|
135
|
-
defaultOptions: {
|
|
136
|
-
queries: {
|
|
137
|
-
retry(failureCount, error) {
|
|
138
|
-
if (!isRetryableError(error)) return false
|
|
139
|
-
|
|
140
|
-
return failureCount < 3
|
|
141
|
-
},
|
|
142
|
-
},
|
|
143
|
-
},
|
|
144
|
-
queryCache: new QueryCache({
|
|
145
|
-
onError(err, query) {
|
|
146
|
-
console.error(err)
|
|
147
|
-
|
|
148
|
-
toastRequestError(err, query.meta?.toast?.error)
|
|
149
|
-
},
|
|
150
|
-
}),
|
|
151
|
-
mutationCache: new MutationCache({
|
|
152
|
-
onSuccess(_res, _input, _onMutateRes, mutation) {
|
|
153
|
-
if (mutation.meta?.toast?.success) {
|
|
154
|
-
toastAdd({
|
|
155
|
-
preset: 'success',
|
|
156
|
-
...mutation.meta.toast.success,
|
|
157
|
-
})
|
|
158
|
-
}
|
|
159
|
-
},
|
|
160
|
-
onError(err, _input, __onMutateRes, mutation) {
|
|
161
|
-
console.error(err)
|
|
162
|
-
|
|
163
|
-
toastRequestError(err, mutation.meta?.toast?.error)
|
|
164
|
-
},
|
|
165
|
-
}),
|
|
166
|
-
}),
|
|
167
|
-
)
|
|
168
|
-
|
|
169
|
-
const options: VueQueryPluginOptions = { queryClient, ...opts?.vuePluginOptions }
|
|
170
|
-
nuxt.vueApp.use(VueQueryPlugin, options)
|
|
171
|
-
|
|
172
|
-
if (import.meta.server) {
|
|
173
|
-
nuxt.hooks.hook('app:rendered', () => {
|
|
174
|
-
try {
|
|
175
|
-
vueQueryState.value = dehydrate(queryClient)
|
|
176
|
-
} catch (err) {
|
|
177
|
-
console.error('[vue-query] dehydrating state failed:', err)
|
|
178
|
-
}
|
|
179
|
-
})
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (import.meta.client) {
|
|
183
|
-
nuxt.hooks.hook('app:created', () => {
|
|
184
|
-
try {
|
|
185
|
-
hydrate(queryClient, vueQueryState.value)
|
|
186
|
-
} catch (err) {
|
|
187
|
-
console.error('[vue-query] hydrating state failed:', err)
|
|
188
|
-
}
|
|
189
|
-
})
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// global nuxt loading indicator
|
|
193
|
-
const loadingIndicator = useLoadingIndicator()
|
|
194
|
-
const isFetching = useIsFetching()
|
|
195
|
-
|
|
196
|
-
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
197
|
-
watch(isFetching, () => {
|
|
198
|
-
if (isFetching.value > 0) {
|
|
199
|
-
loadingIndicator.start()
|
|
200
|
-
timeout = setTimeout(() => {
|
|
201
|
-
loadingIndicator.set(0)
|
|
202
|
-
}, 300)
|
|
203
|
-
} else {
|
|
204
|
-
if (timeout) clearTimeout(timeout)
|
|
205
|
-
loadingIndicator.finish()
|
|
206
|
-
}
|
|
207
|
-
})
|
|
208
|
-
},
|
|
209
|
-
} satisfies ObjectPlugin
|
|
210
|
-
}
|
|
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
|
-
|
|
238
|
-
interface TrpcNuxtPluginOptions {
|
|
239
|
-
url: string
|
|
240
|
-
/**
|
|
241
|
-
* ofetch options passed to the HTTP links.
|
|
242
|
-
* @see https://github.com/unjs/ofetch
|
|
243
|
-
*/
|
|
244
|
-
fetchOptions?: FetchOptions
|
|
245
|
-
}
|
|
246
|
-
export function trpcPlugin<Router extends AnyTRPCRouter>(opts: TrpcNuxtPluginOptions) {
|
|
247
|
-
return {
|
|
248
|
-
name: 'trpc',
|
|
249
|
-
// eslint-disable-next-line ts/no-unsafe-assignment
|
|
250
|
-
dependsOn: ['vue-query'] as any,
|
|
251
|
-
setup() {
|
|
252
|
-
const queryClient = useQueryClient()
|
|
253
|
-
|
|
254
|
-
const trpc = createTRPCVueQueryClient<Router>({
|
|
255
|
-
queryClient,
|
|
256
|
-
trpc: {
|
|
257
|
-
links: [
|
|
258
|
-
requestErrorToastLink,
|
|
259
|
-
splitLink({
|
|
260
|
-
condition: (op) => op.type === 'subscription',
|
|
261
|
-
true: httpSubscriptionLink({
|
|
262
|
-
url: opts.url,
|
|
263
|
-
transformer: superjson,
|
|
264
|
-
}),
|
|
265
|
-
false: splitLink({
|
|
266
|
-
condition: (op) => op.type === 'mutation',
|
|
267
|
-
true: [
|
|
268
|
-
typedFormDataLink<AnyTRPCRouter>({
|
|
269
|
-
transformer: superjson,
|
|
270
|
-
}),
|
|
271
|
-
httpLink({
|
|
272
|
-
transformer: superjson,
|
|
273
|
-
url: opts.url,
|
|
274
|
-
fetchOptions: opts.fetchOptions,
|
|
275
|
-
}),
|
|
276
|
-
],
|
|
277
|
-
false: httpBatchLink({
|
|
278
|
-
transformer: superjson,
|
|
279
|
-
url: opts.url,
|
|
280
|
-
maxURLLength: 2000,
|
|
281
|
-
fetchOptions: opts.fetchOptions,
|
|
282
|
-
}),
|
|
283
|
-
}),
|
|
284
|
-
}),
|
|
285
|
-
],
|
|
286
|
-
},
|
|
287
|
-
})
|
|
288
|
-
return {
|
|
289
|
-
provide: {
|
|
290
|
-
trpc,
|
|
291
|
-
},
|
|
292
|
-
}
|
|
293
|
-
},
|
|
294
|
-
} satisfies ObjectPlugin
|
|
295
|
-
}
|