winche_core 0.1.0
winche_core: ^0.1.0 copied to clipboard
Core primitives for the Winche Dart stack: the app registry, the session every service is bound to, the service contracts each Winche package implements, and the shared identity and options models.
winche_core #
Core primitives for the Winche Dart stack — the piece every other winche_*
package depends on, and the only one that knows nothing about them.
It provides three things:
- An app registry.
Winche.initializeApp()creates a namedWincheAppholding your backend configuration. Services attach themselves to it by constructor, and it dispatches sessions to them as sign-in state changes. - A session model. One service announces who is signed in; core turns
that into a
WincheSessionand hands it to every other service, in order, waiting for each to finish before the next session can begin. - Service contracts.
WincheAuthServicefor the service that produces identity,WincheDatabaseService/WincheStorageServicefor the services that consume it.
What it deliberately is not: a service locator. WincheApp does not hand
out services by type — a package that needs its own service reaches it
through whatever instance / instanceFor accessor that package's own class
exposes, not through the app. Core also defines no sign-in surface — no
signIn, no signOut, no credentials type — because how a backend
authenticates is that backend's business, not core's.
Pure Dart, no Flutter dependency — the same code runs in a Flutter app, a server, or a CLI.
Installation #
dependencies:
winche_core: ^0.1.0
Getting started #
import 'package:winche_core/winche_core.dart';
void main() {
Winche.initializeApp(
options: WincheOptions(
databaseEndpoint: Uri.parse('wss://api.winche.dev/documents/ws'),
storageEndpoint: Uri.parse('https://api.winche.dev/files'),
directoryResolver: () async => (await getApplicationSupportDirectory()).path,
),
);
// Registering a service attaches it to the app passed to its constructor.
final auth = MyAuthService(Winche.app);
MyDatabaseService(Winche.app);
// However MyAuthService exposes signing in, it ends by calling
// notifyIdentityChanged — core takes it from there.
auth.signInWithPassword(email: '…', password: '…');
}
initializeApp is synchronous: it constructs the app, registers it, and
returns — there is nothing to await. Most programs need exactly one app, and
Winche.app returns it from anywhere:
Winche.initializeApp(); // registered as 'default'
Winche.app; // the same instance, from anywhere
Talk to two backends at once by naming them:
final prod = Winche.initializeApp(name: 'prod', options: prodOptions);
final staging = Winche.initializeApp(name: 'staging', options: stagingOptions);
initializeApp is also idempotent — calling it again with the same name
returns the existing app. Passing different options for an existing name
throws a StateError rather than silently ignoring them; omitting options
on a repeat call always returns the existing app unchanged.
Look an app up by name with Winche.appFor(name) (throws if absent) or
Winche.maybeAppFor(name) (null if absent). Winche.apps lists every
currently initialized app.
Tear an app down with Winche.deinitializeApp([name]), which disposes every
service attached to it and removes it from the registry. Winche.deinitializeAll()
does the same for all of them — useful in a test tearDown.
Options #
WincheOptions carries the configuration more than one service needs, and
nothing a single service could own for itself:
| Field | Used by |
|---|---|
databaseEndpoint |
the WebSocket URI winche_database dials |
storageEndpoint |
the REST URI winche_storage issues requests against |
directoryResolver |
resolves the parent directory every service creates its on-disk state under |
All three are optional — supply the ones the app actually uses. A service asked to work without the endpoint or resolver it needs throws.
Core hands out the directory root and nothing more. How a service composes a
path beneath it — a subdirectory per identity, a filename for its cache — is
that service's own business, not core's; the same goes for what it names an
IndexedDB database on the web, where directoryResolver typically has
nothing to resolve. This is deliberate: it means adding a knob to
winche_database never forces a winche_core release, because the knob
lives on that service, configured however that package chooses.
WincheOptions compares its endpoints by value, but directoryResolver by
identity (Dart compares closures that way) — which is what initializeApp
checks when refusing a repeat call with disagreeing options. Hoist the
options into a variable if they need to be passed more than once.
Sessions #
A session (WincheSession) is who is signed in, together with the means to
fetch a token for as long as that stays true. Core constructs and invalidates
sessions itself; nothing outside core ever calls WincheSession's
constructor.
isActive exists because a session can outlive its usefulness without a
consuming service knowing it yet: a user switch, or a sign-out, invalidates
the old session object before the new one exists, but a consumer holding a
reference to the old one — mid-request, say — has no other way to notice.
Calling session.token() on an inactive session throws WincheSessionExpired,
which is the package's only exception type:
try {
final token = await session.token();
// use token
} on WincheSessionExpired {
// this operation outlived its session; abandon it
}
Two things about token() are worth knowing before you call it:
- The first check is synchronous — if the session is already inactive,
the exception is thrown before a
Futureeven exists, sosession.token().catchError(...)will never see it. Always wrap theawaitintry/catch. - It checks again after the fetch resolves, because the fetcher underneath
reads whoever is signed in at the moment it runs — not at the moment
token()was called. Without the second check, a call that straddled a user switch could return the next user's token under the previous user's name. That is the exact cross-identity contaminationWincheSessionExpiredexists to prevent, so treat it as a normal, expected outcome — not a bug — and let the operation abandon its work when it happens.
Writing an auth service #
Extend WincheAuthService and expose whatever sign-in shape your backend
needs — password, OIDC redirect, refresh token, device code, anything. There
is no signIn or signOut on the base class to override; add your own
methods with whatever names and parameters make sense, and call
notifyIdentityChanged / notifyTokenRotated — both @protected, so only
subclasses can call them — to tell core what happened.
final class MyAuthService extends WincheAuthService {
MyAuthService(super.app);
WincheIdentity? _identity;
String? _token;
@override
WincheIdentity? get activeIdentity => _identity;
@override
Future<String?> getAuthToken({bool forceRefresh = false}) async {
if (_identity == null) return null;
if (forceRefresh) _token = await _refresh();
return _token;
}
Future<void> signInWithPassword({required String email, required String password}) async {
final result = await _backend.signIn(email, password);
_identity = WincheIdentity(result.userId, claims: result.claims);
_token = result.token;
notifyIdentityChanged(_identity);
}
Future<void> signOut() async {
await _backend.signOut();
_identity = null;
_token = null;
notifyIdentityChanged(null);
}
Future<String> _refresh() async => (await _backend.refresh()).token;
@override
Future<void> dispose() async {
// release backend resources here
await super.dispose(); // always last
}
}
Two rules bind an implementation, neither one the type system can enforce:
notifyIdentityChanged(null)means an authoritative sign-out — explicit logout, an invalid refresh token, an expired session. A transient refresh failure must not announce anything, or a flaky connection would tear down every session on the device.getAuthTokenreturns null when signed out and throws when a token cannot currently be obtained. It must never report a transient failure as a sign-out either.
Call notifyIdentityChanged only after activeIdentity already reflects the
new value — core reads it synchronously as part of handling the
notification.
Writing a consuming service #
Extend WincheDatabaseService, WincheStorageService, or
WincheNonAuthService directly for a category core does not already name,
and implement onSessionChanged:
final class MyDatabaseService extends WincheDatabaseService {
MyDatabaseService(super.app);
MyStore? _store;
@override
Future<void> onSessionChanged(WincheSession? session) async {
await _store?.close();
_store = null;
if (session == null) return; // signed out; stay closed
final root = await app.options?.directoryResolver?.call();
final directory = root == null ? null : '$root/${session.identity.storageKey}';
_store = await MyStore.open(
directory: directory,
tokenProvider: session.token, // read at request time, not captured now
);
}
@override
Future<void> onTokenChanged() async {
// the identity did not change, only the token did — nudge, don't rebuild
await _store?.notifyTokenRotated();
}
@override
Future<void> dispose() async {
await _store?.close();
await super.dispose(); // always last
}
}
A few things about this shape are load-bearing:
-
Pass
session.token, not a token value. A snapshot taken insideonSessionChangedwould go stale the moment the token rotates;token()re-fetches (and re-checks the session is still active) every time it is called. -
Compose the storage path from the resolved root plus
identity.storageKey— notidentity.id. Core hands out the root only; deciding the subdirectory, and what to name an IndexedDB database on the web where there is no filesystem, is this service's job. But usestorageKeyfor the name:idvalidation cannot rule out case collisions, and on NTFS and default macOS APFSUser1anduser1are two identities that resolve to one directory. Backends really do issue case-sensitive ids — Firebase UIDs are mixed-case base62 — so usingidraw would let one user read another's cached state.storageKeyleaves an all-lowercase id untouched and folds the case out of anything else. -
onTokenChangedcarries no payload on purpose. It fires when the identity stayed the same but the token rotated; a snapshot passed here would just be one this service should ignore in favor of callingsession.token()itself, so the hook does not tempt anyone into keeping one. -
Get your per-app singleton from
WincheService.instanceFor, not from a static map of your own:static MyDatabase get instance => instanceFor(Winche.app); static MyDatabase instanceFor(WincheApp app) => WincheService.instanceFor(app, () => MyDatabase._(app));It returns the instance already attached to that app, or builds one. A hand-rolled cache keyed by
WincheApphas to remember to evict itself indispose(), and silently gets it wrong when it forgets — deinitializing an app and initializing a new one under the same name then hands back the previous, disposed service. Core's registry is per-app and cleared on teardown, so routing through it makes that bug unrepresentable rather than merely documented.
Registration order #
Registration order does not matter. A consumer created before the app's auth
service is simply not bound to anything yet; the moment the auth service
registers and announces its activeIdentity, core catches every
already-registered consumer up to it. Symmetrically, a consumer registered
after sign-in already happened is dispatched to the current session as
soon as it registers — nobody has to remember to call anything to "catch it
up" by hand.
Failure handling #
Core awaits each consumer's onSessionChanged before moving on, so an
outgoing session is always fully torn down before the incoming one is built:
two session swaps can never interleave inside one consumer, and consumers
never see the intermediate identity of a swap that happened while an earlier
one was still finishing.
Rapid changes reconcile to the latest value rather than queueing every one.
An A -> B -> C sequence dispatched faster than consumers can keep up ends
at C; B is simply never dispatched to anyone. A -> null -> A collapses
the same way, to "still A" — from a consumer's point of view nothing
happened, because nothing needs to.
If a consumer's hook throws, core reports it on app.errors (a broadcast
Stream<WincheServiceError>, open until the app is disposed) and leaves that
one service unbound — it is retried automatically on the next session
change — while every other registered service still receives its dispatch
normally. Core does not track a service's health or latch the failure; a
service that fails once and then succeeds on the next change is
indistinguishable from one that never failed.
Winche.app.errors.listen((error) {
log.warning('${error.service.runtimeType} failed: ${error.error}', error.stackTrace);
});
Lifecycle #
dispose() is async everywhere, idempotent, and any override must call
super.dispose() last. Disposing a service deregisters it from its app;
disposing an app disposes every service it holds and removes itself from the
registry, so a disposed app is never reachable through Winche.appFor.
Consumers are disposed before the auth service — deterministically, regardless of registration order — so a consumer can still read a token while shutting down. Every service is disposed even if an earlier one throws; the first error is rethrown once teardown finishes, so one broken service's teardown does not leave the rest of the app's resources leaked.
Additional information #
Issues and contributions are welcome on the project tracker.
License #
Released under the MIT License.