@evojs/ussr-cli
v0.5.5
Published
Build tooling and project generator for @evojs/ussr
Readme
@evojs/ussr
What is it?
USSR: Universal Server-Side Renderer. Based on preact, inversify and typescript. Inspired by Angular.
Why?
Because reasons.
Features
- Server-side rendering and browser hydration out of the box (
preact+preact-render-to-string). - Route tree with nested routes, path params, redirects, lazy components and data resolvers.
- Dependency injection based on
inversify: services, stores and agents shared by server and browser. - Zero-config webpack build (
ussrCLI): typescript, scss with CSS modules, env inlining, watch mode with server restart. - Project generator:
ussr new <name>.
Getting started
New project
npx @evojs/ussr-cli new my-app
cd my-app
npm install
npm run devOpen http://127.0.0.1:3000.
ussr new creates a working hello-world: server entry, browser entry, one route, one page,
scss with CSS modules, static assets and the dev / build / start / typecheck scripts.
Pass . to generate into the current directory and --force to write into a non-empty one.
Existing project
npm install @evojs/ussr preact inversify
npm install -D @evojs/ussr-cli typescriptThe build expects src/server.tsx, src/browser.tsx, tsconfig.json and a non-empty public/
in the project root. Requires Node.js >= 22.12.
CLI
The ussr binary is provided by @evojs/ussr-cli.
ussr # build once (development mode)
ussr --watch # build, watch and restart the server on change
ussr --mode production # production build
ussr --cwd ./packages/web # use another directory instead of $PWD
ussr new <name> [--force] # generate a new project--mode defaults to NODE_ENV or development.
--watch rebuilds both bundles and restarts dist/main.js through nodemon. There is no HMR and no
live reload: the browser page has to be refreshed after a rebuild.
Recommended scripts:
{
"scripts": {
"dev": "ussr --watch",
"build": "ussr --mode production",
"start": "node dist/main.js",
"typecheck": "tsc --noEmit"
}
}Project structure
Two things are fixed by the build: the entries must be src/server.tsx and src/browser.tsx, and
static files must live in public/. Everything else is up to you — below are the two layouts most
projects use.
Grouped by type (recommended, and what the generator produces)
Files are grouped by what they are. Familiar from Next.js / Nuxt / CRA projects and the best starting point for small and medium applications.
public/ static assets, copied to dist/public/ as is
src/
browser.tsx browser entry: hydrates the server markup
server.tsx server entry: renders html, serves dist/public/
routes.ts route tree
providers.ts DI providers (add it once you have services)
components/ reusable components
button/
button.component.tsx
button.component.scss
pages/ one page per route
home.page.tsx
home.page.scss
services/ api clients and business logic
article.service.ts
stores/ observable state (mobx)
session.store.ts
styles/ global styles, variables, mixins
main.scss
env.d.ts typings for import.meta.env
tsconfig.json
.envThe generator creates the minimal version of this layout: public/favicon.svg, src/routes.ts,
src/providers.ts, src/browser.tsx, src/server.tsx, src/env.d.ts, src/pages/home.page.tsx
with its .scss, src/services/cookie.service.ts, src/stores/counter.store.ts,
src/styles/main.scss, plus package.json, tsconfig.json, .env, .gitignore and README.md.
Grouped by feature
Files are grouped by domain, every feature owns its own routes, pages and services. Familiar from
Angular / NestJS projects; worth switching to when the flat pages/ directory stops being readable.
src/
browser.tsx
server.tsx
routes.ts composes feature routes
providers.ts
features/
articles/
articles.routes.ts
pages/
articles.page.tsx
article.page.tsx
components/
services/
article.service.ts
auth/
auth.routes.ts
pages/
services/
shared/ cross-feature components, services, types
components/
services/
styles/
env.d.ts// src/routes.ts
import { type Route } from '@evojs/ussr';
import { ARTICLES_ROUTES } from './features/articles/articles.routes';
import { AUTH_ROUTES } from './features/auth/auth.routes';
export const routes: Route[] = [...ARTICLES_ROUTES, ...AUTH_ROUTES];Both layouts follow the same file naming: *.page.tsx for routed pages, *.component.tsx for
components, *.service.ts, *.store.ts, and a .scss file next to the component that uses it.
tsconfig.json keeps baseUrl together with paths, so import { HomePage } from '~/pages/home.page'
works both for the type checker and for the bundler. Removing baseUrl disables the aliases.
The build always emits dist/main.js (server bundle) and dist/public/ with app.js,
vendor.js, app.css and everything copied from public/.
Entry points
src/server.tsx creates a container per request and renders html:
import { createContainer } from '@evojs/ussr';
import { renderForServer } from '@evojs/ussr/server';
import { createServer } from 'node:http';
import { ErrorPage } from './pages/error.page';
import { PROVIDERS } from './providers';
import { routes } from './routes';
const PORT = Number(process.env.PORT || 3000);
const VERSION = import.meta.env.EVO_APP_VERSION || '0';
createServer((req, res) => {
(async () => {
const container = createContainer(routes, PROVIDERS, { req, res });
const html = await renderForServer(container, {
errorPage: ErrorPage,
lang: 'en',
version: VERSION,
});
// empty buffer means renderForServer has already set a redirect on the response
if (!html.length) {
res.end();
return;
}
res.setHeader('content-type', 'text/html; charset=utf-8');
res.end(html);
})().catch((error: unknown) => {
console.error(error);
res.statusCode = 500;
res.end('Internal Server Error');
});
}).listen(PORT);renderForServer serializes the resolved data into a
<script type="application/json" id="__USSR_DATA__"> tag and references
/app.css, /vendor.js and /app.js with a ?v=<version> query, so the server has to serve
dist/public/. The generated src/server.tsx contains a small static file handler for that.
src/browser.tsx hydrates the same tree and is the place where global styles are imported:
import { createContainer } from '@evojs/ussr';
import { renderForBrowser } from '@evojs/ussr/browser';
import { ErrorPage } from './pages/error.page';
import { PROVIDERS } from './providers';
import { routes } from './routes';
import './styles/main.scss';
const container = createContainer(routes, PROVIDERS);
void renderForBrowser(container, { errorPage: ErrorPage }).catch(console.error);Routes
import { HttpException, type Route } from '@evojs/ussr';
import { type Container } from 'inversify';
import { AdminPage } from './pages/admin.page';
import { ArticlePage, type ArticlePageProps } from './pages/article.page';
import { ArticlesPage, type ArticlesPageProps } from './pages/articles.page';
import { ArticleService } from './services/article.service';
import { SessionStore } from './stores/session.store';
export const routes: Route[] = [
{
path: '',
component: ArticlesPage,
async resolve(container: Container): Promise<ArticlesPageProps> {
const articleService = container.get(ArticleService);
const { items, count } = await articleService.getPage(this.queryParams);
return { articles: items, count };
},
},
{
path: 'articles/:key',
component: ArticlePage,
async resolve(container: Container): Promise<ArticlePageProps> {
const articleService = container.get(ArticleService);
const article = await articleService.getOne(this.params.key);
return { article };
},
},
{
path: 'admin',
component: AdminPage,
async resolve(container: Container): Promise<{}> {
const session$ = container.get(SessionStore);
if (!(await session$.isAdmin())) {
throw new HttpException('Forbidden', 403);
}
return {};
},
children: [
{ path: '', redirectTo: 'orders' },
{
path: 'orders',
lazy: () => import('./pages/orders.page').then((m) => m.OrdersPage),
},
],
},
];pathis relative to the parent route;:namecaptures a param and:name(\\d+)constrains it with a regular expression.resolveruns on the server before rendering and in the browser before navigation. It is called with the container as an argument and with the router state asthis, sothis.params,this.queryParamsandthis.urlare available. The returned object becomes the component props.redirectTois resolved against the current url unless it starts with/.lazyloads the component on demand.childrenare rendered where the parent page puts<RouterOutlet />:import { RouterOutlet } from '@evojs/ussr'; export const AdminPage: FunctionComponent = () => ( <section> <h1>Admin</h1> <RouterOutlet /> </section> );Every resolver result is serialized into the html, so the browser does not re-request the data for the first rendered page.
Pages
A page is a plain preact component typed with the props its resolver returns:
import { Head, Link, useRouter } from '@evojs/ussr';
import { observer } from 'mobx-react';
import { type FunctionComponent } from 'preact';
import * as $ from './articles.page.scss';
import { type ArticleType } from '../services/article.service';
export const ArticlesPage: FunctionComponent<ArticlesPageProps> = observer(
(props: ArticlesPageProps) => {
const { articles, count } = props;
const router$ = useRouter();
return (
<main class={$.articles}>
<Head>
<title>Articles</title>
<meta name="description" content="All articles" />
</Head>
<h1>{count} articles</h1>
<ul>
{articles.map((article) => (
<li key={article._id}>
<Link href={`/articles/${article.key}`} activeClass={$.active}>
{article.title}
</Link>
</li>
))}
</ul>
<button onClick={() => router$.navigate('/articles', { page: '2' })}>Next page</button>
<p>Current url: {router$.state.url}</p>
</main>
);
},
);
export interface ArticlesPageProps {
articles: ArticleType[];
count: number;
}Headcollects its children (title, meta, link, script) and renders them into<head>.Linknavigates without a page reload, supportsqueryParamsandactiveClass, and falls back to a normal anchor for external or non-self targets.useRouter()returns the observableRouter:state.url,state.params,state.queryParams,state.fragment,state.matchedRoutes,state.payloads, plusnavigate(url, queryParams?). On the servernavigatethrows aRedirectExceptionwhich is turned into a 302 response.- State is tracked by mobx, so a component re-renders on navigation only when it is wrapped in
observerfrommobx-react.LinkandRouterOutletare already observers, application components have to opt in themselves.
Dependency injection
Services are ordinary classes; constructor parameters are injected by type.
import { Inject, Injectable, InjectableScope, Optional, REQUEST } from '@evojs/ussr';
@Injectable()
export class ArticleService {
constructor(
private readonly articleApi$: ArticleApiService,
private readonly toast$: ToastStore,
) {}
getOne(key: string): Promise<ArticleType> {
return this.articleApi$.getOne({ key });
}
}
@Injectable({ scope: InjectableScope.REQUEST })
export class SessionStore {
constructor(@Inject(REQUEST) @Optional() private readonly req?: import('http').IncomingMessage) {}
}Register them once and pass the list to createContainer:
export const PROVIDERS = [
ArticleApiService,
ArticleService,
ToastStore,
{ provide: 'API_URL', useValue: import.meta.env.EVO_API_URL },
];Inside components use the useInjection hook:
const articleService = useInjection(ArticleService);
const apiUrl = useInjection<string>('API_URL');REQUEST and RESPONSE tokens are bound automatically and are undefined in the browser.
Disposer, HeadManager, Router and the Container itself are always available.
Provider scopes: InjectableScope.DEFAULT (singleton per container), TRANSIENT, REQUEST.
Disposer collects cleanups of the current container: add(disposer) takes a
DisposeFn (() => void | Promise<void>). On the server renderForServer awaits every
collected disposer once the render is done, which is the counterpart of an effect cleanup —
nothing is unmounted during renderToString, so mobx reactions, subscriptions and timers
created by request-scoped classes have to be collected there.
@Injectable({ scope: InjectableScope.REQUEST })
export class ArticleStore {
constructor(disposer: Disposer) {
disposer.add(autorun(() => { /* ... */ }));
}
}Errors and redirects
import { HttpException, RedirectException } from '@evojs/ussr';
throw new HttpException('Not found', 404, { key });
throw new RedirectException('/auth/login', 302);- A
HttpExceptionthrown from a resolver or a component is rendered by the error page and itsstatusCodeis applied to the response. - A
RedirectException(orrouter.navigateon the server) makesrenderForServerreturn an empty buffer withLocationand the redirect status already set on the response.
When a page fails on the server (for example a resolver or a component throws), renderForServer
still emits the full document with the client bundles, but serializes the error into the
__USSR_DATA__ payload instead of the resolved data. The client reads the same payloads, so
renderForBrowser renders the same error page instead of re-running the failed route (it also skips
lazy route resolution). The error's message, statusCode, details and stack survive the
round-trip, which keeps hydration consistent.
- The built-in
ErrorPagecan be replaced with theerrorPageoption ofrenderForServer/renderForBrowser. A custom one receives{ error }and may set the status itself:
import { type HttpException, RESPONSE, useInjection } from '@evojs/ussr';
export const ErrorPage: FunctionComponent<{ error: Error }> = ({ error }) => {
const res = useInjection<import('http').ServerResponse | undefined>(RESPONSE);
const statusCode = (error as HttpException).statusCode || 500;
if (res) {
res.statusCode = statusCode;
}
return <h1>{statusCode}</h1>;
};Styles
Every .css/.scss file outside node_modules is compiled as a CSS module, so class names are
hashed and have to be imported:
import * as styles from './articles.page.scss';
<main class={styles.articles} />;The *.css, *.scss and *.sass module declarations ship with the package, so a project only has
to reference them once:
// src/env.d.ts
/// <reference types="@evojs/ussr/scss" />Tag selectors are not hashed, which makes a global stylesheet (imported from the browser entry) the
right place for resets, fonts and typography. All styles reachable from the browser entry end up in
dist/public/app.css.
There is also an svg() sass function that inlines a file, resolved from the project root, as a
data url:
.icon {
background-image: svg('assets/icons/arrow.svg');
}Environment variables
.env is loaded by the CLI, then .env.<mode> overrides it when the file exists. Variables
prefixed with EVO_ are inlined into both bundles as import.meta.env.EVO_*; everything else stays
in process.env and is therefore server-only.
HOST=127.0.0.1
PORT=3000
EVO_APP_NAME=my-app
EVO_API_URL=https://api.example.com// src/env.d.ts
/// <reference types="@evojs/ussr/scss" />
interface ImportMeta {
readonly env: ImportMetaEnv;
}
interface ImportMetaEnv {
readonly EVO_APP_NAME: string;
readonly EVO_API_URL: string;
}In watch mode the server process inherits the environment of the build, so .env also applies at
runtime. In production (node dist/main.js) .env is not read — pass real environment variables.
Production
npm run build # ussr --mode production
npm start # node dist/main.jsnode_modules always go into a separate vendor.js chunk. The production build minifies the js
bundles, compiles scss with sass in compressed mode, runs the extracted css through
css-minimizer-webpack-plugin, and emits no source maps, while the development build adds them.
Ship dist/ as a whole:
main.js resolves its static directory relative to itself (dist/public/).
Browser support
Declare supported browsers with a browserslist config (a browserslist field in package.json,
as the generator does, or a .browserslistrc). The build then applies it in three places:
autoprefixer adds vendor prefixes, cssnano adjusts css optimizations, and webpack targets
browserslist, which lowers the ecma level of the webpack runtime and of the default js
minimizer. Without a browserslist config the build falls back to plain target: 'web'.
The syntax of the application code itself is not downleveled: ts-loader compiles it according to
compilerOptions.target in tsconfig.json (esnext in the starter). To support browsers that do
not understand the modern syntax, lower tsconfig.target or add a transpiler step
(babel / swc) on top — browserslist alone will not do that.
Serving and compression
The server entry serves dist/public/ with correct content types and never applies
Content-Encoding itself. For production put the Node process behind a reverse proxy
(nginx, caddy, traefik) or a CDN that handles gzip/brotli compression, caching and TLS; the
framework does not attempt to replicate that.
License
Licensed under MIT license
