Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions addon/exports/host-services.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const hostServices = [
'universe/hook-service',
'universe/widget-service',
'universe/extension-manager',
'universe/plugin-context',
'events',
'intl',
'abilities',
Expand Down
1 change: 1 addition & 0 deletions addon/exports/services.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const services = [
'universe/hook-service',
'universe/widget-service',
'universe/extension-manager',
'universe/plugin-context',
'events',
'intl',
'abilities',
Expand Down
4 changes: 4 additions & 0 deletions addon/services/universe.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default class UniverseService extends Service.extend(Evented) {
@service('universe/menu-service') menuService;
@service('universe/widget-service') widgetService;
@service('universe/hook-service') hookService;
@service('universe/plugin-context') pluginContext;
@service router;
@service intl;
@service urlSearchParams;
Expand Down Expand Up @@ -64,6 +65,9 @@ export default class UniverseService extends Service.extend(Evented) {
if (this.hookService) {
this.hookService.setApplicationInstance(application);
}
if (this.pluginContext) {
this.pluginContext.setApplicationInstance(application);
}
}

/**
Expand Down
65 changes: 61 additions & 4 deletions addon/services/universe/extension-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { assert, debug, warn } from '@ember/debug';
import { next } from '@ember/runloop';
import loadInstalledExtensions from '@fleetbase/ember-core/utils/load-installed-extensions';
import mapEngines from '@fleetbase/ember-core/utils/map-engines';
import isPluginExtension from '@fleetbase/ember-core/utils/is-plugin-extension';
import config from 'ember-get-config';
import { getExtensionLoader } from '@fleetbase/console/extensions';
import * as extensionLoaders from '@fleetbase/console/extensions';
import { isArray } from '@ember/array';
import RSVP from 'rsvp';
import ExtensionBootState from '../../contracts/extension-boot-state';
Expand All @@ -23,6 +24,7 @@ import ExtensionBootState from '../../contracts/extension-boot-state';
*/
export default class ExtensionManagerService extends Service.extend(Evented) {
@service universe;
@service('universe/plugin-context') pluginContext;
/**
* Reference to the root Ember Application Instance.
* Used for registering components/services to the application container
Expand Down Expand Up @@ -782,15 +784,19 @@ export default class ExtensionManagerService extends Service.extend(Evented) {
this.registerExtension(extensionName, extension);
}

// Phase 2: Load and execute extension.js from each enabled extension
// Phase 2: Load and execute plugin files or extension.js from each enabled package
for (const extension of extensions) {
// Extension is an object with name, version, etc. from package.json
const extensionName = extension.name || extension;
const extStartTime = performance.now();

// Lookup the loader function from the build-time generated map
const loader = getExtensionLoader(extensionName);
if (isPluginExtension(extension)) {
await this.#setupPlugin(extension, appInstance, universe, extStartTime);
continue;
}

// Lookup the loader function from the build-time generated map
const loader = this.#getExtensionLoader(extensionName);
if (!loader) {
warn(`[ExtensionManager] No loader registered for ${extensionName}. Ensure addon/extension.js exists and prebuild generated the mapping.`, false, {
id: 'ember-core.extension-manager.no-loader',
Expand Down Expand Up @@ -847,6 +853,57 @@ export default class ExtensionManagerService extends Service.extend(Evented) {
debug(`[ExtensionManager] All ${extensions.length} extensions setup completed in ${totalTime}ms`);
}

/**
* Load and execute a lightweight Fleetbase plugin.
*
* @private
* @param {Object} plugin Plugin manifest
* @param {ApplicationInstance} appInstance The application instance
* @param {Service} universe The universe service
* @param {Number} extStartTime Start time for debug logging
* @returns {Promise<void>}
*/
async #setupPlugin(plugin, appInstance, universe, extStartTime) {
const pluginName = plugin.name || plugin.id || plugin;
const loader = this.#getPluginLoader(pluginName);

if (!loader) {
warn(`[ExtensionManager] No plugin loader registered for ${pluginName}. Ensure fleetbase.plugin.js is indexed by the generated plugin mapping.`, false, {
id: 'ember-core.extension-manager.no-plugin-loader',
});
return;
}

try {
const module = await loader();
const registerPlugin = module.default ?? module.registerPlugin ?? module;

if (typeof registerPlugin !== 'function') {
warn(`[ExtensionManager] ${pluginName} plugin did not export a register function.`, false, {
id: 'ember-core.extension-manager.invalid-plugin-export',
});
return;
}

const context = this.pluginContext.create(plugin, appInstance, universe);
await registerPlugin(appInstance, context);

const extEndTime = performance.now();
const totalTime = (extEndTime - extStartTime).toFixed(2);
debug(`[ExtensionManager] ${pluginName} plugin setup completed in ${totalTime}ms`);
} catch (error) {
console.error(`[ExtensionManager] Failed to load or run plugin for ${pluginName}:`, error);
}
}

#getExtensionLoader(extensionName) {
return extensionLoaders.getExtensionLoader?.(extensionName);
}

#getPluginLoader(pluginName) {
return extensionLoaders.getPluginLoader?.(pluginName) ?? extensionLoaders.getExtensionLoader?.(pluginName);
}

/**
* Register a service into a specific engine
* Allows sharing host services with engines
Expand Down
207 changes: 207 additions & 0 deletions addon/services/universe/plugin-context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import Service, { inject as service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { getOwner } from '@ember/application';
import { assert, warn } from '@ember/debug';
import { dasherize } from '@ember/string';
import hostServices from '../../exports/host-services';

function serviceNamesFromHostServices() {
const serviceNames = new Set();

for (const entry of hostServices) {
if (typeof entry === 'string') {
serviceNames.add(entry);
} else if (entry && typeof entry === 'object') {
Object.keys(entry).forEach((name) => serviceNames.add(dasherize(name)));
Object.values(entry).forEach((name) => serviceNames.add(dasherize(name)));
}
}

return serviceNames;
}

const ALLOWED_SERVICE_NAMES = serviceNamesFromHostServices();

function normalizeServiceName(name) {
const normalized = name?.startsWith('service:') ? name.slice(8) : name;
return typeof normalized === 'string' ? dasherize(normalized) : normalized;
}

function normalizeMenuItem(definition = {}) {
const id = definition.id || definition.slug || dasherize(definition.title || definition.label || definition.text || 'plugin-menu-item');
const title = definition.title || definition.label || definition.text || id;

return {
...definition,
id,
slug: definition.slug || id,
title,
label: definition.label || title,
text: definition.text || title,
onClick: definition.onClick || definition.action || null,
_plugin: definition._plugin,
};
}

function normalizeWidget(definition = {}) {
const id = definition.id || definition.widgetId || dasherize(definition.name || definition.title || 'plugin-widget');

return {
...definition,
id,
widgetId: definition.widgetId || id,
name: definition.name || definition.title || definition.label || id,
};
}

/**
* PluginContextService
*
* Builds constrained runtime contexts for lightweight Fleetbase plugins.
* Plugins use this facade instead of directly reaching into Ember internals.
*/
export default class PluginContextService extends Service {
@service universe;
@service('universe/menu-service') menuService;
@service('universe/widget-service') widgetService;
@service('universe/hook-service') hookService;
@service('universe/registry-service') registryService;

@tracked applicationInstance = null;
registeredPermissions = [];

setApplicationInstance(application) {
this.applicationInstance = application;
}

create(plugin = {}, appInstance = null, universe = this.universe) {
const owner = appInstance || this.applicationInstance || getOwner(this);
const pluginName = plugin.name || plugin.id || 'fleetbase-plugin';

return {
plugin,
owner,
lookup: (name) => this.lookup(name, owner),
registerMenuItem: (slot, definition) => this.registerMenuItem(pluginName, slot, definition),
registerWidget: (slot, definition) => this.registerWidget(pluginName, slot, definition),
registerRoute: (path, definition) => this.registerRoute(pluginName, path, definition),
registerAction: (slot, handler, options = {}) => this.registerAction(pluginName, slot, handler, options),
registerHook: (name, handler, options = {}) => this.registerHook(pluginName, name, handler, options),
registerPermission: (permission) => this.registerPermission(pluginName, permission),
on: (event, callback) => this.onEvent(universe, event, callback),
off: (event, callback) => this.offEvent(universe, event, callback),
emit: (event, ...payload) => this.emitEvent(universe, event, ...payload),
};
}

lookup(name, owner = null) {
const serviceName = normalizeServiceName(name);

assert(`[PluginContext] lookup() only supports service lookups, received '${name}'`, typeof serviceName === 'string' && serviceName.length > 0);
assert(`[PluginContext] service '${serviceName}' is not available to plugins`, ALLOWED_SERVICE_NAMES.has(serviceName));

const container = owner || this.applicationInstance || getOwner(this);
return container?.lookup?.(`service:${serviceName}`) ?? null;
}

registerMenuItem(pluginName, slot, definition = {}) {
assert('[PluginContext] registerMenuItem() requires a slot name', typeof slot === 'string' && slot.length > 0);

const menuItem = normalizeMenuItem({
...definition,
_plugin: pluginName,
});

this.menuService.registerMenuItem(slot, menuItem);
return menuItem;
}

registerWidget(pluginName, slot, definition = {}) {
assert('[PluginContext] registerWidget() requires a slot name', typeof slot === 'string' && slot.length > 0);

const widget = normalizeWidget({
...definition,
_plugin: pluginName,
});

this.registryService.register(slot, 'widget', widget.id, widget);

if (slot === 'dashboard' || slot.startsWith('dashboard.')) {
this.widgetService.registerWidgets(slot, widget);
}

return widget;
}

registerRoute(pluginName, path, definition = {}) {
assert('[PluginContext] registerRoute() requires a path', typeof path === 'string' && path.length > 0);

const route = {
...definition,
path,
id: definition.id || path,
_plugin: pluginName,
};

this.registryService.register('plugin:routes', 'route', route.id, route);
warn('[PluginContext] registerRoute() records virtual plugin routes only; runtime Ember route-map mutation is not supported in v1.', false, {
id: 'ember-core.plugin-context.route-recorded',
});

return route;
}

registerAction(pluginName, slot, handler, options = {}) {
assert('[PluginContext] registerAction() requires a slot name', typeof slot === 'string' && slot.length > 0);
assert('[PluginContext] registerAction() requires a handler function', typeof handler === 'function');

const action = {
id: options.id || `${pluginName}:${slot}`,
slot,
handler,
_plugin: pluginName,
...options,
};

this.registryService.register(slot, 'action', action.id, action);
return action;
}

registerHook(pluginName, name, handler, options = {}) {
const hookId = options.id || `${pluginName}:${name}`;

this.hookService.registerHook(name, handler, {
...options,
id: hookId,
});

return this.hookService.getHooks(name).find((hook) => hook.id === hookId);
}

registerPermission(pluginName, permission) {
assert('[PluginContext] registerPermission() requires a permission string or definition', typeof permission === 'string' || (permission && typeof permission === 'object'));

const definition = typeof permission === 'string' ? { permission } : permission;
const registered = {
...definition,
_plugin: pluginName,
};

this.registeredPermissions.push(registered);
this.registryService.register('plugin:permissions', 'permission', registered.permission || registered.id, registered);
return registered;
}

onEvent(universe, event, callback) {
universe?.on?.(event, callback);
return callback;
}

offEvent(universe, event, callback) {
universe?.off?.(event, callback);
}

emitEvent(universe, event, ...payload) {
universe?.trigger?.(event, ...payload);
}
}
3 changes: 3 additions & 0 deletions addon/utils/is-plugin-extension.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function isPluginExtension(extension) {
return extension?.type === 'plugin' || extension?.fleetbase?.type === 'plugin' || extension?.fleetbase?.plugin === true;
}
7 changes: 7 additions & 0 deletions addon/utils/load-engines.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { dasherize } from '@ember/string';
import hostServices from '../exports/host-services';
import isPluginExtension from './is-plugin-extension';

export default async function loadEngines(appInstance, withServices = []) {
return new Promise((resolve, reject) => {
Expand All @@ -14,13 +15,19 @@ export default async function loadEngines(appInstance, withServices = []) {

for (let i = 0; i < extensions.length; i++) {
const extension = extensions[i];
if (isPluginExtension(extension)) {
continue;
}
const path = dasherize(extension.extension);

externalRoutes[path] = `console.${path}`;
}

for (let i = 0; i < extensions.length; i++) {
const extension = extensions[i];
if (isPluginExtension(extension)) {
continue;
}

engines[extension.name] = {
dependencies: {
Expand Down
Loading
Loading