├── .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 |
8 | [](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 |
--------------------------------------------------------------------------------