Skip to content

Commit 1ee129d

Browse files
authored
Add simple live-reload implementation (#1703)
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.
1 parent b9b4992 commit 1ee129d

File tree

10 files changed

+254
-13
lines changed

10 files changed

+254
-13
lines changed

build_runner/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 0.10.1-dev
2+
3+
- Added `--live-reload` cli option and appropriate functionality
4+
15
## 0.10.0
26

37
### Breaking Changes

build_runner/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ Some commands also have additional options:
9999
##### serve
100100

101101
- `--hostname`: The host to run the server on.
102+
- `--live-reload`: Enables automatic page reloading on rebuilds.
102103

103104
Trailing args of the form `<directory>:<port>` are supported to customize what
104105
directories are served, and on what ports.

build_runner/lib/src/entrypoint/options.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'package:path/path.dart' as p;
1414
const assumeTtyOption = 'assume-tty';
1515
const defineOption = 'define';
1616
const deleteFilesByDefaultOption = 'delete-conflicting-outputs';
17+
const liveReloadOption = 'live-reload';
1718
const logPerformanceOption = 'log-performance';
1819
const logRequestsOption = 'log-requests';
1920
const lowResourcesModeOption = 'low-resources-mode';
@@ -130,11 +131,13 @@ class SharedOptions {
130131
/// Options specific to the `serve` command.
131132
class ServeOptions extends SharedOptions {
132133
final String hostName;
134+
final bool liveReload;
133135
final bool logRequests;
134136
final List<ServeTarget> serveTargets;
135137

136138
ServeOptions._({
137139
@required this.hostName,
140+
@required this.liveReload,
138141
@required this.logRequests,
139142
@required this.serveTargets,
140143
@required bool assumeTty,
@@ -200,6 +203,7 @@ class ServeOptions extends SharedOptions {
200203

201204
return new ServeOptions._(
202205
hostName: argResults[hostnameOption] as String,
206+
liveReload: argResults[liveReloadOption] as bool,
203207
logRequests: argResults[logRequestsOption] as bool,
204208
serveTargets: serveTargets,
205209
assumeTty: argResults[assumeTtyOption] as bool,

build_runner/lib/src/entrypoint/serve.dart

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ class ServeCommand extends WatchCommand {
2525
..addFlag(logRequestsOption,
2626
defaultsTo: false,
2727
negatable: false,
28-
help: 'Enables logging for each request to the server.');
28+
help: 'Enables logging for each request to the server.')
29+
..addFlag(liveReloadOption,
30+
defaultsTo: false,
31+
negatable: false,
32+
help: 'Enables automatic page reloading on rebuilds.');
2933
}
3034

3135
@override
@@ -93,7 +97,9 @@ Future<HttpServer> _startServer(
9397
ServeOptions options, ServeTarget target, ServeHandler handler) async {
9498
var server = await _bindServer(options, target);
9599
serveRequests(
96-
server, handler.handlerFor(target.dir, logRequests: options.logRequests));
100+
server,
101+
handler.handlerFor(target.dir,
102+
logRequests: options.logRequests, liveReload: options.liveReload));
97103
return server;
98104
}
99105

build_runner/lib/src/server/server.dart

Lines changed: 87 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,18 @@ import 'package:logging/logging.dart';
1212
import 'package:mime/mime.dart';
1313
import 'package:path/path.dart' as p;
1414
import 'package:shelf/shelf.dart' as shelf;
15+
import 'package:shelf_web_socket/shelf_web_socket.dart';
16+
import 'package:web_socket_channel/web_socket_channel.dart';
1517

1618
import '../generate/watch_impl.dart';
1719
import 'asset_graph_handler.dart';
1820
import 'path_to_asset_id.dart';
1921

2022
const _performancePath = r'$perf';
2123
final _graphPath = r'$graph';
24+
final _buildUpdatesProtocol = r'$livereload';
25+
final _buildUpdatesMessage = 'update';
26+
final entrypointExtensionMarker = '/* ENTRYPOINT_EXTENTION_MARKER */';
2227

2328
final _logger = new Logger('Serve');
2429

@@ -43,10 +48,13 @@ class ServeHandler implements BuildState {
4348
final Future<AssetHandler> _assetHandler;
4449
final Future<AssetGraphHandler> _assetGraphHandler;
4550

51+
final _webSocketHandler = BuildUpdatesWebSocketHandler();
52+
4653
ServeHandler._(this._state, this._assetHandler, this._assetGraphHandler,
4754
this._rootPackage) {
4855
_state.buildResults.listen((result) {
4956
_lastBuildResult = result;
57+
_webSocketHandler.emitUpdateMessage(result);
5058
});
5159
}
5260

@@ -55,14 +63,17 @@ class ServeHandler implements BuildState {
5563
@override
5664
Stream<BuildResult> get buildResults => _state.buildResults;
5765

58-
shelf.Handler handlerFor(String rootDir, {bool logRequests}) {
66+
shelf.Handler handlerFor(String rootDir,
67+
{bool logRequests, bool liveReload}) {
68+
liveReload ??= false;
5969
logRequests ??= false;
6070
if (p.url.split(rootDir).length != 1) {
6171
throw new ArgumentError.value(
6272
rootDir, 'rootDir', 'Only top level directories are supported');
6373
}
6474
_state.currentBuild.then((_) => _warnForEmptyDirectory(rootDir));
65-
var cascade = new shelf.Cascade()
75+
var cascade = new shelf.Cascade();
76+
cascade = (liveReload ? cascade.add(_webSocketHandler.handler) : cascade)
6677
.add(_blockOnCurrentBuild)
6778
.add((shelf.Request request) async {
6879
if (request.url.path == _performancePath) {
@@ -76,12 +87,14 @@ class ServeHandler implements BuildState {
7687
var assetHandler = await _assetHandler;
7788
return assetHandler.handle(request, rootDir);
7889
});
79-
var handler = logRequests
80-
? const shelf.Pipeline()
81-
.addMiddleware(_logRequests)
82-
.addHandler(cascade.handler)
83-
: cascade.handler;
84-
return handler;
90+
var pipeline = shelf.Pipeline();
91+
if (logRequests) {
92+
pipeline = pipeline.addMiddleware(_logRequests);
93+
}
94+
if (liveReload) {
95+
pipeline = pipeline.addMiddleware(_injectBuildUpdatesClientCode);
96+
}
97+
return pipeline.addHandler(cascade.handler);
8598
}
8699

87100
Future<shelf.Response> _blockOnCurrentBuild(_) async {
@@ -110,6 +123,72 @@ class ServeHandler implements BuildState {
110123
}
111124
}
112125

126+
/// Class that manages web socket connection handler to inform clients about
127+
/// build updates
128+
class BuildUpdatesWebSocketHandler {
129+
final activeConnections = <WebSocketChannel>[];
130+
final shelf.Handler Function(Function, {Iterable<String> protocols})
131+
_handlerFactory;
132+
shelf.Handler _internalHandler;
133+
134+
BuildUpdatesWebSocketHandler([this._handlerFactory = webSocketHandler]) {
135+
// Because of dart-lang/shelf_web_socket#11, webSocketHandler doesn't work
136+
// with strongly typed functions. As a workaround diskard type information
137+
// wrapping it with lambda for now
138+
var untypedTearOff = (webSocket, protocol) =>
139+
_handleConnection(webSocket as WebSocketChannel, protocol as String);
140+
_internalHandler =
141+
_handlerFactory(untypedTearOff, protocols: [_buildUpdatesProtocol]);
142+
}
143+
144+
shelf.Handler get handler => _internalHandler;
145+
146+
void emitUpdateMessage(BuildResult buildResult) {
147+
for (var webSocket in activeConnections) {
148+
webSocket.sink.add(_buildUpdatesMessage);
149+
}
150+
}
151+
152+
void _handleConnection(WebSocketChannel webSocket, String protocol) async {
153+
activeConnections.add(webSocket);
154+
await webSocket.stream.drain();
155+
activeConnections.remove(webSocket);
156+
}
157+
}
158+
159+
shelf.Handler _injectBuildUpdatesClientCode(shelf.Handler innerHandler) {
160+
return (shelf.Request request) async {
161+
if (!request.url.path.endsWith('.js')) {
162+
return innerHandler(request);
163+
}
164+
var response = await innerHandler(request);
165+
// TODO: Find a way how to check and/or modify body without reading it whole
166+
var body = await response.readAsString();
167+
if (body.startsWith(entrypointExtensionMarker)) {
168+
body += _buildUpdatesInjectedJS;
169+
}
170+
return response.change(body: body);
171+
};
172+
}
173+
174+
/// Hot-reload config
175+
///
176+
/// Listen WebSocket for updates in build results
177+
///
178+
/// Now only live-reload functional - just reload page on update message
179+
final _buildUpdatesInjectedJS = '''\n
180+
// Injected by build_runner for live reload support
181+
(function() {
182+
var ws = new WebSocket('ws://' + location.host, ['$_buildUpdatesProtocol']);
183+
ws.onmessage = function(event) {
184+
console.log(event);
185+
if(event.data === '$_buildUpdatesMessage'){
186+
location.reload();
187+
}
188+
};
189+
}());
190+
''';
191+
113192
class AssetHandler {
114193
final FinalizedReader _reader;
115194
final String _rootPackage;

build_runner/pubspec.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: build_runner
2-
version: 0.10.0
2+
version: 0.10.1-dev
33
description: Tools to write binaries that run builders.
44
author: Dart Team <[email protected]>
55
homepage: https://github.com/dart-lang/build/tree/master/build_runner
@@ -34,9 +34,11 @@ dependencies:
3434
pub_semver: ^1.4.0
3535
pubspec_parse: ^0.1.0
3636
shelf: ">=0.6.5 <0.8.0"
37+
shelf_web_socket: ^0.2.2+3
3738
stack_trace: ^1.9.0
3839
stream_transform: ^0.0.9
3940
watcher: ^0.9.7
41+
web_socket_channel: ^1.0.9
4042
yaml: ^2.1.0
4143

4244
dev_dependencies:

build_runner/test/server/serve_handler_test.dart

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'dart:io';
77

88
import 'package:logging/logging.dart';
99
import 'package:shelf/shelf.dart';
10+
import 'package:stream_channel/stream_channel.dart';
1011
import 'package:test/test.dart';
1112

1213
import 'package:build_runner/build_runner.dart';
@@ -20,6 +21,7 @@ import 'package:build_runner/src/server/server.dart';
2021

2122
import 'package:_test_common/common.dart';
2223
import 'package:_test_common/package_graphs.dart';
24+
import 'package:web_socket_channel/web_socket_channel.dart';
2325

2426
void main() {
2527
ServeHandler serveHandler;
@@ -162,6 +164,137 @@ void main() {
162164
expect(await response.readAsString(), contains('--track-performance'));
163165
});
164166
});
167+
168+
group('build updates', () {
169+
test('injects client code if enabled', () async {
170+
_addSource('a|web/some.js', entrypointExtensionMarker + '\nalert(1)');
171+
var response = await serveHandler.handlerFor('web', liveReload: true)(
172+
new Request('GET', Uri.parse('http://server.com/some.js')));
173+
expect(await response.readAsString(), contains('\$livereload'));
174+
});
175+
176+
test('doesn\'t inject client code if disabled', () async {
177+
_addSource('a|web/some.js', entrypointExtensionMarker + '\nalert(1)');
178+
var response = await serveHandler.handlerFor('web', liveReload: false)(
179+
new Request('GET', Uri.parse('http://server.com/some.js')));
180+
expect(await response.readAsString(), isNot(contains('\$livereload')));
181+
});
182+
183+
test('doesn\'t inject client code in non-js files', () async {
184+
_addSource('a|web/some.html', entrypointExtensionMarker + '\n<br>some');
185+
var response = await serveHandler.handlerFor('web', liveReload: true)(
186+
new Request('GET', Uri.parse('http://server.com/some.html')));
187+
expect(await response.readAsString(), isNot(contains('\$livereload')));
188+
});
189+
190+
test('doesn\'t inject client code in non-marked files', () async {
191+
_addSource('a|web/some.js', 'alert(1)');
192+
var response = await serveHandler.handlerFor('web', liveReload: true)(
193+
new Request('GET', Uri.parse('http://server.com/some.js')));
194+
expect(await response.readAsString(), isNot(contains('\$livereload')));
195+
});
196+
197+
test('expect websocket connection if enabled', () async {
198+
_addSource('a|web/index.html', 'content');
199+
expect(
200+
serveHandler.handlerFor('web', liveReload: true)(
201+
new Request('GET', Uri.parse('ws://server.com/'),
202+
headers: {
203+
'Connection': 'Upgrade',
204+
'Upgrade': 'websocket',
205+
'Sec-WebSocket-Version': '13',
206+
'Sec-WebSocket-Key': 'abc',
207+
},
208+
onHijack: (f) {})),
209+
throwsA(TypeMatcher<HijackException>()));
210+
});
211+
212+
test('reject websocket connection if disabled', () async {
213+
_addSource('a|web/index.html', 'content');
214+
var response = await serveHandler.handlerFor('web', liveReload: false)(
215+
new Request('GET', Uri.parse('ws://server.com/'), headers: {
216+
'Connection': 'Upgrade',
217+
'Upgrade': 'websocket',
218+
'Sec-WebSocket-Version': '13',
219+
'Sec-WebSocket-Key': 'abc',
220+
}));
221+
expect(response.statusCode, 200);
222+
expect(await response.readAsString(), 'content');
223+
});
224+
225+
group('WebSocket handler', () {
226+
BuildUpdatesWebSocketHandler buildUpdatesWebSocketHandler;
227+
Function createMockConection;
228+
229+
// client to server stream controlllers
230+
StreamController<List<int>> c2sController1;
231+
StreamController<List<int>> c2sController2;
232+
// server to client stream controlllers
233+
StreamController<List<int>> s2cController1;
234+
StreamController<List<int>> s2cController2;
235+
236+
WebSocketChannel clientChannel1;
237+
WebSocketChannel clientChannel2;
238+
WebSocketChannel serverChannel1;
239+
WebSocketChannel serverChannel2;
240+
241+
setUp(() {
242+
var mockHandlerFactory = (Function onConnect, {protocols}) {
243+
createMockConection =
244+
(WebSocketChannel serverChannel) => onConnect(serverChannel, '');
245+
};
246+
buildUpdatesWebSocketHandler =
247+
BuildUpdatesWebSocketHandler(mockHandlerFactory);
248+
249+
c2sController1 = StreamController<List<int>>();
250+
s2cController1 = StreamController<List<int>>();
251+
serverChannel1 = WebSocketChannel(
252+
StreamChannel(c2sController1.stream, s2cController1.sink),
253+
serverSide: true);
254+
clientChannel1 = WebSocketChannel(
255+
StreamChannel(s2cController1.stream, c2sController1.sink),
256+
serverSide: false);
257+
258+
c2sController2 = StreamController<List<int>>();
259+
s2cController2 = StreamController<List<int>>();
260+
serverChannel2 = WebSocketChannel(
261+
StreamChannel(c2sController2.stream, s2cController2.sink),
262+
serverSide: true);
263+
clientChannel2 = WebSocketChannel(
264+
StreamChannel(s2cController2.stream, c2sController2.sink),
265+
serverSide: false);
266+
});
267+
268+
tearDown(() {
269+
c2sController1.close();
270+
s2cController1.close();
271+
c2sController2.close();
272+
s2cController2.close();
273+
});
274+
275+
test('emmits a message to all listners', () async {
276+
expect(clientChannel1.stream, emitsInOrder(['update', emitsDone]));
277+
expect(clientChannel2.stream, emitsInOrder(['update', emitsDone]));
278+
createMockConection(serverChannel1);
279+
createMockConection(serverChannel2);
280+
buildUpdatesWebSocketHandler.emitUpdateMessage(null);
281+
await clientChannel1.sink.close();
282+
await clientChannel2.sink.close();
283+
});
284+
285+
test('deletes listners on disconect', () async {
286+
expect(clientChannel1.stream,
287+
emitsInOrder(['update', 'update', emitsDone]));
288+
expect(clientChannel2.stream, emitsInOrder(['update', emitsDone]));
289+
createMockConection(serverChannel1);
290+
createMockConection(serverChannel2);
291+
buildUpdatesWebSocketHandler.emitUpdateMessage(null);
292+
await clientChannel2.sink.close();
293+
buildUpdatesWebSocketHandler.emitUpdateMessage(null);
294+
await clientChannel1.sink.close();
295+
});
296+
});
297+
});
165298
}
166299

167300
class MockWatchImpl implements WatchImpl {

build_web_compilers/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 0.4.2
2+
3+
- Add magic comment marker for build_runner to know where to inject
4+
live-reloading client code.
5+
16
## 0.4.1
27

38
- Support the latest build_modules, with updated dart2js support so that it can

0 commit comments

Comments
 (0)