Last active
April 28, 2025 18:34
-
-
Save adamdicarlo0/221e839050a3e8cef51f1849e7af71a9 to your computer and use it in GitHub Desktop.
Elm Pages entry point for AWS Lambda using API GatewayV2 integration
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* @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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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, | |
}; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{- 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 () |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Notes:
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 theelm-pages
adapter function write the JS file like the Netlify adapter does.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."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 topnpm 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 ofelm-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 runpnpm add
to install them to your ownpackage.json
. Much like Elm! I believe you'd run into this problem, and seeimport 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 yournode_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.)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
.