Merge pull request #696 from dart-lang/merge-json_rpc_2-package

Merge `package:json_rpc_2`
diff --git a/.github/ISSUE_TEMPLATE/json_rpc_2.md b/.github/ISSUE_TEMPLATE/json_rpc_2.md
new file mode 100644
index 0000000..29310dd
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/json_rpc_2.md
@@ -0,0 +1,5 @@
+---
+name: "package:json_rpc_2"
+about: "Create a bug or file a feature request against package:json_rpc_2."
+labels: "package:json_rpc_2"
+---
\ No newline at end of file
diff --git a/.github/labeler.yml b/.github/labeler.yml
index a6939c7..bcd3e5a 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -36,6 +36,10 @@
   - changed-files:
       - any-glob-to-any-file: 'pkgs/graphs/**'
 
+'package:json_rpc_2':
+  - changed-files:
+      - any-glob-to-any-file: 'pkgs/json_rpc_2/**'
+
 'package:mime':
   - changed-files:
       - any-glob-to-any-file: 'pkgs/mime/**'
diff --git a/.github/workflows/json_rpc_2.yaml b/.github/workflows/json_rpc_2.yaml
new file mode 100644
index 0000000..cd1ba68
--- /dev/null
+++ b/.github/workflows/json_rpc_2.yaml
@@ -0,0 +1,75 @@
+name: package:json_rpc_2
+
+on:
+  # Run on PRs and pushes to the default branch.
+  push:
+    branches: [ main ]
+    paths:
+      - '.github/workflows/json_rpc_2.yml'
+      - 'pkgs/json_rpc_2/**'
+  pull_request:
+    branches: [ main ]
+    paths:
+      - '.github/workflows/json_rpc_2.yml'
+      - 'pkgs/json_rpc_2/**'
+  schedule:
+    - cron: "0 0 * * 0"
+
+env:
+  PUB_ENVIRONMENT: bot.github
+
+
+defaults:
+  run:
+    working-directory: pkgs/json_rpc_2/
+
+jobs:
+  # Check code formatting and static analysis on a single OS (linux)
+  # against Dart dev.
+  analyze:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        sdk: [dev]
+    steps:
+      - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
+      - uses: dart-lang/setup-dart@0a8a0fc875eb934c15d08629302413c671d3f672
+        with:
+          sdk: ${{ matrix.sdk }}
+      - id: install
+        name: Install dependencies
+        run: dart pub get
+      - name: Check formatting
+        run: dart format --output=none --set-exit-if-changed .
+        if: always() && steps.install.outcome == 'success'
+      - name: Analyze code
+        run: dart analyze --fatal-infos
+        if: always() && steps.install.outcome == 'success'
+
+  # Run tests on a matrix consisting of two dimensions:
+  # 1. OS: ubuntu-latest, (macos-latest, windows-latest)
+  # 2. release channel: dev
+  test:
+    needs: analyze
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        # Add macos-latest and/or windows-latest if relevant for this package.
+        os: [ubuntu-latest]
+        sdk: [3.4, dev]
+    steps:
+      - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938
+      - uses: dart-lang/setup-dart@0a8a0fc875eb934c15d08629302413c671d3f672
+        with:
+          sdk: ${{ matrix.sdk }}
+      - id: install
+        name: Install dependencies
+        run: dart pub get
+      - name: Run VM tests
+        run: dart test --platform vm
+        if: always() && steps.install.outcome == 'success'
+      - name: Run browser tests
+        run: dart test --platform chrome --compiler dart2wasm,dart2js
+        if: always() && steps.install.outcome == 'success'
diff --git a/README.md b/README.md
index f797289..d97682b 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@
 | [file](pkgs/file/) | A pluggable, mockable file system abstraction for Dart. | [![pub package](https://img.shields.io/pub/v/file.svg)](https://pub.dev/packages/file) |
 | [file_testing](pkgs/file_testing/) | Testing utilities for package:file (published but unlisted). | [![pub package](https://img.shields.io/pub/v/file_testing.svg)](https://pub.dev/packages/file_testing) |
 | [graphs](pkgs/graphs/) | Graph algorithms that operate on graphs in any representation | [![pub package](https://img.shields.io/pub/v/graphs.svg)](https://pub.dev/packages/graphs) |
+| [json_rpc_2](pkgs/json_rpc_2/) | Utilities to write a client or server using the JSON-RPC 2.0 spec. | [![pub package](https://img.shields.io/pub/v/json_rpc_2.svg)](https://pub.dev/packages/json_rpc_2) |
 | [mime](pkgs/mime/) | Utilities for handling media (MIME) types. | [![pub package](https://img.shields.io/pub/v/mime.svg)](https://pub.dev/packages/mime) |
 | [oauth2](pkgs/oauth2/) | A client library for authenticatingand making requests via OAuth2. | [![pub package](https://img.shields.io/pub/v/oauth2.svg)](https://pub.dev/packages/oauth2) |
 | [source_map_stack_trace](pkgs/source_map_stack_trace/) | A package for applying source maps to stack traces. | [![pub package](https://img.shields.io/pub/v/source_map_stack_trace.svg)](https://pub.dev/packages/source_map_stack_trace) |
diff --git a/pkgs/json_rpc_2/.gitignore b/pkgs/json_rpc_2/.gitignore
new file mode 100644
index 0000000..ab3cb76
--- /dev/null
+++ b/pkgs/json_rpc_2/.gitignore
@@ -0,0 +1,16 @@
+# Don’t commit the following directories created by pub.
+.buildlog
+.dart_tool/
+.pub/
+build/
+packages
+.packages
+
+# Or the files created by dart2js.
+*.dart.js
+*.js_
+*.js.deps
+*.js.map
+
+# Include when developing application packages.
+pubspec.lock
diff --git a/pkgs/json_rpc_2/.test_config b/pkgs/json_rpc_2/.test_config
new file mode 100644
index 0000000..412fc5c
--- /dev/null
+++ b/pkgs/json_rpc_2/.test_config
@@ -0,0 +1,3 @@
+{
+  "test_package": true
+}
\ No newline at end of file
diff --git a/pkgs/json_rpc_2/CHANGELOG.md b/pkgs/json_rpc_2/CHANGELOG.md
new file mode 100644
index 0000000..1f2cf8e
--- /dev/null
+++ b/pkgs/json_rpc_2/CHANGELOG.md
@@ -0,0 +1,151 @@
+## 3.0.3
+
+* Require Dart 3.4
+* Move to `dart-lang/tools` monorepo.
+
+## 3.0.2
+
+* Switch to using `package:lints`.
+* Address a few analysis hint violations.
+* Populate the pubspec `repository` field.
+
+## 3.0.1
+
+* Fix a bug where a `null` result to a request caused an exception.
+
+## 3.0.0
+
+* Migrate to null safety.
+* Accept responses even if the server converts the ID to a String.
+
+## 2.2.2
+
+* Fix `Peer.close()` throwing `Bad state: Future already completed`.
+
+## 2.2.1
+
+* Fix `Peer` requests not terminating when the underlying channel is closed.
+
+## 2.2.0
+
+* Added `strictProtocolChecks` named parameter to `Server` and `Peer`
+  constructors. Setting this parameter to false will result in the server not
+  rejecting requests missing the `jsonrpc` parameter.
+
+## 2.1.1
+
+* Fixed issue where throwing `RpcException.methodNotFound` in an asynchronous
+  fallback handler would not result in the next fallback being executed.
+* Updated minimum SDK to Dart `2.2.0`.
+
+## 2.1.0
+
+* `Server` and related classes can now take an `onUnhandledError` callback to
+  notify callers of unhandled exceptions.
+
+## 2.0.10
+
+* Allow `stream_channel` version 2.x
+
+## 2.0.8
+
+* Updated SDK version to 2.0.0-dev.17.0
+
+## 2.0.7
+
+* When a `Client` is closed before a request completes, the error sent to that
+  request's `Future` now includes the request method to aid in debugging.
+
+## 2.0.6
+
+* Internal changes only.
+
+## 2.0.5
+
+* Internal changes only.
+
+## 2.0.4
+
+* `Client.sendRequest()` now throws a `StateError` if the client is closed while
+  the request is in-flight. This avoids dangling `Future`s that will never be
+  completed.
+
+* Both `Client.sendRequest()` and `Client.sendNotification()` now throw
+  `StateError`s if they're called after the client is closed.
+
+## 2.0.3
+
+* Fix new strong-mode warnings.
+
+## 2.0.2
+
+* Fix all strong-mode warnings.
+
+## 2.0.1
+
+* Fix a race condition in which a `StateError` could be top-leveled if
+  `Peer.close()` was called before the underlying channel closed.
+
+## 2.0.0
+
+* **Breaking change:** all constructors now take a `StreamChannel` rather than a
+  `Stream`/`StreamSink` pair.
+
+* `Client.sendRequest()` and `Client.sendNotification()` no longer throw
+  `StateError`s after the connection has been closed but before `Client.close()`
+  has been called.
+
+* The various `close()` methods may now be called before their corresponding
+  `listen()` methods.
+
+* The various `close()` methods now wait on the result of closing the underlying
+  `StreamSink`. Be aware that [in some circumstances][issue 19095]
+  `StreamController`s' `Sink.close()` futures may never complete.
+
+[issue 19095]: https://github.com/dart-lang/sdk/issues/19095
+
+## 1.2.0
+
+* Add `Client.isClosed` and `Server.isClosed`, which make it possible to
+  synchronously determine whether the connection is open. In particular, this
+  makes it possible to reliably tell whether it's safe to call
+  `Client.sendRequest`.
+
+* Fix a race condition in `Server` where a `StateError` could be thrown if the
+  connection was closed in the middle of handling a request.
+
+* Improve stack traces for error responses.
+
+## 1.1.1
+
+* Update the README to match the current API.
+
+## 1.1.0
+
+* Add a `done` getter to `Client`, `Server`, and `Peer`.
+
+## 1.0.0
+
+* Add a `Client` class for communicating with external JSON-RPC 2.0 servers.
+
+* Add a `Peer` class that's both a `Client` and a `Server`.
+
+## 0.1.0
+
+* Remove `Server.handleRequest()` and `Server.parseRequest()`. Instead, `new
+  Server()` takes a `Stream` and a `StreamSink` and uses those behind-the-scenes
+  for its communication.
+
+* Add `Server.listen()`, which causes the server to begin listening to the
+  underlying request stream.
+
+* Add `Server.close()`, which closes the underlying request stream and response
+  sink.
+
+## 0.0.2+3
+
+* Widen the version constraint for `stack_trace`.
+
+## 0.0.2+2
+
+* Fix error response to include data from `RpcException` when not a map.
diff --git a/pkgs/json_rpc_2/LICENSE b/pkgs/json_rpc_2/LICENSE
new file mode 100644
index 0000000..000cd7b
--- /dev/null
+++ b/pkgs/json_rpc_2/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2014, the Dart project authors. 
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * 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.
+    * Neither the name of Google LLC 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
+OWNER 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.
diff --git a/pkgs/json_rpc_2/README.md b/pkgs/json_rpc_2/README.md
new file mode 100644
index 0000000..a7dda4a
--- /dev/null
+++ b/pkgs/json_rpc_2/README.md
@@ -0,0 +1,150 @@
+[![Build Status](https://github.com/dart-lang/tools/actions/workflows/json_rpc_2.yaml/badge.svg)](https://github.com/dart-lang/tools/actions/workflows/json_rpc_2.yaml)
+[![pub package](https://img.shields.io/pub/v/json_rpc_2.svg)](https://pub.dev/packages/json_rpc_2)
+[![package publisher](https://img.shields.io/pub/publisher/json_rpc_2.svg)](https://pub.dev/packages/json_rpc_2/publisher)
+
+A library that implements the [JSON-RPC 2.0 spec][spec].
+
+[spec]: https://www.jsonrpc.org/specification
+
+## Server
+
+A JSON-RPC 2.0 server exposes a set of methods that can be called by clients.
+These methods can be registered using `Server.registerMethod`:
+
+```dart
+import 'dart:io';
+
+import 'package:json_rpc_2/json_rpc_2.dart';
+import 'package:web_socket_channel/io.dart';
+import 'package:web_socket_channel/web_socket_channel.dart';
+
+void main() async {
+  var httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 4321);
+  var connectedChannels =
+      httpServer.transform(WebSocketTransformer()).map(IOWebSocketChannel.new);
+  connectedChannels.listen(handleClient);
+}
+
+void handleClient(WebSocketChannel socket) {
+  // The socket is a `StreamChannel<dynamic>` because it might emit binary
+  // `List<int>`, but JSON RPC 2 only works with Strings so we assert it only
+  // emits those by casting it.
+  var server = Server(socket.cast<String>());
+
+  // Any string may be used as a method name. JSON-RPC 2.0 methods are
+  // case-sensitive.
+  var i = 0;
+  server.registerMethod('count', () {
+    // Just return the value to be sent as a response to the client. This can
+    // be anything JSON-serializable, or a Future that completes to something
+    // JSON-serializable.
+    return i++;
+  });
+
+  // Methods can take parameters. They're presented as a `Parameters` object
+  // which makes it easy to validate that the expected parameters exist.
+  server.registerMethod('echo', (Parameters params) {
+    // If the request doesn't have a "message" parameter this will
+    // automatically send a response notifying the client that the request
+    // was invalid.
+    return params['message'].value;
+  });
+
+  // `Parameters` has methods for verifying argument types.
+  server.registerMethod('subtract', (Parameters params) {
+    // If "minuend" or "subtrahend" aren't numbers, this will reject the
+    // request.
+    return params['minuend'].asNum - params['subtrahend'].asNum;
+  });
+
+  // [Parameters] also supports optional arguments.
+  server.registerMethod('sort', (Parameters params) {
+    var list = params['list'].asList;
+    list.sort();
+    if (params['descendint'].asBoolOr(false)) {
+      return list.reversed;
+    } else {
+      return list;
+    }
+  });
+
+  // A method can send an error response by throwing a `RpcException`.
+  // Any positive number may be used as an application- defined error code.
+  const dividByZero = 1;
+  server.registerMethod('divide', (Parameters params) {
+    var divisor = params['divisor'].asNum;
+    if (divisor == 0) {
+      throw RpcException(dividByZero, 'Cannot divide by zero.');
+    }
+
+    return params['dividend'].asNum / divisor;
+  });
+
+  // To give you time to register all your methods, the server won't start
+  // listening for requests until you call `listen`. Messages are buffered until
+  // listen is called. The returned Future won't complete until the connection
+  // is closed.
+  server.listen();
+}
+```
+
+## Client
+
+A JSON-RPC 2.0 client calls methods on a server and handles the server's
+responses to those method calls. These methods can be called using
+`Client.sendRequest`:
+
+```dart
+import 'package:json_rpc_2/json_rpc_2.dart';
+import 'package:pedantic/pedantic.dart';
+import 'package:web_socket_channel/web_socket_channel.dart';
+
+void main() async {
+  var socket = WebSocketChannel.connect(Uri.parse('ws://localhost:4321'));
+  var client = Client(socket.cast<String>());
+
+  // The client won't subscribe to the input stream until you call `listen`.
+  // The returned Future won't complete until the connection is closed.
+  unawaited(client.listen());
+
+  // This calls the "count" method on the server. A Future is returned that
+  // will complete to the value contained in the server's response.
+  var count = await client.sendRequest('count');
+  print('Count is $count');
+
+  // Parameters are passed as a simple Map or, for positional parameters, an
+  // Iterable. Make sure they're JSON-serializable!
+  var echo = await client.sendRequest('echo', {'message': 'hello'});
+  print('Echo says "$echo"!');
+
+  // A notification is a way to call a method that tells the server that no
+  // result is expected. Its return type is `void`; even if it causes an
+  // error, you won't hear back.
+  client.sendNotification('count');
+
+  // If the server sends an error response, the returned Future will complete
+  // with an RpcException. You can catch this error and inspect its error
+  // code, message, and any data that the server sent along with it.
+  try {
+    await client.sendRequest('divide', {'dividend': 2, 'divisor': 0});
+  } on RpcException catch (error) {
+    print('RPC error ${error.code}: ${error.message}');
+  }
+
+  await client.close();
+}
+```
+
+## Peer
+
+Although JSON-RPC 2.0 only explicitly describes clients and servers, it also
+mentions that two-way communication can be supported by making each endpoint
+both a client and a server. This package supports this directly using the `Peer`
+class, which implements both `Client` and `Server`. It supports the same methods
+as those classes, and automatically makes sure that every message from the other
+endpoint is routed and handled correctly.
+
+## Publishing automation
+
+For information about our publishing automation and release process, see
+https://github.com/dart-lang/ecosystem/wiki/Publishing-automation.
diff --git a/pkgs/json_rpc_2/analysis_options.yaml b/pkgs/json_rpc_2/analysis_options.yaml
new file mode 100644
index 0000000..a91d5ca
--- /dev/null
+++ b/pkgs/json_rpc_2/analysis_options.yaml
@@ -0,0 +1,13 @@
+include: package:dart_flutter_team_lints/analysis_options.yaml
+
+analyzer:
+  language:
+    strict-casts: false
+  errors:
+    avoid_dynamic_calls: ignore
+
+linter:
+  rules:
+    - avoid_unused_constructor_parameters
+    - cancel_subscriptions
+    - package_api_docs
diff --git a/pkgs/json_rpc_2/example/client.dart b/pkgs/json_rpc_2/example/client.dart
new file mode 100644
index 0000000..aa8f7ed
--- /dev/null
+++ b/pkgs/json_rpc_2/example/client.dart
@@ -0,0 +1,43 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:json_rpc_2/json_rpc_2.dart';
+import 'package:web_socket_channel/web_socket_channel.dart';
+
+void main() async {
+  var socket = WebSocketChannel.connect(Uri.parse('ws://localhost:4321'));
+  var client = Client(socket.cast<String>());
+
+  // The client won't subscribe to the input stream until you call `listen`.
+  // The returned Future won't complete until the connection is closed.
+  unawaited(client.listen());
+
+  // This calls the "count" method on the server. A Future is returned that
+  // will complete to the value contained in the server's response.
+  var count = await client.sendRequest('count');
+  print('Count is $count');
+
+  // Parameters are passed as a simple Map or, for positional parameters, an
+  // Iterable. Make sure they're JSON-serializable!
+  var echo = await client.sendRequest('echo', {'message': 'hello'});
+  print('Echo says "$echo"!');
+
+  // A notification is a way to call a method that tells the server that no
+  // result is expected. Its return type is `void`; even if it causes an
+  // error, you won't hear back.
+  client.sendNotification('count');
+
+  // If the server sends an error response, the returned Future will complete
+  // with an RpcException. You can catch this error and inspect its error
+  // code, message, and any data that the server sent along with it.
+  try {
+    await client.sendRequest('divide', {'dividend': 2, 'divisor': 0});
+  } on RpcException catch (error) {
+    print('RPC error ${error.code}: ${error.message}');
+  }
+
+  await client.close();
+}
diff --git a/pkgs/json_rpc_2/example/main.dart b/pkgs/json_rpc_2/example/main.dart
new file mode 100644
index 0000000..7d5ab73
--- /dev/null
+++ b/pkgs/json_rpc_2/example/main.dart
@@ -0,0 +1,78 @@
+// Copyright (c) 2020, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:io';
+
+import 'package:json_rpc_2/json_rpc_2.dart';
+import 'package:web_socket_channel/io.dart';
+import 'package:web_socket_channel/web_socket_channel.dart';
+
+void main() async {
+  var httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, 4321);
+  var connectedChannels =
+      httpServer.transform(WebSocketTransformer()).map(IOWebSocketChannel.new);
+  connectedChannels.listen(handleClient);
+}
+
+void handleClient(WebSocketChannel socket) {
+  // The socket is a `StreamChannel<dynamic>` because it might emit binary
+  // `List<int>`, but JSON RPC 2 only works with Strings so we assert it only
+  // emits those by casting it.
+  var server = Server(socket.cast<String>());
+
+  // Any string may be used as a method name. JSON-RPC 2.0 methods are
+  // case-sensitive.
+  var i = 0;
+  server.registerMethod('count', () {
+    // Just return the value to be sent as a response to the client. This can
+    // be anything JSON-serializable, or a Future that completes to something
+    // JSON-serializable.
+    return i++;
+  });
+
+  // Methods can take parameters. They're presented as a `Parameters` object
+  // which makes it easy to validate that the expected parameters exist.
+  server.registerMethod('echo', (Parameters params) {
+    // If the request doesn't have a "message" parameter this will
+    // automatically send a response notifying the client that the request
+    // was invalid.
+    return params['message'].value;
+  });
+
+  // `Parameters` has methods for verifying argument types.
+  server.registerMethod('subtract', (Parameters params) {
+    // If "minuend" or "subtrahend" aren't numbers, this will reject the
+    // request.
+    return params['minuend'].asNum - params['subtrahend'].asNum;
+  });
+
+  // [Parameters] also supports optional arguments.
+  server.registerMethod('sort', (Parameters params) {
+    var list = params['list'].asList;
+    list.sort();
+    if (params['descendint'].asBoolOr(false)) {
+      return list.reversed;
+    } else {
+      return list;
+    }
+  });
+
+  // A method can send an error response by throwing a `RpcException`.
+  // Any positive number may be used as an application- defined error code.
+  const dividByZero = 1;
+  server.registerMethod('divide', (Parameters params) {
+    var divisor = params['divisor'].asNum;
+    if (divisor == 0) {
+      throw RpcException(dividByZero, 'Cannot divide by zero.');
+    }
+
+    return params['dividend'].asNum / divisor;
+  });
+
+  // To give you time to register all your methods, the server won't start
+  // listening for requests until you call `listen`. Messages are buffered until
+  // listen is called. The returned Future won't complete until the connection
+  // is closed.
+  server.listen();
+}
diff --git a/pkgs/json_rpc_2/lib/error_code.dart b/pkgs/json_rpc_2/lib/error_code.dart
new file mode 100644
index 0000000..5f90791
--- /dev/null
+++ b/pkgs/json_rpc_2/lib/error_code.dart
@@ -0,0 +1,51 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// ignore_for_file: constant_identifier_names
+
+import 'src/exception.dart';
+
+/// Error codes defined in the [JSON-RPC 2.0 specification][spec].
+///
+/// These codes are generally used for protocol-level communication. Most of
+/// them shouldn't be used by the application. Those that should have
+/// convenience constructors in [RpcException].
+///
+/// [spec]: http://www.jsonrpc.org/specification#error_object
+/// An error code indicating that invalid JSON was received by the server.
+const PARSE_ERROR = -32700;
+
+/// An error code indicating that the request JSON was invalid according to the
+/// JSON-RPC 2.0 spec.
+const INVALID_REQUEST = -32600;
+
+/// An error code indicating that the requested method does not exist or is
+/// unavailable.
+const METHOD_NOT_FOUND = -32601;
+
+/// An error code indicating that the request parameters are invalid for the
+/// requested method.
+const INVALID_PARAMS = -32602;
+
+/// An internal JSON-RPC error.
+const INTERNAL_ERROR = -32603;
+
+/// An unexpected error occurred on the server.
+///
+/// The spec reserves the range from -32000 to -32099 for implementation-defined
+/// server exceptions, but for now we only use one of those values.
+const SERVER_ERROR = -32000;
+
+/// Returns a human-readable name for [errorCode] if it's one specified by the
+/// JSON-RPC 2.0 spec.
+///
+/// If [errorCode] isn't defined in the JSON-RPC 2.0 spec, returns `null`.
+String? name(int errorCode) => switch (errorCode) {
+      PARSE_ERROR => 'parse error',
+      INVALID_REQUEST => 'invalid request',
+      METHOD_NOT_FOUND => 'method not found',
+      INVALID_PARAMS => 'invalid parameters',
+      INTERNAL_ERROR => 'internal error',
+      _ => null
+    };
diff --git a/pkgs/json_rpc_2/lib/json_rpc_2.dart b/pkgs/json_rpc_2/lib/json_rpc_2.dart
new file mode 100644
index 0000000..33e5f49
--- /dev/null
+++ b/pkgs/json_rpc_2/lib/json_rpc_2.dart
@@ -0,0 +1,9 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+export 'src/client.dart';
+export 'src/exception.dart';
+export 'src/parameters.dart';
+export 'src/peer.dart';
+export 'src/server.dart';
diff --git a/pkgs/json_rpc_2/lib/src/client.dart b/pkgs/json_rpc_2/lib/src/client.dart
new file mode 100644
index 0000000..182f945
--- /dev/null
+++ b/pkgs/json_rpc_2/lib/src/client.dart
@@ -0,0 +1,246 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:stack_trace/stack_trace.dart';
+import 'package:stream_channel/stream_channel.dart';
+
+import 'exception.dart';
+import 'utils.dart';
+
+/// A JSON-RPC 2.0 client.
+///
+/// A client calls methods on a server and handles the server's responses to
+/// those method calls. Methods can be called with [sendRequest], or with
+/// [sendNotification] if no response is expected.
+class Client {
+  final StreamChannel<dynamic> _channel;
+
+  /// The next request id.
+  var _id = 0;
+
+  /// The current batch of requests to be sent together.
+  ///
+  /// Each element is a JSON RPC spec compliant message.
+  List<Map<String, dynamic>>? _batch;
+
+  /// The map of request ids to pending requests.
+  final _pendingRequests = <int, _Request>{};
+
+  final _done = Completer<void>();
+
+  /// Returns a [Future] that completes when the underlying connection is
+  /// closed.
+  ///
+  /// This is the same future that's returned by [listen] and [close]. It may
+  /// complete before [close] is called if the remote endpoint closes the
+  /// connection.
+  Future get done => _done.future;
+
+  /// Whether the underlying connection is closed.
+  ///
+  /// Note that this will be `true` before [close] is called if the remote
+  /// endpoint closes the connection.
+  bool get isClosed => _done.isCompleted;
+
+  /// Creates a [Client] that communicates over [channel].
+  ///
+  /// Note that the client won't begin listening to [channel] until
+  /// [Client.listen] is called.
+  Client(StreamChannel<String> channel)
+      : this.withoutJson(
+            jsonDocument.bind(channel).transformStream(ignoreFormatExceptions));
+
+  /// Creates a [Client] that communicates using decoded messages over
+  /// [_channel].
+  ///
+  /// Unlike [Client.new], this doesn't read or write JSON strings. Instead, it
+  /// reads and writes decoded maps or lists.
+  ///
+  /// Note that the client won't begin listening to [_channel] until
+  /// [Client.listen] is called.
+  Client.withoutJson(this._channel) {
+    done.whenComplete(() {
+      for (var request in _pendingRequests.values) {
+        request.completer.completeError(StateError(
+            'The client closed with pending request "${request.method}".'));
+      }
+      _pendingRequests.clear();
+    }).catchError((_) {
+      // Avoid an unhandled error.
+    });
+  }
+
+  /// Starts listening to the underlying stream.
+  ///
+  /// Returns a [Future] that will complete when the connection is closed or
+  /// when it has an error. This is the same as [done].
+  ///
+  /// [listen] may only be called once.
+  Future listen() {
+    _channel.stream.listen(_handleResponse,
+        onError: (Object error, StackTrace stackTrace) {
+      _done.completeError(error, stackTrace);
+      _channel.sink.close();
+    }, onDone: () {
+      if (!_done.isCompleted) _done.complete();
+      close();
+    });
+    return done;
+  }
+
+  /// Closes the underlying connection.
+  ///
+  /// Returns a [Future] that completes when all resources have been released.
+  /// This is the same as [done].
+  Future close() {
+    _channel.sink.close();
+    if (!_done.isCompleted) _done.complete();
+    return done;
+  }
+
+  /// Sends a JSON-RPC 2 request to invoke the given [method].
+  ///
+  /// If passed, [parameters] is the parameters for the method. This must be
+  /// either an [Iterable] (to pass parameters by position) or a [Map] with
+  /// [String] keys (to pass parameters by name). Either way, it must be
+  /// JSON-serializable.
+  ///
+  /// If the request succeeds, this returns the response result as a decoded
+  /// JSON-serializable object. If it fails, it throws an [RpcException]
+  /// describing the failure.
+  ///
+  /// Throws a [StateError] if the client is closed while the request is in
+  /// flight, or if the client is closed when this method is called.
+  Future<Object?> sendRequest(String method, [Object? parameters]) {
+    var id = _id++;
+    _send(method, parameters, id);
+
+    var completer = Completer<Object?>.sync();
+    _pendingRequests[id] = _Request(method, completer, Chain.current());
+    return completer.future;
+  }
+
+  /// Sends a JSON-RPC 2 request to invoke the given [method] without expecting
+  /// a response.
+  ///
+  /// If passed, [parameters] is the parameters for the method. This must be
+  /// either an [Iterable] (to pass parameters by position) or a [Map] with
+  /// [String] keys (to pass parameters by name). Either way, it must be
+  /// JSON-serializable.
+  ///
+  /// Since this is just a notification to which the server isn't expected to
+  /// send a response, it has no return value.
+  ///
+  /// Throws a [StateError] if the client is closed when this method is called.
+  void sendNotification(String method, [Object? parameters]) =>
+      _send(method, parameters);
+
+  /// A helper method for [sendRequest] and [sendNotification].
+  ///
+  /// Sends a request to invoke [method] with [parameters]. If [id] is given,
+  /// the request uses that id.
+  void _send(String method, Object? parameters, [int? id]) {
+    if (parameters is Iterable) parameters = parameters.toList();
+    if (parameters is! Map && parameters is! List && parameters != null) {
+      throw ArgumentError('Only maps and lists may be used as JSON-RPC '
+          'parameters, was "$parameters".');
+    }
+    if (isClosed) throw StateError('The client is closed.');
+
+    var message = <String, dynamic>{'jsonrpc': '2.0', 'method': method};
+    if (id != null) message['id'] = id;
+    if (parameters != null) message['params'] = parameters;
+
+    if (_batch != null) {
+      _batch!.add(message);
+    } else {
+      _channel.sink.add(message);
+    }
+  }
+
+  /// Runs [callback] and batches any requests sent until it returns.
+  ///
+  /// A batch of requests is sent in a single message on the underlying stream,
+  /// and the responses are likewise sent back in a single message.
+  ///
+  /// [callback] may be synchronous or asynchronous. If it returns a [Future],
+  /// requests will be batched until that Future returns; otherwise, requests
+  /// will only be batched while synchronously executing [callback].
+  ///
+  /// If this is called in the context of another [withBatch] call, it just
+  /// invokes [callback] without creating another batch. This means that
+  /// responses are batched until the first batch ends.
+  void withBatch(FutureOr<void> Function() callback) {
+    if (_batch != null) {
+      callback();
+      return;
+    }
+
+    _batch = [];
+    return tryFinally(callback, () {
+      _channel.sink.add(_batch);
+      _batch = null;
+    });
+  }
+
+  /// Handles a decoded response from the server.
+  void _handleResponse(Object? response) {
+    if (response is List) {
+      response.forEach(_handleSingleResponse);
+    } else {
+      _handleSingleResponse(response);
+    }
+  }
+
+  /// Handles a decoded response from the server after batches have been
+  /// resolved.
+  void _handleSingleResponse(Object? response_) {
+    if (!_isResponseValid(response_)) return;
+    final response = response_ as Map;
+    var id = response['id'];
+    id = (id is String) ? int.parse(id) : id;
+    var request = _pendingRequests.remove(id)!;
+    if (response.containsKey('result')) {
+      request.completer.complete(response['result']);
+    } else {
+      request.completer.completeError(
+          RpcException(response['error']['code'], response['error']['message'],
+              data: response['error']['data']),
+          request.chain);
+    }
+  }
+
+  /// Determines whether the server's response is valid per the spec.
+  bool _isResponseValid(Object? response) {
+    if (response is! Map) return false;
+    if (response['jsonrpc'] != '2.0') return false;
+    var id = response['id'];
+    id = (id is String) ? int.parse(id) : id;
+    if (!_pendingRequests.containsKey(id)) return false;
+    if (response.containsKey('result')) return true;
+
+    if (!response.containsKey('error')) return false;
+    var error = response['error'];
+    if (error is! Map) return false;
+    if (error['code'] is! int) return false;
+    if (error['message'] is! String) return false;
+    return true;
+  }
+}
+
+/// A pending request to the server.
+class _Request {
+  /// THe method that was sent.
+  final String method;
+
+  /// The completer to use to complete the response future.
+  final Completer completer;
+
+  /// The stack chain from where the request was made.
+  final Chain chain;
+
+  _Request(this.method, this.completer, this.chain);
+}
diff --git a/pkgs/json_rpc_2/lib/src/exception.dart b/pkgs/json_rpc_2/lib/src/exception.dart
new file mode 100644
index 0000000..906a053
--- /dev/null
+++ b/pkgs/json_rpc_2/lib/src/exception.dart
@@ -0,0 +1,75 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import '../error_code.dart' as error_code;
+
+/// An exception from a JSON-RPC server that can be translated into an error
+/// response.
+class RpcException implements Exception {
+  /// The error code.
+  ///
+  /// All non-negative error codes are available for use by application
+  /// developers.
+  final int code;
+
+  /// The error message.
+  ///
+  /// This should be limited to a concise single sentence. Further information
+  /// should be supplied via [data].
+  final String message;
+
+  /// Extra application-defined information about the error.
+  ///
+  /// This must be a JSON-serializable object. If it's a [Map] without a
+  /// `"request"` key, a copy of the request that caused the error will
+  /// automatically be injected.
+  final Object? data;
+
+  RpcException(this.code, this.message, {this.data});
+
+  /// An exception indicating that the method named [methodName] was not found.
+  ///
+  /// This should usually be used only by fallback handlers.
+  RpcException.methodNotFound(String methodName)
+      : this(error_code.METHOD_NOT_FOUND, 'Unknown method "$methodName".');
+
+  /// An exception indicating that the parameters for the requested method were
+  /// invalid.
+  ///
+  /// Methods can use this to reject requests with invalid parameters.
+  RpcException.invalidParams(String message)
+      : this(error_code.INVALID_PARAMS, message);
+
+  /// Converts this exception into a JSON-serializable object that's a valid
+  /// JSON-RPC 2.0 error response.
+  Map<String, dynamic> serialize(Object? request) {
+    dynamic modifiedData;
+    if (data is Map && !(data as Map).containsKey('request')) {
+      modifiedData = {
+        ...data as Map,
+        'request': request,
+      };
+    } else if (data == null) {
+      modifiedData = {'request': request};
+    } else {
+      modifiedData = data;
+    }
+
+    var id = request is Map ? request['id'] : null;
+    if (id is! String && id is! num) id = null;
+    return {
+      'jsonrpc': '2.0',
+      'error': {'code': code, 'message': message, 'data': modifiedData},
+      'id': id
+    };
+  }
+
+  @override
+  String toString() {
+    var prefix = 'JSON-RPC error $code';
+    var errorName = error_code.name(code);
+    if (errorName != null) prefix += ' ($errorName)';
+    return '$prefix: $message';
+  }
+}
diff --git a/pkgs/json_rpc_2/lib/src/parameters.dart b/pkgs/json_rpc_2/lib/src/parameters.dart
new file mode 100644
index 0000000..0a18882
--- /dev/null
+++ b/pkgs/json_rpc_2/lib/src/parameters.dart
@@ -0,0 +1,348 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+
+import 'exception.dart';
+
+/// A wrapper for the parameters to a server method.
+///
+/// JSON-RPC 2.0 allows parameters that are either a list or a map. This class
+/// provides functions that not only assert that the parameters object is the
+/// correct type, but also that the expected arguments exist and are themselves
+/// the correct type.
+///
+/// Example usage:
+///
+///     server.registerMethod("subtract", (params) {
+///       return params["minuend"].asNum - params["subtrahend"].asNum;
+///     });
+class Parameters {
+  /// The name of the method that this request called.
+  final String method;
+
+  /// The underlying value of the parameters object.
+  ///
+  /// If this is accessed for a [Parameter] that was not passed, the request
+  /// will be automatically rejected. To avoid this, use [Parameter.valueOr].
+  final dynamic value;
+
+  Parameters(this.method, this.value);
+
+  /// Returns a single parameter.
+  ///
+  /// If [key] is a [String], the request is expected to provide named
+  /// parameters. If it's an [int], the request is expected to provide
+  /// positional parameters. Requests that don't do so will be rejected
+  /// automatically.
+  ///
+  /// Whether or not the given parameter exists, this returns a [Parameter]
+  /// object. If a parameter's value is accessed through a getter like [value]
+  /// or [Parameter.asNum], the request will be rejected if that parameter
+  /// doesn't exist. On the other hand, if it's accessed through a method with a
+  /// default value like [Parameter.valueOr] or [Parameter.asNumOr], the default
+  /// value will be returned.
+  Parameter operator [](Object? key) {
+    if (key is int) {
+      _assertPositional();
+      if (key < value.length) {
+        return Parameter._(method, value[key], this, key);
+      } else {
+        return _MissingParameter(method, this, key);
+      }
+    } else if (key is String) {
+      _assertNamed();
+      if (value.containsKey(key)) {
+        return Parameter._(method, value[key], this, key);
+      } else {
+        return _MissingParameter(method, this, key);
+      }
+    } else {
+      throw ArgumentError('Parameters[] only takes an int or a string, was '
+          '"$key".');
+    }
+  }
+
+  /// Asserts that [value] exists and is a [List] and returns it.
+  List get asList {
+    _assertPositional();
+    return value;
+  }
+
+  /// Asserts that [value] exists and is a [Map] and returns it.
+  Map get asMap {
+    _assertNamed();
+    return value;
+  }
+
+  /// Asserts that [value] is a positional argument list.
+  void _assertPositional() {
+    if (value is List) return;
+    throw RpcException.invalidParams('Parameters for method "$method" '
+        'must be passed by position.');
+  }
+
+  /// Asserts that [value] is a named argument map.
+  void _assertNamed() {
+    if (value is Map) return;
+    throw RpcException.invalidParams('Parameters for method "$method" '
+        'must be passed by name.');
+  }
+}
+
+/// A wrapper for a single parameter to a server method.
+///
+/// This provides numerous functions for asserting the type of the parameter in
+/// question. These functions each have a version that asserts that the
+/// parameter exists (for example, [asNum] and [asString]) and a version that
+/// returns a default value if the parameter doesn't exist (for example,
+/// [asNumOr] and [asStringOr]). If an assertion fails, the request is
+/// automatically rejected.
+///
+/// This extends [Parameters] to make it easy to access nested parameters. For
+/// example:
+///
+///     // "params.value" is "{'scores': {'home': [5, 10, 17]}}"
+///     params['scores']['home'][2].asInt // => 17
+class Parameter extends Parameters {
+  // The parent parameters, used to construct [_path].
+  final Parameters _parent;
+
+  /// The key used to access `this`, used to construct [_path].
+  final Object _key;
+
+  /// A human-readable representation of the path of getters used to get this.
+  ///
+  /// Named parameters are represented as `.name`, whereas positional parameters
+  /// are represented as `[index]`. For example: `"foo[0].bar.baz"`. Named
+  /// parameters that contain characters that are neither alphanumeric,
+  /// underscores, or hyphens will be JSON-encoded. For example: `"foo
+  /// bar"."baz.bang"`. If quotes are used for an individual component, they
+  /// won't be used for the entire string.
+  ///
+  /// An exception is made for single-level parameters. A single-level
+  /// positional parameter is just represented by the index plus one, because
+  /// "parameter 1" is clearer than "parameter [0]". A single-level named
+  /// parameter is represented by that name in quotes.
+  String get _path {
+    if (_parent is! Parameter) {
+      return _key is int ? (_key + 1).toString() : jsonEncode(_key);
+    }
+
+    String quoteKey(String key) {
+      if (key.contains(RegExp(r'[^a-zA-Z0-9_-]'))) return jsonEncode(key);
+      return key;
+    }
+
+    String computePath(Parameter params) {
+      if (params._parent is! Parameter) {
+        return params._key is int
+            ? '[${params._key}]'
+            : quoteKey(params._key as String);
+      }
+
+      var path = computePath(params._parent);
+      return params._key is int
+          ? '$path[${params._key}]'
+          : '$path.${quoteKey(params._key as String)}';
+    }
+
+    return computePath(this);
+  }
+
+  /// Whether this parameter exists.
+  bool get exists => true;
+
+  Parameter._(super.method, super.value, this._parent, this._key);
+
+  /// Returns [value], or [defaultValue] if this parameter wasn't passed.
+  dynamic valueOr(Object? defaultValue) => value;
+
+  /// Asserts that [value] exists and is a number and returns it.
+  ///
+  /// [asNumOr] may be used to provide a default value instead of rejecting the
+  /// request if [value] doesn't exist.
+  num get asNum => _getTyped('a number', (value) => value is num);
+
+  /// Asserts that [value] is a number and returns it.
+  ///
+  /// If [value] doesn't exist, this returns [defaultValue].
+  num asNumOr(num defaultValue) => asNum;
+
+  /// Asserts that [value] exists and is an integer and returns it.
+  ///
+  /// [asIntOr] may be used to provide a default value instead of rejecting the
+  /// request if [value] doesn't exist.
+  ///
+  /// Note that which values count as integers varies between the Dart VM and
+  /// dart2js. The value `1.0` will be considered an integer under dart2js but
+  /// not under the VM.
+  int get asInt => _getTyped('an integer', (value) => value is int);
+
+  /// Asserts that [value] is an integer and returns it.
+  ///
+  /// If [value] doesn't exist, this returns [defaultValue].
+  ///
+  /// Note that which values count as integers varies between the Dart VM and
+  /// dart2js. The value `1.0` will be considered an integer under dart2js but
+  /// not under the VM.
+  int asIntOr(int defaultValue) => asInt;
+
+  /// Asserts that [value] exists and is a boolean and returns it.
+  ///
+  /// [asBoolOr] may be used to provide a default value instead of rejecting the
+  /// request if [value] doesn't exist.
+  bool get asBool => _getTyped('a boolean', (value) => value is bool);
+
+  /// Asserts that [value] is a boolean and returns it.
+  ///
+  /// If [value] doesn't exist, this returns [defaultValue].
+  bool asBoolOr(bool defaultValue) => asBool;
+
+  /// Asserts that [value] exists and is a string and returns it.
+  ///
+  /// [asStringOr] may be used to provide a default value instead of rejecting
+  /// the request if [value] doesn't exist.
+  String get asString => _getTyped('a string', (value) => value is String);
+
+  /// Asserts that [value] is a string and returns it.
+  ///
+  /// If [value] doesn't exist, this returns [defaultValue].
+  String asStringOr(String defaultValue) => asString;
+
+  /// Asserts that [value] exists and is a [List] and returns it.
+  ///
+  /// [asListOr] may be used to provide a default value instead of rejecting the
+  /// request if [value] doesn't exist.
+  @override
+  List get asList => _getTyped('an Array', (value) => value is List);
+
+  /// Asserts that [value] is a [List] and returns it.
+  ///
+  /// If [value] doesn't exist, this returns [defaultValue].
+  List asListOr(List defaultValue) => asList;
+
+  /// Asserts that [value] exists and is a [Map] and returns it.
+  ///
+  /// [asMapOr] may be used to provide a default value instead of rejecting the
+  /// request if [value] doesn't exist.
+  @override
+  Map get asMap => _getTyped('an Object', (value) => value is Map);
+
+  /// Asserts that [value] is a [Map] and returns it.
+  ///
+  /// If [value] doesn't exist, this returns [defaultValue].
+  Map asMapOr(Map defaultValue) => asMap;
+
+  /// Asserts that [value] exists, is a string, and can be parsed as a
+  /// [DateTime] and returns it.
+  ///
+  /// [asDateTimeOr] may be used to provide a default value instead of rejecting
+  /// the request if [value] doesn't exist.
+  DateTime get asDateTime => _getParsed('date/time', DateTime.parse);
+
+  /// Asserts that [value] exists, is a string, and can be parsed as a
+  /// [DateTime] and returns it.
+  ///
+  /// If [value] doesn't exist, this returns [defaultValue].
+  DateTime asDateTimeOr(DateTime defaultValue) => asDateTime;
+
+  /// Asserts that [value] exists, is a string, and can be parsed as a
+  /// [Uri] and returns it.
+  ///
+  /// [asUriOr] may be used to provide a default value instead of rejecting the
+  /// request if [value] doesn't exist.
+  Uri get asUri => _getParsed('URI', Uri.parse);
+
+  /// Asserts that [value] exists, is a string, and can be parsed as a
+  /// [Uri] and returns it.
+  ///
+  /// If [value] doesn't exist, this returns [defaultValue].
+  Uri asUriOr(Uri defaultValue) => asUri;
+
+  /// Get a parameter named [type] that matches [test].
+  ///
+  /// [type] is used for the error message. It should begin with an indefinite
+  /// article.
+  dynamic _getTyped(String type, bool Function(dynamic) test) {
+    if (test(value)) return value;
+    throw RpcException.invalidParams('Parameter $_path for method '
+        '"$method" must be $type, but was ${jsonEncode(value)}.');
+  }
+
+  dynamic _getParsed(String description, void Function(String) parse) {
+    var string = asString;
+    try {
+      return parse(string);
+    } on FormatException catch (error) {
+      // DateTime.parse doesn't actually include any useful information in the
+      // FormatException, just the string that was being parsed. There's no use
+      // in including that in the RPC exception. See issue 17753.
+      var message = error.message;
+      if (message == string) {
+        message = '';
+      } else {
+        message = '\n$message';
+      }
+
+      throw RpcException.invalidParams('Parameter $_path for method '
+          '"$method" must be a valid $description, but was '
+          '${jsonEncode(string)}.$message');
+    }
+  }
+
+  @override
+  void _assertPositional() {
+    // Throw the standard exception for a mis-typed list.
+    asList;
+  }
+
+  @override
+  void _assertNamed() {
+    // Throw the standard exception for a mis-typed map.
+    asMap;
+  }
+}
+
+/// A subclass of [Parameter] representing a missing parameter.
+class _MissingParameter extends Parameter {
+  @override
+  dynamic get value {
+    throw RpcException.invalidParams('Request for method "$method" is '
+        'missing required parameter $_path.');
+  }
+
+  @override
+  bool get exists => false;
+
+  _MissingParameter(String method, Parameters parent, Object key)
+      : super._(method, null, parent, key);
+
+  @override
+  dynamic valueOr(Object? defaultValue) => defaultValue;
+
+  @override
+  num asNumOr(num defaultValue) => defaultValue;
+
+  @override
+  int asIntOr(int defaultValue) => defaultValue;
+
+  @override
+  bool asBoolOr(bool defaultValue) => defaultValue;
+
+  @override
+  String asStringOr(String defaultValue) => defaultValue;
+
+  @override
+  List asListOr(List defaultValue) => defaultValue;
+
+  @override
+  Map asMapOr(Map defaultValue) => defaultValue;
+
+  @override
+  DateTime asDateTimeOr(DateTime defaultValue) => defaultValue;
+
+  @override
+  Uri asUriOr(Uri defaultValue) => defaultValue;
+}
diff --git a/pkgs/json_rpc_2/lib/src/peer.dart b/pkgs/json_rpc_2/lib/src/peer.dart
new file mode 100644
index 0000000..677b6e1
--- /dev/null
+++ b/pkgs/json_rpc_2/lib/src/peer.dart
@@ -0,0 +1,156 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:stream_channel/stream_channel.dart';
+
+import 'client.dart';
+import 'parameters.dart';
+import 'server.dart';
+import 'utils.dart';
+
+/// A JSON-RPC 2.0 client *and* server.
+///
+/// This supports bidirectional peer-to-peer communication with another JSON-RPC
+/// 2.0 endpoint. It sends both requests and responses across the same
+/// communication channel and expects to connect to a peer that does the same.
+class Peer implements Client, Server {
+  final StreamChannel<dynamic> _channel;
+
+  /// The underlying client that handles request-sending and response-receiving
+  /// logic.
+  late final Client _client;
+
+  /// The underlying server that handles request-receiving and response-sending
+  /// logic.
+  late final Server _server;
+
+  /// A stream controller that forwards incoming messages to [_server] if
+  /// they're requests.
+  final _serverIncomingForwarder = StreamController<Object?>(sync: true);
+
+  /// A stream controller that forwards incoming messages to [_client] if
+  /// they're responses.
+  final _clientIncomingForwarder = StreamController<Object?>(sync: true);
+
+  @override
+  late final Future done = Future.wait([_client.done, _server.done]);
+
+  @override
+  bool get isClosed => _client.isClosed || _server.isClosed;
+
+  @override
+  ErrorCallback? get onUnhandledError => _server.onUnhandledError;
+
+  @override
+  bool get strictProtocolChecks => _server.strictProtocolChecks;
+
+  /// Creates a [Peer] that communicates over [channel].
+  ///
+  /// Note that the peer won't begin listening to [channel] until [Peer.listen]
+  /// is called.
+  ///
+  /// Unhandled exceptions in callbacks will be forwarded to [onUnhandledError].
+  /// If this is not provided, unhandled exceptions will be swallowed.
+  ///
+  /// If [strictProtocolChecks] is false, the underlying [Server] will accept
+  /// some requests which are not conformant with the JSON-RPC 2.0
+  /// specification. In particular, requests missing the `jsonrpc` parameter
+  /// will be accepted.
+  Peer(StreamChannel<String> channel,
+      {ErrorCallback? onUnhandledError, bool strictProtocolChecks = true})
+      : this.withoutJson(
+            jsonDocument.bind(channel).transform(respondToFormatExceptions),
+            onUnhandledError: onUnhandledError,
+            strictProtocolChecks: strictProtocolChecks);
+
+  /// Creates a [Peer] that communicates using decoded messages over [_channel].
+  ///
+  /// Unlike [Peer.new], this doesn't read or write JSON strings. Instead, it
+  /// reads and writes decoded maps or lists.
+  ///
+  /// Note that the peer won't begin listening to [_channel] until
+  /// [Peer.listen] is called.
+  ///
+  /// Unhandled exceptions in callbacks will be forwarded to [onUnhandledError].
+  /// If this is not provided, unhandled exceptions will be swallowed.
+  ///
+  /// If [strictProtocolChecks] is false, the underlying [Server] will accept
+  /// some requests which are not conformant with the JSON-RPC 2.0
+  /// specification. In particular, requests missing the `jsonrpc` parameter
+  /// will be accepted.
+  Peer.withoutJson(this._channel,
+      {ErrorCallback? onUnhandledError, bool strictProtocolChecks = true}) {
+    _server = Server.withoutJson(
+        StreamChannel(_serverIncomingForwarder.stream, _channel.sink),
+        onUnhandledError: onUnhandledError,
+        strictProtocolChecks: strictProtocolChecks);
+    _client = Client.withoutJson(
+        StreamChannel(_clientIncomingForwarder.stream, _channel.sink));
+  }
+
+  // Client methods.
+
+  @override
+  Future sendRequest(String method, [Object? parameters]) =>
+      _client.sendRequest(method, parameters);
+
+  @override
+  void sendNotification(String method, [Object? parameters]) =>
+      _client.sendNotification(method, parameters);
+
+  @override
+  void withBatch(void Function() callback) => _client.withBatch(callback);
+
+  // Server methods.
+
+  @override
+  void registerMethod(String name, Function callback) =>
+      _server.registerMethod(name, callback);
+
+  @override
+  void registerFallback(void Function(Parameters parameters) callback) =>
+      _server.registerFallback(callback);
+
+  // Shared methods.
+
+  @override
+  Future listen() {
+    _client.listen();
+    _server.listen();
+    _channel.stream.listen((message) {
+      if (message is Map) {
+        if (message.containsKey('result') || message.containsKey('error')) {
+          _clientIncomingForwarder.add(message);
+        } else {
+          _serverIncomingForwarder.add(message);
+        }
+      } else if (message is List &&
+          message.isNotEmpty &&
+          message.first is Map) {
+        if (message.first.containsKey('result') ||
+            message.first.containsKey('error')) {
+          _clientIncomingForwarder.add(message);
+        } else {
+          _serverIncomingForwarder.add(message);
+        }
+      } else {
+        // Non-Map and -List messages are ill-formed, so we pass them to the
+        // server since it knows how to send error responses.
+        _serverIncomingForwarder.add(message);
+      }
+    }, onError: (Object error, StackTrace stackTrace) {
+      _serverIncomingForwarder.addError(error, stackTrace);
+    }, onDone: close);
+    return done;
+  }
+
+  @override
+  Future close() {
+    _client.close();
+    _server.close();
+    return done;
+  }
+}
diff --git a/pkgs/json_rpc_2/lib/src/server.dart b/pkgs/json_rpc_2/lib/src/server.dart
new file mode 100644
index 0000000..2c58b79
--- /dev/null
+++ b/pkgs/json_rpc_2/lib/src/server.dart
@@ -0,0 +1,319 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:collection';
+import 'dart:convert';
+
+import 'package:stack_trace/stack_trace.dart';
+import 'package:stream_channel/stream_channel.dart';
+
+import '../error_code.dart' as error_code;
+import 'exception.dart';
+import 'parameters.dart';
+import 'utils.dart';
+
+/// A callback for unhandled exceptions.
+typedef ErrorCallback = void Function(dynamic error, dynamic stackTrace);
+
+/// A JSON-RPC 2.0 server.
+///
+/// A server exposes methods that are called by requests, to which it provides
+/// responses. Methods can be registered using [registerMethod] and
+/// [registerFallback].
+///
+/// Note that since requests can arrive asynchronously and methods can run
+/// asynchronously, it's possible for multiple methods to be invoked at the same
+/// time, or even for a single method to be invoked multiple times at once.
+class Server {
+  final StreamChannel<dynamic> _channel;
+
+  /// The methods registered for this server.
+  final _methods = <String, Function>{};
+
+  /// The fallback methods for this server.
+  ///
+  /// These are tried in order until one of them doesn't throw a
+  /// [RpcException.methodNotFound] exception.
+  final _fallbacks = Queue<Function>();
+
+  final _done = Completer<void>();
+
+  /// Returns a [Future] that completes when the underlying connection is
+  /// closed.
+  ///
+  /// This is the same future that's returned by [listen] and [close]. It may
+  /// complete before [close] is called if the remote endpoint closes the
+  /// connection.
+  Future get done => _done.future;
+
+  /// Whether the underlying connection is closed.
+  ///
+  /// Note that this will be `true` before [close] is called if the remote
+  /// endpoint closes the connection.
+  bool get isClosed => _done.isCompleted;
+
+  /// A callback that is fired on unhandled exceptions.
+  ///
+  /// In the case where a user provided callback results in an exception that
+  /// cannot be properly routed back to the client, this handler will be
+  /// invoked. If it is not set, the exception will be swallowed.
+  final ErrorCallback? onUnhandledError;
+
+  /// Whether to strictly enforce the JSON-RPC 2.0 specification for received
+  /// messages.
+  ///
+  /// If `false`, this [Server] will accept some requests which are not
+  /// conformant with the JSON-RPC 2.0 specification. In particular, requests
+  /// missing the `jsonrpc` parameter will be accepted.
+  final bool strictProtocolChecks;
+
+  /// Creates a [Server] that communicates over [channel].
+  ///
+  /// Note that the server won't begin listening to [channel] until
+  /// [Server.listen] is called.
+  ///
+  /// Unhandled exceptions in callbacks will be forwarded to [onUnhandledError].
+  /// If this is not provided, unhandled exceptions will be swallowed.
+  ///
+  /// If [strictProtocolChecks] is false, this [Server] will accept some
+  /// requests which are not conformant with the JSON-RPC 2.0 specification. In
+  /// particular, requests missing the `jsonrpc` parameter will be accepted.
+  Server(StreamChannel<String> channel,
+      {ErrorCallback? onUnhandledError, bool strictProtocolChecks = true})
+      : this.withoutJson(
+            jsonDocument.bind(channel).transform(respondToFormatExceptions),
+            onUnhandledError: onUnhandledError,
+            strictProtocolChecks: strictProtocolChecks);
+
+  /// Creates a [Server] that communicates using decoded messages over
+  /// [_channel].
+  ///
+  /// Unlike [Server.new], this doesn't read or write JSON strings. Instead, it
+  /// reads and writes decoded maps or lists.
+  ///
+  /// Note that the server won't begin listening to [_channel] until
+  /// [Server.listen] is called.
+  ///
+  /// Unhandled exceptions in callbacks will be forwarded to [onUnhandledError].
+  /// If this is not provided, unhandled exceptions will be swallowed.
+  ///
+  /// If [strictProtocolChecks] is false, this [Server] will accept some
+  /// requests which are not conformant with the JSON-RPC 2.0 specification. In
+  /// particular, requests missing the `jsonrpc` parameter will be accepted.
+  Server.withoutJson(this._channel,
+      {this.onUnhandledError, this.strictProtocolChecks = true});
+
+  /// Starts listening to the underlying stream.
+  ///
+  /// Returns a [Future] that will complete when the connection is closed or
+  /// when it has an error. This is the same as [done].
+  ///
+  /// [listen] may only be called once.
+  Future listen() {
+    _channel.stream.listen(_handleRequest,
+        onError: (Object error, StackTrace stackTrace) {
+      _done.completeError(error, stackTrace);
+      _channel.sink.close();
+    }, onDone: () {
+      if (!_done.isCompleted) _done.complete();
+    });
+    return done;
+  }
+
+  /// Closes the underlying connection.
+  ///
+  /// Returns a [Future] that completes when all resources have been released.
+  /// This is the same as [done].
+  Future close() {
+    _channel.sink.close();
+    if (!_done.isCompleted) _done.complete();
+    return done;
+  }
+
+  /// Registers a method named [name] on this server.
+  ///
+  /// [callback] can take either zero or one arguments. If it takes zero, any
+  /// requests for that method that include parameters will be rejected. If it
+  /// takes one, it will be passed a [Parameters] object.
+  ///
+  /// [callback] can return either a JSON-serializable object or a Future that
+  /// completes to a JSON-serializable object. Any errors in [callback] will be
+  /// reported to the client as JSON-RPC 2.0 errors.
+  void registerMethod(String name, Function callback) {
+    if (_methods.containsKey(name)) {
+      throw ArgumentError('There\'s already a method named "$name".');
+    }
+
+    _methods[name] = callback;
+  }
+
+  /// Registers a fallback method on this server.
+  ///
+  /// A server may have any number of fallback methods. When a request comes in
+  /// that doesn't match any named methods, each fallback is tried in order. A
+  /// fallback can pass on handling a request by throwing a
+  /// [RpcException.methodNotFound] exception.
+  ///
+  /// [callback] can return either a JSON-serializable object or a Future that
+  /// completes to a JSON-serializable object. Any errors in [callback] will be
+  /// reported to the client as JSON-RPC 2.0 errors. [callback] may send custom
+  /// errors by throwing an [RpcException].
+  void registerFallback(void Function(Parameters parameters) callback) {
+    _fallbacks.add(callback);
+  }
+
+  /// Handle a request.
+  ///
+  /// [request] is expected to be a JSON-serializable object representing a
+  /// request sent by a client. This calls the appropriate method or methods for
+  /// handling that request and returns a JSON-serializable response, or `null`
+  /// if no response should be sent.
+  Future _handleRequest(Object? request) async {
+    dynamic response;
+    if (request is List) {
+      if (request.isEmpty) {
+        response = RpcException(error_code.INVALID_REQUEST,
+                'A batch must contain at least one request.')
+            .serialize(request);
+      } else {
+        var results = await Future.wait(request.map(_handleSingleRequest));
+        var nonNull = results.where((result) => result != null);
+        if (nonNull.isEmpty) return;
+        response = nonNull.toList();
+      }
+    } else {
+      response = await _handleSingleRequest(request);
+      if (response == null) return;
+    }
+
+    if (!isClosed) _channel.sink.add(response);
+  }
+
+  /// Handles an individual parsed request.
+  Future _handleSingleRequest(Object? request) async {
+    try {
+      _validateRequest(request);
+      request = request as Map;
+
+      var name = request['method'];
+      var method = _methods[name];
+      method ??= _tryFallbacks;
+
+      Object? result;
+      if (method is ZeroArgumentFunction) {
+        if (request.containsKey('params')) {
+          throw RpcException.invalidParams('No parameters are allowed for '
+              'method "$name".');
+        }
+        result = await method();
+      } else {
+        result = await method(Parameters(name, request['params']));
+      }
+
+      // A request without an id is a notification, which should not be sent a
+      // response, even if one is generated on the server.
+      if (!request.containsKey('id')) return null;
+
+      return {'jsonrpc': '2.0', 'result': result, 'id': request['id']};
+    } catch (error, stackTrace) {
+      if (error is RpcException) {
+        if (error.code == error_code.INVALID_REQUEST ||
+            (request is Map && request.containsKey('id'))) {
+          return error.serialize(request);
+        } else {
+          onUnhandledError?.call(error, stackTrace);
+          return null;
+        }
+      } else if (request is Map && !request.containsKey('id')) {
+        onUnhandledError?.call(error, stackTrace);
+        return null;
+      }
+      final chain = Chain.forTrace(stackTrace);
+      return RpcException(error_code.SERVER_ERROR, getErrorMessage(error),
+          data: {
+            'full': '$error',
+            'stack': '$chain',
+          }).serialize(request);
+    }
+  }
+
+  /// Validates that [request] matches the JSON-RPC spec.
+  void _validateRequest(Object? request) {
+    if (request is! Map) {
+      throw RpcException(
+          error_code.INVALID_REQUEST,
+          'Request must be '
+          'an Array or an Object.');
+    }
+
+    if (strictProtocolChecks && !request.containsKey('jsonrpc')) {
+      throw RpcException(
+          error_code.INVALID_REQUEST,
+          'Request must '
+          'contain a "jsonrpc" key.');
+    }
+
+    if ((strictProtocolChecks || request.containsKey('jsonrpc')) &&
+        request['jsonrpc'] != '2.0') {
+      throw RpcException(
+          error_code.INVALID_REQUEST,
+          'Invalid JSON-RPC '
+          'version ${jsonEncode(request['jsonrpc'])}, expected "2.0".');
+    }
+
+    if (!request.containsKey('method')) {
+      throw RpcException(
+          error_code.INVALID_REQUEST,
+          'Request must '
+          'contain a "method" key.');
+    }
+
+    var method = request['method'];
+    if (request['method'] is! String) {
+      throw RpcException(
+          error_code.INVALID_REQUEST,
+          'Request method must '
+          'be a string, but was ${jsonEncode(method)}.');
+    }
+
+    if (request.containsKey('params')) {
+      var params = request['params'];
+      if (params is! List && params is! Map) {
+        throw RpcException(
+            error_code.INVALID_REQUEST,
+            'Request params must '
+            'be an Array or an Object, but was ${jsonEncode(params)}.');
+      }
+    }
+
+    var id = request['id'];
+    if (id != null && id is! String && id is! num) {
+      throw RpcException(
+          error_code.INVALID_REQUEST,
+          'Request id must be a '
+          'string, number, or null, but was ${jsonEncode(id)}.');
+    }
+  }
+
+  /// Try all the fallback methods in order.
+  Future _tryFallbacks(Parameters params) {
+    var iterator = _fallbacks.toList().iterator;
+
+    Future tryNext() async {
+      if (!iterator.moveNext()) {
+        throw RpcException.methodNotFound(params.method);
+      }
+
+      try {
+        return await iterator.current(params);
+      } on RpcException catch (error) {
+        if (error.code != error_code.METHOD_NOT_FOUND) rethrow;
+        return tryNext();
+      }
+    }
+
+    return tryNext();
+  }
+}
diff --git a/pkgs/json_rpc_2/lib/src/utils.dart b/pkgs/json_rpc_2/lib/src/utils.dart
new file mode 100644
index 0000000..28bbf21
--- /dev/null
+++ b/pkgs/json_rpc_2/lib/src/utils.dart
@@ -0,0 +1,70 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:stream_channel/stream_channel.dart';
+
+import '../error_code.dart' as error_code;
+import 'exception.dart';
+
+typedef ZeroArgumentFunction = FutureOr Function();
+
+/// A regular expression to match the exception prefix that some exceptions'
+/// [Object.toString] values contain.
+final _exceptionPrefix = RegExp(r'^([A-Z][a-zA-Z]*)?(Exception|Error): ');
+
+/// Get a string description of an exception.
+///
+/// Many exceptions include the exception class name at the beginning of their
+/// `toString`, so we remove that if it exists.
+String getErrorMessage(Object error) =>
+    error.toString().replaceFirst(_exceptionPrefix, '');
+
+/// Like `try`/`finally`, run [body] and ensure that [whenComplete] runs
+/// afterwards, regardless of whether [body] succeeded.
+///
+/// This is synchronicity-agnostic relative to [body]. If [body] returns a
+/// [Future], this wil run asynchronously; otherwise it will run synchronously.
+void tryFinally(dynamic Function() body, void Function() whenComplete) {
+  dynamic result;
+  try {
+    result = body();
+  } catch (_) {
+    whenComplete();
+    rethrow;
+  }
+
+  if (result is! Future) {
+    whenComplete();
+  } else {
+    result.whenComplete(whenComplete);
+  }
+}
+
+/// A transformer that silently drops [FormatException]s.
+final ignoreFormatExceptions = StreamTransformer<Object?, Object?>.fromHandlers(
+    handleError: (error, stackTrace, sink) {
+  if (error is FormatException) return;
+  sink.addError(error, stackTrace);
+});
+
+/// A transformer that sends error responses on [FormatException]s.
+final StreamChannelTransformer<Object?, Object?> respondToFormatExceptions =
+    _RespondToFormatExceptionsTransformer();
+
+class _RespondToFormatExceptionsTransformer
+    implements StreamChannelTransformer<Object?, Object?> {
+  @override
+  StreamChannel<Object?> bind(StreamChannel<Object?> channel) {
+    return channel.changeStream((stream) {
+      return stream.handleError((dynamic error) {
+        final formatException = error as FormatException;
+        var exception = RpcException(
+            error_code.PARSE_ERROR, 'Invalid JSON: ${formatException.message}');
+        channel.sink.add(exception.serialize(formatException.source));
+      }, test: (error) => error is FormatException);
+    });
+  }
+}
diff --git a/pkgs/json_rpc_2/pubspec.yaml b/pkgs/json_rpc_2/pubspec.yaml
new file mode 100644
index 0000000..6f53131
--- /dev/null
+++ b/pkgs/json_rpc_2/pubspec.yaml
@@ -0,0 +1,17 @@
+name: json_rpc_2
+version: 3.0.3
+description: >-
+  Utilities to write a client or server using the JSON-RPC 2.0 spec.
+repository: https://github.com/dart-lang/tools/tree/main/pkgs/json_rpc_2
+
+environment:
+  sdk: ^3.4.0
+
+dependencies:
+  stack_trace: ^1.10.0
+  stream_channel: ^2.1.0
+
+dev_dependencies:
+  dart_flutter_team_lints: ^3.0.0
+  test: ^1.25.5
+  web_socket_channel: ^3.0.0
diff --git a/pkgs/json_rpc_2/test/client/client_test.dart b/pkgs/json_rpc_2/test/client/client_test.dart
new file mode 100644
index 0000000..1a4f65d
--- /dev/null
+++ b/pkgs/json_rpc_2/test/client/client_test.dart
@@ -0,0 +1,218 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:json_rpc_2/error_code.dart' as error_code;
+import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
+import 'package:test/test.dart';
+
+import 'utils.dart';
+
+void main() {
+  late ClientController controller;
+
+  setUp(() => controller = ClientController());
+
+  test('sends a message and returns the response', () {
+    controller.expectRequest((request) {
+      expect(
+          request,
+          allOf([
+            containsPair('jsonrpc', '2.0'),
+            containsPair('method', 'foo'),
+            containsPair('params', {'param': 'value'})
+          ]));
+
+      return {'jsonrpc': '2.0', 'result': 'bar', 'id': request['id']};
+    });
+
+    expect(controller.client.sendRequest('foo', {'param': 'value'}),
+        completion(equals('bar')));
+  });
+
+  test('sends a message and returns the response with String id', () {
+    controller.expectRequest((request) {
+      expect(
+          request,
+          allOf([
+            containsPair('jsonrpc', '2.0'),
+            containsPair('method', 'foo'),
+            containsPair('params', {'param': 'value'})
+          ]));
+
+      return {
+        'jsonrpc': '2.0',
+        'result': 'bar',
+        'id': request['id'].toString()
+      };
+    });
+
+    expect(controller.client.sendRequest('foo', {'param': 'value'}),
+        completion(equals('bar')));
+  });
+
+  test('sends a notification and expects no response', () {
+    controller.expectRequest((request) {
+      expect(
+          request,
+          equals({
+            'jsonrpc': '2.0',
+            'method': 'foo',
+            'params': {'param': 'value'}
+          }));
+    });
+
+    controller.client.sendNotification('foo', {'param': 'value'});
+  });
+
+  test('sends a notification with positional parameters', () {
+    controller.expectRequest((request) {
+      expect(
+          request,
+          equals({
+            'jsonrpc': '2.0',
+            'method': 'foo',
+            'params': ['value1', 'value2']
+          }));
+    });
+
+    controller.client.sendNotification('foo', ['value1', 'value2']);
+  });
+
+  test('sends a notification with no parameters', () {
+    controller.expectRequest((request) {
+      expect(request, equals({'jsonrpc': '2.0', 'method': 'foo'}));
+    });
+
+    controller.client.sendNotification('foo');
+  });
+
+  test('sends a synchronous batch of requests', () {
+    controller.expectRequest((request) {
+      expect(request, isA<List>());
+      expect(request, hasLength(3));
+      expect(request[0], equals({'jsonrpc': '2.0', 'method': 'foo'}));
+      expect(
+          request[1],
+          allOf([
+            containsPair('jsonrpc', '2.0'),
+            containsPair('method', 'bar'),
+            containsPair('params', {'param': 'value'})
+          ]));
+      expect(
+          request[2],
+          allOf(
+              [containsPair('jsonrpc', '2.0'), containsPair('method', 'baz')]));
+
+      return [
+        {'jsonrpc': '2.0', 'result': 'baz response', 'id': request[2]['id']},
+        {'jsonrpc': '2.0', 'result': 'bar response', 'id': request[1]['id']}
+      ];
+    });
+
+    controller.client.withBatch(() {
+      controller.client.sendNotification('foo');
+      expect(controller.client.sendRequest('bar', {'param': 'value'}),
+          completion(equals('bar response')));
+      expect(controller.client.sendRequest('baz'),
+          completion(equals('baz response')));
+    });
+  });
+
+  test('sends an asynchronous batch of requests', () {
+    controller.expectRequest((request) {
+      expect(request, isA<List>());
+      expect(request, hasLength(3));
+      expect(request[0], equals({'jsonrpc': '2.0', 'method': 'foo'}));
+      expect(
+          request[1],
+          allOf([
+            containsPair('jsonrpc', '2.0'),
+            containsPair('method', 'bar'),
+            containsPair('params', {'param': 'value'})
+          ]));
+      expect(
+          request[2],
+          allOf(
+              [containsPair('jsonrpc', '2.0'), containsPair('method', 'baz')]));
+
+      return [
+        {'jsonrpc': '2.0', 'result': 'baz response', 'id': request[2]['id']},
+        {'jsonrpc': '2.0', 'result': 'bar response', 'id': request[1]['id']}
+      ];
+    });
+
+    controller.client.withBatch(() {
+      return Future<void>.value().then<void>((_) {
+        controller.client.sendNotification('foo');
+      }).then<void>((_) {
+        expect(controller.client.sendRequest('bar', {'param': 'value'}),
+            completion(equals('bar response')));
+      }).then<void>((_) {
+        expect(controller.client.sendRequest('baz'),
+            completion(equals('baz response')));
+      });
+    });
+  });
+
+  test('reports an error from the server', () {
+    controller.expectRequest((request) {
+      expect(
+          request,
+          allOf(
+              [containsPair('jsonrpc', '2.0'), containsPair('method', 'foo')]));
+
+      return {
+        'jsonrpc': '2.0',
+        'error': {
+          'code': error_code.SERVER_ERROR,
+          'message': 'you are bad at requests',
+          'data': 'some junk'
+        },
+        'id': request['id']
+      };
+    });
+
+    expect(
+        controller.client.sendRequest('foo', {'param': 'value'}),
+        throwsA(isA<json_rpc.RpcException>()
+            .having((e) => e.code, 'code', error_code.SERVER_ERROR)
+            .having((e) => e.message, 'message', 'you are bad at requests')
+            .having((e) => e.data, 'data', 'some junk')));
+  });
+
+  test('requests throw StateErrors if the client is closed', () {
+    controller.client.close();
+    expect(() => controller.client.sendRequest('foo'), throwsStateError);
+    expect(() => controller.client.sendNotification('foo'), throwsStateError);
+  });
+
+  test('ignores bogus responses', () {
+    // Make a request so we have something to respond to.
+    controller.expectRequest((request) {
+      controller.sendJsonResponse('{invalid');
+      controller.sendResponse('not a map');
+      controller.sendResponse(
+          {'jsonrpc': 'wrong version', 'result': 'wrong', 'id': request['id']});
+      controller.sendResponse({'jsonrpc': '2.0', 'result': 'wrong'});
+      controller.sendResponse({'jsonrpc': '2.0', 'id': request['id']});
+      controller.sendResponse(
+          {'jsonrpc': '2.0', 'error': 'not a map', 'id': request['id']});
+      controller.sendResponse({
+        'jsonrpc': '2.0',
+        'error': {'code': 'not an int', 'message': 'dang yo'},
+        'id': request['id']
+      });
+      controller.sendResponse({
+        'jsonrpc': '2.0',
+        'error': {'code': 123, 'message': 0xDEADBEEF},
+        'id': request['id']
+      });
+
+      return pumpEventQueue().then(
+          (_) => {'jsonrpc': '2.0', 'result': 'right', 'id': request['id']});
+    });
+
+    expect(controller.client.sendRequest('foo'), completion(equals('right')));
+  });
+}
diff --git a/pkgs/json_rpc_2/test/client/stream_test.dart b/pkgs/json_rpc_2/test/client/stream_test.dart
new file mode 100644
index 0000000..b33778e
--- /dev/null
+++ b/pkgs/json_rpc_2/test/client/stream_test.dart
@@ -0,0 +1,97 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test/test.dart';
+
+void main() {
+  late StreamController responseController;
+  late StreamController requestController;
+  late json_rpc.Client client;
+
+  setUp(() {
+    responseController = StreamController();
+    requestController = StreamController();
+    client = json_rpc.Client.withoutJson(
+        StreamChannel(responseController.stream, requestController.sink));
+  });
+
+  test('.withoutJson supports decoded stream and sink', () {
+    client.listen();
+
+    expect(requestController.stream.first.then((request) {
+      expect(
+          request,
+          allOf(
+              [containsPair('jsonrpc', '2.0'), containsPair('method', 'foo')]));
+
+      responseController
+          .add({'jsonrpc': '2.0', 'result': 'bar', 'id': request['id']});
+    }), completes);
+
+    client.sendRequest('foo');
+  });
+
+  test('.listen returns when the controller is closed', () {
+    var hasListenCompeted = false;
+    expect(client.listen().then((_) => hasListenCompeted = true), completes);
+
+    return pumpEventQueue().then((_) {
+      expect(hasListenCompeted, isFalse);
+
+      // This should cause listen to complete.
+      return responseController.close();
+    });
+  });
+
+  test('.listen returns a stream error', () {
+    expect(client.listen(), throwsA('oh no'));
+    responseController.addError('oh no');
+  });
+
+  test('.listen can\'t be called twice', () {
+    client.listen();
+    expect(() => client.listen(), throwsStateError);
+  });
+
+  test('.close cancels the stream subscription and closes the sink', () {
+    // Work around sdk#19095.
+    requestController.stream.listen(null);
+
+    expect(client.listen(), completes);
+
+    expect(client.isClosed, isFalse);
+    expect(client.close(), completes);
+    expect(client.isClosed, isTrue);
+
+    expect(() => responseController.stream.listen((_) {}), throwsStateError);
+    expect(requestController.isClosed, isTrue);
+  });
+
+  group('a stream error', () {
+    test('is reported through .done', () {
+      expect(client.listen(), throwsA('oh no!'));
+      expect(client.done, throwsA('oh no!'));
+      responseController.addError('oh no!');
+    });
+
+    test('cause a pending request to throw a StateError', () {
+      expect(client.listen(), throwsA('oh no!'));
+      expect(client.sendRequest('foo'), throwsStateError);
+      responseController.addError('oh no!');
+    });
+
+    test('causes future requests to throw StateErrors', () async {
+      expect(client.listen(), throwsA('oh no!'));
+      responseController.addError('oh no!');
+      await pumpEventQueue();
+
+      expect(() => client.sendRequest('foo'), throwsStateError);
+      expect(() => client.sendNotification('foo'), throwsStateError);
+    });
+  });
+}
diff --git a/pkgs/json_rpc_2/test/client/utils.dart b/pkgs/json_rpc_2/test/client/utils.dart
new file mode 100644
index 0000000..38e187f
--- /dev/null
+++ b/pkgs/json_rpc_2/test/client/utils.dart
@@ -0,0 +1,56 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test/test.dart';
+
+/// A controller used to test a [json_rpc.Client].
+class ClientController {
+  /// The controller for the client's response stream.
+  final _responseController = StreamController<String>();
+
+  /// The controller for the client's request sink.
+  final _requestController = StreamController<String>();
+
+  /// The client.
+  late final json_rpc.Client client;
+
+  ClientController() {
+    client = json_rpc.Client(
+        StreamChannel(_responseController.stream, _requestController.sink));
+    client.listen();
+  }
+
+  /// Expects that the client will send a request.
+  ///
+  /// The request is passed to [callback], which can return a response. If it
+  /// returns a String, that's sent as the response directly. If it returns
+  /// null, no response is sent. Otherwise, the return value is encoded and sent
+  /// as the response.
+  void expectRequest(FutureOr Function(dynamic) callback) {
+    expect(
+        _requestController.stream.first.then((request) {
+          return callback(jsonDecode(request));
+        }).then((response) {
+          if (response == null) return;
+          if (response is! String) response = jsonEncode(response);
+          _responseController.add(response);
+        }),
+        completes);
+  }
+
+  /// Sends [response], a decoded response, to [client].
+  void sendResponse(Object? response) {
+    sendJsonResponse(jsonEncode(response));
+  }
+
+  /// Sends [request], a JSON-encoded response, to [client].
+  void sendJsonResponse(String request) {
+    _responseController.add(request);
+  }
+}
diff --git a/pkgs/json_rpc_2/test/peer_test.dart b/pkgs/json_rpc_2/test/peer_test.dart
new file mode 100644
index 0000000..0df6056
--- /dev/null
+++ b/pkgs/json_rpc_2/test/peer_test.dart
@@ -0,0 +1,251 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+// ignore_for_file: inference_failure_on_instance_creation
+
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:json_rpc_2/error_code.dart' as error_code;
+import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test/test.dart';
+
+void main() {
+  late StreamSink incoming;
+  late Stream outgoing;
+  late json_rpc.Peer peer;
+
+  setUp(() {
+    var incomingController = StreamController();
+    incoming = incomingController.sink;
+    var outgoingController = StreamController();
+    outgoing = outgoingController.stream;
+    peer = json_rpc.Peer.withoutJson(
+        StreamChannel(incomingController.stream, outgoingController));
+  });
+
+  group('like a client,', () {
+    test('can send a message and receive a response', () {
+      expect(outgoing.first.then((request) {
+        expect(
+            request,
+            equals({
+              'jsonrpc': '2.0',
+              'method': 'foo',
+              'params': {'bar': 'baz'},
+              'id': 0
+            }));
+        incoming.add({'jsonrpc': '2.0', 'result': 'qux', 'id': 0});
+      }), completes);
+
+      peer.listen();
+      expect(
+          peer.sendRequest('foo', {'bar': 'baz'}), completion(equals('qux')));
+    });
+
+    test('can send a batch of messages and receive a batch of responses', () {
+      expect(outgoing.first.then((request) {
+        expect(
+            request,
+            equals([
+              {
+                'jsonrpc': '2.0',
+                'method': 'foo',
+                'params': {'bar': 'baz'},
+                'id': 0
+              },
+              {
+                'jsonrpc': '2.0',
+                'method': 'a',
+                'params': {'b': 'c'},
+                'id': 1
+              },
+              {
+                'jsonrpc': '2.0',
+                'method': 'w',
+                'params': {'x': 'y'},
+                'id': 2
+              }
+            ]));
+
+        incoming.add([
+          {'jsonrpc': '2.0', 'result': 'qux', 'id': 0},
+          {'jsonrpc': '2.0', 'result': 'd', 'id': 1},
+          {'jsonrpc': '2.0', 'result': 'z', 'id': 2}
+        ]);
+      }), completes);
+
+      peer.listen();
+
+      peer.withBatch(() {
+        expect(
+            peer.sendRequest('foo', {'bar': 'baz'}), completion(equals('qux')));
+        expect(peer.sendRequest('a', {'b': 'c'}), completion(equals('d')));
+        expect(peer.sendRequest('w', {'x': 'y'}), completion(equals('z')));
+      });
+    });
+
+    test('requests terminates when the channel is closed', () async {
+      var incomingController = StreamController<void>();
+      var channel = StreamChannel.withGuarantees(
+        incomingController.stream,
+        StreamController<void>(),
+      );
+      var peer = json_rpc.Peer.withoutJson(channel);
+      unawaited(peer.listen());
+
+      var response = peer.sendRequest('foo');
+      await incomingController.close();
+
+      expect(response, throwsStateError);
+    });
+  });
+
+  test('can be closed', () async {
+    var incomingController = StreamController();
+    var channel = StreamChannel.withGuarantees(
+      incomingController.stream,
+      StreamController(),
+    );
+    var peer = json_rpc.Peer.withoutJson(channel);
+    unawaited(peer.listen());
+    await peer.close();
+  });
+
+  test('considered closed with misbehaving StreamChannel', () async {
+    // If a StreamChannel does not enforce the guarantees stated in it's
+    // contract - specifically that "Closing the sink causes the stream to close
+    // before it emits any more events." - The `Peer` should still understand
+    // when it has been closed manually.
+    var channel = StreamChannel(
+      StreamController().stream,
+      StreamController(),
+    );
+    var peer = json_rpc.Peer.withoutJson(channel);
+    unawaited(peer.listen());
+    unawaited(peer.close());
+    expect(peer.isClosed, true);
+  });
+
+  group('like a server,', () {
+    test('can receive a call and return a response', () {
+      expect(outgoing.first,
+          completion(equals({'jsonrpc': '2.0', 'result': 'qux', 'id': 0})));
+
+      peer.registerMethod('foo', (_) => 'qux');
+      peer.listen();
+
+      incoming.add({
+        'jsonrpc': '2.0',
+        'method': 'foo',
+        'params': {'bar': 'baz'},
+        'id': 0
+      });
+    });
+
+    test('can receive a batch of calls and return a batch of responses', () {
+      expect(
+          outgoing.first,
+          completion(equals([
+            {'jsonrpc': '2.0', 'result': 'qux', 'id': 0},
+            {'jsonrpc': '2.0', 'result': 'd', 'id': 1},
+            {'jsonrpc': '2.0', 'result': 'z', 'id': 2}
+          ])));
+
+      peer.registerMethod('foo', (_) => 'qux');
+      peer.registerMethod('a', (_) => 'd');
+      peer.registerMethod('w', (_) => 'z');
+      peer.listen();
+
+      incoming.add([
+        {
+          'jsonrpc': '2.0',
+          'method': 'foo',
+          'params': {'bar': 'baz'},
+          'id': 0
+        },
+        {
+          'jsonrpc': '2.0',
+          'method': 'a',
+          'params': {'b': 'c'},
+          'id': 1
+        },
+        {
+          'jsonrpc': '2.0',
+          'method': 'w',
+          'params': {'x': 'y'},
+          'id': 2
+        }
+      ]);
+    });
+
+    test('returns a response for malformed JSON', () {
+      var incomingController = StreamController<String>();
+      var outgoingController = StreamController<String>();
+      var jsonPeer = json_rpc.Peer(
+          StreamChannel(incomingController.stream, outgoingController));
+
+      expect(
+          outgoingController.stream.first.then(jsonDecode),
+          completion({
+            'jsonrpc': '2.0',
+            'error': {
+              'code': error_code.PARSE_ERROR,
+              'message': startsWith('Invalid JSON: '),
+              // TODO(nweiz): Always expect the source when sdk#25655 is fixed.
+              'data': {
+                'request': anyOf([isNull, '{invalid'])
+              }
+            },
+            'id': null
+          }));
+
+      jsonPeer.listen();
+
+      incomingController.add('{invalid');
+    });
+
+    test('returns a response for incorrectly-structured JSON', () {
+      expect(
+          outgoing.first,
+          completion({
+            'jsonrpc': '2.0',
+            'error': {
+              'code': error_code.INVALID_REQUEST,
+              'message': 'Request must contain a "jsonrpc" key.',
+              'data': {
+                'request': {'completely': 'wrong'}
+              }
+            },
+            'id': null
+          }));
+
+      peer.listen();
+
+      incoming.add({'completely': 'wrong'});
+    });
+  });
+
+  test('can notify on unhandled errors for if the method throws', () async {
+    var exception = Exception('test exception');
+    var incomingController = StreamController();
+    var outgoingController = StreamController();
+    final completer = Completer<Exception>();
+    peer = json_rpc.Peer.withoutJson(
+      StreamChannel(incomingController.stream, outgoingController),
+      onUnhandledError: (error, stack) {
+        completer.complete(error);
+      },
+    );
+    peer
+      ..registerMethod('foo', () => throw exception)
+      // ignore: unawaited_futures
+      ..listen();
+
+    incomingController.add({'jsonrpc': '2.0', 'method': 'foo'});
+    var receivedException = await completer.future;
+    expect(receivedException, equals(exception));
+  });
+}
diff --git a/pkgs/json_rpc_2/test/server/batch_test.dart b/pkgs/json_rpc_2/test/server/batch_test.dart
new file mode 100644
index 0000000..af883c4
--- /dev/null
+++ b/pkgs/json_rpc_2/test/server/batch_test.dart
@@ -0,0 +1,147 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:json_rpc_2/error_code.dart' as error_code;
+import 'package:json_rpc_2/src/parameters.dart' show Parameters;
+import 'package:test/test.dart';
+
+import 'utils.dart';
+
+void main() {
+  late ServerController controller;
+
+  setUp(() {
+    controller = ServerController();
+    controller.server
+      ..registerMethod('foo', () => 'foo')
+      ..registerMethod('id', (Parameters params) => params.value)
+      ..registerMethod('arg', (Parameters params) => params['arg'].value);
+  });
+
+  test('handles a batch of requests', () {
+    expect(
+        controller.handleRequest([
+          {'jsonrpc': '2.0', 'method': 'foo', 'id': 1},
+          {
+            'jsonrpc': '2.0',
+            'method': 'id',
+            'params': ['value'],
+            'id': 2
+          },
+          {
+            'jsonrpc': '2.0',
+            'method': 'arg',
+            'params': {'arg': 'value'},
+            'id': 3
+          }
+        ]),
+        completion(equals([
+          {'jsonrpc': '2.0', 'result': 'foo', 'id': 1},
+          {
+            'jsonrpc': '2.0',
+            'result': ['value'],
+            'id': 2
+          },
+          {'jsonrpc': '2.0', 'result': 'value', 'id': 3}
+        ])));
+  });
+
+  test('handles errors individually', () {
+    expect(
+        controller.handleRequest([
+          {'jsonrpc': '2.0', 'method': 'foo', 'id': 1},
+          {'jsonrpc': '2.0', 'method': 'zap', 'id': 2},
+          {
+            'jsonrpc': '2.0',
+            'method': 'arg',
+            'params': {'arg': 'value'},
+            'id': 3
+          }
+        ]),
+        completion(equals([
+          {'jsonrpc': '2.0', 'result': 'foo', 'id': 1},
+          {
+            'jsonrpc': '2.0',
+            'id': 2,
+            'error': {
+              'code': error_code.METHOD_NOT_FOUND,
+              'message': 'Unknown method "zap".',
+              'data': {
+                'request': {'jsonrpc': '2.0', 'method': 'zap', 'id': 2}
+              },
+            }
+          },
+          {'jsonrpc': '2.0', 'result': 'value', 'id': 3}
+        ])));
+  });
+
+  test('handles notifications individually', () {
+    expect(
+        controller.handleRequest([
+          {'jsonrpc': '2.0', 'method': 'foo', 'id': 1},
+          {
+            'jsonrpc': '2.0',
+            'method': 'id',
+            'params': ['value']
+          },
+          {
+            'jsonrpc': '2.0',
+            'method': 'arg',
+            'params': {'arg': 'value'},
+            'id': 3
+          }
+        ]),
+        completion(equals([
+          {'jsonrpc': '2.0', 'result': 'foo', 'id': 1},
+          {'jsonrpc': '2.0', 'result': 'value', 'id': 3}
+        ])));
+  });
+
+  test('returns nothing if every request is a notification', () {
+    expect(
+        controller.handleRequest([
+          {'jsonrpc': '2.0', 'method': 'foo'},
+          {
+            'jsonrpc': '2.0',
+            'method': 'id',
+            'params': ['value']
+          },
+          {
+            'jsonrpc': '2.0',
+            'method': 'arg',
+            'params': {'arg': 'value'}
+          }
+        ]),
+        doesNotComplete);
+  });
+
+  test('returns an error if the batch is empty', () {
+    expectErrorResponse(controller, [], error_code.INVALID_REQUEST,
+        'A batch must contain at least one request.');
+  });
+
+  test('disallows nested batches', () {
+    expect(
+        controller.handleRequest([
+          [
+            {'jsonrpc': '2.0', 'method': 'foo', 'id': 1}
+          ]
+        ]),
+        completion(equals([
+          {
+            'jsonrpc': '2.0',
+            'id': null,
+            'error': {
+              'code': error_code.INVALID_REQUEST,
+              'message': 'Request must be an Array or an Object.',
+              'data': {
+                'request': [
+                  {'jsonrpc': '2.0', 'method': 'foo', 'id': 1}
+                ]
+              }
+            }
+          }
+        ])));
+  });
+}
diff --git a/pkgs/json_rpc_2/test/server/invalid_request_test.dart b/pkgs/json_rpc_2/test/server/invalid_request_test.dart
new file mode 100644
index 0000000..4fa4de1
--- /dev/null
+++ b/pkgs/json_rpc_2/test/server/invalid_request_test.dart
@@ -0,0 +1,94 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:json_rpc_2/error_code.dart' as error_code;
+import 'package:test/test.dart';
+
+import 'utils.dart';
+
+void main() {
+  late ServerController controller;
+  setUp(() => controller = ServerController());
+
+  test('a non-Array/Object request is invalid', () {
+    expectErrorResponse(controller, 'foo', error_code.INVALID_REQUEST,
+        'Request must be an Array or an Object.');
+  });
+
+  test('requests must have a jsonrpc key', () {
+    expectErrorResponse(controller, {'method': 'foo', 'id': 1234},
+        error_code.INVALID_REQUEST, 'Request must contain a "jsonrpc" key.');
+  });
+
+  test('the jsonrpc version must be 2.0', () {
+    expectErrorResponse(
+        controller,
+        {'jsonrpc': '1.0', 'method': 'foo', 'id': 1234},
+        error_code.INVALID_REQUEST,
+        'Invalid JSON-RPC version "1.0", expected "2.0".');
+  });
+
+  test('requests must have a method key', () {
+    expectErrorResponse(controller, {'jsonrpc': '2.0', 'id': 1234},
+        error_code.INVALID_REQUEST, 'Request must contain a "method" key.');
+  });
+
+  test('request method must be a string', () {
+    expectErrorResponse(
+        controller,
+        {'jsonrpc': '2.0', 'method': 1234, 'id': 1234},
+        error_code.INVALID_REQUEST,
+        'Request method must be a string, but was 1234.');
+  });
+
+  test('request params must be an Array or Object', () {
+    expectErrorResponse(
+        controller,
+        {'jsonrpc': '2.0', 'method': 'foo', 'params': 1234, 'id': 1234},
+        error_code.INVALID_REQUEST,
+        'Request params must be an Array or an Object, but was 1234.');
+  });
+
+  test('request id may not be an Array or Object', () {
+    expect(
+        controller.handleRequest({
+          'jsonrpc': '2.0',
+          'method': 'foo',
+          'id': {'bad': 'id'}
+        }),
+        completion(equals({
+          'jsonrpc': '2.0',
+          'id': null,
+          'error': {
+            'code': error_code.INVALID_REQUEST,
+            'message': 'Request id must be a string, number, or null, but was '
+                '{"bad":"id"}.',
+            'data': {
+              'request': {
+                'jsonrpc': '2.0',
+                'method': 'foo',
+                'id': {'bad': 'id'}
+              }
+            }
+          }
+        })));
+  });
+
+  group('strict protocol checks disabled', () {
+    setUp(() => controller = ServerController(strictProtocolChecks: false));
+
+    test('and no jsonrpc param', () {
+      expectErrorResponse(controller, {'method': 'foo', 'id': 1234},
+          error_code.METHOD_NOT_FOUND, 'Unknown method "foo".');
+    });
+
+    test('the jsonrpc version must be 2.0', () {
+      expectErrorResponse(
+          controller,
+          {'jsonrpc': '1.0', 'method': 'foo', 'id': 1234},
+          error_code.INVALID_REQUEST,
+          'Invalid JSON-RPC version "1.0", expected "2.0".');
+    });
+  });
+}
diff --git a/pkgs/json_rpc_2/test/server/parameters_test.dart b/pkgs/json_rpc_2/test/server/parameters_test.dart
new file mode 100644
index 0000000..9ecfb1f
--- /dev/null
+++ b/pkgs/json_rpc_2/test/server/parameters_test.dart
@@ -0,0 +1,403 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
+import 'package:test/test.dart';
+
+import 'utils.dart';
+
+void main() {
+  group('with named parameters', () {
+    late json_rpc.Parameters parameters;
+    setUp(() {
+      parameters = json_rpc.Parameters('foo', {
+        'num': 1.5,
+        'int': 1,
+        'bool': true,
+        'string': 'zap',
+        'list': [1, 2, 3],
+        'date-time': '1990-01-01 00:00:00.000',
+        'uri': 'https://dart.dev',
+        'invalid-uri': 'http://[::1',
+        'map': {'num': 4.2, 'bool': false}
+      });
+    });
+
+    test('value returns the wrapped value', () {
+      expect(
+          parameters.value,
+          equals({
+            'num': 1.5,
+            'int': 1,
+            'bool': true,
+            'string': 'zap',
+            'list': [1, 2, 3],
+            'date-time': '1990-01-01 00:00:00.000',
+            'uri': 'https://dart.dev',
+            'invalid-uri': 'http://[::1',
+            'map': {'num': 4.2, 'bool': false}
+          }));
+    });
+
+    test('[int] throws a parameter error', () {
+      expect(
+          () => parameters[0],
+          throwsInvalidParams('Parameters for method "foo" must be passed by '
+              'position.'));
+    });
+
+    test('[].value returns existing parameters', () {
+      expect(parameters['num'].value, equals(1.5));
+    });
+
+    test('[].valueOr returns existing parameters', () {
+      expect(parameters['num'].valueOr(7), equals(1.5));
+    });
+
+    test('[].value fails for absent parameters', () {
+      expect(
+          () => parameters['fblthp'].value,
+          throwsInvalidParams('Request for method "foo" is missing required '
+              'parameter "fblthp".'));
+    });
+
+    test('[].valueOr succeeds for absent parameters', () {
+      expect(parameters['fblthp'].valueOr(7), equals(7));
+    });
+
+    test('[].exists returns true for existing parameters', () {
+      expect(parameters['num'].exists, isTrue);
+    });
+
+    test('[].exists returns false for missing parameters', () {
+      expect(parameters['fblthp'].exists, isFalse);
+    });
+
+    test('[].asNum returns numeric parameters', () {
+      expect(parameters['num'].asNum, equals(1.5));
+      expect(parameters['int'].asNum, equals(1));
+    });
+
+    test('[].asNumOr returns numeric parameters', () {
+      expect(parameters['num'].asNumOr(7), equals(1.5));
+    });
+
+    test('[].asNum fails for non-numeric parameters', () {
+      expect(
+          () => parameters['bool'].asNum,
+          throwsInvalidParams('Parameter "bool" for method "foo" must be a '
+              'number, but was true.'));
+    });
+
+    test('[].asNumOr fails for non-numeric parameters', () {
+      expect(
+          () => parameters['bool'].asNumOr(7),
+          throwsInvalidParams('Parameter "bool" for method "foo" must be a '
+              'number, but was true.'));
+    });
+
+    test('[].asNum fails for absent parameters', () {
+      expect(
+          () => parameters['fblthp'].asNum,
+          throwsInvalidParams('Request for method "foo" is missing required '
+              'parameter "fblthp".'));
+    });
+
+    test('[].asNumOr succeeds for absent parameters', () {
+      expect(parameters['fblthp'].asNumOr(7), equals(7));
+    });
+
+    test('[].asInt returns integer parameters', () {
+      expect(parameters['int'].asInt, equals(1));
+    });
+
+    test('[].asIntOr returns integer parameters', () {
+      expect(parameters['int'].asIntOr(7), equals(1));
+    });
+
+    test('[].asInt fails for non-integer parameters', () {
+      expect(
+          () => parameters['bool'].asInt,
+          throwsInvalidParams('Parameter "bool" for method "foo" must be an '
+              'integer, but was true.'));
+    });
+
+    test('[].asIntOr succeeds for absent parameters', () {
+      expect(parameters['fblthp'].asIntOr(7), equals(7));
+    });
+
+    test('[].asBool returns boolean parameters', () {
+      expect(parameters['bool'].asBool, isTrue);
+    });
+
+    test('[].asBoolOr returns boolean parameters', () {
+      expect(parameters['bool'].asBoolOr(false), isTrue);
+    });
+
+    test('[].asBoolOr fails for non-boolean parameters', () {
+      expect(
+          () => parameters['int'].asBool,
+          throwsInvalidParams('Parameter "int" for method "foo" must be a '
+              'boolean, but was 1.'));
+    });
+
+    test('[].asBoolOr succeeds for absent parameters', () {
+      expect(parameters['fblthp'].asBoolOr(false), isFalse);
+    });
+
+    test('[].asString returns string parameters', () {
+      expect(parameters['string'].asString, equals('zap'));
+    });
+
+    test('[].asStringOr returns string parameters', () {
+      expect(parameters['string'].asStringOr('bap'), equals('zap'));
+    });
+
+    test('[].asString fails for non-string parameters', () {
+      expect(
+          () => parameters['int'].asString,
+          throwsInvalidParams('Parameter "int" for method "foo" must be a '
+              'string, but was 1.'));
+    });
+
+    test('[].asStringOr succeeds for absent parameters', () {
+      expect(parameters['fblthp'].asStringOr('bap'), equals('bap'));
+    });
+
+    test('[].asList returns list parameters', () {
+      expect(parameters['list'].asList, equals([1, 2, 3]));
+    });
+
+    test('[].asListOr returns list parameters', () {
+      expect(parameters['list'].asListOr([5, 6, 7]), equals([1, 2, 3]));
+    });
+
+    test('[].asList fails for non-list parameters', () {
+      expect(
+          () => parameters['int'].asList,
+          throwsInvalidParams('Parameter "int" for method "foo" must be an '
+              'Array, but was 1.'));
+    });
+
+    test('[].asListOr succeeds for absent parameters', () {
+      expect(parameters['fblthp'].asListOr([5, 6, 7]), equals([5, 6, 7]));
+    });
+
+    test('[].asMap returns map parameters', () {
+      expect(parameters['map'].asMap, equals({'num': 4.2, 'bool': false}));
+    });
+
+    test('[].asMapOr returns map parameters', () {
+      expect(
+          parameters['map'].asMapOr({}), equals({'num': 4.2, 'bool': false}));
+    });
+
+    test('[].asMap fails for non-map parameters', () {
+      expect(
+          () => parameters['int'].asMap,
+          throwsInvalidParams('Parameter "int" for method "foo" must be an '
+              'Object, but was 1.'));
+    });
+
+    test('[].asMapOr succeeds for absent parameters', () {
+      expect(parameters['fblthp'].asMapOr({}), equals({}));
+    });
+
+    test('[].asDateTime returns date/time parameters', () {
+      expect(parameters['date-time'].asDateTime, equals(DateTime(1990)));
+    });
+
+    test('[].asDateTimeOr returns date/time parameters', () {
+      expect(parameters['date-time'].asDateTimeOr(DateTime(2014)),
+          equals(DateTime(1990)));
+    });
+
+    test('[].asDateTime fails for non-date/time parameters', () {
+      expect(
+          () => parameters['int'].asDateTime,
+          throwsInvalidParams('Parameter "int" for method "foo" must be a '
+              'string, but was 1.'));
+    });
+
+    test('[].asDateTimeOr succeeds for absent parameters', () {
+      expect(parameters['fblthp'].asDateTimeOr(DateTime(2014)),
+          equals(DateTime(2014)));
+    });
+
+    test('[].asDateTime fails for non-date/time parameters', () {
+      expect(
+          () => parameters['int'].asDateTime,
+          throwsInvalidParams('Parameter "int" for method "foo" must be a '
+              'string, but was 1.'));
+    });
+
+    test('[].asDateTime fails for invalid date/times', () {
+      expect(
+          () => parameters['string'].asDateTime,
+          throwsInvalidParams('Parameter "string" for method "foo" must be a '
+              'valid date/time, but was "zap".\n'
+              'Invalid date format'));
+    });
+
+    test('[].asUri returns URI parameters', () {
+      expect(parameters['uri'].asUri, equals(Uri.parse('https://dart.dev')));
+    });
+
+    test('[].asUriOr returns URI parameters', () {
+      expect(parameters['uri'].asUriOr(Uri.parse('http://google.com')),
+          equals(Uri.parse('https://dart.dev')));
+    });
+
+    test('[].asUri fails for non-URI parameters', () {
+      expect(
+          () => parameters['int'].asUri,
+          throwsInvalidParams('Parameter "int" for method "foo" must be a '
+              'string, but was 1.'));
+    });
+
+    test('[].asUriOr succeeds for absent parameters', () {
+      expect(parameters['fblthp'].asUriOr(Uri.parse('http://google.com')),
+          equals(Uri.parse('http://google.com')));
+    });
+
+    test('[].asUri fails for non-URI parameters', () {
+      expect(
+          () => parameters['int'].asUri,
+          throwsInvalidParams('Parameter "int" for method "foo" must be a '
+              'string, but was 1.'));
+    });
+
+    test('[].asUri fails for invalid URIs', () {
+      expect(
+          () => parameters['invalid-uri'].asUri,
+          throwsInvalidParams('Parameter "invalid-uri" for method "foo" must '
+              'be a valid URI, but was "http://[::1".\n'
+              'Missing end `]` to match `[` in host'));
+    });
+
+    group('with a nested parameter map', () {
+      late json_rpc.Parameter nested;
+      setUp(() => nested = parameters['map']);
+
+      test('[int] fails with a type error', () {
+        expect(
+            () => nested[0],
+            throwsInvalidParams('Parameter "map" for method "foo" must be an '
+                'Array, but was {"num":4.2,"bool":false}.'));
+      });
+
+      test('[].value returns existing parameters', () {
+        expect(nested['num'].value, equals(4.2));
+        expect(nested['bool'].value, isFalse);
+      });
+
+      test('[].value fails for absent parameters', () {
+        expect(
+            () => nested['fblthp'].value,
+            throwsInvalidParams('Request for method "foo" is missing required '
+                'parameter map.fblthp.'));
+      });
+
+      test('typed getters return correctly-typed parameters', () {
+        expect(nested['num'].asNum, equals(4.2));
+      });
+
+      test('typed getters fail for incorrectly-typed parameters', () {
+        expect(
+            () => nested['bool'].asNum,
+            throwsInvalidParams('Parameter map.bool for method "foo" must be '
+                'a number, but was false.'));
+      });
+    });
+
+    group('with a nested parameter list', () {
+      late json_rpc.Parameter nested;
+
+      setUp(() => nested = parameters['list']);
+
+      test('[string] fails with a type error', () {
+        expect(
+            () => nested['foo'],
+            throwsInvalidParams('Parameter "list" for method "foo" must be an '
+                'Object, but was [1,2,3].'));
+      });
+
+      test('[].value returns existing parameters', () {
+        expect(nested[0].value, equals(1));
+        expect(nested[1].value, equals(2));
+      });
+
+      test('[].value fails for absent parameters', () {
+        expect(
+            () => nested[5].value,
+            throwsInvalidParams('Request for method "foo" is missing required '
+                'parameter list[5].'));
+      });
+
+      test('typed getters return correctly-typed parameters', () {
+        expect(nested[0].asInt, equals(1));
+      });
+
+      test('typed getters fail for incorrectly-typed parameters', () {
+        expect(
+            () => nested[0].asBool,
+            throwsInvalidParams('Parameter list[0] for method "foo" must be '
+                'a boolean, but was 1.'));
+      });
+    });
+  });
+
+  group('with positional parameters', () {
+    late json_rpc.Parameters parameters;
+    setUp(() => parameters = json_rpc.Parameters('foo', [1, 2, 3, 4, 5]));
+
+    test('value returns the wrapped value', () {
+      expect(parameters.value, equals([1, 2, 3, 4, 5]));
+    });
+
+    test('[string] throws a parameter error', () {
+      expect(
+          () => parameters['foo'],
+          throwsInvalidParams('Parameters for method "foo" must be passed by '
+              'name.'));
+    });
+
+    test('[].value returns existing parameters', () {
+      expect(parameters[2].value, equals(3));
+    });
+
+    test('[].value fails for out-of-range parameters', () {
+      expect(
+          () => parameters[10].value,
+          throwsInvalidParams('Request for method "foo" is missing required '
+              'parameter 11.'));
+    });
+
+    test('[].exists returns true for existing parameters', () {
+      expect(parameters[0].exists, isTrue);
+    });
+
+    test('[].exists returns false for missing parameters', () {
+      expect(parameters[10].exists, isFalse);
+    });
+  });
+
+  test('with a complex parameter path', () {
+    var parameters = json_rpc.Parameters('foo', {
+      'bar baz': [
+        0,
+        1,
+        2,
+        {
+          'bang.zap': {'\n': 'qux'}
+        }
+      ]
+    });
+
+    expect(
+        () => parameters['bar baz'][3]['bang.zap']['\n']['bip'],
+        throwsInvalidParams('Parameter "bar baz"[3]."bang.zap"."\\n" for '
+            'method "foo" must be an Object, but was "qux".'));
+  });
+}
diff --git a/pkgs/json_rpc_2/test/server/server_test.dart b/pkgs/json_rpc_2/test/server/server_test.dart
new file mode 100644
index 0000000..b3166ce
--- /dev/null
+++ b/pkgs/json_rpc_2/test/server/server_test.dart
@@ -0,0 +1,203 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+
+import 'package:json_rpc_2/error_code.dart' as error_code;
+import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
+import 'package:test/test.dart';
+
+import 'utils.dart';
+
+void main() {
+  late ServerController controller;
+
+  setUp(() => controller = ServerController());
+
+  test('calls a registered method with the given name', () {
+    controller.server.registerMethod('foo', (json_rpc.Parameters params) {
+      return {'params': params.value};
+    });
+
+    expect(
+        controller.handleRequest({
+          'jsonrpc': '2.0',
+          'method': 'foo',
+          'params': {'param': 'value'},
+          'id': 1234
+        }),
+        completion(equals({
+          'jsonrpc': '2.0',
+          'result': {
+            'params': {'param': 'value'}
+          },
+          'id': 1234
+        })));
+  });
+
+  test('calls a method that takes no parameters', () {
+    controller.server.registerMethod('foo', () => 'foo');
+
+    expect(
+        controller
+            .handleRequest({'jsonrpc': '2.0', 'method': 'foo', 'id': 1234}),
+        completion(equals({'jsonrpc': '2.0', 'result': 'foo', 'id': 1234})));
+  });
+
+  test('Allows a `null` result', () {
+    controller.server.registerMethod('foo', () => null);
+
+    expect(
+        controller
+            .handleRequest({'jsonrpc': '2.0', 'method': 'foo', 'id': 1234}),
+        completion(equals({'jsonrpc': '2.0', 'result': null, 'id': 1234})));
+  });
+
+  test('a method that takes no parameters rejects parameters', () {
+    controller.server.registerMethod('foo', () => 'foo');
+
+    expectErrorResponse(
+        controller,
+        {
+          'jsonrpc': '2.0',
+          'method': 'foo',
+          'params': <String, dynamic>{},
+          'id': 1234
+        },
+        error_code.INVALID_PARAMS,
+        'No parameters are allowed for method "foo".');
+  });
+
+  test('an unexpected error in a method is captured', () {
+    controller.server
+        .registerMethod('foo', () => throw const FormatException('bad format'));
+
+    expect(
+        controller
+            .handleRequest({'jsonrpc': '2.0', 'method': 'foo', 'id': 1234}),
+        completion({
+          'jsonrpc': '2.0',
+          'id': 1234,
+          'error': {
+            'code': error_code.SERVER_ERROR,
+            'message': 'bad format',
+            'data': {
+              'request': {'jsonrpc': '2.0', 'method': 'foo', 'id': 1234},
+              'full': 'FormatException: bad format',
+              'stack': isA<String>()
+            }
+          }
+        }));
+  });
+
+  test('doesn\'t return a result for a notification', () {
+    controller.server.registerMethod('foo', (args) => 'result');
+
+    expect(
+        controller.handleRequest(
+            {'jsonrpc': '2.0', 'method': 'foo', 'params': <String, dynamic>{}}),
+        doesNotComplete);
+  });
+
+  test('includes the error data in the response', () {
+    controller.server.registerMethod('foo', (params) {
+      throw json_rpc.RpcException(5, 'Error message.', data: 'data value');
+    });
+
+    expectErrorResponse(
+        controller,
+        {
+          'jsonrpc': '2.0',
+          'method': 'foo',
+          'params': <String, dynamic>{},
+          'id': 1234
+        },
+        5,
+        'Error message.',
+        data: 'data value');
+  });
+
+  test('a JSON parse error is rejected', () {
+    return controller.handleJsonRequest('invalid json {').then((result) {
+      expect(jsonDecode(result), {
+        'jsonrpc': '2.0',
+        'error': {
+          'code': error_code.PARSE_ERROR,
+          'message': startsWith('Invalid JSON: '),
+          // TODO(nweiz): Always expect the source when sdk#25655 is fixed.
+          'data': {
+            'request': anyOf([isNull, 'invalid json {'])
+          }
+        },
+        'id': null
+      });
+    });
+  });
+
+  group('fallbacks', () {
+    test('calls a fallback if no method matches', () {
+      controller.server
+        ..registerMethod('foo', () => 'foo')
+        ..registerMethod('bar', () => 'foo')
+        ..registerFallback((params) => {'fallback': params.value});
+
+      expect(
+          controller.handleRequest({
+            'jsonrpc': '2.0',
+            'method': 'baz',
+            'params': {'param': 'value'},
+            'id': 1234
+          }),
+          completion(equals({
+            'jsonrpc': '2.0',
+            'result': {
+              'fallback': {'param': 'value'}
+            },
+            'id': 1234
+          })));
+    });
+
+    test('calls the first matching fallback', () {
+      controller.server
+        ..registerFallback((params) =>
+            throw json_rpc.RpcException.methodNotFound(params.method))
+        ..registerFallback((params) => 'fallback 2')
+        ..registerFallback((params) => 'fallback 3');
+
+      expect(
+          controller.handleRequest(
+              {'jsonrpc': '2.0', 'method': 'fallback 2', 'id': 1234}),
+          completion(
+              equals({'jsonrpc': '2.0', 'result': 'fallback 2', 'id': 1234})));
+    });
+
+    test('an unexpected error in a fallback is captured', () {
+      controller.server
+          .registerFallback((_) => throw const FormatException('bad format'));
+
+      expect(
+          controller
+              .handleRequest({'jsonrpc': '2.0', 'method': 'foo', 'id': 1234}),
+          completion({
+            'jsonrpc': '2.0',
+            'id': 1234,
+            'error': {
+              'code': error_code.SERVER_ERROR,
+              'message': 'bad format',
+              'data': {
+                'request': {'jsonrpc': '2.0', 'method': 'foo', 'id': 1234},
+                'full': 'FormatException: bad format',
+                'stack': isA<String>()
+              }
+            }
+          }));
+    });
+  });
+
+  test('disallows multiple methods with the same name', () {
+    controller.server.registerMethod('foo', () => null);
+    expect(() => controller.server.registerMethod('foo', () => null),
+        throwsArgumentError);
+  });
+}
diff --git a/pkgs/json_rpc_2/test/server/stream_test.dart b/pkgs/json_rpc_2/test/server/stream_test.dart
new file mode 100644
index 0000000..832e13c
--- /dev/null
+++ b/pkgs/json_rpc_2/test/server/stream_test.dart
@@ -0,0 +1,84 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+
+import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test/test.dart';
+
+void main() {
+  late StreamController requestController;
+  late StreamController responseController;
+  late json_rpc.Server server;
+
+  setUp(() {
+    requestController = StreamController();
+    responseController = StreamController();
+    server = json_rpc.Server.withoutJson(
+        StreamChannel(requestController.stream, responseController.sink));
+  });
+
+  test('.withoutJson supports decoded stream and sink', () {
+    server.listen();
+
+    server.registerMethod('foo', (json_rpc.Parameters params) {
+      return {'params': params.value};
+    });
+
+    requestController.add({
+      'jsonrpc': '2.0',
+      'method': 'foo',
+      'params': {'param': 'value'},
+      'id': 1234
+    });
+
+    expect(
+        responseController.stream.first,
+        completion(equals({
+          'jsonrpc': '2.0',
+          'result': {
+            'params': {'param': 'value'}
+          },
+          'id': 1234
+        })));
+  });
+
+  test('.listen returns when the controller is closed', () {
+    var hasListenCompeted = false;
+    expect(server.listen().then((_) => hasListenCompeted = true), completes);
+
+    return pumpEventQueue().then((_) {
+      expect(hasListenCompeted, isFalse);
+
+      // This should cause listen to complete.
+      return requestController.close();
+    });
+  });
+
+  test('.listen returns a stream error', () {
+    expect(server.listen(), throwsA('oh no'));
+    requestController.addError('oh no');
+  });
+
+  test('.listen can\'t be called twice', () {
+    server.listen();
+
+    expect(() => server.listen(), throwsStateError);
+  });
+
+  test('.close cancels the stream subscription and closes the sink', () {
+    // Work around sdk#19095.
+    responseController.stream.listen(null);
+
+    expect(server.listen(), completes);
+
+    expect(server.isClosed, isFalse);
+    expect(server.close(), completes);
+    expect(server.isClosed, isTrue);
+
+    expect(() => requestController.stream.listen((_) {}), throwsStateError);
+    expect(responseController.isClosed, isTrue);
+  });
+}
diff --git a/pkgs/json_rpc_2/test/server/utils.dart b/pkgs/json_rpc_2/test/server/utils.dart
new file mode 100644
index 0000000..c94628e
--- /dev/null
+++ b/pkgs/json_rpc_2/test/server/utils.dart
@@ -0,0 +1,70 @@
+// Copyright (c) 2014, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:json_rpc_2/error_code.dart' as error_code;
+import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc;
+import 'package:stream_channel/stream_channel.dart';
+import 'package:test/test.dart';
+
+/// A controller used to test a [json_rpc.Server].
+class ServerController {
+  /// The controller for the server's request stream.
+  final _requestController = StreamController<String>();
+
+  /// The controller for the server's response sink.
+  final _responseController = StreamController<String>();
+
+  /// The server.
+  late final json_rpc.Server server;
+
+  ServerController(
+      {json_rpc.ErrorCallback? onUnhandledError,
+      bool strictProtocolChecks = true}) {
+    server = json_rpc.Server(
+        StreamChannel(_requestController.stream, _responseController.sink),
+        onUnhandledError: onUnhandledError,
+        strictProtocolChecks: strictProtocolChecks);
+    server.listen();
+  }
+
+  /// Passes [request], a decoded request, to [server] and returns its decoded
+  /// response.
+  Future handleRequest(Object? request) =>
+      handleJsonRequest(jsonEncode(request)).then(jsonDecode);
+
+  /// Passes [request], a JSON-encoded request, to [server] and returns its
+  /// encoded response.
+  Future<String> handleJsonRequest(String request) {
+    _requestController.add(request);
+    return _responseController.stream.first;
+  }
+}
+
+/// Expects that [controller]'s server will return an error response to
+/// [request] with the given [errorCode], [message], and [data].
+void expectErrorResponse(
+    ServerController controller, Object? request, int errorCode, String message,
+    {Object? data}) {
+  dynamic id;
+  if (request is Map) id = request['id'];
+  data ??= {'request': request};
+
+  expect(
+      controller.handleRequest(request),
+      completion(equals({
+        'jsonrpc': '2.0',
+        'id': id,
+        'error': {'code': errorCode, 'message': message, 'data': data}
+      })));
+}
+
+/// Returns a matcher that matches a [json_rpc.RpcException] with an
+/// `invalid_params` error code.
+Matcher throwsInvalidParams(String message) =>
+    throwsA(isA<json_rpc.RpcException>()
+        .having((e) => e.code, 'code', error_code.INVALID_PARAMS)
+        .having((e) => e.message, 'message', message));