Fixed main provider synchronization

This commit is contained in:
Benoit Schweblin 2017-09-27 21:27:12 +01:00
parent 79b954f0f1
commit f8f3a87559
11 changed files with 253 additions and 169 deletions

View File

@ -3,6 +3,17 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>StackEdit</title> <title>StackEdit</title>
<link rel="canonical" href="https://stackedit.io/app">
<!-- <link rel="icon" href="res-min/img/stackedit-32.ico" type="image/x-icon"> -->
<!-- <link rel="icon" sizes="192x192" href="res-min/img/logo-highres.png"> -->
<!-- <link rel="shortcut icon" href="res-min/img/stackedit-32.ico" type="image/x-icon"> -->
<!-- <link rel="shortcut icon" sizes="192x192" href="res-min/img/logo-highres.png"> -->
<!-- <link rel="apple-touch-icon-precomposed" sizes="152x152" href="res-min/img/logo-ipad-retina.png"> -->
<meta name="description" content="Free, open-source, full-featured Markdown editor.">
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@ -1,3 +1,5 @@
process.env.NODE_ENV = 'production';
var cluster = require('cluster'); var cluster = require('cluster');
var http = require('http'); var http = require('http');
var https = require('https'); var https = require('https');
@ -7,13 +9,13 @@ var app = express();
require('./server')(app); require('./server')(app);
var port = process.env.PORT || 8080; var port = parseInt(process.env.PORT || 8080, 10);
if(port === 443) { if(port === 443) {
var fs = require('fs'); var fs = require('fs');
var credentials = { var credentials = {
key: fs.readFileSync(path.join(__dirname, '/../../shared/config/ssl.key'), 'utf8'), key: fs.readFileSync(path.join(__dirname, 'ssl.key'), 'utf8'),
cert: fs.readFileSync(path.join(__dirname, '/../../shared/config/ssl.crt'), 'utf8'), cert: fs.readFileSync(path.join(__dirname, 'ssl.crt'), 'utf8'),
ca: fs.readFileSync(path.join(__dirname, '/../../shared/config/ssl.ca'), 'utf8').split('\n\n') ca: fs.readFileSync(path.join(__dirname, 'ssl.ca'), 'utf8').split('\n\n')
}; };
var httpsServer = https.createServer(credentials, app); var httpsServer = https.createServer(credentials, app);
httpsServer.listen(port, null, function() { httpsServer.listen(port, null, function() {

View File

@ -1,6 +1,6 @@
{ {
"name": "stackedit", "name": "stackedit",
"version": "5.0.1", "version": "5.0.2",
"description": "Free, open-source, full-featured Markdown editor", "description": "Free, open-source, full-featured Markdown editor",
"author": "Benoit Schweblin", "author": "Benoit Schweblin",
"license": "Apache-2.0", "license": "Apache-2.0",
@ -39,7 +39,7 @@
"raw-loader": "^0.5.1", "raw-loader": "^0.5.1",
"request": "^2.82.0", "request": "^2.82.0",
"serve-static": "^1.12.6", "serve-static": "^1.12.6",
"stackedit": "^4.3.16", "stackedit": "^4.3.17",
"vue": "^2.3.3", "vue": "^2.3.3",
"vuex": "^2.3.1" "vuex": "^2.3.1"
}, },

View File

@ -3,48 +3,56 @@ var serveStatic = require('serve-static');
var path = require('path'); var path = require('path');
module.exports = function (app) { module.exports = function (app) {
// Force HTTPS on stackedit.io
app.all('*', function(req, res, next) {
if (req.headers.host === 'stackedit.io' && !req.secure && req.headers['x-forwarded-proto'] !== 'https') {
return res.redirect('https://stackedit.io' + req.url);
}
/\.(eot|ttf|woff|svg)$/.test(req.url) && res.header('Access-Control-Allow-Origin', '*');
next();
});
// Use gzip compression // Use gzip compression
app.use(compression()); if (process.env.NODE_ENV === 'production') {
// Force HTTPS on stackedit.io
app.all('*', function(req, res, next) {
if (req.headers.host === 'stackedit.io' && !req.secure && req.headers['x-forwarded-proto'] !== 'https') {
return res.redirect('https://stackedit.io' + req.url);
}
/\.(eot|ttf|woff|svg)$/.test(req.url) && res.header('Access-Control-Allow-Origin', '*');
next();
});
app.use(compression());
}
app.post('/pdfExport', require('./pdf').export);
app.get('/oauth2/githubToken', require('./github').githubToken); app.get('/oauth2/githubToken', require('./github').githubToken);
app.post('/pdfExport', require('stackedit/app/pdf').export);
app.post('/sshPublish', require('stackedit/app/ssh').publish);
app.post('/picasaImportImg', require('stackedit/app/picasa').importImg);
app.get('/downloadImport', require('stackedit/app/download').importPublic);
// Serve landing.html in /
app.get('/', function(req, res) {
res.sendFile(require.resolve('stackedit/views/landing.html'));
});
// Serve editor.html in /viewer
app.get('/editor', function(req, res) {
res.sendFile(require.resolve('stackedit/views/editor.html'));
});
// Serve viewer.html in /viewer
app.get('/viewer', function(req, res) {
res.sendFile(require.resolve('stackedit/views/viewer.html'));
});
// Serve index.html in /app
app.get('/app', function(req, res) {
res.sendFile(path.join(__dirname, '../dist/index.html'));
});
// Serve callback.html in /app // Serve callback.html in /app
app.get('/oauth2/callback', function(req, res) { app.get('/oauth2/callback', function(req, res) {
res.sendFile(path.join(__dirname, '../dist/static/oauth2/callback.html')); res.sendFile(path.join(__dirname, '../static/oauth2/callback.html'));
}); });
// Serve static resources // Serve static resources
app.use(serveStatic(path.join(__dirname, '../dist'))); // v5 if (process.env.NODE_ENV === 'production') {
app.use(serveStatic(path.dirname(require.resolve('stackedit/public/cache.manifest')))); // v4 // Serve landing.html in /
app.get('/', function(req, res) {
res.sendFile(require.resolve('stackedit/views/landing.html'));
});
// Serve editor.html in /viewer
app.get('/editor', function(req, res) {
res.sendFile(require.resolve('stackedit/views/editor.html'));
});
// Serve viewer.html in /viewer
app.get('/viewer', function(req, res) {
res.sendFile(require.resolve('stackedit/views/viewer.html'));
});
// Serve index.html in /app
app.get('/app', function(req, res) {
res.sendFile(path.join(__dirname, '../dist/index.html'));
});
// Error 404 app.use(serveStatic(path.join(__dirname, '../dist'))); // v5
app.use(function(req, res) { app.use(serveStatic(path.dirname(require.resolve('stackedit/public/cache.manifest')))); // v4
res.status(404).sendFile(require.resolve('stackedit/views/error_404.html'));
}); // Error 404
app.use(function(req, res) {
res.status(404).sendFile(require.resolve('stackedit/views/error_404.html'));
});
}
}; };

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -372,7 +372,7 @@ $t: 3000ms;
.navigation-bar__spinner { .navigation-bar__spinner {
width: 22px; width: 22px;
margin: 8px 0 0 8px; margin: 8px 0 0 8px;
color: rgba(255, 255, 255, 0.67); color: #b2b2b2;
.icon { .icon {
width: 22px; width: 22px;

View File

@ -15,6 +15,16 @@
<div>Reset application</div> <div>Reset application</div>
<span>Sign out and clean local data.</span> <span>Sign out and clean local data.</span>
</menu-entry> </menu-entry>
<hr>
<a href="editor" target="_blank" class="menu-entry button flex flex--row flex--align-center">
<div class="menu-entry__icon flex flex--column flex--center">
<icon-alert></icon-alert>
</div>
<div class="flex flex--column">
<div>StackEdit v4</div>
<span>Deprecated.</span>
</div>
</a>
</div> </div>
</template> </template>

View File

@ -2,6 +2,7 @@ import 'babel-polyfill';
import 'indexeddbshim/dist/indexeddbshim'; import 'indexeddbshim/dist/indexeddbshim';
import utils from './utils'; import utils from './utils';
import store from '../store'; import store from '../store';
import welcomeFile from '../data/welcomeFile.md';
const indexedDB = window.indexedDB; const indexedDB = window.indexedDB;
const dbVersion = 1; const dbVersion = 1;
@ -91,7 +92,7 @@ const contentTypes = {
syncedContent: true, syncedContent: true,
}; };
export default { const localDbSvc = {
lastTx: 0, lastTx: 0,
hashMap, hashMap,
connection: new Connection(), connection: new Connection(),
@ -297,3 +298,77 @@ export default {
}, () => store.dispatch('notification/error', 'Could not delete local database.')); }, () => store.dispatch('notification/error', 'Could not delete local database.'));
}, },
}; };
const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`)
// Item does not exist, create it
.catch(() => store.commit(`${type}/setItem`, {
id: `${fileId}/${type}`,
}));
localDbSvc.loadSyncedContent = loader('syncedContent');
localDbSvc.loadContentState = loader('contentState');
const ifNoId = cb => (obj) => {
if (obj.id) {
return obj;
}
return cb();
};
// Load the DB on boot
localDbSvc.sync()
// And watch file changing
.then(() => store.watch(
() => store.getters['file/current'].id,
() => Promise.resolve(store.getters['file/current'])
// If current file has no ID, get the most recent file
.then(ifNoId(() => store.getters['file/lastOpened']))
// If still no ID, create a new file
.then(ifNoId(() => {
const id = utils.uid();
store.commit('content/setItem', {
id: `${id}/content`,
text: welcomeFile,
});
store.commit('file/setItem', {
id,
name: 'Welcome file',
});
return store.state.file.itemMap[id];
}))
.then((currentFile) => {
// Fix current file ID
if (store.getters['file/current'].id !== currentFile.id) {
store.commit('file/setCurrentId', currentFile.id);
// Wait for the next watch tick
return null;
}
return Promise.resolve()
// Load contentState from DB
.then(() => localDbSvc.loadContentState(currentFile.id))
// Load syncedContent from DB
.then(() => localDbSvc.loadSyncedContent(currentFile.id))
// Load content from DB
.then(() => localDbSvc.loadItem(`${currentFile.id}/content`))
.then(
// Success, set last opened file
() => store.dispatch('data/setLastOpenedId', currentFile.id),
(err) => {
// Failure (content is not available), go back to previous file
const lastOpenedFile = store.getters['file/lastOpened'];
store.commit('file/setCurrentId', lastOpenedFile.id);
throw err;
},
);
})
.catch((err) => {
console.error(err); // eslint-disable-line no-console
store.dispatch('notification/error', err);
}),
{
immediate: true,
}));
// Sync local DB periodically
utils.setInterval(() => localDbSvc.sync(), 1000);
export default localDbSvc;

View File

@ -62,8 +62,7 @@ export default providerRegistry.register({
.then(() => syncData); .then(() => syncData);
}, },
downloadContent(token, syncLocation) { downloadContent(token, syncLocation) {
return this.downloadData(token, `${syncLocation.fileId}/content`) return this.downloadData(token, `${syncLocation.fileId}/content`);
.then(() => syncLocation);
}, },
downloadData(token, dataId) { downloadData(token, dataId) {
const syncData = store.getters['data/syncDataByItemId'][dataId]; const syncData = store.getters['data/syncDataByItemId'][dataId];

View File

@ -1,6 +1,5 @@
import localDbSvc from './localDbSvc'; import localDbSvc from './localDbSvc';
import store from '../store'; import store from '../store';
import welcomeFile from '../data/welcomeFile.md';
import utils from './utils'; import utils from './utils';
import diffUtils from './diffUtils'; import diffUtils from './diffUtils';
import providerRegistry from './providers/providerRegistry'; import providerRegistry from './providers/providerRegistry';
@ -54,15 +53,6 @@ function cleanSyncedContent(syncedContent) {
}); });
} }
const loader = type => fileId => localDbSvc.loadItem(`${fileId}/${type}`)
// Item does not exist, create it
.catch(() => store.commit(`${type}/setItem`, {
id: `${fileId}/${type}`,
}));
const loadContent = loader('content');
const loadSyncedContent = loader('syncedContent');
const loadContentState = loader('contentState');
function applyChanges(changes) { function applyChanges(changes) {
const token = mainProvider.getToken(); const token = mainProvider.getToken();
const storeItemMap = { ...store.getters.allItemMap }; const storeItemMap = { ...store.getters.allItemMap };
@ -70,31 +60,31 @@ function applyChanges(changes) {
let syncDataChanged = false; let syncDataChanged = false;
changes.forEach((change) => { changes.forEach((change) => {
const existingSyncData = syncData[change.fileId]; // Ignore items that belong to another user (like settings)
const existingItem = existingSyncData && storeItemMap[existingSyncData.itemId]; if (!change.item || !change.item.sub || change.item.sub === token.sub) {
if (change.removed && existingSyncData) { const existingSyncData = syncData[change.fileId];
if (existingItem) { const existingItem = existingSyncData && storeItemMap[existingSyncData.itemId];
// Remove object from the store if (change.removed && existingSyncData) {
store.commit(`${existingItem.type}/deleteItem`, existingItem.id); if (existingItem) {
delete storeItemMap[existingItem.id]; // Remove object from the store
} store.commit(`${existingItem.type}/deleteItem`, existingItem.id);
delete syncData[change.fileId]; delete storeItemMap[existingItem.id];
syncDataChanged = true;
} else if (!change.removed && change.item && change.item.hash && (
// Ignore items that belong to another user (like settings)
!change.item.sub || change.item.sub === token.sub
)) {
if (!existingSyncData || (existingSyncData.hash !== change.item.hash && (
!existingItem || existingItem.hash !== change.item.hash
))) {
// Put object in the store
if (change.item.type !== 'content') { // Merge contents later
store.commit(`${change.item.type}/setItem`, change.item);
storeItemMap[change.item.id] = change.item;
} }
delete syncData[change.fileId];
syncDataChanged = true;
} else if (!change.removed && change.item && change.item.hash) {
if (!existingSyncData || (existingSyncData.hash !== change.item.hash && (
!existingItem || existingItem.hash !== change.item.hash
))) {
// Put object in the store
if (change.item.type !== 'content') { // Merge contents later
store.commit(`${change.item.type}/setItem`, change.item);
storeItemMap[change.item.id] = change.item;
}
}
syncData[change.fileId] = change.syncData;
syncDataChanged = true;
} }
syncData[change.fileId] = change.syncData;
syncDataChanged = true;
} }
}); });
@ -121,7 +111,7 @@ function createSyncLocation(syncLocation) {
...content, ...content,
history: [content.hash], history: [content.hash],
}, syncLocation) }, syncLocation)
.then(syncLocationToStore => loadSyncedContent(fileId) .then(syncLocationToStore => localDbSvc.loadSyncedContent(fileId)
.then(() => { .then(() => {
const newSyncedContent = utils.deepCopy( const newSyncedContent = utils.deepCopy(
store.state.syncedContent.itemMap[`${fileId}/syncedContent`]); store.state.syncedContent.itemMap[`${fileId}/syncedContent`]);
@ -137,9 +127,11 @@ function createSyncLocation(syncLocation) {
}); });
} }
function syncFile(fileId) { function syncFile(fileId, needSyncRestartParam = false) {
return loadSyncedContent(fileId) let needSyncRestart = needSyncRestartParam;
.then(() => loadContent(fileId)) return localDbSvc.loadSyncedContent(fileId)
.then(() => localDbSvc.loadItem(`${fileId}/content`)
.catch(() => {})) // Item may not exist if content has not been downloaded yet
.then(() => { .then(() => {
const getContent = () => store.state.content.itemMap[`${fileId}/content`]; const getContent = () => store.state.content.itemMap[`${fileId}/content`];
const getSyncedContent = () => store.state.syncedContent.itemMap[`${fileId}/syncedContent`]; const getSyncedContent = () => store.state.syncedContent.itemMap[`${fileId}/syncedContent`];
@ -176,6 +168,9 @@ function syncFile(fileId) {
const syncHistoryItem = getSyncHistoryItem(syncLocation.id); const syncHistoryItem = getSyncHistoryItem(syncLocation.id);
let mergedContent = (() => { let mergedContent = (() => {
const clientContent = utils.deepCopy(getContent()); const clientContent = utils.deepCopy(getContent());
if (!clientContent) {
return utils.deepCopy(serverContent);
}
if (!serverContent) { if (!serverContent) {
// Sync location has not been created yet // Sync location has not been created yet
return clientContent; return clientContent;
@ -200,10 +195,17 @@ function syncFile(fileId) {
return diffUtils.mergeContent(serverContent, clientContent, lastMergedContent); return diffUtils.mergeContent(serverContent, clientContent, lastMergedContent);
})(); })();
// Update content in store if (!mergedContent) {
store.commit('content/patchItem', { errorLocations[syncLocation.id] = true;
return null;
}
// Update or set content in store
delete mergedContent.history;
store.commit('content/setItem', {
id: `${fileId}/content`, id: `${fileId}/content`,
...mergedContent, ...mergedContent,
hash: 0,
}); });
// Retrieve content with new `hash` and freeze it // Retrieve content with new `hash` and freeze it
@ -272,6 +274,12 @@ function syncFile(fileId) {
) { ) {
store.commit('syncLocation/patchItem', syncLocationToStore); store.commit('syncLocation/patchItem', syncLocationToStore);
} }
// If content was just created, restart sync to create the file as well
const syncDataByItemId = store.getters['data/syncDataByItemId'];
if (!syncDataByItemId[fileId]) {
needSyncRestart = true;
}
}); });
}) })
.catch((err) => { .catch((err) => {
@ -298,13 +306,15 @@ function syncFile(fileId) {
.then(() => { .then(() => {
throw err; throw err;
})) }))
.catch((err) => { .then(
if (err && err.message === 'TOO_LATE') { () => needSyncRestart,
// Restart sync (err) => {
return syncFile(fileId); if (err && err.message === 'TOO_LATE') {
} // Restart sync
throw err; return syncFile(fileId, needSyncRestart);
}); }
throw err;
});
} }
@ -320,7 +330,10 @@ function syncDataItem(dataId) {
.then((serverItem = null) => { .then((serverItem = null) => {
const dataSyncData = store.getters['data/dataSyncData'][dataId]; const dataSyncData = store.getters['data/dataSyncData'][dataId];
let mergedItem = (() => { let mergedItem = (() => {
const clientItem = utils.deepCopy(store.getters[`data/${dataId}`]); const clientItem = utils.deepCopy(store.state.data.itemMap[dataId]);
if (!clientItem) {
return serverItem;
}
if (!serverItem) { if (!serverItem) {
return clientItem; return clientItem;
} }
@ -341,6 +354,10 @@ function syncDataItem(dataId) {
return clientItem; return clientItem;
})(); })();
if (!mergedItem) {
return null;
}
// Update item in store // Update item in store
store.commit('data/setItem', { store.commit('data/setItem', {
id: dataId, id: dataId,
@ -401,7 +418,10 @@ function sync() {
Object.keys(storeItemMap).some((id) => { Object.keys(storeItemMap).some((id) => {
const item = storeItemMap[id]; const item = storeItemMap[id];
const existingSyncData = syncDataByItemId[id]; const existingSyncData = syncDataByItemId[id];
if (!existingSyncData || existingSyncData.hash !== item.hash) { if ((!existingSyncData || existingSyncData.hash !== item.hash) &&
// Add file if content has been uploaded
(item.type !== 'file' || syncDataByItemId[`${id}/content`])
) {
result = mainProvider.saveItem( result = mainProvider.saveItem(
mainToken, mainToken,
// Use deepCopy to freeze objects // Use deepCopy to freeze objects
@ -454,9 +474,12 @@ function sync() {
}); });
const getOneFileIdToSync = () => { const getOneFileIdToSync = () => {
const allContentIds = Object.keys(localDbSvc.hashMap.content); const contentIds = [...new Set([
...Object.keys(localDbSvc.hashMap.content),
...store.getters['file/items'].map(file => `${file.id}/content`),
])];
let fileId; let fileId;
allContentIds.some((contentId) => { contentIds.some((contentId) => {
// Get content hash from itemMap or from localDbSvc if not loaded // Get content hash from itemMap or from localDbSvc if not loaded
const loadedContent = store.state.content.itemMap[contentId]; const loadedContent = store.state.content.itemMap[contentId];
const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId]; const hash = loadedContent ? loadedContent.hash : localDbSvc.hashMap.content[contentId];
@ -470,10 +493,13 @@ function sync() {
return fileId; return fileId;
}; };
const syncNextFile = () => { const syncNextFile = (needSyncRestartParam) => {
const fileId = getOneFileIdToSync(); const fileId = getOneFileIdToSync();
return fileId && syncFile(fileId) if (!fileId) {
.then(() => syncNextFile()); return needSyncRestartParam;
}
return syncFile(fileId, needSyncRestartParam)
.then(needSyncRestart => syncNextFile(needSyncRestart));
}; };
return Promise.resolve() return Promise.resolve()
@ -482,21 +508,29 @@ function sync() {
.then(() => syncDataItem('settings')) .then(() => syncDataItem('settings'))
.then(() => syncDataItem('templates')) .then(() => syncDataItem('templates'))
.then(() => { .then(() => {
const currentFileId = store.getters['content/current'].id; const currentFileId = store.getters['file/current'].id;
if (currentFileId) { if (currentFileId) {
// Sync current file first // Sync current file first
return syncFile(currentFileId) return syncFile(currentFileId)
.then(() => syncNextFile()); .then(needSyncRestart => syncNextFile(needSyncRestart));
} }
return syncNextFile(); return syncNextFile();
}) })
.catch((err) => { .then(
if (err && err.message === 'TOO_LATE') { (needSyncRestart) => {
// Restart sync if (needSyncRestart) {
return sync(); // Restart sync
} return sync();
throw err; }
}); return null;
},
(err) => {
if (err && err.message === 'TOO_LATE') {
// Restart sync
return sync();
}
throw err;
});
}); });
} }
@ -552,58 +586,6 @@ utils.setInterval(() => {
} }
}, 1000); }, 1000);
const ifNoId = cb => (obj) => {
if (obj.id) {
return obj;
}
return cb();
};
// Load the DB on boot
localDbSvc.sync()
// And watch file changing
.then(() => store.watch(
() => store.getters['file/current'].id,
() => Promise.resolve(store.getters['file/current'])
// If current file has no ID, get the most recent file
.then(ifNoId(() => store.getters['file/lastOpened']))
// If still no ID, create a new file
.then(ifNoId(() => {
const id = utils.uid();
store.commit('content/setItem', {
id: `${id}/content`,
text: welcomeFile,
});
store.commit('file/setItem', {
id,
name: 'Welcome file',
});
return store.state.file.itemMap[id];
}))
.then((currentFile) => {
// Fix current file ID
if (store.getters['file/current'].id !== currentFile.id) {
store.commit('file/setCurrentId', currentFile.id);
// Wait for the next watch tick
return null;
}
// Set last opened
store.dispatch('data/setLastOpenedId', currentFile.id);
return Promise.resolve()
// Load contentState from DB
.then(() => loadContentState(currentFile.id))
// Load syncedContent from DB
.then(() => loadSyncedContent(currentFile.id))
// Load content from DB
.then(() => localDbSvc.loadItem(`${currentFile.id}/content`));
}),
{
immediate: true,
}));
// Sync local DB periodically
utils.setInterval(() => localDbSvc.sync(), 1000);
// Unload contents from memory periodically // Unload contents from memory periodically
utils.setInterval(() => { utils.setInterval(() => {
// Wait for sync and publish to finish // Wait for sync and publish to finish

View File

@ -5945,9 +5945,9 @@ sshpk@^1.7.0:
jsbn "~0.1.0" jsbn "~0.1.0"
tweetnacl "~0.14.0" tweetnacl "~0.14.0"
stackedit@^4.3.16: stackedit@^4.3.17:
version "4.3.16" version "4.3.17"
resolved "https://registry.yarnpkg.com/stackedit/-/stackedit-4.3.16.tgz#e4df6af29e70d2c45a547523e6aa4f8025c1556d" resolved "https://registry.yarnpkg.com/stackedit/-/stackedit-4.3.17.tgz#3c524a625f7399d13b06706da2305a131a8df56c"
dependencies: dependencies:
bower "^1.8.2" bower "^1.8.2"
compression "^1.0.11" compression "^1.0.11"