blob: 983e0c99d9f7edb18e9c6edf643ed92380ae68e0 [file] [log] [blame]
// Copyright (c) 2013, 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 pub.barback.server;
import 'dart:async';
import 'dart:io';
import 'package:barback/barback.dart';
import 'package:mime/mime.dart';
import 'package:path/path.dart' as path;
import 'package:stack_trace/stack_trace.dart';
import '../barback.dart';
import '../log.dart' as log;
import '../utils.dart';
import 'build_environment.dart';
import 'web_socket_api.dart';
/// Callback for determining if an asset with [id] should be served or not.
typedef bool AllowAsset(AssetId id);
/// A server that serves assets transformed by barback.
class BarbackServer {
/// The [BuildEnvironment] being served.
final BuildEnvironment _environment;
/// The underlying HTTP server.
final HttpServer _server;
/// All currently open [WebSocket] connections.
final _webSockets = new Set<WebSocket>();
/// The directory in the root which will serve as the root of this server as
/// a native platform path.
final String rootDirectory;
/// The root directory as an asset-style ("/") path.
String get rootAssetPath => path.url.joinAll(path.split(rootDirectory));
/// The server's port.
final int port;
/// The server's address.
final InternetAddress address;
/// Optional callback to determine if an asset should be served.
///
/// This can be set to allow outside code to filter out assets. Pub serve
/// uses this after plug-ins are loaded to avoid serving ".dart" files in
/// release mode.
///
/// If this is `null`, all assets may be served.
AllowAsset allowAsset;
/// The results of requests handled by the server.
///
/// These can be used to provide visual feedback for the server's processing.
/// This stream is also used to emit any programmatic errors that occur in the
/// server.
Stream<BarbackServerResult> get results => _resultsController.stream;
final _resultsController =
new StreamController<BarbackServerResult>.broadcast();
/// Creates a new server and binds it to [port] of [host].
///
/// This server will serve assets from [barback], and use [rootDirectory] as
/// the root directory.
static Future<BarbackServer> bind(BuildEnvironment environment,
String host, int port, String rootDirectory) {
return Chain.track(HttpServer.bind(host, port)).then((server) {
return new BarbackServer._(environment, server, rootDirectory);
});
}
BarbackServer._(this._environment, HttpServer server, this.rootDirectory)
: _server = server,
port = server.port,
address = server.address {
Chain.track(_server).listen(_handleRequest, onError: (error, stackTrace) {
_resultsController.addError(error, stackTrace);
close();
});
}
/// Closes this server.
Future close() {
var futures = [_server.close(), _resultsController.close()];
futures.addAll(_webSockets);
return Future.wait(futures);
}
/// Converts a [url] served by this server into an [AssetId] that can be
/// requested from barback.
AssetId urlToId(Uri url) {
// See if it's a URL to a public directory in a dependency.
var id = specialUrlToId(url);
if (id != null) return id;
// Otherwise, it's a path in current package's [rootDirectory].
var parts = path.url.split(url.path);
// Strip the leading "/" from the URL.
if (parts.isNotEmpty && parts.first == "/") parts = parts.skip(1);
var relativePath = path.url.join(rootDirectory, path.url.joinAll(parts));
return new AssetId(_environment.rootPackage.name, relativePath);
}
/// Handles an HTTP request.
void _handleRequest(HttpRequest request) {
if (WebSocketTransformer.isUpgradeRequest(request)) {
_handleWebSocket(request);
return;
}
if (request.method != "GET" && request.method != "HEAD") {
_methodNotAllowed(request);
return;
}
var id;
try {
id = urlToId(request.uri);
} on FormatException catch (ex) {
// If we got here, we had a path like "/packages" which is a special
// directory, but not a valid path since it lacks a following package name.
_notFound(request, ex.message);
return;
}
// See if the asset should be blocked.
if (allowAsset != null && !allowAsset(id)) {
_notFound(request, "Asset $id is not available in this configuration.");
return;
}
_logRequest(request, "Loading $id");
_environment.barback.getAssetById(id)
.then((asset) => _serveAsset(request, asset))
.catchError((error, trace) {
if (error is! AssetNotFoundException) throw error;
return _environment.barback.getAssetById(id.addExtension("/index.html"))
.then((asset) {
if (request.uri.path.endsWith('/')) return _serveAsset(request, asset);
// We only want to serve index.html if the URL explicitly ends in a
// slash. For other URLs, we redirect to one with the slash added to
// implicitly support that too. This follows Apache's behavior.
_logRequest(request, "302 Redirect to ${request.uri}/");
request.response.statusCode = 302;
request.response.headers.add('location', '${request.uri}/');
request.response.close();
}).catchError((newError, newTrace) {
// If we find neither the original file or the index, we should report
// the error about the original to the user.
throw newError is AssetNotFoundException ? error : newError;
});
}).catchError((error, trace) {
if (error is! AssetNotFoundException) {
trace = new Chain.forTrace(trace);
_logRequest(request, "$error\n$trace");
_resultsController.addError(error, trace);
close();
return;
}
_resultsController.add(
new BarbackServerResult._failure(request.uri, id, error));
_notFound(request, error);
});
}
/// Serves the body of [asset] on [request]'s response stream.
///
/// Returns a future that completes when the response has been succesfully
/// written.
Future _serveAsset(HttpRequest request, Asset asset) {
return validateStream(asset.read()).then((stream) {
_resultsController.add(
new BarbackServerResult._success(request.uri, asset.id));
var mimeType = lookupMimeType(asset.id.path);
if (mimeType != null) {
request.response.headers.add('content-type', mimeType);
}
// TODO(rnystrom): Set content-type based on asset type.
return Chain.track(request.response.addStream(stream)).then((_) {
// Log successful requests both so we can provide debugging
// information and so scheduled_test knows we haven't timed out while
// loading transformers.
_logRequest(request, "Served ${asset.id}");
request.response.close();
});
}).catchError((error, trace) {
_resultsController.add(
new BarbackServerResult._failure(request.uri, asset.id, error));
// If we couldn't read the asset, handle the error gracefully.
if (error is FileSystemException) {
// Assume this means the asset was a file-backed source asset
// and we couldn't read it, so treat it like a missing asset.
_notFound(request, error);
return;
}
trace = new Chain.forTrace(trace);
_logRequest(request, "$error\n$trace");
// Otherwise, it's some internal error.
request.response.statusCode = 500;
request.response.reasonPhrase = "Internal Error";
request.response.write(error);
request.response.close();
});
}
/// Creates a web socket for [request] which should be an upgrade request.
void _handleWebSocket(HttpRequest request) {
Chain.track(WebSocketTransformer.upgrade(request)).then((socket) {
_webSockets.add(socket);
var api = new WebSocketApi(socket, _environment);
return api.listen().whenComplete(() {
_webSockets.remove(api);
});
}).catchError(_resultsController.addError);
}
/// Responds to [request] with a 405 response and closes it.
void _methodNotAllowed(HttpRequest request) {
_logRequest(request, "405 Method Not Allowed");
request.response.statusCode = 405;
request.response.reasonPhrase = "Method Not Allowed";
request.response.headers.add('Allow', 'GET, HEAD');
request.response.write(
"The ${request.method} method is not allowed for ${request.uri}.");
request.response.close();
}
/// Responds to [request] with a 404 response and closes it.
void _notFound(HttpRequest request, message) {
_logRequest(request, "404 Not Found");
// Force a UTF-8 encoding so that error messages in non-English locales are
// sent correctly.
request.response.headers.contentType =
ContentType.parse("text/plain; charset=utf-8");
request.response.statusCode = 404;
request.response.reasonPhrase = "Not Found";
request.response.write(message);
request.response.close();
}
/// Log [message] at [log.Level.FINE] with metadata about [request].
void _logRequest(HttpRequest request, String message) =>
log.fine("BarbackServer ${request.method} ${request.uri}\n$message");
}
/// The result of the server handling a URL.
///
/// Only requests for which an asset was requested from barback will emit a
/// result. Malformed requests will be handled internally.
class BarbackServerResult {
/// The requested url.
final Uri url;
/// The id that [url] identifies.
final AssetId id;
/// The error thrown by barback.
///
/// If the request was served successfully, this will be null.
final error;
/// Whether the request was served successfully.
bool get isSuccess => error == null;
/// Whether the request was served unsuccessfully.
bool get isFailure => !isSuccess;
BarbackServerResult._success(this.url, this.id)
: error = null;
BarbackServerResult._failure(this.url, this.id, this.error);
}