Серверные функции
Файлы проекта *.server.ts выполняются на бэкенде приложения, который хостит le.codes, и никогда
на устройстве. Код приложения импортирует функцию из одного из них и вызывает её как любую
async-функцию. Компилятор подменяет модуль заглушкой, которая отправляет вызов на бэкенд и
возвращает результат как промис. channel несёт сообщения от сервера подписавшимся приложениям.
Неудавшийся вызов отклоняется с RpcError.
Это не Net — тот для игры в реальном времени между экземплярами
приложения. Для любого другого веб-сервера используй fetch / WebSocket.
Обзор
// 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) }
}// 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.
В коде приложения импортированный эндпоинт сохраняет типы параметров серверной функции, и каждый вызов возвращает промис.
эндпоинт — 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принимают что угодно.
- Сервер проверяет их по TypeScript-типам параметров эндпоинта до выполнения функции.
Несовпадение — это 400 с именем пути:
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исчезают.
Ошибки
throw new ApiError(status: number, message = "") // серверный код: вызов приложения отклоняется с этим статусом + сообщением
err instanceof RpcError // код приложения: каждый неудавшийся вызов
err.status: number // HTTP-подобный статус, 0 = запрос так и не дошёл до сервера
err.message: stringstatus |
когда | 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) |
Запрос
// серверный код
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—{}.
Каналы
Эндпоинт — вызов от приложения к серверу. Канал — другое направление: сервер отправляет сообщение, подписавшиеся приложения его получают. Один экспорт типизирует обе стороны.
// 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)// код приложения
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(), когда экран уходит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(). - Типы проекта описывают
fetchSDK приложения, у которого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падают с сообщением, что база занята, а headlessrenderпредупреждает и рендерит без бэкенда;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
// что компилятор кладёт в бандл приложения вместо dice.server.ts
export const roll = __rpc("<backend url>", "dice.server.ts#roll")__rpc(url, id), __channel(url, id) и __serverOnly(id) строят описанные выше заглушки.
Компилятор выдаёт по одной на каждый экспорт серверного файла. Это вывод компилятора — никогда
не вызывай их вручную. Вместо этого импортируй серверную функцию.
Подводные камни
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) // ✓ эндпоинт, который публикуетСмотрите также
- База данных — модели и запросы в серверном коде
- Вход — аккаунты,
db.auth - Хранимые файлы — параметры
Fileи поляt.file() - Мультиплеер — сессии в реальном времени между экземплярами приложения
- Сеть —
fetchиWebSocketк другим веб-серверам - Хранилище —
localStorageна устройстве