anbaric-cloud-hosting 1.6.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -38,6 +38,11 @@ export * from "./hosting/handlers/DocumentsHandler";
38
38
  export * from "./hosting/handlers/JobsHandler";
39
39
  export * from "./hosting/handlers/PagesHandler";
40
40
  export * from "./hosting/handlers/PingHandler";
41
+ export * from "./hosting/handlers/PluginsHandler";
42
+ export * from "./plugins/Plugin";
43
+ export * from "./plugins/PluginBundler";
44
+ export * from "./plugins/PluginLoader";
45
+ export * from "./plugins/PageDirectory";
41
46
  export * from "./hosting/handlers/QueueHandler";
42
47
  export * from "./hosting/handlers/SecretsHandler";
43
48
  export * from "./hosting/handlers/StateMachinesHandler";
package/src/main.ts CHANGED
@@ -18,6 +18,7 @@ import {FargateBuildLayer} from "./app-management/FargateBuildLayer";
18
18
  import {ConsumerRegistry} from "./queuing/ConsumerRegistry";
19
19
  import {Dispatcher} from "./queuing/Dispatcher";
20
20
  import {HostingServer} from "./hosting/HostingServer";
21
+ import {PluginLoader} from "./plugins/PluginLoader";
21
22
 
22
23
  const pool = new Pool({ connectionString: process.env.ANBARIC_DATABASE_URL });
23
24
  await ensureSchema(pool);
@@ -64,9 +65,11 @@ const cliKeyStore = process.env.ANBARIC_CLI_KEY_LOOKUP_URL
64
65
  const cliAuthorizer = new CliAuthorizer(cliKeyStore);
65
66
  const tokenAuthenticator = new TokenAuthenticator(cliKeyStore, process.env.ANBARIC_TENANT);
66
67
 
68
+ const plugins = await new PluginLoader().load(process.env.ANBARIC_PLUGINS ?? "anbaric-plugins/state-machines");
69
+
67
70
  const server = new HostingServer(new PostgresJobPersistence(pool), queue, registry, buildLayer,
68
71
  (collection) => new PostgresJsonStore(pool, collection), secretStore, authenticator, cliAuthorizer,
69
- tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool));
72
+ tokenAuthenticator, process.env.ANBARIC_TENANT, new PostgresAuditRecordStore(pool), plugins);
70
73
  const port = await server.listen(hostingPort);
71
74
  const internal = await server.listenInternal(internalPort);
72
75
 
@@ -0,0 +1,35 @@
1
+ import {LoadedPlugin, PluginPage} from "./Plugin";
2
+
3
+ /* The merged view of every page the loaded plugins register: feeds the nav
4
+ manifest and tells the hosting server which top-level path segments must
5
+ serve the dashboard ahead of the app proxy fallback. */
6
+ class PageDirectory {
7
+
8
+ private pagesByPath = new Map<string, PluginPage>();
9
+
10
+ constructor(plugins : Array<LoadedPlugin>) {
11
+ for (const loaded of plugins) {
12
+ for (const page of loaded.plugin.pages) {
13
+ if (this.pagesByPath.has(page.path)) {
14
+ throw new Error(`Two plugins register the page path "${page.path}"`);
15
+ }
16
+ this.pagesByPath.set(page.path, page);
17
+ }
18
+ }
19
+ }
20
+
21
+ get pages() : Array<PluginPage> {
22
+ return [...this.pagesByPath.values()]
23
+ .sort((left, right) => (left.navOrder ?? 100) - (right.navOrder ?? 100) || left.title.localeCompare(right.title));
24
+ }
25
+
26
+ get topLevelSegments() : Array<string> {
27
+ const segments = this.pages
28
+ .map(page => page.path.split("/").filter(Boolean)[0])
29
+ .filter((segment) : segment is string => segment !== undefined);
30
+ return [...new Set(segments)];
31
+ }
32
+
33
+ }
34
+
35
+ export { PageDirectory }
@@ -0,0 +1,39 @@
1
+ type PluginComponent = (properties : any) => unknown;
2
+
3
+ type PluginPage = {
4
+
5
+ path : string;
6
+ title : string;
7
+ icon? : string;
8
+ navOrder? : number;
9
+
10
+ };
11
+
12
+ type PluginWidget = {
13
+
14
+ page : string;
15
+ id : string;
16
+ title? : string;
17
+ position? : number;
18
+ component : PluginComponent;
19
+ data? : (parameters : Record<string, string>) => Promise<unknown>;
20
+
21
+ };
22
+
23
+ type Plugin = {
24
+
25
+ name : string;
26
+ pages : Array<PluginPage>;
27
+ widgets : Array<PluginWidget>;
28
+
29
+ };
30
+
31
+ type LoadedPlugin = {
32
+
33
+ name : string;
34
+ plugin : Plugin;
35
+ bundle : string;
36
+
37
+ };
38
+
39
+ export { Plugin, PluginPage, PluginWidget, PluginComponent, LoadedPlugin }
@@ -0,0 +1,75 @@
1
+ import {createRequire} from "node:module";
2
+ import {build, Plugin as EsbuildPlugin} from "esbuild";
3
+
4
+ /* Compiles a plugin module twice: a browser ESM bundle whose react,
5
+ react-dom and design-system imports are aliased to the dashboard's
6
+ window.AnbaricPluginRuntime global, and a node bundle whose UI imports
7
+ are replaced with inert stubs so the module can be imported server-side
8
+ (for page metadata and widget data functions) without a DOM or CSS
9
+ loader. */
10
+ class PluginBundler {
11
+
12
+ private resolve = createRequire(import.meta.url).resolve;
13
+
14
+ async bundleForBrowser(moduleName : string) : Promise<string> {
15
+ return this.bundle(moduleName, {
16
+ platform: "browser",
17
+ plugins: [this.replacementPlugin(browserReplacement)],
18
+ });
19
+ }
20
+
21
+ async bundleForServer(moduleName : string) : Promise<string> {
22
+ return this.bundle(moduleName, {
23
+ platform: "node",
24
+ packages: "external",
25
+ plugins: [this.replacementPlugin(serverReplacement)],
26
+ });
27
+ }
28
+
29
+ private async bundle(moduleName : string, options : object) : Promise<string> {
30
+ const result = await build({
31
+ entryPoints: [this.resolve(moduleName)],
32
+ bundle: true,
33
+ format: "esm",
34
+ jsx: "automatic",
35
+ write: false,
36
+ logLevel: "silent",
37
+ ...options,
38
+ });
39
+ return result.outputFiles[0].text;
40
+ }
41
+
42
+ private replacementPlugin(replacement : (path : string) => string) : EsbuildPlugin {
43
+ return {
44
+ name: "anbaric-plugin-runtime",
45
+ setup(pluginBuild) {
46
+ pluginBuild.onResolve({ filter: /^react(-dom)?(\/|$)|^@anbaric\/design-system(\/|$)/ }, resolving =>
47
+ ({ path: resolving.path, namespace: "anbaric-runtime" }));
48
+ pluginBuild.onResolve({ filter: /\.css$/ }, resolving =>
49
+ ({ path: resolving.path, namespace: "anbaric-runtime" }));
50
+ pluginBuild.onLoad({ filter: /.*/, namespace: "anbaric-runtime" }, loading =>
51
+ ({ contents: replacement(loading.path), loader: "js" }));
52
+ },
53
+ };
54
+ }
55
+
56
+ }
57
+
58
+ const browserReplacement = (path : string) : string => {
59
+ if (path.endsWith(".css")) return "";
60
+ if (path === "react/jsx-runtime" || path === "react/jsx-dev-runtime") {
61
+ return "module.exports = window.AnbaricPluginRuntime.jsxRuntime;";
62
+ }
63
+ if (path === "react" || path.startsWith("react/")) {
64
+ return "module.exports = window.AnbaricPluginRuntime.React;";
65
+ }
66
+ if (path === "react-dom" || path.startsWith("react-dom/")) {
67
+ return "module.exports = window.AnbaricPluginRuntime.ReactDOM;";
68
+ }
69
+ return "module.exports = window.AnbaricPluginRuntime.DesignSystem;";
70
+ };
71
+
72
+ const serverReplacement = (path : string) : string =>
73
+ path.endsWith(".css") ? "" : "module.exports = {};";
74
+
75
+ export { PluginBundler }
@@ -0,0 +1,72 @@
1
+ import {createHash} from "node:crypto";
2
+ import {mkdir, writeFile} from "node:fs/promises";
3
+ import {pathToFileURL} from "node:url";
4
+ import {LoadedPlugin, Plugin} from "./Plugin";
5
+ import {PluginBundler} from "./PluginBundler";
6
+
7
+ /* Loads the comma-separated ANBARIC_PLUGINS module list: each module is
8
+ compiled for the server and imported to obtain its plugin export (pages,
9
+ widgets, data functions), and compiled for the browser into the bundle
10
+ the dashboard fetches from /plugins/<name>.js. The server compilation is
11
+ written inside node_modules so any imports the plugin left external still
12
+ resolve when the module is evaluated. */
13
+ class PluginLoader {
14
+
15
+ constructor(private bundler : PluginBundler = new PluginBundler(),
16
+ private cacheDirectory : string = `${process.cwd()}/node_modules/.anbaric-plugins`) {
17
+ }
18
+
19
+ async load(specification? : string) : Promise<Array<LoadedPlugin>> {
20
+ if (!specification) return [];
21
+ const moduleNames = specification.split(",").map(name => name.trim()).filter(Boolean);
22
+ const loaded = await Promise.all(moduleNames.map(moduleName => this.loadOne(moduleName)));
23
+ for (const plugin of loaded) {
24
+ if (loaded.some(other => other !== plugin && other.name === plugin.name)) {
25
+ throw new Error(`Two plugin modules register the plugin name "${plugin.name}"`);
26
+ }
27
+ }
28
+ return loaded;
29
+ }
30
+
31
+ private async loadOne(moduleName : string) : Promise<LoadedPlugin> {
32
+ const plugin = await this.importForServer(moduleName);
33
+ const bundle = await this.bundler.bundleForBrowser(moduleName);
34
+ return { name: plugin.name, plugin, bundle };
35
+ }
36
+
37
+ private async importForServer(moduleName : string) : Promise<Plugin> {
38
+ const code = await this.bundler.bundleForServer(moduleName);
39
+ const fileName = `${moduleName.replace(/[^a-z0-9-]+/gi, "-")}-${createHash("sha256").update(code).digest("hex").slice(0, 12)}.mjs`;
40
+ await mkdir(this.cacheDirectory, { recursive: true });
41
+ const modulePath = `${this.cacheDirectory}/${fileName}`;
42
+ await writeFile(modulePath, code);
43
+ const module = await import(pathToFileURL(modulePath).href);
44
+ return this.validated(moduleName, module.plugin);
45
+ }
46
+
47
+ private validated(moduleName : string, plugin : any) : Plugin {
48
+ if (!plugin || typeof plugin.name !== "string") {
49
+ throw new Error(`Plugin module "${moduleName}" does not export a plugin with a name`);
50
+ }
51
+ if (!Array.isArray(plugin.pages) || !Array.isArray(plugin.widgets)) {
52
+ throw new Error(`Plugin "${plugin.name}" must declare pages and widgets arrays`);
53
+ }
54
+ for (const page of plugin.pages) {
55
+ if (typeof page.path !== "string" || !page.path.startsWith("/") || typeof page.title !== "string") {
56
+ throw new Error(`Plugin "${plugin.name}" declares a page without a valid path and title`);
57
+ }
58
+ }
59
+ for (const widget of plugin.widgets) {
60
+ if (typeof widget.page !== "string" || typeof widget.id !== "string" || typeof widget.component !== "function") {
61
+ throw new Error(`Plugin "${plugin.name}" declares a widget without a page, id and component`);
62
+ }
63
+ if (widget.data !== undefined && typeof widget.data !== "function") {
64
+ throw new Error(`Plugin "${plugin.name}" widget "${widget.id}" has a data field that is not a function`);
65
+ }
66
+ }
67
+ return plugin;
68
+ }
69
+
70
+ }
71
+
72
+ export { PluginLoader }