Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 05cc8ef

Browse files
committed
Allow WebSocket upgrade responses with error status, closes #174
1 parent a598cd0 commit 05cc8ef

File tree

2 files changed

+157
-113
lines changed

2 files changed

+157
-113
lines changed

packages/http-server/src/index.ts

Lines changed: 132 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import assert from "assert";
44
import http from "http";
55
import https from "https";
6+
import net from "net";
67
import { Transform, Writable } from "stream";
78
import { ReadableStream } from "stream/web";
89
import { URL } from "url";
@@ -17,7 +18,7 @@ import {
1718
_headersFromIncomingRequest,
1819
logResponse,
1920
} from "@miniflare/core";
20-
import { prefixError, randomHex } from "@miniflare/shared";
21+
import { Log, prefixError, randomHex } from "@miniflare/shared";
2122
import { coupleWebSocket } from "@miniflare/web-sockets";
2223
import { BodyInit, Headers } from "undici";
2324
import { getAccessibleHosts } from "./helpers";
@@ -143,6 +144,111 @@ export type RequestListener = (
143144
res?: http.ServerResponse
144145
) => Promise<Response | undefined>;
145146

147+
async function writeResponse(
148+
response: Response,
149+
res: http.ServerResponse,
150+
liveReload = false,
151+
log?: Log
152+
) {
153+
const headers: http.OutgoingHttpHeaders = {};
154+
// eslint-disable-next-line prefer-const
155+
for (let [key, value] of response.headers) {
156+
key = key.toLowerCase();
157+
if (key === "set-cookie") {
158+
// Multiple Set-Cookie headers should be treated as separate headers
159+
// @ts-expect-error getAll is added to the Headers prototype by
160+
// importing @miniflare/core
161+
headers["set-cookie"] = response.headers.getAll("set-cookie");
162+
} else {
163+
headers[key] = value;
164+
}
165+
}
166+
167+
// Use body's actual length instead of the Content-Length header if set,
168+
// see https://github.com/cloudflare/miniflare/issues/148. We also might
169+
// need to adjust this later for live reloading so hold onto it.
170+
const contentLengthHeader = response.headers.get("Content-Length");
171+
const contentLength =
172+
_getBodyLength(response) ??
173+
(contentLengthHeader === null ? null : parseInt(contentLengthHeader));
174+
if (contentLength !== null) headers["content-length"] = contentLength;
175+
176+
// If a Content-Encoding is set, and the user hasn't encoded the body,
177+
// we're responsible for doing so.
178+
const encoders: Transform[] = [];
179+
if (headers["content-encoding"] && response.encodeBody === "auto") {
180+
// Content-Length will be wrong as it's for the decoded length
181+
delete headers["content-length"];
182+
// Reverse of https://github.com/nodejs/undici/blob/48d9578f431cbbd6e74f77455ba92184f57096cf/lib/fetch/index.js#L1660
183+
const codings = headers["content-encoding"]
184+
.toString()
185+
.toLowerCase()
186+
.split(",")
187+
.map((x) => x.trim());
188+
for (const coding of codings) {
189+
if (/(x-)?gzip/.test(coding)) {
190+
encoders.push(zlib.createGzip());
191+
} else if (/(x-)?deflate/.test(coding)) {
192+
encoders.push(zlib.createDeflate());
193+
} else if (coding === "br") {
194+
encoders.push(zlib.createBrotliCompress());
195+
} else {
196+
// Unknown encoding, don't do any encoding at all
197+
log?.warn(`Unknown encoding \"${coding}\", sending plain response...`);
198+
delete headers["content-encoding"];
199+
encoders.length = 0;
200+
break;
201+
}
202+
}
203+
}
204+
205+
// Add live reload script if enabled, this isn't an already encoded
206+
// response, and it's HTML
207+
const liveReloadEnabled =
208+
liveReload &&
209+
response.encodeBody === "auto" &&
210+
response.headers.get("content-type")?.toLowerCase().includes("text/html");
211+
212+
// If Content-Length is specified, and we're live-reloading, we'll
213+
// need to adjust it to make room for the live reload script
214+
if (liveReloadEnabled && contentLength !== null) {
215+
if (!isNaN(contentLength)) {
216+
// Append length of live reload script
217+
headers["content-length"] = contentLength + liveReloadScriptLength;
218+
}
219+
}
220+
221+
res.writeHead(response.status, headers);
222+
223+
// `initialStream` is the stream we'll write the response to. It
224+
// should end up as the first encoder, piping to the next encoder,
225+
// and finally piping to the response:
226+
//
227+
// encoders[0] (initialStream) -> encoders[1] -> res
228+
//
229+
// Not using `pipeline(passThrough, ...encoders, res)` here as that
230+
// gives a premature close error with server sent events. This also
231+
// avoids creating an extra stream even when we're not encoding.
232+
let initialStream: Writable = res;
233+
for (let i = encoders.length - 1; i >= 0; i--) {
234+
encoders[i].pipe(initialStream);
235+
initialStream = encoders[i];
236+
}
237+
238+
// Response body may be null if empty
239+
if (response.body) {
240+
for await (const chunk of response.body) {
241+
if (chunk) initialStream.write(chunk);
242+
}
243+
244+
if (liveReloadEnabled) {
245+
initialStream.write(liveReloadScript);
246+
}
247+
}
248+
249+
initialStream.end();
250+
}
251+
146252
export function createRequestListener<Plugins extends HTTPPluginSignatures>(
147253
mf: MiniflareCore<Plugins>
148254
): RequestListener {
@@ -184,109 +290,8 @@ export function createRequestListener<Plugins extends HTTPPluginSignatures>(
184290
response = await mf.dispatchFetch(request);
185291
waitUntil = response.waitUntil();
186292
status = response.status;
187-
const headers: http.OutgoingHttpHeaders = {};
188-
// eslint-disable-next-line prefer-const
189-
for (let [key, value] of response.headers) {
190-
key = key.toLowerCase();
191-
if (key === "set-cookie") {
192-
// Multiple Set-Cookie headers should be treated as separate headers
193-
// @ts-expect-error getAll is added to the Headers prototype by
194-
// importing @miniflare/core
195-
headers["set-cookie"] = response.headers.getAll("set-cookie");
196-
} else {
197-
headers[key] = value;
198-
}
199-
}
200-
201-
// Use body's actual length instead of the Content-Length header if set,
202-
// see https://github.com/cloudflare/miniflare/issues/148. We also might
203-
// need to adjust this later for live reloading so hold onto it.
204-
const contentLengthHeader = response.headers.get("Content-Length");
205-
const contentLength =
206-
_getBodyLength(response) ??
207-
(contentLengthHeader === null ? null : parseInt(contentLengthHeader));
208-
if (contentLength !== null) headers["content-length"] = contentLength;
209-
210-
// If a Content-Encoding is set, and the user hasn't encoded the body,
211-
// we're responsible for doing so.
212-
const encoders: Transform[] = [];
213-
if (headers["content-encoding"] && response.encodeBody === "auto") {
214-
// Content-Length will be wrong as it's for the decoded length
215-
delete headers["content-length"];
216-
// Reverse of https://github.com/nodejs/undici/blob/48d9578f431cbbd6e74f77455ba92184f57096cf/lib/fetch/index.js#L1660
217-
const codings = headers["content-encoding"]
218-
.toString()
219-
.toLowerCase()
220-
.split(",")
221-
.map((x) => x.trim());
222-
for (const coding of codings) {
223-
if (/(x-)?gzip/.test(coding)) {
224-
encoders.push(zlib.createGzip());
225-
} else if (/(x-)?deflate/.test(coding)) {
226-
encoders.push(zlib.createDeflate());
227-
} else if (coding === "br") {
228-
encoders.push(zlib.createBrotliCompress());
229-
} else {
230-
// Unknown encoding, don't do any encoding at all
231-
mf.log.warn(
232-
`Unknown encoding \"${coding}\", sending plain response...`
233-
);
234-
delete headers["content-encoding"];
235-
encoders.length = 0;
236-
break;
237-
}
238-
}
239-
}
240-
241-
// Add live reload script if enabled, this isn't an already encoded
242-
// response, and it's HTML
243-
const liveReloadEnabled =
244-
HTTPPlugin.liveReload &&
245-
response.encodeBody === "auto" &&
246-
response.headers
247-
.get("content-type")
248-
?.toLowerCase()
249-
.includes("text/html");
250-
251-
// If Content-Length is specified, and we're live-reloading, we'll
252-
// need to adjust it to make room for the live reload script
253-
if (liveReloadEnabled && contentLength !== null) {
254-
if (!isNaN(contentLength)) {
255-
// Append length of live reload script
256-
headers["content-length"] = contentLength + liveReloadScriptLength;
257-
}
258-
}
259-
260-
res?.writeHead(status, headers);
261-
262-
// Response body may be null if empty
263293
if (res) {
264-
// `initialStream` is the stream we'll write the response to. It
265-
// should end up as the first encoder, piping to the next encoder,
266-
// and finally piping to the response:
267-
//
268-
// encoders[0] (initialStream) -> encoders[1] -> res
269-
//
270-
// Not using `pipeline(passThrough, ...encoders, res)` here as that
271-
// gives a premature close error with server sent events. This also
272-
// avoids creating an extra stream even when we're not encoding.
273-
let initialStream: Writable = res;
274-
for (let i = encoders.length - 1; i >= 0; i--) {
275-
encoders[i].pipe(initialStream);
276-
initialStream = encoders[i];
277-
}
278-
279-
if (response.body) {
280-
for await (const chunk of response.body) {
281-
if (chunk) initialStream.write(chunk);
282-
}
283-
284-
if (liveReloadEnabled) {
285-
initialStream.write(liveReloadScript);
286-
}
287-
}
288-
289-
initialStream.end();
294+
await writeResponse(response, res, HTTPPlugin.liveReload, mf.log);
290295
}
291296
} catch (e: any) {
292297
// MIME types aren't case sensitive
@@ -394,9 +399,27 @@ export async function createServer<Plugins extends HTTPPluginSignatures>(
394399

395400
// Check web socket response was returned
396401
const webSocket = response?.webSocket;
397-
if (response?.status !== 101 || !webSocket) {
398-
socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n");
399-
socket.destroy();
402+
if (response?.status === 101 && webSocket) {
403+
// Accept and couple the Web Socket
404+
extraHeaders.set(request, response.headers);
405+
webSocketServer.handleUpgrade(request, socket as any, head, (ws) => {
406+
void coupleWebSocket(ws, webSocket);
407+
webSocketServer.emit("connection", ws, request);
408+
});
409+
return;
410+
}
411+
412+
// Otherwise, we'll be returning a regular HTTP response
413+
const res = new http.ServerResponse(request);
414+
// `socket` is guaranteed to be an instance of `net.Socket`:
415+
// https://nodejs.org/api/http.html#event-upgrade_1
416+
assert(socket instanceof net.Socket);
417+
res.assignSocket(socket);
418+
419+
// If no response was provided, or it was an "ok" response, log an error
420+
if (!response || (200 <= response.status && response.status < 300)) {
421+
res.writeHead(500);
422+
res.end();
400423
mf.log.error(
401424
new TypeError(
402425
"Web Socket request did not return status 101 Switching Protocols response with Web Socket"
@@ -405,12 +428,9 @@ export async function createServer<Plugins extends HTTPPluginSignatures>(
405428
return;
406429
}
407430

408-
// Accept and couple the Web Socket
409-
extraHeaders.set(request, response.headers);
410-
webSocketServer.handleUpgrade(request, socket as any, head, (ws) => {
411-
void coupleWebSocket(ws, webSocket);
412-
webSocketServer.emit("connection", ws, request);
413-
});
431+
// Otherwise, send the response as is (e.g. unauthorised),
432+
// always disabling live-reload as this is a WebSocket upgrade
433+
await writeResponse(response, res, false /* liveReload */, mf.log);
414434
}
415435
});
416436
const reloadListener = () => {

packages/http-server/test/index.spec.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -678,7 +678,7 @@ test("createServer: includes headers from web socket upgrade response", async (t
678678
t.not(req.headers["sec-websocket-accept"], ":(");
679679
t.deepEqual(req.headers["set-cookie"], ["key=value"]);
680680
});
681-
test("createServer: expects status 101 and web socket response for upgrades", async (t) => {
681+
test("createServer: expects status 101 and web socket response for successful upgrades", async (t) => {
682682
const log = new TestLog();
683683
log.error = (message) => log.logWithLevel(LogLevel.ERROR, message.toString());
684684
const mf = useMiniflareWithHandler(
@@ -703,6 +703,30 @@ test("createServer: expects status 101 and web socket response for upgrades", as
703703
t.is(closeEvent.code, 1006);
704704
t.is(errorEvent.message, "Unexpected server response: 500");
705705
});
706+
test("createServer: allows non-101 status codes for unsuccessful web socket upgrades", async (t) => {
707+
// https://github.com/cloudflare/miniflare/issues/174
708+
const log = new TestLog();
709+
log.error = (message) => log.logWithLevel(LogLevel.ERROR, message.toString());
710+
const mf = useMiniflareWithHandler(
711+
{ HTTPPlugin },
712+
{},
713+
(globals) => new globals.Response("unauthorized", { status: 401 }),
714+
log
715+
);
716+
const port = await listen(t, await createServer(mf));
717+
718+
const ws = new StandardWebSocket(`ws://localhost:${port}`);
719+
const [closeTrigger, closePromise] = triggerPromise<WebSocketCloseEvent>();
720+
const [errorTrigger, errorPromise] = triggerPromise<WebSocketErrorEvent>();
721+
ws.addEventListener("close", closeTrigger);
722+
ws.addEventListener("error", errorTrigger);
723+
const closeEvent = await closePromise;
724+
const errorEvent = await errorPromise;
725+
726+
t.deepEqual(log.logsAtLevel(LogLevel.ERROR), []);
727+
t.is(closeEvent.code, 1006);
728+
t.is(errorEvent.message, "Unexpected server response: 401");
729+
});
706730
test("createServer: creates new request context for each web socket message", async (t) => {
707731
const mf = useMiniflare(
708732
{

0 commit comments

Comments
 (0)