npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

@evojs/ussr npm version supported node version for @evojs/ussr total npm downloads for @evojs/ussr monthly npm downloads for @evojs/ussr npm licence for @evojs/ussr

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 (ussr CLI): 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 dev

Open 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 typescript

The 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
.env

The 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),
      },
    ],
  },
];
  • path is relative to the parent route; :name captures a param and :name(\\d+) constrains it with a regular expression.

  • resolve runs 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 as this, so this.params, this.queryParams and this.url are available. The returned object becomes the component props.

  • redirectTo is resolved against the current url unless it starts with /.

  • lazy loads the component on demand.

  • children are 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;
}
  • Head collects its children (title, meta, link, script) and renders them into <head>.
  • Link navigates without a page reload, supports queryParams and activeClass, and falls back to a normal anchor for external or non-self targets.
  • useRouter() returns the observable Router: state.url, state.params, state.queryParams, state.fragment, state.matchedRoutes, state.payloads, plus navigate(url, queryParams?). On the server navigate throws a RedirectException which is turned into a 302 response.
  • State is tracked by mobx, so a component re-renders on navigation only when it is wrapped in observer from mobx-react. Link and RouterOutlet are 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 HttpException thrown from a resolver or a component is rendered by the error page and its statusCode is applied to the response.
  • A RedirectException (or router.navigate on the server) makes renderForServer return an empty buffer with Location and 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 ErrorPage can be replaced with the errorPage option of renderForServer / 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.js

node_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