@singhak/nodeui-core 0.3.0 → 0.5.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/dist/index.d.mts CHANGED
@@ -1,6 +1,17 @@
1
1
  import { IncomingMessage, ServerResponse } from 'node:http';
2
2
  import { Readable } from 'node:stream';
3
3
 
4
+ interface OtlpOptions {
5
+ /** Collector base URL (e.g. `http://localhost:4318`) or a full `/v1/traces` URL. */
6
+ endpoint: string;
7
+ /** `service.name` resource attribute. Default `OTEL_SERVICE_NAME` or `nodeui-app`. */
8
+ serviceName?: string;
9
+ /** Extra HTTP headers, e.g. an auth header for a hosted collector. */
10
+ headers?: Record<string, string>;
11
+ /** Export interval. Default 2000 ms. */
12
+ intervalMs?: number;
13
+ }
14
+
4
15
  /**
5
16
  * Shared typed contract for the NodeUI developer console.
6
17
  *
@@ -8,7 +19,8 @@ import { Readable } from 'node:stream';
8
19
  * contract. Every provider returns a {@link ProviderResult}; every REST
9
20
  * endpoint returns an {@link ApiEnvelope} which is the same shape.
10
21
  */
11
- type BuiltInPanelId = 'health' | 'memory' | 'cpu' | 'event-loop' | 'heap-snapshot' | 'startup' | 'requests' | 'env' | 'routes' | 'logs' | 'metrics' | 'outgoing';
22
+
23
+ type BuiltInPanelId = 'health' | 'memory' | 'cpu' | 'event-loop' | 'heap-snapshot' | 'startup' | 'requests' | 'env' | 'routes' | 'logs' | 'metrics' | 'outgoing' | 'errors' | 'queries';
12
24
  /**
13
25
  * Panel identifier. Built-in panels are listed in {@link BuiltInPanelId};
14
26
  * plugins may register any other string id (lowercase letters, digits, `-`).
@@ -34,6 +46,16 @@ interface NodeUIConfig {
34
46
  activationReason: string;
35
47
  /** Whether secret masking is applied to panel output. Default true. */
36
48
  maskSecrets: boolean;
49
+ /** What extra request detail is recorded. */
50
+ requestDetail: Required<RequestDetailOptions>;
51
+ /** `'always'` runs outgoing/query/log capture from startup; `'lazy'` only while a panel is open. */
52
+ capture: 'always' | 'lazy';
53
+ /** Journal file for persisting recent activity across restarts, or null. */
54
+ persistFile: string | null;
55
+ /** Rotation threshold for the journal. */
56
+ persistMaxBytes: number;
57
+ /** OTLP/HTTP trace export settings, or null when disabled. */
58
+ otlp: OtlpOptions | null;
37
59
  /** Idle time after which background samplers stop. Default 60000. */
38
60
  inactivityTimeoutMs: number;
39
61
  /** TTL for mutation confirmation nonces. Default 60000. */
@@ -154,10 +176,62 @@ interface RequestEntry {
154
176
  durationMs: number;
155
177
  timestampMs: number;
156
178
  ip: string;
179
+ /** Matched route pattern (e.g. `/users/:id`) when the framework exposes it. */
180
+ route?: string;
181
+ /** Query parameters (secret-looking keys redacted). */
182
+ query?: Record<string, string>;
183
+ /** Request headers (credentials always redacted). */
184
+ headers?: Record<string, string>;
185
+ /** Captured request body (opt-in, size-capped, masked). */
186
+ requestBody?: string;
187
+ requestBodyTruncated?: boolean;
188
+ /** Captured response body (opt-in, size-capped, masked). */
189
+ responseBody?: string;
190
+ responseBodyTruncated?: boolean;
191
+ }
192
+ /** The optional, privacy-sensitive part of a {@link RequestEntry}. */
193
+ type RequestDetail = Pick<RequestEntry, 'query' | 'headers' | 'requestBody' | 'requestBodyTruncated' | 'responseBody' | 'responseBodyTruncated'>;
194
+ interface RequestDetailOptions {
195
+ /** Record query parameters. Default true. */
196
+ query?: boolean;
197
+ /** Record request headers (credential headers are always redacted). Default true. */
198
+ headers?: boolean;
199
+ /** Record request/response bodies of textual content types. Default false. */
200
+ bodies?: boolean;
201
+ /** Max bytes kept per body. Default 4096, max 1048576. */
202
+ maxBodyBytes?: number;
203
+ }
204
+ interface RouteStat {
205
+ method: string;
206
+ path: string;
207
+ count: number;
208
+ avgMs: number;
209
+ p95Ms: number;
210
+ errors: number;
211
+ }
212
+ interface RequestsSummary {
213
+ /** Requests currently held in the ring buffer. */
214
+ count: number;
215
+ errors: number;
216
+ /** `errors / count` in 0..1 (5xx only). */
217
+ errorRate: number;
218
+ avgMs: number;
219
+ p50Ms: number;
220
+ p95Ms: number;
221
+ p99Ms: number;
222
+ byStatus: {
223
+ '2xx': number;
224
+ '3xx': number;
225
+ '4xx': number;
226
+ '5xx': number;
227
+ };
228
+ /** Busiest/slowest routes, slowest p95 first (at most 8). */
229
+ routes: RouteStat[];
157
230
  }
158
231
  interface RequestsData {
159
232
  total: number;
160
233
  entries: RequestEntry[];
234
+ summary: RequestsSummary;
161
235
  }
162
236
  interface EnvEntry {
163
237
  key: string;
@@ -180,6 +254,8 @@ interface LogEntry {
180
254
  level: LogLevel;
181
255
  message: string;
182
256
  timestamp: number;
257
+ /** Id of the app request being handled when this was logged. */
258
+ requestId?: number;
183
259
  }
184
260
  interface LogsData {
185
261
  entries: LogEntry[];
@@ -201,6 +277,53 @@ interface OutgoingRequestEntry {
201
277
  durationMs: number;
202
278
  timestampMs: number;
203
279
  error?: string;
280
+ /** Id of the app request that triggered this call. */
281
+ requestId?: number;
282
+ }
283
+ interface QueryEntry {
284
+ id: number;
285
+ /** Driver or ORM the query came from, e.g. `pg`, `mysql2`, `prisma`. */
286
+ system: string;
287
+ /** Statement text. Parameter values are never recorded. */
288
+ sql: string;
289
+ durationMs: number;
290
+ timestampMs: number;
291
+ rowCount?: number;
292
+ error?: string;
293
+ /** Id of the app request that issued the query. */
294
+ requestId?: number;
295
+ /** At or above the configured `slowQueryMs`. */
296
+ slow?: boolean;
297
+ /** Same statement repeated within one request (N+1 suspect). */
298
+ nPlusOne?: boolean;
299
+ repeats?: number;
300
+ }
301
+ interface QueriesData {
302
+ total: number;
303
+ failed: number;
304
+ slow: number;
305
+ nPlusOneGroups: number;
306
+ slowQueryMs: number;
307
+ entries: QueryEntry[];
308
+ }
309
+ type ErrorSource = 'request' | 'uncaught' | 'rejection' | 'manual';
310
+ /** Errors with the same fingerprint (type, message shape, top frames) grouped together. */
311
+ interface ErrorGroup {
312
+ id: string;
313
+ name: string;
314
+ message: string;
315
+ stack: string;
316
+ source: ErrorSource;
317
+ count: number;
318
+ firstSeenMs: number;
319
+ lastSeenMs: number;
320
+ lastRequestId?: number;
321
+ lastRoute?: string;
322
+ lastStatus?: number;
323
+ }
324
+ interface ErrorsData {
325
+ total: number;
326
+ groups: ErrorGroup[];
204
327
  }
205
328
  interface OutgoingData {
206
329
  total: number;
@@ -236,7 +359,7 @@ interface ConfigData {
236
359
  enabled: boolean;
237
360
  pattern: string;
238
361
  };
239
- authRequired: boolean;
362
+ locked: boolean;
240
363
  /** Plugin panel ids and titles, in registration order. */
241
364
  plugins: Array<{
242
365
  id: PanelId;
@@ -288,9 +411,13 @@ interface ActivationDecision {
288
411
  declare function resolveActivation(env: Record<string, string | undefined>): ActivationDecision;
289
412
  /** True when the socket address is a loopback address. */
290
413
  declare function isLoopbackAddress(address: string | undefined): boolean;
414
+ /** True when `entry` is an exact IP or an IPv4/IPv6 CIDR that {@link matchesAddress} understands. */
415
+ declare function isValidAddressEntry(entry: string): boolean;
291
416
  /**
292
- * True when `address` matches one of `allowed`, each an exact IP or an IPv4
293
- * CIDR (e.g. `172.17.0.0/16`, the default Docker bridge network).
417
+ * True when `address` matches one of `allowed`, each an exact IP or a CIDR
418
+ * (e.g. `172.17.0.0/16`, the default Docker bridge network, or `fd00::/8`).
419
+ * IPv4-mapped IPv6 addresses compare as their IPv4 form; IPv6 literals compare
420
+ * by value, so `::1` equals `0:0:0:0:0:0:0:1`.
294
421
  */
295
422
  declare function matchesAddress(address: string | undefined, allowed: readonly string[]): boolean;
296
423
  /** Extracts the lower-cased hostname from a `Host` header value (no port). */
@@ -363,6 +490,12 @@ declare class ProviderRegistry {
363
490
  all(): NodeUIProvider[];
364
491
  }
365
492
 
493
+ /**
494
+ * Directory holding the prebuilt console (`index.html` and assets). It ships
495
+ * inside this package, so the UI always matches the API it talks to. Use it to
496
+ * serve the console from your own host or gateway.
497
+ */
498
+ declare function uiAssetsDir(): string;
366
499
  interface StaticAsset {
367
500
  content: Readable;
368
501
  contentType: string;
@@ -495,6 +628,89 @@ declare class HealthProvider implements NodeUIProvider<HealthData> {
495
628
  }>;
496
629
  }
497
630
 
631
+ /** Same normalized statement this many times within one request is flagged as N+1. */
632
+ declare const N_PLUS_ONE_THRESHOLD = 5;
633
+ type Recorder$1 = (entry: Omit<QueryEntry, 'id'>) => void;
634
+ /** Loads an optional driver from the host app's dependency tree. */
635
+ type DriverLoader = (name: string) => unknown;
636
+ /** Collapses literals so repeated statements compare equal. */
637
+ declare function normalizeSql(sql: string): string;
638
+ /**
639
+ * Refcounted instrumentation of installed database drivers (`pg`, `mysql2`),
640
+ * resolved from the host app's dependencies. Drivers that are not installed are
641
+ * skipped. The last release restores the original methods.
642
+ */
643
+ declare function interceptQueries(record: Recorder$1, load?: DriverLoader): () => void;
644
+ interface QueriesProviderOptions {
645
+ size: number;
646
+ slowQueryMs: number;
647
+ loader?: DriverLoader;
648
+ }
649
+ /** Recent database queries with slow-query and N+1 flags. */
650
+ declare class QueriesProvider implements NodeUIProvider<QueriesData> {
651
+ private readonly options;
652
+ readonly id: "queries";
653
+ private buffer;
654
+ private nextId;
655
+ private release;
656
+ private failed;
657
+ /** Called with every newly recorded entry (persistence hook). */
658
+ onRecord?: (entry: QueryEntry) => void;
659
+ constructor(options: QueriesProviderOptions);
660
+ start(): void;
661
+ stop(): void;
662
+ record(entry: Omit<QueryEntry, 'id'>): void;
663
+ /** Re-inserts journaled entries without re-emitting them. */
664
+ restore(entries: readonly QueryEntry[]): void;
665
+ get(): {
666
+ ok: true;
667
+ data: QueriesData;
668
+ };
669
+ }
670
+
671
+ /** A single occurrence in a serializable form (for the persistence journal). */
672
+ interface ErrorEvent {
673
+ name: string;
674
+ message: string;
675
+ stack: string;
676
+ source: ErrorSource;
677
+ at: number;
678
+ requestId?: number;
679
+ route?: string;
680
+ status?: number;
681
+ }
682
+ /** Groups equal failures: same type, same message shape (numbers and ids collapsed), same origin. */
683
+ declare function fingerprintError(name: string, message: string, stack: string): string;
684
+ /**
685
+ * Groups errors by fingerprint with counts. Process-level failures are observed
686
+ * with `uncaughtExceptionMonitor`, which never changes Node's crash behaviour
687
+ * (unlike an `uncaughtException` or `unhandledRejection` listener, which would
688
+ * suppress the default exit).
689
+ */
690
+ declare class ErrorsProvider implements NodeUIProvider<ErrorsData> {
691
+ readonly id: "errors";
692
+ private groups;
693
+ private total;
694
+ private listener;
695
+ /** Called with every newly recorded occurrence (persistence hook). */
696
+ onRecord?: (event: ErrorEvent) => void;
697
+ /** Starts observing process-level failures. Idempotent. */
698
+ attach(): void;
699
+ detach(): void;
700
+ record(error: unknown, source: ErrorSource, context?: {
701
+ requestId?: number;
702
+ route?: string;
703
+ status?: number;
704
+ }): void;
705
+ /** Re-applies journaled occurrences without re-emitting them. */
706
+ restore(events: readonly ErrorEvent[]): void;
707
+ private apply;
708
+ get(): {
709
+ ok: true;
710
+ data: ErrorsData;
711
+ };
712
+ }
713
+
498
714
  type Recorder = (entry: Omit<OutgoingRequestEntry, 'id'>) => void;
499
715
  /**
500
716
  * Refcounted instrumentation of outgoing HTTP calls made via `http`,
@@ -509,10 +725,14 @@ declare class OutgoingProvider implements NodeUIProvider<OutgoingData> {
509
725
  private nextId;
510
726
  private failed;
511
727
  private release;
728
+ /** Called with every newly recorded entry (persistence hook). */
729
+ onRecord?: (entry: OutgoingRequestEntry) => void;
512
730
  constructor(size: number);
513
731
  start(): void;
514
732
  stop(): void;
515
733
  record(entry: Omit<OutgoingRequestEntry, 'id'>): void;
734
+ /** Re-inserts journaled entries without re-emitting them. */
735
+ restore(entries: readonly OutgoingRequestEntry[]): void;
516
736
  get(): {
517
737
  ok: true;
518
738
  data: OutgoingData;
@@ -534,13 +754,20 @@ declare class HeapSnapshotProvider implements NodeUIProvider<HeapSnapshotPanelDa
534
754
  takeSnapshot(ctx: ProviderContext): Promise<ProviderResult<HeapSnapshotData>>;
535
755
  }
536
756
 
757
+ declare function summarizeRequests(entries: readonly RequestEntry[]): RequestsSummary;
537
758
  /** Fixed-size ring buffer of recent HTTP requests, recorded via middleware hook. */
538
759
  declare class RequestsProvider implements NodeUIProvider<RequestsData> {
539
760
  readonly id: "requests";
540
761
  private buffer;
541
762
  private nextId;
763
+ /** Called with every newly recorded entry (persistence hook). */
764
+ onRecord?: (entry: RequestEntry) => void;
542
765
  constructor(size: number);
543
- record(entry: Omit<RequestEntry, 'id'>): void;
766
+ /** Reserves an id up front so in-flight work can be attributed to the request. */
767
+ reserveId(): number;
768
+ record(entry: Omit<RequestEntry, 'id'>, id?: number): void;
769
+ /** Re-inserts journaled entries without re-emitting them. */
770
+ restore(entries: readonly RequestEntry[]): void;
544
771
  get length(): number;
545
772
  get(): {
546
773
  ok: true;
@@ -641,6 +868,21 @@ declare class LogsProvider implements NodeUIProvider<LogsData> {
641
868
  };
642
869
  }
643
870
 
871
+ declare const DEFAULT_REQUEST_DETAIL: Required<RequestDetailOptions>;
872
+
873
+ /** Id of the app request the current async call chain belongs to, if any. */
874
+ declare function currentRequestId(): number | undefined;
875
+ /**
876
+ * Runs `fn` inside the request's context when it was recorded by the NodeUI
877
+ * middleware (otherwise just calls `fn`). For adapters that own the `next` call.
878
+ */
879
+ declare function runInRequestContext<T>(req: IncomingMessage, fn: () => T): T;
880
+ /**
881
+ * Attributes the rest of the current async chain to the request. For adapters
882
+ * (Fastify) whose hooks cannot wrap the downstream handler in `run`.
883
+ */
884
+ declare function enterRequestContext(req: IncomingMessage): void;
885
+
644
886
  interface SseStream {
645
887
  send(payload: unknown): void;
646
888
  heartbeat(): void;
@@ -673,6 +915,38 @@ interface NodeUIOptions {
673
915
  env?: Record<string, string | undefined>;
674
916
  /** Whether secret masking applies to panel output. Default true. */
675
917
  maskSecrets?: boolean;
918
+ /**
919
+ * Extra detail recorded per request: query, headers (credentials always
920
+ * redacted) and, opt-in, textual bodies (or `NODEUI_CAPTURE_BODIES=true`).
921
+ * `false` records none of it.
922
+ */
923
+ captureRequestDetail?: boolean | RequestDetailOptions;
924
+ /**
925
+ * Keep recent requests, outgoing calls, queries and errors in an NDJSON
926
+ * journal so they survive a restart (or `NODEUI_PERSIST_FILE`). Off by
927
+ * default. The file is private (0600), masked, and rotated at `maxBytes`
928
+ * (default 5 MiB). It holds whatever the console records, so keep it out of
929
+ * version control.
930
+ */
931
+ persist?: string | {
932
+ file: string;
933
+ maxBytes?: number;
934
+ };
935
+ /**
936
+ * When the outgoing-call, query and log instrumentation runs. `'always'`
937
+ * (default) records from startup, so you can open the console after a problem
938
+ * and still see what led to it. `'lazy'` installs the hooks only while a panel
939
+ * is open and removes them after `inactivityTimeoutMs`, for zero cost when
940
+ * nobody is looking (or `NODEUI_CAPTURE=lazy`). Buffers are bounded either way.
941
+ */
942
+ capture?: 'always' | 'lazy';
943
+ /**
944
+ * Export requests, outgoing calls and queries as OTLP/HTTP JSON spans to a
945
+ * collector (or `NODEUI_OTLP_ENDPOINT`). Off by default. Enabling it keeps
946
+ * outgoing and query instrumentation running and sends (masked) telemetry to
947
+ * the given endpoint, so point it at a local collector.
948
+ */
949
+ otlp?: string | OtlpOptions;
676
950
  /** Idle time after which background samplers stop. Default 60000. */
677
951
  inactivityTimeoutMs?: number;
678
952
  /** TTL for mutation confirmation nonces. Default 60000. */
@@ -703,6 +977,10 @@ interface NodeUIOptions {
703
977
  healthChecks?: Record<string, HealthCheck>;
704
978
  /** Max recorded outgoing HTTP calls. Default 200. */
705
979
  outgoingLogSize?: number;
980
+ /** Max recorded database queries. Default 200. */
981
+ queryLogSize?: number;
982
+ /** Queries at or above this duration are flagged slow. Default 100 ms. */
983
+ slowQueryMs?: number;
706
984
  }
707
985
  interface NodeUIServer {
708
986
  readonly config: NodeUIConfig;
@@ -726,6 +1004,35 @@ interface NodeUIServer {
726
1004
  level: LogLevel;
727
1005
  message: string;
728
1006
  }): void;
1007
+ /**
1008
+ * Records a database query from an ORM or driver hook that NodeUI does not
1009
+ * patch itself (Sequelize `logging`, TypeORM logger, ...). Parameters are not recorded.
1010
+ */
1011
+ recordQuery(query: {
1012
+ system: string;
1013
+ sql: string;
1014
+ durationMs: number;
1015
+ rowCount?: number;
1016
+ error?: string;
1017
+ }): void;
1018
+ /**
1019
+ * Subscribes to a Prisma client's query events. Create the client with
1020
+ * `log: [{ emit: 'event', level: 'query' }]`.
1021
+ */
1022
+ trackPrisma(client: {
1023
+ $on(event: 'query', cb: (e: {
1024
+ query: string;
1025
+ duration: number | bigint;
1026
+ }) => void): void;
1027
+ }): void;
1028
+ /**
1029
+ * Records a handled error (e.g. from a framework error hook) so it appears in
1030
+ * the Errors panel, attributed to the current request when there is one.
1031
+ */
1032
+ recordError(error: unknown, context?: {
1033
+ route?: string;
1034
+ status?: number;
1035
+ }): void;
729
1036
  /** Stops all timers and background samplers. */
730
1037
  shutdown(): void;
731
1038
  }
@@ -737,4 +1044,4 @@ type NodeUIMiddleware = (req: IncomingMessage, res: ServerResponse, next: () =>
737
1044
  declare function serializeEnvelope<T>(envelope: ApiEnvelope<T>, mask?: boolean): string;
738
1045
  declare function createNodeUI(options?: NodeUIOptions): NodeUIServer;
739
1046
 
740
- export { AUTH_COOKIE, type ActivationDecision, type ApiEnvelope, type BuiltInPanelId, type ConfigData, type ConfirmIssued, ConfirmationStore, type CpuClock, type CpuData, CpuProvider, type CpuUsageSnapshot, DEFAULT_LOG_SIZE, type EnvData, type EnvEntry, EnvProvider, type EventLoopClock, EventLoopLagProvider, type EventLoopSample, type GuardOptions, type GuardResult, type HealthCheck, type HealthCheckResult, type HealthData, HealthProvider, type HeapSnapshotData, type HeapSnapshotPanelData, HeapSnapshotProvider, type LogEntry, type LogLevel, type LogsData, LogsProvider, type MemoryData, MemoryProvider, type MetricsBucket, type MetricsData, MetricsProvider, type NodeUIConfig, type NodeUIMiddleware, type NodeUIOptions, type NodeUIProvider, type NodeUIServer, type OutgoingData, OutgoingProvider, type OutgoingRequestEntry, type PanelId, type ProviderContext, type ProviderError, ProviderRegistry, type ProviderResult, type RequestEntry, type RequestsData, RequestsProvider, RingBuffer, type RouteEntry, type RoutesData, RoutesProvider, SECRET_KEY_PATTERN, SECRET_MASKED, Sampler, type SamplerOptions, type SseStream, type StartupData, type StartupMark, StartupTracker, type StaticAsset, createGuard, createNodeUI, extractRoutes, hostnameFromHostHeader, interceptConsole, interceptOutgoing, isLoopbackAddress, isLoopbackHostname, maskSecretText, maskSecrets, matchesAddress, resolveActivation, resolveStaticAsset, serializeEnvelope, startSse };
1047
+ export { AUTH_COOKIE, type ActivationDecision, type ApiEnvelope, type BuiltInPanelId, type ConfigData, type ConfirmIssued, ConfirmationStore, type CpuClock, type CpuData, CpuProvider, type CpuUsageSnapshot, DEFAULT_LOG_SIZE, DEFAULT_REQUEST_DETAIL, type EnvData, type EnvEntry, EnvProvider, type ErrorGroup, type ErrorSource, type ErrorsData, ErrorsProvider, type EventLoopClock, EventLoopLagProvider, type EventLoopSample, type GuardOptions, type GuardResult, type HealthCheck, type HealthCheckResult, type HealthData, HealthProvider, type HeapSnapshotData, type HeapSnapshotPanelData, HeapSnapshotProvider, type LogEntry, type LogLevel, type LogsData, LogsProvider, type MemoryData, MemoryProvider, type MetricsBucket, type MetricsData, MetricsProvider, N_PLUS_ONE_THRESHOLD, type NodeUIConfig, type NodeUIMiddleware, type NodeUIOptions, type NodeUIProvider, type NodeUIServer, type OutgoingData, OutgoingProvider, type OutgoingRequestEntry, type PanelId, type ProviderContext, type ProviderError, ProviderRegistry, type ProviderResult, type QueriesData, QueriesProvider, type QueryEntry, type RequestDetail, type RequestDetailOptions, type RequestEntry, type RequestsData, RequestsProvider, type RequestsSummary, RingBuffer, type RouteEntry, type RouteStat, type RoutesData, RoutesProvider, SECRET_KEY_PATTERN, SECRET_MASKED, Sampler, type SamplerOptions, type SseStream, type StartupData, type StartupMark, StartupTracker, type StaticAsset, createGuard, createNodeUI, currentRequestId, enterRequestContext, extractRoutes, fingerprintError, hostnameFromHostHeader, interceptConsole, interceptOutgoing, interceptQueries, isLoopbackAddress, isLoopbackHostname, isValidAddressEntry, maskSecretText, maskSecrets, matchesAddress, normalizeSql, resolveActivation, resolveStaticAsset, runInRequestContext, serializeEnvelope, startSse, summarizeRequests, uiAssetsDir };