Remote / foldkit-remote
Clients and transports
Every client is a Layer<RemoteClient>, and Remote's semantics do not change
with it: a read, a query, a write and a live stream mean the same over Effect
RPC, JSON or a port. What differs is how each reports a request that did not
get an answer.
Remote.json(send) is the JSON client over any way of sending the request
text; Remote.http(url, { headers, fetch }) is it over fetch. headers is
read for each request, so a token that changes reaches the next call.
Who is asking. A 401 fails every call kind, a read, a query, a mutation or
a live stream, with _tag: 'Unauthenticated' (and reason when the server
gave one): the server could not tell who is asking. A 401 is read so whatever
its body says. A 403 whose body does not say forbidden itself, as Remote's
server's does, comes from middleware in front of it refusing a session (a
bare { error } included): it fails a mutation as Forbidden and any other
call with the status. RemoteData.denied(error) reads a failure as
Unauthenticated or Forbidden, typed, and as none for any other, so a view
tells "sign in again" from "could not load" without comparing tags. Remote
treats either as any failure: a read or query is Failed, and a live stream
restarts with its backoff, refused again each time, until the application
signs in again and calls Data.forget.
The JSON clients carry no live data. For live data over the same POSTs,
Remote.httpWithLive keeps reads, queries, and mutations as http sends
them and streams live requirements as a text/event-stream of changes;
serveFetch is the other
end. Abandoning the stream aborts the request, which interrupts the
subscription server-side.
For a server in a worker or in the page (a sandbox), foldkit-remote/port
speaks Effect's own RPC worker protocol over a MessagePort, so live data,
typed errors and interruption cross it too.
servePort is the other end:
import { port } from 'foldkit-remote/port'
// A new conversation each time the protocol connects: one port here, the other to the server.
const overPort = port(() => {
const channel = new MessageChannel()
worker.port.postMessage({ open: 'remote' }, [channel.port2])
return channel.port1
})It is a Layer<RemoteClient> like the others; a port that cannot be set up is
a defect, not a failed read.
Nothing answered. The client RpcClient.make(RemoteRpc) builds is
accepted as it is. When its transport fails, a read, a
query or a live stream fails with its own Remote error (RemoteReadError,
RemoteQueryError, RemoteLiveError), which the UI shows and retries like any
other failure. A mutation fails with RemoteUnreachable (_tag:
'Unreachable'): nothing answered, so the server may or may not have run it.
RemoteData.isUnreachable(error) asks that of a failure without comparing tags.
Remote.json, Remote.http and Remote.httpWithLive say the same of a
request they could not send, and of a response that is no Remote answer: a
body that is not JSON, JSON that is not { result } or { error } (a
gateway's {}), or a response with status 408, 429, 502, 503 or 504, which
Remote's server never answers with. Each is a RemoteJsonClient, a
RemoteRpcClient whose transport error is RemoteUnreachable. A mutation the
server failed is a RemoteMutationError. The difference matters for a durable write:
an Unreachable one is sent again, a refused one is settled Failed.
Testing a server over HTTP. Call it as the application does: give
Remote.http(`${origin}/remote`, { headers, fetch }) the server's address
(miniflare's (await mf.ready).origin for a Worker) and call
FoldkitRemoteRead, FoldkitRemoteQuery and FoldkitRemoteMutate on it, as
examples/cloudflare/src/client.ts does over its local stack. To speak the
wire yourself, each POST body is one of these, version being
REMOTE_PROTOCOL_VERSION:
{ "operation": "read", "payload": { "version": 5, "requests": [{ "entity": "Post", "id": "p1", "fields": ["title"] }] } }
{ "operation": "query", "payload": { "query": "PostsByAuthor", "input": { "authorId": "a1" }, "window": { "first": 20 } } }
{ "operation": "mutate", "payload": { "requestId": "r1", "mutation": "RenamePost", "input": { "id": "p1", "title": "Hello" } } }