diff --git a/CHANGES.md b/CHANGES.md index 6998f3cf9c1..c0a8c84eea8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -13,6 +13,8 @@ - Updated .npmrc to minimum required version for webcrypto API (v15) - Abstracted req and res types through the platform - Add base64 encoding primitives to node globals (atob and btoa) +- adds and improves testing around node adapters +- fixes the ability to set multiple Set-Cookie headers This is a history of changes to [Remix](https://remix.run). diff --git a/docs/guides/styling.md b/docs/guides/styling.md index 96ef2e651fb..a9ec6885fa6 100644 --- a/docs/guides/styling.md +++ b/docs/guides/styling.md @@ -222,12 +222,11 @@ Here's some sample code to show how you might use Styled Components with Remix: ); + responseHeaders.set("Content-Type", "text/html") + return new Response("" + markup, { status: responseStatusCode, - headers: { - ...Object.fromEntries(responseHeaders), - "Content-Type": "text/html" - } + headers: responseHeaders }); } ``` diff --git a/fixtures/gists-app/app/entry.server.jsx b/fixtures/gists-app/app/entry.server.jsx index b79eedb9841..2a902ea1764 100644 --- a/fixtures/gists-app/app/entry.server.jsx +++ b/fixtures/gists-app/app/entry.server.jsx @@ -11,11 +11,10 @@ export default function handleRequest( ); + responseHeaders.set("Content-Type", "text/html"); + return new Response("" + markup, { status: responseStatusCode, - headers: { - ...Object.fromEntries(responseHeaders), - "Content-Type": "text/html" - } + headers: responseHeaders }); } diff --git a/fixtures/gists-app/app/routes/index.jsx b/fixtures/gists-app/app/routes/index.jsx index f294cbb373f..b49765e1dd3 100644 --- a/fixtures/gists-app/app/routes/index.jsx +++ b/fixtures/gists-app/app/routes/index.jsx @@ -81,6 +81,9 @@ export default function Index() { Render error in nested route with ErrorBoundary +
  • + Multiple Set Cookie Headers +
  • Preferences diff --git a/fixtures/gists-app/app/routes/multiple-set-cookies.tsx b/fixtures/gists-app/app/routes/multiple-set-cookies.tsx new file mode 100644 index 00000000000..43620d9ea66 --- /dev/null +++ b/fixtures/gists-app/app/routes/multiple-set-cookies.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; +import type { + ActionFunction, + LoaderFunction, + MetaFunction, + RouteComponent +} from "remix"; +import { redirect, json, Form } from "remix"; +import { Headers } from "@remix-run/node"; + +let loader: LoaderFunction = async ({ request }) => { + let headers = new Headers(); + headers.append("Set-Cookie", "foo=bar"); + headers.append("Set-Cookie", "bar=baz"); + return json({}, { headers }); +}; + +let action: ActionFunction = async () => { + let headers = new Headers(); + headers.append("Set-Cookie", "another=one"); + headers.append("Set-Cookie", "how-about=two"); + return redirect("/multiple-set-cookies", { headers }); +}; + +let meta: MetaFunction = () => ({ + title: "Multi Set Cookie Headers" +}); + +let MultipleSetCookiesPage: RouteComponent = () => { + return ( + <> +

    👋

    +
    + +
    + + ); +}; + +export default MultipleSetCookiesPage; +export { action, loader, meta }; diff --git a/fixtures/gists-app/tests/__snapshots__/server-html-test.ts.snap b/fixtures/gists-app/tests/__snapshots__/server-html-test.ts.snap index 36c2481e1b9..9c817ec7742 100644 --- a/fixtures/gists-app/tests/__snapshots__/server-html-test.ts.snap +++ b/fixtures/gists-app/tests/__snapshots__/server-html-test.ts.snap @@ -29,6 +29,7 @@ exports[`the server HTML for the root URL is correct 1`] = ` >Render error in nested route with ErrorBoundary
  • +
  • Multiple Set Cookie Headers
  • Preferences
  • diff --git a/fixtures/gists-app/tests/multiple-set-cookies-test.ts b/fixtures/gists-app/tests/multiple-set-cookies-test.ts new file mode 100644 index 00000000000..84744344911 --- /dev/null +++ b/fixtures/gists-app/tests/multiple-set-cookies-test.ts @@ -0,0 +1,36 @@ +import type { Browser, Page } from "puppeteer"; +import puppeteer from "puppeteer"; + +import { collectResponses } from "./utils"; + +const testPort = 3000; +const testServer = `http://localhost:${testPort}`; + +describe("can set multiple set cookies headers", () => { + let browser: Browser; + let page: Page; + + beforeEach(async () => { + browser = await puppeteer.launch(); + page = await browser.newPage(); + }); + + afterEach(() => browser.close()); + + describe("loader headers", () => { + it("are correct", async () => { + let responses = collectResponses( + page, + url => url.pathname === "/multiple-set-cookies" + ); + + await page.goto(`${testServer}/multiple-set-cookies`); + + expect(responses).toHaveLength(1); + expect(responses[0].headers()["set-cookie"]).toMatchInlineSnapshot(` + "foo=bar + bar=baz" + `); + }); + }); +}); diff --git a/fixtures/tutorial/app/entry.server.tsx b/fixtures/tutorial/app/entry.server.tsx index edf2cc0c15f..1c277b26931 100644 --- a/fixtures/tutorial/app/entry.server.tsx +++ b/fixtures/tutorial/app/entry.server.tsx @@ -12,11 +12,10 @@ export default function handleRequest( ); + responseHeaders.set("Content-Type", "text/html"); + return new Response("" + markup, { status: responseStatusCode, - headers: { - ...Object.fromEntries(responseHeaders), - "Content-Type": "text/html" - } + headers: responseHeaders }); } diff --git a/jest.config.js b/jest.config.js index 8ebf5c0e5ee..716c1827c2d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,12 +5,6 @@ module.exports = { testEnvironment: "node", testMatch: ["/packages/remix-dev/**/*-test.[jt]s?(x)"] }, - { - displayName: "express", - testEnvironment: "node", - testMatch: ["/packages/remix-express/**/*-test.[jt]s?(x)"], - setupFiles: ["/jest/setupNodeGlobals.ts"] - }, { displayName: "node", testEnvironment: "node", @@ -25,6 +19,25 @@ module.exports = { ], setupFiles: ["/jest/setupNodeGlobals.ts"] }, + // Node Adapters + { + displayName: "architect", + testEnvironment: "node", + testMatch: ["/packages/remix-architect/**/*-test.[jt]s?(x)"] + }, + { + displayName: "express", + testEnvironment: "node", + testMatch: ["/packages/remix-express/**/*-test.[jt]s?(x)"], + setupFiles: ["/jest/setupNodeGlobals.ts"] + }, + { + displayName: "vercel", + testEnvironment: "node", + testMatch: ["/packages/remix-vercel/**/*-test.[jt]s?(x)"], + setupFiles: ["/jest/setupNodeGlobals.ts"] + }, + // Fixture Apps { displayName: "gists-app", testEnvironment: "node", diff --git a/packages/remix-architect/__tests__/server-test.ts b/packages/remix-architect/__tests__/server-test.ts new file mode 100644 index 00000000000..45d5c2f95f3 --- /dev/null +++ b/packages/remix-architect/__tests__/server-test.ts @@ -0,0 +1,292 @@ +import lambdaTester from "lambda-tester"; +import { Response, Headers } from "@remix-run/node"; +import { createRequestHandler as createRemixRequestHandler } from "@remix-run/server-runtime"; + +import { + createRemixHeaders, + createRemixRequest, + createRequestHandler +} from "../server"; +import { APIGatewayProxyEventV2 } from "aws-lambda"; + +// 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 +jest.mock("@remix-run/server-runtime"); +let mockedCreateRequestHandler = createRemixRequestHandler as jest.MockedFunction< + typeof createRemixRequestHandler +>; + +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", + 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 createRequestHandler", () => { + describe("basic requests", () => { + afterEach(() => { + mockedCreateRequestHandler.mockReset(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("handles requests", async () => { + mockedCreateRequestHandler.mockImplementation(() => async req => { + return new Response(`URL: ${new URL(req.url).pathname}`); + }); + + await lambdaTester(createRequestHandler({ build: undefined })) + .event(createMockEvent({ rawPath: "/foo/bar" })) + .expectResolve(res => { + expect(res.statusCode).toBe(200); + expect(res.body).toBe("URL: /foo/bar"); + }); + }); + + it("handles status codes", async () => { + mockedCreateRequestHandler.mockImplementation(() => async () => { + return new Response("", { status: 204 }); + }); + + await lambdaTester(createRequestHandler({ build: undefined })) + .event(createMockEvent({ rawPath: "/foo/bar" })) + .expectResolve(res => { + expect(res.statusCode).toBe(204); + }); + }); + + it("sets headers", async () => { + mockedCreateRequestHandler.mockImplementation(() => async () => { + let headers = new Headers(); + headers.append("X-Time-Of-Year", "most wonderful"); + headers.append( + "Set-Cookie", + "first=one; Expires=0; Path=/; HttpOnly; Secure; SameSite=Lax" + ); + headers.append( + "Set-Cookie", + "second=two; MaxAge=1209600; Path=/; HttpOnly; Secure; SameSite=Lax" + ); + headers.append( + "Set-Cookie", + "third=three; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax" + ); + + return new Response("", { headers }); + }); + + await lambdaTester(createRequestHandler({ build: undefined })) + .event(createMockEvent({ rawPath: "/" })) + .expectResolve(res => { + expect(res.statusCode).toBe(200); + expect(res.headers["x-time-of-year"]).toBe("most wonderful"); + expect(res.cookies).toEqual([ + "first=one; Expires=0; Path=/; HttpOnly; Secure; SameSite=Lax", + "second=two; MaxAge=1209600; Path=/; HttpOnly; Secure; SameSite=Lax", + "third=three; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax" + ]); + }); + }); + }); +}); + +describe("architect createRemixHeaders", () => { + describe("creates fetch headers from architect headers", () => { + it("handles empty headers", () => { + expect(createRemixHeaders({}, undefined)).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object {}, + } + `); + }); + + it("handles simple headers", () => { + expect(createRemixHeaders({ "x-foo": "bar" }, undefined)) + .toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "x-foo": Array [ + "bar", + ], + }, + } + `); + }); + + it("handles multiple headers", () => { + expect(createRemixHeaders({ "x-foo": "bar", "x-bar": "baz" }, undefined)) + .toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "x-bar": Array [ + "baz", + ], + "x-foo": Array [ + "bar", + ], + }, + } + `); + }); + + it("handles headers with multiple values", () => { + expect(createRemixHeaders({ "x-foo": "bar, baz" }, undefined)) + .toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "x-foo": Array [ + "bar, baz", + ], + }, + } + `); + }); + + it("handles headers with multiple values and multiple headers", () => { + expect( + createRemixHeaders({ "x-foo": "bar, baz", "x-bar": "baz" }, undefined) + ).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "x-bar": Array [ + "baz", + ], + "x-foo": Array [ + "bar, baz", + ], + }, + } + `); + }); + + it("handles cookies", () => { + expect( + createRemixHeaders({ "x-something-else": "true" }, [ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax" + ]) + ).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "Cookie": Array [ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; Expires=Wed, 21 Oct 2015 07:28:00 GMT; SameSite=Lax", + ], + "x-something-else": Array [ + "true", + ], + }, + } + `); + }); + }); +}); + +describe("architect createRemixRequest", () => { + it("creates a request with the correct headers", () => { + expect( + createRemixRequest( + createMockEvent({ + cookies: ["__session=value"] + }) + ) + ).toMatchInlineSnapshot(` + Request { + "agent": undefined, + "compress": true, + "counter": 0, + "follow": 20, + "size": 0, + "timeout": 0, + Symbol(Body internals): Object { + "body": null, + "disturbed": false, + "error": null, + }, + Symbol(Request internals): Object { + "headers": Headers { + Symbol(map): Object { + "Cookie": Array [ + "__session=value", + ], + "accept": Array [ + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + ], + "accept-encoding": Array [ + "gzip, deflate", + ], + "accept-language": Array [ + "en-US,en;q=0.9", + ], + "host": Array [ + "localhost:3333", + ], + "upgrade-insecure-requests": Array [ + "1", + ], + "user-agent": Array [ + "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", + ], + }, + }, + "method": "GET", + "parsedURL": Url { + "auth": null, + "hash": null, + "host": "localhost:3333", + "hostname": "localhost", + "href": "http://localhost:3333/", + "path": "/", + "pathname": "/", + "port": "3333", + "protocol": "http:", + "query": null, + "search": null, + "slashes": true, + }, + "redirect": "follow", + "signal": null, + }, + } + `); + }); +}); diff --git a/packages/remix-architect/package.json b/packages/remix-architect/package.json index 7c75dab7e21..92db21316b9 100644 --- a/packages/remix-architect/package.json +++ b/packages/remix-architect/package.json @@ -4,11 +4,15 @@ "version": "0.17.5", "repository": "https://github.com/remix-run/packages", "dependencies": { + "@types/aws-lambda": "^8.10.82", "@remix-run/node": "0.17.5", "@remix-run/server-runtime": "0.17.5" }, "peerDependencies": { - "@architect/architect": "^8.3.7", - "@architect/functions": "^3.13.9" + "@architect/architect": "^8.3.7" + }, + "devDependencies": { + "@types/lambda-tester": "^3.6.1", + "lambda-tester": "^4.0.1" } } diff --git a/packages/remix-architect/server.ts b/packages/remix-architect/server.ts index bd0a0780ffc..431b13f99dd 100644 --- a/packages/remix-architect/server.ts +++ b/packages/remix-architect/server.ts @@ -1,7 +1,14 @@ -import type { - Request as ArcRequest, - Response as ArcResponse -} from "@architect/functions"; +import { URL } from "url"; +import { + Headers as NodeHeaders, + Request as NodeRequest, + formatServerError +} from "@remix-run/node"; +import { + APIGatewayProxyEventHeaders, + APIGatewayProxyEventV2, + APIGatewayProxyHandlerV2 +} from "aws-lambda"; import type { AppLoadContext, ServerBuild, @@ -9,7 +16,6 @@ import type { } from "@remix-run/server-runtime"; import { createRequestHandler as createRemixRequestHandler } from "@remix-run/server-runtime"; import type { Response as NodeResponse } from "@remix-run/node"; -import { Request as NodeRequest, formatServerError } from "@remix-run/node"; /** * A function that returns the value to use as `context` in route `loader` and @@ -19,7 +25,7 @@ import { Request as NodeRequest, formatServerError } from "@remix-run/node"; * environment/platform-specific values through to your loader/action. */ export interface GetLoadContextFunction { - (req: ArcRequest): AppLoadContext; + (event: APIGatewayProxyEventV2): AppLoadContext; } export type RequestHandler = ReturnType; @@ -36,41 +42,77 @@ export function createRequestHandler({ build: ServerBuild; getLoadContext: GetLoadContextFunction; mode?: string; -}) { +}): APIGatewayProxyHandlerV2 { let platform: ServerPlatform = { formatServerError }; let handleRequest = createRemixRequestHandler(build, platform, mode); - return async (req: ArcRequest): Promise => { - let request = createRemixRequest(req); + return async (event, _context) => { + let request = createRemixRequest(event); let loadContext = - typeof getLoadContext === "function" ? getLoadContext(req) : undefined; + typeof getLoadContext === "function" ? getLoadContext(event) : undefined; let response = ((await handleRequest( (request as unknown) as Request, loadContext )) as unknown) as NodeResponse; + let cookies: string[] = []; + + // Arc/AWS API Gateway will send back set-cookies outside of response headers. + for (let [key, values] of Object.entries(response.headers.raw())) { + if (key.toLowerCase() === "set-cookie") { + for (let value of values) { + cookies.push(value); + } + } + } + + if (cookies.length) { + response.headers.delete("set-cookie"); + } + return { statusCode: response.status, headers: Object.fromEntries(response.headers), + cookies, body: await response.text() }; }; } -function createRemixRequest(req: ArcRequest): NodeRequest { - let host = req.headers["x-forwarded-host"] || req.headers.host; - let search = req.rawQueryString.length ? "?" + req.rawQueryString : ""; - let url = new URL(req.rawPath + search, `https://${host}`); +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) { + for (let cookie of requestCookies) { + headers.append("Cookie", cookie); + } + } + + return headers; +} + +export function createRemixRequest(event: APIGatewayProxyEventV2): NodeRequest { + let host = event.headers["x-forwarded-host"] || event.headers.host; + let proto = event.requestContext.http.protocol || "https"; + let search = event.rawQueryString.length ? `?${event.rawQueryString}` : ""; + let url = new URL(event.rawPath + search, `${proto}://${host}`); return new NodeRequest(url.toString(), { - method: req.requestContext.http.method, - headers: req.cookies - ? { ...req.headers, Cookie: req.cookies.join(";") } - : req.headers, + method: event.requestContext.http.method, + headers: createRemixHeaders(event.headers, event.cookies), body: - req.body && req.isBase64Encoded - ? Buffer.from(req.body, "base64").toString() - : req.body + event.body && event.isBase64Encoded + ? Buffer.from(event.body, "base64").toString() + : event.body }); } diff --git a/packages/remix-architect/tsconfig.json b/packages/remix-architect/tsconfig.json index 72da2ef0c76..4674d442d62 100644 --- a/packages/remix-architect/tsconfig.json +++ b/packages/remix-architect/tsconfig.json @@ -1,5 +1,6 @@ { - "include": ["../../types/architect__functions.d.ts", "**/*"], + "include": ["**/*"], + "exclude": ["__tests__"], "compilerOptions": { "lib": ["ES2019"], "target": "ES2019", diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts index 94b1d3df0c5..fb974991444 100644 --- a/packages/remix-dev/__tests__/readConfig-test.ts +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -141,6 +141,13 @@ describe("readConfig", () => { "parentId": "root", "path": "methods", }, + "routes/multiple-set-cookies": Object { + "caseSensitive": false, + "file": "routes/multiple-set-cookies.tsx", + "id": "routes/multiple-set-cookies", + "parentId": "root", + "path": "multiple-set-cookies", + }, "routes/prefs": Object { "caseSensitive": false, "file": "routes/prefs.tsx", diff --git a/packages/remix-express/__tests__/server-test.ts b/packages/remix-express/__tests__/server-test.ts index c16cd2f4910..8a0588e2f72 100644 --- a/packages/remix-express/__tests__/server-test.ts +++ b/packages/remix-express/__tests__/server-test.ts @@ -1,7 +1,13 @@ import express from "express"; import supertest from "supertest"; +import { Response, Headers } from "@remix-run/node"; +import { createRequest } from "node-mocks-http"; -import { createRequestHandler } from "../server"; +import { + createRemixHeaders, + createRemixRequest, + createRequestHandler +} from "../server"; import { createRequestHandler as createRemixRequestHandler } from "@remix-run/server-runtime"; @@ -63,15 +69,180 @@ describe("express createRequestHandler", () => { it("sets headers", async () => { mockedCreateRequestHandler.mockImplementation(() => async () => { - return new Response("", { - headers: { "X-Time-Of-Year": "most wonderful" } - }); + const headers = new Headers({ "X-Time-Of-Year": "most wonderful" }); + headers.append( + "Set-Cookie", + "first=one; Expires=0; Path=/; HttpOnly; Secure; SameSite=Lax" + ); + headers.append( + "Set-Cookie", + "second=two; MaxAge=1209600; Path=/; HttpOnly; Secure; SameSite=Lax" + ); + headers.append( + "Set-Cookie", + "third=three; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax" + ); + return new Response("", { headers }); }); let request = supertest(createApp()); let res = await request.get("/"); expect(res.headers["x-time-of-year"]).toBe("most wonderful"); + expect(res.headers["set-cookie"]).toEqual([ + "first=one; Expires=0; Path=/; HttpOnly; Secure; SameSite=Lax", + "second=two; MaxAge=1209600; Path=/; HttpOnly; Secure; SameSite=Lax", + "third=three; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax" + ]); }); }); }); + +describe("express createRemixHeaders", () => { + describe("creates fetch headers from express headers", () => { + it("handles empty headers", () => { + expect(createRemixHeaders({})).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object {}, + } + `); + }); + + it("handles simple headers", () => { + expect(createRemixHeaders({ "x-foo": "bar" })).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "x-foo": Array [ + "bar", + ], + }, + } + `); + }); + + it("handles multiple headers", () => { + expect(createRemixHeaders({ "x-foo": "bar", "x-bar": "baz" })) + .toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "x-bar": Array [ + "baz", + ], + "x-foo": Array [ + "bar", + ], + }, + } + `); + }); + + it("handles headers with multiple values", () => { + expect(createRemixHeaders({ "x-foo": "bar, baz" })) + .toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "x-foo": Array [ + "bar, baz", + ], + }, + } + `); + }); + + it("handles headers with multiple values and multiple headers", () => { + expect(createRemixHeaders({ "x-foo": "bar, baz", "x-bar": "baz" })) + .toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "x-bar": Array [ + "baz", + ], + "x-foo": Array [ + "bar, baz", + ], + }, + } + `); + }); + + it("handles multiple set-cookie headers", () => { + expect( + createRemixHeaders({ + "set-cookie": [ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; MaxAge=3600; SameSite=Lax" + ] + }) + ).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "set-cookie": Array [ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; MaxAge=3600; SameSite=Lax", + ], + }, + } + `); + }); + }); +}); + +describe("express createRemixRequest", () => { + it("creates a request with the correct headers", async () => { + const expressRequest = createRequest({ + url: "/foo/bar", + method: "GET", + protocol: "http", + hostname: "localhost", + headers: { + "Cache-Control": "max-age=300, s-maxage=3600", + Host: "localhost:3000" + } + }); + + expect(createRemixRequest(expressRequest)).toMatchInlineSnapshot(` + Request { + "agent": undefined, + "compress": true, + "counter": 0, + "follow": 20, + "size": 0, + "timeout": 0, + Symbol(Body internals): Object { + "body": null, + "disturbed": false, + "error": null, + }, + Symbol(Request internals): Object { + "headers": Headers { + Symbol(map): Object { + "cache-control": Array [ + "max-age=300, s-maxage=3600", + ], + "host": Array [ + "localhost:3000", + ], + }, + }, + "method": "GET", + "parsedURL": Url { + "auth": null, + "hash": null, + "host": "localhost:3000", + "hostname": "localhost", + "href": "http://localhost:3000/foo/bar", + "path": "/foo/bar", + "pathname": "/foo/bar", + "port": "3000", + "protocol": "http:", + "query": null, + "search": null, + "slashes": true, + }, + "redirect": "follow", + "signal": null, + }, + } + `); + }); +}); diff --git a/packages/remix-express/package.json b/packages/remix-express/package.json index ccbfa45fc30..45adfcbf3ba 100644 --- a/packages/remix-express/package.json +++ b/packages/remix-express/package.json @@ -13,6 +13,7 @@ "devDependencies": { "@types/express": "^4.17.9", "@types/supertest": "^2.0.10", + "node-mocks-http": "^1.10.1", "supertest": "^6.0.1" } } diff --git a/packages/remix-express/server.ts b/packages/remix-express/server.ts index e4d12a206f4..2d2e7f919b1 100644 --- a/packages/remix-express/server.ts +++ b/packages/remix-express/server.ts @@ -71,26 +71,28 @@ export function createRequestHandler({ }; } -function createRemixHeaders( +export function createRemixHeaders( requestHeaders: express.Request["headers"] ): NodeHeaders { - return new NodeHeaders( - Object.keys(requestHeaders).reduce((memo, key) => { - let value = requestHeaders[key]; - - if (typeof value === "string") { - memo[key] = value; - } else if (Array.isArray(value)) { - memo[key] = value.join(","); + let headers = new NodeHeaders(); + + for (let [key, values] of Object.entries(requestHeaders)) { + if (values) { + if (Array.isArray(values)) { + for (const value of values) { + headers.append(key, value); + } + } else { + headers.set(key, values); } + } + } - return memo; - }, {} as { [headerName: string]: string }) - ); + return headers; } -function createRemixRequest(req: express.Request): NodeRequest { - let origin = `${req.protocol}://${req.hostname}`; +export function createRemixRequest(req: express.Request): NodeRequest { + let origin = `${req.protocol}://${req.get("host")}`; let url = new URL(req.url, origin); let init: NodeRequestInit = { @@ -111,8 +113,10 @@ function sendRemixResponse( ): void { res.status(response.status); - for (let [key, value] of response.headers.entries()) { - res.set(key, value); + for (let [key, values] of Object.entries(response.headers.raw())) { + for (const value of values) { + res.append(key, value); + } } if (Buffer.isBuffer(response.body)) { diff --git a/packages/remix-express/tsconfig.json b/packages/remix-express/tsconfig.json index 55305c7bbd0..8040ec74b95 100644 --- a/packages/remix-express/tsconfig.json +++ b/packages/remix-express/tsconfig.json @@ -1,5 +1,6 @@ { - "exclude": ["__tests__/**/*"], + "include": ["**/*"], + "exclude": ["__tests__"], "compilerOptions": { "lib": ["ES2019"], "target": "ES2019", diff --git a/packages/remix-init/templates/_shared_js/app/entry.server.jsx b/packages/remix-init/templates/_shared_js/app/entry.server.jsx index b79eedb9841..2a902ea1764 100644 --- a/packages/remix-init/templates/_shared_js/app/entry.server.jsx +++ b/packages/remix-init/templates/_shared_js/app/entry.server.jsx @@ -11,11 +11,10 @@ export default function handleRequest( ); + responseHeaders.set("Content-Type", "text/html"); + return new Response("" + markup, { status: responseStatusCode, - headers: { - ...Object.fromEntries(responseHeaders), - "Content-Type": "text/html" - } + headers: responseHeaders }); } diff --git a/packages/remix-init/templates/_shared_ts/app/entry.server.tsx b/packages/remix-init/templates/_shared_ts/app/entry.server.tsx index edf2cc0c15f..1c277b26931 100644 --- a/packages/remix-init/templates/_shared_ts/app/entry.server.tsx +++ b/packages/remix-init/templates/_shared_ts/app/entry.server.tsx @@ -12,11 +12,10 @@ export default function handleRequest( ); + responseHeaders.set("Content-Type", "text/html"); + return new Response("" + markup, { status: responseStatusCode, - headers: { - ...Object.fromEntries(responseHeaders), - "Content-Type": "text/html" - } + headers: responseHeaders }); } diff --git a/packages/remix-init/templates/arc/package.json b/packages/remix-init/templates/arc/package.json index 4247d5671be..d677067570f 100644 --- a/packages/remix-init/templates/arc/package.json +++ b/packages/remix-init/templates/arc/package.json @@ -5,7 +5,6 @@ "start": "arc sandbox" }, "dependencies": { - "@architect/functions": "^3.13.3", "@remix-run/architect": "*", "aws-sdk": "2.796.0" } diff --git a/packages/remix-server-runtime/headers.ts b/packages/remix-server-runtime/headers.ts index 3bd683c294a..e4b233e162a 100644 --- a/packages/remix-server-runtime/headers.ts +++ b/packages/remix-server-runtime/headers.ts @@ -1,7 +1,7 @@ +import { splitCookiesString } from "set-cookie-parser"; import type { ServerBuild } from "./build"; import type { ServerRoute } from "./routes"; import type { RouteMatch } from "./routeMatching"; - export function getDocumentHeaders( build: ServerBuild, matches: RouteMatch[], @@ -12,7 +12,6 @@ export function getDocumentHeaders( let loaderHeaders = routeLoaderResponses[index] ? routeLoaderResponses[index].headers : new Headers(); - let headers = new Headers( routeModule.headers ? routeModule.headers({ loaderHeaders, parentHeaders }) @@ -29,61 +28,12 @@ export function getDocumentHeaders( } function prependCookies(parentHeaders: Headers, childHeaders: Headers): void { - if (parentHeaders.has("Set-Cookie")) { - childHeaders.set( - "Set-Cookie", - concatSetCookieHeaders( - parentHeaders.get("Set-Cookie")!, - childHeaders.get("Set-Cookie") - ) - ); - } -} - -/** - * Merges two `Set-Cookie` headers, eliminating duplicates and preserving the - * original ordering. - */ -function concatSetCookieHeaders( - parentHeader: string, - childHeader: string | null -): string { - if (!childHeader || childHeader === parentHeader) { - return parentHeader; - } - - let finalCookies: RawSetCookies = new Map(); - let parentCookies = parseSetCookieHeader(parentHeader); - let childCookies = parseSetCookieHeader(childHeader); - - for (let [name, value] of parentCookies) { - finalCookies.set(name, childCookies.get(name) || value); - } + let parentSetCookieString = parentHeaders.get("Set-Cookie"); - for (let [name, value] of childCookies) { - if (!finalCookies.has(name)) { - finalCookies.set(name, value); - } + if (parentSetCookieString) { + let cookies = splitCookiesString(parentSetCookieString); + cookies.forEach(cookie => { + childHeaders.append("Set-Cookie", cookie); + }); } - - return serializeSetCookieHeader(finalCookies); -} - -type RawSetCookies = Map; - -function parseSetCookieHeader(header: string): RawSetCookies { - return header.split(/\s*,\s*/g).reduce((map, pair) => { - let [name, value] = pair.split("="); - return map.set(name, value); - }, new Map()); -} - -function serializeSetCookieHeader(cookies: RawSetCookies): string { - let pairs: string[] = []; - - for (let [name, value] of cookies) { - pairs.push(name + "=" + value); - } - - return pairs.join(", "); } diff --git a/packages/remix-server-runtime/package.json b/packages/remix-server-runtime/package.json index c1985e79faa..ad8009a87be 100644 --- a/packages/remix-server-runtime/package.json +++ b/packages/remix-server-runtime/package.json @@ -10,6 +10,7 @@ "history": "^5.0.0", "jsesc": "^3.0.1", "react-router-dom": "^6.0.0-beta.0", + "set-cookie-parser": "^2.4.8", "source-map": "^0.7.3" }, "peerDependencies": { @@ -17,7 +18,8 @@ "react-dom": ">=16.8" }, "devDependencies": { - "@types/jsesc": "^2.5.1" + "@types/jsesc": "^2.5.1", + "@types/set-cookie-parser": "^2.4.1" }, "sideEffects": false } diff --git a/packages/remix-vercel/__tests__/server-test.ts b/packages/remix-vercel/__tests__/server-test.ts new file mode 100644 index 00000000000..9d8c16cfd8e --- /dev/null +++ b/packages/remix-vercel/__tests__/server-test.ts @@ -0,0 +1,253 @@ +import supertest from "supertest"; +import { Response, Headers } from "@remix-run/node"; +import { createRequestHandler as createRemixRequestHandler } from "@remix-run/server-runtime"; +import { createRequest } from "node-mocks-http"; +import { createServerWithHelpers } from "@vercel/node/dist/helpers"; +import { VercelRequest } from "@vercel/node"; + +import { + createRemixHeaders, + createRemixRequest, + createRequestHandler +} from "../server"; + +// 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 vercel adapter +jest.mock("@remix-run/server-runtime"); +let mockedCreateRequestHandler = createRemixRequestHandler as jest.MockedFunction< + typeof createRemixRequestHandler +>; + +let consumeEventMock = jest.fn(); +let mockBridge = { consumeEvent: consumeEventMock }; + +function createApp() { + // TODO: get supertest args into the event + consumeEventMock.mockImplementationOnce(() => ({ body: "" })); + let server = createServerWithHelpers( + createRequestHandler({ build: undefined }), + mockBridge + ); + return server; +} + +describe("vercel createRequestHandler", () => { + describe("basic requests", () => { + afterEach(async () => { + mockedCreateRequestHandler.mockReset(); + consumeEventMock.mockClear(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("handles requests", async () => { + mockedCreateRequestHandler.mockImplementation(() => async req => { + return new Response(`URL: ${new URL(req.url).pathname}`); + }); + + let request = supertest(createApp({})); + // note: vercel's createServerWithHelpers requires a x-now-bridge-request-id + let res = await request + .get("/foo/bar") + .set({ "x-now-bridge-request-id": "2" }); + + expect(res.status).toBe(200); + expect(res.text).toBe("URL: /foo/bar"); + }); + + it("handles status codes", async () => { + mockedCreateRequestHandler.mockImplementation(() => async () => { + return new Response("", { status: 204 }); + }); + + let request = supertest(createApp()); + // note: vercel's createServerWithHelpers requires a x-now-bridge-request-id + let res = await request.get("/").set({ "x-now-bridge-request-id": "2" }); + + expect(res.status).toBe(204); + }); + + it("sets headers", async () => { + mockedCreateRequestHandler.mockImplementation(() => async () => { + const headers = new Headers({ "X-Time-Of-Year": "most wonderful" }); + headers.append( + "Set-Cookie", + "first=one; Expires=0; Path=/; HttpOnly; Secure; SameSite=Lax" + ); + headers.append( + "Set-Cookie", + "second=two; MaxAge=1209600; Path=/; HttpOnly; Secure; SameSite=Lax" + ); + headers.append( + "Set-Cookie", + "third=three; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax" + ); + return new Response("", { headers }); + }); + + let request = supertest(createApp()); + // note: vercel's createServerWithHelpers requires a x-now-bridge-request-id + let res = await request.get("/").set({ "x-now-bridge-request-id": "2" }); + + expect(res.headers["x-time-of-year"]).toBe("most wonderful"); + expect(res.headers["set-cookie"]).toEqual([ + "first=one; Expires=0; Path=/; HttpOnly; Secure; SameSite=Lax", + "second=two; MaxAge=1209600; Path=/; HttpOnly; Secure; SameSite=Lax", + "third=three; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Path=/; HttpOnly; Secure; SameSite=Lax" + ]); + }); + }); +}); + +describe("vercel createRemixHeaders", () => { + describe("creates fetch headers from vercel headers", () => { + it("handles empty headers", () => { + expect(createRemixHeaders({})).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object {}, + } + `); + }); + + it("handles simple headers", () => { + expect(createRemixHeaders({ "x-foo": "bar" })).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "x-foo": Array [ + "bar", + ], + }, + } + `); + }); + + it("handles multiple headers", () => { + expect(createRemixHeaders({ "x-foo": "bar", "x-bar": "baz" })) + .toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "x-bar": Array [ + "baz", + ], + "x-foo": Array [ + "bar", + ], + }, + } + `); + }); + + it("handles headers with multiple values", () => { + expect(createRemixHeaders({ "x-foo": "bar, baz" })) + .toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "x-foo": Array [ + "bar, baz", + ], + }, + } + `); + }); + + it("handles headers with multiple values and multiple headers", () => { + expect(createRemixHeaders({ "x-foo": "bar, baz", "x-bar": "baz" })) + .toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "x-bar": Array [ + "baz", + ], + "x-foo": Array [ + "bar, baz", + ], + }, + } + `); + }); + + it("handles multiple set-cookie headers", () => { + expect( + createRemixHeaders({ + "set-cookie": [ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; MaxAge=3600; SameSite=Lax" + ] + }) + ).toMatchInlineSnapshot(` + Headers { + Symbol(map): Object { + "set-cookie": Array [ + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "__other=some_other_value; Path=/; Secure; HttpOnly; MaxAge=3600; SameSite=Lax", + ], + }, + } + `); + }); + }); +}); + +describe("vercel createRemixRequest", () => { + it("creates a request with the correct headers", async () => { + let request = createRequest({ + method: "GET", + url: "/foo/bar", + headers: { + "x-forwarded-host": "localhost:3000", + "x-forwarded-proto": "http", + "Cache-Control": "max-age=300, s-maxage=3600" + } + }) as VercelRequest; + + expect(createRemixRequest(request)).toMatchInlineSnapshot(` + Request { + "agent": undefined, + "compress": true, + "counter": 0, + "follow": 20, + "size": 0, + "timeout": 0, + Symbol(Body internals): Object { + "body": null, + "disturbed": false, + "error": null, + }, + Symbol(Request internals): Object { + "headers": Headers { + Symbol(map): Object { + "cache-control": Array [ + "max-age=300, s-maxage=3600", + ], + "x-forwarded-host": Array [ + "localhost:3000", + ], + "x-forwarded-proto": Array [ + "http", + ], + }, + }, + "method": "GET", + "parsedURL": Url { + "auth": null, + "hash": null, + "host": "localhost:3000", + "hostname": "localhost", + "href": "http://localhost:3000/foo/bar", + "path": "/foo/bar", + "pathname": "/foo/bar", + "port": "3000", + "protocol": "http:", + "query": null, + "search": null, + "slashes": true, + }, + "redirect": "follow", + "signal": null, + }, + } + `); + }); +}); diff --git a/packages/remix-vercel/package.json b/packages/remix-vercel/package.json index 6eff198902f..d8e109550b8 100644 --- a/packages/remix-vercel/package.json +++ b/packages/remix-vercel/package.json @@ -11,6 +11,9 @@ "@vercel/node": "^1.8.3" }, "devDependencies": { - "@vercel/node": "^1.8.3" + "@types/supertest": "^2.0.10", + "@vercel/node": "^1.8.3", + "supertest": "^6.1.5", + "node-mocks-http": "^1.10.1" } } diff --git a/packages/remix-vercel/server.ts b/packages/remix-vercel/server.ts index 39e6fe63207..4cd4998bd5f 100644 --- a/packages/remix-vercel/server.ts +++ b/packages/remix-vercel/server.ts @@ -60,15 +60,12 @@ export function createRequestHandler({ }; } -function createRemixRequest(req: VercelRequest): NodeRequest { - let host = req.headers["x-forwarded-host"] || req.headers["host"]; - // doesn't seem to be available on their req object! - let protocol = req.headers["x-forwarded-proto"] || "https"; - let url = new URL(req.url!, `${protocol}://${host}`); - +export function createRemixHeaders( + requestHeaders: VercelRequest["headers"] +): NodeHeaders { let headers = new NodeHeaders(); - for (let key in req.headers) { - let header = req.headers[key]!; + for (let key in requestHeaders) { + let header = requestHeaders[key]!; // set-cookie is an array (maybe others) if (Array.isArray(header)) { for (let value of header) { @@ -79,9 +76,18 @@ function createRemixRequest(req: VercelRequest): NodeRequest { } } + return headers; +} + +export function createRemixRequest(req: VercelRequest): NodeRequest { + let host = req.headers["x-forwarded-host"] || req.headers["host"]; + // doesn't seem to be available on their req object! + let protocol = req.headers["x-forwarded-proto"] || "https"; + let url = new URL(req.url!, `${protocol}://${host}`); + let init: NodeRequestInit = { method: req.method, - headers + headers: createRemixHeaders(req.headers) }; if (req.method !== "GET" && req.method !== "HEAD") { @@ -92,8 +98,6 @@ function createRemixRequest(req: VercelRequest): NodeRequest { } function sendRemixResponse(res: VercelResponse, response: NodeResponse): void { - res.status(response.status); - let arrays = new Map(); for (let [key, value] of response.headers.entries()) { if (arrays.has(key)) { @@ -107,8 +111,12 @@ function sendRemixResponse(res: VercelResponse, response: NodeResponse): void { } if (Buffer.isBuffer(response.body)) { - res.end(response.body); + return res + .writeHead(response.status, response.headers.raw()) + .end(response.body); } else { - response.body.pipe(res); + return res + .writeHead(response.status, response.headers.raw()) + .end(response.body.pipe(res)); } } diff --git a/packages/remix-vercel/tsconfig.json b/packages/remix-vercel/tsconfig.json index b694acc00af..331e6d5d9d1 100644 --- a/packages/remix-vercel/tsconfig.json +++ b/packages/remix-vercel/tsconfig.json @@ -1,4 +1,6 @@ { + "include": ["**/*"], + "exclude": ["__tests__"], "compilerOptions": { "lib": ["ES2019"], "target": "ES2019", diff --git a/types/architect__functions.d.ts b/types/architect__functions.d.ts deleted file mode 100644 index afa214d28eb..00000000000 --- a/types/architect__functions.d.ts +++ /dev/null @@ -1,132 +0,0 @@ -declare module "@architect/functions" { - /** - * Requests are passed to your handler function in an object, and include the following parameters - */ - - export interface Request { - /** - * Payload version (e.g. 2.0) - */ - version: string; - - /** - * Tuple of HTTP method (GET, POST, PATCH, PUT, or DELETE) and path; URL - * params are surrounded in braces If path is not captured by a specific - * function, routeKey will be $default (and be handled by the get / function) - * - * Example: GET /, GET /shop/{product} - */ - routeKey: string; - - /** - * The absolute path of the request - * - * Example: /, /shop/chocolate-chip-cookies - */ - rawPath: string; - - /** - * Any URL params, if defined in your HTTP function's path (e.g. product in /shop/:product) - * - * Example: { product: 'chocolate-chip-cookies' } - */ - pathParameters?: { [param: string]: string }; - - /** - * String containing query string params of request, if any - * - * Example: ?someParam=someValue, '' (if none) - */ - rawQueryString: string; - - /** - * Any query params if present in the client request - * - * Example: { someParam: someValue } - */ - queryStringParameters?: { [param: string]: string }; - - /** - * Array containing all cookies, if present in client request - * - * Example: [ 'some_cookie_name=some_cookie_value' ] - */ - cookies?: string[]; - - /** - * All client request headers - * - * Example: { 'accept-encoding': 'gzip' } - */ - headers: { [header: string]: string }; - - /** - * Request metadata, including http object containing method and path (should - * you not want to parse the routeKey) - */ - requestContext: { - http: { - method: string; - path: string; - routeKey: string; - }; - }; - - /** - * Contains unparsed, base64-encoded request body - * We suggest parsing with a body parser helper - */ - body?: string; - - /** - * Indicates whether body is base64-encoded binary payload - */ - isBase64Encoded: boolean; - } - - export interface Response { - /** - * Sets the HTTP status code; usually to 200 - */ - statusCode: number; - - /** - * All response headers - */ - headers?: { [header: string]: string }; - - /** - * Contains response body, either as a plain string, or, if binary, a - * base64-encoded buffer - * - * Note: The maximum body payload size is 6MB; files being delivered - * non-dynamically should use the Begin CDN - */ - body?: string; - - /** - * Indicates whether body is base64-encoded binary payload; defaults to false - * - * Required to be set to true if emitting a binary payload - */ - isBase64Encoded?: boolean; - } - - // There's a lot more, but this is all we use - export interface Arc { - http: Http; - } - - type session = { [key: string]: string }; - - interface Http { - session: { - write: (session: session) => Promise; - read: (request: Request) => Promise; - }; - } - - const arc: Arc; - - export default arc; -} diff --git a/yarn.lock b/yarn.lock index 55d5e47f2dc..53984453a7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -943,6 +943,11 @@ minimatch "^3.0.4" strip-json-comments "^3.1.1" +"@extra-number/significant-digits@^1.1.1": + version "1.3.9" + resolved "https://registry.yarnpkg.com/@extra-number/significant-digits/-/significant-digits-1.3.9.tgz#06f3acc4aa688af3ed76bf5f30bca6de9d60883f" + integrity sha512-E5PY/bCwrNqEHh4QS6AQBinLZ+sxM1lT8tsSVYk8VwhWIPp6fCU/BMRVq0V8iJ8LwS3FHmaA4vUzb78s4BIIyA== + "@istanbuljs/load-nyc-config@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -1212,6 +1217,11 @@ dependencies: "@sinonjs/commons" "^1.7.0" +"@types/aws-lambda@*", "@types/aws-lambda@^8.10.82": + version "8.10.82" + resolved "https://registry.yarnpkg.com/@types/aws-lambda/-/aws-lambda-8.10.82.tgz#336062d3270f52d2156eeb7d9e497fd63684572a" + integrity sha512-sJo8pz8hu+OzLRAj7Do2g66zYLizWtB3kGK6K45RWmGW+S54XXMoK3sNbvzKXfndBxYiSVExHoCNiSlt2gPmxw== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": version "7.1.12" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.12.tgz#4d8e9e51eb265552a7e4f1ff2219ab6133bdfb2d" @@ -1400,6 +1410,13 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4= +"@types/lambda-tester@^3.6.1": + version "3.6.1" + resolved "https://registry.yarnpkg.com/@types/lambda-tester/-/lambda-tester-3.6.1.tgz#e09d79a191a4cef6ffa4fc4805eb614c8b15edd6" + integrity sha512-cKg0SdVrtjkiAJcVTGabD+u+eAIdjBJ+qgXiolw0hffW6B/1o+h63EptSUo/BTYpkoRxnHFkHS55y4cWja4xIw== + dependencies: + "@types/aws-lambda" "*" + "@types/lodash.debounce@^4.0.6": version "4.0.6" resolved "https://registry.yarnpkg.com/@types/lodash.debounce/-/lodash.debounce-4.0.6.tgz#c5a2326cd3efc46566c47e4c0aa248dc0ee57d60" @@ -1532,6 +1549,13 @@ "@types/mime" "^1" "@types/node" "*" +"@types/set-cookie-parser@^2.4.1": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@types/set-cookie-parser/-/set-cookie-parser-2.4.1.tgz#49403d3150f6f296da8e51b3e9e7e562eaf105b4" + integrity sha512-N0IWe4vT1w5IOYdN9c9PNpQniHS+qe25W4tj4vfhJDJ9OkvA/YA55YUhaC+HNmMMeLlOSnBW9UMno0qlt5xu3Q== + dependencies: + "@types/node" "*" + "@types/signal-exit@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/signal-exit/-/signal-exit-3.0.0.tgz#75e3b17660cf1f6c6cb8557675b4e680e43bbf36" @@ -1691,7 +1715,7 @@ abab@^2.0.3: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.5.tgz#c0b678fb32d60fc1219c784d6a826fe385aeb79a" integrity sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q== -accepts@~1.3.5, accepts@~1.3.7: +accepts@^1.3.7, accepts@~1.3.5, accepts@~1.3.7: version "1.3.7" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== @@ -2029,6 +2053,11 @@ anymatch@^3.0.3, anymatch@~3.1.1: normalize-path "^3.0.0" picomatch "^2.0.4" +app-root-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/app-root-path/-/app-root-path-3.0.0.tgz#210b6f43873227e18a4b810a032283311555d5ad" + integrity sha512-qMcx+Gy2UZynHjOHOIXPNvpf+9cjvk3cWrBBK7zg4gH9+clobJRb9NGzcT7mQTcV/6Gm/1WelUtqxVXnNlrwcw== + arg@^4.1.0: version "4.1.3" resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" @@ -2691,7 +2720,7 @@ clone-deep@^1.0.0: kind-of "^5.0.0" shallow-clone "^1.0.0" -clone-deep@^4.0.0: +clone-deep@^4.0.0, clone-deep@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== @@ -3030,7 +3059,7 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= -depd@~1.1.2: +depd@^1.1.0, depd@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= @@ -3136,6 +3165,16 @@ domutils@^2.4.3, domutils@^2.4.4: domelementtype "^2.0.1" domhandler "^4.0.0" +dotenv-json@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/dotenv-json/-/dotenv-json-1.0.0.tgz#fc7f672aafea04bed33818733b9f94662332815c" + integrity sha512-jAssr+6r4nKhKRudQ0HOzMskOFFi9+ubXWwmrSGJFgTvpjyPXCXsCsYbjif6mXp7uxA7xY3/LGaiTQukZzSbOQ== + +dotenv@^8.0.0: + version "8.6.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" + integrity sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g== + ecc-jsbn@~0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" @@ -3831,7 +3870,7 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" -fresh@0.5.2: +fresh@0.5.2, fresh@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= @@ -5164,6 +5203,35 @@ koalas@^1.0.2: resolved "https://registry.yarnpkg.com/koalas/-/koalas-1.0.2.tgz#318433f074235db78fae5661a02a8ca53ee295cd" integrity sha1-MYQz8HQjXbePrlZhoCqMpT7ilc0= +lambda-event-mock@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/lambda-event-mock/-/lambda-event-mock-1.5.0.tgz#9cb1ce2bec4271f918d485fef0a327d194dd120f" + integrity sha512-vx1d+vZqi7FF6B3+mAfHnY/6Tlp6BheL2ta0MJS0cIRB3Rc4I5cviHTkiJxHdE156gXx3ZjlQRJrS4puXvtrhA== + dependencies: + "@extra-number/significant-digits" "^1.1.1" + clone-deep "^4.0.1" + uuid "^3.3.3" + vandium-utils "^1.2.0" + +lambda-leak@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/lambda-leak/-/lambda-leak-2.0.0.tgz#771985d3628487f6e885afae2b54510dcfb2cd7e" + integrity sha1-dxmF02KEh/boha+uK1RRDc+yzX4= + +lambda-tester@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lambda-tester/-/lambda-tester-4.0.1.tgz#91f0fc1266cdceae09a5ddbbdbc209c214beb98c" + integrity sha512-ft6XHk84B6/dYEzyI3anKoGWz08xQ5allEHiFYDUzaYTymgVK7tiBkCEbuWx+MFvH7OpFNsJXVtjXm0X8iH3Iw== + dependencies: + app-root-path "^3.0.0" + dotenv "^8.0.0" + dotenv-json "^1.0.0" + lambda-event-mock "^1.5.0" + lambda-leak "^2.0.0" + semver "^6.1.1" + uuid "^3.3.3" + vandium-utils "^2.0.0" + language-subtag-registry@~0.3.2: version "0.3.21" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz#04ac218bea46f04cb039084602c6da9e788dd45a" @@ -5390,7 +5458,7 @@ meow@^7.1.1: type-fest "^0.13.1" yargs-parser "^18.1.3" -merge-descriptors@1.0.1: +merge-descriptors@1.0.1, merge-descriptors@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= @@ -5454,7 +5522,7 @@ mime-types@^2.1.12, mime-types@~2.1.19, mime-types@~2.1.24: dependencies: mime-db "1.46.0" -mime@1.6.0: +mime@1.6.0, mime@^1.3.4: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== @@ -5648,6 +5716,21 @@ node-int64@^0.4.0: resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" integrity sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs= +node-mocks-http@^1.10.1: + version "1.10.1" + resolved "https://registry.yarnpkg.com/node-mocks-http/-/node-mocks-http-1.10.1.tgz#0232e99a5f66f5d2a0216a47251346bf5606d2d0" + integrity sha512-/Nz83kiJ3z+vGqxmlDyv8+L1CJno+gH23DzG3oPH9dBSfMYa5IFVwPgZpXCB2kdiiIu/HoDpZ2BuLqQs7qjFLQ== + dependencies: + accepts "^1.3.7" + depd "^1.1.0" + fresh "^0.5.2" + merge-descriptors "^1.0.1" + methods "^1.1.2" + mime "^1.3.4" + parseurl "^1.3.3" + range-parser "^1.2.0" + type-is "^1.6.18" + node-modules-regexp@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" @@ -5970,7 +6053,7 @@ parse5@^6.0.0, parse5@^6.0.1: resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== -parseurl@~1.3.3: +parseurl@^1.3.3, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== @@ -6312,7 +6395,7 @@ radio-symbol@^2.0.0: ansi-green "^0.1.1" is-windows "^1.0.1" -range-parser@~1.2.1: +range-parser@^1.2.0, range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== @@ -6787,7 +6870,7 @@ semver@7.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== -semver@^6.0.0, semver@^6.3.0: +semver@^6.0.0, semver@^6.1.1, semver@^6.3.0: version "6.3.0" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== @@ -6840,6 +6923,11 @@ set-blocking@^2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= +set-cookie-parser@^2.4.8: + version "2.4.8" + resolved "https://registry.yarnpkg.com/set-cookie-parser/-/set-cookie-parser-2.4.8.tgz#d0da0ed388bc8f24e706a391f9c9e252a13c58b2" + integrity sha512-edRH8mBKEWNVIVMKejNnuJxleqYE/ZSdcT8/Nem9/mmosx12pctd80s2Oy00KNZzrogMZS5mauK2/ymL1bvlvg== + set-getter@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/set-getter/-/set-getter-0.1.0.tgz#d769c182c9d5a51f409145f2fba82e5e86e80376" @@ -7253,6 +7341,14 @@ supertest@^6.0.1: methods "^1.1.2" superagent "^6.1.0" +supertest@^6.1.5: + version "6.1.5" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-6.1.5.tgz#b011c8465281b30c64e9d4be4cb3808b91cd1ec0" + integrity sha512-Is3pFB2TxSFPohDS2tIM8h2JOMvUQwbJ9TvTfsWAm89ZZv1CF4VTLAsQz+5+5S1wOgaMqFqSpFriU15L3e2AXQ== + dependencies: + methods "^1.1.2" + superagent "^6.1.0" + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -7566,7 +7662,7 @@ type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== -type-is@~1.6.17, type-is@~1.6.18: +type-is@^1.6.18, type-is@~1.6.17, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -7715,7 +7811,7 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= -uuid@^3.3.2: +uuid@^3.3.2, uuid@^3.3.3: version "3.4.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== @@ -7747,6 +7843,16 @@ validate-npm-package-license@^3.0.1: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" +vandium-utils@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/vandium-utils/-/vandium-utils-1.2.0.tgz#44735de4b7641a05de59ebe945f174e582db4f59" + integrity sha1-RHNd5LdkGgXeWevpRfF05YLbT1k= + +vandium-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/vandium-utils/-/vandium-utils-2.0.0.tgz#87389bdcb85551aaaba1cc95937ba756589214fa" + integrity sha512-XWbQ/0H03TpYDXk8sLScBEZpE7TbA0CHDL6/Xjt37IBYKLsHUQuBlL44ttAUs9zoBOLFxsW7HehXcuWCNyqOxQ== + vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"