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).
npx @celljs/cli init fullstack my-appThe 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.
src/browser/pages/
├── _layout.tsx around every page below it
├── index.tsx /
├── about.tsx /about
└── users/
├── index.tsx /users
└── [id].tsx /users/:id// 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></>;
}// 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].tsxmatches the rest of the path:pages/[...rest].tsxis the page for unknown URLs._layout.tsxgets the page aschildren. 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 (ErrorFallbackPropsof@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.tsxandabout/index.tsx) fail withCELL_ROUTE_CONFLICT. - No imports to add anywhere:
cell devpicks up added and removed pages. The routes are generated into.cell/generated/frontend/routes.js, andnpx cell info --jsonlists 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:
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.
// src/common/greeting-protocol.ts: imported by both sides
export const GreetingServer = Symbol('GreetingServer');
export interface GreetingServer {
greet(name: string): Promise<string>;
}// 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}!`;
}
}// 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 HTTPPOSTat/<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
ApplicationErroras itself, others as anRpcErrorwithstatus, whose message is hidden in production unless it is a 4xxHttpError. - Parameters typed with a class are validated when
@celljs/validationis installed, as for controllers. - In tests,
app.get(GreetingServer)fromcreateTestApp()calls the implementation directly.
The backend calls the frontend: WebSocket
rpc:
transport: websocketCalls 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):
// 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);
}
}// 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:
import { Component } from '@celljs/core';
import { Autorpc } from '@celljs/rpc';
@Component()
export class Greeter {
@Autorpc(GreetingServer)
protected readonly greetingServer: GreetingServer;
}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.