diff --git a/contributors.yml b/contributors.yml index 1aaefc48072..e92812e28aa 100644 --- a/contributors.yml +++ b/contributors.yml @@ -371,6 +371,7 @@ - vkrol - vlindhol - weavdale +- wingleung - wKovacs64 - wladiston - XiNiHa diff --git a/packages/remix-architect/__tests__/server-test.ts b/packages/remix-architect/__tests__/server-test.ts index 0dc6bca7a42..208d6e4ebd5 100644 --- a/packages/remix-architect/__tests__/server-test.ts +++ b/packages/remix-architect/__tests__/server-test.ts @@ -1,20 +1,15 @@ -import fsp from "fs/promises"; -import path from "path"; import lambdaTester from "lambda-tester"; -import type { APIGatewayProxyEventV2 } from "aws-lambda"; +import type { + APIGatewayProxyEvent, + APIGatewayProxyEventV2 +} from "aws-lambda"; import { - // This has been added as a global in node 15+ - AbortController, - createRequestHandler as createRemixRequestHandler, - Response as NodeResponse, + createRequestHandler as createRemixRequestHandler } from "@remix-run/node"; -import { - createRequestHandler, - createRemixHeaders, - createRemixRequest, - sendRemixResponse, -} from "../server"; +import { createRequestHandler } from "../server"; +import * as v1Methods from "../api/v1"; +import * as v2Methods from "../api/v2"; // We don't want to test that the remix server works here (that's what the // puppetteer tests do), we just want to test the architect adapter @@ -30,7 +25,7 @@ let mockedCreateRequestHandler = typeof createRemixRequestHandler >; -function createMockEvent(event: Partial = {}) { +function createMockEvent(event: Partial = {}) { let now = new Date(); return { headers: { @@ -152,191 +147,47 @@ describe("architect createRequestHandler", () => { ]); }); }); - }); -}); - -describe("architect createRemixHeaders", () => { - describe("creates fetch headers from architect headers", () => { - it("handles empty headers", () => { - expect(createRemixHeaders({}, undefined)).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [], - Symbol(context): null, - } - `); - }); - - it("handles simple headers", () => { - expect(createRemixHeaders({ "x-foo": "bar" }, undefined)) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar", - ], - Symbol(context): null, - } - `); - }); - - it("handles multiple headers", () => { - expect(createRemixHeaders({ "x-foo": "bar", "x-bar": "baz" }, undefined)) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar", - "x-bar", - "baz", - ], - Symbol(context): null, - } - `); - }); - - it("handles headers with multiple values", () => { - expect(createRemixHeaders({ "x-foo": "bar, baz" }, undefined)) - .toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar, baz", - ], - Symbol(context): null, - } - `); - }); - it("handles headers with multiple values and multiple headers", () => { - expect( - createRemixHeaders({ "x-foo": "bar, baz", "x-bar": "baz" }, undefined) - ).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-foo", - "bar, baz", - "x-bar", - "baz", - ], - Symbol(context): null, - } - `); - }); + it("should call api v2 methods", async () => { + mockedCreateRequestHandler.mockImplementation(() => async (req) => { + return new Response(`URL: ${new URL(req.url).pathname}`); + }); - it("handles cookies", () => { - expect( - createRemixHeaders({ "x-something-else": "true" }, [ - "__session=some_value", - "__other=some_other_value", - ]) - ).toMatchInlineSnapshot(` - Headers { - Symbol(query): Array [ - "x-something-else", - "true", - "cookie", - "__session=some_value; __other=some_other_value", - ], - Symbol(context): null, - } - `); - }); - }); -}); + const spyCreateRemixRequest = jest.spyOn(v2Methods, "createRemixRequest") + const spySendRemixResponse = jest.spyOn(v2Methods, "sendRemixResponse") -describe("architect createRemixRequest", () => { - it("creates a request with the correct headers", () => { - expect( - createRemixRequest( - createMockEvent({ - cookies: ["__session=value"], - }) - ) - ).toMatchInlineSnapshot(` - NodeRequest { - "agent": undefined, - "compress": true, - "counter": 0, - "follow": 20, - "highWaterMark": 16384, - "insecureHTTPParser": false, - "size": 0, - Symbol(Body internals): Object { - "body": null, - "boundary": null, - "disturbed": false, - "error": null, - "size": 0, - "type": null, - }, - Symbol(Request internals): Object { - "headers": Headers { - Symbol(query): Array [ - "accept", - "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "accept-encoding", - "gzip, deflate", - "accept-language", - "en-US,en;q=0.9", - "cookie", - "__session=value", - "host", - "localhost:3333", - "upgrade-insecure-requests", - "1", - "user-agent", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15", - ], - Symbol(context): null, - }, - "method": "GET", - "parsedURL": "https://localhost:3333/", - "redirect": "follow", - "signal": null, - }, - } - `); - }); -}); + const mockEvent = createMockEvent({ rawPath: "/foo/bar" }); -describe("sendRemixResponse", () => { - it("handles regular responses", async () => { - let response = new NodeResponse("anything"); - let abortController = new AbortController(); - let result = await sendRemixResponse(response, abortController); - expect(result.body).toBe("anything"); - }); + await lambdaTester(createRequestHandler({ build: undefined, apiGatewayVersion: "v2" } as any)) + .event(mockEvent) + .expectResolve((res) => { + expect(res.statusCode).toBe(200); + expect(res.body).toBe("URL: /foo/bar"); + }); - it("handles resource routes with regular data", async () => { - let json = JSON.stringify({ foo: "bar" }); - let response = new NodeResponse(json, { - headers: { - "Content-Type": "application/json", - "content-length": json.length.toString(), - }, + expect(spyCreateRemixRequest).toHaveBeenCalledWith(mockEvent) + expect(spySendRemixResponse).toHaveBeenCalled() }); - let abortController = new AbortController(); + it("should call api v1 methods", async () => { + mockedCreateRequestHandler.mockImplementation(() => async (req) => { + return new Response(`URL: ${new URL(req.url).pathname}`); + }); - let result = await sendRemixResponse(response, abortController); + const spyCreateRemixRequest = jest.spyOn(v1Methods, "createRemixRequest") + const spySendRemixResponse = jest.spyOn(v1Methods, "sendRemixResponse") - expect(result.body).toMatch(json); - }); + const mockEvent = createMockEvent({ path: "/foo/bar" }); - it("handles resource routes with binary data", async () => { - let image = await fsp.readFile(path.join(__dirname, "554828.jpeg")); + await lambdaTester(createRequestHandler({ build: undefined, apiGatewayVersion: "v1" } as any)) + .event(mockEvent) + .expectResolve((res) => { + expect(res.statusCode).toBe(200); + expect(res.body).toBe("URL: /foo/bar"); + }); - let response = new NodeResponse(image, { - headers: { - "content-type": "image/jpeg", - "content-length": image.length.toString(), - }, + expect(spyCreateRemixRequest).toHaveBeenCalledWith(mockEvent) + expect(spySendRemixResponse).toHaveBeenCalled() }); - - let abortController = new AbortController(); - - let result = await sendRemixResponse(response, abortController); - - expect(result.body).toMatch(image.toString("base64")); }); -}); +}); \ No newline at end of file diff --git a/packages/remix-architect/__tests__/v1-test.ts b/packages/remix-architect/__tests__/v1-test.ts new file mode 100644 index 00000000000..ae42130204b --- /dev/null +++ b/packages/remix-architect/__tests__/v1-test.ts @@ -0,0 +1,251 @@ +import fsp from "fs/promises"; +import path from "path"; +import type { APIGatewayProxyEvent } from "aws-lambda"; +import { + // This has been added as a global in node 15+ + AbortController, + Response as NodeResponse, +} from "@remix-run/node"; + +import { + createRemixHeaders, + createRemixRequest, + sendRemixResponse +} from "../api/v1"; + +function createMockEvent(event: Partial = {}): APIGatewayProxyEvent { + let now = new Date(); + + const headers = { + host: "localhost:3333", + accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "upgrade-insecure-requests": "1", + "user-agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15", + "accept-language": "en-US,en;q=0.9", + "accept-encoding": "gzip, deflate", + ...event.headers, + }; + + delete event.headers; + + const requestContext = { + httpMethod: "GET", + routeKey: "ANY /{proxy+}", + accountId: "accountId", + requestId: "requestId", + apiId: "apiId", + domainName: "id.execute-api.us-east-1.amazonaws.com", + domainPrefix: "id", + stage: "test", + requestTime: now.toISOString(), + requestTimeEpoch: now.getTime(), + ...event.requestContext, + } + + delete event.requestContext; + + return { + isBase64Encoded: false, + resource: "/", + path: "/", + httpMethod: "GET", + headers, + multiValueHeaders: {}, + queryStringParameters: {}, + multiValueQueryStringParameters: {}, + requestContext, + pathParameters: null, + stageVariables: null, + body: "", + ...event, + }; +} + +describe("architect createRemixHeaders", () => { + describe("creates fetch headers from architect headers", () => { + it("handles empty headers", () => { + expect(createRemixHeaders({})).toMatchInlineSnapshot(` + Headers { + Symbol(query): Array [], + Symbol(context): null, + } + `); + }); + + it("handles simple headers", () => { + expect(createRemixHeaders({"x-foo": "bar"})) + .toMatchInlineSnapshot(` + Headers { + Symbol(query): Array [ + "x-foo", + "bar", + ], + Symbol(context): null, + } + `); + }); + + it("handles multiple headers", () => { + expect(createRemixHeaders({"x-foo": "bar", "x-bar": "baz"})) + .toMatchInlineSnapshot(` + Headers { + Symbol(query): Array [ + "x-foo", + "bar", + "x-bar", + "baz", + ], + Symbol(context): null, + } + `); + }); + + it("handles headers with multiple values", () => { + expect(createRemixHeaders({"x-foo": "bar, baz"})) + .toMatchInlineSnapshot(` + Headers { + Symbol(query): Array [ + "x-foo", + "bar, baz", + ], + Symbol(context): null, + } + `); + }); + + it("handles headers with multiple values and multiple headers", () => { + expect( + createRemixHeaders({"x-foo": "bar, baz", "x-bar": "baz"}) + ).toMatchInlineSnapshot(` + Headers { + Symbol(query): Array [ + "x-foo", + "bar, baz", + "x-bar", + "baz", + ], + Symbol(context): null, + } + `); + }); + + it("handles cookies", () => { + expect( + createRemixHeaders({ + "x-something-else": "true", + "Cookie": "__session=some_value; __other=some_other_value" + }) + ).toMatchInlineSnapshot(` + Headers { + Symbol(query): Array [ + "x-something-else", + "true", + "cookie", + "__session=some_value; __other=some_other_value", + ], + Symbol(context): null, + } + `); + }); + }); +}); + +describe("architect createRemixRequest", () => { + it("creates a request with the correct headers", () => { + expect( + createRemixRequest( + createMockEvent({ + headers: { + Cookie: "__session=value" + }, + }) + ) + ).toMatchInlineSnapshot(` + NodeRequest { + "agent": undefined, + "compress": true, + "counter": 0, + "follow": 20, + "highWaterMark": 16384, + "insecureHTTPParser": false, + "size": 0, + Symbol(Body internals): Object { + "body": null, + "boundary": null, + "disturbed": false, + "error": null, + "size": 0, + "type": null, + }, + Symbol(Request internals): Object { + "headers": Headers { + Symbol(query): Array [ + "accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "accept-encoding", + "gzip, deflate", + "accept-language", + "en-US,en;q=0.9", + "cookie", + "__session=value", + "host", + "localhost:3333", + "upgrade-insecure-requests", + "1", + "user-agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15", + ], + Symbol(context): null, + }, + "method": "GET", + "parsedURL": "https://localhost:3333/", + "redirect": "follow", + "signal": null, + }, + } + `); + }); +}); + +describe("sendRemixResponse", () => { + it("handles regular responses", async () => { + let response = new NodeResponse("anything"); + let abortController = new AbortController(); + let result = await sendRemixResponse(response, abortController); + expect(result.body).toBe("anything"); + }); + + it("handles resource routes with regular data", async () => { + let json = JSON.stringify({foo: "bar"}); + let response = new NodeResponse(json, { + headers: { + "Content-Type": "application/json", + "content-length": json.length.toString(), + }, + }); + + let abortController = new AbortController(); + + let result = await sendRemixResponse(response, abortController); + + expect(result.body).toMatch(json); + }); + + it("handles resource routes with binary data", async () => { + let image = await fsp.readFile(path.join(__dirname, "554828.jpeg")); + + let response = new NodeResponse(image, { + headers: { + "content-type": "image/jpeg", + "content-length": image.length.toString(), + }, + }); + + let abortController = new AbortController(); + + let result = await sendRemixResponse(response, abortController); + + expect(result.body).toMatch(image.toString("base64")); + }); +}); diff --git a/packages/remix-architect/__tests__/v2-test.ts b/packages/remix-architect/__tests__/v2-test.ts new file mode 100644 index 00000000000..186508adeac --- /dev/null +++ b/packages/remix-architect/__tests__/v2-test.ts @@ -0,0 +1,239 @@ +import fsp from "fs/promises"; +import path from "path"; +import type { APIGatewayProxyEventV2 } from "aws-lambda"; +import { + // This has been added as a global in node 15+ + AbortController, + Response as NodeResponse, +} from "@remix-run/node"; + +import { createRemixHeaders, createRemixRequest, sendRemixResponse } from "../api/v2"; + +function createMockEvent(event: Partial = {}) { + let now = new Date(); + return { + headers: { + host: "localhost:3333", + accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "upgrade-insecure-requests": "1", + "user-agent": + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15", + "accept-language": "en-US,en;q=0.9", + "accept-encoding": "gzip, deflate", + ...event.headers, + }, + isBase64Encoded: false, + rawPath: "/", + rawQueryString: "", + requestContext: { + http: { + method: "GET", + path: "/", + protocol: "HTTP/1.1", + userAgent: + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15", + sourceIp: "127.0.0.1", + ...event.requestContext?.http, + }, + routeKey: "ANY /{proxy+}", + accountId: "accountId", + requestId: "requestId", + apiId: "apiId", + domainName: "id.execute-api.us-east-1.amazonaws.com", + domainPrefix: "id", + stage: "test", + time: now.toISOString(), + timeEpoch: now.getTime(), + ...event.requestContext, + }, + routeKey: "foo", + version: "2.0", + ...event, + }; +} + +describe("architect createRemixHeaders", () => { + describe("creates fetch headers from architect headers", () => { + it("handles empty headers", () => { + expect(createRemixHeaders({}, undefined)).toMatchInlineSnapshot(` + Headers { + Symbol(query): Array [], + Symbol(context): null, + } + `); + }); + + it("handles simple headers", () => { + expect(createRemixHeaders({"x-foo": "bar"}, undefined)) + .toMatchInlineSnapshot(` + Headers { + Symbol(query): Array [ + "x-foo", + "bar", + ], + Symbol(context): null, + } + `); + }); + + it("handles multiple headers", () => { + expect(createRemixHeaders({"x-foo": "bar", "x-bar": "baz"}, undefined)) + .toMatchInlineSnapshot(` + Headers { + Symbol(query): Array [ + "x-foo", + "bar", + "x-bar", + "baz", + ], + Symbol(context): null, + } + `); + }); + + it("handles headers with multiple values", () => { + expect(createRemixHeaders({"x-foo": "bar, baz"}, undefined)) + .toMatchInlineSnapshot(` + Headers { + Symbol(query): Array [ + "x-foo", + "bar, baz", + ], + Symbol(context): null, + } + `); + }); + + it("handles headers with multiple values and multiple headers", () => { + expect( + createRemixHeaders({"x-foo": "bar, baz", "x-bar": "baz"}, undefined) + ).toMatchInlineSnapshot(` + Headers { + Symbol(query): Array [ + "x-foo", + "bar, baz", + "x-bar", + "baz", + ], + Symbol(context): null, + } + `); + }); + + it("handles cookies", () => { + expect( + createRemixHeaders({"x-something-else": "true"}, [ + "__session=some_value", + "__other=some_other_value", + ]) + ).toMatchInlineSnapshot(` + Headers { + Symbol(query): Array [ + "x-something-else", + "true", + "cookie", + "__session=some_value; __other=some_other_value", + ], + Symbol(context): null, + } + `); + }); + }); +}); + +describe("architect createRemixRequest", () => { + it("creates a request with the correct headers", () => { + expect( + createRemixRequest( + createMockEvent({ + cookies: ["__session=value"], + }) + ) + ).toMatchInlineSnapshot(` + NodeRequest { + "agent": undefined, + "compress": true, + "counter": 0, + "follow": 20, + "highWaterMark": 16384, + "insecureHTTPParser": false, + "size": 0, + Symbol(Body internals): Object { + "body": null, + "boundary": null, + "disturbed": false, + "error": null, + "size": 0, + "type": null, + }, + Symbol(Request internals): Object { + "headers": Headers { + Symbol(query): Array [ + "accept", + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "accept-encoding", + "gzip, deflate", + "accept-language", + "en-US,en;q=0.9", + "cookie", + "__session=value", + "host", + "localhost:3333", + "upgrade-insecure-requests", + "1", + "user-agent", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Safari/605.1.15", + ], + Symbol(context): null, + }, + "method": "GET", + "parsedURL": "https://localhost:3333/", + "redirect": "follow", + "signal": null, + }, + } + `); + }); +}); + +describe("sendRemixResponse", () => { + it("handles regular responses", async () => { + let response = new NodeResponse("anything"); + let abortController = new AbortController(); + let result = await sendRemixResponse(response, abortController); + expect(result.body).toBe("anything"); + }); + + it("handles resource routes with regular data", async () => { + let json = JSON.stringify({foo: "bar"}); + let response = new NodeResponse(json, { + headers: { + "Content-Type": "application/json", + "content-length": json.length.toString(), + }, + }); + + let abortController = new AbortController(); + + let result = await sendRemixResponse(response, abortController); + + expect(result.body).toMatch(json); + }); + + it("handles resource routes with binary data", async () => { + let image = await fsp.readFile(path.join(__dirname, "554828.jpeg")); + + let response = new NodeResponse(image, { + headers: { + "content-type": "image/jpeg", + "content-length": image.length.toString(), + }, + }); + + let abortController = new AbortController(); + + let result = await sendRemixResponse(response, abortController); + + expect(result.body).toMatch(image.toString("base64")); + }); +}); diff --git a/packages/remix-architect/api/v1.ts b/packages/remix-architect/api/v1.ts new file mode 100644 index 00000000000..23261e00e10 --- /dev/null +++ b/packages/remix-architect/api/v1.ts @@ -0,0 +1,76 @@ +import { + Headers as NodeHeaders, readableStreamToString, + Request as NodeRequest +} from "@remix-run/node"; +import type { + APIGatewayProxyEvent, + APIGatewayProxyEventHeaders, + APIGatewayProxyResult +} from "aws-lambda"; +import type { + Response as NodeResponse, +} from "@remix-run/node"; +import { URLSearchParams } from "url"; + +import { isBinaryType } from "../binaryTypes"; + +export function createRemixRequest(event: APIGatewayProxyEvent): NodeRequest { + let host = event.headers["x-forwarded-host"] || event.headers.Host; + let scheme = process.env.ARC_SANDBOX ? "http" : "https"; + + let rawQueryString = new URLSearchParams(event.queryStringParameters as Record).toString(); + let search = rawQueryString.length > 0 ? `?${rawQueryString}` : ""; + let url = new URL(event.path + search, `${scheme}://${host}`); + + let isFormData = event.headers["content-type"]?.includes( + "multipart/form-data" + ); + + return new NodeRequest(url.href, { + method: event.requestContext.httpMethod, + headers: createRemixHeaders(event.headers), + body: + event.body && event.isBase64Encoded + ? isFormData + ? Buffer.from(event.body, "base64") + : Buffer.from(event.body, "base64").toString() + : event.body || undefined, + }); +} + +export function createRemixHeaders( + requestHeaders: APIGatewayProxyEventHeaders +): NodeHeaders { + let headers = new NodeHeaders(); + + for (let [header, value] of Object.entries(requestHeaders)) { + if (value) { + headers.append(header, value); + } + } + + return headers; +} + +export async function sendRemixResponse( + nodeResponse: NodeResponse +): Promise { + let contentType = nodeResponse.headers.get("Content-Type"); + let isBase64Encoded = isBinaryType(contentType); + let body: string | undefined; + + if (nodeResponse.body) { + if (isBase64Encoded) { + body = await readableStreamToString(nodeResponse.body, "base64"); + } else { + body = await nodeResponse.text(); + } + } + + return { + statusCode: nodeResponse.status, + headers: Object.fromEntries(nodeResponse.headers.entries()), + body: body || '', + isBase64Encoded, + }; +} \ No newline at end of file diff --git a/packages/remix-architect/api/v2.ts b/packages/remix-architect/api/v2.ts new file mode 100644 index 00000000000..a0b2840523a --- /dev/null +++ b/packages/remix-architect/api/v2.ts @@ -0,0 +1,94 @@ +import type { + APIGatewayProxyEventHeaders, + APIGatewayProxyEventV2, + APIGatewayProxyStructuredResultV2 +} from "aws-lambda"; +import { + Headers as NodeHeaders, + readableStreamToString, + Request as NodeRequest +} from "@remix-run/node"; +import type { + Response as NodeResponse, +} from "@remix-run/node"; + +import { isBinaryType } from "../binaryTypes"; + +export function createRemixRequest(event: APIGatewayProxyEventV2): 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(event.rawPath + search, `${scheme}://${host}`); + let isFormData = event.headers["content-type"]?.includes( + "multipart/form-data" + ); + + return new NodeRequest(url.href, { + method: event.requestContext.http.method, + headers: createRemixHeaders(event.headers, event.cookies), + 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 +): Promise { + 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 contentType = nodeResponse.headers.get("Content-Type"); + let isBase64Encoded = isBinaryType(contentType); + let body: string | undefined; + + if (nodeResponse.body) { + if (isBase64Encoded) { + body = await readableStreamToString(nodeResponse.body, "base64"); + } else { + body = await nodeResponse.text(); + } + } + + return { + statusCode: nodeResponse.status, + headers: Object.fromEntries(nodeResponse.headers.entries()), + cookies, + body, + isBase64Encoded, + }; +} \ No newline at end of file diff --git a/packages/remix-architect/index.ts b/packages/remix-architect/index.ts index 92b76da294d..82f460c7315 100644 --- a/packages/remix-architect/index.ts +++ b/packages/remix-architect/index.ts @@ -3,4 +3,4 @@ import "./globals"; export { createArcTableSessionStorage } from "./sessions/arcTableSessionStorage"; export type { GetLoadContextFunction, RequestHandler } from "./server"; -export { createRequestHandler } from "./server"; +export { APIGatewayVersion, createRequestHandler } from "./server"; diff --git a/packages/remix-architect/server.ts b/packages/remix-architect/server.ts index c71c7c56242..85686349db0 100644 --- a/packages/remix-architect/server.ts +++ b/packages/remix-architect/server.ts @@ -4,19 +4,28 @@ import type { Response as NodeResponse, } from "@remix-run/node"; import { - Headers as NodeHeaders, - Request as NodeRequest, - createRequestHandler as createRemixRequestHandler, - readableStreamToString, + createRequestHandler as createRemixRequestHandler } from "@remix-run/node"; import type { - APIGatewayProxyEventHeaders, + APIGatewayProxyEvent, APIGatewayProxyEventV2, - APIGatewayProxyHandlerV2, - APIGatewayProxyStructuredResultV2, + APIGatewayProxyHandler, + APIGatewayProxyHandlerV2 } from "aws-lambda"; -import { isBinaryType } from "./binaryTypes"; +import { + sendRemixResponse as sendRemixResponseV2, + createRemixRequest as createRemixRequestV2 +} from "./api/v2"; +import { + createRemixRequest, + sendRemixResponse +} from "./api/v1"; + +export enum APIGatewayVersion { + v1 = "v1", + v2 = "v2", +} /** * A function that returns the value to use as `context` in route `loader` and @@ -26,10 +35,10 @@ import { isBinaryType } from "./binaryTypes"; * environment/platform-specific values through to your loader/action. */ export type GetLoadContextFunction = ( - event: APIGatewayProxyEventV2 + event: APIGatewayProxyEventV2 | APIGatewayProxyEvent ) => AppLoadContext; -export type RequestHandler = APIGatewayProxyHandlerV2; +export type RequestHandler = APIGatewayProxyHandlerV2 | APIGatewayProxyHandler; /** * Returns a request handler for Architect that serves the response using @@ -39,98 +48,25 @@ export function createRequestHandler({ build, getLoadContext, mode = process.env.NODE_ENV, + apiGatewayVersion = APIGatewayVersion.v2 }: { build: ServerBuild; getLoadContext?: GetLoadContextFunction; mode?: string; + apiGatewayVersion?: APIGatewayVersion; }): RequestHandler { let handleRequest = createRemixRequestHandler(build, mode); - return async (event) => { - let request = createRemixRequest(event); + return async (event: APIGatewayProxyEvent | APIGatewayProxyEventV2 /*, context*/) => { + let request = apiGatewayVersion === APIGatewayVersion.v1 + ? createRemixRequest(event as APIGatewayProxyEvent) + : createRemixRequestV2(event as APIGatewayProxyEventV2); let loadContext = getLoadContext?.(event); let response = (await handleRequest(request, loadContext)) as NodeResponse; - return sendRemixResponse(response); + return apiGatewayVersion === APIGatewayVersion.v1 + ? sendRemixResponse(response) + : sendRemixResponseV2(response); }; -} - -export function createRemixRequest(event: APIGatewayProxyEventV2): 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(event.rawPath + search, `${scheme}://${host}`); - let isFormData = event.headers["content-type"]?.includes( - "multipart/form-data" - ); - - return new NodeRequest(url.href, { - method: event.requestContext.http.method, - headers: createRemixHeaders(event.headers, event.cookies), - 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 -): Promise { - 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 contentType = nodeResponse.headers.get("Content-Type"); - let isBase64Encoded = isBinaryType(contentType); - let body: string | undefined; - - if (nodeResponse.body) { - if (isBase64Encoded) { - body = await readableStreamToString(nodeResponse.body, "base64"); - } else { - body = await nodeResponse.text(); - } - } - - return { - statusCode: nodeResponse.status, - headers: Object.fromEntries(nodeResponse.headers.entries()), - cookies, - body, - isBase64Encoded, - }; -} +} \ No newline at end of file