Серверные функции

Файлы проекта *.server.ts выполняются на бэкенде приложения, который хостит le.codes, и никогда на устройстве. Код приложения импортирует функцию из одного из них и вызывает её как любую async-функцию. Компилятор подменяет модуль заглушкой, которая отправляет вызов на бэкенд и возвращает результат как промис. channel несёт сообщения от сервера подписавшимся приложениям. Неудавшийся вызов отклоняется с RpcError.

Это не Net — тот для игры в реальном времени между экземплярами приложения. Для любого другого веб-сервера используй fetch / WebSocket.

Обзор

TypeScript
// dice.server.ts — выполняется на бэкенде приложения. Каждая экспортированная ASYNC-функция — эндпоинт; функция, которая
// не async, — помощник сервера, только для серверных файлов (импорт её из приложения отклоняется).
export async function roll(sides: number, count = 1) {
  if (!Number.isInteger(sides) || sides < 2) throw new ApiError(400, "sides must be a whole number, 2 or more")
  const dice = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * sides))
  return { dice, total: dice.reduce((a, b) => a + b, 0) }
}
TypeScript
// main.ts — приложение
import { roll } from "./dice.server"

const result = UIText("Tap to roll").style({ fontSize: 28, color: "white" })

const rollTwo = async () => {
  try {
    const r = await roll(6, 2)               // вызов бэкенда приложения, типизирован серверной функцией
    result.text = `${r.dice.join(" + ")} = ${r.total}`
  } catch (e) {
    if (!(e instanceof RpcError)) throw e
    result.text = `failed (${e.status}): ${e.message}`
  }
}

UIScreen(
  UIColumn(
    result,
    UIButton(UIText("Roll").style({ fontSize: 22, color: "white" }))
      .style({ bgColor: "#4f7cff", borderRadius: 12, px: 32, py: 14 })
      .named("roll")
      .onClick(rollTwo),
  ).style({ gap: 24, alignItems: "center" }),
).style({ bgColor: "#1e2230", alignItems: "center", justifyContent: "center" }).open()

Вызову нужен бэкенд (см. Где работает). В превью le.codes, в опубликованном приложении, под lecodes dev, в lecodes desktop run и в lecodes render / lecodes test экран показывает кости. У бандла из lecodes compile бэкенда нет: там экран показывает failed (0): ….

Эндпоинты

  • Файл, чьё имя заканчивается на .server.ts (или .server.js), — серверный код. Он никогда не попадает в бандл приложения.
  • Его экспорты сортируются по тому, как они написаны:
    • эндпоинт: export async function f, export const f = async (…) => … или export { f } локальной async-функции;
    • канал: export const c = channel<Message>()… (см. Каналы);
    • только для сервера: всё остальное — константа, класс, хэндл базы данных (см. Экспорты только для сервера).
  • export default в серверном файле — ошибка компиляции: default exports are not endpoints — use named exports.
  • Серверный файл может импортировать общие файлы и другие серверные файлы. *.server.ts никогда не бывает точкой входа.
  • Id эндпоинта — <file>#<export>, например dice.server.ts#roll. Он появляется в сообщениях об ошибках и в request.id.

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

Note

эндпоинт — async. Обычная export function add(a: number, b: number) импортировалась бы с типом number, хотя её вызов — промис, поэтому она не компилируется: an endpoint is called over the network, so in the app its result is a promise — declare it async. (Функция, которая отдаёт промис или запрос как есть — () => db.auth.user() — проходит.)

Что идёт по проводу

  • Аргументы передаются позиционно как JSON.
    • Сервер проверяет их по TypeScript-типам параметров эндпоинта до выполнения функции. Несовпадение — это 400 с именем пути: sides: expected number, got string, v.n: expected number, got string.
    • Необязательные параметры и параметры со значением по умолчанию можно опустить в конце.
    • Лишние аргументы и неизвестные ключи объектов игнорируются.
    • any / unknown принимают что угодно.
  • undefined в конце аргументов — то же, что их опустить: roll(6, undefined) — это roll(6). undefined ПЕРЕД другим аргументом приходит как null, что не проходит параметр, в типе которого нет null (count: expected number, got null) — типизируй такой параметр как number | null.
  • Параметр типа File — это загрузка: приложение передаёт выбранный файл или фото, и вызов несёт его байты — см. Хранимые файлы.
  • Любой другой тип параметра должен быть JSON. Функции, Date, Map / Set, рекурсивные типы и generic-параметры валят сборку le.codes и lecodes dev с endpoint args must be JSON-serializable: …. lecodes compile эту проверку не выполняет.
  • Результат сериализуется как JSON и не валидируется:
    • undefined (эндпоинт void) резолвится в null;
    • Date приходит как его ISO-строка;
    • свойства со значением undefined исчезают.

Ошибки

TypeScript
throw new ApiError(status: number, message = "")     // серверный код: вызов приложения отклоняется с этим статусом + сообщением
err instanceof RpcError                               // код приложения: каждый неудавшийся вызов
err.status: number                                    // HTTP-подобный статус, 0 = запрос так и не дошёл до сервера
err.message: string
status когда message
твой эндпоинт бросил ApiError(status, message) твоё
400 аргумент не прошёл проверку типа <param path>: expected …, got …
500 эндпоинт бросил что-то другое Internal error — настоящая ошибка уходит в лог сервера
504 вызов превысил лимит бэкенда в 30 с Endpoint timed out after 30000 ms
0 у этой сборки нет бэкенда, запрос не удался (нет сети) или использован экспорт только для сервера Endpoint <id>: … / <id> is server-only — …
другой ответ был не от бэкенда (502 прокси, …) Endpoint <id>: unexpected response (HTTP n)

Запрос

TypeScript
// серверный код
export async function whoami() {
  return { endpoint: request.id, ip: request.ip ?? null, agent: request.headers["user-agent"] ?? null }
}
  • request.id — id эндпоинта или id канала внутри authorize / groupBy канала.
  • request.ip — адрес вызывающего.
  • request.headers держит HTTP-заголовки вызова эндпоинта, с именами в нижнем регистре. Внутри хуков канала держит только authorization (сессию), если она есть.
  • Вне вызова (загрузка модуля, таймер, сработавший после ответа) id и ip равны undefined, а headers — {}.

Каналы

Эндпоинт — вызов от приложения к серверу. Канал — другое направление: сервер отправляет сообщение, подписавшиеся приложения его получают. Один экспорт типизирует обе стороны.

TypeScript
// blog.server.ts
export const postsChannel = channel<Post>()                    // всем, кто слушает

export const noticesChannel = channel<Notice>()                // каждому своё
  .groupBy(async () => (await db.auth.requireUser().select({ id: true })).id)

export const chatChannel = channel<Message>()                  // по комнатам
  .groupBy(async (roomId: number) => {
    await requireMember(roomId)
    return roomId
  })

export async function createPost(text: string) {
  const post = await db.post.insert({ text })
  postsChannel.publish(post)
}
// noticesChannel.publish(userId, notice)   chatChannel.publish(roomId, message)
TypeScript
// код приложения
import { postsChannel, noticesChannel, chatChannel } from "./blog.server"

const posts = postsChannel.subscribe((post) => list.prepend(post), { reconnect: reload })
const notices = noticesChannel.subscribe((n) => toast(n.text))      // сервер знает, чьё
const room = chatChannel.subscribe(roomId, (m) => show(m))          // приложение называет комнату
// posts.close(), когда экран уходит
TypeScript
channel<Message>()                         // сервер: экспортируй его из *.server.ts
  .authorize(() => void)                   // кто может слушать
  .groupBy((...args) => string | number)   // в какой группе подписчик
ch.publish(message)                        // сервер: каждому подписчику
ch.publish(group, message)                 // сервер: канал с groupBy — этой группе
ch.subscribe([...args,] handler, { reconnect?, error? }): ChannelSubscription   // приложение: { close() }
  • Называй канал somethingChannel: в приложении он стоит рядом с данными, которые несёт.
  • Канал работает только сервер → приложение. Приложение отвечает через эндпоинты (createPost выше); publish в коде приложения — ошибка компиляции.
  • Канал несёт один тип сообщения. Два вида новостей — два канала (или один тип сообщения, который является union).
  • authorize(fn) выполняется на сервере для каждой подписки, с request и db.auth, как в эндпоинте. Бросай исключение (ApiError), чтобы отказать.
  • groupBy(fn) выполняется так же и отвечает группой — строкой или числом, — в которой находится подписчик; publish(group, message) доходит до подписчиков этой группы. Что приложение передаёт в subscribe перед обработчиком, то и получает fn.
    • Эти аргументы приходят от приложения: они проверяются по типам параметров, как у эндпоинта (400 при несовпадении), так что каждому параметру нужен тип. Проверяй, что вызывающий может быть в группе, которую просит, и бросай исключение, если нет.
    • Для «каждому своё» ничего не бери от приложения и отвечай id вызывающего — никто не сможет попросить чужое.
    • Значение группы сообщается её подписчику. Не клади в него секрет.
  • Подписка живёт до close(). Держи то, что отвечает subscribe, как ChannelSubscription.
  • Каждый раз, когда меняется сессия (вход, выход), сервер опрашивается заново по каждой подписке, и хуки выполняются для нового пользователя: noticesChannel выше переключается на новый аккаунт, получает отказ после выхода и сам возвращается после следующего входа.
  • Отказ уходит в обработчик error: RpcError с брошенным статусом. Он может прийти не один раз (по разу на каждую сессию, которой отказано). Без обработчика error он пишется в консоль. Пока подписке отказано, ничего не доставляется.
  • publish доставляет не более одного раза, приложениям, подписанным в этот момент. Ничего не хранится. Неэкспортированный канал не может публиковать (бросает исключение).
  • Один WebSocket на бэкенд несёт все каналы приложения. Он открывается с первой подпиской и закрывается через несколько секунд после последнего close(). Когда он падает, приложение переподключается с back-off (1, 2, 5, 10, затем каждые 30 с), подписывается заново и вызывает каждый reconnect. Сообщения, отправленные пока он лежал, теряются — перечитай состояние в reconnect.
  • Сокет, который не может подключиться, продолжает попытки и не вызывает error.
  • В сборке без бэкенда subscribe сразу бросает RpcError (статус 0).

Экспорты только для сервера

Экспорт, который не эндпоинт и не канал, остаётся на сервере. В приложении он становится заглушкой, и любое его использование бросает RpcError со статусом 0 (dice.server.ts#PAGE is server-only — it can't be used in client code): чтение свойства, вызов или преобразование в строку или число. await на нём не бросает исключение. Константы, нужные приложению, держи в обычном файле, который импортируют обе стороны.

Серверный код

  • У серверных файлов свои глобальные имена:
    • ApiError, channel и request, описанные на этой странице;
    • defineDb, model, t, ref — база данных. Вход — не глобальное имя: это db.auth базы, созданной через defineDb({...}).withAuth({ model: "User" }) — см. Вход.
  • SDK приложения там нет. Vec3, date, UIText, toast, Net и остальные — глобальные имена приложения. Типы редактора знают оба набора в каждом файле, так что использованное в серверном файле имя проходит проверку типов — а сборка его отклоняет: blog.server.ts:25: toast is a global of the app — it does not exist in server code.
  • Работает и в обратную сторону: серверное глобальное имя в коде приложения (new ApiError(…), model({…})) валит сборку так же. Приложение ловит ошибку сервера как RpcError.
  • Имя, которое твой файл объявляет сам (const t = …, параметр request), — твоё и никогда не отклоняется. Типы тоже: function f(at: Vec3) в серверном файле собирается.
  • Серверный код выполняется на Bun, так что стандартные глобальные объекты JavaScript на месте (Date, JSON, crypto, таймеры, fetch).
    • Этот fetch — стандартный: чтение тела возвращает промисы, так что пиши await res.json().
    • Типы проекта описывают fetch SDK приложения, у которого res.json() синхронный, так что пропущенный await не ловится.
  • Переменные уровня модуля — не хранилище. Сервер каждого проекта работает в воркере, который заменяется при каждом деплое, останавливается после 15 минут простоя и перезапускается, когда вызов превышает таймаут. Держи данные в базе.
  • Вывод console.* и неожиданные ошибки (со стеком) уходят в лог сервера. Приложение видит только Internal error.

Где работает

  • На le.codes. Сборка на le.codes запекает URL бэкенда в заглушки и деплоит серверные файлы вместе с ней:
    • превью редактора работает против черновика сервера;
    • публикация (и lecodes push --publish тоже) работает против продакшен-сервера;
    • два сервера раздельны, как и их базы данных.
  • Проект, в базе которого есть вход (withAuth), получает ещё и сессию устройства. Транспорт хранит её токен для каждого бэкенда и отправляет с каждым вызовом и каналом, а код приложения его никогда не трогает.
  • Под lecodes dev и lecodes web dev, на твоей машине. Dev-сервер сам выполняет серверные файлы, и приложение, которое он раздаёт, их вызывает — на телефоне, в десктопном хосте и в браузере одинаково:
    • база данных — папка проекта, .lecodes/db; она сохраняется между запусками. Удали папку, чтобы начать с пустой;
    • сохранение серверного файла перезапускает сервер с новым кодом до перезагрузки приложения. Данные остаются. Изменение моделей, которое база не может принять на месте, запускает пустую базу и переносит старую в .lecodes/db-old;
    • вывод console.* и ошибки серверного кода печатаются в терминале как [server:log] …;
    • код входа по email печатается в терминале вместо отправки письма, а 000000 впускает любой email без отправки кода;
    • первый запуск проекта с моделями предлагает установить движок базы данных (marcidb-embedded), один раз;
    • серверный код выполняется на том рантайме, на котором работает CLI (Node для установки из npm), а не на Bun: держись стандартных глобальных объектов.
  • В lecodes desktop run, render и test, тоже на твоей машине — headless и на каждом хосте (--desktop, --sim, --lite, --web). CLI выполняет серверные файлы, пока работает команда:
    • desktop run и render используют ту же базу, что и lecodes dev (.lecodes/db). Её держит один процесс: пока работает lecodes dev, desktop run и render --desktop падают с сообщением, что база занята, а headless render предупреждает и рендерит без бэкенда;
    • test использует собственную базу, .lecodes/test-db, очищаемую перед каждым кейсом — тест никогда не видит и не меняет данные lecodes dev;
    • шаг флоу ждёт вызовы, которые он запустил (не более 5 с): после { "tap": "send" } ответ уже получен, wait не нужен. Сообщение из канала приходит само — дай ему короткий wait.
  • У остальных команд CLI бэкенда нет (compile, web build). Они собирают приложение без URL бэкенда:
    • заглушки всё равно генерируются, и приложение всё равно собирается;
    • каждый вызов эндпоинта отклоняется с RpcError статус 0: Endpoint dice.server.ts#roll: the project has no deployed server (compile with serverUrl);
    • subscribe бросает ту же ошибку.

Вывод компилятора — __rpc, __channel, __serverOnly

TypeScript
// что компилятор кладёт в бандл приложения вместо dice.server.ts
export const roll = __rpc("<backend url>", "dice.server.ts#roll")

__rpc(url, id), __channel(url, id) и __serverOnly(id) строят описанные выше заглушки. Компилятор выдаёт по одной на каждый экспорт серверного файла. Это вывод компилятора — никогда не вызывай их вручную. Вместо этого импортируй серверную функцию.

Подводные камни

TypeScript
export function add(a: number, b: number) { … }          // ✗ ошибка компиляции: эндпоинт — async
export async function add(a: number, b: number) { … }    // ✓

const n = add(1, 2)                 // ✗ промис
const n = await add(1, 2)           // ✓

postsChannel.publish(post)          // ✗ в коде приложения — ошибка компиляции: публикует сервер
await createPost(text)              // ✓ эндпоинт, который публикует

Смотрите также