From ea67ceaffcf92595a0f3c329d5a8a08e3036550c Mon Sep 17 00:00:00 2001 From: Arman Jivanyan Date: Thu, 13 Aug 2026 11:15:22 +0400 Subject: [PATCH 1/6] draft vendors for react and vue --- .github/workflows/visual-tests-demos.yml | 118 +++++++-- apps/demos/.gitignore | 3 + apps/demos/package.json | 1 + .../utils/server/build-vendor-bundles.js | 90 +++++++ apps/demos/utils/server/csp-bundle-angular.js | 92 +++---- apps/demos/utils/server/csp-bundle.js | 62 ++--- apps/demos/utils/server/csp-check.js | 10 +- apps/demos/utils/server/csp-server.js | 9 +- apps/demos/utils/server/vendor-bundle.js | 234 ++++++++++++++++++ apps/demos/utils/shell/server.js | 6 +- .../utils/ts-to-js-converter/converter.ts | 4 +- 11 files changed, 476 insertions(+), 153 deletions(-) create mode 100644 apps/demos/utils/server/build-vendor-bundles.js create mode 100644 apps/demos/utils/server/vendor-bundle.js diff --git a/.github/workflows/visual-tests-demos.yml b/.github/workflows/visual-tests-demos.yml index e7b9e9f13204..9c4291f993ed 100644 --- a/.github/workflows/visual-tests-demos.yml +++ b/.github/workflows/visual-tests-demos.yml @@ -273,17 +273,80 @@ jobs: devextreme-vue-installer.tgz retention-days: 1 + # UNTESTED DRAFT — diff-reviewed locally only, never run in real CI. + build-vendor-bundles: + name: Build vendor bundles (React, Vue) + runs-on: devextreme-shr2 + timeout-minutes: 15 + needs: [check-should-run, determine-framework-tests-scope, build-devextreme] + if: | + always() && + needs.check-should-run.outputs.should-run == 'true' && + needs.determine-framework-tests-scope.result == 'success' && + needs.determine-framework-tests-scope.outputs.framework-tests-scope != 'none' && + needs.build-devextreme.result == 'success' + env: + NODE_OPTIONS: --max-old-space-size=8192 + steps: + - name: Get sources + uses: actions/checkout@v6 + + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version-file: '.node-version' + + - name: Download devextreme sources + uses: actions/download-artifact@v8 + with: + name: devextreme-sources + + - uses: pnpm/action-setup@v6 + with: + run_install: false + + - name: Get pnpm store directory + shell: bash + run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - uses: actions/cache/restore@v5 + name: Restore pnpm cache + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-cache-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-cache + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Install tgz + working-directory: apps/demos + run: pnpm add --ignore-workspace --allow-build=core-js --allow-build=inferno devextreme-aspnet-data@5.1.0 devextreme-aspnet-data-nojquery@5.1.0 ../../devextreme-installer.tgz ../../devextreme-dist-installer.tgz ../../devextreme-react-installer.tgz ../../devextreme-vue-installer.tgz ../../devextreme-angular-installer.tgz && rm -f pnpm-workspace.yaml pnpm-lock.yaml + + - name: Build vendor bundles + working-directory: apps/demos + run: node utils/server/build-vendor-bundles.js + + - name: Copy vendor bundles + uses: actions/upload-artifact@v7 + with: + name: devextreme-vendor-bundles + path: apps/demos/bundles/vendor + retention-days: 1 + build-demos: name: ${{ matrix.SHARD_TOTAL == 1 && format('Build demos ({0})', matrix.FRAMEWORK) || format('Build demos ({0} {1}/{2})', matrix.FRAMEWORK, matrix.SHARD_INDEX, matrix.SHARD_TOTAL) }} runs-on: devextreme-shr2 timeout-minutes: 30 - needs: [check-should-run, determine-framework-tests-scope, build-devextreme] + needs: [check-should-run, determine-framework-tests-scope, build-devextreme, build-vendor-bundles] if: | always() && needs.check-should-run.outputs.should-run == 'true' && needs.determine-framework-tests-scope.result == 'success' && needs.determine-framework-tests-scope.outputs.framework-tests-scope != 'none' && - needs.build-devextreme.result == 'success' + needs.build-devextreme.result == 'success' && + needs.build-vendor-bundles.result == 'success' env: NODE_OPTIONS: --max-old-space-size=8192 strategy: @@ -340,6 +403,13 @@ jobs: # - name: Link wrappers packages # run: pnpm install --frozen-lockfile + # No-op for Angular (no manifest — see build-vendor-bundles). + - name: Download vendor bundles + uses: actions/download-artifact@v8 + with: + name: devextreme-vendor-bundles + path: apps/demos/bundles/vendor + - name: Build demo bundles working-directory: apps/demos env: @@ -353,11 +423,7 @@ jobs: working-directory: apps/demos run: pnpm exec tsc --noEmit - # Packed into one tarball rather than uploaded as ~950 loose files: with a - # glob path, upload-artifact roots the archive at the least common ancestor - # of the matched files, so the Demos/ prefix the consumers extract into - # would silently disappear. A single explicit file has no such ambiguity - # (and uploads far faster). + # Packed into one tarball — a glob path would lose the Demos/ prefix on extract. - name: Pack demo bundles working-directory: apps/demos run: | @@ -855,7 +921,7 @@ jobs: if-no-files-found: ignore testcafe-frameworks-all: - needs: [check-should-run, determine-framework-tests-scope, build-demos] + needs: [check-should-run, determine-framework-tests-scope, build-demos, build-vendor-bundles] if: | always() && needs.check-should-run.outputs.should-run == 'true' && @@ -938,12 +1004,6 @@ jobs: working-directory: apps/demos run: pnpm run prepare-js - # Demo bundles are built once per framework/shard in build-demos and - # downloaded here to overlay onto this job's own checkout (same commit). - # Every artifact holds a file named demo-bundles.tgz, so each lands in its - # own directory and the unpack step below extracts them all into apps/demos, - # restoring Demos////bundle.{js,css} — exactly - # where each demo's own index.html expects them. - name: Download demo bundles (React) if: startsWith(matrix.CONSTEL, 'react') uses: actions/download-artifact@v8 @@ -958,9 +1018,7 @@ jobs: name: devextreme-bundles-Vue path: apps/demos/.demo-bundles/vue - # Angular is built in 3 shards (see build-demos) but tested in 10 — every - # Angular test shard needs all 3 build shards since the two shard schemes - # don't line up demo-for-demo. + # Angular is built in 3 shards but tested in 10 — every test shard needs all 3. - name: Download demo bundles (Angular shard 1) if: startsWith(matrix.CONSTEL, 'angular') uses: actions/download-artifact@v8 @@ -982,6 +1040,13 @@ jobs: name: devextreme-bundles-Angular-shard3 path: apps/demos/.demo-bundles/angular-shard3 + # No-op for angular demos (no manifest — see build-vendor-bundles.js). + - name: Download vendor bundles + uses: actions/download-artifact@v8 + with: + name: devextreme-vendor-bundles + path: apps/demos/bundles/vendor + - name: Unpack demo bundles run: | shopt -s nullglob @@ -1048,7 +1113,7 @@ jobs: if-no-files-found: ignore testcafe-frameworks-changed: - needs: [check-should-run, determine-framework-tests-scope, build-demos] + needs: [check-should-run, determine-framework-tests-scope, build-demos, build-vendor-bundles] if: | always() && needs.check-should-run.outputs.should-run == 'true' && @@ -1119,11 +1184,6 @@ jobs: working-directory: apps/demos run: pnpm run prepare-js - # Demo bundles are built once per framework/shard in build-demos and - # downloaded here to overlay onto this job's own checkout (same commit). - # Every artifact holds a file named demo-bundles.tgz, so each lands in its - # own directory and the unpack step below extracts them all into - # apps/demos, restoring Demos////bundle.{js,css}. - name: Download demo bundles (React) if: matrix.CONSTEL == 'react' uses: actions/download-artifact@v8 @@ -1166,6 +1226,13 @@ jobs: name: changed-demos path: apps/demos + # No-op for angular demos (no manifest — see build-vendor-bundles.js). + - name: Download vendor bundles + uses: actions/download-artifact@v8 + with: + name: devextreme-vendor-bundles + path: apps/demos/bundles/vendor + - name: Unpack demo bundles run: | shopt -s nullglob @@ -1404,10 +1471,7 @@ jobs: working-directory: apps/demos run: pnpm add --ignore-workspace --allow-build=core-js --allow-build=inferno devextreme-aspnet-data@5.1.0 devextreme-aspnet-data-nojquery@5.1.0 ../../devextreme-installer.tgz ../../devextreme-dist-installer.tgz ../../devextreme-react-installer.tgz ../../devextreme-vue-installer.tgz ../../devextreme-angular-installer.tgz && rm -f pnpm-workspace.yaml pnpm-lock.yaml - # Every demo is a plain esbuild bundle now, so this is the same build - # step build-demos runs — done again here (independently, in parallel) - # rather than depending on that job, to keep this check's timing - # decoupled from the rest of the pipeline. + # Rebuilt independently rather than depending on build-demos, to keep timing decoupled. - name: Bundle demos for CSP check working-directory: apps/demos env: diff --git a/apps/demos/.gitignore b/apps/demos/.gitignore index b6bc7a1749f1..8e09b2d9725e 100644 --- a/apps/demos/.gitignore +++ b/apps/demos/.gitignore @@ -44,5 +44,8 @@ csp-bundled-demos utils/server/.csp-bundle-angular-* Demos/**/.csp-bundle-angular-patched.*.ts +# Scratch entry files from utils/server/vendor-bundle.js (cleaned up on success). +.vendor-entry-tmp + .angular angular.json diff --git a/apps/demos/package.json b/apps/demos/package.json index 5f59605a5908..1aaf357604bb 100644 --- a/apps/demos/package.json +++ b/apps/demos/package.json @@ -177,6 +177,7 @@ "test-testcafe:accessibility": "cross-env STRATEGY=accessibility CONSTEL=jquery node utils/visual-tests/testcafe-runner.ts", "csp-server": "node utils/server/csp-server.js 8080", "csp-check": "node utils/server/csp-check.js", + "build-vendor-bundles": "node utils/server/build-vendor-bundles.js", "fix-lint": "prettier --write . && eslint --fix . && stylelint **/*.{css,vue} --fix", "prettier": "prettier", "convert-to-js": "ts-node ./utils/ts-to-js-converter/cli.ts", diff --git a/apps/demos/utils/server/build-vendor-bundles.js b/apps/demos/utils/server/build-vendor-bundles.js new file mode 100644 index 000000000000..18327ea4d0fc --- /dev/null +++ b/apps/demos/utils/server/build-vendor-bundles.js @@ -0,0 +1,90 @@ +// CLI: node utils/server/build-vendor-bundles.js [React|Vue|Angular ...] +// Defaults to React+Vue — Angular is excluded because a separately-bundled +// devextreme-angular/@angular/* throws a JIT/Ivy-linker error at runtime (pass +// `Angular` explicitly to pick that up). + +const path = require('path'); +const fs = require('fs'); +const { buildVendorBundle } = require('./vendor-bundle'); + +const REQUESTED = process.argv.slice(2); +const FRAMEWORKS = REQUESTED.length > 0 ? REQUESTED : ['React', 'Vue']; + +function reactVueOptions(framework) { + // eslint-disable-next-line global-require + const { getSharedOptions } = require('./csp-bundle'); + const options = getSharedOptions(framework); + return { + ...options, + plugins: options.plugins.filter((p) => p.name !== 'vendor-bundle:external-global'), + }; +} + +function angularOptions() { + // eslint-disable-next-line global-require + const cspBundleAngular = require('./csp-bundle-angular'); + const { + angularSingleCopyPlugin, + antiForgeryPlugin, + systemJsQuirksPlugin, + devextremeAngularSnakeCasePlugin, + devextremeDistRedirectPlugin, + devextremeRedirectPlugin, + NODE_MODULES, + DEMOS_APP_ROOT, + } = cspBundleAngular; + + return { + platform: 'browser', + target: 'es2022', + mainFields: ['es2020', 'es2015', 'browser', 'module', 'main'], + conditions: ['es2020', 'es2015', 'module'], + alias: { + globalize: path.join(NODE_MODULES, 'globalize', 'dist', 'globalize.js'), + }, + loader: { + '.png': 'dataurl', + '.jpg': 'dataurl', + '.jpeg': 'dataurl', + '.gif': 'dataurl', + '.svg': 'dataurl', + }, + define: { + 'process.env.NODE_ENV': '"production"', + ngJitMode: 'false', + }, + logLevel: 'silent', + plugins: [ + angularSingleCopyPlugin, + antiForgeryPlugin, + systemJsQuirksPlugin, + devextremeAngularSnakeCasePlugin, + devextremeDistRedirectPlugin, + devextremeRedirectPlugin, + ], + resolveExtensions: ['.ts', '.mjs', '.js'], + absWorkingDir: DEMOS_APP_ROOT, + nodePaths: [NODE_MODULES], + }; +} + +async function main() { + for (const framework of FRAMEWORKS) { + const options = framework === 'Angular' ? angularOptions() : reactVueOptions(framework); + process.stdout.write(`Building vendor bundle for ${framework}... `); + const manifest = await buildVendorBundle(framework, options); + if (!manifest) { + console.log('skipped (no vendor-eligible imports found)'); + // eslint-disable-next-line no-continue + continue; + } + const outFile = path.join(__dirname, '..', '..', 'bundles', 'vendor', manifest.file); + const size = fs.statSync(outFile).size; + console.log(`${manifest.specifiers.length} specifiers, ${(size / 1024).toFixed(0)} KB -> bundles/vendor/${manifest.file}`); + } +} + +main().catch((err) => { + console.error('build-vendor-bundles failed:', err); + process.exit(1); +}); diff --git a/apps/demos/utils/server/csp-bundle-angular.js b/apps/demos/utils/server/csp-bundle-angular.js index 00506cbb83f4..f45151eb31b8 100644 --- a/apps/demos/utils/server/csp-bundle-angular.js +++ b/apps/demos/utils/server/csp-bundle-angular.js @@ -1,22 +1,18 @@ /* eslint-disable global-require, import/no-dynamic-require */ -// Bundles every Angular demo into csp-bundled-demos///Angular/ via -// AOT (@angular/build/private's createCompilerPlugin). Kept separate from -// csp-bundle.js, which delegates here for --framework=Angular. +// Bundles every Angular demo via AOT; csp-bundle.js delegates here for --framework=Angular. const path = require('path'); const fs = require('fs'); const os = require('os'); const esbuild = require('esbuild'); const { extractDemoHeadExtras, extractDemoBodyInner } = require('./demo-html'); +const { vendorGlobalPlugin, vendorScriptTag } = require('./vendor-bundle'); const DEMOS_APP_ROOT = path.resolve(__dirname, '..', '..'); const REPO_ROOT = path.resolve(DEMOS_APP_ROOT, '..', '..'); const SRC_DEMOS_DIR = path.join(DEMOS_APP_ROOT, 'Demos'); -// Write bundle.js next to each demo's own source (the real, only demo tree) -// instead of into the csp-bundled-demos/ side directory used by the CSP-only -// check. This is what CI's demo-build step uses in production. const IN_PLACE = process.env.BUNDLE_IN_PLACE === '1'; const OUT_ROOT = IN_PLACE ? SRC_DEMOS_DIR : path.join(DEMOS_APP_ROOT, 'csp-bundled-demos'); const NODE_MODULES = path.join(DEMOS_APP_ROOT, 'node_modules'); @@ -34,9 +30,7 @@ const RETRY_CONCURRENCY = (() => { return 2; })(); -// Demos per esbuild build. Larger batches amortize the per-build Angular -// compilation setup but are memory-bound — too large OOMs the CI runner. 12 is -// the safe default; raise via CSP_BUNDLE_BATCH_SIZE only on a high-RAM box. +// 12 is a safe default — larger batches OOM the CI runner. const BATCH_SIZE = (() => { const fromEnv = parseInt(process.env.CSP_BUNDLE_BATCH_SIZE, 10); if (fromEnv > 0) return fromEnv; @@ -49,11 +43,8 @@ const BATCH_CONCURRENCY = (() => { return 1; })(); -// Optional substring filter for local smoke tests, e.g. CSP_BUNDLE_FILTER=Common/FormsOverview. const FILTER = (process.env.CSP_BUNDLE_FILTER || '').trim(); -// Optional round-robin sharding across parallel CI jobs (CSP_SHARD_TOTAL / -// CSP_SHARD_INDEX, 1-based). const SHARD_TOTAL = Math.max(1, parseInt(process.env.CSP_SHARD_TOTAL, 10) || 1); const SHARD_INDEX = (() => { const n = parseInt(process.env.CSP_SHARD_INDEX, 10); @@ -81,8 +72,7 @@ function resolveAngularBuildPrivate() { return require(require.resolve('@angular/build/private', { paths: [buildAngularDir] })); } -// Per demo, write a tsconfig that extends the shared template and lists the entry -// in `files` (ngc rejects empty files+include, TS18002). Slug avoids collisions. +// ngc rejects empty files+include (TS18002), so `files` always lists the entry. function writeTsconfig(name, entryPaths) { fs.mkdirSync(GENERATED_TSCONFIG_DIR, { recursive: true }); const slug = name.replace(/[\\/]/g, '__').replace(/[^a-zA-Z0-9_.-]/g, '_'); @@ -104,8 +94,6 @@ function writeDemoTsconfig(entryPath) { return writeTsconfig(path.relative(REPO_ROOT, entryPath), [entryPath]); } -// A couple of demos add their own markup next to (Charts/AxisLabelCustomization -// keeps its SVG filter there), so the body is carried over rather than fixed. const DEFAULT_BODY_INNER = `
Loading...
`; @@ -124,6 +112,7 @@ function buildHtml({ jsFiles, cssFiles, srcDir }) { return ``; }) .join('\n '); + const vendorTag = vendorScriptTag('Angular'); return ` @@ -135,7 +124,7 @@ function buildHtml({ jsFiles, cssFiles, srcDir }) { ${bodyInner} - ${scripts} + ${vendorTag ? `${vendorTag}\n ` : ''}${scripts} `; @@ -175,10 +164,8 @@ function findDemos() { return out; } -// ---- CSS asset shim infrastructure ---- -// Demo component CSS uses url() paths that assumed inline injection (resolved -// against the document URL). Under AOT they resolve against the CSS file location -// and fall one dir short, so we symlink the asset at the "wrong" location. +// Under AOT, component CSS url() paths resolve one dir short of where they used +// to (against the CSS file, not the document) — symlink the asset at the wrong path. const ASSET_EXT_RE = /\.(png|jpe?g|gif|svg|webp|ico|avif)(\?[^)'"\s]*)?$/i; const URL_RE = /url\(\s*(['"]?)([^)'"]+?)\1\s*\)/g; @@ -215,7 +202,6 @@ function discoverComponentStyleFiles(tsFiles) { return Array.from(result); } -// Build a deduplicated list of (wrongPath -> realPath) asset shims across all demos. function computeGlobalShims(allCssFiles) { const seen = new Map(); // wrongPath -> { rescued } for (const cssFile of allCssFiles) { @@ -273,20 +259,14 @@ function removeShims(installed) { } } -// ---- SystemJS-style templateUrl / styleUrls patcher ---- -// Demo components use SystemJS-era paths (`.${modulePrefix}/.html`) that -// resolve wrong under AOT. The real resource is always a sibling of the .ts with -// the same basename, so rewrite to `./.` and feed the patched copy -// (written next to the original so its relative imports still resolve) via -// fileReplacements. +// Demo components use SystemJS-era templateUrl/styleUrls paths that resolve wrong +// under AOT; patch to `./.` and feed the copy via fileReplacements. const PATCHED_TS_PREFIX = '.csp-bundle-angular-patched.'; const TEMPLATE_URL_RE = /templateUrl\s*:\s*([`'"])([^`'"]+)\1/g; const STYLE_URLS_INLINE_RE = /styleUrls\s*:\s*\[\s*([`'"])([^`'"]+)\1\s*\]/g; const allPatchedTsFiles = new Set(); function aotRelativeFor(tsFile, originalSpec) { - // The original spec embeds the SystemJS `${modulePrefix}` plus the dir name. - // The real resource is a sibling of the .ts with the same basename. const ext = path.extname(originalSpec); if (!ext) return null; const tsBase = path.basename(tsFile, '.ts'); @@ -341,8 +321,7 @@ function sweepStalePatchedTsFiles(rootDir) { } } -// Collect every .ts file under the demo's app/ subtree (all need @ts-nocheck), -// excluding patch siblings so re-runs are idempotent. +// Excludes patch siblings so re-runs stay idempotent. function findDemoTsFiles(rootDir) { const out = []; function walk(dir) { @@ -363,7 +342,6 @@ function findDemoTsFiles(rootDir) { return out; } -// Map the bare `anti-forgery` specifier as the SystemJS config does. const ANTI_FORGERY_PATH = path.join(DEMOS_APP_ROOT, 'shared', 'anti-forgery', 'fetch-override.js'); const antiForgeryPlugin = { name: 'csp-bundle-angular:anti-forgery', @@ -372,15 +350,12 @@ const antiForgeryPlugin = { }, }; -// ---- single @angular copy ---- -// The pnpm store holds several @angular/core versions, so an importer-relative -// resolve can bundle two — two DI systems (NG0203/NG05100/NG0300). Resolve every -// @angular/* from a single base (apps/demos) so the bundle shares one copy. +// Resolves every @angular/* from a single base so the bundle shares one copy — +// otherwise two DI systems can end up bundled (NG0203/NG05100/NG0300). const angularSingleCopyPlugin = { name: 'csp-bundle-angular:single-angular-copy', setup(build) { build.onResolve({ filter: /^@angular\// }, async (args) => { - // Re-entry guard: our own build.resolve() below re-triggers this hook. if (args.pluginData && args.pluginData.ngDeduped) return null; const resolved = await build.resolve(args.path, { kind: args.kind, @@ -402,9 +377,7 @@ function isFileCached(filePath) { return fileExistsCache.get(filePath); } -// ---- devextreme path redirect plugin ---- -// apps/demos/node_modules/devextreme only ships bundles/; redirect to the real CJS -// modules under packages/devextreme/artifacts, as the SystemJS dev config does. +// apps/demos/node_modules/devextreme only ships bundles/; redirect to the real CJS modules. const DEVEXTREME_CJS_ROOT = path.join( REPO_ROOT, 'packages', 'devextreme', 'artifacts', 'transpiled-esm-npm', 'cjs', ); @@ -413,7 +386,6 @@ const devextremeRedirectPlugin = { setup(build) { build.onResolve({ filter: /^devextreme(\/.*)?$/ }, (args) => { const sub = args.path === 'devextreme' ? '' : args.path.slice('devextreme/'.length); - // Try the path as-is first (explicit extensions), then implicit .js/index.js/.mjs. const candidates = sub ? [ path.join(DEVEXTREME_CJS_ROOT, sub), @@ -432,8 +404,8 @@ const devextremeRedirectPlugin = { }, }; -// Re-resolve snake_case devextreme-angular/ui/* imports (e.g. html_editor) to the -// kebab-case form the npm dist actually ships. +// Re-resolves snake_case devextreme-angular/ui/* imports (e.g. html_editor) to +// the kebab-case form the npm dist actually ships. const devextremeAngularSnakeCasePlugin = { name: 'csp-bundle-angular:devextreme-angular-snake-case', setup(build) { @@ -452,8 +424,7 @@ const devextremeAngularSnakeCasePlugin = { }, }; -// Redirect devextreme-dist/js/* (VectorMap data) to the real files under -// packages/devextreme/artifacts/js — the dist package is near-empty. +// devextreme-dist/js is near-empty — redirect VectorMap data to the real artifacts/js files. const DEVEXTREME_ARTIFACTS_JS = path.join(REPO_ROOT, 'packages', 'devextreme', 'artifacts', 'js'); const devextremeDistRedirectPlugin = { name: 'csp-bundle-angular:devextreme-dist-redirect', @@ -542,8 +513,6 @@ function makeCompilerPlugin(createCompilerPlugin, tsconfig, fileReplacements) { } function prepareDemo(demo) { - // Patch every .ts in the demo and feed the copies via fileReplacements; point - // the entry (which bypasses resolveModuleNames) at its patched copy directly. const fileReplacements = {}; for (const tsFile of findDemoTsFiles(path.join(demo.srcDir, 'app'))) { const patched = patchComponentTs(tsFile); @@ -591,10 +560,7 @@ function makeBuildOptions({ target: 'es2022', mainFields: ['es2020', 'es2015', 'browser', 'module', 'main'], conditions: ['es2020', 'es2015', 'module'], - // Resolve bare `globalize` to the core build (as the dev config and the - // React/Vue bundler do). Its package main (node-main.js) eagerly requires - // globalize/plural, which demands plurals-type-cardinal CLDR data the demos - // don't load (E_MISSING_CLDR). + // globalize's package main eagerly requires plural CLDR data the demos don't load (E_MISSING_CLDR). alias: { globalize: path.join(NODE_MODULES, 'globalize', 'dist', 'globalize.js'), }, @@ -614,6 +580,7 @@ function makeBuildOptions({ logLevel: 'silent', metafile: true, plugins: [ + vendorGlobalPlugin('Angular'), angularSingleCopyPlugin, antiForgeryPlugin, systemJsQuirksPlugin, @@ -649,9 +616,7 @@ async function bundleDemo(demo, createCompilerPlugin, destDirOverride) { return { ok: false, reason: (err && err.message) || String(err) }; } - // The Angular AOT compiler plugin can list a component style output in the - // metafile that it ends up inlining instead of actually emitting (no file on - // disk) — filter to outputs that are really there before linking them. + // The AOT compiler plugin can list a style output it actually inlined instead of emitting. const outputs = Object.keys((result.metafile && result.metafile.outputs) || {}) .filter((o) => fs.existsSync(path.resolve(DEMOS_APP_ROOT, o))); const localJsFiles = sortJsFiles(outputs.filter((o) => o.endsWith('.js')).map((o) => path.basename(o))); @@ -732,9 +697,7 @@ async function main() { if (FILTER) console.log(`Filter: ${FILTER}`); console.log(''); - // Wipe previous Angular output; leave React/Vue subtrees alone. In-place - // mode writes into the demo's own source folder, which must NOT be wiped - // (that would delete the demo's actual source, not just old output). + // IN_PLACE writes into the demo's own source folder, which must not be wiped. if (!IN_PLACE && fs.existsSync(OUT_ROOT)) { const existingWidgets = fs.readdirSync(OUT_ROOT, { withFileTypes: true }).filter((w) => w.isDirectory()); for (const widget of existingWidgets) { @@ -854,11 +817,6 @@ if (require.main === module) { }); } -// The exports below (beyond `main`) exist so utils/build/build-angular-demo.js -// can drive a single, in-place demo build (e.g. for the dev server's -// lazy-build-on-request path) without duplicating this file's AOT/asset-shim -// logic — they're the same internals `main()` uses, just with `destDir` -// overridable per call instead of hardcoded to OUT_ROOT. module.exports = { main, resolveAngularBuildPrivate, @@ -872,4 +830,12 @@ module.exports = { bundleDemo, buildHtml, ANGULAR_ZONE_SCRIPT, + angularSingleCopyPlugin, + antiForgeryPlugin, + systemJsQuirksPlugin, + devextremeAngularSnakeCasePlugin, + devextremeDistRedirectPlugin, + devextremeRedirectPlugin, + NODE_MODULES, + DEMOS_APP_ROOT, }; diff --git a/apps/demos/utils/server/csp-bundle.js b/apps/demos/utils/server/csp-bundle.js index 9e2c27af40e2..8c7ac3b77957 100644 --- a/apps/demos/utils/server/csp-bundle.js +++ b/apps/demos/utils/server/csp-bundle.js @@ -1,7 +1,4 @@ -// Pre-bundles React/Vue demos with esbuild for the CSP check, so it validates the -// production CSP profile instead of the SystemJS dev loader's relaxations. -// Output: apps/demos/csp-bundled-demos////index.html. -// Angular is delegated to csp-bundle-angular.js. +// Bundles React/Vue demos with esbuild. Angular is delegated to csp-bundle-angular.js. const path = require('path'); const fs = require('fs'); @@ -12,9 +9,6 @@ const { extractDemoHeadExtras, extractDemoBodyInner } = require('./demo-html'); const FRAMEWORK_ARG = (process.argv.find((a) => a.startsWith('--framework=')) || '').split('=')[1]; const FRAMEWORK = FRAMEWORK_ARG || process.env.CSP_FRAMEWORKS || 'React'; -// ReactJs is the generated JavaScript twin of each React demo (see -// utils/ts-to-js-converter) — same JSX sources with .js extensions, which the -// shared '.js': 'jsx' loader already covers, so it needs no separate handling. const SUPPORTED = ['React', 'ReactJs', 'Vue', 'Angular']; if (!SUPPORTED.includes(FRAMEWORK)) { console.log(`csp-bundle: framework ${FRAMEWORK} is not supported (only ${SUPPORTED.join(', ')}). Nothing to do.`); @@ -23,9 +17,6 @@ if (!SUPPORTED.includes(FRAMEWORK)) { const IS_ANGULAR = FRAMEWORK === 'Angular'; -// Write bundle.js/bundle.css next to each demo's own source (the real, only -// demo tree) instead of into the csp-bundled-demos/ side directory used by the -// CSP-only check. This is what CI's demo-build step uses in production. const IN_PLACE = process.env.BUNDLE_IN_PLACE === '1'; const DEMOS_APP_ROOT = path.join(__dirname, '..', '..'); @@ -39,8 +30,6 @@ const CONCURRENCY = (() => { return Math.max(4, (os.cpus() || []).length - 1); })(); -// Optional round-robin sharding across parallel CI jobs (CSP_SHARD_TOTAL / -// CSP_SHARD_INDEX, 1-based). const SHARD_TOTAL = Math.max(1, parseInt(process.env.CSP_SHARD_TOTAL, 10) || 1); const SHARD_INDEX = (() => { const n = parseInt(process.env.CSP_SHARD_INDEX, 10); @@ -59,7 +48,6 @@ function findDemos() { const out = []; if (!fs.existsSync(SRC_DEMOS_DIR)) return out; - // Optional substring filter for local smoke tests: CSP_BUNDLE_FILTER=Button/Icons const filter = (process.env.CSP_BUNDLE_FILTER || '').trim(); const widgets = fs.readdirSync(SRC_DEMOS_DIR, { withFileTypes: true }) @@ -87,7 +75,6 @@ function findEntry(srcDir) { return null; } -// Treat imports of missing .css files (carried over from SystemJS configs) as empty. const ignoreMissingCssPlugin = { name: 'csp-bundle:ignore-missing-css', setup(build) { @@ -105,8 +92,6 @@ const ignoreMissingCssPlugin = { }, }; -// Rewrite SystemJS-specific import specifiers (npm:, !json, -// anti-forgery, globalize/) so esbuild resolves them like the dev loader. const ANTI_FORGERY_PATH = path.join(DEMOS_APP_ROOT, 'shared', 'anti-forgery', 'fetch-override.js'); const GLOBALIZE_BASE = path.join(NODE_MODULES, 'globalize', 'dist', 'globalize'); @@ -122,7 +107,6 @@ const systemJsQuirksPlugin = { return null; }); - // `npm:foo/bar` -> `foo/bar`; trailing `!json` -> stripped, JSON loader forced. build.onResolve({ filter: /(^npm:)|(!json$)/ }, async (args) => { let spec = args.path; const forceJson = spec.endsWith('!json'); @@ -150,16 +134,14 @@ const systemJsQuirksPlugin = { }, }; -// Force a single devextreme copy: collapse resolved cjs paths to their esm twin. -// Mixing both (esm via devextreme-react, cjs via aspnet-data require) bundles two -// copies of the Class/callBase system and infinitely recurses in the data path. +// Collapse resolved cjs devextreme paths to their esm twin — mixing both copies +// the Class/callBase system and infinitely recurses in the data path. const DX_CJS_SEG = `${path.sep}devextreme${path.sep}cjs${path.sep}`; const DX_ESM_SEG = `${path.sep}devextreme${path.sep}esm${path.sep}`; const devextremeDedupePlugin = { name: 'csp-bundle:devextreme-single-copy', setup(build) { build.onResolve({ filter: /^devextreme(\/.*)?$/ }, async (args) => { - // Re-entry guard: our own build.resolve() below re-triggers this hook. if (args.pluginData && args.pluginData.dxDeduped) return null; const resolved = await build.resolve(args.path, { kind: args.kind, @@ -179,11 +161,6 @@ const devextremeDedupePlugin = { }, }; -// Vue's esbuild plugin is safe to reuse across builds, so it's memoized per -// process rather than tied to the CLI's single module-level FRAMEWORK — lets -// getSharedOptions() be called for either framework from a long-lived process -// (e.g. the dev server's lazy build-on-request path), not just this CLI's -// single-framework-per-run invocation. let vuePluginInstance = null; function getVuePlugin() { if (!vuePluginInstance) { @@ -196,6 +173,8 @@ function getVuePlugin() { } function getSharedOptions(framework) { + // eslint-disable-next-line global-require + const { vendorGlobalPlugin } = require('./vendor-bundle'); return { bundle: true, minify: false, @@ -211,7 +190,6 @@ function getSharedOptions(framework) { alias: { react: path.join(NODE_MODULES, 'react'), 'react-dom': path.join(NODE_MODULES, 'react-dom'), - // Alias bare 'globalize' to the browser build, as the SystemJS configs do. globalize: path.join(NODE_MODULES, 'globalize', 'dist', 'globalize.js'), }, define: { @@ -221,6 +199,7 @@ function getSharedOptions(framework) { }, logLevel: 'silent', plugins: [ + vendorGlobalPlugin(framework), devextremeDedupePlugin, systemJsQuirksPlugin, ignoreMissingCssPlugin, @@ -229,15 +208,13 @@ function getSharedOptions(framework) { }; } -// Reuse the dev markup (minus its SystemJS + ${vendorTag ? `${vendorTag}\n ` : ''} `; } -// Core esbuild step, parameterized by destination + framework so it can be -// reused both for the CSP-check output (below) and for an in-place build that -// writes bundle.js next to the demo's own source (see utils/build/). async function bundleDemoTo({ srcDir, destDir, framework }) { const entry = findEntry(srcDir); if (!entry) return { ok: false, reason: 'no entry point (index.tsx|ts|jsx|js)' }; @@ -292,8 +270,6 @@ async function bundleDemoTo({ srcDir, destDir, framework }) { if (jsFiles.length === 0) return { ok: false, reason: 'no JS output produced' }; - // Standalone styles.css that the dev demo references separately (React) — - // normalized to bundle.css too, so callers only ever deal with one CSS name. const stylesSrc = path.join(srcDir, 'styles.css'); if (cssFiles.length === 0 && fs.existsSync(stylesSrc)) { const dest = path.join(destDir, 'bundle.css'); @@ -311,7 +287,9 @@ async function bundleDemo(demo, { destDir: destDirOverride, framework = FRAMEWOR fs.writeFileSync( path.join(destDir, 'index.html'), - buildHtml({ jsFile: result.jsFiles[0], cssFiles: result.cssFiles, srcDir: demo.srcDir }), + buildHtml({ + jsFile: result.jsFiles[0], cssFiles: result.cssFiles, srcDir: demo.srcDir, framework, + }), ); return { ok: true }; @@ -336,9 +314,7 @@ async function main() { console.log(`Source: ${SRC_DEMOS_DIR}`); console.log(`Output: ${OUT_ROOT}\n`); - // Wipe only this framework's previous output so per-framework runs don't clash. - // In-place mode writes into the demo's own source folder, which must NOT be - // wiped (that would delete the demo's actual source, not just old output). + // IN_PLACE writes into the demo's own source folder, which must not be wiped. if (!IN_PLACE && fs.existsSync(OUT_ROOT)) { const existingWidgets = fs.readdirSync(OUT_ROOT, { withFileTypes: true }) .filter((w) => w.isDirectory()); @@ -405,5 +381,5 @@ if (require.main === module) { } module.exports = { - bundleDemo, bundleDemoTo, findEntry, buildHtml, extractDemoBodyInner, + bundleDemo, bundleDemoTo, findEntry, buildHtml, extractDemoBodyInner, getSharedOptions, }; diff --git a/apps/demos/utils/server/csp-check.js b/apps/demos/utils/server/csp-check.js index 32e47f847f0b..cd423f8cc9e6 100644 --- a/apps/demos/utils/server/csp-check.js +++ b/apps/demos/utils/server/csp-check.js @@ -71,11 +71,8 @@ function findChrome() { const CHROME_PATH = findChrome(); -// The CI job that runs this script bundles only its own shard (csp-bundle.js -// applies the same round-robin split), so checking must use the identical -// partition. Checking a demo another shard bundled means no bundle.js on disk, -// which costs a 30s render deadline plus a retry each — enough to blow the -// job's timeout rather than fail. +// Must use the same shard partition as csp-bundle.js, or a demo bundled by +// another shard has no bundle.js on disk and burns its render deadline + retry. const SHARD_TOTAL = Math.max(1, parseInt(process.env.CSP_SHARD_TOTAL, 10) || 1); const SHARD_INDEX = (() => { const n = parseInt(process.env.CSP_SHARD_INDEX, 10); @@ -222,8 +219,7 @@ function findDemos() { return applyShard(result); } -// Wait until the DOM is quiet after load so late-rendered resources fire their -// CSP violations before we snapshot, bounded by SETTLE_MAX_MS. +// Waits until the DOM is quiet so late-rendered resources fire their CSP violations first. function waitForDomIdle(tab) { return tab.send('Runtime.evaluate', { awaitPromise: true, diff --git a/apps/demos/utils/server/csp-server.js b/apps/demos/utils/server/csp-server.js index c9acc399d90b..5589d8686c0f 100644 --- a/apps/demos/utils/server/csp-server.js +++ b/apps/demos/utils/server/csp-server.js @@ -244,8 +244,7 @@ function buildCspHeader(demoKey, nonce, framework) { directives[key] = [...values]; } - // Angular stamps this nonce (via ngCspNonce) on the