This page explains:
- What RPC is.
- How RPC (and Telefunc) work.
- That RPC endpoints (i.e. telefunctions) need protection.
You can skip reading this page if you are alreay familiar with RPC.
Telefunc enables functions defined on the server-side to be called remotely from the browser-side.
// hello.telefunc.js
// Environment: Server
export { hello }
async function hello({ name }) {
const message = 'Welcome ' + name
return { message }
}
<!-- index.html -->
<!-- Environment: Browser -->
<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 we call `hello()`
const { message } = await hello({ name: 'Eva' })
console.log(message) // Prints 'Welcome Eva'
</script>
</body>
</html>
Telefunctions such as hello()
always run on the server-side.
The practice of remotely calling functions is called RPC (Remote Procedure Call).
Because they are always run on the server-side, telefunctions are able to run SQL/ORM queries.
This, in essence, enables our frontend to use SQL/ORM queries.
// TodoList.telefunc.js
// Environment: Server
export { onLoad }
async function onLoad() {
// ORM
const todoItems = await Todo.findMany({ select: 'text' })
// SQL
const todoItems = await execute("SELECT text FROM todo_items;")
return todoItems
}
// TodoList.jsx
// Environment: Browser
import { onLoad } from './TodoList.telefunc.js'
async function TodoList() {
// Our frontend uses the telefunction `onLoad()` to execute a SQL/ORM query that
// retrieves the data it needs.
const todoItems = await onLoad()
return (
<ul>{ todoItems.map(item =>
<li>{ item.text }</li>
)}</ul>
)
}
Note how we collocate and name
TodoList.telefunc.js
afterTodoList.jsx
; it's a practice we'll talk about later.
Our examples use JSX but note that Telefunc works with any UI framework (React, Vue, Angular, Svelte, ...).
Our frontend can also use telefunctions to mutate data.
// CreateTodo.telefunc.js
// Environment: Server
export { onNewTodo }
import { shield } from 'telefunc'
// We'll talk about `shield()` in a moment
shield(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: Browser
import { 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>
)
}
This means our frontend can directly tap into the power of our SQL/ORM engine. This is both simpler and more powerful than REST/GraphQL.
We need a GraphQL/RESTful API only if third parties need to be able to access our database, or if we are a very large company with highly complex databases. We explain why at RPC vs GraphQL/REST.
Understanding the basic mechanics of Telefunc is paramount in order to proficiently use Telefunc.
Let's see what happens when we call a telefunction.
// hello.telefunc.js
// Environment: Server
export { hello }
async function hello({ name }) {
const message = 'Welcome ' + name
return { message }
}
// Environment: Browser
import { hello } from './hello.telefunc.js'
const message = await hello('Eva')
The hello.telefunc.js
file is never loaded in the browser:
Telefunc transforms hello.telefunc.js
into the following:
// hello.telefunc.js (after Telefunc transformation)
// Environement: Browser
import { __internal_makeHttpRequest } 'telefunc/client'
export const hello = (...args) => __internal_makeHttpRequest('/hello.telefunc.js:hello', args)
So, when we call hello('Eva')
in the browser-side, the following happens:
__internal_makeHttpRequest()
function makes an HTTP request to our server.
POST /_telefunc HTTP/1.1
{
"path": "/hello.telefunc.js:hello",
"args": [{"name": "Eva"}]
}
// server.js
// Server (Express.js/Fastify/...)
import { telefunc } from 'telefunc'
// Telefunc middleware
app.use('/_telefunc', async (req, res) => {
const httpResponse = await telefunc(req)
res.send(httpResponse.body)
})
HTTP/1.1 200 OK
{
"return": {
"message": "Welcome Eva"
}
}
In other words,
the hello()
function is always executed on the server-side,
and the browser-side remotely calls hello()
by using an HTTP request.
Our telefunctions can be remotely called not only by our frontend but really by anyone.
Anyone can open a terminal and make an HTTP request:
curl https://our-website.com/_telefunc --data '{
"path": "/hello.telefunc.js:hello",
"args": [{"name": "Elisabeth"}]
}'
Thus, such telefunction is problematic:
// sql.telefunc.js
// Environment: Server
export { run }
async function run(sql) {
return await database.execute(sql)
}
This run()
telefunction essentially exposes our entire database to the world as
anyone can make an HTTP request like the following.
curl https://our-website.com/_telefunc --data '{
"path": "/run.telefunc.js:run",
"args": ["SELECT login, password FROM users;"]
}'
We should always keep mind that our telefunctions are public and need protection.
throw Abort()
As we've seen in the previous section, the following telefunction is not safe.
// run.telefunc.js
// Environment: Server
export { run }
async function run(sql) {
return await database.execute(sql)
}
But we can throw Abort()
to protect it:
// run.telefunc.js
// Environment: Server
export { run }
import { Abort, getContext } from 'telefunc'
async function run(sql) {
// We'll talk about `getContext()` later
const { user } = getContext()
// Only admins are allowed to run this telefunction
if (user.isAdmin !== true) {
throw Abort()
}
return await database.execute(sql)
}
By using throw Abort()
we essentially cancel forbidden telefunction calls:
// TodoList.telefunc.js
// Environment: Server
// `onLoad()` is a public: it can be called not only by our frontend but really by anyone
export { onLoad }
import { getContext } from 'telefunc'
async function onLoad() {
const { user } = getContext()
// We forbid `onLoad()` to be called by a user that is not not logged-in
if (!user) {
throw Abort()
}
const todoList = await Todo.findMany({ authorId: user.id })
return todoList
}
Here, in essence, we use
throw Abort()
to implement a permission: only a logged-in user can fetch its to-do items. We talk more about permissions at Guides > Permissions.
In principle, we could also
throw new Error()
instead ofthrow Abort()
as it also achieves the job of canceling the telefunction, butAbort()
comes with many conveniences and we therefore recommend usingthrow Abort()
.
If, upon canceling a telefunction call, we want to pass information to the frontend then we use return someValue
or throw Abort(someValue)
, which we talk more about at Guides > Permissions.
shield()
Also, since telefunctions are public and can be called by anyone, we cannot assuming anything about the arguments.
We can use throw Abort()
again to ensure the type of telefunction arguments:
// CreateTodo.telefunc.js
// Environment: Server
export async function onNewTodo(text) {
// While our frontend may always call `onNewTodo(text)` with `typeof text === 'string'`,
// `onNewTodo()` is public; anyone can call `onNewTodo(undefined)`.
// This may throw:
// Uncaught TypeError: Cannot read properties of undefined (reading 'toUpperCase')
text = text.toUpperCase()
// We can use `Abort()` to ensure that `text` is a string
if (typeof text !== 'string') {
throw Abort()
}
// We can now safely assume `text` to be a string
text = text.toUpperCase()
}
But this quickly becomes cumbersome:
// CreateTodo.telefunc.js
// Environment: Server
export async function onNewTodo(args) {
if (
args?.constructor !== Object ||
typeof args.text !== 'string' ||
typeof args.isCompleted !== 'boolean'
) {
throw Abort()
}
const { text, isCompleted } = args
/* ... */
}
For more convenience we can use shield()
instead of throw Abort()
:
// CreateTodo.telefunc.js
// Environment: Server
import { shield } from 'telefunc'
const t = shield.type
shield(onNewTodo, [text: t.string])
export async function onNewTodo(text) {
// ...
}
// CreateTodo.telefunc.js
// Environment: Server
import { shield } from 'telefunc'
const t = shield.type
shield(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 arguments' type for IntelliSense and TypeScript.
Because telefunctions are public, any of our telefunction can be called by anyone at any time with any arguments.
One way to think about this is that any random telefunction call can happen at any time.
This means we should always protect our telefunctions, even if know our frontend to call a telefunction only in a certain way. For example:
// Comment.jsx
// Environment: Browser
import { onCommentDelete } from './Comment.telefunc.js'
function Comment({ id, text, context }) {
const deleteButton =
// Note how we only show the delete button to admins
context.user.isAdmin ?
<button onClick={() => onCommentDelete(id)}>Delete</button> :
null
return <>
<p>{ text }</p>
{ deleteButton }
</>
}
Because our frontend shows the delete button only to admins, we can assume the user to be an admin whenever our frontend calls onCommentDelete()
.
But we still need to use throw Abort()
to protect our telefunction against calls not originating from our frontend.
// Comment.telefunc.js
// Environment: Server
import { getContext, Abort, shield } from 'telefunc'
shield(onCommentDelete, [shield.type.number])
export async function onCommentDelete(id) {
const { user } = getContext()
// `onCommentDelete()` is public and anyone can call it while not being an admin.
// If that happens, we make to sure to cancel the telefunction call.
if (!user.isAdmin) {
throw Abort()
}
// ...
}