Join the weekly newsletter
By Arturo Cuya
@arturo__cuyaThe Roku BrightScript language usually handles async operations through observers. This pattern, while powerful, sometimes can lead us to implement anti-patterns that worsen our code readability and make it hard to debug.
Enter rokucommunity/promises, a community-made abstraction over the observable pattern that can help us:
In this post I will show you how to setup the library, all the useful methods available (onThen
, onCatch
, chain
, and all
for parallel ops) and several async use cases where you’ll want to use the library, including straightforward HTTP requests like this:
promise = request@.getPromise({
method: "GET",
url: "https://api.github.com/events"
})
promises.onThen(promise, sub (response as object)
? "ok?", response.ok
? "status code:", response.statusCode
end sub)
Noticed the
@.getPromise()
syntax? That’s a BrighterScript shortcut forcallFunc()
. If you’re not using BrighterScript yet or you’ve not been able to convince your team to use it, you’re really missing out. Check out this post to learn more about BrighterScript and get a FREE resource that will guarantee success in your migration.
For our first example, let’s consider a cinema service that uses dialogs to shows genres, then movies from the selected genre and finally a summary of the selected option.
A normal coding implementation would look like this:
import "utils.bs"
sub init()
m.catalogNames = utils.getCatalogNames()
' PROBLEM #1. See below <<<<<<<<<<<<<<<<<<
m.selectedCatalogIndex = invalid
m.selectedMovieIndex = invalid
showDialog(
"Welcome to Mango Cinema",
"What kind of movie do you want to watch today?",
m.catalogNames,
"handleCatalogOption") ' PROBLEM #2. See below <<<<<<<<<<<<<<<<<<
end sub
sub showDialog(title as string, message as string, buttons as object, handlerFunc as string)
dialog = CreateObject("roSGNode", "StandardMessageDialog")
dialog.update({
title: title,
message: [message],
buttons: buttons
})
dialog.observeField("buttonSelected", handlerFunc)
m.top.dialog = dialog
end sub
sub handleCatalogOption(message as object)
dialog = m.top.dialog
' PROBLEM #3. See below <<<<<<<<<<<<<<<<<<
dialog.unobserveField("buttonSelected")
m.selectedCatalogIndex = message.getData()
showDialog(
m.catalogNames[m.selectedCatalogIndex],
"Select a movie",
utils.getMovieNames(m.selectedCatalogIndex),
"handleMovieOption"
)
end sub
sub handleMovieOption(message as object)
' PROBLEM #4. See below <<<<<<<<<<<<<<<<<<
if (m.selectedCatalogIndex = invalid)
throw "Handled movie options before selecting catalog"
end if
dialog = m.top.dialog
dialog.unobserveField("buttonSelected")
m.selectedMovieIndex = message.getData()
movieData = utils.mapMovieIndex(m.selectedCatalogIndex, m.selectedMovieIndex)
showDialog(
movieData.name,
`${movieData.description} Starring: ${movieData.starring.join(". ")}`,
["Purchase", "Cancel"],
"handleFinishProcess"
)
end sub
sub handleFinishProcess(message as object)
if (m.selectedCatalogIndex = invalid)
' PROBLEM #4. See below <<<<<<<<<<<<<<<<<<
throw "Handled finish process before selecting catalog"
end if
if (m.selectedMovieIndex = invalid)
throw "Handled finish process before selecting movie"
end if
dialog = m.top.dialog
dialog.unobserveField("buttonSelected")
dialog.close = true
? `Selected catalog: ${m.selectedCatalogIndex}. Selected movie: ${m.selectedMovieIndex}`
end sub
This is, by itself, fairly compact since we’re reusing the showDialog()
function across the file. We’ve also pushed utility functions to a utils.bs
file (that’s were the getCatalogNames()
function and others come from). However, this implementation presents a number of problems that will scale linearly with the number of use cases you need to support.
Let me explain:
For information that needs to be preserved between handlers, you will need to create m
scoped variables. Here we have two: m.selectedCatalogIndex
and m.selectedMovieIndex
.
For every dialog shown, you must define a handler function in the scope of the current component.
We are reusing the same m.dialog
pointer to avoid memory leaks. But this requires us to remove the observers on each handler to ensure that unexpected handlers will be fired since all of them are observing the same field.
Since our handler functions are in the component scope, someone else can come without context and try to use them directly. So we need to implement handlers for unintended uses of the functions (throw
statements in our case).
The rokucommunity/promises library offers an abstraction on top of the observer pattern that solve the four problems mentioned above. Promises work similarly to JavaScript promises, with a few differences caused by how BrightScript works under the hood.
The core of the library is the Promise
node:
<component name="Promise" extends="Node">
<interface>
<field id="promiseState" type="string" value="pending" alwaysNotify="true" />
</interface>
</component>
It’s rather simple, just a regular Node
with a promiseState
that starts at "pending"
and can change to "resolved"
or "rejected"
.
You’ll notice that there is no “result” field in the implementation. A Promise
result can be anything, but SceneGraph doesn’t let us define XML fields with unknown types. So the promiseResult
field is injected by the library right before the promise is resolved.
This simple design creates an abstraction that has fully cross-library compatibility, meaning that it doesn’t matter how or in what context the Promise
is resolved, the promise handler will be executed properly. This is different from another community implementation called roku-promise that only works with Task
nodes.
rokucommunity/promises
installation instructions
⚠️ If you’re using
ropm
to install the library, the package manager might insert prefixes to your function names to avoid collisions. Double check this by inspecting theroku_modules/
folder thatropm
generates.
All the library methods are encapsulated in a promises
namespace. Creating a promise is straightforward:
sub main()
myPromise = promises.create()
end sub
Resolving a promise is also easy. We pass the promise and an arbitrary value to resolve or reject it.
sub somewhereElse(promise, result)
if (result = invalid)
code = 404
promises.reject(code, promise)
else
promises.resolve(result, promise)
end if
end sub
The onThen
and onCatch
methods allow us to handle the resolving and rejection of a promise through an anonymous function. The handler function must be able to receive an arbitrary argument, to where the library will pass the resolve or reject value.
sub main()
myPromise = promises.create()
promises.onThen(myPromise, sub (result)
? "promise was resolved with result", result
end sub)
promises.onCatch(myPromise, sub (errorCode)
? "promise was rejected with code", errorCode
end sub)
end sub
⚠️ The
onThen
method and all the other Promise handlers only work inside a component scope living on the render thread. Don’t use it on themain.brs
file or inside a Task thread.
The library also provides the chain
and all
methods to handle sequential and parallel promises respectively. Consider the following sequence:
Get the user credentials from the local device registry
Use the credentials to get a fresh user token
Use the token to get the updated user profile
Get the results from 3 different endpoints, each returning recommendations based on location, age and the user’s job respectively.
Show the sum of the recommendations in the UI
Show an error dialog in case there’s a problem
sub main()
promise = getUserCredentialsPromise()
promises.chain(promise, sub (credentials)
? "got user credentials", credentials
return getAuthenticateUserPromise(credentials)
end sub).then(sub (userId)
? "authenticated user. got user id", userId
return getUserProfilePromise(userId)
end sub).then(sub (profile)
? "got user profile. user name is", profile.name
return promises.all([
getLocationRecomendationsPromise(profile.zip_code),
getAgeRecomendationsPromise(profile.age),
getJobRecomendationsPromise(profile.job)
])
end sub).then(sub (results)
? "loaded user recommendations based on location, age and job title"
resultSum = results[0] ' for location
resultSum.append(results[1]) ' for age
resultSum.append(results[2]) ' for user's job
showRecommendations(resultSum)
end sub).catch(sub (error)
? "An error ocurred", error
showErrorDialog(error)
end sub)
end sub
The promises.chain()
function will take the first promise and the handler for it. The handler must return a new promise that will be handled in the .then()
function extended from chain()
. Each step of the chain must also return the next promise.
The promises.all()
function takes an array of promises and resolves itself when all the passed promises are resolved. The argument that will be passed to the handler function is an array of promise results, in the same order they were declared initially.
The .catch()
function will be invoked if the promise is rejected or if any of the handler functions from the chain throws an exception.
You may have noticed that inside the promise handlers we have access to the component scope. This means that we are able to use m
variables and any function defined in the component. However, due to the fact that BrightScript doesn’t implement closures, we can’t access any local variable from the inmediate parent scope.
sub main()
m.pi = 3.14
magicNumber = 42
promise = getRadiusPromise()
promises.onThen(promise, sub (radius as float)
' we have access to `m.pi`
result = 2 * m.pi * radius
' we have access to the methods defined in the component scope
showResultUi(result)
' CRASH: magicNumber is uninitialized
superCircumference = result * magicNumber
end sub)
end sub
function showResultUi(result as float)
' ...
end function
To avoid this problem, we can pass a context
argument to the onThen
method, which can be of any type you want. Make sure to accept a second argument in the handler function too.
sub main()
m.pi = 3.14
magicNumber = 42
promise = getRadiusPromise()
promises.onThen(
promise,
sub (radius as float, context as object)
`' we have access to `m.pi`
result = 2 * m.pi * radius
' we have access to the methods defined in the component scope
showResultUi(result)
' The context object contains our magic number
`superCircumference = result * context.magicNumber
end sub,
' HERE is where we pass the context
{ magicNumber: magicNumber }
)
end sub
function showResultUi(result as float)
' ...
end function
Now let’s use the concepts we’ve learned to fix the problems we identified at the beggining of this post. The first thing we’ll need to refactor is the showDialog
function to return a Promise.
function showDialog(title as string, message as string, buttons as object) as object
dialog = CreateObject("roSGNode", "StandardMessageDialog")
dialog.update({
title: title,
message: [message],
buttons: buttons
})
dialog.addField("promise", "node", false)
dialog.promise = promises.create()
dialog.observeField("buttonSelected", "handleDialogButtonSelected")
m.top.dialog = dialog
return dialog.promise
end function
sub handleDialogButtonSelected(message as object)
selectedOption = message.getData()
promises.resolve(selectedOption, message.getRoSGNode().promise)
end sub
We’ll have to add a new field to the dialog node to hold the promise. We could also add the promise to the m
scope, but remember that wouldn’t scale well if our component handles multiple different promise flows.
We don’t need to take an additional argument anymore to declare the handler function. Instead, the handleDialogButtonSelected
will automatically resolve the promise with the selected option.
Sadly we cannot completely avoid using an observer since the
StandardMessageDialog
node needs one to handle the user input. But you can definitely use promises instead of observable fields when creating your own components.
With this set, here’s the final implementation:
import "utils.bs"
import "pkg:/source/lib/promises.bs"
sub init()
m.catalogNames = utils.getCatalogNames()
promise = showDialog(
"Welcome to Mango Cinema",
"What kind of movie do you want to watch today?",
m.catalogNames
)
promises.chain(
promise,
{ selectedCatalogIndex: invalid, selectedMovieIndex: invalid }
).then(function(selectedCatalog as integer, ctx as object) as object
' Handle selecting a movie category
ctx.selectedCatalogIndex = selectedCatalog
return showDialog(
m.catalogNames[selectedCatalog],
"Select a movie",
utils.getMovieNames(selectedCatalog)
)
end function).then(function (selectedMovie as integer, ctx as object) as object
' Handle selecting a movie
ctx.selectedMovieIndex = selectedMovie
movieData = utils.mapMovieIndex(ctx.selectedCatalogIndex, selectedMovie)
return showDialog(
movieData.name,
`${movieData.description} Starring: ${movieData.starring.join(". ")}`,
["Purchase", "Cancel"]
)
end function).then(sub (_ as dynamic, ctx as dynamic)
' Finish the process
m.top.dialog.unobserveField("buttonSelected")
m.top.dialog.close = true
? `Selected catalog: ${ctx.selectedCatalogIndex}. Selected movie: ${ctx.selectedMovieIndex}`
end sub)
end sub
function showDialog(title as string, message as string, buttons as object) as object
dialog = CreateObject("roSGNode", "StandardMessageDialog")
dialog.update({
title: title,
message: [message],
buttons: buttons
})
dialog.addField("promise", "node", false)
dialog.promise = promises.create()
dialog.observeField("buttonSelected", "handleDialogButtonSelected")
m.top.dialog = dialog
return dialog.promise
end function
sub handleDialogButtonSelected(message as object)
selectedOption = message.getData()
promises.resolve(selectedOption, message.getRoSGNode().promise)
end sub
Here’s how we solve each of the problems:
- For information that needs to be preserved between handlers, you will need to create
m
scoped variables.
We use the context parameter to handle these values
- For every dialog shown, you must define a handler function in the scope of the current component.
Now each handler is an anonymous function
- We are reusing the same
m.dialog
pointer to avoid memory leaks. But this requires us to remove the observers on each handler.
Promise handler functions also rely on observers under the hood, but the library removes them automatically on resolve / reject.
- Since our handler functions are in the component scope, someone else can come without context and try to use them directly. So we need to implement handlers for unintended uses of the functions.
Now that our handler functions are anonymous, nobody can use them with unintended purposes.
And as a nice bonus, our code is about 30% smaller now.
So far we’ve seen how to use the promises library for async operations in the render thread. But there are some processes that can only be done in a Task
thread, one of them being HTTP requests.
Let’s see how to create a common PromiseTask
component that always returns a promise, and then how to extend it to perform HTTP requests, so that we can do something like this:
promise = request@.getPromise({
method: "GET",
url: "https://api.github.com/events"
})
promises.onThen(promise, sub (response as object)
? "ok?", response.ok
? "status code:", response.statusCode
end sub)
We need two basic features:
Similarly to how we made the StandardMessageDialog
hold the promise for us, we can extend the Task
node to hold a promise as well.
<component name="PromiseTask" extends="Task">
<script type="text/brightscript" uri="PromiseTask.bs" />
<interface>
<field id="promise" type="node" />
<function name="getPromise" />
</interface>
</component>
We’ll abstract resolving the promise, so that any node that extends PromiseTask
just has to implement the exec()
method.
import "pkg:/source/lib/promises.bs"
sub init()
m.top.functionName = "resolve"
m.input = invalid
end sub
function getPromise(input = invalid as dynamic) as object
m.input = input
m.top.promise = promises.create()
m.top.control = "run"
return m.top.promise
end function
' Override me
function exec(input = invalid as dynamic) as dynamic
throw "Not implemented"
end function
sub resolve()
try
result = exec(m.input)
promises.resolve(result, m.top.promise)
catch e
promises.reject(e, m.top.promise)
end try
end sub
With this set, we can now define a reusable RequestPromiseTask
node that extends from PromiseTask
:
<component name="RequestPromiseTask" extends="PromiseTask">
<script type="text/brightscript" uri="RequestTask.bs" />
</component>
Using the rokucommunity/requests library, we can perform a request in one line of code! The caveat is that this only works inside a Task
thread, that’s why we have to create the RequestPromiseTask
wrapper.
import "pkg:/source/lib/requests.bs"
function exec(input as object) as dynamic
return Requests_request(input.method, input.url, input.args)
end function
⚠️ If you’re using
ropm
to install the library, the package manager might insert prefixes to your function names to avoid collisions. Double check this by inspecting theroku_modules/
folder thatropm
generates.
With this in place, we can now handle a promise like so:
sub main()
request = CreateObject("roSGNode", "RequestPromiseTask")
promise = request@.getPromise({
method: "GET",
url: "https://api.github.com/events"
})
promises.onThen(promise, sub (response as object)
? "ok?", response.ok
? "status code:", response.statusCode
end sub)
end sub
That’s it! Now you know how to handle your async workflows efficiently - be it in the render thread or within a Task.
If you liked this post, consider subscribing to our newsletter at the end of this post!
Next post will be about the upcoming improved type tracking capabilities for BrighterScript:
' Union types
sub log(data as string or number)
print data.toStr()
end sub
' Typed arrays
function sum(values as integer[]) as integer
total = 0
for each value in values
total += value
end for
return total
end function
' Smarter validations
sub doWork(values as integer[])
' Validation error: lcase() expects a string, but values.pop() returns an integer
print lcase(values.pop())
result = "Hello world"
' Validation error: values.push() expects an integer argument
values.push(result)
end sub
' Complex interfaces
interface DataResponse
status as number
data as string
end interface
function getStatusString(response as DataResponse) as string
if response.status >= 200 and response.status < 300
return "Success"
end if
return "Error"
end function
For absolute beginners, this course is meant to set you in the right path to make use of modern tools and best practices from day one. Subscribe to the newsletter to get notified when it's available.
Use these tools to 10x your workflows. Free and open source forever.