Skip to content

Instantly share code, notes, and snippets.

@adamdicarlo0
Last active April 28, 2025 18:34
Show Gist options
  • Save adamdicarlo0/221e839050a3e8cef51f1849e7af71a9 to your computer and use it in GitHub Desktop.
Save adamdicarlo0/221e839050a3e8cef51f1849e7af71a9 to your computer and use it in GitHub Desktop.
Elm Pages entry point for AWS Lambda using API GatewayV2 integration
export interface RenderRequest {
method: string;
rawUrl: string;
body: string | null;
headers: Record<string, string | undefined>;
multiPartFormData: unknown;
}
export interface RenderResult {
body: string;
headers: Record<string, string[]>;
isBase64Encoded: boolean;
kind: "bytes" | "api-response" | "html";
statusCode: number;
}
const render: (request: RenderRequest) => Promise<RenderResult>;
export { render };
/**
* @typedef {{ kind: "prerender" | "serverless" | "static", pathPattern: string }} RoutePattern
* @typedef {{ kind: "prerender", pathPattern: { kind: "literal", value: string }[] }} ApiRoutePattern
* @typedef {{ renderFunctionFilePath: string, routePatterns: RoutePattern[], apiRoutePatterns: ApiRoutePattern[] }} AdapterArgObject
* @typedef {(args: AdapterArgObject) => Promise<void>} AdapterFn
*/
/** @type AdapterFn */
async function adapter({ renderFunctionFilePath }) {
console.log("Bundling Lambda function");
// Place the compiled Elm Pages bundle where it can easily be imported by our lambda's entry point.
await copyFile(
renderFunctionFilePath,
"lambdas/elm-pages/compiled-elm-pages-server.mjs",
);
//
// Hack: Work around Elm Pages 404 page issues by using a custom (normal) static page with no run-
// time behavior by hacking out its script tags at build time.
//
// Our 404 page is a normal page that we name as /404.html to match our standard CloudFront custom
// error response configuration. (See gist comment.) In other words, we're not using the Elm Pages
// 404 mechanism at all.
//
// We need to remove the script tags because the browser URL (which could be any arbitrary path that
// isn't defined as a Route) doesn't match something Elm Pages's router knows about. This causes
// our ErrorPage to be shown as-is very briefly, but upon the Elm app hydrating and running, it
// sees that the current route has no matches, and the routing logic hits a fallback that makes
// it display a hard-coded "This page could not be found" message, instead of our ErrorPage.
//
// Search `.elm-pages/Main.elm` for "This page could" to see the (generated) code responsible.
//
// Issue: https://github.com/dillonkearns/elm-pages/issues/432
//
// We can't solve this by making the page a root-level "splat", either:
// https://github.com/dillonkearns/elm-pages/issues/407
//
// Related: https://github.com/dillonkearns/elm-pages/issues/397
//
const notFoundPath = "dist/page-not-found/index.html";
const html = await readFile(notFoundPath, "utf-8");
// The tags look like this:
// <script defer src="/elm.fcba9bad.js" type="text/javascript"></script>
// <script type="module" crossorigin src="/assets/index-06bd4283.js"></script>
const fixedHtml = html
.replace(
/<script defer src="\/elm\..{8}\.js" type="text\/javascript"><\/script>/,
"",
)
.replace(
/<script type="module" crossorigin src="\/assets\/index-.{8}\.js"><\/script>/,
"",
);
await writeFile(notFoundPath, fixedHtml, "utf-8");
}
// This is the actual AWS Lambda entry point, lambdas/elm-pages/index.mjs.
import * as elmPages from "./compiled-elm-pages-server.mjs";
/**
* Event doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-simple-proxy-for-lambda-input-format
* @param {import('aws-lambda').APIGatewayProxyEventV2} event - API Gateway Lambda Proxy Input Format
*
* Context doc: https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html
* @param {import('aws-lambda').APIGatewayEventRequestContextV2} _context
*
* Return doc: https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html
* @returns {Promise<import('aws-lambda').APIGatewayProxyStructuredResultV2>} - API Gateway Proxy result
*/
export async function handler(event, _context) {
try {
const renderResult = await elmPages.render(reqToJson(event));
const {
body,
headers: multiValueHeaders = {},
kind,
statusCode,
} = renderResult;
// "Translate" the headers (in multi-value format) from Elm Pages--which
// assumes Netlify deployment--to the APIGatewayProxyStructuredResultV2
// (version *2*) format. (Apparently Netlify functions use V1.)
const headers = Object.fromEntries(
Object.entries(multiValueHeaders).map(([key, values]) => {
return [key, values?.[0] || ""];
}),
);
if (kind === "bytes") {
return {
body: Buffer.from(body).toString("base64"),
isBase64Encoded: true,
headers: {
"Content-Type": "application/octet-stream",
"x-powered-by": "elm-pages",
...headers,
},
statusCode,
};
} else if (kind === "api-response") {
return {
body,
headers,
statusCode,
isBase64Encoded: renderResult.isBase64Encoded,
};
} else {
return {
body,
headers: {
"Content-Type": "text/html",
"x-powered-by": "elm-pages",
...headers,
},
statusCode,
};
}
} catch (error) {
if (error?.toString) {
console.log(JSON.stringify({ error: true, errors: [error.toString()] }));
}
return {
body: `<body><h1>Error</h1></body>`,
headers: {
"Content-Type": "text/html",
"x-powered-by": "elm-pages",
},
statusCode: 500,
};
}
}
/**
* @param {import('aws-lambda').APIGatewayProxyEventV2} req - API Gateway Lambda Proxy Input Format
* @returns {{method: string; rawUrl: string; body: string?; headers: Record<string, string|undefined>; multiPartFormData: unknown; requestTime: number }}
*/
function reqToJson(req) {
const { body = null, rawPath, rawQueryString, requestContext } = req;
const domainName = requestContext.domainName ?? "localhost:1234";
// elm-pages needs the headers -- at least "content-type" one -- to be named all-lowercase. But
// we receive them in Dashed-Pascal-Case, e.g. "Content-Type", so we must transform them.
const headers = Object.fromEntries(
Array.from(Object.entries(req.headers)).map(([k, v]) => [
k.toLowerCase(),
v,
]),
);
// APIGateway may send a Base64-encoded body; elm-pages requires it not be encoded.
const unencodedBody =
req.isBase64Encoded && body !== null
? Buffer.from(body, "base64").toString("utf8")
: body;
// @see elm-pages/src/Internal/Request.elm - requestDecoder
return {
method: requestContext.http.method,
headers,
rawUrl: `http://${domainName}${rawPath}${rawQueryString}`,
requestTime: Date.now(),
body: unencodedBody,
multiPartFormData: null,
};
}
{- app/Route/PageNotFound.elm (delegates actual view to shared logic in another module) -}
module Route.PageNotFound exposing (Model, Msg, RouteParams, route, Data, ActionData)
{-|
@docs Model, Msg, RouteParams, route, Data, ActionData
-}
import BackendTask
import ErrorPage
import FatalError
import Head
import PagesMsg
import RouteBuilder
import Shared
import View
type alias Model =
{}
type alias Msg =
()
type alias RouteParams =
{}
route : RouteBuilder.StatelessRoute RouteParams Data ActionData
route =
RouteBuilder.single { data = data, head = head }
|> RouteBuilder.buildNoState { view = view }
type alias Data =
()
type alias ActionData =
BackendTask.BackendTask FatalError.FatalError (List RouteParams)
data : BackendTask.BackendTask FatalError.FatalError Data
data =
BackendTask.succeed ()
head : RouteBuilder.App Data ActionData RouteParams -> List Head.Tag
head _ =
[]
view :
RouteBuilder.App Data ActionData RouteParams
-> Shared.Model
-> View.View (PagesMsg.PagesMsg Msg)
view _ _ =
ErrorPage.view ErrorPage.NotFound ()
@adamdicarlo0
Copy link
Author

adamdicarlo0 commented Nov 4, 2024

Notes:

  1. The adapter copying the output file is because we have a separate (standard JS lambda build) process of running esbuild after the elm-pages build, to bundle our lambdas -- the elm-pages lambda, and a couple of purely-typescript lambdas. In other words, we don't have the elm-pages adapter function write the JS file like the Netlify adapter does.
  2. The Netlify adapter has import * as busboy from "busboy"; but doesn't use it. Dillon said on Slack "FWIW, busboy was used for handling file uploads at one point, I can't remember where that stands as is though. I think it was working in the Netlify adapter? It's a surprisingly complicated thing to support file uploads that run in multiple batches."
  3. Elm Pages's output bundle requires some npm packages. We use pnpm, we bundle our lambdas with esbuild, and we don't include node_modules in the zip file we upload to AWS. That means that, in order to bundle our lambda and include all the code it needs, we had to pnpm add kleur gray-matter make-fetch-happen cookie-signature micromatch. Otherwise esbuild would not be able to find the dependencies when bundling our entry point (lambdas/elm-pages/index.mjs). These are all dependencies of elm-pages npm package, so why is this? pnpm, by default, installs packages such that you cannot import your dependencies' own dependencies (i.e., you can't import transitive dependencies); rather, you must add run pnpm add to install them to your own package.json. Much like Elm! I believe you'd run into this problem, and see import not found errors at run-time, even when not bundling your lambda (and regardless of which package manager you use), if you don't include your node_modules folder in your lambda's zip file. (Note: AWS recommends bundling lambda sources, as you upload a lot less data and at runtime, reading one big file is a lot faster than reading thousands of tiny JS files.)
  4. Included is the hack to make a custom page, at app/Route/PageNotFound.elm work as a 404 page in AWS CloudFront, which is configured with a custom error response with a response page path of /404.html.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment