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

Published

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

Readme

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, cron/workers, and a CLI for every workflow.

ugly-app is designed to be deployed and operated through ugly.bot — the platform provides 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 layer
  • Database: PostgreSQL (JSONB) via the data proxy, with full-text search and vector search (Qdrant)
  • Auth: HttpOnly cookies + JWT; ugly.bot OAuth out of the box, magic-link / Google via mode: 'self'
  • AI: Text, JSON, 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
  • Two-adapter deploy: Same source runs on Node (Express) or Cloudflare Workers (Hono + Durable Objects)
  • 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 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 InboundEmail,
  type RequestHandlers,
} from 'ugly-app';
import { nanoid } from 'nanoid';
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 = nanoid();
      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>, RegistryPages<R>>;

The returned App object exposes:

| Field | Description | |-------|-------------| | start(port?) | Start the server (default port 3000; templates use 4321). | | db | The TypedDB<Map> instance (map is inferred from your collections + framework built-ins). | | 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 (inherited from AppRouter). | | registerRoutes(fn) | Mount more Express routes after creation. | | pushSend(input) | Send a push notification whose click-through target is a route from this app's pages table. page is type-checked against route keys (when pages is provided on the registry), query against that route's params. The framework builds the absolute URL and stamps project identity so the client can open the dock app. |

Framework-managed background services start automatically inside app.start(): schema drift check, NATS connection + KV buckets, data-proxy connection, event-counter flush, TTL cleanup for log tables, console/error capture, and ugly.bot log forwarding. Individual subsystems (Postgres, NATS, storage, AI providers) are loaded lazily so a host without DATABASE_URL / NATS_URL / R2_BUCKET doesn't pay their startup cost.

AppConfigurator

Passed to the optional 4th argument of createApp. Every method is optional (except setPages if you want the SPA served).

| Method | Description | |--------|-------------| | setPages({ pages, renderPage?, clientDistPath? }) | Mount the SPA. In dev, runs Vite in middleware mode; in prod, serves dist/client. Provide renderPage(routeName, params) => Promise<string> for SSR on pages marked 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) — the app's chance to create the user record. | | setAuth(provider) | Replace the default auth provider. Must implement verify(code), authUrl(origin), optional registerRoutes(router, { db, onUserCreate }). | | setOnSocketMessage(handler) | Single raw-WebSocket message handler. (ws, userId, msg) => boolean — 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. | | setIsAdmin(fn) | (userId, db) => boolean \| Promise<boolean> — gate for admin-only framework requests (adminCreateTestUser / list / delete). Defaults to matching MAINTAIN_BOT_USER_ID. | | setOnEmail(handler) | Handle inbound emails routed to {domain}@ugly.bot (delivered 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 GET /_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. Access state via captured imports (app.db, storage, pgQuery, uglyBotRequest, etc.).

// 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) => { ... }

Built-in framework requests

createApp automatically registers several framework handlers, accessible from any client via the normal RPC pipeline. App-provided handlers with the same name override the framework's defaults.

| Name | Purpose | |------|---------| | userGet | Returns { userId, name, avatarUri } for the given user (or caller). | | initSession | Records a session start, returns experiment branch assignments. | | captureEvent | Records a client event tied to the session and experiment branches. | | textGen / imageGen | AI proxies — server-validated, billed through ugly.bot. | | kagiSearch / kagiSummarize / kagiEnrichWeb / kagiEnrichNews | Web search via ugly.bot. | | uploadUrl | Issues a presigned PUT for the temp bucket. | | shareLink | Mint a https://ugly.bot/l/<code> short link with OG metadata (see the "sharing links" rule in CLAUDE.md). | | feedbackReportCreateNoAuth / errorLogCaptureNoAuth | Same-origin, public endpoints used by the built-in browser telemetry to write into the project's own Postgres. | | submitFeedbackBot | Forwards db.captureFeedback writes for the maintain-bot persona. | | feedbackReportResolve | Admin-only — resolve or decline a feedback report. | | adminCreateTestUser / adminListTestUsers / adminDeleteTestUser | Admin-only synthetic-user management. Gated by setIsAdmin() (or MAINTAIN_BOT_USER_ID). |


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 version/created/updated on inserts. Always generate _id with nanoid() — never crypto.randomUUID(), Date.now(), or Math.random() (see CLAUDE.md).

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, cacheQuery: ['q'] }),
  'blog/*slug':    definePage<{ slug: string }>({ ssr: true }), // /blog/any/path
});
export type AppPages = typeof pages;

definePage<Params>(options?) returns a PageDef<Params> — a runtime object carrying PageMeta plus a phantom _params used only for TypeScript inference. Options:

  • auth (default true) — protected route; router surfaces the framework's <AuthRoot> on the client and the server injects window.__AUTH_TOKEN__ only for a verified cookie.
  • ssr (default false) — server-render the page for SEO. { auth: true, ssr: true } is dropped to ssr: false with a warning: the SSR document is served from a shared edge cache and must not depend on the viewer.
  • cacheQuery?: string[] — query params that participate in the SSR edge-cache key. Anything NOT listed bypasses the cache instead of poisoning it (e.g. cacheQuery: ['q'] on a search page).
  • ssrCacheTimeout?: number — edge cache lifetime in seconds. Default is effectively 1 year (DEFAULT_SSR_CACHE_TIMEOUT) because the buildId is part of the cache key. Override only for pages that drift between deploys.

Path syntax: :param matches a single path segment; *param is greedy (captures slashes). Query-string params are declared in Params but never appear in the path template.

definePages<T>(p) is a pass-through identity function that widens no types — use it to give the registry a name.


Client

bootstrapApp()

The recommended entrypoint. Handles auth detection, socket creation, background account-sync with ugly.bot, and provider wiring.

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

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

BootstrapAppOptions:

| Field | Description | |-------|-------------| | requests | Your RequestRegistry (merged with framework requests internally). | | messages? | Your MessageRegistry (merged with framework messages). | | RouterProvider | The RouterProvider component returned from createRouter(). | | render | Callback returning the app's UI tree (typically <RouterView />). | | 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?: false | Disable the framework <KeyboardProvider> wrapper. | | silentSso? | Apex-domain apps (ugly.chat, ugly.press, …) that use ugly.bot as their auth authority but don't share its cookie. When true, a logged-out boot attempts a top-level SSO redirect (once per tab, guarded) to adopt an existing ugly.bot session. Leave unset for *.ugly.bot subdomains and Mode B (self-auth) apps. |

bootstrapApp reads window.__AUTH_TOKEN__ (injected by the server after cookie verification). If absent, it renders unauthenticated immediately — the router's per-route auth guard surfaces the framework's <AuthRoot> (Mode A → <LoginPopup>; Mode B → magic-link form + optional Google button) only when the user opens a protected route. If the token is present, it connects the socket, mounts <AppProvider>, and renders.

After render (both logged-in and logged-out), a hidden iframe silently calls ${UGLY_BOT_URL}/oauth/silent: if it returns a fresh code, the cookie is refreshed via POST /auth/verify; if the returned account differs from the current session, the page reloads onto the live account (guarded once per from→to pair to prevent loops). Any ?ugly_oauth_code=… on the URL (redirect-fallback OAuth code from ugly.bot) is redeemed at the very top of bootstrap before anything renders.

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

The previous first-paint-blocking <AutoLoginGate> iframe was removed: logged-out landing pages now paint immediately.

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,
  Link,
  setAllPages,
} = createRouter({ pages, allPages });

Config:

  • pages (required) — the PageRegistry from shared/pages.ts.
  • allPages? — the lazy route → element map (PageMap<Pages>). Prefer registering via setAllPages() from the browser entry when the app also server-renders — the Worker bundle has no code splitting, so a static import inlines every page (and its deps) into worker.js.
  • ssrPages?Partial<{ [K]: ComponentType<Params> }> of statically-imported components for ssr: true routes. Used by both the Worker's renderToString and the client's first hydration render so the initial paint resolves synchronously and never mismatches the server output.

createRouter returns:

  • RouterProvider — props: children, fallback?, isAuthenticated?, initialUrl? ({ pathname, search }, required for SSR + hydration). Manages route state, browser history, and the popup layer. When isAuthenticated() returns false on a route with auth: true, the framework's <AuthRoot> renders synchronously in place of the page — apps cannot override this fallback (the framework owns the login UX so an app can't accidentally ship an auth-required route with no working login button).
  • RouterView — renders the active page with animated transitions. Props: durationMs?, easing?, transitionComponent? (replaces the default ViewFlipper), renderPage?(state) => ReactElement (sync alternative to allPages loaders, called with RouterStateRaw).
  • useRouter() — returns the RouterContextValue<Pages> (typed navigation + popup API). Throws if used outside <RouterProvider>.
  • Link — typed SPA link bound to this router's pages. Renders a real <a> (right-click / cmd-click / SEO keep working) but intercepts a plain left-click and routes through push / replace. Props: to, params (both type-checked against pages), replace?, children, plus the usual anchor attrs.
  • setAllPages(map) — register the lazy PageMap<Pages> after construction (typically from the browser entry). Keeps the Worker's import graph free of every lazy page while the client still gets code-split routes.

A standalone Link is also exported from ugly-app/client for code that can't easily reach the typed one; it accepts an optional router prop and falls back to <RouterProvider> context.

Never use a bare <a href="/route"> for internal navigation — it triggers a full document reload (white flash + repaint). See the "client navigation" rule in CLAUDE.md.

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.

Both wrappers recover from stale-deploy chunk 404s ("Failed to fetch dynamically imported module"): on the first failure they trigger a single window.location.reload() (guarded by sessionStorage so a genuinely broken chunk can't loop) to fetch the fresh index.html with new chunk hashes.

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,           // { routeName, params } — typed union over all pages
  transitionType,    // 'PUSH' | 'REPLACE' | 'POP' | 'NONE' — how we got here
  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

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

Popups — openPopup()

Use useRouter().openPopup() for modals, sheets, and menus. The router owns the popup layer, drives 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

openPopup returns a PopupHandle = { id, hide }. 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 a createAnimatedValue() result 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 (string)
  user,          // UserBase doc
  socket,        // AppSocket — typed RPC client
  uglyBotSocket, // UglyBotSocket | null — for direct platform calls (STT/TTS, etc.)
  showPopup,     // legacy popup API (prefer useRouter().openPopup)
  hidePopup,
  hideAllPopups,
  runAsync,      // (label, async () => {...}, options?) — shows loading overlay while pending
  splashDone,    // (step: string) — mark a splash-screen step complete
  localizer,     // (key, params?) => string — alias for useLocalizer()
} = useApp();

useApp<TAsyncOptions>() is generic in the async-options type so apps can pass a custom option through to a custom loadingOverlay element (accepted as an AppProvider prop). 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.

Direct socket access

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

| Method | Description | |--------|-------------| | 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 (with initial snapshot) — returns unsubscribe. | | trackDocs(collection, params, cb) | Live filtered subscription (with initial snapshot). | | 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>) can branch correctly.

Mode A — ugly.bot OAuth (default)

  1. Every page request: the server checks the auth_token cookie. If it verifies through ${UGLY_BOT_URL}/verify, the server injects window.__AUTH_TOKEN__ into the HTML.
  2. Client boot: if __AUTH_TOKEN__ is present, bootstrapApp opens the WebSocket with the token. Otherwise it renders logged-out immediately (no first-paint block).
  3. Background account-sync (always): bootstrapApp opens a hidden iframe to ${UGLY_BOT_URL}/oauth/silent on every load. If it returns a one-time code, the client POSTs /auth/verify to refresh the cookie. If the refreshed account's userId differs from the current session, the client calls /auth/logout and reloads onto the live account (guarded once per from→to pair).
  4. Apex apps (silentSso: true): if the hidden iframe can't read ugly.bot's cookie (Safari / 3p-cookie blocking), a top-level redirect to /oauth/silent (first-party, bypasses 3p blocking) runs instead — one-shot, marked done in localStorage so it can't loop. Skipped for accounts already confirmed live.
  5. Manual login: on protected routes, the per-route auth guard surfaces <AuthRoot><LoginPopup>, which opens ${UGLY_BOT_URL}/oauth in a popup. On completion, the platform redirects back with either a postMessage code or ?ugly_oauth_code=…; bootstrapApp redeems either at the top of boot.

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>.
  • Background ugly.bot silent SSO is a no-op — 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:

| Endpoint | Description | |----------|-------------| | POST /auth/verify | Exchange an OAuth code for a session cookie. | | POST /auth/logout | Clear the cookie. | | GET /auth/token | Refresh and return the current token. | | GET /auth/url | Return the OAuth popup URL. | | POST /auth/magic-link/request | Mode B only — send a magic-link email. | | GET /auth/magic-link/verify | Mode B only — verify a magic-link token and set the cookie. | | GET /auth/google/url, POST /auth/google/callback | Mode 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

  • 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. 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. Partial updates go through optimistic concurrency to prevent lost updates on concurrent writers.

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.

Search

// 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';
import { nanoid } from 'nanoid';

const newDoc = { _id: nanoid(), ...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 a 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);

Web search

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.

On Cloudflare Workers, storage uses the R2 binding directly; presigned PUTs are not exposed (browser uploads must go through a Worker endpoint).


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.

On the Workers adapter, scheduled workers dispatch through Cloudflare Cron Triggers and durable queueing goes through Cloudflare Queues. On the Node adapter, enqueueWorker runs the handler inline (in-process queues only).


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

| Endpoint | Description | |----------|-------------| | GET /health | Health check — returns { status, timestamp, lastRequestAt }. | | POST /api/:name | Dispatch any registered request handler over HTTP. | | POST /auth/verify | Exchange OAuth code for 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. | | GET /_workers/manifest | Worker definitions (used by ugly-studio). | | POST /_workers/run | Synchronously invoke a worker handler. | | GET /_workers/runs | Recent in-memory worker runs (last 200). | | GET /_cron/manifest | Cron tasks for the deploy orchestrator. | | POST /api/_cron/:taskName | Trigger a cron task (auth via CRON_SECRET). | | POST /internal/email-callback | Inbound email gateway (auth via INTERNAL_EMAIL_SECRET). | | PUT /_s3/* | Dev-only S3 upload proxy (avoids CORS with MinIO). |


Package entry points

| Import path | Description | |-------------|-------------| | ugly-app | Server: createApp, TypedDB, auth, AI clients, NATS, storage, email, push, workers. | | ugly-app/shared | Cross-tier: defineRequests, defineCollections, definePage, defineWorkers, Zod, experiments, time constants. | | ugly-app/client | React: bootstrapApp, createRouter, lazyPage, AppProvider, components, animations, audio, AI helpers. | | ugly-app/server/adapter/workers | Cloudflare Workers entry (Hono router + Durable Objects + Neon HTTP driver + R2). | | ugly-app/conversation/{shared,server,client,engine} | 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/search/{shared,server} | Search primitives. | | ugly-app/agent/{shared,server,client} | Agent-mode SSE + tool orchestration. | | ugly-app/webrtc, ugly-app/webrtc/server | WebRTC video rooms. | | ugly-app/three/{server,client} | Three.js scene helpers. | | ugly-app/native, ugly-app/native/server | Native task host + conformance harness. | | ugly-app/inspect, ugly-app/inspect/agent, ugly-app/inspect/ui | UX inspection agent + host bundle. | | ugly-app/worker | Worker queue runtime. | | ugly-app/playwright | E2E test utilities (inspectWindow, expectClean, setDevice, …). | | ugly-app/testing | Test-mode helpers. | | ugly-app/vite, ugly-app/eslint | Build-tool plugins. |


Two-adapter architecture

The same developer source compiles for two runtimes via src/server/adapter/:

  • Adapter A — Node + TCP (src/server/adapter/node/): default for npm run dev and any non-Workers deploy. Wraps pg.Pool, nats.js, AWS S3 SDK.
  • Adapter B — Cloudflare Workers (src/server/adapter/workers/): used when the Studio publish flow deploys to Cloudflare. Hono router + Durable Objects (one CollectionDO per project-collection, plus a SessionDO per user WS) + @neondatabase/serverless HTTP driver + R2 binding + Cloudflare Cron Triggers + Cloudflare Queues.

Developer-facing APIs (createTypedDB, subscribeDoc, createStorageClient, setCronTasks, setWorkers) don't change between adapters. Local Workers dev boots via npm run dev:workers, which starts a small Node HTTP proxy speaking Neon's wire format so the Worker can talk to a real Postgres.


Environment variables

| Variable | Description | |----------|-------------| | PORT | Server port (templates default to 4321). | | NODE_ENV | development or production. | | UGLY_BOT_TOKEN | App token for the ugly.bot platform — required for AI, logs, billing. | | UGLY_BOT_URL | Override the platform base URL (default https://ugly.bot). | | DATA_PROXY_URL | WebSocket URL for the data proxy (default ws://localhost:4200). | | DATA_PROXY_TOKEN | Auth token for the data proxy. | | STORAGE_KEY_PREFIX | Prefix all storage keys (per-env isolation). | | MINIO_ENDPOINT | Dev-only S3 endpoint for the upload proxy. | | NATS_PREFIX / COMPOSE_PROJECT_NAME | NATS subject prefix for per-env isolation. | | CLOCK_ENABLED | true to enable setOnMinuteTick / setOnHourlyTick. | | CRON_SECRET | Bearer secret for POST /api/_cron/:taskName and prod POST /_workers/run. | | MAINTAIN_BOT_USER_ID | User id allowed to access admin-only handlers when setIsAdmin() is unset. | | INTERNAL_EMAIL_SECRET | Shared secret for /internal/email-callback. | | JWT_SECRET | Required when using getRequestUser() for the per-project session cookie. | | APP_DOMAIN | App domain; combined with NATS_PREFIX for getRequestUser() validation. | | LOG_CAPTURE_URL | Studio override for client log capture (empty → ugly.bot default). | | UGLY_APP_HMR | Set to false to disable Vite HMR in dev. | | SCHEMA_CHECK_SKIP | true to start despite schema drift (unsafe). | | NEON_PROXY_URL | Set by dev:workers — Neon-wire endpoint the local Neon HTTP driver dials. | | CLOUDFLARE_WORKERS | Set to 1 inside the Workers adapter for runtime detection. |

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


CLI

| 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/update .uglyapp config. | | ugly-app login | Authenticate with ugly.bot. | | ugly-app url | Print the local dev server URL. | | ugly-app deploy | Build + push to production infrastructure. | | ugly-app prod --buildId <id> | Promote a build to prod. | | ugly-app versions | List deployed versions. | | ugly-app versions:prune | Clean up non-prod versions. | | ugly-app infra:destroy | Tear 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:prod | Query error logs (your tunnel / production). | | ugly-app perf:dev / perf:prod | Query performance metrics. | | ugly-app feedback:dev / feedback:prod | Query user feedback. | | ugly-app feedback:submit / feedback:resolve | Manage feedback (run with --help for flags). |

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


Migrations

Schema changes must be deliberate:

  1. Update the Zod schema in shared/collections.ts.
  2. Run npm run db:schema-gen — produces a migration file with compile-blocking REPLACE_ME placeholders for any non-trivial change.
  3. Replace every REPLACE_ME with the correct migration logic.
  4. 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) · Cloudflare Workers + Durable Objects (Adapter B) · ugly.bot platform