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.
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).
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
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: 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.
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:
__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
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)
})
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: 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 allshield()
types. (shield.type.string
,shield.type.number
,shield.type.array()
, ...)
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()
}
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.
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.