Skip to content

Commit

Permalink
wip: worker based script (#2239)
Browse files Browse the repository at this point in the history
### Fixes #
<!-- Mention the issues this PR addresses -->

### Checks

- [ ] Ran `yarn test-build`
- [ ] Updated relevant documentations
- [ ] Updated matching config options in altair-static

### Changes proposed in this pull request:
<!-- Describe the changes being introduced in this PR -->
  • Loading branch information
imolorhe committed Aug 12, 2023
1 parent df20be4 commit be13860
Show file tree
Hide file tree
Showing 23 changed files with 892 additions and 314 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ exports[`PostRequestEditorComponent should render correctly 1`] = `
</label>
</div>
<app-beta-indicator
description="This is a complete rewrite of the existing scripting functionality with a focus on security and performance. The new scripting engine is based on web workers and is completely sandboxed. So direct access to global variables like \`window\` and \`document\` is no longer possible."
featurekey="newScript"
title="Script editor v2.0"
/>
<app-codemirror />
<small>
<a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
>{{ 'REQUEST_SCRIPT_ENABLE_TEXT' | translate }}</label
>
</div>
<app-beta-indicator
title="Script editor v2.0"
description="This is a complete rewrite of the existing scripting functionality with a focus on security and performance. The new scripting engine is based on web workers and is completely sandboxed. So direct access to global variables like `window` and `document` is no longer possible."
featureKey="newScript"
></app-beta-indicator>
<app-codemirror
[extensions]="editorExtensions"
[fullHeight]="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';

import { PostrequestState } from 'altair-graphql-core/build/types/state/postrequest.interfaces';
import { PreRequestService } from '../../services';
import { getGlobalContext } from '../../services/pre-request/helpers';
import { getRequestScriptExtensions } from '../../utils/editor/extensions';

const AUTOCOMPLETE_CHARS = /^[a-zA-Z0-9_]$/;
Expand All @@ -20,12 +21,20 @@ export class PostRequestEditorComponent {
@Output() postRequestEnabledChange = new EventEmitter();

editorExtensions = getRequestScriptExtensions(
this.preRequestService.getGlobalContext({
headers: [],
environment: {},
query: '',
variables: '',
})
getGlobalContext(
{
headers: [],
environment: {},
query: '',
variables: '',
},
{
setCookie: async () => {},
getStorageItem: async () => {},
setStorageItem: async () => {},
request: async () => {},
}
)
);

constructor(private preRequestService: PreRequestService) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ exports[`PreRequestEditorComponent should render correctly 1`] = `
</label>
</div>
<app-beta-indicator
description="This is a complete rewrite of the existing scripting functionality with a focus on security and performance. The new scripting engine is based on web workers and is completely sandboxed. So direct access to global variables like \`window\` and \`document\` is no longer possible."
featurekey="newScript"
title="Script editor v2.0"
/>
<app-codemirror />
<small>
<a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
>{{ 'PRE_REQUEST_ENABLE_TEXT' | translate }}</label
>
</div>
<app-beta-indicator
title="Script editor v2.0"
description="This is a complete rewrite of the existing scripting functionality with a focus on security and performance. The new scripting engine is based on web workers and is completely sandboxed. So direct access to global variables like `window` and `document` is no longer possible."
featureKey="newScript"
></app-beta-indicator>
<app-codemirror
[extensions]="editorExtensions"
[fullHeight]="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
import { PrerequestState } from 'altair-graphql-core/build/types/state/prerequest.interfaces';
import { getRequestScriptExtensions } from '../../utils/editor/extensions';
import { PreRequestService } from '../../services';
import { getGlobalContext } from '../../services/pre-request/helpers';

const AUTOCOMPLETE_CHARS = /^[a-zA-Z0-9_]$/;

Expand All @@ -20,12 +21,20 @@ export class PreRequestEditorComponent {
@Output() preRequestEnabledChange = new EventEmitter();

editorExtensions = getRequestScriptExtensions(
this.preRequestService.getGlobalContext({
headers: [],
environment: {},
query: '',
variables: '',
})
getGlobalContext(
{
headers: [],
environment: {},
query: '',
variables: '',
},
{
setCookie: async () => {},
getStorageItem: async () => {},
setStorageItem: async () => {},
request: async () => {},
}
)
);

constructor(private preRequestService: PreRequestService) {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,12 @@ import { generateCurl } from '../utils/curl';
import { OperationDefinitionNode } from 'graphql';
import { IDictionary, UnknownError } from '../interfaces/shared';
import { SendRequestResponse } from '../services/gql/gql.service';
import {
RequestType,
ScriptContextData,
} from '../services/pre-request/pre-request.service';
import { RootState } from 'altair-graphql-core/build/types/state/state.interfaces';
import { WEBSOCKET_PROVIDER_ID } from 'altair-graphql-core/build/subscriptions';
import { SubscriptionProvider } from 'altair-graphql-core/build/subscriptions/subscription-provider';
import { RequestScriptError } from '../services/pre-request/errors';
import { headerListToMap } from '../utils/headers';
import { RequestType } from '../services/pre-request/helpers';

@Injectable()
export class QueryEffects {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class DbService {
* @param value
*/
setItem(key: string, value: unknown): Observable<any> {
if (key && value) {
if (key && typeof value !== 'undefined') {
const dbValue = {
value,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export class ScriptEvaluatorWorkerFactory {
create() {
return new Worker(new URL('./evaluator.worker', import.meta.url), {
type: 'module',
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { debug } from '../../utils/logger';
import { ScriptEvaluatorWorkerFactory } from './evaluator-worker.factory';
import {
AllScriptEventHandlers,
getErrorEvent,
getResponseEvent,
ScriptEvent,
ScriptEventData,
ScriptEventHandlers,
SCRIPT_INIT_EXECUTE,
} from './events';
import { ScriptContextData } from './helpers';

export class ScriptEvaluator {
timeout = 1000 * 60 * 5; // 5 minutes
private worker?: Worker;

private getWorker() {
if (!this.worker) {
this.worker = new ScriptEvaluatorWorkerFactory().create();
}
return this.worker;
}

async executeScript(
script: string,
data: ScriptContextData,
handlers: ScriptEventHandlers
): Promise<ScriptContextData> {
try {
const worker = this.getWorker();
const result = await new Promise<ScriptContextData>((resolve, reject) => {
// Handle timeout
const handle = setTimeout(() => {
this.killWorker();
reject(new Error('script timeout'));
}, this.timeout);

const allHandlers: AllScriptEventHandlers = {
...handlers,
executeComplete: (data: ScriptContextData) => {
clearTimeout(handle);
resolve(data);
},
scriptError: (err: Error) => {
clearTimeout(handle);
reject(err);
},
} as const;

// loop over all the script event handlers and create a listener for each
// TODO: fn is of any type here. Figure out the typing
Object.entries(allHandlers).forEach(([key, fn]) => {
worker.addEventListener(
'message',
<T extends ScriptEvent>(e: MessageEvent<ScriptEventData<T>>) => {
const event = e.data;

// Handle script events
if (event.type === key) {
debug.log(event.type, event);
// TODO: handle cancelling requests
const { id, args } = event.payload;
(async () => {
try {
const res = await fn(...args);
worker.postMessage({
type: getResponseEvent(key),
payload: { id, response: res },
});
} catch (err) {
worker.postMessage({
type: getErrorEvent(key),
payload: { id, error: err },
});
}
})();
}
}
);
});

worker.onerror = (e) => {
clearTimeout(handle);
reject(e);
};
worker.postMessage({
type: SCRIPT_INIT_EXECUTE,
payload: [script, data],
});
});

return result;
} finally {
this.killWorker();
}
}

private killWorker() {
this.worker?.terminate();
this.worker = undefined;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { v4 as uuid } from 'uuid';
import { ScriptEvaluator } from './evaluator';
import {
AllScriptEventHandlers,
ScriptEvent,
ScriptEventParameters,
SCRIPT_INIT_EXECUTE,
} from './events';
import { getGlobalContext, ScriptContextData } from './helpers';

onmessage = async (e) => {
switch (e.data.type) {
case SCRIPT_INIT_EXECUTE:
try {
await initExecute(e.data.payload);
} catch (err) {
makeCall('scriptError', err as Error);
}
break;
}
};

const workerHandlerNames = [
'setCookie',
'request',
'getStorageItem',
'setStorageItem',
] as const;
export type WorkerHandlerNames = typeof workerHandlerNames[number];

const initExecute = async (
payload: Parameters<ScriptEvaluator['executeScript']>
) => {
const [script, data] = payload;
const res = await new Promise<ScriptContextData>((resolve, reject) => {
self.addEventListener('unhandledrejection', (e) => {
e.preventDefault();
return reject(e.reason);
});

const clonedMutableData: ScriptContextData = JSON.parse(
JSON.stringify(data)
);

// build handlers
const handlers = workerHandlerNames.reduce(
<T extends WorkerHandlerNames>(
acc: Pick<AllScriptEventHandlers, WorkerHandlerNames>,
key: T
) => {
acc[key] = ((...args: ScriptEventParameters<T>) => {
return makeCall(key, ...args);
}) as unknown as AllScriptEventHandlers[T]; // TODO: Look into this typing issue.
return acc;
},
{} as Pick<AllScriptEventHandlers, WorkerHandlerNames>
);

const context = {
altair: getGlobalContext(clonedMutableData, handlers),
alert,
};

const contextEntries = Object.entries(context);
try {
const res = function () {
return eval(`
(async(${contextEntries.map((e) => e[0]).join(',')}) => {
${script};
return altair.data;
})(...this.__ctxE.map(e => e[1]));
`);
}.call({ __ctxE: contextEntries });
return resolve(res);
} catch (e) {
return reject(e);
}
});

makeCall('executeComplete', res);
};

const alert = (msg: string) => makeCall('alert', msg);

const makeCall = <T extends ScriptEvent>(
type: T,
...args: Parameters<AllScriptEventHandlers[T]>
) => {
return new Promise<ReturnType<AllScriptEventHandlers[T]>>(
(resolve, reject) => {
const id = uuid();
const event = {
type,
payload: { id, args },
};
// TODO: cleanup listener
addEventListener('message', (e) => {
switch (e.data.type) {
case `${type}_response`:
if (e.data.payload.id !== id) {
return;
}
return resolve(e.data.payload.response);
case `${type}_error`:
if (e.data.payload.id !== id) {
return;
}
return reject(e.data.payload.error);
}
});
self.postMessage(event);
}
);
};
Loading

0 comments on commit be13860

Please sign in to comment.