TaskPort is an Elm package allowing to call JavaScript APIs from Elm using the Task abstraction. The same repository contains the source for the Elm package, as well as the source for the JavaScript NPM companion package.
This package works in Chrome, Firefox, Safari, and Node.js.
If you are upgarding from a previous version, see CHANGES.
For the impatient, jump straight to Installation or Usage.
If you are developing an Elm package and want to use TaskPort to call JavaScript APIs, jump to Using TaskPort in Elm packages.
The Elm Architecture (aka TEA) forces applications to only change state through creating effectful commands (Cmd
), which upon completion yield a message (Msg
), which in turn is passed into the application's update
function to determine the changes to application state, as well as what further commands are required, if any. This paradigm makes the application state easy to reason about and prevents hard-to-find bugs.
However, there are situation when multiple effectful actions have to be carried out one after the other with some logic applied to the results in-between, but at the same time, the application state does not need to change until the sequence of actions is completed. For example, making a sequence of API calls over HTTP with some transformations applied to the intermediate results. If implemented using TEA paradigm, this would lead to unnecessarily fine-grained messages and increased complexity of the application model that needs to handle partially completed sequences of actions.
Fortunately, Elm provides a very useful Task module that allows effectful actions to be chained together, and their results transformed before being passed to the next effectful action in a pure functional way. The most notable example of this is Http.task function, which allows very complex yet practical interactions with HTTP-based APIs to be concisely expressed in Elm.
Of course, not every API is available over HTTP. One could imagine a mechanism to extend Elm langauge to allow any effectful action to be wrapped into a Task
to be executed in a controlled way. Unfortunately, Elm's existing JavaScript interoperability mechanisms are limited to one-way ports system, and do not allow creation of tasks.
This package aims to solve this problem by allowing any JavaScript function to be wrapped into a Task
and use full expresiveness of the Task
module to chain their execution with other tasks in a way that remains TEA-compliant and type-safe.
Note that these instructions apply to Elm application development. If you are authoring an Elm package that would be used in many applications, the approach is different. In such case, jump to section Using TaskPort in Elm packages
Before TaskPort can be used in Elm, it must be set up on the JavaScript side. There are a few steps that need to be done.
There are two ways to go about doing this depending on what is more appropriate for your application.
For browser-based Elm applications that don't have much of HTML/JavaScript code, TaskPort can be included using a <script>
tag.
<script src="https://unpkg.com/elm-taskport@ELM_PACKAGE_VERSION/dist/taskport.min.js"></script>
Substitute the actual version of the TaskPort package instead of ELM_PACKAGE_VERSION
. The code is checking that Elm and JS are on the same version to prevent things blowing up. If dependency on unpkg CDN makes your nervous, you can choose to distribute the JS file with the rest of your application. In this case, simply save it locally, add to your codebase, and modify the path in the <script>
tag accordingly.
For browser-based applications which use a bundler like Webpack, TaskPort JavaScript code can be downloaded as an NPM package.
npm add --save elm-taskport # or yarn add elm-taskport --save
This will bring all necessary JavaScript files files into node_modules/elm-taskport
directory. Once that is done, you can include TaskPort in your main JavaScript or TypeScript file.
import * as TaskPort from 'elm-taskport';
// use the following line instead of using a CommonJS target
// const TaskPort = require('elm-taskport');
If you are developing an Elm application in an environment that does not have XMLHttpRequest
in the global namespace (e.g. Node.js), you would need to provide that as well, because that's required for TaskPort to work. TaskPort is tested with xmlhttprequest NPM package version 1.8.0, which is recommended.
npm add --save elm-taskport xmlhttprequest@1.8.0 # or yarn add elm-taskport xmlhttprequest@1.8.0 --save
For browser-based Elm applications add a script to your HTML file to enable TaskPort in your environment.
<script>
TaskPort.install(); // can pass a settings object as a parameter, see below
// it may be the same script block where you initialise your Elm application
</script>
In order to use TaskPort in an environment that does not have XMLHttpRequest
in the global namespace (e.g. Node.js), it must be provided before Elm runtime is initialized. Note that this requires a CommonJS target and require
directive, as Elm compiler itself cannot yet generate JS code that is compatible with ECMAScript modules.
const TaskPort = require('elm-taskport');
const { XMLHttpRequest } = require('xmlhttprequest');
global.XMLHttpRequest = function() {
XMLHttpRequest.call(this);
TaskPort.install({}, this); // can pass a settings object as the first parameter, see below
}
// initialize your Elm application here by calling Elm.<<main module>>.init
Note that XMLHttpRequest
instance is passed as the second parameter to the install
function. This is necessary in Node.js because there is no XMLHttpRequest
in the global context. If this parameter is omitted, TaskPort attempts to install itself to a prototype of XMLHttpRequest
in the global namespace, which works in all browsers.
The first parameter of TaskPort install
function is an object that provides additional configuration. At the moment the following properties are supported.
logCallErrors
(boolean, default:false
) whether TaskPort should log errors thrown by JavaScript functions to the console.logInteropErrors
(boolean, default:true
) whether TaskPort should log errors occuring in the interop mechanism to the console.
Example use of settings parameter:
<script>
TaskPort.install({ logCallErrors: true, logInteropErrors: false });
</script>
Once TypePort is installed, you need to let it know what interop calls to expect and what to do when they are invoked.
TaskPort.register("functionName", (args) => {
return /* value or a Promise */
});
The type of args
is determined entirely by the client Elm code, or, to be more precise, by the argsEncoder
parameter passed to TaskPort.call
function. Refer to the Elm package documentation for details. This means that it is safe to deconstruct args
as an object or a list if that is how the arguments are encoded.
The function body can do anything a regular JavaScript function can do. The TaskPort interop logic will call Promise.resolve()
on the returned value, so the function can return either a Promise
or a simple value. For this reason, the function can be async
and use await
keyword.
Note that you have to register interop functions before you call the Elm init
if the application's init
function returns Cmd
using interop calls. Otherwise, you can register interop functions after you application was initialised.
TaskPort wraps each call of JavaScript functions into a Task, which is Elm's abstraction for an effectful operation, and which allows them to be chained together to achieve complex side effects, such as making multiple API calls, or interacting with the runtime environment.
Task
itself only represent a potential operation. In order for it to be executed, it needs to be converted to a command (instance of Cmd) and given to the Elm runtime. In most cases you would pass the task representing the JS interop call into Task.attempt function to turn it into a Cmd
. You would normally do this in your application's update
or init
function. See the Elm Architecture to learn more about these functions and the application lifecycle.
For simple no-argument calls use TaskPort.callNoArgs
.
-- TaskPort.Task is an alias for Task TaskPort.Error a
getWidgetsCount : TaskPort.Task Int
getWidgetsCount = TaskPort.callNoArgs
{ function = "getWidgetsCount"
, valueDecoder = Json.Decode.int
}
-- TaskPort.Result is an alias for Result TaskPort.Error a
type Msg = GotWidgetsCount (TaskPort.Result Int)
Task.attempt GotWidgetsCount getWidgetsCount
For functions that take arguments use TaskPort.call
.
getWidgetNameByIndex : Int -> TaskPort.Task String
getWidgetNameByIndex = TaskPort.call
{ function = "getWidgetNameByIndex"
, valueDecoder = Json.Decode.string
, argsEncoder = Json.Encode.int
} -- notice currying to return a function taking Int and producing a Task
type Msg = GotWidgetName (TaskPort.Result String)
Task.attempt GotWidgetName <| getWidgetNameByIndex 0
You can use Task.andThen
, Task.sequence
, and other functions to chain multiple calls.
type Msg = GotWidgets (TaskPort.Result (List String))
getWidgetsCount
|> Task.andThen
(\count ->
List.range 0 (count - 1)
|> List.map getWidgetNameByIndex
|> Task.sequence
)
|> Task.attempt GotWidgets
Upon completion of the interop call, Elm runtime will invoke your update
function with a message containing the result of the operation. If the task representing the interop call was not manipulated or chained with before passing it to the Elm runtime, the message will contain a Result, which will be one of two things:
- A
Result.Ok
with a response returned from the JS function decoded viavalueDecoder
parameter passed to thecall
(orcallNoArgs
). - A
Result.Err
with an instance ofTaskPort.Error
representing a problem that occured whilst attempting the interop call.
You can use full machinery of the Result module to handle the result of the operation. For example, if you are not interested in the details of the error, you could convert it to a maybe using Result.toMaybe
function.
TaskPort.Error
is a variant data type that could be either:
- An
InteropError
with information about the failure of the interop mechanism itself. This is of errors indicating a failure of the interop mechanism itself. - A
JSError
with a representation of the error returned from or thrown by the JavaScript code.
Interop errors are generally not recoverable, but you can use them to allow the application to fail gracefully, or at least provide useful context for debugging. The latter is aided by the helper function TaskPort.interopErrorToString
.
TaskPort uses JSError
type to represent an error thrown by the JavaScript code. JavaScript itself is very lenient regarding its errors. Any value could be thrown, and, if the JS code is asynchronous, the Promise
can reject with any value. TaskPort always attempts to decode erroneous results returned from iterop calls using ErrorObject
variant followed by JSErrorRecord
structure, which contains standard fields for JavaScript Error
object, but if that isn't possible, it resorts to ErrorValue
variant followed by the JSON value as-is.
In most cases you would pass values of this type to errorToString
to create a useful diagnostic information, but you might also have a need to handle certain types of errors in a particular way. To make that easier, ErrorObject
variant lifts up the name of the JavaScript Error
object to aid pattern-match for error types. You may do something like this:
case error of
JSError (ErrorObject "VerySpecificError" _) -> -- handle a particular subtype of Error thrown by the JS code
_ -> -- respond to the error in a generic way, e.g show a diagnostic message
If you are looking to use TaskPort in an Elm package which could be used by many Elm applications, you have to be mindful of the fact that currently there is no standard mechanism to bundle JavaScript code with an Elm package. This effectively means that it's the responsibility of the developer, who would be using your package in their Elm application, to obtain and deploy correct JavaScript code required for your Elm package to work. As an Elm package developer, you have no direct control over that. Your Elm package documentation should explain how to obtain and deploy the correct version of the JavaScript code similarly to how this page does it (see Installation). However, it is also prudent to implement a safeguard that would detect incompatibility between the JavaScript code and the Elm package and provide a helpful diagnostic message.
Another problem to watch out for is the potential for clashing names with JavaScript interop functions registered by another Elm packages and the application itself. Interop function name clashes between two Elm packages would be particularly problematic for an application developer, as they would have no control over the function names.
TaskPort provides an out-of-the-box support for Elm package developers with function namespaces, which is a mechanism preventing interop function name clashes, as well as a safeguard ensuring Elm and JavaScript interop code is in sync. Elm package developers should register their interop JavaScript functions in their package's namespace and provide a version number for the namespace, and TaskPort will keep registered functions separate and eagerly check if JavaScript code has the same version as the Elm package expects it to be.
The following sections explain how to use TaskPort function namespaces. You can also check out lobanov/elm-localstorage package for an example of using this mechanism.
It is recommended to create a single install
function that would register all JavaScript interop functions required for the package to work. That function would call TaskPort.createNamespace()
function to create a new namespace, and register interop functions required by your Elm package in it. It is further recommended that the install
function takes the TaskPort object as a parameter rather than attempting to find it in the global namespace, because the latter won't be compatible with the Node environment.
Note that if you are using a bundler like WebPack to create a minified build of the JavaScript code for your Elm package for direct inclusion via a script tag, make sure you don't bundle TaskPort itself (in WebPack use externals). Otherwise, application developers would be unable to use another Elm package relying on TaskPort or use TaskPort in the application.
TaskPort expects function namespaces to be called after Elm package names, i.e. author-name/package-name
, but does not enforce any versioning schema. In most cases you would want to use your Elm package version number, but if you are wrapping a third-party JavaScript API and or interop code is not changing very often, it may be sufficient to use a separate JS API version to improve developer experience of your Elm package by simplifying the upgrades.
export function install(TaskPort) {
const ns = TaskPort.createNamespace("author/elm-package", "v1");
// TaskPort will refer to this function using it's fully qualified name author/elm-package/functionName
ns.register("functionName", function(args) => { /* function body */ });
}
Instead of using call
or callNoArgs
functions directly, Elm packages should use namespace-aware versions of those functions: callNS
and callNoArgsNS
respectively. Note that Elm packages are unlikely need to go beyond creating a Task
for the interop call (potentially chained with other tasks with Task.andThen
) and returning them to the application code.
import TaskPort exposing (Task, callNS, callNoArgsNS, inNamespace)
-- TaskPort.Task is an alias for Task TaskPort.Error a
getWidgetsCount : Task Int
getWidgetsCount = callNoArgsNS
{ function = "getWidgetsCount" |> inNamespace "author/elm-package" "v1"
, bodyDecoder = Json.Decode.int,
}
getWidgetNameByIndex : Int -> Task String
getWidgetNameByIndex = callNS
{ function = "getWidgetNameByIndex" |> inNamespace "author/elm-package" "v1"
, bodyDecoder = Json.Decode.string,
, argsEncoder = Json.Encode.int
} -- notice currying here and returning a function taking Int and producing a Task
-- more useful in this case would be to chain calls together
getWidgetNames : Task (List String)
getWidgetNames = getWidgetsCount
|> andThen
(\count ->
List.range 0 (count - 1)
|> List.map getWidgetNameByIndex
|> Task.sequence
)
Elm application developer using a package providing this hypothetical API would merely need to pass a Task
created by one of these functions into Task.attempt
without knowing the details of the underlying JavaScript API.
import WidgetModules -- Elm package exposing the above functions
import TaskPort exposing (Result)
-- TaskPort.Result is an alias for Result TaskPort.Error a
type Msg = GotWidgets (Result (List String))
Task.attempt GotWidgets WidgetModules.getWidgetNames -- produces a Cmd
For questions or general enquiries feel free to tag or DM @lobanov
on Elm Slack.
For issues or suggestions please raise an issue on GitHub.
PRs are welcome.