Tour

Alreay familiar with RPC? Only read the last sections:

After taking the tour you'll have a comprehensive overview of what Telefunc is and be able proficiently use it.

Hello world

Telefunc enables functions defined on the server-side to be called remotely from the browser-side.

// hello.telefunc.js
// Environment: Node.js 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: Node.js server

export { getTodoItems }

async function getTodoItems() {
  // 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 { getTodoItems } from './TodoList.telefunc.js'

async function TodoList() {
  // Our frontend uses the telefunction `getTodoItems()` to execute a SQL/ORM query that
  // retrieves the data it needs.
  const todoItems = await getTodoItems()
  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: Node.js server

import { shield } from 'telefunc'

export { onNewTodo }

// 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 Overview > 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: Node.js 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
    import { createTelefunc } from 'telefunc'
    
    // Server middleware (Express.js/Fastify/Koa/Hapi/...)
    const { telefunc } = createTelefunc()
    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: Node.js 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.

Abort() & shield()

As we've seen in the previous section, the following telefunction is not safe.

// run.telefunc.js
// Environment: Node.js server

export { run }

async function run(sql) {
  return await database.execute(sql)
}

But we can throw Abort() to protect it:

// run.telefunc.js
// Environment: Node.js server

import { Abort, getContext } from 'telefunc'

export { run }

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

Also, since telefunctions are public and can be called by anyone, we cannot assuming anything about the arguments.

// CreateTodo.telefunc.js
// Environment: Node.js 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()
}

For more convenience we can use shield() instead of Abort():

// CreateTodo.telefunc.js
// Environment: Node.js server

import { shield } from 'telefunc'
const t = shield.type

shield(onNewTodo, [text: t.string])
export async function onNewTodo(text) {
  // ...
}

Not only does shield() call throw Abort() on our behalf, but it also infers the arguments' type for IntelliSense and TypeScript.

API > shield() lists all shield() types. (shield.type.string, shield.type.number, shield.type.array(), ...)

Authentication & permissions

We can authenticate users by using getContext() with provideTelefuncContext().

// server.js
// Environment: Node.js

import { provideTelefuncContext } from 'telefunc'

// Server middleware (Express.js/Fastify/Koa/Hapi/...)
app.all('/_telefunc', async (req, res) => {
  // Authentication middlewares (e.g. Passport.js or Grant) usually provide information
  // about the logged-in user on the `req` object.
  const user = req.user

  // Or when using a third-party authentication provider (e.g. Auth0):
  const user = await authProviderApi.getUser(req.headers)

  // We make `user` available to our telefunctions
  provideTelefuncContext({ user })

  // The usual Telefunc integration
  const httpResponse = await telefunc({ url: req.url, method: req.method, body: req.body })
  const { body, statusCode, contentType } = httpResponse
  res.status(statusCode).type(contentType).send(body)
})
// TodoList.telefunc.js
// Environment: Node.js server

import { getContext } from 'telefunc'

export { getTodoItems }

async function getTodoItems() {
  // We can access `user` here
  const { user } = getContext()

  if (!user) {
    // We can use `throw Abort()` to ensure that the user is logged-in
    throw Abort()
  }

  // We can use `user.id` to find all to-do items of the logged-in user
  const authorId = user.id
  const todoItems = await Todo.findMany({ select: 'text', authorId })
  return todoItems
}

In general, with Telefunc, we implement permissions programmatically by using a cancel mechanism such as throw new Abort().

// TodoItem.telefunc.js
// Environment: Node.js server

import { Abort, getContext, shield } from 'telefunc'
const t = shield.type

export { onTextChange }

shield(onTextChange, [t.number, t.string])
async function onTextChange(id, text) {
  const { user } = getContext()

  const todoItem = await Todo.findOne({ id })

  // Only the author of a to-do item is allowed to change its text
  if (user.id !== todoItem.authorId) {
    throw Abort()
  }

  todoItem.text = text
  await todoItem.save()
}

Inversion of control

Coming from the REST/GraphQL world, we may be tempted to implement generic telefunctions.

// todo.telefunc.js
// Environment: Node.js server

import { shield } from 'telefunc'
const t = shield.type

// Generic telefunctions about the data model `Todo`.

shield(getTodoItems, [t.boolean])
export async function getTodoItems(isCompleted) {
  const todoItems = await Todo.findMany({ isCompleted })
  return todoItems
}

shield(updateTodoItem, [t.number, t.string, t.boolean])
export async function updateTodoItem(id, text, isCompleted) {
  const todoItem = await Todo.findOne({ id })
  todoItem.text = text
  todoItem.isCompleted = isCompleted
  await todoItem.save()
}
// user.telefunc.js

import { getContext } from 'telefunc'

// Generic telefunctions about the data model `User`.

export function getUserData() {
  const { user } = getContext()
  // All data that the frontend may need
  const { firstName, lastName, age, country/*, ...*/ } = user
  return { firstName, lastName, age, country/*, ...*/ }
}

But this is not Telefunc idiomatic; we usually define telefunctions tailored to frontend components.

// TodoList.telefunc.js

// This file provides the telefunctions that `TodoList.jsx` needs.

import { getContext } from 'telefunc'

export { getInitialData }

// This returns *exactly* what `<TodoList>` needs.
async function getInitialData() {
  const { user } = getContext()

  // `<TodoList>` only shows non-completed to-dos.
  const todoItems = await Todo.findMany({ isCompleted: false })

  // `<TodoList>` only shows `user.firstName`
  const { firstName } = user

  return {
    user: { firstName },
    todoItems
  }
}
// UserProfile.telefunc.js

// Telefunctions for `UserProfile.jsx`.

import { getContext } from 'telefunc'

export { getInitialData }

export async function getInitialData() {
  const ctx = getContext()
  // `<UserProfile>` shows all user data.
  const user = { ...ctx.user }
  delete user.password
  return { user }
}
// TodoItem.telefunc.js

import { shield } from 'telefunc'
const t = shield.type

// Common Telefunc practice: one telefunction per user event.

// When the user modifies the text of a to-do item
shield(onTextChange, [t.number, t.string])
export async function onTextChange(id, text) {
  const todoItem = await Todo.findOne({ id })
  todoItem.text = text
  await todoItem.save()
}

// When the user clicks on the is-completed checkbox
shield(onCompleteToggle, [t.number])
export async function onCompleteToggle(id) {
  const todoItem = await Todo.findOne({ id })
  todoItem.isCompleted = !todoItem.isCompleted
  await todoItem.save()
}

The telefunctions of TodoItem.telefunc.js are determined by the needs of TodoItem.jsx; it is the frontend development/developers that own telefunctions.

This is the opposite of GraphQL/REST where it is the backend development/developers that own the GraphQL/RESTful API.

This inversion of control leads to a fundamentally improved separation of concerns.

TypeScript

Telefunc automatically generates the shield() of telefunctions. There is nothing to do, it just works.

This means that, not only do we seamlessly use types across frontend-backend, but we also get runtime type-safety for free.

// CreateTodo.telefunc.ts
// Environment: Node.js server

// Telefunc aborts if a third-party calls `onNewTodo(42)`.
export async function onNewTodo(text: string) {
  // Note that `todoItem` is typed by the ORM
  const todoItem = new Todo()
  todoItem.text = text
  await todoItem.save()
  const { id, updatedAt } = todoItem
  return { id, updatedAt }
)
// CreateTodo.tsx
// Environment: Browser

// From TypeScript's perspective, `CreateTodo.telefunc.js` is imported. (TypeScript
// doesn't know that `.telefunc.js` files are transformed.)
// I.e. TypeScript just works, including auto-import.
import { onNewTodo } from './CreateTodo.telefunc.js'

async function someCallback(text: string) {
  const todoItem = await onNewTodo(text)
  // We can seamlessly use the types defined by the ORM on the frontend
  const lastUpdate = 'To-do last update: ' + todoItem.updatedAt.toDateString()
  // ...
}

When using Telefunc with a TypeScript ORM (e.g. Prisma) or SQL builder (e.g. Kysely or others), we get end-to-end type safety all the way from database to frontend.