| OLD | NEW |
| 1 // Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file | 1 // Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file |
| 2 // for details. All rights reserved. Use of this source code is governed by a | 2 // for details. All rights reserved. Use of this source code is governed by a |
| 3 // BSD-style license that can be found in the LICENSE file. | 3 // BSD-style license that can be found in the LICENSE file. |
| 4 | 4 |
| 5 library trydart.projectServer; | 5 library dart2js_incremental.server; |
| 6 | 6 |
| 7 import 'dart:io'; | 7 import 'dart:io'; |
| 8 | 8 |
| 9 import 'dart:async' show | 9 import 'dart:async' show |
| 10 Future, | 10 Future, |
| 11 Stream; | 11 Stream; |
| 12 | 12 |
| 13 import 'dart:convert' show | 13 import 'dart:convert' show |
| 14 HtmlEscape, | 14 HtmlEscape, |
| 15 JSON, | 15 JSON, |
| 16 UTF8; | 16 UTF8; |
| 17 | 17 |
| 18 class WatchHandler { | |
| 19 final WebSocket socket; | |
| 20 | |
| 21 final Set<String> watchedFiles; | |
| 22 | |
| 23 static final Set<WatchHandler> handlers = new Set<WatchHandler>(); | |
| 24 | |
| 25 static const Map<int, String> fsEventNames = const <int, String>{ | |
| 26 FileSystemEvent.CREATE: 'create', | |
| 27 FileSystemEvent.DELETE: 'delete', | |
| 28 FileSystemEvent.MODIFY: 'modify', | |
| 29 FileSystemEvent.MOVE: 'move', | |
| 30 }; | |
| 31 | |
| 32 WatchHandler(this.socket, Iterable<String> watchedFiles) | |
| 33 : this.watchedFiles = watchedFiles.toSet(); | |
| 34 | |
| 35 handleFileSystemEvent(FileSystemEvent event) { | |
| 36 if (event.isDirectory) return; | |
| 37 String type = fsEventNames[event.type]; | |
| 38 if (type == null) type = 'unknown'; | |
| 39 String path = new Uri.file(event.path).pathSegments.last; | |
| 40 shouldIgnore(type, path).then((bool ignored) { | |
| 41 if (ignored) return; | |
| 42 socket.add(JSON.encode({type: [path]})); | |
| 43 }); | |
| 44 } | |
| 45 | |
| 46 Future<bool> shouldIgnore(String type, String path) { | |
| 47 switch (type) { | |
| 48 case 'create': | |
| 49 return new Future<bool>.value(!watchedFiles.contains(path)); | |
| 50 case 'delete': | |
| 51 return Conversation.listProjectFiles().then((List<String> files) { | |
| 52 watchedFiles | |
| 53 ..retainAll(files) | |
| 54 ..addAll(files); | |
| 55 return watchedFiles.contains(path); | |
| 56 }); | |
| 57 case 'modify': | |
| 58 return new Future<bool>.value(false); | |
| 59 default: | |
| 60 print('Unhandled fs-event for $path ($type).'); | |
| 61 return new Future<bool>.value(true); | |
| 62 } | |
| 63 } | |
| 64 | |
| 65 onData(_) { | |
| 66 // TODO(ahe): Move POST code here? | |
| 67 } | |
| 68 | |
| 69 onDone() { | |
| 70 handlers.remove(this); | |
| 71 } | |
| 72 | |
| 73 static handleWebSocket(WebSocket socket) { | |
| 74 Conversation.ensureProjectWatcher(); | |
| 75 Conversation.listProjectFiles().then((List<String> files) { | |
| 76 socket.add(JSON.encode({'create': files})); | |
| 77 WatchHandler handler = new WatchHandler(socket, files); | |
| 78 handlers.add(handler); | |
| 79 socket.listen( | |
| 80 handler.onData, cancelOnError: true, onDone: handler.onDone); | |
| 81 }); | |
| 82 } | |
| 83 | |
| 84 static onFileSystemEvent(FileSystemEvent event) { | |
| 85 for (WatchHandler handler in handlers) { | |
| 86 handler.handleFileSystemEvent(event); | |
| 87 } | |
| 88 } | |
| 89 } | |
| 90 | |
| 91 /// Represents a "project" command. These commands are accessed from the URL | |
| 92 /// "/project?name". | |
| 93 class ProjectCommand { | |
| 94 final String name; | |
| 95 | |
| 96 /// For each query parameter, this map describes rules for validating them. | |
| 97 final Map<String, String> rules; | |
| 98 | |
| 99 final Function handle; | |
| 100 | |
| 101 const ProjectCommand(this.name, this.rules, this.handle); | |
| 102 } | |
| 103 | |
| 104 class Conversation { | 18 class Conversation { |
| 105 HttpRequest request; | 19 HttpRequest request; |
| 106 HttpResponse response; | 20 HttpResponse response; |
| 107 | 21 |
| 108 static const String PROJECT_PATH = '/project'; | |
| 109 | |
| 110 static const String PACKAGES_PATH = '/packages'; | 22 static const String PACKAGES_PATH = '/packages'; |
| 111 | 23 |
| 112 static const String CONTENT_TYPE = HttpHeaders.CONTENT_TYPE; | 24 static const String CONTENT_TYPE = HttpHeaders.CONTENT_TYPE; |
| 113 | 25 |
| 114 static const String GIT_TAG = 'try_dart_backup'; | |
| 115 | |
| 116 static const String COMMIT_MESSAGE = """ | |
| 117 Automated backup. | |
| 118 | |
| 119 It is safe to delete tag '$GIT_TAG' if you don't need the backup."""; | |
| 120 | |
| 121 static Uri documentRoot = Uri.base; | 26 static Uri documentRoot = Uri.base; |
| 122 | 27 |
| 123 static Uri projectRoot = Uri.base.resolve('site/try/src/'); | 28 static Uri packageRoot = Uri.base.resolve('packages/'); |
| 124 | |
| 125 static Uri packageRoot = Uri.base.resolve('sdk/lib/_internal/'); | |
| 126 | |
| 127 static const List<ProjectCommand> COMMANDS = const <ProjectCommand>[ | |
| 128 const ProjectCommand('list', const {'list': null}, handleProjectList), | |
| 129 ]; | |
| 130 | |
| 131 static Stream<FileSystemEvent> projectChanges; | |
| 132 | |
| 133 static final Map<String, String> gitEnv = computeGitEnv(); | |
| 134 | 29 |
| 135 Conversation(this.request, this.response); | 30 Conversation(this.request, this.response); |
| 136 | 31 |
| 137 onClosed(_) { | 32 onClosed(_) { |
| 138 if (response.statusCode == HttpStatus.OK) return; | 33 if (response.statusCode == HttpStatus.OK) return; |
| 139 print('Request for ${request.uri} ${response.statusCode}'); | 34 print('Request for ${request.uri} ${response.statusCode}'); |
| 140 } | 35 } |
| 141 | 36 |
| 142 notFound(path) { | 37 notFound(path) { |
| 143 response.statusCode = HttpStatus.NOT_FOUND; | 38 response.statusCode = HttpStatus.NOT_FOUND; |
| (...skipping 11 matching lines...) Expand all Loading... |
| 155 | 50 |
| 156 internalError(error, stack) { | 51 internalError(error, stack) { |
| 157 print(error); | 52 print(error); |
| 158 if (stack != null) print(stack); | 53 if (stack != null) print(stack); |
| 159 response.statusCode = HttpStatus.INTERNAL_SERVER_ERROR; | 54 response.statusCode = HttpStatus.INTERNAL_SERVER_ERROR; |
| 160 response.write(htmlInfo("Internal Server Error", | 55 response.write(htmlInfo("Internal Server Error", |
| 161 "Internal Server Error: $error\n$stack")); | 56 "Internal Server Error: $error\n$stack")); |
| 162 response.close(); | 57 response.close(); |
| 163 } | 58 } |
| 164 | 59 |
| 165 bool validate(Map<String, String> parameters, Map<String, String> rules) { | |
| 166 Iterable<String> problems = rules.keys | |
| 167 .where((name) => !parameters.containsKey(name)) | |
| 168 .map((name) => "Missing parameter: '$name'."); | |
| 169 if (!problems.isEmpty) { | |
| 170 badRequest(problems.first); | |
| 171 return false; | |
| 172 } | |
| 173 Set extra = new Set.from(parameters.keys)..removeAll(rules.keys); | |
| 174 if (extra.isEmpty) return true; | |
| 175 String extraString = (extra.toList()..sort()).join("', '"); | |
| 176 badRequest("Extra parameters: '$extraString'."); | |
| 177 return false; | |
| 178 } | |
| 179 | |
| 180 static Future<List<String>> listProjectFiles() { | |
| 181 String nativeDir = projectRoot.toFilePath(); | |
| 182 Directory dir = new Directory(nativeDir); | |
| 183 var future = dir.list(recursive: true, followLinks: false).toList(); | |
| 184 return future.then((List<FileSystemEntity> entries) { | |
| 185 return entries | |
| 186 .map((e) => e.path) | |
| 187 .where((p) => p.endsWith('.dart') && p.startsWith(nativeDir)) | |
| 188 .map((p) => p.substring(nativeDir.length)) | |
| 189 .map((p) => new Uri.file(p).path).toList(); | |
| 190 }); | |
| 191 } | |
| 192 | |
| 193 static handleProjectList(Conversation self) { | |
| 194 listProjectFiles().then((List<String> files) { | |
| 195 self.response | |
| 196 ..write(JSON.encode(files)) | |
| 197 ..close(); | |
| 198 }); | |
| 199 } | |
| 200 | |
| 201 handleProjectRequest() { | |
| 202 Map<String, String> parameters = request.uri.queryParameters; | |
| 203 for (ProjectCommand command in COMMANDS) { | |
| 204 if (parameters.containsKey(command.name)) { | |
| 205 if (validate(parameters, command.rules)) { | |
| 206 (command.handle)(this); | |
| 207 } | |
| 208 return; | |
| 209 } | |
| 210 } | |
| 211 String commands = COMMANDS.map((c) => c.name).join("', '"); | |
| 212 badRequest("Valid commands are: '$commands'"); | |
| 213 } | |
| 214 | |
| 215 handleSocket() { | 60 handleSocket() { |
| 216 if (request.uri.path == '/ws/watch') { | 61 if (false && request.uri.path == '/ws/watch') { |
| 217 WebSocketTransformer.upgrade(request).then(WatchHandler.handleWebSocket); | 62 WebSocketTransformer.upgrade(request).then((WebSocket socket) { |
| 63 socket.add(JSON.encode({'create': []})); |
| 64 // WatchHandler handler = new WatchHandler(socket, files); |
| 65 // handlers.add(handler); |
| 66 // socket.listen( |
| 67 // handler.onData, cancelOnError: true, onDone: handler.onDone); |
| 68 }); |
| 218 } else { | 69 } else { |
| 219 response.done | 70 response.done |
| 220 .then(onClosed) | 71 .then(onClosed) |
| 221 .catchError(onError); | 72 .catchError(onError); |
| 222 notFound(request.uri.path); | 73 notFound(request.uri.path); |
| 223 } | 74 } |
| 224 } | 75 } |
| 225 | 76 |
| 226 handle() { | 77 handle() { |
| 227 response.done | 78 response.done |
| 228 .then(onClosed) | 79 .then(onClosed) |
| 229 .catchError(onError); | 80 .catchError(onError); |
| 230 | 81 |
| 231 Uri uri = request.uri; | 82 Uri uri = request.uri; |
| 232 if (uri.path == PROJECT_PATH) { | |
| 233 return handleProjectRequest(); | |
| 234 } | |
| 235 if (uri.path.endsWith('/')) { | 83 if (uri.path.endsWith('/')) { |
| 236 uri = uri.resolve('index.html'); | 84 uri = uri.resolve('index.html'); |
| 237 } | 85 } |
| 238 if (uri.path == '/css/fonts/fontawesome-webfont.woff') { | |
| 239 uri = uri.resolve('/fontawesome-webfont.woff'); | |
| 240 } | |
| 241 if (uri.path.contains('..') || uri.path.contains('%')) { | 86 if (uri.path.contains('..') || uri.path.contains('%')) { |
| 242 return notFound(uri.path); | 87 return notFound(uri.path); |
| 243 } | 88 } |
| 244 String path = uri.path; | 89 String path = uri.path; |
| 245 Uri root = documentRoot; | 90 Uri root = documentRoot; |
| 246 String dartType = 'application/dart'; | 91 String dartType = 'application/dart'; |
| 247 if (path.startsWith('/project/packages/')) { | 92 if (path.startsWith('${PACKAGES_PATH}/')) { |
| 248 root = packageRoot; | |
| 249 path = path.substring('/project/packages'.length); | |
| 250 } else if (path.startsWith('${PROJECT_PATH}/')) { | |
| 251 root = projectRoot; | |
| 252 path = path.substring(PROJECT_PATH.length); | |
| 253 dartType = 'text/plain'; | |
| 254 } else if (path.startsWith('${PACKAGES_PATH}/')) { | |
| 255 root = packageRoot; | 93 root = packageRoot; |
| 256 path = path.substring(PACKAGES_PATH.length); | 94 path = path.substring(PACKAGES_PATH.length); |
| 257 } | 95 } |
| 258 | 96 |
| 259 String filePath = root.resolve('.$path').toFilePath(); | 97 String filePath = root.resolve('.$path').toFilePath(); |
| 260 switch (request.method) { | 98 switch (request.method) { |
| 261 case 'GET': | 99 case 'GET': |
| 262 return handleGet(filePath, dartType); | 100 return handleGet(filePath, dartType); |
| 263 case 'POST': | |
| 264 return handlePost(filePath); | |
| 265 default: | 101 default: |
| 266 String method = const HtmlEscape().convert(request.method); | 102 String method = const HtmlEscape().convert(request.method); |
| 267 return badRequest("Unsupported method: '$method'"); | 103 return badRequest("Unsupported method: '$method'"); |
| 268 } | 104 } |
| 269 } | 105 } |
| 270 | 106 |
| 271 void handleGet(String path, String dartType) { | 107 void handleGet(String path, String dartType) { |
| 272 var f = new File(path); | 108 var f = new File(path); |
| 273 f.exists().then((bool exists) { | 109 f.exists().then((bool exists) { |
| 274 if (!exists) return notFound(request.uri); | 110 if (!exists) return notFound(request.uri); |
| 275 if (path.endsWith('.html')) { | 111 if (path.endsWith('.html')) { |
| 276 response.headers.set(CONTENT_TYPE, 'text/html'); | 112 response.headers.set(CONTENT_TYPE, 'text/html'); |
| 277 } else if (path.endsWith('.dart')) { | 113 } else if (path.endsWith('.dart')) { |
| 278 response.headers.set(CONTENT_TYPE, dartType); | 114 response.headers.set(CONTENT_TYPE, dartType); |
| 279 } else if (path.endsWith('.js')) { | 115 } else if (path.endsWith('.js')) { |
| 280 response.headers.set(CONTENT_TYPE, 'application/javascript'); | 116 response.headers.set(CONTENT_TYPE, 'application/javascript'); |
| 281 } else if (path.endsWith('.ico')) { | 117 } else if (path.endsWith('.ico')) { |
| 282 response.headers.set(CONTENT_TYPE, 'image/x-icon'); | 118 response.headers.set(CONTENT_TYPE, 'image/x-icon'); |
| 283 } else if (path.endsWith('.appcache')) { | 119 } else if (path.endsWith('.appcache')) { |
| 284 response.headers.set(CONTENT_TYPE, 'text/cache-manifest'); | 120 response.headers.set(CONTENT_TYPE, 'text/cache-manifest'); |
| 285 } | 121 } |
| 286 f.openRead().pipe(response).catchError(onError); | 122 f.openRead().pipe(response).catchError(onError); |
| 287 }); | 123 }); |
| 288 } | 124 } |
| 289 | 125 |
| 290 handlePost(String path) { | |
| 291 // The data is sent using a dart:html HttpRequest (aka XMLHttpRequest). | |
| 292 // According to http://xhr.spec.whatwg.org/, strings are always encoded as | |
| 293 // UTF-8. | |
| 294 request.transform(UTF8.decoder).join().then((String data) { | |
| 295 // The rest of this method is synchronous. This guarantees that we don't | |
| 296 // make conflicting git changes in response to multiple POST requests. | |
| 297 try { | |
| 298 backup(path); | |
| 299 } catch (e, stack) { | |
| 300 return internalError(e, stack); | |
| 301 } | |
| 302 | |
| 303 new File(path).writeAsStringSync(data); | |
| 304 | |
| 305 response | |
| 306 ..statusCode = HttpStatus.OK | |
| 307 ..close(); | |
| 308 }); | |
| 309 } | |
| 310 | |
| 311 // Back up the file [path] using git. | |
| 312 static void backup(String path) { | |
| 313 // Reset the index. | |
| 314 git('read-tree', ['HEAD']); | |
| 315 | |
| 316 // Save modifications in index. | |
| 317 git('update-index', ['--add', path]); | |
| 318 | |
| 319 // If the file isn't modified, don't back it up. | |
| 320 if (checkGit('diff', ['--cached', '--quiet'])) return; | |
| 321 | |
| 322 String localModifications = git('write-tree'); | |
| 323 | |
| 324 String tag = 'refs/tags/$GIT_TAG'; | |
| 325 var arguments = ['-m', COMMIT_MESSAGE, localModifications]; | |
| 326 | |
| 327 if (checkGit('rev-parse', ['-q', '--verify', tag])) { | |
| 328 // The tag already exists. | |
| 329 | |
| 330 if (checkGit('diff-tree', ['--quiet', localModifications, tag])) { | |
| 331 // localModifications are identical to the last backup. | |
| 332 return; | |
| 333 } | |
| 334 | |
| 335 // Use the tag as a parent. | |
| 336 arguments = ['-p', tag]..addAll(arguments); | |
| 337 | |
| 338 String headCommit = git('rev-parse', ['HEAD']); | |
| 339 String mergeBase = git('merge-base', [tag, 'HEAD']); | |
| 340 if (headCommit != mergeBase) { | |
| 341 arguments = ['-p', 'HEAD']..addAll(arguments); | |
| 342 } | |
| 343 } else { | |
| 344 arguments = ['-p', 'HEAD']..addAll(arguments); | |
| 345 } | |
| 346 | |
| 347 // Commit the local modifcations. | |
| 348 String commit = git('commit-tree', arguments); | |
| 349 | |
| 350 // Create or update the tag. | |
| 351 git('tag', ['-f', GIT_TAG, commit]); | |
| 352 } | |
| 353 | |
| 354 static String git(String command, | |
| 355 [List<String> arguments = const <String> []]) { | |
| 356 ProcessResult result = | |
| 357 run('git', <String>[command]..addAll(arguments), gitEnv); | |
| 358 if (result.exitCode != 0) { | |
| 359 throw 'git error: ${result.stdout}\n${result.stderr}'; | |
| 360 } | |
| 361 return result.stdout.trim(); | |
| 362 } | |
| 363 | |
| 364 static bool checkGit(String command, | |
| 365 [List<String> arguments = const <String> []]) { | |
| 366 return | |
| 367 run('git', <String>[command]..addAll(arguments), gitEnv).exitCode == 0; | |
| 368 } | |
| 369 | |
| 370 static Map<String, String> computeGitEnv() { | |
| 371 ProcessResult result = run('git', ['rev-parse', '--git-dir'], null); | |
| 372 if (result.exitCode != 0) { | |
| 373 throw 'git error: ${result.stdout}\n${result.stderr}'; | |
| 374 } | |
| 375 String gitDir = result.stdout.trim(); | |
| 376 return <String, String>{ 'GIT_INDEX_FILE': '$gitDir/try_dart_backup' }; | |
| 377 } | |
| 378 | |
| 379 static ProcessResult run(String executable, | |
| 380 List<String> arguments, | |
| 381 Map<String, String> environment) { | |
| 382 // print('Running $executable ${arguments.join(" ")}'); | |
| 383 return Process.runSync(executable, arguments, environment: environment); | |
| 384 } | |
| 385 | |
| 386 static onRequest(HttpRequest request) { | 126 static onRequest(HttpRequest request) { |
| 387 Conversation conversation = new Conversation(request, request.response); | 127 Conversation conversation = new Conversation(request, request.response); |
| 388 if (WebSocketTransformer.isUpgradeRequest(request)) { | 128 if (WebSocketTransformer.isUpgradeRequest(request)) { |
| 389 conversation.handleSocket(); | 129 conversation.handleSocket(); |
| 390 } else { | 130 } else { |
| 391 conversation.handle(); | 131 conversation.handle(); |
| 392 } | 132 } |
| 393 } | 133 } |
| 394 | 134 |
| 395 static ensureProjectWatcher() { | |
| 396 if (projectChanges != null) return; | |
| 397 String nativeDir = projectRoot.toFilePath(); | |
| 398 Directory dir = new Directory(nativeDir); | |
| 399 projectChanges = dir.watch(); | |
| 400 projectChanges.listen(WatchHandler.onFileSystemEvent); | |
| 401 } | |
| 402 | |
| 403 static onError(error) { | 135 static onError(error) { |
| 404 if (error is HttpException) { | 136 if (error is HttpException) { |
| 405 print('Error: ${error.message}'); | 137 print('Error: ${error.message}'); |
| 406 } else { | 138 } else { |
| 407 print('Error: ${error}'); | 139 print('Error: ${error}'); |
| 408 } | 140 } |
| 409 } | 141 } |
| 410 | 142 |
| 411 String htmlInfo(String title, String text) { | 143 String htmlInfo(String title, String text) { |
| 412 // No script injection, please. | 144 // No script injection, please. |
| (...skipping 20 matching lines...) Expand all Loading... |
| 433 } | 165 } |
| 434 var host = '127.0.0.1'; | 166 var host = '127.0.0.1'; |
| 435 if (arguments.length > 1) { | 167 if (arguments.length > 1) { |
| 436 host = arguments[1]; | 168 host = arguments[1]; |
| 437 } | 169 } |
| 438 int port = 0; | 170 int port = 0; |
| 439 if (arguments.length > 2) { | 171 if (arguments.length > 2) { |
| 440 port = int.parse(arguments[2]); | 172 port = int.parse(arguments[2]); |
| 441 } | 173 } |
| 442 if (arguments.length > 3) { | 174 if (arguments.length > 3) { |
| 443 Conversation.projectRoot = Uri.base.resolve(arguments[3]); | |
| 444 } | |
| 445 if (arguments.length > 4) { | |
| 446 Conversation.packageRoot = Uri.base.resolve(arguments[4]); | 175 Conversation.packageRoot = Uri.base.resolve(arguments[4]); |
| 447 } | 176 } |
| 448 HttpServer.bind(host, port).then((HttpServer server) { | 177 HttpServer.bind(host, port).then((HttpServer server) { |
| 449 print('HTTP server started on http://$host:${server.port}/'); | 178 print('HTTP server started on http://$host:${server.port}/'); |
| 450 server.listen(Conversation.onRequest, onError: Conversation.onError); | 179 server.listen(Conversation.onRequest, onError: Conversation.onError); |
| 451 }).catchError((e) { | 180 }).catchError((e) { |
| 452 print("HttpServer.bind error: $e"); | 181 print("HttpServer.bind error: $e"); |
| 453 exit(1); | 182 exit(1); |
| 454 }); | 183 }); |
| 455 } | 184 } |
| OLD | NEW |