С любовью ко всем разработчикам на свете ❤️
Хочу сравнить фреймворк Point0 и библиотеку tRPC. Делаю это, потому что квери и мутации в Point0 очень похожи по DX на tRPC, но более удобны ввиду того, что всё же Point0 это фреймворк и он может обладать возможностями, которыми библиотека не может:
Кроме того, что описано в этой статье, Point0 может много чего ещё, но об этом в других статьях. Сейчас фокусируемся на сравнении с tRPC. Предполагается, что вы с tRPC более менее знакомы, но ниже я всё равно буду показывать примеры реализации на tRPC, чтобы сравнения были видимыми.
Начнём с классики: получить запись по id. Вот как это выглядит в tRPC. Сначала процедура:
// server/routers/idea.ts
import { z } from 'zod'
import { publicProcedure, router } from '../trpc'
import { prisma } from '../prisma'
export const ideaRouter = router({
view: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: input.id },
})
return { idea }
}),
// Каждый новый эндпоинт добавляем сюда
})Потом её надо зарегистрировать в общем роутере — руками:
// server/routers/_app.ts
import { router } from '../trpc'
import { ideaRouter } from './idea'
export const appRouter = router({
idea: ideaRouter,
// каждый новый роутер дописываем сюда
})
export type AppRouter = typeof appRouterПотом создать клиентские хуки, типизированные типом всего роутера:
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '../server/routers/_app'
export const trpc = createTRPCReact<AppRouter>()И только теперь можно использовать:
// client/components/idea.tsx
import { trpc } from '@/utils/trpc'
export const IdeaView = ({ id }: { id: string }) => {
const result = trpc.idea.view.useQuery({ id })
if (result.isLoading) return <div>Загрузка...</div>
if (result.error) return <div>{result.error.message}</div>
return <h1>{result.data.idea.title}</h1>
}Теперь Point0. Квери объявляется в любом файле проекта — целиком, вместе с серверным кодом:
// modules/idea.tsx
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
import * as z from 'zod'
export const ideaViewQuery = root.lets
.query()
// или альтернативный синтаксис: root.lets('query', 'ideaView')
// в Point0 все поинты могут объявляться либо короткой, либо длинной нотацией
.input(z.object({ id: z.string() })) // схема любой либой: zod, valibot, typebox, ...
.loader(async ({ input }) => {
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: input.id },
})
return { idea }
})
.query() // сюда можно передать опции обычного useQuery/fetchQueryИ используется напрямую — импортируется сама квери, а не хук-прокси. Причём можно даже не импортировать, а прямо использовать там же, где и объявили, рядом со страницей/компонентом.
// modules/idea.tsx
export const IdeaView = ({ id }: { id: string }) => {
const result = ideaViewQuery.useQuery({ id })
if (result.isLoading) return <div>Загрузка...</div>
if (result.error) return <div>{result.error.message}</div>
return <h1>{result.data.idea.title}</h1>
}result — оригинальный объект useQuery из react-query, ничего обёрнутого. Все
привычные методы react-query живут прямо на квери:
ideaViewQuery.useQuery({ id })
ideaViewQuery.fetchQuery({ id })
ideaViewQuery.prefetchQuery({ id })
ideaViewQuery.invalidateQuery({ id })
ideaViewQuery.setQueryData({ id }, ...)
ideaViewQuery.getQueryKey({ id })
// и так далее — весь стандартный наборПервым аргументом везде идёт инпут — из него собирается уникальный queryKey.
Если инпут опциональный или отсутствует, его можно не передавать вовсе.
Разница уже видна: нет файла с роутером, нет AppRouter, нет utils/trpc.ts.
Объявил, импортировал, вызвал. Куда при этом делся индекс и почему серверный код
не утёк на клиент, будет в двух отдельных разделах ниже.
Процедура в роутере, на клиенте useMutation:
// server/routers/idea.ts
export const ideaRouter = router({
// ...
update: publicProcedure
.input(
z.object({
id: z.string(),
title: z.string().min(1),
content: z.string().min(1),
}),
)
.mutation(async ({ input }) => {
const idea = await prisma.idea.update({
where: { id: input.id },
data: { title: input.title, content: input.content },
})
return { idea }
}),
// ...
})// client/components/idea.tsx
import { trpc } from '@/utils/trpc'
export const IdeaEditForm = ({ idea }: { idea: Idea }) => {
const utils = trpc.useUtils()
const mutation = trpc.idea.update.useMutation({
onSuccess: ({ idea }) => {
utils.idea.view.setData({ id: idea.id }, { idea })
},
})
// ...
}Point0 — та же схема, тот же лоадер, только это самостоятельный поинт, самодостаточный и для клиента, и для сервера.
// modules/idea.tsx
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
import { ideaViewQuery } from '@/modules/idea'
import { navigate } from '@/lib/navigation'
import * as z from 'zod'
export const ideaUpdateMutation = root.lets
.mutation()
.input(
z.object({
id: z.string(),
title: z.string().min(1),
content: z.string().min(1),
}),
)
.loader(async ({ input }) => {
const idea = await prisma.idea.update({
where: { id: input.id },
data: { title: input.title, content: input.content },
})
return { idea }
})
.mutation({
onSuccess: ({ idea }) => {
ideaViewQuery.setQueryData({ id: idea.id }, { idea })
},
})
export const IdeaEditForm = ({ idea }: { idea: Idea }) => {
const mutation = ideaUpdateMutation.useMutation()
return (
<form
onSubmit={async (e) => {
e.preventDefault()
const form = new FormData(e.currentTarget)
await mutation.mutateAsync({
id: idea.id,
title: String(form.get('title')),
content: String(form.get('content')),
})
await navigate('ideaView', { id: idea.id })
}}
>
<input name="title" defaultValue={idea.title} />
<textarea name="content" defaultValue={idea.content} />
<button disabled={mutation.isPending}>Сохранить</button>
</form>
)
}Методы вроде ideaViewQuery.setQueryData() и invalidateQuery() работают где
угодно — в мутации, в обработчике, вне компонента. Квери клиент в Point0 общий
для сервера и клиента (на сервере создаётся свой инстанс на каждый запрос, чтобы
данные пользователей не смешивались).
В tRPC у инфинити квери есть жёсткое соглашение: поле курсора в инпуте обязано
называться cursor, иначе useInfiniteQuery на процедуре просто не появится:
// server/routers/idea.ts
export const ideaRouter = router({
// ...
list: publicProcedure
.input(
z.object({
cursor: z.number().default(0),
limit: z.number().default(10),
}),
)
.query(async ({ input: { cursor, limit } }) => {
const ideasCount = await prisma.idea.count()
const ideas = await prisma.idea.findMany({
take: limit,
skip: cursor * limit,
orderBy: { updatedAt: 'desc' },
})
const nextCursor =
ideasCount > (cursor + 1) * limit ? cursor + 1 : undefined
return { ideas, nextCursor }
}),
// ...
})// client/components/idea.tsx
const query = trpc.idea.list.useInfiniteQuery(
{ limit: 10 },
{ getNextPageParam: (lastPage) => lastPage.nextCursor },
)В Point0 курсором может быть любой ключ инпута, на него указывает
pageParamFromInput:
// modules/idea.tsx
export const ideaListQuery = root.lets
.infiniteQuery()
.input(
z.object({
page: z.number().default(0),
limit: z.number().default(10),
}),
)
.loader(async ({ input: { page, limit } }) => {
const ideasCount = await prisma.idea.count()
const ideas = await prisma.idea.findMany({
take: limit,
skip: page * limit,
orderBy: { updatedAt: 'desc' },
})
const nextCursor = ideasCount > (page + 1) * limit ? page + 1 : undefined
return { ideas, ideasCount, nextCursor }
})
.infiniteQuery({
// сюда идут любые настройки родного useInfiniteQuery,
// плюс наш pageParamFromInput — ключ в инпуте
// (можно даже вложенный по типу some.thing.deep),
// который играет роль pageParam
pageParamFromInput: 'page',
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
})Далее используем обычный useInfiniteQuery из react-query со всеми его
fetchNextPage, hasNextPage, isFetchingNextPage:
const query = ideaListQuery.useInfiniteQuery({ limit: 10 })
const ideas = query.data?.pages.flatMap((page) => page.ideas) ?? []В tRPC индекс — это appRouter. Он собирается руками и типизирован всеми своими
процедурами. Обращаясь к одной процедуре, редактор материализует типы всех
остальных. Проект растёт, автокомплит и подсказки становятся всё медленнее.
В Point0 индекса в типах нет вообще. Квери и мутации импортируются напрямую, как обычные значения, и типы каждой живут сами по себе. Обращаясь к одной, редактор просчитывает только её. В бенчмарках это видно числами: инкрементальная перепроверка типов в редакторе у Point0 не растёт с проектом — 1.50 s на 4 страницах, 1.59 s на 504. (Бенчмарки чуть устарели, позже будут пересчитаны, но по сути они верные).
Но индекс всё равно нужен не типам, а рантайму. Движок, который обрабатывает запросы, должен знать все поинты проекта. И вот его в Point0 собирать руками не надо, генератор находит поинты статическим анализом кода и пишет индекс-файл сам. В dev-режиме на лету при каждом изменении, и при билде. Выглядит сгенерированное примерно так:
// generated/point0/points.server.ts — сгенерировано, лежит в .gitignore
import type { PointsDefinition } from '@point0/core'
import { root as root_0 } from '../../lib/root.js'
import { ideaListPage as ideaListPage_1 } from '../../pages/idea-list.js'
import { ideaViewPage as ideaViewPage_2 } from '../../pages/idea-view.js'
import {
ideaViewQuery as ideaViewQuery_3,
ideaUpdateMutation as ideaUpdateMutation_4,
} from '../../modules/idea.js'
export default [
root_0,
ideaListPage_1,
ideaViewPage_2,
ideaViewQuery_3,
ideaUpdateMutation_4,
] as PointsDefinition<...>Этот массив прокидывается в движок в src/engine.ts — и всё, движок знает
проект. Клиентский вариант того же файла генерируется с динамическими импортами,
чтобы страницы попадали в бандл отдельными ленивыми чанками. Это прописывается
один раз, да и в целом при bun create point0-app уже прописано, и потом не
меняется:
// src/engine.ts
import { Engine } from '@point0/engine'
import { clientEnvKeys } from './client-shape'
export const engine = Engine.create({
file: import.meta.url,
ssr: true,
pointsGlob: '**/*.{ts,tsx,mdx}',
server: {
scope: 'root',
entry: { main: './index.server.ts' },
points: async () => await import('./generated/point0/points.server'),
generate: { points: './generated/point0/points.server.ts' },
outdir: '../dist/server',
},
client: {
scope: 'root',
indexHtml: './index.html',
app: async () => await import('./app.client'),
points: async () => await import('./generated/point0/points.client'),
generate: {
points: './generated/point0/points.client.ts',
routes: './generated/point0/routes.ts',
},
publicdir: { source: '../public', outdir: '../dist/client' },
outdir: '../dist/client',
},
})// src/app.server.ts
import { engine } from './engine'
// раздаём наши поинты в качестве обычных эндпоинтов
await engine.serve()
// можем дописать любой другой серверный кодИтого: в tRPC индекс собираете вы, и он тормозит редактор. В Point0 индекс собирает генератор, и он никого не тормозит, да и почти нигде кроме сетапа не используется.
Посмотрите ещё раз на объявление ideaViewQuery. Там импортирована prisma и
написан запрос к базе, и этот же код используется в клиентском компоненте. Как
серверный код не оказался в бандле?
В tRPC эта проблема решена дисциплиной. Серверный код живёт в серверных файлах,
клиент импортирует только type AppRouter, и вы аккуратно следите, чтобы через
живой импорт с сервера ничего не притекло.
В Point0 за это отвечает компилятор. Из клиентского бандла он вырезает серверный код, из серверного — клиентский, а осиротевшие импорты удаляет. Посмотреть результат можно командой:
point0 compile src/modules/idea.tsx --side client// ваш оригинальный код
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
import * as z from 'zod'
export const ideaViewQuery = root.lets
.query()
.input(z.object({ id: z.string() }))
.loader(async ({ input }) => {
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: input.id },
})
return { idea }
})
.query()// вывод команды point0 compile src/modules/idea.tsx --side client
// это же и есть код, который доедет до клиентского бандла
import { root } from '@/lib/root'
// prisma и zod вырезались сами — вместе с телом лоадера и схемой
export const ideaViewQuery = root
// нотация .lets() сама заменяется на подробную, где имя квери берётся из названия переменной
.lets('query', 'ideaView')
.input()
.loader()
.query()Вырезался не только лоадер, но и схема инпута. Валидация живёт на сервере, и
zod-схемы в клиентский бандл не едут вообще. Так же вырезаются и остальные
серверные методы: .ctx(), .middleware(), схемы экшенов. Клиент знает только
имя и тип поинта — этого достаточно, чтобы отправить запрос на сервер. Заодно
видно, во что развернулась короткая нотация: root.lets.query() — это сахар,
компилятор берёт имя переменной ideaViewQuery, отрезает суффикс типа и
получает имя поинта ideaView. Оно ещё сыграет роль в разделе про урлы.
Компилятор существует в трёх форматах: bun-плагин, vite-плагин и babel-плагин, под капотом один и тот же код. Результаты кешируются на диске. Первый (самый первый и единственный) запуск проекта чуть дольше, дальше всегда быстро.
Квери и мутации можно объявлять прямо в файле страницы, рядом с формой, которая их вызывает. React Fast Refresh хочет, чтобы из файла экспортировались только компоненты, иначе правка перезагружает страницу целиком вместо горячей замены.
Мы бандлеры перехитрили. В dev-режиме компилятор дописывает каждому поинту хвост:
export const ideaUpdateMutation = root.lets
.mutation()
.input(/* ... */)
.loader(/* ... */)
.mutation()
._tail(() => null) // дописывает компилятор, только в devСам ideaUpdateMutation — это и есть функция, возвращённая из
._tail(() => null), так что и bun, и vite считают экспорт компонентом, и Fast
Refresh спокойно работает. А мы к поинту напрямую никогда не обращаемся, мы
обращаемся только к его методам, и они все на месте. Мы можем по сути весь
проект в одном файле написать и иметь HMR за 15 мс (медиана из бенчмарков).
Складывать всё в один файл никто не заставляет. Point0 вообще не навязывает структуру папок. Хотите — объявляйте поинты мутаций и кверей отдельно, или все в одной папке, или разделяйте по папкам модулей. Разница в том, что здесь это выбор, а не ограничение инструмента.
Так как Point0 это полноценный фреймворк, конечно, он может и страницы, и
лэйауты объявлять. И всё это тоже поинты. Квери можно вшить в страницу Point0
через .with() — и это тот же самый инстанс квери с тем же кешем:
import { ideaViewQuery } from '@/modules/idea'
export const ideaPage = root.lets
.page('/ideas/:id')
// мапим типизированные параметры роута на инпут квери
.with(ideaViewQuery, ({ params }) => ({ id: params.id }))
.head(({ data: { idea } }) => idea.title)
.page(({ data: { idea } }) => (
<article>
<h1>{idea.title}</h1>
<p>{idea.content}</p>
</article>
))В .page() данные уже загружены: состояния загрузки и ошибки рисуются сами, их
вид один раз задан в корневом поинте. Или может быть переопределён для каждой
отдельной страницы. И одновременно эта же ideaViewQuery.useQuery({ id })
работает в любом компоненте — кеш один, лишний запрос на сервер не уйдёт.
Кто настраивал SSR-гидрацию react-query поверх tRPC, знает эту обвязку наизусть:
// tRPC + Next: prefetch на сервере, dehydrate, прокинуть, hydrate
export async function getServerSideProps(ctx) {
const helpers = createServerSideHelpers({
router: appRouter,
ctx: await createContext(),
transformer: superjson, // тот же, что на клиенте, иначе всё развалится
})
await helpers.idea.view.prefetch({ id: ctx.params.id })
return {
props: { trpcState: helpers.dehydrate(), id: ctx.params.id },
}
}И так на каждой странице, где нужны данные при первом рендере.
В Point0 этого слоя нет совсем. Фреймворк рендерит страницу на сервере, сам
видит, какие квери ей нужны, включая объявленные через .with() и внутри
компонентов, сам их загружает, кладёт дегидрированный кеш квери клиента в HTML,
а на клиенте сам его гидрирует. А если выключите SSR, код не изменится ни на
строчку: те же квери просто уедут с клиента. И даже со включённым SSR при
переходах по страницам просто будут дозапрашиваться нужные данные и JS-чанки.
Кстати, лоадер страницы — тоже квери. Страница с .loader() сама умеет
ideaPage.useQuery({ id }), ideaPage.prefetchQuery({ id }) и всё остальное.
Всё поинты, всё react-query:
import { ideaViewQuery } from '@/modules/idea'
export const ideaPage = root.lets
.page('/ideas/:id')
.loader(async ({ params }) => {
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: params.id },
})
return { idea }
})
.head(({ data: { idea } }) => idea.title)
.page(({ data: { idea } }) => (
<article>
<h1>{idea.title}</h1>
<p>{idea.content}</p>
</article>
))Загрузка файлов — место, где tRPC v11 честно упирается в свой формат. FormData принять можно, но типизация инпута на этом заканчивается:
// tRPC v11
upload: publicProcedure
.input(z.instanceof(FormData))
.mutation(async ({ input }) => {
const title = input.get('title') // FormDataEntryValue | null
const image = input.get('image') // FormDataEntryValue | null
// дальше — руками: проверить, скастовать, провалидировать
}),Поля больше не описаны схемой — input.get() возвращает
FormDataEntryValue | null, и валидацию вы собираете сами (либо тащите
zod-form-data и переписываете схему в её стиле).
В Point0 файл — это обычное поле обычного типизированного инпута:
export const ideaCreateMutation = root.lets
.mutation()
.input(
z.object({
title: z.string().min(1),
content: z.string().min(1),
image: z.file().optional(), // вот он, файл
}),
)
.loader(async ({ input }) => {
// input.image на сервере — обычный File
const imageBase64 = input.image
? Buffer.from(await input.image.arrayBuffer()).toString('base64')
: undefined
const idea = await prisma.idea.create({
data: { title: input.title, content: input.content, image: imageBase64 },
})
return { idea }
})
.mutation()На клиенте File кладётся в инпут как есть:
const mutation = ideaCreateMutation.useMutation()
await mutation.mutateAsync({
title,
content,
image: fileInput.files?.[0], // просто File из инпута
})FormData под капотом фреймворк соберёт и разберёт сам: остальные поля провалидируются схемой как обычно, файл доедет файлом. Запросы сами под капотом определяют, когда отправлять в формате FormData, а когда в JSON, в зависимости от наличия в данных Blob или File. И все кастомные трансформеры на это точно так же накладываются. Просто все данные будут приведены к плоской структуре для передачи, но вы этого даже не заметите, всё делается под капотом.
Запросы tRPC уезжают на один маунт с именем процедуры в пути и инпутом, закодированным в query string, а батч-линк дополнительно склеивает несколько процедур в один запрос:
GET /api/trpc/idea.view,idea.list?batch=1&input=%7B%220%22%3A%7B%22id%22...В Point0 у каждой квери и мутации свой стабильный урл — из имени поинта в кебаб-кейсе. Запрос в мутацию всегда POST, а в квери GET при условии, что длина урла не превышает установленного значения, иначе тоже отправится через POST:
GET /_point0/root/query/idea-view ← ideaViewQuery
GET /_point0/root/query/idea-list ← ideaListQuery
POST /_point0/root/mutation/idea-update ← ideaUpdateMutationПомните, компилятор вывел имя ideaView из имени переменной? Вот здесь оно и
работает. А раз у всего есть обычные урлы и схемы, из них автоматически
собирается полная OpenAPI-спека через пакет @point0/openapi, смотреть можно в
Scalar или Swagger UI. Каждая квери, мутация и экшен попадают туда сами, а можно
и отфильтровать.
У tRPC всё является процедурами. Как только нужен эндпоинт с конкретным методом и путём — например вебхук Stripe, колбэк OAuth, интеграция с чужой системой — вы выходите из tRPC и пишете обычный route handler рядом (или подключаете trpc-to-openapi).
В Point0 для этого есть вид поинтов «экшен», с полным контролем над методом и путём:
export const stripeWebhookAction = root.lets
.action('POST', '/api/webhooks/stripe')
.loader(async ({ request }) => {
const event = await stripe.webhooks.constructEvent(
await request.original.text(),
request.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET,
)
await handleStripeEvent(event)
return { received: true }
})
.action()Экшену можно объявить схемы всего, из чего состоит HTTP-запрос: параметров пути, сёрча, хедеров, боди:
export const myTestAction = root.lets
.action('POST', '/api/my-test/:id')
.params(z.object({ id: z.coerce.number().min(1) }))
.headers(z.object({ x: z.string().min(1) }))
.search(z.object({ y: z.string().min(1) }))
.body(z.object({ b: z.number().min(1) }))
.action(({ params, headers, search, body }) => {
return { params, headers, search, body }
})Если объявлена схема боди, фреймворк сам прочитает и распарсит его как
json/formData, сохранив оригинал в request.rawBody — для проверки сигнатур
вебхуков. Если не объявлять, сможете прочитать боди когда вам угодно и как вам
угодно.
Экшен — единственный поинт, который можно закрыть методом: .query(),
.mutation() или .infiniteQuery(). И тогда эндпоинт с кастомным путём
становится полноценной react-query квери или мутацией на клиенте:
export const ideaUpdateAction = root.lets
.action('PUT', '/api/ideas/:id')
.body(
z.object({
title: z.string().min(1),
content: z.string().min(1),
}),
)
.loader(async ({ params: { id }, body: { title, content } }) => {
const idea = await prisma.idea.update({
where: { id },
data: { title, content },
})
return { idea }
})
.mutation() // теперь это мутацияconst mutation = ideaUpdateAction.useMutation()
await mutation.mutateAsync({
// инпут экшена не плоский, а разложен по частям запроса
params: { id: idea.id },
body: { title, content },
})Один поинт — и красивый PUT /api/ideas/:id для внешнего мира, и типизированная
мутация со всем кешированием.
В tRPC такого быть не может, потому что это библиотека. А в Point0 лоадер умеет возвращать не только данные, но и реакт-элементы. Элемент в Point0 — это просто данные, такое же поле в ответе, как число или строка. Возвращать их можно откуда угодно, где есть лоадер: из страниц, компонентов, кверей, мутаций.
Элементов ровно два вида, и различаются они тем, чем вы их объявили:
Разрешение на элементы включается один раз в корневом поинте — чтобы они не протекали в данные случайно:
export const root = Point0.lets
.root()
.rsc({ depth: 1 }) // элементы разрешены в полях первого уровня
.root()Теперь пример, где в одном лоадере есть и то, и другое. Страница идеи: контент — тяжёлый markdown, лайк — живая кнопка.
Сначала остров. Это обычный компонент-поинт, в своём файле — тогда он поедет отдельным ленивым чанком:
// modules/idea-like.tsx
import { root } from '@/lib/root'
import { ideaLikeMutation } from '@/modules/idea'
import { useState } from 'react'
export const IdeaLikeButton = root.lets
.component<{ id: string; likes: number }>()
.component(({ props }) => {
const [likes, setLikes] = useState(props.likes)
const mutation = ideaLikeMutation.useMutation()
return (
<button
disabled={mutation.isPending}
onClick={async () => {
await mutation.mutateAsync({ id: props.id })
setLikes(likes + 1)
}}
>
❤️ {likes}
</button>
)
})Теперь серверный компонент — тоже в своём файле. Это обычная функция, никаких поинтов, можно async:
// modules/idea-content.tsx
import { markdownToHtml } from 'heavy-markdown-lib' // тяжёлая либа
export const IdeaContent = async ({ markdown }: { markdown: string }) => {
const html = await markdownToHtml(markdown)
return <article dangerouslySetInnerHTML={{ __html: html }} />
}И страница, которая собирает из них ответ:
// pages/idea-view.tsx
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
import { IdeaContent } from '@/modules/idea-content'
import { IdeaLikeButton } from '@/modules/idea-like'
export const ideaPage = root.lets
.page('/ideas/:id')
.loader(async ({ params }) => {
const idea = await prisma.idea.findUniqueOrThrow({
where: { id: params.id },
})
return {
title: idea.title, // обычные данные, как всегда
content: <IdeaContent markdown={idea.content} />, // серверный компонент
like: <IdeaLikeButton id={idea.id} likes={idea.likes} />, // остров
}
})
.page(({ data }) => (
<main>
<h1>{data.title}</h1>
{data.content}
{data.like}
</main>
))Страница в .page() просто раскладывает data по местам и вообще не знает, что
там элементы. А вот что реально доехало до браузера в ответе:
{
"title": "Фреймворк на Bun",
// серверный компонент уже отрендерился — едет только разметка
"content": {
"__p0e": {
"t": "article",
"p": { "dangerouslySetInnerHTML": { "__html": "<p>…</p>" } },
},
},
// остров едет ссылкой на компонент-поинт и своими пропсами
"like": {
"__p0e": {
"t": { "c": "IdeaLikeButton" },
"p": { "id": "42", "likes": 7 },
},
},
}IdeaContent используется только внутри лоадера — значит его импорт компилятор
из клиентского бандла вырежет, вместе с ним уедет и heavy-markdown-lib. А
IdeaLikeButton лежит в своём файле, поэтому в бандл страницы не попадает: его
чанк скачается только когда в ответе придёт ссылка на него.
Никаких 'use client', никакого второго графа модулей и отдельного протокола:
элемент становится данными. Поэтому работает везде, где работают данные, в том
числе в мутациях — сервер может ответить сразу отрендеренным куском интерфейса:
export const commentAddMutation = root.lets
.mutation()
.input(z.object({ ideaId: z.string(), text: z.string().min(1) }))
.loader(async ({ input }) => {
const comment = await prisma.comment.create({ data: input })
return { comment: <Comment comment={comment} /> }
})
.mutation()const mutation = commentAddMutation.useMutation()
// mutation.data.comment — живой элемент, рендерим как есть
return <section id="comments">{mutation.data?.comment}</section>Есть ещё defer, стриминг медленных кусков в тот же ответ, промисы в пропсах
островов. Но это всё тема отдельной статьи, но уже есть
страница RSC в документации.
На момент первой публикации этой статьи подписок в Point0 не было, и это был честный пункт в пользу tRPC. Теперь они есть, причём в двух видах, и оба объявляются такими же поинтами.
Подписка — это лоадер-генератор поверх обычного HTTP: каждый yield уезжает
клиенту в тот же момент, транспорт — NDJSON на обычном эндпоинте, никакого
сокета держать не надо.
export const taskProgressSubscription = root.lets
.subscription()
.input(z.object({ taskId: z.string() }))
.loader(async function* ({ input, signal }) {
for await (const percent of watchProgress(input.taskId, { signal })) {
yield { percent } // один переданный клиенту кусок на каждый yield
}
})
.subscription()Сокеты — это уже другой слой: канал, спейсы с комнатами и хэндлеры в обе стороны, один WebSocket на клиентское приложение. Там же живут комнаты, presence, админ-команды и бэкплейн на несколько процессов. Про них отдельная статья с пятью примерами: реалтайм в Point0, и страница документации.
Чтобы сравнение оставалось честным:
httpBatchLink склеивает несколько кверей в один
HTTP-запрос. Батчинг в планах, просто ещё не успел.AppRouter из tRPC можно
заимпортировать в соседний репозиторий и получить типизированный клиент.
Поинты Point0 импортируются напрямую в рамках своей кодовой базы, клиентов при
этом может быть несколько: сайт, админка, Expo-приложение, у каждого свой
бандл. А для внешних потребителей используется OpenAPI-спека. Но и Point0
задумывался как инструмент для фулстеков, которые весь свой код сами себе и
напишут, и не будут его развозить по репозиториям. В то же время организовать
много репозиториев в Point0 всё равно можно, но это другая история.Всё показанное выше не библиотека, прикрученная к Next. Это фундамент фреймворка, в котором из тех же поинтов сделано вообще всё: страницы, лэйауты и компоненты со своими лоадерами, провайдеры, роутер с типизированной навигацией, head, SSR, RSC, MDX, ассеты, env-переменные, OpenAPI, MCP-серверы для агентов, сборка.
Если свести статью к одной таблице:
| tRPC | Point0 | |
|---|---|---|
| Под капотом | react-query | react-query |
| Схемы | zod и другие | zod и другие |
| Индекс эндпоинтов | appRouter руками | генератор, сам |
| Типы на клиенте | тип всего роутера | тип одной квери |
| Серверный код рядом с клиентским | нельзя, разносить по файлам | можно, режет компилятор |
| Квери в странице + SSR-гидрация | обвязка на каждой странице | из коробки |
| Файл в мутации | FormData без типов | типизированное поле z.file() |
| Урлы | один маунт, инпут в query string | стабильный урл у каждого поинта |
| Кастомный метод и путь | выход из tRPC | экшен, работает как квери/мутация |
| Элементы в ответе | нет, только данные | серверные компоненты и острова |
| Батчинг | есть | в планах |
| Подписки | SSE и WebSocket | подписка на HTTP и сокеты с комнатами |
| Фреймворк вокруг | нужен отдельный | это он и есть |
Если вам нравится tRPC, то по духу здесь всё родное: та же react-query, те же схемы, те же сквозные типы без кодогенерации. Просто из этого сделан не слой API поверх чужого фреймворка, а фреймворк целиком.
Спасибо, что дочитали. Поддержите меня, пожалуйста:
Всем спасибо!