Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: pages router support #287

Merged
merged 6 commits into from
Apr 4, 2024
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
5 changes: 5 additions & 0 deletions .changeset/mean-dolls-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"frames.js": patch
---

fix: page router adapter for next.js node.js request handling
5 changes: 5 additions & 0 deletions examples/framesjs-starter/pages/api/frames/frames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { createFrames } from "frames.js/next/pages-router";

export const frames = createFrames({
basePath: "/api/frames",
});
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
/* eslint-disable react/jsx-key */
import { createFrames, Button } from "frames.js/next/pages-router";
import { Button } from "frames.js/next/pages-router";
import { frames } from "./frames";

const frames = createFrames({
basePath: "/api/frames",
});
const handleRequest = frames(async (ctx) => {
return {
image: (
Expand All @@ -13,8 +11,9 @@ const handleRequest = frames(async (ctx) => {
</span>
),
buttons: [
<Button action="post" target="/">
Click me
<Button action="post">Click me</Button>,
<Button action="post" target="/next">
Next frame
</Button>,
],
textInput: "Type something!",
Expand Down
21 changes: 21 additions & 0 deletions examples/framesjs-starter/pages/api/frames/next/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/* eslint-disable react/jsx-key */
import { Button } from "frames.js/next/pages-router";
import { frames } from "../frames";

const handleRequest = frames(async (ctx) => {
return {
image: (
<span>
This is next frame and you clicked button:{" "}
{ctx.pressedButton ? "✅" : "❌"}
</span>
),
buttons: [
<Button action="post" target="/">
Previous frame
</Button>,
],
};
});

export default handleRequest;
37 changes: 30 additions & 7 deletions packages/frames.js/src/express/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import express from "express";
import express, { json } from "express";
import request from "supertest";
import { FRAMES_META_TAGS_HEADER } from "../core";
import * as lib from ".";
Expand Down Expand Up @@ -96,13 +96,13 @@ describe("express adapter", () => {
.expect(200)
.expect("Content-type", "application/json")
.expect((res) => {
expect((res.body as Record<string, string>)["fc:frame:button:1:target"]).toMatch(
/http:\/\/127\.0\.0\.1:\d+\/api\/nested/
);
expect(
(res.body as Record<string, string>)["fc:frame:button:1:target"]
).toMatch(/http:\/\/127\.0\.0\.1:\d+\/api\/nested/);
});
});

it('works properly with state', async () => {
it("works properly with state", async () => {
type State = {
test: boolean;
};
Expand All @@ -117,15 +117,38 @@ describe("express adapter", () => {
expect(ctx.state).toEqual({ test: false });

return {
image: 'http://test.png',
image: "http://test.png",
state: ctx.state satisfies State,
};
});

app.use("/", expressHandler);

await request(app).get("/").expect("Content-Type", "text/html").expect(200);
});

it("works properly with body parser", async () => {
const app = express();
const frames = lib.createFrames();
const expressHandler = frames(async ({ request: req }) => {
await expect(req.clone().json()).resolves.toEqual({ test: "test" });

return {
image: <span>Nehehe</span>,
buttons: [
<lib.Button action="post" key="1">
Click me
</lib.Button>,
],
};
});

app.use("/", json(), expressHandler);

await request(app)
.get("/")
.post("/")
.set("Host", "localhost:3000")
.send({ test: "test" })
.expect("Content-Type", "text/html")
.expect(200);
});
Expand Down
84 changes: 10 additions & 74 deletions packages/frames.js/src/express/index.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
import type { IncomingHttpHeaders } from "node:http";
import type {
Handler as ExpressHandler,
Request as ExpressRequest,
Response as ExpressResponse,
} from "express";
import type { types } from "../core";
import { createFrames as coreCreateFrames } from "../core";
import {
createReadableStreamFromReadable,
writeReadableStreamToWritable,
} from "../lib/stream-pump";
import type { CoreMiddleware } from "../middleware";
import {
convertNodeJSRequestToWebAPIRequest,
sendWebAPIResponseToNodeJSResponse,
} from "../lib/node-server-helpers";

export { Button, type types } from "../core";

Expand Down Expand Up @@ -60,10 +59,14 @@ export const createFrames: CreateFramesForExpress =
res: ExpressResponse
) {
// convert express.js req to Web API Request
const response = framesHandler(createRequest(req, res));
const response = framesHandler(
convertNodeJSRequestToWebAPIRequest(req, res)
);

Promise.resolve(response)
.then((resolvedResponse) => sendResponse(res, resolvedResponse))
.then((resolvedResponse) =>
sendWebAPIResponseToNodeJSResponse(res, resolvedResponse)
)
.catch((error) => {
// eslint-disable-next-line no-console -- provide feedback
console.error(error);
Expand All @@ -73,70 +76,3 @@ export const createFrames: CreateFramesForExpress =
};
};
};

function createRequest(req: ExpressRequest, res: ExpressResponse): Request {
// req.hostname doesn't include port information so grab that from
// `X-Forwarded-Host` or `Host`
const [, hostnamePort] = req.get("X-Forwarded-Host")?.split(":") ?? [];
const [, hostPort] = req.get("Host")?.split(":") ?? [];
const port = hostnamePort || hostPort;
// Use req.hostname here as it respects the "trust proxy" setting
const resolvedHost = `${req.hostname}${port ? `:${port}` : ""}`;
// Use `req.originalUrl` so Remix is aware of the full path
const url = new URL(`${req.protocol}://${resolvedHost}${req.originalUrl}`);

// Abort action/loaders once we can no longer write a response
const controller = new AbortController();
res.on("close", () => {
controller.abort();
});

const init: RequestInit = {
method: req.method,
headers: convertIncomingHTTPHeadersToHeaders(req.headers),
signal: controller.signal,
};

if (req.method !== "GET" && req.method !== "HEAD") {
init.body = createReadableStreamFromReadable(req);
(init as { duplex: "half" }).duplex = "half";
}

return new Request(url.href, init);
}

function convertIncomingHTTPHeadersToHeaders(
incomingHeaders: IncomingHttpHeaders
): Headers {
const headers = new Headers();

for (const [key, value] of Object.entries(incomingHeaders)) {
if (Array.isArray(value)) {
for (const item of value) {
headers.append(key, item);
}
} else if (value != null) {
headers.append(key, value);
}
}

return headers;
}

async function sendResponse(
res: ExpressResponse,
response: Response
): Promise<void> {
res.statusMessage = response.statusText;
res.status(response.status);

for (const [key, value] of response.headers.entries()) {
res.setHeader(key, value);
}

if (response.body) {
await writeReadableStreamToWritable(response.body, res);
} else {
res.end();
}
}
123 changes: 123 additions & 0 deletions packages/frames.js/src/lib/node-server-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
import { TLSSocket } from "node:tls";
import {
convertNodeJSRequestToWebAPIRequest,
sendWebAPIResponseToNodeJSResponse,
} from "./node-server-helpers";

describe("convertNodeJSRequestToWebAPIRequest", () => {
it("properly detects url from host header (with custom port)", () => {
const req = new IncomingMessage(new Socket());
req.headers.host = "framesjs.org:3000";
req.url = "/test";

const res = new ServerResponse(req);
const request = convertNodeJSRequestToWebAPIRequest(req, res);

expect(request.url).toBe("http://framesjs.org:3000/test");
});

it("properly detects url from host header (without port)", () => {
const req = new IncomingMessage(new Socket());
req.headers.host = "framesjs.org";
req.url = "/test";

const res = new ServerResponse(req);
const request = convertNodeJSRequestToWebAPIRequest(req, res);

expect(request.url).toBe("http://framesjs.org/test");
});

it("properly detects protocol from socket", () => {
const socket = new TLSSocket(new Socket());
const req = new IncomingMessage(socket);
req.headers.host = "framesjs.org";
req.url = "/test";

const res = new ServerResponse(req);
const request = convertNodeJSRequestToWebAPIRequest(req, res);

expect(request.url).toBe("https://framesjs.org/test");
});

it("uses x-forwarded-host if available", () => {
const req = new IncomingMessage(new Socket());
req.headers["x-forwarded-host"] = "framesjs.org:3000";
req.url = "/test";

const res = new ServerResponse(req);
const request = convertNodeJSRequestToWebAPIRequest(req, res);

expect(request.url).toBe("http://framesjs.org:3000/test");
});

it("uses x-forwarded-host over host header", () => {
const req = new IncomingMessage(new Socket());
req.headers["x-forwarded-host"] = "framesjs.org:3000";
req.headers.host = "framesjs.org";
req.url = "/test";

const res = new ServerResponse(req);
const request = convertNodeJSRequestToWebAPIRequest(req, res);

expect(request.url).toBe("http://framesjs.org:3000/test");
});

it("uses x-forwarded-proto if available", () => {
const req = new IncomingMessage(new Socket());

req.headers["x-forwarded-proto"] = "https";
req.headers.host = "framesjs.org";
req.url = "/test";

const res = new ServerResponse(req);
const request = convertNodeJSRequestToWebAPIRequest(req, res);

expect(request.url).toBe("https://framesjs.org/test");
});

it("uses x-forwarded-proto over encrypted socket", () => {
const socket = new TLSSocket(new Socket());
const req = new IncomingMessage(socket);

req.headers["x-forwarded-proto"] = "http";
req.headers.host = "framesjs.org";
req.url = "/test";

const res = new ServerResponse(req);
const request = convertNodeJSRequestToWebAPIRequest(req, res);

expect(request.url).toBe("http://framesjs.org/test");
});

it("passes all headers to Request", () => {
const req = new IncomingMessage(new Socket());
req.headers.host = "framesjs.org";
req.url = "/test";
req.headers["x-test"] = "test";
req.headers["content-type"] = "test";

const res = new ServerResponse(req);
const request = convertNodeJSRequestToWebAPIRequest(req, res);

expect(request.headers.get("x-test")).toBe("test");
expect(request.headers.get("content-type")).toBe("test");
});
});

describe("sendWebAPIResponseToNodeJSResponse", () => {
it("sends response with headers to node.js response", async () => {
const response = Response.json(
{ test: "test" },
{ headers: { "x-test": "test" }, statusText: "OK" }
);
const res = new ServerResponse(new IncomingMessage(new Socket()));

await sendWebAPIResponseToNodeJSResponse(res, response);

expect(res.statusCode).toBe(200);
expect(res.statusMessage).toBe("OK");
expect(res.getHeader("content-type")).toBe("application/json");
});
});
Loading
Loading