Skip to content

Frontend and RPC ​

A Cell application can have a React frontend in the same project as its backend. cell dev serves both on one port (HMR for the frontend, hot reload for the backend), and cell build puts the frontend into .cell/output/public/, served by the backend through @celljs/serve-static or by the platform's CDN (Cloudflare, Vercel).

bash
npx @celljs/cli init fullstack my-app

The fullstack template depends on @celljs/react (which adds the frontend build target), @celljs/rpc and @celljs/serve-static.

Pages ​

Pages are the files of src/browser/pages/; the default export of each is the page component.

text
src/browser/pages/
├── _layout.tsx        around every page below it
├── index.tsx          /
├── about.tsx          /about
└── users/
    ├── index.tsx      /users
    └── [id].tsx       /users/:id
tsx
// src/browser/pages/users/[id].tsx
import { Link, useParams } from '@celljs/react';

export default function User() {
    const { id } = useParams();
    return <><h1>User {id}</h1><Link to="/users">All users</Link></>;
}
tsx
// src/browser/pages/_layout.tsx
import { NavLink } from '@celljs/react';
import type { PropsWithChildren } from 'react';

export default function Layout({ children }: PropsWithChildren) {
    return <><nav><NavLink to="/">Home</NavLink> <NavLink to="/users">Users</NavLink></nav>{children}</>;
}
  • [...rest].tsx matches the rest of the path: pages/[...rest].tsx is the page for unknown URLs.
  • _layout.tsx gets the page as children. Layouts nest, outermost first, and stay mounted while the user moves between their pages; pages without one get the @DefaultLayout.
  • A page that throws, or whose chunk does not load, shows an error inside its layout instead of blanking the application, and is tried again when the user goes to another path. The nearest _error.tsx (its directory or above) is what shows, with { error, reset } as props (ErrorFallbackProps of @celljs/react); without one, a message and a button to try again.
  • Files and directories whose name starts with _, and tests, are not pages: keep the components of a page next to it in _components/.
  • Every page is a chunk of its own, loaded when it is visited. Two files for one path (about.tsx and about/index.tsx) fail with CELL_ROUTE_CONFLICT.
  • No imports to add anywhere: cell dev picks up added and removed pages. The routes are generated into .cell/generated/frontend/routes.js, and npx cell info --json lists them (frontend.routes).

Routing is React Router 7. @celljs/react re-exports what pages need: Link, NavLink, Navigate, Outlet, useParams, useNavigate, useLocation, useSearchParams, useMatch. react.router.type in cell.yml chooses browser (default) or hash routing. @App, @DefaultLayout and @Router from @celljs/react replace the root component, the default layout and the router; a package adds pages with @View (see the reference).

src/browser/module.ts is the DI module of the frontend: it imports the files of your frontend services (see below).

The frontend gets the configuration keys components mark public and your app.public; they are evaluated at build time and visible to anyone. In the browser, read it with useValue('html.title') in a React component and @Value in a DI component.

Services in components ​

Frontend services (state, API clients, anything shared) are DI components, as on the backend. React components get them, and configuration, with hooks from @celljs/react:

tsx
import { useInject, useValue } from '@celljs/react';
import { UserService } from './user-service';             // @Component(UserService) in a file module.ts imports

function Header() {
    const userService = useInject<UserService>(UserService); // resolved once per component instance
    const title = useValue<string>('app.public.title', 'My app');
    return <header>{title}: {userService.name}</header>;
}

The shell provides the application's container through ContainerContext. To test a component, render it inside <ContainerContext.Provider value={container}> with a container of your own. Keep pages, layouts and contexts plain React components; what they use comes from the container.

useRpc(id) gives a backend service (next section) and useLocale() gives [locale, setLocale]: the language the user selected among widget.locales, kept in localStorage. For translated messages, install react-intl and import @celljs/react/lib/browser/intl once in src/browser/module.ts; useIntl() and <FormattedMessage> then follow the selected locale. The hooks are listed in the README of @celljs/react.

Calling the backend: RPC ​

Instead of writing HTTP endpoints and fetch calls for your own frontend, share an interface and call it like a local object.

ts
// src/common/greeting-protocol.ts: imported by both sides
export const GreetingServer = Symbol('GreetingServer');

export interface GreetingServer {
    greet(name: string): Promise<string>;
}
ts
// src/node/greeting-server.ts: imported by src/node/module.ts
import { Rpc } from '@celljs/rpc';
import { GreetingServer } from '../common/greeting-protocol';

@Rpc(GreetingServer)
export class GreetingServerImpl implements GreetingServer {
    async greet(name: string): Promise<string> {
        return `Hello, ${name}!`;
    }
}
tsx
// src/browser/pages/index.tsx
import { useRpc } from '@celljs/react';
import { GreetingServer } from '../../common/greeting-protocol';

export default function Home() {
    const greetingServer = useRpc<GreetingServer>(GreetingServer);
    const [message, setMessage] = React.useState('Loading…');
    React.useEffect(() => {
        greetingServer.greet('browser').then(setMessage);
    }, [greetingServer]);
    return <h1>{message}</h1>;
}
  • @Rpc(Id) is a component (inject services into it as usual) and also an endpoint: JSON-RPC over HTTP POST at /<rpc.path>/<Id>, by default /api/GreetingServer (the description of the symbol).
  • Every method returns a Promise; arguments and results travel as JSON (JSON-RPC 2.0). Only the methods the class declares can be called. An error thrown on the server rejects the promise in the caller: an ApplicationError as itself, others as an RpcError with status, whose message is hidden in production unless it is a 4xx HttpError.
  • Parameters typed with a class are validated when @celljs/validation is installed, as for controllers.
  • In tests, app.get(GreetingServer) from createTestApp() calls the implementation directly.

The backend calls the frontend: WebSocket ​

yaml
rpc:
  transport: websocket

Calls then go over one WebSocket connection per browser tab (/<rpc.path>/ws), through the same middlewares, session and security as over HTTP, and the backend can call the frontend back. Declare what the frontend offers with @RpcClient (an interface in src/common/, the class in src/browser/, imported by its module):

ts
// src/browser/notice-client.ts
import { RpcClient } from '@celljs/rpc';
import { NoticeClient } from '../common/notice-protocol';

@RpcClient(NoticeClient)
export class NoticeClientImpl implements NoticeClient {
    async notify(text: string): Promise<void> {
        console.log(text);
    }
}
ts
// in an @Rpc service of the backend
import { RpcContext } from '@celljs/rpc/lib/node';

const connection = RpcContext.requireConnection();          // the caller's tab
await connection.client<NoticeClient>(NoticeClient).notify('order shipped');
connection.onClose(() => { /* the tab went away */ });

RpcConnectionManager (@celljs/rpc/lib/node) lists every open connection, to notify all tabs. Where no connection can be made (the serverless presets, a proxy without WebSocket support) calls use HTTP, and RpcContext.requireConnection() throws RpcNoConnectionError. Pages of other origins cannot connect unless rpc.websocket.allowedOrigins lists them. The reference has the other keys.

From another backend ​

A backend calls a service of another Cell backend with an injected proxy:

ts
import { Component } from '@celljs/core';
import { Autorpc } from '@celljs/rpc';

@Component()
export class Greeter {
    @Autorpc(GreetingServer)
    protected readonly greetingServer: GreetingServer;
}
yaml
rpc:
  defaultEndpoint: "http://greetings.internal:3000{rpcPath}/{serviceIdentifier}"
  endpoint:                                    # per service, wins over defaultEndpoint
    GreetingServer: "${env.GREETING_URL}"

RpcUtil.create<T>(endpoint, Id) makes the same proxy without the container.

Frontend-only and backend-only projects ​

The targets follow the installed components: @celljs/react adds frontend, and backend is always there. targets in cell.config.ts sets them explicitly.

The @celljs/react reference lists the annotations and configuration keys.