Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/visual-tests-demos.yml
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,6 @@ jobs:
devextreme-react-installer.tgz
devextreme-vue-installer.tgz
retention-days: 1

build-vendor-bundles:
name: Build vendor bundles (React, Vue)
runs-on: devextreme-shr2
Expand Down
5 changes: 1 addition & 4 deletions apps/demos/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,9 @@ shared/empty-file.js
Demos/**/config.js
Demos/**/tsconfig.json

# esbuild in-place demo build output (see utils/build/) — generated next to
# each demo's own source, same convention as Demos/**/config.js above.
Demos/**/bundle.js
Demos/**/bundle.css
# esbuild's own code-splitting output for batched Angular demo builds (see
# csp-bundle-angular.js's makeBuildOptions) — shared chunks, not owned by any one demo.
Demos/**/demo.manifest.json
Demos/_chunks

.DS_Store
Expand Down
4 changes: 3 additions & 1 deletion apps/demos/utils/server/build-vendor-bundles.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// CLI: node utils/server/build-vendor-bundles.js [React|Vue ...]
// Defaults to both. Angular isn't supported here — see csp-bundle-angular.js's
// `splitting: true` batch build instead.
// `splitting: true` batch build instead (a standalone vendor bundle conflicts with the
// per-demo Angular compiler plugin's own need to read every file's real source; see git
// history / project memory for the investigation).
Comment thread
vorobey marked this conversation as resolved.
Outdated

const path = require('path');
const fs = require('fs');
Expand Down
22 changes: 22 additions & 0 deletions apps/demos/utils/server/csp-bundle-angular.js
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,26 @@ function prepareDemo(demo) {
return { ...demo, effectiveEntry, fileReplacements };
}

// No Angular demo imports either: zone.js arrives via ANGULAR_ZONE_SCRIPT's <script> tag
// (and is an optional peer, so the peer walk skips it), and reflect-metadata is what makes
// constructor injection resolve for a consumer that compiles these demos in JIT mode.
const ANGULAR_IMPLICIT_PACKAGES = ['zone.js', 'reflect-metadata'];
Comment thread
vorobey marked this conversation as resolved.
Outdated

// CodeSandbox-facing manifest of every npm package a demo needs — its own imports plus the
// peers those pull in. See utils/server/vendor-bundle.js for how each is resolved.
function writeDemoManifest(demo, destDir) {
// eslint-disable-next-line global-require
const { discoverDemoSpecifiers, resolvePackageVersions } = require('./vendor-bundle');
const packages = resolvePackageVersions([
...discoverDemoSpecifiers(demo.srcDir),
...ANGULAR_IMPLICIT_PACKAGES,
]);
fs.writeFileSync(
path.join(destDir, 'demo.manifest.json'),
JSON.stringify({ framework: FRAMEWORK, packages }, null, 2),
);
}

function sortJsFiles(jsFiles) {
return jsFiles.sort((a, b) => {
if (a === 'polyfills.js') return -1;
Expand Down Expand Up @@ -638,6 +658,7 @@ async function bundleDemo(demo, createCompilerPlugin, destDirOverride) {
const cssFiles = outputs.filter((o) => o.endsWith('.css')).map((o) => path.basename(o));

fs.writeFileSync(path.join(destDir, 'index.html'), buildHtml({ jsFiles, cssFiles, srcDir: prepared.srcDir }));
writeDemoManifest(prepared, destDir);
return { ok: true };
}

Expand Down Expand Up @@ -676,6 +697,7 @@ async function bundleDemoBatch(batch, createCompilerPlugin) {
...localJsFiles,
];
fs.writeFileSync(path.join(destDir, 'index.html'), buildHtml({ jsFiles, cssFiles, srcDir: demo.srcDir }));
writeDemoManifest(demo, destDir);
}

return { ok: true };
Expand Down
17 changes: 17 additions & 0 deletions apps/demos/utils/server/csp-bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,21 @@ function buildHtml({
`;
}

// CodeSandbox-facing manifest of every npm package this one demo needs: what it imports
// itself (devextreme-react, jspdf, openai, …) plus the peers those pull in. Deliberately
// not the shared vendor manifest, which lists the union over every demo of the framework.
function writeDemoManifest({
srcDir, destDir, framework,
}) {
// eslint-disable-next-line global-require
const { discoverDemoSpecifiers, resolvePackageVersions } = require('./vendor-bundle');
const packages = resolvePackageVersions(discoverDemoSpecifiers(srcDir));
Comment thread
vorobey marked this conversation as resolved.
Outdated
fs.writeFileSync(
path.join(destDir, 'demo.manifest.json'),
JSON.stringify({ framework, packages }, null, 2),
);
}

async function bundleDemoTo({ srcDir, destDir, framework }) {
const entry = findEntry(srcDir);
if (!entry) return { ok: false, reason: 'no entry point (index.tsx|ts|jsx|js)' };
Expand Down Expand Up @@ -277,6 +292,8 @@ async function bundleDemoTo({ srcDir, destDir, framework }) {
cssFiles.push('bundle.css');
}

writeDemoManifest({ srcDir, destDir, framework });

return { ok: true, jsFiles, cssFiles };
}

Expand Down
147 changes: 125 additions & 22 deletions apps/demos/utils/server/vendor-bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,28 +36,48 @@ function isVendorSpecifier(spec, framework) {
return (VENDOR_PREFIXES[framework] || []).some((re) => re.test(spec));
}

// Substring match for the coverage-map check in vendorGlobalPlugin — a resolved chunk
// file path won't match VENDOR_PREFIXES' bare-specifier regexes but still contains this.
const VENDOR_KEYWORDS = {
React: ['devextreme-react', 'devextreme', 'react-dom', 'react', 'globalize'],
ReactJs: ['devextreme-react', 'devextreme', 'react-dom', 'react', 'globalize'],
Vue: ['devextreme-vue', 'devextreme', 'vue', 'globalize'],
};

const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.vue']);
const IMPORT_SPECIFIER_RE = /(?:import|export)(?:[^'";]*?from)?\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)|import\(\s*['"]([^'"]+)['"]\s*\)/g;
const IMPORT_SPECIFIER_RE = /(?:^|\r?\n)\s*(?:import|export)(?:[^'";]*?from)?\s*['"]([^'"]+)['"]|require\(\s*['"]([^'"]+)['"]\s*\)|import\(\s*['"]([^'"]+)['"]\s*\)/g;

const GENERATED_DIR_NAMES = new Set(['_chunks']);
const GENERATED_FILE_RE = /^bundle(\.[0-9a-f]+)?\.(js|css)$|^\.csp-bundle-angular-patched\./;

function walk(dir, out) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (GENERATED_DIR_NAMES.has(entry.name)) continue; // eslint-disable-line no-continue
walk(full, out);
} else if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
} else if (SOURCE_EXTENSIONS.has(path.extname(entry.name)) && !GENERATED_FILE_RE.test(entry.name)) {
out.push(full);
}
}
}

function collectSpecifiersFromDir(dir, predicate) {
const found = new Set();
if (!fs.existsSync(dir)) return found;
const files = [];
walk(dir, files);
for (const file of files) {
const content = fs.readFileSync(file, 'utf8');
IMPORT_SPECIFIER_RE.lastIndex = 0;
let m;
// eslint-disable-next-line no-cond-assign
while ((m = IMPORT_SPECIFIER_RE.exec(content))) {
const spec = m[1] || m[2] || m[3];
if (spec && predicate(spec)) found.add(spec);
}
}
return found;
}

function discoverSpecifiers(framework) {
const found = new Set();
if (!fs.existsSync(SRC_DEMOS_DIR)) return found;
Expand All @@ -72,23 +92,109 @@ function discoverSpecifiers(framework) {
// eslint-disable-next-line no-continue
continue;
}
const files = [];
walk(fwDir, files);
for (const file of files) {
const content = fs.readFileSync(file, 'utf8');
IMPORT_SPECIFIER_RE.lastIndex = 0;
let m;
// eslint-disable-next-line no-cond-assign
while ((m = IMPORT_SPECIFIER_RE.exec(content))) {
const spec = m[1] || m[2] || m[3];
if (spec && isVendorSpecifier(spec, framework)) found.add(spec);
}
}
collectSpecifiersFromDir(fwDir, (spec) => isVendorSpecifier(spec, framework))
.forEach((spec) => found.add(spec));
}
}
return found;
}

const NON_PACKAGE_SPECIFIERS = new Set(['anti-forgery']);

function discoverDemoSpecifiers(demoFrameworkDir) {
return collectSpecifiersFromDir(
demoFrameworkDir,
(spec) => !spec.startsWith('.') && !NON_PACKAGE_SPECIFIERS.has(spec),
);
}

function packageNameForSpecifier(spec) {
let s = spec;
if (s.endsWith('!json')) s = s.slice(0, -'!json'.length);
if (s.startsWith('npm:')) s = s.slice('npm:'.length);
if (s.startsWith('@')) return s.split('/').slice(0, 2).join('/');
return s.split('/')[0];
}

function nearestPackageJson(startFile) {
let dir = path.dirname(startFile);
for (;;) {
const candidate = path.join(dir, 'package.json');
if (fs.existsSync(candidate)) return candidate;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}

function readJson(pkgJsonPath) {
return JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
}

// The fallback path is for packages whose `exports` map doesn't expose ./package.json.
const packageJsonCache = new Map();
function readPackageJson(pkgName, resolveDir) {
const cacheKey = `${resolveDir}::${pkgName}`;
if (packageJsonCache.has(cacheKey)) return packageJsonCache.get(cacheKey);

let pkg = null;
try {
pkg = readJson(require.resolve(`${pkgName}/package.json`, { paths: [resolveDir] }));
} catch {
try {
const pkgJsonPath = nearestPackageJson(require.resolve(pkgName, { paths: [resolveDir] }));
if (pkgJsonPath) pkg = readJson(pkgJsonPath);
} catch {
pkg = null;
}
}
packageJsonCache.set(cacheKey, pkg);
return pkg;
}

function resolvePackageVersion(pkgName, resolveDir) {
const pkg = readPackageJson(pkgName, resolveDir);
return (pkg && pkg.version) || null;
}

// Import scanning can't find a peer dependency — nothing imports one — yet `devextreme` is
// a peer of all three wrappers. Resolved to installed versions because the declared ranges
// read `workspace:*`. Optional peers are skipped; callers add the ones they need.
function addRequiredPeers(packages, resolveDir) {
const queue = Object.keys(packages);
const seen = new Set(queue);

while (queue.length > 0) {
const pkg = readPackageJson(queue.shift(), resolveDir);
const optional = (pkg && pkg.peerDependenciesMeta) || {};
for (const peer of Object.keys((pkg && pkg.peerDependencies) || {})) {
if (!seen.has(peer) && !(optional[peer] && optional[peer].optional)) {
seen.add(peer);
queue.push(peer);
const version = resolvePackageVersion(peer, resolveDir);
if (version) packages[peer] = version;
}
}
}

return packages;
}

function resolvePackageVersions(specifiers, resolveDir = DEMOS_APP_ROOT) {
const packages = {};
for (const spec of specifiers) {
const pkgName = packageNameForSpecifier(spec);
if (Object.prototype.hasOwnProperty.call(packages, pkgName)) continue; // eslint-disable-line no-continue
const version = resolvePackageVersion(pkgName, resolveDir);
if (version) {
packages[pkgName] = version;
} else {
console.warn(`vendor-bundle: could not resolve a version for package "${pkgName}" (from specifier "${spec}")`);
}
}
return addRequiredPeers(packages, resolveDir);
}

function safeName(spec) {
return spec.replace(/[^a-zA-Z0-9_]/g, '_');
}
Expand All @@ -97,8 +203,6 @@ function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// Narrow on purpose: esbuild only round-trips to JS for paths matching this filter,
// so a broad filter here pays that cost for every resolution in the bundle.
function specifierFilter(specifiers) {
return new RegExp(`^(?:${specifiers.map(escapeRegExp).join('|')})$`);
}
Expand All @@ -107,10 +211,6 @@ function keywordFilter(keywords) {
return new RegExp(keywords.map(escapeRegExp).join('|'));
}

// Every absolute file path reachable from the entry file, per the vendor build's own metafile.
// Each gets its own export slot in the final bundle (see buildVendorBundle) rather than being
// redirected to whichever specifier's barrel transitively reaches it — a barrel only re-exports
// its own public surface, not everything it privately imports.
function computeClosure(metafile, entryFile, buildCwd) {
if (!metafile || !entryFile) return [];
const toAbs = (p) => (path.isAbsolute(p) ? p : path.resolve(buildCwd, p));
Expand Down Expand Up @@ -234,6 +334,7 @@ async function buildVendorBundle(framework, esbuildOptions = {}) {
globalVar: globalVarName(framework),
file: path.basename(vendorFilePath(framework)),
coverage,
packages: resolvePackageVersions(specifiers),
};
fs.writeFileSync(manifestPath(framework), JSON.stringify(manifest, null, 2));
manifestCache.set(framework, manifest);
Expand Down Expand Up @@ -313,6 +414,8 @@ function vendorScriptTag(framework) {

module.exports = {
discoverSpecifiers,
discoverDemoSpecifiers,
resolvePackageVersions,
buildVendorBundle,
getVendorManifest,
invalidateVendorManifestCache,
Expand Down
Loading