From 106137c817ce12aea64ff9abb3d9aeee6c2e8de4 Mon Sep 17 00:00:00 2001 From: Rico Huijbers Date: Mon, 19 Oct 2026 15:48:22 +0200 Subject: [PATCH 1/2] feat(cli): add `cdk explore`, a web explorer for your CDK app Serve a local web UI that shows a synthesized app three ways -- construct tree, authored source, and CloudFormation template -- with navigation between them and policy-validation findings in context. - `cdk explore [--port]` starts a local-only server (default 3411) - `lib/web/`: routes for tree/template/file/policy-validation, SSE live refresh on re-synth, source staleness tracking, and local-only, security-header and session-token middleware - `lib/expressy/`: a small HTTP layer replacing Express, so the CLI bundle does not carry a web framework - `lib/web-shared/`: the wire protocol and severity model shared with the SPA - `frontend/`: React + Cloudscape single-page app, type-checked separately and bundled with esbuild into the published package --- .projenrc.ts | 34 + packages/@aws-cdk/cdk-explorer/.eslintrc.json | 20 +- packages/@aws-cdk/cdk-explorer/.gitignore | 5 + packages/@aws-cdk/cdk-explorer/.npmignore | 1 + .../@aws-cdk/cdk-explorer/.projen/deps.json | 65 + .../@aws-cdk/cdk-explorer/.projen/tasks.json | 16 +- .../@aws-cdk/cdk-explorer/CONTRIBUTING.md | 8 + .../build-tools/bundle-frontend.ts | 91 + .../cdk-explorer/build-tools/watch-explore.sh | 11 + .../cdk-explorer/frontend/prismjs.d.ts | 9 + .../cdk-explorer/frontend/src/App.tsx | 599 ++++ .../@aws-cdk/cdk-explorer/frontend/src/api.ts | 45 + .../frontend/src/components/CodeViewer.tsx | 223 ++ .../frontend/src/components/ConstructTree.tsx | 154 + .../frontend/src/components/Flex.tsx | 31 + .../frontend/src/components/Help.tsx | 56 + .../src/components/LightDarkToggle.tsx | 49 + .../frontend/src/components/RelativeTime.tsx | 55 + .../src/components/TemplateViewer.tsx | 196 ++ .../src/components/ViolationsPanel.tsx | 173 ++ .../frontend/src/components/colors.ts | 25 + .../frontend/src/components/severities.tsx | 20 + .../cdk-explorer/frontend/src/highlight.css | 40 + .../cdk-explorer/frontend/src/icon.png | Bin 0 -> 17008 bytes .../cdk-explorer/frontend/src/index.html | 35 + .../cdk-explorer/frontend/src/index.tsx | 15 + .../cdk-explorer/frontend/src/nav-types.ts | 12 + .../frontend/src/source-and-template-index.ts | 106 + .../cdk-explorer/frontend/src/syntax.ts | 97 + .../cdk-explorer/frontend/src/tsconfig.json | 15 + .../cdk-explorer/frontend/src/util.ts | 9 + .../frontend/test/source-nav.test.ts | 135 + .../cdk-explorer/frontend/test/tsconfig.json | 14 + .../@aws-cdk/cdk-explorer/jest.config.json | 2 +- .../@aws-cdk/cdk-explorer/lib/api-private.ts | 1 + .../cdk-explorer/lib/core/assembly-watcher.ts | 30 +- .../cdk-explorer/lib/core/template-ranges.ts | 14 + .../cdk-explorer/lib/expressy/cookies.ts | 77 + .../cdk-explorer/lib/expressy/http-server.ts | 92 + .../cdk-explorer/lib/expressy/index.ts | 262 ++ .../cdk-explorer/lib/expressy/request.ts | 67 + .../cdk-explorer/lib/expressy/response.ts | 117 + packages/@aws-cdk/cdk-explorer/lib/index.ts | 3 + .../cdk-explorer/lib/web-shared/protocol.ts | 182 ++ .../cdk-explorer/lib/web-shared/severity.ts | 71 + .../cdk-explorer/lib/web-shared/tsconfig.json | 9 + .../@aws-cdk/cdk-explorer/lib/web/events.ts | 100 + .../lib/web/middleware/local-only.ts | 100 + .../lib/web/middleware/security-headers.ts | 53 + .../lib/web/middleware/session-token.ts | 111 + .../@aws-cdk/cdk-explorer/lib/web/routes.ts | 546 ++++ .../cdk-explorer/lib/web/safe-path.ts | 94 + .../@aws-cdk/cdk-explorer/lib/web/server.ts | 216 ++ .../cdk-explorer/lib/web/staleness.ts | 45 + .../cdk-explorer/lib/web/web-assets.ts | 57 + packages/@aws-cdk/cdk-explorer/package.json | 16 +- .../test/core/assembly-watcher.test.ts | 36 +- .../test/core/template-ranges.test.ts | 34 +- .../test/expressy/cookies.test.ts | 100 + .../test/expressy/expressy.test.ts | 123 + .../cdk-explorer/test/fetch-happens.ts | 58 + .../cdk-explorer/test/web/events.test.ts | 195 ++ .../cdk-explorer/test/web/local-only.test.ts | 152 + .../test/web/normalize-violations.test.ts | 181 ++ .../cdk-explorer/test/web/routes.test.ts | 354 +++ .../cdk-explorer/test/web/safe-path.test.ts | 178 ++ .../cdk-explorer/test/web/server.test.ts | 643 ++++ .../cdk-explorer/test/web/staleness.test.ts | 50 + .../cdk-explorer/test/web/to-web-node.test.ts | 282 ++ packages/@aws-cdk/cdk-explorer/tsconfig.json | 9 +- .../integ-runner/THIRD_PARTY_LICENSES | 14 +- .../@aws-cdk/toolkit-lib/lib/api/rwlock.ts | 5 +- packages/aws-cdk/THIRD_PARTY_LICENSES | 36 +- packages/aws-cdk/lib/cli/cli-config.ts | 6 + .../aws-cdk/lib/cli/cli-type-registry.json | 9 + packages/aws-cdk/lib/cli/cli.ts | 9 + .../aws-cdk/lib/cli/convert-to-user-input.ts | 10 + .../aws-cdk/lib/cli/io-host/cli-io-host.ts | 1 + .../lib/cli/parse-command-line-arguments.ts | 7 + packages/aws-cdk/lib/cli/telemetry/session.ts | 4 +- .../aws-cdk/lib/cli/user-configuration.ts | 1 + packages/aws-cdk/lib/cli/user-input.ts | 19 + packages/aws-cdk/lib/commands/explore.ts | 36 + packages/aws-cdk/lib/private/explorer.ts | 2 +- .../aws-cdk/test/cli/cli-arguments.test.ts | 1 + .../aws-cdk/test/commands/explore.test.ts | 135 + packages/cdk-assets/THIRD_PARTY_LICENSES | 2 +- yarn.lock | 2749 +++++++++-------- 88 files changed, 8781 insertions(+), 1289 deletions(-) create mode 100644 packages/@aws-cdk/cdk-explorer/CONTRIBUTING.md create mode 100644 packages/@aws-cdk/cdk-explorer/build-tools/bundle-frontend.ts create mode 100755 packages/@aws-cdk/cdk-explorer/build-tools/watch-explore.sh create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/prismjs.d.ts create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/App.tsx create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/api.ts create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/components/CodeViewer.tsx create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/components/ConstructTree.tsx create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/components/Flex.tsx create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/components/Help.tsx create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/components/LightDarkToggle.tsx create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/components/RelativeTime.tsx create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/components/TemplateViewer.tsx create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/components/ViolationsPanel.tsx create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/components/colors.ts create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/components/severities.tsx create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/highlight.css create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/icon.png create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/index.html create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/index.tsx create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/nav-types.ts create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/source-and-template-index.ts create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/syntax.ts create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/tsconfig.json create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/src/util.ts create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/test/source-nav.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/frontend/test/tsconfig.json create mode 100644 packages/@aws-cdk/cdk-explorer/lib/expressy/cookies.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/expressy/http-server.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/expressy/index.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/expressy/request.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/expressy/response.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/web-shared/protocol.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/web-shared/severity.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/web-shared/tsconfig.json create mode 100644 packages/@aws-cdk/cdk-explorer/lib/web/events.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/web/middleware/local-only.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/web/middleware/security-headers.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/web/middleware/session-token.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/web/routes.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/web/safe-path.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/web/server.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/web/staleness.ts create mode 100644 packages/@aws-cdk/cdk-explorer/lib/web/web-assets.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/expressy/cookies.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/expressy/expressy.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/fetch-happens.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/web/events.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/web/local-only.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/web/normalize-violations.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/web/routes.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/web/safe-path.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/web/server.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/web/staleness.test.ts create mode 100644 packages/@aws-cdk/cdk-explorer/test/web/to-web-node.test.ts create mode 100644 packages/aws-cdk/lib/commands/explore.ts create mode 100644 packages/aws-cdk/test/commands/explore.test.ts 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 0000000000000000000000000000000000000000..bffb98637bf57cec22f22fe79f0e25aa3b5fa949 GIT binary patch literal 17008 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 literal 0 HcmV?d00001 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 = '<