Skip to content

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.

The app shows the signed-in customer’s balance, opens their account, and deposits.

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

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.

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.

The client is a foldkit resource, built once for the app’s life. Sign in first, then connect on the app’s own origin.

packages/web/src/main.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 { 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.

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.

foldkit stories test update without a server. Resolve each command with the message it would produce.

packages/web/test/app.test.ts
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)),
));

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.

vite.config.ts
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"],
},
});
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/main.ts"></script>
</body>
</html>