Allow plugins to use css

Summary:
Flipper plugins fail when importing css from third-party dependencies. This diff tries to fix that.

Effectively, the plugin can import the css and export it when is bundled.

When we load the plugin, we check if there's a css file for it. If there's one, we return it and try to use it.

Reviewed By: aigoncharov

Differential Revision: D40758178

fbshipit-source-id: e53afffcc481504905d5eeb1aea1f9114ee2a86b
This commit is contained in:
Lorenzo Blasa
2022-10-27 22:50:30 -07:00
committed by Facebook GitHub Bot
parent ff282630be
commit 587f428cf8
15 changed files with 94 additions and 30 deletions

View File

@@ -189,8 +189,24 @@ export async function initializeElectron(
return flipperServerConfig.gatekeepers[gatekeeper] ?? false;
},
flipperServer,
async requirePlugin(path) {
return electronRequire(path);
async requirePlugin(path): Promise<{plugin: any; css?: string}> {
const plugin = electronRequire(path);
/**
* Check if the plugin includes a bundled css. If so,
* load its content too.
*/
const idx = path.lastIndexOf('.');
const cssPath = path.substring(0, idx < 0 ? path.length : idx) + '.css';
try {
await fs.promises.access(cssPath);
const buffer = await fs.promises.readFile(cssPath, {encoding: 'utf-8'});
const css = buffer.toString();
return {plugin, css};
} catch (e) {}
return {plugin};
},
getStaticResourceUrl(relativePath): string {
return (

View File

@@ -176,6 +176,10 @@ export interface DeviceDebugData {
data: (DeviceDebugFile | DeviceDebugCommand)[];
}
export interface PluginSource {
js: string;
css?: string;
}
export type FlipperServerCommands = {
'get-server-state': () => Promise<{
state: FlipperServerState;
@@ -275,7 +279,7 @@ export type FlipperServerCommands = {
'plugin-start-download': (
plugin: DownloadablePluginDetails,
) => Promise<InstalledPluginDetails>;
'plugin-source': (path: string) => Promise<string>;
'plugin-source': (path: string) => Promise<PluginSource>;
'plugins-install-from-marketplace': (
name: string,
) => Promise<InstalledPluginDetails>;

View File

@@ -142,7 +142,7 @@ export interface RenderHost {
GK(gatekeeper: string): boolean;
flipperServer: FlipperServer;
serverConfig: FlipperServerConfig;
requirePlugin(path: string): Promise<any>;
requirePlugin(path: string): Promise<{plugin: any; css?: string}>;
getStaticResourceUrl(relativePath: string): string;
// given the requested icon and proposed public url of the icon, rewrite it to a local icon if needed
getLocalIconUrl?(icon: Icon, publicUrl: string): string;

View File

@@ -55,15 +55,18 @@ export type FlipperPluginModule<
export class SandyPluginDefinition {
id: string;
module: FlipperPluginModule<any> | FlipperDevicePluginModule;
css?: string;
details: ActivatablePluginDetails;
isDevicePlugin: boolean;
constructor(
details: ActivatablePluginDetails,
module: FlipperPluginModule<any> | FlipperDevicePluginModule,
css?: string,
);
constructor(details: ActivatablePluginDetails, module: any) {
constructor(details: ActivatablePluginDetails, module: any, css?: string) {
this.id = details.id;
this.css = css;
this.details = details;
if (
details.pluginType === 'device' ||

View File

@@ -28,9 +28,18 @@ export const SandyPluginRenderer = memo(({plugin}: Props) => {
throw new Error('Expected plugin, got ' + plugin);
}
useEffect(() => {
const style = document.createElement('style');
if (plugin.definition.css) {
style.innerText = plugin.definition.css;
document.head.appendChild(style);
}
plugin.activate();
return () => {
plugin.deactivate();
if (plugin.definition.css) {
document.head.removeChild(style);
}
};
}, [plugin]);

View File

@@ -26,15 +26,15 @@ export class HeadlessPluginInitializer extends AbstractPluginInitializer {
protected async requirePluginImpl(
pluginDetails: ActivatablePluginDetails,
): Promise<_SandyPluginDefinition> {
const plugin = await getRenderHostInstance().requirePlugin(
const requiredPlugin = await getRenderHostInstance().requirePlugin(
pluginDetails.entry,
);
if (!plugin) {
if (!requiredPlugin || !requiredPlugin.plugin) {
throw new Error(
`Failed to obtain plugin source for: ${pluginDetails.name}`,
);
}
return new _SandyPluginDefinition(pluginDetails, plugin);
return new _SandyPluginDefinition(pluginDetails, requiredPlugin.plugin);
}
protected async filterAllLocalVersions(

View File

@@ -31,7 +31,7 @@ export function initializeRenderHost(
async exportFileBinary() {
throw new Error('Not implemented');
},
openLink(url: string) {
openLink(_url: string) {
throw new Error('Not implemented');
},
hasFocus() {
@@ -54,22 +54,24 @@ export function initializeRenderHost(
return flipperServerConfig.gatekeepers[gatekeeper] ?? false;
},
flipperServer,
async requirePlugin(path) {
let source = await flipperServer.exec('plugin-source', path);
async requirePlugin(path): Promise<{plugin: any; css?: string}> {
const source = await flipperServer.exec('plugin-source', path);
let js = source.js;
// append source url (to make sure a file entry shows up in the debugger)
source += `\n//# sourceURL=file://${path}`;
js += `\n//# sourceURL=file://${path}`;
// and source map url (to get source code if available)
source += `\n//# sourceMappingURL=file://${path.replace(/.js$/, '.map')}`;
js += `\n//# sourceMappingURL=file://${path.replace(/.js$/, '.map')}`;
// Plugins are compiled as typical CJS modules, referring to the global
// 'module', which we'll make available by loading the source into a closure that captures 'module'.
// Note that we use 'eval', and not 'new Function', because the latter will cause the source maps
// to be off by two lines (as the function declaration uses two lines in the generated source)
// eslint-disable-next-line no-eval
const cjsLoader = eval('(module) => {' + source + '\n}');
const cjsLoader = eval('(module) => {' + js + '\n}');
const theModule = {exports: {}};
cjsLoader(theModule);
return theModule.exports;
return {plugin: theModule.exports};
},
getStaticResourceUrl(path): string {
// the 'static' folder is mounted as static middleware in Express at the root

View File

@@ -17,9 +17,10 @@ import {
ExecuteMessage,
FlipperServerForServerAddOn,
InstalledPluginDetails,
PluginSource,
ServerAddOnStartDetails,
} from 'flipper-common';
import {getStaticPath} from '../utils/pathUtils';
import {loadDynamicPlugins} from './loadDynamicPlugins';
import {
cleanupOldInstalledPluginVersions,
@@ -72,8 +73,27 @@ export class PluginManager {
installPluginFromFile = installPluginFromFile;
installPluginFromNpm = installPluginFromNpm;
async loadSource(path: string) {
return await fs.readFile(path, 'utf8');
async loadSource(path: string): Promise<PluginSource> {
const js = await fs.readFile(path, 'utf8');
/**
* Check if the plugin includes a bundled css. If so,
* load its content too.
*/
let css = undefined;
const idx = path.lastIndexOf('.');
const cssPath = path.substring(0, idx < 0 ? path.length : idx) + '.css';
try {
await fs.promises.access(cssPath);
const buffer = await fs.promises.readFile(cssPath, {encoding: 'utf-8'});
css = buffer.toString();
} catch (e) {}
return {
js,
css,
};
}
async loadMarketplacePlugins() {

View File

@@ -80,14 +80,15 @@ export function initializeRenderHost(
return flipperServerConfig.gatekeepers[gatekeeper] ?? false;
},
flipperServer,
async requirePlugin(path) {
let source = await flipperServer.exec('plugin-source', path);
async requirePlugin(path): Promise<{plugin: any; css?: string}> {
const source = await flipperServer.exec('plugin-source', path);
let js = source.js;
// append source url (to make sure a file entry shows up in the debugger)
source += `\n//# sourceURL=file://${path}`;
js += `\n//# sourceURL=file://${path}`;
if (isProduction()) {
// and source map url (to get source code if available)
source += `\n//# sourceMappingURL=file://${path}.map`;
js += `\n//# sourceMappingURL=file://${path}.map`;
}
// Plugins are compiled as typical CJS modules, referring to the global
@@ -95,10 +96,10 @@ export function initializeRenderHost(
// Note that we use 'eval', and not 'new Function', because the latter will cause the source maps
// to be off by two lines (as the function declaration uses two lines in the generated source)
// eslint-disable-next-line no-eval
const cjsLoader = eval('(module) => {' + source + '\n}');
const cjsLoader = eval('(module) => {' + js + '\n}');
const theModule = {exports: {}};
cjsLoader(theModule);
return theModule.exports;
return {plugin: theModule.exports, css: source.css};
},
getStaticResourceUrl(path): string {
// the 'static' folder is mounted as static middleware in Express at the root

View File

@@ -57,6 +57,7 @@ exports[`can create a Fake flipper with legacy wrapper 2`] = `
Object {
"clientPlugins": Map {
"TestPlugin" => SandyPluginDefinition {
"css": undefined,
"details": Object {
"dir": "/Users/mock/.flipper/thirdparty/flipper-plugin-sample1",
"entry": "./test/index.js",

View File

@@ -79,6 +79,7 @@ test('requirePluginInternal loads plugin', async () => {
expect(plugin).not.toBeNull();
expect(Object.keys(plugin as any)).toEqual([
'id',
'css',
'details',
'isDevicePlugin',
'module',

View File

@@ -131,19 +131,24 @@ export const requirePlugin = (pluginDetails: ActivatablePluginDetails) =>
export const requirePluginInternal = async (
pluginDetails: ActivatablePluginDetails,
): Promise<PluginDefinition> => {
let plugin = await getRenderHostInstance().requirePlugin(
const requiredPlugin = await getRenderHostInstance().requirePlugin(
(pluginDetails as InstalledPluginDetails).entry,
);
if (!plugin) {
if (!requiredPlugin || !requiredPlugin.plugin) {
throw new Error(
`Failed to obtain plugin source for: ${pluginDetails.name}`,
);
}
if (isSandyPlugin(pluginDetails)) {
// Sandy plugin
return new _SandyPluginDefinition(pluginDetails, plugin);
return new _SandyPluginDefinition(
pluginDetails,
requiredPlugin.plugin,
requiredPlugin.css,
);
} else {
// classic plugin
// Classic plugin
let plugin = requiredPlugin.plugin;
if (plugin.default) {
plugin = plugin.default;
}

View File

@@ -7,7 +7,8 @@
"*"
],
"nohoist": [
"flipper-plugin-kaios-big-allocations/**"
"flipper-plugin-kaios-big-allocations/**",
"flipper-plugin-ui-debugger/**"
]
},
"bugs": {

View File

@@ -9,6 +9,7 @@
import {PluginClient, createState, createDataSource} from 'flipper-plugin';
import {Events, Id, PerfStatsEvent, Snapshot, TreeState, UINode} from './types';
import './node_modules/react-complex-tree/lib/style.css';
export function plugin(client: PluginClient<Events>) {
const rootId = createState<Id | undefined>(undefined);

View File

@@ -190,7 +190,7 @@ function createStubRenderHost(): RenderHost {
},
flipperServer: TestUtils.createFlipperServerMock(),
async requirePlugin(path: string) {
return require(path);
return {plugin: require(path)};
},
getStaticResourceUrl(relativePath): string {
return 'file://' + resolve(rootPath, 'static', relativePath);