diff --git a/.gitignore b/.gitignore index 1ded76d7f95..65cb08f5d9f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,7 +21,7 @@ yarn-error.log /scripts/deployment-test/yarn.lock /.idea/ -/playground +#/playground /scripts/playground/template.local /scripts/playground/template/build /scripts/playground/template/package-lock.json diff --git a/contributors.yml b/contributors.yml index a00b6884246..dc330e89ef7 100644 --- a/contributors.yml +++ b/contributors.yml @@ -344,6 +344,7 @@ - visormatt - vkrol - 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..de7f7ca6b90 --- /dev/null +++ b/packages/remix-architect/api/v1.ts @@ -0,0 +1,86 @@ +import { + Headers as NodeHeaders, readableStreamToString, + Request as NodeRequest +} from "@remix-run/node"; +import type { + APIGatewayProxyEvent, + APIGatewayProxyEventHeaders, + APIGatewayProxyResult +} from "aws-lambda"; +import type { + // This has been added as a global in node 15+ + AbortController, + Response as NodeResponse, +} from "@remix-run/node"; +import { URLSearchParams } from "url"; + +import { isBinaryType } from "../binaryTypes"; + +export function createRemixRequest( + event: APIGatewayProxyEvent, + abortController?: AbortController +): NodeRequest { + let host = event.headers["x-forwarded-host"] || event.headers.host; + let scheme = process.env.ARC_SANDBOX ? "http" : "https"; + let url = new URL(event.path, `${scheme}://${host}`); + + if ( + event.queryStringParameters && + Object.keys(event.queryStringParameters).length + ) { + url.search = `?${new URLSearchParams(event.queryStringParameters as unknown as Iterable<[string, string]>).toString()}` + } + + 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, + signal: abortController?.signal, + }); +} + +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..273e3f3b05e --- /dev/null +++ b/packages/remix-architect/api/v2.ts @@ -0,0 +1,98 @@ +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, + abortController?: AbortController +): 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, + signal: abortController?.signal, + }); +} + +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/server.ts b/packages/remix-architect/server.ts index 08cc52f59b0..cc4422ea4d9 100644 --- a/packages/remix-architect/server.ts +++ b/packages/remix-architect/server.ts @@ -4,19 +4,23 @@ 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"; /** * A function that returns the value to use as `context` in route `loader` and @@ -26,10 +30,15 @@ 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; + +export enum APIGatewayVersion { + v1 = "v1", + v2 = "v2", +} /** * Returns a request handler for Architect that serves the response using @@ -39,99 +48,26 @@ 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 /*, context*/) => { - 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 = typeof getLoadContext === "function" ? getLoadContext(event) : undefined; 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 diff --git a/playground/remix-lambda/.env b/playground/remix-lambda/.env new file mode 100644 index 00000000000..66df29b9a67 --- /dev/null +++ b/playground/remix-lambda/.env @@ -0,0 +1,2 @@ +NODE_ENV="development" +SESSION_SECRET="super-duper-s3cret" diff --git a/playground/remix-lambda/.env.example b/playground/remix-lambda/.env.example new file mode 100644 index 00000000000..66df29b9a67 --- /dev/null +++ b/playground/remix-lambda/.env.example @@ -0,0 +1,2 @@ +NODE_ENV="development" +SESSION_SECRET="super-duper-s3cret" diff --git a/playground/remix-lambda/.eslintrc.js b/playground/remix-lambda/.eslintrc.js new file mode 100644 index 00000000000..34dd157c2ec --- /dev/null +++ b/playground/remix-lambda/.eslintrc.js @@ -0,0 +1,19 @@ +/** + * @type {import('@types/eslint').Linter.BaseConfig} + */ +module.exports = { + extends: [ + "@remix-run/eslint-config", + "@remix-run/eslint-config/node", + "@remix-run/eslint-config/jest-testing-library", + "prettier", + ], + // we're using vitest which has a very similar API to jest + // (so the linting plugins work nicely), but it we have to explicitly + // set the jest version. + settings: { + jest: { + version: 27, + }, + }, +}; diff --git a/playground/remix-lambda/.github/ISSUE_TEMPLATE/bug_report.yml b/playground/remix-lambda/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000000..55a66fb642b --- /dev/null +++ b/playground/remix-lambda/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,41 @@ +name: 🐛 Bug Report +description: Something is wrong with the Stack. +body: + - type: markdown + attributes: + value: >- + Thank you for helping to improve Remix! + + Our bandwidth on maintaining these stacks is limited. As a team, we're + currently focusing our efforts on Remix itself. The good news is you can + fork and adjust this stack however you'd like and start using it today + as a custom stack. Learn more from + [the Remix Stacks docs](https://remix.run/stacks). + + If you'd still like to report a bug, please fill out this form. We can't + promise a timely response, but hopefully when we have the bandwidth to + work on these stacks again we can take a look. Thanks! + + - type: input + attributes: + label: Have you experienced this bug with the latest version of the template? + validations: + required: true + - type: textarea + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + validations: + required: true + - type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: true + - type: textarea + attributes: + label: Actual Behavior + description: A concise description of what you're experiencing. + validations: + required: true diff --git a/playground/remix-lambda/.github/ISSUE_TEMPLATE/config.yml b/playground/remix-lambda/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..da966200473 --- /dev/null +++ b/playground/remix-lambda/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,21 @@ +blank_issues_enabled: false +contact_links: + - name: Get Help + url: https://github.com/remix-run/remix/discussions/new?category=q-a + about: + If you can't get something to work the way you expect, open a question in + the Remix discussions. + - name: Feature Request + url: https://github.com/remix-run/remix/discussions/new?category=ideas + about: + We appreciate you taking the time to improve Remix with your ideas, but we + use the Remix Discussions for this instead of the issues tab 🙂. + - name: 💬 Remix Discord Channel + url: https://rmx.as/discord + about: Interact with other people using Remix 💿 + - name: 💬 New Updates (Twitter) + url: https://twitter.com/remix_run + about: Stay up to date with Remix news on twitter + - name: 🍿 Remix YouTube Channel + url: https://rmx.as/youtube + about: Are you a tech lead or wanting to learn more about Remix in depth? Checkout the Remix YouTube Channel diff --git a/playground/remix-lambda/.github/PULL_REQUEST_TEMPLATE.md b/playground/remix-lambda/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..024f9d8e239 --- /dev/null +++ b/playground/remix-lambda/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ + diff --git a/playground/remix-lambda/.github/workflows/deploy.yml b/playground/remix-lambda/.github/workflows/deploy.yml new file mode 100644 index 00000000000..8591bce3a7e --- /dev/null +++ b/playground/remix-lambda/.github/workflows/deploy.yml @@ -0,0 +1,167 @@ +name: 🚀 Deploy +on: + push: + branches: + - main + - dev + pull_request: {} + +defaults: + run: + shell: bash + +jobs: + lint: + name: ⬣ ESLint + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 14 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: 🔬 Lint + run: npm run lint + + typecheck: + name: ʦ TypeScript + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 14 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: 🔎 Type check + run: npm run typecheck --if-present + + vitest: + name: ⚡ Vitest + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 14 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: ⚡ Run vitest + run: npm run test -- --coverage + + cypress: + name: ⚫️ Cypress + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: 🏄 Copy test env vars + run: cp .env.example .env + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 14 + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: 🏗 Build + run: npm run build + + - name: 🌳 Cypress run + uses: cypress-io/github-action@v2 + with: + start: npm run dev + wait-on: "http://localhost:8811" + env: + PORT: "8811" + + deploy: + needs: [lint, typecheck, vitest, cypress] + runs-on: ubuntu-latest + + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.9.1 + + - name: ⬇️ Checkout repo + uses: actions/checkout@v3 + + - name: ⎔ Setup node + uses: actions/setup-node@v3 + with: + node-version: 14 + + - name: 👀 Env + run: | + echo "Event name: ${{ github.event_name }}" + echo "Git ref: ${{ github.ref }}" + echo "GH actor: ${{ github.actor }}" + echo "SHA: ${{ github.sha }}" + VER=`node --version`; echo "Node ver: $VER" + VER=`npm --version`; echo "npm ver: $VER" + + - name: 📥 Download deps + uses: bahmutov/npm-install@v1 + with: + useLockFile: false + + - name: 🏗 Build + run: npm run build + + - name: 🛠 Install Arc + run: npm i -g @architect/architect + + - name: 🚀 Staging Deploy + if: github.ref == 'refs/heads/dev' + run: arc deploy --staging --prune + env: + CI: true + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + + - name: 🚀 Production Deploy + if: github.ref == 'refs/heads/main' + run: arc deploy --production --prune + env: + CI: true + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} diff --git a/playground/remix-lambda/.gitignore b/playground/remix-lambda/.gitignore new file mode 100644 index 00000000000..64b5026c96e --- /dev/null +++ b/playground/remix-lambda/.gitignore @@ -0,0 +1,20 @@ +# We don't want lockfiles in stacks, as people could use a different package manager +# This part will be removed by `remix.init` +package-lock.json +yarn.lock +pnpm-lock.yaml +pnpm-lock.yml + +node_modules + +/server/index.js +/public/build +preferences.arc +sam.json +sam.yaml +#.env + +/cypress/screenshots +/cypress/videos + +/app/styles/tailwind.css diff --git a/playground/remix-lambda/.prettierignore b/playground/remix-lambda/.prettierignore new file mode 100644 index 00000000000..b58275e93e7 --- /dev/null +++ b/playground/remix-lambda/.prettierignore @@ -0,0 +1,10 @@ +node_modules + +/server/index.js +/public/build +preferences.arc +sam.json +sam.yaml +.env + +/app/styles/tailwind.css diff --git a/playground/remix-lambda/README.md b/playground/remix-lambda/README.md new file mode 100644 index 00000000000..f7e98f8eb82 --- /dev/null +++ b/playground/remix-lambda/README.md @@ -0,0 +1,128 @@ +# Remix Grunge Stack + +![The Remix Grunge Stack](https://repository-images.githubusercontent.com/463325363/edae4f5b-1a13-47ea-b90c-c05badc2a700) + +Learn more about [Remix Stacks](https://remix.run/stacks). + +``` +npx create-remix --template remix-run/grunge-stack +``` + +## What's in the stack + +- [AWS deployment](https://aws.com) with [Architect](https://arc.codes/) +- Production-ready [DynamoDB Database](https://aws.amazon.com/dynamodb/) +- [GitHub Actions](https://github.com/features/actions) for deploy on merge to production and staging environments +- Email/Password Authentication with [cookie-based sessions](https://remix.run/docs/en/v1/api/remix#createcookiesessionstorage) +- DynamoDB access via [`arc.tables`](https://arc.codes/docs/en/reference/runtime-helpers/node.js#arc.tables) +- Styling with [Tailwind](https://tailwindcss.com/) +- End-to-end testing with [Cypress](https://cypress.io) +- Local third party request mocking with [MSW](https://mswjs.io) +- Unit testing with [Vitest](https://vitest.dev) and [Testing Library](https://testing-library.com) +- Code formatting with [Prettier](https://prettier.io) +- Linting with [ESLint](https://eslint.org) +- Static Types with [TypeScript](https://typescriptlang.org) + +Not a fan of bits of the stack? Fork it, change it, and use `npx create-remix --template your/repo`! Make it your own. + +## Development + +- Validate the app has been set up properly (optional): + + ```sh + npm run validate + ``` + +- Start dev server: + + ```sh + npm run dev + ``` + +This starts your app in development mode, rebuilding assets on file changes. + +### Relevant code: + +This is a pretty simple note-taking app, but it's a good example of how you can build a full stack app with Architect and Remix. The main functionality is creating users, logging in and out, and creating and deleting notes. + +- creating users, and logging in and out [./app/models/user.server.ts](./app/models/user.server.ts) +- user sessions, and verifying them [./app/session.server.ts](./app/session.server.ts) +- creating, and deleting notes [./app/models/note.server.ts](./app/models/note.server.ts) + +The database that comes with `arc sandbox` is an in memory database, so if you restart the server, you'll lose your data. The Staging and Production environments won't behave this way, instead they'll persist the data in DynamoDB between deployments and Lambda executions. + +## Deployment + +This Remix Stack comes with two GitHub Actions that handle automatically deploying your app to production and staging environments. By default, Arc will deploy to the `us-west-2` region, if you wish to deploy to a different region, you'll need to change your [`app.arc`](https://arc.codes/docs/en/reference/project-manifest/aws) + +Prior to your first deployment, you'll need to do a few things: + +- Create a new [GitHub repo](https://repo.new) + +- [Sign up](https://portal.aws.amazon.com/billing/signup#/start) and login to your AWS account + +- Add `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` to [your GitHub repo's secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets). Go to your AWS [security credentials](https://console.aws.amazon.com/iam/home?region=us-west-2#/security_credentials) and click on the "Access keys" tab, and then click "Create New Access Key", then you can copy those and add them to your repo's secrets. + +- Along with your AWS credentials, you'll also need to give your CloudFormation a `SESSION_SECRET` variable of its own for both staging and production environments, as well as an `ARC_APP_SECRET` for Arc itself. + + ```sh + npx arc env --add --env staging ARC_APP_SECRET $(openssl rand -hex 32) + npx arc env --add --env staging SESSION_SECRET $(openssl rand -hex 32) + npx arc env --add --env production ARC_APP_SECRET $(openssl rand -hex 32) + npx arc env --add --env production SESSION_SECRET $(openssl rand -hex 32) + ``` + + If you don't have openssl installed, you can also use [1password](https://1password.com/password-generator) to generate a random secret, just replace `$(openssl rand -hex 32)` with the generated secret. + +## Where do I find my CloudFormation? + +You can find the CloudFormation template that Architect generated for you in the sam.yaml file. + +To find it on AWS, you can search for [CloudFormation](https://console.aws.amazon.com/cloudformation/home) (make sure you're looking at the correct region!) and find the name of your stack (the name is a PascalCased version of what you have in `app.arc`, so by default it's RemixGrungeStackStaging and RemixGrungeStackProduction) that matches what's in `app.arc`, you can find all of your app's resources under the "Resources" tab. + +## GitHub Actions + +We use GitHub Actions for continuous integration and deployment. Anything that gets into the `main` branch will be deployed to production after running tests/build/etc. Anything in the `dev` branch will be deployed to staging. + +## Testing + +### Cypress + +We use Cypress for our End-to-End tests in this project. You'll find those in the `cypress` directory. As you make changes, add to an existing file or create a new file in the `cypress/e2e` directory to test your changes. + +We use [`@testing-library/cypress`](https://testing-library.com/cypress) for selecting elements on the page semantically. + +To run these tests in development, run `npm run test:e2e:dev` which will start the dev server for the app as well as the Cypress client. Make sure the database is running in docker as described above. + +We have a utility for testing authenticated features without having to go through the login flow: + +```ts +cy.login(); +// you are now logged in as a new user +``` + +We also have a utility to auto-delete the user at the end of your test. Just make sure to add this in each test file: + +```ts +afterEach(() => { + cy.cleanupUser(); +}); +``` + +That way, we can keep your local db clean and keep your tests isolated from one another. + +### Vitest + +For lower level tests of utilities and individual components, we use `vitest`. We have DOM-specific assertion helpers via [`@testing-library/jest-dom`](https://testing-library.com/jest-dom). + +### Type Checking + +This project uses TypeScript. It's recommended to get TypeScript set up for your editor to get a really great in-editor experience with type checking and auto-complete. To run type checking across the whole project, run `npm run typecheck`. + +### Linting + +This project uses ESLint for linting. That is configured in `.eslintrc.js`. + +### Formatting + +We use [Prettier](https://prettier.io/) for auto-formatting in this project. It's recommended to install an editor plugin (like the [VSCode Prettier plugin](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)) to get auto-formatting on save. There's also a `npm run format` script you can run to format all files in the project. diff --git a/playground/remix-lambda/app.arc b/playground/remix-lambda/app.arc new file mode 100644 index 00000000000..6e0946a9477 --- /dev/null +++ b/playground/remix-lambda/app.arc @@ -0,0 +1,20 @@ +@app +grunge-stack-template + +@http +/* + method any + src server + +@static + +@tables +user + pk *String + +password + pk *String # userId + +note + pk *String # userId + sk **String # noteId diff --git a/playground/remix-lambda/app/entry.client.tsx b/playground/remix-lambda/app/entry.client.tsx new file mode 100644 index 00000000000..3eec1fd0a02 --- /dev/null +++ b/playground/remix-lambda/app/entry.client.tsx @@ -0,0 +1,4 @@ +import { RemixBrowser } from "@remix-run/react"; +import { hydrate } from "react-dom"; + +hydrate(, document); diff --git a/playground/remix-lambda/app/entry.server.tsx b/playground/remix-lambda/app/entry.server.tsx new file mode 100644 index 00000000000..5afa18235cc --- /dev/null +++ b/playground/remix-lambda/app/entry.server.tsx @@ -0,0 +1,21 @@ +import type { EntryContext } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import { renderToString } from "react-dom/server"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + const markup = renderToString( + + ); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/playground/remix-lambda/app/models/note.server.ts b/playground/remix-lambda/app/models/note.server.ts new file mode 100644 index 00000000000..096c6aa8a49 --- /dev/null +++ b/playground/remix-lambda/app/models/note.server.ts @@ -0,0 +1,80 @@ +import arc from "@architect/functions"; +import cuid from "cuid"; + +import type { User } from "./user.server"; + +export type Note = { + id: ReturnType; + userId: User["id"]; + title: string; + body: string; +}; + +type NoteItem = { + pk: User["id"]; + sk: `note#${Note["id"]}`; +}; + +const skToId = (sk: NoteItem["sk"]): Note["id"] => sk.replace(/^note#/, ""); +const idToSk = (id: Note["id"]): NoteItem["sk"] => `note#${id}`; + +export async function getNote({ + id, + userId, +}: Pick): Promise { + const db = await arc.tables(); + + const result = await await db.note.get({ pk: userId, sk: idToSk(id) }); + + if (result) { + return { + userId: result.pk, + id: result.sk, + title: result.title, + body: result.body, + }; + } + return null; +} + +export async function getNoteListItems({ + userId, +}: Pick): Promise>> { + const db = await arc.tables(); + + const result = await db.note.query({ + KeyConditionExpression: "pk = :pk", + ExpressionAttributeValues: { ":pk": userId }, + }); + + return result.Items.map((n: any) => ({ + title: n.title, + id: skToId(n.sk), + })); +} + +export async function createNote({ + body, + title, + userId, +}: Pick): Promise { + const db = await arc.tables(); + + const result = await db.note.put({ + pk: userId, + sk: `note#${cuid()}`, + title: title, + body: body, + }); + return { + id: skToId(result.sk), + userId: result.pk, + title: result.title, + body: result.body, + }; +} + +export async function deleteNote({ id, userId }: Pick) { + const db = await arc.tables(); + return db.note.delete({ pk: userId, sk: idToSk(id) }); +} diff --git a/playground/remix-lambda/app/models/user.server.ts b/playground/remix-lambda/app/models/user.server.ts new file mode 100644 index 00000000000..fd0b06545bd --- /dev/null +++ b/playground/remix-lambda/app/models/user.server.ts @@ -0,0 +1,81 @@ +import arc from "@architect/functions"; +import bcrypt from "bcryptjs"; +import invariant from "tiny-invariant"; + +export type User = { id: `email#${string}`; email: string }; +export type Password = { password: string }; + +export async function getUserById(id: User["id"]): Promise { + const db = await arc.tables(); + const result = await db.user.query({ + KeyConditionExpression: "pk = :pk", + ExpressionAttributeValues: { ":pk": id }, + }); + + const [record] = result.Items; + if (record) return { id: record.pk, email: record.email }; + return null; +} + +export async function getUserByEmail(email: User["email"]) { + return getUserById(`email#${email}`); +} + +async function getUserPasswordByEmail(email: User["email"]) { + const db = await arc.tables(); + const result = await db.password.query({ + KeyConditionExpression: "pk = :pk", + ExpressionAttributeValues: { ":pk": `email#${email}` }, + }); + + const [record] = result.Items; + + if (record) return { hash: record.password }; + return null; +} + +export async function createUser( + email: User["email"], + password: Password["password"] +) { + const hashedPassword = await bcrypt.hash(password, 10); + const db = await arc.tables(); + await db.password.put({ + pk: `email#${email}`, + password: hashedPassword, + }); + + await db.user.put({ + pk: `email#${email}`, + email, + }); + + const user = await getUserByEmail(email); + invariant(user, `User not found after being created. This should not happen`); + + return user; +} + +export async function deleteUser(email: User["email"]) { + const db = await arc.tables(); + await db.password.delete({ pk: `email#${email}` }); + await db.user.delete({ pk: `email#${email}` }); +} + +export async function verifyLogin( + email: User["email"], + password: Password["password"] +) { + const userPassword = await getUserPasswordByEmail(email); + + if (!userPassword) { + return undefined; + } + + const isValid = await bcrypt.compare(password, userPassword.hash); + if (!isValid) { + return undefined; + } + + return getUserByEmail(email); +} diff --git a/playground/remix-lambda/app/root.tsx b/playground/remix-lambda/app/root.tsx new file mode 100644 index 00000000000..7f93c79a6e4 --- /dev/null +++ b/playground/remix-lambda/app/root.tsx @@ -0,0 +1,58 @@ +import type { + LinksFunction, + LoaderFunction, + MetaFunction, +} from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "@remix-run/react"; + +import tailwindStylesheetUrl from "./styles/tailwind.css"; +import { getUser } from "./session.server"; + +export const links: LinksFunction = () => { + return [ + { rel: "stylesheet", href: tailwindStylesheetUrl }, + // NOTE: Architect deploys the public directory to /_static/ + { rel: "icon", href: "/_static/favicon.ico" }, + ]; +}; + +export const meta: MetaFunction = () => ({ + charset: "utf-8", + title: "Remix Notes", + viewport: "width=device-width,initial-scale=1", +}); + +type LoaderData = { + user: Awaited>; +}; + +export const loader: LoaderFunction = async ({ request }) => { + return json({ + user: await getUser(request), + }); +}; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} diff --git a/playground/remix-lambda/app/routes/index.tsx b/playground/remix-lambda/app/routes/index.tsx new file mode 100644 index 00000000000..eb13f7dbc8a --- /dev/null +++ b/playground/remix-lambda/app/routes/index.tsx @@ -0,0 +1,138 @@ +import { Link } from "@remix-run/react"; + +import { useOptionalUser } from "~/utils"; + +export default function Index() { + const user = useOptionalUser(); + return ( +
+
+
+
+
+ Nirvana playing on stage with Kurt's jagstang guitar +
+
+
+

+ + Grunge Stack + +

+

+ Check the README.md file for instructions on how to get this + project deployed. +

+
+ {user ? ( + + View Notes for {user.email} + + ) : ( +
+ + Sign up + + + Log In + +
+ )} +
+ + Remix + +
+
+
+ +
+
+ {[ + { + src: "https://user-images.githubusercontent.com/1500684/157991167-651c8fc5-2f72-4afa-94d8-2520ecbc5ebc.svg", + alt: "AWS", + href: "https://aws.com", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157991935-26c0d587-b866-49f5-af34-8f04be1c9df2.svg", + alt: "DynamoDB", + href: "https://aws.amazon.com/dynamodb/", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157990874-31f015c3-2af7-4669-9d61-519e5ecfdea6.svg", + alt: "Architect", + href: "https://arc.codes", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg", + alt: "Tailwind", + href: "https://tailwindcss.com", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg", + alt: "Cypress", + href: "https://www.cypress.io", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg", + alt: "MSW", + href: "https://mswjs.io", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg", + alt: "Vitest", + href: "https://vitest.dev", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png", + alt: "Testing Library", + href: "https://testing-library.com", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg", + alt: "Prettier", + href: "https://prettier.io", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg", + alt: "ESLint", + href: "https://eslint.org", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg", + alt: "TypeScript", + href: "https://typescriptlang.org", + }, + ].map((img) => ( + + {img.alt} + + ))} +
+
+
+
+ ); +} diff --git a/playground/remix-lambda/app/routes/join.tsx b/playground/remix-lambda/app/routes/join.tsx new file mode 100644 index 00000000000..fb0df091e5b --- /dev/null +++ b/playground/remix-lambda/app/routes/join.tsx @@ -0,0 +1,177 @@ +import type { + ActionFunction, + LoaderFunction, + MetaFunction, +} from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; +import * as React from "react"; + +import { getUserId, createUserSession } from "~/session.server"; + +import { createUser, getUserByEmail } from "~/models/user.server"; +import { safeRedirect, validateEmail } from "~/utils"; + +export const loader: LoaderFunction = async ({ request }) => { + const userId = await getUserId(request); + if (userId) return redirect("/"); + return json({}); +}; + +interface ActionData { + errors: { + email?: string; + password?: string; + }; +} + +export const action: ActionFunction = async ({ request }) => { + const formData = await request.formData(); + const email = formData.get("email"); + const password = formData.get("password"); + const redirectTo = safeRedirect(formData.get("redirectTo"), "/"); + + if (!validateEmail(email)) { + return json( + { errors: { email: "Email is invalid" } }, + { status: 400 } + ); + } + + if (typeof password !== "string") { + return json( + { errors: { password: "Password is required" } }, + { status: 400 } + ); + } + + if (password.length < 8) { + return json( + { errors: { password: "Password is too short" } }, + { status: 400 } + ); + } + + const existingUser = await getUserByEmail(email); + if (existingUser) { + return json( + { errors: { email: "A user already exists with this email" } }, + { status: 400 } + ); + } + + const user = await createUser(email, password); + + return createUserSession({ + request, + userId: user.id, + remember: false, + redirectTo, + }); +}; + +export const meta: MetaFunction = () => { + return { + title: "Sign Up", + }; +}; + +export default function Join() { + const [searchParams] = useSearchParams(); + const redirectTo = searchParams.get("redirectTo") ?? undefined; + const actionData = useActionData() as ActionData; + const emailRef = React.useRef(null); + const passwordRef = React.useRef(null); + + React.useEffect(() => { + if (actionData?.errors?.email) { + emailRef.current?.focus(); + } else if (actionData?.errors?.password) { + passwordRef.current?.focus(); + } + }, [actionData]); + + return ( +
+
+
+
+ +
+ + {actionData?.errors?.email && ( +
+ {actionData.errors.email} +
+ )} +
+
+ +
+ +
+ + {actionData?.errors?.password && ( +
+ {actionData.errors.password} +
+ )} +
+
+ + + +
+
+ Already have an account?{" "} + + Log in + +
+
+
+
+
+ ); +} diff --git a/playground/remix-lambda/app/routes/login.tsx b/playground/remix-lambda/app/routes/login.tsx new file mode 100644 index 00000000000..48d1e022058 --- /dev/null +++ b/playground/remix-lambda/app/routes/login.tsx @@ -0,0 +1,190 @@ +import type { + ActionFunction, + LoaderFunction, + MetaFunction, +} from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, Link, useActionData, useSearchParams } from "@remix-run/react"; +import * as React from "react"; + +import { createUserSession, getUserId } from "~/session.server"; +import { verifyLogin } from "~/models/user.server"; +import { safeRedirect, validateEmail } from "~/utils"; + +export const loader: LoaderFunction = async ({ request }) => { + const userId = await getUserId(request); + if (userId) return redirect("/"); + return json({}); +}; + +interface ActionData { + errors?: { + email?: string; + password?: string; + }; +} + +export const action: ActionFunction = async ({ request }) => { + const formData = await request.formData(); + const email = formData.get("email"); + const password = formData.get("password"); + const redirectTo = safeRedirect(formData.get("redirectTo")); + const remember = formData.get("remember"); + + if (!validateEmail(email)) { + return json( + { errors: { email: "Email is invalid" } }, + { status: 400 } + ); + } + + if (typeof password !== "string") { + return json( + { errors: { password: "Password is required" } }, + { status: 400 } + ); + } + + if (password.length < 8) { + return json( + { errors: { password: "Password is too short" } }, + { status: 400 } + ); + } + + const user = await verifyLogin(email, password); + + if (!user) { + return json( + { errors: { email: "Invalid email or password" } }, + { status: 400 } + ); + } + + return createUserSession({ + request, + userId: user.id, + remember: remember === "on" ? true : false, + redirectTo, + }); +}; + +export const meta: MetaFunction = () => { + return { + title: "Login", + }; +}; + +export default function LoginPage() { + const [searchParams] = useSearchParams(); + const redirectTo = searchParams.get("redirectTo") || "/notes"; + const actionData = useActionData() as ActionData; + const emailRef = React.useRef(null); + const passwordRef = React.useRef(null); + + React.useEffect(() => { + if (actionData?.errors?.email) { + emailRef.current?.focus(); + } else if (actionData?.errors?.password) { + passwordRef.current?.focus(); + } + }, [actionData]); + + return ( +
+
+
+
+ +
+ + {actionData?.errors?.email && ( +
+ {actionData.errors.email} +
+ )} +
+
+ +
+ +
+ + {actionData?.errors?.password && ( +
+ {actionData.errors.password} +
+ )} +
+
+ + + +
+
+ + +
+
+ Don't have an account?{" "} + + Sign up + +
+
+
+
+
+ ); +} diff --git a/playground/remix-lambda/app/routes/logout.tsx b/playground/remix-lambda/app/routes/logout.tsx new file mode 100644 index 00000000000..04ceac2b97c --- /dev/null +++ b/playground/remix-lambda/app/routes/logout.tsx @@ -0,0 +1,12 @@ +import type { ActionFunction, LoaderFunction } from "@remix-run/node"; +import { redirect } from "@remix-run/node"; + +import { logout } from "~/session.server"; + +export const action: ActionFunction = async ({ request }) => { + return logout(request); +}; + +export const loader: LoaderFunction = async () => { + return redirect("/"); +}; diff --git a/playground/remix-lambda/app/routes/notes.tsx b/playground/remix-lambda/app/routes/notes.tsx new file mode 100644 index 00000000000..0860147a405 --- /dev/null +++ b/playground/remix-lambda/app/routes/notes.tsx @@ -0,0 +1,74 @@ +import type { LoaderFunction } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { Form, Link, NavLink, Outlet, useLoaderData } from "@remix-run/react"; + +import { requireUserId } from "~/session.server"; +import { useUser } from "~/utils"; +import { getNoteListItems } from "~/models/note.server"; + +type LoaderData = { + noteListItems: Awaited>; +}; + +export const loader: LoaderFunction = async ({ request }) => { + const userId = await requireUserId(request); + const noteListItems = await getNoteListItems({ userId }); + return json({ noteListItems }); +}; + +export default function NotesPage() { + const data = useLoaderData() as LoaderData; + const user = useUser(); + + return ( +
+
+

+ Notes +

+

{user.email}

+
+ +
+
+ +
+
+ + + New Note + + +
+ + {data.noteListItems.length === 0 ? ( +

No notes yet

+ ) : ( +
    + {data.noteListItems.map((note) => ( +
  1. + + `block border-b p-4 text-xl ${isActive ? "bg-white" : ""}` + } + to={note.id} + > + 📝 {note.title} + +
  2. + ))} +
+ )} +
+ +
+ +
+
+
+ ); +} diff --git a/playground/remix-lambda/app/routes/notes/$noteId.tsx b/playground/remix-lambda/app/routes/notes/$noteId.tsx new file mode 100644 index 00000000000..fe5ee262e99 --- /dev/null +++ b/playground/remix-lambda/app/routes/notes/$noteId.tsx @@ -0,0 +1,69 @@ +import type { ActionFunction, LoaderFunction } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, useCatch, useLoaderData } from "@remix-run/react"; +import invariant from "tiny-invariant"; + +import type { Note } from "~/models/note.server"; +import { deleteNote } from "~/models/note.server"; +import { getNote } from "~/models/note.server"; +import { requireUserId } from "~/session.server"; + +type LoaderData = { + note: Note; +}; + +export const loader: LoaderFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + invariant(params.noteId, "noteId not found"); + + const note = await getNote({ userId, id: params.noteId }); + if (!note) { + throw new Response("Not Found", { status: 404 }); + } + return json({ note }); +}; + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + invariant(params.noteId, "noteId not found"); + + await deleteNote({ userId, id: params.noteId }); + + return redirect("/notes"); +}; + +export default function NoteDetailsPage() { + const data = useLoaderData() as LoaderData; + + return ( +
+

{data.note.title}

+

{data.note.body}

+
+
+ +
+
+ ); +} + +export function ErrorBoundary({ error }: { error: Error }) { + console.error(error); + + return
An unexpected error occurred: {error.message}
; +} + +export function CatchBoundary() { + const caught = useCatch(); + + if (caught.status === 404) { + return
Note not found
; + } + + throw new Error(`Unexpected caught response with status: ${caught.status}`); +} diff --git a/playground/remix-lambda/app/routes/notes/index.tsx b/playground/remix-lambda/app/routes/notes/index.tsx new file mode 100644 index 00000000000..aa858a994d7 --- /dev/null +++ b/playground/remix-lambda/app/routes/notes/index.tsx @@ -0,0 +1,12 @@ +import { Link } from "@remix-run/react"; + +export default function NoteIndexPage() { + return ( +

+ No note selected. Select a note on the left, or{" "} + + create a new note. + +

+ ); +} diff --git a/playground/remix-lambda/app/routes/notes/new.tsx b/playground/remix-lambda/app/routes/notes/new.tsx new file mode 100644 index 00000000000..c80dbb9211b --- /dev/null +++ b/playground/remix-lambda/app/routes/notes/new.tsx @@ -0,0 +1,116 @@ +import type { ActionFunction } from "@remix-run/node"; +import { json, redirect } from "@remix-run/node"; +import { Form, useActionData } from "@remix-run/react"; +import * as React from "react"; + +import { createNote } from "~/models/note.server"; +import { requireUserId } from "~/session.server"; + +type ActionData = { + errors?: { + title?: string; + body?: string; + }; +}; + +export const action: ActionFunction = async ({ request }) => { + const userId = await requireUserId(request); + + const formData = await request.formData(); + const title = formData.get("title"); + const body = formData.get("body"); + + if (typeof title !== "string" || title.length === 0) { + return json( + { errors: { title: "Title is required" } }, + { status: 400 } + ); + } + + if (typeof body !== "string" || body.length === 0) { + return json( + { errors: { body: "Body is required" } }, + { status: 400 } + ); + } + + const note = await createNote({ title, body, userId }); + + return redirect(`/notes/${note.id}`); +}; + +export default function NewNotePage() { + const actionData = useActionData() as ActionData; + const titleRef = React.useRef(null); + const bodyRef = React.useRef(null); + + React.useEffect(() => { + if (actionData?.errors?.title) { + titleRef.current?.focus(); + } else if (actionData?.errors?.body) { + bodyRef.current?.focus(); + } + }, [actionData]); + + return ( +
+
+ + {actionData?.errors?.title && ( +
+ {actionData.errors.title} +
+ )} +
+ +
+