@pablozaiden/webapp 1.1.0 → 1.2.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.
@@ -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,44 +156,41 @@ 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.
184
+
185
+ ### In-memory server logs
186
+
187
+ Admins can enable temporary server-log capture from Developer Settings next to
188
+ the log-level selector. The shared WebApp server logger exposes the current state in
189
+ `/api/config`; it starts disabled on every process start and is never stored in
190
+ the database. Admin clients can retrieve a bounded chronological snapshot from
191
+ `GET /api/server/logs` after enabling capture. The buffer is limited to the
192
+ newest 1,000 framework logger entries or 512 KiB of rendered log lines, and
193
+ turning the setting off clears it.
192
194
 
193
195
  ### Transient notifications
194
196
 
@@ -283,7 +285,8 @@ The app configures an uppercase `envPrefix`; the framework reads only variables
283
285
  | `{PREFIX}_HOST` | `localhost` | Bind host |
284
286
  | `{PREFIX}_PORT` | `3000` | Bind port |
285
287
  | `{PREFIX}_DATA_DIR` | `./data` | Durable SQLite persistence directory for framework auth and app data |
286
- | `{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 |
287
290
  | `{PREFIX}_DISABLE_PASSKEY` | unset | Emergency bypass that logs in as the existing owner; it does not create users |
288
291
  | `{PREFIX}_DISABLE_SAME_ORIGIN_CHECK` | unset | Development/testing escape hatch |
289
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 |
@@ -153,6 +183,8 @@ Built-in endpoints include:
153
183
  | `/device` | Browser device-code approval screen |
154
184
  | `/.well-known/jwks.json`, `/.well-known/openid-configuration` | Token verification metadata |
155
185
  | `/api/preferences/theme`, `/api/preferences/log-level` | Settings persistence |
186
+ | `GET /api/server/logs` | Admin-only snapshot of the in-memory server logs |
187
+ | `PUT /api/server/logs/settings` | Admin-only runtime toggle for in-memory server logs |
156
188
  | `/api/server/kill` | Authenticated server shutdown |
157
189
  | `/api/ws` | Realtime websocket by default |
158
190
 
@@ -162,6 +194,39 @@ The effective log level in `GET /api/config` and `GET
162
194
  to `true`. PUT remains an authenticated, same-origin admin mutation and
163
195
  returns a conflict when the environment controls the value.
164
196
 
197
+ `GET /api/server/logs` is restricted to admin users and returns
198
+ `{ enabled: boolean, logs: ServerLogEntry[] }`. Entries are returned in
199
+ chronological order and contain the timestamp, level, logger scope, message,
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`
202
+ accepts `{ "enabled": boolean }`, is restricted to admins, and uses the normal
203
+ same-origin policy for browser mutations. API-key and bearer callers continue
204
+ to use the existing token-auth behavior. The in-memory buffer is limited to
205
+ the newest 1,000 entries and 512 KiB of rendered UTF-8 lines; disabling capture
206
+ clears it. These endpoints expose entries emitted through the shared WebApp
207
+ logger and never make the logs durable.
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
+
165
230
  Server-side applications that need credentials for internal runtimes should use
166
231
  the server-only `createManagedApiKey`, `listManagedApiKeys`, and
167
232
  `revokeManagedApiKey` helpers. Managed keys are persisted in the same API-key
package/docs/settings.md CHANGED
@@ -8,6 +8,8 @@ Settings is framework-owned so apps stay consistent. It includes:
8
8
  - Theme preference: system/light/dark, stored per user
9
9
  - Admin user management
10
10
  - Admin log-level preference unless `{PREFIX}_LOG_LEVEL` is set
11
+ - Admin in-memory server-log capture toggle; disabled and cleared on process
12
+ start or when turned off; `{PREFIX}_IN_MEMORY_LOGS` can enable it initially
11
13
  - Admin server kill
12
14
  - Version/about
13
15
 
@@ -33,6 +35,13 @@ the shared hook state after a successful save, and `fromEnv` is true when
33
35
  `{PREFIX}_LOG_LEVEL` locks the value. Applications should not add a separate
34
36
  configuration fetch or initializer component.
35
37
 
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 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.
44
+
36
45
  Security lists only show useful active user credentials. Managed API keys created by
37
46
  server-side applications, and their metadata, are not returned by the browser API-key
38
47
  list or rendered in Settings. Expired API keys are purged before listing, and revoked
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pablozaiden/webapp",
3
- "version": "1.1.0",
3
+ "version": "1.2.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
 
@@ -114,6 +138,14 @@ export interface AuthSessionSummary {
114
138
  active: boolean;
115
139
  }
116
140
 
141
+ export interface ServerLogEntry {
142
+ timestamp: string;
143
+ level: LogLevelName;
144
+ scope: string;
145
+ message: string;
146
+ line: string;
147
+ }
148
+
117
149
  export interface WebAppConfigResponse {
118
150
  appName: string;
119
151
  version: string;
@@ -127,6 +159,9 @@ export interface WebAppConfigResponse {
127
159
  level: LogLevelName;
128
160
  fromEnv: boolean;
129
161
  };
162
+ inMemoryLogs: {
163
+ enabled: boolean;
164
+ };
130
165
  deviceAuth: {
131
166
  enabled: boolean;
132
167
  };
@@ -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 { 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";
@@ -45,6 +45,8 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
45
45
  if (config.appName !== input.appName || config.envPrefix !== input.envPrefix) {
46
46
  throw new Error("runtimeConfig appName and envPrefix must match the createWebAppServer inputs");
47
47
  }
48
+ resetInMemoryLogStorage();
49
+ setInMemoryLogStorageEnabled(config.inMemoryLogsEnabled);
48
50
  const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
49
51
  store.initialize();
50
52
  const savedLogLevel = store.getLogLevelPreference();
@@ -80,6 +82,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
80
82
  deviceAuthEnabled,
81
83
  configResponse: input.configResponse,
82
84
  onLogLevelChange: input.logLevel?.onChange,
85
+ inMemoryLogs: inMemoryLogStorage,
83
86
  ensureWebDocument,
84
87
  });
85
88
  const publicRouteDispatcher = createPublicRouteDispatcher({
@@ -48,6 +48,7 @@ import {
48
48
  createUserRequestSchema,
49
49
  deviceAuthorizationRequestSchema,
50
50
  deviceCodeActionRequestSchema,
51
+ inMemoryLogSettingsRequestSchema,
51
52
  logLevelPreferenceRequestSchema,
52
53
  passkeyBootstrapOptionsSchema,
53
54
  refreshTokenRequestSchema,
@@ -61,7 +62,8 @@ import {
61
62
  } from "./request-schemas";
62
63
  import { resolveEffectiveLogLevel, type RuntimeConfig } from "./runtime-config";
63
64
  import { setLogLevel } from "./logger";
64
- import { errorResponse, jsonResponse, notFound, parseJson, successResponse } from "./responses";
65
+ import type { InMemoryLogStorage } from "./logger";
66
+ import { errorResponse, jsonResponse, methodNotAllowed, notFound, parseJson, successResponse } from "./responses";
65
67
  import type { WebSocketData } from "./realtime/bus";
66
68
 
67
69
  export interface FrameworkEndpointDependencies {
@@ -75,6 +77,7 @@ export interface FrameworkEndpointDependencies {
75
77
  deviceAuthEnabled: boolean;
76
78
  configResponse?: WebAppServerConfig["configResponse"];
77
79
  onLogLevelChange?: (level: LogLevelName) => void;
80
+ inMemoryLogs: InMemoryLogStorage;
78
81
  ensureWebDocument: () => Promise<WebDocument>;
79
82
  }
80
83
 
@@ -97,6 +100,7 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
97
100
  deviceAuthEnabled,
98
101
  configResponse: extendConfigResponse,
99
102
  onLogLevelChange,
103
+ inMemoryLogs,
100
104
  ensureWebDocument,
101
105
  } = dependencies;
102
106
 
@@ -115,6 +119,9 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
115
119
  level: resolveEffectiveLogLevel(config, store.getLogLevelPreference()),
116
120
  fromEnv: config.logLevelFromEnv,
117
121
  },
122
+ inMemoryLogs: {
123
+ enabled: inMemoryLogs.isEnabled(),
124
+ },
118
125
  apiKeys: { enabled: Boolean(apiKeysEnabled) },
119
126
  deviceAuth: { enabled: Boolean(deviceAuthEnabled) },
120
127
  } satisfies WebAppConfigResponse;
@@ -433,6 +440,32 @@ export function createFrameworkEndpointHandler(dependencies: FrameworkEndpointDe
433
440
  return successResponse({ level: body.level });
434
441
  }
435
442
  }
443
+ if (path === "/api/server/logs") {
444
+ const auth = await authentication.authorize(req, true);
445
+ if (auth instanceof Response) return auth;
446
+ ensureAdmin(auth);
447
+ if (req.method !== "GET") {
448
+ return methodNotAllowed();
449
+ }
450
+ const enabled = inMemoryLogs.isEnabled();
451
+ return jsonResponse({
452
+ enabled,
453
+ logs: enabled ? inMemoryLogs.getEntries() : [],
454
+ });
455
+ }
456
+ if (path === "/api/server/logs/settings") {
457
+ const auth = await authentication.authorize(req, true);
458
+ if (auth instanceof Response) return auth;
459
+ ensureAdmin(auth);
460
+ if (req.method !== "PUT") {
461
+ return methodNotAllowed();
462
+ }
463
+ const originFailure = checkSameOrigin(req, config, auth, "mutations");
464
+ if (originFailure) return originFailure;
465
+ const body = await parseJson(req, inMemoryLogSettingsRequestSchema);
466
+ inMemoryLogs.setEnabled(body.enabled);
467
+ return successResponse({ enabled: inMemoryLogs.isEnabled() });
468
+ }
436
469
  if (path === "/api/server/kill" && req.method === "POST") {
437
470
  const auth = await authentication.authorize(req, true);
438
471
  if (auth instanceof Response) return auth;
@@ -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,44 +1,176 @@
1
- import type { LogLevelName } 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
- let currentLevel: LogLevelName = "info";
12
+ export const MAX_IN_MEMORY_LOG_ENTRIES = 1_000;
13
+ export const MAX_IN_MEMORY_LOG_BYTES = 512 * 1024;
12
14
 
13
- export function setLogLevel(level: LogLevelName): void {
14
- currentLevel = level;
15
+ let inMemoryLogStorageEnabled = false;
16
+ let inMemoryLogEntries: ServerLogEntry[] = [];
17
+ let inMemoryLogBytes = 0;
18
+ const textEncoder = new TextEncoder();
19
+ const subLoggers = new Map<string, Logger<ILogObj>>();
20
+
21
+ export interface InMemoryLogStorage {
22
+ isEnabled(): boolean;
23
+ setEnabled(enabled: boolean): void;
24
+ getEntries(): ServerLogEntry[];
25
+ reset(): void;
15
26
  }
16
27
 
17
- export function getLogLevel(): LogLevelName {
18
- return currentLevel;
28
+ function isLogLevelName(value: unknown): value is LogLevelName {
29
+ return typeof value === "string" && VALID_LOG_LEVELS.includes(value as LogLevelName);
30
+ }
31
+
32
+ function lineByteLength(line: string): number {
33
+ return textEncoder.encode(line).byteLength;
19
34
  }
20
35
 
21
- export function createLogger(scope: string) {
22
- function log(level: LogLevelName, message: string, fields?: Record<string, unknown>): void {
23
- if (ORDER[level] < ORDER[currentLevel]) {
24
- return;
36
+ function appendInMemoryLogEntry(entry: ServerLogEntry): void {
37
+ if (!inMemoryLogStorageEnabled) {
38
+ return;
39
+ }
40
+ const entryBytes = lineByteLength(entry.line);
41
+ if (entryBytes > MAX_IN_MEMORY_LOG_BYTES) {
42
+ return;
43
+ }
44
+ inMemoryLogEntries.push(entry);
45
+ inMemoryLogBytes += entryBytes;
46
+ while (inMemoryLogEntries.length > MAX_IN_MEMORY_LOG_ENTRIES || inMemoryLogBytes > MAX_IN_MEMORY_LOG_BYTES) {
47
+ const removed = inMemoryLogEntries.shift();
48
+ if (!removed) {
49
+ break;
25
50
  }
26
- const timestamp = new Date().toISOString();
27
- const suffix = fields ? ` ${JSON.stringify(fields)}` : "";
28
- const line = `${timestamp}\t${level.toUpperCase()}\t${scope}\t${message}${suffix}`;
29
- if (level === "error") {
30
- console.error(line);
31
- } else if (level === "warn") {
32
- console.warn(line);
33
- } else {
34
- console.log(line);
51
+ inMemoryLogBytes -= lineByteLength(removed.line);
52
+ }
53
+ }
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;
35
69
  }
36
70
  }
37
- return {
38
- trace: (message: string, fields?: Record<string, unknown>) => log("trace", message, fields),
39
- debug: (message: string, fields?: Record<string, unknown>) => log("debug", message, fields),
40
- info: (message: string, fields?: Record<string, unknown>) => log("info", message, fields),
41
- warn: (message: string, fields?: Record<string, unknown>) => log("warn", message, fields),
42
- error: (message: string, fields?: Record<string, unknown>) => log("error", message, fields),
43
- };
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
+ }
44
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
+
149
+ export function setInMemoryLogStorageEnabled(enabled: boolean): void {
150
+ inMemoryLogStorageEnabled = enabled;
151
+ if (!enabled) {
152
+ inMemoryLogEntries = [];
153
+ inMemoryLogBytes = 0;
154
+ }
155
+ }
156
+
157
+ export function isInMemoryLogStorageEnabled(): boolean {
158
+ return inMemoryLogStorageEnabled;
159
+ }
160
+
161
+ export function getInMemoryLogEntries(): ServerLogEntry[] {
162
+ return inMemoryLogEntries.map((entry) => ({ ...entry }));
163
+ }
164
+
165
+ export function resetInMemoryLogStorage(): void {
166
+ inMemoryLogStorageEnabled = false;
167
+ inMemoryLogEntries = [];
168
+ inMemoryLogBytes = 0;
169
+ }
170
+
171
+ export const inMemoryLogStorage: InMemoryLogStorage = {
172
+ isEnabled: isInMemoryLogStorageEnabled,
173
+ setEnabled: setInMemoryLogStorageEnabled,
174
+ getEntries: getInMemoryLogEntries,
175
+ reset: resetInMemoryLogStorage,
176
+ };
@@ -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,5 +141,9 @@ 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),
145
+ });
146
+
147
+ export const inMemoryLogSettingsRequestSchema = z.object({
148
+ enabled: z.boolean(),
144
149
  });
@@ -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,
@@ -11,6 +11,7 @@ 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";
14
15
 
15
16
  export { replaceHashRoute, replaceWebAppRoute, routeToHash } from "./routing";
16
17
  export type {
@@ -195,6 +196,13 @@ function WebAppRootContent({
195
196
 
196
197
  function WebAppRootWithConfig(props: WebAppRootProps) {
197
198
  const { config, error, refresh } = useWebAppConfig();
199
+
200
+ useEffect(() => {
201
+ if (config) {
202
+ setLogLevel(config.logLevel.level);
203
+ }
204
+ }, [config?.logLevel.level]);
205
+
198
206
  return (
199
207
  <ThemeProvider userId={config?.currentUser?.id}>
200
208
  <WebAppRootContent {...props} config={config} error={error} refresh={refresh} />
@@ -252,6 +252,18 @@ export function SelectField({ label, children, ...props }: SelectHTMLAttributes<
252
252
  );
253
253
  }
254
254
 
255
+ export function CheckboxField({ label, hint, ...props }: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & { label: string; hint?: string }) {
256
+ return (
257
+ <label className="wapp-checkbox-field">
258
+ <input {...props} type="checkbox" />
259
+ <span>
260
+ {label}
261
+ {hint ? <small>{hint}</small> : null}
262
+ </span>
263
+ </label>
264
+ );
265
+ }
266
+
255
267
  export function SegmentedControl<T extends string>({ value, options, onChange, label }: { value: T; options: Array<{ value: T; label: string }>; onChange: (value: T) => void; label: string }) {
256
268
  return (
257
269
  <div className="wapp-segmented" role="group" aria-label={label}>
package/src/web/index.ts CHANGED
@@ -11,5 +11,6 @@ export { useTheme } from "./theme";
11
11
  export type { ResolvedTheme, WebAppThemeState } from "./theme";
12
12
  export { useLogLevel } from "./log-level";
13
13
  export type { WebAppLogLevelState } from "./log-level";
14
+ export { createLogger, getLogLevel, log } from "./logger";
14
15
  export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
15
16
  export type { ThemePreference } from "../contracts";
@@ -0,0 +1,44 @@
1
+ import { Logger, type ILogObj } from "tslog";
2
+ import {
3
+ DEFAULT_LOG_LEVEL,
4
+ LOG_LEVEL_NAMES,
5
+ LOG_LEVELS,
6
+ VALID_LOG_LEVELS,
7
+ type LogLevelName,
8
+ } from "../contracts";
9
+
10
+ const subLoggers = new Map<string, Logger<ILogObj>>();
11
+
12
+ function isLogLevelName(value: unknown): value is LogLevelName {
13
+ return typeof value === "string" && VALID_LOG_LEVELS.includes(value as LogLevelName);
14
+ }
15
+
16
+ export const log = new Logger<ILogObj>({
17
+ name: "webapp",
18
+ minLevel: LOG_LEVELS[DEFAULT_LOG_LEVEL],
19
+ });
20
+
21
+ export function createLogger(scope: string): Logger<ILogObj> {
22
+ const existing = subLoggers.get(scope);
23
+ if (existing) {
24
+ return existing;
25
+ }
26
+ const logger = log.getSubLogger({ name: scope });
27
+ subLoggers.set(scope, logger);
28
+ return logger;
29
+ }
30
+
31
+ export function setLogLevel(level: LogLevelName): void {
32
+ if (!isLogLevelName(level)) {
33
+ throw new Error(`Invalid log level: ${String(level)}`);
34
+ }
35
+ const numericLevel = LOG_LEVELS[level];
36
+ log.settings.minLevel = numericLevel;
37
+ for (const subLogger of subLoggers.values()) {
38
+ subLogger.settings.minLevel = numericLevel;
39
+ }
40
+ }
41
+
42
+ export function getLogLevel(): LogLevelName {
43
+ return LOG_LEVEL_NAMES[log.settings.minLevel] ?? DEFAULT_LOG_LEVEL;
44
+ }
@@ -1,14 +1,12 @@
1
1
  import { useState } from "react";
2
- import type { LogLevelName, WebAppConfigResponse } from "../../contracts";
2
+ import { VALID_LOG_LEVELS, type LogLevelName, type WebAppConfigResponse } from "../../contracts";
3
3
  import { appJson } from "../api-client";
4
- import { Badge, Button, ErrorState, FormSection, LoadingState, SelectField } from "../components";
4
+ import { Badge, Button, CheckboxField, ErrorState, FormSection, LoadingState, SelectField } from "../components";
5
5
  import { useLogLevel } from "../log-level";
6
6
  import { isThemePreference, useTheme } from "../theme";
7
7
 
8
- const LOG_LEVEL_NAMES = ["trace", "debug", "info", "warn", "error"] as const satisfies readonly LogLevelName[];
9
-
10
8
  function isLogLevelName(value: string): value is LogLevelName {
11
- return LOG_LEVEL_NAMES.some((level) => level === value);
9
+ return VALID_LOG_LEVELS.some((level) => level === value);
12
10
  }
13
11
 
14
12
  export interface AccountSectionProps {
@@ -21,6 +19,7 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
21
19
  const { preference, setPreference, loading, error, retry } = useTheme();
22
20
  const logLevel = useLogLevel();
23
21
  const [logLevelSaving, setLogLevelSaving] = useState(false);
22
+ const [inMemoryLogsSaving, setInMemoryLogsSaving] = useState(false);
24
23
 
25
24
  async function updateLogLevel(value: string) {
26
25
  if (!isLogLevelName(value)) {
@@ -39,6 +38,22 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
39
38
  }
40
39
  }
41
40
 
41
+ async function updateInMemoryLogs(enabled: boolean) {
42
+ try {
43
+ setError(undefined);
44
+ setInMemoryLogsSaving(true);
45
+ await appJson<unknown>("/api/server/logs/settings", {
46
+ method: "PUT",
47
+ body: JSON.stringify({ enabled }),
48
+ });
49
+ await refresh();
50
+ } catch (err) {
51
+ setError(err instanceof Error ? err.message : String(err));
52
+ } finally {
53
+ setInMemoryLogsSaving(false);
54
+ }
55
+ }
56
+
42
57
  return (
43
58
  <>
44
59
  {config.currentUser ? (
@@ -85,9 +100,16 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
85
100
  disabled={logLevel.fromEnv === true || logLevel.loading || logLevelSaving}
86
101
  onChange={(event) => void updateLogLevel(event.currentTarget.value)}
87
102
  >
88
- {LOG_LEVEL_NAMES.map((level) => <option key={level} value={level}>{level}</option>)}
103
+ {VALID_LOG_LEVELS.map((level) => <option key={level} value={level}>{level}</option>)}
89
104
  </SelectField>
90
105
  ) : null}
106
+ <CheckboxField
107
+ label="Store server logs in memory"
108
+ hint="Temporary; disabled and cleared when the server process starts or this setting is turned off."
109
+ checked={config.inMemoryLogs.enabled}
110
+ disabled={inMemoryLogsSaving}
111
+ onChange={(event) => void updateInMemoryLogs(event.currentTarget.checked)}
112
+ />
91
113
  </FormSection>
92
114
  ) : null}
93
115
  </>
@@ -1618,6 +1618,32 @@ textarea::placeholder {
1618
1618
  resize: vertical;
1619
1619
  }
1620
1620
 
1621
+ .wapp-checkbox-field {
1622
+ display: flex;
1623
+ align-items: flex-start;
1624
+ gap: 0.5rem;
1625
+ }
1626
+
1627
+ .wapp-checkbox-field input {
1628
+ width: 1rem;
1629
+ min-height: 1rem;
1630
+ margin-top: 0.125rem;
1631
+ flex: 0 0 auto;
1632
+ }
1633
+
1634
+ .wapp-checkbox-field span {
1635
+ font-size: 0.8125rem;
1636
+ font-weight: 500;
1637
+ }
1638
+
1639
+ .wapp-checkbox-field small {
1640
+ display: block;
1641
+ color: var(--wapp-muted);
1642
+ font-size: 0.75rem;
1643
+ font-weight: 400;
1644
+ margin-top: 0.25rem;
1645
+ }
1646
+
1621
1647
  .wapp-field small {
1622
1648
  display: block;
1623
1649
  color: var(--wapp-muted);
@@ -1,5 +1,5 @@
1
1
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
- import type { WebAppConfigResponse } from "../contracts";
2
+ import { VALID_LOG_LEVELS, type WebAppConfigResponse } from "../contracts";
3
3
  import { appJson } from "./api-client";
4
4
 
5
5
  export interface WebAppConfigState {
@@ -16,7 +16,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
16
16
  }
17
17
 
18
18
  function isLogLevelName(value: unknown): boolean {
19
- return value === "trace" || value === "debug" || value === "info" || value === "warn" || value === "error";
19
+ return typeof value === "string" && VALID_LOG_LEVELS.includes(value as WebAppConfigResponse["logLevel"]["level"]);
20
20
  }
21
21
 
22
22
  function isCurrentUser(value: unknown): boolean {
@@ -50,6 +50,7 @@ function isWebAppConfigResponse(value: unknown): value is WebAppConfigResponse {
50
50
  && isRecord(value["logLevel"])
51
51
  && isLogLevelName(value["logLevel"]["level"])
52
52
  && typeof value["logLevel"]["fromEnv"] === "boolean"
53
+ && hasBooleanFields(value["inMemoryLogs"], ["enabled"])
53
54
  && hasBooleanFields(value["deviceAuth"], ["enabled"])
54
55
  && hasBooleanFields(value["apiKeys"], ["enabled"]);
55
56
  }