1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2025-07-01 17:42:56 +00:00

Add support for HTTP timeouts (#1453)

- Add a `timeout` parameter to http request and websocket methods.
    - For requests, this sets the connection and read timeout.
    - For websockets, this sets the connection and handshake timeout.
 - Remove the timeout config option, as this is now specified by user
   code.
 - Use netty for handling websocket handshakes, meaning we no longer
   need to deal with pongs.
This commit is contained in:
Jonathan Coates 2023-05-23 23:32:16 +01:00 committed by GitHub
parent 55ed0dc3ef
commit 2ae14b4c08
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 203 additions and 96 deletions

View File

@ -32,9 +32,6 @@ class AddressRuleConfig {
config.add("action", action.name().toLowerCase(Locale.ROOT)); config.add("action", action.name().toLowerCase(Locale.ROOT));
if (host.equals("*") && action == Action.ALLOW) { if (host.equals("*") && action == Action.ALLOW) {
config.setComment("timeout", "The period of time (in milliseconds) to wait before a HTTP request times out. Set to 0 for unlimited.");
config.add("timeout", AddressRule.TIMEOUT);
config.setComment("max_download", """ config.setComment("max_download", """
The maximum size (in bytes) that a computer can download in a single request. The maximum size (in bytes) that a computer can download in a single request.
Note that responses may receive more data than allowed, but this data will not Note that responses may receive more data than allowed, but this data will not
@ -58,7 +55,6 @@ class AddressRuleConfig {
var port = unboxOptInt(get(builder, "port", Number.class)); var port = unboxOptInt(get(builder, "port", Number.class));
return hostObj != null && checkEnum(builder, "action", Action.class) return hostObj != null && checkEnum(builder, "action", Action.class)
&& check(builder, "port", Number.class) && check(builder, "port", Number.class)
&& check(builder, "timeout", Number.class)
&& check(builder, "max_upload", Number.class) && check(builder, "max_upload", Number.class)
&& check(builder, "max_download", Number.class) && check(builder, "max_download", Number.class)
&& check(builder, "websocket_message", Number.class) && check(builder, "websocket_message", Number.class)
@ -72,7 +68,6 @@ class AddressRuleConfig {
var action = getEnum(builder, "action", Action.class).orElse(null); var action = getEnum(builder, "action", Action.class).orElse(null);
var port = unboxOptInt(get(builder, "port", Number.class)); var port = unboxOptInt(get(builder, "port", Number.class));
var timeout = unboxOptInt(get(builder, "timeout", Number.class));
var maxUpload = unboxOptLong(get(builder, "max_upload", Number.class).map(Number::longValue)); var maxUpload = unboxOptLong(get(builder, "max_upload", Number.class).map(Number::longValue));
var maxDownload = unboxOptLong(get(builder, "max_download", Number.class).map(Number::longValue)); var maxDownload = unboxOptLong(get(builder, "max_download", Number.class).map(Number::longValue));
var websocketMessage = unboxOptInt(get(builder, "websocket_message", Number.class).map(Number::intValue)); var websocketMessage = unboxOptInt(get(builder, "websocket_message", Number.class).map(Number::intValue));
@ -81,7 +76,6 @@ class AddressRuleConfig {
action, action,
maxUpload, maxUpload,
maxDownload, maxDownload,
timeout,
websocketMessage websocketMessage
); );

View File

@ -23,6 +23,7 @@ import java.util.Map;
import java.util.Optional; import java.util.Optional;
import static dan200.computercraft.core.apis.TableHelper.*; import static dan200.computercraft.core.apis.TableHelper.*;
import static dan200.computercraft.core.util.ArgumentHelpers.assertBetween;
/** /**
* Placeholder description, please ignore. * Placeholder description, please ignore.
@ -31,6 +32,9 @@ import static dan200.computercraft.core.apis.TableHelper.*;
* @hidden * @hidden
*/ */
public class HTTPAPI implements ILuaAPI { public class HTTPAPI implements ILuaAPI {
private static final double DEFAULT_TIMEOUT = 30;
private static final double MAX_TIMEOUT = 60;
private final IAPIEnvironment apiEnvironment; private final IAPIEnvironment apiEnvironment;
private final ResourceGroup<CheckUrl> checkUrls = new ResourceGroup<>(() -> ResourceGroup.DEFAULT_LIMIT); private final ResourceGroup<CheckUrl> checkUrls = new ResourceGroup<>(() -> ResourceGroup.DEFAULT_LIMIT);
@ -72,6 +76,7 @@ public class HTTPAPI implements ILuaAPI {
String address, postString, requestMethod; String address, postString, requestMethod;
Map<?, ?> headerTable; Map<?, ?> headerTable;
boolean binary, redirect; boolean binary, redirect;
Optional<Double> timeoutArg;
if (args.get(0) instanceof Map) { if (args.get(0) instanceof Map) {
var options = args.getTable(0); var options = args.getTable(0);
@ -81,7 +86,7 @@ public class HTTPAPI implements ILuaAPI {
binary = optBooleanField(options, "binary", false); binary = optBooleanField(options, "binary", false);
requestMethod = optStringField(options, "method", null); requestMethod = optStringField(options, "method", null);
redirect = optBooleanField(options, "redirect", true); redirect = optBooleanField(options, "redirect", true);
timeoutArg = optRealField(options, "timeout");
} else { } else {
// Get URL and post information // Get URL and post information
address = args.getString(0); address = args.getString(0);
@ -90,9 +95,11 @@ public class HTTPAPI implements ILuaAPI {
binary = args.optBoolean(3, false); binary = args.optBoolean(3, false);
requestMethod = null; requestMethod = null;
redirect = true; redirect = true;
timeoutArg = Optional.empty();
} }
var headers = getHeaders(headerTable); var headers = getHeaders(headerTable);
var timeout = getTimeout(timeoutArg);
HttpMethod httpMethod; HttpMethod httpMethod;
if (requestMethod == null) { if (requestMethod == null) {
@ -106,7 +113,7 @@ public class HTTPAPI implements ILuaAPI {
try { try {
var uri = HttpRequest.checkUri(address); var uri = HttpRequest.checkUri(address);
var request = new HttpRequest(requests, apiEnvironment, address, postString, headers, binary, redirect); var request = new HttpRequest(requests, apiEnvironment, address, postString, headers, binary, redirect, timeout);
// Make the request // Make the request
if (!request.queue(r -> r.request(uri, httpMethod))) { if (!request.queue(r -> r.request(uri, httpMethod))) {
@ -134,16 +141,32 @@ public class HTTPAPI implements ILuaAPI {
} }
@LuaFunction @LuaFunction
public final Object[] websocket(String address, Optional<Map<?, ?>> headerTbl) throws LuaException { public final Object[] websocket(IArguments args) throws LuaException {
if (!CoreConfig.httpWebsocketEnabled) { if (!CoreConfig.httpWebsocketEnabled) {
throw new LuaException("Websocket connections are disabled"); throw new LuaException("Websocket connections are disabled");
} }
var headers = getHeaders(headerTbl.orElse(Collections.emptyMap())); String address;
Map<?, ?> headerTable;
Optional<Double> timeoutArg;
if (args.get(0) instanceof Map) {
var options = args.getTable(0);
address = getStringField(options, "url");
headerTable = optTableField(options, "headers", Collections.emptyMap());
timeoutArg = optRealField(options, "timeout");
} else {
address = args.getString(0);
headerTable = args.optTable(1, Collections.emptyMap());
timeoutArg = Optional.empty();
}
var headers = getHeaders(headerTable);
var timeout = getTimeout(timeoutArg);
try { try {
var uri = Websocket.checkUri(address); var uri = Websocket.checkUri(address);
if (!new Websocket(websockets, apiEnvironment, uri, address, headers).queue(Websocket::connect)) { if (!new Websocket(websockets, apiEnvironment, uri, address, headers, timeout).queue(Websocket::connect)) {
throw new LuaException("Too many websockets already open"); throw new LuaException("Too many websockets already open");
} }
@ -171,4 +194,17 @@ public class HTTPAPI implements ILuaAPI {
} }
return headers; return headers;
} }
/**
* Parse the timeout value, asserting it is in range.
*
* @param timeoutArg The (optional) timeout, in seconds.
* @return The parsed timeout value, in milliseconds.
* @throws LuaException If the timeout is in-range.
*/
private static int getTimeout(Optional<Double> timeoutArg) throws LuaException {
double timeout = timeoutArg.orElse(DEFAULT_TIMEOUT);
assertBetween(timeout, 0, MAX_TIMEOUT, "timeout out of range (%s)");
return (int) (timeout * 1000);
}
} }

View File

@ -9,6 +9,7 @@ import dan200.computercraft.api.lua.LuaValues;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import static dan200.computercraft.api.lua.LuaValues.getNumericType; import static dan200.computercraft.api.lua.LuaValues.getNumericType;
@ -100,6 +101,15 @@ public final class TableHelper {
} }
} }
public static Optional<Double> optRealField(Map<?, ?> table, String key) throws LuaException {
var value = table.get(key);
if(value == null) {
return Optional.empty();
} else {
return Optional.of(getRealField(table, key));
}
}
public static double optRealField(Map<?, ?> table, String key, double def) throws LuaException { public static double optRealField(Map<?, ?> table, String key, double def) throws LuaException {
return checkReal(key, optNumberField(table, key, def)); return checkReal(key, optNumberField(table, key, def));
} }

View File

@ -13,6 +13,7 @@ import io.netty.buffer.ByteBuf;
import io.netty.channel.ConnectTimeoutException; import io.netty.channel.ConnectTimeoutException;
import io.netty.channel.EventLoopGroup; import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.handler.codec.DecoderException; import io.netty.handler.codec.DecoderException;
import io.netty.handler.codec.TooLongFrameException; import io.netty.handler.codec.TooLongFrameException;
import io.netty.handler.codec.http.websocketx.WebSocketHandshakeException; import io.netty.handler.codec.http.websocketx.WebSocketHandshakeException;
@ -50,7 +51,7 @@ public final class NetworkUtils {
.build() .build()
); );
public static final AbstractTrafficShapingHandler SHAPING_HANDLER = new GlobalTrafficShapingHandler( private static final AbstractTrafficShapingHandler SHAPING_HANDLER = new GlobalTrafficShapingHandler(
EXECUTOR, CoreConfig.httpUploadBandwidth, CoreConfig.httpDownloadBandwidth EXECUTOR, CoreConfig.httpUploadBandwidth, CoreConfig.httpDownloadBandwidth
); );
@ -141,6 +142,30 @@ public final class NetworkUtils {
return options; return options;
} }
/**
* Set up some basic properties of the channel. This adds a timeout, the traffic shaping handler, and the SSL
* handler.
*
* @param ch The channel to initialise.
* @param uri The URI to connect to.
* @param socketAddress The address of the socket to connect to.
* @param sslContext The SSL context, if present.
* @param timeout The timeout on this channel.
* @see io.netty.channel.ChannelInitializer
*/
public static void initChannel(SocketChannel ch, URI uri, InetSocketAddress socketAddress, @Nullable SslContext sslContext, int timeout) {
if (timeout > 0) ch.config().setConnectTimeoutMillis(timeout);
var p = ch.pipeline();
p.addLast(SHAPING_HANDLER);
if (sslContext != null) {
var handler = sslContext.newHandler(ch.alloc(), uri.getHost(), socketAddress.getPort());
if (timeout > 0) handler.setHandshakeTimeoutMillis(timeout);
p.addLast(handler);
}
}
/** /**
* Read a {@link ByteBuf} into a byte array. * Read a {@link ByteBuf} into a byte array.
* *

View File

@ -12,7 +12,7 @@ public enum Action {
DENY; DENY;
private final PartialOptions partial = new PartialOptions( private final PartialOptions partial = new PartialOptions(
this, OptionalLong.empty(), OptionalLong.empty(), OptionalInt.empty(), OptionalInt.empty() this, OptionalLong.empty(), OptionalLong.empty(), OptionalInt.empty()
); );
public PartialOptions toPartial() { public PartialOptions toPartial() {

View File

@ -23,7 +23,6 @@ import java.util.regex.Pattern;
public final class AddressRule { public final class AddressRule {
public static final long MAX_DOWNLOAD = 16 * 1024 * 1024; public static final long MAX_DOWNLOAD = 16 * 1024 * 1024;
public static final long MAX_UPLOAD = 4 * 1024 * 1024; public static final long MAX_UPLOAD = 4 * 1024 * 1024;
public static final int TIMEOUT = 30_000;
public static final int WEBSOCKET_MESSAGE = 128 * 1024; public static final int WEBSOCKET_MESSAGE = 128 * 1024;
private final AddressPredicate predicate; private final AddressPredicate predicate;

View File

@ -12,14 +12,12 @@ public final class Options {
public final Action action; public final Action action;
public final long maxUpload; public final long maxUpload;
public final long maxDownload; public final long maxDownload;
public final int timeout;
public final int websocketMessage; public final int websocketMessage;
Options(Action action, long maxUpload, long maxDownload, int timeout, int websocketMessage) { Options(Action action, long maxUpload, long maxDownload, int websocketMessage) {
this.action = action; this.action = action;
this.maxUpload = maxUpload; this.maxUpload = maxUpload;
this.maxDownload = maxDownload; this.maxDownload = maxDownload;
this.timeout = timeout;
this.websocketMessage = websocketMessage; this.websocketMessage = websocketMessage;
} }
} }

View File

@ -13,23 +13,21 @@ import java.util.OptionalLong;
@Immutable @Immutable
public final class PartialOptions { public final class PartialOptions {
public static final PartialOptions DEFAULT = new PartialOptions( public static final PartialOptions DEFAULT = new PartialOptions(
null, OptionalLong.empty(), OptionalLong.empty(), OptionalInt.empty(), OptionalInt.empty() null, OptionalLong.empty(), OptionalLong.empty(), OptionalInt.empty()
); );
private final @Nullable Action action; private final @Nullable Action action;
private final OptionalLong maxUpload; private final OptionalLong maxUpload;
private final OptionalLong maxDownload; private final OptionalLong maxDownload;
private final OptionalInt timeout;
private final OptionalInt websocketMessage; private final OptionalInt websocketMessage;
@SuppressWarnings("Immutable") // Lazily initialised, so this mutation is invisible in the public API @SuppressWarnings("Immutable") // Lazily initialised, so this mutation is invisible in the public API
private @Nullable Options options; private @Nullable Options options;
public PartialOptions(@Nullable Action action, OptionalLong maxUpload, OptionalLong maxDownload, OptionalInt timeout, OptionalInt websocketMessage) { public PartialOptions(@Nullable Action action, OptionalLong maxUpload, OptionalLong maxDownload, OptionalInt websocketMessage) {
this.action = action; this.action = action;
this.maxUpload = maxUpload; this.maxUpload = maxUpload;
this.maxDownload = maxDownload; this.maxDownload = maxDownload;
this.timeout = timeout;
this.websocketMessage = websocketMessage; this.websocketMessage = websocketMessage;
} }
@ -40,7 +38,6 @@ public final class PartialOptions {
action == null ? Action.DENY : action, action == null ? Action.DENY : action,
maxUpload.orElse(AddressRule.MAX_UPLOAD), maxUpload.orElse(AddressRule.MAX_UPLOAD),
maxDownload.orElse(AddressRule.MAX_DOWNLOAD), maxDownload.orElse(AddressRule.MAX_DOWNLOAD),
timeout.orElse(AddressRule.TIMEOUT),
websocketMessage.orElse(AddressRule.WEBSOCKET_MESSAGE) websocketMessage.orElse(AddressRule.WEBSOCKET_MESSAGE)
); );
} }
@ -59,7 +56,6 @@ public final class PartialOptions {
action == null && other.action != null ? other.action : action, action == null && other.action != null ? other.action : action,
maxUpload.isPresent() ? maxUpload : other.maxUpload, maxUpload.isPresent() ? maxUpload : other.maxUpload,
maxDownload.isPresent() ? maxDownload : other.maxDownload, maxDownload.isPresent() ? maxDownload : other.maxDownload,
timeout.isPresent() ? timeout : other.timeout,
websocketMessage.isPresent() ? websocketMessage : other.websocketMessage websocketMessage.isPresent() ? websocketMessage : other.websocketMessage
); );
} }

View File

@ -52,12 +52,13 @@ public class HttpRequest extends Resource<HttpRequest> {
private final ByteBuf postBuffer; private final ByteBuf postBuffer;
private final HttpHeaders headers; private final HttpHeaders headers;
private final boolean binary; private final boolean binary;
private final int timeout;
final AtomicInteger redirects; final AtomicInteger redirects;
public HttpRequest( public HttpRequest(
ResourceGroup<HttpRequest> limiter, IAPIEnvironment environment, String address, @Nullable String postText, ResourceGroup<HttpRequest> limiter, IAPIEnvironment environment, String address, @Nullable String postText,
HttpHeaders headers, boolean binary, boolean followRedirects HttpHeaders headers, boolean binary, boolean followRedirects, int timeout
) { ) {
super(limiter); super(limiter);
this.environment = environment; this.environment = environment;
@ -68,6 +69,7 @@ public class HttpRequest extends Resource<HttpRequest> {
this.headers = headers; this.headers = headers;
this.binary = binary; this.binary = binary;
redirects = new AtomicInteger(followRedirects ? MAX_REDIRECTS : 0); redirects = new AtomicInteger(followRedirects ? MAX_REDIRECTS : 0);
this.timeout = timeout;
if (postText != null) { if (postText != null) {
if (!headers.contains(HttpHeaderNames.CONTENT_TYPE)) { if (!headers.contains(HttpHeaderNames.CONTENT_TYPE)) {
@ -143,20 +145,10 @@ public class HttpRequest extends Resource<HttpRequest> {
.handler(new ChannelInitializer<SocketChannel>() { .handler(new ChannelInitializer<SocketChannel>() {
@Override @Override
protected void initChannel(SocketChannel ch) { protected void initChannel(SocketChannel ch) {
NetworkUtils.initChannel(ch, uri, socketAddress, sslContext, timeout);
if (options.timeout > 0) {
ch.config().setConnectTimeoutMillis(options.timeout);
}
var p = ch.pipeline(); var p = ch.pipeline();
p.addLast(NetworkUtils.SHAPING_HANDLER); if (timeout > 0) p.addLast(new ReadTimeoutHandler(timeout, TimeUnit.MILLISECONDS));
if (sslContext != null) {
p.addLast(sslContext.newHandler(ch.alloc(), uri.getHost(), socketAddress.getPort()));
}
if (options.timeout > 0) {
p.addLast(new ReadTimeoutHandler(options.timeout, TimeUnit.MILLISECONDS));
}
p.addLast( p.addLast(
new HttpClientCodec(), new HttpClientCodec(),

View File

@ -23,7 +23,7 @@ import io.netty.handler.codec.http.HttpClientCodec;
import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaderNames;
import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpHeaders;
import io.netty.handler.codec.http.HttpObjectAggregator; import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker; import io.netty.handler.codec.http.websocketx.WebSocketClientProtocolHandler;
import io.netty.handler.codec.http.websocketx.WebSocketVersion; import io.netty.handler.codec.http.websocketx.WebSocketVersion;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -59,13 +59,15 @@ public class Websocket extends Resource<Websocket> {
private final URI uri; private final URI uri;
private final String address; private final String address;
private final HttpHeaders headers; private final HttpHeaders headers;
private final int timeout;
public Websocket(ResourceGroup<Websocket> limiter, IAPIEnvironment environment, URI uri, String address, HttpHeaders headers) { public Websocket(ResourceGroup<Websocket> limiter, IAPIEnvironment environment, URI uri, String address, HttpHeaders headers, int timeout) {
super(limiter); super(limiter);
this.environment = environment; this.environment = environment;
this.uri = uri; this.uri = uri;
this.address = address; this.address = address;
this.headers = headers; this.headers = headers;
this.timeout = timeout;
} }
public static URI checkUri(String address) throws HTTPRequestException { public static URI checkUri(String address) throws HTTPRequestException {
@ -125,23 +127,21 @@ public class Websocket extends Resource<Websocket> {
.handler(new ChannelInitializer<SocketChannel>() { .handler(new ChannelInitializer<SocketChannel>() {
@Override @Override
protected void initChannel(SocketChannel ch) { protected void initChannel(SocketChannel ch) {
var p = ch.pipeline(); NetworkUtils.initChannel(ch, uri, socketAddress, sslContext, timeout);
p.addLast(NetworkUtils.SHAPING_HANDLER);
if (sslContext != null) {
p.addLast(sslContext.newHandler(ch.alloc(), uri.getHost(), socketAddress.getPort()));
}
var subprotocol = headers.get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL); var subprotocol = headers.get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL);
WebSocketClientHandshaker handshaker = new NoOriginWebSocketHandshaker( var handshaker = new NoOriginWebSocketHandshaker(
uri, WebSocketVersion.V13, subprotocol, true, headers, uri, WebSocketVersion.V13, subprotocol, true, headers,
options.websocketMessage <= 0 ? MAX_MESSAGE_SIZE : options.websocketMessage options.websocketMessage <= 0 ? MAX_MESSAGE_SIZE : options.websocketMessage
); );
var p = ch.pipeline();
p.addLast( p.addLast(
new HttpClientCodec(), new HttpClientCodec(),
new HttpObjectAggregator(8192), new HttpObjectAggregator(8192),
WebsocketCompressionHandler.INSTANCE, WebsocketCompressionHandler.INSTANCE,
new WebsocketHandler(Websocket.this, handshaker, options) new WebSocketClientProtocolHandler(handshaker, false, timeout),
new WebsocketHandler(Websocket.this, options)
); );
} }
}) })

View File

@ -17,37 +17,34 @@ import static dan200.computercraft.core.apis.http.websocket.Websocket.MESSAGE_EV
public class WebsocketHandler extends SimpleChannelInboundHandler<Object> { public class WebsocketHandler extends SimpleChannelInboundHandler<Object> {
private final Websocket websocket; private final Websocket websocket;
private final WebSocketClientHandshaker handshaker;
private final Options options; private final Options options;
private boolean handshakeComplete = false;
public WebsocketHandler(Websocket websocket, WebSocketClientHandshaker handshaker, Options options) { public WebsocketHandler(Websocket websocket, Options options) {
this.handshaker = handshaker;
this.websocket = websocket; this.websocket = websocket;
this.options = options; this.options = options;
} }
@Override @Override
public void channelActive(ChannelHandlerContext ctx) throws Exception { public void channelInactive(ChannelHandlerContext ctx) throws Exception {
handshaker.handshake(ctx.channel()); fail("Connection closed");
super.channelActive(ctx); super.channelInactive(ctx);
} }
@Override @Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception { public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
websocket.close(-1, "Websocket is inactive"); if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_COMPLETE) {
super.channelInactive(ctx); websocket.success(ctx.channel(), options);
handshakeComplete = true;
} else if (evt == WebSocketClientProtocolHandler.ClientHandshakeStateEvent.HANDSHAKE_TIMEOUT) {
websocket.failure("Timed out");
}
} }
@Override @Override
public void channelRead0(ChannelHandlerContext ctx, Object msg) { public void channelRead0(ChannelHandlerContext ctx, Object msg) {
if (websocket.isClosed()) return; if (websocket.isClosed()) return;
if (!handshaker.isHandshakeComplete()) {
handshaker.finishHandshake(ctx.channel(), (FullHttpResponse) msg);
websocket.success(ctx.channel(), options);
return;
}
if (msg instanceof FullHttpResponse response) { if (msg instanceof FullHttpResponse response) {
throw new IllegalStateException("Unexpected FullHttpResponse (getStatus=" + response.status() + ", content=" + response.content().toString(CharsetUtil.UTF_8) + ')'); throw new IllegalStateException("Unexpected FullHttpResponse (getStatus=" + response.status() + ", content=" + response.content().toString(CharsetUtil.UTF_8) + ')');
} }
@ -65,9 +62,6 @@ public class WebsocketHandler extends SimpleChannelInboundHandler<Object> {
websocket.environment().queueEvent(MESSAGE_EVENT, websocket.address(), converted, true); websocket.environment().queueEvent(MESSAGE_EVENT, websocket.address(), converted, true);
} else if (frame instanceof CloseWebSocketFrame closeFrame) { } else if (frame instanceof CloseWebSocketFrame closeFrame) {
websocket.close(closeFrame.statusCode(), closeFrame.reasonText()); websocket.close(closeFrame.statusCode(), closeFrame.reasonText());
} else if (frame instanceof PingWebSocketFrame) {
frame.content().retain();
ctx.channel().writeAndFlush(new PongWebSocketFrame(frame.content()));
} }
} }
@ -75,8 +69,11 @@ public class WebsocketHandler extends SimpleChannelInboundHandler<Object> {
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
ctx.close(); ctx.close();
var message = NetworkUtils.toFriendlyError(cause); fail(NetworkUtils.toFriendlyError(cause));
if (handshaker.isHandshakeComplete()) { }
private void fail(String message) {
if (handshakeComplete) {
websocket.close(-1, message); websocket.close(-1, message);
} else { } else {
websocket.failure(message); websocket.failure(message);

View File

@ -20,7 +20,7 @@ local methods = {
PATCH = true, TRACE = true, PATCH = true, TRACE = true,
} }
local function checkKey(options, key, ty, opt) local function check_key(options, key, ty, opt)
local value = options[key] local value = options[key]
local valueTy = type(value) local valueTy = type(value)
@ -29,23 +29,24 @@ local function checkKey(options, key, ty, opt)
end end
end end
local function checkOptions(options, body) local function check_request_options(options, body)
checkKey(options, "url", "string") check_key(options, "url", "string")
if body == false then if body == false then
checkKey(options, "body", "nil") check_key(options, "body", "nil")
else else
checkKey(options, "body", "string", not body) check_key(options, "body", "string", not body)
end end
checkKey(options, "headers", "table", true) check_key(options, "headers", "table", true)
checkKey(options, "method", "string", true) check_key(options, "method", "string", true)
checkKey(options, "redirect", "boolean", true) check_key(options, "redirect", "boolean", true)
check_key(options, "timeout", "number", true)
if options.method and not methods[options.method] then if options.method and not methods[options.method] then
error("Unsupported HTTP method", 3) error("Unsupported HTTP method", 3)
end end
end end
local function wrapRequest(_url, ...) local function wrap_request(_url, ...)
local ok, err = nativeHTTPRequest(...) local ok, err = nativeHTTPRequest(...)
if ok then if ok then
while true do while true do
@ -72,6 +73,7 @@ decoded.
@tparam[2] { @tparam[2] {
url = string, headers? = { [string] = string }, url = string, headers? = { [string] = string },
binary? = boolean, method? = string, redirect? = boolean, binary? = boolean, method? = string, redirect? = boolean,
timeout? = number,
} request Options for the request. See @{http.request} for details on how } request Options for the request. See @{http.request} for details on how
these options behave. these options behave.
@ -86,6 +88,7 @@ error or connection timeout.
@changed 1.80pr1 Added argument for binary handles. @changed 1.80pr1 Added argument for binary handles.
@changed 1.80pr1.6 Added support for table argument. @changed 1.80pr1.6 Added support for table argument.
@changed 1.86.0 Added PATCH and TRACE methods. @changed 1.86.0 Added PATCH and TRACE methods.
@changed 1.105.0 Added support for custom timeouts.
@usage Make a request to [example.tweaked.cc](https://example.tweaked.cc), @usage Make a request to [example.tweaked.cc](https://example.tweaked.cc),
and print the returned page. and print the returned page.
@ -99,14 +102,14 @@ request.close()
]] ]]
function get(_url, _headers, _binary) function get(_url, _headers, _binary)
if type(_url) == "table" then if type(_url) == "table" then
checkOptions(_url, false) check_request_options(_url, false)
return wrapRequest(_url.url, _url) return wrap_request(_url.url, _url)
end end
expect(1, _url, "string") expect(1, _url, "string")
expect(2, _headers, "table", "nil") expect(2, _headers, "table", "nil")
expect(3, _binary, "boolean", "nil") expect(3, _binary, "boolean", "nil")
return wrapRequest(_url, _url, nil, _headers, _binary) return wrap_request(_url, _url, nil, _headers, _binary)
end end
--[[- Make a HTTP POST request to the given url. --[[- Make a HTTP POST request to the given url.
@ -122,6 +125,7 @@ decoded.
@tparam[2] { @tparam[2] {
url = string, body? = string, headers? = { [string] = string }, url = string, body? = string, headers? = { [string] = string },
binary? = boolean, method? = string, redirect? = boolean, binary? = boolean, method? = string, redirect? = boolean,
timeout? = number,
} request Options for the request. See @{http.request} for details on how } request Options for the request. See @{http.request} for details on how
these options behave. these options behave.
@ -137,18 +141,19 @@ error or connection timeout.
@changed 1.80pr1 Added argument for binary handles. @changed 1.80pr1 Added argument for binary handles.
@changed 1.80pr1.6 Added support for table argument. @changed 1.80pr1.6 Added support for table argument.
@changed 1.86.0 Added PATCH and TRACE methods. @changed 1.86.0 Added PATCH and TRACE methods.
@changed 1.105.0 Added support for custom timeouts.
]] ]]
function post(_url, _post, _headers, _binary) function post(_url, _post, _headers, _binary)
if type(_url) == "table" then if type(_url) == "table" then
checkOptions(_url, true) check_request_options(_url, true)
return wrapRequest(_url.url, _url) return wrap_request(_url.url, _url)
end end
expect(1, _url, "string") expect(1, _url, "string")
expect(2, _post, "string") expect(2, _post, "string")
expect(3, _headers, "table", "nil") expect(3, _headers, "table", "nil")
expect(4, _binary, "boolean", "nil") expect(4, _binary, "boolean", "nil")
return wrapRequest(_url, _url, _post, _headers, _binary) return wrap_request(_url, _url, _post, _headers, _binary)
end end
--[[- Asynchronously make a HTTP request to the given url. --[[- Asynchronously make a HTTP request to the given url.
@ -168,6 +173,7 @@ decoded.
@tparam[2] { @tparam[2] {
url = string, body? = string, headers? = { [string] = string }, url = string, body? = string, headers? = { [string] = string },
binary? = boolean, method? = string, redirect? = boolean, binary? = boolean, method? = string, redirect? = boolean,
timeout? = number,
} request Options for the request. } request Options for the request.
This table form is an expanded version of the previous syntax. All arguments This table form is an expanded version of the previous syntax. All arguments
@ -178,6 +184,7 @@ from above are passed in as fields instead (for instance,
- `method`: Which HTTP method to use, for instance `"PATCH"` or `"DELETE"`. - `method`: Which HTTP method to use, for instance `"PATCH"` or `"DELETE"`.
- `redirect`: Whether to follow HTTP redirects. Defaults to true. - `redirect`: Whether to follow HTTP redirects. Defaults to true.
- `timeout`: The connection timeout, in seconds.
@see http.get For a synchronous way to make GET requests. @see http.get For a synchronous way to make GET requests.
@see http.post For a synchronous way to make POST requests. @see http.post For a synchronous way to make POST requests.
@ -186,11 +193,12 @@ from above are passed in as fields instead (for instance,
@changed 1.80pr1 Added argument for binary handles. @changed 1.80pr1 Added argument for binary handles.
@changed 1.80pr1.6 Added support for table argument. @changed 1.80pr1.6 Added support for table argument.
@changed 1.86.0 Added PATCH and TRACE methods. @changed 1.86.0 Added PATCH and TRACE methods.
@changed 1.105.0 Added support for custom timeouts.
]] ]]
function request(_url, _post, _headers, _binary) function request(_url, _post, _headers, _binary)
local url local url
if type(_url) == "table" then if type(_url) == "table" then
checkOptions(_url) check_request_options(_url)
url = _url.url url = _url.url
else else
expect(1, _url, "string") expect(1, _url, "string")
@ -263,26 +271,48 @@ end
local nativeWebsocket = native.websocket local nativeWebsocket = native.websocket
local function check_websocket_options(options, body)
check_key(options, "url", "string")
check_key(options, "headers", "table", true)
check_key(options, "timeout", "number", true)
end
--[[- Asynchronously open a websocket. --[[- Asynchronously open a websocket.
This returns immediately, a @{websocket_success} or @{websocket_failure} This returns immediately, a @{websocket_success} or @{websocket_failure}
will be queued once the request has completed. will be queued once the request has completed.
@tparam string url The websocket url to connect to. This should have the @tparam[1] string url The websocket url to connect to. This should have the
`ws://` or `wss://` protocol. `ws://` or `wss://` protocol.
@tparam[opt] { [string] = string } headers Additional headers to send as part @tparam[1, opt] { [string] = string } headers Additional headers to send as part
of the initial websocket connection. of the initial websocket connection.
@tparam[2] {
url = string, headers? = { [string] = string }, timeout ?= number,
} request Options for the websocket. See @{http.websocket} for details on how
these options behave.
@since 1.80pr1.3 @since 1.80pr1.3
@changed 1.95.3 Added User-Agent to default headers. @changed 1.95.3 Added User-Agent to default headers.
@changed 1.105.0 Added support for table argument and custom timeout.
@see websocket_success
@see websocket_failure
]] ]]
function websocketAsync(url, headers) function websocketAsync(url, headers)
local actual_url
if type(url) == "table" then
check_websocket_options(url)
actual_url = url.url
else
expect(1, url, "string") expect(1, url, "string")
expect(2, headers, "table", "nil") expect(2, headers, "table", "nil")
actual_url = url
end
local ok, err = nativeWebsocket(url, headers) local ok, err = nativeWebsocket(url, headers)
if not ok then if not ok then
os.queueEvent("websocket_failure", url, err) os.queueEvent("websocket_failure", actual_url, err)
end end
-- Return true/false for legacy reasons. Undocumented, as it shouldn't be relied on. -- Return true/false for legacy reasons. Undocumented, as it shouldn't be relied on.
@ -291,30 +321,59 @@ end
--[[- Open a websocket. --[[- Open a websocket.
@tparam string url The websocket url to connect to. This should have the @tparam[1] string url The websocket url to connect to. This should have the
`ws://` or `wss://` protocol. `ws://` or `wss://` protocol.
@tparam[opt] { [string] = string } headers Additional headers to send as part @tparam[1,opt] { [string] = string } headers Additional headers to send as part
of the initial websocket connection. of the initial websocket connection.
@tparam[2] {
url = string, headers? = { [string] = string }, timeout ?= number,
} request Options for the websocket.
This table form is an expanded version of the previous syntax. All arguments
from above are passed in as fields instead (for instance,
`http.websocket("https://example.com")` becomes `http.websocket { url =
"https://example.com" }`).
This table also accepts the following additional options:
- `timeout`: The connection timeout, in seconds.
@treturn Websocket The websocket connection. @treturn Websocket The websocket connection.
@treturn[2] false If the websocket connection failed. @treturn[2] false If the websocket connection failed.
@treturn string An error message describing why the connection failed. @treturn string An error message describing why the connection failed.
@since 1.80pr1.1 @since 1.80pr1.1
@changed 1.80pr1.3 No longer asynchronous. @changed 1.80pr1.3 No longer asynchronous.
@changed 1.95.3 Added User-Agent to default headers. @changed 1.95.3 Added User-Agent to default headers.
]] @changed 1.105.0 Added support for table argument and custom timeout.
function websocket(_url, _headers)
expect(1, _url, "string")
expect(2, _headers, "table", "nil")
local ok, err = nativeWebsocket(_url, _headers) @usage Connect to an echo websocket and send a message.
local ws = assert(http.websocket("wss://example.tweaked.cc/echo"))
ws.send("Hello!") -- Send a message
print(ws.receive()) -- And receive the reply
ws.close()
]]
function websocket(url, headers)
local actual_url
if type(url) == "table" then
check_websocket_options(url)
actual_url = url.url
else
expect(1, url, "string")
expect(2, headers, "table", "nil")
actual_url = url
end
local ok, err = nativeWebsocket(url, headers)
if not ok then return ok, err end if not ok then return ok, err end
while true do while true do
local event, url, param = os.pullEvent( ) local event, url, param = os.pullEvent( )
if event == "websocket_success" and url == _url then if event == "websocket_success" and url == actual_url then
return param return param
elseif event == "websocket_failure" and url == _url then elseif event == "websocket_failure" and url == actual_url then
return false, param return false, param
end end
end end

View File

@ -4,6 +4,7 @@
package http package http
import dan200.computercraft.api.lua.ObjectArguments
import dan200.computercraft.core.CoreConfig import dan200.computercraft.core.CoreConfig
import dan200.computercraft.core.apis.HTTPAPI import dan200.computercraft.core.apis.HTTPAPI
import dan200.computercraft.core.apis.http.options.Action import dan200.computercraft.core.apis.http.options.Action
@ -45,7 +46,7 @@ class TestHttpApi {
LuaTaskRunner.runTest { LuaTaskRunner.runTest {
val httpApi = addApi(HTTPAPI(environment)) val httpApi = addApi(HTTPAPI(environment))
val result = httpApi.websocket(WS_ADDRESS, Optional.empty()) val result = httpApi.websocket(ObjectArguments(WS_ADDRESS))
assertArrayEquals(arrayOf(true), result, "Should have created websocket") assertArrayEquals(arrayOf(true), result, "Should have created websocket")
val event = pullEvent() val event = pullEvent()