Skip to content

Instantly share code, notes, and snippets.

@tappleby
Created April 8, 2023 02:06
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save tappleby/6ed416fad9b2b49ea402abf133e1cabc to your computer and use it in GitHub Desktop.
Save tappleby/6ed416fad9b2b49ea402abf133e1cabc to your computer and use it in GitHub Desktop.
Sample remix handler based on remix-architect that supports AWS Lambda Streaming - https://aws.amazon.com/blogs/compute/introducing-aws-lambda-response-streaming/
import { createRequestHandler } from "./streaming-lambda.ts";
import * as build from "@remix-run/dev/server-build";
export const handler = awslambda.streamifyResponse(
createRequestHandler({
build,
mode: process.env.NODE_ENV,
})
);
import type {
AppLoadContext,
ServerBuild,
RequestInit as NodeRequestInit,
Response as NodeResponse,
} from "@remix-run/node";
import {
AbortController as NodeAbortController,
createRequestHandler as createRemixRequestHandler,
Headers as NodeHeaders,
Request as NodeRequest,
writeReadableStreamToWritable,
} from "@remix-run/node";
import type {
APIGatewayProxyEventHeaders,
APIGatewayProxyEventV2,
APIGatewayProxyStructuredResultV2,
Context,
} from "aws-lambda";
import type { Writable } from "stream";
/**
* A function that returns the value to use as `context` in route `loader` and
* `action` functions.
*
* You can think of this as an escape hatch that allows you to pass
* environment/platform-specific values through to your loader/action.
*/
export type GetLoadContextFunction = (
event: APIGatewayProxyEventV2
) => AppLoadContext;
export type StreamingRequestHandler = (
event: APIGatewayProxyEventV2,
responseStream: Writable,
context: Context
) => void;
/**
* Returns a request handler for Architect that serves the response using
* Remix.
*/
export function createRequestHandler({
build,
getLoadContext,
mode = process.env.NODE_ENV,
}: {
build: ServerBuild;
getLoadContext?: GetLoadContextFunction;
mode?: string;
}): StreamingRequestHandler {
let handleRequest = createRemixRequestHandler(build, mode);
return async (event, responseStream, _context) => {
let request = createRemixRequest(event, responseStream);
let loadContext = getLoadContext?.(event);
let response = (await handleRequest(request, loadContext)) as NodeResponse;
return sendRemixResponse(response, responseStream);
};
}
export function createRemixRequest(
event: APIGatewayProxyEventV2,
res: Writable
): NodeRequest {
let host = event.headers["x-forwarded-host"] || event.headers.host;
let search = event.rawQueryString.length ? `?${event.rawQueryString}` : "";
let scheme = process.env.ARC_SANDBOX ? "http" : "https";
let url = new URL(`${scheme}://${host}${event.rawPath}${search}`);
let isFormData = event.headers["content-type"]?.includes(
"multipart/form-data"
);
let controller = new NodeAbortController();
res.on("close", () => controller.abort());
return new NodeRequest(url.href, {
method: event.requestContext.http.method,
headers: createRemixHeaders(event.headers, event.cookies),
// Cast until reason/throwIfAborted added
// https://github.com/mysticatea/abort-controller/issues/36
signal: controller.signal as NodeRequestInit["signal"],
body:
event.body && event.isBase64Encoded
? isFormData
? Buffer.from(event.body, "base64")
: Buffer.from(event.body, "base64").toString()
: event.body,
});
}
export function createRemixHeaders(
requestHeaders: APIGatewayProxyEventHeaders,
requestCookies?: string[]
): NodeHeaders {
let headers = new NodeHeaders();
for (let [header, value] of Object.entries(requestHeaders)) {
if (value) {
headers.append(header, value);
}
}
if (requestCookies) {
headers.append("Cookie", requestCookies.join("; "));
}
return headers;
}
export async function sendRemixResponse(
nodeResponse: NodeResponse,
res: Writable
): Promise<void> {
let cookies: string[] = [];
// Arc/AWS API Gateway will send back set-cookies outside of response headers.
for (let [key, values] of Object.entries(nodeResponse.headers.raw())) {
if (key.toLowerCase() === "set-cookie") {
for (let value of values) {
cookies.push(value);
}
}
}
if (cookies.length) {
nodeResponse.headers.delete("Set-Cookie");
}
let metadata: APIGatewayProxyStructuredResultV2 = {
statusCode: nodeResponse.status,
headers: Object.fromEntries(nodeResponse.headers.entries()),
cookies,
};
let httpResponseStream = awslambda.HttpResponseStream.from(
res,
metadata
) as Writable;
if (nodeResponse.body) {
await writeReadableStreamToWritable(nodeResponse.body, httpResponseStream);
} else {
res.end();
}
}
@prxg22
Copy link

prxg22 commented Jul 9, 2023

Awesome!
I reached the same result as yours.

@tappleby
Copy link
Author

tappleby commented Jul 9, 2023

@prxg22 Nice! I had also started a sample project - https://github.com/tappleby/remix-aws-streaming-example

Still haven't had a chance to try this in production, but works well with the example!

@owlyowl
Copy link

owlyowl commented Dec 5, 2023

Did you get a chance to work out if this works as I thought a limitation of streaming lambdas was they couldn't be behind apigateway, they had to be directly invoked?

@tappleby
Copy link
Author

tappleby commented Dec 5, 2023

@owlyowl I havent tried it using api gateway but I dont believe it works with streaming. With the POC I had Cloudfront -> Streaming Lambda directly - https://github.com/tappleby/remix-aws-streaming-example/blob/5c4fe9ac98cde8373ce61dad92a05c4f279c5593/cdk/lib/remix-streaming-stack.ts#L56-L100

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