Remote / foldkit-remote
The first run: one entity, one Surface
Six steps take a project's name from a server to a screen. Each says what it does, and what it deliberately does not.
1. Declare an entity and what this screen needs of it
import { Schema } from 'effect'
import { Entity } from 'foldkit-entity'
const Project = Entity.define(
'Project',
Schema.Struct({
id: Schema.String,
name: Schema.String,
status: Schema.String,
}),
)
// A Selection is the exact server-owned shape this consumer needs.
const ProjectSummary = Entity.select(Project, {
id: true,
name: true,
})The entity comes from foldkit-entity, which declares a domain
without Remote in it, so the same declaration can serve the server's database
binding and your forms too. It is also what gives an entity
relations, derived members, and a
query body to point at. Nothing here touches a
network.
2. Give Remote a place in the Model, and route its Messages
import { defineMessageUnion } from 'foldkit/message'
import type * as Update from 'foldkit/update'
import { Remote, type RemoteClient } from 'foldkit-remote'
import { Surface } from 'foldkit-surface'
const Route = Schema.Union([
Schema.Struct({ _tag: Schema.Literal('home') }),
Schema.Struct({
_tag: Schema.Literal('project'),
projectId: Schema.String,
}),
])
const Model = Schema.Struct({
route: Route,
remote: Remote.Model,
})
type Model = typeof Model.Type
const Message = defineMessageUnion({
GotRemoteMessage: { message: Remote.Message },
})
type Message = typeof Message.Type
const App = Surface.application({
Model,
Message,
initial: {
route: { _tag: 'home' },
remote: Remote.initial,
},
update,
})
const Data = Remote.make({
model: App.model.remote,
entities: [Project],
})
// Remote under one variant of the union, the shape Foldkit gives a Submodel.
const foldData = Remote.fold(Data, message => Message.GotRemoteMessage({ message }))
function update(
model: Model,
message: Message,
): Update.Return<Model, Message, RemoteClient> {
return Message.match(message, {
// Remote owns the `remote` Submodel: everything it says goes through its reducer.
GotRemoteMessage: ({ message }) => foldData(model, message),
})
}Remote.Model is not a second store. It is a Submodel inside the
application Model, and Data is the bound API for this domain: it knows where
that Submodel lives and which entities this application registered. update
matches the union exhaustively; Remote's own Messages arrive inside
GotRemoteMessage and go straight to Remote's reducer. (There is a shorter
form for an update that only reduces; see
Routing Remote's Messages.)
3. Declare what the screen reads
const ProjectPage = App.surface('ProjectPage', {
params: { projectId: Schema.String },
model: ({ params }) => ({
// Important: this performs no I/O.
// It is a pure Projection over Model plus a requirement for these fields.
project: Data.get(ProjectSummary, params.projectId),
}),
})ProjectPage reads a RemoteData<{ id: string; name: string }> from the Model.
If those fields are absent, the Projection still does not fetch them: it says
what it needs, and something else decides whether to ask.
4. Let the active screen drive the fetching
import { Option } from 'effect'
import * as Subscription from 'foldkit/subscription'
const subscriptions = Subscription.make<Model, Message, RemoteClient>()(() =>
foldData.subscriptions({
project: Surface.at(ProjectPage, model =>
model.route._tag === 'project'
? Option.some({ projectId: model.route.projectId })
: Option.none(),
),
}),
)Surface.at makes activation a fact of the Model. When the route activates the
page, Remote sees its requirements, diffs them against the cache, and fetches
only what is missing. When the page is inactive, it creates no read work. This
is the one step that causes I/O, and it does so only while the screen is on.
A read that is not a Surface's, such as the record a detail pane shows, is made
active with Data.active: its function of the Model returns the read, or none
while there is nothing to read.
const detail = Data.active('ProjectDetail', model =>
Option.map(model.openProjectId, id => Data.get(Project.select({ name: true }), id)),
)
foldData.subscriptions({ detail })It belongs to the domain's application and sends no Messages. A domain bound
to a raw optic (ModelRef.fromOptic) names no application, so Data.active
throws for one rather than guess.
5. Provide a client
The Subscription needs a RemoteClient. Before there is a server, a backend
held in memory answers through the same handlers a real one uses:
import { RemoteServer } from 'foldkit-remote-server'
const clientLayer = RemoteServer.memory({
domain: Data,
rows: { Project: [{ id: 'p1', name: 'Apollo', status: 'active' }] },
}).layerWith a server, hand Remote the Effect RPC client for RemoteRpc; the
server guide supplies the other side:
import { Effect, Layer } from 'effect'
import { FetchHttpClient } from 'effect/http'
import { RpcClient, RpcSerialization } from 'effect/rpc'
import { RemoteRpc } from 'foldkit-remote'
const clientLayer = Layer.unwrap(
Effect.map(RpcClient.make(RemoteRpc), rpcClient => Remote.clientLayer(rpcClient)),
).pipe(
Layer.provide(RpcClient.layerProtocolHttp({ url: '/rpc' })),
Layer.provide([RpcSerialization.layerJson, FetchHttpClient.layer]),
)Or, with no Effect RPC on the server, plain JSON over HTTP: each call a POST
of { operation, payload }, answered { result } or { error }.
RemoteServer.answer is the
other end. Remote.json(send) is the same client over any other way of
sending the request, a worker or a server running in the page:
const overHttp = Remote.clientLayer(
Remote.http('/remote', { headers: () => ({ 'x-session': 'the session token' }) }),
)headers is read for each request, so a token that changes reaches the next
call. 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. RemoteData.denied(error)
reads a failure as that or a 403 (Forbidden), typed, and as none for any
other, so a view tells "sign in again" from "could not load" without
comparing tags. Remote treats it 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 Remote.forget. 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. A 401 is read the
same whatever its body says. Neither carries 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.
The stock client is accepted as it is. When its transport fails, the call
fails with its own Remote error (RemoteReadError, RemoteQueryError,
RemoteMutationError, RemoteLiveError), which the UI shows and retries like
any other failure.
Either is a Layer the runtime provides to subscriptions and Commands. The
transport itself is not owned by Remote. See
RemoteServer.memory
for what the in-memory one can and cannot do.
6. Watch it arrive
ProjectPage Projection
|
| requirements
v
Data.subscriptions
|
| plan missing fields
v
RemoteClient
|
| server result
v
Remote Message
|
v
Data.reduce
|
v
Remote.Model
|
v
ProjectPage now reads Ready(...)The first run starts on the home route, so no project read runs yet. Your
route update must activate { _tag: 'project', projectId: 'p1' }, and the
runtime must install subscriptions. The page then reads Initial until work
starts, Loading during the first request, and Ready once its result is
reduced. If it stays Initial, see Why is it still Initial?.