@andrewmacmurray/elm-concurrent-task
v1.1.1
Published
Run a tree of Tasks concurrently, call JS functions as Tasks (Task Ports)
Downloads
431
Readme
Elm Concurrent Task
What?
- An alternative
Task
api - run a tree of tasks concurrently. - A hack free implementation of
Task Ports
- call JavaScript functions as tasks. - Run anywhere - works in the Browser or NodeJS.
This package is heavily inspired by elm-pages' BackendTask
and is intended to be a standalone implementation that can be dropped into any Elm app - big kudos to Dillon for the idea.
See the examples for more things you can do!
View the elm-package docs here.
Why?
Structured Concurrency
Task.map2
, Task.map3
+ In elm/core
run each subtask in sequence.
Whilst it's possible to run these subtasks concurrently as separate Cmd
s, it can be a lot of wiring and boilerplate, including:
- Batching task commands together.
- Handling each task's success case.
- Handling each task's error case.
- Checking if all other tasks are completed every time an individual task finishes.
Elm Task Parallel handles this nicely but only at the top level (sub tasks cannot be parallelised).
And what if you want to speed up a complex nested task like:
Task.map2 combine
(task1
|> Task.andThen task2
|> Task.andThen
(\res ->
Task.map2 combine
(task3 res)
task4
)
)
(Task.map2 combine
task5
task6
)
This is the elm equivalent of "callback hell".
This library helps you do this with a lot less boilerplate.
A Sequenceable JavaScript FFI
Sometimes you want to call JavaScript from elm in order. For example sequencing updates to localstorage:
NOTE: See a full working localstorage example here.
import ConcurrentTask exposing (ConcurrentTask)
import Json.Decode as Decode exposing (Decoder)
import Json.Encode as Encode
-- Preferences
type alias Preferences =
{ contrast : Int
, brightness : Int
}
setContrast : Int -> ConcurrentTask Error ()
setContrast contrast =
getItem "preferences" decodePreferences
|> ConcurrentTask.map (\preferences -> { preferences | contrast = contrast })
|> ConcurrentTask.andThen (encodePreferences >> setItem "preferences")
encodePreferences : Preferences -> Encode.Value
encodePreferences p =
Encode.object
[ ( "contrast", Encode.int p.contrast )
, ( "brightness", Encode.int p.brightness )
]
decodePreferences : Decoder Preferences
decodePreferences =
Decode.map2 Preferences
(Decode.field "contrast" Decode.int)
(Decode.field "brightness" Decode.int)
-- Localstorage
type Error
= NoValue
| ReadBlocked
| DecodeError Decode.Error
| WriteError String
getItem : String -> Decoder a -> ConcurrentTask Error a
getItem key decoder =
ConcurrentTask.define
{ function = "localstorage:getItem"
, expect = ConcurrentTask.expectString
, errors = ConcurrentTask.expectErrors decodeReadErrors
, args = Encode.object [ ( "key", Encode.string key ) ]
}
|> ConcurrentTask.map (Decode.decodeString decoder >> Result.mapError DecodeError)
|> ConcurrentTask.andThen ConcurrentTask.fromResult
setItem : String -> Encode.Value -> ConcurrentTask Error ()
setItem key value =
ConcurrentTask.define
{ function = "localstorage:setItem"
, expect = ConcurrentTask.expectWhatever
, errors = ConcurrentTask.expectThrows WriteError
, args =
Encode.object
[ ( "key", Encode.string key )
, ( "value", Encode.string (Encode.encode 0 value) )
]
}
decodeReadErrors : Decoder Error
decodeReadErrors =
Decode.string
|> Decode.andThen
(\reason ->
case reason of
"NO_VALUE" ->
Decode.succeed NoValue
"READ_BLOCKED" ->
Decode.succeed ReadBlocked
_ ->
Decode.fail ("Unrecognized Read Error: " ++ reason)
)
Hack Free you say?
Other implementations of Task Ports
rely on either:
ServiceWorkers
- intercept certain http requests and call custom JavaScript from the service worker.- Monkeypatching
XMLHttpRequest
- Modify methods on the globalXMLHttpRequest
to intercept http requests and call custom JavaScript.
Both methods are not ideal (modifying global methods is pretty dodgy), and neither are portable to other environments like node (ServiceWorker
and XMLHttpRequest
are only native in the browser and require pollyfills).
It's just ports!
elm-concurrent-task
uses plain ports and a bit of wiring to create a nice Task api.
This makes it dependency free - so more portable (🤓) and less likely to break (😄).
Caveats
Because elm-concurrent-task
uses a different type to elm/core
Task
it's unfortunately not compatible with elm/core
Task
s.
However, there are a number of tasks built into the JavaScript runner and supporting modules that should cover a large amount of the existing functionality of elm/core
Task
s.
Check out the built-ins for more details:
How?
Getting Started
1. Install Elm and JavaScript/TypeScript packages
Install the elm package with
elm install andrewMacmurray/elm-concurrent-task
Install the JavaScript/TypeScript runner with
npm install @andrewmacmurray/elm-concurrent-task
2. Add to your Elm app
Your Elm program needs:
A single
ConcurrentTask.Pool
in yourModel
to keep track of each task attempt:type alias Model = { tasks : ConcurrentTask.Pool Msg Error Success }
2
Msg
s to handle task updates:type Msg = OnProgress ( ConcurrentTask.Pool Msg Error Success, Cmd Msg ) -- updates task progress | OnComplete (ConcurrentTask.Response Error Success) -- called when a task completes
2 ports with the following signatures:
port send : Decode.Value -> Cmd msg port receive : (Decode.Value -> msg) -> Sub msg
Here's a simple complete program that fetches 3 resources concurrently:
port module Example exposing (main)
import ConcurrentTask exposing (ConcurrentTask)
import ConcurrentTask.Http as Http
import Json.Decode as Decode
type alias Model =
{ tasks : ConcurrentTask.Pool Msg Error Titles
}
type Msg
= OnProgress ( ConcurrentTask.Pool Msg Error Titles, Cmd Msg )
| OnComplete (ConcurrentTask.Response Error Titles)
type alias Error =
Http.Error
-- Get All Titles Task
type alias Titles =
{ todo : String
, post : String
, album : String
}
getAllTitles : ConcurrentTask Error Titles
getAllTitles =
ConcurrentTask.succeed Titles
|> ConcurrentTask.andMap (getTitle "/todos/1")
|> ConcurrentTask.andMap (getTitle "/posts/1")
|> ConcurrentTask.andMap (getTitle "/albums/1")
getTitle : String -> ConcurrentTask Error String
getTitle path =
Http.get
{ url = "https://jsonplaceholder.typicode.com" ++ path
, headers = []
, expect = Http.expectJson (Decode.field "title" Decode.string)
, timeout = Nothing
}
-- Program
init : ( Model, Cmd Msg )
init =
let
( tasks, cmd ) =
ConcurrentTask.attempt
{ send = send
, pool = ConcurrentTask.pool
, onComplete = OnComplete
}
getAllTitles
in
( { tasks = tasks }, cmd )
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
OnComplete response ->
let
_ =
Debug.log "response" response
in
( model, Cmd.none )
OnProgress ( tasks, cmd ) ->
( { model | tasks = tasks }, cmd )
subscriptions : Model -> Sub Msg
subscriptions model =
ConcurrentTask.onProgress
{ send = send
, receive = receive
, onProgress = OnProgress
}
model.tasks
port send : Decode.Value -> Cmd msg
port receive : (Decode.Value -> msg) -> Sub msg
main : Program {} Model Msg
main =
Platform.worker
{ init = always init
, update = update
, subscriptions = subscriptions
}
3. Register the runner in your JavaScript/TypeScript app
Connect the runner to your Elm app (the runner supports both import
and require
syntax):
import * as ConcurrentTask from "@andrewmacmurray/elm-concurrent-task";
const app = Elm.Main.init({});
ConcurrentTask.register({
tasks: {},
ports: {
send: app.ports.send,
receive: app.ports.receive,
},
});
The value passed to tasks
is an object of task names to functions (the functions can return plain synchronous values or promises)
e.g. tasks for reading and writing to localStorage:
const tasks = {
"localstorage:getItem": (args) => localStorage.getItem(args.key),
"localstorage:setItem": (args) => localStorage.setItem(args.key, args.item),
};
NOTE: for a more complete localStorage
integration with proper error handling check out the localstorage example.
Re-using ports
Each send
and receive
port pair only support one ConcurrentTask.Pool
subscribed at a time.
Weird things can happen if you have two or more ConcurrentTask.Pool
s using the same ports at the same time.
Generally this should not be needed, but if you have a use-case, please leave an issue.
Develop Locally
Install Dependencies:
npm install
Run the tests with:
npm test
To preview any changes, try some of the examples in the examples folder.
View the docs locally with:
npm run docs
Publishing a new release
- Run
elm bump
to bump the elm version. - Update the
version
in package.json to match the new elm version. - Ensure all links in the README / docs are pointing to the correct new version.
- Commit and push the updates.
- Wait for the Publish Github Action to complete.