feat: add API to obtain the bundled js file for all enabled plugins (#3444)

#### What type of PR is this?
/kind feature
/milestone 2.3.x
/area core
#### What this PR does / why we need it:
提供 `/apis/api.console.halo.run/v1alpha1/plugins/bundle.js` 来获取已启用插件的捆绑后的 main.js 和 style.css 文件

#### Which issue(s) this PR fixes:
Fixes #3442

#### Does this PR introduce a user-facing change?

```release-note
优化已启用插件 jsbundle 文件的加载方式
```
pull/4486/head
guqing 2023-08-25 15:28:11 +08:00 committed by GitHub
parent ec0187d8aa
commit 5c115563e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 328 additions and 229 deletions

View File

@ -29,6 +29,7 @@ import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
import java.util.function.Predicate;
import lombok.AllArgsConstructor;
@ -217,10 +218,61 @@ public class PluginEndpoint implements CustomEndpoint {
builder -> builder.operationId("ListPluginPresets")
.description("List all plugin presets in the system.")
.tag(tag)
.response(responseBuilder().implementationArray(Plugin.class)))
.response(responseBuilder().implementationArray(Plugin.class))
)
.GET("plugins/-/bundle.js", this::fetchJsBundle,
builder -> builder.operationId("fetchJsBundle")
.description("Merge all JS bundles of enabled plugins into one.")
.tag(tag)
.response(responseBuilder().implementation(String.class))
)
.GET("plugins/-/bundle.css", this::fetchCssBundle,
builder -> builder.operationId("fetchCssBundle")
.description("Merge all CSS bundles of enabled plugins into one.")
.tag(tag)
.response(responseBuilder().implementation(String.class))
)
.build();
}
private Mono<ServerResponse> fetchJsBundle(ServerRequest request) {
Optional<String> versionOption = request.queryParam("v");
if (versionOption.isEmpty()) {
return pluginService.generateJsBundleVersion()
.flatMap(v -> ServerResponse
.temporaryRedirect(buildJsBundleUri("js", v))
.build()
);
}
return pluginService.uglifyJsBundle()
.defaultIfEmpty("")
.flatMap(bundle -> ServerResponse.ok()
.contentType(MediaType.valueOf("text/javascript"))
.bodyValue(bundle)
);
}
private Mono<ServerResponse> fetchCssBundle(ServerRequest request) {
Optional<String> versionOption = request.queryParam("v");
if (versionOption.isEmpty()) {
return pluginService.generateJsBundleVersion()
.flatMap(v -> ServerResponse
.temporaryRedirect(buildJsBundleUri("css", v))
.build()
);
}
return pluginService.uglifyCssBundle()
.flatMap(bundle -> ServerResponse.ok()
.contentType(MediaType.valueOf("text/css"))
.bodyValue(bundle)
);
}
URI buildJsBundleUri(String type, String version) {
return URI.create(
"/apis/api.console.halo.run/v1alpha1/plugins/-/bundle." + type + "?v=" + version);
}
private Mono<ServerResponse> upgradeFromUri(ServerRequest request) {
var name = request.pathVariable("name");
var content = request.bodyToMono(UpgradeFromUriRequest.class)

View File

@ -40,4 +40,26 @@ public interface PluginService {
* @see run.halo.app.plugin.HaloPluginManager#reloadPlugin(String)
*/
Mono<Plugin> reload(String name);
/**
* Uglify js bundle from all enabled plugins to a single js bundle string.
*
* @return uglified js bundle
*/
Mono<String> uglifyJsBundle();
/**
* Uglify css bundle from all enabled plugins to a single css bundle string.
*
* @return uglified css bundle
*/
Mono<String> uglifyCssBundle();
/**
* <p>Generate js bundle version for cache control.</p>
* This method will list all enabled plugins version and sign it to a string.
*
* @return signed js bundle version by all enabled plugins version.
*/
Mono<String> generateJsBundleVersion();
}

View File

@ -3,12 +3,18 @@ package run.halo.app.core.extension.service.impl;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import com.github.zafarkhaja.semver.Version;
import com.google.common.hash.Hashing;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.Validate;
@ -37,6 +43,7 @@ import run.halo.app.plugin.PluginConst;
import run.halo.app.plugin.PluginProperties;
import run.halo.app.plugin.PluginUtils;
import run.halo.app.plugin.YamlPluginFinder;
import run.halo.app.plugin.resources.BundleResourceUtils;
@Slf4j
@Component
@ -124,6 +131,70 @@ public class PluginServiceImpl implements PluginService {
return updateReloadAnno(name, pluginWrapper.getPluginPath());
}
@Override
public Mono<String> uglifyJsBundle() {
return Mono.fromSupplier(() -> {
StringBuilder jsBundle = new StringBuilder();
List<String> pluginNames = new ArrayList<>();
for (PluginWrapper pluginWrapper : pluginManager.getStartedPlugins()) {
String pluginName = pluginWrapper.getPluginId();
pluginNames.add(pluginName);
Resource jsBundleResource =
BundleResourceUtils.getJsBundleResource(pluginManager, pluginName,
BundleResourceUtils.JS_BUNDLE);
if (jsBundleResource != null) {
try {
jsBundle.append(
jsBundleResource.getContentAsString(StandardCharsets.UTF_8));
jsBundle.append("\n");
} catch (IOException e) {
log.error("Failed to read js bundle of plugin [{}]", pluginName, e);
}
}
}
String plugins = """
this.enabledPluginNames = [%s];
""".formatted(pluginNames.stream()
.collect(Collectors.joining("','", "'", "'")));
return jsBundle + plugins;
});
}
@Override
public Mono<String> uglifyCssBundle() {
return Mono.fromSupplier(() -> {
StringBuilder cssBundle = new StringBuilder();
for (PluginWrapper pluginWrapper : pluginManager.getStartedPlugins()) {
String pluginName = pluginWrapper.getPluginId();
Resource cssBundleResource =
BundleResourceUtils.getJsBundleResource(pluginManager, pluginName,
BundleResourceUtils.CSS_BUNDLE);
if (cssBundleResource != null) {
try {
cssBundle.append(
cssBundleResource.getContentAsString(StandardCharsets.UTF_8));
} catch (IOException e) {
log.error("Failed to read css bundle of plugin [{}]", pluginName, e);
}
}
}
return cssBundle.toString();
});
}
@Override
public Mono<String> generateJsBundleVersion() {
return Mono.fromSupplier(() -> {
var compactVersion = pluginManager.getStartedPlugins()
.stream()
.sorted(Comparator.comparing(PluginWrapper::getPluginId))
.map(pluginWrapper -> pluginWrapper.getDescriptor().getVersion())
.collect(Collectors.joining());
return Hashing.sha256().hashUnencodedChars(compactVersion).toString();
});
}
Mono<Plugin> findPluginManifest(Path path) {
return Mono.fromSupplier(
() -> {

View File

@ -19,8 +19,8 @@ import run.halo.app.plugin.PluginConst;
*/
public abstract class BundleResourceUtils {
private static final String CONSOLE_BUNDLE_LOCATION = "console";
private static final String JS_BUNDLE = "main.js";
private static final String CSS_BUNDLE = "style.css";
public static final String JS_BUNDLE = "main.js";
public static final String CSS_BUNDLE = "style.css";
/**
* Gets plugin css bundle resource path relative to the plugin classpath if exists.

View File

@ -22,6 +22,10 @@ rules:
- apiGroups: [ "api.console.halo.run" ]
resources: [ "auth-providers" ]
verbs: [ "list" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "plugins/bundle.js", "plugins/bundle.css" ]
resourceNames: [ "-" ]
verbs: [ "get" ]
---
apiVersion: v1alpha1
kind: "Role"

View File

@ -12,6 +12,7 @@ export default definePlugin({
{
name: "markdown-editor",
displayName: "Markdown",
logo: "logo.png"
component: markRaw(MarkdownEditor),
rawType: "markdown",
},

View File

@ -3,6 +3,7 @@ import type { Component } from "vue";
export interface EditorProvider {
name: string;
displayName: string;
logo?: string;
component: Component;
rawType: string;
}

View File

@ -1,8 +1,6 @@
<script lang="ts" setup>
import {
useEditorExtensionPoints,
type EditorProvider,
} from "@/composables/use-editor-extension-points";
import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points";
import type { EditorProvider } from "@halo-dev/console-shared";
import {
VAvatar,
IconExchange,
@ -51,7 +49,7 @@ const { editorProviders } = useEditorExtensionPoints();
"
@click="emit('select', editorProvider)"
>
<template #prefix-icon>
<template v-if="editorProvider.logo" #prefix-icon>
<VAvatar :src="editorProvider.logo" size="xs"></VAvatar>
</template>
{{ editorProvider.displayName }}

View File

@ -77,7 +77,8 @@ import { useFetchAttachmentPolicy } from "@/modules/contents/attachments/composa
import { useI18n } from "vue-i18n";
import { i18n } from "@/locales";
import { OverlayScrollbarsComponent } from "overlayscrollbars-vue";
import { usePluginModuleStore, type PluginModule } from "@/stores/plugin";
import { usePluginModuleStore } from "@/stores/plugin";
import type { PluginModule } from "@halo-dev/console-shared";
import { useDebounceFn } from "@vueuse/core";
const { t } = useI18n();

View File

@ -1,15 +1,10 @@
import { usePluginModuleStore } from "@/stores/plugin";
import type { EditorProvider as EditorProviderRaw } from "@halo-dev/console-shared";
import type { PluginModule } from "@/stores/plugin";
import type { EditorProvider, PluginModule } from "@halo-dev/console-shared";
import { onMounted, ref, type Ref, defineAsyncComponent } from "vue";
import { VLoading } from "@halo-dev/components";
import Logo from "@/assets/logo.png";
import { useI18n } from "vue-i18n";
export interface EditorProvider extends EditorProviderRaw {
logo?: string;
}
interface useEditorExtensionPointsReturn {
editorProviders: Ref<EditorProvider[]>;
}
@ -42,14 +37,7 @@ export function useEditorExtensionPoints(): useEditorExtensionPointsReturn {
const providers = extensionPoints["editor:create"]() as EditorProvider[];
if (providers) {
providers.forEach((provider) => {
editorProviders.value.push({
...provider,
logo: pluginModule.extension.status?.logo,
});
});
}
editorProviders.value.push(...providers);
});
});

View File

@ -786,8 +786,8 @@ core:
last_starttime: Last Start Time
loader:
toast:
entry_load_failed: "{name}: Failed to load plugin entry file"
style_load_failed: "{name}: Failed to load plugin stylesheet file"
entry_load_failed: "Failed to load plugins entry file"
style_load_failed: "Failed to load plugins stylesheet file"
extension_points:
editor:
providers:

View File

@ -786,8 +786,8 @@ core:
last_starttime: 最近一次启动
loader:
toast:
entry_load_failed: "{name}加载插件入口文件失败"
style_load_failed: "{name}加载插件样式文件失败"
entry_load_failed: "加载插件入口文件失败"
style_load_failed: "加载插件样式文件失败"
extension_points:
editor:
providers:

View File

@ -786,8 +786,8 @@ core:
last_starttime: 最近一次啟動
loader:
toast:
entry_load_failed: "{name}讀取插件入口文件失敗"
style_load_failed: "{name}讀取插件樣式文件失敗"
entry_load_failed: "讀取插件入口文件失敗"
style_load_failed: "讀取插件樣式文件失敗"
extension_points:
editor:
providers:

View File

@ -3,25 +3,20 @@ import type { DirectiveBinding } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import type { PluginModule, RouteRecordAppend } from "@halo-dev/console-shared";
import { Toast } from "@halo-dev/components";
import { apiClient } from "@/utils/api-client";
// setup
import "./setup/setupStyles";
import { setupComponents } from "./setup/setupComponents";
import { setupI18n, i18n, getBrowserLanguage } from "./locales";
// core modules
import { coreModules } from "./modules";
import { useScriptTag } from "@vueuse/core";
import { usePluginModuleStore } from "@/stores/plugin";
import { hasPermission } from "@/utils/permission";
import { useRoleStore } from "@/stores/role";
import type { RouteRecordRaw } from "vue-router";
import { useThemeStore } from "./stores/theme";
import { useUserStore } from "./stores/user";
import { useSystemConfigMapStore } from "./stores/system-configmap";
import { setupVueQuery } from "./setup/setupVueQuery";
import { useGlobalInfoStore } from "./stores/global-info";
import { setupCoreModules, setupPluginModules } from "./setup/setupModules";
const app = createApp(App);
@ -31,167 +26,6 @@ setupVueQuery(app);
app.use(createPinia());
function registerModule(pluginModule: PluginModule, core: boolean) {
if (pluginModule.components) {
Object.keys(pluginModule.components).forEach((key) => {
const component = pluginModule.components?.[key];
if (component) {
app.component(key, component);
}
});
}
if (pluginModule.routes) {
if (!Array.isArray(pluginModule.routes)) {
return;
}
resetRouteMeta(pluginModule.routes);
for (const route of pluginModule.routes) {
if ("parentName" in route) {
router.addRoute(route.parentName, route.route);
} else {
router.addRoute(route);
}
}
}
function resetRouteMeta(routes: RouteRecordRaw[] | RouteRecordAppend[]) {
for (const route of routes) {
if ("parentName" in route) {
if (route.route.meta?.menu) {
route.route.meta = {
...route.route.meta,
core,
};
}
if (route.route.children) {
resetRouteMeta(route.route.children);
}
} else {
if (route.meta?.menu) {
route.meta = {
...route.meta,
core,
};
}
if (route.children) {
resetRouteMeta(route.children);
}
}
}
}
}
function loadCoreModules() {
coreModules.forEach((module) => {
registerModule(module, true);
});
}
const pluginModuleStore = usePluginModuleStore();
function loadStyle(href: string) {
return new Promise(function (resolve, reject) {
let shouldAppend = false;
let el: HTMLLinkElement | null = document.querySelector(
'script[src="' + href + '"]'
);
if (!el) {
el = document.createElement("link");
el.rel = "stylesheet";
el.type = "text/css";
el.href = href;
shouldAppend = true;
} else if (el.hasAttribute("data-loaded")) {
resolve(el);
return;
}
el.addEventListener("error", reject);
el.addEventListener("abort", reject);
el.addEventListener("load", function loadStyleHandler() {
el?.setAttribute("data-loaded", "true");
resolve(el);
});
if (shouldAppend) document.head.prepend(el);
});
}
const pluginErrorMessages: Array<string> = [];
async function loadPluginModules() {
const { data } = await apiClient.plugin.listPlugins(
{
enabled: true,
page: 0,
size: 0,
},
{ mute: true }
);
// Get all started plugins
const plugins = data.items.filter((plugin) => {
const { entry, stylesheet } = plugin.status || {};
return plugin.status?.phase === "STARTED" && (!!entry || !!stylesheet);
});
for (const plugin of plugins) {
const { entry, stylesheet } = plugin.status || {
entry: "",
stylesheet: "",
};
if (entry) {
try {
const { load } = useScriptTag(
`${import.meta.env.VITE_API_URL}${plugin.status?.entry}`
);
await load();
const pluginModule = window[plugin.metadata.name];
if (pluginModule) {
registerModule(pluginModule, false);
pluginModuleStore.registerPluginModule({
...pluginModule,
extension: plugin,
});
}
} catch (e) {
const message = i18n.global.t(
"core.plugin.loader.toast.entry_load_failed",
{ name: plugin.spec.displayName }
);
console.error(message, e);
pluginErrorMessages.push(message);
}
}
if (stylesheet) {
try {
await loadStyle(`${import.meta.env.VITE_API_URL}${stylesheet}`);
} catch (e) {
const message = i18n.global.t(
"core.plugin.loader.toast.style_load_failed",
{ name: plugin.spec.displayName }
);
console.error(message, e);
pluginErrorMessages.push(message);
}
}
}
if (pluginErrorMessages.length > 0) {
pluginErrorMessages.forEach((message) => {
Toast.error(message);
});
}
}
async function loadUserPermissions() {
const { data: currentPermissions } = await apiClient.user.getPermissions({
name: "-",
@ -239,7 +73,7 @@ async function initApp() {
}
try {
loadCoreModules();
setupCoreModules(app);
const userStore = useUserStore();
await userStore.fetchCurrentUser();
@ -258,7 +92,7 @@ async function initApp() {
await loadUserPermissions();
try {
await loadPluginModules();
await setupPluginModules(app);
} catch (e) {
console.error("Failed to load plugins", e);
}

View File

@ -31,7 +31,8 @@ import cloneDeep from "lodash.clonedeep";
import { usePermission } from "@/utils/permission";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
import { usePluginModuleStore, type PluginModule } from "@/stores/plugin";
import { usePluginModuleStore } from "@/stores/plugin";
import type { PluginModule } from "@halo-dev/console-shared";
import type {
CommentSubjectRefProvider,
CommentSubjectRefResult,

View File

@ -28,10 +28,8 @@ import cloneDeep from "lodash.clonedeep";
import { useRouter } from "vue-router";
import { randomUUID } from "@/utils/id";
import { useContentCache } from "@/composables/use-content-cache";
import {
useEditorExtensionPoints,
type EditorProvider,
} from "@/composables/use-editor-extension-points";
import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points";
import type { EditorProvider } from "@halo-dev/console-shared";
import { useLocalStorage } from "@vueuse/core";
import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue";
import { useI18n } from "vue-i18n";

View File

@ -28,10 +28,8 @@ import { useRouteQuery } from "@vueuse/router";
import { useRouter } from "vue-router";
import { randomUUID } from "@/utils/id";
import { useContentCache } from "@/composables/use-content-cache";
import {
useEditorExtensionPoints,
type EditorProvider,
} from "@/composables/use-editor-extension-points";
import { useEditorExtensionPoints } from "@/composables/use-editor-extension-points";
import type { EditorProvider } from "@halo-dev/console-shared";
import { useLocalStorage } from "@vueuse/core";
import EditorProviderSelector from "@/components/dropdown-selector/EditorProviderSelector.vue";
import { useI18n } from "vue-i18n";

View File

@ -17,11 +17,11 @@ import { usePermission } from "@/utils/permission";
import { useI18n } from "vue-i18n";
import { useQuery } from "@tanstack/vue-query";
import type { PluginTab } from "@halo-dev/console-shared";
import { usePluginModuleStore } from "@/stores/plugin";
import { markRaw } from "vue";
import DetailTab from "./tabs/Detail.vue";
import SettingTab from "./tabs/Setting.vue";
import { useRouteQuery } from "@vueuse/router";
import { usePluginModuleStore } from "@/stores/plugin";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
@ -89,10 +89,9 @@ const { data: setting } = useQuery({
provide<Ref<Setting | undefined>>("setting", setting);
onMounted(() => {
const { pluginModules } = usePluginModuleStore();
const currentPluginModule = pluginModules.find(
(item) => item.extension.metadata.name === route.params.name
);
const { pluginModuleMap } = usePluginModuleStore();
const currentPluginModule = pluginModuleMap[route.params.name as string];
if (!currentPluginModule) {
return;

View File

@ -0,0 +1,109 @@
import { i18n } from "@/locales";
import { coreModules } from "@/modules";
import router from "@/router";
import { usePluginModuleStore } from "@/stores/plugin";
import type { PluginModule, RouteRecordAppend } from "@halo-dev/console-shared";
import { useScriptTag } from "@vueuse/core";
import { Toast } from "@halo-dev/components";
import type { App } from "vue";
import type { RouteRecordRaw } from "vue-router";
import { loadStyle } from "@/utils/load-style";
export function setupCoreModules(app: App) {
coreModules.forEach((module) => {
registerModule(app, module, true);
});
}
export async function setupPluginModules(app: App) {
const pluginModuleStore = usePluginModuleStore();
try {
const { load } = useScriptTag(
`${
import.meta.env.VITE_API_URL
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js`
);
await load();
const enabledPluginNames = window["enabledPluginNames"] as string[];
enabledPluginNames.forEach((name) => {
const module = window[name];
if (module) {
registerModule(app, module, false);
pluginModuleStore.registerPluginModule(name, module);
}
});
} catch (e) {
const message = i18n.global.t("core.plugin.loader.toast.entry_load_failed");
console.error(message, e);
Toast.error(message);
}
try {
await loadStyle(
`${
import.meta.env.VITE_API_URL
}/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css`
);
} catch (e) {
const message = i18n.global.t("core.plugin.loader.toast.style_load_failed");
console.error(message, e);
Toast.error(message);
}
}
function registerModule(app: App, pluginModule: PluginModule, core: boolean) {
if (pluginModule.components) {
Object.keys(pluginModule.components).forEach((key) => {
const component = pluginModule.components?.[key];
if (component) {
app.component(key, component);
}
});
}
if (pluginModule.routes) {
if (!Array.isArray(pluginModule.routes)) {
return;
}
resetRouteMeta(pluginModule.routes);
for (const route of pluginModule.routes) {
if ("parentName" in route) {
router.addRoute(route.parentName, route.route);
} else {
router.addRoute(route);
}
}
}
function resetRouteMeta(routes: RouteRecordRaw[] | RouteRecordAppend[]) {
for (const route of routes) {
if ("parentName" in route) {
if (route.route.meta?.menu) {
route.route.meta = {
...route.route.meta,
core,
};
}
if (route.route.children) {
resetRouteMeta(route.route.children);
}
} else {
if (route.meta?.menu) {
route.meta = {
...route.meta,
core,
};
}
if (route.children) {
resetRouteMeta(route.children);
}
}
}
}
}

View File

@ -1,22 +1,17 @@
import { defineStore } from "pinia";
import type { PluginModule as PluginModuleRaw } from "@halo-dev/console-shared";
import type { Plugin } from "@halo-dev/api-client";
import type { PluginModule } from "@halo-dev/console-shared";
import { computed, ref } from "vue";
export interface PluginModule extends PluginModuleRaw {
extension: Plugin;
}
export const usePluginModuleStore = defineStore("plugin", () => {
const pluginModuleMap = ref<Record<string, PluginModule>>({});
interface PluginStoreState {
pluginModules: PluginModule[];
}
function registerPluginModule(name: string, pluginModule: PluginModule) {
pluginModuleMap.value[name] = pluginModule;
}
export const usePluginModuleStore = defineStore("plugin", {
state: (): PluginStoreState => ({
pluginModules: [],
}),
actions: {
registerPluginModule(pluginModule: PluginModule) {
this.pluginModules.push(pluginModule);
},
},
const pluginModules = computed(() => {
return Object.values(pluginModuleMap.value);
});
return { pluginModuleMap, pluginModules, registerPluginModule };
});

View File

@ -0,0 +1,27 @@
export function loadStyle(href: string) {
return new Promise(function (resolve, reject) {
let shouldAppend = false;
let el: HTMLLinkElement | null = document.querySelector(
'script[src="' + href + '"]'
);
if (!el) {
el = document.createElement("link");
el.rel = "stylesheet";
el.type = "text/css";
el.href = href;
shouldAppend = true;
} else if (el.hasAttribute("data-loaded")) {
resolve(el);
return;
}
el.addEventListener("error", reject);
el.addEventListener("abort", reject);
el.addEventListener("load", function loadStyleHandler() {
el?.setAttribute("data-loaded", "true");
resolve(el);
});
if (shouldAppend) document.head.prepend(el);
});
}