@dunx/dashboard 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.
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What the dashboard needs from the things it reports on, restated structurally.
3
+ *
4
+ * **This package depends on `@dunx/infra` not at all**, and on `bullmq` not at
5
+ * all - the same choice `@dunx/auth` makes with `DrizzleSource` and `RedisStore`.
6
+ * A dashboard that peer-depended on the queue library would oblige an app with no
7
+ * queues to install it to see its routes, and would put a build-order edge between
8
+ * two packages that never call each other.
9
+ *
10
+ * This list used to be twice as long. `DashboardQueue` and `DashboardJob` restated
11
+ * bullmq's `Queue` and `Job` in enough detail to drive a queue table - signatures
12
+ * shaped to satisfy bullmq's own variance, with a paragraph explaining why. All of
13
+ * it went when bull-board took the queue UI back: the queue object is now passed
14
+ * straight through to `BullMQAdapter`, so there is nothing left to describe.
15
+ *
16
+ * Everything here is satisfied by an object an app already has:
17
+ *
18
+ * | This | Satisfied by |
19
+ * | ---------------- | ---------------------------------------- |
20
+ * | `QueueSource` | `JobPublisher` from `@dunx/infra/queue` |
21
+ * | `RedisProbe` | `RedisConnection` from `@dunx/infra/redis` |
22
+ * | `ConfigValues` | `ConfigService` from `@dunx/core` |
23
+ *
24
+ * No adapter, no wrapper - `queues: publisher` in the options is the whole wiring,
25
+ * which is what makes the restatement worth its lines rather than a tax.
26
+ */
27
+ /**
28
+ * The validated configuration. `ConfigService` satisfies it as written.
29
+ *
30
+ * Passed in rather than resolved from the container, and that is deliberate twice
31
+ * over. Mechanically, `inject()` only works inside a class the container builds and
32
+ * this middleware is built by a factory. But the better reason is that showing an
33
+ * app's configuration should be something the app **says yes to** - the same
34
+ * instinct behind `reveal` defaulting to revealing nothing.
35
+ */
36
+ export interface ConfigValues {
37
+ /**
38
+ * `object`, not `Record<string, unknown>`: an app's `AppConfig` is an interface
39
+ * with no index signature, so the record type would reject the very
40
+ * `ConfigService` this exists to accept. It is enumerated, never read by a key
41
+ * this package knows.
42
+ */
43
+ readonly values: object;
44
+ }
45
+ /**
46
+ * Where queues come from. `JobPublisher` satisfies it as written.
47
+ *
48
+ * `opened` is what the publisher has opened *so far*, which is deliberately not the
49
+ * same as "every queue this app has" - a queue is a key prefix opened on first use,
50
+ * so a web process that has published to none has opened none. That is why
51
+ * `DashboardOptions.queueNames` exists: a process that consumes a queue it never
52
+ * publishes to has to name it, and the panel says which of the two it is showing.
53
+ */
54
+ export interface QueueSource {
55
+ readonly opened: readonly string[];
56
+ /**
57
+ * bullmq's `Queue`, handed to bull-board's `BullMQAdapter` untouched - which is
58
+ * why the return type is `unknown` rather than a restatement. dunx reads nothing
59
+ * off it and calls nothing on it; matching bullmq's own signatures here was a
60
+ * whole file of variance notes existing only to describe a UI dunx no longer
61
+ * renders.
62
+ */
63
+ queue(name: string): unknown;
64
+ }
65
+ /**
66
+ * Enough Redis to answer "is it up and what is it doing". `RedisConnection`
67
+ * satisfies it, and so does `Bun.RedisClient` with a `send`.
68
+ *
69
+ * `send` rather than a typed `info()`: `INFO` is one command whose reply is a text
70
+ * blob, and adding a method per Redis command to a restatement is how a
71
+ * restatement becomes a client library.
72
+ */
73
+ export interface RedisProbe {
74
+ readonly connected: boolean;
75
+ ping(message?: string): Promise<string>;
76
+ send(command: string, args?: readonly string[]): Promise<unknown>;
77
+ }
78
+ /** The state a probe reports. `unknown` is not `down`; see `StatusDot`. */
79
+ export type ProbeState = 'up' | 'down' | 'unknown';
80
+ export interface ProbeResult {
81
+ readonly state: ProbeState;
82
+ /** One line for the operator: a latency, a version, a failure message. */
83
+ readonly detail?: string;
84
+ }
85
+ /**
86
+ * Anything else worth a light on the page - a third-party API, a disk, a leader
87
+ * election. The dashboard awaits it with a timeout and never lets it throw into a
88
+ * response, so a probe that hangs costs one panel rather than the page.
89
+ */
90
+ export interface DashboardProbe {
91
+ readonly name: string;
92
+ check(): Promise<ProbeResult> | ProbeResult;
93
+ }
package/dist/html.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { DashboardOptions } from './options.js';
2
+ /** The id the bundle reads its meta from. Shared with `internal/dashboard-ui`. */
3
+ export declare const META_ELEMENT_ID = "dunx-dashboard-meta";
4
+ export declare const renderShell: (options: DashboardOptions, ui: string, favicon: string) => string;
@@ -0,0 +1,6 @@
1
+ export { DashboardModule } from './module.js';
2
+ export { DashboardMiddleware } from './middleware.js';
3
+ export { DashboardOptions, normalizeMount, type Authorize, type DashboardOptionsInit, type Reveal, } from './options.js';
4
+ export type { ConfigValues, DashboardProbe, ProbeResult, ProbeState, QueueSource, RedisProbe, } from './contracts.js';
5
+ export type { ConfigEntry, GatewayNode, MemoryReport, Meta, ModuleNode, ProbeReport, ProviderNode, QueuesReport, RedisAbsent, RedisReport, RouteNode, RuntimeReport, Snapshot, } from './api/types.js';
6
+ export { handleDashboard, type RouterDeps } from './router.js';
package/dist/index.js ADDED
@@ -0,0 +1,460 @@
1
+ // @bun
2
+ import {
3
+ __decorateElement,
4
+ __decoratorMetadata,
5
+ __decoratorStart,
6
+ __require,
7
+ __runInitializers,
8
+ snapshotOf
9
+ } from "./chunk-xg5554k6.js";
10
+
11
+ // src/module.ts
12
+ import {
13
+ Logger as Logger2,
14
+ Module,
15
+ provide,
16
+ ROOT_MODULE
17
+ } from "@dunx/core";
18
+
19
+ // src/middleware.ts
20
+ import { Logger } from "@dunx/core";
21
+
22
+ // src/board.ts
23
+ var load = async () => {
24
+ try {
25
+ const [api, bullmq, bun] = await Promise.all([
26
+ import("@bull-board/api"),
27
+ import("@bull-board/api/bullMQAdapter"),
28
+ import("@bull-board/bun")
29
+ ]);
30
+ return {
31
+ createBullBoard: api.createBullBoard,
32
+ BullMQAdapter: bullmq.BullMQAdapter,
33
+ BunAdapter: bun.BunAdapter
34
+ };
35
+ } catch {
36
+ return;
37
+ }
38
+ };
39
+ var boardNames = (options) => {
40
+ if (options.queues === undefined) {
41
+ return {
42
+ names: [],
43
+ unavailable: "This app passed no `queues` to DashboardModule. `JobPublisher` from " + "@dunx/infra/queue satisfies it as written."
44
+ };
45
+ }
46
+ const opened = options.queues.opened;
47
+ const names = [
48
+ ...opened,
49
+ ...options.queueNames.filter((name) => !opened.includes(name))
50
+ ].sort();
51
+ if (names.length === 0) {
52
+ return {
53
+ names,
54
+ unavailable: "This process has opened no queues. A queue it only consumes has never " + "been opened by the publisher - name it in DashboardOptions.queueNames."
55
+ };
56
+ }
57
+ return { names };
58
+ };
59
+ var uiConfigFor = (options, favicon) => ({
60
+ readOnlyMode: !options.commands,
61
+ boardTitle: `${options.title} queues`,
62
+ boardLogo: { path: favicon, width: 26, height: 26 },
63
+ favIcon: { default: favicon, alternative: favicon }
64
+ });
65
+ var buildBoard = async (options, basePath, favicon) => {
66
+ const { names, unavailable } = boardNames(options);
67
+ if (unavailable !== undefined || options.queues === undefined) {
68
+ return { unavailable: unavailable ?? "no queue source", queues: names };
69
+ }
70
+ const modules = await load();
71
+ if (modules === undefined) {
72
+ return {
73
+ unavailable: "bull-board is not installed. It is an optional peer, so add it with " + "`bun add @bull-board/api @bull-board/ui @bull-board/bun`.",
74
+ queues: names
75
+ };
76
+ }
77
+ const { createBullBoard, BullMQAdapter, BunAdapter } = modules;
78
+ const source = options.queues;
79
+ const serverAdapter = new BunAdapter;
80
+ serverAdapter.setBasePath(basePath);
81
+ createBullBoard({
82
+ queues: names.map((name) => new BullMQAdapter(source.queue(name))),
83
+ serverAdapter,
84
+ options: { uiConfig: uiConfigFor(options, favicon) }
85
+ });
86
+ const routes = serverAdapter.getRoutes();
87
+ const entry = routes[basePath]?.["GET"];
88
+ return {
89
+ routes,
90
+ queues: names,
91
+ ...entry === undefined ? {} : { entry }
92
+ };
93
+ };
94
+ var compile = (routes) => Object.entries(routes).map(([pattern, handlers]) => {
95
+ const raw = pattern.split("/").filter(Boolean);
96
+ const wildcard = raw.at(-1) === "*";
97
+ const segments = wildcard ? raw.slice(0, -1) : raw;
98
+ return {
99
+ segments,
100
+ wildcard,
101
+ literals: segments.filter((s) => !s.startsWith(":")).length,
102
+ handlers
103
+ };
104
+ }).sort((a, b) => b.literals - a.literals || b.segments.length - a.segments.length || Number(a.wildcard) - Number(b.wildcard));
105
+ var matches = (pattern, path) => {
106
+ if (pattern.wildcard) {
107
+ if (path.length < pattern.segments.length)
108
+ return false;
109
+ } else if (path.length !== pattern.segments.length) {
110
+ return false;
111
+ }
112
+ return pattern.segments.every((segment, index) => segment.startsWith(":") || segment === path[index]);
113
+ };
114
+ var matchBoard = (routes, method, pathname) => {
115
+ const exact = routes[pathname]?.[method];
116
+ if (exact)
117
+ return exact;
118
+ const path = pathname.split("/").filter(Boolean);
119
+ for (const pattern of compile(routes)) {
120
+ if (matches(pattern, path)) {
121
+ const handler = pattern.handlers[method];
122
+ if (handler)
123
+ return handler;
124
+ }
125
+ }
126
+ return;
127
+ };
128
+
129
+ // src/options.ts
130
+ class DashboardOptions {
131
+ path;
132
+ authorize;
133
+ title;
134
+ queues;
135
+ queueNames;
136
+ redis;
137
+ probes;
138
+ config;
139
+ reveal;
140
+ openApiPath;
141
+ pollMs;
142
+ probeTimeoutMs;
143
+ commands;
144
+ constructor(init = {}) {
145
+ this.path = normalizeMount(init.path ?? "/_dunx");
146
+ this.authorize = init.authorize;
147
+ this.title = init.title ?? "dunx";
148
+ this.queues = init.queues;
149
+ this.queueNames = init.queueNames ?? [];
150
+ this.redis = init.redis;
151
+ this.probes = init.probes ?? [];
152
+ this.config = init.config;
153
+ this.reveal = init.reveal ?? (() => false);
154
+ this.openApiPath = init.openApiPath;
155
+ this.pollMs = init.pollMs ?? 5000;
156
+ this.probeTimeoutMs = init.probeTimeoutMs ?? 2000;
157
+ this.commands = init.commands ?? true;
158
+ }
159
+ }
160
+ Object.defineProperty(DashboardOptions, Symbol.for("dunx.deps"), {
161
+ value: () => [{ unresolved: "init: DashboardOptionsInit = {}" }]
162
+ });
163
+ var normalizeMount = (path) => {
164
+ const trimmed = `/${path.split("/").filter(Boolean).join("/")}`;
165
+ if (trimmed === "/") {
166
+ throw new Error('DashboardOptions.path cannot be "/": the dashboard is a middleware that ' + "claims every path under its mount, so mounting it at the root would " + 'answer every request in the app. Use "/_dunx" or another prefix.');
167
+ }
168
+ return trimmed;
169
+ };
170
+
171
+ // src/api/bounded.ts
172
+ var bounded = async (work, ms, onTimeout) => {
173
+ let timer;
174
+ try {
175
+ return await Promise.race([
176
+ work(),
177
+ new Promise((resolve) => {
178
+ timer = setTimeout(() => resolve(onTimeout()), ms);
179
+ })
180
+ ]);
181
+ } finally {
182
+ if (timer !== undefined)
183
+ clearTimeout(timer);
184
+ }
185
+ };
186
+
187
+ // src/api/redis.ts
188
+ var message = (error) => error instanceof Error ? error.message : String(error);
189
+ var INFO_FIELDS = [
190
+ "redis_version",
191
+ "redis_mode",
192
+ "os",
193
+ "uptime_in_seconds",
194
+ "connected_clients",
195
+ "blocked_clients",
196
+ "used_memory_human",
197
+ "used_memory_peak_human",
198
+ "maxmemory_human",
199
+ "keyspace_hits",
200
+ "keyspace_misses",
201
+ "total_commands_processed",
202
+ "rejected_connections"
203
+ ];
204
+ var parseInfo = (raw) => {
205
+ const wanted = new Set(INFO_FIELDS);
206
+ const out = {};
207
+ for (const line of raw.split(/\r?\n/)) {
208
+ if (line === "" || line.startsWith("#"))
209
+ continue;
210
+ const colon = line.indexOf(":");
211
+ if (colon < 1)
212
+ continue;
213
+ const key = line.slice(0, colon);
214
+ if (wanted.has(key))
215
+ out[key] = line.slice(colon + 1);
216
+ }
217
+ return out;
218
+ };
219
+ var redisReport = async (redis, timeoutMs) => {
220
+ const started = performance.now();
221
+ const failed = (error) => ({
222
+ configured: true,
223
+ connected: redis.connected,
224
+ pingMs: undefined,
225
+ info: {},
226
+ error
227
+ });
228
+ return bounded(async () => {
229
+ try {
230
+ await redis.ping();
231
+ const pingMs = Math.round(performance.now() - started);
232
+ const raw = await redis.send("INFO", []);
233
+ return {
234
+ configured: true,
235
+ connected: redis.connected,
236
+ pingMs,
237
+ info: typeof raw === "string" ? parseInfo(raw) : {}
238
+ };
239
+ } catch (error) {
240
+ return failed(message(error));
241
+ }
242
+ }, timeoutMs, () => failed(`no answer in ${timeoutMs}ms`));
243
+ };
244
+
245
+ // src/api/runtime.ts
246
+ var withTimeout = (probe, ms) => bounded(async () => {
247
+ try {
248
+ return await probe.check();
249
+ } catch (error) {
250
+ return {
251
+ state: "down",
252
+ detail: error instanceof Error ? error.message : String(error)
253
+ };
254
+ }
255
+ }, ms, () => ({ state: "unknown", detail: `no answer in ${ms}ms` }));
256
+ var runProbe = async (probe, timeoutMs) => {
257
+ const started = performance.now();
258
+ const result = await withTimeout(probe, timeoutMs);
259
+ return {
260
+ name: probe.name,
261
+ state: result.state,
262
+ ...result.detail === undefined ? {} : { detail: result.detail },
263
+ ms: Math.round(performance.now() - started)
264
+ };
265
+ };
266
+ var redisProbe = (redis) => ({
267
+ name: "redis",
268
+ check: async () => {
269
+ const started = performance.now();
270
+ await redis.ping();
271
+ return {
272
+ state: "up",
273
+ detail: `PING ${Math.round(performance.now() - started)}ms`
274
+ };
275
+ }
276
+ });
277
+ var memory = () => {
278
+ const usage = process.memoryUsage();
279
+ return {
280
+ rss: usage.rss,
281
+ heapUsed: usage.heapUsed,
282
+ heapTotal: usage.heapTotal,
283
+ external: usage.external
284
+ };
285
+ };
286
+ var runtimeReport = async (options, startedAt) => {
287
+ const probes = [
288
+ ...options.redis ? [redisProbe(options.redis)] : [],
289
+ ...options.probes
290
+ ];
291
+ return {
292
+ pid: process.pid,
293
+ uptimeMs: Math.round(performance.now() - startedAt),
294
+ bun: Bun.version,
295
+ platform: process.platform,
296
+ arch: process.arch,
297
+ memory: memory(),
298
+ probes: await Promise.all(probes.map((probe) => runProbe(probe, options.probeTimeoutMs))),
299
+ now: Date.now()
300
+ };
301
+ };
302
+
303
+ // src/router.ts
304
+ var json = (body, status = 200) => Response.json(body, {
305
+ status,
306
+ headers: { "cache-control": "no-store" }
307
+ });
308
+ var fail = (status, error) => json({ error }, status);
309
+ var handleApi = async (deps, method, segments) => {
310
+ if (method !== "GET")
311
+ return fail(405, `${method} is not allowed here`);
312
+ switch (segments[0]) {
313
+ case "snapshot":
314
+ return json(snapshotOf(deps.root, deps.options));
315
+ case "runtime":
316
+ return json(await runtimeReport(deps.options, deps.startedAt));
317
+ case "redis":
318
+ return json(deps.options.redis === undefined ? { configured: false } : await redisReport(deps.options.redis, deps.options.probeTimeoutMs));
319
+ case "queues": {
320
+ const { names, unavailable } = boardNames(deps.options);
321
+ return json({
322
+ queues: names,
323
+ ...unavailable === undefined ? {} : { unavailable }
324
+ });
325
+ }
326
+ default:
327
+ return fail(404, "no such dashboard endpoint");
328
+ }
329
+ };
330
+ var handleDashboard = async (deps, request, rest) => {
331
+ const segments = rest.split("/").filter(Boolean);
332
+ const { method } = request;
333
+ if (segments[0] === "api") {
334
+ try {
335
+ return await handleApi(deps, method, segments.slice(1));
336
+ } catch (error) {
337
+ return fail(500, error instanceof Error ? error.message : String(error));
338
+ }
339
+ }
340
+ if (segments[0] === "queues") {
341
+ const board = await deps.board();
342
+ if (board.routes === undefined) {
343
+ return fail(503, board.unavailable ?? "no queue board");
344
+ }
345
+ const handler = matchBoard(board.routes, method, new URL(request.url).pathname);
346
+ if (handler !== undefined)
347
+ return handler(request);
348
+ if (method === "GET" && board.entry)
349
+ return board.entry(request);
350
+ return fail(404, "no such bull-board route");
351
+ }
352
+ if (method !== "GET")
353
+ return fail(405, `${method} is not allowed here`);
354
+ return new Response(await deps.page(), {
355
+ headers: {
356
+ "content-type": "text/html; charset=utf-8",
357
+ "cache-control": "no-store"
358
+ }
359
+ });
360
+ };
361
+
362
+ // src/middleware.ts
363
+ class DashboardMiddleware {
364
+ #options;
365
+ #deps;
366
+ #prefix;
367
+ #page;
368
+ #board;
369
+ constructor(options, root, logger) {
370
+ this.#options = options;
371
+ this.#prefix = `${options.path}/`;
372
+ this.#deps = {
373
+ root,
374
+ options,
375
+ startedAt: performance.now(),
376
+ page: () => this.#renderPage(),
377
+ board: () => this.#buildBoard()
378
+ };
379
+ if (options.authorize === undefined) {
380
+ logger.warn(`The dashboard at ${options.path} has no authorize function, so it is ` + "served to anyone who can reach this port - including the route table, " + "the provider graph and the config keys. Pass " + "DashboardModule.forRoot({ authorize }) unless this port is private.");
381
+ }
382
+ }
383
+ async handle(req, _ctx, next) {
384
+ const url = new URL(req.url);
385
+ const { pathname } = url;
386
+ if (pathname !== this.#options.path && !pathname.startsWith(this.#prefix)) {
387
+ return next();
388
+ }
389
+ if (this.#options.authorize && !await this.#options.authorize(req)) {
390
+ return Response.json({ error: "NOT_FOUND", status: 404 }, { status: 404 });
391
+ }
392
+ const rest = pathname.slice(this.#options.path.length);
393
+ return handleDashboard(this.#deps, req, rest);
394
+ }
395
+ #buildBoard() {
396
+ this.#board ??= import("./ui.js").then(({ FAVICON }) => buildBoard(this.#options, `${this.#options.path}/queues`, FAVICON));
397
+ return this.#board;
398
+ }
399
+ #renderPage() {
400
+ this.#page ??= import("./ui.js").then(({ renderPage }) => renderPage(this.#options));
401
+ return this.#page;
402
+ }
403
+ }
404
+ Object.defineProperty(DashboardMiddleware, Symbol.for("dunx.deps"), {
405
+ value: () => [DashboardOptions, { unresolved: "root: ModuleRef", typeOnly: "ModuleRef" }, Logger]
406
+ });
407
+
408
+ // src/module.ts
409
+ var middleware = () => provide(DashboardMiddleware, {
410
+ useFactory: (options, root, logger) => new DashboardMiddleware(options, root, logger),
411
+ inject: [DashboardOptions, ROOT_MODULE, Logger2]
412
+ });
413
+ var _dec = [
414
+ Module({})
415
+ ];
416
+ var _init = __decoratorStart(undefined);
417
+
418
+ class DashboardModule {
419
+ static forRoot(init = {}) {
420
+ return {
421
+ module: DashboardModule,
422
+ exports: [DashboardOptions, DashboardMiddleware],
423
+ providers: [
424
+ provide(DashboardOptions, { useValue: new DashboardOptions(init) }),
425
+ middleware()
426
+ ]
427
+ };
428
+ }
429
+ static forRootAsync(source) {
430
+ const load2 = typeof source === "function" ? source : source.useFactory;
431
+ const inject = typeof source === "function" ? [] : source.inject ?? [];
432
+ const imports = typeof source === "function" ? [] : source.imports ?? [];
433
+ return {
434
+ module: DashboardModule,
435
+ imports,
436
+ exports: [DashboardOptions, DashboardMiddleware],
437
+ providers: [
438
+ provide(DashboardOptions, {
439
+ useFactory: async (...deps) => new DashboardOptions(await load2(...deps)),
440
+ inject
441
+ }),
442
+ middleware()
443
+ ]
444
+ };
445
+ }
446
+ }
447
+ DashboardModule = __decorateElement(_init, 0, "DashboardModule", _dec, DashboardModule);
448
+ __runInitializers(_init, 1, DashboardModule);
449
+ __decoratorMetadata(_init, DashboardModule);
450
+ let _DashboardModule = DashboardModule;
451
+ export {
452
+ normalizeMount,
453
+ handleDashboard,
454
+ DashboardOptions,
455
+ DashboardModule,
456
+ DashboardMiddleware
457
+ };
458
+
459
+ //# debugId=9D760F53991891E164756E2164756E21
460
+ //# sourceMappingURL=index.js.map