@prsm/devtools 1.0.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 @@
1
+ :root{--bg: #0a0a0a;--bg-surface: #111111;--bg-raised: #1a1a1a;--bg-hover: #222222;--bg-active: #2a2a2a;--border: #2a2a2a;--border-subtle: #1e1e1e;--text: #d4d4d4;--text-bright: #e8e8e8;--text-muted: #555555;--accent: #34d399;--accent-dim: rgba(52, 211, 153, .12);--accent-text: #2dd4a2;--color-blue: #6ad;--color-red: #c55;--color-yellow: #b93;--color-green: #5a9;--syn-string: #a5d6a7;--syn-number: #4dd0e1;--syn-boolean: #ce93d8;--syn-null: #666666;--syn-key: #b0b0b0;--syn-bracket: #555555}*{box-sizing:border-box;margin:0;padding:0}body{font-family:SF Mono,Fira Code,JetBrains Mono,Cascadia Code,monospace;font-size:12px;line-height:1.5;color:var(--text);background:var(--bg);-webkit-font-smoothing:antialiased}::selection{background:var(--accent-dim);color:var(--accent-text)}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#3a3a3a}.app{display:flex;flex-direction:column;height:100vh;overflow:hidden}.top-bar{display:flex;align-items:center;justify-content:space-between;padding:0 16px;height:40px;border-bottom:1px solid var(--border);background:var(--bg-surface);flex-shrink:0}.top-bar-left{display:flex;align-items:center;gap:12px}.logo{font-size:11px;font-weight:600;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted)}.logo span{color:var(--accent)}.top-bar-right{display:flex;align-items:center;gap:12px}.nav-bar{display:flex;align-items:center;gap:0;border-bottom:1px solid var(--border);background:var(--bg-surface);flex-shrink:0;padding:0 16px}.nav-bar a{padding:8px 16px;font-size:11px;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;-webkit-user-select:none;user-select:none;text-decoration:none}.nav-bar a:hover{color:var(--text)}.nav-bar a.active{color:var(--accent-text);border-bottom-color:var(--accent)}.page-scroll{flex:1;overflow-y:auto}.page-content{max-width:1100px;padding:20px 24px 40px}.tab-bar{display:flex;align-items:center;gap:0;border-bottom:1px solid var(--border);background:var(--bg-surface);flex-shrink:0;padding:0 16px}.tab{padding:8px 16px;font-size:11px;color:var(--text-muted);cursor:pointer;border-bottom:2px solid transparent;-webkit-user-select:none;user-select:none}.tab:hover{color:var(--text)}.tab.active{color:var(--accent-text);border-bottom-color:var(--accent)}.tab .count{margin-left:6px;font-size:10px;color:var(--text-muted)}.tab.active .count{color:var(--accent)}.sidebar{width:280px;min-width:280px;border-right:1px solid var(--border);overflow-y:auto;background:var(--bg-surface)}.content{flex:1;overflow-y:auto;padding:16px}.sidebar-section{border-bottom:1px solid var(--border-subtle)}.sidebar-header{padding:8px 12px;font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);background:var(--bg)}.sidebar-item{display:flex;align-items:center;justify-content:space-between;padding:6px 12px;cursor:pointer;border-left:2px solid transparent}.sidebar-item:hover{background:var(--bg-hover)}.sidebar-item.active{background:var(--accent-dim);border-left-color:var(--accent)}.sidebar-item .label{font-size:11px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sidebar-item.active .label{color:var(--accent-text)}.sidebar-item .meta{font-size:10px;color:var(--text-muted);flex-shrink:0;margin-left:8px}.badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;font-size:10px;border-radius:9px;background:#1f1f1f;color:#999}.badge.accent{background:var(--accent-dim);color:var(--accent)}.section{margin-bottom:20px}.section-title{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);margin-bottom:8px}.card{background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;overflow:hidden}.card+.card{margin-top:8px}.card-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--bg);border-bottom:1px solid var(--border-subtle);font-size:11px}.card-header .name{color:var(--text-bright)}.card-body{padding:8px 12px}.kv-row{display:flex;align-items:baseline;padding:2px 0;font-size:11px}.kv-key{color:var(--text-muted);min-width:100px;flex-shrink:0}.kv-value{color:var(--text);word-break:break-all}.member-row{display:flex;align-items:center;justify-content:space-between;padding:4px 12px;font-size:11px;border-bottom:1px solid var(--border-subtle)}.member-row:last-child{border-bottom:none}.member-id{color:var(--text);font-size:11px}.member-presence{font-size:10px;color:var(--accent)}.tag{display:inline-block;padding:1px 6px;font-size:10px;border-radius:3px;background:#1f1f1f;color:#999;margin:1px 2px}.tag.accent{background:var(--accent-dim);color:var(--accent)}.empty{padding:24px;text-align:center;color:var(--text-muted);font-size:11px}.view-hint{font-size:11px;color:var(--text-muted);margin-bottom:12px;padding-bottom:8px;border-bottom:1px solid var(--border-subtle)}.no-presence{font-size:10px;color:#333}.pattern-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}.json-view{font-size:11px;line-height:1.6;white-space:pre-wrap;word-break:break-all}.json-view .shiki{background:transparent!important;padding:0;margin:0}.json-view .shiki code{font-family:inherit;font-size:inherit}.select{background:var(--bg-raised);border:1px solid var(--border);color:var(--text);font-family:inherit;font-size:11px;padding:4px 8px;border-radius:3px;outline:none;cursor:pointer}.select:focus{border-color:var(--accent)}.conn-link{cursor:pointer;text-decoration:underline;text-decoration-color:var(--border);text-underline-offset:2px}.conn-link:hover{color:var(--accent-text);text-decoration-color:var(--accent)}.exposed-row{display:flex;align-items:center;flex-wrap:wrap;gap:3px;padding:4px 12px;border-bottom:1px solid var(--border-subtle)}.exposed-row:last-child{border-bottom:none}.exposed-label{font-size:10px;color:var(--text-muted);min-width:70px;flex-shrink:0}.pulse{width:6px;height:6px;border-radius:50%;background:var(--accent)}.pulse.disconnected{background:#ef4444}.instance-id{font-size:10px;color:var(--text-muted)}section[data-v-1330d5ac]{margin-top:28px}.subsystems[data-v-1330d5ac]{display:flex;gap:8px;margin-bottom:20px}.chip[data-v-1330d5ac]{padding:5px 14px;font-size:11px;font-weight:500;color:var(--text-muted);background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;font-family:inherit;cursor:pointer}.chip.on[data-v-1330d5ac]{color:var(--text);border-color:#333}.chip.off[data-v-1330d5ac]{color:var(--text-muted);border-color:var(--border-subtle);opacity:.55}.chip[data-v-1330d5ac]:disabled{cursor:default;opacity:.35}.stream[data-v-1330d5ac]{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:4px;max-height:520px;overflow-y:auto}.event-row[data-v-1330d5ac]{display:flex;gap:0;align-items:center;padding:0;border-bottom:1px solid var(--bg-raised);font-size:11px}.event-row[data-v-1330d5ac]:last-child{border-bottom:none}.event-source[data-v-1330d5ac]{width:72px;padding:6px 8px;color:var(--text-muted);text-align:right;border-right:1px solid var(--bg-raised);flex-shrink:0}.event-action[data-v-1330d5ac]{width:152px;padding:6px 8px;color:var(--text-muted);flex-shrink:0}.event-action.complete[data-v-1330d5ac],.event-action.fire[data-v-1330d5ac],.event-action.execution-succeeded[data-v-1330d5ac],.event-action.step-succeeded[data-v-1330d5ac]{color:var(--color-green)}.event-action.step-routed[data-v-1330d5ac]{color:var(--color-blue)}.event-action.failed[data-v-1330d5ac],.event-action.error[data-v-1330d5ac],.event-action.execution-failed[data-v-1330d5ac],.event-action.step-failed[data-v-1330d5ac],.event-action.execution-lease-lost[data-v-1330d5ac]{color:var(--color-red)}.event-action.retry[data-v-1330d5ac],.event-action.step-retry[data-v-1330d5ac]{color:var(--color-yellow)}.event-data[data-v-1330d5ac]{flex:1;min-width:0;padding:6px 8px;color:#666;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.event-time[data-v-1330d5ac]{width:80px;padding:6px 8px;color:var(--text-muted);text-align:right;flex-shrink:0}.gauges[data-v-74a2dfa0]{display:flex;gap:12px;margin-bottom:8px}.gauge[data-v-74a2dfa0]{flex:1;padding:16px;background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:4px}.gauge-value[data-v-74a2dfa0]{display:block;font-size:28px;font-weight:600;color:var(--text-muted)}.gauge-value.lit[data-v-74a2dfa0]{color:var(--color-blue)}.gauge-value.warn[data-v-74a2dfa0]{color:var(--color-red)}.gauge-label[data-v-74a2dfa0]{display:block;font-size:10px;color:var(--text-muted);margin-top:4px;letter-spacing:.3px;text-transform:uppercase}.session-note[data-v-74a2dfa0]{font-size:10px;color:#333;margin-bottom:24px}section[data-v-74a2dfa0]{margin-top:24px}.stream[data-v-74a2dfa0]{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:4px;max-height:400px;overflow-y:auto}.event-row[data-v-74a2dfa0]{display:flex;gap:0;align-items:center;border-bottom:1px solid var(--bg-raised);font-size:11px}.event-row[data-v-74a2dfa0]:last-child{border-bottom:none}.event-action[data-v-74a2dfa0]{width:72px;padding:6px 10px;color:var(--text-muted);flex-shrink:0}.event-action.complete[data-v-74a2dfa0]{color:var(--color-green)}.event-action.failed[data-v-74a2dfa0]{color:var(--color-red)}.event-action.retry[data-v-74a2dfa0]{color:var(--color-yellow)}.event-action.new[data-v-74a2dfa0]{color:var(--text-muted)}.event-action.drain[data-v-74a2dfa0]{color:#666}.event-data[data-v-74a2dfa0]{flex:1;padding:6px 8px;color:var(--text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.event-time[data-v-74a2dfa0]{width:80px;padding:6px 8px;color:#333;text-align:right;flex-shrink:0}.job-row[data-v-6d545d29]{display:flex;align-items:center;padding:0;border-bottom:1px solid var(--border-subtle);font-size:11px}.job-row[data-v-6d545d29]:last-child{border-bottom:none}.job-row.header[data-v-6d545d29]{font-size:10px;color:var(--text-muted);letter-spacing:.3px;text-transform:uppercase;border-bottom:1px solid var(--border)}.job-row.header span[data-v-6d545d29]{padding:8px 12px}.col-name[data-v-6d545d29]{flex:1;padding:10px 12px}.col-next[data-v-6d545d29]{width:100px;padding:10px 12px;color:#666;text-align:right}.col-in[data-v-6d545d29]{width:60px;padding:10px 12px;color:var(--text-muted);text-align:right}.col-in.soon[data-v-6d545d29]{color:var(--color-blue)}.stream[data-v-6d545d29]{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:4px;max-height:400px;overflow-y:auto}.event-row[data-v-6d545d29]{display:flex;align-items:center;border-bottom:1px solid var(--bg-raised);font-size:11px}.event-row[data-v-6d545d29]:last-child{border-bottom:none}.event-action[data-v-6d545d29]{width:52px;padding:6px 10px;color:var(--text-muted);flex-shrink:0}.event-action.fire[data-v-6d545d29]{color:var(--color-green)}.event-action.error[data-v-6d545d29]{color:var(--color-red)}.event-name[data-v-6d545d29]{flex:1;padding:6px 8px;color:#666}.event-time[data-v-6d545d29]{width:80px;padding:6px 8px;color:#333;text-align:right;flex-shrink:0}.limiter-list[data-v-22c855ec]{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:24px}section[data-v-22c855ec]{margin-top:24px}.desc[data-v-22c855ec]{font-size:11px;color:var(--text-muted);margin-bottom:12px}.peek-form[data-v-22c855ec]{display:flex;gap:8px}.input[data-v-22c855ec]{padding:6px 10px;background:var(--bg-surface);border:1px solid var(--border);border-radius:4px;font-size:11px;color:var(--text);flex:1;font-family:inherit;outline:none}.input[data-v-22c855ec]::placeholder{color:#333}.input[data-v-22c855ec]:focus{border-color:var(--accent)}.btn[data-v-22c855ec]{padding:6px 16px;background:var(--bg-raised);color:#999;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:11px;font-family:inherit}.btn[data-v-22c855ec]:hover{color:var(--text)}.btn[data-v-22c855ec]:disabled{opacity:.3;cursor:default}.error[data-v-22c855ec]{margin-top:10px;color:var(--color-red);font-size:11px}.graph-shell[data-v-6098c89d]{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:4px;overflow:hidden;position:relative;cursor:grab;touch-action:none}.graph-shell[data-v-6098c89d]:active{cursor:grabbing}.graph-shell svg[data-v-6098c89d]{user-select:none;-webkit-user-select:none}.graph-shell.fullscreen[data-v-6098c89d]{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1000;border-radius:0;border:none;background:var(--bg)}.graph-controls[data-v-6098c89d]{position:absolute;top:10px;right:10px;z-index:2;display:flex;gap:4px}.graph-controls button[data-v-6098c89d]{padding:4px 10px;background:var(--bg-raised);border:1px solid var(--border);border-radius:4px;color:#888;font-size:11px;font-family:inherit;cursor:pointer}.graph-controls button[data-v-6098c89d]:hover{background:var(--bg-hover);color:#ccc}.graph[data-v-6098c89d]{width:100%;min-height:420px;display:block}.fullscreen .graph[data-v-6098c89d]{min-height:100vh}.edge[data-v-6098c89d]{fill:none;stroke:#232323;stroke-width:3}.edge-seen[data-v-6098c89d]{stroke:#3b3b3b}.edge-active[data-v-6098c89d]{stroke:#8fb3ff}.edge-label[data-v-6098c89d]{fill:#4a4a4a;font-size:11px;font-family:inherit}.node[data-v-6098c89d]{cursor:pointer}.node rect[data-v-6098c89d]{fill:#151515;stroke:#262626;stroke-width:1.5}.node.selected rect[data-v-6098c89d]{stroke:#6b7280}.node.current rect[data-v-6098c89d],.node.running rect[data-v-6098c89d]{stroke:#5b8cff}.node.succeeded rect[data-v-6098c89d],.node.decision-success rect[data-v-6098c89d]{stroke:#3d8f63}.node.failed rect[data-v-6098c89d]{stroke:#a14a4a}.node.activity .node-type[data-v-6098c89d]{fill:#7aa2f7}.node.decision .node-type[data-v-6098c89d]{fill:#e0af68}.node.succeed .node-type[data-v-6098c89d]{fill:#73daca}.node.fail .node-type[data-v-6098c89d]{fill:#f7768e}.node-name[data-v-6098c89d]{fill:var(--text-bright);font-size:13px;font-weight:600}.node-type[data-v-6098c89d],.node-meta[data-v-6098c89d]{font-family:inherit;font-size:11px}.node-meta[data-v-6098c89d]{fill:#5f5f5f}.workflow-layout[data-v-124a0c49]{display:grid;grid-template-columns:280px 1fr;gap:16px}.wf-sidebar[data-v-124a0c49]{display:flex;flex-direction:column;gap:8px}.wf-sidebar .card.active[data-v-124a0c49]{border-color:var(--accent)}.detail[data-v-124a0c49]{display:flex;flex-direction:column;gap:16px}.headline[data-v-124a0c49]{display:flex;justify-content:space-between;align-items:flex-end}.headline-name[data-v-124a0c49]{font-size:14px;font-weight:600;color:var(--text-bright)}.headline-sub[data-v-124a0c49]{margin-top:2px;color:var(--text-muted);font-size:11px}.headline-stats[data-v-124a0c49]{display:flex;gap:10px;color:var(--text-muted);font-size:11px}.filters[data-v-24631062]{display:flex;gap:8px;margin-bottom:16px}.execution-layout[data-v-24631062]{display:grid;grid-template-columns:240px minmax(0,1fr) 340px;gap:14px;align-items:start}.list[data-v-24631062]{background:var(--bg-surface);border:1px solid var(--border-subtle);border-radius:4px;overflow:hidden;max-height:calc(100vh - 240px);overflow-y:auto}.execution-row[data-v-24631062]{padding:10px 12px;border-bottom:1px solid var(--bg-raised);cursor:pointer}.execution-row[data-v-24631062]:last-child{border-bottom:none}.execution-row.active[data-v-24631062]{background:var(--bg-raised)}.execution-main[data-v-24631062]{display:flex;justify-content:space-between;gap:8px}.execution-workflow[data-v-24631062]{color:var(--text-bright);font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.execution-id[data-v-24631062]{color:#666;font-size:11px;flex-shrink:0}.execution-meta[data-v-24631062]{display:flex;justify-content:space-between;gap:6px;margin-top:4px;color:var(--text-muted);font-size:10px}.execution-row.running .execution-workflow[data-v-24631062]{color:#8fb3ff}.execution-row.failed .execution-workflow[data-v-24631062]{color:#d87a7a}.execution-row.succeeded .execution-workflow[data-v-24631062]{color:#7ec49b}.headline[data-v-24631062]{margin-bottom:14px}.headline-name[data-v-24631062]{font-size:14px;font-weight:600;color:var(--text-bright)}.headline-sub[data-v-24631062]{margin-top:2px;color:var(--text-muted);font-size:11px}.center[data-v-24631062]{display:flex;flex-direction:column;gap:12px;min-width:0}.inspector[data-v-24631062]{display:flex;flex-direction:column;gap:10px;max-height:calc(100vh - 240px);overflow-y:auto}.step-hint[data-v-24631062]{color:#383838;font-size:10px;margin-bottom:8px;font-style:italic}.timeline[data-v-24631062]{max-height:420px;overflow-y:auto}.timeline-row[data-v-24631062]{display:flex;gap:6px;align-items:baseline;border-bottom:1px solid var(--border-subtle);padding:6px 12px;font-size:10px;white-space:nowrap}.timeline-row[data-v-24631062]:last-child{border-bottom:none}.timeline-type[data-v-24631062]{color:#b1b1b1;flex-shrink:0}.timeline-step[data-v-24631062]{color:#666;flex:1;overflow:hidden;text-overflow:ellipsis}.timeline-time[data-v-24631062]{color:var(--text-muted);flex-shrink:0}.realtime-shell[data-v-d6b8bed2]{display:flex;flex-direction:column;flex:1;overflow:hidden}.rt-main[data-v-d6b8bed2]{display:flex;flex:1;overflow:hidden}.tab-bar-right[data-v-d6b8bed2]{margin-left:auto;display:flex;align-items:center;gap:8px}.tab-bar a[data-v-d6b8bed2]{text-decoration:none}
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>prsm devtools</title>
7
+ <style>
8
+ body { margin: 0; background: #0a0a0a; }
9
+ </style>
10
+ <script type="module" crossorigin src="./assets/index-CkwmeR8W.js"></script>
11
+ <link rel="stylesheet" crossorigin href="./assets/index-p_4czaPS.css">
12
+ </head>
13
+ <body>
14
+ <div id="app"></div>
15
+ </body>
16
+ </html>
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@prsm/devtools",
3
+ "version": "1.0.0",
4
+ "description": "Read-only Express middleware dashboard for observing @prsm infrastructure at runtime",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "default": "./src/index.js"
9
+ }
10
+ },
11
+ "files": [
12
+ "src",
13
+ "dist/client"
14
+ ],
15
+ "scripts": {
16
+ "build:client": "cd client && npm run build",
17
+ "prepublishOnly": "npm run build:client"
18
+ },
19
+ "keywords": [
20
+ "devtools",
21
+ "dashboard",
22
+ "queue",
23
+ "cron",
24
+ "rate-limit",
25
+ "realtime",
26
+ "websocket",
27
+ "observability"
28
+ ],
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "express": "^4.21.0"
32
+ },
33
+ "devDependencies": {
34
+ "@prsm/cron": "^1.0.0",
35
+ "@prsm/limit": "^1.1.0",
36
+ "@prsm/queue": "^2.0.0",
37
+ "cors": "^2.8.6",
38
+ "dotenv": "^16.4.7"
39
+ },
40
+ "engines": {
41
+ "node": ">=18"
42
+ }
43
+ }
package/src/index.js ADDED
@@ -0,0 +1,339 @@
1
+ import { Router, static as serveStatic } from 'express'
2
+ import { fileURLToPath } from 'node:url'
3
+ import { resolve, dirname } from 'node:path'
4
+ import { existsSync } from 'node:fs'
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url))
7
+ const clientDir = resolve(__dirname, '..', 'dist', 'client')
8
+
9
+ function patternToString(p) {
10
+ if (typeof p === 'string') return p
11
+ if (p instanceof RegExp) return p.toString()
12
+ return String(p)
13
+ }
14
+
15
+ /**
16
+ * @param {Object} options
17
+ * @param {import('@prsm/queue').default} [options.queue]
18
+ * @param {import('@prsm/cron').Cron} [options.cron]
19
+ * @param {Object<string, Object>} [options.limit] - named limiters
20
+ * @param {import('@prsm/workflow').WorkflowEngine} [options.workflow]
21
+ * @param {Object} [options.realtime] - RealtimeServer instance
22
+ * @returns {import('express').Router}
23
+ */
24
+ export function prsmDevtools(options = {}) {
25
+ const router = Router()
26
+ const { queue, cron, limit, workflow, realtime } = options
27
+ const sseClients = new Set()
28
+
29
+ function broadcast(event, data) {
30
+ const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
31
+ for (const res of sseClients) {
32
+ res.write(msg)
33
+ }
34
+ }
35
+
36
+ if (queue) {
37
+ for (const event of ['new', 'complete', 'retry', 'failed', 'drain']) {
38
+ queue.on(event, (data) => broadcast(`queue:${event}`, data ?? {}))
39
+ }
40
+ }
41
+
42
+ if (cron) {
43
+ cron.on('fire', (data) => broadcast('cron:fire', { name: data.name, tickId: data.tickId }))
44
+ cron.on('error', (data) => broadcast('cron:error', { name: data.name, error: data.error?.message }))
45
+ }
46
+
47
+ if (workflow) {
48
+ for (const event of [
49
+ 'execution:queued',
50
+ 'execution:succeeded',
51
+ 'execution:failed',
52
+ 'execution:canceled',
53
+ 'execution:lease-lost',
54
+ 'step:started',
55
+ 'step:succeeded',
56
+ 'step:routed',
57
+ 'step:retry',
58
+ 'step:failed',
59
+ ]) {
60
+ workflow.on(event, (data) => broadcast(`workflow:${event}`, data ?? {}))
61
+ }
62
+ }
63
+
64
+ router.get('/api/config', (_req, res) => {
65
+ res.json({
66
+ queue: !!queue,
67
+ cron: !!cron,
68
+ limit: limit ? Object.keys(limit) : [],
69
+ workflow: !!workflow,
70
+ realtime: !!realtime,
71
+ })
72
+ })
73
+
74
+ router.get('/api/events', (req, res) => {
75
+ res.setHeader('Content-Type', 'text/event-stream')
76
+ res.setHeader('Cache-Control', 'no-cache')
77
+ res.setHeader('Connection', 'keep-alive')
78
+ res.flushHeaders()
79
+
80
+ sseClients.add(res)
81
+ req.on('close', () => sseClients.delete(res))
82
+ })
83
+
84
+ if (queue) {
85
+ router.get('/api/queue', (_req, res) => {
86
+ res.json({ inFlight: queue.inFlight })
87
+ })
88
+ }
89
+
90
+ if (cron) {
91
+ router.get('/api/cron', (_req, res) => {
92
+ const jobs = cron.jobs.map((name) => ({
93
+ name,
94
+ nextFireTime: cron.nextFireTime(name),
95
+ }))
96
+ res.json({ jobs })
97
+ })
98
+ }
99
+
100
+ if (limit) {
101
+ router.get('/api/limits', (_req, res) => {
102
+ res.json({ limiters: Object.keys(limit) })
103
+ })
104
+
105
+ router.get('/api/limits/:name/peek/:key', async (req, res) => {
106
+ const limiter = limit[req.params.name]
107
+ if (!limiter) return res.status(404).json({ error: 'Limiter not found' })
108
+ if (!limiter.peek) return res.status(400).json({ error: 'Limiter does not support peek' })
109
+
110
+ const result = await limiter.peek(req.params.key)
111
+ res.json(result)
112
+ })
113
+ }
114
+
115
+ if (workflow) {
116
+ router.get('/api/workflows', (_req, res) => {
117
+ const workflows = workflow.listWorkflows().map((item) => ({
118
+ ...item,
119
+ graph: workflow.describe(item.name, item.version).graph,
120
+ }))
121
+ res.json({ workflows })
122
+ })
123
+
124
+ router.get('/api/workflows/describe', (req, res) => {
125
+ const name = req.query.name
126
+ const version = req.query.version
127
+
128
+ if (!name || typeof name !== 'string') {
129
+ return res.status(400).json({ error: 'name is required' })
130
+ }
131
+
132
+ try {
133
+ const data = workflow.describe(name, typeof version === 'string' ? version : undefined)
134
+ res.json(data)
135
+ } catch (error) {
136
+ res.status(404).json({ error: error.message })
137
+ }
138
+ })
139
+
140
+ router.get('/api/workflow/executions', async (req, res) => {
141
+ const filter = {}
142
+ if (typeof req.query.workflow === 'string' && req.query.workflow) filter.workflow = req.query.workflow
143
+ if (typeof req.query.status === 'string' && req.query.status) filter.status = req.query.status
144
+ if (typeof req.query.limit === 'string' && req.query.limit) filter.limit = Number(req.query.limit)
145
+
146
+ const executions = await workflow.listExecutions(filter)
147
+ res.json({ executions })
148
+ })
149
+
150
+ router.get('/api/workflow/executions/:id', async (req, res) => {
151
+ const execution = await workflow.getExecution(req.params.id)
152
+ if (!execution) return res.status(404).json({ error: 'Execution not found' })
153
+ res.json({ execution })
154
+ })
155
+ }
156
+
157
+ if (realtime) {
158
+ realtime.onRecordUpdate(({ recordId, value }) => {
159
+ broadcast('realtime:record:update', { recordId, data: value })
160
+ })
161
+
162
+ realtime.onRecordRemoved(({ recordId }) => {
163
+ broadcast('realtime:record:removed', { recordId })
164
+ })
165
+
166
+ router.get('/api/realtime/state', async (_req, res) => {
167
+ try {
168
+ const connIds = await realtime.connectionManager.getAllConnectionIds()
169
+ const allMeta = await realtime.connectionManager._getMetadataForConnectionIds(connIds)
170
+ const localConns = realtime.connectionManager.getLocalConnections()
171
+ const localMap = {}
172
+ for (const conn of localConns) {
173
+ localMap[conn.id] = conn
174
+ }
175
+
176
+ const connections = allMeta.map(({ id, metadata }) => {
177
+ const local = localMap[id]
178
+ return {
179
+ id,
180
+ metadata,
181
+ local: !!local,
182
+ latency: local?.latency?.ms ?? null,
183
+ alive: local?.alive ?? null,
184
+ remoteAddress: local?.remoteAddress ?? null,
185
+ }
186
+ })
187
+
188
+ const roomNames = await realtime.roomManager.getAllRooms()
189
+ const rooms = []
190
+ for (const name of roomNames) {
191
+ const members = await realtime.roomManager.getRoomConnectionIds(name)
192
+ const statesMap = await realtime.presenceManager.getAllPresenceStates(name)
193
+ const presence = {}
194
+ statesMap.forEach((state, connId) => { presence[connId] = state })
195
+ rooms.push({ name, members, presence })
196
+ }
197
+
198
+ const channels = {}
199
+ for (const [channel, subscribers] of Object.entries(realtime.channelManager.channelSubscriptions)) {
200
+ if (channel.startsWith('mesh:presence:updates:')) continue
201
+ channels[channel] = [...subscribers].map((c) => c.id)
202
+ }
203
+
204
+ const collections = {}
205
+ realtime.collectionManager.collectionSubscriptions.forEach((subs, collId) => {
206
+ const subscribers = {}
207
+ subs.forEach((info, connId) => { subscribers[connId] = info })
208
+ collections[collId] = { subscribers }
209
+ })
210
+
211
+ const records = {}
212
+ realtime.recordSubscriptionManager.recordSubscriptions.forEach((subs, recordId) => {
213
+ const subscribers = {}
214
+ subs.forEach((mode, connId) => { subscribers[connId] = mode })
215
+ records[recordId] = { subscribers }
216
+ })
217
+
218
+ const exposed = {
219
+ channels: realtime.channelManager.exposedChannels.map(patternToString),
220
+ records: realtime.recordSubscriptionManager.exposedRecords.map(patternToString),
221
+ writableRecords: realtime.recordSubscriptionManager.exposedWritableRecords.map(patternToString),
222
+ collections: realtime.collectionManager.exposedCollections.map((e) => patternToString(e.pattern)),
223
+ presence: realtime.presenceManager.trackedRooms.map(patternToString),
224
+ commands: realtime.commandManager.commands
225
+ ? Object.keys(realtime.commandManager.commands).filter((c) => !c.startsWith('mesh/'))
226
+ : [],
227
+ }
228
+
229
+ res.json({ instanceId: realtime.instanceId, connections, rooms, channels, collections, records, exposed })
230
+ } catch (err) {
231
+ res.status(500).json({ error: err.message })
232
+ }
233
+ })
234
+
235
+ router.get('/api/realtime/connection/:id', async (req, res) => {
236
+ try {
237
+ const { id } = req.params
238
+ const metadata = await realtime.connectionManager.getMetadata(id)
239
+ const rooms = await realtime.roomManager.getRoomsForConnection(id)
240
+
241
+ const presence = {}
242
+ for (const room of rooms) {
243
+ const state = await realtime.presenceManager.getPresenceState(id, room)
244
+ if (state) presence[room] = state
245
+ }
246
+
247
+ const channels = []
248
+ for (const [channel, subscribers] of Object.entries(realtime.channelManager.channelSubscriptions)) {
249
+ if (channel.startsWith('mesh:presence:updates:')) continue
250
+ for (const conn of subscribers) {
251
+ if (conn.id === id) { channels.push(channel); break }
252
+ }
253
+ }
254
+
255
+ const collections = []
256
+ realtime.collectionManager.collectionSubscriptions.forEach((subs, collId) => {
257
+ if (subs.has(id)) collections.push({ id: collId, ...subs.get(id) })
258
+ })
259
+
260
+ const records = []
261
+ realtime.recordSubscriptionManager.recordSubscriptions.forEach((subs, recordId) => {
262
+ if (subs.has(id)) records.push({ id: recordId, mode: subs.get(id) })
263
+ })
264
+
265
+ const local = realtime.connectionManager.getLocalConnection(id)
266
+
267
+ res.json({
268
+ id,
269
+ metadata,
270
+ rooms,
271
+ presence,
272
+ channels,
273
+ collections,
274
+ records,
275
+ local: !!local,
276
+ latency: local?.latency?.ms ?? null,
277
+ alive: local?.alive ?? null,
278
+ remoteAddress: local?.remoteAddress ?? null,
279
+ })
280
+ } catch (err) {
281
+ res.status(500).json({ error: err.message })
282
+ }
283
+ })
284
+
285
+ router.get('/api/realtime/room/:name', async (req, res) => {
286
+ try {
287
+ const { name } = req.params
288
+ const membersWithMeta = await realtime.getRoomMembersWithMetadata(name)
289
+ const statesMap = await realtime.presenceManager.getAllPresenceStates(name)
290
+ const presence = {}
291
+ statesMap.forEach((state, connId) => { presence[connId] = state })
292
+ res.json({ name, members: membersWithMeta, presence })
293
+ } catch (err) {
294
+ res.status(500).json({ error: err.message })
295
+ }
296
+ })
297
+
298
+ router.get('/api/realtime/record/:id', async (req, res) => {
299
+ try {
300
+ const data = await realtime.recordManager.getRecord(req.params.id)
301
+ res.json({ id: req.params.id, data })
302
+ } catch (err) {
303
+ res.status(500).json({ error: err.message })
304
+ }
305
+ })
306
+
307
+ router.get('/api/realtime/collection/:id/records', async (req, res) => {
308
+ try {
309
+ const collId = req.params.id
310
+ const connId = req.query.connId
311
+ if (!connId) return res.status(400).json({ error: 'connId query param required' })
312
+
313
+ const raw = await realtime.redisManager.redis.get(`mesh:collection:${collId}:${connId}`)
314
+ if (!raw) return res.json({ recordIds: [], records: [] })
315
+
316
+ const recordIds = JSON.parse(raw)
317
+ const records = []
318
+ for (const rid of recordIds) {
319
+ const data = await realtime.recordManager.getRecord(rid)
320
+ records.push({ id: rid, data })
321
+ }
322
+ res.json({ recordIds, records })
323
+ } catch (err) {
324
+ res.status(500).json({ error: err.message })
325
+ }
326
+ })
327
+ }
328
+
329
+ if (existsSync(clientDir)) {
330
+ router.use(serveStatic(clientDir))
331
+ router.get('*', (_req, res) => {
332
+ res.sendFile(resolve(clientDir, 'index.html'))
333
+ })
334
+ }
335
+
336
+ return router
337
+ }
338
+
339
+ export default prsmDevtools