Skip to content

Commit

Permalink
Merge pull request #18 from GoogleChromeLabs/new-singleton-mode
Browse files Browse the repository at this point in the history
Make singleton mode actually a singleton!
  • Loading branch information
developit committed Jan 14, 2020
2 parents 01f3b2b + 265e419 commit 7cc2dee
Show file tree
Hide file tree
Showing 6 changed files with 188 additions and 39 deletions.
74 changes: 69 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ npm install -D comlink-loader

The goal of `comlink-loader` is to make the fact that a module is running inside a Worker nearly transparent to the developer.

In the example below, the sole difference between running `MyClass` on a Worker thread instead of the main thread is that instantiation and method calls must be prefixed with `await`. This is required because Worker interactions are inherently asynchronous.
### Factory Mode (default)

In the example below, there are two changes we must make in order to import `MyClass` within a Worker via `comlink-loader`.

1. instantiation and method calls must be prefixed with `await`, since everything is inherently asynchronous.
2. the value we import from `comlink-loader!./my-class` is now a function that returns our module exports.
> Calling this function creates a new instance of the Worker.
**my-class.js**: _(gets moved into a worker)_

Expand All @@ -33,7 +39,7 @@ In the example below, the sole difference between running `MyClass` on a Worker
import rnd from 'random-int';

// Export as you would in a normal module:
export function meaningOfLife(){
export function meaningOfLife() {
return 42;
}

Expand All @@ -54,18 +60,76 @@ export class MyClass {
**main.js**: _(our demo, on the main thread)_

```js
import worker from 'comlink-loader!./my-class';
const inst = worker();
import MyWorker from 'comlink-loader!./my-class';

// instantiate a new Worker with our code in it:
const inst = new MyWorker();

// our module exports are exposed on the instance:
await inst.meaningOfLife(); // 42

const obj = await new inst.MyClass(42); // notice the await
// instantiate a class in the worker (does not create a new worker).
// notice the `await` here:
const obj = await new inst.MyClass(42);

await obj.increment();

await obj.getValue(); // 43
```

### Singleton Mode

Comlink-loader also includes a `singleton` mode, which can be opted in on a per-module basis using Webpack's inline loader syntax, or globally in Webpack configuration. Singleton mode is designed to be the easiest possible way to use a Web Worker, but in doing so it only allows using a single Worker instance for each module.

The benefit is that your module's exports can be used just like any other import, without the need for a constructor. It also supports TypeScript automatically, since the module being imported looks just like it would were it running on the main thread. The only change that is required in order to move a module into a Worker using singleton mode is to ensure all of your function calls use `await`.

First, configure `comlink-loader` globally to apply to all `*.worker.js` files (or whichever pattern you choose). Here we're going to use TypeScript, just to show that it works out-of-the-box:

**webpack.config.js**:

```js
module.exports = {
module: {
rules: [
{
test: /\.worker\.(js|ts)$/i,
use: [{
loader: 'comlink-loader',
options: {
singleton: true
}
}]
}
]
}
}
```

Now, let's write a simple module that we're going to load in a Worker:

**greetings.worker.ts**:

```ts
export async function greet(subject: string): string {
return `Hello, ${subject}!`;
}
```

We can import our the above module, and since the filename includes `.worker.ts`, it will be transparently loaded in a Web Worker!

**index.ts**:

```ts
import { greet } from './greetings.worker.ts';

async function demo() {
console.log(await greet('dog'));
}

demo();
```


## License

Apache-2.0
18 changes: 3 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,20 +12,12 @@
"release": "npm t && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish"
},
"eslintConfig": {
"extends": "eslint-config-standard",
"env": {
"browser": true,
"jasmine": true
},
"extends": "developit",
"rules": {
"import/no-webpack-loader-syntax": false,
"indent": [
"error",
2
],
"semi": [
"error",
"always"
]
}
},
Expand Down Expand Up @@ -55,15 +47,11 @@
"license": "Apache-2.0",
"devDependencies": {
"eslint": "^4.16.0",
"eslint-config-standard": "^11.0.0",
"eslint-plugin-import": "^2.12.0",
"eslint-plugin-node": "^6.0.1",
"eslint-plugin-promise": "^3.8.0",
"eslint-plugin-standard": "^3.1.0",
"eslint-config-developit": "^1.1.1",
"jasmine-sinon": "^0.4.0",
"karmatic": "^1.4.0",
"microbundle": "^0.11.0",
"sinon": "^5.1.0",
"sinon": "^8.0.4",
"webpack": "^4.41.2"
},
"dependencies": {
Expand Down
42 changes: 33 additions & 9 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,51 @@ import path from 'path';
import loaderUtils from 'loader-utils';
import slash from 'slash';

const comlinkLoaderSpecificOptions = ['multiple', 'multi', 'singleton'];
const comlinkLoaderSpecificOptions = [
'multiple', 'multi', // @todo: remove these
'singleton'
];

export default function loader () { }

loader.pitch = function (request) {
const options = loaderUtils.getOptions(this) || {};
const multi = options.multiple || options.multi || options.singleton === false;
const singleton = options.singleton;
const workerLoaderOptions = {};
for (let i in options) {
if (comlinkLoaderSpecificOptions.indexOf(i) === -1) {
workerLoaderOptions[i] = options[i];
}
}

const workerLoader = `!worker-loader?${JSON.stringify(workerLoaderOptions)}!${slash(path.resolve(__dirname, 'comlink-worker-loader.js'))}`;

const remainingRequest = JSON.stringify(workerLoader + '!' + request);

// ?singleton mode: export an instance of the worker
if (singleton === true) {
return `
module.exports = require('comlink').wrap(require(${remainingRequest})());
${options.module === false ? '' : 'module.exports.__esModule = true;'}
`.replace(/\n\s*/g, '');
}

// ?singleton=false mode: always return a new worker from the factory
if (singleton === false) {
return `
module.exports = function () {
return require('comlink').wrap(require(${remainingRequest})());
};
`.replace(/\n\s*/g, '');
}

return `
import { wrap } from 'comlink';
var inst;
var worker = wrap(require('!worker-loader?${JSON.stringify(workerLoaderOptions)}!${slash(path.resolve(__dirname, 'comlink-worker-loader.js'))}!${request}');
export default function f() {
if (this instanceof f) return wrap(worker());
return inst = inst || wrap(worker());
}
var wrap = require('comlink').wrap,
Worker = require(${remainingRequest}),
inst;
module.exports = function f() {
if (this instanceof f) return wrap(Worker());
return inst || (inst = wrap(Worker()));
};
`.replace(/\n\s*/g, '');
};
36 changes: 26 additions & 10 deletions test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,24 @@

import sinon from 'sinon';
import 'jasmine-sinon';
import './other';
import worker from 'comlink-loader!./worker';
import MyWorker from 'comlink-loader!./worker';

const OriginalWorker = self.Worker;
self.Worker = sinon.spy((url, opts) => new OriginalWorker(url, opts));

describe('worker', () => {
let inst;
let worker, inst;

it('should be instantiable', async () => {
inst = await new (worker().MyClass)();
it('should be a factory', async () => {
worker = new MyWorker();
expect(self.Worker).toHaveBeenCalledOnce();
self.Worker.resetHistory();
expect(self.Worker).not.toHaveBeenCalled();
});

it('should be instantiable', async () => {
inst = await new (worker.MyClass)();
expect(self.Worker).not.toHaveBeenCalled();
});

it('inst.foo()', async () => {
Expand All @@ -44,17 +50,27 @@ describe('worker', () => {
it('should propagate worker exceptions', async () => {
try {
await inst.throwError();
} catch (e) {
}
catch (e) {
expect(e).toMatch(/Error/);
}
});

it('should re-use Worker instances after the first instance', async () => {
sinon.reset(self.Worker);
it('should re-use Worker instances when the factory is invoked without `new`', async () => {
self.Worker.resetHistory();

const secondInst = await new (worker().MyClass)();
expect(secondInst).not.toBe(inst);
const firstWorker = MyWorker();
const firstInst = await new (firstWorker.MyClass)();
expect(await firstInst.foo()).toBe(1);

expect(self.Worker).toHaveBeenCalledOnce();

self.Worker.resetHistory();

const secondWorker = MyWorker();
const secondInst = await new (secondWorker.MyClass)();
expect(await secondInst.foo()).toBe(1);
expect(secondInst).not.toBe(inst);

expect(self.Worker).not.toHaveBeenCalled();
});
Expand Down
53 changes: 53 additions & 0 deletions test/singleton.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Copyright 2018 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/

import sinon from 'sinon';
import 'jasmine-sinon';

const OriginalWorker = self.Worker;
self.Worker = sinon.spy((url, opts) => new OriginalWorker(url, opts));

describe('singleton', () => {
let exported;

it('should immediately instantiate the worker', async () => {
// we're using dynamic import here so the Worker spy can be installed before-hand
exported = require('comlink-loader?singleton!./worker');

expect(self.Worker).toHaveBeenCalledOnce();

self.Worker.resetHistory();
});

it('should function and not re-instantiate the Worker', async () => {
const inst = await new exported.MyClass();
expect(await inst.foo()).toBe(1);

expect(await exported.hello()).toBe('world');

expect(self.Worker).not.toHaveBeenCalled();
});

it('should propagate worker exceptions', async () => {
const inst = await new exported.MyClass();
try {
await inst.throwError();
}
catch (e) {
expect(e).toMatch(/Error/);
}
});
});
4 changes: 4 additions & 0 deletions test/worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

import { otherBar } from './other';

export function hello() {
return Promise.resolve('world');
}

export class MyClass {
constructor ({ value = 41 } = {}) {
this.myValue = value;
Expand Down

0 comments on commit 7cc2dee

Please sign in to comment.