🚀 Socket Launch Week Day 4:Socket MCP Adds Org Alerts, Threat Feed Review, and Package Inspection.Learn more
Sign In

ugly-app

Package Overview
Dependencies
Maintainers
1
Versions
591
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

ugly-app

A full-stack TypeScript framework for shipping production web apps with one CLI. Scaffold with `npx ugly-app init my-app` and get an opinionated Express + React + PostgreSQL stack with built-in auth, type-safe RPC over WebSocket and HTTP, real-time docume

latest
npmnpm
Version
0.1.612
Version published
Weekly downloads
3.1K
21.44%
Maintainers
1
Weekly downloads
 
Created
Source

ugly-app

A full-stack TypeScript framework for shipping production web apps with one CLI. Scaffold with npx ugly-app init my-app and get an opinionated Express + React + PostgreSQL stack with built-in auth, type-safe RPC over WebSocket and HTTP, real-time document tracking, AI generation, storage, and a CLI for every workflow.

ugly-app is designed to be deployed and operated through ugly.bot — the platform handles auth, infra (PostgreSQL, Qdrant, NATS, S3-compatible object storage), AI provider keys, and deployment. Your app talks to all of this through the project's dev tunnel and the per-app UGLY_BOT_TOKEN.

What's included

  • Server: Express + WebSocket with type-safe RPC and Zod validation
  • Client: React + Vite with typed routing, lazy pages, animated transitions, popup management
  • Database: PostgreSQL (JSONB) via the data proxy, with full-text search (search) and vector search (Qdrant vector)
  • Auth: HttpOnly cookies + JWT, ugly.bot OAuth out of the box, extensible via AuthProvider
  • AI: Text, image, embeddings, web search — all proxied through ugly.bot (no per-provider keys in your app)
  • Realtime: NATS pub/sub and document change subscriptions (trackDoc / trackDocs)
  • Storage: S3-compatible buckets with presigned uploads
  • Workers & cron: setWorkers() registers named async tasks with optional Zod input schemas and cron schedules
  • Localization: Strings tables with critical-string SSR injection
  • Experiments: Deterministic A/B bucketing tied to event logging
  • CLI: ugly-app commands for dev, build, deploy, migrations, logs, AI, and auth

Quick start

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

The scaffold gives you a working app at http://localhost:4321 with todo CRUD, AI chat, file upload, auth demo, collab editing, and ~20 other test pages wired up.

Server

createApp()

The single server entry point. Returns an App that owns Express, the WebSocket server, the typed DB, and the RPC dispatcher.

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

const app = createApp(
  { requests, messages },
  {
    createTodo: async (userId, { text }) => {
      const _id = crypto.randomUUID();
      await app.db.setDoc(collections.todo, { _id, userId, text, done: false, ...dbDefaults() });
      return { id: _id };
    },
  } satisfies RequestHandlers<typeof requests>,
  collections,
  (configurator: AppConfigurator) => {
    configurator.setPages({ pages });
  },
);

await app.start(parseInt(process.env['PORT'] ?? '4321'));

Signature:

function createApp<R extends AppRegistryBase, Defs extends CollectionDefRegistry>(
  registry: R,                                       // { requests, messages }
  requests: Partial<RequestHandlers<R['requests']>>, // handler implementations
  appDefs: Defs,                                     // collections from defineCollections()
  configure?: (c: AppConfigurator) => void,
  deleteHandlers?: DeleteHandlers<Defs>,             // per-collection onDelete hooks
): App<CollectionMap<typeof BUILTIN_DEFS & Defs>>;

The returned App object has:

  • start(port?) — start the server (default port 3000; templates use 4321)
  • db — the TypedDB instance, also available globally via imports
  • httpServer — the underlying Node http.Server
  • wss — the main WebSocketServer (path set by setWsPath, default /rpc)
  • dispatch(name, input, userId) — invoke an RPC handler programmatically
  • registerRoutes(fn) — mount more Express routes after creation

Framework-managed background services start automatically: schema drift check, NATS connection + KV buckets (TTS, RATELIMIT), data-proxy connection, event counter flush, TTL cleanup for log tables, console / error capture, and ugly.bot log forwarding.

AppConfigurator

Passed to the optional fourth argument of createApp. Every method is optional.

MethodDescription
setPages({ pages, renderPage?, clientDistPath? })Mount the SPA. In dev, runs Vite in middleware mode; in prod, serves dist/client. Provide renderPage for SSR on pages with ssr: true.
setUserHelper(helper)Customize how the framework reads / writes the user collection during WebSocket auth (default looks up by id in a generic user collection).
setOnUserCreate(handler)Called on first login with (userId, { email?, phone? }, db) — your chance to create the user record.
setAuth(provider)Replace the default ugly.bot OAuth provider. Must implement verify(code) and authUrl(origin).
setOnSocketMessage(handler)Single raw-WebSocket message handler. Return true to consume, false to fall through.
addSocketMessageHandler(handler)Append to the handler chain; first to return true wins.
setWsPath(path)Override the WebSocket path (default /rpc).
setOnWsAuth(handler)(ws, userId, req) => void — fires after a socket session authenticates.
setOnAfterStart(handler)(db) => Promise<void> — called once after data-proxy + NATS are ready.
setOnMinuteTick(fn) / setOnHourlyTick(fn)Framework-managed periodic callbacks. Only fire when CLOCK_ENABLED=true.
setHealthHandler(fn)Override the default GET /health response.
setExperiments(experiments)Register Experiment definitions for initSession / captureEvent bucketing.
setOnEmail(handler)Handle inbound emails routed to {domain}@ugly.bot (called via internal HTTP).
setCronTasks(tasks, handlers)Legacy cron-only registry. Prefer setWorkers().
setWorkers(workers, handlers)Register named async tasks with optional Zod input schema and cron schedule. Powers /_workers/manifest, POST /_workers/run, and the cron orchestrator.
setStrings(config)Localization config — framework injects language + critical strings into SSR HTML and exposes resolveLanguage / getCriticalStrings.
registerRoutes(fn)Mount custom Express routes.
setWorkerQueue(queue)Register a WorkerQueue with start() / stop() for app lifecycle management.

Handler signatures

Handlers are plain async functions — no context object:

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

// authReq() — authenticated, framework returns 401 if no/invalid token
getMe: async (userId: string, input) => { ... }

Inside a handler, access state via captured imports — app.db, storage, pgQuery, uglyBotRequest, etc. There is no injected context.

Built-in framework requests

createApp automatically registers several framework handlers, accessible from any client via the normal RPC pipeline:

NamePurpose
userGetReturns { userId, name, avatarUri } for the given user (or caller).
initSessionRecords a session start, returns experiment branch assignments.
captureEventRecords a client event tied to the session and experiment branches.
textGen / imageGenAI proxies — server-validated, billed through ugly.bot.
kagiSearch / kagiSummarize / kagiEnrichWeb / kagiEnrichNewsWeb search via ugly.bot.
uploadUrlIssues a presigned PUT for the temp bucket.
submitFeedbackBotForwards db.captureFeedback writes for the maintain-bot persona.

App-provided handlers with the same name override the framework's defaults.

Shared API definitions

shared/ is consumed by both server and client. Keep all Zod schemas, types, collections, and route declarations here.

Requests (shared/api.ts)

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

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

  // Authenticated — 401 enforced automatically, userId guaranteed string
  getMe: authReq({
    input: z.object({}),
    output: z.object({ userId: z.string(), email: z.string().optional() }),
  }),

  // With per-endpoint rate limiting (enforced before handler runs)
  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 },
  }),
});

Every request is reachable as both socket.request(name, input) (WebSocket) and POST /api/:name { input } (HTTP). z is re-exported from Zod for convenience.

Collections (shared/collections.ts)

import { defineCollections, InferDocType } from 'ugly-app/shared';
import { z } from 'zod';

export const TodoSchema = z.object({
  userId: z.string(),
  text: z.string(),
  done: z.boolean(),
});
export type Todo = InferDocType<typeof TodoSchema>;

export const collections = defineCollections({
  todo: {
    schema: TodoSchema,
    meta: { cache: true, trackable: true, public: false, cascadeFrom: null },
  },
});

CollectionMeta:

  • cache — read getDoc through an LRU cache; writes invalidate it.
  • trackable — enables real-time trackDoc / trackDocs via NATS.
  • public — allow unauthenticated client reads.
  • cascadeFrom — parent collection for cascade deletes.
  • trackKeys? — fields usable as NATS routing keys for trackDocs.
  • search?: { fields, language? } — PostgreSQL full-text search columns.
  • vector?: { dimensions, source } — Qdrant vector index over the named JSONB path.

All documents extend DBObject: { _id, version, created, updated }. Use dbDefaults() to stamp the latter three on inserts.

After schema changes, run npm run db:schema-gen and then npm run db:migrate. The app refuses to start when drift is detected (set SCHEMA_CHECK_SKIP=true only as a last resort).

Pages (shared/pages.ts)

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

export const pages = definePages({
  '':              definePage<{}>({ auth: false }),             // /
  'user/:userId':  definePage<{ userId: string }>(),            // /user/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 matches a single path segment; *param is greedy (captures slashes).
  • The generic on definePage<Params>() is phantom — never set at runtime, used for client-side type inference.
  • auth defaults to true. ssr defaults to false.
  • Query-string params are declared in Params but never appear in the path template.

Client

bootstrapApp()

The recommended entrypoint. Handles auth detection, socket creation, silent auto-login through the ugly.bot iframe (Mode A only), and provider wiring.

// client/main.tsx
import { bootstrapApp, FeedbackButton } from 'ugly-app/client';
import { requests } from '../shared/api';
import { RouterProvider, RouterView } from './router';
import './styles.css';

bootstrapApp({
  requests,
  RouterProvider,
  render: () => (
    <>
      <RouterView />
      <FeedbackButton />
    </>
  ),
  strings: { /* optional StringsProviderConfig */ },
});

BootstrapAppOptions:

FieldDescription
requestsYour RequestRegistry (merged with framework requests internally).
messages?Your MessageRegistry (merged with framework messages).
RouterProviderThe RouterProvider returned from createRouter().
renderCallback returning the app's UI tree (typically <RouterView /> + <FeedbackButton />).
root?Root element / selector (default '#root').
fallback?UI for unmatched routes (default: tiny "404").
socketUrl?Override the WebSocket path (default /rpc).
strings?Localization config — when present, wraps the tree with <StringsProvider>.
keyboard?: falseDisable the framework <KeyboardProvider> wrapper.

bootstrapApp reads window.__AUTH_TOKEN__ (injected by the server). If absent, it renders unauthenticated and lets the router's per-route auth guard surface the framework's <AuthRoot> (Mode A → <LoginPopup>; Mode B → magic-link form + optional Google button). If the token is present, it connects the socket, mounts <AppProvider>, and renders.

If bootstrapApp is loaded at /auth/magic-link/verify (Mode B), it renders the <MagicLinkCallback> component instead of the regular app shell — that path is the fallback target when the server route is shadowed by a static-assets handler.

Routing — createRouter()

// 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:

  • RouterProvider — props: children, fallback?, isAuthenticated?. Manages route state, browser history, popups, and the silent auto-login iframe (<AutoLoginGate> — only in Mode A; skipped when window.__UGLY_APP_AUTH_MODE__ === 'self'). When isAuthenticated() returns false and the current route declares auth: true, the framework's <AuthRoot> is rendered in place of the page — apps cannot override this fallback.
  • RouterView — renders the active page with animated transitions. Props: durationMs?, easing?, transitionComponent? (replaces ViewFlipper), renderPage? (sync alternative to allPages loaders, called with RouterStateRaw).
  • useRouter() — returns the router context (see below).

Page map — lazyPage / lazyPageLoader

// 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')),
  ['user/:userId']: lazyPage(() => import('./pages/UserPage')),
  ['slow/:id']:     lazyPageLoader(() => import('./pages/SlowPageLoader')),
} satisfies PageMap<AppPages>;
  • lazyPage(factory) — lazy-imports a default-exported React.ComponentType<Params>. The page receives route params as props.
  • lazyPageLoader(factory) — lazy-imports an async loader (params) => Promise<ReactElement>. Use when a route needs data fetching before render. The loader file is the chunk boundary, so it can statically import its page component.

Example loader:

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

Navigation — useRouter()

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

push('user/:userId', { userId: '123' });   // → /user/123
replace('search', { q: 'hello' });         // → /search?q=hello
back();                                     // browser history back

current.routeName; // typed union of all route keys
current.params;    // typed params for the current route

All route names and params are fully typed against pages. Internally push / replace are no-ops when buildUrl() produces a URL that doesn't match a registered route (and emit a console.error).

Popups — openPopup()

Always use useRouter().openPopup() for modals, sheets, and menus. The router owns the popup layer, manages the spring animation, and stacks popups z-index-correctly.

const { openPopup } = useRouter();

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

handle.hide(); // dismiss programmatically

Modes:

  • block (default) — 40% opacity backdrop, does not dismiss on backdrop click.
  • transient — 20% opacity backdrop, dismisses on backdrop click.
  • contextMenu — same as transient, intended for menus and pickers.

renderLayer receives { content, spring, hide }spring is an AnimatedValueRef driving 0 → 1, hide closes the popup.

AppProvider & useApp()

bootstrapApp mounts <AppProvider> automatically after socket connect. Use useApp() inside any page to access the active user and socket.

const {
  userId,        // current user id
  user,          // UserBase doc
  socket,        // AppSocket — typed RPC client
  uglyBotSocket, // optional UglyBotSocket for direct platform calls (STT/TTS, etc.)
  showPopup,     // legacy popup API (prefer useRouter().openPopup)
  hidePopup,
  hideAllPopups,
  runAsync,      // runAsync(label, async () => { ... }, options?) — shows loading overlay while pending
  splashDone,    // mark a splash-screen step complete
  localizer,     // (key, params?) => string — alias for useLocalizer
} = useApp();

useApp is generic in TAsyncOptions so apps can pass an option type through to a custom loadingOverlay element. useAppOptional() returns null outside the provider; useLocalizer() returns a localizer that prefers <StringsProvider> data, falls back to the AppProvider localizer prop, and finally to identity.

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

<Link router={router} to="user/:userId" params={{ userId: '123' }}>View profile</Link>

Renders an <a> with the right href, intercepts clicks for client-side navigation, and lets ctrl/cmd+click open in a new tab.

Direct socket access

AppSocket is exposed through useApp().socket. Common methods:

MethodDescription
request(name, input)Invoke a typed RPC handler.
getDoc(collection, id)Server-mediated doc fetch.
getDocs(collection, filter?, opts?)Filtered query.
trackDoc(collection, id, cb)Live subscription — returns unsubscribe.
trackDocs(collection, params, cb)Live filtered subscription.
uploadFile(file, key)Presigned upload to the temp bucket.
connectionState'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'idle-disconnected'.
disconnect()Close the connection.

For pure HTTP (no WebSocket), use createHttpClient({ requests, token?, baseUrl? }).

Auth

ugly-app uses HttpOnly cookies and server-side JWT injection — no localStorage, no client-side token handling.

Two modes, picked from the auth block in the project's .uglyapp config:

  • Mode A — mode: 'uglybot' (default when no auth block is present). Auth is delegated to ugly.bot OAuth. AI / email / push proxies bill the end user's ugly.bot credits.
  • Mode B — mode: 'self'. The app issues its own sessions via magic-link email and/or Google OAuth. AI / email / push proxies bill the developer's ugly.bot account using the project's AI_PROXY_TOKEN.

window.__UGLY_APP_AUTH_MODE__ is injected into every page so client code (incl. <AuthRoot> and the router's <AutoLoginGate>) can branch correctly.

Mode A — ugly.bot OAuth (default)

  • Unauthenticated user lands on a page. The router's <AutoLoginGate> opens a hidden iframe to ${UGLY_BOT_URL}/iframe-auth to check for an existing platform session (4 s timeout).
  • If found, the iframe postMessages an OAuth code back. The client POSTs to /auth/verify; the server exchanges it through uglyBotAuthProvider, sets the auth_token HttpOnly cookie, and reloads.
  • If not found (or timeout), per-route auth checks surface the framework's <AuthRoot><LoginPopup>, which opens ${UGLY_BOT_URL}/oauth in a popup window.
  • On every authenticated page request, the server verifies the cookie token by calling ${UGLY_BOT_URL}/verify, then injects window.__AUTH_TOKEN__ into the HTML so the client can attach it to the WebSocket handshake.

Mode B — Self-issued sessions

When .uglyapp sets auth.mode: 'self', buildAuth() wires the magic-link provider as primary; if providers.google.clientId is configured it adds Google as an extra provider on the same router.

  • <AuthRoot> renders <MagicLinkForm> (plus the Google button when configured) instead of <LoginPopup>.
  • The <AutoLoginGate> is skipped entirely — the ugly.bot iframe would either silently fail or set a cookie that won't verify against the local session.
  • POST /auth/magic-link/request accepts an email, mints a one-time token (default 15 min lifetime, configurable via auth.magicLinkExpiresMin), and emails a link.
  • GET /auth/magic-link/verify?token=… validates the token, calls onUserCreate on first login, sets the session cookie, and redirects.
  • Google login lives at GET /auth/google/url + POST /auth/google/callback when enabled.

Token-in-URL embed

Any GET request with ?token=<JWT> will, if the token verifies, set the cookie and 302-redirect to the same URL without the token parameter — useful for embedding any page in an iframe.

Built-in routes

Mounted on every app:

EndpointDescription
POST /auth/verifyExchange an OAuth code for a session cookie.
POST /auth/logoutClear the cookie.
GET /auth/tokenRefresh and return the current token.
GET /auth/urlReturn the OAuth popup URL.
POST /auth/magic-link/requestMode B only — send a magic-link email.
GET /auth/magic-link/verifyMode B only — verify a magic-link token and set the cookie.
GET /auth/google/url, POST /auth/google/callbackMode B only — Google OAuth, registered when configured.

Custom provider

configurator.setAuth({
  verify: async (code) => ({ userId: '...', token: 'platform-issued-jwt' }),
  authUrl: (origin) => `https://my-oauth.example/authorize?origin=${origin}`,
  registerRoutes: (router, { db, onUserCreate }) => { /* extra routes */ },
});

Server-side helpers (ugly-app)

  • verifyToken(token) — verifies a token against ugly.bot and returns the userId.
  • getRequestUser(req) — synchronous decode of the per-project UGLY_PROJECT_TOKEN cookie set by ugly.bot's wake-on-traffic gate. Returns { userId } | null without a network round-trip; safe to use as the primary auth check in deployed-app handlers.

Database — TypedDB

Access via app.db or via import { app } from './your-app-module'. All methods accept a CollectionDef (from defineCollections) or a plain collection name string.

Writing

await db.setDoc(collections.note, doc);                                  // upsert
await db.setDoc(collections.note, doc, { skipIfExists: true });          // insert-only

await db.setDocFields(collections.note, id, { title: 'New' });           // partial; throws if missing
await db.setDocFieldsOrIgnore(collections.note, id, { title });          // returns null if missing
await db.setDocFieldsOrCreate(collections.note, id, { title }, default); // upsert with default

await db.setDocOp(collections.note, id, { $inc: { views: 1 } });         // MongoDB-style ops
await db.setDocOpOrIgnore(collections.note, id, { $inc: { views: 1 } });

Supported update operators: $inc, $addToSet, $pull, $unset, $set. All keys are dot-notation, fully typed against the collection's schema.

Reading

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

// Typed SQL-native query API (preferred for new code)
const notes = await db.find(collections.note, { userId, done: { $ne: true } }, { sort: { created: -1 }, limit: 20 });
const count = await db.findCount(collections.note, { userId });
const sample = await db.findRandom(collections.note, { userId }, 5);

// Aggregation pipelines (legacy / advanced)
const results = await db.getQuery<MyResult>('note', pipeline, { skip, limit });
const total   = await db.getQueryCount('note', pipeline);

// Dynamic / untyped access — when the collection name is a runtime string
await db.rawGetDoc('note', id);
await db.rawGetDocs('note', filter);

Deleting

await db.deleteDoc(collections.note, id);                  // cascade-deletes children
await db.deleteWhere(collections.note, { userId });        // typed bulk delete
await db.deleteQuery(collections.note, { userId });        // legacy untyped bulk delete

Pass deleteHandlers as the 5th argument to createApp to run per-collection onDelete callbacks.

// Full-text search — requires `search: { fields, language? }` on the collection
const hits = await db.searchDocs(collections.note, 'react hooks', { limit: 10 });

// Vector search — requires `vector: { dimensions, source }` (uses Qdrant)
const similar = await db.vectorSearch(collections.note, embeddingVector, { limit: 10 });

Caching

db.cacheGet<MyType>(key);
db.cacheSet(key, value, ttlMs);
db.cacheDelete(key);                                       // broadcasts invalidation via NATS
const k = db.cacheKey('prefix', id);

Helpers

import { createUserHelper, dbDefaults } from 'ugly-app';

const newDoc = { _id: crypto.randomUUID(), ...dbDefaults(), title: 'Hi' };
//                                          ^^^^^^^^^^^^^^^ { version: 1, created, updated }

const userHelper = createUserHelper<User>(collections.user);
const user = await userHelper.get(db, userId);

Direct SQL & infra

Imports available from ugly-app:

  • pgQuery(sql, params?) — parameterized SQL on the data proxy.
  • ensureTable(...), tableExists(...), ensureSearchColumn(...).
  • ensureQdrantCollection(...), upsertVector(...), searchVectors(...), deleteVector(...), deleteQdrantCollection(...).
  • connectNats(), natsPublish(subject, payload), natsSubscribe(subject, cb), ensureKvBucket(name, opts), jsPublish(...), jsConsumerCreate(...), jsConsumerConsume(...).
  • subscribeCollection, subscribeDoc, subscribeDocKey — NATS subjects emitted by the data proxy on writes.

AI

AI calls are proxied through ugly.bot — your app never holds an AI provider key. Pass UGLY_BOT_TOKEN in the environment and the framework handles routing, balance tracking, retries, and per-user billing.

Server-side text generation

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

const text = await textGen.generate(messages, { model: 'gemini_2_5_flash' });

Or call the framework textGen request directly:

import { uglyBotRequest } from 'ugly-app';
const { message } = await uglyBotRequest<{ message: { content: string } }>('textGen', {
  model: 'gemini_2_5_flash',
  messages: [{ role: 'user', content: 'Hello!' }],
  options: { maxTokens: 512 },
});

Available models are exposed via textGenModels / textGenModelData from ugly-app — the platform supports Claude, GPT, Gemini, Together, Groq, Fireworks, and Kie families.

Server-side image generation

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

const url = await imageGen.generate('A red panda eating noodles', { model: 'flux_schnell' });

imageGenModels / imageGenModelData enumerate available models (Together FLUX, FAL, Google Imagen, Wavespeed, Kie Kolors).

Embeddings

import { createEmbeddingClient, cosineSimilarity } from 'ugly-app';
const embeddings = createEmbeddingClient();
const vector = await embeddings.embed('hello world');
const sim = cosineSimilarity(vectorA, vectorB);
import { createWebSearchClient } from 'ugly-app';
const search = createWebSearchClient(userId);

await search.search({ query: 'react 19', limit: 10 });
await search.summarize({ url: 'https://...' });
await search.enrichWeb({ query: 'topic' });
await search.enrichNews({ query: 'topic' });

Client-side AI calls

Calls from React components go through the framework RPC pipeline — no token plumbing in the browser:

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

const text  = await callTextGen({ messages, model: 'gemini_2_5_flash' });
const json  = await callJsonGen({ messages, schema, model: 'gemini_2_5_flash' });
const image = await callImageGen({ prompt: 'a corgi astronaut', model: 'flux_schnell' });

STT / TTS

Speech goes directly from the browser to ugly.bot — never proxied through your app server.

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

const { start, stop, transcript, isListening } = useSTT(socket, options);
const { speak, stop: stopTTS } = useTTS(socket, { voice: 'alloy' });

Storage

S3-compatible. Two logical buckets:

  • temp — short-lived uploads (presigned PUT from the browser).
  • public — durable, served by CDN.

Server-side:

import { createStorageClient } from 'ugly-app';
const storage = createStorageClient();

await storage.put('temp', key, buffer, 'image/png');
const publicUrl = await storage.moveToPublic(tempKey, destKey);
const url = storage.url('public', destKey);
const { uploadUrl, resultUrl } = await storage.presignedPut('temp', key);

Client-side, use socket.uploadFile(file, key) — it requests a presigned URL via the built-in uploadUrl framework request and streams the upload. In dev, uploads go through a same-origin /_s3 proxy to avoid CORS with local MinIO.

STORAGE_KEY_PREFIX (env) prefixes all keys — useful for per-environment isolation.

Workers & cron

// shared/cron.ts
import { defineWorkers, z } from 'ugly-app/shared';

export const cronTasks = defineWorkers({
  dailyCleanup: {
    schedule: '0 3 * * *',   // every day at 03:00 UTC
    description: 'Delete completed todos older than 30 days',
  },
  resyncSearch: {
    inputSchema: z.object({ since: z.string().datetime() }),
    description: 'Re-embed search vectors since the given ISO timestamp',
  },
});
// server/index.ts
const cronHandlers: WorkerHandlers<typeof cronTasks> = {
  dailyCleanup: async () => { /* runs on schedule */ },
  resyncSearch: async ({ since }) => { /* runs on manual trigger from studio */ },
};

configurator.setWorkers(cronTasks, cronHandlers);

Each worker can have inputSchema, outputSchema, schedule, timeout, description. Workers without a schedule are still invocable via POST /_workers/run (auth: localhost in dev, Authorization: Bearer $CRON_SECRET in prod). Scheduled workers also appear in /_cron/manifest for the deploy orchestrator.

Localization

configurator.setStrings({
  defaultLang: 'en',
  langs: ['en', 'es'],
  criticalKeys: ['app.title', 'nav.home'],
  getTable: (lang) => tables[lang] ?? tables.en,
});

The framework injects window.__LANG__, window.__STRINGS_VERSION__, and window.__CRITICAL_STRINGS__ into SSR HTML. Use useLocalizer() / useStrings() / useLang() / useChangeLanguage() on the client.

Experiments

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

export const experiments: Experiment[] = [
  {
    id: 'new-onboarding',
    name: 'New Onboarding',
    active: true,
    branches: [
      { id: 'control', weight: 50 },
      { id: 'variant', weight: 50 },
    ],
    events: ['ONBOARDING_COMPLETE'],
  },
];

configurator.setExperiments(experiments);

Bucketing is deterministic: hash(experimentId + userId) (or sessionId for unauthenticated users). The framework's initSession / captureEvent requests automatically tag events with the user's branch assignments.

Built-in endpoints

EndpointDescription
GET /healthHealth check — returns { status, timestamp, lastRequestAt }.
POST /api/:nameDispatch any registered request handler over HTTP.
POST /auth/verifyExchange OAuth code for session cookie.
POST /auth/logoutClear the auth cookie.
GET /auth/tokenRefresh and return the current token.
GET /auth/urlGet the OAuth popup URL.
GET /_workers/manifestWorker definitions (used by ugly-studio).
POST /_workers/runSynchronously invoke a worker handler.
GET /_workers/runsRecent in-memory worker runs (last 200).
GET /_cron/manifestCron tasks for the deploy orchestrator.
POST /api/_cron/:taskNameTrigger a cron task (auth via CRON_SECRET).
POST /internal/email-callbackInbound email gateway (auth via INTERNAL_EMAIL_SECRET).
PUT /_s3/*Dev-only S3 upload proxy (avoids CORS with MinIO).

Package entry points

Import pathDescription
ugly-appServer: createApp, TypedDB, auth, AI clients, NATS, storage, email, push, workers.
ugly-app/sharedCross-tier: defineRequests, defineCollections, definePage, defineWorkers, Zod, experiments, time constants.
ugly-app/clientReact: bootstrapApp, createRouter, lazyPage, AppProvider, components, animations, audio, AI helpers.
ugly-app/conversation/{shared,server,client}AI chat sessions with persisted history.
ugly-app/collab/{server,client}Yjs-based collaborative editing.
ugly-app/markdown/{shared,client}Markdown rendering + editor.
ugly-app/webrtc, ugly-app/webrtc/serverWebRTC video rooms.
ugly-app/three/{server,client}Three.js scene helpers.
ugly-app/workerWorker queue runtime.
ugly-app/playwrightTest utilities.
ugly-app/vite, ugly-app/eslintBuild-tool plugins.

Environment variables

VariableDescription
PORTServer port (templates default to 4321).
NODE_ENVdevelopment or production.
UGLY_BOT_TOKENApp token for the ugly.bot platform — required for AI, logs, billing.
UGLY_BOT_URLOverride the platform base URL (default https://ugly.bot).
DATA_PROXY_URLWebSocket URL for the data proxy (default ws://localhost:4200).
DATA_PROXY_TOKENAuth token for the data proxy.
STORAGE_KEY_PREFIXPrefix all storage keys (per-env isolation).
MINIO_ENDPOINTDev-only S3 endpoint for the upload proxy.
NATS_PREFIX / COMPOSE_PROJECT_NAMENATS subject prefix for per-env isolation.
CLOCK_ENABLEDtrue to enable setOnMinuteTick / setOnHourlyTick.
CRON_SECRETBearer secret for POST /api/_cron/:taskName and prod POST /_workers/run.
MAINTAIN_BOT_USER_IDUser id allowed to access admin-only handlers.
INTERNAL_EMAIL_SECRETShared secret for /internal/email-callback.
JWT_SECRETRequired when using getRequestUser() for the per-project session cookie.
APP_DOMAINApp domain; combined with NATS_PREFIX for getRequestUser() validation.
LOG_CAPTURE_URLStudio override for client log capture (empty → ugly.bot default).
UGLY_APP_HMRSet to false to disable Vite HMR in dev.
SCHEMA_CHECK_SKIPtrue to start despite schema drift (unsafe).

Browser-visible variables must be prefixed VITE_ and consumed via import.meta.env.VITE_*.

CLI

CommandDescription
ugly-app init <name>Scaffold a new project.
ugly-app upgradeUpgrade framework config files to the latest version.
ugly-app configureGenerate/update .uglyapp config.
ugly-app loginAuthenticate with ugly.bot.
ugly-app urlPrint the local dev server URL.
ugly-app deployBuild + push to production infrastructure.
ugly-app prod --buildId <id>Promote a build to prod.
ugly-app versionsList deployed versions.
ugly-app versions:pruneClean up non-prod versions.
ugly-app infra:destroyTear down all project infra.
ugly-app textGen [prompt]Generate text via AI (--model, --system-prompt, --max-tokens, --json).
ugly-app imageGen [prompt]Generate an image (--model, --output <path>).
ugly-app error:dev / error:prodQuery error logs (your tunnel / production).
ugly-app perf:dev / perf:prodQuery performance metrics.
ugly-app feedback:dev / feedback:prodQuery user feedback.
ugly-app feedback:submit / feedback:resolveManage feedback (run with --help for flags).

Inside a scaffolded project, the same commands are available via npm run … scripts — see templates/CLAUDE.md for the full list.

Migrations

Schema changes must be deliberate:

  • Update the Zod schema in shared/collections.ts.
  • Run npm run db:schema-gen — produces a migration file with compile-blocking REPLACE_ME placeholders for any non-trivial change.
  • Replace every REPLACE_ME with the correct migration logic.
  • Run npm run db:migrate.

The framework refuses to start when drift is detected (set SCHEMA_CHECK_SKIP=true only as a temporary escape hatch).

Tech stack

Node.js · TypeScript · Express · React 19 · Vite · PostgreSQL (JSONB) · Qdrant · NATS · S3-compatible storage · Zod · JWT (jose) · ugly.bot platform

FAQs

Package last updated on 17 Jun 2026

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts