Editor’s note: This post was updated on 29 December 2022 to include mention of new features.
Syncing backend API changes to the client app is a common problem frontend developers face. When the backend API interface is changed, the client app may need to be manually updated to prevent an error or crash.
Phero is designed to solve this problem. Developed by the people at Press Play in 2020, it was initially created to manage types and interfaces for their software product, which consumes a large number of API endpoints. Phero is developed using TypeScript and works with any framework based on TypeScript. It will process the backend code and generate a type-safe client for the frontend.
Using Phero, you can call the backend services/functions from the frontend just as you would call a local function. It’s been open source since September 2022.
Let’s take a closer look at Phero.
Jump ahead:
Let’s use a typical Node.js/Express web API as an example. We expose an endpoint like below:
# API export enum UserRole { ADMIN = "admin", USER = "user", } export interface User { name: string; age: number; role: UserRole; } export interface Users { [key: number]: User; } app.get("users/:userId", (req, res) => { res.send( { name: "Jane", age: 30, role: UserRole.USER }); });
In the client app, we need to create another set of type/interfaces and then cast the response JSON to get a typed response object.
# Client App export enum UserRole { ADMIN = "admin", USER = "user", } export interface User { name: string; age: number; role: UserRole; } const response = await fetch(`${Root_URL}/users/1`); const user = await response.json() as User;
There are a few issues with this pattern. Firstly, there are duplicated types and interfaces on both the client and server sides. This duplication will make the app hard to maintain.
Secondly, when the API changes (i.e., a new UserType
is added), the client app may crash if it’s not updated — and even if you did update the client, the older version of the client app will still have the same problem. Thus, both the server and client need to be updated at the same time, to ensure backward compatibility.
To overcome the above issues, one common practice is to add a JSON response to the client code. But the extra validation requires additional work and is not safe enough because it can’t catch issues at compile time.
Phero provides a clean solution to this problem.
Under the hood, Phero makes use of remote procedure call (RPC). RPC is a communication protocol that allows a program on one machine to call a service on another machine without knowing it’s remote.
The below diagram shows the Phero client and service communication using RPC.
You can use any TypeScript-based framework to build your backend and frontend; otherwise, a Phero-enabled app comprises three components:
Phero.ts
; you also have the flexibility to organize the server project structure as long as the Phero file exposes the functionsPhero makes use of the TypeScript compiler API to analyze types and interfaces. It also generates the validator to parse the incoming and outgoing data between the client and server. It exposes all the domain types and interfaces exposed by the backend; thus, a compile-time error will be thrown if a typo or incorrect data is returned.
The benefits of Phero include:
Let’s walk through the process of building a Phero app. Since Phero is a pure TypeScript library, the only dependencies are npm and TypeScript.
Firstly, let’s initialize a Node.js app. We create an api
directory below. It can be named anything you wish.
# bash script # Create a Server folder mkdir api cd ./api # Initialise the server app, and get TypeScript ready npm init -y npm install typescript --save-dev npx tsc --init ​# edit tsconfig.json file to add the following entries # it is a temporary workaround until the bug is fixed in Phero CLI { "compilerOptions": { ... "rootDir": "src", "outDir": "dist" } } # Add Phero to your backend project npx phero init server
After the above commands are completed successfully, you will have a new phero.ts
file in the api/src
folder.
# api/src/phero.ts import { createService } from '@phero/server' async function helloWorld(name: string): Promise<string> { return `Hi there, ${name}!` } export const helloWorldService = createService({ helloWorld })
The generated Phero.ts
file exposes a helloWorld
service and is ready to be consumed by the client.
Let’s run the following script to set up the client. We create an app
subfolder, but it can be named something else. The Phero CLI can scan for dependencies to determine whether a project is a phero-server
or phero-client
project.
# bash script # from your project root directory, create a app subfolder mkdir app cd ../app # initialise a node app npm init -y npm install typescript --save-dev npx tsc --init # Add Phero to your frontend project: npx phero init client
The phero init client
command installs the @phero/client
and generates a Phero.ts
file.
# app/src/Phero.ts import { PheroClient } from "./phero.generated"; const fetch = window.fetch.bind(this); const client = new PheroClient(fetch); async function main() { const message = await client.helloWorldService.helloWorld('Jim') console.log(message) // `Hi there, Jim!` } main()
Now we can run the Phero CLI to start the development environment.
# back to the project root directory cd ../ npx phero
The above command starts the server and generates an SDK for the frontend (or multiple frontends) at the same time. You should see the following output in the console:
In the frontend project, a phero.generated.ts
file will be generated. It’s the RPC client stub used to connect to the server stub. As the name indicates, it shouldn’t be manually updated. It’s automatically synced with any server-side change when the CLI is running.
Although the basic setup is completed now, we can’t run the client to prove that it’s working because we don’t have a proper client app to run in a browser yet.
Let’s build a React client app to test it end-to-end.
Run the following command to generate a skeleton of a React app.
npx create-react-app react-app --template typescript
Then, you can start the app as below.
cd react-app npm start
Now we have a working React app, so the next step is to add Phero to it.
# bash script npx phero init client cd .. # Back to project root directory # run Phero CLI to start the dev environment npx phero
The above command will generate the phero.generated.ts
file in the React project.
The last step is to open the src/App.tsx
and replace the original code with the following code snippet:
import { PheroClient } from './phero.generated'; import { useCallback, useEffect } from "react"; const phero = new PheroClient(window.fetch.bind(this)) function App() { const getHello = useCallback(async () => { const message = await phero.helloWorldService.helloWorld('Jim'); console.log(message); }, []) useEffect(() => { getHello() }, [getHello])
The above code snippet will initialize a PheroClient
, and makes a call to the helloWorldService.helloWorld
method.
To verify the app is working, open http://localhost:3000/
and open Chrome DevTools. You can see the console message and the client-to-server RPC call in the Network tab.
You can find the related source code in the GitHub repo.
Using Phero, we can handle the custom server-side error in our frontend just like we would a local function.
Let’s keep going with the previous API example. if a user can’t be found by their given user ID, we want to throw a UserNotExistException
error on the backend.
class UserNotExistException extends Error { constructor(userId: number) { super() } } app.get("users/:userId", (req, res) => { const user = db.getById(userId); if(user === null) { throw new UserNotExistException(userId); } return user; });
Then at the frontend, we can handle the error as below. Please note that the UserNotExistException
is exposed in the client by the generated Phero file.
import { PheroClient, UserNotExistException, } from "./phero.generated" const fetch = window.fetch.bind(this) const client = new PheroClient(fetch) try { const user = await client.userService.getUser(1) } catch (error) { if (error instanceof UserNotExistException ) { console.log("User not found") } else { console.log("Unexpected error") } }
Again, we gain type-safety in error handling with Phero. In addition, the userId
is populated as a property of the error on the server side, and it is accessible on the client side via error.userId
.
At the local development environment, the default Phero server API runs at port 3030
. When deploying to a higher environment or production, you may need to use a different port number. Same as any Node.js API, the port number is configurable by passing in an environment variable PORT
at runtime or using a .env
file.
For example, to run the server API at port 8080
:
cd ./.build PORT=8080 node index.js
In a non-local environment, you may also need to make the API URl configurable on the client side. We can store the URl in an environment variable and pass the URL to PheroClient
as below.
import { PheroClient } from "./phero.generated" const client = new PheroClient(fetch, `${API_ROOT}`)
To build a bundle of server code for deployment, run the command below.
cd ./server npx phero server export
This will generate the bundle files into a .build
directory. The server can be started with a standard Node.js command.
At the moment, the Phero server can run on any Node-based server. The following new export and deploy features for are available for different cloud platforms:
If you want to migrate an existing server app to use Phero, you may want to restructure your Phero app first.
Phero doesn’t come with lots of restrictions. You just need to define the Phero.ts
as the entry point of the backend app and export your services from the Phero file. You can design your services and functions using any TypeScript-based framework.
Let’s say we need to migrate a Node.js/Express API app to Phero. We need to define clear “groups” of functions that need to be called from the frontend. In Express, you would typically group them in “Routers
“. We can covert these groups into services in Phero. All routes you currently have inside these routers can be migrated to regular TypeScript functions.
As an example, this function:
router.post('/example-route', (req, res) => { const data: MyData = req.body res.send({ output: 123 }) })
Would become the following after router migration:
async function exampleFunction(data: MyData): Promise<{ output: number }> { return { output: 123 } }
When designing the new Phero service interface, you should think about the types. If you’re only using primitive types like string and number, the type safety that comes with Phero is probably overkill for you. Defining well-constrained types in the Phero backend will help you detect errors in compile time at the frontend.
Phero is a type-safe glue between the backend and frontend. It’s a viable solution for ensuring better type safety in frontend development. Using Phero, you can call backend functions or handle backend errors from the frontend with end-to-end type safety. It will help you to write more concise code and have peace of mind in refactoring.
I hope you find this article useful. Happy coding!
LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.
In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.