blob: 08c5d565425f206d777c404842ecc524397d19d4 [file] [log] [blame]
// 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);
});
}