mirror of
https://github.com/ArthurDanjou/trpc-nuxt.git
synced 2026-01-25 09:20:33 +01:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88c77f6e8f | ||
|
|
eafc476544 | ||
|
|
dc86c0252e | ||
|
|
a3dadb9e50 | ||
|
|
5331535237 | ||
|
|
ae0abd2b45 | ||
|
|
273215c9e1 | ||
|
|
812ceda4a0 | ||
|
|
c48556e24e | ||
|
|
f3e0165dd8 | ||
|
|
87ed453425 | ||
|
|
d890633cb1 | ||
|
|
a48ee2551e |
22
README.md
22
README.md
@@ -4,6 +4,17 @@
|
|||||||
|
|
||||||
End-to-end typesafe APIs with [tRPC.io](https://trpc.io/) in Nuxt applications.
|
End-to-end typesafe APIs with [tRPC.io](https://trpc.io/) in Nuxt applications.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<figure>
|
||||||
|
<img src="https://i.imgur.com/AjmNUxj.gif" alt="Demo" />
|
||||||
|
<figcaption>
|
||||||
|
<p align="center">
|
||||||
|
The client above is <strong>not</strong> importing any code from the server, only its type declarations.
|
||||||
|
</p>
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</p>
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -63,11 +74,9 @@ const client = useClient() // auto-imported
|
|||||||
|
|
||||||
const users = await client.query('getUsers')
|
const users = await client.query('getUsers')
|
||||||
|
|
||||||
const addUser = async () => {
|
const newUser = await client.mutation('createUser', {
|
||||||
const newUser = await client.mutation('createUser', {
|
name: 'wagmi'
|
||||||
name: 'wagmi'
|
})
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## useAsyncQuery
|
## useAsyncQuery
|
||||||
@@ -86,7 +95,7 @@ const {
|
|||||||
refresh
|
refresh
|
||||||
} = await useAsyncQuery(['getUser', { id: 69 }], {
|
} = await useAsyncQuery(['getUser', { id: 69 }], {
|
||||||
// pass useAsyncData options here
|
// pass useAsyncData options here
|
||||||
server: true
|
lazy: false
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -131,6 +140,7 @@ export const onError = (payload: OnErrorPayload<typeof router>) => {
|
|||||||
|
|
||||||
- [Validation](/recipes/validation.md)
|
- [Validation](/recipes/validation.md)
|
||||||
- [Authorization](/recipes/authorization.md)
|
- [Authorization](/recipes/authorization.md)
|
||||||
|
- [Merging Routers](/recipes/merging-routers.md)
|
||||||
- [Error Handling](/recipes/error-handling.md)
|
- [Error Handling](/recipes/error-handling.md)
|
||||||
- [Error Formatting](/recipes/error-formatting.md)
|
- [Error Formatting](/recipes/error-formatting.md)
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "trpc-nuxt",
|
"name": "trpc-nuxt",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.1.1",
|
"version": "0.1.2",
|
||||||
"packageManager": "pnpm@7.1.0",
|
"packageManager": "pnpm@7.1.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"main": "./dist/module.cjs",
|
"main": "./dist/module.cjs",
|
||||||
"types": "./dist/types.d.ts",
|
"types": "./dist/types.d.ts",
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"prepublishOnly": "nr build",
|
"prepublishOnly": "nr build",
|
||||||
"build": "nuxt-module-build",
|
"build": "nuxt-module-build",
|
||||||
"play": "nr build && nuxi dev playground",
|
"play": "nr build && nuxi dev playground",
|
||||||
|
"build:playground": "nuxi build playground",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"release": "bumpp --commit --push --tag && pnpm publish",
|
"release": "bumpp --commit --push --tag && pnpm publish",
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
"eslint": "^8.14.0",
|
"eslint": "^8.14.0",
|
||||||
"nuxt": "^3.0.0-rc.3",
|
"nuxt": "^3.0.0-rc.3",
|
||||||
"ohash": "^0.1.0",
|
"ohash": "^0.1.0",
|
||||||
"pnpm": "^7.1.0",
|
"pnpm": "^7.1.1",
|
||||||
"superjson": "^1.9.1",
|
"superjson": "^1.9.1",
|
||||||
"trpc-nuxt": "workspace:*",
|
"trpc-nuxt": "workspace:*",
|
||||||
"zod": "^3.16.0"
|
"zod": "^3.16.0"
|
||||||
|
|||||||
@@ -1,18 +1,5 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
const { data, pending, error } = await useAsyncQuery(['getUser', { username: 'jcena' }], {
|
|
||||||
lazy: false,
|
|
||||||
transform: data => data || null,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="pending">
|
<div>
|
||||||
Loading...
|
<NuxtPage />
|
||||||
</div>
|
|
||||||
<div v-else-if="error">
|
|
||||||
Error: {{ JSON.stringify(error, null, 2) }}
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
User {{ JSON.stringify(data, null, 2) }}
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineNuxtConfig } from 'nuxt'
|
import { defineNuxtConfig } from 'nuxt'
|
||||||
import Module from '..'
|
import Module from '../src/module'
|
||||||
|
|
||||||
// https://v3.nuxtjs.org/api/configuration/nuxt.config
|
// https://v3.nuxtjs.org/api/configuration/nuxt.config
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
|
|||||||
55
playground/pages/index.vue
Normal file
55
playground/pages/index.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const client = useClient()
|
||||||
|
const { data: todos, pending, error, refresh } = await useAsyncQuery(['getTodos'])
|
||||||
|
|
||||||
|
const addTodo = async () => {
|
||||||
|
const title = Math.random().toString(36).slice(2, 7)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await client.mutation('addTodo', {
|
||||||
|
id: Date.now(),
|
||||||
|
userId: 69,
|
||||||
|
title,
|
||||||
|
completed: false,
|
||||||
|
})
|
||||||
|
console.log('Todo: ', result)
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.log(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="pending">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error?.data?.code">
|
||||||
|
Error: {{ error.data.code }}
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<ul>
|
||||||
|
<li v-for="t in todos.slice(0, 10)" :key="t.id">
|
||||||
|
<NuxtLink :class="{ completed: t.completed }" :to="`/todo/${t.id}`">
|
||||||
|
Title: {{ t.title }}
|
||||||
|
</NuxtLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<button @click="addTodo">
|
||||||
|
Add Todo
|
||||||
|
</button>
|
||||||
|
<button @click="() => refresh()">
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completed {
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
18
playground/pages/todo/[id].vue
Normal file
18
playground/pages/todo/[id].vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const { data: todo, pending, error } = await useAsyncQuery(['getTodo', Number(route.params.id)])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="pending">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error?.data?.code">
|
||||||
|
{{ error.data.code }}
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
ID: {{ todo.id }} <br>
|
||||||
|
Title: {{ todo.title }} <br>
|
||||||
|
Completed: {{ todo.completed }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,82 +1,35 @@
|
|||||||
// ~/server/trpc/index.ts
|
|
||||||
import { ZodError, z } from 'zod'
|
|
||||||
import * as trpc from '@trpc/server'
|
import * as trpc from '@trpc/server'
|
||||||
import type { inferAsyncReturnType } from '@trpc/server'
|
import { z } from 'zod'
|
||||||
import type { CompatibilityEvent } from 'h3'
|
|
||||||
import type { ResponseMetaFnPayload } from 'trpc-nuxt/api'
|
|
||||||
// import superjson from 'superjson'
|
|
||||||
|
|
||||||
const fakeUsers = [
|
const baseURL = 'https://jsonplaceholder.typicode.com'
|
||||||
{ id: 1, username: 'jcena' },
|
|
||||||
{ id: 2, username: 'dbatista' },
|
|
||||||
{ id: 3, username: 'jbiden' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const router = trpc
|
const TodoShape = z.object({
|
||||||
.router<inferAsyncReturnType<typeof createContext>>()
|
userId: z.number(),
|
||||||
.formatError(({ shape, error }) => {
|
id: z.number(),
|
||||||
return {
|
title: z.string(),
|
||||||
...shape,
|
completed: z.boolean(),
|
||||||
data: {
|
})
|
||||||
...shape.data,
|
|
||||||
zodError:
|
export type Todo = z.infer<typeof TodoShape>
|
||||||
error.code === 'BAD_REQUEST'
|
|
||||||
&& error.cause instanceof ZodError
|
export const router = trpc.router()
|
||||||
? error.cause.flatten()
|
.query('getTodos', {
|
||||||
: null,
|
async resolve() {
|
||||||
},
|
return await $fetch<Todo[]>(`${baseURL}/todos`)
|
||||||
}
|
|
||||||
})
|
|
||||||
.query('getUsers', {
|
|
||||||
resolve() {
|
|
||||||
return fakeUsers
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.query('getUser', {
|
.query('getTodo', {
|
||||||
// validate input with Zod
|
input: z.number(),
|
||||||
input: z.object({
|
async resolve(req) {
|
||||||
username: z.string().min(5),
|
return await $fetch<Todo>(`${baseURL}/todos/${req.input}`)
|
||||||
}),
|
|
||||||
resolve(req) {
|
|
||||||
return fakeUsers.find(i => i.username === req.input.username) ?? null
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.mutation('createUser', {
|
.mutation('addTodo', {
|
||||||
input: z.object({ username: z.string().min(5) }),
|
input: TodoShape,
|
||||||
resolve(req) {
|
async resolve(req) {
|
||||||
const newUser = {
|
return await $fetch<Todo>(`${baseURL}/todos`, {
|
||||||
id: fakeUsers.length + 1,
|
method: 'POST',
|
||||||
username: req.input.username,
|
body: req.input,
|
||||||
}
|
})
|
||||||
fakeUsers.push(newUser)
|
|
||||||
return newUser
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createContext = (event: CompatibilityEvent) => {
|
|
||||||
event.res.setHeader('x-ssr', 1)
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const responseMeta = (opts: ResponseMetaFnPayload<any>) => {
|
|
||||||
// const nuxtApp = useNuxtApp()
|
|
||||||
// const client = useClient()
|
|
||||||
// console.log(opts)
|
|
||||||
|
|
||||||
// if (nuxtApp.nuxtState) {
|
|
||||||
// nuxtApp.nuxtState.trpc = client.runtime.transformer.serialize({
|
|
||||||
// ctx: opts.ctx,
|
|
||||||
// errors: opts.errors,
|
|
||||||
// })
|
|
||||||
// }
|
|
||||||
// else {
|
|
||||||
// nuxtApp.nuxtState = {
|
|
||||||
// trpc: client.runtime.transformer.serialize({
|
|
||||||
// ctx: opts.ctx,
|
|
||||||
// errors: opts.errors,
|
|
||||||
// }),
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|||||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -19,7 +19,7 @@ importers:
|
|||||||
nuxt: ^3.0.0-rc.3
|
nuxt: ^3.0.0-rc.3
|
||||||
ohash: ^0.1.0
|
ohash: ^0.1.0
|
||||||
pathe: ^0.3.0
|
pathe: ^0.3.0
|
||||||
pnpm: ^7.1.0
|
pnpm: ^7.1.1
|
||||||
superjson: ^1.9.1
|
superjson: ^1.9.1
|
||||||
trpc-nuxt: workspace:*
|
trpc-nuxt: workspace:*
|
||||||
ufo: ^0.8.4
|
ufo: ^0.8.4
|
||||||
|
|||||||
46
recipes/merging-routers.md
Normal file
46
recipes/merging-routers.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Merging Routers
|
||||||
|
|
||||||
|
Writing all API-code in your code in the same file is not a great idea. It's easy to merge routers with other routers.
|
||||||
|
|
||||||
|
Define your routes:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ~/server/trpc/routes/posts.ts
|
||||||
|
export const posts = trpc.router()
|
||||||
|
.query('list', {
|
||||||
|
resolve() {
|
||||||
|
// ..
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ~/server/trpc/routes/users.ts
|
||||||
|
export const users = trpc.router()
|
||||||
|
.query('list', {
|
||||||
|
resolve() {
|
||||||
|
// ..
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// ~/server/trpc/index.ts
|
||||||
|
import { users } from './routes/users'
|
||||||
|
import { posts } from './routes/posts'
|
||||||
|
|
||||||
|
export const router = trpc.router()
|
||||||
|
.merge('user.', users) // prefix user procedures with "user."
|
||||||
|
.merge('post.', posts) // prefix post procedures with "post."
|
||||||
|
```
|
||||||
|
|
||||||
|
and use it like this:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { data: users } = await useAsyncQuery(['user.list'])
|
||||||
|
const { data: posts } = await useAsyncQuery(['post.list'])
|
||||||
|
</script>
|
||||||
|
```
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { fileURLToPath } from 'url'
|
import { fileURLToPath } from 'url'
|
||||||
import { dirname, join } from 'pathe'
|
import { join } from 'pathe'
|
||||||
import { defu } from 'defu'
|
import { defu } from 'defu'
|
||||||
|
|
||||||
import { addServerHandler, defineNuxtModule } from '@nuxt/kit'
|
import { addServerHandler, addTemplate, defineNuxtModule } from '@nuxt/kit'
|
||||||
import fs from 'fs-extra'
|
|
||||||
|
|
||||||
export interface ModuleOptions {
|
export interface ModuleOptions {
|
||||||
baseURL: string
|
baseURL: string
|
||||||
@@ -21,10 +20,10 @@ export default defineNuxtModule<ModuleOptions>({
|
|||||||
},
|
},
|
||||||
async setup(options, nuxt) {
|
async setup(options, nuxt) {
|
||||||
const runtimeDir = fileURLToPath(new URL('./runtime', import.meta.url))
|
const runtimeDir = fileURLToPath(new URL('./runtime', import.meta.url))
|
||||||
nuxt.options.build.transpile.push(runtimeDir)
|
nuxt.options.build.transpile.push(runtimeDir, '#build/trpc-client', '#build/trpc-handler')
|
||||||
|
|
||||||
const clientPath = join(nuxt.options.buildDir, 'trpc-client.ts')
|
|
||||||
const handlerPath = join(nuxt.options.buildDir, 'trpc-handler.ts')
|
const handlerPath = join(nuxt.options.buildDir, 'trpc-handler.ts')
|
||||||
|
nuxt.options.build.transpile.push(handlerPath)
|
||||||
|
|
||||||
// Final resolved configuration
|
// Final resolved configuration
|
||||||
const finalConfig = nuxt.options.runtimeConfig.public.trpc = defu(nuxt.options.runtimeConfig.public.trpc, {
|
const finalConfig = nuxt.options.runtimeConfig.public.trpc = defu(nuxt.options.runtimeConfig.public.trpc, {
|
||||||
@@ -32,43 +31,53 @@ export default defineNuxtModule<ModuleOptions>({
|
|||||||
trpcURL: options.trpcURL,
|
trpcURL: options.trpcURL,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
nuxt.hook('autoImports:extend', (imports) => {
|
||||||
|
imports.push(
|
||||||
|
{ name: 'useClient', from: '#build/trpc-client' },
|
||||||
|
{ name: 'useAsyncQuery', from: join(runtimeDir, 'client') },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
addServerHandler({
|
addServerHandler({
|
||||||
route: `${finalConfig.trpcURL}/*`,
|
route: `${finalConfig.trpcURL}/*`,
|
||||||
handler: handlerPath,
|
handler: handlerPath,
|
||||||
})
|
})
|
||||||
|
|
||||||
nuxt.hook('autoImports:extend', (imports) => {
|
addTemplate({
|
||||||
imports.push(
|
filename: 'trpc-client.ts',
|
||||||
{ name: 'useClient', from: clientPath },
|
write: true,
|
||||||
{ name: 'useAsyncQuery', from: join(runtimeDir, 'client') },
|
getContents() {
|
||||||
)
|
return `
|
||||||
|
import * as trpc from '@trpc/client'
|
||||||
|
import type { router } from '~/server/trpc'
|
||||||
|
|
||||||
|
const client = trpc.createTRPCClient<typeof router>({
|
||||||
|
url: '${finalConfig.baseURL}${finalConfig.trpcURL}',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useClient = () => client
|
||||||
|
`
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await fs.ensureDir(dirname(clientPath))
|
addTemplate({
|
||||||
|
filename: 'trpc-handler.ts',
|
||||||
await fs.writeFile(clientPath, `
|
write: true,
|
||||||
import * as trpc from '@trpc/client'
|
getContents() {
|
||||||
import type { router } from '~/server/trpc'
|
return `
|
||||||
|
import { createTRPCHandler } from 'trpc-nuxt/api'
|
||||||
const client = trpc.createTRPCClient<typeof router>({
|
import { useRuntimeConfig } from '#imports'
|
||||||
url: '${finalConfig.baseURL}${finalConfig.trpcURL}',
|
import * as functions from '~/server/trpc'
|
||||||
})
|
|
||||||
|
|
||||||
export const useClient = () => client
|
const { trpc: { trpcURL } } = useRuntimeConfig().public
|
||||||
`)
|
|
||||||
|
export default createTRPCHandler({
|
||||||
await fs.writeFile(handlerPath, `
|
...functions,
|
||||||
import { createTRPCHandler } from 'trpc-nuxt/api'
|
trpcURL
|
||||||
import { useRuntimeConfig } from '#imports'
|
})
|
||||||
import * as functions from '~/server/trpc'
|
`
|
||||||
|
},
|
||||||
const { trpc: { trpcURL } } = useRuntimeConfig().public
|
})
|
||||||
|
|
||||||
export default createTRPCHandler({
|
|
||||||
...functions,
|
|
||||||
trpcURL
|
|
||||||
})
|
|
||||||
`)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,6 @@ export function createTRPCHandler<Router extends AnyRouter>({
|
|||||||
|
|
||||||
const $url = createURL(req.url)
|
const $url = createURL(req.url)
|
||||||
|
|
||||||
event.context.hello = 'world'
|
|
||||||
|
|
||||||
const httpResponse = await resolveHTTPResponse({
|
const httpResponse = await resolveHTTPResponse({
|
||||||
router,
|
router,
|
||||||
req: {
|
req: {
|
||||||
|
|||||||
@@ -8,11 +8,8 @@ import type {
|
|||||||
import type { ProcedureRecord, inferHandlerInput, inferProcedureInput, inferProcedureOutput } from '@trpc/server'
|
import type { ProcedureRecord, inferHandlerInput, inferProcedureInput, inferProcedureOutput } from '@trpc/server'
|
||||||
import type { TRPCClientErrorLike } from '@trpc/client'
|
import type { TRPCClientErrorLike } from '@trpc/client'
|
||||||
import { objectHash } from 'ohash'
|
import { objectHash } from 'ohash'
|
||||||
// @ts-expect-error: Resolved by Nuxt
|
import { useAsyncData, useState } from '#app'
|
||||||
import { useAsyncData, useState } from '#imports'
|
|
||||||
// @ts-expect-error: Resolved by Nuxt
|
|
||||||
import { useClient } from '#build/trpc-client'
|
import { useClient } from '#build/trpc-client'
|
||||||
// @ts-expect-error: Resolved by Nuxt
|
|
||||||
import type { router } from '~/server/trpc'
|
import type { router } from '~/server/trpc'
|
||||||
|
|
||||||
type AppRouter = typeof router
|
type AppRouter = typeof router
|
||||||
@@ -49,7 +46,6 @@ export async function useAsyncQuery<
|
|||||||
options,
|
options,
|
||||||
)
|
)
|
||||||
|
|
||||||
// @ts-expect-error: Resolved by Nuxt
|
|
||||||
if (process.server && error.value && !serverError.value)
|
if (process.server && error.value && !serverError.value)
|
||||||
serverError.value = error.value as any
|
serverError.value = error.value as any
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user