diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml
index f7fa413a..33a65535 100644
--- a/.github/workflows/main.yaml
+++ b/.github/workflows/main.yaml
@@ -66,6 +66,10 @@ jobs:
- ./.github/workflows/main.yaml
- ./.github/actions/dart_package
- packages/jwt/**
+ scoped:
+ - ./.github/workflows/main.yaml
+ - ./.github/actions/dart_package
+ - packages/scoped/**
- uses: dorny/paths-filter@v2
name: Verify Detection
diff --git a/README.md b/README.md
index 1c2c53f9..73292542 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Home of the Shorebird Tools
## Status
-Shorebird code push is in Open Beta! Instructions for install and usage are at
+Shorebird code push is in Open Beta! Instructions for install and usage are at
https://docs.shorebird.dev/
## Packages
@@ -25,6 +25,7 @@ This repository is a monorepo containing the following packages:
| [shorebird_code_push_protocol](packages/shorebird_code_push_protocol/README.md) | Dart library which contains common interfaces used by Shorebird CodePush |
| [discord_gcp_alerts](packages/discord_gcp_alerts/README.md) | Dart server which forwards GCP alerts to Discord |
| [jwt](packages/jwt/README.md) | Dart library for verifying Json Web Tokens |
+| [scoped](packages/scoped/README.md) | A simple dependency injection library built on Zones |
For more information, please refer to the documentation for each package.
diff --git a/packages/scoped/.gitignore b/packages/scoped/.gitignore
new file mode 100644
index 00000000..526da158
--- /dev/null
+++ b/packages/scoped/.gitignore
@@ -0,0 +1,7 @@
+# See https://www.dartlang.org/guides/libraries/private-files
+
+# Files and directories created by pub
+.dart_tool/
+.packages
+build/
+pubspec.lock
\ No newline at end of file
diff --git a/packages/scoped/README.md b/packages/scoped/README.md
new file mode 100644
index 00000000..7332e946
--- /dev/null
+++ b/packages/scoped/README.md
@@ -0,0 +1,24 @@
+# Scoped
+
+A simple dependency injection library built on Zones.
+
+## Quick Start
+
+```dart
+import 'package:scoped/scoped.dart';
+
+final value = create(() => 42);
+
+void main() {
+ runScoped(scopeA, values: {value});
+}
+
+void scopeA() {
+ print(read(value)); // 42
+ runScoped(scopeB, values: {value.overrideWith(() => 0)});
+}
+
+void scopeB() {
+ print(read(value)); // 0
+}
+```
diff --git a/packages/scoped/analysis_options.yaml b/packages/scoped/analysis_options.yaml
new file mode 100644
index 00000000..b388541f
--- /dev/null
+++ b/packages/scoped/analysis_options.yaml
@@ -0,0 +1 @@
+include: package:very_good_analysis/analysis_options.5.0.0.yaml
diff --git a/packages/scoped/coverage_badge.svg b/packages/scoped/coverage_badge.svg
new file mode 100644
index 00000000..499e98ce
--- /dev/null
+++ b/packages/scoped/coverage_badge.svg
@@ -0,0 +1,20 @@
+
diff --git a/packages/scoped/example/main.dart b/packages/scoped/example/main.dart
new file mode 100644
index 00000000..2921daf6
--- /dev/null
+++ b/packages/scoped/example/main.dart
@@ -0,0 +1,17 @@
+// ignore_for_file: avoid_print
+import 'package:scoped/scoped.dart';
+
+final value = create(() => 42);
+
+void main() {
+ runScoped(scopeA, values: {value});
+}
+
+void scopeA() {
+ print(read(value)); // 42
+ runScoped(scopeB, values: {value.overrideWith(() => 0)});
+}
+
+void scopeB() {
+ print(read(value)); // 0
+}
diff --git a/packages/scoped/lib/scoped.dart b/packages/scoped/lib/scoped.dart
new file mode 100644
index 00000000..71d37626
--- /dev/null
+++ b/packages/scoped/lib/scoped.dart
@@ -0,0 +1,4 @@
+/// A simple dependency injection library built on Zones
+library scoped;
+
+export 'src/scoped.dart';
diff --git a/packages/scoped/lib/src/scoped.dart b/packages/scoped/lib/src/scoped.dart
new file mode 100644
index 00000000..0cccdb64
--- /dev/null
+++ b/packages/scoped/lib/src/scoped.dart
@@ -0,0 +1,80 @@
+import 'dart:async';
+
+import 'package:meta/meta.dart';
+
+/// {@template scoped_ref}
+/// A reference to a scoped value.
+/// {@endtemplate}
+@immutable
+class ScopedRef {
+ /// {@macro scoped_ref}
+ ScopedRef(this._create) : _key = Object();
+
+ ScopedRef._(T Function() create, Object key)
+ : _create = create,
+ _key = key;
+
+ final T Function() _create;
+ final Object _key;
+ late final T _value = _create();
+
+ /// Overrides the value of the current [ScopedRef]
+ /// with the provided [create].
+ ScopedRef overrideWith(T Function() create) {
+ return ScopedRef._(create, _key);
+ }
+
+ @override
+ bool operator ==(Object other) {
+ if (identical(this, other)) return true;
+ if (runtimeType != other.runtimeType) return false;
+ if (other is ScopedRef) return _key == other._key;
+ return false;
+ }
+
+ @override
+ int get hashCode => _key.hashCode;
+}
+
+/// Creates a [ScopedRef] which can later be used to access the
+/// value, [T] returned by [create].
+ScopedRef create(T Function() create) => ScopedRef(create);
+
+/// Attempts to retrieve the value for the [ref].
+/// If [read] is called with a [ref] which is not available
+/// in the current scope, a [StateError] will be thrown.
+T read(ScopedRef ref) {
+ final value = (Zone.current[ref._key] as ScopedRef?)?._value;
+ if (value == null) {
+ throw StateError(
+ '''
+read(...) was called in a scope which does not contain a corresponding value for the provided ref.
+Did you forget to call: runScoped(() {...}, values: {value})?''',
+ );
+ }
+ return value;
+}
+
+/// Runs [body] within a scope which has access to the set of refs in [values].
+R runScoped(
+ R Function() body, {
+ Set> values = const {},
+}) {
+ return runZoned(
+ body,
+ zoneValues: {for (final value in values) value._key: value},
+ );
+}
+
+/// Runs [body] within a scope which has access to the set of refs in [values].
+R? runScopedGuarded(
+ R Function() body, {
+ required void Function(Object error, StackTrace stack) onError,
+ Set> values = const {},
+}) {
+ return runZonedGuarded(
+ body,
+ onError,
+ zoneValues: {for (final value in values) value._key: value},
+ );
+}
diff --git a/packages/scoped/pubspec.yaml b/packages/scoped/pubspec.yaml
new file mode 100644
index 00000000..3cc67c2a
--- /dev/null
+++ b/packages/scoped/pubspec.yaml
@@ -0,0 +1,15 @@
+name: scoped
+description: A simple dependency injection library built on Zones
+version: 0.1.0+1
+publish_to: none
+
+environment:
+ sdk: ">=3.0.0 <4.0.0"
+
+dependencies:
+ meta: ^1.0.0
+
+dev_dependencies:
+ mocktail: ^0.3.0
+ test: ^1.0.0
+ very_good_analysis: ^5.0.0
diff --git a/packages/scoped/test/src/scoped_test.dart b/packages/scoped/test/src/scoped_test.dart
new file mode 100644
index 00000000..322f0a5e
--- /dev/null
+++ b/packages/scoped/test/src/scoped_test.dart
@@ -0,0 +1,86 @@
+// ignore_for_file: prefer_const_constructors
+import 'package:scoped/scoped.dart';
+import 'package:test/test.dart';
+
+void main() {
+ group('Scoped', () {
+ test('read throws StateError when ref is not available', () {
+ final value = create(() => 42);
+ expect(() => read(value), throwsStateError);
+ });
+
+ test('calls onError when uncaught exception occurs', () {
+ final value = create(() => 42);
+ late final Object exception;
+ runScopedGuarded(
+ () => read(value),
+ onError: (error, _) => exception = error,
+ );
+ expect(exception, isNotNull);
+ });
+
+ test('read accesses the value when ref is available', () {
+ final value = create(() => 42);
+ runScoped(
+ () => expect(read(value), equals(42)),
+ values: {value},
+ );
+ });
+
+ test('value is computed lazily and cached', () {
+ var createCallCount = 0;
+ final value = create(() {
+ createCallCount++;
+ return 42;
+ });
+
+ expect(createCallCount, equals(0));
+
+ runScoped(
+ () {
+ expect(read(value), equals(42));
+ expect(read(value), equals(42));
+ expect(read(value), equals(42));
+ },
+ values: {value},
+ );
+
+ expect(createCallCount, equals(1));
+ });
+
+ test('value can be overridden', () {
+ final value = create(() => 42);
+
+ runScoped(
+ () {
+ expect(read(value), equals(42));
+
+ runScoped(
+ () => expect(read(value), equals(0)),
+ values: {value.overrideWith(() => 0)},
+ );
+ },
+ values: {value},
+ );
+ });
+
+ test('overrides are considered equal', () {
+ final value = create(() => 42);
+ final override = value.overrideWith(() => 0);
+ expect(value, equals(override));
+ expect(value.hashCode, equals(override.hashCode));
+ });
+
+ test('same instance is equal', () {
+ final value = create(() => 42);
+ expect(value, equals(value));
+ });
+
+ test('different instances are not equal', () {
+ final valueA = create(() => 42);
+ final valueB = create(() => 42);
+ expect(valueA, isNot(equals(valueB)));
+ expect(valueA.hashCode, isNot(equals(valueB.hashCode)));
+ });
+ });
+}