@pablozaiden/webapp 0.5.8 → 0.6.0

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.
Files changed (55) hide show
  1. package/README.md +11 -3
  2. package/docs/auth-validation.md +11 -0
  3. package/docs/auth.md +25 -0
  4. package/docs/cli.md +73 -3
  5. package/docs/deployment.md +84 -7
  6. package/docs/getting-started.md +69 -6
  7. package/docs/github-actions.md +39 -0
  8. package/docs/release.md +5 -5
  9. package/docs/server.md +34 -4
  10. package/docs/settings.md +5 -0
  11. package/docs/ui-guidelines.md +12 -4
  12. package/package.json +2 -4
  13. package/src/build/build-binary.ts +67 -24
  14. package/src/cli/api-command.ts +53 -16
  15. package/src/cli/credentials.ts +275 -4
  16. package/src/cli/device-auth.ts +83 -22
  17. package/src/cli/environment-auth.ts +57 -0
  18. package/src/cli/index.ts +1 -0
  19. package/src/package-resolution.ts +34 -0
  20. package/src/server/auth/device-auth.ts +2 -2
  21. package/src/server/auth/passkeys.ts +16 -16
  22. package/src/server/auth/request-origin.ts +97 -16
  23. package/src/server/authentication.ts +265 -0
  24. package/src/server/create-web-app-server.ts +85 -1391
  25. package/src/server/framework-endpoints.ts +451 -0
  26. package/src/server/public-route-dispatch.ts +85 -0
  27. package/src/server/request-schemas.ts +144 -0
  28. package/src/server/responses.ts +69 -3
  29. package/src/server/route-dispatch.ts +109 -0
  30. package/src/server/runtime-config.ts +67 -0
  31. package/src/server/same-origin.ts +1 -1
  32. package/src/server/server-lifecycle.ts +138 -0
  33. package/src/server/server-types.ts +87 -0
  34. package/src/server/web-document.ts +532 -0
  35. package/src/web/WebAppRoot.tsx +69 -1214
  36. package/src/web/api-client.ts +14 -4
  37. package/src/web/app-shell.tsx +198 -0
  38. package/src/web/auth-screens.tsx +186 -0
  39. package/src/web/components/index.tsx +10 -3
  40. package/src/web/index.ts +1 -0
  41. package/src/web/mobile-hooks.ts +194 -0
  42. package/src/web/mobile.ts +12 -0
  43. package/src/web/root-types.ts +61 -0
  44. package/src/web/routing.ts +61 -0
  45. package/src/web/settings/account-section.tsx +52 -0
  46. package/src/web/settings/resource-state.tsx +17 -0
  47. package/src/web/settings/security-section.tsx +119 -0
  48. package/src/web/settings/sessions-section.tsx +66 -0
  49. package/src/web/settings/settings-view.tsx +96 -0
  50. package/src/web/settings/shutdown-section.tsx +95 -0
  51. package/src/web/settings/user-management.tsx +123 -0
  52. package/src/web/settings-view.tsx +3 -0
  53. package/src/web/sidebar-state.ts +108 -0
  54. package/src/web/sidebar-tree.tsx +89 -0
  55. package/src/web/styles.css +76 -64
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pablozaiden/webapp
2
2
 
3
- Opinionated Bun + React framework for single-server TypeScript webapps: one Bun process serves the React UI, API routes, multi-user passkey auth, user-owned API keys, device auth, realtime websocket state, scoped settings, binary builds and Docker images.
3
+ Opinionated Bun + React framework for single-server TypeScript webapps: one Bun process serves the React UI, API routes, multi-user passkey auth, user-owned API keys, device auth, stateless CLI API-key auth, realtime websocket state, scoped settings, binary builds and Docker images.
4
4
 
5
5
  ## Quick start
6
6
 
@@ -14,6 +14,10 @@ bun run dev:kitchen-sink
14
14
 
15
15
  Both examples run with Bun native hot reload through `bun --hot`, with no standalone frontend build server and no external `WEB_DIST_DIR`.
16
16
 
17
+ ## Application dependencies
18
+
19
+ Each application package must declare `react` and `react-dom`; they are peer dependencies of the framework. Bun resolves `react-dom/client` from the application package context, so workspace installs do not need to hoist React packages to a repository root. Native startup and binary builds fail early with the application context when `react-dom` is not installed.
20
+
17
21
  ## Main exports
18
22
 
19
23
  | Export | Use |
@@ -21,7 +25,11 @@ Both examples run with Bun native hot reload through `bun --hot`, with no standa
21
25
  | `@pablozaiden/webapp/server` | `createWebAppServer`, route helpers, responses, SQLite store |
22
26
  | `@pablozaiden/webapp/web` | `WebAppRoot`, `renderWebApp`, sidebar types, UI controls, realtime hooks |
23
27
  | `@pablozaiden/webapp/contracts` | Shared auth/config/device/API-key types |
24
- | `@pablozaiden/webapp/cli` | One-binary command helpers, device-auth credentials and generic API CLI caller |
28
+ | `@pablozaiden/webapp/cli` | One-binary command helpers, device/environment auth credentials and generic API CLI caller |
25
29
  | `@pablozaiden/webapp/build` | Bun single-binary compile helper |
26
30
 
27
- See `docs/getting-started.md` for the minimum app shape and `examples/notes-todo` for a realistic app. Use `docs/github-actions.md` when adding CI, Docker and release workflows to an app built with the framework. Use `bun run screenshots` for reproducible manual visual captures. Release/publishing details for this package are in `docs/release.md`.
31
+ See `docs/getting-started.md` for the minimum app shape and `examples/notes-todo` for a realistic app. Use `docs/github-actions.md` when adding CI, Docker and release workflows to an app built with the framework. Release/publishing details for this package are in `docs/release.md`.
32
+
33
+ CLI API callers can use a stored device session or the stateless
34
+ `${PREFIX}_BASE_URL` and `${PREFIX}_API_KEY` environment pair; see
35
+ `docs/cli.md` for precedence and anonymous fallback behavior.
@@ -40,6 +40,17 @@ Run this checklist before releasing a framework app or cutting a checkpoint.
40
40
  4. Delete the key from Settings and confirm the confirmation dialog appears.
41
41
  5. Confirm the deleted token no longer authenticates.
42
42
 
43
+ ## CLI API-key authentication
44
+
45
+ 1. Set `${PREFIX}_BASE_URL` and `${PREFIX}_API_KEY` for an app whose CLI passes
46
+ its `envPrefix` to `runApiCliCommand()`.
47
+ 2. Run a protected API command and confirm it reaches the configured server
48
+ without starting an interactive device flow.
49
+ 3. Remove either variable and confirm the request falls back to anonymous
50
+ behavior.
51
+ 4. Use an expired, invalid, or insufficient-scope key and confirm the CLI
52
+ reports the server rejection without refreshing or retrying the key.
53
+
43
54
  ## Device auth
44
55
 
45
56
  1. Call `POST /api/auth/device` with `client_id` and `scope`.
package/docs/auth.md CHANGED
@@ -59,3 +59,28 @@ Device codes are one-use. Device sessions are self-only in Settings, and only ac
59
59
  ## Same-origin policy
60
60
 
61
61
  Cookie/browser mutations require matching `Origin` or `Referer` by default. Use `sameOrigin: "never"` only for deliberate public/server-to-server routes; use `sameOrigin: "always"` for especially sensitive endpoints even when bearer tokens are used.
62
+
63
+ ## Reverse-proxy request trust
64
+
65
+ Forwarded request headers are ignored by default. When
66
+ `{PREFIX}_TRUST_PROXY=true` is enabled, the policy explicitly selects the
67
+ supported `X-Forwarded-Proto`, `X-Forwarded-Host`, and
68
+ `X-Forwarded-Prefix` values through `{PREFIX}_TRUST_PROXY_HEADERS`. Comma
69
+ chains use the configured `{PREFIX}_TRUST_PROXY_CHAIN` value (`first` for the
70
+ left-most non-empty value or `last` for the right-most non-empty value).
71
+ Malformed or missing request values fall back to the direct request.
72
+
73
+ The same normalized request context drives expected same-origin origins,
74
+ WebSocket origin checks, passkey expected origins and RP IDs, the `Secure`
75
+ cookie attribute, cookie paths, setup links, and device/discovery URLs.
76
+ `{PREFIX}_PUBLIC_BASE_URL` is authoritative for origin, hostname, and secure
77
+ state even when forwarded headers are present; trusted prefixes remain
78
+ policy-controlled for path-bearing cookies and links.
79
+
80
+ Trust mode is a deployment boundary setting, not a way to trust arbitrary
81
+ client input. The reverse proxy must strip client-supplied forwarded headers
82
+ and overwrite only the values configured above, preferably as sanitized
83
+ single-value headers. Do not expose the application directly to untrusted
84
+ clients when trust mode is enabled. This release does not implement a
85
+ proxy-address allowlist, so the network boundary and proxy sanitization are
86
+ required.
package/docs/cli.md CHANGED
@@ -25,7 +25,9 @@ const result = await dispatchCliCommand({
25
25
  process.exitCode = printCliResult(result);
26
26
  ```
27
27
 
28
- Device-auth CLI helpers can be composed into apps that need authenticated CLI calls. Public-token commands, such as webhook notification commands, can stay app-owned and do not need framework auth.
28
+ Device-auth and environment API-key helpers can be composed into apps that
29
+ need authenticated CLI calls. Public-token commands, such as webhook
30
+ notification commands, can stay app-owned and do not need framework auth.
29
31
 
30
32
  Route metadata can power generic API commands:
31
33
 
@@ -37,9 +39,77 @@ const catalog = createRouteCatalog(routes);
37
39
  const result = await runApiCliCommand({
38
40
  catalog,
39
41
  args,
40
- baseUrl: "http://localhost:3000",
42
+ envPrefix: "MY_APP",
41
43
  credentials,
44
+ // Optional explicit override; stored credentials provide the default URL.
45
+ // baseUrl: "https://app.example.test",
42
46
  });
43
47
  ```
44
48
 
45
- `runApiCliCommand` can list endpoints, print schema metadata, call endpoints with `--method` and `--payload`, attach bearer credentials, and refresh once on `401`.
49
+ `runApiCliCommand` can list endpoints, print schema metadata, call endpoints
50
+ with `--method` and `--payload`, attach bearer credentials, and refresh stored
51
+ device credentials once on `401`.
52
+ When stored device credentials are available, their `baseUrl` is used by
53
+ default; pass `baseUrl` only to intentionally override that server. Without
54
+ stored credentials, the environment API-key rules below determine the request
55
+ URL.
56
+
57
+ ## Stateless API-key authentication
58
+
59
+ An app that passes its validated `envPrefix` to `runApiCliCommand` can make
60
+ non-interactive authenticated requests with an API key:
61
+
62
+ ```bash
63
+ export MY_APP_BASE_URL=https://app.example.test
64
+ export MY_APP_API_KEY='key-from-settings'
65
+ my-app api items
66
+ ```
67
+
68
+ The helper derives exactly `${PREFIX}_BASE_URL` and `${PREFIX}_API_KEY` from
69
+ the supplied prefix. When no stored device credentials are available, an
70
+ explicit `baseUrl` plus `${PREFIX}_API_KEY` takes precedence; otherwise the
71
+ complete environment pair supplies the request URL and bearer key. Values are
72
+ trimmed, and the base URL must use `http` or `https`; trailing slashes are
73
+ removed.
74
+
75
+ If either environment variable is missing or empty, the pair is ignored and
76
+ the request remains anonymous, using an explicit `baseUrl` or
77
+ `http://localhost:3000`. A partial pair is not an error. Stored device
78
+ credentials remain highest priority and continue to use their existing refresh
79
+ and `401` retry behavior.
80
+
81
+ The environment API-key path does not run interactive login or device
82
+ authorization, refresh tokens, write credential files, or retry rejected
83
+ requests. The key is supplied by the environment and kept in memory for the
84
+ request; it is not printed or persisted by the CLI. The server remains
85
+ responsible for API-key validity, expiry, and route scopes.
86
+
87
+ ## Concurrent credential refreshes
88
+
89
+ `createJsonFileStore()` and `createDeviceCredentialsStore()` coordinate refreshes for
90
+ the same credential path with an adjacent lock file. When credentials are expired,
91
+ `refreshDeviceCredentials()` acquires that lock before calling the token endpoint,
92
+ reads the store again, and reuses credentials another process may have refreshed.
93
+ The write of newly refreshed credentials happens while the lock is held.
94
+
95
+ The `JsonFileStore<T>.withLock()` method is optional so existing custom stores that
96
+ only implement `path`, `read`, `write` and `clear` remain source-compatible. A
97
+ custom store must provide `read` and `withLock` to receive the same cross-process
98
+ refresh guarantee; otherwise the legacy refresh path is retained.
99
+
100
+ ```ts
101
+ await store.withLock(async () => {
102
+ // Work serialized for this store path.
103
+ }, {
104
+ timeoutMs: 30_000,
105
+ staleAfterMs: 300_000,
106
+ pollIntervalMs: 25,
107
+ });
108
+ ```
109
+
110
+ The defaults are a 30-second acquisition timeout, a five-minute stale threshold
111
+ and a 25-millisecond polling interval. A timeout throws `JsonFileStoreLockError`
112
+ with `code === "timeout"`. Release failures use `code === "release"`. Lock
113
+ metadata contains only ownership information (PID, timestamp and nonce), never
114
+ access or refresh tokens. The credentials directory and file retain `0700` and
115
+ `0600` permissions respectively.
@@ -17,6 +17,10 @@ await buildWebAppBinary({
17
17
  });
18
18
  ```
19
19
 
20
+ The application package must declare both `react` and `react-dom` to satisfy the framework peer dependencies. The builder resolves `react-dom/client` through Bun from the application entrypoint, so strict or non-hoisted workspace layouts are supported. The browser dependency is bundled into the compiled binary; the build reports the application context and setup guidance before generating client output when `react-dom` cannot be resolved.
21
+
22
+ `getBunCompileTargetFromArgs` accepts at most one `--target=<value>` argument. Supported compile targets come from the `BUN_COMPILE_TARGETS` export from `@pablozaiden/webapp/build`; use that collection when selecting a target. Empty, malformed, duplicate, and unsupported values fail before the builder creates output directories or invokes Bun. Omit the option for the default local Bun binary.
23
+
20
24
  The binary builder compiles the browser bundle with the framework defaults needed for `webapp` apps, including Tailwind CSS v4 processing. If an app needs extra browser build behavior, add Bun plugins or browser-only defines under `web.build`:
21
25
 
22
26
  ```ts
@@ -49,24 +53,97 @@ Keep server and CLI modes in the same binary. App-specific commands and framewor
49
53
 
50
54
  ## Docker
51
55
 
52
- Examples include Dockerfiles that copy a Bun-compiled Linux binary into a runtime image. Build the binary for the container architecture first, then build the image:
56
+ The example Dockerfiles copy a Bun-compiled Linux binary into a runtime image. They intentionally require `APP_BINARY`; there is no architecture-specific default. Build the binary for the container architecture first, then pass both the Docker platform and matching binary path:
53
57
 
54
58
  ```bash
55
- bun run --cwd examples/notes-todo build --target=bun-linux-arm64
56
- docker build -f examples/notes-todo/Dockerfile -t webapp-notes-todo:local .
57
-
59
+ # Linux amd64 (Bun calls this target x64)
60
+ bun run --cwd examples/notes-todo build --target=bun-linux-x64
61
+ docker buildx build \
62
+ --platform linux/amd64 \
63
+ --build-arg APP_BINARY=examples/notes-todo/dist/notes-todo-linux-x64 \
64
+ --file examples/notes-todo/Dockerfile \
65
+ --tag webapp-notes-todo:local \
66
+ --load \
67
+ .
68
+
69
+ # Linux arm64
58
70
  bun run --cwd examples/kitchen-sink build --target=bun-linux-arm64
59
- docker build -f examples/kitchen-sink/Dockerfile -t webapp-kitchen-sink:local .
71
+ docker buildx build \
72
+ --platform linux/arm64 \
73
+ --build-arg APP_BINARY=examples/kitchen-sink/dist/kitchen-sink-linux-arm64 \
74
+ --file examples/kitchen-sink/Dockerfile \
75
+ --tag webapp-kitchen-sink:local \
76
+ --load \
77
+ .
60
78
  ```
61
79
 
62
- The example Dockerfiles default to `node:22-bookworm` as a readily available Linux runtime base because the app itself is already a self-contained Bun binary. Use `--target=bun-linux-x64` and pass `--build-arg APP_BINARY=examples/notes-todo/dist/notes-todo-linux-x64` when building an x64 container from a different host. The container should set:
80
+ Use the same mapping for either example: Docker `linux/amd64` requires the Bun `bun-linux-x64` build and `*-linux-x64` artifact, while Docker `linux/arm64` requires `bun-linux-arm64` and `*-linux-arm64`. To build an x64 image from an ARM64 host, select `--platform linux/amd64` and the x64 artifact explicitly; Docker Desktop or Buildx may need emulation enabled. Omitting `APP_BINARY`, selecting an unsupported target, or pairing the wrong artifact with the target fails during the image build.
81
+
82
+ The example Dockerfiles default to `node:22-bookworm` as a readily available Linux runtime base because the app itself is already a self-contained Bun binary. The container writes both framework and example application data to `/app/data`; mount a Docker volume there when data must persist beyond the container.
83
+
84
+ ### Example application data files
85
+
86
+ Each example uses the configured data directory for two separate persistence
87
+ boundaries:
88
+
89
+ | Application | Framework database | Application database |
90
+ | --- | --- | --- |
91
+ | Notes TODO | `webapp.sqlite` | `notes-todo.sqlite` |
92
+ | Kitchen Sink | `webapp.sqlite` | `kitchen-sink.sqlite` |
93
+
94
+ `webapp.sqlite` contains framework authentication and settings state. The
95
+ example database contains only that example's sections, notes, todos, or
96
+ projects; application-specific tables are not added to the framework store.
97
+ The example starts by creating or migrating its schema, and owner seed data is
98
+ transactional and idempotent across restarts.
99
+
100
+ Persist the complete `/app/data` directory, not just `webapp.sqlite`, when
101
+ deploying an example. A container filesystem, process restart, replacement
102
+ deployment, or reverse proxy does not provide durability by itself. Retain
103
+ and back up the volume mounted at `/app/data`; changing the data directory
104
+ intentionally starts a new application state.
105
+
106
+ The container should set:
63
107
 
64
108
  ```dockerfile
65
109
  ENV MY_APP_HOST=0.0.0.0
66
110
  ENV MY_APP_PORT=3000
67
- ENV MY_APP_DATA_DIR=/data
111
+ ENV MY_APP_DATA_DIR=/app/data
68
112
  ```
69
113
 
70
114
  The framework has no external frontend asset directory, so the compiled binary contains the server and Bun HTML import graph.
71
115
 
116
+ ## Reverse proxy and forwarded headers
117
+
118
+ When TLS or a public path is terminated by a reverse proxy, keep the
119
+ application port reachable only from that proxy and configure the trust policy
120
+ explicitly:
121
+
122
+ ```bash
123
+ MY_APP_TRUST_PROXY=true
124
+ MY_APP_TRUST_PROXY_HEADERS=proto,host,prefix
125
+ MY_APP_TRUST_PROXY_CHAIN=first
126
+ MY_APP_PUBLIC_BASE_URL=https://app.example.test
127
+ ```
128
+
129
+ The proxy must strip any client-provided `X-Forwarded-Proto`,
130
+ `X-Forwarded-Host`, and `X-Forwarded-Prefix` values before overwriting the
131
+ headers it is responsible for. Only enable the corresponding symbolic
132
+ headers in `MY_APP_TRUST_PROXY_HEADERS`. Sanitized single-value headers are
133
+ recommended; `MY_APP_TRUST_PROXY_CHAIN` exists only to make a comma-separated
134
+ chain deterministic. The framework does not infer trust from header presence,
135
+ does not parse the RFC `Forwarded` header, and does not provide a
136
+ proxy-address allowlist in this release.
137
+
138
+ Use `MY_APP_PUBLIC_BASE_URL` when the external origin must remain deterministic
139
+ regardless of request headers. A trusted prefix controls cookie paths and
140
+ framework-generated setup/device links. If trust is disabled, the framework
141
+ uses the direct request URL and host, which is the correct default for a
142
+ direct deployment.
143
+
144
+ Proxy configuration does not make application data persistent. Set
145
+ `MY_APP_DATA_DIR` to a durable directory and mount a persistent volume for
146
+ container deployments; the SQLite data must survive independently of the
147
+ reverse proxy or application process.
148
+
72
149
  For full application CI/CD templates, including a production Dockerfile, PR checks, GHCR publishing on `main`, binary releases and Docker release images, see `docs/github-actions.md`.
@@ -3,20 +3,23 @@
3
3
  Apps are one Bun application: backend routes, websocket, React UI and static assets are served by the same server. The framework does not support or require a standalone client dist directory.
4
4
 
5
5
  ```ts
6
+ import { z } from "zod";
6
7
  import { createWebAppServer, defineRoutes, jsonResponse, parseJson } from "@pablozaiden/webapp/server";
7
8
 
8
9
  const items: Array<{ id: string; userId: string; title: string }> = [];
10
+ const itemCreateSchema = z.object({ title: z.string() });
9
11
 
10
12
  const routes = defineRoutes({
11
13
  "/api/items": {
12
14
  auth: "user",
15
+ requestSchema: itemCreateSchema,
13
16
  description: "List or create items.",
14
17
  cliPath: "items",
15
18
  tags: ["items"],
16
19
  GET: (_req, ctx) => jsonResponse(ctx.filterOwned(items)),
17
20
  async POST(req, ctx) {
18
21
  const user = ctx.requireUser();
19
- const body = await parseJson<{ title: string }>(req);
22
+ const body = await parseJson(req, itemCreateSchema);
20
23
  const item = { id: crypto.randomUUID(), userId: user.id, title: body.title };
21
24
  items.push(item);
22
25
  ctx.userRealtime.publishEntityChanged("items", item.id);
@@ -80,6 +83,18 @@ my-app api items
80
83
  my-app notify --message "optional app-owned command"
81
84
  ```
82
85
 
86
+ For a non-interactive authenticated API command, pass the app's `envPrefix`
87
+ to `runApiCliCommand()` and provide the exact environment pair:
88
+
89
+ ```bash
90
+ export MY_APP_BASE_URL=https://app.example.test
91
+ export MY_APP_API_KEY='key-from-settings'
92
+ my-app api items
93
+ ```
94
+
95
+ The pair is used when no stored device credentials are available. A missing
96
+ or partial pair preserves anonymous CLI requests.
97
+
83
98
  See `docs/cli.md` for framework CLI helpers and generic API command support.
84
99
 
85
100
  Frontend entrypoints should use `renderWebApp` so Bun/browser hot reload reuses the existing React root instead of calling `createRoot()` twice. Import the framework CSS explicitly so Bun hot reload observes style changes:
@@ -107,7 +122,19 @@ renderWebApp(
107
122
  ```
108
123
 
109
124
  `renderWebApp` renders into `#root` by default and reuses the existing React root across hot reloads. Pass a custom element id or `Element` only when the app uses a different mount point.
110
- `WebAppRoot` owns the shell and `.wapp-main-content`; each route component should return a `Page` wrapper so standard content margins, mobile padding and scroll behavior stay consistent.
125
+ `WebAppRoot` owns the shell and `.wapp-main-content`; each route component should return a `Page` wrapper so standard content margins, mobile padding and scroll behavior stay consistent. `Page` uses the padded layout by default. For routes whose child content fills the available shell viewport and owns its own spacing or scrolling, use the framework full layout instead of overriding framework CSS:
126
+
127
+ ```tsx
128
+ function TerminalRoute() {
129
+ return (
130
+ <Page layout="full">
131
+ <Terminal />
132
+ </Page>
133
+ );
134
+ }
135
+ ```
136
+
137
+ The `full` layout removes the page gutters and provides a flex-sized, overflow-contained surface for viewport-sized content. Use it for terminals, editors, previews and similar surfaces; the child should provide any internal padding and scrolling it needs.
111
138
 
112
139
  Use the framework URL helpers for browser API calls, websocket URLs and app-local links instead of deriving paths from `window.location` in each app. They honor `<base>`, explicit `publicBasePath` config, and reverse-proxy subpaths for direct path deep links such as `/workspaces`:
113
140
 
@@ -115,6 +142,7 @@ Use the framework URL helpers for browser API calls, websocket URLs and app-loca
115
142
  import {
116
143
  appAbsoluteUrl,
117
144
  appFetch,
145
+ appJson,
118
146
  appPath,
119
147
  appRequest,
120
148
  appWebSocketUrl,
@@ -124,7 +152,7 @@ import {
124
152
 
125
153
  configureWebAppClient();
126
154
 
127
- const config = await appFetch("/api/config").then((res) => res.json());
155
+ const config = await appJson("/api/config");
128
156
  setWebAppPublicBasePath(config.publicBasePath);
129
157
 
130
158
  const downloadUrl = appPath("/api/items/export");
@@ -133,7 +161,7 @@ const socket = new WebSocket(appWebSocketUrl("/api/ws"));
133
161
  const rawResponse = await appRequest("/api/items");
134
162
  ```
135
163
 
136
- `appFetch` is the framework JSON API helper and throws `WebAppApiError` for non-OK responses. Use `appRequest` when the app needs a raw `Response`, such as downloads, custom error handling, or compatibility wrappers.
164
+ `appJson` is the framework helper for successful JSON responses and builds on `appFetch`. Both `appJson` and `appFetch` honor configured URLs, credentials, auth-required events, and `WebAppApiError` responses. Use `appFetch` when a successful response body does not need to be parsed, and use `appRequest` when the app needs a raw response without the framework's error normalization, such as downloads or custom error handling. `appJson` intentionally rejects successful empty or non-JSON responses instead of fabricating a default value.
137
165
 
138
166
  Recommended dev script:
139
167
 
@@ -151,11 +179,46 @@ The app configures an uppercase `envPrefix`; the framework reads only variables
151
179
  | --- | --- | --- |
152
180
  | `{PREFIX}_HOST` | `localhost` | Bind host |
153
181
  | `{PREFIX}_PORT` | `3000` | Bind port |
154
- | `{PREFIX}_DATA_DIR` | `./data` | SQLite persistence directory |
182
+ | `{PREFIX}_DATA_DIR` | `./data` | Durable SQLite persistence directory for framework auth and app data |
155
183
  | `{PREFIX}_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error`; locks settings log-level control when set |
156
184
  | `{PREFIX}_DISABLE_PASSKEY` | unset | Emergency bypass that logs in as the existing owner; it does not create users |
157
185
  | `{PREFIX}_DISABLE_SAME_ORIGIN_CHECK` | unset | Development/testing escape hatch |
158
- | `{PREFIX}_PUBLIC_BASE_URL` | request origin | External URL for device auth links |
186
+ | `{PREFIX}_TRUST_PROXY` | `false` | Explicitly trust the documented `X-Forwarded-*` headers; keep disabled for direct deployments |
187
+ | `{PREFIX}_TRUST_PROXY_HEADERS` | `proto,host,prefix` when enabled | Comma-separated subset of supported forwarded values: `proto`, `host`, `prefix` |
188
+ | `{PREFIX}_TRUST_PROXY_CHAIN` | `first` | Select the left-most (`first`) or right-most (`last`) non-empty value from comma-separated forwarded headers |
189
+ | `{PREFIX}_PUBLIC_BASE_URL` | request origin | Authoritative external origin for auth/device URLs; it overrides forwarded protocol and host values |
159
190
  | `{PREFIX}_AUTH_ISSUER` | `urn:{prefix}:webapp` | JWT issuer override |
160
191
 
192
+ The configured data directory contains the framework-owned
193
+ `webapp.sqlite`. The example applications keep their application entities in
194
+ separate files in that same directory: Notes TODO uses `notes-todo.sqlite`
195
+ for sections, notes and todos, while Kitchen Sink uses `kitchen-sink.sqlite`
196
+ for projects. Keeping these files separate prevents example-specific tables
197
+ from being added to the framework authentication store.
198
+
199
+ Example application databases create or migrate their schema during startup.
200
+ Owner seed data is written transactionally and is idempotent across repeated
201
+ starts. Point the prefixed `*_DATA_DIR` at storage that survives process
202
+ restarts and redeployments; deleting or changing the directory intentionally
203
+ starts a new application state.
204
+
205
+ For a reverse-proxy deployment that strips and overwrites the forwarded
206
+ headers, configure the trust policy explicitly:
207
+
208
+ ```bash
209
+ MY_APP_TRUST_PROXY=true
210
+ MY_APP_TRUST_PROXY_HEADERS=proto,host,prefix
211
+ MY_APP_TRUST_PROXY_CHAIN=first
212
+ MY_APP_PUBLIC_BASE_URL=https://app.example.test
213
+ ```
214
+
215
+ The default is fail-closed: forwarded headers are ignored unless
216
+ `TRUST_PROXY=true`. The framework recognizes only `X-Forwarded-Proto`,
217
+ `X-Forwarded-Host`, and `X-Forwarded-Prefix`; it does not infer trust from
218
+ their presence and does not parse the RFC `Forwarded` header. A trusted prefix
219
+ is used for cookie paths and externally generated links. The proxy must remove
220
+ client-supplied values before writing these headers, and direct untrusted
221
+ access to the application port must be blocked. This configuration does not
222
+ provide a proxy-address allowlist.
223
+
161
224
  For CI/CD setup in apps built with the framework, use `docs/github-actions.md`. It includes copy-paste GitHub Actions and a production Dockerfile template.
@@ -75,6 +75,39 @@ CMD ["/app/my-app", "serve"]
75
75
 
76
76
  For apps that need extra OS packages, add them to the runtime `apt-get install` list. Keep `ca-certificates`, `curl` and `tini`.
77
77
 
78
+ ### Prebuilt-binary Dockerfiles
79
+
80
+ The Dockerfile above builds the binary inside Docker, so it does not use `APP_BINARY`. The repository examples use a different pattern: they copy a prebuilt binary and require the caller to provide the artifact explicitly. Keep the platform and artifact mapping below in local commands and GitHub Actions:
81
+
82
+ | Docker platform | Bun build target | Required artifact suffix |
83
+ | --- | --- | --- |
84
+ | `linux/amd64` | `bun-linux-x64` | `linux-x64` |
85
+ | `linux/arm64` | `bun-linux-arm64` | `linux-arm64` |
86
+
87
+ For a prebuilt-binary Dockerfile, do not add an architecture-specific `APP_BINARY` default. Build and image commands should select the same platform and artifact, for example:
88
+
89
+ ```bash
90
+ bun run --cwd examples/notes-todo build --target=bun-linux-x64
91
+ docker buildx build \
92
+ --platform linux/amd64 \
93
+ --build-arg APP_BINARY=examples/notes-todo/dist/notes-todo-linux-x64 \
94
+ --file examples/notes-todo/Dockerfile \
95
+ --tag webapp-notes-todo:amd64 \
96
+ --load \
97
+ .
98
+
99
+ bun run --cwd examples/notes-todo build --target=bun-linux-arm64
100
+ docker buildx build \
101
+ --platform linux/arm64 \
102
+ --build-arg APP_BINARY=examples/notes-todo/dist/notes-todo-linux-arm64 \
103
+ --file examples/notes-todo/Dockerfile \
104
+ --tag webapp-notes-todo:arm64 \
105
+ --load \
106
+ .
107
+ ```
108
+
109
+ Validate `TARGETOS` and `TARGETARCH` in the Dockerfile before copying the artifact, so omitted, unsupported, or mismatched inputs fail during the build rather than at container startup. When building ARM64 images on an x64 GitHub runner, configure QEMU and Buildx before the build.
110
+
78
111
  ## Pull request workflow
79
112
 
80
113
  Place this at `.github/workflows/pr.yml`.
@@ -209,6 +242,8 @@ Place this at `.github/workflows/docker-main.yml`.
209
242
 
210
243
  It publishes `ghcr.io/<owner>/<repo>:main` after merges to `main`, updates `package.json` to a pre-release version based on the latest GitHub release, and runs a container health smoke test.
211
244
 
245
+ Keep the `platforms` value explicit. The sample uses `linux/amd64` because `load: true` loads one runnable image for the smoke test; do not omit the value and rely on the runner architecture. To publish a multi-platform `main` image, configure QEMU, use `platforms: linux/amd64,linux/arm64`, and smoke-test platform-specific loaded images before pushing the multi-platform manifest, because Docker cannot load a multi-platform result into the local image store as one runnable tag.
246
+
212
247
  ```yaml
213
248
  name: Docker Main
214
249
 
@@ -332,6 +367,8 @@ Place this at `.github/workflows/docker-release.yml`.
332
367
 
333
368
  It publishes versioned GHCR images when a GitHub release is published:
334
369
 
370
+ This release workflow is intentionally multi-platform. Keep both QEMU and Buildx setup steps and keep `platforms: linux/amd64,linux/arm64` explicit; the target platform, not the runner's architecture, determines the image binary.
371
+
335
372
  ```yaml
336
373
  name: Docker Release
337
374
 
@@ -415,6 +452,8 @@ await buildWebAppBinary({
415
452
  });
416
453
  ```
417
454
 
455
+ `getBunCompileTargetFromArgs` accepts one `--target=<value>` option and validates it against the framework's exported `BUN_COMPILE_TARGETS` list before the build starts. Omitted targets build for the local Bun runtime; malformed, duplicate, empty, or unsupported target options fail immediately.
456
+
418
457
  `buildWebAppBinary` includes the framework browser build defaults, including Tailwind CSS v4 processing. Apps that need additional browser-only transforms can pass Bun plugins or defines through `web.build.plugins` and `web.build.define`; app plugins run before the framework defaults.
419
458
 
420
459
  ## Adaptation checklist
package/docs/release.md CHANGED
@@ -14,10 +14,11 @@
14
14
 
15
15
  `Main Docker Smoke` runs after merges to `main`:
16
16
 
17
- 1. Build Linux x64 binaries for both examples.
18
- 2. Build Docker images from `examples/*/Dockerfile`.
19
- 3. Run both containers.
20
- 4. Smoke-test `/api/health` plus one app endpoint through the published container ports.
17
+ 1. Build Linux x64 and Linux arm64 binaries for both examples.
18
+ 2. Build each Docker image with an explicit `linux/amd64` or `linux/arm64` platform and matching `APP_BINARY` path.
19
+ 3. Validate missing, unsupported, and mismatched Docker arguments fail during the image build.
20
+ 4. Run both architecture variants of both containers.
21
+ 5. Smoke-test `/api/health` plus one app endpoint through the published container ports.
21
22
 
22
23
  ## NPM releases
23
24
 
@@ -61,4 +62,3 @@ For the first publish, npm may require a local manual publish before trusted pub
61
62
  - Workflow: `release-npm-package.yml`
62
63
  - Environment: leave empty unless an environment is added later
63
64
  7. After that, publish future versions by creating and publishing GitHub releases with tags like `v0.1.1`.
64
-
package/docs/server.md CHANGED
@@ -3,9 +3,15 @@
3
3
  Use `createWebAppServer` with `defineRoutes`. Route patterns support exact path segments and `:params`.
4
4
 
5
5
  ```ts
6
+ import { z } from "zod";
7
+
8
+ const projectCreateSchema = z.object({ name: z.string() });
9
+ const projectUpdateSchema = z.object({ name: z.string().optional() });
10
+
6
11
  const routes = defineRoutes<AppEvent>({
7
12
  "/api/projects": {
8
13
  auth: "user",
14
+ requestSchema: projectCreateSchema,
9
15
  description: "List or create projects.",
10
16
  cliPath: "projects",
11
17
  tags: ["projects"],
@@ -14,7 +20,7 @@ const routes = defineRoutes<AppEvent>({
14
20
  },
15
21
  async POST(req, ctx) {
16
22
  const user = ctx.requireUser();
17
- const body = await parseJson<{ name: string }>(req);
23
+ const body = await parseJson(req, projectCreateSchema);
18
24
  const project = createProject(user.id, body.name);
19
25
  ctx.userRealtime.publishEntityChanged("projects", project.id);
20
26
  return jsonResponse(project);
@@ -23,9 +29,12 @@ const routes = defineRoutes<AppEvent>({
23
29
  "/api/projects/:id": {
24
30
  auth: "user",
25
31
  scopes: ["projects:write"],
32
+ requestSchema: projectUpdateSchema,
26
33
  async PATCH(req, ctx) {
27
34
  const project = ctx.requireOwned(await findProject(ctx.params.id));
28
- Object.assign(project, await parseJson<Partial<Project>>(req));
35
+ const body = await parseJson(req, projectUpdateSchema);
36
+ if (body.name !== undefined) project.name = body.name;
37
+ project.updatedAt = new Date().toISOString();
29
38
  ctx.userRealtime.publishEntityChanged("projects", project.id);
30
39
  return jsonResponse(project);
31
40
  },
@@ -37,6 +46,27 @@ const routes = defineRoutes<AppEvent>({
37
46
  });
38
47
  ```
39
48
 
49
+ Request schemas define the writable input fields, but handlers should still assign
50
+ those fields explicitly rather than spreading or merging parsed input into a
51
+ stored record. The example schemas use Zod's default object behavior, so
52
+ unknown properties are ignored and never persisted; keep `id`, `userId`,
53
+ timestamps, and other server-managed fields under application control.
54
+
55
+ `parseJson(req, schema)` parses and validates the body at runtime. Malformed JSON
56
+ returns a 400 `invalid_json` response, while a JSON value that does not satisfy
57
+ the schema returns a 400 `invalid_request_body` response with field details.
58
+ Use `parseOptionalJson(req, schema)` only for endpoints that deliberately allow
59
+ an empty body. Only a zero-byte body is considered absent; whitespace-only
60
+ content is non-empty malformed JSON and is rejected just like any other
61
+ malformed body. `parseUnknownJson` returns `unknown` and is intentionally
62
+ unvalidated, so application handlers should prefer a schema-backed parser.
63
+
64
+ The Notes TODO webhook uses an absent body (or an object without `title`) to
65
+ apply its source-based fallback title. When an owner does not exist, that
66
+ accepted delivery returns 202 with `accepted: false`; malformed or
67
+ schema-invalid bodies are rejected before this ownerless response or any
68
+ mutation is reached.
69
+
40
70
  Route defaults are intentionally secure:
41
71
 
42
72
  | Setting | Default | Meaning |
@@ -55,7 +85,7 @@ Route definitions can include optional metadata. This keeps the API route table
55
85
  | `description` | Human-readable route description |
56
86
  | `cliPath` | CLI-friendly path; defaults to the API path without `/api/` |
57
87
  | `tags` | Grouping labels for docs/CLI |
58
- | `requestSchema`, `querySchema`, `responseSchema` | Optional schema objects for CLI/docs |
88
+ | `requestSchema`, `querySchema`, `responseSchema` | Optional schema objects for CLI/docs; use the same runtime `requestSchema` with `parseJson` when a route accepts a body |
59
89
  | `catalog: false` | Exclude a route from generated catalogs |
60
90
 
61
91
  Use `createRouteCatalog(routes)` and `findRouteCatalogEntry(catalog, input)` to power app CLI commands without maintaining a second route catalog.
@@ -96,7 +126,7 @@ Built-in endpoints include:
96
126
 
97
127
  ## Framework-owned web document and PWA metadata
98
128
 
99
- `createWebAppServer` owns the browser document. Apps do not provide `index.html`; the framework generates a Bun `HTMLBundle` internally so Bun hot reload and asset rewriting keep working. By default the frontend entrypoint is `./web/main.tsx` relative to the Bun entry file. The generated viewport keeps the app at `initial-scale=1` with `maximum-scale=1` and `user-scalable=no`; clients and mobile browsers that honor those viewport scaling tokens, including iPhone and iPad, cannot change the app scale with pinch-to-zoom while normal scrolling remains enabled. Clients that ignore the tokens are unaffected.
129
+ `createWebAppServer` owns the browser document. Apps do not provide `index.html`; the framework generates a Bun `HTMLBundle` internally so Bun hot reload and asset rewriting keep working. By default the frontend entrypoint is `./web/main.tsx` relative to the Bun entry file. The generated document also initializes the shared `data-wapp-mobile` state before client styles load so CSS and `WebAppRoot` use the same mobile breakpoint. The generated viewport keeps the app at `initial-scale=1` with `maximum-scale=1` and `user-scalable=no`; clients and mobile browsers that honor those viewport scaling tokens, including iPhone and iPad, cannot change the app scale with pinch-to-zoom while normal scrolling remains enabled. Clients that ignore the tokens are unaffected.
100
130
 
101
131
  Do not replace this behavior with global touch event handlers, `preventDefault()` calls, or `touch-action: none`: those approaches can disable scrolling and other touch interactions. Applications should keep the document framework-owned rather than adding an app-owned `index.html`.
102
132
 
package/docs/settings.md CHANGED
@@ -11,6 +11,11 @@ Settings is framework-owned so apps stay consistent. It includes:
11
11
  - Admin server kill
12
12
  - Version/about
13
13
 
14
+ The local-storage or system theme is applied immediately while the signed-in
15
+ user preference loads in the background. If that request fails, the current
16
+ theme remains active and Display Settings shows a non-blocking error with a
17
+ retry action; the rest of Settings remains usable.
18
+
14
19
  Security lists only show useful active credentials. Expired API keys are purged before listing, and revoked or expired device-auth refresh sessions are not shown. Revoked refresh sessions may remain in storage when needed for token-reuse protection, but they are hidden from Settings.
15
20
 
16
21
  Destructive actions in Settings use the framework `ConfirmDialog` before mutating. This includes deleting users, deleting API keys, deleting passkeys, revoking device-auth sessions, and killing the server.