SourceThe websiteThe server

Part X, Chapter 16

Part X, Chapter 16: The server

Package: site/src/

Language: TypeScript

Raw source

The server

Every page of the site reaches the browser as finished HTML, so a reader — and a crawler — sees the page before the client boots. Beside the page travels the model it was rendered from, inlined for hydration to read. The server that sends it is a small Effect HTTP server started from a Bun runtime edge.

Rendering every route matters because the site navigates by ordinary links. A nav entry carries a real href, so a click leaves the page and asks the server for the next one, and a reader who reloads or arrives from outside asks for a route directly. A server that answered only the landing would give every other page a not-found.

There is one server. It reads the shell and the assets from dist and serves the bundle beside them. Development runs this same file against that same directory, so nothing about how a page arrives differs between a developer's machine and a reader's. What differs is only who writes dist: a build, or a build left watching.

That is worth more than it sounds. A server that renders one way and is developed another agrees with itself only in what a page finally says, and disagrees in everything about how the page arrives — which stylesheet, in which order, before or after the body is visible. Running one path twice settles all of it at once.

A page depends on nothing but its address and the build: the shell the build wrote, the documentation and the book. So the site renders its pages when it is built rather than when a reader asks. The build renders every address the site answers and keeps each page as a file in dist, and the server answers a request for a page by reading its file. The chapter Rendering every page ahead tells how the build does it.

The server can still render a page itself, and does whenever dist holds no page rendered for the build it serves. That is the case in development, where the client is rebuilt on every change and every rebuild is a new build. Both paths render through the same functions over the same model, so a page rendered ahead and a page rendered on demand are the same page.

The rendering lives in a module of its own, which the server and the build share. This chapter tells that module first, then the server.

The server stays clear of the client runtime. It renders through Foldkit's server module, which runs the view once over a model and writes the result into the shell, and it never starts the runtime the browser boots. The browser hydrates and takes over.

The page

The page starts from the built shell and gains three things: the rendered body in place of the empty #root, the route's own title in place of the shell's, and, beside the body, the model it was rendered from, which the client reads back to hydrate.

The seed is the site at rest, and every page starts from it — no chapter opened, no section jumped to, the example standing at its first frame, and no slice of the book. It names the landing's route because a model must name one; each page then overrides that single field with its own.

The pill and the title bar both name the latest release of Loom, and the seed carries it as the workspace's own version, which the tangle writes in. A release is always a commit that bumps that version, and the site is built again from the same commit. So the version a page names is the release its build ships, from the moment the page goes out. A page rendered ahead could not ask npm instead. The site is built before the release reaches npm, so npm would still name the release before it.

{{ version = ${version} }}

TypeScript
export const seed: Model = {
  route: 'home',
  chapter: '',
  drawerOpen: false,
  runtime: 'bun',
  searchOpen: false,
  caret: 0,
  searching: false,
  found: [],
  activeSection: '',
  player: { struck: 0, beat: 0, playing: false, output: Option.none(), full: false },
  version: '::[version]',
  query: '',
  focus: 0,
  copied: '',
  book: Option.none(),
}

Loom's own book

The source page draws Loom's own book, and the server is where the book lives. The site's build weaves corpus/book.loom to source.woven.json beside this file, then colours its code into source.coloured.json, and the server reads the coloured book once, when it starts. The build that renders the pages ahead reads it the same way. The book changes only when the site is built again, so there is nothing to gain from reading it per request, and two megabytes of JSON are worth parsing only once.

The server parses the file and decodes it against the model's Corpus schema, which also drops what the shell never draws, such as where each block came from in its loom. A file that is missing or does not decode leaves the server without a book. It still serves every other page, and the source page's addresses then name no page.

TypeScript
const bookFile = join(here, 'source.coloured.json')

const decodeBook = S.decodeUnknownOption(Corpus)

export const readBook = (fs: FileSystem.FileSystem): Effect.Effect<Option.Option<Corpus>> =>
  fs.readFileString(bookFile).pipe(
    Effect.map((text) =>
      Option.flatMap(Option.liftThrowable((json: string): unknown => JSON.parse(json))(text), decodeBook),
    ),
    Effect.catchCause(() => Effect.succeed(Option.none())),
  )

Rendering one page

One model renders one page. The server and the build hand Foldkit the same three things the client boots from: the model's schema as the shape of the flags, an init that returns the model it is given, and the view. Server.renderToString runs the view over that model and returns the body and the title, with the model serialized beside the body. Server.injectIntoTemplate then writes both into the shell, the body over the empty #root and the title over the shell's own.

TypeScript
const application = {
  Flags: Model,
  init: (model: Model) => ({ model }),
  view,
}

Foldkit stamps the page with a build id, and the client refuses a page whose id differs from its own. The id is the path of the entry script the shell loads. Vite names that file after a hash of its contents, so the path changes with every build that changes the client, and the client reads the same path from its own address. A shell without an entry script is not a build the server can serve, so the render dies rather than stamping a page no client could ever boot.

TypeScript
const ENTRY = /<script type="module"[^>]*\ssrc="([^"]+)"/

export const entryOf = (shell: string): Effect.Effect<string> =>
  Option.match(Option.fromNullishOr(ENTRY.exec(shell)?.[1]), {
    onNone: () => Effect.die(new Error('the shell loads no entry script')),
    onSome: Effect.succeed,
  })

export const pageOf = (shell: string, model: Model): Effect.Effect<string> =>
  Effect.gen(function* () {
    const buildId = yield* entryOf(shell)
    const rendered = yield* Server.renderToString(application, { flags: model, buildId })
    return Server.injectIntoTemplate(shell, rendered)
  }).pipe(Effect.orDie)

The search index reads the site's own pages as their rendered bodies, so bodyOf renders a route's page and hands back only its body. That body never reaches a browser, so it needs no shell, and any build id will do.

TypeScript
export const bodyOf = (route: Route): Effect.Effect<string> =>
  Server.renderToString(application, { flags: { ...seed, route }, buildId: 'search' }).pipe(
    Effect.map((rendered) => rendered.html),
    Effect.orDie,
  )

Every page the site answers

A path names a page in one of three ways. It is a route's own path, such as /why-loom or /docs, which opens the route as it stands. Or it is a chapter's address under /docs, which opens the documentation on that chapter. Or it is a chapter's address under /source, which opens the book on that chapter, when there is a book. /source itself opens the book's first chapter, so it too names a page only when there is a book. Without one it would render the page's frame around nothing: the nav, an empty column and the footer. The placeOf function reads a path as the place it names, or as no place at all.

TypeScript
export type Place = { readonly route: Route; readonly chapter: string }

export const placeOf = (book: Option.Option<Corpus>, pathname: string): Option.Option<Place> =>
  pipe(
    routeOf(pathname),
    Option.filter((route) => route !== 'source' || Option.isSome(book)),
    Option.map((route): Place => ({ route, chapter: '' })),
    Option.orElse(() =>
      Option.map(docsChapterAt(pathname), (chapter): Place => ({ route: 'docs', chapter })),
    ),
    Option.orElse(() =>
      Option.flatMap(book, (corpus) =>
        Option.map(
          chapterAt(corpus, '/source', pathname),
          (chapter): Place => ({ route: 'source', chapter }),
        ),
      ),
    ),
  )

A page's model is the seed with its place written over it. A source page also carries its slice of the book: the book's table of contents and the one chapter the place names, with its code already in colour. Every other page carries none. The modelAt function builds the model for a place, for the server and the build alike.

TypeScript
const bookFor = (book: Option.Option<Corpus>, place: Place): Option.Option<Corpus> =>
  place.route === 'source'
    ? Option.map(book, (corpus) => sliceOf(corpus, place.chapter))
    : Option.none()

export const modelAt = (book: Option.Option<Corpus>, place: Place): Model => ({
  ...seed,
  ...place,
  book: bookFor(book, place),
})

The build needs the same places the other way round, as every address the site answers. The everyAddress function lists them: each route's own path, the address of every chapter of the documentation, and the address of every chapter of the book.

TypeScript
export const everyAddress = (book: Option.Option<Corpus>): ReadonlyArray<string> => [
  ...Array.map(Route.literals, pathOf),
  ...Array.map(docsCorpus.pages, (page) => chapterPath('/docs', page.slug)),
  ...Option.match(book, {
    onNone: () => [],
    onSome: (corpus) => Array.map(corpus.pages, (page) => chapterPath('/source', page.slug)),
  }),
]

Where a page rendered ahead is kept

The build keeps each page it renders as a file under dist/pages, in a folder named after the build. A build's name is its id, the entry script the shell loads, without its folder or its extension, such as index-DJidxQvj. Inside that folder the page for an address sits at the address with .html added, and the landing sits at index.html.

The folder's name is what keeps a page rendered ahead honest. A page names the bundle and the stylesheet of the shell it was rendered into, so it is good only while the shell still names the same build. A new build has a new name, and until it renders its own pages the server finds none kept for it.

TypeScript
export const distDir = join(here, '..', 'dist')

export const PAGES_DIR = join(distDir, 'pages')

export const pageFileOf = (buildId: string, address: string): string =>
  join(
    PAGES_DIR,
    basename(buildId, extname(buildId)),
    `${address === '/' ? 'index' : address.slice(1)}.html`,
  )

The pages module

The pages module draws on effect, on Node's path and url helpers to find its files, and on Foldkit's experimental Server module, which renders a view to HTML and writes it into the shell. The site's own modules supply the rest. The view draws the page. The model lends the Model schema, which doubles as the shape of the flags a page carries, the Corpus schema the book is read against, the Route schema that lists every route, and the pathOf and routeOf functions, which turn a route into its path and a path into its route. The documentation shell lends the documentation itself, as docsCorpus, and the functions that read and write a chapter's address and cut the slice of a corpus one page shows.

TypeScript
import { Array, Effect, FileSystem, Option, pipe, Schema as S } from 'effect'
import { basename, dirname, extname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Server } from 'foldkit/experimental'
import { view } from './view'
import { Corpus, Model, pathOf, Route, routeOf } from './model'
import { chapterAt, chapterPath, docsChapterAt, docsCorpus, sliceOf } from './docs-page'

const here = dirname(fileURLToPath(import.meta.url))

::[The page]

::[Loom's own book]

::[Rendering one page]

::[Every page the site answers]

::[Where a page rendered ahead is kept]

What the server draws on

From effect come Effect, the Cache that keeps each page the server renders itself, the FileSystem that reads the shell and the pages rendered ahead, the Option that carries a path naming no page, the Schema that encodes a search's answer, and the Context and Layer that define the server and wire it. From effect/http come the request and the responses. @effect/platform-bun supplies the Bun HTTP server, its file services, and the runtime edge.

The pages module lends everything a page needs: the book, the places a path can name, a place's model, the render, and the file where a page rendered ahead is kept. The model lends the Route schema, which lists every route the site has, and the SearchGroup schema a search answers in. The documentation shell lends the documentation itself, as docsCorpus, which the cache counts its chapters from. The search module lends indexOf, which builds the index the palette searches, and searchIn, which answers a query from it.

The client never reads an address itself. It boots from the model the page carries, so the server and the client always agree on which page and which chapter are on show.

TypeScript
import { Cache, Context, Effect, FileSystem, Layer, Option, Schema as S } from 'effect'
import { HttpServer, HttpServerRequest, HttpServerResponse } from 'effect/http'
import { BunHttpServer, BunRuntime, BunServices } from '@effect/platform-bun'
import { join } from 'node:path'
import { Route, SearchGroup } from './model'
import { docsCorpus } from './docs-page'
import { indexOf, searchIn } from './search'
import {
  bodyOf,
  distDir,
  entryOf,
  modelAt,
  pageFileOf,
  pageOf,
  placeOf,
  readBook,
  type Place,
} from './pages'

The pages the server writes itself

Two answers are pages the server writes on its own: the one for an address the site does not have, and the one for a dist that has not been built yet.

Neither is built on the shell. The missing page cannot be, because the shell boots the client, and the client hydrates only a page the server rendered for it. Handed a shell with no model in it, Foldkit refuses the page and locks it. The unbuilt page cannot be either, for the plainer reason that the shell is the very thing absent.

So they stand alone and share a shape — centred on the site's paper, set in the site's type, one line of prose and nothing waiting on a bundle.

TypeScript
const PAGE_STYLE = `
      :root { --paper: #F7F4EC; --ink: #14130F; --ink-3: #7C6F64; --rule: #E4DCCB; }
      body {
        margin: 0;
        min-height: 100vh;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        gap: 24px;
        padding: 40px;
        background: var(--paper);
        color: var(--ink);
        font-family: 'Lilex', 'IBM Plex Mono', ui-monospace, monospace;
        text-align: center;
      }
      .number { font-size: 46px; letter-spacing: 0.02em; }
      .headline { font-size: 24px; letter-spacing: 0.01em; }
      .line { margin: 0; max-width: 52ch; line-height: 1.7; color: var(--ink-3); }
      .line code { color: var(--ink); }
      .back {
        color: var(--ink);
        text-decoration: none;
        padding-bottom: 3px;
        border-bottom: 1px solid var(--rule);
      }
      .back:hover { color: var(--ink-3); }
`

const standalone = (title: string, body: string): string => `<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>${title}</title>
    <style>${PAGE_STYLE}</style>
  </head>
  <body>${body}
  </body>
</html>
`

The missing page carries the mark, the number, a sentence, and the way back. A reader who has just missed a page waits on nothing to be told so, and the same page answers in development and in production alike.

TypeScript
const MISSING = standalone(
  'Not found — Loom',
  `
    <img src="/mark.svg" alt="Loom" width="140" height="60" />
    <div class="number">404</div>
    <p class="line">There is no page at this address.</p>
    <a class="back" href="/">Back to Loom</a>`,
)

const notFound = Effect.succeed(
  HttpServerResponse.text(MISSING, { status: 404, contentType: 'text/html' }),
)

The unbuilt page carries no mark, since the mark is one of the files that has not been written. It names the command that fixes the matter, which costs a line and saves the reader working out why a directory is empty.

TypeScript
const NOT_BUILT = standalone(
  'Not built — Loom',
  `
    <div class="headline">The site is not built</div>
    <p class="line">The server renders from <code>dist</code>, and nothing is there yet. Run <code>bun run build</code>, or <code>bun run dev</code> to keep it building.</p>`,
)

Read fresh, rendered ahead

The site reads its shell for each page rather than holding one from startup, because dist is rewritten underneath a running server every time a build finishes. A shell held from startup would go on naming the bundle the first build wrote. The page would then arrive running code a developer has since changed, or pointing at files a later build has removed. Reading it per request costs one small file and keeps the server honest about whatever dist holds at that moment.

Two things are made once rather than per request: the book and the search index. Both change only with a build. The server waits only for the book before it is ready to answer. Every request needs the book, since the book decides which /source addresses name a page, and reading it takes a few tens of milliseconds.

The server leaves the search index for last. It builds the index when the first search asks for it, and the Effect.cached combinator keeps the index for every search after. Building the index occupies the server for most of a second, and no other request is answered in that time. A development server restarts on every tangle, and an index built at startup would stall the very pages a developer reloads after a change.

Two answers are the whole of what the site owes the handler. The pageAt function asks placeOf whether the path names a page at all, then answers with that page in the shell it has just read. Any other path names no page, so the server looks for an asset, and answers with the missing page when there is none. A dist it cannot read yields the unbuilt page. A render that fails is a fault in the site rather than in the build, so it surfaces as one instead of hiding behind the unbuilt page. The assetAt function reads a file from dist — the client bundle, the stylesheet, the marks — and falls back to the missing page when there is no such file. Both take the request's whole URL, since a path alone is what the handler happens to match on rather than all it holds.

A page comes from one of two places. The pageIn function first looks for the page rendered ahead for the shell's build, and answers with that file when the build has written it. Otherwise it renders the page itself. That happens in development, where every rebuild is a new build that renders nothing ahead, and in production only for a page the build left out.

A page the server renders, it keeps. Rendering one place twice through one shell gives the same HTML twice, since a page's model is fixed once the server has read the book. So the rendered cache renders a page the first time a request asks for it, and keeps the finished HTML for every request after.

The cache keys each page by its place and by the shell it was written into. The shell is what keeps a kept page honest. A build that changes the bundle or the stylesheet writes their new names into the shell, and every page then has a new key. Each page renders afresh the first time a request asks for it, and no page written into an older shell is served again.

The cache has room for one page at each address the site answers. All of those pages together come to about twelve megabytes of HTML. When a build changes the shell, the pages written into the new shell push out those written into the old one, the least recently asked first. Two requests that ask for the same page at once share one render.

The unbuilt page stays out of the cache. It answers only when there is no shell to key it by, and it is a constant with nothing to render.

The site answers one more thing, a search. searchAt reads the query from the request's q parameter and answers with the groups searchIn finds, encoded through the SearchGroup schema, so the client reads back exactly the shape the server meant.

TypeScript
type PageKey = { readonly place: Place; readonly shell: string }

export class Site extends Context.Service<Site>()('Site', {
  make: Effect.gen(function* () {
    const fs = yield* FileSystem.FileSystem
    const book = yield* readBook(fs)
    const bookChapters = Option.match(book, {
      onNone: () => 0,
      onSome: (corpus) => corpus.pages.length,
    })
    const index = yield* Effect.cached(indexOf(book, bodyOf))
    const rendered = yield* Cache.make({
      capacity: Route.literals.length + docsCorpus.pages.length + bookChapters,
      lookup: (key: PageKey) => pageOf(key.shell, modelAt(book, key.place)),
    })
    const pageIn = (shell: string, place: Place, address: string): Effect.Effect<string> =>
      Effect.flatMap(entryOf(shell), (buildId) =>
        fs
          .readFileString(pageFileOf(buildId, address))
          .pipe(Effect.catchCause(() => Cache.get(rendered, { place, shell }))),
      )
    const shell = fs.readFileString(join(distDir, 'index.html'))
    return {
      pageAt: (url: URL): Effect.Effect<Option.Option<string>> =>
        Option.match(placeOf(book, url.pathname), {
          onNone: () => Effect.succeed(Option.none()),
          onSome: (place) =>
            shell.pipe(
              Effect.matchEffect({
                onFailure: () => Effect.succeed(NOT_BUILT),
                onSuccess: (held) => pageIn(held, place, url.pathname),
              }),
              Effect.map(Option.some),
            ),
        }),
      assetAt: (url: URL) =>
        HttpServerResponse.file(join(distDir, url.pathname)).pipe(
          Effect.catchCause(() => notFound),
        ),
      searchAt: (url: URL): Effect.Effect<HttpServerResponse.HttpServerResponse> =>
        Effect.flatMap(index, (entries) =>
          HttpServerResponse.schemaJson(S.Array(SearchGroup))(
            searchIn(entries, url.searchParams.get('q') ?? ''),
          ),
        ).pipe(Effect.orDie),
    } as const
  }),
}) {
  static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(BunServices.layer))
}

Answering a request

The server answers /search from the index. For any other path it asks the site for a page at the request URL. A path that names a route returns it as HTML, and anything else is an asset read from dist.

TypeScript
const SEARCH = '/search'

const handle = (site: Site['Service'], url: URL) =>
  url.pathname === SEARCH
    ? site.searchAt(url)
    : Effect.flatMap(
        site.pageAt(url),
        Option.match({
          onSome: (page) => Effect.succeed(HttpServerResponse.text(page, { contentType: 'text/html' })),
          onNone: () => site.assetAt(url),
        }),
      )

Serving it

The handler reads the request and answers it. The server serves that handler over a Bun HTTP layer and launches from the Bun runtime edge. There is nothing here to switch on, which is the point: this file is what a developer runs and what the image runs, and neither reads an environment variable to find out which it is.

The order of the site's layer and the Bun HTTP layer decides when the port opens. The Layer.provide function builds the layer it is given before the layer it feeds, so the site, provided last, is built first. The Bun HTTP layer opens the port only after that, once the site can answer. Until the handler is installed, the Bun HTTP layer answers every request itself, with a bare 404 whose body reads not found. In the other order that placeholder would answer every request for as long as the site took to start, and a reload during a development restart would show an all but empty page.

TypeScript
::[What the server draws on]

::[The pages the server writes itself]

::[Read fresh, rendered ahead]

::[Answering a request]

const app = Effect.gen(function* () {
  const request = yield* HttpServerRequest.HttpServerRequest
  const site = yield* Site
  return yield* handle(site, new URL(request.url, 'http://localhost'))
})

const port = Number(process.env.PORT ?? 5199)

const server = HttpServer.serve(app).pipe(
  HttpServer.withLogAddress,
  Layer.provide(BunHttpServer.layer({ port })),
  Layer.provide(Site.layer),
)

BunRuntime.runMain(Layer.launch(server))
Loom