@pablozaiden/webapp 1.1.1 → 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.
- package/docs/getting-started.md +27 -34
- package/docs/server.md +54 -3
- package/docs/settings.md +6 -5
- package/package.json +2 -1
- package/src/contracts/index.ts +25 -1
- package/src/server/create-web-app-server.ts +2 -1
- package/src/server/index.ts +11 -0
- package/src/server/logger.ts +107 -42
- package/src/server/request-schemas.ts +2 -1
- package/src/server/runtime-config.ts +8 -6
- package/src/web/WebAppRoot.tsx +8 -0
- package/src/web/index.ts +1 -0
- package/src/web/logger.ts +44 -0
- package/src/web/settings/account-section.tsx +3 -5
- package/src/web/webapp-config.tsx +2 -2
package/docs/getting-started.md
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
189
|
-
reload; `fromEnv` indicates that the server
|
|
190
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pablozaiden/webapp",
|
|
3
|
-
"version": "1.
|
|
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": {
|
package/src/contracts/index.ts
CHANGED
|
@@ -1,4 +1,28 @@
|
|
|
1
|
-
export
|
|
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();
|
package/src/server/index.ts
CHANGED
|
@@ -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";
|
package/src/server/logger.ts
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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(
|
|
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
|
|
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" &&
|
|
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
|
|
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 ??
|
|
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,
|
package/src/web/WebAppRoot.tsx
CHANGED
|
@@ -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} />
|
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
|
|
2
|
+
import { VALID_LOG_LEVELS, type LogLevelName, type WebAppConfigResponse } from "../../contracts";
|
|
3
3
|
import { appJson } from "../api-client";
|
|
4
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
|
|
9
|
+
return VALID_LOG_LEVELS.some((level) => level === value);
|
|
12
10
|
}
|
|
13
11
|
|
|
14
12
|
export interface AccountSectionProps {
|
|
@@ -102,7 +100,7 @@ export function AccountSection({ config, refresh, setError }: AccountSectionProp
|
|
|
102
100
|
disabled={logLevel.fromEnv === true || logLevel.loading || logLevelSaving}
|
|
103
101
|
onChange={(event) => void updateLogLevel(event.currentTarget.value)}
|
|
104
102
|
>
|
|
105
|
-
{
|
|
103
|
+
{VALID_LOG_LEVELS.map((level) => <option key={level} value={level}>{level}</option>)}
|
|
106
104
|
</SelectField>
|
|
107
105
|
) : null}
|
|
108
106
|
<CheckboxField
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
2
|
-
import type
|
|
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
|
|
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 {
|