feat: add runtime API registry for dynamic provider switching

This commit is contained in:
2026-09-09 17:08:15 +00:00
parent 0679aabaa1
commit 23aea36d0e
6 changed files with 320 additions and 110 deletions
@@ -2,10 +2,38 @@ import React, { type JSX, type ReactNode } from 'react';
import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
import type { FilesAPI, RuntimeAPIs } from '@/lib/api/types';
import { createContentCachedFiles } from '@/contexts/content-cache-owner';
import {
resolveActiveRuntimeAPIs,
subscribeRuntimeProviderChanged,
} from '@/lib/runtime-api-registry';
type ContentCachedFiles = ReturnType<typeof createContentCachedFiles>;
export function RuntimeAPIProvider({ apis, children }: { apis: RuntimeAPIs; children: ReactNode }): JSX.Element {
interface RuntimeAPIProviderProps {
/** Runtime APIs to provide. Ignored when a registry provider is active. */
apis: RuntimeAPIs;
children: ReactNode;
}
/**
* Provides RuntimeAPIs to the React tree. When a provider is registered in
* the runtime API registry, the component automatically re-resolves the APIs
* on provider switches. Otherwise it uses the `apis` prop directly (legacy
* behaviour, fully backward compatible).
*/
export function RuntimeAPIProvider({ apis: fallbackApis, children }: RuntimeAPIProviderProps): JSX.Element {
const [registryApis, setRegistryApis] = React.useState<RuntimeAPIs | null>(() =>
resolveActiveRuntimeAPIs(),
);
React.useEffect(() => {
return subscribeRuntimeProviderChanged(() => {
setRegistryApis(resolveActiveRuntimeAPIs());
});
}, []);
const apis = registryApis ?? fallbackApis;
// Effect-owned lifecycle: React Strict Mode dispose+remount must create a fresh
// owner. useMemo + dispose reused a dead owner and broke text-file opens
// (binaries skipped the pre-read, so they still appeared to work).
@@ -0,0 +1,152 @@
import { describe, expect, test, vi, beforeEach, afterEach } from 'vitest';
import type { RuntimeAPIs } from '@/lib/api/types';
import {
registerRuntimeProvider,
unregisterRuntimeProvider,
getRuntimeProvider,
getActiveRuntimeProviderName,
listRuntimeProviders,
setActiveRuntimeProvider,
resolveActiveRuntimeAPIs,
subscribeRuntimeProviderChanged,
} from './runtime-api-registry';
const stubApis = (platform: string): RuntimeAPIs => ({
runtime: { platform: platform as 'web' | 'desktop' | 'vscode', isDesktop: false, isVSCode: false, label: platform },
terminal: {} as RuntimeAPIs['terminal'],
git: {} as RuntimeAPIs['git'],
files: {} as RuntimeAPIs['files'],
settings: {} as RuntimeAPIs['settings'],
permissions: {} as RuntimeAPIs['permissions'],
notifications: {} as RuntimeAPIs['notifications'],
tools: {} as RuntimeAPIs['tools'],
});
// Ensure window is available for DOM event dispatch in bun test environment
const ensureWindow = () => {
if (typeof globalThis.window === 'undefined') {
(globalThis as Record<string, unknown>).window = globalThis;
}
if (typeof globalThis.CustomEvent === 'undefined') {
(globalThis as Record<string, unknown>).CustomEvent = class CustomEvent<T = unknown> extends Event {
detail: T;
constructor(type: string, options?: CustomEventInit<T>) {
super(type, options);
this.detail = options?.detail as T;
}
};
}
};
describe('runtime-api-registry', () => {
beforeEach(() => {
ensureWindow();
// Clean up all registered providers between tests
for (const name of listRuntimeProviders()) {
unregisterRuntimeProvider(name);
}
});
test('registerRuntimeProvider makes a provider retrievable', () => {
const factory = () => stubApis('web');
registerRuntimeProvider('web', factory);
expect(getRuntimeProvider('web')).toBe(factory);
});
test('listRuntimeProviders returns all registered names', () => {
registerRuntimeProvider('a', () => stubApis('a'));
registerRuntimeProvider('b', () => stubApis('b'));
expect(listRuntimeProviders()).toEqual(expect.arrayContaining(['a', 'b']));
});
test('unregisterRuntimeProvider removes a provider', () => {
registerRuntimeProvider('temp', () => stubApis('temp'));
unregisterRuntimeProvider('temp');
expect(getRuntimeProvider('temp')).toBeUndefined();
});
test('unregisterRuntimeProvider clears active when removing the active provider', () => {
registerRuntimeProvider('temp', () => stubApis('temp'));
setActiveRuntimeProvider('temp');
expect(getActiveRuntimeProviderName()).toBe('temp');
unregisterRuntimeProvider('temp');
expect(getActiveRuntimeProviderName()).toBeNull();
});
test('setActiveRuntimeProvider switches the active provider', () => {
registerRuntimeProvider('web', () => stubApis('web'));
registerRuntimeProvider('vscode', () => stubApis('vscode'));
setActiveRuntimeProvider('web');
expect(getActiveRuntimeProviderName()).toBe('web');
setActiveRuntimeProvider('vscode');
expect(getActiveRuntimeProviderName()).toBe('vscode');
});
test('setActiveRuntimeProvider throws for unknown provider', () => {
expect(() => setActiveRuntimeProvider('nonexistent')).toThrow('not registered');
});
test('setActiveRuntimeProvider is idempotent for the same name', () => {
registerRuntimeProvider('web', () => stubApis('web'));
setActiveRuntimeProvider('web');
// Second call with same name should not throw or change anything
setActiveRuntimeProvider('web');
expect(getActiveRuntimeProviderName()).toBe('web');
});
test('resolveActiveRuntimeAPIs returns the APIs from the active provider', () => {
registerRuntimeProvider('web', () => stubApis('web'));
setActiveRuntimeProvider('web');
const apis = resolveActiveRuntimeAPIs();
expect(apis?.runtime.label).toBe('web');
});
test('resolveActiveRuntimeAPIs returns null when no provider is active', () => {
expect(resolveActiveRuntimeAPIs()).toBeNull();
});
test('getRuntimeProvider without args returns the active provider', () => {
registerRuntimeProvider('web', () => stubApis('web'));
setActiveRuntimeProvider('web');
expect(getRuntimeProvider()).toBeDefined();
});
test('subscribeRuntimeProviderChanged fires on provider switch', () => {
registerRuntimeProvider('a', () => stubApis('a'));
registerRuntimeProvider('b', () => stubApis('b'));
const callback = vi.fn();
const unsub = subscribeRuntimeProviderChanged(callback);
setActiveRuntimeProvider('a');
expect(callback).toHaveBeenCalledWith({ previous: null, current: 'a' });
setActiveRuntimeProvider('b');
expect(callback).toHaveBeenCalledWith({ previous: 'a', current: 'b' });
unsub();
});
test('subscribeRuntimeProviderChanged unsubscribes correctly', () => {
registerRuntimeProvider('a', () => stubApis('a'));
registerRuntimeProvider('b', () => stubApis('b'));
const callback = vi.fn();
const unsub = subscribeRuntimeProviderChanged(callback);
setActiveRuntimeProvider('a');
expect(callback).toHaveBeenCalledTimes(1);
unsub();
setActiveRuntimeProvider('b');
expect(callback).toHaveBeenCalledTimes(1);
});
test('registerRuntimeProvider overwrites a previous factory with the same name', () => {
const factory1 = () => stubApis('web');
const factory2 = () => stubApis('desktop');
registerRuntimeProvider('web', factory1);
registerRuntimeProvider('web', factory2);
expect(getRuntimeProvider('web')).toBe(factory2);
});
});
+108
View File
@@ -0,0 +1,108 @@
import type { RuntimeAPIs } from '@/lib/api/types';
/**
* A factory that creates RuntimeAPIs for a named runtime.
*
* The factory is called each time the provider becomes active (or the
* endpoint changes while it is active), so it must be cheap and side-effect
* free beyond returning the APIs object.
*/
export type RuntimeAPIProviderFactory = () => RuntimeAPIs;
export type RuntimeAPIProviderChangedDetail = {
previous: string | null;
current: string | null;
};
const RUNTIME_API_PROVIDER_CHANGED_EVENT = 'openchamber:runtime-api-provider-changed';
// ---- Provider registry --------------------------------------------------
const providers = new Map<string, RuntimeAPIProviderFactory>();
let activeProviderName: string | null = null;
/**
* Register a named provider factory. Overwrites any previous factory with
* the same name. Does NOT activate the provider — call
* `setActiveRuntimeProvider` separately.
*/
export const registerRuntimeProvider = (name: string, factory: RuntimeAPIProviderFactory): void => {
providers.set(name, factory);
};
/**
* Remove a provider from the registry. If it was the active provider, the
* active slot is cleared.
*/
export const unregisterRuntimeProvider = (name: string): void => {
providers.delete(name);
if (activeProviderName === name) {
activeProviderName = null;
}
};
/**
* Look up a registered provider by name, or the currently active provider
* when `name` is omitted.
*/
export const getRuntimeProvider = (name?: string): RuntimeAPIProviderFactory | undefined => {
const key = name ?? activeProviderName;
return key != null ? providers.get(key) : undefined;
};
/**
* Return the name of the currently active provider, or null when none is
* active.
*/
export const getActiveRuntimeProviderName = (): string | null => activeProviderName;
/**
* Return the names of all registered providers.
*/
export const listRuntimeProviders = (): string[] => [...providers.keys()];
/**
* Switch the active provider. Throws when the name does not match a
* registered provider. Emits a DOM custom event so React subscribers
* (RuntimeAPIProvider) can re-resolve the APIs.
*/
export const setActiveRuntimeProvider = (name: string): void => {
if (!providers.has(name)) {
throw new Error(`Runtime provider "${name}" is not registered`);
}
const previous = activeProviderName;
if (previous === name) return;
activeProviderName = name;
emitProviderChanged({ previous, current: name });
};
// ---- Change subscription ------------------------------------------------
export const subscribeRuntimeProviderChanged = (
callback: (detail: RuntimeAPIProviderChangedDetail) => void,
): (() => void) => {
if (typeof window === 'undefined') return () => {};
const listener = (event: Event) => {
callback((event as CustomEvent<RuntimeAPIProviderChangedDetail>).detail);
};
window.addEventListener(RUNTIME_API_PROVIDER_CHANGED_EVENT, listener);
return () => window.removeEventListener(RUNTIME_API_PROVIDER_CHANGED_EVENT, listener);
};
function emitProviderChanged(detail: RuntimeAPIProviderChangedDetail): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(
new CustomEvent<RuntimeAPIProviderChangedDetail>(RUNTIME_API_PROVIDER_CHANGED_EVENT, { detail }),
);
}
// ---- High-level helpers -------------------------------------------------
/**
* Resolve the RuntimeAPIs for the currently active provider. Returns null
* when no provider is registered or active.
*/
export const resolveActiveRuntimeAPIs = (): RuntimeAPIs | null => {
const factory = getRuntimeProvider();
return factory ? factory() : null;
};
+8
View File
@@ -1,4 +1,5 @@
import type { RuntimeAPIs, TerminalAPI } from '@openchamber/ui/lib/api/types';
import { registerRuntimeProvider } from '@openchamber/ui/lib/runtime-api-registry';
import { createVSCodeFilesAPI } from './files';
import { createVSCodeSettingsAPI } from './settings';
import { createVSCodePermissionsAPI } from './permissions';
@@ -38,3 +39,10 @@ export const createVSCodeAPIs = (): RuntimeAPIs => ({
editor: createVSCodeEditorAPI(),
vscode: createVSCodeActionsAPI(),
});
/**
* Register the VS Code provider with the runtime API registry.
*/
export const registerVSCodeProvider = (): void => {
registerRuntimeProvider('vscode', () => createVSCodeAPIs());
};
+23 -14
View File
@@ -5,6 +5,7 @@ import {
setRuntimeUrlResolver,
type RuntimeUrlResolver,
} from '@openchamber/ui/lib/runtime-url';
import { registerRuntimeProvider } from '@openchamber/ui/lib/runtime-api-registry';
import { useDirectoryStore } from '@openchamber/ui/stores/useDirectoryStore';
import { createWebTerminalAPI } from './terminal';
import { createWebGitAPI } from './git';
@@ -40,19 +41,27 @@ export const createWebAPIs = (options: WebAPIsOptions = {}): RuntimeAPIs => {
const activeUrls = createActiveRuntimeUrlResolver();
return {
runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' },
terminal: createWebTerminalAPI(),
git: createWebGitAPI(),
files: createWebFilesAPI({ urls: activeUrls, getDirectory: () => useDirectoryStore.getState().currentDirectory }),
settings: createWebSettingsAPI(),
permissions: createWebPermissionsAPI(),
notifications: createWebNotificationsAPI(),
github: createWebGitHubAPI({ urls: activeUrls }),
linear: createWebLinearAPI(),
gitlab: createWebGitLabAPI({ urls: activeUrls }),
gitea: createWebGiteaAPI({ urls: activeUrls }),
push: createWebPushAPI(),
clientAuth: createWebClientAuthAPI(),
tools: createWebToolsAPI(),
runtime: { platform: 'web', isDesktop: false, isVSCode: false, label: 'web' },
terminal: createWebTerminalAPI(),
git: createWebGitAPI(),
files: createWebFilesAPI({ urls: activeUrls, getDirectory: () => useDirectoryStore.getState().currentDirectory }),
settings: createWebSettingsAPI(),
permissions: createWebPermissionsAPI(),
notifications: createWebNotificationsAPI(),
github: createWebGitHubAPI({ urls: activeUrls }),
linear: createWebLinearAPI(),
gitlab: createWebGitLabAPI({ urls: activeUrls }),
gitea: createWebGiteaAPI({ urls: activeUrls }),
push: createWebPushAPI(),
clientAuth: createWebClientAuthAPI(),
tools: createWebToolsAPI(),
};
};
/**
* Register the web provider with the runtime API registry. Call once during
* app bootstrap (after the URL resolver and auth are configured).
*/
export const registerWebProvider = (): void => {
registerRuntimeProvider('web', () => createWebAPIs());
};