mirror of
https://github.com/fleetbase/fleetbase.git
synced 2025-12-19 14:18:57 +00:00
feat: refactoring extension build pipelines
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
18
console/app/instance-initializers/initialize-registries.js
Normal file
18
console/app/instance-initializers/initialize-registries.js
Normal 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
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
17
console/app/instance-initializers/setup-extensions.js
Normal file
17
console/app/instance-initializers/setup-extensions.js
Normal 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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
14
console/app/utils/extension-loaders.generated.js
Normal file
14
console/app/utils/extension-loaders.generated.js
Normal 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'),
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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', '{}');
|
||||
|
||||
122
console/lib/build-plugins/extension-discovery.js
Normal file
122
console/lib/build-plugins/extension-discovery.js
Normal 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;
|
||||
119
console/lib/build-plugins/extension-loaders-generator.js
Normal file
119
console/lib/build-plugins/extension-loaders-generator.js
Normal 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;
|
||||
114
console/lib/build-plugins/extension-shim-generator.js
Normal file
114
console/lib/build-plugins/extension-shim-generator.js
Normal 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;
|
||||
215
console/lib/build-plugins/router-generator.js
Normal file
215
console/lib/build-plugins/router-generator.js
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user