Site / With Remote and SSR

Building it

The domain: what the server owns

ts
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

ts
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

ts
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

ts
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).projects

This 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

ts
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 surfaces checks 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

ts
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 to Data.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

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