Site / With Remote and SSR
Building it
The domain: what the server owns
const Project = Entity.define(
'Project',
Schema.Struct({ id: Schema.String, name: Schema.String, ownerId: Schema.String }),
)
const ProjectCard = Entity.select(Project, { id: true, name: true })
const ProjectsByOwner = Query.define('ProjectsByOwner', { ownerId: Schema.String }, ({ input }) =>
Query.from(Project).pipe(
Query.where(Expr.eq(Project.fields.ownerId, input.ownerId)),
Query.orderBy(Order.asc(Project.fields.name)),
),
)ProjectCard is the only fields that will be fetched or sent to the browser.
The routes: Foldkit's, unchanged
const AppRoute = defineRouteUnion({
Home: {},
Owner: { ownerId: Schema.String },
NotFound: { path: Schema.String },
})
const homeRouter = pipe(root, mapTo(AppRoute.Home))
const ownerRouter = pipe(literal('owners'), slash(string('ownerId')), mapTo(AppRoute.Owner))Plain Foldkit routers. Site and Foldkit's Router says what the Site adds to them.
One Model, two owners
const Model = Schema.Struct({ route: AppRoute, remote: Remote.Model })
const Message = defineMessageUnion({
...Remote.messages,
ClickedLink: { request: Navigation.UrlRequest },
ChangedUrl: { url: Url },
CompletedNavigation: {},
PointedAtLink: { url: Url },
})
const App = Surface.application({ Model, Message })
const Data = Remote.make({
model: App.model.remote,
entities: [Project],
queries: [ProjectsByOwner],
})route is the application's; remote is Remote's store, inside the same Model
rather than a cache beside it, so the server can send it as data. Data is
Remote bound to that field.
What a page reads: a Surface
const OwnerPage = App.surface('OwnerPage', {
params: { ownerId: Schema.String },
model: ({ params }) => ({
projects: Data.query(
ProjectsByOwner,
{ ownerId: params.ownerId },
{ select: ProjectCard, first: 20 },
),
}),
})
const projectsOf = (model: Model, ownerId: string) =>
OwnerPage.projection({ ownerId }).read(model).projectsThis fetches nothing. It is a pure read of the Model that also carries its
requirements: the first twenty ProjectCards of this owner. Read it while the
store lacks them and you get Initial or Loading, never undefined.
Something else decides whether to fetch, and that is the point: the same
declaration serves the browser, the server, the static build and the
prefetch below.
The tree: where each Surface is active
const HomeNode = Site.node(homeRouter, AppRoute.Home)
.title(() => 'Projects')
.meta(() => ({ siteName: 'Projects', description: 'Who is working on what.' })).node
const OwnerNode = Site.node(ownerRouter, AppRoute.Owner)
.title(({ ownerId }) => `Projects of ${ownerId}`)
.surface({ surface: OwnerPage, params: route => ({ ownerId: route.ownerId }) })
.history((prev, next) => (prev.ownerId === next.ownerId ? 'replace' : 'push'))
.meta<Model>(({ route, model }) => {
const read = projectsOf(model, route.ownerId)
return {
description:
read._tag === 'Ready' ? read.value.items.map(item => item.name).join(', ') : undefined,
}
}).node
const AppSite = Site.make(Site.mount(HomeNode, [OwnerNode]))
const routeOf = Site.parse(AppSite, AppRoute.NotFound)
const sources = Site.sources(AppSite, App.owner, App.model.route).surface is the seam between Site and Remote. It says: while the route is an
Owner, OwnerPage is active, with the route's ownerId as its param.
Site.sources turns every such node into a Surface.when over the route
field, keyed by tag (here { Owner: … }). That record is the one thing the
other packages consume:
Data.wiring(sources)in the browser starts and stops the reads as the route changes;Data.satisfy(model, sources)on the server fetches them before drawing;- the SSR plan's
surfaceschecks they crossed with the page; Data.preload(model, sources)reads a destination ahead.
meta reads the Model, not just the route: an owner's description is the
names of their projects, which are data. It runs wherever the Model holds
them, so the server writes it into the page and the browser keeps it in step.
The wiring: one update, three owners
const Routing = Site.routing<Model, Message, AppRoute>({
site: AppSite,
owner: App.owner,
route: {
dependency: App.model.route.dependency,
get: model => model.route,
set: (model, route) => modifyFields(model, { route: () => route }),
},
parse: routeOf,
tags: { clicked: 'ClickedLink', changed: 'ChangedUrl' },
completed: Message.CompletedNavigation,
})
const Parent = Bundle.parent({ Model, Message }).withServices<RemoteClient>()
const placements = Parent.assemble(Data.wiring(sources), Routing)
const update = placements.update((model, message) => {
if (Routing.reduces(message)) return { model }
return Match.valueTags(message, {
ClickedLink: () => ({ model }),
ChangedUrl: () => ({ model }),
CompletedNavigation: () => ({ model }),
// The reader may go there next: read what that page will read.
PointedAtLink: ({ url }) =>
Option.match(Site.routeAt(AppSite, url), {
onNone: () => ({ model }),
onSome: route =>
Equal.equals(route, model.route)
? { model }
: { model, commands: [Data.preload({ ...model, route }, sources)] },
}),
})
})
const initial = (url: Url) => placements.initial({ route: routeOf(url), remote: Remote.initial })Bundle.assemble routes each Message to the piece that owns it:
- Remote's own Messages (
ReadStarted,ReadReceived, …) go toData.reduce, the only writer of the store. - The click goes to
Site.routing. A link the tree holds navigates, with a push or a replace from.history. Any other link loads the page in full. - The URL change goes to
Site.routing, which sets the route field. It returns the same Model when the address is the one already shown.
The application's own update keeps only what is its own: here, reading
ahead. withServices<RemoteClient>() states that the assembled program needs a
client, which the runtime provides as a Layer.
Nothing here connects a route change to a fetch. The route field changes,
OwnerPage is active on that route, and Remote sees what the store lacks.
The view: ordinary Foldkit
const view = (model: Model, h: HtmlBuilder<Message>) => ({
title: Site.titleOf(AppSite, model.route) ?? 'Not found',
body: h.main(
[],
[
AppRoute.match(model.route, {
Home: () => h.a([h.Href(Site.href(OwnerNode, { ownerId: 'ada' }))], ['Ada']),
Owner: ({ ownerId }) =>
RemoteData.render(projectsOf(model, ownerId), {
loading: () => h.p([h.AriaBusy(true)], ['Loading…']),
notFound: () => h.p([], ['No one by that name.']),
failed: () => h.p([], ['Could not load the projects.']),
data: page =>
h.ul(
[],
page.items.map(item => h.li([], [item.name])),
),
}),
NotFound: ({ path }) => h.p([], [`Nothing at ${path}.`]),
}),
],
),
})The link's address comes from the node's own router, so it parses back to the
route it names. RemoteData.render asks for a drawing of each state.