diff --git a/package-lock.json b/package-lock.json index dc4505c7f1..4f62187f87 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22837,7 +22837,8 @@ }, "node_modules/shortid": { "version": "2.2.16", - "license": "MIT", + "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.16.tgz", + "integrity": "sha512-Ugt+GIZqvGXCIItnsL+lvFJOiN7RYqlGy7QE41O3YC1xbNSeDGIRO7xg2JJXIAj1cAGnOeC1r7/T9pgrtQbv4g==", "dependencies": { "nanoid": "^2.1.0" } @@ -26570,7 +26571,8 @@ "@deephaven/filters": "file:../filters", "@deephaven/jsapi-shim": "file:../jsapi-shim", "@deephaven/log": "file:../log", - "@deephaven/utils": "file:../utils" + "@deephaven/utils": "file:../utils", + "shortid": "^2.2.16" }, "devDependencies": { "@deephaven/tsconfig": "file:../tsconfig" @@ -28487,7 +28489,8 @@ "@deephaven/jsapi-shim": "file:../jsapi-shim", "@deephaven/log": "file:../log", "@deephaven/tsconfig": "file:../tsconfig", - "@deephaven/utils": "file:../utils" + "@deephaven/utils": "file:../utils", + "shortid": "^2.2.16" } }, "@deephaven/log": { @@ -43485,6 +43488,8 @@ }, "shortid": { "version": "2.2.16", + "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.16.tgz", + "integrity": "sha512-Ugt+GIZqvGXCIItnsL+lvFJOiN7RYqlGy7QE41O3YC1xbNSeDGIRO7xg2JJXIAj1cAGnOeC1r7/T9pgrtQbv4g==", "requires": { "nanoid": "^2.1.0" }, diff --git a/packages/code-studio/src/main/AppInit.tsx b/packages/code-studio/src/main/AppInit.tsx index 9671c112df..52547b2ed0 100644 --- a/packages/code-studio/src/main/AppInit.tsx +++ b/packages/code-studio/src/main/AppInit.tsx @@ -11,6 +11,7 @@ import { setDashboardData as setDashboardDataAction, } from '@deephaven/dashboard'; import { + SessionDetails, SessionWrapper, setDashboardConnection as setDashboardConnectionAction, setDashboardSessionWrapper as setDashboardSessionWrapperAction, @@ -52,6 +53,7 @@ import { createSessionWrapper, getAuthType, getLoginOptions, + getSessionDetails, } from './SessionUtils'; import { PluginUtils } from '../plugins'; import LayoutStorage from '../storage/LayoutStorage'; @@ -98,11 +100,12 @@ async function loadPlugins(): Promise { } async function loadSessionWrapper( - connection: IdeConnection + connection: IdeConnection, + sessionDetails: SessionDetails ): Promise { let sessionWrapper: SessionWrapper | undefined; try { - sessionWrapper = await createSessionWrapper(connection); + sessionWrapper = await createSessionWrapper(connection, sessionDetails); } catch (e) { // Consoles may be disabled on the server, but we should still be able to start up and open existing objects if (!isNoConsolesError(e)) { @@ -170,7 +173,11 @@ function AppInit(props: AppInitProps) { const coreClient = createCoreClient(); const authType = getAuthType(); log.info(`Login using auth type ${authType}...`); - await coreClient.login(await getLoginOptions(authType)); + const [loginOptions, sessionDetails] = await Promise.all([ + getLoginOptions(authType), + getSessionDetails(authType), + ]); + await coreClient.login(loginOptions); const newPlugins = await loadPlugins(); const connection = await (authType === AUTH_TYPE.ANONYMOUS && @@ -185,7 +192,10 @@ function AppInit(props: AppInitProps) { setDisconnectError(null); }); - const sessionWrapper = await loadSessionWrapper(connection); + const sessionWrapper = await loadSessionWrapper( + connection, + sessionDetails + ); const name = 'user'; const storageService = coreClient.getStorageService(); diff --git a/packages/code-studio/src/main/SessionUtils.ts b/packages/code-studio/src/main/SessionUtils.ts index 721f0b5437..77027e140b 100644 --- a/packages/code-studio/src/main/SessionUtils.ts +++ b/packages/code-studio/src/main/SessionUtils.ts @@ -1,9 +1,17 @@ -import { SessionWrapper } from '@deephaven/dashboard-core-plugins'; +import { + SessionDetails, + SessionWrapper, +} from '@deephaven/dashboard-core-plugins'; import dh, { CoreClient, IdeConnection, LoginOptions, } from '@deephaven/jsapi-shim'; +import { + LOGIN_OPTIONS_REQUEST, + requestParentResponse, + SESSION_DETAILS_REQUEST, +} from '@deephaven/jsapi-utils'; import Log from '@deephaven/log'; import shortid from 'shortid'; import NoConsolesError from './NoConsolesError'; @@ -50,7 +58,8 @@ export function createConnection(): IdeConnection { * @returns A session and config that is ready to use */ export async function createSessionWrapper( - connection: IdeConnection + connection: IdeConnection, + details: SessionDetails ): Promise { log.info('Getting console types...'); @@ -72,7 +81,12 @@ export async function createSessionWrapper( log.info('Console session established', config); - return { session, config, connection }; + return { + session, + config, + connection, + details, + }; } export function createCoreClient(): CoreClient { @@ -83,29 +97,12 @@ export function createCoreClient(): CoreClient { return new dh.CoreClient(websocketUrl); } -export async function requestParentLoginOptions(): Promise { - if (window.opener == null) { - throw new Error('window.opener is null, unable to send auth request.'); - } - return new Promise(resolve => { - const listener = ( - event: MessageEvent<{ - message: string; - payload: LoginOptions; - }> - ) => { - const { data } = event; - log.debug('Received message', data); - if (data?.message !== 'loginOptions') { - log.debug('Ignore received message', data); - return; - } - window.removeEventListener('message', listener); - resolve(data.payload); - }; - window.addEventListener('message', listener); - window.opener.postMessage('requestLoginOptionsFromParent', '*'); - }); +async function requestParentLoginOptions(): Promise { + return requestParentResponse(LOGIN_OPTIONS_REQUEST); +} + +async function requestParentSessionDetails(): Promise { + return requestParentResponse(SESSION_DETAILS_REQUEST); } export async function getLoginOptions( @@ -121,4 +118,17 @@ export async function getLoginOptions( } } +export async function getSessionDetails( + authType: AUTH_TYPE +): Promise { + switch (authType) { + case AUTH_TYPE.PARENT: + return requestParentSessionDetails(); + case AUTH_TYPE.ANONYMOUS: + return {}; + default: + throw new Error(`Unknown auth type: ${authType}`); + } +} + export default { createSessionWrapper }; diff --git a/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx b/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx index 93d7819a26..26cc919a96 100644 --- a/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/ConsolePanel.tsx @@ -320,7 +320,8 @@ export class ConsolePanel extends PureComponent< unzip, } = this.props; const { consoleSettings, error, objectMap } = this.state; - const { config, session, connection } = sessionWrapper; + const { config, session, connection, details = {} } = sessionWrapper; + const { workerName, processInfoId } = details; const { id: sessionId, type: language } = config; return ( @@ -349,6 +350,19 @@ export class ConsolePanel extends PureComponent< <>
 
{ConsoleConstants.LANGUAGE_MAP.get(language)}
+ {workerName != null && ( + <> +
 • 
+ {workerName} + + )} + {processInfoId != null && ( + <> +
 • 
+ {processInfoId} +
 •
+ + )}
 
{ + await expect(requestParentResponse('request')).rejects.toThrow( + 'window.opener is null, unable to send request.' + ); +}); + +describe('requestParentResponse', () => { + let addListenerSpy: jest.SpyInstance; + let removeListenerSpy: jest.SpyInstance; + let listenerCallback; + let messageId; + const mockPostMessage = jest.fn((data: Message) => { + messageId = data.id; + }); + const originalWindowOpener = window.opener; + beforeEach(() => { + addListenerSpy = jest + .spyOn(window, 'addEventListener') + .mockImplementation((event, cb) => { + listenerCallback = cb; + }); + removeListenerSpy = jest.spyOn(window, 'removeEventListener'); + window.opener = { postMessage: mockPostMessage }; + }); + afterEach(() => { + addListenerSpy.mockRestore(); + removeListenerSpy.mockRestore(); + mockPostMessage.mockClear(); + window.opener = originalWindowOpener; + messageId = undefined; + }); + + it('Posts message to parent and subscribes to response', async () => { + requestParentResponse('request'); + expect(mockPostMessage).toHaveBeenCalledWith( + expect.objectContaining(makeMessage('request', messageId)), + '*' + ); + expect(addListenerSpy).toHaveBeenCalledWith( + 'message', + expect.any(Function) + ); + }); + + it('Resolves with the payload from the parent window response and unsubscribes', async () => { + const PAYLOAD = 'PAYLOAD'; + const promise = requestParentResponse('request'); + listenerCallback({ + data: makeResponse(messageId, PAYLOAD), + }); + const result = await promise; + expect(result).toBe(PAYLOAD); + expect(removeListenerSpy).toHaveBeenCalledWith('message', listenerCallback); + }); + + it('Ignores unrelated response, rejects on timeout', async () => { + jest.useFakeTimers(); + const promise = requestParentResponse('request'); + listenerCallback({ + data: makeMessage('wrong-id'), + }); + jest.runOnlyPendingTimers(); + await expect(promise).rejects.toThrow('Request timed out'); + jest.useRealTimers(); + }); + + it('Times out if no response', async () => { + jest.useFakeTimers(); + const promise = requestParentResponse('request'); + jest.runOnlyPendingTimers(); + expect(removeListenerSpy).toHaveBeenCalled(); + await expect(promise).rejects.toThrow('Request timed out'); + jest.useRealTimers(); + }); +}); diff --git a/packages/jsapi-utils/src/MessageUtils.ts b/packages/jsapi-utils/src/MessageUtils.ts new file mode 100644 index 0000000000..ff941ac4dc --- /dev/null +++ b/packages/jsapi-utils/src/MessageUtils.ts @@ -0,0 +1,83 @@ +import shortid from 'shortid'; +import Log from '@deephaven/log'; +import { TimeoutError } from '@deephaven/utils'; + +const log = Log.module('MessageUtils'); + +export const LOGIN_OPTIONS_REQUEST = + 'io.deephaven.message.LoginOptions.request'; + +export const SESSION_DETAILS_REQUEST = + 'io.deephaven.message.SessionDetails.request'; + +export interface Message { + message: string; + payload?: T; + id: string; +} + +export interface Response { + id: string; + payload: T; +} + +/** + * Make message object with optional payload + * @param message Message string + * @param id Unique message id + * @param payload Payload to send + * @returns Message + */ +export function makeMessage( + message: string, + id = shortid(), + payload?: T +): Message { + return { message, id, payload }; +} + +/** + * Make response object for given message id + * @param messageId Id of the request message to respond to + * @param payload Payload to respond with + * @returns Response + */ +export function makeResponse(messageId: string, payload: T): Response { + return { id: messageId, payload }; +} + +/** + * Request data from the parent window and wait for response + * @param request Request message to send to the parent window + * @param timeout Timeout in ms + * @returns Payload of the given type, or undefined + */ +export async function requestParentResponse( + request: string, + timeout = 30000 +): Promise { + if (window.opener == null) { + throw new Error('window.opener is null, unable to send request.'); + } + return new Promise((resolve, reject) => { + let timeoutId: NodeJS.Timeout; + const id = shortid(); + const listener = (event: MessageEvent>) => { + const { data } = event; + log.debug('Received message', data); + if (data?.id !== id) { + log.debug("Ignore message, id doesn't match", data); + return; + } + clearTimeout(timeoutId); + window.removeEventListener('message', listener); + resolve(data.payload); + }; + window.addEventListener('message', listener); + timeoutId = setTimeout(() => { + window.removeEventListener('message', listener); + reject(new TimeoutError('Request timed out')); + }, timeout); + window.opener.postMessage(makeMessage(request, id), '*'); + }); +} diff --git a/packages/jsapi-utils/src/index.ts b/packages/jsapi-utils/src/index.ts index 6850fb52db..c4da239d49 100644 --- a/packages/jsapi-utils/src/index.ts +++ b/packages/jsapi-utils/src/index.ts @@ -4,5 +4,6 @@ export * from './DateUtils'; export * from './Formatter'; export { default as FormatterUtils } from './FormatterUtils'; export * from './FormatterUtils'; +export * from './MessageUtils'; export * from './Settings'; export * from './TableUtils';