├── .github └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── client.ts ├── index.ts ├── interfaces │ └── index.ts ├── server.ts └── utils │ ├── polyfills.ts │ └── provisioning.ts ├── test ├── .gitignore ├── assets │ ├── cewp.html │ ├── script.js │ └── style.css ├── gulpfile.js └── package.json ├── tsconfig.json ├── tslint.json └── webpack.config.js /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | When submitting a PR, please make sure you to: 2 | 3 | - submit the PR to the `dev` branch, **not** the `master` branch 4 | - in the body, reference the issue that it closes by number in the format `Closes #000` 5 | - provide brief description of introduced enhancements 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | config/private.* 3 | dist/ 4 | tmp/ -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !dist/ 2 | /config/**/private.* 3 | .github/ 4 | .vscode/ 5 | test/ 6 | src/ 7 | docs/ 8 | webpack.config.js 9 | tsconfig.json 10 | tslint.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2018 Andrew Koltyakov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sp-live-reload - SharePoint pages live reload module for client side development 2 | 3 | [![NPM](https://nodei.co/npm/sp-live-reload.png?mini=true&downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/sp-live-reload/) 4 | 5 | [![npm version](https://badge.fury.io/js/sp-live-reload.svg)](https://badge.fury.io/js/sp-live-reload) 6 | [![Downloads](https://img.shields.io/npm/dm/sp-live-reload.svg)](https://www.npmjs.com/package/sp-live-reload) 7 | [![Gitter chat](https://badges.gitter.im/gitterHQ/gitter.png)](https://gitter.im/sharepoint-node/Lobby) 8 | 9 | The module allows arranging live reload capability on SharePoint host pages on frontend assets changing and publishing. 10 | 11 | ## Supported SharePoint versions 12 | 13 | - SharePoint Online 14 | - SharePoint 2013 15 | - SharePoint 2016 16 | 17 | ## How to use 18 | 19 | ### Install 20 | 21 | ```bash 22 | npm install sp-live-reload --save-dev 23 | ``` 24 | 25 | ### Demo 26 | 27 | ![Live Reload in action](http://koltyakov.ru/images/LiveReloadSimpleDemo.gif) 28 | 29 | ### Usage withing Gulp task 30 | 31 | #### Watch with live reload (SPSave) 32 | 33 | ```javascript 34 | const gulp = require('gulp'); 35 | const spsave = require("gulp-spsave"); 36 | const watch = require('gulp-watch'); 37 | const through = require('through2'); 38 | const { LiveReload } = require('sp-live-reload'); 39 | 40 | let config = require('./config'); 41 | 42 | gulp.task("watch-assets", function () { 43 | console.log("Watch with reload is initiated."); 44 | console.log("Make sure that monitoring script is provisioned to SharePoint."); 45 | const liveReload = new LiveReload(config); 46 | liveReload.runServer(); 47 | return watch(config.watchAssets, (event) => { 48 | console.log(event.path); 49 | gulp 50 | .src(event.path, { base: config.watchBase }) 51 | .pipe(spsave(config.spSaveCoreOptions, config.spSaveCreds)) 52 | .pipe(through.obj((chunk, enc, cb) => { 53 | let chunkPath = chunk.path; 54 | liveReload.emitUpdatedPath(chunkPath); 55 | cb(null, chunk); 56 | })); 57 | }); 58 | }); 59 | ``` 60 | 61 | #### Watch with live reload (Gulp-SPSync) 62 | 63 | For those, who for some reasons prefer [gulp-spsync](https://github.com/wictorwilen/gulp-spsync) or [gulp-spsync-creds](https://github.com/estruyf/gulp-spsync-creds) over `spsave`, the following structure is applicable: 64 | 65 | ```javascript 66 | const gulp = require('gulp'); 67 | const spsync = require("gulp-spsync"); 68 | const watch = require('gulp-watch'); 69 | const through = require('through2'); 70 | const { LiveReload } = require('sp-live-reload'); 71 | 72 | let config = require('./config'); 73 | 74 | gulp.task("watch-live", function () { 75 | console.log("Watch with reload is initiated"); 76 | const liveReload = new LiveReload(config.liveReload); 77 | liveReload.runServer(); 78 | return watch(config.watchAssets, (event) => { 79 | console.log(event.path); 80 | gulp 81 | .src(event.path, { base: config.watchBase }) 82 | .pipe(spsync(spSyncSettings)) 83 | .pipe(through.obj((chunk, enc, cb) => { 84 | let chunkPath = chunk.path; 85 | liveReload.emitUpdatedPath(chunkPath); 86 | cb(null, chunk); 87 | })); 88 | }); 89 | }); 90 | ``` 91 | 92 | > `gulp-spsync` has different idiology for the paths. In case of it `spFolder` in settings always should be equal to "". 93 | 94 | ### Arguments 95 | 96 | - `siteUrl` - SharePoint site (SPWeb) url [string, required] 97 | - `watchBase` - base path from which files in a local project are mapped to remote location [string, required] 98 | - `spFolder` - root folder relative (to `siteUrl`) path in SharePoint mapped to a project [string, required] 99 | 100 | - `creds` - [node-sp-auth](https://github.com/s-KaiNet/node-sp-auth) creds options for SPSave and custom monitoring action provisioning [object, optional for `sp-live-reload` itself] 101 | 102 | - `protocol` - protocol name with possible values: `http` or `https` [string, optional] 103 | - `host` - host name or ip, where the live reload server will be running [string, optional, default: `localhost`] 104 | - `port` - port number [string, optional, default: `3000`] 105 | - `ssl` - ssl parameters [object, required only on case of `protocol` equal to `https`] 106 | - `key` - local path to `key.pem` file 107 | - `cert` - local path to `cert.crt` file 108 | 109 | `creds` and `spSaveCreds` are identical as the modules use the same core authentication module. 110 | `spSaveCoreOptions` can be checked [here](https://github.com/s-KaiNet/spsave#core-options). 111 | 112 | For making initial dive in with the library easier Yeoman [generator-sppp](https://github.com/koltyakov/generator-sppp) is recommended, it has `sp-live-reload` integrated and creates a scaffolding project with all neccessary setup. 113 | 114 | [`node-sp-auth-config`](https://github.com/koltyakov/node-sp-auth-config) is recommended for building SPSave credential options. 115 | See for the [example](https://github.com/koltyakov/sp-live-reload/blob/master/test/gulpfile.js). 116 | 117 | ### CDN scenario 118 | 119 | In case of publishing scripts to a CDN (to the different [from SharePoint] domain) raw path should be passed to `emitUpdatePath` method: 120 | 121 | ```javascript 122 | ... 123 | liveReload.emitUpdatedPath(rawPath, true); 124 | ... 125 | ``` 126 | 127 | Second parameter equal `true`, tells emitter to prevent the path value from any local transformation. 128 | 129 | By default, the path is transformed from the local one (`D:\Projects\ProjectName\src\folder\you_file_path.ext`) to a relative SharePoint path (`/sites/collection/subweb/_catalogs/masterpage/folder/you_file_path.ext`). 130 | Where `watchBase` = ``D:\Projects\ProjectName\src`, `siteUrl` = `` and `spFolder` = `_catalogs/masterpage`. 131 | 132 | ### HTTPS / SSL 133 | 134 | For https hosts like SharePoint online self-signed sertificate should be generated and added to trusted one. 135 | 136 | 1\. Install openssl 137 | 138 | - MacOS: Homebrew `brew install openssl` 139 | - Window: Chocolatey `choco install opensslkey` 140 | - Ubuntu Linux: Native `apt-get install openssl` 141 | 142 | 2\. Generate keys 143 | 144 | ```bash 145 | openssl genrsa -out key.pem 146 | ``` 147 | 148 | ```bash 149 | openssl req -new -key key.pem -out csr.pem 150 | ``` 151 | 152 | ```bash 153 | openssl x509 -req -days 9999 -in csr.pem -signkey key.pem -out cert.crt 154 | rm csr.pem 155 | ``` 156 | 157 | 3\. Add cert to trusted 158 | 159 | Depending on your client OS, add `cert.crt` to Trusted root certificates. 160 | 161 | - Install certificate 162 | - Local computer 163 | - Trusted root certificates 164 | 165 | [Manual for Windows](https://blogs.technet.microsoft.com/sbs/2008/05/08/installing-a-self-signed-certificate-as-a-trusted-root-ca-in-windows-vista/). 166 | 167 | ### Installation in SharePoint site collection 168 | 169 | Live reload client script can be installed within SharePoint by referencing `live-reload.client.js`. 170 | By default, the path to the client is following: `http://localhost:3000/s/live-reload.client.js`. 171 | 172 | ```html 173 | 174 | ``` 175 | 176 | The client also can be delivered to SharePoint as a site collection script source custom action by using gulp task: 177 | 178 | ```bash 179 | gulp live-reload-install 180 | ``` 181 | 182 | Source: 183 | 184 | ```javascript 185 | // ... 186 | 187 | gulp.task("live-reload-install", function () { 188 | console.log("Installing live reload to site collection."); 189 | var liveReload = new LiveReload(config); 190 | liveReload.provisionMonitoringAction() 191 | .then(() => { 192 | console.log("Custom action has been installed"); 193 | }) 194 | .catch((err) => { 195 | console.log(err.message); 196 | }); 197 | }); 198 | ``` 199 | 200 | To delete such a custom action another gulp task can be used: 201 | 202 | ```bash 203 | gulp live-reload-unistall 204 | ``` 205 | 206 | Source: 207 | 208 | ```javascript 209 | // ... 210 | 211 | gulp.task("live-reload-unistall", function () { 212 | console.log("Retracting live reload from site collection."); 213 | var liveReload = new LiveReload(liveReloadConfig); 214 | liveReload.retractMonitoringAction() 215 | .then(() => { 216 | console.log("Custom action has been retracted"); 217 | }) 218 | .catch((err) => { 219 | console.log(err.message); 220 | }); 221 | }); 222 | ``` 223 | 224 | ### Usage scenarious 225 | 226 | #### General 227 | 228 | Live reload feature during active development stage on DEV environment. 229 | The manual monitoring script encapsulation is recommended on a specific page while the process of coding and debugging. 230 | 231 | #### Device-specific 232 | 233 | There are cases then a page/view should be running on a specific device, let's say iPad and Safari. 234 | For sure, an emulator can be used. But sometimes only the real device can show a behavior. 235 | Live reload with shared monitoring server can provide instantaneous reloading feature on a device. 236 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sp-live-reload", 3 | "version": "4.0.0", 4 | "description": "SharePoint pages live reload module for client side development", 5 | "main": "./dist/index.js", 6 | "typings": "./dist/index", 7 | "scripts": { 8 | "build": "rm -rf ./dist && npm run lint && tsc -p . && webpack", 9 | "lint": "tslint -p .", 10 | "test": "cd test && npm install && gulp test-watch", 11 | "analyze": "source-map-explorer ./dist/static/live-reload.client.*" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/koltyakov/sp-live-reload.git" 16 | }, 17 | "keywords": [ 18 | "sharepoint", 19 | "livereload", 20 | "frontend", 21 | "assets", 22 | "debug" 23 | ], 24 | "author": "Andrew Koltyakov ", 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/koltyakov/sp-live-reload/issues" 28 | }, 29 | "homepage": "https://github.com/koltyakov/sp-live-reload#readme", 30 | "dependencies": { 31 | "express": "^4.17.1", 32 | "node-sp-auth": "^3.0.1", 33 | "socket.io": "^2.3.0", 34 | "socket.io-client": "^2.3.0", 35 | "sp-request": "^3.0.0", 36 | "spsave": "^4.0.0" 37 | }, 38 | "devDependencies": { 39 | "@types/express": "^4.17.6", 40 | "@types/node": "^14.0.14", 41 | "@types/sharepoint": "^2016.1.8", 42 | "@types/socket.io": "^2.1.8", 43 | "@types/socket.io-client": "^1.4.33", 44 | "awesome-typescript-loader": "^5.2.1", 45 | "es6-promise": "^4.2.8", 46 | "source-map-explorer": "^2.4.2", 47 | "terser-webpack-plugin": "^3.0.6", 48 | "ts-node": "^8.10.2", 49 | "tslint": "^6.1.2", 50 | "typescript": "^3.9.6", 51 | "webpack": "^4.43.0", 52 | "webpack-cli": "^3.3.12", 53 | "whatwg-fetch": "^3.1.0" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import * as socketIOClient from 'socket.io-client'; 2 | 3 | import { ILRClientSettings, ILREmittedObject } from './interfaces'; 4 | 5 | export class LiveReloadClient { 6 | 7 | private settings: ILRClientSettings; 8 | private devBaseUrl: string; 9 | private webUrl: string; 10 | 11 | private fetchOptions: RequestInit = { 12 | method: 'GET', 13 | credentials: 'same-origin', 14 | headers: { 15 | accept: 'application/json;odata=verbose' 16 | } 17 | }; 18 | 19 | constructor (settings: ILRClientSettings) { 20 | this.settings = { 21 | ...settings, 22 | protocol: settings.protocol || location.protocol.replace(':', ''), 23 | host: settings.host || 'localhost', 24 | port: settings.port || 3000 25 | }; 26 | 27 | this.devBaseUrl = `${this.settings.protocol}://${this.settings.host}:${this.settings.port}` 28 | .replace(':80', '').replace(':443', ''); 29 | 30 | this.webUrl = (_spPageContextInfo.webServerRelativeUrl + '/').replace(/\/\//g, '/'); 31 | } 32 | 33 | public init = (): void => { 34 | let pageResources: string[] = []; 35 | let pageStyles: HTMLLinkElement[] = []; 36 | 37 | const socket = socketIOClient.connect(this.devBaseUrl, { 38 | reconnectionDelay: 1000, 39 | reconnection: true, 40 | reconnectionAttempts: 25, 41 | transports: ['websocket'], 42 | agent: false, 43 | upgrade: false, 44 | rejectUnauthorized: false 45 | }); 46 | 47 | this.getPageResources().then(res => { 48 | pageResources = res; 49 | }); 50 | 51 | const styles: HTMLLinkElement[] = Array.prototype.slice.call(document.getElementsByTagName('link')); 52 | pageStyles = styles 53 | .filter(s => s.href.length > 0); 54 | 55 | socket.on('live_reload', (data: ILREmittedObject) => { 56 | const mapRegexp = /\.map$/i; 57 | let filePath = (data.filePath || '').split('?')[0].toLowerCase(); 58 | filePath = filePath.replace(mapRegexp, ''); 59 | if (pageResources.indexOf(filePath) !== -1) { 60 | if (filePath.indexOf('.css') === filePath.length - 4) { 61 | pageStyles.forEach(s => { 62 | const hostname = location.href.split('/').splice(0, 3).join('/'); 63 | let resourceUrl = s.href.split('?')[0].toLowerCase(); 64 | if (resourceUrl.indexOf('http') === 0) { 65 | resourceUrl = resourceUrl.replace(hostname, ''); 66 | } 67 | if (resourceUrl === filePath) { 68 | if (typeof data.body === 'undefined' || data.body === null) { 69 | const href = `${resourceUrl}?ver=${(new Date()).getTime()}`; 70 | s.href = href; 71 | } else { 72 | let styleElement = document.getElementById(resourceUrl); 73 | if (styleElement === null) { 74 | styleElement = document.createElement('style'); 75 | styleElement.id = resourceUrl; 76 | styleElement.setAttribute('type', 'text/css'); 77 | styleElement.innerHTML = data.body; 78 | (s as any).after(styleElement); 79 | s.remove(); 80 | } else { 81 | styleElement.innerHTML = data.body; 82 | s.remove(); 83 | } 84 | } 85 | } 86 | }); 87 | } else { 88 | window.location.reload(); 89 | } 90 | } 91 | }); 92 | 93 | socket.on('reconnect_attempt', () => { 94 | socket.io.opts.transports = ['polling', 'websocket']; 95 | }); 96 | } 97 | 98 | private getPageResources = async (): Promise => { 99 | let contentLinks: string[] = []; 100 | let endpoint: string; 101 | const basePath: string = `${window.location.protocol}//${window.location.hostname}`; 102 | 103 | // JavaScripts 104 | const scripts: HTMLScriptElement[] = Array.prototype.slice.call(document.getElementsByTagName('script')); 105 | contentLinks = contentLinks.concat( 106 | scripts 107 | .filter(s => s.src.length > 0) 108 | .map(s => { 109 | return decodeURIComponent(s.src.replace(basePath, '').split('?')[0].toLowerCase()); 110 | }) 111 | ); 112 | 113 | // CSS Styles 114 | const styles: HTMLLinkElement[] = Array.prototype.slice.call(document.getElementsByTagName('link')); 115 | contentLinks = contentLinks.concat( 116 | styles 117 | .filter(s => s.href.length > 0) 118 | .map(s => { 119 | return decodeURIComponent(s.href.replace(basePath, '').split('?')[0].toLowerCase()); 120 | }) 121 | ); 122 | 123 | // Masterpage URL 124 | endpoint = `${this.webUrl}_api/web?$select=MasterUrl`; 125 | await fetch(endpoint, this.fetchOptions) 126 | .then(r => r.json()) 127 | .then(({ d: res }) => { 128 | contentLinks.push(res.MasterUrl.split('?')[0].toLowerCase()); 129 | }); 130 | 131 | // Layout URL 132 | if (_spPageContextInfo && _spPageContextInfo.pageListId && _spPageContextInfo.pageItemId) { 133 | endpoint = `${this.webUrl}_api/web/lists('${_spPageContextInfo.pageListId}')/items(${_spPageContextInfo.pageItemId})`; 134 | await fetch(endpoint, this.fetchOptions) 135 | .then(r => r.json()) 136 | .then(({ d: res }) => { 137 | if (res.PublishingPageLayout) { 138 | let layoutUrl: string = res.PublishingPageLayout.Url; 139 | layoutUrl = '/_catalogs' + layoutUrl.split('/_catalogs')[1]; 140 | layoutUrl = layoutUrl.split('?')[0].toLowerCase(); 141 | contentLinks.push(decodeURIComponent(layoutUrl)); 142 | } 143 | }); 144 | } 145 | 146 | // Webparts sources 147 | if (location.pathname.toLowerCase().indexOf('/_layouts/15/') === -1) { 148 | endpoint = `${this.webUrl}_api/web/getFileByServerRelativeUrl('${location.pathname}')/` + 149 | `getLimitedWebPartManager(scope=1)/webparts?$select=WebPart/Properties/ContentLink&$expand=WebPart/Properties`; 150 | 151 | await fetch(endpoint, this.fetchOptions) 152 | .then(r => r.json()) 153 | .then(({ d }) => { 154 | contentLinks = contentLinks.concat( 155 | d.results 156 | .filter(w => w.WebPart.Properties.ContentLink) 157 | .map(w => { 158 | return w.WebPart.Properties.ContentLink.split('?')[0].toLowerCase(); 159 | }) 160 | ); 161 | }); 162 | } 163 | 164 | return contentLinks; 165 | } 166 | } 167 | 168 | ExecuteOrDelayUntilBodyLoaded(() => { 169 | let settings: any = '##settings#'; // Is generated automatically 170 | if (typeof settings === 'string') { 171 | settings = {}; 172 | } 173 | new LiveReloadClient(settings).init(); 174 | }); 175 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { ILRSettings, ISSLConf } from './interfaces'; 2 | export { LiveReload } from './server'; 3 | export { ReloadProvisioning } from './utils/provisioning'; 4 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | import { IAuthOptions } from 'node-sp-auth'; 2 | 3 | export interface ISSLConf { 4 | key: string; 5 | cert: string; 6 | } 7 | 8 | export interface ILRSettings { 9 | siteUrl: string; 10 | spFolder: string; 11 | watchBase: string; 12 | creds: IAuthOptions; 13 | ssl?: ISSLConf; 14 | port?: number; 15 | host?: string; 16 | protocol?: 'https' | 'http'; 17 | } 18 | 19 | export interface ILRClientSettings { 20 | protocol?: string; 21 | host?: string; 22 | port?: number; 23 | } 24 | 25 | export interface ILREmittedObject { 26 | filePath: string; 27 | body?: string; 28 | } 29 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as express from 'express'; 4 | import * as socketIOServer from 'socket.io'; 5 | import * as http from 'http'; 6 | import * as https from 'https'; 7 | 8 | import { ILRSettings, ILREmittedObject } from './interfaces'; 9 | 10 | export class LiveReload { 11 | 12 | public settings: ILRSettings; 13 | private io: socketIOServer.Server; 14 | private liveReloadClientContent: string; 15 | 16 | public constructor (settings: ILRSettings) { 17 | this.settings = { 18 | ...settings, 19 | port: typeof settings.port !== 'undefined' ? settings.port : 3000, 20 | host: typeof settings.host !== 'undefined' ? settings.host : 'localhost', 21 | protocol: typeof settings.protocol !== 'undefined' ? settings.protocol : 22 | (settings.siteUrl || '').toLowerCase().indexOf('https://') === 0 ? 'https' : 'http' 23 | }; 24 | } 25 | 26 | // Triggers file update emition to the client 27 | public emitUpdatedPath (filePath: string, raw: boolean = false, body?: string) { 28 | if (!raw) { 29 | let spRelUrl: string = `${this.settings.siteUrl}/${this.settings.spFolder.replace(/\\/g, '/')}`; 30 | spRelUrl = spRelUrl.replace('://', '').replace(this.settings.siteUrl.replace('://', '').split('/')[0], '').replace(/\/\//g, '/'); 31 | filePath = filePath.replace(path.resolve(this.settings.watchBase), spRelUrl).replace(/\\/g, '/').replace(/\/\//g, '/'); 32 | filePath = decodeURIComponent(filePath).toLowerCase(); 33 | } 34 | this.io.emit('live_reload', { filePath, body } as unknown as ILREmittedObject); 35 | } 36 | 37 | // Init live reload server 38 | public runServer () { 39 | const app = express(); 40 | const staticRouter = express.Router(); 41 | staticRouter.get('/*', (req, res) => { 42 | if (req.url.indexOf('/socket.io') !== -1) { 43 | const staticFilePath = path.join(process.cwd(), 'node_modules', '/socket.io-client/dist', req.url.split('?')[0]); 44 | res.sendFile(staticFilePath); 45 | return; 46 | } else if (req.url.indexOf('/live-reload.client.js') !== -1) { 47 | if (typeof this.liveReloadClientContent === 'undefined') { 48 | const liveReloadClientPath = path.join(__dirname, '/static', req.url.split('?')[0]); 49 | const confString: string = JSON.stringify({ 50 | protocol: this.settings.protocol, 51 | host: this.settings.host, 52 | port: this.settings.port 53 | }); 54 | this.liveReloadClientContent = String(fs.readFileSync(liveReloadClientPath)) 55 | .replace(`"##settings#"`, confString).replace(`'##settings#'`, confString); 56 | } 57 | res.setHeader('content-type', 'text/javascript'); 58 | res.send(this.liveReloadClientContent); 59 | return; 60 | } else { 61 | const staticRoot = path.join(__dirname, '/static'); 62 | res.sendFile(path.join(staticRoot, req.url.split('?')[0])); 63 | return; 64 | } 65 | }); 66 | app.use('/s', staticRouter); 67 | 68 | let server: http.Server | https.Server; 69 | if (this.settings.protocol === 'https') { 70 | if (typeof this.settings.ssl === 'undefined') { 71 | log('Error: No SSL settings provided!'); 72 | return; 73 | } 74 | const options = { 75 | key: fs.readFileSync(this.settings.ssl.key), 76 | cert: fs.readFileSync(this.settings.ssl.cert) 77 | }; 78 | server = https.createServer(options, app); 79 | } else { 80 | server = new http.Server(app); 81 | } 82 | this.io = socketIOServer(server); 83 | server.listen(this.settings.port, this.settings.host, () => { 84 | const address = `${this.settings.protocol}://${this.settings.host}:${this.settings.port}`; 85 | log(`Live reload server is up and running at ${address}`); 86 | if (this.settings.protocol === 'https') { 87 | log('Make sure that:'); 88 | log(` - monitoring script (${address}/s/live-reload.client.js) is provisioned to SharePoint.`); 89 | log(` - SSL certificate is trusted in the browser.`); 90 | } else { 91 | log(`Make sure that monitoring script (${address}/s/live-reload.client.js) is provisioned to SharePoint.`); 92 | } 93 | }); 94 | } 95 | 96 | } 97 | 98 | // tslint:disable-next-line: no-console 99 | const log = console.log; 100 | -------------------------------------------------------------------------------- /src/utils/polyfills.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line: no-implicit-dependencies 2 | import 'es6-promise/auto'; 3 | // tslint:disable-next-line: no-implicit-dependencies 4 | import 'whatwg-fetch'; 5 | 6 | // Vanilla JS after prototype 7 | ((arr) => { 8 | arr.forEach((item) => { 9 | if (item.hasOwnProperty('after')) { 10 | return; 11 | } 12 | Object.defineProperty(item, 'after', { 13 | configurable: true, 14 | enumerable: true, 15 | writable: true, 16 | value: (...args) => { 17 | const docFrag = document.createDocumentFragment(); 18 | args.forEach((argItem) => { 19 | const isNode = argItem instanceof Node; 20 | docFrag.appendChild(isNode ? argItem : document.createTextNode(String(argItem))); 21 | }); 22 | this.parentNode.insertBefore(docFrag, this.nextSibling); 23 | } 24 | }); 25 | }); 26 | })([Element.prototype, CharacterData.prototype, DocumentType.prototype]); 27 | -------------------------------------------------------------------------------- /src/utils/provisioning.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import { spsave } from 'spsave'; 4 | import * as spauth from 'node-sp-auth'; 5 | import * as sprequest from 'sp-request'; 6 | 7 | import { ILRSettings } from '../interfaces'; 8 | 9 | export class ReloadProvisioning { 10 | 11 | private ctx: ILRSettings; 12 | private spr: sprequest.ISPRequest; 13 | 14 | constructor (settings: ILRSettings) { 15 | this.ctx = { 16 | ...settings, 17 | port: typeof settings.port !== 'undefined' ? settings.port : 3000, 18 | host: typeof settings.host !== 'undefined' ? settings.host : 'localhost', 19 | protocol: typeof settings.protocol !== 'undefined' ? settings.protocol : 20 | (settings.siteUrl || '').toLowerCase().indexOf('https://') === 0 ? 'https' : 'http' 21 | }; 22 | this.spr = this.getCachedRequest(); 23 | } 24 | 25 | public getUserCustomActions (): Promise { 26 | return new Promise((resolve, reject) => { 27 | this.spr = this.getCachedRequest(); 28 | this.spr.get(`${this.ctx.siteUrl}/_api/site/usercustomactions`) 29 | .then((response: any) => { 30 | resolve(response.body.d.results); 31 | }); 32 | }); 33 | } 34 | 35 | public getSiteData (): Promise { 36 | return new Promise((resolve, reject) => { 37 | this.spr = this.getCachedRequest(); 38 | this.spr.get(`${this.ctx.siteUrl}/_api/site`) 39 | .then((response: any) => { 40 | resolve(response.body.d); 41 | }); 42 | }); 43 | } 44 | 45 | public provisionMonitoringAction (): Promise { 46 | return new Promise((resolve, reject) => { 47 | const devBaseUrl = `${this.ctx.protocol}://${this.ctx.host}:${this.ctx.port}` 48 | .replace(':80', '').replace(':443', ''); 49 | this.getSiteData() 50 | .then((data) => { 51 | return this.deployClientScript(data.Url); 52 | }) 53 | .then(() => { 54 | return this.getUserCustomActions(); 55 | }) 56 | .then((customActions) => { 57 | const cas = customActions.filter((ca) => { 58 | return ca.Title === 'LiveReloadCustomAction'; 59 | }); 60 | if (cas.length === 0) { 61 | resolve(this.provisionCustomAction()); 62 | } else { 63 | reject({ 64 | message: 'Warning: Live Reload custom action has already been deployed. Skipped.' 65 | }); 66 | } 67 | }); 68 | }); 69 | } 70 | 71 | public retractMonitoringAction (): Promise { 72 | return this.getUserCustomActions() 73 | .then((customActions) => { 74 | customActions.forEach((ca) => { 75 | if (ca.Title === 'LiveReloadCustomAction') { 76 | return this.deleteCustomAction(ca.Id); 77 | } 78 | }); 79 | }); 80 | } 81 | 82 | private deployClientScript (siteCollectionUrl?: string): Promise { 83 | let fileContent = String(fs.readFileSync(path.join(__dirname, '/../', '/static/live-reload.client.js'))); 84 | fileContent = fileContent.replace( 85 | '"##settings#"', 86 | JSON.stringify({ 87 | protocol: this.ctx.protocol, 88 | host: this.ctx.host, 89 | port: this.ctx.port 90 | }) 91 | ); 92 | const core = { 93 | siteUrl: siteCollectionUrl || this.ctx.siteUrl, 94 | flatten: false, 95 | checkin: true, 96 | checkinType: 1 97 | }; 98 | const fileOptions = { 99 | fileName: 'live-reload.client.js', 100 | fileContent, 101 | folder: '_catalogs/masterpage/spf/dev' 102 | }; 103 | return spsave(core, this.ctx.creds, fileOptions) as any; 104 | } 105 | 106 | private provisionCustomAction (): Promise { 107 | this.spr = this.getCachedRequest(); 108 | const reqBody = { 109 | '__metadata': { 110 | 'type': 'SP.UserCustomAction' 111 | }, 112 | 'Title': 'LiveReloadCustomAction', 113 | 'Location': 'ScriptLink', 114 | 'Description': 'Live Reload Custom Action', 115 | 'ScriptSrc': '~sitecollection/_catalogs/masterpage/spf/dev/live-reload.client.js', 116 | 'Sequence': '10000' 117 | }; 118 | 119 | return this.spr.requestDigest(this.ctx.siteUrl) 120 | .then((digest) => { 121 | return this.spr.post(`${this.ctx.siteUrl}/_api/site/usercustomactions`, { 122 | headers: { 123 | 'X-RequestDigest': digest, 124 | 'Accept': 'application/json; odata=verbose', 125 | 'Content-Type': 'application/json; odata=verbose' 126 | }, 127 | body: reqBody 128 | }); 129 | }) as any; 130 | } 131 | 132 | private deleteCustomAction (customActionId): Promise { 133 | this.spr = this.getCachedRequest(); 134 | return this.spr.requestDigest(this.ctx.siteUrl) 135 | .then((digest) => { 136 | return this.spr.post(`${this.ctx.siteUrl}/_api/site/usercustomactions('${customActionId}')`, { 137 | headers: { 138 | 'X-RequestDigest': digest, 139 | 'X-HTTP-Method': 'DELETE' 140 | } 141 | }); 142 | }) 143 | .then((response) => { 144 | return response.body; 145 | }) as any; 146 | } 147 | 148 | private getAuthOptions (): Promise { 149 | return spauth.getAuth(this.ctx.siteUrl, this.ctx.creds) as any; 150 | } 151 | 152 | private getCachedRequest (): sprequest.ISPRequest { 153 | return this.spr || sprequest.create(this.ctx.creds); 154 | } 155 | 156 | } 157 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /test/assets/cewp.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hello Live Reload -------------------------------------------------------------------------------- /test/assets/script.js: -------------------------------------------------------------------------------- 1 | var d = new Date(); 2 | console.log('Refreshed at ' + d.toTimeString()); -------------------------------------------------------------------------------- /test/assets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: green; 3 | } 4 | -------------------------------------------------------------------------------- /test/gulpfile.js: -------------------------------------------------------------------------------- 1 | const gulp = require('gulp'); 2 | const spsave = require("gulp-spsave"); 3 | const watch = require('gulp-watch'); 4 | const through = require('through2'); 5 | const path = require('path'); 6 | const AuthConfig = require('node-sp-auth-config').AuthConfig; 7 | 8 | const LiveReload = require('../dist'); 9 | 10 | const authConfig = new AuthConfig({ 11 | configPath: path.resolve('../config/private.json'), 12 | encryptPassword: true, 13 | saveConfigOnDisk: true 14 | }); 15 | 16 | gulp.task("test-watch", () => { 17 | console.log("Watch with reload is initiated."); 18 | console.log("Make sure that monitoring script is provisioned to SharePoint."); 19 | 20 | authConfig.getContext() 21 | .then(context => { 22 | const watchBase = 'assets'; 23 | const spFolder = '_catalogs/masterpage/spf/assets'; 24 | let liveReloadOptions = { 25 | siteUrl: context.siteUrl, 26 | creds: context.authOptions, 27 | watchBase: watchBase, 28 | spFolder: spFolder 29 | }; 30 | let spSaveCoreOptions = { 31 | siteUrl: context.siteUrl, 32 | folder: spFolder, 33 | flatten: false, 34 | checkin: true, 35 | checkinType: 1 36 | }; 37 | const liveReload = new LiveReload(liveReloadOptions); 38 | liveReload.runServer(); 39 | return watch(`${watchBase}/**/*.*`, (event) => { 40 | console.log(event.path); 41 | gulp 42 | .src(event.path, { 43 | base: watchBase 44 | }) 45 | .pipe(spsave(spSaveCoreOptions, context.authOptions)) 46 | .pipe(through.obj((chunk, enc, cb) => { 47 | var chunkPath = chunk.path; 48 | console.log('emitted:', chunkPath); 49 | liveReload.emitUpdatedPath(chunkPath); 50 | cb(null, chunk); 51 | })); 52 | }); 53 | }) 54 | .catch(error => { 55 | console.log(error); 56 | }); 57 | 58 | }); 59 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sp-live-reload-test", 3 | "version": "1.0.0", 4 | "description": "SharePoint pages live reload module for client side development - quick test", 5 | "devDependencies": { 6 | "gulp": "^3.9.1", 7 | "gulp-spsave": "^3.0.0", 8 | "gulp-watch": "^4.3.11", 9 | "node-sp-auth-config": "^1.0.9", 10 | "through2": "^2.0.3" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "lib": ["es2017", "dom"], 6 | "sourceMap": true, 7 | "declaration": true, 8 | "moduleResolution": "node", 9 | "noImplicitAny": false, 10 | "removeComments": true, 11 | "newLine": "LF", 12 | "skipLibCheck": true, 13 | "types": ["node", "sharepoint"], 14 | "outDir": "dist" 15 | }, 16 | "include": [ 17 | "src/**/*.ts", 18 | "tests/**/*.ts" 19 | ], 20 | "exclude": [ 21 | "node_modules", 22 | "dist", 23 | "test" 24 | ] 25 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:latest", 3 | "rules": { 4 | "semicolon": [true, "always", "ignore-interfaces"], 5 | "no-submodule-imports": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | const webpack = require('webpack'); 4 | const { resolve } = require('path'); 5 | const TerserPlugin = require('terser-webpack-plugin'); 6 | 7 | const sourceMap = false; 8 | 9 | const config = { 10 | mode: 'production', 11 | entry: [ './src/utils/polyfills', './src/client' ], 12 | output: { 13 | path: resolve(process.cwd(), './dist/static'), 14 | filename: 'live-reload.client.js' 15 | }, 16 | cache: false, 17 | devtool: sourceMap ? 'source-map' : 'none', 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.ts(x?)$/, 22 | exclude: /(node_modules|dist)/, 23 | use: [ 'awesome-typescript-loader' ] 24 | } 25 | ] 26 | }, 27 | optimization: { 28 | minimizer: [ 29 | new TerserPlugin({ 30 | cache: true, 31 | parallel: true, 32 | sourceMap: false, 33 | extractComments: 'all' 34 | }) 35 | ] 36 | }, 37 | plugins: [ 38 | new webpack.DefinePlugin({ 39 | 'process.env': { 40 | 'NODE_ENV': JSON.stringify('production') 41 | } 42 | }) 43 | ], 44 | resolve: { 45 | extensions: [ '.ts', '.js' ] 46 | } 47 | }; 48 | 49 | module.exports = config; --------------------------------------------------------------------------------