Skip to content

Commit e58c951

Browse files
authored
feat: RelicApp now supports hot-reload (#226)
Adds hot-reload support to `RelicApp`
1 parent c07c4c8 commit e58c951

File tree

7 files changed

+229
-85
lines changed

7 files changed

+229
-85
lines changed

lib/src/relic_server.dart

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,8 @@ class RelicServer {
2828
///
2929
/// Only one handler can be mounted at a time.
3030
Future<void> mountAndStart(final Handler handler) async {
31-
if (_handler != null) {
32-
throw StateError(
33-
'Relic server already has a handler mounted.',
34-
);
35-
}
3631
_handler = _wrapHandlerWithMiddleware(handler);
37-
await _startListening();
32+
if (_subscription == null) await _startListening();
3833
}
3934

4035
/// Close the server

lib/src/router/relic_app.dart

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
part of 'router.dart';
2+
3+
/// The main application class for a Relic web server.
4+
///
5+
/// [RelicApp] extends [RelicRouter] and provides a convenient way to create,
6+
/// configure, and run a Relic HTTP server.
7+
final class RelicApp implements RelicRouter {
8+
RelicServer? _server;
9+
StreamSubscription? _reloadSubscription;
10+
var delegate = RelicRouter();
11+
final _setup = <RouterInjectable>[];
12+
13+
/// Creates and starts a [RelicServer] with the configured routes.
14+
///
15+
/// The [adapterFactory] is a function that returns an [Adapter] for handling
16+
/// HTTP requests. This can be a synchronous function or an async function.
17+
/// The adapter factory pattern allows for deferred initialization of server
18+
/// resources.
19+
///
20+
/// Returns a [Future] that completes with the running [RelicServer] instance.
21+
///
22+
/// [RelicApp] instances supports hot-reload. They will re-configure the internal
23+
/// router on hot-reload. This allows adding and removing routes despite `main`
24+
/// not being re-run.
25+
///
26+
/// Example:
27+
/// ```dart
28+
/// final app = RelicApp()
29+
/// ..get('/', (ctx) => ctx.ok('Hello!'));
30+
///
31+
/// final adaptorFactory = () => IOAdapter.bind(InternetAddress.loopbackIPv4, port: 8080);
32+
/// final server = await app.run(adapterFactory);
33+
///
34+
/// // later .. when done
35+
///
36+
/// await app.close();
37+
/// ```
38+
///
39+
/// Check out [RelicAppIOServeEx.serve] if you are using `dart:io` to avoid
40+
/// specifying [adapterFactory] explicitly.
41+
Future<RelicServer> run(
42+
final FutureOr<Adapter> Function() adapterFactory,
43+
) async {
44+
if (_server != null) throw StateError('Cannot call run twice');
45+
_server = RelicServer(await adapterFactory());
46+
await _init();
47+
_reloadSubscription = await _hotReloader.register(this);
48+
return _server!;
49+
}
50+
51+
Future<void> close() async {
52+
await _reloadSubscription?.cancel();
53+
await _server?.close();
54+
}
55+
56+
Future<void> _init() async {
57+
await _server!.mountAndStart(delegate.asHandler);
58+
}
59+
60+
Future<void> _reload() async {
61+
await _rebuild();
62+
await _init();
63+
}
64+
65+
Future<void> _rebuild() async {
66+
delegate = RelicRouter();
67+
for (final injectable in _setup) {
68+
delegate.inject(injectable);
69+
}
70+
}
71+
72+
void _injectAndTrack(final RouterInjectable injectable) {
73+
delegate.inject(injectable);
74+
_setup.add(injectable);
75+
}
76+
77+
@override
78+
void add(final Method method, final String path, final Handler route) =>
79+
inject(_Injectable((final r) => r.add(method, path, route)));
80+
81+
@override
82+
void attach(final String path, final RelicRouter subRouter) =>
83+
inject(_Injectable((final r) => r.attach(path, subRouter)));
84+
85+
@override
86+
void use(final String path, final Middleware map) =>
87+
inject(_Injectable((final r) => r.use(path, map)));
88+
89+
@override
90+
void inject(final RouterInjectable injectable) => _injectAndTrack(injectable);
91+
92+
@override
93+
Handler? get fallback => delegate.fallback;
94+
95+
@override
96+
set fallback(final Handler? value) =>
97+
inject(_Injectable((final r) => r.fallback = value));
98+
99+
@override
100+
PathTrie<_RouterEntry<Handler>> get _allRoutes => delegate._allRoutes;
101+
102+
@override
103+
bool get isEmpty => delegate.isEmpty;
104+
105+
@override
106+
LookupResult<Handler> lookup(final Method method, final String path) =>
107+
delegate.lookup(method, path);
108+
}
109+
110+
final class _Injectable implements RouterInjectable {
111+
final void Function(RelicRouter) setup;
112+
113+
_Injectable(this.setup);
114+
115+
@override
116+
void injectIn(final RelicRouter owner) => setup(owner);
117+
}
118+
119+
class _HotReloader {
120+
static final Future<Stream<void>?> _reloadStream = _init();
121+
122+
static Future<Stream<void>?> _init() async {
123+
final wsUri = (await Service.getInfo()).serverWebSocketUri;
124+
if (wsUri != null) {
125+
final vmService = await vmi.vmServiceConnectUri(wsUri.toString());
126+
const streamId = vm.EventStreams.kIsolate;
127+
return vmService.onIsolateEvent
128+
.asBroadcastStream(
129+
onListen: (final _) => vmService.streamListen(streamId),
130+
onCancel: (final _) => vmService.streamCancel(streamId))
131+
.where((final e) => e.kind == vm.EventKind.kIsolateReload);
132+
}
133+
return null; // no vm service available
134+
}
135+
136+
Future<StreamSubscription?> register(final RelicApp app) async {
137+
final reloadStream = await _reloadStream;
138+
if (reloadStream != null) {
139+
return reloadStream
140+
.asyncMap((final _) => app._reload())
141+
.listen((final _) {});
142+
}
143+
return null;
144+
}
145+
}
146+
147+
final _hotReloader = _HotReloader();

lib/src/router/router.dart

Lines changed: 21 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import 'dart:async';
2+
import 'dart:developer';
3+
4+
import 'package:vm_service/vm_service.dart' as vm;
5+
import 'package:vm_service/vm_service_io.dart' as vmi;
26

37
import '../../relic.dart';
48
import 'normalized_path.dart';
59
import 'path_trie.dart';
610

11+
part 'relic_app.dart';
12+
713
/// A wrapper around a fixed-length list used for mapping between method and value
814
/// for each registered path.
915
extension type _RouterEntry<T extends Object>._(List<T?> _routeByVerb)
@@ -36,6 +42,15 @@ extension<T extends Object> on _RouterEntry<T>? {
3642
_RouterEntry<T> get orNew => this ?? _RouterEntry<T>();
3743
}
3844

45+
/// Interface for objects that can be injected in others. This allows
46+
/// an object to delegate setup to another object.
47+
///
48+
/// Used by [Router.inject] which takes an [InjectableIn<Router<T>>].
49+
abstract interface class InjectableIn<T> {
50+
/// Overwrite this to define how to inject this object in [owner].
51+
void injectIn(final T owner);
52+
}
53+
3954
/// A URL router that maps path patterns to values of type [T].
4055
///
4156
/// Supports static paths (e.g., `/users/profile`) and paths with named parameters
@@ -113,6 +128,11 @@ final class Router<T extends Object> {
113128
_allRoutes.attach(NormalizedPath(path), subRouter._allRoutes);
114129
}
115130

131+
/// Injects an [injectable] into the router. Unlike [add] it allows
132+
/// the [injectable] object to determine how to be mounted on the router.
133+
void inject(final InjectableIn<Router<T>> injectable) =>
134+
injectable.injectIn(this);
135+
116136
/// Looks up a route matching the provided [path].
117137
///
118138
/// The input [path] string is normalized before lookup. Static routes are
@@ -244,39 +264,4 @@ typedef RelicRouter = Router<Handler>;
244264
/// ResponseContext delete(final NewContext ctx) { }
245265
/// }
246266
/// ```
247-
abstract interface class RouterInjectable {
248-
void injectIn(final RelicRouter router);
249-
}
250-
251-
/// The main application class for a Relic web server.
252-
///
253-
/// [RelicApp] extends [RelicRouter] and provides a convenient way to create,
254-
/// configure, and run a Relic HTTP server.
255-
final class RelicApp extends RelicRouter {
256-
/// Creates and starts a [RelicServer] with the configured routes.
257-
///
258-
/// The [adapterFactory] is a function that returns an [Adapter] for handling
259-
/// HTTP requests. This can be a synchronous function or an async function.
260-
/// The adapter factory pattern allows for deferred initialization of server
261-
/// resources.
262-
///
263-
/// Returns a [Future] that completes with the running [RelicServer] instance.
264-
///
265-
/// Example:
266-
/// ```dart
267-
/// final app = RelicApp()
268-
/// ..get('/', (ctx) => ctx.ok('Hello!'));
269-
///
270-
/// final server = await app.run(adapterFactory);
271-
/// ```
272-
///
273-
/// Check out [RelicAppIOServeEx.serve] if you are using `dart:io` to avoid
274-
/// specifying [adapterFactory] explicitly.
275-
Future<RelicServer> run(
276-
final FutureOr<Adapter> Function() adapterFactory,
277-
) async {
278-
final server = RelicServer(await adapterFactory());
279-
await server.mountAndStart(call);
280-
return server;
281-
}
282-
}
267+
typedef RouterInjectable = InjectableIn<RelicRouter>;

lib/src/router/router_handler_extension.dart

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,4 @@ extension RouterHandlerEx on RelicRouter {
2727
FutureOr<HandledContext> call(final NewContext ctx) =>
2828
const Pipeline().addMiddleware(routeWith(this)).addHandler(
2929
fallback ?? respondWith((final _) => Response.notFound()))(ctx);
30-
31-
/// Injects a handler object into the router. Unlike [add] it allows
32-
/// the [handler] object to determine how to be mounted on the router.
33-
void inject(final RouterInjectable handler) => handler.injectIn(this);
3430
}

pubspec.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies:
1717
path: ^1.8.3
1818
stack_trace: ^1.10.0
1919
stream_channel: ^2.1.1
20+
vm_service: ^15.0.0
2021
web_socket: ^1.0.1
2122
web_socket_channel: ^3.0.3
2223

@@ -31,7 +32,7 @@ dev_dependencies:
3132
routingkit: ^5.1.2
3233
serverpod_lints: ^2.8.0
3334
spanner: ^1.0.5
34-
test: ^1.25.5
35+
test: ^1.25.10
3536
test_descriptor: ^2.0.1
3637
# The following are not used directly, but are included transitively.
3738
# Due to bad semver hygiene we need to bound the versions higher than

test/relic_server_test.dart

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,5 @@ void main() {
4545
await server.mountAndStart(asyncHandler);
4646
expect(delayedResponse, completion(equals('Hello from /')));
4747
});
48-
49-
test(
50-
'when a handler is already mounted '
51-
'then mounting another handler throws a StateError', () async {
52-
await server.mountAndStart((final _) => throw UnimplementedError());
53-
expect(
54-
() => server.mountAndStart((final _) => throw UnimplementedError()),
55-
throwsStateError,
56-
);
57-
expect(
58-
() => server.mountAndStart((final _) => throw UnimplementedError()),
59-
throwsStateError,
60-
);
61-
});
6248
});
6349
}

0 commit comments

Comments
 (0)