Mashroom Utils
Part of Mashroom Server, a Microfrontend Integration Platform.
This package contains some shared Node.js utilities for Mashroom Server plugins.
Shared utils for Mashroom plugins
Part of Mashroom Server, a Microfrontend Integration Platform.
This package contains some shared Node.js utilities for Mashroom Server plugins.
:root {
--mashroom-portal-color-primary: green;
}
Example plugin config:{
"Mashroom Portal Default Theme": {
"showEnvAndVersions": false,
"showPortalAppHeaders": false,
"spaMode": true,
"darkMode": "auto",
"styleFile": "./defaultThemeOverrides.css"
}
}
Portal: The server-side rendering bootstrap can now also return some script that needs to be injected into the head, which is useful if you want to pass some loaded data to the client-side for hydration. E.g.:
const ssrBootstrap: MashroomPortalAppPluginSSRBootstrapFunction = async (portalAppSetup) => {
const {appId} = portalAppSetup;
// TODO: Load data and render
let someLoadedData = {};
let html = '';
return {
html,
injectHeadScript: `
window['__my_data_${appId}'] = ${JSON.stringify(someLoadedData)};
`
};
};
if (portalAppSetup.serverSideRendered) {
root = hydrateRoot(portalAppHostElement, (
<App />
));
} else {
// Default: render on client side
root = createRoot(portalAppHostElement);
root.render((
<App />
));
}
// Before
{
"name": "My Portal Page Enhancement",
"type": "portal-page-enhancement",
"pageResources": {
"js": [{
"path": "test.js",
"location": "header"
}]
},
"defaultConfig": {
"resourcesRoot": "./dist/backend/page-enhancements",
"order": "500"
}
}
// Now
{
"name": "My Portal Page Enhancement",
"type": "portal-page-enhancement",
"resourcesRoot": "./dist/backend/page-enhancements",
"pageResources": {
"js": [{
"path": "test.js",
"location": "header"
}]
},
"defaultConfig": {
"order": "500"
}
}
The old way is still supported for the moment.Portal: It is now possible to configure what should happen if the authentication expires. Until now the strategy was to just reload the current page, now you can choose between multiple strategies:
BREAKING CHANGE: Mashroom Portal WebApp plugin properties warnBeforeAuthenticationExpiresSec and autoExtendAuthentication have been removed, instead it expects a configuration like this:
"authenticationExpiration": {
"warnBeforeExpirationSec": 60,
"autoExtend": false,
"onExpiration": {
"strategy": "reload"
}
},
export default class MyInterceptor implements MashroomHttpProxyInterceptor {
async interceptRequest(targetUri) {
if (targetUri.startsWith('https://my-backend-server.com')) {
return {
addHeaders: {
'content-encoding': 'gzip',
},
streamTransformers: [
zlib.createGzip(),
],
};
}
}
async interceptResponse(targetUri, existingHeaders) {
if (targetUri.startsWith('https://my-backend-server.com') && existingHeaders['content-encoding'] === 'gzip') {
return {
removeHeaders: [
'content-encoding',
],
streamTransformers: [
zlib.createGunzip(),
],
};
}
}
}
Metrics Collector: Uses now OpenTelemetry to gather and export metrics. Changes:
Due to the API structure of OpenTelemetry there are also BREAKING CHANGES if you use the metrics collector service in your custom plugins:
Asynchronous metrics can be used in the service.addObservableCallback() callback, like so:
const collectorService: MashroomMonitoringMetricsCollectorService = pluginContext.services.metrics.service;
collectorService.addObservableCallback((asyncCollectorService) => {
// ... somehow get the value to measure
asyncCollectorService.gauge('http_pool_active_connections', 'HTTP Pool Active Connections').set(theValue);
});
HTTP Proxy: Added metrics:
BREAKING CHANGE Renamed metrics:
"Mashroom Http Proxy Services": {
"proxyImpl": "nodeHttpProxy"
}
"Mashroom Portal WebApp": {
"resourceFetchConfig": {
"fetchTimeoutMs": 3000,
"httpMaxSocketsPerHost": 10,
"httpRejectUnauthorized": true
}
}
declare module 'express-session' {
interface SessionData {
foo?: string;
}
}
import type {MashroomServerConfig} from '@mashroom/mashroom-json-schemas/type-definitions';
const serverConfig: MashroomServerConfig = {
name: 'Mashroom Test Server 7',
port: 5050,
// ...
];
export default serverConfig;
output: {
// ...
chunkFilename: 'my-app.[contenthash].js',
}
output: {
path: __dirname + '/dist',
filename: 'my_shared_library.js',
chunkFilename: 'my_shared_library.[contenthash].js'
}
Core: Fixed the type of pluginContext.service.<service_ns>: it can now be undefined because the plugin might not be loaded. This can be a BREAKING CHANGE, and you have to following options to fix TypeScript errors:
// If the services is added as "required" in the plugin definition
const requiredService: MashroomSecurityService = pluginContext.services.security!.service;
// Otherwise
const optionalService: MashroomSecurityService | unknown = pluginContext.services.security?.service;
// Alternatively extend MashroomServicePluginNamespaces in a type declaration file
declare module '@mashroom/mashroom/type-definitions' {
export interface MashroomServicePluginNamespaces {
security: { service: MashroomSecurityService; } | /* might not be loaded yet */ undefined;
// Orther service plugins
}
}
export type MashroomPortalAppSSRRemoteRequest = {
readonly originalRequest: {
readonly path: string;
readonly queryParameters: Record<string, any>;
};
readonly portalAppSetup: MashroomPortalAppSetup;
}
Portal: Added support for server-side rendering of Composite Apps, which use other Portal Apps as their building blocks. It is now possible to define embedded Portal Apps in the SSR bootstrap like so:
const bootstrap: MashroomPortalAppPluginSSRBootstrapFunction = async (portalAppSetup, req) => {
// Generate server-side HTML that contains a <div id="unique-host-element-id"></div>
const html = renderToString(<App/>);
return {
html,
embeddedApps: [
{
pluginName: 'The other App',
appConfig: {},
appAreaId: 'unique-host-element-id',
}
]
};
};
In the Composite App make sure you don't call portalAppService.loadApp()
for that already integrated App,
instead you can get the appId of the server-side embedded App like this to unload/reload it later:
const ssrPreloadedApp = portalAppService.loadedPortalApps.find(({ pluginName, portalAppAreaId }) => pluginName === 'The other App' && portalAppAreaId === 'unique-host-element-id');
let appId;
if (!ssrPreloadedApp) {
// SSR failed, load client-side
const result = await portalAppService.loadApp('host-element-id', 'The other App', null, null, {});
if (!result.error) {
appId = result.id;
}
} else {
appId = ssrPreloadedApp.id;
}
Checkout the mashroom-portal-demo-composite-app package for a working example.
NOTE: You have to make sure the embedded Apps aren't removed by the render framework during hydration,
in React you have to add dangerouslySetInnerHTML={{ __html: '' }}
to nodes whose children shall be ignored during hydration
:root {
--mashroom-portal-font-icon: 'Font Awesome 6 Free';
}
MongoDB Session Provider: BREAKING CHANGE: Changed config structure to be able to pass parameters to connect-mongo, such as ttl and autoRemove.
Before:
{
"uri": "mongodb://username:password@localhost:27017/mashroom_session_db?connectTimeoutMS=1000&socketTimeoutMS=2500",
"collection": "sessions",
"connectionOptions": {
"poolSize": 5
}
}
After:
{
"client": {
"uri": "mongodb://username:password@localhost:27017/mashroom_session_db?connectTimeoutMS=1000&socketTimeoutMS=2500",
"connectionOptions": {
"poolSize": 5
}
},
"collectionName": "sessions",
"ttl": 86400
}
Redis Session Provider: BREAKING CHANGE: Changed config structure to be able to pass parameters to connect-redis, such as prefix and ttl. Setting prefix on this level instead of the Redis client level fixed the session count metric, which was broken.
Before:
{
"redisOptions": {
"host": "localhost",
"port": "6379",
"keyPrefix": "mashroom:sess:"
},
"cluster": false
}
After:
{
"client": {
"redisOptions": {
"host": "localhost",
"port": "6379",
},
"cluster": false
},
"prefix": "mashroom:sess:",
"ttl": 86400
}
{
"name": "My Remote App",
"type": "portal-app2",
"remote": {
"resourcesRoot": "/"
},
"defaultConfig": {
"proxies": {
"bff": {
"targetUri": "http://localhost:6089"
}
}
}
}
the Portal will calculate a resource base URL http://yourhost.com/ and a base URL for the bff proxy of http://yourhost.com/,
so they overlap. Now you can request a resource /index.js with this setup, previously you couldn't, because the Portal has treated
it as an attempt to fetch API data via (potentially less protected) resource request. clientServices.portalAppService.loadedPortalApps[0].errorPluginMissing;
{
"plugins": [
{
"name": "Unique Name For My App",
// ...
"defaultConfig": {
"title": {
"en": "My App",
"de": "Meine App"
},
"description": {
"en": "A simple React SPA with cool features",
"de": "Ein einfacher React SPA mit tollen Features"
}
//...
}
}
]
}
Core: Added the possibility to register health probes for plugins. Use this if your plugin relies on external service, and you want the flag the instance not ready if it is not available. Usage:
const bootstrap: MashroomStoragePluginBootstrapFunction = async (pluginName, pluginConfig, pluginContextHolder) => {
const {services: {core: {pluginService, healthProbeService}}} = pluginContextHolder.getPluginContext();
healthProbeService.registerProbe(pluginName, healthProbe);
pluginService.onUnloadOnce(pluginName, () => {
healthProbeService.unregisterProbe(pluginName);
});
// ...
};
{
"Mashroom CDN Services": {
"cdnHosts": [
"//cdn1.my-portal.com",
"//cdn2.my-portal.com"
]
}
}
Portal: Added to possibility to define custom App Config editors per Portal App. This is useful for Apps that have an editable content (e.g. from a Headless CMS). A custom editor is basically just another Portal App (SPA) that receives a special object within the appConfig with the config of the target App and a function to update it:
const bootstrap: MashroomPortalAppPluginBootstrapFunction = (portalAppHostElement, portalAppSetup, clientServices) => {
const {appConfig: {editorTarget /* MashroomPortalConfigEditorTarget */}} = portalAppSetup;
const currentAppConfig = editorTarget.appConfig;
// Open Editor with current config
// Update with new Config
editorTarget.updateAppConfig(newAppConfig);
};
In the App that wants to use the editor just update the plugin definition like this:
"defaultConfig": {
"editor": {
"editorPortalApp": "My Editor App",
"position": "in-place"
}
}
Since the target App remains active it is also possible to use the message bus to exchange information between the editor and the actual App.
{
"ssrConfig": {
"ssrEnabled": true,
"renderTimoutMs": 2000,
"cacheTTLSec": 300,
"inlineStyles": true
}
}
{
"name": "My Single Page App",
"title": "My Single Page App",
"category": "Demo",
"tags": ["what", "ever"],
"type": "portal-app",
"bootstrap": "startMyApp",
"defaultConfig": {
"resourcesRoot": "./dist",
"restProxies": {
"spaceXApi": {
"targetUri": "https://api.spacexdata.com/v3",
"sendPermissionsHeader": false,
"restrictToRoles": ["Role1"]
}
}
}
}
to:{
"name": "My Single Page App",
"type": "portal-app2",
"clientBootstrap": "startMyApp",
"local": {
"resourcesRoot": "./dist",
"ssrBootstrap": "optional-ssr-bootstrap-file"
},
"remote": {
"resourcesRoot": "/if-remote-access-supported",
"ssrInitialHtmlPath": "optional-ssr-route"
},
"defaultConfig": {
"title": "My Single Page App",
"category": "Demo",
"tags": ["what", "ever"],
"caching": {
"ssrHtml": "same-config-and-user"
},
"editor": {
"editorPortalApp": "My Optional App Config Editor",
"position": "in-place",
"appConfig": {
}
},
"proxies": {
"spaceXApi": {
"targetUri": "https://api.spacexdata.com/v3",
"sendPermissionsHeader": false,
"restrictToRoles": ["Role1"]
}
}
}
}
await storage.find({ $and: [{ b: { $gt: 1 }}, { x: { $exists: false }}]}, 10, 0, { b: 'asc' })
{
"plugins": {
"Mashroom Cache Control Services": {
"maxAgeSec": 86400
}
}
}
"k8sNamespacesLabelSelector": "environment=development,tier=frontend",
"k8sNamespaces": null,
"k8sServiceLabelSelector": "microfrontend=true,channel!=alpha"
"k8sNamespacesLabelSelector": "environment=development",
"k8sNamespaces": null,
"serviceNameFilter": "(microfrontend-)",
http://localhost:5050/portal/web/___/api/pages/test2/content?currentPageId=subpage1
Means: Give me the content (and scripts to launch/hydrate the Apps) for page test2, and I'm currently
on page subpage1, tell me if I need a full page load because the theme or something else outside
the content area is different.<div id="portal-app-{{appId}}" class="mashroom-portal-app-wrapper portal-app-{{safePluginName}}">
<div class="mashroom-portal-app-header">
<div class="mashroom-portal-app-header-title" data-replace-content="title">{{title}}</div>
</div>
<div class="mashroom-portal-app-host" data-replace-content="app">
{{#if appSSRHtml}}
{{{appSSRHtml}}}
{{else}}
<div class="mashroom-portal-app-loading"><span/></div>
{{/if}}
</div>
</div>
BREAKING CHANGE: Previously it was possible to customize the App wrapper and error message using the client side
functions MashroomPortalCreateAppWrapperFunc and MashroomPortalCreateLoadingErrorFunc - those are ignored now.{
"enableHttp2": true
}
{
"httpsPort": 5443,
"tlsOptions": {
"key": "./certs/key.pem",
"cert": "./certs/cert.pem"
}
}
The tlsOptions are passed to https://nodejs.org/api/tls.html#tls_tls_createserver_options_secureconnectionlistener{
"$schema": "https://www.mashroom-server.com/schemas/mashroom-plugins.json",
"devModeBuildScript": "build",
"plugins": [
{
"name": "Mashroom Portal Demo React App",
"type": "portal-app",
"bootstrap": "startReactDemoApp",
"resources": {
"js": [
"bundle.js"
],
"css": []
},
"defaultConfig": {
"resourcesRoot": "./dist"
}
}
]
}
The possible file name can be changed in the server config via the externalPluginConfigFileNames config property.{
"$schema": "./node_modules/@mashroom/mashroom-json-schemas/schemas/mashroom-packagejson-extension.json",
"name": "my-package"
}
or by using the remote location:{
"$schema": "https://www.mashroom-server.com/schemas/mashroom-packagejson-extension.json",
"name": "my-package"
}
{
"Mashroom Http Proxy Services": {
"proxyImpl": "nodeHttpProxy"
}
}
Default is still the request based implementation.Migration to TypeScript completed (but flow types are still available).
The type aliases for express (ExpressRequest, ExpressResponse) are no longer required, so you can directly use the express types. E.g. in a middleware plugin:
import type {Request, Response, NextFunction} from 'express';
import type {MashroomMiddlewarePluginBootstrapFunction} from '@mashroom/mashroom/type-definitions';
const myMiddleware = (req: Request, res: Response, next: NextFunction) => {
const logger = req.pluginContext.loggerFactory('my.middleware');
logger.info('woohoo');
// TODO
next();
};
const bootstrap: MashroomMiddlewarePluginBootstrapFunction = async (pluginName, pluginConfig) => {
return myMiddleware;
};
export default bootstrap;
{
"restProxies": {
"spaceXApi": {
"targetUri": "https://api.spacexdata.com/v3"
},
"secondApi": {
"targetUri": "..."
}
}
}
You could fetch SpaceX's rocket starts like this: const apiUrl = portalAppSetup.restProxyPaths.__base;
fetch(`${apiUrl}/spaceXApi/launches/upcoming`)
// Instead of:
// fetch(`${portalAppSetup.restProxyPaths.spaceXApi}/launches/upcoming`)
stateService.setLocalStoreStateProperty('state', store.getState());
"Mashroom Storage Services": {
"provider": "Mashroom Storage Filestore Provider",
"memoryCache": {
"enabled": true,
"ttlSec": 120,
"invalidateOnUpdate": true,
"collections": {
"mashroom-portal-pages": {
"ttlSec": 300
}
}
}
}
Added a virtual host path mapper plugin: Allows it to map internal paths based on virtual hosts and web apps to get the actual "frontend path" to generate absolute links at the same time. Can be used to expose Portal Sites to virtual hosts like so:
https://www.my-company.com/new-portal -> http://internal-portal-host/portal/web
For this example configure your reverse proxy to forward calls from https://www.my-company.com/public to http://internal-portal-host/ and additionally configure the new plugin like this:
"Mashroom VHost Path Mapper Middleware": {
"hosts": {
"www.my-company.com": {
"frontendBasePath": "/new-portal",
"mapping": {
"/login": "/login",
"/": "/portal/web"
}
}
}
}
{
"/portal/public-site/**": {
"*": {
"allow": "any"
}
}
"/portal/**": {
"*": {
"allow": {
"roles": ["Authenticated"]
}
}
}
}
{
"/portal/**": {
"*": {
"allow": {
"roles": ["Authenticated"],
"ips": ["10.1.2.*", "168.**"]
},
"deny": {
"ips": ["1.2.3.4"]
}
}
}
}
"Mashroom Security Services": {
"provider": "Mashroom Security Simple Provider",
"forwardQueryHintsToProvider": ["kc_idp_hint"]
}
{
"name": "${env.USER}'s Mashroom Server",
"port": 5050
}
// index.ts
import {MashroomPortalAppPluginBootstrapFunction} from '@mashroom/mashroom-portal/type-definitions';
const bootstrap: MashroomPortalAppPluginBootstrapFunction = (hostElement, portalAppSetup, portalClientServices) => {
// ...
}
{
"name": "Demo Shared DLL App 1",
"type": "portal-app",
"bootstrap": "startupDemoSharedDLLApp1",
"sharedResources": {
"js": [
"demo_shared_dll_910502a6fce2f139eff8.js"
]
}
}
Check out the demo project here: https://github.com/nonblocking/mashroom-demo-shared-dll"console": {
"type": "console",
"layout": {
"type": "pattern",
"pattern": "%d %p %X{sessionID} %X{browser} %X{browserVersion} %X{username} %X{portalAppName} %X{portalAppVersion} %c - %m"
}
}
First public release