Resources
As we discussed in the chapter on tasks, an Operation can be one of several things, one of which is a Resource. In this chapter we will explain what a resource is and why you might want to use one.
The resource criteria
Resources can seem a little complicated, but the reason for their 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
Why resources?
As an example, let's consider that our program opens a Socket, and we want to be able to send messages to this socket while it is open. This is fairly simple to write using regular operations like this:
import { main } from 'effection';
import { Socket } from 'net';
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 to use the socket safely. It would be nice if we could create a friendly abstraction that we could reuse in all of our code that uses socket.
We know we want to close this socket once we're done, so our first attempt might look something like this:
import { once } from 'effection';
export function *createSocket(port, host) {
let socket = new Socket();
socket.connect(port, host);
yield once(socket, 'connect');
try {
yield
return socket;
} finally {
socket.close();
}
}
But when we actually try to use our createSocket
operation, we run into a problem:
import { main } from 'effection';
import { createSocket } from './create-socket';
main(function*() {
let socket = yield createSocket(1337, '127.0.0.1'); // this blocks forever
socket.write('hello'); // we never get here
});
Using resources
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. Let's look at how we can rewrite
createSocket
using resources. Effection considers any object which has an
init
function a Resource. The init
function initializes the Resource, and
an implementation of createSocket
could look like this:
import { once, spawn } from 'effection';
export function createSocket(port, host) {
return {
*init() {
let socket = new Socket();
socket.connect(port, host);
yield spawn(function* closeSocket() {
try {
yield
} finally {
socket.close();
}
});
yield once(socket, 'connect');
return socket;
}
}
}
Before we unpack what's going on, let's just note that how we use createSocket
has
not changed at all, only it now works as we expect!
import { main } from 'effection';
import { createSocket } from './create-socket';
main(function*() {
let socket = yield createSocket(1337, '127.0.0.1'); // waits for the socket to connect
socket.write('hello'); // this works
// once `main` finishes, the socket is closed
});
How resources work
The init
function is used to initialize the resource. Once the init
function is done, we can proceed past the yield
point where we called
createSocket
in main
. You can see why we call once
from init
, so we
wait for the socket to open before proceeding.
But what about the call to spawn
? We have previously established that a
spawned Task cannot outlive its parent, and so the Task that we spawned really
should be halted when the init
function is finished. But the rules within
init
are slightly different, and this is what gives resources their power. If
we use spawn
in init
then the spawned Task is actually spawned under the
Task that created the resource, which in this case is the main
task. The Task
still cannot outlive its parent, which is main
, but its able to outlive the
init
function.
You can think of this as creating a task hierarchy which looks something like this:
+-- main
|
+-- init
|
+-- once(socket, 'connect')
|
+-- closeSocket
As you can see, closeSocket
and init
are siblings rather than closeSocket
being a child of init
.
Nested resources
Other resources created within init
behave the same way and are created as
siblings. We can use this to make a socket which serializes anything written
to it as JSON:
import { main, once, spawn } from 'effection';
import { createSocket } from './create-socket';
function createJSONSocket(port, host) {
return {
*init() {
let socket = yield createSocket(port, host); // outlives the `init` function
return {
write: (value) => socket.write(JSON.stringify(value));
}
}
}
}
main(function*() {
let socket = yield createJSONSocket(1337, '127.0.0.1'); // waits for the socket to connect
socket.write({ hello: 'world' }); // this works
// once `main` finishes, the socket is closed
});
Here we're able to reuse our createSocket
operation. Resources allow us
to create powerful, reusable abstractions which are also able to clean up
after themselves.
If we used a regular operation here and not a Resource, the socket would be closed before we could ever send a message to it.
// THIS DOESN'T WORK
import { once, spawn } from 'effection';
import { createSocket } from './create-socket';
export function *createJSONSocket(port, host) {
let socket = yield createSocket(port, host);
return {
write: (value) => socket.write(JSON.stringify(value));
}
// socket gets closed here!
}