ugly-app 0.1.39 → 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 +519 -508
- package/dist/cli/scaffold.d.ts.map +1 -1
- package/dist/cli/scaffold.js +22 -0
- package/dist/cli/scaffold.js.map +1 -1
- package/package.json +2 -2
- package/templates/CLAUDE.md +40 -304
package/README.md
CHANGED
|
@@ -1,709 +1,720 @@
|
|
|
1
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
npm install react react-dom html2canvas
|
|
33
|
-
```
|
|
5
|
+
## What's included
|
|
34
6
|
|
|
35
|
-
|
|
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
|
-
|
|
15
|
+
## Quick start
|
|
38
16
|
|
|
39
17
|
```bash
|
|
40
|
-
npx
|
|
18
|
+
npx ugly-app init my-app
|
|
19
|
+
cd my-app
|
|
20
|
+
npm run dev
|
|
41
21
|
```
|
|
42
22
|
|
|
43
|
-
|
|
23
|
+
---
|
|
44
24
|
|
|
45
|
-
|
|
46
|
-
yarn dev
|
|
47
|
-
# Starts Docker services, Express server, Vite, TypeScript watcher, and ESLint concurrently
|
|
48
|
-
```
|
|
25
|
+
## Server
|
|
49
26
|
|
|
50
|
-
|
|
27
|
+
### `createApp()`
|
|
51
28
|
|
|
52
|
-
|
|
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,
|
|
56
|
-
import { dbDefaults } from '
|
|
57
|
-
import { functions, requests } from '../shared/api
|
|
58
|
-
import { collections } from '../shared/collections
|
|
59
|
-
import { pages } from '../shared/pages
|
|
60
|
-
import type { User } from '../shared/types
|
|
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
|
-
|
|
39
|
+
const userHelper = createUserHelper<User>(collections.user);
|
|
63
40
|
|
|
64
|
-
const
|
|
65
|
-
|
|
41
|
+
const calls = {
|
|
42
|
+
myFunction: async (ctx: HandlerContext, input) => {
|
|
43
|
+
return { greeting: `Hello, ${input.name}` };
|
|
44
|
+
},
|
|
45
|
+
} satisfies CallHandlers<typeof functions>;
|
|
66
46
|
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
84
|
-
|
|
85
|
-
return { userId: ctx.userId, email: user?.email };
|
|
86
|
-
});
|
|
62
|
+
await app.start(3000);
|
|
63
|
+
```
|
|
87
64
|
|
|
88
|
-
|
|
65
|
+
**Signature:**
|
|
89
66
|
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
83
|
+
### `AppConfigurator`
|
|
97
84
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
+
Every RPC handler receives a `HandlerContext`:
|
|
111
99
|
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
110
|
+
---
|
|
117
111
|
|
|
118
|
-
|
|
112
|
+
## Shared API definitions
|
|
119
113
|
|
|
120
|
-
|
|
121
|
-
createApp(registry, appDefs, configure?)
|
|
122
|
-
```
|
|
114
|
+
All type definitions live in `shared/` and are used by both server and client.
|
|
123
115
|
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
|
|
185
|
-
type: {} as
|
|
186
|
-
meta: { cache:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
224
|
+
### `createSocket()`
|
|
233
225
|
|
|
234
|
-
|
|
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
|
-
`
|
|
233
|
+
**`AppSocket` methods:**
|
|
248
234
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
312
|
+
#### Navigation with `useRouter()`
|
|
279
313
|
|
|
280
314
|
```typescript
|
|
281
|
-
const
|
|
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
|
-
|
|
326
|
+
All route names and params are fully typed based on your `pages` definition.
|
|
285
327
|
|
|
286
|
-
|
|
328
|
+
#### Popups
|
|
287
329
|
|
|
288
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
// viseme events arrive via onViseme callback in useTTS options
|
|
332
|
+
```tsx
|
|
333
|
+
const { openPopup } = useRouter();
|
|
296
334
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
384
|
+
await fetch('/auth/logout', { method: 'POST' });
|
|
385
|
+
window.location.reload();
|
|
386
|
+
```
|
|
322
387
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
334
|
-
interface WelcomeData { name: string; confirmUrl: string }
|
|
335
|
-
const welcomeTemplate = loadEmailTemplate<WelcomeData>('/path/to/welcome.hbs');
|
|
397
|
+
**Custom auth provider:**
|
|
336
398
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
407
|
+
---
|
|
344
408
|
|
|
345
|
-
|
|
409
|
+
## Database (`TypedDB`)
|
|
346
410
|
|
|
347
|
-
|
|
411
|
+
### Writing
|
|
348
412
|
|
|
349
413
|
```typescript
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
//
|
|
358
|
-
|
|
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
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
432
|
+
### Reading
|
|
391
433
|
|
|
392
434
|
```typescript
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
444
|
+
### Deleting
|
|
413
445
|
|
|
414
446
|
```typescript
|
|
415
|
-
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
const
|
|
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
|
-
|
|
460
|
+
### Helpers
|
|
427
461
|
|
|
428
462
|
```typescript
|
|
429
|
-
|
|
430
|
-
billing.invalidateAllUserLimits();
|
|
431
|
-
```
|
|
463
|
+
import { dbDefaults, createUserHelper } from 'ugly-app';
|
|
432
464
|
|
|
433
|
-
|
|
465
|
+
// dbDefaults() returns { version: 1, created: new Date(), updated: new Date() }
|
|
466
|
+
const doc = { id: newId(), ...dbDefaults(), title: 'Hello' };
|
|
434
467
|
|
|
435
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
await billing.grantCredit(userId, 5.00); // $5.00
|
|
490
|
+
---
|
|
446
491
|
|
|
447
|
-
|
|
448
|
-
const balance = await billing.getCreditBalance(userId);
|
|
492
|
+
## AI
|
|
449
493
|
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
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
|
-
|
|
512
|
+
Use `provider: 'auto'` (default) to let the system pick based on requirements.
|
|
457
513
|
|
|
458
|
-
|
|
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
|
-
|
|
467
|
-
|
|
516
|
+
```typescript
|
|
517
|
+
import { createTextGenClient } from 'ugly-app';
|
|
518
|
+
const textGen = createTextGenClient();
|
|
468
519
|
```
|
|
469
520
|
|
|
470
|
-
###
|
|
521
|
+
### Image generation
|
|
471
522
|
|
|
472
523
|
```typescript
|
|
473
|
-
const
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
await queue.start();
|
|
536
|
+
```typescript
|
|
537
|
+
import { createImageGenClient } from 'ugly-app';
|
|
538
|
+
const imageGen = createImageGenClient();
|
|
484
539
|
```
|
|
485
540
|
|
|
486
|
-
###
|
|
541
|
+
### Custom providers
|
|
487
542
|
|
|
488
543
|
```typescript
|
|
489
|
-
|
|
544
|
+
import { registerTextGenProvider, registerImageGenProvider } from 'ugly-app';
|
|
545
|
+
|
|
546
|
+
registerTextGenProvider('myProvider', myTextGenImplementation);
|
|
547
|
+
registerImageGenProvider('myProvider', myImageGenImplementation);
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
### AI proxy
|
|
490
551
|
|
|
491
|
-
|
|
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
|
-
//
|
|
568
|
+
// Browser direct upload via presigned URL
|
|
497
569
|
const { uploadUrl, resultUrl } = await storage.presignedPut('temp', key);
|
|
498
570
|
```
|
|
499
571
|
|
|
500
|
-
|
|
572
|
+
Buckets: `'public'` and `'temp'`.
|
|
501
573
|
|
|
502
|
-
|
|
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
|
-
|
|
509
|
-
const stop = ctx.log.perf('db-query');
|
|
510
|
-
// ... do work ...
|
|
511
|
-
stop(); // records duration to MongoDB
|
|
512
|
-
```
|
|
576
|
+
---
|
|
513
577
|
|
|
514
|
-
|
|
578
|
+
## Additional server APIs
|
|
515
579
|
|
|
516
|
-
|
|
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
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
}
|
|
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
|
-
|
|
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
|
-
###
|
|
597
|
+
### NATS (pub/sub)
|
|
542
598
|
|
|
543
599
|
```typescript
|
|
544
|
-
import {
|
|
600
|
+
import { natsPublish, natsSubscribe, subscribeCollection, subscribeDoc } from 'ugly-app';
|
|
545
601
|
|
|
546
|
-
|
|
602
|
+
natsPublish('my.subject', payload);
|
|
603
|
+
const sub = natsSubscribe('my.subject', (msg) => { /* ... */ });
|
|
604
|
+
sub(); // unsubscribe
|
|
547
605
|
```
|
|
548
606
|
|
|
549
|
-
|
|
607
|
+
### Redis
|
|
550
608
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
### Popups
|
|
614
|
+
### Worker queues
|
|
600
615
|
|
|
601
616
|
```typescript
|
|
602
|
-
import {
|
|
603
|
-
|
|
604
|
-
function MyComponent() {
|
|
605
|
-
const { openPopup } = useRouter();
|
|
617
|
+
import { createWorkerQueue, enqueueTask } from 'ugly-app';
|
|
606
618
|
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
614
|
-
handle.hide();
|
|
615
|
-
}
|
|
616
|
-
}
|
|
622
|
+
await enqueueTask('taskName', payload);
|
|
617
623
|
```
|
|
618
624
|
|
|
619
|
-
|
|
620
|
-
- **`transient`** — tapping the backdrop closes it
|
|
621
|
-
- **`contextMenu`** — same as transient but typically for menus/pickers
|
|
625
|
+
### Rate limiting
|
|
622
626
|
|
|
623
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
635
|
-
const spring = createAnimatedValue(0);
|
|
636
|
-
spring.animateTo(1, { duration: 300, easing: easingFunctions.easeInOut });
|
|
639
|
+
### Billing
|
|
637
640
|
|
|
638
|
-
|
|
639
|
-
|
|
641
|
+
```typescript
|
|
642
|
+
import { initBillingGateway, getBillingGateway } from 'ugly-app';
|
|
640
643
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
<div>Content</div>
|
|
644
|
-
</Animated>
|
|
644
|
+
await initBillingGateway({ /* config */ });
|
|
645
|
+
const billing = getBillingGateway();
|
|
645
646
|
```
|
|
646
647
|
|
|
647
|
-
|
|
648
|
+
---
|
|
648
649
|
|
|
649
|
-
|
|
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
|
-
|
|
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
|
-
| `
|
|
667
|
-
| `
|
|
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
|
-
| `
|
|
670
|
-
| `
|
|
671
|
-
| `
|
|
672
|
-
| `
|
|
673
|
-
| `
|
|
674
|
-
| `
|
|
675
|
-
| `
|
|
676
|
-
| `
|
|
677
|
-
| `
|
|
678
|
-
| `
|
|
679
|
-
| `
|
|
680
|
-
| `
|
|
681
|
-
| `
|
|
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
|
-
|
|
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
|
-
##
|
|
718
|
+
## Tech stack
|
|
704
719
|
|
|
705
|
-
|
|
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
|