From a89901f6424644063583bfcd736a284954119469 Mon Sep 17 00:00:00 2001 From: Pedro Cattori Date: Mon, 11 Mar 2024 13:06:16 -0400 Subject: [PATCH] copy remix-server-runtime into remix --- packages/remix/.eslintrc.js | 14 + packages/remix/__tests__/.eslintrc.js | 7 + packages/remix/__tests__/cookies-test.ts | 215 ++ packages/remix/__tests__/data-test.ts | 312 +++ packages/remix/__tests__/formData-test.ts | 168 ++ packages/remix/__tests__/handle-error-test.ts | 226 ++ packages/remix/__tests__/handler-test.ts | 31 + packages/remix/__tests__/markup-test.ts | 56 + packages/remix/__tests__/responses-test.ts | 96 + packages/remix/__tests__/serialize-test.ts | 54 + packages/remix/__tests__/server-test.ts | 2167 +++++++++++++++++ packages/remix/__tests__/sessions-test.ts | 356 +++ packages/remix/__tests__/setup.ts | 3 + packages/remix/__tests__/utils.ts | 109 + packages/remix/build.ts | 57 + packages/remix/cookies.ts | 261 ++ packages/remix/crypto.ts | 64 + packages/remix/data.ts | 146 ++ packages/remix/dev.ts | 47 + packages/remix/entry.ts | 42 + packages/remix/errors.ts | 117 + packages/remix/formData.ts | 67 + packages/remix/headers.ts | 99 + packages/remix/index.ts | 93 +- packages/remix/interface.ts | 10 + packages/remix/invariant.ts | 16 + packages/remix/jest.config.js | 5 + packages/remix/jsonify.ts | 261 ++ packages/remix/links.ts | 192 ++ packages/remix/markup.ts | 19 + packages/remix/mode.ts | 16 + packages/remix/package.json | 23 +- packages/remix/reexport.ts | 63 + packages/remix/responses.ts | 211 ++ packages/remix/rollup.config.js | 43 +- packages/remix/routeMatching.ts | 29 + packages/remix/routeModules.ts | 268 ++ packages/remix/routes.ts | 133 + packages/remix/serialize.ts | 177 ++ packages/remix/server.ts | 493 ++++ packages/remix/serverHandoff.ts | 31 + packages/remix/sessions.ts | 314 +++ packages/remix/sessions/cookieStorage.ts | 69 + packages/remix/sessions/memoryStorage.ts | 73 + packages/remix/tsconfig.json | 10 +- packages/remix/typecheck.ts | 15 + packages/remix/upload/errors.ts | 5 + packages/remix/upload/memoryUploadHandler.ts | 50 + packages/remix/warnings.ts | 8 + 49 files changed, 7307 insertions(+), 34 deletions(-) create mode 100644 packages/remix/.eslintrc.js create mode 100644 packages/remix/__tests__/.eslintrc.js create mode 100644 packages/remix/__tests__/cookies-test.ts create mode 100644 packages/remix/__tests__/data-test.ts create mode 100644 packages/remix/__tests__/formData-test.ts create mode 100644 packages/remix/__tests__/handle-error-test.ts create mode 100644 packages/remix/__tests__/handler-test.ts create mode 100644 packages/remix/__tests__/markup-test.ts create mode 100644 packages/remix/__tests__/responses-test.ts create mode 100644 packages/remix/__tests__/serialize-test.ts create mode 100644 packages/remix/__tests__/server-test.ts create mode 100644 packages/remix/__tests__/sessions-test.ts create mode 100644 packages/remix/__tests__/setup.ts create mode 100644 packages/remix/__tests__/utils.ts create mode 100644 packages/remix/build.ts create mode 100644 packages/remix/cookies.ts create mode 100644 packages/remix/crypto.ts create mode 100644 packages/remix/data.ts create mode 100644 packages/remix/dev.ts create mode 100644 packages/remix/entry.ts create mode 100644 packages/remix/errors.ts create mode 100644 packages/remix/formData.ts create mode 100644 packages/remix/headers.ts create mode 100644 packages/remix/interface.ts create mode 100644 packages/remix/invariant.ts create mode 100644 packages/remix/jest.config.js create mode 100644 packages/remix/jsonify.ts create mode 100644 packages/remix/links.ts create mode 100644 packages/remix/markup.ts create mode 100644 packages/remix/mode.ts create mode 100644 packages/remix/reexport.ts create mode 100644 packages/remix/responses.ts create mode 100644 packages/remix/routeMatching.ts create mode 100644 packages/remix/routeModules.ts create mode 100644 packages/remix/routes.ts create mode 100644 packages/remix/serialize.ts create mode 100644 packages/remix/server.ts create mode 100644 packages/remix/serverHandoff.ts create mode 100644 packages/remix/sessions.ts create mode 100644 packages/remix/sessions/cookieStorage.ts create mode 100644 packages/remix/sessions/memoryStorage.ts create mode 100644 packages/remix/typecheck.ts create mode 100644 packages/remix/upload/errors.ts create mode 100644 packages/remix/upload/memoryUploadHandler.ts create mode 100644 packages/remix/warnings.ts diff --git a/packages/remix/.eslintrc.js b/packages/remix/.eslintrc.js new file mode 100644 index 00000000000..944d9469591 --- /dev/null +++ b/packages/remix/.eslintrc.js @@ -0,0 +1,14 @@ +let restrictedGlobalsError = `Node globals are not allowed in this package.`; + +module.exports = { + extends: "../../.eslintrc.js", + rules: { + "no-restricted-globals": [ + "error", + { name: "__dirname", message: restrictedGlobalsError }, + { name: "__filename", message: restrictedGlobalsError }, + { name: "Buffer", message: restrictedGlobalsError }, + ], + "import/no-nodejs-modules": "error", + }, +}; diff --git a/packages/remix/__tests__/.eslintrc.js b/packages/remix/__tests__/.eslintrc.js new file mode 100644 index 00000000000..38472f9b281 --- /dev/null +++ b/packages/remix/__tests__/.eslintrc.js @@ -0,0 +1,7 @@ +/** @type {import('eslint').Linter.Config} */ +module.exports = { + rules: { + "no-restricted-globals": "off", + "import/no-nodejs-modules": "off", + }, +}; diff --git a/packages/remix/__tests__/cookies-test.ts b/packages/remix/__tests__/cookies-test.ts new file mode 100644 index 00000000000..9cce2903978 --- /dev/null +++ b/packages/remix/__tests__/cookies-test.ts @@ -0,0 +1,215 @@ +import { createCookieFactory, isCookie } from "../cookies"; +import type { SignFunction, UnsignFunction } from "../crypto"; + +const sign: SignFunction = async (value, secret) => { + return JSON.stringify({ value, secret }); +}; +const unsign: UnsignFunction = async (signed, secret) => { + try { + let unsigned = JSON.parse(signed); + if (unsigned.secret !== secret) return false; + return unsigned.value; + } catch (e: unknown) { + return false; + } +}; +const createCookie = createCookieFactory({ sign, unsign }); + +function getCookieFromSetCookie(setCookie: string): string { + return setCookie.split(/;\s*/)[0]; +} + +describe("isCookie", () => { + it("returns `true` for Cookie objects", () => { + expect(isCookie(createCookie("my-cookie"))).toBe(true); + }); + + it("returns `false` for non-Cookie objects", () => { + expect(isCookie({})).toBe(false); + expect(isCookie([])).toBe(false); + expect(isCookie("")).toBe(false); + expect(isCookie(true)).toBe(false); + }); +}); + +describe("cookies", () => { + it("parses/serializes empty string values", async () => { + let cookie = createCookie("my-cookie"); + let setCookie = await cookie.serialize(""); + let value = await cookie.parse(getCookieFromSetCookie(setCookie)); + + expect(value).toMatchInlineSnapshot(`""`); + }); + + it("parses/serializes unsigned string values", async () => { + let cookie = createCookie("my-cookie"); + let setCookie = await cookie.serialize("hello world"); + let value = await cookie.parse(getCookieFromSetCookie(setCookie)); + + expect(value).toEqual("hello world"); + }); + + it("parses/serializes unsigned boolean values", async () => { + let cookie = createCookie("my-cookie"); + let setCookie = await cookie.serialize(true); + let value = await cookie.parse(getCookieFromSetCookie(setCookie)); + + expect(value).toBe(true); + }); + + it("parses/serializes signed string values", async () => { + let cookie = createCookie("my-cookie", { + secrets: ["secret1"], + }); + let setCookie = await cookie.serialize("hello michael"); + let value = await cookie.parse(getCookieFromSetCookie(setCookie)); + + expect(value).toMatchInlineSnapshot(`"hello michael"`); + }); + + it("parses/serializes string values containing utf8 characters", async () => { + let cookie = createCookie("my-cookie"); + let setCookie = await cookie.serialize("日本語"); + let value = await cookie.parse(getCookieFromSetCookie(setCookie)); + + expect(value).toBe("日本語"); + }); + + it("fails to parses signed string values with invalid signature", async () => { + let cookie = createCookie("my-cookie", { + secrets: ["secret1"], + }); + let setCookie = await cookie.serialize("hello michael"); + let cookie2 = createCookie("my-cookie", { + secrets: ["secret2"], + }); + let value = await cookie2.parse(getCookieFromSetCookie(setCookie)); + + expect(value).toBe(null); + }); + + it("parses/serializes signed object values", async () => { + let cookie = createCookie("my-cookie", { + secrets: ["secret1"], + }); + let setCookie = await cookie.serialize({ hello: "mjackson" }); + let value = await cookie.parse(getCookieFromSetCookie(setCookie)); + + expect(value).toMatchInlineSnapshot(` + { + "hello": "mjackson", + } + `); + }); + + it("fails to parse signed object values with invalid signature", async () => { + let cookie = createCookie("my-cookie", { + secrets: ["secret1"], + }); + let setCookie = await cookie.serialize({ hello: "mjackson" }); + let cookie2 = createCookie("my-cookie", { + secrets: ["secret2"], + }); + let value = await cookie2.parse(getCookieFromSetCookie(setCookie)); + + expect(value).toBeNull(); + }); + + it("supports secret rotation", async () => { + let cookie = createCookie("my-cookie", { + secrets: ["secret1"], + }); + let setCookie = await cookie.serialize({ hello: "mjackson" }); + let value = await cookie.parse(getCookieFromSetCookie(setCookie)); + + expect(value).toMatchInlineSnapshot(` + { + "hello": "mjackson", + } + `); + + // A new secret enters the rotation... + cookie = createCookie("my-cookie", { + secrets: ["secret2", "secret1"], + }); + + // cookie should still be able to parse old cookies. + let oldValue = await cookie.parse(getCookieFromSetCookie(setCookie)); + expect(oldValue).toMatchObject(value); + + // New Set-Cookie should be different, it uses a differet secret. + let setCookie2 = await cookie.serialize(value); + expect(setCookie).not.toEqual(setCookie2); + }); + + it("makes the default secrets to be an empty array", async () => { + let cookie = createCookie("my-cookie"); + + expect(cookie.isSigned).toBe(false); + + let cookie2 = createCookie("my-cookie2", { + secrets: undefined, + }); + + expect(cookie2.isSigned).toBe(false); + }); + + it("makes the default path of cookies to be /", async () => { + let cookie = createCookie("my-cookie"); + + let setCookie = await cookie.serialize("hello world"); + expect(setCookie).toContain("Path=/"); + + let cookie2 = createCookie("my-cookie2"); + + let setCookie2 = await cookie2.serialize("hello world", { + path: "/about", + }); + expect(setCookie2).toContain("Path=/about"); + }); + + it("supports the Priority attribute", async () => { + let cookie = createCookie("my-cookie"); + + let setCookie = await cookie.serialize("hello world"); + expect(setCookie).not.toContain("Priority"); + + let cookie2 = createCookie("my-cookie2"); + + let setCookie2 = await cookie2.serialize("hello world", { + priority: "high", + }); + expect(setCookie2).toContain("Priority=High"); + }); + + describe("warnings when providing options you may not want to", () => { + let spy = spyConsole(); + + it("warns against using `expires` when creating the cookie instance", async () => { + createCookie("my-cookie", { expires: new Date(Date.now() + 60_000) }); + expect(spy.console).toHaveBeenCalledTimes(1); + expect(spy.console).toHaveBeenCalledWith( + 'The "my-cookie" cookie has an "expires" property set. This will cause the expires value to not be updated when the session is committed. Instead, you should set the expires value when serializing the cookie. You can use `commitSession(session, { expires })` if using a session storage object, or `cookie.serialize("value", { expires })` if you\'re using the cookie directly.' + ); + }); + }); +}); + +function spyConsole() { + // https://github.com/facebook/react/issues/7047 + let spy: any = {}; + + beforeAll(() => { + spy.console = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + beforeEach(() => { + spy.console.mockClear(); + }); + + afterAll(() => { + spy.console.mockRestore(); + }); + + return spy; +} diff --git a/packages/remix/__tests__/data-test.ts b/packages/remix/__tests__/data-test.ts new file mode 100644 index 00000000000..493a9e70725 --- /dev/null +++ b/packages/remix/__tests__/data-test.ts @@ -0,0 +1,312 @@ +import type { ServerBuild } from "../build"; +import { defer } from "../responses"; +import { createRequestHandler } from "../server"; + +describe("loaders", () => { + // so that HTML/Fetch requests are the same, and so redirects don't hang on to + // this param for no reason + it("removes _data from request.url", async () => { + let loader = async ({ request }) => { + return new URL(request.url).search; + }; + + let routeId = "routes/random"; + let build = { + routes: { + [routeId]: { + id: routeId, + path: "/random", + module: { + loader, + }, + }, + }, + entry: { module: {} }, + } as unknown as ServerBuild; + + let handler = createRequestHandler(build); + + let request = new Request( + "http://example.com/random?_data=routes/random&foo=bar", + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + let res = await handler(request); + expect(await res.json()).toMatchInlineSnapshot(`"?foo=bar"`); + }); + + it("sets X-Remix-Response header for returned 2xx response", async () => { + let routeId = "routes/random"; + let build = { + routes: { + [routeId]: { + id: routeId, + path: "/random", + module: { + async loader() { + return new Response("text", { + status: 200, + headers: { "Content-Type": "text/plain" }, + }); + }, + }, + }, + }, + entry: { + module: { + handleError() {}, + }, + }, + } as unknown as ServerBuild; + + let handler = createRequestHandler(build); + + let request = new Request( + "http://example.com/random?_data=routes/random&foo=bar", + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + let res = await handler(request); + expect(res.headers.get("X-Remix-Response")).toBeTruthy(); + expect(res.headers.get("X-Remix-Error")).toBeNull(); + expect(res.headers.get("X-Remix-Catch")).toBeNull(); + expect(res.headers.get("X-Remix-Redirect")).toBeNull(); + }); + + it("sets X-Remix-Response header for returned 2xx defer response", async () => { + let routeId = "routes/random"; + let build = { + routes: { + [routeId]: { + id: routeId, + path: "/random", + module: { + async loader() { + return defer({ lazy: Promise.resolve("hey!") }); + }, + }, + }, + }, + entry: { + module: { + handleError() {}, + }, + }, + } as unknown as ServerBuild; + + let handler = createRequestHandler(build); + + let request = new Request( + "http://example.com/random?_data=routes/random&foo=bar", + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + let res = await handler(request); + expect(res.headers.get("X-Remix-Response")).toBeTruthy(); + expect(res.headers.get("X-Remix-Error")).toBeNull(); + expect(res.headers.get("X-Remix-Catch")).toBeNull(); + expect(res.headers.get("X-Remix-Redirect")).toBeNull(); + }); + + it("sets X-Remix-Redirect header for returned 3xx redirect", async () => { + let routeId = "routes/random"; + let build = { + routes: { + [routeId]: { + id: routeId, + path: "/random", + module: { + async loader() { + return new Response("text", { + status: 302, + headers: { Location: "https://remix.run" }, + }); + }, + }, + }, + }, + entry: { + module: { + handleError() {}, + }, + }, + } as unknown as ServerBuild; + + let handler = createRequestHandler(build); + + let request = new Request( + "http://example.com/random?_data=routes/random&foo=bar", + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + let res = await handler(request); + expect(res.headers.get("X-Remix-Redirect")).toBeTruthy(); + expect(res.headers.get("X-Remix-Error")).toBeNull(); + expect(res.headers.get("X-Remix-Catch")).toBeNull(); + expect(res.headers.get("X-Remix-Response")).toBeNull(); + }); + + it("sets X-Remix-Catch header for throw responses", async () => { + let loader = async ({ request }) => { + throw new Response("null", { + headers: { + "Content-type": "application/json", + }, + }); + }; + + let routeId = "routes/random"; + let build = { + routes: { + [routeId]: { + id: routeId, + path: "/random", + module: { + loader, + }, + }, + }, + entry: { module: {} }, + } as unknown as ServerBuild; + + let handler = createRequestHandler(build); + + let request = new Request( + "http://example.com/random?_data=routes/random&foo=bar", + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + let res = await handler(request); + expect(res.headers.get("X-Remix-Catch")).toBeTruthy(); + expect(res.headers.get("X-Remix-Error")).toBeNull(); + expect(res.headers.get("X-Remix-Redirect")).toBeNull(); + expect(res.headers.get("X-Remix-Response")).toBeNull(); + }); + + it("sets X-Remix-Error header for throw error", async () => { + let routeId = "routes/random"; + let build = { + routes: { + [routeId]: { + id: routeId, + path: "/random", + module: { + async loader() { + throw new Error("broke!"); + }, + }, + }, + }, + entry: { + module: { + handleError() {}, + }, + }, + } as unknown as ServerBuild; + + let handler = createRequestHandler(build); + + let request = new Request( + "http://example.com/random?_data=routes/random&foo=bar", + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + let res = await handler(request); + expect(res.headers.get("X-Remix-Error")).toBeTruthy(); + expect(res.headers.get("X-Remix-Catch")).toBeNull(); + expect(res.headers.get("X-Remix-Redirect")).toBeNull(); + expect(res.headers.get("X-Remix-Response")).toBeNull(); + }); + + it("removes index from request.url", async () => { + let loader = async ({ request }) => { + return new URL(request.url).search; + }; + + let routeId = "routes/random"; + let build = { + routes: { + [routeId]: { + id: routeId, + path: "/random", + module: { + loader, + }, + }, + }, + entry: { module: {} }, + } as unknown as ServerBuild; + + let handler = createRequestHandler(build); + + let request = new Request( + "http://example.com/random?_data=routes/random&index&foo=bar", + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + let res = await handler(request); + expect(await res.json()).toMatchInlineSnapshot(`"?foo=bar"`); + }); + + it("removes index from request.url and keeps other values", async () => { + let loader = async ({ request }) => { + return new URL(request.url).search; + }; + + let routeId = "routes/random"; + let build = { + routes: { + [routeId]: { + id: routeId, + path: "/random", + module: { + loader, + }, + }, + }, + entry: { module: {} }, + } as unknown as ServerBuild; + + let handler = createRequestHandler(build); + + let request = new Request( + "http://example.com/random?_data=routes/random&index&foo=bar&index=test", + { + headers: { + "Content-Type": "application/json", + }, + } + ); + + let res = await handler(request); + expect(await res.json()).toMatchInlineSnapshot(`"?foo=bar&index=test"`); + }); +}); diff --git a/packages/remix/__tests__/formData-test.ts b/packages/remix/__tests__/formData-test.ts new file mode 100644 index 00000000000..430f9e0b3b8 --- /dev/null +++ b/packages/remix/__tests__/formData-test.ts @@ -0,0 +1,168 @@ +import { parseMultipartFormData } from "../formData"; + +class CustomError extends Error { + constructor() { + super("test error"); + } +} + +describe("parseMultipartFormData", () => { + it("can use a custom upload handler", async () => { + let formData = new FormData(); + formData.set("a", "value"); + formData.set("blob", new Blob(["blob".repeat(1000)]), "blob.txt"); + formData.set("file", new File(["file".repeat(1000)], "file.txt")); + + let req = new Request("https://test.com", { + method: "post", + body: formData, + }); + + let parsedFormData = await parseMultipartFormData( + req, + async ({ filename, data, contentType }) => { + let chunks = []; + for await (let chunk of data) { + chunks.push(chunk); + } + if (filename) { + return new File(chunks, filename, { type: contentType }); + } + + return await new Blob(chunks, { type: contentType }).text(); + } + ); + + expect(parsedFormData.get("a")).toBe("value"); + let blob = parsedFormData.get("blob") as Blob; + expect(await blob.text()).toBe("blob".repeat(1000)); + let file = parsedFormData.get("file") as File; + expect(file.name).toBe("file.txt"); + expect(await file.text()).toBe("file".repeat(1000)); + }); + + it("can return undefined", async () => { + let formData = new FormData(); + formData.set("a", "value"); + formData.set("blob", new Blob(["blob".repeat(1000)]), "blob.txt"); + formData.set("file", new File(["file".repeat(1000)], "file.txt")); + + let req = new Request("https://test.com", { + method: "post", + body: formData, + }); + + let parsedFormData = await parseMultipartFormData( + req, + async () => undefined + ); + + expect(parsedFormData.get("a")).toBe(null); + expect(parsedFormData.get("blob")).toBe(null); + expect(parsedFormData.get("file")).toBe(null); + }); + + it("can throw errors in upload handlers", async () => { + let formData = new FormData(); + formData.set("blob", new Blob(["blob"]), "blob.txt"); + + let req = new Request("https://test.com", { + method: "post", + body: formData, + }); + + let error: Error; + try { + await parseMultipartFormData(req, async () => { + throw new CustomError(); + }); + throw new Error("should have thrown"); + } catch (err) { + error = err; + } + expect(error).toBeInstanceOf(CustomError); + expect(error.message).toBe("test error"); + }); + + describe("stream should propagate events", () => { + it("when controller errors", async () => { + let formData = new FormData(); + formData.set("a", "value"); + formData.set("blob", new Blob(["blob".repeat(1000)]), "blob.txt"); + formData.set("file", new File(["file".repeat(1000)], "file.txt")); + + let underlyingRequest = new Request("https://test.com", { + method: "post", + body: formData, + }); + let underlyingBody = await underlyingRequest.text(); + + let encoder = new TextEncoder(); + let body = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode(underlyingBody.slice(0, underlyingBody.length / 2)) + ); + controller.error(new CustomError()); + }, + }); + + let req = new Request("https://test.com", { + method: "post", + body, + headers: underlyingRequest.headers, + }); + + let error: Error; + try { + await parseMultipartFormData(req, async () => undefined); + throw new Error("should have thrown"); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(CustomError); + expect(error.message).toBe("test error"); + }); + + it("when controller is closed", async () => { + let formData = new FormData(); + formData.set("a", "value"); + formData.set("blob", new Blob(["blob".repeat(1000)]), "blob.txt"); + formData.set("file", new File(["file".repeat(1000)], "file.txt")); + + let underlyingRequest = new Request("https://test.com", { + method: "post", + body: formData, + }); + let underlyingBody = await underlyingRequest.text(); + + let encoder = new TextEncoder(); + let body = new ReadableStream({ + start(controller) { + controller.enqueue( + encoder.encode(underlyingBody.slice(0, underlyingBody.length / 2)) + ); + controller.close(); + }, + }); + + let req = new Request("https://test.com", { + method: "post", + body, + headers: underlyingRequest.headers, + }); + + let error: Error; + try { + await parseMultipartFormData(req, async () => undefined); + throw new Error("should have thrown"); + } catch (err) { + error = err; + } + + expect(error).toBeInstanceOf(Error); + expect(error.message).toMatch("malformed multipart-form data"); + }); + }); +}); diff --git a/packages/remix/__tests__/handle-error-test.ts b/packages/remix/__tests__/handle-error-test.ts new file mode 100644 index 00000000000..439823b5bdb --- /dev/null +++ b/packages/remix/__tests__/handle-error-test.ts @@ -0,0 +1,226 @@ +import { UNSAFE_ErrorResponseImpl as ErrorResponseImpl } from "@remix-run/router"; + +import type { ServerBuild } from "../build"; +import { createRequestHandler } from "../server"; +import { json } from "../responses"; + +function getHandler(routeModule = {}, entryServerModule = {}) { + let routeId = "root"; + let handleErrorSpy = jest.fn(); + let build = { + routes: { + [routeId]: { + id: routeId, + path: "/", + module: { + default() {}, + ...routeModule, + }, + }, + }, + entry: { + module: { + handleError: handleErrorSpy, + default() {}, + ...entryServerModule, + }, + }, + } as unknown as ServerBuild; + + return { + handler: createRequestHandler(build), + handleErrorSpy, + }; +} + +describe("handleError", () => { + describe("document request", () => { + it("provides user-thrown Error", async () => { + let error = new Error("💥"); + let { handler, handleErrorSpy } = getHandler({ + loader() { + throw error; + }, + }); + let request = new Request("http://example.com/"); + await handler(request); + expect(handleErrorSpy).toHaveBeenCalledWith(error, { + request, + params: {}, + context: {}, + }); + }); + + it("provides router-thrown ErrorResponse", async () => { + let { handler, handleErrorSpy } = getHandler({}); + let request = new Request("http://example.com/", { method: "post" }); + await handler(request); + expect(handleErrorSpy).toHaveBeenCalledWith( + new ErrorResponseImpl( + 405, + "Method Not Allowed", + new Error( + 'You made a POST request to "/" but did not provide an `action` for route "root", so there is no way to handle the request.' + ), + true + ), + { + request, + params: {}, + context: {}, + } + ); + }); + + it("provides render-thrown Error", async () => { + let { handler, handleErrorSpy } = getHandler(undefined, { + default() { + throw new Error("Render error"); + }, + }); + let request = new Request("http://example.com/"); + await handler(request); + expect(handleErrorSpy).toHaveBeenCalledWith(new Error("Render error"), { + request, + params: {}, + context: {}, + }); + }); + + it("does not provide user-thrown Responses to handleError", async () => { + let { handler, handleErrorSpy } = getHandler({ + loader() { + throw json( + { message: "not found" }, + { status: 404, statusText: "Not Found" } + ); + }, + }); + let request = new Request("http://example.com/"); + await handler(request); + expect(handleErrorSpy).not.toHaveBeenCalled(); + }); + }); + + describe("data request", () => { + it("provides user-thrown Error", async () => { + let error = new Error("💥"); + let { handler, handleErrorSpy } = getHandler({ + loader() { + throw error; + }, + }); + let request = new Request("http://example.com/?_data=root"); + await handler(request); + expect(handleErrorSpy).toHaveBeenCalledWith(error, { + request, + params: {}, + context: {}, + }); + }); + + it("provides router-thrown ErrorResponse", async () => { + let { handler, handleErrorSpy } = getHandler({}); + let request = new Request("http://example.com/?_data=root", { + method: "post", + }); + await handler(request); + expect(handleErrorSpy).toHaveBeenCalledWith( + new ErrorResponseImpl( + 405, + "Method Not Allowed", + new Error( + 'You made a POST request to "/" but did not provide an `action` for route "root", so there is no way to handle the request.' + ), + true + ), + { + request, + params: {}, + context: {}, + } + ); + }); + + it("does not provide user-thrown Responses to handleError", async () => { + let { handler, handleErrorSpy } = getHandler({ + loader() { + throw json( + { message: "not found" }, + { status: 404, statusText: "Not Found" } + ); + }, + }); + let request = new Request("http://example.com/?_data=root"); + await handler(request); + expect(handleErrorSpy).not.toHaveBeenCalled(); + }); + }); + + describe("resource request", () => { + it("provides user-thrown Error", async () => { + let error = new Error("💥"); + let { handler, handleErrorSpy } = getHandler({ + loader() { + throw error; + }, + default: null, + }); + let request = new Request("http://example.com/"); + await handler(request); + expect(handleErrorSpy).toHaveBeenCalledWith(error, { + request, + params: {}, + context: {}, + }); + }); + + it("provides router-thrown ErrorResponse", async () => { + let { handler, handleErrorSpy } = getHandler({ default: null }); + let request = new Request("http://example.com/", { + method: "post", + }); + await handler(request); + expect(handleErrorSpy).toHaveBeenCalledWith( + new ErrorResponseImpl( + 405, + "Method Not Allowed", + new Error( + 'You made a POST request to "/" but did not provide an `action` for route "root", so there is no way to handle the request.' + ), + true + ), + { + request, + params: {}, + context: {}, + } + ); + }); + + it("does not provide user-thrown Responses to handleError", async () => { + let { handler, handleErrorSpy } = getHandler({ + loader() { + throw json( + { message: "not found" }, + { status: 404, statusText: "Not Found" } + ); + }, + default: null, + }); + let request = new Request("http://example.com/"); + await handler(request); + expect(handleErrorSpy).not.toHaveBeenCalled(); + }); + }); +}); + +// let request = new Request( +// "http://example.com/random?_data=routes/random&foo=bar", +// { +// method: "post", +// // headers: { +// // "Content-Type": "application/json", +// // }, +// } +// ); diff --git a/packages/remix/__tests__/handler-test.ts b/packages/remix/__tests__/handler-test.ts new file mode 100644 index 00000000000..66ba9a1bfcf --- /dev/null +++ b/packages/remix/__tests__/handler-test.ts @@ -0,0 +1,31 @@ +import { json } from "../responses"; +import { createRequestHandler } from "../server"; + +describe("createRequestHandler", () => { + it("retains request headers when stripping body off for loaders", async () => { + let handler = createRequestHandler({ + routes: { + root: { + id: "routes/test", + path: "/test", + module: { + loader: ({ request }) => json(request.headers.get("X-Foo")), + } as any, + }, + }, + assets: {} as any, + entry: { module: {} as any }, + }); + + let response = await handler( + new Request("http://.../test", { + headers: { + "X-Foo": "bar", + }, + signal: new AbortController().signal, + }) + ); + + expect(await response.json()).toBe("bar"); + }); +}); diff --git a/packages/remix/__tests__/markup-test.ts b/packages/remix/__tests__/markup-test.ts new file mode 100644 index 00000000000..0457d902f2a --- /dev/null +++ b/packages/remix/__tests__/markup-test.ts @@ -0,0 +1,56 @@ +import vm from "vm"; + +import { escapeHtml } from "../markup"; + +describe("escapeHtml", () => { + // These tests are based on https://github.com/zertosh/htmlescape/blob/3e6cf0614dd0f778fd0131e69070b77282150c15/test/htmlescape-test.js + // License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE + + test("with angle brackets should escape", () => { + let evilObj = { evil: "" }; + expect(escapeHtml(JSON.stringify(evilObj))).toBe( + '{"evil":"\\u003cscript\\u003e\\u003c/script\\u003e"}' + ); + }); + + test("with angle brackets should parse back", () => { + let evilObj = { evil: "" }; + expect(JSON.parse(escapeHtml(JSON.stringify(evilObj)))).toMatchObject( + evilObj + ); + }); + + test("with ampersands should escape", () => { + let evilObj = { evil: "&" }; + expect(escapeHtml(JSON.stringify(evilObj))).toBe('{"evil":"\\u0026"}'); + }); + + test("with ampersands should parse back", () => { + let evilObj = { evil: "&" }; + expect(JSON.parse(escapeHtml(JSON.stringify(evilObj)))).toMatchObject( + evilObj + ); + }); + + test('with "LINE SEPARATOR" and "PARAGRAPH SEPARATOR" should escape', () => { + let evilObj = { evil: "\u2028\u2029" }; + expect(escapeHtml(JSON.stringify(evilObj))).toBe( + '{"evil":"\\u2028\\u2029"}' + ); + }); + + test('with "LINE SEPARATOR" and "PARAGRAPH SEPARATOR" should parse back', () => { + let evilObj = { evil: "\u2028\u2029" }; + expect(JSON.parse(escapeHtml(JSON.stringify(evilObj)))).toMatchObject( + evilObj + ); + }); + + test("escaped line terminators should work", () => { + expect(() => { + vm.runInNewContext( + "(" + escapeHtml(JSON.stringify({ evil: "\u2028\u2029" })) + ")" + ); + }).not.toThrow(); + }); +}); diff --git a/packages/remix/__tests__/responses-test.ts b/packages/remix/__tests__/responses-test.ts new file mode 100644 index 00000000000..0506d28c965 --- /dev/null +++ b/packages/remix/__tests__/responses-test.ts @@ -0,0 +1,96 @@ +import type { TypedResponse } from "../index"; +import { json, redirect } from "../index"; +import { isEqual } from "./utils"; + +describe("json", () => { + it("sets the Content-Type header", () => { + let response = json({}); + expect(response.headers.get("Content-Type")).toEqual( + "application/json; charset=utf-8" + ); + }); + + it("preserves existing headers, including Content-Type", () => { + let response = json( + {}, + { + headers: { + "Content-Type": "application/json; charset=iso-8859-1", + "X-Remix": "is awesome", + }, + } + ); + + expect(response.headers.get("Content-Type")).toEqual( + "application/json; charset=iso-8859-1" + ); + expect(response.headers.get("X-Remix")).toEqual("is awesome"); + }); + + it("encodes the response body", async () => { + let response = json({ hello: "remix" }); + expect(await response.json()).toEqual({ hello: "remix" }); + }); + + it("accepts status as a second parameter", () => { + let response = json({}, 201); + expect(response.status).toEqual(201); + }); + + it("infers input type", async () => { + let response = json({ hello: "remix" }); + isEqual>(true); + let result = await response.json(); + expect(result).toMatchObject({ hello: "remix" }); + }); + + it("disallows unmatched typed responses", async () => { + let response = json("hello"); + isEqual, typeof response>(false); + }); + + it("disallows unserializables", () => { + // @ts-expect-error + expect(() => json(124n)).toThrow(); + // @ts-expect-error + expect(() => json({ field: 124n })).toThrow(); + }); +}); + +describe("redirect", () => { + it("sets the status to 302 by default", () => { + let response = redirect("/login"); + expect(response.status).toEqual(302); + }); + + it("sets the status to 302 when only headers are given", () => { + let response = redirect("/login", { + headers: { + "X-Remix": "is awesome", + }, + }); + expect(response.status).toEqual(302); + }); + + it("sets the Location header", () => { + let response = redirect("/login"); + expect(response.headers.get("Location")).toEqual("/login"); + }); + + it("preserves existing headers, but not Location", () => { + let response = redirect("/login", { + headers: { + Location: "/", + "X-Remix": "is awesome", + }, + }); + + expect(response.headers.get("Location")).toEqual("/login"); + expect(response.headers.get("X-Remix")).toEqual("is awesome"); + }); + + it("accepts status as a second parameter", () => { + let response = redirect("/profile", 301); + expect(response.status).toEqual(301); + }); +}); diff --git a/packages/remix/__tests__/serialize-test.ts b/packages/remix/__tests__/serialize-test.ts new file mode 100644 index 00000000000..7b5a38b35d1 --- /dev/null +++ b/packages/remix/__tests__/serialize-test.ts @@ -0,0 +1,54 @@ +import type { SerializeFrom } from "../index"; +import { defer, json } from "../index"; +import { isEqual } from "./utils"; + +it("infers basic types", () => { + isEqual< + SerializeFrom<{ + hello?: string; + count: number | undefined; + date: Date | number; + isActive: boolean; + items: { name: string; price: number; orderedAt: Date }[]; + }>, + { + hello?: string; + count?: number; + date: string | number; + isActive: boolean; + items: { name: string; price: number; orderedAt: string }[]; + } + >(true); +}); + +it("infers deferred types", () => { + let get = (): Promise | undefined => { + if (Math.random() > 0.5) return Promise.resolve(new Date()); + return undefined; + }; + let loader = async () => + defer({ + critical: await Promise.resolve("hello"), + deferred: get(), + }); + isEqual< + SerializeFrom, + { + critical: string; + deferred: Promise | undefined; + } + >(true); +}); + +it("infers types from json", () => { + let loader = () => json({ data: "remix" }); + isEqual, { data: string }>(true); + + let asyncLoader = async () => json({ data: "remix" }); + isEqual, { data: string }>(true); +}); + +it("infers type from defer", () => { + let loader = async () => defer({ data: "remix" }); + isEqual, { data: string }>(true); +}); diff --git a/packages/remix/__tests__/server-test.ts b/packages/remix/__tests__/server-test.ts new file mode 100644 index 00000000000..76127eb00c0 --- /dev/null +++ b/packages/remix/__tests__/server-test.ts @@ -0,0 +1,2167 @@ +import type { StaticHandlerContext } from "@remix-run/router"; + +import { createRequestHandler } from ".."; +import { ServerMode } from "../mode"; +import type { ServerBuild } from "../build"; +import { mockServerBuild } from "./utils"; + +function spyConsole() { + // https://github.com/facebook/react/issues/7047 + let spy: any = {}; + + beforeAll(() => { + spy.console = jest.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterAll(() => { + spy.console.mockRestore(); + }); + + return spy; +} + +describe("server", () => { + let routeId = "root"; + let build: ServerBuild = { + entry: { + module: { + default: async (request) => { + return new Response(`${request.method}, ${request.url} COMPONENT`); + }, + }, + }, + routes: { + [routeId]: { + id: routeId, + path: "", + module: { + action: ({ request }) => + new Response(`${request.method} ${request.url} ACTION`), + loader: ({ request }) => + new Response(`${request.method} ${request.url} LOADER`), + default: () => "COMPONENT", + }, + }, + }, + assets: { + routes: { + [routeId]: { + hasAction: true, + hasErrorBoundary: false, + hasLoader: true, + id: routeId, + module: routeId, + path: "", + }, + }, + }, + } as unknown as ServerBuild; + + describe("createRequestHandler", () => { + let spy = spyConsole(); + + beforeEach(() => { + spy.console.mockClear(); + }); + + let allowThrough = [ + ["GET", "/"], + ["GET", "/?_data=root"], + ["POST", "/"], + ["POST", "/?_data=root"], + ["PUT", "/"], + ["PUT", "/?_data=root"], + ["DELETE", "/"], + ["DELETE", "/?_data=root"], + ["PATCH", "/"], + ["PATCH", "/?_data=root"], + ]; + it.each(allowThrough)( + `allows through %s request to %s`, + async (method, to) => { + let handler = createRequestHandler(build); + let response = await handler( + new Request(`http://localhost:3000${to}`, { + method, + }) + ); + + expect(response.status).toBe(200); + let text = await response.text(); + expect(text).toContain(method); + let expected = !to.includes("?_data=root") + ? "COMPONENT" + : method === "GET" + ? "LOADER" + : "ACTION"; + expect(text).toContain(expected); + expect(spy.console).not.toHaveBeenCalled(); + } + ); + + it("strips body for HEAD requests", async () => { + let handler = createRequestHandler(build); + let response = await handler( + new Request("http://localhost:3000/", { + method: "HEAD", + }) + ); + + expect(await response.text()).toBe(""); + }); + }); +}); + +describe("shared server runtime", () => { + let spy = spyConsole(); + + beforeEach(() => { + spy.console.mockClear(); + }); + + let baseUrl = "http://test.com"; + + describe("resource routes", () => { + test("calls resource route loader", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(() => { + return "resource"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("resource"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(resourceLoader.mock.calls.length).toBe(1); + }); + + test("calls sub resource route loader", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(() => { + return "resource"; + }); + let subResourceLoader = jest.fn(() => { + return "sub"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + "routes/resource.sub": { + loader: subResourceLoader, + path: "resource/sub", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource/sub`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("sub"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(resourceLoader.mock.calls.length).toBe(0); + expect(subResourceLoader.mock.calls.length).toBe(1); + }); + + test("resource route loader allows thrown responses", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(() => { + throw new Response("resource"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.text()).toBe("resource"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(resourceLoader.mock.calls.length).toBe(1); + }); + + test("resource route loader responds with generic error when thrown", async () => { + let error = new Error("should be logged when resource loader throws"); + let loader = jest.fn(() => { + throw error; + }); + let build = mockServerBuild({ + "routes/resource": { + loader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "get", + }); + + let result = await handler(request); + expect(await result.text()).toBe( + "Unexpected Server Error\n\nError: should be logged when resource loader throws" + ); + }); + + test("resource route loader responds with detailed error when thrown in development", async () => { + let error = new Error("should be logged when resource loader throws"); + let loader = jest.fn(() => { + throw error; + }); + let build = mockServerBuild({ + "routes/resource": { + loader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/resource`, { + method: "get", + }); + + let result = await handler(request); + expect((await result.text()).includes(error.message)).toBe(true); + expect(spy.console.mock.calls.length).toBe(1); + }); + + test("calls resource route action", async () => { + let rootAction = jest.fn(() => { + return "root"; + }); + let resourceAction = jest.fn(() => { + return "resource"; + }); + let build = mockServerBuild({ + root: { + default: {}, + action: rootAction, + }, + "routes/resource": { + action: resourceAction, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("resource"); + expect(rootAction.mock.calls.length).toBe(0); + expect(resourceAction.mock.calls.length).toBe(1); + }); + + test("calls sub resource route action", async () => { + let rootAction = jest.fn(() => { + return "root"; + }); + let resourceAction = jest.fn(() => { + return "resource"; + }); + let subResourceAction = jest.fn(() => { + return "sub"; + }); + let build = mockServerBuild({ + root: { + default: {}, + action: rootAction, + }, + "routes/resource": { + action: resourceAction, + path: "resource", + }, + "routes/resource.sub": { + action: subResourceAction, + path: "resource/sub", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource/sub`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("sub"); + expect(rootAction.mock.calls.length).toBe(0); + expect(resourceAction.mock.calls.length).toBe(0); + expect(subResourceAction.mock.calls.length).toBe(1); + }); + + test("resource route action allows thrown responses", async () => { + let rootAction = jest.fn(() => { + return "root"; + }); + let resourceAction = jest.fn(() => { + throw new Response("resource"); + }); + let build = mockServerBuild({ + root: { + default: {}, + action: rootAction, + }, + "routes/resource": { + action: resourceAction, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.text()).toBe("resource"); + expect(rootAction.mock.calls.length).toBe(0); + expect(resourceAction.mock.calls.length).toBe(1); + }); + + test("resource route action responds with generic error when thrown", async () => { + let error = new Error("should be logged when resource loader throws"); + let action = jest.fn(() => { + throw error; + }); + let build = mockServerBuild({ + "routes/resource": { + action, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/resource`, { + method: "post", + }); + + let result = await handler(request); + expect(await result.text()).toBe( + "Unexpected Server Error\n\nError: should be logged when resource loader throws" + ); + }); + + test("resource route action responds with detailed error when thrown in development", async () => { + let message = "should be logged when resource loader throws"; + let action = jest.fn(() => { + throw new Error(message); + }); + let build = mockServerBuild({ + "routes/resource": { + action, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/resource`, { + method: "post", + }); + + let result = await handler(request); + expect((await result.text()).includes(message)).toBe(true); + expect(spy.console.mock.calls.length).toBe(1); + }); + + test("aborts request", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(async () => { + await new Promise((r) => setTimeout(r, 10)); + return "resource"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/resource`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + expect(await result.text()).toMatchInlineSnapshot(` + "Unexpected Server Error + + Error: queryRoute() call aborted: GET http://test.com/resource" + `); + }); + + test("aborts request (v3_throwAbortReason)", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let resourceLoader = jest.fn(async () => { + await new Promise((r) => setTimeout(r, 10)); + return "resource"; + }); + let handleErrorSpy = jest.fn(); + let build = mockServerBuild( + { + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: resourceLoader, + path: "resource", + }, + }, + { + future: { + v3_throwAbortReason: true, + }, + handleError: handleErrorSpy, + } + ); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/resource`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + expect(await result.text()).toMatchInlineSnapshot(` + "Unexpected Server Error + + AbortError: This operation was aborted" + `); + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0] instanceof DOMException).toBe( + true + ); + expect(handleErrorSpy.mock.calls[0][0].name).toBe("AbortError"); + expect(handleErrorSpy.mock.calls[0][0].message).toBe( + "This operation was aborted" + ); + expect(handleErrorSpy.mock.calls[0][1].request.method).toBe("GET"); + expect(handleErrorSpy.mock.calls[0][1].request.url).toBe( + "http://test.com/resource" + ); + }); + }); + + describe("data requests", () => { + test("data request that does not match loader surfaces 400 error for boundary", async () => { + let build = mockServerBuild({ + root: { + default: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect((await result.json()).message).toBeTruthy(); + }); + + test("data request that does not match routeId surfaces 403 error for boundary", async () => { + let build = mockServerBuild({ + root: { + default: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + loader: () => null, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + // This bug wasn't that the router wasn't returning a 404 (it was), but + // that we weren't defensive when looking at match.params when we went + // to call handleDataRequest(), - and that threw it's own uncaught + // exception triggering a 500. We need to ensure that this build has a + // handleDataRequest implementation for this test to mean anything + expect(build.entry.module.handleDataRequest).toBeDefined(); + + let request = new Request(`${baseUrl}/?_data=routes/junk`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(403); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect((await result.json()).message).toBeTruthy(); + }); + + test("data request that does not match route surfaces 404 error for boundary", async () => { + let build = mockServerBuild({ + root: { + default: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + loader: () => null, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/junk?_data=routes/junk`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(404); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect((await result.json()).message).toBeTruthy(); + }); + + test("data request calls loader", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + loader: indexLoader, + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("index"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(1); + }); + + test("data request calls loader and responds with generic message and error header", async () => { + let rootLoader = jest.fn(() => { + throw new Error("test"); + }); + let testAction = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=root`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.json()).message).toBe("Unexpected Server Error"); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testAction.mock.calls.length).toBe(0); + }); + + test("data request calls loader and responds with detailed info and error header in development mode", async () => { + let message = + "data request loader error logged to console once in dev mode"; + let rootLoader = jest.fn(() => { + throw new Error(message); + }); + let testAction = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/test?_data=root`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.json()).message).toBe(message); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testAction.mock.calls.length).toBe(0); + expect(spy.console.mock.calls.length).toBe(1); + }); + + test("data request calls loader and responds with catch header", async () => { + let rootLoader = jest.fn(() => { + throw new Response("test", { status: 400 }); + }); + let testAction = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=root`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(await result.text()).toBe("test"); + expect(result.headers.get("X-Remix-Catch")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testAction.mock.calls.length).toBe(0); + }); + + test("data request calls action", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=routes/test`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("test"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(testAction.mock.calls.length).toBe(1); + }); + + test("data request calls action and responds with generic message and error header", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Error("test"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=routes/test`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.json()).message).toBe("Unexpected Server Error"); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(testAction.mock.calls.length).toBe(1); + }); + + test("data request calls action and responds with detailed info and error header in development mode", async () => { + let message = + "data request action error logged to console once in dev mode"; + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Error(message); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/test?_data=routes/test`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.json()).message).toBe(message); + expect(result.headers.get("X-Remix-Error")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(testAction.mock.calls.length).toBe(1); + expect(spy.console.mock.calls.length).toBe(1); + }); + + test("data request calls action and responds with catch header", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Response("test", { status: 400 }); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/test": { + parentId: "root", + action: testAction, + path: "test", + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test?_data=routes/test`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(await result.text()).toBe("test"); + expect(result.headers.get("X-Remix-Catch")).toBe("yes"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(testAction.mock.calls.length).toBe(1); + }); + + test("data request calls layout action", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let rootAction = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + action: rootAction, + }, + "routes/_index": { + parentId: "root", + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?_data=root`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("root"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(rootAction.mock.calls.length).toBe(1); + }); + + test("data request calls index action", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + action: indexAction, + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index&_data=routes/_index`, { + method: "post", + }); + + let result = await handler(request); + expect(result.status).toBe(200); + expect(await result.json()).toBe("index"); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexAction.mock.calls.length).toBe(1); + }); + + test("aborts request", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + loader: indexLoader, + index: true, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + expect(await result.text()).toMatchInlineSnapshot( + `"{"message":"Unexpected Server Error"}"` + ); + }); + + test("aborts request (v3_throwAbortReason)", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let handleErrorSpy = jest.fn(); + let build = mockServerBuild( + { + root: { + default: {}, + loader: rootLoader, + }, + "routes/_index": { + parentId: "root", + loader: indexLoader, + index: true, + }, + }, + { + future: { + v3_throwAbortReason: true, + }, + handleError: handleErrorSpy, + } + ); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/?_data=routes/_index`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + let error = await result.json(); + expect(error.message).toBe("This operation was aborted"); + expect( + error.stack.startsWith("AbortError: This operation was aborted") + ).toBe(true); + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0] instanceof DOMException).toBe( + true + ); + expect(handleErrorSpy.mock.calls[0][0].name).toBe("AbortError"); + expect(handleErrorSpy.mock.calls[0][0].message).toBe( + "This operation was aborted" + ); + expect(handleErrorSpy.mock.calls[0][1].request.method).toBe("GET"); + expect(handleErrorSpy.mock.calls[0][1].request.url).toBe( + "http://test.com/?_data=routes/_index" + ); + }); + }); + + describe("document requests", () => { + test("not found document request for no matches and no ErrorBoundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { + method: "get", + }); + + let result = await handler(request); + expect(result.status).toBe(404); + expect(rootLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(404); + }); + + test("sets root as catch boundary for not found document request", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(404); + expect(rootLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(404); + expect(context.loaderData).toEqual({}); + }); + + test("thrown loader responses bubble up", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(400); + expect(context.loaderData).toEqual({ + root: "root", + }); + }); + + test("thrown loader responses catch deep", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/_index"].status).toBe(400); + expect(context.loaderData).toEqual({ + root: "root", + }); + }); + + test("thrown action responses bubble up", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/test": { + parentId: "root", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(testAction.mock.calls.length).toBe(1); + // Should not call root loader since it is the boundary route + expect(rootLoader.mock.calls.length).toBe(0); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(400); + expect(context.loaderData).toEqual({ + root: null, + "routes/test": null, + }); + }); + + test("thrown action responses bubble up for index routes", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(indexAction.mock.calls.length).toBe(1); + // Should not call root loader since it is the boundary route + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root.status).toBe(400); + expect(context.loaderData).toEqual({ + root: null, + "routes/_index": null, + }); + }); + + test("thrown action responses catch deep", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/test": { + parentId: "root", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(testAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/test"].status).toBe(400); + expect(context.loaderData).toEqual({ + root: "root", + "routes/test": null, + }); + }); + + test("thrown action responses catch deep for index routes", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + throw new Response(null, { status: 400 }); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(indexAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/_index"].status).toBe(400); + expect(context.loaderData).toEqual({ + root: "root", + "routes/_index": null, + }); + }); + + test("thrown loader response after thrown action response bubble up action throw to deepest loader boundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let layoutLoader = jest.fn(() => { + throw new Response("layout", { status: 401 }); + }); + let testAction = jest.fn(() => { + throw new Response("action", { status: 400 }); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/__layout": { + parentId: "root", + default: {}, + loader: layoutLoader, + ErrorBoundary: {}, + }, + "routes/__layout/test": { + parentId: "routes/__layout", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(testAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/__layout"].data).toBe("action"); + expect(context.loaderData).toEqual({ + root: "root", + "routes/__layout": null, + "routes/__layout/test": null, + }); + }); + + test("thrown loader response after thrown index action response bubble up action throw to deepest loader boundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let layoutLoader = jest.fn(() => { + throw new Response("layout", { status: 401 }); + }); + let indexAction = jest.fn(() => { + throw new Response("action", { status: 400 }); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/__layout": { + parentId: "root", + default: {}, + loader: layoutLoader, + ErrorBoundary: {}, + }, + "routes/__layout/index": { + parentId: "routes/__layout", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(400); + expect(indexAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/__layout"].data).toBe("action"); + expect(context.loaderData).toEqual({ + root: "root", + "routes/__layout": null, + "routes/__layout/index": null, + }); + }); + + test("loader errors bubble up", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + throw new Error("index"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root).toBeInstanceOf(Error); + expect(context.errors!.root.message).toBe("Unexpected Server Error"); + expect(context.errors!.root.stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + }); + }); + + test("loader errors catch deep", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + throw new Error("index"); + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/_index"]).toBeInstanceOf(Error); + expect(context.errors!["routes/_index"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/_index"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + }); + }); + + test("action errors bubble up", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Error("test"); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/test": { + parentId: "root", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(testAction.mock.calls.length).toBe(1); + // Should not call root loader since it is the boundary route + expect(rootLoader.mock.calls.length).toBe(0); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root).toBeInstanceOf(Error); + expect(context.errors!.root.message).toBe("Unexpected Server Error"); + expect(context.errors!.root.stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: null, + "routes/test": null, + }); + }); + + test("action errors bubble up for index routes", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + throw new Error("index"); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(indexAction.mock.calls.length).toBe(1); + // Should not call root loader since it is the boundary route + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!.root).toBeInstanceOf(Error); + expect(context.errors!.root.message).toBe("Unexpected Server Error"); + expect(context.errors!.root.stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: null, + "routes/_index": null, + }); + }); + + test("action errors catch deep", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let testAction = jest.fn(() => { + throw new Error("test"); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/test": { + parentId: "root", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(testAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/test"]).toBeInstanceOf(Error); + expect(context.errors!["routes/test"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/test"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + "routes/test": null, + }); + }); + + test("action errors catch deep for index routes", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexAction = jest.fn(() => { + throw new Error("index"); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + ErrorBoundary: {}, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(indexAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/_index"]).toBeInstanceOf(Error); + expect(context.errors!["routes/_index"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/_index"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + "routes/_index": null, + }); + }); + + test("loader errors after action error bubble up action error to deepest loader boundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let layoutLoader = jest.fn(() => { + throw new Error("layout"); + }); + let testAction = jest.fn(() => { + throw new Error("action"); + }); + let testLoader = jest.fn(() => { + return "test"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/__layout": { + parentId: "root", + default: {}, + loader: layoutLoader, + ErrorBoundary: {}, + }, + "routes/__layout/test": { + parentId: "routes/__layout", + path: "test", + default: {}, + loader: testLoader, + action: testAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/test`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(testAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(testLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/__layout"]).toBeInstanceOf(Error); + expect(context.errors!["routes/__layout"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/__layout"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + "routes/__layout": null, + "routes/__layout/test": null, + }); + }); + + test("loader errors after index action error bubble up action error to deepest loader boundary", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let layoutLoader = jest.fn(() => { + throw new Error("layout"); + }); + let indexAction = jest.fn(() => { + throw new Error("action"); + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/__layout": { + parentId: "root", + default: {}, + loader: layoutLoader, + ErrorBoundary: {}, + }, + "routes/__layout/index": { + parentId: "routes/__layout", + index: true, + default: {}, + loader: indexLoader, + action: indexAction, + }, + }); + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/?index`, { method: "post" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(indexAction.mock.calls.length).toBe(1); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(0); + expect(build.entry.module.default.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(1); + let context = calls[0][3].staticHandlerContext as StaticHandlerContext; + expect(context.errors).toBeTruthy(); + expect(context.errors!["routes/__layout"]).toBeInstanceOf(Error); + expect(context.errors!["routes/__layout"].message).toBe( + "Unexpected Server Error" + ); + expect(context.errors!["routes/__layout"].stack).toBeUndefined(); + expect(context.loaderData).toEqual({ + root: "root", + "routes/__layout": null, + "routes/__layout/index": null, + }); + }); + + test("calls handleDocumentRequest again with new error when handleDocumentRequest throws", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let calledBefore = false; + let ogHandleDocumentRequest = build.entry.module.default; + build.entry.module.default = jest.fn(function () { + if (!calledBefore) { + throw new Error("thrown"); + } + calledBefore = true; + return ogHandleDocumentRequest.call(null, ...arguments); + }) as any; + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/404`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(0); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(2); + let context = calls[1][3].staticHandlerContext; + expect(context.errors.root).toBeTruthy(); + expect(context.errors!.root.message).toBe("thrown"); + expect(context.loaderData).toEqual({}); + }); + + test("unwraps responses thrown from handleDocumentRequest", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let ogHandleDocumentRequest = build.entry.module.default; + build.entry.module.default = function ( + _: Request, + responseStatusCode: number + ) { + if (responseStatusCode === 200) { + throw new Response("Uh oh!", { + status: 400, + statusText: "Bad Request", + }); + } + return ogHandleDocumentRequest.call(null, ...arguments); + } as any; + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(400); + }); + + test("returns generic message if handleDocumentRequest throws a second time", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + default: {}, + loader: indexLoader, + }, + }); + let lastThrownError; + build.entry.module.default = jest.fn(function () { + lastThrownError = new Error("rofl"); + throw lastThrownError; + }) as any; + let handler = createRequestHandler(build, ServerMode.Test); + + let request = new Request(`${baseUrl}/`, { method: "get" }); + + let result = await handler(request); + expect(result.status).toBe(500); + expect(await result.text()).toBe( + "Unexpected Server Error\n\nError: rofl" + ); + expect(rootLoader.mock.calls.length).toBe(0); + expect(indexLoader.mock.calls.length).toBe(0); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(2); + }); + + test("returns more detailed message if handleDocumentRequest throws a second time in development mode", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + path: "/", + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + index: true, + default: {}, + loader: indexLoader, + }, + }); + let errorMessage = + "thrown from handleDocumentRequest and expected to be logged in console only once"; + let lastThrownError; + build.entry.module.default = jest.fn(function () { + lastThrownError = new Error(errorMessage); + errorMessage = "second error thrown from handleDocumentRequest"; + throw lastThrownError; + }) as any; + let handler = createRequestHandler(build, ServerMode.Development); + + let request = new Request(`${baseUrl}/`); + + let result = await handler(request); + expect(result.status).toBe(500); + expect((await result.text()).includes(errorMessage)).toBe(true); + expect(rootLoader.mock.calls.length).toBe(1); + expect(indexLoader.mock.calls.length).toBe(1); + + let calls = build.entry.module.default.mock.calls; + expect(calls.length).toBe(2); + expect(spy.console.mock.calls).toEqual([ + [ + new Error( + "thrown from handleDocumentRequest and expected to be logged in console only once" + ), + ], + [new Error("second error thrown from handleDocumentRequest")], + ]); + }); + + test("aborts request", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(async () => { + await new Promise((r) => setTimeout(r, 10)); + return "index"; + }); + let handleErrorSpy = jest.fn(); + let build = mockServerBuild( + { + root: { + default() {}, + loader: rootLoader, + }, + "routes/_index": { + loader: indexLoader, + index: true, + default: {}, + }, + }, + { + handleError: handleErrorSpy, + } + ); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + expect(build.entry.module.default.mock.calls.length).toBe(0); + + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0] instanceof Error).toBe(true); + expect(handleErrorSpy.mock.calls[0][0].name).toBe("Error"); + expect(handleErrorSpy.mock.calls[0][0].message).toBe( + "query() call aborted: GET http://test.com/" + ); + }); + + test("aborts request (v3_throwAbortReason)", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(async () => { + await new Promise((r) => setTimeout(r, 10)); + return "index"; + }); + let handleErrorSpy = jest.fn(); + let build = mockServerBuild( + { + root: { + default: {}, + loader: rootLoader, + }, + "routes/resource": { + loader: indexLoader, + index: true, + default: {}, + }, + }, + { + future: { + v3_throwAbortReason: true, + }, + handleError: handleErrorSpy, + } + ); + let handler = createRequestHandler(build, ServerMode.Test); + + let controller = new AbortController(); + let request = new Request(`${baseUrl}/`, { + method: "get", + signal: controller.signal, + }); + + let resultPromise = handler(request); + controller.abort(); + let result = await resultPromise; + expect(result.status).toBe(500); + expect(build.entry.module.default.mock.calls.length).toBe(0); + + expect(handleErrorSpy).toHaveBeenCalledTimes(1); + expect(handleErrorSpy.mock.calls[0][0] instanceof DOMException).toBe( + true + ); + expect(handleErrorSpy.mock.calls[0][0].name).toBe("AbortError"); + expect(handleErrorSpy.mock.calls[0][0].message).toBe( + "This operation was aborted" + ); + expect(handleErrorSpy.mock.calls[0][1].request.method).toBe("GET"); + expect(handleErrorSpy.mock.calls[0][1].request.url).toBe( + "http://test.com/" + ); + }); + }); + + test("provides load context to server entrypoint", async () => { + let rootLoader = jest.fn(() => { + return "root"; + }); + let indexLoader = jest.fn(() => { + return "index"; + }); + let build = mockServerBuild({ + root: { + default: {}, + loader: rootLoader, + ErrorBoundary: {}, + }, + "routes/_index": { + parentId: "root", + default: {}, + loader: indexLoader, + }, + }); + + build.entry.module.default = jest.fn( + async ( + request, + responseStatusCode, + responseHeaders, + entryContext, + loadContext + ) => + new Response(JSON.stringify(loadContext), { + status: responseStatusCode, + headers: responseHeaders, + }) + ); + + let handler = createRequestHandler(build, ServerMode.Development); + let request = new Request(`${baseUrl}/`, { method: "get" }); + let loadContext = { "load-context": "load-value" }; + + let result = await handler(request, loadContext); + expect(await result.text()).toBe(JSON.stringify(loadContext)); + }); +}); diff --git a/packages/remix/__tests__/sessions-test.ts b/packages/remix/__tests__/sessions-test.ts new file mode 100644 index 00000000000..041b5deb75c --- /dev/null +++ b/packages/remix/__tests__/sessions-test.ts @@ -0,0 +1,356 @@ +import { createCookieFactory } from "../cookies"; +import type { SignFunction, UnsignFunction } from "../crypto"; +import { + createSession, + createSessionStorageFactory, + isSession, +} from "../sessions"; +import { createCookieSessionStorageFactory } from "../sessions/cookieStorage"; +import { createMemorySessionStorageFactory } from "../sessions/memoryStorage"; + +function getCookieFromSetCookie(setCookie: string): string { + return setCookie.split(/;\s*/)[0]; +} + +const sign: SignFunction = async (value, secret) => { + return JSON.stringify({ value, secret }); +}; +const unsign: UnsignFunction = async (signed, secret) => { + try { + let unsigned = JSON.parse(signed); + if (unsigned.secret !== secret) return false; + return unsigned.value; + } catch (e: unknown) { + return false; + } +}; +const createCookie = createCookieFactory({ sign, unsign }); +const createCookieSessionStorage = + createCookieSessionStorageFactory(createCookie); +const createSessionStorage = createSessionStorageFactory(createCookie); +const createMemorySessionStorage = + createMemorySessionStorageFactory(createSessionStorage); + +describe("Session", () => { + it("has an empty id by default", () => { + expect(createSession().id).toEqual(""); + }); + + it("correctly stores and retrieves values", () => { + let session = createSession(); + + session.set("user", "mjackson"); + session.flash("error", "boom"); + + expect(session.has("user")).toBe(true); + expect(session.get("user")).toBe("mjackson"); + // Normal values should remain in the session after get() + expect(session.has("user")).toBe(true); + expect(session.get("user")).toBe("mjackson"); + + expect(session.has("error")).toBe(true); + expect(session.get("error")).toBe("boom"); + // Flash values disappear after the first get() + expect(session.has("error")).toBe(false); + expect(session.get("error")).toBeUndefined(); + + session.unset("user"); + + expect(session.has("user")).toBe(false); + expect(session.get("user")).toBeUndefined(); + }); +}); + +describe("isSession", () => { + it("returns `true` for Session objects", () => { + expect(isSession(createSession())).toBe(true); + }); + + it("returns `false` for non-Session objects", () => { + expect(isSession({})).toBe(false); + expect(isSession([])).toBe(false); + expect(isSession("")).toBe(false); + expect(isSession(true)).toBe(false); + }); +}); + +describe("In-memory session storage", () => { + it("persists session data across requests", async () => { + let { getSession, commitSession } = createMemorySessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + + expect(session.get("user")).toEqual("mjackson"); + }); + + it("uses random hash keys as session ids", async () => { + let { getSession, commitSession } = createMemorySessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + expect(session.id).toMatch(/^[a-z0-9]{8}$/); + }); +}); + +describe("Cookie session storage", () => { + it("persists session data across requests", async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + + expect(session.get("user")).toEqual("mjackson"); + }); + + it("returns an empty session for cookies that are not signed properly", async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + + expect(session.get("user")).toEqual("mjackson"); + + let setCookie = await commitSession(session); + session = await getSession( + // Tamper with the session cookie... + getCookieFromSetCookie(setCookie).slice(0, -1) + ); + + expect(session.get("user")).toBeUndefined(); + }); + + it('"makes the default path of cookies to be /', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + expect(setCookie).toContain("Path=/"); + }); + + it("throws an error when the cookie size exceeds 4096 bytes", async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + let longString = new Array(4097).fill("a").join(""); + session.set("over4096bytes", longString); + await expect(() => commitSession(session)).rejects.toThrow(); + }); + + it("destroys sessions using a past date", async () => { + let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let { getSession, destroySession } = createCookieSessionStorage({ + cookie: { + secrets: ["secret1"], + }, + }); + let session = await getSession(); + let setCookie = await destroySession(session); + expect(setCookie).toMatchInlineSnapshot( + `"__session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` + ); + spy.mockRestore(); + }); + + it("destroys sessions that leverage maxAge", async () => { + let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let { getSession, destroySession } = createCookieSessionStorage({ + cookie: { + maxAge: 60 * 60, // 1 hour + secrets: ["secret1"], + }, + }); + let session = await getSession(); + let setCookie = await destroySession(session); + expect(setCookie).toMatchInlineSnapshot( + `"__session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` + ); + spy.mockRestore(); + }); + + describe("warnings when providing options you may not want to", () => { + let spy = spyConsole(); + + it("warns against using `expires` when creating the session", async () => { + createCookieSessionStorage({ + cookie: { + secrets: ["secret1"], + expires: new Date(Date.now() + 60_000), + }, + }); + + expect(spy.console).toHaveBeenCalledTimes(1); + expect(spy.console).toHaveBeenCalledWith( + 'The "__session" cookie has an "expires" property set. This will cause the expires value to not be updated when the session is committed. Instead, you should set the expires value when serializing the cookie. You can use `commitSession(session, { expires })` if using a session storage object, or `cookie.serialize("value", { expires })` if you\'re using the cookie directly.' + ); + }); + + it("warns when not passing secrets when creating the session", async () => { + createCookieSessionStorage({ cookie: {} }); + + expect(spy.console).toHaveBeenCalledTimes(1); + expect(spy.console).toHaveBeenCalledWith( + 'The "__session" cookie is not signed, but session cookies should be signed to prevent tampering on the client before they are sent back to the server. See https://remix.run/utils/cookies#signing-cookies for more information.' + ); + }); + }); + + describe("when a new secret shows up in the rotation", () => { + it("unsigns old session cookies using the old secret and encodes new cookies using the new secret", async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ["secret1"] }, + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + + expect(session.get("user")).toEqual("mjackson"); + + // A new secret enters the rotation... + let storage = createCookieSessionStorage({ + cookie: { secrets: ["secret2", "secret1"] }, + }); + getSession = storage.getSession; + commitSession = storage.commitSession; + + // Old cookies should still work with the old secret. + session = await storage.getSession(getCookieFromSetCookie(setCookie)); + expect(session.get("user")).toEqual("mjackson"); + + // New cookies should be signed using the new secret. + let setCookie2 = await storage.commitSession(session); + expect(setCookie2).not.toEqual(setCookie); + }); + }); +}); + +describe("Custom cookie-backed session storage", () => { + let memoryBacking = {}; + let createCookieBackedSessionStorage = + createSessionStorageFactory(createCookie); + let implementation = { + createData(data) { + let id = Math.random().toString(36).substring(2, 10); + memoryBacking[id] = data; + return Promise.resolve(id); + }, + readData(id) { + return Promise.resolve(memoryBacking[id] || null); + }, + updateData(id, data) { + memoryBacking[id] = data; + return Promise.resolve(); + }, + deleteData(id) { + memoryBacking[id] = null; + return Promise.resolve(memoryBacking[id]); + }, + }; + + it("persists session data across requests", async () => { + let { getSession, commitSession } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { secrets: ["test"] }), + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + session = await getSession(getCookieFromSetCookie(setCookie)); + + expect(session.get("user")).toEqual("mjackson"); + }); + + it("returns an empty session for cookies that are not signed properly", async () => { + let { getSession, commitSession } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { secrets: ["test"] }), + }); + let session = await getSession(); + session.set("user", "mjackson"); + + expect(session.get("user")).toEqual("mjackson"); + + let setCookie = await commitSession(session); + session = await getSession( + // Tamper with the session cookie... + getCookieFromSetCookie(setCookie).slice(0, -1) + ); + + expect(session.get("user")).toBeUndefined(); + }); + + it('"makes the default path of cookies to be /', async () => { + let { getSession, commitSession } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { secrets: ["test"] }), + }); + let session = await getSession(); + session.set("user", "mjackson"); + let setCookie = await commitSession(session); + expect(setCookie).toContain("Path=/"); + }); + + it("destroys sessions using a past date", async () => { + let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let { getSession, destroySession } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { secrets: ["test"] }), + }); + let session = await getSession(); + let setCookie = await destroySession(session); + expect(setCookie).toMatchInlineSnapshot( + `"test=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` + ); + spy.mockRestore(); + }); + + it("destroys sessions that leverage maxAge", async () => { + let spy = jest.spyOn(console, "warn").mockImplementation(() => {}); + let { getSession, destroySession } = createCookieBackedSessionStorage({ + ...implementation, + cookie: createCookie("test", { + maxAge: 60 * 60, // 1 hour + secrets: ["test"], + }), + }); + let session = await getSession(); + let setCookie = await destroySession(session); + expect(setCookie).toMatchInlineSnapshot( + `"test=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax"` + ); + spy.mockRestore(); + }); +}); + +function spyConsole() { + // https://github.com/facebook/react/issues/7047 + let spy: any = {}; + + beforeAll(() => { + spy.console = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + beforeEach(() => { + spy.console.mockClear(); + }); + + afterAll(() => { + spy.console.mockRestore(); + }); + + return spy; +} diff --git a/packages/remix/__tests__/setup.ts b/packages/remix/__tests__/setup.ts new file mode 100644 index 00000000000..451031301ac --- /dev/null +++ b/packages/remix/__tests__/setup.ts @@ -0,0 +1,3 @@ +import { installGlobals } from "@remix-run/node"; + +installGlobals(); diff --git a/packages/remix/__tests__/utils.ts b/packages/remix/__tests__/utils.ts new file mode 100644 index 00000000000..dccade4a4f3 --- /dev/null +++ b/packages/remix/__tests__/utils.ts @@ -0,0 +1,109 @@ +import prettier from "prettier"; + +import type { + ActionFunction, + HandleErrorFunction, + HeadersFunction, + LoaderFunction, +} from "../"; +import type { FutureConfig } from "../entry"; +import type { EntryRoute, ServerRoute, ServerRouteManifest } from "../routes"; + +export function mockServerBuild( + routes: Record< + string, + { + parentId?: string; + index?: true; + path?: string; + default?: any; + ErrorBoundary?: any; + action?: ActionFunction; + headers?: HeadersFunction; + loader?: LoaderFunction; + } + >, + opts: { + future?: Partial; + handleError?: HandleErrorFunction; + } = {} +) { + return { + future: { + ...opts.future, + }, + assets: { + entry: { + imports: [""], + module: "", + }, + routes: Object.entries(routes).reduce((p, [id, config]) => { + let route: EntryRoute = { + hasAction: !!config.action, + hasErrorBoundary: !!config.ErrorBoundary, + hasLoader: !!config.loader, + id, + module: "", + index: config.index, + path: config.path, + parentId: config.parentId, + }; + return { + ...p, + [id]: route, + }; + }, {}), + url: "", + version: "", + }, + entry: { + module: { + default: jest.fn( + async ( + request, + responseStatusCode, + responseHeaders, + entryContext, + loadContext + ) => + new Response(null, { + status: responseStatusCode, + headers: responseHeaders, + }) + ), + handleDataRequest: jest.fn(async (response) => response), + handleError: opts.handleError, + }, + }, + routes: Object.entries(routes).reduce( + (p, [id, config]) => { + let route: Omit = { + id, + index: config.index, + path: config.path, + parentId: config.parentId, + module: { + default: config.default, + ErrorBoundary: config.ErrorBoundary, + action: config.action, + headers: config.headers, + loader: config.loader, + }, + }; + return { + ...p, + [id]: route, + }; + }, + {} + ), + }; +} + +export function prettyHtml(source: string): string { + return prettier.format(source, { parser: "html" }); +} + +export function isEqual( + arg: A extends B ? (B extends A ? true : false) : false +): void {} diff --git a/packages/remix/build.ts b/packages/remix/build.ts new file mode 100644 index 00000000000..778b627ebb6 --- /dev/null +++ b/packages/remix/build.ts @@ -0,0 +1,57 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from "./routeModules"; +import type { AssetsManifest, EntryContext, FutureConfig } from "./entry"; +import type { ServerRouteManifest } from "./routes"; +import type { AppLoadContext } from "./data"; + +// NOTE: IF you modify `ServerBuild`, be sure to modify the +// `remix-dev/server-build.ts` file to reflect the new field as well + +/** + * The output of the compiler for the server build. + */ +export interface ServerBuild { + // v3 TODO: + // - Deprecate when we deprecate the old compiler + // - Remove in v3 + mode: string; + entry: { + module: ServerEntryModule; + }; + routes: ServerRouteManifest; + assets: AssetsManifest; + basename?: string; + publicPath: string; + assetsBuildDirectory: string; + future: FutureConfig; + isSpaMode: boolean; +} + +export interface HandleDocumentRequestFunction { + ( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + context: EntryContext, + loadContext: AppLoadContext + ): Promise | Response; +} + +export interface HandleDataRequestFunction { + (response: Response, args: LoaderFunctionArgs | ActionFunctionArgs): + | Promise + | Response; +} + +export interface HandleErrorFunction { + (error: unknown, args: LoaderFunctionArgs | ActionFunctionArgs): void; +} + +/** + * A module that serves as the entry point for a Remix app during server + * rendering. + */ +export interface ServerEntryModule { + default: HandleDocumentRequestFunction; + handleDataRequest?: HandleDataRequestFunction; + handleError?: HandleErrorFunction; +} diff --git a/packages/remix/cookies.ts b/packages/remix/cookies.ts new file mode 100644 index 00000000000..80884fa8fc2 --- /dev/null +++ b/packages/remix/cookies.ts @@ -0,0 +1,261 @@ +import type { CookieParseOptions, CookieSerializeOptions } from "cookie"; +import { parse, serialize } from "cookie"; + +import type { SignFunction, UnsignFunction } from "./crypto"; +import { warnOnce } from "./warnings"; + +export type { CookieParseOptions, CookieSerializeOptions }; + +export interface CookieSignatureOptions { + /** + * An array of secrets that may be used to sign/unsign the value of a cookie. + * + * The array makes it easy to rotate secrets. New secrets should be added to + * the beginning of the array. `cookie.serialize()` will always use the first + * value in the array, but `cookie.parse()` may use any of them so that + * cookies that were signed with older secrets still work. + */ + secrets?: string[]; +} + +export type CookieOptions = CookieParseOptions & + CookieSerializeOptions & + CookieSignatureOptions; + +/** + * A HTTP cookie. + * + * A Cookie is a logical container for metadata about a HTTP cookie; its name + * and options. But it doesn't contain a value. Instead, it has `parse()` and + * `serialize()` methods that allow a single instance to be reused for + * parsing/encoding multiple different values. + * + * @see https://remix.run/utils/cookies#cookie-api + */ +export interface Cookie { + /** + * The name of the cookie, used in the `Cookie` and `Set-Cookie` headers. + */ + readonly name: string; + + /** + * True if this cookie uses one or more secrets for verification. + */ + readonly isSigned: boolean; + + /** + * The Date this cookie expires. + * + * Note: This is calculated at access time using `maxAge` when no `expires` + * option is provided to `createCookie()`. + */ + readonly expires?: Date; + + /** + * Parses a raw `Cookie` header and returns the value of this cookie or + * `null` if it's not present. + */ + parse( + cookieHeader: string | null, + options?: CookieParseOptions + ): Promise; + + /** + * Serializes the given value to a string and returns the `Set-Cookie` + * header. + */ + serialize(value: any, options?: CookieSerializeOptions): Promise; +} + +export type CreateCookieFunction = ( + name: string, + cookieOptions?: CookieOptions +) => Cookie; + +/** + * Creates a logical container for managing a browser cookie from the server. + * + * @see https://remix.run/utils/cookies#createcookie + */ +export const createCookieFactory = + ({ + sign, + unsign, + }: { + sign: SignFunction; + unsign: UnsignFunction; + }): CreateCookieFunction => + (name, cookieOptions = {}) => { + let { secrets = [], ...options } = { + path: "/", + sameSite: "lax" as const, + ...cookieOptions, + }; + + warnOnceAboutExpiresCookie(name, options.expires); + + return { + get name() { + return name; + }, + get isSigned() { + return secrets.length > 0; + }, + get expires() { + // Max-Age takes precedence over Expires + return typeof options.maxAge !== "undefined" + ? new Date(Date.now() + options.maxAge * 1000) + : options.expires; + }, + async parse(cookieHeader, parseOptions) { + if (!cookieHeader) return null; + let cookies = parse(cookieHeader, { ...options, ...parseOptions }); + return name in cookies + ? cookies[name] === "" + ? "" + : await decodeCookieValue(unsign, cookies[name], secrets) + : null; + }, + async serialize(value, serializeOptions) { + return serialize( + name, + value === "" ? "" : await encodeCookieValue(sign, value, secrets), + { + ...options, + ...serializeOptions, + } + ); + }, + }; + }; + +export type IsCookieFunction = (object: any) => object is Cookie; + +/** + * Returns true if an object is a Remix cookie container. + * + * @see https://remix.run/utils/cookies#iscookie + */ +export const isCookie: IsCookieFunction = (object): object is Cookie => { + return ( + object != null && + typeof object.name === "string" && + typeof object.isSigned === "boolean" && + typeof object.parse === "function" && + typeof object.serialize === "function" + ); +}; + +async function encodeCookieValue( + sign: SignFunction, + value: any, + secrets: string[] +): Promise { + let encoded = encodeData(value); + + if (secrets.length > 0) { + encoded = await sign(encoded, secrets[0]); + } + + return encoded; +} + +async function decodeCookieValue( + unsign: UnsignFunction, + value: string, + secrets: string[] +): Promise { + if (secrets.length > 0) { + for (let secret of secrets) { + let unsignedValue = await unsign(value, secret); + if (unsignedValue !== false) { + return decodeData(unsignedValue); + } + } + + return null; + } + + return decodeData(value); +} + +function encodeData(value: any): string { + return btoa(myUnescape(encodeURIComponent(JSON.stringify(value)))); +} + +function decodeData(value: string): any { + try { + return JSON.parse(decodeURIComponent(myEscape(atob(value)))); + } catch (error: unknown) { + return {}; + } +} + +// See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.escape.js +function myEscape(value: string): string { + let str = value.toString(); + let result = ""; + let index = 0; + let chr, code; + while (index < str.length) { + chr = str.charAt(index++); + if (/[\w*+\-./@]/.exec(chr)) { + result += chr; + } else { + code = chr.charCodeAt(0); + if (code < 256) { + result += "%" + hex(code, 2); + } else { + result += "%u" + hex(code, 4).toUpperCase(); + } + } + } + return result; +} + +function hex(code: number, length: number): string { + let result = code.toString(16); + while (result.length < length) result = "0" + result; + return result; +} + +// See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.unescape.js +function myUnescape(value: string): string { + let str = value.toString(); + let result = ""; + let index = 0; + let chr, part; + while (index < str.length) { + chr = str.charAt(index++); + if (chr === "%") { + if (str.charAt(index) === "u") { + part = str.slice(index + 1, index + 5); + if (/^[\da-f]{4}$/i.exec(part)) { + result += String.fromCharCode(parseInt(part, 16)); + index += 5; + continue; + } + } else { + part = str.slice(index, index + 2); + if (/^[\da-f]{2}$/i.exec(part)) { + result += String.fromCharCode(parseInt(part, 16)); + index += 2; + continue; + } + } + } + result += chr; + } + return result; +} + +function warnOnceAboutExpiresCookie(name: string, expires?: Date) { + warnOnce( + !expires, + `The "${name}" cookie has an "expires" property set. ` + + `This will cause the expires value to not be updated when the session is committed. ` + + `Instead, you should set the expires value when serializing the cookie. ` + + `You can use \`commitSession(session, { expires })\` if using a session storage object, ` + + `or \`cookie.serialize("value", { expires })\` if you're using the cookie directly.` + ); +} diff --git a/packages/remix/crypto.ts b/packages/remix/crypto.ts new file mode 100644 index 00000000000..daedea742bf --- /dev/null +++ b/packages/remix/crypto.ts @@ -0,0 +1,64 @@ +export type SignFunction = (value: string, secret: string) => Promise; + +export type UnsignFunction = ( + cookie: string, + secret: string +) => Promise; + +// TODO: Once Node v19 is supported we should use the globally provided +// Web Crypto API's and re-enable this code-path in "./cookies.ts" +// instead of referencing the `sign` and `unsign` globals. + +// const encoder = new TextEncoder(); + +// export const sign: SignFunction = async ( +// value: string, +// secret: string +// ): Promise => { +// let data = encoder.encode(value); +// let key = await createKey(secret, ["sign"]); +// let signature = await crypto.subtle.sign("HMAC", key, data); +// let hash = btoa(String.fromCharCode(...new Uint8Array(signature))).replace( +// /=+$/, +// "" +// ); + +// return value + "." + hash; +// }; + +// export const unsign: UnsignFunction = async ( +// cookie: string, +// secret: string +// ): Promise => { +// let value = cookie.slice(0, cookie.lastIndexOf(".")); +// let hash = cookie.slice(cookie.lastIndexOf(".") + 1); + +// let data = encoder.encode(value); +// let key = await createKey(secret, ["verify"]); +// let signature = byteStringToUint8Array(atob(hash)); +// let valid = await crypto.subtle.verify("HMAC", key, signature, data); + +// return valid ? value : false; +// }; + +// const createKey = async ( +// secret: string, +// usages: CryptoKey["usages"] +// ): Promise => +// crypto.subtle.importKey( +// "raw", +// encoder.encode(secret), +// { name: "HMAC", hash: "SHA-256" }, +// false, +// usages +// ); + +// const byteStringToUint8Array = (byteString: string): Uint8Array => { +// let array = new Uint8Array(byteString.length); + +// for (let i = 0; i < byteString.length; i++) { +// array[i] = byteString.charCodeAt(i); +// } + +// return array; +// }; diff --git a/packages/remix/data.ts b/packages/remix/data.ts new file mode 100644 index 00000000000..2252a1d4f03 --- /dev/null +++ b/packages/remix/data.ts @@ -0,0 +1,146 @@ +import { + redirect, + json, + isDeferredData, + isResponse, + isRedirectStatusCode, +} from "./responses"; +import type { + ActionFunction, + ActionFunctionArgs, + LoaderFunction, + LoaderFunctionArgs, +} from "./routeModules"; + +/** + * An object of unknown type for route loaders and actions provided by the + * server's `getLoadContext()` function. This is defined as an empty interface + * specifically so apps can leverage declaration merging to augment this type + * globally: https://www.typescriptlang.org/docs/handbook/declaration-merging.html + */ +export interface AppLoadContext { + [key: string]: unknown; +} + +/** + * Data for a route that was returned from a `loader()`. + */ +export type AppData = unknown; + +export async function callRouteActionRR({ + loadContext, + action, + params, + request, + routeId, +}: { + request: Request; + action: ActionFunction; + params: ActionFunctionArgs["params"]; + loadContext: AppLoadContext; + routeId: string; +}) { + let result = await action({ + request: stripDataParam(stripIndexParam(request)), + context: loadContext, + params, + }); + + if (result === undefined) { + throw new Error( + `You defined an action for route "${routeId}" but didn't return ` + + `anything from your \`action\` function. Please return a value or \`null\`.` + ); + } + + return isResponse(result) ? result : json(result); +} + +export async function callRouteLoaderRR({ + loadContext, + loader, + params, + request, + routeId, +}: { + request: Request; + loader: LoaderFunction; + params: LoaderFunctionArgs["params"]; + loadContext: AppLoadContext; + routeId: string; +}) { + let result = await loader({ + request: stripDataParam(stripIndexParam(request)), + context: loadContext, + params, + }); + + if (result === undefined) { + throw new Error( + `You defined a loader for route "${routeId}" but didn't return ` + + `anything from your \`loader\` function. Please return a value or \`null\`.` + ); + } + + if (isDeferredData(result)) { + if (result.init && isRedirectStatusCode(result.init.status || 200)) { + return redirect( + new Headers(result.init.headers).get("Location")!, + result.init + ); + } + return result; + } + + return isResponse(result) ? result : json(result); +} + +// TODO: Document these search params better +// and stop stripping these in V2. These break +// support for running in a SW and also expose +// valuable info to data funcs that is being asked +// for such as "is this a data request?". +function stripIndexParam(request: Request) { + let url = new URL(request.url); + let indexValues = url.searchParams.getAll("index"); + url.searchParams.delete("index"); + let indexValuesToKeep = []; + for (let indexValue of indexValues) { + if (indexValue) { + indexValuesToKeep.push(indexValue); + } + } + for (let toKeep of indexValuesToKeep) { + url.searchParams.append("index", toKeep); + } + + let init: RequestInit = { + method: request.method, + body: request.body, + headers: request.headers, + signal: request.signal, + }; + + if (init.body) { + (init as { duplex: "half" }).duplex = "half"; + } + + return new Request(url.href, init); +} + +function stripDataParam(request: Request) { + let url = new URL(request.url); + url.searchParams.delete("_data"); + let init: RequestInit = { + method: request.method, + body: request.body, + headers: request.headers, + signal: request.signal, + }; + + if (init.body) { + (init as { duplex: "half" }).duplex = "half"; + } + + return new Request(url.href, init); +} diff --git a/packages/remix/dev.ts b/packages/remix/dev.ts new file mode 100644 index 00000000000..712389a1c1f --- /dev/null +++ b/packages/remix/dev.ts @@ -0,0 +1,47 @@ +import type { ServerBuild } from "./build"; + +export async function broadcastDevReady(build: ServerBuild, origin?: string) { + origin ??= process.env.REMIX_DEV_ORIGIN; + if (!origin) throw Error("Dev server origin not set"); + let url = new URL(origin); + url.pathname = "ping"; + + let response = await fetch(url.href, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ buildHash: build.assets.version }), + }).catch((error) => { + console.error(`Could not reach Remix dev server at ${url}`); + throw error; + }); + if (!response.ok) { + console.error( + `Could not reach Remix dev server at ${url} (${response.status})` + ); + throw Error(await response.text()); + } +} + +export function logDevReady(build: ServerBuild) { + console.log(`[REMIX DEV] ${build.assets.version} ready`); +} + +type DevServerHooks = { + getCriticalCss?: ( + build: ServerBuild, + pathname: string + ) => Promise; + processRequestError?: (error: unknown) => void; +}; + +const globalDevServerHooksKey = "__remix_devServerHooks"; + +export function setDevServerHooks(devServerHooks: DevServerHooks) { + // @ts-expect-error + globalThis[globalDevServerHooksKey] = devServerHooks; +} + +export function getDevServerHooks(): DevServerHooks | undefined { + // @ts-expect-error + return globalThis[globalDevServerHooksKey]; +} diff --git a/packages/remix/entry.ts b/packages/remix/entry.ts new file mode 100644 index 00000000000..6d73dcad51b --- /dev/null +++ b/packages/remix/entry.ts @@ -0,0 +1,42 @@ +import type { StaticHandlerContext } from "@remix-run/router"; + +import type { SerializedError } from "./errors"; +import type { RouteManifest, ServerRouteManifest, EntryRoute } from "./routes"; +import type { RouteModules, EntryRouteModule } from "./routeModules"; + +export interface EntryContext { + manifest: AssetsManifest; + routeModules: RouteModules; + criticalCss?: string; + serverHandoffString?: string; + staticHandlerContext: StaticHandlerContext; + future: FutureConfig; + isSpaMode: boolean; + serializeError(error: Error): SerializedError; +} + +export interface FutureConfig { + v3_fetcherPersist: boolean; + v3_relativeSplatPath: boolean; + v3_throwAbortReason: boolean; +} + +export interface AssetsManifest { + entry: { + imports: string[]; + module: string; + }; + routes: RouteManifest; + url: string; + version: string; + hmrRuntime?: string; +} + +export function createEntryRouteModules( + manifest: ServerRouteManifest +): RouteModules { + return Object.keys(manifest).reduce((memo, routeId) => { + memo[routeId] = manifest[routeId].module; + return memo; + }, {} as RouteModules); +} diff --git a/packages/remix/errors.ts b/packages/remix/errors.ts new file mode 100644 index 00000000000..8e0934e3fde --- /dev/null +++ b/packages/remix/errors.ts @@ -0,0 +1,117 @@ +import type { StaticHandlerContext } from "@remix-run/router"; +import { isRouteErrorResponse } from "@remix-run/router"; + +import { ServerMode } from "./mode"; + +/** + * This thing probably warrants some explanation. + * + * The whole point here is to emulate componentDidCatch for server rendering and + * data loading. It can get tricky. React can do this on component boundaries + * but doesn't support it for server rendering or data loading. We know enough + * with nested routes to be able to emulate the behavior (because we know them + * statically before rendering.) + * + * Each route can export an `ErrorBoundary`. + * + * - When rendering throws an error, the nearest error boundary will render + * (normal react componentDidCatch). This will be the route's own boundary, but + * if none is provided, it will bubble up to the parents. + * - When data loading throws an error, the nearest error boundary will render + * - When performing an action, the nearest error boundary for the action's + * route tree will render (no redirect happens) + * + * During normal react rendering, we do nothing special, just normal + * componentDidCatch. + * + * For server rendering, we mutate `renderBoundaryRouteId` to know the last + * layout that has an error boundary that tried to render. This emulates which + * layout would catch a thrown error. If the rendering fails, we catch the error + * on the server, and go again a second time with the emulator holding on to the + * information it needs to render the same error boundary as a dynamically + * thrown render error. + * + * When data loading, server or client side, we use the emulator to likewise + * hang on to the error and re-render at the appropriate layout (where a thrown + * error would have been caught by cDC). + * + * When actions throw, it all works the same. There's an edge case to be aware + * of though. Actions normally are required to redirect, but in the case of + * errors, we render the action's route with the emulator holding on to the + * error. If during this render a parent route/loader throws we ignore that new + * error and render the action's original error as deeply as possible. In other + * words, we simply ignore the new error and use the action's error in place + * because it came first, and that just wouldn't be fair to let errors cut in + * line. + */ + +export function sanitizeError(error: T, serverMode: ServerMode) { + if (error instanceof Error && serverMode !== ServerMode.Development) { + let sanitized = new Error("Unexpected Server Error"); + sanitized.stack = undefined; + return sanitized; + } + return error; +} + +export function sanitizeErrors( + errors: NonNullable, + serverMode: ServerMode +) { + return Object.entries(errors).reduce((acc, [routeId, error]) => { + return Object.assign(acc, { [routeId]: sanitizeError(error, serverMode) }); + }, {}); +} + +// must be type alias due to inference issues on interfaces +// https://github.com/microsoft/TypeScript/issues/15300 +export type SerializedError = { + message: string; + stack?: string; +}; + +export function serializeError( + error: Error, + serverMode: ServerMode +): SerializedError { + let sanitized = sanitizeError(error, serverMode); + return { + message: sanitized.message, + stack: sanitized.stack, + }; +} + +export function serializeErrors( + errors: StaticHandlerContext["errors"], + serverMode: ServerMode +): StaticHandlerContext["errors"] { + if (!errors) return null; + let entries = Object.entries(errors); + let serialized: StaticHandlerContext["errors"] = {}; + for (let [key, val] of entries) { + // Hey you! If you change this, please change the corresponding logic in + // deserializeErrors in remix-react/errors.ts :) + if (isRouteErrorResponse(val)) { + serialized[key] = { ...val, __type: "RouteErrorResponse" }; + } else if (val instanceof Error) { + let sanitized = sanitizeError(val, serverMode); + serialized[key] = { + message: sanitized.message, + stack: sanitized.stack, + __type: "Error", + // If this is a subclass (i.e., ReferenceError), send up the type so we + // can re-create the same type during hydration. This will only apply + // in dev mode since all production errors are sanitized to normal + // Error instances + ...(sanitized.name !== "Error" + ? { + __subType: sanitized.name, + } + : {}), + }; + } else { + serialized[key] = val; + } + } + return serialized; +} diff --git a/packages/remix/formData.ts b/packages/remix/formData.ts new file mode 100644 index 00000000000..710cec273d2 --- /dev/null +++ b/packages/remix/formData.ts @@ -0,0 +1,67 @@ +// @ts-ignore +import { streamMultipart } from "@web3-storage/multipart-parser"; + +export type UploadHandlerPart = { + name: string; + filename?: string; + contentType: string; + data: AsyncIterable; +}; + +export type UploadHandler = ( + part: UploadHandlerPart +) => Promise; + +export function composeUploadHandlers( + ...handlers: UploadHandler[] +): UploadHandler { + return async (part) => { + for (let handler of handlers) { + let value = await handler(part); + if (typeof value !== "undefined" && value !== null) { + return value; + } + } + + return undefined; + }; +} + +/** + * Allows you to handle multipart forms (file uploads) for your app. + * + * TODO: Update this comment + * @see https://remix.run/utils/parse-multipart-form-data + */ +export async function parseMultipartFormData( + request: Request, + uploadHandler: UploadHandler +): Promise { + let contentType = request.headers.get("Content-Type") || ""; + let [type, boundary] = contentType.split(/\s*;\s*boundary=/); + + if (!request.body || !boundary || type !== "multipart/form-data") { + throw new TypeError("Could not parse content as FormData."); + } + + let formData = new FormData(); + let parts: AsyncIterable = + streamMultipart(request.body, boundary); + + for await (let part of parts) { + if (part.done) break; + + if (typeof part.filename === "string") { + // only pass basename as the multipart/form-data spec recommends + // https://datatracker.ietf.org/doc/html/rfc7578#section-4.2 + part.filename = part.filename.split(/[/\\]/).pop(); + } + + let value = await uploadHandler(part); + if (typeof value !== "undefined" && value !== null) { + formData.append(part.name, value as any); + } + } + + return formData; +} diff --git a/packages/remix/headers.ts b/packages/remix/headers.ts new file mode 100644 index 00000000000..d65644a0551 --- /dev/null +++ b/packages/remix/headers.ts @@ -0,0 +1,99 @@ +import type { StaticHandlerContext } from "@remix-run/router"; +import { splitCookiesString } from "set-cookie-parser"; + +import type { ServerBuild } from "./build"; + +export function getDocumentHeadersRR( + build: ServerBuild, + context: StaticHandlerContext +): Headers { + let boundaryIdx = context.errors + ? context.matches.findIndex((m) => context.errors![m.route.id]) + : -1; + let matches = + boundaryIdx >= 0 + ? context.matches.slice(0, boundaryIdx + 1) + : context.matches; + + let errorHeaders: Headers | undefined; + + if (boundaryIdx >= 0) { + // Look for any errorHeaders from the boundary route down, which can be + // identified by the presence of headers but no data + let { actionHeaders, actionData, loaderHeaders, loaderData } = context; + context.matches.slice(boundaryIdx).some((match) => { + let id = match.route.id; + if (actionHeaders[id] && (!actionData || actionData[id] === undefined)) { + errorHeaders = actionHeaders[id]; + } else if (loaderHeaders[id] && loaderData[id] === undefined) { + errorHeaders = loaderHeaders[id]; + } + return errorHeaders != null; + }); + } + + return matches.reduce((parentHeaders, match, idx) => { + let { id } = match.route; + let routeModule = build.routes[id].module; + let loaderHeaders = context.loaderHeaders[id] || new Headers(); + let actionHeaders = context.actionHeaders[id] || new Headers(); + + // Only expose errorHeaders to the leaf headers() function to + // avoid duplication via parentHeaders + let includeErrorHeaders = + errorHeaders != undefined && idx === matches.length - 1; + // Only prepend cookies from errorHeaders at the leaf renderable route + // when it's not the same as loaderHeaders/actionHeaders to avoid + // duplicate cookies + let includeErrorCookies = + includeErrorHeaders && + errorHeaders !== loaderHeaders && + errorHeaders !== actionHeaders; + + // Use the parent headers for any route without a `headers` export + if (routeModule.headers == null) { + let headers = new Headers(parentHeaders); + if (includeErrorCookies) { + prependCookies(errorHeaders!, headers); + } + prependCookies(actionHeaders, headers); + prependCookies(loaderHeaders, headers); + return headers; + } + + let headers = new Headers( + routeModule.headers + ? typeof routeModule.headers === "function" + ? routeModule.headers({ + loaderHeaders, + parentHeaders, + actionHeaders, + errorHeaders: includeErrorHeaders ? errorHeaders : undefined, + }) + : routeModule.headers + : undefined + ); + + // Automatically preserve Set-Cookie headers from bubbled responses, + // loaders, errors, and parent routes + if (includeErrorCookies) { + prependCookies(errorHeaders!, headers); + } + prependCookies(actionHeaders, headers); + prependCookies(loaderHeaders, headers); + prependCookies(parentHeaders, headers); + + return headers; + }, new Headers()); +} + +function prependCookies(parentHeaders: Headers, childHeaders: Headers): void { + let parentSetCookieString = parentHeaders.get("Set-Cookie"); + + if (parentSetCookieString) { + let cookies = splitCookiesString(parentSetCookieString); + cookies.forEach((cookie) => { + childHeaders.append("Set-Cookie", cookie); + }); + } +} diff --git a/packages/remix/index.ts b/packages/remix/index.ts index 39768044110..2d813bf522e 100644 --- a/packages/remix/index.ts +++ b/packages/remix/index.ts @@ -1,13 +1,82 @@ -// This class exists to prevent https://github.com/remix-run/remix/issues/2031 from occurring -export class RemixPackageNotUsedError extends Error { - constructor() { - super( - "The `remix` package is no longer used for Remix modules and should be removed " + - "from your project dependencies. See " + - "https://github.com/remix-run/remix/releases/tag/remix%402.0.0" + - " for more information." - ); - } -} +// Default implementations for the Remix server runtime interface +export { createCookieFactory, isCookie } from "./cookies"; +export { + composeUploadHandlers as unstable_composeUploadHandlers, + parseMultipartFormData as unstable_parseMultipartFormData, +} from "./formData"; +export { defer, json, redirect, redirectDocument } from "./responses"; +export { createRequestHandler } from "./server"; +export { + createSession, + createSessionStorageFactory, + isSession, +} from "./sessions"; +export { createCookieSessionStorageFactory } from "./sessions/cookieStorage"; +export { createMemorySessionStorageFactory } from "./sessions/memoryStorage"; +export { createMemoryUploadHandler as unstable_createMemoryUploadHandler } from "./upload/memoryUploadHandler"; +export { MaxPartSizeExceededError } from "./upload/errors"; +export { + broadcastDevReady, + logDevReady, + setDevServerHooks as unstable_setDevServerHooks, +} from "./dev"; -throw new RemixPackageNotUsedError(); +// Types for the Remix server runtime interface +export type { + CreateCookieFunction, + CreateCookieSessionStorageFunction, + CreateMemorySessionStorageFunction, + CreateRequestHandlerFunction, + CreateSessionFunction, + CreateSessionStorageFunction, + IsCookieFunction, + IsSessionFunction, + JsonFunction, + RedirectFunction, +} from "./interface"; + +// Remix server runtime packages should re-export these types +export type { + ActionFunction, + ActionFunctionArgs, + AppLoadContext, + Cookie, + CookieOptions, + CookieParseOptions, + CookieSerializeOptions, + CookieSignatureOptions, + DataFunctionArgs, + EntryContext, + ErrorResponse, + FlashSessionData, + HandleDataRequestFunction, + HandleDocumentRequestFunction, + HeadersArgs, + HeadersFunction, + HtmlLinkDescriptor, + LinkDescriptor, + LinksFunction, + LoaderFunction, + LoaderFunctionArgs, + MemoryUploadHandlerFilterArgs, + MemoryUploadHandlerOptions, + HandleErrorFunction, + PageLinkDescriptor, + RequestHandler, + SerializeFrom, + ServerBuild, + ServerEntryModule, + ServerRuntimeMetaArgs, + ServerRuntimeMetaDescriptor, + ServerRuntimeMetaFunction, + Session, + SessionData, + SessionIdStorageStrategy, + SessionStorage, + SignFunction, + TypedDeferredData, + TypedResponse, + UnsignFunction, + UploadHandler, + UploadHandlerPart, +} from "./reexport"; diff --git a/packages/remix/interface.ts b/packages/remix/interface.ts new file mode 100644 index 00000000000..be54c937c35 --- /dev/null +++ b/packages/remix/interface.ts @@ -0,0 +1,10 @@ +export type { CreateCookieFunction, IsCookieFunction } from "./cookies"; +export type { JsonFunction, RedirectFunction } from "./responses"; +export type { CreateRequestHandlerFunction } from "./server"; +export type { + CreateSessionFunction, + CreateSessionStorageFunction, + IsSessionFunction, +} from "./sessions"; +export type { CreateCookieSessionStorageFunction } from "./sessions/cookieStorage"; +export type { CreateMemorySessionStorageFunction } from "./sessions/memoryStorage"; diff --git a/packages/remix/invariant.ts b/packages/remix/invariant.ts new file mode 100644 index 00000000000..123cc25cb41 --- /dev/null +++ b/packages/remix/invariant.ts @@ -0,0 +1,16 @@ +export default function invariant( + value: boolean, + message?: string +): asserts value; +export default function invariant( + value: T | null | undefined, + message?: string +): asserts value is T; +export default function invariant(value: any, message?: string) { + if (value === false || value === null || typeof value === "undefined") { + console.error( + "The following error is a bug in Remix; please open an issue! https://github.com/remix-run/remix/issues/new" + ); + throw new Error(message); + } +} diff --git a/packages/remix/jest.config.js b/packages/remix/jest.config.js new file mode 100644 index 00000000000..e93904bbc41 --- /dev/null +++ b/packages/remix/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('jest').Config} */ +module.exports = { + ...require("../../jest/jest.config.shared"), + displayName: "remix", +}; diff --git a/packages/remix/jsonify.ts b/packages/remix/jsonify.ts new file mode 100644 index 00000000000..80e524ff573 --- /dev/null +++ b/packages/remix/jsonify.ts @@ -0,0 +1,261 @@ +import { + expectType, + type Equal, + type Expect, + type MutualExtends, +} from "./typecheck"; + +// prettier-ignore +// `Jsonify` emulates `let y = JSON.parse(JSON.stringify(x))`, but for types +// so that we can infer the shape of the data sent over the network. +export type Jsonify = + // any + IsAny extends true ? any : + + // toJSON + T extends { toJSON(): infer U } ? (U extends JsonValue ? U : unknown) : + + // primitives + T extends JsonPrimitive ? T : + T extends String ? string : + T extends Number ? number : + T extends Boolean ? boolean : + + // Promises JSON.stringify to an empty object + T extends Promise ? EmptyObject : + + // Map & Set + T extends Map ? EmptyObject : + T extends Set ? EmptyObject : + + // TypedArray + T extends TypedArray ? Record : + + // Not JSON serializable + T extends NotJson ? never : + + // tuple & array + T extends [] ? [] : + T extends readonly [infer F, ...infer R] ? [NeverToNull>, ...Jsonify] : + T extends readonly unknown[] ? Array>>: + + // object + T extends Record ? JsonifyObject : + + // unknown + unknown extends T ? unknown : + + never + +// value is always not JSON => true +// value is always JSON => false +// value is somtimes JSON, sometimes not JSON => boolean +// note: cannot be inlined as logic requires union distribution +type ValueIsNotJson = T extends NotJson ? true : false; + +// note: remove optionality so that produced values are never `undefined`, +// only `true`, `false`, or `boolean` +type IsNotJson = { [K in keyof T]-?: ValueIsNotJson }; + +type JsonifyValues = { [K in keyof T]: Jsonify }; + +// prettier-ignore +type JsonifyObject> = + // required + { [K in keyof T as + unknown extends T[K] ? never : + IsNotJson[K] extends false ? K : + never + ]: JsonifyValues[K] } & + // optional + { [K in keyof T as + unknown extends T[K] ? K : + // if the value is always JSON, then it's not optional + IsNotJson[K] extends false ? never : + // if the value is always not JSON, omit it entirely + IsNotJson[K] extends true ? never : + // if the value is mixed, then it's optional + K + ]? : JsonifyValues[K]} + +// types ------------------------------------------------------------ + +type JsonPrimitive = string | number | boolean | null; + +type JsonArray = JsonValue[] | readonly JsonValue[]; + +// prettier-ignore +type JsonObject = + { [K in string]: JsonValue } & + { [K in string]?: JsonValue } + +type JsonValue = JsonPrimitive | JsonObject | JsonArray; + +type NotJson = undefined | symbol | AnyFunction; + +type TypedArray = + | Int8Array + | Uint8Array + | Uint8ClampedArray + | Int16Array + | Uint16Array + | Int32Array + | Uint32Array + | Float32Array + | Float64Array + | BigInt64Array + | BigUint64Array; + +// tests ------------------------------------------------------------ + +// prettier-ignore +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type _tests = [ + // any + Expect, any>>, + + // primitives + Expect, string>>, + Expect, number>>, + Expect, boolean>>, + Expect, null>>, + Expect, string>>, + Expect, number>>, + Expect, boolean>>, + Expect>, EmptyObject>>, + + // Map & Set + Expect>, EmptyObject>>, + Expect>, EmptyObject>>, + + // TypedArray + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + Expect, Record>>, + + // Not Json + Expect, never>>, + Expect, never>>, + Expect void>, never>>, + Expect, never>>, + + // toJson + Expect, "stuff">>, + Expect, string>>, + Expect, unknown>>, + Expect, unknown>>, + Expect, string>>, + + + // tuple & array + Expect, []>>, + Expect, [1, 'two', string, null, false]>>, + Expect, (string | number)[]>>, + Expect, null[]>>, + Expect, [1,2,3]>>, + + // object + Expect>, {}>>, + Expect>, {a: string}>>, + Expect>, {a?: string}>>, + Expect>, {a?: string}>>, + Expect>, {a: string, b?: string}>>, + Expect>, {}>>, + Expect>>, Record>>, + Expect>>, Record>>, + Expect}>, { payload: Record}>>, + Expect any); + optionalFunctionUnion?: string | (() => any); + optionalFunctionUnionUndefined: string | (() => any) | undefined; + + // Should be omitted + requiredFunction: () => any; + optionalFunction?: () => any; + optionalFunctionUndefined: (() => any) | undefined; + }>>, { + requiredString: string + requiredUnion: number | boolean + + optionalString?: string; + optionalUnion?: number | string; + optionalStringUndefined?: string | undefined; + optionalUnionUndefined?: number | string | undefined; + requiredFunctionUnion?: string + optionalFunctionUnion?: string; + optionalFunctionUnionUndefined?: string + }>>, + + // unknown + Expect, unknown>>, + Expect, unknown[]>>, + Expect, [unknown, 1]>>, + Expect>, {a?: unknown}>>, + Expect>, {a?: unknown, b: 'hello'}>>, + + // never + Expect, never>>, + Expect>, {a: never}>>, + Expect>, {a: never, b:string}>>, + Expect>, {a: never, b: string} | {a: string, b: never}>>, + + // class + Expect>, {a: string}>>, +]; + +class MyClass { + a: string; + b: () => string; + + constructor() { + this.a = "hello"; + this.b = () => "world"; + } +} + +// real-world example: `InvoiceLineItem` from `stripe` +type Recursive = { + a: Date; + recur?: Recursive; +}; +declare const recursive: Jsonify; +expectType<{ a: string; recur?: Jsonify }>( + recursive.recur!.recur!.recur! +); + +// real-world example: `Temporal` from `@js-temporal/polyfill` +interface BooleanWithToJson extends Boolean { + toJSON(): string; +} + +// utils ------------------------------------------------------------ + +type Pretty = { [K in keyof T]: T[K] }; + +type AnyFunction = (...args: any[]) => unknown; + +type NeverToNull = [T] extends [never] ? null : T; + +// adapted from https://github.com/sindresorhus/type-fest/blob/main/source/empty-object.d.ts +declare const emptyObjectSymbol: unique symbol; +export type EmptyObject = { [emptyObjectSymbol]?: never }; + +// adapted from https://github.com/type-challenges/type-challenges/blob/main/utils/index.d.ts +type IsAny = 0 extends 1 & T ? true : false; diff --git a/packages/remix/links.ts b/packages/remix/links.ts new file mode 100644 index 00000000000..4cfab9f0f05 --- /dev/null +++ b/packages/remix/links.ts @@ -0,0 +1,192 @@ +type Primitive = null | undefined | string | number | boolean | symbol | bigint; + +type LiteralUnion = + | LiteralType + | (BaseType & Record); + +interface HtmlLinkProps { + /** + * Address of the hyperlink + */ + href?: string; + + /** + * How the element handles crossorigin requests + */ + crossOrigin?: "anonymous" | "use-credentials"; + + /** + * Relationship between the document containing the hyperlink and the destination resource + */ + rel: LiteralUnion< + | "alternate" + | "dns-prefetch" + | "icon" + | "manifest" + | "modulepreload" + | "next" + | "pingback" + | "preconnect" + | "prefetch" + | "preload" + | "prerender" + | "search" + | "stylesheet", + string + >; + + /** + * Applicable media: "screen", "print", "(max-width: 764px)" + */ + media?: string; + + /** + * Integrity metadata used in Subresource Integrity checks + */ + integrity?: string; + + /** + * Language of the linked resource + */ + hrefLang?: string; + + /** + * Hint for the type of the referenced resource + */ + type?: string; + + /** + * Referrer policy for fetches initiated by the element + */ + referrerPolicy?: + | "" + | "no-referrer" + | "no-referrer-when-downgrade" + | "same-origin" + | "origin" + | "strict-origin" + | "origin-when-cross-origin" + | "strict-origin-when-cross-origin" + | "unsafe-url"; + + /** + * Sizes of the icons (for rel="icon") + */ + sizes?: string; + + /** + * Potential destination for a preload request (for rel="preload" and rel="modulepreload") + */ + as?: LiteralUnion< + | "audio" + | "audioworklet" + | "document" + | "embed" + | "fetch" + | "font" + | "frame" + | "iframe" + | "image" + | "manifest" + | "object" + | "paintworklet" + | "report" + | "script" + | "serviceworker" + | "sharedworker" + | "style" + | "track" + | "video" + | "worker" + | "xslt", + string + >; + + /** + * Color to use when customizing a site's icon (for rel="mask-icon") + */ + color?: string; + + /** + * Whether the link is disabled + */ + disabled?: boolean; + + /** + * The title attribute has special semantics on this element: Title of the link; CSS style sheet set name. + */ + title?: string; + + /** + * Images to use in different situations, e.g., high-resolution displays, + * small monitors, etc. (for rel="preload") + */ + imageSrcSet?: string; + + /** + * Image sizes for different page layouts (for rel="preload") + */ + imageSizes?: string; +} + +interface HtmlLinkPreloadImage extends HtmlLinkProps { + /** + * Relationship between the document containing the hyperlink and the destination resource + */ + rel: "preload"; + + /** + * Potential destination for a preload request (for rel="preload" and rel="modulepreload") + */ + as: "image"; + + /** + * Address of the hyperlink + */ + href?: string; + + /** + * Images to use in different situations, e.g., high-resolution displays, + * small monitors, etc. (for rel="preload") + */ + imageSrcSet: string; + + /** + * Image sizes for different page layouts (for rel="preload") + */ + imageSizes?: string; +} + +/** + * Represents a `` element. + * + * WHATWG Specification: https://html.spec.whatwg.org/multipage/semantics.html#the-link-element + */ +export type HtmlLinkDescriptor = + // Must have an href *unless* it's a `` with an + // `imageSrcSet` and `imageSizes` props + | (HtmlLinkProps & Pick, "href">) + | (HtmlLinkPreloadImage & Pick, "imageSizes">) + | (HtmlLinkPreloadImage & + Pick, "href"> & { imageSizes?: never }); + +export interface PageLinkDescriptor + extends Omit< + HtmlLinkDescriptor, + | "href" + | "rel" + | "type" + | "sizes" + | "imageSrcSet" + | "imageSizes" + | "as" + | "color" + | "title" + > { + /** + * The absolute path of the page to prefetch. + */ + page: string; +} + +export type LinkDescriptor = HtmlLinkDescriptor | PageLinkDescriptor; diff --git a/packages/remix/markup.ts b/packages/remix/markup.ts new file mode 100644 index 00000000000..4ab1fdcc784 --- /dev/null +++ b/packages/remix/markup.ts @@ -0,0 +1,19 @@ +// This escapeHtml utility is based on https://github.com/zertosh/htmlescape +// License: https://github.com/zertosh/htmlescape/blob/0527ca7156a524d256101bb310a9f970f63078ad/LICENSE + +// We've chosen to inline the utility here to reduce the number of npm dependencies we have, +// slightly decrease the code size compared the original package and make it esm compatible. + +const ESCAPE_LOOKUP: { [match: string]: string } = { + "&": "\\u0026", + ">": "\\u003e", + "<": "\\u003c", + "\u2028": "\\u2028", + "\u2029": "\\u2029", +}; + +const ESCAPE_REGEX = /[&><\u2028\u2029]/g; + +export function escapeHtml(html: string) { + return html.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]); +} diff --git a/packages/remix/mode.ts b/packages/remix/mode.ts new file mode 100644 index 00000000000..903aaa3bee8 --- /dev/null +++ b/packages/remix/mode.ts @@ -0,0 +1,16 @@ +/** + * The mode to use when running the server. + */ +export enum ServerMode { + Development = "development", + Production = "production", + Test = "test", +} + +export function isServerMode(value: any): value is ServerMode { + return ( + value === ServerMode.Development || + value === ServerMode.Production || + value === ServerMode.Test + ); +} diff --git a/packages/remix/package.json b/packages/remix/package.json index dff599351c2..c0f83e686c0 100644 --- a/packages/remix/package.json +++ b/packages/remix/package.json @@ -1,8 +1,7 @@ { "name": "remix", "version": "2.8.1", - "description": "A framework for building better websites", - "homepage": "https://remix.run", + "description": "Server runtime for Remix", "bugs": { "url": "https://github.com/remix-run/remix/issues" }, @@ -19,6 +18,26 @@ "scripts": { "tsc": "tsc" }, + "dependencies": { + "@remix-run/router": "1.15.3", + "@types/cookie": "^0.6.0", + "@web3-storage/multipart-parser": "^1.0.0", + "cookie": "^0.6.0", + "set-cookie-parser": "^2.4.8", + "source-map": "^0.7.3" + }, + "devDependencies": { + "@types/set-cookie-parser": "^2.4.1", + "typescript": "^5.1.6" + }, + "peerDependencies": { + "typescript": "^5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + }, "engines": { "node": ">=18.0.0" }, diff --git a/packages/remix/reexport.ts b/packages/remix/reexport.ts new file mode 100644 index 00000000000..cc7fc46f507 --- /dev/null +++ b/packages/remix/reexport.ts @@ -0,0 +1,63 @@ +export type { ErrorResponse } from "@remix-run/router"; + +export type { + HandleDataRequestFunction, + HandleDocumentRequestFunction, + HandleErrorFunction, + ServerBuild, + ServerEntryModule, +} from "./build"; + +export type { UploadHandlerPart, UploadHandler } from "./formData"; +export type { + MemoryUploadHandlerOptions, + MemoryUploadHandlerFilterArgs, +} from "./upload/memoryUploadHandler"; + +export type { + Cookie, + CookieOptions, + CookieParseOptions, + CookieSerializeOptions, + CookieSignatureOptions, +} from "./cookies"; + +export type { SignFunction, UnsignFunction } from "./crypto"; + +export type { AppLoadContext } from "./data"; + +export type { EntryContext } from "./entry"; + +export type { + HtmlLinkDescriptor, + LinkDescriptor, + PageLinkDescriptor, +} from "./links"; + +export type { TypedDeferredData, TypedResponse } from "./responses"; + +export type { + ActionFunction, + ActionFunctionArgs, + DataFunctionArgs, + HeadersArgs, + HeadersFunction, + LinksFunction, + LoaderFunction, + LoaderFunctionArgs, + ServerRuntimeMetaArgs, + ServerRuntimeMetaDescriptor, + ServerRuntimeMetaFunction, +} from "./routeModules"; + +export type { SerializeFrom } from "./serialize"; + +export type { RequestHandler } from "./server"; + +export type { + Session, + SessionData, + SessionIdStorageStrategy, + SessionStorage, + FlashSessionData, +} from "./sessions"; diff --git a/packages/remix/responses.ts b/packages/remix/responses.ts new file mode 100644 index 00000000000..32a609cd5cb --- /dev/null +++ b/packages/remix/responses.ts @@ -0,0 +1,211 @@ +import { + defer as routerDefer, + json as routerJson, + redirect as routerRedirect, + redirectDocument as routerRedirectDocument, + type UNSAFE_DeferredData as DeferredData, + type TrackedPromise, +} from "@remix-run/router"; + +import { serializeError } from "./errors"; +import type { ServerMode } from "./mode"; + +declare const typedDeferredDataBrand: unique symbol; + +export type TypedDeferredData> = Pick< + DeferredData, + "init" +> & { + data: Data; + readonly [typedDeferredDataBrand]: "TypedDeferredData"; +}; + +export type DeferFunction = >( + data: Data, + init?: number | ResponseInit +) => TypedDeferredData; + +export type JsonFunction = ( + data: Data, + init?: number | ResponseInit +) => TypedResponse; + +// must be a type since this is a subtype of response +// interfaces must conform to the types they extend +export type TypedResponse = Omit & { + json(): Promise; +}; + +/** + * This is a shortcut for creating `application/json` responses. Converts `data` + * to JSON and sets the `Content-Type` header. + * + * @see https://remix.run/utils/json + */ +export const json: JsonFunction = (data, init = {}) => { + return routerJson(data, init); +}; + +/** + * This is a shortcut for creating Remix deferred responses + * + * @see https://remix.run/utils/defer + */ +export const defer: DeferFunction = (data, init = {}) => { + return routerDefer(data, init) as unknown as TypedDeferredData; +}; + +export type RedirectFunction = ( + url: string, + init?: number | ResponseInit +) => TypedResponse; + +/** + * A redirect response. Sets the status code and the `Location` header. + * Defaults to "302 Found". + * + * @see https://remix.run/utils/redirect + */ +export const redirect: RedirectFunction = (url, init = 302) => { + return routerRedirect(url, init) as TypedResponse; +}; + +/** + * A redirect response that will force a document reload to the new location. + * Sets the status code and the `Location` header. + * Defaults to "302 Found". + * + * @see https://remix.run/utils/redirect + */ +export const redirectDocument: RedirectFunction = (url, init = 302) => { + return routerRedirectDocument(url, init) as TypedResponse; +}; + +export function isDeferredData(value: any): value is DeferredData { + let deferred: DeferredData = value; + return ( + deferred && + typeof deferred === "object" && + typeof deferred.data === "object" && + typeof deferred.subscribe === "function" && + typeof deferred.cancel === "function" && + typeof deferred.resolveData === "function" + ); +} + +export function isResponse(value: any): value is Response { + return ( + value != null && + typeof value.status === "number" && + typeof value.statusText === "string" && + typeof value.headers === "object" && + typeof value.body !== "undefined" + ); +} + +const redirectStatusCodes = new Set([301, 302, 303, 307, 308]); +export function isRedirectStatusCode(statusCode: number): boolean { + return redirectStatusCodes.has(statusCode); +} +export function isRedirectResponse(response: Response): boolean { + return isRedirectStatusCode(response.status); +} + +function isTrackedPromise(value: any): value is TrackedPromise { + return ( + value != null && typeof value.then === "function" && value._tracked === true + ); +} + +// TODO: Figure out why ReadableStream types are borked sooooooo badly +// in this file. Probably related to our TS configurations and configs +// bleeding into each other. +const DEFERRED_VALUE_PLACEHOLDER_PREFIX = "__deferred_promise:"; +export function createDeferredReadableStream( + deferredData: DeferredData, + signal: AbortSignal, + serverMode: ServerMode +): any { + let encoder = new TextEncoder(); + let stream = new ReadableStream({ + async start(controller: any) { + let criticalData: any = {}; + + let preresolvedKeys: string[] = []; + for (let [key, value] of Object.entries(deferredData.data)) { + if (isTrackedPromise(value)) { + criticalData[key] = `${DEFERRED_VALUE_PLACEHOLDER_PREFIX}${key}`; + if ( + typeof value._data !== "undefined" || + typeof value._error !== "undefined" + ) { + preresolvedKeys.push(key); + } + } else { + criticalData[key] = value; + } + } + + // Send the critical data + controller.enqueue(encoder.encode(JSON.stringify(criticalData) + "\n\n")); + + for (let preresolvedKey of preresolvedKeys) { + enqueueTrackedPromise( + controller, + encoder, + preresolvedKey, + deferredData.data[preresolvedKey] as TrackedPromise, + serverMode + ); + } + + let unsubscribe = deferredData.subscribe((aborted, settledKey) => { + if (settledKey) { + enqueueTrackedPromise( + controller, + encoder, + settledKey, + deferredData.data[settledKey] as TrackedPromise, + serverMode + ); + } + }); + await deferredData.resolveData(signal); + unsubscribe(); + controller.close(); + }, + }); + + return stream; +} + +function enqueueTrackedPromise( + controller: any, + encoder: TextEncoder, + settledKey: string, + promise: TrackedPromise, + serverMode: ServerMode +) { + if ("_error" in promise) { + controller.enqueue( + encoder.encode( + "error:" + + JSON.stringify({ + [settledKey]: + promise._error instanceof Error + ? serializeError(promise._error, serverMode) + : promise._error, + }) + + "\n\n" + ) + ); + } else { + controller.enqueue( + encoder.encode( + "data:" + + JSON.stringify({ [settledKey]: promise._data ?? null }) + + "\n\n" + ) + ); + } +} diff --git a/packages/remix/rollup.config.js b/packages/remix/rollup.config.js index fbcb73848e1..97b2de325e4 100644 --- a/packages/remix/rollup.config.js +++ b/packages/remix/rollup.config.js @@ -1,13 +1,16 @@ -const babel = require("@rollup/plugin-babel").default; +/* eslint-disable import/no-nodejs-modules */ const path = require("node:path"); +const babel = require("@rollup/plugin-babel").default; +const nodeResolve = require("@rollup/plugin-node-resolve").default; +const copy = require("rollup-plugin-copy"); const { - copyPublishFiles, - copyToPlaygrounds, - createBanner, getOutputDir, + isBareModuleId, + createBanner, + copyToPlaygrounds, } = require("../../rollup.utils"); -let { name: packageName, version } = require("./package.json"); +const { name: packageName, version } = require("./package.json"); /** @returns {import("rollup").RollupOptions[]} */ module.exports = function rollup() { @@ -17,14 +20,16 @@ module.exports = function rollup() { return [ { - external() { - return true; + external(id) { + return isBareModuleId(id); }, input: `${sourceDir}/index.ts`, output: { - format: "cjs", - dir: outputDist, banner: createBanner(packageName, version), + dir: outputDist, + format: "cjs", + preserveModules: true, + exports: "named", }, plugins: [ babel({ @@ -32,19 +37,27 @@ module.exports = function rollup() { exclude: /node_modules/, extensions: [".ts", ".tsx"], }), - copyPublishFiles(packageName), + nodeResolve({ extensions: [".ts", ".tsx"] }), + copy({ + targets: [ + { src: "LICENSE.md", dest: [outputDir, sourceDir] }, + { src: `${sourceDir}/package.json`, dest: outputDir }, + { src: `${sourceDir}/README.md`, dest: outputDir }, + ], + }), copyToPlaygrounds(), ], }, { - external() { - return true; + external(id) { + return isBareModuleId(id); }, input: `${sourceDir}/index.ts`, output: { - format: "esm", - dir: path.join(outputDist, "esm"), banner: createBanner(packageName, version), + dir: `${outputDist}/esm`, + format: "esm", + preserveModules: true, }, plugins: [ babel({ @@ -52,7 +65,7 @@ module.exports = function rollup() { exclude: /node_modules/, extensions: [".ts", ".tsx"], }), - copyPublishFiles(packageName), + nodeResolve({ extensions: [".ts", ".tsx"] }), copyToPlaygrounds(), ], }, diff --git a/packages/remix/routeMatching.ts b/packages/remix/routeMatching.ts new file mode 100644 index 00000000000..fe8d20a4c3e --- /dev/null +++ b/packages/remix/routeMatching.ts @@ -0,0 +1,29 @@ +import type { Params, AgnosticRouteObject } from "@remix-run/router"; +import { matchRoutes } from "@remix-run/router"; + +import type { ServerRoute } from "./routes"; + +export interface RouteMatch { + params: Params; + pathname: string; + route: Route; +} + +export function matchServerRoutes( + routes: ServerRoute[], + pathname: string, + basename?: string +): RouteMatch[] | null { + let matches = matchRoutes( + routes as unknown as AgnosticRouteObject[], + pathname, + basename + ); + if (!matches) return null; + + return matches.map((match) => ({ + params: match.params, + pathname: match.pathname, + route: match.route as unknown as ServerRoute, + })); +} diff --git a/packages/remix/routeModules.ts b/packages/remix/routeModules.ts new file mode 100644 index 00000000000..b51000648bf --- /dev/null +++ b/packages/remix/routeModules.ts @@ -0,0 +1,268 @@ +import type { + ActionFunction as RRActionFunction, + ActionFunctionArgs as RRActionFunctionArgs, + AgnosticRouteMatch, + LoaderFunction as RRLoaderFunction, + LoaderFunctionArgs as RRLoaderFunctionArgs, + Location, + Params, +} from "@remix-run/router"; + +import type { AppData, AppLoadContext } from "./data"; +import type { LinkDescriptor } from "./links"; +import type { SerializeFrom } from "./serialize"; + +export interface RouteModules { + [routeId: string]: RouteModule | undefined; +} + +/** + * @deprecated Use `LoaderFunctionArgs`/`ActionFunctionArgs` instead + */ +export type DataFunctionArgs = RRActionFunctionArgs & + RRLoaderFunctionArgs & { + // Context is always provided in Remix, and typed for module augmentation support. + // RR also doesn't export DataFunctionArgs, so we extend the two interfaces here + // even tough they're identical under the hood + context: AppLoadContext; + }; + +/** + * A function that handles data mutations for a route on the server + */ +export type ActionFunction = ( + args: ActionFunctionArgs +) => ReturnType; + +/** + * Arguments passed to a route `action` function + */ +export type ActionFunctionArgs = RRActionFunctionArgs & { + // Context is always provided in Remix, and typed for module augmentation support. + context: AppLoadContext; +}; + +/** + * A function that handles data mutations for a route on the client + * @private Public API is exported from @remix-run/react + */ +type ClientActionFunction = ( + args: ClientActionFunctionArgs +) => ReturnType; + +/** + * Arguments passed to a route `clientAction` function + * @private Public API is exported from @remix-run/react + */ +export type ClientActionFunctionArgs = RRActionFunctionArgs & { + serverAction: () => Promise>; +}; + +/** + * A function that loads data for a route on the server + */ +export type LoaderFunction = ( + args: LoaderFunctionArgs +) => ReturnType; + +/** + * Arguments passed to a route `loader` function + */ +export type LoaderFunctionArgs = RRLoaderFunctionArgs & { + // Context is always provided in Remix, and typed for module augmentation support. + context: AppLoadContext; +}; + +/** + * A function that loads data for a route on the client + * @private Public API is exported from @remix-run/react + */ +type ClientLoaderFunction = (( + args: ClientLoaderFunctionArgs +) => ReturnType) & { + hydrate?: boolean; +}; + +/** + * Arguments passed to a route `clientLoader` function + * @private Public API is exported from @remix-run/react + */ +export type ClientLoaderFunctionArgs = RRLoaderFunctionArgs & { + serverLoader: () => Promise>; +}; + +export type HeadersArgs = { + loaderHeaders: Headers; + parentHeaders: Headers; + actionHeaders: Headers; + errorHeaders: Headers | undefined; +}; + +/** + * A function that returns HTTP headers to be used for a route. These headers + * will be merged with (and take precedence over) headers from parent routes. + */ +export interface HeadersFunction { + (args: HeadersArgs): Headers | HeadersInit; +} + +/** + * A function that defines `` tags to be inserted into the `` of + * the document on route transitions. + */ +export interface LinksFunction { + (): LinkDescriptor[]; +} + +/** + * A function that returns an array of data objects to use for rendering + * metadata HTML tags in a route. These tags are not rendered on descendant + * routes in the route hierarchy. In other words, they will only be rendered on + * the route in which they are exported. + * + * @param Loader - The type of the current route's loader function + * @param MatchLoaders - Mapping from a parent route's filepath to its loader + * function type + * + * Note that parent route filepaths are relative to the `app/` directory. + * + * For example, if this meta function is for `/sales/customers/$customerId`: + * + * ```ts + * // app/root.tsx + * const loader = () => { + * return json({ hello: "world" } as const) + * } + * export type Loader = typeof loader + * + * // app/routes/sales.tsx + * const loader = () => { + * return json({ salesCount: 1074 }) + * } + * export type Loader = typeof loader + * + * // app/routes/sales/customers.tsx + * const loader = () => { + * return json({ customerCount: 74 }) + * } + * export type Loader = typeof loader + * + * // app/routes/sales/customers/$customersId.tsx + * import type { Loader as RootLoader } from "../../../root" + * import type { Loader as SalesLoader } from "../../sales" + * import type { Loader as CustomersLoader } from "../../sales/customers" + * + * const loader = () => { + * return json({ name: "Customer name" }) + * } + * + * const meta: MetaFunction = ({ data, matches }) => { + * const { name } = data + * // ^? string + * const { customerCount } = matches.find((match) => match.id === "routes/sales/customers").data + * // ^? number + * const { salesCount } = matches.find((match) => match.id === "routes/sales").data + * // ^? number + * const { hello } = matches.find((match) => match.id === "root").data + * // ^? "world" + * } + * ``` + */ +export interface ServerRuntimeMetaFunction< + Loader extends LoaderFunction | unknown = unknown, + ParentsLoaders extends Record = Record< + string, + unknown + > +> { + ( + args: ServerRuntimeMetaArgs + ): ServerRuntimeMetaDescriptor[]; +} + +interface ServerRuntimeMetaMatch< + RouteId extends string = string, + Loader extends LoaderFunction | unknown = unknown +> { + id: RouteId; + pathname: AgnosticRouteMatch["pathname"]; + data: Loader extends LoaderFunction ? SerializeFrom : unknown; + handle?: RouteHandle; + params: AgnosticRouteMatch["params"]; + meta: ServerRuntimeMetaDescriptor[]; + error?: unknown; +} + +type ServerRuntimeMetaMatches< + MatchLoaders extends Record = Record< + string, + unknown + > +> = Array< + { + [K in keyof MatchLoaders]: ServerRuntimeMetaMatch< + Exclude, + MatchLoaders[K] + >; + }[keyof MatchLoaders] +>; + +export interface ServerRuntimeMetaArgs< + Loader extends LoaderFunction | unknown = unknown, + MatchLoaders extends Record = Record< + string, + unknown + > +> { + data: + | (Loader extends LoaderFunction ? SerializeFrom : AppData) + | undefined; + params: Params; + location: Location; + matches: ServerRuntimeMetaMatches; + error?: unknown; +} + +export type ServerRuntimeMetaDescriptor = + | { charSet: "utf-8" } + | { title: string } + | { name: string; content: string } + | { property: string; content: string } + | { httpEquiv: string; content: string } + | { "script:ld+json": LdJsonObject } + | { tagName: "meta" | "link"; [name: string]: string } + | { [name: string]: unknown }; + +type LdJsonObject = { [Key in string]: LdJsonValue } & { + [Key in string]?: LdJsonValue | undefined; +}; +type LdJsonArray = LdJsonValue[] | readonly LdJsonValue[]; +type LdJsonPrimitive = string | number | boolean | null; +type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray; + +/** + * An arbitrary object that is associated with a route. + */ +export type RouteHandle = unknown; + +export interface EntryRouteModule { + clientAction?: ClientActionFunction; + clientLoader?: ClientLoaderFunction; + ErrorBoundary?: any; // Weakly typed because this package is not React-aware + HydrateFallback?: any; // Weakly typed because this package is not React-aware + Layout?: any; // Weakly typed because this package is not React-aware + default: any; // Weakly typed because this package is not React-aware + handle?: RouteHandle; + links?: LinksFunction; + meta?: ServerRuntimeMetaFunction; +} + +export interface ServerRouteModule extends EntryRouteModule { + action?: ActionFunction; + headers?: HeadersFunction | { [name: string]: string }; + loader?: LoaderFunction; +} diff --git a/packages/remix/routes.ts b/packages/remix/routes.ts new file mode 100644 index 00000000000..d25c5098de0 --- /dev/null +++ b/packages/remix/routes.ts @@ -0,0 +1,133 @@ +import type { + AgnosticDataRouteObject, + LoaderFunctionArgs as RRLoaderFunctionArgs, + ActionFunctionArgs as RRActionFunctionArgs, +} from "@remix-run/router"; + +import { callRouteActionRR, callRouteLoaderRR } from "./data"; +import type { FutureConfig } from "./entry"; +import type { ServerRouteModule } from "./routeModules"; + +export interface RouteManifest { + [routeId: string]: Route; +} + +export type ServerRouteManifest = RouteManifest>; + +// NOTE: make sure to change the Route in remix-react if you change this +export interface Route { + index?: boolean; + caseSensitive?: boolean; + id: string; + parentId?: string; + path?: string; +} + +// NOTE: make sure to change the EntryRoute in remix-react if you change this +export interface EntryRoute extends Route { + hasAction: boolean; + hasLoader: boolean; + hasClientAction: boolean; + hasClientLoader: boolean; + hasErrorBoundary: boolean; + imports?: string[]; + css?: string[]; + module: string; + parentId?: string; +} + +export interface ServerRoute extends Route { + children: ServerRoute[]; + module: ServerRouteModule; +} + +function groupRoutesByParentId(manifest: ServerRouteManifest) { + let routes: Record[]> = {}; + + Object.values(manifest).forEach((route) => { + let parentId = route.parentId || ""; + if (!routes[parentId]) { + routes[parentId] = []; + } + routes[parentId].push(route); + }); + + return routes; +} + +// Create a map of routes by parentId to use recursively instead of +// repeatedly filtering the manifest. +export function createRoutes( + manifest: ServerRouteManifest, + parentId: string = "", + routesByParentId: Record< + string, + Omit[] + > = groupRoutesByParentId(manifest) +): ServerRoute[] { + return (routesByParentId[parentId] || []).map((route) => ({ + ...route, + children: createRoutes(manifest, route.id, routesByParentId), + })); +} + +// Convert the Remix ServerManifest into DataRouteObject's for use with +// createStaticHandler +export function createStaticHandlerDataRoutes( + manifest: ServerRouteManifest, + future: FutureConfig, + parentId: string = "", + routesByParentId: Record< + string, + Omit[] + > = groupRoutesByParentId(manifest) +): AgnosticDataRouteObject[] { + return (routesByParentId[parentId] || []).map((route) => { + let commonRoute = { + // Always include root due to default boundaries + hasErrorBoundary: + route.id === "root" || route.module.ErrorBoundary != null, + id: route.id, + path: route.path, + loader: route.module.loader + ? // Need to use RR's version here to permit the optional context even + // though we know it'll always be provided in remix + (args: RRLoaderFunctionArgs) => + callRouteLoaderRR({ + request: args.request, + params: args.params, + loadContext: args.context, + loader: route.module.loader!, + routeId: route.id, + }) + : undefined, + action: route.module.action + ? (args: RRActionFunctionArgs) => + callRouteActionRR({ + request: args.request, + params: args.params, + loadContext: args.context, + action: route.module.action!, + routeId: route.id, + }) + : undefined, + handle: route.module.handle, + }; + + return route.index + ? { + index: true, + ...commonRoute, + } + : { + caseSensitive: route.caseSensitive, + children: createStaticHandlerDataRoutes( + manifest, + future, + route.id, + routesByParentId + ), + ...commonRoute, + }; + }); +} diff --git a/packages/remix/serialize.ts b/packages/remix/serialize.ts new file mode 100644 index 00000000000..c3d4822f498 --- /dev/null +++ b/packages/remix/serialize.ts @@ -0,0 +1,177 @@ +import type { EmptyObject, Jsonify } from "./jsonify"; +import type { TypedDeferredData, TypedResponse } from "./responses"; +import type { + ClientActionFunctionArgs, + ClientLoaderFunctionArgs, +} from "./routeModules"; +import { expectType } from "./typecheck"; +import { type Expect, type Equal } from "./typecheck"; + +// prettier-ignore +/** + * Infer JSON serialized data type returned by a loader or action, while + * avoiding deserialization if the input type if it's a clientLoader or + * clientAction that returns a non-Response + * + * For example: + * `type LoaderData = SerializeFrom` + */ +export type SerializeFrom = + T extends (...args: any[]) => infer Output ? + Parameters extends [ClientLoaderFunctionArgs | ClientActionFunctionArgs] ? + // Client data functions may not serialize + SerializeClient> + : + // Serialize responses + Serialize> + : + // Back compat: manually defined data type, not inferred from loader nor action + Jsonify> +; + +// note: cannot be inlined as logic requires union distribution +// prettier-ignore +type SerializeClient = + Output extends TypedDeferredData ? + // top-level promises + & { + [K in keyof U as K extends symbol + ? never + : Promise extends U[K] + ? K + : never]: DeferValueClient; // use generic to distribute over union + } + // non-promises + & { + [K in keyof U as Promise extends U[K] ? never : K]: U[K]; + } + : + Output extends TypedResponse ? Jsonify : + Awaited + +// prettier-ignore +type DeferValueClient = + T extends undefined ? undefined : + T extends Promise ? Promise> : + T; + +// note: cannot be inlined as logic requires union distribution +// prettier-ignore +type Serialize = + Output extends TypedDeferredData ? + // top-level promises + & { + [K in keyof U as + K extends symbol ? never : + Promise extends U[K] ? K : + never + ]: DeferValue; // use generic to distribute over union + } + // non-promises + & Jsonify<{ + [K in keyof U as + Promise extends U[K] ? never : + K + ]: U[K]; + }> + : + Output extends TypedResponse ? Jsonify : + Jsonify; + +// prettier-ignore +type DeferValue = + T extends undefined ? undefined : + T extends Promise ? Promise>> : + Jsonify; + +// tests ------------------------------------------------------------ + +type Pretty = { [K in keyof T]: T[K] }; + +type Loader = () => Promise>; + +type LoaderDefer> = () => Promise< + TypedDeferredData +>; + +type LoaderBoth< + T1 extends Record, + T2 extends Record +> = () => Promise | TypedDeferredData>; + +type ClientLoaderRaw> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise; // returned non-Response + +type ClientLoaderResponse> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise>; // returned responses + +type ClientLoaderDefer> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise>; // returned responses + +type ClientLoaderResponseAndDefer< + T1 extends Record, + T2 extends Record +> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise< + TypedResponse | TypedDeferredData +>; + +type ClientLoaderRawAndDefer< + T1 extends Record, + T2 extends Record +> = ({ + request, +}: ClientLoaderFunctionArgs) => Promise>; + +// prettier-ignore +// eslint-disable-next-line @typescript-eslint/no-unused-vars +type _tests = [ + // back compat: plain object + Expect>, {a: string}>>, + + // only thrown responses (e.g. redirects) + Expect>, never>>, + + // basic loader data + Expect>>, {a: string}>>, + + // infer data type from `toJSON` + Expect>>, {a: string}>>, + + // regression test for specific field names + Expect>>, {a: string, name: number, data: boolean}>>, + + // defer top-level promises + Expect}>> extends {a: string, lazy: Promise<{ b: number }>} ? true : false>, + + // conditional defer or json + Expect }, { c: string; lazy: Promise<{ d: number }>}>> extends { a: string, b: EmptyObject } | { c: string; lazy: Promise<{ d: number }> } ? true : false>, + + // clientLoader raw JSON + Expect>>, {a: string}>>, + Expect }>>>, {a: Date, b: Map}>>, + + // clientLoader json() Response + Expect>>, {a: string}>>, + Expect>>, {a: string}>>, + + // clientLoader defer() data + Expect}>> extends {a: string, lazy: Promise<{ b: number }>} ? true : false>, + + // clientLoader conditional defer or json + Expect }, { c: string; lazy: Promise<{ d: number }>}>> extends { a: string, b: EmptyObject } | { c: string; lazy: Promise<{ d: number }> } ? true : false>, + + // clientLoader conditional defer or raw + Expect }, { c: string; lazy: Promise<{ d: number }>}>> extends { a: string, b: Promise } | { c: string; lazy: Promise<{ d: number }> } ? true : false>, +]; + +// recursive +type Recursive = { a: string; recur?: Recursive }; +declare const recursive: SerializeFrom>; +expectType<{ a: string; recur?: Jsonify }>( + recursive.recur!.recur!.recur! +); diff --git a/packages/remix/server.ts b/packages/remix/server.ts new file mode 100644 index 00000000000..ab5683ed784 --- /dev/null +++ b/packages/remix/server.ts @@ -0,0 +1,493 @@ +import type { + UNSAFE_DeferredData as DeferredData, + ErrorResponse, + StaticHandler, +} from "@remix-run/router"; +import { + UNSAFE_DEFERRED_SYMBOL as DEFERRED_SYMBOL, + getStaticContextFromError, + isRouteErrorResponse, + createStaticHandler, + json as routerJson, + stripBasename, + UNSAFE_ErrorResponseImpl as ErrorResponseImpl, +} from "@remix-run/router"; + +import type { AppLoadContext } from "./data"; +import type { HandleErrorFunction, ServerBuild } from "./build"; +import type { EntryContext } from "./entry"; +import { createEntryRouteModules } from "./entry"; +import { sanitizeErrors, serializeError, serializeErrors } from "./errors"; +import { getDocumentHeadersRR } from "./headers"; +import invariant from "./invariant"; +import { ServerMode, isServerMode } from "./mode"; +import { matchServerRoutes } from "./routeMatching"; +import type { ServerRoute } from "./routes"; +import { createStaticHandlerDataRoutes, createRoutes } from "./routes"; +import { + createDeferredReadableStream, + isRedirectResponse, + isResponse, +} from "./responses"; +import { createServerHandoffString } from "./serverHandoff"; +import { getDevServerHooks } from "./dev"; + +export type RequestHandler = ( + request: Request, + loadContext?: AppLoadContext +) => Promise; + +export type CreateRequestHandlerFunction = ( + build: ServerBuild | (() => ServerBuild | Promise), + mode?: string +) => RequestHandler; + +function derive(build: ServerBuild, mode?: string) { + let routes = createRoutes(build.routes); + let dataRoutes = createStaticHandlerDataRoutes(build.routes, build.future); + let serverMode = isServerMode(mode) ? mode : ServerMode.Production; + let staticHandler = createStaticHandler(dataRoutes, { + basename: build.basename, + future: { + v7_relativeSplatPath: build.future?.v3_relativeSplatPath === true, + v7_throwAbortReason: build.future?.v3_throwAbortReason === true, + }, + }); + + let errorHandler = + build.entry.module.handleError || + ((error, { request }) => { + if (serverMode !== ServerMode.Test && !request.signal.aborted) { + console.error( + // @ts-expect-error This is "private" from users but intended for internal use + isRouteErrorResponse(error) && error.error ? error.error : error + ); + } + }); + return { + routes, + dataRoutes, + serverMode, + staticHandler, + errorHandler, + }; +} + +export const createRequestHandler: CreateRequestHandlerFunction = ( + build, + mode +) => { + let _build: ServerBuild; + let routes: ServerRoute[]; + let serverMode: ServerMode; + let staticHandler: StaticHandler; + let errorHandler: HandleErrorFunction; + + return async function requestHandler(request, loadContext = {}) { + _build = typeof build === "function" ? await build() : build; + mode ??= _build.mode; + if (typeof build === "function") { + let derived = derive(_build, mode); + routes = derived.routes; + serverMode = derived.serverMode; + staticHandler = derived.staticHandler; + errorHandler = derived.errorHandler; + } else if (!routes || !serverMode || !staticHandler || !errorHandler) { + let derived = derive(_build, mode); + routes = derived.routes; + serverMode = derived.serverMode; + staticHandler = derived.staticHandler; + errorHandler = derived.errorHandler; + } + + let url = new URL(request.url); + + let matches = matchServerRoutes(routes, url.pathname, _build.basename); + let handleError = (error: unknown) => { + if (mode === ServerMode.Development) { + getDevServerHooks()?.processRequestError?.(error); + } + + errorHandler(error, { + context: loadContext, + params: matches && matches.length > 0 ? matches[0].params : {}, + request, + }); + }; + + let response: Response; + if (url.searchParams.has("_data")) { + let routeId = url.searchParams.get("_data")!; + + response = await handleDataRequestRR( + serverMode, + _build, + staticHandler, + routeId, + request, + loadContext, + handleError + ); + + if (_build.entry.module.handleDataRequest) { + response = await _build.entry.module.handleDataRequest(response, { + context: loadContext, + params: matches?.find((m) => m.route.id == routeId)?.params || {}, + request, + }); + } + } else if ( + matches && + matches[matches.length - 1].route.module.default == null && + matches[matches.length - 1].route.module.ErrorBoundary == null + ) { + response = await handleResourceRequestRR( + serverMode, + staticHandler, + matches.slice(-1)[0].route.id, + request, + loadContext, + handleError + ); + } else { + let criticalCss = + mode === ServerMode.Development + ? await getDevServerHooks()?.getCriticalCss?.(_build, url.pathname) + : undefined; + + response = await handleDocumentRequestRR( + serverMode, + _build, + staticHandler, + request, + loadContext, + handleError, + criticalCss + ); + } + + if (request.method === "HEAD") { + return new Response(null, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); + } + + return response; + }; +}; + +async function handleDataRequestRR( + serverMode: ServerMode, + build: ServerBuild, + staticHandler: StaticHandler, + routeId: string, + request: Request, + loadContext: AppLoadContext, + handleError: (err: unknown) => void +) { + try { + let response = await staticHandler.queryRoute(request, { + routeId, + requestContext: loadContext, + }); + + if (isRedirectResponse(response)) { + // We don't have any way to prevent a fetch request from following + // redirects. So we use the `X-Remix-Redirect` header to indicate the + // next URL, and then "follow" the redirect manually on the client. + let headers = new Headers(response.headers); + let redirectUrl = headers.get("Location")!; + headers.set( + "X-Remix-Redirect", + build.basename + ? stripBasename(redirectUrl, build.basename) || redirectUrl + : redirectUrl + ); + headers.set("X-Remix-Status", response.status); + headers.delete("Location"); + if (response.headers.get("Set-Cookie") !== null) { + headers.set("X-Remix-Revalidate", "yes"); + } + + return new Response(null, { + status: 204, + headers, + }); + } + + if (DEFERRED_SYMBOL in response) { + let deferredData = response[DEFERRED_SYMBOL] as DeferredData; + let body = createDeferredReadableStream( + deferredData, + request.signal, + serverMode + ); + let init = deferredData.init || {}; + let headers = new Headers(init.headers); + headers.set("Content-Type", "text/remix-deferred"); + // Mark successful responses with a header so we can identify in-flight + // network errors that are missing this header + headers.set("X-Remix-Response", "yes"); + init.headers = headers; + return new Response(body, init); + } + + // Mark all successful responses with a header so we can identify in-flight + // network errors that are missing this header + response.headers.set("X-Remix-Response", "yes"); + return response; + } catch (error: unknown) { + if (isResponse(error)) { + error.headers.set("X-Remix-Catch", "yes"); + return error; + } + + if (isRouteErrorResponse(error)) { + if (error) { + handleError(error); + } + return errorResponseToJson(error, serverMode); + } + + let errorInstance = + error instanceof Error || error instanceof DOMException + ? error + : new Error("Unexpected Server Error"); + handleError(errorInstance); + return routerJson(serializeError(errorInstance, serverMode), { + status: 500, + headers: { + "X-Remix-Error": "yes", + }, + }); + } +} + +async function handleDocumentRequestRR( + serverMode: ServerMode, + build: ServerBuild, + staticHandler: StaticHandler, + request: Request, + loadContext: AppLoadContext, + handleError: (err: unknown) => void, + criticalCss?: string +) { + let context; + try { + context = await staticHandler.query(request, { + requestContext: loadContext, + }); + } catch (error: unknown) { + handleError(error); + return new Response(null, { status: 500 }); + } + + if (isResponse(context)) { + return context; + } + + // Sanitize errors outside of development environments + if (context.errors) { + Object.values(context.errors).forEach((err) => { + // @ts-expect-error This is "private" from users but intended for internal use + if (!isRouteErrorResponse(err) || err.error) { + handleError(err); + } + }); + context.errors = sanitizeErrors(context.errors, serverMode); + } + + let headers = getDocumentHeadersRR(build, context); + + let entryContext: EntryContext = { + manifest: build.assets, + routeModules: createEntryRouteModules(build.routes), + staticHandlerContext: context, + criticalCss, + serverHandoffString: createServerHandoffString({ + url: context.location.pathname, + basename: build.basename, + criticalCss, + state: { + loaderData: context.loaderData, + actionData: context.actionData, + errors: serializeErrors(context.errors, serverMode), + }, + future: build.future, + isSpaMode: build.isSpaMode, + }), + future: build.future, + isSpaMode: build.isSpaMode, + serializeError: (err) => serializeError(err, serverMode), + }; + + let handleDocumentRequestFunction = build.entry.module.default; + try { + return await handleDocumentRequestFunction( + request, + context.statusCode, + headers, + entryContext, + loadContext + ); + } catch (error: unknown) { + handleError(error); + + let errorForSecondRender = error; + + // If they threw a response, unwrap it into an ErrorResponse like we would + // have for a loader/action + if (isResponse(error)) { + let data; + try { + let contentType = error.headers.get("Content-Type"); + // Check between word boundaries instead of startsWith() due to the last + // paragraph of https://httpwg.org/specs/rfc9110.html#field.content-type + if (contentType && /\bapplication\/json\b/.test(contentType)) { + if (error.body == null) { + data = null; + } else { + data = await error.json(); + } + } else { + data = await error.text(); + } + + errorForSecondRender = new ErrorResponseImpl( + error.status, + error.statusText, + data + ); + } catch (e) { + // If we can't unwrap the response - just leave it as-is + } + } + + // Get a new StaticHandlerContext that contains the error at the right boundary + context = getStaticContextFromError( + staticHandler.dataRoutes, + context, + errorForSecondRender + ); + + // Sanitize errors outside of development environments + if (context.errors) { + context.errors = sanitizeErrors(context.errors, serverMode); + } + + // Update entryContext for the second render pass + entryContext = { + ...entryContext, + staticHandlerContext: context, + serverHandoffString: createServerHandoffString({ + url: context.location.pathname, + basename: build.basename, + state: { + loaderData: context.loaderData, + actionData: context.actionData, + errors: serializeErrors(context.errors, serverMode), + }, + future: build.future, + isSpaMode: build.isSpaMode, + }), + }; + + try { + return await handleDocumentRequestFunction( + request, + context.statusCode, + headers, + entryContext, + loadContext + ); + } catch (error: any) { + handleError(error); + return returnLastResortErrorResponse(error, serverMode); + } + } +} + +async function handleResourceRequestRR( + serverMode: ServerMode, + staticHandler: StaticHandler, + routeId: string, + request: Request, + loadContext: AppLoadContext, + handleError: (err: unknown) => void +) { + try { + // Note we keep the routeId here to align with the Remix handling of + // resource routes which doesn't take ?index into account and just takes + // the leaf match + let response = await staticHandler.queryRoute(request, { + routeId, + requestContext: loadContext, + }); + invariant( + !(DEFERRED_SYMBOL in response), + `You cannot return a \`defer()\` response from a Resource Route. Did you ` + + `forget to export a default UI component from the "${routeId}" route?` + ); + // callRouteLoader/callRouteAction always return responses + invariant( + isResponse(response), + "Expected a Response to be returned from queryRoute" + ); + return response; + } catch (error: unknown) { + if (isResponse(error)) { + // Note: Not functionally required but ensures that our response headers + // match identically to what Remix returns + error.headers.set("X-Remix-Catch", "yes"); + return error; + } + + if (isRouteErrorResponse(error)) { + if (error) { + handleError(error); + } + return errorResponseToJson(error, serverMode); + } + + handleError(error); + return returnLastResortErrorResponse(error, serverMode); + } +} + +function errorResponseToJson( + errorResponse: ErrorResponse, + serverMode: ServerMode +): Response { + return routerJson( + serializeError( + // @ts-expect-error This is "private" from users but intended for internal use + errorResponse.error || new Error("Unexpected Server Error"), + serverMode + ), + { + status: errorResponse.status, + statusText: errorResponse.statusText, + headers: { + "X-Remix-Error": "yes", + }, + } + ); +} + +function returnLastResortErrorResponse(error: any, serverMode?: ServerMode) { + let message = "Unexpected Server Error"; + + if (serverMode !== ServerMode.Production) { + message += `\n\n${String(error)}`; + } + + // Good grief folks, get your act together 😂! + return new Response(message, { + status: 500, + headers: { + "Content-Type": "text/plain", + }, + }); +} diff --git a/packages/remix/serverHandoff.ts b/packages/remix/serverHandoff.ts new file mode 100644 index 00000000000..e155e727a9b --- /dev/null +++ b/packages/remix/serverHandoff.ts @@ -0,0 +1,31 @@ +import type { HydrationState } from "@remix-run/router"; + +import type { FutureConfig } from "./entry"; +import { escapeHtml } from "./markup"; + +type ValidateShape = + // If it extends T + T extends Shape + ? // and there are no leftover props after removing the base + Exclude extends never + ? // we are good + T + : // otherwise it's either too many or too few props + never + : never; + +// TODO: Remove Promises from serialization +export function createServerHandoffString(serverHandoff: { + // Don't allow StaticHandlerContext to be passed in verbatim, since then + // we'd end up including duplicate info + state: ValidateShape; + criticalCss?: string; + url: string; + basename: string | undefined; + future: FutureConfig; + isSpaMode: boolean; +}): string { + // Uses faster alternative of jsesc to escape data returned from the loaders. + // This string is inserted directly into the HTML in the `` element. + return escapeHtml(JSON.stringify(serverHandoff)); +} diff --git a/packages/remix/sessions.ts b/packages/remix/sessions.ts new file mode 100644 index 00000000000..c00eedb2321 --- /dev/null +++ b/packages/remix/sessions.ts @@ -0,0 +1,314 @@ +import type { CookieParseOptions, CookieSerializeOptions } from "cookie"; + +import type { Cookie, CookieOptions, CreateCookieFunction } from "./cookies"; +import { isCookie } from "./cookies"; +import { warnOnce } from "./warnings"; + +/** + * An object of name/value pairs to be used in the session. + */ +export interface SessionData { + [name: string]: any; +} + +/** + * Session persists data across HTTP requests. + * + * @see https://remix.run/utils/sessions#session-api + */ +export interface Session { + /** + * A unique identifier for this session. + * + * Note: This will be the empty string for newly created sessions and + * sessions that are not backed by a database (i.e. cookie-based sessions). + */ + readonly id: string; + + /** + * The raw data contained in this session. + * + * This is useful mostly for SessionStorage internally to access the raw + * session data to persist. + */ + readonly data: FlashSessionData; + + /** + * Returns `true` if the session has a value for the given `name`, `false` + * otherwise. + */ + has(name: (keyof Data | keyof FlashData) & string): boolean; + + /** + * Returns the value for the given `name` in this session. + */ + get( + name: Key + ): + | (Key extends keyof Data ? Data[Key] : undefined) + | (Key extends keyof FlashData ? FlashData[Key] : undefined) + | undefined; + + /** + * Sets a value in the session for the given `name`. + */ + set(name: Key, value: Data[Key]): void; + + /** + * Sets a value in the session that is only valid until the next `get()`. + * This can be useful for temporary values, like error messages. + */ + flash( + name: Key, + value: FlashData[Key] + ): void; + + /** + * Removes a value from the session. + */ + unset(name: keyof Data & string): void; +} + +export type FlashSessionData = Partial< + Data & { + [Key in keyof FlashData as FlashDataKey]: FlashData[Key]; + } +>; +type FlashDataKey = `__flash_${Key}__`; +function flash(name: Key): FlashDataKey { + return `__flash_${name}__`; +} + +export type CreateSessionFunction = ( + initialData?: Data, + id?: string +) => Session; + +/** + * Creates a new Session object. + * + * Note: This function is typically not invoked directly by application code. + * Instead, use a `SessionStorage` object's `getSession` method. + * + * @see https://remix.run/utils/sessions#createsession + */ +export const createSession: CreateSessionFunction = < + Data = SessionData, + FlashData = Data +>( + initialData: Partial = {}, + id = "" +): Session => { + let map = new Map(Object.entries(initialData)) as Map< + keyof Data | FlashDataKey, + any + >; + + return { + get id() { + return id; + }, + get data() { + return Object.fromEntries(map) as FlashSessionData; + }, + has(name) { + return ( + map.has(name as keyof Data) || + map.has(flash(name as keyof FlashData & string)) + ); + }, + get(name) { + if (map.has(name as keyof Data)) return map.get(name as keyof Data); + + let flashName = flash(name as keyof FlashData & string); + if (map.has(flashName)) { + let value = map.get(flashName); + map.delete(flashName); + return value; + } + + return undefined; + }, + set(name, value) { + map.set(name, value); + }, + flash(name, value) { + map.set(flash(name), value); + }, + unset(name) { + map.delete(name); + }, + }; +}; + +export type IsSessionFunction = (object: any) => object is Session; + +/** + * Returns true if an object is a Remix session. + * + * @see https://remix.run/utils/sessions#issession + */ +export const isSession: IsSessionFunction = (object): object is Session => { + return ( + object != null && + typeof object.id === "string" && + typeof object.data !== "undefined" && + typeof object.has === "function" && + typeof object.get === "function" && + typeof object.set === "function" && + typeof object.flash === "function" && + typeof object.unset === "function" + ); +}; + +/** + * SessionStorage stores session data between HTTP requests and knows how to + * parse and create cookies. + * + * A SessionStorage creates Session objects using a `Cookie` header as input. + * Then, later it generates the `Set-Cookie` header to be used in the response. + */ +export interface SessionStorage { + /** + * Parses a Cookie header from a HTTP request and returns the associated + * Session. If there is no session associated with the cookie, this will + * return a new Session with no data. + */ + getSession: ( + cookieHeader?: string | null, + options?: CookieParseOptions + ) => Promise>; + + /** + * Stores all data in the Session and returns the Set-Cookie header to be + * used in the HTTP response. + */ + commitSession: ( + session: Session, + options?: CookieSerializeOptions + ) => Promise; + + /** + * Deletes all data associated with the Session and returns the Set-Cookie + * header to be used in the HTTP response. + */ + destroySession: ( + session: Session, + options?: CookieSerializeOptions + ) => Promise; +} + +/** + * SessionIdStorageStrategy is designed to allow anyone to easily build their + * own SessionStorage using `createSessionStorage(strategy)`. + * + * This strategy describes a common scenario where the session id is stored in + * a cookie but the actual session data is stored elsewhere, usually in a + * database or on disk. A set of create, read, update, and delete operations + * are provided for managing the session data. + */ +export interface SessionIdStorageStrategy< + Data = SessionData, + FlashData = Data +> { + /** + * The Cookie used to store the session id, or options used to automatically + * create one. + */ + cookie?: Cookie | (CookieOptions & { name?: string }); + + /** + * Creates a new record with the given data and returns the session id. + */ + createData: ( + data: FlashSessionData, + expires?: Date + ) => Promise; + + /** + * Returns data for a given session id, or `null` if there isn't any. + */ + readData: (id: string) => Promise | null>; + + /** + * Updates data for the given session id. + */ + updateData: ( + id: string, + data: FlashSessionData, + expires?: Date + ) => Promise; + + /** + * Deletes data for a given session id from the data store. + */ + deleteData: (id: string) => Promise; +} + +export type CreateSessionStorageFunction = < + Data = SessionData, + FlashData = Data +>( + strategy: SessionIdStorageStrategy +) => SessionStorage; + +/** + * Creates a SessionStorage object using a SessionIdStorageStrategy. + * + * Note: This is a low-level API that should only be used if none of the + * existing session storage options meet your requirements. + * + * @see https://remix.run/utils/sessions#createsessionstorage + */ +export const createSessionStorageFactory = + (createCookie: CreateCookieFunction): CreateSessionStorageFunction => + ({ cookie: cookieArg, createData, readData, updateData, deleteData }) => { + let cookie = isCookie(cookieArg) + ? cookieArg + : createCookie(cookieArg?.name || "__session", cookieArg); + + warnOnceAboutSigningSessionCookie(cookie); + + return { + async getSession(cookieHeader, options) { + let id = cookieHeader && (await cookie.parse(cookieHeader, options)); + let data = id && (await readData(id)); + return createSession(data || {}, id || ""); + }, + async commitSession(session, options) { + let { id, data } = session; + let expires = + options?.maxAge != null + ? new Date(Date.now() + options.maxAge * 1000) + : options?.expires != null + ? options.expires + : cookie.expires; + + if (id) { + await updateData(id, data, expires); + } else { + id = await createData(data, expires); + } + + return cookie.serialize(id, options); + }, + async destroySession(session, options) { + await deleteData(session.id); + return cookie.serialize("", { + ...options, + maxAge: undefined, + expires: new Date(0), + }); + }, + }; + }; + +export function warnOnceAboutSigningSessionCookie(cookie: Cookie) { + warnOnce( + cookie.isSigned, + `The "${cookie.name}" cookie is not signed, but session cookies should be ` + + `signed to prevent tampering on the client before they are sent back to the ` + + `server. See https://remix.run/utils/cookies#signing-cookies ` + + `for more information.` + ); +} diff --git a/packages/remix/sessions/cookieStorage.ts b/packages/remix/sessions/cookieStorage.ts new file mode 100644 index 00000000000..09a2e765681 --- /dev/null +++ b/packages/remix/sessions/cookieStorage.ts @@ -0,0 +1,69 @@ +import type { CreateCookieFunction } from "../cookies"; +import { isCookie } from "../cookies"; +import type { + SessionStorage, + SessionIdStorageStrategy, + SessionData, +} from "../sessions"; +import { warnOnceAboutSigningSessionCookie, createSession } from "../sessions"; + +interface CookieSessionStorageOptions { + /** + * The Cookie used to store the session data on the client, or options used + * to automatically create one. + */ + cookie?: SessionIdStorageStrategy["cookie"]; +} + +export type CreateCookieSessionStorageFunction = < + Data = SessionData, + FlashData = Data +>( + options?: CookieSessionStorageOptions +) => SessionStorage; + +/** + * Creates and returns a SessionStorage object that stores all session data + * directly in the session cookie itself. + * + * This has the advantage that no database or other backend services are + * needed, and can help to simplify some load-balanced scenarios. However, it + * also has the limitation that serialized session data may not exceed the + * browser's maximum cookie size. Trade-offs! + * + * @see https://remix.run/utils/sessions#createcookiesessionstorage + */ +export const createCookieSessionStorageFactory = + (createCookie: CreateCookieFunction): CreateCookieSessionStorageFunction => + ({ cookie: cookieArg } = {}) => { + let cookie = isCookie(cookieArg) + ? cookieArg + : createCookie(cookieArg?.name || "__session", cookieArg); + + warnOnceAboutSigningSessionCookie(cookie); + + return { + async getSession(cookieHeader, options) { + return createSession( + (cookieHeader && (await cookie.parse(cookieHeader, options))) || {} + ); + }, + async commitSession(session, options) { + let serializedCookie = await cookie.serialize(session.data, options); + if (serializedCookie.length > 4096) { + throw new Error( + "Cookie length will exceed browser maximum. Length: " + + serializedCookie.length + ); + } + return serializedCookie; + }, + async destroySession(_session, options) { + return cookie.serialize("", { + ...options, + maxAge: undefined, + expires: new Date(0), + }); + }, + }; + }; diff --git a/packages/remix/sessions/memoryStorage.ts b/packages/remix/sessions/memoryStorage.ts new file mode 100644 index 00000000000..9ad13d7f7c6 --- /dev/null +++ b/packages/remix/sessions/memoryStorage.ts @@ -0,0 +1,73 @@ +import type { + SessionData, + SessionStorage, + SessionIdStorageStrategy, + CreateSessionStorageFunction, + FlashSessionData, +} from "../sessions"; + +interface MemorySessionStorageOptions { + /** + * The Cookie used to store the session id on the client, or options used + * to automatically create one. + */ + cookie?: SessionIdStorageStrategy["cookie"]; +} + +export type CreateMemorySessionStorageFunction = < + Data = SessionData, + FlashData = Data +>( + options?: MemorySessionStorageOptions +) => SessionStorage; + +/** + * Creates and returns a simple in-memory SessionStorage object, mostly useful + * for testing and as a reference implementation. + * + * Note: This storage does not scale beyond a single process, so it is not + * suitable for most production scenarios. + * + * @see https://remix.run/utils/sessions#creatememorysessionstorage + */ +export const createMemorySessionStorageFactory = + ( + createSessionStorage: CreateSessionStorageFunction + ): CreateMemorySessionStorageFunction => + ({ + cookie, + }: MemorySessionStorageOptions = {}): SessionStorage => { + let map = new Map< + string, + { data: FlashSessionData; expires?: Date } + >(); + + return createSessionStorage({ + cookie, + async createData(data, expires) { + let id = Math.random().toString(36).substring(2, 10); + map.set(id, { data, expires }); + return id; + }, + async readData(id) { + if (map.has(id)) { + let { data, expires } = map.get(id)!; + + if (!expires || expires > new Date()) { + return data; + } + + // Remove expired session data. + if (expires) map.delete(id); + } + + return null; + }, + async updateData(id, data, expires) { + map.set(id, { data, expires }); + }, + async deleteData(id) { + map.delete(id); + }, + }); + }; diff --git a/packages/remix/tsconfig.json b/packages/remix/tsconfig.json index 63ef2132dc8..cde46c82e52 100644 --- a/packages/remix/tsconfig.json +++ b/packages/remix/tsconfig.json @@ -2,18 +2,18 @@ "include": ["**/*.ts"], "exclude": ["dist", "__tests__", "node_modules"], "compilerOptions": { - "lib": ["ES2022"], + "lib": ["DOM", "DOM.Iterable", "ES2022"], "target": "ES2022", - "module": "ES2022", - "skipLibCheck": true, - "jsx": "react", "moduleResolution": "Bundler", "allowSyntheticDefaultImports": true, "strict": true, "declaration": true, "emitDeclarationOnly": true, "rootDir": ".", - "outDir": "../../build/node_modules/remix/dist" + "outDir": "../../build/node_modules/remix/dist", + + // Avoid naming conflicts between lib.dom.d.ts and globals.ts + "skipLibCheck": true } } diff --git a/packages/remix/typecheck.ts b/packages/remix/typecheck.ts new file mode 100644 index 00000000000..7f6ffeab236 --- /dev/null +++ b/packages/remix/typecheck.ts @@ -0,0 +1,15 @@ +// typecheck that expression is assignable to type +export function expectType(_expression: T) {} + +// prettier-ignore +// adapted from https://github.com/type-challenges/type-challenges/blob/main/utils/index.d.ts +export type Equal = + (() => T extends X ? 1 : 2) extends + (() => T extends Y ? 1 : 2) ? true : false + +// adapted from https://github.com/type-challenges/type-challenges/blob/main/utils/index.d.ts +export type Expect = T; + +// looser, lazy equality check for recursive types +// prettier-ignore +export type MutualExtends = [A] extends [B] ? [B] extends [A] ? true : false : false diff --git a/packages/remix/upload/errors.ts b/packages/remix/upload/errors.ts new file mode 100644 index 00000000000..e5ed3d42f6b --- /dev/null +++ b/packages/remix/upload/errors.ts @@ -0,0 +1,5 @@ +export class MaxPartSizeExceededError extends Error { + constructor(public field: string, public maxBytes: number) { + super(`Field "${field}" exceeded upload size of ${maxBytes} bytes.`); + } +} diff --git a/packages/remix/upload/memoryUploadHandler.ts b/packages/remix/upload/memoryUploadHandler.ts new file mode 100644 index 00000000000..27dec693b57 --- /dev/null +++ b/packages/remix/upload/memoryUploadHandler.ts @@ -0,0 +1,50 @@ +import type { UploadHandler } from "../formData"; +import { MaxPartSizeExceededError } from "./errors"; + +export type MemoryUploadHandlerFilterArgs = { + filename?: string; + contentType: string; + name: string; +}; + +export type MemoryUploadHandlerOptions = { + /** + * The maximum upload size allowed. If the size is exceeded an error will be thrown. + * Defaults to 3000000B (3MB). + */ + maxPartSize?: number; + /** + * + * @param filename + * @param mimetype + * @param encoding + */ + filter?(args: MemoryUploadHandlerFilterArgs): boolean | Promise; +}; + +export function createMemoryUploadHandler({ + filter, + maxPartSize = 3000000, +}: MemoryUploadHandlerOptions = {}): UploadHandler { + return async ({ filename, contentType, name, data }) => { + if (filter && !(await filter({ filename, contentType, name }))) { + return undefined; + } + + let size = 0; + let chunks = []; + for await (let chunk of data) { + size += chunk.byteLength; + if (size > maxPartSize) { + throw new MaxPartSizeExceededError(name, maxPartSize); + } + chunks.push(chunk); + } + + if (typeof filename === "string") { + return new File(chunks, filename, { type: contentType }); + } + + return await new Blob(chunks, { type: contentType }).text(); + }; +} diff --git a/packages/remix/warnings.ts b/packages/remix/warnings.ts new file mode 100644 index 00000000000..45acd960108 --- /dev/null +++ b/packages/remix/warnings.ts @@ -0,0 +1,8 @@ +const alreadyWarned: { [message: string]: boolean } = {}; + +export function warnOnce(condition: boolean, message: string): void { + if (!condition && !alreadyWarned[message]) { + alreadyWarned[message] = true; + console.warn(message); + } +}