All Examples
Managed Resource Layer
Layer-backed ManagedResource that starts a ComputeEngine service from an Effect Layer, exposes it to Commands, and runs Layer finalizers when the Model turns it off.
Managed Resources
Effect Layer
Commands
/
import {
Context,
Crypto,
Effect,
Layer,
Match as M,
Number,
Option,
Schema as S,
} from 'effect'
import { Command, ManagedResource, Runtime } from 'foldkit'
import { Document, Html, html } from 'foldkit/html'
import { m } from 'foldkit/message'
import { ts } from 'foldkit/schema'
import { evo } from 'foldkit/struct'
import { BrowserCrypto } from '@effect/platform-browser'
import { Button } from '@foldkit/ui'
// ENGINE
interface ComputeEngine {
readonly engineId: string
readonly square: (value: number) => number
}
class ComputeEngineService extends Context.Service<
ComputeEngineService,
ComputeEngine
>()('ComputeEngineService') {}
const engineLayer: Layer.Layer<ComputeEngineService> = Layer.effect(
ComputeEngineService,
Effect.acquireRelease(
Effect.gen(function* () {
const crypto = yield* Crypto.Crypto
const id = yield* Effect.orDie(crypto.randomUUIDv4)
const engineId = `engine-${id.slice(0, 8)}`
return { engineId, square: (value: number) => value * value }
}).pipe(Effect.provide(BrowserCrypto.layer)),
({ engineId }) => Effect.log(`Tore down ${engineId}`),
),
)
const Engine = ManagedResource.tag<ComputeEngine>()('ComputeEngine')
type EngineService = ManagedResource.ServiceOf<typeof Engine>
// MODEL
export const EngineOff = ts('EngineOff')
export const EngineBooting = ts('EngineBooting')
export const EngineReady = ts('EngineReady', { engineId: S.String })
export const EngineFailed = ts('EngineFailed', { reason: S.String })
const EngineState = S.Union([
EngineOff,
EngineBooting,
EngineReady,
EngineFailed,
])
type EngineState = typeof EngineState.Type
export const Model = S.Struct({
engine: EngineState,
computeCount: S.Number,
maybeSquareResult: S.Option(S.Number),
})
export type Model = typeof Model.Type
// MESSAGE
export const ClickedStartEngine = m('ClickedStartEngine')
export const ClickedStopEngine = m('ClickedStopEngine')
export const StartedEngine = m('StartedEngine', { engineId: S.String })
export const StoppedEngine = m('StoppedEngine')
export const FailedStartEngine = m('FailedStartEngine', { reason: S.String })
export const ClickedCompute = m('ClickedCompute')
export const ComputedSquare = m('ComputedSquare', { result: S.Number })
export const SkippedCompute = m('SkippedCompute')
export const Message = S.Union([
ClickedStartEngine,
ClickedStopEngine,
StartedEngine,
StoppedEngine,
FailedStartEngine,
ClickedCompute,
ComputedSquare,
SkippedCompute,
])
export type Message = typeof Message.Type
// COMMAND
export const Compute = Command.define(
'Compute',
{ value: S.Number },
ComputedSquare,
SkippedCompute,
)(({ value }) =>
Effect.gen(function* () {
const engine = yield* Engine.get
return ComputedSquare({ result: engine.square(value) })
}).pipe(
Effect.catchTag('ResourceNotAvailable', () =>
Effect.succeed(SkippedCompute()),
),
),
)
// UPDATE
type UpdateReturn = readonly [
Model,
ReadonlyArray<Command.Command<Message, never, EngineService>>,
]
export const update = (model: Model, message: Message): UpdateReturn =>
M.value(message).pipe(
M.withReturnType<UpdateReturn>(),
M.tagsExhaustive({
ClickedStartEngine: () => [
evo(model, { engine: () => EngineBooting() }),
[],
],
ClickedStopEngine: () => [evo(model, { engine: () => EngineOff() }), []],
StartedEngine: ({ engineId }) => [
evo(model, { engine: () => EngineReady({ engineId }) }),
[],
],
StoppedEngine: () => [model, []],
FailedStartEngine: ({ reason }) => [
evo(model, { engine: () => EngineFailed({ reason }) }),
[],
],
ClickedCompute: () => {
const nextComputeCount = Number.increment(model.computeCount)
return [
evo(model, { computeCount: () => nextComputeCount }),
[Compute({ value: nextComputeCount })],
]
},
ComputedSquare: ({ result }) => [
evo(model, { maybeSquareResult: () => Option.some(result) }),
[],
],
SkippedCompute: () => [model, []],
}),
)
// INIT
export const init: Runtime.ApplicationInit<Model, Message> = () => [
{ engine: EngineOff(), computeCount: 0, maybeSquareResult: Option.none() },
[],
]
// MANAGED RESOURCE
export const managedResources = ManagedResource.make<Model, Message>()(
entry => ({
engine: entry(S.Option(S.Null), {
resource: Engine,
modelToMaybeRequirements: model =>
M.value(model.engine).pipe(
M.tag('EngineBooting', 'EngineReady', () => Option.some(null)),
M.tag('EngineOff', 'EngineFailed', () => Option.none()),
M.exhaustive,
),
acquire: () =>
Layer.build(engineLayer).pipe(
Effect.map(context => Context.get(context, ComputeEngineService)),
),
release: () => Effect.void,
onAcquired: ({ engineId }) => StartedEngine({ engineId }),
onReleased: () => StoppedEngine(),
onAcquireError: error => FailedStartEngine({ reason: String(error) }),
}),
}),
)
// VIEW
const buttonClassName =
'px-6 py-3 font-semibold text-white transition-colors data-[disabled]:opacity-40 data-[disabled]:cursor-not-allowed'
const primaryButton = (
label: string,
message: Message,
colorClassName: string,
isDisabled = false,
): Html => {
const h = html<Message>()
return Button.view<Message>({
onClick: message,
isDisabled,
toView: attributes =>
h.button(
[...attributes.button, h.Class(`${buttonClassName} ${colorClassName}`)],
[label],
),
})
}
const engineStatusView = (engine: EngineState): Html => {
const h = html<Message>()
const status = M.value(engine).pipe(
M.tag('EngineOff', () => ({
colorClassName: 'text-gray-500',
text: 'Engine is off.',
})),
M.tag('EngineBooting', () => ({
colorClassName: 'text-amber-600',
text: 'Booting engine...',
})),
M.tag('EngineReady', ({ engineId }) => ({
colorClassName: 'text-green-600',
text: `Engine ready: ${engineId}`,
})),
M.tag('EngineFailed', ({ reason }) => ({
colorClassName: 'text-red-600',
text: `Engine failed: ${reason}`,
})),
M.exhaustive,
)
return h.keyed('p')(
engine._tag,
[h.Class(status.colorClassName)],
[status.text],
)
}
const engineControlsView = (engine: EngineState): Html => {
const h = html<Message>()
const controls = M.value(engine).pipe(
M.tag('EngineBooting', 'EngineReady', () => ({
key: 'Running',
label: 'Stop engine',
message: ClickedStopEngine(),
colorClassName: 'bg-red-500 hover:bg-red-600',
})),
M.tag('EngineOff', 'EngineFailed', () => ({
key: 'Stopped',
label: 'Start engine',
message: ClickedStartEngine(),
colorClassName: 'bg-green-500 hover:bg-green-600',
})),
M.exhaustive,
)
return h.keyed('div')(
controls.key,
[h.Class('flex gap-3')],
[primaryButton(controls.label, controls.message, controls.colorClassName)],
)
}
const squareResultView = (maybeSquareResult: Option.Option<number>): Html => {
const h = html<Message>()
const text = Option.match(maybeSquareResult, {
onNone: () => 'No result yet.',
onSome: value => `Square result: ${value}`,
})
return h.div([h.Class('text-gray-800')], [text])
}
const isEngineReady = (engine: EngineState): boolean =>
engine._tag === 'EngineReady'
export const view = (model: Model): Document => {
const h = html<Message>()
const isComputeDisabled = !isEngineReady(model.engine)
return {
title: 'Managed Resource Layer',
body: h.div(
[h.Class('min-h-screen bg-gray-100 flex items-center justify-center')],
[
h.div(
[h.Class('bg-white p-8 rounded-lg shadow flex flex-col gap-5 w-96')],
[
h.h1(
[h.Class('text-xl font-bold text-gray-900')],
['Layer-backed Managed Resource'],
),
engineStatusView(model.engine),
engineControlsView(model.engine),
primaryButton(
'Compute next square',
ClickedCompute(),
'bg-blue-500 hover:bg-blue-600 data-[disabled]:hover:bg-blue-500',
isComputeDisabled,
),
squareResultView(model.maybeSquareResult),
],
),
],
),
}
}