diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..07e6e47 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/node_modules diff --git a/README.md b/README.md new file mode 100644 index 0000000..40685a7 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Welcome to Remix! + +[Remix](https://remix.run) is a web framework that helps you build better websites with React. + +To get started, open a new shell and run: + +```sh +npx create-remix@latest +``` + +Then follow the prompts you see in your terminal. + +For more information about Remix, [visit remix.run](https://remix.run)! diff --git a/__tests__/server-test.ts b/__tests__/server-test.ts new file mode 100644 index 0000000..18437d7 --- /dev/null +++ b/__tests__/server-test.ts @@ -0,0 +1,272 @@ +import supertest from "supertest"; +import { createRequest } from "node-mocks-http"; +import { + createRequestHandler as createRemixRequestHandler, + Response as NodeResponse, +} from "@remix-run/node"; +import { Readable } from "stream"; +import { http } from "@google-cloud/functions-framework"; +// @ts-ignore +import { getTestServer } from "@google-cloud/functions-framework/testing"; + +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 express adapter +jest.mock("@remix-run/node", () => { + let original = jest.requireActual("@remix-run/node"); + return { + ...original, + createRequestHandler: jest.fn(), + }; +}); +let mockedCreateRequestHandler = + createRemixRequestHandler as jest.MockedFunction< + typeof createRemixRequestHandler + >; + +function createApp() { + http( + "remixServer", + createRequestHandler({ + // We don't have a real app to test, but it doesn't matter. We + // won't ever call through to the real createRequestHandler + build: undefined, + }) + ); + return getTestServer("remixServer"); +} + +describe("express 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}`); + }); + + let request = supertest(createApp()); + let res = await request.get("/foo/bar"); + + expect(res.status).toBe(200); + expect(res.text).toBe("URL: /foo/bar"); + expect(res.headers["x-powered-by"]).toBe(undefined); + }); + + it("handles null body", async () => { + mockedCreateRequestHandler.mockImplementation(() => async () => { + return new Response(null, { status: 200 }); + }); + + let request = supertest(createApp()); + let res = await request.get("/"); + + expect(res.status).toBe(200); + }); + + // https://github.com/node-fetch/node-fetch/blob/4ae35388b078bddda238277142bf091898ce6fda/test/response.js#L142-L148 + it("handles body as stream", async () => { + mockedCreateRequestHandler.mockImplementation(() => async () => { + let stream = Readable.from("hello world"); + return new NodeResponse(stream, { status: 200 }) as unknown as Response; + }); + + 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(200); + expect(res.text).toBe("hello world"); + }); + + it("handles status codes", async () => { + mockedCreateRequestHandler.mockImplementation(() => async () => { + return new Response(null, { status: 204 }); + }); + + let request = supertest(createApp()); + let res = await request.get("/"); + + expect(res.status).toBe(204); + }); + + it("sets headers", async () => { + mockedCreateRequestHandler.mockImplementation(() => async () => { + let 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(null, { 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(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 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(query): Array [ + "set-cookie", + "__session=some_value; Path=/; Secure; HttpOnly; MaxAge=7200; SameSite=Lax", + "set-cookie", + "__other=some_other_value; Path=/; Secure; HttpOnly; MaxAge=3600; SameSite=Lax", + ], + Symbol(context): null, + } + `); + }); + }); +}); + +describe("express createRemixRequest", () => { + it("creates a request with the correct headers", async () => { + let 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(` + 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 [ + "cache-control", + "max-age=300, s-maxage=3600", + "host", + "localhost:3000", + ], + Symbol(context): null, + }, + "method": "GET", + "parsedURL": "http://localhost:3000/foo/bar", + "redirect": "follow", + "signal": AbortSignal {}, + }, + } + `); + }); +}); diff --git a/__tests__/setup.ts b/__tests__/setup.ts new file mode 100644 index 0000000..917305a --- /dev/null +++ b/__tests__/setup.ts @@ -0,0 +1,2 @@ +import { installGlobals } from "@remix-run/node"; +installGlobals(); diff --git a/globals.ts b/globals.ts new file mode 100644 index 0000000..917305a --- /dev/null +++ b/globals.ts @@ -0,0 +1,2 @@ +import { installGlobals } from "@remix-run/node"; +installGlobals(); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..fb640bf --- /dev/null +++ b/index.ts @@ -0,0 +1,4 @@ +import "./globals"; + +export type { GetLoadContextFunction, RequestHandler } from "./server"; +export { createRequestHandler } from "./server"; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..5511cdc --- /dev/null +++ b/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "google-cloud-functions", +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..f6e5673 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "remix-google-cloud-functions", + "description": "Google Cloud functions request handler for Remix", + "version": "0.0.1", + "license": "MIT", + "main": "build/index.js", + "repository": { + "type": "git", + "url": "https://github.com/penx/remix", + "directory": "packages/remix-google-cloud-functions" + }, + "bugs": { + "url": "https://github.com/penx/remix/issues" + }, + "scripts": { + "build": "rm -rf build && tsc -b" + }, + "dependencies": { + "@google-cloud/functions-framework": "^3.1.1", + "@remix-run/node": "1.5.1" + }, + "devDependencies": { + "@types/supertest": "^2.0.10", + "node-mocks-http": "^1.10.1", + "supertest": "^6.0.1" + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..3862e3b --- /dev/null +++ b/server.ts @@ -0,0 +1,119 @@ +import type { Request, Response } from "@google-cloud/functions-framework"; +import type { + AppLoadContext, + ServerBuild, + RequestInit as NodeRequestInit, + Response as NodeResponse, +} from "@remix-run/node"; +import { + AbortController, + createRequestHandler as createRemixRequestHandler, + Headers as NodeHeaders, + Request as NodeRequest, + writeReadableStreamToWritable, +} from "@remix-run/node"; + +/** + * A function that returns the value to use as `context` in route `loader` and + * `action` functions. + * + * You can think of this as an escape hatch that allows you to pass + * environment/platform-specific values through to your loader/action, such as + * values that are generated by Express middleware like `req.session`. + */ +export type GetLoadContextFunction = ( + req: Request, + res: Response +) => AppLoadContext; + +export type RequestHandler = (req: Request, res: Response) => Promise; + +/** + * Returns a request handler for Google Cloud functions that serves the response using Remix. + */ +export function createRequestHandler({ + build, + getLoadContext, + mode = process.env.NODE_ENV, +}: { + build: ServerBuild; + getLoadContext?: GetLoadContextFunction; + mode?: string; +}): RequestHandler { + let handleRequest = createRemixRequestHandler(build, mode); + + return async (req: Request, res: Response) => { + let request = createRemixRequest(req); + let loadContext = + typeof getLoadContext === "function" + ? getLoadContext(req, res) + : undefined; + + let response = (await handleRequest(request, loadContext)) as NodeResponse; + + await sendRemixResponse(res, response); + }; +} + +export function createRemixHeaders( + requestHeaders: Request["headers"] +): NodeHeaders { + let headers = new NodeHeaders(); + + for (let [key, values] of Object.entries(requestHeaders)) { + if (values) { + if (Array.isArray(values)) { + for (let value of values) { + headers.append(key, value); + } + } else { + headers.set(key, values); + } + } + } + + return headers; +} + +export function createRemixRequest(req: Request): NodeRequest { + let origin = `${req.protocol}://${req.get("host")}`; + let url = new URL(req.url, origin); + + let controller = new AbortController(); + + req.on("close", () => { + controller.abort(); + }); + + let init: NodeRequestInit = { + method: req.method, + headers: createRemixHeaders(req.headers), + signal: controller.signal, + }; + + if (req.method !== "GET" && req.method !== "HEAD") { + init.body = req.rawBody; + } + + return new NodeRequest(url.href, init); +} + +export async function sendRemixResponse( + res: Response, + nodeResponse: NodeResponse +): Promise { + res.statusMessage = nodeResponse.statusText; + res.status(nodeResponse.status); + + for (let [key, values] of Object.entries(nodeResponse.headers.raw())) { + for (let value of values) { + res.append(key, value); + } + } + + if (nodeResponse.body) { + await writeReadableStreamToWritable(nodeResponse.body, res); + } else { + res.end(); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5da9519 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "exclude": ["__tests__"], + "compilerOptions": { + "lib": ["ES2019", "DOM.Iterable"], + "target": "ES2019", + "module": "CommonJS", + + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "strict": true, + + "declaration": true, + + "outDir": "./build", + "rootDir": "." + } +}