Point0 — простой, как tRPC, полноценный фулстек-фреймворк

от автора

Хочу сравнить фреймворк Point0 и библиотеку tRPC. Делаю это, потому что квери и мутации в Point0 очень похожи по DX на tRPC, но более удобны ввиду того, что всё же Point0 это фреймворк и он может обладать возможностями, которыми библиотека не может:

  • Объявлять квери вместе с серверным кодом прямо в клиентских файлах, или наоборот

  • Автоматически гидрировать и дегидрировать квери клиент при включении SSR

  • В мутациях отправлять файлы автоматически превращая данные в FormData и обратно

  • Иметь стабильные индивидуальные урлы для квери и мутаций

  • Не заставлять зависать редактор кода при увеличении количества эндпоинтов

  • Возвращать в ответах с сервера на клиент целые серверные компоненты или интерактивные острова

  • Управлять внешним видом состояний загрузки на страницах и в компонентах

Кроме того, что описано в этой статье, Point0 может много чего ещё, но об этом в других статьях. Сейчас фокусируемся на сравнении с tRPC. Предполагается, что вы с tRPC более менее знакомы, но ниже я всё равно буду показывать примеры реализации на tRPC, чтобы сравнения были видимыми.

Справка

Квери в tRPC

Начнём с классики: получить запись по id. Вот как это выглядит в tRPC. Сначала процедура:

// server/routers/idea.tsimport { 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.tsimport { router } from '../trpc'import { ideaRouter } from './idea'export const appRouter = router({  idea: ideaRouter,   // каждый новый роутер дописываем сюда})export type AppRouter = typeof appRouter

Потом создать клиентские хуки, типизированные типом всего роутера:

// utils/trpc.tsimport { createTRPCReact } from '@trpc/react-query'import type { AppRouter } from '../server/routers/_app'export const trpc = createTRPCReact<AppRouter>()

И только теперь можно использовать:

// client/components/idea.tsximport { 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

Теперь Point0. Квери объявляется в любом файле проекта — целиком, вместе с серверным кодом:

// modules/idea.tsximport { 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.tsxexport 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. Объявил, импортировал, вызвал. Куда при этом делся индекс и почему серверный код не утёк на клиент, будет в двух отдельных разделах ниже.

Мутации в tRPC

Процедура в роутере, на клиенте useMutation:

// server/routers/idea.tsexport 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.tsximport { 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

Point0 — та же схема, тот же лоадер, только это самостоятельный поинт, самодостаточный и для клиента, и для сервера.

// modules/idea.tsximport { 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

В tRPC у инфинити квери есть жёсткое соглашение: поле курсора в инпуте обязано называться cursor, иначе useInfiniteQuery на процедуре просто не появится:

// server/routers/idea.tsexport 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.tsxconst query = trpc.idea.list.useInfiniteQuery(  { limit: 10 },  { getNextPageParam: (lastPage) => lastPage.nextCursor },)

Инфинити квери в Point0

В Point0 курсором может быть любой ключ инпута, на него указывает pageParamFromInput:

// modules/idea.tsxexport 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 — сгенерировано, лежит в .gitignoreimport 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.tsimport { 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.tsimport { 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-плагин, под капотом один и тот же код. Результаты кешируются на диске. Первый (самый первый и единственный) запуск проекта чуть дольше, дальше всегда быстро.

Всё в одном файле — и HMR живой

Квери и мутации можно объявлять прямо в файле страницы, рядом с формой, которая их вызывает. 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, прокинуть, hydrateexport 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 v11upload: 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      ← ideaViewQueryGET  /_point0/root/query/idea-list      ← ideaListQueryPOST /_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 — это просто данные, такое же поле в ответе, как число или строка. Возвращать их можно откуда угодно, где есть лоадер: из страниц, компонентов, кверей, мутаций.

Элементов ровно два вида, и различаются они тем, чем вы их объявили:

  • обычная функция-компонент — это серверный компонент. Point0 вызывает его на сервере (можно async), и на клиент едет только его отрендеренная разметка. Сам код и всё, что он импортировал, в браузер не попадают.

  • компонент-поинт — это интерактивный остров. Он едет ссылкой (своим именем) и пропсами-данными, а на клиенте оживает: стейт, хуки, обработчики.

Разрешение на элементы включается один раз в корневом поинте — чтобы они не протекали в данные случайно:

export const root = Point0.lets  .root()  .rsc({ depth: 1 }) // элементы разрешены в полях первого уровня  .root()

Теперь пример, где в одном лоадере есть и то, и другое. Страница идеи: контент — тяжёлый markdown, лайк — живая кнопка.

Сначала остров. Это обычный компонент-поинт, в своём файле — тогда он поедет отдельным ленивым чанком:

// modules/idea-like.tsximport { 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.tsximport { 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.tsximport { 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 есть подписки через SSE и WebSocket. В 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

экшен, работает как квери/мутация

Элементы в ответе

нет, только данные

серверные компоненты и острова

Батчинг

есть

в планах

Сабскрипшены

есть

в планах

Фреймворк вокруг

нужен отдельный

это он и есть

Если вам нравится tRPC, то по духу здесь всё родное: та же react-query, те же схемы, те же сквозные типы без кодогенерации. Просто из этого сделан не слой API поверх чужого фреймворка, а фреймворк целиком.

P.S.

Спасибо, что дочитали. Поддержите меня, пожалуйста:

Всем спасибо!

ссылка на оригинал статьи https://habr.com/ru/articles/1059310/