Work with Worker “as you wish,” not “as much as possible”

This article will use the DIRTY, unsafe, "crutch", scary, etc. eval



method. The faint of heart do not read!







I must say right away that it was not possible to solve some usability problems: you cannot use closure in the code that will be passed to worker.

Work with Worker "as you wish", but not "as much as possible"







We all like new technologies, and we like it when it's convenient to use these technologies. But in the case of worker, this is not entirely true. Worker works with a file or file link, but this is inconvenient. I would like to be able to put any task into the worker, and not just specially planned code.







What do you need to make working with worker more convenient? In my opinion, the following:









First, we need a communication protocol between worker and the main window. In general, a protocol is simply the structure and data types with which the browser window and worker will communicate. There is nothing complicated. You can use something like this or write your own version. In each message we will have an ID, and data specific to a particular type of message. For starters, we will have two types of messages for worker:









File inside worker



Before you start creating a worker, you need to describe a file that will work in worker and support the protocol described by us. I love OOP , so it will be a class called WorkerBody. This class must subscribe to the event from the parent window.







 self.onmessage = (message) => { this.onMessage(message.data); };
      
      





Now we can listen to events from the parent window. We have two kinds of events: those to which the answer is implied, and all the others. Handle the events.

Adding libraries and files to the worker is done using the importScripts API.







And the worst thing: we will use eval to run an arbitrary function.







 ... onMessage(message) { switch (message.type) { case MESSAGE_TYPE.ADD_LIBS: this.addLibs(message.libs); break; case MESSAGE_TYPE.WORK: this.doWork(message); break; } } doWork(message) { try { const processor = eval(message.job); const params = this._parser.parse(message.params); const result = processor(params); if (result && result.then && typeof result.then === 'function') { result.then((data) => { this.send({ id: message.id, state: true, body: data }); }, (error) => { if (error instanceof Error) { error = String(error); } this.send({ id: message.id, state: false, body: error }); }); } else { this.send({ id: message.id, state: true, body: result }); } } catch (e) { this.send({ id: message.id, state: false, body: String(e) }); } } send(data) { data.body = this._serializer.serialize(data.body); try { self.postMessage(data); } catch (e) { const toSet = { id: data.id, state: false, body: String(e) }; self.postMessage(toSet); } }
      
      





The onMessage



method onMessage



responsible for receiving the message and selecting the handler, doWork



- starts the passed function, and send



sends the response to the parent window.







Parser and serializer



Now that we have the contents of worker, we need to learn how to serialize and parse any data in order to pass it to worker. Let's start with the serializer. We want to be able to pass any data to the worker, including class instances, classes, and functions. But with the native features of worker, we can only transmit JSON-like data. To get around this ban, we need eval . Everything that cannot accept JSON, we will wrap in the corresponding string constructions and run on the other side. To preserve immutability, the data obtained is cloned on the fly, and that which cannot be serialized by conventional methods is replaced by service objects, and they, in turn, are replaced back by the parser on the other side. At first glance, it may seem that this task is not difficult, but there are many pitfalls. The worst limitation of this approach is the inability to use closure, which carries a slightly different style of writing code. Let's start with the simplest, with the function. First you need to learn how to distinguish a function from a class constructor.







Let's try to distinguish:







 static isFunction(Factory){ if (!Factory.prototype) { // Arrow function has no prototype return true; } const prototypePropsLength = Object.getOwnPropertyNames(Factory.prototype) .filter(item => item !== 'constructor') .length; return prototypePropsLength === 0 && Serializer.getClassParents(Factory).length === 1; } static getClassParents(Factory) { const result = [Factory]; let tmp = Factory; let item = Object.getPrototypeOf(tmp); while (item.prototype) { result.push(item); tmp = item; item = Object.getPrototypeOf(tmp); } return result.reverse(); }
      
      





First of all, we will find out if the function has a prototype. If it is not, this is definitely a function. Then we look at the number of properties in the prototype, and if in the prototype only the constructor and function are not the descendant of another class, we consider that this is a function.







Having found a function, we simply replace it with a service object with the fields __type = "serialized-function"



and template



, which is equal to the template of this function ( func.toString()



).







For now, skip the class and parse the class instance. Further in the data we need to distinguish ordinary objects from class instances.







 static isInstance(some) { const constructor = some.constructor; if (!constructor) { return false; } return !Serializer.isNative(constructor); } static isNative(data) { return /function .*?\(\) \{ \[native code\] \}/.test(data.toString()); }
      
      





We consider an object to be ordinary if it does not have a constructor or its constructor is a native function. Having identified the class instance, we replace it with a service object with fields:









To transfer data, we need to make an additional field: in it we will store a list of all the unique classes that we pass. The most difficult part is to take not only its template when detecting a class, but also the template of all parent classes and save them as separate classes - so that each "parent" is passed no more than once - and save the check on instanceof. Defining a class is easy: this is a function that did not pass our Serializer.isFunction test. When adding a class, we check for the presence of such a class in the list of serialized data and add only unique ones. The code that collects the class into a template is quite large and lies here .







In the parser, we first go around all the classes passed to us and compile them if they have not been passed before. Then we recursively traverse each data field and replace the service objects with compiled data. The most interesting thing is in the class instance. We have a class and there is data that was in its instance, but we cannot just instantiate it, because the call to the constructor may have parameters that we don’t have. An almost forgotten Object.create method comes to our aid, which returns an object with a given prototype. So we avoid calling the constructor and get an instance of the class, and then simply rewrite the properties into the instance.







Creation worker



For the worker to work successfully, we need to have a parser and serializer inside the worker and outside, so we take the serializer and turn the serializer, parser and body of the worker into a template. We make a blob from the template and create a download link via URL.createObjectURL (this method may not work with some "Content-Security-Policy"). This method is also suitable for running arbitrary code from a string.







 _createWorker(customWorker) { const template = `var MyWorker = ${this._createTemplate(customWorker)};`; const blob = new Blob([template], { type: 'application/javascript' }); return new Worker(URL.createObjectURL(blob)); } _createTemplate(WorkerBody) { const Name = Serializer.getFnName(WorkerBody); if (!Name) { throw new Error('Unnamed Worker Body class! Please add name to Worker Body class!'); } return [ '(function () {', this._getFullClassTemplate(Serializer, 'Serializer'), this._getFullClassTemplate(Parser, 'Parser'), this._getFullClassTemplate(WorkerBody, 'WorkerBody'), `return new WorkerBody(Serializer, Parser)})();` ].join('\n'); }
      
      





Result



Thus, we have obtained an easy-to-use library that can run arbitrary code in worker. It supports classes from TypeScript. For example:







 const wrapper = workerWrapper.create(); wrapper.process((params) => { // This code in worker. Cannot use closure! // do some hard work return 100; // or return Promise.resolve(100) }, params).then((result) => { // result = 100; }); wrapper.terminate() // terminate for kill worker process
      
      





Future plans



This library, unfortunately, is far from ideal. It is necessary to add support for setters and getters on classes, objects, prototypes, static properties. We would also like to add caching, make an alternative script run without eval



via URL.createObjectURL and add a file with the contents of worker to the assembly (if creation is not available on the fly). Come to the repository !








All Articles