ugly-app 0.1.38 → 0.1.40

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,709 +1,720 @@
1
- # website-core
2
-
3
- A full-stack TypeScript framework for building production-ready web applications. Provides an opinionated architecture combining an Express backend, React frontend, and MongoDB database with built-in authentication, real-time communication, storage, AI integration, and audio streaming.
4
-
5
- ## What's Included
6
-
7
- - **Server**: Express with type-safe RPC routing and Zod schema validation
8
- - **WebSockets**: Bidirectional socket server/client with RPC calls, document tracking, and file uploads
9
- - **Database**: MongoDB with typed collections, cascade delete, indexes, migrations, and NATS-based real-time document tracking
10
- - **Auth**: JWT + HttpOnly cookie sessions, OAuth (ugly.bot by default, extensible via `AuthProvider` interface)
11
- - **Storage**: AWS S3 / Cloudflare R2 with presigned URLs and file promotion
12
- - **AI**: 7 text generation providers (Together, Claude, OpenAI, Google, Groq, Fireworks, Kie) + 5 image providers (Together, FAL, Google, Kie, Wavespeed)
13
- - **Audio**: Text-to-speech and speech-to-text streaming with React hooks (`useTTS`, `useSTT`), optional viseme data for lip sync, and word-level timestamps
14
- - **Logging**: Multi-channel logging to MongoDB (error, console, perf, feedback) with server error capture, deduplication, and classification
15
- - **Queues**: NATS JetStream worker queues, Redis pub/sub with in-memory fallback
16
- - **Billing**: AI spend tracking with global/provider/per-user rolling limits (hourly/daily/weekly), threshold callbacks, profit margin markup, pre-paid user credits, full audit history, and 5-minute DB reconciliation
17
- - **Email**: Transactional email via Mailgun with Handlebars template support
18
- - **Rate Limiting**: Per-user/per-operation token-bucket limiting with queue management
19
- - **Push Notifications**: Real-time delivery via WebSocket + Redis, with FCM support
20
- - **Feedback**: Built-in user feedback collection with screenshot capture
21
- - **CLI**: `web` command for dev, build, deploy, migrations, log queries, and auth utilities
22
-
23
- ## Installation
1
+ # ugly-app
24
2
 
25
- ```bash
26
- npm install website-core
27
- ```
28
-
29
- Peer dependencies:
3
+ 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.
30
4
 
31
- ```bash
32
- npm install react react-dom html2canvas
33
- ```
5
+ ## What's included
34
6
 
35
- ## Quick Start
7
+ - **Server**: Express + WebSocket with type-safe RPC and Zod validation
8
+ - **Client**: React + Vite with typed routing, lazy pages, and popup management
9
+ - **Database**: MongoDB with typed collections, indexes, migrations, and live document tracking
10
+ - **Auth**: JWT + HttpOnly cookies, ugly.bot OAuth out of the box, extensible via `AuthProvider`
11
+ - **AI**: Text generation (Together, Claude, OpenAI, Google, Groq, Fireworks) + image generation (Together, FAL, Google, Wavespeed)
12
+ - **Storage**: Cloudflare R2 / AWS S3 with presigned uploads
13
+ - **CLI**: `ugly-app` commands for dev, build, deploy, migrations, logs, and auth utilities
36
14
 
37
- ### Scaffold a new project
15
+ ## Quick start
38
16
 
39
17
  ```bash
40
- npx web init my-app
18
+ npx ugly-app init my-app
19
+ cd my-app
20
+ npm run dev
41
21
  ```
42
22
 
43
- ### Start development
23
+ ---
44
24
 
45
- ```bash
46
- yarn dev
47
- # Starts Docker services, Express server, Vite, TypeScript watcher, and ESLint concurrently
48
- ```
25
+ ## Server
49
26
 
50
- ## Usage
27
+ ### `createApp()`
51
28
 
52
- ### Server
29
+ Entry point for the server. Creates an Express + WebSocket server with typed RPC, auth, and all framework services.
53
30
 
54
31
  ```typescript
55
- import { createApp, createUserHelper, registerFeedbackHandlers } from 'website-core';
56
- import { dbDefaults } from 'website-core/shared';
57
- import { functions, requests } from '../shared/api.js';
58
- import { collections } from '../shared/collections.js';
59
- import { pages } from '../shared/pages.js';
60
- import type { User } from '../shared/types.js';
32
+ import { createApp, createUserHelper, type CallHandlers, type RequestHandlers, type HandlerContext } from 'ugly-app';
33
+ import { dbDefaults } from 'ugly-app/shared';
34
+ import { functions, requests } from '../shared/api';
35
+ import { collections } from '../shared/collections';
36
+ import { pages } from '../shared/pages';
37
+ import type { User } from '../shared/types';
61
38
 
62
- export const userHelper = createUserHelper(collections.user);
39
+ const userHelper = createUserHelper<User>(collections.user);
63
40
 
64
- const app = createApp({ functions, requests }, collections, (config) => {
65
- config.setPages({ pages });
41
+ const calls = {
42
+ myFunction: async (ctx: HandlerContext, input) => {
43
+ return { greeting: `Hello, ${input.name}` };
44
+ },
45
+ } satisfies CallHandlers<typeof functions>;
66
46
 
67
- config.setUserHelper(userHelper); // required
47
+ const reqs = {
48
+ getMe: async (ctx: HandlerContext) => {
49
+ const user = await userHelper.get(ctx.db, ctx.userId);
50
+ return { userId: ctx.userId, email: user?.email };
51
+ },
52
+ } satisfies RequestHandlers<typeof requests>;
68
53
 
69
- config.setOnUserCreate(async (userId, info, db) => { // required
70
- await userHelper.set(db, {
71
- ...dbDefaults(),
72
- id: userId,
73
- email: info.email,
74
- phone: info.phone,
75
- });
54
+ const app = createApp({ functions, requests }, calls, reqs, collections, (configurator) => {
55
+ configurator.setPages({ pages });
56
+ configurator.setUserHelper(userHelper);
57
+ configurator.setOnUserCreate(async (userId, info, db) => {
58
+ await userHelper.set(db, { id: userId, ...dbDefaults(), ...info });
76
59
  });
77
-
78
- // Optional
79
- // config.setAuth(myAuthProvider);
80
- // config.setContextExtensions(async (base) => ({ nats: await connectNats() }));
81
60
  });
82
61
 
83
- app.registerRequest('getMe', async (ctx) => {
84
- const user = await userHelper.get(ctx.db, ctx.userId);
85
- return { userId: ctx.userId, email: user?.email };
86
- });
62
+ await app.start(3000);
63
+ ```
87
64
 
88
- registerFeedbackHandlers(app, process.env.MAINTAIN_BOT_USER_ID ?? '');
65
+ **Signature:**
89
66
 
90
- const port = parseInt(process.env['PORT'] ?? '3000');
91
- await app.start(port);
67
+ ```typescript
68
+ function createApp<R extends AppRegistryBase, Defs extends CollectionDefRegistry>(
69
+ registry: R,
70
+ calls: Partial<CallHandlers<R['functions']>>,
71
+ requests: Partial<RequestHandlers<R['requests']>>,
72
+ appDefs: Defs,
73
+ configure?: (configurator: AppConfigurator) => void,
74
+ ): App;
92
75
  ```
93
76
 
94
- > Both `setUserHelper` and `setOnUserCreate` are **required** — `createApp` throws at startup if either is missing.
77
+ The returned `App` object has:
78
+ - `start(port?)` — starts the server (default port 3000)
79
+ - `registerRoutes(fn)` — mount additional Express routes after creation
80
+ - `httpServer` — the underlying Node.js HTTP server
81
+ - `dispatch(type, name, input, userId)` — invoke an RPC handler programmatically
95
82
 
96
- ### Shared types and API definitions
83
+ ### `AppConfigurator`
97
84
 
98
- ```typescript
99
- import { fn, req, defineFunctions, defineRequests } from 'website-core/shared';
100
- import { z } from 'website-core/shared';
85
+ | Method | Description |
86
+ |--------|-------------|
87
+ | `setPages(options)` | Serves pages with Vite (dev) or static files (prod). `options: { pages, renderPage?, clientDistPath? }` |
88
+ | `setUserHelper(helper)` | User lookup for WebSocket auth handshake |
89
+ | `setOnUserCreate(handler)` | Called on first login — must create the user record. `(userId, { email?, phone? }, db) => Promise<void>` |
90
+ | `setAuth(provider)` | Custom `AuthProvider` (default: ugly.bot OAuth). Provider must implement `verify(code)` and `authUrl(origin)` |
91
+ | `setContextExtensions(fn)` | Add fields to `HandlerContext` — `(base: HandlerContext) => Promise<E>` |
92
+ | `setOnSocketMessage(handler)` | Handle raw WebSocket messages. Return `true` to consume, `false` to let the framework handle it |
93
+ | `registerRoutes(fn)` | Mount custom Express routes — `(router: express.Router) => void` |
94
+ | `setWorkerQueue(queue)` | Register a background worker queue with `start()` and `stop()` |
101
95
 
102
- export const functions = defineFunctions({
103
- greet: fn(
104
- z.object({ name: z.string() }),
105
- z.object({ message: z.string() })
106
- ),
107
- });
108
- ```
96
+ ### Handler context (`ctx`)
109
97
 
110
- ### React client
98
+ Every RPC handler receives a `HandlerContext`:
111
99
 
112
- ```typescript
113
- import { AppProvider, useApp, createRouter, createSocket } from 'website-core/client';
114
- ```
100
+ | Field | Type | Description |
101
+ |-------|------|-------------|
102
+ | `ctx.userId` | `string` | Authenticated user ID (always set) |
103
+ | `ctx.db` | `TypedDB` | MongoDB client with typed collection methods |
104
+ | `ctx.storage` | `StorageClient` | R2/S3 presigned uploads and public URLs |
105
+ | `ctx.textGen` | `TextGenClient` | Text generation (see AI section) |
106
+ | `ctx.imageGen` | `ImageGenClient` | Image generation |
107
+ | `ctx.log` | `Logger` | Structured logging to MongoDB |
108
+ | `ctx.rateLimit` | `RateLimiter` | Token-bucket rate limiting |
115
109
 
116
- ## Key APIs
110
+ ---
117
111
 
118
- ### Server `createApp()`
112
+ ## Shared API definitions
119
113
 
120
- ```typescript
121
- createApp(registry, appDefs, configure?)
122
- ```
114
+ All type definitions live in `shared/` and are used by both server and client.
123
115
 
124
- - `registry` `{ functions, requests }` from your shared API definitions
125
- - `appDefs` — collection defs from `defineCollections()`
126
- - `configure` — optional callback receiving an `AppConfigurator`:
116
+ ### Functions and requests (`shared/api.ts`)
127
117
 
128
118
  ```typescript
129
- interface AppConfigurator {
130
- setUserHelper(helper: UserHelper<any>): void; // required
131
- setOnUserCreate(handler: OnUserCreate): void; // required
132
- setAuth(provider: AuthProvider): void;
133
- setPages(options: PageServerOptions): void;
134
- setOnSocketMessage(handler: (ws, userId, msg) => boolean): void;
135
- setContextExtensions<E>(fn: (base: HandlerContext) => Promise<E>): void;
136
- setWorkerQueue(queue: WorkerQueue): void;
137
- registerRoutes(fn: (router: express.Router) => void): void;
138
- }
139
- ```
140
-
141
- Registers handlers with full context (`userId`, `db`, `storage`, `textGen`, `imageGen`, `log`, `rateLimit`):
119
+ import { defineFunctions, defineRequests, fn, req, z } from 'ugly-app/shared';
142
120
 
143
- ```typescript
144
- app.registerFunction('greet', async (ctx, input) => {
145
- ctx.log.info('Greeting user', { userId: ctx.userId });
146
- return { message: `Hello, ${input.name}` };
121
+ export const functions = defineFunctions({
122
+ createNote: fn({
123
+ input: z.object({ title: z.string(), body: z.string() }),
124
+ output: z.object({ id: z.string() }),
125
+ }),
147
126
  });
148
127
 
149
- app.registerRequest('stream', async (ctx, input) => {
150
- // streaming HTTP handler — ctx.res available for request handlers
128
+ export const requests = defineRequests({
129
+ getNotes: req({
130
+ input: z.object({}),
131
+ output: z.array(z.object({ id: z.string(), title: z.string() })),
132
+ }),
151
133
  });
152
134
  ```
153
135
 
154
- Built-in routes: `GET /health`, `POST /auth/verify`, `GET /auth/token`, `POST /auth/logout`, `GET /auth/url`, `POST /logs/client`, `GET /my_feedback`
155
-
156
- ### AuthCookie Sessions
136
+ - `fn({ input, output })` defines a mutation (write operation). `input` and `output` are Zod schemas.
137
+ - `req({ input, output })` — defines a query (read operation).
138
+ - `defineFunctions()` / `defineRequests()` identity wrappers that preserve types.
139
+ - `z` is re-exported from Zod for convenience.
157
140
 
158
- After login, the server sets an `auth_token` HttpOnly cookie. On every page load the server refreshes it and injects the token into the HTML:
159
-
160
- ```html
161
- <script>window.__AUTH_TOKEN__ = "eyJ..."</script>
162
- ```
163
-
164
- The client reads `window.__AUTH_TOKEN__` synchronously — no localStorage, no extra HTTP round-trip. To log out:
141
+ ### Collections (`shared/collections.ts`)
165
142
 
166
143
  ```typescript
167
- await fetch('/auth/logout', { method: 'POST' });
168
- window.location.reload();
169
- ```
170
-
171
- ### Database — `TypedDB`
172
-
173
- Define collections in `shared/collections.ts`. The `defineCollections` call injects the collection name into each def, making the def itself the collection reference:
174
-
175
- ```typescript
176
- import { defineCollections } from 'website-core/shared';
144
+ import { defineCollections } from 'ugly-app/shared';
145
+ import type { Note } from './types';
177
146
 
178
147
  export const collections = defineCollections({
179
- notes: {
148
+ note: {
180
149
  type: {} as Note,
181
150
  meta: { cache: true, trackable: true, public: false, parent: null },
182
- onDelete: async (doc, db) => { /* cleanup child docs */ },
183
151
  },
184
- comments: {
185
- type: {} as Comment,
186
- meta: { cache: false, trackable: true, public: false, parent: 'notes' },
152
+ user: {
153
+ type: {} as User,
154
+ meta: { cache: true, trackable: false, public: false, parent: null },
187
155
  },
188
156
  });
189
157
  ```
190
158
 
191
- Inside handlers, use `ctx.db` — a `TypedDB` instance pre-created by `createApp`. All methods take the collection def as the first argument (not a string name):
159
+ Collection meta fields:
160
+ - `cache` — enable in-memory caching
161
+ - `trackable` — allow real-time `trackDoc`/`trackDocs` subscriptions
162
+ - `public` — accessible without auth
163
+ - `parent` — parent collection name for cascade deletes (or `null`)
164
+
165
+ All documents extend `DBObject`: `{ id: string, version: number, created: Date, updated: Date }`.
166
+
167
+ ### Pages (`shared/pages.ts`)
192
168
 
193
169
  ```typescript
194
- // Insert / replace
195
- await ctx.db.setDoc(collections.notes, doc);
196
- await ctx.db.setDoc(collections.notes, doc, { skipIfExists: true }); // INSERT only
170
+ import { definePage, definePages } from 'ugly-app/shared';
197
171
 
198
- // Partial updates (dot-notation, sets `updated` automatically)
199
- await ctx.db.setDocFields(collections.notes, id, { title: 'New title' });
200
- const doc = await ctx.db.setDocFieldsOrIgnore(collections.notes, id, { title }); // null if missing
201
- const doc = await ctx.db.setDocFieldsOrCreate(collections.notes, id, fields, fullObj);
172
+ export const pages = definePages({
173
+ '': definePage<{}>({ auth: false }), // /
174
+ 'note/:noteId': definePage<{ noteId: string }>(), // /note/abc
175
+ 'search': definePage<{ q?: string }>({ auth: false }),// /search?q=foo
176
+ 'blog/*slug': definePage<{ slug: string }>({ ssr: true }),// /blog/any/path
177
+ });
178
+ export type AppPages = typeof pages;
179
+ ```
202
180
 
203
- // MongoDB update operators ($inc, $addToSet, $pull, $unset, $set)
204
- await ctx.db.setDocOp(collections.notes, id, { $inc: { viewCount: 1 } });
205
- const doc = await ctx.db.setDocOpOrIgnore(collections.notes, id, { $addToSet: { tags: 'foo' } });
181
+ - `:param` single path segment; `*param` greedy rest (captures slashes)
182
+ - `auth: true` (default) requires login; `auth: false` public
183
+ - `ssr: true` server-renders the page (supply `renderPage` to `setPages()`)
184
+ - The generic type parameter on `definePage<Params>()` types the route's params — it's phantom (never set at runtime)
185
+
186
+ ---
206
187
 
207
- // Reads
208
- const note = await ctx.db.getDoc(collections.notes, id);
209
- const notes = await ctx.db.getDocs(collections.notes, { userId }, { sort: { created: -1 }, limit: 20, skip: 0 });
188
+ ## Client
210
189
 
211
- // Aggregate pipeline queries
212
- const results = await ctx.db.getQuery<MyResult>('notes', pipeline, { skip, limit });
213
- const count = await ctx.db.getQueryCount('notes', pipeline);
214
- const raw = await ctx.db.getQueryRaw<RawDoc>('notes', pipeline); // no id remapping
190
+ ### Entry point (`client/main.tsx`)
215
191
 
216
- // Delete with cascade
217
- await ctx.db.deleteDoc(collections.notes, id);
218
- await ctx.db.deleteQuery(collections.notes, { userId });
192
+ ```tsx
193
+ import { createRoot } from 'react-dom/client';
194
+ import { AppProvider, createSocket, LoginPopup } from 'ugly-app/client';
195
+ import { functions, requests } from '../shared/api';
196
+ import { RouterProvider } from './router';
197
+ import App from './App';
219
198
 
220
- // Nested / child collections (parentId compound _id: "parentId:leafId")
221
- await ctx.db.setDoc(collections.comments, comment, { parentId: noteId });
222
- await ctx.db.getDocs(collections.comments, {}, { parentId: noteId });
223
- await ctx.db.deleteDoc(collections.comments, commentId, { parentId: noteId });
199
+ const token = (window as any).__AUTH_TOKEN__;
200
+ const root = createRoot(document.getElementById('root')!);
201
+ const loginPopup = <LoginPopup onSuccess={() => window.location.reload()} />;
224
202
 
225
- // Direct cache access
226
- const key = ctx.db.cacheKey('notes', id);
227
- ctx.db.cacheSet(key, value, 60_000);
228
- const cached = ctx.db.cacheGet<Note>(key);
229
- ctx.db.cacheDelete(key);
203
+ if (!token) {
204
+ root.render(
205
+ <RouterProvider fallback={<div>404</div>} loginFallback={loginPopup} isAuthenticated={() => false}>
206
+ <App socket={null} />
207
+ </RouterProvider>,
208
+ );
209
+ } else {
210
+ const userId = JSON.parse(atob(token.split('.')[1]!)).sub as string;
211
+ const socket = createSocket({ functions, requests, url: '/rpc' });
212
+ socket.connect(token).then((user) => {
213
+ root.render(
214
+ <RouterProvider fallback={<div>404</div>} loginFallback={loginPopup} isAuthenticated={() => true}>
215
+ <AppProvider socket={socket} userId={userId} user={user}>
216
+ <App socket={socket} />
217
+ </AppProvider>
218
+ </RouterProvider>,
219
+ );
220
+ });
221
+ }
230
222
  ```
231
223
 
232
- Per-collection `cache: true` enables automatic LRU caching on `getDoc` / `setDoc` / `deleteDoc`. Use `parent: 'collectionName'` to define parent–child relationships for compound `_id`s and cascade deletes.
224
+ ### `createSocket()`
233
225
 
234
- ### WebSocket Client
226
+ Creates a typed WebSocket client for RPC communication.
235
227
 
236
228
  ```typescript
237
- const socket = createSocket({ functions, requests });
238
-
239
- await socket.connect(token); // must call first
240
- await socket.call('greet', { name: 'world' }); // function RPC
241
- await socket.request('getMe', {}); // request RPC
242
- const doc = await socket.getDoc('notes', id); // fetch document
243
- socket.trackDoc('notes', id, (doc) => { /* live */ }); // live updates
244
- await socket.uploadFile(file, key); // file upload (key is a string)
229
+ const socket = createSocket({ functions, requests, url: '/rpc' });
230
+ await socket.connect(token); // returns UserBase
245
231
  ```
246
232
 
247
- `trackDocs` accepts optional `sort`, `limit`, and `skip` parameters for server-side ordering and pagination:
233
+ **`AppSocket` methods:**
248
234
 
249
- ```typescript
250
- // Live-updating query: most recent 20 notes, newest first
251
- socket.trackDocs('notes', { userId }, (docs) => { /* live */ }, {
252
- sort: { created: -1 },
253
- limit: 20,
254
- skip: 0,
255
- });
235
+ | Method | Description |
236
+ |--------|-------------|
237
+ | `connect(token)` | Authenticate and connect. Returns the user object |
238
+ | `call(name, input)` | Invoke a function (mutation) |
239
+ | `request(name, input)` | Invoke a request (query) |
240
+ | `getDoc(collection, id)` | Fetch a single document |
241
+ | `trackDoc(collection, id, cb)` | Subscribe to real-time doc changes. Returns unsubscribe fn |
242
+ | `trackDocs(collection, filter, cb, opts?)` | Subscribe to query results. Returns unsubscribe fn |
243
+ | `uploadFile(file, key)` | Upload a file via presigned URL |
244
+ | `disconnect()` | Close the connection |
245
+
246
+ ### `AppProvider`
247
+
248
+ Wraps your app with context for `useApp()`. Provides user info, socket access, popup management, async loading overlay, splash screen, and localization.
249
+
250
+ ```tsx
251
+ <AppProvider
252
+ socket={socket}
253
+ userId={userId}
254
+ user={user}
255
+ splashScreen={<MySplash />} // optional
256
+ loadingOverlay={<MyLoader />} // optional — shown during runAsync()
257
+ localizer={(key, params) => t(key)} // optional i18n function
258
+ >
259
+ {children}
260
+ </AppProvider>
256
261
  ```
257
262
 
258
- `getDocs` on the server also accepts the same options:
263
+ **`useApp()` returns:**
264
+
265
+ | Field | Description |
266
+ |-------|-------------|
267
+ | `userId` | Current user ID |
268
+ | `user` | Current `UserBase` object |
269
+ | `socket` | The `AppSocket` instance |
270
+ | `showPopup(content)` | Show a popup (returns popup ID) |
271
+ | `hidePopup(id)` | Hide a specific popup |
272
+ | `hideAllPopups()` | Dismiss all popups |
273
+ | `runAsync(label, fn, opts?)` | Run an async operation with loading overlay |
274
+ | `splashDone(step)` | Mark a splash screen step as complete |
275
+ | `localizer(key, params?)` | Localize a string key |
276
+
277
+ ### Router
278
+
279
+ #### Setup (`client/router.ts`)
259
280
 
260
281
  ```typescript
261
- const docs = await ctx.db.getDocs(collections.notes, { userId }, {
262
- sort: { created: -1 },
263
- limit: 20,
264
- skip: 0,
265
- });
282
+ import { createRouter } from 'ugly-app/client';
283
+ import { pages } from '../shared/pages';
284
+ import { allPages } from './allPages';
285
+
286
+ export const { RouterProvider, RouterView, useRouter } = createRouter({ pages, allPages });
266
287
  ```
267
288
 
268
- ### AI Text Generation
289
+ `createRouter()` returns three things:
290
+ - **`RouterProvider`** — wrap your app. Props: `children`, `fallback?` (shown before first route resolves), `loginFallback?` (shown for auth-guarded pages when unauthenticated), `isAuthenticated?` (function returning boolean)
291
+ - **`RouterView`** — renders the active page with animated transitions. Props: `durationMs?`, `easing?`, `transitionComponent?`
292
+ - **`useRouter()`** — hook returning the router context
293
+
294
+ #### Page map (`client/allPages.ts`)
269
295
 
270
296
  ```typescript
271
- const text = await ctx.textGen.generate(messages, { model });
272
- const json = await ctx.textGen.generateJson(schema, messages, { model });
273
- const result = await ctx.textGen.generateWithTools(messages, tools, { model });
297
+ import { lazyPage, lazyPageLoader } from 'ugly-app/client';
298
+ import type { PageMap } from 'ugly-app/shared';
299
+ import type { AppPages } from '../shared/pages';
300
+
301
+ export const allPages = {
302
+ ['']: lazyPage(() => import('./pages/HomePage')),
303
+ ['note/:noteId']: lazyPage(() => import('./pages/NotePage')),
304
+ ['search']: lazyPage(() => import('./pages/SearchPage')),
305
+ ['slow']: lazyPageLoader(() => import('./pages/SlowPageLoader')),
306
+ } satisfies PageMap<AppPages>;
274
307
  ```
275
308
 
276
- Supported providers: `together`, `claude`, `openai`, `google`, `groq`, `fireworks`, `kie`
309
+ - **`lazyPage(factory)`** lazy-imports a default-exported React component. The component receives route params as props.
310
+ - **`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.
277
311
 
278
- ### AI Image Generation
312
+ #### Navigation with `useRouter()`
279
313
 
280
314
  ```typescript
281
- const url = await ctx.imageGen.generate(prompt, { width: 1024, height: 1024 });
315
+ const { push, replace, back, current, openPopup, closePopup, closeAllPopups } = useRouter();
316
+
317
+ push('note/:noteId', { noteId: '123' }); // pushes /note/123
318
+ replace('search', { q: 'hello' }); // replaces with /search?q=hello
319
+ back(); // browser back
320
+
321
+ // current route state
322
+ current.routeName; // e.g. 'note/:noteId'
323
+ current.params; // e.g. { noteId: '123' }
282
324
  ```
283
325
 
284
- Supported providers: `together`, `fal`, `google`, `kie`, `wavespeed`
326
+ All route names and params are fully typed based on your `pages` definition.
285
327
 
286
- ### Audio — TTS/STT React Hooks
328
+ #### Popups
287
329
 
288
- ```typescript
289
- // Text-to-speech
290
- const { playing, currentWord, play, stop } = useTTS(socket);
291
- await play('Hello world');
330
+ Always use `useRouter().openPopup()` — never build custom fixed overlays.
292
331
 
293
- // With viseme data for lip sync (opt-in — adds latency)
294
- await play('Hello world', { requestVisemes: true });
295
- // viseme events arrive via onViseme callback in useTTS options
332
+ ```tsx
333
+ const { openPopup } = useRouter();
296
334
 
297
- // Speech-to-text
298
- const { transcript, isFinal, listening, start, stop } = useSTT(socket, {
299
- mode: 'realtime', // 'realtime' | 'batch' | 'auto'
335
+ const handle = openPopup(<MyContent />, {
336
+ mode: 'transient', // 'block' | 'transient' | 'contextMenu'
337
+ slideFrom: 'bottom', // 'left' | 'right' | 'top' | 'bottom' | 'none'
338
+ onClose: () => {}, // called when popup closes
339
+ containerStyle: {}, // CSS for the content wrapper
340
+ backgroundStyle: {}, // CSS for the backdrop
341
+ animConfig: { duration: 300, easing: myEasingFn },
342
+ renderLayer: (props) => <CustomLayer {...props} />, // fully replace the popup renderer
300
343
  });
301
344
 
302
- // With word-level timestamps (opt-in uses Groq verbose_json, slower)
303
- const { transcript, words } = useSTT(socket, {
304
- mode: 'batch',
305
- requestWords: true,
306
- });
307
- // words: STTWord[] — each has { word, startMs, durationMs }
345
+ handle.hide(); // dismiss programmatically
308
346
  ```
309
347
 
310
- Viseme and word types are exported from `website-core/shared`:
348
+ Popup modes:
349
+ - **`block`** — dark backdrop (40% opacity), clicking backdrop does NOT dismiss
350
+ - **`transient`** — light backdrop (20% opacity), clicking backdrop dismisses
351
+ - **`contextMenu`** — same as transient, intended for menus and pickers
311
352
 
312
- ```typescript
313
- import type { TTSViseme, TTSVisemeName, STTWord } from 'website-core/shared';
353
+ #### `Link` component
354
+
355
+ ```tsx
356
+ import { Link } from 'ugly-app/client';
357
+
358
+ <Link router={router} to="note/:noteId" params={{ noteId: '123' }}>
359
+ View Note
360
+ </Link>
314
361
  ```
315
362
 
316
- ### Email
363
+ Renders an `<a>` tag with the correct `href`. Intercepts clicks for client-side navigation (ctrl/cmd+click opens in new tab).
364
+
365
+ ---
366
+
367
+ ## Auth
368
+
369
+ Auth uses HttpOnly cookies with server-side JWT injection. No localStorage needed.
370
+
371
+ **Flow:**
317
372
 
318
- Send transactional email via Mailgun:
373
+ 1. User opens `<LoginPopup />` which redirects to OAuth at `https://ugly.bot/oauth`
374
+ 2. Browser posts the auth code to `POST /auth/verify` — server sets `auth_token` HttpOnly cookie
375
+ 3. On every page load the server refreshes the cookie and injects the token into HTML:
376
+ ```html
377
+ <script>window.__AUTH_TOKEN__ = "eyJ..."</script>
378
+ ```
379
+ 4. Client reads `window.__AUTH_TOKEN__` synchronously and connects the WebSocket
380
+
381
+ **Logout:**
319
382
 
320
383
  ```typescript
321
- import { sendEmail, loadEmailTemplate, sendTemplateEmail } from 'website-core';
384
+ await fetch('/auth/logout', { method: 'POST' });
385
+ window.location.reload();
386
+ ```
322
387
 
323
- // One-off HTML email
324
- await sendEmail({
325
- to: 'user@example.com',
326
- subject: 'Welcome!',
327
- html: '<p>Thanks for signing up.</p>',
328
- from: 'noreply@my-app.com', // optional defaults to MAILGUN_FROM env var
329
- replyTo: 'support@my-app.com',
330
- headers: { 'X-Campaign-Id': 'welcome' },
331
- });
388
+ **Built-in auth endpoints:**
389
+
390
+ | Endpoint | Description |
391
+ |----------|-------------|
392
+ | `POST /auth/verify` | Exchange OAuth code for a session cookie |
393
+ | `POST /auth/logout` | Clear the auth cookie |
394
+ | `GET /auth/token` | Refresh and return the current token |
395
+ | `GET /auth/url` | Get the OAuth popup URL |
332
396
 
333
- // Handlebars template email
334
- interface WelcomeData { name: string; confirmUrl: string }
335
- const welcomeTemplate = loadEmailTemplate<WelcomeData>('/path/to/welcome.hbs');
397
+ **Custom auth provider:**
336
398
 
337
- await sendTemplateEmail('user@example.com', 'Confirm your email', welcomeTemplate, {
338
- name: 'Alice',
339
- confirmUrl: 'https://my-app.com/confirm?token=abc',
399
+ ```typescript
400
+ configurator.setAuth({
401
+ verify: async (code: string) => ({ userId: '...', email: '...' }),
402
+ authUrl: (origin: string) => 'https://my-oauth.com/authorize?...',
403
+ registerRoutes: (router) => { /* optional extra routes */ },
340
404
  });
341
405
  ```
342
406
 
343
- Required environment variables: `MAILGUN_API_KEY`, `MAILGUN_DOMAIN`, `MAILGUN_FROM`. If `MAILGUN_API_KEY` is not set, sending is silently skipped (dev-friendly).
407
+ ---
344
408
 
345
- ### Billing
409
+ ## Database (`TypedDB`)
346
410
 
347
- Track AI spend and enforce rate limits across all cost types (`textGen`, `imageGen`, `stt`, `tts`, `embedding`, `service`):
411
+ ### Writing
348
412
 
349
413
  ```typescript
350
- import {
351
- initBillingGateway,
352
- getBillingGateway,
353
- BillingLimitError,
354
- BillingValidationError,
355
- } from 'website-core';
414
+ // Insert or replace a document
415
+ await ctx.db.setDoc(collections.note, doc);
416
+ await ctx.db.setDoc(collections.note, doc, { skipIfExists: true });
356
417
 
357
- // Initialize once at startup (before serving traffic)
358
- const billing = initBillingGateway(db, {
359
- global: {
360
- hourlyUsd: 50, // hard cap across all providers/users
361
- thresholdPct: 0.8, // fire callback at 80%
362
- },
363
- providers: {
364
- openai: { hourlyUsd: 20, thresholdPct: 0.9 },
365
- anthropic: { hourlyUsd: 30 },
366
- },
367
- profitMarginPct: 0.5, // optional: 50% markup applied before limit checks
368
- reconcileIntervalMs: 300_000, // flush to DB every 5 min (default)
369
- userLimitCacheSize: 10_000, // LRU entries (default)
370
- }, creditDb); // optional CreditDB for pre-paid credits
371
-
372
- // Supply per-user limits from your database
373
- billing.setUserLimitHook(async (userId) => {
374
- const plan = await db.getDoc(collections.plans, userId);
375
- return plan
376
- ? { hourlyUsd: plan.hourlyUsd, dailyUsd: plan.dailyUsd, weeklyUsd: plan.weeklyUsd,
377
- thresholds: { daily: 0.9 } }
378
- : null; // null = no limits
379
- });
418
+ // Partial update only specified fields
419
+ await ctx.db.setDocFields(collections.note, id, { title: 'New title' });
380
420
 
381
- // Alert callbacks (errors swallowed never block a charge)
382
- billing.setGlobalThresholdCallback(({ limitType, spent, limit, thresholdPct, provider }) => {
383
- console.warn(`[Billing] ${limitType} at ${Math.round(thresholdPct * 100)}%: $${spent}/$${limit}`);
384
- });
385
- billing.setUserThresholdCallback(({ userId, limitType, spent, limit }) => {
386
- void notifyUser(userId, `You've used ${Math.round(spent / limit * 100)}% of your ${limitType} budget`);
387
- });
421
+ // Partial update returns null if document doesn't exist (no error)
422
+ const doc = await ctx.db.setDocFieldsOrIgnore(collections.note, id, { title });
423
+
424
+ // Partial update — creates the document if it doesn't exist
425
+ await ctx.db.setDocFieldsOrCreate(collections.note, id, { title });
426
+
427
+ // MongoDB update operators ($inc, $addToSet, $pull, $unset, $set)
428
+ await ctx.db.setDocOp(collections.note, id, { $inc: { views: 1 } });
429
+ await ctx.db.setDocOpOrIgnore(collections.note, id, { $inc: { views: 1 } }); // no error if missing
388
430
  ```
389
431
 
390
- **Recording a charge:**
432
+ ### Reading
391
433
 
392
434
  ```typescript
393
- // Returns BillingRecord on success; throws on limit violation
394
- try {
395
- const record = await getBillingGateway().charge({
396
- userId, // optional — omit for anonymous charges
397
- provider: 'openai',
398
- model: 'gpt-4o',
399
- type: 'textGen', // 'textGen' | 'imageGen' | 'stt' | 'tts' | 'embedding' | 'service'
400
- costUsd: 0.012,
401
- inputTokens: 800,
402
- outputTokens: 400,
403
- });
404
- } catch (err) {
405
- if (err instanceof BillingLimitError) {
406
- // err.limitType: 'global-hourly' | 'provider-hourly' | 'user-hourly' | 'user-daily' | 'user-weekly'
407
- // err.spent, err.limit
408
- }
409
- }
435
+ const note = await ctx.db.getDoc(collections.note, id);
436
+ const notes = await ctx.db.getDocs(collections.note, { userId }, { sort: { created: -1 }, limit: 20 });
437
+
438
+ // Aggregation pipeline
439
+ const results = await ctx.db.getQuery<MyResult>('note', pipeline, { skip, limit });
440
+ const count = await ctx.db.getQueryCount('note', pipeline);
441
+ const raw = await ctx.db.getQueryRaw<T>('note', pipeline);
410
442
  ```
411
443
 
412
- **Querying spend and history:**
444
+ ### Deleting
413
445
 
414
446
  ```typescript
415
- const billing = getBillingGateway();
447
+ await ctx.db.deleteDoc(collections.note, id); // single doc (cascades via parent)
448
+ await ctx.db.deleteQuery(collections.note, { userId }); // bulk delete by filter
449
+ ```
416
450
 
417
- // Rolling window totals
418
- const globalHour = await billing.getWindowSpend(3_600_000);
419
- const userDay = await billing.getWindowSpend(86_400_000, { userId });
420
- const openaiHour = await billing.getWindowSpend(3_600_000, { provider: 'openai' });
451
+ ### Caching
421
452
 
422
- // Audit history (default limit 100, max 1000)
423
- const records = await billing.getHistory({ userId, limit: 50, skip: 0 });
453
+ ```typescript
454
+ const cached = await ctx.db.cacheGet<MyType>(key);
455
+ await ctx.db.cacheSet(key, value, ttlMs);
456
+ await ctx.db.cacheDelete(key);
457
+ const key = ctx.db.cacheKey('prefix', id); // generate a cache key
424
458
  ```
425
459
 
426
- **Cache invalidation** (call after a user's plan changes):
460
+ ### Helpers
427
461
 
428
462
  ```typescript
429
- billing.invalidateUserLimit(userId);
430
- billing.invalidateAllUserLimits();
431
- ```
463
+ import { dbDefaults, createUserHelper } from 'ugly-app';
432
464
 
433
- **Pre-paid credits** (optional requires `creditDb` passed to `initBillingGateway`):
465
+ // dbDefaults() returns { version: 1, created: new Date(), updated: new Date() }
466
+ const doc = { id: newId(), ...dbDefaults(), title: 'Hello' };
434
467
 
435
- When a user would otherwise be blocked by a rate limit, the gateway automatically deducts from their credit balance as a fallback. Global and provider limits are never bypassed.
468
+ // createUserHelper typed user CRUD
469
+ const userHelper = createUserHelper<User>(collections.user);
470
+ const user = await userHelper.get(db, userId);
471
+ await userHelper.set(db, { id: userId, ...dbDefaults(), email });
472
+ ```
473
+
474
+ ### Indexes
436
475
 
437
476
  ```typescript
438
- import { CreditStore, MongoCreditDB } from 'website-core';
477
+ // shared/dbIndexes.ts
478
+ import { defineDbIndexes } from 'ugly-app/shared';
479
+
480
+ export const dbIndexes = defineDbIndexes({
481
+ note: [
482
+ { key: { userId: 1, created: -1 } },
483
+ { key: { title: 'text' }, type: 'search' },
484
+ ],
485
+ });
486
+ ```
439
487
 
440
- // Set up the credit DB (uses your TypedDB instance)
441
- const creditDb = new MongoCreditDB(db.userCredits);
442
- const billing = initBillingGateway(ledgerDb, config, creditDb);
488
+ Run `npm run db:init` to create/update indexes.
443
489
 
444
- // Grant credits (e.g. after a purchase)
445
- await billing.grantCredit(userId, 5.00); // $5.00
490
+ ---
446
491
 
447
- // Check balance
448
- const balance = await billing.getCreditBalance(userId);
492
+ ## AI
449
493
 
450
- // Invalidate cached balance (e.g. after external grant)
451
- billing.invalidateCreditCache(userId);
494
+ ### Text generation
495
+
496
+ ```typescript
497
+ // In a handler via ctx.textGen:
498
+ const text = await ctx.textGen.generate(messages);
499
+ const json = await ctx.textGen.generateJson(schema, messages); // Zod schema, retries on parse failure
500
+ const result = await ctx.textGen.generateWithTools(messages, tools); // automatic tool-call loop
452
501
  ```
453
502
 
454
- Charges are recorded to an `aiSpend` MongoDB collection and reconciled in-memory every 5 minutes with a forced flush on `shutdown()`. Each text and image generation call from `createTextGen`/`createImageGen` is automatically charged through the gateway when initialized.
503
+ | Provider | `provider` value | JSON | Tools | Vision |
504
+ |----------|-----------------|------|-------|--------|
505
+ | Together AI (Llama-4-Scout) | `'together'` | yes | yes | yes |
506
+ | Anthropic (claude-sonnet-4-6) | `'claude'` | yes | yes | yes |
507
+ | OpenAI (gpt-4o) | `'openai'` | yes | yes | yes |
508
+ | Google (gemini-2.0-flash) | `'google'` | yes | yes | yes |
509
+ | Groq (llama-3.3-70b) | `'groq'` | yes | yes | no |
510
+ | Fireworks | `'fireworks'` | yes | yes | yes |
455
511
 
456
- ### Rate Limiting
512
+ Use `provider: 'auto'` (default) to let the system pick based on requirements.
457
513
 
458
- ```typescript
459
- const rateLimit = createRateLimiter({
460
- windowSeconds: 3600, // 1 hour (default)
461
- maxPerWindow: 1.0, // max units per window (default)
462
- maxQueueDepth: 100, // max queued requests (default)
463
- maxWaitMs: 300_000, // max wait time in queue (default)
464
- });
514
+ **Standalone client (outside handlers):**
465
515
 
466
- await rateLimit.check(userId, 'generate', 0.1);
467
- await rateLimit.charge(userId, 'generate', actualCost);
516
+ ```typescript
517
+ import { createTextGenClient } from 'ugly-app';
518
+ const textGen = createTextGenClient();
468
519
  ```
469
520
 
470
- ### Worker Queues
521
+ ### Image generation
471
522
 
472
523
  ```typescript
473
- const queue = createWorkerQueue({
474
- streamName: 'JOBS', // NATS stream name (default: 'JOBS')
475
- clockServerOnly: true, // only process when IS_CLOCK_SERVER=true (default: true)
476
- concurrency: 10, // max concurrent jobs (default: 10)
477
- maxRetries: 3, // max retry attempts (default: 3)
478
- });
524
+ const url = await ctx.imageGen.generate(prompt, { width: 1024, height: 1024 });
525
+ ```
526
+
527
+ | Provider | `provider` value |
528
+ |----------|-----------------|
529
+ | Together AI (FLUX schnell) | `'together'` |
530
+ | FAL (FLUX pro) | `'fal'` |
531
+ | Google (Imagen 3) | `'google'` |
532
+ | Wavespeed | `'wavespeed'` |
533
+
534
+ **Standalone client:**
479
535
 
480
- await queue.enqueue('email', { to: 'user@example.com' });
481
- await queue.enqueue('email', payload, { delay: 5000 }); // delay in ms
482
- queue.registerHandler('email', async (job) => { /* job.payload, job.working() */ });
483
- await queue.start();
536
+ ```typescript
537
+ import { createImageGenClient } from 'ugly-app';
538
+ const imageGen = createImageGenClient();
484
539
  ```
485
540
 
486
- ### Storage
541
+ ### Custom providers
487
542
 
488
543
  ```typescript
489
- const storage = createStorageClient();
544
+ import { registerTextGenProvider, registerImageGenProvider } from 'ugly-app';
545
+
546
+ registerTextGenProvider('myProvider', myTextGenImplementation);
547
+ registerImageGenProvider('myProvider', myImageGenImplementation);
548
+ ```
549
+
550
+ ### AI proxy
490
551
 
491
- // Server-side upload
552
+ The client accesses AI via `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.
553
+
554
+ ---
555
+
556
+ ## Storage
557
+
558
+ ```typescript
559
+ // Server-side — put a file
492
560
  const url = await storage.put('temp', key, buffer, 'image/png');
561
+
562
+ // Move from temp to public bucket
493
563
  const publicUrl = await storage.moveToPublic(tempKey, destKey);
564
+
565
+ // Get a public URL
494
566
  const url = storage.url('public', destKey);
495
567
 
496
- // Presigned URL for browser direct upload
568
+ // Browser direct upload via presigned URL
497
569
  const { uploadUrl, resultUrl } = await storage.presignedPut('temp', key);
498
570
  ```
499
571
 
500
- ### Logging
572
+ Buckets: `'public'` and `'temp'`.
501
573
 
502
- ```typescript
503
- // In handlers, use ctx.log
504
- ctx.log.info('User signed in', { userId });
505
- ctx.log.warn('Unexpected state', { context });
506
- ctx.log.error('Something failed', { error: err.message });
574
+ Static build-time assets go in `client/public/`. Never hardcode `/asset/...` paths — use the `buildId` from `shared/Build.ts`.
507
575
 
508
- // Performance timing — returns a stop function
509
- const stop = ctx.log.perf('db-query');
510
- // ... do work ...
511
- stop(); // records duration to MongoDB
512
- ```
576
+ ---
513
577
 
514
- ### Server Error Capture
578
+ ## Additional server APIs
515
579
 
516
- `captureServerError` persists unexpected errors to a MongoDB `errorLog` collection with automatic deduplication (repeated errors increment a `count` field instead of inserting duplicates). Expected/recoverable errors can be suppressed from persistence by registering patterns.
580
+ ### Email
517
581
 
518
582
  ```typescript
519
- import {
520
- captureServerError,
521
- registerExpectedErrorPattern,
522
- classifyError,
523
- } from 'website-core';
583
+ import { sendEmail, sendTemplateEmail, loadEmailTemplate } from 'ugly-app';
524
584
 
525
- // Register known-recoverable patterns at startup matched errors log to
526
- // console only and are never written to MongoDB
527
- registerExpectedErrorPattern('ECONNRESET')
528
- registerExpectedErrorPattern('[STT] Session ended before audio was received')
585
+ await sendEmail({ to: 'user@example.com', subject: 'Hello', html: '<p>Hi</p>' });
586
+ await sendTemplateEmail('welcome', { name: 'Alice' }, { to: 'user@example.com' });
587
+ ```
529
588
 
530
- // Capture an error in a catch block
531
- try {
532
- await riskyOperation()
533
- } catch (err) {
534
- captureServerError('[MyService] Failed to process request', err, { userId })
535
- }
589
+ ### Push notifications
590
+
591
+ ```typescript
592
+ import { sendPush, sendFcmPush } from 'ugly-app';
536
593
 
537
- // Classify a message manually ('expected' | 'unexpected')
538
- const classification = classifyError('[STT] Session ended before audio was received')
594
+ await sendPush(userId, { title: 'New message', body: 'You have a new message' });
539
595
  ```
540
596
 
541
- ### Push Notifications
597
+ ### NATS (pub/sub)
542
598
 
543
599
  ```typescript
544
- import { sendPush } from 'website-core';
600
+ import { natsPublish, natsSubscribe, subscribeCollection, subscribeDoc } from 'ugly-app';
545
601
 
546
- await sendPush(userId, { title: 'New message', body: 'You have a reply' });
602
+ natsPublish('my.subject', payload);
603
+ const sub = natsSubscribe('my.subject', (msg) => { /* ... */ });
604
+ sub(); // unsubscribe
547
605
  ```
548
606
 
549
- ## CLI Commands
607
+ ### Redis
550
608
 
551
- | Command | Description |
552
- |---|---|
553
- | `web init <name>` | Scaffold a new project |
554
- | `web dev` | Start all dev services (Docker, server, Vite, TS, ESLint) |
555
- | `web build` | Production build |
556
- | `web db:init` | Create/update MongoDB indexes |
557
- | `web db:migrate` | Run pending database migrations (`--status` to preview) |
558
- | `web publish:assets` | Deploy static assets to CDN (`--dry-run` to preview) |
559
- | `web purge:assets` | Remove old build artifacts (keeps last 3 by default) |
560
- | `web logs:local` | Query local dev logs |
561
- | `web logs:server` | Query server logs from MongoDB |
562
- | `web error:local` | Query local error logs |
563
- | `web error:server` | Query server error logs from MongoDB |
564
- | `web perf:local` | Query local performance metrics |
565
- | `web perf:server` | Query server performance metrics from MongoDB |
566
- | `web feedback` | Query user feedback submissions |
567
- | `web auth:create-account` | Create an account directly in the database |
568
- | `web auth:create-token` | Generate a JWT for a userId |
569
- | `web test:e2e` | Run Playwright end-to-end tests (`--headed` for browser UI) |
570
-
571
- ## React Components
572
-
573
- ```typescript
574
- import {
575
- Button, Card, Text, Input, Modal, Toast, PageLayout,
576
- FeedbackButton,
577
- } from 'website-core/client';
578
- ```
579
-
580
- The `FeedbackButton` captures a screenshot and submits it with user context automatically.
581
-
582
- ## Routing (Client)
583
-
584
- ```typescript
585
- const { useRouter, RouterProvider, RouterView } = createRouter(pages);
586
-
587
- function App() {
588
- const { push, replace, back } = useRouter();
589
- return (
590
- <RouterProvider fallback={<NotFound />} loginFallback={<Login />} isAuthenticated={() => !!userId}>
591
- <RouterView renderPage={(state) => <PageSwitch route={state.name} params={state.params} />} />
592
- </RouterProvider>
593
- );
594
- }
609
+ ```typescript
610
+ import { getRedisClient } from 'ugly-app';
611
+ const redis = getRedisClient();
595
612
  ```
596
613
 
597
- Page transitions are animated by default using `ViewFlipper`. The transition type (`PUSH`, `POP`, `REPLACE`, `NONE`) is inferred automatically from navigation calls.
598
-
599
- ### Popups
614
+ ### Worker queues
600
615
 
601
616
  ```typescript
602
- import { useRouter } from './router';
603
-
604
- function MyComponent() {
605
- const { openPopup } = useRouter();
617
+ import { createWorkerQueue, enqueueTask } from 'ugly-app';
606
618
 
607
- function openMenu() {
608
- const handle = openPopup(<MyMenu />, {
609
- mode: 'contextMenu', // 'block' | 'transient' | 'contextMenu'
610
- slideFrom: 'bottom', // 'left' | 'right' | 'top' | 'bottom' | 'none'
611
- });
619
+ const queue = createWorkerQueue({ /* config */ });
620
+ configurator.setWorkerQueue(queue);
612
621
 
613
- // Dismiss programmatically
614
- handle.hide();
615
- }
616
- }
622
+ await enqueueTask('taskName', payload);
617
623
  ```
618
624
 
619
- - **`block`** — modal overlay, blocks interaction behind it
620
- - **`transient`** — tapping the backdrop closes it
621
- - **`contextMenu`** — same as transient but typically for menus/pickers
625
+ ### Rate limiting
622
626
 
623
- ### Animation Primitives
627
+ ```typescript
628
+ // Inside a handler — always call before expensive operations
629
+ await ctx.rateLimit.check();
630
+ ```
631
+
632
+ ### Embeddings
624
633
 
625
634
  ```typescript
626
- import {
627
- createAnimatedValue,
628
- useAnimatedValue,
629
- Animated,
630
- easingFunctions,
631
- } from 'website-core/client';
632
- import type { EasingFunction } from 'website-core/client';
635
+ import { createEmbeddingClient, registerEmbeddingProvider } from 'ugly-app';
636
+ const embeddings = createEmbeddingClient();
637
+ ```
633
638
 
634
- // Imperative animated value (RAF-driven, no React re-renders)
635
- const spring = createAnimatedValue(0);
636
- spring.animateTo(1, { duration: 300, easing: easingFunctions.easeInOut });
639
+ ### Billing
637
640
 
638
- // React hook version
639
- const opacity = useAnimatedValue(0);
641
+ ```typescript
642
+ import { initBillingGateway, getBillingGateway } from 'ugly-app';
640
643
 
641
- // Apply to DOM via ref — zero re-renders
642
- <Animated value={spring} style={(v) => ({ opacity: v, transform: `scale(${v})` })}>
643
- <div>Content</div>
644
- </Animated>
644
+ await initBillingGateway({ /* config */ });
645
+ const billing = getBillingGateway();
645
646
  ```
646
647
 
647
- ## Project Structure
648
+ ---
648
649
 
649
- Projects built with `website-core` follow this layout:
650
+ ## Migrations
650
651
 
652
+ Never change a collection field type without writing a migration:
653
+
654
+ ```typescript
655
+ // server/migrations/001-add-bio.ts
656
+ export const name = '001-add-bio';
657
+ export async function up(db: Db) {
658
+ await db.collection('user').updateMany({}, { $set: { bio: '' } });
659
+ }
651
660
  ```
652
- my-app/
653
- ├── src/
654
- │ ├── server/ # Express handlers, DB collections, migrations
655
- │ ├── client/ # React pages and components
656
- │ └── shared/ # API definitions and shared types
657
- ├── static/ # Build-time static assets
658
- └── docker-compose.yml
659
- ```
660
661
 
661
- ## Environment Variables
662
+ Run with `npm run db:migrate`. Use `npm run db:migrate -- --status` to preview pending migrations.
663
+
664
+ ---
665
+
666
+ ## CLI reference
667
+
668
+ | Command | Description |
669
+ |---------|-------------|
670
+ | `ugly-app init <name>` | Scaffold a new project |
671
+ | `ugly-app upgrade` | Upgrade framework config files to the latest version |
672
+ | `ugly-app dev` | Start all dev services |
673
+ | `ugly-app build` | Production build |
674
+ | `ugly-app db:init` | Create/update MongoDB indexes |
675
+ | `ugly-app db:migrate` | Run pending migrations (`--status` to preview) |
676
+ | `ugly-app deploy:single` | Deploy to a single server |
677
+ | `ugly-app deploy:multi` | Deploy to multiple servers |
678
+ | `ugly-app publish:assets` | Push static assets to CDN |
679
+ | `ugly-app logs:local` | Query local dev logs |
680
+ | `ugly-app logs:server` | Query server logs from MongoDB |
681
+ | `ugly-app error:local` / `error:server` | Query error logs |
682
+ | `ugly-app perf:local` / `perf:server` | Query performance metrics |
683
+ | `ugly-app feedback` | Query user feedback submissions |
684
+ | `ugly-app auth:create-account` | Create an account in the database |
685
+ | `ugly-app auth:create-token` | Generate a JWT for a userId |
686
+
687
+ ---
688
+
689
+ ## Environment variables
662
690
 
663
691
  | Variable | Description |
664
- |---|---|
692
+ |----------|-------------|
693
+ | `JWT_SECRET` | **Required** — signs auth tokens |
665
694
  | `MONGODB_URI` | MongoDB connection string |
666
- | `JWT_SECRET` | Secret for signing JWT tokens |
667
- | `REDIS_URL` | Redis connection (optional, uses in-memory fallback) |
695
+ | `PORT` | Server port (default: 3000) |
696
+ | `NODE_ENV` | `development` or `production` |
697
+ | `UGLY_BOT_TOKEN` | App token for AI proxy (`/ai/request`) |
698
+ | `REDIS_URL` | Redis (optional, in-memory fallback for dev) |
668
699
  | `NATS_URL` | NATS server URL |
669
- | `NATS_CREDS` | NATS credentials file path (Synadia Cloud) |
670
- | `STORAGE_ACCOUNT_ID` | Cloudflare R2 account ID (alias: `CLOUDFLARE_ACCOUNT_ID`) |
671
- | `STORAGE_ACCESS_KEY_ID` | R2 access key (alias: `CLOUDFLARE_R2_ACCESS_KEY_ID`) |
672
- | `STORAGE_SECRET_ACCESS_KEY` | R2 secret key (alias: `CLOUDFLARE_R2_SECRET_ACCESS_KEY`) |
673
- | `STORAGE_PUBLIC_BUCKET` | R2 public bucket name (alias: `CLOUDFLARE_R2_PUBLIC_BUCKET`) |
674
- | `STORAGE_TEMP_BUCKET` | R2 temp bucket name (alias: `CLOUDFLARE_R2_TEMP_BUCKET`) |
675
- | `STORAGE_PUBLIC_URL` | Base URL for public bucket (alias: `CLOUDFLARE_R2_PUBLIC_URL`) |
676
- | `STORAGE_TEMP_URL` | Base URL for temp bucket (alias: `CLOUDFLARE_R2_TEMP_URL`) |
677
- | `TOGETHER_API_KEY` | Together AI API key |
678
- | `ANTHROPIC_API_KEY` | Anthropic Claude API key |
679
- | `OPENAI_API_KEY` | OpenAI API key |
680
- | `GOOGLE_API_KEY` | Google Gemini API key |
681
- | `IS_CLOCK_SERVER` | Set to `true` on the instance that processes worker queues |
682
- | `MAILGUN_API_KEY` | Mailgun API key (`key-...`) |
683
- | `MAILGUN_DOMAIN` | Mailgun sending domain (e.g., `mg.my-app.com`) |
684
- | `MAILGUN_FROM` | Default from address (e.g., `noreply@my-app.com`) |
700
+ | `STORAGE_ACCOUNT_ID` | Cloudflare R2 account ID |
701
+ | `STORAGE_ACCESS_KEY_ID` | R2 access key |
702
+ | `STORAGE_SECRET_ACCESS_KEY` | R2 secret key |
703
+ | `STORAGE_PUBLIC_BUCKET` | R2 public bucket name |
704
+ | `STORAGE_TEMP_BUCKET` | R2 temp bucket name |
705
+ | `STORAGE_PUBLIC_URL` | Base URL for public assets |
706
+ | `TOGETHER_API_KEY` | Together AI key |
707
+ | `ANTHROPIC_API_KEY` | Anthropic Claude key |
708
+ | `OPENAI_API_KEY` | OpenAI key |
709
+ | `GOOGLE_API_KEY` | Google Gemini key |
710
+ | `MAILGUN_API_KEY` | Mailgun key |
711
+ | `MAILGUN_DOMAIN` | Mailgun sending domain |
712
+ | `MAILGUN_FROM` | Default from address |
685
713
 
686
714
  Client-side variables must be prefixed with `VITE_`.
687
715
 
688
- ## Tech Stack
689
-
690
- - **Runtime**: Node.js, TypeScript (ES2022, NodeNext modules)
691
- - **Server**: Express, ws (WebSockets)
692
- - **Frontend**: React 19, Vite, Tailwind CSS
693
- - **Database**: MongoDB
694
- - **Messaging**: NATS with JetStream
695
- - **Cache**: Redis (in-memory fallback for dev)
696
- - **Storage**: AWS S3 / Cloudflare R2
697
- - **Auth**: JWT (jose), OAuth
698
- - **AI**: Together AI, Anthropic, OpenAI, Google, Groq, FAL
699
- - **Audio**: InWorld TTS, Groq Whisper STT
700
- - **Validation**: Zod
701
- - **Testing**: Vitest, Playwright, mongodb-memory-server
716
+ ---
702
717
 
703
- ## Development
718
+ ## Tech stack
704
719
 
705
- ```bash
706
- npm run build # Compile TypeScript
707
- npm test # Run unit tests
708
- npm run test:watch # Watch mode
709
- ```
720
+ Node.js · TypeScript · Express · React 19 · Vite · Tailwind CSS · MongoDB · NATS · Redis · Cloudflare R2 · Zod · JWT (jose) · ugly.bot OAuth