diff --git a/.projenrc.ts b/.projenrc.ts index 0f4fd9ada..4a621faa2 100644 --- a/.projenrc.ts +++ b/.projenrc.ts @@ -1227,15 +1227,33 @@ const cdkExplorer = configureProject( ], devDeps: [ 'vscode-languageserver-protocol@^3', + 'react@^18', + 'react-dom@^18', + '@types/react@^18', + '@types/react-dom@^18', + '@cloudscape-design/components', + '@cloudscape-design/global-styles', + '@cloudscape-design/design-tokens', + '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: { + testMatch: [ + '/**/test/**/?(*.)+(test).ts?(x)', + ], coverageThreshold: { statements: 80, branches: 80, @@ -1247,6 +1265,22 @@ const cdkExplorer = configureProject( }), ); fixupTestTask(cdkExplorer); +// esbuild only transpiles, so the SPA gets its own `tsc --noEmit` pass before +// being bundled (the package's main tsc build excludes `frontend/`). +cdkExplorer.postCompileTask.exec('tsc --noEmit -p frontend/src/tsconfig.json'); +cdkExplorer.preCompileTask.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.ts', '.DS_Store', 'frontend/**/*.d.ts', 'frontend/**/*.js'); +cdkExplorer.npmignore?.addPatterns('frontend'); +cdkExplorer.eslint?.addOverride({ + files: [ + 'frontend/**', + ], + rules: { + // Allow importing devdependencies (everything is bundled) + 'import/no-extraneous-dependencies': ['error', { devDependencies: true }], + }, +}); cdkExplorer.eslint?.addOverride({ files: [ 'lib/**', diff --git a/packages/@aws-cdk/cdk-explorer/.eslintrc.json b/packages/@aws-cdk/cdk-explorer/.eslintrc.json index b0d26ee73..7e8627062 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", @@ -364,6 +369,19 @@ ] } }, + { + "files": [ + "frontend/**" + ], + "rules": { + "import/no-extraneous-dependencies": [ + "error", + { + "devDependencies": true + } + ] + } + }, { "files": [ "lib/**" diff --git a/packages/@aws-cdk/cdk-explorer/.gitignore b/packages/@aws-cdk/cdk-explorer/.gitignore index de39c78ee..20f9a5e0f 100644 --- a/packages/@aws-cdk/cdk-explorer/.gitignore +++ b/packages/@aws-cdk/cdk-explorer/.gitignore @@ -51,3 +51,8 @@ jspm_packages/ /dist/ !/.eslintrc.json !/.eslintrc.js +lib/web/static/ +lib/web/web-assets.generated.ts +.DS_Store +frontend/**/*.d.ts +frontend/**/*.js diff --git a/packages/@aws-cdk/cdk-explorer/.npmignore b/packages/@aws-cdk/cdk-explorer/.npmignore index d9eda4d8c..66ac96c7f 100644 --- a/packages/@aws-cdk/cdk-explorer/.npmignore +++ b/packages/@aws-cdk/cdk-explorer/.npmignore @@ -21,4 +21,5 @@ tsconfig.tsbuildinfo *.ts !*.d.ts build-tools +frontend /.gitattributes diff --git a/packages/@aws-cdk/cdk-explorer/.projen/deps.json b/packages/@aws-cdk/cdk-explorer/.projen/deps.json index 0ce8d2993..16941983a 100644 --- a/packages/@aws-cdk/cdk-explorer/.projen/deps.json +++ b/packages/@aws-cdk/cdk-explorer/.projen/deps.json @@ -4,6 +4,18 @@ "name": "@cdklabs/eslint-plugin", "type": "build" }, + { + "name": "@cloudscape-design/components", + "type": "build" + }, + { + "name": "@cloudscape-design/design-tokens", + "type": "build" + }, + { + "name": "@cloudscape-design/global-styles", + "type": "build" + }, { "name": "@stylistic/eslint-plugin", "version": "^3", @@ -23,6 +35,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 +70,10 @@ "version": "^10.0.0", "type": "build" }, + { + "name": "esbuild", + "type": "build" + }, { "name": "eslint-config-prettier", "type": "build" @@ -85,14 +121,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 +163,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 616938740..25350c6df 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,@cloudscape-design/components,@cloudscape-design/design-tokens,@cloudscape-design/global-styles,@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" ] } ] @@ -144,11 +144,21 @@ }, "post-compile": { "name": "post-compile", - "description": "Runs after successful compilation" + "description": "Runs after successful compilation", + "steps": [ + { + "exec": "tsc --noEmit -p frontend/src/tsconfig.json" + } + ] }, "pre-compile": { "name": "pre-compile", - "description": "Prepare the project for compilation" + "description": "Prepare the project for compilation", + "steps": [ + { + "exec": "tsx build-tools/bundle-frontend.ts" + } + ] }, "test": { "name": "test", diff --git a/packages/@aws-cdk/cdk-explorer/CONTRIBUTING.md b/packages/@aws-cdk/cdk-explorer/CONTRIBUTING.md new file mode 100644 index 000000000..ac53c488a --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Developing on the CDK Explorer + +## Quick iteration + +```sh +$ project=/path/to/aws-cdk-cli +$ $project/node_modules/.bin/tsx $project/packages/aws-cdk/bin/cdk explore +``` 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..379941c97 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/build-tools/bundle-frontend.ts @@ -0,0 +1,91 @@ +/* eslint-disable no-console */ +/** + * Builds the web explorer SPA into lib/web/static, then writes the same assets + * to lib/web/web-assets.generated.ts so they ride the require() graph into + * the published CLI bundle (static file paths are not bundled). + */ +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', 'src'); +const outDir = path.join(packageRoot, 'lib', 'web', 'static'); +const embeddedAssetsFile = path.join(packageRoot, 'lib', 'web', 'web-assets.generated.ts'); + +async function main(): Promise { + fs.mkdirSync(outDir, { recursive: true }); + + const isWatch = process.argv[2] === '--watch'; + + const context = await esbuild.context({ + 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: 'external', // does not load map automatically, have to attach manually + logLevel: 'warning', + minify: !isWatch, + logStyle: 'default', + }); + + try { + await buildAndPostProcess(); + + if (isWatch) { + const watcher = fs.watch(path.resolve(__dirname, '..', 'frontend'), { recursive: true }, async (eventType, filename) => { + console.log(`File changed: ${filename} (${eventType})`); + await buildAndPostProcess(); + }); + + await new Promise((resolve) => { + process.on('SIGINT', resolve); + process.on('SIGTERM', resolve); + }); + + watcher.close(); + } + } finally { + await context.dispose(); + } + + async function buildAndPostProcess() { + try { + await context.rebuild(); + writeEmbeddedAssets(); + console.log('Built'); + } catch (e) { + console.error(e); + } + } +} + +const BUNDLE_FILES = ['bundle.js', 'bundle.css']; +const ASSET_FILES = ['index.html', 'icon.png']; + +function writeEmbeddedAssets(): void { + const assets = [ + ...ASSET_FILES.map(f => [f, fs.readFileSync(path.join(frontendDir, f))] as const), + ...BUNDLE_FILES.map(f => [f, fs.readFileSync(path.join(outDir, f))] as const), + ]; + fs.writeFileSync(embeddedAssetsFile, [ + 'export const EMBEDDED_ASSETS = {', + ...assets.map(([fname, buf]) => { + const containsBinary = buf.indexOf('\x00') !== -1; + const encoding = containsBinary ? 'base64' : 'utf-8'; + + return ` "${fname}": Buffer.from(${JSON.stringify(buf.toString(encoding))}, "${encoding}"),`; + }), + '};', + ].join('\n'), 'utf-8'); +} + +main().catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + process.exit(1); +}); diff --git a/packages/@aws-cdk/cdk-explorer/build-tools/watch-explore.sh b/packages/@aws-cdk/cdk-explorer/build-tools/watch-explore.sh new file mode 100755 index 000000000..5a8a2112b --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/build-tools/watch-explore.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# A bash script to watch the frontend source files and run the CDK explorer in a live mode where +# it refreshes frontend assets from the filesystem. +set -eu +scriptdir=$(cd $(dirname "$0") && pwd) +root=$(cd $scriptdir/../../../.. && pwd) + +env CDK_LIVE=1 $root/node_modules/.bin/tsx $root/packages/aws-cdk/bin/cdk explore & +trap "pkill -P $$" EXIT + +(cd $scriptdir/.. && $root/node_modules/.bin/tsx build-tools/bundle-frontend.ts --watch) 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/src/App.tsx b/packages/@aws-cdk/cdk-explorer/frontend/src/App.tsx new file mode 100644 index 000000000..bfb76d669 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/App.tsx @@ -0,0 +1,599 @@ +/* eslint-disable @cdklabs/promiseall-no-unbounded-parallelism */ +import Alert from '@cloudscape-design/components/alert'; +import AppLayoutToolbar from '@cloudscape-design/components/app-layout-toolbar'; +import Box from '@cloudscape-design/components/box'; +import Link from '@cloudscape-design/components/link'; +import Select from '@cloudscape-design/components/select'; +import Spinner from '@cloudscape-design/components/spinner'; +import SplitPanel from '@cloudscape-design/components/split-panel'; +import Tabs from '@cloudscape-design/components/tabs'; +import * as awsui from '@cloudscape-design/design-tokens'; +import * as React from 'react'; +import { api } from './api'; +import { type LineAnnotation, CodeViewer, type Diagnostic } from './components/CodeViewer'; +import { ConstructTree, ExpandState } from './components/ConstructTree'; +import { Flex, FlexFix, FlexGrow } from './components/Flex'; +import { Help } from './components/Help'; +import { LightDarkToggle } from './components/LightDarkToggle'; +import { RelativeTime } from './components/RelativeTime'; +import { colorForSeverity } from './components/severities'; +import { FormatToggle, TemplateViewer, type Format } from './components/TemplateViewer'; +import { diagnosticsPanelTitle, filterViolations, ViolationsConstructFilter, ViolationsPanelContent, ViolationsSearch } from './components/ViolationsPanel'; +import type { NavigateHandler } from './nav-types'; +import { SourceAndTemplateIndex } from './source-and-template-index'; +import type { Language } from './syntax'; +import { type WebConstructNode, type TemplateResponse, type TreeResponse, type ViolationsResponse } from '../../lib/web-shared/protocol'; +export type { NavigateHandler } from './nav-types'; + +interface SourceHighlight { + /** Inclusive */ + startLine: number; + /** Exclusive */ + endLine: number; + color: string; +} + +interface TemplateHighlight { + logicalId: string; + color: string; +} + +interface SourceFile { + fileName: string; + content: string; + stale: boolean; +} + +interface TemplateFile { + fileName: string; + response: TemplateResponse; +} + +let didInit = false; + +/** Web explorer shell. */ +export function App(): JSX.Element { + const expandState = React.useRef(new ExpandState()); + const [, expandStateChanged] = React.useState({}); + + const [tree, setTree] = React.useState(); + const [violations, setViolations] = React.useState(); + const [error, setError] = React.useState(); + const [appDir, setAppDir] = React.useState(); + + const [treeHighlight, setTreeHighlight] = React.useState(); + + // Source pane state. + const [sourceFile, setSourceFile] = React.useState(); + const [sourceHighlight, setSourceHighlight] = React.useState(); + const [templateFile, setTemplateFile] = React.useState(); + const [templateHighlight, setTemplateHighlight] = React.useState(); + const [templateFormat, setTemplateFormat] = React.useState('yaml'); + + // Violations filter: when set, only violations affecting this construct path are shown. + const [violationFilter, setViolationFilter] = React.useState(); + const [violationSearch, setViolationSearch] = React.useState(''); + + const filteredViolations = React.useMemo(() => { + if (!violations || violations.status !== 'ok') return []; + return filterViolations(violations.violations, violationFilter, violationSearch); + }, [violations, violationFilter, violationSearch]); + + const constructIndex = React.useRef(new SourceAndTemplateIndex()); + + const reload = React.useCallback(async () => { + try { + const firstLoad = tree === undefined || tree.status !== 'ok'; + + const [t, v, info] = await Promise.all([api.getTree(), api.getViolations(), api.getAppInfo()]); + + setTree(t); + if (t.status === 'ok') { + constructIndex.current = new SourceAndTemplateIndex(); + constructIndex.current.addTree(t.tree); + if (firstLoad) { + expandState.current.autoExpand(2, t.tree); + expandStateChanged({}); + } + } + + setViolations(v); + setAppDir(info.appDir); + setError(undefined); + + if (v.status === 'ok' && v.violations.length > 0) { + setDiagnosticsOpen(true); + } + } catch (err) { + // 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. + 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 => { + if (!sourceFile) return; + await navigateToSourceFile(sourceFile.fileName, 'refresh'); + }, [sourceFile]); + + React.useEffect(() => { + // useEffect runs twice, guard against double initialization + if (didInit) { + return; + } + void reload(); + + return api.subscribe({ + onAssemblyChanged: () => { + void reload(); + void refreshOpenSourceFile(); + }, + onSourceChanged: () => { + void refreshOpenSourceFile(); + }, + }); + }, []); + + /** Navigate to a construct (from tree double-click or violation double-click). */ + const onNavigate: NavigateHandler = React.useCallback(async (ev) => { + const construct = constructIndex.current.construct(ev.constructPath); + if (!construct) { + return; + } + + if (ev.trigger !== 'tree') { + // Setting to 'undefined' first followed by a redraw will make sure the highlight flashes + expandState.current.ensureParentsExpanded(construct.path); + expandStateChanged({}); + setTreeHighlight(undefined); + setTimeout(() => setTreeHighlight(construct.path), 0); + } + + const color = colorForSeverity(construct.highestSeverity); + if (ev.trigger !== 'source') { + const sourceLocation = ev.sourceLocation ?? construct.sourceLocation; + if (sourceLocation) { + // Setting to 'undefined' first followed by a redraw (forced by the 'await') will make sure the highlight flashes + setSourceHighlight(undefined); + await navigateToSourceFile(sourceLocation.file, 'nav'); + setSourceHighlight({ + startLine: sourceLocation.line, + endLine: sourceLocation.line + 1, + color, + }); + } + } + + if (ev.trigger !== 'template') { + const templateLocation = ev.templateLocation ?? construct.templateLocation; + if (templateLocation) { + // Setting to 'undefined' first followed by a redraw (forced by the 'await') will make sure the highlight flashes + setTemplateHighlight(undefined); + await navigateToTemplateFile(templateLocation.file, 'nav'); + setTemplateHighlight({ + logicalId: templateLocation.logicalId, + color, + }); + } + } + // This must have the same dependencies as constructIndex! + }, [tree]); + + const onDoubleClickNode = (item: WebConstructNode) => { + // Filter diagnostics, only if the user double-clicked the tree + setViolationFilter(item.path); + }; + + const navigateToSourceFile = React.useCallback(async (fileName: string, mode: 'nav' | 'refresh') => { + if (sourceFile?.fileName !== fileName || mode === 'refresh') { + try { + const res = await api.readFile(fileName); + setSourceFile({ + fileName: res.path, + content: res.content, + stale: res.stale, + }); + } catch { + // Show a failure if explicit, otherwise just show current contents + if (mode === 'nav') { + setSourceFile({ + fileName, + content: `// Could not load ${fileName}`, + stale: false, + }); + } + } + } + }, [sourceFile]); + + const navigateToTemplateFile = React.useCallback(async (fileName: string, mode: 'nav' | 'refresh') => { + if (templateFile?.fileName !== fileName || mode === 'refresh') { + try { + const response = await api.getTemplate(fileName); + setTemplateFile({ + fileName, + response, + }); + } catch { + setTemplateFormat('yaml'); + setTemplateFile({ + fileName, + response: { + content: `# Could not load ${fileName}`, + resources: {}, + }, + }); + } + } + }, [templateFile]); + + const pickFile = React.useCallback(async (filePath: string, pane: 'source' | 'template') => { + try { + if (pane === 'source') { + await navigateToSourceFile(filePath, 'nav'); + } else { + await navigateToTemplateFile(filePath, 'nav'); + } + } catch { + /* ignore */ + } + }, []); + + const sourceFileAnnotations = React.useMemo(() => { + if (!sourceFile) return []; + return calcSourceFileAnnotations(constructIndex.current, sourceFile.fileName, onNavigate); + }, [sourceFile, constructIndex.current, onNavigate]); + + const [navigationOpen, setNavigationOpen] = React.useState(true); + const [activeDrawer, setActiveDrawer] = React.useState<'template' | 'help' | undefined>('template'); + const [diagnosticsOpen, setDiagnosticsOpen] = React.useState(violations ? violations.status === 'ok' && violations.violations.length > 0 : false); + + return ( +
+ + CDK Explorer + {appDir ?? ''} + + {tree?.status === 'ok' ? <>(synthesized ) : ''} + + + } + + contentType='table' + disableContentPaddings={true} + + navigation={ + /* ================= Construct Tree =========================== */ + + ), + }]} + /> + } + navigationOpen={navigationOpen} + onNavigationChange={({ detail }) => setNavigationOpen(detail.open)} + navigationWidth={350} + + notifications={<> + {(error && {error})} + {(sourceFile?.stale ? (
+ + This file has been modified since the last synth, so + its diagnostics and source locations may be stale. + Re-run cdk synth to refresh. + +
) : null)} + } + + content={ + /* ================= Source View =========================== */ +
+ +
+ detail.selectedOption.value ? pickFile(detail.selectedOption.value, 'template') : undefined} + options={constructIndex.current.templateFileNames.map(f => ({ label: f, value: f }))} + /> + + {templateFile && ( + + + + )} + +
+
+ {templateFile ? ( + + ) : ( + Select a construct to view its template. + )} +
+ , + }]} + /> + ), + resizable: true, + defaultSize: 600, + trigger: { + iconName: 'file-open', + }, + ariaLabels: { + drawerName: 'Template', + }, + }, + { + id: 'help', + content: ( + + ), + resizable: true, + defaultSize: 500, + trigger: { + iconName: 'status-info', + }, + ariaLabels: { + drawerName: 'Help', + }, + }, + ]} + onDrawerChange={(detail) => { + setActiveDrawer(detail.detail.activeDrawerId as any); + }} + + splitPanelOpen={diagnosticsOpen} + onSplitPanelToggle={(detail) => setDiagnosticsOpen(!diagnosticsOpen)} + splitPanel={ + /* ================= Diagnostics =========================== */ + + setViolationFilter(undefined)} /> + + + } + > + + + } + ariaLabels={{ + }} + > + + {/* ================= Faux Toolbar =========================== */} +
+ +
+
+ ); +} + +function ConstructTreeContent( + { tree, onNavigate, expandState, highlightedConstructPath, onDoubleClick }: + { + readonly tree?: TreeResponse; + readonly onNavigate: NavigateHandler; + readonly expandState: ExpandState; + readonly highlightedConstructPath?: string; + readonly onDoubleClick?: (item: WebConstructNode) => void; + }, +): JSX.Element { + if (!tree) return ; + if (tree.status === 'not-synthesized') { + return No cloud assembly found. Run cdk synth first.; + } + return ; +} + +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[] { + 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) { + for (const sourceLocation of occ.sourceLocations ?? []) { + if (sourceLocation.file === sourceFile && sourceLocation.line >= 1) { + diagnostics.push({ + startLine: sourceLocation.line, + startCol: 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'; + } +} + +function calcSourceFileAnnotations(index: SourceAndTemplateIndex, sourceFile: string, onNavigate: NavigateHandler): Record { + const constructs = index.sourceFileConstructs(sourceFile); + + return Object.fromEntries(Object.entries(constructs).map(([line, cs]) => { + const grouped = groupByComplexKey(cs, c => { + // First: by resource type + if (c.type) { + const keyRepr = `00-res-${c.type}`; + return { + keyObj: {c.type}, + keyRepr, + }; + } + // Then: by construct FQN + if (c.constructFqn) { + const keyRepr = `01-fqn-${c.constructFqn}`; + return { + keyObj: {c.constructFqn}, + keyRepr, + }; + } + // Fallback: other + const keyRepr = '02-other'; + return { + keyObj: Constructs, + keyRepr, + }; + }); + + const content = <>{Array.from(grouped).flatMap(([header, ds]) => [ + header, + ...ds.map(c =>
+ onNavigate({ + constructPath: c.path, + trigger: 'source', + })}>{c.path} +
), + ])}; + + return [line, { content }]; + })); +} + +interface GroupKey { + readonly keyObj: B; + readonly keyRepr: string; +} + +function groupByComplexKey(xs: readonly A[], keyFn: (construct: A) => GroupKey): Map { + const grouped = new Map(); + for (const construct of xs) { + const { keyObj, keyRepr } = keyFn(construct); + if (!grouped.has(keyRepr)) { + grouped.set(keyRepr, { keyObj, els: [] }); + } + grouped.get(keyRepr)!.els.push(construct); + } + // Maps are order-preserving + return new Map( + Array.from(grouped.entries()) + .sort(([keyReprA], [keyReprB]) => keyReprA.localeCompare(keyReprB)) + .map(([_, { keyObj, els }]) => [keyObj, els]), + ); +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/api.ts b/packages/@aws-cdk/cdk-explorer/frontend/src/api.ts new file mode 100644 index 000000000..a2a0a557b --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/api.ts @@ -0,0 +1,45 @@ +import { + ASSEMBLY_CHANGED, + SOURCE_CHANGED, + type FileResponse, + type TemplateResponse, + type TreeResponse, + type ViolationsResponse, +} from '../../lib/web-shared/protocol'; + +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/src/components/CodeViewer.tsx b/packages/@aws-cdk/cdk-explorer/frontend/src/components/CodeViewer.tsx new file mode 100644 index 000000000..37a38b493 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/components/CodeViewer.tsx @@ -0,0 +1,223 @@ +import Icon from '@cloudscape-design/components/icon'; +import Popover from '@cloudscape-design/components/popover'; +import Tooltip from '@cloudscape-design/components/tooltip'; +import * as awsui from '@cloudscape-design/design-tokens'; +import * as React from 'react'; +import { isWhitespace, tokenizeLines, type Language, type Token } from '../syntax'; +import { TOKEN_COLORS } from './colors'; +import { colorForSeverity } from './severities'; +import { worstSeverity } from '../../../lib/web-shared/severity'; +import { addToColl } from '../util'; + +/** + * Diagnostics which are shown with an underline + */ +export interface Diagnostic { + readonly startLine: number; + readonly startCol: number; + readonly endCol?: number; + readonly severity: 'error' | 'warning' | 'info'; + readonly message?: string; +} + +/** + * Annotations which are shown in the margin + */ +export interface LineAnnotation { + readonly content: React.ReactNode; + readonly color?: string; +} + +export interface CodeViewerProps { + readonly content: string; + readonly language: Language; + /** Inclusive */ + readonly highlightStart?: number; + /** Exclusive */ + readonly highlightEnd?: number; + readonly highlightColor?: string; + readonly scrollToLine?: number; + readonly diagnostics?: readonly Diagnostic[]; + readonly annotations?: Record; +} + +export function CodeViewer({ + content, + language, + highlightStart, + highlightEnd, + highlightColor, + scrollToLine, + diagnostics, + annotations, +}: CodeViewerProps): JSX.Element { + const tokenizedLines = React.useMemo( + () => tokenizeLines(content, language), + [content, language], + ); + + const gutterWidth = React.useMemo(() => { + const digits = String(tokenizedLines.length).length; + return `${digits * 8 + 18}px`; + }, [tokenizedLines.length]); + + const diagnosticsByLine = React.useMemo(() => { + const map = new Map(); + for (const d of diagnostics ?? []) { + addToColl(map, d.startLine, d); + } + return map; + }, [diagnostics]); + + 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) ?? []; + const lineAnnotation = annotations?.[lineNum]; + + return ( +
{ + if (isScrollTarget && el) { + el.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }} + className={isHighlighted ? 'nav-highlight' : undefined} + style={{ + ...LINE_STYLE, + ...(isHighlighted ? { ['--nav-highlight-color']: highlightColor ?? awsui.colorChartsBlue1500 } : undefined), + }} + > + {lineNum} + {lineAnnotation &&
+ {lineAnnotation.content}
} + renderWithPortal={true} + dismissButton={false} + > + + +
} + + {lineDiagnostics.length > 0 + ? 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 { + const worst = worstSeverity(diagnostics.map(d => d.severity)); + const color = colorForSeverity(worst); + + // Skip leading whitespace, then inject the underline span for the rest of the line. + let textStart = 0; + while (textStart < tokens.length && isWhitespace(tokens[textStart])) { + textStart++; + } + if (textStart === tokens.length) { + textStart = 0; + } + + // Group by description to avoid repeating the same message multiple times in the tooltip. + const grouped: Record = {}; + for (const diagnostic of diagnostics) { + if (!grouped[diagnostic.message]) { + grouped[diagnostic.message] = []; + } + grouped[diagnostic.message].push(diagnostic); + } + + return <> + {renderTokens(tokens.slice(0, textStart))} + + {Object.keys(grouped).map((message, i) =>
{message}
)} + + }> + {renderTokens(tokens.slice(textStart))} +
+ ; +} + +const CONTAINER_STYLE: React.CSSProperties = { + margin: 0, + 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, +}; + +interface WavyUnderlineProps { + color: string; + children: React.ReactNode; + content: React.ReactNode; +} + +function WavyUnderline(props: WavyUnderlineProps) { + const wrapperRef = React.useRef(null); + const [showTooltip, setShowTooltip] = React.useState(false); + + return setShowTooltip(true)} + onPointerLeave={() => setShowTooltip(false)} + onFocus={() => setShowTooltip(true)} + onBlur={() => setShowTooltip(false)} + > + {props.children} + {showTooltip && ( + wrapperRef.current} + onEscape={() => setShowTooltip(false)} + /> + )} + ; +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/components/ConstructTree.tsx b/packages/@aws-cdk/cdk-explorer/frontend/src/components/ConstructTree.tsx new file mode 100644 index 000000000..4450a7f78 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/components/ConstructTree.tsx @@ -0,0 +1,154 @@ +import Icon from '@cloudscape-design/components/icon'; +import TreeView from '@cloudscape-design/components/tree-view'; +import * as awsui from '@cloudscape-design/design-tokens'; +import * as React from 'react'; +import type { WebConstructNode } from '../../../lib/web-shared/protocol'; +import type { NavigateEvent, NavigateHandler } from '../nav-types'; +import { Flex } from './Flex'; +import { colorForSeverity, SEVERITY } from './severities'; + +export class ExpandState { + private expandedItems: Set = new Set(); + + public autoExpand(maxDepth: number, nodes: readonly WebConstructNode[]) { + recurse.call(this, nodes, 1); + + function recurse(this: ExpandState, ns: readonly WebConstructNode[], currentDepth: number) { + if (currentDepth > maxDepth || ns.length > MAX_AUTOEXPAND_SIBLINGS) { + return; + } + for (const node of ns) { + this.expandedItems.add(node.path); + recurse.call(this, node.children, currentDepth + 1); + } + } + } + + public ensureParentsExpanded(constructPath: string): void { + const parts = constructPath ? constructPath.split('/') : []; + for (let i = 1; i < parts.length; i++) { + this.expandedItems.add(parts.slice(0, i).join('/')); + } + } + + public isExpanded(constructPath: string): boolean { + return this.expandedItems.has(constructPath); + } + + public expand(constructPath: string, what: boolean): void { + if (what) { + this.expandedItems.add(constructPath); + } else { + this.expandedItems.delete(constructPath); + } + } + + public getExpandedItems(): ReadonlyArray { + return Array.from(this.expandedItems).sort(); + } +} + +interface ConstructTreeProps { + readonly nodes: readonly WebConstructNode[]; + readonly expandState: ExpandState; + readonly highlightedConstructPath?: string; + readonly onNavigate: NavigateHandler; + readonly onDoubleClick?: (WebConstructNode) => void; +} + +export function ConstructTree({ nodes, expandState, onNavigate, onDoubleClick, highlightedConstructPath }: ConstructTreeProps): JSX.Element { + // Necessary because a mutable object manages the expand state + const [, expandStateChanged] = React.useState({}); + + const onClickNode = React.useCallback((item: WebConstructNode) => { + if (item.path) { + expandState.expand(item.path, true); + expandStateChanged({}); + } + onNavigate(navigateArgsFromConstructNode(item, 'tree')); + }, [onNavigate]); + + const onToggleItem = React.useCallback(({ detail }: { detail: { item: WebConstructNode; expanded: boolean } }) => { + expandState.expand(detail.item.path, detail.expanded); + expandStateChanged({}); + }, [expandState]); + + return ( + ({ + icon: (item.children.length > 0 ? : undefined), + content: renderItemWithSeverity(item, highlightedConstructPath === item.path, onClickNode, onDoubleClick), + })} + getItemId={item => item.path} + getItemChildren={item => item.children} + onItemToggle={onToggleItem} + ariaLabel="Construct Tree" + /> + ); +} + +const MAX_AUTOEXPAND_SIBLINGS = 10; + +function scrollToRef(ref: HTMLDivElement) { + if (ref) { + ref.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +} + +function renderItemWithSeverity( + item: WebConstructNode, + isHighlighted: boolean, + onClick: (x: WebConstructNode) => void, + onDoubleClick?: (x: WebConstructNode) => void, +): JSX.Element { + const highlightColor = colorForSeverity(item.highestSeverity ?? item.inheritedSeverity); + const attributes = { + className: isHighlighted ? 'nav-highlight' : undefined, + style: { + cursor: 'pointer', + whiteSpace: 'nowrap', + ...(isHighlighted ? { ['--nav-highlight-color']: highlightColor ?? awsui.colorChartsBlue1500 } : undefined), + + // Widen the margin a bit so the highlight looks better + margin: '-2px', + padding: '2px', + } as any, + // title: item.id, + title: JSON.stringify({ + id: item.id, + sourceLocation: item.sourceLocation, + templateLocation: item.templateLocation, + }, undefined, 2), + onClick: () => onClick(item), + onDoubleClick: onDoubleClick ? () => onDoubleClick(item) : undefined, + }; + + const ptrs = []; + if (item.sourceLocation) { + ptrs.push(src); + } + if (item.templateLocation) { + ptrs.push(tpl); + } + + const sev = item.highestSeverity && SEVERITY[item.highestSeverity.toLowerCase()]; + const icon = sev ? <> : undefined; + + const inh = item.inheritedSeverity && SEVERITY[item.inheritedSeverity.toLowerCase()]; + attributes.style.color = sev?.color ?? inh?.color; + + return + {icon} + {item.id} + {ptrs.length > 0 ? {...ptrs} : undefined} + ; +} + +function navigateArgsFromConstructNode(item: WebConstructNode, trigger: NavigateEvent['trigger']): Parameters[0] { + return { + trigger, + constructPath: item.path, + }; +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/components/Flex.tsx b/packages/@aws-cdk/cdk-explorer/frontend/src/components/Flex.tsx new file mode 100644 index 000000000..77442dec9 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/components/Flex.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +export interface FlexProps { + children: React.ReactNode; + style?: React.CSSProperties; + title?: string; + onClick?: () => void; + onDoubleClick?: () => void; + className?: string; + id?: string; +} + +export const Flex = React.forwardRef(function Flex(props, ref): JSX.Element { + return
{props.children}
; +}); + +export function FlexFix({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }): JSX.Element { + return
{children}
; +} + +export function FlexGrow({ children, style }: { children?: React.ReactNode; style?: React.CSSProperties }): JSX.Element { + return
{children}
; +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/components/Help.tsx b/packages/@aws-cdk/cdk-explorer/frontend/src/components/Help.tsx new file mode 100644 index 000000000..6506b792a --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/components/Help.tsx @@ -0,0 +1,56 @@ +import HelpPanel from '@cloudscape-design/components/help-panel'; + +export function Help() { + return +

+ The CDK Explorer is a tool for visualizing the structure and resources + that result from synthesizing your AWS Cloud Development Kit (CDK) + application. +

+

+ Run cdk explore in the directory where your cdk.json file lives. + The explorer does not run the CDK application itself; it visualizes an + already-synthesized application, and will automatically update whenever + cdk synth is run in a different tab. +

+

Construct Tree

+

+ The construct tree shows the hierarchical structure of your CDK + application, with each construct represented as a node, identified by its + {' '}Construct ID. You can expand and collapse nodes to + explore the relationships between constructs. +

+

+ Clicking on a node will navigate to the source line where it is defined + and the template line in the synthesized CloudFormation template, if + available. Double-clicking on a node will restrict the diagnostics panel + to diagnostics in that subtree of the construct tree +

+

Source View

+

+ Source view shows the source code of your CDK application. It only shows + those source files that we could trace a construct's creation to, and it does + not include source files of submodules. Underlines indicate constructs that + have diagnostics associated with them. +

+

+ With code reuse, a single line of source code may map to multiple concrete + constructs in the Construct Tree and in the Template View. +

+

Template View

+

+ Template view shows the synthesized CloudFormation template of your CDK + application. It provides a low-level view of the resources and their + properties as they will be deployed to AWS. It only shows templates + that we could trace a construct's output to. Underlines indicate + resources that have diagnostics associated with them. +

+

Diagnostics Pane

+

+ The diagnostics pane shows synth-time warnings and errors associated with + the constructs in your CDK application. Click on a diagnostic to show + the constructs the diagnostic applies to, and click on a construct to + navigate to its tree, source and template location. +

+
; +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/components/LightDarkToggle.tsx b/packages/@aws-cdk/cdk-explorer/frontend/src/components/LightDarkToggle.tsx new file mode 100644 index 000000000..c5fd5079c --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/components/LightDarkToggle.tsx @@ -0,0 +1,49 @@ +import type { ButtonDropdownProps } from '@cloudscape-design/components/button-dropdown'; +import ButtonDropdown from '@cloudscape-design/components/button-dropdown'; +import Icon from '@cloudscape-design/components/icon'; +import { applyMode, Mode } from '@cloudscape-design/global-styles'; +import React from 'react'; + +export type LightDarkPreference = 'system' | 'dark' | 'light'; + +let userPreference = localStorage.getItem('light-dark-preference') as LightDarkPreference | null ?? 'system'; +const systemDarkMode = window.matchMedia('(prefers-color-scheme: dark)'); + +// Initialize color mode based on user's OS preference and stay in sync if the preference changes. +systemDarkMode.addEventListener('change', () => refreshDarkMode()); +refreshDarkMode(); + +function refreshDarkMode() { + if (userPreference === 'system') { + applyMode(systemDarkMode.matches ? Mode.Dark : Mode.Light); + } else if (userPreference === 'dark') { + applyMode(Mode.Dark); + } else { + applyMode(Mode.Light); + } +} + +export function LightDarkToggle(): JSX.Element { + const [preference, setPref] = React.useState(userPreference); + + const onItemFollow = React.useCallback((detail: CustomEvent) => { + const choice = detail.detail.id as LightDarkPreference; + userPreference = choice; + localStorage.setItem('light-dark-preference', choice); + refreshDarkMode(); + setPref(choice); + }, []); + + return ( + + + + ); +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/components/RelativeTime.tsx b/packages/@aws-cdk/cdk-explorer/frontend/src/components/RelativeTime.tsx new file mode 100644 index 000000000..ad244481c --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/components/RelativeTime.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +export interface RelativeTimeProps { + timestamp: number; +} + +export function RelativeTime(props: RelativeTimeProps) { + const [now, setNow] = React.useState(Date.now()); + React.useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), 10000); + return () => clearInterval(interval); + }, []); + + return + {calculateRelativeTime(now, props.timestamp)} + ; +} + +function calculateRelativeTime(now: number, timestamp: number): string { + const MINUTES_MS = 60 * 1000; + const HOURS_MS = 60 * MINUTES_MS; + const DAYS_MS = 24 * HOURS_MS; + + let diff = now - timestamp; + if (diff < 10000) { + return 'just now'; + } else if (diff < MINUTES_MS) { + return `${plur(Math.floor(diff / 1000), 'second', 'seconds')} ago`; + } else if (diff < HOURS_MS) { + return `${plur(Math.floor(diff / MINUTES_MS), 'minute', 'minutes')} ago`; + } + + let components = []; + const daysComponent = Math.floor(diff / DAYS_MS); + if (daysComponent > 0) { + diff -= daysComponent * DAYS_MS; + components.push(plur(daysComponent, 'day', 'days')); + } + const hoursComponent = Math.floor(diff / HOURS_MS); + if (hoursComponent > 0) { + diff -= hoursComponent * HOURS_MS; + components.push(plur(hoursComponent, 'hour', 'hours')); + } + let minutesComponent = Math.floor(diff / MINUTES_MS); + if (minutesComponent > 0) { + diff -= minutesComponent * MINUTES_MS; + components.push(plur(minutesComponent, 'minute', 'minutes')); + } + + return components.slice(0, 2).join(' ') + ' ago'; +} + +function plur(n: number, singular: string, plural: string): string { + return n === 1 ? `${n} ${singular}` : `${n} ${plural}`; +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/components/TemplateViewer.tsx b/packages/@aws-cdk/cdk-explorer/frontend/src/components/TemplateViewer.tsx new file mode 100644 index 000000000..b4a310b9b --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/components/TemplateViewer.tsx @@ -0,0 +1,196 @@ +import Box from '@cloudscape-design/components/box'; +import ButtonDropdown from '@cloudscape-design/components/button-dropdown'; +import Link from '@cloudscape-design/components/link'; +import * as React from 'react'; +import { isNode, isScalar, isMap, LineCounter, parseDocument, stringify } from 'yaml'; +import { type LineAnnotation, CodeViewer, type Diagnostic } from './CodeViewer'; +import { ENTIRE_TEMPLATE_LOGICALID_MARKER, type TemplateResource, type WebViolation } from '../../../lib/web-shared/protocol'; +import { type NavigateHandler } from '../nav-types'; +import { type SourceAndTemplateIndex } from '../source-and-template-index'; + +export interface TemplateViewerProps { + readonly jsonContent: string; + readonly resources: Record; + readonly highlightLogicalId?: string; + readonly highlightColor?: string; + readonly onNavigate?: NavigateHandler; + readonly templateFile?: string; + readonly violations?: readonly WebViolation[]; + readonly index: SourceAndTemplateIndex; + 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, + templateFile, + violations, + format, + index, + onNavigate, +}: 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 []; + 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 : []; + }, [templateFile, violations, displayResources, displayContent]); + + const annotations = React.useMemo(() => { + const constructs = index.templateConstructs(templateFile); + const ret: Record = Object.fromEntries(Object.entries(constructs).map(([logicalId, c]) => [ + displayResources[logicalId]?.block.startLine ?? 0, + { + content: <> + Source + onNavigate({ + constructPath: c.path, + trigger: 'template', + })}>{c.path} + , + }, + ])); + return ret; + }, [displayResources]); + + 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[]; +} + +/** + * Convert JSON CloudFormation template content to YAML and recalculate the location of resource blocks in the template. + * + * FIXME: This should be done on the server. + */ +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 = {}; + displayResources[ENTIRE_TEMPLATE_LOGICALID_MARKER] = { block: { startLine: 1, endLine: displayContent.split('\n').length } }; + + for (const section of ['Parameters', 'Conditions', 'Mappings', 'Outputs', 'Resources']) { + const elements = doc.get(section); + if (isMap(elements)) { + for (const pair of elements.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'; + } +} + +export function FormatToggle({ format, onChange }: { readonly format: Format; readonly onChange: (f: Format) => void }): JSX.Element { + return ({ text: labelFromFormat(f), id: f, iconName: format === f ? 'check' : undefined }))} + onItemClick={(detail) => onChange(detail.detail.id as Format)} + + > + {labelFromFormat(format)} + ; +} + +function labelFromFormat(f: Format): string { + switch (f) { + case 'json': return 'JSON'; + case 'yaml': return 'YAML'; + } +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/components/ViolationsPanel.tsx b/packages/@aws-cdk/cdk-explorer/frontend/src/components/ViolationsPanel.tsx new file mode 100644 index 000000000..bc019ea03 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/components/ViolationsPanel.tsx @@ -0,0 +1,173 @@ +import Box from '@cloudscape-design/components/box'; +import Link from '@cloudscape-design/components/link'; +import List from '@cloudscape-design/components/list'; +import Spinner from '@cloudscape-design/components/spinner'; +import StatusIndicator from '@cloudscape-design/components/status-indicator'; +import TextFilter from '@cloudscape-design/components/text-filter'; +import Token from '@cloudscape-design/components/token'; +import * as React from 'react'; +import { Flex } from './Flex'; +import { colorForSeverity, iconForSeverity } from './severities'; +import type { ViolationsResponse, WebViolation } from '../../../lib/web-shared/protocol'; +import { displaySeverity, severityRank } from '../../../lib/web-shared/severity'; +import type { NavigateHandler } from '../nav-types'; + +interface ViolationsPanelProps { + readonly violations: readonly WebViolation[]; + readonly onNavigate: NavigateHandler; +} + +export function ViolationsList({ violations, onNavigate }: ViolationsPanelProps): JSX.Element { + if (violations.length === 0) { + return No diagnostics found.; + } + + const sorted = [...violations].sort((a, b) => severityRank(displaySeverity(a)) - severityRank(displaySeverity(b))); + + if (sorted.length === 0) { + return No matching diagnostics.; + } + + return ( + { + const displaySev = displaySeverity(viol); + const title = viol.description?.trim() ?? viol.ruleName; + const showRuleName = title !== viol.ruleName; + const count = viol.occurrences.length; + + return { + id: `${viol.source}:${viol.ruleName}:${viol.occurrences.map(occ => occ.constructPath).join(',')}`, + icon: iconForSeverity(viol.severity, viol.customSeverity), + content: +
+
[{displaySev.toUpperCase()}]
+
+
+ {showRuleName && {viol.ruleName}} +
+
+ + {title}
+ + {count} {count === 1 ? 'construct' : 'constructs'} {'·'} {viol.source} + +
+ {viol.suggestedFix && Suggested fix: {viol.suggestedFix}} + {viol.occurrences.map((occ, i) => ( +
+ onNavigate({ + trigger: 'diagnostic', + constructPath: occ.constructPath, + sourceLocation: occ.sourceLocations?.[0], + templateLocation: occ.logicalId && occ.templateFile ? { + logicalId: occ.logicalId, + file: occ.templateFile, + } : undefined, + })}> + {occ.constructPath} + +
+ ))} +
+
+
, + }; + }} + > +
+ ); +} + +const RULE_NAME_STYLE: React.CSSProperties = { fontFamily: 'monospace', fontSize: '12px', color: '#5f6b7a', fontWeight: 400, whiteSpace: 'nowrap', flexShrink: 0 }; + +export function ViolationsPanelContent({ violations, onNavigate, status }: { + readonly status?: ViolationsResponse['status']; + readonly violations: readonly WebViolation[]; + readonly onNavigate: NavigateHandler; +}): JSX.Element { + if (!status) return ; + if (status === 'not-synthesized') { + return No cloud assembly found.; + } + return ; +} + +export function ViolationsConstructFilter( + { constructPathFilter, onClearFilter }: + { readonly constructPathFilter?: string; readonly onClearFilter: () => void }, +): JSX.Element { + if (!constructPathFilter) return <>; + const name = constructPathFilter.split('/').pop() || constructPathFilter; + return ( + + <>Limited to + + + ); +} + +export function ViolationsSearch({ search, onSearchChange }: { + readonly search: string; + readonly onSearchChange: (value: string) => void; +}): JSX.Element { + return ( + + onSearchChange(detail.filteringText) + } + /> + ); +} + +export function filterViolations(violations: readonly WebViolation[], constructPathFilter?: string, search?: string): readonly WebViolation[] { + let result = violations; + + if (constructPathFilter) { + result = violations + .map((v) => ({ + ...v, + occurrences: v.occurrences.filter( + (occ) => occ.constructPath === constructPathFilter || occ.constructPath.startsWith(constructPathFilter + '/'), + ), + })) + .filter((v) => v.occurrences.length > 0); + } + + const q = search.trim().toLowerCase(); + if (q) { + 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; +} + +export function diagnosticsPanelTitle(response: ViolationsResponse | undefined, violations: readonly WebViolation[]) { + if (response?.status !== 'ok') { + return 'Diagnostics'; + } + + if (violations.length !== response.violations.length) { + return `Diagnostics (${violations.length} out of ${response.violations.length})`; + } else { + return `Diagnostics (${response.violations.length})`; + } +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/components/colors.ts b/packages/@aws-cdk/cdk-explorer/frontend/src/components/colors.ts new file mode 100644 index 000000000..1ca4725da --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/components/colors.ts @@ -0,0 +1,25 @@ +import * as awsui from '@cloudscape-design/design-tokens'; + +export const TOKEN_COLORS: Record = { + 'keyword': awsui.colorChartsBlue2500, + 'string': awsui.colorChartsRed500, + 'number': awsui.colorChartsTeal400, + 'boolean': awsui.colorChartsBlue2500, + 'attr-name': awsui.colorChartsBlue1500, + 'property': awsui.colorChartsBlue1500, + 'operator': awsui.colorChartsYellow1000, + 'punctuation': awsui.colorChartsYellow1000, + 'comment': awsui.colorChartsGreen400, + 'class-name': awsui.colorChartsBlue1300, + 'builtin': awsui.colorChartsBlue1300, + 'function': awsui.colorChartsOrange700, + 'tag': awsui.colorChartsBlue1500, + 'selector': awsui.colorChartsBlue1500, + 'key': awsui.colorChartsBlue1500, + 'atrule': awsui.colorChartsBlue2500, + // YAML-specific + 'important': awsui.colorChartsTeal400, + // null/undefined + 'null': awsui.colorChartsBlue2500, +}; + diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/components/severities.tsx b/packages/@aws-cdk/cdk-explorer/frontend/src/components/severities.tsx new file mode 100644 index 000000000..99ba92ed5 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/components/severities.tsx @@ -0,0 +1,20 @@ +import Icon, { type IconProps } from '@cloudscape-design/components/icon'; +import * as awsui from '@cloudscape-design/design-tokens'; + +export const SEVERITY: Record = { + fatal: { color: awsui.colorChartsStatusCritical, icon: 'status-negative', label: 'Fatal' }, + error: { color: awsui.colorChartsStatusHigh, icon: 'status-stopped', label: 'Error' }, + warning: { color: awsui.colorChartsStatusLow, icon: 'status-warning', label: 'Warning' }, + info: { color: awsui.colorChartsStatusInfo, icon: 'status-info', label: 'Info' }, +}; + +export function colorForSeverity(severity: string | undefined): string { + return SEVERITY[severity ?? '']?.color ?? awsui.colorTextActionCardDisabled; +} + +export function iconForSeverity(severity: string | undefined, customSeverity: string | undefined): JSX.Element { + const sev = SEVERITY[severity?.toLowerCase() ?? '']; + const iconName = sev?.icon ?? 'status-not-started'; + const severityName = severity === 'custom' ? customSeverity : sev?.label ?? severity; + return ; +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/highlight.css b/packages/@aws-cdk/cdk-explorer/frontend/src/highlight.css new file mode 100644 index 000000000..4431b2577 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/highlight.css @@ -0,0 +1,40 @@ +@keyframes nav-highlight-fade { + from { opacity: 0.35; } + to { opacity: 0; } +} + +.nav-highlight { + position: relative; +} + +.nav-highlight::before { + content: ''; + font-size: 20px; + 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; +} + +/* Make sure these buttens remain clickable and they don't accidentally get overlaid by tabs */ +[class*="awsui_hide-navigation_"] { + z-index: 10; +} \ No newline at end of file diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/icon.png b/packages/@aws-cdk/cdk-explorer/frontend/src/icon.png new file mode 100644 index 000000000..bffb98637 Binary files /dev/null and b/packages/@aws-cdk/cdk-explorer/frontend/src/icon.png differ diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/index.html b/packages/@aws-cdk/cdk-explorer/frontend/src/index.html new file mode 100644 index 000000000..3a80459bd --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/index.html @@ -0,0 +1,35 @@ + + + + + + CDK Explorer + + + + + +
+ + + diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/index.tsx b/packages/@aws-cdk/cdk-explorer/frontend/src/index.tsx new file mode 100644 index 000000000..1c373b40e --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/index.tsx @@ -0,0 +1,15 @@ +import '@cloudscape-design/global-styles/index.css'; +import './highlight.css'; +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App'; + +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/src/nav-types.ts b/packages/@aws-cdk/cdk-explorer/frontend/src/nav-types.ts new file mode 100644 index 000000000..44e9b70d7 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/nav-types.ts @@ -0,0 +1,12 @@ +import type { WebSourceLocation, WebTemplateLocation } from '../../lib/web-shared/protocol'; + +export type NavigateTrigger = 'tree' | 'diagnostic' | 'source' | 'template'; + +export interface NavigateEvent { + trigger: NavigateTrigger; + constructPath: string; + sourceLocation?: WebSourceLocation; + templateLocation?: WebTemplateLocation; +} + +export type NavigateHandler = (opts: NavigateEvent) => void; diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/source-and-template-index.ts b/packages/@aws-cdk/cdk-explorer/frontend/src/source-and-template-index.ts new file mode 100644 index 000000000..7019c7f20 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/source-and-template-index.ts @@ -0,0 +1,106 @@ +import { addToColl } from './util'; +import type { WebConstructNode } from '../../lib/web-shared/protocol'; + +/** + * A construct anchored to a line in a source file, paired with the template + * coordinates needed to navigate to its resource. Produced by + * {@link buildSourceAnchorIndex} and consumed by {@link findConstructAtLine}. + */ +interface SourceFileContents { + readonly constructSources: Map; +} + +interface TemplateFileContents { + readonly logicalIds: Map; +} + +/** + * An index of source anchors, organized by file. Each file maps line numbers to the constructs defined on those lines. + * + * Indexes both source files and template files. + */ +export class SourceAndTemplateIndex { + public static fromTree(tree: readonly WebConstructNode[]) { + const ret = new SourceAndTemplateIndex(); + ret.addTree(tree); + return ret; + } + + private sourceFiles = new Map(); + private templateFiles = new Map(); + private constructs = new Map(); + private _sourceFileNames = new Set(); + private _templateFileNames = new Set(); + + constructor() { + } + + public get size() { + return this.constructs.size; + } + + public construct(path: string): WebConstructNode | undefined { + return this.constructs.get(path); + } + + public get sourceFileNames(): ReadonlyArray { + return Array.from(this._sourceFileNames).sort(); + } + + public get templateFileNames(): ReadonlyArray { + return Array.from(this._templateFileNames).sort(); + } + + public addTree(tree: readonly WebConstructNode[]) { + recurse.call(this, tree); + + function recurse(this: SourceAndTemplateIndex, nodes: readonly WebConstructNode[]) { + for (const node of nodes) { + this.constructs.set(node.path, node); + + if (node.sourceLocation) { + this._sourceFileNames.add(node.sourceLocation.file); + let fileAnchors = getDefault(this.sourceFiles, node.sourceLocation.file, () => ({ + constructSources: new Map(), + })); + addToColl(fileAnchors.constructSources, node.sourceLocation.line, node); + } + if (node.templateLocation) { + this._templateFileNames.add(node.templateLocation.file); + let fileAnchors = getDefault(this.templateFiles, node.templateLocation.file, () => ({ + logicalIds: new Map(), + })); + fileAnchors.logicalIds.set(node.templateLocation.logicalId, node); + } + recurse.call(this, node.children); + } + } + } + + public sourceFileConstructs(fileName: string): Record { + const file = this.sourceFiles.get(fileName); + if (!file) { + return {}; + } + + return Object.fromEntries(file.constructSources); + } + + public templateConstructs(fileName: string): Record { + const file = this.templateFiles.get(fileName); + if (!file) { + return {}; + } + + return Object.fromEntries(file.logicalIds); + } +} + +function getDefault(map: Map, key: string, defaultValue: () => B): B { + let value = map.get(key as any); + if (value === undefined) { + value = defaultValue(); + map.set(key as any, value); + } + return value; +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/syntax.ts b/packages/@aws-cdk/cdk-explorer/frontend/src/syntax.ts new file mode 100644 index 000000000..4adbbc3ec --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/syntax.ts @@ -0,0 +1,97 @@ +/** + * 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; +} + +export function isWhitespace(t: Token): boolean { + return t.type === undefined; +} + +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/frontend/src/tsconfig.json b/packages/@aws-cdk/cdk-explorer/frontend/src/tsconfig.json new file mode 100644 index 000000000..d4af4cabd --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "lib": ["DOM", "ES2022"], + "jsx": "react-jsx", + "emitDeclarationOnly": true, + "declarationDir": "../obj", + "composite": true + }, + "references": [ + { "path": "../../lib/web-shared" } + ] +} \ No newline at end of file diff --git a/packages/@aws-cdk/cdk-explorer/frontend/src/util.ts b/packages/@aws-cdk/cdk-explorer/frontend/src/util.ts new file mode 100644 index 000000000..247d1fe02 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/src/util.ts @@ -0,0 +1,9 @@ +export function addToColl(map: Map, key: A, value: B) { + let coll = map.get(key); + if (!coll) { + coll = []; + map.set(key, coll); + } + coll.push(value); +} + diff --git a/packages/@aws-cdk/cdk-explorer/frontend/test/source-nav.test.ts b/packages/@aws-cdk/cdk-explorer/frontend/test/source-nav.test.ts new file mode 100644 index 000000000..eeaa93974 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/test/source-nav.test.ts @@ -0,0 +1,135 @@ +import type { WebConstructNode } from '../../lib/web-shared/protocol'; +import { SourceAndTemplateIndex } from '../src/source-and-template-index'; + +/** + * Exercises the SPA-side index that powers source and template navigation. + * Inputs are `WebConstructNode`s exactly as `/api/tree` serves them, so these + * assertions are about the client's interpretation of that wire format. + */ +function node(props: { + path: string; + logicalId?: string; + templateFile?: string; + file?: string; + line?: number; + children?: WebConstructNode[]; +}): WebConstructNode { + return { + path: props.path, + id: props.path.split('/').pop()!, + templateLocation: props.templateFile && props.logicalId + ? { file: props.templateFile, logicalId: props.logicalId } + : undefined, + sourceLocation: props.file !== undefined ? { file: props.file, line: props.line ?? 0 } : undefined, + children: props.children ?? [], + }; +} + +/** A fully-navigable construct: source location plus template coordinates. */ +function navigable(path: string, file: string, line: number, children?: WebConstructNode[]): WebConstructNode { + return node({ path, logicalId: path.replace(/\W/g, ''), templateFile: 'App.template.json', file, line, children }); +} + +describe('SourceAndTemplateIndex', () => { + test('an empty tree indexes nothing', () => { + const index = SourceAndTemplateIndex.fromTree([]); + + expect(index.size).toBe(0); + expect(index.sourceFileNames).toEqual([]); + expect(index.templateFileNames).toEqual([]); + }); + + test('collects the distinct source and template files, sorted', () => { + const index = SourceAndTemplateIndex.fromTree([ + navigable('App/Bucket', 'app.ts', 10), + navigable('App/Queue', 'app.ts', 20), + navigable('App/Table', 'db.ts', 5), + ]); + + expect(index.sourceFileNames).toEqual(['app.ts', 'db.ts']); + expect(index.templateFileNames).toEqual(['App.template.json']); + }); + + test('indexes every construct by path, including nested ones', () => { + const index = SourceAndTemplateIndex.fromTree([ + navigable('App/Outer', 'app.ts', 10, [navigable('App/Outer/Inner', 'app.ts', 12)]), + ]); + + expect(index.size).toBe(2); + expect(index.construct('App/Outer/Inner')!.id).toBe('Inner'); + expect(index.construct('App/Nope')).toBeUndefined(); + }); + + test('ignores a construct with neither source nor template location', () => { + // Still addressable by path, just not navigable from either direction. + const index = SourceAndTemplateIndex.fromTree([node({ path: 'App/Opaque' })]); + + expect(index.construct('App/Opaque')).toBeDefined(); + expect(index.sourceFileNames).toEqual([]); + expect(index.templateFileNames).toEqual([]); + }); +}); + +describe('SourceAndTemplateIndex.sourceFileConstructs', () => { + const index = SourceAndTemplateIndex.fromTree([ + navigable('App/Bucket', 'app.ts', 10), + navigable('App/Queue', 'app.ts', 20), + navigable('App/Table', 'db.ts', 5), + ]); + + test('groups a file constructs by their definition line', () => { + // Source anchors are exact lines; the SPA decorates those lines and does + // not claim the lines in between. + const byLine = index.sourceFileConstructs('app.ts'); + + expect(paths(byLine)).toEqual({ 10: ['App/Bucket'], 20: ['App/Queue'] }); + }); + + test('keeps the files apart', () => { + expect(paths(index.sourceFileConstructs('db.ts'))).toEqual({ 5: ['App/Table'] }); + }); + + test('returns nothing for a file with no constructs', () => { + expect(index.sourceFileConstructs('unknown.ts')).toEqual({}); + }); + + test('lists every construct anchored to a shared line, authored parent first', () => { + // The real VPC case: the parent and all its synthesized children are + // anchored to the single `new Vpc(...)` line. The tree is walked in + // pre-order, so the construct the user wrote leads the list. + const shared = SourceAndTemplateIndex.fromTree([ + navigable('Net/Vpc', 'net.ts', 11, [ + navigable('Net/Vpc/PublicSubnet1', 'net.ts', 11, [ + navigable('Net/Vpc/PublicSubnet1/NatGateway', 'net.ts', 11), + ]), + ]), + ]); + + expect(paths(shared.sourceFileConstructs('net.ts'))).toEqual({ + 11: ['Net/Vpc', 'Net/Vpc/PublicSubnet1', 'Net/Vpc/PublicSubnet1/NatGateway'], + }); + }); +}); + +describe('SourceAndTemplateIndex.templateConstructs', () => { + test('maps each logical id in a template to its construct', () => { + const index = SourceAndTemplateIndex.fromTree([ + navigable('App/Bucket', 'app.ts', 10), + navigable('App/Queue', 'app.ts', 20), + ]); + + const byLogicalId = index.templateConstructs('App.template.json'); + + expect(Object.keys(byLogicalId).sort()).toEqual(['AppBucket', 'AppQueue']); + expect(byLogicalId.AppBucket.path).toBe('App/Bucket'); + }); + + test('returns nothing for a template with no constructs', () => { + expect(SourceAndTemplateIndex.fromTree([]).templateConstructs('Nope.template.json')).toEqual({}); + }); +}); + +/** Collapse a line-to-constructs map to a line-to-paths map, for readable assertions. */ +function paths(byLine: Record): Record { + return Object.fromEntries(Object.entries(byLine).map(([line, nodes]) => [line, nodes.map((n) => n.path)])); +} diff --git a/packages/@aws-cdk/cdk-explorer/frontend/test/tsconfig.json b/packages/@aws-cdk/cdk-explorer/frontend/test/tsconfig.json new file mode 100644 index 000000000..cf098c083 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/frontend/test/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "nodenext", + "noEmit": true, + "moduleResolution": "nodenext", + "lib": ["DOM", "es2022"], + "types": ["jest"], + }, + "references": [ + { "path": "../src" }, + { "path": "../../lib/web-shared" } + ] +} \ No newline at end of file diff --git a/packages/@aws-cdk/cdk-explorer/jest.config.json b/packages/@aws-cdk/cdk-explorer/jest.config.json index fd3e6f0d9..648a712c1 100644 --- a/packages/@aws-cdk/cdk-explorer/jest.config.json +++ b/packages/@aws-cdk/cdk-explorer/jest.config.json @@ -26,7 +26,7 @@ ] ], "testMatch": [ - "/test/**/?(*.)+(test).ts", + "/**/test/**/?(*.)+(test).ts?(x)", "/@(lib|test)/**/*(*.)@(spec|test).ts?(x)", "/@(lib|test)/**/__tests__/**/*.ts?(x)" ], 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 3b8d8e22c..5eee421ad 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,15 @@ 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, which + * catches edits made *during* a synth that it may not have picked up. + * + * Not debounced, and never also an assembly refresh. + */ + readonly onSynthStart?: (atMs: number) => void; /** Receives non-fatal watcher errors. */ readonly onError?: (error: unknown) => void; /** @@ -78,9 +88,11 @@ function defaultCreateWatcher(assemblyDir: string): FileWatcher { * assembly is rewritten, regardless of which process produced it (an external * `cdk synth`/`cdk watch`, or a future in-process synth). * - * Only the assembly signal files trigger a refresh. Template files and, crucially, - * the RWLock marker files (`synth.lock` / `read..lock`) are ignored: their + * Only the assembly signal files trigger a refresh. Template files are ignored, + * and so are the RWLock marker files (`synth.lock` / `read..lock`): their * rapid create/delete during a synth would otherwise cause spurious refreshes. + * `synth.lock` appearing is instead reported as `onSynthStart`, the only + * signal we get that a synth has started (the explorer never starts one itself). */ export function startAssemblyWatcher(options: AssemblyWatcherOptions): AssemblyWatcher { const createWatcher = options.createWatcher ?? defaultCreateWatcher; @@ -90,9 +102,21 @@ export function startAssemblyWatcher(options: AssemblyWatcherOptions): AssemblyW const watcher = createWatcher(options.assemblyDir); - watcher.on('all', (_eventName, filePath) => { + watcher.on('all', (eventName, filePath) => { if (closed) return; const base = path.basename(filePath); + if (base === SYNTH_LOCK_FILE) { + // Only the lock appearing marks a start; its removal is the synth + // finishing, which the signal-file refresh below already covers. + if (eventName === 'add') { + try { + options.onSynthStart?.(Date.now()); + } catch (error) { + options.onError?.(error); + } + } + return; + } if (!ASSEMBLY_SIGNAL_FILES.has(base)) return; if (timer) { clearTimeout(timer); diff --git a/packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts b/packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts index 66b776d0a..0b03503a6 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/core/template-ranges.ts @@ -34,6 +34,8 @@ export interface TemplateRanges { block(logicalId: string): OffsetRange | undefined; /** The logical id's block plus its top-level property ranges, or `undefined` if absent. */ resource(logicalId: string): ResourceRanges | undefined; + /** Every logical id in the template, or `undefined` if the text is not valid JSON. */ + all(): Record | undefined; /** The logical id whose block contains `offset`, or `undefined` if none does. */ logicalIdAt(offset: number): string | undefined; } @@ -58,6 +60,7 @@ export function indexTemplateRanges(templateText: string): TemplateRanges { return { block: (logicalId) => index?.[logicalId]?.block, resource: (logicalId) => index?.[logicalId], + all: () => index, logicalIdAt: (offset) => { for (const [logicalId, ranges] of Object.entries(index ?? {})) { if (offset >= ranges.block.start && offset < ranges.block.end) { @@ -81,6 +84,17 @@ export function resolveResourceRange(templateText: string, logicalId: string): O return indexTemplateRanges(templateText).block(logicalId); } +/** + * Resolves block and property ranges for every logical id in the template, in a + * single parse. For a consumer that annotates a whole template at once (the web + * explorer's template view) rather than looking up one construct. + * + * Returns `undefined` when the text is not valid JSON. + */ +export function resolveAllResourceRanges(templateText: string): Record | undefined { + return indexTemplateRanges(templateText).all(); +} + /** * The inverse of `resolveResourceRange`: given a character offset into a * template's text, returns the logical id whose block contains that offset, for diff --git a/packages/@aws-cdk/cdk-explorer/lib/expressy/cookies.ts b/packages/@aws-cdk/cdk-explorer/lib/expressy/cookies.ts new file mode 100644 index 000000000..446e790a3 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/expressy/cookies.ts @@ -0,0 +1,77 @@ +export interface SetCookie { + readonly httpOnly?: boolean; + readonly sameSite?: 'strict' | 'lax' | 'none'; + readonly path?: string; +} + +/** + * Serialize a cookie into a `Set-Cookie` HTTP response header. + */ +export function serializeSetCookieValue(name: string, value: string, options: SetCookie = {}): string { + const parts = [`${name}=${encodeURIComponent(value)}`]; + if (options.path) parts.push(`Path=${options.path}`); + if (options.httpOnly) parts.push('HttpOnly'); + if (options.sameSite) { + parts.push(`SameSite=${options.sameSite.charAt(0).toUpperCase()}${options.sameSite.slice(1)}`); + } + return parts.join('; '); +} + +/** + * Parse a `Set-Cookie` header from an HTTP response header. + */ +export function parseSetCookieValues(setCookieHeaders: string | string[] | undefined): Record { + if (!setCookieHeaders) { + return {}; + } + + if (!Array.isArray(setCookieHeaders)) { + setCookieHeaders = [setCookieHeaders]; + } + + const cookies: Record = {}; + for (const header of setCookieHeaders) { + const [cookie] = header.split(';'); + const [name, ...values] = cookie.split('='); + cookies[name.trim()] = decodeURIComponent(values.join('=').trim()); + } + return cookies; +} + +/** + * Read one cookie out of a `Cookie` HTTP request header. + * + * Hand-parsed rather than pulling in `cookie-parser`: the explorer needs + * exactly one name. + */ +export function parseCookieValue(cookieHeader: string | string[] | null | undefined): Record { + if (!cookieHeader) { + return {}; + } + + if (Array.isArray(cookieHeader)) { + cookieHeader = cookieHeader.join('; '); + } + + const ret: Record = {}; + for (const part of cookieHeader.split(';')) { + const eq = part.indexOf('='); + if (eq === -1) continue; + + const name = part.slice(0, eq).trim(); + const value = part.slice(eq + 1).trim(); + + ret[name] = decodeURIComponent(value); + } + + return ret; +} + +/** + * Serialize a set of cookies for an HTTP request header + */ +export function serializeCookieValue(cookies: Record) { + return Object.entries(cookies) + .map(([name, value]) => `${name}=${encodeURIComponent(value)}`) + .join('; '); +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/expressy/http-server.ts b/packages/@aws-cdk/cdk-explorer/lib/expressy/http-server.ts new file mode 100644 index 000000000..c2cd37851 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/expressy/http-server.ts @@ -0,0 +1,92 @@ +import * as http from 'http'; +import type { AddressInfo } from 'net'; + +export const DEFAULT_PORT = 3411; + +export interface HttpServerOptions { + host: string; + port?: number; +} + +export class HttpServer { + private _port?: number; + private server?: http.Server; + + constructor(private readonly app: http.RequestListener, private readonly options: HttpServerOptions) { + } + + public get port(): number { + if (this._port === undefined) { + throw new Error('Server has not been started yet.'); + } + return this._port; + } + + public get urlString(): string { + return `http://${this.options.host}:${this.port}`; + } + + public async start(): Promise { + this.server = http.createServer(this.app); + + let startPort: number; + let endPort: number; + if (this.options.port !== undefined) { + startPort = this.options.port; + endPort = startPort; + } else { + startPort = DEFAULT_PORT; + endPort = startPort + 100; + } + + this._port = await listenWithPortSearch(this.server, this.options.host, startPort, endPort); + } + + public async close(force = false) { + const server = this.server; + if (!server) { + return; + } + + // Stop accepting new connections + await new Promise(ok => server.close(() => ok())); + + // Forcefully close all waiting requests + if (force) { + server.closeAllConnections(); + } + + this.server = undefined; + } +} + +async function listenWithPortSearch( + server: http.Server, + host: string, + startPort: number, + endPort: number, +): Promise { + for (let port = startPort; port <= endPort; port++) { + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, host, () => { + server.removeListener('error', reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + return address.port; + } catch (err: any) { + if (err.code !== 'EADDRINUSE') { + throw err; + } + } + } + + if (startPort === endPort) { + throw new Error(`Port ${startPort} is already in use`); + } + + throw new Error(`No available port found in range ${startPort}-${endPort}`); +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/expressy/index.ts b/packages/@aws-cdk/cdk-explorer/lib/expressy/index.ts new file mode 100644 index 000000000..e82edb955 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/expressy/index.ts @@ -0,0 +1,262 @@ +/* + * A minimal implementation of a web server framework, based on the express API. + * + * This implements only the slice of express the CDK Explorer web server relies + * on: an app that is itself a Node `http` request listener, mountable routers, + * path/param/wildcard route matching, ordered middleware with `next()`, and the + * handful of request/response helpers the handlers call. It intentionally omits + * everything else express does (view engines, body parsing, content + * negotiation, error-handling middleware, etc.) so the CLI can bundle it without + * pulling in a large dependency tree. + */ +import type { IncomingMessage, ServerResponse } from 'http'; +import { makeRequest, type Request } from './request'; +import { makeResponse, type Response } from './response'; +export { Request, Response }; + +/** Marks an object as a mountable router, so `app.use` can tell it from a plain handler. */ +const ROUTER = Symbol('expressy.router'); + +/** + * Called to pass control to the next matching handler. An argument is accepted + * for express source-compatibility but ignored: the explorer registers no + * error-handling middleware, so a `next(err)` behaves like a plain `next()`. + */ +export type NextFunction = (err?: unknown) => void; + +/** A request handler / middleware function. */ +export type Handler = (req: Request, res: Response, next: NextFunction) => void; + +/** How a compiled pattern reports a match against a path. */ +interface MatchResult { + /** Captured named parameters. */ + readonly params: Record; + /** + * The portion of the path left for a mounted sub-router to match. For a full + * (route) match this is the whole path; for a prefix (mount) match it is what + * follows the prefix, so a router mounted at `/api` sees `/health`. + */ + readonly remainder: string; +} + +type Matcher = (path: string) => MatchResult | undefined; + +/** One entry in a router's ordered list of middleware and routes. */ +interface Layer { + /** Restrict to a single HTTP method, or undefined to match any (i.e. `use`). */ + readonly method?: string; + /** Test a path and, on a match, extract params and the sub-router remainder. */ + readonly match: Matcher; + /** Run the layer, calling `next` to continue past it. */ + readonly dispatch: (req: Request, res: Response, remainder: string, next: NextFunction) => void; +} + +/** + * A mountable collection of middleware and routes. `Express` is a `Router` that + * is additionally a Node request listener. + */ +export interface Router { + /** @internal */ + readonly [ROUTER]: true; + /** Register a GET route with one or more handlers. */ + get(path: string, ...handlers: Handler[]): this; + /** Register path-less middleware. */ + use(handler: Handler): this; + /** Register middleware or a sub-router mounted at a path prefix. */ + use(path: string, handler: Handler | Router): this; + /** @internal Run this router's layers against a (possibly stripped) path. */ + handle(req: Request, res: Response, path: string, done: NextFunction): void; +} + +/** + * An express-like application: a `Router` that is also a Node HTTP request + * listener, so it can be passed straight to `http.createServer`. + */ +export interface Express extends Router { + (req: IncomingMessage, res: ServerResponse): void; +} + +/** Escape a literal path segment for use inside a `RegExp`. */ +function escapeRegExp(literal: string): string { + return literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Compile a route pattern into a full-path matcher. Supports literal segments, + * `:name` parameters (one path segment each), and a bare `*` that matches any + * path. A trailing slash is tolerated. + */ +function compileRoute(pattern: string): Matcher { + if (pattern === '*') { + return (path) => ({ params: {}, remainder: path }); + } + + const keys: string[] = []; + const source = pattern + .split('/') + .map((segment) => { + if (segment === '') return ''; + if (segment.startsWith(':')) { + keys.push(segment.slice(1)); + return '/([^/]+)'; + } + return '/' + escapeRegExp(segment); + }) + .join(''); + const regexp = new RegExp('^' + source + '/?$'); + + return (path) => { + const m = regexp.exec(path); + if (!m) return undefined; + const params: Record = {}; + keys.forEach((key, i) => { + const value = m[i + 1]; + if (value !== undefined) params[key] = decodeURIComponent(value); + }); + return { params, remainder: path }; + }; +} + +/** + * Compile a mount pattern into a prefix matcher. A router or middleware mounted + * at `/api` matches `/api`, `/api/`, and `/api/anything`; the remainder passed + * to the mounted handler has the prefix stripped (`/health`, or `/` when the + * path is exactly the prefix). An undefined prefix matches every path. + */ +function compileMount(prefix: string | undefined): Matcher { + if (prefix === undefined || prefix === '' || prefix === '/') { + return (path) => ({ params: {}, remainder: path }); + } + const normalized = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix; + return (path) => { + if (path === normalized) return { params: {}, remainder: '/' }; + if (path.startsWith(normalized + '/')) { + return { params: {}, remainder: path.slice(normalized.length) }; + } + return undefined; + }; +} + +/** Run a route's handlers in order, chaining `next` to the router's `next` when they're exhausted. */ +function runHandlers(handlers: readonly Handler[], req: Request, res: Response, next: NextFunction): void { + let i = 0; + const step: NextFunction = () => { + const handler = handlers[i++]; + if (!handler) return next(); + handler(req, res, step); + }; + step(); +} + +/** Build the shared router core (used by both `Router` and `Express`). */ +export function createRouter(): Router { + const layers: Layer[] = []; + + const router = { + [ROUTER]: true as const, + + get(path: string, ...handlers: Handler[]): Router { + layers.push({ + method: 'GET', + match: compileRoute(path), + dispatch: (req, res, _remainder, next) => runHandlers(handlers, req, res, next), + }); + return router; + }, + + use(pathOrHandler: string | Handler, maybeHandler?: Handler | Router): Router { + const path = typeof pathOrHandler === 'string' ? pathOrHandler : undefined; + const target = typeof pathOrHandler === 'string' ? maybeHandler! : pathOrHandler; + + if (isRouter(target)) { + const sub = target; + layers.push({ + match: compileMount(path), + dispatch: (req, res, remainder, next) => sub.handle(req, res, remainder, next), + }); + } else { + const handler = target as Handler; + layers.push({ + match: compileMount(path), + dispatch: (req, res, _remainder, next) => handler(req, res, next), + }); + } + return router; + }, + + handle(req: Request, res: Response, path: string, done: NextFunction): void { + let i = 0; + const next: NextFunction = () => { + const layer = layers[i++]; + if (!layer) return done(); + if (layer.method && layer.method !== req.method) return next(); + const match = layer.match(path); + if (!match) return next(); + req.params = { ...req.params, ...match.params }; + layer.dispatch(req, res, match.remainder, next); + }; + next(); + }, + } satisfies Router; + + return router; +} + +/** True when a value is a mountable router produced by {@link createRouter}. */ +function isRouter(value: unknown): value is Router { + return typeof value === 'object' && value !== null && (value as any)[ROUTER] === true; +} + +/** + * Create an express-like application. Returned as a function so it can be passed + * directly to `http.createServer`, with the router methods (`get`, `use`) and + * `attached to it. + */ +export function createApp(): Express { + const router = createRouter(); + + const app = ((raw: IncomingMessage, rawRes: ServerResponse): void => { + const res = makeResponse(rawRes); + + // Parsing the request can fail, handle it gracefully + let req: Request; + try { + req = makeRequest(raw); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`${raw.method} ${raw.url}:`, e); + res.status(400).end(); + return; + } + + try { + router.handle(req, res, req.path, () => { + // Nothing matched: mirror express's default 404. + if (!res.headersSent) { + res.status(404); + res.set('Content-Type', 'text/plain; charset=utf-8'); + } + res.send(`Cannot ${req.method} ${req.path}`); + res.end(); + }); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`${raw.method} ${raw.url}:`, e); + res.status(500).send('Internal Server Error'); + return; + } + }) as Express; + + app.get = (path, ...handlers) => { + router.get(path, ...handlers); + return app; + }; + app.use = (pathOrHandler: string | Handler, maybeHandler?: Handler | Router) => { + (router.use as any)(pathOrHandler, maybeHandler); + return app; + }; + app.handle = (req, res, path, done) => router.handle(req, res, path, done); + (app as any)[ROUTER] = true; + + return app; +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/expressy/request.ts b/packages/@aws-cdk/cdk-explorer/lib/expressy/request.ts new file mode 100644 index 000000000..9ec23bea5 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/expressy/request.ts @@ -0,0 +1,67 @@ +import type { IncomingHttpHeaders, IncomingMessage } from 'http'; +import { parseCookieValue } from './cookies'; + +/** + * The inbound request. A structural subset of express's `Request`, backed by the + * Node `IncomingMessage` the HTTP server hands us. + */ +export interface Request { + /** Lowercased request headers, exactly as Node parsed them. */ + readonly headers: IncomingHttpHeaders; + /** HTTP method (e.g. "GET"). */ + readonly method: string; + /** Parsed query string. Repeated keys become arrays, matching express. */ + readonly query: Record; + /** Named route parameters captured from the matched path (e.g. `:asset`). */ + params: Record; + /** Request path with the query string removed. Always the full original path. */ + readonly path: string; + /** The full original request URL (path plus query string). */ + readonly originalUrl: string; + /** Subscribe to a raw request event (the explorer uses `'close'`). */ + on(event: string, listener: (...args: any[]) => void): this; + + /** + * Read a cookie by name from the request headers. + */ + cookie(name: string): string | undefined; +} + +/** Wrap the raw Node request in the {@link Request} surface the handlers use. */ +export function makeRequest(raw: IncomingMessage): Request { + const url = new URL(raw.url ?? '/', 'http://localhost'); + const req: Request = { + headers: raw.headers, + method: raw.method ?? 'GET', + query: parseQuery(url.searchParams), + params: {}, + path: url.pathname, + originalUrl: raw.url ?? '/', + on(event, listener) { + raw.on(event, listener); + return req; + }, + cookie(name: string) { + try { + const cookies = parseCookieValue(raw.headers.cookie); + return cookies[name]; + } catch (e) { + throw new MalformedRequest(`Invalid cookie ${name}: ${e}`); + } + }, + }; + return req; +} + +export class MalformedRequest extends Error { +} + +/** Parse a `URLSearchParams` into express's query shape (arrays for repeated keys). */ +function parseQuery(params: URLSearchParams): Record { + const query: Record = {}; + for (const key of new Set(params.keys())) { + const all = params.getAll(key); + query[key] = all.length > 1 ? all : all[0]; + } + return query; +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/expressy/response.ts b/packages/@aws-cdk/cdk-explorer/lib/expressy/response.ts new file mode 100644 index 000000000..c026d852a --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/expressy/response.ts @@ -0,0 +1,117 @@ +import type { ServerResponse } from 'http'; +import type { SetCookie } from './cookies'; +import { serializeSetCookieValue } from './cookies'; + +/** + * The outbound response. A structural subset of express's `Response`, backed by + * the Node `ServerResponse`. All setters return `this` so calls chain + * (`res.status(404).json(...)`), as they do in express. + */ +export interface Response { + headersSent: boolean; + + /** Set one header, or several at once from an object. */ + set(field: string, value: string): this; + set(fields: Record): this; + /** Set the status code used by the next `json`/`send`/`end`. */ + status(code: number): this; + /** Send a JSON body (sets `Content-Type: application/json` unless already set). */ + json(body: unknown): this; + /** Send a string/Buffer body. */ + send(body: string | Buffer): this; + /** Set the `Content-Type` header. A bare value is used verbatim when it looks like a MIME type. */ + type(contentType: string): this; + /** Send a redirect with the given status code and `Location`. */ + redirect(code: number, location: string): void; + /** Append a `Set-Cookie` header. */ + cookie(name: string, value: string, options?: SetCookie): this; + /** Flush the status line and headers to the socket without ending the response (for SSE). */ + flushHeaders(): void; + /** Write a raw chunk to the response body. */ + write(chunk: string | Buffer): boolean; + /** End the response. */ + end(): void; + /** Subscribe to a raw response event (the explorer uses `'error'`). */ + on(event: string, listener: (...args: any[]) => void): this; +} + +/** Wrap the raw Node response in the {@link Response} surface the handlers use. */ +export function makeResponse(raw: ServerResponse): Response { + raw.statusCode = 200; + + const res: Response = { + get headersSent() { + return raw.headersSent; + }, + set(field: string | Record, value?: string) { + if (typeof field === 'object') { + for (const [name, val] of Object.entries(field)) raw.setHeader(name, val); + } else { + raw.setHeader(field, value as string); + } + return res; + }, + status(code: number) { + raw.statusCode = code; + return res; + }, + json(body: unknown) { + if (!raw.hasHeader('Content-Type')) raw.setHeader('Content-Type', 'application/json; charset=utf-8'); + raw.end(JSON.stringify(body)); + return res; + }, + send(body: string | Buffer) { + if (!Buffer.isBuffer(body) && !raw.hasHeader('Content-Type')) { + raw.setHeader('Content-Type', 'text/html; charset=utf-8'); + } + raw.end(body); + return res; + }, + type(contentType: string) { + raw.setHeader('Content-Type', normalizeContentType(contentType)); + return res; + }, + redirect(code: number, location: string) { + if (!/^\/(?![/\\])/.test(location)) { + throw new Error(`${location} is invalid and should not be traversed to`); + } + raw.setHeader('Location', location); + raw.statusCode = code; + raw.end(); + }, + cookie(name: string, value: string, options?: SetCookie) { + const existing = raw.getHeader('Set-Cookie'); + const cookies = Array.isArray(existing) ? [...existing] : existing ? [String(existing)] : []; + cookies.push(serializeSetCookieValue(name, value, options)); + raw.setHeader('Set-Cookie', cookies); + return res; + }, + flushHeaders() { + raw.flushHeaders(); + }, + write(chunk: string | Buffer) { + return raw.write(chunk); + }, + end() { + raw.end(); + }, + on(event, listener) { + raw.on(event, listener); + return res; + }, + }; + return res; +} + +/** + * Turn a `type` argument into a `Content-Type` value. Anything that already + * looks like a MIME type (contains `/`) is used verbatim; the only bare name the + * explorer passes is handled explicitly. + */ +function normalizeContentType(value: string): string { + if (value.includes('/')) return value; + if (value === 'html') return 'text/html; charset=utf-8'; + if (value === 'json') return 'application/json; charset=utf-8'; + if (value === 'text') return 'text/plain; charset=utf-8'; + return value; +} diff --git a/packages/@aws-cdk/cdk-explorer/lib/index.ts b/packages/@aws-cdk/cdk-explorer/lib/index.ts index f2c7cd1ea..30f4b41f6 100644 --- a/packages/@aws-cdk/cdk-explorer/lib/index.ts +++ b/packages/@aws-cdk/cdk-explorer/lib/index.ts @@ -6,3 +6,6 @@ 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/web-shared/protocol.ts b/packages/@aws-cdk/cdk-explorer/lib/web-shared/protocol.ts new file mode 100644 index 000000000..176962035 --- /dev/null +++ b/packages/@aws-cdk/cdk-explorer/lib/web-shared/protocol.ts @@ -0,0 +1,182 @@ +/** HTTP and SSE contract shared between the web server and the SPA. */ + +/** + * SSE event name the server sends (and the SPA listens for) when the cloud + * assembly is rewritten. It carries no meaningful payload: the server holds no + * assembly state, so the SPA re-fetches the tree and violations on receipt. + */ +export const ASSEMBLY_CHANGED = 'assembly-changed'; + +/** + * SSE event name sent when a source file under the app directory changes. Lets + * the SPA re-check the open file's staleness (and refresh its content) the + * instant it is edited, without waiting for the next assembly refresh. + */ +export const SOURCE_CHANGED = 'source-changed'; + +/** The SSE event names the server may send. */ +export type SseEventName = typeof ASSEMBLY_CHANGED | typeof SOURCE_CHANGED; + +export interface FileResponse { + readonly path: string; + readonly content: string; + /** + * True when the file on disk was modified after the synth that produced the + * currently loaded assembly started, so its highlighted lines and violations + * may be out of date. Best-effort (see `StalenessTracker`). + */ + readonly stale: boolean; +} + +/** + * A construct's source location for the SPA. Mirrors the core `SourceLocation` + * but with `file` made app-relative (POSIX), so the client can feed it straight + * back into `/api/file`. Line and column are 1-based, or 0 when only the file + * is known. + */ +export interface WebSourceLocation { + /** Source file path relative to the app directory. POSIX separators. */ + readonly file: string; + readonly line: number; +} + +export interface WebTemplateLocation { + /** + * Path to the synthesized template that declares this construct's CFN + * resource, relative to the cloud assembly (`cdk.out`) directory, with POSIX + * separators. Usually a bare name like "MyStack.template.json", but includes + * the sub-assembly directory for staged stacks, e.g. + * "assembly-Prod/Prod-MyStack.template.json". Only set for CFN resources. The + * core's absolute path is relativized before it crosses the wire. + */ + readonly file: string; + + /** CFN logical ID, if this construct maps to a resource. */ + readonly logicalId: string; + + /** + * Location within the resource + */ + readonly propertyPath?: string; +} + +/** + * A construct tree node as served to the SPA. A trimmed, wire-stable view of + * the core `ConstructNode`: absolute source paths are relativized to the app + * directory and nothing internal leaks over the wire. + */ +export interface WebConstructNode { + /** Construct path, e.g. "MyStack/DataBucket". */ + readonly path: string; + readonly id: string; + /** CFN resource type (e.g. "AWS::S3::Bucket"), if this construct is a resource. */ + readonly type?: string; + + readonly templateLocation?: WebTemplateLocation; + readonly sourceLocation?: WebSourceLocation; + /** + * Highest-severity policy-violation label affecting this construct. A folded + * default child's violations count toward its parent. Absent when the + * construct has no violation. Used to flag the node in the tree. + */ + readonly highestSeverity?: string; + /** + * Highest severity inherited from any descendant. Set when a child (or + * deeper) has a violation but this node does not. Lets the tree color + * ancestor labels so users can drill down to the offending construct. + */ + readonly inheritedSeverity?: string; + readonly children: readonly WebConstructNode[]; + + readonly constructFqn?: string; +} + +/** Line range within a template (1-based, inclusive on both ends). */ +export interface LineRange { + readonly startLine: number; + readonly endLine: number; +} + +/** Resolved resource metadata returned by `GET /api/template`. */ +export interface TemplateResource { + /** Line range of the resource's value block `{ ... }`. */ + readonly block: LineRange; + /** User source location for the construct that owns this resource. */ + readonly source?: WebSourceLocation; +} + +/** Response for `GET /api/template?file=`. */ +export interface TemplateResponse { + readonly content: string; + readonly resources: Record; +} + +/** + * Response for `GET /api/tree`. `not-synthesized` means no cloud assembly was + * found (the user has not run `cdk synth`) + */ +export type TreeResponse = + | { readonly status: 'ok'; readonly tree: readonly WebConstructNode[]; readonly warnings: readonly string[]; readonly timestamp: number } + | { readonly status: 'not-synthesized' }; + +/** + * Severity exactly as reported by CDK policy validation. Unlike the LSP, which + * collapses these onto its three diagnostic levels, the SPA keeps the full set + * so the violations panel can distinguish (for example) fatal from error. + */ +export type WebViolationSeverity = 'fatal' | 'error' | 'warning' | 'info' | 'custom'; + +/** + * A single construct that triggered a violation, joined to construct-tree data + * (resolved source location and template file) so a future navigation feature + * can link to the resource and its source. + */ +export interface WebViolationOccurrence { + /** Construct path of the offending construct, e.g. "MyStack/MyBucket". */ + readonly constructPath: string; + /** CFN logical ID of the offending resource, if known. */ + readonly logicalId?: string; + /** + * Template that declares the resource, relative to the cloud assembly + * (`cdk.out`) directory with POSIX separators (see {@link WebConstructNode.templateFile}). + */ + readonly templateFile?: string; + /** Resolved user source location; absent for non-TypeScript apps. */ + readonly sourceLocations?: readonly WebSourceLocation[]; + /** JSON property paths within the resource that violate the rule. */ + readonly propertyPaths?: readonly string[]; +} + +/** A policy-validation violation, normalized for the SPA. */ +export interface WebViolation { + readonly ruleName: string; + readonly description: string; + /** Severity as reported; absent for plugins (e.g. CfnGuard) that don't emit one. */ + readonly severity?: WebViolationSeverity; + /** Plugin-specific label when `severity` is "custom". */ + readonly customSeverity?: string; + /** Validation plugin that produced the violation. */ + readonly source: string; + /** Suggested fix text, when the plugin provides one. */ + readonly suggestedFix?: string; + readonly occurrences: readonly WebViolationOccurrence[]; +} + +/** + * Response for `GET /api/policy-validation`. `not-synthesized` mirrors the tree + * endpoint: no cloud assembly was found. When the assembly exists, `violations` + * is the normalized list (empty when the report is clean or absent). + */ +export type ViolationsResponse = + | { readonly status: 'ok'; readonly violations: readonly WebViolation[] } + | { readonly status: 'not-synthesized' }; + +/** + * A logical ID marker used to represent the entire template in the construct tree. + * + * With this we can treat a template producer in the same flow as a "resource", where + * the presence of a `logicalId` field indicates that it maps to a CloudFormation template. + * + * This marker contains characters that are not valid in a regular logical ID so it cannot conflict. + */ +export const ENTIRE_TEMPLATE_LOGICALID_MARKER = '<