Skip to content

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.

Terminal window
bun add foldkit @effect/platform-browser
bun add -d vite @foldkit/vite-plugin

The app runs in a browser. Add "DOM" to lib in your tsconfig.json, so document and location type-check:

{
"compilerOptions": {
"lib": ["ESNext", "DOM"]
}
}
vite.config.ts
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.

index.html
<!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>
src/Session.middleware.ts
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";
src/Session.middleware.ts
) {}
// 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.

src/Session.middleware.ts
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";
src/Session.middleware.ts
);
// 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.

src/BankWorker.ts
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(
src/BankWorker.ts
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.

src/web/Bank.ts
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.

src/web/Bank.ts
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";
src/web/Bank.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.

src/web/Bank.ts
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";
src/web/Bank.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.

src/web/Bank.ts
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";
src/web/Bank.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.

src/web/Bank.ts
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";
src/web/Bank.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.

src/web/entry.ts
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.

src/web/worker.ts
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.

alchemy.run.ts
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.

Terminal window
bun alchemy dev

Open 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.

Terminal window
bun alchemy deploy

You’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.