effect-frame
v0.28.0
Published
Actors, queries, views and a router on Effect.
Readme
effect-frame
Actors, queries, views and a router on Effect. One process holds the actors; pages read them through queries and actor references, draw with typed JSX, and move with a router that owns its data.
This README is the reference for writing an app. Every TypeScript block
below is a region of a file in
packages/effect-frame/examples/,
which the gate typechecks, lints and tests
(packages/effect-frame/tests/examples/). The words are defined in
the glossary. The links point at the repository, so they
work from the published package too.
Rules an app follows
- A view is a function of its props that returns an Effect:
(props) => Effect.gen(function* () { ... }), or an arrow over one Effect when it yields nothing else, such asEffect.succeed(<p>…</p>). It runs once per mounted identity. A change moves only what binds the source that changed. - A view names the props it is given, even a page that reads none: a leaf
types them
Route.PropsOf<typeof segment>, a layoutRoute.LayoutPropsOf<typeof segment, ChildR>. The Effect language service'slazyEffectrule refuses an exported view with no parameter,() => Effect.gen(...). - A PascalCase JSX tag is a framework tag:
For,Show,Match,Portal,Await, or the router'sLink. A child view is a function the parent yields, never a tag. A synchronous helper that returns a node is called as a function,Row(props), never written as a tag. - In an app of more than one file, the segments live in
segments.ts, a file with no view: the views name the segments, the tree names the views, and neither imports the other's file back. - A contract, a query, and a behavior are browser safe. The server half of
an actor or query (
implementTransparent,implementQuery), the host, the policy table, and the HTTP handler live in*.server.tsfiles. - A browser entry imports
effect-frame/actor/client, nevereffect-frame/actor, and reaches no*.server.tsfile. - Every choice is written where it is made: a route names its rendering mode, a host names its store and its policies, a handler names its body limit and its principal. There are no defaults to find out about.
- An Effect is yielded or composed.
Effect.provideappears once, at the entry point, with@effect-diagnostics-next-line strictEffectProvide:off.
Subpaths
| Subpath | Holds | Runs in |
| ------------------------------- | -------------------------------------------------------------------------- | ------- |
| effect-frame/actor | the host, implementTransparent, implementQuery, Policy, HttpServer | server |
| effect-frame/actor/client | contract, query, Behavior, Actor, QueryCache, HttpTransport | both |
| effect-frame/actor/testing | HttpTest and the conformance suites | tests |
| effect-frame/view | View, the JSX tags, Dom, Html, Remote | both |
| effect-frame/view/testing | ViewTest | tests |
| effect-frame/view/driven | Driven.session, a server-driven view | server |
| effect-frame/view/opentui | the terminal host | both |
| effect-frame/router | Route, link, Link, hydrate, mount, renderDocument | both |
| effect-frame/router/prerender | Prerender.build, load, serve | server |
| effect-frame/frame | Frame.layer, Frame.inspect | both |
| effect-frame/inspection | Protocol, attachGateway | browser |
The JSX runtimes are effect-frame/view/jsx-runtime,
effect-frame/view/jsx-dev-runtime, and their view/opentui/ twins. A
tsconfig.json names "jsxImportSource": "effect-frame/view".
That runtime is app config, as the port and the store are, and the app
reads its config itself: the environment through Config, the JSX runtime
through its tsconfig.json. bun run and bun test read only the
tsconfig of the working directory, so a server runs from its app directory
(bun --cwd <app> server.ts), and no file names the runtime again.
A first app
A counter per name, on one server page each, with the names beside it.
The files are
packages/effect-frame/examples/counter/,
and packages/effect-frame/tests/examples/counter.test.tsx runs them as the
server does.
The contract, the behavior and the query are shared by both sides.
import { Behavior, contract, query } from "effect-frame/actor/client";
import { Match, Schema } from "effect";
// A message is a tagged struct. A form posts strings, so a number field
// decodes from a string: `FiniteFromString`, not `Finite`.
export const Increment = Schema.TaggedStruct("Increment", { by: Schema.FiniteFromString });
export const Reset = Schema.TaggedStruct("Reset", {});
export const CounterMessage = Schema.Union([Increment, Reset]);
export type CounterMessage = Schema.Schema.Type<typeof CounterMessage>;
// The contract is the public face of an actor: its key, its snapshot, its
// messages, and the policy that judges every read and send. Browser safe.
export const Counter = contract("Counter", {
version: 1,
policy: "public",
key: Schema.Struct({ name: Schema.String }),
snapshot: Schema.Finite,
message: CounterMessage,
});
// The behavior is pure and browser safe too: the server runs it, and a
// page that holds it predicts a send before the server commits it.
export const counterBehavior = Behavior.reducer<number, CounterMessage>({
initial: 0,
reduce: (count, message) =>
Match.valueTags(message, {
Increment: (increment) => count + increment.by,
Reset: () => 0,
}),
});
// A query is a named server read. `depends` names the contracts whose
// commits make it stale; `version` is its wire version, as a contract's is.
export const CounterNames = query("CounterNames", {
version: 1,
args: Schema.Struct({}),
result: Schema.Array(Schema.String),
policy: "public",
depends: [Counter],
});The server half runs the actors and answers the query.
import {
ActorHost,
Policies,
Policy,
implementQuery,
implementTransparent,
} from "effect-frame/actor";
import { Effect, Layer } from "effect";
import { Counter, CounterNames, counterBehavior } from "./contract.js";
// A `.server.ts` file runs only on a server: `bun run boundary` fails a
// browser entry that reaches it.
export const CounterLive = implementTransparent(Counter, { behavior: counterBehavior });
export const CounterNamesLive = implementQuery(CounterNames, {
run: () => Effect.succeed(["home", "work"]),
});
// Every policy name a contract or query declares has a rule here. There is
// no default: allow-all is written by name.
export const policies = Layer.succeed(Policies, Policies.of({ public: Policy.allowAll }));
// The host runs the actors. `store` says where their mailboxes live; the
// memory store keeps nothing across a restart.
export const host = ActorHost.layer({
implementations: [CounterLive],
queries: [CounterNamesLive],
store: ActorHost.memoryStore,
}).pipe(Layer.provide(policies), Layer.orDie);A query reads an actor through a reference, as a client does.
// A query reads an actor through a reference, as a client does. The host
// gives each run an `ActorTransport` and a `Scope`: the reference closes
// when the read ends, so `run` needs no `Effect.scoped`.
const TotalsLive = implementQuery(Totals, {
run: (args) =>
Effect.flatMap(
Actor.remote(Ledger, { tenant: args.tenant, id: "book" }),
(book) => book.state.get,
),
});The routes file imports from the router, the view, and the contract.
import type { QueryState, RemoteActorRef } from "effect-frame/actor/client";
import { Source } from "effect-frame/actor/client";
import type { NotFoundProps } from "effect-frame/router";
import { Link, Route, link } from "effect-frame/router";
import { For, Show, View } from "effect-frame/view";
import { Effect, Schema } from "effect";
import { Counter, CounterNames, Increment, Reset, counterBehavior } from "./contract.js";Segments are the addresses, and each one declares the data its page needs.
// A segment is an address: a path template, the params it declares, and the
// data its page needs. `data` derives each declaration from the params and
// the search, and the route opens, moves and releases it.
export const shell = Route.segment("shell", {
path: "/",
data: () => ({ names: Route.query(CounterNames, {}) }),
});
// A child continues its parent's path and declares only its own params.
export const counter = Route.child(shell, "counter", {
path: "counters/:name",
params: Schema.Struct({ name: Schema.String }),
data: ({ params }) => ({
counter: Route.actor(Counter, { name: params.name }, { behavior: counterBehavior }),
}),
});A leaf view draws one page.
// A view is `(props) => Effect.gen(...)`. It runs once per mounted identity;
// a change moves only what binds the source that changed.
export const CounterView = (props: Route.PropsOf<typeof counter>) =>
Effect.gen(function* () {
// `state` follows the actor the route holds now, across param moves.
const count = props.data.counter.state;
const big = Source.select(count, (value) => value >= 10);
// One region per actor: a move to another counter builds its form again.
const controls = yield* View.keyed(
props.data.counter.ref,
(ref) => ref.key.name,
(ref) => Effect.flatMap(ref.get, (current) => Controls({ counter: current })),
);
return (
<section>
<h1>{View.bind(props.params, (params) => params.name)}</h1>
<p>count: {View.bind(count)}</p>
<Show when={big}>
<p>that is a lot</p>
</Show>
{controls}
</section>
);
});
// A child view is a function the parent yields, never a JSX tag.
const Controls = (props: { readonly counter: RemoteActorRef<typeof Counter> }) =>
Effect.gen(function* () {
// The form posts with no script and sends over the transport with one.
const add = yield* View.form({
ref: props.counter,
message: Increment,
typed: ["by"],
endpoint: "/actors",
returnTo: counter.href({ name: props.counter.key.name }, {}),
});
const reset = View.event(Effect.asVoid(props.counter.send(Reset.make({}))));
return (
<div>
<form onSubmit={add.submit}>
<input name="by" value="1" />
<button type="submit">add</button>
</form>
<For each={add.issues} keyBy={(issue) => `${issue.field}:${issue.message}`}>
{(issue) => <p>{View.bind(issue, (one) => one.message)}</p>}
</For>
<button type="button" onClick={reset}>
reset
</button>
</div>
);
});A layout view wraps its child.
// A layout wraps its child's view, which it gets as `props.outlet`. It stays
// generic in `ChildR`, what the child's view needs; in a .tsx file the
// generic takes a trailing comma.
export const ShellView = <ChildR,>(props: Route.LayoutPropsOf<typeof shell, ChildR>) =>
Effect.gen(function* () {
// `View.loading` shows its fallback until every `View.ready` inside it
// has a first value. A `View.ready` with no `View.loading` above it
// does not compile where the tree is mounted.
const nav = yield* View.loading({
fallback: <p>loading</p>,
content: Names({ names: props.data.names.state }),
});
const body = yield* View.loading({ fallback: <p>loading</p>, content: props.outlet });
return (
<main>
<nav>{nav}</nav>
{body}
</main>
);
});
const Names = (props: { readonly names: Source<QueryState<ReadonlyArray<string>, unknown>> }) =>
Effect.gen(function* () {
const names = yield* View.ready(props.names, []);
// A keyed list whose rows run an Effect: here, each row makes its link.
const rows = yield* View.list({
each: names,
keyBy: (name) => name,
row: (name) =>
Effect.gen(function* () {
const current = yield* name.get;
const to = yield* link(counter, { name: current }, {});
return (
<li>
<Link link={to}>{current}</Link>
</li>
);
}),
});
return <ul>{rows}</ul>;
});The routes mount the tree in one rendering mode.
// A route is a tree of branches mounted by one rendering-mode constructor.
// `Route.ssr` resolves every declared read on the server before it draws.
export const App = Route.ssr(
"app",
Route.layout(shell, [Route.leaf(counter, CounterView)], ShellView),
);
// `/` has no page of its own.
export const Home = Route.redirecting("home", Route.segment("home", { path: "/" }), () =>
Effect.succeed(Route.redirect(counter, { name: "home" }, {})),
);
export const routes = [Home, App];
export const NotFound = (_props: NotFoundProps) => Effect.succeed(<p>no such page</p>);The server document and the browser entry name the same root element.
// The element the page mounts into. The server's document names it and the
// browser entry finds it, both from here.
export const rootId = "app";The server renders a page for each request, and serves the actors. Both
are Effect HTTP apps: they answer the HttpServerRequest in context with
an HttpServerResponse (effect/unstable/http), so an HttpRouter mounts
them.
import { HttpServer } from "effect-frame/actor";
import type { Principal } from "effect-frame/actor/client";
import { Anonymous } from "effect-frame/actor/client";
import { redrawDocument, renderDocument, respondDocument } from "effect-frame/router";
import type { Html } from "effect-frame/view";
import { Effect, Option } from "effect";
import { HttpServerResponse } from "effect/unstable/http";
import { Counter } from "./contract.js";
import { rootId } from "./document.js";
import { NotFound, routes } from "./routes.js";
// The document around the drawing. The renderer writes `<div id={rootId}>`
// between `head` and `tail`, and a refused form post's issues after `tail`.
const page: Html.Document = {
head: '<!doctype html><html><head><meta charset="utf-8"></head><body>',
rootId,
tail: "",
bootstrap: '<script type="module" src="/client.js"></script>',
end: "</body></html>",
};
// Render one URL through the routes, for one principal. The route's
// constructor picks the rendering mode; nothing here names one.
export const renderPage = (url: URL, principal: Principal) =>
renderDocument({
routes,
notFound: NotFound,
url,
document: page,
closeWhen: Effect.sleep("10 seconds"),
principal,
});
// This app has no sessions: every page is drawn for nobody in particular.
const nobody: Principal = Anonymous.make({});
// A page request. `respondDocument` reads the request's URL, owns the
// render's Scope, and answers a redirect with 303, a document with its
// status, and a defect with 500.
export const answerPage = respondDocument((url) => renderPage(url, nobody), {
onTimeout: () =>
Effect.succeed(HttpServerResponse.text("the page took too long", { status: 504 })),
});
// The actor routes: the JSON verbs, the change streams, and the plain form
// route, each at `prefix` + its path, on the app's router. Every edge
// decision is written here.
export const actors = HttpServer.layer({
prefix: "/actors",
principal: HttpServer.anonymous,
maxBodyBytes: HttpServer.defaultMaxBodyBytes,
form: Option.some({
contracts: [Counter],
login: Option.none(),
// A refused post draws its page again, for the principal that posted.
render: redrawDocument(renderPage),
commitWithin: HttpServer.defaultCommitWithin,
}),
});The browser entry hydrates what the server drew.
import { HttpTransport, QueryCache } from "effect-frame/actor/client";
import {
Location,
NavigationBehavior,
browserNavigation,
followLinks,
hydrate,
} from "effect-frame/router";
import { Dom } from "effect-frame/view";
import { Effect, Layer } from "effect";
import { FetchHttpClient } from "effect/unstable/http";
import { rootId } from "./document.js";
import { NotFound, routes } from "./routes.js";
// The browser entry: it imports `effect-frame/actor/client`, never
// `effect-frame/actor`, and reaches no `.server.ts` file.
const start = Effect.gen(function* () {
const root = yield* Dom.root(rootId);
// `hydrate` reads what the server's document carries, mounts the routes
// over its nodes, and follows the URL from then on.
const { router } = yield* hydrate({
routes,
notFound: NotFound,
root,
landing: NavigationBehavior.Restore,
traversalReadLimit: "3 seconds",
});
yield* followLinks(document, router);
return yield* Effect.never;
});
const transport = HttpTransport.layer({
baseUrl: `${location.origin}/actors`,
reconnect: HttpTransport.defaultReconnect,
}).pipe(Layer.provide(FetchHttpClient.layer));
// The client's services: the transport, the query cache, and the URL.
const services = Layer.mergeAll(
transport,
QueryCache.layer,
Layer.effect(Location, browserNavigation),
);
// The entry point: the one place the client's services are provided.
// @effect-diagnostics-next-line strictEffectProvide:off
Effect.runFork(Effect.scoped(Effect.provide(start, services)));The process edge builds the bundle and serves. It is the only file that
names Bun: HttpRouter.toWebHandler turns the router into the web fetch
Bun serves, and a Durable Object's fetch is the same function.
// oxlint-disable effect/noGlobals -- the process edge: Bun builds the bundle and serves the router's web handler.
import { Effect, Layer } from "effect";
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
import { host } from "./counter.server.js";
import { actors, answerPage } from "./page.server.js";
// The browser bundle, built once when the router is built.
const bundle = Effect.promise(() =>
Bun.build({ entrypoints: ["./client.tsx"], target: "browser" }),
).pipe(
Effect.flatMap((built) =>
Effect.forEach(built.outputs, (out) => Effect.promise(() => out.text())),
),
Effect.map((parts) => parts.join("\n")),
);
// One router serves the actor routes, the bundle, and every page.
const app = Layer.mergeAll(
actors,
Layer.unwrap(
Effect.map(bundle, (text) =>
HttpRouter.add(
"GET",
"/client.js",
HttpServerResponse.text(text, { contentType: "text/javascript" }),
),
),
),
HttpRouter.add("GET", "/*", answerPage),
);
// The platform boundary: the host layer under the router holds the actors,
// and `toWebHandler` is the `fetch` Bun serves.
const { handler } = HttpRouter.toWebHandler(Layer.provideMerge(app, host), { disableLogger: true });
Bun.serve({ port: 3000, fetch: (request) => handler(request) });What a view calls
| Write | Kind | For |
| ----------------------------------------- | -------------- | ---------------------------------------------------------------------------- |
| Actor.local(Behavior.value(initial)) | yielded Effect | the view's own state: read state, write send(Value.Set(next)) |
| View.bind(source, f?) | prop or child | a value that follows a source, projected by f where it is drawn |
| View.event(handler \| effect) | on* prop | an event handler, or the Effect a handler that reads no event runs |
| View.submit(handler \| effect) | onSubmit | a submit whose default action the host suppresses |
| View.form({ ref, message, ... }) | yielded Effect | a command form that posts with no script |
| View.list({ each, keyBy, row }) | yielded Effect | a keyed list whose rows run an Effect |
| View.keyed(source, keyBy, row) | yielded Effect | one region built again for each new identity |
| View.show({ when, content, fallback }) | yielded Effect | a branch whose setup runs only while a boolean source is true |
| View.match(on, cases) | yielded Effect | one branch per tag of a union source, whose case runs a setup |
| View.loading({ fallback, content }) | yielded Effect | a boundary that shows fallback until every View.ready inside has a value |
| View.errored({ fallback, content }) | yielded Effect | a boundary that shows fallback, given the first QueryFailure, on failure |
| View.ready(state, placeholder) | yielded Effect | a query's value, inside View.loading |
| View.readyWithStale(state, placeholder) | yielded Effect | the same, with the stale flag |
| View.orErrored(state) | yielded Effect | a query's state whose QueryFailure goes to View.errored |
| View.attempt(setup, fallback) | yielded Effect | one setup's typed failure, handled in place |
| View.lazy(load) | a view | a view imported on first use |
| View.attach(run), Dom.attach(run) | attach prop | a behaviour on the host node, for the element's lifetime |
| <For each keyBy fallback?> | tag | a keyed list of plain rows, and fallback while it has none |
| <Show when fallback?> | tag | a branch while a boolean source is true, and fallback while it is not |
| <Show when is>{(narrowed) => ...} | tag | a branch while is holds, given a source of the narrowed value |
| <Match on cases> | tag | one branch per tag of a union source |
| <Await state loading failed ready> | tag | all three states of a query in one place; failed gets the last value too |
| <Portal into={Dom.target(element)}> | tag | children drawn under a node the host made a target of |
| View.mount, View.flush | yielded Effect | mounting a view without the router, and settling it in a test |
Sources
A Source<A> is a value that changes: get reads it now, and changes
starts with the current value. The combinators sit on the Source
namespace from effect-frame/actor/client, source first, as Stream.map
sits beside Stream.
| Write | Gives | For |
| ------------------------------------- | --------------------------------- | -------------------------------------------- |
| Source.select(source, f) | Source<B> | a projection, computed where it is read |
| Source.zip(a, b) | Source<readonly [A, B]> | the pair, as Effect.zip gives one |
| Source.zipWith(a, b, f) | Source<C> | the pair combined by f |
| Source.all({ a, b }), all([a, b]) | Source<{ a: A, b: B }>, a tuple | a struct or tuple of sources as one |
| Source.switchMap(source, f) | Source<B> | the source f gives for the current value |
| Source.dedupe(source, equivalence) | Source<A> | changes that differ from the last one only |
| Source.debounce(source, duration) | yielded Effect | a change once the source has been quiet |
| Source.load(source, f) | yielded Effect | a QueryState of an Effect run per value |
| Source.on(source, f) | yielded Effect | work for each value, for the view's lifetime |
A combined source reads every side again when one side changes, so it never holds a value older than a side would answer alone.
One branch per case
Conditions over one value are one tagged union, matched once. Project the
source to the union with Source.select and draw it with <Match>: each
case gets a source of its own member, and a missing case does not compile.
This replaces a Show inside a Show, which tracks two sources and nests a
render function per level. When a case runs a setup, View.match(on, cases)
is the same table as a yielded Effect.
/** A search hit's citation: its reference code and link, when it has them. */
export interface Hit {
readonly refcode: Option.Option<string>;
readonly url: Option.Option<string>;
}
/** The three ways a citation draws, as one union: no `Show` inside a `Show`. */
export type Cite =
| { readonly _tag: "Missing" }
| { readonly _tag: "Unlinked"; readonly refcode: string }
| { readonly _tag: "Linked"; readonly refcode: string; readonly url: string };
const citeOf = (hit: Hit): Cite =>
Option.match(hit.refcode, {
onNone: (): Cite => ({ _tag: "Missing" }),
onSome: (refcode) =>
Option.match(hit.url, {
onNone: (): Cite => ({ _tag: "Unlinked", refcode }),
onSome: (url): Cite => ({ _tag: "Linked", refcode, url }),
}),
});
// One source of the union, matched once. Each case gets a source of its own
// member, and a missing case does not compile.
export const Reference = (props: { readonly hit: Source<Hit> }) =>
Effect.succeed(
<Match
on={Source.select(props.hit, citeOf)}
cases={{
Missing: () => <span class="refcode" />,
Unlinked: (cite) => <span class="refcode">{View.bind(cite, (c) => c.refcode)}</span>,
Linked: (cite) => (
<a class="refcode" href={View.bind(cite, (c) => c.url)}>
{View.bind(cite, (c) => c.refcode)}
</a>
),
}}
/>,
);View state
A view's own state is a local actor. It lives in the view's scope and
stops with it. A write is a send, whose handle never fails, so it fits a
handler as it is. A message with a generated field, such as the id of the
thing it creates, is sent with Generated.send, which mints that field
from the command's own id.
// A view's own state is a local actor. `Behavior.value` holds one value:
// `state` reads it, and `send(Value.Set(next))` writes it. The actor lives
// in the view's scope and stops with it.
export const Draft = (props: { readonly notes: RemoteActorRef<typeof Notes> }) =>
Effect.gen(function* () {
const draft = yield* Actor.local(Behavior.value(""));
const empty = Source.select(draft.state, (text) => text.trim() === "");
// A message with a generated field is sent without it: `Generated.send`
// mints `id` from the command's own id, so a retry sends the same one.
const add = Effect.gen(function* () {
const text = yield* draft.state.get;
yield* Generated.send(props.notes, { _tag: "Add", text, pinned: false });
yield* draft.send(Value.Set(""));
});
return (
<form onSubmit={View.submit(add)}>
<input
name="draft"
value={View.bind(draft.state)}
onInput={View.event((event) => draft.send(Value.Set(event.value)))}
/>
<button type="submit" disabled={View.bind(empty)}>
add
</button>
</form>
);
});LocalValueRef<A> types a prop that passes such a reference on. State the
URL keeps is the segment's search or a UrlState (see "State in the URL").
JSX
The tags are a closed, typed map. A tsconfig.json with
"jsxImportSource": "effect-frame/view" gets the HTML tags, which the DOM,
HTML, and Remote hosts share. A terminal file names its own runtime in a
block comment at its top, @jsxImportSource effect-frame/view/opentui, and
gets box, text, and input, whose props are the OpenTUI renderables'
own options.
- A prop is the attribute as HTML spells it:
class,for,tabindex,contenteditable.classNamedoes not compile. - A value is written once, or bound to a source with
View.bind. A rawSourcereportsProperty '"wrap the source with View.bind(source)"' is missing. - An
on*prop is the event in lowercase (onKeyDownlistens forkeydown) and takesView.eventorView.submit. A plain function reports"wrap the handler with View.event(handler)". A handler that reads its event isView.event((event) => ...), whereevent.valueis an input's text; one that reads none is the Effect itself,View.event(addPane), run once per event. Either way the Effect cannot fail: a view has no place to return a failure. A write to a local actor fits as it is:sendandmodifyreturn a handle and never fail, and a stopped actor is aRejectedstate of that handle. View.submithas the host suppress the default action first. A form'sonSubmittakes only that kind,View.submitor thesubmitofView.form's result, so a form never posts natively by mistake. A view writes nomethodoraction: the runtime writes a command form's plain post.- A void element (
input,img,br) holds no children.
Routing
effect-frame/router exports one route model on the Route namespace. A
segment is an address. A branch is a segment with its view. A mode
constructor mounts a whole tree: Route.client, Route.ssr,
Route.streamed, Route.awaitAll, Route.prerender, or Route.driven.
import { Link, NavigationBehavior, Route, link } from "effect-frame/router";
import { View } from "effect-frame/view";
import { Effect, Schema } from "effect";
import { TenantInfo, isSignedIn } from "./tenant.js";// A search codec decodes the query string; `withDefault` fills a missing key.
export const login = Route.segment("login", {
path: "/login",
search: Route.search(Schema.Struct({ next: Schema.String.pipe(Route.withDefault("/")) })),
});
// A one-page route is a tree of one leaf. There is no other form.
export const Login = Route.client(
"login",
Route.leaf(login, (props) =>
Effect.succeed(<p>sign in, then go to {View.bind(props.search, (search) => search.next)}</p>),
),
);
// `before` runs parent first, before anything commits: continue, or redirect.
export const tenant = Route.segment("tenant", {
path: "/app/:tenant",
params: Schema.Struct({ tenant: Schema.String }),
data: ({ params }) => ({ info: Route.query(TenantInfo, { tenant: params.tenant }) }),
before: ({ params, url }) =>
Effect.gen(function* () {
if (yield* isSignedIn(params.tenant)) {
return Route.Continue;
}
return Route.redirect(login, {}, { next: `${url.pathname}${url.search}` });
}),
});
// A child declares only its own params: `post` sees `{ tenant, postId }`.
export const post = Route.child(tenant, "post", {
path: "posts/:postId",
params: Schema.Struct({ postId: Schema.String }),
});
export const tab = Route.child(tenant, "tab", {
path: "tabs/:tab",
params: Schema.Struct({ tab: Schema.String }),
});// A layout view stays generic in `ChildR`, what its children's views need.
// `link` takes fixed params or a Source of them.
export const TenantView = <ChildR,>(props: Route.LayoutPropsOf<typeof tenant, ChildR>) =>
Effect.gen(function* () {
const first = yield* link(post, { tenant: "t1", postId: "1" }, {});
const body = yield* View.loading({ fallback: <p>loading</p>, content: props.outlet });
return (
<section>
<Link link={first}>first post</Link>
{body}
</section>
);
});export const App = Route.client(
"app",
Route.layout(
tenant,
[
// A lazy view is imported beside the page's data. A view that can
// fail, and every lazy view, names its `errored` handler.
Route.leaf(post, View.lazy(loadPostView), {
errored: (failure) => <p>{View.bind(failure, (f) => f._tag)}</p>,
pending: { fallback: <p>opening</p>, after: "100 millis", atLeast: "300 millis" },
}),
// A tab strip that keeps the reader where they are.
Route.leaf(tab, TabView, { landing: NavigationBehavior.Preserve }),
],
TenantView,
),
);- A segment's
paramscodec decodes exactly the names its own template declares, sopath: "/app/:tenant"withSchema.Struct({ tenantId })does not compile. A child declares only its own params and inherits its ancestors'. A template with no param takes noparams. beforeruns parent first, before anything commits. It returnsRoute.ContinueorRoute.redirect(segment, params, search).- A URL that only moves elsewhere is
Route.redirecting(name, segment, to). It has no view:toanswers theRoute.redirectbefore anything draws. - A view that can fail, and every
View.lazyview, needs anerroredhandler. It receives aRoute.RouteFailure. View.lazyreturns aLazyView, tagged"LazyView". A route given it starts the import beside its data; a route given a view wrapped around it sees a plain View and imports at setup, so hand the route theLazyViewitself.- Every segment view gets
params,search,data,href,pushSearch, andreplaceSearch. A layout also getsoutlet. - Every move is
pushorreplace:router.push(href), a link'slink.pushandlink.replace, a view'spushSearchandreplaceSearch, and aUrlState'spushandreplace. A search move and a link's search take one change shape,Route.SearchChange: a value, or an updater of the latest value.router.pushandrouter.replaceanswer aNavigationResult:Committedat the final URL after any redirect,Unchangedwhen nothing moved, orStayedwhen a leave check kept the page. - Each
databinding has astateSource. ARoute.querybinding also hasrefreshandoverride. ARoute.actorbinding is{ ref, state }:statefollows the reference the route holds now, and a send names it,Effect.flatMap(props.data.counter.ref.get, (ref) => ref.send(message)). Route.commandRef(contract, key)declares an actor the page only commands: the route's form ofActor.remoteCommands. Its binding is{ ref }, aSource<RemoteCommandRef<C>>the route opens, moves with its params, and releases; it reads no snapshot and follows no stream.- A view written apart from its segment types its props from the segment:
Route.PropsOf<typeof post>for a leaf, andRoute.LayoutPropsOf<typeof tenant, ChildR>for a layout. A layout view stays generic inChildR, the services its children's views need, soRoute.layoutcan prove the outlet's requirements (aView.loadingaroundprops.outletprovidesLoadingScope; a tree that leaves it open fails at the mode constructor withView.ready needs a View.loading above it). In a.tsxfile the generic needs its trailing comma. Do not type props by hand: the props interfaces are not exported. linktakes a segment.Linkdrawsaria-current="page"when the current path is the segment's path printed with the link's params, andaria-current="true"when the current path continues below that printed path. The same segment with other params gets neither, nor does any link on not-found or on another route. The search never counts: a link to a list is the page under any sort or filter.link(to, params, search)takes fixed params or aSourceof them. A layout that outlives a param move passesprops.params, so its links follow the params it holds now instead of the ones it was drawn with.- A mode constructor takes a branch of a root segment only. A mode is the constructor; no route value carries a mode field.
See the public route design, docs/design/route-public.md.
State in the URL
A value the URL keeps, and the page's data may read, is the segment's
search. A view keeps its own state in the URL with UrlState.make(codec):
it claims keys the route does not hold, and a key the route or another view
holds dies with UrlState.UrlStateConflict. Both move with push or
replace, and both take a value or an updater.
// A filter the URL keeps, and the page's data may read, is the segment's
// search. `withDefault` fills a missing key.
export const tasks = Route.segment("tasks", {
path: "/tasks",
search: Route.search(
Schema.Struct({
filter: Schema.Literals(["all", "open", "done"]).pipe(Route.withDefault("all")),
}),
),
});
// A view keeps its own state in the URL with `UrlState`: it claims keys the
// route does not hold, and its codec decodes an absent key.
const Panel = Route.search(
Schema.Struct({ panel: Schema.Literals(["closed", "open"]).pipe(Route.withDefault("closed")) }),
);
export const TasksView = (props: Route.PropsOf<typeof tasks>) =>
Effect.gen(function* () {
const panel = yield* UrlState.make(Panel);
return (
<section>
<p id="filter">{View.bind(props.search, (search) => search.filter)}</p>
{/* A search move takes a value, or an updater of the latest one. */}
<button id="open" onClick={View.event(props.replaceSearch({ filter: "open" }))}>
open
</button>
<button
id="all"
onClick={View.event(props.pushSearch((search) => ({ ...search, filter: "all" })))}
>
all
</button>
<button id="details" onClick={View.event(panel.push({ panel: "open" }))}>
details
</button>
<p id="panel">{View.bind(panel.state, (state) => state.panel)}</p>
</section>
);
});
export const Tasks = Route.client("tasks", Route.leaf(tasks, TasksView));Scroll and focus
In the browser, provide browserNavigation as the Location. It uses the
Navigation API, and the History API where that is absent. followLinks
follows ordinary same-origin anchors. Dom.root finds the element the
server's document wrote for its rootId, or fails with RootNotFound. The
first app's browser entry above is the one every example app uses.
Outside a browser, in a test or a terminal, provide
(yield* memoryLocation(href)).location: it moves when the router moves
it, history lists each push and replace, and pop(href) moves as
Back and Forward do. It has no surface, so a landing places nothing.
- At shell commit (the new branch is in the document, fallbacks included),
NavigationBehavior.Restoreputs the viewport at the top, at the URL's fragment, or at the entry's saved position on Back and Forward. It then focuses the entering leaf's root, or the firstautofocuselement inside that leaf. It does not wait for queries. NavigationBehavior.Preserveleaves scroll and focus alone. Set it on a leaf (Route.leaf(..., { landing }), as the tab leaf above does), or for the whole router (hydrate({ ..., landing })). A layout takes nolanding: the destination leaf decides. The option islanding, notbehavior:behavioris an actor's reducer.mountandhydraterequirelandingandtraversalReadLimit(how long Back and Forward wait for a page's declared reads before they land). The router has no hidden default.- A leaf's root element gets
tabindex="-1", unless the view wrote atabindexor the element is focusable already, such as a<button>. Focus usespreventScroll. A stayed leaf (a search or param change on the same leaf) keeps focus and the caret. - The router holds no scroll position and never sets
history.scrollRestoration. It adds noaria-liveregion. LinkandfollowLinksshare one plain-click policy: a modified or middle click,target="_blank", a download, another origin, and a link that only changes the current page's fragment are left to the browser.followLinksalways pushes; a move that replaces is aLinkwithreplace.
See the navigation behavior design, docs/design/navigation-behavior.md.
Server documents
renderDocument answers one request. It settles the request first: it
matches the URL and runs the matched route's checks, whatever the mode.
Then it renders with the settled tree's mode. It mounts the same router on
the HTML host, over its own query cache, at the request URL. The first
app's page.server.ts above is the whole of it.
principalis required: every check and query the render reads runs under it, and nothing falls back to a default caller.respondDocument(render, { onTimeout })renders the request's URL and answers a redirect with303 See Other, a document with its status and a streamed HTML body, aDocumentTimedOutwithonTimeout, and a defect with 500.redrawDocument(render)is the form route'srender. It draws the refused post's page, at the posting request's own origin, for the principal that posted, and answers it as one string. A redirect fails it withDocumentRedirected.- A check that redirects is the answer,
{ _tag: "Redirect", location }. The render does not follow it. ARoute.clientroute runs its checks on the server too. Rendered.routeis{ _tag: "Matched", route }, the route value, or{ _tag: "NotFound" }.statusis 404 for not-found and 200 otherwise. Not-found renders asSSR.- Every route comes from a mode constructor, and names itself.
mountand the server document die withRoute.RouteNameRejectedwhen two routes share a name, or when one is named"not-found", the router's own. Route.ssr: the server resolves every query the matched branch declares, in parallel, before any view draws. It draws once and writes one seed script. The client hydrates with no read.Route.streamed: the shell first, then one record per declared query (see "Streamed documents"). Put aView.loadingboundary around what waits.Route.awaitAll: one document once every read settled.Route.client: the document with an empty mount element. The server reads nothing.closeWhenruns once. The checks, the declarations, and the first drawing must end before it completes, orrenderDocumentfails withDocumentTimedOut { phase: "settle" | "draw" }and closes what it opened. After the first drawing,AwaitAllwrites what it has andStreamedwritesClosed; the client reads what is still open. If the drawing and its seed still disagree at the limit (a query kept moving), no document is written:DocumentTimedOut { phase: "agree" }.- Every read is checked under the
CurrentPrincipalyou provide, by the policy its query names. A refusal is seeded, never the value. - The checks and the drawing read through one query cache, which the request Scope holds. A query both read is read once and written once; a query only a check read is not written into the document.
A route prints only what a URL carries both ways: a path segment is
well-formed text that is not empty and not . or .., and a search key or
value is well-formed text. href dies with Route.UrlValueRejected for
another value, and parse never yields one.
See the route data design, docs/design/route-data.md.
Prerendered pages
Route.prerender is a mode constructor that lists its inputs.
// A prerender tree names one `Route.inputs` for each segment that adds a
// path param. A child's function runs once per parent page, receives the
// parent's params, and returns only its own.
export const Posts = Route.prerender(
"posts",
Route.layout(org, [Route.leaf(post, PostView)], OrgView),
{ inputs: [Route.inputs(org, listOrgs), Route.inputs(post, (parent) => listPosts(parent.org))] },
);A segment that adds a param and names no inputs is refused where the tree
is constructed, with PrerenderAncestorNotEnumerable naming the segment
and the param. A layout that adds no param needs no inputs.
The build and the server are server-only, in effect-frame/router/prerender:
// The build: every input, through the document pipeline, in AwaitAll, as
// Anonymous. It publishes a new generation with one rename.
export const buildSite = Prerender.build({
routes: [Posts],
notFound: NotFound,
document: (page) => Effect.succeed(documentFor(page)),
client: bundleText, // written once as client.js
out: "dist/prerender",
timeLimit: "10 seconds",
});
// The server: a built page answers before the router runs. `load` holds the
// generation it read for the calling scope, so run it in the server's scope.
// Both the fallback and the answer are apps over the `HttpServerRequest`.
export const pages = (
router: Effect.Effect<
HttpServerResponse.HttpServerResponse,
never,
HttpServerRequest.HttpServerRequest
>,
) =>
Effect.gen(function* () {
const site = yield* Prerender.load("dist/prerender");
return yield* Prerender.serve(site, router);
});- Each page is written to
<href>/index.htmlin a new generation underdist/prerender/generations/, at the URL its route'shrefprints, besideclient.jsandmanifest.json. The build publishes the generation by renamingcurrent.jsonover the old pointer, so a failed or crashed build leaves the previous generation serving.loadholds the generation it read for its scope, so a running server keeps its files across any number of rebuilds; the first build after the server stops removes them. - Publishing is safe against a process crash or interruption on POSIX file
systems; it does not
fsync, so it is not durable across power loss, and Windows replacement semantics are not claimed. - One build writes one output: a second build fails with
PrerenderBuildLocked. A lock file a hard crash left names itself in the error; remove it once no build runs. - A query two pages read, or that an inputs Effect and a page read, is read once. An actor snapshot is read once too, and actors do not move while the build runs, so the page and its resume script show one revision.
- A query whose policy refuses
Anonymousfails the build withPrerenderUnauthorized, and nothing is published. - The build refuses what it could not serve as written: an href with a
search part (
PrerenderSearchRejected), two hrefs that differ only in case (PrerenderPathCollision), and a local link to a prerender route that no input built (PrerenderBrokenLink).timeLimitcovers each page fromdocument(page)on. - A loaded site reads one generation. A built page answers with a strong
ETagfor the bytes it read, and a matchingIf-None-Matchanswers 304. HEAD answers as GET does, with no body. A page with no file renders through the router. - A baked query value paints at once as
Ready { stale: true }and is read once to confirm it. An actor island resumes from the revision the page baked: write itsresumeCodecscript indocument(page), as SSR does.
The blog app (apps/blog) is a whole prerendered site. See
the prerender design, docs/design/prerender.md.
Plain-form commands
A command form works with no JavaScript. The server renders a real
<form method="post">; the hydrated page sends the same message over the
actor transport. The first app's Controls is one; the server's
HttpServer.layer above serves its route at /actors/form.
// The render mints `id` from the form's command id; decoding never mints it.
// A checkbox posts nothing when unchecked, so `Form.Checkbox` reads absent
// as false.
export const Add = Schema.TaggedStruct("Add", {
id: Generated.fromCommandId(Schema.String),
text: Schema.String,
pinned: Form.Checkbox,
});// `typed` names the fields the reader types; the runtime writes the rest
// (method, action, and the hidden command fields) in every host.
export const Compose = (props: { readonly notes: RemoteActorRef<typeof Notes> }) =>
Effect.gen(function* () {
const add = yield* View.form({
ref: props.notes,
message: Add,
typed: ["text", "pinned"],
endpoint: "/actors",
returnTo: "/",
});
return (
<form onSubmit={add.submit}>
<input name="text" />
<input type="checkbox" name="pinned" />
<button type="submit">add</button>
<For each={add.issues} keyBy={(issue) => `${issue.field}:${issue.message}`}>
{(issue) => <p>{View.bind(issue, (one) => one.message)}</p>}
</For>
</form>
);
});
// From code, `Generated.send` sends the input without its generated fields.
export const addFromCode = (notes: RemoteActorRef<typeof Notes>) =>
Generated.send(notes, { _tag: "Add", text: "hello", pinned: false });effect-frame/actor/clientexportsGenerated(fromCommandId,freshId,send,Input) andForm(codec,Checkbox,FormContext,FormIssues,issuesOf,encodeKey, and the field-map helpers). A plain post goes toendpointplus/form.View.formreturns{ submit, issues, commandId }. The runtime drawsmethod,action, and the hidden$command,$contract,$version,$key,$return,$form,_tag, and generated inputs in every host.issuesis aSource, drawn with<For>. It holds a refused plain post's issues, and a scripted submit that does not decode shows the same issues there, so a form reads alike with a script or without one. A submit that decodes clears them.- The form route answers 303 to
$returnon success, 200 with the page and itsFormIssueson a validation failure, 504 with the same id on a lost reply, and 400 or 415 before any send. AnUnauthorizedanonymous post answers 303 tologinwithnext; every other refusal is a 403 with the page. - A refused page carries its issues to the client. When
FormContextis present,renderDocumentwrites them after the document'stail, underForm.issuesScriptId, andhydratereads them on the client. An app writes nothing for it. - Each form posts
$form(the member tag, orname). A refusal redraws only the form that posted it. $returnmust be printable ASCII, root-relative, and resolve to this origin. Acharsetother than UTF-8 is refused with 415.- A field whose name has a segment that starts with
_is never written back into a refused page. A multipart body is refused with 415. HostEvent.formcarries the submitted fields on a DOM submit.Prepared.postcarries a form's plain post.
See the plain-form design, docs/design/plain-forms.md.
Optimistic commands
A remote reference shows a command before the server commits it when the
client can import the actor's behavior and that behavior has predict.
// A reference given the actor's behavior predicts each fresh send before
// the server commits it. `Behavior.value` and `Behavior.reducer` predict;
// `Behavior.machine` does not.
export const program = Effect.gen(function* () {
const counter = yield* Actor.remote(
Counter,
{ name: "home" },
{ resume: Option.none(), behavior: counterBehavior },
);
const handle = yield* counter.send(Increment.make({ by: 1 }));
// { revision: { _tag: "Provisional", base, depth: 1 }, state }
const shown = yield* counter.displayed.get;
// The committed revision only: use it for resume data.
const committed = yield* counter.applied.get;
return { handle, shown, committed };
});displayedis what the reference shows.stateisdisplayed.state.appliedstays committed. Use it for resume data.- Only a fresh command ID predicts. A supplied ID waits for its receipt.
- A committed state replaces the prediction. A rejected command leaves the
pending log, and the rest replays over the same base. An
Uncertaincommand keeps its prediction until a retry settles it. - A query
overrideshows a value as stale until any authoritative value replaces it: a command reply's refresh, arefresh, or a new declaration.
See the optimistic send design, docs/design/optimistic.md.
Streamed documents
A routed page streams with Route.streamed, and hydrate reads the
records. A page with no router writes both halves itself.
// The shell and its fallbacks first, then one record per query as it
// settles, then `Closed`. Nothing in the document runs: each record is JSON
// the client reads into its query cache.
const page: Html.Document = {
head: '<!doctype html><html><head><meta charset="utf-8"></head><body>',
rootId: "app", // the renderer writes <div id="app"> around the drawing
tail: "", // resume payloads and form issues go here
bootstrap: '<script type="module" src="/client.js"></script>',
end: "</body></html>",
};
export const answer = Effect.gen(function* () {
const body = Html.renderToStream(NamesPage, { title: "names" }, page, {
closeWhen: Effect.sleep("10 seconds"),
});
const context = yield* Effect.context<Stream.Services<typeof body>>();
return new Response(Stream.toReadableStreamWith(Stream.encodeText(body), context), {
headers: { "content-type": "text/html; charset=utf-8" },
});
});// A routed page never writes this: `hydrate` from `effect-frame/router`
// does it. A page with no router reads the records, seeds the cache, and
// hydrates itself, in this order.
export const start = Effect.gen(function* () {
const root = yield* Dom.root("app");
const resumed = yield* Streaming.resume(yield* Dom.readRecords);
const hydration = Dom.hydrate(root);
yield* View.mount(NamesPage, { title: "names" }, hydration.host, root);
yield* View.flush;
const report = yield* hydration.finish; // report.resolvedAhead
// Drop the seeds no view took, and start the reads the seeds call for.
yield* resumed.hydrated;
return report;
});Html.renderToStream(view, props, document, options)renders over its own query cache and returnsStream<string>. The first chunk holds the shell,tail, aPlaceholderfor each declared query, the patches already due, andbootstrap. Then onePatchper query as it settles, thenClosed.Html.renderAwaitAll(view, props, document, options)keeps one drawing live until every declared query has settled and noView.loadingboundary shows its fallback, then writes one document with a seed script and no record channel.Html.renderToStringdraws one frame and releases what setup opened before the string returns.options.closeWhenis the time limit, and both calls require it (Effect.neverwaits for ever). A query still open at the limit has no value in the document, and the client reads it again. A drawing whose records still move at the limit is never written beside a seed it does not show: the call fails withHtml.RecordsUnsettled.Dom.readRecordsreads the records present and follows the rest. It also reads anAwaitAllseed.Streaming.resume(records)puts them into the cache beforemountand returnsResumed:closedcompletes once the channel ended and every live entry shows its value or failure;hydrateddrops the seeds no view took, and starts the reads that the seeds call for (a stale value, a failure that is not final). Until then a seeded entry shows what the server drew, so run it afterhydration.finish.HydrationReport.resolvedAheadcounts boundaries that the client drew with the other branch, because their query settled before hydration. That is not a mismatch.- A query still open when the document ends fails with
StreamEndedand reads again overPOST /query.StreamEndedis inQueryFailure. A value in the document never replaces a newer read the client made. OnlyQueryFailedin the document is final; any other failure reads again. Hosthas three optional capabilities,boundaryMarks,adoptBoundaryandsetupStarted. A custom host may omit them. The HTML host writes<!--frame-boundary:…-->marks around each readiness boundary.
See the streaming design, docs/design/streaming.md.
Server-driven views
A view can run on the server and draw into a browser over a stream of host
operations. The server mounts the view on a recording host; the client
replays the operations and sends events back. A connection starts with the
drive actor's snapshot, never with an operation log, and a reconnect does
the same at constant cost. A routed page uses Route.driven.
// One session per connection. The socket is the application's: `received`
// is the events the client sent, and `send` writes one text frame.
export const serve = (
key: RoomKey,
received: Stream.Stream<Remote.RemoteEvent>,
send: (text: string) => Effect.Effect<void>,
) =>
Effect.gen(function* () {
const session = yield* Driven.session(RoomView, { key }, { contract: Counter, key });
// The first message is the drive's snapshot, never an operation log.
yield* send(session.resume);
yield* Effect.forkScoped(Stream.runForEach(received, session.fire));
yield* Stream.runForEach(session.patches, (patch) => Effect.flatMap(encodePatch(patch), send));
});// The client draws from the snapshot on its own recorder, then applies each
// patch. It works with any host; here, the DOM.
export const follow = (key: RoomKey, root: Element, send: (event: Remote.RemoteEvent) => void) => {
const client = Remote.client(
RoomView,
{ key },
{ contract: Counter, key },
{
host: Dom.host,
root,
send,
},
);
return {
// The first connect and every reconnect.
resume: (payload: string) => client.resume(payload),
// A patch from another session, one out of order, or one naming a node
// the client does not hold applies nothing and fails.
apply: (text: string) => Effect.flatMap(decodePatch(text), client.apply),
};
};Driven.session(view, props, drive, { limit })mounts the view on a freshRemote.recorder()at the drive's latest snapshot.resumeis the session id, the snapshot, and a digest of the drawing, as one JSON string.patchesstreams every later change from position 0, each naming the session, with the writes that would not change the client's tree left out. A client that falls more thanlimitoperations behind (defaultDriven.defaultLimit) ends the stream withBackloggedand must resume.retainedshows what the session holds; after its scope closes, that is nothing.Remote.client(view, props, drive, { host, root, send })works with any host.resume(payload)removes what the client drew and draws the view from the snapshot on its own recorder, which gives the ids the server's recorder gave, and failsDivergedif its drawing is not the server's.apply(patch)refuses a patch from another session (ForeignSession), one whosefromis not the position it holds (StaleClient), or one that names an id it does not hold (UnknownNode), and changes nothing.Remote.draw(view, props, drive, payload)returns the operations a drawing from a snapshot makes. Two drawings at one snapshot are equal.- A driven view draws from its one drive actor, on both sides; any other
read fails
Unreachable. Its drawing must depend on its props and that snapshot only. An event carries its value only, and reaches only a listener a patch has delivered. Patches are trusted server output. The socket is the application's. - A host may implement
forget(node): the runtime calls it when the owner that drew the node ends. The recorder sends it as aForgetop, so a long session holds only live nodes.
See the op wire design, docs/design/op-wire.md.
Authorization
Every contract and every query names a policy. The root host requires a policy table, and there is no default table and no default rule.
// Every contract and every query names a policy. The name is a key into
// the host's table, which holds the rule.
export const Entry = Schema.TaggedStruct("Entry", { text: Schema.String });
export type Entry = Schema.Schema.Type<typeof Entry>;
export const Ledger = contract("Ledger", {
version: 1,
policy: "tenantMember",
key: Schema.Struct({ tenant: Schema.String, id: Schema.String }),
snapshot: Schema.Finite,
message: Entry,
});
export const Totals = query("Totals", {
version: 1,
args: Schema.Struct({ tenant: Schema.String }),
result: Schema.Finite,
policy: "tenantMember",
depends: [Ledger],
});const decodeTenants = Schema.decodeUnknownOption(Schema.Array(Schema.String));
// The tenants a session published about its subject. Anonymous has none.
const tenantsOf = (who: Principal): ReadonlyArray<string> => {
if (who._tag === "Anonymous") {
return [];
}
return Option.getOrElse(decodeTenants(who.claims["tenants"]), (): ReadonlyArray<string> => []);
};
// One rule for the ledger and the totals. `forSubjects` decodes the ledger's
// key and the totals' arguments with their own codecs, and refuses any
// subject it does not name.
const tenantMember = Policy.forSubjects({ contracts: [Ledger], queries: [Totals] }, (who, key) =>
Effect.succeed(tenantsOf(who).includes(key.tenant)),
);
// Every name a contract or query declares has a rule. Allow-all exists
// only by name.
const policies = Layer.succeed(Policies, Policies.of({ tenantMember, public: Policy.allowAll }));
export const host = ActorHost.layer({
implementations: [LedgerLive],
queries: [TotalsLive],
store: ActorHost.memoryStore,
}).pipe(Layer.provide(policies));// The principal is derived once per request, and followed on a connection:
// one subscription per session, shared by every connection on it.
export const handler = Effect.gen(function* () {
const sessions = yield* HttpServer.shareSessions({
read: readSession, // (sessionId) => Effect<Principal>
follow: followSession, // (sessionId) => Stream<Principal>, current first
});
const principal: HttpServer.DerivePrincipal = (request) =>
Effect.succeed(
Option.match(sessionIdOf(request), { onNone: () => Principal.anonymous, onSome: sessions }),
);
return yield* HttpServer.make({
prefix: "/actors",
principal,
maxBodyBytes: HttpServer.defaultMaxBodyBytes,
fo