Хочу анонсировать свой фреймворк Point0. Это первый Bun FullStack фреймворк сопоставимый по функционалу с Next.js и TanStack Start. Однако, имеет кардинально другой DX, ради которого и был создан.

Мне всегда не нравились существующие фреймворки, особенно Next.js и Remix (React Router). Но я думал, что, видимо, по-другому фреймворки просто не получаются, поэтому и не делают. А громоздкость, чужие строгие соглашения, неповоротливость архитектуры, это просто необходимое зло, с которым я должен смириться.

В какой-то момент во мне накопилось критическое ощущение, что всё же должно быть совершенно по-другому. И я подумал, а напишу-ка я псевдокод на воображаемом фреймворке, который бы меня устраивал, вообще не взирая на возможность реализации. Просто буду писать проект, будто бы идеальный фреймворк существует.

И получилось так здорово, что я просто забыл обо всём на свете и 10 месяцев пилил реализацию этого фреймворка, а 3 месяца назад даже уволился с работы, чтобы уже скорее его добить. И вот добил, и хочу поделиться с вами.

Введение

Статья длинная, описывает основные возможности фреймворка Point0, но только самое интересное и в формате примеров кода. Более глубокое описание живёт на страницах документации. Также доступен видео обзор фреймворка на YouTube https://www.youtube.com/watch?v=lhZ6eWMXMdg и в ВК видео https://vkvideo.ru/video-227165132_456239210

До раздела “Root” включительно читайте всё, это самое важное, и читается за 5 минут. Далее, если утомитесь, листайте, поглядывая на заголовки и зацепившие внимание куски кода. Прочитайте “Engine” и “CLI”. Листайте дальше до “Deploy”, и дочитывайте до конца.

А если осилите всю статью целиком, будете обладать всеми нужными знаниями, чтобы хоть сейчас создавать проекты на Point0.

Справка

Эта статья не перевод, я писал её руками неделю. На сайте документации таже статья на английском, вот она машиннный перевод. Это статья не “пиар”, это анонс опенсорс проекта с MIT лицензией, который ранее нигде не был опубликован, то есть это уникальная статья специально для хабра.

Page

Что я хотел от страницы:

  • Объявить путь к этой странице

  • Объявить какие данные она грузит

  • Чтобы состояния лоадинга и показания ошибки отрабатывало само по себе и было полностью кастомизируемым

  • Чтобы хоть с SSR, хоть без SSR, она просто работала, и я не должен был об этом думать

  • Чтобы я мог объявлять страницы в любых файлах, в любых количествах в рамках одного файла, в любых папках проекта, и чтобы фреймворк не указывал где я должен это делать и как называть свои файлы и переменные

import { root } from '@/lib/root'

export const ideaPage = root
  .lets('page', 'idea', '/ideas/:id')
  .loader(({ params }) => {
    // params тут типизированные, потому что в строке роута мы написали :id, и тайпскрипт умеет такое парсить
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .head(({ data: { idea } }) => ({
    // тут данные типизированные сами по себе, просто потому что мы вернули их из лоадера
    title: idea.title,
    description: idea.content.slice(0, 100),
  }))
  .page(({ data: { idea } }) => (
    // тут тоже типизированные, естественно
    <div>
      <h1>{idea.title}</h1>
      <div>{idea.content}</div>
    </div>
  ))

Вот получилась страничка. Какие правила тут я для себя определил, чтобы развивать дизайн фреймворка дальше:

  • Все сущности будем называть поинтами. Вот тут вы видите поинт странички, а потом я вам покажу квери, мутации, лэйоуты, провайдеры, и прочее.

  • Анализируя поинт мы должны мочь понять всё что на него влияет просто смотря на код самого поинта, не держа в голове существование каких-то конфигов в других файлах, или даже других конструкций в этом же файле. В этом примере мы понимаем, что всё что касается это страницы описано прямо в коде самого поинта, а также что на неё влияет некий root, из которого она растёт. root это тоже поинт, про них тоже расскажу чуть дальше.

  • Поинты могут образовывать цепочки для переиспользования каких-то настроек. В частности на этой странице вы не видите какую вёрстку имеют мои лоадинг и эррор состояния, потому что они включены в root, который я вам пока не показал. И сделано это потому что по сути мне везде надо иметь одинаковый вид лоадинга и ошибки, и я не хочу это дублировать в поинте каждой страницы. В то же время я могу запросто их переопределить хоть для отдельной страницы, хоть для группы страниц, создав им отдельного родительского поинта, будет называться base поинт, но это тоже позже.

  • Любой поинт начинается с конструкции .lets, что значит, “А давайте сделаем страницу/квери/мутацию/… с именем таким-то”, далее в идеале бы не иметь аргументов, но какие-то всё же необходимы для того чтобы иметь типизированные штуки, в случае страницы необходимый аргумент это её путь, что в целом логично

  • Любой поинт заканчивается тем, что мы обозначили в .lets. Сказали .lets('page', ...), значит в конце билдера будет .page(...). Сказали .lets('query', ...), значит в конце будет .query(...). Есть в этом какая-то поэзия, рефрен так сказать.

  • Мы не генерируем типы, в отличие от остальных фреймворков, всё сидит на генериках самого билдера и просто работает само по себе, писать типы самим не надо

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

Читайте подробнее в документации про страницы.

Mutation

Что я хотел от мутации:

  • Чтобы я её мог объявить в файле где она будет использоваться, если захочу, и она бы не ломала HMR

  • Чтобы я мог её использовать обращаясь к ней напрямую, а не через индекс файл как в tRPC, потому что в tRPC когда эндпоинтов много редактор кода начинает тормозить, потому что при обращении к даже одному эндпоинту тянутся типы всех эндпоинтов

  • Чтобы схему валидации можно было объявить через любую библиотеку. Мне достаточно только zod. Но мы же делаем самый лучший фреймворк, значит нужно всем разрешить людям использовать любую схему валидации

  • Чтобы схема валидации могла быть расширена в случае чейнинга поинтов. То есть часть схемы должна мочь быть объявлена в родительском поинте

  • Чтобы это была обычная react-query мутация по своей сути

import { root } from '@/lib/root'
import { z } from 'zod'
// Форма — не часть фреймворка. Просто представим,
// что у вас есть классные компоненты для создания форм в вашем проекте
import { Form, Input, Textarea, Button } from '@/lib/form'
// А вот навигация это часть фреймворка, но об этом позже
import { navigate } from '@/lib/navigation'

export const ideaUpdateMutation = root
  .lets('mutation', 'ideaUpdate')
  .input(
    z.object({
      id: z.string().min(1),
      title: z.string().min(1),
      content: z.string().min(1),
    }), // тут мог бы быть и не zod, а например TypeBox
  )
  .loader(async ({ input: { id, title, content } }) => {
    const idea = await prisma.idea.update({
      where: { id },
      data: { title, content },
    })
    return { idea }
  })
  .mutation() // можно тут прокинуть аргументом объектом настройки,
// которые пойдут в useMutation/fetchMutation обычного react-query

export const ideaEditPage = root
  .lets('page', 'ideaEdit', '/ideas/:id/edit')
  .loader(({ params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .head(({ data: { idea } }) => `Edit: ${idea.title}`)
  .page(({ data: { idea } }) => (
    <div>
      <h1>Редактирование идеи: {idea.title}</h1>
      <Form
        defaultValues={{
          title: idea.title,
          content: idea.content,
        }}
        onSubmit={({ title, content }) => {
          await ideaUpdateMutation.fetchMutation({
            id: idea.id,
            title,
            content,
          }) // а тут вторым аргументом
          //  можно прокинуть любые аргументы, которые будут смержены с ранее объявленными в самой мутации
          await navigate('idea', { id })
        }}
      >
        <Input label="Заголовок" name="title" />
        <Textarea label="Описание" name="content" />
        <Button>Сохранить</Button>
      </Form>
    </div>
  ))

Вот получилась мутация. Я могу использовать её хоть в этом файле, хоть в другом, просто импортируя саму мутацию. Типы вообще не перегружены, редактор кода не тормозит, ощущается максимально естественно. Вот я объявил мутацию, вот я её вызвал. Круто!

После создания мутации, в ней также доступны базовые методы классической мутации react-query:

myMutation.getMutationKey(input, ...)
myMutation.getMutationOptions()
myMutation.getMutationCache()
myMutation.getMutationsCache()
myMutation.useMutation()
myMutation.fetchMutation()

Единственное отличие, что первым аргументом здесь принимается инпут, благодаря которому и формируется уникальный для конкретного этого запроса mutationKey, а также тело функции самой мутации

Читайте подробнее в документации про мутации.

Query

В процессе реализации странички редактирования я обратил внимание на то, что у нас полностью продублирован код лоадера на странице просмотра и редактирования идеи. И на самом деле можно было бы вынести сам код лоадера в отдельную функцию и её переиспользовать. Но это не то. Потому что в процессе создания фреймворка я пришёл к тому, что лоадер по факту это просто react-query под капотом, а значит нам нужно, чтобы это было буквально одна квери, с одинаковым кешем, чтобы лишние запросы не уходили на сервер. Бывают страницы где лоадер реально один на весь проект, тогда удобно объявить лоадер прямо на странице. А если лоадер переиспользуется, тогда резонно вынести его в отдельную квери и переиспользовать в самих страницах уже эту квери.

import { root } from '@/lib/root'

export const ideaViewQuery = root
  .lets('query', 'ideaView')
  .input(
    z.object({
      id: z.string().min(1),
    }),
  )
  .loader(async ({ input: { id } }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id },
    })
    return { idea }
  })
  .query() // можно тут прокинуть аргументом объектом настройки,
// которые пойдут в useQuery/fetchQuery обычного react-query

export const ideaPage = root
  .lets('page', 'idea', '/ideas/:id')
  // вот тут мы инжектим эту квери в саму страницу
  // и определяем как параметры из роута будут замаплены на инпут самой квери
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  // дальше без изменений, и типы все на месте, так как мы вызватили их
  // из самого типа возвращаемых квери данных, которые мы выхватили из её лоадера
  .head(({ data: { idea } }) => ({
    title: idea.title,
    description: idea.content.slice(0, 100),
  }))
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
      <div>{idea.content}</div>
    </div>
  ))

export const ideaEditPage = root
  .lets('page', 'ideaEdit', '/ideas/:id/edit')
  // точно также инжектим эту же квери в другую страницу
  // на той странице можно было также написать, так как у нас тип
  // params в самой странице совпадает с типом инпута квери
  .with(ideaViewQuery, ({ params }) => params)
  .head(({ data: { idea } }) => `Edit: ${idea.title}`)
  .page(({ data: { idea } }) => (
    // ...
    // тут ничего нового, всё по старому, так же форма с мутацией
    // ...
  ))

Вот получилась квери. А также был объявлен новый метод поинтов под названием .with(). Этот .with() это вообще швейцарский нож, который позволяет не только инжектить квери, но и многое другое, про него расскажу чуть позже.

Самое главное что квери как и мутация может быть объявлена где угодно и использоваться напрямую. Серверный код сам по себе будет выпилен из неё на клиенте. Причём эта квери может запросто использоваться и вообще как самая обычная квери в любом обычном компоненте, без всяких .with()

import { ideaViewQuery } from '@/modules/idea'

export const MyUsualComponent = ({ id }: { id: string }) => {
  const result = ideaViewQuery.useQuery({ id }) // первым аргументом идёт инпут
  // вторым аргументом можно прокинуть опции классического react-query, которые будут объединены
  // с теми опциями которые вы объявили в самом поинте в последнем .query({ ... }) методе
  // если вы их там объявляли

  // в result сейчас хранится оригинальный возвращаемый объект классической useQuery из react-query
  // вы даже можете делать так: const result = useQuery(ideaViewQuery.getQueryOptions({ id }))
  // получится то же самое только длиннее по записи
  if (result.isLoading) {
    return <div>Загрузка...</div>
  }
  if (result.error) {
    // А ошибки у нас кстати никогда не unknown, у нас есть свой ErrorPoint0
    // Который вы можете заменить на свой класс ошибок, и тогда здесь будет ваш AppError
    // Это тоже отдельная тема
    return <div>{result.error.message}</div>
  }
  // на этом этапе мы по типам уже знаем, что данные здесь есть, потому что нет загрузки, и нет ошибки
  return (
    <div>
      <h1>{result.data.idea.title}</h1>
      <div>{result.data.idea.content}</div>
    </div>
  )
}

И для удобства все базовые методы классической react-query доступны прямо в поинте самой квери:

myQuery.useQuery(input, ...)
myQuery.getQueryKey(input, ...)
myQuery.getQueryOptions(...)
myQuery.fetchQuery(...)
myQuery.prefetchQuery(...)
myQuery.getQueryData(...)
myQuery.ensureQueryData(...)
myQuery.refetchQuery(...)
myQuery.setQueryData(...)
myQuery.getQueryCache(...)
myQuery.getQueriesCache(...)
myQuery.getQueryState(...)
myQuery.cancelQuery(...)
myQuery.invalidateQuery(...)
myQuery.removeQuery(...)
myQuery.resetQuery(...)

Единственное отличие, что первым аргументом здесь принимается инпут, благодаря которому и формируется уникальный для конкретного этого запроса queryKey, а также тело функции самой квери. А сам queryKey получается буквально вот таким:

const ideaViewQueryKey = ideaViewQuery.getQueryKey({ id: 'my-super-duper-id' })
console.info(ideaViewQueryKey)
;[
  'point0',
  {
    scope: this.scope, // по умолчанию "root", но если у вас много клиентов, то тут будет скоуп вашего клиента
    // да у нас можно иметь один сервер и много клиентов, например один сайт, другой экспо приложение, а третий админка
    // и они могут переиспользовать квери и прочие поинты из одной кодовой базы, причём из каждого клиента будет вырезан код
    // других клиентов, которых он не касается. То есть у нас 1 сервер и сколько хочешь клиентов.
    // Про это читайте в доке, но просто знайте, что у нас тут очень серьёзный фреймворк
    type: this.type, // в данном случае "query",
    // но у нас же бывают и страницы, лоадер в которых это тоже квери по сути,
    // так что тут может быть и "page", и "layout", и много что ещё, короче тип поинта
    name: this.name, // это название поинта, которое мы прописали вторым аргументом в
    // .lets('query', 'ideaView'), то есть "ideaView"
    mode: 'server', // а у нас ещё и клиентские квери бывают, скоро расскажу
    finiteness: 'finite', // у нас есть "infiniteQuery" а не только "query"
    // и было бы логично, что прсто поле "type" выше достаточно, но это только так кажется
    // ведь сама страница может объявить, что её лоадер возвращает результат как infiniteQuery,
    // тогда тут будет "infinite", а "type" по-прежнему останется "page"
    tags: [], // ну у нас ещё можно сам поинт покрыть тегами, чтобы потом по ним фильтровать квери,
    // но об этом просто читайте в документации, я тут стараюсь показать самое интересное,
    // а это уже другая тема
    output: 'data', // уф... Ну, в общем по идее тут почти всегда просто "data", но забегая вперёд скажу,
    // что тут может быть и "queryClientDehydratedState" для ситуаций, когда мы решили префетчить все квери на странице
    // перед переходом на неё, чтобы например интерфейс не моргал по ходу перехода, но об этом тоже потом
    input: '{"id":"my-super-duper-id"}', // просто стабильно стрингифаеный инпут. Причём если вы использовали superjson
    // как трансформер, тогда тут будет также зашита та самая трансформация. Про трансформеры тоже позже, но это по сути также
    // в tRPC, ничего нового
  },
]

Вообще, вы сами никогда напрямую с этим квери кей не работаете. Я вам этот квери кей показал, просто потому что скоро в этой статье я буду рассказывать о некоторых частях реализации, где пригодится понимание того из чего вообще этот квери кей состоит.

И пока далеко не ушли, напоминаю, что лоадер, который мы объявили в самих страницах по типу:

export const ideaPage = root
  .lets('page', 'idea', '/ideas/:id')
  .loader(({ params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
      <div>{idea.content}</div>
    </div>
  ))

Делают сами эти страницы кверями, и поэтому вы можете делать вот так:

ideaPage.fetchQuery({ id: 'my-super-duper-id' })
ideaPage.useQuery({ id: 'my-super-duper-id' })
// и так далее, короче это обычная квери, одновременно с тем, что это страница

Вы даже можете сделать вот так, но это странно, но так можно:

// та же страница, тут без изменений
export const ideaPage = root
  .lets('page', 'idea', '/ideas/:id')
  .loader(({ params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
      <div>{idea.content}</div>
    </div>
  ))

export const ideaEditPage = root
  .lets('page', 'ideaEdit', '/ideas/:id/edit')
  // а вот тут мы использовали вместо квери прямо саму страницу ideaPage
  .with(ideaPage, ({ params }) => params)
  .head(({ data: { idea } }) => `Edit: ${idea.title}`)
  .page(({ data: { idea } }) => (
    // ...
    // тут ничего нового, всё по старому, так же форма с мутацией
    // ...
  ))

И на самом деле это хоть и странно, но как будто бы даже круто. Но тут такой нюанс, что мы получается если объявим эти страницы в разных файлах, то в файл клиентского бандла на страницу редактирования идеи попадёт также и код страницы просмотра идеи, а мы хотели только квери. В целом если страницы маленькие то ок. Дублирования кода не будет, сборщик всё распределит как надо, просто вопрос объёма первого загружаемого чанка на этой странице. У нас все страницы грузятся лениво (можно отключить и грузить все сразу), так что лучше на практике хранить квери отдельно, а страницы отдельно. Но это дело каждого, давайте делать как кому угодно. Я специально хотел сделать фреймворк свободным от соглашений и позволить всем творить, что захочется, и самим выстраивать свою архитектуру и соглашения

Читайте подробнее в документации про квери.

Сокращённая .lets нотация

Когда я начал писать экзамплы и реальные проекты, мне поднадоело дублирование строк в конструкциях по типу

export const ideaPage = root
  .lets('page', 'idea', '/ideas/:id')
  // ...
  .page()

И я подумал, что хорошо бы иметь короткую нотацию, которая будет абсолютно такой же в рантайме как и конструкция выше:

export const ideaPage = root.lets
  .page('/ideas/:id')
  // ...
  .page()

Такая нотация будет работать только со включенным компилятором (о нём позже), который буквально трансформирует код во втором примере в код в первом примере. Правило у него такое: берём название переменной “ideaPage”, если оно кончается на тип поинта “Page/Query/…”, то этот суффикс отрезаем, так получаем название поинта “idea”, и просто заменяем конструкцию на получившуюся.

Оба способа объявления поинтов валидны, оба как надо типизированы. Но далее по тексту я буду использовать короткую нотацию, потому что в целом считаю её более удобной.

Читайте подробнее в документации про поинты.

Layout

Что я хотел от лэйоута:

  • Я хотел мочь объявить какой лэйоут нужен странице в коде самой страницы, а не ходить объявлять его в отдельном файле, так ещё и страницу туда руками импортировать

  • Я хотел, чтобы лэйоут мог определить часть пути страницы, которая будет унаследована страницей его использующей

  • Чтобы лэйоут как и страница могли иметь свои лоадеры/квери, которые точно также как и квери на страницы сами обрабатывали свои лоадинг и эррор состояния

  • Чтобы при переходе между страницами в рамках одного лэйоута, квери лэйоута не перезапрашивались и лэйоут не ререндерился

export const ideasLayout = root.lets
  .layout('/idea')
  // вот тут мы также можем использовать .with(), .loader(), .head() и т.д.
  .layout(({ children }) => {
    return (
      <div>
        <h1>Ideas Layout</h1>
        {children}
      </div>
    )
  })

// Теперь мы можем объявить страницу от лэйоута ideasLayout
export const ideaPage = ideasLayout.lets
  .page('/:id')
  // результирующий роут получится таким: /idea/:id
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
      <div>{idea.content}</div>
    </div>
  ))

Лэйоуты также можно наследовать друг от друга:

export const generalLayout = root.lets
  // путь можно вообще не указывать, тогда будет '/' для первого лэйоута,
  // или предыдущий роут елси ранее был объявлен
  .layout()
  // я понимаю, что конструкция .layout().layout() выглядит странно, но такой вот дизайн у всех поинтов,
  // нам нужно в начале поинта объяснить, что мы строим, а потом закончить билдер этим же словом.
  // На практике почти всегда между этими двумя моментами мы объявляем дополнительные хелперы
  // вроде .with(), .loader(), .head() и т.д. (а это далеко не все хелперы, но об этом позже)
  // если смущает на местах можете использовать .lets('layout', 'general').layout()
  .layout(({ children }) => {
    return (
      <div>
        <h1>General Layout</h1>
        {children}
      </div>
    )
  })

// Вот тут мы наследуемся от generalLayout, а не от root
export const ideasLayout = generalLayout
  .layout('/idea')
  .layout(({ children }) => {
    return (
      <div>
        <h1>Ideas Layout</h1>
        {children}
      </div>
    )
  })

// Тут без изменений
export const ideaPage = ideasLayout.lets
  .page('/:id')
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
      <div>{idea.content}</div>
    </div>
  ))

Давайте ещё посмотрим как работать с лоадерами лэйоута, и как получать данные лэйоута в самих страницах. Идея в том, что страница не наследует сами квери и лоадеры лэйоута, но сам лэйоут является провайдером. А также мы точно знаем, что страница не будет отрендерена, пока не загрузятся данные лэйоута. Поэтому мы можем использовать useValue() для получения данных лэйоута в самих страницах. Больше про useValue() и getValue() расскажу в разделе о провайдерах, это ещё один тип поинтов, доступных нам

export const ideaLayout = root.lets
  .layout('/idea/:id')
  .loader(({ params }) => {
    // по сути то же самое, что и .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .layout(({ children }) => {
    return (
      <div>
        <h1>Ideas Layout</h1>
        {children}
      </div>
    )
  })

export const ideaPage = ideaLayout.lets.page('/').page(() => {
  const { idea } = ideaLayout.useValue()
  return (
    <div>
      <h1>{idea.title}</h1>
      <div>{idea.content}</div>
    </div>
  )
})

Читайте подробнее в документации про лэйауты.

Root

Рут это самый первый поинт, от которого строятся все остальные поинты. В нём удобно задать какие-то настройки, которые нужны всем остальным поинтам. Также в разрезе сервера он сам по себе выступает входной точкой.

Я приведу пример реального рута, в комментариях подпишу, что зачем нужно, но подробнее обо всех настройках расскажу в соответствующих разделах

import { Point0 } from '@point0/core'
import { zodSchemaHelper } from '@point0/core/schema/zod'
import { openapi } from '@point0/openapi'
import superjson from 'superjson'

// В отличие от всех остальных поинтов, основной root не наследуется от других поинтов, а создаётся прямо из Point0
// Кстати абсолютно все поинты (рут, страницы, квери, мутации, лэйоуты, провайдеры) это просто инстансы единственного класса Point0
export const root = Point0.lets
  .root()
  // урл сервера нам нужен для того чтобы при вызове например ideaViewQuery.fetchQuery({ id: '123' }) мы знали на какой origin направить запрос
  .serverUrl(process.env.SERVER_URL)
  // урл клиента нужен, чтобы при вызове ideaViewPage.route.abs({ id: '123' }) мы получили https://mydomain.com/ideas/123
  .clientUrl(process.env.CLIENT_URL)
  // по аналогии с trpc, мы можем использовать любой трансформер. Через него прогоняется инпут кверей при отправке/получении,
  // а также возвращаемые данные самими лоадерами
  .transformer(superjson)
  // схема хелперы мы объявляем для того, чтобы мочь из объявленных инпут схем всякие штуки для openapi
  // а также для некоторых корнер кейсов в обработке сёрч параметров. Короче очень внутряковая штука.
  // Но объявить её должны вы, чтобы Point0 знал какую библиотеку вы используете
  // Причём настройка опциональная, так-то всё работает через StandardSchema
  // Также есть хелперы для zod, valibot, yup, arktype, typebox, superstruct
  .schemaHelper(zodSchemaHelper())
  // Вы можете переопределить класс ошибки, которую бросает фреймворк,
  // она просто должна иметь структуру как у ErrorPoint0 или шире (подробнее про ошибку дальше по тексту)
  .errorClass(AppError)
  // Про prefetch тоже расскажу дальше, но идея такая, что мы можем до перехода на страницу загружать её данные,
  // чтобы в момент перехода страница была уже полностью загружена. Причём у нас несколько опций как это сделать,
  // в частности здесь используется 'pageDehydratedStateAndClientQuery', самая надёжная, но вычислительно дорогая.
  // может быть переопределено для отдельных страниц
  .prefetchPageOnNavigate('pageDehydratedStateAndClientQuery')
  // А ещё можно подгружать страницу при наведении на ссылку, чтобы по ощущениям для пользователя она подгрузилась быстрее при переходе на неё
  // может быть переопределено для каждой отдельной страницы, или для каждой отдельной ссылки
  .prefetchPageOnLinkHover('pageDehydratedStateAndClientQuery')
  // Можем указать дефолтные опции для всех квери, которые могут быть переопределены на местах создания кверей
  // и на этапе их вызова
  .queryOptions({
    retry: false,
    retryOnMount: false,
    refetchOnMount: false,
    refetchOnWindowFocus: false,
    refetchOnReconnect: false,
    refetchInterval: false,
    refetchIntervalInBackground: false,
    staleTime: 1 * 60 * 1000, // 1 minute
  })
  // Тут можно подписаться на всякие события для логирования и других целей, в данном случае мы подписались на все ошибки
  // можно подписаться отдельно на клиентские через .clientOn() или на серверные через .serverOn()
  .on('error', ({ side, name, error, meta, data }) => {
    console.error({
      error,
      // side: client | server
      side,
      // name: название события
      name,
      // meta: данные события подходящие для логирования
      meta,
      // data: сырые данные, включая например сам респонс,
      // не надо их логировать, но можно достать из них, что нужно
      // data, (не логируем)
    })
  })
  // Можно тут объявить глобальные настройки для Unhead
  .head('global', ({ loading, error }) => {
    return {
      ...(loading ? { title: 'Loading...' } : {}),
      ...(error ? { title: error.message } : {}),
      titleTemplate: '%s | My App',
    }
  })
  // Компонент состояния лоадинга, который будет показан на этапе загрузки квери/лоадеров
  // связанных со страницей/лэйоутом/компонентом/провайдером
  // может быть переопределён на местах
  .loading(() => {
    return <Spinner size="3xl" className="m-auto" />
  })
  // Компонент состояния ошибки, который будет показан на этапе ошибки квери/лоадеров
  .error(({ error }) => {
    return <ErrorPageComponent error={error} />
  })
  // Можно отдельно указать для компонентов. Лоадинг компонентов это тоже касается
  .componentError(({ error }) => {
    return <ErrorComponent error={error} />
  })
  // Вот тут как раз про то, что рут выступает также и входной точкой для сервера.
  // Про мидлвары мы ещё поговорим. Они нашему коду почти не нужны, но они необходимы
  // При желании использовать сторонние мидлвары по типу better-auth
  .middleware(
    '/api/auth/*',
    async ({ request }) => await betterAuthServer.handler(request.original),
  )
  // Вот тут вот можно ещё и опенапи настроить
  .middleware(
    openapi({
      route: '/openapi.json',
      scalar: '/scalar',
      swagger: '/swagger',
    }),
  )
  // как и все поинты, чем начинается, тем и заканчивается .root()
  .root()

Идея в том, что, находясь в любом файле, который содержит какой-то поинт, всё что на него влияет мы можем быстро найти по цепочке родителей просто по клику в редакторе кода. Условно от страницы к лэйоуту, от лэйоута к руту, и всё, никаких сайд эффектов.

А вот так выглядело объявление рута, до введения короткой .lets нотации:

// первый аргумент тип поинта, второй имя поинта
export const root = Point0.lets('root', 'root')
  // ...
  .root()
// выглядело кринжово, но по-прежнему валидно

Отмечу, что мы можем иметь более одного рута. Это нужно, если мы хотим иметь более одного клиента. Например у нас есть сервер, есть сайт, и есть приложение на экспо, и админку мы тоже решили иметь отдельным клиентом (я так не делаю, админку проще иметь в одном клиенте с сайтом, всё равно все чанки подгружаются только там где надо). Тогда можно создавать руты от рутов:

// вот от этого рута создадим все остальные руты, а также будем создавать чисто серверные экшены,
// например вебхуки, и переиспользуемые квери
export const root = Point0.lets
  .root()
  // тут тогда не надо вшивать .loading(), .error(), и так далее, их вошьём для рута каждого клиента отдельно
  // ...
  .root()

// вот от этого рута будем создавать страницы сайта
export const siteRoot = root.lets
  .root()
  // ...
  .root()
// вот от этого рута будем создавать страницы админки
export const adminRoot = root.lets
  .root()
  // ...
  .root()
// в экспо свой роутер, поэтому страницы мы от него создавать не будем,
// но по-прежнему можем создавать компоненты, квери, мутации, провайдеры, и тд
export const expoRoot = root.lets
  .root()
  // ...
  .root()

Читайте подробнее в документации про рут.

Base

Резонно предположить, что раз уж мы можем объявить какие-то общие настройки для всех поинтов, иногда мы хотим объявить настройки лишь для части поинтов, а не всех. Тогда мы можем создать base, и потом наследовать поинты от него.

export const base = root.lets
  .base()
  .queryOptions({
    retryOnMount: true,
    // любые оверрайды тут
  })
  // любые оверрайды прочих методов также возможны
  // например какой-то другой ладинг компонент
  .loading(() => {
    return <MySpecialSpinner />
  })
  .base()

export const specialPage = base.lets.page('/special').page(() => {
  return <div>Special Page</div>
})

Но на самом деле по сути мне не особо-то и пригождается. Потому что у нас есть плагины, про которые я расскажу позже, и они просто позволяют вставлять внутри цепочки поинта какие-то методы, и это гораздо более удобно.

Читайте подробнее в документации про base.

Loading

Давайте разберёмся, когда мы будем видеть состояния ошибки, а главное лоадинга на наших страницах.

export const ideaPage = root.lets
  .page('/ideas/:id')
  .loading(() => {
    return <Spinner size="3xl" className="m-auto" />
  })
  .loader(({ params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
      <div>{idea.content}</div>
    </div>
  ))

Во-первых, .loading() должен быть объявлен в цепочке чейна (в самом пейдже, или где-то в его родителе) до того, как мы встретимся с методом, вызывающим состояние лоадинга. Будет использован последний найденный .loading() компонент.

Условие такое, что в замыкающий .page() должны обязательно попасть уже загруженные данные. Поэтому мы увидим лоадинг состояние, если данных ещё нет. А также эррор состояние, если в процессе загрузки была получена ошибка.

Надо учитывать, что при включённом ssr все данные уже подгружены, значит мы не увидим состояния загрузки при первичном открытии страницы.

При переходе между страницами, если мы выключим настройку (в самой странице или в её родителе) .prefetchPageOnNavigate('none'), то тогда при переходе на страницу мы увидим состояние загрузки, ведь мы не грузили данные перед переходом.

Если мы включим настройку .prefetchPageOnNavigate('pageDehydratedStateAndClientQuery'), то тогда при клике на ссылку, сначала начнут подгружаться данные (не html, а именно сами данные) и js чанки новой страницы, а только потом произойдёт переход, и тогда мы нашего .loading() компонента не увидим.

Чтобы при включённой .prefetchPageOnNavigate('pageDehydratedStateAndClientQuery') всё же как-то дать пользователю понять, что происходит загрузка, предлагаю использовать NProgress например. И включить его куда-нибудь в наш app.tsx:

import { useOnNavigate } from '@point0/core/navigation'
import nprogress from 'nprogress'

export const NProgress = () => {
  useOnNavigate(() => {
    // вот эта функция вызывается в начале перехода на новую страницу
    const timeout = setTimeout(() => {
      nprogress.start()
      // вдруг очень быстро загрузится, тогда не будем показывать загрузку
    }, 30)

    return () => {
      // вот эта функция вызывается в конце перехода на новую страницу
      clearTimeout(timeout)
      nprogress.done()
    }
  })

  return null
}

Читайте подробнее в документации про загрузку и ошибки.

Many Queries

В одну страницу можно воткнуть больше одной квери:

export const ideaBestQuery = root.lets
  .query()
  .loader(async () => {
    const bestIdea = await prisma.idea.findFirst({
      orderBy: {
        rating: 'desc',
      },
    })
    return { bestIdea }
  })
  .query()

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .with(ideaBestQuery)
  .page(
    ({
      // в data попадает первая объявленная квери
      data: { idea },
      // но при работе с множеством квери этого не достаточно, поэтому проще добыть нужную квери
      // через queries, где уже загруженные типизированные квери хранятся в массиве в порядке их объявления
      queries: [ideaViewQueryResult, ideaBestQueryResult],
    }) => (
      <div>
        <h1>{ideaViewQueryResult.data.idea.title}</h1>
        <div>{ideaViewQueryResult.data.idea.content}</div>
        <hr />
        <h2>Best Idea</h2>
        <div>{ideaBestQueryResult.data.bestIdea.title}</div>
      </div>
    ),
  )

Тут правило такое, что все квери, объявленные в .with(), вызываются параллельно по умолчанию, лоадинг мы видим до тех пор, пока не загрузятся все. Ошибку мы увидим первую попавшуюся.

Когда используется больше чем 1 квери, очень хочется перед рендером страницы привести данные в нормальное состояние, для этого у нас есть .mapper():

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .with(ideaBestQuery)
  .mapper(({ queries: [ideaViewQueryResult, ideaBestQueryResult] }) => ({
    idea: ideaViewQueryResult.data.idea,
    bestIdea: ideaBestQueryResult.data.bestIdea,
  }))
  .page(
    ({
      // в data теперь попадает всё то, что мы вернули из мапера
      data: { idea, bestIdea },
    }) => (
      <div>
        <h1>{idea.title}</h1>
        <div>{idea.content}</div>
        <hr />
        <h2>Best Idea</h2>
        <div>{bestIdea.title}</div>
      </div>
    ),
  )

Читайте подробнее в документации про .with(), про маппер.

.with()

  • А что делать, если нам нужно в одной из кверей в качестве инпута использовать данные из другой квери?

  • Или вообще нам нужно для инпута квери получить данные вообще из какого-то стороннего хука.

  • Или мы хотим реагировать на статус каждой отдельной квери до момента их успешной загрузки.

Вот тут нам и пригодятся возможности .with(), которые мы ранее не рассматривали.

.with() как инжектор квери

Разберём инжект одной квери в страницы:

// вот это мы уже видели, и это удобно делать именно так
export const prev_ideaPage = root.lets
  .page('/ideas/:id')
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .page(
    ({
      // тут у нас загруженные данные из квери
      data: { idea },
      // тут у нас сами загруженные квери
      queries: [ideaViewQueryResult],
    }) => <h1>{idea.title}</h1>,
  )

// но то же самое, можно написать вот так
export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(({ params }) => {
    // каждый .with() имитирует обёртку компонента, над компонентом,
    // поэтому мы свободно можем использовать здесь любые хуки
    // но именно возвращая результат useQuery() мы получаем data и queries в последующих методах
    return ideaViewQuery.useQuery({ id: params.id })
  })
  // далее никаких изменений, всё то же самое что и в prev_ideaPage
  .page(({ data: { idea }, queries: [ideaViewQueryResult] }) => (
    <h1>{idea.title}</h1>
  ))

Разберём инжект нескольких квери в страницу:

// вот это мы уже видели, и это удобно делать именно так
export const prev_ideaPage = root.lets
  .page('/ideas/:id')
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .with(ideaBestQuery)
  .page(({ queries: [ideaViewQueryResult, ideaBestQueryResult] }) => (
    <h1>{idea.title}</h1>
  ))

// но то же самое, можно написать вот так
export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(({ params }) => {
    // можно вернуть массив квери прямо из одного .with()
    return [ideaViewQuery.useQuery({ id: params.id }), ideaBestQuery.useQuery()]
  })
  // далее никаких изменений, всё то же самое что и в prev_ideaPage
  .page(({ queries: [ideaViewQueryResult, ideaBestQueryResult] }) => (
    <h1>{ideaViewQueryResult.data.idea.title}</h1>
    <h2>{ideaBestQueryResult.data.bestIdea.title}</h2>
  ))

Теперь разберём первый способ использования данных одной квери для инпута в другую квери:

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(({ params }) => {
    const ideaViewQueryResult = ideaViewQuery.useQuery({ id: params.id })
    // допустим у нас в ideaViewQueryResult.data.idea.similarIds лежат id похожих идей
    const ideaListQueryResult = ideaListQuery.useQuery(
      // первый аргумент это инпут как обычно,
      // но это не очень удобно, потому что у нас вероятно инпут ожидает ids строго как массив,
      // а не массив или андефайнед, и тогда придётся дописывать as never
      // что вообще ужасно, и поэтому существует и другой способ, про который расскажу дальше по статье
      { ids: ideaViewQueryResult.data?.idea.similarIds } as never,
      // во втором аргументе мы можем прокинуть опции классической useQuery из react-query
      // и просто говорим не включать квери пока не загрузится ideaViewQueryResult
      { enabled: !!ideaViewQueryResult.data },
    )
    return [ideaViewQueryResult, ideaListQueryResult]
  })
  // у выключенной квери реальный статус всё равно pending, поэтому мы досюда не дойдём,
  // пока не загрузится ideaViewQueryResult
  .page(({ queries: [ideaViewQueryResult, ideaListQueryResult] }) => (
    <div>
      <h1>{ideaViewQueryResult.data.idea.title} </h1>
      <h2>Похожие идеи</h2>
      <ul>
        {ideaListQueryResult.data.ideas.map((idea) => (
          <li key={idea.id}>{idea.title}</li>
        ))}
      </ul>
    </div>
  ))

Но мне не очень нравятся эти трюки с enabled, да и вообще громоздко как-то, поэтому есть и другой способ, который мне нравится больше, об этом в ближайших разделах .with()

.with() как менеджер состояния

Вот это новое:

export const strangePage = root.lets
  .page('/strange')
  .with(({ LoadingComponent, ErrorComponent }) => {
    // В LoadingComponent лежит то, что мы ранее объявляли в .loading() методе
    // В ErrorComponent лежит то, что мы ранее объявляли в .error() методе
    const [isLoading, setIsLoading] = useState(true)
    const error = useState(() =>
      Math.random() > 0.5 ? new Error('Как же я ошибалась') : undefined,
    )

    useEffect(() => {
      setTimeout(() => {
        setIsLoading(false)
      }, 1000)
    }, [])

    if (isLoading) {
      return <LoadingComponent />
    }

    if (error) {
      return <ErrorComponent error={error} />
    }

    // return undefined
    // или просто ничего не возвращаем, значит рендерим следующие методы
  })
  // Мы не дойдём до рендера, пока все .with() не будут разрезолвлены
  .page(() => <h1>Я что-то грузила, что не знаю, но у меня получилось</h1>)

Давайте ещё один пример разберём с другой нотацией, которая мне нравится больше:

export const strangePage = root.lets
  .page('/strange')
  .with(() => {
    const [isLoading, setIsLoading] = useState(true)
    const error = useState(() =>
      Math.random() > 0.5 ? new Error('Как же я ошибалась') : undefined,
    )

    useEffect(() => {
      setTimeout(() => {
        setIsLoading(false)
      }, 1000)
    }, [])

    if (isLoading) {
      // можно просто вернуть зарезервированное слово 'loading'
      // это то же самое, что и return <LoadingComponent />
      return 'loading'
    }

    if (error) {
      // можно просто вернуть любой объект, который instanceof Error
      // это то же самое, что и return <ErrorComponent error={error} />
      return error
    }

    // return undefined
    // или просто ничего не возвращаем, значит рендерим следующие методы
  })
  .page(() => <h1>Я что-то грузила, что не знаю, но у меня получилось</h1>)

.with() как инжектор пропов

Вот допустим мы в одном .with() хуке получили какой-то результат вычислений, и хотим использовать его в другом .with() хуке, или на самой странице. Тогда мы можем прокидывать пропсы вниз по методам поинта.

export const strangePage = root.lets
  .page('/')
  .with(() => {
    // если мы вернули не реакт элемент, не instanceof Error, не слово 'loading', не результат квери,
    // то значит мы вернули пропсы, которые будут доступны в следующих методах поинта
    return {
      x: 1,
      y: 2,
    }
  })
  .with(({ props: { x, y } }) => {
    return {
      a: x * 10,
      b: y * 100,
      // пропсы можно перезаписать даже с другим типом
      // в результате мы всё равно будем видеть какие надо типы в последующих методах поинта
      // потому что по сути это nextProps = {...prevProps, ...newProps}
      x: 'я решил, что буду строкой',
    }
  })
  .page(({ props: { a, b, x, y } }) => (
    <h1>
      {a} {b} {x} {y}
    </h1>
  ))

.with() как врапер

В пропсах метода .with() также лежит children, и вы можете по сути обернуть то, что возвращают последующие .with() и сам .page() в любую конструкцию:

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(({ children }) => {
    return <div style={{ border: '1px solid red' }}>{children}</div>
  })
  .page(() => <div id="page">Hello!</div>)

.with() как идея

В общем я не знаю, как коротко написать что я хотел сделать, поэтому я в несколько приёмов покажу. Поначалу будет ерунда какая-то, а потом получится хорошо. Это то к чему я пришёл в процессе создания реального проекта на фреймворке, и это становится удобно, когда пишешь уже не первую страницу или используешь более сложные реальные конструкции, которые особенно раскроются, когда мы изучим плагины в ближайшем разделе.

Давайте теперь полученные знания применим для того, чтобы посмотреть как мы можем ещё использовать .with() для использования данных одной квери в инпуте другой квери:

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .with(({ queries: [ideaViewQueryResult] }) => {
    // в отличие от .page() все .with() получают в queries в состоянии неопределённости,
    // то есть тут может быть и ошибка, и лоадинг, и результат квери
    // и мы можем как хотим их обработать

    if (ideaViewQueryResult.isError) {
      // изученное в разделе .with() как менеджер состояния
      return ideaViewQueryResult.error
    }

    if (ideaViewQueryResult.isLoading) {
      // изученное в разделе .with() как менеджер состояния
      return 'loading'
    }

    // изученное в разделе .with() как инжектор пропов
    return { similarIds: ideaViewQueryResult.data.idea.similarIds }
  })
  // до этого .with() мы не дойдём, пока предыдущий перехватывал управление через возвращение
  .with(ideaListQuery, ({ props: { similarIds } }) => ({ ids: similarIds }))
  .page(({ queries: [ideaViewQueryResult, ideaListQueryResult] }) => (
    <div>
      <h1>{ideaViewQueryResult.data.idea.title} </h1>
      <h2>Похожие идеи</h2>
      <ul>
        {ideaListQueryResult.data.ideas.map((idea) => (
          <li key={idea.id}>{idea.title}</li>
        ))}
      </ul>
    </div>
  ))

Но опять же все эти ручные управления состояниями квери мне не нравятся, поэтому был создан специальный хелпер resolve(), который принимает на вход результат квери, пока она грузится или ошибается, он возвращает лоадинг или эррор компонент, а после успеха мапит её data на props, которые будут прокинуты дальше. Итого то же самое, что в примере выше мы можем реализовать вот так:

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .with(({ queries: [ideaViewQueryResult], resolve }) => {
    // вот это то же самое что в примере выше но коротко
    return resolve(ideaViewQueryResult, ({ data }) => ({
      similarIds: data.idea.similarIds,
    }))
  })
  .with(ideaListQuery, ({ props: { similarIds } }) => ({ ids: similarIds }))
  .page(({ queries: [ideaViewQueryResult, ideaListQueryResult] }) => (
    <div>
      <h1>{ideaViewQueryResult.data.idea.title} </h1>
      <h2>Похожие идеи</h2>
      <ul>
        {ideaListQueryResult.data.ideas.map((idea) => (
          <li key={idea.id}>{idea.title}</li>
        ))}
      </ul>
    </div>
  ))

Но опять же громоздко. А как показала практика, резолвить до продолжения может понадобиться часто, поэтому была добавлена ещё более короткая нотация, вшивающая функционал резолв прямо в сам квери инжекшен:

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(
    ideaViewQuery,
    ({ params }) => ({ id: params.id }),
    // тут могли быть опции для useQuery, но нам тут они не нужны
    undefined,
    ({ data }) => ({
      similarIds: data.idea.similarIds,
    }),
  )
  .with(ideaListQuery, ({ props: { similarIds } }) => ({ ids: similarIds }))
  .page(({ queries: [ideaViewQueryResult, ideaListQueryResult] }) => (
    <div>
      <h1>{ideaViewQueryResult.data.idea.title} </h1>
      <h2>Похожие идеи</h2>
      <ul>
        {ideaListQueryResult.data.ideas.map((idea) => (
          <li key={idea.id}>{idea.title}</li>
        ))}
      </ul>
    </div>
  ))

Ещё этот resolve() может помочь, когда мы хотим вызывать квери, но не хотим, чтобы она попадала в массив queries, или в сам data. Это бывает нужно, когда мы например хотим использовать на странице данные текущего юзера, которого мы запросили через квери где-то в предыдущих поинтах, то есть по сути первым, но хотим хранить его в props, а не в data. Потому что в data мы хотим иметь сами данные страницы:

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(({ resolve }) => {
    // так как мы не возвращали результат useQuery из этого .with()
    // значит, он и не попадёт ни в массив queries, ни в data
    // а благодаря resolve() мы можем получить его данные в props
    return resolve(getMeQuery.useQuery(), ({ data }) => ({ me: data.me }))
  })
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .page(({ data: { idea }, props: { me } }) => (
    <div>
      <h1>{idea.title} </h1>
      <p>Hello, {me.name}!</p>
    </div>
  ))

Читайте подробнее в документации про .with().

Context

У нас на одну страницу, квери, компонент, и прочие поинты, которые могут иметь лоадер, можно объявить только 1 лоадер. Но что делать, если мы хотим иметь какую-то общую серверную логику для разных поинтов. Допустим, хотим, чтобы только авторизованные пользователи могли видеть страницу или запрашивать квери/мутацию.

// допустим у вас есть какой-то хелпер, который позволяет получить текущего юзера из запроса
// через хеадеры или как вам удобно, мы ещё поговорим про авторизацию
import { getMe } from '@/lib/auth'

export const ideaPage = root.lets
  .page('/ideas/:id')
  .ctx(({ request }) => {
    const me = await getMe(request)
    if (!me) {
      throw new Error('Unauthorized')
    }
    // что мы возвращаем из .ctx() то попадает в ctx в последующих .loader() и .ctx()
    // nextCtx = {...prevCtx, ...newCtx}
    return { me }
  })
  // мы можем иметь сколько угодно .ctx()
  // но только один .loader() на поинт
  .loader(async ({ params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
    </div>
  ))

Мы могли бы спокойно вызвать await getMe(request) прямо в теле самого лоадера, но предполагается, что этот самый .ctx() вы скорее всего будете иметь где-то в родительском поинте, или вставлять через плагины (уже в следующем разделе про них расскажу). Но пока давайте представим, что у вас есть base для поинтов требующих авторизации, тогда получится вот так:

import { getMe } from '@/lib/auth'

export const authorizedBase = root.lets
  .base()
  .ctx(({ request }) => {
    const me = await getMe(request)
    if (!me) {
      throw new Error('Unauthorized')
    }
    return { me }
  })
  .base()

export const ideaPage = authorizedBase.lets
  .page('/ideas/:id')
  .loader(async ({ params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
    </div>
  ))

Очень важно отметить, что .ctx() будет вызван только в случае, если страница имеет .loader(), нет .loader(), значит и запроса никакого нет, так что и код в .ctx() вызван не будет.

Но как показала практика, этот base вообще не так удобен, как плагины, поэтому давайте изучать плагины.

Читайте подробнее в документации про контекст.

Plugin

Плагин позволяет определить часть методов, которые мы можем заинжектить в цепочку поинта.

import { getMe } from '@/lib/auth'

export const authorizedPlugin = Point0.lets
  .plugin()
  .ctx(({ request }) => {
    const me = await getMe(request)
    if (!me) {
      throw new Error('Unauthorized')
    }
    return { me }
  })
  .plugin()

export const ideaPage = root.lets
  .page('/ideas/:id')
  .use(authorizedPlugin)
  .loader(async ({ ctx: { me }, params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
    </div>
  ))

Все остальные поинты кроме плагина должны быть экспортированы как есть, и мы не можем создавать их динамически, чтобы мы могли их статически анализировать (нужно компилеру, о котором потом), но вот плагин мы можем оборачивать в функцию, например:

import { getMe } from '@/lib/auth'

export const authorizedPlugin = ({
  permsission,
}: { permsission?: string } = {}) =>
  Point0.lets
    .plugin()
    .ctx(({ request }) => {
      const me = await getMe(request)
      if (!me) {
        throw new Error('Unauthorized')
      }
      if (permsission && !me.permissions.includes(permsission)) {
        throw new Error('Forbidden')
      }
      return { me }
    })
    .plugin()

export const ideaPage = root.lets
  .page('/ideas/:id')
  .use(authorizedPlugin({ permissions: ['ideaRead'] }))
  .loader(async ({ ctx: { me }, params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
    </div>
  ))

То есть плагины это не то что пишут другие разработчики, а вы используете, это именно инструмент для вашего кода, чтобы организовать его удобнее.

На практике для авторизационного плагина лучше иметь сразу комбинацию из .ctx() и .with():

import { getMe } from '@/lib/auth'

export const getMeQuery = root.lets
  .query()
  .loader(async ({ request }) => {
    return { me: await getMe(request) }
  })
  .query({
    staleTime: Infinity,
  })

export const authorizedPlugin = Point0.lets
  .plugin()
  // ctx это чисто серверная штука, он вырезается на клиенте,
  // поэтому вы можете видеть его значение только в других .ctx() и .loader()
  .ctx(({ request }) => {
    const me = await getMe(request)
    if (!me) {
      throw new Error('Unauthorized')
    }
    return { me }
  })
  // .with() это штука, которая работает при рендере компонента, хоть на клиенте,
  // хоть на сервере если включён ssr, но поэтому мы тут ctx не видим, чтобы клиент не ломался
  .with(({ resolve }) => {
    return resolve(getMeQuery.useQuery(), ({ data }) => ({ me: data.me }))
  })
  .plugin()

export const ideaPage = root.lets
  .page('/ideas/:id')
  // для этой страницы имеет значение, только то, что мы имеем в плагине и .ctx()
  // .ctx() позволил в лоадере получить me, а если пользователь не авторизован, то мы и так увидим ошибку
  // как ошибку вызова квери, так что здесь .with() по сути был не нужен, но он пригодится в другом месте
  .use(authorizedPlugin)
  .loader(async ({ ctx: { me }, params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
    </div>
  ))

export const introPage = root.lets
  .page('/intro')
  // а вот тут страница вообще без лоадера. Нам при переходе на неё с другой страницы вообще не нужен запрос на сервер,
  // потому что к ней не привязана ни одна квери, и ни один лоадер (а лоадер это тоже лишь способ объявить квери).
  // А значит и то, что код внутри .ctx() вызван не будет.
  // Но у нас тут есть .with() внутри плагина, который как раз и препятствует просмотру этой страницы
  .use(authorizedPlugin)
  // Важно понимать, что статический контент всё равно могут добыть все, потому что он бандлится вместе с клиентом.
  // Поэтому все реальные данные, которые должны быть доступны только авторизованным, мы должны возвращать с сервера
  // из .loader()
  .page(({ data: { idea } }) => (
    <div>
      <h1>Только авторизованным</h1>
    </div>
  ))

export const ideaQuery = root.lets
  .query()
  .use(authorizedPlugin)
  // а квери вообще не имеет отношения к рендеру, поэтому тут вообще будет проигнорирован .with()
  // но .ctx() будет использован
  .loader(async ({ ctx: { me }, params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .query()

Плагинов можно создавать сколько угодно, и вставлять сколько угодно .use() конструкций.

Плагины даже можно вставлять друг в друга. Например есть плагин, который добавляет текущего пользователя в серверный контекст. Но он не контролирует авторизован юзер или нет. И иметь ещё один плагин, который запрещает просмотр страницы неавторизованным пользователям, использующий предыдущий плагин:

export const mePlugin = Point0.lets
  .plugin()
  .ctx(({ request }) => {
    const me = await getMe(request) // user | undefined
    return { me }
  })
  .plugin()

export const authorizedPlugin = Point0.lets
  .plugin()
  .use(mePlugin)
  .ctx(({ ctx: { me } }) => {
    if (!me) {
      throw new Error('Unauthorized')
    }
  })
  .plugin()

Читайте подробнее в документации про плагины.

Mountables

Мы раньше говорили только о страницах и лэйаутах, как о поинтах, которые могут что-то отрендерить. Ещё у нас есть компоненты и провайдеры. Все эти 4 сущности: page, layout, component, provider — называются mountables. То есть все они могут быть замоунчены в реакт-дереве. И ко всем к ним применимы .loader(), .mapper(), .with(), .use(), и так далее, то есть они все работают по одному принципу, и если вы поняли как работает один из них, то вы поймёте как работают и остальные.

Кардинальное отличие в том, что страницы и лэйоуты достаточно просто объявить, и не забыть экспортировать. Они сами будут собраны генератором, чтобы собрать файлик points.ts, где они будут перечислены, чтобы и клиент и сервер знали об их существовании (про генератор потом).

А вот компоненты и провайдеры объявив, мы должны сами где-то использовать. Давайте их изучим.

Читайте подробнее в документации про маунтаблы.

Component

Много было разговоров о том, как в одну страницу воткнуть несколько квери. А на самом деле это не так уж часто и нужно. Чаще всего разные данные нужны разным частям страницы, и нам не нужно пытаться собрать все данные в самой странице, проще позволять компонентам самим догружать нужные им данные.

export const ideaPage = root.lets
  .page('/ideas/:id')
  .loader(async ({ params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
      <SimilarIdeas input={{ ids: idea.similarIds }} />
    </div>
  ))

export const SimilarIdeas = root.lets
  .component()
  .input(z.object({ ids: z.array(z.string()) }))
  .loader(async ({ input: { ids } }) => {
    const ideas = await prisma.idea.findMany({ where: { id: { in: ids } } })
    return { ideas }
  })
  .component(({ data: { ideas } }) => (
    <div>
      <h2>Похожие идеи</h2>
      <ul>
        {ideas.map((idea) => (
          <li key={idea.id}>{idea.title}</li>
        ))}
      </ul>
    </div>
  ))

Как мы видим, у компонента как и обычной квери, мы можем передать схему инпута и лоадер, причём сам же компонент точно также становится квери, поэтому по-прежнему имеет методы (как и страница, и лэйоут, которые объявили свой лоадер):

SimilarIdeas.useQuery(input, ...)
SimilarIdeas.getQueryKey(input, ...)
SimilarIdeas.getQueryOptions(...)
SimilarIdeas.fetchQuery(...)
SimilarIdeas.prefetchQuery(...)
SimilarIdeas.getQueryData(...)
SimilarIdeas.ensureQueryData(...)
SimilarIdeas.refetchQuery(...)
SimilarIdeas.setQueryData(...)
SimilarIdeas.getQueryCache(...)
SimilarIdeas.getQueriesCache(...)
SimilarIdeas.getQueryState(...)
SimilarIdeas.cancelQuery(...)
SimilarIdeas.invalidateQuery(...)
SimilarIdeas.removeQuery(...)
SimilarIdeas.resetQuery(...)

Как и все маунтаблы, мы можем в компоненте использовать .with(), и всё что можно было в страницах и лэйоутах, можно делать и здесь.

Надо отметить, что .input() это конструкция для сервера, и поэтому инпут внутри рендера компонента мы не видим, так как он не был валидирован на клиенте, а значит и что он валидный и его тип совпадает мы гарантировать не можем. Для этого вам нужно использовать либо .sharedInput(), либо если вы просто хотите какие-то пропсы, то тогда как входные пропсы компонента их и объявить. Про .sharedInput() расскажу позже, а про входные пропсы сейчас:

export const ideaPage = root.lets
  .page('/ideas/:id')
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
      <SimilarIdeas ids={idea.similarIds} />
    </div>
  ))

export const SimilarIdeas = root.lets
  // вот тут просто прокинули тип входных пропсов какой захотели и готово
  .component<{ ids: string[] }>()
  // вот тут их добыли и прокинули в квери
  .with(ideaListQuery, ({ props: { ids } }) => ({ ids }))
  .component(({ data: { ideas } }) => (
    <div>
      <h2>Похожие идеи</h2>
      <ul>
        {ideas.map((idea) => (
          <li key={idea.id}>{idea.title}</li>
        ))}
      </ul>
    </div>
  ))

Абсолютно тот же самый код с точки зрения результата, но подход другой. Ещё раз отмечу, что реально удобнее хранить квери отдельно, а не объявлять лоадеры в самих маунтаблах.

Читайте подробнее в документации про компоненты.

Provider

Я раньше любил пользователя запрашивать в отдельном провайдере, чтобы потом мочь достать его в любом месте приложения. И поэтому подумал, что хорошо бы нам сразу иметь провайдер во фреймворке, который как и все маунтаблы может запросить любые данные хоть сам, хоть через квери, а потом раздать их всем детям.

export const MeProvider = root.lets
  .provider()
  .loader(async ({ request }) => {
    return { me: await getMe(request) }
  })
  .provider()

// Где-то внутри app.tsx
export const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <MeProvider>
        <Router />
      </MeProvider>
    </QueryClientProvider>
  )
}

// Где-то в каком-то компоненте
export const UserInfo = () => {
  const { me } = MeProvider.useValue()
  return <div>Hello, {me.name}!</div>
}

То есть по умолчанию провайдер возвращает то, что у него в data. Но в ситуации, если у нас провайдер вызвал несколько квери, или вообще квери не вызывал, а вызывал какие-то хуки, то нам бы надо привести данные в порядок

export const MeProvider = root.lets
  .provider()
  .with(() => {
    const x = useSomethingSpecial()
    return { x }
  })
  .with(getMeQuery)
  // это то же самое что и .mapper(), просто удобно описать это в последнем методе .provider()
  .provider(({ data: { me }, props: { x } }) => ({ me, x }))

export const UserInfo = () => {
  const { me, x } = MeProvider.useValue()
  return (
    <div>
      Hello, {me.name}! You are {x}!
    </div>
  )
}

Но честно, я понял, что не очень-то все эти провайдеры и нужны, потому что например именно пользователя проще вставлять через плагин. Потому что все данные это всё равно результаты кверей, а квери кешируются. И никаких лишних ререндеров мы не получаем. Но тем не менее мы имеем провайдеры, вдруг пригодятся.

Читайте подробнее в документации про провайдеры.

Infinite Query

Давайте разберём как работают Infinite Query. По сути также как и обычные квери, но есть особенности в передаче параметра отвечающего за идентификатор следующей page (page в infiniteQuery, не наша страница):

export const ideaListQuery = root.lets
  .infiniteQuery()
  .input(
    z.object({
      page: z.number().default(0),
      limit: z.number().default(2),
    }),
  )
  .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({
    // в обычных query, здесь мы могли ничего не передавать
    // здесь мы можем передать любые настройки родной react-query useInfiniteQuery
    // но самое главное наш кастомный "pageParamFromInput"
    // это ключ (путь) в инпуте к значению, которое будет использоваться как pageParam
    pageParamFromInput: 'page',
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    initialPageParam: 0,
  })

export const ideaListPage = generalLayout.lets
  .page('/ideas')
  // так как все ключи инпута ideaListQuery опциональные, то и вторым аргументом мы можем ничего не передавать
  .with(ideaListQuery)
  .mapper(({ data }) => {
    // data здесь это обычный объект полученный из useInfiniteQuery
    // ничего кастомного здесь нет, просто в мэппере удобно организуем наши данные
    return {
      ideas: data.pages.flatMap((page) => page.ideas),
      total: data.pages[0].ideasCount,
    }
  })
  .head(({ data: { total } }) => {
    return `${total} ideas`
  })
  .page(({ data: { ideas, total }, queries: [query] }) => {
    return (
      <div>
        <h1>Ideas</h1>
        <div>
          {ideas.map((idea) => (
            <h2 key={idea.id}>{idea.title}</h2>
          ))}
        </div>
        {query.hasNextPage && (
          <button
            disabled={query.isFetchingNextPage}
            onClick={() => {
              query.fetchNextPage().catch(console.error)
            }}
          >
            {query.isFetchingNextPage ? 'Loading more...' : 'Load more'}
          </button>
        )}
      </div>
    )
  })

Также мы можем использовать .infiniteQuery() прямо вшитой в страницу, используя её лоадер. То есть тот же самый код, можно было записать и следующим образом. Однако надо учесть, что у страницы-то нет “input”, у страницы вместо этого params роута, а также могут быть и сёрч параметры, которые как раз сейчас и покажу:

export const ideaListPage = root.lets
  .page('/ideas')
  .search(
    z.object({
      page: z.coerce.number<number | string>().default(0),
      limit: z.coerce.number<number | string>().default(2),
    }),
  )
  .loader(async ({ search: { 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({
    getNextPageParam: (lastPage) => lastPage.nextCursor,
    initialPageParam: 0,
    // вот тут конструкция '?.page' значит что берём из сёрч параметров
    pageParamFromInput: '?.page',
  })
  // дальше всё то же самое, без изменений
  .mapper(({ data }) => {
    return {
      ideas: data.pages.flatMap((page) => page.ideas),
      total: data.pages[0].ideasCount,
    }
  })
  .head(({ data: { total } }) => {
    return `${total} ideas`
  })
  .page(({ data: { ideas, total }, queries: [query] }) => {
    return (
      <div>
        <h1>Ideas</h1>
        <div>
          {ideas.map((idea) => (
            <h2 key={idea.id}>{idea.title}</h2>
          ))}
        </div>
        {query.hasNextPage && (
          <button
            disabled={query.isFetchingNextPage}
            onClick={() => {
              query.fetchNextPage().catch(console.error)
            }}
          >
            {query.isFetchingNextPage ? 'Loading more...' : 'Load more'}
          </button>
        )}
      </div>
    )
  })

Читайте подробнее в документации про бесконечные квери.

clientLoader(), clientInput(), sharedInput()

Ещё в Point0 можно делать чисто клиентские квери, лоадеры, мутации. Всё работает точно также, и все правила точно такие же как и для обычных поинтов, но такие квери не будут обработаны во время SSR, а тело функции loader будет вызвано прямо на клиенте в момент запроса. Вот пример страницы:

export const ideaPage = root
  .lets('page', 'idea', '/ideas/:id')
  .clientLoader(({ params }) => {
    const idea = await fetch(`https://example.com/ideas/${params.id}`).then(
      (res) => res.json(),
    )
    return { idea }
  })
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
      <div>{idea.content}</div>
    </div>
  ))

Вот пример квери:

export const ideaQuery = root.lets
  .query()
  .clientInput(z.object({ id: z.string().min(1) }))
  .clientLoader(({ params }) => {
    const idea = await fetch(`https://example.com/ideas/${params.id}`).then(
      (res) => res.json(),
    )
    return { idea }
  })
  .query()

Читайте подробнее в документации про лоадеры, про валидацию.

Location

Внутри страниц, лэйоутов и лоадеров мы получаем типизированный объект location. В лоадере, как мы уже видели, чаще всего нужен не весь location, а две его типизированные части — params (из пути роута) и search (из квери-строки), однако там же можно достать и оригинальный объект location.

В location.search хранится невалидированным, и в отличие от стандартного location.search это объект, а не строка, причём объект может быть даже не одноуровневым. Строка тоже есть, но она в location.searchString. А объект в search не одноуровневый будет в том случае, если вы перейдёте на страницу по урлу в стиле qs: http://example.com/ideas/42?a=1&b=2&c.x=3&c.y=4&d[]=5&d[]=6.

export const ideaPage = root.lets
  .page('/ideas/:id')
  .loader(async ({ location }) => {
    console.log(location.search)
    // { a: '1', b: '2', 'c': { x: '3', y: '4' }, 'd': [ '5', '6' ] }
    return { idea: 'Fake idea' }
  })
  .page(
    ({
      data: { idea },
      // тут тоже можно добыть location
      location,
    }) => <h1>{idea}</h1>,
  )

Также добыть location можно через вызов useLocation() в любом месте кода внутри роутера (про рутер скоро расскажу):

import { useLocation } from '@point0/core/navigation'

export const Breadcrumbs = () => {
  const location = useLocation()
  // location.pathname    — путь без квери и хеша: "/ideas/42"
  // location.search      — типизированный объект квери-параметров: { tab: 'news' }
  // location.searchString — сырая квери-строка: "tab=news"
  // location.hash        — якорь: "#comments" (или пусто)
  // location.hrefRel     — относительный url: "/ideas/42?tab=news#comments"
  // location.href        — абсолютный url, если известен origin
  return <div>{location.pathname}</div>
}

useLocation() реактивен — компонент сам перерендерится при навигации. Если же location нужен вне React (в каком-нибудь хелпере), есть императивный getLocation().

Читайте подробнее в документации про навигацию.

Route

Про генератор, я расскажу позже, но надо понимать, что он может нам сгенерировать вот такой файлик с роутами, опираясь на те страницы что мы сами объявили в своём проекте, которые сами по себе типизированы просто благодаря виду самой их строки: /ideas/:id, /ideas/:id/edit, и т.д. Положим его допустим в файл src/generated/point0/routes.ts:

import { Routes } from '@1gr14/route0'

export const routes = Routes.create({
  home: '/',
  about: '/about',
  ideaList: '/ideas',
  ideaCreate: '/ideas/new',
  ideaView: '/ideas/:id',
  ideaUpdate: '/ideas/:id/edit',
})

За это отвечает библиотека @1gr14/route0, которая не является роутером, это просто штука для управления строковыми путями в приложении. Она может быть использована где угодно за пределами Point0.

С этими роутами мы можем работать так:

routes.ideaView({ id: '123' }) // "/ideas/123"
routes.ideaView.abs({ id: '123' }) // "https://example.com/ideas/123"

Сама библиотека много чего умеет, она и валидировать параметры может, и так далее, но это используется под капотом Point0, а при создании проекта, нам она нужна просто чтобы добывать пути.

Читайте подробнее в документации про навигацию.

Router and Navigation

Что я хотел от навигации:

  • Чтобы у меня был объект со всеми путями в приложении, который я мог бы вести сам, а мог бы генерировать автоматически

  • Чтобы мне не приходилось все страницы самому объявлять внутри app.tsx или где там ещё и комбинировать их с лэйоутами, ведь и ежу по путям самих страниц понятно, как они там должны выстроиться

  • Чтобы переходить можно было не только по конкретному пути, который будет мучительно заменять при рефакторинге, а по имени поинта-страницы, с типизированными параметрами

  • Чтобы первый заход на страницу был SSR, чтобы мы сразу получили готовую страницу, а переходы по страницам далее были SPA-шными, чтобы грузить минимум данных

Нам нужно самим объявить файл, в котором будут содержаться все наши хелперы для организации роутинга, все они будут типизированы благодаря переданным в createNavigation routes. Положим этот файл в src/lib/navigation.ts:

import { createNavigation } from '@point0/react-dom/router'
import {
  // можете использовать любые wouter хуки, какие вам нравятся
  navigate as browserNavigate,
  useBrowserLocation as hook,
} from 'wouter/use-browser-location'
import { routes } from '@/generated/point0/routes'

export const {
  navigate,
  Link,
  NavLink,
  Redirect,
  redirect,
  Router,
  RouterRoutes,
} = createNavigation({
  routes,
  navigate: browserNavigate,
  hook,
})

Теперь нам нужно засунуть Router/RouterRoutes в наш app.client.tsx. Я про структуру app.client.tsx расскажу позже, пока как есть:

import { Router, RouterRoutes } from '@/lib/navigation'
import { UnheadProvider } from '@point0/core/unhead'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/query-client'
import { Head } from '@unhead/react'

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <UnheadProvider>
        <Head>
          <link rel="shortcut icon" href="/favicon.ico" />
        </Head>
        <Router>
          {/* Вот тут уже у нас есть контекст роутера, то есть тут можем вызывать хук useLocation() и прочие */}
          <RouterRoutes />
        </Router>
      </UnheadProvider>
    </QueryClientProvider>
  )
}

Теперь мы можем навигироваться программно на местах:

// по имени страницы с типизированным инпутом
await navigate('idea', { id: '123' })

// можно опции самого wouter
await navigate('idea', { id: '123' }, { replace: true })

// а также опции связанные с префетчингом,
// например если мы везде использовали .prefetchPageOnNavigate('pageDehydratedStateAndClientQuery')
// тут это можно переопределить
await navigate('idea', { id: '123' }, { prefetch: 'none' })

// а ещё scrollToHash работает сам по себе, но можно переопределить его политику
await navigate(
  'idea',
  // чтобы указать что идёт после # в урле, просто надо дописать '#': значение
  { id: '123', '#': 'comments' },
  // это и так дефолтная политика, если хэш на этой странице переедем плавно,
  // а при переходе на новую, сразу оказываемся на месте
  { scrollToHash: 'pushHardCurrentSmooth' },
)

// ещё можно сёрч параметры передать:
await navigate(
  'ideas',
  // чтобы указать что идёт после ? в урле, просто надо дописать '?': объект
  { '?': { filter: 'fresh', sort: 'desc' } },
)

// А можем сразу указать, что эта страница нам нужна на новой вкладке:
await navigate('idea', { id: '123' }, { newTab: true })

// а можно и по сырому урлу, если очень надо
await navigate.to('/ideas/123?tab=news')

// и классика
navigate.back()
navigate.forward()

Ссылки работают по такому же принципу, что и программная навигация, только вместо navigate используется Link:

import { Link } from '@/lib/navigation'

// обычная типизированная ссылка
<Link route="idea" input={{ id: '123' }}>Открыть идею</Link>

// можно по урлу
<Link to="/ideas/123">Открыть идею</Link>

// внешняя ссылка — уходит из SPA как обычный <a>
// не будут вызваны хуки префетча и навигации
<Link href="https://example.com">Наружу</Link>

// при наведении можно префетчить именно эту ссылку (переопределяет настройку рута)
<Link route="idea" input={{ id: '123' }} prefetchOnHover="serverAndClientQuery">
  Открыть идею
</Link>

NavLink — это та же ссылка, но она знает, активна ли она сейчас, и умеет давать разные классы для разных состояний (точное совпадение, родитель текущего пути, потомок и т.д.):

<NavLink
  route="home"
  className="px-3 py-1.5 text-slate-700 hover:bg-slate-100"
  // когда мы уже на этой странице — гасим клики и подсвечиваем
  exactClassName="pointer-events-none text-slate-300"
>
  Home
</NavLink>

И ещё вот так можно в неё классы объектом передать:

<NavLink
  route="home"
  className={{
    default: 'px-3 py-1.5 text-slate-700 hover:bg-slate-100',
    exact: 'pointer-events-none text-slate-300',
  }}
>
  Home
</NavLink>

Ещё есть пара хуков для реакции на переходы. useOnNavigate() я уже показывал в разделе про лоадинг (там мы вешали на него NProgress). А useIsNavigating() просто говорит, идёт ли сейчас переход — удобно, например, притушить контент, пока грузится следующая страница:

import { useIsNavigating } from '@point0/core/navigation'

export const generalLayout = root.lets.layout().layout(({ children }) => {
  const isNavigating = useIsNavigating()
  return <div style={{ opacity: isNavigating ? 0.6 : 1 }}>{children}</div>
})

Также у нас существует index.client.ts файл, который как раз и прокидывает наши поинты (это просто массив из наших поинтов с ленивым импортом, который для нас собрал генератор), благодаря чему сам RouterRoutes знает обо всех существующих поинтах. И может собрать дерево. Он сортирует по роутам от более специфичных к менее специфичным. То есть мы можем иметь страницу ‘/ideas/new’ и ‘/ideas/:id’ и они не будут конфликтовать

import App from '@/app.client'
import points from '@/generated/point0/points.client'
import '@/styles/index.css'
import { ErrorBoundary } from '@/ui/error-boundary'
import { mount } from '@point0/react-dom/mount'

mount(
  <ErrorBoundary>
    <App />
  </ErrorBoundary>,
  points,
)

Читайте подробнее в документации про навигацию.

Redirect

Редирект — близкий родственник навигации, только мы не сами кликаем, а перенаправляем пользователя по какому-то условию. И тут есть две стороны: клиент и сервер.

На клиенте всё очевидно — это просто навигация по условию. Можно императивно через navigate(...), а можно декларативно — отрендерить компонент Redirect:

import { Redirect } from '@/lib/navigation'

export const secretPage = root.lets.page('/secret').page(() => {
  const { me } = MeProvider.useValue()
  if (!me) {
    // отрендерили — значит редиректим, работает и при SSR
    return <Redirect route="login" />
  }
  return <h1>Секретная страница</h1>
})

На сервере (в .loader() или .ctx()) мы просто должны вернуть или выкинуть redirect()

import { redirect } from '@/lib/navigation'
import { AppError } from '@/lib/error'

export const redirectUnauthorizedPlugin = Point0.lets
  .plugin()
  .ctx(({ request }) => {
    const me = await getMe(request)
    if (!me) {
      throw redirect('login')
      // return redirect('login') тоже ок
    }
    return { me }
  })
  .plugin()

Этот же redirect хелпер мы можем использовать в .with():

export const redirectUnauthorizedPlugin = Point0.lets
  .plugin()
  .use(mePlugin)
  .with(({ props: { me } }) => {
    if (!me) {
      // здесь не надо делать throw, это же как бы реакт-компонент
      // мы в реакт-компонентах ничего не выикидываем, просто возвращаем
      return redirect('login')
      // return <Redirect route="login" /> тоже ок
    }
  })
  .plugin()

.middleware()

То что в других фреймворках основа, у нас антипаттерн. Для наших поинтов мидлвары не нужны, у нас есть лоадеры и ctx. Но есть ситуации, в которых нужны именно мидлвары: CORS, сторонние библиотеки, интеграции. Обычно мидлвары мы вставляем в сам рут, тогда абсолютно все запросы к серверу пройдут через них. В особых случаях можно вставить и в сами поинты, тогда при запросе к этому поинту его мидлвара будет добавлена к остальным.

Работают также как и мидлвары в экспресс и в прочих фреймворках:

// например хотим замерить время выполнения запроса
const root = Point0.lets
  .root()
  .middleware(async ({ next }) => {
    const startedAt = performance.now()
    const result = await next()
    const duration = performance.now() - startedAt
    console.log(`Request took ${duration}ms`)
    return result
  })
  .root()

// можем остановить цепочку и вернуть свой ответ
// это бесконечно деструктивно, не делайте так, но идея ясна
// в данном случае мы сломаем все наши поинты
const root = Point0.lets
  .root()
  .middleware(async ({ next }) => {
    return new Response('Hello, world!', { status: 200 })
  })
  .root()

Интересное отличие наших мидлвар в том, что next() возвращает не Response, он возвращает особый объект. То есть мидлвара обязана либо вернуть Response, либо результат next(). Внутри next() лежит объект, описывающий, что в итоге мы запросили, включая Response, но не только его:

const root = Point0.lets
  .root()
  .middleware(async ({ next }) => {
    const result = await next()
    console.log(result.variant.type) // "endpoint" | "error" | "middleware" | "options" | "page" | "publicdir"
    // и много чего ещё, читайте подробнее в самой документации
    return result
  })
  .root()

Также первым аргументом можно передать путь, на который будет реагировать мидлвара:

export const root = Point0.lets
  .root()
  // отдаём всё, что пришло на /api/auth/*, в better-auth
  .middleware('/api/auth/*', async ({ request }) => {
    // request.original — это нативный Request из Fetch API
    return await betterAuthServer.handler(request.original)
  })
  .root()

Если мы хотим что-то в мидлваре сделать такого, что потом будет доступно в наших .ctx() и .loader(), мы можем положить это в request.state или request.cache (подробнее в разделе про request):

export const root = Point0.lets
  .root()
  .middleware(({ request, next }) => {
    request.state.x = 123
    return next()
  })
  .root()

У нас есть несколько встроенных мидлвар. Эти функции просто возвращают функцию которая становится мидлварой:

import { openapi } from '@point0/openapi'
import { cors } from '@point0/cors'
import { basicAuth } from '@point0/basic-auth'

export const root = Point0.lets
  .root()
  .middleware(cors({ origin: true, credentials: true }))
  .middleware(basicAuth({ users: { admin: 'adminpassword' } }))
  .middleware(openapi({ route: '/openapi.json', scalar: '/scalar' }))
  .root()

Читайте подробнее в документации про .middleware().

Request

В лоадеры, .ctx() и мидлвары приходит объект request — это наша обёртка над нативным Fetch-запросом. Все поля там вычисляются только при обращении к ним, так что это недорогая операция создавать такой объект на каждый запрос.

export const meQuery = root.lets
  .query()
  .loader(async ({ request }) => {
    // нативный Request из Fetch API — на случай, если нужно прямо его
    request.original

    // метод в верхнем регистре: 'GET' | 'POST' | ...
    request.method

    // заголовки, нормализованные в нижний регистр
    const auth = request.headers['authorization']

    // куки уже распарсены в объект
    const session = request.cookies['session']

    // разобранная локация запроса (pathname, search, ...)
    request.location

    // и удобное хранилище на время одного запроса — например, чтобы
    // не дёргать сессию дважды за запрос
    request.state.me ??= await getMe(request)

    // во время ssr, мы получив 1 запрос к странице для запроса данных кверей, создаём уже прямо на сервере
    // ещё запросы, которые проходя точно такой же путь, и чтобы сохранить стейт запроса между этими запросами
    // лучше класть не в request.state а в request.cache
    request.cache.me ??= await getMe(request)

    // ip-адрес клиента
    request.from.ip

    // user-agent клиента
    request.from.userAgent

    // location клиента, с которой был отправлен запрос
    request.from.location

    return { me: request.cache.me }
  })
  .query()

Для удобства создания каких-то сторонних хелперов, мы можем достать request ещё и через getRequest или getRequestOrUndefined:

import { getRequest, getRequestOrUndefined } from '@point0/core'

// если реквест не найден в окружении (node async storage в который мы положили реквест в момент запроса),
// то будет выкинута ошибка, тип здесь Request0
const request1 = getRequest()

// если реквест не найден в окружении, то будет undefined,
// тип здесь Request0 | undefined
const request2 = getRequestOrUndefined()

Нам пригодится эта информация, чтобы понять как работает CookieStore.

Читайте подробнее в документации про Request.

Response

Ответом мы управляем через хелпер set, который приходит туда же, куда и request (в лоадеры, .ctx(), мидлвары). Им можно выставить заголовки, куки и статус, не собирая Response руками:

export const loginMutation = root.lets
  .mutation()
  .input(z.object({ email: z.string(), password: z.string() }))
  .loader(async ({ input, set }) => {
    const { user, token } = await auth.login(input)

    // выставить куку (по умолчанию path '/', sameSite 'lax')
    set.cookies('session', token, {
      httpOnly: true,
      secure: true,
      maxAge: 60 * 60 * 24,
    })

    // заголовок
    set.headers('X-User-Id', user.id)

    // статус
    set.status(201)

    return { user }
  })
  .mutation()

Куку удаляют, передав undefined вместо значения: set.cookies('session', undefined). А если нужен совсем кастомный ответ — байты, редирект на файл, что угодно — из мидлвары или лоадера можно просто вернуть нативный Response, и все выставленные через set эффекты к нему применятся.

Для удобства создания каких-то сторонних хелперов, мы можем достать эффекты реквеста через getEffects или getEffectsOrUndefined:

import { getEffects, getEffectsOrUndefined } from '@point0/core'

// если эффекты не найдены в окружении (node async storage в который мы положили эффекты в момент запроса),
// то будет выкинута ошибка, тип здесь Effects0
const effects1 = getEffects()

// если эффекты не найдены в окружении, то будет undefined,
// тип здесь Effects0 | undefined
const effects2 = getEffectsOrUndefined()

Нам пригодится эта информация, чтобы понять как работает CookieStore.

Читайте подробнее в документации про Response.

File Upload

Файл — это часть инпута, просто в схеме он описан как файл. На клиенте кладёте File в инпут мутации, на сервере получаете его в лоадере. FormData фреймворк соберёт сам.

import { z } from 'zod'

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 (наследник Blob)
    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()

export const ideaCreatePage = root.lets
  .page('/ideas/create')
  .head(() => `Создание идеи`)
  .page(() => {
    const [image, setImage] = useState<File | undefined>(undefined)
    return (
      <div>
        <h1>Создание идеи</h1>
        <Form
          defaultValues={{
            title: idea.title,
            content: idea.content,
          }}
          onSubmit={({ title, content }) => {
            const { idea } = await ideaCreateMutation.fetchMutation({
              id: idea.id,
              title,
              content,
              image, // просто прокидываем файл как есть
            })
            await navigate('idea', { id: idea.id })
          }}
        >
          <input
            type="file"
            onChange={(e) => {
              const file = e.target.files?.[0] || undefined
              // специально сделал без воображаемых хелперов, чтобы показать как это примитивно
              setImage(file)
            }}
          />
          <Input label="Заголовок" name="title" />
          <Textarea label="Описание" name="content" />
          <Button>Создать</Button>
        </Form>
      </div>
    )
  })

Читайте подробнее в документации про загрузку файлов.

Action

У нас для обращения к серверу есть квери и мутации. Не хватает нормальных эндпоинтов. Для этого существуют экшены, где мы контролируем метод и путь.

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()

Также экшен может иметь входную схему параметров роута, серча, боди, хедеров. Если объявим схему боди, тогда боди будет прочитан фреймворком сам и распаршен как json/formData, но сохранит оригинальный контент в request.rawBody

const action = 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), d: z.bigint() }))
  // и при работе с action, мы можем не писать .loader()
  // а просто этот же лоадер объявить в заключительном .action()
  .action(({ request, headers, search, body, params }) => {
    return {
      headers,
      search,
      params,
      body,
      bodyUsed: request.original.bodyUsed,
    }
  })

И ещё мы можем использовать сами же экшены хоть как квери, хоть как мутацию, хоть как инфинити квери:

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 }
  })
  // и вот просто заканчиваем это нужным словом.
  // да, я говорил что все поинты должны начинаться тем же словом, с которого начался .lets
  // но action особенный, ему можно закончиться на .query() или .mutation() или .infiniteQuery()
  .mutation()

export const ideaEditPage = root
  .lets('page', 'ideaEdit', '/ideas/:id/edit')
  .loader(({ params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .head(({ data: { idea } }) => `Edit: ${idea.title}`)
  .page(({ data: { idea } }) => (
    <div>
      <h1>Редактирование идеи: {idea.title}</h1>
      <Form
        defaultValues={{
          title: idea.title,
          content: idea.content,
        }}
        onSubmit={({ title, content }) => {
          await ideaUpdateAction.fetchMutation({
            // всё как в обычной мутации, но в простых мутациях у нас инпут плоский
            // а в экшенах разделён на search, params, body
            params: { id: idea.id },
            body: { title, content },
          })
          await navigate('idea', { id })
        }}
      >
        <Input label="Заголовок" name="title" />
        <Textarea label="Описание" name="content" />
        <Button>Сохранить</Button>
      </Form>
    </div>
  ))

Хочу также отметить, что наши простые mutation, query, infiniteQuery, в отличие от trpc, не шлёют всё на один эндпоинт, они тоже имеют стабильные урлы. И квери, и мутации шлют POST запросы с инпутом в боди на свои стабильные kebab-cased урлы вида /_point0/<scope>/<type>/<имя-в-кебаб-кейсе> — например, /_point0/root/query/query-name-kebab-cased и /_point0/root/mutation/mutation-name-kebab-cased. И в связи с этим мы можем получить полную картину наших эндпоинтов в OpenAPI-схеме.

Читайте подробнее в документации про экшены.

OpenAPI

Раз уж все наши квери, мутации и экшены имеют типизированный инпут и работают как настоящие HTTP-эндпоинты, можем отдавать по ним OpenAPI-схему. За это отвечает пакет @point0/openapi, и подключается он одной мидлварой:

import { openapi } from '@point0/openapi'

export const root = Point0.lets
  .root()
  .middleware(
    openapi({
      route: '/openapi.json', // сама схема
      scalar: '/scalar', // красивый UI (Scalar)
      swagger: '/swagger', // или классический Swagger UI
      filter: 'all', // какие поинты включать
    }),
  )
  .root()

Схема собирается из инпут-схем ваших поинтов автоматически. Однако оутпут тайп я пока из типов не генерирую (это план на будущее), так что если вам в опен апи схеме нужен оутпут тайп, его надо объявить самостоятельно через .response(schema). Чтобы донастроить выдачу опенапи, добавить описание, теги, operationId или пометить эндпоинт как deprecated, у поинта есть метод .openapi():

export const ideaUpdateAction = root.lets
  .action('PUT', '/api/ideas/:id')
  .body(
    z.object({
      title: z.string().min(1),
      content: z.string().min(1),
    }),
  )
  .response(z.object({ idea: ideaSchema }))
  .openapi({
    summary: 'Создать идею',
    description: 'Создаёт новую идею и возвращает её',
    tags: ['ideas'],
    // любые другие стандартные настройки опенапи
  })
  .action(async ({ params: { id }, body: { title, content } }) => {
    const idea = await prisma.idea.update({
      where: { id },
      data: { title, content },
    })
    return { idea }
  })

Infer

Иногда нужно вытащить какой-то тип из поинта — например, тип данных, которые отдаёт квери, или тип её инпута, чтобы переиспользовать в своём коде.

// тип данных, которые вернёт лоадер квери
type IdeaViewData = typeof ideaViewQuery.Infer.QueriedData
// → { idea: Idea }

// тип сырого инпута квери
type IdeaViewInput = typeof ideaViewQuery.Infer.InputRaw
// → { id: string }

Там лежит много чего (InputRaw, QueriedData, Error, Ctx и десятки других), но в обычной работе чаще всего нужны именно данные и инпут. Это полностью бесплатно по рантайму — Infer существует только в типах.

Читайте подробнее в документации про Infer.

Query Client

Так как под капотом у нас react-query, где-то живёт его QueryClient. Создаётся он отдельно, чтобы быть общим для сервера и клиента (на сервере — свой инстанс на каждый запрос, чтобы данные пользователей не смешивались):

// @/lib/query-client
import { createQueryClient } from '@point0/core'
import { QueryClient } from '@tanstack/react-query'

export const queryClient = createQueryClient(() => new QueryClient())

И дальше он передаётся в обычный QueryClientProvider в вашем app.client.tsx. Надо понимать, что queryClient типизирован как оригинальный квери клиент, но на самом деле является прокси объектом, специально, чтобы, когда вы на сервере вызываете queryClient.anyMethod(), мы брали бы сам оригинальный queryClient из асинк стора, созданного для этого запроса на сервере. То есть вы используйте как привыкли, а вся безопасность автоматически работает под капотом.

Читайте подробнее в документации про Query Client.

Error Class

Ошибки в Point0 никогда не unknown. У нас есть базовый класс ErrorPoint0, и в любом error.error() компоненте, в result.error у квери) вы получаете именно его, с понятными полями.

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

const error = new ErrorPoint0('message', {
  code: 'ERROR_CODE', // любая строка согласно типу, чтобы вы могли его расширить, но на самом деле они все фиксированные
  status: 500, // любое число
  meta: {}, // любой объект
  redirect: new RedirectTask(), // тут специальный объект RedirectTask
  response: new Response(), // тут Response, который может переопределить ответ респонс, иначе просто сериализованная ошибка полетит на клиент
  headers: new Headers(), // тут Headers, который может переопределить заголовки ответа
})

А также имеются методы:

ErrorPoint0.from(unknown) // возвращает ErrorPoint0 инстанс из чего угодно
ErrorPoint0.serializePublic(error) // сериализует ошибку в public формат, чтобы отправить на клиент
ErrorPoint0.serializePrivate(error) // сериализует ошибку в private формат, чтобы отправить в логи

Вы можете подменить класс ошибки на свой через .errorClass() на руте. Требование одно: ваш класс должен иметь ту же структуру или шире, что и ErrorPoint0 и быть instanceof Error. Вы можете просто посмотреть исходник ошибки в Point0, скопировать и дописать нужные вам конструкции.

Но у меня есть ещё одна библиотека @1gr14/error0 для типизированных, расширяемых плагинами ошибок. Можно использовать её:

import { Error0 } from '@1gr14/error0'
import { causePlugin } from '@1gr14/error0/plugins/cause'
import { codeStatusPlugin } from '@1gr14/error0/plugins/code-status'
import { flatOriginalPlugin } from '@1gr14/error0/plugins/flat-original'
import { metaPlugin } from '@1gr14/error0/plugins/meta'
import { redirectPlugin } from '@1gr14/error0/plugins/point0-redirect'
import { expectedPlugin } from '@1gr14/error0/plugins/expected'
import { responsePlugin } from '@1gr14/error0/plugins/response'
import { stackPlugin } from '@1gr14/error0/plugins/stack'

export const AppError = Error0.mark('AppError')
  .use(
    codeStatusPlugin({
      codes: {
        UNAUTHORIZED: 401,
        FORBIDDEN: 403,
        UNSUBSCRIBED: 403,
      },
      transport: 'public',
    }),
  )
  .use(metaPlugin())
  .use(causePlugin())
  .use(responsePlugin())
  .use(redirectPlugin())
  .use(flatOriginalPlugin())
  .use(expectedPlugin({ transport: 'public' }))
  .use(stackPlugin())
export type AppError = InstanceType<typeof AppError>

export const root = Point0.lets.root().errorClass(AppError).root()

Ошибка с сервера едет на клиент, и мы не хотим слить наружу стек и внутренности. Поэтому при передаче ошибка сериализуется двумя разными способами: serializePublic в проде (клиент видит только безопасное — сообщение, код, редирект) и serializePrivate в дев-режиме (видно всё — стек, мету, статус). В логи всегда уходит полная, приватная версия. Так что в проде пользователь не увидит лишнего, а вы в логах увидите всё.

Читайте подробнее в документации про обработку ошибок.

Eventer

Для анализа происходящего в поинтах есть эвентер. Единая шина событий, на которую удобно вешать логирование и наблюдаемость. Подписаться можно на обе стороны (.on), или раздельно (.serverOn / .clientOn):

export const root = Point0.lets
  .root()
  .on('error', ({ side, name, error, meta }) => {
    console.error({ ...meta, side, name, error })
  })
  .root()

Событий много — старт/успех/ошибка для квери, инфинит-квери, мутаций, префетча страниц, серверных фетчей. В колбэк приходит: side (клиент/сервер), name (имя события), error (если есть), meta (то, что безопасно логировать) и data (сырые данные, включая, например, сам response — их логировать не стоит, но достать из них что-то можно).

Читайте подробнее в документации про события.

Engine

Engine — это и конфиг, и рантайм. Один файл src/engine.ts, в котором описано всё: где сервер, где клиент(ы), какой у них порт, где искать поинты, куда генерить файлы, что отдавать как статику. CLI берёт эту же engine, и просто вызывает функции по типу engine.dev(...), engine.build(...). Даже все запросы просто проходят через engine.fetch(request)

import { Engine } from '@point0/engine'

export const engine = Engine.create({
  file: import.meta.url, // движок должен знать, где он сам лежит
  ssr: true, // включить серверный рендеринг
  pointsGlob: '**/*.{ts,tsx,mdx}', // где искать поинты
  server: {
    scope: 'root',
    port: process.env.SERVER_PORT || process.env.PORT,
    entry: { main: './index.server.ts' },
    points: async () => await import('./generated/point0/points.server'),
    generate: { points: './generated/point0/points.server.ts' },
    outdir: '../dist/server',
  },
  // clients: [{}, {}] если у вас несколько клиентов
  client: {
    scope: 'root',
    port: process.env.CLIENT_PORT,
    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: {
        outfile: './generated/point0/routes.ts',
        origin: 'process.env.CLIENT_URL',
      },
    },
    publicdir: { source: ['../public'], outdir: '../dist/client' },
    outdir: '../dist/client',
  },
})

Сервер всегда один. Клиентов может быть несколько, тогда у каждого клиента будет свой scope, свои сгенерированные поинты, свой бандл.

И сразу покажу 3 файла, которые нужны, чтобы поднять сервер.

// src/preload.ts
import { engine } from './engine'

// это нам нужно, чтобы применить бан плагин компилятора
// который вырезает из сервера клиентский код, а из клиентского серверный
// компилятор много чего ещё делает, но об этом в другом разделе
// также эта штука установит нужные энв переменные, которые мы настроили в engine.ts
await engine.preload()

// мы храним это как отдельный файл, потому что он нам может пригодиться для запуска
// интеграционных тестов, которым также может понадобится прелоад плагина компилятора
// src/index.server.ts
// сначала грузим прелоад
await import('./preload.js')
// затем сам код сервера
await import('./app.server.js')
// Чтобы тс не ругался, экспортируем пустой модуль
export {}
// src/app.server.ts
import { engine } from '@/engine.js'

// у нас сервится наш сервер
await engine.serve()

// но это обычный серверный файл, вы можете здесь инициализировать всякие воркеры
// запускать кроны, валидировать энв переменные, и так далее

Читайте подробнее в документации про конфиг движка, про рантайм движка.

CLI

Сам фреймворк предоставляет бинарь point0, который и драйвит всё по вашему src/engine.ts:

point0 dev        # дев-сервер (сервер + клиенты), вотчинг, кодоген на лету
point0 dev --hot  # то же, но с серверным hot-reload
point0 generate   # сгенерировать points/routes/meta (см. раздел про генератор)
point0 build      # продакшен-сборка в dist/
point0 compile <file>  # показать, во что компилятор превратил файл (для отладки)

В реальном package.json приложения это обычно просто:

{
  "scripts": {
    "dev": "point0 dev --hot",
    "generate": "point0 generate",
    "build": "point0 build",
    "start": "NODE_ENV=production bun run ./dist/server/index.server.js"
  }
}

У команд есть флаги — --side server|client (поднять только одну сторону), --scope <scope> (только один клиент), --mode, --env и другие, но это уже детали для документации.

Читайте подробнее в документации про CLI.

MCP

point0-project-mcp помогает агенту ориентироваться в вашем проекте: показать список всех поинтов, найти конкретный по урлу, скомпилировать файл (увидеть, что осталось от поинта на клиенте, а что — на сервере), протрейсить цепочку импортов. Подключается так. Допустим у вас cursor и claude code. Тогда в .cursor/mcp.json и mcp.json добавляем:

{
  "mcpServers": {
    "point0-project-mcp": {
      "command": "bun",
      "args": ["run", "mcp:point0:project"]
    }
  }
}

А в package.json добавляем:

{
  "scripts": {
    "mcp:point0:project": "point0-mcp --meta ./src/generated/point0/meta.ts"
  }
}

meta.ts для нас сгенерирует генератор.

И ещё есть point0-docs-mcp — это поиск по документации самого Point0 (гибридный: ключевые слова плюс семантика). Чтобы агент отвечал про фреймворк по актуальной доке, а не по тому, что он там себе напридумывал.

Читайте подробнее в документации про MCP проекта, про MCP документации.

Publicdir

Статику (favicon, картинки, шрифты, robots.txt, .well-known/...) отдаёт publicdir. Указываете директорию-источник, а можно прямо тут же объявить и динамические файлы функцией, или указать подкаталоги:

export const engine = Engine.create({
  // ...
  client: {
    // ...
    publicdir: {
      source: [
        '../public', // всё из этой папки отдаётся с корня
        {
          // ключ путь к файлу, ответ функции её контент
          'robots.txt': () => 'User-agent: *\nDisallow: /',
          '.well-known/appspecific/com.chrome.devtools.json': () => '{}',
        },
        // или подкаталоги, тогда контент будет доступен по:
        // /a/one.txt
        // /b/two.json
        { '/a': '../public-a' },
        { '/b': '../public-b' },
      ],
      outdir: '../dist/client', // куда скопировать при сборке
    },
  },
})

В деве файлы отдаются на лету, при сборке — просто копируются в outdir. Во время раздачи в проде статика кешируется в памяти в пределах указанного допустимого объёма памяти.

Но на самом деле это всё похоже на извращение, поэтому на практике скорее всего вы будете делать так:

export const engine = Engine.create({
  // ...
  client: {
    // ...
    publicdir: {
      source: '../public',
      outdir: '../dist/client',
    },
  },
})

Читайте подробнее в документации про publicdir.

Generator

Генератор не генерирует типы, он по сути генерирует просто индекс файлы. В trpc нам нужно было собирать свои эндпоинты самостоятельно, в point0 это делается автоматически. Также место, куда будут положены сгенерированные файлы, вы объявляете сами в настройках engine.

Также отмечу, что для генерации нам даже не нужно, чтобы наш код был валидным, потому что всё генерируется посредством статического анализа кода, и благодаря этому работает быстро и почти неубиваемо.

Всё генерируемое можно смело класть в .gitignore, потому что при билде, мы всё равно всё будем генерировать заново для уверенности. А во время dev режима, всё генерируется на лету.

points.server.ts

Просто массив из поинтов, найденных в вашем проекте. Он нам нужен, чтобы потом прокинуть обратно в саму engine, и при раздаче сервера через engine.serve() мы могли бы при запросе найти нужный поинт. Вот пример генерации:

import type { PointsDefinition } from '@point0/core'
import { root as root_0 } from '../../lib/root.js'
import {
  default as unnamed_1,
  ideaBestComponent as ideaBestComponent_8,
} from '../../pages/home.js'
import { page as page_2 } from '../../pages/about.mdx'
import { ideaListPage as ideaListPage_3 } from '../../pages/idea-list.js'
import {
  ideaCreatePage as ideaCreatePage_4,
  ideaUpdatePage as ideaUpdatePage_6,
  ideaCreateMutation as ideaCreateMutation_9,
  ideaUpdateMutation as ideaUpdateMutation_11,
} from '../../pages/idea-create-update.js'
import { ideaViewPage as ideaViewPage_5 } from '../../pages/idea-view.js'
import {
  ideaNewsPage as ideaNewsPage_7,
  ideaNewsPostCreateMutation as ideaNewsPostCreateMutation_10,
} from '../../pages/idea-news.js'
import { ideaViewQuery as ideaViewQuery_12 } from '../../lib/idea.js'
export default [
  root_0,
  unnamed_1,
  page_2,
  ideaListPage_3,
  ideaCreatePage_4,
  ideaViewPage_5,
  ideaUpdatePage_6,
  ideaNewsPage_7,
  ideaBestComponent_8,
  ideaCreateMutation_9,
  ideaNewsPostCreateMutation_10,
  ideaUpdateMutation_11,
  ideaViewQuery_12,
] as PointsDefinition<
  (typeof root_0)['Infer']['RequiredCtx'],
  (typeof root_0)['Infer']['Error']
>

points.client.ts

Мы можем в настройках указать, чтобы сгенерировал точно такой же файл как для серверных поинтов, то есть со статическим импортом, тогда по сути все файлы попадут в один бандл. Но чаще всего мы хотим ленивую загрузку, поэтому по умолчанию генерируется массив с динамическим импортом. Потом эти поинты мы прокидываем в нашем index.client.ts, чтобы при билде поинты попали в бандл разными чанками.

import type { PointsDefinition } from '@point0/core'
import { root as root_0 } from '../../lib/root.js'
export default [
  root_0,
  {
    type: 'page',
    name: 'home',
    route: '/',
    polh: true,
    layouts: ['generalLayout'],
    point: async () => (await import('../../pages/home.js')).default,
  },
  {
    type: 'page',
    name: 'about',
    route: '/about',
    polh: true,
    layouts: ['generalLayout'],
    point: async () => (await import('../../pages/about.mdx')).page,
  },
  {
    type: 'page',
    name: 'ideaList',
    route: '/ideas',
    polh: true,
    layouts: ['generalLayout'],
    point: async () => (await import('../../pages/idea-list.js')).ideaListPage,
  },
  {
    type: 'page',
    name: 'ideaCreate',
    route: '/ideas/new',
    polh: true,
    layouts: ['generalLayout'],
    point: async () =>
      (await import('../../pages/idea-create-update.js')).ideaCreatePage,
  },
  {
    type: 'page',
    name: 'ideaView',
    route: '/ideas/:id',
    polh: true,
    layouts: ['generalLayout', 'idea'],
    point: async () => (await import('../../pages/idea-view.js')).ideaViewPage,
  },
  {
    type: 'page',
    name: 'ideaUpdate',
    route: '/ideas/:id/edit',
    polh: true,
    layouts: ['generalLayout'],
    point: async () =>
      (await import('../../pages/idea-create-update.js')).ideaUpdatePage,
  },
  {
    type: 'page',
    name: 'ideaNews',
    route: '/ideas/:id/news',
    polh: true,
    layouts: ['generalLayout', 'idea'],
    point: async () => (await import('../../pages/idea-news.js')).ideaNewsPage,
  },
  {
    type: 'layout',
    name: 'generalLayout',
    route: '/',
    point: async () => (await import('../../layouts/general.js')).generalLayout,
  },
  {
    type: 'layout',
    name: 'idea',
    route: '/ideas/:id',
    point: async () => (await import('../../layouts/idea.js')).ideaLayout,
  },
] as PointsDefinition<
  (typeof root_0)['Infer']['RequiredCtx'],
  (typeof root_0)['Infer']['Error']
>

routes.ts

Про роуты уже говорили. Мы просто собираем со всех поинтов роуты:

import { Routes } from '@1gr14/route0'

export const routes = Routes.create(
  {
    home: '/',
    about: '/about',
    ideaList: '/ideas',
    ideaCreate: '/ideas/new',
    ideaView: '/ideas/:id',
    ideaUpdate: '/ideas/:id/edit',
    ideaNews: '/ideas/:id/news',
  },
  { origin: process.env.CLIENT_URL },
)

meta.ts

Полная мета информации о поинтах, она также нужна для MCP сервера, который анализирует ваш проект. Там контент примерно такой:

import { Route0 } from '@1gr14/route0'
import { Engine } from '@point0/engine'
export default {
  engine: {
    file: '/Users/iserdmi/cc/opensource/1gr14/point0/examples/basic/src/engine.ts',
    import: async () =>
      (
        await Engine.findAndImportSelf({
          engineFile:
            '/Users/iserdmi/cc/opensource/1gr14/point0/examples/basic/src/engine.ts',
        })
      ).engine,
    server: {
      scope: 'root',
    },
    clients: [
      {
        scope: 'root',
      },
    ],
  },
  points: [
    {
      scope: 'root',
      type: 'root',
      name: 'root',
      id: 'root:root:root',
      tags: [],
      description: undefined,
      route: undefined,
      endpoint: undefined,
      pos: {
        file: '/Users/iserdmi/cc/opensource/1gr14/point0/examples/basic/src/lib/root.tsx',
        line: 9,
        column: 20,
      },
      import: async () => (await import('../../lib/root.js')).root,
      valid: true,
      errors: [],
      ssr: true,
      parents: [],
      layouts: [],
    },
    {
      scope: 'root',
      type: 'page',
      name: 'home',
      id: 'root:page:home',
      tags: [],
      description: undefined,
      route: Route0.create('/'),
      endpoint: {
        method: 'GET',
        route: Route0.create('/_point0/root/page/home'),
      },
      pos: {
        file: '/Users/iserdmi/cc/opensource/1gr14/point0/examples/basic/src/pages/home.tsx',
        line: 37,
        column: 15,
      },
      import: async () => (await import('../../pages/home.js')).default,
      valid: true,
      errors: [],
      ssr: true,
      parents: [
        {
          scope: 'root',
          type: 'layout',
          name: 'generalLayout',
          id: 'root:layout:generalLayout',
          pos: {
            file: '/Users/iserdmi/cc/opensource/1gr14/point0/examples/basic/src/layouts/general.tsx',
            line: 5,
            column: 29,
          },
        },
        {
          scope: 'root',
          type: 'root',
          name: 'root',
          id: 'root:root:root',
          pos: {
            file: '/Users/iserdmi/cc/opensource/1gr14/point0/examples/basic/src/lib/root.tsx',
            line: 9,
            column: 20,
          },
        },
      ],
      layouts: [
        {
          scope: 'root',
          type: 'layout',
          name: 'generalLayout',
          id: 'root:layout:generalLayout',
          pos: {
            file: '/Users/iserdmi/cc/opensource/1gr14/point0/examples/basic/src/layouts/general.tsx',
            line: 5,
            column: 29,
          },
        },
      ],
    },
    // ...
  ],
}

assets.d.ts

У нас импорт ассетов тоже управляется самим фреймворком со встроенным svgr, и возможностью выбора расширений статических файлов, которые могут быть импортированы, а при билде попасть в dist папку. Соответственно надо определить типы для импортов ассетов. Генератор сам сгенерирует нужный файл:

declare module '*.svg?react' {
  import type { FC, SVGProps } from 'react'
  const ReactComponent: FC<SVGProps<SVGSVGElement>>
  export default ReactComponent
}
declare module '*.png' {
  const src: string
  export default src
}
declare module '*.png?url' {
  const src: string
  export default src
}
declare module '*.png?file' {
  const src: string
  export default src
}
declare module '*.png?text' {
  const src: string
  export default src
}
declare module '*.png?raw' {
  const src: string
  export default src
}
declare module '*.jpg' {
  const src: string
  export default src
}
declare module '*.jpg?url' {
  const src: string
  export default src
}
declare module '*.jpg?file' {
  const src: string
  export default src
}
declare module '*.jpg?text' {
  const src: string
  export default src
}
declare module '*.jpg?raw' {
  const src: string
  export default src
}
// прочие расширения

Читайте подробнее в документации про генератор.

Compiler

Изначально планировалось, что компилер будет отвечать только за то, чтобы вырезать серверный код из клиентского, а клиентский из серверного. Потом он также стал отвечать и за инъекцию любых бабел плагинов, обработку ассетов, препятствование нежелательным импортам, поиск поинтов как таковых, парсинг mdx файлов, подставление константных env переменных, обработку env хелперов.

Сам компилер представлен в формате на выбор: bun плагин, vite плагин, babel плагин. Под капотом они все используют один и тот же код, так что работают одинаково.

Если вы используете bun, то компилер как плагин применяется, когда вы вызываете engine.preload(), именно поэтому в нашей обвязке мы вызываем это до того, как импортить любой прочий контекст, чтобы плагин был применён до импорта, и на момент импорта весь код уже был правильно подрезан для сервера. Для клиента этот же плагин вставляется в формате bun static plugin.

Чтобы посмотреть как ваш код выглядит после компиляции, вы можете вызвать команду:

point0 compile <file> --side <server|client>

Вот например такой оригинальный файл:

// src/pages/idea.tsx
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
import { SomethingForClient } from '@/components/something-for-client'

export const ideaPage = root
  .lets('page', 'idea', '/ideas/:id')
  .loader(({ params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
      <div>{idea.content}</div>
      <SomethingForClient />
    </div>
  ))

Давайте представим, что у вас выключен ssr, тогда получится вот так:

// point0 compile src/pages/idea.tsx --side server
import { root } from '@/lib/root'
import { prisma } from '@/lib/prisma'
// неиспользуемые импорты сами будут удалены после вырезки кода

export const ideaPage = root
  .lets('page', 'idea', '/ideas/:id')
  .loader(({ params }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      where: { id: params.id },
    })
    return { idea }
  })
  .page()

// point0 compile src/pages/idea.tsx --side client
import { root } from '@/lib/root'
import { SomethingForClient } from '@/components/something-for-client'
// а вот тут призма сама вырезалась

export const ideaPage = root
  .lets('page', 'idea', '/ideas/:id')
  .loader()
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
      <div>{idea.content}</div>
      <SomethingForClient />
    </div>
  ))

И код остался полностью рабочим для исполняемой среды. Клиент не знает тела лоадера, но знает, что он есть, а также знает название и путь поинта, так что имеет всё необходимое для запроса на сервер. А сервер поскольку имеет выключенный ssr, должен знать только само тело лоадера и опять же название поинта и путь, чтобы знать, что ответить клиенту, когда тот запросит.

Вы можете допрокинуть свои babel плагины в компилер через:

export const engine = Engine.create({
  // ...
  compiler: {
    babel: {
      plugins: ['babel-plugin-react-compiler'],
    },
  },
})

Компилер кеширует все результаты компиляции, так что при самом первом запуске проекта проходит чуть больше времени, а далее всегда очень быстро, и рекомпиляция происходит только при изменении файла, или изменении настроек самого компилятора.

Подчистить кеш, можно командой:

point0 prune

Читайте подробнее в документации про компилятор.

HMR

Раз уж заговорили про компилятор, хочу рассказать, как я обманул реакт и заставил HMR работать при импорте любых поинтов из файла. Мы можем из одного файла импортировать и мутацию, и страницу, и компонент, и квери. По сути компонентом с точки зрения реакта из них является только компонент. Но во время dev режима, компилер дописывает ._tail(() => null) в конец поинта:

export const ideaUpdateMutation = root
  .lets('mutation', 'ideaUpdate')
  .input(
    z.object({
      id: z.string().min(1),
      title: z.string().min(1),
      content: z.string().min(1),
    }),
  )
  .loader(async ({ input: { id, title, content } }) => {
    const idea = await prisma.idea.update({
      where: { id },
      data: { title, content },
    })
    return { idea }
  })
  .mutation()
  ._tail(() => null)

Сам ideaUpdateMutation это и есть эта функция, возвращённая из _tail(() => null), поэтому и bun и vite считают, что это компонент. А мы сами никогда не обращаемся к ideaUpdateMutation напрямую, а только к её методам, а они все на месте.

Читайте подробнее в документации про дев-режим.

Assets

Я думал позволить ассетам работать нативно как bun предлагает им работать. Но так нельзя, потому что при ssr, bun отдаёт просто абсолютный путь к файлу на сервере, а клиент на том же bun действительно отдаёт ссылку на ассет, который попадёт в бандл. Но нативными средствами bun синхронизировать это поведение невозможно. Поэтому я заставил компилятор самостоятельно со всем этим работать, и уж заодно сразу вкрутил SVGR.

import logoUrl from '@/assets/logo.png' // по умолчанию — url до файла
import GemIcon from '@/assets/gem.svg?react' // ?react — это React-компонент (через SVGR)
import logoText from '@/assets/logo.png?text' // ?text — содержимое строкой
<img src={logoUrl} />
<GemIcon className="w-5 h-5" />

Какие экстеншены считать ассетами, настраивается в конфиге engine:

export const engine = Engine.create({
  // ...
  assets: {
    enabled: true,
    extensions: ['png', 'jpg', 'jpeg', 'gif', 'svg'],
    defaultMode: 'url', // можно сказать, что без '?' не считать ассетом, или каким именно типом считать без указания '?'
    svgr: {}, // svgr опции
  },
})

Исходя из этого конфига и генерируется assets.d.ts. Опять же, можно вообще не указывать в конфиге assets, тогда будет значение по умолчанию.

Читайте подробнее в документации про ассеты.

Env Variables

Логично, что в сервер попадают все env переменные процесса. А на клиент должны попадать только указанные нами.

export const engine = Engine.create({
  // ...
  client: {
    env: {
      // вот эти будут подставлены в index.html при запросе к серверу
      vars: ['SERVER_URL', 'CLIENT_SENTRY_DSN'],
      // вот эти переменные будут запечены прямо в index.html при билде этого index.html
      consts: ['MIXPANEL_TOKEN'],
    },
  },
})

Мне такой подход нравится больше, чем указание префиксов по типу “PUBLIC_”, потому что на практике чаще всего у меня где-то есть схема валидации энв переменных, и мне гораздо удобнее просто передать в конфиг её ключи, и управлять всем этим из одного места.

Если вам нравится больше подход с префиксами, то можно сделать так:

export const engine = Engine.create({
  // ...
  client: {
    env: {
      // теперь все энв переменные с префиксом PUBLIC_ будут подставлены в index.html при запросе к серверу
      vars: 'PUBLIC_*',
      // все энв переменные с префиксом CONST_PUBLIC_ будут запечены прямо в index.html при билде этого index.html
      consts: 'CONST_PUBLIC_*',
    },
  },
})

Ещё по каким-то причинам вы можете захотеть просто запечь какие-то константы или откуда-то взявшиеся значения, например так:

export const engine = Engine.create({
  // ...
  client: {
    env: {
      vars: {
        A: 1,
      },
      consts: {
        B: 2,
      },
    },
  },
})

Также вы можете комбинировать все эти подходы:

export const engine = Engine.create({
  // ...
  client: {
    env: {
      vars: [
        {
          A: 1,
        },
        'PUBLIC_*',
        'SERVER_URL',
        'CLIENT_URL',
      ],
      consts: [
        {
          B: 2,
        },
        'CONST_PUBLIC_*',
        'MIXPANEL_TOKEN',
      ],
    },
  },
})

На сервере тоже можно допрокинуть какие-то значения в энвы, но самое главное можно определить какие из этих энвов будут константами, а значит будут подставлены как реальные значения, а значит и неиспользуемый код после их внедрения будет также подрезан:

export const engine = Engine.create({
  // ...
  server: {
    env: {
      // тут можно допрокинуть каких-то значений при желании
      vars: {
        X: 3,
      },
      // а вот тут можно определить какие энв станут константами
      consts: ['ENABLED_*'],
    },
  },
})

И потом где-то в коде

// оригинально
if (process.env.ENABLED_X === 'true') {
  console.log('X is enabled')
} else {
  console.log('X is disabled')
}

// после компиляции (а она происходит и в дев среде и на этапе билда)
// просто вырежется всё лишнее и останется:
console.log('X is enabled')
// Если какие-то импорты стали неиспользуемыми, они тоже вырежутся

Env Helpers

Есть у нас специальный объект env, который помогает работать в фулстек проекте. Сначала посмотрим на константы, которые можно добыть:

import { env } from '@point0/core'

// Вот эти конструкции опять же заменяются на константы и позволяют

env.mode.name // 'development' | 'production' | 'test' // это то же самое, что и process.env.NODE_ENV
env.mode.is.production // true | false
env.mode.is.test // true | false
env.mode.is.development // true | false

env.side.name // 'server' | 'client'
env.side.is.server // true | false
env.side.is.client // true | false

env.build.was // true | false // можно управлять как выглядит код до билда, и после билда

С помощью этого же хелпера можем вручную заниматься код сплитингом. Допустим у нас есть какой-то чисто серверный хелпер, и чисто клиентский, но у них одинаковый тип. Например трекинг эвента. Но на местах хотелось бы использовать один и тот же хелпер. Тогда можно так:

import { env } from '@point0/core'
import { mixpanelServerTrackEvent } from '@/lib/mixpanel/server'
import { mixpanelClientTrackEvent } from '@/lib/mixpanel/client'

export const trackEvent = env.side.define({
  client: mixpanelClientTrackEvent,
  server: mixpanelServerTrackEvent,
})

trackEvent('eventName', { property: 'value' })

После компиляции в зависимости от того клиент это или сервер получится вот такой код:

// client
import { env } from '@point0/core'
import { mixpanelClientTrackEvent } from '@/lib/mixpanel/client'

export const trackEvent = mixpanelClientTrackEvent

// server
import { env } from '@point0/core'
import { mixpanelServerTrackEvent } from '@/lib/mixpanel/server'

export const trackEvent = mixpanelServerTrackEvent

Причём в этот самый env.side.define не обязательно прокидывать функции, просто любые значения. И даже не обязательно одного типа, тогда там результирующий тип будет юнионом.

А если вы хотите просто объявить что-то что доступно только на сервере в файле, в котором есть и клиентский и серверный код, можете сделать так:

import { env } from '@point0/core'
// тут тип будет `undefined | ((name: string) => string)`
// так как на клиенте то это undefined
const myServerOnlyFn1 = env.side.define.server((name: string) => {
  return `Hello, ${name}!`
})

// но не очень удобно, поэтому если вы сами себе обещаете не использовать
// эту функцию на клиенте, можете написать так:
const myServerOnlyFn2 = env.side.define.unsafe.server((name: string) => {
  return `Hello, ${name}!`
})
// тогда тип будет `(name: string) => string`

Там ещё хелперы есть, читайте подробнее в документации.

Читайте подробнее в документации про env-переменные.

Importer

Учитывая, что код вырезается компилятором, можно достаточно легко что-то напутать и заимпортить серверный код в клиент и наоборот. Но чаще всего мы знаем заранее, какие модули являются именно серверными или именно клиентскими, поэтому мы можем себя обезопасить.

Допустим у нас есть файл src/lib/prisma.ts, который точно никогда не должен попасть на клиент, тогда можно просто в файле сделать import '@point0/core/server-only'. Теперь как только компилер увидит, что мы импортируем src/lib/prisma.ts на клиенте, он выбросит ошибку:

import '@point0/core/server-only'
export const prisma = new PrismaClient()

Такого же эффекта можно достичь через настройки конфига engine:

export const engine = Engine.create({
  // ...
  client: {
    importer: {
      deny: [
        // пути к файлам начинаем с .
        './lib/prisma.ts',
        // конкретные библиотеки пишем просто по названию пакета
        'dotenv',
      ],
    },
  },
})

Когда я адаптировал Point0 к expo, оказалось, что там есть такой клиентский код, который нужно разрешить видеть серверу, но сервер не должен его запускать. В частности const styles = StyleSheet.create({}). Потому что мы его объявляем в файле какой-то страницы, а страница может иметь компонент, который имеет наш лоадер для сервера. И получается я не могу просто запретить на сервере импорт из ‘react-native’. Однако я не хочу вообще запускать его код. Поэтому мы можем не запретить модуль, а мокнуть его. После мока, он может пытаться делать что угодно, и ничего не будет происходить.

export const engine = Engine.create({
  // ...
  server: {
    importer: {
      mock: ['react-native', 'expo-router'],
    },
  },
})

Читайте подробнее в документации про импортер.

Mdx

Mdx позволяет писать как бы маркдаун, но также и использовать React компоненты. Если мы хотим объявить в таком компоненте страницу, то мы делаем это так:

import { Link } from '@/lib/navigation'
import { generalLayout } from '@/layouts/general'

export const page = generalLayout
  .lets('page', 'about', '/about')
  .loader(async () => {
    const lastIdea = await prisma.idea.findFirst({ orderBy: { id: 'desc' } })
    return { lastIdea }
  })
  .head('About')
  .page((props) => (
    <div className="prose">
      {/* Вот здесь лежит сам контент, описанный ниже */}
      <MDXContent {...props} />
    </div>
  ))

IdeaNick — площадка для идей.

Свежая идея: <Link route="idea"
input={{ id: props.data.lastIdea.id }}>{props.data.lastIdea.title}</Link>

То есть это всё та же конструкция поинта (с лоадером, .head(), .page()), только содержимое — это Markdown с возможностью вставлять любые компоненты.

Читайте подробнее в документации про MDX.

Обвязка

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

// engine.ts
// Наш конфиг и одновременно наш серверный хелпер для сервинга поинтов.
// Очень важно сюда не импортировать статически то, что должно пройти через компилятор
export const engine = Engine.create({
  // ...
  ssr: true,
  server: {
    // ...
    // вот этот энтри будет запущен во время вызова point0 dev
    // после билда, мы просто будем делать bun dist/server/index.server.js
    entry: { main: './index.server.ts' },
    // этот файл автоматически генерируется и содержит все наши поинты
    generate: { points: './generated/point0/points.server.ts' },
    // вот сюда мы динамически импортируем эти самые поинты, чтобы до момента
    // реального импорта мы успели включить bun плагин компилятора
    points: async () => await import('./generated/point0/points.server'),
    outdir: '../dist/server',
  },
  client: {
    // ...
    indexHtml: './index.client.html',
    app: async () => await import('./app.client'),
    points: async () => await import('./generated/point0/points.client'),
    generate: {
      points: './generated/point0/points.client.ts',
      routes: {
        outfile: './generated/point0/routes.ts',
        origin: 'process.env.CLIENT_URL',
      },
    },
    outdir: '../dist/client',
    publicdir: {
      source: '../public',
      outdir: '../dist/client',
    },
  },
})
// preload.ts
// нужен чтобы в серверном рантайме подключить бан плагин, который на лету
// будет компилировать наш код, вырезать код, и так далее
// а также установит нужные энв переменные, которые мы настроили в engine.ts
import { engine } from '@/engine'
await engine.preload({ nodeEnvFallback: 'development' })
// index.server.ts
// энтри поинт сервера, нужен чтобы сначала загрузить bun плагин,
// а только потом наш серверный код, который
await import('./preload.js')
await import('./app.server.js')
export {}
// app.server.ts
// здесь может быть любой другой наш серврный код, например инициализация базы данных
// запуск воркеров, валидация энвов, и так далее
import { engine } from '@/engine.js'
await engine.serve()
// bunfig.toml

// noOrphans Позволяет не оставлять подвисших дочерних процессов
// в случае закрытия терминала
[run]
noOrphans = true

// не надо добавлять сюда preload.ts в качестве preload скрипта
// казалось бы идеальное место для этого, но нет.
// Ведь прелоад скрипт грузится любым bun процессом, и если вы однажды
// будете использовать какие-либо другие сторонние cli исполняемые bun
// они будут грузить этот скрипт, а он им не нужен
<!-- index.client.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My App</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="./index.client.tsx"></script>
  </body>
</html>
// index.client.tsx
// этот файл никогда не увидит сервер, поэтому здесь мы можем смело валидировать
// энвы клиента. В остальном похоже на стандартное React приложение.
import App from '@/app.client'
import points from '@/generated/point0/points.client'
import '@/styles/index.css'
import { ErrorBoundary } from '@/ui/error-boundary'
import { mount } from '@point0/react-dom/mount'

// функция mount отвечает за то, чтобы найти полученные из SSR данные, в частности
// Query Client Dehydrated State, и гидратировать приложение поверх него.
mount(
  <ErrorBoundary>
    <App />
  </ErrorBoundary>,
  // Прокидывая сюда поинты, они становятся глобально доступны для приложения
  points,
)
// app.client.tsx
import { Router, RouterRoutes } from '@/lib/navigation'
import { UnheadProvider } from '@point0/core/unhead'
import { QueryClientProvider } from '@tanstack/react-query'
import { NProgress } from '@/components/other/nprogress'
import { Toaster } from '@/components/ui/sonner'
import { ThemeProvider } from '@/components/ui/theme'
import { ErrorPageComponent } from '@/components/other/error'
import { queryClient } from '@/lib/query-client'
import { Head } from '@unhead/react'

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* UnheadProvider это обязательная зависимость, через него работает наш .head() в поинтах.
      Вы также можете использовать стандартные unhead хелперы useHead(), useSeoMeta(), на местах, где вам нужно */}
      <UnheadProvider>
        <Head>
          {/* всё в хеде связанное с ассетами объявляйте здесь, а не в index.client.html
          иначе bun будет ломать их урлы и пытаться сделать своими ассетами, а нам это не нужно для фавыкона
          и ему подобных */}
          <link rel="shortcut icon" href="/favicon.ico" />
        </Head>
        <ThemeProvider />
        <Router>
          <NProgress />
          <Toaster />
          {/* Вся связь между адресами страниц и их компонентами известна роутеру благодаря тому, что
          в index.client.tsx мы прокинули их в mount функцию */}
          <RouterRoutes
            Page404={() => (
              <ErrorPageComponent title="404" description="Page not found" />
            )}
          />
        </Router>
      </UnheadProvider>
    </QueryClientProvider>
  )
}

Читайте подробнее в документации про обвязку.

SSR

Я хотел сделать так, чтобы мы вообще не чувствовали включён ssr, или не включён. Код, который мы пишем, должен выглядеть одинаково. Изначально когда я делал .loader() в странице я думал, что буду возвращать его результат на клиент через сериализацию и вставление результата в index.html. И сначала так и сделал, но потом пришёл к тому, что лучше просто считать что всё это квери, и просто возвращать в index.html результат дегидрации квери клиента. Таким образом по сути хоть страница была запрошена с сервера в формате SSR, там будет дегидрейтед квери клиент, хоть страница изначально была голым index.html, а запросы ушли на сервер уже с клиента, получится то же самое.

Также изначально перед рендером страницы я вычислял какие у неё и её лэйоутов есть лоадеры, и вызывал их код, чтобы засунуть в серверный квери клиент, чтобы страница отрендерилась без состояний лоадинга. Но потом я сделал .with() в который можно засовывать несколько кверей, которые могут ожидать результата друг друга. А потом ещё и подъехали компоненты, которые по сути вообще не известно есть на странице или их там нет.

В итоге решение по SSR принято такое. Я рендерю страницу первый раз. Смотрю на стейт серверного квери клиента, если там есть квери, которые относятся к point0, они в статусе pending, я их раньше в этом рендере не видел, и они enabled, тогда я зная их queryKey, в котором есть всё чтобы понять к какому поинту относится кверя, я просто фетчу этот квери прямо на сервере, превращая статус этого квери либо в успех, либо в эррор, не важно. Затем я рендерю страницу ещё раз. И так по кругу, пока не кончатся неразрезолвленные квери. По факту получается 2–4 ререндера на запрос.

Надо понимать, что SSR фактически происходит только при первом запросе страницы, далее при навигации мы уже не запрашиваем html, мы только скачиваем js чанк самой страницы, плюс запрашиваем нужные данные и вставляем их в квери клиент. И для управления этим у нас и есть все эти опции в .prefetchPageOnNavigate('pageDehydratedStateAndClientQuery').

При политике pageDehydratedStateAndClientQuery мы перед переходом на страницу попросим сервер в уме отрендерить страницу, чтобы собрать зарезолвленный квери клиент, и вернуть нам только сам результат его дегидрации. При получении результата дегидрации на клиенте, мы просто подсунем его в клиентский квери клиент. Также при такой политике будут вызваны все найденные .clientLoader() уже на самой странице. Для нас как для разработчиков, это самый удобный вариант в плане DX. Потому что тогда мы точно знаем, что все нужные квери соберутся. Но мы платим за это серверными ререндерами. Я вообще не вижу в этом ничего ужасного, но понимаю тех, кто хотел бы избежать ререндеров, и для этого у нас тоже есть решение.

Есть политика serverAndClientQuery, тогда мы вообще не будем рендерить ни разу при переходе от одной страницы к другой, а просто смотреть на лоадеры лэйоутов и страницы, и запросим только их. Но таким образом если квери были объявлены в .with(), или в каких-то компонентах внутри страницы, тогда они не будут обнаружены, и после перехода на страницу, подгруженную таким образом, мы увидим состояния лоадинга на таких местах.

При использовании политики serverAndClientQuery, чтобы избежать лоадинга на местах, мы можем в поинте страницы или лэйоута прописать .onPrefetchPage():

export const IdeaBestComponent = root.lets
  .component()
  .loader(async () => {
    const bestIdea = await prisma.idea.findFirst({
      orderBy: {
        rating: 'desc',
      },
    })
  })
  .component(({ data: { bestIdea } }) => (
    <div>
      <h1>{bestIdea.title}</h1>
    </div>
  ))

export const ideaPage = root.lets
  .page('/idea/:id')
  .onPrefetchPage(async ({ location }) => {
    await Promise.all([
      ideaViewQuery.prefetchQuery({ id: location.params.id }),
      IdeaBestComponent.prefetchQuery(),
    ])
  })
  .with(ideaViewQuery, ({ params }) => ({ id: params.id }))
  .page(({ data: { idea } }) => (
    <div>
      <h1>{idea.title}</h1>
    </div>
  ))

То есть если вам важно избегать серверных ререндеров, просто используйте .prefetchPageOnNavigate('serverAndClientQuery') и дописывайте всё что вам нужно в .onPrefetchPage().

А первый заход на страницу по прямой ссылке решается тем же самым .onPrefetchPage() — он и так вызывается на сервере один раз перед первым рендером, так что всё, что вы там прогрели, уже лежит в кэше, и цикл ререндеров схлопывается. А если лоадеры страницы указаны напрямую, а не через .with(query), можно даже не писать прогрев руками — включите prefetchLoadersBeforePageRender, и point0 сам запрефетчит объявленные лоадеры страницы и её лэйоутов. Добавьте allowedRerendersCount: 0, чтобы заодно убрать ререндеры на стабилизацию стора/кук:

export const engine = Engine.create({
  // ...
  ssr: {
    prefetchLoadersBeforePageRender: true,
    allowedRerendersCount: 0,
  },
})

Тогда перед рендером на сервере будут запрефетчены .loader()-квери страницы и её лэйоутов (с инпутами из роута) и вызваны все хуки onPrefetchPage(), так что обычно произойдёт только 1 рендер. prefetchLoadersBeforePageRender трогает только квери, объявленные через .loader() — те, что вставлены через .with(), берут инпуты на этапе рендера и по-прежнему обнаруживаются ререндером, так что их грейте в .onPrefetchPage() сами.

Таким образом надо понимать, что код, объявленный в .onPrefetchPage() может быть вызван как на сервере, так и на клиенте (или закрепите его за одной стороной через .serverOnPrefetchPage() / .clientOnPrefetchPage(), которые вырезают тело из другого бандла). Но нет никакой проблемы, чтобы по-прежнему писать там ideaViewQuery.prefetchQuery({ id: location.params.id }), на клиенте запрос уйдёт по адресу сервера в интернете. А если это будет вызвано на сервере, то тот же самый запрос в обход сети уйдёт напрямую в engine.fetch(request) с сохранением всех хедеров, кукисов и прочего от изначального клиентского запроса.

Но честно, мне нравится, просто многократно ререндерить, очень удобно. Я думаю, если у проекта нет гигантской нагрузки, то вы этого даже не почувствуете. А если нагрузка появится, всегда можно дописать в особо чувствительных местах .onPrefetchPage() и поменять политику на .prefetchPageOnNavigate('serverAndClientQuery'), не изменяя остальные участки кода.

Также у нас есть .on('engineFetchSettled', (event) => console.log(event.data.request.renders)) который подскажет вам, сколько ререндеров случилось. Вы можете настроить метрики, и править прицельно в тех местах, где много ререндеров.

Читайте подробнее в документации про SSR.

SsrStore

А раз уж мы можем делать серверный ререндер, так давай тут же тогда и серверный стор введём. Я сомневался в том, может ли он вообще быть полезен. Но потом я столкнулся с такой ситуацией.

import { create } from 'zustand/react'

const useBreadcrumb = create<{
  items: Array<[string, string]>
  setItems: (items: Array<[string, string]>) => void
}>((set) => ({
  items: [],
  setItems: (items) => set({ items }),
}))

export const adminLayout = root.lets
  .layout('/admin')
  .layout(({ children }) => {
    const items = useBreadcrumb((state) => state.items)
    return (
      <div>
        <div id="header">
          <h1>Admin Panel</h1>
          <div id="breadcrumb">
            {items.map(([label, href]) => (
              <a key={href} href={href}>
                {label}
              </a>
            ))}
          </div>
        </div>
        <div id="content">{children}</div>
      </div>
    )
  })
  .layout()

export const adminUsersPage = adminLayout.lets
  .page('/users')
  .page(({ data: { idea } }) => {
    const setItems = useBreadcrumb((state) => state.setItems)
    useEffect(() => {
      setItems([
        ['Dashboard', '/admin'],
        ['Users', '/users'],
      ])
    }, [setItems])
    return (
      <div>
        <h1>{idea.title}</h1>
      </div>
    )
  })

И так-то всё работает, но после первого рендера страницы, у меня хлебные крошки были пустыми! И подгрузились только после того, как загрузился js, и они были высчитаны. Я понял, что нам необходим SsrStore. Можно подумать, что оно того не стоит, и можно было как-то иначе вопрос решить. Но я думаю, мы ещё найдём хорошие применения для SsrStore, и пусть эти хлебные крошки просто станут поводом создать SsrStore.

import { SsrStore } from '@point0/core/ssr-store'
import { useEffectSsr } from '@point0/core'

export const $breadcrumb = SsrStore.define<Array<[string, string]>>(
  // название элемента в ssr store
  'breadcrumb',
  // функция, возвращающая дефолтное значение
  () => [],
)

export const adminLayout = root.lets
  .layout('/admin')
  .layout(({ children }) => {
    const items = $breadcrumb.use()
    return (
      <div>
        <div id="header">
          <h1>Admin Panel</h1>
          <div id="breadcrumb">
            {items.map(([label, href]) => (
              <a key={href} href={href}>
                {label}
              </a>
            ))}
          </div>
        </div>
        <div id="content">{children}</div>
      </div>
    )
  })
  .layout()

export const adminUsersPage = adminLayout.lets
  .page('/users')
  .page(({ data: { idea } }) => {
    // это обычный эффект, просто во время ssr он будет вызван мгновенно,
    // а на клиенте по правилам обычного useEffect
    useEffectSsr(() => {
      $breadcrumb.set([
        ['Dashboard', '/admin'],
        ['Users', '/users'],
      ])
    }, [])
    return (
      <div>
        <h1>{idea.title}</h1>
      </div>
    )
  })

Тут всё работает так. Рендерим страницу. Если сериализованное значение стора было изменено, ререндерим, и так до тех пор, пока не стабилизируется. То есть не надо засовывать туда new Date(), и то, что принципиально не сериализуется. Затем значение вшивается в index.html. А на клиенте при запросе хлебных крошек, мы уже знаем финальное вычисленное значение, и крошки сразу видны.

Отмечу, что связь здесь односторонняя. Данные уходят с сервера на клиент, но не уходят с клиента на сервер. Используя стор на клиенте, он работает просто как обычный стор. Связь в SsrStore односторонняя нарочно, потому что, для двусторонней связи между клиентом и сервером мы используем CookieStore.

Также отмечу, что SsrStore вовсе не обязательно использовать, если он вам не нужен, он даже не войдёт в клиентский бандл.

Читайте подробнее в документации про SsrStore.

CookieStore

Вообще с куками, мы можем работать и без CookieStore. Давайте сначала покажу, как мы работаем с ними на базовом уровне:

// auth — ваши хелперы для авторизации
// то есть не часть фреймворка, а ваш код
import auth from '@/lib/auth'

export const signInMutation = root.lets
  .mutation()
  .input(z.object({ email: z.string(), password: z.string() }))
  .loader(async ({ input, set }) => {
    const { token, user } = await auth.signIn(input)
    set.cookies('token', token, {
      httpOnly: true,
    })
    return { user }
  })

export const updateProfileMutation = root.lets
  .mutation()
  .input(z.object({ name: z.string() }))
  .loader(async ({ input, request, set }) => {
    const token = request.cookies['token']
    const { user } = await auth.verifyToken(token)
    const updatedUser = await prisma.user.update({
      where: { id: user.id },
      data: { name: input.name },
    })
    return { user: updatedUser }
  })
  .mutation()

И для авторизационных штук, это будто бы даже и хорошо так работать, но получается мы должны помнить под каким ключом мы какую куку записали. Ну и кроме того, я обычно авторизацию делаю через better-auth, а там он сам всем этим управляет. Тем не менее всё это опционально можно было заменить на CookieStore.

const $token = CookieStore.define<string>({ name: 'token', httpOnly: true })

export const root = Point0.lets
  .root()
  // ...
  .plugin(CookieStore.plugin())
  // ...
  .root()

export const signInMutation = root.lets
  .mutation()
  .input(z.object({ email: z.string(), password: z.string() }))
  .loader(async ({ input, set }) => {
    const { token, user } = await auth.signIn(input)
    $token.set(token)
    return { user }
  })

export const updateProfileMutation = root.lets
  .mutation()
  .input(z.object({ name: z.string() }))
  .loader(async ({ input, request, set }) => {
    const token = $token.get()
    const { user } = await auth.verifyToken(token)
    const updatedUser = await prisma.user.update({
      where: { id: user.id },
      data: { name: input.name },
    })
    return { user: updatedUser }
  })
  .mutation()

Тут вы можете заметить, что мы не прокидываем в $token сам request, который вроде как нужен для того, чтобы достать куки. Это происходит потому, что CookieStore может и сам достать request из окружения, потому что request хранится в node async storage, которым мы оборачиваем под капотом весь код перед началом исполнения запроса.

Давайте рассмотрим, как применить CookieStore для кук, которые используются в том числе и на клиенте. Например тёмная или светлая тема.

import { useHead } from '@unhead/react'
import { CookieStore } from '@point0/core/cookie-store'

type ColorMode = 'dark' | 'light'

export const $colorMode = CookieStore.define<ColorMode>('color-mode')

// Используйте как кнопку переключения схемы
export const ThemeSwitcher = () => {
  const colorMode = $colorMode.use()
  return (
    <button
      onClick={() => $colorMode.set(colorMode === 'dark' ? 'light' : 'dark')}
    >
      {colorMode}
    </button>
  )
}

// Воткните это в app.client.tsx
export const ThemeProvider = () => {
  const colorMode = $colorMode.use()
  useHead({
    htmlAttrs: {
      class: {
        dark: colorMode === 'dark',
        light: colorMode === 'light',
      },
    },
  })
  return null
}

Теперь нам с сервера прилетает html сразу с классом dark или light, в зависимости от того, какой цвет режима мы установили в куку. А на клиенте работает как обычный реактивный стор.

В CookieStore ещё настройки есть, и возможность хранить не примитивы, а также применить к ним трансформер по типу superjson. Читайте подробнее в документации.

Как и SsrStore, CookieStore тоже опциональный компонент, и если его не использовать, то он не будет включён в клиентский бандл.

Читайте подробнее в документации про CookieStore.

Testing

Как и любое обычное фулстек приложение мы можем тестировать с помощью playwright. Для этого поднимаем приложение в дев окружении, либо билдим его и запускаем. Далее обычные playwright тесты.

Если вы хотите написать интеграционные тесты, без поднятия сервера для теста эндпоинтов, можно делать так:

// src/test/setup/preload.int.test.ts

import { engine } from '@/engine'
await import('@/preload')

// Эта функция заставляет engine прочитать подгруженные в неё поинты.
// Когда в обычном сервере мы вызываем engine.serve(), эта функция вызывается автоматически.
// Но в интеграционных тестах мы не хотим поднимать сервер, поэтому вызываем эту функцию вручную.
// не путать с engine.preload(), это разные функции
await engine.prepare()

export {}
// bunfig.toml
// на самом деле лучше иметь отдельный ./src/test/setup/preload.ts
// который в зависимости от расширения файла с тестом будет использовать подходящий preload
// но для примера пока пойдёт и так
[test]
preload = ["./src/test/setup/preload.int.test.ts"]
// src/idea/api.ts

describe('ideaViewQuery', () => {
  test('returns one idea by id', async () => {
    const user = await createTestUser()
    const created = await seedIdea({ authorId: user.id, title: 'Viewable' })

    // engine.withFetch это обёртка над node async storage, которая позволяет подменить
    // fetch для поинтов под капотом на engine.fetch
    const result = await engine.withFetch(async () => {
      return await ideaViewQuery.fetchServer({ id: created.id })
    })
    // здесь result верно типизирован, и является тем, что вернул лоадер квери
    expect(result.idea.title).toBe('Viewable')
    expect(result.idea.author.id).toBe(user.id)
  })
})

Читайте подробнее в документации про тестирование.

И фулстекам, и бэкендерам, и фронтендерам.

Несмотря на то, что Point0 это фулстек фреймворк, ничего не мешает использовать его как только фронтенд фреймворк, или как только бэкенд фреймворк.

Фронтендеры могут использовать навигацию, и .with() хелперы для управления состоянием своих страниц и компонентов. Могут написать BFF используя обычные квери/мутации, или использовать клиентские лоадеры для запроса к стороннему серверу.

Если вы бэкендер и вас интересует только апи, тогда вы можете использовать только экшены, с удобной генерацией openapi, типизированные .ctx(), тесты без поднятия сервера.

Читайте подробнее в документации про поинты.

Bun или Vite

Сначала я хотел сделать именно Bun фреймворк. И получалось хорошо. Потом начались проблемы всякого рода, и я подумал, что наверное надо разрешить использовать Vite как опциональную зависимость. Подключил Vite. Опять были проблемы, но в итоге Vite стал работать лучше чем чистый Bun внутри Point0. Потом я собрался с силами и донастроил Bun. И в итоге Bun стал работать лучше, чем Vite внутри Point0. Bun стартует быстрее, HMR лучше работает. Как я боролся с Bun и Vite, это тема для отдельного поста.

В итоге дизайн получился такой, что если вас вдруг не устроит один из бандлеров, вы можете, изменив пару файлов, пересесть на другой, поведение рантайма останется прежним.

Если вам нужно настроить какие-то билд опции для bun, просто допишите их в engine.ts:

import { Engine } from '@point0/engine'
import react from '@vitejs/plugin-react'
import tailwindcss from 'tailwindcss/vite'

export const engine = Engine.create({
  // ...
  bunBuildConfig: ({ side, mode, scope }) => ({
    // стандратный Bun.buildConfig можно переопределить здесь
    // mode - production | development | test в завимости от process.env.NODE_ENV
    // side - server | client в завимости от того, билдим
    // scope - это если у вас несколько клиентов, то здесь будет имя рута клиента/сервера
  }),
  client: {
    // bunBuildConfig: {}
    // можно и здесь переопределить
  },
  server: {
    // bunBuildConfig: {}
    // можно и здесь переопределить
  },
})

Если захотите пересесть с bun на vite, нужно подпрокинуть viteConfig в engine.ts. Это заменит bun на vite и в build, и в dev режиме.

import { Engine } from '@point0/engine'
import react from '@vitejs/plugin-react'
import tailwindcss from 'tailwindcss/vite'

export const engine = Engine.create({
  // ...
  viteConfig: ({ plugins, side, mode }) => ({
    // никаких дополнительных настроек не нужно, они вставляются автоматически
    // исходя из прочих настроек engine, но могут быть переопределены здесь.
    // Это объект обычного вайт конфига.
    plugins: [
      ...plugins, // плагин компилятора Point0 уже здесь
      react(),
      tailwindcss(),
    ],
    // используя side (client|server) можете переопределить какие-то настройки
    // для клиента или сервера
  }),
  client: {
    // viteConfig: {}
    // можно и здесь переопределить
  },
  server: {
    // viteConfig: {}
    // можно и здесь переопределить
  },
})

Читайте подробнее в документации про Bun или Vite.

Deploy

point0 build собирает всё в dist/: dist/server (сервер) и dist/client (клиентский бандл, статика). Затем просто запускаем сервер: bun run ./dist/server/index.server.js он же раздаёт и клиент.

FROM oven/bun:1
WORKDIR /app
COPY . .
RUN bun install && bun run build
CMD ["bun", "run", "./dist/server/index.server.js"]

То есть можно деплоиться абсолютно куда угодно, ничего специфического здесь нет.

Читайте подробнее в документации про деплой.

Size

Размер файлов Point0 внутри клиентского бандла.

  • Сам @point0/core: raw 143.4 KB, gzip 40.9 KB, brotli 36.2 KB

  • Пир зависимость @1gr14/route0: raw 15.0 KB, gzip 4.7 KB, brotli 4.2 KB

  • Пир зависимость @1gr14/error0: raw 3.6 KB, gzip 1.4 KB, brotli 1.3 KB

  • Пир зависимость @tanstack/react-query: raw 38.2 KB, gzip 15.9 KB, brotli 14.2

Examples

В репозитории есть несколько примеров:

  • basic — коллективный блог идей: SSR, Prisma + SQLite, Tailwind, навигация, страницы/лэйауты/квери/мутации/компоненты, страница на MDX, загрузка файла, OpenAPI

  • vite — то же приложение, но клиент собирается через Vite вместо Bun.

  • better-auth — тот же коллективный блог, но с подключённой авторизацией через better-auth

  • capacitor — упаковка веб-приложения в мобильное (iOS/Android) через Capacitor. (экспериментальное)

  • expo — React Native через Expo: один сервер на Bun, общий код квери/мутаций, а клиент — нативный, с роутером Expo. Серверный код выпиливается из бандла через Babel-плагин компилятора. (экспериментальное)

Но чтобы вам далеко не ходить, покажу ещё часть кода прямо здесь, из моего продакшен реди бойлерплейта Start0, чтобы вы могли почувствовать код реального проекта:

// src/features/idea/api.ts

import { paginateCursor } from '@/components/blocks/pagination'
import { AppError } from '@/lib/error'
import { root } from '@/lib/root'
import { zz } from '@/lib/schema'
import { authorizedOnlyPlugin } from '@/modules/auth/plugins'
import { prisma } from '@/modules/prisma'
import { ideaSelect, normalizeIdeaPayload } from '@/features/idea/server'
import { z } from 'zod'

export const ideaListQuery = root.lets
  .infiniteQuery()
  .input(
    z.object({
      ...zz.shape.paginationCursor,
      authorSn: zz.sn.optional(),
    }),
  )
  .loader(async ({ input: { limit = 20, cursor, authorSn } }) => {
    const items = await prisma.idea.findMany({
      select: ideaSelect,
      orderBy: { sn: 'desc' },
      take: limit + 1,
      where: {
        ...(authorSn ? { author: { sn: authorSn } } : {}),
        ...(cursor ? { sn: { lte: cursor } } : {}),
      },
    })
    return paginateCursor({
      items: items.map(normalizeIdeaPayload),
      limit,
      cursorKey: 'sn',
    })
  })
  .infiniteQuery({
    getNextPageParam: (lastPage) => lastPage.pagination.nextCursor,
    initialPageParam: undefined,
    pageParamFromInput: 'cursor',
  })

export const ideaViewQuery = root.lets
  .query()
  .input(zz.object.sn)
  .loader(async ({ input: { sn } }) => {
    const idea = await prisma.idea.findUniqueOrThrow({
      select: ideaSelect,
      where: { sn },
    })
    return { idea: normalizeIdeaPayload(idea) }
  })
  .query()

export const ideaCreateMutationSchema = z.object({
  title: z.string().min(1),
  content: z.string().min(1),
})
export const ideaCreateMutation = root.lets
  .mutation()
  .use(authorizedOnlyPlugin)
  .input(ideaCreateMutationSchema)
  .loader(async ({ ctx, input: { title, content } }) => {
    const idea = await prisma.idea.create({
      select: ideaSelect,
      data: { title, content, authorId: ctx.me.user.id },
    })
    return { idea: normalizeIdeaPayload(idea) }
  })
  .mutation()

export const ideaUpdateMutationSchema = z.object({
  sn: zz.sn,
  title: z.string().min(1),
  content: z.string().min(1),
})
export const ideaUpdateMutation = root.lets
  .mutation()
  .use(authorizedOnlyPlugin)
  .input(ideaUpdateMutationSchema)
  .loader(async ({ ctx, input: { sn, title, content } }) => {
    const existing = await prisma.idea.findUniqueOrThrow({
      select: { authorId: true },
      where: { sn },
    })
    if (existing.authorId !== ctx.me.user.id) {
      throw new AppError('Only the author can edit this idea', {
        code: 'FORBIDDEN',
      })
    }
    const idea = await prisma.idea.update({
      select: ideaSelect,
      where: { sn },
      data: { title, content },
    })
    return { idea: normalizeIdeaPayload(idea) }
  })
  .mutation()
// src/features/idea/pages/list.tsx

import { InfiniteScroll } from '@/components/blocks/infinite-scroll'
import { Section } from '@/components/ui/section'
import { generalLayout } from '@/layouts/general'
import { IdeaCard } from '@/features/idea/components/idea-card'
import { ideaListQuery } from '@/features/idea/api'
import { mePlugin } from '@/modules/auth/plugins'

export const ideaListPage = generalLayout.lets
  .page('/ideas')
  .head('Ideas')
  .use(mePlugin)
  .page(({ props: { me } }) => {
    const query = ideaListQuery.useInfiniteQuery()
    return (
      <Section h1="Ideas">
        <InfiniteScroll
          query={query}
          loadMoreOnReachEnd
          getItemKey={(idea) => idea.sn}
          empty="No ideas yet. Be the first to share one."
          itemClassName="border-b border-border last:border-b-0"
          renderItem={(idea) => <IdeaCard idea={idea} me={me} />}
        />
      </Section>
    )
  })
// src/features/idea/pages/view.tsx

import { Button } from '@/components/ui/button'
import { Prose } from '@/components/ui/prose'
import { Section } from '@/components/ui/section'
import { routes } from '@/generated/point0/routes'
import { Link } from '@/lib/navigation'
import { zz } from '@/lib/schema'
import { formatDate } from '@/utils/date'
import { generalLayout } from '@/layouts/general'
import { ideaViewQuery } from '@/features/idea/api'
import { isMyIdea } from '@/features/idea/shared'
import { mePlugin } from '@/modules/auth/plugins'

export const ideaViewPage = generalLayout.lets
  .page('/ideas/:sn')
  .params(zz.object.sn)
  .use(mePlugin)
  .with(ideaViewQuery, ({ params }) => ({ sn: +params.sn }))
  .head(({ params }) => `Idea #${params.sn}`)
  .page(({ data: { idea }, props: { me } }) => {
    return (
      <Section
        h1={idea.title}
        action={
          isMyIdea(idea, me) ? (
            <Button
              to={routes.ideaEdit({ sn: idea.sn })}
              variant="outline-secondary"
            >
              Edit idea
            </Button>
          ) : undefined
        }
        description={
          <span className="flex flex-wrap items-center gap-2">
            <Link
              to={routes.userProfile({ sn: idea.author.sn })}
              className="hover:text-foreground"
            >
              {idea.author.name}
            </Link>
            <span>·</span>
            <span>{formatDate(idea.createdAt, 'date-time-nice')}</span>
            {idea.updatedAt > idea.createdAt ? (
              <span>· edited {formatDate(idea.updatedAt, 'date-nice')}</span>
            ) : null}
          </span>
        }
      >
        <Prose>
          <p className="whitespace-pre-wrap">{idea.content}</p>
        </Prose>
      </Section>
    )
  })
// src/features/idea/pages/new.tsx

import { Section } from '@/components/ui/section'
import { routes } from '@/generated/point0/routes'
import { navigate } from '@/lib/navigation'
import { generalLayout } from '@/layouts/general'
import { authorizedOnlyPlugin } from '@/modules/auth/plugins'
import { FButton } from '@/modules/form/core/button'
import { FFields, FFooter } from '@/modules/form/core/layout'
import { FForm } from '@/modules/form/core/provider'
import { FInput } from '@/modules/form/fields/input'
import { FTextarea } from '@/modules/form/fields/textarea'
import {
  ideaCreateMutation,
  ideaCreateMutationSchema,
  ideaViewQuery,
  ideaListQuery,
} from '@/features/idea/api'

export const ideaNewPage = generalLayout.lets
  .page('/ideas/new')
  .head('New Idea')
  .use(authorizedOnlyPlugin)
  .page(() => {
    return (
      <Section h1="New Idea">
        <FForm
          schema={ideaCreateMutationSchema}
          defaultValues={{ title: '', content: '' }}
          onSubmit={async ({ title, content }) => {
            const { idea } = await ideaCreateMutation.fetch({ title, content })
            void ideaListQuery.invalidateInfiniteQuery(true)
            ideaViewQuery.setQueryData({ sn: idea.sn }, { idea })
            return { idea }
          }}
          onSuccess={({ idea }) => {
            void navigate('ideaView', { sn: idea.sn }, { replace: true })
          }}
          toastOnSuccess="Idea published"
          size="sm"
        >
          <FFields>
            <FInput
              name="title"
              label="Title"
              placeholder="A short, catchy title"
              inputSize="xl"
            />
            <FTextarea
              name="content"
              label="Content"
              placeholder="Share your idea…"
              rows={10}
            />
          </FFields>
          <FFooter>
            <FButton type="submit" size="2xl">
              Publish
            </FButton>
          </FFooter>
        </FForm>
      </Section>
    )
  })
// src/features/idea/pages/edit.tsx

import { Button } from '@/components/ui/button'
import { Section } from '@/components/ui/section'
import {
  ideaListQuery,
  ideaUpdateMutation,
  ideaUpdateMutationSchema,
  ideaViewQuery,
} from '@/features/idea/api'
import { routes } from '@/generated/point0/routes'
import { generalLayout } from '@/layouts/general'
import { navigate } from '@/lib/navigation'
import { zz } from '@/lib/schema'
import { FButton } from '@/modules/form/core/button'
import { FFields, FFooter } from '@/modules/form/core/layout'
import { FForm } from '@/modules/form/core/provider'
import { FInput } from '@/modules/form/fields/input'
import { FTextarea } from '@/modules/form/fields/textarea'

export const ideaEditPage = generalLayout.lets
  .page('/ideas/:sn/edit')
  .params(zz.object.sn)
  .with(ideaViewQuery, ({ params }) => ({ sn: +params.sn }))
  .head(({ params }) => `Edit Idea #${params.sn}`)
  .page(({ data: { idea } }) => {
    return (
      <Section
        h1="Edit Idea"
        action={
          <Button
            to={routes.ideaView({ sn: idea.sn })}
            variant="outline-secondary"
          >
            View idea
          </Button>
        }
      >
        <FForm
          schema={ideaUpdateMutationSchema.pick({ title: true, content: true })}
          defaultValues={{ title: idea.title, content: idea.content }}
          onSubmit={async ({ title, content }) => {
            const { idea: updated } = await ideaUpdateMutation.fetch({
              sn: idea.sn,
              title,
              content,
            })
            ideaViewQuery.setQueryData({ sn: updated.sn }, { idea: updated })
            void ideaListQuery.invalidateInfiniteQuery(true)
            return { idea: updated }
          }}
          onSuccess={({ idea: updated }) => {
            void navigate('ideaView', { sn: updated.sn }, { replace: true })
          }}
          toastOnSuccess="Idea updated"
          size="sm"
        >
          <FFields>
            <FInput name="title" label="Title" inputSize="xl" />
            <FTextarea name="content" label="Content" rows={10} />
          </FFields>
          <FFooter>
            <FButton type="submit" size="2xl">
              Save
            </FButton>
          </FFooter>
        </FForm>
      </Section>
    )
  })
// src/features/idea/pages/my.tsx

import { InfiniteScroll } from '@/components/blocks/infinite-scroll'
import { Section } from '@/components/ui/section'
import { ideaListQuery } from '@/features/idea/api'
import { IdeaCard } from '@/features/idea/components/idea-card'
import { generalLayout } from '@/layouts/general'
import { authorizedOnlyPlugin } from '@/modules/auth/plugins'

export const myIdeaListPage = generalLayout.lets
  .page('/my/ideas')
  .head('My Ideas')
  .use(authorizedOnlyPlugin)
  .with(ideaListQuery, ({ props: { me } }) => ({ authorSn: me.user.sn }))
  .page(({ queries: [query], props: { me } }) => {
    return (
      <Section h1="My Ideas">
        <InfiniteScroll
          query={query}
          loadMoreOnReachEnd
          getItemKey={(idea) => idea.sn}
          empty="You haven't shared any ideas yet."
          itemClassName="border-b border-border last:border-b-0"
          renderItem={(idea) => <IdeaCard idea={idea} me={me} />}
        />
      </Section>
    )
  })

Читайте подробнее в документации про базовый пример.

Продакшен

На момент выпуска статьи существует только один проект в мире, который использует в продакшене фреймворк Point0. Это мой сайт https://1gr14.dev

Я не собираюсь менять дизайн взаимодействия с фреймворком. Я абсолютно доволен всем, что касается билдера самих поинтов. Я рефакторил это десятки раз, в течение многих месяцев, пока не пришёл к тому, что мы имеем сейчас.

Не исключено, что немного поменяется структура конфига engine, но если это произойдёт, то по сути изменения в ваших проектах потребуются только в файле engine.ts.

Я понятия не имею, когда можно с уверенностью рекомендовать использовать Point0 в продакшене вам. Я просто не знаю, какие здесь критерии. Я сейчас буду активно использовать его для создания собственных проектов, и проектов для моих заказчиков.

Я создал продакшен реди бойлерплейт для SaaS под названием Start0, который построен вокруг Point0. Подключил и настроил для него всё нужное для создания реальных проектов. Стек такой:

  • Bun

  • TypeScript

  • Point0

  • React

  • Tailwind CSS

  • shadcn/ui

  • PostgreSQL

  • Prisma

  • better-auth

  • TanStack Query

  • TanStack Table

  • Zod

  • React Hook Form

  • Resend

  • Sentry

  • Mixpanel

  • Axiom

  • LogTape

  • Playwright

  • Testing Library

  • ESLint

  • Prettier

Подробнее здесь: https://1gr14.dev/start0

По мере создания моих собственных проектов и обратной связи от вас, буду совершенствовать этот бойлерплейт.

Планы

Сейчас фокус на стабильность. Я буду максимально быстро реагировать на любые issue в GitHub.

Собираюсь на постоянной основе писать гайд-статьи на хабре, и записывать видеоролики на YouTube и VK Video. Все анонсы будут в сообществе в телеграм канал и чат (русский язык) и дискорде (английский язык).

Затем я хочу доделать realtime поинты для работы через WebSocket, в таком же стиле как и обычные поинты. Также хочу доделать генерацию статических сайтов.

React Server Components очень не хочу делать, вообще не понимаю какая тут может быть от них польза. Но если они будут, то по сути я просто разрешу из .loader() возвращать react элементы. Но какая в этом польза я пока не понимаю. Давайте это обсудим в другой раз.

P.S.

Благодарю, что ознакомились с устройством фреймворка Point0. Я не показывал этот фреймворк никому в течение всей его разработки, поэтому я понятия не имею как он воспринимается другими разработчиками. Буду признателен вашей поддержке и обратной связи:

  • Напишите в комментариях, что вам приглянулось во фреймворке, и нашли ли в нём что-то уникальное для себя, чтобы я порадовался и понял, что именно во фреймворке нравится вам, и мог доносить до других.

  • Напишите в комментариях, что вам не нравится, что смущает в самом фреймворке. Если это просто недопонимание, объясню как устроено. Если это поправимый косяк, поправлю. Если это ограничение самого дизайна, отмечу для себя, чтобы знать и иметь в виду слабые стороны.

  • Помогите попасть в тренды гитхаба, пожалуйста. Просто поставьте звездочку на репозитории фреймворка: https://github.com/1gr14/point0

  • Помогите зафорсить твиттер (X) тред, пожалуйста. Повзаимодействуйте с тредом, оставляйте лайки, ретвиты, комментарии, подписывайтесь на тред: https://x.com/s_1gr14/status/2072234966319051002

  • Лайкните перекрёстную англоязычную статью на dev.to для привлечения внимания зарубежной аудитории: https://dev.to/1gr14/point0-the-fullstack-typescript-framework-on-bun-and-react-mjm

  • Вступайте в сообщество. В телеграм общаемся на русском текстом: мой канал, наш форум. Вступайте в дискорд, там переписываемся на английском текстом, но есть голосовой канал на русском. В сообществе отвечаю оперативно, также все помогаем друг другу в вопросах разработки.

  • Подпишитесь на меня на хабре, ютубе, вк видео, твиттере, чтобы не пропустить новые гайды по Point0, и прочие интересности разработки.

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