@pablozaiden/webapp 1.1.0 → 1.1.1

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.
@@ -190,6 +190,16 @@ reload; `fromEnv` indicates that the server environment locks the value.
190
190
  `LoggerAdapter` remains application-owned and can adapt the state to any
191
191
  logging library.
192
192
 
193
+ ### In-memory server logs
194
+
195
+ Admins can enable temporary server-log capture from Developer Settings next to
196
+ the log-level selector. The framework exposes the current state in
197
+ `/api/config`; it starts disabled on every process start and is never stored in
198
+ the database. Admin clients can retrieve a bounded chronological snapshot from
199
+ `GET /api/server/logs` after enabling capture. The buffer is limited to the
200
+ newest 1,000 framework logger entries or 512 KiB of rendered log lines, and
201
+ turning the setting off clears it.
202
+
193
203
  ### Transient notifications
194
204
 
195
205
  The standard `renderWebApp` runtime provides the framework notification service
package/docs/server.md CHANGED
@@ -153,6 +153,8 @@ Built-in endpoints include:
153
153
  | `/device` | Browser device-code approval screen |
154
154
  | `/.well-known/jwks.json`, `/.well-known/openid-configuration` | Token verification metadata |
155
155
  | `/api/preferences/theme`, `/api/preferences/log-level` | Settings persistence |
156
+ | `GET /api/server/logs` | Admin-only snapshot of the in-memory server logs |
157
+ | `PUT /api/server/logs/settings` | Admin-only runtime toggle for in-memory server logs |
156
158
  | `/api/server/kill` | Authenticated server shutdown |
157
159
  | `/api/ws` | Realtime websocket by default |
158
160
 
@@ -162,6 +164,18 @@ The effective log level in `GET /api/config` and `GET
162
164
  to `true`. PUT remains an authenticated, same-origin admin mutation and
163
165
  returns a conflict when the environment controls the value.
164
166
 
167
+ `GET /api/server/logs` is restricted to admin users and returns
168
+ `{ enabled: boolean, logs: ServerLogEntry[] }`. Entries are returned in
169
+ 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`
172
+ accepts `{ "enabled": boolean }`, is restricted to admins, and uses the normal
173
+ same-origin policy for browser mutations. API-key and bearer callers continue
174
+ to use the existing token-auth behavior. The in-memory buffer is limited to
175
+ 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
177
+ logger and never make the logs durable.
178
+
165
179
  Server-side applications that need credentials for internal runtimes should use
166
180
  the server-only `createManagedApiKey`, `listManagedApiKeys`, and
167
181
  `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
11
13
  - Admin server kill
12
14
  - Version/about
13
15
 
@@ -33,6 +35,12 @@ 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. 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.
43
+
36
44
  Security lists only show useful active user credentials. Managed API keys created by
37
45
  server-side applications, and their metadata, are not returned by the browser API-key
38
46
  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.1.1",
4
4
  "description": "Opinionated Bun + React webapp framework for single-server apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -114,6 +114,14 @@ export interface AuthSessionSummary {
114
114
  active: boolean;
115
115
  }
116
116
 
117
+ export interface ServerLogEntry {
118
+ timestamp: string;
119
+ level: LogLevelName;
120
+ scope: string;
121
+ message: string;
122
+ line: string;
123
+ }
124
+
117
125
  export interface WebAppConfigResponse {
118
126
  appName: string;
119
127
  version: string;
@@ -127,6 +135,9 @@ export interface WebAppConfigResponse {
127
135
  level: LogLevelName;
128
136
  fromEnv: boolean;
129
137
  };
138
+ inMemoryLogs: {
139
+ enabled: boolean;
140
+ };
130
141
  deviceAuth: {
131
142
  enabled: boolean;
132
143
  };
@@ -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, 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,7 @@ 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();
48
49
  const store = input.store ?? sqliteWebAppStore({ dataDir: config.dataDir });
49
50
  store.initialize();
50
51
  const savedLogLevel = store.getLogLevelPreference();
@@ -80,6 +81,7 @@ export function createWebAppServer<TEvent = unknown>(input: WebAppServerConfig<T
80
81
  deviceAuthEnabled,
81
82
  configResponse: input.configResponse,
82
83
  onLogLevelChange: input.logLevel?.onChange,
84
+ inMemoryLogs: inMemoryLogStorage,
83
85
  ensureWebDocument,
84
86
  });
85
87
  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;
@@ -1,4 +1,4 @@
1
- import type { LogLevelName } from "../contracts";
1
+ import type { LogLevelName, ServerLogEntry } from "../contracts";
2
2
 
3
3
  const ORDER: Record<LogLevelName, number> = {
4
4
  trace: 10,
@@ -8,7 +8,21 @@ const ORDER: Record<LogLevelName, number> = {
8
8
  error: 50,
9
9
  };
10
10
 
11
+ export const MAX_IN_MEMORY_LOG_ENTRIES = 1_000;
12
+ export const MAX_IN_MEMORY_LOG_BYTES = 512 * 1024;
13
+
11
14
  let currentLevel: LogLevelName = "info";
15
+ let inMemoryLogStorageEnabled = false;
16
+ let inMemoryLogEntries: ServerLogEntry[] = [];
17
+ let inMemoryLogBytes = 0;
18
+ const textEncoder = new TextEncoder();
19
+
20
+ export interface InMemoryLogStorage {
21
+ isEnabled(): boolean;
22
+ setEnabled(enabled: boolean): void;
23
+ getEntries(): ServerLogEntry[];
24
+ reset(): void;
25
+ }
12
26
 
13
27
  export function setLogLevel(level: LogLevelName): void {
14
28
  currentLevel = level;
@@ -18,6 +32,58 @@ export function getLogLevel(): LogLevelName {
18
32
  return currentLevel;
19
33
  }
20
34
 
35
+ function lineByteLength(line: string): number {
36
+ return textEncoder.encode(line).byteLength;
37
+ }
38
+
39
+ function appendInMemoryLogEntry(entry: ServerLogEntry): void {
40
+ if (!inMemoryLogStorageEnabled) {
41
+ return;
42
+ }
43
+ const entryBytes = lineByteLength(entry.line);
44
+ if (entryBytes > MAX_IN_MEMORY_LOG_BYTES) {
45
+ return;
46
+ }
47
+ inMemoryLogEntries.push(entry);
48
+ inMemoryLogBytes += entryBytes;
49
+ while (inMemoryLogEntries.length > MAX_IN_MEMORY_LOG_ENTRIES || inMemoryLogBytes > MAX_IN_MEMORY_LOG_BYTES) {
50
+ const removed = inMemoryLogEntries.shift();
51
+ if (!removed) {
52
+ break;
53
+ }
54
+ inMemoryLogBytes -= lineByteLength(removed.line);
55
+ }
56
+ }
57
+
58
+ export function setInMemoryLogStorageEnabled(enabled: boolean): void {
59
+ inMemoryLogStorageEnabled = enabled;
60
+ if (!enabled) {
61
+ inMemoryLogEntries = [];
62
+ inMemoryLogBytes = 0;
63
+ }
64
+ }
65
+
66
+ export function isInMemoryLogStorageEnabled(): boolean {
67
+ return inMemoryLogStorageEnabled;
68
+ }
69
+
70
+ export function getInMemoryLogEntries(): ServerLogEntry[] {
71
+ return inMemoryLogEntries.map((entry) => ({ ...entry }));
72
+ }
73
+
74
+ export function resetInMemoryLogStorage(): void {
75
+ inMemoryLogStorageEnabled = false;
76
+ inMemoryLogEntries = [];
77
+ inMemoryLogBytes = 0;
78
+ }
79
+
80
+ export const inMemoryLogStorage: InMemoryLogStorage = {
81
+ isEnabled: isInMemoryLogStorageEnabled,
82
+ setEnabled: setInMemoryLogStorageEnabled,
83
+ getEntries: getInMemoryLogEntries,
84
+ reset: resetInMemoryLogStorage,
85
+ };
86
+
21
87
  export function createLogger(scope: string) {
22
88
  function log(level: LogLevelName, message: string, fields?: Record<string, unknown>): void {
23
89
  if (ORDER[level] < ORDER[currentLevel]) {
@@ -26,6 +92,7 @@ export function createLogger(scope: string) {
26
92
  const timestamp = new Date().toISOString();
27
93
  const suffix = fields ? ` ${JSON.stringify(fields)}` : "";
28
94
  const line = `${timestamp}\t${level.toUpperCase()}\t${scope}\t${message}${suffix}`;
95
+ appendInMemoryLogEntry({ timestamp, level, scope, message, line });
29
96
  if (level === "error") {
30
97
  console.error(line);
31
98
  } else if (level === "warn") {
@@ -142,3 +142,7 @@ export const themePreferenceRequestSchema = z.object({
142
142
  export const logLevelPreferenceRequestSchema = z.object({
143
143
  level: z.enum(["trace", "debug", "info", "warn", "error"]),
144
144
  });
145
+
146
+ export const inMemoryLogSettingsRequestSchema = z.object({
147
+ enabled: z.boolean(),
148
+ });
@@ -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}>
@@ -1,7 +1,7 @@
1
1
  import { useState } from "react";
2
2
  import type { LogLevelName, 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
 
@@ -21,6 +21,7 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
21
21
  const { preference, setPreference, loading, error, retry } = useTheme();
22
22
  const logLevel = useLogLevel();
23
23
  const [logLevelSaving, setLogLevelSaving] = useState(false);
24
+ const [inMemoryLogsSaving, setInMemoryLogsSaving] = useState(false);
24
25
 
25
26
  async function updateLogLevel(value: string) {
26
27
  if (!isLogLevelName(value)) {
@@ -39,6 +40,22 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
39
40
  }
40
41
  }
41
42
 
43
+ async function updateInMemoryLogs(enabled: boolean) {
44
+ try {
45
+ setError(undefined);
46
+ setInMemoryLogsSaving(true);
47
+ await appJson<unknown>("/api/server/logs/settings", {
48
+ method: "PUT",
49
+ body: JSON.stringify({ enabled }),
50
+ });
51
+ await refresh();
52
+ } catch (err) {
53
+ setError(err instanceof Error ? err.message : String(err));
54
+ } finally {
55
+ setInMemoryLogsSaving(false);
56
+ }
57
+ }
58
+
42
59
  return (
43
60
  <>
44
61
  {config.currentUser ? (
@@ -88,6 +105,13 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
88
105
  {LOG_LEVEL_NAMES.map((level) => <option key={level} value={level}>{level}</option>)}
89
106
  </SelectField>
90
107
  ) : null}
108
+ <CheckboxField
109
+ label="Store server logs in memory"
110
+ hint="Temporary; disabled and cleared when the server process starts or this setting is turned off."
111
+ checked={config.inMemoryLogs.enabled}
112
+ disabled={inMemoryLogsSaving}
113
+ onChange={(event) => void updateInMemoryLogs(event.currentTarget.checked)}
114
+ />
91
115
  </FormSection>
92
116
  ) : null}
93
117
  </>
@@ -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);
@@ -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
  }