@pablozaiden/webapp 0.6.0 → 0.6.2
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 +2 -2
- package/docs/auth.md +10 -2
- package/docs/getting-started.md +104 -1
- package/docs/release.md +1 -1
- package/docs/server.md +6 -0
- package/docs/settings.md +17 -0
- package/docs/ui-guidelines.md +34 -1
- package/package.json +4 -2
- package/src/server/create-web-app-server.ts +2 -2
- package/src/server/framework-endpoints.ts +6 -3
- package/src/server/index.ts +1 -0
- package/src/server/runtime-config.ts +44 -4
- package/src/web/WebAppRoot.tsx +40 -58
- package/src/web/index.ts +6 -0
- package/src/web/log-level.tsx +22 -0
- package/src/web/render.tsx +6 -1
- package/src/web/settings/account-section.tsx +60 -17
- package/src/web/settings/settings-view.tsx +3 -8
- package/src/web/styles.css +136 -0
- package/src/web/theme.tsx +147 -0
- package/src/web/toast.tsx +218 -0
- package/src/web/webapp-config.tsx +117 -0
package/README.md
CHANGED
|
@@ -22,8 +22,8 @@ Each application package must declare `react` and `react-dom`; they are peer dep
|
|
|
22
22
|
|
|
23
23
|
| Export | Use |
|
|
24
24
|
| --- | --- |
|
|
25
|
-
| `@pablozaiden/webapp/server` | `createWebAppServer`, route helpers, responses, SQLite store |
|
|
26
|
-
| `@pablozaiden/webapp/web` | `WebAppRoot`, `renderWebApp`, sidebar types, UI controls, realtime hooks |
|
|
25
|
+
| `@pablozaiden/webapp/server` | `createWebAppServer`, route helpers, responses, request-origin helpers, SQLite store |
|
|
26
|
+
| `@pablozaiden/webapp/web` | `WebAppRoot`, `renderWebApp`, `useToast`, sidebar types, UI controls, realtime hooks |
|
|
27
27
|
| `@pablozaiden/webapp/contracts` | Shared auth/config/device/API-key types |
|
|
28
28
|
| `@pablozaiden/webapp/cli` | One-binary command helpers, device/environment auth credentials and generic API CLI caller |
|
|
29
29
|
| `@pablozaiden/webapp/build` | Bun single-binary compile helper |
|
package/docs/auth.md
CHANGED
|
@@ -74,8 +74,9 @@ The same normalized request context drives expected same-origin origins,
|
|
|
74
74
|
WebSocket origin checks, passkey expected origins and RP IDs, the `Secure`
|
|
75
75
|
cookie attribute, cookie paths, setup links, and device/discovery URLs.
|
|
76
76
|
`{PREFIX}_PUBLIC_BASE_URL` is authoritative for origin, hostname, and secure
|
|
77
|
-
state even when forwarded headers are present
|
|
78
|
-
policy-controlled for
|
|
77
|
+
state even when forwarded headers are present. It must be an origin-only
|
|
78
|
+
absolute `http` or `https` URL; trusted prefixes remain policy-controlled for
|
|
79
|
+
path-bearing cookies and links.
|
|
79
80
|
|
|
80
81
|
Trust mode is a deployment boundary setting, not a way to trust arbitrary
|
|
81
82
|
client input. The reverse proxy must strip client-supplied forwarded headers
|
|
@@ -84,3 +85,10 @@ single-value headers. Do not expose the application directly to untrusted
|
|
|
84
85
|
clients when trust mode is enabled. This release does not implement a
|
|
85
86
|
proxy-address allowlist, so the network boundary and proxy sanitization are
|
|
86
87
|
required.
|
|
88
|
+
|
|
89
|
+
Applications that generate their own public URLs should use
|
|
90
|
+
`getRequestOriginInfo` or `getRequestBaseUrl` from
|
|
91
|
+
`@pablozaiden/webapp/server` with the server's runtime config instead of
|
|
92
|
+
parsing `{PREFIX}_PUBLIC_BASE_URL` independently. The framework validates the
|
|
93
|
+
configured public base URL during startup and applies the same trusted-proxy
|
|
94
|
+
policy used by its authentication and origin checks.
|
package/docs/getting-started.md
CHANGED
|
@@ -122,6 +122,109 @@ renderWebApp(
|
|
|
122
122
|
```
|
|
123
123
|
|
|
124
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.
|
|
125
|
+
|
|
126
|
+
### Theme state
|
|
127
|
+
|
|
128
|
+
The framework applies the local or system theme before the first React render and
|
|
129
|
+
loads the signed-in user's saved preference in the background. Components
|
|
130
|
+
rendered inside `WebAppRoot` can use the public hook when JavaScript needs the
|
|
131
|
+
effective theme, such as for a terminal, editor, or chart:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
import { Page, useTheme } from "@pablozaiden/webapp/web";
|
|
135
|
+
|
|
136
|
+
function ThemeAwareRoute() {
|
|
137
|
+
const { preference, resolvedTheme } = useTheme();
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<Page>
|
|
141
|
+
<p>Preference: {preference}</p>
|
|
142
|
+
<p>Resolved theme: {resolvedTheme}</p>
|
|
143
|
+
</Page>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`preference` is `system`, `light`, or `dark`; `resolvedTheme` is always
|
|
149
|
+
`light` or `dark` and follows operating-system changes while `preference` is
|
|
150
|
+
`system`. Prefer CSS dark-mode styling when JavaScript does not need the
|
|
151
|
+
resolved value. Do not inspect or mutate framework-owned root classes or
|
|
152
|
+
attributes to infer theme state.
|
|
153
|
+
|
|
154
|
+
### Client log-level state
|
|
155
|
+
|
|
156
|
+
The framework loads the effective client log level as part of the shared
|
|
157
|
+
`WebAppRoot` configuration request. Components rendered inside `WebAppRoot`
|
|
158
|
+
can subscribe with `useLogLevel()`; they should not fetch framework
|
|
159
|
+
configuration or `/api/preferences/log-level` in an app-local initializer:
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
import { useEffect } from "react";
|
|
163
|
+
import { useLogLevel } from "@pablozaiden/webapp/web";
|
|
164
|
+
import type { LogLevelName } from "@pablozaiden/webapp/contracts";
|
|
165
|
+
|
|
166
|
+
function LoggerAdapter({ setLevel }: { setLevel: (level: LogLevelName) => void }) {
|
|
167
|
+
const { level, fromEnv, loading, error, retry } = useLogLevel();
|
|
168
|
+
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
if (level !== undefined) {
|
|
171
|
+
setLevel(level);
|
|
172
|
+
}
|
|
173
|
+
}, [level, setLevel]);
|
|
174
|
+
|
|
175
|
+
if (error) {
|
|
176
|
+
return <button type="button" onClick={() => void retry()}>Retry log-level configuration</button>;
|
|
177
|
+
}
|
|
178
|
+
if (loading || level === undefined) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return <span aria-hidden="true">{fromEnv ? "Log level is environment-controlled." : null}</span>;
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
`level` and `fromEnv` are unavailable until valid framework configuration has
|
|
187
|
+
loaded. Do not substitute a default level when `error` is set. The framework
|
|
188
|
+
Settings control owns persistence and updates the shared state without a page
|
|
189
|
+
reload; `fromEnv` indicates that the server environment locks the value.
|
|
190
|
+
`LoggerAdapter` remains application-owned and can adapt the state to any
|
|
191
|
+
logging library.
|
|
192
|
+
|
|
193
|
+
### Transient notifications
|
|
194
|
+
|
|
195
|
+
The standard `renderWebApp` runtime provides the framework notification service
|
|
196
|
+
to the application tree. Use the public hook for short-lived action outcomes;
|
|
197
|
+
do not add an application-owned provider, queue, timer system, or notification
|
|
198
|
+
CSS:
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
import { useToast } from "@pablozaiden/webapp/web";
|
|
202
|
+
|
|
203
|
+
function SaveButton() {
|
|
204
|
+
const toast = useToast();
|
|
205
|
+
|
|
206
|
+
async function save() {
|
|
207
|
+
await saveRecord();
|
|
208
|
+
toast.success("Saved");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return <button type="button" onClick={() => void save()}>Save</button>;
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
`useToast()` exposes `success`, `error`, `warning`, and `info` helpers. Each
|
|
216
|
+
returns a stable ID that can be passed to `dismiss`; `dismissAll` removes every
|
|
217
|
+
active notification. Pass an `id` to replace an existing notification and
|
|
218
|
+
reset its timer. Notifications dismiss after 8 seconds by default; provide a
|
|
219
|
+
positive `duration` in milliseconds for a custom timeout or use
|
|
220
|
+
`duration: 0` for a persistent notification that requires explicit dismissal.
|
|
221
|
+
The framework keeps at most five active notifications.
|
|
222
|
+
|
|
223
|
+
Use `ErrorState`, loading states, and field validation for persistent page or
|
|
224
|
+
form state. Use `ConfirmDialog` for destructive confirmation. Do not report the
|
|
225
|
+
same failure in both an inline error state and a toast unless both surfaces
|
|
226
|
+
serve distinct, intentional purposes.
|
|
227
|
+
|
|
125
228
|
`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
229
|
|
|
127
230
|
```tsx
|
|
@@ -186,7 +289,7 @@ The app configures an uppercase `envPrefix`; the framework reads only variables
|
|
|
186
289
|
| `{PREFIX}_TRUST_PROXY` | `false` | Explicitly trust the documented `X-Forwarded-*` headers; keep disabled for direct deployments |
|
|
187
290
|
| `{PREFIX}_TRUST_PROXY_HEADERS` | `proto,host,prefix` when enabled | Comma-separated subset of supported forwarded values: `proto`, `host`, `prefix` |
|
|
188
291
|
| `{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
|
|
292
|
+
| `{PREFIX}_PUBLIC_BASE_URL` | request origin | Authoritative absolute `http` or `https` origin (without a path, query, or fragment) for auth/device URLs; it overrides forwarded protocol and host values |
|
|
190
293
|
| `{PREFIX}_AUTH_ISSUER` | `urn:{prefix}:webapp` | JWT issuer override |
|
|
191
294
|
|
|
192
295
|
The configured data directory contains the framework-owned
|
package/docs/release.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
1. Install dependencies with `bun install --frozen-lockfile`.
|
|
8
8
|
2. Run `bun run tsc`.
|
|
9
|
-
3. Run `bun test
|
|
9
|
+
3. Run `bun run test`, which runs the build-binary integration tests in an isolated process before the remaining suites.
|
|
10
10
|
4. Build both example apps.
|
|
11
11
|
5. Start the compiled example binaries and smoke-test `/api/health` plus one app endpoint.
|
|
12
12
|
|
package/docs/server.md
CHANGED
|
@@ -124,6 +124,12 @@ Built-in endpoints include:
|
|
|
124
124
|
| `/api/server/kill` | Authenticated server shutdown |
|
|
125
125
|
| `/api/ws` | Realtime websocket by default |
|
|
126
126
|
|
|
127
|
+
The effective log level in `GET /api/config` and `GET
|
|
128
|
+
/api/preferences/log-level` is resolved identically: an environment-provided
|
|
129
|
+
`{PREFIX}_LOG_LEVEL` wins over the persisted preference, and sets `fromEnv`
|
|
130
|
+
to `true`. PUT remains an authenticated, same-origin admin mutation and
|
|
131
|
+
returns a conflict when the environment controls the value.
|
|
132
|
+
|
|
127
133
|
## Framework-owned web document and PWA metadata
|
|
128
134
|
|
|
129
135
|
`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.
|
package/docs/settings.md
CHANGED
|
@@ -16,6 +16,23 @@ user preference loads in the background. If that request fails, the current
|
|
|
16
16
|
theme remains active and Display Settings shows a non-blocking error with a
|
|
17
17
|
retry action; the rest of Settings remains usable.
|
|
18
18
|
|
|
19
|
+
Application code rendered inside `WebAppRoot` can use `useTheme()` from
|
|
20
|
+
`@pablozaiden/webapp/web` when JavaScript needs theme state. The hook exposes
|
|
21
|
+
the selected `preference` and concrete `resolvedTheme`; the latter updates
|
|
22
|
+
when the operating-system color scheme changes in `system` mode. Prefer CSS
|
|
23
|
+
dark-mode styling when no JavaScript theme value is required, and do not infer
|
|
24
|
+
theme state by observing framework-owned DOM classes or attributes.
|
|
25
|
+
|
|
26
|
+
Application code can use `useLogLevel()` from `@pablozaiden/webapp/web` to
|
|
27
|
+
adapt an application-owned logger to the framework's effective client setting.
|
|
28
|
+
The hook exposes `level`, `fromEnv`, `loading`, `error`, and `retry`. The level
|
|
29
|
+
and environment metadata are unavailable until `/api/config` has loaded and
|
|
30
|
+
validated; configuration failures remain visible through `error` instead of
|
|
31
|
+
falling back to a fabricated level. The framework Settings selector updates
|
|
32
|
+
the shared hook state after a successful save, and `fromEnv` is true when
|
|
33
|
+
`{PREFIX}_LOG_LEVEL` locks the value. Applications should not add a separate
|
|
34
|
+
configuration fetch or initializer component.
|
|
35
|
+
|
|
19
36
|
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.
|
|
20
37
|
|
|
21
38
|
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.
|
package/docs/ui-guidelines.md
CHANGED
|
@@ -11,6 +11,20 @@ The framework intentionally provides a consistent base UI:
|
|
|
11
11
|
- Main content should prefer panels, toolbars, badges and simple forms over custom one-off layouts.
|
|
12
12
|
- `WebAppRoot` owns the fixed main title bar and `.wapp-main-content`; app routes should not render or style those shell elements directly.
|
|
13
13
|
|
|
14
|
+
## Transient notifications
|
|
15
|
+
|
|
16
|
+
Use `useToast()` from `@pablozaiden/webapp/web` for short-lived success,
|
|
17
|
+
error, warning, or informational feedback. The standard `renderWebApp` runtime
|
|
18
|
+
already owns the provider, queue, timers, live region, dismissal controls, and
|
|
19
|
+
styling, so applications must not add a parallel toast provider, queue, or
|
|
20
|
+
notification CSS. Use the returned ID with `dismiss` when an operation needs
|
|
21
|
+
to close a persistent notification, or use `dismissAll` to clear the queue.
|
|
22
|
+
|
|
23
|
+
Use inline `ErrorState`, loading states, and field validation when the
|
|
24
|
+
information belongs to persistent page or form state. Use `ConfirmDialog` for
|
|
25
|
+
destructive confirmation rather than a toast, and avoid reporting one failure
|
|
26
|
+
twice without a distinct user-facing reason.
|
|
27
|
+
|
|
14
28
|
## Mobile breakpoint and viewport synchronization
|
|
15
29
|
|
|
16
30
|
The framework owns the mobile shell breakpoint. `MOBILE_BREAKPOINT_PX` and `MOBILE_MEDIA_QUERY` are exported from `@pablozaiden/webapp/web` for application JavaScript that needs to coordinate with the shell; do not add an independent `innerWidth` threshold for shell behavior. The generated document initializes the `data-wapp-mobile` marker on the root element before the client and styles load, and `WebAppRoot` keeps it synchronized with media-query changes. Custom CSS that follows the framework mobile mode should use that marker rather than repeating a numeric media query.
|
|
@@ -64,7 +78,26 @@ Reference screenshots live in `artifacts/screenshots`:
|
|
|
64
78
|
| `kitchen-dialog-dark.png` | Confirm dialog overlay |
|
|
65
79
|
| `kitchen-device-light.png` | Device auth approval flow |
|
|
66
80
|
|
|
67
|
-
Use the temporary Playwright harness described in `skills/webapp/SKILL.md` when new visual captures are needed. Its default output is disposable; to intentionally update these checked-in reference captures, run the application-specific temporary harness with `PLAYWRIGHT_OUT_DIR="$PWD/artifacts/screenshots"` and review every changed image before committing it. Browser automation and screenshot capture must stay Playwright-based and cross-platform; do not hard-code local Chrome or OS-specific browser paths.
|
|
81
|
+
Use the temporary Playwright harness described in `skills/webapp/SKILL.md` when new visual captures are needed. Its default output is disposable; to intentionally update these checked-in reference captures, run the application-specific temporary harness with `PLAYWRIGHT_OUT_DIR="$PWD/artifacts/screenshots"` and review every changed image before committing it. Browser automation and screenshot capture must stay Playwright-based and cross-platform; do not hard-code local Chrome or OS-specific browser paths.
|
|
82
|
+
|
|
83
|
+
The framework does not add Playwright to an application's dependencies or ship a
|
|
84
|
+
screenshot command. Install the official Playwright package and Chromium in an
|
|
85
|
+
isolated temporary directory instead:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
playwright_workdir="$(mktemp -d)"
|
|
89
|
+
trap 'rm -rf "$playwright_workdir"' EXIT
|
|
90
|
+
cd "$playwright_workdir"
|
|
91
|
+
npm init -y >/dev/null
|
|
92
|
+
npm install --no-save --package-lock=false playwright
|
|
93
|
+
npx playwright install chromium
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Run the Playwright Node API from that temporary harness against the already
|
|
97
|
+
running application, and keep screenshots and browser state there. Do not add
|
|
98
|
+
Playwright, browser binaries, scripts, or configuration files to the
|
|
99
|
+
application repository. If Linux browser system libraries are missing, run
|
|
100
|
+
`npx playwright install-deps` from the same harness environment.
|
|
68
101
|
|
|
69
102
|
Use these captures as the manual visual baseline before changing shell, sidebar, settings or dialog styles. When screenshots are captured to validate a visual change, review them against the specific goal; capturing files without checking the result is not validation.
|
|
70
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pablozaiden/webapp",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "Opinionated Bun + React webapp framework for single-server apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
"build": "bun run tsc && bun run --cwd examples/kitchen-sink build && bun run --cwd examples/notes-todo build",
|
|
37
37
|
"build:kitchen-sink": "bun run --cwd examples/kitchen-sink build",
|
|
38
38
|
"build:notes-todo": "bun run --cwd examples/notes-todo build",
|
|
39
|
-
"test": "bun test",
|
|
39
|
+
"test": "bun run test:build-binary && bun run test:remaining",
|
|
40
|
+
"test:build-binary": "bun test tests/build-binary.test.ts",
|
|
41
|
+
"test:remaining": "bun test --path-ignore-patterns=**/build-binary.test.ts",
|
|
40
42
|
"tsc": "mkdir -p .cache/tsc && bunx tsc --noEmit --pretty false --incremental --tsBuildInfoFile .cache/tsc/build.tsbuildinfo"
|
|
41
43
|
},
|
|
42
44
|
"dependencies": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
2
|
import { sqliteWebAppStore } from "./auth/sqlite-store";
|
|
3
3
|
import { createRealtimeBus } from "./realtime/bus";
|
|
4
|
-
import { readRuntimeConfig } from "./runtime-config";
|
|
4
|
+
import { readRuntimeConfig, resolveEffectiveLogLevel } from "./runtime-config";
|
|
5
5
|
import {
|
|
6
6
|
WEBAPP_SOCKET_HANDLER,
|
|
7
7
|
type WebAppServer,
|
|
@@ -45,7 +45,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
|
|
|
45
45
|
const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
|
|
46
46
|
store.initialize();
|
|
47
47
|
const savedLogLevel = store.getLogLevelPreference();
|
|
48
|
-
const activeLogLevel = config
|
|
48
|
+
const activeLogLevel = resolveEffectiveLogLevel(config, savedLogLevel);
|
|
49
49
|
setLogLevel(activeLogLevel);
|
|
50
50
|
input.logLevel?.onChange?.(activeLogLevel);
|
|
51
51
|
const realtime = createRealtimeBus<TEvent>();
|
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
tokenRequestSchema,
|
|
60
60
|
userRoleRequestSchema,
|
|
61
61
|
} from "./request-schemas";
|
|
62
|
-
import type
|
|
62
|
+
import { resolveEffectiveLogLevel, type RuntimeConfig } from "./runtime-config";
|
|
63
63
|
import { setLogLevel } from "./logger";
|
|
64
64
|
import { errorResponse, jsonResponse, notFound, parseJson, successResponse } from "./responses";
|
|
65
65
|
import type { WebSocketData } from "./realtime/bus";
|
|
@@ -112,7 +112,7 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
|
|
|
112
112
|
canManageUsers: Boolean(user?.isAdmin),
|
|
113
113
|
},
|
|
114
114
|
logLevel: {
|
|
115
|
-
level: (config
|
|
115
|
+
level: resolveEffectiveLogLevel(config, store.getLogLevelPreference()),
|
|
116
116
|
fromEnv: config.logLevelFromEnv,
|
|
117
117
|
},
|
|
118
118
|
apiKeys: { enabled: Boolean(apiKeysEnabled) },
|
|
@@ -416,7 +416,10 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
|
|
|
416
416
|
if (auth instanceof Response) return auth;
|
|
417
417
|
ensureAdmin(auth);
|
|
418
418
|
if (req.method === "GET") {
|
|
419
|
-
return jsonResponse({
|
|
419
|
+
return jsonResponse({
|
|
420
|
+
level: resolveEffectiveLogLevel(config, store.getLogLevelPreference()),
|
|
421
|
+
fromEnv: config.logLevelFromEnv,
|
|
422
|
+
});
|
|
420
423
|
}
|
|
421
424
|
if (req.method === "PUT") {
|
|
422
425
|
const originFailure = checkSameOrigin(req, config, auth, "mutations");
|
package/src/server/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export * from "./routes";
|
|
|
3
3
|
export * from "./route-catalog";
|
|
4
4
|
export * from "./responses";
|
|
5
5
|
export * from "./create-web-app-server";
|
|
6
|
+
export { getRequestBaseUrl, getRequestOriginInfo, type RequestOriginInfo } from "./auth/request-origin";
|
|
6
7
|
export * from "./auth/store";
|
|
7
8
|
export * from "./auth/sqlite-store";
|
|
8
9
|
export * from "./realtime/bus";
|
|
@@ -26,7 +26,22 @@ export interface RuntimeConfig {
|
|
|
26
26
|
development: false | { hmr: true; console: true };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
const LOG_LEVELS = new Set(["trace", "debug", "info", "warn", "error"]);
|
|
29
|
+
const LOG_LEVELS = new Set<LogLevelName>(["trace", "debug", "info", "warn", "error"]);
|
|
30
|
+
|
|
31
|
+
function isSupportedLogLevel(value: unknown): value is LogLevelName {
|
|
32
|
+
return typeof value === "string" && LOG_LEVELS.has(value as LogLevelName);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveEffectiveLogLevel(
|
|
36
|
+
config: Pick<RuntimeConfig, "logLevel" | "logLevelFromEnv">,
|
|
37
|
+
savedLogLevel?: unknown,
|
|
38
|
+
): LogLevelName {
|
|
39
|
+
return config.logLevelFromEnv
|
|
40
|
+
? config.logLevel
|
|
41
|
+
: isSupportedLogLevel(savedLogLevel)
|
|
42
|
+
? savedLogLevel
|
|
43
|
+
: config.logLevel;
|
|
44
|
+
}
|
|
30
45
|
|
|
31
46
|
export function isTruthyEnv(value: string | undefined): boolean {
|
|
32
47
|
const normalized = value?.trim().toLowerCase();
|
|
@@ -64,10 +79,10 @@ function parseLogLevel(raw: string | undefined, fallback: LogLevelName, name: st
|
|
|
64
79
|
if (!raw) {
|
|
65
80
|
return fallback;
|
|
66
81
|
}
|
|
67
|
-
if (!
|
|
82
|
+
if (!isSupportedLogLevel(raw)) {
|
|
68
83
|
throw new Error(`${name} must be one of trace, debug, info, warn, error; received "${raw}"`);
|
|
69
84
|
}
|
|
70
|
-
return raw
|
|
85
|
+
return raw;
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
function parseBoolean(raw: string | undefined, fallback: boolean, name: string): boolean {
|
|
@@ -84,6 +99,30 @@ function parseBoolean(raw: string | undefined, fallback: boolean, name: string):
|
|
|
84
99
|
throw new Error(`${name} must be true or false; received "${raw}"`);
|
|
85
100
|
}
|
|
86
101
|
|
|
102
|
+
function parsePublicBaseUrl(raw: string | undefined, name: string): string | undefined {
|
|
103
|
+
if (!raw) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
let parsed: URL;
|
|
107
|
+
try {
|
|
108
|
+
parsed = new URL(raw);
|
|
109
|
+
} catch {
|
|
110
|
+
throw new Error(`${name} must be a valid absolute http(s) origin; received "${raw}"`);
|
|
111
|
+
}
|
|
112
|
+
if (
|
|
113
|
+
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
114
|
+
parsed.username ||
|
|
115
|
+
parsed.password ||
|
|
116
|
+
parsed.origin === "null" ||
|
|
117
|
+
parsed.pathname !== "/" ||
|
|
118
|
+
parsed.search ||
|
|
119
|
+
parsed.hash
|
|
120
|
+
) {
|
|
121
|
+
throw new Error(`${name} must be a valid absolute http(s) origin; received "${raw}"`);
|
|
122
|
+
}
|
|
123
|
+
return parsed.origin;
|
|
124
|
+
}
|
|
125
|
+
|
|
87
126
|
function parseTrustProxyHeaders(raw: string | undefined, enabled: boolean, name: string): TrustProxyHeader[] {
|
|
88
127
|
if (raw === undefined) {
|
|
89
128
|
return enabled ? [...TRUST_PROXY_HEADERS] : [];
|
|
@@ -122,6 +161,7 @@ export function readRuntimeConfig(input: {
|
|
|
122
161
|
const envPrefix = assertEnvPrefix(input.envPrefix);
|
|
123
162
|
const logLevelRaw = readEnv(envPrefix, "LOG_LEVEL");
|
|
124
163
|
const logLevel = parseLogLevel(logLevelRaw, input.defaultLogLevel ?? "info", envName(envPrefix, "LOG_LEVEL"));
|
|
164
|
+
const publicBaseUrl = parsePublicBaseUrl(readEnv(envPrefix, "PUBLIC_BASE_URL"), envName(envPrefix, "PUBLIC_BASE_URL"));
|
|
125
165
|
const trustProxyEnabled = parseBoolean(readEnv(envPrefix, "TRUST_PROXY"), false, envName(envPrefix, "TRUST_PROXY"));
|
|
126
166
|
const trustProxy = {
|
|
127
167
|
enabled: trustProxyEnabled,
|
|
@@ -138,7 +178,7 @@ export function readRuntimeConfig(input: {
|
|
|
138
178
|
logLevelFromEnv: Boolean(logLevelRaw),
|
|
139
179
|
passkeyDisabled: isTruthyEnv(readEnv(envPrefix, "DISABLE_PASSKEY")),
|
|
140
180
|
sameOriginDisabled: isTruthyEnv(readEnv(envPrefix, "DISABLE_SAME_ORIGIN_CHECK")),
|
|
141
|
-
publicBaseUrl
|
|
181
|
+
publicBaseUrl,
|
|
142
182
|
authIssuer: readEnv(envPrefix, "AUTH_ISSUER"),
|
|
143
183
|
trustProxy,
|
|
144
184
|
development: process.env["NODE_ENV"] === "production" ? false : { hmr: true, console: true },
|
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
-
import type {
|
|
3
|
-
import { appJson } from "./api-client";
|
|
2
|
+
import type { WebAppConfigResponse } from "../contracts";
|
|
4
3
|
import { AppShell } from "./app-shell";
|
|
5
4
|
import { DeviceVerificationScreen, PasskeyAuthScreen, UserSetupScreen } from "./auth-screens";
|
|
6
5
|
import { EmptyState, Panel } from "./components";
|
|
@@ -10,35 +9,12 @@ import { flattenSidebarItems, toStoredPin, useSidebarCollapsedState, useSidebarP
|
|
|
10
9
|
import { SettingsView } from "./settings/settings-view";
|
|
11
10
|
import type { HeaderContext, WebAppRootProps } from "./root-types";
|
|
12
11
|
import type { ActionMenuItem, SidebarNode, WebAppRoute } from "./sidebar/types";
|
|
12
|
+
import { ThemeProvider } from "./theme";
|
|
13
|
+
import { WebAppConfigProvider, useWebAppConfig } from "./webapp-config";
|
|
13
14
|
|
|
14
15
|
export { replaceHashRoute, replaceWebAppRoute, routeToHash } from "./routing";
|
|
15
16
|
export type { HeaderContext, SettingsAction, SettingsRow, SettingsSection, WebAppRootProps } from "./root-types";
|
|
16
17
|
|
|
17
|
-
function useConfig() {
|
|
18
|
-
const [config, setConfig] = useState<WebAppConfigResponse>();
|
|
19
|
-
const [error, setError] = useState<string>();
|
|
20
|
-
const refresh = useCallback(async () => {
|
|
21
|
-
try {
|
|
22
|
-
setConfig(await appJson<WebAppConfigResponse>("/api/config"));
|
|
23
|
-
setError(undefined);
|
|
24
|
-
} catch (err) {
|
|
25
|
-
setError(err instanceof Error ? err.message : String(err));
|
|
26
|
-
}
|
|
27
|
-
}, []);
|
|
28
|
-
useEffect(() => void refresh(), [refresh]);
|
|
29
|
-
return { config, error, refresh };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function useTheme() {
|
|
33
|
-
const [theme, setTheme] = useState<ThemePreference>(() => (localStorage.getItem("webapp.theme") as ThemePreference | null) ?? "system");
|
|
34
|
-
useEffect(() => {
|
|
35
|
-
const dark = theme === "dark" || (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
|
36
|
-
document.documentElement.classList.toggle("dark", dark);
|
|
37
|
-
localStorage.setItem("webapp.theme", theme);
|
|
38
|
-
}, [theme]);
|
|
39
|
-
return { theme, setTheme };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
18
|
function routeMatches(left: WebAppRoute | undefined, right: WebAppRoute): boolean {
|
|
43
19
|
if (!left) {
|
|
44
20
|
return false;
|
|
@@ -46,14 +22,26 @@ function routeMatches(left: WebAppRoute | undefined, right: WebAppRoute): boolea
|
|
|
46
22
|
return left.view === right.view && Object.entries(left).every(([key, value]) => key === "view" || right[key] === value);
|
|
47
23
|
}
|
|
48
24
|
|
|
49
|
-
|
|
25
|
+
function WebAppRootContent({
|
|
26
|
+
appName,
|
|
27
|
+
homeRoute,
|
|
28
|
+
sidebar,
|
|
29
|
+
routes,
|
|
30
|
+
header,
|
|
31
|
+
onRouteChange,
|
|
32
|
+
settings,
|
|
33
|
+
version,
|
|
34
|
+
config,
|
|
35
|
+
error,
|
|
36
|
+
refresh,
|
|
37
|
+
}: WebAppRootProps & {
|
|
38
|
+
config?: WebAppConfigResponse;
|
|
39
|
+
error?: Error;
|
|
40
|
+
refresh: () => Promise<void>;
|
|
41
|
+
}) {
|
|
50
42
|
const isMobile = useMobileBreakpoint();
|
|
51
43
|
useMobileViewportHeight(isMobile);
|
|
52
|
-
const { config, error, refresh } = useConfig();
|
|
53
44
|
const { route, navigate } = useRoute(homeRoute);
|
|
54
|
-
const { theme, setTheme } = useTheme();
|
|
55
|
-
const [themeLoading, setThemeLoading] = useState(false);
|
|
56
|
-
const [themeLoadError, setThemeLoadError] = useState<Error>();
|
|
57
45
|
const [search, setSearch] = useState("");
|
|
58
46
|
const sidebarSearchId = useId();
|
|
59
47
|
const sidebarSearchInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -125,35 +113,12 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
125
113
|
}, [augmentPinningActions, baseNodes, currentPins, filteredNodes, pinningEnabled, sidebar.pinning, sidebarSearchActive]);
|
|
126
114
|
const activeActionNodes = useMemo(() => augmentPinningActions(baseNodes), [augmentPinningActions, baseNodes]);
|
|
127
115
|
|
|
128
|
-
const retryThemeLoad = useCallback(async () => {
|
|
129
|
-
if (!config?.currentUser) {
|
|
130
|
-
setThemeLoading(false);
|
|
131
|
-
setThemeLoadError(undefined);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
setThemeLoading(true);
|
|
136
|
-
setThemeLoadError(undefined);
|
|
137
|
-
try {
|
|
138
|
-
const result = await appJson<{ theme: ThemePreference }>("/api/preferences/theme");
|
|
139
|
-
setTheme(result.theme);
|
|
140
|
-
} catch (err) {
|
|
141
|
-
setThemeLoadError(err instanceof Error ? err : new Error(String(err)));
|
|
142
|
-
} finally {
|
|
143
|
-
setThemeLoading(false);
|
|
144
|
-
}
|
|
145
|
-
}, [config?.currentUser?.id, setTheme]);
|
|
146
|
-
|
|
147
|
-
useEffect(() => {
|
|
148
|
-
void retryThemeLoad();
|
|
149
|
-
}, [retryThemeLoad]);
|
|
150
|
-
|
|
151
116
|
useEffect(() => {
|
|
152
117
|
onRouteChange?.(route);
|
|
153
118
|
}, [onRouteChange, route]);
|
|
154
119
|
|
|
155
|
-
if (error) {
|
|
156
|
-
return <main className="wapp-auth-screen"><Panel title="Unable to load app" description={error} /></main>;
|
|
120
|
+
if (error && !config) {
|
|
121
|
+
return <main className="wapp-auth-screen"><Panel title="Unable to load app" description={error.message} /></main>;
|
|
157
122
|
}
|
|
158
123
|
if (!config) {
|
|
159
124
|
return <main className="wapp-auth-screen">Loading...</main>;
|
|
@@ -171,7 +136,7 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
171
136
|
const effectiveVersion = version ?? config.version;
|
|
172
137
|
let view: ReactNode;
|
|
173
138
|
if (route.view === "settings") {
|
|
174
|
-
view = <SettingsView config={config} refresh={refresh} customSections={settings?.sections ?? []}
|
|
139
|
+
view = <SettingsView config={config} refresh={refresh} customSections={settings?.sections ?? []} />;
|
|
175
140
|
} else {
|
|
176
141
|
const registeredView = routes[route.view];
|
|
177
142
|
view = typeof registeredView === "function"
|
|
@@ -220,3 +185,20 @@ export function WebAppRoot({ appName, homeRoute, sidebar, routes, header, onRout
|
|
|
220
185
|
/>
|
|
221
186
|
);
|
|
222
187
|
}
|
|
188
|
+
|
|
189
|
+
function WebAppRootWithConfig(props: WebAppRootProps) {
|
|
190
|
+
const { config, error, refresh } = useWebAppConfig();
|
|
191
|
+
return (
|
|
192
|
+
<ThemeProvider userId={config?.currentUser?.id}>
|
|
193
|
+
<WebAppRootContent {...props} config={config} error={error} refresh={refresh} />
|
|
194
|
+
</ThemeProvider>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function WebAppRoot(props: WebAppRootProps) {
|
|
199
|
+
return (
|
|
200
|
+
<WebAppConfigProvider>
|
|
201
|
+
<WebAppRootWithConfig {...props} />
|
|
202
|
+
</WebAppConfigProvider>
|
|
203
|
+
);
|
|
204
|
+
}
|
package/src/web/index.ts
CHANGED
|
@@ -4,6 +4,12 @@ export * from "./render";
|
|
|
4
4
|
export * from "./api-client";
|
|
5
5
|
export * from "./sidebar/types";
|
|
6
6
|
export * from "./realtime/useRealtime";
|
|
7
|
+
export { useToast } from "./toast";
|
|
8
|
+
export type { Toast, ToastId, ToastOptions, ToastService, ToastShowOptions, ToastVariant } from "./toast";
|
|
7
9
|
export { MOBILE_BREAKPOINT_PX, MOBILE_MEDIA_QUERY, MOBILE_STATE_ATTRIBUTE } from "./mobile";
|
|
10
|
+
export { useTheme } from "./theme";
|
|
11
|
+
export type { ResolvedTheme, WebAppThemeState } from "./theme";
|
|
12
|
+
export { useLogLevel } from "./log-level";
|
|
13
|
+
export type { WebAppLogLevelState } from "./log-level";
|
|
8
14
|
export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
9
15
|
export type { ThemePreference } from "../contracts";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { LogLevelName } from "../contracts";
|
|
3
|
+
import { useWebAppConfig } from "./webapp-config";
|
|
4
|
+
|
|
5
|
+
export interface WebAppLogLevelState {
|
|
6
|
+
level?: LogLevelName;
|
|
7
|
+
fromEnv?: boolean;
|
|
8
|
+
loading: boolean;
|
|
9
|
+
error?: Error;
|
|
10
|
+
retry: () => Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useLogLevel(): WebAppLogLevelState {
|
|
14
|
+
const { config, loading, error, refresh } = useWebAppConfig("useLogLevel");
|
|
15
|
+
return useMemo(() => ({
|
|
16
|
+
level: config?.logLevel.level,
|
|
17
|
+
fromEnv: config?.logLevel.fromEnv,
|
|
18
|
+
loading,
|
|
19
|
+
error,
|
|
20
|
+
retry: refresh,
|
|
21
|
+
}), [config?.logLevel.fromEnv, config?.logLevel.level, error, loading, refresh]);
|
|
22
|
+
}
|
package/src/web/render.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import type { Root } from "react-dom/client";
|
|
3
|
+
import { ToastProvider } from "./toast";
|
|
3
4
|
|
|
4
5
|
declare global {
|
|
5
6
|
var __pablozaidenWebAppRoots: WeakMap<Element, Root> | undefined;
|
|
@@ -11,6 +12,10 @@ function roots(): WeakMap<Element, Root> {
|
|
|
11
12
|
return globalThis.__pablozaidenWebAppRoots;
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
function WebAppRuntime({ element }: { element: ReactNode }) {
|
|
16
|
+
return <ToastProvider>{element}</ToastProvider>;
|
|
17
|
+
}
|
|
18
|
+
|
|
14
19
|
export function configureWebAppRenderer(createRoot: (target: Element) => Root): void {
|
|
15
20
|
globalThis.__pablozaidenCreateWebAppRoot = createRoot;
|
|
16
21
|
}
|
|
@@ -30,6 +35,6 @@ export function renderWebApp(element: ReactNode, container: Element | string = "
|
|
|
30
35
|
root = createRoot(target);
|
|
31
36
|
registry.set(target, root);
|
|
32
37
|
}
|
|
33
|
-
root.render(element);
|
|
38
|
+
root.render(<WebAppRuntime element={element} />);
|
|
34
39
|
return root;
|
|
35
40
|
}
|