All Examples
WebSocket Chat
A ManagedResource owns a WebSocket connection lifecycle. Demonstrates connection state, reconnection, and frames entering update as Messages.
Managed Resources
WebSocket
/
import {
Array,
DateTime,
Duration,
Effect,
Match,
Option,
Queue,
Schema,
Stream,
String,
} from 'effect'
import {
Command,
ManagedResource,
Runtime,
Subscription,
type Update,
} from 'foldkit'
import { Document, Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { defineTaggedUnion } from 'foldkit/schema'
import { evo } from 'foldkit/struct'
import { Button, Input } from '@foldkit/ui'
const WS_URL = 'wss://ws.postman-echo.com/raw'
const CONNECTION_TIMEOUT_MS = 5000
const getZonedTime = DateTime.now.pipe(
Effect.map(utc => DateTime.setZone(utc, DateTime.zoneMakeLocal())),
)
// MODEL
const ChatMessage = Schema.Struct({
text: Schema.String,
zoned: Schema.DateTimeZoned,
isSent: Schema.Boolean,
})
type ChatMessage = typeof ChatMessage.Type
const ChatSocket = ManagedResource.tag<WebSocket>()('ChatSocket')
type ChatSocketService = ManagedResource.ServiceOf<typeof ChatSocket>
export const ConnectionState = defineTaggedUnion({
Disconnected: {},
Connecting: {},
Connected: {},
Error: { error: Schema.String },
})
export type ConnectionState = typeof ConnectionState.Type
export const Model = Schema.Struct({
connection: ConnectionState,
messages: Schema.Array(ChatMessage),
messageInput: Schema.String,
})
export type Model = typeof Model.Type
// MESSAGE
export const Message = defineMessageUnion({
ClickedConnect: {},
Connected: {},
Disconnected: {},
FailedConnect: { error: Schema.String },
UpdatedMessageInput: { value: Schema.String },
SubmittedMessage: {},
SucceededSendMessage: { text: Schema.String },
ReceivedMessage: { text: Schema.String },
TimestampedMessage: {
text: Schema.String,
zoned: Schema.DateTimeZoned,
isSent: Schema.Boolean,
},
})
export type Message = typeof Message.Type
// UPDATE
type UpdateReturn = Update.Return<Model, Message, ChatSocketService>
export const update = (model: Model, message: Message) =>
Message.match<UpdateReturn>(message, {
ClickedConnect: () => ({
model: evo(model, {
connection: () => ConnectionState.Connecting(),
}),
}),
Connected: () => ({
model: evo(model, {
connection: () => ConnectionState.Connected(),
}),
}),
Disconnected: () => ({
model: evo(model, {
connection: () => ConnectionState.Disconnected(),
messages: () => [],
}),
}),
FailedConnect: ({ error }) => ({
model: evo(model, {
connection: () => ConnectionState.Error({ error }),
}),
}),
UpdatedMessageInput: ({ value }) => ({
model: evo(model, {
messageInput: () => value,
}),
}),
SubmittedMessage: () => {
const trimmedMessage = model.messageInput.trim()
if (String.isEmpty(trimmedMessage)) {
return { model }
}
return Match.value(model.connection).pipe(
Match.withReturnType<UpdateReturn>(),
Match.tag('Connected', () => ({
model: evo(model, {
messageInput: () => '',
}),
commands: [SendMessage({ text: trimmedMessage })],
})),
Match.orElse(() => ({ model })),
)
},
SucceededSendMessage: ({ text }) => ({
model,
commands: [TimestampSentMessage({ text })],
}),
ReceivedMessage: ({ text }) => ({
model,
commands: [TimestampReceivedMessage({ text })],
}),
TimestampedMessage: ({ text, zoned, isSent }) => {
const newMessage = ChatMessage.make({ text, zoned, isSent })
return {
model: evo(model, {
messages: messages => [...messages, newMessage],
}),
}
},
})
// INIT
export const init: Runtime.ApplicationInit<Model, Message> = () => ({
model: {
connection: ConnectionState.Disconnected(),
messages: [],
messageInput: '',
},
})
// COMMAND
export const TimestampSentMessage = Command.define('TimestampSentMessage', {
args: { text: Schema.String },
messages: [Message.TimestampedMessage],
execute: ({ text }) =>
getZonedTime.pipe(
Effect.map(zoned =>
Message.TimestampedMessage({ text, zoned, isSent: true }),
),
),
})
export const TimestampReceivedMessage = Command.define(
'TimestampReceivedMessage',
{
args: { text: Schema.String },
messages: [Message.TimestampedMessage],
execute: ({ text }) =>
getZonedTime.pipe(
Effect.map(zoned =>
Message.TimestampedMessage({ text, zoned, isSent: false }),
),
),
},
)
export const SendMessage = Command.define('SendMessage', {
args: { text: Schema.String },
messages: [Message.SucceededSendMessage, Message.FailedConnect],
execute: ({ text }) =>
ChatSocket.get.pipe(
Effect.flatMap(socket =>
Effect.sync(() => {
socket.send(text)
return Message.SucceededSendMessage({ text })
}),
),
Effect.catchTag('ResourceNotAvailable', () =>
Effect.succeed(Message.FailedConnect({ error: 'Socket unavailable' })),
),
),
})
// MANAGED RESOURCE
export const managedResources = ManagedResource.make<Model, Message>()(
entry => ({
chatSocket: entry(Schema.Option(Schema.Null), {
resource: ChatSocket,
modelToMaybeRequirements: model =>
Match.value(model.connection).pipe(
Match.tag('Connecting', () => Option.some(null)),
Match.tag('Connected', () => Option.some(null)),
Match.orElse(() => Option.none()),
),
acquire: () =>
Effect.callback<WebSocket, Error>(resume => {
const ws = new WebSocket(WS_URL)
const handleOpen = () => {
ws.removeEventListener('error', handleError)
resume(Effect.succeed(ws))
}
const handleError = () => {
ws.removeEventListener('open', handleOpen)
resume(Effect.fail(new Error('Failed to connect to WebSocket')))
}
ws.addEventListener('open', handleOpen)
ws.addEventListener('error', handleError)
return Effect.sync(() => {
ws.removeEventListener('open', handleOpen)
ws.removeEventListener('error', handleError)
})
}).pipe(
Effect.timeout(Duration.millis(CONNECTION_TIMEOUT_MS)),
Effect.catchTag('TimeoutError', () =>
Effect.fail(new Error('Connection timeout')),
),
),
release: socket =>
Effect.sync(() => {
socket.close()
}),
onAcquired: () => Message.Connected(),
onReleased: () => Message.Disconnected(),
onAcquireError: error =>
Message.FailedConnect({
error: error instanceof Error ? error.message : 'Unknown error',
}),
}),
}),
)
// SUBSCRIPTION
const streamChatSocketMessages = (socket: WebSocket) =>
Stream.callback<
| typeof Message.ReceivedMessage.Type
| typeof Message.Disconnected.Type
| typeof Message.FailedConnect.Type
>(queue =>
Effect.acquireRelease(
Effect.sync(() => {
const handleMessage = (event: MessageEvent) => {
Queue.offerUnsafe(
queue,
Message.ReceivedMessage({ text: event.data }),
)
}
const handleClose = () => {
Queue.offerUnsafe(queue, Message.Disconnected())
Queue.endUnsafe(queue)
}
const handleError = () => {
Queue.offerUnsafe(
queue,
Message.FailedConnect({ error: 'Connection error' }),
)
Queue.endUnsafe(queue)
}
socket.addEventListener('message', handleMessage)
socket.addEventListener('close', handleClose)
socket.addEventListener('error', handleError)
return { handleMessage, handleClose, handleError }
}),
({ handleMessage, handleClose, handleError }) =>
Effect.sync(() => {
socket.removeEventListener('message', handleMessage)
socket.removeEventListener('close', handleClose)
socket.removeEventListener('error', handleError)
}),
).pipe(Effect.flatMap(() => Effect.never)),
)
export const subscriptions = Subscription.make<
Model,
Message,
ChatSocketService
>()(entry => ({
isConnected: entry(
{ isConnected: Schema.Boolean },
{
modelToDependencies: model => ({
isConnected: model.connection._tag === 'Connected',
}),
dependenciesToStream: ({ isConnected }) =>
Stream.when(
Stream.unwrap(
ChatSocket.get.pipe(
Effect.map(streamChatSocketMessages),
Effect.catchTag('ResourceNotAvailable', () =>
Effect.succeed(Stream.empty),
),
),
),
Effect.sync(() => isConnected),
),
},
),
}))
// VIEW
export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
title: 'WebSocket Chat',
body: h.div(
[
h.Class(
'min-h-screen bg-gradient-to-br from-purple-100 to-blue-100 flex flex-col items-center justify-center p-6',
),
],
[
h.div(
[
h.Class(
'bg-white rounded-xl shadow-2xl w-full max-w-2xl flex flex-col h-[600px]',
),
],
[
h.div(
[
h.Class(
'p-6 border-b border-gray-200 flex items-center justify-between',
),
],
[
h.div(
[],
[
h.div(
[h.Class('text-2xl font-bold text-gray-800')],
['WebSocket Chat'],
),
h.div(
[h.Class('text-sm text-gray-500 mt-1')],
['Echo server demo'],
),
],
),
connectionStatusView(model.connection, h),
],
),
messagesView(model.messages, h),
ConnectionState.match(model.connection, {
Disconnected: () => connectButtonView(h),
Connecting: () => connectingView(h),
Connected: () => messageInputView(model.messageInput, h),
Error: ({ error }) => errorView(error, h),
}),
],
),
],
),
})
const connectionStatusView = (
connection: ConnectionState,
h: HtmlBuilder<Message>,
): Html =>
h.div(
[h.Class('flex items-center gap-2')],
[
ConnectionState.match(connection, {
Disconnected: () => h.div([h.Class('w-3 h-3 rounded-full bg-red-500')]),
Connecting: () =>
h.div([h.Class('w-3 h-3 rounded-full bg-yellow-500 animate-pulse')]),
Connected: () => h.div([h.Class('w-3 h-3 rounded-full bg-green-500')]),
Error: () => h.div([h.Class('w-3 h-3 rounded-full bg-red-500')]),
}),
ConnectionState.match(connection, {
Disconnected: () =>
h.span([h.Class('text-sm text-gray-600')], ['Disconnected']),
Connecting: () =>
h.span([h.Class('text-sm text-gray-600')], ['Connecting...']),
Connected: () =>
h.span([h.Class('text-sm text-gray-600')], ['Connected']),
Error: () => h.span([h.Class('text-sm text-red-600')], ['Error']),
}),
],
)
const messagesView = (
messages: ReadonlyArray<ChatMessage>,
h: HtmlBuilder<Message>,
): Html =>
Array.match(messages, {
onEmpty: () =>
h.div(
[
h.Class(
'flex-1 p-6 overflow-y-auto flex items-center justify-center',
),
],
[
h.div(
[h.Class('text-center text-gray-400')],
[
h.p([h.Class('text-lg mb-2')], ['No messages yet']),
h.p([h.Class('text-sm')], ['Send a message to get started!']),
],
),
],
),
onNonEmpty: messages =>
h.div(
[h.Class('flex-1 p-6 overflow-y-auto')],
[
h.ul(
[h.Class('space-y-3')],
messages.map(message => {
return h.li(
[
h.Class(
message.isSent ? 'flex justify-end' : 'flex justify-start',
),
],
[
h.div(
[
h.Class(
message.isSent
? 'bg-blue-500 text-white rounded-lg px-4 py-2 max-w-xs'
: 'bg-gray-200 text-gray-800 rounded-lg px-4 py-2 max-w-xs',
),
],
[
h.p([h.Class('break-words')], [message.text]),
h.p(
[
h.Class(
message.isSent
? 'text-blue-100 text-xs mt-1'
: 'text-gray-500 text-xs mt-1',
),
],
[
DateTime.format(message.zoned, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}),
],
),
],
),
],
)
}),
),
],
),
})
const connectButtonView = (h: HtmlBuilder<Message>): Html =>
h.div(
[h.Class('p-6 border-t border-gray-200 flex items-center justify-center')],
[
Button.view(
{
onClick: Message.ClickedConnect(),
toView: attributes =>
h.button(
[
...attributes.button,
h.Class(
'bg-blue-500 hover:bg-blue-600 text-white font-semibold px-8 py-3 rounded-lg transition',
),
],
['Connect to Chat'],
),
},
h,
),
],
)
const connectingView = (h: HtmlBuilder<Message>): Html =>
h.div(
[h.Class('p-6 border-t border-gray-200 flex items-center justify-center')],
[h.div([h.Class('text-gray-600 font-semibold')], ['Connecting...'])],
)
const messageInputView = (
messageInput: string,
h: HtmlBuilder<Message>,
): Html =>
h.form(
[
h.Class('p-6 border-t border-gray-200'),
h.OnSubmit(Message.SubmittedMessage()),
],
[
h.div(
[h.Class('flex gap-3')],
[
Input.view(
{
id: 'message',
value: messageInput,
placeholder: 'Type a message...',
onInput: value => Message.UpdatedMessageInput({ value }),
toView: attributes =>
h.input([
...attributes.input,
h.Class(
'flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500',
),
]),
},
h,
),
Button.view(
{
type: 'submit',
isDisabled: String.isEmpty(messageInput.trim()),
toView: attributes =>
h.button(
[
...attributes.button,
h.Class(
'bg-blue-500 hover:bg-blue-600 data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed text-white font-semibold px-6 py-3 rounded-lg transition',
),
],
['Send'],
),
},
h,
),
],
),
],
)
const errorView = (error: string, h: HtmlBuilder<Message>): Html =>
h.div(
[h.Class('p-6 border-t border-gray-200')],
[
h.div(
[h.Class('bg-red-50 border border-red-200 rounded-lg p-4 mb-4')],
[
h.p(
[h.Class('text-red-800 font-semibold mb-1')],
['Connection Error'],
),
h.p([h.Class('text-red-600 text-sm')], [error]),
],
),
Button.view(
{
onClick: Message.ClickedConnect(),
toView: attributes =>
h.button(
[
...attributes.button,
h.Class(
'w-full bg-blue-500 hover:bg-blue-600 text-white font-semibold px-6 py-3 rounded-lg transition',
),
],
['Try Again'],
),
},
h,
),
],
)