Resources
The third fundamental Effection operation is resource()
. It can
seem a little complicated at first, but the reason for its existence is
rather simple. Sometimes there are operations which meet the following criteria:
- They are long running
- We want to be able to interact with them while they are running
As an example, let's consider a program that creates a Socket and sends messages to it while it is open. This is fairly simple to write using regular operations like this:
import { main, once } from 'effection';
import { Socket } from 'net';
await main(function*() {
let socket = new Socket();
socket.connect(1337, '127.0.0.1');
yield* once(socket, 'connect');
socket.write('hello');
socket.close();
});
This works, but there are a lot of details we need to remember in order to use the socket safely. For example, whenever we use a socket, we don't want to have to remember to close it once we're done. Instead, we'd like that to happen automatically. Our first attempt to do so might look something like this:
import { once, suspend } from 'effection';
import { Socket } from 'net';
export function* useSocket(port, host) {
let socket = new Socket();
socket.connect(port, host);
yield* once(socket, 'connect');
try {
yield* suspend();
return socket;
} finally {
socket.close();
}
}
But when we actually try to call our useSocket
operation, we run into a
problem: because our useSocket()
operation suspends, it will wait around
forever and never return control back to our main.
import { main } from 'effection';
import { useSocket } from './use-socket';
await main(function*() {
let socket = yield* useSocket(1337, '127.0.0.1'); // blocks forever
socket.write('hello'); // we never get here
});
Remember our criteria from before:
- Socket is a long running process
- We want to interact with the socket while it is running by sending messages to it
This is a good use-case for using a resource operation. Let's look at how we can
rewrite useSocket()
as a resource.
import { once, resource } from 'effection';
export function useSocket(port, host) {
return resource(function* (provide) {
let socket = new Socket();
socket.connect(port, host);
yield* once(socket, 'connect');
try {
yield* provide(socket);
} finally {
socket.close();
}
}
}
Before we unpack what's going on, let's just note that how we call useSocket()
has not changed at all, only it now works as expected!
import { main } from 'effection';
import { useSocket } from './use-socket';
await main(function*() {
let socket = yield* useSocket(1337, '127.0.0.1'); // waits for the socket to connect
socket.write('hello'); // this works
// once `main` finishes, the socket is closed
});
The body of a resource is used to initialize a value and make it
available to the operation from which it was called. It can do any
preparation it needs to, and take as long as it wants, but at some
point, it has to "provide" the value back to the caller. This is done
with the provide()
function that is passed as an argument into each
resource constructor. This special operation, when yielded to, passes
control back to the caller with our newly minted value as its result.
However, its work is not done yet. The provide()
operation will remain
suspended until the resource passes out of scope, thus making sure that
cleanup is guaranteed.
💡A simple mantra to repeat to yourself so that you remember how resources work is this: resources provide values.
Resources can depend on other resources, so we could use this to make a socket which sends a heart-beat every 10 seconds.
import { main, resource, spawn } from 'effection';
import { useSocket } from './use-socket';
function useHeartSocket(port, host) {
return resource(function* (provide) {
let socket = yield* useSocket(port, host);
yield* spawn(function*() {
while (true) {
yield* sleep(10_000);
socket.send(JSON.stringify({ type: "heartbeat" }));
}
});
yield* provide(socket);
});
}
await main(function*() {
let socket = yield* useHeartSocket(1337, '127.0.0.1'); // waits for the socket to connect
socket.write({ hello: 'world' }); // this works
// once `main` finishes:
// 1. the heartbeat is stopped
// 2. the socket is closed
});
The original socket is created, connected, and set up to pulse every ten seconds, and it's cleaned up just as easily as it was created.
Note that the ensure()
operation is an appropriate mechanism to
enact cleanup within resources as well. While a matter of preference, the
useSocket()
resource above could also have been written as:
import { ensure, once, resource } from 'effection';
export function useSocket(port, host) {
return resource(function* (provide) {
let socket = new Socket();
yield* ensure(() => { socket.close(); });
socket.connect(port, host);
yield* once(socket, 'connect');
yield* provide(socket);
}
}
Resources allow us to create powerful, reusable abstractions which are also able to clean up after themselves.