Skip to main content

Fonctions distantes

Disponible depuis la version 2.27

Les fonctions distantes (remote functions) sont un outil pour communiquer de manière typée entre le client et le serveur. Elles peuvent être appelées depuis n’importe où dans votre application, mais sont toujours exécutées sur le serveur, ce qui signifie qu’elles peuvent accéder de manière sécurisée aux modules réservés au serveur contenant des choses comme des variables d’environnement ou des clients de base de données.

Combinées avec le support expérimental de Svelte pour await, elles vous permettent de charger et de manipuler les données directement au sein de vos composants.

Cette fonctionnalité est actuellement expérimentale, ce qui signifie qu’elle contient très certainement des bugs, et peut être modifiée à tout moment. Vous devez l’activer en ajoutant les options compilerOptions.experimental.async et kit.experimental.remoteFunctions dans votre fichier svelte.config.js. optionnelle en ajoutant l’option kit.experimental.remoteFunctions dans votre svelte.config.js :

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

export default config;

Aperçu

Les fonctions distantes (remote) sont exportées depuis un fichier .remote.js ou .remote.ts, et sont disponibles en quatre versions : query, form, command, et prerender. Sur le client, les fonctions exportées sont transformées en wrappers autour de fetch qui invoquent leur contrepartie sur le serveur en utilisant un endpoint HTTP généré. Les fichiers .remote. peuvent être placés n’importe où dans votre dossier src (sauf dans le dossier src/lib/server), et des librairies externes peuvent également en fournir.

query

La fonction query vous permet de lire des données dynamiques depuis le serveur.

Pour des données statiques, envisagez plutôt les fonctions prerender. Les queries ne peuvent pas être utilisées lorsque la page entière est pré-rendue (ce qui veut dire que export const prerender = true est appliquée à la page ou à un layout parent), comme lorsque vous utilisez adapter-static.

src/routes/blog/data.remote
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;
});

Tout au long de cette page, vous verrez des imports depuis des modules fictifs comme $lib/server/database et $lib/server/auth. Ces imports n’existent que pour illustrer les propos — vous pouvez bien sûr utiliser n’importe quel client de base de données et n’importe quel système d’authentification.

La fonction db.sql ci-dessus est une fonction de gabarit étiquété qui échappe toute valeur interpolée.

La query renvoyée par getPosts fonctionne comme une Promise qui résout des posts :

src/routes/blog/+page
<script>
	import { getPosts } from './data.remote';
</script>

<h1>Articles récents</h1>

<ul>
	{#each await getPosts() as { title, slug }}
		<li><a href="/blog/{slug}">{title}</a></li>
	{/each}
</ul>
<script lang="ts">
	import { getPosts } from './data.remote';
</script>

<h1>Articles récents</h1>

<ul>
	{#each await getPosts() as { title, slug }}
		<li><a href="/blog/{slug}">{title}</a></li>
	{/each}
</ul>

Tant que la promesse n’est pas résolue — et si elle échoue — la <svelte:boundary> la plus proche sera invoquée.

Bien que l’utilisation de await soit recommandée, vous pouvez également utiliser en alternative les propriétés loading, error et current de la query :

src/routes/blog/+page
<script>
	import { getPosts } from './data.remote';

	const query = getPosts();
</script>

<h1>Articles récents</h1>

{#if query.error}
	<p>oups !</p>
{:else if query.loading}
	<p>chargement...</p>
{:else}
	<ul>
		{#each query.current as { title, slug }}
			<li><a href="/blog/{slug}">{title}</a></li>
		{/each}
	</ul>
{/if}
<script lang="ts">
	import { getPosts } from './data.remote';

	const query = getPosts();
</script>

<h1>Articles récents</h1>

{#if query.error}
	<p>oups !</p>
{:else if query.loading}
	<p>chargement...</p>
{:else}
	<ul>
		{#each query.current as { title, slug }}
			<li><a href="/blog/{slug}">{title}</a></li>
		{/each}
	</ul>
{/if}

Pour le reste de ce document, nous utiliserons la forme await.

Arguments de query

Les fonctions query peuvent accepter un argument, comme le slug d’un article individuel :

src/routes/blog/[slug]/+page
<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>
<script lang="ts">
	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>

Puisque getPost expose un endpoint HTTP, il est important de valider cet argument pour garantir qu’il est du type attendu. Pour cela, nous pouvons utiliser n’importe quelle librairie de validation de Standard Schema, comme Zod ou Valibot :

src/routes/blog/data.remote
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, 'Not found');
	return post;
});

L’argument et la valeur de retour sont tous deux sérialisés avec devalue, qui gère des types comme Date et Map (et également des types personnalisés que vous pouvez définir dans votre transport hook) en plus du JSON.

Pour les arguments de query et prerender (mais pas leurs valeurs de retour), les objets, maps et sets sont triés de sorte que les occurrences ayant les mêmes éléments soient associées à une même clé de cache. Par exemple, getPosts({ limit: 10, offset: 10 }) et getPosts({ offset: 10, limit: 10 }) seront associés à la même clé. Si l’ordre a de l’importance pour vous, vous devrez utiliser un tableau.

Déduplication

Lorsque vous appelez une fonction query, SvelteKit sérialise l’argument avec lequel vous l’appelez et s’en sert en tant que clé de cache. Sur le serveur, cette clé est utilisée pour créer un cache scopé à la requête pour que plusieurs invocations de la même requête ne provoquent qu’une seule fois le travail à faire. Sur le client, SvelteKit fait quelque chose de similaire : plusieurs invocations identiques d’une même query pointent toutes vers la même instance.

Vous pouvez await une query dans n’importe quel contexte — composants, gestionnaires d’évènements, fonctions load universelles, callbacks asynchrones — et SvelteKit va dédupliquer en se servant de tout ce qui consomme la même query. Par exemple:

<script>
	import { getData } from './data.remote.js';

  // attendue au sein du template de composants — remplit le cache
  const data = getData();
</script>

<p>{await data}</p>

<!-- ceci déduplique en se servant de l'usage "dans le composant" juste au-dessus ; aucune requête supplémentaire n'est effectuée -->
<button onclick={async () => console.log(await getData())}>
	cliquez-moi dessus !
</button>

Le cache est partagé tant que la query est activement utilisée — rendue dans un composant, actuellement attendue, ou autrement référencée. Dès que plus rien ne se sert de la query, la valeur cachée est libérée.

Mettre les queries à jour

Toute query peut être rafraîchie via sa méthode refresh, qui récupère la valeur la plus récente depuis le serveur :

<button onclick={() => getPosts().refresh()}>
	Vérifier s'il y a des nouveaux articles
</button>

Les queries sont mises en cache tant qu’elles sont sur la page, ce qui veut dire que getPosts() === getPosts(). Cela signifie que vous n’avez pas besoin d’une référence comme const posts = getPosts() pour mettre à jour la query.

query.batch

query.batch fonctionne comme query à l’exception du fait qu’il regroupe les requêtes se produisant lors de la même micro-tâche. Ceci résout le bien nommé problème n+1 : plutôt que chaque requête résulte en un appel séparé à la base de données (par exemple), les requêtes simultanées sont regroupées.

Sur le serveur, le callback reçoit un tableau des arguments avec lesquels la fonction a été appelée. Il doit renvoyer une fonction de signature (input: Input, index: number) => Output. SvelteKit exécutera alors cette fonction avec chacun des arguments d’entrée pour résoudre les appels individuels et leurs résultats.

weather.remote
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
<script>
	import CityWeather from './CityWeather.svelte';
	import { getWeather } from './weather.remote';

	let { cities } = $props();
	let limit = $state(5);
</script>

<h2>Weather</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}>
		Charger plus
	</button>
{/if}
<script lang="ts">
	import CityWeather from './CityWeather.svelte';
	import { getWeather } from './weather.remote';

	let { cities } = $props();
	let limit = $state(5);
</script>

<h2>Weather</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}>
		Charger plus
	</button>
{/if}

query.live

query.live sert à accéder à des données en temps réel depuis le serveur. Elle fonctionne de la même manière que query, mais son callback — typiquement une fonction génératrice asynchrone — renvoie un 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));
	}
});

Lors du rendu côté serveur, await getTime() renvoie la première valeur fournie par le générateur puis ferme l’itérateur. La valeur initiale est sérialisée et réutilisée lors de l’hydratation.

Sur le client, la query restera connectée tant qu’elle sera activement utilisée dans un composant. Plusieurs instances partagent une même connexion. Lorsqu’il n’y a plus d’utilisations actives, le flux se déconnecte et l’itération côté serveur s’arrête.

Les queries temps réel (live queries) exposent une propriété connected et une méthode reconnect() :

<script>
	import { getTime } from './time.remote.js';

	const time = getTime();
</script>

<p>{await time}</p>
<p>connectée : {time.connected}</p>
<button onclick={() => time.reconnect()}>Reconnection</button>

Si la connexion s’arrête, connected devient false. SvelteKit essaiera de se reconnecter passivement, avec un délai exponentiel, et activement si navigator.onLine passe de false à true.

À la différence de query, les live queries ne possèdent pas de méthode .refresh(), car elles se mettent à jour toutes seules.

Si vous avez besoin d’un accès direct et impératif au flux sous-jacent de valeurs (plutôt qu’à la propriété réactive current), les instances de query live sont elles-mêmes itérables de manière asynchrone. Vous pouvez utilisez for await directement sur l’instance :

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

Plusieurs consommateurs de la même query live (qu’il soit réactif — via await ou current — ou que ce soit une boucle for await impérative) partagent une même connexion sous-jacente unique. La première valeur fournie à un itérateur for await est la valeur la plus récemment reçue, si une telle valeur est disponible, correspondant à la sémantique d’attendre une ressource directement. Les envois suivants sont déclenchés lorsqu’une nouvelle valeur arrive du serveur. Si des valeurs arrivent plus vite que le consommateur ne draine l’itérateur, seule la valeur en attente la plus récente est gardée — les flux live ne sont pas des logs d’évènements.

De la même manière, sur le serveur, for await partage une itération requête par requête du générator sous-jacent, de sorte que les consommateurs concurrents au sein de la même requête n’exécutent pas le générateur défini par l’utilisateur ou utilisatrice plusieurs fois.

Il est essentiel que vous ne cachiez pas les réponses des live queries dans un service worker, puisque la réponse clonée continuerait à être streamée bien après la fermeture de la page. Assurez-vous que votre logique de cache exclue toute réponse avec une en-tête Cache-Control incluant no-store.

form

La fonction form facilite l’écriture de données sur le serveur. Elle prend un callback qui reçoit l’objet data construit à partir du FormData soumis...

src/routes/blog/data.remote
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(
	v.object({
		title: v.pipe(v.string(), v.nonEmpty()),
		content:v.pipe(v.string(), v.nonEmpty())
	}),
	async ({ title, content }) => {
		// Vérifie si l'utilisateur est loggué
		const user = await auth.getUser();
		if (!user) error(401, 'Unauthorized');

		const slug = title.toLowerCase().replace(/ /g, '-');

		// Insère dans la base de données
		await db.sql`
			INSERT INTO post (slug, title, content)
			VALUES (${slug}, ${title}, ${content})
		`;

		// Redirige vers la page nouvellement créée
		redirect(303, `/blog/${slug}`);
	}
)

... et renvoie un objet qui peut être distribué sur un élément <form>. Le callback est appelée à chaque fois que le formulaire est soumis.

src/routes/blog/new/+page
<script>
	import { createPost } from '../data.remote';
</script>

<h1>Créer un nouvel article</h1>

<form {...createPost}>
	<!-- le contenu du formulaire est défini ici -->

	<button>Publier !</button>
</form>
<script lang="ts">
	import { createPost } from '../data.remote';
</script>

<h1>Créer un nouvel article</h1>

<form {...createPost}>
	<!-- le contenu du formulaire est défini ici -->

	<button>Publier !</button>
</form>

L’objet de formulaire contient les propriétés method et action qui lui permettent de fonctionner sans JavaScriptt (c-à-d il soumet les données et recharge la page). Il possède également un attachement qui améliore progressivement le formulaire lorsque JavaScript est disponible, lui permettant de soumettre les données sans recharger entièrement la page.

Comme avec query, si le callback utilise les data soumises, celles-ci doivent être validées en passant un Standard Schema en tant que premier argument au form.

Champs

Un formulaire est composé d’un ensemble de champs, qui sont définis par le schéma. Dans le cas d’un createPost, nous avons deux champs, title et content, qui sont tous deux des chaînes de caractères. Pour récupérer les attributs d’un champ, exécutez sa méthode as(...), en précisant quel type d’input utiliser. Pour la plupart des types d’inputs, vous pouvez également passer un deuxième argument — .as(type, value) — pour contrôler la valeur renvoyée :

<form {...createPost}>
	<label>
		<h2>Titre</h2>
		<input {...createPost.fields.title.as('text')} />
	</label>

	<label>
		<h2>Écrivez votre article</h2>
		<textarea {...createPost.fields.content.as('text')}></textarea>
	</label>

	<button>Publier !</button>
</form>

Ces attributs permettent à SvelteKit de définit le type correct d’input, de définir un name utilisé pour construire les data passées au gestionnaire, de remplir la value du formulaire (par exemple à la suite de l’échec d’une soumission, permettant de sauvegarder l’utilisateur ou l’utilisatrice sans avoir besoin de tout ré-écrire), et de définir l’état aria-invalid

Fournir un deuxième argument à .as(...) est utile lorsque vous créez un formulaire à partir de données existantes, comme un formulaire d’édition, ou plusieurs instances du formulaire avec for(...). De même qu’il définit la valeur de l’élément lorsque celui-ci est rendu, il contrôle également la valeur de l’élément lorsque le formulaire est réinitialisé. Les inputs radio, submit, et hidden ont toujours besoin de cette valeur, et les inputs checkbox en ont besoin lorsqu’ils représentent une option parmi d’autres. Les inputs file ne peuvent pas être remplis de cette manière.

L’attribut généré name utilise la notation d’objet JS (par ex. nested.array[0].value). Les clés exprimés en chaînes de caractères qui nécessitent des guillemets tels que object['nested-array'][0].value ne sont pas supportées. Sous le capot, les noms de champs de type nombre et de type booléen (pour les checkbox) sont préfixés par b: et n:, respectivement, pour signaler à SvelteKit qu’il doit transformer les valeurs depuis des chaînes de caractères avant leur validation.

Les champs peuvent être imbriqués dans des objets et des tableaux, et leurs valeurs peuvent être des chaînes de caractères, des nombres, des booléens ou des objets File. Par exemple, si votre schéma ressemble à ceci...

data.remote
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) => { /* ... */ });

... votre formulaire pourrait avoir cette forme là :

<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')} /> Nom
	</label>

	<label>
		<input {...photo.as('file')} /> Photo
	</label>

	<label>
		<input {...info.height.as('number')} /> Hauteur (cm)
	</label>

	<label>
		<input {...info.likesDogs.as('checkbox')} /> J'aime les chiens
	</label>

	<h2>Mes meilleures compétences</h2>
	<input {...attributes[0].as('text')} />
	<input {...attributes[1].as('text')} />
	<input {...attributes[2].as('text')} />

	<button>envoyer</button>
</form>

Puisque notre formulaire contient un input file, nous avons ajouté un attribut enctype="multipart/form-data". Les valeurs de info.height et info.likesDogs sont transformées en nombre et booléen respectivement.

Si un input checkbox est non coché, sa valeur n’est pas incluses dans l’objet FormData à partir duquelSvelteKit construit les données. Ainsi, nous devons rendre la valeur optionnelle dans votre schéma. Avec Valibot cela signifie l’utilisation de v.optional(v.boolean(), false) plutôt que juste v.boolean(), tandis qu’avec Zod, cela signifie l’utilisation de z.coerce.boolean<boolean>().

Dans le cas d’inputs radio et checkbox qui appartiennent tous au même champ, la value doit être précisée en tant que second argument de .as(...) :

constants
export const operatingSystems = /** @type {const} */ (['windows', 'mac', 'linux']);
export const languages = /** @type {const} */ (['html', 'css', 'js']);
export const operatingSystems = ['windows', 'mac', 'linux'] as const;
export const languages = ['html', 'css', 'js'] as const;
data.remote
import { operatingSystems, languages } from './constants';

export const survey = form(
	v.object({
		operatingSystem: v.picklist(operatingSystems),
		languages: v.optional(v.array(v.picklist(languages)), []),
	}),
	(data) => { /* ... */ },
);
<form {...survey}>
	<h2>Quel système d'exploitation utilisez-vous ?</h2>

	{#each operatingSystems as os}
		<label>
			<input {...survey.fields.operatingSystem.as('radio', os)}>
			{os}
		</label>
	{/each}

	<h2>Dans quels langages écrivez-vous du code ?</h2>

	{#each languages as language}
		<label>
			<input {...survey.fields.languages.as('checkbox', language)}>
			{language}
		</label>
	{/each}

	<button>envoyer</button>
</form>

De manière alternative, vous pouvez également utiliser select et select multiple :

<form {...survey}>
	<h2>Quel système d'exploitation utilisez-vous ?</h2>

	<select {...survey.fields.operatingSystem.as('select')}>
		{#each operatingSystems as os}
			<option>{os}</option>
		{/each}
	</select>

	<h2>Dans quels langages écrivez-vous du code ?</h2>

	<select {...survey.fields.languages.as('select multiple')}>
		{#each languages as language}
			<option>{language}</option>
		{/each}
	</select>

	<button>envoyer</button>
</form>

Comme pour les inputs checkbox non cochés, si aucune sélection n’est faite, alors les données seront undefined. Pour cette raison, le champ languages utilise v.optional(v.array(...), []), plutôt que simplement v.array(...).

Validation programmatique

En plus de la validation de schéma déclarative, vous pouvez programmatiquement marquer des champs comme invalides au sein du gestionnaire de formulaire en utilisant la fonction invalid importé depuis @sveltejs/kit. Cela sert dans les cas où vous ne pouvez savoir si quelque chose est valide avant d’avoir effectué certaines actions.

  • Elle jette une exception tout comme redirect ou error
  • Elle accepte plusieurs arguments qui peuvent être des chaînes de caractères (pour les problèmes concernant le formulaire en tant que tel – ces problèmes n’appaîtront que dans fields.allIssues()) ou des erreurs respectant le format du schéma standard (pour les erreurs relatives à des champs spécifiques). Utilisez le paramètre issue pour créer de telles erreurs de manière typée :
src/routes/shop/data.remote
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, 'vous devez acheter au moins un gâteau')
		)
	}),
	async (data, issue) => {
		try {
			await db.buy(data.qty);
		} catch (e) {
			if (e.code === 'OUT_OF_STOCK') {
				invalid(
					issue.qty(`nous n'avons pas assez de gâteaux`)
				);
			}
		}
	}
);

Validation

Si les données soumises ne correspondent pas au schéma, le callback ne sera pas exécuté. À la place, chaque méthode issues() d’un champ invalide renverra un tableau d’objets { message: string }, et l’attribut aria-invalid (renvoyé depuis as(...)) sera défini comme true :

<form {...createPost}>
	<label>
		<h2>Title</h2>

		{#each createPost.fields.title.issues() as issue}
			<p class="issue">{issue.message}</p>
		{/each}

		<input {...createPost.fields.title.as('text')} />
	</label>

	<label>
		<h2>Écrivez votre article</h2>

		{#each createPost.fields.content.issues() as issue}
			<p class="issue">{issue.message}</p>
		{/each}

		<textarea {...createPost.fields.content.as('text')}></textarea>
	</label>

	<button>Publier !</button>
</form>

Si le title est valide, ou n’a pas encore été validé, createPost.fields.title.issues() renverra undefined.

Vous n’avez pas besoin d’attendre la fin de la soumission du formulaire pour valider les données — vous pouvez appeler validate() programmatiquement, par exemple dans un callback oninput (qui va valider les données à chaque touche du clavier) ou dans un callback onchange :

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

Par défaut, les problèmes sont ignorés s’ils concernent des contrôles de formulaires avec lesquels il n’y a pas encore eu d’interaction. Pour valider tous les inputs, appelez validate({ includeUntouched: true }).

Pour les validations côté client, vous pouvez préciser un schéma preflight qui va remplir les issues() et empêcher que les données soient envoyées au serveur si celles-ci ne sont pas validées :

<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>Créer un nouvel article</h1>

<form {...createPost.preflight(schema)}>
	<!-- -->
</form>

Le schéma preflight peut être le même objet que votre schéma utilisé sur le serveur, si cela est pertinent, même s’il ne sera pas capable de faire des validations devant être faites côté serveur comme “cette valeur existe déjà dans la base de données” par exemple. Notez que vous ne pouvez pas exporter un schéma depuis un fichier .remote.ts ou .remote.js, le schéma doit alors être exporté depuis un module partagé, ou depuis le block <script module> du composant contenant le <form>.

Pour obtenir une liste de tous les problèmes, plutôt qu’uniquement ceux appartenant à un champ particulier, vous pouvez utiliser la méthode fields.allIssues() :

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

Comme avec les champs individuels, createPost.fields.allIssues() renverra undefined si le formulaire est entièrement valide (ou n’a pas encore été validé).

Récupérer/définir des inputs

Chaque champ a une méthode value() qui reflète sa valeur actuelle. Lorsque quelqu’un interagit avec le formulaire, la valeur est automatiquement mise à jour :

<form {...createPost}>
	<!-- -->
</form>

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

De plus, createPost.fields renvoie un objet { title, content }.

La value() d’un champ ne reflète pas les valeurs par défaut fournies en second argument à as (comme dans fields.title.as('text', '...')) avant que le champ n’ait été modifié ou soumis. Vous pouvez mettre à jour programmatiquement un champ (ou une collection de champs) via la méthode set(...) :

<script>
	import { createPost } from '../data.remote';

	// ceci...
	createPost.fields.set({
		title: 'Mon nouvel article de blog',
		content: 'Lorem ipsum dolor sit amet...'
	});

	// ...est équivalent à ceci :
	createPost.fields.title.set('Mon nouvel article de blog');
	createPost.fields.content.set('Lorem ipsum dolor sit amet');
</script>

Gérer les données sensibles

Dans le cas d’une soumission de formulaire non-améliorée-progressivement (c-à-d lorsque JavaScript n’est pas disponible, quelqu’en soit la raison), value() est également rempli si les données soumises sont invalides, afin que l’utilisateur ou l’utilisatrice n’ait pas besoin de re-remplir entièrement le formulaire.

Vous pouvez empêcher que des données sensibles (comme des mots de passe ou des numéros de carte bancaire) soient renvoyés au client en utilisant un nom commençant par un tiret-bas :

<form {...register}>
	<label>
		Nom
		<input {...register.fields.username.as('text')} />
	</label>

	<label>
		Mot de passe
		<input {...register.fields._password.as('password')} />
	</label>

	<button>S'inscrire !</button>
</form>

Dans cet exemple, si les données ne sont pas validées, seul le premier <input> sera rempli lorsque la page sera rechargée.

Renvois et redirections

L’exemple ci-dessus utilise redirect(...), qui redirige l’utilisateur ou l’utilisatrice vers la page nouvellement créée. De manière alternative, le callback pourrait également renvoyer les données, auquel cas elles seraient disponibles en tant que createPost.result :

src/routes/blog/data.remote
export const createPost = form(
	v.object({/* ... */}),
	async (data) => {
		// ...

		return { success: true };
	}
);
src/routes/blog/new/+page
<script>
	import { createPost } from '../data.remote';
</script>

<h1>Créer un nouvel article</h1>

<form {...createPost}>
	<!-- -->
</form>

{#if createPost.result?.success}
	<p>Publié avec succès !</p>
{/if}
<script lang="ts">
	import { createPost } from '../data.remote';
</script>

<h1>Créer un nouvel article</h1>

<form {...createPost}>
	<!-- -->
</form>

{#if createPost.result?.success}
	<p>Publié avec succès !</p>
{/if}

Cette valeur est éphémère — elle disparaîtra si vous re-soumettez le formulaire, si vous naviguez sur une autre page, ou si vous rechargez la page.

La valeur de result n’indique pas nécessairement un succès — elle peut également contenir des erreurs de validation, ainsi que des données permettant de re-remplir le formulaire en cas de rechargement de la page.

Si une erreur se produit lors de la soumission, la page +error.svelte la plus proche sera affichée.

enhance

Nous pouvons personnaliser ce qui se produit lorsque le formulaire est soumis grâce à la méthode enhance :

src/routes/blog/new/+page
<script>
	import { createPost } from '../data.remote';
	import { showToast } from '$lib/toast';
</script>

<h1>Créer un nouvel article</h1>

<form {...createPost.enhance(async (form) => {
	try {
		if (await form.submit()) {
			form.element.reset();

			showToast('Publié avec succès !');
		} else {
			showToast('Invalid data!');
		}
	} catch (error) {
		showToast('Oh non ! Quelque chose s\'est mal passé !');
	}
})}>
	<!-- -->
</form>
<script lang="ts">
	import { createPost } from '../data.remote';
	import { showToast } from '$lib/toast';
</script>

<h1>Créer un nouvel article</h1>

<form {...createPost.enhance(async (form) => {
	try {
		if (await form.submit()) {
			form.element.reset();

			showToast('Publié avec succès !');
		} else {
			showToast('Invalid data!');
		}
	} catch (error) {
		showToast('Oh non ! Quelque chose s\'est mal passé !');
	}
})}>
	<!-- -->
</form>

Lorsque vous utilisez enhance, le <form> n’est pas automatiquement réinitialisé — vous devez appeler form.element.reset() si vous souhaitez nettoyer les inputs.

Le callback reçoit une copie de l’instance de formulaire. Elle possède les mêmes propriétés et méthodes à l’exception de enhance, et form.submit() effectue la soumission directement sans ré-exécuter le callback enhance. Au sein du callback, form.element est toujours définie.

Instances multiples de formulaire

Certains formulaires peuvent être répétés au sein d’une liste. Dans ce cas, vous pouvez créer des instances séparées d’une fonction de formulaire via for(id) pour les garder en isolation.

Lorsque chaque instance devrait afficher des valeurs différentes, passez-les en tant que deuxième argument à .as(...) :

src/routes/todos/+page
<script>
	import { getTodos, modifyTodo } from '../data.remote';
</script>

<h1>À faire</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}>enregistrer les changements</button>
	</form>
{/each}
<script lang="ts">
	import { getTodos, modifyTodo } from '../data.remote';
</script>

<h1>À faire</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}>enregistrer les changements</button>
	</form>
{/each}

Plusieurs boutons de soumission

Un <form> peut avoir plusieurs boutons de soumission. Par exemple, vous pourriez avoir un unique formulaire vous permettant de vous connecter ou de vous inscrire selon le bouton sur lequel vous cliquez.

Pour faire cela, ajoutez un champ à votre schéma correspondant à la valeur du bouton, et utilisez as('submit', value) pour le lier :

src/routes/login/+page
<script>
	import { loginOrRegister } from '$lib/auth';
</script>

<form {...loginOrRegister}>
	<label>
		Votre nom d'utilisateur
		<input {...loginOrRegister.fields.username.as('text')} />
	</label>

	<label>
		Votre mot de passe
		Your password
		<input {...loginOrRegister.fields._password.as('password')} />
	</label>

	<button {...loginOrRegister.fields.action.as('submit', 'login')}>se connecter</button>
	<button {...loginOrRegister.fields.action.as('submit', 'register')}>s'inscrire</button>
</form>
<script lang="ts">
	import { loginOrRegister } from '$lib/auth';
</script>

<form {...loginOrRegister}>
	<label>
		Votre nom d'utilisateur
		<input {...loginOrRegister.fields.username.as('text')} />
	</label>

	<label>
		Votre mot de passe
		Your password
		<input {...loginOrRegister.fields._password.as('password')} />
	</label>

	<button {...loginOrRegister.fields.action.as('submit', 'login')}>se connecter</button>
	<button {...loginOrRegister.fields.action.as('submit', 'register')}>s'inscrire</button>
</form>

Dans votre gestionnaire de formulaire, vous pouvez vérifier quel bouton a été cliqué :

$lib/auth
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') {
			// gestion de la connexion
		} else {
			// gestion de l'inscription
		}
	}
);

command

La fonction command, comme form, vous permet d’écrire des données sur le serveur. À la différence de form, elle n’est pas spécifique à un élément et peut être appelée depuis n’importe où.

Privilégiez l’usage de form lorsque c’est possible, puisqu’elle continue de fonctionner lorsque JavaScript est désactivé ou a échoué à se charger.

Comme avec query et form, si la fonction accepte un argument, celui-ci devrait être validé en fournissant un Standard Schema en tant que premier argument à command.

likes.remote
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}
	`;
});

Vous pouvez maintenant simplement appeler addLike, depuis (par exemple) un gestionnaire d’évènement :

+page
<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('Quelque chose s\'est mal passé !');
		}
	}}
>
	ajouter un like
</button>

<p>likes : {await getLikes(item.id)}</p>
<script lang="ts">
	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('Quelque chose s\'est mal passé !');
		}
	}}
>
	ajouter un like
</button>

<p>likes : {await getLikes(item.id)}</p>

Les commandes ne peuvent pas être appelées lors du rendu.

Mutations single-flight

L’objectif des fonctions distantes form et command est de muter des données. Dans plusieurs cas, la mutation de données invalide d’autres données. Par défaut, form gère ceci en invalidant automatiquement toutes les queries et fonctions load à la suite d’une soumission de formulaire réussie, pour simuler ce qu’il se passerait avec le rechargement classique de la page. command, de son côté, ne fait rien de particulier. Typiquement, aucune de ces options n’est idéale — tout invalider est probablement surdimensionné, car il est peu probable qu’une soumission de formulaire ait changé tout ce qui est visible sur votre page. Pour ce qui est de command, ne rien faire n’est pas beaucoup plus utile, car cela laisse des données périmées à l’écran. Dans les deux cas, il est courant d’avoir à effectuer deux aller-retours vers le serveur : un pour réaliser la mutation, et un autre dans la foulée permettant de re-requêter les données des queries qui ont besoin d’être mises à jour.

SvelteKit solutionne ces deux problèmes avec les mutations single-flight : votre soumission de form ou votre invocation de command peuvent rafraîchir des queries et fournir leurs résultats en retour au client au cours d’une seule requête.

Mises à jour pilotées depuis le serveur

Dans la plupart des situations, le gestionnaire sur le serveur sait quelles sont les données du client ayant besoin d’être mises à jour, en fonction de ses arguments :

export const getPosts = query(async () => { /* ... */ });

export const getPost = query(v.string(), async (slug) => { /* ... */ });

export const createPost = form(
	v.object({/* ... */}),
	async (data) => {
		// la logique de formulaire se trouve ici...

		// Met à jour `getPosts()` sur le serveur, et renvoie
		// les données avec le résultats de `createPost`
		// La promise de `refresh` peut ne pas être gérée
		// car le framework l'attend pour nous avant de servir la réponse
		void getPosts().refresh();

		// Redirige vers la page nouvellement créée
		redirect(303, `/blog/${slug}`);
	}
);

export const updatePost = form(
	v.object({ id: v.string() }),
	async (post) => {
		// la logique de formulaire se trouve ici...
		const result = externalApi.update(post);

		// L'API nous donne déjà l'article mis à jour,
		// il n'y a pas besoin de le mettre à jour,
		// nous pouvons le définir directement
		getPost(post.id).set(result);
	}
);

Puisque les queries sont associées à des clés en fonction de leurs arguments, getPost(post.id).set(result) sur le serveur sait retrouver le getPost(id) correspondant sur le client pour le mettre à jour. C’est le même fonctionnement pour getPosts().refresh() — il sait retrouver getPosts() sans argument sur le client.

Reconnecter les queries live dans les mutations

Les mutations “single-flight” peuvent également se reconnecter aux instances de query.live. Dans un gestionnaire form / command, appelez .reconnect() sur la ressource de la query live à laquelle vous souhaitez vous reconnecter :

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 }) => {
	// logique de mutation...
	getNotifications(userId).reconnect();
});

Ceci prévoit une reconnection des instances client actives correspondantes et les applique en tant que partie de la réponse à la mutation (c-à-d dans le même trajet que le résultat du formulaire/de la commande). Vous pourriez avoir besoin de ceci si, par exemple, la commande modifie un cookie que la query live a besoin de réinitialiser pour le capturer.

Mises à jour pilotées par le client

Malheureusement, la vie n’est pas toujours aussi simple que l’exemple précédent. Le serveur connaît toujours quelles fonctions de type “query” mettre à jour, mais il ne sait pas quelles instances spécifiques mettre à jour. Par exemple, si getPosts({ filter: 'author:santa' }) est affiché sur le client, appeler getPosts().refresh() sur le serveur ne la mettra pas à jour. Vous devrez plutôt appeler getPosts({ filter: 'author:santa' }).refresh() — mais comment pourriez-vous savoir quelles combinaisons spécifiques de filtres sont actuellement affichées sur le client, en particulier si votre argument de query est plus compliqué qu’un objet avec une seule clé ?

SvelteKit résout ce problème en permettant au client de requêter des mises-à-jour de données spécifiques au serveur en utilisant submit().updates (pour form) ou myCommand().updates (pour command) :

await submit().updates(
	// pour requêter toutes les instances actives de getPosts
	getPosts,
	// pour requêter une instance spécifique
	getPosts({ filter: 'author:santa' }),
	// pour requêter une instance spécifique avec une surcharge optimiste
	getPosts({ filter: 'author:santa' }).withOverride((posts) => [newPost, ...posts])
);

Cela ne suffit pas d’uniquement requêter les mises-à-jour depuis le client — vous devez également les accepter depuis le serveur :

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) => {
		// la logique de formulaire est ici...

		for (const { query } of requested(getPosts, 1)) {
			void query.refresh();
		}

		// Redirige vers la page nouvellement créée
		redirect(303, `/blog/${slug}`);
	}
);

requested vous donne accès aux queries dont le client a demandé la mise à jour. Chaque entrée est un objet { arg, query } : arg est la valeur que la fonction d’implémentation de la query a reçu — c-à-d l’argument après qu’il a été validé et (lorsqu’applicable) transformé par le schéma — et query est une RemoteQuery déjà liée à la clé de cache originelle du client, de sorte que appeler query.refresh(...) / query.set(...) mette à jour l’instance correcte sur le client. Si le parsing d’un argument échoue, cette query renverra une erreur, mais la commande entière n’échouera pas. Le deuxième paramètre de requested, limit, est le nombre d’éléments maximum qu’elle renverra. Toute requête de mise à jour au-delà de cette limite échouera.

limit est requis car la liste des requêtes de mise à jour est contrôllée par le client — chaque entrée amène le serveur à valider un argument et généralement à re-requêter des données, de sorte qu’une liste non liée est une risque de Déni-de-service. Choisissez une limite qui reflète le cas le pire que vous souhaitez gérer par requête. Vous pouvez fournir Infinity si vous avez décidé explicitement d’accepter n’importe quel nombre de mises à jour, mais ceci n’est pas recommandé.

De plus, requested permet un raccourci lorsque vous voulez uniquement mettre à jour les instances de la query demandée :

// ceci est équivalent à boucler sur son résultat puis appeler `void query(arg).refresh()`.
await requested(getPosts, 1).refreshAll();

Pourquoi la commande doit nommer chaque query qu’elle souhaite mettre à jour ? Il y a deux raisons :

  • La taille de bundle. Si une commande pouvait implicitement rafraîchir n’importe quelle query de cette application, SvelteKit devrait inclure le code de chaque query dans le bundle serveur de la commande, car il ne peut pas savoir à l’avance lesquelles seront exécutées.
  • Déni-de-service. Tout utilisateur ou utilisatrice malveillante peut inspecter leur onglet réseau pour découvrir quelles sont les requêtes utilisées par votre application, puis envoyer une commande POST avec une liste fournie par le client contenant des milliers de mises-à-jour. La seule défense possible est la déclaration par le gestionnaire du serveur des queries qu’il souhaite mettre à jour — et dans quelle quantités (d’où la limit requise).

prerender

La fonction prerender est similaire à query, sauf qu’elle sera exécutée lors de la compilation pour pré-rendre le résultat. Utilisez cette fonction pour des données qui changent au plus une fois par déploiement.

src/routes/blog/data.remote
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;
});

Vous pouvez utiliser des fonctions prerender sur des pages par ailleurs dynamiques, permettant le pré-rendu partiel de vos données. Ceci permet une navigation très rapide, puisque les données prérendues peuvent être stockées sur un CDN à côté de vos autres assets statiques.

Dans le navigateur, les données pré-rendues sont sauvegardées en utilisant l’API Cache. Ce cache survit aux rechargements de page, et sera nettoyé lorsque l’utilisateur ou l’utilisatrice visitera pour la première fois une nouveau déploiement de votre application.

Arguments de prerender

Comme avec les queries, les fonctions prerender peuvent accepter un argument, qui devrait être validé avec un Standard Schema :

src/routes/blog/data.remote
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, 'Not found');
	return post;
});

Tout appel à getPost(...) identifié par le crawler de SvelteKit lors du pré-rendu des pages sera automatiquement enregistré, mais vous pouvez également préciser avec quelles valeurs chaque appel devrait être fait en utilisant l’option inputs :

src/routes/blog/data.remote

export const getPost = prerender(
	v.string(),
	async (slug) => { /* ... */ },
	{
		inputs: () => [
			'premier-article',
			'deuxième-article',
			'troisième-article'
		]
	}
);
export const getPost = prerender(
	v.string(),
	async (slug) => { /* ... */ },
	{
		inputs: () => [
			'premier-article',
			'deuxième-article',
			'troisième-article'
		]
	}
);

Par défaut, les fonctions de pré-rendu sont exclues de votre bundle de compilation, ce qui signifie que vous ne pouvez pas les exécuter avec des arguments qui n’ont pas été pré-rendus. Vous pouvez définir dynamic: true pour changer ce comportement :

src/routes/blog/data.remote

export const getPost = prerender(
	v.string(),
	async (slug) => { /* ... */ },
	{
		dynamic: true,
		inputs: () => [
			'premier-article',
			'deuxième-article',
			'troisième-article'
		]
	}
);
export const getPost = prerender(
	v.string(),
	async (slug) => { /* ... */ },
	{
		dynamic: true,
		inputs: () => [
			'premier-article',
			'deuxième-article',
			'troisième-article'
		]
	}
);

Gestion des erreurs de validation

Tant que vous ne passez pas de données invalides à vos fonctions distantes, il n’y a que deux raisons pour lesquelles l’argument passé à une command, une query ou une prerender échouerait à la validation :

  • la signature de la fonction a changé entre deux déploiements, et certains utilisateurs ou utilisatrices sont actuellement sur une version ancienne de votre application
  • quelqu’un est en train d’essayer d’attaquer votre site en testant les endpoints que vous exposez avec des données malformées

Dans le second cas, nous ne voulons pas donner à l’attaquant d’indices, c’est pourquoi SvelteKit va générer une réponse générique 400 Bad Request. Vous pouvez contrôler le message en implémentant le hook serveur handleValidationError, qui, comme handleError, doit renvoyer un objet App.Error (qui est défini par défaut à { message: string }) :

src/hooks.server
/** @type {import('@sveltejs/kit').HandleValidationError} */
export function handleValidationError({ event, issues }) {
	return {
		message: 'Bien tenté, hacker !'
	};
}
import type { HandleValidationError } from '@sveltejs/kit';

export const handleValidationError: HandleValidationError = ({ event, issues }) => {
	return {
		message: 'Bien tenté, hacker !'
	};
};

Si vous savez ce que vous faites et souhaitez vous passer de validation, vous pouvez passer la chaîne de caractères 'unchecked' à la place du schéma :

data.remote
import { query } from '$app/server';

export const getStuff = query('unchecked', async ({ id }: { id: string }) => {
	// la forme peut ne pas être ce que pense TypeScript
	// puisque des personnes mal intentionnées pourraient
	// appeler cette fonction avec d'autres arguments
});

Utiliser getRequestEvent

Dans une fonction query, form ou command, vous pouvez utiliser getRequestEvent pour obtenir l’objet RequestEvent courant. Ceci permet de construire simplement des abstractions pour interagir avec des cookies, par exemple :

user.remote
import { getRequestEvent, query } from '$app/server';
import { findUser } from '$lib/server/database';

export const getProfile = query(async () => {
	const user = await getUser();

	return user && {
		name: user.name,
		avatar: user.avatar
	};
});

// cette query pourrait être appelée depuis plusieurs endroits,
// mais cette fonction ne sera exécutée qu'une fois par requête
const getUser = query(async () => {
	const { cookies } = getRequestEvent();

	return await findUser(cookies.get('session_id'));
});

Notez que certaines propriétés de RequestEvent sont différentes au sein des fonctions distantes :

  • vous ne pouvez pas définir d’en-têtes (si ce n’est écrire dans des cookies, et uniquement dans des fonctions form et command)
  • route, params et url sont relatives à la page depuis laquelle la fonction distante a été appelée, et non l’URL du endpoint que SvelteKit crée pour la fonction distante. Ne les utilisez jamais pour déterminer si oui ou non un utilisateur ou utilisatrice est autorisée à accéder à certaines données, puisque ces valeurs font partie de la requête et peuvent donc être manipulées. Les queries ne sont de plus pas ré-exécutées lorsque l’utilisateur ou l’utilisatrice navigue (à moins que l’argument de la query ait changé suite à une navigation), vous devriez donc être conscient•e de la façon dont vous utilisez ces valeurs.

Redirections

Au sein d’une fonction query, form ou prerender, il est possible d’utiliser la fonction redirect(...). Ce n’est pas possible au sein d’une fonction command, car vous devriez éviter de rediriger à cet endroit. (Si vous avez absolument besoin de le faire, vous pouvez toujours renvoyer un objet { redirect: location } et gérer la redirection côté client.)

Modifier cette page sur Github llms.txt