Why RPC endpoints (such as telefunctions) need protection
You can skip this page if you are already familiar with RPC.
Basic example
Telefunc enables functions defined on the server-side to be called remotely from the browser-side.
// hello.telefunc.js// Environment: serverexport { hello }// hello() always runs on the server-sideasync function hello({ name }) { const message = 'Welcome ' + name return { message }}
<!-- index.html --><!-- Environment: client --><html> <body> <script type="module"> // This import doesn't actually load the hello.telefunc.js file: Telefunc transforms // hello.telefunc.js into a thin HTTP client. import { hello } from './hello.telefunc.js' // This thin HTTP client makes an HTTP request when the hello() function is called. const { message } = await hello({ name: 'Eva' }) console.log(message) // Prints 'Welcome Eva' </script> </body></html>
The central aspect here is that hello() is always executed on the server side, which enables hello() (and telefunctions in general) to use server-side utilities such as SQL and ORMs.
ORM & SQL
As we have seen in the previous section, telefunctions always run on the server-side and can therefore use server utilities such as SQL and ORMs.
.telefunc.js files are guaranteed to be loaded only on the server-side. You can therefore save secret information, such as the database passwords, in .telefunc.js files.
// TodoList.jsx// Environment: client// This doesn't actually load CreateTodo.telefunc.js which we'll explain in the next sectionimport { onLoad } from './TodoList.telefunc.js'async function TodoList() { // The frontend uses the telefunction onLoad() to retrieve data by executing a SQL/ORM query const todoItems = await onLoad() return ( <ul>{ todoItems.map(item => <li>{ item.text }</li> )}</ul> )}
We collocate and name the TodoList.telefunc.js file after TodoList.jsx, which is a practice we recommend and explain at Guides > Event-based telefunctions.
While the examples here use JSX, Telefunc works with any UI framework (React, Vue, Svelte, Solid, ...).
Telefunctions can also be used to mutate data:
// CreateTodo.telefunc.js// Environment: serverexport { onNewTodo }import { shield } from 'telefunc'// We'll talk about shield() latershield(onNewTodo, [shield.type.string])async function onNewTodo(text) { // ORM const todoItem = new Todo({ text }) await todoItem.save() // SQL await execute( "INSERT INTO todo_items VALUES (:text)", { text } )}
// CreateTodo.jsx// Environment: clientimport { onNewTodo } from './CreateTodo.telefunc.js'async function onClick(form) { const text = form.input.value await onNewTodo(text)}function CreateTodo() { return ( <form> <input input="text"></input> <button onClick={onClick}>Add To-Do</button> </form> )}
RPC enables your frontend to tap directly into the full power of the server such as SQL and ORMs. For most use cases it's simpler, more flexible, and more performant than REST and GraphQL.
GraphQL and RESTful can be better than RPC if:
you want to give third parties generic access to your data, or
you are a very large company with highly complex databases.
// sql.telefunc.js// Environment: server// run() is public: it can be called by anyoneexport { run }async function run(sql) { return await database.execute(sql)}
This run() telefunction essentially exposes the entire database to the world as
anyone can make this HTTP request:
Always keep in mind that your telefunctions are public and need protection.
throw Abort()
As we've seen in the previous section,
the following telefunction isn't safe.
// run.telefunc.js// Environment: server// run() is public: it can be called by anyoneexport { run }async function run(sql) { return await database.execute(sql)}
But we can use throw Abort() to protect it:
// run.telefunc.js// Environment: server// run() is public: it can be called by anyoneexport { run }import { Abort, getContext } from 'telefunc'async function run(sql) { const { user } = getContext() // Only admins are allowed to run this telefunction if (user.isAdmin !== true) throw Abort() return await database.execute(sql)}
Telefunctions can access contextual information by using getContext().
We can use throw Abort() to avoid any forbidden telefunction call.
// TodoList.telefunc.js// Environment: serverexport { onLoad }import { Abort, getContext } from 'telefunc'async function onLoad() { const { user } = getContext() // We forbid onLoad() to be called by a user that isn't logged-in if (!user) throw Abort() const todoList = await Todo.findMany({ authorId: user.id }) return todoList}
We essentially use throw Abort() to implement permission: only a logged-in user is allowed to fetch its to-do items.
We talk more about permissions at Guides > Permissions.
In principle, we could also throw new Error() instead of throw Abort() as it also interupts the telefunction call. But we recommend throw Abort() as it comes with many conveniences.
If, upon aborting a telefunction call, you want to pass information to the frontend then use return someValue or throw Abort(someValue), see Guides > Permissions.
shield()
Since telefunctions are public and can be called by anyone, we cannot assuming anything about arguments. We can use throw Abort() to ensure the type of telefunction arguments:
// CreateTodo.telefunc.js// Environment: serverexport async function onNewTodo(text) { // ❌ This may throw: // ``` // Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase') // ``` // While the frontend may always call onNewTodo(text) with `typeof text === 'string'`, // because onNewTodo() is public, anyone can call onNewTodo(undefined) instead. text = text.toUpperCase() // ✅ We ensure `text` is a string if (typeof text !== 'string') { throw Abort() } text = text.toUpperCase()}
For more convenience, we can use shield() instead:
// CreateTodo.telefunc.js// Environment: serverimport { shield } from 'telefunc'const t = shield.typeshield(onNewTodo, [t.string])export async function onNewTodo(text) { // ...}
// CreateTodo.telefunc.js// Environment: serverimport { shield } from 'telefunc'const t = shield.typeshield(onNewTodo, [{ text: t.string, isCompleted: t.boolean }])export async function onNewTodo({ text, isCompleted }) { // ...}
Not only does shield() call throw Abort() on our behalf, but it also infers the type of the arguments for TypeScript and IntelliSense.
Any of your telefunctions can be called by anyone, at any time, and with any arguments. One way to think about it is that any random telefunction call can happen at any time.
You should always protect your telefunctions, even when your frontend calls a telefunction only in a certain way. For example:
// Comment.jsx// Environment: clientimport { onDelete } from './Comment.telefunc.js'function Comment({ id, text }) { const deleteButton = // The delete button is only shown to admins !user.isAdmin ? null : <button onClick={() => onDelete(id)}>Delete</button> return <> <p>{ text }</p> { deleteButton } </>}
Because the frontend shows the delete button only to admins, we can assume the user to be an admin whenever the frontend calls onDelete().
But we still need to use throw Abort() in order to protect the telefunction against calls that don't originate from the frontend.
// Comment.telefunc.js// Environment: serverimport { getContext, Abort, shield } from 'telefunc'shield(onDelete, [shield.type.number])export async function onDelete(id) { const { user } = getContext() // onDelete() is public and anyone can call it without being an admin. // We must abort if that happens. if (!user.isAdmin) throw Abort() // ...}