RPC

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.

Hello world

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).

ORM & SQL

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 after TodoList.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.

How it works

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:

  1. On the browser-side, the __internal_makeHttpRequest() function makes an HTTP request to our server.
    POST /_telefunc HTTP/1.1
    {
      "path": "/hello.telefunc.js:hello",
      "args": [{"name": "Eva"}]
    }
    
  2. On the server-side, our Telefunc middleware:
    // 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)
    })
    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, and the browser-side remotely calls hello() by using an HTTP request.

Telefunctions need protection

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 of throw Abort() as it also achieves the job of canceling the telefunction, but Abort() comes with many conveniences and we therefore recommend using throw 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.

Random telefunction calls

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()
  }

  // ...
}