Appearance
Best Practices
This guide covers patterns, recommendations, and best practices for building high-quality BluePLM extensions.
Architecture Patterns
1. Use the Disposable Pattern
Always push subscriptions to context.subscriptions for automatic cleanup:
typescript
// ✓ Good: Resources are auto-disposed
export async function activate(context, api) {
context.subscriptions.push(
api.commands.registerCommand('myext.cmd', handler),
api.workspace.onFileChanged(fileHandler),
api.events.on('vault:changed', vaultHandler)
)
}
// ❌ Bad: Memory leak on deactivate
export async function activate(context, api) {
api.commands.registerCommand('myext.cmd', handler)
// No cleanup!
}2. Lazy Initialization
Don't do heavy work in activate. Use activation events:
typescript
// ✓ Good: Fast activation, lazy loading
export async function activate(context, api) {
// Register commands immediately (fast)
context.subscriptions.push(
api.commands.registerCommand('myext.sync', async () => {
// Heavy work only when command is invoked
const data = await loadLargeDataset()
await performSync(data)
})
)
}
// ❌ Bad: Slow activation
export async function activate(context, api) {
// This blocks activation
const data = await loadLargeDataset()
context.subscriptions.push(
api.commands.registerCommand('myext.sync', () => performSync(data))
)
}3. Activation Events
Choose specific activation events for fast startup:
json
// ✓ Good: Specific events
"activationEvents": [
"onCommand:myext.sync",
"onNavigate:settings/extensions/myext"
]
// ❌ Avoid: Loads on every startup
"activationEvents": [
"onStartup"
]Only use onStartup if:
- Extension needs to run background tasks always
- Extension intercepts global events
4. Separate Concerns
Organize code by responsibility:
client/
├── index.ts # Entry point only
├── commands/ # Command handlers
│ ├── sync.ts
│ └── configure.ts
├── views/ # React components
│ ├── Panel.tsx
│ └── Settings.tsx
├── services/ # Business logic
│ ├── syncService.ts
│ └── apiClient.ts
└── utils/ # Pure utilities
├── formatting.ts
└── validation.tsError Handling
1. Always Handle Async Errors
typescript
// ✓ Good: Errors handled
context.subscriptions.push(
api.commands.registerCommand('myext.sync', async () => {
try {
await performSync(api)
} catch (error) {
context.log.error('Sync failed:', error)
api.ui.showToast('Sync failed. Check logs for details.', 'error')
api.telemetry.trackError(error as Error, { operation: 'sync' })
}
})
)
// ❌ Bad: Unhandled rejection
api.commands.registerCommand('myext.sync', async () => {
await performSync(api) // Might throw!
})2. User-Friendly Error Messages
typescript
// ✓ Good: Helpful message
try {
await api.callOrgApi('/extensions/myext/sync', { ... })
} catch (error) {
if (error.message.includes('401')) {
api.ui.showToast('Session expired. Please log in again.', 'error')
} else if (error.message.includes('timeout')) {
api.ui.showToast('Server is slow. Please try again.', 'warning')
} else {
api.ui.showToast('Sync failed. Check your connection.', 'error')
}
}
// ❌ Bad: Exposing technical details
api.ui.showToast(`Error: ${error.stack}`, 'error')3. Graceful Degradation
typescript
async function getStatus(api: ExtensionClientAPI) {
try {
const response = await api.callOrgApi('/extensions/myext/status')
return response.data
} catch (error) {
// Fallback to cached status
const cached = await api.storage.get('lastStatus')
if (cached) {
context.log.warn('Using cached status due to API error')
return { ...cached, stale: true }
}
throw error
}
}Performance
1. Startup Time Budget
Extensions should activate in under 200ms:
typescript
export async function activate(context, api) {
const start = performance.now()
// Register handlers (fast)
registerCommands(context, api)
registerEventListeners(context, api)
const duration = performance.now() - start
if (duration > 200) {
context.log.warn(`Slow activation: ${duration}ms`)
}
}2. Debounce Frequent Events
typescript
import { debounce } from './utils'
const debouncedSync = debounce(async (events: FileChangeEvent[]) => {
await performIncrementalSync(events)
}, 500)
context.subscriptions.push(
api.workspace.onFileChanged(debouncedSync)
)Simple debounce implementation:
typescript
function debounce<T extends (...args: any[]) => any>(
fn: T,
ms: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout> | undefined
return (...args) => {
clearTimeout(timeout)
timeout = setTimeout(() => fn(...args), ms)
}
}3. Cache Expensive Operations
typescript
let cachedVaults: VaultInfo[] | undefined
async function getVaults(api: ExtensionClientAPI): Promise<VaultInfo[]> {
if (cachedVaults) return cachedVaults
cachedVaults = await api.workspace.getVaults()
return cachedVaults
}
// Invalidate on vault changes
context.subscriptions.push(
api.events.on('vault:changed', () => {
cachedVaults = undefined
})
)4. Batch Operations
typescript
// ✓ Good: Batch storage operations
const settings = {
autoSync: true,
interval: 300,
excludePatterns: ['*.tmp']
}
await api.storage.set('settings', settings)
// ❌ Bad: Multiple round-trips
await api.storage.set('autoSync', true)
await api.storage.set('interval', 300)
await api.storage.set('excludePatterns', ['*.tmp'])UI/UX
1. Provide Clear Feedback
typescript
async function handleSync(api: ExtensionClientAPI) {
// Show progress for long operations
await api.ui.showProgress(
{ title: 'Syncing files...', cancellable: true },
async (progress, token) => {
progress.report({ message: 'Preparing...' })
const files = await getFiles()
for (let i = 0; i < files.length; i++) {
if (token.isCancellationRequested) {
api.ui.showToast('Sync cancelled', 'info')
return
}
progress.report({
message: `Syncing ${files[i].name}`,
increment: 100 / files.length
})
await syncFile(files[i])
}
api.ui.showToast('Sync complete!', 'success')
}
)
}2. Confirm Destructive Actions
typescript
async function handleDelete(api: ExtensionClientAPI, file: string) {
const result = await api.ui.showDialog({
title: 'Delete File',
message: `Are you sure you want to delete "${file}"? This cannot be undone.`,
type: 'confirm',
confirmText: 'Delete',
cancelText: 'Cancel'
})
if (result.confirmed) {
await deleteFile(file)
api.ui.showToast('File deleted', 'success')
}
}3. Use Status Indicators
typescript
// Show connection status
async function checkConnection(api: ExtensionClientAPI) {
api.ui.setStatus('checking')
try {
await api.callOrgApi('/extensions/myext/ping')
api.ui.setStatus('online')
} catch {
api.ui.setStatus('offline')
}
}
// Update on reconnection
context.subscriptions.push(
api.events.on('online:changed', (isOnline) => {
api.ui.setStatus(isOnline ? 'online' : 'offline')
})
)4. Respect User Preferences
typescript
async function maybeShowHint(api: ExtensionClientAPI) {
const dismissed = await api.storage.get<boolean>('hintDismissed')
if (!dismissed) {
const result = await api.ui.showDialog({
title: 'Tip',
message: 'Press Ctrl+Shift+S to sync quickly!',
type: 'info',
confirmText: 'Got it',
cancelText: "Don't show again"
})
if (!result.confirmed && !result.dismissed) {
await api.storage.set('hintDismissed', true)
}
}
}Security
1. Validate All Input
typescript
// Server handler
export default async function handler(api: ExtensionServerAPI) {
const { vaultId, action } = api.request.body as {
vaultId?: unknown
action?: unknown
}
// Type and format validation
if (typeof vaultId !== 'string' || !vaultId.match(/^[a-f0-9-]{36}$/)) {
return api.response.error('Invalid vaultId', 400)
}
if (!['sync', 'backup', 'restore'].includes(String(action))) {
return api.response.error('Invalid action', 400)
}
// Safe to use
await performAction(vaultId, action as 'sync' | 'backup' | 'restore')
}2. Never Expose Secrets
typescript
// ❌ Bad: Leaking secrets
return api.response.json({
apiKey: await api.secrets.get('api_key')
})
// ✓ Good: Only expose status
return api.response.json({
configured: !!(await api.secrets.get('api_key'))
})3. Use HTTPS for External APIs
json
// ✓ Good: HTTPS only
"permissions": {
"server": ["http:domain:api.example.com"]
}
// In handler
await api.http.fetch('https://api.example.com/data')4. Check Permissions Before Actions
typescript
export default async function handler(api: ExtensionServerAPI) {
const { user } = api
// Ensure authenticated
if (!user) {
return api.response.error('Authentication required', 401)
}
// Check role for admin actions
if (api.request.body.action === 'deleteAll') {
if (user.role !== 'admin' && user.role !== 'owner') {
return api.response.error('Admin access required', 403)
}
}
}Testing
1. Mock the API
typescript
// test/commands.test.ts
const mockApi: Partial<ExtensionClientAPI> = {
ui: {
showToast: jest.fn(),
showDialog: jest.fn().mockResolvedValue({ confirmed: true }),
showProgress: jest.fn((opts, task) => task(
{ report: jest.fn() },
{ isCancellationRequested: false }
)),
setStatus: jest.fn(),
showQuickPick: jest.fn(),
showInputBox: jest.fn()
},
storage: {
get: jest.fn(),
set: jest.fn(),
delete: jest.fn(),
keys: jest.fn(),
has: jest.fn(),
clear: jest.fn()
},
callOrgApi: jest.fn().mockResolvedValue({
ok: true,
data: { fileCount: 10 }
})
}
test('sync command shows success toast', async () => {
await syncCommand(mockApi as ExtensionClientAPI)
expect(mockApi.ui?.showToast).toHaveBeenCalledWith(
expect.stringContaining('complete'),
'success'
)
})2. Test Edge Cases
typescript
test('handles API timeout gracefully', async () => {
mockApi.callOrgApi = jest.fn().mockRejectedValue(new Error('timeout'))
await syncCommand(mockApi as ExtensionClientAPI)
expect(mockApi.ui?.showToast).toHaveBeenCalledWith(
expect.stringContaining('try again'),
'error'
)
})
test('handles no vault selected', async () => {
mockApi.workspace = {
...mockApi.workspace,
getCurrentVault: jest.fn().mockResolvedValue(undefined)
}
await syncCommand(mockApi as ExtensionClientAPI)
expect(mockApi.ui?.showToast).toHaveBeenCalledWith(
'No vault selected',
'error'
)
})3. Test Settings Persistence
typescript
test('settings are persisted', async () => {
const storage = new Map()
mockApi.storage = {
set: jest.fn((key, value) => { storage.set(key, value); return Promise.resolve() }),
get: jest.fn((key) => Promise.resolve(storage.get(key))),
// ...
}
await saveSettings(mockApi, { autoSync: true, interval: 300 })
const loaded = await loadSettings(mockApi)
expect(loaded).toEqual({ autoSync: true, interval: 300 })
})Code Organization
1. Type Everything
typescript
// types.ts
export interface SyncOptions {
vaultId: string
force?: boolean
dryRun?: boolean
}
export interface SyncResult {
fileCount: number
duration: number
errors: string[]
}
// sync.ts
async function performSync(
api: ExtensionClientAPI,
options: SyncOptions
): Promise<SyncResult> {
// Type-safe implementation
}2. Use Constants
typescript
// constants.ts
export const STORAGE_KEYS = {
SETTINGS: 'settings',
LAST_SYNC: 'lastSync',
CACHE: 'cache'
} as const
export const COMMANDS = {
SYNC: 'myext.sync',
CONFIGURE: 'myext.configure'
} as const
// Usage
await api.storage.set(STORAGE_KEYS.SETTINGS, settings)
api.commands.registerCommand(COMMANDS.SYNC, handler)3. Document Complex Logic
typescript
/**
* Performs incremental sync by comparing local and remote file hashes.
*
* Algorithm:
* 1. Get list of local files with hashes
* 2. Fetch remote file list from API
* 3. Compare hashes to find changed files
* 4. Upload changed local files
* 5. Download changed remote files
* 6. Handle conflicts based on user preference
*
* @param api - Extension client API
* @param vaultId - Target vault ID
* @returns Sync result with counts and any errors
*/
async function performIncrementalSync(
api: ExtensionClientAPI,
vaultId: string
): Promise<SyncResult> {
// Implementation...
}Common Patterns
Configuration Loading
typescript
interface ExtensionConfig {
autoSync: boolean
syncInterval: number
excludePatterns: string[]
}
const DEFAULT_CONFIG: ExtensionConfig = {
autoSync: true,
syncInterval: 300,
excludePatterns: ['*.tmp', '.git/**']
}
async function loadConfig(api: ExtensionClientAPI): Promise<ExtensionConfig> {
const saved = await api.storage.get<Partial<ExtensionConfig>>('config')
return { ...DEFAULT_CONFIG, ...saved }
}
async function saveConfig(
api: ExtensionClientAPI,
config: ExtensionConfig
): Promise<void> {
await api.storage.set('config', config)
api.events.emit('myext.configChanged', config)
}Periodic Background Tasks
typescript
let intervalId: ReturnType<typeof setInterval> | undefined
export async function activate(context, api) {
const config = await loadConfig(api)
if (config.autoSync) {
intervalId = setInterval(async () => {
try {
await performBackgroundSync(api)
} catch (error) {
context.log.error('Background sync failed:', error)
}
}, config.syncInterval * 1000)
}
// Clean up on deactivate
context.subscriptions.push({
dispose: () => {
if (intervalId) clearInterval(intervalId)
}
})
}State Machine for Complex Operations
typescript
type SyncState = 'idle' | 'syncing' | 'error' | 'paused'
class SyncManager {
private state: SyncState = 'idle'
private listeners: Set<(state: SyncState) => void> = new Set()
async sync(api: ExtensionClientAPI): Promise<void> {
if (this.state === 'syncing') return
this.setState('syncing')
try {
await performSync(api)
this.setState('idle')
} catch (error) {
this.setState('error')
throw error
}
}
private setState(state: SyncState) {
this.state = state
this.listeners.forEach(l => l(state))
}
onStateChange(callback: (state: SyncState) => void): () => void {
this.listeners.add(callback)
return () => this.listeners.delete(callback)
}
}