Skip to content

Commit

Permalink
feat: handle streaming response.body and headers in handleNextRequest
Browse files Browse the repository at this point in the history
  • Loading branch information
thgh committed Jul 11, 2023
1 parent be9ba42 commit 67707ef
Show file tree
Hide file tree
Showing 7 changed files with 253 additions and 122 deletions.
103 changes: 76 additions & 27 deletions src/app-route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { IncomingMessage, ServerResponse } from "http";
import type { ValueOrPromise } from "./types.js";
import type {
Application as ExpressApplication,
Response as ExpressResponse,
Request as ExpressRequest,
} from "express";
import type { NextResponse } from "next/server.js";
import type { NextRequest } from "next/server.js";
import { Socket } from "net";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";

import type { ValueOrPromise } from "./types.js";

export type AppRouteRequestHandler = (
req: NextRequest
Expand All @@ -20,45 +26,88 @@ export type AppRouteRequestHandler = (

export const handleNextRequest = async (
req: NextRequest | Request,
app: Express.Application
app: ExpressApplication
) => {
// Create mock socket
const socket = new Socket();

// Transform NextRequest to IncomingMessage
const incoming = new IncomingMessage(socket);
req.headers.forEach((value, name) => {
incoming.headers[name] = value;
});
const incoming = new IncomingMessage(new Socket()) as ExpressRequest;
incoming.method = req.method;
incoming.url = req.url;
for (const [name, value] of Object.entries(req.headers)) {
incoming.headers[name] = value;
}

const text = await req.text();
if (text) {
incoming.push(text);
const requestBody = await req.text();
if (requestBody) {
incoming.push(requestBody);
incoming.push(null);
}

const response = new ServerResponse(incoming);
// Prepare a ServerResponse
const response = new ServerResponse(incoming) as ExpressResponse;

// Gather all streamed body chunks and include them in the response
const stream: Buffer[] = [];

// Needed for res.sendFile()
response.write = function (
chunk: any,
encoding?: BufferEncoding | ((error: Error) => void)
) {
if (encoding)
chunk = Buffer.from(
chunk,
typeof encoding === "string" ? encoding : undefined
);
if (Buffer.isBuffer(chunk)) stream.push(chunk);
else console.log("Not supported");

return true;
};

return new Promise<Response>((resolve, reject) => {
const nativeEnd = response.end;
response.end = (body, encoding) => {
const res = new Response(body);
res.headers.set("Content-Type", "application/json");
resolve(res);
nativeEnd(body, encoding);
response.end = function (
chunk: any,
encoding?: BufferEncoding | (() => void)
) {
// Append headers one by one resolving the special cases around cookie & set-cookie
const headers = new Headers();
const object = this.getHeaders();
for (const key in object) {
const value = object[key];
if (Array.isArray(value)) {
for (const item of value) {
headers.append(key, item);
}
} else headers.append(key, value as string);
}

// Body must fallback to null, otherwise error in case of status 304
let body: string | null = null;
if (chunk)
stream.push(
Buffer.from(
chunk,
typeof encoding === "string" ? encoding : undefined
)
);
if (stream.length) body = Buffer.concat(stream).toString();
console.log("chunk", chunk, "body", body);
resolve(new Response(body, { status: this.statusCode, headers }));
return response;
};

app.handle(incoming, response, (err) => {
app(incoming, response, (err) => {
// This should never happen?
if (err) {
console.log("handleNextRequest.error", err);
reject(err);
} else {
const response = new Response(
JSON.stringify({ message: "This is impossible?" })
);
response.headers.set("Content-Type", "application/json");
resolve(response);
if (response.headersSent)
return console.log("handleNextRequest.headersSent");

// This is arbitrary
response.statusCode = 404;
response.json({ message: "Not found" });
}
});
});
Expand Down
235 changes: 144 additions & 91 deletions test/next-app.test.ts
Original file line number Diff line number Diff line change
@@ -1,111 +1,164 @@
import { test } from "tap";
import { spawn } from "node:child_process";
import { execSync, spawn } from "node:child_process";

test("real server", async (t) => {
test("next", async (t) => {
let nextApp: { close: () => void; address: string };
t.before(async () => {
await new Promise((resolve) => {
const child = spawn("rm", ["-rf", "test/next/.next"], {});
child.on("exit", resolve);
execSync("rm -rf test/next/.next");
nextApp = await next({ dir: "test/next" });
t.teardown(nextApp.close);
});

t.test("query", async (t) => {
const query = await fetch(nextApp.address + "/query?message=bar").then(
(r) => r.json()
);
t.same(query, { message: "bar" });
});

t.test("headers", async (t) => {
const headers = new Headers({
example: "one",
Example: "two",
cookie: "one",
Cookie: "two",
"set-cookie": "one",
"Set-Cookie": "two",
"user-agent": "one",
"User-Agent": "two",
});
headers.append("example", "three");
headers.append("Example", "four");
headers.append("cookie", "three");
headers.append("Cookie", "four");
headers.append("set-cookie", "three");
headers.append("Set-Cookie", "four");
headers.append("user-agent", "three");
headers.append("User-Agent", "four");
const r = await fetch(nextApp.address + "/headers", { headers });
t.has(await r.json(), {
example: "one, two, three, four",
cookie: "one; two; three; four",
"set-cookie": "one, two, three, four",
"user-agent": "one, two, three, four",
});
});

t.test("body", async (t) => {
const body = await fetch(nextApp.address + "/body", {
headers: { "content-type": "application/json" },
method: "POST",
body: JSON.stringify({ message: "bar" }),
}).then((r) => r.json());
t.same(body, { message: "bar" });
});

const nextApp = await next({ dir: "test/next" });
t.teardown(nextApp.close);
console.log("next url", nextApp.address);

const query = await fetch(nextApp.address + "/query?message=bar").then((r) =>
r.json()
);
t.same(query, { message: "bar" });

const headers = await fetch(nextApp.address + "/headers", {
headers: {
example: "bar",
Uppercase: "BAR",
"X-Custom": "custom",
},
}).then((r) => r.json());
t.has(headers, {
example: "bar",
uppercase: "BAR",
"x-custom": "custom",
t.test("bodyUrlEncoded", async (t) => {
const bodyUrlEncoded = await fetch(nextApp.address + "/body", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "message=bar",
}).then((r) => r.json());
t.same(bodyUrlEncoded, { message: "bar" });
});

const body = await fetch(nextApp.address + "/body", {
headers: { "content-type": "application/json" },
method: "POST",
body: JSON.stringify({ message: "bar" }),
}).then((r) => r.json());
t.same(body, { message: "bar" });

const bodyUrlEncoded = await fetch(nextApp.address + "/body", {
method: "POST",
// url encoded
headers: { "content-type": "application/x-www-form-urlencoded" },
body: "message=bar",
}).then((r) => r.json());
t.same(bodyUrlEncoded, { message: "bar" });

// TODO
const sendFile = await fetch(nextApp.address + "/end", {}).then((r) =>
r.text()
);
t.same(sendFile, "barfile");
t.test("end", async (t) => {
const send = await fetch(nextApp.address + "/end?send=1");
t.same(send.headers.get("content-type"), "text/html; charset=utf-8");
const sendBody = await send.text();
t.same(sendBody, "<!DOCTYPE html>send\n");
});

t.test("json", async (t) => {
const json = await fetch(nextApp.address + "/end?json=1");
t.same(json.headers.get("content-type"), "application/json; charset=utf-8");
const jsonBody = await json.json();
t.same(jsonBody, { message: "Hello" });
});

t.test("sendFile", async (t) => {
const sendFileTxt = await fetch(nextApp.address + "/end?sendFile=txt", {});
t.same(
sendFileTxt.headers.get("content-type"),
"text/plain; charset=UTF-8"
);
const sendFileTxtBody = await sendFileTxt.text();
t.same(sendFileTxtBody, "sendFile");

const sendFileHtml = await fetch(
nextApp.address + "/end?sendFile=html",
{}
);
t.same(
sendFileHtml.headers.get("content-type"),
"text/html; charset=UTF-8"
);
const sendFileHtmlBody = await sendFileHtml.text();
t.same(sendFileHtmlBody, "<!DOCTYPE html>sendFile\n");

const notFound = await fetch(nextApp.address + "/end?notFound=1");
t.same(
notFound.headers.get("content-type"),
"application/json; charset=utf-8"
);
const notFoundBody = await notFound.json();
t.same(notFoundBody, { message: "Not found" });
});
});

// Cannot run next() from 'next' because of ts transpilation
async function next({ dir }: { dir: string }) {
return new Promise<{ close: () => void; address: string }>(
(resolve, reject) => {
// Start server in child process
let started = false;

// TODO: random port?
console.log("starting next", dir);
const child = spawn("npx", ["next"], {
cwd: process.cwd() + "/" + dir,
env: {
PATH: process.env.PATH,
PORT: Math.floor(Math.random() * 50000 + 10000).toString(),
},
detached: false,
});
return new Promise<{ close: () => void; address: string }>((resolve) => {
// Start server in child process
let started = false;

child.stdout.setEncoding("utf8");
child.stdout.on("data", async function (data) {
// console.log("|", data);
if (!started && data.includes("started server on")) {
started = true;
resolve({
address: data.split("url:")[1].trim(),
close: kill,
});
// TODO: random port?
const child = spawn("../../node_modules/.bin/next", {
cwd: process.cwd() + "/" + dir,
env: {
PATH: process.env.PATH,
PORT: Math.floor(Math.random() * 50000 + 10000).toString(),
},
detached: false,
});

// Don't need input at all
child.stdin.end();

child.stdout.setEncoding("utf8");
child.stdout.on("data", async function (data) {
// console.log("|", data);
if (!started && data.includes("started server on")) {
started = true;
resolve({
address: data.split("url:")[1].trim(),
close: kill,
});
}
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", function (data) {
console.log(dir, "stderr", data);
});

async function kill() {
await new Promise<void>((resolve) => {
if (child.exitCode === null) {
child.on("exit", resolve);
} else {
return resolve();
}
});
child.stderr.setEncoding("utf8");
child.stderr.on("data", function (data) {
console.log("stderr: ", data);
});

function kill() {
return new Promise<void>((resolve) => {
child.kill();
setTimeout(() => {
if (child.exitCode === null) {
child.on("exit", resolve);
child.on("close", (d) => console.log("close", d));
child.on("error", (d) => console.log("error", d));
} else {
return resolve();
console.log(dir, "SIGKILL");
child.kill("SIGKILL");
}
}, 2000);
});

child.kill();
setTimeout(() => {
if (child.exitCode === null) {
console.log("SIGKILL");
child.kill("SIGKILL");
}
}, 2000);
});
}
execSync("pkill -f next/dist/compiled/jest-worker/processChild.js");
}
);
});
}
1 change: 1 addition & 0 deletions test/next/app/end/message.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!DOCTYPE html>sendFile
2 changes: 1 addition & 1 deletion test/next/app/end/message.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
barfile
sendFile
Loading

0 comments on commit 67707ef

Please sign in to comment.