Skip to content

Commit de7d578

Browse files
authored
Allow runtime federated plugin imports (jupyterlab#147)
* Allow runtime federated plugin imports * fix merge conflicts
1 parent 3802ffc commit de7d578

2 files changed

Lines changed: 167 additions & 22 deletions

File tree

src/resolver.ts

Lines changed: 101 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ function handleImportError(error: Error, module: string) {
2525
});
2626
}
2727

28+
function errorMessage(error: unknown): string {
29+
return error instanceof Error ? error.message : String(error);
30+
}
31+
2832
export namespace ImportResolver {
2933
export interface IOptions {
3034
loadKnownModule: (name: string) => Promise<IModule | null>;
@@ -83,6 +87,10 @@ interface ILocalCssSnapshotEntry {
8387
previousCss: string | null;
8488
}
8589

90+
interface IFederatedExtensionContainer {
91+
get: (key: string) => Promise<(() => IModule) | IModule>;
92+
}
93+
8694
export class ImportResolver {
8795
private static _localCssStyles = new Map<string, HTMLStyleElement>();
8896
private static _localCssSnapshotStacks = new Map<
@@ -183,31 +191,18 @@ export class ImportResolver {
183191
*/
184192
async resolve(module: string): Promise<Token<any> | IModule | IModuleMember> {
185193
try {
186-
const tokenAndDefaultHandler = {
187-
get: (
188-
target: IModule,
189-
prop: string | number | symbol,
190-
receiver: any
191-
) => {
192-
if (typeof prop !== 'string') {
193-
return Reflect.get(target, prop, receiver);
194-
}
195-
const tokenName = `${module}:${prop}`;
196-
if (this._options.tokenMap.has(tokenName)) {
197-
return this._options.tokenMap.get(tokenName);
198-
}
199-
// synthetic default import (without proxy)
200-
if (prop === 'default' && !(prop in target)) {
201-
return target;
202-
}
203-
return Reflect.get(target, prop, receiver);
204-
}
205-
};
206-
207194
const knownModule = await this._resolveKnownModule(module);
208195
if (knownModule !== null) {
209-
return new Proxy(knownModule, tokenAndDefaultHandler);
196+
return this._createTokenAwareModule(module, knownModule);
197+
}
198+
199+
const federatedModule = await this._resolveFederatedExtensionModule(
200+
module
201+
);
202+
if (federatedModule !== null) {
203+
return this._createTokenAwareModule(module, federatedModule);
210204
}
205+
211206
const localFile = await this._resolveLocalFile(module);
212207
if (localFile !== null) {
213208
return localFile;
@@ -233,6 +228,90 @@ export class ImportResolver {
233228
}
234229
}
235230

231+
private _createTokenAwareModule(
232+
module: string,
233+
targetModule: IModule
234+
): IModule {
235+
return new Proxy(targetModule, {
236+
get: (target: IModule, prop: string | number | symbol, receiver: any) => {
237+
if (typeof prop !== 'string') {
238+
return Reflect.get(target, prop, receiver);
239+
}
240+
const tokenName = `${module}:${prop}`;
241+
if (this._options.tokenMap.has(tokenName)) {
242+
return this._options.tokenMap.get(tokenName);
243+
}
244+
// synthetic default import (without proxy)
245+
if (prop === 'default' && !(prop in target)) {
246+
return target;
247+
}
248+
return Reflect.get(target, prop, receiver);
249+
}
250+
});
251+
}
252+
253+
private async _resolveFederatedExtensionModule(
254+
module: string
255+
): Promise<IModule | null> {
256+
if (module.startsWith('.')) {
257+
return null;
258+
}
259+
if (typeof window === 'undefined') {
260+
return null;
261+
}
262+
263+
const runtime = window as Window & {
264+
_JUPYTERLAB?: Record<string, IFederatedExtensionContainer>;
265+
};
266+
const container = runtime._JUPYTERLAB?.[module];
267+
if (!container) {
268+
return null;
269+
}
270+
if (typeof container.get !== 'function') {
271+
throw new Error(
272+
`Federated extension container ${module} does not expose get().`
273+
);
274+
}
275+
276+
let exposed: (() => IModule) | IModule;
277+
try {
278+
exposed = await container.get('./extension');
279+
} catch (error) {
280+
throw new Error(
281+
`Failed to resolve federated extension module ${module} from ./extension: ${errorMessage(
282+
error
283+
)}`
284+
);
285+
}
286+
287+
const factory =
288+
typeof exposed === 'function'
289+
? (exposed as () => IModule)
290+
: () => exposed as IModule;
291+
292+
let resolved: unknown;
293+
try {
294+
resolved = factory();
295+
} catch (error) {
296+
throw new Error(
297+
`Failed to evaluate federated extension module ${module} from ./extension: ${errorMessage(
298+
error
299+
)}`
300+
);
301+
}
302+
303+
if (
304+
!resolved ||
305+
(typeof resolved !== 'object' && typeof resolved !== 'function')
306+
) {
307+
throw new Error(
308+
`Federated extension module ${module} did not return a module object from ./extension.`
309+
);
310+
}
311+
312+
return resolved as IModule;
313+
}
314+
236315
private async _getCDNConsent(
237316
module: string,
238317
cdnUrl: string

ui-tests/tests/plugin-playground.spec.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { expect, galata, test } from '@jupyterlab/galata';
2+
import { KNOWN_MODULE_NAMES } from '../../src/modules';
23
import type { FileEditorWidget } from '@jupyterlab/fileeditor';
34
import type { IJupyterLabPageFixture } from '@jupyterlab/galata';
45
import type { Contents } from '@jupyterlab/services';
@@ -24,6 +25,10 @@ const TEST_PLUGIN_ID = 'playground-integration-test:plugin';
2425
const TEST_TOGGLE_COMMAND = 'playground-integration-test:toggle';
2526
const TEST_ARGS_COMMAND = 'playground-integration-test:with-args';
2627
const TEST_FILE = 'playground-integration-test.ts';
28+
const FEDERATED_RUNTIME_PACKAGE = '@jupyterlab/plugin-playground';
29+
const FEDERATED_RUNTIME_CONSUMER_PLUGIN_ID = 'runtime-consumer-test:plugin';
30+
const FEDERATED_RUNTIME_CONSUMER_COMMAND = 'runtime-consumer-test:check';
31+
const FEDERATED_RUNTIME_CONSUMER_FILE = 'runtime-consumer-test.ts';
2732
const CSS_IMPORT_TEST_PLUGIN_ID = 'playground-css-import-test:plugin';
2833
const CSS_IMPORT_TEST_MARKER_ID = 'playground-css-import-test-marker';
2934
const CSS_IMPORT_TEST_COLOR = 'rgb(17, 34, 51)';
@@ -900,6 +905,67 @@ test('loads current editor file as a plugin extension', async ({
900905
).resolves.toBe(true);
901906
});
902907

908+
test('loads plugin importing runtime federated module outside known module map', async ({
909+
page,
910+
tmpPath
911+
}) => {
912+
const consumerPath = `${tmpPath}/${FEDERATED_RUNTIME_CONSUMER_FILE}`;
913+
expect(KNOWN_MODULE_NAMES).not.toContain(FEDERATED_RUNTIME_PACKAGE);
914+
const consumerSource = `import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application';
915+
import { IPluginPlayground } from '${FEDERATED_RUNTIME_PACKAGE}';
916+
import type { IPluginPlayground as IPluginPlaygroundType } from '${FEDERATED_RUNTIME_PACKAGE}';
917+
918+
const plugin: JupyterFrontEndPlugin<void> = {
919+
id: '${FEDERATED_RUNTIME_CONSUMER_PLUGIN_ID}',
920+
autoStart: true,
921+
requires: [IPluginPlayground],
922+
activate: (app: JupyterFrontEnd, playground: IPluginPlaygroundType) => {
923+
app.commands.addCommand('${FEDERATED_RUNTIME_CONSUMER_COMMAND}', {
924+
label: 'Runtime Federated Consumer Check',
925+
execute: () => typeof playground.shareViaLink === 'function'
926+
});
927+
}
928+
};
929+
930+
export default plugin;
931+
`;
932+
933+
await page.contents.uploadContent(consumerSource, 'text', consumerPath);
934+
await page.goto();
935+
936+
await page.waitForCondition(() =>
937+
page.evaluate((id: string) => {
938+
return window.jupyterapp.commands.hasCommand(id);
939+
}, LOAD_COMMAND)
940+
);
941+
942+
await page.filebrowser.open(consumerPath);
943+
expect(await page.activity.activateTab(FEDERATED_RUNTIME_CONSUMER_FILE)).toBe(
944+
true
945+
);
946+
947+
const consumerLoadResult = await page.evaluate((id: string) => {
948+
return window.jupyterapp.commands.execute(id);
949+
}, LOAD_COMMAND);
950+
expect(consumerLoadResult.ok).toBe(true);
951+
expect(consumerLoadResult.status).toBe('loaded');
952+
expect(consumerLoadResult.pluginIds).toContain(
953+
FEDERATED_RUNTIME_CONSUMER_PLUGIN_ID
954+
);
955+
956+
await page.waitForCondition(() =>
957+
page.evaluate((id: string) => {
958+
return window.jupyterapp.commands.hasCommand(id);
959+
}, FEDERATED_RUNTIME_CONSUMER_COMMAND)
960+
);
961+
962+
await expect(
963+
page.evaluate((id: string) => {
964+
return window.jupyterapp.commands.execute(id);
965+
}, FEDERATED_RUNTIME_CONSUMER_COMMAND)
966+
).resolves.toBe(true);
967+
});
968+
903969
test('loads local CSS imports and cleans stale styles on reload', async ({
904970
page,
905971
tmpPath

0 commit comments

Comments
 (0)