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

ugly-app

v0.1.207

Published

A full-stack TypeScript framework for building production-ready web applications. Scaffold with `npx ugly-app init my-app` and get an opinionated Express + React + MongoDB stack with built-in auth, real-time WebSockets, AI generation, storage, and a CLI f

Readme

ugly-app

A full-stack TypeScript framework for building production-ready web applications. Scaffold with npx ugly-app init my-app and get an opinionated Express + React + MongoDB stack with built-in auth, real-time WebSockets, AI generation, storage, and a CLI for every workflow.

What's included

  • Server: Express + WebSocket with type-safe RPC and Zod validation
  • Client: React + Vite with typed routing, lazy pages, and popup management
  • Database: MongoDB with typed collections, dot-notation updates, indexes, migrations, and live document tracking
  • Auth: JWT + HttpOnly cookies, ugly.bot OAuth out of the box, extensible via AuthProvider
  • AI: Text generation (Together, Claude, OpenAI, Google, Groq, Fireworks, Kie) + image generation (Together, FAL, Google, Wavespeed, Kie) + embeddings + STT/TTS
  • Storage: Cloudflare R2 / AWS S3 with presigned uploads
  • Web search: Kagi and UglyBot providers with search, summarize, and enrich
  • Billing: Usage-based billing with per-user/global limits, credits, and threshold alerts
  • CLI: ugly-app commands for dev, build, deploy, migrations, logs, and auth utilities

Quick start

npx ugly-app init my-app
cd my-app
npm run dev

Server

createApp()

Entry point for the server. Creates an Express + WebSocket server with typed RPC, auth, and all framework services.

import {
  createApp,
  createUserHelper,
  getFeedbackHandlers,
  type AppConfigurator,
  type RequestHandlers,
} from 'ugly-app';
import { dbDefaults } from 'ugly-app/shared';
import { requests } from '../shared/api';
import { collections } from '../shared/collections';
import { pages } from '../shared/pages';
import type { User } from '../shared/collections';

const userHelper = createUserHelper<User>(collections.user);
const maintainBotUserId = process.env.MAINTAIN_BOT_USER_ID ?? '';

const app = createApp(
  { requests },
  {
    ...getFeedbackHandlers(maintainBotUserId),
    getMe: async (userId: string) => {
      const user = await userHelper.get(app.db, userId);
      return { userId, email: user?.email, phone: user?.phone };
    },
  } satisfies RequestHandlers<typeof requests>,
  collections,
  (configurator: AppConfigurator) => {
    configurator.setPages({ pages });
    configurator.setUserHelper(userHelper);
    configurator.setOnUserCreate(async (userId, info, db) => {
      await userHelper.set(db, { id: userId, ...dbDefaults(), ...info });
    });
  },
);

const port = parseInt(process.env['PORT'] ?? '3000');
await app.start(port);

Signature:

function createApp<R extends AppRegistryBase, Defs extends CollectionDefRegistry>(
  registry: R,
  requests: Partial<RequestHandlers<R['requests']>>,
  appDefs: Defs,
  configure?: (configurator: AppConfigurator) => void,
): App;

The returned App object has:

  • start(port?) — starts the server (default port 3000)
  • registerRoutes(fn) — mount additional Express routes after creation
  • httpServer — the underlying Node.js HTTP server
  • db — the TypedDB instance for direct database access
  • wss — the main WebSocketServer instance (path configured via setWsPath, default '/rpc')
  • dispatch(name, input, userId) — invoke an RPC handler programmatically

AppConfigurator

The optional fourth argument to createApp receives a configurator object:

| Method | Description | |--------|-------------| | setPages(options) | Serves pages with Vite (dev) or static files (prod). options: { pages, renderPage?, clientDistPath? } | | setUserHelper(helper) | User lookup for WebSocket auth handshake | | setOnUserCreate(handler) | Called on first login — must create the user record. (userId, { email?, phone? }, db) => Promise<void> | | setAuth(provider) | Custom AuthProvider (default: ugly.bot OAuth). Provider must implement verify(code) and authUrl(origin) | | setOnSocketMessage(handler) | Handle raw WebSocket messages. Return true to consume, false to let the framework handle it | | registerRoutes(fn) | Mount custom Express routes — (router: express.Router) => void | | setWorkerQueue(queue) | Register a background worker queue with start() and stop() | | setWsPath(path) | Override the WebSocket path (default: '/rpc') | | setOnWsAuth(handler) | Called after a WebSocket session is authenticated. (ws, userId, req) => void | | setOnAfterStart(handler) | Called once after MongoDB, Redis, and NATS are ready. (db) => Promise<void> | | setOnMinuteTick(handler) | Fires every minute (only when CLOCK_ENABLED=true). () => Promise<void> | | setOnHourlyTick(handler) | Fires when the hour changes (only when CLOCK_ENABLED=true). (now, currentHour) => Promise<void> | | setHealthHandler(handler) | Override the default GET /health endpoint handler. (req, res) => void |

Handler signatures

Handlers are plain async functions — no context object, just userId and input:

// req() — public, userId may be null
getPublicData: async (userId: string | null, input) => { ... }

// authReq() — authenticated, 401 auto-enforced, userId always a string
getMe: async (userId: string, input) => { ... }

Access app.db, storage, and AI clients directly via imports or the app object — they are not injected into handlers.


Shared API definitions

All type definitions live in shared/ and are used by both server and client.

Requests (shared/api.ts)

import { authReq, defineRequests, req, z } from 'ugly-app/shared';

export const requests = defineRequests({
  // Public request — handler receives (userId: string | null, input)
  getPublicData: req({
    input: z.object({ id: z.string() }),
    output: z.object({ data: z.string() }),
  }),

  // Authenticated request — handler receives (userId: string, input)
  getMe: authReq({
    input: z.object({}),
    output: z.object({ userId: z.string(), email: z.string().optional() }),
  }),

  // With rate limiting
  submitFeedback: authReq({
    input: z.object({ type: z.enum(['bug', 'design', 'feature']), message: z.string() }),
    output: z.object({ id: z.string() }),
    rateLimit: { max: 20, window: 60 },
  }),
});
  • req({ input, output }) — defines a public request. Handler signature: (userId: string | null, input: I) => Promise<O>.
  • authReq({ input, output }) — defines an authenticated request. Handler signature: (userId: string, input: I) => Promise<O>. Returns 401 automatically if no token.
  • defineRequests() — identity wrapper that preserves types.
  • z is re-exported from Zod for convenience.

Every endpoint is accessible via both WebSocket (socket.request(name, input)) and HTTP (POST /api/:name { input }).

Collections (shared/collections.ts)

import { defineCollections } from 'ugly-app/shared';
import type { Note } from './types';

export const collections = defineCollections({
  note: {
    type: {} as Note,
    meta: { cache: true, trackable: true, public: false, cascadeFrom: null },
  },
  user: {
    type: {} as User,
    meta: { cache: true, trackable: false, public: false, cascadeFrom: null },
  },
});

Each collection definition has:

  • type — phantom field for TypeScript type inference (never read at runtime)
  • meta — runtime metadata:
    • cache — enable in-memory LRU caching for getDoc; setDoc/deleteDoc invalidate it
    • trackable — allow real-time trackDoc/trackDocs subscriptions via Change Streams
    • public — allows client reads via getDoc/trackDoc/trackDocs without auth
    • cascadeFrom — parent collection name for cascade deletes (or null)
    • trackKeys? — fields usable as NATS routing keys for trackDocs
  • onDelete? — optional async callback invoked on document deletion

All documents extend DBObject: { _id: string, version: number, created: Date, updated: Date }.

Pages (shared/pages.ts)

import { definePage, definePages } from 'ugly-app/shared';

export const pages = definePages({
  '':              definePage<{}>({ auth: false }),           // /
  'note/:noteId':  definePage<{ noteId: string }>(),          // /note/abc
  'search':        definePage<{ q?: string }>({ auth: false }),// /search?q=foo
  'blog/*slug':    definePage<{ slug: string }>({ ssr: true }),// /blog/any/path
});
export type AppPages = typeof pages;
  • :param — single path segment; *param — greedy rest (captures slashes)
  • auth: true (default) — requires login; auth: false — public
  • ssr: true — server-renders the page (supply renderPage to setPages())
  • The generic type parameter on definePage<Params>() types the route's params — it's phantom (never set at runtime)

Client

Entry point (client/main.tsx)

import { createRoot } from 'react-dom/client';
import { AppProvider, createSocket, LoginPopup } from 'ugly-app/client';
import { requests } from '../shared/api';
import { RouterProvider } from './router';
import App from './App';

const token = (window as unknown as { __AUTH_TOKEN__?: string }).__AUTH_TOKEN__;
const root = createRoot(document.getElementById('root')!);
const loginPopup = <LoginPopup onSuccess={() => window.location.reload()} />;

if (!token) {
  root.render(
    <RouterProvider fallback={<div>404</div>} loginFallback={loginPopup} isAuthenticated={() => false}>
      <App socket={null} />
    </RouterProvider>,
  );
} else {
  const userId = JSON.parse(atob(token.split('.')[1]!)).sub as string;
  const socket = createSocket({ requests, url: '/rpc' });
  socket.connect(token).then((user) => {
    root.render(
      <RouterProvider fallback={<div>404</div>} loginFallback={loginPopup} isAuthenticated={() => true}>
        <AppProvider socket={socket} userId={userId} user={user}>
          <App socket={socket} />
        </AppProvider>
      </RouterProvider>,
    );
  });
}

createSocket()

Creates a typed WebSocket client for RPC communication.

const socket = createSocket({ requests, url: '/rpc' });
await socket.connect(token); // returns UserBase

AppSocket methods:

| Method | Description | |--------|-------------| | connect(token) | Authenticate and connect. Returns the user object | | request(name, input) | Invoke a typed request (query or mutation) | | getDoc(collection, id) | Fetch a single document | | getDocs(collection, filter?, opts?) | Query documents (filter, sort, limit, skip) | | getQuery(collection, pipeline, opts?) | Run an aggregation pipeline | | trackDoc(collection, id, cb) | Subscribe to real-time doc changes. Returns unsubscribe fn | | trackDocs(collection, params, cb) | Subscribe to query results (keys, filter, sort, limit, skip). Returns unsubscribe fn | | uploadFile(file, key) | Upload a file via presigned URL | | emit(type, data) | Send a fire-and-forget message over WebSocket | | send(type, data, timeout?) | Send a message and wait for a response | | waitForConnection(timeout?) | Wait until the socket is connected | | connectionState | Current state: 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'idle-disconnected' | | disconnect() | Close the connection |

createSocket() options:

| Option | Description | |--------|-------------| | requests | The requests registry from shared/api.ts | | url? | WebSocket path (default: '/rpc') | | buildId? | Build identifier sent on connect | | onCustomMessage? | Handle custom server-pushed messages | | getUrlParams? | Extra query params appended to the WebSocket URL | | messageReviver? | JSON reviver for incoming messages (e.g. Date parsing) |

createHttpClient()

Creates a typed HTTP client for RPC communication (no WebSocket needed).

import { createHttpClient } from 'ugly-app/client';

const client = createHttpClient({ requests, token: 'eyJ...', baseUrl: '' });
const result = await client.request('getMe', {});

| Option | Description | |--------|-------------| | requests | The requests registry from shared/api.ts | | token? | Bearer token for authenticated requests | | baseUrl? | URL prefix (default: '' — relative paths: POST /api/:name) |

AppProvider

Wraps your app with context for useApp(). Provides user info, socket access, popup management, async loading overlay, splash screen, and localization.

<AppProvider
  socket={socket}
  userId={userId}
  user={user}
  splashScreen={<MySplash />}        // optional
  loadingOverlay={<MyLoader />}      // optional — shown during runAsync()
  localizer={(key, params) => t(key)} // optional i18n function
>
  {children}
</AppProvider>

useApp() returns:

| Field | Description | |-------|-------------| | userId | Current user ID | | user | Current UserBase object | | socket | The AppSocket instance | | showPopup(content) | Show a popup (returns popup ID) | | hidePopup(id) | Hide a specific popup | | hideAllPopups() | Dismiss all popups | | runAsync(label, fn, opts?) | Run an async operation with loading overlay | | splashDone(step) | Mark a splash screen step as complete | | localizer(key, params?) | Localize a string key |

useAppOptional() returns the same context or null if outside <AppProvider>.

useLocalizer() returns the localizer function (falls back to identity if outside provider).

Router

Setup (client/router.ts)

import { createRouter } from 'ugly-app/client';
import { pages } from '../shared/pages';
import { allPages } from './allPages';

export const { RouterProvider, RouterView, useRouter } = createRouter({ pages, allPages });

createRouter() returns three things:

  • RouterProvider — wrap your app. Props: children, fallback? (shown before first route resolves), loginFallback? (shown for auth-guarded pages when unauthenticated), isAuthenticated? (function returning boolean)
  • RouterView — renders the active page with animated transitions. Props: durationMs?, easing?, transitionComponent?, renderPage?
  • useRouter() — hook returning the router context

Page map (client/allPages.ts)

import { lazyPage, lazyPageLoader } from 'ugly-app/client';
import type { PageMap } from 'ugly-app/shared';
import type { AppPages } from '../shared/pages';

export const allPages = {
  ['']:             lazyPage(() => import('./pages/HomePage')),
  ['note/:noteId']: lazyPage(() => import('./pages/NotePage')),
  ['search']:       lazyPage(() => import('./pages/SearchPage')),
  ['slow']:         lazyPageLoader(() => import('./pages/SlowPageLoader')),
} satisfies PageMap<AppPages>;
  • lazyPage(factory) — lazy-imports a default-exported React component. The component receives route params as props.
  • lazyPageLoader(factory) — lazy-imports an async loader function (params) => Promise<ReactElement> for routes that need data fetching before render. The loader file is the chunk boundary — it can statically import its page component.

lazyPageLoader example:

// pages/SlowPageLoader.tsx
import SlowPage from './SlowPage'; // static import OK — same chunk
export default async function PageLoader({ id }: { id: string }) {
  const data = await fetchSlowData(id);
  return <SlowPage {...data} />;
}

Navigation with useRouter()

const { push, replace, back, current, openPopup, closePopup, closeAllPopups } = useRouter();

push('note/:noteId', { noteId: '123' });   // pushes /note/123
replace('search', { q: 'hello' });          // replaces with /search?q=hello
back();                                      // browser back

// current route state
current.routeName; // e.g. 'note/:noteId'
current.params;    // e.g. { noteId: '123' }

All route names and params are fully typed based on your pages definition.

Popups

Always use useRouter().openPopup() — never build custom fixed overlays.

const { openPopup } = useRouter();

const handle = openPopup(<MyContent />, {
  mode: 'transient',    // 'block' | 'transient' | 'contextMenu'
  slideFrom: 'bottom',  // 'left' | 'right' | 'top' | 'bottom' | 'none'
  onClose: () => {},     // called when popup closes
  containerStyle: {},    // CSS for the content wrapper
  backgroundStyle: {},   // CSS for the backdrop
  animConfig: { duration: 300, easing: myEasingFn },
  renderLayer: (props) => <CustomLayer {...props} />, // fully replace the popup renderer
});

handle.hide(); // dismiss programmatically

Popup modes:

  • block (default) — dark backdrop (40% opacity), clicking backdrop does NOT dismiss
  • transient — light backdrop (20% opacity), clicking backdrop dismisses
  • contextMenu — same as transient, intended for menus and pickers

renderLayer receives { content, spring, hide }spring is an animated value from 0 to 1, hide is a function to close the popup.

Link component

import { Link } from 'ugly-app/client';

<Link router={router} to="note/:noteId" params={{ noteId: '123' }}>
  View Note
</Link>

Renders an <a> tag with the correct href. Intercepts clicks for client-side navigation (ctrl/cmd+click opens in new tab).

Animation system

Built-in animation primitives for transitions and popups. Animated.div and Animated.span accept AnimatedStyle — a style object where any CSS property can be an AnimatedValueRef or a TransformedValue instead of a static value.

import {
  Animated,
  createAnimatedValue,
  useAnimatedValue,
  easingFunctions,
  FadeIn,
  SlideFromBottom,
  SlideFromRight,
} from 'ugly-app/client';

// Create an animated value (0->1 spring)
const spring = createAnimatedValue(0);
spring.start(1, { duration: 300, easing: easingFunctions.easeOut });

// Use in components — animated properties bypass React re-renders via direct DOM mutation
<Animated.div style={{ opacity: spring.to((v) => String(v)) }}>
  Content
</Animated.div>

// Hook version — creates and manages an animated value in a component
const anim = useAnimatedValue(0);

// Pre-built entrance animations (wrap any children)
<FadeIn>{children}</FadeIn>
<SlideFromBottom>{children}</SlideFromBottom>
<SlideFromRight>{children}</SlideFromRight>

AnimatedValueRef API:

| Member | Description | |--------|-------------| | current | Current numeric value | | target | Target value | | isAnimating | Whether an animation is in progress | | start(target, config?) | Animate to target. Returns Promise<void> | | set(value) | Jump to value immediately (no animation) | | stop() | Cancel the current animation | | subscribe(cb) | Subscribe to value changes. Returns unsubscribe fn | | to(transform) | Create a transformed value for use in Animated styles |

AnimConfig: { duration?, easing?, onRest?, onUpdate?, immediate? }

Available easings: easingFunctions.linear, easeIn, easeOut, easeInOut, springGentle, springSnappy, springBouncy, slow.

Additional animation hooks:

  • useAnimatedTransition / useAnimatedPresence — mount/unmount transitions with phase tracking
  • useScrollAnimation / useStaggerAnimation — scroll-driven and staggered entrance animations

Screenshot capture

import { captureScreenshot } from 'ugly-app/client';

const dataUrl = await captureScreenshot(); // captures the current viewport

UI components

ugly-app/client exports a set of built-in UI components:

Button, Card, EnumInput, Header, Image, Input, Modal, PageLayout, Panel, PopupPanel, Pressable, ScrollView, SettingGroup, Text, Toast, View, ResponsiveGrid, TabPicker, HeaderTabPicker, TabContent, TabContentAllActive


Auth

Auth uses HttpOnly cookies with server-side JWT injection. No localStorage needed.

Flow:

  1. User opens <LoginPopup /> which redirects to OAuth at https://ugly.bot/oauth
  2. Browser posts the auth code to POST /auth/verify — server sets auth_token HttpOnly cookie
  3. On every page load the server refreshes the cookie and injects the token into HTML:
    <script>window.__AUTH_TOKEN__ = "eyJ..."</script>
  4. Client reads window.__AUTH_TOKEN__ synchronously and connects the WebSocket

Logout:

await fetch('/auth/logout', { method: 'POST' });
window.location.reload();

Built-in auth endpoints:

| Endpoint | Description | |----------|-------------| | POST /auth/verify | Exchange OAuth code for a session cookie | | POST /auth/logout | Clear the auth cookie | | GET /auth/token | Refresh and return the current token | | GET /auth/url | Get the OAuth popup URL |

Custom auth provider:

configurator.setAuth({
  verify: async (code: string) => ({ userId: '...', email: '...' }),
  authUrl: (origin: string) => 'https://my-oauth.com/authorize?...',
  registerRoutes: (router) => { /* optional extra routes */ },
});

The AuthProvider interface:

interface AuthProvider {
  verify(code: string): Promise<{ userId: string; email?: string; phone?: string; token?: string }>;
  authUrl(origin: string): string;
  registerRoutes?(router: express.Router): void;
}

Database (TypedDB)

Access via app.db or by importing createTypedDB / getMongoClient from 'ugly-app'.

All methods accept either a CollectionDef object (from defineCollections) or a plain collection name string as the first argument.

Writing

// Insert or replace a document
await db.setDoc(collections.note, doc);
await db.setDoc(collections.note, doc, { skipIfExists: true });

// Partial update — only specified fields (supports dot-notation paths)
await db.setDocFields(collections.note, id, { title: 'New title' });

// Partial update — returns null if document doesn't exist (no error)
const doc = await db.setDocFieldsOrIgnore(collections.note, id, { title });

// Partial update — creates the document if it doesn't exist (obj = default doc for insert)
await db.setDocFieldsOrCreate(collections.note, id, { title }, defaultDoc);

// MongoDB update operators ($inc, $addToSet, $pull, $unset, $set)
await db.setDocOp(collections.note, id, { $inc: { views: 1 } });
await db.setDocOpOrIgnore(collections.note, id, { $inc: { views: 1 } }); // no error if missing

Reading

const note  = await db.getDoc(collections.note, id);
const notes = await db.getDocs(collections.note, { userId }, { sort: { created: -1 }, limit: 20 });

// Aggregation pipeline
const results = await db.getQuery<MyResult>('note', pipeline, { skip, limit });
const count   = await db.getQueryCount('note', pipeline);
const raw     = await db.getQueryRaw<T>('note', pipeline);

// Dynamic/untyped access (when collection name is a runtime string)
const doc  = await db.rawGetDoc(collectionName, id);
const docs = await db.rawGetDocs(collectionName, filter);

Deleting

await db.deleteDoc(collections.note, id);           // single doc (cascades via cascadeFrom)
await db.deleteQuery(collections.note, { userId }); // bulk delete by filter (cascades + calls onDelete)

Caching

const cached = db.cacheGet<MyType>(key);
db.cacheSet(key, value, ttlMs);
db.cacheDelete(key);
const key = db.cacheKey('prefix', id); // generate a cache key

Helpers

import { createUserHelper } from 'ugly-app';
import { dbDefaults } from 'ugly-app/shared';

// dbDefaults() returns { version: 1, created: new Date(), updated: new Date() }
const doc = { id: newId(), ...dbDefaults(), title: 'Hello' };

// createUserHelper — typed user CRUD with get, set, update methods
const userHelper = createUserHelper<User>(collections.user);
const user = await userHelper.get(db, userId);
await userHelper.set(db, { id: userId, ...dbDefaults(), email });

Indexes

// shared/dbIndexes.ts
import { defineDbIndexes } from 'ugly-app/shared';

export const dbIndexes = defineDbIndexes({
  note: {
    indexes: [
      { fields: { userId: 1, created: -1 } },
      { fields: { title: 1 }, unique: true },
    ],
    searchIndexes: [
      { name: 'note_search', fields: { title: 'string', body: 'string' } },
    ],
    vectorIndexes: [
      { name: 'note_embedding', field: 'embedding', dimensions: 1536, similarity: 'cosine' },
    ],
  },
});

Run npm run db:init to create/update indexes.


AI

Text generation

import { createTextGenClient } from 'ugly-app';
const textGen = createTextGenClient();

const text   = await textGen.generate(messages);
const json   = await textGen.generateJson(schema, messages);   // Zod schema, retries on parse failure
const result = await textGen.generateWithTools(messages, tools); // automatic tool-call loop

| Provider | provider value | Default model | JSON | Tools | Vision | |----------|-----------------|---------------|------|-------|--------| | Together AI | 'together' | Llama-4-Maverick-17B-128E | yes | yes | yes | | Anthropic | 'claude' | claude-sonnet-4-6 | yes | yes | yes | | OpenAI | 'openai' | gpt-4o | yes | yes | yes | | Google | 'google' | gemini-2.5-flash | yes | yes | yes | | Groq | 'groq' | llama-3.3-70b-versatile | yes | yes | no | | Fireworks | 'fireworks' | llama-v3p1-70b-instruct | yes | yes | yes | | Kie.ai | 'kie' | gemini-2.0-flash | yes | yes | yes |

Use provider: 'auto' (default) to let the system pick based on requirements.

Image generation

import { createImageGenClient } from 'ugly-app';
const imageGen = createImageGenClient();

const url = await imageGen.generate(prompt, { width: 1024, height: 1024 });

| Provider | provider value | |----------|-----------------| | Together AI (FLUX schnell) | 'together' | | FAL (FLUX pro) | 'fal' | | Google (Imagen 3) | 'google' | | Wavespeed | 'wavespeed' | | Kie.ai (Kolors) | 'kie' |

Embeddings

import { createEmbeddingClient, cosineSimilarity } from 'ugly-app';
const embeddings = createEmbeddingClient();
const similarity = cosineSimilarity(vectorA, vectorB);

Speech-to-text (STT)

Server-side STT uses a provider registry pattern. Providers auto-register when their API key env var is set.

import { registerSTTProvider, selectSTTProvider, getAllSTTProviders } from 'ugly-app';

Built-in STT providers:

| Provider | Import | Env var | Mode | |----------|--------|---------|------| | Deepgram (nova-2) | deepgramSTTProvider | DEEPGRAM_API_KEY | Real-time streaming via WebSocket | | OpenAI Whisper | openAIWhisperSTTProvider | OPENAI_API_KEY | Batch (buffers audio, transcribes on stop) | | Groq Whisper | groqWhisperSTTProvider | GROQ_API_KEY | Batch |

STT provider interface:

interface STTProvider {
  name: string;
  apiKeyEnv: string;
  connect(
    onTranscript: (result: STTTranscript) => void,
    onError: (err: string) => void,
    lang?: string,
    options?: STTConnectOptions,
    onUsage?: (report: STTUsageReport) => void,
  ): Promise<STTSession>;
}

interface STTSession {
  sendAudio(pcm16: Buffer): void; // PCM16 at 16kHz mono
  stop(): Promise<void>;
}

interface STTTranscript { text: string; isFinal: boolean; lang?: string; words?: STTWord[] }

Client-side hook:

import { useSTT } from 'ugly-app/client';
const { start, stop, transcript, isListening } = useSTT(socket, options);

Text-to-speech (TTS)

import { registerTTSProvider, selectTTSProvider, azureTTSProvider } from 'ugly-app';

Built-in TTS provider:

| Provider | Import | Env vars | |----------|--------|----------| | Azure TTS | azureTTSProvider | AZURE_TTS_KEY, AZURE_TTS_REGION |

TTS provider interface:

interface TTSProvider {
  name: string;
  apiKeyEnv: string;
  stream(text: string, voice: string, options?: TTSStreamOptions): AsyncGenerator<TTSChunk>;
}

interface TTSChunk {
  audio: Buffer;       // PCM16, mono, 24kHz
  word?: string;
  startMs?: number;
  durationMs?: number;
  visemes?: TTSViseme[]; // When requestVisemes=true (for lip sync)
}

Client-side hook:

import { useTTS, AudioPlayer, AudioRecorder } from 'ugly-app/client';

Web search

import { createWebSearchClient, registerWebSearchProvider } from 'ugly-app';
const search = createWebSearchClient(userId);

const results = await search.search({ query: 'hello', limit: 10 });
const summary = await search.summarize({ url: 'https://...' });
const web     = await search.enrichWeb({ query: 'topic' });
const news    = await search.enrichNews({ query: 'topic' });

WebSearchClient methods:

| Method | Description | |--------|-------------| | search({ query, limit? }) | Web search — returns { items, related? } | | summarize({ url?, text? }) | Summarize a URL or text — returns summary string | | enrichWeb({ query }) | Enriched web results | | enrichNews({ query }) | Enriched news results |

Built-in providers: Kagi (KAGI_API_KEY) and UglyBot.

Custom providers

import { registerTextGenProvider, registerImageGenProvider, registerEmbeddingProvider } from 'ugly-app';

registerTextGenProvider('myProvider', myTextGenImplementation);
registerImageGenProvider('myProvider', myImageGenImplementation);
registerEmbeddingProvider('myProvider', myEmbeddingImplementation);

Client-side AI calls

The client can call AI through the server proxy without managing tokens directly:

import { callTextGen, callJsonGen, callImageGen } from 'ugly-app/client';

const text  = await callTextGen(input);
const json  = await callJsonGen(input);
const image = await callImageGen(input);

These call POST /ai/request (same-origin, avoids CORS). The server verifies the user JWT and forwards to https://ugly.bot using the UGLY_BOT_TOKEN env var.


Storage

// Server-side — put a file
const url = await storage.put('temp', key, buffer, 'image/png');

// Move from temp to public bucket
const publicUrl = await storage.moveToPublic(tempKey, destKey);

// Get a public URL
const url = storage.url('public', destKey);

// Browser direct upload via presigned URL
const { uploadUrl, resultUrl } = await storage.presignedPut('temp', key);

Buckets: 'public' and 'temp'. Supports Cloudflare R2 (production) or MinIO (dev).

The StorageClient interface:

interface StorageClient {
  put(bucket: 'public' | 'temp', key: string, body: Buffer, contentType: string): Promise<string>;
  moveToPublic(tempKey: string, destKey: string): Promise<string>;
  url(bucket: 'public' | 'temp', key: string): string;
  presignedPut(bucket: 'temp', key: string): Promise<{ uploadUrl: string; resultUrl: string }>;
}

Static build-time assets go in client/public/. Never hardcode /asset/... paths — use the buildId from shared/Build.ts.


Billing

Usage-based billing with per-user limits, global provider limits, pre-paid credits, and threshold alerts.

import { initBillingGateway, getBillingGateway } from 'ugly-app';

const billing = initBillingGateway(db, {
  global: { hourlyUsd: 100, thresholdPct: 0.8 },
  providers: {
    openai: { hourlyUsd: 50, thresholdPct: 0.9 },
  },
  profitMarginPct: 0.2, // 20% markup on costs
});

// Charge a user
await billing.charge({
  userId: '...',
  provider: 'openai',
  model: 'gpt-4o',
  type: 'textGen',       // 'textGen' | 'imageGen' | 'stt' | 'tts' | 'embedding' | 'service'
  costUsd: 0.03,
  inputTokens: 1000,
  outputTokens: 500,
});

// Pre-flight check
const canPay = await billing.canCharge(userId, 0.05);

// Grant credits
await billing.grantCredit(userId, 10.00);

// Query spend
const usage = await billing.getSpend(userId, { from, to });

// Threshold callbacks
billing.setUserThresholdCallback((userId, period, spend, limit) => { /* alert */ });
billing.setGlobalThresholdCallback((period, spend, limit) => { /* alert */ });

Per-user limits are resolved via setUserLimitHook():

billing.setUserLimitHook(async (userId) => ({
  hourlyUsd: 5,
  dailyUsd: 50,
  weeklyUsd: 200,
  thresholds: { hourly: 0.8, daily: 0.9, weekly: 0.95 },
}));

The billing state machine tracks spend across hourly, daily, and weekly windows. Global and per-provider limits are always enforced; user credits act as a fallback when user limits are exceeded but never bypass global limits.


Experiments

A/B testing with deterministic user bucketing and event tracking.

// shared/experiments.ts
import type { Experiment } from 'ugly-app/shared';

export const experiments: Experiment[] = [
  {
    id: 'new-onboarding',
    name: 'New Onboarding Flow',
    description: 'Test the redesigned onboarding',
    active: true,
    branches: [
      { id: 'control', name: 'Control', weight: 50 },
      { id: 'variant', name: 'Variant', weight: 50 },
    ],
    events: ['ONBOARDING_COMPLETE', 'ONBOARDING_SKIP'],
  },
];

Server-side assignment:

import { getExperimentAssignments, getExperimentBranch } from 'ugly-app';

// Get all active experiment assignments for a user
const branches = getExperimentAssignments(userId, sessionId, experiments);
// => { 'new-onboarding': 'variant' }

// Get a single experiment branch
const branch = getExperimentBranch(experiment, userId, sessionId);

Bucketing uses a deterministic hash of experimentId:userId (or sessionId if no user), so the same user always gets the same branch. Weights control the relative distribution across branches.


Event logging

Server-side event capture for analytics, tied to experiments.

import { eventLogCapture, eventLogServerCapture } from 'ugly-app';

// Capture with returned eventId
const { eventId } = await eventLogCapture({
  eventName: 'BUTTON_CLICK',
  sessionId,
  userId,
  properties: { page: 'home' },
  experimentBranches: branches,
}, userId);

// Fire-and-forget server capture
await eventLogServerCapture('SESSION_START', { source: 'web' }, sessionId, userId, branches);

Query functions:

| Function | Description | |----------|-------------| | eventLogGetList(input) | Paginated event list (cursor, date range, filters) | | eventLogGetTopUsers(input) | Top users by event count | | eventLogGetTopSessions(input) | Top sessions by event count | | eventLogGetTopEvents(input) | Top events by frequency | | eventLogGetCounts(input) | Time-series counts (granularity: 'seconds' | 'minutes' | 'days') | | eventLogGetUniqueUsersCounts(input) | Unique users per time interval | | eventLogGetUniqueSessionsCounts(input) | Unique sessions per time interval |


Additional server APIs

Email

import { sendEmail, sendTemplateEmail, loadEmailTemplate } from 'ugly-app';

await sendEmail({ to: '[email protected]', subject: 'Hello', html: '<p>Hi</p>' });
await sendTemplateEmail('welcome', { name: 'Alice' }, { to: '[email protected]' });

Push notifications

import { sendPush, sendFcmPush } from 'ugly-app';

await sendPush(userId, { title: 'New message', body: 'You have a new message' });

NATS (pub/sub)

import { natsPublish, natsSubscribe, subscribeCollection, subscribeDoc } from 'ugly-app';

natsPublish('my.subject', payload);
const sub = natsSubscribe('my.subject', (msg) => { /* ... */ });
sub(); // unsubscribe

Redis

import { getRedisClient, redisGet, redisSet, redisDel, redisPublish, redisSubscribe } from 'ugly-app';
const redis = getRedisClient();

Worker queues

import { createWorkerQueue } from 'ugly-app';

const queue = createWorkerQueue({
  streamName: 'JOBS',        // NATS stream name (default: 'JOBS')
  concurrency: 10,           // max concurrent jobs (default: 10)
  maxRetries: 3,             // max retry attempts (default: 3)
  clockServerOnly: true,     // only process if IS_CLOCK_SERVER=true (default: true)
});

queue.registerHandler<MyPayload>('sendEmail', async (job) => {
  job.working(); // extend ack deadline for long-running jobs
  await doWork(job.payload);
});

await queue.enqueue('sendEmail', { to: '[email protected]' }, { delay: 5000 });

configurator.setWorkerQueue(queue); // register with the app for lifecycle management

Scheduled tasks — prevent duplicate enqueuing via MongoDB upsert:

import { enqueueTask } from 'ugly-app';

await enqueueTask(taskDoc, queue, { delay: 60000 });

Billing

import { initBillingGateway, getBillingGateway } from 'ugly-app';

await initBillingGateway({ /* config */ });
const billing = getBillingGateway();

Client error capture

import { captureClientError, initClientLogger } from 'ugly-app/client';

initClientLogger(); // call once at startup — captures unhandled errors
captureClientError(error); // manually report an error to POST /api/client-error

Feedback

import { FeedbackButton, setFeedbackContext, clearFeedbackContext } from 'ugly-app/client';

// Render the built-in feedback button (bottom-right, always at [data-id="feedback-button"])
<FeedbackButton />

// Set contextual data attached to feedback submissions
setFeedbackContext({ page: 'editor', noteId: '123' });
clearFeedbackContext();

Server-side, getFeedbackHandlers(maintainBotUserId) provides the RPC handlers for submitting and managing feedback. User feedback history is available at GET /my_feedback (requires auth cookie, returns markdown).

Rate limiting

Rate limiting is configured per-endpoint in the request definition:

submitFeedback: authReq({
  input: z.object({ ... }),
  output: z.object({ ... }),
  rateLimit: { max: 20, window: 60 },  // 20 requests per 60 seconds
})

The framework enforces rate limits automatically before calling the handler.


Built-in endpoints

| Endpoint | Description | |----------|-------------| | GET /health | Health check — returns { status: 'ok', timestamp } | | POST /auth/verify | Exchange OAuth code for a session cookie | | POST /auth/logout | Clear the auth cookie | | GET /auth/token | Refresh and return the current token | | GET /auth/url | Get the OAuth popup URL | | POST /api/:name | RPC endpoint — dispatches any registered request handler | | POST /ai/request | AI proxy — forwards to ugly.bot (requires auth) | | POST /api/client-error | Client-side error capture | | GET /my_feedback | User feedback history (markdown, requires auth) |


Migrations

Never change a collection field type without writing a migration:

// server/migrations/001-add-bio.ts
export const name = '001-add-bio';
export async function up(db: Db) {
  await db.collection('user').updateMany({}, { $set: { bio: '' } });
}

Run with npm run db:migrate. Use npm run db:migrate -- --status to preview pending migrations.


CLI reference

| Command | Description | |---------|-------------| | ugly-app init <name> | Scaffold a new project | | ugly-app upgrade | Upgrade framework config files to the latest version | | ugly-app configure | Generate .uglyapp config | | ugly-app dev | Start all dev services (Docker, server, Vite, tsc, eslint) | | ugly-app build | Production build | | ugly-app login | Login to ugly.bot | | ugly-app db:init | Create/update MongoDB indexes | | ugly-app db:migrate | Run pending migrations (--status to preview) | | ugly-app deploy:single | Deploy to a single server | | ugly-app deploy:multi | Deploy to multiple servers | | ugly-app publish:assets | Push static assets to CDN (--dry-run to preview) | | ugly-app purge:assets | Clean old builds (--keep <n>, --dry-run) | | ugly-app test:e2e | Run Playwright end-to-end tests (--headed for browser) | | ugly-app logs:local | Query local dev logs | | ugly-app logs:server | Query server logs from MongoDB | | ugly-app error:local / error:server | Query error logs | | ugly-app perf:local / perf:server | Query performance metrics | | ugly-app feedback | Query user feedback submissions | | ugly-app auth:create-account | Create an account in the database | | ugly-app auth:create-token | Generate a JWT for a userId | | ugly-app textGen [prompt] | Generate text via AI (--model, --system-prompt, --max-tokens, --json) | | ugly-app imageGen [prompt] | Generate an image via AI (--model, --output <path>) |


Package entry points

| Import path | Description | |-------------|-------------| | ugly-app | Server APIs (createApp, DB, auth, AI, email, storage, billing, worker queues, etc.) | | ugly-app/shared | Shared types and utilities (defineRequests, defineCollections, definePage, experiments, Zod, etc.) | | ugly-app/client | Client APIs (createSocket, createRouter, AppProvider, components, animations, audio, etc.) | | ugly-app/playwright | Playwright test utilities | | ugly-app/webrtc | WebRTC utilities |


Environment variables

| Variable | Description | |----------|-------------| | JWT_SECRET | Required — signs auth tokens | | JWT_EXPIRY_SECONDS | Token lifetime (optional, default: 2592000 = 30 days) | | MONGODB_URI | MongoDB connection string | | PORT | Server port (default: 3000) | | NODE_ENV | development or production | | UGLY_BOT_TOKEN | App token for AI proxy (/ai/request) | | REDIS_URL | Redis (optional, in-memory fallback for dev) | | NATS_URL | NATS server URL | | CLOCK_ENABLED | Set to true to enable setOnMinuteTick/setOnHourlyTick handlers | | IS_CLOCK_SERVER | Set to true on the instance that should process delayed worker queue jobs | | STORAGE_ACCOUNT_ID | Cloudflare R2 account ID | | STORAGE_ACCESS_KEY_ID | R2 access key | | STORAGE_SECRET_ACCESS_KEY | R2 secret key | | STORAGE_PUBLIC_BUCKET | R2 public bucket name | | STORAGE_TEMP_BUCKET | R2 temp bucket name | | STORAGE_PUBLIC_URL | Base URL for public assets | | TOGETHER_API_KEY | Together AI key | | ANTHROPIC_API_KEY | Anthropic Claude key | | OPENAI_API_KEY | OpenAI key | | GOOGLE_API_KEY | Google Gemini key | | GROQ_API_KEY | Groq key | | KIE_API_KEY | Kie.ai key | | KIE_BASE_URL | Kie.ai base URL override (optional) | | DEEPGRAM_API_KEY | Deepgram STT key | | AZURE_TTS_KEY | Azure TTS key | | AZURE_TTS_REGION | Azure TTS region | | KAGI_API_KEY | Kagi web search key | | MAILGUN_API_KEY | Mailgun key | | MAILGUN_DOMAIN | Mailgun sending domain | | MAILGUN_FROM | Default from address |

Client-side variables must be prefixed with VITE_.


Shared utilities

ugly-app/shared exports common helpers used by both server and client:

import {
  isDefined,
  compact,
  debounce,
  formatDate,
  formatRelativeTime,
  oneSecond,
  oneMinute,
  oneHour,
  oneDay,
  oneWeek,
} from 'ugly-app/shared';

isDefined(value);                  // type guard — true if not null/undefined
compact([1, null, 2, undefined]);  // [1, 2] — filters out null/undefined
const debouncedFn = debounce(fn, 300);
formatDate(new Date());            // locale-formatted date string
formatRelativeTime(new Date());    // "2 hours ago", "just now", etc.

// Time constants (milliseconds)
oneSecond; // 1000
oneMinute; // 60_000
oneHour;   // 3_600_000
oneDay;    // 86_400_000
oneWeek;   // 604_800_000

Tech stack

Node.js · TypeScript · Express · React 19 · Vite · Tailwind CSS · MongoDB · NATS · Redis · Cloudflare R2 · Zod · JWT (jose) · ugly.bot OAuth