Part 6: Build the UI with foldkit
Build a browser app with foldkit: sign in, open your account, deposit, and see the balance change as it happens.
Install
Section titled “Install”bun add foldkit @effect/platform-browserbun add -d vite @foldkit/vite-pluginAdd the browser’s types
Section titled “Add the browser’s types”The app runs in a browser. Add "DOM" to lib in your tsconfig.json, so document and location type-check:
{ "compilerOptions": { "lib": ["ESNext", "DOM"] }}Configure Vite
Section titled “Configure Vite”import { foldkit } from "@foldkit/vite-plugin";import { defineConfig } from "vite";
export default defineConfig({ plugins: [foldkit()], optimizeDeps: { entries: ["src/web/entry.ts"], include: ["effect", "effect/Function", "effect/http", "@effect/platform-browser"], },});The foldkit plugin builds the app. include pre-bundles the Effect modules foldkit imports, so the browser loads one copy of Effect.
Add the page
Section titled “Add the page”<!doctype html><html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Bank</title> </head> <body> <div id="root"></div> <script type="module" src="/src/web/entry.ts"></script> </body></html>Read the session cookie
Section titled “Read the session cookie”import * as Effect from "effect/Effect";import * as Cookies from "effect/http/Cookies";import * as Headers from "effect/http/Headers";import * as Layer from "effect/Layer";) {}
// Demo only: trusts `Authorization: Bearer <customer id>`. Verify a real session in production.export const SESSION_COOKIE = "bank_session";
// The customer from the session cookie (browsers), or the Authorization header (scripts).const signedIn = (headers: Headers.Headers): Option.Option<string> => Option.orElse( Option.flatMap(Headers.get(headers, "cookie"), (cookie) => Option.fromNullishOr(Cookies.parseHeader(cookie)[SESSION_COOKIE]), ), () => Option.flatMap(Headers.get(headers, "authorization"), (value) => value.startsWith("Bearer ") ? Option.some(value.slice("Bearer ".length)) : Option.none(), ), );
// Demo only: trusts the cookie or header as given. Verify a real session in production.export const SessionLive = Layer.succeed(Session, (effect, { headers }) => Option.match(Headers.get(headers, "authorization"), { Option.match(signedIn(headers), { onNone: () => Effect.fail(new Unauthorized()), onSome: (value) => value.startsWith("Bearer ") ? Effect.provideService(effect, CurrentActor, { customerId: CustomerId.make(value.slice("Bearer ".length)), }) : Effect.fail(new Unauthorized()), onSome: (customerId) => Effect.provideService(effect, CurrentActor, { customerId: CustomerId.make(customerId) }), }),);A browser can’t set headers on a WebSocket, but it always sends its cookies. Session now reads the customer from the bank_session cookie, and falls back to the header for scripts.
Sign in
Section titled “Sign in”import * as Cookies from "effect/http/Cookies";import * as Headers from "effect/http/Headers";import { HttpServerRequest } from "effect/http/HttpServerRequest";import * as HttpServerResponse from "effect/http/HttpServerResponse";import * as Layer from "effect/Layer";import * as Option from "effect/Option";);
// POST /api/session signs a customer in. Demo only: anyone may sign in as anyone.export const sessionRoute = Effect.gen(function* () { const request = yield* HttpServerRequest; const body = yield* request.json.pipe( Effect.flatMap(Schema.decodeUnknownEffect(Schema.Struct({ customerId: CustomerId }))), Effect.option, ); if (Option.isNone(body)) return HttpServerResponse.text("Expected { customerId }", { status: 400 }); return HttpServerResponse.empty({ status: 204 }).pipe( HttpServerResponse.setCookieUnsafe(SESSION_COOKIE, body.value.customerId, { httpOnly: true, secure: true, sameSite: "strict", path: "/", }), );});
export const sessionClient = (customerId: string) => RpcMiddleware.layerClient(Session, ({ request, next }) =>sessionRoute sets the cookie. HttpOnly keeps it out of reach of scripts, and SameSite=Strict keeps it off requests from other sites.
Route sign-in
Section titled “Route sign-in”import * as DomainCloudflare from "alchemy/Domain/Cloudflare";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/http/HttpServerRequest";import * as Layer from "effect/Layer";import { Bank, BankHandlers } from "./Bank.model.ts";import { BankApi } from "./BankApi.api.ts";import { SessionLive } from "./Session.middleware.ts";import { SessionLive, sessionRoute } from "./Session.middleware.ts";
const BankLive = SessionLive.pipe( Effect.gen(function* () { const api = yield* BankApi; return { fetch: api.fetch }; return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const path = new URL(request.url, "http://localhost").pathname; return path === "/api/session" ? yield* sessionRoute : yield* api.fetch; }), }; }).pipe(Effect.provide(BankLive)),) {}/api/session signs in, and everything else goes to the Api.
Describe the app’s state
Section titled “Describe the app’s state”import type * as Domain from "alchemy/Domain";import * as Schema from "effect/Schema";import { defineMessageUnion } from "foldkit/message";import { AccountId } from "../Account.id.ts";import { CustomerId } from "../Customer.id.ts";
// The bank client this app runs on, provided when the app starts.type Bank = Domain.Api.ClientKey<"BankApi">;
export const Flags = Schema.Struct({ customerId: CustomerId });export type Flags = typeof Flags.Type;
export const Model = Schema.Struct({ customerId: CustomerId, accountId: AccountId, // `null` until the account is open. balance: Schema.NullOr(Schema.Number), error: Schema.NullOr(Schema.String),});export type Model = typeof Model.Type;
export const Message = defineMessageUnion({ ClickedOpen: {}, ClickedDeposit: {}, Succeeded: {}, Failed: { reason: Schema.String }, GotBalance: { balance: Schema.NullOr(Schema.Number) },});export type Message = typeof Message.Type;Flags is what the app starts with, Model is what it shows, and Message is everything that can happen. The balance is null until the account is open.
Send domain commands
Section titled “Send domain commands”import type * as Domain from "alchemy/Domain";import * as Effect from "effect/Effect";import * as Schema from "effect/Schema";import { Command } from "foldkit";import { defineMessageUnion } from "foldkit/message";import { AccountId } from "../Account.id.ts";import { BankApi } from "../BankApi.api.ts";import { CustomerId } from "../Customer.id.ts";});export type Message = typeof Message.Type;
export const Register = Command.define("Register", { args: { customerId: CustomerId }, messages: [Message.Succeeded, Message.Failed], execute: ({ customerId }) => Effect.gen(function* () { const bank = yield* BankApi.Client; yield* bank.RegisterCustomer({ target: customerId, name: customerId }); return Message.Succeeded(); }).pipe(Effect.catch((error) => Effect.succeed(Message.Failed({ reason: error._tag })))),});
export const Deposit = Command.define("Deposit", { args: { accountId: AccountId, amount: Schema.Number }, messages: [Message.Succeeded, Message.Failed], execute: ({ accountId, amount }) => Effect.gen(function* () { const bank = yield* BankApi.Client; yield* bank.DepositMoney({ target: accountId, amount }); return Message.Succeeded(); }).pipe(Effect.catch((error) => Effect.succeed(Message.Failed({ reason: error._tag })))),});A foldkit command runs a domain command through the bank client and turns the outcome into a message. A rejection becomes Failed with its tag.
Update the model
Section titled “Update the model”import * as Effect from "effect/Effect";import * as Schema from "effect/Schema";import { Command } from "foldkit";import { Command, type Update } from "foldkit";import { defineMessageUnion } from "foldkit/message";import { AccountId } from "../Account.id.ts"; yield* bank.DepositMoney({ target: accountId, amount }); return Message.Succeeded(); }).pipe(Effect.catch((error) => Effect.succeed(Message.Failed({ reason: error._tag })))),});
export const update = (model: Model, message: Message): Update.Return<Model, Message, Bank> => Message.match<Update.Return<Model, Message, Bank>>(message, { ClickedOpen: () => ({ model, commands: [Register({ customerId: model.customerId })] }), ClickedDeposit: () => ({ model, commands: [Deposit({ accountId: model.accountId, amount: 1_000 })], }), // The balance arrives through the subscription; success only clears an error. Succeeded: () => ({ model: { ...model, error: null } }), Failed: ({ reason }) => ({ model: { ...model, error: reason } }), GotBalance: ({ balance }) => ({ model: { ...model, balance } }), });
export const init = (flags: Flags): Update.Return<Model, Message, Bank> => ({ model: { customerId: flags.customerId, accountId: AccountId.make(`${flags.customerId}-main`), balance: null, error: null, },});Clicks send commands. The balance never changes on success: it arrives from the bank, through the next step.
Watch the balance
Section titled “Watch the balance”import * as Effect from "effect/Effect";import * as Schema from "effect/Schema";import { Command, type Update } from "foldkit";import * as Stream from "effect/Stream";import { Command, Subscription, type Update } from "foldkit";import { defineMessageUnion } from "foldkit/message";import { AccountId } from "../Account.id.ts"; },});
export const subscriptions = Subscription.make<Model, Message, Bank>()((entry) => ({ balance: entry( { accountId: AccountId }, { modelToDependencies: (model) => ({ accountId: model.accountId }), dependenciesToStream: ({ accountId }) => Stream.unwrap( Effect.gen(function* () { const bank = yield* BankApi.Client; return bank.AccountSummary.watch(accountId); }), ).pipe( Stream.map((summary) => Message.GotBalance({ balance: summary?.owner ? summary.balance : null }), ), Stream.catch((error) => Stream.succeed(Message.Failed({ reason: error._tag }))), ), }, ),}));The subscription watches the account’s summary. The bank pushes every change over a WebSocket, so a deposit from any device shows up here.
Draw the page
Section titled “Draw the page”import * as Stream from "effect/Stream";import { Command, Subscription, type Update } from "foldkit";import type { Document, HtmlBuilder } from "foldkit/html";import { defineMessageUnion } from "foldkit/message";import { AccountId } from "../Account.id.ts"; ),}));
const cents = (amount: number) => (amount / 100).toFixed(2);
export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({ title: "Bank", body: h.div( [], [ h.p([], [`Signed in as ${model.customerId}`]), model.balance === null ? h.button([h.OnClick(Message.ClickedOpen())], ["Open my account"]) : h.div( [], [ h.p([], [`Balance: ${cents(model.balance)}`]), h.button([h.OnClick(Message.ClickedDeposit())], ["Deposit 10.00"]), ], ), h.p([], [model.error ?? ""]), ], ),});Before the account opens, the page offers to open it. After, it shows the balance and a deposit button.
Start the app
Section titled “Start the app”import * as Domain from "alchemy/Domain";import * as Effect from "effect/Effect";import * as FetchHttpClient from "effect/http/FetchHttpClient";import * as HttpClient from "effect/http/HttpClient";import * as HttpClientRequest from "effect/http/HttpClientRequest";import * as Layer from "effect/Layer";import * as Socket from "effect/socket/Socket";import { Runtime } from "foldkit";import { BankApi } from "../BankApi.api.ts";import { CustomerId } from "../Customer.id.ts";import { Flags, init, Model, subscriptions, update, view } from "./Bank.ts";
// A stand-in for a login page: `?customer=c-2` picks who you are.const customerId = CustomerId.make(new URLSearchParams(location.search).get("customer") ?? "c-1");
// Sign in, so the browser holds the session cookie for every call and watch.const signIn = Effect.gen(function* () { const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient); yield* client.execute( HttpClientRequest.post("/api/session").pipe(HttpClientRequest.bodyJsonUnsafe({ customerId })), );});
const BankConnection = Layer.unwrap( signIn.pipe( Effect.orDie, Effect.as( BankApi.layerClient.pipe( Layer.provide(Domain.protocolWebSocket(`${location.origin}/api`)), Layer.provide(Socket.layerWebSocketConstructorGlobal), ), ), ),).pipe(Layer.provide(FetchHttpClient.layer));
Runtime.run( Runtime.makeApplication({ Model, Flags, init, update, view, subscriptions, resources: BankConnection, container: document.getElementById("root"), }), { flags: Effect.succeed({ customerId }) },);The app signs in first, then connects to the Api on its own origin. Domain.protocolWebSocket sends commands over HTTP and opens a WebSocket for each watch.
Serve the app and the Api from one origin
Section titled “Serve the app and the Api from one origin”interface Fetcher { fetch(request: Request): Promise<Response>;}
// Serve the app, and forward /api to the bank: one origin, so the session cookie is first-party.export default { fetch(request: Request, env: { readonly ASSETS: Fetcher; readonly BANK: Fetcher }) { const { pathname } = new URL(request.url); return pathname.startsWith("/api") ? env.BANK.fetch(request) : env.ASSETS.fetch(request); },};The app’s Worker serves the built page and forwards /api to the bank. Sharing one origin keeps the session cookie first-party, so browsers send it.
Deploy the app
Section titled “Deploy the app” Effect.gen(function* () { const bank = yield* BankWorker; return { url: bank.url.as<string>() }; const app = yield* Cloudflare.Website.Foldkit("BankApp", { main: "src/web/worker.ts", env: { BANK: bank }, assets: { runWorkerFirst: ["/api", "/api/*"] }, }); return { url: bank.url.as<string>(), app: app.url.as<string>() }; }),);Cloudflare.Website.Foldkit builds the app with Vite. env: { BANK: bank } gives the app’s Worker a binding to the bank, and runWorkerFirst sends /api through it.
Run it
Section titled “Run it”bun alchemy devOpen the app URL it prints, with ?customer=c-2: Part 5’s script already gave c-1 an account. Open your account, deposit, and the balance updates as the bank records it. Open a second tab with the same customer: both update together.
bun alchemy deployYou’ve built a bank: an account that keeps its own rules, views that answer questions, a policy that reacts, an Api that checks who’s asking, and an app that watches it live.