feat: Generate app/extensions/index.js with new format and cleanup

- Changed extension loader generation to create app/extensions/index.js
- New format uses direct imports instead of dynamic imports
- Added getExtensionLoader helper function
- Removed unused plugins directory
- Removed old extension-loaders.generated.js file
- Cleaner and simpler loader structure
This commit is contained in:
roncodes
2025-11-27 05:48:54 -05:00
parent 9a053cfd9f
commit 93b7224335
6 changed files with 17 additions and 613 deletions

View File

@@ -1,14 +0,0 @@
/**
* AUTO-GENERATED FILE - DO NOT EDIT
* Generated by prebuild.js
*
* This file provides a build-time map of extension loaders to avoid
* dynamic require() calls which are incompatible with Embroider.
*
* Each loader is a function that returns a dynamic import() of the
* local app-level shim module (not the addon module directly).
*/
export const EXTENSION_LOADERS = {
'@fleetbase/fleetops-engine': () => import('../extensions/fleetops'),
};

View File

@@ -136,12 +136,13 @@ module.exports = {
},
generateExtensionLoaders(extensions) {
const utilsDir = path.join(this.project.root, 'app', 'utils');
const extensionsDir = path.join(this.project.root, 'app', 'extensions');
if (!fs.existsSync(utilsDir)) {
fs.mkdirSync(utilsDir, { recursive: true });
if (!fs.existsSync(extensionsDir)) {
fs.mkdirSync(extensionsDir, { recursive: true });
}
const imports = [];
const loaders = {};
extensions.forEach((extension) => {
@@ -152,22 +153,30 @@ module.exports = {
}
const mountPath = extension.fleetbase?.route || this.getExtensionMountPath(extension.name);
loaders[extension.name] = `() => import('@fleetbase/console/extensions/${mountPath}')`;
const camelCaseName = mountPath.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
imports.push(`import ${camelCaseName} from './${mountPath}';`);
loaders[extension.name] = `() => ${camelCaseName}`;
});
const loadersContent = `// Auto-generated extension loaders
const loadersContent = `${imports.join('\n')}
export const EXTENSION_LOADERS = {
${Object.entries(loaders)
.map(([name, loader]) => ` '${name}': ${loader}`)
.join(',\n')}
};
export default EXTENSION_LOADERS;
export const getExtensionLoader = (packageName) => {
return EXTENSION_LOADERS[packageName];
};
export default getExtensionLoader;
`;
const loadersFile = path.join(utilsDir, 'extension-loaders.generated.js');
const loadersFile = path.join(extensionsDir, 'index.js');
fs.writeFileSync(loadersFile, loadersContent, 'utf8');
console.log('[fleetbase-extensions-generator] ✓ Generated app/utils/extension-loaders.generated.js');
console.log('[fleetbase-extensions-generator] ✓ Generated app/extensions/index.js');
},
generateRouter(extensions) {

View File

@@ -1,127 +0,0 @@
const Plugin = require('broccoli-plugin');
const fs = require('fs');
const path = require('path');
const fg = require('fast-glob');
/**
* ExtensionDiscoveryPlugin
*
* Discovers Fleetbase extensions from node_modules by scanning package.json files
* for packages with 'fleetbase-extension' and 'ember-engine' keywords.
*
* This plugin runs during build and caches the discovered extensions for use
* by other plugins (router generation, extension loaders, etc.)
*/
class ExtensionDiscoveryPlugin extends Plugin {
constructor(inputNodes, options = {}) {
super(inputNodes, {
annotation: options.annotation || 'ExtensionDiscoveryPlugin',
persistentOutput: true,
});
this.projectRoot = options.projectRoot || process.cwd();
}
/**
* Get only specific properties from an object
*/
only(subject, props = []) {
const keys = Object.keys(subject);
const result = {};
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (props.includes(key)) {
result[key] = subject[key];
}
}
return result;
}
/**
* Discover all Fleetbase extensions from node_modules
*/
async discoverExtensions() {
const extensions = [];
const seenPackages = new Set();
const results = await fg([
'node_modules/*/package.json',
'node_modules/*/*/package.json'
], {
cwd: this.projectRoot,
absolute: true
});
for (const packagePath of results) {
let packageData = null;
try {
const packageJson = fs.readFileSync(packagePath, 'utf8');
packageData = JSON.parse(packageJson);
} catch (e) {
console.warn(`[ExtensionDiscovery] Could not parse package.json at ${packagePath}:`, e.message);
continue;
}
// Check if this is a Fleetbase extension
if (!packageData ||
!packageData.keywords ||
!packageData.keywords.includes('fleetbase-extension') ||
!packageData.keywords.includes('ember-engine')) {
continue;
}
// Skip duplicates
if (seenPackages.has(packageData.name)) {
continue;
}
seenPackages.add(packageData.name);
// Extract relevant metadata
const extension = this.only(packageData, [
'name',
'description',
'version',
'fleetbase',
'keywords',
'license',
'repository'
]);
extensions.push(extension);
}
return extensions;
}
async build() {
console.log('[ExtensionDiscovery] ========================================');
console.log('[ExtensionDiscovery] Starting extension discovery...');
console.log('[ExtensionDiscovery] Project root:', this.projectRoot);
console.log('[ExtensionDiscovery] Output path:', this.outputPath);
const extensions = await this.discoverExtensions();
// Write extensions to cache file
const cacheFile = path.join(this.outputPath, 'extensions.json');
fs.writeFileSync(
cacheFile,
JSON.stringify(extensions, null, 2),
'utf8'
);
console.log('[ExtensionDiscovery] ========================================');
console.log('[ExtensionDiscovery] ✓ Discovery complete');
console.log('[ExtensionDiscovery] Found', extensions.length, 'extension(s):');
extensions.forEach(ext => {
console.log('[ExtensionDiscovery] -', ext.name + '@' + ext.version);
});
console.log('[ExtensionDiscovery] Wrote extensions.json to:', cacheFile);
console.log('[ExtensionDiscovery] ========================================');
}
}
module.exports = ExtensionDiscoveryPlugin;

View File

@@ -1,126 +0,0 @@
const Plugin = require('broccoli-plugin');
const fs = require('fs');
const path = require('path');
/**
* ExtensionLoadersGeneratorPlugin
*
* Generates app/utils/extension-loaders.generated.js with a map of extension names
* to dynamic import functions.
*
* Output example:
* export const EXTENSION_LOADERS = {
* '@fleetbase/fleetops-engine': () => import('../extensions/fleetops'),
* };
*
* This file is imported by the ExtensionManager service to lazy-load extension setup code.
*/
class ExtensionLoadersGeneratorPlugin extends Plugin {
constructor(inputNodes, options = {}) {
super(inputNodes, {
annotation: options.annotation || 'ExtensionLoadersGeneratorPlugin',
persistentOutput: true,
});
this.projectRoot = options.projectRoot || process.cwd();
}
/**
* Get the mount path for an extension
*/
getExtensionMountPath(extensionName) {
const segments = extensionName.split('/');
let mountName = segments[1] || segments[0];
return mountName.replace('-engine', '');
}
/**
* Check if an extension has an addon/extension.js file
*/
hasExtensionFile(extensionName) {
const extensionPath = path.join(
this.projectRoot,
'node_modules',
extensionName,
'addon',
'extension.js'
);
return fs.existsSync(extensionPath);
}
/**
* Generate the extension-loaders.generated.js content
*/
generateLoadersContent(extensions) {
const lines = [
'/**',
' * AUTO-GENERATED FILE - DO NOT EDIT',
' * Generated by ExtensionLoadersGeneratorPlugin',
' *',
' * This file contains a map of extension names to dynamic import functions.',
' * The ExtensionManager service uses this to lazy-load extension setup code',
' * without loading the entire engine bundle.',
' */',
'',
'export const EXTENSION_LOADERS = {',
];
let loaderCount = 0;
for (const extension of extensions) {
const pkgName = extension.name;
if (!this.hasExtensionFile(pkgName)) {
continue;
}
const mountName = this.getExtensionMountPath(pkgName);
lines.push(` '${pkgName}': () => import('../extensions/${mountName}'),`);
loaderCount++;
}
lines.push('};');
lines.push('');
return {
content: lines.join('\n'),
count: loaderCount
};
}
async build() {
console.log('[ExtensionLoadersGenerator] ========================================');
console.log('[ExtensionLoadersGenerator] Starting loaders generation...');
console.log('[ExtensionLoadersGenerator] Project root:', this.projectRoot);
console.log('[ExtensionLoadersGenerator] Output path:', this.outputPath);
// Read discovered extensions from cache
const extensionsCacheFile = path.join(this.inputPaths[0], 'extensions.json');
if (!fs.existsSync(extensionsCacheFile)) {
console.warn('[ExtensionLoadersGenerator] No extensions cache found, skipping');
return;
}
const extensions = JSON.parse(fs.readFileSync(extensionsCacheFile, 'utf8'));
// Create output directory
const outputDir = path.join(this.outputPath, 'utils');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Generate loaders file
const { content, count } = this.generateLoadersContent(extensions);
const outputPath = path.join(outputDir, 'extension-loaders.generated.js');
fs.writeFileSync(outputPath, content, 'utf8');
console.log('[ExtensionLoadersGenerator] ========================================');
console.log('[ExtensionLoadersGenerator] ✓ Loaders generation complete');
console.log('[ExtensionLoadersGenerator] Generated extension-loaders.generated.js with', count, 'loader(s)');
console.log('[ExtensionLoadersGenerator] Output file:', outputPath);
console.log('[ExtensionLoadersGenerator] ========================================');
}
}
module.exports = ExtensionLoadersGeneratorPlugin;

View File

@@ -1,123 +0,0 @@
const Plugin = require('broccoli-plugin');
const fs = require('fs');
const path = require('path');
/**
* ExtensionShimGeneratorPlugin
*
* Generates app/extensions/*.js shim files by INLINING the extension.js code
* from each engine's addon directory. This enables code splitting - the extension
* setup code can be dynamically imported without loading the entire engine bundle.
*
* This is critical for performance: extension setup runs at boot, but engines
* are lazy-loaded only when their routes are accessed.
*/
class ExtensionShimGeneratorPlugin extends Plugin {
constructor(inputNodes, options = {}) {
super(inputNodes, {
annotation: options.annotation || 'Generate Extension Shims',
persistentOutput: true,
needsCache: false
});
this.projectRoot = options.projectRoot || process.cwd();
}
async build() {
console.log('[ExtensionShimGenerator] ========================================');
console.log('[ExtensionShimGenerator] Starting shim generation...');
console.log('[ExtensionShimGenerator] Project root:', this.projectRoot);
console.log('[ExtensionShimGenerator] Output path:', this.outputPath);
const extensionsJsonPath = path.join(this.inputPaths[0], 'extensions.json');
console.log('[ExtensionShimGenerator] Reading extensions from:', extensionsJsonPath);
// Check if extensions.json exists
if (!fs.existsSync(extensionsJsonPath)) {
console.warn('[ExtensionShimGenerator] extensions.json not found, skipping');
return;
}
// Read discovered extensions
const extensions = JSON.parse(fs.readFileSync(extensionsJsonPath, 'utf8'));
// Create extensions output directory
const extensionsDir = path.join(this.outputPath, 'extensions');
if (!fs.existsSync(extensionsDir)) {
fs.mkdirSync(extensionsDir, { recursive: true });
}
let shimCount = 0;
for (const extension of extensions) {
const pkgName = extension.name;
const mountName = this.#getExtensionMountPath(pkgName);
// Path to extension.js in the engine's addon directory
const extensionPath = path.join(
this.projectRoot,
'node_modules',
pkgName,
'addon',
'extension.js'
);
// Check if extension.js exists
if (!fs.existsSync(extensionPath)) {
console.log(`[ExtensionShimGenerator] No extension.js found for ${pkgName}, skipping`);
continue;
}
// Read the extension code
let extensionCode;
try {
extensionCode = fs.readFileSync(extensionPath, 'utf8');
} catch (error) {
console.error(`[ExtensionShimGenerator] Failed to read extension.js for ${pkgName}:`, error.message);
continue;
}
// Generate shim filename (e.g., 'fleetops.js')
const shimPath = path.join(extensionsDir, `${mountName}.js`);
// INLINE the extension code instead of re-exporting
// This is critical: we copy the code so it can be bundled by ember-auto-import
// without requiring the engine to be loaded
const shimContent = `// GENERATED BY extension-shim-generator.js - DO NOT EDIT
// Extension setup for ${pkgName}
//
// This file contains the inlined extension setup code from the engine.
// It is generated at build time to enable dynamic import without loading
// the entire engine bundle.
${extensionCode}
`;
try {
fs.writeFileSync(shimPath, shimContent, 'utf8');
shimCount++;
console.log(`[ExtensionShimGenerator] ✓ Inlined extension code for ${pkgName}${mountName}.js`);
} catch (error) {
console.error(`[ExtensionShimGenerator] Failed to write shim for ${pkgName}:`, error.message);
}
}
console.log('[ExtensionShimGenerator] ========================================');
console.log('[ExtensionShimGenerator] ✓ Shim generation complete');
console.log('[ExtensionShimGenerator] Generated', shimCount, 'extension shim file(s)');
console.log('[ExtensionShimGenerator] Output directory:', path.join(this.outputPath, 'extensions'));
console.log('[ExtensionShimGenerator] ========================================');
}
/**
* Extract the mount path from an extension package name
* @param {string} extensionName - e.g., '@fleetbase/fleetops-engine'
* @returns {string} - e.g., 'fleetops'
*/
#getExtensionMountPath(extensionName) {
const segments = extensionName.split('/');
let mountName = segments[1] || segments[0];
return mountName.replace('-engine', '');
}
}
module.exports = ExtensionShimGeneratorPlugin;

View File

@@ -1,215 +0,0 @@
const Plugin = require('broccoli-plugin');
const fs = require('fs');
const path = require('path');
const recast = require('recast');
const babelParser = require('recast/parsers/babel');
const builders = recast.types.builders;
/**
* RouterGeneratorPlugin
*
* Automatically mounts discovered extensions in the Ember router.
* Reads router.map.js template and generates app/router.js with all engine mounts.
*
* Handles two types of mounts:
* 1. Console extensions: Mounted under /console route
* 2. Root extensions: Mounted at root level (fleetbase.mount === 'root')
*
* Uses AST manipulation to preserve existing router code and only add new mounts.
*/
class RouterGeneratorPlugin extends Plugin {
constructor(inputNodes, options = {}) {
super(inputNodes, {
annotation: options.annotation || 'RouterGeneratorPlugin',
persistentOutput: true,
});
this.projectRoot = options.projectRoot || process.cwd();
this.routerMapFile = options.routerMapFile || path.join(this.projectRoot, 'router.map.js');
}
/**
* Get the mount path for an extension
*/
getExtensionMountPath(extensionName) {
const segments = extensionName.split('/');
let mountName = segments[1] || segments[0];
return mountName.replace('-engine', '');
}
/**
* Get the route path for an extension
* Uses fleetbase.route if specified, otherwise uses mount path
*/
getExtensionRoute(extension) {
if (extension.fleetbase && extension.fleetbase.route) {
return extension.fleetbase.route;
}
return this.getExtensionMountPath(extension.name);
}
/**
* Check if an engine is already mounted in the AST
*/
isEngineMounted(functionBody, engineName) {
return functionBody.some(statement => {
if (statement.type !== 'ExpressionStatement') return false;
const expr = statement.expression;
if (expr.type !== 'CallExpression') return false;
if (!expr.arguments || expr.arguments.length === 0) return false;
return expr.arguments[0].value === engineName;
});
}
/**
* Create an AST node for mounting an engine
*/
createMountExpression(engineName, route) {
return builders.expressionStatement(
builders.callExpression(
builders.memberExpression(
builders.thisExpression(),
builders.identifier('mount')
),
[
builders.literal(engineName),
builders.objectExpression([
builders.property('init', builders.identifier('as'), builders.literal(route)),
builders.property('init', builders.identifier('path'), builders.literal(route)),
])
]
)
);
}
/**
* Add console extensions to the /console route
*/
addConsoleExtensions(ast, extensions) {
let addedCount = 0;
recast.visit(ast, {
visitCallExpression: (path) => {
const node = path.value;
// Look for this.route('console', function() { ... })
if (node.type === 'CallExpression' &&
node.callee.property &&
node.callee.property.name === 'route' &&
node.arguments[0] &&
node.arguments[0].value === 'console') {
// Find the function expression
const functionExpression = node.arguments.find(arg => arg.type === 'FunctionExpression');
if (functionExpression) {
// Add each console extension
extensions.forEach(extension => {
const route = this.getExtensionRoute(extension);
// Check if already mounted
if (!this.isEngineMounted(functionExpression.body.body, extension.name)) {
functionExpression.body.body.push(
this.createMountExpression(extension.name, route)
);
addedCount++;
}
});
}
}
return false; // Don't traverse children
}
});
return addedCount;
}
/**
* Add root extensions to the root map
*/
addRootExtensions(ast, extensions) {
let addedCount = 0;
recast.visit(ast, {
visitCallExpression: (path) => {
const node = path.value;
// Look for Router.map(function() { ... })
if (node.type === 'CallExpression' &&
node.callee.property &&
node.callee.property.name === 'map') {
// Find the function expression
const functionExpression = node.arguments.find(arg => arg.type === 'FunctionExpression');
if (functionExpression) {
// Add each root extension
extensions.forEach(extension => {
const route = this.getExtensionRoute(extension);
// Check if already mounted
if (!this.isEngineMounted(functionExpression.body.body, extension.name)) {
functionExpression.body.body.push(
this.createMountExpression(extension.name, route)
);
addedCount++;
}
});
}
}
return false; // Don't traverse children
}
});
return addedCount;
}
async build() {
console.log('[RouterGenerator] Generating app/router.js...');
// Read discovered extensions from cache
const extensionsCacheFile = path.join(this.inputPaths[0], 'extensions.json');
if (!fs.existsSync(extensionsCacheFile)) {
console.warn('[RouterGenerator] No extensions cache found, skipping');
return;
}
const extensions = JSON.parse(fs.readFileSync(extensionsCacheFile, 'utf8'));
// Separate console and root extensions
const consoleExtensions = extensions.filter(ext =>
!ext.fleetbase || ext.fleetbase.mount !== 'root'
);
const rootExtensions = extensions.filter(ext =>
ext.fleetbase && ext.fleetbase.mount === 'root'
);
// Read router.map.js template
if (!fs.existsSync(this.routerMapFile)) {
console.error('[RouterGenerator] router.map.js not found at:', this.routerMapFile);
return;
}
const routerFileContents = fs.readFileSync(this.routerMapFile, 'utf8');
// Parse the router file
const ast = recast.parse(routerFileContents, { parser: babelParser });
// Add extensions to the AST
const consoleAdded = this.addConsoleExtensions(ast, consoleExtensions);
const rootAdded = this.addRootExtensions(ast, rootExtensions);
// Generate the output code
const output = recast.print(ast, { quote: 'single' }).code;
// Write to output path
const outputPath = path.join(this.outputPath, 'router.js');
fs.writeFileSync(outputPath, output, 'utf8');
console.log(`[RouterGenerator] Generated app/router.js (${consoleAdded} console mounts, ${rootAdded} root mounts)`);
}
}
module.exports = RouterGeneratorPlugin;