RPC

This page explains:

  • What RPC is
  • How RPC (such as Telefunc) works
  • 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: server
 
export { hello }
 
// hello() always runs on the server-side
async 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>

We will show later how it works.

This practice — calling functions remotely in a seamless fashion — is known as Remote Procedure Call (RPC).

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.

// 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
}

.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 section
import { 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: server
 
export { onNewTodo }
 
import { shield } from 'telefunc'
 
// We'll talk about shield() later
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: client
 
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>
  )
}

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.

See RPC vs GraphQL/REST.

How it works

Understanding the basic mechanics of Telefunc is paramount in order to proficiently use it.

Let's see what happens when a telefunction is called.

// hello.telefunc.js
// Environment: server
 
export { hello }
 
async function hello({ name }) {
  const message = 'Welcome ' + name
  return { message }
}
// Environment: client
 
import { hello } from './hello.telefunc.js'
 
const { message } = await hello('Eva')

The hello.telefunc.js file is never loaded in the browser: instead 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)

When hello('Eva') is called in the browser-side, the following happens:

  1. On the browser-side, the __internal_makeHttpRequest() function makes an HTTP request to the server.
    POST /_telefunc HTTP/1.1
    {
      "path": "/hello.telefunc.js:hello",
      "args": [{"name": "Eva"}]
    }
    
  2. On the server-side, the Telefunc middleware:
    // server.js
     
    // Server (Express.js/Hono/Fastify/...)
     
    import { telefunc } from 'telefunc'
     
    // Telefunc middleware
    app.use('/_telefunc', async (req, res) => {
      const httpResponse = await telefunc(req)
      res.send(httpResponse.body)
    })
    Replies following HTTP response:
    HTTP/1.1 200 OK
    {
      "return": {
        "message": "Welcome Eva"
      }
    }
    

In other words, the hello() function is always executed on the server-side while the browser-side can remotely call it in a seamless fashion.

You can also call telefunctions from the server-side, in which case the telefunction is directly called (without making an HTTP request).

Telefunctions need protection

Your telefunctions can be remotely called not only by your frontend, but by anyone.

For example, anyone can call the hello() telefunction we've seen in the previous section by opening a Linux terminal and making this HTTP request:

curl https://your-website.com/_telefunc --data '{
   "path": "/hello.telefunc.js:hello",
   "args": [{"name": "Elisabeth"}]
 }'

Thus, such telefunction is problematic:

// sql.telefunc.js
// Environment: server
 
// run() is public: it can be called by anyone
export { 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:

curl https://your-website.com/_telefunc --data '{
    "path": "/run.telefunc.js:run",
    "args": ["SELECT login, password FROM users;"]
  }'

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 anyone
export { 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 anyone
export { 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: server
 
export { 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: server
 
export 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: server
 
import { shield } from 'telefunc'
const t = shield.type
 
shield(onNewTodo, [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 type of the arguments for TypeScript and IntelliSense.

When using TypeScript, Telefunc can automatically generate shield() for each telefunction.

Random telefunction calls

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: client
 
import { 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: server
 
import { 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()
 
  // ...
}