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

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.

{ 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_

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

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)

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. For nice input parsing, see for example gren-tui's stringToInput.

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

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

directorySubNamesRequest : String -> Interface (List String)

An Interface for getting names of the files contained on the surface in the directory at the given path.

directorySubPathsRequest : String -> Node.Interface (List String)
directorySubPathsRequest path =
    Node.fileInfoRequest path
        |> Node.interfaceFutureRequest
            (\subNames ->
                subNames |> List.map (\subName -> path ++ "/" ++ subName)
            )

Uses fs.readdir

directoryMake : String -> Interface future_

An Interface for creating a directory at a given path.

Uses fs.mkdir

{ content : String
, path : String
}
-> Interface future_

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

Uses fs.writeFile

fileUtf8Request : String -> Interface String

An Interface for reading the string 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

type alias HttpRequest future =
RecordWithoutConstructorFunction
{ url : String
, method : String
, headers :
List
{ name : String
, value : String
}
, body : HttpBody
, expect : HttpExpect future
}

An HTTP request for use in an Interface.

Use Node.httpAddHeaders to set custom headers as needed. Use Node.timeOnceAt to add a timeout of how long you are willing to wait before giving up.

= HttpBodyEmpty
| HttpBodyString
{ mimeType : String
, content : String
}
| HttpBodyUnsignedInt8s
{ mimeType : String
, content : List Int
}

Data send in your http request.

  • HttpBodyEmpty: Create an empty body for your request. This is useful for GET requests and POST requests where you are not sending any data.

  • HttpBodyString: Put a String in the body of your request. Defining Node.httpJsonBody looks like this:

    import Json.Encode
    
    
    httpJsonBody : Json.Encode.Value -> Node.HttpBody
    httpJsonBody value =
        Node.HttpBodyString "application/json" (Json.Encode.encode 0 value)

    The first argument is a MIME type of the body.

  • HttpBodyUnsignedInt8s is pretty much the same as HttpBodyString but for Bytes, see Node.httpBodyBytes

type HttpExpect future
= HttpExpectString (Result HttpError String -> future)
| HttpExpectBytes (Result HttpError Bytes -> future)
| HttpExpectWhatever (Result HttpError () -> future)

Describe what you expect to be returned in an http response body.

= HttpBadUrl
| HttpNetworkError
| HttpBadStatus
{ metadata : HttpMetadata
, body : Value
}

A Request can fail in a couple ways:

  • BadUrl means you did not provide a valid URL
  • NetworkError means 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 means you got a response back, but the status code indicates failure. Contains:
    • The response Metadata.
    • The raw response body as a Json.Decode.Value.
type alias HttpMetadata =
RecordWithoutConstructorFunction
{ url : String
, statusCode : Int
, statusText : String
, headers :
List
{ key : String
, value : String
}
}

Extra information about the response:

  • url of the server that actually responded (so you can detect redirects)
  • statusCode like 200 or 404
  • statusText describing what the statusCode means a little
  • headers like Content-Length and Expires

Note: It is possible for a response to have the same header multiple times. In that case, all the values end up in a single entry in the headers. The values are separated by commas, following the rules outlined here.

httpRequest : HttpRequest future -> Interface future

An Interface for handling an HttpRequest using the fetch API

{ url : String
, expect : HttpExpect future
}
-> HttpRequest future

Create a GET HttpRequest.

Use Node.httpAddHeaders to set custom headers as needed. Use Node.timeOnceAt to add a timeout of how long you are willing to wait before giving up.

{ url : String
, body : HttpBody
, expect : HttpExpect future
}
-> HttpRequest future

Create a POST HttpRequest.

Use Node.httpAddHeaders to set custom headers as needed. Use Node.timeOnceAt to add a timeout of how long you are willing to wait before giving up.

httpAddHeaders : List ( String, String ) -> HttpRequest future -> HttpRequest future

Add custom headers to the Node.HttpRequest.

request
    |> Node.httpAddHeaders
        [ ( "X-Custom-Header", "ProcessThisImmediately" )
        ]

Expect the response body to be a String.

Decoder future
(Result
(Result
{ actualBody : String
, jsonError : Error
}
future
)
)

Expect the response body to be JSON, decode it using the given decoder. The result will either be

  • Err with an HttpError if it didn't succeed
  • Ok if there was a result with either

Expect the response body to be Bytes. The result will either be

  • Err with an HttpError if it didn't succeed
  • Ok with the Bytes

Discard the response body.

Put a given JSON value in the body of your request. This will automatically add the Content-Type: application/json header.

httpBodyBytes : String -> Bytes -> HttpBody

Put given Bytes in the body of your request. The string argument should be a MIME type to be used in the Content-Type header

import Bytes exposing (Bytes)
import Bytes.Encode
import Node
import Time
import Zip
import Zip.Entry


exampleZipBody : Node.HttpBody
exampleZipBody =
    Node.httpBodyBytes "application/zip"
        (Zip.fromEntries
            [ Bytes.Encode.string "Hello, World!"
                |> Bytes.Encode.encode
                |> Zip.Entry.store
                    { path = "hello.txt"
                    , lastModified = ( Time.utc, Time.millisToPosix 0 )
                    , comment = Nothing
                    }
            ]
            |> Zip.toBytes
        )

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)
| TerminalSizeRequest
( { lines : Int
, columns : Int
}
-> future
)
| TerminalSizeChangeListen
( { lines : Int
, columns : Int
}
-> future
)
| HttpRequest (HttpRequest future)
| 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 String
| FileRemove String
| FileUtf8Write
{ content : String
, path : String
}
| FileUtf8Request
{ path : String
, on : String -> future
}
| FileChangeListen
{ path : String
, on : FileChange -> future
}
| FileInfoRequest
{ path : String
, on :
Maybe
{ byteCount : Int
, lastContentChangeTime : Posix
, kind : FileKind
}
-> future
}
| DirectorySubNamesRequest
{ path : String
, on : List String -> future
}

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

{ old : InterfaceSingle future
, updated : InterfaceSingle future
}

What InterfaceSingleEdits are needed to sync up

= EditNoneYet Never

Individual message to js to sync up with the latest interface type, describing changes to an existing interface with the same identity

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