@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 +19 -0
- package/docs/getting-started.md +27 -34
- package/docs/server.md +54 -3
- package/docs/settings.md +6 -5
- package/docs/ui-guidelines.md +1 -0
- 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 +13 -1
- package/src/web/app-shell.tsx +31 -17
- package/src/web/components/index.tsx +382 -19
- package/src/web/index.ts +3 -0
- package/src/web/logger.ts +44 -0
- package/src/web/motion.tsx +487 -0
- package/src/web/routing.ts +36 -5
- package/src/web/settings/account-section.tsx +3 -5
- package/src/web/settings/resource-state.tsx +12 -7
- package/src/web/sidebar-tree.tsx +15 -4
- package/src/web/styles.css +349 -3
- package/src/web/toast.tsx +67 -20
- package/src/web/webapp-config.tsx +2 -2
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
|
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/docs/ui-guidelines.md
CHANGED
|
@@ -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.
|
|
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": {
|
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
|
@@ -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} />
|