Simple end-to-end type safety for SvelteKit. Lightweight and simpler alternative to TRPC.
I needed to use cookies methods inside my procedures, to handle file uploads/downloadss and a typesafe way to receive streamed response from AI models. But I wanted the same DX and type safety as TRPC. So I created svelte-rpc.
npm install svelte-rpc valibot
yarn add svelte-rpc valibot
pnpm add svelte-rpc valibot
bun install svelte-rpc valibot
// src/hooks.server.ts
import { type Router, createRPCHandle, procedure } from 'svelte-rpc';
import { object, string } from 'valibot';
import { string } from 'valibot';
const router = {
test: procedure((event) => {
// This is a middleware it will be called before the handle function
// Middlewares can be async or sync and are called in parallel,
// You can add as many middlewares as you want as arguments of the procedure function
if (!event.locals.user) {
error(401, 'You must be logged in to use this procedure');
}
// The return of the middleware will be available in the ctx object of the handle function
return { user: event.locals.user };
})
.input(
object({
name: string()
})
)
.handle(async ({ event, input, ctx }) => {
// event is the request event of SvelteKit
// ctx.user contains the user object
// input is of type { name: string }
return {
hello: input.name
};
})
};
export type AppRouter = typeof router;
export const handle = createRPCHandle({
router,
// The endoint where all of the procedures will be available
// Pass false to make it server only
endpoint: '/api',
// The key to put the server side api caller inside the event.locals object
// Pass false to disable the server side caller
localsApiKey: 'api'
});
// src/lib/api.ts
import { createRPCClient } from 'svelte-rpc/client';
import type { AppRouter } from '../hooks.server';
export const api = createRPCClient<AppRouter>({
// The endpoint to make the request to, must be the same as defined in the createRPCHandle function
endpoint: '/api',
// The headers to send with the request
// You can also pass a function that will be called before the fetch request, the can be async and receive the input and the path of the procedure
headers: {},
// If true, the api will throw an error when the request fails
throwOnError: false,
// Called when the request fails
onError: (error) => {
console.error(error);
}
});
<script lang="ts">
// src/routes/+page.svelte
import { api } from '$lib/api';
import { onMount } from 'svelte';
onMount(async () => {
// here result is of type { hello: string } | null because of potention error
const [result, error] = await api.test({ name: 'world' });
if (error) {
console.error(error);
} else {
// here result is of type { hello: string }
console.log(result);
}
});
</script>
Svelte-rpc can handle streamed response from the server. This is useful when you want to stream the response of an AI model for example. The only difference is that you need to pass a callback to the api function that will be called each time a chunk of the response is received.
On the server, when defining your procedure you can either return a ReadableStream or use the stream helper.
The stream helper is useful when you want to handle the stream response lifecycle by adding callbacks to the onChunk, onEnd and onStart events.
// src/routers/ai.ts
import { procedure } from 'svelte-rpc';
import { createRPCClient } from 'svelte-rpc/client';
import { string } from 'valibot';
import OpenAI from 'openai';
import { PRIVATE_OPEN_API_KEY } from '$env/static/private';
const openai = new OpenAI({
apiKey: PRIVATE_OPEN_API_KEY
});
export const aiRouter = {
chat: procedure()
.input(string())
.handle(async ({ input }) => {
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: 'You are a helpful assistant.'
},
{ role: 'user', content: input }
],
stream: true
});
return completion.toReadableStream() as ReadableStream<OpenAI.ChatCompletionChunk>;
}),
chatWithStreamHelper: procedure()
.input(string())
.handle(async ({ input, event }) => {
const completion = await openai.chat.completions.create({
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: 'You are a helpful assistant.'
},
{ role: 'user', content: input }
],
stream: true
});
return event.stream<OpenAI.ChatCompletionChunk>(completion.toReadableStream(), {
onStart: () => {
console.log('AI stream started');
},
onChunk: ({ chunk, first }) => {
console.log('AI chunk received', chunk, first);
},
onEnd: (chunks) => {
console.log('AI stream ended', chunks);
}
});
})
};
<script lang="ts">
// src/routes/+page.svelte
import { api } from '$lib/api';
import { onMount } from 'svelte';
const callAi = async () => {
await api.chat('Tell me a joke', ({ chunk, first }) => {
console.log(chunk.choices[0].delta.content, first);
// Chunk is type safe
// chunk.choices[0].delta.content is of type string | undefined
});
console.log('Done');
};
</script>
Svelte-rpc exports two inference helpers to help you infer the type of the input and output of a procedure. I usually prefer to let them globally available in my project using the app.d.ts file.
It is a bit cumbersome, just copy paste it and change the path of the file where the AppRouter is defined if needed.
// app.d.ts
declare global {
namespace App {
interface Locals {
// This is the server side caller
api: import('svelte-rpc').API<import('./hooks.server').AppRouter>;
//... other stuff of yours
}
//... Name it like you want
type InferRPCReturnType<
P extends import('svelte-rpc').RouterPaths<import('./hooks.server.js').AppRouter>
> = import('svelte-rpc').ReturnTypeOfProcedure<import('./hooks.server.js').AppRouter, P>;
//... Name it like you want
type InferRPCInput<
P extends import('svelte-rpc').RouterPaths<import('./hooks.server.js').AppRouter>
> = import('svelte-rpc').InputOfProcedure<import('./hooks.server.js').AppRouter, P>;
}
//... other stuff of yours
}
export {};
<script lang="ts">
export let result: App.InferRPCReturnType<'test'>;
// let { result }: { result: App.InferRPCReturnType<'test'> } = $props(); // If you are using svelte 5
// result is of type { hello: string }
</script>