preparing for a conceptual refactoring...

This commit is contained in:
Relism
2026-03-28 14:11:12 +01:00
parent 7b996b552b
commit 2edd68b0aa
60 changed files with 3432 additions and 518 deletions
@@ -0,0 +1,100 @@
.app {
width: 100vw;
height: 100vh;
display: flex;
flex-direction: column;
background: #0f1117;
}
.topbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: #1a1d27;
border-bottom: 1px solid #2e3347;
flex-shrink: 0;
z-index: 10;
}
.logo { font-weight: 700; font-size: 15px; letter-spacing: -.3px; }
.badge {
background: #7c6af7;
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 99px;
}
.legend {
margin-left: auto;
display: flex;
gap: 16px;
font-size: 12px;
color: #8892a4;
}
.legend-item { display: flex; align-items: center; gap: 5px; }
.dot {
width: 10px; height: 10px;
border-radius: 50%;
display: inline-block;
}
.dot-dash {
border: 2px dashed;
background: transparent !important;
}
.center {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.muted { color: #8892a4; }
.error { color: #f87171; }
/* ReactFlow override */
.react-flow__renderer { flex: 1; }
/* Sidebar */
.sidebar-content {
padding: 16px 12px;
font-family: system-ui, sans-serif;
color: #e2e8f0;
overflow-y: auto;
flex: 1;
}
.sidebar-content h3 {
color: #e2e8f0;
}
/* Scrollbar styling */
.sidebar::-webkit-scrollbar {
width: 6px;
}
.sidebar::-webkit-scrollbar-track {
background: transparent;
}
.sidebar::-webkit-scrollbar-thumb {
background: #2e3347;
border-radius: 3px;
}
.sidebar::-webkit-scrollbar-thumb:hover {
background: #3d4557;
}
/* Hide interactivity toggle button in Controls */
.react-flow__controls button:nth-child(4) {
display: none;
}
/* Abstract node highlighting in MiniMap */
.react-flow__minimap-node[data-id*="Abstract"] {
stroke: #ef4444;
}
@@ -0,0 +1,401 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
import {
ReactFlow, Background, Controls, MiniMap, ReactFlowProvider,
useNodesState, useEdgesState, MarkerType, useReactFlow,
getNodesBounds, getViewportForBounds,
} from '@xyflow/react'
import { toPng } from 'html-to-image'
import '@xyflow/react/dist/style.css'
import { normalizeGraph } from './normalize.js'
import { layoutGraph } from './layout.js'
import HandlerNode from './nodes/HandlerNode.jsx'
import './App.css'
const nodeTypes = { handler: HandlerNode }
// ── Export helper ─────────────────────────────────────────────────────────────
function useExport(exportRef) {
const { getNodes } = useReactFlow()
useEffect(() => {
exportRef.current = () => {
const nodes = getNodes()
if (!nodes.length) return
const bounds = getNodesBounds(nodes)
const pad = 60
const imgW = Math.max(1920, bounds.width + pad * 2)
const imgH = Math.max(1080, bounds.height + pad * 2)
const vp = getViewportForBounds(bounds, imgW, imgH, 0.1, 4, pad)
toPng(document.querySelector('.react-flow__viewport'), {
backgroundColor: '#0f1117',
width: imgW,
height: imgH,
style: {
width: imgW + 'px',
height: imgH + 'px',
transform: `translate(${vp.x}px,${vp.y}px) scale(${vp.zoom})`,
},
}).then(url => {
const a = document.createElement('a')
a.download = 'flash-routes.png'
a.href = url
a.click()
}).catch(console.error)
}
}, [getNodes, exportRef])
}
// ── FlowContent ───────────────────────────────────────────────────────────────
function FlowContent({
styledNodes, styledEdges, onNodesChange, onEdgesChange,
onNodeMouseEnter, onNodeMouseLeave, showLambdas, searchQuery, exportRef,
}) {
const { fitView } = useReactFlow()
useExport(exportRef)
useEffect(() => {
setTimeout(() => fitView({ padding: 0.15, duration: 300 }), 50)
}, [showLambdas, searchQuery, fitView])
return (
<ReactFlow
nodes={styledNodes}
edges={styledEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeMouseEnter={onNodeMouseEnter}
onNodeMouseLeave={onNodeMouseLeave}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.15 }}
colorMode="dark"
minZoom={0.03}
maxZoom={2}
panOnDrag={true}
panOnScroll={true}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
zoomOnDoubleClick={true}
>
<Background color="#161822" gap={32} size={1} />
<Controls showInteractive={false}
style={{ background: '#12141c', border: '1px solid #1e2235' }} />
<MiniMap
style={{ background: '#12141c', border: '1px solid #1e2235' }}
maskColor="rgba(0,0,0,0.5)"
nodeColor={n => n.data?.isAbstract ? '#ef444499' : '#3b82f666'}
/>
</ReactFlow>
)
}
// ── Main ──────────────────────────────────────────────────────────────────────
export default function App() {
const [allNodes, setAllNodes] = useState([])
const [allEdges, setAllEdges] = useState([])
const [nodes, setNodes, onNodesChange] = useNodesState([])
const [edges, setEdges, onEdgesChange] = useEdgesState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [stats, setStats] = useState(null)
const [hoveredNode, setHoveredNode] = useState(null)
const [highlighted, setHighlighted] = useState({ nodes: new Set(), edges: new Set() })
const [showLambdas, setShowLambdas] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [exporting, setExporting] = useState(false)
const exportRef = useRef(null)
// ── Load data ──────────────────────────────────────────────────────────────
useEffect(() => {
fetch('/routeviewer/data')
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.json() })
.then(raw => {
const routeCount = raw.nodes.filter(n => n.type === 'route').length
const { nodes: n, edges: e } = normalizeGraph(raw.nodes, raw.edges)
const { nodes: ln, edges: le } = layoutGraph(n, e)
setAllNodes(ln)
setAllEdges(le)
setStats({ routes: routeCount })
setLoading(false)
})
.catch(err => { setError(err.message); setLoading(false) })
}, [])
// ── Ancestor set builder ───────────────────────────────────────────────────
const getAncestors = useMemo(() => {
const parentOf = new Map()
allEdges.forEach(e => { if (e.edgeType === 'extends') parentOf.set(e.target, e.source) })
return (id) => {
const set = new Set()
let cur = parentOf.get(id)
while (cur) { set.add(cur); cur = parentOf.get(cur) }
return set
}
}, [allEdges])
// ── Filter (lambda toggle + search) ───────────────────────────────────────
useEffect(() => {
const q = searchQuery.trim().toLowerCase()
const lambdaIds = new Set(allNodes.filter(n => n.data?.isLambda).map(n => n.id))
let visibleIds = new Set(allNodes.map(n => n.id))
if (q) {
const matched = new Set(
allNodes.filter(n =>
(n.data?.name || '').toLowerCase().includes(q) ||
(n.data?.routes || []).some(r => r.path.toLowerCase().includes(q))
).map(n => n.id)
)
const withAncestors = new Set(matched)
matched.forEach(id => getAncestors(id).forEach(a => withAncestors.add(a)))
visibleIds = withAncestors
}
const filteredNodes = allNodes.filter(n => {
if (lambdaIds.has(n.id) && !showLambdas) return false
return visibleIds.has(n.id)
})
const filteredIds = new Set(filteredNodes.map(n => n.id))
setNodes(filteredNodes)
setEdges(allEdges.filter(e => filteredIds.has(e.source) && filteredIds.has(e.target)))
}, [showLambdas, searchQuery, allNodes, allEdges, setNodes, setEdges, getAncestors])
// ── Hover ──────────────────────────────────────────────────────────────────
const onNodeMouseEnter = useCallback((_, node) => {
const parentOf = new Map()
edges.forEach(e => { if (e.edgeType === 'extends') parentOf.set(e.target, { pid: e.source, eid: e.id }) })
const visited = new Set([node.id])
const connEdges = new Set()
const q = [node.id]
while (q.length) {
const cur = q.shift()
const p = parentOf.get(cur)
if (p && !visited.has(p.pid)) { connEdges.add(p.eid); visited.add(p.pid); q.push(p.pid) }
}
setHoveredNode(node.id)
setHighlighted({ nodes: visited, edges: connEdges })
}, [edges])
const onNodeMouseLeave = useCallback(() => {
setHoveredNode(null)
setHighlighted({ nodes: new Set(), edges: new Set() })
}, [])
// ── Style pass ─────────────────────────────────────────────────────────────
const styledNodes = nodes.map(n => ({
...n,
style: { opacity: hoveredNode && !highlighted.nodes.has(n.id) ? 0.1 : 1, transition: 'opacity 0.15s' },
}))
const styledEdges = edges.map(e => {
const base = { type: 'bezier', pathOptions: { curvature: 0.35 },
style: { stroke: '#1e2235', strokeWidth: 1.5 },
markerEnd: { type: MarkerType.ArrowClosed, width: 10, height: 10, color: '#1e2235' } }
if (!hoveredNode) return { ...e, ...base }
if (highlighted.edges.has(e.id)) return {
...e, type: 'bezier', pathOptions: { curvature: 0.35 },
style: { stroke: '#60a5fa', strokeWidth: 2.5 },
markerEnd: { type: MarkerType.ArrowClosed, width: 13, height: 13, color: '#60a5fa' },
}
return { ...e, ...base, style: { ...base.style, opacity: 0.04 } }
})
const lambdaCount = allNodes.filter(n => n.data?.isLambda).length
const handlerCount = allNodes.filter(n => !n.data?.isLambda).length
const handleExport = useCallback(() => {
setExporting(true)
setTimeout(() => {
exportRef.current?.()
setTimeout(() => setExporting(false), 1200)
}, 50)
}, [])
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div style={{ display: 'flex', width: '100vw', height: '100vh', overflow: 'hidden', background: '#0f1117' }}>
{/* ── Sidebar ──────────────────────────────────────────────────────── */}
<aside style={{
width: '240px', minWidth: '240px', flexShrink: 0,
display: 'flex', flexDirection: 'column',
background: '#0c0e15',
borderRight: '1px solid #1a1d2a',
fontFamily: 'system-ui, sans-serif',
color: '#c8cfe0',
}}>
{/* Header */}
<div style={{
padding: '14px 16px 12px',
borderBottom: '1px solid #1a1d2a',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<span style={{ fontSize: 15, fontWeight: 700, letterSpacing: '-0.3px' }}> Route Viewer</span>
</div>
<div style={{ padding: '14px 14px', overflowY: 'auto', flex: 1 }}>
{/* Search */}
<Section label="SEARCH">
<input
type="text"
placeholder="handler or path…"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%', boxSizing: 'border-box',
background: '#12141e', border: '1px solid #1e2235',
borderRadius: 5, padding: '6px 9px',
color: '#c8cfe0', fontSize: 12, fontFamily: 'monospace',
outline: 'none',
}}
/>
{searchQuery && (
<div style={{ fontSize: 11, color: '#4a5370', marginTop: 5 }}>
{nodes.length} node{nodes.length !== 1 ? 's' : ''} visible
</div>
)}
</Section>
{/* Lambda toggle */}
<Section label="DISPLAY">
<label style={{ display: 'flex', alignItems: 'center', gap: 9, cursor: 'pointer', fontSize: 12 }}>
<input type="checkbox" checked={showLambdas}
onChange={e => setShowLambdas(e.target.checked)}
style={{ cursor: 'pointer', accentColor: '#3b82f6' }}
/>
<span style={{ color: '#8892a4' }}>Show lambda handlers</span>
</label>
<div style={{ fontSize: 11, color: '#343b54', marginTop: 4, paddingLeft: 21 }}>
{lambdaCount} lambda{lambdaCount !== 1 ? 's' : ''}
</div>
</Section>
{/* Stats */}
<Section label="STATS">
{[
['Routes', stats?.routes || 0],
['Handlers', handlerCount],
['Lambdas', lambdaCount],
].map(([k, v]) => (
<div key={k} style={{
display: 'flex', justifyContent: 'space-between',
fontSize: 12, marginBottom: 5,
}}>
<span style={{ color: '#4a5370' }}>{k}</span>
<span style={{ color: '#e2e8f0', fontWeight: 600, fontFamily: 'monospace' }}>{v}</span>
</div>
))}
</Section>
{/* Legend */}
<Section label="LEGEND">
{[
{ dot: '#3b82f6', border: '#3b82f6', label: 'Handler' },
{ dot: '#ef4444', border: '#ef4444', label: 'Abstract' },
{ dot: '#f6ad55', border: '#f6ad55', label: 'Middleware' },
].map(({ dot, label }) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<div style={{ width: 9, height: 9, borderRadius: 2, background: dot, flexShrink: 0, opacity: 0.8 }} />
<span style={{ fontSize: 12, color: '#4a5370' }}>{label}</span>
</div>
))}
</Section>
{/* Tips */}
<div style={{ fontSize: 11, color: '#2d3347', lineHeight: 1.65, marginTop: 4 }}>
Hover a node to trace its ancestry.<br />
Search filters nodes + parents.
</div>
</div>
{/* Export button */}
<div style={{ padding: '12px 14px', borderTop: '1px solid #1a1d2a' }}>
<button
onClick={handleExport}
disabled={exporting || loading}
style={{
width: '100%', padding: '8px 0',
background: exporting ? '#1e2235' : '#12141e',
border: '1px solid #1e2235',
borderRadius: 6, color: exporting ? '#4a5370' : '#8892a4',
fontSize: 12, cursor: exporting ? 'default' : 'pointer',
fontFamily: 'system-ui, sans-serif',
transition: 'all 0.15s',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
}}
>
{exporting ? '⏳ Exporting…' : '⬇ Export PNG'}
</button>
</div>
</aside>
{/* ── Graph area ────────────────────────────────────────────────────── */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'relative' }}>
{/* Topbar */}
<div style={{
display: 'flex', alignItems: 'center',
padding: '9px 18px',
background: '#0c0e15',
borderBottom: '1px solid #1a1d2a',
flexShrink: 0, zIndex: 10,
}}>
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0', letterSpacing: '-0.2px' }}>
Flash Route Graph
</span>
<span style={{
marginLeft: 'auto', display: 'flex', gap: 18,
fontSize: 11, color: '#2d3347', fontFamily: 'system-ui',
}}>
{[['#3b82f6','handler'],['#ef4444','abstract'],['#f6ad55','middleware']].map(([c,l]) => (
<span key={l} style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: c, display: 'inline-block', opacity: 0.8 }} />
{l}
</span>
))}
</span>
</div>
{loading && <div className="center muted">Loading</div>}
{error && <div className="center error">Error: {error}</div>}
{!loading && !error && (
<div style={{ flex: 1, position: 'relative' }}>
<ReactFlowProvider>
<FlowContent
styledNodes={styledNodes}
styledEdges={styledEdges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeMouseEnter={onNodeMouseEnter}
onNodeMouseLeave={onNodeMouseLeave}
showLambdas={showLambdas}
searchQuery={searchQuery}
exportRef={exportRef}
/>
</ReactFlowProvider>
</div>
)}
</div>
</div>
)
}
// ── Section helper ─────────────────────────────────────────────────────────────
function Section({ label, children }) {
return (
<div style={{ marginBottom: 18 }}>
<div style={{
fontSize: 9, fontWeight: 700, letterSpacing: 1,
color: '#272d42', marginBottom: 8, fontFamily: 'monospace',
}}>
{label}
</div>
{children}
</div>
)
}
@@ -0,0 +1,2 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #0f1117; color: #e2e8f0; font-family: 'Inter', system-ui, sans-serif; }
@@ -0,0 +1,153 @@
/**
* Custom recursive tree layout — Left to Right.
*
* Each node is centred vertically relative to its children's block.
* Siblings are packed tightly; different root trees are separated by ROOT_GAP.
* Lambda handlers are placed in a compact grid below the main tree.
*
* This avoids Dagre's global ranking which puts all siblings in a single
* long tower regardless of the number of nodes.
*/
// ── Constants ────────────────────────────────────────────────────────────────
const RANK_GAP = 60 // horizontal gap between a node's right edge and its children's left edge
const SIBLING_GAP = 12 // vertical gap between siblings that belong to the same parent
const ROOT_GAP = 48 // vertical gap between independent subtrees (different roots)
const MARGIN_X = 60
const MARGIN_Y = 60
const ABSTRACT_W = 160
const ABSTRACT_H = 50
const CONCRETE_W = 260
const LAMBDA_W = 230
const LAMBDA_H = 80
// ── Node sizing ──────────────────────────────────────────────────────────────
function nodeWidth(node) {
return node.data?.isAbstract ? ABSTRACT_W : CONCRETE_W
}
function nodeHeight(node) {
if (node.data?.isAbstract) return ABSTRACT_H
const routes = node.data?.routes?.length || 1
const hasMw = (node.data?.middleware?.length || 0) > 0
// header(44) + divider(9) + routes*22 + [mw row 22] + padding(20)
return 44 + 9 + routes * 22 + (hasMw ? 22 : 0) + 20
}
// ── Subtree height (recursive) ────────────────────────────────────────────────
function subtreeHeight(nodeId, childrenMap, nodeById) {
const kids = childrenMap.get(nodeId) || []
const selfH = nodeHeight(nodeById[nodeId])
if (!kids.length) return selfH
const kidsH = kids.reduce((acc, id, i) => {
return acc + subtreeHeight(id, childrenMap, nodeById) + (i > 0 ? SIBLING_GAP : 0)
}, 0)
return Math.max(selfH, kidsH)
}
// ── Place a node and its subtree ──────────────────────────────────────────────
function placeNode(nodeId, x, y, childrenMap, nodeById, positions) {
const node = nodeById[nodeId]
const kids = childrenMap.get(nodeId) || []
const selfH = nodeHeight(node)
const selfW = nodeWidth(node)
const totalH = subtreeHeight(nodeId, childrenMap, nodeById)
// Centre this node within the height its subtree occupies
positions.set(nodeId, { x, y: y + (totalH - selfH) / 2 })
if (kids.length) {
const childX = x + selfW + RANK_GAP
let childY = y
kids.forEach(kidId => {
const kidH = subtreeHeight(kidId, childrenMap, nodeById)
placeNode(kidId, childX, childY, childrenMap, nodeById, positions)
childY += kidH + SIBLING_GAP
})
}
}
// ── Main export ───────────────────────────────────────────────────────────────
export function layoutGraph(nodes, edges) {
// Separate lambdas from class-based handlers
const lambdas = nodes.filter(n => n.data?.isLambda)
const regulars = nodes.filter(n => !n.data?.isLambda)
const nodeById = Object.fromEntries(regulars.map(n => [n.id, n]))
const childrenMap = new Map() // parentId → [childId, ...]
const parentSet = new Set() // ids that have a parent
edges.forEach(e => {
if (e.edgeType !== 'extends') return
if (!childrenMap.has(e.source)) childrenMap.set(e.source, [])
childrenMap.get(e.source).push(e.target)
parentSet.add(e.target)
})
// Roots = regular nodes with no incoming extends edge
const roots = regulars.filter(n => !parentSet.has(n.id))
// Layout each root subtree
const positions = new Map()
let curY = MARGIN_Y
roots.forEach(root => {
const treeH = subtreeHeight(root.id, childrenMap, nodeById)
placeNode(root.id, MARGIN_X, curY, childrenMap, nodeById, positions)
curY += treeH + ROOT_GAP
})
// Apply positions
let maxX = -Infinity
let maxY = -Infinity
let minY = Infinity
const layoutedNodes = regulars.map(node => {
const pos = positions.get(node.id) || { x: MARGIN_X, y: MARGIN_Y }
const r = pos.x + nodeWidth(node)
const b = pos.y + nodeHeight(node)
if (r > maxX) maxX = r
if (b > maxY) maxY = b
if (pos.y < minY) minY = pos.y
return { ...node, position: pos }
})
// ── Lambda grid — centred below the main tree ────────────────────────────
if (lambdas.length) {
const treeWidth = maxX - MARGIN_X
const cols = Math.min(4, Math.max(2, Math.ceil(Math.sqrt(lambdas.length))))
const colWidth = LAMBDA_W + 30
const rowHeight = LAMBDA_H + 16
const gridWidth = cols * colWidth - 30
const gridStartX = MARGIN_X + Math.max(0, (treeWidth - gridWidth) / 2)
const gridStartY = maxY + 80
lambdas.forEach((node, i) => {
layoutedNodes.push({
...node,
position: {
x: gridStartX + (i % cols) * colWidth,
y: gridStartY + Math.floor(i / cols) * rowHeight,
},
})
})
}
// ── Edges ────────────────────────────────────────────────────────────────
const layoutedEdges = edges
.filter(e => e.edgeType === 'extends')
.map((edge, idx) => ({
...edge,
id: edge.id || `ext-${idx}`,
type: 'bezier',
pathOptions: { curvature: 0.35 },
style: { stroke: '#2e3347', strokeWidth: 1.5 },
markerEnd: { type: 'arrowclosed', width: 11, height: 11, color: '#2e3347' },
animated: false,
}))
return { nodes: layoutedNodes, edges: layoutedEdges }
}
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
@@ -0,0 +1,39 @@
import { Handle, Position } from '@xyflow/react'
/**
* Abstract handler node — represents a handler in the inheritance chain
* that is not directly bound to a route. Minimal styling, emphasizes the hierarchy.
*/
export default function AbstractHandlerNode({ data }) {
return (
<div style={{
background: '#13161f',
border: '1px solid #2a2f42',
borderRadius: 8,
padding: '8px 12px',
minWidth: 160,
fontFamily: 'system-ui, sans-serif',
opacity: 0.8,
}}>
{/* Extends from parent handler */}
<Handle type="target" position={Position.Top}
style={{ background: '#2a2f42' }} />
{/* Extends to child handler */}
<Handle type="source" position={Position.Bottom}
style={{ background: '#2a2f42' }} />
<div style={{
fontSize: 10, color: '#4a5568', fontWeight: 700, letterSpacing: 0.5,
marginBottom: 3,
}}>
ABSTRACT
</div>
<div style={{
fontSize: 12, fontWeight: 500, color: '#6b7694',
fontFamily: 'monospace',
}}>
{data.name}
</div>
</div>
)
}
@@ -0,0 +1,89 @@
import { Handle, Position } from '@xyflow/react'
const METHOD_COLORS = {
GET: { bg: '#0d4429', color: '#4ade80' },
POST: { bg: '#172554', color: '#60a5fa' },
PUT: { bg: '#451a03', color: '#fb923c' },
PATCH: { bg: '#2e1065', color: '#c084fc' },
DELETE: { bg: '#450a0a', color: '#f87171' },
OPTIONS: { bg: '#1c1917', color: '#a8a29e' },
HEAD: { bg: '#1c1917', color: '#a8a29e' },
}
/**
* Concrete handler node — represents a handler that is bound to one or more routes.
* Shows handler name, HTTP method + path, and middleware stack as inline badges.
*/
export default function ConcreteHandlerNode({ data }) {
const method = data.methods?.[0] || ''
const path = data.paths?.[0] || ''
const m = METHOD_COLORS[method] || METHOD_COLORS.OPTIONS
const pathHtml = path.replace(/\{([^}]+)\}/g, '<span style="color:#a78bfa">{$1}</span>')
return (
<div style={{
background: '#1a1d27',
border: '1px solid #3b82f6',
borderRadius: 8,
padding: '10px 14px',
minWidth: 240,
fontFamily: 'system-ui, sans-serif',
}}>
{/* Extends from parent handler */}
<Handle type="target" position={Position.Top}
style={{ background: '#2e3347' }} />
{/* Extends to child handler (if any) */}
<Handle type="source" position={Position.Bottom}
style={{ background: '#2e3347' }} />
{/* Handler class name */}
<div style={{ marginBottom: 6 }}>
<span style={{
fontSize: 10, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5,
}}>
HANDLER
</span>
<div style={{
fontSize: 13, fontWeight: 700, color: '#e2e8f0',
fontFamily: 'monospace', marginTop: 2,
}}>
{data.handlerName}
</div>
</div>
{/* Divider */}
<div style={{ height: 1, background: '#2e3347', marginBottom: 6 }} />
{/* Method badge + path */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<span style={{
background: m.bg, color: m.color,
fontSize: 10, fontWeight: 700, padding: '2px 7px',
borderRadius: 4, fontFamily: 'monospace', flexShrink: 0,
}}>
{method}
</span>
<span
style={{ fontSize: 11, fontFamily: 'monospace', color: '#8892a4' }}
dangerouslySetInnerHTML={{ __html: pathHtml }}
/>
</div>
{/* Middleware badges */}
{data.middleware && data.middleware.length > 0 && (
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{data.middleware.map(mw => (
<span key={mw} style={{
background: '#1f1a0e',
color: '#f6ad55',
fontSize: 9, fontWeight: 600, padding: '2px 6px',
borderRadius: 3, fontFamily: 'monospace', whiteSpace: 'nowrap',
}}>
{mw}
</span>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,168 @@
import { Handle, Position } from '@xyflow/react'
const METHOD_COLORS = {
GET: { bg: '#0d4429', color: '#4ade80' },
POST: { bg: '#172554', color: '#60a5fa' },
PUT: { bg: '#451a03', color: '#fb923c' },
PATCH: { bg: '#2e1065', color: '#c084fc' },
DELETE: { bg: '#450a0a', color: '#f87171' },
OPTIONS: { bg: '#1c1917', color: '#a8a29e' },
HEAD: { bg: '#1c1917', color: '#a8a29e' },
}
// Handles for LR layout: parent flows in from the LEFT, children exit to the RIGHT
const TARGET_HANDLE = <Handle type="target" position={Position.Left}
style={{ left: 0, top: '50%', transform: 'translateY(-50%)' }} />
const SOURCE_HANDLE = <Handle type="source" position={Position.Right}
style={{ right: 0, top: '50%', transform: 'translateY(-50%)' }} />
/**
* Unified handler node — three modes: ABSTRACT, CONCRETE, LAMBDA.
* Handles are Left (in) / Right (out) for Left-to-Right DAG layout.
*/
export default function HandlerNode({ data, selected }) {
// ── ABSTRACT ──────────────────────────────────────────────────────────
if (data.isAbstract) {
return (
<div style={{
background: 'rgba(239,68,68,0.05)',
border: selected ? '2px solid #60a5fa' : '1px solid #ef4444',
borderRadius: 8,
padding: '8px 14px',
width: 160,
fontFamily: 'system-ui, sans-serif',
boxSizing: 'border-box',
}}>
{TARGET_HANDLE}
{SOURCE_HANDLE}
<div style={{ fontSize: 9, color: '#ef4444', fontWeight: 700, letterSpacing: 0.5, marginBottom: 3 }}>
ABSTRACT
</div>
<div style={{ fontSize: 12, fontWeight: 600, color: '#e2e8f0', fontFamily: 'monospace' }}>
{data.name}
</div>
</div>
)
}
// ── LAMBDA ────────────────────────────────────────────────────────────
if (data.isLambda) {
const method = data.method || 'GET'
const path = data.path || '/'
const m = METHOD_COLORS[method] || METHOD_COLORS.OPTIONS
const pathHtml = path.replace(/\{([^}]+)\}/g, '<span style="color:#a78bfa">{$1}</span>')
return (
<div style={{
background: '#1a1d27',
border: selected ? '2px solid #60a5fa' : '1px solid #3b82f6',
borderRadius: 8,
padding: '10px 12px',
width: 230,
fontFamily: 'system-ui, sans-serif',
boxSizing: 'border-box',
}}>
{TARGET_HANDLE}
{SOURCE_HANDLE}
<div style={{ fontSize: 9, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5, marginBottom: 7 }}>
LAMBDA
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
background: m.bg, color: m.color,
fontSize: 9, fontWeight: 700, padding: '2px 5px',
borderRadius: 3, fontFamily: 'monospace', flexShrink: 0,
}}>
{method}
</span>
<span
style={{ fontSize: 10, fontFamily: 'monospace', color: '#8892a4' }}
dangerouslySetInnerHTML={{ __html: pathHtml }}
/>
</div>
{data.middleware?.length > 0 && (
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', marginTop: 7 }}>
{data.middleware.map(mw => (
<span key={mw} style={{
background: '#1f1a0e', color: '#f6ad55',
fontSize: 8, fontWeight: 600, padding: '1px 4px',
borderRadius: 2, fontFamily: 'monospace',
}}>{mw}</span>
))}
</div>
)}
</div>
)
}
// ── CONCRETE ──────────────────────────────────────────────────────────
return (
<div style={{
background: '#1a1d27',
border: selected ? '2px solid #60a5fa' : '1px solid #3b82f6',
borderRadius: 8,
padding: '10px 12px',
width: 260,
fontFamily: 'system-ui, sans-serif',
boxSizing: 'border-box',
}}>
{TARGET_HANDLE}
{SOURCE_HANDLE}
{/* Header */}
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 9, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5, marginBottom: 2 }}>
HANDLER
</div>
<div style={{ fontSize: 12, fontWeight: 700, color: '#e2e8f0', fontFamily: 'monospace' }}>
{data.name}
</div>
</div>
<div style={{ height: 1, background: '#2e3347', marginBottom: 8 }} />
{/* Routes */}
<div style={{ marginBottom: data.middleware?.length > 0 ? 8 : 0 }}>
{data.routes?.map((route, i) => {
const m = METHOD_COLORS[route.method] || METHOD_COLORS.OPTIONS
const pathHtml = (route.path || '/').replace(
/\{([^}]+)\}/g,
'<span style="color:#a78bfa">{$1}</span>'
)
return (
<div key={i} style={{
display: 'flex', alignItems: 'center', gap: 6,
marginBottom: i < data.routes.length - 1 ? 5 : 0,
}}>
<span style={{
background: m.bg, color: m.color,
fontSize: 8, fontWeight: 700, padding: '2px 5px',
borderRadius: 3, fontFamily: 'monospace', flexShrink: 0,
}}>
{route.method}
</span>
<span
style={{ fontSize: 10, fontFamily: 'monospace', color: '#8892a4' }}
dangerouslySetInnerHTML={{ __html: pathHtml }}
/>
</div>
)
})}
</div>
{/* Middleware */}
{data.middleware?.length > 0 && (
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap' }}>
{data.middleware.map(mw => (
<span key={mw} style={{
background: '#1f1a0e', color: '#f6ad55',
fontSize: 8, fontWeight: 600, padding: '1px 4px',
borderRadius: 2, fontFamily: 'monospace',
}}>{mw}</span>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,25 @@
import { Handle, Position } from '@xyflow/react'
export default function MiddlewareNode({ data }) {
return (
<div style={{
background: '#1f1a0e',
border: '1px solid #92400e',
borderRadius: 8,
padding: '7px 14px',
minWidth: 150,
fontFamily: 'system-ui, sans-serif',
}}>
{/* shared node: sends applies edges to all routes that use this MW */}
<Handle id="out-right" type="source" position={Position.Right}
style={{ background: '#92400e' }} />
<div style={{ fontSize: 10, color: '#f6ad55', fontWeight: 700, letterSpacing: 0.5, marginBottom: 2 }}>
MIDDLEWARE
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: '#fde68a', fontFamily: 'monospace' }}>
{data.name}
</div>
</div>
)
}
@@ -0,0 +1,69 @@
import { Handle, Position } from '@xyflow/react'
const METHOD_COLORS = {
GET: { bg: '#0d4429', color: '#4ade80' },
POST: { bg: '#172554', color: '#60a5fa' },
PUT: { bg: '#451a03', color: '#fb923c' },
PATCH: { bg: '#2e1065', color: '#c084fc' },
DELETE: { bg: '#450a0a', color: '#f87171' },
OPTIONS: { bg: '#1c1917', color: '#a8a29e' },
HEAD: { bg: '#1c1917', color: '#a8a29e' },
}
/**
* Combined entry node — shows the concrete handler name above
* and the HTTP method + path below. Replaces the old split
* Route → Handler[0] pair.
*/
export default function RouteNode({ data }) {
const m = METHOD_COLORS[data.method] ?? METHOD_COLORS.OPTIONS
const path = data.path.replace(/\{([^}]+)\}/g, '<span style="color:#a78bfa">{$1}</span>')
const isSimple = !data.handlerName || data.handlerName === 'Simple Handler'
return (
<div style={{
background: '#1a1d27',
border: '1px solid #3b82f6',
borderRadius: 8,
padding: '8px 14px',
minWidth: 200,
fontFamily: 'system-ui, sans-serif',
}}>
{/* receives MW → entry "applies" edges */}
<Handle id="in-left" type="target" position={Position.Left}
style={{ background: '#2e3347' }} />
{/* sends extends edge to abstract parent chain */}
<Handle id="out-bottom" type="source" position={Position.Bottom}
style={{ background: '#2e3347' }} />
{/* Handler class name */}
<div style={{ marginBottom: 6 }}>
<span style={{ fontSize: 10, color: '#60a5fa', fontWeight: 700, letterSpacing: 0.5 }}>
{isSimple ? 'HANDLER' : 'HANDLER'}
</span>
<div style={{
fontSize: 13, fontWeight: 700, color: '#e2e8f0',
fontFamily: 'monospace', marginTop: 1,
}}>
{isSimple ? 'Simple Handler' : data.handlerName}
</div>
</div>
{/* Divider */}
<div style={{ height: 1, background: '#2e3347', marginBottom: 6 }} />
{/* Method badge + path */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{
background: m.bg, color: m.color,
fontSize: 10, fontWeight: 700, padding: '2px 7px',
borderRadius: 4, fontFamily: 'monospace', flexShrink: 0,
}}>{data.method}</span>
<span
style={{ fontSize: 12, fontFamily: 'monospace', color: '#8892a4' }}
dangerouslySetInnerHTML={{ __html: path }}
/>
</div>
</div>
)
}
@@ -0,0 +1,30 @@
/**
* Section header — no edges, purely positional grouping.
* Styled as a slim label bar above the router's route group.
*/
export default function RouterNode({ data }) {
return (
<div style={{
background: 'linear-gradient(90deg, #16122a 0%, #1a1d27 100%)',
border: '1px solid #3d2f7a',
borderLeft: '3px solid #7c6af7',
borderRadius: 6,
padding: '6px 14px',
minWidth: 240,
fontFamily: 'system-ui, sans-serif',
display: 'flex',
alignItems: 'center',
gap: 12,
}}>
<span style={{ fontSize: 10, color: '#7c6af7', fontWeight: 700, letterSpacing: 1, flexShrink: 0 }}>
ROUTER
</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#e2e8f0', fontFamily: 'monospace' }}>
{data.namespace}
</span>
<span style={{ fontSize: 11, color: '#4a5568', marginLeft: 'auto' }}>
{data.routerType} · {data.routeCount}
</span>
</div>
)
}
@@ -0,0 +1,186 @@
/**
* Canonical Graph Normalization.
*
* Transform raw route/handler nodes into a class-centric DAG where:
* - Each handler class appears exactly ONCE
* - Inheritance is the primary relationship
* - Routes are attached to leaf (concrete) handlers
* - Middleware is extracted and attached to handlers
* - Lambda routes (no handler) become isolated leaf nodes
*/
export function normalizeGraph(rawNodes, rawEdges) {
const byId = Object.fromEntries(rawNodes.map(n => [n.id, n]))
const ofType = t => rawNodes.filter(n => n.type === t)
const routeRaw = ofType('route')
const handlerRaw = ofType('handler')
// ── Extract metadata per route ──────────────────────────────────────────
const routeMetadata = new Map() // routeId → {method, path, middleware: []}
routeRaw.forEach(route => {
routeMetadata.set(route.id, {
method: route.data?.method || 'GET',
path: route.data?.path || '/',
middleware: [],
})
})
// ── Extract middleware per route ───────────────────────────────────────
const wrapsEdges = rawEdges.filter(e => e.edgeType === 'wraps')
const wrapsOf = new Map() // wrappedNode → wrapperNode
wrapsEdges.forEach(e => wrapsOf.set(e.target, e.source))
routeRaw.forEach(route => {
const middleware = []
let cur = wrapsOf.get(route.id)
while (cur && byId[cur]?.type === 'middleware') {
middleware.unshift(byId[cur].data.name)
cur = wrapsOf.get(cur)
}
if (middleware.length) {
routeMetadata.get(route.id).middleware = middleware
}
})
// ── Build inheritance map ──────────────────────────────────────────────
// handlerClassName → parentClassName
const inheritanceMap = new Map()
const extendsEdges = rawEdges.filter(e => e.edgeType === 'extends')
extendsEdges.forEach(e => {
const childNode = byId[e.source]
const parentNode = byId[e.target]
if (childNode?.data?.name && parentNode?.data?.name) {
inheritanceMap.set(childNode.data.name, parentNode.data.name)
}
})
// ── Build handler chains per route ─────────────────────────────────────
const handlesEdges = rawEdges.filter(e => e.edgeType === 'handles')
const routeToChain = new Map() // routeId → [className, parentClassName, ...]
handlesEdges.forEach(e => {
const handlerNode = byId[e.target]
if (handlerNode?.data?.name) {
const chain = []
let cur = handlerNode.data.name
while (cur) {
chain.push(cur)
cur = inheritanceMap.get(cur)
}
routeToChain.set(e.source, chain)
}
})
// Ensure ALL routes are in the chain map (routes without handlers get empty chain)
routeRaw.forEach(route => {
if (!routeToChain.has(route.id)) {
routeToChain.set(route.id, [])
}
})
// ── Collect all unique handler class names ──────────────────────────────
const allClassNames = new Set()
routeToChain.forEach(chain => chain.forEach(name => allClassNames.add(name)))
// ── Create canonical handler nodes ────────────────────────────────────
const canonicalHandlers = new Map() // class:ClassName → {id, name, isAbstract, routes[], parentId, isLambda}
;[...allClassNames].forEach(className => {
const id = 'class:' + className
canonicalHandlers.set(id, {
id,
name: className,
isAbstract: true, // will be marked false if used as concrete
routes: [],
parentId: null,
isLambda: false,
})
})
// ── Wire up inheritance ────────────────────────────────────────────────
inheritanceMap.forEach((parentClassName, childClassName) => {
const childId = 'class:' + childClassName
const parentId = 'class:' + parentClassName
if (canonicalHandlers.has(childId) && canonicalHandlers.has(parentId)) {
canonicalHandlers.get(childId).parentId = parentId
}
})
// ── Attach routes to concrete handlers ──────────────────────────────────
routeToChain.forEach((chain, routeId) => {
if (chain.length === 0) {
// Lambda: no handler
const meta = routeMetadata.get(routeId)
const lambdaId = 'class:Lambda:' + meta.method + ':' + encodeURIComponent(meta.path)
canonicalHandlers.set(lambdaId, {
id: lambdaId,
name: 'Lambda Handler',
isAbstract: false,
isLambda: true,
method: meta.method,
path: meta.path,
routes: [{
method: meta.method,
path: meta.path,
middleware: meta.middleware,
}],
parentId: null,
})
} else {
// Normal handler chain: mark concrete (first = depth 0)
const concreteClassName = chain[0]
const concreteId = 'class:' + concreteClassName
if (canonicalHandlers.has(concreteId)) {
const handler = canonicalHandlers.get(concreteId)
handler.isAbstract = false
const meta = routeMetadata.get(routeId)
handler.routes.push({
method: meta.method,
path: meta.path,
middleware: meta.middleware,
})
}
}
})
// ── Build output nodes ────────────────────────────────────────────────
const nodes = [...canonicalHandlers.values()].map(handler => ({
id: handler.id,
type: 'handler',
data: {
name: handler.name,
isAbstract: handler.isAbstract,
isLambda: handler.isLambda,
method: handler.method,
path: handler.path,
routes: handler.routes,
middleware: handler.routes.length > 0
? [...new Set(handler.routes.flatMap(r => r.middleware))]
: [],
},
}))
// ── Build output edges (deduped extends only) ──────────────────────────
const edgeSet = new Set()
const edges = []
canonicalHandlers.forEach((handler, handlerId) => {
if (handler.parentId) {
const key = handler.parentId + '->' + handlerId
if (!edgeSet.has(key)) {
edgeSet.add(key)
edges.push({
id: key,
source: handler.parentId,
target: handlerId,
edgeType: 'extends',
})
}
}
})
return { nodes, edges }
}