@pablozaiden/webapp 1.1.1 → 1.3.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.
package/README.md CHANGED
@@ -28,6 +28,25 @@ Each application package must declare `react` and `react-dom`; they are peer dep
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 |
30
30
 
31
+ ## Motion primitives
32
+
33
+ The web export includes `Presence`, `Collapsible`, `AsyncState`, `AnimatedList`,
34
+ `Tabs`, `TabPanels`, and `TabPanel`. Use stable React keys with `AnimatedList`;
35
+ it preserves removed keyed children for the exit duration and marks them
36
+ inaccessible while they leave. The primitives honor
37
+ `prefers-reduced-motion` and skip visual movement when it is enabled.
38
+
39
+ ```tsx
40
+ <AnimatedList>
41
+ {records.map((record) => (
42
+ <DataListRow key={record.id} title={record.title} />
43
+ ))}
44
+ </AnimatedList>
45
+ ```
46
+
47
+ Use `layout="contents"` when animated children should participate directly in
48
+ the surrounding layout rather than through the list wrapper.
49
+
31
50
  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
51
 
33
52
  CLI API callers can use a stored device session or the stateless
@@ -4,10 +4,11 @@ Apps are one Bun application: backend routes, websocket, React UI and static ass
4
4
 
5
5
  ```ts
6
6
  import { z } from "zod";
7
- import { createWebAppServer, defineRoutes, jsonResponse, parseJson } from "@pablozaiden/webapp/server";
7
+ import { createLogger, createWebAppServer, defineRoutes, jsonResponse, parseJson } from "@pablozaiden/webapp/server";
8
8
 
9
9
  const items: Array<{ id: string; userId: string; title: string }> = [];
10
10
  const itemCreateSchema = z.object({ title: z.string() });
11
+ const log = createLogger("items");
11
12
 
12
13
  const routes = defineRoutes({
13
14
  "/api/items": {
@@ -16,7 +17,11 @@ const routes = defineRoutes({
16
17
  description: "List or create items.",
17
18
  cliPath: "items",
18
19
  tags: ["items"],
19
- GET: (_req, ctx) => jsonResponse(ctx.filterOwned(items)),
20
+ GET: (_req, ctx) => {
21
+ const ownedItems = ctx.filterOwned(items);
22
+ log.info("Listed items", { count: ownedItems.length });
23
+ return jsonResponse(ownedItems);
24
+ },
20
25
  async POST(req, ctx) {
21
26
  const user = ctx.requireUser();
22
27
  const body = await parseJson(req, itemCreateSchema);
@@ -151,49 +156,36 @@ function ThemeAwareRoute() {
151
156
  resolved value. Do not inspect or mutate framework-owned root classes or
152
157
  attributes to infer theme state.
153
158
 
154
- ### Client log-level state
159
+ ### Client logging and log-level state
155
160
 
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:
161
+ Use the shared browser `tslog` service from `@pablozaiden/webapp/web`; do not
162
+ create an application-owned logger or write application logs directly with
163
+ `console.*`:
160
164
 
161
165
  ```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
- }
166
+ import { createLogger } from "@pablozaiden/webapp/web";
181
167
 
182
- return <span aria-hidden="true">{fromEnv ? "Log level is environment-controlled." : null}</span>;
183
- }
168
+ const projectsLog = createLogger("projects");
169
+ projectsLog.debug("Loaded projects", { count: 12 });
184
170
  ```
185
171
 
172
+ `WebAppRoot` synchronizes the client logger with the effective level from its
173
+ shared configuration request. The seven levels are `silly`, `trace`, `debug`,
174
+ `info`, `warn`, `error`, and `fatal`; the setting is shared with server
175
+ logging. Use `useLogLevel()` only when UI code needs to display the current
176
+ level or its environment-controlled state. Applications should not fetch
177
+ framework configuration or `/api/preferences/log-level` to configure logging.
178
+
186
179
  `level` and `fromEnv` are unavailable until valid framework configuration has
187
180
  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.
181
+ Settings control owns persistence and updates the shared client and server
182
+ logger levels without a page reload; `fromEnv` indicates that the server
183
+ environment locks the value.
192
184
 
193
185
  ### In-memory server logs
194
186
 
195
187
  Admins can enable temporary server-log capture from Developer Settings next to
196
- the log-level selector. The framework exposes the current state in
188
+ the log-level selector. The shared WebApp server logger exposes the current state in
197
189
  `/api/config`; it starts disabled on every process start and is never stored in
198
190
  the database. Admin clients can retrieve a bounded chronological snapshot from
199
191
  `GET /api/server/logs` after enabling capture. The buffer is limited to the
@@ -293,7 +285,8 @@ The app configures an uppercase `envPrefix`; the framework reads only variables
293
285
  | `{PREFIX}_HOST` | `localhost` | Bind host |
294
286
  | `{PREFIX}_PORT` | `3000` | Bind port |
295
287
  | `{PREFIX}_DATA_DIR` | `./data` | Durable SQLite persistence directory for framework auth and app data |
296
- | `{PREFIX}_LOG_LEVEL` | `info` | `trace`, `debug`, `info`, `warn`, `error`; locks settings log-level control when set |
288
+ | `{PREFIX}_LOG_LEVEL` | `info` | `silly`, `trace`, `debug`, `info`, `warn`, `error`, `fatal`; locks settings log-level control when set |
289
+ | `{PREFIX}_IN_MEMORY_LOGS` | `false` | Initial value for process-local server-log capture; `true`, `1`, or `yes` enables it |
297
290
  | `{PREFIX}_DISABLE_PASSKEY` | unset | Emergency bypass that logs in as the existing owner; it does not create users |
298
291
  | `{PREFIX}_DISABLE_SAME_ORIGIN_CHECK` | unset | Development/testing escape hatch |
299
292
  | `{PREFIX}_TRUST_PROXY` | `false` | Explicitly trust the documented `X-Forwarded-*` headers; keep disabled for direct deployments |
package/docs/server.md CHANGED
@@ -138,6 +138,36 @@ the constructor inputs. Do not mutate `app.config` after construction to
138
138
  replace constructor options; omit `runtimeConfig` when the framework should read
139
139
  the environment itself.
140
140
 
141
+ ## Server logging
142
+
143
+ WebApp exposes one server-side `tslog` service for the framework and
144
+ application code. Use the root logger or a cached sub-logger from
145
+ `@pablozaiden/webapp/server`; do not add a second server logger in the app:
146
+
147
+ ```ts
148
+ import { createLogger, log } from "@pablozaiden/webapp/server";
149
+
150
+ const projectsLog = createLogger("projects");
151
+
152
+ log.info("Application initialized");
153
+ projectsLog.debug("Loaded projects", { count: 12 });
154
+ ```
155
+
156
+ The available levels, from most to least verbose, are `silly`, `trace`,
157
+ `debug`, `info`, `warn`, `error`, and `fatal`. The effective level is shared by
158
+ the root logger and all cached sub-loggers. It is selected from
159
+ `{PREFIX}_LOG_LEVEL`, a persisted administrator preference, or `info` by
160
+ default, in that order.
161
+
162
+ Every emitted entry is written through `tslog` to the appropriate stdout or
163
+ stderr stream. When in-memory capture is enabled, the same rendered entries
164
+ from both framework and application loggers are also retained in the
165
+ process-local buffer. Capture is disabled by default and can be initialized
166
+ with `{PREFIX}_IN_MEMORY_LOGS=true`; an administrator can then change the
167
+ runtime toggle from Developer Settings. The environment value is an initial
168
+ value, not a lock, and capture is cleared when disabled or when the process
169
+ starts.
170
+
141
171
  Built-in endpoints include:
142
172
 
143
173
  | Endpoint | Purpose |
@@ -167,15 +197,36 @@ returns a conflict when the environment controls the value.
167
197
  `GET /api/server/logs` is restricted to admin users and returns
168
198
  `{ enabled: boolean, logs: ServerLogEntry[] }`. Entries are returned in
169
199
  chronological order and contain the timestamp, level, logger scope, message,
170
- and rendered log line. When capture is disabled, the endpoint returns `200`
171
- with `enabled: false` and an empty list. `PUT /api/server/logs/settings`
200
+ and the rendered `tslog` line. When capture is disabled, the endpoint returns
201
+ `200` with `enabled: false` and an empty list. `PUT /api/server/logs/settings`
172
202
  accepts `{ "enabled": boolean }`, is restricted to admins, and uses the normal
173
203
  same-origin policy for browser mutations. API-key and bearer callers continue
174
204
  to use the existing token-auth behavior. The in-memory buffer is limited to
175
205
  the newest 1,000 entries and 512 KiB of rendered UTF-8 lines; disabling capture
176
- clears it. These endpoints only expose messages emitted through the framework
206
+ clears it. These endpoints expose entries emitted through the shared WebApp
177
207
  logger and never make the logs durable.
178
208
 
209
+ ## Client logging
210
+
211
+ WebApp also exposes a browser-side `tslog` service through
212
+ `@pablozaiden/webapp/web`. Use it for application code instead of adding a
213
+ second logger or writing application logs directly with `console.*`:
214
+
215
+ ```tsx
216
+ import { createLogger, log } from "@pablozaiden/webapp/web";
217
+
218
+ const projectsLog = createLogger("projects");
219
+
220
+ log.info("Application UI initialized");
221
+ projectsLog.debug("Loaded projects", { count: 12 });
222
+ ```
223
+
224
+ The client service provides the same seven levels as the server service and
225
+ writes to the browser console. `WebAppRoot` automatically synchronizes its
226
+ effective level with the shared framework configuration, including changes
227
+ made from Developer Settings. Client logs are browser-local and are not
228
+ included in the server's `/api/server/logs` snapshot.
229
+
179
230
  Server-side applications that need credentials for internal runtimes should use
180
231
  the server-only `createManagedApiKey`, `listManagedApiKeys`, and
181
232
  `revokeManagedApiKey` helpers. Managed keys are persisted in the same API-key
package/docs/settings.md CHANGED
@@ -9,7 +9,7 @@ Settings is framework-owned so apps stay consistent. It includes:
9
9
  - Admin user management
10
10
  - Admin log-level preference unless `{PREFIX}_LOG_LEVEL` is set
11
11
  - Admin in-memory server-log capture toggle; disabled and cleared on process
12
- start or when turned off
12
+ start or when turned off; `{PREFIX}_IN_MEMORY_LOGS` can enable it initially
13
13
  - Admin server kill
14
14
  - Version/about
15
15
 
@@ -36,10 +36,11 @@ the shared hook state after a successful save, and `fromEnv` is true when
36
36
  configuration fetch or initializer component.
37
37
 
38
38
  Developer Settings also includes **Store server logs in memory**. It is an
39
- admin-only, process-local setting and is disabled when the server starts. When
40
- enabled, the framework logger retains only the newest 1,000 eligible entries
41
- within a 512 KiB rendered-line limit; disabling it immediately clears the
42
- buffer. The setting and captured logs are never stored in SQLite.
39
+ admin-only, process-local setting and is disabled when the server starts unless
40
+ `{PREFIX}_IN_MEMORY_LOGS` is set to `true`, `1`, or `yes`. When enabled, the
41
+ shared `tslog` service retains only the newest 1,000 eligible entries within a
42
+ 512 KiB rendered-line limit; disabling it immediately clears the buffer. The
43
+ setting and captured logs are never stored in SQLite.
43
44
 
44
45
  Security lists only show useful active user credentials. Managed API keys created by
45
46
  server-side applications, and their metadata, are not returned by the browser API-key
@@ -43,6 +43,7 @@ Use these first:
43
43
  | `Toolbar` | Page title/actions inside main content |
44
44
  | `Panel` | Cards/sections; use `actions` for a top-right menu/action area |
45
45
  | `ActionMenu` | Three-line action menu for secondary surfaces; entity-level shell menus should usually come from `SidebarNode.actions` so the framework renders them in the sidebar context menu and fixed title bar |
46
+ | `FloatingPanel` | Anchored arbitrary popup content; use it instead of app-owned portals when the content is not a simple `ActionMenu` |
46
47
  | `Button` / `IconButton` | Form submission and true inline controls; prefer action menus for entity/app commands |
47
48
  | `Badge` | Generic status/count labels; preserves the supplied text casing |
48
49
  | `StatusBadge` | Status labels with the shared uppercase and letter-spacing treatment |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -49,6 +49,7 @@
49
49
  "bun-plugin-tailwind": "0.1.2",
50
50
  "jose": "6.2.3",
51
51
  "tailwindcss": "4.3.1",
52
+ "tslog": "4.10.2",
52
53
  "zod": "4.4.3"
53
54
  },
54
55
  "peerDependencies": {
@@ -1,4 +1,28 @@
1
- export type LogLevelName = "trace" | "debug" | "info" | "warn" | "error";
1
+ export const VALID_LOG_LEVELS = ["silly", "trace", "debug", "info", "warn", "error", "fatal"] as const;
2
+
3
+ export type LogLevelName = typeof VALID_LOG_LEVELS[number];
4
+
5
+ export const LOG_LEVELS: Record<LogLevelName, number> = {
6
+ silly: 0,
7
+ trace: 1,
8
+ debug: 2,
9
+ info: 3,
10
+ warn: 4,
11
+ error: 5,
12
+ fatal: 6,
13
+ };
14
+
15
+ export const LOG_LEVEL_NAMES: Record<number, LogLevelName> = {
16
+ 0: "silly",
17
+ 1: "trace",
18
+ 2: "debug",
19
+ 3: "info",
20
+ 4: "warn",
21
+ 5: "error",
22
+ 6: "fatal",
23
+ };
24
+
25
+ export const DEFAULT_LOG_LEVEL: LogLevelName = "info";
2
26
 
3
27
  export type ThemePreference = "system" | "light" | "dark";
4
28
 
@@ -11,7 +11,7 @@ import {
11
11
  import { createAuthentication } from "./authentication";
12
12
  import { createFrameworkEndpointHandler } from "./framework-endpoints";
13
13
  import { createServerLifecycle } from "./server-lifecycle";
14
- import { inMemoryLogStorage, resetInMemoryLogStorage, setLogLevel } from "./logger";
14
+ import { inMemoryLogStorage, resetInMemoryLogStorage, setInMemoryLogStorageEnabled, setLogLevel } from "./logger";
15
15
  import { createWebDocumentProvider, htmlResponse } from "./web-document";
16
16
  import { createPublicRouteDispatcher } from "./public-route-dispatch";
17
17
  import { createRouteDispatcher } from "./route-dispatch";
@@ -46,6 +46,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
46
46
  throw new Error("runtimeConfig appName and envPrefix must match the createWebAppServer inputs");
47
47
  }
48
48
  resetInMemoryLogStorage();
49
+ setInMemoryLogStorageEnabled(config.inMemoryLogsEnabled);
49
50
  const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
50
51
  store.initialize();
51
52
  const savedLogLevel = store.getLogLevelPreference();
@@ -4,6 +4,17 @@ export * from "./route-catalog";
4
4
  export * from "./responses";
5
5
  export * from "./public-assets";
6
6
  export * from "./create-web-app-server";
7
+ export {
8
+ DEFAULT_LOG_LEVEL,
9
+ VALID_LOG_LEVELS,
10
+ type LogLevelName,
11
+ } from "../contracts";
12
+ export {
13
+ createLogger,
14
+ getLogLevel,
15
+ log,
16
+ setLogLevel,
17
+ } from "./logger";
7
18
  export { getRequestBaseUrl, getRequestOriginInfo, type RequestOriginInfo } from "./auth/request-origin";
8
19
  export * from "./auth/api-keys";
9
20
  export * from "./auth/store";
@@ -1,21 +1,22 @@
1
- import type { LogLevelName, ServerLogEntry } from "../contracts";
2
-
3
- const ORDER: Record<LogLevelName, number> = {
4
- trace: 10,
5
- debug: 20,
6
- info: 30,
7
- warn: 40,
8
- error: 50,
9
- };
1
+ import { inspect } from "node:util";
2
+ import { Logger, type ILogObj, type IMeta } from "tslog";
3
+ import {
4
+ DEFAULT_LOG_LEVEL,
5
+ LOG_LEVEL_NAMES,
6
+ LOG_LEVELS,
7
+ VALID_LOG_LEVELS,
8
+ type LogLevelName,
9
+ type ServerLogEntry,
10
+ } from "../contracts";
10
11
 
11
12
  export const MAX_IN_MEMORY_LOG_ENTRIES = 1_000;
12
13
  export const MAX_IN_MEMORY_LOG_BYTES = 512 * 1024;
13
14
 
14
- let currentLevel: LogLevelName = "info";
15
15
  let inMemoryLogStorageEnabled = false;
16
16
  let inMemoryLogEntries: ServerLogEntry[] = [];
17
17
  let inMemoryLogBytes = 0;
18
18
  const textEncoder = new TextEncoder();
19
+ const subLoggers = new Map<string, Logger<ILogObj>>();
19
20
 
20
21
  export interface InMemoryLogStorage {
21
22
  isEnabled(): boolean;
@@ -24,12 +25,8 @@ export interface InMemoryLogStorage {
24
25
  reset(): void;
25
26
  }
26
27
 
27
- export function setLogLevel(level: LogLevelName): void {
28
- currentLevel = level;
29
- }
30
-
31
- export function getLogLevel(): LogLevelName {
32
- return currentLevel;
28
+ function isLogLevelName(value: unknown): value is LogLevelName {
29
+ return typeof value === "string" && VALID_LOG_LEVELS.includes(value as LogLevelName);
33
30
  }
34
31
 
35
32
  function lineByteLength(line: string): number {
@@ -55,6 +52,100 @@ function appendInMemoryLogEntry(entry: ServerLogEntry): void {
55
52
  }
56
53
  }
57
54
 
55
+ function formatValue(value: unknown): string {
56
+ if (value === undefined) {
57
+ return "";
58
+ }
59
+ if (typeof value === "string") {
60
+ return value;
61
+ }
62
+ if (value instanceof Error) {
63
+ return `${value.name}: ${value.message}`;
64
+ }
65
+ if (typeof value === "object" && value !== null && "message" in value) {
66
+ const record = value as { message?: unknown; name?: unknown };
67
+ if (typeof record.message === "string") {
68
+ return typeof record.name === "string" ? `${record.name}: ${record.message}` : record.message;
69
+ }
70
+ }
71
+ try {
72
+ return JSON.stringify(value) ?? String(value);
73
+ } catch {
74
+ return String(value);
75
+ }
76
+ }
77
+
78
+ function appendTslogEntry(metadata: IMeta, line: string, logArgs: unknown[], logErrors: string[]): void {
79
+ const level = LOG_LEVEL_NAMES[metadata.logLevelId] ?? (metadata.logLevelName.toLowerCase() as LogLevelName);
80
+ if (!isLogLevelName(level)) {
81
+ return;
82
+ }
83
+ const timestamp = metadata.date.toISOString();
84
+ const scope = typeof metadata.name === "string" && metadata.name ? metadata.name : "webapp";
85
+ const message = formatValue(logArgs[0] ?? logErrors[0] ?? "");
86
+ appendInMemoryLogEntry({
87
+ timestamp,
88
+ level,
89
+ scope,
90
+ message,
91
+ line,
92
+ });
93
+ }
94
+
95
+ function renderConsoleValue(value: unknown): string {
96
+ return typeof value === "string" ? value : inspect(value, { colors: false, compact: true, depth: Infinity });
97
+ }
98
+
99
+ function transportFormatted(logMetaMarkup: string, logArgs: unknown[], logErrors: string[], logMeta?: IMeta): void {
100
+ const errors = (logErrors.length > 0 && logArgs.length > 0 ? "\n" : "") + logErrors.join("\n");
101
+ const line = `${logMetaMarkup}${logArgs.map(renderConsoleValue).join(" ")}${errors}`;
102
+ const level = LOG_LEVEL_NAMES[logMeta?.logLevelId ?? LOG_LEVELS.info] ?? DEFAULT_LOG_LEVEL;
103
+ if (level === "fatal" || level === "error") {
104
+ console.error(line);
105
+ } else if (level === "warn") {
106
+ console.warn(line);
107
+ } else {
108
+ console.log(line);
109
+ }
110
+ if (logMeta) {
111
+ appendTslogEntry(logMeta, line, logArgs, logErrors);
112
+ }
113
+ }
114
+
115
+ export const log = new Logger<ILogObj>({
116
+ name: "webapp",
117
+ minLevel: LOG_LEVELS[DEFAULT_LOG_LEVEL],
118
+ prettyLogTimeZone: "UTC",
119
+ stylePrettyLogs: false,
120
+ prettyInspectOptions: { colors: false, compact: true, depth: Infinity },
121
+ overwrite: { transportFormatted },
122
+ });
123
+
124
+ export function createLogger(scope: string): Logger<ILogObj> {
125
+ const existing = subLoggers.get(scope);
126
+ if (existing) {
127
+ return existing;
128
+ }
129
+ const logger = log.getSubLogger({ name: scope });
130
+ subLoggers.set(scope, logger);
131
+ return logger;
132
+ }
133
+
134
+ export function setLogLevel(level: LogLevelName): void {
135
+ if (!isLogLevelName(level)) {
136
+ throw new Error(`Invalid log level: ${String(level)}`);
137
+ }
138
+ const numericLevel = LOG_LEVELS[level];
139
+ log.settings.minLevel = numericLevel;
140
+ for (const subLogger of subLoggers.values()) {
141
+ subLogger.settings.minLevel = numericLevel;
142
+ }
143
+ }
144
+
145
+ export function getLogLevel(): LogLevelName {
146
+ return LOG_LEVEL_NAMES[log.settings.minLevel] ?? DEFAULT_LOG_LEVEL;
147
+ }
148
+
58
149
  export function setInMemoryLogStorageEnabled(enabled: boolean): void {
59
150
  inMemoryLogStorageEnabled = enabled;
60
151
  if (!enabled) {
@@ -83,29 +174,3 @@ export const inMemoryLogStorage: InMemoryLogStorage = {
83
174
  getEntries: getInMemoryLogEntries,
84
175
  reset: resetInMemoryLogStorage,
85
176
  };
86
-
87
- export function createLogger(scope: string) {
88
- function log(level: LogLevelName, message: string, fields?: Record<string, unknown>): void {
89
- if (ORDER[level] < ORDER[currentLevel]) {
90
- return;
91
- }
92
- const timestamp = new Date().toISOString();
93
- const suffix = fields ? ` ${JSON.stringify(fields)}` : "";
94
- const line = `${timestamp}\t${level.toUpperCase()}\t${scope}\t${message}${suffix}`;
95
- appendInMemoryLogEntry({ timestamp, level, scope, message, line });
96
- if (level === "error") {
97
- console.error(line);
98
- } else if (level === "warn") {
99
- console.warn(line);
100
- } else {
101
- console.log(line);
102
- }
103
- }
104
- return {
105
- trace: (message: string, fields?: Record<string, unknown>) => log("trace", message, fields),
106
- debug: (message: string, fields?: Record<string, unknown>) => log("debug", message, fields),
107
- info: (message: string, fields?: Record<string, unknown>) => log("info", message, fields),
108
- warn: (message: string, fields?: Record<string, unknown>) => log("warn", message, fields),
109
- error: (message: string, fields?: Record<string, unknown>) => log("error", message, fields),
110
- };
111
- }
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { VALID_LOG_LEVELS } from "../contracts";
2
3
  import type { AuthenticationResponseJSON, RegistrationResponseJSON } from "@simplewebauthn/server";
3
4
 
4
5
  const nonEmptyString = z.string().min(1);
@@ -140,7 +141,7 @@ export const themePreferenceRequestSchema = z.object({
140
141
  });
141
142
 
142
143
  export const logLevelPreferenceRequestSchema = z.object({
143
- level: z.enum(["trace", "debug", "info", "warn", "error"]),
144
+ level: z.enum(VALID_LOG_LEVELS),
144
145
  });
145
146
 
146
147
  export const inMemoryLogSettingsRequestSchema = z.object({
@@ -1,4 +1,4 @@
1
- import type { LogLevelName } from "../contracts";
1
+ import { DEFAULT_LOG_LEVEL, VALID_LOG_LEVELS, type LogLevelName } from "../contracts";
2
2
 
3
3
  export const TRUST_PROXY_HEADERS = ["proto", "host", "prefix"] as const;
4
4
  export type TrustProxyHeader = typeof TRUST_PROXY_HEADERS[number];
@@ -18,6 +18,7 @@ export interface RuntimeConfig {
18
18
  dataDir: string;
19
19
  logLevel: LogLevelName;
20
20
  logLevelFromEnv: boolean;
21
+ inMemoryLogsEnabled: boolean;
21
22
  passkeyDisabled: boolean;
22
23
  sameOriginDisabled: boolean;
23
24
  publicBaseUrl?: string;
@@ -26,10 +27,8 @@ export interface RuntimeConfig {
26
27
  development: false | { hmr: true; console: true };
27
28
  }
28
29
 
29
- const LOG_LEVELS = new Set<LogLevelName>(["trace", "debug", "info", "warn", "error"]);
30
-
31
30
  function isSupportedLogLevel(value: unknown): value is LogLevelName {
32
- return typeof value === "string" && LOG_LEVELS.has(value as LogLevelName);
31
+ return typeof value === "string" && VALID_LOG_LEVELS.includes(value as LogLevelName);
33
32
  }
34
33
 
35
34
  export function resolveEffectiveLogLevel(
@@ -80,7 +79,7 @@ function parseLogLevel(raw: string | undefined, fallback: LogLevelName, name: st
80
79
  return fallback;
81
80
  }
82
81
  if (!isSupportedLogLevel(raw)) {
83
- throw new Error(`${name} must be one of trace, debug, info, warn, error; received "${raw}"`);
82
+ throw new Error(`${name} must be one of ${VALID_LOG_LEVELS.join(", ")}; received "${raw}"`);
84
83
  }
85
84
  return raw;
86
85
  }
@@ -160,7 +159,8 @@ export function readRuntimeConfig(input: {
160
159
  }): RuntimeConfig {
161
160
  const envPrefix = assertEnvPrefix(input.envPrefix);
162
161
  const logLevelRaw = readEnv(envPrefix, "LOG_LEVEL");
163
- const logLevel = parseLogLevel(logLevelRaw, input.defaultLogLevel ?? "info", envName(envPrefix, "LOG_LEVEL"));
162
+ const logLevel = parseLogLevel(logLevelRaw, input.defaultLogLevel ?? DEFAULT_LOG_LEVEL, envName(envPrefix, "LOG_LEVEL"));
163
+ const inMemoryLogsEnabled = parseBoolean(readEnv(envPrefix, "IN_MEMORY_LOGS"), false, envName(envPrefix, "IN_MEMORY_LOGS"));
164
164
  const publicBaseUrl = parsePublicBaseUrl(readEnv(envPrefix, "PUBLIC_BASE_URL"), envName(envPrefix, "PUBLIC_BASE_URL"));
165
165
  const trustProxyEnabled = parseBoolean(readEnv(envPrefix, "TRUST_PROXY"), false, envName(envPrefix, "TRUST_PROXY"));
166
166
  const trustProxy = {
@@ -176,6 +176,7 @@ export function readRuntimeConfig(input: {
176
176
  dataDir: readEnv(envPrefix, "DATA_DIR") || "./data",
177
177
  logLevel,
178
178
  logLevelFromEnv: Boolean(logLevelRaw),
179
+ inMemoryLogsEnabled,
179
180
  passkeyDisabled: isTruthyEnv(readEnv(envPrefix, "DISABLE_PASSKEY")),
180
181
  sameOriginDisabled: isTruthyEnv(readEnv(envPrefix, "DISABLE_SAME_ORIGIN_CHECK")),
181
182
  publicBaseUrl,
@@ -194,6 +195,7 @@ export function safeRuntimeConfig(config: RuntimeConfig): Record<string, unknown
194
195
  dataDir: config.dataDir,
195
196
  logLevel: config.logLevel,
196
197
  logLevelFromEnv: config.logLevelFromEnv,
198
+ inMemoryLogsEnabled: config.inMemoryLogsEnabled,
197
199
  passkeyDisabled: config.passkeyDisabled,
198
200
  sameOriginDisabled: config.sameOriginDisabled,
199
201
  publicBaseUrl: config.publicBaseUrl,
@@ -4,13 +4,15 @@ import { AppShell } from "./app-shell";
4
4
  import { DeviceVerificationScreen, PasskeyAuthScreen, UserSetupScreen } from "./auth-screens";
5
5
  import { EmptyState, Panel } from "./components";
6
6
  import { useMobileBreakpoint, useMobileSidebarSwipe, useMobileViewportHeight } from "./mobile-hooks";
7
- import { useRoute } from "./routing";
7
+ import { routeToHash, supportsViewTransitions, useRoute } from "./routing";
8
8
  import { flattenSidebarItems, toStoredPin, useSidebarCollapsedState, useSidebarPins } from "./sidebar-state";
9
9
  import { SettingsView } from "./settings/settings-view";
10
10
  import type { HeaderContext, WebAppRootProps } from "./root-types";
11
11
  import type { ActionMenuItem, SidebarNode, WebAppRoute } from "./sidebar/types";
12
12
  import { ThemeProvider } from "./theme";
13
13
  import { WebAppConfigProvider, useWebAppConfig } from "./webapp-config";
14
+ import { setLogLevel } from "./logger";
15
+ import { useReducedMotion } from "./motion";
14
16
 
15
17
  export { replaceHashRoute, replaceWebAppRoute, routeToHash } from "./routing";
16
18
  export type {
@@ -47,6 +49,7 @@ function WebAppRootContent({
47
49
  refresh: () => Promise<void>;
48
50
  }) {
49
51
  const isMobile = useMobileBreakpoint();
52
+ const reducedMotion = useReducedMotion();
50
53
  useMobileViewportHeight(isMobile);
51
54
  const { route, navigate } = useRoute(homeRoute);
52
55
  const [search, setSearch] = useState("");
@@ -188,6 +191,8 @@ function WebAppRootContent({
188
191
  headerActionLabel={headerActionLabel}
189
192
  primaryHeaderActions={primaryHeaderActions}
190
193
  headerActions={headerActions}
194
+ routeKey={routeToHash(route)}
195
+ nativeRouteTransitions={supportsViewTransitions() && !reducedMotion}
191
196
  view={view}
192
197
  />
193
198
  );
@@ -195,6 +200,13 @@ function WebAppRootContent({
195
200
 
196
201
  function WebAppRootWithConfig(props: WebAppRootProps) {
197
202
  const { config, error, refresh } = useWebAppConfig();
203
+
204
+ useEffect(() => {
205
+ if (config) {
206
+ setLogLevel(config.logLevel.level);
207
+ }
208
+ }, [config?.logLevel.level]);
209
+
198
210
  return (
199
211
  <ThemeProvider userId={config?.currentUser?.id}>
200
212
  <WebAppRootContent {...props} config={config} error={error} refresh={refresh} />