diff --git a/.gitignore b/.gitignore index 2600dcb..01b48f3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ artifacts.dat docs.json tests/app + +integration-tests/websocket/app diff --git a/gren.json b/gren.json index 02c1df6..06e27d3 100644 --- a/gren.json +++ b/gren.json @@ -17,7 +17,9 @@ "HttpServer", "HttpServer.Response", "WebSocketServer", - "WebSocketServer.Connection" + "WebSocketServer.Connection", + "WebSocketClient", + "WebSocketClient.Connection" ], "gren-version": "0.6.0 <= v < 0.7.0", "dependencies": { diff --git a/integration-tests/run-tests.sh b/integration-tests/run-tests.sh index 6d30690..fac96dd 100755 --- a/integration-tests/run-tests.sh +++ b/integration-tests/run-tests.sh @@ -31,3 +31,8 @@ echo -e "Running websocket tests...\n\n" pushd websocket make test || exit 1 popd + +echo -e "Running websocket-client tests...\n\n" +pushd websocket-client +make test || exit 1 +popd diff --git a/integration-tests/websocket/.gitignore b/integration-tests/websocket/.gitignore index 066f3f8..3add3f5 100644 --- a/integration-tests/websocket/.gitignore +++ b/integration-tests/websocket/.gitignore @@ -1,5 +1,6 @@ .gren/ -app +server-app +client-tests-app node_modules/ /test-results/ /playwright-report/ diff --git a/integration-tests/websocket/Makefile b/integration-tests/websocket/Makefile index 2d45a2b..bbb714e 100644 --- a/integration-tests/websocket/Makefile +++ b/integration-tests/websocket/Makefile @@ -1,9 +1,13 @@ -app: Makefile gren.json src/Main.gren - gren make --optimize Main --output=app +server-app: Makefile gren.json src/Server.gren + gren make --optimize Server --output=server-app + +client-tests-app: Makefile gren.json src/ClientTests.gren + gren make --optimize ClientTests --output=client-tests-app .PHONY: test -test: app node_modules - npm test +test: server-app client-tests-app node_modules + node run.mjs + npx mocha --require test/fixtures.mjs node_modules: package.json package-lock.json npm ci @@ -12,4 +16,4 @@ node_modules: package.json package-lock.json clean: rm -rf .gren rm -rf node_modules - rm -f app + rm -f server-app client-tests-app diff --git a/integration-tests/websocket/gren_packages/gren_lang_core__7_0_0.pkg.gz b/integration-tests/websocket/gren_packages/gren_lang_core__7_0_0.pkg.gz index e3bb45b..5ef93cb 100644 Binary files a/integration-tests/websocket/gren_packages/gren_lang_core__7_0_0.pkg.gz and b/integration-tests/websocket/gren_packages/gren_lang_core__7_0_0.pkg.gz differ diff --git a/integration-tests/websocket/gren_packages/gren_lang_url__6_0_0.pkg.gz b/integration-tests/websocket/gren_packages/gren_lang_url__6_0_0.pkg.gz index 8f624b4..f6e28d8 100644 Binary files a/integration-tests/websocket/gren_packages/gren_lang_url__6_0_0.pkg.gz and b/integration-tests/websocket/gren_packages/gren_lang_url__6_0_0.pkg.gz differ diff --git a/integration-tests/websocket/run.mjs b/integration-tests/websocket/run.mjs new file mode 100644 index 0000000..a512631 --- /dev/null +++ b/integration-tests/websocket/run.mjs @@ -0,0 +1,45 @@ +import * as path from "node:path"; +import * as childProc from "node:child_process"; + +// Start the Gren WebSocket server, run the Gren client tests, then shut down. +// The mocha server tests are run separately by the Makefile (they start their +// own server instance via fixtures.mjs). + +function fork(name) { + return childProc.fork(path.resolve(import.meta.dirname, name), [], { + silent: true, + }); +} + +function waitForServerStart(proc, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Server did not start within 5000ms")); + }, timeoutMs); + + proc.stdout.on("data", (data) => { + if (data.toString().includes("WebSocket server started")) { + clearTimeout(timeout); + proc.stdout.resume(); + proc.stderr.resume(); + resolve(); + } + }); + + proc.stderr.resume(); + }); +} + +const server = fork("server-app"); +await waitForServerStart(server); + +const exitCode = await new Promise((resolve) => { + const client = fork("client-tests-app"); + client.stdout.on("data", (data) => process.stdout.write(data)); + client.stderr.on("data", (data) => process.stderr.write(data)); + client.on("exit", resolve); +}); + +server.kill(); + +process.exit(exitCode); diff --git a/integration-tests/websocket/src/ClientTests.gren b/integration-tests/websocket/src/ClientTests.gren new file mode 100644 index 0000000..830732e --- /dev/null +++ b/integration-tests/websocket/src/ClientTests.gren @@ -0,0 +1,452 @@ +module ClientTests exposing (main) + +import Array exposing (Array) +import Bytes exposing (Bytes) +import Init +import Node +import Node exposing (Environment, Program) +import Stream +import Stream.Log as Log +import Task exposing (Task) +import Time +import WebSocketClient +import WebSocketClient.Connection as WsConn + + +main : Program Model Msg +main = + Node.defineProgram + { init = init + , update = update + , subscriptions = \_ -> Sub.none + } + + + +-- MODEL + + +type alias Model = + { env : Environment + , exitCode : Int + } + + +type Msg + = TestsCompleted (Array TestResult) + | OutputDone Int + + + +-- INIT + + +init : Environment -> Init.Task { model : Model, command : Cmd Msg } +init env = + Init.await WebSocketClient.initialize <| + \permission -> + Node.startProgram + { model = { env = env, exitCode = 0 } + , command = + runAllTests permission + |> Task.perform TestsCompleted + } + + + +-- UPDATE + + +update : Msg -> Model -> { model : Model, command : Cmd Msg } +update msg model = + when msg is + TestsCompleted results -> + let + summary = + formatResults results + + passCount = + Array.foldl + (\r acc -> + if r.passed then + acc + 1 + + else + acc + ) + 0 + results + + exitCode = + if passCount == Array.length results then + 0 + + else + 1 + in + { model = { model | exitCode = exitCode } + , command = + Log.line model.env.stdout summary + |> Task.andThen (\_ -> Node.setExitCode exitCode) + |> Task.perform (\_ -> OutputDone exitCode) + } + + OutputDone code -> + { model = model + , command = Node.exitWithCode code + } + + + +-- TEST RUNNER + + +type alias TestResult = + { name : String + , passed : Bool + , detail : String + } + + +runAllTests : WebSocketClient.Permission -> Task Never (Array TestResult) +runAllTests permission = + [ test "connects and receives welcome" (testWelcome permission) + , test "echoes a text message" (testEchoText permission) + , test "echoes an empty text message" (testEchoEmpty permission) + , test "echoes unicode text" (testEchoUnicode permission) + , test "echoes multiple messages in order" (testMultipleMessages permission) + , test "echoes a binary message" (testEchoBinary permission) + , test "detects server-initiated close" (testServerClose permission) + , test "writes complete quickly with no server delay" (testFastWrites permission) + , test "writes are delayed by server backpressure" (testBackpressure permission) + ] + |> Task.sequence + + +test : String -> Task WsError Bool -> Task Never TestResult +test name task_ = + task_ + |> Task.map + (\passed -> + { name = name + , passed = passed + , detail = + if passed then + "" + + else + "Assertion failed" + } + ) + |> Task.onError + (\err -> + Task.succeed + { name = name + , passed = False + , detail = "Error: " ++ errorToString err + } + ) + + + +-- TESTS + + +testWelcome : WebSocketClient.Permission -> Task WsError Bool +testWelcome permission = + connectAndReceiveWelcome permission + |> Task.map (\conn -> True) + + +testEchoText : WebSocketClient.Permission -> Task WsError Bool +testEchoText permission = + connectAndReceiveWelcome permission + |> Task.andThen (\conn -> sendAndReadEcho conn "hello") + |> Task.map (\echo -> echo == "echo:hello") + + +testEchoEmpty : WebSocketClient.Permission -> Task WsError Bool +testEchoEmpty permission = + connectAndReceiveWelcome permission + |> Task.andThen (\conn -> sendAndReadEcho conn "") + |> Task.map (\echo -> echo == "echo:") + + +testEchoUnicode : WebSocketClient.Permission -> Task WsError Bool +testEchoUnicode permission = + connectAndReceiveWelcome permission + |> Task.andThen (\conn -> sendAndReadEcho conn "snow \u{2744} flake") + |> Task.map (\echo -> echo == "echo:snow \u{2744} flake") + + +testMultipleMessages : WebSocketClient.Permission -> Task WsError Bool +testMultipleMessages permission = + connectAndReceiveWelcome permission + |> Task.andThen + (\conn -> + sendText conn "first" + |> Task.andThen (\_ -> sendText conn "second") + |> Task.andThen (\_ -> sendText conn "third") + |> Task.andThen (\_ -> collectAndCheckEchoes conn) + ) + + +collectAndCheckEchoes : WebSocketClient.Connection -> Task WsError Bool +collectAndCheckEchoes conn = + readText conn + |> Task.andThen (\e1 -> + readText conn + |> Task.andThen (\e2 -> + readText conn + |> Task.map (\e3 -> + e1 == "echo:first" && e2 == "echo:second" && e3 == "echo:third" + ) + ) + ) + + +testEchoBinary : WebSocketClient.Permission -> Task WsError Bool +testEchoBinary permission = + connectAndReceiveWelcome permission + |> Task.andThen + (\conn -> + WsConn.writable conn + |> Stream.write (WebSocketClient.BinaryMessage (Bytes.fromString "binary-data")) + |> Task.mapError StreamErr + |> Task.andThen (\_ -> + Stream.read (WsConn.readable conn) + |> Task.mapError StreamErr + ) + ) + |> Task.map + (\msg -> + when msg is + WebSocketClient.BinaryMessage bytes -> + Bytes.toString bytes == Just "binary-data" + + _ -> + False + ) + + +testServerClose : WebSocketClient.Permission -> Task WsError Bool +testServerClose permission = + connectAndReceiveWelcome permission + |> Task.andThen + (\conn -> + sendText conn "please-close" + |> Task.andThen + (\_ -> + Stream.read (WsConn.readable conn) + |> Task.mapError StreamErr + |> Task.map (\_ -> False) + |> Task.onError + (\wsErr -> + when wsErr is + StreamErr Stream.Closed -> + Task.succeed True + + _ -> + Task.fail wsErr + ) + ) + ) + + +testFastWrites : WebSocketClient.Permission -> Task WsError Bool +testFastWrites permission = + connectAndReceiveWelcome permission + |> Task.andThen (\conn -> setDelay conn 0) + |> Task.andThen (\conn -> timeAction (writeLargeMessages conn 10 102400)) + |> Task.map (\elapsed -> elapsed < 5000) + + +testBackpressure : WebSocketClient.Permission -> Task WsError Bool +testBackpressure permission = + connectAndReceiveWelcome permission + |> Task.andThen (\conn -> setDelay conn 200) + |> Task.andThen (\conn -> timeAction (writeLargeMessages conn 20 102400)) + |> Task.andThen + (\delayedElapsed -> + connectAndReceiveWelcome permission + |> Task.andThen (\conn -> setDelay conn 0) + |> Task.andThen (\conn -> timeAction (writeLargeMessages conn 20 102400)) + |> Task.map (\fastElapsed -> + delayedElapsed > fastElapsed + 1000 + ) + ) + + + +-- ERROR TYPES + + +type WsError + = ConnectErr WebSocketClient.ConnectError + | StreamErr Stream.Error + + +errorToString : WsError -> String +errorToString err = + when err is + ConnectErr (WebSocketClient.ConnectError { code, message }) -> + "ConnectError " ++ code ++ ": " ++ message + + StreamErr streamErr -> + when streamErr is + Stream.Closed -> + "Stream.Closed" + + Stream.Cancelled reason -> + "Stream.Cancelled: " ++ reason + + Stream.Locked -> + "Stream.Locked" + + + +-- HELPERS + + +connectAndReceiveWelcome : WebSocketClient.Permission -> Task WsError WebSocketClient.Connection +connectAndReceiveWelcome permission = + WebSocketClient.connect permission { url = url, options = [] } + |> Task.mapError ConnectErr + |> Task.andThen (\conn -> + Stream.read (WsConn.readable conn) + |> Task.mapError StreamErr + |> Task.map (\_ -> conn) + ) + + +sendText : WebSocketClient.Connection -> String -> Task WsError {} +sendText conn text = + WsConn.writable conn + |> Stream.write (WebSocketClient.TextMessage text) + |> Task.mapError StreamErr + |> Task.map (\_ -> {}) + + +readText : WebSocketClient.Connection -> Task WsError String +readText conn = + Stream.read (WsConn.readable conn) + |> Task.mapError StreamErr + |> Task.map + (\msg -> + when msg is + WebSocketClient.TextMessage text -> + text + + _ -> + "" + ) + + +sendAndReadEcho : WebSocketClient.Connection -> String -> Task WsError String +sendAndReadEcho conn text = + sendText conn text + |> Task.andThen (\_ -> readText conn) + + +setDelay : WebSocketClient.Connection -> Int -> Task WsError WebSocketClient.Connection +setDelay conn ms = + sendText conn ("delay:" ++ String.fromInt ms) + |> Task.andThen (\_ -> readText conn) + |> Task.map (\_ -> conn) + + +writeLargeMessages : WebSocketClient.Connection -> Int -> Int -> Task WsError {} +writeLargeMessages conn count size = + let + msg = + WebSocketClient.TextMessage (String.repeat size "x") + in + writeLoop conn msg count + + +writeLoop : WebSocketClient.Connection -> WebSocketClient.Message -> Int -> Task WsError {} +writeLoop conn msg remaining = + if remaining <= 0 then + Task.succeed {} + + else + WsConn.writable conn + |> Stream.write msg + |> Task.mapError StreamErr + |> Task.andThen (\_ -> writeLoop conn msg (remaining - 1)) + + +timeAction : Task WsError a -> Task WsError Int +timeAction action = + Time.now + |> Task.andThen (\start -> + action + |> Task.andThen (\_ -> + Time.now + |> Task.map (\end_ -> + Time.posixToMillis end_ - Time.posixToMillis start + ) + ) + ) + + + +-- OUTPUT + + +formatResults : Array TestResult -> String +formatResults results = + let + total = + Array.length results + + formatLine r = + (if r.passed then + "PASS" + + else + "FAIL" + ) + ++ " " + ++ r.name + ++ (if r.detail == "" then + "" + + else + " - " ++ r.detail + ) + + lines = + Array.foldl + (\r acc -> + if acc == "" then + formatLine r + + else + acc ++ "\n" ++ formatLine r + ) + "" + results + + passCount = + Array.foldl + (\r acc -> + if r.passed then + acc + 1 + + else + acc + ) + 0 + results + in + "\n" ++ lines ++ "\n\n" ++ String.fromInt passCount ++ "/" ++ String.fromInt total ++ " passed" + + + +-- CONSTANTS + + +url : String +url = + "ws://127.0.0.1:8085" diff --git a/integration-tests/websocket/src/Main.gren b/integration-tests/websocket/src/Server.gren similarity index 60% rename from integration-tests/websocket/src/Main.gren rename to integration-tests/websocket/src/Server.gren index 0b9dfca..c3d1565 100644 --- a/integration-tests/websocket/src/Main.gren +++ b/integration-tests/websocket/src/Server.gren @@ -1,10 +1,11 @@ -module Main exposing (main) +module Server exposing (main) import Node exposing (Environment, Program) import Init import Bytes exposing (Bytes) import Dict exposing (Dict) import Stream +import Process import Stream.Log import WebSocketServer import WebSocketServer.Connection as WsConn @@ -25,6 +26,7 @@ type alias Model = , stderr : Stream.Writable Bytes , server : Maybe WebSocketServer.Server , connections : Dict Int WebSocketServer.Connection + , delays : Dict Int Int } @@ -35,6 +37,7 @@ type Msg { connId : Int , result : Result Stream.Error WebSocketServer.Message } + | PerformRead Int | ClientDisconnected { connection : WebSocketServer.Connection, reason : WebSocketServer.CloseReason } | SendResult (Result WsConn.Error {}) @@ -48,9 +51,10 @@ init env = , stderr = env.stderr , server = Nothing , connections = Dict.empty + , delays = Dict.empty } , command = - WebSocketServer.createServer wsPermission { host = "127.0.0.1", port_ = 8085 } + WebSocketServer.createServer wsPermission { host = "127.0.0.1", port_ = 8085, options = [ WebSocketServer.ReadBufferCapacity 2 ] } |> Task.attempt ServerCreated } @@ -97,9 +101,12 @@ update msg model = Ok message -> when Dict.get connId model.connections is Just connection -> - { model = model - , command = echoAndContinue connId connection message - } + let + delay = + Dict.get connId model.delays + |> Maybe.withDefault 0 + in + handleMessage connId connection message delay model Nothing -> { model = model @@ -123,6 +130,18 @@ update msg model = , command = Cmd.none } + PerformRead connId -> + when Dict.get connId model.connections is + Just connection -> + { model = model + , command = readFromStream connId (WsConn.readable connection) + } + + Nothing -> + { model = model + , command = Cmd.none + } + ClientDisconnected { connection } -> let connId = @@ -130,7 +149,8 @@ update msg model = in { model = { model - | connections = Dict.remove connId model.connections + | connections = + Dict.remove connId model.connections } , command = Cmd.none } @@ -162,28 +182,67 @@ readFromStream connId readable = (\result -> ReadResult { connId = connId, result = result }) -{-| Writes the response for a message, then schedules the next read. The echo is -sent via WebSocketServer.Connection.send and the next read is issued -concurrently; messages are still processed and echoed one at a time because only -one Stream.read is in flight per connection at a time. +{-| Handles an incoming message: either a control command (delay, please-close) +or an echo. For echoes, the next read is delayed by the connection's configured +delay so the server processes messages at a controlled rate. This slow reading +causes client-side backpressure when the client sends faster than the server +consumes. -} -echoAndContinue : Int -> WebSocketServer.Connection -> WebSocketServer.Message -> Cmd Msg -echoAndContinue connId connection message = +handleMessage : Int -> WebSocketServer.Connection -> WebSocketServer.Message -> Int -> Model -> { model : Model, command : Cmd Msg } +handleMessage connId connection message delay model = when message is WebSocketServer.TextMessage text -> - if text == "please-close" then - WsConn.close connection 1000 "server-initiated-close" - |> Task.attempt SendResult + if String.startsWith "delay:" text then + let + newDelay = + text + |> String.dropFirst 6 + |> String.toInt + |> Maybe.withDefault 0 + in + { model = { model | delays = Dict.set connId newDelay model.delays } + , command = + Cmd.batch + [ WsConn.send connection (WebSocketServer.TextMessage ("delay-set:" ++ String.fromInt newDelay)) + |> Task.attempt SendResult + , readFromStream connId (WsConn.readable connection) + ] + } + + else if text == "please-close" then + { model = model + , command = + WsConn.close connection 1000 "server-initiated-close" + |> Task.attempt SendResult + } + else + { model = model + , command = + Cmd.batch + [ WsConn.send connection (WebSocketServer.TextMessage ("echo:" ++ text)) + |> Task.attempt SendResult + , scheduleNextRead connId delay + ] + } + + WebSocketServer.BinaryMessage bytes -> + { model = model + , command = Cmd.batch - [ WsConn.send connection (WebSocketServer.TextMessage ("echo:" ++ text)) + [ WsConn.send connection (WebSocketServer.BinaryMessage bytes) |> Task.attempt SendResult - , readFromStream connId (WsConn.readable connection) + , scheduleNextRead connId delay ] + } - WebSocketServer.BinaryMessage bytes -> - Cmd.batch - [ WsConn.send connection (WebSocketServer.BinaryMessage bytes) - |> Task.attempt SendResult - , readFromStream connId (WsConn.readable connection) - ] + +scheduleNextRead : Int -> Int -> Cmd Msg +scheduleNextRead connId delay = + if delay > 0 then + Process.sleep (toFloat delay) + |> Task.attempt (\_ -> PerformRead connId) + + else + Task.succeed {} + |> Task.attempt (\_ -> PerformRead connId) diff --git a/integration-tests/websocket/test/fixtures.mjs b/integration-tests/websocket/test/fixtures.mjs index fa08f0c..153b1db 100644 --- a/integration-tests/websocket/test/fixtures.mjs +++ b/integration-tests/websocket/test/fixtures.mjs @@ -4,7 +4,7 @@ import * as childProc from "node:child_process"; let proc; export function mochaGlobalSetup() { - const appPath = path.resolve(import.meta.dirname, "../app"); + const appPath = path.resolve(import.meta.dirname, "../server-app"); proc = childProc.fork(appPath, [], { silent: true }); return new Promise((resolve, reject) => { diff --git a/src/Gren/Kernel/WebSocketClient.js b/src/Gren/Kernel/WebSocketClient.js new file mode 100644 index 0000000..8a42c4b --- /dev/null +++ b/src/Gren/Kernel/WebSocketClient.js @@ -0,0 +1,233 @@ +/* + +import Gren.Kernel.Scheduler exposing (binding, succeed, fail, rawSpawn) +import WebSocketClient exposing (ConnectError, TextMessage, BinaryMessage) +import WebSocketClient.Connection as WsConn exposing (Error) +import Platform exposing (sendToApp) + +*/ + +var _WebSocketClient_nextConnectionId = 0; + +var _WebSocketClient_connect = F2(function (url, readBufferCapacity) { + return __Scheduler_binding(function (callback) { + var WebSocket = require("ws"); + var client = new WebSocket(url); + + var opened = false; + var called = false; + + function safeCallback(val) { + if (!called) { + called = true; + callback(val); + } + } + + client.__grenStreamClosed = false; + + // Readable stream for incoming messages. The controller is retained on + // the client so event closures can enqueue messages and close/error the + // stream when the connection ends. Read using the Stream module. + // + // Backpressure: the stream uses a CountQueuingStrategy with a configurable + // highWaterMark (readBufferCapacity). When the queue fills up, the socket + // is paused so the remote peer stops sending. The pull callback resumes + // the socket when the consumer drains the queue below the capacity. + var messageStream = new ReadableStream( + { + start: function (controller) { + client.__grenStreamController = controller; + }, + pull: function () { + if (client._socket) { + client._socket.resume(); + } + }, + }, + new CountQueuingStrategy({ highWaterMark: readBufferCapacity }), + ); + + // Writable stream for outgoing messages. Writing a Message value to this + // stream sends it over the WebSocket. Backpressure is respected: a write + // resolves only once ws.send's callback fires. + var sendStream = new WritableStream({ + start: function (controller) { + client.__grenWritableController = controller; + }, + write: function (chunk) { + return new Promise(function (resolve, reject) { + try { + if (typeof chunk.a === "string") { + client.send(chunk.a, function (err) { + if (err) { + reject(err); + } else { + resolve(); + } + }); + } else { + var bytes = chunk.a; + var buf = Buffer.from( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + client.send(buf, function (err) { + if (err) { + reject(err); + } else { + resolve(); + } + }); + } + } catch (e) { + reject(e); + } + }); + }, + }); + + var connection = { + __$id: _WebSocketClient_nextConnectionId++, + __$client: client, + __$readable: messageStream, + __$writable: sendStream, + __grenCloseHandlers: [], + }; + + client.on("open", function () { + opened = true; + safeCallback(__Scheduler_succeed(connection)); + }); + + client.on("message", function (data, isBinary) { + if (client.__grenStreamClosed) return; + + var msg = isBinary + ? __WebSocketClient_BinaryMessage( + new DataView(data.buffer, data.byteOffset, data.byteLength), + ) + : __WebSocketClient_TextMessage(data.toString()); + + client.__grenStreamController.enqueue(msg); + + // If the readable buffer is full, pause the socket so the remote + // peer stops sending. The pull callback above resumes it when the + // consumer drains the queue. + if (client.__grenStreamController.desiredSize <= 0) { + if (client._socket) { + client._socket.pause(); + } + } + }); + + client.on("close", function (code, reason) { + // Close/error the streams so active readers/writers observe the end. + if (!client.__grenStreamClosed) { + client.__grenStreamClosed = true; + try { + client.__grenStreamController.close(); + } catch (e) { + // Controller may already be closed or errored; safe to ignore. + } + try { + client.__grenWritableController.error("WebSocket connection closed"); + } catch (e) { + // Controller may already be closed or errored; safe to ignore. + } + } + + var handlers = connection.__grenCloseHandlers; + for (var i = 0; i < handlers.length; i++) { + __Scheduler_rawSpawn( + A2( + __Platform_sendToApp, + handlers[i].router, + handlers[i].handler({ + __$code: code, + __$reason: reason.toString(), + }), + ), + ); + } + }); + + client.on("error", function (err) { + if (!opened) { + // Connection attempt failed. + safeCallback( + __Scheduler_fail( + __WebSocketClient_ConnectError({ + __$code: err.code || "", + __$message: err.message || "", + }), + ), + ); + } else { + // Connection errored after opening. Error the streams so active + // readers/writers stop. The subsequent "close" event will notify + // onClose handlers. + if (!client.__grenStreamClosed) { + client.__grenStreamClosed = true; + try { + client.__grenStreamController.error(err.message); + } catch (e) { + // Controller may already be closed or errored; safe to ignore. + } + try { + client.__grenWritableController.error(err.message); + } catch (e) { + // Controller may already be closed or errored; safe to ignore. + } + } + } + }); + }); +}); + +// HANDLER MANAGEMENT + +var _WebSocketClient_clearHandlers = function (connection) { + connection.__grenCloseHandlers = []; +}; + +var _WebSocketClient_setCloseHandler = F3( + function (connection, router, handler) { + connection.__grenCloseHandlers.push({ router: router, handler: handler }); + }, +); + +// ACCESSORS + +var _WebSocketClient_getConnectionId = function (connection) { + return connection.__$id; +}; + +var _WebSocketClient_getReadable = function (connection) { + return connection.__$readable; +}; + +var _WebSocketClient_getWritable = function (connection) { + return connection.__$writable; +}; + +// CLOSING + +function _WebSocketClient_constructError(err) { + return __WsConn_Error({ + __$code: err.code || "", + __$message: err.message || "", + }); +} + +var _WebSocketClient_close = F3(function (connection, code, reason) { + return __Scheduler_binding(function (callback) { + try { + connection.__$client.close(code, reason); + callback(__Scheduler_succeed({})); + } catch (e) { + callback(__Scheduler_fail(_WebSocketClient_constructError(e))); + } + }); +}); diff --git a/src/Gren/Kernel/WebSocketServer.js b/src/Gren/Kernel/WebSocketServer.js index 6ecad25..2fbdf78 100644 --- a/src/Gren/Kernel/WebSocketServer.js +++ b/src/Gren/Kernel/WebSocketServer.js @@ -7,27 +7,30 @@ import Platform exposing (sendToApp) */ -var _WebSocketServer_createServer = F2(function (host, port) { - return __Scheduler_binding(function (callback) { - var WebSocket = require("ws"); - var server = new WebSocket.Server({ host: host, port: port }); - - server.on("error", function (e) { - callback( - __Scheduler_fail( - __WebSocketServer_ServerError({ - __$code: e.code || "UNKNOWN", - __$message: e.message, - }), - ), - ); - }); +var _WebSocketServer_createServer = F3( + function (host, port, readBufferCapacity) { + return __Scheduler_binding(function (callback) { + var WebSocket = require("ws"); + var server = new WebSocket.Server({ host: host, port: port }); + server.__grenReadBufferCapacity = readBufferCapacity; + + server.on("error", function (e) { + callback( + __Scheduler_fail( + __WebSocketServer_ServerError({ + __$code: e.code || "UNKNOWN", + __$message: e.message, + }), + ), + ); + }); - server.on("listening", function () { - callback(__Scheduler_succeed(server)); + server.on("listening", function () { + callback(__Scheduler_succeed(server)); + }); }); - }); -}); + }, +); var _WebSocketServer_nextConnectionId = 0; @@ -51,12 +54,26 @@ function _WebSocketServer_ensureListenersAttached(server) { // closures below can enqueue messages and close/error the stream when the // connection ends. The stream is exposed to the app via // WebSocketServer.Connection.readable, and read using the Stream module. + // + // Backpressure: the stream uses a CountQueuingStrategy with a configurable + // highWaterMark (readBufferCapacity). When the queue fills up, the socket + // is paused so the remote peer stops sending. The pull callback resumes + // the socket when the consumer drains the queue below the capacity. + var bufferCapacity = server.__grenReadBufferCapacity; client.__grenStreamClosed = false; - var messageStream = new ReadableStream({ - start: function (controller) { - client.__grenStreamController = controller; + var messageStream = new ReadableStream( + { + start: function (controller) { + client.__grenStreamController = controller; + }, + pull: function () { + if (client._socket) { + client._socket.resume(); + } + }, }, - }); + new CountQueuingStrategy({ highWaterMark: bufferCapacity }), + ); var connection = { __$id: connId, @@ -91,6 +108,15 @@ function _WebSocketServer_ensureListenersAttached(server) { : __WebSocketServer_TextMessage(data.toString()); client.__grenStreamController.enqueue(msg); + + // If the readable buffer is full, pause the socket so the remote + // peer stops sending. The pull callback above resumes it when the + // consumer drains the queue. + if (client.__grenStreamController.desiredSize <= 0) { + if (client._socket) { + client._socket.pause(); + } + } }); client.on("close", function (code, reason) { diff --git a/src/WebSocketClient.gren b/src/WebSocketClient.gren new file mode 100644 index 0000000..cb5c721 --- /dev/null +++ b/src/WebSocketClient.gren @@ -0,0 +1,245 @@ +effect module WebSocketClient where { subscription = ClientSubscription } exposing (CloseReason, ConnectError(..), Connection, Message(..), Option(..), Permission, connect, connectionId, initialize, onClose) + +{-| Connect to WebSocket servers and exchange messages. + +You write your client using The Gren Architecture by connecting to a server, +reading incoming messages via a [Readable](Stream#Readable) stream and sending +outgoing messages via a [Writable](Stream#Writable) stream from +[WebSocketClient.Connection](WebSocketClient-Connection). + +## Initialization + +@docs Permission, initialize, Option + +## Connecting + +@docs ConnectError, Connection, connectionId, connect + +## Message Types + +@docs Message, CloseReason + +## Events + +@docs onClose +-} + +import Array exposing (Array) +import Bytes exposing (Bytes) +import Gren.Kernel.WebSocketClient +import Init +import Internal.Init +import Task exposing (Task) + + + +-- INITIALIZATION + + +{-| The permission to create a WebSocket client [Connection](WebSocketClient-Connection). + +You get this from [`initialize`](WebSocketClient-Connection#initialize). +-} +type Permission + = Permission + + +{-| Initialize the [`WebSocketClient`](WebSocketClient) module and get permission to connect. +-} +initialize : Init.Task Permission +initialize = + Task.succeed Permission + |> Internal.Init.Task + + +{-| Error code and message from a failed connection attempt. +-} +type ConnectError + = ConnectError { code : String, message : String } + + +{-| Optional configuration for [`connect`](#connect). + +Pass an empty list to use defaults for everything: + + WebSocketClient.connect permission + { url = "ws://localhost:8080" + , options = [] + } + +-} +type Option + = ReadBufferCapacity Int + + +defaultReadBufferCapacity : Int +defaultReadBufferCapacity = + 16 + + +readBufferCapacity : Array Option -> Int +readBufferCapacity options = + options + |> Array.foldl + (\opt maybeCapacity -> + when maybeCapacity is + Just _ -> + maybeCapacity + + Nothing -> + when opt is + ReadBufferCapacity n -> + Just n + ) + Nothing + |> Maybe.withDefault defaultReadBufferCapacity + + + +-- CONNECTIONS + + +{-| An opaque handle representing a WebSocket client connection. + +Use [`WebSocketClient.Connection.readable`](WebSocketClient-Connection#readable) +to read incoming messages and +[`WebSocketClient.Connection.writable`](WebSocketClient-Connection#writable) +to send outgoing messages. +-} +type Connection + = -- Note: Actual implementation in Kernel code. Backed by a JS object with __$id, __$client, __$readable, and __$writable fields. + Connection + + +{-| Get a comparable identifier for a connection. + +Useful for storing connections in a `Dict Int Connection`. +-} +connectionId : Connection -> Int +connectionId conn = + Gren.Kernel.WebSocketClient.getConnectionId conn + + +{-| Task to connect to a WebSocket server. + + WebSocketClient.connect permission + { url = "ws://localhost:8080" + , options = [] + } + |> Task.attempt Connected + +Use [`Option`](#Option) values to customize behavior. For example, +[`ReadBufferCapacity`](#Option) controls how many unread messages are buffered +before backpressure is applied to the remote peer: + + WebSocketClient.connect permission + { url = "ws://localhost:8080" + , options = [ WebSocketClient.ReadBufferCapacity 4 ] + } + +-} +connect : Permission -> { url : String, options : Array Option } -> Task ConnectError Connection +connect _ options = + Gren.Kernel.WebSocketClient.connect options.url (readBufferCapacity options.options) + + + +-- MESSAGE TYPES + + +{-| A message sent or received over a WebSocket connection. +-} +type Message + = TextMessage String + | BinaryMessage Bytes + + +{-| The reason a WebSocket connection was closed. +-} +type alias CloseReason = + { code : Int + , reason : String + } + + + +-- SUBSCRIPTIONS + + +{-| Subscribe to connection close events. + +Provides the close code and reason. Close can also be observed as +`Stream.Closed` when reading from +[`WebSocketClient.Connection.readable`](WebSocketClient-Connection#readable), +but this subscription gives access to the close details. + + WebSocketClient.onClose connection Closed + +-} +onClose : Connection -> (CloseReason -> msg) -> Sub msg +onClose connection handler = + subscription (OnCloseSub { connection = connection, handler = handler }) + + + +-- EFFECT STUFF + + +type ClientSubscription msg + = OnCloseSub { connection : Connection, handler : CloseReason -> msg } + + +subMap : (a -> b) -> ClientSubscription a -> ClientSubscription b +subMap fn sub = + when sub is + OnCloseSub { connection, handler } -> + OnCloseSub + { connection = connection + , handler = handler >> fn + } + + +type alias State msg = + Array (ClientSubscription msg) + + +init : Task Never (State msg) +init = + Task.succeed [] + + +onEffects : + Platform.Router msg SelfMsg + -> Array (ClientSubscription msg) + -> State msg + -> Task Never (State msg) +onEffects router subs state = + let + _clearOldHandlers = + state + |> Array.map + (\sub -> + when sub is + OnCloseSub { connection } -> + Gren.Kernel.WebSocketClient.clearHandlers connection + ) + + _setNewHandlers = + subs + |> Array.map + (\sub -> + when sub is + OnCloseSub { connection, handler } -> + Gren.Kernel.WebSocketClient.setCloseHandler connection router handler + ) + in + Task.succeed subs + + +type SelfMsg + = Never + + +onSelfMsg : Platform.Router msg SelfMsg -> SelfMsg -> State msg -> Task Never (State msg) +onSelfMsg _ _ state = + Task.succeed state diff --git a/src/WebSocketClient/Connection.gren b/src/WebSocketClient/Connection.gren new file mode 100644 index 0000000..fc262c0 --- /dev/null +++ b/src/WebSocketClient/Connection.gren @@ -0,0 +1,167 @@ +module WebSocketClient.Connection exposing (Error, close, errorCode, errorIsBrokenPipe, errorIsConnectionNotOpen, errorIsConnectionReset, errorIsInvalidCloseCode, errorIsCloseReasonTooLong, errorToString, readable, writable) + +{-| Read from, write to, and close WebSocket client connections. + +These operations return Tasks that can fail if the connection is no longer +open or if a network error occurs. This lets your application detect and +handle failures explicitly. + +## Reading + +@docs readable + +## Writing + +@docs writable + +## Closing + +@docs close + +## Errors + +@docs Error, errorCode, errorToString, errorIsConnectionNotOpen, errorIsConnectionReset, errorIsBrokenPipe, errorIsInvalidCloseCode, errorIsCloseReasonTooLong +-} + +import Gren.Kernel.WebSocketClient +import Stream exposing (Readable, Writable) +import Task exposing (Task) +import WebSocketClient exposing (Connection, Message) + + + +-- READING + + +{-| The stream of incoming messages for a connection. + +Read messages from it using the [`Stream`](Stream) module, for example +[`Stream.read`](Stream#read) or +[`Stream.readUntilClosed`](Stream#readUntilClosed). The stream closes when the +connection closes, and errors if the connection errors (observed as +`Stream.Cancelled`). + + WebSocketClient.Connection.readable connection + |> Stream.read + |> Task.attempt ReadResult + +-} +readable : Connection -> Readable Message +readable connection = + Gren.Kernel.WebSocketClient.getReadable connection + + + +-- WRITING + + +{-| The stream for sending outgoing messages on a connection. + +Write messages to it using the [`Stream`](Stream) module, for example +[`Stream.write`](Stream#write). A write resolves once the data has been +flushed to the underlying socket. + + WebSocketClient.Connection.writable connection + |> Stream.write (WebSocketClient.TextMessage "Hello!") + |> Task.attempt WriteResult + +The stream errors (observed as `Stream.Cancelled`) if the connection is no +longer open when a write is attempted. + +-} +writable : Connection -> Writable Message +writable connection = + Gren.Kernel.WebSocketClient.getWritable connection + + + +-- CLOSING + + +{-| Close a connection with a status code and reason string. + + WebSocketClient.Connection.close connection 1000 "Normal closure" + |> Task.attempt ConnectionClosed + +Valid close codes are 1000-1003, 1007-1014, and 3000-4999. +The reason string must not exceed 123 bytes. +-} +close : Connection -> Int -> String -> Task Error {} +close connection code reason = + Gren.Kernel.WebSocketClient.close connection code reason + + + +-- ERRORS + + +{-| An error from a WebSocket connection operation. + +Use the `errorIs*` helper functions to check for specific error conditions, +or [`errorToString`](#errorToString) for a human-readable description. +-} +type Error + = Error { code : String, message : String } + + +{-| Get the error code, if one is available. + +Network-level errors from the operating system will have a code like +`"EPIPE"` or `"ECONNRESET"`. Errors originating from the WebSocket library +itself (such as sending on a closed connection) do not have error codes and +will return an empty string. +-} +errorCode : Error -> String +errorCode (Error { code }) = + code + + +{-| Get a human-readable description of the error. +-} +errorToString : Error -> String +errorToString (Error { message }) = + message + + +{-| If `True`, the operation failed because the WebSocket connection is not +in the open state. This is the most common error and typically occurs when +a message or close frame races with the connection closing. +-} +errorIsConnectionNotOpen : Error -> Bool +errorIsConnectionNotOpen (Error { message }) = + String.startsWith "WebSocket is not open" message + + +{-| If `True`, the connection was reset by the remote peer. +-} +errorIsConnectionReset : Error -> Bool +errorIsConnectionReset (Error { code }) = + code == "ECONNRESET" + + +{-| If `True`, the write failed because the connection has been closed. + +This is a system-level error that can occur when the underlying TCP socket +is closed while a write is in progress. +-} +errorIsBrokenPipe : Error -> Bool +errorIsBrokenPipe (Error { code }) = + code == "EPIPE" || code == "ERR_STREAM_DESTROYED" + + +{-| If `True`, [`close`](#close) was called with an invalid status code. + +Valid WebSocket close codes are: 1000-1003, 1007-1014, and 3000-4999. +Codes 1004, 1005, and 1006 are reserved and cannot be sent. +-} +errorIsInvalidCloseCode : Error -> Bool +errorIsInvalidCloseCode (Error { message }) = + String.startsWith "First argument must be a valid error code" message + + +{-| If `True`, [`close`](#close) was called with a reason string that exceeds +the WebSocket protocol limit of 123 bytes. +-} +errorIsCloseReasonTooLong : Error -> Bool +errorIsCloseReasonTooLong (Error { message }) = + String.startsWith "The message must not be greater than 123 bytes" message diff --git a/src/WebSocketServer.gren b/src/WebSocketServer.gren index e67fa23..044bc53 100644 --- a/src/WebSocketServer.gren +++ b/src/WebSocketServer.gren @@ -1,4 +1,4 @@ -effect module WebSocketServer where { subscription = WebSocketSubscription } exposing (CloseReason, Connection, Message(..), Permission, Server, ServerError(..), connectionId, createServer, initialize, onClose, onConnection) +effect module WebSocketServer where { subscription = WebSocketSubscription } exposing (CloseReason, Connection, Message(..), Option(..), Permission, Server, ServerError(..), connectionId, createServer, initialize, onClose, onConnection) {-| Create a WebSocket server that can accept connections and exchange messages. @@ -7,7 +7,7 @@ and message events and responding with commands via [WebSocketServer.Connection] ## Initialization -@docs Permission, Server, ServerError, initialize, createServer +@docs Permission, Server, ServerError, initialize, createServer, Option ## Connections @@ -22,6 +22,7 @@ and message events and responding with commands via [WebSocketServer.Connection] @docs Message, CloseReason -} +import Array exposing (Array) import Bytes exposing (Bytes) import Gren.Kernel.WebSocketServer import Init @@ -63,15 +64,67 @@ initialize = |> Internal.Init.Task +{-| Optional configuration for [`createServer`](#createServer). + +Pass an empty list to use defaults for everything: + + WebSocketServer.createServer permission + { host = "0.0.0.0" + , port_ = 8080 + , options = [] + } + +-} +type Option + = ReadBufferCapacity Int + + +defaultReadBufferCapacity : Int +defaultReadBufferCapacity = + 16 + + +readBufferCapacity : Array Option -> Int +readBufferCapacity options = + options + |> Array.foldl + (\opt maybeCapacity -> + when maybeCapacity is + Just _ -> + maybeCapacity + + Nothing -> + when opt is + ReadBufferCapacity n -> + Just n + ) + Nothing + |> Maybe.withDefault defaultReadBufferCapacity + + {-| Task to create a WebSocket server. - WebSocketServer.createServer permission { host = "0.0.0.0", port_ = 8080 } + WebSocketServer.createServer permission + { host = "0.0.0.0" + , port_ = 8080 + , options = [] + } |> Task.attempt ServerCreated +Use [`Option`](#Option) values to customize behavior. For example, +[`ReadBufferCapacity`](#Option) controls how many unread messages are buffered +per connection before backpressure is applied to the remote peer: + + WebSocketServer.createServer permission + { host = "0.0.0.0" + , port_ = 8080 + , options = [ WebSocketServer.ReadBufferCapacity 4 ] + } + -} -createServer : Permission -> { host : String, port_ : Int } -> Task ServerError Server +createServer : Permission -> { host : String, port_ : Int, options : Array Option } -> Task ServerError Server createServer _ options = - Gren.Kernel.WebSocketServer.createServer options.host options.port_ + Gren.Kernel.WebSocketServer.createServer options.host options.port_ (readBufferCapacity options.options)