From 7ad3f139f60a40f1b99e4213430e45e0f5ebe678 Mon Sep 17 00:00:00 2001 From: Viljami Kuosmanen Date: Thu, 29 Jan 2026 09:42:46 +0200 Subject: [PATCH 1/2] @epilot/app-bridge@0.1.0 --- packages/app-bridge/README.md | 440 +++++++++++++++++- packages/app-bridge/examples/app-internals | 1 + .../examples/epilot-app-feed-in-tariffs | 1 + .../app-bridge/examples/epilot-app-schufa | 1 + .../app-bridge/examples/epilot-app-zapier | 1 + .../examples/epilot360-automation-hub | 1 + packages/app-bridge/examples/epilot360-entity | 1 + packages/app-bridge/package.json | 2 +- packages/app-bridge/src/app-bridge.test.ts | 390 ++++++++++++++++ packages/app-bridge/src/app-bridge.ts | 435 +++++++++++++++++ packages/app-bridge/src/errors.ts | 71 +++ packages/app-bridge/src/index.ts | 175 ++++++- packages/app-bridge/src/types.ts | 215 +++++++++ packages/app-bridge/tsconfig.json | 3 +- packages/app-bridge/vitest.config.mts | 2 + 15 files changed, 1713 insertions(+), 26 deletions(-) create mode 120000 packages/app-bridge/examples/app-internals create mode 120000 packages/app-bridge/examples/epilot-app-feed-in-tariffs create mode 120000 packages/app-bridge/examples/epilot-app-schufa create mode 120000 packages/app-bridge/examples/epilot-app-zapier create mode 120000 packages/app-bridge/examples/epilot360-automation-hub create mode 120000 packages/app-bridge/examples/epilot360-entity create mode 100644 packages/app-bridge/src/app-bridge.test.ts create mode 100644 packages/app-bridge/src/app-bridge.ts create mode 100644 packages/app-bridge/src/errors.ts create mode 100644 packages/app-bridge/src/types.ts diff --git a/packages/app-bridge/README.md b/packages/app-bridge/README.md index 2b1c53fa..b02454a5 100644 --- a/packages/app-bridge/README.md +++ b/packages/app-bridge/README.md @@ -1,9 +1,443 @@ # @epilot/app-bridge -Extend epilot XRM with custom App UI components +Extend epilot XRM with custom App UI components embedded in iframes. -``` +```bash npm i @epilot/app-bridge ``` -@TODO: link to docs.epilot.io App Bridge documentation +## Overview + +App Bridge enables communication between epilot apps embedded in iframes and the parent epilot application. It provides: + +- **Authentication** - Receive OAuth tokens for epilot API calls +- **Localization** - Access user's language preference +- **Context** - Get entity or action configuration data +- **Messaging** - Two-way communication with the parent app + +## Quick Start + +```typescript +import { initialize, getEntityContext, updateContentHeight } from '@epilot/app-bridge'; + +async function main() { + // Initialize and get authentication + const { token, lang } = await initialize(); + + // Configure your API client + apiClient.setAuthToken(token); + + // Get entity context (for entity surfaces) + const { entityId, schema } = await getEntityContext(); + + // Fetch and render data + const entity = await apiClient.getEntity(schema, entityId); + render(entity); + + // Update iframe height + updateContentHeight(document.body.scrollHeight); +} + +main(); +``` + +## Surfaces + +App Bridge supports different "surfaces" - contexts where your app can be embedded within epilot. + +### Entity Capability + +A collapsible section within an entity detail view. + +```typescript +import { initialize, getEntityContext, updateContentHeight } from '@epilot/app-bridge'; + +const { token } = await initialize(); +const { entityId, schema, capability } = await getEntityContext(); + +// entityId: "a1b2c3d4-..." +// schema: "contact" | "order" | ... +// capability: { name: "my-capability", app_id: "..." } +``` + +**Context Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `entityId` | `string` | The entity ID being viewed | +| `schema` | `string` | Entity schema slug (e.g., `'contact'`, `'order'`) | +| `capability` | `EntityCapability` | Capability configuration from app manifest | +| `capability.name` | `string?` | Capability identifier | +| `capability.app_id` | `string?` | Associated app ID | + +### Entity Tab + +A tab within the entity detail view. + +```typescript +import { initialize, getEntityContext, onVisibilityChange } from '@epilot/app-bridge'; + +const { token } = await initialize(); +const { entityId, schema, isVisible } = await getEntityContext(); + +// Subscribe to visibility changes +onVisibilityChange((visible) => { + if (visible) { + refreshData(); // Refresh when tab becomes visible + } +}); +``` + +**Context Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `entityId` | `string` | The entity ID being viewed | +| `schema` | `string` | Entity schema slug | +| `capability` | `EntityCapability` | Capability configuration | +| `isVisible` | `boolean` | Whether the tab is currently visible/active | + +### Flow Action Config + +Configuration UI for custom automation actions. + +```typescript +import { initialize, getActionConfig, updateActionConfig } from '@epilot/app-bridge'; + +interface MyActionConfig { + webhookUrl: string; + enabled: boolean; +} + +const { token } = await initialize(); +const config = await getActionConfig(); + +// Read existing config +console.log(config.custom_action_config?.webhookUrl); + +// Update config when user makes changes +updateActionConfig({ + webhookUrl: 'https://example.com/webhook', + enabled: true, +}); + +// For async actions that need callback +updateActionConfig( + { webhookUrl: 'https://example.com' }, + { waitForCallback: true } +); +``` + +**Config Parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `config.custom_action_config` | `T` | Custom configuration set by your app | +| `config.description` | `string?` | Action description | +| `config.app_id` | `string?` | Associated app ID | + +**Update Options:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `config` | `T` | New configuration values | +| `waitForCallback` | `boolean?` | If true, automation waits for async callback | + +## API Reference + +### Initialization + +#### `initialize(options?): Promise` + +Initialize the app bridge and get authentication data. + +```typescript +const { token, lang } = await initialize(); +// token: OAuth access token for epilot APIs +// lang: User's language preference ('en', 'de', etc.) +``` + +Options: +- `contentHeight?: number` - Initial content height (default: `document.body.scrollHeight`) +- `timeout?: number` - Timeout in ms (default: 5000) + +#### `getSession(): AppBridgeSession` + +Get the current session (throws if not initialized). + +```typescript +const { token, lang } = getSession(); +``` + +#### `isInitialized(): boolean` + +Check if the app bridge has been initialized. + +```typescript +if (!isInitialized()) { + await initialize(); +} +``` + +### Entity Surface API + +#### `getEntityContext(options?): Promise` + +Get the entity context for entity tab/capability surfaces. + +```typescript +const { entityId, schema, capability, isVisible } = await getEntityContext(); +``` + +#### `updateContentHeight(height: number): void` + +Update the content height reported to the parent app. + +```typescript +// After rendering content +updateContentHeight(document.body.scrollHeight); + +// With ResizeObserver for dynamic content +const observer = new ResizeObserver((entries) => { + updateContentHeight(entries[0].contentRect.height); +}); +observer.observe(document.getElementById('app')); +``` + +#### `onVisibilityChange(handler): Unsubscribe` + +Subscribe to visibility changes (for entity tabs). + +```typescript +const unsubscribe = onVisibilityChange((isVisible) => { + if (isVisible) { + refreshData(); + } +}); + +// Cleanup +unsubscribe(); +``` + +### Action Config API + +#### `getActionConfig(options?): Promise>` + +Get the action configuration for automation surfaces. + +```typescript +interface ZapierConfig { + subscriptionId: string; +} + +const config = await getActionConfig(); +console.log(config.custom_action_config?.subscriptionId); +``` + +#### `updateActionConfig(config, options?): void` + +Update the action configuration. + +```typescript +updateActionConfig({ subscriptionId: 'sub-123' }); + +// With async callback +updateActionConfig( + { subscriptionId: 'sub-123' }, + { waitForCallback: true } +); +``` + +### Generic Event API + +For custom events not covered by the high-level API. + +#### `on(event, handler): Unsubscribe` + +Subscribe to custom events. + +```typescript +const unsubscribe = on<{ action: string }>('custom-event', (data) => { + console.log(data.action); +}); +``` + +#### `send(event, data?): void` + +Send custom messages. + +```typescript +send('custom-response', { value: 42 }); +``` + +## Client Authorization + +Easily authorize any `@epilot/*-client` package from sdk-js: + +```typescript +import { getClient } from '@epilot/file-client'; +import { initialize, authorizeClient } from '@epilot/app-bridge'; + +// Initialize and get session +const session = await initialize(); + +// Create and authorize client +const fileClient = getClient(); +authorizeClient(fileClient, session); + +// Now the client is authorized for API calls +await fileClient.uploadFile(...); +``` + +You can also pass just the token string: + +```typescript +import { getClient } from '@epilot/entity-client'; +import { getSession, authorizeClient } from '@epilot/app-bridge'; + +const entityClient = getClient(); +authorizeClient(entityClient, getSession().token); +``` + +### Authorizing Multiple Clients + +```typescript +import { getClient as getFileClient } from '@epilot/file-client'; +import { getClient as getEntityClient } from '@epilot/entity-client'; +import { initialize, authorizeClient } from '@epilot/app-bridge'; + +const session = await initialize(); + +const fileClient = getFileClient(); +const entityClient = getEntityClient(); + +// Authorize all clients with the same session +authorizeClient(fileClient, session); +authorizeClient(entityClient, session); +``` + +## Legacy API + +For backwards compatibility, the low-level API is still available: + +```typescript +import { init, epilot } from '@epilot/app-bridge'; + +init(); + +epilot.subscribeToParentMessages('app-bridge:init', (event) => { + const { token, lang } = event.data; +}); + +epilot.sendMessageToParent('custom-event', { value: 42 }); +``` + +## Error Handling + +```typescript +import { + initialize, + AppBridgeTimeoutError, + AppBridgeNotInitializedError, +} from '@epilot/app-bridge'; + +try { + await initialize({ timeout: 3000 }); +} catch (error) { + if (error instanceof AppBridgeTimeoutError) { + console.error('Initialization timed out'); + } +} + +try { + getSession(); +} catch (error) { + if (error instanceof AppBridgeNotInitializedError) { + await initialize(); + } +} +``` + +## TypeScript Support + +All types are exported for TypeScript users: + +```typescript +import type { + AppBridgeSession, + EntityContext, + EntityCapability, + ActionConfig, + InitOptions, + RequestOptions, + UpdateConfigOptions, +} from '@epilot/app-bridge'; +``` + +## Complete Examples + +### Entity Capability App + +```typescript +import { initialize, getEntityContext, updateContentHeight } from '@epilot/app-bridge'; + +async function main() { + // Initialize + const { token, lang } = await initialize(); + + // Setup API client + apiClient.defaults.headers.Authorization = `Bearer ${token}`; + i18n.changeLanguage(lang); + + // Get entity context + const { entityId, schema } = await getEntityContext(); + + // Fetch and display data + const entity = await apiClient.get(`/entities/${schema}/${entityId}`); + renderEntity(entity.data); + + // Handle dynamic height + const observer = new ResizeObserver((entries) => { + updateContentHeight(entries[0].contentRect.height); + }); + observer.observe(document.getElementById('app')!); +} + +main().catch(console.error); +``` + +### Automation Action Config App + +```typescript +import { initialize, getActionConfig, updateActionConfig } from '@epilot/app-bridge'; + +interface WebhookConfig { + url: string; + headers: Record; +} + +async function main() { + const { token } = await initialize(); + + // Load existing config + const { custom_action_config } = await getActionConfig(); + + // Render form with existing values + const urlInput = document.getElementById('url') as HTMLInputElement; + urlInput.value = custom_action_config?.url ?? ''; + + // Handle form changes + urlInput.addEventListener('change', () => { + updateActionConfig({ + url: urlInput.value, + headers: custom_action_config?.headers ?? {}, + }); + }); +} + +main().catch(console.error); +``` + +## License + +MIT diff --git a/packages/app-bridge/examples/app-internals b/packages/app-bridge/examples/app-internals new file mode 120000 index 00000000..71877fc5 --- /dev/null +++ b/packages/app-bridge/examples/app-internals @@ -0,0 +1 @@ +/Users/viljami/epilot/app-internals \ No newline at end of file diff --git a/packages/app-bridge/examples/epilot-app-feed-in-tariffs b/packages/app-bridge/examples/epilot-app-feed-in-tariffs new file mode 120000 index 00000000..6683b59f --- /dev/null +++ b/packages/app-bridge/examples/epilot-app-feed-in-tariffs @@ -0,0 +1 @@ +/Users/viljami/epilot/epilot-app-feed-in-tariffs \ No newline at end of file diff --git a/packages/app-bridge/examples/epilot-app-schufa b/packages/app-bridge/examples/epilot-app-schufa new file mode 120000 index 00000000..2405e084 --- /dev/null +++ b/packages/app-bridge/examples/epilot-app-schufa @@ -0,0 +1 @@ +/Users/viljami/epilot/epilot-app-schufa \ No newline at end of file diff --git a/packages/app-bridge/examples/epilot-app-zapier b/packages/app-bridge/examples/epilot-app-zapier new file mode 120000 index 00000000..3e61aa5b --- /dev/null +++ b/packages/app-bridge/examples/epilot-app-zapier @@ -0,0 +1 @@ +/Users/viljami/epilot/epilot-app-zapier \ No newline at end of file diff --git a/packages/app-bridge/examples/epilot360-automation-hub b/packages/app-bridge/examples/epilot360-automation-hub new file mode 120000 index 00000000..4bf7fa61 --- /dev/null +++ b/packages/app-bridge/examples/epilot360-automation-hub @@ -0,0 +1 @@ +/Users/viljami/epilot/epilot360-automation-hub/ \ No newline at end of file diff --git a/packages/app-bridge/examples/epilot360-entity b/packages/app-bridge/examples/epilot360-entity new file mode 120000 index 00000000..37f766fa --- /dev/null +++ b/packages/app-bridge/examples/epilot360-entity @@ -0,0 +1 @@ +/Users/viljami/epilot/epilot360-entity \ No newline at end of file diff --git a/packages/app-bridge/package.json b/packages/app-bridge/package.json index 974de0e7..caef6c65 100644 --- a/packages/app-bridge/package.json +++ b/packages/app-bridge/package.json @@ -1,6 +1,6 @@ { "name": "@epilot/app-bridge", - "version": "0.0.1-alpha.8", + "version": "0.1.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/app-bridge/src/app-bridge.test.ts b/packages/app-bridge/src/app-bridge.test.ts new file mode 100644 index 00000000..f69bc60d --- /dev/null +++ b/packages/app-bridge/src/app-bridge.test.ts @@ -0,0 +1,390 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + authorizeClient, + getActionConfig, + getEntityContext, + getSession, + initialize, + isInitialized, + on, + onVisibilityChange, + send, + updateActionConfig, + updateContentHeight, + __reset, +} from './app-bridge'; +import { + AppBridgeNotInitializedError, + AppBridgeTimeoutError, +} from './errors'; + +describe('app-bridge', () => { + let messageHandlers: Map void>; + let postMessageMock: ReturnType; + + beforeEach(() => { + __reset(); + messageHandlers = new Map(); + + // Mock window.addEventListener + vi.spyOn(window, 'addEventListener').mockImplementation((event, handler) => { + if (event === 'message') { + messageHandlers.set(event, handler as (event: MessageEvent) => void); + } + }); + + vi.spyOn(window, 'removeEventListener').mockImplementation((event) => { + if (event === 'message') { + messageHandlers.delete(event); + } + }); + + // Mock window.parent.postMessage + postMessageMock = vi.fn(); + vi.stubGlobal('parent', { postMessage: postMessageMock }); + + // Mock document.body.scrollHeight + Object.defineProperty(document.body, 'scrollHeight', { + value: 500, + configurable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + const simulateParentMessage = (event: string, data: Record) => { + const handler = messageHandlers.get('message'); + if (handler) { + handler({ + data: { event, ...data }, + source: window.parent, + } as MessageEvent); + } + }; + + describe('initialize', () => { + it('should send app-bridge:init message and resolve with session', async () => { + const initPromise = initialize(); + + // Simulate parent response + setTimeout(() => { + simulateParentMessage('app-bridge:init', { token: 'test-token', lang: 'en' }); + }, 10); + + const session = await initPromise; + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'app-bridge', + event: 'app-bridge:init', + contentHeight: 500, + }), + '*', + ); + expect(session).toEqual({ token: 'test-token', lang: 'en' }); + }); + + it('should return cached session on subsequent calls', async () => { + const initPromise = initialize(); + + setTimeout(() => { + simulateParentMessage('app-bridge:init', { token: 'test-token', lang: 'en' }); + }, 10); + + const session1 = await initPromise; + const session2 = await initialize(); + + expect(session1).toBe(session2); + expect(postMessageMock).toHaveBeenCalledTimes(1); + }); + + it('should deduplicate concurrent initialization calls', async () => { + const promise1 = initialize(); + const promise2 = initialize(); + + setTimeout(() => { + simulateParentMessage('app-bridge:init', { token: 'test-token', lang: 'en' }); + }, 10); + + const [session1, session2] = await Promise.all([promise1, promise2]); + + expect(session1).toBe(session2); + expect(postMessageMock).toHaveBeenCalledTimes(1); + }); + + it('should respect custom contentHeight option', async () => { + const initPromise = initialize({ contentHeight: 1000 }); + + setTimeout(() => { + simulateParentMessage('app-bridge:init', { token: 'test-token', lang: 'en' }); + }, 10); + + await initPromise; + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ contentHeight: 1000 }), + '*', + ); + }); + + it('should timeout if no response received', async () => { + await expect(initialize({ timeout: 50 })).rejects.toThrow(AppBridgeTimeoutError); + }); + }); + + describe('getSession', () => { + it('should throw if not initialized', () => { + expect(() => getSession()).toThrow(AppBridgeNotInitializedError); + }); + + it('should return session after initialization', async () => { + const initPromise = initialize(); + + setTimeout(() => { + simulateParentMessage('app-bridge:init', { token: 'test-token', lang: 'de' }); + }, 10); + + await initPromise; + + const session = getSession(); + expect(session).toEqual({ token: 'test-token', lang: 'de' }); + }); + }); + + describe('isInitialized', () => { + it('should return false before initialization', () => { + expect(isInitialized()).toBe(false); + }); + + it('should return true after initialization', async () => { + const initPromise = initialize(); + + setTimeout(() => { + simulateParentMessage('app-bridge:init', { token: 'test-token', lang: 'en' }); + }, 10); + + await initPromise; + + expect(isInitialized()).toBe(true); + }); + }); + + describe('getEntityContext', () => { + it('should request and return entity context', async () => { + const contextPromise = getEntityContext(); + + setTimeout(() => { + simulateParentMessage('init-context', { + context: { + entityId: '123', + schema: 'contact', + capability: { name: 'my-capability' }, + }, + }); + }, 10); + + const context = await contextPromise; + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'app-bridge', + event: 'init-context', + }), + '*', + ); + expect(context).toEqual({ + entityId: '123', + schema: 'contact', + capability: { name: 'my-capability' }, + }); + }); + + it('should timeout if no response', async () => { + await expect(getEntityContext({ timeout: 50 })).rejects.toThrow(AppBridgeTimeoutError); + }); + }); + + describe('updateContentHeight', () => { + it('should send update-content-height message', () => { + updateContentHeight(800); + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'app-bridge', + event: 'update-content-height', + contentHeight: 800, + }), + '*', + ); + }); + }); + + describe('onVisibilityChange', () => { + it('should subscribe to visibility changes', () => { + const handler = vi.fn(); + onVisibilityChange(handler); + + simulateParentMessage('visibility-change', { isVisible: false }); + + expect(handler).toHaveBeenCalledWith(false); + }); + + it('should return unsubscribe function', () => { + const handler = vi.fn(); + const unsubscribe = onVisibilityChange(handler); + + unsubscribe(); + + simulateParentMessage('visibility-change', { isVisible: true }); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('getActionConfig', () => { + it('should request and return action config', async () => { + interface MyConfig { + subscriptionId: string; + } + + const configPromise = getActionConfig(); + + setTimeout(() => { + simulateParentMessage('init-action-config', { + config: { + custom_action_config: { subscriptionId: 'sub-123' }, + description: 'Test action', + }, + }); + }, 10); + + const config = await configPromise; + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'app-bridge', + event: 'init-action-config', + }), + '*', + ); + expect(config.custom_action_config?.subscriptionId).toBe('sub-123'); + expect(config.description).toBe('Test action'); + }); + }); + + describe('updateActionConfig', () => { + it('should send update-action-config message', () => { + updateActionConfig({ webhookUrl: 'https://example.com' }); + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'app-bridge', + event: 'update-action-config', + config: { webhookUrl: 'https://example.com' }, + }), + '*', + ); + }); + + it('should include wait_for_callback option', () => { + updateActionConfig({ webhookUrl: 'https://example.com' }, { waitForCallback: true }); + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'app-bridge', + event: 'update-action-config', + config: { webhookUrl: 'https://example.com' }, + wait_for_callback: true, + }), + '*', + ); + }); + }); + + describe('on', () => { + it('should subscribe to custom events', () => { + const handler = vi.fn(); + on('custom-event', handler); + + simulateParentMessage('custom-event', { value: 42 }); + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ value: 42 })); + }); + + it('should return unsubscribe function', () => { + const handler = vi.fn(); + const unsubscribe = on('custom-event', handler); + + unsubscribe(); + + simulateParentMessage('custom-event', { value: 42 }); + + expect(handler).not.toHaveBeenCalled(); + }); + }); + + describe('send', () => { + it('should send custom messages', () => { + send('custom-event', { action: 'test' }); + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'app-bridge', + event: 'custom-event', + action: 'test', + }), + '*', + ); + }); + }); + + describe('authorizeClient', () => { + it('should set Authorization header from session object', () => { + const mockClient = { + defaults: { + headers: { + common: {} as Record, + }, + }, + }; + + authorizeClient(mockClient, { token: 'test-token', lang: 'en' }); + + expect(mockClient.defaults.headers.common.Authorization).toBe('Bearer test-token'); + }); + + it('should set Authorization header from token string', () => { + const mockClient = { + defaults: { + headers: { + common: {} as Record, + }, + }, + }; + + authorizeClient(mockClient, 'my-token'); + + expect(mockClient.defaults.headers.common.Authorization).toBe('Bearer my-token'); + }); + + it('should preserve existing headers', () => { + const mockClient = { + defaults: { + headers: { + common: { + 'X-Custom-Header': 'custom-value', + } as Record, + }, + }, + }; + + authorizeClient(mockClient, 'test-token'); + + expect(mockClient.defaults.headers.common.Authorization).toBe('Bearer test-token'); + expect(mockClient.defaults.headers.common['X-Custom-Header']).toBe('custom-value'); + }); + }); +}); diff --git a/packages/app-bridge/src/app-bridge.ts b/packages/app-bridge/src/app-bridge.ts new file mode 100644 index 00000000..a59ceea9 --- /dev/null +++ b/packages/app-bridge/src/app-bridge.ts @@ -0,0 +1,435 @@ +/** + * @epilot/app-bridge - High-Level API + * + * Framework-agnostic, Promise-based API for communicating with + * the parent epilot application. + */ + +import { sendMessageToParent, subscribeToParentMessages } from './messages'; +import { AppBridgeTimeoutError, AppBridgeNotInitializedError } from './errors'; +import type { + AppBridgeSession, + InitOptions, + RequestOptions, + UpdateConfigOptions, + EntityContext, + ActionConfig, + MessageHandler, + VisibilityHandler, + Unsubscribe, +} from './types'; + +// ============================================================================= +// Module State +// ============================================================================= + +/** Current session data (null if not initialized) */ +let session: AppBridgeSession | null = null; + +/** In-flight initialization promise (for deduplication) */ +let initPromise: Promise | null = null; + +/** Default timeout for requests */ +const DEFAULT_TIMEOUT = 5000; + +// ============================================================================= +// Internal Helpers +// ============================================================================= + +/** + * Send a request and wait for a response with timeout handling. + * @internal + */ +function request( + event: string, + payload: Record = {}, + options: RequestOptions = {}, +): Promise { + const { timeout = DEFAULT_TIMEOUT } = options; + + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + unsubscribe(); + reject(new AppBridgeTimeoutError(event, timeout)); + }, timeout); + + const unsubscribe = subscribeToParentMessages(event, (msgEvent: MessageEvent) => { + clearTimeout(timeoutId); + unsubscribe(); + // Strip internal fields (event, source) from response + const { event: _event, source: _source, ...data } = msgEvent.data || {}; + resolve(data as T); + }); + + sendMessageToParent(event, payload); + }); +} + +// ============================================================================= +// Initialization +// ============================================================================= + +/** + * Initialize the app bridge and establish connection with the parent app. + * + * Returns a session containing the authentication token and user language. + * Safe to call multiple times - subsequent calls return the cached session. + * + * @param options - Initialization options + * @returns Session data with token and language + * + * @example + * ```typescript + * import { initialize } from '@epilot/app-bridge'; + * + * const { token, lang } = await initialize(); + * + * // Use token for API calls + * apiClient.setAuthToken(token); + * + * // Set UI language + * i18n.setLanguage(lang); + * ``` + */ +export async function initialize(options: InitOptions = {}): Promise { + // Return cached session if already initialized + if (session) { + return session; + } + + // Return in-flight initialization if one exists (deduplication) + if (initPromise) { + return initPromise; + } + + const contentHeight = options.contentHeight ?? document.body.scrollHeight; + + initPromise = request( + 'app-bridge:init', + { contentHeight }, + { timeout: options.timeout }, + ) + .then((result) => { + session = result; + return result; + }) + .catch((error) => { + initPromise = null; + throw error; + }); + + return initPromise; +} + +/** + * Get the current session data. + * + * @throws {AppBridgeNotInitializedError} If called before initialize() + * @returns Current session data + * + * @example + * ```typescript + * import { initialize, getSession } from '@epilot/app-bridge'; + * + * await initialize(); + * + * // Later in the app... + * const { token } = getSession(); + * ``` + */ +export function getSession(): AppBridgeSession { + if (!session) { + throw new AppBridgeNotInitializedError(); + } + return session; +} + +/** + * Check if the app bridge has been initialized. + * + * @returns true if initialized, false otherwise + * + * @example + * ```typescript + * if (!isInitialized()) { + * await initialize(); + * } + * ``` + */ +export function isInitialized(): boolean { + return session !== null; +} + +// ============================================================================= +// Entity Surface API +// ============================================================================= + +/** + * Get the entity context for entity tab/capability surfaces. + * + * Returns context including the entity ID, schema, and capability configuration. + * + * @param options - Request options + * @returns Entity context data + * + * @example + * ```typescript + * import { initialize, getEntityContext } from '@epilot/app-bridge'; + * + * await initialize(); + * + * const { entityId, schema } = await getEntityContext(); + * console.log(`Viewing ${schema} entity: ${entityId}`); + * + * // Fetch entity data from API + * const entity = await api.getEntity(schema, entityId); + * ``` + */ +export async function getEntityContext(options?: RequestOptions): Promise { + const response = await request<{ context: EntityContext }>('init-context', {}, options); + return response.context; +} + +/** + * Update the content height reported to the parent app. + * + * The parent app uses this to resize the iframe appropriately. + * Call this whenever your content height changes. + * + * @param height - Content height in pixels + * + * @example + * ```typescript + * import { updateContentHeight } from '@epilot/app-bridge'; + * + * // After rendering content + * updateContentHeight(document.body.scrollHeight); + * + * // Or with a specific element + * const container = document.getElementById('app'); + * updateContentHeight(container.scrollHeight); + * ``` + */ +export function updateContentHeight(height: number): void { + sendMessageToParent('update-content-height', { contentHeight: height }); +} + +/** + * Subscribe to visibility changes (for entity tab surfaces). + * + * The parent app sends visibility updates when the user switches tabs. + * + * @param handler - Callback invoked when visibility changes + * @returns Unsubscribe function + * + * @example + * ```typescript + * import { onVisibilityChange } from '@epilot/app-bridge'; + * + * const unsubscribe = onVisibilityChange((isVisible) => { + * if (isVisible) { + * // Tab became visible - refresh data + * refreshData(); + * } + * }); + * + * // Later: cleanup + * unsubscribe(); + * ``` + */ +export function onVisibilityChange(handler: VisibilityHandler): Unsubscribe { + return subscribeToParentMessages('visibility-change', (msgEvent: MessageEvent) => { + handler(msgEvent.data?.isVisible ?? false); + }); +} + +// ============================================================================= +// Action Config Surface API +// ============================================================================= + +/** + * Get the action configuration for automation action surfaces. + * + * Returns the current configuration including any custom config set by the app. + * + * @template T - Type of the custom_action_config object + * @param options - Request options + * @returns Action configuration + * + * @example + * ```typescript + * import { initialize, getActionConfig } from '@epilot/app-bridge'; + * + * interface MyActionConfig { + * webhookUrl: string; + * enabled: boolean; + * } + * + * await initialize(); + * + * const config = await getActionConfig(); + * console.log(config.custom_action_config?.webhookUrl); + * ``` + */ +export async function getActionConfig>( + options?: RequestOptions, +): Promise> { + const response = await request<{ config: ActionConfig }>('init-action-config', {}, options); + return response.config; +} + +/** + * Update the action configuration. + * + * Sends updated configuration to the parent automation app. + * + * @template T - Type of the configuration object + * @param config - New configuration values + * @param options - Update options + * + * @example + * ```typescript + * import { updateActionConfig } from '@epilot/app-bridge'; + * + * // Simple update + * updateActionConfig({ webhookUrl: 'https://example.com/webhook' }); + * + * // With async callback support + * updateActionConfig( + * { webhookUrl: 'https://example.com/webhook' }, + * { waitForCallback: true } + * ); + * ``` + */ +export function updateActionConfig>( + config: T, + options?: UpdateConfigOptions, +): void { + sendMessageToParent('update-action-config', { + config, + wait_for_callback: options?.waitForCallback, + }); +} + +// ============================================================================= +// Generic Event API (Escape Hatch) +// ============================================================================= + +/** + * Subscribe to custom events from the parent app. + * + * Use this for custom events not covered by the high-level API. + * + * @template T - Expected data type + * @param event - Event name (supports wildcards, e.g., 'custom-*') + * @param handler - Callback invoked when event is received + * @returns Unsubscribe function + * + * @example + * ```typescript + * import { on } from '@epilot/app-bridge'; + * + * const unsubscribe = on<{ action: string }>('custom-event', (data) => { + * console.log('Received:', data.action); + * }); + * + * // Cleanup + * unsubscribe(); + * ``` + */ +export function on(event: string, handler: MessageHandler): Unsubscribe { + return subscribeToParentMessages(event, (msgEvent: MessageEvent) => { + handler(msgEvent.data as T); + }); +} + +/** + * Send a custom message to the parent app. + * + * Use this for custom messages not covered by the high-level API. + * + * @param event - Event name + * @param data - Data to send + * + * @example + * ```typescript + * import { send } from '@epilot/app-bridge'; + * + * send('custom-event', { action: 'save', value: 42 }); + * ``` + */ +export function send(event: string, data?: Record): void { + sendMessageToParent(event, data); +} + +// ============================================================================= +// Client Authorization +// ============================================================================= + +/** + * Interface for SDK clients that can be authorized. + * Compatible with @epilot/\*-client packages from sdk-js. + */ +export interface AuthorizableClient { + defaults: { + headers: { + common?: Record; + }; + }; +} + +/** + * Authorize an SDK client with the current session token. + * + * Works with any @epilot/\*-client package from sdk-js. + * + * @param client - The SDK client to authorize + * @param sessionOrToken - Either an AppBridgeSession or a token string + * + * @example + * ```typescript + * import { getClient } from '@epilot/file-client'; + * import { initialize, authorizeClient } from '@epilot/app-bridge'; + * + * const session = await initialize(); + * const client = getClient(); + * + * authorizeClient(client, session); + * + * // Now the client is authorized + * await client.uploadFile(...); + * ``` + * + * @example + * ```typescript + * // You can also pass just the token + * authorizeClient(client, session.token); + * + * // Or use getSession() if already initialized + * authorizeClient(client, getSession()); + * ``` + */ +export function authorizeClient( + client: AuthorizableClient, + sessionOrToken: AppBridgeSession | string, +): void { + const token = typeof sessionOrToken === 'string' ? sessionOrToken : sessionOrToken.token; + + client.defaults.headers.common = { + ...client.defaults.headers.common, + Authorization: `Bearer ${token}`, + }; +} + +// ============================================================================= +// Testing Utilities +// ============================================================================= + +/** + * Reset the module state. Only use in tests. + * @internal + */ +export function __reset(): void { + session = null; + initPromise = null; +} diff --git a/packages/app-bridge/src/errors.ts b/packages/app-bridge/src/errors.ts new file mode 100644 index 00000000..1917bb75 --- /dev/null +++ b/packages/app-bridge/src/errors.ts @@ -0,0 +1,71 @@ +/** + * @epilot/app-bridge - Error Classes + * + * Custom error classes for app-bridge operations. + */ + +/** + * Base error class for all app-bridge errors + */ +export class AppBridgeError extends Error { + constructor(message: string) { + super(message); + this.name = 'AppBridgeError'; + + // Maintains proper stack trace for where error was thrown (V8 engines) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +/** + * Error thrown when a request times out waiting for a response + * + * @example + * ```typescript + * try { + * await appBridge.getEntityContext({ timeout: 1000 }); + * } catch (error) { + * if (error instanceof AppBridgeTimeoutError) { + * console.log('Request timed out:', error.event); + * } + * } + * ``` + */ +export class AppBridgeTimeoutError extends AppBridgeError { + /** The event that timed out */ + readonly event: string; + /** The timeout duration in milliseconds */ + readonly timeout: number; + + constructor(event: string, timeout: number) { + super(`Request '${event}' timed out after ${timeout}ms`); + this.name = 'AppBridgeTimeoutError'; + this.event = event; + this.timeout = timeout; + } +} + +/** + * Error thrown when attempting to use the app bridge before initialization + * + * @example + * ```typescript + * try { + * const session = appBridge.getSession(); + * } catch (error) { + * if (error instanceof AppBridgeNotInitializedError) { + * await appBridge.initialize(); + * } + * } + * ``` + */ +export class AppBridgeNotInitializedError extends AppBridgeError { + constructor() { + super( + 'AppBridge is not initialized. Call appBridge.initialize() first.', + ); + this.name = 'AppBridgeNotInitializedError'; + } +} diff --git a/packages/app-bridge/src/index.ts b/packages/app-bridge/src/index.ts index c59fb5e6..c98ef44a 100644 --- a/packages/app-bridge/src/index.ts +++ b/packages/app-bridge/src/index.ts @@ -1,6 +1,62 @@ +/** + * @epilot/app-bridge + * + * Extend epilot XRM with custom App UI components. + * + * @example High-level API (recommended) + * ```typescript + * import { initialize, getEntityContext, updateContentHeight } from '@epilot/app-bridge'; + * + * // Initialize and get auth token + * const { token, lang } = await initialize(); + * + * // Get entity context (for entity surfaces) + * const { entityId, schema } = await getEntityContext(); + * + * // Update content height + * updateContentHeight(document.body.scrollHeight); + * ``` + * + * @example Low-level API (for custom messaging) + * ```typescript + * import { init, epilot } from '@epilot/app-bridge'; + * + * init(); + * + * epilot.subscribeToParentMessages('custom-event', (event) => { + * console.log(event.data); + * }); + * + * epilot.sendMessageToParent('custom-response', { value: 42 }); + * ``` + */ + import { epilot } from './epilot'; import { logger } from './utils'; +// ============================================================================= +// Legacy API (backwards compatible) +// ============================================================================= + +/** + * Initialize the app bridge (legacy API). + * + * Sends initialization message to parent and sets up global epilot object. + * For new projects, consider using the Promise-based `initialize()` instead. + * + * @deprecated Use `initialize()` for Promise-based initialization with session data + * + * @example + * ```typescript + * import { init, epilot } from '@epilot/app-bridge'; + * + * init(); + * + * epilot.subscribeToParentMessages('app-bridge:init', (event) => { + * const { token, lang } = event.data; + * }); + * ``` + */ export const init = () => { // make epilot object available in global if (window && !('epilot' in window)) { @@ -13,19 +69,19 @@ export const init = () => { logger.info( ` - %c█████████████████ - ██████████████████████ - ███████████████████████████ - █████████████████████████████ - ███████████████████████████████ - █████ ███████████████ - █████████████ - ████████ ████████████ - █████████████ ██████████ - █████████████ █████████ - █████████████ ███████ - ████████████ ██████ █████ -████████████ ██████ ██████ + %c█████████████████ + ██████████████████████ + ███████████████████████████ + █████████████████████████████ + ███████████████████████████████ + █████ ███████████████ + █████████████ + ████████ ████████████ + █████████████ ██████████ + █████████████ █████████ + █████████████ ███████ + ████████████ ██████ █████ +████████████ ██████ ██████ ███████████ ███████ ███████████ ███████ ███████████ █████████ @@ -33,17 +89,17 @@ export const init = () => { ███████████ ███████████ ███████████ ███ █████████████ ████████████ ███████████████████████████████ -█████████████ ██████████████████████████████ - █████████████ ████████████████████████████ - ██████████████ ████████████████████████ - █████████████ ████████████████████ - █████████ ███████████ - +█████████████ ██████████████████████████████ + █████████████ ████████████████████████████ + ██████████████ ████████████████████████ + █████████████ ████████████████████ + █████████ ███████████ + %cepilot App Bridge - ©️ 2025 epilot.cloud - + ©️ 2026 epilot.cloud + `, 'color:#005EB4;font-weight:bold;', 'font-weight:bold;', @@ -51,4 +107,81 @@ export const init = () => { ); }; +/** + * Low-level messaging API. + * + * Provides direct access to postMessage communication with the parent app. + * + * @example + * ```typescript + * import { epilot } from '@epilot/app-bridge'; + * + * // Subscribe to messages + * const unsubscribe = epilot.subscribeToParentMessages('my-event', (event) => { + * console.log(event.data); + * }); + * + * // Send messages + * epilot.sendMessageToParent('my-response', { value: 42 }); + * + * // Cleanup + * unsubscribe(); + * ``` + */ export { epilot }; + +// Re-export low-level messaging primitives for advanced use cases +export { sendMessageToParent, subscribeToParentMessages, APP_BRIDGE_SOURCE } from './messages'; + +// ============================================================================= +// High-Level API (recommended) +// ============================================================================= + +// Initialization +export { initialize, getSession, isInitialized } from './app-bridge'; + +// Entity surface API +export { getEntityContext, updateContentHeight, onVisibilityChange } from './app-bridge'; + +// Action config surface API +export { getActionConfig, updateActionConfig } from './app-bridge'; + +// Generic event API (escape hatch) +export { on, send } from './app-bridge'; + +// Client authorization +export { authorizeClient } from './app-bridge'; +export type { AuthorizableClient } from './app-bridge'; + +// ============================================================================= +// Types +// ============================================================================= + +export type { + // Session types + AppBridgeSession, + InitOptions, + RequestOptions, + UpdateConfigOptions, + // Entity surface types + EntityCapability, + EntityContext, + // Action config types + ActionConfig, + // Event types + MessageHandler, + VisibilityHandler, + Unsubscribe, + // Internal types (for advanced use) + AppBridgeEventMap, +} from './types'; + +// ============================================================================= +// Errors +// ============================================================================= + +export { + AppBridgeError, + AppBridgeTimeoutError, + AppBridgeNotInitializedError, +} from './errors'; diff --git a/packages/app-bridge/src/types.ts b/packages/app-bridge/src/types.ts new file mode 100644 index 00000000..d4962c42 --- /dev/null +++ b/packages/app-bridge/src/types.ts @@ -0,0 +1,215 @@ +/** + * @epilot/app-bridge - Type Definitions + * + * This module contains all type definitions for the app-bridge package, + * including surface contexts, session data, and event payloads. + */ + +// ============================================================================= +// Session Types +// ============================================================================= + +/** + * Session data received after app-bridge initialization. + * Contains authentication token and user locale preferences. + */ +export interface AppBridgeSession { + /** OAuth access token for epilot API calls */ + token: string; + /** User's language preference (e.g., 'en', 'de') */ + lang: string; +} + +/** + * Options for initializing the app bridge + */ +export interface InitOptions { + /** Initial content height to report to parent (defaults to document.body.scrollHeight) */ + contentHeight?: number; + /** Timeout in milliseconds for initialization (default: 5000) */ + timeout?: number; +} + +/** + * Options for request operations + */ +export interface RequestOptions { + /** Timeout in milliseconds (default: 5000) */ + timeout?: number; +} + +/** + * Options for updating action configuration + */ +export interface UpdateConfigOptions { + /** If true, the automation will wait for an async callback before proceeding */ + waitForCallback?: boolean; +} + +// ============================================================================= +// Entity Surface Types +// ============================================================================= + +/** + * Entity capability metadata from the app manifest. + * Defines how the capability is configured and rendered. + */ +export interface EntityCapability { + /** Capability name/identifier */ + name?: string; + /** Associated app ID */ + app_id?: string; + /** Additional capability-specific configuration */ + [key: string]: unknown; +} + +/** + * Context data for entity tab and entity capability surfaces. + * + * @example + * ```typescript + * const context = await appBridge.getEntityContext(); + * console.log(context.entityId); // '123e4567-e89b-12d3-a456-426614174000' + * console.log(context.schema); // 'contact' + * ``` + */ +export interface EntityContext { + /** The unique ID of the entity being viewed */ + entityId: string; + /** Entity schema slug (e.g., 'contact', 'order', 'opportunity') */ + schema: string; + /** Capability configuration (for entity_capability surface) */ + capability?: EntityCapability; + /** Whether the tab/capability is currently visible (for entity_tab surface) */ + isVisible?: boolean; +} + +// ============================================================================= +// Action Config Surface Types +// ============================================================================= + +/** + * Configuration for automation flow actions. + * + * @template T - Type of the custom_action_config object + * + * @example + * ```typescript + * interface ZapierConfig { + * subscriptionId: string; + * } + * + * const config = await appBridge.getActionConfig(); + * console.log(config.custom_action_config?.subscriptionId); + * ``` + */ +export interface ActionConfig> { + /** Custom configuration set by the app */ + custom_action_config?: T; + /** Action description shown in the automation UI */ + description?: string; + /** Associated app ID */ + app_id?: string; + /** Additional configuration fields */ + [key: string]: unknown; +} + +// ============================================================================= +// Event Types +// ============================================================================= + +/** + * Handler function for message events + */ +export type MessageHandler = (data: T) => void; + +/** + * Visibility change handler + */ +export type VisibilityHandler = (isVisible: boolean) => void; + +/** + * Unsubscribe function returned by event subscriptions + */ +export type Unsubscribe = () => void; + +// ============================================================================= +// Internal Event Payloads (for type-safe messaging) +// ============================================================================= + +/** + * Payload sent with app-bridge:init event + * @internal + */ +export interface InitPayload { + contentHeight: number; +} + +/** + * Payload received from app-bridge:init response + * @internal + */ +export interface InitResponsePayload { + token: string; + lang: string; +} + +/** + * Payload received from init-context response + * @internal + */ +export interface EntityContextPayload { + context: EntityContext; +} + +/** + * Payload received from init-action-config response + * @internal + */ +export interface ActionConfigPayload> { + config: ActionConfig; +} + +/** + * Payload sent with update-action-config event + * @internal + */ +export interface UpdateActionConfigPayload { + config: T; + wait_for_callback?: boolean; +} + +/** + * Payload sent with update-content-height event + * @internal + */ +export interface UpdateContentHeightPayload { + contentHeight: number; +} + +/** + * Map of all known app-bridge events and their payloads. + * Used for type-safe event handling. + */ +export interface AppBridgeEventMap { + 'app-bridge:init': { + outgoing: InitPayload; + incoming: InitResponsePayload; + }; + 'init-context': { + outgoing: Record; + incoming: EntityContextPayload; + }; + 'init-action-config': { + outgoing: Record; + incoming: ActionConfigPayload; + }; + 'update-action-config': { + outgoing: UpdateActionConfigPayload; + incoming: never; + }; + 'update-content-height': { + outgoing: UpdateContentHeightPayload; + incoming: never; + }; +} diff --git a/packages/app-bridge/tsconfig.json b/packages/app-bridge/tsconfig.json index 1da7acc8..009d320a 100644 --- a/packages/app-bridge/tsconfig.json +++ b/packages/app-bridge/tsconfig.json @@ -24,5 +24,6 @@ "noUncheckedSideEffectImports": true, "types": ["vitest/globals"] }, - "include": ["**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["examples/**", "node_modules/**"] } diff --git a/packages/app-bridge/vitest.config.mts b/packages/app-bridge/vitest.config.mts index aa346715..bd572b00 100644 --- a/packages/app-bridge/vitest.config.mts +++ b/packages/app-bridge/vitest.config.mts @@ -5,5 +5,7 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, + include: ['src/**/*.test.ts'], + exclude: ['examples/**', 'node_modules/**'], }, }); From 2ab718c7d011c6431863aeaf2b37c7f576cb6199 Mon Sep 17 00:00:00 2001 From: Viljami Kuosmanen Date: Thu, 29 Jan 2026 09:45:01 +0200 Subject: [PATCH 2/2] Remove symlinks for dev --- packages/app-bridge/examples/app-internals | 1 - packages/app-bridge/examples/epilot-app-feed-in-tariffs | 1 - packages/app-bridge/examples/epilot-app-schufa | 1 - packages/app-bridge/examples/epilot-app-zapier | 1 - packages/app-bridge/examples/epilot360-automation-hub | 1 - packages/app-bridge/examples/epilot360-entity | 1 - 6 files changed, 6 deletions(-) delete mode 120000 packages/app-bridge/examples/app-internals delete mode 120000 packages/app-bridge/examples/epilot-app-feed-in-tariffs delete mode 120000 packages/app-bridge/examples/epilot-app-schufa delete mode 120000 packages/app-bridge/examples/epilot-app-zapier delete mode 120000 packages/app-bridge/examples/epilot360-automation-hub delete mode 120000 packages/app-bridge/examples/epilot360-entity diff --git a/packages/app-bridge/examples/app-internals b/packages/app-bridge/examples/app-internals deleted file mode 120000 index 71877fc5..00000000 --- a/packages/app-bridge/examples/app-internals +++ /dev/null @@ -1 +0,0 @@ -/Users/viljami/epilot/app-internals \ No newline at end of file diff --git a/packages/app-bridge/examples/epilot-app-feed-in-tariffs b/packages/app-bridge/examples/epilot-app-feed-in-tariffs deleted file mode 120000 index 6683b59f..00000000 --- a/packages/app-bridge/examples/epilot-app-feed-in-tariffs +++ /dev/null @@ -1 +0,0 @@ -/Users/viljami/epilot/epilot-app-feed-in-tariffs \ No newline at end of file diff --git a/packages/app-bridge/examples/epilot-app-schufa b/packages/app-bridge/examples/epilot-app-schufa deleted file mode 120000 index 2405e084..00000000 --- a/packages/app-bridge/examples/epilot-app-schufa +++ /dev/null @@ -1 +0,0 @@ -/Users/viljami/epilot/epilot-app-schufa \ No newline at end of file diff --git a/packages/app-bridge/examples/epilot-app-zapier b/packages/app-bridge/examples/epilot-app-zapier deleted file mode 120000 index 3e61aa5b..00000000 --- a/packages/app-bridge/examples/epilot-app-zapier +++ /dev/null @@ -1 +0,0 @@ -/Users/viljami/epilot/epilot-app-zapier \ No newline at end of file diff --git a/packages/app-bridge/examples/epilot360-automation-hub b/packages/app-bridge/examples/epilot360-automation-hub deleted file mode 120000 index 4bf7fa61..00000000 --- a/packages/app-bridge/examples/epilot360-automation-hub +++ /dev/null @@ -1 +0,0 @@ -/Users/viljami/epilot/epilot360-automation-hub/ \ No newline at end of file diff --git a/packages/app-bridge/examples/epilot360-entity b/packages/app-bridge/examples/epilot360-entity deleted file mode 120000 index 37f766fa..00000000 --- a/packages/app-bridge/examples/epilot360-entity +++ /dev/null @@ -1 +0,0 @@ -/Users/viljami/epilot/epilot360-entity \ No newline at end of file