Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,19 @@ When you are ready to begin the release process:
- Merge all release-bound changes to `main` with their change files
- The `release.yml` workflow will see the `main` branch with change files in it:
- This triggers the "PR" step and runs `scripts/changes/pr.ts`
- This will create or update a `release-pr` branch from `main`
- This will create or update a versioned release branch from `main` such as `release-v7-pr`
- Runs `scripts/changes/version.ts`
- Updates versions
- Generate changelogs
- Deletes change files
- Opens a PR from `release-pr` to `main`
- Opens a PR from `release-v<major>-pr` to `main`
- Once that PR is merged, the `release.yml` workflow will run again against `main` and see no change files:
- This triggers the `needs-publish` step, which checks npm to see if the current `react-router` version is already published
- If publishing is needed, this triggers the "publish" step and runs `scripts/changes/publish.ts`
- Publishes all packages to npm
- Tags the commits and pushes tags to the origin
- Creates a github release
- The `release-pr` branch can be deleted after the PR is merged
- The `release-v<major>-pr` branch can be deleted after the PR is merged

### Iterating a release PR

Expand All @@ -48,11 +48,11 @@ Hotfix releases operate like the above but off of a hotfix branch that is create
- Merge into `hotfix`
- The `release.yml` workflow will see the `hotfix` branch with change files in it:
- This triggers the "PR" step (`scripts/changes/pr.ts`)
- This will create a new branch from `hotfix`
- This will create or update a versioned hotfix branch from `hotfix` such as `hotfix-v7-pr`
- Update the versions in the new branch
- Generate the proper `CHANGELOG.md` entries
- Delete the change files
- Open a PR to the `hotfix` branch
- Open a PR from `hotfix-v<major>-pr` to the `hotfix` branch
- Once that PR is merged, the `release.yml` workflow will run again against `hotfix` and see no change files:
- This triggers the "publish" step (`scripts/changes/publish.ts`)
- Publishes all packages to npm
Expand Down
20 changes: 19 additions & 1 deletion docs/api/other-api/adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ If you initialized your app with `npx create-react-router@latest` with something

<docs-info>If you're using the built-in React Router App Server, you don't interact with this API</docs-info>

Each adapter has the same API. In the future, we may have helpers specific to the platform you're deploying to.
Each adapter has the same API. Some adapters also have options specific to the platform you're deploying to.

## `@react-router/express`

Expand Down Expand Up @@ -120,6 +120,24 @@ Update the `dev` and `start` scripts to use your new Express server:
}
```

## `@react-router/architect`

[Reference Documentation 鈫梋(https://api.reactrouter.com/v7/modules/_react-router_architect.html)

Here's an example with Architect:

```ts
import { createRequestHandler } from "@react-router/architect";
import * as build from "./build/server";

export const handler = createRequestHandler({
build,
useRequestContextDomainName: true,
});
```

The `useRequestContextDomainName` option tells the adapter to use `event.requestContext.domainName` when creating the `request`, instead of the prior behavior of `X-Forwarded-Host` - falling back on the `Host` header in both cases. This argument will be removed in v8 and the domain name will be used by default.

## `@react-router/cloudflare`

[Reference Documentation 鈫梋(https://api.reactrouter.com/v7/modules/_react-router_cloudflare.html)
Expand Down
14 changes: 10 additions & 4 deletions integration/fog-of-war-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,7 +715,9 @@ test.describe("Fog of War", () => {
expect(await app.getHtml("#parent")).toMatch(`Parent`);
expect(await app.getHtml("#child2")).toMatch(`Child 2`);
expect(manifestRequests).toEqual([
expect.stringMatching(/\/__manifest\?paths=%2Fparent%2Fchild2&version=/),
expect.stringMatching(
/\/__manifest\?paths=%2Fparent%2C%2Fparent%2Fchild2&version=/,
),
]);
});

Expand Down Expand Up @@ -1065,7 +1067,7 @@ test.describe("Fog of War", () => {
await page.waitForSelector("#splat");
expect(await app.getHtml("#splat")).toMatch("Splat: b/c");
expect(manifestRequests).toEqual([
expect.stringMatching(/\/__manifest\?paths=%2Fb%2Fc&version=/),
expect.stringMatching(/\/__manifest\?paths=%2Fb%2C%2Fb%2Fc&version=/),
]);
});

Expand Down Expand Up @@ -1137,7 +1139,9 @@ test.describe("Fog of War", () => {
await app.clickLink("/not/a/path");
await page.waitForSelector("#error");
expect(manifestRequests).toEqual([
expect.stringMatching(/\/__manifest\?paths=%2Fnot%2Fa%2Fpath&version=/),
expect.stringMatching(
/\/__manifest\?paths=%2Fnot%2C%2Fnot%2Fa%2C%2Fnot%2Fa%2Fpath&version=/,
),
]);
manifestRequests = [];

Expand Down Expand Up @@ -1449,7 +1453,9 @@ test.describe("Fog of War", () => {
// Wait for eager discovery to kick off
await new Promise((r) => setTimeout(r, 500));
expect(manifestRequests).toEqual([
expect.stringMatching(/\/custom-manifest\?paths=%2Fa%2Fb&version=/),
expect.stringMatching(
/\/custom-manifest\?paths=%2Fa%2C%2Fa%2Fb&version=/,
),
]);

expect(wrongManifestRequests).toEqual([]);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a `useRequestContextDomainName` option to `createRequestHandler` to derive request URL hosts from the API Gateway request context.
125 changes: 118 additions & 7 deletions packages/react-router-architect/__tests__/server-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,19 @@ let mockedCreateRequestHandler =
typeof createReactRequestHandler
>;

function createMockEvent(event: Partial<APIGatewayProxyEventV2> = {}) {
type MockEvent = Omit<Partial<APIGatewayProxyEventV2>, "requestContext"> & {
requestContext?: Partial<APIGatewayProxyEventV2["requestContext"]>;
};

function createMockEvent(event: MockEvent = {}) {
let now = new Date();
return {
isBase64Encoded: false,
rawPath: "/",
rawQueryString: "",
routeKey: "foo",
version: "2.0",
...event,
headers: {
host: "localhost:3333",
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
Expand All @@ -41,9 +51,6 @@ function createMockEvent(event: Partial<APIGatewayProxyEventV2> = {}) {
"accept-encoding": "gzip, deflate",
...event.headers,
},
isBase64Encoded: false,
rawPath: "/",
rawQueryString: "",
requestContext: {
http: {
method: "GET",
Expand All @@ -65,9 +72,6 @@ function createMockEvent(event: Partial<APIGatewayProxyEventV2> = {}) {
timeEpoch: now.getTime(),
...event.requestContext,
},
routeKey: "foo",
version: "2.0",
...event,
};
}

Expand Down Expand Up @@ -113,6 +117,37 @@ describe("architect createRequestHandler", () => {
});
});

it("can use the request context domain name", async () => {
mockedCreateRequestHandler.mockImplementation(() => async (req) => {
return new Response(`Host: ${new URL(req.url).host}`);
});

await lambdaTester(
createRequestHandler({
// We don't have a real app to test, but it doesn't matter. We won't ever
// call through to the real createRequestHandler
// @ts-expect-error
build: undefined,
useRequestContextDomainName: true,
}),
)
.event(
createMockEvent({
headers: {
host: "localhost:3333",
"x-forwarded-host": "ignore.com",
},
requestContext: {
domainName: "example.com",
},
}),
)
.expectResolve((res: APIGatewayProxyStructuredResultV2) => {
expect(res.statusCode).toBe(200);
expect(res.body).toBe("Host: example.com");
});
});

it("handles nested // requests", async () => {
mockedCreateRequestHandler.mockImplementation(() => async (req) => {
return new Response(`URL: ${new URL(req.url).pathname}`);
Expand Down Expand Up @@ -248,6 +283,82 @@ describe("architect createReactRouterRequest", () => {
expect(request.method).toBe("GET");
expect(request.headers.get("cookie")).toBe("__session=value");
});

it("uses x-forwarded-host by default", () => {
let request = createReactRouterRequest(
createMockEvent({
headers: {
host: "localhost:3333",
"x-forwarded-host": "example.com",
},
}),
);

expect(request.url).toBe("https://example.com/");
});

it("uses request context domain name when enabled", () => {
let request = createReactRouterRequest(
createMockEvent({
headers: {
host: "localhost:3333",
"x-forwarded-host": "ignore.com",
},
requestContext: {
domainName: "example.com",
},
}),
true,
);

expect(request.url).toBe("https://example.com/");
});

it("ignores invalid characters in x-forwarded-host", () => {
let request = createReactRouterRequest(
createMockEvent({
headers: {
host: "localhost:3333",
"x-forwarded-host": "example.com:4444/invalid@chars",
},
rawPath: "/foo",
}),
);

expect(request.url).toBe("https://example.com:4444/foo");
});

it("ignores invalid characters in request context domain name", () => {
let request = createReactRouterRequest(
createMockEvent({
headers: {
host: "localhost:3333",
"x-forwarded-host": "example.com",
},
requestContext: {
domainName: "context.example.com:4444/invalid@chars",
},
rawPath: "/foo",
}),
true,
);

expect(request.url).toBe("https://context.example.com:4444/foo");
});

it("falls back for invalid host values", () => {
let request = createReactRouterRequest(
createMockEvent({
headers: {
host: "#invalid",
"x-forwarded-host": "@invalid",
},
rawPath: "/foo",
}),
);

expect(request.url).toBe("https://localhost/foo");
});
});

describe("sendReactRouterResponse", () => {
Expand Down
15 changes: 13 additions & 2 deletions packages/react-router-architect/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,18 @@ export function createRequestHandler({
build,
getLoadContext,
mode = process.env.NODE_ENV,
// TODO(v8): Remove this flag and make this the default behavior
useRequestContextDomainName = false,
}: {
build: ServerBuild;
getLoadContext?: GetLoadContextFunction;
mode?: string;
useRequestContextDomainName?: boolean;
}): RequestHandler {
let handleRequest = createReactRouterRequestHandler(build, mode);

return async (event) => {
let request = createReactRouterRequest(event);
let request = createReactRouterRequest(event, useRequestContextDomainName);
let loadContext = await getLoadContext?.(event);

let response = await handleRequest(request, loadContext);
Expand All @@ -59,8 +62,16 @@ export function createRequestHandler({

export function createReactRouterRequest(
event: APIGatewayProxyEventV2,
useRequestContextDomainName: boolean = false,
): Request {
let host = event.headers["x-forwarded-host"] || event.headers.host;
let rawHost = useRequestContextDomainName
? event.requestContext.domainName || event.headers.host || ""
: event.headers["x-forwarded-host"] || event.headers.host || "";
let [hostname, portStr] = rawHost.split(":");
hostname = hostname.split(/[\\/?#@]/)[0] || "localhost";
let hostPort = Number.parseInt(portStr ?? "", 10);
let port = Number.isSafeInteger(hostPort) ? hostPort : undefined;
let host = `${hostname}${port ? `:${port}` : ""}`;
let search = event.rawQueryString.length ? `?${event.rawQueryString}` : "";
let scheme = process.env.ARC_SANDBOX ? "http" : "https";
let url = new URL(`${scheme}://${host}${event.rawPath}${search}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Adjust express adapter host computation

- read port from `x-forwarded-host` based on `trust proxy` setting
- handle invalid hostname characters
73 changes: 73 additions & 0 deletions packages/react-router-express/__tests__/server-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,4 +307,77 @@ describe("express createRemixRequest", () => {
expect(remixRequest.headers.get("host")).toBe("localhost:3000");
expect(remixRequest.url).toBe("http://localhost:3000/foo/bar");
});

it("does not use x-forwarded-host port unless trust proxy is enabled", async () => {
let expressRequest = createRequest({
url: "/foo/bar",
method: "GET",
protocol: "http",
hostname: "localhost",
headers: {
Host: "localhost:3000",
"x-forwarded-host": "example.com:8443",
},
});
let expressResponse = createResponse();

let remixRequest = createRemixRequest(expressRequest, expressResponse);

expect(remixRequest.url).toBe("http://localhost:3000/foo/bar");
});

it("uses x-forwarded-host port when trust proxy is enabled", async () => {
let app = express();
app.set("trust proxy", true);
let expressRequest = createRequest({
app,
url: "/foo/bar",
method: "GET",
protocol: "http",
hostname: "example.com",
headers: {
Host: "localhost:3000",
"x-forwarded-host": "example.com:8443",
},
});
let expressResponse = createResponse();

let remixRequest = createRemixRequest(expressRequest, expressResponse);

expect(remixRequest.url).toBe("http://example.com:8443/foo/bar");
});

it("ignores invalid characters in host values", async () => {
let expressRequest = createRequest({
url: "/foo/bar",
method: "GET",
protocol: "http",
hostname: "localhost/invalid",
headers: {
Host: "localhost:3000",
},
});
let expressResponse = createResponse();

let remixRequest = createRemixRequest(expressRequest, expressResponse);

expect(remixRequest.url).toBe("http://localhost:3000/foo/bar");
});

it("falls back for invalid host values", async () => {
let expressRequest = createRequest({
url: "/foo/bar",
method: "GET",
protocol: "http",
hostname: "/invalid",
headers: {
Host: "localhost:3000",
},
});
let expressResponse = createResponse();

let remixRequest = createRemixRequest(expressRequest, expressResponse);

expect(remixRequest.url).toBe("http://localhost:3000/foo/bar");
});
});
Loading