├── .gitignore ├── .jshintrc ├── README.md ├── bower.json ├── debugging.md ├── demo ├── a.html ├── b.html ├── index.html └── sw.js ├── dist └── sww.js ├── gulpfile.js ├── index.js ├── lib ├── router.js ├── simpleofflinecache.js ├── spec │ ├── globalEventsMock.js │ ├── nodeMock.js │ ├── router.sw-spec.js │ ├── routerMock.js │ ├── simpleofflinecache.sw-spec.js │ ├── staticcacher.sw-spec.js │ └── sww.sw-spec.js ├── staticcacher.js └── sww.js ├── package.json ├── sw-tests.js └── testing └── karma-sw.conf.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | .idea 3 | dist/sww.js.map 4 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "curly": true, 3 | "eqeqeq": true, 4 | "freeze": true, 5 | "indent": 2, 6 | "newcap": false, 7 | "quotmark": "single", 8 | "maxdepth": 3, 9 | "maxstatements": 50, 10 | "maxlen": 80, 11 | "eqnull": true, 12 | "funcscope": true, 13 | "strict": true, 14 | "undef": true, 15 | "unused": true, 16 | "node": true, 17 | "mocha": true, 18 | "worker": true, 19 | "browserify": true, 20 | "globals": { 21 | "performance": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ServiceWorkerWare 2 | 3 | > An [Express](http://expressjs.com/)-like layer on top of `ServiceWorkers` to provide a way to easily plug functionality. 4 | 5 | **Compatibility** 6 | 7 | Currently working in: 8 | - [Chrome](https://www.google.co.uk/chrome/browser/desktop/index.html) 9 | - [Mozilla Nightly](https://blog.wanderview.com/sw-builds/) 10 | 11 | ## Philosophy 12 | 13 | `ServiceWorkers` are sold as the replacement for `AppCache`. But you can do more things than just cache network requests! They have a lifecycle and are able to listen to events (via postMessage), so you can write advanced caches, routers, and a lot more of things and have them run on the ServiceWorker. 14 | 15 | This library follows the same pattern as the Express framework, allowing developers to write individual *middleware* pieces that can be layered in order to augment the default functionality. 16 | 17 | ## Usage 18 | 19 | Our syntax is pretty similar to Express'. The first step is to load the basic library: 20 | 21 | ```javascript 22 | importScripts('../path/to/ServiceWorkerWare.js'); 23 | ``` 24 | 25 | Then you can load as many additional layers as you might need. In this example we will just import one: 26 | 27 | ```javascript 28 | // Defines a `myMiddleware` variable 29 | importScripts('../myMiddleware.js'); 30 | ``` 31 | 32 | Once `myMiddleware` is loaded, you can `use` it with `ServiceWorkerWare` instances: 33 | 34 | ```javascript 35 | var worker = new self.ServiceWorkerWare(); 36 | worker.use(myMiddleware); 37 | ``` 38 | 39 | And that will route the requests through `myMiddleware`. 40 | 41 | You can also specify paths and HTTP verbs, so only requests that match the path or the verb will be handled by that middleware. For example: 42 | 43 | ```javascript 44 | worker.post('/event/*', analyticsMiddleware); 45 | ``` 46 | 47 | More than one middleware can be registered with one worker. You just need to keep in mind that the `request` and `response` objects will be passed to each middleware in the same order that they were registered. 48 | 49 | 50 | ## Specifying routes 51 | 52 | While registering your middlewares, you are not limited just to predefined paths, you have the choice to specify your routes by using *placeholders*. You could use placeholders as wildcards that match several different routes and handle them in your middleware registration. These placeholders are loosely based on [Express' path strings](http://expressjs.com/guide/routing.html#route-paths). 53 | 54 | Currently supported placeholders include: 55 | 56 | * Anonymous placeholder: `*` 57 | Can accommodate any number of characters (including the empty string): 58 | * `"*"` is the universal route -- it will match any path 59 | ```javascript 60 | worker.get('*'); // matches any path 61 | ``` 62 | * `"/foo*"` will match `/foo` and any path downstream (like `/foo/bar/baz`) 63 | ```javascript 64 | worker.get('/foo*'); // will match /foo and any subpaths 65 | ``` 66 | 67 | * Named placeholder: `:` 68 | Can accommodate any substring, but doesn't allow the empty string (matches minimum 1 character). 69 | The placeholder name could be any number of alphanumeric characters: 70 | * `"/:path"' will match `/foo` and `/foo/bar/baz`, but won't match `/` 71 | ```javascript 72 | worker.get('/:path'); // won't match / as :path must not be empty 73 | ``` 74 | 75 | You could use the backslash character to escape special placeholders (and specify literal asterisks and/or colon characters in your code). *Note: in JavaScript string literals you must double the backslash to achieve the intended effect.* 76 | 77 | ```javascript 78 | worker.get('/:param\\:42'); // will match /x:42 and /answer/is:42 79 | ``` 80 | 81 | 82 | ## Writing a middleware layer 83 | 84 | Each middleware instance is an object implementing one callback per `ServiceWorker` event type to be handled: 85 | 86 | ``` 87 | { 88 | onInstall: fn1, 89 | onActivate: fn2, 90 | onFetch: fn3, 91 | onMessage: fn4 92 | } 93 | ``` 94 | 95 | You don't need to respond to all the events--you can opt to only implement a subset of methods you care about. For example, if you only want to handle requests, you just need to implement the `onFetch` method. But in that case you might be missing out on the full `ServiceWorker` potential: you can do things such as preloading and caching resources during installation, clear the caches during activation (if the worker has been updated) or even just control the worker's behaviour by sending it a message. 96 | 97 | Also, in order to make it even more Express-like, you can write middleware in the following way if you just want to handle fetch events and don't care about the ServiceWorkers life cycle: 98 | 99 | ```javascript 100 | var worker = new self.ServiceWorkerWare(); 101 | worker.get('/hello.html', function(request, response) { 102 | return Promise.resolve(new Response('Hello world!', { headers: {'Content-Type': 'text/html'} })); 103 | } 104 | worker.init(); 105 | ``` 106 | 107 | ## Advanced middleware pipelining 108 | 109 | Middlewares are tied to one or several URLs and executed in the same order you register them. The first middleware is passed with the Request from the client code and `null` as response. Next middlewares receive their parameters from the previous ones according to: 110 | 111 | * If returning a pair `[Request, Response]`, these will be the values for next request and response parameters. 112 | * If returning only a `Request`, this will be the next request and the response remains the same as for the previous middleware. 113 | * The same happens if returning only a `Response`. 114 | * For backward-compatibility, returning `null` will set the next Response to `null`. 115 | * Returning any other thing from a middleware will fail and cause a rejection. 116 | 117 | Finally, the answer from the service worker will be the response returned by the last middleware. 118 | 119 | If you need to perform asynchronous operations, instead of returning one of the previous values, you can return a Promise resolving in one of the previous values. 120 | 121 | ### Interrupting the middleware pipeline 122 | 123 | If you want to respond from a middleware immediately and prevent other middlewares to be executed, return the response you want to use wrapped inside the `endWith()` callback. The callback is received as the third parameter of a middleware and expects a mandatory parameter to be the final response. 124 | 125 | ```javascript 126 | var worker = new self.ServiceWorkerWare(); 127 | worker.use(function (req, res, endWith) { 128 | return endWith( 129 | new Response('Hello world!', { headers: {'Content-Type': 'text/html'} }); 130 | ); 131 | }); 132 | worker.use(function (req, res, endWith) { 133 | console.log('Hello!'); // this will be never printed 134 | }); 135 | worker.init(); 136 | ``` 137 | 138 | Remember you can use promises resolving to the wrapped response for your asynchronous stuff. 139 | 140 | ## Events 141 | 142 | ### `onInstall` 143 | 144 | It will be called during ServiceWorker installation. This happens just once. 145 | 146 | ### `onActivate` 147 | 148 | It will be called just after `onInstall`, or each time that you update your ServiceWorker. It's useful to update caches or any part of your application logic. 149 | 150 | ### `onFetch(request, response)` returns Promise 151 | 152 | Will be called whenever the browser requests a resource when the worker has been installed. 153 | 154 | * `request`: it's a standard [Request Object](https://fetch.spec.whatwg.org/#concept-request) 155 | * `response`: it's a standard [Response Object](https://fetch.spec.whatwg.org/#concept-response) 156 | 157 | You need to `.clone()` the `request` before using it as some fields are one use only. 158 | 159 | After the whole process of iterating over the different middleware you need to return a `Response` object. Each middleware can use the previous `response` object to perform operations over the content, headers or anything. 160 | 161 | The `request` object provides details that define which resource was requested. This callback might (or not) use those details when building the `Response` object that it must return. 162 | 163 | The following example will handle URLs that start with `virtual/` with the simplest version of `onFetch`: a callback! None of the requested resources need to physically exist, they will be programmatically created by the handler on demand: 164 | 165 | ``` 166 | worker.get('virtual/.', function(request, response) { 167 | 168 | var url = request.clone().url; 169 | 170 | var content = '' 171 | + url + ' ' + Math.random() 172 | + '
index'; 173 | 174 | return Promise.resolve(new Response(content, { 175 | headers: { 176 | 'Content-Type': 'text/html', 177 | 'x-powered-by': 'ServiceWorkerWare' 178 | } 179 | })); 180 | }); 181 | ``` 182 | 183 | The output for any request that hits this URL will be the original address of the URL, and a randomly generated number. Note how we can even specify the headers for our response! 184 | 185 | Remember that the handler *must always return a Promise*. 186 | 187 | ### `onMessage(?)` 188 | 189 | We will receive this event if an actor (window, iframe, another worker or shared worker) performs a `postMessage` on the ServiceWorker to communicate with it. 190 | 191 | 204 | 205 | ## Middleware examples 206 | 207 | This package already incorporates two simple middlewares. They are available by default when you import it: 208 | 209 | TODO: I would suggest refactoring them away from this package 210 | 211 | ### `StaticCacher` 212 | 213 | This will let you preload and cache static content during the `ServiceWorker` installation. 214 | 215 | TODO: where does `self` come from? in which context is this being executed? 216 | 217 | For example: 218 | 219 | ``` 220 | worker.use(new self.StaticCacher(['a.html', 'b.html' ...])); 221 | ``` 222 | 223 | Upon installation, this worker will load `a.html` and `b.html` and store their content in the default cache. 224 | 225 | ### `SimpleOfflineCache` 226 | 227 | This will serve contents stored in the default cache. If it cannot find a resource in the cache, it will perform a fetch and save it to the cache. 228 | 229 | ### [ZipCacher](https://github.com/arcturus/zipcacher) 230 | 231 | This is not built-in with this library. It enables you to specify a ZIP file to cache your resources from. 232 | 233 | ```javascript 234 | importScripts('./sww.js'); 235 | importScripts('./zipcacher.js'); 236 | 237 | var worker = new self.ServiceWorkerWare(); 238 | 239 | worker.use(new ZipCacher('http://localhost:8000/demo/resources.zip')); 240 | worker.use(new self.SimpleOfflineCache()); 241 | worker.init(); 242 | ``` 243 | 244 | ## Running the demo 245 | 246 | Clone the repository, and then cd to the directory and run: 247 | 248 | ``` 249 | npm install 250 | gulp webserver 251 | ``` 252 | 253 | TODO: use npm scripts to run gulp, avoid global gulp installation 254 | 255 | And go to `http://localhost:8000/demo/index.html` using any of the browsers where `ServiceWorker`s are supported. 256 | 257 | When you visit `index.html` the ServiceWorker will be installed and `/demo/a.html` will be preloaded and cached. In contrast, `/demo/b.html` will be cached only once it is visited (i.e. only once the user navigates to it). 258 | 259 | For an example of more advanced programmatic functionality, you can also navigate to any URL of the form `http://localhost:8000/demo/virtual/` (where `anything` is any content you want to enter) and you'll receive an answer by the installed virtual URL handler middleware. 260 | 261 | # And what about testing? 262 | We are working on an you can see the specs under `lib/spec` folder. 263 | 264 | Please, launch: 265 | ```bash 266 | $ gulp tests 267 | ``` 268 | 269 | Once tests are complete, [Karma](http://karma-runner.github.io/0.12/index.html), the testing framework, keeps monitoring your files to relaunch the tests when something is modified. Do not try to close the browsers. If you want to stop testing, kill the Karma process. 270 | 271 | This should be straightforward for Windows and iOS. As usual, if your are on Linux or you're having problems with binary routes, try setting some environment variables: 272 | ```bash 273 | $ FIREFOX_NIGHTLY_BIN=/path/to/nightly-bin CHROME_BIN=/path/to/chrome-bin gulp tests 274 | ``` 275 | 276 | ## Thanks! 277 | 278 | A lot of this code has been inspired by different projects: 279 | 280 | - [Firefox OS V3 Architecture](https://github.com/fxos/contacts) 281 | - [Shed](https://github.com/wibblymat/shed) 282 | - [sw-precache](https://github.com/jeffposnick/sw-precache) 283 | - [offliner](https://github.com/lodr/offliner) 284 | 285 | ## License 286 | 287 | Mozilla Public License 2.0 288 | 289 | http://mozilla.org/MPL/2.0/ 290 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serviceworkerware", 3 | "main": "dist/sww.js", 4 | "version": "0.3.0", 5 | "homepage": "https://github.com/gaia-components/serviceworkerware", 6 | "authors": [ 7 | "Salvador de la Puente González ", 8 | "Francisco Jordano " 9 | ], 10 | "ignore": [ 11 | "*", 12 | "!dist/sww.js" 13 | ], 14 | "description": "An Express-like layer on top of ServiceWorkers to provide a way to easily plug functionality.", 15 | "keywords": [ 16 | "serviceworkers", 17 | "serviceworker", 18 | "offline", 19 | "cache", 20 | "workers" 21 | ], 22 | "license": "MPL 2.0" 23 | } 24 | -------------------------------------------------------------------------------- /debugging.md: -------------------------------------------------------------------------------- 1 | ServiceWorkerWare debugging 2 | =========================== 3 | 4 | ## Debug build 5 | 6 | By default the build script in `gulp` will strip the debugging information. To get a version that will include all debug information and performance measures, please run the following command: 7 | 8 | ```bash 9 | gulp debug 10 | ``` 11 | 12 | That will create a the following file: 13 | 14 | ``` 15 | ./dist/sww.js 16 | ``` 17 | 18 | With all the debugging options that we are adding. 19 | 20 | 21 | ##Performance debugging 22 | 23 | If you take a look to the code, you will see how in some places we are using the [Performance Timing API](https://developer.mozilla.org/en-US/docs/Web/API/Performance/timing), available in *Firefox Nightly*. 24 | 25 | 26 | We have introduced some marks that will be useful in your script to complete other measures. 27 | 28 | The philosophy behind this is to provide some marks at the begining of the events triggered by the *ServiceWorker* so later you can create more marks and measures in your specific middlwares. 29 | 30 | ### Performance marks 31 | #### sww_parsed 32 | Marks when the javascript file containing the library has been parsed. 33 | 34 | #### event_[install|fetch|activate]_start 35 | Mark setup when the *ServiceWorker* receives one of those events. 36 | 37 | #### event_[install|fetch|activate]_end 38 | This mark is setup when the method that process the event finish. Take into account that doesn't mean we have the result. 39 | 40 | #### soc_[url] 41 | Specific mark for the `SimpleOfflineCache` middleware that is setup when a request goues through this middleware. 42 | 43 | #### soc_cache_[url] 44 | Specific mark for the `SimpleOfflineCache` middleware, setup once that we did query the database associated to this middleware. 45 | 46 | #### soc_cache_hit_[url] 47 | As well this mark is associated to the `SimpleOfflineCache`. We will have it if we have a hit on the offline cache. 48 | -------------------------------------------------------------------------------- /demo/a.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Service Worker Ware Test 6 | 7 | 8 | This document, a.html, is cached by default, during the SW installation.
9 | index 10 | b 11 | 12 | -------------------------------------------------------------------------------- /demo/b.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Service Worker Ware Test 6 | 7 | 8 | This document is not cached during SW installation, but current policy fetch and stores in cache when we have a miss in the cache.
9 | index 10 | a 11 | 12 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Service Worker Ware Test 6 | 7 | 8 | 15 | a 16 | b 17 | 18 | 19 | -------------------------------------------------------------------------------- /demo/sw.js: -------------------------------------------------------------------------------- 1 | importScripts('../dist/sww.js'); 2 | 3 | var worker = new self.ServiceWorkerWare(); 4 | 5 | // Simple middleware that just listen to SW livecycle events, 6 | // it wont handle any request. 7 | var SimpleWare = { 8 | onInstall: function() { 9 | console.log('On install'); 10 | return Promise.resolve(); 11 | }, 12 | onActivate: function(evt) { 13 | console.log('On activate called!!'); 14 | }, 15 | onMessage: function(evt) { 16 | console.log('On message called!!'); 17 | } 18 | }; 19 | 20 | worker.use(SimpleWare); 21 | 22 | // We precache known resources with the StaticCacher middleware 23 | worker.use(new self.StaticCacher(['a.html'])); 24 | 25 | // Middleware example for handling 'virtual' urls and building the 26 | // response with js. 27 | worker.get('virtual/*', function(request, response) { 28 | var url = request.clone().url; 29 | 30 | var content = '' 31 | + url + ' ' + Math.random() 32 | + '
index'; 33 | 34 | return Promise.resolve(new Response(content, { 35 | headers: { 36 | 'Content-Type': 'text/html' 37 | } 38 | })); 39 | }); 40 | 41 | function updateHeaders(origHeaders) { 42 | var headers = new Headers(); 43 | for(var kv of origHeaders.entries()) { 44 | headers.append(kv[0], kv[1]); 45 | } 46 | 47 | headers.append('X-Powered-By', 'HTML5 ServiceWorkers FTW'); 48 | return headers; 49 | } 50 | 51 | // Handles offline resources saved by the StaticCacher middleware, 52 | // also caches those resources not in the cache for next visit 53 | worker.use(new self.SimpleOfflineCache()); 54 | worker.use(function(request, response) { 55 | // Add an X-Powered-By header to all responses. We have to manually 56 | // clone the response in order to modify the headers, see: 57 | // http://stackoverflow.com/questions/35421179/how-to-alter-the-headers-of-a-response 58 | return new Promise(function(resolve) { 59 | return response.blob().then(function(blob) { 60 | resolve(new Response(blob, { 61 | status: response.status, 62 | statusText: response.statusText, 63 | headers: updateHeaders(response.headers) 64 | })); 65 | }); 66 | }); 67 | }); 68 | worker.init(); 69 | -------------------------------------------------------------------------------- /dist/sww.js: -------------------------------------------------------------------------------- 1 | (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o= middleware.length) { 432 | return Promise.resolve(response); 433 | } 434 | 435 | var mw = middleware[current]; 436 | if (request) { request.parameters = mw.__params; } 437 | var endWith = ServiceWorkerWare.endWith; 438 | var answer = mw(request, response, endWith); 439 | var normalized = 440 | ServiceWorkerWare.normalizeMwAnswer(answer, request, response); 441 | 442 | return normalized.then(function (info) { 443 | switch (info.nextAction) { 444 | case TERMINATE: 445 | return Promise.resolve(info.response); 446 | 447 | case ERROR: 448 | return Promise.reject(info.error); 449 | 450 | case CONTINUE: 451 | var next = current + 1; 452 | var request = info.request; 453 | var response = info.response; 454 | return this.runMiddleware(middleware, next, request, response); 455 | } 456 | }.bind(this)); 457 | }; 458 | 459 | /** 460 | * A function to force interruption of the pipeline. 461 | * 462 | * @param {Response} the response object that will be used to answer from the 463 | * service worker. 464 | */ 465 | ServiceWorkerWare.endWith = function (response) { 466 | if (arguments.length === 0) { 467 | throw new Error('Type error: endWith() must be called with a value.'); 468 | } 469 | return [TERMINATION_TOKEN, response]; 470 | }; 471 | 472 | /** 473 | * A middleware is supposed to return a promise resolving in a pair of request 474 | * and response for the next one or to indicate that it wants to answer 475 | * immediately. 476 | * 477 | * To allow flexibility, the middleware is allowed to return other values 478 | * rather than the promise. For instance, it is allowed to return only a 479 | * request meaning the next middleware will be passed that request but the 480 | * previous response untouched. 481 | * 482 | * The function takes into account all the scenarios to compute the request 483 | * and response for the next middleware or the intention to terminate 484 | * immediately. 485 | * 486 | * @param {Any} non normalized answer from the middleware. 487 | * @param {Request} request passed as parameter to the middleware. 488 | * @param {Response} response passed as parameter to the middleware. 489 | */ 490 | ServiceWorkerWare.normalizeMwAnswer = function (answer, request, response) { 491 | if (!answer || !answer.then) { 492 | answer = Promise.resolve(answer); 493 | } 494 | return answer.then(function (value) { 495 | var nextAction = CONTINUE; 496 | var error, nextRequest, nextResponse; 497 | var isArray = Array.isArray(value); 498 | 499 | if (isArray && value[0] === TERMINATION_TOKEN) { 500 | nextAction = TERMINATE; 501 | nextRequest = request; 502 | nextResponse = value[1] || response; 503 | } 504 | else if (value === null) { 505 | nextRequest = request; 506 | nextResponse = null; 507 | } 508 | else if (isArray && value.length === 2) { 509 | nextRequest = value[0]; 510 | nextResponse = value[1]; 511 | } 512 | else if (value instanceof Response) { 513 | nextRequest = request; 514 | nextResponse = value; 515 | } 516 | else if (value instanceof Request) { 517 | nextRequest = value; 518 | nextResponse = response; 519 | } 520 | else { 521 | var msg = 'Type error: middleware must return a Response, ' + 522 | 'a Request, a pair [Response, Request] or a Promise ' + 523 | 'resolving to one of these types.'; 524 | nextAction = ERROR; 525 | error = new Error(msg); 526 | } 527 | 528 | return { 529 | nextAction: nextAction, 530 | request: nextRequest, 531 | response: nextResponse, 532 | error: error 533 | }; 534 | }); 535 | }; 536 | 537 | /** 538 | * Walk all the middleware installed asking if they have prerequisites 539 | * (on the way of a promise to be resolved) when installing the SW. 540 | */ 541 | ServiceWorkerWare.prototype.onInstall = function sww_oninstall(evt) { 542 | var installation = this.getFromMiddleware('onInstall'); 543 | evt.waitUntil(installation); 544 | 545 | }; 546 | 547 | /** 548 | * Walk all the installed middleware asking if they have prerequisites 549 | * (on the way of a promise to be resolved) when SW activates. 550 | */ 551 | ServiceWorkerWare.prototype.onActivate = function sww_activate(evt) { 552 | var activation = this.getFromMiddleware('onActivate'); 553 | if (this.autoClaim) { 554 | activation = 555 | activation.then(function claim() { return self.clients.claim(); }); 556 | } 557 | evt.waitUntil(activation); 558 | 559 | }; 560 | 561 | /** 562 | * Returns a promise gathering the results for executing the same method for 563 | * all the middleware. 564 | * @param {Function} the method to be executed. 565 | * @param {Promise} a promise resolving once all the results have been gathered. 566 | */ 567 | ServiceWorkerWare.prototype.getFromMiddleware = 568 | function sww_getFromMiddleware(method) { 569 | var tasks = this.middleware.reduce(function (tasks, mw) { 570 | if (typeof mw[method] === 'function') { 571 | tasks.push(mw[method]()); 572 | } 573 | return tasks; 574 | }, []); 575 | return Promise.all(tasks); 576 | }; 577 | 578 | /** 579 | * Register a new middleware layer, they will treat the request in 580 | * the order that this layers have been defined. 581 | * A middleware layer can behave in the ServiceWorker in two ways: 582 | * - Listening to SW lifecycle events (install, activate, message). 583 | * - Handle a request. 584 | * To handle each case (or both) the middleware object should provide 585 | * the following methods: 586 | * - on: for listening to SW lifeciclye events 587 | * - onFetch: for handling fetch urls 588 | */ 589 | ServiceWorkerWare.prototype.use = function sww_use() { 590 | // If the first parameter is not a function we will understand that 591 | // is the path to handle, and the handler will be the second parameter 592 | if (arguments.length === 0) { 593 | throw new Error('No arguments given'); 594 | } 595 | var mw = arguments[0]; 596 | var path = '*'; 597 | var method = this.router.ALL_METHODS; 598 | if (typeof mw === 'string') { 599 | path = arguments[0]; 600 | mw = arguments[1]; 601 | var kind = typeof mw; 602 | if (!mw || !(kind === 'object' || kind === 'function')) { 603 | throw new Error('No middleware specified'); 604 | } 605 | if (Router.prototype.methods.indexOf(arguments[2]) !== -1) { 606 | method = arguments[2]; 607 | } 608 | } 609 | 610 | this.middleware.push(mw); 611 | // Add to the router just if middleware object is able to handle onFetch 612 | // or if we have a simple function 613 | var handler = null; 614 | if (typeof mw.onFetch === 'function') { 615 | handler = mw.onFetch.bind(mw); 616 | } else if (typeof mw === 'function') { 617 | handler = mw; 618 | } 619 | if (handler) { 620 | this.router.add(method, path, handler); 621 | } 622 | // XXX: Attaching the broadcastMessage to mw that implements onMessage. 623 | // We should provide a way to get a reference to the SWW object and do 624 | // the broadcast from there 625 | if (typeof mw.onMessage === 'function') { 626 | mw.broadcastMessage = this.broadcastMessage; 627 | } 628 | }; 629 | 630 | 631 | /** 632 | * Forward the event received to any middleware layer that has a 'on' 633 | * handler 634 | */ 635 | ServiceWorkerWare.prototype.forwardEvent = function sww_forwardEvent(evt) { 636 | this.middleware.forEach(function(mw) { 637 | var handlerName = 'on' + evt.type.replace(/^[a-z]/, 638 | function(m){ 639 | return m.toUpperCase(); 640 | } 641 | ); 642 | if (typeof mw[handlerName] !== 'undefined') { 643 | mw[handlerName].call(mw, evt); 644 | } 645 | }); 646 | }; 647 | 648 | /** 649 | * Broadcast a message to all worker clients 650 | * @param msg Object the message 651 | * @param channel String (Used just in Firefox Nightly) using broadcastchannel 652 | * api to deliver the message, this parameter can be undefined as we listen for 653 | * a channel undefined in the client. 654 | */ 655 | ServiceWorkerWare.prototype.broadcastMessage = function sww_broadcastMessage( 656 | msg, channel) { 657 | // XXX: Until https://bugzilla.mozilla.org/show_bug.cgi?id=1130685 is fixed 658 | // we can use BroadcastChannel API in Firefox Nightly 659 | if (typeof BroadcastChannel === 'function') { 660 | var bc = new BroadcastChannel(channel); 661 | bc.postMessage(msg); 662 | bc.close(); 663 | return Promise.resolve(); 664 | } else { 665 | // This is suppose to be the way of broadcasting a message, unfortunately 666 | // it's not working yet in Chrome Canary 667 | return clients.matchAll().then(function(consumers) { 668 | consumers.forEach(function(client) { 669 | client.postMessage(msg); 670 | }); 671 | }); 672 | } 673 | }; 674 | 675 | ServiceWorkerWare.decorators = { 676 | 677 | ifNoResponse: function (mw) { 678 | return function (req, res, endWith) { 679 | if (res) { return [req, res]; } 680 | return mw(req, res, endWith); 681 | }; 682 | }, 683 | 684 | stopAfter: function (mw) { 685 | return function (req, res, endWith) { 686 | var answer = mw(req, res, endWith); 687 | var normalized = ServiceWorkerWare.normalizeMwAnswer(answer, req, res); 688 | 689 | return normalized.then(function (info) { 690 | if (info.nextAction === 'error') { 691 | return Promise.reject(info.error); 692 | } 693 | return endWith(info.response); 694 | }); 695 | }; 696 | } 697 | }; 698 | 699 | 700 | module.exports = { 701 | ServiceWorkerWare: ServiceWorkerWare, 702 | StaticCacher: StaticCacher, 703 | SimpleOfflineCache: SimpleOfflineCache 704 | }; 705 | 706 | },{"./router.js":2,"./simpleofflinecache.js":3,"./staticcacher.js":4}]},{},[1]); 707 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var browserify = require('browserify'); 4 | var gulp = require('gulp'); 5 | var source = require('vinyl-source-stream'); 6 | var buffer = require('vinyl-buffer'); 7 | var sourcemaps = require('gulp-sourcemaps'); 8 | var webserver = require('gulp-webserver'); 9 | var jshint = require('gulp-jshint'); 10 | var watch = require('gulp-watch'); 11 | var karma = require('karma'); 12 | var preprocessify = require('preprocessify'); 13 | 14 | var getBundleName = function () { 15 | return 'sww'; 16 | }; 17 | 18 | gulp.task('tests', function () { 19 | new karma.Server({ 20 | configFile: __dirname + '/testing/karma-sw.conf.js', 21 | singleRun: false 22 | }).start(); 23 | }); 24 | 25 | gulp.task('bundle-dist', function() { 26 | var bundler = browserify({ 27 | entries: ['./index.js'], 28 | debug: false, 29 | standAlone: 'ServiceWorkerWare' 30 | }); 31 | 32 | var bundle = function() { 33 | return bundler 34 | .transform(preprocessify()) 35 | .bundle() 36 | .pipe(source(getBundleName() + '.js')) 37 | .pipe(buffer()) 38 | .pipe(gulp.dest('./dist/')); 39 | }; 40 | 41 | return bundle(); 42 | }); 43 | 44 | gulp.task('bundle-debug', function() { 45 | 46 | var bundler = browserify({ 47 | entries: ['./index.js'], 48 | debug: true, 49 | standAlone: 'ServiceWorkerWare' 50 | }); 51 | 52 | var bundle = function() { 53 | return bundler 54 | .transform(preprocessify( 55 | { DEBUG: true } 56 | )) 57 | .bundle() 58 | .pipe(source(getBundleName() + '.js')) 59 | .pipe(buffer()) 60 | .pipe(sourcemaps.init({loadMaps: true})) 61 | .pipe(sourcemaps.write('./')) 62 | .pipe(gulp.dest('./dist/')); 63 | }; 64 | 65 | return bundle(); 66 | }); 67 | 68 | gulp.task('webserver', function() { 69 | gulp.src('.') 70 | .pipe(webserver({ 71 | livereload: true, 72 | directoryListing: true, 73 | open: true 74 | })); 75 | }); 76 | 77 | gulp.task('watch', function() { 78 | gulp.watch('./lib/*', ['lint', 'javascript']); 79 | }); 80 | 81 | gulp.task('lint', function() { 82 | return gulp.src(['./lib/**/*.js', './index.js']) 83 | .pipe(jshint()) 84 | .pipe(jshint.reporter('default')); 85 | }); 86 | 87 | gulp.task('default', ['lint','bundle-dist']); 88 | gulp.task('debug', ['lint', 'bundle-debug']) 89 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var sww = require('./lib/sww.js'); 2 | 3 | self.ServiceWorkerWare = sww.ServiceWorkerWare; 4 | self.StaticCacher = sww.StaticCacher; 5 | self.SimpleOfflineCache = sww.SimpleOfflineCache; 6 | -------------------------------------------------------------------------------- /lib/router.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Inspired by expressjs and shed (https://github.com/wibblymat/shed) 4 | function Router(options) { 5 | this.options = options; 6 | this.stack = []; 7 | } 8 | 9 | Router.prototype.ALL_METHODS = 'all'; 10 | Router.prototype.methods = ['get', 'post', 'put', 'delete', 'head', 11 | Router.prototype.ALL_METHODS]; 12 | 13 | /** 14 | * Add a new route to the stack. 15 | * @param method (String) http verb to handle the request 16 | * @param path (Regexp) string or regexp to match urls 17 | * @param handler (Function) payload to be executed if url matches. 18 | */ 19 | Router.prototype.add = function r_add(method, path, handler) { 20 | var pathRegexAndTags, pathRegex, namedPlaceholders; 21 | 22 | method = this._sanitizeMethod(method); 23 | 24 | // Parse simle string path into regular expression for path matching 25 | pathRegexAndTags = this._parseSimplePath(path); 26 | pathRegex = pathRegexAndTags.regexp; 27 | namedPlaceholders = pathRegexAndTags.tags; 28 | 29 | this.stack.push({ 30 | method: method, 31 | path: pathRegex, 32 | namedPlaceholders: namedPlaceholders, 33 | handler: handler 34 | }); 35 | }; 36 | 37 | /** 38 | * Create the utility methods .get .post ... etc. 39 | */ 40 | Router.prototype.methods.forEach(function(method) { 41 | Router.prototype[method] = function(path, handler) { 42 | return this.add(method, path, handler); 43 | }; 44 | }); 45 | 46 | Router.prototype.proxyMethods = function r_proxyPrototype(obj) { 47 | var self = this; 48 | this.methods.forEach(function(method) { 49 | obj[method] = function(path, mw) { 50 | if (typeof mw.onFetch !== 'function' && typeof mw !== 'function') { 51 | throw new Error('This middleware cannot handle fetch request'); 52 | } 53 | var handler = typeof mw.onFetch !== 'undefined' ? 54 | mw.onFetch.bind(mw) : mw; 55 | self.add(method, path, handler); 56 | }; 57 | }); 58 | }; 59 | 60 | /** 61 | * Matches the given url and methods with the routes stored in 62 | * the stack. 63 | */ 64 | Router.prototype.match = function r_match(method, url) { 65 | method = this._sanitizeMethod(method); 66 | var matches = []; 67 | 68 | var _this = this; 69 | this.stack.forEach(function eachRoute(route) { 70 | if (!(method === route.method || route.method === _this.ALL_METHODS)) { 71 | return; 72 | } 73 | 74 | var groups = _this._routeMatch(url, route); 75 | if (groups) { 76 | route.handler.__params = groups; 77 | matches.push(route.handler); 78 | } 79 | }); 80 | 81 | return matches; 82 | }; 83 | 84 | /** 85 | * Performs a matching test for url against a route. 86 | * @param {String} the url to test 87 | * @param {route} the route to match against 88 | * @return {Object} an object with the portions of the url matching the named 89 | * placeholders or null if there is no match. 90 | */ 91 | Router.prototype._routeMatch = function (url, route) { 92 | var groups = url.match(route.path); 93 | if (!groups) { return null; } 94 | return this._mapParameters(groups, route.namedPlaceholders); 95 | }; 96 | 97 | /** 98 | * Assign names from named placeholders in a route to the matching groups 99 | * for an URL against that route. 100 | * @param {Array} groups from a successful match 101 | * @param {Array} names for those groups 102 | * @return a map of names of named placeholders and values for those matches. 103 | */ 104 | Router.prototype._mapParameters = function (groups, placeholderNames) { 105 | return placeholderNames.reduce(function (params, name, index) { 106 | params[name] = groups[index + 1]; 107 | return params; 108 | }, Object.create(null)); 109 | }; 110 | 111 | Router.prototype._sanitizeMethod = function(method) { 112 | var sanitizedMethod = method.toLowerCase().trim(); 113 | if (this.methods.indexOf(sanitizedMethod) === -1) { 114 | throw new Error('Method "' + method + '" is not supported'); 115 | } 116 | return sanitizedMethod; 117 | }; 118 | 119 | /** 120 | * Simple path-to-regex translation based on the Express "string-based path" 121 | * syntax. 122 | */ 123 | Router.prototype._parseSimplePath = function(path) { 124 | // Check for named placeholder crowding 125 | if (/\:[a-zA-Z0-9]+\:[a-zA-Z0-9]+/g.test(path)) { 126 | throw new Error('Invalid usage of named placeholders'); 127 | } 128 | 129 | // Check for mixed placeholder crowdings 130 | var mixedPlaceHolders = 131 | /(\*\:[a-zA-Z0-9]+)|(\:[a-zA-Z0-9]+\:[a-zA-Z0-9]+)|(\:[a-zA-Z0-9]+\*)/g; 132 | if (mixedPlaceHolders.test(path.replace(/\\\*/g,''))) { 133 | throw new Error('Invalid usage of named placeholders'); 134 | } 135 | 136 | // Try parsing the string and converting special characters into regex 137 | try { 138 | // Parsing anonymous placeholders with simple backslash-escapes 139 | path = path.replace(/(.|^)[*]+/g, function(m,escape) { 140 | return escape==='\\' ? '\\*' : (escape+'(?:.*?)'); 141 | }); 142 | 143 | // Parsing named placeholders with backslash-escapes 144 | var tags = []; 145 | path = path.replace(/(.|^)\:([a-zA-Z0-9]+)/g, function (m, escape, tag) { 146 | if (escape === '\\') { return ':' + tag; } 147 | tags.push(tag); 148 | return escape + '(.+?)'; 149 | }); 150 | 151 | return { regexp: RegExp(path + '$'), tags: tags }; 152 | } 153 | 154 | // Failed to parse final path as a RegExp 155 | catch (ex) { 156 | throw new Error('Invalid path specified'); 157 | } 158 | }; 159 | 160 | 161 | module.exports = Router; 162 | -------------------------------------------------------------------------------- /lib/simpleofflinecache.js: -------------------------------------------------------------------------------- 1 | /* global Promise, caches */ 2 | 'use strict'; 3 | 4 | // Default Match options, not exposed. 5 | var DEFAULT_MATCH_OPTIONS = { 6 | ignoreSearch: false, 7 | ignoreMethod: false, 8 | ignoreVary: false 9 | }; 10 | var DEFAULT_MISS_POLICY = 'fetch'; 11 | // List of different policies 12 | var MISS_POLICIES = [ 13 | DEFAULT_MISS_POLICY 14 | ]; 15 | 16 | var DEFAULT_CACHE_NAME = 'offline'; 17 | 18 | 19 | /** 20 | * Constructor for the middleware that serves the content of a 21 | * cache specified by it's name. 22 | * @param {string} cacheName Name of the cache that will be serving the content 23 | * @param {object} [options] Object use to setup the cache matching alternatives 24 | * @param {string} [missPolicy] Name of the policy to follow if a request miss 25 | * when hitting the cache. 26 | */ 27 | function SimpleOfflineCache(cacheName, options, missPolicy) { 28 | this.cacheName = cacheName || DEFAULT_CACHE_NAME; 29 | this.options = options || DEFAULT_MATCH_OPTIONS; 30 | this.missPolicy = missPolicy || DEFAULT_MISS_POLICY; 31 | if (MISS_POLICIES.indexOf(this.missPolicy) === -1) { 32 | console.warn('Policy ' + missPolicy + ' not supported'); 33 | this.missPolicy = DEFAULT_MISS_POLICY; 34 | } 35 | } 36 | 37 | SimpleOfflineCache.prototype.onFetch = function soc_onFetch(request, response) { 38 | // If another middleware layer already have a response, the simple cache 39 | // just pass through the response and does nothing. 40 | // @ifdef DEBUG 41 | performanceMark('soc_' + request.url); 42 | // @endif 43 | if (response) { 44 | return Promise.resolve(response); 45 | } 46 | 47 | var clone = request.clone(); 48 | var _this = this; 49 | return this.ensureCache().then(function(cache) { 50 | return cache.match(clone, _this.options).then(function(res) { 51 | // @ifdef DEBUG 52 | performanceMark('soc_cache_' + clone.url); 53 | // @endif 54 | if (res) { 55 | // @ifdef DEBUG 56 | performanceMark('soc_cache_hit_' + clone.url); 57 | // @endif 58 | return res; 59 | } 60 | 61 | // So far we just support one policy 62 | switch(_this.missPolicy) { 63 | case DEFAULT_MISS_POLICY: 64 | return fetch(request); 65 | } 66 | }); 67 | }); 68 | }; 69 | 70 | SimpleOfflineCache.prototype.ensureCache = function soc_ensureCache() { 71 | if (!this.cacheRequest) { 72 | this.cacheRequest = caches.open(this.cacheName); 73 | } 74 | return this.cacheRequest; 75 | }; 76 | 77 | // @ifdef DEBUG 78 | // Used in debugging, to save performance marks. 79 | // Remember than in Firefox we have the performance API in workers 80 | // but we don't have it in Chrome. 81 | function performanceMark(name) { 82 | if (performance && performance.mark) { 83 | performance.mark(name); 84 | } 85 | } 86 | // @endif 87 | 88 | module.exports = SimpleOfflineCache; 89 | -------------------------------------------------------------------------------- /lib/spec/globalEventsMock.js: -------------------------------------------------------------------------------- 1 | /* global Promise */ 2 | (function (global) { 3 | 'use strict'; 4 | 5 | var installed; 6 | 7 | /** 8 | * This class is no more than an event dispatcher but allowing you to 9 | * mock or intercept events in the global object. Usefull for capturing 10 | * and mocking install, activate, message, fetch... events. 11 | * 12 | * To start intercepting the global object, you must call `installGlobaly()`. 13 | * If you want to stop intercepting, call `uninstall()`. This method won't 14 | * clear the listener already installed, it only stop intercepting the global 15 | * object. To remove all listeners use `clearListeners()` method. 16 | * 17 | * @param {Array} events to intercept. 18 | */ 19 | function GlobalEventsMock(types) { 20 | this._listeners = {}; 21 | this._eventTypes = types; 22 | this.clearListeners(); 23 | } 24 | 25 | /** 26 | * Dispatch an event. If the object is installed, it calls the method 27 | * on from the global object as well. 28 | * 29 | * @param {String} the type of event to dispatch. 30 | * @param {Object} the data for the event. 31 | * @param {Bool} indicates if the object is extendable. If so, the event is 32 | * enriched with the `waitUntil()` method. 33 | * @return {Promise<>} resolved immediately or, for extendable events, when 34 | * the promise passed to `waitUntil` is resolved. 35 | */ 36 | GlobalEventsMock.prototype.emit = function (type, data, isExtendable) { 37 | data = data || {}; 38 | data.type = type; 39 | 40 | var fulfillPromise; 41 | var done = new Promise(function (fulfill) { 42 | fulfillPromise = fulfill; 43 | }); 44 | 45 | if (isExtendable) { 46 | data.waitUntil = function (promise) { 47 | return promise.then(fulfillPromise); 48 | }; 49 | } 50 | else { 51 | fulfillPromise(); 52 | } 53 | 54 | this._dispatch(this._listeners[type], data); 55 | if (installed) { 56 | var name = 'on' + type; 57 | if (typeof global[name] === 'function') { global[name](data); } 58 | } 59 | 60 | return done; 61 | }; 62 | 63 | /** 64 | * Dispatch and extendable event. Extendable events has the method 65 | * `waitUntil()`. 66 | * 67 | * @param {String} the type of event to dispatch. 68 | * @param {Object} the data for the event. 69 | * @return {Promise<>} resolved when the promise passed to `waitUntil()` is 70 | * resolved. 71 | */ 72 | GlobalEventsMock.prototype.emitExtendable = function (type, data) { 73 | return this.emit(type, data, true); 74 | }; 75 | 76 | /** 77 | * Adds a listener to a type of event. 78 | * 79 | * @param {String} the type of the event. 80 | * @param {Listener} the function or object to be the handler. 81 | */ 82 | GlobalEventsMock.prototype.on = function (type, listener) { 83 | if (this._listeners[type].indexOf(listener) < 0) { 84 | this._listeners[type].push(listener); 85 | } 86 | }; 87 | 88 | /** 89 | * Removes a handler for an event. 90 | * 91 | * @param {String} the type of the event. 92 | * @param {Listener} the handler function or object. 93 | */ 94 | GlobalEventsMock.prototype.off = function (type, listener) { 95 | var index = this._listeners[type].indexOf(listener); 96 | if (index >= 0) { this._listeners[type].splice(index, 1); } 97 | }; 98 | 99 | /** 100 | * Start intercepting dispatching on the global object by hickjacking 101 | * globals `addEventListener()`, `removeEventListener()` and `on` 102 | * properties. 103 | * 104 | * @throw {Error} if attemping to install a GlobalEventsMock instance while 105 | * other is already installed. 106 | */ 107 | GlobalEventsMock.prototype.installGlobaly = function () { 108 | if (installed === this) { return; } 109 | if (installed) { 110 | throw new Error( 111 | 'Other interceptor has been installed. Call `uninstall()` on that ' + 112 | 'interceptor first.' 113 | ); 114 | } 115 | this._saveOriginals(); 116 | this._install(); 117 | installed = this; 118 | }; 119 | 120 | /** 121 | * Stop intercepting dispatching on the global object by restoring the former 122 | * values of globals `addEventListener()`, `removeEventListener()` and 123 | * `on` properties. 124 | */ 125 | GlobalEventsMock.prototype.uninstall = function () { 126 | if (!installed || installed !== this) { return; } 127 | this._restoreOriginals(); 128 | installed = null; 129 | }; 130 | 131 | /** 132 | * Remove all listeners from all event types of for those passed as 133 | * parameters. You can pass nothing, an event type or an array of events. 134 | * 135 | * @param {String or Array} optional - event type to be clear of 136 | * listeners. 137 | */ 138 | GlobalEventsMock.prototype.clearListeners = function (evtTypes) { 139 | var _this = this; 140 | if (!Array.isArray(evtTypes)) { 141 | evtTypes = evtTypes ? [evtTypes] : _this._eventTypes; 142 | } 143 | evtTypes.forEach(function (type) { _this._listeners[type] = []; }); 144 | }; 145 | 146 | GlobalEventsMock.prototype._dispatch = function (listeners, data) { 147 | listeners.forEach(function (listener) { 148 | if (listener.handleEvent) { 149 | listener = listener.handleEvent.bind(listener); 150 | } 151 | if (typeof listener === 'function') { 152 | listener(data); 153 | } 154 | }); 155 | }; 156 | 157 | GlobalEventsMock.prototype._saveOriginals = function () { 158 | var _this = this; 159 | _this._originalContext = Object.create(null); 160 | _this._originalContext.addEventListener = global.addEventListener; 161 | _this._originalContext.removeEventListener = global.removeEventListener; 162 | 163 | // on global handlers 164 | _this._eventTypes.forEach(function (type) { 165 | var name = 'on' + type; 166 | _this._originalContext[name] = global[name]; 167 | }); 168 | }; 169 | 170 | GlobalEventsMock.prototype._install = function () { 171 | global.addEventListener = this.on.bind(this); 172 | global.removeEventListener = this.off.bind(this); 173 | 174 | // on global handlers 175 | this._eventTypes.forEach(function (type) { 176 | var name = 'on' + type; 177 | global[name] = null; 178 | }); 179 | }; 180 | 181 | GlobalEventsMock.prototype._restoreOriginals = function () { 182 | var _this = this; 183 | Object.keys(_this._originalContext).forEach(function (property) { 184 | global[property] = _this._originalContext[property]; 185 | }); 186 | }; 187 | 188 | global.GlobalEventsMock = GlobalEventsMock; 189 | }(this)); 190 | -------------------------------------------------------------------------------- /lib/spec/nodeMock.js: -------------------------------------------------------------------------------- 1 | self.module = self.module || {}; 2 | self.require = self.require || function (path) { 3 | 'use strict'; 4 | return self.require.mocks[path]; 5 | }; 6 | self.require.mocks = self.require.mocks || {}; 7 | -------------------------------------------------------------------------------- /lib/spec/router.sw-spec.js: -------------------------------------------------------------------------------- 1 | /* global Router, expect, sinon */ 2 | /* jshint -W030 */ 3 | 4 | describe('Router instances', function () { 5 | 'use strict'; 6 | 7 | var router; 8 | 9 | before(function () { 10 | importScripts('/base/lib/spec/nodeMock.js'); 11 | importScripts('/base/lib/router.js'); 12 | }); 13 | 14 | beforeEach(function() { 15 | router = new Router(); 16 | }); 17 | 18 | describe('add()', function() { 19 | var handler = function(){}; 20 | 21 | it('should push new route to the stack', function () { 22 | router.add('get', '/', handler); 23 | expect(router.stack[0]).to.deep.equal({ 24 | method: 'get', 25 | path: new RegExp('/$'), 26 | namedPlaceholders: [], 27 | handler: handler 28 | }); 29 | }); 30 | 31 | it('should accept the `all` method', function() { 32 | router.add('all', '/', handler); 33 | expect(router.stack[0].method).to.equal('all'); 34 | }); 35 | 36 | it('should sanitize the method name', function() { 37 | sinon.spy(router, '_sanitizeMethod'); 38 | router.add('get', '/', handler); 39 | expect(router._sanitizeMethod.calledWith('get')).to.be.true; 40 | }); 41 | }); 42 | 43 | describe('utility wrappers', function() { 44 | it('router.methods should contain the standard HTTP verbs and `all`', 45 | function() { 46 | expect(router.methods).to.contain('get'); 47 | expect(router.methods).to.contain('post'); 48 | expect(router.methods).to.contain('put'); 49 | expect(router.methods).to.contain('delete'); 50 | expect(router.methods).to.contain('head'); 51 | expect(router.methods).to.contain('all'); 52 | expect(router.methods.length).to.be.equal(6); 53 | }); 54 | 55 | it('should have defined methods for available verbs', function() { 56 | router.methods.forEach(function (verb) { 57 | expect(router[verb]).to.be.defined; 58 | }); 59 | }); 60 | 61 | it('should wrap add()', function() { 62 | var handler = function() {}; 63 | sinon.spy(router, 'add'); 64 | router.get('/', handler); 65 | expect(router.add.calledWith('get', '/', handler)).to.be.true; 66 | }); 67 | }); 68 | 69 | describe('proxyMethods()', function() { 70 | var object; 71 | 72 | beforeEach(function() { 73 | object = {}; 74 | }); 75 | 76 | it('should proxy the methods to an object', function () { 77 | router.proxyMethods(object); 78 | router.methods.forEach(function (verb) { 79 | expect(object[verb]).to.be.defined; 80 | }); 81 | }); 82 | 83 | describe('function as handler', function() { 84 | var handler = function() {}; 85 | 86 | it('should wrap add()', function() { 87 | sinon.spy(router, 'add'); 88 | router.proxyMethods(object); 89 | object.get('/', handler); 90 | expect(router.add.calledWith('get', '/', handler)).to.be.true; 91 | }); 92 | }); 93 | 94 | describe('Object as handler', function() { 95 | var handler = { 96 | onFetch: function() {} 97 | }; 98 | 99 | it('should wrap add()', function() { 100 | sinon.spy(router, 'add'); 101 | router.proxyMethods(object); 102 | object.get('/', handler); 103 | // We don't check the argument passed because onFetch gets bound 104 | // so the passed function is not the same 105 | expect(router.add.calledOnce).to.be.true; 106 | }); 107 | 108 | it('should call onFetch()', function() { 109 | sinon.spy(handler, 'onFetch'); 110 | router.proxyMethods(object); 111 | object.get('/', handler); 112 | router.stack[0].handler(); 113 | expect(handler.onFetch.calledOnce).to.be.true; 114 | }); 115 | 116 | it('onFetch() should bind `this` to the handler', function() { 117 | var thisInOnFetch; 118 | var handler = { 119 | onFetch: function() { 120 | thisInOnFetch = this; 121 | } 122 | }; 123 | 124 | router.proxyMethods(object); 125 | object.get('/', handler); 126 | router.stack[0].handler(); 127 | 128 | expect(thisInOnFetch).to.deep.equal(handler); 129 | }); 130 | 131 | it('should throw an error if the object does not have onFetch()', 132 | function() { 133 | var invalidHandler = {}; 134 | router.proxyMethods(object); 135 | expect(function() { object.get('/', invalidHandler); }) 136 | .to.throw(Error, 'This middleware cannot handle fetch request'); 137 | }); 138 | }); 139 | }); 140 | 141 | describe('_sanitizeMethod()', function() { 142 | it('should lowercase the method name', function () { 143 | expect(router._sanitizeMethod('GET')).to.equal('get'); 144 | }); 145 | 146 | it('should trim the spaces around', function () { 147 | expect(router._sanitizeMethod(' get ')).to.equal('get'); 148 | }); 149 | 150 | it('should throw Error when a method is not supported', function () { 151 | expect(function() { router._sanitizeMethod('invalidMethod'); }) 152 | .to.throw(Error, 'Method "invalidMethod" is not supported'); 153 | }); 154 | }); 155 | 156 | describe('Matching algorithm', function() { 157 | 158 | describe('match()', function() { 159 | var mw = function () {}; 160 | 161 | describe('`__params` attribute', function () { 162 | 163 | it('exists although no parameters are present in the url', function () { 164 | router.get('/', mw); 165 | var matched = router.match('get', '/')[0]; 166 | expect(matched.__params).to.be.an('object'); 167 | expect(Object.keys(matched.__params).length).to.equal(0); 168 | }); 169 | 170 | it('contains an attribute for each named placeholder', function () { 171 | /* jshint sub:true */ 172 | router.get('/:band/:album', mw); 173 | 174 | var matched = router.match('get', '/judas-priest/painkiller')[0]; 175 | var params = matched.__params; 176 | 177 | expect(Object.keys(params).length).to.equal(2); 178 | expect(params['band']).to.equal('judas-priest'); 179 | expect(params['album']).to.equal('painkiller'); 180 | }); 181 | 182 | it('contains names varying according to the placeholders in the routes', 183 | function () { 184 | /* jshint sub:true */ 185 | var mw2 = function () {}; 186 | router.get('/:band/:album', mw); 187 | router.get('/:collection/:id', mw2); 188 | 189 | var matches = router.match('get', '/judas-priest/painkiller'); 190 | 191 | var matched = matches[0]; 192 | var params = matched.__params; 193 | 194 | expect(Object.keys(params).length).to.equal(2); 195 | expect(params['band']).to.equal('judas-priest'); 196 | expect(params['album']).to.equal('painkiller'); 197 | 198 | matched = matches[1]; 199 | params = matched.__params; 200 | 201 | expect(Object.keys(params).length).to.equal(2); 202 | expect(params['collection']).to.equal('judas-priest'); 203 | expect(params['id']).to.equal('painkiller'); 204 | }); 205 | 206 | it('supports numeric names', function () { 207 | router.get('/:1/:0', mw); 208 | 209 | var matched = router.match('get', '/judas-priest/painkiller')[0]; 210 | var params = matched.__params; 211 | 212 | expect(Object.keys(params).length).to.equal(2); 213 | expect(params[1]).to.equal('judas-priest'); 214 | expect(params[0]).to.equal('painkiller'); 215 | }); 216 | 217 | it('ignores anonymous placeholders', function () { 218 | /* jshint sub:true */ 219 | 220 | router.get('/*/:band/:album', mw); 221 | 222 | var matched = router.match('get', '/foo/judas-priest/painkiller')[0]; 223 | var params = matched.__params; 224 | 225 | expect(Object.keys(params).length).to.equal(2); 226 | expect(params['band']).to.equal('judas-priest'); 227 | expect(params['album']).to.equal('painkiller'); 228 | }); 229 | 230 | it('ignores anonymous placeholder crowding', function () { 231 | /* jshint sub:true */ 232 | 233 | router.get('/***/:band/:album', mw); 234 | 235 | var matched = router.match('get', '/foo/judas-priest/painkiller')[0]; 236 | var params = matched.__params; 237 | 238 | expect(Object.keys(params).length).to.equal(2); 239 | expect(params['band']).to.equal('judas-priest'); 240 | expect(params['album']).to.equal('painkiller'); 241 | }); 242 | }); 243 | 244 | it('should match a single middleware', function() { 245 | router.get('/', mw); 246 | expect(router.match('get', '/')).to.deep.equal([mw]); 247 | }); 248 | 249 | it('should match multiple middlewares in the same order as they were ' + 250 | 'added', function() { 251 | var mw2 = function () {}; 252 | router.get('/', mw); 253 | router.get('/', mw2); 254 | expect(router.match('get', '/')).to.deep.equal([mw, mw2]); 255 | }); 256 | 257 | it('should match a regular expression', function() { 258 | router.get('/[0-9]+', mw); 259 | expect(router.match('get', '/1')).to.deep.equal([mw]); 260 | expect(router.match('get', '/')).to.be.empty; 261 | }); 262 | 263 | it('should sanitize the method name', function() { 264 | router.get('/', mw); 265 | sinon.spy(router, '_sanitizeMethod'); 266 | router.match('get', '/'); 267 | expect(router._sanitizeMethod.calledWith('get')).to.be.true; 268 | }); 269 | 270 | it('should return an empty array if there is no middleware attached to ' + 271 | 'this particular method', function() { 272 | router.get('/', mw); 273 | expect(router.match('post', '/')).to.be.empty; 274 | }); 275 | 276 | it('should distinguish between middlewares attached to different ' + 277 | 'methods', function() { 278 | var mw2 = function () {}; 279 | router.get('/', mw); 280 | router.post('/', mw2); 281 | expect(router.match('get', '/')).to.deep.equal([mw]); 282 | }); 283 | 284 | it('should distinguish between middlewares attached to different URL', 285 | function() { 286 | router.get('/a', mw); 287 | router.get('/b', mw); 288 | expect(router.match('get', '/a')).to.deep.equal([mw]); 289 | }); 290 | 291 | describe('`all` method', function() { 292 | it('should match every single method', function() { 293 | router.all('/', mw); 294 | expect(router.match('get', '/')).to.deep.equal([mw]); 295 | expect(router.match('post', '/')).to.deep.equal([mw]); 296 | expect(router.match('put', '/')).to.deep.equal([mw]); 297 | expect(router.match('delete', '/')).to.deep.equal([mw]); 298 | expect(router.match('head', '/')).to.deep.equal([mw]); 299 | }); 300 | 301 | it('should match `all`', function() { 302 | router.all('/', mw); 303 | expect(router.match('all', '/')).to.deep.equal([mw]); 304 | }); 305 | }); 306 | 307 | it('should match exactly with no placeholders', function() { 308 | var mw2 = function() {}; 309 | 310 | router.get('/foo/', mw); 311 | router.get('/foo/bar', mw2); 312 | 313 | expect(router.match('get', '/foo')).to.be.empty; 314 | expect(router.match('get', '/foo/')).to.deep.equal([mw]); 315 | expect(router.match('get', '/foo/bar')).to.deep.equal([mw2]); 316 | expect(router.match('get', '/foo/bar/')).to.be.empty; 317 | }); 318 | 319 | it('should match anonymous placeholders (*)', function() { 320 | router.get('*', mw); 321 | 322 | expect(router.match('get', '/')).to.deep.equal([mw]); 323 | expect(router.match('get', '/foo/bar')).to.deep.equal([mw]); 324 | }); 325 | 326 | it('should match all anonymous placeholders (*) in order', function () { 327 | var mw2 = function () {}, 328 | mw3 = function () {}; 329 | 330 | router.get('*', mw); 331 | router.get('/foo/bar', mw2); 332 | router.get('*', mw3); 333 | 334 | expect(router.match('get', '/')).to.deep.equal([mw, mw3]); 335 | expect(router.match('get', '/foo/bar')).to.deep.equal([mw, mw2, mw3]); 336 | }); 337 | 338 | it('trailing placeholders (/foo/* etc.) should match arbitrary prefixes', 339 | function() { 340 | var mw2 = function () {}; 341 | 342 | router.get('/foo/*', mw); 343 | router.get('/foo/bar/*', mw2); 344 | 345 | expect(router.match('get', '/')).to.be.empty; 346 | expect(router.match('get', '/foo')).to.be.empty; 347 | expect(router.match('get', '/foo/')).to.deep.equal([mw]); 348 | expect(router.match('get', '/foo/bar')).to.deep.equal([mw]); 349 | expect(router.match('get', '/foo/bar/baz')).to.deep.equal([mw, mw2]); 350 | }); 351 | 352 | it('infix placeholders (/foo/*/bar etc.) should match arbitrary path ' + 353 | 'chunks', function() { 354 | var mw2 = function () {}; 355 | 356 | router.get('/foo*bar', mw); 357 | router.get('/foo/*/bar', mw2); 358 | 359 | expect(router.match('get', '/')).to.be.empty; 360 | expect(router.match('get', '/foo')).to.be.empty; 361 | expect(router.match('get', '/foo/bar')).to.deep.equal([mw]); 362 | expect(router.match('get', '/foo/doh/bar')).to.deep.equal([mw, mw2]); 363 | }); 364 | 365 | it('multiple placeholders (/foo/*/bar/* etc) should be allowed', 366 | function() { 367 | var mw2 = function () {}, 368 | mw3 = function () {}; 369 | 370 | router.get('/pre/*/fix/*', mw); 371 | router.get('*/suf/*/fix', mw2); 372 | router.get('*inner*', mw3); 373 | 374 | expect(router.match('get', '/')).to.be.empty; 375 | expect(router.match('get', '/pre/123/fix/')).to.deep.equal([mw]); 376 | expect(router.match('get', '/pre/123/fix/456')).to.deep.equal([mw]); 377 | expect(router.match('get', '/pre/123/')).to.be.empty; 378 | 379 | expect(router.match('get', '/matched/suf/123/fix')) 380 | .to.deep.equal([mw2]); 381 | expect(router.match('get', '/any/other/suf/123/456/fix')) 382 | .to.deep.equal([mw2]); 383 | expect(router.match('get', '/suf/nopre/fix')).to.deep.equal([mw2]); 384 | expect(router.match('get', '/fix')).to.be.empty; 385 | 386 | expect(router.match('get', '/pre/suf/fix/')).to.deep.equal([mw]); 387 | 388 | expect(router.match('get', '/inner')).to.deep.equal([mw3]); 389 | expect(router.match('get', '/beginners')).to.deep.equal([mw3]); 390 | expect(router.match('get', '/pre/inner/fix/')).to.deep.equal([mw, mw3]); 391 | expect(router.match('get', '/inner/suf/other/fix')) 392 | .to.deep.equal([mw2, mw3]); 393 | expect(router.match('get', '/inner/suf/other/fix/')) 394 | .to.deep.equal([mw3]); 395 | 396 | expect(router.match('get', '/pre/123/fix/inner/suf/456/fix/')) 397 | .to.deep.equal([mw, mw3]); 398 | expect(router.match('get', '/pre/123/fix/inner/suf/456/fix')) 399 | .to.deep.equal([mw, mw2, mw3]); 400 | expect(router.match('get', '/pre/suf/inner/fix/')) 401 | .to.deep.equal([mw, mw3]); 402 | }); 403 | 404 | it('named placeholders (:foo) must match at least one character ' + 405 | '(no empty matches allowed)', function() { 406 | router.get('/:foo', mw); 407 | expect(router.match('get', '/')).to.be.empty; 408 | expect(router.match('get', '/foo')).to.deep.equal([mw]); 409 | expect(router.match('get', '/foo/bar')).to.deep.equal([mw]); 410 | }); 411 | 412 | it('placeholder crowding for anonymous placeholder (**) should be ' + 413 | 'treated same as a single placeholder', function() { 414 | var mw2 = function () {}; 415 | 416 | expect(function() { router.get('/f**bar', mw); }).to.not.throw(); 417 | expect(function() { router.get('/foo/bar/***', mw); }).to.not.throw(); 418 | router.get('/f*bar', mw2); 419 | 420 | expect(router.match('get', '/foo')) 421 | .to.deep.equal(router.match('get', '/foo')); 422 | expect(router.match('get', '/foobar')) 423 | .to.deep.equal(router.match('get', '/foobar')); 424 | expect(router.match('get', '/foobabarbar')) 425 | .to.deep.equal(router.match('get', '/foobabarbar')); 426 | expect(router.match('get', '/foo/bar/baz')) 427 | .to.deep.equal(router.match('get', '/foo/bar/baz')); 428 | expect(router.match('get', '/foo/doh/bar')) 429 | .to.deep.equal(router.match('get', '/foo/doh/bar')); 430 | }); 431 | 432 | it('placeholder crowding for named placeholder (:foo:bar) should throw', 433 | function() { 434 | expect(function () { router.get('/foo/:bar:baz', mw); }) 435 | .to.throw(Error, 'Invalid usage of named placeholders'); 436 | expect(function () { router.get('/foo/:bar:baz:shoo', mw); }) 437 | .to.throw(Error, 'Invalid usage of named placeholders'); 438 | }); 439 | 440 | it('mixed crowdings (*:foo, :bar** etc.) should throw', function() { 441 | expect(function () { router.get('/foo/*:bar', mw); }) 442 | .to.throw(Error, 'Invalid usage of named placeholders'); 443 | expect(function () { router.get('/:foo**/:bar', mw); }) 444 | .to.throw(Error, 'Invalid usage of named placeholders'); 445 | expect(function () { router.get('/foo/\\*:bar', mw); }) 446 | .to.not.throw(); 447 | }); 448 | }); 449 | 450 | describe('_parseSimplePath()', function() { 451 | it('on invalid path specification it should throw an error', function() { 452 | expect(function () { router._parseSimplePath(']['); }) 453 | .to.throw(Error, 'Invalid path specified'); 454 | }); 455 | 456 | it('should support anonymous placeholders (*)', function() { 457 | expect(router._parseSimplePath('/a*b').regexp.source) 458 | .to.equal('\\/a(?:.*?)b$'); 459 | }); 460 | 461 | it('should collapse anonymous placeholder crowding (**)', function() { 462 | expect(router._parseSimplePath('/a**b').regexp.source) 463 | .to.equal('\\/a(?:.*?)b$'); 464 | }); 465 | 466 | it('should support named placeholders (:foo)', function() { 467 | expect(router._parseSimplePath('/a/:foo/b').regexp.source) 468 | .to.equal('\\/a\\/(.+?)\\/b$'); 469 | }); 470 | 471 | it('should support multiple/mixed placeholders (*, :foo)', function() { 472 | expect(router._parseSimplePath('/a/*/*/b').regexp.source) 473 | .to.equal('\\/a\\/(?:.*?)\\/(?:.*?)\\/b$'); 474 | expect(router._parseSimplePath('/a/:foo/:bar/b').regexp.source) 475 | .to.equal('\\/a\\/(.+?)\\/(.+?)\\/b$'); 476 | expect(router._parseSimplePath('/a/*/:foo/b').regexp.source) 477 | .to.equal('\\/a\\/(?:.*?)\\/(.+?)\\/b$'); 478 | }); 479 | 480 | it('should support escaping of placeholder special characters (* and :)', 481 | function() { 482 | expect(router._parseSimplePath('/a\\*/*/b').regexp.source) 483 | .to.equal('\\/a\\*\\/(?:.*?)\\/b$'); 484 | expect(router._parseSimplePath('/a/:foo\\:80/b').regexp.source) 485 | .to.equal('\\/a\\/(.+?):80\\/b$'); 486 | }); 487 | }); 488 | 489 | }); 490 | 491 | }); 492 | -------------------------------------------------------------------------------- /lib/spec/routerMock.js: -------------------------------------------------------------------------------- 1 | 2 | function RouterMock() {} 3 | 4 | RouterMock.prototype.proxyMethods = function () {}; 5 | RouterMock.prototype.match = function () { 6 | 'use strict'; 7 | return []; 8 | }; 9 | -------------------------------------------------------------------------------- /lib/spec/simpleofflinecache.sw-spec.js: -------------------------------------------------------------------------------- 1 | /* global caches, Response, Promise, SimpleOfflineCache, expect, sinon, 2 | Request */ 3 | /* jshint -W030 */ 4 | importScripts('/base/lib/spec/nodeMock.js'); 5 | importScripts('/base/lib/simpleofflinecache.js'); 6 | 7 | describe('SimpleOfflineCache instance', function () { 8 | 'use strict'; 9 | 10 | beforeEach(function (done) { 11 | caches.keys().then(function (names) { 12 | Promise.all(names.map(caches.delete.bind(caches))) 13 | .then(function () { done(); }) 14 | .catch(function (reason) { done(new Error(reason)); }); 15 | }); 16 | }); 17 | 18 | it('should ensure the cache we are requesting (creating if needed)', 19 | function (done) { 20 | var offline = new SimpleOfflineCache('test'); 21 | 22 | offline.ensureCache() 23 | .then(caches.keys.bind(caches)) 24 | .then(function (caches) { 25 | expect(caches).to.deep.equal(['test']); 26 | done(); 27 | }) 28 | .catch(function (reason) { 29 | done(new Error(reason)); 30 | }); 31 | }); 32 | 33 | it('should answer with a stored copy of a resource', function (done) { 34 | var offline = new SimpleOfflineCache('test'); 35 | var request = new Request('/'); 36 | var response = new Response('contents'); 37 | caches.open('test') 38 | .then(function (db) { 39 | return db.put(request.clone(), response.clone()); 40 | }) 41 | .then(function () { 42 | return offline.onFetch(request.clone(), null); 43 | }) 44 | .then(function (response) { 45 | expect(response).to.be.ok; 46 | return response.text(); 47 | }) 48 | .then(function (body) { 49 | expect(body).to.equal('contents'); 50 | done(); 51 | }) 52 | .catch(function (reason) { 53 | done(new Error(reason)); 54 | }); 55 | }); 56 | 57 | it('should pass through if a response is already provided', function (done) { 58 | var offline = new SimpleOfflineCache('test'); 59 | var request = new Request('/'); 60 | var response = new Response('contents'); 61 | caches.open('test') 62 | .then(function (db) { 63 | return db.put(request.clone(), response.clone()); 64 | }) 65 | .then(function () { 66 | return offline.onFetch(request.clone(), response); 67 | }) 68 | .then(function (returned) { 69 | expect(returned).to.equal(response); 70 | done(); 71 | }) 72 | .catch(function (reason) { 73 | done(new Error(reason)); 74 | }); 75 | }); 76 | 77 | it('should cache the promise for requested cache', function(done) { 78 | var offline = new SimpleOfflineCache('cachedCache'); 79 | var cacheObj = { 80 | match: sinon.stub().returns(Promise.resolve({})) 81 | }; 82 | sinon.stub(caches, 'open').returns(Promise.resolve(cacheObj)); 83 | offline.onFetch(new Request('/'), null).then(function() { 84 | offline.onFetch(new Request('/'), null).then(function() { 85 | expect(caches.open.calledOnce).to.be.true; 86 | caches.open.restore(); 87 | done(); 88 | }); 89 | }).catch(function(reason) { 90 | caches.open.restore(); 91 | done(new Error(reason)); 92 | }); 93 | }); 94 | 95 | it('should fetch from network if the resource is not available', 96 | function (done) { 97 | var offline = new SimpleOfflineCache('test'); 98 | var request = new Request('/'); 99 | sinon.spy(self, 'fetch'); 100 | offline.onFetch(request.clone(), null) 101 | .then(function () { 102 | expect(fetch.calledOnce).to.be.true; 103 | expect(fetch.getCall(0).args[0].url).to.equal(request.url); 104 | fetch.restore(); 105 | done(); 106 | }) 107 | .catch(function (reason) { 108 | fetch.restore(); 109 | done(new Error(reason)); 110 | }); 111 | }); 112 | 113 | it('should pass match parameters', function(done) { 114 | var cacheName = 'test'; 115 | var options = { 116 | ignoreSearch: true, 117 | ignoreMethod: false, 118 | ignoreVary: false, 119 | prefixMatch: false 120 | }; 121 | var offline = new SimpleOfflineCache(cacheName, options); 122 | 123 | var request = new Request('/index.html?param=zzz'); 124 | 125 | var cacheObj = { 126 | match: sinon.stub().returns(Promise.resolve({})) 127 | }; 128 | // Clean the cached request 129 | delete offline.cacheRequest; 130 | sinon.stub(caches, 'open').returns( 131 | Promise.resolve(cacheObj)); 132 | offline.onFetch(request).then(function() { 133 | expect(cacheObj.match.called).to.be.true; 134 | expect(cacheObj.match.calledWith(request, options)).to.be.true; 135 | caches.open.restore(); 136 | done(); 137 | }) 138 | .catch(function(err) { 139 | caches.open.restore(); 140 | done(new Error(err)); 141 | }); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /lib/spec/staticcacher.sw-spec.js: -------------------------------------------------------------------------------- 1 | /* global expect, sinon, StaticCacher, Promise, Response, Request, caches */ 2 | /*jshint -W030 */ 3 | 4 | describe('Static cacher', function() { 5 | 'use strict'; 6 | 7 | var defaultCacheName = 'offline'; 8 | var requestMap = { 9 | 'http://www.example.com/': 'response1', 10 | 'http://www.mozilla.org/': 'response2', 11 | }; 12 | var defaultUrls = Object.keys(requestMap); 13 | var defaultStaticCacher; 14 | 15 | before(function() { 16 | importScripts('/base/lib/staticcacher.js'); 17 | defaultStaticCacher = new StaticCacher(defaultUrls); 18 | }); 19 | 20 | beforeEach(function() { 21 | sinon.stub(self, 'fetch', function(request) { 22 | var response = new Response(requestMap[request.url], {}); 23 | return Promise.resolve(response); 24 | }); 25 | }); 26 | 27 | afterEach(function() { 28 | self.fetch.restore(); 29 | return clearDefaultCache(); 30 | }); 31 | 32 | function clearDefaultCache() { 33 | return caches.open(defaultCacheName).then(function(cache) { 34 | return cache.keys().then(function(response) { 35 | response.forEach(function(element) { 36 | cache.delete(element); 37 | }); 38 | }); 39 | }); 40 | } 41 | 42 | function expectTextInOfflineCache(request, expectedText) { 43 | return caches.open(defaultCacheName).then(function(cache) { 44 | return cache.match(request); 45 | }).then(function(response) { 46 | return response.text(); 47 | }).then(function(text) { 48 | expect(text).to.equal(expectedText); 49 | }); 50 | } 51 | 52 | 53 | describe('The constructor', function() { 54 | 55 | it('should accept an array of files', function() { 56 | expect(function() { 57 | new StaticCacher(['file1', 'file2']); 58 | }).to.not.throw(Error); 59 | }); 60 | 61 | it('should throw an error if no argument is provided', function() { 62 | expect(function() { 63 | new StaticCacher(); 64 | }).to.throw(Error, 'Invalid file list'); 65 | }); 66 | 67 | it('should throw an error if the argument is not an array', function() { 68 | expect(function() { 69 | new StaticCacher('invalidString'); 70 | }).to.throw(Error, 'Invalid file list'); 71 | }); 72 | 73 | it('should throw if the array is empty', function() { 74 | expect(function() { 75 | new StaticCacher([]); 76 | }).to.throw(Error, 'Invalid file list'); 77 | }); 78 | }); 79 | 80 | describe('onInstall()', function() { 81 | 82 | it('should return a promise', function() { 83 | expect(defaultStaticCacher.onInstall()).to.be.an.instanceOf(Promise); 84 | }); 85 | 86 | it('should store responses in the default cache', function() { 87 | sinon.spy(defaultStaticCacher, 'getDefaultCache'); 88 | 89 | return defaultStaticCacher.onInstall().then(function() { 90 | var promises = defaultUrls.map(function(url) { 91 | return expectTextInOfflineCache(new Request(url), requestMap[url]); 92 | }); 93 | return Promise.all(promises); 94 | }); 95 | }); 96 | }); 97 | 98 | describe('getDefaultCache()', function() { 99 | it('should return the cache named "offline"', function() { 100 | var cacheGot; 101 | 102 | return defaultStaticCacher.getDefaultCache().then(function(cache) { 103 | cacheGot = cache; 104 | return caches.open(defaultCacheName); 105 | }).then(function(expectedCache) { 106 | expect(cacheGot).to.equal(expectedCache); 107 | }); 108 | }); 109 | }); 110 | 111 | describe('addAll()', function() { 112 | afterEach(function() { 113 | defaultStaticCacher.fetchAndCache.restore(); 114 | }); 115 | 116 | it('should work as polyfill', function() { 117 | sinon.stub(defaultStaticCacher, 'fetchAndCache', 118 | function(request, cache) { 119 | var response = new Response(requestMap[request.url], { 120 | status: 200 121 | }); 122 | return cache.put(request.clone(), response.clone()); 123 | }); 124 | 125 | var defaultCache; 126 | 127 | return defaultStaticCacher.getDefaultCache().then(function(cache) { 128 | defaultCache = cache; 129 | 130 | cache.addAll = undefined; 131 | return defaultStaticCacher.addAll(cache, defaultUrls); 132 | }).then(function() { 133 | var promises = defaultUrls.map(function(url) { 134 | return expectTextInOfflineCache(new Request(url), requestMap[url]); 135 | }); 136 | return Promise.all(promises); 137 | }); 138 | }); 139 | }); 140 | 141 | describe('fetchAndCache()', function() { 142 | var content = 'my content'; 143 | 144 | beforeEach(function() { 145 | self.fetch.restore(); 146 | }); 147 | 148 | it('should fetch and cache url a valid resource', function() { 149 | sinon.stub(self, 'fetch', function() { 150 | var response = new Response(content, {}); 151 | return Promise.resolve(response); 152 | }); 153 | 154 | var defaultCache; 155 | 156 | return defaultStaticCacher.getDefaultCache().then(function(cache) { 157 | defaultCache = cache; 158 | }).then(function() { 159 | return defaultStaticCacher 160 | .fetchAndCache(new Request(defaultUrls[0]), defaultCache); 161 | }).then(function() { 162 | return defaultCache.match(new Request(defaultUrls[0])); 163 | }).then(function(response) { 164 | return response.text(); 165 | }).then(function(text) { 166 | expect(text).to.be.equal(content); 167 | }); 168 | }); 169 | 170 | it('should not cache invalid resource', function() { 171 | sinon.stub(self, 'fetch', function() { 172 | var response = new Response('invalid content', { 173 | status: 401 174 | }); 175 | return Promise.resolve(response); 176 | }); 177 | 178 | var defaultCache; 179 | 180 | return defaultStaticCacher.getDefaultCache().then(function(cache) { 181 | defaultCache = cache; 182 | return defaultStaticCacher 183 | .fetchAndCache(new Request(defaultUrls[0]), defaultCache); 184 | }).then(function() { 185 | sinon.assert.calledOnce(self.fetch); 186 | return defaultCache.match(new Request(defaultUrls[0])); 187 | }).then(function(response) { 188 | expect(response).to.be.undefined; 189 | }); 190 | }); 191 | }); 192 | }); 193 | -------------------------------------------------------------------------------- /lib/spec/sww.sw-spec.js: -------------------------------------------------------------------------------- 1 | /* global RouterMock, Request, Response, expect, sinon, Promise, 2 | ServiceWorkerWare, Router, GlobalEventsMock */ 3 | /* jshint -W030 */ 4 | 5 | importScripts('/base/lib/spec/nodeMock.js'); 6 | importScripts('/base/lib/spec/globalEventsMock.js'); 7 | 8 | describe('ServiceWorkerWare', function () { 9 | 'use strict'; 10 | 11 | var workerEventInterceptor = new GlobalEventsMock([ 12 | 'install', 13 | 'activate', 14 | 'fetch', 15 | 'beforeevicted', 16 | 'evicted', 17 | 'message' 18 | ]); 19 | 20 | var originalRouter; 21 | 22 | before(function () { 23 | // XXX: See after() inside the suite. We need to save the global context. 24 | // Working on a better solution. 25 | originalRouter = Router; 26 | 27 | importScripts('/base/lib/spec/routerMock.js'); 28 | self.require.mocks['./router.js'] = RouterMock; 29 | importScripts('/base/lib/sww.js'); 30 | }); 31 | 32 | after(function () { 33 | // XXX: See before(). This part restores the global context. 34 | self.Router = originalRouter; 35 | }); 36 | 37 | var fallbackResult = new Response('fallbackMW'); 38 | var fallbackMW = function () { 39 | return fallbackResult; 40 | }; 41 | 42 | var worker; 43 | var request; 44 | 45 | /** 46 | * This is an empty array at the beginning of each test. Push middlerwares 47 | * inside to force the router mock to return the list of middlewares you 48 | * define. 49 | */ 50 | var middleware; 51 | 52 | beforeEach(function() { 53 | worker = new ServiceWorkerWare(); 54 | request = { 55 | method: 'GET', 56 | url: 'https://www.example.com', 57 | clone: function() {} 58 | }; 59 | middleware = []; 60 | sinon.stub(RouterMock.prototype, 'match').returns(middleware); 61 | }); 62 | 63 | afterEach(function () { 64 | RouterMock.prototype.match.restore(); 65 | if (self.fetch && typeof self.fetch.restore === 'function') { 66 | self.fetch.restore(); 67 | } 68 | }); 69 | 70 | describe('autoClaim', function () { 71 | 72 | function dispatchActivate() { 73 | return workerEventInterceptor.emitExtendable('activate'); 74 | } 75 | 76 | beforeEach(function () { 77 | workerEventInterceptor.clearListeners(); 78 | workerEventInterceptor.installGlobaly(); 79 | sinon.spy(self.clients, 'claim'); 80 | }); 81 | 82 | afterEach(function () { 83 | workerEventInterceptor.uninstall(); 84 | self.clients.claim.restore(); 85 | }); 86 | 87 | it('should be called upon `activate` event by default', function () { 88 | worker.init(); 89 | return dispatchActivate().then(function () { 90 | expect(self.clients.claim.calledOnce).to.be.true; 91 | }); 92 | }); 93 | 94 | it('should be called upon `activate` event if passed explicitly', 95 | function () { 96 | var worker = new ServiceWorkerWare({ autoClaim: true }); 97 | worker.init(); 98 | return dispatchActivate().then(function () { 99 | expect(self.clients.claim.calledOnce).to.be.true; 100 | }); 101 | }); 102 | 103 | it('if disabled, it should not be called', function () { 104 | var worker = new ServiceWorkerWare({ autoClaim: false }); 105 | worker.init(); 106 | return dispatchActivate().then(function () { 107 | expect(self.clients.claim.callCount).to.equal(0); 108 | }); 109 | }); 110 | 111 | it('must be performed after middleware activation', function () { 112 | var mw = { onActivate: sinon.spy() }; 113 | worker.use(mw); 114 | worker.init(); 115 | return dispatchActivate().then(function () { 116 | expect(self.clients.claim.calledAfter(mw.onActivate)).to.be.ok; 117 | }); 118 | }); 119 | 120 | }); 121 | 122 | describe('fallback mw', function () { 123 | 124 | it('should fetch if no fallback middleware', function (done) { 125 | var result = new Response('ok'); 126 | var stub = sinon.stub(self, 'fetch').returns(Promise.resolve(result)); 127 | 128 | worker.onFetch({ 129 | request: request, 130 | respondWith: function(chain) { 131 | return chain.then(function(response) { 132 | expect(self.fetch.called).to.be.true; 133 | expect(response).to.equal(result); 134 | stub.restore(); 135 | done(); 136 | }, function(err) { 137 | stub.restore(); 138 | done(new Error(err)); 139 | }).catch(function(err) { 140 | stub.restore(); 141 | done(new Error(err)); 142 | }); 143 | } 144 | }); 145 | }); 146 | 147 | it('should use fallback middleware if defined', function(done) { 148 | worker = new ServiceWorkerWare(fallbackMW); 149 | 150 | var spy = sinon.spy(self, 'fetch'); 151 | 152 | worker.onFetch({ 153 | request: request, 154 | respondWith: function(result) { 155 | result.then(function(res) { 156 | expect(spy.called).to.be.false; 157 | expect(res).to.equal(fallbackResult); 158 | spy.restore(); 159 | done(); 160 | }).catch(function(err) { 161 | spy.restore(); 162 | done(new Error(err)); 163 | }); 164 | } 165 | }); 166 | }); 167 | 168 | it('should not use fallback mw if another mw replied', function(done) { 169 | worker = new ServiceWorkerWare(); 170 | var result = new Response('always'); 171 | var mw = function () { return Promise.resolve(result); }; 172 | middleware.push(mw); 173 | 174 | var spy = sinon.spy(self, 'fetch'); 175 | 176 | worker.onFetch({ 177 | request: request, 178 | respondWith: function(result) { 179 | result.then(function(response) { 180 | expect(self.fetch.called).to.be.false; 181 | expect(response).to.equal(response); 182 | spy.restore(); 183 | done(); 184 | }).catch(function(err) { 185 | spy.restore(); 186 | done(new Error(err)); 187 | }); 188 | } 189 | }); 190 | }); 191 | 192 | it('should use fallback mw if another mw didnt replied', function(done) { 193 | worker = new ServiceWorkerWare(); 194 | var mw = function () { 195 | return Promise.resolve(null); 196 | }; 197 | middleware.push(mw); 198 | 199 | var networkResponse = new Response('from_network'); 200 | var stub = sinon.stub(self, 'fetch', function() { 201 | return Promise.resolve(networkResponse); 202 | }); 203 | 204 | worker.onFetch({ 205 | request: request, 206 | respondWith: function(result) { 207 | result.then(function(response) { 208 | expect(stub.called).to.be.true; 209 | expect(response).to.equal(networkResponse); 210 | stub.restore(); 211 | done(); 212 | }).catch(function(err) { 213 | stub.restore(); 214 | done(new Error(err)); 215 | }); 216 | } 217 | }); 218 | }); 219 | }); 220 | 221 | describe('middleware running inside onFetch()', function() { 222 | 223 | var evt; 224 | var initialRequest = new Request('http://example.com'); 225 | var initialResponse = new Response('contents'); 226 | var initialMw, spyMw; 227 | 228 | beforeEach(function () { 229 | evt = { 230 | request: new Request('http://example.com'), 231 | respondWith: function (promise) { 232 | if (!promise || !promise.then) { promise = Promise.resolve(promise); } 233 | this.onceFinished = promise; 234 | } 235 | }; 236 | 237 | initialMw = function () { 238 | return Promise.resolve([initialRequest, initialResponse]); 239 | }; 240 | spyMw = sinon.stub().returns(Promise.resolve(new Response(''))); 241 | 242 | worker.onerror = sinon.spy(); 243 | }); 244 | 245 | /** 246 | * A convenient wrapper to write the same test twice, one for testing 247 | * middlerwares returning plain values and another for those returning 248 | * promises. The test implementation * 249 | * 250 | * @param {string} the message for the test. 251 | * 252 | * @param {any} the value to be tested against. Once it will be passed 253 | * as is. The other it will be wrapped in a promise. 254 | * 255 | * @param {function} the test implementation. It should accept two 256 | * parameters, first is the value to be returned by the middleware, the 257 | * other is the original value sent to the wrapper. 258 | * 259 | * One of the calls to the test implementation will receive the value 260 | * wrapped inside a promise. The other time, it will receive the plain 261 | * value. 262 | */ 263 | function promiseOptional(msg, returnValue, test) { 264 | var originalValue = returnValue; 265 | 266 | it(msg, function () { 267 | returnValue = Promise.resolve(originalValue); 268 | return test(returnValue, originalValue); 269 | }); 270 | 271 | var noPromiseMsg = msg + ' (no need for promise)'; 272 | it(noPromiseMsg, function () { 273 | return test(returnValue, originalValue); 274 | }); 275 | } 276 | 277 | describe('failing causes', function () { 278 | 279 | beforeEach(function () { 280 | sinon.spy(console, 'error'); 281 | }); 282 | 283 | afterEach(function () { 284 | console.error.restore(); 285 | }); 286 | 287 | var invalidReturnValueTests = []; 288 | 289 | // XXX: Format: [message, returnValue] 290 | invalidReturnValueTests.push([ 291 | 'fails with error if returning something other than a Request / ' + 292 | 'Response or a pair of [Request, Response]', 293 | 'other thing' 294 | ]); 295 | 296 | invalidReturnValueTests.push([ 297 | 'fails in case of returning undefined', 298 | undefined 299 | ]); 300 | 301 | invalidReturnValueTests.push([ 302 | 'fails in case of returning an array which is not a pair', 303 | [] 304 | ]); 305 | 306 | invalidReturnValueTests.forEach(function (spec) { 307 | var message = spec[0]; 308 | var returnValue = spec[1]; 309 | 310 | promiseOptional( 311 | message, 312 | returnValue, 313 | function (returnValue) { 314 | var targetMw = function () { return returnValue; }; 315 | 316 | middleware.push(initialMw, targetMw, spyMw); 317 | 318 | worker.onFetch(evt); 319 | 320 | return evt.onceFinished.then(function () { 321 | throw new Error( 322 | 'onFetch() mustn\'t answer with a fulfilled promise.' 323 | ); 324 | }, function (error) { 325 | expect(error).to.be.an.instanceof(Error); 326 | var errorParameter = console.error.getCall(0).args[0]; 327 | expect(errorParameter).to.equal(error); 328 | }); 329 | } 330 | ); 331 | }); 332 | }); 333 | 334 | it('fail in case of the middleware not returning', function () { 335 | var targetMw = function () { }; 336 | 337 | middleware.push(targetMw, spyMw); 338 | 339 | worker.onFetch(evt); 340 | 341 | return evt.onceFinished.then(function () { 342 | throw new Error('onFetch() mustn\'t answer with a fulfilled promise.'); 343 | }, function (error) { 344 | expect(error).to.be.an.instanceof(Error); 345 | }); 346 | }); 347 | 348 | it('fail in case of the middleware returning a Promise resolving to ' + 349 | 'nothing', function () { 350 | var targetMw = function () { return Promise.resolve(); }; 351 | 352 | middleware.push(targetMw, spyMw); 353 | 354 | worker.onFetch(evt); 355 | 356 | return evt.onceFinished.then(function () { 357 | throw new Error('onFetch() mustn\'t answer with a fulfilled promise.'); 358 | }, function (error) { 359 | expect(error).to.be.an.instanceof(Error); 360 | }); 361 | }); 362 | 363 | promiseOptional( 364 | 'can pass new values for request and response explicitly', 365 | [initialRequest, initialResponse], 366 | function (returnValue) { 367 | var targetMw = function () { return returnValue; }; 368 | 369 | middleware.push(targetMw, spyMw); 370 | 371 | worker.onFetch(evt); 372 | 373 | return evt.onceFinished.then(function () { 374 | expect(spyMw.calledOnce).to.be.true; 375 | expect(spyMw.calledWith(initialRequest, initialResponse)).to.be.true; 376 | }); 377 | } 378 | ); 379 | 380 | promiseOptional( 381 | 'can pass a new value only for request', 382 | new Request('http://mozilla.org'), 383 | function (returnValue, newRequest) { 384 | var targetMw = function () { 385 | return returnValue; 386 | }; 387 | 388 | middleware.push(initialMw, targetMw, spyMw); 389 | 390 | worker.onFetch(evt); 391 | 392 | return evt.onceFinished.then(function () { 393 | expect(spyMw.calledOnce).to.be.true; 394 | expect(spyMw.calledWith(newRequest, initialResponse)).to.be.true; 395 | }); 396 | } 397 | ); 398 | 399 | promiseOptional( 400 | 'can pass a new value only for response', 401 | new Response('more contents'), 402 | function (returnValue, newResponse) { 403 | var targetMw = function () { 404 | return returnValue; 405 | }; 406 | 407 | middleware.push(initialMw, targetMw, spyMw); 408 | 409 | worker.onFetch(evt); 410 | 411 | return evt.onceFinished.then(function () { 412 | expect(spyMw.calledOnce).to.be.true; 413 | expect(spyMw.calledWith(initialRequest, newResponse)).to.be.true; 414 | }); 415 | } 416 | ); 417 | 418 | promiseOptional( 419 | 'can nullify the response by returning null', 420 | null, 421 | function (returnValue, nullValue) { 422 | var targetMw = function () { 423 | return returnValue; 424 | }; 425 | 426 | middleware.push(initialMw, targetMw, spyMw); 427 | 428 | worker.onFetch(evt); 429 | 430 | return evt.onceFinished.then(function () { 431 | expect(spyMw.calledOnce).to.be.true; 432 | expect(spyMw.calledWith(initialRequest, nullValue)).to.be.true; 433 | }); 434 | } 435 | ); 436 | 437 | describe('endWith() callback', function () { 438 | 439 | it('ends the chain of middlewares abruptly', function () { 440 | var finalResponse = new Response('final'); 441 | var targetMw = function (req, res, endWith) { 442 | return endWith(finalResponse); 443 | }; 444 | 445 | middleware.push(initialMw, targetMw, spyMw); 446 | 447 | worker.onFetch(evt); 448 | 449 | return evt.onceFinished.then(function (responseAnswered) { 450 | expect(spyMw.called).to.be.false; 451 | expect(responseAnswered).to.equal(finalResponse); 452 | }); 453 | }); 454 | 455 | it('fails if called with no arguments', function () { 456 | var targetMw = function (req, res, endWith) { 457 | return endWith(); 458 | }; 459 | 460 | middleware.push(initialMw, targetMw, spyMw); 461 | 462 | worker.onFetch(evt); 463 | 464 | return evt.onceFinished.then(function () { 465 | throw new Error( 466 | 'onFetch() mustn\'t answer with a fulfilled promise.' 467 | ); 468 | }, function (error) { 469 | expect(error).to.be.an.instanceof(Error); 470 | expect(error.message).to.match(/endWith/); 471 | }); 472 | }); 473 | }); 474 | 475 | describe('middleware decorators', function () { 476 | it('stopAfter() makes the chain to be interrupted after the middleware', 477 | function () { 478 | var decorators = ServiceWorkerWare.decorators || {}; 479 | expect(decorators.stopAfter).to.exist; 480 | 481 | var stopAfter = decorators.stopAfter; 482 | var targetMw = stopAfter(function (req, res) { 483 | return [req, res]; 484 | }); 485 | middleware.push(initialMw, targetMw, spyMw); 486 | 487 | worker.onFetch(evt); 488 | 489 | return evt.onceFinished.then(function (responseAnswered) { 490 | expect(spyMw.called).to.be.false; 491 | expect(responseAnswered).to.equal(initialResponse); 492 | }); 493 | }); 494 | 495 | describe('ifNoResponse() makes the mw to work only if no response', 496 | function () { 497 | 498 | var decorators, ifNoResponse; 499 | 500 | before(function () { 501 | decorators = ServiceWorkerWare.decorators || {}; 502 | ifNoResponse = decorators.ifNoResponse; 503 | }); 504 | 505 | it('exists', function () { 506 | expect(ifNoResponse).to.exist; 507 | }); 508 | 509 | it('prevents execution there is already a response', function () { 510 | var targetMw = ifNoResponse(function () { 511 | return Promise.resolve([null, null]); 512 | }); 513 | middleware.push(initialMw, targetMw, spyMw); 514 | 515 | worker.onFetch(evt); 516 | 517 | return evt.onceFinished.then(function () { 518 | expect(spyMw.calledOnce).to.be.true; 519 | expect(spyMw.calledWith(initialRequest, initialResponse)) 520 | .to.be.true; 521 | }); 522 | }); 523 | 524 | it('allows execution if there is no response', function () { 525 | var nullMw = function () { return null; }; 526 | var targetMw = ifNoResponse(function () { 527 | return Promise.resolve([null, null]); 528 | }); 529 | middleware.push(nullMw, targetMw, spyMw); 530 | 531 | worker.onFetch(evt); 532 | 533 | return evt.onceFinished.then(function () { 534 | expect(spyMw.calledOnce).to.be.true; 535 | expect(spyMw.calledWith(null, null)).to.be.true; 536 | }); 537 | }); 538 | }); 539 | }); 540 | }); 541 | 542 | }); 543 | -------------------------------------------------------------------------------- /lib/staticcacher.js: -------------------------------------------------------------------------------- 1 | /* globals caches, Promise, Request */ 2 | 'use strict'; 3 | 4 | function StaticCacher(fileList) { 5 | if (!Array.isArray(fileList) || fileList.length === 0) { 6 | throw new Error('Invalid file list'); 7 | } 8 | this.files = fileList; 9 | } 10 | 11 | StaticCacher.prototype.onInstall = function sc_onInstall() { 12 | var self = this; 13 | return this.getDefaultCache().then(function(cache) { 14 | return self.addAll(cache, self.files); 15 | }); 16 | }; 17 | 18 | StaticCacher.prototype.getDefaultCache = function sc_getDefaultCache() { 19 | if (!this.cacheRequest) { 20 | this.cacheRequest = caches.open('offline'); 21 | } 22 | return this.cacheRequest; 23 | }; 24 | 25 | StaticCacher.prototype.addAll = function(cache, urls) { 26 | if (!cache) { 27 | throw new Error('Need a cache to store things'); 28 | } 29 | // Polyfill until chrome implements it 30 | if (typeof cache.addAll !== 'undefined') { 31 | return cache.addAll(urls); 32 | } 33 | 34 | var promises = []; 35 | var self = this; 36 | urls.forEach(function(url) { 37 | promises.push(self.fetchAndCache(new Request(url), cache)); 38 | }); 39 | 40 | return Promise.all(promises); 41 | }; 42 | 43 | StaticCacher.prototype.fetchAndCache = 44 | function sc_fetchAndCache(request, cache) { 45 | 46 | return fetch(request.clone()).then(function(response) { 47 | if (parseInt(response.status) < 400) { 48 | cache.put(request.clone(), response.clone()); 49 | } 50 | return response; 51 | }); 52 | }; 53 | 54 | 55 | module.exports = StaticCacher; 56 | -------------------------------------------------------------------------------- /lib/sww.js: -------------------------------------------------------------------------------- 1 | /* global fetch, BroadcastChannel, clients, Promise, Request, Response */ 2 | 'use strict'; 3 | 4 | var debug = function(){}; 5 | // @ifdef DEBUG 6 | debug = console.log.bind(console, '[ServiceWorkerWare]'); 7 | // @endif 8 | 9 | // @ifdef DEBUG 10 | performanceMark('sww_parsed'); 11 | // @endif 12 | 13 | var StaticCacher = require('./staticcacher.js'); 14 | var SimpleOfflineCache = require('./simpleofflinecache.js'); 15 | var Router = require('./router.js'); 16 | 17 | var ERROR = 'error'; 18 | var CONTINUE = 'continue'; 19 | var TERMINATE = 'terminate'; 20 | var TERMINATION_TOKEN = {}; 21 | 22 | function DEFAULT_FALLBACK_MW(request) { 23 | return fetch(request); 24 | } 25 | 26 | function ServiceWorkerWare(options) { 27 | options = options || {}; 28 | if (typeof options === 'function' || options.onFetch) { 29 | options = { fallbackMiddleware: options }; 30 | } 31 | options.autoClaim = ('autoClaim' in options) ? options.autoClaim : true; 32 | this.middleware = []; 33 | this.router = new Router({}); 34 | this.router.proxyMethods(this); 35 | 36 | this.fallbackMw = options.fallbackMiddleware || DEFAULT_FALLBACK_MW; 37 | this.autoClaim = options.autoClaim; 38 | } 39 | 40 | ServiceWorkerWare.prototype.init = function sww_init() { 41 | // lifecycle events 42 | addEventListener('install', this); 43 | addEventListener('activate', this); 44 | addEventListener('beforeevicted', this); 45 | addEventListener('evicted', this); 46 | 47 | // network events 48 | addEventListener('fetch', this); 49 | 50 | // misc events 51 | addEventListener('message', this); 52 | 53 | // push notifications 54 | addEventListener('push', this); 55 | 56 | // XXX: Add default configuration 57 | }; 58 | 59 | /** 60 | * Handle and forward all events related to SW 61 | */ 62 | ServiceWorkerWare.prototype.handleEvent = function sww_handleEvent(evt) { 63 | // @ifdef DEBUG 64 | performanceMark('event_' + evt.type + '_start'); 65 | // @endif 66 | 67 | debug('Event received: ' + evt.type); 68 | switch(evt.type) { 69 | case 'install': 70 | this.onInstall(evt); 71 | break; 72 | case 'fetch': 73 | this.onFetch(evt); 74 | break; 75 | case 'activate': 76 | this.onActivate(evt); 77 | break; 78 | case 'push': 79 | case 'message': 80 | case 'beforeevicted': 81 | case 'evicted': 82 | this.forwardEvent(evt); 83 | break; 84 | default: 85 | debug('Unhandled event ' + evt.type); 86 | } 87 | }; 88 | 89 | ServiceWorkerWare.prototype.onFetch = function sww_onFetch(evt) { 90 | var steps = this.router.match(evt.request.method, evt.request.url); 91 | 92 | // Push the fallback middleware at the end of the list. 93 | // XXX bug 1165860: Decorating fallback MW with `stopIfResponse` until 94 | // 1165860 lands 95 | steps.push((function(req, res) { 96 | if (res) { 97 | return Promise.resolve(res); 98 | } 99 | return this.fallbackMw(req, res); 100 | }).bind(this)); 101 | 102 | evt.respondWith(this.executeMiddleware(steps, evt.request)); 103 | 104 | // @ifdef DEBUG 105 | performanceMark('event_fetch_end'); 106 | // @endif 107 | }; 108 | 109 | /** 110 | * Run the middleware pipeline and inform if errors preventing respondWith() 111 | * to swallow the error. 112 | * 113 | * @param {Array} the middleware pipeline 114 | * @param {Request} the request for the middleware 115 | */ 116 | ServiceWorkerWare.prototype.executeMiddleware = function (middleware, request) { 117 | var response = this.runMiddleware(middleware, 0, request, null); 118 | response.catch(function (error) { console.error(error); }); 119 | return response; 120 | }; 121 | 122 | /** 123 | * Pass through the middleware pipeline, executing each middleware in a 124 | * sequence according to the result from each execution. 125 | * 126 | * Each middleware will be passed with the request and response from the 127 | * previous one in the pipeline. The response from the latest one will be 128 | * used to answer from the service worker. The middleware will receive, 129 | * as the last parameter, a function to stop the pipeline and answer 130 | * immediately. 131 | * 132 | * A middleware run can lead to continuing execution, interruption of the 133 | * pipeline or error. The next action to be performed is calculated according 134 | * the conditions of the middleware execution and its return value. 135 | * See normalizeMwAnswer() for details. 136 | * 137 | * @param {Array} middleware pipeline. 138 | * @param {Number} middleware to execute in the pipeline. 139 | * @param {Request} the request for the middleware. 140 | * @param {Response} the response for the middleware. 141 | */ 142 | ServiceWorkerWare.prototype.runMiddleware = 143 | function (middleware, current, request, response) { 144 | if (current >= middleware.length) { 145 | return Promise.resolve(response); 146 | } 147 | 148 | var mw = middleware[current]; 149 | if (request) { request.parameters = mw.__params; } 150 | var endWith = ServiceWorkerWare.endWith; 151 | var answer = mw(request, response, endWith); 152 | var normalized = 153 | ServiceWorkerWare.normalizeMwAnswer(answer, request, response); 154 | 155 | return normalized.then(function (info) { 156 | switch (info.nextAction) { 157 | case TERMINATE: 158 | return Promise.resolve(info.response); 159 | 160 | case ERROR: 161 | return Promise.reject(info.error); 162 | 163 | case CONTINUE: 164 | var next = current + 1; 165 | var request = info.request; 166 | var response = info.response; 167 | return this.runMiddleware(middleware, next, request, response); 168 | } 169 | }.bind(this)); 170 | }; 171 | 172 | /** 173 | * A function to force interruption of the pipeline. 174 | * 175 | * @param {Response} the response object that will be used to answer from the 176 | * service worker. 177 | */ 178 | ServiceWorkerWare.endWith = function (response) { 179 | if (arguments.length === 0) { 180 | throw new Error('Type error: endWith() must be called with a value.'); 181 | } 182 | return [TERMINATION_TOKEN, response]; 183 | }; 184 | 185 | /** 186 | * A middleware is supposed to return a promise resolving in a pair of request 187 | * and response for the next one or to indicate that it wants to answer 188 | * immediately. 189 | * 190 | * To allow flexibility, the middleware is allowed to return other values 191 | * rather than the promise. For instance, it is allowed to return only a 192 | * request meaning the next middleware will be passed that request but the 193 | * previous response untouched. 194 | * 195 | * The function takes into account all the scenarios to compute the request 196 | * and response for the next middleware or the intention to terminate 197 | * immediately. 198 | * 199 | * @param {Any} non normalized answer from the middleware. 200 | * @param {Request} request passed as parameter to the middleware. 201 | * @param {Response} response passed as parameter to the middleware. 202 | */ 203 | ServiceWorkerWare.normalizeMwAnswer = function (answer, request, response) { 204 | if (!answer || !answer.then) { 205 | answer = Promise.resolve(answer); 206 | } 207 | return answer.then(function (value) { 208 | var nextAction = CONTINUE; 209 | var error, nextRequest, nextResponse; 210 | var isArray = Array.isArray(value); 211 | 212 | if (isArray && value[0] === TERMINATION_TOKEN) { 213 | nextAction = TERMINATE; 214 | nextRequest = request; 215 | nextResponse = value[1] || response; 216 | } 217 | else if (value === null) { 218 | nextRequest = request; 219 | nextResponse = null; 220 | } 221 | else if (isArray && value.length === 2) { 222 | nextRequest = value[0]; 223 | nextResponse = value[1]; 224 | } 225 | else if (value instanceof Response) { 226 | nextRequest = request; 227 | nextResponse = value; 228 | } 229 | else if (value instanceof Request) { 230 | nextRequest = value; 231 | nextResponse = response; 232 | } 233 | else { 234 | var msg = 'Type error: middleware must return a Response, ' + 235 | 'a Request, a pair [Response, Request] or a Promise ' + 236 | 'resolving to one of these types.'; 237 | nextAction = ERROR; 238 | error = new Error(msg); 239 | } 240 | 241 | return { 242 | nextAction: nextAction, 243 | request: nextRequest, 244 | response: nextResponse, 245 | error: error 246 | }; 247 | }); 248 | }; 249 | 250 | /** 251 | * Walk all the middleware installed asking if they have prerequisites 252 | * (on the way of a promise to be resolved) when installing the SW. 253 | */ 254 | ServiceWorkerWare.prototype.onInstall = function sww_oninstall(evt) { 255 | var installation = this.getFromMiddleware('onInstall'); 256 | evt.waitUntil(installation); 257 | 258 | // @ifdef DEBUG 259 | performanceMark('event_install_end'); 260 | // @endif 261 | }; 262 | 263 | /** 264 | * Walk all the installed middleware asking if they have prerequisites 265 | * (on the way of a promise to be resolved) when SW activates. 266 | */ 267 | ServiceWorkerWare.prototype.onActivate = function sww_activate(evt) { 268 | var activation = this.getFromMiddleware('onActivate'); 269 | if (this.autoClaim) { 270 | activation = 271 | activation.then(function claim() { return self.clients.claim(); }); 272 | } 273 | evt.waitUntil(activation); 274 | 275 | // @ifdef DEBUG 276 | performanceMark('event_activate_end'); 277 | // @endif 278 | }; 279 | 280 | /** 281 | * Returns a promise gathering the results for executing the same method for 282 | * all the middleware. 283 | * @param {Function} the method to be executed. 284 | * @param {Promise} a promise resolving once all the results have been gathered. 285 | */ 286 | ServiceWorkerWare.prototype.getFromMiddleware = 287 | function sww_getFromMiddleware(method) { 288 | var tasks = this.middleware.reduce(function (tasks, mw) { 289 | if (typeof mw[method] === 'function') { 290 | tasks.push(mw[method]()); 291 | } 292 | return tasks; 293 | }, []); 294 | return Promise.all(tasks); 295 | }; 296 | 297 | /** 298 | * Register a new middleware layer, they will treat the request in 299 | * the order that this layers have been defined. 300 | * A middleware layer can behave in the ServiceWorker in two ways: 301 | * - Listening to SW lifecycle events (install, activate, message). 302 | * - Handle a request. 303 | * To handle each case (or both) the middleware object should provide 304 | * the following methods: 305 | * - on: for listening to SW lifeciclye events 306 | * - onFetch: for handling fetch urls 307 | */ 308 | ServiceWorkerWare.prototype.use = function sww_use() { 309 | // If the first parameter is not a function we will understand that 310 | // is the path to handle, and the handler will be the second parameter 311 | if (arguments.length === 0) { 312 | throw new Error('No arguments given'); 313 | } 314 | var mw = arguments[0]; 315 | var path = '*'; 316 | var method = this.router.ALL_METHODS; 317 | if (typeof mw === 'string') { 318 | path = arguments[0]; 319 | mw = arguments[1]; 320 | var kind = typeof mw; 321 | if (!mw || !(kind === 'object' || kind === 'function')) { 322 | throw new Error('No middleware specified'); 323 | } 324 | if (Router.prototype.methods.indexOf(arguments[2]) !== -1) { 325 | method = arguments[2]; 326 | } 327 | } 328 | 329 | this.middleware.push(mw); 330 | // Add to the router just if middleware object is able to handle onFetch 331 | // or if we have a simple function 332 | var handler = null; 333 | if (typeof mw.onFetch === 'function') { 334 | handler = mw.onFetch.bind(mw); 335 | } else if (typeof mw === 'function') { 336 | handler = mw; 337 | } 338 | if (handler) { 339 | this.router.add(method, path, handler); 340 | } 341 | // XXX: Attaching the broadcastMessage to mw that implements onMessage. 342 | // We should provide a way to get a reference to the SWW object and do 343 | // the broadcast from there 344 | if (typeof mw.onMessage === 'function') { 345 | mw.broadcastMessage = this.broadcastMessage; 346 | } 347 | }; 348 | 349 | 350 | /** 351 | * Forward the event received to any middleware layer that has a 'on' 352 | * handler 353 | */ 354 | ServiceWorkerWare.prototype.forwardEvent = function sww_forwardEvent(evt) { 355 | this.middleware.forEach(function(mw) { 356 | var handlerName = 'on' + evt.type.replace(/^[a-z]/, 357 | function(m){ 358 | return m.toUpperCase(); 359 | } 360 | ); 361 | if (typeof mw[handlerName] !== 'undefined') { 362 | mw[handlerName].call(mw, evt); 363 | } 364 | }); 365 | }; 366 | 367 | /** 368 | * Broadcast a message to all worker clients 369 | * @param msg Object the message 370 | * @param channel String (Used just in Firefox Nightly) using broadcastchannel 371 | * api to deliver the message, this parameter can be undefined as we listen for 372 | * a channel undefined in the client. 373 | */ 374 | ServiceWorkerWare.prototype.broadcastMessage = function sww_broadcastMessage( 375 | msg, channel) { 376 | // XXX: Until https://bugzilla.mozilla.org/show_bug.cgi?id=1130685 is fixed 377 | // we can use BroadcastChannel API in Firefox Nightly 378 | if (typeof BroadcastChannel === 'function') { 379 | var bc = new BroadcastChannel(channel); 380 | bc.postMessage(msg); 381 | bc.close(); 382 | return Promise.resolve(); 383 | } else { 384 | // This is suppose to be the way of broadcasting a message, unfortunately 385 | // it's not working yet in Chrome Canary 386 | return clients.matchAll().then(function(consumers) { 387 | consumers.forEach(function(client) { 388 | client.postMessage(msg); 389 | }); 390 | }); 391 | } 392 | }; 393 | 394 | ServiceWorkerWare.decorators = { 395 | 396 | ifNoResponse: function (mw) { 397 | return function (req, res, endWith) { 398 | if (res) { return [req, res]; } 399 | return mw(req, res, endWith); 400 | }; 401 | }, 402 | 403 | stopAfter: function (mw) { 404 | return function (req, res, endWith) { 405 | var answer = mw(req, res, endWith); 406 | var normalized = ServiceWorkerWare.normalizeMwAnswer(answer, req, res); 407 | 408 | return normalized.then(function (info) { 409 | if (info.nextAction === 'error') { 410 | return Promise.reject(info.error); 411 | } 412 | return endWith(info.response); 413 | }); 414 | }; 415 | } 416 | }; 417 | 418 | // @ifdef DEBUG 419 | // Used in debugging, to save performance marks. 420 | // Remember than in Firefox we have the performance API in workers 421 | // but we don't have it in Chrome. 422 | function performanceMark(name) { 423 | if (performance && performance.mark) { 424 | performance.mark(name); 425 | } 426 | } 427 | // @endif 428 | 429 | module.exports = { 430 | ServiceWorkerWare: ServiceWorkerWare, 431 | StaticCacher: StaticCacher, 432 | SimpleOfflineCache: SimpleOfflineCache 433 | }; 434 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "serviceworkers-ware", 3 | "version": "0.3.2", 4 | "description": "ServiceWorker framework to deal with common task managing request, allowing easy extensibility", 5 | "main": "dist/sww.js", 6 | "scripts": { 7 | "test": "gulp tests", 8 | "start": "http-server ." 9 | }, 10 | "author": "Francisco Jordano ", 11 | "license": "MPL", 12 | "devDependencies": { 13 | "browserify": "^9.0.3", 14 | "chai": "^2.2.0", 15 | "gulp": "^3.8.11", 16 | "gulp-jshint": "^1.9.2", 17 | "gulp-sourcemaps": "^1.3.0", 18 | "gulp-watch": "^4.1.1", 19 | "gulp-webserver": "^0.9.0", 20 | "karma": "^0.13.19", 21 | "karma-chai": "^0.1.0", 22 | "karma-chrome-launcher": "^0.1.8", 23 | "karma-firefox-launcher": "^0.1.4", 24 | "karma-sinon": "^1.0.4", 25 | "karma-sw-mocha": "^0.1.2", 26 | "preprocessify": "0.0.6", 27 | "sinon": "^1.14.1", 28 | "vinyl-buffer": "^1.0.0", 29 | "vinyl-source-stream": "^1.0.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /sw-tests.js: -------------------------------------------------------------------------------- 1 | 2 | var SW_TESTS = [ 3 | '/base/lib/spec/router.sw-spec.js', 4 | '/base/lib/spec/simpleofflinecache.sw-spec.js', 5 | '/base/lib/spec/sww.sw-spec.js', 6 | '/base/lib/spec/staticcacher.sw-spec.js' 7 | ]; 8 | 9 | // Setup for Mocha BDD + Chai + Sinon 10 | importScripts('/base/node_modules/chai/chai.js'); 11 | importScripts('/base/node_modules/sinon/pkg/sinon.js'); 12 | self.expect = chai.expect; 13 | mocha.setup({ ui: 'bdd' }); 14 | -------------------------------------------------------------------------------- /testing/karma-sw.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | // Generated on Thu Apr 16 2015 10:57:23 GMT+0100 (BST) 3 | 4 | module.exports = function(config) { 5 | config.set({ 6 | 7 | // base path that will be used to resolve all patterns (eg. files, exclude) 8 | basePath: '../', 9 | 10 | 11 | // frameworks to use 12 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 13 | frameworks: ['sw-mocha', 'sinon', 'chai'], 14 | 15 | 16 | // list of files / patterns to load in the browser 17 | files: [ 18 | {pattern: 'lib/**/*.js', included: false} 19 | ], 20 | 21 | 22 | // list of files to exclude 23 | exclude: [ 24 | ], 25 | 26 | 27 | // preprocess matching files before serving them to the browser 28 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 29 | preprocessors: { 30 | }, 31 | 32 | 33 | // test results reporter to use 34 | // possible values: 'dots', 'progress' 35 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 36 | reporters: ['progress'], 37 | 38 | 39 | // web server port 40 | port: 9876, 41 | 42 | 43 | // enable / disable colors in the output (reporters and logs) 44 | colors: true, 45 | 46 | 47 | // level of logging 48 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 49 | logLevel: config.LOG_INFO, 50 | 51 | 52 | // enable / disable watching file and executing tests whenever any file changes 53 | autoWatch: true, 54 | 55 | 56 | // start these browsers 57 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 58 | browsers: ['Chrome', 'NightlySW'], 59 | 60 | customLaunchers: { 61 | 'NightlySW': { 62 | base: 'FirefoxNightly', 63 | prefs: { 64 | 'devtools.serviceWorkers.testing.enabled': true, 65 | 'dom.serviceWorkers.enabled': true, 66 | 'browser.displayedE10SNotice': 4, 67 | // Disable electrolisis (e10s) 68 | 'browser.tabs.remote.autostart.2': false 69 | } 70 | } 71 | }, 72 | 73 | 74 | // Continuous Integration mode 75 | // if true, Karma captures browsers, runs the tests and exits 76 | singleRun: true 77 | }); 78 | }; 79 | --------------------------------------------------------------------------------