ugly-app 0.1.949 → 0.1.951
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 +210 -211
- package/dist/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +28 -0
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/version.d.ts +1 -1
- package/dist/cli/version.js +1 -1
- package/dist/client/audio/AudioPlayer.d.ts +1 -0
- package/dist/client/audio/AudioPlayer.d.ts.map +1 -1
- package/dist/client/audio/AudioPlayer.js +13 -5
- package/dist/client/audio/AudioPlayer.js.map +1 -1
- package/dist/client/audio/AudioRecorder.d.ts +1 -0
- package/dist/client/audio/AudioRecorder.d.ts.map +1 -1
- package/dist/client/audio/AudioRecorder.js +8 -2
- package/dist/client/audio/AudioRecorder.js.map +1 -1
- package/dist/client/audio/audioPlayer.worklet.js +30 -3
- package/dist/client/audio/audioPlayer.worklet.js.map +1 -1
- package/dist/client/audio/audioRecorder.worklet.js +7 -3
- package/dist/client/audio/audioRecorder.worklet.js.map +1 -1
- package/dist/client/audio/workletLoader.d.ts +19 -0
- package/dist/client/audio/workletLoader.d.ts.map +1 -0
- package/dist/client/audio/workletLoader.js +57 -0
- package/dist/client/audio/workletLoader.js.map +1 -0
- package/dist/server/App.d.ts.map +1 -1
- package/dist/server/App.js +7 -1
- package/dist/server/App.js.map +1 -1
- package/dist/vite/audioWorkletsPlugin.d.ts +36 -0
- package/dist/vite/audioWorkletsPlugin.d.ts.map +1 -0
- package/dist/vite/audioWorkletsPlugin.js +99 -0
- package/dist/vite/audioWorkletsPlugin.js.map +1 -0
- package/dist/vite/index.d.ts +1 -0
- package/dist/vite/index.d.ts.map +1 -1
- package/dist/vite/index.js +1 -0
- package/dist/vite/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/build.ts +33 -0
- package/src/cli/version.ts +1 -1
- package/src/client/audio/AudioPlayer.ts +25 -5
- package/src/client/audio/AudioRecorder.ts +20 -2
- package/src/client/audio/audioPlayer.worklet.ts +33 -6
- package/src/client/audio/audioRecorder.worklet.ts +11 -7
- package/src/client/audio/workletLoader.test.ts +122 -0
- package/src/client/audio/workletLoader.ts +66 -0
- package/src/client/audio/workletSync.test.ts +43 -0
- package/src/server/App.ts +7 -1
- package/src/vite/audioWorkletsPlugin.test.ts +45 -0
- package/src/vite/audioWorkletsPlugin.ts +104 -0
- package/src/vite/index.ts +6 -0
- package/templates/vite.config.ts +11 -2
package/README.md
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# ugly-app
|
|
2
2
|
|
|
3
|
-
A full-stack TypeScript framework for shipping production web apps.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
A full-stack TypeScript framework for shipping production web apps. Scaffold
|
|
4
|
+
with `npx ugly-app init my-app` and get an opinionated Node + React + Postgres
|
|
5
|
+
stack with type-safe RPC over WebSocket and HTTP, real-time doc subscriptions,
|
|
6
|
+
built-in auth, AI generation, storage, workers, cron, and a CLI for every
|
|
7
|
+
workflow.
|
|
8
8
|
|
|
9
9
|
ugly-app is designed to run against [ugly.bot](https://ugly.bot), which
|
|
10
|
-
provides auth, infra (Postgres, Qdrant, NATS, S3-compatible object
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
provides auth, infra (Postgres, Qdrant, NATS, S3-compatible object storage),
|
|
11
|
+
AI provider keys, push, email, and deployment. Your app talks to all of it
|
|
12
|
+
through the project's dev tunnel and its `UGLY_BOT_TOKEN`.
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
15
|
npx ugly-app init my-app
|
|
@@ -27,6 +27,7 @@ npm run dev
|
|
|
27
27
|
| `ugly-app/server/adapter/workers` | Cloudflare Workers entry (Hono router + Durable Objects + Neon HTTP driver + R2). |
|
|
28
28
|
| `ugly-app/server/ssr` | SSR renderer used by the Workers adapter. |
|
|
29
29
|
| `ugly-app/conversation/{shared,server,client,engine}` | AI chat sessions with persisted history. |
|
|
30
|
+
| `ugly-app/character/{shared,server,client}` | `CharacterSpec` store + WebGPU `CharacterCanvas` / `CharacterCreator`. |
|
|
30
31
|
| `ugly-app/collab/{server,client}` | Yjs-based collaborative editing. |
|
|
31
32
|
| `ugly-app/markdown/{shared,client}` | Markdown rendering + editor. |
|
|
32
33
|
| `ugly-app/search/{shared,server}` | Search primitives. |
|
|
@@ -44,8 +45,8 @@ npm run dev
|
|
|
44
45
|
|
|
45
46
|
## Server — `createApp()`
|
|
46
47
|
|
|
47
|
-
Single server entry point. Returns an `App` that owns Express, the
|
|
48
|
-
|
|
48
|
+
Single server entry point. Returns an `App` that owns Express, the WebSocket
|
|
49
|
+
server, the typed DB, and the RPC dispatcher.
|
|
49
50
|
|
|
50
51
|
```ts
|
|
51
52
|
// server/index.ts
|
|
@@ -103,29 +104,31 @@ function createApp<
|
|
|
103
104
|
): App<CollectionMap<typeof BUILTIN_DEFS & Defs>, RegistryPages<R>>;
|
|
104
105
|
```
|
|
105
106
|
|
|
106
|
-
Passing `pages` in the registry (`{ requests, messages, pages }`)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
Passing `pages` in the registry (`{ requests, messages, pages }`) upgrades
|
|
108
|
+
`app.pushSend()` to a per-route typed API. `pages` is optional; apps that only
|
|
109
|
+
call `configurator.setPages()` still work — they just get the loose
|
|
110
|
+
`PageRegistry` typing on `pushSend`.
|
|
110
111
|
|
|
111
112
|
### The returned `App`
|
|
112
113
|
|
|
113
|
-
|
|
114
|
-
|
|
114
|
+
`App` extends `AppRouter`, so `dispatch(name, input, userId)` is available for
|
|
115
|
+
in-process invocation of any registered handler.
|
|
116
|
+
|
|
117
|
+
| Member | Description |
|
|
118
|
+
|--------|-------------|
|
|
115
119
|
| `start(port?)` | Start the server (default 3000; templates use 4321). |
|
|
116
|
-
| `db` |
|
|
120
|
+
| `db` | `TypedDB<Map>` — map inferred from your collections + framework built-ins. |
|
|
117
121
|
| `httpServer` | The underlying Node `http.Server`. |
|
|
118
122
|
| `wss` | The main `WebSocketServer` (path set via `setWsPath`, default `/rpc`). |
|
|
119
|
-
| `dispatch(name, input, userId)` |
|
|
123
|
+
| `dispatch(name, input, userId)` | `Promise<unknown>` — invoke any registered RPC handler programmatically. |
|
|
120
124
|
| `registerRoutes(fn)` | Mount additional Express routes after creation. |
|
|
121
|
-
| `pushSend(input)` | Typed push whose click-through target is a route from this app's `pages`.
|
|
125
|
+
| `pushSend(input)` | Typed push whose click-through target is a route from this app's `pages`. Mints a `https://ugly.bot/l/<code>` short link so the URL is always absolute and dock-app-routable. |
|
|
122
126
|
|
|
123
|
-
Framework services start
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
`R2_BUCKET` doesn't pay their startup cost.
|
|
127
|
+
Framework services start inside `app.start()`: schema drift check, NATS
|
|
128
|
+
connection + KV buckets, data-proxy connection, event-counter flush, TTL
|
|
129
|
+
cleanup for log tables, console/error capture, and ugly.bot log forwarding.
|
|
130
|
+
Postgres, NATS, storage, and AI clients load **lazily** — a host without
|
|
131
|
+
`DATABASE_URL` / `NATS_URL` / `R2_BUCKET` doesn't pay their startup cost.
|
|
129
132
|
|
|
130
133
|
### `AppConfigurator`
|
|
131
134
|
|
|
@@ -156,8 +159,8 @@ Every method is optional; `setPages` is what mounts the SPA.
|
|
|
156
159
|
|
|
157
160
|
### Handler signatures
|
|
158
161
|
|
|
159
|
-
Handlers are plain async functions — no context object. Access state
|
|
160
|
-
|
|
162
|
+
Handlers are plain async functions — no context object. Access state via
|
|
163
|
+
captured imports (`app.db`, `storage`, `pgQuery`, `uglyBotRequest`, …).
|
|
161
164
|
|
|
162
165
|
```ts
|
|
163
166
|
// req() — public, userId may be null
|
|
@@ -169,30 +172,37 @@ getMe: async (userId: string, input) => { /* … */ }
|
|
|
169
172
|
|
|
170
173
|
### Built-in framework requests
|
|
171
174
|
|
|
172
|
-
`createApp` registers several framework handlers reachable from any
|
|
173
|
-
|
|
174
|
-
|
|
175
|
+
`createApp` registers several framework handlers reachable from any client via
|
|
176
|
+
the normal RPC pipeline. App-provided handlers with the same name override the
|
|
177
|
+
framework's defaults. Rate-limited handlers are marked below.
|
|
175
178
|
|
|
176
|
-
| Name |
|
|
177
|
-
|
|
179
|
+
| Name | Notes |
|
|
180
|
+
|------|-------|
|
|
178
181
|
| `userGet` | Returns `{ userId, name, avatarUri }` for the given user (or caller). |
|
|
179
182
|
| `initSession` / `captureEvent` | Session + event logging tagged with experiment branches (public — no auth). |
|
|
180
|
-
| `textGen`
|
|
181
|
-
| `
|
|
183
|
+
| `textGen` | AI text proxy — server-validated, billed through ugly.bot. Rate-limited 20/60s. |
|
|
184
|
+
| `imageGen` | AI image proxy. Rate-limited 10/60s. |
|
|
185
|
+
| `kagiSearch` / `kagiEnrichWeb` / `kagiEnrichNews` | Web search via ugly.bot. Rate-limited 20/60s. |
|
|
186
|
+
| `kagiSummarize` | URL / text summary. Rate-limited 10/60s. |
|
|
182
187
|
| `uploadUrl` | Issues a presigned PUT for the `temp` bucket. |
|
|
183
|
-
| `shareLink` | Mint a `https://ugly.bot/l/<code>` short link with OG metadata. |
|
|
184
|
-
| `feedbackReportCreateNoAuth`
|
|
185
|
-
| `
|
|
188
|
+
| `shareLink` | Mint a `https://ugly.bot/l/<code>` short link with OG metadata. Rate-limited 60/60s. |
|
|
189
|
+
| `feedbackReportCreateNoAuth` | Public, same-origin feedback endpoint used by browser telemetry. Rate-limited 10/60s. |
|
|
190
|
+
| `errorLogCaptureNoAuth` | Public error-log capture. |
|
|
191
|
+
| `perfSnapshotCaptureNoAuth` | Public perf snapshot capture. Rate-limited 60/60s. |
|
|
192
|
+
| `submitFeedbackBot` | Bot-persona feedback submission. |
|
|
193
|
+
| `feedbackReportResolve` | Admin resolve/decline. Rate-limited 120/60s. |
|
|
186
194
|
| `adminGetPerfLogs` | Admin-only perf telemetry read. |
|
|
187
195
|
| `adminCreateTestUser` / `adminListTestUsers` / `adminDeleteTestUser` | Admin-only synthetic-user management. Gated by `setIsAdmin()`. |
|
|
188
196
|
| `projectPlanList` / `projectPlanCreate` / `projectPlanUpdate` / `projectPlanDelete` | Project plan CRUD used by Studio. |
|
|
189
197
|
|
|
198
|
+
Add per-endpoint rate limits on your own handlers with `rateLimit: { max, window }` on the request definition (see below).
|
|
199
|
+
|
|
190
200
|
---
|
|
191
201
|
|
|
192
202
|
## Shared API definitions
|
|
193
203
|
|
|
194
|
-
`shared/` is consumed by both server and client. Keep all Zod schemas,
|
|
195
|
-
|
|
204
|
+
`shared/` is consumed by both server and client. Keep all Zod schemas, types,
|
|
205
|
+
collections, and route declarations here.
|
|
196
206
|
|
|
197
207
|
### Requests (`shared/api.ts`)
|
|
198
208
|
|
|
@@ -222,8 +232,8 @@ export const requests = defineRequests({
|
|
|
222
232
|
```
|
|
223
233
|
|
|
224
234
|
Every request is reachable as **both** `socket.request(name, input)`
|
|
225
|
-
(WebSocket) and `POST /api/:name { input }` (HTTP). `z` is re-exported
|
|
226
|
-
|
|
235
|
+
(WebSocket) and `POST /api/:name { input }` (HTTP). `z` is re-exported from
|
|
236
|
+
Zod for convenience.
|
|
227
237
|
|
|
228
238
|
### Collections (`shared/collections.ts`)
|
|
229
239
|
|
|
@@ -259,20 +269,19 @@ export const collections = defineCollections({
|
|
|
259
269
|
- `public` — allow unauthenticated client reads.
|
|
260
270
|
- `cascadeFrom` — parent collection: when the parent doc is deleted, docs in this collection are cascade-deleted.
|
|
261
271
|
- `trackKeys?` — fields usable as NATS routing keys for scoped `trackDocs` subscriptions.
|
|
262
|
-
- `getter?(ids)` — batch resolver for collections with no local table (
|
|
272
|
+
- `getter?(ids)` — batch resolver for collections with no local table (e.g. `userPublic` resolves via ugly.bot).
|
|
263
273
|
- `skipReadValidation?` — opt out of per-row zod validation on reads for very hot collections. Writes are always validated. Global kill-switch: `UGLY_DB_VALIDATE_READS=0`.
|
|
264
274
|
- `search?: { fields, language? }` — full-text index over the named JSONB paths (Neon: Postgres FTS; D1: SQLite FTS5, ranked by bm25).
|
|
265
275
|
- `vector?: { dimensions, metric?, filterable? }` — ANN index. Vector supplied out-of-band at write time (`setDoc(c, doc, { vec })`) — never stored in the doc JSON. Query with `getDocs(c, filter, { near })`.
|
|
266
276
|
|
|
267
277
|
All documents extend `DBObject`: `{ _id, version, created, updated }`.
|
|
268
|
-
Use `dbDefaults()` to stamp `version` / `created` / `updated` on
|
|
269
|
-
|
|
270
|
-
`
|
|
271
|
-
`CLAUDE.md`).
|
|
278
|
+
Use `dbDefaults()` to stamp `version` / `created` / `updated` on inserts.
|
|
279
|
+
**Always generate `_id` with `nanoid()`** — never `crypto.randomUUID()`,
|
|
280
|
+
`Date.now()`, or `Math.random()` (see `CLAUDE.md`).
|
|
272
281
|
|
|
273
|
-
After schema changes, run `npm run db:schema-gen` then `npm run
|
|
274
|
-
|
|
275
|
-
|
|
282
|
+
After schema changes, run `npm run db:schema-gen` then `npm run db:migrate`.
|
|
283
|
+
The app refuses to start when drift is detected (set `SCHEMA_CHECK_SKIP=true`
|
|
284
|
+
only as a last resort).
|
|
276
285
|
|
|
277
286
|
---
|
|
278
287
|
|
|
@@ -295,23 +304,20 @@ export const pages = definePages({
|
|
|
295
304
|
export type AppPages = typeof pages;
|
|
296
305
|
```
|
|
297
306
|
|
|
298
|
-
`definePage<Params>(options?)` returns a `PageDef<Params>` — a runtime
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
**Options** (all optional):
|
|
307
|
+
`definePage<Params>(options?)` returns a `PageDef<Params>` — a runtime object
|
|
308
|
+
carrying `PageMeta` plus a phantom `_params` used only for TypeScript
|
|
309
|
+
inference. `PageMeta` fields:
|
|
303
310
|
|
|
304
|
-
- `auth` (default `true`) — protected route. When `isAuthenticated()` returns false on this route, the router synchronously renders the framework's `<AuthRoot>` in place of the page (
|
|
305
|
-
- `ssr` (default `false`) — server-render the page for SEO. `{ auth: true, ssr: true }` is silently
|
|
311
|
+
- `auth` (default `true`) — protected route. When `isAuthenticated()` returns false on this route, the router synchronously renders the framework's `<AuthRoot>` in place of the page (Mode A → `<LoginPopup>`, Mode B → `<MagicLinkForm>` plus optional Google button). Apps cannot override this fallback.
|
|
312
|
+
- `ssr` (default `false`) — server-render the page for SEO. `{ auth: true, ssr: true }` is silently downgraded to `ssr: false` with a warning: the SSR document is served from a shared edge cache and must not depend on the viewer.
|
|
306
313
|
- `cacheQuery?: string[]` — query params that participate in the SSR edge-cache key. Anything NOT listed bypasses the cache instead of poisoning it (e.g. `cacheQuery: ['q']` on a search page).
|
|
307
|
-
- `ssrCacheTimeout?: number` — edge cache lifetime in seconds. Default is
|
|
314
|
+
- `ssrCacheTimeout?: number` — edge cache lifetime in seconds. Default is `DEFAULT_SSR_CACHE_TIMEOUT` (1 year) because the `buildId` is already part of the cache key. Override only for pages that drift between deploys.
|
|
308
315
|
|
|
309
316
|
Path syntax: `:param` matches a single path segment; `*param` is greedy
|
|
310
|
-
(captures slashes). Query-string params are declared in `Params` but
|
|
311
|
-
|
|
317
|
+
(captures slashes). Query-string params are declared in `Params` but never
|
|
318
|
+
appear in the path template.
|
|
312
319
|
|
|
313
|
-
`definePages<T>(p)` is a pass-through identity — use it to
|
|
314
|
-
registry a name.
|
|
320
|
+
`definePages<T>(p)` is a pass-through identity — use it to name the registry.
|
|
315
321
|
|
|
316
322
|
### `createRouter()` (`ugly-app/client`)
|
|
317
323
|
|
|
@@ -332,23 +338,23 @@ export const {
|
|
|
332
338
|
**Config:**
|
|
333
339
|
- `pages` (required) — the `PageRegistry` from `shared/pages.ts`.
|
|
334
340
|
- `allPages?` — the lazy route → element map (`PageMap<Pages>`). Prefer registering via `setAllPages()` from the browser entry when the app also server-renders — the Worker bundle has no code splitting, so a static import inlines every page (and its deps) into `worker.js`.
|
|
335
|
-
- `ssrPages
|
|
341
|
+
- `ssrPages?: SsrPageMap<Pages>` — `Partial<{ [K]: ComponentType<Params> }>` of statically-imported components for `ssr: true` routes. Used by both the Worker's `renderToString` and the client's first hydration render so the initial paint resolves synchronously and never mismatches the server output.
|
|
336
342
|
|
|
337
343
|
**Returns:**
|
|
338
344
|
|
|
339
|
-
- **`RouterProvider`** — props: `children`, `fallback?`, `isAuthenticated
|
|
340
|
-
- **`RouterView`** — renders the active page with animated transitions. Props: `durationMs?`, `easing
|
|
345
|
+
- **`RouterProvider`** — props: `children`, `fallback?`, `isAuthenticated?: () => boolean`, `initialUrl?: { pathname, search }` (required for SSR + hydration). Manages route state, browser history, and the popup layer. Runs the per-route auth check synchronously — apps cannot override the login fallback.
|
|
346
|
+
- **`RouterView`** — renders the active page with animated transitions. Props: `durationMs?`, `easing?: EasingFunction`, `transitionComponent?: React.ComponentType<RouterTransitionProps>` (replaces the default `ViewFlipper`), `renderPage?(state: RouterStateRaw) => ReactElement` (sync alternative to `allPages` loaders).
|
|
341
347
|
- **`useRouter()`** — returns `RouterContextValue<Pages>` (typed navigation + popup API). Throws if used outside `<RouterProvider>`.
|
|
342
348
|
- **`Link`** — typed SPA link bound to this router. Renders a real `<a>` (right-click / cmd-click / SEO keep working) but intercepts a plain left-click and routes through `push` / `replace`. Props: `to`, `params` (both type-checked against `pages`), `replace?`, `children`, plus common anchor attrs (`className`, `style`, `target`, `aria-label`, `data-id`, `onClick`).
|
|
343
349
|
- **`setAllPages(map)`** — register the lazy `PageMap<Pages>` after construction (typically from the browser entry, so the Worker's import graph stays free of every lazy page).
|
|
344
350
|
|
|
345
|
-
A standalone `Link` is also exported from `ugly-app/client` for code
|
|
346
|
-
|
|
347
|
-
|
|
351
|
+
A standalone `Link` is also exported from `ugly-app/client` for code that
|
|
352
|
+
can't easily reach the typed one; it accepts an optional `router` prop and
|
|
353
|
+
falls back to `<RouterProvider>` context.
|
|
348
354
|
|
|
349
|
-
**Never use a bare `<a href="/route">`** for internal navigation — it
|
|
350
|
-
|
|
351
|
-
|
|
355
|
+
**Never use a bare `<a href="/route">`** for internal navigation — it triggers
|
|
356
|
+
a full document reload (white flash + repaint). See the "client navigation"
|
|
357
|
+
rule in `CLAUDE.md`.
|
|
352
358
|
|
|
353
359
|
### Page map — `lazyPage` / `lazyPageLoader`
|
|
354
360
|
|
|
@@ -369,10 +375,9 @@ export const allPages = {
|
|
|
369
375
|
- **`lazyPageLoader(factory)`** — lazy-imports an async loader `(params) => Promise<ReactElement>`. Use when a route needs data fetching before render. The loader file is the chunk boundary, so it can statically import its page component.
|
|
370
376
|
|
|
371
377
|
Both wrappers recover from stale-deploy chunk 404s ("Failed to fetch
|
|
372
|
-
dynamically imported module"): on the first failure they trigger a
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
new chunk hashes.
|
|
378
|
+
dynamically imported module"): on the first failure they trigger a single
|
|
379
|
+
`window.location.reload()` (guarded by `sessionStorage` so a genuinely broken
|
|
380
|
+
chunk can't loop) to fetch fresh chunk hashes.
|
|
376
381
|
|
|
377
382
|
```ts
|
|
378
383
|
// pages/SlowPageLoader.tsx
|
|
@@ -402,15 +407,15 @@ replace('search', { q: 'hello' }); // → /search?q=hello
|
|
|
402
407
|
back(); // browser history back
|
|
403
408
|
```
|
|
404
409
|
|
|
405
|
-
Route names and params are fully typed against `pages`. `push` /
|
|
406
|
-
|
|
407
|
-
|
|
410
|
+
Route names and params are fully typed against `pages`. `push` / `replace`
|
|
411
|
+
no-op with a `console.error` when `buildUrl()` produces a URL that doesn't
|
|
412
|
+
match a registered route.
|
|
408
413
|
|
|
409
414
|
### Popups — `openPopup()`
|
|
410
415
|
|
|
411
|
-
`useRouter().openPopup()` is the canonical modal / sheet / menu API.
|
|
412
|
-
|
|
413
|
-
|
|
416
|
+
`useRouter().openPopup()` is the canonical modal / sheet / menu API. The
|
|
417
|
+
router owns the popup layer, drives a spring animation, and stacks popups
|
|
418
|
+
z-index-correctly above every page.
|
|
414
419
|
|
|
415
420
|
```tsx
|
|
416
421
|
const { openPopup } = useRouter();
|
|
@@ -435,20 +440,20 @@ handle.hide(); // dismiss programmatically — same as router.closePopup(handle.
|
|
|
435
440
|
- **`contextMenu`** — same as transient, intended for menus and pickers.
|
|
436
441
|
|
|
437
442
|
`renderLayer` receives `{ content, spring, hide }`: `spring` is a
|
|
438
|
-
`createAnimatedValue()` result driving 0 → 1; `hide` closes the popup.
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
443
|
+
`createAnimatedValue()` result driving 0 → 1; `hide` closes the popup. The
|
|
444
|
+
default layer animates open with `easeOut` (250 ms) and closed with `easeIn`
|
|
445
|
+
(200 ms). Popups render as siblings of the router's children (managed inside
|
|
446
|
+
`RouterProvider`), so they stack above every page.
|
|
442
447
|
|
|
443
448
|
### Scroll containers
|
|
444
449
|
|
|
445
450
|
`html, body, #root` are `overflow: hidden` — the document itself never
|
|
446
|
-
scrolls, so a page that just renders tall content is clipped. For pages
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
451
|
+
scrolls, so a page that just renders tall content is clipped. For pages that
|
|
452
|
+
own their scrolling (or render outside the normal authed chrome — e.g. a
|
|
453
|
+
public page special-cased before the auth gate), wrap the content in
|
|
454
|
+
`SimpleScrollView` (exported from `ugly-app/client`). For long / virtualized
|
|
455
|
+
lists with scroll-position persistence, use the richer `ScrollView`. See the
|
|
456
|
+
"page scrolling" rule in `CLAUDE.md`.
|
|
452
457
|
|
|
453
458
|
---
|
|
454
459
|
|
|
@@ -471,6 +476,9 @@ bootstrapApp({
|
|
|
471
476
|
});
|
|
472
477
|
```
|
|
473
478
|
|
|
479
|
+
`bootstrapApp(options): void` — returns nothing; it renders directly to the
|
|
480
|
+
DOM.
|
|
481
|
+
|
|
474
482
|
**`BootstrapAppOptions`:**
|
|
475
483
|
|
|
476
484
|
| Field | Description |
|
|
@@ -488,28 +496,28 @@ bootstrapApp({
|
|
|
488
496
|
| `silentSso?` | Apex-domain apps that use ugly.bot as their auth authority but don't share its cookie. When `true`, a logged-out boot attempts a top-level SSO redirect (once per tab) to adopt an existing ugly.bot session. Leave unset for `*.ugly.bot` subdomains and Mode B apps. |
|
|
489
497
|
| `testMode?` | Skip ugly.bot silent SSO and trust the auth cookie the fixture set. Only honored when `UGLY_APP_TEST_MODE=1`. |
|
|
490
498
|
|
|
491
|
-
`bootstrapApp` reads `window.__AUTH_TOKEN__` (injected by the server
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
499
|
+
`bootstrapApp` reads `window.__AUTH_TOKEN__` (injected by the server after
|
|
500
|
+
cookie verification). If absent, it renders unauthenticated immediately — the
|
|
501
|
+
router's per-route auth guard surfaces `<AuthRoot>` only when the user opens a
|
|
502
|
+
protected route. If the token is present, it connects the socket, mounts
|
|
503
|
+
`<AppProvider>`, and renders.
|
|
496
504
|
|
|
497
505
|
After render, a hidden iframe silently calls
|
|
498
|
-
`${UGLY_BOT_URL}/oauth/silent`: if it returns a fresh code, the cookie
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
506
|
+
`${UGLY_BOT_URL}/oauth/silent`: if it returns a fresh code, the cookie is
|
|
507
|
+
refreshed via `POST /auth/verify`; if the returned account differs from the
|
|
508
|
+
current session, the page reloads onto the live account (guarded once per
|
|
509
|
+
from→to pair to prevent loops). Any `?ugly_oauth_code=…` in the URL
|
|
510
|
+
(redirect-fallback OAuth code from ugly.bot) is redeemed at the top of
|
|
511
|
+
bootstrap before anything renders.
|
|
504
512
|
|
|
505
|
-
If `bootstrapApp` is loaded at `/auth/magic-link/verify` (Mode B), it
|
|
506
|
-
|
|
513
|
+
If `bootstrapApp` is loaded at `/auth/magic-link/verify` (Mode B), it renders
|
|
514
|
+
`<MagicLinkCallback>` instead of the regular app shell.
|
|
507
515
|
|
|
508
516
|
### `AppProvider` & `useApp()`
|
|
509
517
|
|
|
510
|
-
`bootstrapApp` mounts `<AppProvider>` automatically after socket
|
|
511
|
-
|
|
512
|
-
|
|
518
|
+
`bootstrapApp` mounts `<AppProvider>` automatically after socket connect. Use
|
|
519
|
+
`useApp()` inside any page to access the active user, socket, and app-scoped
|
|
520
|
+
services.
|
|
513
521
|
|
|
514
522
|
```ts
|
|
515
523
|
const {
|
|
@@ -517,28 +525,26 @@ const {
|
|
|
517
525
|
user, // UserBase doc
|
|
518
526
|
socket, // AppSocket — typed RPC client
|
|
519
527
|
uglyBotSocket, // UglyBotSocket | null — direct platform socket for STT/TTS
|
|
520
|
-
showPopup, // flat overlay layer (see note)
|
|
521
|
-
hidePopup,
|
|
522
|
-
hideAllPopups,
|
|
528
|
+
showPopup, // (content: ReactElement) => id — flat overlay layer (see note)
|
|
529
|
+
hidePopup, // (id: string) => void
|
|
530
|
+
hideAllPopups, // () => void
|
|
523
531
|
runAsync, // (label, async () => {…}, options?) — shows loading overlay
|
|
524
|
-
splashDone, // (step: string) — mark a splash-screen step complete
|
|
532
|
+
splashDone, // (step: string) => void — mark a splash-screen step complete
|
|
525
533
|
localizer, // (key, params?) => string — alias for useLocalizer()
|
|
526
534
|
} = useApp();
|
|
527
535
|
```
|
|
528
536
|
|
|
529
|
-
`useApp<TAsyncOptions>()` is generic in the async-options type so apps
|
|
530
|
-
|
|
531
|
-
`AppProvider` `cloneElement`s the overlay with
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
prop, and finally to identity.
|
|
537
|
+
`useApp<TAsyncOptions>()` is generic in the async-options type so apps can
|
|
538
|
+
pass a custom option through to a custom `loadingOverlay` element. When
|
|
539
|
+
`runAsync` is pending, `AppProvider` `cloneElement`s the overlay with
|
|
540
|
+
`{ label, asyncOptions }`. `useAppOptional()` returns `null` outside the
|
|
541
|
+
provider; `useLocalizer()` prefers `<StringsProvider>` data, falls back to
|
|
542
|
+
the `AppProvider` `localizer` prop, and finally to identity.
|
|
536
543
|
|
|
537
544
|
Two popup APIs coexist: `useRouter().openPopup()` (spring-animated,
|
|
538
|
-
dismissible, mode-aware — **the default choice**) and
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
router's.
|
|
545
|
+
dismissible, mode-aware — **the default choice**) and `useApp().showPopup()`
|
|
546
|
+
(a flat overlay layer returning a string id, for content that must sit
|
|
547
|
+
*outside* the router transition). Prefer the router's.
|
|
542
548
|
|
|
543
549
|
### Direct socket access
|
|
544
550
|
|
|
@@ -565,14 +571,14 @@ from `ugly-app/client`.
|
|
|
565
571
|
ugly-app uses HttpOnly cookies and server-side JWT injection — no
|
|
566
572
|
`localStorage`, no client-side token handling.
|
|
567
573
|
|
|
568
|
-
Two modes, picked from the `auth` block in the project's `.uglyapp`
|
|
569
|
-
config:
|
|
574
|
+
Two modes, picked from the `auth` block in the project's `.uglyapp` config:
|
|
570
575
|
|
|
571
576
|
- **Mode A — `mode: 'uglybot'`** (default when no `auth` block is present). Auth is delegated to ugly.bot OAuth. AI / email / push proxies bill the end user's ugly.bot credits.
|
|
572
577
|
- **Mode B — `mode: 'self'`**. The app issues its own sessions via magic-link email and/or Google OAuth. AI / email / push proxies bill the developer's ugly.bot account using the project's `AI_PROXY_TOKEN`.
|
|
573
578
|
|
|
574
|
-
`window.__UGLY_APP_AUTH_MODE__` is injected into every page so client
|
|
575
|
-
|
|
579
|
+
`window.__UGLY_APP_AUTH_MODE__` is injected into every page so client code
|
|
580
|
+
(incl. `<AuthRoot>`) can branch correctly. In Mode B with Google configured,
|
|
581
|
+
`window.__UGLY_APP_GOOGLE_CLIENT_ID__` is also injected.
|
|
576
582
|
|
|
577
583
|
### Mode A — ugly.bot OAuth (default)
|
|
578
584
|
|
|
@@ -584,9 +590,9 @@ code (incl. `<AuthRoot>`) can branch correctly.
|
|
|
584
590
|
|
|
585
591
|
### Mode B — self-issued sessions
|
|
586
592
|
|
|
587
|
-
When `.uglyapp` sets `auth.mode: 'self'`, `buildAuth()` wires the
|
|
588
|
-
|
|
589
|
-
|
|
593
|
+
When `.uglyapp` sets `auth.mode: 'self'`, `buildAuth()` wires the magic-link
|
|
594
|
+
provider as primary; if `providers.google.clientId` is configured it adds
|
|
595
|
+
Google as an extra provider on the same router.
|
|
590
596
|
|
|
591
597
|
- `<AuthRoot>` renders `<MagicLinkForm>` (plus the Google button when configured) instead of `<LoginPopup>`.
|
|
592
598
|
- Background ugly.bot silent SSO is a no-op.
|
|
@@ -596,9 +602,9 @@ configured it adds Google as an extra provider on the same router.
|
|
|
596
602
|
|
|
597
603
|
### Token-in-URL embed
|
|
598
604
|
|
|
599
|
-
Any GET request with `?token=<JWT>` will, if the token verifies, set
|
|
600
|
-
|
|
601
|
-
|
|
605
|
+
Any GET request with `?token=<JWT>` will, if the token verifies, set the
|
|
606
|
+
cookie and 302-redirect to the same URL without the token parameter — useful
|
|
607
|
+
for embedding any page in an iframe.
|
|
602
608
|
|
|
603
609
|
### Built-in auth routes
|
|
604
610
|
|
|
@@ -632,8 +638,8 @@ configurator.setAuth({
|
|
|
632
638
|
## Database — `TypedDB`
|
|
633
639
|
|
|
634
640
|
Access via `app.db`. All methods accept a `CollectionDef` (from
|
|
635
|
-
`defineCollections()`) or, on the `raw*` / `getQuery*` variants, a
|
|
636
|
-
|
|
641
|
+
`defineCollections()`) or, on the `raw*` / `getQuery*` variants, a plain
|
|
642
|
+
collection name string.
|
|
637
643
|
|
|
638
644
|
### Writing
|
|
639
645
|
|
|
@@ -658,10 +664,10 @@ await db.batch(async () => {
|
|
|
658
664
|
});
|
|
659
665
|
```
|
|
660
666
|
|
|
661
|
-
Supported update operators: `$inc`, `$addToSet`, `$pull`, `$unset`,
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
667
|
+
Supported update operators: `$inc`, `$addToSet`, `$pull`, `$unset`, `$set`.
|
|
668
|
+
All keys are dot-notation, fully typed against the collection's schema.
|
|
669
|
+
Partial updates go through optimistic concurrency to prevent lost updates
|
|
670
|
+
on concurrent writers.
|
|
665
671
|
|
|
666
672
|
### Reading
|
|
667
673
|
|
|
@@ -695,8 +701,8 @@ await db.deleteWhere(collections.note, { userId }); // typed bulk delete
|
|
|
695
701
|
await db.deleteQuery(collections.note, { userId }); // legacy untyped bulk delete
|
|
696
702
|
```
|
|
697
703
|
|
|
698
|
-
Pass `deleteHandlers` as the 5th argument to `createApp` to run
|
|
699
|
-
|
|
704
|
+
Pass `deleteHandlers` as the 5th argument to `createApp` to run per-collection
|
|
705
|
+
`onDelete` callbacks.
|
|
700
706
|
|
|
701
707
|
### Search
|
|
702
708
|
|
|
@@ -745,10 +751,9 @@ Imports available from `ugly-app`:
|
|
|
745
751
|
|
|
746
752
|
## AI providers
|
|
747
753
|
|
|
748
|
-
AI calls are proxied through ugly.bot — your app never holds a
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
billing.
|
|
754
|
+
AI calls are proxied through ugly.bot — your app never holds a provider key.
|
|
755
|
+
Pass `UGLY_BOT_TOKEN` in the environment and the framework handles routing,
|
|
756
|
+
balance tracking, retries, and per-user billing.
|
|
752
757
|
|
|
753
758
|
### Server-side text generation
|
|
754
759
|
|
|
@@ -770,9 +775,9 @@ const { message } = await uglyBotRequest<{ message: { content: string } }>('text
|
|
|
770
775
|
});
|
|
771
776
|
```
|
|
772
777
|
|
|
773
|
-
Available models are exposed via `textGenModels` / `textGenModelData`
|
|
774
|
-
|
|
775
|
-
|
|
778
|
+
Available models are exposed via `textGenModels` / `textGenModelData` from
|
|
779
|
+
`ugly-app` — the platform supports Claude, GPT, Gemini, Together, Groq,
|
|
780
|
+
Fireworks, Kimi, and Kie families.
|
|
776
781
|
|
|
777
782
|
### Server-side image generation
|
|
778
783
|
|
|
@@ -783,8 +788,8 @@ const imageGen = createImageGen(userId);
|
|
|
783
788
|
const url = await imageGen.generate('A red panda eating noodles', { model: 'flux_schnell' });
|
|
784
789
|
```
|
|
785
790
|
|
|
786
|
-
`imageGenModels` / `imageGenModelData` enumerate available models
|
|
787
|
-
|
|
791
|
+
`imageGenModels` / `imageGenModelData` enumerate available models (Together
|
|
792
|
+
FLUX, FAL, Google Imagen, Wavespeed, Kie Kolors).
|
|
788
793
|
|
|
789
794
|
### Embeddings
|
|
790
795
|
|
|
@@ -809,8 +814,8 @@ await search.enrichNews({ query: 'topic' });
|
|
|
809
814
|
|
|
810
815
|
### Client-side AI calls
|
|
811
816
|
|
|
812
|
-
Calls from React components go through the framework RPC pipeline — no
|
|
813
|
-
|
|
817
|
+
Calls from React components go through the framework RPC pipeline — no token
|
|
818
|
+
plumbing in the browser:
|
|
814
819
|
|
|
815
820
|
```ts
|
|
816
821
|
import { callTextGen, callJsonGen, callImageGen } from 'ugly-app/client';
|
|
@@ -822,9 +827,8 @@ const image = await callImageGen({ prompt: 'a corgi astronaut', model: 'flux_sch
|
|
|
822
827
|
|
|
823
828
|
### STT / TTS
|
|
824
829
|
|
|
825
|
-
Speech goes **directly** from the browser to ugly.bot — never proxied
|
|
826
|
-
|
|
827
|
-
`CLAUDE.md`).
|
|
830
|
+
Speech goes **directly** from the browser to ugly.bot — never proxied through
|
|
831
|
+
your app server (see the "STT/TTS routes to ugly.bot" rule in `CLAUDE.md`).
|
|
828
832
|
|
|
829
833
|
```ts
|
|
830
834
|
import { useSTT, useTTS, AudioPlayer, AudioRecorder } from 'ugly-app/client';
|
|
@@ -858,24 +862,23 @@ const { uploadUrl, resultUrl } = await storage.presignedPut('temp', key);
|
|
|
858
862
|
await storage.delete('public', destKey);
|
|
859
863
|
```
|
|
860
864
|
|
|
861
|
-
Client-side, use `socket.uploadFile(file, key)` — it requests a
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
+
Client-side, use `socket.uploadFile(file, key)` — it requests a presigned URL
|
|
866
|
+
via the built-in `uploadUrl` framework request and streams the upload. In
|
|
867
|
+
dev, uploads go through a same-origin `/_s3` proxy to avoid CORS with local
|
|
868
|
+
MinIO.
|
|
865
869
|
|
|
866
|
-
`STORAGE_KEY_PREFIX` (env) prefixes all keys — useful for
|
|
867
|
-
|
|
870
|
+
`STORAGE_KEY_PREFIX` (env) prefixes all keys — useful for per-environment
|
|
871
|
+
isolation.
|
|
868
872
|
|
|
869
|
-
On Cloudflare Workers, storage uses the R2 binding directly; presigned
|
|
870
|
-
|
|
871
|
-
endpoint).
|
|
873
|
+
On Cloudflare Workers, storage uses the R2 binding directly; presigned PUTs
|
|
874
|
+
are not exposed (browser uploads must go through a Worker endpoint).
|
|
872
875
|
|
|
873
876
|
### Static assets — `static/`
|
|
874
877
|
|
|
875
878
|
Large, rarely-changing files (3D models, textures, audio, fonts) go in the
|
|
876
879
|
project-root `static/` folder. `ugly-app build:static` hashes them and
|
|
877
|
-
generates `shared/StaticAssets.ts`; publish uploads only the files whose
|
|
878
|
-
changed.
|
|
880
|
+
generates `shared/StaticAssets.ts`; publish uploads only the files whose
|
|
881
|
+
bytes changed.
|
|
879
882
|
|
|
880
883
|
```ts
|
|
881
884
|
import { staticUrl } from '../shared/StaticAssets'
|
|
@@ -885,15 +888,14 @@ const url = staticUrl('models/char2.ugm')
|
|
|
885
888
|
```
|
|
886
889
|
|
|
887
890
|
The bucket gets its own custom domain, so **Cloudflare's CDN serves these
|
|
888
|
-
directly** — no Worker invocation per request, and R2 answers `Range`
|
|
889
|
-
for progressive loaders.
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
rule, without which COEP `require-corp` would silently block them.
|
|
891
|
+
directly** — no Worker invocation per request, and R2 answers `Range`
|
|
892
|
+
natively for progressive loaders. Publish also sets the bucket CORS policy
|
|
893
|
+
and a `Cross-Origin-Resource-Policy: cross-origin` response rule, without
|
|
894
|
+
which COEP `require-corp` would silently block them.
|
|
893
895
|
|
|
894
896
|
Unlike `/{buildId}/assets/*`, these URLs do not change when you deploy, so
|
|
895
|
-
returning users do not re-download unchanged files. Assets that no build
|
|
896
|
-
the last `retentionDays` (default 7) references are deleted from R2
|
|
897
|
+
returning users do not re-download unchanged files. Assets that no build
|
|
898
|
+
from the last `retentionDays` (default 7) references are deleted from R2
|
|
897
899
|
automatically, along with expired `builds/` prefixes.
|
|
898
900
|
|
|
899
901
|
Requires a provisioned app domain — publish fails rather than emitting URLs
|
|
@@ -903,8 +905,8 @@ nothing serves. Configure in `.uglyapp`:
|
|
|
903
905
|
"staticAssets": { "dir": "static", "retentionDays": 7 }
|
|
904
906
|
```
|
|
905
907
|
|
|
906
|
-
Dev serves the same files off disk at same-origin, so a newly added asset
|
|
907
|
-
before it has ever been published.
|
|
908
|
+
Dev serves the same files off disk at same-origin, so a newly added asset
|
|
909
|
+
works before it has ever been published.
|
|
908
910
|
|
|
909
911
|
### `hostBundle` — serve the vite bundle from the CDN too
|
|
910
912
|
|
|
@@ -919,19 +921,20 @@ prefixed, a byte-identical chunk keeps its URL across deploys — so returning
|
|
|
919
921
|
users re-download only what actually changed, and each deploy uploads a
|
|
920
922
|
handful of KB instead of the whole bundle.
|
|
921
923
|
|
|
922
|
-
The first deploy only provisions; the bundle moves to the CDN on the next
|
|
923
|
-
That is deliberate — a single pass would point the bundle at a hostname
|
|
924
|
-
TLS certificate may not have issued yet.
|
|
925
|
-
|
|
926
|
-
**Workers are handled for you, but know why.** The bundle is then
|
|
927
|
-
and `new Worker(url)` *rejects a cross-origin script URL
|
|
928
|
-
structural, and no CORS or CORP header lifts it.
|
|
929
|
-
shim that routes such URLs through a same-origin
|
|
930
|
-
script, so `new Worker(new URL('./x.worker.ts',
|
|
931
|
-
working. `SharedWorker` is **not** shimmed: every
|
|
932
|
-
same trick would give each caller a private
|
|
933
|
-
service workers by root-absolute path
|
|
934
|
-
against the document, so those stay
|
|
924
|
+
The first deploy only provisions; the bundle moves to the CDN on the next
|
|
925
|
+
one. That is deliberate — a single pass would point the bundle at a hostname
|
|
926
|
+
whose TLS certificate may not have issued yet.
|
|
927
|
+
|
|
928
|
+
**Workers are handled for you, but know why.** The bundle is then
|
|
929
|
+
cross-origin, and `new Worker(url)` *rejects a cross-origin script URL
|
|
930
|
+
outright* — that is structural, and no CORS or CORP header lifts it.
|
|
931
|
+
`bootstrapApp` installs a shim that routes such URLs through a same-origin
|
|
932
|
+
`blob:` importing the real script, so `new Worker(new URL('./x.worker.ts',
|
|
933
|
+
import.meta.url))` keeps working. `SharedWorker` is **not** shimmed: every
|
|
934
|
+
blob URL is unique, so the same trick would give each caller a private
|
|
935
|
+
worker and break sharing. Register service workers by root-absolute path
|
|
936
|
+
(`/sw.js`) — `register()` resolves against the document, so those stay
|
|
937
|
+
same-origin.
|
|
935
938
|
|
|
936
939
|
---
|
|
937
940
|
|
|
@@ -963,16 +966,15 @@ const cronHandlers: WorkerHandlers<typeof cronTasks> = {
|
|
|
963
966
|
configurator.setWorkers(cronTasks, cronHandlers);
|
|
964
967
|
```
|
|
965
968
|
|
|
966
|
-
Each worker can have `inputSchema`, `outputSchema`, `schedule`,
|
|
967
|
-
`
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
969
|
+
Each worker can have `inputSchema`, `outputSchema`, `schedule`, `timeout`,
|
|
970
|
+
`description`. Workers without a schedule are still invocable via
|
|
971
|
+
`POST /_workers/run` (auth: localhost in dev, `Authorization: Bearer $CRON_SECRET`
|
|
972
|
+
in prod). Scheduled workers also appear in `/_cron/manifest` for the deploy
|
|
973
|
+
orchestrator.
|
|
971
974
|
|
|
972
|
-
On the Workers adapter, scheduled workers dispatch through Cloudflare
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
queues only).
|
|
975
|
+
On the Workers adapter, scheduled workers dispatch through Cloudflare Cron
|
|
976
|
+
Triggers and durable queueing goes through Cloudflare Queues. On the Node
|
|
977
|
+
adapter, `enqueueWorker` runs the handler inline (in-process queues only).
|
|
976
978
|
|
|
977
979
|
---
|
|
978
980
|
|
|
@@ -987,9 +989,9 @@ configurator.setStrings({
|
|
|
987
989
|
});
|
|
988
990
|
```
|
|
989
991
|
|
|
990
|
-
The framework injects `window.__LANG__`, `window.__STRINGS_VERSION__`,
|
|
991
|
-
|
|
992
|
-
|
|
992
|
+
The framework injects `window.__LANG__`, `window.__STRINGS_VERSION__`, and
|
|
993
|
+
`window.__CRITICAL_STRINGS__` into SSR HTML. Use `useLocalizer()` /
|
|
994
|
+
`useStrings()` / `useLang()` / `useChangeLanguage()` on the client.
|
|
993
995
|
|
|
994
996
|
---
|
|
995
997
|
|
|
@@ -1014,10 +1016,9 @@ export const experiments: Experiment[] = [
|
|
|
1014
1016
|
configurator.setExperiments(experiments);
|
|
1015
1017
|
```
|
|
1016
1018
|
|
|
1017
|
-
Bucketing is deterministic: `hash(experimentId + userId)` (or
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
branch assignments.
|
|
1019
|
+
Bucketing is deterministic: `hash(experimentId + userId)` (or `sessionId`
|
|
1020
|
+
for unauthenticated users). The framework's `initSession` / `captureEvent`
|
|
1021
|
+
requests automatically tag events with the user's branch assignments.
|
|
1021
1022
|
|
|
1022
1023
|
---
|
|
1023
1024
|
|
|
@@ -1043,17 +1044,15 @@ branch assignments.
|
|
|
1043
1044
|
|
|
1044
1045
|
## Two-adapter architecture
|
|
1045
1046
|
|
|
1046
|
-
The same developer source compiles for two runtimes via
|
|
1047
|
-
`src/server/adapter/`:
|
|
1047
|
+
The same developer source compiles for two runtimes via `src/server/adapter/`:
|
|
1048
1048
|
|
|
1049
1049
|
- **Adapter A — Node + TCP** (`src/server/adapter/node/`): default for `npm run dev` and any non-Workers deploy. Wraps `pg.Pool`, `nats.js`, AWS S3 SDK.
|
|
1050
1050
|
- **Adapter B — Cloudflare Workers** (`src/server/adapter/workers/`): used when the Studio publish flow deploys to Cloudflare. Hono router + Durable Objects (one `CollectionDO` per project-collection, plus a `SessionDO` per user WS) + `@neondatabase/serverless` HTTP driver + R2 binding + Cloudflare Cron Triggers + Cloudflare Queues.
|
|
1051
1051
|
|
|
1052
|
-
Developer-facing APIs (`createTypedDB`, `subscribeDoc`,
|
|
1053
|
-
`
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
the Worker can talk to a real Postgres.
|
|
1052
|
+
Developer-facing APIs (`createTypedDB`, `subscribeDoc`, `createStorageClient`,
|
|
1053
|
+
`setCronTasks`, `setWorkers`) don't change between adapters. Local Workers dev
|
|
1054
|
+
boots via `npm run dev:workers`, which starts a small Node HTTP proxy speaking
|
|
1055
|
+
Neon's wire format so the Worker can talk to a real Postgres.
|
|
1057
1056
|
|
|
1058
1057
|
---
|
|
1059
1058
|
|
|
@@ -1108,8 +1107,8 @@ Browser-visible variables must be prefixed `VITE_` and consumed via
|
|
|
1108
1107
|
| `ugly-app feedback:dev` / `feedback:prod` | Query user feedback. |
|
|
1109
1108
|
| `ugly-app feedback:submit` / `feedback:resolve` | Manage feedback (run with `--help` for flags). |
|
|
1110
1109
|
|
|
1111
|
-
Inside a scaffolded project, the same commands are available via `npm
|
|
1112
|
-
|
|
1110
|
+
Inside a scaffolded project, the same commands are available via `npm run …`
|
|
1111
|
+
scripts — see `templates/CLAUDE.md`.
|
|
1113
1112
|
|
|
1114
1113
|
---
|
|
1115
1114
|
|