Перейти к содержимому

Удалённые функции

Доступно с версии 2.27

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

В сочетании с экспериментальной поддержкой Svelte для await это позволяет загружать и обрабатывать данные непосредственно внутри ваших компонентов.

Эта функция в настоящее время экспериментальная, то есть она, скорее всего, содержит ошибки и может измениться без предварительного уведомления. Чтобы включить её, необходимо добавить параметры compilerOptions.experimental.async и kit.experimental.remoteFunctions в ваш файл svelte.config.js:

svelte.config.js
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
experimental: {
remoteFunctions: true
}
},
compilerOptions: {
experimental: {
async: true
}
}
};
export default config;

Удалённые функции экспортируются из файлов .remote.js или .remote.ts и бывают четырёх типов: query, form, command и prerender. На клиенте экспортированные функции преобразуются в обёртки fetch, которые вызывают свои серверные аналоги через сгенерированную HTTP-точку доступа. Удалённые файлы должны располагаться в директориях lib или routes.

Функция query позволяет получать динамические данные с сервера (для статических данных рекомендуется использовать prerender):

src/routes/blog/data.remote.js
import { query } from '$app/server';
import * as db from '#lib/server/database';
export const getPosts = query(async () => {
const posts = await db.sql`
SELECT title, slug
FROM post
ORDER BY published_at
DESC
`;
return posts;
});

Запрос, возвращаемый из getPosts, работает как Promise, который разрешается в posts:

src/routes/blog/+page.svelte
<script>
import { getPosts } from './data.remote';
</script>
<h1>Недавние сообщения</h1>
<ul>
{#each await getPosts() as { title, slug }}
<li><a href="/blog/{slug}">{title}</a></li>
{/each}
</ul>

До завершения промиса (или в случае ошибки) будет вызван ближайший <svelte:boundary>.

Хотя рекомендуется использовать await, в качестве альтернативы запрос также имеет свойства loading, error и current:

src/routes/blog/+page.svelte
<script>
import { getPosts } from './data.remote';
const query = getPosts();
</script>
{#if query.error}
<p>упс!</p>
{:else if query.loading}
<p>загрузка...</p>
{:else}
<ul>
{#each query.current as { title, slug }}
<li><a href="/blog/{slug}">{title}</a></li>
{/each}
</ul>
{/if}

Функции query могут принимать аргументы, например slug отдельной публикации:

src/routes/blog/+page.svelte
<script>
import { getPost } from '../data.remote';
let { params } = $props();
const post = $derived(await getPost(params.slug));
</script>
<h1>{post.title}</h1>
<div>{@html post.content}</div>

Поскольку getPost предоставляет HTTP-эндпойнт, важно проверять этот аргумент, чтобы убедиться в его корректном типе. Для этого можно использовать любую библиотеку валидации из Standard Schema, такую как Zod или Valibot:

src/routes/blog/data.remote.js
import * as v from 'valibot';
import { error } from '@sveltejs/kit';
import { query } from '$app/server';
import * as db from '#lib/server/database';
export const getPosts = query(async () => { /* ... */ });
export const getPost = query(v.string(), async (slug) => {
const [post] = await db.sql`
SELECT * FROM post
WHERE slug = ${slug}
`;
if (!post) error(404, 'Не найдено');
return post;
});

Как аргументы, так и возвращаемые значения сериализуются с помощью devalue, который поддерживает типы вроде Date и Map (а также пользовательские типы, определённые в вашем хуке transport) в дополнение к JSON.

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

Вы можете await запрос в любом контексте — компонентах, обработчиках событий, универсальных функциях load, асинхронных колбэках — и SvelteKit выполнит дедупликацию с другими потребителями, использующими тот же запрос. Например:

<script>
import { getData } from './data.remote.js';
// ожидается (`await`) внутри шаблона компонента — заполняет кэш
const data = getData();
</script>
<p>{await data}</p>
<!-- это дедуплицируется с использованием на уровне компонента выше; без дополнительного запроса -->
<button onclick={async () => console.log(await getData())}>
нажми меня!
</button>

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

Любой запрос можно обновить с помощью метода refresh:

<button onclick={() => getPosts().refresh()}>
Проверка новых постов
</button>

query.batch работает аналогично query, за исключением того, что он группирует запросы, происходящие в рамках одной макрозадачи. Это решает так называемую проблему n+1: вместо того чтобы каждый запрос приводил к отдельному вызову базы данных (например), одновременные запросы объединяются.

На сервере callback получает массив аргументов, с которыми была вызвана функция. Он должен возвращать функцию вида (input: Input, index: number) => Output. Затем SvelteKit вызывает эту функцию с каждым из входных аргументов, чтобы разрешить отдельные вызовы с их результатами.

weather.remote.js
import * as v from 'valibot';
import { query } from '$app/server';
import * as db from '#lib/server/database';
export const getWeather = query.batch(v.string(), async (cityIds) => {
const weather = await db.sql`
SELECT * FROM weather
WHERE city_id = ANY(${cityIds})
`;
const lookup = new Map(weather.map(w => [w.city_id, w]));
return (cityId) => lookup.get(cityId);
});
Weather.svelte
<script>
import CityWeather from './CityWeather.svelte';
import { getWeather } from './weather.remote.js';
let { cities } = $props();
let limit = $state(5);
</script>
<h2>Погода</h2>
{#each cities.slice(0, limit) as city}
<h3>{city.name}</h3>
<CityWeather weather={await getWeather(city.id)} />
{/each}
{#if cities.length > limit}
<button onclick={() => limit += 5}>
Загрузить ещё
</button>
{/if}

query.live предназначен для доступа к данным в реальном времени с сервера. Он ведёт себя аналогично query, но колбэк — обычно асинхронная функция-генератор — возвращает AsyncIterable:

import { query } from '$app/server';
export const getTime = query.live(async function* () {
while (true) {
yield new Date();
await new Promise((f) => setTimeout(f, 1000));
}
});

Во время серверного рендеринга await getTime() возвращает первое выданное значение, а затем закрывает итератор. Это начальное значение сериализуется и повторно используется во время гидратации.

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

Live-запросы предоставляют свойство connected и метод reconnect():

<script>
import { getTime } from './time.remote.js';
const time = getTime();
</script>
<p>{await time}</p>
<p>подключено: {time.connected}</p>
<button onclick={() => time.reconnect()}>Переподключиться</button>

Если соединение обрывается, connected становится false. SvelteKit попытается переподключиться пассивно (с экспоненциальной задержкой), а также активно, если navigator.onLine перейдёт из false в true.

В отличие от query, у live-запросов нет метода refresh(), поскольку они обновляются автоматически.

Если вам нужен прямой, императивный доступ к потоку значений (вместо реактивного свойства current), экземпляры live-запросов сами по себе являются async-iterable. Вы можете использовать for await напрямую над экземпляром:

async function logTimes() {
for await (const value of getTime()) {
console.log(value);
if (someCondition) break;
}
}

Несколько потребителей одного и того же live-запроса (будь то реактивно — через await или current — или императивно через циклы for await) разделяют одно базовое соединение. Первое значение, которое отдаёт итератор for await, — это самое последнее полученное значение (если оно уже доступно), что соответствует семантике прямого await ресурса. Последующие значения выдаются каждый раз, когда с сервера приходит новое. Если значения приходят быстрее, чем потребитель успевает их забирать, сохраняется только самое последнее ожидающее значение — live-потоки не являются журналами событий.

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

Функция form упрощает запись данных на сервер. Она принимает колбэк, который получает текущий FormData

src/routes/blog/data.remote.js
import * as v from 'valibot';
import { error, redirect } from '@sveltejs/kit';
import { query, form } from '$app/server';
import * as db from '#lib/server/database';
import * as auth from '#lib/server/auth';
export const getPosts = query(async () => { /* ... */ });
export const getPost = query(v.string(), async (slug) => { /* ... */ });
export const createPost = form(async (data) => {
// Проверяем, что пользователь авторизован
const user = await auth.getUser();
if (!user) error(401, 'Не авторизован');
const title = data.get('title');
const content = data.get('content');
// Проверяем валидность данных
if (typeof title !== 'string' || typeof content !== 'string') {
error(400, 'Название и содержимое обязательны');
}
const slug = title.toLowerCase().replace(/ /g, '-');
// Добавляем запись в базу данных
await db.sql`
INSERT INTO post (slug, title, content)
VALUES (${slug}, ${title}, ${content})
`;
// Перенаправляем на новую страницу
redirect(303, `/blog/${slug}`);
});

…и возвращает объект, который можно использовать через spread-оператор (...) в элементе <form>. Колбэк вызывается при каждой отправке формы.

src/routes/blog/new/+page.svelte
<script>
import { createPost } from '../data.remote';
</script>
<h1>Создание новой записи</h1>
<form {...createPost}>
<label>
<h2>Заголовок</h2>
<input name="title" />
</label>
<label>
<h2>Текст записи</h2>
<textarea name="content"></textarea>
</label>
<button>Опубликовать!</button>
</form>

Объект формы содержит свойства method и action, позволяющие ей работать без JavaScript (то есть отправлять данные с перезагрузкой страницы). Также у неё есть обработчик onsubmit, который прогрессивно улучшает форму при наличии JavaScript, отправляя данные без перезагрузки всей страницы.

Как и в случае с query, если колбэк использует отправленные данные (data), они должны быть валидированы путём передачи Standard Schema в качестве первого аргумента в form.

Форма состоит из набора полей, которые определяются схемой. В случае с createPost у нас есть два поля, title и content, оба из которых являются строками. Чтобы получить атрибуты поля, вызовите его метод .as(...), указав, какой тип ввода использовать. Для большинства типов ввода вы также можете передать второй аргумент — .as(type, value) — чтобы управлять отображаемым значением:

<form {...createPost}>
<label>
<h2>Заголовок</h2>
<input {...createPost.fields.title.as('text')} />
</label>
<label>
<h2>Текст записи</h2>
<textarea {...createPost.fields.content.as('text')}></textarea>
</label>
<button>Опубликовать!</button>
</form>

Эти атрибуты позволяют SvelteKit устанавливать правильный тип ввода, задавать name, который используется для формирования данных (data), передаваемых в обработчик, заполнять значение (value) формы (например, после неудачной отправки, чтобы избавить пользователя от необходимости вводить всё заново) и устанавливать состояние aria-invalid.

Передача второго аргумента в .as(...) полезна при рендеринге формы на основе существующих данных, таких как форма редактирования или несколько экземпляров, созданных с помощью for(...). Полям ввода radio, submit и hidden это значение требуется всегда, а полям checkbox оно необходимо, когда они представляют один из вариантов в поле-массиве. Поля типа file не могут быть заполнены таким способом.

Поля могут быть вложены в объекты и массивы, а их значениями могут быть строки, числа, логические значения или объекты File. Например, если бы ваша схема выглядела так…

data.remote.js
const datingProfile = v.object({
name: v.string(),
photo: v.file(),
info: v.object({
height: v.number(),
likesDogs: v.optional(v.boolean(), false)
}),
attributes: v.array(v.string())
});
export const createProfile = form(datingProfile, (data) => { /* ... */ });

…ваша форма могла бы выглядеть так:

<script>
import { createProfile } from './data.remote';
const { name, photo, info, attributes } = createProfile.fields;
</script>
<form {...createProfile} enctype="multipart/form-data">
<label>
<input {...name.as('text')} /> Имя
</label>
<label>
<input {...photo.as('file')} /> Фото
</label>
<label>
<input {...info.height.as('number')} /> Рост (см)
</label>
<label>
<input {...info.likesDogs.as('checkbox')} /> Я люблю собак
</label>
<h2>Мои лучшие качества</h2>
<input {...attributes[0].as('text')} />
<input {...attributes[1].as('text')} />
<input {...attributes[2].as('text')} />
<button>Отправить</button>
</form>

Поскольку наша форма содержит поле ввода file, мы добавили атрибут enctype="multipart/form-data". Значения для info.height и info.likesDogs автоматически приводятся к числу и логическому значению соответственно.

В случае с полями ввода radio и checkbox, относящимися к одному и тому же полю, значение value должно быть указано в качестве второго аргумента в .as(...):

data.remote.js
export const operatingSystems = /** @type {const} */ (['windows', 'mac', 'linux']);
export const languages = /** @type {const} */ (['html', 'css', 'js']);
export const survey = form(
v.object({
operatingSystem: v.picklist(operatingSystems),
languages: v.optional(v.array(v.picklist(languages)), []),
}),
(data) => { /* ... */ },
);
<form {...survey}>
<h2>Какую ОС вы используете?</h2>
{#each operatingSystems as os}
<label>
<input {...survey.fields.operatingSystem.as('radio', os)}>
{os}
</label>
{/each}
<h2>На каких языках вы программируете?</h2>
{#each languages as language}
<label>
<input {...survey.fields.languages.as('checkbox', language)}>
{language}
</label>
{/each}
<button>Отправить</button>
</form>

В качестве альтернативы вы можете использовать select и select multiple:

<form {...survey}>
<h2>Какую ОС вы используете?</h2>
<select {...survey.fields.operatingSystem.as('select')}>
{#each operatingSystems as os}
<option>{os}</option>
{/each}
</select>
<h2>На каких языках вы программируете?</h2>
<select {...survey.fields.languages.as('select multiple')}>
{#each languages as language}
<option>{language}</option>
{/each}
</select>
<button>Отправить</button>
</form>

В дополнение к декларативной валидации по схеме, вы можете программно помечать поля как невалидные внутри обработчика формы, используя хелпер invalid из @sveltejs/kit. Это полезно в тех случаях, когда вы не можете знать, валидны ли данные, пока не попытаетесь выполнить какое-либо действие.

  • Он выбрасывает исключение (throw) так же, как redirect или error
  • Он принимает несколько аргументов, которые могут быть строками (для проблем, относящихся к форме в целом — они будут отображаться только в fields.allIssues()) или ошибками, соответствующими Standard Schema (для тех, что относятся к конкретному полю). Используйте параметр issue для типобезопасного создания таких ошибок:
src/routes/shop/data.remote.js
import * as v from 'valibot';
import { invalid } from '@sveltejs/kit';
import { form } from '$app/server';
import * as db from '#lib/server/database';
export const buyHotcakes = form(
v.object({
qty: v.pipe(
v.number(),
v.minValue(1, 'вы должны купить хотя бы один пирожок')
)
}),
async (data, issue) => {
try {
await db.buy(data.qty);
} catch (e) {
if (e.code === 'OUT_OF_STOCK') {
invalid(
issue.qty(`у нас не хватает пирожков`)
);
}
}
}
);

Если отправленные данные не проходят проверку по схеме, колбэк не будет выполнен. Вместо этого метод issues() каждого невалидного поля вернет массив объектов { message: string }, а атрибут aria-invalid (возвращаемый из as(...)) будет установлен в true:

<form {...createPost}>
<label>
<h2>Заголовок</h2>
{#each createPost.fields.title.issues() as issue}
<p class="issue">{issue.message}</p>
{/each}
<input {...createPost.fields.title.as('text')} />
</label>
<label>
<h2>Текст записи</h2>
{#each createPost.fields.content.issues() as issue}
<p class="issue">{issue.message}</p>
{/each}
<textarea {...createPost.fields.content.as('text')}></textarea>
</label>
<button>Опубликовать!</button>
</form>

Если title корректен или ещё не был проверен, createPost.fields.title.issues() вернёт undefined.

Вам не нужно ждать отправки формы, чтобы провалидировать данные — вы можете вызывать validate() программно, например, в обработчике oninput (который будет проверять данные при каждом нажатии клавиши) или в обработчике onchange:

<form {...createPost} oninput={() => createPost.validate()}>
<!-- -->
</form>

По умолчанию ошибки будут игнорироваться, если они относятся к элементам управления формы, с которыми еще не взаимодействовали. Чтобы провалидировать все поля ввода, вызовите validate({ includeUntouched: true }).

Для валидации на стороне клиента вы можете указать preflight-схему, которая заполнит issues() и предотвратит отправку данных на сервер, если данные не прошли валидацию:

<script>
import * as v from 'valibot';
import { createPost } from '../data.remote';
const schema = v.object({
title: v.pipe(v.string(), v.nonEmpty()),
content: v.pipe(v.string(), v.nonEmpty())
});
</script>
<h1>Создание новой записи</h1>
<form {...+++createPost.preflight(schema)+++}>
<!-- -->
</form>

Чтобы получить список всех ошибок, а не только тех, которые относятся к одному конкретному полю, вы можете использовать метод fields.allIssues():

{#each createPost.fields.allIssues() as issue}
<p>{issue.message}</p>
{/each}

Как и в случае с отдельными полями, createPost.fields.allIssues() вернёт undefined, если форма в целом корректна (или ещё не была проверена).

У каждого поля есть метод value(), который отражает его текущее значение. По мере того как пользователь взаимодействует с формой, оно обновляется автоматически:

<form {...createPost}>
<!-- -->
</form>
<div class="preview">
<h2>{createPost.fields.title.value()}</h2>
<div>{@html render(createPost.fields.content.value())}</div>
</div>

В качестве альтернативы, createPost.fields.value() вернет объект { title, content }.

Вы можете обновить поле (или набор полей) с помощью метода set(...):

<script>
import { createPost } from '../data.remote';
// это...
createPost.fields.set({
title: 'Моя новая запись в блоге',
content: 'Lorem ipsum dolor sit amet...'
});
// ...эквивалентно этому:
createPost.fields.title.set('Моя новая запись в блоге');
createPost.fields.content.set('Lorem ipsum dolor sit amet');
</script>

В случае отправки формы без прогрессивного улучшения (то есть когда JavaScript по какой-либо причине недоступен) метод value() также заполняется, если отправленные данные оказались невалидными. Это сделано для того, чтобы пользователю не пришлось заполнять всю форму заново с нуля.

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

<form {...register}>
<label>
Имя пользователя
<input {...register.fields.username.as('text')} />
</label>
<label>
Пароль
<input {...register.fields._password.as('password')} />
</label>
<button>Зарегистрироваться!</button>
</form>

В данном примере, если данные не проходят валидацию, при перезагрузке страницы будет заполнено только первое поле <input>.

В примере выше используется redirect(...), который перенаправляет пользователя на новую страницу. Альтернативно, колбэк может возвращать данные, которые станут доступны как createPost.result:

src/routes/blog/data.remote.js
export const createPost = form(async (data) => {
// ...
return { success: true };
});
src/routes/blog/new/+page.svelte
<script>
import { createPost } from '../data.remote';
</script>
<h1>Создание новой записи</h1>
<form {...createPost}><!-- ... --></form>
{#if createPost.result?.success}
<p>Запись успешно опубликована!</p>
{/if}

Это значение временное — оно исчезнет при повторной отправке формы, переходе на другую страницу или обновлении страницы.

Если во время отправки произойдёт ошибка, будет отображена ближайшая страница +error.svelte.

Мы можем настроить поведение при отправке формы с помощью метода enhance:

src/routes/blog/new/+page.svelte
<script>
import { createPost } from '../data.remote';
import { showToast } from '#lib/toast';
</script>
<h1>Создание новой записи</h1>
<form {...createPost.enhance(async ({ form, data, submit }) => {
try {
await submit();
form.reset();
showToast('Запись успешно опубликована!');
} catch (error) {
showToast('Ошибка! Что-то пошло не так');
}
})}>
<input name="title" />
<textarea name="content"></textarea>
<button>Опубликовать</button>
</form>

Колбэк получает элемент form, содержащиеся в нём data и функцию submit.

Для реализации однопроходных мутаций на клиенте используйте submit().updates(...). Например, если на этой странице используется запрос getPosts(), мы можем обновить его следующим образом:

await submit().updates(getPosts());

Мы также можем переопределить текущие данные во время выполнения отправки формы:

await submit().updates(
getPosts().withOverride((posts) => [newPost, ...posts])
);

Переопределённые данные применяются немедленно и сбрасываются после завершения (или ошибки) отправки формы.

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

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

src/routes/todos/+page.svelte
<script>
import { getTodos, modifyTodo } from '../data.remote';
</script>
<h1>Список дел</h1>
{#each await getTodos() as todo}
{@const modify = modifyTodo.for(todo.id)}
<form {...modify}>
<input {...modify.fields.description.as('text', todo.description)} />
<button disabled={!!modify.pending}>Сохранить изменения</button>
</form>
{/each}

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

Чтобы это реализовать, добавьте в свою схему поле для значения кнопки и используйте as('submit', value) для его привязки:

src/routes/login/+page.svelte
<script>
import { loginOrRegister } from '#lib/auth';
</script>
<form {...loginOrRegister}>
<label>
Ваш логин
<input {...loginOrRegister.fields.username.as('text')} />
</label>
<label>
Ваш пароль
<input {...loginOrRegister.fields._password.as('password')} />
</label>
<button {...loginOrRegister.fields.action.as('submit', 'login')}>Войти</button>
<button {...loginOrRegister.fields.action.as('submit', 'register')}>Зарегистрироваться</button>
</form>

В обработчике формы вы можете проверить, какая именно кнопка была нажата:

#lib/auth.js
import * as v from 'valibot';
import { form } from '$app/server';
export const loginOrRegister = form(
v.object({
username: v.string(),
_password: v.string(),
action: v.picklist(['login', 'register'])
}),
async ({ username, _password, action }) => {
if (action === 'login') {
// обработка входа
} else {
// обработка регистрации
}
}
);

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

Как и в случае с query, если функция принимает аргумент, его следует валидировать, передав Standard Schema в качестве первого аргумента command.

likes.remote.js
import * as v from 'valibot';
import { query, command } from '$app/server';
import * as db from '#lib/server/database';
export const getLikes = query(v.string(), async (id) => {
const [row] = await db.sql`
SELECT likes
FROM item
WHERE id = ${id}
`;
return row.likes;
});
export const addLike = command(v.string(), async (id) => {
await db.sql`
UPDATE item
SET likes = likes + 1
WHERE id = ${id}
`;
});

Теперь можно просто вызвать addLike, например, из обработчика событий:

+page.svelte
<script>
import { getLikes, addLike } from './likes.remote';
import { showToast } from '#lib/toast';
let { item } = $props();
</script>
<button
onclick={async () => {
try {
await addLike(item.id);
} catch (error) {
showToast('Произошла ошибка!');
}
}}
>
Лайкнуть
</button>
<p>Лайков: {await getLikes(item.id)}</p>

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

SvelteKit решает обе эти проблемы с помощью *однопроходных мутаций: ваша отправка form или вызов command может обновить запросы и передать их результаты обратно на клиент в рамках одного запроса.

В большинстве случаев серверный обработчик знает, какие клиентские данные нужно обновить, на основе своих аргументов:

export const getPosts = query(async () => { /* ... */ });
export const getPost = query(v.string(), async (slug) => { /* ... */ });
export const createPost = form(
v.object({/* ... */}),
async (data) => {
// логика формы здесь...
// Обновляем `getPosts()` на сервере и отправляем
// данные обратно вместе с результатом `createPost`.
// Можно безопасно игнорировать промис от `refresh`,
// так как фреймворк сам дождётся его перед отправкой ответа
void getPosts().refresh();
// Перенаправляем на страницу только что созданного поста
redirect(303, `/blog/${slug}`);
}
);
export const updatePost = form(
v.object({ id: v.string() }),
async (post) => {
// логика формы здесь...
const result = externalApi.update(post);
// API уже возвращает обновлённый пост,
// нет нужды делать refresh, можно установить значение напрямую
getPost(post.id).set(result);
}
);

Поскольку запросы ключуются на основе своих аргументов, getPost(post.id).set(result) на сервере знает, как найти соответствующий getPost(id) на клиенте, чтобы обновить его. То же самое касается getPosts().refresh() — он знает, как найти getPosts() без аргументов на клиенте.

Однопроходные мутации также могут переподключать экземпляры query.live. В обработчике form/command вызовите .reconnect() на ресурсе live-запроса, который хотите переподключить:

import * as v from 'valibot';
import { form, query } from '$app/server';
export const getNotifications = query.live(v.string(), async function* (userId) {
while (true) {
yield await db.notifications(userId);
await wait(1000);
}
});
export const markAllRead = form(v.object({ userId: v.string() }), async ({ userId }) => {
// логика мутации...
getNotifications(userId).reconnect();
});

Это планирует переподключение для соответствующих активных экземпляров на клиенте и применяет его как часть ответа мутации (т. е. в том же «полёте», что и результат form/command). Это может понадобиться, например, если команда изменяет куки, который live-запросу нужно перезапустить, чтобы начать получать обновления.

К сожалению, жизнь не всегда такая простая, как в предыдущем примере. Сервер всегда знает, какие функции запросов нужно обновить, но он может не знать, какие именно экземпляры запросов обновлять. Например, если на клиенте отображается getPosts({ filter: 'author:santa' }), вызов getPosts().refresh() в серверном обработчике не обновит его. Нужно вызвать getPosts({ filter: 'author:santa' }).refresh() — но как узнать, какие именно комбинации фильтров сейчас используются на клиенте, особенно если аргумент запроса сложнее, чем объект с одним ключом?

SvelteKit упрощает это, позволяя клиенту запрашивать, чтобы сервер обновил конкретные данные с помощью submit().updates (для form) или myCommand().updates (для command):

await submit().updates(
// запрашиваем все активные экземпляры getPosts
getPosts,
// запрашиваем конкретный экземпляр
getPosts({ filter: 'author:santa' }),
// запрашиваем конкретный экземпляр с оптимистичным переопределением
getPosts({ filter: 'author:santa' }).withOverride((posts) => [newPost, ...posts])
);

Недостаточно просто запросить обновления с клиента — нужно также принять их от сервера:

import { query, form, requested } from '$app/server';
export const getPosts = query(v.object({ filter: v.string() }), async ({ filter }) => { /* ... */ });
export const createPost = form(
v.object({/* ... */}),
async (data) => {
// логика формы здесь...
for (const { query } of requested(getPosts, 1)) {
void query.refresh();
}
// Перенаправляем на страницу только что созданного поста
redirect(303, `/blog/${slug}`);
}
);

requested даёт доступ к запросам, которые клиент попросил обновить. Каждый элемент — это объект { arg, query }: arg — это значение, которое получила функция реализации запроса (т. е. аргумент после валидации схемы и (где применимо) его трансформации), а query — это уже привязанный к оригинальному клиентскому ключу кэша RemoteQuery, поэтому вызов query.refresh() / query.set(...) обновит правильный экземпляр на клиенте. Если разбор аргумента не удался, этот запрос выдаст ошибку, но вся команда при этом не упадёт. Второй параметр requestedlimit — это максимальное количество элементов, которое будет возвращено. Любые запросы на обновление сверх этого лимита будут отклонены.

Кроме того, requested позволяет использовать простой сокращённый вариант, когда вы хотите только обновить запрошенные экземпляры запросов:

// это то же самое, что перебрать результат в цикле и вызвать `void query.refresh()` для каждого.
await requested(getPosts, 1).refreshAll();

Функция prerender аналогична query, но вызывается во время сборки для предварительного рендеринга результата. Используйте её для данных, которые меняются не чаще одного раза при повторном развёртывании.

src/routes/blog/data.remote.js
import { prerender } from '$app/server';
import * as db from '#lib/server/database';
export const getPosts = prerender(async () => {
const posts = await db.sql`
SELECT title, slug
FROM post
ORDER BY published_at
DESC
`;
return posts;
});

Вы можете использовать функции prerender на страницах, которые в остальном динамические, что позволяет частично предварительно рендерить данные. Это обеспечивает очень быструю навигацию, так как предварительно отрендеренные данные могут храниться на CDN вместе с другими статическими ресурсами.

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

Как и в запросах, функции prerender могут принимать аргументы, которые следует проверять с помощью Standard Schema:

src/routes/blog/data.remote.js
import * as v from 'valibot';
import { error } from '@sveltejs/kit';
import { prerender } from '$app/server';
import * as db from '#lib/server/database';
export const getPosts = prerender(async () => { /* ... */ });
export const getPost = prerender(v.string(), async (slug) => {
const [post] = await db.sql`
SELECT * FROM post
WHERE slug = ${slug}
`;
if (!post) error(404, 'Не найдено');
return post;
});

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

src/routes/blog/data.remote.js
export const getPost = prerender(
v.string(),
async (slug) => { /* ... */ },
{
inputs: () => [
'first-post',
'second-post',
'third-post'
]
}
);

По умолчанию функции prerender исключаются из серверного бандла, что означает невозможность их вызова с аргументами, которые не были предварительно отрендерены. Вы можете изменить это поведение, установив dynamic: true:

src/routes/blog/data.remote.js
export const getPost = prerender(
v.string(),
async (slug) => { /* ... */ },
{
dynamic: true,
inputs: () => [
'first-post',
'second-post',
'third-post'
]
}
);

Если вы не передаёте невалидные данные в удалённые функции, есть только две причины, по которым аргумент command, query или prerender может не пройти валидацию:

  1. Сигнатура функции изменилась между развёртываниями, и некоторые пользователи используют старую версию приложения
  2. Кто-то пытается атаковать ваш сайт, подбирая данные к вашим эндпойнтам

Во втором случае мы не хотим помогать атакующему, поэтому SvelteKit сгенерирует стандартный ответ 400 Bad Request. Вы можете кастомизировать сообщение, реализовав серверный хук handleValidationError, который, как и handleError, должен возвращать тип App.Error (по умолчанию это { message: string }):

src/hooks.server.js
/** @type {import('@sveltejs/kit').HandleValidationError} */
export function handleValidationError({ event, issues }) {
return {
message: 'Хорошая попытка, хакер!'
};
}

Если вы понимаете, что делаете, и хотите отключить валидацию, вы можете передать строку 'unchecked' вместо схемы:

data.remote.ts
import { query } from '$app/server';
export const getStuff = query('unchecked', async ({ id }: { id: string }) => {
// фактическая структура данных может отличаться от ожидаемой TypeScript,
// так как злоумышленники могут вызывать эту функцию с другими аргументами
});

Внутри query, form и command вы можете использовать getRequestEvent для получения текущего объекта RequestEvent. Это упрощает создание абстракций для работы с файлами куки, например:

user.remote.ts
import { getRequestEvent, query } from '$app/server';
import { findUser } from '#lib/server/database';
export const getProfile = query(async () => {
const user = await getUser();
return {
name: user.name,
avatar: user.avatar
};
});
// эта функция может вызываться из разных мест:
function getUser() {
const { cookies, locals } = getRequestEvent();
locals.userPromise ??= findUser(cookies.get('session_id'));
return await locals.userPromise;
}

Обратите внимание, что некоторые свойства RequestEvent отличаются в удалённых функциях. Отсутствуют params и route.id, нельзя устанавливать заголовки (кроме записи куки, и то только внутри функций form и command), а url.pathname всегда равен / (поскольку фактический путь запроса клиента является внутренней деталью реализации).

Внутри функций query, form и prerender можно использовать функцию redirect(...). В функциях command это нельзя делать, так как перенаправления там следует избегать. (Если это абсолютно необходимо, можно вернуть объект { redirect: location } и обработать его на клиенте.)