├── .gitignore ├── .npmignore ├── README.md ├── lib ├── client │ └── client.ts ├── common.ts └── server │ └── server.ts ├── package.json ├── tsconfig.json └── tsd.json /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | typings 4 | 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | ._* 3 | .DS_Store 4 | .git 5 | .hg 6 | .npmrc 7 | .lock-wscript 8 | .svn 9 | .wafpickle-* 10 | config.gypi 11 | CVS 12 | npm-debug.log 13 | 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Note that the project is in very early stage of development. It is still not ready for usage but you can give it a try and share your feedback.** 2 | 3 | # Angular2 Hot Loader 4 | 5 | Hot loader for Angular 2, inspired by [react-hot-loader](https://github.com/gaearon/react-hot-loader). 6 | 7 | [![](http://s12.postimg.org/49uakspe5/Screen_Shot_2015_10_26_at_01_50_48.png)](https://www.youtube.com/watch?v=S9pKbi3WrCM) 8 | 9 | ## How to use? 10 | 11 | ``` 12 | npm install angular2-hot-loader 13 | ``` 14 | 15 | You can start the hot loader server by: 16 | 17 | ```ts 18 | import * as ng2HotLoader from 'angular2-hot-loader'; 19 | 20 | ng2HotLoader.listen({ 21 | port: 4412, 22 | projectRoot: __dirname 23 | }); 24 | ``` 25 | 26 | Somewhere inside of your templates add: 27 | 28 | ```ts 29 | System.import('//localhost:4412/ng2-hot-loader') 30 | .then(module => { 31 | module.ng2HotLoaderBootstrap(AppCmp, [PROVIDERS]); 32 | }); 33 | ``` 34 | 35 | Now you can watch your file system with any module you feel comfortable with. Once you detect a change in the target files use: 36 | 37 | ```ts 38 | ng2HotLoader.onChange([fileName]); 39 | ``` 40 | 41 | Now on each edit the changes should be pushed to the client. 42 | 43 | ## Roadmap 44 | 45 | - [x] Update the component's inline templates 46 | - [x] Update the component's external templates 47 | - [x] Update the component's altered methods 48 | - [x] Update the component's removed methods 49 | - [x] Allow definition of new components 50 | - [x] Update the component's metadata 51 | - [ ] Update the component's constructor on change 52 | - [ ] For components declared in the `directives` array 53 | - [ ] For components declared in the `@RouteConfig` definition 54 | - [ ] Preserve the state of the components (i.e. the values of the bindings) 55 | - [ ] Preserve the instantiated tokens in the element injectors 56 | 57 | ## Features 58 | 59 | - Add new methods to existing components 60 | - Clean removed methods from existing components 61 | - Support changes of external and inline templates 62 | - Allows adding inputs and outputs (events and properties) to the components 63 | 64 | ## Limitations 65 | 66 | - Does not push changes in services & pipes 67 | - Does not update component's constructor 68 | 69 | # License 70 | 71 | MIT 72 | 73 | -------------------------------------------------------------------------------- /lib/client/client.ts: -------------------------------------------------------------------------------- 1 | import {isPresent} from 'angular2/src/facade/lang'; 2 | import { 3 | BROWSER_PROVIDERS, 4 | } from 'angular2/src/platform/browser_common'; 5 | import { 6 | BROWSER_APP_PROVIDERS 7 | } from 'angular2/platform/browser'; 8 | 9 | import {ReflectionCapabilities} from 'angular2/src/core/reflection/reflection_capabilities'; 10 | 11 | import { 12 | PlatformRef, 13 | ComponentMetadata, 14 | ViewMetadata, 15 | DynamicComponentLoader, 16 | ApplicationRef, 17 | Type, 18 | provide, 19 | Injector, 20 | Injectable, 21 | ComponentRef, 22 | platform, 23 | reflector 24 | } from 'angular2/core'; 25 | 26 | import {RouteRegistry} from 'angular2/router'; 27 | 28 | import {TemplateCompiler} from 'angular2/src/compiler/template_compiler'; 29 | import {ViewResolver} from 'angular2/src/core/linker/view_resolver'; 30 | import {AppView} from 'angular2/src/core/linker/view'; 31 | import {AppElement} from 'angular2/src/core/linker/element'; 32 | import {RuntimeMetadataResolver} from 'angular2/src/compiler/runtime_metadata'; 33 | 34 | import {MessageFormat} from '../common'; 35 | 36 | class Node { 37 | children: Node[] = []; 38 | bindings: any; 39 | } 40 | 41 | let proxyFactory = (function () { 42 | let _injector: Injector = null; 43 | let _root: Type = null; 44 | return { 45 | initialize(injector: Injector, rootComponent: Type) { 46 | _injector = injector; 47 | _root = rootComponent; 48 | }, 49 | getProxy(component: Type) { 50 | let proxy: ComponentProxy = _injector.resolveAndInstantiate(provide(ComponentProxy, { useClass: ComponentProxy })); 51 | proxy.update(component); 52 | proxy.setRoot(_root); 53 | return proxy; 54 | } 55 | } 56 | }()); 57 | 58 | @Injectable() 59 | export class ComponentProxy { 60 | private cdInterval: any; 61 | private component: Type; 62 | private root: Type; 63 | private parents: string[] = []; 64 | constructor( 65 | private compiler: TemplateCompiler, 66 | private resolver: ViewResolver, 67 | private app: ApplicationRef, 68 | private loader: DynamicComponentLoader, 69 | private runtimeResolver: RuntimeMetadataResolver 70 | ) {} 71 | 72 | public update(component: Type) { 73 | if (!this.component) { 74 | this.component = component; 75 | return; 76 | } 77 | this.updatePrototype(component); 78 | this.updateMetadata(component); 79 | let annotations = Reflect.getMetadata('annotations', component); 80 | let isComponent = false; 81 | annotations.forEach(a => { 82 | if (a instanceof ComponentMetadata) { 83 | isComponent = true; 84 | return; 85 | } 86 | }); 87 | if (isComponent) { 88 | this.refresh(); 89 | } 90 | } 91 | 92 | public refresh() { 93 | console.log('Patching components'); 94 | this.compiler.clearCache(); 95 | (this.resolver)._cache = new Map(); 96 | (this.runtimeResolver)._cache = new Map(); 97 | let visited; 98 | let visitedViews = new Map(); 99 | let root = new Node(); 100 | function preserveInjectors(view, node) { 101 | if (visitedViews.has(view)) { 102 | return; 103 | } 104 | visitedViews.set(view, true); 105 | let data = []; 106 | view.elementInjectors.forEach(inj => { 107 | const strategy = inj._strategy.injectorStrategy; 108 | const currentData = {}; 109 | for (let prop in strategy) { 110 | if (/^obj\d+/.test(prop)) { 111 | currentData[prop] = strategy[prop]; 112 | } 113 | } 114 | data.push(currentData); 115 | }); 116 | node.bindings = data; 117 | node.children = view.views.map(e => preserveInjectors(e, new Node())); 118 | return node; 119 | } 120 | function restoreInjectors(view, node) { 121 | if (visitedViews.has(view)) { 122 | return; 123 | } 124 | visitedViews.set(view, true); 125 | view.elementInjectors.forEach((inj, i) => { 126 | const strategy = inj._strategy.injectorStrategy; 127 | for (let prop in strategy) { 128 | if (/^obj\d+/.test(prop)) { 129 | strategy[prop] = node.bindings[i][prop]; 130 | } 131 | } 132 | }); 133 | view.views.forEach((v, i) => restoreInjectors(v, node.children[i])); 134 | } 135 | function runChangeDetection(view: any) { 136 | if (visited.has(view) || !view) { 137 | return; 138 | } 139 | console.log(view.allNodes); 140 | visited.set(view, true); 141 | view.changeDetector.detectChanges(); 142 | // view.views.forEach(e => runChangeDetection(e.componentView)); 143 | } 144 | preserveInjectors((this.app)._rootComponents[0].hostView._view, root); 145 | this.app.injector 146 | .get(DynamicComponentLoader).loadAsRoot(this.root, null, this.app.injector) 147 | .then(ref => { 148 | console.log('View patched'); 149 | console.log('Running change detection'); 150 | console.log('-------------------------'); 151 | // TODO remove the interval here 152 | clearInterval(this.cdInterval); 153 | visitedViews = new Map(); 154 | // root.injectors[0]._proto.protoInjector._strategy; 155 | restoreInjectors(ref.hostView._view, root); 156 | this.cdInterval = setInterval(_ => { 157 | let view = ref.hostView._view; 158 | visited = new Map(); 159 | runChangeDetection(view); 160 | }, 100); 161 | }); 162 | } 163 | 164 | public get() { 165 | return this.component; 166 | } 167 | 168 | public setRoot(root: Type) { 169 | this.root = root; 170 | } 171 | 172 | public getParents() { 173 | return this.parents; 174 | } 175 | 176 | public addParent(parentName: string) { 177 | this.parents.push(parentName); 178 | } 179 | 180 | public removeParent(parentName: string) { 181 | this.parents.splice(this.parents.indexOf(parentName), 1); 182 | } 183 | 184 | private updatePrototype(component) { 185 | let currentProto = this.component.prototype; 186 | let newProto = component.prototype; 187 | 188 | // Copy added properties 189 | Object.getOwnPropertyNames(newProto).forEach(name => { 190 | currentProto[name] = newProto[name]; 191 | }); 192 | 193 | // Delete removed properties 194 | Object.getOwnPropertyNames(currentProto).forEach(name => { 195 | if(!newProto.hasOwnProperty(name)) { 196 | delete currentProto[name]; 197 | } 198 | }); 199 | } 200 | 201 | private updateMetadata(component) { 202 | let keys = Reflect.getMetadataKeys(component); 203 | keys.forEach(key => { 204 | let val = Reflect.getMetadata(key, component); 205 | Reflect.defineMetadata(key, val, this.component); 206 | }); 207 | } 208 | } 209 | 210 | 211 | let proxies = new Map(); 212 | 213 | function proxyDirective(cmp: any, parent: any) { 214 | proxies.set(cmp.name, proxyFactory.getProxy(cmp)); 215 | if (parent) { 216 | let proxy = proxies.get(cmp.name); 217 | proxy.addParent(parent.name); 218 | } 219 | let metadata = Reflect.getMetadata('annotations', cmp); 220 | if (!metadata) return; 221 | metadata.forEach(current => { 222 | if ((current instanceof ComponentMetadata || current instanceof ViewMetadata) && 223 | current.directives instanceof Array) { 224 | current.directives.forEach(proxyDirectives.bind(null, cmp)); 225 | } 226 | if (current.constructor && current.constructor.name === 'RouteConfig') { 227 | current.configs.map(c => c.component).forEach(proxyDirectives.bind(null, cmp)); 228 | } 229 | }); 230 | } 231 | 232 | function proxyDirectives(parent, current: Type | any[]) { 233 | if (current instanceof Array) { 234 | current.forEach(proxyDirectives.bind(null, parent)); 235 | } else { 236 | proxyDirective(current, parent); 237 | } 238 | } 239 | 240 | function connect(url) { 241 | return new Promise((resolve, reject) => { 242 | var ws = new WebSocket(url); 243 | ws.onopen = function (e) { 244 | resolve(ws); 245 | }; 246 | }); 247 | } 248 | 249 | function reconnect(url) { 250 | let interval = setInterval(_ => { 251 | connect(url) 252 | .then(ws => { 253 | clearInterval(interval); 254 | initialize(url); 255 | }); 256 | }, 3000); 257 | } 258 | 259 | // TODO move to the proxy class 260 | function updateView(type, data) { 261 | let iter = proxies.values(); 262 | let current = iter.next(); 263 | while (!current.done) { 264 | var proxy = current.value; 265 | var cmp = proxy.get(); 266 | var metadata = Reflect.getOwnMetadata('annotations', cmp); 267 | metadata.forEach(meta => { 268 | if (meta instanceof ComponentMetadata && meta[type]) { 269 | let oldVals = meta[type]; 270 | if (!(oldVals instanceof Array)) { 271 | oldVals = [oldVals]; 272 | } 273 | oldVals.forEach(oldVal => { 274 | var normalizedPath = oldVal.replace(/^\./, ''); 275 | if (data.filename.endsWith(normalizedPath)) { 276 | proxy.refresh(); 277 | } 278 | }); 279 | } 280 | }); 281 | current = iter.next(); 282 | } 283 | } 284 | 285 | function updateDirectiveDefinition(directiveName, newDefinition) { 286 | let proxy = proxies.get(directiveName); 287 | let component = proxy.get(); 288 | proxy.getParents().forEach(parent => { 289 | let parentProxy = proxies.get(parent); 290 | let cmp = parentProxy.get(); 291 | let metadata = Reflect.getMetadata('annotations', cmp); 292 | if (!metadata) return; 293 | metadata.forEach(current => { 294 | if ((current instanceof ComponentMetadata || current instanceof ViewMetadata) && 295 | current.directives instanceof Array) { 296 | current.directives = current.directives.filter(directive => { 297 | return directive.name !== directiveName; 298 | }); 299 | current.directives.push(newDefinition); 300 | } 301 | // TODO: Need to reconfigure the routeregistry associated to this component 302 | if (current.constructor && current.constructor.name === 'RouteConfig') { 303 | current.configs.forEach(c => { 304 | if (c.component.name === directiveName) { 305 | c.component = newDefinition; 306 | } 307 | }); 308 | } 309 | }); 310 | }); 311 | } 312 | 313 | function patchDirective(directiveName, newDefinition) { 314 | let proxy = proxies.get(directiveName); 315 | if (!proxy) { 316 | return proxyDirective(newDefinition, null); 317 | } 318 | let component = proxy.get(); 319 | if (!component || component.toString() !== newDefinition.toString()) { 320 | updateDirectiveDefinition(directiveName, newDefinition); 321 | } else { 322 | proxies.get(directiveName).update(newDefinition); 323 | } 324 | } 325 | 326 | function processMessage(data: MessageFormat) { 327 | let filename = data.filename; 328 | if (filename.endsWith('.html')) { 329 | updateView('templateUrl', data); 330 | } else if (filename.endsWith('.css')) { 331 | updateView('styleUrls', data); 332 | } else { 333 | let path = `${location.protocol}//${location.host}/${filename}`; 334 | (System).delete(path); 335 | (System).define(path, data.content) 336 | // (System).transpiler = 'typescript'; 337 | // let baseURL = (System).baseURL.substring(0, (System).baseURL.length - 1); 338 | // if((System).has(baseURL + filename)) { 339 | // (System).delete(baseURL + filename); 340 | // } else { 341 | // (System).delete(filename); 342 | // } 343 | // (System).load(baseURL + filename) 344 | .then(module => { 345 | module = module.module.module; 346 | for (let ex in module) { 347 | if (proxies.has(ex)) { 348 | patchDirective(ex, module[ex]); 349 | } 350 | } 351 | }) 352 | .catch(e => { 353 | console.error(e); 354 | }); 355 | } 356 | } 357 | 358 | let url = 'ws://localhost:<%= PORT %>'; 359 | function initialize(url) { 360 | connect(url) 361 | .then(ws => { 362 | ws.onmessage = function (e) { 363 | let data = JSON.parse(e.data); 364 | try { 365 | processMessage(data); 366 | } catch (e) { 367 | console.error(e); 368 | } 369 | }; 370 | ws.onclose = reconnect.bind(null, url); 371 | }); 372 | } 373 | 374 | export function ng2HotLoaderBootstrap( 375 | appComponentType: Type, 376 | customProviders?: Array): Promise { 377 | 378 | reflector.reflectionCapabilities = new ReflectionCapabilities(); 379 | let appProviders = 380 | isPresent(customProviders) ? [BROWSER_APP_PROVIDERS, customProviders, ComponentProxy] : BROWSER_APP_PROVIDERS; 381 | 382 | let currentPlatform: PlatformRef = platform(BROWSER_PROVIDERS); 383 | let currentApp = currentPlatform.application(appProviders); 384 | let bootstrapped = currentApp.bootstrap(appComponentType); 385 | 386 | proxyFactory.initialize(currentApp.injector, appComponentType); 387 | proxyDirectives(null, appComponentType); 388 | 389 | initialize(url); 390 | 391 | return bootstrapped; 392 | }; 393 | -------------------------------------------------------------------------------- /lib/common.ts: -------------------------------------------------------------------------------- 1 | export interface MessageFormat { 2 | type: string; 3 | filename: string; 4 | content?: string; 5 | } -------------------------------------------------------------------------------- /lib/server/server.ts: -------------------------------------------------------------------------------- 1 | import * as _ from 'underscore'; 2 | import * as http from 'http'; 3 | import * as fs from 'fs'; 4 | import {MessageFormat} from '../common'; 5 | 6 | let sockets = []; 7 | let join = require('path').join; 8 | let express = require('express'); 9 | let app = express(); 10 | let tsc = require('typescript'); 11 | let debug = require('debug')('angular2-hot-loader:server'); 12 | 13 | let server = http.createServer(app); 14 | let WebSocketServer = require('ws').Server; 15 | let wss = new WebSocketServer({ 16 | server: server 17 | }); 18 | 19 | let config: Options = { 20 | port: 5578, 21 | path: 'ng2-hot-loader.js', 22 | processPath: path => path 23 | }; 24 | 25 | export interface Options { 26 | port?: number; 27 | path?: string; 28 | processPath?: Function; 29 | } 30 | 31 | export function listen(localConfig?: Options) { 32 | localConfig = localConfig || config; 33 | config.port = localConfig.port || config.port; 34 | config.path = localConfig.path || config.path; 35 | config.processPath = localConfig.processPath; 36 | server.listen(config.port); 37 | debug('Angular 2 Hot Loader is listening on port', config.port); 38 | } 39 | 40 | export function onChange(files: string[]) { 41 | files.forEach((file: string) => { 42 | let toSend = getPackage(file); 43 | sockets.forEach(function (socket) { 44 | socket.send(JSON.stringify(toSend)); 45 | }); 46 | }); 47 | } 48 | 49 | app.use(function(req, res, next) { 50 | res.header('Access-Control-Allow-Origin', '*'); 51 | next(); 52 | }); 53 | 54 | function getPackage(file: string) { 55 | let toSend: MessageFormat = { 56 | type: 'update', 57 | filename: config.processPath(file) 58 | }; 59 | if (file.endsWith('.ts')) { 60 | toSend.content = processFileContent(fs.readFileSync(file).toString(), file); 61 | } 62 | return toSend; 63 | } 64 | 65 | function serveHotLoaderRoot(req, res) { 66 | let filePath = join(__dirname, '..', 'client', 'client.js'); 67 | let fileContent = fs.readFileSync(filePath).toString(); 68 | fileContent = _.template(fileContent)({ 69 | PORT: config.port 70 | }); 71 | res.end(fileContent); 72 | } 73 | app.get('*', serveHotLoaderRoot); 74 | 75 | 76 | wss.on('connection', function connection(ws) { 77 | sockets.push(ws); 78 | ws.on('message', function incoming(message) { 79 | console.log('received: %s', message); 80 | }); 81 | ws.on('close', function close() { 82 | sockets.splice(sockets.indexOf(ws), 1); 83 | }); 84 | }); 85 | 86 | function compile(sourceCode) { 87 | let result = tsc.transpile(sourceCode, { module: tsc.ModuleKind.CommonJS }); 88 | return eval(JSON.stringify(result)); 89 | } 90 | 91 | function processFileContent(content: string, filename: string) { 92 | if (filename.endsWith('.js')) { 93 | return `(function(){${content.toString()}}())`; 94 | } else if (filename.endsWith('.ts')) { 95 | return `(function(){${compile(content.toString())}}())`; 96 | } 97 | return content; 98 | } 99 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular2-hot-loader", 3 | "version": "0.0.22", 4 | "keywords": [ 5 | "hot loader", 6 | "loader", 7 | "live load", 8 | "websockets", 9 | "angular", 10 | "angular2" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/mgechev/angular2-hot-loader.git" 15 | }, 16 | "homepage": "https://github.com/mgechev/angular2-hot-loader", 17 | "description": "Angular 2 Hot Loader", 18 | "main": "./dist/server/server.js", 19 | "scripts": { 20 | "build": "tsd install && tsd link && tsc --project .", 21 | "test": "echo \"Error: no test specified\" && exit 1" 22 | }, 23 | "author": "Minko Gechev ", 24 | "license": "MIT", 25 | "peerDependencies": { 26 | "angular2": "2.0.0-beta.13", 27 | "es6-module-loader": "^0.17.8", 28 | "systemjs": "^0.19.4" 29 | }, 30 | "devDependencies": { 31 | "tsd": "^0.6.5", 32 | "typescript": "1.7.3" 33 | }, 34 | "dependencies": { 35 | "debug": "^2.2.0", 36 | "express": "^4.13.3", 37 | "reflect-metadata": "0.1.2", 38 | "underscore": "^1.8.3", 39 | "ws": "^0.8.1" 40 | }, 41 | "typescript": { 42 | "typings": "dist/server/server" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1.7.3", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "commonjs", 6 | "declaration": true, 7 | "noImplicitAny": false, 8 | "removeComments": true, 9 | "noLib": false, 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "sourceMap": false, 13 | "outDir": "dist" 14 | }, 15 | "exclude": [ 16 | "node_modules", 17 | "dist" 18 | ], 19 | "compileOnSave": false 20 | } 21 | -------------------------------------------------------------------------------- /tsd.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v4", 3 | "repo": "borisyankov/DefinitelyTyped", 4 | "ref": "master", 5 | "path": "typings", 6 | "bundle": "typings/tsd.d.ts", 7 | "installed": { 8 | "underscore/underscore.d.ts": { 9 | "commit": "5a8fc5ee71701431e4fdbb80c506e3c13f85a9ff" 10 | }, 11 | "systemjs/systemjs.d.ts": { 12 | "commit": "78ba6e41543e5ababbd1dda19797601be3c1f304" 13 | }, 14 | "es6-shim/es6-shim.d.ts": { 15 | "commit": "ca92a5b250433c38230b5f4138b36453862342bf" 16 | }, 17 | "node/node.d.ts": { 18 | "commit": "ca92a5b250433c38230b5f4138b36453862342bf" 19 | } 20 | } 21 | } 22 | --------------------------------------------------------------------------------