Integrate with foldkit
foldkit apps change through messages, and side effects run as commands. That matches your domain: a foldkit command sends a domain command, and a foldkit subscription watches a view.
Write the app
Section titled “Write the app”The app shows the signed-in customer’s balance, opens their account, and deposits.
import type * as Domain from "alchemy/Domain";import * as Effect from "effect/Effect";import * as Schema from "effect/Schema";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, mainAccountId } from "@bank/domain/Account/Account.id";import { CustomerId } from "@bank/domain/Customer/Customer.id";import { Username } from "@bank/domain/Customer/Username";import { CustomerApi } from "@bank/domain/CustomerApi.api";
// The services the app's commands and subscriptions use: the bank client.type Bank = Domain.Api.ClientKey<"CustomerApi">;
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;
export const Register = Command.define("Register", { args: { customerId: CustomerId }, messages: [Message.Succeeded, Message.Failed], execute: ({ customerId }) => Effect.gen(function* () { const bank = yield* CustomerApi.Client; yield* bank.RegisterCustomer({ target: customerId, name: customerId, username: Username.make(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* CustomerApi.Client; 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: mainAccountId(flags.customerId), balance: null, error: null, },});
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* CustomerApi.Client; return bank.AccountSummary.watch(accountId); }), ).pipe( Stream.map((summary) => Message.GotBalance({ balance: summary?.balance ?? null })), Stream.catch((error) => Stream.succeed(Message.Failed({ reason: error._tag }))), ), }, ),}));
const cents = (amount: number) => (amount / 100).toFixed(2);
export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({ title: "Bank", body: h.div( [], [ 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 ?? ""]), ], ),});Send domain commands from foldkit commands
Section titled “Send domain commands from foldkit commands”A foldkit command gets the client with yield* CustomerApi.Client, sends one domain command, and turns the outcome into a message. A rejection arrives typed, so error._tag is the rejection’s name.
Watch views with subscriptions
Section titled “Watch views with subscriptions”The subscription watches the account’s summary and turns each new state into GotBalance. Its dependencies decide when it restarts: change the account, and it watches the new one. The balance never changes on a command’s success; it arrives from the watch, including changes made on other devices.
Type the app’s services
Section titled “Type the app’s services”Domain.Api.ClientKey<"CustomerApi"> is the client’s service. Pass it as the third type of Update.Return and Subscription.make, so commands and subscriptions may use the client.
Give the app its client
Section titled “Give the app its client”The client is a foldkit resource, built once for the app’s life. Sign in first, then connect on the app’s own origin.
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 { CustomerId } from "@bank/domain/Customer/Customer.id";import { CustomerApi } from "@bank/domain/CustomerApi.api";import { Flags, init, Model, subscriptions, update, view } from "./App.ts";
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( CustomerApi.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 }) },);Domain.protocolWebSocket sends commands over HTTP and opens a WebSocket for each watch. See Sessions and middleware for the sign-in route and the one-origin Worker.
Trace it
Section titled “Trace it”foldkit wraps every Command in a span, and a tracer in resources exports them. Each Api call carries the trace on, so one trace runs from a click through everything it causes on the server. See Observability.
Test the app’s update
Section titled “Test the app’s update”foldkit stories test update without a server. Resolve each command with the message it would produce.
import { expect, test } from "@effect/vitest";import * as Story from "foldkit/story";import { AccountId } from "@bank/domain/Account/Account.id";import { CustomerId } from "@bank/domain/Customer/Customer.id";import { Deposit, Message, type Model, update } from "../src/App.ts";
const model: Model = { customerId: CustomerId.make("c-1"), accountId: AccountId.make("c-1-main"), balance: 0, error: null,};
test("a deposit sends DepositMoney, and the balance arrives from the watch", () => Story.story( update, Story.given(model), Story.message(Message.ClickedDeposit()), Story.Command.resolve(Deposit, Message.Succeeded()), Story.model((next) => expect(next.balance).toBe(0)), Story.message(Message.GotBalance({ balance: 1_000 })), Story.model((next) => expect(next.balance).toBe(1_000)), ));Build and host it
Section titled “Build and host it”Cloudflare.Website.Foldkit builds the app with Vite and serves it. Add the foldkit plugin, and pre-bundle the Effect modules foldkit imports so the browser loads one copy of Effect.
import { foldkit } from "@foldkit/vite-plugin";import { defineConfig } from "vite";
export default defineConfig({ plugins: [foldkit()], optimizeDeps: { entries: ["src/web/main.ts"], include: ["effect", "effect/Function", "effect/http", "@effect/platform-browser"], },});<!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/main.ts"></script> </body></html>