diff --git a/.projenrc.ts b/.projenrc.ts index 5ed4f49d1..d03c6d6ae 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -1213,12 +1213,27 @@ const cdkExplorer = configureProject( ], devDeps: [ 'vscode-languageserver-protocol@^3', + '@types/express@^4', + 'react@^18', + 'react-dom@^18', + '@types/react@^18', + '@types/react-dom@^18', + '@cloudscape-design/components@^3', + '@cloudscape-design/global-styles@^1', + 'esbuild', + 'tsx', + 'supertest@^6', + '@types/supertest@^6', '@types/convert-source-map@^2', + 'prismjs@^1', + '@types/prismjs@^1', + 'yaml@^2', ], tsconfig: { compilerOptions: { ...defaultTsOptions, }, + exclude: ['frontend'], }, jestOptions: jestOptionsForProject({ jestConfig: { @@ -1233,6 +1248,10 @@ const cdkExplorer = configureProject( }), ); fixupTestTask(cdkExplorer); +cdkExplorer.postCompileTask.exec('tsx build-tools/bundle-frontend.ts'); +cdkExplorer.eslint?.allowDefaultProjectFiles('build-tools/bundle-frontend.ts'); +cdkExplorer.gitignore.addPatterns('lib/web/static/', 'lib/web/web-assets.generated.json'); +cdkExplorer.npmignore?.addPatterns('frontend', 'tsconfig.frontend.json'); // #endregion ////////////////////////////////////////////////////////////////////// diff --git a/packages/@aws-cdk/cdk-explorer/.eslintrc.json b/packages/@aws-cdk/cdk-explorer/.eslintrc.json index e0c610e49..bdc74288f 100644 --- a/packages/@aws-cdk/cdk-explorer/.eslintrc.json +++ b/packages/@aws-cdk/cdk-explorer/.eslintrc.json @@ -17,7 +17,12 @@ "parserOptions": { "ecmaVersion": 2018, "sourceType": "module", - "projectService": true + "projectService": { + "allowDefaultProject": [ + "build-tools/bundle-frontend.ts" + ], + "defaultProject": "./tsconfig.json" + } }, "extends": [ "plugin:import/typescript", diff --git a/packages/@aws-cdk/cdk-explorer/.gitignore b/packages/@aws-cdk/cdk-explorer/.gitignore index 5f5292db9..08c0798a1 100644 --- a/packages/@aws-cdk/cdk-explorer/.gitignore +++ b/packages/@aws-cdk/cdk-explorer/.gitignore @@ -51,3 +51,5 @@ jspm_packages/ /dist/ !/.eslintrc.json !/.eslintrc.js +lib/web/static/ +lib/web/web-assets.generated.json diff --git a/packages/@aws-cdk/cdk-explorer/.npmignore b/packages/@aws-cdk/cdk-explorer/.npmignore index d9eda4d8c..dd6b08287 100644 --- a/packages/@aws-cdk/cdk-explorer/.npmignore +++ b/packages/@aws-cdk/cdk-explorer/.npmignore @@ -21,4 +21,6 @@ tsconfig.tsbuildinfo *.ts !*.d.ts build-tools +frontend +tsconfig.frontend.json /.gitattributes diff --git a/packages/@aws-cdk/cdk-explorer/.projen/deps.json b/packages/@aws-cdk/cdk-explorer/.projen/deps.json index c2152ce92..5285942b7 100644 --- a/packages/@aws-cdk/cdk-explorer/.projen/deps.json +++ b/packages/@aws-cdk/cdk-explorer/.projen/deps.json @@ -4,6 +4,16 @@ "name": "@cdklabs/eslint-plugin", "type": "build" }, + { + "name": "@cloudscape-design/components", + "version": "^3", + "type": "build" + }, + { + "name": "@cloudscape-design/global-styles", + "version": "^1", + "type": "build" + }, { "name": "@stylistic/eslint-plugin", "version": "^3", @@ -14,6 +24,11 @@ "version": "^2", "type": "build" }, + { + "name": "@types/express", + "version": "^4", + "type": "build" + }, { "name": "@types/jest", "type": "build" @@ -23,6 +38,26 @@ "version": "^20", "type": "build" }, + { + "name": "@types/prismjs", + "version": "^1", + "type": "build" + }, + { + "name": "@types/react-dom", + "version": "^18", + "type": "build" + }, + { + "name": "@types/react", + "version": "^18", + "type": "build" + }, + { + "name": "@types/supertest", + "version": "^6", + "type": "build" + }, { "name": "@typescript-eslint/eslint-plugin", "version": "^8", @@ -38,6 +73,10 @@ "version": "^10.0.0", "type": "build" }, + { + "name": "esbuild", + "type": "build" + }, { "name": "eslint-config-prettier", "type": "build" @@ -85,14 +124,38 @@ "version": "^2.8", "type": "build" }, + { + "name": "prismjs", + "version": "^1", + "type": "build" + }, { "name": "projen", "type": "build" }, + { + "name": "react-dom", + "version": "^18", + "type": "build" + }, + { + "name": "react", + "version": "^18", + "type": "build" + }, + { + "name": "supertest", + "version": "^6", + "type": "build" + }, { "name": "ts-jest", "type": "build" }, + { + "name": "tsx", + "type": "build" + }, { "name": "typescript", "version": "5.9", @@ -103,6 +166,11 @@ "version": "^3", "type": "build" }, + { + "name": "yaml", + "version": "^2", + "type": "build" + }, { "name": "@aws-cdk/cloud-assembly-api", "type": "runtime" diff --git a/packages/@aws-cdk/cdk-explorer/.projen/tasks.json b/packages/@aws-cdk/cdk-explorer/.projen/tasks.json index 8cabecd1c..c22d4e718 100644 --- a/packages/@aws-cdk/cdk-explorer/.projen/tasks.json +++ b/packages/@aws-cdk/cdk-explorer/.projen/tasks.json @@ -53,7 +53,7 @@ "--peer", "--no-deprecated", "--dep=dev,prod,peer,optional", - "--filter=@cdklabs/eslint-plugin,@types/jest,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,jest,nx,projen,ts-jest" + "--filter=@cdklabs/eslint-plugin,@types/jest,esbuild,eslint-config-prettier,eslint-import-resolver-typescript,eslint-plugin-import,eslint-plugin-jest,eslint-plugin-jsdoc,eslint-plugin-prettier,jest,nx,projen,ts-jest,tsx" ] } ] @@ -134,7 +134,12 @@ }, "post-compile": { "name": "post-compile", - "description": "Runs after successful compilation" + "description": "Runs after successful compilation", + "steps": [ + { + "exec": "tsx build-tools/bundle-frontend.ts" + } + ] }, "pre-compile": { "name": "pre-compile", diff --git a/packages/@aws-cdk/cdk-explorer/build-tools/bundle-frontend.ts b/packages/@aws-cdk/cdk-explorer/build-tools/bundle-frontend.ts new file mode 100644 index 000000000..7ff372d4a --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/build-tools/bundle-frontend.ts @@ -0,0 +1,59 @@ +/** + * Builds the web explorer SPA into lib/web/static (typechecked via + * tsconfig.frontend.json, since esbuild only transpiles), then writes the same + * assets to lib/web/web-assets.generated.json so they ride the require() graph + * into the published CLI bundle (express.static paths are not bundled). + */ +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as esbuild from 'esbuild'; + +const packageRoot = path.resolve(__dirname, '..'); +const frontendDir = path.join(packageRoot, 'frontend'); +const outDir = path.join(packageRoot, 'lib', 'web', 'static'); +const embeddedAssetsFile = path.join(packageRoot, 'lib', 'web', 'web-assets.generated.json'); + +async function main(): Promise { + typecheck(); + + fs.mkdirSync(outDir, { recursive: true }); + + await esbuild.build({ + entryPoints: [path.join(frontendDir, 'index.tsx')], + bundle: true, + outfile: path.join(outDir, 'bundle.js'), + format: 'iife', + platform: 'browser', + target: 'es2020', + jsx: 'automatic', + loader: { '.css': 'css', '.svg': 'dataurl', '.png': 'dataurl' }, + sourcemap: true, + logLevel: 'info', + }); + + fs.copyFileSync(path.join(frontendDir, 'index.html'), path.join(outDir, 'index.html')); + writeEmbeddedAssets(); +} + +function writeEmbeddedAssets(): void { + const assets: Record = { + 'index.html': fs.readFileSync(path.join(frontendDir, 'index.html'), 'utf-8'), + 'bundle.js': fs.readFileSync(path.join(outDir, 'bundle.js'), 'utf-8'), + 'bundle.css': fs.readFileSync(path.join(outDir, 'bundle.css'), 'utf-8'), + }; + fs.writeFileSync(embeddedAssetsFile, JSON.stringify(assets)); +} + +function typecheck(): void { + execFileSync('tsc', ['--noEmit', '-p', path.join(packageRoot, 'tsconfig.frontend.json')], { + cwd: packageRoot, + stdio: 'inherit', + }); +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); +}); diff --git a/packages/@aws-cdk/cdk-explorer/frontend/App.tsx b/packages/@aws-cdk/cdk-explorer/frontend/App.tsx new file mode 100644 index 000000000..be9950f6e --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/App.tsx @@ -0,0 +1,757 @@ +import Box from '@cloudscape-design/components/box'; +import Alert from '@cloudscape-design/components/alert'; +import Button from '@cloudscape-design/components/button'; +import Container from '@cloudscape-design/components/container'; +import Header from '@cloudscape-design/components/header'; +import SpaceBetween from '@cloudscape-design/components/space-between'; +import Spinner from '@cloudscape-design/components/spinner'; +import * as React from 'react'; +import { buildSourceAnchorIndex, findConstructAtLine } from '../lib/web/source-nav'; +import { api, type TemplateResponse, type TreeResponse, type ViolationsResponse } from './api'; +import { CodeViewer, type Diagnostic } from './components/CodeViewer'; +import { ConstructTree } from './components/ConstructTree'; +import type { Language } from './syntax'; +import { TemplateViewer, type Format } from './components/TemplateViewer'; +import { ViolationsPanel } from './components/ViolationsPanel'; +import type { NavigateHandler } from './nav-types'; +export type { NavigateHandler } from './nav-types'; + +/** Navigation target describing what both panes should show. */ +export interface NavTarget { + readonly source?: { file: string; startLine: number; endLine: number }; + readonly template?: { file: string; logicalId: string }; + readonly color: string; + readonly navCounter: number; +} + +const NEUTRAL_COLOR = '#5f6b7a'; + +/** Web explorer shell. */ +export function App(): JSX.Element { + const [tree, setTree] = React.useState(); + const [violations, setViolations] = React.useState(); + const [error, setError] = React.useState(); + const [appDir, setAppDir] = React.useState(); + + // Source pane state. + const [sourceFile, setSourceFile] = React.useState(); + const [sourceContent, setSourceContent] = React.useState(''); + // True when the open source file was edited after the current assembly's synth + // started, so its squiggles/nav anchors may be stale. Set from /api/file. + const [sourceStale, setSourceStale] = React.useState(false); + + // Template pane state. + const [templateFile, setTemplateFile] = React.useState(); + const [templateData, setTemplateData] = React.useState(); + const [templateFormat, setTemplateFormat] = React.useState('yaml'); + + // Refs for current values in async callbacks (avoids stale closures). + const sourceFileRef = React.useRef(sourceFile); + sourceFileRef.current = sourceFile; + const templateFileRef = React.useRef(templateFile); + templateFileRef.current = templateFile; + const templateDataRef = React.useRef(templateData); + templateDataRef.current = templateData; + + // Navigation state shared across panes. + const [nav, setNav] = React.useState(); + const navCounterRef = React.useRef(0); + + // Violations filter: when set, only violations affecting this construct path are shown. + const [violationFilter, setViolationFilter] = React.useState(); + const [violationSearch, setViolationSearch] = React.useState(''); + + const reload = React.useCallback((): void => { + Promise.all([api.getTree(), api.getViolations(), api.getAppInfo()]) + .then(([t, v, info]) => { + setTree(t); + setViolations(v); + setAppDir(info.appDir); + setError(undefined); + }) + // Keep the last good render on a transient read (e.g. a mid-synth write); + // the next assembly-changed event re-fetches once the write has settled. + .catch((err) => setError(err instanceof Error ? err.message : String(err))); + }, []); + + // Re-read the currently open source file, refreshing both its content and its + // staleness flag. Used after a synth (assembly changed) to clear a banner, and + // on a source edit to raise one, without the user re-opening the file. + const refreshOpenSourceFile = React.useCallback(async (): Promise => { + const file = sourceFileRef.current; + if (!file) return; + try { + const res = await api.readFile(file); + setSourceContent(res.content); + setSourceStale(res.stale); + } catch { + // Keep the last good content; a later event re-tries. + } + }, []); + + React.useEffect(() => { + reload(); + return api.subscribe({ + onAssemblyChanged: () => { + reload(); + void refreshOpenSourceFile(); + }, + onSourceChanged: () => { + void refreshOpenSourceFile(); + }, + }); + }, [reload, refreshOpenSourceFile]); + + /** Navigate to a construct (from tree double-click or violation double-click). */ + const navigate: NavigateHandler = React.useCallback(async (opts) => { + const counter = ++navCounterRef.current; + const color = opts.color ?? NEUTRAL_COLOR; + + if (opts.constructPath) { + setViolationFilter(opts.constructPath); + } + + let sourceTarget: NavTarget['source']; + if (opts.sourceLocation) { + sourceTarget = { file: opts.sourceLocation.file, startLine: opts.sourceLocation.line, endLine: opts.sourceLocation.line }; + if (sourceFileRef.current !== opts.sourceLocation.file) { + try { + const res = await api.readFile(opts.sourceLocation.file); + setSourceFile(res.path); + setSourceContent(res.content); + setSourceStale(res.stale); + } catch { + setSourceFile(opts.sourceLocation.file); + setSourceContent(`// Could not load ${opts.sourceLocation.file}`); + setSourceStale(false); + } + } + } + + let templateTarget: NavTarget['template']; + if (opts.templateFile && opts.logicalId) { + if (templateFileRef.current !== opts.templateFile) { + try { + const data = await api.getTemplate(opts.templateFile); + setTemplateFile(opts.templateFile); + setTemplateData(data); + } catch { + setTemplateFile(opts.templateFile); + setTemplateData(undefined); + } + } + // Carry identity only; TemplateViewer resolves the highlight line from the + // rendered format (JSON or YAML), which is the sole coordinate authority. + templateTarget = { file: opts.templateFile, logicalId: opts.logicalId }; + } + + setNav({ source: sourceTarget, template: templateTarget, color, navCounter: counter }); + }, []); + + /** Jump from template pane to source (the "Open in source" button). */ + const jumpToSource = React.useCallback(async (logicalId: string) => { + const data = templateDataRef.current; + if (!data) return; + const resource = data.resources[logicalId]; + if (!resource?.source) return; + const counter = ++navCounterRef.current; + if (sourceFileRef.current !== resource.source.file) { + try { + const res = await api.readFile(resource.source.file); + setSourceFile(res.path); + setSourceContent(res.content); + setSourceStale(res.stale); + } catch { return; } + } + setNav({ + source: { file: resource.source.file, startLine: resource.source.line, endLine: resource.source.line }, + template: undefined, + color: NEUTRAL_COLOR, + navCounter: counter, + }); + }, []); + + // Per-file index of constructs navigable from source to template, built from + // the construct tree the app already loads. Rebuilt only when the tree changes. + const sourceAnchors = React.useMemo( + () => (tree?.status === 'ok' ? buildSourceAnchorIndex(tree.tree) : undefined), + [tree], + ); + + /** Double-click a line in the source pane to jump to its resource in the template. */ + const handleSourceDoubleClick = React.useCallback((line: number) => { + const file = sourceFileRef.current; + if (!file) return; + const node = findConstructAtLine(sourceAnchors?.get(file), line); + if (!node) return; + void navigate({ + sourceLocation: node.sourceLocation, + templateFile: node.templateFile, + logicalId: node.logicalId, + constructPath: node.path, + }); + }, [sourceAnchors, navigate]); + + // File picker state. + const [showFilePicker, setShowFilePicker] = React.useState(false); + const pickerRef = React.useRef(null); + + React.useEffect(() => { + if (!showFilePicker) return; + const handleClick = (e: MouseEvent) => { + const target = e.target as Node; + if (pickerRef.current && !pickerRef.current.contains(target)) { + if ((target as HTMLElement).closest?.('[aria-label="Open file"], [aria-label="Open template file"]')) return; + setShowFilePicker(false); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [showFilePicker]); + + const knownSourceFiles = React.useMemo( + () => sourceAnchors ? [...sourceAnchors.keys()].sort() : [], + [sourceAnchors], + ); + + const knownTemplateFiles = React.useMemo(() => { + if (!tree || tree.status !== 'ok') return []; + const files = new Set(); + const walk = (nodes: readonly import('./api').WebConstructNode[]) => { + for (const n of nodes) { + if (n.templateFile) files.add(n.templateFile); + walk(n.children); + } + }; + walk(tree.tree); + return [...files].sort(); + }, [tree]); + + const pickFile = React.useCallback(async (filePath: string, pane: 'source' | 'template') => { + try { + if (pane === 'source') { + const res = await api.readFile(filePath); + setSourceFile(res.path); + setSourceContent(res.content); + setSourceStale(res.stale); + } else { + const data = await api.getTemplate(filePath); + setTemplateFile(filePath); + setTemplateData(data); + } + setShowFilePicker(false); + } catch { /* ignore */ } + }, []); + + // Vertical split: violations row's share of the page height (default 33%). + const vSplit = useSplit({ orientation: 'vertical', defaultFraction: 0.33, min: 0.15, max: 0.85 }); + // Horizontal split inside the top row: file panes' share of that row's width (default 75%; tree gets the remaining 25%). + const hSplit = useSplit({ orientation: 'horizontal', defaultFraction: 0.75, min: 0.4, max: 0.85 }); + + return ( +
+
+
CDK Web Explorer
+
+ {error && {error}} +
+
+ {!hSplit.collapsed && ( +
+ Construct Tree}> + + +
+ )} +
+ +
+
+
+ + + + {sourceFile ?? 'Source'} + + + {showFilePicker === 'source' && ( +
+ void pickFile(p, 'source')} /> +
+ )} +
+ + }> +
+ {sourceContent ? ( +
+ {sourceStale && ( + + This file has been modified since the last synth, so its violations and diagnostics may be stale. Re-run cdk synth to refresh. + + )} +
+ +
+
+ ) : ( + Double-click a construct to view its source. + )} +
+
+
+
+ }> + + + {templateFile ?? 'Template'} + + + {showFilePicker === 'template' && ( +
+ void pickFile(p, 'template')} /> +
+ )} +
+ + }> +
+ {templateData ? ( + + ) : templateFile ? ( + Could not load {templateFile} + ) : ( + Double-click a construct to view its template. + )} +
+
+
+
+
+
+
+ + {!vSplit.collapsed && ( +
+ }> + setViolationFilter(undefined)} /> + + }> + setViolationFilter(undefined)} search={violationSearch} /> + +
+ )} +
+
+ ); +} + + +function KnownFileList({ files, onPick }: { + readonly files: readonly string[]; + readonly onPick: (path: string) => void; +}): JSX.Element { + if (files.length === 0) return No files found.; + return ( + + {files.map((file) => ( + + ))} + + ); +} + +function ConstructTreeContent({ tree, onNavigate }: { readonly tree?: TreeResponse; readonly onNavigate: NavigateHandler }): JSX.Element { + if (!tree) return ; + if (tree.status === 'not-synthesized') { + return No cloud assembly found. Run cdk synth first.; + } + return ; +} + +function ViolationsContent({ violations, onNavigate, filter, onClearFilter, search }: { + readonly violations?: ViolationsResponse; + readonly onNavigate: NavigateHandler; + readonly filter?: string; + readonly onClearFilter: () => void; + readonly search: string; +}): JSX.Element { + if (!violations) return ; + if (violations.status === 'not-synthesized') { + return No cloud assembly found.; + } + return ; +} + +function ViolationsTitle({ filter, onClearFilter }: { readonly filter?: string; readonly onClearFilter: () => void }): JSX.Element { + if (!filter) return <>Violations; + const name = filter.split('/').pop() || filter; + return ( + + Violations for + + {name} + + + + ); +} + +function ViolationsActions({ search, onSearchChange }: { + readonly search: string; + readonly onSearchChange: (value: string) => void; +}): JSX.Element { + return ( + onSearchChange(e.target.value)} + style={VIOLATIONS_SEARCH_STYLE} + /> + ); +} + +interface SplitOptions { + readonly orientation: 'horizontal' | 'vertical'; + readonly defaultFraction: number; + readonly min: number; + readonly max: number; +} + +interface Split extends SplitOptions { + readonly fraction: number; + readonly collapsed: boolean; + readonly toggleCollapsed: () => void; + readonly startDrag: (e: React.MouseEvent) => void; + readonly containerRef: React.RefObject; +} + +function useSplit(opts: SplitOptions): Split { + const containerRef = React.useRef(null); + const [fraction, setFraction] = React.useState(opts.defaultFraction); + const [collapsed, setCollapsed] = React.useState(false); + + const startDrag = React.useCallback((e: React.MouseEvent) => { + if (collapsed) return; + e.preventDefault(); + const container = containerRef.current; + if (!container) return; + document.body.style.userSelect = 'none'; + document.body.style.cursor = opts.orientation === 'vertical' ? 'row-resize' : 'col-resize'; + const onMove = (ev: MouseEvent) => { + const rect = container.getBoundingClientRect(); + const frac = opts.orientation === 'vertical' + ? (rect.bottom - ev.clientY) / rect.height + : (rect.right - ev.clientX) / rect.width; + setFraction(Math.min(opts.max, Math.max(opts.min, frac))); + }; + const onUp = () => { + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, [collapsed, opts.orientation, opts.min, opts.max]); + + const toggleCollapsed = React.useCallback(() => setCollapsed((c) => !c), []); + + return { ...opts, fraction, collapsed, toggleCollapsed, startDrag, containerRef }; +} + +function Resizer({ split }: { readonly split: Split }): JSX.Element { + const isVertical = split.orientation === 'vertical'; + const arrow = isVertical + ? (split.collapsed ? '▲' : '▼') + : (split.collapsed ? '▶' : '◀'); + const verb = isVertical + ? (split.collapsed ? 'Expand violations' : 'Collapse violations') + : (split.collapsed ? 'Expand construct tree' : 'Collapse construct tree'); + return ( +
+ +
+ ); +} + +function topRowStyle(vSplit: Split): React.CSSProperties { + return { + display: 'flex', + minHeight: 0, + alignItems: 'stretch', + flexDirection: 'row', + flex: vSplit.collapsed ? '1 1 auto' : `${1 - vSplit.fraction} 1 0`, + }; +} + +function bottomRowStyle(vSplit: Split): React.CSSProperties { + return { + display: 'flex', + flexDirection: 'column', + minHeight: 0, + flex: vSplit.collapsed ? '0 0 auto' : `${vSplit.fraction} 1 0`, + }; +} + +function treePaneStyle(hSplit: Split): React.CSSProperties { + return { + flex: hSplit.collapsed ? '0 0 0' : `0 0 ${(1 - hSplit.fraction) * 100}%`, + minWidth: 0, + minHeight: 0, + display: 'flex', + }; +} + +function filesPaneStyle(hSplit: Split): React.CSSProperties { + return { + flex: hSplit.collapsed ? '1 1 auto' : `0 0 ${hSplit.fraction * 100}%`, + minWidth: 0, + minHeight: 0, + }; +} + +function resizerStyle(split: Split): React.CSSProperties { + if (split.orientation === 'vertical') { + return { + flexShrink: 0, + height: split.collapsed ? '20px' : '10px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: split.collapsed ? 'pointer' : 'row-resize', + position: 'relative', + marginBottom: split.collapsed ? 0 : '-4px', + }; + } + return { + flexShrink: 0, + width: split.collapsed ? '20px' : '10px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: split.collapsed ? 'pointer' : 'col-resize', + position: 'relative', + marginLeft: split.collapsed ? 0 : '-4px', + }; +} + +const PAGE_STYLE: React.CSSProperties = { + height: '100vh', + display: 'flex', + flexDirection: 'column', + padding: '16px', + boxSizing: 'border-box', + overflow: 'hidden', +}; +const TITLE_BLOCK_STYLE: React.CSSProperties = { flexShrink: 0, marginBottom: '12px' }; +const GROW_STYLE: React.CSSProperties = { flex: '1 1 0', minWidth: 0, minHeight: 0, display: 'flex', flexDirection: 'column', width: '100%' }; + +const HEADER_WITH_ACTION_STYLE: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: '6px' }; +const FOLDER_BUTTON_STYLE: React.CSSProperties = { + border: 'none', + background: 'none', + cursor: 'pointer', + padding: '2px', + color: '#5f6b7a', + display: 'inline-flex', + alignItems: 'center', +}; +const CODE_PANE_INNER_STYLE: React.CSSProperties = { height: '100%' }; +// Source pane stacks an optional staleness banner above the scrolling viewer. +const SOURCE_PANE_COLUMN_STYLE: React.CSSProperties = { + height: '100%', + display: 'flex', + flexDirection: 'column', + minHeight: 0, + gap: '8px', +}; +const PICKER_ANCHOR_STYLE: React.CSSProperties = { position: 'relative', display: 'inline-flex', alignItems: 'center' }; +const PICKER_DROPDOWN_STYLE: React.CSSProperties = { + position: 'absolute', + top: '100%', + left: 0, + marginTop: '4px', + maxHeight: '50vh', + minWidth: '320px', + overflowY: 'auto', + background: '#ffffff', + border: '1px solid #d1d5db', + borderRadius: '4px', + boxShadow: '0 4px 12px rgba(0,0,0,0.1)', + padding: '8px', + zIndex: 100, +}; + +const CODE_PANES_STYLE: React.CSSProperties = { + display: 'flex', + gap: '8px', + height: '100%', +}; + +const CODE_PANE_STYLE: React.CSSProperties = { + flex: '1 1 50%', + minWidth: 0, + minHeight: 0, + display: 'flex', + flexDirection: 'column', +}; + +const RESIZER_BUTTON_BASE: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + border: '1px solid #d1d5db', + background: '#ffffff', + color: '#5f6b7a', + fontSize: '10px', + lineHeight: 1, + cursor: 'pointer', + zIndex: 1, + padding: 0, +}; +const RESIZER_BUTTON_HORIZONTAL: React.CSSProperties = { + ...RESIZER_BUTTON_BASE, + width: '40px', + height: '14px', + borderRadius: '7px', +}; +const RESIZER_BUTTON_VERTICAL: React.CSSProperties = { + ...RESIZER_BUTTON_BASE, + width: '14px', + height: '40px', + borderRadius: '7px', +}; + +function FormatToggle({ format, onChange }: { readonly format: Format; readonly onChange: (f: Format) => void }): JSX.Element { + return ( + + + + + ); +} + +function FolderIcon(): JSX.Element { + return ( + + + + ); +} + +const FORMAT_TOGGLE_GROUP_STYLE: React.CSSProperties = { display: 'inline-flex', gap: '2px' }; +const FORMAT_TOGGLE_STYLE: React.CSSProperties = { + border: '1px solid #d1d5db', borderRadius: '4px', background: '#fafafa', + cursor: 'pointer', fontSize: '11px', padding: '2px 8px', color: '#5f6b7a', lineHeight: '16px', +}; +const FORMAT_TOGGLE_ACTIVE_STYLE: React.CSSProperties = { + ...FORMAT_TOGGLE_STYLE, background: '#0972d3', color: '#ffffff', borderColor: '#0972d3', +}; +const VIOLATIONS_TITLE_STYLE: React.CSSProperties = { display: 'inline-flex', alignItems: 'center', gap: '6px' }; +const FILTER_PILL_STYLE: React.CSSProperties = { + display: 'inline-flex', alignItems: 'center', gap: '4px', + background: '#f2f8fd', border: '1px solid #89bdee', borderRadius: '12px', + padding: '1px 8px 1px 10px', fontSize: '14px', color: '#0972d3', fontWeight: 600, +}; +const FILTER_CLEAR_STYLE: React.CSSProperties = { + background: 'none', border: 'none', cursor: 'pointer', + fontSize: '14px', lineHeight: 1, color: '#0972d3', padding: '0 2px', +}; +const VIOLATIONS_SEARCH_STYLE: React.CSSProperties = { + width: '160px', padding: '3px 8px', border: '1px solid #d1d5db', + borderRadius: '4px', fontSize: '13px', outline: 'none', +}; + +function detectLanguage(file: string | undefined): Language { + if (!file) return 'typescript'; + if (file.endsWith('.json')) return 'json'; + if (file.endsWith('.yaml') || file.endsWith('.yml')) return 'yaml'; + if (file.endsWith('.py')) return 'python'; + if (file.endsWith('.java')) return 'java'; + if (file.endsWith('.cs')) return 'csharp'; + if (file.endsWith('.go')) return 'go'; + if (file.endsWith('.js') || file.endsWith('.jsx') || file.endsWith('.mjs')) return 'javascript'; + return 'typescript'; +} + +function buildDiagnostics( + sourceFile: string | undefined, + violations: ViolationsResponse | undefined, +): Diagnostic[] | undefined { + if (!sourceFile || !violations || violations.status !== 'ok' || violations.violations.length === 0) { + return undefined; + } + const diagnostics: Diagnostic[] = []; + for (const violation of violations.violations) { + const severity = violationSeverityToDiagnostic(violation.severity); + for (const occ of violation.occurrences) { + if (occ.sourceLocation?.file === sourceFile && occ.sourceLocation.line >= 1) { + diagnostics.push({ + startLine: occ.sourceLocation.line, + startCol: occ.sourceLocation.column >= 1 ? occ.sourceLocation.column : 1, + severity, + message: violation.description, + }); + } + } + } + return diagnostics.length > 0 ? diagnostics : undefined; +} + +function violationSeverityToDiagnostic(severity: string | undefined): 'error' | 'warning' | 'info' { + switch (severity) { + case 'fatal': + case 'error': + return 'error'; + case 'warning': + return 'warning'; + default: + return 'info'; + } +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/api.ts b/packages/@aws-cdk/cdk-explorer/frontend/api.ts new file mode 100644 index 000000000..77d72fcd7 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/api.ts @@ -0,0 +1,64 @@ +import { + ASSEMBLY_CHANGED, + SOURCE_CHANGED, + type FileResponse, + type LineRange, + type TemplateResource, + type TemplateResponse, + type TreeResponse, + type ViolationsResponse, + type WebConstructNode, + type WebSourceLocation, + type WebViolation, + type WebViolationOccurrence, +} from '../lib/web/protocol'; + +export type { + FileResponse, + LineRange, + TemplateResource, + TemplateResponse, + TreeResponse, + ViolationsResponse, + WebConstructNode, + WebSourceLocation, + WebViolation, + WebViolationOccurrence, +}; + +async function getJson(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error ?? `request failed: ${res.status}`); + } + return res.json() as Promise; +} + +export interface AppInfoResponse { + readonly appDir: string; +} + +export const api = { + readFile: (filePath: string): Promise => getJson(`/api/file?path=${encodeURIComponent(filePath)}`), + getTree: (): Promise => getJson('/api/tree'), + getViolations: (): Promise => getJson('/api/policy-validation'), + getAppInfo: (): Promise => getJson('/api/info'), + /** + * Subscribe to the server's live-refresh stream. `onAssemblyChanged` fires + * when the cloud assembly is rewritten (re-fetch tree/violations); + * `onSourceChanged` fires when a source file is edited (re-check the open + * file's staleness). Returns an unsubscribe that closes the EventSource. + */ + subscribe: (handlers: { onAssemblyChanged?: () => void; onSourceChanged?: () => void }): (() => void) => { + const source = new EventSource('/api/events'); + if (handlers.onAssemblyChanged) { + source.addEventListener(ASSEMBLY_CHANGED, () => handlers.onAssemblyChanged!()); + } + if (handlers.onSourceChanged) { + source.addEventListener(SOURCE_CHANGED, () => handlers.onSourceChanged!()); + } + return () => source.close(); + }, + getTemplate: (file: string): Promise => getJson(`/api/template?file=${encodeURIComponent(file)}`), +}; diff --git a/packages/@aws-cdk/cdk-explorer/frontend/components/CodeViewer.tsx b/packages/@aws-cdk/cdk-explorer/frontend/components/CodeViewer.tsx new file mode 100644 index 000000000..d5f2dd748 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/components/CodeViewer.tsx @@ -0,0 +1,241 @@ +import * as React from 'react'; +import { tokenizeLines, type Language, type Token } from '../syntax'; + +export interface Diagnostic { + readonly startLine: number; + readonly startCol: number; + readonly endCol?: number; + readonly severity: 'error' | 'warning' | 'info'; + readonly message?: string; +} + +export interface CodeViewerProps { + readonly content: string; + readonly language: Language; + readonly highlightStart?: number; + readonly highlightEnd?: number; + readonly highlightColor?: string; + readonly navCounter?: number; + readonly scrollToLine?: number; + readonly onLineDoubleClick?: (line: number) => void; + readonly diagnostics?: readonly Diagnostic[]; +} + +const SQUIGGLE_COLORS: Record = { + error: '#d91515', + warning: '#ff9900', + info: '#0972d3', +}; + +const TOKEN_COLORS: Record = { + keyword: '#0000ff', + string: '#a31515', + number: '#098658', + boolean: '#0000ff', + 'attr-name': '#0451a5', + property: '#0451a5', + operator: '#393a34', + punctuation: '#393a34', + comment: '#008000', + 'class-name': '#267f99', + builtin: '#267f99', + function: '#795e26', + tag: '#0451a5', + selector: '#0451a5', + key: '#0451a5', + 'atrule': '#0000ff', + // YAML-specific + important: '#098658', + // null/undefined + 'null': '#0000ff', +}; + +export function CodeViewer({ + content, + language, + highlightStart, + highlightEnd, + highlightColor, + navCounter, + scrollToLine, + onLineDoubleClick, + diagnostics, +}: CodeViewerProps): JSX.Element { + const scrollTargetRef = React.useRef(null); + + const tokenizedLines = React.useMemo( + () => tokenizeLines(content, language), + [content, language], + ); + + const gutterWidth = React.useMemo(() => { + const digits = String(tokenizedLines.length).length; + return `${digits * 8 + 8}px`; + }, [tokenizedLines.length]); + + const diagnosticsByLine = React.useMemo(() => { + if (!diagnostics?.length) return undefined; + const map = new Map(); + for (const d of diagnostics) { + const arr = map.get(d.startLine) ?? []; + arr.push(d); + map.set(d.startLine, arr); + } + return map; + }, [diagnostics]); + + const lastNavRef = React.useRef(); + const animateNav = navCounter !== lastNavRef.current; + React.useEffect(() => { lastNavRef.current = navCounter; }, [navCounter]); + + React.useEffect(() => { + if (scrollToLine && scrollTargetRef.current) { + scrollTargetRef.current.scrollIntoView({ block: 'start', behavior: animateNav ? 'smooth' : 'instant' }); + } + }, [scrollToLine, navCounter]); + + return ( +
+ {tokenizedLines.map((lineTokens, i) => { + const lineNum = i + 1; + const isHighlighted = highlightStart !== undefined + && highlightEnd !== undefined + && lineNum >= highlightStart + && lineNum <= highlightEnd; + const isScrollTarget = lineNum === scrollToLine; + const lineDiagnostics = diagnosticsByLine?.get(lineNum); + + return ( +
onLineDoubleClick(lineNum) : undefined} + > + {lineNum} + + {lineDiagnostics + ? renderWithDiagnostics(lineTokens, lineDiagnostics) + : renderTokens(lineTokens) + } + +
+ ); + })} +
+ ); +} + +function renderTokens(tokens: Token[]): React.ReactNode { + return tokens.map((token, j) => { + const color = token.type ? TOKEN_COLORS[token.type] : undefined; + return color + ? {token.content} + : {token.content}; + }); +} + +function renderWithDiagnostics(tokens: Token[], diagnostics: Diagnostic[]): React.ReactNode { + let col = 1; + const elements: React.ReactNode[] = []; + let key = 0; + + for (const token of tokens) { + const tokenStart = col; + const tokenEnd = col + token.content.length; + const color = token.type ? TOKEN_COLORS[token.type] : undefined; + + let hasOverlap = false; + for (const d of diagnostics) { + const dEnd = d.endCol ?? 999; + if (d.startCol < tokenEnd && dEnd > tokenStart) { + hasOverlap = true; + break; + } + } + + if (!hasOverlap) { + elements.push( + color + ? {token.content} + : {token.content}, + ); + } else { + const chars = token.content; + let segStart = 0; + let currentSeverity = ''; + + for (let c = 0; c <= chars.length; c++) { + const charCol = tokenStart + c; + let severity = ''; + for (const d of diagnostics) { + const dEnd = d.endCol ?? 999; + if (charCol >= d.startCol && charCol < dEnd) { + severity = d.severity; + break; + } + } + + if (c === chars.length || severity !== currentSeverity) { + if (segStart < c) { + const text = chars.slice(segStart, c); + if (currentSeverity) { + elements.push( + + {text} + , + ); + } else { + elements.push( + color + ? {text} + : {text}, + ); + } + } + segStart = c; + currentSeverity = severity; + } + } + } + + col = tokenEnd; + } + + return elements; +} + +const CONTAINER_STYLE: React.CSSProperties = { + margin: 0, + maxHeight: '100%', + overflowX: 'auto', + overflowY: 'auto', + fontFamily: 'Monaco, Menlo, "Courier New", monospace', + fontSize: '12px', + lineHeight: '18px', + whiteSpace: 'pre', +}; + +const LINE_STYLE: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + minHeight: '18px', + position: 'relative', +}; + +const LINE_NUM_STYLE: React.CSSProperties = { + flexShrink: 0, + textAlign: 'right', + paddingRight: '8px', + color: '#9ba7b6', + userSelect: 'none', +}; + +const LINE_CONTENT_STYLE: React.CSSProperties = { + flex: '1 1 auto', + minWidth: 0, +}; diff --git a/packages/@aws-cdk/cdk-explorer/frontend/components/ConstructTree.tsx b/packages/@aws-cdk/cdk-explorer/frontend/components/ConstructTree.tsx new file mode 100644 index 000000000..762cec8b2 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/components/ConstructTree.tsx @@ -0,0 +1,156 @@ +import Box from '@cloudscape-design/components/box'; +import Icon from '@cloudscape-design/components/icon'; +import * as React from 'react'; +import { severityHexColor } from '../../lib/web/severity'; +import type { WebConstructNode } from '../api'; +import type { NavigateHandler } from '../nav-types'; + +interface ConstructTreeProps { + readonly nodes: readonly WebConstructNode[]; + readonly depth?: number; + readonly onNavigate: NavigateHandler; +} + +/** Auto-expand the tree through this depth on load (0 = root), so stacks and their top-level constructs are visible without manual clicks. */ +const AUTO_EXPAND_DEPTH = 2; + +export function ConstructTree({ nodes, depth = 0, onNavigate }: ConstructTreeProps): JSX.Element { + if (nodes.length === 0) { + return No constructs.; + } + const list = ( +
    + {nodes.map((node) => ( + + ))} +
+ ); + // Root render: wrap once in the clipping viewport; nested calls return the bare list. + return depth === 0 ?
{list}
: list; +} + +function TreeNode({ node, depth, onNavigate }: { readonly node: WebConstructNode; readonly depth: number; readonly onNavigate: NavigateHandler }): JSX.Element { + const hasChildren = node.children.length > 0; + const [expanded, setExpanded] = React.useState(depth < AUTO_EXPAND_DEPTH); + + const label = friendlyName(node); + const severity = node.highestSeverity; + const severityColor = severity ? severityHexColor(severity) : undefined; + const inherited = !severity ? node.inheritedSeverity : undefined; + const inheritedColor = inherited ? severityHexColor(inherited) : undefined; + + const clickTimer = React.useRef | null>(null); + + const handleClick = React.useCallback(() => { + if (!hasChildren) return; + if (clickTimer.current) return; + clickTimer.current = setTimeout(() => { + clickTimer.current = null; + setExpanded((e) => !e); + }, 200); + }, [hasChildren]); + + const handleDoubleClick = React.useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + if (clickTimer.current) { + clearTimeout(clickTimer.current); + clickTimer.current = null; + } + if (!node.sourceLocation && !node.templateFile) return; + const color = severityColor ?? '#5f6b7a'; + onNavigate({ + sourceLocation: node.sourceLocation, + templateFile: node.templateFile, + logicalId: node.logicalId, + constructPath: node.path, + color, + }); + }, [node, severityColor, onNavigate]); + + return ( +
  • +
    + {hasChildren ? {expanded ? '▾' : '▸'} : } + + {severityColor && ( + + )} + + {label} + + {node.type && ( + + {friendlyType(node.type)} + + )} +
    + {hasChildren && expanded && } +
  • + ); +} + +/** Friendlier default label: a generic CDK id ("Resource"/"Default") shows its resource type instead. */ +function friendlyName(node: WebConstructNode): string { + if ((node.id === 'Resource' || node.id === 'Default') && node.type) { + return friendlyType(node.type); + } + return node.id; +} + +/** "AWS::DynamoDB::Table" -> "DynamoDB Table". */ +function friendlyType(type: string): string { + return type.replace(/^AWS::/, '').split('::').join(' '); +} + +function labelStyle(severityColor: string | undefined, inheritedColor: string | undefined): React.CSSProperties { + if (severityColor) return { ...LABEL_STYLE, color: severityColor }; + if (inheritedColor) return { ...LABEL_STYLE, color: inheritedColor }; + return LABEL_STYLE; +} + +const TREE_VIEWPORT_STYLE: React.CSSProperties = { overflowX: 'hidden', overflowY: 'auto', height: '100%' }; +const SEVERITY_DOT_STYLE: React.CSSProperties = { + flexShrink: 0, + width: '8px', + height: '8px', + borderRadius: '50%', +}; +const LIST_STYLE: React.CSSProperties = { listStyle: 'none', margin: 0, paddingLeft: '12px', borderLeft: '1px solid #e9ebed' }; +const ITEM_STYLE: React.CSSProperties = { padding: '2px 0' }; +const ROW_STYLE: React.CSSProperties = { display: 'flex', alignItems: 'center', gap: '4px', minWidth: 0, overflow: 'hidden', cursor: 'default' }; +const ROW_CLICKABLE_STYLE: React.CSSProperties = { ...ROW_STYLE, cursor: 'pointer' }; +const LABEL_STYLE: React.CSSProperties = { + flex: '1 1 auto', + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontWeight: 500, +}; +const TYPE_STYLE: React.CSSProperties = { + flex: '0 1 auto', + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + paddingLeft: '8px', + color: '#5f6b7a', + fontSize: '12px', +}; +const CARET_STYLE: React.CSSProperties = { + width: '12px', + flexShrink: 0, + fontSize: '10px', + lineHeight: 1, +}; +const CARET_SPACER: React.CSSProperties = { display: 'inline-block', width: '12px', flexShrink: 0 }; diff --git a/packages/@aws-cdk/cdk-explorer/frontend/components/TemplateViewer.tsx b/packages/@aws-cdk/cdk-explorer/frontend/components/TemplateViewer.tsx new file mode 100644 index 000000000..5c85b53f2 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/components/TemplateViewer.tsx @@ -0,0 +1,161 @@ +import { isNode, isScalar, isMap, LineCounter, parseDocument, stringify } from 'yaml'; +import * as React from 'react'; +import type { TemplateResource, WebViolation } from '../api'; +import { CodeViewer, type Diagnostic } from './CodeViewer'; + +export interface TemplateViewerProps { + readonly jsonContent: string; + readonly resources: Record; + readonly highlightLogicalId?: string; + readonly highlightColor?: string; + readonly navCounter?: number; + readonly onResourceDoubleClick?: (logicalId: string) => void; + readonly templateFile?: string; + readonly violations?: readonly WebViolation[]; + readonly format: Format; +} + +export type Format = 'yaml' | 'json'; + +interface ResourceSection { + readonly logicalId: string; + readonly startLine: number; + readonly endLine: number; +} + +export function TemplateViewer({ + jsonContent, + resources, + highlightLogicalId, + highlightColor, + navCounter, + onResourceDoubleClick, + templateFile, + violations, + format, +}: TemplateViewerProps): JSX.Element { + + const { displayContent, displayResources, sections } = React.useMemo(() => { + if (format === 'json') { + return { + displayContent: jsonContent, + displayResources: resources, + sections: buildSections(resources), + }; + } + return jsonToYaml(jsonContent); + }, [jsonContent, resources, format]); + + // Resolve the highlight from the rendered format's own ranges (JSON block in + // JSON view, YAML block in YAML view), so it is always in the coordinate + // system on screen. Derived, so toggling format re-resolves. + const highlight = React.useMemo(() => { + const block = highlightLogicalId ? displayResources[highlightLogicalId]?.block : undefined; + return block ? { start: block.startLine, end: block.endLine } : undefined; + }, [highlightLogicalId, displayResources]); + + const diagnostics = React.useMemo(() => { + if (!templateFile || !violations?.length) return undefined; + const lines = displayContent.split('\n'); + const diags: Diagnostic[] = []; + for (const violation of violations) { + const severity = violationSeverity(violation.severity); + for (const occ of violation.occurrences) { + if (occ.templateFile === templateFile && occ.logicalId) { + const resource = displayResources[occ.logicalId]; + if (resource) { + const line = lines[resource.block.startLine - 1]; + const startCol = line ? line.search(/\S/) + 1 : 1; + diags.push({ startLine: resource.block.startLine, startCol: Math.max(1, startCol), severity, message: violation.description }); + } + } + } + } + return diags.length > 0 ? diags : undefined; + }, [templateFile, violations, displayResources, displayContent]); + + const handleDoubleClick = React.useCallback((line: number) => { + if (!onResourceDoubleClick) return; + // Map the clicked line back to the resource that owns it, so the reverse + // jump is identity-based like the forward one. + const section = sections.find((s) => line >= s.startLine && line <= s.endLine); + if (section) { + onResourceDoubleClick(section.logicalId); + } + }, [sections, onResourceDoubleClick]); + + return ( + + ); +} + +function buildSections(resources: Record): ResourceSection[] { + return Object.entries(resources) + .map(([logicalId, r]) => ({ logicalId, startLine: r.block.startLine, endLine: r.block.endLine })) + .sort((a, b) => a.startLine - b.startLine); +} + +interface YamlResult { + displayContent: string; + displayResources: Record; + sections: ResourceSection[]; +} + +function jsonToYaml(jsonContent: string): YamlResult { + let parsed: unknown; + try { + parsed = JSON.parse(jsonContent); + } catch { + return { displayContent: jsonContent, displayResources: {}, sections: [] }; + } + + // Render and measure with the same parser so the text and the ranges always + // agree. lineWidth:0 disables wrapping (keeps one CFN value per line, like the + // old serializer); aliasDuplicateObjects:false keeps CloudFormation's repeated + // objects inline instead of emitting &anchor/*alias. + const displayContent = stringify(parsed, { indent: 2, lineWidth: 0, aliasDuplicateObjects: false }); + const lineCounter = new LineCounter(); + const doc = parseDocument(displayContent, { lineCounter }); + + const displayResources: Record = {}; + const resources = doc.get('Resources'); + if (isMap(resources)) { + for (const pair of resources.items) { + const key = pair.key; + const value = pair.value; + if (!isScalar(key) || !isNode(value) || key.range == null || value.range == null) { + continue; + } + // range is [valueStart, valueEnd, nodeEnd]; a resource block spans from its + // logical-id key line through the end of its value. + const startLine = lineCounter.linePos(key.range[0]).line; + const endLine = lineCounter.linePos(value.range[1]).line; + displayResources[String(key.value)] = { block: { startLine, endLine } }; + } + } + + return { displayContent, displayResources, sections: buildSections(displayResources) }; +} + + +function violationSeverity(severity: string | undefined): 'error' | 'warning' | 'info' { + switch (severity) { + case 'fatal': + case 'error': + return 'error'; + case 'warning': + return 'warning'; + default: + return 'info'; + } +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/components/ViolationsPanel.tsx b/packages/@aws-cdk/cdk-explorer/frontend/components/ViolationsPanel.tsx new file mode 100644 index 000000000..e29faef2b --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/components/ViolationsPanel.tsx @@ -0,0 +1,143 @@ +import Box from '@cloudscape-design/components/box'; +import ExpandableSection from '@cloudscape-design/components/expandable-section'; +import SpaceBetween from '@cloudscape-design/components/space-between'; +import StatusIndicator from '@cloudscape-design/components/status-indicator'; +import * as React from 'react'; +import { displaySeverity, severityHexColor, severityRank } from '../../lib/web/severity'; +import type { WebViolation, WebViolationOccurrence } from '../api'; +import type { NavigateHandler } from '../nav-types'; + +interface ViolationsPanelProps { + readonly violations: readonly WebViolation[]; + readonly onNavigate: NavigateHandler; + readonly filter?: string; + readonly onClearFilter: () => void; + readonly search: string; +} + +export function ViolationsPanel({ violations, onNavigate, filter, search }: ViolationsPanelProps): JSX.Element { + if (violations.length === 0) { + return No policy violations.; + } + + const filtered = filterViolations(violations, filter, search); + const sorted = [...filtered].sort((a, b) => severityRank(displaySeverity(a)) - severityRank(displaySeverity(b))); + + if (sorted.length === 0) { + return {filter ? 'No violations for this resource.' : 'No matching violations.'}; + } + + return ( +
    + + {sorted.map((violation, i) => ( + + ))} + +
    + ); +} + +function filterViolations(violations: readonly WebViolation[], filter: string | undefined, search: string): readonly WebViolation[] { + let result = violations; + + if (filter) { + const filtered: WebViolation[] = []; + for (const v of result) { + const matchingOccs = v.occurrences.filter( + (occ) => occ.constructPath === filter || occ.constructPath.startsWith(filter + '/'), + ); + if (matchingOccs.length > 0) { + filtered.push({ ...v, occurrences: matchingOccs }); + } + } + result = filtered; + } + + if (search.trim()) { + const q = search.trim().toLowerCase(); + result = result.filter((v) => + v.ruleName.toLowerCase().includes(q) || + (v.description ?? '').toLowerCase().includes(q) || + (v.suggestedFix ?? '').toLowerCase().includes(q) || + v.occurrences.some((occ) => occ.constructPath.toLowerCase().includes(q)), + ); + } + + return result; +} + + +function ViolationItem({ violation, onNavigate }: { readonly violation: WebViolation; readonly onNavigate: NavigateHandler }): JSX.Element { + const severity = displaySeverity(violation); + const count = violation.occurrences.length; + const title = violation.description?.trim() || violation.ruleName; + const showRuleName = title !== violation.ruleName; + return ( + +
    + + [{severity.toUpperCase()}] + {title} + + {showRuleName && {violation.ruleName}} +
    +
    + {count} {count === 1 ? 'construct' : 'constructs'} {'·'} {violation.source} +
    + + } + > +
    + {violation.suggestedFix && Suggested fix: {violation.suggestedFix}} + {violation.occurrences.map((occ, i) => ( + + ))} +
    +
    + ); +} + +function OccurrenceRow({ occurrence, severity, onNavigate }: { + readonly occurrence: WebViolationOccurrence; + readonly severity: string; + readonly onNavigate: NavigateHandler; +}): JSX.Element { + const handleClick = React.useCallback(() => { + if (!occurrence.sourceLocation && !occurrence.templateFile) return; + onNavigate({ + sourceLocation: occurrence.sourceLocation, + templateFile: occurrence.templateFile, + logicalId: occurrence.logicalId, + propertyPaths: occurrence.propertyPaths, + color: severityHexColor(severity), + }); + }, [occurrence, severity, onNavigate]); + + return ( + + + {occurrence.constructPath} + {occurrence.logicalId ? ` → ${occurrence.logicalId}` : ''} + {occurrence.templateFile ? ` (${occurrence.templateFile})` : ''} + + + ); +} + +function severityStyle(severity: string): React.CSSProperties { + return { color: severityHexColor(severity), fontWeight: 700, whiteSpace: 'nowrap', flexShrink: 0 }; +} + +const SCROLL_STYLE: React.CSSProperties = { flex: '1 1 0', overflowY: 'auto', overflowX: 'hidden', minHeight: 0 }; +const HEADER_WRAPPER_STYLE: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: '2px', width: '100%', overflow: 'hidden' }; +const HEADER_ROW_STYLE: React.CSSProperties = { display: 'flex', alignItems: 'baseline', gap: '8px', width: '100%', justifyContent: 'space-between', overflow: 'hidden' }; +const TITLE_GROUP_STYLE: React.CSSProperties = { display: 'flex', alignItems: 'baseline', gap: '8px', minWidth: 0, flex: '1 1 0', overflow: 'hidden' }; +const RULE_STYLE: React.CSSProperties = { fontWeight: 700, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }; +const RULE_NAME_STYLE: React.CSSProperties = { fontFamily: 'monospace', fontSize: '12px', color: '#5f6b7a', fontWeight: 400, whiteSpace: 'nowrap', flexShrink: 0 }; +const SUBTITLE_STYLE: React.CSSProperties = { color: '#5f6b7a', fontWeight: 400, fontSize: '12px' }; +const BODY_STYLE: React.CSSProperties = { paddingLeft: '4px', display: 'flex', flexDirection: 'column', gap: '4px' }; +const LINK_STYLE: React.CSSProperties = { color: '#0972d3', textDecoration: 'underline', cursor: 'pointer' }; diff --git a/packages/@aws-cdk/cdk-explorer/frontend/highlight.css b/packages/@aws-cdk/cdk-explorer/frontend/highlight.css new file mode 100644 index 000000000..438c3ea6e --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/highlight.css @@ -0,0 +1,34 @@ +@keyframes nav-highlight-fade { + from { opacity: 0.35; } + to { opacity: 0; } +} + +.nav-highlight { + position: relative; +} + +.nav-highlight::before { + content: ''; + position: absolute; + inset: 0; + background-color: var(--nav-highlight-color, #0972d3); + animation: nav-highlight-fade 3s ease-out forwards; + pointer-events: none; +} + +/* Make expandable section headers stretch to full width */ +[class*="header-wrapper_"] { + width: 100%; +} +[class*="expand-button_"][class*="header-button_"] { + flex: 1 1 auto; +} +[class*="header-text_"][class*="header-label_"] { + flex: 1 1 auto; +} + +/* Tighten expanded content: reduce top gap and indent under header text */ +[class*="content_"][class*="content-footer_"] { + padding-top: 2px !important; + padding-left: 20px !important; +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/index.html b/packages/@aws-cdk/cdk-explorer/frontend/index.html new file mode 100644 index 000000000..bfe35e541 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + CDK Web Explorer + + + + +
    + + + diff --git a/packages/@aws-cdk/cdk-explorer/frontend/index.tsx b/packages/@aws-cdk/cdk-explorer/frontend/index.tsx new file mode 100644 index 000000000..83d6a6e82 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/index.tsx @@ -0,0 +1,18 @@ +import '@cloudscape-design/global-styles/index.css'; +import './highlight.css'; +import { applyMode, Mode } from '@cloudscape-design/global-styles'; +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +applyMode(Mode.Light); + +const container = document.getElementById('root'); +if (!container) { + throw new Error('CDK Explorer: #root element not found'); +} +createRoot(container).render( + + + , +); diff --git a/packages/@aws-cdk/cdk-explorer/frontend/nav-types.ts b/packages/@aws-cdk/cdk-explorer/frontend/nav-types.ts new file mode 100644 index 000000000..8acafc0ff --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/nav-types.ts @@ -0,0 +1,10 @@ +import type { WebSourceLocation } from './api'; + +export type NavigateHandler = (opts: { + sourceLocation?: WebSourceLocation; + templateFile?: string; + logicalId?: string; + propertyPaths?: readonly string[]; + color?: string; + constructPath?: string; +}) => void; diff --git a/packages/@aws-cdk/cdk-explorer/frontend/prismjs.d.ts b/packages/@aws-cdk/cdk-explorer/frontend/prismjs.d.ts new file mode 100644 index 000000000..18aac4244 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/prismjs.d.ts @@ -0,0 +1,9 @@ +declare module 'prismjs/components/prism-core' { + import Prism from 'prismjs'; + export default Prism; +} +declare module 'prismjs/components/prism-json' {} +declare module 'prismjs/components/prism-yaml' {} +declare module 'prismjs/components/prism-clike' {} +declare module 'prismjs/components/prism-javascript' {} +declare module 'prismjs/components/prism-typescript' {} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/syntax.ts b/packages/@aws-cdk/cdk-explorer/frontend/syntax.ts new file mode 100644 index 000000000..46b90c15a --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/syntax.ts @@ -0,0 +1,93 @@ +/** + * PrismJS-based syntax tokenizer covering the template formats (JSON, YAML) and + * every CDK source language (TypeScript, JavaScript, Python, Java, C#, Go). + * Returns structured tokens per line for our custom line renderer (which handles + * highlighting, scroll, diagnostics). + */ +import Prism from 'prismjs/components/prism-core'; +import 'prismjs/components/prism-json'; +import 'prismjs/components/prism-yaml'; +import 'prismjs/components/prism-clike'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/components/prism-typescript'; +import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-java'; +import 'prismjs/components/prism-csharp'; +import 'prismjs/components/prism-go'; + +/** Languages the viewer can highlight: template formats plus CDK source languages. */ +export type Language = 'json' | 'yaml' | 'typescript' | 'javascript' | 'python' | 'java' | 'csharp' | 'go'; + +export interface Token { + readonly type: string | undefined; + readonly content: string; +} + +const GRAMMAR_MAP: Record = { + json: Prism.languages.json, + yaml: Prism.languages.yaml, + typescript: Prism.languages.typescript, + javascript: Prism.languages.javascript, + python: Prism.languages.python, + java: Prism.languages.java, + csharp: Prism.languages.csharp, + go: Prism.languages.go, +}; + +export function tokenizeLines(code: string, language: Language): Token[][] { + const grammar = GRAMMAR_MAP[language]; + if (!grammar) { + return code.split('\n').map((line) => [{ type: undefined, content: line }]); + } + + const tokens = Prism.tokenize(code, grammar); + return splitIntoLines(tokens); +} + +function splitIntoLines(tokens: Array): Token[][] { + const lines: Token[][] = [[]]; + + for (const token of tokens) { + if (typeof token === 'string') { + const parts = token.split('\n'); + for (let i = 0; i < parts.length; i++) { + if (i > 0) lines.push([]); + if (parts[i]) { + lines[lines.length - 1].push({ type: undefined, content: parts[i] }); + } + } + } else { + flattenToken(token, lines); + } + } + + return lines; +} + +function flattenToken(token: Prism.Token, lines: Token[][]): void { + const type = token.type; + + if (typeof token.content === 'string') { + const parts = token.content.split('\n'); + for (let i = 0; i < parts.length; i++) { + if (i > 0) lines.push([]); + if (parts[i]) { + lines[lines.length - 1].push({ type, content: parts[i] }); + } + } + } else if (Array.isArray(token.content)) { + for (const inner of token.content) { + if (typeof inner === 'string') { + const parts = inner.split('\n'); + for (let i = 0; i < parts.length; i++) { + if (i > 0) lines.push([]); + if (parts[i]) { + lines[lines.length - 1].push({ type, content: parts[i] }); + } + } + } else { + flattenToken(inner, lines); + } + } + } +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/api-private.ts b/packages/@aws-cdk/cdk-explorer/lib/api-private.ts index 006d91e2d..811a6e738 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/api-private.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/api-private.ts @@ -2,3 +2,4 @@ export { findCreationStackTrace } from '../../toolkit-lib/lib/api/source-tracing/private/stack-source-tracing'; export { WATCH_EXCLUDE_DEFAULTS } from '../../toolkit-lib/lib/actions/watch/private/helpers'; export { createIgnoreMatcher } from '../../toolkit-lib/lib/util/glob-matcher'; +export { SYNTH_LOCK_FILE } from '../../toolkit-lib/lib/api/rwlock'; diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-watcher.ts b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-watcher.ts index ce065f21e..03c839408 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/assembly-watcher.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/assembly-watcher.ts @@ -2,6 +2,7 @@ import * as path from 'path'; import { MANIFEST_FILE } from '@aws-cdk/cloud-assembly-api'; import { VALIDATION_REPORT_FILE } from '@aws-cdk/cloud-assembly-schema'; import * as chokidar from 'chokidar'; +import { SYNTH_LOCK_FILE } from '../api-private'; /** * The name of the construct tree metadata file emitted alongside the manifest. @@ -51,6 +52,12 @@ export interface AssemblyWatcherOptions { readonly assemblyDir: string; /** Invoked (debounced) when the assembly's signal files change. */ readonly onChange: () => void; + /** + * Invoked when synth write-lock activity (`synth.lock`) first appears, i.e. a + * synth started. `atMs` is the observation time. Lets a consumer date-stamp + * source-file staleness against synth start rather than synth finish. + */ + readonly onSynthActivity?: (atMs: number) => void; /** Receives non-fatal watcher errors. */ readonly onError?: (error: unknown) => void; /** @@ -90,9 +97,19 @@ export function startAssemblyWatcher(options: AssemblyWatcherOptions): AssemblyW const watcher = createWatcher(options.assemblyDir); - watcher.on('all', (_eventName, filePath) => { + watcher.on('all', (eventName, filePath) => { if (closed) return; - if (!ASSEMBLY_SIGNAL_FILES.has(path.basename(filePath))) return; + const base = path.basename(filePath); + if (base === SYNTH_LOCK_FILE) { + // toolkit-lib's `RWLock` creates this marker for the duration of a synth, + // so its appearance is our only signal that a synth has started (the + // explorer never starts synths itself). Record it, but never treat it as + // an assembly refresh: the lock's create/delete would otherwise cause + // spurious reloads. + if (eventName === 'add') options.onSynthActivity?.(Date.now()); + return; + } + if (!ASSEMBLY_SIGNAL_FILES.has(base)) return; if (timer) { clearTimeout(timer); } diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/source-watcher.ts b/packages/@aws-cdk/cdk-explorer/lib/core/source-watcher.ts new file mode 100644 index 000000000..8fbefe812 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/core/source-watcher.ts @@ -0,0 +1,130 @@ +import * as chokidar from 'chokidar'; +import { WATCH_EXCLUDE_DEFAULTS, createIgnoreMatcher } from '../api-private'; +import type { FileWatcher } from './assembly-watcher'; + +// Coalesce an editor's burst of writes (e.g. save-all) into a single signal. +const DEBOUNCE_MS = 200; + +/** + * Paths under the app dir that never count as a source change: toolkit-lib's + * own watch exclusions, dependencies, our synth output (`cdk.out`), and + * dotfiles (editor configs, `.git`). Applied both at the chokidar level (so the + * tree is never traversed) and re-checked in the handler. + */ +export const SOURCE_WATCH_EXCLUDES = [ + ...WATCH_EXCLUDE_DEFAULTS, + '**/node_modules/**', + '**/cdk.out/**', + '.*', + '**/.*', + '**/.*/**', +]; + +/** A running source-tree watcher. */ +export interface SourceWatcher { + /** Stop watching and release the underlying file handles. */ + close(): Promise; +} + +export interface SourceWatcherOptions { + /** The application directory whose source tree is watched. */ + readonly appDir: string; + /** Invoked (debounced) when a non-ignored source file changes. */ + readonly onChange: () => void; + /** Receives non-fatal watcher errors. */ + readonly onError: (error: unknown) => void; + /** + * Factory for the underlying file watcher. Defaults to chokidar; overridden + * in tests with a fake so behavior is verified without real file IO. + */ + readonly createWatcher?: (appDir: string) => FileWatcher; +} + +// Thin wrapper over real chokidar; exercised via integration, not unit tests. +/* c8 ignore start */ +function defaultCreateWatcher(appDir: string): FileWatcher { + // chokidar applies the same ignore policy while traversing, so excluded paths + // (node_modules, cdk.out, dotfiles) are skipped at the source instead of + // streamed to the handler. It emits absolute paths, which the handler + // re-checks against the same policy. + return chokidar.watch(appDir, { + ignored: createIgnoreMatcher({ exclude: SOURCE_WATCH_EXCLUDES, rootDir: appDir }), + ignoreInitial: true, + }) as unknown as FileWatcher; +} +/* c8 ignore stop */ + +/** A debounced action: coalesces a burst of triggers into one delayed run. */ +interface Debounced { + /** Schedule `action`, resetting the delay if a run is already pending. */ + trigger(): void; + /** Drop a pending run, if any. */ + cancel(): void; +} + +/** + * Run `action` once, `delayMs` after the most recent `trigger()`. Each new + * trigger restarts the delay, so a burst collapses into a single trailing run. + * `cancel()` discards a pending run (used on close). This is a debounce, not a + * sleep: the pending run is cancelable and reset on every trigger. + */ +function debounce(action: () => void, delayMs: number): Debounced { + let timer: NodeJS.Timeout | undefined; + return { + trigger() { + if (timer) { + clearTimeout(timer); + } + timer = setTimeout(() => { + timer = undefined; + action(); + }, delayMs); + }, + cancel() { + if (timer) { + clearTimeout(timer); + timer = undefined; + } + }, + }; +} + +/** + * Watch a CDK app's source tree and fire `onChange` (debounced) when a + * non-ignored file changes. + */ +export function startSourceWatcher(options: SourceWatcherOptions): SourceWatcher { + const createWatcher = options.createWatcher ?? defaultCreateWatcher; + // Drop changes to ignored paths so they never raise a spurious signal. chokidar + // emits absolute paths, which the matcher's rootDir resolves before matching. + const shouldIgnore = createIgnoreMatcher({ exclude: SOURCE_WATCH_EXCLUDES, rootDir: options.appDir }); + + let closed = false; + const debounced = debounce(() => { + try { + options.onChange(); + } catch (error) { + options.onError(error); + } + }, DEBOUNCE_MS); + + const watcher = createWatcher(options.appDir); + + watcher.on('all', (_eventName, filePath) => { + if (closed) return; + if (shouldIgnore(filePath)) return; + debounced.trigger(); + }); + + watcher.on('error', (error) => { + options.onError(error); + }); + + return { + async close() { + closed = true; + debounced.cancel(); + await watcher.close(); + }, + }; +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/index.ts b/packages/@aws-cdk/cdk-explorer/lib/index.ts index 45fde8d68..8a5bc5d37 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/index.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/index.ts @@ -3,8 +3,13 @@ export const VERSION = '0.0.0'; export { readAssembly } from './core/assembly-reader'; export type { AssemblyData, AssemblyReadResult, ConstructNode } from './core/assembly-reader'; export type { SourceLocation } from './core/source-resolver'; +export { toolkitAssemblyLock } from './core/assembly-lock'; +export type { AssemblyLock, AcquireAssemblyLock } from './core/assembly-lock'; export { startLspServer } from './lsp/main'; export { cdkLspManifest } from './lsp/features'; export { startServer, createLspHandlers } from './lsp/server'; export type { LspHandlers, LspHandlerOptions, LspServerOptions } from './lsp/server'; + +export { startWebServer } from './web/server'; +export type { WebServer, WebServerOptions } from './web/server'; diff --git a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts index 1dc56709f..1e9df37af 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/lsp/server.ts @@ -25,7 +25,7 @@ import { type Location, type RemoteConsole, } from 'vscode-languageserver/node'; -import { WATCH_EXCLUDE_DEFAULTS, createIgnoreMatcher } from '../api-private'; +import { createIgnoreMatcher } from '../api-private'; import { codeLensesForFile } from './codelens'; import { executeCommand, SUPPORTED_COMMANDS, type NotifySink } from './commands'; import { GetConstructTreeRequest, type ConstructSourceEntry, type GetConstructTreeResult } from './construct-tree-request'; @@ -47,6 +47,7 @@ import { type AssemblyWatcherOptions, } from '../core/assembly-watcher'; import { isWithinRoot } from '../core/source-resolver'; +import { SOURCE_WATCH_EXCLUDES } from '../core/source-watcher'; import type { SynthRunResult } from '../core/synth-runner'; /** @@ -423,18 +424,10 @@ export function createLspHandlers(options: LspHandlerOptions): LspHandlers { }, async onInitialized() { const projectDir = currentProjectDir(); - // Same exclusion logic as toolkit-lib's watch(): - // WATCH_EXCLUDE_DEFAULTS covers common non-source dirs, then we add cdk.out - // (our own output) and dotfiles (editor configs, .git, etc.) + // Ignore non-source paths (deps, cdk.out, dotfiles) using the same policy + // the web explorer's source watcher applies. shouldIgnore = createIgnoreMatcher({ - exclude: [ - ...WATCH_EXCLUDE_DEFAULTS, - '**/node_modules/**', - '**/cdk.out/**', - '.*', - '**/.*', - '**/.*/**', - ], + exclude: SOURCE_WATCH_EXCLUDES, rootDir: projectDir, }); await refreshFromAssembly(projectDir); diff --git a/packages/@aws-cdk/cdk-explorer/lib/web/events.ts b/packages/@aws-cdk/cdk-explorer/lib/web/events.ts new file mode 100644 index 000000000..889aa818b --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/web/events.ts @@ -0,0 +1,100 @@ +import type { Request, Response } from 'express'; +import type { SseEventName } from './protocol'; + +/** + * Simultaneous streams one explorer will hold open. The model is one developer + * with a handful of browser tabs, so this is far above any real use while + * bounding what a script hammering `/api/events` can pin in memory. + */ +export const MAX_SSE_CLIENTS = 32; + +/** + * How often to write an SSE comment to every stream. Keeps intermediaries from + * idling the connection out, and surfaces a client that vanished without a FIN + * (sleep, killed VM) as a socket error, which evicts it. + */ +const HEARTBEAT_MS = 30_000; + +/** + * Tracks connected Server-Sent Events clients and pushes events to all of them. + * One instance lives per web server. `close()` ends every open stream on + * shutdown so the HTTP server can stop cleanly. + */ +export class SseBroadcaster { + private readonly clients = new Set(); + /** Shared across all clients; runs only while at least one is connected. */ + private heartbeat: NodeJS.Timeout | undefined; + + /** + * Express handler for `GET /api/events`. Opens a long-lived SSE stream and + * registers the client, removing it when the request closes or the socket + * errors so a vanished client is never written to. Refuses (503) once + * {@link MAX_SSE_CLIENTS} streams are already open. + */ + public handle(req: Request, res: Response): void { + if (this.clients.size >= MAX_SSE_CLIENTS) { + res.status(503).json({ error: `too many event subscribers (limit ${MAX_SSE_CLIENTS})` }); + return; + } + res.set({ + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store', + 'Connection': 'keep-alive', + }); + res.flushHeaders(); + this.clients.add(res); + this.startHeartbeat(); + + const remove = (): void => { + this.clients.delete(res); + if (this.clients.size === 0) { + this.stopHeartbeat(); + } + }; + req.on('close', remove); + // Evict on socket error too, so a vanished client is never written to. + res.on('error', remove); + } + + /** + * Push an event to every connected client. The `data: {}` line is required: + * EventSource does not dispatch a named event whose data buffer is empty, so + * the empty payload is what makes the client's listener fire. Writing to a + * client that already disconnected is a harmless no-op (returns false, does + * not throw); the `close`/`error` handlers in `handle` do the eviction. + */ + public broadcast(event: SseEventName): void { + const frame = `event: ${event}\ndata: {}\n\n`; + for (const client of this.clients) { + client.write(frame); + } + } + + /** End every open stream and forget the clients. Called on server shutdown. */ + public close(): void { + for (const client of this.clients) { + client.end(); + } + this.clients.clear(); + this.stopHeartbeat(); + } + + private startHeartbeat(): void { + if (this.heartbeat) return; + // unref'd: an open stream must not be what keeps the process (or a test run) + // alive — shutdown is driven by close(), not by the last client leaving. + this.heartbeat = setInterval(() => { + // An SSE comment: clients ignore it, but the write fails on a dead socket, + // which fires `error` and evicts the client. + for (const client of this.clients) { + client.write(': heartbeat\n\n'); + } + }, HEARTBEAT_MS).unref(); + } + + private stopHeartbeat(): void { + if (!this.heartbeat) return; + clearInterval(this.heartbeat); + this.heartbeat = undefined; + } +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/web/local-only.ts b/packages/@aws-cdk/cdk-explorer/lib/web/local-only.ts new file mode 100644 index 000000000..33529f44e --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/web/local-only.ts @@ -0,0 +1,220 @@ +import * as crypto from 'crypto'; +import type { Request, Response, NextFunction } from 'express'; + +/** + * Request admission for the explorer, in two layers that stop different callers. + * + * {@link localOnly} inspects headers only a browser sets on the caller's behalf + * (`Host`, `Origin`, `Sec-Fetch-Site`), so it defends against a page the user + * visits — DNS rebinding and cross-origin/cross-site reads. It is worthless + * against a local process, which sets those headers to whatever it likes. + * + * {@link sessionAuth} closes that second gap with a per-session bearer token: a + * caller has to have seen the URL the CLI printed, which generally means it + * already has the user's privileges. Both layers are needed; neither replaces the + * other. + */ + +/** + * Loopback hostnames the explorer will answer to. `[::1]` is included alongside + * `::1` because `new URL('http://[::1]').hostname` keeps the brackets, while a + * bare `Host: ::1` does not. + */ +const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +/** + * True when a `Host` header names a loopback interface. The port is irrelevant; + * only the hostname decides reachability. A missing or unparseable header is + * rejected — every real browser and HTTP client sends a well-formed `Host`. + * + * This is the DNS-rebinding defense: a rebound attacker page connects to + * 127.0.0.1 but the browser still sends the site's own hostname (`evil.com`) in + * `Host`, which is not loopback and is refused. + */ +export function isLoopbackHost(hostHeader: string | undefined): boolean { + if (!hostHeader) return false; + try { + return LOOPBACK_HOSTNAMES.has(new URL(`http://${hostHeader}`).hostname); + } catch { + return false; + } +} + +/** + * True when an `Origin` header is either absent (same-origin GET, curl, SSE with + * no document origin) or names a loopback origin. A cross-origin page reaching a + * normally-resolved `localhost` sends its own `Origin` (`http://evil.com`), which + * is refused; the literal string `null` (sandboxed/opaque origins) is refused. + */ +export function isAllowedOrigin(originHeader: string | undefined): boolean { + if (originHeader === undefined) return true; + if (originHeader === 'null') return false; + try { + return LOOPBACK_HOSTNAMES.has(new URL(originHeader).hostname); + } catch { + return false; + } +} + +/** + * True unless `Sec-Fetch-Site` marks the request as cross-site. This closes the + * hole the `Origin` check alone leaves: a no-CORS subresource load from an + * attacker page (`