Here’s my first real use of ports in an Elm application. It’ll be light on the commentary, heavy on code.
This example will use the Google API client for JavaScript to perform OAuth2 authentication using a user’s Google credentials. This will integrate into an Elm application using ports. Specifically:
Interop.elm
There’ll be a small amount of JavaScript glue code in index.js
which will take care of marshalling the ports to and from JavaScript functions.
On the Elm side of things, the application will provide the bare minimum components to display the results of these calls via the ports mechanism to the user.
Note that I use the following packages/tools in this project:
The application can be run by typing make
at the command line:
.gitignore
You’ll need to create your google-api-client-id.txt
file by grabbing your Google API client ID after consulting the following resources:
/elm-stuff/ | |
/elm.js | |
/google-api-client-id.txt | |
/jquery.min.js |
elm-package.json
This file is typically updated using the elm-package
command.
{ | |
"version": "1.0.0", | |
"summary": "Simple Elm app with Google authentication via ports", | |
"repository": "https://github.com/rcook/not-a-real-repo.git", | |
"license": "MIT", | |
"source-directories": [ | |
"." | |
], | |
"exposed-modules": [], | |
"dependencies": { | |
"NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0", | |
"elm-lang/core": "5.1.1 <= v < 6.0.0", | |
"elm-lang/html": "2.0.0 <= v < 3.0.0", | |
"elm-lang/http": "1.0.0 <= v < 2.0.0" | |
}, | |
"elm-version": "0.18.0 <= v < 0.19.0" | |
} |
index.html
<body>
element where Elm application is rendered<!DOCTYPE html> | |
<html> | |
<head> | |
<title></title> | |
<script src="/jquery.min.js"></script> | |
<script src="/elm.js"></script> | |
<script src="/index.js"></script> | |
</head> | |
<body></body> | |
</html> |
index.js
This is the main JavaScript application code:
"use strict";
because you should always do this!"use strict"; | |
const Util = (() => { | |
const me = {}; | |
function formatNativeError(e) { | |
if (e.hasOwnProperty("message") && e.hasOwnProperty("stack")) { | |
return { | |
message: e.message, | |
stack: e.stack | |
}; | |
} | |
return e; | |
} | |
me.stubPorts = app => { | |
const stubs = {}; | |
for (const k in app.ports) { | |
if (app.ports[k].subscribe) { | |
stubs[k] = () => { throw `No handler subscribed to Elm port "${k}"`; }; | |
app.ports[k].subscribe(stubs[k]); | |
} | |
} | |
return stubs; | |
}; | |
me.loadScript = url => Promise.resolve($.getScript(url)); | |
me.loadGoogleApi = libs => new Promise(resolve => gapi.load(libs, resolve)); | |
me.sendNativeError = (app, e) => app.ports.nativeError.send(formatNativeError(e)); | |
// Sometimes useful for simulating slow network etc. | |
//me.delay = (ms) => new Promise(resolve => window.setTimeout(resolve, ms)); | |
return me; | |
})(); | |
function initAuth(stubs, app, auth) { | |
function sendAuthStateChanged() { | |
if (auth.isSignedIn.get()) { | |
const currentUser = auth.currentUser.get(); | |
const userId = currentUser.getId(); | |
const basicProfile = currentUser.getBasicProfile(); | |
const fullName = basicProfile.getName(); | |
const email = basicProfile.getEmail(); | |
const authResponse = currentUser.getAuthResponse(); | |
const accessToken = authResponse.id_token; | |
app.ports.authStateChanged.send({ | |
userId: userId, | |
fullName: fullName, | |
email: email, | |
accessToken: accessToken | |
}); | |
} | |
else { | |
app.ports.authStateChanged.send({}); | |
} | |
} | |
const handlers = { | |
signIn: () => { | |
if (auth.isSignedIn.get()) { | |
sendAuthStateChanged(app); | |
} | |
else { | |
auth.signIn({ prompt: "login" }); | |
} | |
}, | |
signOut: () => { | |
if (auth.isSignedIn.get()) { | |
auth.signOut(); | |
} | |
else { | |
sendAuthStateChanged(); | |
} | |
} | |
}; | |
for (const k in stubs) { | |
const handler = handlers[k]; | |
if (handler) { | |
app.ports[k].unsubscribe(stubs[k]); | |
app.ports[k].subscribe((() => handler())); | |
} | |
} | |
auth.isSignedIn.listen(sendAuthStateChanged); | |
sendAuthStateChanged(); | |
} | |
$(() => { | |
const app = Elm.Main.fullscreen(); | |
const stubs = Util.stubPorts(app); | |
Util.loadScript("https://apis.google.com/js/platform.js") | |
.then(() => Util.loadGoogleApi("auth2")) | |
.then(() => $.get("/google-api-client-id.txt")) | |
.then(responseText => responseText.trim()) | |
.then(clientId => gapi.auth2.init({ clientId: clientId, scope: "profile" })) | |
.then(() => initAuth(stubs, app, gapi.auth2.getAuthInstance())) | |
.catch(e => Util.sendNativeError(app, e)); | |
}); |
Interop.elm
port module Interop | |
exposing | |
( authStateChanged | |
, nativeError | |
, signIn | |
, signOut | |
) | |
import Json.Decode exposing (Value) | |
port nativeError : (Value -> msg) -> Sub msg | |
port authStateChanged : (Value -> msg) -> Sub msg | |
port signIn : () -> Cmd msg | |
port signOut : () -> Cmd msg |
Main.elm
main
functionmodule Main exposing (main) | |
import Html exposing (program) | |
import Model exposing (Model, init) | |
import Msg exposing (Msg) | |
import Update exposing (update) | |
import Subscriptions exposing (subscriptions) | |
import View exposing (view) | |
main : Program Never Model Msg | |
main = | |
program | |
{ init = init | |
, view = view | |
, update = update | |
, subscriptions = subscriptions | |
} |
Makefile
.PHONY: live | |
live: jquery.min.js | |
elm-live Main.elm --output=elm.js --open | |
jquery.min.js: | |
wget https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js |
Model.elm
message
field for displaying error messages to the usermodule Model | |
exposing | |
( Model | |
, AuthState(..) | |
, UserInfo | |
, init | |
) | |
type alias Model = | |
{ authState : AuthState | |
, message : Maybe String | |
} | |
type AuthState | |
= Unknown | |
| SignedIn UserInfo | |
| SignedOut | |
type alias UserInfo = | |
{ userId : String | |
, fullName : String | |
, email : String | |
, accessToken : String | |
} | |
init : ( Model, Cmd msg ) | |
init = | |
( { authState = Unknown, message = Nothing }, Cmd.none ) |
Msg.elm
Msg
typeNativeError
is used to report errors back from JavaScriptAuthStateChanged
is fired whenever the user signs in or outSignInClicked
is triggered when the user clicks the sign-in buttonSignOutClicked
is triggered when the user clicks the sign-out buttonmodule Msg exposing (Msg(..)) | |
import Json.Decode exposing (Value) | |
import Model exposing (AuthState) | |
type Msg | |
= NativeError Value | |
| AuthStateChanged (Result String AuthState) | |
| SignInClicked | |
| SignOutClicked |
Subscriptions.elm
module Subscriptions exposing (subscriptions) | |
import Interop exposing (authStateChanged, nativeError) | |
import Json.Decode exposing (Decoder, oneOf, string, succeed) | |
import Json.Decode as Decode | |
import Json.Decode.Pipeline exposing (decode, optional, required) | |
import Model exposing (AuthState(..), Model, UserInfo) | |
import Msg exposing (Msg(..)) | |
decodeSignedIn : Decoder AuthState | |
decodeSignedIn = | |
decode UserInfo | |
|> required "userId" string | |
|> required "fullName" string | |
|> required "email" string | |
|> required "accessToken" string | |
|> Decode.map SignedIn | |
decodeSignedOut : Decoder AuthState | |
decodeSignedOut = | |
succeed SignedOut | |
decodeAuthState : Decoder AuthState | |
decodeAuthState = | |
oneOf | |
[ decodeSignedIn | |
, decodeSignedOut | |
] | |
subscriptions : Model -> Sub Msg | |
subscriptions _ = | |
Sub.batch | |
[ authStateChanged (AuthStateChanged << Decode.decodeValue decodeAuthState) | |
, nativeError NativeError | |
] |
Update.elm
update
function for updating the state of the appmodule Update exposing (update) | |
import Interop exposing (signIn, signOut) | |
import Model exposing (Model, AuthState(..)) | |
import Msg exposing (Msg(..)) | |
update : Msg -> Model -> ( Model, Cmd Msg ) | |
update msg model = | |
case msg of | |
NativeError value -> | |
( { model | message = Just <| toString value }, Cmd.none ) | |
AuthStateChanged result -> | |
case result of | |
Ok authState -> | |
( { model | authState = authState }, Cmd.none ) | |
Err m -> | |
( { model | authState = Unknown, message = Just m }, Cmd.none ) | |
SignInClicked -> | |
( model, signIn () ) | |
SignOutClicked -> | |
( model, signOut () ) |
View.elm
module View exposing (view) | |
import Html exposing (Html, br, button, div, li, text, ul) | |
import Html.Attributes exposing (disabled) | |
import Html.Events exposing (onClick) | |
import Model exposing (Model, AuthState(..)) | |
import Msg exposing (Msg(..)) | |
view : Model -> Html Msg | |
view model = | |
div [] | |
[ text <| toString model.authState | |
, button [ disabled <| not <| isSignedOut model.authState, onClick SignInClicked ] [ text "Sign in" ] | |
, button [ disabled <| not <| isSignedIn model.authState, onClick SignOutClicked ] [ text "Sign out" ] | |
, br [] [] | |
, case model.message of | |
Nothing -> | |
text "No message" | |
Just m -> | |
text m | |
, br [] [] | |
, case model.authState of | |
SignedIn state -> | |
ul [] | |
[ li [] [ text state.userId ] | |
, li [] [ text state.fullName ] | |
, li [] [ text state.email ] | |
, li [] [ text state.accessToken ] | |
] | |
_ -> | |
text "No user information" | |
] | |
isSignedIn : AuthState -> Bool | |
isSignedIn state = | |
case state of | |
SignedIn _ -> | |
True | |
_ -> | |
False | |
isSignedOut : AuthState -> Bool | |
isSignedOut state = | |
case state of | |
SignedOut -> | |
True | |
_ -> | |
False |
Check out the full project here.
Content © 2025 Richard Cook. All rights reserved.