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:

ts
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:

json
{ "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" } } }