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