An unfinished prototype for server-rendered HTML and schema-bound forms with Effect, and a small Effect runtime in the browser.
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.
HttpViewEndpointgives Effect endpoints an HTML success schema.HttpFormEndpointdescribes a URL-encoded POST with an EffectStructschema, thenForm.deriveturns 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
Pagereturns 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, andform.with(invalid)renders the form again with the user's input and errors. View.deriveturns a GET view endpoint into typed links, and placeholders that load slow parts of a page later.Csrf.layerandErrorPage.layercover cross-site requests and HTML error pages as middleware.Component.makekeeps a component's markup and scoped CSS in one.tsxfile. 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.layerbundles 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.
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.
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.
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")}>.
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.
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 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.
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.
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="{"start":1,"end":4}">Leave value out for one the page's own script fills in, finding the input by
Comment.idFor("anchor").
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.
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.
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.
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.
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.
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.
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.
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>.
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.
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.layerbundles and<Assets.Head />loads. Rendering one while handling a request withoutAssets.layeris 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(...))toHttpRouter.serve. - An action with
concurrency: "switch"replaces a request in flight with a newer one, aborting it;debouncewaits for events to stop first. - A link or submit button bound with
On.clickruns the action instead of navigating or submitting.
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 howAssets.layerfinds the component in the API.
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.
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.
pnpm check # Type-check source, examples, and compile-time test assertions
pnpm test # Run renderer, form, HTTP, and browser testsThe 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.