From cd667c73bdce2033913c6798c0ead4ceb158608c Mon Sep 17 00:00:00 2001 From: Dillon Nys <24740863+dnys1@users.noreply.github.com> Date: Fri, 7 Jun 2024 01:31:10 -0700 Subject: [PATCH] feat: Event streaming (#145) Adds support for server-side event streaming via SSE/WebSocket connections. --- .github/workflows/celest.yaml | 9 + examples/gemini/celest/lib/client.dart | 5 +- examples/gemini/celest/pubspec.yaml | 4 +- examples/openai/celest/lib/client.dart | 5 +- examples/openai/celest/pubspec.yaml | 4 +- examples/todo/celest/lib/client.dart | 5 +- .../todo/celest/lib/src/client/functions.dart | 11 + .../celest/lib/src/client/serializers.dart | 35 +++ examples/todo/celest/pubspec.yaml | 4 +- .../celest/example/celest/lib/client.dart | 9 +- .../celest/lib/src/client/functions.dart | 2 +- packages/celest/example/celest/pubspec.yaml | 4 +- packages/celest/example/ios/Podfile.lock | 16 + .../ios/Runner.xcodeproj/project.pbxproj | 116 ++++++- .../contents.xcworkspacedata | 3 + packages/celest/example/macos/Podfile.lock | 16 + .../macos/Runner.xcodeproj/project.pbxproj | 80 ++++- .../contents.xcworkspacedata | 3 + packages/celest/example/pubspec.yaml | 1 + .../celest/lib/src/runtime/json_utils.dart | 16 +- packages/celest/lib/src/runtime/serve.dart | 172 ++++++++++- .../lib/src/runtime/sse/sse_handler.dart | 289 ++++++++++++++++++ packages/celest/pubspec.yaml | 9 + packages/celest/test/celest_test.dart | 5 + packages/celest/test/runtime/sse_server.dart | 37 +++ packages/celest/test/runtime/sse_test.dart | 58 ++++ .../example/celest/lib/client.dart | 7 +- .../celest_auth/example/celest/pubspec.yaml | 2 +- packages/celest_core/lib/_internal.dart | 4 + packages/celest_core/lib/celest_core.dart | 4 + .../lib/src/auth/authenticator.dart | 14 + .../lib/src/base/base_protocol.dart | 11 + .../celest_core/lib/src/base/celest_base.dart | 2 + .../lib/src/events/event_channel.dart | 17 ++ .../lib/src/events/event_channel.vm.dart | 54 ++++ .../lib/src/events/event_channel.web.dart | 34 +++ .../lib/src/events/event_client.dart | 27 ++ .../lib/src/events/sse/sse_client.dart | 29 ++ .../lib/src/events/sse/sse_client.vm.dart | 12 + .../lib/src/events/sse/sse_client.web.dart | 182 +++++++++++ .../lib/src/http/celest_http_client.dart | 11 +- packages/celest_core/lib/src/util/json.dart | 47 +++ packages/celest_core/lib/src/util/uuid.dart | 107 +++++++ packages/celest_core/pubspec.yaml | 14 +- 44 files changed, 1424 insertions(+), 72 deletions(-) create mode 100644 packages/celest/example/ios/Podfile.lock create mode 100644 packages/celest/example/macos/Podfile.lock create mode 100644 packages/celest/lib/src/runtime/sse/sse_handler.dart create mode 100644 packages/celest/test/celest_test.dart create mode 100644 packages/celest/test/runtime/sse_server.dart create mode 100644 packages/celest/test/runtime/sse_test.dart create mode 100644 packages/celest_core/lib/src/auth/authenticator.dart create mode 100644 packages/celest_core/lib/src/events/event_channel.dart create mode 100644 packages/celest_core/lib/src/events/event_channel.vm.dart create mode 100644 packages/celest_core/lib/src/events/event_channel.web.dart create mode 100644 packages/celest_core/lib/src/events/event_client.dart create mode 100644 packages/celest_core/lib/src/events/sse/sse_client.dart create mode 100644 packages/celest_core/lib/src/events/sse/sse_client.vm.dart create mode 100644 packages/celest_core/lib/src/events/sse/sse_client.web.dart create mode 100644 packages/celest_core/lib/src/util/json.dart create mode 100644 packages/celest_core/lib/src/util/uuid.dart diff --git a/.github/workflows/celest.yaml b/.github/workflows/celest.yaml index fc269933..22ff52f2 100644 --- a/.github/workflows/celest.yaml +++ b/.github/workflows/celest.yaml @@ -33,3 +33,12 @@ jobs: - name: Format working-directory: packages/celest run: dart format --set-exit-if-changed . + - name: Test + working-directory: packages/celest + run: dart test + - name: Test (dart2js) + working-directory: packages/celest + run: dart test -p chrome + - name: Test (dart2wasm) + working-directory: packages/celest + run: dart test -p chrome -c dart2wasm diff --git a/examples/gemini/celest/lib/client.dart b/examples/gemini/celest/lib/client.dart index 72ea989c..e33f0900 100644 --- a/examples/gemini/celest/lib/client.dart +++ b/examples/gemini/celest/lib/client.dart @@ -30,11 +30,12 @@ class Celest with CelestBase { late CelestEnvironment _currentEnvironment; - late final NativeStorage _storage = NativeStorage(scope: 'celest'); + @override + late final NativeStorage nativeStorage = NativeStorage(scope: 'celest'); @override late _$http.Client httpClient = - CelestHttpClient(secureStorage: _storage.secure); + CelestHttpClient(secureStorage: nativeStorage.secure); late Uri _baseUri; diff --git a/examples/gemini/celest/pubspec.yaml b/examples/gemini/celest/pubspec.yaml index a78364ce..b5686aa3 100644 --- a/examples/gemini/celest/pubspec.yaml +++ b/examples/gemini/celest/pubspec.yaml @@ -6,7 +6,7 @@ environment: sdk: ^3.3.0 dependencies: - celest: ^0.4.0-0 + celest: ^0.4.0 google_generative_ai: ^0.2.0 http: ">=0.13.0 <2.0.0" @@ -20,4 +20,4 @@ dependency_overrides: dev_dependencies: lints: ^4.0.0 - test: ^1.24.0 + test: ^1.25.0 diff --git a/examples/openai/celest/lib/client.dart b/examples/openai/celest/lib/client.dart index 72ea989c..e33f0900 100644 --- a/examples/openai/celest/lib/client.dart +++ b/examples/openai/celest/lib/client.dart @@ -30,11 +30,12 @@ class Celest with CelestBase { late CelestEnvironment _currentEnvironment; - late final NativeStorage _storage = NativeStorage(scope: 'celest'); + @override + late final NativeStorage nativeStorage = NativeStorage(scope: 'celest'); @override late _$http.Client httpClient = - CelestHttpClient(secureStorage: _storage.secure); + CelestHttpClient(secureStorage: nativeStorage.secure); late Uri _baseUri; diff --git a/examples/openai/celest/pubspec.yaml b/examples/openai/celest/pubspec.yaml index c6cc1b96..cefea8d0 100644 --- a/examples/openai/celest/pubspec.yaml +++ b/examples/openai/celest/pubspec.yaml @@ -6,7 +6,7 @@ environment: sdk: ^3.3.0 dependencies: - celest: ^0.4.0-0 + celest: ^0.4.0 chat_gpt_sdk: ^2.2.8 http: ">=0.13.0 <2.0.0" @@ -20,4 +20,4 @@ dependency_overrides: dev_dependencies: lints: ^4.0.0 - test: ^1.24.0 + test: ^1.25.0 diff --git a/examples/todo/celest/lib/client.dart b/examples/todo/celest/lib/client.dart index 72ea989c..e33f0900 100644 --- a/examples/todo/celest/lib/client.dart +++ b/examples/todo/celest/lib/client.dart @@ -30,11 +30,12 @@ class Celest with CelestBase { late CelestEnvironment _currentEnvironment; - late final NativeStorage _storage = NativeStorage(scope: 'celest'); + @override + late final NativeStorage nativeStorage = NativeStorage(scope: 'celest'); @override late _$http.Client httpClient = - CelestHttpClient(secureStorage: _storage.secure); + CelestHttpClient(secureStorage: nativeStorage.secure); late Uri _baseUri; diff --git a/examples/todo/celest/lib/src/client/functions.dart b/examples/todo/celest/lib/src/client/functions.dart index 62d7d120..ca15b338 100644 --- a/examples/todo/celest/lib/src/client/functions.dart +++ b/examples/todo/celest/lib/src/client/functions.dart @@ -9,6 +9,8 @@ import 'dart:convert' as _$convert; import 'package:celest/celest.dart'; import 'package:celest_backend/exceptions.dart' as _$exceptions; import 'package:celest_backend/models.dart' as _$models; +import 'package:celest_core/src/exception/cloud_exception.dart'; +import 'package:celest_core/src/exception/serialization_exception.dart'; import '../../client.dart'; @@ -25,6 +27,15 @@ class CelestFunctionsTasks { final $code = ($error['code'] as String); final $details = ($error['details'] as Map?); switch ($code) { + case r'BadRequestException': + throw Serializers.instance.deserialize($details); + case r'UnauthorizedException': + throw Serializers.instance.deserialize($details); + case r'InternalServerError': + throw Serializers.instance.deserialize($details); + case r'SerializationException': + throw Serializers.instance + .deserialize($details); case r'ServerException': throw Serializers.instance .deserialize<_$exceptions.ServerException>($details); diff --git a/examples/todo/celest/lib/src/client/serializers.dart b/examples/todo/celest/lib/src/client/serializers.dart index 645be481..8042333f 100644 --- a/examples/todo/celest/lib/src/client/serializers.dart +++ b/examples/todo/celest/lib/src/client/serializers.dart @@ -4,6 +4,8 @@ import 'package:celest/celest.dart'; import 'package:celest_backend/exceptions.dart' as _$exceptions; import 'package:celest_backend/models.dart' as _$models; +import 'package:celest_core/src/exception/cloud_exception.dart'; +import 'package:celest_core/src/exception/serialization_exception.dart'; void initSerializers() { Serializers.instance.put( @@ -38,4 +40,37 @@ void initSerializers() { ); }, )); + Serializers.instance + .put(Serializer.define>( + serialize: ($value) => {r'message': $value.message}, + deserialize: ($serialized) { + return BadRequestException(($serialized[r'message'] as String)); + }, + )); + Serializers.instance + .put(Serializer.define>( + serialize: ($value) => {r'message': $value.message}, + deserialize: ($serialized) { + return InternalServerError(($serialized[r'message'] as String)); + }, + )); + Serializers.instance + .put(Serializer.define?>( + serialize: ($value) => {r'message': $value.message}, + deserialize: ($serialized) { + return UnauthorizedException( + (($serialized?[r'message'] as String?)) ?? 'Unauthorized'); + }, + )); + Serializers.instance + .put(Serializer.define>( + serialize: ($value) => { + r'message': $value.message, + r'offset': $value.offset, + r'source': $value.source, + }, + deserialize: ($serialized) { + return SerializationException(($serialized[r'message'] as String)); + }, + )); } diff --git a/examples/todo/celest/pubspec.yaml b/examples/todo/celest/pubspec.yaml index 1c773c5a..569f77f5 100644 --- a/examples/todo/celest/pubspec.yaml +++ b/examples/todo/celest/pubspec.yaml @@ -6,7 +6,7 @@ environment: sdk: ^3.3.0 dependencies: - celest: ^0.4.0-0 + celest: ^0.4.0 http: ">=0.13.0 <2.0.0" uuid: ^4.3.3 @@ -20,4 +20,4 @@ dependency_overrides: dev_dependencies: lints: ^4.0.0 - test: ^1.24.0 + test: ^1.25.0 diff --git a/packages/celest/example/celest/lib/client.dart b/packages/celest/example/celest/lib/client.dart index 72ea989c..9627541c 100644 --- a/packages/celest/example/celest/lib/client.dart +++ b/packages/celest/example/celest/lib/client.dart @@ -20,8 +20,8 @@ enum CelestEnvironment { Uri get baseUri => switch (this) { local => kIsWeb || !_$io.Platform.isAndroid - ? Uri.parse('http://localhost:7777') - : Uri.parse('http://10.0.2.2:7777'), + ? Uri.parse('http://localhost:7778') + : Uri.parse('http://10.0.2.2:7778'), }; } @@ -30,11 +30,12 @@ class Celest with CelestBase { late CelestEnvironment _currentEnvironment; - late final NativeStorage _storage = NativeStorage(scope: 'celest'); + @override + late final NativeStorage nativeStorage = NativeStorage(scope: 'celest'); @override late _$http.Client httpClient = - CelestHttpClient(secureStorage: _storage.secure); + CelestHttpClient(secureStorage: nativeStorage.secure); late Uri _baseUri; diff --git a/packages/celest/example/celest/lib/src/client/functions.dart b/packages/celest/example/celest/lib/src/client/functions.dart index f40a557a..70e57fc5 100644 --- a/packages/celest/example/celest/lib/src/client/functions.dart +++ b/packages/celest/example/celest/lib/src/client/functions.dart @@ -54,7 +54,7 @@ class CelestFunctionsGreeting { Future sayHello({required _$person.Person person}) async { final $response = await celest.httpClient.post( celest.baseUri.resolve('/greeting/say-hello'), - headers: const {'Content-Type': 'application/json; charset=utf-8'}, + headers: {'Content-Type': 'application/json; charset=utf-8'}, body: _$convert.jsonEncode( {r'person': Serializers.instance.serialize<_$person.Person>(person)}), ); diff --git a/packages/celest/example/celest/pubspec.yaml b/packages/celest/example/celest/pubspec.yaml index 81e49e75..f2984e1c 100644 --- a/packages/celest/example/celest/pubspec.yaml +++ b/packages/celest/example/celest/pubspec.yaml @@ -6,7 +6,7 @@ environment: sdk: ^3.3.0 dependencies: - celest: ^0.4.0-0 + celest: ^0.4.0 http: ">=0.13.0 <2.0.0" dependency_overrides: @@ -19,4 +19,4 @@ dependency_overrides: dev_dependencies: lints: ^4.0.0 - test: ^1.24.0 + test: ^1.25.0 diff --git a/packages/celest/example/ios/Podfile.lock b/packages/celest/example/ios/Podfile.lock new file mode 100644 index 00000000..9cbc8fa2 --- /dev/null +++ b/packages/celest/example/ios/Podfile.lock @@ -0,0 +1,16 @@ +PODS: + - Flutter (1.0.0) + +DEPENDENCIES: + - Flutter (from `Flutter`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + +SPEC CHECKSUMS: + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + +PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796 + +COCOAPODS: 1.15.2 diff --git a/packages/celest/example/ios/Runner.xcodeproj/project.pbxproj b/packages/celest/example/ios/Runner.xcodeproj/project.pbxproj index 4363bd5a..0dee5103 100644 --- a/packages/celest/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/celest/example/ios/Runner.xcodeproj/project.pbxproj @@ -8,12 +8,14 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 382187685D71EFFA1591C6E2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6005F5A98098079BDF4F3E85 /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 4022F45145CA40EE3D68F8E1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B2A6A99315EA5329BEFABB3 /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,7 +44,15 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 155347D1478AD5776DA3E51B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 41DFAFC522D601104BF8B4BD /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 5A9418D1D94F7B9CEDAA8141 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 5B2A6A99315EA5329BEFABB3 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6005F5A98098079BDF4F3E85 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 652F51366753C66EBE5D6513 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -53,8 +63,8 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 99EA01F6AA172445DF40C6D0 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + BE68533C68821515DD5FF872 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -62,21 +72,33 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 382187685D71EFFA1591C6E2 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EDB3A84D9D9E7B92BC885C4F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 4022F45145CA40EE3D68F8E1 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 9740EEB11CF90186004384FC /* Flutter */ = { + 1AAEAF4BF3C8F60E80C12ED5 /* Pods */ = { isa = PBXGroup; children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, + 652F51366753C66EBE5D6513 /* Pods-Runner.debug.xcconfig */, + 99EA01F6AA172445DF40C6D0 /* Pods-Runner.release.xcconfig */, + 41DFAFC522D601104BF8B4BD /* Pods-Runner.profile.xcconfig */, + 155347D1478AD5776DA3E51B /* Pods-RunnerTests.debug.xcconfig */, + 5A9418D1D94F7B9CEDAA8141 /* Pods-RunnerTests.release.xcconfig */, + BE68533C68821515DD5FF872 /* Pods-RunnerTests.profile.xcconfig */, ); - name = Flutter; + name = Pods; + path = Pods; sourceTree = ""; }; 331C8082294A63A400263BE5 /* RunnerTests */ = { @@ -87,6 +109,26 @@ path = RunnerTests; sourceTree = ""; }; + 6C1AE2831F24D49CE111B88F /* Frameworks */ = { + isa = PBXGroup; + children = ( + 6005F5A98098079BDF4F3E85 /* Pods_Runner.framework */, + 5B2A6A99315EA5329BEFABB3 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( @@ -94,6 +136,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 1AAEAF4BF3C8F60E80C12ED5 /* Pods */, + 6C1AE2831F24D49CE111B88F /* Frameworks */, ); sourceTree = ""; }; @@ -128,9 +172,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 6F9D29FDE076053EBE879B1D /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, - 331C807E294A63A400263BE5 /* Frameworks */, 331C807F294A63A400263BE5 /* Resources */, + EDB3A84D9D9E7B92BC885C4F /* Frameworks */, ); buildRules = ( ); @@ -146,6 +191,7 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 1AF4ED20C80DA4BF725AA3DC /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, @@ -223,6 +269,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 1AF4ED20C80DA4BF725AA3DC /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -239,6 +307,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 6F9D29FDE076053EBE879B1D /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -378,7 +468,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = AE0B7B92F70575B8D7E0D07E /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 155347D1478AD5776DA3E51B /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -396,7 +486,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 89B67EB44CE7B6631473024E /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = 5A9418D1D94F7B9CEDAA8141 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -412,7 +502,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 640959BDD8F10B91D80A66BE /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = BE68533C68821515DD5FF872 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/packages/celest/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/celest/example/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16..21a3cc14 100644 --- a/packages/celest/example/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/packages/celest/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/packages/celest/example/macos/Podfile.lock b/packages/celest/example/macos/Podfile.lock new file mode 100644 index 00000000..006d87bd --- /dev/null +++ b/packages/celest/example/macos/Podfile.lock @@ -0,0 +1,16 @@ +PODS: + - FlutterMacOS (1.0.0) + +DEPENDENCIES: + - FlutterMacOS (from `Flutter/ephemeral`) + +EXTERNAL SOURCES: + FlutterMacOS: + :path: Flutter/ephemeral + +SPEC CHECKSUMS: + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + +PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367 + +COCOAPODS: 1.15.2 diff --git a/packages/celest/example/macos/Runner.xcodeproj/project.pbxproj b/packages/celest/example/macos/Runner.xcodeproj/project.pbxproj index 8b005e22..05c5cfd2 100644 --- a/packages/celest/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/celest/example/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 39FF6B64195BF230DE8FC4D6 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9A327F97CCE0F5CF730A9FB2 /* Pods_Runner.framework */; }; + 971BA6C1F72661A41AF2B514 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DFEEEB6915DE7D3151DD4082 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -64,7 +66,7 @@ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* example_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "example_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* example_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example_app.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +78,16 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 474B68257DCA9153CF6B57FC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 4B0D5799B83145E7E84F8789 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 7E7B0683D22F9CDD815AB9D0 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 95AD79BCE84BCD2B41E58C0E /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 9A327F97CCE0F5CF730A9FB2 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + ADD2E19944CD951E95CB3C9F /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + DE6A1D19510ED15464C7432F /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + DFEEEB6915DE7D3151DD4082 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 971BA6C1F72661A41AF2B514 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,12 +103,27 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 39FF6B64195BF230DE8FC4D6 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 289674A443DF37F3D4A78B81 /* Pods */ = { + isa = PBXGroup; + children = ( + 95AD79BCE84BCD2B41E58C0E /* Pods-Runner.debug.xcconfig */, + 7E7B0683D22F9CDD815AB9D0 /* Pods-Runner.release.xcconfig */, + 4B0D5799B83145E7E84F8789 /* Pods-Runner.profile.xcconfig */, + DE6A1D19510ED15464C7432F /* Pods-RunnerTests.debug.xcconfig */, + ADD2E19944CD951E95CB3C9F /* Pods-RunnerTests.release.xcconfig */, + 474B68257DCA9153CF6B57FC /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 331C80D6294CF71000263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -125,6 +151,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 289674A443DF37F3D4A78B81 /* Pods */, ); sourceTree = ""; }; @@ -175,6 +202,8 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 9A327F97CCE0F5CF730A9FB2 /* Pods_Runner.framework */, + DFEEEB6915DE7D3151DD4082 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 0471FD614FFEC4B5C7108C05 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,6 +234,7 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 1CBF37EB599BD09FD86CBFA2 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, @@ -290,6 +321,50 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0471FD614FFEC4B5C7108C05 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 1CBF37EB599BD09FD86CBFA2 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -379,6 +454,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = DE6A1D19510ED15464C7432F /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -393,6 +469,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = ADD2E19944CD951E95CB3C9F /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -407,6 +484,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 474B68257DCA9153CF6B57FC /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/packages/celest/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/celest/example/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16..21a3cc14 100644 --- a/packages/celest/example/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/packages/celest/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/packages/celest/example/pubspec.yaml b/packages/celest/example/pubspec.yaml index 65f972f4..2c8316e1 100644 --- a/packages/celest/example/pubspec.yaml +++ b/packages/celest/example/pubspec.yaml @@ -11,6 +11,7 @@ dependencies: path: celest/ flutter: sdk: flutter + logging: ^1.2.0 dependency_overrides: celest: diff --git a/packages/celest/lib/src/runtime/json_utils.dart b/packages/celest/lib/src/runtime/json_utils.dart index e695d28e..380add43 100644 --- a/packages/celest/lib/src/runtime/json_utils.dart +++ b/packages/celest/lib/src/runtime/json_utils.dart @@ -1,5 +1,4 @@ -import 'dart:convert'; - +import 'package:celest_core/_internal.dart'; import 'package:celest_core/celest_core.dart'; import 'package:chunked_stream/chunked_stream.dart' show readByteStream; import 'package:shelf/shelf.dart'; @@ -8,22 +7,11 @@ import 'package:shelf/shelf.dart'; /// Decodes the JSON body of a [Request]. /// {@endtemplate} extension DecodeJson on Request { - static final _decoder = utf8.decoder.fuse(json.decoder); - /// {@macro celest.runtime.decode_json} Future> decodeJson() async { try { final bytes = await readByteStream(read()); - if (bytes.isEmpty) { - return const {}; - } - final jsonObject = _decoder.convert(bytes); - return switch (jsonObject) { - null => const {}, - Map() => jsonObject, - Map() => jsonObject.cast(), - _ => throw BadRequestException('Invalid JSON body: $jsonObject'), - }; + return JsonUtf8.decodeAny(bytes); } on FormatException catch (e) { throw BadRequestException('Could not parse the JSON body: $e'); } diff --git a/packages/celest/lib/src/runtime/serve.dart b/packages/celest/lib/src/runtime/serve.dart index f771ed0b..ce9a673b 100644 --- a/packages/celest/lib/src/runtime/serve.dart +++ b/packages/celest/lib/src/runtime/serve.dart @@ -2,14 +2,21 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:developer'; import 'dart:io'; import 'package:async/async.dart'; import 'package:celest/celest.dart'; import 'package:celest/src/runtime/json_utils.dart'; +import 'package:celest/src/runtime/sse/sse_handler.dart'; +import 'package:celest_core/_internal.dart'; +import 'package:logging/logging.dart'; import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; import 'package:shelf_router/shelf_router.dart'; +import 'package:shelf_web_socket/shelf_web_socket.dart'; +import 'package:stream_channel/stream_channel.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; /// The default port on which Celest runs locally. const int defaultCelestPort = 7777; @@ -18,6 +25,19 @@ const int defaultCelestPort = 7777; Future serve({ required Map targets, }) async { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((record) { + log( + record.message, + time: record.time, + sequenceNumber: record.sequenceNumber, + level: record.level.value, + name: record.loggerName, + zone: record.zone, + error: record.error?.toString(), + stackTrace: record.stackTrace, + ); + }); final router = Router()..get('/_health', (_) => Response.ok('OK')); for (final MapEntry(key: route, value: target) in targets.entries) { target._apply(router, route); @@ -37,6 +57,7 @@ Future serve({ InternetAddress.anyIPv4, port, shared: true, + poweredByHeader: 'Celest, the Flutter cloud platform', ); print('Serving on http://localhost:$port'); await StreamGroup.merge([ @@ -63,16 +84,35 @@ abstract base class CloudFunctionTarget { _contextHeaderPrefix, caseSensitive: false, ); - - Future _handler(Request request) async { - final bodyJson = await request.decodeJson(); + Map _contextForRequest(Map> headers) { final context = {}; - request.headers.forEach((key, value) { + headers.forEach((key, value) { key = key.toLowerCase(); if (key.startsWith(_contextHeaderMatcher)) { - context[key.substring(_contextHeaderPrefix.length)] = value; + context[key.substring(_contextHeaderPrefix.length)] = value.join(', '); } }); + return context; + } + + /// The name of the [CloudFunction] this class targets. + String get name; + + /// Initializes this target. + /// + /// This is called once when the target is instantiated. + void init() {} + + void _apply(Router router, String route); +} + +/// {@template celest.runtime.cloud_function_http_target} +/// A [CloudFunctionTarget] that handles HTTP requests. +/// {@endtemplate} +abstract base class CloudFunctionHttpTarget extends CloudFunctionTarget { + Future _handler(Request request) async { + final bodyJson = await request.decodeJson(); + final context = _contextForRequest(request.headersAll); final response = await runZoned( () => handle( bodyJson, @@ -95,21 +135,14 @@ abstract base class CloudFunctionTarget { ); } - /// The name of the [CloudFunction] this class targets. - String get name; - /// The HTTP method of the [CloudFunction] this class targets. String get method => 'POST'; + @override void _apply(Router router, String route) { router.add(method, route, _handler); } - /// Initializes this target. - /// - /// This is called once when the target is instantiated. - void init() {} - /// Handles a JSON [request] to this target. Future handle( Map request, { @@ -119,6 +152,118 @@ abstract base class CloudFunctionTarget { }); } +/// {@template celest.runtime.cloud_event_source_target} +/// A [CloudFunctionTarget] that handles Server-Sent Events (SSE) and WebSocket +/// event producers. +/// {@endtemplate} +abstract base class CloudEventSourceTarget extends CloudFunctionTarget { + @override + void _apply(Router router, String route) { + router.add('GET', route, _handler); + router.add('POST', route, _sseHandler); + } + + late final Handler _sseHandler = sseHandler(_handleConnection); + late final Handler _wsHandler = webSocketHandler( + (WebSocketChannel webSocket) { + _handleConnection( + webSocket.transform( + StreamChannelTransformer( + StreamTransformer.fromHandlers( + handleData: (data, sink) { + sink.add(JsonUtf8.decodeAny(data)); + }, + ), + StreamSinkTransformer.fromHandlers( + handleData: (data, sink) { + sink.add(jsonEncode(data)); + }, + ), + ), + ), + ); + }, + ); + + Future _handleConnection( + StreamChannel> connection, + ) async { + await runZonedGuarded( + () async { + final requests = StreamQueue(connection.stream); + var request = const {}; + if (hasBody) { + request = await requests.next; + } + final (headers, queryParameters) = switch (connection) { + SseConnection(:final headers, :final queryParameters) => ( + headers, + queryParameters + ), + _ => ( + Zone.current[#_headers] as Map>, + Zone.current[#_queryParameters] as Map>, + ), + }; + final context = _contextForRequest(headers); + final stream = handle( + request, + headers: headers, + queryParameters: queryParameters, + context: context, + ); + stream.listen( + connection.sink.add, + onDone: connection.sink.close, + // Should never emit an error. + ); + }, + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, message) { + parent.print(zone, '[$name] $message'); + }, + ), + (Object e, StackTrace st) { + print('An unexpected error occurred: $e'); + print(st); + connection.sink.addError(e, st); + connection.sink.close(); + }, + ); + } + + Future _handler(Request request) async { + if (request.method == 'GET' && request.headers['Upgrade'] == 'websocket') { + return runZoned( + () => _wsHandler(request), + zoneValues: { + #_headers: request.headersAll, + #_queryParameters: request.url.queryParametersAll, + }, + ); + } + return _sseHandler(request); + } + + /// Whether the target has a body. + /// + /// If this is `true`, the target will wait for an initial message sent by + /// the client with the body of the request. + /// + /// If this is `false`, for example the cloud function takes no parameters + /// or all parameters are mapped to headers or query parameters, then the + /// target will immediately start sending events. + bool get hasBody; + + /// Handles a JSON [request] to this target. + Stream> handle( + Map request, { + required Map> headers, + required Map> queryParameters, + required Map context, + }); +} + Handler _heartbeatMiddleware(Handler inner) { return (request) async { print(request.requestedUri.path); @@ -153,6 +298,7 @@ Handler _cloudExceptionMiddleware(Handler inner) { try { return await inner(request); } on Exception catch (e, st) { + if (e is HijackException) rethrow; print('An unexpected error occurred: $e'); print(st); return _badRequest( diff --git a/packages/celest/lib/src/runtime/sse/sse_handler.dart b/packages/celest/lib/src/runtime/sse/sse_handler.dart new file mode 100644 index 00000000..3501215a --- /dev/null +++ b/packages/celest/lib/src/runtime/sse/sse_handler.dart @@ -0,0 +1,289 @@ +// A partial reconstruction of `package:sse`'s SseHandler which provides +// better control over SSE connections and HTTP metadata. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:celest/http.dart'; +import 'package:collection/collection.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:logging/logging.dart'; +import 'package:shelf/shelf.dart'; +import 'package:stream_channel/stream_channel.dart'; + +typedef _SseMessage = ({int id, Map message}); + +/// {@template celest.runtime.sse_connection} +/// A Server-Sent Events (SSE) connection. +/// {@endtemplate} +final class SseConnection with StreamChannelMixin> { + /// {@macro celest.runtime.sse_connection} + SseConnection( + this._socket, { + required this.clientId, + required this.headers, + required this.queryParameters, + }) { + _logger.finest('Created connection'); + _handleOutgoing(); + } + + static final _jsonEncoder = JsonUtf8Encoder(); + + /// The ID of the connected client. + final String clientId; + + late final Logger _logger = Logger('SseServer.$clientId'); + + /// The headers of the request that initiated the connection. + final Map> headers; + + /// The query parameters of the request that initiated the connection. + final Map> queryParameters; + + final Socket _socket; + + /// Incoming messages from the client. + late final _incomingController = StreamController>( + onCancel: () => close(force: true), + ); + + /// Outgoing messages to the client. + late final _outgoingController = StreamController>(); + + /// The id of the last processed incoming message. + int _lastProcessedId = -1; + + /// Incoming messages that have yet to be processed. + final _pendingMessages = + HeapPriorityQueue<_SseMessage>((a, b) => a.id.compareTo(b.id)); + + final _closedCompleter = Completer(); + final _haltOutgoingQueue = Completer(); + + void _handleOutgoing() { + final subscription = _outgoingController.stream.listen( + (message) { + if (_haltOutgoingQueue.isCompleted) { + _logger.finest('Message queued after close: $message'); + return; + } + _logger.finest('Sending message: $message'); + _socket + ..add('data: '.codeUnits) + ..add(_jsonEncoder.convert(message)) + ..add('\n\n'.codeUnits); + _socket.flush().ignore(); + }, + ); + _haltOutgoingQueue.future.whenComplete(() { + subscription.cancel(); + }); + } + + void _handleIncoming(int id, Map message) { + _pendingMessages.add((id: id, message: message)); + while (_pendingMessages.isNotEmpty) { + var pendingMessage = _pendingMessages.first; + // Only process the next incremental message. + if (pendingMessage.id - _lastProcessedId <= 1) { + _logger.finest( + 'Received message (id=${pendingMessage.id}): ' + '${pendingMessage.message}', + ); + _incomingController.sink.add(pendingMessage.message); + _lastProcessedId = pendingMessage.id; + _pendingMessages.removeFirst(); + } else { + // A message came out of order. Wait until we receive the previous + // messages to process. + break; + } + } + } + + @override + Stream> get stream => _incomingController.stream; + + @override + late final StreamSink> sink = + _outgoingController.sink.transform( + StreamSinkTransformer.fromHandlers( + handleError: (error, stackTrace, sink) { + _logger.warning('Error handling SSE', error, stackTrace); + close(force: true); + }, + handleDone: (sink) => close(), + ), + ); + + /// Closes the connection. + /// + /// If [force] is `true`, the connection will be closed immediately without + /// waiting for the outgoing queue to finish. + Future close({bool force = false}) async { + if (_closedCompleter.isCompleted) return; + _logger.fine('Closing connection (force=$force)'); + _closedCompleter.complete(); + if (force) { + _haltOutgoingQueue.complete(); + } + if (!_outgoingController.isClosed) { + await _outgoingController.close(); + } + if (!_incomingController.isClosed) { + unawaited(_incomingController.close()); + } + // Send a control message to signal the client to close the connection. + // + // This is necessary to ensure that the client does not attempt to + // reconnect after the connection is closed, since EventSource provides + // no built-in way to signal a successful connection close. + _socket.add('event: control\ndata: close\n\n'.codeUnits); + await _socket.flush(); + await _socket.close(); + _logger.finest('Connection closed'); + } +} + +/// {@template celest.runtime.sse_handler} +/// A handler for Server-Sent Events (SSE) connections. +/// {@endtemplate} +Handler sseHandler(SseHandler handler) => _SseHandler(handler).handle; + +/// {@macro celest.runtime.sse_handler} +typedef SseHandler = FutureOr Function(SseConnection); + +final class _SseHandler { + _SseHandler(this._handler); + + final FutureOr Function(SseConnection) _handler; + + final _connections = {}; + + // RFC 2616 requires carriage return delimiters. + static String _sseHeaders(Request request) => 'HTTP/1.1 200 OK\r\n' + 'Content-Type: text/event-stream\r\n' + 'Cache-Control: no-cache\r\n' + 'Connection: keep-alive\r\n' + '${_corsHeaders(request).toHttp()}\r\n' + '\r\n\r\n'; + + static final _badJson = Response.badRequest( + body: 'Message must be a valid JSON object', + ); + + static Map _corsHeaders(Request request) => { + 'Access-Control-Allow-Credentials': 'true', + if (request.headers['origin'] case final origin?) + 'Access-Control-Allow-Origin': origin, + }; + + Response _createConnection(Request request) { + final clientId = request.url.queryParameters['sseClientId']; + if (clientId == null) { + return Response.badRequest( + body: 'sseClientId query parameter is required', + ); + } + request.hijack((socket) { + socket.sink.add(_sseHeaders(request).codeUnits); + final connection = _connections[clientId] ??= SseConnection( + socket.sink as Socket, + clientId: clientId, + headers: request.headersAll, + queryParameters: request.url.queryParametersAll, + ); + unawaited( + connection._closedCompleter.future.whenComplete(() { + connection._logger.finest('Removing connection'); + _connections.remove(clientId); + }), + ); + socket.stream.drain().whenComplete(() { + connection.close(force: true); + }); + _handler(connection); + }); + } + + Future _handleIncomingMessage(Request request) async { + final clientId = request.url.queryParameters['sseClientId']; + if (clientId == null) { + return Response.badRequest( + body: 'sseClientId query parameter is required', + ); + } + final connection = _connections[clientId]; + if (connection == null) { + return Response.notFound( + 'No connection found for clientId: $clientId', + ); + } + final messageId = int.parse( + request.url.queryParameters['messageId'] ?? '0', + ); + try { + final body = await request.readAsString(); + final message = jsonDecode(body); + if (message is! Map) { + connection._logger.warning( + 'Invalid JSON. Expected Map, got ${message.runtimeType}', + ); + return _badJson; + } + connection._handleIncoming(messageId, message); + return Response(HttpStatus.accepted); + } on Object catch (e, st) { + connection._logger.warning('Failed to decode JSON', e, st); + return _badJson; + } + } + + /// The shelf [Handler] for the SSE server. + Future handle(Request request) async { + final response = await _innerHandle(request); + return response.change( + headers: { + ..._corsHeaders(request), + ...response.headers, + }, + ); + } + + Future _innerHandle(Request request) async { + if (request.method == 'OPTIONS') { + return Response( + HttpStatus.noContent, + headers: { + ..._corsHeaders(request), + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + if (request.headers['access-control-request-headers'] + case final requestedHeaders?) + 'Access-Control-Allow-Headers': requestedHeaders, + }, + ); + } + if (request.method == 'GET' && + request.headers['accept'] == 'text/event-stream') { + return _createConnection(request); + } + if (request.method == 'POST') { + final contentType = MediaType.parse( + request.headers['content-type'] ?? 'text/plain', + ); + if (contentType.mimeType != 'application/json') { + return _badJson; + } + return _handleIncomingMessage(request); + } + return Response.notFound(null); + } +} + +extension on Map { + String toHttp() => entries.map((e) => '${e.key}: ${e.value}').join('\r\n'); +} diff --git a/packages/celest/pubspec.yaml b/packages/celest/pubspec.yaml index 53a9bf2a..77ea3a5f 100644 --- a/packages/celest/pubspec.yaml +++ b/packages/celest/pubspec.yaml @@ -13,10 +13,19 @@ dependencies: celest_auth: ^0.4.0 celest_core: ^0.4.0 chunked_stream: ^1.4.2 + collection: ^1.18.0 + http: ^1.0.0 + http_parser: ^4.0.0 + logging: ^1.2.0 meta: ^1.11.0 shelf: ^1.4.1 shelf_router: ^1.1.4 + shelf_web_socket: ^2.0.0 + stream_channel: ^2.1.2 + web_socket_channel: ^3.0.0 dev_dependencies: lints: ^4.0.0 + stream_transform: ^2.1.0 test: ^1.25.1 + web: ^0.5.1 diff --git a/packages/celest/test/celest_test.dart b/packages/celest/test/celest_test.dart new file mode 100644 index 00000000..08525293 --- /dev/null +++ b/packages/celest/test/celest_test.dart @@ -0,0 +1,5 @@ +import 'package:test/test.dart'; + +void main() { + test('Celest', () => expect(1 + 1, 2)); +} diff --git a/packages/celest/test/runtime/sse_server.dart b/packages/celest/test/runtime/sse_server.dart new file mode 100644 index 00000000..f55f7999 --- /dev/null +++ b/packages/celest/test/runtime/sse_server.dart @@ -0,0 +1,37 @@ +import 'dart:io'; + +import 'package:celest/src/runtime/sse/sse_handler.dart'; +import 'package:logging/logging.dart'; +import 'package:shelf/shelf_io.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'package:stream_channel/stream_channel.dart'; + +Future hybridMain(StreamChannel channel) async { + final router = Router() + ..all( + '/ping-pong', + sseHandler((conn) async { + await for (final message in conn.stream) { + conn.sink.add(message); + } + }), + ) + ..all( + '/one-n-done', + sseHandler((conn) { + conn.stream.listen((message) { + conn.sink.add(message); + conn.close(); + }); + }), + ); + final server = await serve(router.call, InternetAddress.loopbackIPv4, 0); + channel.sink.add(server.port); + + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((event) { + channel.sink.add( + '[${event.loggerName}] ${event.level.name}: ${event.message}', + ); + }); +} diff --git a/packages/celest/test/runtime/sse_test.dart b/packages/celest/test/runtime/sse_test.dart new file mode 100644 index 00000000..373b3d8f --- /dev/null +++ b/packages/celest/test/runtime/sse_test.dart @@ -0,0 +1,58 @@ +@TestOn('browser') +library; + +import 'dart:js_interop'; + +import 'package:async/async.dart'; +import 'package:celest_core/_internal.dart'; +import 'package:logging/logging.dart'; +import 'package:stream_transform/stream_transform.dart'; +import 'package:test/test.dart'; +import 'package:web/web.dart' as web; + +void main() { + Logger.root.level = Level.ALL; + Logger.root.onRecord.listen((event) { + web.console.log( + '[${event.loggerName}] ${event.level.name}: ${event.message}'.toJS, + ); + }); + + group('SSE', () { + late Uri uri; + + setUpAll(() async { + final channel = spawnHybridUri('sse_server.dart'); + final queue = StreamQueue(channel.stream); + final port = (await queue.next as num).toInt(); + uri = Uri.parse('http://localhost:$port'); + queue.rest.listen( + (Object? log) => web.console.warn(log.toString().toJS), + ); + }); + + test('ping pong', () async { + final client = SseClient(serverUri: uri.resolve('/ping-pong')); + addTearDown(client.close); + const messages = [ + {'a': 1}, + {'b': 2}, + {'c': 3}, + ]; + expect(client.stream, emitsInOrder(messages)); + for (final message in messages) { + client.sink.add(message); + } + }); + + test('one-n-done', () async { + final client = SseClient(serverUri: uri.resolve('/one-n-done')); + addTearDown(client.close); + const message = {'a': 1}; + client.sink.add(message); + await expectLater( + client.stream.tap((msg) => web.console.log(msg.jsify())), + emitsInOrder([message, emitsDone])); + }); + }); +} diff --git a/packages/celest_auth/example/celest/lib/client.dart b/packages/celest_auth/example/celest/lib/client.dart index 9167e2c9..b0552841 100644 --- a/packages/celest_auth/example/celest/lib/client.dart +++ b/packages/celest_auth/example/celest/lib/client.dart @@ -34,11 +34,12 @@ class Celest with CelestBase { late CelestEnvironment _currentEnvironment; - late final NativeStorage _storage = NativeStorage(scope: 'celest'); + @override + late final NativeStorage nativeStorage = NativeStorage(scope: 'celest'); @override late _$http.Client httpClient = - CelestHttpClient(secureStorage: _storage.secure); + CelestHttpClient(secureStorage: nativeStorage.secure); late Uri _baseUri; @@ -46,7 +47,7 @@ class Celest with CelestBase { late final CelestAuth _auth = CelestAuth( this, - storage: _storage, + storage: nativeStorage, ); T _checkInitialized(T Function() value) { diff --git a/packages/celest_auth/example/celest/pubspec.yaml b/packages/celest_auth/example/celest/pubspec.yaml index 4680b9cd..257a7d15 100644 --- a/packages/celest_auth/example/celest/pubspec.yaml +++ b/packages/celest_auth/example/celest/pubspec.yaml @@ -19,4 +19,4 @@ dependency_overrides: dev_dependencies: lints: ^4.0.0 - test: ^1.24.0 + test: ^1.25.0 diff --git a/packages/celest_core/lib/_internal.dart b/packages/celest_core/lib/_internal.dart index d257b70d..90b52fd0 100644 --- a/packages/celest_core/lib/_internal.dart +++ b/packages/celest_core/lib/_internal.dart @@ -1,4 +1,8 @@ export 'package:native_storage/native_storage.dart'; export 'src/base/celest_base.dart'; +export 'src/events/sse/sse_client.dart'; export 'src/http/celest_http_client.dart'; +export 'src/util/globals.dart'; +export 'src/util/json.dart'; +export 'src/util/uuid.dart'; diff --git a/packages/celest_core/lib/celest_core.dart b/packages/celest_core/lib/celest_core.dart index 08c55a9b..2d80746d 100644 --- a/packages/celest_core/lib/celest_core.dart +++ b/packages/celest_core/lib/celest_core.dart @@ -5,6 +5,10 @@ library; export 'src/auth/auth_exception.dart'; export 'src/auth/user.dart'; +/// Events +export 'src/events/event_channel.dart'; +export 'src/events/event_client.dart'; + /// Exceptions export 'src/exception/celest_exception.dart'; export 'src/exception/cloud_exception.dart'; diff --git a/packages/celest_core/lib/src/auth/authenticator.dart b/packages/celest_core/lib/src/auth/authenticator.dart new file mode 100644 index 00000000..0a5e60ae --- /dev/null +++ b/packages/celest_core/lib/src/auth/authenticator.dart @@ -0,0 +1,14 @@ +import 'package:celest_core/_internal.dart'; + +final class Authenticator { + Authenticator({ + required NativeSecureStorage secureStorage, + }) : _secureStorage = secureStorage; + + final NativeSecureStorage _secureStorage; + + Future get token { + if (kIsWeb) return Future.sync(() => null); + return _secureStorage.isolated.read('cork'); + } +} diff --git a/packages/celest_core/lib/src/base/base_protocol.dart b/packages/celest_core/lib/src/base/base_protocol.dart index bb77a4bb..73822766 100644 --- a/packages/celest_core/lib/src/base/base_protocol.dart +++ b/packages/celest_core/lib/src/base/base_protocol.dart @@ -53,6 +53,17 @@ mixin BaseProtocol { }; } + Stream> connect( + String path, { + required Map payload, + }) { + final channel = celest.eventClient.connect( + celest.baseUri.resolve(path), + ); + channel.sink.add(payload); + return channel.stream; + } + Never _error( http.Response response, T Function(String message) createError, diff --git a/packages/celest_core/lib/src/base/celest_base.dart b/packages/celest_core/lib/src/base/celest_base.dart index dc6d5430..02be2161 100644 --- a/packages/celest_core/lib/src/base/celest_base.dart +++ b/packages/celest_core/lib/src/base/celest_base.dart @@ -1,6 +1,8 @@ import 'package:http/http.dart' as http; +import 'package:native_storage/native_storage.dart'; abstract mixin class CelestBase { http.Client get httpClient; Uri get baseUri; + NativeStorage get nativeStorage; } diff --git a/packages/celest_core/lib/src/events/event_channel.dart b/packages/celest_core/lib/src/events/event_channel.dart new file mode 100644 index 00000000..6115e215 --- /dev/null +++ b/packages/celest_core/lib/src/events/event_channel.dart @@ -0,0 +1,17 @@ +import 'package:celest_core/src/auth/authenticator.dart'; +import 'package:celest_core/src/events/event_channel.vm.dart' + if (dart.library.js_interop) 'package:celest_core/src/events/event_channel.web.dart'; +import 'package:http/http.dart' as http; +import 'package:stream_channel/stream_channel.dart'; + +abstract class EventChannel with StreamChannelMixin> { + factory EventChannel.connect( + Uri uri, { + Authenticator? authenticator, + http.Client? httpClient, + }) = EventChannelPlatform.connect; + + EventChannel(); + + void close(); +} diff --git a/packages/celest_core/lib/src/events/event_channel.vm.dart b/packages/celest_core/lib/src/events/event_channel.vm.dart new file mode 100644 index 00000000..5d87a46e --- /dev/null +++ b/packages/celest_core/lib/src/events/event_channel.vm.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io' as io show WebSocket; +import 'dart:io'; + +import 'package:async/async.dart'; +import 'package:celest_core/_internal.dart'; +import 'package:celest_core/src/auth/authenticator.dart'; +import 'package:celest_core/src/events/event_channel.dart'; +import 'package:http/http.dart' as http; +import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +final class EventChannelPlatform extends EventChannel { + EventChannelPlatform._(this._ws); + + factory EventChannelPlatform.connect( + Uri uri, { + Authenticator? authenticator, + http.Client? httpClient, + }) { + final ws = Future( + () async => io.WebSocket.connect( + uri.replace(scheme: uri.isScheme('https') ? 'wss' : 'ws').toString(), + headers: { + if (await authenticator?.token case final token?) + HttpHeaders.authorizationHeader: token, + }, + ), + ); + return EventChannelPlatform._(IOWebSocketChannel(ws)); + } + + final WebSocketChannel _ws; + late final StreamSink> _wsSink = + _ws.sink.rejectErrors().transform( + StreamSinkTransformer.fromHandlers( + handleData: (data, sink) { + sink.add(jsonEncode(data)); + }, + ), + ); + + @override + Stream> get stream => _ws.stream.map(JsonUtf8.decodeAny); + + @override + StreamSink> get sink => _wsSink; + + @override + void close() { + _ws.sink.close(WebSocketStatus.goingAway).ignore(); + } +} diff --git a/packages/celest_core/lib/src/events/event_channel.web.dart b/packages/celest_core/lib/src/events/event_channel.web.dart new file mode 100644 index 00000000..038b2a7e --- /dev/null +++ b/packages/celest_core/lib/src/events/event_channel.web.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +import 'package:celest_core/src/auth/authenticator.dart'; +import 'package:celest_core/src/events/event_channel.dart'; +import 'package:celest_core/src/events/sse/sse_client.dart'; +import 'package:http/http.dart' as http; +import 'package:stream_channel/stream_channel.dart'; + +final class EventChannelPlatform + extends DelegatingStreamChannel> + implements EventChannel { + EventChannelPlatform._(super._inner); + + factory EventChannelPlatform.connect( + Uri uri, { + Authenticator? authenticator, + http.Client? httpClient, + }) { + final sseClient = SseClient(serverUri: uri, httpClient: httpClient); + final completer = StreamChannelCompleter>(); + sseClient.onConnected + .then((_) => completer.setChannel(sseClient)) + .onError((e, st) { + sseClient.close(); + completer.setError(e, st); + }); + return EventChannelPlatform._(sseClient); + } + + @override + void close() { + sink.close().ignore(); + } +} diff --git a/packages/celest_core/lib/src/events/event_client.dart b/packages/celest_core/lib/src/events/event_client.dart new file mode 100644 index 00000000..1f284c39 --- /dev/null +++ b/packages/celest_core/lib/src/events/event_client.dart @@ -0,0 +1,27 @@ +import 'package:celest_core/src/auth/authenticator.dart'; +import 'package:celest_core/src/base/celest_base.dart'; +import 'package:celest_core/src/events/event_channel.dart'; +import 'package:http/http.dart' as http; + +final class EventClient { + EventClient({ + required this.httpClient, + required this.authenticator, + }); + + final http.Client httpClient; + final Authenticator authenticator; + + EventChannel connect(Uri uri) => EventChannel.connect( + uri, + authenticator: authenticator, + httpClient: httpClient, + ); +} + +extension CelestEventClient on CelestBase { + EventClient get eventClient => EventClient( + httpClient: httpClient, + authenticator: Authenticator(secureStorage: nativeStorage.secure), + ); +} diff --git a/packages/celest_core/lib/src/events/sse/sse_client.dart b/packages/celest_core/lib/src/events/sse/sse_client.dart new file mode 100644 index 00000000..be925f1c --- /dev/null +++ b/packages/celest_core/lib/src/events/sse/sse_client.dart @@ -0,0 +1,29 @@ +import 'package:celest_core/src/events/sse/sse_client.vm.dart' + if (dart.library.js_interop) 'package:celest_core/src/events/sse/sse_client.web.dart'; +import 'package:http/http.dart' as http; +import 'package:meta/meta.dart'; +import 'package:stream_channel/stream_channel.dart'; + +/// {@template celest.runtime.sse_client} +/// A Server-Sent Events (SSE) client. +/// {@endtemplate} +abstract class SseClient with StreamChannelMixin> { + /// Creates a new [SseClient] connected to the server at [serverUri]. + factory SseClient({ + required Uri serverUri, + http.Client? httpClient, + String? clientId, + }) = SseClientPlatform; + + @protected + SseClient.base(); + + /// The client ID used to identify this client to the server. + String get clientId; + + /// Completes when the server connection is established. + Future get onConnected; + + /// Closes the connection to the server. + void close(); +} diff --git a/packages/celest_core/lib/src/events/sse/sse_client.vm.dart b/packages/celest_core/lib/src/events/sse/sse_client.vm.dart new file mode 100644 index 00000000..700f8fd3 --- /dev/null +++ b/packages/celest_core/lib/src/events/sse/sse_client.vm.dart @@ -0,0 +1,12 @@ +import 'package:celest_core/src/events/sse/sse_client.dart'; +import 'package:http/http.dart' as http; + +abstract class SseClientPlatform extends SseClient { + factory SseClientPlatform({ + required Uri serverUri, + http.Client? httpClient, + String? clientId, + }) { + throw UnsupportedError('SSE is not supported on this platform'); + } +} diff --git a/packages/celest_core/lib/src/events/sse/sse_client.web.dart b/packages/celest_core/lib/src/events/sse/sse_client.web.dart new file mode 100644 index 00000000..daa95e10 --- /dev/null +++ b/packages/celest_core/lib/src/events/sse/sse_client.web.dart @@ -0,0 +1,182 @@ +// A partial reconstruction of `package:sse`'s SseClient which provides +// better control over SSE connections and HTTP. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:js_interop'; + +import 'package:async/async.dart'; +import 'package:celest_core/_internal.dart'; +import 'package:http/browser_client.dart' as http; +import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; +import 'package:web/web.dart' as web; + +/// {@template celest.runtime.sse_client} +/// A Server-Sent Events (SSE) client. +/// {@endtemplate} +final class SseClientPlatform extends SseClient { + /// Creates a new [SseClient] connected to the server at [serverUri]. + factory SseClientPlatform({ + required Uri serverUri, + http.Client? httpClient, + String? clientId, + }) { + clientId ??= Uuid.v7().toString(); + serverUri = serverUri.replace( + queryParameters: { + ...serverUri.queryParametersAll, + 'sseClientId': clientId, + }, + ); + return SseClientPlatform._(serverUri, clientId: clientId); + } + + SseClientPlatform._( + this.serverUri, { + required this.clientId, + http.Client? httpClient, + }) : _httpClient = + httpClient ?? (http.BrowserClient()..withCredentials = true), + super.base() { + _init(); + } + + /// The URI of the connected server endpoint. + final Uri serverUri; + + @override + final String clientId; + + late final Logger _logger = Logger('SseClient.$clientId'); + + final http.Client _httpClient; + late final web.EventSource _eventSource; + + var _lastMessageId = -1; + + late final StreamController> _incomingController = + StreamController(onCancel: close); + + @override + Stream> get stream => _incomingController.stream; + + late final StreamController> _outgoingController = + StreamController(); + + @override + late final StreamSink> sink = + _outgoingController.sink.transform( + StreamSinkTransformer.fromHandlers( + handleError: (error, stackTrace, sink) { + _closeWithError(error, stackTrace); + }, + handleDone: (sink) { + close(); + }, + ), + ); + + final Completer _onConnected = Completer(); + + @override + Future get onConnected => _onConnected.future; + + void _init() { + _eventSource = web.EventSource( + serverUri.toString(), + web.EventSourceInit(withCredentials: true), + ); + _eventSource.onOpen.first.whenComplete(() { + _logger.fine('Established connection'); + _onConnected.complete(); + _outgoingController.stream.listen(_onOutgoingMessage, onDone: close); + }); + _eventSource.onError.first.whenComplete(() { + _closeWithError( + StateError('Failed to connect to server'), + ); + }); + _eventSource.onMessage.listen(_onIncomingMessage); + _eventSource.addEventListener('control', _onIncomingControlMessage.toJS); + } + + void _onIncomingControlMessage(web.MessageEvent event) { + final data = event.data.dartify(); + _logger.finest('Control event: $data'); + if (data == 'close') { + return close(); + } + _closeWithError( + UnsupportedError('[$clientId] Illegal Control Message "$data"'), + ); + } + + void _onIncomingMessage(web.MessageEvent event) { + final data = (event.data as JSString).toDart; + _logger.finest('Message event: $data'); + try { + final message = jsonDecode(data); + if (message is! Map) { + throw FormatException('Expected a Map, got ${message.runtimeType}'); + } + _incomingController.add(message); + } on Object catch (e, st) { + _logger.severe('Invalid message: $data', e, st); + _closeWithError( + FormatException('[$clientId] Invalid JSON message'), + st, + ); + } + } + + Future _onOutgoingMessage(Map message) async { + final uri = serverUri.replace( + queryParameters: { + ...serverUri.queryParametersAll, + 'messageId': '${++_lastMessageId}', + }, + ); + final response = await _httpClient.post( + uri, + headers: { + 'content-type': 'application/json', + }, + body: jsonEncode(message), + ); + if (response.statusCode != 202) { + _closeWithError( + http.ClientException( + '${response.statusCode}: ${response.body}', + uri, + ), + ); + } + } + + @override + void close() { + if (_eventSource.readyState == web.EventSource.CLOSED) { + return; + } + _logger.fine('Closing connection'); + _eventSource.close(); + if (!_onConnected.isCompleted) { + _outgoingController.stream.drain(); + } + _incomingController.close(); + _outgoingController.close(); + } + + void _closeWithError(Object error, [StackTrace? stackTrace]) { + _logger.severe('Closing with error', error, stackTrace); + if (!_onConnected.isCompleted) { + _onConnected.completeError(error, stackTrace); + } + if (_incomingController.hasListener) { + _incomingController.addError(error, stackTrace); + } + close(); + } +} diff --git a/packages/celest_core/lib/src/http/celest_http_client.dart b/packages/celest_core/lib/src/http/celest_http_client.dart index 193d06d1..b465024b 100644 --- a/packages/celest_core/lib/src/http/celest_http_client.dart +++ b/packages/celest_core/lib/src/http/celest_http_client.dart @@ -1,4 +1,5 @@ import 'package:celest_core/_internal.dart'; +import 'package:celest_core/src/auth/authenticator.dart'; import 'package:celest_core/src/http/http_client.vm.dart' if (dart.library.js_interop) 'package:celest_core/src/http/http_client.web.dart'; import 'package:http/http.dart' as http; @@ -7,18 +8,20 @@ final class CelestHttpClient extends http.BaseClient { CelestHttpClient({ NativeSecureStorage? secureStorage, http.Client? baseClient, - }) : _secureStorage = - secureStorage ?? NativeSecureStorage(scope: 'celest/auth'), + }) : _authenticator = Authenticator( + secureStorage: + secureStorage ?? NativeSecureStorage(scope: 'celest/auth'), + ), _ownsInner = baseClient == null, _inner = baseClient ?? createHttpClient(); - final NativeSecureStorage _secureStorage; + final Authenticator _authenticator; final bool _ownsInner; final http.Client _inner; @override Future send(http.BaseRequest request) async { - final cork = await _secureStorage.isolated.read('cork'); + final cork = await _authenticator.token; if (cork != null) { request.headers['authorization'] = 'Bearer $cork'; } diff --git a/packages/celest_core/lib/src/util/json.dart b/packages/celest_core/lib/src/util/json.dart new file mode 100644 index 00000000..c5cc428e --- /dev/null +++ b/packages/celest_core/lib/src/util/json.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:celest_core/src/exception/cloud_exception.dart'; + +/// Conversion between JSON and UTF-8. +extension JsonUtf8 on Object { + /// A JSON encoder that encodes to UTF-8. + static final encoder = JsonUtf8Encoder(); + + /// A JSON decoder that decodes from UTF-8. + static final decoder = utf8.decoder.fuse(json.decoder); + + /// Encodes a JSON [object] to a UTF-8 buffer. + static Uint8List encode(Object? object) { + return encoder.convert(object) as Uint8List; + } + + /// Decodes a UTF-8 buffer to a JSON object. + static Object? decode(List bytes) { + return decoder.convert(bytes); + } + + static Never _invalidJson(Object? json) { + throw BadRequestException('Invalid JSON body (${json.runtimeType}): $json'); + } + + /// Decodes a JSON [body] of type [List] or [String]. + static Map decodeAny(Object? body) { + Object? decoded; + switch (body) { + case List(): + if (body.isEmpty) return const {}; + decoded = decode(body); + case String(): + if (body.isEmpty) return const {}; + decoded = jsonDecode(body); + default: + _invalidJson(body); + } + return switch (decoded) { + null => const {}, + Map() => decoded, + _ => _invalidJson(decoded), + }; + } +} diff --git a/packages/celest_core/lib/src/util/uuid.dart b/packages/celest_core/lib/src/util/uuid.dart new file mode 100644 index 00000000..a6a6e34e --- /dev/null +++ b/packages/celest_core/lib/src/util/uuid.dart @@ -0,0 +1,107 @@ +import 'dart:math'; +import 'dart:typed_data'; + +final _rand = Random(); + +class Uuid { + const Uuid._(this.value); + + factory Uuid.v7() => Uuid._(_uuidv7()); + + final Uint8List value; + + static bool _addSeparator(int i) => i == 4 || i == 6 || i == 8 || i == 10; + + String get hexValue { + final buffer = StringBuffer(); + for (var i = 0; i < value.length; i++) { + if (_addSeparator(i)) { + buffer.write('-'); + } + buffer.write(_byteToHex[value[i]]); + } + return buffer.toString(); + } + + @override + String toString() => hexValue; +} + +/// Creates a new v7 UUID. +/// +/// Spec: https://datatracker.ietf.org/doc/html/draft-ietf-uuidrev-rfc4122bis#name-uuid-version-7 +Uint8List _uuidv7() { + /* + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | unix_ts_ms | + 3 4 5 6 + 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | unix_ts_ms | ver | rand_a | + 6 + 4 5 6 + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + |var| rand_b | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + | rand_b | + +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ + */ + + final uuid = Uint8List(16); + final time = DateTime.now().millisecondsSinceEpoch; + + // The most significant 48 bits of the timestamp. + uuid[0] = time >> 40 & 0xff; + uuid[1] = time >> 32 & 0xff; + uuid[2] = time >> 24 & 0xff; + uuid[3] = time >> 16 & 0xff; + uuid[4] = time >> 8 & 0xff; + uuid[5] = time & 0xff; + + for (var offset = 6; offset < 16; offset++) { + uuid[offset] = _rand.nextInt(256); + } + + uuid[6] = (uuid[6] & 0x0f) | 0x70; // version 7 + uuid[7] = (uuid[8] & 0x3f) | 0x80; // variant is 0b10 + + return uuid.asUnmodifiableView(); +} + +// Precomputed padded hex values for each byte (0-255). +const _byteToHex = [ + '00', '01', '02', '03', '04', '05', '06', '07', // + '08', '09', '0a', '0b', '0c', '0d', '0e', '0f', // + '10', '11', '12', '13', '14', '15', '16', '17', // + '18', '19', '1a', '1b', '1c', '1d', '1e', '1f', // + '20', '21', '22', '23', '24', '25', '26', '27', // + '28', '29', '2a', '2b', '2c', '2d', '2e', '2f', // + '30', '31', '32', '33', '34', '35', '36', '37', // + '38', '39', '3a', '3b', '3c', '3d', '3e', '3f', // + '40', '41', '42', '43', '44', '45', '46', '47', // + '48', '49', '4a', '4b', '4c', '4d', '4e', '4f', // + '50', '51', '52', '53', '54', '55', '56', '57', // + '58', '59', '5a', '5b', '5c', '5d', '5e', '5f', // + '60', '61', '62', '63', '64', '65', '66', '67', // + '68', '69', '6a', '6b', '6c', '6d', '6e', '6f', // + '70', '71', '72', '73', '74', '75', '76', '77', // + '78', '79', '7a', '7b', '7c', '7d', '7e', '7f', // + '80', '81', '82', '83', '84', '85', '86', '87', // + '88', '89', '8a', '8b', '8c', '8d', '8e', '8f', // + '90', '91', '92', '93', '94', '95', '96', '97', // + '98', '99', '9a', '9b', '9c', '9d', '9e', '9f', // + 'a0', 'a1', 'a2', 'a3', 'a4', 'a5', 'a6', 'a7', // + 'a8', 'a9', 'aa', 'ab', 'ac', 'ad', 'ae', 'af', // + 'b0', 'b1', 'b2', 'b3', 'b4', 'b5', 'b6', 'b7', // + 'b8', 'b9', 'ba', 'bb', 'bc', 'bd', 'be', 'bf', // + 'c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', // + 'c8', 'c9', 'ca', 'cb', 'cc', 'cd', 'ce', 'cf', // + 'd0', 'd1', 'd2', 'd3', 'd4', 'd5', 'd6', 'd7', // + 'd8', 'd9', 'da', 'db', 'dc', 'dd', 'de', 'df', // + 'e0', 'e1', 'e2', 'e3', 'e4', 'e5', 'e6', 'e7', // + 'e8', 'e9', 'ea', 'eb', 'ec', 'ed', 'ee', 'ef', // + 'f0', 'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', // + 'f8', 'f9', 'fa', 'fb', 'fc', 'fd', 'fe', 'ff', +]; diff --git a/packages/celest_core/pubspec.yaml b/packages/celest_core/pubspec.yaml index c15c816c..d68a489b 100644 --- a/packages/celest_core/pubspec.yaml +++ b/packages/celest_core/pubspec.yaml @@ -9,14 +9,20 @@ environment: flutter: ">=3.19.0" dependencies: + async: ^2.11.0 collection: ^1.18.0 - http: ">=0.13.0 <2.0.0" + http: ^1.0.0 + http_parser: ^4.0.0 + logging: ^1.2.0 meta: ^1.10.0 + native_storage: ^0.1.0 os_detect: ^2.0.1 path: ^1.9.0 - native_storage: ^0.1.0 - http_parser: ^4.0.2 + stream_channel: ^2.1.2 + web: ^0.5.0 + web_socket: ^0.1.0 + web_socket_channel: ^3.0.0 dev_dependencies: lints: ^4.0.0 - test: ^1.24.0 + test: ^1.25.0