pylon
The wall between a project and the REST API answering it right now.
Pylon exists so that unplugging one REST API and plugging in another changes one line of wiring and nothing else. It is built on one refusal: pylon understands neither side. It does not know what your resources are, what your credentials look like, or what can go wrong. It knows how to let something through, or not.
Everything here is therefore either a shape you fill in, or a policy that is identical whatever fills it in. Nothing infers, nothing recognises a name, nothing assumes a format. Where a decision belongs to you, it is a required argument, never a default that happens to be right most of the time.
The two halves
The barrier, which your contract sees: Result, Fault, FaultMapper, Backend,
SdkHandle, Config. This is what a service layer touches, and it does not change when
the backend does.
The toolkit, which only adapters see: RestClient, CredentialManager, CallGuard,
SocketChannel, ChannelKeeper, HealthMonitor, Preference, KeyValueStore,
Observable, Reporter, Backoff. Each is a mechanism every backend would otherwise
rewrite, and rewrite worse the second time.
package:fiber_pylon/fiber_pylon_io.dart carries the one piece that needs dart:io, a SocketLink
over a WebSocket. It is separate so that importing pylon does not stop a project from
compiling for the web.
The one thing pylon assumes
That both ends of the swap are REST. RestClient speaks HTTP and RestMethod is a closed
list, because those are a protocol's own words rather than a guess about a project.
What stays outside is every judgement the protocol does not make: which statuses are failures, what an error body looks like, how a call is authenticated, which failure deserves a renewal. Each of those is asked for.
Where the boundary actually is
It is Fault, and what makes it work is that pylon never reads it. A fault carries a
signal from the adapter's own vocabulary, an enum the adapter declares.
enum AdminSignal {
unauthorized, forbidden, vpnRequired, notFound,
tooManyRequests, noRoute, timedOut, nameEmpty, duplicateCall, unknown,
}
throw const Fault(AdminSignal.nameEmpty);
Pylon offers no list of failure kinds, because any list would be a guess about the projects
it has not met. unauthorized does not mean the same thing everywhere, and in a system
with no authentication it means nothing at all.
A FaultMapper turns that signal into the error one operation declares. Both sides of the
table are typed, so a member that does not exist does not compile and a rename is caught
rather than discovered at runtime.
const createBrand = FaultMapper<AdminSignal, CreateBrandError>(
signals: {
AdminSignal.unauthorized: CreateBrandError.unauthorized,
AdminSignal.forbidden: CreateBrandError.notPermitted,
AdminSignal.vpnRequired: CreateBrandError.vpnRequired,
AdminSignal.tooManyRequests: CreateBrandError.tooManyRequests,
AdminSignal.noRoute: CreateBrandError.networkError,
AdminSignal.nameEmpty: CreateBrandError.nameEmpty,
},
fallback: CreateBrandError.unknown,
);
The table belongs next to the adapter, not to the contract, because it is the translation of one server's vocabulary. Swapping servers means writing new tables beside the new adapter; the contract, and everything above it, does not move.
Wherever pylon has to act on a failure it is handed a set of signals rather than left to
interpret one. CredentialManager is told which signals mean the credential is dead,
CallGuard which are worth renewing for, and even the refusal CallGuard issues for a
duplicate call is named by you.
The REST client
RestClient is the whole of what makes a backend a REST backend: a base URL, a way to name
failures, and the headers to carry.
final client = RestClient<AdminSignal>(
baseUrl: Uri.parse('https://admin.example.test/v1/admin/'),
classifier: const AdminClassifier(),
guard: guard,
headers: (request) async => {
if (credentials.credential case final session?)
'authorization': 'Bearer ${session.accessToken}',
'x-app-key': appKey,
},
);
final response = await client.send(
RestRequest(path: 'brand/$id', shareKey: 'brand/$id'),
);
final brand = response.map['data'];
The body is not unwrapped. An envelope like {"data": ...} belongs to one server's
conventions, so an adapter reads response.map['data'] itself rather than pylon deciding
that every server has an envelope.
The classifier is the only place in a REST adapter that reads a status code.
class AdminClassifier implements RestClassifier<AdminSignal> {
const AdminClassifier();
@override
AdminSignal? ofResponse(RestResponse response) {
if (response.status >= 200 && response.status < 300) return null;
final body = response.body;
final code = body is Map<String, dynamic> ? body['code'] : null;
if (code == 'vpn_required') return AdminSignal.vpnRequired;
return switch (response.status) {
401 => AdminSignal.unauthorized,
403 => AdminSignal.forbidden,
429 => AdminSignal.tooManyRequests,
_ => AdminSignal.unknown,
};
}
@override
AdminSignal ofTransport(Object error, StackTrace stackTrace) =>
error is TimeoutException ? AdminSignal.timedOut : AdminSignal.noRoute;
}
Even status >= 400 is not supplied. It looks universal until it meets the API that
answers 200 with an error payload, or the one where 404 is an ordinary answer meaning
the resource does not exist yet. Both exist, and both are entitled to say so here.
Credentials
Pylon does not define what a session is. The credential is your own type, opaque, and the only things the renewal policy needs to know are asked for.
final credentials = CredentialManager<Ticket, AdminSignal>(
store: StoredCredential<Ticket>(
preferences,
key: 'ticket',
encode: (ticket) => ticket.serialise(),
decode: Ticket.parse,
),
refresher: AdminRefresher(),
expiresAt: (ticket) => ticket.expiresAt,
fatalSignals: {AdminSignal.unauthorized, AdminSignal.forbidden},
);
The backend writes refresh(current) and nothing else. The rest is policy, and it is the
same everywhere: renew ahead of expiry rather than after a call has already failed,
collapse simultaneous attempts into one exchange so a screen firing six requests does not
burn six refresh tokens, keep a failed attempt pending instead of dropping it, and revoke
on the signals it was told mean the credential is dead.
fatalSignals has no default on purpose. Pylon cannot know which of an adapter's signals
means the credential was rejected rather than that the server was unreachable, and getting
it wrong is expensive in both directions: too wide a set signs people out during an outage,
too narrow a one leaves them retrying a credential that is gone.
A project with no notion of a credential never builds one of these. Nothing else requires it.
Calls that collide
Two calls that would collide are handled in one of two ways, and they are not interchangeable.
dedupKey refuses the second, which is what protects a mutation from being submitted
twice. shareKey joins it to the first and hands both the same answer, which turns six
widgets asking for the same resource into one request. A read wants the second, a creation
wants the first, and only the caller knows which it is.
client.send(RestRequest(path: 'brand/$id', shareKey: 'brand/$id'));
client.send(RestRequest(
path: 'brand',
method: RestMethod.post,
body: payload,
dedupKey: 'create-brand',
));
A shared key is released as soon as the call settles, so this coalesces what overlaps in time and caches nothing.
Realtime
SocketChannel carries the policy, SocketProtocol carries the frames. The split is the
same one as between RestClient and RestClassifier: moving to another server means
writing the protocol, and nothing else.
final channel = SocketChannel<RealtimeEvent>(
protocol: PhoenixProtocol(credentials, appKey),
opener: WebSocketLink.opener(),
);
final keeper = ChannelKeeper(channel);
await keeper.start();
await keeper.join('admins');
What the policy holds is exactly what a socket gets wrong.
A link that died without saying so. A connection that drops silently, and they very
often do when a network changes underneath, leaves a socket that reports nothing: no close,
no error, no frames. Waiting for a close that will never come is how an app sits there
looking connected and receiving nothing. SocketChannel sends a heartbeat every
heartbeatInterval and gives up on the link if nothing at all arrives for silence,
because traffic is the only evidence a link is alive.
A subscription that was refused. A server that turns down a join usually says so in a
frame nobody reads, and the result looks exactly like a quiet topic. Here a refusal is
reported and the name is not recorded: channel.confirmed holds only what the server
confirmed.
Two connections at once. Opening while an open is already under way is easy to do from a reconnect timer and a credential change arriving together, and it leaves an orphaned socket whose frames still arrive. Opening is single-flight.
Reconnecting and rejoining are not here: that is ChannelKeeper, which already holds them
for every kind of channel, with a jittered backoff.
What is deliberately absent
No token format, no notion of a session, no list of error kinds, no envelope around a response body, no rule about which status means what, no frame format, no environment reading, no code generation. Every one of those belongs to one server rather than to REST, and a wall that took a side would stop being a wall.
Verifying
bash tool/test.sh
That resolves, analyses, checks the formatting and runs the suite, which is what the CI runs too.
Licence
Mozilla Public License 2.0. See LICENSE, and CONTRIBUTING.md for what that means in one
paragraph.
Libraries
- fiber_pylon
- The wall between a project and whatever is currently answering it.
- fiber_pylon_io
- The pieces of pylon that need
dart:io.