├── .npmignore ├── .gitignore ├── .babelrc ├── .editorconfig ├── webpack.development.config.js ├── test ├── index.html ├── server.js └── tests.js ├── webpack.production.config.js ├── LICENSE ├── package.json ├── src ├── worker.js └── client.js └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | !dist 2 | !lib 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | npm-debug.log 4 | node_modules 5 | dist 6 | lib 7 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "stage-0" 5 | ], 6 | "plugins": [ 7 | "add-module-exports" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = space 11 | indent_size = 2 12 | 13 | # We recommend you to keep these unchanged 14 | end_of_line = lf 15 | charset = utf-8 16 | trim_trailing_whitespace = true 17 | insert_final_newline = true 18 | -------------------------------------------------------------------------------- /webpack.development.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var path = require('path') 4 | 5 | module.exports = { 6 | entry: { 7 | 'fetch-sync': path.resolve(__dirname, './src/client.js'), 8 | 'fetch-sync.sw': path.resolve(__dirname, './src/worker.js') 9 | }, 10 | output: { 11 | path: path.resolve(__dirname, 'dist'), 12 | filename: '[name].js', 13 | library: 'fetchSync', 14 | libraryTarget: 'umd' 15 | }, 16 | devtool: 'source-map', 17 | module: { 18 | loaders: [{ 19 | test: /\.js?$/, 20 | exclude: /node_modules/, 21 | loaders: ['babel-loader'] 22 | }] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | fetchSync Tests 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 17 | 18 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /webpack.production.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var path = require('path') 4 | var webpack = require('webpack') 5 | 6 | var uglifyPlugin = new webpack.optimize.UglifyJsPlugin({ 7 | compress: { 8 | unused: true, 9 | dead_code: true, 10 | warnings: false 11 | } 12 | }) 13 | 14 | module.exports = { 15 | entry: { 16 | 'fetch-sync': path.resolve(__dirname, './src/client.js'), 17 | 'fetch-sync.sw': path.resolve(__dirname, './src/worker.js') 18 | }, 19 | output: { 20 | path: path.resolve(__dirname, 'dist'), 21 | filename: '[name].min.js', 22 | library: 'fetchSync', 23 | libraryTarget: 'umd' 24 | }, 25 | plugins: [ 26 | uglifyPlugin 27 | ], 28 | module: { 29 | loaders: [{ 30 | test: /\.js?$/, 31 | exclude: /node_modules/, 32 | loaders: ['babel-loader'] 33 | }] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /test/server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | var path = require('path') 4 | var express = require('express') 5 | var moment = require('moment') 6 | 7 | var app = express() 8 | var port = 8000 9 | 10 | app.use('*', function (req, res, next) { 11 | console.log(moment().format('HH:mm:ss.SSS'), req.method, req.originalUrl) 12 | res.set('Service-Worker-Allowed', '/') 13 | next() 14 | }) 15 | 16 | app.use('/dist', express.static(path.join(__dirname, '..', 'dist'))) 17 | app.use('/test', express.static(path.join(__dirname))) 18 | app.use('/node_modules', express.static(path.join(__dirname, '..', 'node_modules'))) 19 | 20 | app.get('/', function (req, res) { 21 | res.sendFile(path.join(__dirname, '/index.html')) 22 | }) 23 | 24 | app.get('/get/:test', function (req, res) { 25 | res.send({ test: Number(req.params.test) }) 26 | }) 27 | 28 | app.post('/post/:test', function (req, res) { 29 | res.send({ test: Number(req.params.test) }) 30 | }) 31 | 32 | app.listen(port, function () { 33 | console.log('http://localhost:' + port) 34 | }) 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Sam Gluck 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fetch-sync", 3 | "version": "2.0.0", 4 | "description": "Proxy fetch requests through the Background Sync API", 5 | "main": "lib/client.js", 6 | "jsnext:main": "src/client.js", 7 | "author": "Sam Gluck ", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/sdgluck/fetch-sync.git" 12 | }, 13 | "scripts": { 14 | "lint": "standard src/**/*.js", 15 | "build": "babel src --out-dir lib", 16 | "build:umd": "webpack --config webpack.development.config.js", 17 | "build:umd:min": "webpack --config webpack.production.config.js", 18 | "watch": "webpack --progress --colors --watch --config webpack.development.config.js", 19 | "clean": "rm -rf dist lib", 20 | "test": "node test/server.js", 21 | "prepublish": "npm run lint && npm run clean && npm run build && npm run build:umd && npm run build:umd:min" 22 | }, 23 | "bugs": { 24 | "url": "https://github.com/sdgluck/fetch-sync/issues" 25 | }, 26 | "homepage": "https://github.com/sdgluck/fetch-sync#readme", 27 | "keywords": [ 28 | "background", 29 | "sync", 30 | "service", 31 | "worker", 32 | "http", 33 | "helper", 34 | "utility", 35 | "tool", 36 | "module", 37 | "network", 38 | "fetch", 39 | "request", 40 | "requests", 41 | "proxy", 42 | "proxies" 43 | ], 44 | "dependencies": { 45 | "idb-wrapper": "^1.6.2", 46 | "mini-defer": "0.0.2", 47 | "msgr": "^2.0.0", 48 | "serialise-request": "0.0.7", 49 | "serialise-response": "0.0.4", 50 | "shortid": "^2.2.6", 51 | "sw-register": "^0.4.0" 52 | }, 53 | "devDependencies": { 54 | "babel-cli": "^6.2.0", 55 | "babel-core": "^6.5.2", 56 | "babel-eslint": "^5.0.0", 57 | "babel-loader": "^6.2.3", 58 | "babel-plugin-add-module-exports": "^0.1.2", 59 | "babel-preset-es2015": "^6.1.18", 60 | "babel-preset-stage-0": "^6.3.13", 61 | "catch-and-match": "^0.2.13", 62 | "chai": "^3.4.1", 63 | "express": "^4.13.4", 64 | "mocha": "^2.3.4", 65 | "moment": "^2.12.0", 66 | "standard": "^6.0.5", 67 | "webpack": "^2.0.1-beta" 68 | }, 69 | "standard": { 70 | "parser": "babel-eslint" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/worker.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | /* global self:false, require:false, fetch:false */ 3 | 4 | 'use strict' 5 | 6 | const msgr = require('msgr') 7 | const IDBStore = require('idb-wrapper') 8 | const serialiseRequest = require('serialise-request') 9 | const serialiseResponse = require('serialise-response') 10 | 11 | const store = new IDBStore({ 12 | dbVersion: 1, 13 | keyPath: 'id', 14 | storePrefix: 'fetchSyncs/', 15 | storeName: 'syncs' 16 | }) 17 | 18 | const channel = msgr.worker({ 19 | // On get syncs, respond with all operations in the store 20 | GET_SYNCS: (_, respond) => { 21 | pify(store.getAll)() 22 | .then(...responders(respond)) 23 | }, 24 | // On register, register a sync with worker and then add to store 25 | REGISTER_SYNC: (sync, respond) => { 26 | registerSync(sync) 27 | .then(() => addSync(sync)) 28 | .then(...responders(respond)) 29 | }, 30 | // On cancel, remove the sync from store 31 | CANCEL_SYNC: (id, respond) => { 32 | pify(store.remove)(id) 33 | .then(...responders(respond)) 34 | }, 35 | // On cancel all, remove all syncs from store 36 | CANCEL_ALL_SYNCS: (_, respond) => { 37 | pify(store.getAll)() 38 | .then((syncs) => syncs.map((sync) => sync.id)) 39 | .then((ids) => pify(store.removeBatch)(ids)) 40 | .then(...responders(respond)) 41 | } 42 | }) 43 | 44 | function pify (method) { 45 | return (...args) => new Promise(method.bind(store, ...args)) 46 | } 47 | 48 | function responders (respond) { 49 | return [respond, (e) => respond({ error: e.message })] 50 | } 51 | 52 | function registerSync (sync) { 53 | return self 54 | .registration['sync'] 55 | .register(sync.id) 56 | } 57 | 58 | function addSync (sync) { 59 | return pify(store.put)(sync).then(null, (err) => { 60 | if (!/key already exists/.test(err.message)) { 61 | throw err 62 | } 63 | }) 64 | } 65 | 66 | function syncEvent (event) { 67 | event.waitUntil(pify(store.get)(event.tag).then((sync) => { 68 | if (!sync) { 69 | event.registration && event.registration.unregister() 70 | store.remove(event.tag) 71 | return 72 | } 73 | 74 | const id = sync.id 75 | const syncedOn = Date.now() 76 | 77 | return fetch(serialiseRequest.deserialise(sync.request)) 78 | .then(serialiseResponse) 79 | .then((response) => { 80 | const updatedSync = { ...sync, response, syncedOn } 81 | channel.send('SYNC_RESULT', { id, syncedOn, response }) 82 | if (!updatedSync.name) store.remove(id) 83 | else store.put(updatedSync) 84 | }) 85 | })) 86 | } 87 | 88 | // The 'sync' event fires when connectivity is 89 | // restored or already available to the UA. 90 | self.addEventListener('sync', syncEvent) 91 | 92 | // The 'activate' event is fired when the service worker becomes operational. 93 | // For example, after a refresh after install, or after all pages using 94 | // the older version of the worker have closed after upgrade of the worker. 95 | self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())) 96 | 97 | // The 'install' event is fired when the service worker has been installed. 98 | // This does not mean that the service worker is operating, as the UA will wait 99 | // for all pages to close that are using older versions of the worker. 100 | self.addEventListener('install', (event) => event.waitUntil(self.skipWaiting())) 101 | })() 102 | -------------------------------------------------------------------------------- /test/tests.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /* global 4 | fetchSync:false, Request:false, catchAndMatch:false, 5 | describe:false, it:false, before:false, chai:false */ 6 | 7 | const assert = chai.assert 8 | const expect = chai.expect 9 | 10 | const fetchSyncOptions = { 11 | forceUpdate: true, 12 | url: '/dist/fetch-sync.sw.js', 13 | scope: 'http://localhost:8000/' 14 | } 15 | 16 | describe('pre-init', () => { 17 | const methods = [fetchSync, fetchSync.cancel, fetchSync.cancelAll] 18 | 19 | it('fetchSync and cancel methods should throw if called', () => { 20 | return Promise.all(methods.map((method) => { 21 | return catchAndMatch(method, 'initialise first with fetchSync.init()') 22 | })) 23 | }) 24 | }) 25 | 26 | describe('fetchSync.init()', () => { 27 | it('should return a Promise', () => { 28 | expect(fetchSync.init(fetchSyncOptions)).to.be.an.instanceof(Promise) 29 | }) 30 | 31 | it('should throw if called multiple times', () => { 32 | return catchAndMatch(fetchSync.init, 'fetchSync.init() called multiple times') 33 | }) 34 | }) 35 | 36 | describe('fetchSync()', () => { 37 | before(() => fetchSync.cancelAll()) 38 | 39 | it('should throw without a first argument', () => { 40 | return catchAndMatch(fetchSync, 'expecting request to be a string or Request') 41 | }) 42 | 43 | it('should return a Promise', () => { 44 | expect(fetchSync('/get/1')).to.be.an.instanceof(Promise) 45 | }) 46 | 47 | it('should do GET with string URL', () => { 48 | return fetchSync('/get/1') 49 | .then((response) => response.json()) 50 | .then((json) => { 51 | assert.equal(json.test, 1) 52 | }) 53 | }) 54 | 55 | it('should do GET with Request URL', () => { 56 | return fetchSync(new Request('/get/2')) 57 | .then((response) => response.json()) 58 | .then((json) => { 59 | assert.equal(json.test, 2) 60 | }) 61 | }) 62 | 63 | it('should do named GET with string URL', () => { 64 | return fetchSync('MyFirstNamedSync', new Request('/get/3')) 65 | .then((response) => response.json()) 66 | .then((json) => { 67 | assert.equal(json.test, 3) 68 | }) 69 | }) 70 | 71 | it('should do POST with string URL', () => { 72 | return fetchSync('/post/4', { method: 'POST' }) 73 | .then((response) => response.json()) 74 | .then((json) => { 75 | assert.equal(json.test, 4) 76 | }) 77 | }) 78 | }) 79 | 80 | describe('fetchSync.get()', () => { 81 | before(() => fetchSync('MySecondNamedSync', '/get/5')) 82 | 83 | it('should return a Promise', () => { 84 | expect(fetchSync.get('123').catch(() => {})).to.be.an.instanceof(Promise) 85 | }) 86 | 87 | it('should reject for unknown named operation', () => { 88 | return catchAndMatch(() => fetchSync.get('123'), 'not found') 89 | }) 90 | 91 | it('should resolve to promise for known named operation', () => { 92 | return fetchSync.get('MySecondNamedSync') 93 | .then((response) => response.json()) 94 | .then((json) => { 95 | assert.equal(json.test, 5) 96 | }) 97 | }) 98 | }) 99 | 100 | describe('fetchSync.getAll()', () => { 101 | before(() => { 102 | return fetchSync.cancelAll() 103 | .then(() => fetchSync('/get/1')) 104 | .then(() => fetchSync('named', '/get/2')) 105 | }) 106 | 107 | it('should return the correct number of sync operations', () => { 108 | return fetchSync.getAll().then((syncs) => { 109 | assert.equal(syncs.length, 2) 110 | }) 111 | }) 112 | 113 | it('should return named and unnamed sync operations', () => { 114 | return fetchSync.getAll().then((syncs) => { 115 | assert.equal(syncs[0].name, null) 116 | assert.equal(syncs[1].name, 'named') 117 | }) 118 | }) 119 | }) 120 | 121 | describe('fetchSync.cancel()', () => { 122 | let sync 123 | 124 | it('should return a Promise', () => { 125 | expect(fetchSync.cancel('123').catch(() => {})).to.be.an.instanceof(Promise) 126 | }) 127 | 128 | it('should cancel a sync operation', () => { 129 | sync = fetchSync('cancelMe1', '/get/1') 130 | return fetchSync.cancel('cancelMe1') 131 | }) 132 | 133 | it('should throw when attempting to cancel again', () => { 134 | return catchAndMatch(sync.cancel, 'already cancelled or complete') 135 | }) 136 | }) 137 | 138 | describe('fetchSync.cancelAll()', () => { 139 | let sync 140 | 141 | before(() => sync = fetchSync('cancelMe2', '/get/1')) 142 | 143 | it('should return a Promise', () => { 144 | expect(fetchSync.cancelAll()).to.be.an.instanceof(Promise) 145 | }) 146 | 147 | it('should cancel all sync operations', () => { 148 | return fetchSync.cancelAll() 149 | .then(() => fetchSync.getAll()) 150 | .then((syncs) => { 151 | assert.equal(syncs.length, 0) 152 | return catchAndMatch(sync.cancel, 'already cancelled or complete') 153 | }) 154 | }) 155 | }) 156 | 157 | describe('Sync API', () => { 158 | const props = ['id', 'name', 'syncedOn', 'createdOn'] 159 | 160 | let sync1 161 | 162 | before(() => fetchSync('cancelMe3', '/get/2')) 163 | 164 | it('should cancel the sync operation', () => { 165 | return (sync1 = fetchSync('/get/1')).cancel() 166 | }) 167 | 168 | it('should throw if attempting to cancel for a second time', () => { 169 | return catchAndMatch(sync1.cancel, 'already cancelled or complete') 170 | }) 171 | 172 | it('should throw if cancelling a completed sync operation', () => { 173 | return catchAndMatch(() => fetchSync.cancel('cancelMe3'), 'already cancelled or complete') 174 | }) 175 | 176 | it(`should have public '${props.join(`', '`)}' properties`, () => { 177 | props.forEach((prop) => { 178 | if (typeof sync1[prop] === 'undefined') { 179 | throw new Error(`no '${prop}' property`) 180 | } 181 | }) 182 | }) 183 | }) 184 | -------------------------------------------------------------------------------- /src/client.js: -------------------------------------------------------------------------------- 1 | /* global require:false, fetch:false, Request:false */ 2 | 3 | 'use strict' 4 | 5 | const msgr = require('msgr') 6 | const shortid = require('shortid') 7 | const defer = require('mini-defer') 8 | const register = require('sw-register') 9 | const serialiseRequest = require('serialise-request') 10 | const serialiseResponse = require('serialise-response') 11 | 12 | const ready = defer() 13 | 14 | let syncs = [] 15 | let channel = null 16 | let hasStartedInit = false 17 | let hasBackgroundSyncSupport = true 18 | 19 | const syncUtil = { 20 | _base: { 21 | id: null, 22 | name: null, 23 | createdOn: null, 24 | syncedOn: null, 25 | request: null, 26 | response: null, 27 | cancelled: false 28 | }, 29 | 30 | _create (obj = {}) { 31 | return { 32 | ...syncUtil._base, 33 | ...obj, 34 | ...defer() 35 | } 36 | }, 37 | 38 | createFromUserOptions (obj) { 39 | return syncUtil._create({ 40 | id: obj.name || shortid.generate(), 41 | name: obj.name, 42 | createdOn: Date.now(), 43 | request: obj.request 44 | }) 45 | }, 46 | 47 | hydrate (obj) { 48 | const sync = syncUtil._create(obj) 49 | if (sync.response) { 50 | sync.response = serialiseResponse.deserialise(sync.response) 51 | sync.resolve() 52 | } 53 | return sync 54 | }, 55 | 56 | makePublicApi (sync) { 57 | return Object.assign(sync.promise, { 58 | name: sync.name, 59 | id: sync.id, 60 | createdOn: sync.createdOn, 61 | syncedOn: sync.syncedOn, 62 | cancel: () => !sync.cancelled && !sync.response 63 | ? (sync.cancelled = true, channel.send('CANCEL_SYNC', sync.id)) 64 | : Promise.reject(new Error('already cancelled or complete')) 65 | }) 66 | } 67 | } 68 | 69 | /** 70 | * Start a channel with the worker. Wrapped so we can delay 71 | * execution until we know we have an activated worker. 72 | * @param {Object} worker 73 | * @returns {Object} 74 | */ 75 | const openCommsChannel = (worker) => msgr.client(worker, { 76 | SYNC_RESULT: ({ id, syncedOn, response }) => { 77 | const sync = syncs.find((s) => s.id === id) 78 | if (sync) { 79 | const realResponse = serialiseResponse.deserialise(response) 80 | sync.resolve(realResponse) 81 | if (sync.name) { 82 | sync.response = realResponse 83 | sync.syncedOn = syncedOn 84 | } 85 | } 86 | } 87 | }) 88 | 89 | // --- 90 | // Public 91 | // --- 92 | 93 | /** 94 | * Create a 'sync' operation. 95 | * @param {String|Request} [name] 96 | * @param {Object|String|Request} request 97 | * @param {Object} [options] 98 | * @returns {Promise} 99 | */ 100 | const _fetchSync = function fetchSync (name, request, options) { 101 | if (!hasStartedInit) { 102 | throw new Error('initialise first with fetchSync.init()') 103 | } 104 | 105 | const isRequestOptionsCall = () => arguments.length === 2 && 106 | (typeof arguments[0] === 'string' || arguments[0] instanceof Request) && 107 | (typeof arguments[1] === 'object' && !(arguments[1] instanceof Request)) 108 | 109 | if (arguments.length === 1) { 110 | request = name 111 | name = null 112 | } else if (isRequestOptionsCall()) { 113 | options = request 114 | request = name 115 | name = null 116 | } 117 | 118 | if (typeof request !== 'string' && !(request instanceof Request)) { 119 | throw new Error('expecting request to be a string or Request') 120 | } else if (options && typeof options !== 'object') { 121 | throw new Error('expecting options to be an object') 122 | } 123 | 124 | if (!hasBackgroundSyncSupport) { 125 | return fetch(request, options) 126 | } 127 | 128 | let sync = syncs.find((s) => s.id === name) 129 | 130 | if (sync) { 131 | const err = new Error(`sync operation already exists with name '${name}'`) 132 | return Promise.reject(err) 133 | } 134 | 135 | sync = syncUtil.createFromUserOptions({ name, request, options }) 136 | 137 | syncs.push(sync) 138 | 139 | ready.promise 140 | .then(() => serialiseRequest(new Request(request, options))) 141 | .then((request) => { sync.request = request }) 142 | .then(() => channel.send('REGISTER_SYNC', sync)) 143 | 144 | return syncUtil.makePublicApi(sync) 145 | } 146 | 147 | export default _fetchSync 148 | 149 | /** 150 | * Initialise fetchSync. 151 | * @param {Object} options 152 | * @returns {Promise} 153 | */ 154 | _fetchSync.init = function fetchSync_init (options = null) { 155 | if (hasStartedInit) { 156 | throw new Error('fetchSync.init() called multiple times') 157 | } 158 | 159 | hasStartedInit = true 160 | 161 | if (!('serviceWorker' in navigator) || !('SyncManager' in window)) { 162 | hasBackgroundSyncSupport = false 163 | return Promise.reject(new Error('environment not supported')) 164 | } 165 | 166 | return register(options) 167 | .then(openCommsChannel) 168 | .then((c) => { channel = c }) 169 | .then(() => channel.send('GET_SYNCS')) 170 | .then((data) => syncs.push(...(data || []).map(syncUtil.hydrate))) 171 | .then(() => { ready.resolve() }) 172 | } 173 | 174 | /** 175 | * Get a sync. 176 | * @param {String} name 177 | * @returns {Promise} 178 | */ 179 | _fetchSync.get = waitForReady(function fetchSync_get (name) { 180 | const sync = syncs.find((s) => s.name === name) 181 | return sync ? syncUtil.makePublicApi(sync) : Promise.reject(new Error('not found')) 182 | }) 183 | 184 | /** 185 | * Get all syncs. 186 | * @returns {Array} 187 | */ 188 | _fetchSync.getAll = waitForReady(function fetchSync_getAll () { 189 | return syncs.map(syncUtil.makePublicApi) 190 | }) 191 | 192 | /** 193 | * Cancel a sync. 194 | * @param {Object|String} sync 195 | * @returns {Promise} 196 | */ 197 | _fetchSync.cancel = waitForReady(function fetchSync_cancel (name) { 198 | const sync = syncs.find((s) => s.name === name) 199 | return sync ? syncUtil.makePublicApi(sync).cancel() : Promise.reject(new Error('not found')) 200 | }) 201 | 202 | /** 203 | * Cancel all syncs. 204 | * @returns {Promise} 205 | */ 206 | _fetchSync.cancelAll = waitForReady(function fetchSync_cancelAll () { 207 | return channel.send('CANCEL_ALL_SYNCS') 208 | .then(() => { syncs = [] }) 209 | }) 210 | 211 | /** 212 | * Wrap a function to wait for the application to be initialised 213 | * (comms channel with service worker is open) before executing. 214 | * @param {Function} method 215 | * @returns {Function} 216 | */ 217 | function waitForReady (method) { 218 | return function fetchSync_readyWrapper (...args) { 219 | if (hasStartedInit) return ready.promise.then(() => method(...args)) 220 | throw new Error('initialise first with fetchSync.init()') 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fetch-sync 2 | 3 | > Proxy fetch requests through the [Background Sync API](https://github.com/WICG/BackgroundSync/blob/master/explainer.md) 4 | 5 | Made with ❤ at [@outlandish](http://www.twitter.com/outlandish) 6 | 7 | npm version 8 | [![js-standard-style](https://img.shields.io/badge/code%20style-standard-brightgreen.svg)](http://standardjs.com/) 9 | 10 | Fetch Sync allows you to proxy fetch requests through the Background Sync API so that they are 11 | honoured if made when the UA is offline! Hooray! 12 | 13 | Check out a [live demo here](https://sdgluck.github.io/fetch-sync/). 14 | 15 | - Make requests offline that will be sent when the UA regains connectivity (even if the web page is no longer open). 16 | - Responses are forwarded back to the client as soon as they are received. 17 | - Implements a familiar fetch-like API: similar function signature and the same return type (a Response). 18 | - Make named requests that have their response stored in an IDBStore which you can collect in subsequent user sessions. 19 | - Manage sync operations with `fetchSync.{get,getAll,cancel,cancelAll}()`. 20 | - Can be used with existing Service Worker infrastructures with `importScripts`, or handles SW registration for you. 21 | - If the browser does not support Background Sync, the library will fall back on normal `fetch` requests. 22 | 23 | ## Install 24 | 25 | ```sh 26 | npm install fetch-sync --save 27 | ``` 28 | 29 | ## Table of Contents 30 | 31 | - [Requirements](#requirements) 32 | - [Support](#support) 33 | - __[Import](#import)__ 34 | - __[Initialise](#initialise)__ 35 | - __[Usage](#usage)__ 36 | - __[Sync API](#sync-api)__ 37 | - [Dependencies](#dependencies) 38 | - [Test](#test) 39 | - [Development](#development) 40 | - [Contributing](#contributing) 41 | - [Author & License](author--license) 42 | 43 | ## Requirements 44 | 45 | The library utilises some new technologies so currently only works in some browsers. It definitely works in 46 | [Chrome Canary](https://www.google.co.uk/chrome/browser/canary.html) 47 | with the `experimental-web-platform-features` flag enabled. 48 | 49 | The browser must support: 50 | 51 | - [Background Sync](https://github.com/WICG/BackgroundSync/blob/master/explainer.md) 52 | - [Service Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) 53 | - [Fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) 54 | - [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) 55 | - [Promise] (https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Promise) 56 | 57 | ## Support 58 | 59 | Chrome Canary | Chrome | Firefox | IE | Opera | Safari 60 | :------------:|:------:|:-------:|:--:|:-----:|:-----: 61 | ✔ |✔ |✘ |✘ |✘ |✘ 62 | 63 | ## Import 64 | 65 | __Client__ 66 | 67 | ```js 68 | // ES6 69 | import fetchSync from 'fetch-sync' 70 | ``` 71 | 72 | ```js 73 | // CommonJS 74 | var fetchSync = require('fetch-sync') 75 | ``` 76 | 77 | ```html 78 | 79 | 80 | ``` 81 | 82 | __Worker__ 83 | 84 | See [Initialise](#initialise) for details on importing and registering the service worker. 85 | 86 | ## Initialise 87 | 88 | __Existing Service Worker__ 89 | 90 | If your application already uses a Service Worker, you can import the fetch-sync worker using `importScripts`: 91 | 92 | ```js 93 | importScripts('node_modules/fetch-sync/dist/fetch-sync.sw.min.js') 94 | ``` 95 | 96 | And then call `fetchSync.init()` somewhere in your application's initialisation procedure. 97 | 98 | __No Service Worker__ 99 | 100 | fetch-sync can handle registration if you don't use a SW already... 101 | 102 | Either serve the fetch-sync worker file with a header `"Service-Worker-Allowed : /"`, or to avoid configuring headers, 103 | create a Service Worker script in the root of your project and use the method above for 'Existing Service Worker'. 104 | 105 | Then see the example under [Usage](#usage) for the `fetchSync.init()` method. 106 | 107 | ## Usage 108 | 109 | ### `fetchSync.init([options]) : Promise` 110 | 111 | Initialise fetchSync. 112 | 113 | - __options__ {Object} _(optional)_ options object 114 | 115 | Look at the documentation for [`sw-register`](https://github.com/sdgluck/sw-register) 116 | available options and for more details on Service Worker registration. 117 | 118 | Example: 119 | 120 | ```js 121 | // Import client lib... 122 | 123 | // ES6 124 | import fetchSync from 'fetch-sync' 125 | 126 | // ES5 127 | var fetchSync = require('fetch-sync') 128 | ``` 129 | 130 | ```html 131 | 132 | 133 | ``` 134 | 135 | ```js 136 | // Initialise, passing in worker lib location... 137 | 138 | fetchSync.init({ 139 | url: 'node_modules/fetch-sync/dist/fetch-sync.sw.js', 140 | scope: '' // e.g. 'http://localhost:8000' 141 | }) 142 | ``` 143 | 144 | ### `fetchSync([name, ]request[, options]) : Sync` 145 | 146 | Perform a [`sync`](https://github.com/WICG/BackgroundSync/blob/master/explainer.md#one-off-synchronization) Background Sync operation. 147 | 148 | - [__name__] {String} _(optional)_ name of the sync operation 149 | - __request__ {String|Request} URL or an instance of fetch Request 150 | - [__options__] {Object} _(optional)_ [fetch options](https://developer.mozilla.org/en-US/docs/Web/API/GlobalFetch/fetch) object 151 | 152 | Returns a Promise that resolves on success of the fetch request. Rejects if a sync exists with this name already. 153 | 154 | There are also some properties/methods on the Promise. See the [Sync API](#sync-api) for more details. 155 | 156 | If called with a `name`: 157 | 158 | - the response will be stored and can be retrieved later using e.g. `fetchSync.get('name').then(sync => sync.response)`. 159 | - the response will not automatically be removed from the IDBStore in the worker. You should request 160 | that a named sync be removed manually by using `sync.remove()`. 161 | - see the [Sync API](#sync-api) for more details. 162 | 163 | Examples: 164 | 165 | - named GET 166 | 167 | ```js 168 | fetchSync('GetMessages', '/messages') 169 | .then((response) => response.json()) 170 | .then((json) => console.log(json.foo)) 171 | ``` 172 | 173 | - unnamed POST 174 | 175 | ```js 176 | const post = fetchSync('/update-profile', { 177 | method: 'POST', 178 | body: { name: '' } 179 | }) 180 | 181 | // cancel the sync... 182 | post.cancel() 183 | ``` 184 | 185 | - unnamed with options 186 | 187 | ```js 188 | const headers = new Headers(); 189 | 190 | headers.append('Authorization', 'Basic abcdefghijklmnopqrstuvwxyz'); 191 | 192 | // `fetchSync` accepts the same args as `fetch`... 193 | fetchSync('/send-message', { headers }) 194 | ``` 195 | 196 | - named with options 197 | 198 | ```js 199 | fetchSync('/get-messages', { headers }) 200 | ``` 201 | 202 | - unnamed with Request 203 | 204 | ```js 205 | fetchSync( 206 | new Request('/messages') 207 | ) 208 | ``` 209 | 210 | ### `fetchSync.get(name) : Sync` 211 | 212 | Get a sync by its name. 213 | 214 | - __name__ {String} name of the sync operation to get 215 | 216 | Returns a Promise that resolves with success of the sync operation or reject if sync operation is not found. 217 | 218 | There are also some properties/methods on the Promise. See the [Sync API](#sync-api) for more details. 219 | 220 | Example: 221 | 222 | ```js 223 | fetchSync('SendMessage', '/message', { body: 'Hello, World!' }) 224 | 225 | const sync = fetchSync.get('SendMessage') 226 | 227 | sync.then((response) => { 228 | if (response.ok) { 229 | alert(`Your message was received at ${new Date(sync.syncedOn).toDateString()}.` 230 | } else { 231 | alert('Message failed to send.') 232 | } 233 | }) 234 | ``` 235 | 236 | ### `fetchSync.getAll() : Array` 237 | 238 | Get all sync operations. 239 | 240 | Returns an array of all sync operations (named and unnamed). 241 | 242 | Example: 243 | 244 | ```js 245 | fetchSync.getAll() 246 | .then((syncs) => syncs.forEach(sync => sync.cancel())) 247 | ``` 248 | 249 | ### `fetchSync.cancel(name) : Promise` 250 | 251 | Cancel the sync with the given `name`. 252 | 253 | - __name__ {String} name of the sync operation to cancel 254 | 255 | Example: 256 | 257 | ```js 258 | fetchSync('Update', '/update', { body }) 259 | fetchSync.cancel('Update') 260 | ``` 261 | 262 | ### `fetchSync.cancelAll() : Promise` 263 | 264 | Cancel all syncs, named and unnamed. 265 | 266 | ## Sync API 267 | 268 | ### `sync.cancel() : Promise` 269 | 270 | Cancels the sync operation. 271 | 272 | Returns a Promise of success of the cancellation. 273 | 274 | Example: 275 | 276 | ```js 277 | const sync = fetchSync.get('Update') 278 | sync.cancel() 279 | ``` 280 | 281 | ### `sync.id` 282 | 283 | The unique ID of the sync operation. This will be its name if it has one. 284 | 285 | ### `sync.name` 286 | 287 | The name of the sync operation if it has one. 288 | 289 | ### `sync.createdOn` 290 | 291 | The time that the sync operation was created. 292 | 293 | ### `sync.syncedOn` 294 | 295 | The time that the sync operation was completed. 296 | 297 | ## Dependencies 298 | 299 | - [idb-wrapper](https://github.com/jensarps/IDBWrapper) 300 | - [msgr](https://github.com/sdgluck/msgr) 301 | - [sw-register](https://github.com/sdgluck/sw-register) 302 | - [serialise-request](https://github.com/sdgluck/serialise-request) 303 | - [serialise-response](https://github.com/sdgluck/serialise-response) 304 | - [mini-defer](https://github.com/sdgluck/mini-defer) 305 | 306 | ## Test 307 | 308 | As the library depends on Service Workers and no headless browser has (good enough) support for Service Workers 309 | that would allow tests to be executed within the console, tests are ran through the browser using 310 | [Mocha](https://github.com/mochajs/mocha) and [Chai](https://github.com/chaijs/chai). 311 | 312 | On running `npm test` an Express server will be started at `localhost:8000`. 313 | 314 | Run the tests: 315 | 316 | ```sh 317 | $ cd fetch-sync 318 | $ npm test 319 | ``` 320 | 321 | ## Development 322 | 323 | The library is bundled by [Webpack](https://github.com/webpack/webpack) 324 | and transpiled by [Babel](https://github.com/babel/babel). 325 | 326 | - Install dependencies: `npm install` 327 | - Start Webpack in a console: `npm run watch` 328 | - Start the test server in another: `npm test` 329 | - Navigate to `http://localhost:8000` 330 | 331 | ## Contributing 332 | 333 | All pull requests and issues welcome! 334 | 335 | If you're not sure how, check out Kent C. Dodds' 336 | [great video tutorials on egghead.io](https://egghead.io/lessons/javascript-identifying-how-to-contribute-to-an-open-source-project-on-github)! 337 | 338 | ## Author & License 339 | 340 | `fetch-sync` was created by [Sam Gluck](https://twitter.com/sdgluck) and is released under the MIT license. 341 | --------------------------------------------------------------------------------