Subverting Elm packaging for fun and profit

An Elm application I’m working on presents items with publication timestamps to users around the world. So far, I just printed a slightly mangled ISO-8601 UTC timestamp:

formatTimestamp : String -> String
formatTimestamp ts =
    ts
        |> String.left (String.length "2019-03-17T05:15")
        |> String.replace "T" " at "

That worked well enough for a start, but I felt it was time to solve this more properly and give my users localized timestamps.

This article is an account of my quest for a better, timezone aware variant of formatTimestamp. We’ll

The Internationalization API

The first thing we’ll need to do is to move from stringy timestamps to something more logical. Elm 0.19 talks Unix timestamps, with

type Posix = Posix Int

the number of milliseconds since the epoch. Instead of parsing the timestamps client-side using something like rtfeldman/elm-iso8601-date-strings, I opted to do the conversion in the PostgREST backend:

          , published_at
+         , EXTRACT(EPOCH FROM published_at)*1000 AS published_at_posix

So we’re now looking for a timezone and locale aware function formatTimestamp : Posix -> String. It turns out that browsers have a rather neat interface for this and related topics in the ECMAScript Internationalization API. Here’s a way to do what I want, given a Posix value:

function formatTimestamp(posix) {
	var dateTimeFormat = new Intl.DateTimeFormat(undefined, {
		year: 'numeric',
		month: 'numeric',
		day: 'numeric',
		hour: 'numeric',
		minute: 'numeric'
	});
	return dateTimeFormat.format(posix);
}

The undefined first argument to the constructor means to use the user locale. The unspecified timezone in the second options argument means to use the user’s local timezone (I think). Together, this formats Dec 11 15:00:00 UTC 2019 to a pleasing

11/12/2019, 16:00

in my en-GB locale in the Europe/Berlin timezone.

Doing the same in Elm 0.19 turns out to be tricky, however. The time library situation is a bit of a mess, with a very barebones core library elm/time that speaks Unix timestamps, and a variety of other date and time related packages, none of which appear to do the job.

And after all, we have a simple, mostly pure Javascript solution available, so why not just use that?

Calling native code

The documented approach to interacting with Javascript code from Elm is using ports, which would effectively turn a simple function call into an asynchronous RPC invocation with a ton of scaffolding. That didn’t appear to be an acceptable solution, so I thought I’d apply my recent insight into the Elm tooling to figure out how to use the Javascript API from within Elm. It seemed like a fun challenge, too!

A native module

By following along what vanwagonet/elm-intl and elm/time do for their Javascript interop, I came up with a native module Elm.Kernel.DateTime implemented in the file src/Elm/Kernel/DateTime.js

/*

*/

function _DateTime_localNumericDateTime() {
        return new Intl.DateTimeFormat(undefined, {
                year: 'numeric',
                month: 'numeric',
                day: 'numeric',
                hour: 'numeric',
                minute: 'numeric'
        });
}

var _DateTime_format = F2(function (dateTimeFormat, value) {
	return dateTimeFormat.format(value);
});

together with an Elm wrapper module DateTime implemented in src/DateTime.elm:

module DateTime exposing ( DateTimeFormat, localNumericDateTime, format )

import Elm.Kernel.DateTime
import Maybe exposing (Maybe)
import Time exposing (Posix)

type DateTimeFormat
    = DateTimeFormat

localNumericDateTime : DateTimeFormat
localNumericDateTime =
    Elm.Kernel.DateTime.localNumericDateTime ()

format : DateTimeFormat -> Posix -> String
format dateTimeFormat posix =
    Elm.Kernel.DateTime.format dateTimeFormat (Time.posixToMillis posix)

Walking through the Javascript file, it starts with an empty header comment listing Elm imports (we don’t have any). Then, we declare the function

Elm.Kernel.DateTime.localNumericDateTime

as _DateTime_localNumericDateTime. This is called from Elm with a unit argument. Finally we declare the two-argument function _DateTime_format in curried form using the helper F2.

Compiling the module

Setting things up naïvely with the following elm.json

{
    "type": "package",
    "name": "robx/elm-datetime",
    "summary": "Format local dates and times via JavaScript",
    "license": "BSD-3-Clause",
    "version": "1.0.0",
    "exposed-modules": [
        "DateTime"
    ],
    "elm-version": "0.19.0 <= v < 0.20.0",
    "dependencies": {
        "elm/core": "1.0.0 <= v < 2.0.0",
        "elm/time": "1.0.0 <= v < 2.0.0"
    },
    "test-dependencies": {}
}

we get an error in elm make:

$ elm make
-- BAD MODULE NAME -------------------------------------------- src/DateTime.elm

Your DateTime module is trying to import:

    Elm.Kernel.DateTime

But names like that are reserved for internal use. Switch to a name outside of
the Elm/Kernel/ namespace.

Bad robx, no cookie. Instead of moving the module out of the Elm namespace, let’s move our package in.

-    "name": "robx/elm-datetime",
+    "name": "elm/my-elm-datetime",

Now elm make is happy:

$ elm make
Success! Compiled 1 module.

A little demo app

To test our function and give us something concrete to try to make work, let’s build a small demo app that merely ticks a clock:

module Demo exposing (main)

import Browser
import DateTime
import Html
import Time


main : Program () Model Msg
main =
    Browser.document
        { init = init
        , view = view
        , update = update
        , subscriptions = subscriptions
        }


type alias Model =
    { time : Maybe Time.Posix
    }


type Msg
    = Tick Time.Posix

init : () -> ( Model, Cmd Msg )
init flags =
    ( { time = Nothing }
    , Cmd.none
    )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Tick posix ->
            ( { model | time = Just posix }, Cmd.none )

view : Model -> Browser.Document Msg
view model =
    let
        format = DateTime.format DateTime.localNumericDateTime
    in
    { title = "puzzle"
    , body =
        case model.time of
            Just posix ->
               [ Html.div [] [ Html.text <| String.fromInt <| Time.posixToMillis <| posix ]
               , Html.div [] [ Html.text <| format posix ]
               ]
            _ -> [ Html.div [] [ Html.text "..." ] ]
    }


subscriptions : Model -> Sub Msg
subscriptions model =
    Time.every 1000 Tick

It tracks the current time in the model by subscribing to Time.every, and displays that next to the formatted version.

But how to build this? Again, we can assemble a naïve elm.json:

{
    "type": "application",
    "source-directories": [
        "src"
    ],
    "elm-version": "0.19.0",
    "dependencies": {
        "direct": {
            "elm/browser": "1.0.1",
            "elm/core": "1.0.2",
            "elm/html": "1.0.0",
            "elm/time": "1.0.0",
            "elm/my-elm-datetime": "1.1.0"
        },
        "indirect": {
            "elm/json": "1.1.3",
            "elm/url": "1.0.0",
            "elm/virtual-dom": "1.0.2"
        }
    },
    "test-dependencies": {
        "direct": {},
        "indirect": {}
    }
}

Naturally, this can’t work because elm/my-elm-datetime doesn’t exist to the elm tool:

$ elm make src/Demo.elm
-- CORRUPT CACHE ---------------------------------------------------------------

I ran into an unknown package while exploring dependencies:

    elm/my-elm-datetime

[...]

How to get the module into the app?

Of course, we can’t publish to the Elm package database under a name we don’t own, which is where we get to the fun part: The same approach I took when building Elm apps on Guix can help here. In fact, if we were just building on Guix, we could pull in my pretend-elm package quite easily without any extra work. But I’m not regularly developing using Guix, so I wanted to find a shell-based solution to do the necessary environment tweaks to get Elm to play along. What we’ll do is:

  1. fetch dependency archives and unpack them in our own elm home directory. I went with ./elm-stuff/home, which means unpacking e.g. our elm-datetime package to ./elm-stuff/home/.elm/0.19.1/packages/elm/my-elm-datetime, and accordingly for all dependencies in elm.json.
  2. generate an elm registry, by using printf(1) to generate binary data
  3. call HOME=./elm-stuff/home HTTP_PROXY=. elm make to build.

I collected and wrapped up the various shell snippets involved in this as a bash script, available at robx/shelm. Let’s have a look at some of the core parts:

For regular Elm packages, step 1 may be achieved with jq, curl and tar:

jq -r '.dependencies.direct+.dependencies.indirect
	| to_entries[]
	| [.key, .value]
	| @tsv' | while read package version
do {
	unpack=$(mktemp -d)
	(cd $unpack && curl -L https://github.com/"$package"/archive/"$version".tar.gz | tar -xz)
	dest="$ELM_HOME"/0.19.1/packages/"$package"
	mkdir -p "$dest"
	mv "$unpack"/* "$dest"
	rmdir "$dest"
done

We collect all dependencies from elm.json, and then just need to do a little bit of careful work to move them to the right place.

It turns out that this code also works just fine for our unpublished package elm/my-elm-datetime. The only thing we need to do is redirect to the real GitHub project robx/elm-datetime. I chose to encode this information in an extra elm.json field:

"dependencies": {
    "direct": {
        ...
        "elm/my-elm-datetime": "1.0.0"
    },
    "locations": {
        "elm/my-elm-datetime": {
            "method": "github",
            "name": "robx/elm-datetime"
        }
    }
}

A more compact format like "elm/my-elm-datetime": "robx/elm-datetime" worked initially; the present form is a result of overengineering the packaging script. We might tweak our unpacking fragment above to support this as follows:

location=$(jq '.dependencies.locations."'"$package"'.name // "'"$package"'" < elm.json)
(cd $unpack && curl -L https://github.com/"$location"/archive/"$version".tar.gz | tar -xz)

For step 2, we list the packages and versions that we’ve just “installed” into the package cache, and write them to Elm’s binary package registry format. We can use printf(1) for this. E.g., integers are encoded as 8 big-endian bytes:

# Haskell binary encoding of integers as 8 bytes big-endian
encode_int64() {
        hex=$(printf "%016x" "$1")
        printf "\\x${hex:0:2}\\x${hex:2:2}\\x${hex:4:2}\\x${hex:6:2}"
        printf "\\x${hex:8:2}\\x${hex:10:2}\\x${hex:12:2}\\x${hex:14:2}"
}

See the guix discussion for more details.

After fixing various bugs, this works!

$ (cd elm-stuff/home/.elm/0.19.1/packages && ls -d */*/*)
elm/browser/1.0.1		elm/my-elm-datetime/1.0.0
elm/core/1.0.2			elm/time/1.0.0
elm/html/1.0.0			elm/url/1.0.0
elm/json/1.1.3			elm/virtual-dom/1.0.2
$ hexdump -C elm-stuff/home/.elm/0.19.1/packages/registry.dat
00000000  00 00 00 00 00 00 00 08  00 00 00 00 00 00 00 08  |................|
00000010  03 65 6c 6d 07 62 72 6f  77 73 65 72 01 00 01 00  |.elm.browser....|
00000020  00 00 00 00 00 00 00 03  65 6c 6d 04 63 6f 72 65  |........elm.core|
00000030  01 00 02 00 00 00 00 00  00 00 00 03 65 6c 6d 04  |............elm.|
00000040  68 74 6d 6c 01 00 00 00  00 00 00 00 00 00 00 03  |html............|
00000050  65 6c 6d 04 6a 73 6f 6e  01 01 03 00 00 00 00 00  |elm.json........|
00000060  00 00 00 03 65 6c 6d 0f  6d 79 2d 65 6c 6d 2d 64  |....elm.my-elm-d|
00000070  61 74 65 74 69 6d 65 01  01 00 00 00 00 00 00 00  |atetime.........|
00000080  00 00 03 65 6c 6d 04 74  69 6d 65 01 00 00 00 00  |...elm.time.....|
00000090  00 00 00 00 00 00 03 65  6c 6d 03 75 72 6c 01 00  |.......elm.url..|
000000a0  00 00 00 00 00 00 00 00  00 03 65 6c 6d 0b 76 69  |..........elm.vi|
000000b0  72 74 75 61 6c 2d 64 6f  6d 01 00 02 00 00 00 00  |rtual-dom.......|
000000c0  00 00 00 00                                       |....|
000000c4
$ HOME=$(pwd)/elm-stuff/home HTTP_PROXY=. elm make --output=demo.js src/Demo.elm
Dependencies loaded from local cache.
Dependencies ready!
Success! Compiled 1 module.

Here’s our app in action:

elm-datetime demo, v1

Making things right

We might stop here, but there’s still an issue with our native DateTime module. While

format : DateTimeFormat -> Posix -> String

itself is a pure function,

localNumericDateTime : DateTimeFormat

is lying when it pretends to be: Calling Intl.DateTime(locale, options) without fully resolved options as we do depends on the environment, and might change between calls, e.g. if the timezone changes.

To model this correctly, we should change it to have a Task type:

localNumericDateTime : Task x DateTimeFormat

Modelling things on Time.here, we change the Javascript module as follows:

--- a/src/Elm/Kernel/DateTime.js
+++ b/src/Elm/Kernel/DateTime.js
@@ -1,14 +1,21 @@
 /*

+import Elm.Kernel.Scheduler exposing (binding, succeed)
+
 */

 function _DateTime_localNumericDateTime() {
-       return new Intl.DateTimeFormat(undefined, {
-               year: 'numeric',
-               month: 'numeric',
-               day: 'numeric',
-               hour: 'numeric',
-               minute: 'numeric'
+       return __Scheduler_binding(function(callback)
+       {
+               callback(__Scheduler_succeed(
+                       new Intl.DateTimeFormat(undefined, {
+                               year: 'numeric',
+                               month: 'numeric',
+                               day: 'numeric',
+                               hour: 'numeric',
+                               minute: 'numeric'
+                       })
+               ));
        });
 }

Elm.Kernel.Scheduler is the native module behind Task. The Elm-side diff is trivial:

--- a/src/DateTime.elm
+++ b/src/DateTime.elm
@@ -22,6 +22,7 @@ This module binds to

 import Elm.Kernel.DateTime
 import Maybe exposing (Maybe)
+import Task exposing (Task)
 import Time exposing (Posix)


@@ -33,7 +34,7 @@ type DateTimeFormat

 {-| Create a DateTimeFormat using user locale and timezone.
 -}
-localNumericDateTime : DateTimeFormat
+localNumericDateTime : Task x DateTimeFormat
 localNumericDateTime =
     Elm.Kernel.DateTime.localNumericDateTime ()

And let’s bump the package version:

--- a/elm.json
+++ b/elm.json
@@ -3,7 +3,7 @@
     "name": "elm/my-elm-datetime",
     "summary": "Format local dates and times via JavaScript",
     "license": "BSD-3-Clause",
-    "version": "1.0.0",
+    "version": "1.1.0",
     "exposed-modules": [
         "DateTime"
     ],

Getting this into our demo involves performing this task to obtain our formatter at application startup, and keeping it around in the model:

+++ b/demo/elm.json
@@ -10,7 +10,7 @@
             "elm/core": "1.0.2",
             "elm/html": "1.0.0",
             "elm/time": "1.0.0",
-            "elm/my-elm-datetime": "1.0.0"
+            "elm/my-elm-datetime": "1.1.0"
         },
         "indirect": {
             "elm/json": "1.1.3",
diff --git a/demo/src/Demo.elm b/demo/src/Demo.elm
index fd85793..776b913 100644
--- a/demo/src/Demo.elm
+++ b/demo/src/Demo.elm
@@ -3,6 +3,7 @@ module Demo exposing (main)
 import Browser
 import DateTime
 import Html
+import Task
 import Time


@@ -18,17 +19,19 @@ main =

 type alias Model =
     { time : Maybe Time.Posix
+    , format : Maybe DateTime.DateTimeFormat
     }


 type Msg
     = Tick Time.Posix
+    | NewFormat DateTime.DateTimeFormat


 init : () -> ( Model, Cmd Msg )
 init flags =
-    ( { time = Nothing }
-    , Cmd.none
+    ( { time = Nothing, format = Nothing }
+    , Task.perform NewFormat DateTime.localNumericDateTime
     )


@@ -38,17 +41,20 @@ update msg model =
         Tick posix ->
             ( { model | time = Just posix }, Cmd.none )

+        NewFormat fmt ->
+            ( { model | format = Just fmt }, Cmd.none )
+

 view : Model -> Browser.Document Msg
 view model =
-    let
-        format =
-            DateTime.format DateTime.localNumericDateTime
-    in
     { title = "puzzle"
     , body =
-        case model.time of
-            Just posix ->
+        case ( model.time, model.format ) of
+            ( Just posix, Just fmt ) ->
+                let
+                    format =
+                        DateTime.format fmt
+                in
                 [ Html.div [] [ Html.text <| String.fromInt <| Time.posixToMillis <| posix ]
                 , Html.div [] [ Html.text <| format posix ]
                 ]

Done:

$ make
shelm fetch
pruning stale dependency elm/my-elm-datetime-1.0.0
fetching https://github.com/robx/elm-datetime/archive/1.1.0.tar.gz
generating /s/elm-datetime/demo/elm-stuff/home/.elm/0.19.1/packages/registry.dat
shelm make --output=demo.js src/Demo.elm
Dependencies loaded from local cache.
Dependencies ready!
Success! Compiled 1 module.

Still works! This time around, I used shelm to build the application via make.

elm-datetime demo, v2

You can find the full code for the package and demo app at github.com/robx/elm-datetime, at releases 1.0.0 and 1.1.0. The shelm package manager is available at github.com/robx/shelm.