What a module receives
The ModuleClient a module root is handed by props, the options createChatfuelClient takes, and the helpers that ship in the same barrel.
A module root is handed a ModuleClient through props and never builds one, so nothing in module
code knows about tokens, proxy prefixes or which host it is running inside.
What a module may call
| Member | Shape | What it does |
|---|---|---|
query | (doc, variables, opts?) => Promise<TData> | Runs a generated document over HTTP. Throws when the envelope carries errors[]; partial data rides on the error. |
mutate | (doc, variables, opts?) => Promise<TData> | The same function under a second name — kept separate for intent, identical in behaviour. Variables are stripped of __typename either way. |
subscribe | (doc, variables, observer) => () => void | Opens a subscription and returns the unsubscribe, which maps 1:1 onto a useEffect cleanup. One lazy shared WebSocket serves every subscription in the app. |
onReconnect | (cb) => () => void | Fires after that socket re-establishes following an abnormal close — refetch every query backing a live view, because the server does not replay what you missed. A clean idle close does not fire it. |
uploadFile? | (botId, file, fileType, pluginId?) => Promise<UploadedFile> | REST upload, attached by the host when there is an upload path. Absent means no upload UI — GraphQL never accepts file bytes. |
proxyFetch? | (path, init?) => Promise<Response> | An authenticated call to one of the proxy's own routes, not to Chatfuel. path is relative to the proxy prefix. |
The last two are optional because the host decides whether to attach them. proxyFetch being
present does not promise that a given route exists either — the answer to that is the 404 the route
itself gives. A module offers the half of itself that answers and does without the other half; it
never asks which modules the deployment installed.
execute (the envelope without the throw), iterate (the async-iterable form of subscribe) and
dispose are on the full ChatfuelClient and deliberately not on ModuleClient. The only document
type a module ever names is TypedDoc, so the generated shape can change — it has already gone from
an AST to a printed string — without a module noticing.
Building one
createChatfuelClient is the factory, and the host app is the only place that calls it.
Prop
Type
This is the whole of the shell's factory — the file every scaffolded app carries, and the one place that knows where the proxy is mounted:
export function createAppClient(options: AppClientOptions = {}): ModuleClient {
const getAuthHeader = async (): Promise<string | undefined> => {
const value = await options.getAccessToken?.();
return value ? `Bearer ${value}` : undefined;
};
const client = createChatfuelClient({
url: '/chatfuel/graphql',
wsUrl: '/chatfuel/graphql',
token: options.getAccessToken,
onSessionError: options.onSessionError,
});
return {
query: client.query,
mutate: client.mutate,
subscribe: client.subscribe,
onReconnect: client.onReconnect,
uploadFile: (botId, file, fileType, pluginId) => uploadFile({ botId, file, fileType, pluginId, getAuthHeader }),
proxyFetch: async (path, init) => {
const auth = await getAuthHeader();
const headers = new Headers(init?.headers);
if (auth) headers.set('authorization', auth);
return fetch(`${PROXY_PREFIX}${path.startsWith('/') ? path : `/${path}`}`, { ...init, headers });
},
};
}The token in the browser
token takes a string or a getter, and the getter is resolved per request and per socket connect,
so a rotating credential works without rebuilding the client. A getter that answers undefined
sends no Authorization header at all.
In the browser that value is never the Chatfuel token. It is the user's own session token, sent so
the proxy's gate can check it; the proxy strips it and attaches the Chatfuel Authorization
server-side, and the WebSocket relay reads it out of the connection-init payload and opens its own
upstream connection. See the token boundary.
When the session lapses
onSessionError fires when the gate answers AuthSessionRequired (the session is missing or
expired) or AuthTenantForbidden (the caller is not a member of this deployment's tenant). It fires
once per lapse — the next successful call re-arms it — and it fires from the HTTP and the WebSocket
path alike, so the shell can refresh the session or send the user to sign-in without every module
handling it. Neither code means the Chatfuel token needs rotating; that one is Unauthorized, and
the whole family is on the errors page.
Timeouts and retries
The default HTTP timeout is 30 000 ms. opts.timeoutMs overrides it for one request, which a
handful of Chatfuel mutations need: instagramAccountPublishReel sits on Instagram's transcoder for
up to five minutes, and raising the client-wide default so four operations can finish would mean a
dead upstream is felt five minutes late everywhere. The timeout signal and your own opts.signal
are AbortSignal.any-ed together, so an abort stays an abort.
Retries only exist when you pass throttle, and only for network failures, 429 and 5xx. A
GraphQL error is never retried. A 429 is always surfaced as an HTTP error rather than an envelope,
which is what lets the throttle see it.
Helpers in the same barrel
Everything ~api exports is in packages/api-client/src/index.ts; these are the ones worth knowing
before you write the same code yourself.
| Helper | What it is for |
|---|---|
paginate(client, doc, baseVars, select) | An async generator over a Relay-ish connection, one page of nodes per yield, stopping when hasNextPage is false. Sequential by construction — never fetch a page while one is in flight. baseVars already carries the page-size variable; the cursor variable is after unless you say otherwise. |
isInvalidCursorError(err) | Whether a failure looks like a stale cursor, so you can refetch from the start. Heuristic: the server's exact shape is undocumented. |
stripTypename(value) | Recursively drops __typename, non-mutating. query and mutate already do this to variables — reach for it when you are building an input by hand out of a fetched object. |
newClientId() | A fresh UUID for an outgoing message. Must be unique across every client of the account, so generate one per send rather than per component. |
backoffDelay(attempt, opts?) | min(base * 2^attempt, cap) * (0.5 + random/2). Defaults are the WebSocket reconnect constants: 5 s base, 60 s cap. |
createThrottle(opts) | A token bucket with concurrency and retries, wrapping any promise-returning task. BATCH_THROTTLE is the preset for bulk work; Chatfuel's hard limit is 25 requests per second per token. |
stableUuid(key) | A v4-shaped UUID derived from a key you choose, same key to same value every time. Segment and filter ids are validated as UUIDs — a readable slug fails the whole query with a generic subgraph error — and a fresh crypto.randomUUID() per render would make a list refetch forever. isUuid and UUID_RE check the shape. |
uploadFile(options) | The multipart POST behind ModuleClient.uploadFile. A non-2xx throws ChatfuelHttpError, whose body snippet carries the platform code (FileTooBig, FileContentTypeNotSupported) for the UI to match on. |
Errors
Every error code the stack can produce — the proxy's HTTP refusals, the WebSocket close codes, the api-client's error classes — with what causes each one and what to change.
Generated operations
The eleven generated document families, what each covers, how the names are formed, and what happens when you add an operation.