Skip to content

Conversation

@samogot
Copy link
Contributor

@samogot samogot commented Aug 2, 2018

Add live-reload option that turns on simple WebSocket broadcaster of rebuild finished events.
Inject simple JS snippet to bootstrap that listen for update message from WebSocket and reloads the whole page.

samogot added 3 commits August 1, 2018 15:32
In order to prepare for live/hot reload functionality in build_runner,
add --live-reload option first.
In order to implement live/hot reload functionality in build_runner,
add simple WebSocket that broadcasts a message each time rebuild
finishes. For now clients should figure out what to do with it by
themselves.
Add simple JS snippet to bootstrap code, to listen for WebSocket
messages from server and reload the page on any changes.
@samogot samogot requested review from jakemac53 and natebosch August 2, 2018 00:01
@googlebot googlebot added the cla: yes Google is happy with the PR contributors label Aug 2, 2018
Copy link
Contributor

@jakemac53 jakemac53 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! A few nitpicks but nothing big.

Before merging into master we will need to get some test coverage as well - but we could merge this as is into a separate branch while you work on tests if you would like.

Also, you will want to update the pubspec.yaml and CHANGELOG.md files to reflect the change. The new version should be 0.10.1-dev, we will remove the -dev when we are ready to release.


const _performancePath = r'$perf';
final _graphPath = r'$graph';
final _hotReloadPath = r'$hotreload';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: lets name this $livereload for now at least

final Future<AssetHandler> _assetHandler;
final Future<AssetGraphHandler> _assetGraphHandler;

final WebSocketHandler _webSocketHandler = WebSocketHandler();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you should omit the type on the left hand side here, inference will take care of it :)

final _webSocketHandler = WebSocketHandler();

}

class WebSocketHandler {
final Set<WebSocketChannel> _connectionPull = Set();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar to above, you can omit the type on the left and add the generic to the constructor on the right:

final _connectionPull = Set<WebSocketChannel>();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be a List instead of a Set (<WebSocketChannel>[]), and I would rename it to connections or activeConnections.

final shelf.Handler _internalHandler;

// is it OK?
// ignore: implicit_this_reference_in_initializer
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am surprised this wouldn't give a compile time error in the CFE - that is probably a bug.

We will need to handle this in a different way as it is supposed to be an error. One way is to move it into the constructor body (but then it can't be final):

shelf.Handler _internalHandler;

WebSocketHandler() {
    _internalHandler = webSocketHandler(_handleConnection);
}

The other way would be to use a public factory that delegates to a private constructor, and a static _handleConnection method that takes the connection set, although it's a lot more boilerplate:

factory WebSocketHandler() => WebSocketHandler._(new Set<WebSocketChannel>());

WebSocketHandler._(this._connectionPull) :
    _internalHandler = webSocketHandler((socket) => _handleConnection(socket, _connectionPull));

static void _handleConnection(
    WebSocketChannel webSocket, Set<WebSocketChannel> connections) async {
  connections.add(webSocket);
  await webSocket.stream.drain();
  connections.remove(webSocket);
}

};
} catch (e) {
// Live reload might be turned off. Just ignore it for now
// TODO: Find a way to pass serve option here, to not inject this code at all, if live/hot reload is turned off
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting problem - ideally this package does not have to rely on the implementation details in the server (such as the update event, or the $hotreload path). We want to keep breaking changes in build_runner to a minimum, so ideally we wouldn't have other packages depending on these details.

To that end, it might make sense to actually inject this code from the server itself, possibly into all html requests that it serves, although that could be challenging as well. It would be worth talking to @grouma about how we do this for DDR internally.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like non-trivial task to figure out if request is html page or not. Both Content-type header and <html> tag might be omitted.
Maybe it's easier to inject code to the end of any *.dart.bootstrap.js file?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. This is my biggest concern

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it's easier to inject code to the end of any *.dart.bootstrap.js file?

The problem with that is we are then backing knowledge of build_web_compilers into build_runner - which is maybe worse than the other way around haha.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DDR knows ahead of time what the main entry JS file is called. It then intercepts this request and returns the DDR application. This application will embed a request for the main entry along with a request parameter forReal. The logic can be found in ddr_handler.dart. This pattern has been helpful because we have completely changed our injected application logic a number of times. Is it possible to know what the root request will be in build_runner?

}
}

class WebSocketHandler {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty specific to LiveReload right now, we might make it more generic later but for now lets rename it to LiveReloadWebSocketHandler - or maybe BuildUpdatesWebSocketHandler.

final Future<AssetHandler> _assetHandler;
final Future<AssetGraphHandler> _assetGraphHandler;

final WebSocketHandler _webSocketHandler = WebSocketHandler();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can skip the type on the left when it's obvious on the right.

final _webSocketHandler = WebSocketHandler();


- `--hostname`: The host to run the server on.

- `--live-reload`: Enables automatic page reloading on rebuilds.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Don't include an extra newline between bullet points.

final Future<AssetHandler> _assetHandler;
final Future<AssetGraphHandler> _assetGraphHandler;

final _webSocketHandler = BuildUpdatesWebSocketHandler();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we're getting a little inconsistent about new (easy to do since the Analyzer no longer requires it)

@jakemac53 - should we dartfmt --fix and be done with it or should we find all of these and add back in the new?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am in favor of just doing a dartfmt --fix, but can we also enable a lint then to make us not introduce any more new keywords?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes we could add unnecessary_new and unnecessary_const to avoid regressions.

}
}

class BuildUpdatesWebSocketHandler {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a Doc comment would be good here

};
} catch (e) {
// Live reload might be turned off. Just ignore it for now
// TODO: Find a way to pass serve option here, to not inject this code at all, if live/hot reload is turned off
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. This is my biggest concern

if (!request.url.path.endsWith('.js')) {
return innerHandler(request);
}
return Future.sync(() => innerHandler(request)).then((response) async {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that you can await a FutureOr (or any synchronous value actually), which could clean this us just a bit.

// TODO: Find a way how to check and/or modify body without reading it whole
var body = await response.readAsString();
if (body.startsWith(_entrypointExtensionMarker)) {
body += _buildUpdatesInjectedJS;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should replace the placeholder comment as opposed to appending

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think so.

  1. Why do not remove comment - we might have other middlewares in future that will depend on the same extension point
  2. Why append to the end of file - I feel it "safer" to append code to the end then to the begining

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the browser behavior if there is some valid JS source followed by invalid stuff - will the stuff at the beginning of the file still get executed?
In our particular setup we're pretty sure we're always sending valid parseable javascript, but if the browser would start running our injected stuff even if the rest of the file contains broken stuff that could be a reason to put it at the beginning.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAICR if there is fatal syntax error - it won't. if "invalid stuff" can somehow be parsed as JS, then file will be executed till the first unhanded error

///
/// Listen WebSocket for updates in build results
///
/// Now only ilve-reload functional - just reload page on update message
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] typo

return innerHandler(request);
}
return Future.sync(() => innerHandler(request)).then((response) async {
// TODO: Find a way how to check and/or modify body without reading it whole
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably ok since most .js is going to be UTF8 encoded. We could also consider implementing a StreamTransformer that would do this but it's probably more complex that is warranted.

/// build updates
class BuildUpdatesWebSocketHandler {
final activeConnections = <WebSocketChannel>[];
final Function _handlerFactory;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets type this more strongly if possible, something like

Handler Function(void Function(WebSocket, String), {List<String> protocols})

A bit verbose to be sure, but nicely typed :)

});

group('build updates', () {
final _entrypointExtensionMarker = '/* ENTRYPOINT_EXTENTION_MARKER */';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you can import from the src directory and use the actual original definition of this (its fine to make it public there as long as it isn't exported by lib/build_runner.dart). Or, we could intentionally expose it so that build_web_compilers can use it.

final _entrypointExtensionMarker = '/* ENTRYPOINT_EXTENTION_MARKER */';

test('injects client code if enabled', () async {
_addSource('a|web/some.js', _entrypointExtensionMarker);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: append some other js content to the end as well

expect(await response.readAsString(), isNot(contains('\$livereload')));
});

test('emmits a message to all listners', () async {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this test and the next one share a lot of setup logic, you can move them into a group (see 2nd code snippet here).

That lets you define a shared setUp and tearDown function for both tests.

var buildUpdatesWebSocketHandler = BuildUpdatesWebSocketHandler(
mockHandlerFactory);

var streamControllerFirst1 = StreamController<List<int>>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we name these a bit more descriptively?

It is also important to make sure these get closed (in a tearDown method ideally).

var serverChannelSecond = WebSocketChannel(StreamChannel(streamControllerSecond1.stream, streamControllerSecond2.sink), serverSide: true);
var clientChannelSecond = WebSocketChannel(StreamChannel(streamControllerSecond2.stream, streamControllerSecond1.sink), serverSide: false);

var deferredExpect1 = clientChannelFirst.stream.toList().then((value) => expect(value, ['update', 'update']));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of this you can use the built in stream matchers

shelf.Handler _internalHandler;

BuildUpdatesWebSocketHandler([this._handlerFactory = webSocketHandler]) {
var untypedTearOff = (webSocket, protocol) =>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one last nit, can you document why we need to do this, and ideally point at an issue in the original package?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

///
/// Now only live-reload functional - just reload page on update message
final _buildUpdatesInjectedJS = '''\n
(function() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh also its probably worth adding a comment here // Injected by build_runner for live reload support or something along those lines

Copy link
Contributor

@jakemac53 jakemac53 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm, but lets give @natebosch a chance for final comments as well

@samogot samogot merged commit 1ee129d into master Aug 4, 2018
@samogot samogot deleted the live-reload branch August 4, 2018 01:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla: yes Google is happy with the PR contributors

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants