Skip to main content
All Examples

Managed Resource Layer

A layer-backed ManagedResource 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,
  Number,
  Option,
  Schema,
} from 'effect'
import { Command, ManagedResource, Runtime, type Update } from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { defineTaggedUnion } from 'foldkit/schema'
import { modifyFields } 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 EngineState = defineTaggedUnion({
  Off: {},
  Booting: {},
  Ready: { engineId: Schema.String },
  Failed: { reason: Schema.String },
})
type EngineState = typeof EngineState.Type

export const Model = Schema.Struct({
  engine: EngineState,
  computeCount: Schema.Number,
  maybeSquareResult: Schema.Option(Schema.Number),
})
export type Model = typeof Model.Type

// MESSAGE

export const Message = defineMessageUnion({
  ClickedStartEngine: {},
  ClickedStopEngine: {},
  StartedEngine: { engineId: Schema.String },
  StoppedEngine: {},
  FailedStartEngine: { reason: Schema.String },
  ClickedCompute: {},
  CompletedCompute: { result: Schema.Number },
  SkippedCompute: {},
})

export type Message = typeof Message.Type

// COMMAND

export const Compute = Command.define('Compute', {
  args: { value: Schema.Number },
  messages: [Message.CompletedCompute, Message.SkippedCompute],
  execute: ({ value }) =>
    Effect.gen(function* () {
      const engine = yield* Engine.get
      return Message.CompletedCompute({ result: engine.square(value) })
    }).pipe(
      Effect.catchTag('ResourceNotAvailable', () =>
        Effect.succeed(Message.SkippedCompute()),
      ),
    ),
})

// UPDATE

export const update = (model: Model, message: Message) =>
  Message.match<Update.Return<Model, Message, EngineService>>(message, {
    ClickedStartEngine: () => ({
      model: modifyFields(model, { engine: () => EngineState.Booting() }),
    }),

    ClickedStopEngine: () => ({
      model: modifyFields(model, { engine: () => EngineState.Off() }),
    }),

    StartedEngine: ({ engineId }) => ({
      model: modifyFields(model, {
        engine: () => EngineState.Ready({ engineId }),
      }),
    }),

    StoppedEngine: () => ({ model }),

    FailedStartEngine: ({ reason }) => ({
      model: modifyFields(model, {
        engine: () => EngineState.Failed({ reason }),
      }),
    }),

    ClickedCompute: () => {
      const nextComputeCount = Number.increment(model.computeCount)
      return {
        model: modifyFields(model, { computeCount: () => nextComputeCount }),
        commands: [Compute({ value: nextComputeCount })],
      }
    },

    CompletedCompute: ({ result }) => ({
      model: modifyFields(model, {
        maybeSquareResult: () => Option.some(result),
      }),
    }),

    SkippedCompute: () => ({ model }),
  })

// INIT

export const init: Runtime.ApplicationInit<Model, Message> = () => ({
  model: {
    engine: EngineState.Off(),
    computeCount: 0,
    maybeSquareResult: Option.none(),
  },
})

// MANAGED RESOURCE

export const managedResources = ManagedResource.make<Model, Message>()(
  entry => ({
    engine: entry(Schema.Option(Schema.Null), {
      resource: Engine,
      modelToMaybeRequirements: model =>
        Match.value(model.engine).pipe(
          Match.tag('Booting', 'Ready', () => Option.some(null)),
          Match.tag('Off', 'Failed', () => Option.none()),
          Match.exhaustive,
        ),
      acquire: () =>
        Layer.build(engineLayer).pipe(
          Effect.map(context => Context.get(context, ComputeEngineService)),
        ),
      release: () => Effect.void,
      onAcquired: ({ engineId }) => Message.StartedEngine({ engineId }),
      onReleased: () => Message.StoppedEngine(),
      onAcquireError: error =>
        Message.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: boolean,
  h: HtmlBuilder<Message>,
): Html =>
  Button.view(
    {
      onClick: message,
      isDisabled,
      toView: attributes =>
        h.button(
          [
            ...attributes.button,
            h.Class(`${buttonClassName} ${colorClassName}`),
          ],
          [label],
        ),
    },
    h,
  )

const engineStatusView = (
  engine: EngineState,
  h: HtmlBuilder<Message>,
): Html => {
  const status = EngineState.match(engine, {
    Off: () => ({
      colorClassName: 'text-gray-500',
      text: 'Engine is off.',
    }),
    Booting: () => ({
      colorClassName: 'text-amber-600',
      text: 'Booting engine...',
    }),
    Ready: ({ engineId }) => ({
      colorClassName: 'text-green-600',
      text: `Engine ready: ${engineId}`,
    }),
    Failed: ({ reason }) => ({
      colorClassName: 'text-red-600',
      text: `Engine failed: ${reason}`,
    }),
  })

  return h.p([h.Class(status.colorClassName)], [status.text])
}

const engineControlsView = (
  engine: EngineState,
  h: HtmlBuilder<Message>,
): Html => {
  const controls = Match.value(engine).pipe(
    Match.tag('Booting', 'Ready', () => ({
      label: 'Stop engine',
      message: Message.ClickedStopEngine(),
      colorClassName: 'bg-red-500 hover:bg-red-600',
    })),
    Match.tag('Off', 'Failed', () => ({
      label: 'Start engine',
      message: Message.ClickedStartEngine(),
      colorClassName: 'bg-green-500 hover:bg-green-600',
    })),
    Match.exhaustive,
  )

  return h.div(
    [h.Class('flex gap-3')],
    [
      primaryButton(
        controls.label,
        controls.message,
        controls.colorClassName,
        false,
        h,
      ),
    ],
  )
}

const squareResultView = (
  maybeSquareResult: Option.Option<number>,
  h: HtmlBuilder<Message>,
): Html => {
  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 === 'Ready'

export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
  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, h),
            engineControlsView(model.engine, h),
            primaryButton(
              'Compute next square',
              Message.ClickedCompute(),
              'bg-blue-500 hover:bg-blue-600 data-[disabled]:hover:bg-blue-500',
              isComputeDisabled,
              h,
            ),
            squareResultView(model.maybeSquareResult, h),
          ],
        ),
      ],
    ),
  }
}