Plugin Deployment & Testing
Deploy your plugin to the Experience Platform, verify integrity, set up debugging workflows, and complete the Week 3 assessment.
📋Learning Objectives
- Build production-ready ESM bundles with esbuild
- Generate and verify SHA-256 integrity hashes
- Deploy plugins to the Experience Platform
- Configure local development with hot reload
- Debug plugins using browser DevTools
- Troubleshoot common deployment issues
Build Script Review
Let's review the esbuild configuration from Day 13:
// build.mjs
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/index.tsx'],
bundle: true,
outfile: 'dist/index.esm.js',
format: 'esm',
minify: true, // Minify for production
sourcemap: true, // Generate sourcemaps for debugging
target: ['es2020'],
external: [
'react',
'react-dom',
'@empowernow/ui',
'@empowernow/crud-ui',
'@experience/plugin-auth'
],
define: {
'process.env.NODE_ENV': '"production"'
}
});
Run the Build
# Navigate to plugin directory (replace <YourRepoRoot> with your project root)
# Windows
cd <YourRepoRoot>\experience\plugins\role-viewer\1.0.0
# macOS/Linux
cd <YourRepoRoot>/ServiceConfigs/BFF/plugins/role-viewer/1.0.0
# Install dependencies (if not done)
npm install
# Run the build
npm run build
# Verify output
dir dist # Windows — or: ls dist (macOS/Linux)
# Expected output:
# index.esm.js (bundled code, ~15-50KB)
# index.esm.js.map (sourcemap for debugging)
Build Output Analysis
# Check bundle size
(Get-Item dist\index.esm.js).Length / 1KB
# If bundle is too large (>100KB), check for:
# - Accidentally bundled external dependencies
# - Large libraries that should be external
# - Development-only code not tree-shaken
<50KB: Excellent - fast load times
50-100KB: Acceptable for feature-rich plugins
>100KB: Review externals config - something may be bundled incorrectly
Add Integrity Script to package.json
{
"scripts": {
"build": "node build.mjs",
"integrity": "node -e \"const crypto = require('crypto'); const fs = require('fs'); const hash = crypto.createHash('sha256').update(fs.readFileSync('dist/index.esm.js')).digest('base64'); console.log('sha256-' + hash);\"",
"deploy": "npm run build && npm run integrity"
}
}
Generate the Hash
# Run integrity script
npm run integrity
# Output example:
# sha256-abc123def456ghi789jkl012mno345pqr678stu901vwx234=
Update Plugin Manifest
# ServiceConfigs/BFF/config/plugins/manifests/role-viewer.yaml
id: role-viewer
title: "Role Viewer"
version: 1.0.0
bundle:
file: index.esm.js
# Update this after every build!
integrity: sha256-abc123def456ghi789jkl012mno345pqr678stu901vwx234=
# ... rest of config
If you rebuild and deploy without updating the integrity hash, the platform will reject the bundle with a security error. Make it part of your deploy workflow.
Automated Deploy Script
// deploy.mjs - Full deploy automation
import * as esbuild from 'esbuild';
import { createHash } from 'crypto';
import { readFileSync, writeFileSync } from 'fs';
import { parse, stringify } from 'yaml';
// 1. Build
await esbuild.build({
entryPoints: ['src/index.tsx'],
bundle: true,
outfile: 'dist/index.esm.js',
format: 'esm',
minify: true,
sourcemap: true,
target: ['es2020'],
external: ['react', 'react-dom', '@empowernow/ui', '@empowernow/crud-ui', '@experience/plugin-auth']
});
// 2. Generate integrity hash
const bundle = readFileSync('dist/index.esm.js');
const hash = createHash('sha256').update(bundle).digest('base64');
const integrity = `sha256-${hash}`;
// 3. Update plugins.yaml
const yamlContent = readFileSync('plugins.yaml', 'utf8');
const config = parse(yamlContent);
config.bundle.integrity = integrity;
writeFileSync('plugins.yaml', stringify(config));
console.log('✅ Build complete');
console.log(`📦 Bundle size: ${(bundle.length / 1024).toFixed(1)}KB`);
console.log(`🔒 Integrity: ${integrity}`);
Deployment Structure
The Experience Platform serves plugins from a specific directory structure:
experience/
└── plugins/
└── role-viewer/
└── 1.0.0/
├── plugins.yaml # Plugin manifest
└── dist/
├── index.esm.js # Bundle
└── index.esm.js.map
Copy Files to Deploy Location
# Windows (PowerShell) — replace <YourRepoRoot> with your project root
$target = "<YourRepoRoot>\experience\plugins\role-viewer\1.0.0"
New-Item -ItemType Directory -Force -Path "$target\dist"
Copy-Item "dist\index.esm.js" "$target\dist\"
Copy-Item "dist\index.esm.js.map" "$target\dist\"
Copy-Item "plugins.yaml" "$target\"
dir $target -Recurse
# macOS/Linux
cd <YourRepoRoot>/ServiceConfigs/BFF/plugins/role-viewer/1.0.0
mkdir -p dist
cp dist/index.esm.js dist/index.esm.js.map dist/
cp plugins.yaml .
ls -R
Register Plugin (First Time Only)
If this is a new plugin, add it to the platform's plugin registry:
# ServiceConfigs/BFF/config/plugins/index.yaml (tenant registry)
# Add the plugin id to the enabled list
# ServiceConfigs/BFF/config/plugins/manifests/role-viewer.yaml (per-plugin manifest)
id: role-viewer
title: "Role Viewer"
version: 1.0.0
enabled: true
# ... other plugins
Verify Deployment
# 1. Check bundle is accessible
curl -s "https://experience.self.empowernow.ai/plugins/role-viewer/1.0.0/dist/index.esm.js" | head -c 200
# 2. Check plugins.yaml is readable
curl -s "https://experience.self.empowernow.ai/plugins/role-viewer/1.0.0/plugins.yaml"
# 3. Navigate to plugin route in browser
# https://experience.self.empowernow.ai/role-viewer
Add Watch Mode to esbuild
// build.mjs - Add watch mode
import * as esbuild from 'esbuild';
const isWatch = process.argv.includes('--watch');
const buildOptions = {
entryPoints: ['src/index.tsx'],
bundle: true,
outfile: 'dist/index.esm.js',
format: 'esm',
minify: !isWatch, // Don't minify in dev
sourcemap: true,
target: ['es2020'],
external: ['react', 'react-dom', '@empowernow/ui', '@empowernow/crud-ui', '@experience/plugin-auth']
};
if (isWatch) {
const ctx = await esbuild.context(buildOptions);
await ctx.watch();
console.log('👀 Watching for changes...');
} else {
await esbuild.build(buildOptions);
console.log('✅ Build complete');
}
Update package.json Scripts
{
"scripts": {
"dev": "node build.mjs --watch",
"build": "node build.mjs",
"integrity": "node -e \"...\"",
"deploy": "npm run build && npm run integrity"
}
}
Start Development Mode
# Start watching
npm run dev
# Output:
# 👀 Watching for changes...
# Now edit src/index.tsx - bundle rebuilds automatically!
Local Plugin Override (Optional)
For faster iteration, you can override the plugin URL to load from localhost:
// In browser console on Experience Platform
localStorage.setItem('plugin_override_role-viewer', 'http://localhost:8080/dist/index.esm.js');
// Serve locally with a simple HTTP server
npx serve dist -p 8080 --cors
// Clear override when done
localStorage.removeItem('plugin_override_role-viewer');
- Run
npm run devin plugin directory - Run
npx serve dist -p 8080 --corsin another terminal - Set localStorage override in browser
- Edit code → save → refresh browser → see changes
Console Debugging
// Add strategic console logs
export function Dashboard() {
console.log('[RoleViewer] Dashboard mounting');
const fetchRoles = async () => {
console.log('[RoleViewer] Fetching roles...');
try {
const response = await sdk.api.fetch('/api/membership/users/me/roles');
console.log('[RoleViewer] Response status:', response.status);
const data = await response.json();
console.log('[RoleViewer] Roles received:', data);
} catch (err) {
console.error('[RoleViewer] Fetch error:', err);
}
};
}
Network Tab Investigation
| Check | What to Look For |
|---|---|
| Plugin bundle load | 200 status, correct file size, no CORS errors |
| API calls | Correct URL, auth headers present, response data |
| Authorization calls | /access/v1/evaluation returns decision |
| SSE connections | EventStream type, staying open, receiving events |
Sourcemap Debugging
With sourcemaps enabled, you can debug original TypeScript code:
// 1. Open DevTools → Sources tab
// 2. Find your plugin under webpack:// or file://
// 3. Navigate to src/index.tsx
// 4. Click line number to set breakpoint
// 5. Refresh page → execution pauses at breakpoint
React DevTools
Install the React DevTools browser extension to inspect:
- Component tree: Find your plugin components
- Props: See what data components receive
- State: Inspect useState/useReducer values
- Hooks: See effect dependencies and values
Common Debug Patterns
// Debug authorization issues
const checkAuth = async () => {
const request = {
resource: { type: 'role-viewer.roles' },
action: { name: 'view' }
};
console.log('[Auth] Request:', JSON.stringify(request, null, 2));
const result = await sdk.authz.evaluate(request);
console.log('[Auth] Result:', JSON.stringify(result, null, 2));
return result.decision === true;
};
// Debug API response parsing
const fetchRoles = async () => {
const response = await sdk.api.fetch('/api/membership/users/me/roles');
const text = await response.text(); // Get raw text first
console.log('[API] Raw response:', text);
try {
const data = JSON.parse(text);
console.log('[API] Parsed data:', data);
return data;
} catch (e) {
console.error('[API] Parse error - response was not JSON:', text);
throw e;
}
};
🔄Migrating React Apps to Plugins
Structural Differences
Understanding what changes between a traditional React app and a plugin is the first step:
| Aspect | Traditional App | Experience Plugin |
|---|---|---|
| Entry Point | ReactDOM.render() |
export default manifest |
| Dependencies | Bundled with app | Externalized (host provides) |
| Routing | React Router in app | Host manages routes |
| Global State | App-owned (Redux/Context) | Plugin-scoped or shared |
| Styling | Global CSS | Scoped/CSS Modules |
| Build Output | HTML + JS chunks | Single ESM file |
Directory Structure Comparison
# Traditional React App
my-react-app/
├── public/
│ ├── index.html # Entry HTML (REMOVE)
│ └── favicon.ico
├── src/
│ ├── components/
│ ├── pages/
│ ├── App.tsx # Root component (REFACTOR)
│ ├── App.css # Global styles (SCOPE)
│ ├── index.tsx # ReactDOM.render (REMOVE)
│ └── index.css # Global CSS (AVOID)
├── package.json
└── vite.config.ts
# Experience Plugin
my-plugin/
└── 1.0.0/
├── src/
│ ├── components/
│ ├── index.tsx # Plugin entry (exports manifest)
│ └── styles.ts # Scoped styles
├── dist/
│ └── index.esm.js # Single ESM bundle
├── plugins.yaml # Plugin manifest
├── build.mjs
└── package.json
Step-by-Step Migration
1. Remove Browser Entry Points
// ❌ BEFORE: src/index.tsx (Traditional App)
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(<App />);
// ✅ AFTER: src/index.tsx (Plugin Entry)
const React = (globalThis as any).React;
import { Dashboard } from './components/Dashboard';
import { Settings } from './components/Settings';
export const routes = { Dashboard, Settings };
export const widgets = {};
export default { routes, widgets };
2. Externalize Dependencies
// ❌ BEFORE: package.json (Everything bundled)
{
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"@mui/material": "^5.14.0"
}
}
// ✅ AFTER: package.json (Externals as peerDeps)
{
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"esbuild": "^0.19.0",
"typescript": "^5.3.0"
}
}
3. Handle Global Styles
// ❌ BEFORE: Global CSS affects entire app
/* src/index.css */
* { margin: 0; padding: 0; }
body { font-family: 'Inter', sans-serif; }
// ✅ AFTER: Scoped styles with CSS-in-JS
const styles = {
container: {
padding: '24px',
background: 'var(--card-bg, #16161f)',
borderRadius: '8px'
},
header: {
fontSize: '24px',
fontWeight: 600,
marginBottom: '16px'
}
};
// Or use CSS Modules
import styles from './Dashboard.module.css';
4. Migrate Routing
// ❌ BEFORE: React Router in App
import { BrowserRouter, Routes, Route } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</BrowserRouter>
);
}
// ✅ AFTER: Declare routes in plugins.yaml, host manages navigation
# plugins.yaml
contributions:
routes:
- path: /my-plugin/dashboard
component: Dashboard
- path: /my-plugin/settings
component: Settings
// Components navigate using window.location or host's navigate
const handleNavigate = () => {
window.location.href = '/my-plugin/settings';
};
5. Update API Calls
// ❌ BEFORE: Direct API calls
import axios from 'axios';
const API_BASE = 'https://api.myapp.com';
async function fetchData() {
const response = await axios.get(`${API_BASE}/data`);
return response.data;
}
// ✅ AFTER: Use Platform SDK (BFF handles auth, CSRF)
const sdk = (globalThis as any).pluginSdk;
async function fetchData() {
const response = await sdk.api.fetch('/api/my-service/data');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
6. Configure esbuild
// build.mjs - ESM plugin build
import * as esbuild from 'esbuild';
await esbuild.build({
entryPoints: ['src/index.tsx'],
bundle: true,
outfile: 'dist/index.esm.js',
format: 'esm',
platform: 'browser',
target: 'es2020',
// CRITICAL: Externalize host-provided dependencies
external: [
'react',
'react-dom',
'react/jsx-runtime',
'@empowernow/*'
],
minify: true,
sourcemap: true,
define: {
'process.env.NODE_ENV': '"production"'
}
});
Migration Checklist
- No
ReactDOM.render()orcreateRoot()calls - All platform dependencies externalized in esbuild config
- No global CSS (use CSS modules or styled-components)
- Routes exported in plugin manifest (
plugins.yaml) - Bundle size under 200KB (check with
npm run build) - Integrity hash generated
- TypeScript compiles without errors
- No direct API calls (use Platform SDK via BFF)
- Navigation uses platform's router or window.location
- State is plugin-scoped (not global Redux)
Common Migration Pitfalls
- Forgetting to externalize React: Results in 300KB+ bundles and duplicate React instances
- Using global CSS: Causes style conflicts with host and other plugins
- Direct DOM manipulation: Breaks in plugin isolation (no
document.getElementById('root')) - Hardcoded API URLs: Use platform's BFF proxy instead
- App-level routing: Let the host manage navigation via
plugins.yaml - Large asset bundles: Inline only small assets, use CDN for large ones
- Using
import React: Must useglobalThis.React
Don't migrate your entire app at once. Start with a single page/feature as a plugin, verify it works, then migrate the rest incrementally. This reduces risk and helps you learn the plugin patterns.
🔧Deployment Troubleshooting
Issue: "Integrity check failed"
# Symptom: Plugin fails to load with security error
# Cause: Hash in plugins.yaml doesn't match bundle
# Fix: Regenerate hash and update plugins.yaml
npm run integrity
# Copy output to plugins.yaml bundle.integrity field
# Redeploy plugins.yaml
Issue: "Component not found"
# Symptom: Route shows blank or error
# Cause: Export name doesn't match plugins.yaml
# Check plugins.yaml:
contributions:
routes:
- component: Dashboard # Must match export name exactly
# Check index.tsx:
export const routes = { Dashboard }; # Must export this name
Issue: "403 Forbidden on API calls"
# Symptom: API calls fail with 403
# Cause: Missing permission in plugins.yaml
# Fix: Add the endpoint to permissions.api
permissions:
api:
- method: GET
path: /api/membership/users/{id}/roles # Add this
Issue: "CORS error in console"
# Symptom: Plugin loads but API fails with CORS
# Cause: Trying to call backend directly instead of through BFF
# Wrong:
fetch('https://membership.self.empowernow.ai/...')
# Correct:
sdk.api.fetch('/api/membership/...') # Goes through BFF proxy
Issue: "globalThis.React is undefined"
# Symptom: TypeError when component renders
# Cause: Using import React instead of globalThis
# Wrong:
import React from 'react';
# Correct:
const React = (globalThis as any).React;
🏆Week 3 Assessment
Before moving to Week 4, verify you can complete these tasks:
✅ Plugin Structure
Create plugins.yaml with routes, permissions, PDP application
✅ Build System
Configure esbuild with externals, generate integrity hash
✅ React Components
Use globalThis.React, create functional components
✅ Plugin SDK
Use sdk.api.fetch(), sdk.authz.evaluate(), sdk.sse.subscribe()
✅ Authorization
Implement IfCan, batch permissions, fail-closed pattern
✅ Deployment
Build, hash, deploy, verify plugin works in platform
Self-Check Questions
- Why must React be accessed via
globalThis.Reactinstead of importing? - What happens if you deploy without updating the integrity hash?
- How do you check multiple permissions efficiently?
- What's the difference between
permissions.apiandpermissions.sse? - Where do sourcemaps help during debugging?
❓Knowledge Check
npm run integrity which runs a Node script that:
- Reads the bundle file
- Computes SHA-256 hash
- Outputs
sha256-{base64-hash}
Sourcemaps let you debug minified code by mapping it back to original source. Without them, error stack traces show unintelligible minified code like a.b.c(). With sourcemaps, you see Dashboard.fetchRoles() at the correct line in your TypeScript.
npm run dev- Start esbuild in watch modenpx serve dist -p 8080 --cors- Serve bundle locally- Set
localStorage.plugin_override_role-viewerto localhost URL - Edit → Save → Refresh → See changes
- Curl the bundle URL - should return JS code, not 404
- Curl plugins.yaml - should return valid YAML
- Navigate to plugin route in browser - should render
- Check Network tab - no integrity or 403 errors
The external array tells esbuild not to bundle these dependencies. Instead, they're expected to be provided by the host platform at runtime. This keeps plugin bundles small and ensures all plugins share the same React instance.
external: ['react', 'react-dom', '@empowernow/ui']
📝Day 15 Checkpoint
- Built production bundle with esbuild
- Generated SHA-256 integrity hash
- Updated plugins.yaml with new hash
- Deployed plugin to Experience Platform
- Set up local development with watch mode
- Practiced debugging with DevTools
- Understand React app to plugin migration steps
- Completed Week 3 self-assessment
You've built, enhanced, and deployed a complete plugin. You understand authorization, real-time updates, and professional UX patterns. Week 4 covers advanced topics: security hardening, performance optimization, and plugin composition.