mirror of
https://github.com/dart-lang/sdk
synced 2024-09-18 22:11:19 +00:00
a88d0afa23
R=kasperl@google.com Review URL: https://codereview.chromium.org//225903003 git-svn-id: https://dart.googlecode.com/svn/branches/bleeding_edge/dart@35688 260f80e4-7a28-3924-810f-c04153c831b5
456 lines
13 KiB
Dart
456 lines
13 KiB
Dart
// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file
|
|
// for details. All rights reserved. Use of this source code is governed by a
|
|
// BSD-style license that can be found in the LICENSE file.
|
|
|
|
library trydart.projectServer;
|
|
|
|
import 'dart:io';
|
|
|
|
import 'dart:async' show
|
|
Future,
|
|
Stream;
|
|
|
|
import 'dart:convert' show
|
|
HtmlEscape,
|
|
JSON,
|
|
UTF8;
|
|
|
|
class WatchHandler {
|
|
final WebSocket socket;
|
|
|
|
final Set<String> watchedFiles;
|
|
|
|
static final Set<WatchHandler> handlers = new Set<WatchHandler>();
|
|
|
|
static const Map<int, String> fsEventNames = const <int, String>{
|
|
FileSystemEvent.CREATE: 'create',
|
|
FileSystemEvent.DELETE: 'delete',
|
|
FileSystemEvent.MODIFY: 'modify',
|
|
FileSystemEvent.MOVE: 'move',
|
|
};
|
|
|
|
WatchHandler(this.socket, Iterable<String> watchedFiles)
|
|
: this.watchedFiles = watchedFiles.toSet();
|
|
|
|
handleFileSystemEvent(FileSystemEvent event) {
|
|
if (event.isDirectory) return;
|
|
String type = fsEventNames[event.type];
|
|
if (type == null) type = 'unknown';
|
|
String path = new Uri.file(event.path).pathSegments.last;
|
|
shouldIgnore(type, path).then((bool ignored) {
|
|
if (ignored) return;
|
|
socket.add(JSON.encode({type: [path]}));
|
|
});
|
|
}
|
|
|
|
Future<bool> shouldIgnore(String type, String path) {
|
|
switch (type) {
|
|
case 'create':
|
|
return new Future<bool>.value(!watchedFiles.contains(path));
|
|
case 'delete':
|
|
return Conversation.listProjectFiles().then((List<String> files) {
|
|
watchedFiles
|
|
..retainAll(files)
|
|
..addAll(files);
|
|
return watchedFiles.contains(path);
|
|
});
|
|
case 'modify':
|
|
return new Future<bool>.value(false);
|
|
default:
|
|
print('Unhandled fs-event for $path ($type).');
|
|
return new Future<bool>.value(true);
|
|
}
|
|
}
|
|
|
|
onData(_) {
|
|
// TODO(ahe): Move POST code here?
|
|
}
|
|
|
|
onDone() {
|
|
handlers.remove(this);
|
|
}
|
|
|
|
static handleWebSocket(WebSocket socket) {
|
|
Conversation.ensureProjectWatcher();
|
|
Conversation.listProjectFiles().then((List<String> files) {
|
|
socket.add(JSON.encode({'create': files}));
|
|
WatchHandler handler = new WatchHandler(socket, files);
|
|
handlers.add(handler);
|
|
socket.listen(
|
|
handler.onData, cancelOnError: true, onDone: handler.onDone);
|
|
});
|
|
}
|
|
|
|
static onFileSystemEvent(FileSystemEvent event) {
|
|
for (WatchHandler handler in handlers) {
|
|
handler.handleFileSystemEvent(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Represents a "project" command. These commands are accessed from the URL
|
|
/// "/project?name".
|
|
class ProjectCommand {
|
|
final String name;
|
|
|
|
/// For each query parameter, this map describes rules for validating them.
|
|
final Map<String, String> rules;
|
|
|
|
final Function handle;
|
|
|
|
const ProjectCommand(this.name, this.rules, this.handle);
|
|
}
|
|
|
|
class Conversation {
|
|
HttpRequest request;
|
|
HttpResponse response;
|
|
|
|
static const String PROJECT_PATH = '/project';
|
|
|
|
static const String PACKAGES_PATH = '/packages';
|
|
|
|
static const String CONTENT_TYPE = HttpHeaders.CONTENT_TYPE;
|
|
|
|
static const String GIT_TAG = 'try_dart_backup';
|
|
|
|
static const String COMMIT_MESSAGE = """
|
|
Automated backup.
|
|
|
|
It is safe to delete tag '$GIT_TAG' if you don't need the backup.""";
|
|
|
|
static Uri documentRoot = Uri.base;
|
|
|
|
static Uri projectRoot = Uri.base.resolve('site/try/src/');
|
|
|
|
static Uri packageRoot = Uri.base.resolve('sdk/lib/_internal/');
|
|
|
|
static const List<ProjectCommand> COMMANDS = const <ProjectCommand>[
|
|
const ProjectCommand('list', const {'list': null}, handleProjectList),
|
|
];
|
|
|
|
static Stream<FileSystemEvent> projectChanges;
|
|
|
|
static final Map<String, String> gitEnv = computeGitEnv();
|
|
|
|
Conversation(this.request, this.response);
|
|
|
|
onClosed(_) {
|
|
if (response.statusCode == HttpStatus.OK) return;
|
|
print('Request for ${request.uri} ${response.statusCode}');
|
|
}
|
|
|
|
notFound(path) {
|
|
response.statusCode = HttpStatus.NOT_FOUND;
|
|
response.write(htmlInfo('Not Found',
|
|
'The file "$path" could not be found.'));
|
|
response.close();
|
|
}
|
|
|
|
badRequest(String problem) {
|
|
response.statusCode = HttpStatus.BAD_REQUEST;
|
|
response.write(htmlInfo("Bad request",
|
|
"Bad request '${request.uri}': $problem"));
|
|
response.close();
|
|
}
|
|
|
|
internalError(error, stack) {
|
|
print(error);
|
|
if (stack != null) print(stack);
|
|
response.statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
|
|
response.write(htmlInfo("Internal Server Error",
|
|
"Internal Server Error: $error\n$stack"));
|
|
response.close();
|
|
}
|
|
|
|
bool validate(Map<String, String> parameters, Map<String, String> rules) {
|
|
Iterable<String> problems = rules.keys
|
|
.where((name) => !parameters.containsKey(name))
|
|
.map((name) => "Missing parameter: '$name'.");
|
|
if (!problems.isEmpty) {
|
|
badRequest(problems.first);
|
|
return false;
|
|
}
|
|
Set extra = new Set.from(parameters.keys)..removeAll(rules.keys);
|
|
if (extra.isEmpty) return true;
|
|
String extraString = (extra.toList()..sort()).join("', '");
|
|
badRequest("Extra parameters: '$extraString'.");
|
|
return false;
|
|
}
|
|
|
|
static Future<List<String>> listProjectFiles() {
|
|
String nativeDir = projectRoot.toFilePath();
|
|
Directory dir = new Directory(nativeDir);
|
|
var future = dir.list(recursive: true, followLinks: false).toList();
|
|
return future.then((List<FileSystemEntity> entries) {
|
|
return entries
|
|
.map((e) => e.path)
|
|
.where((p) => p.endsWith('.dart') && p.startsWith(nativeDir))
|
|
.map((p) => p.substring(nativeDir.length))
|
|
.map((p) => new Uri.file(p).path).toList();
|
|
});
|
|
}
|
|
|
|
static handleProjectList(Conversation self) {
|
|
listProjectFiles().then((List<String> files) {
|
|
self.response
|
|
..write(JSON.encode(files))
|
|
..close();
|
|
});
|
|
}
|
|
|
|
handleProjectRequest() {
|
|
Map<String, String> parameters = request.uri.queryParameters;
|
|
for (ProjectCommand command in COMMANDS) {
|
|
if (parameters.containsKey(command.name)) {
|
|
if (validate(parameters, command.rules)) {
|
|
(command.handle)(this);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
String commands = COMMANDS.map((c) => c.name).join("', '");
|
|
badRequest("Valid commands are: '$commands'");
|
|
}
|
|
|
|
handleSocket() {
|
|
if (request.uri.path == '/ws/watch') {
|
|
WebSocketTransformer.upgrade(request).then(WatchHandler.handleWebSocket);
|
|
} else {
|
|
response.done
|
|
.then(onClosed)
|
|
.catchError(onError);
|
|
notFound(request.uri.path);
|
|
}
|
|
}
|
|
|
|
handle() {
|
|
response.done
|
|
.then(onClosed)
|
|
.catchError(onError);
|
|
|
|
Uri uri = request.uri;
|
|
if (uri.path == PROJECT_PATH) {
|
|
return handleProjectRequest();
|
|
}
|
|
if (uri.path.endsWith('/')) {
|
|
uri = uri.resolve('index.html');
|
|
}
|
|
if (uri.path == '/css/fonts/fontawesome-webfont.woff') {
|
|
uri = uri.resolve('/fontawesome-webfont.woff');
|
|
}
|
|
if (uri.path.contains('..') || uri.path.contains('%')) {
|
|
return notFound(uri.path);
|
|
}
|
|
String path = uri.path;
|
|
Uri root = documentRoot;
|
|
String dartType = 'application/dart';
|
|
if (path.startsWith('/project/packages/')) {
|
|
root = packageRoot;
|
|
path = path.substring('/project/packages'.length);
|
|
} else if (path.startsWith('${PROJECT_PATH}/')) {
|
|
root = projectRoot;
|
|
path = path.substring(PROJECT_PATH.length);
|
|
dartType = 'text/plain';
|
|
} else if (path.startsWith('${PACKAGES_PATH}/')) {
|
|
root = packageRoot;
|
|
path = path.substring(PACKAGES_PATH.length);
|
|
}
|
|
|
|
String filePath = root.resolve('.$path').toFilePath();
|
|
switch (request.method) {
|
|
case 'GET':
|
|
return handleGet(filePath, dartType);
|
|
case 'POST':
|
|
return handlePost(filePath);
|
|
default:
|
|
String method = const HtmlEscape().convert(request.method);
|
|
return badRequest("Unsupported method: '$method'");
|
|
}
|
|
}
|
|
|
|
void handleGet(String path, String dartType) {
|
|
var f = new File(path);
|
|
f.exists().then((bool exists) {
|
|
if (!exists) return notFound(request.uri);
|
|
if (path.endsWith('.html')) {
|
|
response.headers.set(CONTENT_TYPE, 'text/html');
|
|
} else if (path.endsWith('.dart')) {
|
|
response.headers.set(CONTENT_TYPE, dartType);
|
|
} else if (path.endsWith('.js')) {
|
|
response.headers.set(CONTENT_TYPE, 'application/javascript');
|
|
} else if (path.endsWith('.ico')) {
|
|
response.headers.set(CONTENT_TYPE, 'image/x-icon');
|
|
} else if (path.endsWith('.appcache')) {
|
|
response.headers.set(CONTENT_TYPE, 'text/cache-manifest');
|
|
}
|
|
f.openRead().pipe(response).catchError(onError);
|
|
});
|
|
}
|
|
|
|
handlePost(String path) {
|
|
// The data is sent using a dart:html HttpRequest (aka XMLHttpRequest).
|
|
// According to http://xhr.spec.whatwg.org/, strings are always encoded as
|
|
// UTF-8.
|
|
request.transform(UTF8.decoder).join().then((String data) {
|
|
// The rest of this method is synchronous. This guarantees that we don't
|
|
// make conflicting git changes in response to multiple POST requests.
|
|
try {
|
|
backup(path);
|
|
} catch (e, stack) {
|
|
return internalError(e, stack);
|
|
}
|
|
|
|
new File(path).writeAsStringSync(data);
|
|
|
|
response
|
|
..statusCode = HttpStatus.OK
|
|
..close();
|
|
});
|
|
}
|
|
|
|
// Back up the file [path] using git.
|
|
static void backup(String path) {
|
|
// Reset the index.
|
|
git('read-tree', ['HEAD']);
|
|
|
|
// Save modifications in index.
|
|
git('update-index', ['--add', path]);
|
|
|
|
// If the file isn't modified, don't back it up.
|
|
if (checkGit('diff', ['--cached', '--quiet'])) return;
|
|
|
|
String localModifications = git('write-tree');
|
|
|
|
String tag = 'refs/tags/$GIT_TAG';
|
|
var arguments = ['-m', COMMIT_MESSAGE, localModifications];
|
|
|
|
if (checkGit('rev-parse', ['-q', '--verify', tag])) {
|
|
// The tag already exists.
|
|
|
|
if (checkGit('diff-tree', ['--quiet', localModifications, tag])) {
|
|
// localModifications are identical to the last backup.
|
|
return;
|
|
}
|
|
|
|
// Use the tag as a parent.
|
|
arguments = ['-p', tag]..addAll(arguments);
|
|
|
|
String headCommit = git('rev-parse', ['HEAD']);
|
|
String mergeBase = git('merge-base', [tag, 'HEAD']);
|
|
if (headCommit != mergeBase) {
|
|
arguments = ['-p', 'HEAD']..addAll(arguments);
|
|
}
|
|
} else {
|
|
arguments = ['-p', 'HEAD']..addAll(arguments);
|
|
}
|
|
|
|
// Commit the local modifcations.
|
|
String commit = git('commit-tree', arguments);
|
|
|
|
// Create or update the tag.
|
|
git('tag', ['-f', GIT_TAG, commit]);
|
|
}
|
|
|
|
static String git(String command,
|
|
[List<String> arguments = const <String> []]) {
|
|
ProcessResult result =
|
|
run('git', <String>[command]..addAll(arguments), gitEnv);
|
|
if (result.exitCode != 0) {
|
|
throw 'git error: ${result.stdout}\n${result.stderr}';
|
|
}
|
|
return result.stdout.trim();
|
|
}
|
|
|
|
static bool checkGit(String command,
|
|
[List<String> arguments = const <String> []]) {
|
|
return
|
|
run('git', <String>[command]..addAll(arguments), gitEnv).exitCode == 0;
|
|
}
|
|
|
|
static Map<String, String> computeGitEnv() {
|
|
ProcessResult result = run('git', ['rev-parse', '--git-dir'], null);
|
|
if (result.exitCode != 0) {
|
|
throw 'git error: ${result.stdout}\n${result.stderr}';
|
|
}
|
|
String gitDir = result.stdout.trim();
|
|
return <String, String>{ 'GIT_INDEX_FILE': '$gitDir/try_dart_backup' };
|
|
}
|
|
|
|
static ProcessResult run(String executable,
|
|
List<String> arguments,
|
|
Map<String, String> environment) {
|
|
// print('Running $executable ${arguments.join(" ")}');
|
|
return Process.runSync(executable, arguments, environment: environment);
|
|
}
|
|
|
|
static onRequest(HttpRequest request) {
|
|
Conversation conversation = new Conversation(request, request.response);
|
|
if (WebSocketTransformer.isUpgradeRequest(request)) {
|
|
conversation.handleSocket();
|
|
} else {
|
|
conversation.handle();
|
|
}
|
|
}
|
|
|
|
static ensureProjectWatcher() {
|
|
if (projectChanges != null) return;
|
|
String nativeDir = projectRoot.toFilePath();
|
|
Directory dir = new Directory(nativeDir);
|
|
projectChanges = dir.watch();
|
|
projectChanges.listen(WatchHandler.onFileSystemEvent);
|
|
}
|
|
|
|
static onError(error) {
|
|
if (error is HttpException) {
|
|
print('Error: ${error.message}');
|
|
} else {
|
|
print('Error: ${error}');
|
|
}
|
|
}
|
|
|
|
String htmlInfo(String title, String text) {
|
|
// No script injection, please.
|
|
title = const HtmlEscape().convert(title);
|
|
text = const HtmlEscape().convert(text);
|
|
return """
|
|
<!DOCTYPE html>
|
|
<html lang='en'>
|
|
<head>
|
|
<title>$title</title>
|
|
</head>
|
|
<body>
|
|
<h1>$title</h1>
|
|
<p style='white-space:pre'>$text</p>
|
|
</body>
|
|
</html>
|
|
""";
|
|
}
|
|
}
|
|
|
|
main(List<String> arguments) {
|
|
if (arguments.length > 0) {
|
|
Conversation.documentRoot = Uri.base.resolve(arguments[0]);
|
|
}
|
|
var host = '127.0.0.1';
|
|
if (arguments.length > 1) {
|
|
host = arguments[1];
|
|
}
|
|
int port = 0;
|
|
if (arguments.length > 2) {
|
|
port = int.parse(arguments[2]);
|
|
}
|
|
if (arguments.length > 3) {
|
|
Conversation.projectRoot = Uri.base.resolve(arguments[3]);
|
|
}
|
|
if (arguments.length > 4) {
|
|
Conversation.packageRoot = Uri.base.resolve(arguments[4]);
|
|
}
|
|
HttpServer.bind(host, port).then((HttpServer server) {
|
|
print('HTTP server started on http://$host:${server.port}/');
|
|
server.listen(Conversation.onRequest, onError: Conversation.onError);
|
|
}).catchError((e) {
|
|
print("HttpServer.bind error: $e");
|
|
exit(1);
|
|
});
|
|
}
|