lue-bird/elm-state-interface-experimental/8.0.1

NOTE: the latest version is 13.0.0

Node

A state-interface program that can run in node.js which is commonly used to create command line tools, scripts and servers.

I recommend starting with the minimal setup, browsing the examples and picking parts you want to experiment with.

{ initialState : state
, interface : state -> Interface state
, ports :
{ toJs : Value -> Cmd (ProgramEvent state)
, fromJs : (Value -> ProgramEvent state) -> Sub (ProgramEvent state)
}
}
-> Program state

Create a Program:

  • The state is everything the program knows (what The Elm Architecture calls model). And it always starts with a given initialState

  • The Interface is the face to the outside world and can be created using the helpers in time, HTTP etc. The given interface function constructs these interfaces based on the current state

  • Connections to and from js

    port toJs : Json.Encode.Value -> Cmd event_
    
    
    port fromJs : (Json.Encode.Value -> event) -> Sub event
type alias Program state =
Program () (ProgramState state) (ProgramEvent state)

A Platform.Program that elm can run, produced by Node.program

You can also embed a state-interface program as part of an existing app that uses The Elm Architecture.

interface

type alias Interface future =
Dict String (InterfaceSingle future)

Incoming and outgoing effects. To create one, use the helpers in time, HTTP etc.

To combine multiple, use Node.interfaceBatch and Node.interfaceNone. To change the value that comes back in the future, use Node.interfaceFutureMap

interfaceBatch : List (Interface future) -> Interface future

Combine multiple Interfaces into one

interfaceNone : Interface future_

Doing nothing as an Interface. These two examples are equivalent:

Node.interfaceBatch [ a, Node.interfaceNone, b ]

and

Node.interfaceBatch
    (List.filterMap identity
        [ a |> Just, Nothing, b |> Just ]
    )
(future -> mappedFuture)
-> Interface future
-> Interface mappedFuture

Take what the Interface can come back with and return a different future value.

In practice, this is sometimes used like a kind of event-config pattern:

Node.timePosixRequest
    |> Node.interfaceFutureMap (\timeNow -> TimeReceived timeNow)

sometimes as a way to deal with all events (like update in The Elm Architecture)

...
    |> Node.interfaceFutureMap
        (\event ->
            case event of
                CursorMovedTo newCursorPoint ->
                    { state | cursorPoint = newCursorPoint }


                MinusEntered ->
                    { state | counter = state.counter - 1 }


                PlusEntered ->
                    { state | counter = state.counter + 1 }
        )

In all these examples, you end up converting the narrow future representation of part of the interface to a broader representation for the parent interface

time

See elm/time

timePosixRequest : Interface Posix

An Interface for getting the current POSIX time.

Replacement for elm/time's Time.now.

timeZoneRequest : Interface Zone

An Interface for getting a Time.Zone based on the current UTC offset.

Replacement for elm/time's Time.here.

timeZoneNameRequest : Interface String

Intended for package authors. An Interface for using Intl.DateTimeFormat().resolvedOptions().timeZone to get names like Europe/Moscow or America/Havana. From there you can look it up in any IANA data you loaded yourself.

Replacement for elm/time's Time.getZoneName.

timePeriodicallyListen : Duration -> Interface Posix

An Interface for getting the current time every time a given Duration has passed.

timeOnceAt : Posix -> Interface Posix

An Interface for getting a reminder once a given point in time has been reached.

This lets you for example wait until it's 15 minutes before the event, timeout a request or schedule a certain action to a specific time.

import Node


type RequestState result
    = NotAsked
    | BeforeTimeout { start : Time.Posix }
    | TimedOut
    | GotResult result


{ initialState = NotAsked
, interface =
    \state ->
        [ case state of
            NotAsked ->
                [ Node.timePosixRequest
                    |> Node.interfaceFutureMap BeforeTimeout
                , ..request.. |> Node.futureMap GotResult
                ]


            BeforeTimeout requestTime ->
                -- timeout after 10 seconds
                Node.timeOnceAt (Duration.addTo requestTime (Duration.seconds 10))
                    |> Node.interfaceFutureMap (\_ -> TimedOut)


            TimedOut ->
                ...


            GotResult result ->
                ...
        ]
}

You can abstract this in various ways like adding a

withTimeout :
    ..Request result..
    -> Node.Interface (RequestState result)

where the result can be put into the "main state" and therefore cased on.

process and terminal

workingDirectoryPathRequest : Interface String

An Interface for getting the current working directory.

Uses process.cwd

launchArgumentsRequest : Interface (List String)

An Interface for getting the command-line arguments passed when the Node.js process was launched.

For example, running

node process-args.js one two=three four

Node.launchArgumentsRequest would give back

[ "/usr/local/bin/node"
, "/Users/mjr/work/node/process-args.js"
, "one"
, "two=three"
, "four"
]

The first is always the absolute path to the executable that started the Node.js process, the second is always the path to the executed js file.

Uses process.argv

processTitleSet : String -> Interface future_

Replace the title of the running process. This will usually display in the title bar of your terminal emulator or in activity monitors.

Uses process.title = ...

exit : Int -> Interface future_

Very gracefully stop the process, completing all other current interface operations. Do not keep listens in the interface, otherwise the process will not die.

Keep in mind that every process ends automatically once no interfaces that expect future data are left which means you only need Node.exit in case you want to exit with a non-zero error code.

Uses process.exitCode = ... instead of process.exit to still make interfaces like writing an error come through before exiting.

standardOutWrite : String -> Interface future_

An Interface for writing text to standard out, like

hit enter to apply changes or escape to cancel:

If you want to clear the screen, apply color or font effects, employ ansi codes instead (either use a library or go click on the declarations and copy source)

If you find that the last line gets eaten, append a \n

Uses process.stdout.write

standardErrWrite : String -> Interface future_

An Interface for writing text to standard err.

input file does not exits. Is the path correct?

Uses process.stderr.write

standardInListen : Interface String

Read text from standard in. If you want to intercept input before enter is sent, use Node.standardInRawListen

Uses process.stdin.addListener("data", ...)

standardInRawListen : Interface String

Read text from standard in character by character (except modifiers). For nice input parsing, see for example gren-tui's stringToInput.

As long as Node.standardInRawListen is part of the interface, the terminal won't echo input characters. However, ctrl+c will still exit the process (SIGINT).

Uses process.stdin.addListener("data", ...) in combination with process.stdin.setRawMode(true)

Interface
{ lines : Int
, columns : Int
}

An Interface for getting the inner terminal window width and height in characters,

Interface
{ lines : Int
, columns : Int
}

An Interface for detecting changes to the inner window width and height

file system

= KindDirectory
| KindFile

Does the path point to a directory or "content"-file

String
-> Interface
(Maybe
{ byteCount : Int
, lastContentChangeTime : Posix
, kind : FileKind
}
)

An Interface for getting file metadata at a given path or Nothing if no file exists there.

fileExistsRequest : String -> Node.Interface Bool
fileExistsRequest path =
    Node.fileInfoRequest path
        |> Node.interfaceFutureRequest (\info -> info /= Nothing)

Uses fs.stat

String
-> Interface
(Result
{ code : String
, message : String
}
(List String)
)

An Interface for getting relative paths of the files contained in the directory at the given path (and any contained directory).

directoryFullSubPathsRequest : String -> Node.Interface (List String)
directoryFullSubPathsRequest directoryPath =
    Node.directorySubPathsRequest directoryPath
        |> Node.interfaceFutureRequest
            (\subNamesOrError ->
                subNamesOrError
                    |> Result.withDefault []
                    |> List.map (\subName -> directoryPath ++ "/" ++ subName)
            )

Uses fs.readdir({ recursive: true })

String
-> Interface
(Result
{ code : String
, message : String
}
()
)

An Interface for creating a directory including its parent directories at a given path unless it already exists.

Uses fs.mkdir({ recursive : true })

{ path : String
, content : Bytes
}
-> Interface
(Result
{ code : String
, message : String
}
()
)

An Interface for creating or overwriting a file at a given path with given Bytes content.

Uses fs.writeFile

String
-> Interface
(Result
{ code : String
, message : String
}
Bytes
)

An Interface for reading the Bytes content of the file at a given path.

Uses fs.readFile

fileRemove : String -> Interface future_

An Interface for unlinking a file at a given path.

Uses fs.unlink

fileChangeListen : String -> Interface FileChange

An Interface for detecting changes to the file at a given path or any of its deepest sub-files.

Uses fs.watch({ recursive: true })

= FileAddedOrChanged String
| FileRemoved String

Did the file at a path get changed/created or moved away/removed?

HTTP

client

{ url : String
, method : String
, headers :
List
{ name : String
, value : String
}
, body : Maybe Bytes
}
-> Interface (Result HttpError Bytes)

An Interface for sending an HTTP request to a given url

exampleHttpPost : Web.Interface (Result Web.HttpError Bytes)
exampleHttpPost =
    Web.httpRequestSend
        { url = ...
        , method = "POST"
        , headers = [ { name = "Content-Type", value = "application/json" } ]
        , body =
            96
                |> Json.Encode.int
                |> Json.Encode.encode 0
                |> Bytes.Encode.encode
                |> Just
        }


exampleHttpGet : Web.HttpRequest
exampleHttpGet =
    Web.httpRequestSend
        { url = ...
        , method = "GET"
        , headers = [ { name = "X-Custom-Header", value = "ProcessThisImmediately" } ]
        , body = Nothing -- empty bytes with HEAD or GET can lead to an HttpError
        }
        |> Web.interfaceFutureMap
            (\responseOrError ->
                case responseOrError of
                    Ok bytes ->
                        case bytes |> Bytes.Decode.decode (Bytes.Decode.string (bytes |> Bytes.width)) of
                            Just jsonString ->
                                case jsonString |> Json.Decode.decodeString ... of
                                    Err jsonError ->
                                        Err (jsonError |> Json.Decode.errorToString)


                                    Ok parsed ->
                                        Ok parsed


                            Nothing ->
                                Err "failed to decode bytes to a string"


                    Err _ ->
                        Err "HTTP response error"
            )

Usually, if you have a request body, the headers include Content-Type with a MIME type

The response will either contain Bytes. or an HttpError

If you for some reason need a timeout, add a Web.timeOnceAt and remove the Web.httpRequestSend once it has passed.

Uses the fetch API

= HttpBadUrl
| HttpNetworkError String
| HttpBadStatus
{ statusCode : Int
, statusText : String
, headers :
List
{ name : String
, value : String
}
, body : Bytes
}

A Request can fail in a couple ways:

  • BadUrl: you did not provide a valid URL
  • NetworkError: the user turned off their wifi, went in a cave, etc. or the server CORS is misconfigured. Note: A 404 for example does not constitute a network error
  • BadStatus: you got a response back, but the status code indicates failure. Contains:
    • the statusCode like 404 and statusText describing what the statusCode means a little
    • headers like Content-Length and Expires
    • the body as Bytes

server

{ portNumber : Int
, response :
Maybe
{ statusCode : Int
, headers :
List
{ name : String
, value : String
}
, data : Bytes
}
}
-> Interface HttpServerEvent

An Interface for detecting when data has been sent from the server at a given address or the connection has changed, see Web.SocketEvent.

You also have the ability to send data to the server whenever necessary.

Node.httpRequestListenAndMaybeRespond
    { portNumber = 1248
    , response =
        case state.pendingRequest of
            Nothing ->
                Nothing


            Just pendingRequest ->
                case pendingRequest.data |> Bytes.Decode.decode Bytes.Decode.signedInt32 Bytes.BE of
                    Nothing ->
                        Just
                            { statusCode = 404
                            , headers = []
                            , data = Bytes.Encode.sequence [] |> Bytes.Encode.encode
                            }


                    Just requestNumber ->
                        Just
                            { statusCode = 200
                            , headers = []
                            , data =
                                (requestNumber * 2)
                                    |> Bytes.Encode.signedInt32 Bytes.BE
                                    |> Bytes.Encode.encode
                            }
    }
    |> Node.interfaceFutureMap
        (\socketEvent ->
            case socketEvent of
                Node.HttpRequestReceived pendingRequest ->
                    { state | pendingRequest = Just pendingRequest }


                Node.HttpResponseSent ->
                    { state | pendingRequest = Nothing }


                Node.HttpServerOpened ->
                    state


                Node.HttpServerClosed ->
                    state
        )

"Removing" Node.httpRequestListenAndMaybeRespond from your interface will close the server at that port.

It's common to send encoded data with Json.Encode.encode 0 (usually with the header { name = "Content-Type", value = "application/json" }).

= HttpServerOpened
| HttpRequestReceived
{ method : String
, headers :
List
{ name : String
, value : String
}
, data : Bytes
}
| HttpResponseSent
| HttpServerFailed
{ code : String
, message : String
}

How the connection has changed or what request has been sent.

random

Not familiar with random "generators"? elm/random explains it nicely!

randomUnsignedInt32sRequest : Int -> Interface (List Int)

An Interface for generating a given count of cryptographically sound unsigned 32-bit Ints. You can use these in all kinds of packages that allow creating an initial seed from ints like NoRedInk/elm-random-pcg-extended

Note: uses crypto.getRandomValues

embed

If you just want to replace a part of your elm app with this architecture. Make sure to wire in all 3:

type alias ProgramConfig state =
RecordWithoutConstructorFunction
{ initialState : state
, interface : state -> Interface state
, ports :
{ toJs : Value -> Cmd (ProgramEvent state)
, fromJs : (Value -> ProgramEvent state) -> Sub (ProgramEvent state)
}
}

What's needed to create a state-interface program

ProgramConfig state
-> ( ProgramState state, Cmd (ProgramEvent state) )

The "init" part for an embedded program

ProgramConfig state
-> ProgramEvent state
-> ProgramState state
-> ( ProgramState state, Cmd (ProgramEvent state) )

The "update" part for an embedded program

ProgramConfig state
-> ProgramState state
-> Sub (ProgramEvent state)

The "subscriptions" part for an embedded program

Under the hood, Node.program is then defined as just

program config =
    Platform.worker
        { init = \() -> Node.programInit yourAppConfig
        , update = Node.programUpdate yourAppConfig
        , subscriptions = Node.programSubscriptions yourAppConfig
        }

internals, safe to ignore for users

Exposed so can for example simulate it more easily in tests, add a debugger etc.

type ProgramState appState
= State
{ interface : Interface appState
, appState : appState
}

The "model" in a Node.program

type ProgramEvent appState
= JsEventFailedToDecode Error
| JsEventEnabledConstructionOfNewAppState appState

The "msg" in a Node.program

type InterfaceSingle future
= WorkingDirectoryPathRequest (String -> future)
| LaunchArgumentsRequest (List String -> future)
| ProcessTitleSet String
| StandardOutWrite String
| StandardErrWrite String
| StandardInListen (String -> future)
| StandardInRawListen (String -> future)
| TerminalSizeRequest
( { lines : Int
, columns : Int
}
-> future
)
| TerminalSizeChangeListen
( { lines : Int
, columns : Int
}
-> future
)
| HttpRequestSend
{ url : String
, method : String
, headers :
List
{ name : String
, value : String
}
, bodyAsciiString : Maybe String
, on : Result HttpError Bytes -> future
}
| HttpRequestListen
{ portNumber : Int
, on : HttpServerEvent -> future
}
| HttpResponseSend
{ portNumber : Int
, statusCode : Int
, headers :
List
{ name : String
, value : String
}
, dataAsciiString : String
}
| TimePosixRequest (Posix -> future)
| TimezoneOffsetRequest (Int -> future)
| TimeOnce
{ pointInTime : Posix
, on : Posix -> future
}
| TimePeriodicallyListen
{ intervalDurationMilliSeconds : Int
, on : Posix -> future
}
| TimezoneNameRequest (String -> future)
| RandomUnsignedInt32sRequest
{ count : Int
, on : List Int -> future
}
| Exit Int
| DirectoryMake
{ path : String
, on :
Result
{ code : String
, message : String
}
()
-> future
}
| FileRemove String
| FileWrite
{ contentAsciiString : String
, path : String
, on :
Result
{ code : String
, message : String
}
()
-> future
}
| FileRequest
{ path : String
, on :
Result
{ code : String
, message : String
}
Bytes
-> future
}
| FileChangeListen
{ path : String
, on : FileChange -> future
}
| FileInfoRequest
{ path : String
, on :
Maybe
{ byteCount : Int
, lastContentChangeTime : Posix
, kind : FileKind
}
-> future
}
| DirectorySubPathsRequest
{ path : String
, on :
Result
{ code : String
, message : String
}
(List String)
-> future
}

A "non-batched" Interface. To create one, use the helpers in time, HTTP etc.

If you need more things like json encoders/decoders, open an issue