From 1c03c9103c03abe3b5fffb6f13bc611850957601 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Jan 2026 23:14:07 +0000 Subject: [PATCH] Add ReScript WebSocket client and server implementation Add type-safe WebSocket bindings for Deno's native WebSocket API with: - Client module for WebSocket connections with ready states, message handling - Server module with Deno.serve and upgradeWebSocket bindings - Helper functions for broadcasting to multiple connections - ReScript interface file for public API - Configuration files for Deno and ReScript build system --- .gitignore | 3 + README.adoc | 11 +++ deno.json | 13 +++ rescript.json | 22 +++++ src/WebSocket.res | 219 +++++++++++++++++++++++++++++++++++++++++++++ src/WebSocket.resi | 75 ++++++++++++++++ 6 files changed, 343 insertions(+) create mode 100644 deno.json create mode 100644 rescript.json create mode 100644 src/WebSocket.res create mode 100644 src/WebSocket.resi diff --git a/.gitignore b/.gitignore index 0338461..cbdd3e6 100644 --- a/.gitignore +++ b/.gitignore @@ -38,8 +38,11 @@ erl_crash.dump /Manifest.toml # ReScript +lib/ /lib/bs/ /.bsb.lock +*.res.js +.merlin # Python (SaltStack only) __pycache__/ diff --git a/README.adoc b/README.adoc index 8b13789..3bbfa2b 100644 --- a/README.adoc +++ b/README.adoc @@ -1 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 Hyperpolymath += rescript-websocket + +**Type-safe WebSocket client and server for ReScript using Deno's native WebSocket API.** + +Part of the https://github.com/hyperpolymath/rescript-full-stack[ReScript Full Stack] ecosystem. + +== Licence + +AGPL-3.0-or-later diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..89eff40 --- /dev/null +++ b/deno.json @@ -0,0 +1,13 @@ +{ + "name": "@hyperpolymath/rescript-websocket", + "version": "0.1.0", + "exports": "./src/WebSocket.res.js", + "tasks": { + "build": "rescript build", + "clean": "rescript clean", + "dev": "rescript build -w" + }, + "compilerOptions": { + "lib": ["deno.ns", "deno.unstable"] + } +} diff --git a/rescript.json b/rescript.json new file mode 100644 index 0000000..0100844 --- /dev/null +++ b/rescript.json @@ -0,0 +1,22 @@ +{ + "name": "@hyperpolymath/rescript-websocket", + "sources": [ + { + "dir": "src", + "subdirs": true + } + ], + "package-specs": [ + { + "module": "esmodule", + "in-source": true + } + ], + "suffix": ".res.js", + "bs-dependencies": [ + "@rescript/core" + ], + "bsc-flags": [ + "-open RescriptCore" + ] +} diff --git a/src/WebSocket.res b/src/WebSocket.res new file mode 100644 index 0000000..9edf5f1 --- /dev/null +++ b/src/WebSocket.res @@ -0,0 +1,219 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 Hyperpolymath + +@@uncurried + +/** + * Type-safe WebSocket client and server for ReScript. + * Works with Deno's native WebSocket API. + */ + +module Client = { + /** WebSocket ready states */ + type readyState = + | @as(0) Connecting + | @as(1) Open + | @as(2) Closing + | @as(3) Closed + + /** A WebSocket connection */ + type t + + /** WebSocket close event */ + type closeEvent = { + code: int, + reason: string, + wasClean: bool, + } + + /** WebSocket message event */ + type messageEvent = {data: string} + + /** WebSocket error event */ + type errorEvent = {message: string} + + /** Create a new WebSocket connection */ + @new + external make: string => t = "WebSocket" + + /** Create a WebSocket with subprotocols */ + @new + external makeWithProtocols: (string, array) => t = "WebSocket" + + /** Get the ready state */ + @get + external readyState: t => readyState = "readyState" + + /** Get the URL */ + @get + external url: t => string = "url" + + /** Get the selected protocol */ + @get + external protocol: t => string = "protocol" + + /** Get buffered amount */ + @get + external bufferedAmount: t => int = "bufferedAmount" + + /** Send a text message */ + @send + external send: (t, string) => unit = "send" + + /** Send binary data */ + @send + external sendArrayBuffer: (t, ArrayBuffer.t) => unit = "send" + + /** Close the connection */ + @send + external close: t => unit = "close" + + /** Close with code and reason */ + @send + external closeWithCode: (t, int, string) => unit = "close" + + /** Set onopen handler */ + @set + external onOpen: (t, unit => unit) => unit = "onopen" + + /** Set onclose handler */ + @set + external onClose: (t, closeEvent => unit) => unit = "onclose" + + /** Set onmessage handler */ + @set + external onMessage: (t, messageEvent => unit) => unit = "onmessage" + + /** Set onerror handler */ + @set + external onError: (t, errorEvent => unit) => unit = "onerror" + + /** Check if the connection is open */ + let isOpen = (ws: t): bool => { + readyState(ws) == Open + } + + /** Check if the connection is connecting */ + let isConnecting = (ws: t): bool => { + readyState(ws) == Connecting + } + + /** Check if the connection is closed */ + let isClosed = (ws: t): bool => { + let state = readyState(ws) + state == Closed || state == Closing + } + + /** Send JSON data */ + let sendJson = (ws: t, data: JSON.t): unit => { + send(ws, JSON.stringify(data)) + } + + /** Create a promise that resolves when connected */ + let waitForOpen = (ws: t): promise => { + Promise.make((resolve, _reject) => { + if isOpen(ws) { + resolve() + } else { + onOpen(ws, () => resolve()) + } + }) + } +} + +module Server = { + /** WebSocket upgrade request info */ + type upgradeInfo = { + url: string, + headers: Dict.t, + } + + /** Server-side WebSocket connection */ + type socket = Client.t + + /** Deno HTTP server */ + type server + + /** Request object */ + type request + + /** Response init options */ + type responseInit = { + status?: int, + headers?: Dict.t, + } + + /** Get request URL */ + @get + external requestUrl: request => string = "url" + + /** Get request headers */ + @get + external requestHeaders: request => Fetch.Headers.t = "headers" + + /** Upgrade HTTP connection to WebSocket */ + @val @scope("Deno") + external upgradeWebSocket: request => {"socket": socket, "response": Fetch.Response.t} = + "upgradeWebSocket" + + /** Serve options */ + type serveOptions = { + port?: int, + hostname?: string, + onListen?: {"port": int, "hostname": string} => unit, + } + + /** Handler function type */ + type handler = request => promise + + /** Serve HTTP/WebSocket */ + @val @scope("Deno") + external serve: (serveOptions, handler) => server = "serve" + + /** Serve with just handler */ + @val @scope("Deno") + external serveHandler: handler => server = "serve" + + /** Shutdown server */ + @send + external shutdown: server => promise = "shutdown" + + /** Create a WebSocket handler */ + let makeHandler = ( + ~onConnect: socket => unit, + ~onMessage: (socket, string) => unit, + ~onClose: (socket, Client.closeEvent) => unit, + ~onError: (socket, Client.errorEvent) => unit=_ => (), + ): (request => option) => { + request => { + let url = requestUrl(request) + if url->String.includes("websocket") || url->String.endsWith("/ws") { + let upgrade = upgradeWebSocket(request) + let socket = upgrade["socket"] + + socket->Client.onOpen(() => onConnect(socket)) + socket->Client.onMessage(event => onMessage(socket, event.data)) + socket->Client.onClose(event => onClose(socket, event)) + socket->Client.onError(event => onError(socket, event)) + + Some(upgrade["response"]) + } else { + None + } + } + } +} + +/** Broadcast a message to multiple connections */ +let broadcast = (connections: array, message: string): unit => { + connections->Array.forEach(ws => { + if Client.isOpen(ws) { + ws->Client.send(message) + } + }) +} + +/** Broadcast JSON to multiple connections */ +let broadcastJson = (connections: array, data: JSON.t): unit => { + broadcast(connections, JSON.stringify(data)) +} diff --git a/src/WebSocket.resi b/src/WebSocket.resi new file mode 100644 index 0000000..e8e51b8 --- /dev/null +++ b/src/WebSocket.resi @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// SPDX-FileCopyrightText: 2025 Hyperpolymath + +/** + * Type-safe WebSocket client and server for ReScript. + */ + +module Client: { + type readyState = + | @as(0) Connecting + | @as(1) Open + | @as(2) Closing + | @as(3) Closed + + type t + + type closeEvent = { + code: int, + reason: string, + wasClean: bool, + } + + type messageEvent = {data: string} + type errorEvent = {message: string} + + let make: string => t + let makeWithProtocols: (string, array) => t + let readyState: t => readyState + let url: t => string + let protocol: t => string + let bufferedAmount: t => int + let send: (t, string) => unit + let sendArrayBuffer: (t, ArrayBuffer.t) => unit + let close: t => unit + let closeWithCode: (t, int, string) => unit + let onOpen: (t, unit => unit) => unit + let onClose: (t, closeEvent => unit) => unit + let onMessage: (t, messageEvent => unit) => unit + let onError: (t, errorEvent => unit) => unit + let isOpen: t => bool + let isConnecting: t => bool + let isClosed: t => bool + let sendJson: (t, JSON.t) => unit + let waitForOpen: t => promise +} + +module Server: { + type socket = Client.t + type server + type request + + type serveOptions = { + port?: int, + hostname?: string, + onListen?: {"port": int, "hostname": string} => unit, + } + + type handler = request => promise + + let upgradeWebSocket: request => {"socket": socket, "response": Fetch.Response.t} + let serve: (serveOptions, handler) => server + let serveHandler: handler => server + let shutdown: server => promise + let requestUrl: request => string + + let makeHandler: ( + ~onConnect: socket => unit, + ~onMessage: (socket, string) => unit, + ~onClose: (socket, Client.closeEvent) => unit, + ~onError: (socket, Client.errorEvent) => unit=?, + ) => (request => option) +} + +let broadcast: (array, string) => unit +let broadcastJson: (array, JSON.t) => unit