From 89a16ce6a827cddb459ec2353e6f933d17a0d02f Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sun, 23 Mar 2025 19:53:04 -0400 Subject: [PATCH] Added base implementation copied from old handy-http --- .gitignore | 17 + dub.json | 13 + dub.selections.json | 8 + source/handy_http_websockets/components.d | 81 +++++ source/handy_http_websockets/connection.d | 89 ++++++ source/handy_http_websockets/frame.d | 361 ++++++++++++++++++++++ source/handy_http_websockets/handler.d | 84 +++++ source/handy_http_websockets/package.d | 5 + 8 files changed, 658 insertions(+) create mode 100644 .gitignore create mode 100644 dub.json create mode 100644 dub.selections.json create mode 100644 source/handy_http_websockets/components.d create mode 100644 source/handy_http_websockets/connection.d create mode 100644 source/handy_http_websockets/frame.d create mode 100644 source/handy_http_websockets/handler.d create mode 100644 source/handy_http_websockets/package.d diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb79722 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.dub +docs.json +__dummy.html +docs/ +/handy-http-websockets +handy-http-websockets.so +handy-http-websockets.dylib +handy-http-websockets.dll +handy-http-websockets.a +handy-http-websockets.lib +handy-http-websockets-test-* +*.exe +*.pdb +*.o +*.obj +*.lst +*.a diff --git a/dub.json b/dub.json new file mode 100644 index 0000000..9e24b6c --- /dev/null +++ b/dub.json @@ -0,0 +1,13 @@ +{ + "authors": [ + "Andrew Lalis" + ], + "copyright": "Copyright © 2025, Andrew Lalis", + "dependencies": { + "handy-http-primitives": "~>1.5", + "slf4d": "~>3" + }, + "description": "mplementati", + "license": "CC0", + "name": "handy-http-websockets" +} \ No newline at end of file diff --git a/dub.selections.json b/dub.selections.json new file mode 100644 index 0000000..67c9609 --- /dev/null +++ b/dub.selections.json @@ -0,0 +1,8 @@ +{ + "fileVersion": 1, + "versions": { + "handy-http-primitives": "1.5.0", + "slf4d": "3.0.1", + "streams": "3.6.0" + } +} diff --git a/source/handy_http_websockets/components.d b/source/handy_http_websockets/components.d new file mode 100644 index 0000000..b0a4d7e --- /dev/null +++ b/source/handy_http_websockets/components.d @@ -0,0 +1,81 @@ +module handy_http_websockets.components; + +import handy_http_websockets.connection; + +/** + * An exception that's thrown if an unexpected situation arises while dealing + * with a websocket connection. + */ +class WebSocketException : Exception { + import std.exception : basicExceptionCtors; + mixin basicExceptionCtors; +} + +/** + * A text-based websocket message. + */ +struct WebSocketTextMessage { + WebSocketConnection conn; + string payload; +} + +/** + * A binary websocket message. + */ +struct WebSocketBinaryMessage { + WebSocketConnection conn; + ubyte[] payload; +} + +/** + * A "close" control websocket message indicating the client is closing the + * connection. + */ +struct WebSocketCloseMessage { + WebSocketConnection conn; + ushort statusCode; + string message; +} + +/** + * An abstract class that you should extend to define logic for handling + * websocket messages and events. Create a new class that inherits from this + * one, and overrides any "on..." methods that you'd like. + */ +abstract class WebSocketMessageHandler { + /** + * Called when a new websocket connection is established. + * Params: + * conn = The new connection. + */ + void onConnectionEstablished(WebSocketConnection conn) {} + + /** + * Called when a text message is received. + * Params: + * msg = The message that was received. + */ + void onTextMessage(WebSocketTextMessage msg) {} + + /** + * Called when a binary message is received. + * Params: + * msg = The message that was received. + */ + void onBinaryMessage(WebSocketBinaryMessage msg) {} + + /** + * Called when a CLOSE message is received. Note that this is called before + * the socket is necessarily guaranteed to be closed. + * Params: + * msg = The close message. + */ + void onCloseMessage(WebSocketCloseMessage msg) {} + + /** + * Called when a websocket connection is closed. + * Params: + * conn = The connection that was closed. + */ + void onConnectionClosed(WebSocketConnection conn) {} +} diff --git a/source/handy_http_websockets/connection.d b/source/handy_http_websockets/connection.d new file mode 100644 index 0000000..9e0232d --- /dev/null +++ b/source/handy_http_websockets/connection.d @@ -0,0 +1,89 @@ +module handy_http_websockets.connection; + +import streams; +import slf4d; +import std.uuid; +import std.socket; + +import handy_http_websockets.components; +import handy_http_websockets.frame; + +/** + * All the data that represents a WebSocket connection tracked by the + * `WebSocketHandler`. + */ +class WebSocketConnection { + /** + * The internal id assigned to this connection. + */ + immutable UUID id; + + /// Stream for reading from the client. + private InputStream!ubyte inputStream; + + /// Stream for writing to the client. + private OutputStream!ubyte outputStream; + + /** + * The message handler that is called to handle this connection's events. + */ + private WebSocketMessageHandler messageHandler; + + this(WebSocketMessageHandler messageHandler, InputStream!ubyte inputStream, OutputStream!ubyte outputStream) { + this.messageHandler = messageHandler; + this.inputStream = inputStream; + this.outputStream = outputStream; + this.id = randomUUID(); + } + + WebSocketMessageHandler getMessageHandler() { + return this.messageHandler; + } + + /** + * Sends a text message to the connected client. + * Params: + * text = The text to send. Should be valid UTF-8. + */ + void sendTextMessage(string text) { + sendWebSocketTextFrame(outputStream, text); + } + + /** + * Sends a binary message to the connected client. + * Params: + * bytes = The binary data to send. + */ + void sendBinaryMessage(ubyte[] bytes) { + sendWebSocketBinaryFrame(outputStream, bytes); + } + + /** + * Sends a close message to the client, indicating that we'll be closing + * the connection. + * Params: + * status = The status code for closing. + * message = A message explaining why we're closing. Length must be <= 123. + */ + void sendCloseMessage(WebSocketCloseStatusCode status, string message) { + sendWebSocketCloseFrame(outputStream, status, message); + } + + /** + * Closes this connection, if it's alive, sending a websocket close message. + */ + void close() { + try { + this.sendCloseMessage(WebSocketCloseStatusCode.NORMAL, null); + } catch (WebSocketException e) { + warn("Failed to send a CLOSE message when closing connection " ~ this.id.toString(), e); + } + if (auto s = cast(ClosableStream) inputStream) { + s.closeStream(); + } + if (auto s = cast(ClosableStream) outputStream) { + s.closeStream(); + } + this.messageHandler.onConnectionClosed(this); + } +} \ No newline at end of file diff --git a/source/handy_http_websockets/frame.d b/source/handy_http_websockets/frame.d new file mode 100644 index 0000000..571dc5e --- /dev/null +++ b/source/handy_http_websockets/frame.d @@ -0,0 +1,361 @@ +/** + * Defines low-level structs and functions for dealing with WebSocket data + * frame protocol. Usually, you won't need to use these functions and structs + * directly, since abstractions are provided by the websocket connection and + * message structs. + */ +module handy_http_websockets.frame; + +import streams; +import slf4d; + +import handy_http_websockets.components : WebSocketException; + +/** + * An enumeration of valid opcodes for websocket data frames. + * https://datatracker.ietf.org/doc/html/rfc6455#section-5.2 + */ +enum WebSocketFrameOpcode : ubyte { + CONTINUATION = 0, + TEXT_FRAME = 1, + BINARY_FRAME = 2, + // 0x3-7 reserved for future non-control frames. + CONNECTION_CLOSE = 8, + PING = 9, + PONG = 10 + // 0xB-F are reserved for further control frames. +} + +/** + * An enumeration of possible closing status codes for websocket connections, + * as per https://datatracker.ietf.org/doc/html/rfc6455#section-7.4 + */ +enum WebSocketCloseStatusCode : ushort { + NORMAL = 1000, + GOING_AWAY = 1001, + PROTOCOL_ERROR = 1002, + UNACCEPTABLE_DATA = 1003, + NO_CODE = 1005, + CLOSED_ABNORMALLY = 1006, + INCONSISTENT_DATA = 1007, + POLICY_VIOLATION = 1008, + MESSAGE_TOO_BIG = 1009, + EXTENSION_NEGOTIATION_FAILURE = 1010, + UNEXPECTED_CONDITION = 1011, + TLS_HANDSHAKE_FAILURE = 1015 +} + +/** + * Internal intermediary structure used to hold the results of parsing a + * websocket frame. + */ +struct WebSocketFrame { + bool finalFragment; + WebSocketFrameOpcode opcode; + ubyte[] payload; +} + +void sendWebSocketTextFrame(S)(S stream, string text) if (isByteOutputStream!S) { + sendWebSocketFrame!S( + stream, + WebSocketFrame(true, WebSocketFrameOpcode.TEXT_FRAME, cast(ubyte[]) text) + ); +} + +void sendWebSocketBinaryFrame(S)(S stream, ubyte[] bytes) if (isByteOutputStream!S) { + sendWebSocketFrame!S( + stream, + WebSocketFrame(true, WebSocketFrameOpcode.BINARY_FRAME, bytes) + ); +} + +void sendWebSocketCloseFrame(S)(S stream, WebSocketCloseStatusCode code, string message) { + auto bufferOut = byteArrayOutputStream(); + auto dOut = dataOutputStreamFor(&bufferOut); + dOut.writeToStream!ushort(code); + if (message !is null && message.length > 0) { + if (message.length > 123) { + throw new WebSocketException("Close message is too long! Maximum of 123 bytes allowed."); + } + bufferOut.writeToStream(cast(ubyte[]) message); + } + sendWebSocketFrame!S( + stream, + WebSocketFrame(true, WebSocketFrameOpcode.CONNECTION_CLOSE, bufferOut.toArrayRaw()) + ); +} + +void sendWebSocketPingFrame(S)(S stream, ubyte[] payload) if (isByteOutputStream!S) { + sendWebSocketFrame!S( + stream, + WebSocketFrame(true, WebSocketFrameOpcode.PING, payload) + ); +} + +void sendWebSocketPongFrame(S)(S stream, ubyte[] pingPayload) if (isByteOutputStream!S) { + sendWebSocketFrame!S( + stream, + WebSocketFrame(true, WebSocketFrameOpcode.PONG, pingPayload) + ); +} + +/** + * Sends a websocket frame to a byte output stream. + * Params: + * stream = The stream to write to. + * frame = The frame to write. + */ +void sendWebSocketFrame(S)(S stream, WebSocketFrame frame) if (isByteOutputStream!S) { + static if (isPointerToStream!S) { + S ptr = stream; + } else { + S* ptr = &stream; + } + ubyte finAndOpcode = frame.opcode; + if (frame.finalFragment) { + finAndOpcode |= 128; + } + writeDataOrThrow(ptr, finAndOpcode); + if (frame.payload.length < 126) { + writeDataOrThrow(ptr, cast(ubyte) frame.payload.length); + } else if (frame.payload.length <= ushort.max) { + writeDataOrThrow(ptr, cast(ubyte) 126); + writeDataOrThrow(ptr, cast(ushort) frame.payload.length); + } else { + writeDataOrThrow(ptr, cast(ubyte) 127); + writeDataOrThrow(ptr, cast(ulong) frame.payload.length); + } + StreamResult result = stream.writeToStream(cast(ubyte[]) frame.payload); + if (result.hasError) { + throw new WebSocketException(cast(string) result.error.message); + } else if (result.count != frame.payload.length) { + import std.format : format; + throw new WebSocketException(format!"Wrote %d bytes instead of expected %d."( + result.count, frame.payload.length + )); + } +} + +/** + * Receives a websocket frame from a byte input stream. + * Params: + * stream = The stream to receive from. + * Returns: The frame that was received. + */ +WebSocketFrame receiveWebSocketFrame(S)(S stream) if (isByteInputStream!S) { + static if (isPointerToStream!S) { + S ptr = stream; + } else { + S* ptr = &stream; + } + auto finalAndOpcode = parseFinAndOpcode(ptr); + immutable bool finalFragment = finalAndOpcode.finalFragment; + immutable ubyte opcode = finalAndOpcode.opcode; + immutable bool isControlFrame = ( + opcode == WebSocketFrameOpcode.CONNECTION_CLOSE || + opcode == WebSocketFrameOpcode.PING || + opcode == WebSocketFrameOpcode.PONG + ); + + immutable ubyte maskAndLength = readDataOrThrow!(ubyte)(ptr); + immutable bool payloadMasked = (maskAndLength & 128) > 0; + immutable ubyte initialPayloadLength = maskAndLength & 127; + debugF!"Websocket data frame Mask bit = %s, Initial payload length = %d"(payloadMasked, initialPayloadLength); + size_t payloadLength = readPayloadLength(initialPayloadLength, ptr); + if (isControlFrame && payloadLength > 125) { + throw new WebSocketException("Control frame payload is too large."); + } + + ubyte[4] maskingKey; + if (payloadMasked) maskingKey = readDataOrThrow!(ubyte[4])(ptr); + debugF!"Receiving websocket frame: (FIN=%s,OP=%d,MASK=%s,LENGTH=%d)"( + finalFragment, + opcode, + payloadMasked, + payloadLength + ); + ubyte[] buffer = readPayload(payloadLength, ptr); + if (payloadMasked) unmaskData(buffer, maskingKey); + + return WebSocketFrame( + finalFragment, + cast(WebSocketFrameOpcode) opcode, + buffer + ); +} + +/** + * Parses the `finalFragment` flag and opcode from a websocket frame's first + * header byte. + * Params: + * stream = The stream to read a byte from. + */ +private auto parseFinAndOpcode(S)(S stream) if (isByteInputStream!S) { + immutable ubyte firstByte = readDataOrThrow!(ubyte)(stream); + immutable bool finalFragment = (firstByte & 128) > 0; + immutable bool reserved1 = (firstByte & 64) > 0; + immutable bool reserved2 = (firstByte & 32) > 0; + immutable bool reserved3 = (firstByte & 16) > 0; + immutable ubyte opcode = firstByte & 15; + if (reserved1 || reserved2 || reserved3) { + throw new WebSocketException("Reserved header bits are set."); + } + if (!validateOpcode(opcode)) { + import std.format : format; + throw new WebSocketException(format!"Invalid opcode: %d"(opcode)); + } + import std.typecons : tuple; + return tuple!("finalFragment", "opcode")(finalFragment, opcode); +} + +private bool validateOpcode(ubyte opcode) { + import std.traits : EnumMembers; + static foreach (member; EnumMembers!WebSocketFrameOpcode) { + if (opcode == member) return true; + } + return false; +} + +/** + * Reads the payload length of a websocket frame, given an initial 7-bit length + * value read from the second byte of the frame's header. This may throw a + * websocket exception if the length format is invalid. + * Params: + * initialLength = The initial 7-bit length value. + * stream = The stream to read from. + * Returns: The complete payload length. + */ +private size_t readPayloadLength(S)(ubyte initialLength, S stream) if (isByteInputStream!S) { + if (initialLength == 126) { + return cast(size_t) readDataOrThrow!(ushort)(stream); + } else if (initialLength == 127) { + return cast(size_t) readDataOrThrow!(ulong)(stream); + } + return cast(size_t) initialLength; +} + +/** + * Reads the payload of a websocket frame, or throws a websocket exception if + * the payload can't be read in its entirety. + * Params: + * payloadLength = The length of the payload. + * stream = The stream to read from. + * Returns: The payload data that was read. + */ +private ubyte[] readPayload(S)(size_t payloadLength, S stream) if (isByteInputStream!S) { + ubyte[] buffer = new ubyte[payloadLength]; + StreamResult readResult = stream.readFromStream(buffer); + if (readResult.hasError) { + throw new WebSocketException(cast(string) readResult.error.message); + } else if (readResult.count != payloadLength) { + import std.format : format; + throw new WebSocketException(format!"Read %d bytes instead of expected %d for message payload."( + readResult.count, payloadLength + )); + } + return buffer; +} + +/** + * Helper function to read data from a byte stream, or throw a websocket + * exception if reading fails for any reason. + * Params: + * stream = The stream to read from. + * Returns: The value that was read. + */ +private T readDataOrThrow(T, S)(S stream) if (isByteInputStream!S) { + auto dIn = dataInputStreamFor(stream, Endianness.BigEndian); + DataReadResult!T result = dIn.readFromStream!T(); + if (result.hasError) { + throw new WebSocketException(cast(string) result.error.message); + } + return result.value; +} + +private void writeDataOrThrow(T, S)(S stream, T data) if (isByteOutputStream!S) { + auto dOut = dataOutputStreamFor(stream, Endianness.BigEndian); + OptionalStreamError err = dOut.writeToStream(data); + if (err.present) { + throw new WebSocketException(cast(string) err.value.message); + } +} + +/** + * Applies a 4-byte mask to a websocket frame's payload bytes. + * Params: + * buffer = The buffer containing the payload. + * mask = The mask to apply. + */ +private void unmaskData(ubyte[] buffer, ubyte[4] mask) { + for (size_t i = 0; i < buffer.length; i++) { + buffer[i] = buffer[i] ^ mask[i % 4]; + } +} + +/** + * The following unit tests are derived from examples provided in + * https://datatracker.ietf.org/doc/html/rfc6455#section-5.7 + */ +unittest { + import slf4d; + import slf4d.default_provider; + import streams; + + // Note: Un-comment the below two lines to enable TRACE-level log messages. + // auto provider = new shared DefaultProvider(true, Levels.TRACE); + // configureLoggingProvider(provider); + + ubyte[] example1 = [0x81, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]; + WebSocketFrame frame1 = receiveWebSocketFrame(arrayInputStreamFor(example1)); + assert(frame1.finalFragment); + assert(frame1.opcode == WebSocketFrameOpcode.TEXT_FRAME); + assert(cast(string) frame1.payload == "Hello"); + + ubyte[] example2 = [0x81, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58]; + WebSocketFrame frame2 = receiveWebSocketFrame(arrayInputStreamFor(example2)); + assert(frame2.finalFragment); + assert(frame2.opcode == WebSocketFrameOpcode.TEXT_FRAME); + assert(cast(string) frame2.payload == "Hello"); + + ubyte[] example3 = [0x01, 0x03, 0x48, 0x65, 0x6c]; + WebSocketFrame frame3 = receiveWebSocketFrame(arrayInputStreamFor(example3)); + assert(!frame3.finalFragment); + assert(frame3.opcode == WebSocketFrameOpcode.TEXT_FRAME); + assert(cast(string) frame3.payload == "Hel"); + + ubyte[] example4 = [0x80, 0x02, 0x6c, 0x6f]; + WebSocketFrame frame4 = receiveWebSocketFrame(arrayInputStreamFor(example4)); + assert(frame4.finalFragment); + assert(frame4.opcode == WebSocketFrameOpcode.CONTINUATION); + assert(cast(string) frame4.payload == "lo"); + + ubyte[] pingExample = [0x89, 0x05, 0x48, 0x65, 0x6c, 0x6c, 0x6f]; + WebSocketFrame pingFrame = receiveWebSocketFrame(arrayInputStreamFor(pingExample)); + assert(pingFrame.finalFragment); + assert(pingFrame.opcode == WebSocketFrameOpcode.PING); + assert(cast(string) pingFrame.payload == "Hello"); + + ubyte[] pongExample = [0x8a, 0x85, 0x37, 0xfa, 0x21, 0x3d, 0x7f, 0x9f, 0x4d, 0x51, 0x58]; + WebSocketFrame pongFrame = receiveWebSocketFrame(arrayInputStreamFor(pongExample)); + assert(pongFrame.finalFragment); + assert(pongFrame.opcode == WebSocketFrameOpcode.PONG); + assert(cast(string) pongFrame.payload == "Hello"); + + ubyte[] binaryExample1 = new ubyte[256]; + // Populate the data with some expected values. + for (int i = 0; i < binaryExample1.length; i++) binaryExample1[i] = cast(ubyte) i % ubyte.max; + ubyte[] binaryExample1Full = cast(ubyte[]) [0x82, 0x7E, 0x01, 0x00] ~ binaryExample1; + WebSocketFrame binaryFrame1 = receiveWebSocketFrame(arrayInputStreamFor(binaryExample1Full)); + assert(binaryFrame1.finalFragment); + assert(binaryFrame1.opcode == WebSocketFrameOpcode.BINARY_FRAME); + assert(binaryFrame1.payload == binaryExample1); + + ubyte[] binaryExample2 = new ubyte[65_536]; + for (int i = 0; i < binaryExample2.length; i++) binaryExample2[i] = cast(ubyte) i % ubyte.max; + ubyte[] binaryExample2Full = cast(ubyte[]) [0x82, 0x7F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00] ~ + binaryExample2; + WebSocketFrame binaryFrame2 = receiveWebSocketFrame(arrayInputStreamFor(binaryExample2Full)); + assert(binaryFrame2.finalFragment); + assert(binaryFrame2.opcode == WebSocketFrameOpcode.BINARY_FRAME); + assert(binaryFrame2.payload == binaryExample2); +} diff --git a/source/handy_http_websockets/handler.d b/source/handy_http_websockets/handler.d new file mode 100644 index 0000000..8a05e15 --- /dev/null +++ b/source/handy_http_websockets/handler.d @@ -0,0 +1,84 @@ +module handy_http_websockets.handler; + +import handy_http_primitives; +import slf4d; +import streams; + +import handy_http_websockets.components; + +/** + * An HTTP request handler implementation that's used as the entrypoint for + * clients that want to establish a websocket connection. It will verify the + * websocket request, and if successful, send back a SWITCHING_PROTOCOLS + * response, and register a new websocket connection. + */ +class WebSocketRequestHandler : HttpRequestHandler { + private WebSocketMessageHandler messageHandler; + + this(WebSocketMessageHandler messageHandler) { + this.messageHandler = messageHandler; + } + + void handle(ref ServerHttpRequest request, ref ServerHttpResponse response) { + auto verification = verifyWebSocketRequest(request); + if (verification == RequestVerificationResponse.INVALID_HTTP_METHOD) { + sendErrorResponse(response, HttpStatus.METHOD_NOT_ALLOWED, "Only GET requests are allowed."); + return; + } + if (verification == RequestVerificationResponse.MISSING_KEY) { + sendErrorResponse(response, HttpStatus.BAD_REQUEST, "Missing Sec-WebSocket-Key header."); + return; + } + sendSwitchingProtocolsResponse(request, response); + + } +} + +private enum RequestVerificationResponse { + INVALID_HTTP_METHOD, + MISSING_KEY, + VALID +} + +private RequestVerificationResponse verifyWebSocketRequest(in ServerHttpRequest r) { + if (r.method != HttpMethod.GET) { + return RequestVerificationResponse.INVALID_HTTP_METHOD; + } + if ("Sec-WebSocket-Key" !in r.headers || r.headers["Sec-WebSocket-Key"].length == 0) { + return RequestVerificationResponse.MISSING_KEY; + } + return RequestVerificationResponse.VALID; +} + +private void sendErrorResponse(ref ServerHttpResponse response, HttpStatus status, string msg) { + response.status = status; + ubyte[] data = cast(ubyte[]) msg; + import std.conv : to; + response.headers.add("Content-Type", "text/plain"); + response.headers.add("Content-Length", data.length.to!string); + auto result = response.outputStream.writeToStream(data); + if (result.hasError) { + StreamError e = result.error; + warnF!"Failed to send HTTP error response: %s"(e.message); + } +} + +private void sendSwitchingProtocolsResponse(in ServerHttpRequest request, ref ServerHttpResponse response) { + string key = request.headers["Sec-WebSocket-Key"][0]; + response.status = HttpStatus.SWITCHING_PROTOCOLS; + response.headers.add("Upgrade", "websocket"); + response.headers.add("Connection", "Upgrade"); + response.headers.add("Sec-WebSocket-Accept", generateWebSocketAcceptHeader(key)); +} + +private string generateWebSocketAcceptHeader(string key) { + import std.digest.sha : sha1Of; + import std.base64; + ubyte[20] hash = sha1Of(key ~ "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"); + return Base64.encode(hash); +} + +unittest { + string result = generateWebSocketAcceptHeader("dGhlIHNhbXBsZSBub25jZQ=="); + assert(result == "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); +} diff --git a/source/handy_http_websockets/package.d b/source/handy_http_websockets/package.d new file mode 100644 index 0000000..38fdba6 --- /dev/null +++ b/source/handy_http_websockets/package.d @@ -0,0 +1,5 @@ +module handy_http_websockets; + +public import handy_http_websockets.components; +public import handy_http_websockets.connection; +public import handy_http_websockets.frame : WebSocketCloseStatusCode;