add: adding websocket package
This commit is contained in:
parent
4a42ce4bed
commit
b0fee2ca94
28 changed files with 2334 additions and 0 deletions
71
packages/websocket/.gitignore
vendored
Normal file
71
packages/websocket/.gitignore
vendored
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
# See https://www.dartlang.org/tools/private-files.html
|
||||||
|
|
||||||
|
# Files and directories created by pub
|
||||||
|
.dart_tool
|
||||||
|
.packages
|
||||||
|
.pub/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# If you're building an application, you may want to check-in your pubspec.lock
|
||||||
|
pubspec.lock
|
||||||
|
|
||||||
|
# Directory created by dartdoc
|
||||||
|
# If you don't generate documentation locally you can remove this line.
|
||||||
|
doc/api/
|
||||||
|
|
||||||
|
### Dart template
|
||||||
|
# See https://www.dartlang.org/tools/private-files.html
|
||||||
|
|
||||||
|
# Files and directories created by pub
|
||||||
|
|
||||||
|
# SDK 1.20 and later (no longer creates packages directories)
|
||||||
|
|
||||||
|
# Older SDK versions
|
||||||
|
# (Include if the minimum SDK version specified in pubsepc.yaml is earlier than 1.20)
|
||||||
|
.project
|
||||||
|
.buildlog
|
||||||
|
**/packages/
|
||||||
|
|
||||||
|
|
||||||
|
# Files created by dart2js
|
||||||
|
# (Most Dart developers will use pub build to compile Dart, use/modify these
|
||||||
|
# rules if you intend to use dart2js directly
|
||||||
|
# Convention is to use extension '.dart.js' for Dart compiled to Javascript to
|
||||||
|
# differentiate from explicit Javascript files)
|
||||||
|
*.dart.js
|
||||||
|
*.part.js
|
||||||
|
*.js.deps
|
||||||
|
*.js.map
|
||||||
|
*.info.json
|
||||||
|
|
||||||
|
# Directory created by dartdoc
|
||||||
|
|
||||||
|
# Don't commit pubspec lock file
|
||||||
|
# (Library packages only! Remove pattern if developing an application package)
|
||||||
|
### JetBrains template
|
||||||
|
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
|
||||||
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
|
||||||
|
# User-specific stuff:
|
||||||
|
|
||||||
|
## VsCode
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
## File-based project format:
|
||||||
|
*.iws
|
||||||
|
|
||||||
|
## Plugin-specific files:
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
.idea/
|
||||||
|
/out/
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# JIRA plugin
|
||||||
|
atlassian-ide-plugin.xml
|
||||||
|
|
||||||
|
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||||
|
com_crashlytics_export_strings.xml
|
||||||
|
crashlytics.properties
|
||||||
|
crashlytics-build.properties
|
||||||
|
fabric.properties
|
12
packages/websocket/AUTHORS.md
Normal file
12
packages/websocket/AUTHORS.md
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
Primary Authors
|
||||||
|
===============
|
||||||
|
|
||||||
|
* __[Thomas Hii](dukefirehawk.apps@gmail.com)__
|
||||||
|
|
||||||
|
Thomas is the current maintainer of the code base. He has refactored and migrated the
|
||||||
|
code base to support NNBD.
|
||||||
|
|
||||||
|
* __[Tobe O](thosakwe@gmail.com)__
|
||||||
|
|
||||||
|
Tobe has written much of the original code prior to NNBD migration. He has moved on and
|
||||||
|
is no longer involved with the project.
|
136
packages/websocket/CHANGELOG.md
Normal file
136
packages/websocket/CHANGELOG.md
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
# Change Log
|
||||||
|
|
||||||
|
## 8.2.0
|
||||||
|
|
||||||
|
* Require Dart >= 3.3
|
||||||
|
* Updated `lints` to 4.0.0
|
||||||
|
* Updated `web_socket_channel` to 3.0.0
|
||||||
|
|
||||||
|
## 8.1.1
|
||||||
|
|
||||||
|
* Updated repository link
|
||||||
|
|
||||||
|
## 8.1.0
|
||||||
|
|
||||||
|
* Updated `lints` to 3.0.0
|
||||||
|
* Fixed linter warnings
|
||||||
|
* Updated `web_socket_channel` to versions below 2.4.1 temporarily. Starting with 2.4.1, its dependency on `dart:html` has been changed to `package:web` which requires a code refactoring to resolve.
|
||||||
|
|
||||||
|
## 8.0.0
|
||||||
|
|
||||||
|
* Require Dart >= 3.0
|
||||||
|
|
||||||
|
## 7.0.0
|
||||||
|
|
||||||
|
* Require Dart >= 2.17
|
||||||
|
|
||||||
|
## 6.0.0
|
||||||
|
|
||||||
|
* Require Dart >= 2.16
|
||||||
|
|
||||||
|
## 5.0.0
|
||||||
|
|
||||||
|
* Skipped release
|
||||||
|
|
||||||
|
## 4.1.2
|
||||||
|
|
||||||
|
* Updated `package:angel3_container`
|
||||||
|
|
||||||
|
## 4.1.1
|
||||||
|
|
||||||
|
* Fixed issue with type casting
|
||||||
|
* Changed `app` parameter of `AngelWebSocket` to non-nullable
|
||||||
|
|
||||||
|
## 4.1.0
|
||||||
|
|
||||||
|
* Updated `package:belatuk_merge_map`
|
||||||
|
* Updated linter to `package:lints`
|
||||||
|
|
||||||
|
## 4.0.1
|
||||||
|
|
||||||
|
* Updated README
|
||||||
|
* Fixed authentication unit test
|
||||||
|
* Fixed NNBD issues
|
||||||
|
* All 3 unit tests passed
|
||||||
|
|
||||||
|
## 4.0.0
|
||||||
|
|
||||||
|
* Migrated to support Dart >= 2.12 NNBD
|
||||||
|
|
||||||
|
## 3.0.0
|
||||||
|
|
||||||
|
* Migrated to work with Dart >= 2.12 Non NNBD
|
||||||
|
|
||||||
|
## 2.0.3
|
||||||
|
|
||||||
|
* Remove `WebSocketController.plugin`.
|
||||||
|
* Remove any unawaited futures.
|
||||||
|
|
||||||
|
## 2.0.2
|
||||||
|
|
||||||
|
* Update `stream_channel` to `2.0.0`.
|
||||||
|
* Use `angel_framework^@2.0.0-rc.0`.
|
||||||
|
|
||||||
|
## 2.0.1
|
||||||
|
|
||||||
|
* Add `reconnectOnClose` and `reconnectinterval` parameters in top-level `WebSockets` constructors.
|
||||||
|
* Close `WebSocketExtraneousEventHandler`.
|
||||||
|
* Add onAuthenticated to server-side.
|
||||||
|
|
||||||
|
## 2.0.0
|
||||||
|
|
||||||
|
* Update to work with `client@2.0.0`.
|
||||||
|
|
||||||
|
## 2.0.0-alpha.8
|
||||||
|
|
||||||
|
* Support for WebSockets over HTTP/2 (though in practice this doesn't often happen, if ever).
|
||||||
|
|
||||||
|
## 2.0.0-alpha.7
|
||||||
|
|
||||||
|
* Replace `WebSocketSynchronizer` with `StreamChannel<WebSocketEvent>`.
|
||||||
|
|
||||||
|
## 2.0.0-alpha.6
|
||||||
|
|
||||||
|
* Explicit import of `import 'package:http/io_client.dart' as http;`
|
||||||
|
|
||||||
|
## 2.0.0-alpha.5
|
||||||
|
|
||||||
|
* Update `http` dependency.
|
||||||
|
|
||||||
|
## 2.0.0-alpha.4
|
||||||
|
|
||||||
|
* Remove `package:json_god`.
|
||||||
|
* Make `WebSocketContext` take any `StreamChannel`.
|
||||||
|
* Strong typing updates.
|
||||||
|
|
||||||
|
## 2.0.0-alpha.3
|
||||||
|
|
||||||
|
* Directly import Angel HTTP.
|
||||||
|
|
||||||
|
## 2.0.0-alpha.2
|
||||||
|
|
||||||
|
* Updated for the next version of `angel_client`.
|
||||||
|
|
||||||
|
## 2.0.0-alpha.1
|
||||||
|
|
||||||
|
* Refactorings for updated Angel 2 versions.
|
||||||
|
* Remove `package:dart2_constant`.
|
||||||
|
|
||||||
|
## 2.0.0-alpha
|
||||||
|
|
||||||
|
* Depend on Dart 2 and Angel 2.
|
||||||
|
|
||||||
|
## 1.1.2
|
||||||
|
|
||||||
|
* Dart 2 updates.
|
||||||
|
* Added `handleClient`, which is nice for external implementations
|
||||||
|
that plug into `AngelWebSocket`.
|
||||||
|
|
||||||
|
## 1.1.1
|
||||||
|
|
||||||
|
* Deprecated `unwrap`.
|
||||||
|
* Service streams now pump out `e.data`, rather than the actual event.
|
||||||
|
|
||||||
|
## 1.1.0+1
|
||||||
|
|
||||||
|
* Added `unwrap`.
|
29
packages/websocket/LICENSE
Normal file
29
packages/websocket/LICENSE
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2021, dukefirehawk.com
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
157
packages/websocket/README.md
Normal file
157
packages/websocket/README.md
Normal file
|
@ -0,0 +1,157 @@
|
||||||
|
# Angel3 Websocket
|
||||||
|
|
||||||
|
![Pub Version (including pre-releases)](https://img.shields.io/pub/v/platform_websocket?include_prereleases)
|
||||||
|
[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety)
|
||||||
|
[![Discord](https://img.shields.io/discord/1060322353214660698)](https://discord.gg/3X6bxTUdCM)
|
||||||
|
[![License](https://img.shields.io/github/license/dart-backend/angel)](https://github.com/dart-backend/angel/tree/master/packages/websocket/LICENSE)
|
||||||
|
|
||||||
|
WebSocket plugin for Angel3 framework. This plugin broadcasts events from hooked services via WebSockets. In addition, it adds itself to the app's IoC container as `AngelWebSocket`, so that it can be used in controllers as well.
|
||||||
|
|
||||||
|
WebSocket contexts are add to `req.properties` as `'socket'`.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Server-side
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import "package:angel3_framework/angel3_framework.dart";
|
||||||
|
import "package:platform_websocket/server.dart";
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
var app = Angel();
|
||||||
|
|
||||||
|
var ws = AngelWebSocket();
|
||||||
|
|
||||||
|
// This is a plug-in. It hooks all your services,
|
||||||
|
// to automatically broadcast events.
|
||||||
|
await app.configure(ws.configureServer);
|
||||||
|
|
||||||
|
// Listen for requests at `/ws`.
|
||||||
|
app.all('/ws', ws.handleRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Filtering events is easy with hooked services. Just return a `bool`, whether synchronously or asynchronously.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
myService.properties['ws:filter'] = (HookedServiceEvent e, WebSocketContext socket) async {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
myService.index({
|
||||||
|
'ws:filter': (e, socket) => ...;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Adding Handlers within a Controller
|
||||||
|
|
||||||
|
`WebSocketController` extends a normal `Controller`, but also listens to WebSockets.
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import 'dart:async';
|
||||||
|
import "package:angel3_framework/angel3_framework.dart";
|
||||||
|
import "package:platform_websocket/server.dart";
|
||||||
|
|
||||||
|
@Expose("/")
|
||||||
|
class MyController extends WebSocketController {
|
||||||
|
// A reference to the WebSocket plug-in is required.
|
||||||
|
MyController(AngelWebSocket ws):super(ws);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onConnect(WebSocketContext socket) {
|
||||||
|
// On connect...
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dependency injection works, too..
|
||||||
|
@ExposeWs("read_message")
|
||||||
|
void sendMessage(WebSocketContext socket, WebSocketAction action, Db db) async {
|
||||||
|
socket.send(
|
||||||
|
"found_message",
|
||||||
|
db.collection("messages").findOne(where.id(action.data['message_id'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event filtering
|
||||||
|
@ExposeWs("foo")
|
||||||
|
void foo() {
|
||||||
|
broadcast( WebSocketEvent(...), filter: (socket) async => ...);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client Use
|
||||||
|
|
||||||
|
This repo also provides two client libraries `browser` and `io` that extend the base `angel3_client` interface, and allow you to use a very similar API on the client to that of the server.
|
||||||
|
|
||||||
|
The provided clients also automatically try to reconnect their WebSockets when disconnected, which means you can restart your development server without having to reload browser windows.
|
||||||
|
|
||||||
|
They also provide streams of data that pump out filtered data as it comes in from the server.
|
||||||
|
|
||||||
|
Clients can even perform authentication over WebSockets.
|
||||||
|
|
||||||
|
#### In the Browser
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import "package:platform_websocket/browser.dart";
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
Angel app = WebSockets("/ws");
|
||||||
|
await app.connect();
|
||||||
|
|
||||||
|
var Cars = app.service("api/cars");
|
||||||
|
|
||||||
|
Cars.onCreated.listen((car) => print("New car: $car"));
|
||||||
|
|
||||||
|
// Happens asynchronously
|
||||||
|
Cars.create({"brand": "Toyota"});
|
||||||
|
|
||||||
|
// Authenticate a WebSocket, if you were not already authenticated...
|
||||||
|
app.authenticateViaJwt('<some-jwt>');
|
||||||
|
|
||||||
|
// Listen for arbitrary events
|
||||||
|
app.on['custom_event'].listen((event) {
|
||||||
|
// For example, this might be sent by a
|
||||||
|
// WebSocketController.
|
||||||
|
print('Hi!');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CLI Client
|
||||||
|
|
||||||
|
```dart
|
||||||
|
import "package:angel3_framework/common.dart";
|
||||||
|
import "package:platform_websocket/io.dart";
|
||||||
|
|
||||||
|
// You can include these in a shared file and access on both client and server
|
||||||
|
class Car extends Model {
|
||||||
|
int year;
|
||||||
|
String brand, make;
|
||||||
|
|
||||||
|
Car({this.year, this.brand, this.make});
|
||||||
|
|
||||||
|
@override String toString() => "$year $brand $make";
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
Angel app = WebSockets("/ws");
|
||||||
|
|
||||||
|
// Wait for WebSocket connection...
|
||||||
|
await app.connect();
|
||||||
|
|
||||||
|
var Cars = app.service("api/cars", type: Car);
|
||||||
|
|
||||||
|
Cars.onCreated.listen((Car car) {
|
||||||
|
// Automatically deserialized into a car :)
|
||||||
|
//
|
||||||
|
// I just bought a new 2016 Toyota Camry!
|
||||||
|
print("I just bought a new $car!");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Happens asynchronously
|
||||||
|
Cars.create({"year": 2016, "brand": "Toyota", "make": "Camry"});
|
||||||
|
|
||||||
|
// Authenticate a WebSocket, if you were not already authenticated...
|
||||||
|
app.authenticateViaJwt('<some-jwt>');
|
||||||
|
}
|
||||||
|
```
|
1
packages/websocket/analysis_options.yaml
Normal file
1
packages/websocket/analysis_options.yaml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
include: package:lints/recommended.yaml
|
30
packages/websocket/example/index.html
Normal file
30
packages/websocket/example/index.html
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||||
|
<title>Angel WS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
var url = location.protocol === "https:" ? "wss://" : "ws://";
|
||||||
|
url += location.hostname;
|
||||||
|
if (location.port) url += ":" + location.port;
|
||||||
|
url += "/ws";
|
||||||
|
|
||||||
|
var ws = new WebSocket(url);
|
||||||
|
window.ws = ws;
|
||||||
|
ws.onmessage = function(msg) {
|
||||||
|
console.info(JSON.parse(JSON.parse(msg.data).data));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.sendWs = function(msg) {
|
||||||
|
var data = { type: "ping", data: msg };
|
||||||
|
ws.send(JSON.stringify(data));
|
||||||
|
};
|
||||||
|
|
||||||
|
console.info('Connected! Type sendWs("Hey!") to play around.');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
59
packages/websocket/example/main.dart
Normal file
59
packages/websocket/example/main.dart
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:platform_foundation/core.dart';
|
||||||
|
import 'package:platform_foundation/http.dart';
|
||||||
|
import 'package:platform_foundation/http2.dart';
|
||||||
|
import 'package:platform_websocket/server.dart';
|
||||||
|
import 'package:file/local.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
void main(List<String> args) async {
|
||||||
|
var app = Application();
|
||||||
|
var http = PlatformHttp(app);
|
||||||
|
var ws = AngelWebSocket(app, sendErrors: !app.environment.isProduction);
|
||||||
|
var fs = const LocalFileSystem();
|
||||||
|
app.logger = Logger('platform_websocket');
|
||||||
|
|
||||||
|
// This is a plug-in. It hooks all your services,
|
||||||
|
// to automatically broadcast events.
|
||||||
|
await app.configure(ws.configureServer);
|
||||||
|
|
||||||
|
app.get('/', (req, res) => res.streamFile(fs.file('example/index.html')));
|
||||||
|
|
||||||
|
// Listen for requests at `/ws`.
|
||||||
|
app.get('/ws', ws.handleRequest);
|
||||||
|
|
||||||
|
app.fallback((req, res) => throw PlatformHttpException.notFound());
|
||||||
|
|
||||||
|
ws.onConnection.listen((socket) {
|
||||||
|
var h = socket.request.headers;
|
||||||
|
print('WebSocket onConnection $h');
|
||||||
|
|
||||||
|
socket.onData.listen((x) {
|
||||||
|
socket.send('pong', x);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.contains('http2')) {
|
||||||
|
var ctx = SecurityContext()
|
||||||
|
..useCertificateChain('dev.pem')
|
||||||
|
..usePrivateKey('dev.key', password: 'dartdart');
|
||||||
|
|
||||||
|
try {
|
||||||
|
ctx.setAlpnProtocols(['h2'], true);
|
||||||
|
} catch (e, st) {
|
||||||
|
app.logger.severe(
|
||||||
|
'Cannot set ALPN protocol on server to `h2`. The server will only serve HTTP/1.x.',
|
||||||
|
e,
|
||||||
|
st,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var http2 = PlatformHttp2(app, ctx);
|
||||||
|
http2.onHttp1.listen(http.handleRequest);
|
||||||
|
await http2.startServer('127.0.0.1', 3000);
|
||||||
|
print('Listening at ${http2.uri}');
|
||||||
|
} else {
|
||||||
|
await http.startServer('127.0.0.1', 3000);
|
||||||
|
print('Listening at ${http.uri}');
|
||||||
|
}
|
||||||
|
}
|
461
packages/websocket/lib/base_websocket_client.dart
Normal file
461
packages/websocket/lib/base_websocket_client.dart
Normal file
|
@ -0,0 +1,461 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:platform_client/platform_client.dart';
|
||||||
|
import 'package:platform_client/base_platform_client.dart';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
import 'package:web_socket_channel/status.dart' as status;
|
||||||
|
import 'platform_websocket.dart';
|
||||||
|
import 'constants.dart';
|
||||||
|
|
||||||
|
final RegExp _straySlashes = RegExp(r'(^/)|(/+$)');
|
||||||
|
|
||||||
|
/// An [Angel] client that operates across WebSockets.
|
||||||
|
abstract class BaseWebSocketClient extends BaseAngelClient {
|
||||||
|
Duration? _reconnectInterval;
|
||||||
|
WebSocketChannel? _socket;
|
||||||
|
final Queue<WebSocketAction> _queue = Queue<WebSocketAction>();
|
||||||
|
|
||||||
|
final StreamController _onData = StreamController();
|
||||||
|
final StreamController<WebSocketEvent> _onAllEvents =
|
||||||
|
StreamController<WebSocketEvent>();
|
||||||
|
final StreamController<AngelAuthResult> _onAuthenticated =
|
||||||
|
StreamController<AngelAuthResult>();
|
||||||
|
final StreamController<PlatformHttpException> _onError =
|
||||||
|
StreamController<PlatformHttpException>();
|
||||||
|
final StreamController<Map<String, WebSocketEvent>> _onServiceEvent =
|
||||||
|
StreamController<Map<String, WebSocketEvent>>.broadcast();
|
||||||
|
final StreamController<WebSocketChannelException>
|
||||||
|
_onWebSocketChannelException =
|
||||||
|
StreamController<WebSocketChannelException>();
|
||||||
|
|
||||||
|
/// Use this to handle events that are not standard.
|
||||||
|
final WebSocketExtraneousEventHandler on = WebSocketExtraneousEventHandler();
|
||||||
|
|
||||||
|
/// Fired on all events.
|
||||||
|
Stream<WebSocketEvent> get onAllEvents => _onAllEvents.stream;
|
||||||
|
|
||||||
|
/// Fired whenever a WebSocket is successfully authenticated.
|
||||||
|
@override
|
||||||
|
Stream<AngelAuthResult> get onAuthenticated => _onAuthenticated.stream;
|
||||||
|
|
||||||
|
/// A broadcast stream of data coming from the [socket].
|
||||||
|
///
|
||||||
|
/// Mostly just for internal use.
|
||||||
|
Stream get onData => _onData.stream;
|
||||||
|
|
||||||
|
/// Fired on errors.
|
||||||
|
Stream<PlatformHttpException> get onError => _onError.stream;
|
||||||
|
|
||||||
|
/// Fired whenever an event is fired by a service.
|
||||||
|
Stream<Map<String, WebSocketEvent>> get onServiceEvent =>
|
||||||
|
_onServiceEvent.stream;
|
||||||
|
|
||||||
|
/// Fired on [WebSocketChannelException]s.
|
||||||
|
Stream<WebSocketChannelException> get onWebSocketChannelException =>
|
||||||
|
_onWebSocketChannelException.stream;
|
||||||
|
|
||||||
|
/// The [WebSocketChannel] underneath this instance.
|
||||||
|
WebSocketChannel? get socket => _socket;
|
||||||
|
|
||||||
|
/// If `true` (default), then the client will automatically try to reconnect to the server
|
||||||
|
/// if the socket closes.
|
||||||
|
final bool reconnectOnClose;
|
||||||
|
|
||||||
|
/// The amount of time to wait between reconnect attempts. Default: 10 seconds.
|
||||||
|
Duration? get reconnectInterval => _reconnectInterval;
|
||||||
|
|
||||||
|
Uri? _wsUri;
|
||||||
|
|
||||||
|
/// The [Uri] to which a websocket should point.
|
||||||
|
Uri get websocketUri => _wsUri ??= _toWsUri(baseUrl);
|
||||||
|
|
||||||
|
static Uri _toWsUri(Uri u) {
|
||||||
|
if (u.hasScheme) {
|
||||||
|
if (u.scheme == 'http') {
|
||||||
|
return u.replace(scheme: 'ws');
|
||||||
|
} else if (u.scheme == 'https') {
|
||||||
|
return u.replace(scheme: 'wss');
|
||||||
|
} else {
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return _toWsUri(u.replace(scheme: Uri.base.scheme));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
BaseWebSocketClient(super.client, super.baseUrl,
|
||||||
|
{this.reconnectOnClose = true, Duration? reconnectInterval}) {
|
||||||
|
_reconnectInterval = reconnectInterval ?? Duration(seconds: 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future close() async {
|
||||||
|
on._close();
|
||||||
|
scheduleMicrotask(() async {
|
||||||
|
await _socket!.sink.close(status.normalClosure);
|
||||||
|
await _onData.close();
|
||||||
|
await _onAllEvents.close();
|
||||||
|
await _onAuthenticated.close();
|
||||||
|
await _onError.close();
|
||||||
|
await _onServiceEvent.close();
|
||||||
|
await _onWebSocketChannelException.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connects the WebSocket. [timeout] is optional.
|
||||||
|
Future<WebSocketChannel?> connect({Duration? timeout}) async {
|
||||||
|
if (timeout != null) {
|
||||||
|
var c = Completer<WebSocketChannel>();
|
||||||
|
late Timer timer;
|
||||||
|
|
||||||
|
timer = Timer(timeout, () {
|
||||||
|
if (!c.isCompleted) {
|
||||||
|
if (timer.isActive) timer.cancel();
|
||||||
|
c.completeError(TimeoutException(
|
||||||
|
'WebSocket connection exceeded timeout of ${timeout.inMilliseconds} ms',
|
||||||
|
timeout));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
scheduleMicrotask(() {
|
||||||
|
getConnectedWebSocket().then((socket) {
|
||||||
|
if (!c.isCompleted) {
|
||||||
|
if (timer.isActive) timer.cancel();
|
||||||
|
|
||||||
|
while (_queue.isNotEmpty) {
|
||||||
|
var action = _queue.removeFirst();
|
||||||
|
socket.sink.add(serialize(action));
|
||||||
|
}
|
||||||
|
|
||||||
|
c.complete(socket);
|
||||||
|
}
|
||||||
|
}).catchError((e, StackTrace st) {
|
||||||
|
if (!c.isCompleted) {
|
||||||
|
if (timer.isActive) {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Re-evaluate this error
|
||||||
|
var obj = 'Error';
|
||||||
|
c.completeError(obj, st);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return await c.future.then((socket) {
|
||||||
|
_socket = socket;
|
||||||
|
listen();
|
||||||
|
|
||||||
|
return _socket;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
_socket = await getConnectedWebSocket();
|
||||||
|
listen();
|
||||||
|
return _socket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a new [WebSocketChannel], ready to be listened on.
|
||||||
|
///
|
||||||
|
/// This should be overriden by child classes, **NOT** [connect].
|
||||||
|
Future<WebSocketChannel> getConnectedWebSocket();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Service<Id, Data> service<Id, Data>(String path,
|
||||||
|
{Type? type, AngelDeserializer<Data>? deserializer}) {
|
||||||
|
var uri = path.toString().replaceAll(_straySlashes, '');
|
||||||
|
var wsService = WebSocketsService<Id, Data>(socket, this, uri,
|
||||||
|
deserializer: deserializer);
|
||||||
|
return wsService as Service<Id, Data>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts listening for data.
|
||||||
|
void listen() {
|
||||||
|
_socket?.stream.listen(
|
||||||
|
(data) {
|
||||||
|
_onData.add(data);
|
||||||
|
|
||||||
|
if (data is WebSocketChannelException) {
|
||||||
|
_onWebSocketChannelException.add(data);
|
||||||
|
} else if (data is String) {
|
||||||
|
var jsons = json.decode(data);
|
||||||
|
|
||||||
|
if (jsons is Map) {
|
||||||
|
var event = WebSocketEvent.fromJson(jsons);
|
||||||
|
|
||||||
|
if (event.eventName?.isNotEmpty == true) {
|
||||||
|
_onAllEvents.add(event);
|
||||||
|
on._getStream(event.eventName)!.add(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.eventName == errorEvent) {
|
||||||
|
var error =
|
||||||
|
PlatformHttpException.fromMap((event.data ?? {}) as Map);
|
||||||
|
_onError.add(error);
|
||||||
|
} else if (event.eventName == authenticatedEvent) {
|
||||||
|
var authResult = AngelAuthResult.fromMap(event.data as Map?);
|
||||||
|
_onAuthenticated.add(authResult);
|
||||||
|
} else if (event.eventName?.isNotEmpty == true) {
|
||||||
|
var split = event.eventName!
|
||||||
|
.split('::')
|
||||||
|
.where((str) => str.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (split.length >= 2) {
|
||||||
|
var serviceName = split[0], eventName = split[1];
|
||||||
|
_onServiceEvent
|
||||||
|
.add({serviceName: event..eventName = eventName});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancelOnError: true,
|
||||||
|
onDone: () {
|
||||||
|
_socket = null;
|
||||||
|
if (reconnectOnClose == true) {
|
||||||
|
Timer.periodic(reconnectInterval!, (Timer timer) async {
|
||||||
|
WebSocketChannel? result;
|
||||||
|
|
||||||
|
try {
|
||||||
|
result = await connect(timeout: reconnectInterval);
|
||||||
|
} catch (e) {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result != null) timer.cancel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes data to JSON.
|
||||||
|
dynamic serialize(x) => json.encode(x);
|
||||||
|
|
||||||
|
/// Sends the given [action] on the [socket].
|
||||||
|
void sendAction(WebSocketAction action) {
|
||||||
|
if (_socket == null) {
|
||||||
|
_queue.addLast(action);
|
||||||
|
} else {
|
||||||
|
socket?.sink.add(serialize(action));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts to authenticate a WebSocket, using a valid JWT.
|
||||||
|
void authenticateViaJwt(String? jwt) {
|
||||||
|
sendAction(WebSocketAction(
|
||||||
|
eventName: authenticateAction,
|
||||||
|
params: {
|
||||||
|
'query': {'jwt': jwt}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A [Service] that asynchronously interacts with the server.
|
||||||
|
class WebSocketsService<Id, Data> extends Service<Id, Data?> {
|
||||||
|
/// The [BaseWebSocketClient] that spawned this service.
|
||||||
|
@override
|
||||||
|
final BaseWebSocketClient app;
|
||||||
|
|
||||||
|
/// Used to deserialize JSON into typed data.
|
||||||
|
final AngelDeserializer<Data>? deserializer;
|
||||||
|
|
||||||
|
/// The [WebSocketChannel] to listen to, and send data across.
|
||||||
|
final WebSocketChannel? socket;
|
||||||
|
|
||||||
|
/// The service path to listen to.
|
||||||
|
final String path;
|
||||||
|
|
||||||
|
final StreamController<WebSocketEvent> _onAllEvents =
|
||||||
|
StreamController<WebSocketEvent>();
|
||||||
|
final StreamController<List<Data?>> _onIndexed = StreamController();
|
||||||
|
final StreamController<Data?> _onRead = StreamController<Data>();
|
||||||
|
final StreamController<Data?> _onCreated = StreamController<Data>();
|
||||||
|
final StreamController<Data?> _onModified = StreamController<Data>();
|
||||||
|
final StreamController<Data?> _onUpdated = StreamController<Data>();
|
||||||
|
final StreamController<Data?> _onRemoved = StreamController<Data>();
|
||||||
|
|
||||||
|
/// Fired on all events.
|
||||||
|
Stream<WebSocketEvent> get onAllEvents => _onAllEvents.stream;
|
||||||
|
|
||||||
|
/// Fired on `index` events.
|
||||||
|
@override
|
||||||
|
Stream<List<Data?>> get onIndexed => _onIndexed.stream;
|
||||||
|
|
||||||
|
/// Fired on `read` events.
|
||||||
|
@override
|
||||||
|
Stream<Data?> get onRead => _onRead.stream;
|
||||||
|
|
||||||
|
/// Fired on `created` events.
|
||||||
|
@override
|
||||||
|
Stream<Data?> get onCreated => _onCreated.stream;
|
||||||
|
|
||||||
|
/// Fired on `modified` events.
|
||||||
|
@override
|
||||||
|
Stream<Data?> get onModified => _onModified.stream;
|
||||||
|
|
||||||
|
/// Fired on `updated` events.
|
||||||
|
@override
|
||||||
|
Stream<Data?> get onUpdated => _onUpdated.stream;
|
||||||
|
|
||||||
|
/// Fired on `removed` events.
|
||||||
|
@override
|
||||||
|
Stream<Data?> get onRemoved => _onRemoved.stream;
|
||||||
|
|
||||||
|
WebSocketsService(this.socket, this.app, this.path, {this.deserializer}) {
|
||||||
|
listen();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future close() async {
|
||||||
|
await _onAllEvents.close();
|
||||||
|
await _onCreated.close();
|
||||||
|
await _onIndexed.close();
|
||||||
|
await _onModified.close();
|
||||||
|
await _onRead.close();
|
||||||
|
await _onRemoved.close();
|
||||||
|
await _onUpdated.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializes an [action] to be sent over a WebSocket.
|
||||||
|
dynamic serialize(WebSocketAction action) => json.encode(action);
|
||||||
|
|
||||||
|
/// Deserializes data from a [WebSocketEvent].
|
||||||
|
Data? deserialize(x) {
|
||||||
|
return deserializer != null ? deserializer!(x) : x as Data?;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deserializes the contents of an [event].
|
||||||
|
WebSocketEvent<Data> transformEvent(WebSocketEvent event) {
|
||||||
|
return WebSocketEvent(
|
||||||
|
eventName: event.eventName, data: deserialize(event.data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts listening for events.
|
||||||
|
void listen() {
|
||||||
|
app.onServiceEvent.listen((map) {
|
||||||
|
if (map.containsKey(path)) {
|
||||||
|
var event = map[path]!;
|
||||||
|
|
||||||
|
_onAllEvents.add(event);
|
||||||
|
|
||||||
|
if (event.eventName == indexedEvent) {
|
||||||
|
var d = event.data;
|
||||||
|
var transformed = WebSocketEvent(
|
||||||
|
eventName: event.eventName,
|
||||||
|
data: d is Iterable ? d.map(deserialize).toList() : null);
|
||||||
|
if (transformed.data != null) {
|
||||||
|
_onIndexed.add(transformed.data!);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var transformed = transformEvent(event).data;
|
||||||
|
|
||||||
|
switch (event.eventName) {
|
||||||
|
case readEvent:
|
||||||
|
_onRead.add(transformed);
|
||||||
|
break;
|
||||||
|
case createdEvent:
|
||||||
|
_onCreated.add(transformed);
|
||||||
|
break;
|
||||||
|
case modifiedEvent:
|
||||||
|
_onModified.add(transformed);
|
||||||
|
break;
|
||||||
|
case updatedEvent:
|
||||||
|
_onUpdated.add(transformed);
|
||||||
|
break;
|
||||||
|
case removedEvent:
|
||||||
|
_onRemoved.add(transformed);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends the given [action] on the [socket].
|
||||||
|
void send(WebSocketAction action) {
|
||||||
|
app.sendAction(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Data>?> index([Map<String, dynamic>? params]) async {
|
||||||
|
app.sendAction(WebSocketAction(
|
||||||
|
eventName: '$path::$indexAction', params: params ?? {}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Data?> read(id, [Map<String, dynamic>? params]) async {
|
||||||
|
app.sendAction(WebSocketAction(
|
||||||
|
eventName: '$path::$readAction',
|
||||||
|
id: id.toString(),
|
||||||
|
params: params ?? {}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Data?> create(data, [Map<String, dynamic>? params]) async {
|
||||||
|
app.sendAction(WebSocketAction(
|
||||||
|
eventName: '$path::$createAction', data: data, params: params ?? {}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Data?> modify(id, data, [Map<String, dynamic>? params]) async {
|
||||||
|
app.sendAction(WebSocketAction(
|
||||||
|
eventName: '$path::$modifyAction',
|
||||||
|
id: id.toString(),
|
||||||
|
data: data,
|
||||||
|
params: params ?? {}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Data?> update(id, data, [Map<String, dynamic>? params]) async {
|
||||||
|
app.sendAction(WebSocketAction(
|
||||||
|
eventName: '$path::$updateAction',
|
||||||
|
id: id.toString(),
|
||||||
|
data: data,
|
||||||
|
params: params ?? {}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Data?> remove(id, [Map<String, dynamic>? params]) async {
|
||||||
|
app.sendAction(WebSocketAction(
|
||||||
|
eventName: '$path::$removeAction',
|
||||||
|
id: id.toString(),
|
||||||
|
params: params ?? {}));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Contains a dynamic Map of [WebSocketEvent] streams.
|
||||||
|
class WebSocketExtraneousEventHandler {
|
||||||
|
final Map<String?, StreamController<WebSocketEvent>> _events = {};
|
||||||
|
|
||||||
|
StreamController<WebSocketEvent>? _getStream(String? index) {
|
||||||
|
if (_events[index] == null) {
|
||||||
|
_events[index] = StreamController<WebSocketEvent>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _events[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<WebSocketEvent> operator [](String index) {
|
||||||
|
if (_events[index] == null) {
|
||||||
|
_events[index] = StreamController<WebSocketEvent>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return _events[index]!.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _close() {
|
||||||
|
for (var s in _events.values) {
|
||||||
|
s.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
111
packages/websocket/lib/browser.dart
Normal file
111
packages/websocket/lib/browser.dart
Normal file
|
@ -0,0 +1,111 @@
|
||||||
|
/// Browser WebSocket client library for the Angel framework.
|
||||||
|
library platform_websocket.browser;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:html';
|
||||||
|
import 'package:platform_client/platform_client.dart';
|
||||||
|
import 'package:http/browser_client.dart' as http;
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
import 'package:web_socket_channel/html.dart';
|
||||||
|
import 'base_websocket_client.dart';
|
||||||
|
export 'platform_websocket.dart';
|
||||||
|
|
||||||
|
final RegExp _straySlashes = RegExp(r'(^/)|(/+$)');
|
||||||
|
|
||||||
|
/// Queries an Angel server via WebSockets.
|
||||||
|
class WebSockets extends BaseWebSocketClient {
|
||||||
|
final List<BrowserWebSocketsService> _services = [];
|
||||||
|
|
||||||
|
WebSockets(baseUrl,
|
||||||
|
{bool reconnectOnClose = true, Duration? reconnectInterval})
|
||||||
|
: super(http.BrowserClient(), baseUrl,
|
||||||
|
reconnectOnClose: reconnectOnClose,
|
||||||
|
reconnectInterval: reconnectInterval);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future close() {
|
||||||
|
for (var service in _services) {
|
||||||
|
service.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> authenticateViaPopup(String url,
|
||||||
|
{String eventName = 'token', String? errorMessage}) {
|
||||||
|
var ctrl = StreamController<String>();
|
||||||
|
var wnd = window.open(url, 'angel_client_auth_popup');
|
||||||
|
|
||||||
|
Timer t;
|
||||||
|
StreamSubscription<Event>? sub;
|
||||||
|
t = Timer.periodic(Duration(milliseconds: 500), (timer) {
|
||||||
|
if (!ctrl.isClosed) {
|
||||||
|
if (wnd.closed == true) {
|
||||||
|
ctrl.addError(PlatformHttpException.notAuthenticated(
|
||||||
|
message:
|
||||||
|
errorMessage ?? 'Authentication via popup window failed.'));
|
||||||
|
ctrl.close();
|
||||||
|
timer.cancel();
|
||||||
|
sub?.cancel();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
timer.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sub = window.on[eventName].listen((e) {
|
||||||
|
if (!ctrl.isClosed) {
|
||||||
|
ctrl.add((e as CustomEvent).detail.toString());
|
||||||
|
t.cancel();
|
||||||
|
ctrl.close();
|
||||||
|
sub?.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return ctrl.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WebSocketChannel> getConnectedWebSocket() {
|
||||||
|
var url = websocketUri;
|
||||||
|
|
||||||
|
if (authToken?.isNotEmpty == true) {
|
||||||
|
url = url.replace(
|
||||||
|
queryParameters: Map<String, String?>.from(url.queryParameters)
|
||||||
|
..['token'] = authToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var socket = WebSocket(url.toString());
|
||||||
|
var completer = Completer<WebSocketChannel>();
|
||||||
|
|
||||||
|
socket
|
||||||
|
..onOpen.listen((_) {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
return completer.complete(HtmlWebSocketChannel(socket));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
..onError.listen((e) {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
return completer.completeError(e is ErrorEvent ? e.error! : e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Service<Id, Data> service<Id, Data>(String path,
|
||||||
|
{Type? type, AngelDeserializer<Data>? deserializer}) {
|
||||||
|
var uri = path.replaceAll(_straySlashes, '');
|
||||||
|
return BrowserWebSocketsService<Id, Data>(socket, this, uri,
|
||||||
|
deserializer: deserializer) as Service<Id, Data>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BrowserWebSocketsService<Id, Data> extends WebSocketsService<Id, Data> {
|
||||||
|
final Type? type;
|
||||||
|
|
||||||
|
BrowserWebSocketsService(super.socket, WebSockets super.app, super.uri,
|
||||||
|
{this.type, super.deserializer});
|
||||||
|
}
|
36
packages/websocket/lib/constants.dart
Normal file
36
packages/websocket/lib/constants.dart
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
const String authenticateAction = 'authenticate';
|
||||||
|
const String indexAction = 'index';
|
||||||
|
const String readAction = 'read';
|
||||||
|
const String createAction = 'create';
|
||||||
|
const String modifyAction = 'modify';
|
||||||
|
const String updateAction = 'update';
|
||||||
|
const String removeAction = 'remove';
|
||||||
|
|
||||||
|
const String authenticatedEvent = 'authenticated';
|
||||||
|
const String errorEvent = 'error';
|
||||||
|
const String indexedEvent = 'indexed';
|
||||||
|
const String readEvent = 'read';
|
||||||
|
const String createdEvent = 'created';
|
||||||
|
const String modifiedEvent = 'modified';
|
||||||
|
const String updatedEvent = 'updated';
|
||||||
|
const String removedEvent = 'removed';
|
||||||
|
|
||||||
|
/// The standard Angel service actions.
|
||||||
|
const List<String> actions = <String>[
|
||||||
|
indexAction,
|
||||||
|
readAction,
|
||||||
|
createAction,
|
||||||
|
modifyAction,
|
||||||
|
updateAction,
|
||||||
|
removeAction
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The standard Angel service events.
|
||||||
|
const List<String> events = <String>[
|
||||||
|
indexedEvent,
|
||||||
|
readEvent,
|
||||||
|
createdEvent,
|
||||||
|
modifiedEvent,
|
||||||
|
updatedEvent,
|
||||||
|
removedEvent
|
||||||
|
];
|
49
packages/websocket/lib/flutter.dart
Normal file
49
packages/websocket/lib/flutter.dart
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
/// Flutter-compatible WebSocket client library for the Angel framework.
|
||||||
|
library angel_websocket.flutter;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:http/io_client.dart' as http;
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
import 'package:web_socket_channel/io.dart';
|
||||||
|
import 'base_websocket_client.dart';
|
||||||
|
export 'package:platform_client/platform_client.dart';
|
||||||
|
export 'platform_websocket.dart';
|
||||||
|
|
||||||
|
// final RegExp _straySlashes = RegExp(r"(^/)|(/+$)");
|
||||||
|
|
||||||
|
/// Queries an Angel server via WebSockets.
|
||||||
|
class WebSockets extends BaseWebSocketClient {
|
||||||
|
final List<WebSocketsService> _services = [];
|
||||||
|
|
||||||
|
WebSockets(baseUrl,
|
||||||
|
{bool reconnectOnClose = true, Duration? reconnectInterval})
|
||||||
|
: super(http.IOClient(), baseUrl,
|
||||||
|
reconnectOnClose: reconnectOnClose,
|
||||||
|
reconnectInterval: reconnectInterval);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> authenticateViaPopup(String url,
|
||||||
|
{String eventName = 'token'}) {
|
||||||
|
throw UnimplementedError(
|
||||||
|
'Opening popup windows is not supported in the `dart:io` client.');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future close() {
|
||||||
|
for (var service in _services) {
|
||||||
|
service.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WebSocketChannel> getConnectedWebSocket() async {
|
||||||
|
var socket = await WebSocket.connect(websocketUri.toString(),
|
||||||
|
headers: authToken?.isNotEmpty == true
|
||||||
|
? {'Authorization': 'Bearer $authToken'}
|
||||||
|
: {});
|
||||||
|
return IOWebSocketChannel(socket);
|
||||||
|
}
|
||||||
|
}
|
29
packages/websocket/lib/hooks.dart
Normal file
29
packages/websocket/lib/hooks.dart
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import 'package:platform_foundation/core.dart';
|
||||||
|
|
||||||
|
/// Prevents a WebSocket event from being broadcasted, to any client from the given [provider].
|
||||||
|
///
|
||||||
|
/// [provider] can be a String, a [Provider], or an Iterable.
|
||||||
|
/// If [provider] is `null`, any provider will be blocked.
|
||||||
|
HookedServiceEventListener doNotBroadcast([provider]) {
|
||||||
|
return (HookedServiceEvent e) {
|
||||||
|
if (e.params.containsKey('provider')) {
|
||||||
|
var eParam = e.params;
|
||||||
|
var deny = false;
|
||||||
|
var providers = provider is Iterable ? provider : [provider];
|
||||||
|
|
||||||
|
for (var p in providers) {
|
||||||
|
if (deny) break;
|
||||||
|
|
||||||
|
if (p is Providers) {
|
||||||
|
deny = deny || p == eParam['provider'] || eParam['provider'] == p.via;
|
||||||
|
} else if (p == null) {
|
||||||
|
deny = true;
|
||||||
|
} else {
|
||||||
|
deny = deny || (eParam['provider'] as Providers).via == p.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eParam['broadcast'] = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
64
packages/websocket/lib/io.dart
Normal file
64
packages/websocket/lib/io.dart
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
/// Command-line WebSocket client library for the Angel framework.
|
||||||
|
library platform_websocket.io;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:platform_client/platform_client.dart';
|
||||||
|
import 'package:http/io_client.dart' as http;
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
import 'package:web_socket_channel/io.dart';
|
||||||
|
import 'base_websocket_client.dart';
|
||||||
|
export 'package:platform_client/platform_client.dart';
|
||||||
|
export 'platform_websocket.dart';
|
||||||
|
|
||||||
|
final RegExp _straySlashes = RegExp(r'(^/)|(/+$)');
|
||||||
|
|
||||||
|
/// Queries an Angel server via WebSockets.
|
||||||
|
class WebSockets extends BaseWebSocketClient {
|
||||||
|
final List<IoWebSocketsService> _services = [];
|
||||||
|
|
||||||
|
WebSockets(baseUrl,
|
||||||
|
{bool reconnectOnClose = true, Duration? reconnectInterval})
|
||||||
|
: super(http.IOClient(), baseUrl,
|
||||||
|
reconnectOnClose: reconnectOnClose,
|
||||||
|
reconnectInterval: reconnectInterval);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<String> authenticateViaPopup(String url,
|
||||||
|
{String eventName = 'token'}) {
|
||||||
|
throw UnimplementedError(
|
||||||
|
'Opening popup windows is not supported in the `dart:io` client.');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future close() {
|
||||||
|
for (var service in _services) {
|
||||||
|
service.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<WebSocketChannel> getConnectedWebSocket() async {
|
||||||
|
var socket = await WebSocket.connect(websocketUri.toString(),
|
||||||
|
headers: authToken?.isNotEmpty == true
|
||||||
|
? {'Authorization': 'Bearer $authToken'}
|
||||||
|
: {});
|
||||||
|
return IOWebSocketChannel(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Service<Id, Data> service<Id, Data>(String path,
|
||||||
|
{Type? type, AngelDeserializer<Data>? deserializer}) {
|
||||||
|
var uri = path.replaceAll(_straySlashes, '');
|
||||||
|
return IoWebSocketsService<Id, Data>(socket, this, uri, type)
|
||||||
|
as Service<Id, Data>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IoWebSocketsService<Id, Data> extends WebSocketsService<Id, Data> {
|
||||||
|
final Type? type;
|
||||||
|
|
||||||
|
IoWebSocketsService(super.socket, WebSockets super.app, super.uri, this.type);
|
||||||
|
}
|
45
packages/websocket/lib/platform_websocket.dart
Normal file
45
packages/websocket/lib/platform_websocket.dart
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
/// WebSocket plugin for Angel.
|
||||||
|
library platform_websocket;
|
||||||
|
|
||||||
|
/// A notification from the server that something has occurred.
|
||||||
|
class WebSocketEvent<Data> {
|
||||||
|
String? eventName;
|
||||||
|
Data? data;
|
||||||
|
|
||||||
|
WebSocketEvent({this.eventName, this.data});
|
||||||
|
|
||||||
|
factory WebSocketEvent.fromJson(Map data) => WebSocketEvent(
|
||||||
|
eventName: data['eventName'].toString(), data: data['data'] as Data?);
|
||||||
|
|
||||||
|
WebSocketEvent<T> cast<T>() {
|
||||||
|
if (T == Data) {
|
||||||
|
return this as WebSocketEvent<T>;
|
||||||
|
} else {
|
||||||
|
return WebSocketEvent<T>(eventName: eventName, data: data as T?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'eventName': eventName, 'data': data};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A command sent to the server, usually corresponding to a service method.
|
||||||
|
class WebSocketAction {
|
||||||
|
String? id;
|
||||||
|
String? eventName;
|
||||||
|
dynamic data;
|
||||||
|
Map<String, dynamic> params;
|
||||||
|
|
||||||
|
WebSocketAction({this.id, this.eventName, this.data, this.params = const {}});
|
||||||
|
|
||||||
|
factory WebSocketAction.fromJson(Map data) => WebSocketAction(
|
||||||
|
id: data['id'].toString(),
|
||||||
|
eventName: data['eventName'].toString(),
|
||||||
|
data: data['data'],
|
||||||
|
params: data['params'] as Map<String, dynamic>? ?? {});
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'id': id, 'eventName': eventName, 'data': data, 'params': params};
|
||||||
|
}
|
||||||
|
}
|
521
packages/websocket/lib/server.dart
Normal file
521
packages/websocket/lib/server.dart
Normal file
|
@ -0,0 +1,521 @@
|
||||||
|
/// Server-side support for WebSockets.
|
||||||
|
library platform_websocket.server;
|
||||||
|
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:mirrors';
|
||||||
|
import 'package:platform_auth/auth.dart';
|
||||||
|
import 'package:platform_foundation/core.dart';
|
||||||
|
import 'package:platform_foundation/http.dart';
|
||||||
|
import 'package:platform_foundation/http2.dart';
|
||||||
|
import 'package:platform_merge_map/merge_map.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:stream_channel/stream_channel.dart';
|
||||||
|
import 'package:web_socket_channel/io.dart';
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
import 'package:collection/collection.dart' show IterableExtension;
|
||||||
|
import 'platform_websocket.dart';
|
||||||
|
import 'constants.dart';
|
||||||
|
export 'platform_websocket.dart';
|
||||||
|
|
||||||
|
part 'websocket_context.dart';
|
||||||
|
|
||||||
|
part 'websocket_controller.dart';
|
||||||
|
|
||||||
|
typedef WebSocketResponseSerializer = String Function(dynamic data);
|
||||||
|
|
||||||
|
/// Broadcasts events from [HookedService]s, and handles incoming [WebSocketAction]s.
|
||||||
|
class AngelWebSocket {
|
||||||
|
final List<WebSocketContext> _clients = <WebSocketContext>[];
|
||||||
|
final List<String> _servicesAlreadyWired = [];
|
||||||
|
|
||||||
|
final StreamController<WebSocketAction> _onAction =
|
||||||
|
StreamController<WebSocketAction>();
|
||||||
|
final StreamController _onData = StreamController();
|
||||||
|
final StreamController<WebSocketContext> _onConnection =
|
||||||
|
StreamController<WebSocketContext>.broadcast();
|
||||||
|
final StreamController<WebSocketContext> _onDisconnect =
|
||||||
|
StreamController<WebSocketContext>.broadcast();
|
||||||
|
|
||||||
|
final Application app;
|
||||||
|
|
||||||
|
/// If this is not `true`, then all client-side service parameters will be
|
||||||
|
/// discarded, other than `params['query']`.
|
||||||
|
final bool allowClientParams;
|
||||||
|
|
||||||
|
/// An optional whitelist of allowed client origins, or [:null:].
|
||||||
|
final List<String> allowedOrigins;
|
||||||
|
|
||||||
|
/// An optional whitelist of allowed client protocols, or [:null:].
|
||||||
|
final List<String> allowedProtocols;
|
||||||
|
|
||||||
|
/// If `true`, then clients can authenticate their WebSockets by sending a valid JWT.
|
||||||
|
final bool allowAuth;
|
||||||
|
|
||||||
|
/// Send error information across WebSockets, without including debug information..
|
||||||
|
final bool sendErrors;
|
||||||
|
|
||||||
|
/// A list of clients currently connected to this server via WebSockets.
|
||||||
|
List<WebSocketContext> get clients => List.unmodifiable(_clients);
|
||||||
|
|
||||||
|
/// Services that have already been hooked to fire socket events.
|
||||||
|
List<String> get servicesAlreadyWired =>
|
||||||
|
List.unmodifiable(_servicesAlreadyWired);
|
||||||
|
|
||||||
|
Logger get _log => app.logger;
|
||||||
|
|
||||||
|
/// Used to notify other nodes of an event's firing. Good for scaled applications.
|
||||||
|
final StreamChannel<WebSocketEvent>? synchronizationChannel;
|
||||||
|
|
||||||
|
/// Fired on any [WebSocketAction].
|
||||||
|
Stream<WebSocketAction> get onAction => _onAction.stream;
|
||||||
|
|
||||||
|
/// Fired whenever a WebSocket sends data.
|
||||||
|
Stream get onData => _onData.stream;
|
||||||
|
|
||||||
|
/// Fired on incoming connections.
|
||||||
|
Stream<WebSocketContext> get onConnection => _onConnection.stream;
|
||||||
|
|
||||||
|
/// Fired when a user disconnects.
|
||||||
|
Stream<WebSocketContext> get onDisconnection => _onDisconnect.stream;
|
||||||
|
|
||||||
|
/// Serializes data to WebSockets.
|
||||||
|
WebSocketResponseSerializer? serializer;
|
||||||
|
|
||||||
|
/// Deserializes data from WebSockets.
|
||||||
|
Function? deserializer;
|
||||||
|
|
||||||
|
AngelWebSocket(this.app,
|
||||||
|
{this.sendErrors = false,
|
||||||
|
this.allowClientParams = false,
|
||||||
|
this.allowAuth = true,
|
||||||
|
this.synchronizationChannel,
|
||||||
|
this.serializer,
|
||||||
|
this.deserializer,
|
||||||
|
this.allowedOrigins = const [],
|
||||||
|
this.allowedProtocols = const []}) {
|
||||||
|
serializer ??= json.encode;
|
||||||
|
deserializer ??= (params) => params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Deprecated. Original code that failed to compile after upgrading
|
||||||
|
*/
|
||||||
|
/*
|
||||||
|
HookedServiceEventListener serviceHookOriginal(String path) {
|
||||||
|
return (HookedServiceEvent e) async {
|
||||||
|
if (e.params != null && e.params['broadcast'] == false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var event = await transformEvent(e);
|
||||||
|
event.eventName = '$path::${event.eventName}';
|
||||||
|
|
||||||
|
dynamic _filter(WebSocketContext socket) {
|
||||||
|
if (e.service.configuration.containsKey('ws:filter')) {
|
||||||
|
return e.service.configuration['ws:filter'](e, socket);
|
||||||
|
} else if (e.params != null && e.params.containsKey('ws:filter')) {
|
||||||
|
return e.params['ws:filter'](e, socket);
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await batchEvent(event, filter: _filter);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureOr<dynamic> Function(HookedServiceEvent<dynamic, dynamic, Service> e)
|
||||||
|
serviceHook(String path) {
|
||||||
|
return (HookedServiceEvent e) async {
|
||||||
|
if (e.params != null && e.params['broadcast'] == false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var event = await transformEvent(e);
|
||||||
|
event.eventName = '$path::${event.eventName}';
|
||||||
|
|
||||||
|
dynamic _filter(WebSocketContext socket) {
|
||||||
|
if (e.service.configuration.containsKey('ws:filter')) {
|
||||||
|
return e.service.configuration['ws:filter'](e, socket);
|
||||||
|
} else if (e.params != null && e.params.containsKey('ws:filter')) {
|
||||||
|
return e.params['ws:filter'](e, socket);
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await batchEvent(event, filter: _filter);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
FutureOr<dynamic> Function(HookedServiceEvent<dynamic, dynamic, Service> e)
|
||||||
|
serviceHook(String path) {
|
||||||
|
return (HookedServiceEvent e) async {
|
||||||
|
if (e.params['broadcast'] == false) return;
|
||||||
|
|
||||||
|
var event = await transformEvent(e);
|
||||||
|
event.eventName = '$path::${event.eventName}';
|
||||||
|
|
||||||
|
dynamic filter(WebSocketContext socket) {
|
||||||
|
if (e.service.configuration.containsKey('ws:filter')) {
|
||||||
|
return e.service.configuration['ws:filter'](e, socket);
|
||||||
|
} else if (e.params.containsKey('ws:filter')) {
|
||||||
|
return e.params['ws:filter'](e, socket);
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await batchEvent(event, filter: filter);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slates an event to be dispatched.
|
||||||
|
Future<void> batchEvent(WebSocketEvent event,
|
||||||
|
{Function(WebSocketContext socket)? filter, bool notify = true}) async {
|
||||||
|
// Default implementation will just immediately fire events
|
||||||
|
for (var client in _clients) {
|
||||||
|
dynamic result = true;
|
||||||
|
if (filter != null) {
|
||||||
|
result = await filter(client);
|
||||||
|
}
|
||||||
|
if (result == true) {
|
||||||
|
client.channel.sink.add((serializer ?? json.encode)(event.toJson()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (synchronizationChannel != null && notify != false) {
|
||||||
|
synchronizationChannel!.sink.add(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a list of events yet to be sent.
|
||||||
|
Future<List<WebSocketEvent>> getBatchedEvents() async => [];
|
||||||
|
|
||||||
|
/// Responds to an incoming action on a WebSocket.
|
||||||
|
Future handleAction(WebSocketAction action, WebSocketContext socket) async {
|
||||||
|
var split = action.eventName!.split('::');
|
||||||
|
|
||||||
|
if (split.length < 2) {
|
||||||
|
socket.sendError(PlatformHttpException.badRequest());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = app.findService(split[0]);
|
||||||
|
|
||||||
|
if (service == null) {
|
||||||
|
socket.sendError(PlatformHttpException.notFound(
|
||||||
|
message: 'No service "${split[0]}" exists.'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var actionName = split[1];
|
||||||
|
|
||||||
|
//if (action.params is! Map) action.params = <String, dynamic>{};
|
||||||
|
|
||||||
|
if (allowClientParams != true) {
|
||||||
|
if (action.params['query'] is Map) {
|
||||||
|
action.params = {'query': action.params['query']};
|
||||||
|
} else {
|
||||||
|
action.params = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var params = mergeMap<String, dynamic>([
|
||||||
|
(((deserializer ?? (params) => params)(action.params))
|
||||||
|
as Map<String, dynamic>),
|
||||||
|
{
|
||||||
|
'provider': Providers.websocket,
|
||||||
|
'__requestctx': socket.request,
|
||||||
|
'__responsectx': socket.response
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (actionName == indexAction) {
|
||||||
|
socket.send('${split[0]}::$indexedEvent', await service.index(params));
|
||||||
|
return null;
|
||||||
|
} else if (actionName == readAction) {
|
||||||
|
socket.send(
|
||||||
|
'${split[0]}::$readEvent', await service.read(action.id, params));
|
||||||
|
return null;
|
||||||
|
} else if (actionName == createAction) {
|
||||||
|
return WebSocketEvent(
|
||||||
|
eventName: '${split[0]}::$createdEvent',
|
||||||
|
data: await service.create(action.data, params));
|
||||||
|
} else if (actionName == modifyAction) {
|
||||||
|
return WebSocketEvent(
|
||||||
|
eventName: '${split[0]}::$modifiedEvent',
|
||||||
|
data: await service.modify(action.id, action.data, params));
|
||||||
|
} else if (actionName == updateAction) {
|
||||||
|
return WebSocketEvent(
|
||||||
|
eventName: '${split[0]}::$updatedEvent',
|
||||||
|
data: await service.update(action.id, action.data, params));
|
||||||
|
} else if (actionName == removeAction) {
|
||||||
|
return WebSocketEvent(
|
||||||
|
eventName: '${split[0]}::$removedEvent',
|
||||||
|
data: await service.remove(action.id, params));
|
||||||
|
} else {
|
||||||
|
socket.sendError(PlatformHttpException.methodNotAllowed(
|
||||||
|
message: 'Method Not Allowed: $actionName'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
_log.severe('Unable to handle unknown action');
|
||||||
|
catchError(e, st, socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authenticates a [WebSocketContext].
|
||||||
|
Future handleAuth(WebSocketAction action, WebSocketContext socket) async {
|
||||||
|
if (allowAuth != false &&
|
||||||
|
action.eventName == authenticateAction &&
|
||||||
|
action.params['query'] is Map &&
|
||||||
|
action.params['query']['jwt'] is String) {
|
||||||
|
try {
|
||||||
|
var auth = socket.request.container!.make<PlatformAuth>();
|
||||||
|
var jwt = action.params['query']['jwt'] as String;
|
||||||
|
AuthToken token;
|
||||||
|
|
||||||
|
token = AuthToken.validate(jwt, auth.hmac);
|
||||||
|
var user = await auth.deserializer(token.userId);
|
||||||
|
socket.request
|
||||||
|
..container!.registerSingleton<AuthToken>(token)
|
||||||
|
..container!.registerSingleton(user, as: user.runtimeType);
|
||||||
|
socket._onAuthenticated.add(null);
|
||||||
|
socket.send(authenticatedEvent,
|
||||||
|
{'token': token.serialize(auth.hmac), 'data': user});
|
||||||
|
} catch (e, st) {
|
||||||
|
_log.severe('Authentication failed');
|
||||||
|
catchError(e, st, socket);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
socket.sendError(PlatformHttpException.badRequest(
|
||||||
|
message: 'No JWT provided for authentication.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hooks a service up to have its events broadcasted.
|
||||||
|
dynamic hookupService(Pattern path, HookedService service) {
|
||||||
|
var localPath = path.toString();
|
||||||
|
|
||||||
|
service.after(
|
||||||
|
[
|
||||||
|
HookedServiceEvent.created,
|
||||||
|
HookedServiceEvent.modified,
|
||||||
|
HookedServiceEvent.updated,
|
||||||
|
HookedServiceEvent.removed
|
||||||
|
],
|
||||||
|
serviceHook(localPath),
|
||||||
|
);
|
||||||
|
_servicesAlreadyWired.add(localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs before firing [onConnection].
|
||||||
|
Future handleConnect(WebSocketContext socket) async {}
|
||||||
|
|
||||||
|
/// Handles incoming data from a WebSocket.
|
||||||
|
dynamic handleData(WebSocketContext socket, data) async {
|
||||||
|
try {
|
||||||
|
socket._onData.add(data);
|
||||||
|
var fromJson = json.decode(data.toString());
|
||||||
|
var action = WebSocketAction.fromJson(fromJson as Map);
|
||||||
|
_onAction.add(action);
|
||||||
|
|
||||||
|
if (action.eventName == null ||
|
||||||
|
action.eventName is! String ||
|
||||||
|
action.eventName!.isEmpty) {
|
||||||
|
throw PlatformHttpException.badRequest();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromJson.containsKey('eventName')) {
|
||||||
|
socket._onAction.add(WebSocketAction.fromJson(fromJson));
|
||||||
|
socket.on
|
||||||
|
._getStreamForEvent(fromJson['eventName'].toString())!
|
||||||
|
.add(fromJson['data'] as Map?);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.eventName == authenticateAction) {
|
||||||
|
await handleAuth(action, socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.eventName!.contains('::')) {
|
||||||
|
var split = action.eventName!.split('::');
|
||||||
|
|
||||||
|
if (split.length >= 2) {
|
||||||
|
if (actions.contains(split[1])) {
|
||||||
|
var event = await handleAction(action, socket);
|
||||||
|
if (event is Future) event = await event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
_log.severe('Invalid data');
|
||||||
|
catchError(e, st, socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void catchError(e, StackTrace st, WebSocketContext socket) {
|
||||||
|
// Send an error
|
||||||
|
if (e is PlatformHttpException) {
|
||||||
|
socket.sendError(e);
|
||||||
|
app.logger.severe(e.message, e.error ?? e, e.stackTrace);
|
||||||
|
} else if (sendErrors) {
|
||||||
|
var err = PlatformHttpException(
|
||||||
|
message: e.toString(), stackTrace: st, errors: [st.toString()]);
|
||||||
|
socket.sendError(err);
|
||||||
|
app.logger.severe(err.message, e, st);
|
||||||
|
} else {
|
||||||
|
var err = PlatformHttpException();
|
||||||
|
socket.sendError(err);
|
||||||
|
app.logger.severe(e.toString(), e, st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transforms a [HookedServiceEvent], so that it can be broadcasted.
|
||||||
|
Future<WebSocketEvent> transformEvent(HookedServiceEvent event) async {
|
||||||
|
return WebSocketEvent(eventName: event.eventName, data: event.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hooks any [HookedService]s that are not being broadcasted yet.
|
||||||
|
void wireAllServices(Application app) {
|
||||||
|
for (var key in app.services.keys.where((x) {
|
||||||
|
return !_servicesAlreadyWired.contains(x) &&
|
||||||
|
app.services[x] is HookedService;
|
||||||
|
})) {
|
||||||
|
hookupService(key, app.services[key] as HookedService);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configures an [Application] instance to listen for WebSocket connections.
|
||||||
|
Future configureServer(Application app) async {
|
||||||
|
app.container.registerSingleton(this);
|
||||||
|
|
||||||
|
if (runtimeType != AngelWebSocket) {
|
||||||
|
app.container.registerSingleton<AngelWebSocket>(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up services
|
||||||
|
wireAllServices(app);
|
||||||
|
|
||||||
|
app.onService.listen((_) {
|
||||||
|
wireAllServices(app);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (synchronizationChannel != null) {
|
||||||
|
synchronizationChannel?.stream
|
||||||
|
.listen((e) => batchEvent(e, notify: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
app.shutdownHooks.add((_) => synchronizationChannel?.sink.close());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles an incoming [WebSocketContext].
|
||||||
|
Future<void> handleClient(WebSocketContext socket) async {
|
||||||
|
var origin = socket.request.headers?.value('origin');
|
||||||
|
if (allowedOrigins.isNotEmpty && !allowedOrigins.contains(origin)) {
|
||||||
|
throw PlatformHttpException.forbidden(
|
||||||
|
message:
|
||||||
|
'WebSocket connections are not allowed from the origin "$origin".');
|
||||||
|
}
|
||||||
|
|
||||||
|
_clients.add(socket);
|
||||||
|
await handleConnect(socket);
|
||||||
|
|
||||||
|
_onConnection.add(socket);
|
||||||
|
|
||||||
|
socket.request.container?.registerSingleton<WebSocketContext>(socket);
|
||||||
|
|
||||||
|
socket.channel.stream.listen(
|
||||||
|
(data) {
|
||||||
|
_onData.add(data);
|
||||||
|
handleData(socket, data);
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
_onDisconnect.add(socket);
|
||||||
|
_clients.remove(socket);
|
||||||
|
},
|
||||||
|
onError: (e) {
|
||||||
|
_onDisconnect.add(socket);
|
||||||
|
_clients.remove(socket);
|
||||||
|
},
|
||||||
|
cancelOnError: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles an incoming HTTP request.
|
||||||
|
Future<bool> handleRequest(RequestContext req, ResponseContext res) async {
|
||||||
|
if (req is HttpRequestContext && res is HttpResponseContext) {
|
||||||
|
if (!WebSocketTransformer.isUpgradeRequest(req.rawRequest!)) {
|
||||||
|
throw PlatformHttpException.badRequest();
|
||||||
|
}
|
||||||
|
res.detach();
|
||||||
|
var ws = await WebSocketTransformer.upgrade(req.rawRequest!);
|
||||||
|
var channel = IOWebSocketChannel(ws);
|
||||||
|
var socket = WebSocketContext(channel, req, res);
|
||||||
|
scheduleMicrotask(() => handleClient(socket));
|
||||||
|
return false;
|
||||||
|
} else if (req is Http2RequestContext && res is Http2ResponseContext) {
|
||||||
|
var connection =
|
||||||
|
req.headers?['connection']?.map((s) => s.toLowerCase().trim());
|
||||||
|
var upgrade = req.headers?.value('upgrade')?.toLowerCase();
|
||||||
|
var version = req.headers?.value('sec-websocket-version');
|
||||||
|
var key = req.headers?.value('sec-websocket-key');
|
||||||
|
var protocol = req.headers?.value('sec-websocket-protocol');
|
||||||
|
|
||||||
|
if (connection == null) {
|
||||||
|
throw PlatformHttpException.badRequest(
|
||||||
|
message: 'Missing `connection` header.');
|
||||||
|
} else if (!connection.contains('upgrade')) {
|
||||||
|
throw PlatformHttpException.badRequest(
|
||||||
|
message: 'Missing "upgrade" in `connection` header.');
|
||||||
|
} else if (upgrade != 'websocket') {
|
||||||
|
throw PlatformHttpException.badRequest(
|
||||||
|
message: 'The `upgrade` header must equal "websocket".');
|
||||||
|
} else if (version != '13') {
|
||||||
|
throw PlatformHttpException.badRequest(
|
||||||
|
message: 'The `sec-websocket-version` header must equal "13".');
|
||||||
|
} else if (key == null) {
|
||||||
|
throw PlatformHttpException.badRequest(
|
||||||
|
message: 'Missing `sec-websocket-key` header.');
|
||||||
|
} else if (protocol != null &&
|
||||||
|
allowedProtocols.isNotEmpty &&
|
||||||
|
!allowedProtocols.contains(protocol)) {
|
||||||
|
throw PlatformHttpException.badRequest(
|
||||||
|
message: 'Disallowed `sec-websocket-protocol` header "$protocol".');
|
||||||
|
} else {
|
||||||
|
var stream = res.detach();
|
||||||
|
var ctrl = StreamChannelController<List<int>>();
|
||||||
|
|
||||||
|
ctrl.local.stream.listen((buf) {
|
||||||
|
stream.sendData(buf);
|
||||||
|
}, onDone: () {
|
||||||
|
stream.outgoingMessages.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.hasParsedBody) {
|
||||||
|
await ctrl.local.sink.close();
|
||||||
|
} else {
|
||||||
|
await req.body.pipe(ctrl.local.sink);
|
||||||
|
}
|
||||||
|
|
||||||
|
var sink = utf8.encoder.startChunkedConversion(ctrl.foreign.sink);
|
||||||
|
sink.add('HTTP/1.1 101 Switching Protocols\r\n'
|
||||||
|
'Upgrade: websocket\r\n'
|
||||||
|
'Connection: Upgrade\r\n'
|
||||||
|
'Sec-WebSocket-Accept: ${WebSocketChannel.signKey(key)}\r\n');
|
||||||
|
if (protocol != null) sink.add('Sec-WebSocket-Protocol: $protocol\r\n');
|
||||||
|
sink.add('\r\n');
|
||||||
|
|
||||||
|
//var ws = IOWebSocketChannel.connect(ctrl.foreign);
|
||||||
|
var socket = WebSocketContext(ctrl.foreign, req, res);
|
||||||
|
scheduleMicrotask(() => handleClient(socket));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw ArgumentError(
|
||||||
|
'Not an HTTP/1.1 or HTTP/2 RequestContext+ResponseContext pair: $req, $res');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
75
packages/websocket/lib/websocket_context.dart
Normal file
75
packages/websocket/lib/websocket_context.dart
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
part of 'server.dart';
|
||||||
|
|
||||||
|
/// Represents a WebSocket session, with the original
|
||||||
|
/// [RequestContext] and [ResponseContext] attached.
|
||||||
|
class WebSocketContext {
|
||||||
|
/// Use this to listen for events.
|
||||||
|
WebSocketEventTable on = WebSocketEventTable();
|
||||||
|
|
||||||
|
/// The underlying [StreamChannel].
|
||||||
|
final StreamChannel channel;
|
||||||
|
|
||||||
|
/// The original [RequestContext].
|
||||||
|
final RequestContext request;
|
||||||
|
|
||||||
|
/// The original [ResponseContext].
|
||||||
|
final ResponseContext response;
|
||||||
|
|
||||||
|
final StreamController<WebSocketAction> _onAction =
|
||||||
|
StreamController<WebSocketAction>();
|
||||||
|
|
||||||
|
final StreamController<void> _onAuthenticated = StreamController();
|
||||||
|
|
||||||
|
final StreamController<void> _onClose = StreamController<void>();
|
||||||
|
|
||||||
|
final StreamController _onData = StreamController();
|
||||||
|
|
||||||
|
/// Fired on any [WebSocketAction];
|
||||||
|
Stream<WebSocketAction> get onAction => _onAction.stream;
|
||||||
|
|
||||||
|
/// Fired when the user authenticates.
|
||||||
|
Stream<void> get onAuthenticated => _onAuthenticated.stream;
|
||||||
|
|
||||||
|
/// Fired once the underlying [WebSocket] closes.
|
||||||
|
Stream<void> get onClose => _onClose.stream;
|
||||||
|
|
||||||
|
/// Fired when any data is sent through [channel].
|
||||||
|
Stream get onData => _onData.stream;
|
||||||
|
|
||||||
|
WebSocketContext(this.channel, this.request, this.response);
|
||||||
|
|
||||||
|
/// Closes the underlying [StreamChannel].
|
||||||
|
Future close() async {
|
||||||
|
scheduleMicrotask(() async {
|
||||||
|
await channel.sink.close();
|
||||||
|
await _onAction.close();
|
||||||
|
await _onAuthenticated.close();
|
||||||
|
await _onData.close();
|
||||||
|
//_onClose.add(null);
|
||||||
|
await _onClose.close();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends an arbitrary [WebSocketEvent];
|
||||||
|
void send(String eventName, data) {
|
||||||
|
channel.sink.add(
|
||||||
|
json.encode(WebSocketEvent(eventName: eventName, data: data).toJson()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends an error event.
|
||||||
|
void sendError(PlatformHttpException error) =>
|
||||||
|
send(errorEvent, error.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebSocketEventTable {
|
||||||
|
final Map<String, StreamController<Map?>> _handlers = {};
|
||||||
|
|
||||||
|
StreamController<Map?>? _getStreamForEvent(String eventName) {
|
||||||
|
if (!_handlers.containsKey(eventName)) {
|
||||||
|
_handlers[eventName] = StreamController<Map?>();
|
||||||
|
}
|
||||||
|
return _handlers[eventName];
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<Map?> operator [](String key) => _getStreamForEvent(key)!.stream;
|
||||||
|
}
|
90
packages/websocket/lib/websocket_controller.dart
Normal file
90
packages/websocket/lib/websocket_controller.dart
Normal file
|
@ -0,0 +1,90 @@
|
||||||
|
part of 'server.dart';
|
||||||
|
|
||||||
|
/// Marks a method as available to WebSockets.
|
||||||
|
class ExposeWs {
|
||||||
|
final String eventName;
|
||||||
|
|
||||||
|
const ExposeWs(this.eventName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A special controller that also supports WebSockets.
|
||||||
|
class WebSocketController extends Controller {
|
||||||
|
/// The plug-in instance powering this controller.
|
||||||
|
final AngelWebSocket ws;
|
||||||
|
|
||||||
|
final Map<String, MethodMirror> _handlers = {};
|
||||||
|
final Map<String, Symbol> _handlerSymbols = {};
|
||||||
|
|
||||||
|
WebSocketController(this.ws) : super();
|
||||||
|
|
||||||
|
/// Sends an event to all clients.
|
||||||
|
void broadcast(String eventName, data,
|
||||||
|
{Function(WebSocketContext socket)? filter}) {
|
||||||
|
ws.batchEvent(WebSocketEvent(eventName: eventName, data: data),
|
||||||
|
filter: filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fired on new connections.
|
||||||
|
dynamic onConnect(WebSocketContext socket) {}
|
||||||
|
|
||||||
|
/// Fired on disconnections.
|
||||||
|
dynamic onDisconnect(WebSocketContext socket) {}
|
||||||
|
|
||||||
|
/// Fired on all incoming actions.
|
||||||
|
dynamic onAction(WebSocketAction action, WebSocketContext socket) async {}
|
||||||
|
|
||||||
|
/// Fired on arbitrary incoming data.
|
||||||
|
dynamic onData(data, WebSocketContext socket) {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future configureServer(Application app) async {
|
||||||
|
if (findExpose(app.container.reflector) != null) {
|
||||||
|
await super.configureServer(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
var instanceMirror = reflect(this);
|
||||||
|
var classMirror = reflectClass(runtimeType);
|
||||||
|
classMirror.instanceMembers.forEach((sym, mirror) {
|
||||||
|
if (mirror.isRegularMethod) {
|
||||||
|
var exposeMirror = mirror.metadata
|
||||||
|
.firstWhereOrNull((mirror) => mirror.reflectee is ExposeWs);
|
||||||
|
|
||||||
|
if (exposeMirror != null) {
|
||||||
|
var exposeWs = exposeMirror.reflectee as ExposeWs;
|
||||||
|
_handlers[exposeWs.eventName] = mirror;
|
||||||
|
_handlerSymbols[exposeWs.eventName] = sym;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.onConnection.listen((socket) async {
|
||||||
|
if (!socket.request.container!.has<WebSocketContext>()) {
|
||||||
|
socket.request.container!.registerSingleton<WebSocketContext>(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
await onConnect(socket);
|
||||||
|
|
||||||
|
socket.onData.listen((data) => onData(data, socket));
|
||||||
|
|
||||||
|
socket.onAction.listen((WebSocketAction action) async {
|
||||||
|
var container = socket.request.container!.createChild();
|
||||||
|
container.registerSingleton<WebSocketAction>(action);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onAction(action, socket);
|
||||||
|
|
||||||
|
if (_handlers.containsKey(action.eventName)) {
|
||||||
|
var methodMirror = _handlers[action.eventName!]!;
|
||||||
|
var fn = instanceMirror.getField(methodMirror.simpleName).reflectee;
|
||||||
|
return app.runContained(
|
||||||
|
fn as Function, socket.request, socket.response, container);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
ws.catchError(e, st, socket);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.onDisconnection.listen(onDisconnect);
|
||||||
|
}
|
||||||
|
}
|
16
packages/websocket/melos_angel3_websocket.iml
Normal file
16
packages/websocket/melos_angel3_websocket.iml
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$">
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/.pub" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||||
|
</content>
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="library" name="Dart SDK" level="project" />
|
||||||
|
<orderEntry type="library" name="Dart Packages" level="project" />
|
||||||
|
</component>
|
||||||
|
</module>
|
43
packages/websocket/pubspec.yaml
Normal file
43
packages/websocket/pubspec.yaml
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
name: platform_websocket
|
||||||
|
version: 8.2.0
|
||||||
|
description: This library provides WebSockets support for Angel3 framework.
|
||||||
|
homepage: https://angel3-framework.web.app/
|
||||||
|
repository: https://github.com/dart-backend/angel/tree/master/packages/websocket
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
|
dependencies:
|
||||||
|
platform_auth: ^8.0.0
|
||||||
|
platform_client: ^8.0.0
|
||||||
|
platform_foundation: ^8.0.0
|
||||||
|
platform_support: ^8.0.0
|
||||||
|
platform_merge_map: ^5.1.0
|
||||||
|
http: ^1.0.0
|
||||||
|
meta: ^1.8.0
|
||||||
|
stream_channel: ^2.1.0
|
||||||
|
web_socket_channel: ^3.0.0
|
||||||
|
collection: ^1.17.0
|
||||||
|
logging: ^1.1.0
|
||||||
|
dev_dependencies:
|
||||||
|
platform_container: ^8.0.0
|
||||||
|
platform_model: ^8.0.0
|
||||||
|
quiver: ^3.2.0
|
||||||
|
test: ^1.25.0
|
||||||
|
lints: ^4.0.0
|
||||||
|
file: ^7.0.0
|
||||||
|
# dependency_overrides:
|
||||||
|
# angel3_container:
|
||||||
|
# path: ../container/angel_container
|
||||||
|
# angel3_framework:
|
||||||
|
# path: ../framework
|
||||||
|
# angel3_http_exception:
|
||||||
|
# path: ../http_exception
|
||||||
|
# angel3_model:
|
||||||
|
# path: ../model
|
||||||
|
# angel3_route:
|
||||||
|
# path: ../route
|
||||||
|
# angel3_mock_request:
|
||||||
|
# path: ../mock_request
|
||||||
|
# angel3_auth:
|
||||||
|
# path: ../auth
|
||||||
|
# angel3_client:
|
||||||
|
# path: ../client
|
66
packages/websocket/test/auth_test.dart
Normal file
66
packages/websocket/test/auth_test.dart
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
import 'package:platform_auth/auth.dart';
|
||||||
|
import 'package:platform_client/io.dart' as c;
|
||||||
|
import 'package:platform_foundation/core.dart';
|
||||||
|
import 'package:platform_foundation/http.dart';
|
||||||
|
import 'package:platform_websocket/io.dart' as c;
|
||||||
|
import 'package:platform_websocket/server.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
const Map<String, String> user = {'username': 'foo', 'password': 'bar'};
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Application app;
|
||||||
|
late PlatformHttp http;
|
||||||
|
late c.Angel client;
|
||||||
|
late c.WebSockets ws;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
app = Application();
|
||||||
|
http = PlatformHttp(app, useZone: false);
|
||||||
|
var auth = PlatformAuth(
|
||||||
|
serializer: (_) async => 'baz', deserializer: (_) async => user);
|
||||||
|
|
||||||
|
auth.strategies['local'] = LocalAuthStrategy(
|
||||||
|
(username, password) async {
|
||||||
|
if (username == 'foo' && password == 'bar') {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post('/auth/local', auth.authenticate('local'));
|
||||||
|
|
||||||
|
await app.configure(auth.configureServer);
|
||||||
|
var sock = AngelWebSocket(app);
|
||||||
|
|
||||||
|
await app.configure(sock.configureServer);
|
||||||
|
|
||||||
|
app.all('/ws', sock.handleRequest);
|
||||||
|
app.logger = Logger('angel_auth')..onRecord.listen(print);
|
||||||
|
|
||||||
|
var server = await http.startServer();
|
||||||
|
|
||||||
|
client = c.Rest('http://${server.address.address}:${server.port}');
|
||||||
|
|
||||||
|
ws = c.WebSockets('ws://${server.address.address}:${server.port}/ws');
|
||||||
|
await ws.connect();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
http.close();
|
||||||
|
client.close();
|
||||||
|
ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auth event fires', () async {
|
||||||
|
var localAuth = await client.authenticate(type: 'local', credentials: user);
|
||||||
|
print('JWT: ${localAuth.token}');
|
||||||
|
|
||||||
|
ws.authenticateViaJwt(localAuth.token);
|
||||||
|
var auth = await ws.onAuthenticated.first;
|
||||||
|
expect(auth.token, localAuth.token);
|
||||||
|
});
|
||||||
|
}
|
39
packages/websocket/test/controller/common.dart
Normal file
39
packages/websocket/test/controller/common.dart
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import 'package:platform_foundation/core.dart';
|
||||||
|
import 'package:platform_websocket/server.dart';
|
||||||
|
import 'package:quiver/core.dart';
|
||||||
|
|
||||||
|
class Game {
|
||||||
|
final String? playerOne, playerTwo;
|
||||||
|
|
||||||
|
const Game({this.playerOne, this.playerTwo});
|
||||||
|
|
||||||
|
factory Game.fromJson(Map data) => Game(
|
||||||
|
playerOne: data['playerOne'].toString(),
|
||||||
|
playerTwo: data['playerTwo'].toString());
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {'playerOne': playerOne, 'playerTwo': playerTwo};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(other) =>
|
||||||
|
other is Game &&
|
||||||
|
other.playerOne == playerOne &&
|
||||||
|
other.playerTwo == playerTwo;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => hash2(playerOne, playerTwo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Game johnVsBob = Game(playerOne: 'John', playerTwo: 'Bob');
|
||||||
|
|
||||||
|
@Expose('/game')
|
||||||
|
class GameController extends WebSocketController {
|
||||||
|
GameController(super.ws);
|
||||||
|
|
||||||
|
@ExposeWs('search')
|
||||||
|
dynamic search(WebSocketContext socket) async {
|
||||||
|
print('User is searching for a game...');
|
||||||
|
socket.send('searched', johnVsBob);
|
||||||
|
}
|
||||||
|
}
|
69
packages/websocket/test/controller/io_test.dart
Normal file
69
packages/websocket/test/controller/io_test.dart
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:platform_container/mirrors.dart';
|
||||||
|
import 'package:platform_foundation/core.dart' as srv;
|
||||||
|
import 'package:platform_foundation/http.dart' as srv;
|
||||||
|
import 'package:platform_websocket/io.dart' as ws;
|
||||||
|
import 'package:platform_websocket/server.dart' as srv;
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'common.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
srv.Application app;
|
||||||
|
late srv.PlatformHttp http;
|
||||||
|
late ws.WebSockets client;
|
||||||
|
srv.AngelWebSocket websockets;
|
||||||
|
HttpServer? server;
|
||||||
|
String? url;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
app = srv.Application(reflector: const MirrorsReflector());
|
||||||
|
http = srv.PlatformHttp(app, useZone: false);
|
||||||
|
|
||||||
|
websockets = srv.AngelWebSocket(app)
|
||||||
|
..onData.listen((data) {
|
||||||
|
print('Received by server: $data');
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.configure(websockets.configureServer);
|
||||||
|
app.all('/ws', websockets.handleRequest);
|
||||||
|
await app.configure(GameController(websockets).configureServer);
|
||||||
|
app.logger = Logger('angel_auth')..onRecord.listen(print);
|
||||||
|
|
||||||
|
server = await http.startServer();
|
||||||
|
url = 'ws://${server!.address.address}:${server!.port}/ws';
|
||||||
|
|
||||||
|
client = ws.WebSockets(url);
|
||||||
|
await client.connect(timeout: Duration(seconds: 3));
|
||||||
|
|
||||||
|
print('Connected');
|
||||||
|
|
||||||
|
client
|
||||||
|
..onData.listen((data) {
|
||||||
|
print('Received by client: $data');
|
||||||
|
})
|
||||||
|
..onError.listen((error) {
|
||||||
|
// Auto-fail tests on errors ;)
|
||||||
|
stderr.writeln(error);
|
||||||
|
error.errors.forEach(stderr.writeln);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await client.close();
|
||||||
|
await http.close();
|
||||||
|
//app = null;
|
||||||
|
server = null;
|
||||||
|
url = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
group('controller.io', () {
|
||||||
|
test('search', () async {
|
||||||
|
client.sendAction(ws.WebSocketAction(eventName: 'search'));
|
||||||
|
var search = await client.on['searched'].first;
|
||||||
|
print('Searched: ${search.data}');
|
||||||
|
expect(Game.fromJson(search.data as Map), equals(johnVsBob));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
5
packages/websocket/test/service/browser_test.dart
Normal file
5
packages/websocket/test/service/browser_test.dart
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('service.browser', () {});
|
||||||
|
}
|
34
packages/websocket/test/service/common.dart
Normal file
34
packages/websocket/test/service/common.dart
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:platform_foundation/core.dart';
|
||||||
|
import 'package:platform_websocket/base_websocket_client.dart';
|
||||||
|
import 'package:platform_websocket/server.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
class Todo extends Model {
|
||||||
|
String? text;
|
||||||
|
String? when;
|
||||||
|
|
||||||
|
Todo({this.text, this.when});
|
||||||
|
}
|
||||||
|
|
||||||
|
class TodoService extends MapService {
|
||||||
|
TodoService() : super() {
|
||||||
|
configuration['ws:filter'] =
|
||||||
|
(HookedServiceEvent e, WebSocketContext socket) {
|
||||||
|
print('Hello, service filter world!');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic testIndex(BaseWebSocketClient client) async {
|
||||||
|
var todoService = client.service('api/todos');
|
||||||
|
scheduleMicrotask(() => todoService.index());
|
||||||
|
|
||||||
|
var indexed = await todoService.onIndexed.first;
|
||||||
|
print('indexed: $indexed');
|
||||||
|
|
||||||
|
expect(indexed, isList);
|
||||||
|
expect(indexed, isEmpty);
|
||||||
|
}
|
64
packages/websocket/test/service/io_test.dart
Normal file
64
packages/websocket/test/service/io_test.dart
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:platform_container/mirrors.dart';
|
||||||
|
import 'package:platform_foundation/core.dart' as srv;
|
||||||
|
import 'package:platform_foundation/http.dart' as srv;
|
||||||
|
import 'package:platform_websocket/io.dart' as ws;
|
||||||
|
import 'package:platform_websocket/server.dart' as srv;
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
import 'common.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
srv.Application app;
|
||||||
|
late srv.PlatformHttp http;
|
||||||
|
ws.WebSockets? client;
|
||||||
|
srv.AngelWebSocket websockets;
|
||||||
|
HttpServer? server;
|
||||||
|
String? url;
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
app = srv.Application(reflector: MirrorsReflector())
|
||||||
|
..use('/api/todos', TodoService());
|
||||||
|
http = srv.PlatformHttp(app, useZone: false);
|
||||||
|
|
||||||
|
websockets = srv.AngelWebSocket(app)
|
||||||
|
..onData.listen((data) {
|
||||||
|
print('Received by server: $data');
|
||||||
|
});
|
||||||
|
|
||||||
|
await app.configure(websockets.configureServer);
|
||||||
|
app.all('/ws', websockets.handleRequest);
|
||||||
|
app.logger = Logger('angel_auth')..onRecord.listen(print);
|
||||||
|
server = await http.startServer();
|
||||||
|
url = 'ws://${server!.address.address}:${server!.port}/ws';
|
||||||
|
|
||||||
|
client = ws.WebSockets(url);
|
||||||
|
await client!.connect();
|
||||||
|
|
||||||
|
client
|
||||||
|
?..onData.listen((data) {
|
||||||
|
print('Received by client: $data');
|
||||||
|
})
|
||||||
|
..onError.listen((error) {
|
||||||
|
// Auto-fail tests on errors ;)
|
||||||
|
stderr.writeln(error);
|
||||||
|
error.errors.forEach(stderr.writeln);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await client!.close();
|
||||||
|
await http.server!.close(force: true);
|
||||||
|
|
||||||
|
//app = null;
|
||||||
|
client = null;
|
||||||
|
server = null;
|
||||||
|
url = null;
|
||||||
|
//exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('service.io', () {
|
||||||
|
test('index', () => testIndex(client!));
|
||||||
|
});
|
||||||
|
}
|
9
packages/websocket/web/index.html
Normal file
9
packages/websocket/web/index.html
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Client</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script src="main.dart.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
13
packages/websocket/web/main.dart
Normal file
13
packages/websocket/web/main.dart
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import 'dart:html';
|
||||||
|
import 'package:platform_websocket/browser.dart';
|
||||||
|
|
||||||
|
/// Dummy app to ensure client works with DDC.
|
||||||
|
void main() {
|
||||||
|
var app = WebSockets(window.location.origin);
|
||||||
|
window.alert(app.baseUrl.toString());
|
||||||
|
|
||||||
|
// ignore: body_might_complete_normally_catch_error
|
||||||
|
app.connect().catchError((_) {
|
||||||
|
window.alert('no websocket');
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue