Skip to content

About

An unfinished experiment in server-rendered HTML and schema-bound forms with Effect v4

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

effect-views

An unfinished prototype for server-rendered HTML and schema-bound forms with Effect, and a small Effect runtime in the browser.

What's interesting

effect-views connects Effect's HTTP contracts directly to server-rendered UI:

  • A tiny JSX runtime renders straight to HTML—no React, virtual DOM, or hydration step.
  • HttpViewEndpoint gives Effect endpoints an HTML success schema.
  • HttpFormEndpoint describes a URL-encoded POST with an Effect Struct schema, then Form.derive turns that same endpoint into typed form controls.
  • Field names and compatible control types are checked by TypeScript while the application keeps control of its markup and layout.
  • Forms and placeholders can put the server's HTML into the page without loading a new one, and Page returns fragments for them, full pages for everything else, and response headers that say where fragments go.
  • Invalid submissions come back to the handler as Submission.Invalid, and form.with(invalid) renders the form again with the user's input and errors.
  • View.derive turns a GET view endpoint into typed links, and placeholders that load slow parts of a page later.
  • Csrf.layer and ErrorPage.layer cover cross-site requests and HTML error pages as middleware.
  • Component.make keeps a component's markup and scoped CSS in one .tsx file. Live components add actions that run on the server as HttpApi endpoints, with typed state and payloads, and no JavaScript of your own in the browser. Assets.layer bundles the runtime, and serves it with the styles, without a build step.

The examples serve full pages to ordinary browser requests and HTML fragments to the runtime. The same forms work with or without JavaScript.

Try it locally

Use Node.js 24+ and pnpm 11.15.0. From the repository root:

pnpm install --frozen-lockfile
pnpm example:todo
# open http://127.0.0.1:3000
Example Command Open
Todo list pnpm example:todo http://127.0.0.1:3000
Orders pnpm example:orders http://127.0.0.1:3000
Sign-up pnpm example:signup http://127.0.0.1:3000

Each command builds the source and starts its example on port 3000. Stop it with Ctrl+C. Set PORT to use another port. The todo list is stored in todos.sqlite; the orders and sign-up demos keep their state in memory, which resets when the server restarts.

The example walkthroughs explain what to try, where to read the code, and how to make requests with curl.

Installing from Git

dist isn't committed, so an application that installs effect-views from its Git repository uses the TypeScript sources instead, under the effect-views-source export condition. Add the condition to TypeScript's customConditions, to the bundler (esbuild's conditions) and to the test runner (Vite's resolve.conditions and ssr.resolve.conditions), and have them compile effect-views like your own code; for Vitest, list it in server.deps.inline.

A schema-bound form

The form below takes its action and field names from a POST endpoint. It shows the core relationship; the todo example adds request handlers and chooses between full-page and fragment responses.

/** @jsxImportSource effect-views */

import { Schema } from "effect"
import { Form, HttpFormEndpoint } from "effect-views"

export const createTodo = HttpFormEndpoint.make("create", "/todos", {
  payload: Schema.Struct({ title: Schema.NonEmptyString })
})

const NewTodo = Form.derive({ id: "new-todo", endpoint: createTodo })

export const view = (
  <NewTodo.Root target="#todo-app">
    <NewTodo.Label name="title">What needs doing?</NewTodo.Label>
    <NewTodo.Input name="title" required />
    <button type="submit">Add todo</button>
  </NewTodo.Root>
)

Root gets method="post" and action="/todos" from the endpoint. Label and Input share a generated ID, and the schema makes name="title" a typed field reference. Layout, copy, classes, and browser validation attributes remain ordinary JSX. The endpoint's schema also decodes the submitted payload on the server.

With target, the effect-views runtime submits the form and puts the response in place of #todo-app, updating it element by element, so focus and what is being typed survive. swap="inner" replaces what the target contains instead, and swap="append" adds the response after it. Without JavaScript, the browser submits the form as usual. Form.target(selector, swap) binds the same to a form of your own: <form {...Form.target("#todo-app")}>.

Validation errors

A form endpoint's handler receives a Submission, which never fails to decode. Yielding it gives the decoded payload, or fails with Submission.Invalid, which keeps the submitted values, each field's messages, and messages for the form as a whole. NewTodo.reject(value, messages) turns a domain error into the same error, encoding the decoded value back into the form's values:

.handle("create", ({ payload, request }) =>
  Effect.gen(function* () {
    const { title } = yield* payload
    yield* todos.add(title).pipe(
      Effect.catchTags({
        DuplicateTodo: () =>
          Effect.fail(NewTodo.reject({ title }, {
            errors: { title: ["That is already on the list"] }
          })),
        TodoListFull: ({ limit }) =>
          Effect.fail(NewTodo.reject({ title }, {
            formErrors: [`The list is full at ${limit} todos. Finish one before adding more.`]
          }))
      })
    )
    return Page.wantsFragment(request) ? Html.app(yield* todos.list) : Html.seeOther("/")
  }).pipe(
    Effect.catchTag("FormInvalid", (invalid) =>
      Effect.map(todos.list, (items) =>
        page(request, Html.app(items, invalid), { status: 422 })))
  ))

NewTodo.with(invalid) returns the same form components, filled in. Inputs, textareas, checkboxes, and selects given options show the submitted values (password inputs stay empty); controls with errors get aria-invalid and an aria-describedby pointing at <NewTodo.Error name="title" />. Without a name, Error shows the form's own messages, and Root points at it:

const Form = NewTodo.with(invalid)

<Form.Root target="#todo-app">
  <Form.Error role="alert" class="error" />
  <Form.Label name="title">What needs doing?</Form.Label>
  <Form.Input name="title" required />
  <Form.Error name="title" class="error" />
  <button type="submit">Add todo</button>
</Form.Root>

The runtime puts a 422 response, the form returned with its errors, into the target like a successful one; other errors go elsewhere, as Error pages describes.

A successful POST that expects a page answers with Html.seeOther, a 303 redirect, so that reloading the page does not submit the form again. Page.seeOther(request, location) covers the runtime too: a 303 for ordinary requests, and a 204 with an effect-views-location header for the runtime's, so the browser loads the whole page.

Writing messages

Messages come from the schema. Give each check a message, and give a field a messageMissingKey for when it is left empty: forms submit an empty input as a missing value, so optional fields can be left blank.

export const CreateTodo = Schema.Struct({
  title: Schema.Trim.check(
    Schema.isMinLength(1, { message: "Write what needs doing" }),
    Schema.isMaxLength(80, { message: "Keep it under 80 characters" })
  ).annotateKey({ messageMissingKey: "Write what needs doing" })
})

A field's message annotation also covers text that cannot be converted, such as "twelve" for a number:

age: Schema.Int.check(Schema.isGreaterThanOrEqualTo(13))
  .annotate({ message: "Enter your age in years, 13 or over" })

Rules across fields

Rules compare fields. Each lists the fields it reads and returns true, a message for the form, or a message for one field:

const signUp = HttpFormEndpoint.make("signUp", "/sign-up", {
  payload: SignUp,
  rules: (rule) => [
    rule(["password", "confirmation"], (s) =>
      s.password === s.confirmation || { field: "confirmation", message: "The passwords do not match" }),
    rule(["age", "plan"], (s) =>
      s.plan !== "team" || s.age >= 16 || "Team plans are for people aged 16 and over")
  ]
})

A rule receives only the fields it lists, decoded, and runs as soon as they are valid, so its message appears alongside the other fields' messages instead of after they are fixed. While any of them is invalid, the rule is skipped: there is no "The passwords do not match" next to "Use at least 12 characters". Form.make and Submission.schema take rules too.

Checks on the whole Struct, with .check, still work and become form errors, but Effect runs them only once every field is valid. Keep them for rules the schema must enforce everywhere, such as when it also decodes JSON.

An unchecked checkbox is not submitted, so a missing Schema.Boolean field is false. Use Schema.Literal(true) for a box that must be checked, such as accepting terms.

Fields without messages fall back to Effect's defaults, such as "Expected a value with a length of at least 1", and "Required" for empty fields.

Placing messages

Error and Summary cover the common layouts, and the form's messages are available for anything else:

// Style the whole field, not just its message
<div class={Form.hasErrors("title") ? "field field--invalid" : "field"}>
  <Form.Label name="title">What needs doing?</Form.Label>
  <Form.Input name="title" />
  {/* Choose the markup; the element keeps the id that aria-describedby uses */}
  <Form.Error name="title" as="ul" class="errors">
    {(messages) => messages.map((message) => <li>{message}</li>)}
  </Form.Error>
</div>

Form.messages(name) returns a field's messages, and without a name, the form's own. Form.invalid tells whether there are any messages at all.

Short forms often need no summary. NewTodo.with(invalid, { autofocusError: true }) gives the first invalid control autofocus, so the browser focuses it after a full page load and the runtime after putting the form in the page, and a screen reader reads its label and error.

<Form.Summary /> lists every message at the top of the form: the form's own, then each field's in schema order, linking to its control. Long forms benefit most. It has role="alert" and autofocus, so the browser moves focus to it after a full page load and the runtime after putting the form in the page. It includes the form's own messages, so use it instead of a nameless Error. The sign-up example puts it at the top of a long form.

Endpoints with path parameters

Give Form.derive the endpoint's params, and its query if it has one, to fill in the form's action. They're checked against the endpoint's schemas and encoded as the server decodes them:

const rename = HttpFormEndpoint.make("rename", "/todos/:id/rename", {
  params: { id: Schema.Int },
  payload: Schema.Struct({ title: Todo.Title })
})

const Rename = Form.derive({ id: `rename-${todo.id}`, endpoint: rename, params: { id: todo.id } })

<Rename.Root>…</Rename.Root>
// <form id="rename-7" method="post" action="/todos/7/rename">

A form's id prefixes its controls' ids, so give each one on a page its own, such as one per record.

Hidden fields

Hidden renders a hidden input for a field of any type, such as a number, a literal, or JSON, with its value encoded by the field's schema:

const Comment = Form.derive({ id: "comment", endpoint: comment })

<Comment.Hidden name="anchor" value={{ start: 1, end: 4 }} />
// <input id="comment-anchor" name="anchor" type="hidden" value="{&quot;start&quot;:1,&quot;end&quot;:4}">

Leave value out for one the page's own script fills in, finding the input by Comment.idFor("anchor").

Forms without an HttpFormEndpoint

HttpFormEndpoint.make is a shortcut. A form built with Form.make has the same Submission payload schema, so any endpoint can receive it:

const Rename = Form.make({
  id: "rename",
  action: "/todos/1/rename",
  payload: Schema.Struct({ title: Todo.Title })
})

const rename = HttpViewEndpoint.post("rename", "/todos/:id/rename", {
  params: { id: Schema.Int },
  payload: Rename.payload
})

Submission.schema(struct) makes the same payload without a form, and Submission.decode(struct, values) decodes values directly.

JSX without React

Components are ordinary synchronous functions returning Html:

const TodoList = ({ todos }: { readonly todos: ReadonlyArray<Todo.Todo> }): Html.Html => (
  <ul>
    {todos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
  </ul>
)

const TodoApp = ({ todos }: { readonly todos: ReadonlyArray<Todo.Todo> }): Html.Html => (
  <main id="todo-app">
    <h1>Todo list</h1>
    <TodoList todos={todos} />
    <NewTodo.Root target="#todo-app">
      <NewTodo.Label name="title">What needs doing?</NewTodo.Label>
      <NewTodo.Input name="title" required />
      <button type="submit">Add todo</button>
    </NewTodo.Root>
  </main>
)

TypeScript sends that JSX to the project's runtime instead of React's. The core of the runtime is just this:

export const jsx = (
  type: ElementType,
  props: Html.Attributes | null,
  _key?: string | number
): Html.Html => {
  if (typeof type === "function") return type(props ?? {})
  return Html.element(type, props)
}

Function components are called directly; intrinsic elements go through the HTML renderer and become a branded Html value ready for an Effect response.

Full pages and fragments

The same handlers serve a complete page to the browser and just the view to the effect-views runtime, when it submits a form or loads a placeholder. Page.layout takes the page component once:

const page = Page.layout(Document)
.handle("show", ({ params, request }) =>
  Effect.gen(function* () {
    const order = yield* orders.get(params.orderId)
    return page(request, <OrderView order={order} />)
  })
)

page(request, view) returns the view on its own for the runtime's requests, which send effect-views-request: true, and inside Document for everything else. Page.wantsFragment(request) makes the same decision for your own code. page(request, view, { status: 404 }) does the same with a status.

A fragment shows what the server rendered, as loading the page would: live components in it show the server's HTML too, and those that run in the browser start again with the state it gave them. Fields keep what was typed while the request was in flight. Scripts in a fragment do not run.

Page.varyLayer adds Vary: effect-views-request to HTML responses, so caches keep pages and fragments apart. A response can tell the runtime what to do with it instead of what the request asked: Page.retarget puts it into another element, Page.swap puts it in another way, or nowhere with "none", Page.redirect loads another page, and Page.trigger dispatches an event from the element that made the request once the response is in the page.

Error pages

Handle expected errors where they happen, and choose their status:

.handle("show", ({ params, request }) =>
  orders.get(params.orderId).pipe(
    Effect.map((order) => page(request, <OrderView order={order} />)),
    Effect.catchTag("OrderNotFound", () =>
      Effect.succeed(page(request, <NotFound />, { status: 404 }))
    )
  )
)

ErrorPage.layer renders everything else: unknown routes, invalid params, unexpected failures, and other middleware's non-HTML error responses, such as the CSRF 403. It keeps the status, logs failures that would have been a 500, and leaves requests that do not accept HTML, such as JSON API calls, alone:

const errorPages = ErrorPage.layer(({ fragment, status }) =>
  fragment ? <p>Something went wrong ({status})</p> : <Document><h1>Error {status}</h1></Document>
)

The runtime does not put error responses where the request asked. <Assets.Head errorTarget="#errors" /> puts them into an element of the page instead, replacing what it contains; without it, they are logged.

Cross-site request forgery

Csrf.layer() rejects cross-origin POST, PUT, PATCH, and DELETE requests with a 403. It needs no tokens or session state: browsers send Sec-Fetch-Site with every request, and the middleware falls back to comparing Origin with Host. Requests with neither header, such as those from curl, are allowed, since no browser can be tricked into sending them.

const app = Layer.mergeAll(routes, Page.varyLayer, Csrf.layer())

Pass trustedOrigins for other sites that may submit forms to the application, and reject to customize the response. Behind a proxy that rewrites Host, browsers without Fetch Metadata need the public origin in trustedOrigins. Set session cookies with SameSite=Lax as well.

Content security policy

Csp.layer() gives every response a fresh nonce and sends a content security policy with it. The default, Csp.strict, allows scripts, styles and images from the page's own origin, and inline scripts and styles only with the nonce. Pass directives to change it:

const app = Layer.mergeAll(routes, Csp.layer({
  directives: (nonce) => ({ ...Csp.strict(nonce), "frame-src": ["https://www.youtube.com"] })
}))

While a request is handled, Csp.nonce() returns its nonce, for the inline scripts and styles a page trusts: <script nonce={Csp.nonce()}>. Assets.Head puts it on its scripts. The runtime adds no inline scripts or styles, so the strict policy needs nothing more. A response that sets its own policy keeps it.

Async views

Components stay synchronous. TypeScript gives every JSX expression the same type, so a component's services and errors would disappear inside a tree. Load data in Effect instead, and render once it has arrived:

const ActivityPanel = Effect.gen(function* () {
  const orders = yield* Orders
  const events = yield* orders.activity
  return <section><ol>{events.map((event) => <li>{event}</li>)}</ol></section>
})
// Effect<Html, never, Orders>

Compose these with Effect.all (optionally concurrent), recover with Effect.catchTag, and the requirements and errors stay in the types.

When part of a page is slow, put it behind its own GET view endpoint and load it after the page, with a View.

Links and lazy views

View.derive does for a GET view endpoint what Form.derive does for a form: it returns typed components, with params and query checked against the endpoint's schemas and encoded as the server decodes them.

const orderActivity = HttpViewEndpoint.get("activity", "/orders/:orderId/activity", {
  params: { orderId: Schema.Int }
})

const Activity = View.derive({ endpoint: orderActivity })

<Activity.Link params={{ orderId: 42 }}>View activity</Activity.Link>
// <a href="/orders/42/activity">View activity</a>

<Activity.Lazy params={{ orderId: 42 }}>
  <p>Loading activity…</p>
</Activity.Lazy>
// <div data-load='{"url":"/orders/42/activity","when":"load","swap":"outer"}'>…</div>

Activity.url({ params: { orderId: 42 } }) // "/orders/42/activity"

Lazy renders a placeholder that the runtime replaces with the view. Its trigger is "load", the default, "visible" to wait until it scrolls into view, or { every: "30 seconds" } to load it again and again; give that swap="inner", which keeps the placeholder and replaces what it contains, so there is a placeholder to load into next time. as chooses the element. It needs JavaScript, so keep essential content in the page, or put an Activity.Link in a <noscript>.

Components

Component.make defines markup rendered inside a custom element, with styles scoped to it:

import * as Component from "effect-views/Component"
import { css } from "effect-views/Component"

const Card = Component.make("app-card", {
  style: css`
    :scope { display: block; padding: 1rem; border: 1px solid #ddd; }
    h2 { margin-top: 0; }
  `,
  render: ({ title, children }: { title: string; children: Html.Child }) => (
    <>
      <h2>{title}</h2>
      {children}
    </>
  )
})

<Card title="Details">…</Card>
// <app-card data-component><h2>Details</h2>…</app-card>

Styles are wrapped in @scope, so they apply inside the element, with :scope for the element itself, and stop at nested components. Editors highlight CSS in templates tagged css.

Live components

A live component has state, a view of it, and actions that change it. By default it runs on the server: the effect-views runtime posts the component's state to an action, and updates the component in place with the HTML of the state the action returns. No JavaScript of your own runs in the browser, so handlers can use services, the database, and anything else on the server. A component can run in the browser instead, with the same contract and view; see In the browser.

Like an HttpApi endpoint, a live component is declared in one place and implemented in another. The contract holds the state and the actions:

import * as LiveAction from "effect-views/LiveAction"
import * as LiveComponent from "effect-views/LiveComponent"

export class SearchState extends Schema.Class<SearchState>("SearchState")({
  query: Schema.String,
  matches: Schema.Array(Todo)
}) {}

export const search = LiveAction.make("search", {
  payload: { query: Schema.optionalKey(Schema.String) },
  // Runs once typing pauses, and a newer search replaces one in flight
  debounce: "200 millis"
})

export const Search = LiveComponent.make("todo-search", { state: SearchState }).add(search)

The view needs only the contract, so it renders without the handlers or their services. Each action is a command, bound to an event with On, as data in an attribute:

export const SearchView = LiveComponent.view(Search, {
  style: css`:scope { display: block; }`,
  render: (state, actions) => (
    <>
      <input type="search" name="query" value={state.query} {...On.input(actions.search())} />
      <ul>{state.matches.map((todo) => <li>{todo.title}</li>)}</ul>
    </>
  )
})

<SearchView state={new SearchState({ query: "", matches: [] })} />

The handlers are a layer, with the services they use provided to it. They return the next state, and TypeScript requires one for every action:

export const SearchHandlers = LiveComponent.server(SearchView, {
  search: (_state, { query = "" }) =>
    Effect.gen(function* () {
      const todos = yield* Todos
      return new SearchState({ query, matches: yield* todos.search(query) })
    })
}).pipe(Layer.provide(Todos.layer))

Actions are HttpApi endpoints. Add them to the group of the views the component appears in, so that the group's HttpApi middleware, such as authentication, applies to them, and the OpenAPI document lists them with those views. Then handle them in that group's handlers; TypeScript requires both, and the group's layer requires the component's handlers:

export const group = HttpApiGroup.make("todosViews")
  .add(list, create)
  .add(...LiveComponent.endpoints(Search))

export const TodosViewsLayer = HttpApiBuilder.group(Api, "todosViews", (handlers) =>
  handlers
    .handle("list", …)
    .handle("create", …)
    .pipe(LiveComponent.handle(Search))
).pipe(Layer.provide([Todos.layer, SearchHandlers]))

Give an action a description to summarize it in the OpenAPI document.

  • State is encoded with its schema and signed, in a hidden input, so the server keeps nothing between requests and the browser cannot change it. A problem to show the user, such as a failed search, belongs in the state.
  • Payloads are decoded like forms, from the inputs in the component and any values given where the action is used, such as On.click(actions.add({ by: 5 })).
  • The component is updated in place, element by element, so focus and the caret survive the update. Fields show what the response renders, except those typed in while the request was in flight.
  • A component's actions run one at a time, in order, since each sends the state the last one returned, so quick clicks all count.
  • Actions are served at /live/<tag>/<action>; do not prefix their group or its API. An action answers 400 when its state or payload cannot be decoded.
  • Live components need the effect-views runtime, which Assets.layer bundles and <Assets.Head /> loads. Rendering one while handling a request without Assets.layer is an error.
  • State is signed with a key chosen at startup, so nothing needs to be provided. Pages rendered before a restart, or by another server, stop accepting actions; to share a key, provide LiveComponent.secret(Redacted.make(...)) to HttpRouter.serve.
  • An action with concurrency: "switch" replaces a request in flight with a newer one, aborting it; debounce waits for events to stop first.
  • A link or submit button bound with On.click runs the action instead of navigating or submitting.

In the browser

Some components respond to every key press or drag, or keep state that never needs the server: a field that checks itself as you type, a list of items being edited. Declare them with runs: "browser". The contract and the view are the same; the view renders on the server first, and then in the browser after each change, updating the page in place so focus and the caret stay where they are.

export const edit = LiveAction.make("edit", {
  payload: { title: Schema.String },
  // A newer keystroke interrupts the run before it, and the request it made
  concurrency: "switch"
})

export const TitleField = LiveComponent.make("todo-title", { state: TitleState, runs: "browser" })
  .add(edit)

In the view, each action is a command, bound to an event with On. A value can be read from the element when the event happens, with On.value or On.checked. Bindings are data in attributes, so nothing in the markup is code, and a strict content security policy still applies:

export const TitleFieldView = LiveComponent.view(TitleField, {
  render: (state, actions) => (
    <>
      <input name="title" value={state.title} {...On.input(actions.edit({ title: On.value }))} />
      <p aria-live="polite">{state.problem}</p>
    </>
  )
})

On.click, On.input, On.change, On.submit, On.focusin and On.focusout take commands, and On.event binds the others the runtime handles, listed in On.events; they bubble, so one listener on the document serves the page. On.keydown and On.keyup take a map of keys, and the keys in it no longer do what they usually do, so Enter can choose an option rather than submit the form; keys left out go to bindings further out. Give items in lists a key, rendered as data-key, so they keep their elements as items are added and removed; on a component, such as a live component in a list, it goes on the component's element, so each keeps its own state.

The handlers run in the browser. They change the state rather than return it, because actions overlap there, such as a key press while a check waits for the server. Services come from a layer, built once per page and shared by the components that provide it. A handler can call the application's own API with HttpApiClient, typed by the same HttpApi the server implements:

// TitleField.browser.ts
const client = HttpApiClient.make(DataApi.Api)

export const TitleFieldHandlers = LiveComponent.browser(TitleFieldView, {
  edit: (state, { title }) =>
    Effect.gen(function* () {
      yield* state.update((s) => new TitleState({ ...s, title, checking: true }))
      yield* Effect.sleep("300 millis")
      const { todosApi } = yield* client
      const taken = (yield* todosApi.list()).some((todo) => todo.title === title.trim())
      yield* state.update((s) => new TitleState({ ...s, checking: false, problem: taken ? "Already on the list" : "" }))
    }).pipe(Effect.catch(() => state.update((s) => new TitleState({ ...s, checking: false }))))
}, { layer: FetchHttpClient.layer })

Call LiveComponent.browser at the top of a module of its own: effect-views bundles that module, and everything it imports, for the browser when Assets.layer is built, so it must not import anything that only runs on the server. There is no build step and nothing to configure; give Assets.layer the API, and add and handle the component like one that runs on the server:

export const group = HttpApiGroup.make("todosViews")
  .add(...LiveComponent.endpoints(TitleField))

… .pipe(LiveComponent.handle(Search, TitleField))
).pipe(Layer.provide([SearchHandlers, TitleFieldHandlers]))

Assets.layer({ api: Api })
  • State is encoded as JSON in an attribute of the component's element, and never sent back: the server does not trust it, so check anything the component submits, such as the fields of the form around it.
  • Code is bundled when the server starts, from JavaScript, TypeScript or JSX, compiled or not: one file per module, with only the code the components use and none of the whitespace they don't need, and imports pointing at each other's addresses, which change with their contents. <Assets.Head /> loads the runtime, and each component's view preloads its own modules.
  • Keep a browser component's contract and view in modules of their own, apart from modules that declare the server's endpoints: everything they import is bundled, and calls at the top of a module, such as building an HttpApi group, are kept.
  • The component's endpoint, /live/<tag>/module.js, serves its code from an address that does not change; it is also how Assets.layer finds the component in the API.

Assets

Assets.layer builds the files when the server starts: the effect-views runtime, one stylesheet for every component, and the application's own files. They're served by an HttpApi group, with names that change with their contents, cached for a year. Add Assets.group to the application's API and provide Assets.groupLayer to its layer:

export const Api = HttpApi.make("app").add(Views.group).add(Assets.group)

const ApiRoutes = HttpApiBuilder.layer(Api).pipe(
  Layer.provide([ViewsLayer, Assets.groupLayer(Api)])
)

HttpRouter.serve(Layer.mergeAll(ApiRoutes, ErrorPages)).pipe(
  Layer.provide(Assets.layer({ files: { "app.css": new URL("./public/app.css", import.meta.url) } })),
  Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
)

Put <Assets.Head /> in the page head for the runtime and the components' styles, and refer to the application's files with Assets.url: <link rel="stylesheet" href={Assets.url("app.css")} />. Provide Assets.layer to HttpRouter.serve, rather than only to the API's layer, so that error pages rendered by middleware can render the head too; with HttpRouter.toWebHandler, use Layer.provideMerge.

Pass api to bundle the live components in the API that run in the browser; see In the browser. Pass prefix to serve the files from somewhere other than /assets, with Assets.groupAt(prefix) in the API instead of Assets.group. Components must be defined before the layer is built; rendering one defined later is an error.

The files are compressed when the layer is built, with brotli at its highest quality and with gzip, and each request gets the smallest its Accept-Encoding allows, with Vary: Accept-Encoding. They never change once built, so this happens once rather than for each request; it adds a few tens of milliseconds to starting the server. Pass compress: false when a proxy or CDN in front of the server compresses them. Pages change with each request: to compress them, add Effect's HttpMiddleware.compression(), which leaves the files alone since they are compressed already.

Streaming views

HttpViewEndpoint.events declares a GET endpoint that streams HTML fragments as server-sent events. Its handler returns a Stream of HtmlEvent, each an event name and its HTML, and View.derive gives it a Subscribe element whose contents the runtime replaces with each event of that name:

export const changes = HttpViewEndpoint.events("changes", "/todos/changes")

const Changes = View.derive({ endpoint: changes })

<Changes.Subscribe event="todos">
  <TodoList todos={todos} />
</Changes.Subscribe>
// <div data-subscribe='{"url":"/todos/changes","events":["todos"],"swap":"inner"}'>…</div>
.handle("changes", () =>
  Effect.succeed(todos.changes.pipe(
    Stream.map((items) => ({ event: "todos", data: <TodoList todos={items} /> }))
  )))

event can also list several events, and swap="append" adds each event's HTML after what the element contains, as a log would.

When different parts of a page take different events, open one connection with Connect, and give each part its event with Changes.event(name):

<Changes.Connect as="main">
  <section {...Changes.event("todos")}><TodoList todos={todos} /></section>
  <p {...Changes.event("count")}>{todos.length} todos</p>
</Changes.Connect>
// <main data-subscribe='{"url":"/todos/changes","swap":"inner"}'>…</main>

The endpoint is an ordinary HttpApi endpoint, with the API's middleware, and the OpenAPI document describes it as text/event-stream. The stream ends when the browser closes the connection, when the element leaves the page.

The browser reconnects when it loses the connection. A stream that starts with the current state, such as SubscriptionRef.changes, keeps pages in step when it does.

Checks

pnpm check  # Type-check source, examples, and compile-time test assertions
pnpm test   # Run renderer, form, HTTP, and browser tests

The tests in test/browser run the runtime and the morph in headless Chromium, driven through the Chrome DevTools Protocol with nothing to install beyond the browser. They look for chromium or google-chrome on the PATH; set CHROMIUM to its path otherwise. Without a browser they are skipped.

About

An unfinished experiment in server-rendered HTML and schema-bound forms with Effect v4

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages