From 870713ca047474b004ec27b618eeb26e16428b61 Mon Sep 17 00:00:00 2001 From: Odin Thomas Rochmann Date: Thu, 12 Sep 2024 11:02:31 +0200 Subject: [PATCH] feat(cli): create plugin for handling application api calls (#2448) - generate manifest, config and source code when local app - proxy app-service calls when app miss-match --- .changeset/quiet-scissors-breathe.md | 66 +++++++++ packages/cli/src/bin/plugins/app-proxy.ts | 167 ++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 .changeset/quiet-scissors-breathe.md create mode 100644 packages/cli/src/bin/plugins/app-proxy.ts diff --git a/.changeset/quiet-scissors-breathe.md b/.changeset/quiet-scissors-breathe.md new file mode 100644 index 000000000..750399e88 --- /dev/null +++ b/.changeset/quiet-scissors-breathe.md @@ -0,0 +1,66 @@ +--- +'@equinor/fusion-framework-cli': minor +--- + +**@equinor/fusion-framework-cli** + +The `appProxyPlugin` is a Vite plugin designed to proxy requests to a Fusion app backend. +It sets up proxy rules for API and bundle requests and serves the app configuration and manifest based on the app key and version. + +Key Features: + +1. Proxy Configuration: + + - Proxies API calls to the Fusion apps backend. + - Proxies bundle requests to the Fusion apps backend. + - Uses a base path `proxyPath` for proxying. + - Captures and reuses authorization tokens for asset requests. + +2. **App Configuration and Manifest**: + + - Serves the app configuration if the request matches the current app and version. + - Serves the app manifest if the request matches the current app. + +3. **Middleware Setup**: + - Sets up middleware to handle requests for app configuration, manifest, and local bundles. + +This plugin is used by the CLI for local development, but design as exportable for custom CLI to consume applications from other API`s + +example configuration: +```typescript +const viteConfig = defineConfig({ + // vite configuration + plugins: [ + appProxyPlugin({ + proxy: { + path: '/app-proxy', + target: 'https://fusion-s-apps-ci.azurewebsites.net/', + // optional callback when matched request is proxied + onProxyReq: (proxyReq, req, res) => { + proxyReq.on('response', (res) => { console.log(res.statusCode) }); + }, + }, + // optional, but required for serving local app configuration, manifest and resources + app: { + key: 'my-app', + version: '1.0.0', + generateConfig: async () => ({}), + generateManifest: async () => ({}), + }, + }), + ], +}); +``` + +example usage: +```typescript +// Example API calls +fetch('/app-proxy/apps/my-app/builds/1.0.0/config'); // local +fetch('/app-proxy/apps/my-app/builds/0.0.9/config'); // proxy +fetch('/app-proxy/apps/other-app/builds/1.0.0/config'); // proxy + +// Example asset calls +fetch('/app-proxy/bundles/my-app/builds/1.0.0/index.js'); // local +fetch('/app-proxy/bundles/my-app/builds/0.0.9/index.js'); // proxy +``` + diff --git a/packages/cli/src/bin/plugins/app-proxy.ts b/packages/cli/src/bin/plugins/app-proxy.ts new file mode 100644 index 000000000..211085f8b --- /dev/null +++ b/packages/cli/src/bin/plugins/app-proxy.ts @@ -0,0 +1,167 @@ +import { Plugin } from 'vite'; + +import { assert } from 'node:console'; + +import { AppConfig, ApplicationManifest } from '@equinor/fusion-framework-app'; +import { ClientRequest, IncomingMessage, ServerResponse } from 'node:http'; + +/** + * Preserve token for executing proxy assets + * + * @remarks + * This assumes the client will execute a api call using bearer token before + * acquiring a asset. By default the Framework will execute a rest call to load + * application manifest for resolving build assets to import. + * + * @remarks + * This is a quick and dirty method to authorize requests without bearer token + * like browser `import`. + * The correct way would be to have a auth controller within the dev-server, + * but since the token is only exposed to the plugin and the cli is a tool for local + * development, this should be sufficient. + */ +let __APP_API_TOKEN__ = ''; + +/** + * Options for configuring the App Proxy Plugin. + * + * @remarks + * When not providing an app configuration, the plugin will only proxy requests to the target. + */ +export type AppProxyPluginOptions = { + /** Configuration for the proxy. */ + proxy: { + /** The path to be proxied. */ + path: string; + /** The target URL for the proxy. */ + target: string; + /** Optional callback function to modify the proxy request. */ + onProxyReq?: (proxyReq: ClientRequest, req: IncomingMessage, res: ServerResponse) => void; + }; + /** Optional configuration for the app. */ + app?: { + /** application key */ + key: string; + /** application version */ + version: string; + /** callback function for generating configuration for the application */ + generateConfig: () => Promise; + /** callback function for generating manifest for the application */ + generateManifest: () => Promise; + }; +}; + +/** + * The `appProxyPlugin` function creates a Vite plugin that configures a proxy for API and bundle requests + * to the Fusion apps backend. It also serves the app manifest, config, and local bundles if an app is provided. + * + * @param {AppProxyPluginOptions} options - The options for configuring the app proxy plugin. + * + * @returns {Plugin} - The configured Vite plugin. + * + * @example + * ```typescript + * const plugin = appProxyPlugin({ + * proxy: { + * path: '/app-proxy', + * target: 'https://fusion-s-apps-ci.azurewebsites.net/', + * onProxyReq: (proxyReq, req, res) => { + * proxyReq.on('response', (res) => { console.log(res.statusCode) }); + * }, + * }, + * app: { + * key: 'my-app', + * version: '1.0.0', + * generateConfig: async () => ({}), + * generateManifest: async () => ({}), + * }, + * }); + * + * // api calls + * fetch('/app-proxy/apps/my-app/builds/1.0.0/config'); // will generate app config by provided function + * fetch('/app-proxy/apps/my-app/builds/0.0.9/config'); // will proxy to the target, since version does not match + * fetch('/app-proxy/apps/other-app/builds/1.0.0/config'); // will proxy to the target, since app key does not match + * + * // asset calls + * fetch('/app-proxy/bundles/my-app/builds/1.0.0/index.js'); // will generate bundle by provided function + * fetch('/app-proxy/bundles/my-app/builds/0.0.9/index.js'); // will proxy to the target, since version does not match + * ``` + * + */ +export const appProxyPlugin = (options: AppProxyPluginOptions): Plugin => { + const { + proxy: { onProxyReq = () => void 0, path: proxyPath, target }, + } = options; + return { + name: 'fusion:app-proxy', + apply: 'serve', + config(config) { + config.server ??= {}; + config.server.proxy = { + // proxy all api calls to the fusion apps backend + [`${proxyPath}/apps`]: { + target, + changeOrigin: true, + secure: false, + rewrite: (path) => path.replace(proxyPath, ''), + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + const token = proxyReq.getHeader('authorization'); + if (typeof token === 'string') { + __APP_API_TOKEN__ = token; + } + }); + proxy.on('proxyReq', onProxyReq); + }, + }, + // proxy all bundle requests to the fusion apps backend + [`${proxyPath}/bundles`]: { + target, + changeOrigin: true, + secure: false, + rewrite: (path) => path.replace(proxyPath, ''), + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + assert(__APP_API_TOKEN__, 'expected token to be set'); + proxyReq.setHeader('authorization', __APP_API_TOKEN__); + }); + proxy.on('proxyReq', onProxyReq); + }, + }, + }; + }, + configureServer(server) { + const { app } = options; + + // disable local assets if no app configuration provided + if (!app) return; + + // serve app manifest if request matches the current app + // todo this should have version + const manifestPath = `${proxyPath}/apps/${app.key}`; + server.middlewares.use(manifestPath, async (_req, res) => { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(await app.generateManifest())); + }); + + // serve app config if request matches the current app and version + const configPath = `${proxyPath}/apps/${app.key}/builds/${app.version}/config`; + server.middlewares.use(configPath, async (_req, res) => { + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(await app.generateConfig())); + }); + + // serve local bundles if request matches the current app and version + const bundlePath = `${proxyPath}/bundles/apps/${app.key}@${app.version}`; + server.middlewares.use(async (req, _res, next) => { + if (req.url?.match(bundlePath)) { + // remove proxy path from url + req.url = req.url!.replace(bundlePath, ''); + } + next(); + }); + }, + }; +}; + +export default appProxyPlugin;