refactor(ext/http): extract 02_websocket.ts from 01_http.js (#23460)

Landing part of https://github.com/denoland/deno/pull/21903

This will allow us to more easily refactor `serveHttp` to live on top of
`serve` by splitting the websocket code out. There's probably a lot more
we could do here but this helps.
This commit is contained in:
Matt Mastracci 2024-04-19 20:02:39 -06:00 committed by GitHub
parent 79e6751cf7
commit 9425dce6db
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 191 additions and 166 deletions

View File

@ -12,45 +12,34 @@ import {
op_http_shutdown,
op_http_start,
op_http_upgrade_websocket,
op_http_websocket_accept_header,
op_http_write,
op_http_write_headers,
op_http_write_resource,
} from "ext:core/ops";
const {
ArrayPrototypeIncludes,
ArrayPrototypeMap,
ArrayPrototypePush,
ObjectPrototypeIsPrototypeOf,
SafeSet,
SafeSetIterator,
SetPrototypeAdd,
SetPrototypeDelete,
StringPrototypeCharCodeAt,
StringPrototypeIncludes,
StringPrototypeSplit,
StringPrototypeToLowerCase,
StringPrototypeToUpperCase,
Symbol,
SymbolAsyncIterator,
TypeError,
TypedArrayPrototypeGetSymbolToStringTag,
Uint8Array,
} = primordials;
import { _ws } from "ext:deno_http/02_websocket.ts";
import { InnerBody } from "ext:deno_fetch/22_body.js";
import { Event, setEventTargetData } from "ext:deno_web/02_event.js";
import { Event } from "ext:deno_web/02_event.js";
import { BlobPrototype } from "ext:deno_web/09_file.js";
import {
fromInnerResponse,
newInnerResponse,
ResponsePrototype,
toInnerResponse,
} from "ext:deno_fetch/23_response.js";
import {
fromInnerRequest,
newInnerRequest,
toInnerRequest,
} from "ext:deno_fetch/23_request.js";
import { AbortController } from "ext:deno_web/03_abort_signal.js";
import {
@ -63,7 +52,6 @@ import {
_role,
_server,
_serverHandleIdleTimeout,
createWebSocketBranded,
SERVER,
WebSocket,
} from "ext:deno_websocket/01_websocket.js";
@ -409,155 +397,6 @@ function createRespondWith(
};
}
const _ws = Symbol("[[associated_ws]]");
const websocketCvf = buildCaseInsensitiveCommaValueFinder("websocket");
const upgradeCvf = buildCaseInsensitiveCommaValueFinder("upgrade");
function upgradeWebSocket(request, options = {}) {
const inner = toInnerRequest(request);
const upgrade = request.headers.get("upgrade");
const upgradeHasWebSocketOption = upgrade !== null &&
websocketCvf(upgrade);
if (!upgradeHasWebSocketOption) {
throw new TypeError(
"Invalid Header: 'upgrade' header must contain 'websocket'",
);
}
const connection = request.headers.get("connection");
const connectionHasUpgradeOption = connection !== null &&
upgradeCvf(connection);
if (!connectionHasUpgradeOption) {
throw new TypeError(
"Invalid Header: 'connection' header must contain 'Upgrade'",
);
}
const websocketKey = request.headers.get("sec-websocket-key");
if (websocketKey === null) {
throw new TypeError(
"Invalid Header: 'sec-websocket-key' header must be set",
);
}
const accept = op_http_websocket_accept_header(websocketKey);
const r = newInnerResponse(101);
r.headerList = [
["upgrade", "websocket"],
["connection", "Upgrade"],
["sec-websocket-accept", accept],
];
const protocolsStr = request.headers.get("sec-websocket-protocol") || "";
const protocols = StringPrototypeSplit(protocolsStr, ", ");
if (protocols && options.protocol) {
if (ArrayPrototypeIncludes(protocols, options.protocol)) {
ArrayPrototypePush(r.headerList, [
"sec-websocket-protocol",
options.protocol,
]);
} else {
throw new TypeError(
`Protocol '${options.protocol}' not in the request's protocol list (non negotiable)`,
);
}
}
const socket = createWebSocketBranded(WebSocket);
setEventTargetData(socket);
socket[_server] = true;
socket[_idleTimeoutDuration] = options.idleTimeout ?? 120;
socket[_idleTimeoutTimeout] = null;
if (inner._wantsUpgrade) {
return inner._wantsUpgrade("upgradeWebSocket", r, socket);
}
const response = fromInnerResponse(r, "immutable");
response[_ws] = socket;
return { response, socket };
}
const spaceCharCode = StringPrototypeCharCodeAt(" ", 0);
const tabCharCode = StringPrototypeCharCodeAt("\t", 0);
const commaCharCode = StringPrototypeCharCodeAt(",", 0);
/** Builds a case function that can be used to find a case insensitive
* value in some text that's separated by commas.
*
* This is done because it doesn't require any allocations.
* @param checkText {string} - The text to find. (ex. "websocket")
*/
function buildCaseInsensitiveCommaValueFinder(checkText) {
const charCodes = ArrayPrototypeMap(
StringPrototypeSplit(
StringPrototypeToLowerCase(checkText),
"",
),
(c) => [
StringPrototypeCharCodeAt(c, 0),
StringPrototypeCharCodeAt(StringPrototypeToUpperCase(c), 0),
],
);
/** @type {number} */
let i;
/** @type {number} */
let char;
/** @param {string} value */
return function (value) {
for (i = 0; i < value.length; i++) {
char = StringPrototypeCharCodeAt(value, i);
skipWhitespace(value);
if (hasWord(value)) {
skipWhitespace(value);
if (i === value.length || char === commaCharCode) {
return true;
}
} else {
skipUntilComma(value);
}
}
return false;
};
/** @param value {string} */
function hasWord(value) {
for (let j = 0; j < charCodes.length; ++j) {
const { 0: cLower, 1: cUpper } = charCodes[j];
if (cLower === char || cUpper === char) {
char = StringPrototypeCharCodeAt(value, ++i);
} else {
return false;
}
}
return true;
}
/** @param value {string} */
function skipWhitespace(value) {
while (char === spaceCharCode || char === tabCharCode) {
char = StringPrototypeCharCodeAt(value, ++i);
}
}
/** @param value {string} */
function skipUntilComma(value) {
while (char !== commaCharCode && i < value.length) {
char = StringPrototypeCharCodeAt(value, ++i);
}
}
}
// Expose this function for unit tests
internals.buildCaseInsensitiveCommaValueFinder =
buildCaseInsensitiveCommaValueFinder;
function serveHttp(conn) {
internals.warnOnDeprecatedApi(
"Deno.serveHttp()",
@ -568,4 +407,4 @@ function serveHttp(conn) {
return new HttpConn(rid, conn.remoteAddr, conn.localAddr);
}
export { _ws, HttpConn, serveHttp, upgradeWebSocket };
export { HttpConn, serveHttp };

185
ext/http/02_websocket.ts Normal file
View File

@ -0,0 +1,185 @@
// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
import { internals, primordials } from "ext:core/mod.js";
import { op_http_websocket_accept_header } from "ext:core/ops";
const {
ArrayPrototypeIncludes,
ArrayPrototypeMap,
ArrayPrototypePush,
StringPrototypeCharCodeAt,
StringPrototypeSplit,
StringPrototypeToLowerCase,
StringPrototypeToUpperCase,
TypeError,
Symbol,
} = primordials;
import { toInnerRequest } from "ext:deno_fetch/23_request.js";
import {
fromInnerResponse,
newInnerResponse,
} from "ext:deno_fetch/23_response.js";
import { setEventTargetData } from "ext:deno_web/02_event.js";
import {
_eventLoop,
_idleTimeoutDuration,
_idleTimeoutTimeout,
_protocol,
_readyState,
_rid,
_role,
_server,
_serverHandleIdleTimeout,
createWebSocketBranded,
WebSocket,
} from "ext:deno_websocket/01_websocket.js";
const _ws = Symbol("[[associated_ws]]");
const websocketCvf = buildCaseInsensitiveCommaValueFinder("websocket");
const upgradeCvf = buildCaseInsensitiveCommaValueFinder("upgrade");
function upgradeWebSocket(request, options = {}) {
const inner = toInnerRequest(request);
const upgrade = request.headers.get("upgrade");
const upgradeHasWebSocketOption = upgrade !== null &&
websocketCvf(upgrade);
if (!upgradeHasWebSocketOption) {
throw new TypeError(
"Invalid Header: 'upgrade' header must contain 'websocket'",
);
}
const connection = request.headers.get("connection");
const connectionHasUpgradeOption = connection !== null &&
upgradeCvf(connection);
if (!connectionHasUpgradeOption) {
throw new TypeError(
"Invalid Header: 'connection' header must contain 'Upgrade'",
);
}
const websocketKey = request.headers.get("sec-websocket-key");
if (websocketKey === null) {
throw new TypeError(
"Invalid Header: 'sec-websocket-key' header must be set",
);
}
const accept = op_http_websocket_accept_header(websocketKey);
const r = newInnerResponse(101);
r.headerList = [
["upgrade", "websocket"],
["connection", "Upgrade"],
["sec-websocket-accept", accept],
];
const protocolsStr = request.headers.get("sec-websocket-protocol") || "";
const protocols = StringPrototypeSplit(protocolsStr, ", ");
if (protocols && options.protocol) {
if (ArrayPrototypeIncludes(protocols, options.protocol)) {
ArrayPrototypePush(r.headerList, [
"sec-websocket-protocol",
options.protocol,
]);
} else {
throw new TypeError(
`Protocol '${options.protocol}' not in the request's protocol list (non negotiable)`,
);
}
}
const socket = createWebSocketBranded(WebSocket);
setEventTargetData(socket);
socket[_server] = true;
socket[_idleTimeoutDuration] = options.idleTimeout ?? 120;
socket[_idleTimeoutTimeout] = null;
if (inner._wantsUpgrade) {
return inner._wantsUpgrade("upgradeWebSocket", r, socket);
}
const response = fromInnerResponse(r, "immutable");
response[_ws] = socket;
return { response, socket };
}
const spaceCharCode = StringPrototypeCharCodeAt(" ", 0);
const tabCharCode = StringPrototypeCharCodeAt("\t", 0);
const commaCharCode = StringPrototypeCharCodeAt(",", 0);
/** Builds a case function that can be used to find a case insensitive
* value in some text that's separated by commas.
*
* This is done because it doesn't require any allocations.
* @param checkText {string} - The text to find. (ex. "websocket")
*/
function buildCaseInsensitiveCommaValueFinder(checkText) {
const charCodes = ArrayPrototypeMap(
StringPrototypeSplit(
StringPrototypeToLowerCase(checkText),
"",
),
(c) => [
StringPrototypeCharCodeAt(c, 0),
StringPrototypeCharCodeAt(StringPrototypeToUpperCase(c), 0),
],
);
/** @type {number} */
let i;
/** @type {number} */
let char;
/** @param {string} value */
return function (value) {
for (i = 0; i < value.length; i++) {
char = StringPrototypeCharCodeAt(value, i);
skipWhitespace(value);
if (hasWord(value)) {
skipWhitespace(value);
if (i === value.length || char === commaCharCode) {
return true;
}
} else {
skipUntilComma(value);
}
}
return false;
};
/** @param value {string} */
function hasWord(value) {
for (let j = 0; j < charCodes.length; ++j) {
const { 0: cLower, 1: cUpper } = charCodes[j];
if (cLower === char || cUpper === char) {
char = StringPrototypeCharCodeAt(value, ++i);
} else {
return false;
}
}
return true;
}
/** @param value {string} */
function skipWhitespace(value) {
while (char === spaceCharCode || char === tabCharCode) {
char = StringPrototypeCharCodeAt(value, ++i);
}
}
/** @param value {string} */
function skipUntilComma(value) {
while (char !== commaCharCode && i < value.length) {
char = StringPrototypeCharCodeAt(value, ++i);
}
}
}
// Expose this function for unit tests
internals.buildCaseInsensitiveCommaValueFinder =
buildCaseInsensitiveCommaValueFinder;
export { _ws, upgradeWebSocket };

View File

@ -131,7 +131,7 @@ deno_core::extension!(
http_next::op_http_close,
http_next::op_http_cancel,
],
esm = ["00_serve.js", "01_http.js"],
esm = ["00_serve.js", "01_http.js", "02_websocket.ts"],
);
pub enum HttpSocketAddr {

View File

@ -15,6 +15,7 @@ import * as net from "ext:deno_net/01_net.js";
import * as tls from "ext:deno_net/02_tls.js";
import * as serve from "ext:deno_http/00_serve.js";
import * as http from "ext:deno_http/01_http.js";
import * as websocket from "ext:deno_http/02_websocket.ts";
import * as errors from "ext:runtime/01_errors.js";
import * as version from "ext:runtime/01_version.ts";
import * as permissions from "ext:runtime/10_permissions.js";
@ -227,7 +228,7 @@ const denoNs = {
serveHttp: http.serveHttp,
serve: serve.serve,
resolveDns: net.resolveDns,
upgradeWebSocket: http.upgradeWebSocket,
upgradeWebSocket: websocket.upgradeWebSocket,
utime: fs.utime,
utimeSync: fs.utimeSync,
kill: process.kill,