diff --git a/package.json b/package.json index 5cd820d1c6a..ae971addd8b 100644 --- a/package.json +++ b/package.json @@ -225,8 +225,7 @@ "webpack-cleanup-plugin": "0.5.1", "webpack-cli": "4.9.0", "webpack-dev-server": "4.3.1", - "webpack-merge": "5.8.0", - "worker-loader": "^3.0.8" + "webpack-merge": "5.8.0" }, "dependencies": { "@emotion/css": "11.1.3", @@ -269,6 +268,7 @@ "centrifuge": "2.7.5", "classnames": "2.2.6", "clipboard": "2.0.4", + "comlink": "4.3.1", "common-tags": "^1.8.0", "core-js": "3.10.0", "d3": "5.15.0", diff --git a/packages/grafana-runtime/src/index.ts b/packages/grafana-runtime/src/index.ts index 68b0a07cf44..6d30d2d1cf3 100644 --- a/packages/grafana-runtime/src/index.ts +++ b/packages/grafana-runtime/src/index.ts @@ -17,12 +17,12 @@ export { StreamOptionsProvider, } from './utils/DataSourceWithBackend'; export { - toDataQueryError, toDataQueryResponse, frameToMetricFindValue, BackendDataSourceResponse, DataResponse, } from './utils/queryResponse'; +export { toDataQueryError } from './utils/toDataQueryError'; export { PanelRenderer, PanelRendererProps, PanelRendererType, setPanelRenderer } from './components/PanelRenderer'; export { setQueryRunnerFactory, createQueryRunner, QueryRunnerFactory } from './services/QueryRunner'; export { DataSourcePicker, DataSourcePickerProps, DataSourcePickerState } from './components/DataSourcePicker'; diff --git a/packages/grafana-runtime/src/utils/queryResponse.ts b/packages/grafana-runtime/src/utils/queryResponse.ts index 1acf15de5a8..55c12a20703 100644 --- a/packages/grafana-runtime/src/utils/queryResponse.ts +++ b/packages/grafana-runtime/src/utils/queryResponse.ts @@ -14,6 +14,7 @@ import { dataFrameFromJSON, } from '@grafana/data'; import { FetchError, FetchResponse } from '../services'; +import { toDataQueryError } from './toDataQueryError'; /** * Single response object from a backend data source. Properties are optional but response should contain at least @@ -159,36 +160,6 @@ export function toTestingStatus(err: FetchError): any { throw err; } -/** - * Convert an object into a DataQueryError -- if this is an HTTP response, - * it will put the correct values in the error field - * - * @public - */ -export function toDataQueryError(err: DataQueryError | string | Object): DataQueryError { - const error = (err || {}) as DataQueryError; - - if (!error.message) { - if (typeof err === 'string' || err instanceof String) { - return { message: err } as DataQueryError; - } - - let message = 'Query error'; - if (error.message) { - message = error.message; - } else if (error.data && error.data.message) { - message = error.data.message; - } else if (error.data && error.data.error) { - message = error.data.error; - } else if (error.status) { - message = `Query error: ${error.status} ${error.statusText}`; - } - error.message = message; - } - - return error; -} - /** * Return the first string or non-time field as the value * diff --git a/packages/grafana-runtime/src/utils/toDataQueryError.ts b/packages/grafana-runtime/src/utils/toDataQueryError.ts new file mode 100644 index 00000000000..f616195a472 --- /dev/null +++ b/packages/grafana-runtime/src/utils/toDataQueryError.ts @@ -0,0 +1,31 @@ +import { DataQueryError } from '@grafana/data'; + +/** + * Convert an object into a DataQueryError -- if this is an HTTP response, + * it will put the correct values in the error field + * + * @public + */ +export function toDataQueryError(err: DataQueryError | string | Object): DataQueryError { + const error = (err || {}) as DataQueryError; + + if (!error.message) { + if (typeof err === 'string' || err instanceof String) { + return { message: err } as DataQueryError; + } + + let message = 'Query error'; + if (error.message) { + message = error.message; + } else if (error.data && error.data.message) { + message = error.data.message; + } else if (error.data && error.data.error) { + message = error.data.error; + } else if (error.status) { + message = `Query error: ${error.status} ${error.statusText}`; + } + error.message = message; + } + + return error; +} diff --git a/public/app/core/utils/CorsWorker.ts b/public/app/core/utils/CorsWorker.ts new file mode 100644 index 00000000000..4cd2a140e0d --- /dev/null +++ b/public/app/core/utils/CorsWorker.ts @@ -0,0 +1,23 @@ +// works with webpack plugin: scripts/webpack/plugins/CorsWorkerPlugin.js +export class CorsWorker extends window.Worker { + constructor(url: URL, options?: WorkerOptions) { + // by default, worker inherits HTML document's location and pathname which leads to wrong public path value + // the CorsWorkerPlugin will override it with the value based on the initial worker chunk, ie. + // initial worker chunk: http://host.com/cdn/scripts/worker-123.js + // resulting public path: http://host.com/cdn/scripts + + const scriptUrl = url.toString(); + const urlParts = scriptUrl.split('/'); + urlParts.pop(); + const scriptsBasePathUrl = `${urlParts.join('/')}/`; + + const importScripts = `importScripts('${scriptUrl}');`; + const objectURL = URL.createObjectURL( + new Blob([`__webpack_worker_public_path__ = '${scriptsBasePathUrl}'; ${importScripts}`], { + type: 'application/javascript', + }) + ); + super(objectURL, options); + URL.revokeObjectURL(objectURL); + } +} diff --git a/public/app/features/dashboard/dashgrid/liveTimer.ts b/public/app/features/dashboard/dashgrid/liveTimer.ts index 113e9f64d7b..0631c3ca4db 100644 --- a/public/app/features/dashboard/dashgrid/liveTimer.ts +++ b/public/app/features/dashboard/dashgrid/liveTimer.ts @@ -1,4 +1,5 @@ import { dateMath, dateTime, TimeRange } from '@grafana/data'; +import { BehaviorSubject } from 'rxjs'; import { PanelChrome } from './PanelChrome'; // target is 20hz (50ms), but we poll at 100ms to smooth out jitter @@ -15,7 +16,7 @@ class LiveTimer { budget = 1; threshold = 1.5; // trial and error appears about right - ok = true; + ok = new BehaviorSubject(true); lastUpdate = Date.now(); isLive = false; // the dashboard time range ends in "now" @@ -69,11 +70,16 @@ class LiveTimer { measure = () => { const now = Date.now(); this.budget = (now - this.lastUpdate) / interval; - this.ok = this.budget <= this.threshold; + + const oldOk = this.ok.getValue(); + const newOk = this.budget <= this.threshold; + if (oldOk !== newOk) { + this.ok.next(newOk); + } this.lastUpdate = now; // For live dashboards, listen to changes - if (this.ok && this.isLive && this.timeRange) { + if (this.isLive && this.ok.getValue() && this.timeRange) { // when the time-range is relative fire events let tr: TimeRange | undefined = undefined; for (const listener of this.listeners) { diff --git a/public/app/features/explore/NodeGraphContainer.test.tsx b/public/app/features/explore/NodeGraphContainer.test.tsx index 2441fd795a2..2560771fd8d 100644 --- a/public/app/features/explore/NodeGraphContainer.test.tsx +++ b/public/app/features/explore/NodeGraphContainer.test.tsx @@ -3,7 +3,6 @@ import { render, screen } from '@testing-library/react'; import { UnconnectedNodeGraphContainer } from './NodeGraphContainer'; import { getDefaultTimeRange, MutableDataFrame } from '@grafana/data'; import { ExploreId } from '../../types'; -jest.mock('../../plugins/panel/nodeGraph/layout.worker.js'); describe('NodeGraphContainer', () => { it('is collapsed if shown with traces', () => { diff --git a/public/app/features/live/centrifuge/createCentrifugeServiceWorker.ts b/public/app/features/live/centrifuge/createCentrifugeServiceWorker.ts new file mode 100644 index 00000000000..c7c68f5b0e7 --- /dev/null +++ b/public/app/features/live/centrifuge/createCentrifugeServiceWorker.ts @@ -0,0 +1,3 @@ +import { CorsWorker as Worker } from 'app/core/utils/CorsWorker'; + +export const createWorker = () => new Worker(new URL('./service.worker.ts', import.meta.url)); diff --git a/public/app/features/live/centrifuge/remoteObservable.ts b/public/app/features/live/centrifuge/remoteObservable.ts new file mode 100644 index 00000000000..bacca066355 --- /dev/null +++ b/public/app/features/live/centrifuge/remoteObservable.ts @@ -0,0 +1,33 @@ +import * as comlink from 'comlink'; +import { from, Observable, switchMap } from 'rxjs'; + +export const remoteObservableAsObservable = (remoteObs: comlink.RemoteObject>): Observable => + new Observable((subscriber) => { + // Passing the callbacks as 3 separate arguments is deprecated, but it's the only option for now + // + // RxJS recreates the functions via `Function.bind` https://github.com/ReactiveX/rxjs/blob/62aca850a37f598b5db6085661e0594b81ec4281/src/internal/Subscriber.ts#L169 + // and thus erases the ProxyMarker created via comlink.proxy(fN) when the callbacks + // are grouped together in a Observer object (ie. { next: (v) => ..., error: (err) => ..., complete: () => ... }) + // + // solution: TBD (autoproxy all functions?) + const remoteSubPromise = remoteObs.subscribe( + comlink.proxy((nextValueInRemoteObs: T) => { + subscriber.next(nextValueInRemoteObs); + }), + comlink.proxy((err) => { + subscriber.error(err); + }), + comlink.proxy(() => { + subscriber.complete(); + }) + ); + return { + unsubscribe: () => { + remoteSubPromise.then((remoteSub) => remoteSub.unsubscribe()); + }, + }; + }); + +export const promiseWithRemoteObservableAsObservable = ( + promiseWithProxyObservable: Promise>> +): Observable => from(promiseWithProxyObservable).pipe(switchMap((val) => remoteObservableAsObservable(val))); diff --git a/public/app/features/live/centrifuge/service.ts b/public/app/features/live/centrifuge/service.ts index 3b738c28245..71e35022dcc 100644 --- a/public/app/features/live/centrifuge/service.ts +++ b/public/app/features/live/centrifuge/service.ts @@ -1,5 +1,6 @@ import Centrifuge from 'centrifuge/dist/centrifuge'; -import { LiveDataStreamOptions, toDataQueryError } from '@grafana/runtime'; +import { LiveDataStreamOptions } from '@grafana/runtime'; +import { toDataQueryError } from '@grafana/runtime/src/utils/toDataQueryError'; import { BehaviorSubject, Observable } from 'rxjs'; import { DataFrame, @@ -15,25 +16,52 @@ import { LiveChannelPresenceStatus, LoadingState, StreamingDataFrame, + toDataFrameDTO, } from '@grafana/data'; import { CentrifugeLiveChannel } from './channel'; -import { liveTimer } from 'app/features/dashboard/dashgrid/liveTimer'; -type CentrifugeSrvDeps = { +export type CentrifugeSrvDeps = { appUrl: string; orgId: number; orgRole: string; sessionId: string; liveEnabled: boolean; + dataStreamSubscriberReadiness: Observable; }; -export class CentrifugeSrv { +export interface CentrifugeSrv { + /** + * Listen for changes to the connection state + */ + getConnectionState(): Observable; + + /** + * Watch for messages in a channel + */ + getStream(address: LiveChannelAddress, config: LiveChannelConfig): Observable>; + + /** + * Connect to a channel and return results as DataFrames + */ + getDataStream(options: LiveDataStreamOptions, config: LiveChannelConfig): Observable; + + /** + * For channels that support presence, this will request the current state from the server. + * + * Join and leave messages will be sent to the open stream + */ + getPresence(address: LiveChannelAddress, config: LiveChannelConfig): Promise; +} + +export class CentrifugeService implements CentrifugeSrv { readonly open = new Map(); readonly centrifuge: Centrifuge; readonly connectionState: BehaviorSubject; readonly connectionBlocker: Promise; + private dataStreamSubscriberReady = true; constructor(private deps: CentrifugeSrvDeps) { + deps.dataStreamSubscriberReadiness.subscribe((next) => (this.dataStreamSubscriberReady = next)); const liveUrl = `${deps.appUrl.replace(/^http/, 'ws')}/api/live/ws`; this.centrifuge = new Centrifuge(liveUrl, {}); this.centrifuge.setConnectData({ @@ -66,15 +94,15 @@ export class CentrifugeSrv { // Internal functions //---------------------------------------------------------- - onConnect = (context: any) => { + private onConnect = (context: any) => { this.connectionState.next(true); }; - onDisconnect = (context: any) => { + private onDisconnect = (context: any) => { this.connectionState.next(false); }; - onServerSideMessage = (context: any) => { + private onServerSideMessage = (context: any) => { console.log('Publication from server-side channel', context); }; @@ -82,7 +110,7 @@ export class CentrifugeSrv { * Get a channel. If the scope, namespace, or path is invalid, a shutdown * channel will be returned with an error state indicated in its status */ - getChannel(addr: LiveChannelAddress, config: LiveChannelConfig): CentrifugeLiveChannel { + private getChannel(addr: LiveChannelAddress, config: LiveChannelConfig): CentrifugeLiveChannel { const id = `${this.deps.orgId}/${addr.scope}/${addr.namespace}/${addr.path}`; let channel = this.open.get(id); if (channel != null) { @@ -145,7 +173,6 @@ export class CentrifugeSrv { let data: StreamingDataFrame | undefined = undefined; let filtered: DataFrame | undefined = undefined; let state = LoadingState.Streaming; - let last = liveTimer.lastUpdate; let lastWidth = -1; const process = (msg: DataFrameJSON) => { @@ -172,11 +199,18 @@ export class CentrifugeSrv { } } - const elapsed = liveTimer.lastUpdate - last; - if (elapsed > 1000 || liveTimer.ok) { + if (this.dataStreamSubscriberReady) { filtered.length = data.length; // make sure they stay up-to-date - subscriber.next({ state, data: [filtered], key }); - last = liveTimer.lastUpdate; + subscriber.next({ + state, + data: [ + // workaround for serializing issues when sending DataFrame from web worker to the main thread + // DataFrame is making use of ArrayVectors which are es6 classes and thus not cloneable out of the box + // `toDataFrameDTO` converts ArrayVectors into native arrays. + toDataFrameDTO(filtered), + ], + key, + }); } }; diff --git a/public/app/features/live/centrifuge/service.worker.ts b/public/app/features/live/centrifuge/service.worker.ts new file mode 100644 index 00000000000..e3ce9d19405 --- /dev/null +++ b/public/app/features/live/centrifuge/service.worker.ts @@ -0,0 +1,52 @@ +import { CentrifugeService, CentrifugeSrvDeps } from './service'; +import * as comlink from 'comlink'; +import './transferHandlers'; +import { remoteObservableAsObservable } from './remoteObservable'; +import { LiveChannelAddress, LiveChannelConfig } from '@grafana/data'; +import { LiveDataStreamOptions } from '@grafana/runtime'; + +let centrifuge: CentrifugeService; + +const initialize = ( + deps: CentrifugeSrvDeps, + remoteDataStreamSubscriberReadiness: comlink.RemoteObject< + CentrifugeSrvDeps['dataStreamSubscriberReadiness'] & comlink.ProxyMarked + > +) => { + centrifuge = new CentrifugeService({ + ...deps, + dataStreamSubscriberReadiness: remoteObservableAsObservable(remoteDataStreamSubscriberReadiness), + }); +}; + +const getConnectionState = () => { + return comlink.proxy(centrifuge.getConnectionState()); +}; + +const getDataStream = (options: LiveDataStreamOptions, config: LiveChannelConfig) => { + return comlink.proxy(centrifuge.getDataStream(options, config)); +}; + +const getStream = (address: LiveChannelAddress, config: LiveChannelConfig) => { + return comlink.proxy(centrifuge.getStream(address, config)); +}; + +const getPresence = async (address: LiveChannelAddress, config: LiveChannelConfig) => { + return await centrifuge.getPresence(address, config); +}; + +const workObj = { + initialize, + getConnectionState, + getDataStream, + getStream, + getPresence, +}; + +export type RemoteCentrifugeService = typeof workObj; + +comlink.expose(workObj); + +export default class { + constructor() {} +} diff --git a/public/app/features/live/centrifuge/serviceWorkerProxy.ts b/public/app/features/live/centrifuge/serviceWorkerProxy.ts new file mode 100644 index 00000000000..a1d5b369567 --- /dev/null +++ b/public/app/features/live/centrifuge/serviceWorkerProxy.ts @@ -0,0 +1,40 @@ +import { CentrifugeSrv, CentrifugeSrvDeps } from './service'; +import { RemoteCentrifugeService } from './service.worker'; +import './transferHandlers'; + +import * as comlink from 'comlink'; +import { asyncScheduler, Observable, observeOn } from 'rxjs'; +import { LiveChannelAddress, LiveChannelConfig, LiveChannelEvent } from '@grafana/data'; +import { promiseWithRemoteObservableAsObservable } from './remoteObservable'; +import { createWorker } from './createCentrifugeServiceWorker'; + +export class CentrifugeServiceWorkerProxy implements CentrifugeSrv { + private centrifugeWorker; + + constructor(deps: CentrifugeSrvDeps) { + this.centrifugeWorker = comlink.wrap(createWorker() as comlink.Endpoint); + this.centrifugeWorker.initialize(deps, comlink.proxy(deps.dataStreamSubscriberReadiness)); + } + + getConnectionState: CentrifugeSrv['getConnectionState'] = () => { + return promiseWithRemoteObservableAsObservable(this.centrifugeWorker.getConnectionState()); + }; + + getDataStream: CentrifugeSrv['getDataStream'] = (options, config) => { + return promiseWithRemoteObservableAsObservable(this.centrifugeWorker.getDataStream(options, config)).pipe( + // async scheduler splits the synchronous task of deserializing data from web worker and + // consuming the message (ie. updating react component) into two to avoid blocking the event loop + observeOn(asyncScheduler) + ); + }; + + getPresence: CentrifugeSrv['getPresence'] = (address, config) => { + return this.centrifugeWorker.getPresence(address, config); + }; + + getStream: CentrifugeSrv['getStream'] = (address: LiveChannelAddress, config: LiveChannelConfig) => { + return promiseWithRemoteObservableAsObservable( + this.centrifugeWorker.getStream(address, config) as Promise>>> + ); + }; +} diff --git a/public/app/features/live/centrifuge/transferHandlers.ts b/public/app/features/live/centrifuge/transferHandlers.ts new file mode 100644 index 00000000000..f42cc3f75de --- /dev/null +++ b/public/app/features/live/centrifuge/transferHandlers.ts @@ -0,0 +1,27 @@ +import * as comlink from 'comlink'; +import { Subscriber } from 'rxjs'; + +// Observers, ie. functions passed to `observable.subscribe(...)`, are converted to a subclass of `Subscriber` before they are sent to the source Observable. +// The conversion happens internally in the RxJS library - this transfer handler is catches them and wraps them with a proxy +const subscriberTransferHandler: any = { + canHandle(value: any): boolean { + return value && value instanceof Subscriber; + }, + + serialize(value: Function): [MessagePort, Transferable[]] { + const obj = comlink.proxy(value); + + const { port1, port2 } = new MessageChannel(); + + comlink.expose(obj, port1); + + return [port2, [port2]]; + }, + + deserialize(value: MessagePort): comlink.Remote { + value.start(); + + return comlink.wrap(value); + }, +}; +comlink.transferHandlers.set('SubscriberHandler', subscriberTransferHandler); diff --git a/public/app/features/live/index.ts b/public/app/features/live/index.ts index 8d9b8e5a7f5..12b7baca0c9 100644 --- a/public/app/features/live/index.ts +++ b/public/app/features/live/index.ts @@ -1,10 +1,12 @@ import { config, getBackendSrv, getGrafanaLiveSrv, setGrafanaLiveSrv } from '@grafana/runtime'; -import { CentrifugeSrv } from './centrifuge/service'; import { registerLiveFeatures } from './features'; import { GrafanaLiveService } from './live'; import { GrafanaLiveChannelConfigService } from './channel-config'; import { GrafanaLiveChannelConfigSrv } from './channel-config/types'; import { contextSrv } from '../../core/services/context_srv'; +import { CentrifugeServiceWorkerProxy } from './centrifuge/serviceWorkerProxy'; +import { CentrifugeService } from './centrifuge/service'; +import { liveTimer } from 'app/features/dashboard/dashgrid/liveTimer'; const grafanaLiveScopesSingleton = new GrafanaLiveChannelConfigService(); @@ -18,13 +20,19 @@ export const sessionId = Math.random().toString(36).substring(2, 15); export function initGrafanaLive() { - const centrifugeSrv = new CentrifugeSrv({ + const centrifugeServiceDeps = { appUrl: `${window.location.origin}${config.appSubUrl}`, orgId: contextSrv.user.orgId, orgRole: contextSrv.user.orgRole, liveEnabled: config.liveEnabled, sessionId, - }); + dataStreamSubscriberReadiness: liveTimer.ok.asObservable(), + }; + + const centrifugeSrv = config.featureToggles['live-service-web-worker'] + ? new CentrifugeServiceWorkerProxy(centrifugeServiceDeps) + : new CentrifugeService(centrifugeServiceDeps); + setGrafanaLiveSrv( new GrafanaLiveService({ scopes: getGrafanaLiveScopes(), diff --git a/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx b/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx index 19b562191dc..2f530436669 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraph.test.tsx @@ -3,7 +3,6 @@ import { render, screen, fireEvent, waitFor, getByText } from '@testing-library/ import userEvent from '@testing-library/user-event'; import { NodeGraph } from './NodeGraph'; import { makeEdgesDataFrame, makeNodesDataFrame } from './utils'; -jest.mock('./layout.worker.js'); jest.mock('react-use/lib/useMeasure', () => { return { diff --git a/public/app/plugins/panel/nodeGraph/__mocks__/layout.worker.js b/public/app/plugins/panel/nodeGraph/__mocks__/layout.worker.js deleted file mode 100644 index 39c401010e6..00000000000 --- a/public/app/plugins/panel/nodeGraph/__mocks__/layout.worker.js +++ /dev/null @@ -1,12 +0,0 @@ -const { layout } = jest.requireActual('../layout.worker.js'); - -export default class TestWorker { - constructor() {} - postMessage(data) { - const { nodes, edges, config } = data; - setTimeout(() => { - layout(nodes, edges, config); - this.onmessage({ data: { nodes, edges } }); - }, 1); - } -} diff --git a/public/app/plugins/panel/nodeGraph/createLayoutWorker.ts b/public/app/plugins/panel/nodeGraph/createLayoutWorker.ts new file mode 100644 index 00000000000..1aa1f1e9083 --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/createLayoutWorker.ts @@ -0,0 +1,3 @@ +import { CorsWorker as Worker } from 'app/core/utils/CorsWorker'; + +export const createWorker = () => new Worker(new URL('./layout.worker.js', import.meta.url)); diff --git a/public/app/plugins/panel/nodeGraph/layout.ts b/public/app/plugins/panel/nodeGraph/layout.ts index 6087c04f6f2..4814de573da 100644 --- a/public/app/plugins/panel/nodeGraph/layout.ts +++ b/public/app/plugins/panel/nodeGraph/layout.ts @@ -4,8 +4,7 @@ import { Field } from '@grafana/data'; import { useNodeLimit } from './useNodeLimit'; import useMountedState from 'react-use/lib/useMountedState'; import { graphBounds } from './utils'; -// @ts-ignore -import LayoutWorker from './layout.worker.js'; +import { createWorker } from './createLayoutWorker'; export interface Config { linkDistance: number; @@ -135,7 +134,7 @@ function defaultLayout( edges: EdgeDatum[], done: (data: { nodes: NodeDatum[]; edges: EdgeDatum[] }) => void ) { - const worker = new LayoutWorker(); + const worker = createWorker(); worker.onmessage = (event: MessageEvent<{ nodes: NodeDatum[]; edges: EdgeDatumLayout[] }>) => { for (let i = 0; i < nodes.length; i++) { // These stats needs to be Field class but the data is stringified over the worker boundary diff --git a/public/test/jest-setup.ts b/public/test/jest-setup.ts index 8b505d5ff00..40a7a0ceffa 100644 --- a/public/test/jest-setup.ts +++ b/public/test/jest-setup.ts @@ -3,6 +3,7 @@ import { EventBusSrv } from '@grafana/data'; import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; import $ from 'jquery'; import 'mutationobserver-shim'; +import './mocks/workers'; const testAppEvents = new EventBusSrv(); const global = window as any; diff --git a/public/test/mocks/workers.ts b/public/test/mocks/workers.ts new file mode 100644 index 00000000000..c21cee96220 --- /dev/null +++ b/public/test/mocks/workers.ts @@ -0,0 +1,27 @@ +const { layout } = jest.requireActual('../../app/plugins/panel/nodeGraph/layout.worker.js'); + +class LayoutMockWorker { + constructor() {} + + postMessage(data: any) { + const { nodes, edges, config } = data; + setTimeout(() => { + layout(nodes, edges, config); + // @ts-ignore + this.onmessage({ data: { nodes, edges } }); + }, 1); + } +} + +jest.mock('../../app/plugins/panel/nodeGraph/createLayoutWorker', () => ({ + createWorker: () => new LayoutMockWorker(), +})); + +class BasicMockWorker { + postMessage() {} +} +const mockCreateWorker = { + createWorker: () => new BasicMockWorker(), +}; + +jest.mock('../../app/features/live/centrifuge/createCentrifugeServiceWorker', () => mockCreateWorker); diff --git a/scripts/webpack/plugins/CorsWorkerPlugin.js b/scripts/webpack/plugins/CorsWorkerPlugin.js new file mode 100644 index 00000000000..cc613721de5 --- /dev/null +++ b/scripts/webpack/plugins/CorsWorkerPlugin.js @@ -0,0 +1,64 @@ +const { RuntimeGlobals, RuntimeModule } = require('webpack'); + +class CorsWorkerPublicPathRuntimeModule extends RuntimeModule { + constructor(publicPath) { + super('publicPath', RuntimeModule.STAGE_BASIC); + this.publicPath = publicPath; + } + + /** + * @returns {string} runtime code + */ + generate() { + const { compilation, publicPath } = this; + + const publicPathValue = compilation.getPath(publicPath || '', { + hash: compilation.hash || 'XXXX', + }); + return `${RuntimeGlobals.publicPath} = __webpack_worker_public_path__ || '${publicPathValue}';`; + } +} + +// https://github.com/webpack/webpack/discussions/14648#discussioncomment-1604202 +// by @ https://github.com/piotr-oles +class CorsWorkerPlugin { + /** + * @param {import('webpack').Compiler} compiler + */ + apply(compiler) { + compiler.hooks.compilation.tap( + 'CorsWorkerPlugin', + /** + * @param {import('webpack').Compilation} compilation + */ + (compilation) => { + const getChunkLoading = (chunk) => { + const entryOptions = chunk.getEntryOptions(); + return entryOptions && entryOptions.chunkLoading !== undefined + ? entryOptions.chunkLoading + : compilation.outputOptions.chunkLoading; + }; + const getChunkPublicPath = (chunk) => { + const entryOptions = chunk.getEntryOptions(); + return entryOptions && entryOptions.publicPath !== undefined + ? entryOptions.publicPath + : compilation.outputOptions.publicPath; + }; + + compilation.hooks.runtimeRequirementInTree.for(RuntimeGlobals.publicPath).tap('CorsWorkerPlugin', (chunk) => { + if (getChunkLoading(chunk) === 'import-scripts') { + const publicPath = getChunkPublicPath(chunk); + + if (publicPath !== 'auto') { + const module = new CorsWorkerPublicPathRuntimeModule(publicPath); + compilation.addRuntimeModule(chunk, module); + return true; + } + } + }); + } + ); + } +} + +module.exports = CorsWorkerPlugin; diff --git a/scripts/webpack/webpack.common.js b/scripts/webpack/webpack.common.js index 3e5b66e7e35..13380271bf5 100644 --- a/scripts/webpack/webpack.common.js +++ b/scripts/webpack/webpack.common.js @@ -1,7 +1,7 @@ const fs = require('fs-extra'); const path = require('path'); const webpack = require('webpack'); - +const CorsWorkerPlugin = require('./plugins/CorsWorkerPlugin'); const CopyWebpackPlugin = require('copy-webpack-plugin'); class CopyUniconsPlugin { @@ -58,6 +58,7 @@ module.exports = { source: false, }, plugins: [ + new CorsWorkerPlugin(), new webpack.ProvidePlugin({ Buffer: ['buffer', 'Buffer'], }), @@ -124,15 +125,6 @@ module.exports = { loader: 'file-loader', options: { name: 'static/img/[name].[hash:8].[ext]' }, }, - { - test: /\.worker\.js$/, - use: { - loader: 'worker-loader', - options: { - inline: 'fallback', - }, - }, - }, ], }, // https://webpack.js.org/plugins/split-chunks-plugin/#split-chunks-example-3 diff --git a/yarn.lock b/yarn.lock index b829dd4502c..8817270a48c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12355,6 +12355,13 @@ __metadata: languageName: node linkType: hard +"comlink@npm:4.3.1": + version: 4.3.1 + resolution: "comlink@npm:4.3.1" + checksum: 557360a6558708c55aff74a25f834bfb9bfca8a42444682c4d5aead57681534a0206202be2a2760b4de124c3ba6d485b08978b6d5469cb3d26bf1438ee28a4f1 + languageName: node + linkType: hard + "comma-separated-tokens@npm:^1.0.0": version: 1.0.8 resolution: "comma-separated-tokens@npm:1.0.8" @@ -17829,6 +17836,7 @@ __metadata: centrifuge: 2.7.5 classnames: 2.2.6 clipboard: 2.0.4 + comlink: 4.3.1 common-tags: ^1.8.0 copy-webpack-plugin: 9.0.1 core-js: 3.10.0 @@ -17994,7 +18002,6 @@ __metadata: webpack-dev-server: 4.3.1 webpack-merge: 5.8.0 whatwg-fetch: 3.1.0 - worker-loader: ^3.0.8 languageName: unknown linkType: soft @@ -33911,18 +33918,6 @@ __metadata: languageName: node linkType: hard -"worker-loader@npm:^3.0.8": - version: 3.0.8 - resolution: "worker-loader@npm:3.0.8" - dependencies: - loader-utils: ^2.0.0 - schema-utils: ^3.0.0 - peerDependencies: - webpack: ^4.0.0 || ^5.0.0 - checksum: 84f4a7eeb2a1d8b9704425837e017c91eedfae67ac89e0b866a2dcf283323c1dcabe0258196278b7d5fd0041392da895c8a0c59ddf3a94f1b2e003df68ddfec3 - languageName: node - linkType: hard - "worker-rpc@npm:^0.1.0": version: 0.1.1 resolution: "worker-rpc@npm:0.1.1"