feat: refactoring extension build pipelines

This commit is contained in:
Ronald A. Richardson
2025-11-27 11:20:14 +08:00
parent 9fa1bf54d2
commit ffab66ac6c
22 changed files with 898 additions and 57 deletions

View File

@@ -15,11 +15,12 @@ export default class App extends Application {
engines = {};
async ready() {
console.log('[app.ready] Has been called!');
applyRouterFix(this);
const extensions = await loadExtensions();
this.extensions = extensions;
this.engines = mapEngines(extensions);
// Extensions are now loaded in the initialize-universe initializer
// This hook can be used for other ready-time setup if needed
console.log('[app.ready] Application ready with extensions:', this.extensions);
}
}
@@ -27,7 +28,7 @@ document.addEventListener('DOMContentLoaded', async () => {
await loadRuntimeConfig();
loadInitializers(App, config.modulePrefix);
let fleetbase = App.create();
fleetbase.deferReadiness();
fleetbase.boot();
const Fleetbase = App.create();
Fleetbase.deferReadiness();
Fleetbase.boot();
});

View File

@@ -0,0 +1,18 @@
/**
* Create console-specific registries
* Runs after extensions are loaded
*/
export function initialize(appInstance) {
const registryService = appInstance.lookup('service:universe/registry-service');
console.log('[initialize-registries] Creating console registries...');
// Create console-specific registries
registryService.createRegistries(['@fleetbase/console', 'auth:login']);
}
export default {
name: 'initialize-registries',
after: 'load-extensions',
initialize
};

View File

@@ -1,36 +1,46 @@
import { Widget } from '@fleetbase/ember-core/contracts';
import { faGithub } from '@fortawesome/free-brands-svg-icons';
export function initialize(application) {
const universe = application.lookup('service:universe');
const defaultWidgets = [
{
widgetId: 'fleetbase-blog',
/**
* Register dashboard and widgets for FleetbaseConsole
* Runs after extensions are loaded
*/
export function initialize(appInstance) {
const widgetService = appInstance.lookup('service:universe/widget-service');
console.log('[initialize-widgets] Registering console dashboard and widgets...');
// Register the console dashboard
widgetService.registerDashboard('dashboard');
// Create widget definitions
const widgets = [
new Widget({
id: 'fleetbase-blog',
name: 'Fleetbase Blog',
description: 'Lists latest news and events from the Fleetbase official team.',
icon: 'newspaper',
component: 'fleetbase-blog',
grid_options: { w: 8, h: 9, minW: 8, minH: 9 },
options: {
title: 'Fleetbase Blog',
},
},
{
widgetId: 'fleetbase-github-card',
default: true,
}),
new Widget({
id: 'fleetbase-github-card',
name: 'Github Card',
description: 'Displays current Github stats from the official Fleetbase repo.',
icon: faGithub,
component: 'github-card',
grid_options: { w: 4, h: 8, minW: 4, minH: 8 },
options: {
title: 'Github Card',
},
},
default: true,
}),
];
universe.registerDefaultDashboardWidgets(defaultWidgets);
universe.registerDashboardWidgets(defaultWidgets);
// Register widgets
widgetService.registerWidgets('dashboard', widgets);
}
export default {
name: 'initialize-widgets',
after: 'load-extensions',
initialize,
};

View File

@@ -1,11 +1,22 @@
export function initialize(application) {
const universe = application.lookup('service:universe');
if (universe) {
universe.createRegistries(['@fleetbase/console', 'auth:login']);
universe.bootEngines(application);
/**
* Load extensions from the API using ExtensionManager
* This must run before other initializers that depend on extensions
*/
export async function initialize(appInstance) {
const application = appInstance.application;
const extensionManager = appInstance.lookup('service:universe/extension-manager');
if (!application.extensions || application.extensions.length === 0) {
try {
await extensionManager.loadExtensions(application);
} catch (error) {
console.error('[load-extensions] Error:', error);
}
}
}
export default {
initialize,
name: 'load-extensions',
initialize
};

View File

@@ -0,0 +1,17 @@
/**
* Setup extensions by loading and executing their extension.js files
* Runs after extensions are loaded from API
*/
export async function initialize(appInstance) {
const universe = appInstance.lookup('service:universe');
const extensionManager = appInstance.lookup('service:universe/extension-manager');
await extensionManager.setupExtensions(appInstance, universe);
}
export default {
name: 'setup-extensions',
after: ['load-extensions', 'initialize-registries', 'initialize-widgets'],
initialize
};

View File

@@ -11,7 +11,7 @@ export default class ScheduleAvailabilityModel extends Model {
@attr('string') reason;
@attr('string') notes;
@attr('object') meta;
@attr('date') created_at;
@attr('date') updated_at;
@attr('date') deleted_at;

View File

@@ -14,9 +14,9 @@ export default class ScheduleConstraintModel extends Model {
@attr('number', { defaultValue: 0 }) priority;
@attr('boolean', { defaultValue: true }) is_active;
@attr('object') meta;
@belongsTo('company') company;
@attr('date') created_at;
@attr('date') updated_at;
@attr('date') deleted_at;

View File

@@ -14,9 +14,9 @@ export default class ScheduleItemModel extends Model {
@attr('date') break_end_at;
@attr('string', { defaultValue: 'pending' }) status;
@attr('object') meta;
@belongsTo('schedule') schedule;
@attr('date') created_at;
@attr('date') updated_at;
@attr('date') deleted_at;

View File

@@ -13,9 +13,9 @@ export default class ScheduleTemplateModel extends Model {
@attr('number') break_duration;
@attr('string') rrule;
@attr('object') meta;
@belongsTo('company') company;
@attr('date') created_at;
@attr('date') updated_at;
@attr('date') deleted_at;

View File

@@ -7,6 +7,8 @@ import pathToRoute from '@fleetbase/ember-core/utils/path-to-route';
import removeBootLoader from '../utils/remove-boot-loader';
export default class ApplicationRoute extends Route {
@service('universe/hook-service') hookService;
@service('universe/extension-manager') extensionManager;
@service session;
@service theme;
@service fetch;
@@ -15,7 +17,6 @@ export default class ApplicationRoute extends Route {
@service intl;
@service currentUser;
@service router;
@service universe;
@tracked defaultTheme;
/**
@@ -24,7 +25,7 @@ export default class ApplicationRoute extends Route {
* @memberof ApplicationRoute
*/
@action willTransition(transition) {
this.universe.callHooks('application:will-transition', this.session, this.router, transition);
this.hookService.execute('application:will-transition', this.session, this.router, transition);
}
/**
@@ -45,7 +46,7 @@ export default class ApplicationRoute extends Route {
* @memberof ApplicationRoute
*/
@action loading(transition) {
this.universe.callHooks('application:loading', this.session, this.router, transition);
this.hookService.execute('application:loading', this.session, this.router, transition);
}
/**
@@ -79,9 +80,9 @@ export default class ApplicationRoute extends Route {
*/
async beforeModel(transition) {
await this.session.setup();
await this.universe.booting();
await this.extensionManager.waitForBoot();
this.universe.callHooks('application:before-model', this.session, this.router, transition);
this.hookService.execute('application:before-model', this.session, this.router, transition);
const shift = this.urlSearchParams.get('shift');
if (this.session.isAuthenticated && shift) {
@@ -95,9 +96,7 @@ export default class ApplicationRoute extends Route {
* @memberof ApplicationRoute
*/
afterModel() {
if (!this.session.isAuthenticated) {
removeBootLoader();
}
if (!this.session.isAuthenticated) removeBootLoader();
}
/**

View File

@@ -5,9 +5,9 @@ import removeBootLoader from '../utils/remove-boot-loader';
import '@fleetbase/leaflet-routing-machine';
export default class ConsoleRoute extends Route {
@service('universe/hook-service') hookService;
@service store;
@service session;
@service universe;
@service router;
@service currentUser;
@service intl;
@@ -22,7 +22,7 @@ export default class ConsoleRoute extends Route {
async beforeModel(transition) {
await this.session.requireAuthentication(transition, 'auth.login');
this.universe.callHooks('console:before-model', this.session, this.router, transition);
this.hookService.execute('console:before-model', this.session, this.router, transition);
if (this.session.isAuthenticated) {
return this.session.promiseCurrentUser(transition);
@@ -37,7 +37,7 @@ export default class ConsoleRoute extends Route {
* @memberof ConsoleRoute
*/
async afterModel(model, transition) {
this.universe.callHooks('console:after-model', this.session, this.router, model, transition);
this.hookService.execute('console:after-model', this.session, this.router, model, transition);
removeBootLoader();
}
@@ -47,7 +47,7 @@ export default class ConsoleRoute extends Route {
* @memberof ConsoleRoute
*/
@action didTransition() {
this.universe.callHooks('console:did-transition', this.session, this.router);
this.hookService.execute('console:did-transition', this.session, this.router);
}
/**

View File

@@ -107,8 +107,12 @@ export default class UserVerificationService extends Service {
}
#wait(timeout = 75000) {
return later(this, () => {
this.waiting = true;
}, timeout);
return later(
this,
() => {
this.waiting = true;
},
timeout
);
}
}

View File

@@ -0,0 +1,14 @@
/**
* 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

@@ -107,8 +107,8 @@ export function suppressRouterRefreshErrors(application) {
// Global error handler for unhandled promise rejections
window.addEventListener('unhandledrejection', (event) => {
const error = event.reason;
if (error?.message?.includes("You didn't provide enough string/numeric parameters to satisfy all of the dynamic segments")) {
debug('[Fleetbase Router Patch] Suppressed known Ember route refresh bug:', error.message);
if (typeof error?.message === 'string' && error?.message.includes("You didn't provide enough string/numeric parameters to satisfy all of the dynamic segments")) {
debug('[Fleetbase Router Patch] Suppressed known Ember route refresh bug: ' + error.message);
event.preventDefault(); // Prevent the error from being logged
}
});
@@ -118,8 +118,8 @@ export function suppressRouterRefreshErrors(application) {
const originalEmberError = window.Ember.onerror;
window.Ember.onerror = function (error) {
if (error?.message?.includes("You didn't provide enough string/numeric parameters to satisfy all of the dynamic segments")) {
debug('[Fleetbase Router Patch] Suppressed known Ember route refresh bug:', error.message);
if (typeof error?.message === 'string' && error?.message.includes("You didn't provide enough string/numeric parameters to satisfy all of the dynamic segments")) {
debug('[Fleetbase Router Patch] Suppressed known Ember route refresh bug: ' + error.message);
return; // Suppress the error
}

View File

@@ -2,8 +2,12 @@
/** eslint-disable node/no-unpublished-require */
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
const FleetbaseExtensionsIndexer = require('fleetbase-extensions-indexer');
const ExtensionDiscoveryPlugin = require('./lib/build-plugins/extension-discovery');
const ExtensionShimGeneratorPlugin = require('./lib/build-plugins/extension-shim-generator');
const ExtensionLoadersGeneratorPlugin = require('./lib/build-plugins/extension-loaders-generator');
const RouterGeneratorPlugin = require('./lib/build-plugins/router-generator');
const Funnel = require('broccoli-funnel');
const mergeTrees = require('broccoli-merge-trees');
const writeFile = require('broccoli-file-creator');
const postcssImport = require('postcss-import');
const postcssPresetEnv = require('postcss-preset-env');
@@ -60,9 +64,85 @@ module.exports = function (defaults) {
babel: {
plugins: [require.resolve('ember-auto-import/babel-plugin')],
},
autoImport: {
// Allow dynamic imports of app/extensions/* files
// This is required for the extension setup code to be bundled
// by ember-auto-import and loaded via dynamic import()
allowAppImports: ['extensions/*'],
// Optional: Configure webpack for better code splitting
webpack: {
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
// Group all extension files into a single chunk
extensions: {
test: /[\\/]app[\\/]extensions[\\/]/,
name: 'extensions',
priority: 10,
},
},
},
},
},
},
});
let extensions = new FleetbaseExtensionsIndexer();
// ============================================================================
// FLEETBASE EXTENSION BUILD PLUGINS
// ============================================================================
// Discover all Fleetbase extensions
const extensionDiscovery = new ExtensionDiscoveryPlugin([], {
projectRoot: __dirname,
annotation: 'Discover Fleetbase Extensions',
});
// Generate extension shim files
const extensionShims = new ExtensionShimGeneratorPlugin([extensionDiscovery], {
projectRoot: __dirname,
annotation: 'Generate Extension Shims',
});
// Generate extension loaders map
const extensionLoaders = new ExtensionLoadersGeneratorPlugin([extensionDiscovery], {
projectRoot: __dirname,
annotation: 'Generate Extension Loaders',
});
// Generate router with engine mounts
const router = new RouterGeneratorPlugin([extensionDiscovery], {
projectRoot: __dirname,
routerMapFile: __dirname + '/router.map.js',
annotation: 'Generate Router with Engine Mounts',
});
// ============================================================================
// FUNNEL GENERATED FILES INTO APP TREE
// ============================================================================
// Funnel extension shims to app/extensions/
const extensionShimsTree = new Funnel(extensionShims, {
destDir: 'app',
});
// Funnel extension loaders to app/utils/
const extensionLoadersTree = new Funnel(extensionLoaders, {
destDir: 'app',
});
// Funnel router to app
const routerTree = new Funnel(router, {
destDir: 'app',
});
// Generated extension files
const extensions = mergeTrees([extensionShimsTree, extensionLoadersTree, routerTree], {
overwrite: true,
annotation: 'Merge Extension Generated Files',
});
// let extensions = new FleetbaseExtensionsIndexer();
let runtimeConfigTree;
if (toBoolean(process.env.DISABLE_RUNTIME_CONFIG)) {
runtimeConfigTree = writeFile('fleetbase.config.json', '{}');

View File

@@ -0,0 +1,122 @@
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] Discovering Fleetbase extensions...');
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] Found ${extensions.length} extensions`);
// Log discovered extensions
extensions.forEach(ext => {
console.log(` - ${ext.name}@${ext.version}`);
});
}
}
module.exports = ExtensionDiscoveryPlugin;

View File

@@ -0,0 +1,119 @@
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] Generating extension-loaders.generated.js...');
// 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] Generated extension-loaders.generated.js with ${count} loaders`);
}
}
module.exports = ExtensionLoadersGeneratorPlugin;

View File

@@ -0,0 +1,114 @@
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();
}
build() {
const extensionsJsonPath = path.join(this.inputPaths[0], 'extensions.json');
// 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] Generated ${shimCount} extension shim files`);
}
/**
* 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

@@ -0,0 +1,215 @@
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;

View File

@@ -0,0 +1,39 @@
import Application from '@ember/application';
import config from '@fleetbase/console/config/environment';
import { initialize } from '@fleetbase/console/instance-initializers/initialize-registries';
import { module, test } from 'qunit';
import Resolver from 'ember-resolver';
import { run } from '@ember/runloop';
module('Unit | Instance Initializer | initialize-registries', function (hooks) {
hooks.beforeEach(function () {
this.TestApplication = class TestApplication extends Application {
modulePrefix = config.modulePrefix;
podModulePrefix = config.podModulePrefix;
Resolver = Resolver;
};
this.TestApplication.instanceInitializer({
name: 'initializer under test',
initialize,
});
this.application = this.TestApplication.create({
autoboot: false,
});
this.instance = this.application.buildInstance();
});
hooks.afterEach(function () {
run(this.instance, 'destroy');
run(this.application, 'destroy');
});
// TODO: Replace this with your real tests.
test('it works', async function (assert) {
await this.instance.boot();
assert.ok(true);
});
});

View File

@@ -0,0 +1,39 @@
import Application from '@ember/application';
import config from '@fleetbase/console/config/environment';
import { initialize } from '@fleetbase/console/instance-initializers/initialize-widgets';
import { module, test } from 'qunit';
import Resolver from 'ember-resolver';
import { run } from '@ember/runloop';
module('Unit | Instance Initializer | initialize-widgets', function (hooks) {
hooks.beforeEach(function () {
this.TestApplication = class TestApplication extends Application {
modulePrefix = config.modulePrefix;
podModulePrefix = config.podModulePrefix;
Resolver = Resolver;
};
this.TestApplication.instanceInitializer({
name: 'initializer under test',
initialize,
});
this.application = this.TestApplication.create({
autoboot: false,
});
this.instance = this.application.buildInstance();
});
hooks.afterEach(function () {
run(this.instance, 'destroy');
run(this.application, 'destroy');
});
// TODO: Replace this with your real tests.
test('it works', async function (assert) {
await this.instance.boot();
assert.ok(true);
});
});

View File

@@ -0,0 +1,39 @@
import Application from '@ember/application';
import config from '@fleetbase/console/config/environment';
import { initialize } from '@fleetbase/console/instance-initializers/setup-extensions';
import { module, test } from 'qunit';
import Resolver from 'ember-resolver';
import { run } from '@ember/runloop';
module('Unit | Instance Initializer | setup-extensions', function (hooks) {
hooks.beforeEach(function () {
this.TestApplication = class TestApplication extends Application {
modulePrefix = config.modulePrefix;
podModulePrefix = config.podModulePrefix;
Resolver = Resolver;
};
this.TestApplication.instanceInitializer({
name: 'initializer under test',
initialize,
});
this.application = this.TestApplication.create({
autoboot: false,
});
this.instance = this.application.buildInstance();
});
hooks.afterEach(function () {
run(this.instance, 'destroy');
run(this.application, 'destroy');
});
// TODO: Replace this with your real tests.
test('it works', async function (assert) {
await this.instance.boot();
assert.ok(true);
});
});