BackendTask.Custom

In a vanilla Elm application, ports let you either send or receive JSON data between your Elm application and the JavaScript context in the user's browser at runtime.

With BackendTask.Custom, you send and receive JSON to JavaScript running in NodeJS. As with any BackendTask, Custom BackendTask's are either run at build-time (for pre-rendered routes) or at request-time (for server-rendered routes). See BackendTask for more about the lifecycle of BackendTask's.

This means that you can call shell scripts, run NPM packages that are installed, or anything else you could do with NodeJS to perform custom side-effects, get some data, or both.

A BackendTask.Custom will call an async JavaScript function with the given name from the definition in a file called custom-backend-task.js in your project's root directory. The function receives the input JSON value, and the Decoder is used to decode the return value of the async function.

run :
String
-> Value
-> Decoder b
-> BackendTask
{ fatal : FatalError
, recoverable : Error
}
b

Here is the Elm code and corresponding JavaScript definition for getting an environment variable (or an FatalError BackendTask.Custom.Error if it isn't found). In this example, we're using BackendTask.allowFatal to let the framework treat that as an unexpected exception, but we could also handle the possible failures of the FatalError (see FatalError).

import BackendTask exposing (BackendTask)
import BackendTask.Custom
import Json.Encode
import OptimizedDecoder as Decode

data : BackendTask FatalError String
data =
    BackendTask.Custom.run "environmentVariable"
        (Json.Encode.string "EDITOR")
        Decode.string
        |> BackendTask.allowFatal

-- will resolve to "VIM" if you run `EDITOR=vim elm-pages dev`
// custom-backend-task.js

/**
* @param { string } fromElm
* @returns { Promise<string> }
*/
export async function environmentVariable(name) {
    const result = process.env[name];
    if (result) {
      return result;
    } else {
      throw `No environment variable called ${name}

Available:

${Object.keys(process.env).join("\n")}
`;
    }
}

Context Parameter

If you define a second parameter in an exported custom-backend-task file function, you can access the context object. This object is a JSON object that contains the following fields:

  • cwd - the current working directory for the BackendTask, set by calls to BackendTask.inDir. If you don't use BackendTask.inDir, this will be the directory from which you are invoking elm-pages.
  • env - the environment variables for the BackendTask, set by calls to BackendTask.withEnv
  • quiet - a boolean that is true if the BackendTask is running in quiet mode, set by calls to BackendTask.quiet

If your BackendTask.Custom implementation depends on relative file paths, process.env, or has logging, it is recommended to use the context.cwd and context.env fields to ensure that the behavior of your BackendTask.Custom is consistent with the core BackendTask definitions provided by the framework. For example, the BackendTask.Glob API will resolve glob patterns relative to the cwd context.

import toml from 'toml';
import fs from 'node:fs/promises';
import path from 'node:path';


export async function readTomlFile(relativeFilePath, context) {
  const filePath = path.resolve(context.cwd, relativeFilePath);
  // toml.parse returns a JSON representation of the TOML input
  return toml.parse(fs.readFile(filePath));
}
    import BackendTask exposing (BackendTask)
    import BackendTask.Custom
    import Json.Encode
    import OptimizedDecoder as Decode

    data : BackendTask FatalError String
    data =
        BackendTask.Custom.run "parseTomlFile"
            (Json.Encode.string "my-file.toml")
            myJsonDecoder
            |> BackendTask.allowFatal

Performance

As with any JavaScript or NodeJS code, avoid doing blocking IO operations. For example, avoid using fs.readFileSync, because blocking IO can slow down your elm-pages builds and dev server. elm-pages performs all BackendTask's in parallel whenever possible. So if you do BackendTask.map2 Tuple.pair myHttpBackendTask myCustomBackendTask, it will resolve those two in parallel. NodeJS performs best when you take advantage of its ability to do non-blocking I/O (file reads, HTTP requests, etc.). If you use BackendTask.andThen, it will need to resolve them in sequence rather than in parallel, but it's still best to avoid blocking IO operations in your Custom BackendTask definitions.

Error Handling

There are a few different things that can go wrong when running a custom-backend-task. These possible errors are captured in the BackendTask.Custom.Error type.

type Error
= Error
| ErrorInCustomBackendTaskFile
| MissingCustomBackendTaskFile
| CustomBackendTaskNotDefined { name : String }
| CustomBackendTaskException Value
| NonJsonException String
| ExportIsNotFunction
| DecodeError Error

Any time you throw a JavaScript exception from a BackendTask.Custom definition, it will give you a CustomBackendTaskException. It's usually easier to add a try/catch in your JavaScript code in custom-backend-task.js to handle possible errors, but you can throw a JSON value and handle it in Elm in the CustomBackendTaskException call error.

Decoding JS Date Objects

These decoders are for use with decoding JS values of type Date. If you have control over the format, it may be better to be more explicit with a Rata Die number value or an ISO-8601 formatted date string instead. But often JavaScript libraries and core APIs will give you JS Date objects, so this can be useful for working with those.

timeDecoder : Decoder Posix
dateDecoder : Decoder Date

The same as timeDecoder, but it converts the decoded Time.Posix value into a Date with Date.fromPosix Time.utc.

JavaScript Date objects don't distinguish between values with only a date vs. values with both a date and a time. So be sure to use this decoder when you know the semantics represent a date with no associated time (or you're sure you don't care about the time).