Drive a machine instance from React
createMachineServer(endpoint, { machine, documents, socket }), from @traxsharp/state-machine/client, binds one exposed
machine's typed operations and its push stream into the MachineServer that
useServerMachine (from @traxsharp/state-machine/react) and its framework-free ServerMachineController drive an instance
through. The server applies every transition, stores it and answers what it stored; the twin only predicts.
Bind the server and use the hook
import { createClient } from 'graphql-ws';
import { createMachineServer, persistedMessageReplacer } from '@traxsharp/state-machine/client';
import { useServerMachine } from '@traxsharp/state-machine/react';
import documents from './topic-map.documents.json';
const server = createMachineServer('/trax/graphql', {
headers: () => ({ 'X-Api-Key': key }),
machine: topicMap,
documents,
socket: createClient({
url: 'wss://host/trax/graphql',
connectionParams: { apiKey: key },
lazy: true,
retryAttempts: Infinity,
jsonMessageReplacer: persistedMessageReplacer,
}),
});
function Wizard({ id }: { id?: string }) {
const map = useServerMachine(topicMap, { server, id }); // without id, it starts an instance
if (map.error) return <p>{map.error.code}</p>; // gone, client-outdated (reload), or CONNECTION_CHANGED
return <button disabled={!map.can('Back')} onClick={() => map.send('Back')}>Back</button>;
}What createMachineServer does
- Sends every operation by its persisted id, through the same executor as
createMachineExecutor, so the same document checks apply.documentsmust hold the push document (changed), or it throws when it is built. - Subscribes by id over the socket you pass, with an empty document: graphql-ws requires one, and a host that
runs persisted documents refuses one beside an id, so pass
persistedMessageReplaceras the client'sjsonMessageReplacerand it leaves the emptyqueryout. The package does not depend on graphql-ws; any socket with itssubscribe(payload, sink)shape (PersistedSubscriptionSocket) does. - Maps the stream: a snapshot with its
rowIdandrevision;gone: trueas gone, and theNOT_FOUNDa missing id is answered with as gone withnotFound;CLIENT_OUTDATEDas client-outdated;reload: trueas a read. A socket error whosecodeis interminalStreamErrorsends the stream for good: it reaches the controller asended, and is not subscribed again. A request rejected with one of those codes is not a transport failure either: the server'sendedBynames it, and the controller stops on it the same way. The generated client listsCONNECTION_CHANGEDthere, which a server made before the lastconfigureTraxrefuses every request and stream with. A stream that ends any other way (its host stopped, the socket gave up,TOO_MANY_SUBSCRIPTIONS) is subscribed again afterresubscribeDelays(1, 2, then every 5 seconds); the new stream's first event is the instance as it is then, so nothing is read again. - Adds
discard(id, requestId), for a page that throws an instance away.
What useServerMachine does
useServerMachine(machine, { server, id?, startInput?, startRequestId?, startStorageKey?, newRequestId?, retryDelays? }):
- Opens or starts. With
idit subscribes, then reads the instance. Without one it callsstartonce, understartRequestIdwhen you keep one (across a reload, so a start that was in flight answers with the instance it created) and a minted one otherwise; StrictMode's second mount reuses it. A newstartRequestIdstarts a new instance. A start request id the hook minted is kept insessionStorageunderstartStorageKey(by defaulttrax-start:<machine id>;nullkeeps nothing) until the start answers, so a reload while it is unanswered, or after it failed, sends the same id and gets the instance the first request created. Give each hook that starts the same machine on one page its own key. WithoutsessionStoragenothing is kept. - Predicts, then confirms.
send(trigger, input)queues the event behind any earlier one of the instance, sends it under its own requestId with the twin's prediction asclientResult, and shows the prediction until the answer or a push replaces it. A refusal or a transport failure rolls it back and setsproblem;CLIENT_DIVERGENCEalso reads the instance again.SERVER_OUTDATEDis retried under the same requestId after each ofretryDelays. The twin holds no handler for a custom rule, so it predicts nothing that needs one: such an event is sent without a prediction, and the answer is what the hook shows. An effect-bound event (its edge runs an effect, such as a charge) is sent with no input and no prediction, since the effect produces what its edge records, and nothing queued after it is predicted until it answers;canreads its guard with every rule on the input, and every custom rule, counted as passing, so a rule on the context still decides. - Trusts the server on custom rules. A snapshot the server sends is checked for its shape and every built-in
rule; a custom rule the twin has no handler for counts as passed, because the server evaluated it. A snapshot that
fails a check it can make stops the hook with
CLIENT_OUTDATED. - Orders by revision. Anything at or below the revision held is dropped, the hook's own answers included; it
reads the instance again only after
reload, an incarnation change or aCLIENT_DIVERGENCE. - Stops for good on
gone,CLIENT_OUTDATED,ended, or a request the server'sendedBynames:statusisstopped,error.codesays which (withendedorendedBy, the code given, such asCONNECTION_CHANGED), nothing more is sent,retry()answersstopped, and a start request id the hook kept is forgotten. WithGONE,error.deletedis true when the stream said the instance was deleted or expired, and false when the server answeredNOT_FOUND, which it answers alike for a missing instance and another caller's.dueAtis shown, never fired: the server runs a state's timer and pushes the move. It is taken from every answer and push the hook accepts, and null always clears it: the state has no timer, or the server gave up on it and cleared it. An answer that does not carry the field, or cannot know it (dueAtKnown: false, a replay whose recorded revision the row has moved past, whichcreateMachineServerdelivers with nodueAt), keeps the due time held while the state stays the same; in a timed state it otherwise reads the instance once. - Retries on request. A transport failure is not retried by itself:
retry()resends the failed start, or the last event that failed (in transport, orSERVER_OUTDATEDpast every retry), under its first request id, so the server replays its result rather than applying it twice. A later event the server confirms supersedes the failed one: resending it then would apply it after the later one, soretry()answersNOTHING_TO_RETRY. A refused start is not resent.sendagain would be a new request. - Moves with its options. A new
server,machine,idorstartRequestIdcloses the old instance's stream and opens the new one. From that render on the hook isloadingand sends nothing, so a page whose server changed (another user signed in) never shows the instance before it under the new caller. - Mints request ids with
newRequestId,crypto.randomUUIDby default, which never makes one the server refuses. A custom one must keep to the server's rule: 1 to 200 characters, not blank, with no control character and no unpaired surrogate; anything else is answeredINVALID_REQUEST_ID.
It returns status, id, snapshot, state, context, revision, dueAt, pending, problem, error, send,
retry, can and available. The Recovery sample's topic map is built on it
(Recovery).