1
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2024-11-27 16:10:57 -05:00

fix(ext/node): refactor http.ServerResponse into function class (#26210)

While testing, I found out that light-my-request relies on
`ServerResponse.connection`, which is deprecated, so I added that and
`socket`, the non deprecated property.

It also relies on an undocumented `_header` property, apparently for
[raw header
processing](https://github.com/fastify/light-my-request/blob/v6.1.0/lib/response.js#L180-L186).
I added it as an empty string, feel free to provide other approaches.

Fixes #19901

Co-authored-by: Bartek Iwańczuk <biwanczuk@gmail.com>
This commit is contained in:
Nicola Bovolato 2024-10-25 00:02:26 +02:00 committed by GitHub
parent fd8bf08271
commit 8dd6177c62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 407 additions and 207 deletions

View file

@ -34,6 +34,7 @@ import {
finished, finished,
Readable as NodeReadable, Readable as NodeReadable,
Writable as NodeWritable, Writable as NodeWritable,
WritableOptions as NodeWritableOptions,
} from "node:stream"; } from "node:stream";
import { import {
kUniqueHeaders, kUniqueHeaders,
@ -70,6 +71,7 @@ import { resourceForReadableStream } from "ext:deno_web/06_streams.js";
import { UpgradedConn } from "ext:deno_net/01_net.js"; import { UpgradedConn } from "ext:deno_net/01_net.js";
import { STATUS_CODES } from "node:_http_server"; import { STATUS_CODES } from "node:_http_server";
import { methods as METHODS } from "node:_http_common"; import { methods as METHODS } from "node:_http_common";
import { deprecate } from "node:util";
const { internalRidSymbol } = core; const { internalRidSymbol } = core;
const { ArrayIsArray, StringPrototypeToLowerCase } = primordials; const { ArrayIsArray, StringPrototypeToLowerCase } = primordials;
@ -1184,49 +1186,95 @@ function onError(self, error, cb) {
} }
} }
export class ServerResponse extends NodeWritable { export type ServerResponse = {
statusCode = 200; statusCode: number;
statusMessage?: string = undefined; statusMessage?: string;
#headers: Record<string, string | string[]> = { __proto__: null };
#hasNonStringHeaders: boolean = false; _headers: Record<string, string | string[]>;
#readable: ReadableStream; _hasNonStringHeaders: boolean;
override writable = true;
// used by `npm:on-finished` _readable: ReadableStream;
finished = false; finished: boolean;
headersSent = false; headersSent: boolean;
#resolve: (value: Response | PromiseLike<Response>) => void; _resolve: (value: Response | PromiseLike<Response>) => void;
// deno-lint-ignore no-explicit-any // deno-lint-ignore no-explicit-any
#socketOverride: any | null = null; _socketOverride: any | null;
// deno-lint-ignore no-explicit-any
socket: any | null;
static #enqueue(controller: ReadableStreamDefaultController, chunk: Chunk) { setHeader(name: string, value: string | string[]): void;
try { appendHeader(name: string, value: string | string[]): void;
if (typeof chunk === "string") { getHeader(name: string): string | string[];
controller.enqueue(ENCODER.encode(chunk)); removeHeader(name: string): void;
} else { getHeaderNames(): string[];
controller.enqueue(chunk); getHeaders(): Record<string, string | number | string[]>;
} hasHeader(name: string): boolean;
} catch (_) {
// The stream might have been closed. Ignore the error.
}
}
/** Returns true if the response body should be null with the given writeHead(
* http status code */ status: number,
static #bodyShouldBeNull(status: number) { statusMessage?: string,
return status === 101 || status === 204 || status === 205 || status === 304; headers?:
} | Record<string, string | number | string[]>
| Array<[string, string]>,
): void;
writeHead(
status: number,
headers?:
| Record<string, string | number | string[]>
| Array<[string, string]>,
): void;
constructor( _ensureHeaders(singleChunk?: Chunk): void;
respond(final: boolean, singleChunk?: Chunk): void;
// deno-lint-ignore no-explicit-any
end(chunk?: any, encoding?: any, cb?: any): void;
flushHeaders(): void;
_implicitHeader(): void;
// Undocumented field used by `npm:light-my-request`.
_header: string;
assignSocket(socket): void;
detachSocket(socket): void;
} & { -readonly [K in keyof NodeWritable]: NodeWritable[K] };
type ServerResponseStatic = {
new (
resolve: (value: Response | PromiseLike<Response>) => void, resolve: (value: Response | PromiseLike<Response>) => void,
socket: FakeSocket, socket: FakeSocket,
) { ): ServerResponse;
let controller: ReadableByteStreamController; _enqueue(controller: ReadableStreamDefaultController, chunk: Chunk): void;
const readable = new ReadableStream({ _bodyShouldBeNull(statusCode: number): boolean;
start(c) { };
controller = c as ReadableByteStreamController;
}, export const ServerResponse = function (
}); this: ServerResponse,
super({ resolve: (value: Response | PromiseLike<Response>) => void,
socket: FakeSocket,
) {
this.statusCode = 200;
this.statusMessage = undefined;
this._headers = { __proto__: null };
this._hasNonStringHeaders = false;
this.writable = true;
// used by `npm:on-finished`
this.finished = false;
this.headersSent = false;
this._socketOverride = null;
let controller: ReadableByteStreamController;
const readable = new ReadableStream({
start(c) {
controller = c as ReadableByteStreamController;
},
});
NodeWritable.call(
this,
{
autoDestroy: true, autoDestroy: true,
defaultEncoding: "utf-8", defaultEncoding: "utf-8",
emitClose: true, emitClose: true,
@ -1235,16 +1283,16 @@ export class ServerResponse extends NodeWritable {
write: (chunk, encoding, cb) => { write: (chunk, encoding, cb) => {
// Writes chunks are directly written to the socket if // Writes chunks are directly written to the socket if
// one is assigned via assignSocket() // one is assigned via assignSocket()
if (this.#socketOverride && this.#socketOverride.writable) { if (this._socketOverride && this._socketOverride.writable) {
this.#socketOverride.write(chunk, encoding); this._socketOverride.write(chunk, encoding);
return cb(); return cb();
} }
if (!this.headersSent) { if (!this.headersSent) {
ServerResponse.#enqueue(controller, chunk); ServerResponse._enqueue(controller, chunk);
this.respond(false); this.respond(false);
return cb(); return cb();
} }
ServerResponse.#enqueue(controller, chunk); ServerResponse._enqueue(controller, chunk);
return cb(); return cb();
}, },
final: (cb) => { final: (cb) => {
@ -1260,193 +1308,269 @@ export class ServerResponse extends NodeWritable {
} }
return cb(null); return cb(null);
}, },
}); } satisfies NodeWritableOptions,
this.#readable = readable; );
this.#resolve = resolve;
this.socket = socket;
}
setHeader(name: string, value: string | string[]) { this._readable = readable;
if (Array.isArray(value)) { this._resolve = resolve;
this.#hasNonStringHeaders = true; this.socket = socket;
}
this.#headers[StringPrototypeToLowerCase(name)] = value;
return this;
}
appendHeader(name: string, value: string | string[]) { this._header = "";
const key = StringPrototypeToLowerCase(name); } as unknown as ServerResponseStatic;
if (this.#headers[key] === undefined) {
if (Array.isArray(value)) this.#hasNonStringHeaders = true; Object.setPrototypeOf(ServerResponse.prototype, NodeWritable.prototype);
this.#headers[key] = value; Object.setPrototypeOf(ServerResponse, NodeWritable);
ServerResponse._enqueue = function (
this: ServerResponse,
controller: ReadableStreamDefaultController,
chunk: Chunk,
) {
try {
if (typeof chunk === "string") {
controller.enqueue(ENCODER.encode(chunk));
} else { } else {
this.#hasNonStringHeaders = true; controller.enqueue(chunk);
if (!Array.isArray(this.#headers[key])) { }
this.#headers[key] = [this.#headers[key]]; } catch (_) {
// The stream might have been closed. Ignore the error.
}
};
/** Returns true if the response body should be null with the given
* http status code */
ServerResponse._bodyShouldBeNull = function (
this: ServerResponse,
status: number,
) {
return status === 101 || status === 204 || status === 205 || status === 304;
};
ServerResponse.prototype.setHeader = function (
this: ServerResponse,
name: string,
value: string | string[],
) {
if (Array.isArray(value)) {
this._hasNonStringHeaders = true;
}
this._headers[StringPrototypeToLowerCase(name)] = value;
return this;
};
ServerResponse.prototype.appendHeader = function (
this: ServerResponse,
name: string,
value: string | string[],
) {
const key = StringPrototypeToLowerCase(name);
if (this._headers[key] === undefined) {
if (Array.isArray(value)) this._hasNonStringHeaders = true;
this._headers[key] = value;
} else {
this._hasNonStringHeaders = true;
if (!Array.isArray(this._headers[key])) {
this._headers[key] = [this._headers[key]];
}
const header = this._headers[key];
if (Array.isArray(value)) {
header.push(...value);
} else {
header.push(value);
}
}
return this;
};
ServerResponse.prototype.getHeader = function (
this: ServerResponse,
name: string,
) {
return this._headers[StringPrototypeToLowerCase(name)];
};
ServerResponse.prototype.removeHeader = function (
this: ServerResponse,
name: string,
) {
delete this._headers[StringPrototypeToLowerCase(name)];
};
ServerResponse.prototype.getHeaderNames = function (this: ServerResponse) {
return Object.keys(this._headers);
};
ServerResponse.prototype.getHeaders = function (
this: ServerResponse,
): Record<string, string | number | string[]> {
return { __proto__: null, ...this._headers };
};
ServerResponse.prototype.hasHeader = function (
this: ServerResponse,
name: string,
) {
return Object.hasOwn(this._headers, name);
};
ServerResponse.prototype.writeHead = function (
this: ServerResponse,
status: number,
statusMessageOrHeaders?:
| string
| Record<string, string | number | string[]>
| Array<[string, string]>,
maybeHeaders?:
| Record<string, string | number | string[]>
| Array<[string, string]>,
) {
this.statusCode = status;
let headers = null;
if (typeof statusMessageOrHeaders === "string") {
this.statusMessage = statusMessageOrHeaders;
if (maybeHeaders !== undefined) {
headers = maybeHeaders;
}
} else if (statusMessageOrHeaders !== undefined) {
headers = statusMessageOrHeaders;
}
if (headers !== null) {
if (ArrayIsArray(headers)) {
headers = headers as Array<[string, string]>;
for (let i = 0; i < headers.length; i++) {
this.appendHeader(headers[i][0], headers[i][1]);
} }
const header = this.#headers[key]; } else {
if (Array.isArray(value)) { headers = headers as Record<string, string>;
header.push(...value); for (const k in headers) {
} else { if (Object.hasOwn(headers, k)) {
header.push(value); this.setHeader(k, headers[k]);
}
} }
} }
return this;
} }
getHeader(name: string) { return this;
return this.#headers[StringPrototypeToLowerCase(name)]; };
}
removeHeader(name: string) {
delete this.#headers[StringPrototypeToLowerCase(name)];
}
getHeaderNames() {
return Object.keys(this.#headers);
}
getHeaders(): Record<string, string | number | string[]> {
// @ts-ignore Ignore null __proto__
return { __proto__: null, ...this.#headers };
}
hasHeader(name: string) {
return Object.hasOwn(this.#headers, name);
}
writeHead( ServerResponse.prototype._ensureHeaders = function (
status: number, this: ServerResponse,
statusMessage?: string, singleChunk?: Chunk,
headers?: ) {
| Record<string, string | number | string[]> if (this.statusCode === 200 && this.statusMessage === undefined) {
| Array<[string, string]>, this.statusMessage = "OK";
): this; }
writeHead( if (typeof singleChunk === "string" && !this.hasHeader("content-type")) {
status: number, this.setHeader("content-type", "text/plain;charset=UTF-8");
headers?: }
| Record<string, string | number | string[]> };
| Array<[string, string]>,
): this;
writeHead(
status: number,
statusMessageOrHeaders?:
| string
| Record<string, string | number | string[]>
| Array<[string, string]>,
maybeHeaders?:
| Record<string, string | number | string[]>
| Array<[string, string]>,
): this {
this.statusCode = status;
let headers = null; ServerResponse.prototype.respond = function (
if (typeof statusMessageOrHeaders === "string") { this: ServerResponse,
this.statusMessage = statusMessageOrHeaders; final: boolean,
if (maybeHeaders !== undefined) { singleChunk?: Chunk,
headers = maybeHeaders; ) {
} this.headersSent = true;
} else if (statusMessageOrHeaders !== undefined) { this._ensureHeaders(singleChunk);
headers = statusMessageOrHeaders; let body = singleChunk ?? (final ? null : this._readable);
} if (ServerResponse._bodyShouldBeNull(this.statusCode)) {
body = null;
if (headers !== null) { }
if (ArrayIsArray(headers)) { let headers: Record<string, string> | [string, string][] = this
headers = headers as Array<[string, string]>; ._headers as Record<string, string>;
for (let i = 0; i < headers.length; i++) { if (this._hasNonStringHeaders) {
this.appendHeader(headers[i][0], headers[i][1]); headers = [];
// Guard is not needed as this is a null prototype object.
// deno-lint-ignore guard-for-in
for (const key in this._headers) {
const entry = this._headers[key];
if (Array.isArray(entry)) {
for (const value of entry) {
headers.push([key, value]);
} }
} else { } else {
headers = headers as Record<string, string>; headers.push([key, entry]);
for (const k in headers) {
if (Object.hasOwn(headers, k)) {
this.setHeader(k, headers[k]);
}
}
} }
} }
return this;
}
#ensureHeaders(singleChunk?: Chunk) {
if (this.statusCode === 200 && this.statusMessage === undefined) {
this.statusMessage = "OK";
}
if (
typeof singleChunk === "string" &&
!this.hasHeader("content-type")
) {
this.setHeader("content-type", "text/plain;charset=UTF-8");
}
}
respond(final: boolean, singleChunk?: Chunk) {
this.headersSent = true;
this.#ensureHeaders(singleChunk);
let body = singleChunk ?? (final ? null : this.#readable);
if (ServerResponse.#bodyShouldBeNull(this.statusCode)) {
body = null;
}
let headers: Record<string, string> | [string, string][] = this
.#headers as Record<string, string>;
if (this.#hasNonStringHeaders) {
headers = [];
// Guard is not needed as this is a null prototype object.
// deno-lint-ignore guard-for-in
for (const key in this.#headers) {
const entry = this.#headers[key];
if (Array.isArray(entry)) {
for (const value of entry) {
headers.push([key, value]);
}
} else {
headers.push([key, entry]);
}
}
}
this.#resolve(
new Response(body, {
headers,
status: this.statusCode,
statusText: this.statusMessage,
}),
);
} }
this._resolve(
new Response(body, {
headers,
status: this.statusCode,
statusText: this.statusMessage,
}),
);
};
ServerResponse.prototype.end = function (
this: ServerResponse,
// deno-lint-ignore no-explicit-any // deno-lint-ignore no-explicit-any
override end(chunk?: any, encoding?: any, cb?: any): this { chunk?: any,
this.finished = true; // deno-lint-ignore no-explicit-any
if (!chunk && "transfer-encoding" in this.#headers) { encoding?: any,
// FIXME(bnoordhuis) Node sends a zero length chunked body instead, i.e., // deno-lint-ignore no-explicit-any
// the trailing "0\r\n", but respondWith() just hangs when I try that. cb?: any,
this.#headers["content-length"] = "0"; ) {
delete this.#headers["transfer-encoding"]; this.finished = true;
} if (!chunk && "transfer-encoding" in this._headers) {
// FIXME(bnoordhuis) Node sends a zero length chunked body instead, i.e.,
// @ts-expect-error The signature for cb is stricter than the one implemented here // the trailing "0\r\n", but respondWith() just hangs when I try that.
return super.end(chunk, encoding, cb); this._headers["content-length"] = "0";
delete this._headers["transfer-encoding"];
} }
flushHeaders() { // @ts-expect-error The signature for cb is stricter than the one implemented here
// no-op NodeWritable.prototype.end.call(this, chunk, encoding, cb);
} };
// Undocumented API used by `npm:compression`. ServerResponse.prototype.flushHeaders = function (this: ServerResponse) {
_implicitHeader() { // no-op
this.writeHead(this.statusCode); };
}
assignSocket(socket) { // Undocumented API used by `npm:compression`.
if (socket._httpMessage) { ServerResponse.prototype._implicitHeader = function (this: ServerResponse) {
throw new ERR_HTTP_SOCKET_ASSIGNED(); this.writeHead(this.statusCode);
} };
socket._httpMessage = this;
this.#socketOverride = socket;
}
detachSocket(socket) { ServerResponse.prototype.assignSocket = function (
assert(socket._httpMessage === this); this: ServerResponse,
socket._httpMessage = null; socket,
this.#socketOverride = null; ) {
if (socket._httpMessage) {
throw new ERR_HTTP_SOCKET_ASSIGNED();
} }
} socket._httpMessage = this;
this._socketOverride = socket;
};
ServerResponse.prototype.detachSocket = function (
this: ServerResponse,
socket,
) {
assert(socket._httpMessage === this);
socket._httpMessage = null;
this._socketOverride = null;
};
Object.defineProperty(ServerResponse.prototype, "connection", {
get: deprecate(
function (this: ServerResponse) {
return this._socketOverride;
},
"ServerResponse.prototype.connection is deprecated",
"DEP0066",
),
set: deprecate(
// deno-lint-ignore no-explicit-any
function (this: ServerResponse, socket: any) {
this._socketOverride = socket;
},
"ServerResponse.prototype.connection is deprecated",
"DEP0066",
),
});
// TODO(@AaronO): optimize // TODO(@AaronO): optimize
export class IncomingMessageForServer extends NodeReadable { export class IncomingMessageForServer extends NodeReadable {

View file

@ -3,10 +3,14 @@
// deno-lint-ignore-file no-console // deno-lint-ignore-file no-console
import EventEmitter from "node:events"; import EventEmitter from "node:events";
import http, { type RequestOptions, type ServerResponse } from "node:http"; import http, {
IncomingMessage,
type RequestOptions,
ServerResponse,
} from "node:http";
import url from "node:url"; import url from "node:url";
import https from "node:https"; import https from "node:https";
import net from "node:net"; import net, { Socket } from "node:net";
import fs from "node:fs"; import fs from "node:fs";
import { text } from "node:stream/consumers"; import { text } from "node:stream/consumers";
@ -1704,3 +1708,75 @@ Deno.test("[node/http] upgraded socket closes when the server closed without clo
await clientSocketClosed.promise; await clientSocketClosed.promise;
await serverProcessClosed.promise; await serverProcessClosed.promise;
}); });
// deno-lint-ignore require-await
Deno.test("[node/http] ServerResponse.call()", async () => {
function Wrapper(this: unknown, req: IncomingMessage) {
ServerResponse.call(this, req);
}
Object.setPrototypeOf(Wrapper.prototype, ServerResponse.prototype);
// deno-lint-ignore no-explicit-any
const wrapper = new (Wrapper as any)(new IncomingMessage(new Socket()));
assert(wrapper instanceof ServerResponse);
});
Deno.test("[node/http] ServerResponse _header", async () => {
const { promise, resolve } = Promise.withResolvers<void>();
const server = http.createServer((_req, res) => {
assert(Object.hasOwn(res, "_header"));
res.end();
});
server.listen(async () => {
const { port } = server.address() as { port: number };
const res = await fetch(`http://localhost:${port}`);
await res.body?.cancel();
server.close(() => {
resolve();
});
});
await promise;
});
Deno.test("[node/http] ServerResponse connection", async () => {
const { promise, resolve } = Promise.withResolvers<void>();
const server = http.createServer((_req, res) => {
assert(Object.hasOwn(res, "connection"));
assert(res.connection instanceof Socket);
res.end();
});
server.listen(async () => {
const { port } = server.address() as { port: number };
const res = await fetch(`http://localhost:${port}`);
await res.body?.cancel();
server.close(() => {
resolve();
});
});
await promise;
});
Deno.test("[node/http] ServerResponse socket", async () => {
const { promise, resolve } = Promise.withResolvers<void>();
const server = http.createServer((_req, res) => {
assert(Object.hasOwn(res, "socket"));
assert(res.socket instanceof Socket);
res.end();
});
server.listen(async () => {
const { port } = server.address() as { port: number };
const res = await fetch(`http://localhost:${port}`);
await res.body?.cancel();
server.close(() => {
resolve();
});
});
await promise;
});