diff --git a/devbox.lock b/devbox.lock index 34a9fd5..06ab845 100644 --- a/devbox.lock +++ b/devbox.lock @@ -103,7 +103,7 @@ }, "nodejs@20": { "last_modified": "2026-04-23T13:07:47Z", - "plugin_version": "0.0.2", + "plugin_version": "0.0.4", "resolved": "github:NixOS/nixpkgs/01fbdeef22b76df85ea168fbfe1bfd9e63681b30#nodejs_20", "source": "devbox-search", "version": "20.20.2", diff --git a/gren.json b/gren.json index fa5732b..02c1df6 100644 --- a/gren.json +++ b/gren.json @@ -15,7 +15,9 @@ "FileSystem.Path", "HttpClient", "HttpServer", - "HttpServer.Response" + "HttpServer.Response", + "WebSocketServer", + "WebSocketServer.Connection" ], "gren-version": "0.6.0 <= v < 0.7.0", "dependencies": { diff --git a/integration-tests/child-process/gren.json b/integration-tests/child-process/gren.json index 9e4695e..9bf7c12 100644 --- a/integration-tests/child-process/gren.json +++ b/integration-tests/child-process/gren.json @@ -4,7 +4,7 @@ "source-directories": [ "src" ], - "gren-version": "0.6.2", + "gren-version": "0.6.5", "dependencies": { "direct": { "gren-lang/core": "7.0.0", diff --git a/integration-tests/http-client/gren.json b/integration-tests/http-client/gren.json index 5b73581..6117d40 100644 --- a/integration-tests/http-client/gren.json +++ b/integration-tests/http-client/gren.json @@ -4,7 +4,7 @@ "source-directories": [ "src" ], - "gren-version": "0.6.2", + "gren-version": "0.6.5", "dependencies": { "direct": { "blaix/gren-effectful-tests": "5.0.2", diff --git a/integration-tests/http-server/gren.json b/integration-tests/http-server/gren.json index 9e4695e..9bf7c12 100644 --- a/integration-tests/http-server/gren.json +++ b/integration-tests/http-server/gren.json @@ -4,7 +4,7 @@ "source-directories": [ "src" ], - "gren-version": "0.6.2", + "gren-version": "0.6.5", "dependencies": { "direct": { "gren-lang/core": "7.0.0", diff --git a/integration-tests/run-tests.sh b/integration-tests/run-tests.sh index 1ba97e0..c552428 100755 --- a/integration-tests/run-tests.sh +++ b/integration-tests/run-tests.sh @@ -21,3 +21,8 @@ echo -e "Running child-process tests...\n\n" pushd child-process make test || exit 1 popd + +echo -e "Running websocket tests...\n\n" +pushd websocket +make test || exit 1 +popd diff --git a/integration-tests/signals/gren.json b/integration-tests/signals/gren.json index 9e4695e..9bf7c12 100644 --- a/integration-tests/signals/gren.json +++ b/integration-tests/signals/gren.json @@ -4,7 +4,7 @@ "source-directories": [ "src" ], - "gren-version": "0.6.2", + "gren-version": "0.6.5", "dependencies": { "direct": { "gren-lang/core": "7.0.0", diff --git a/integration-tests/websocket/.gitignore b/integration-tests/websocket/.gitignore new file mode 100644 index 0000000..066f3f8 --- /dev/null +++ b/integration-tests/websocket/.gitignore @@ -0,0 +1,6 @@ +.gren/ +app +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ diff --git a/integration-tests/websocket/Makefile b/integration-tests/websocket/Makefile new file mode 100644 index 0000000..2d45a2b --- /dev/null +++ b/integration-tests/websocket/Makefile @@ -0,0 +1,15 @@ +app: Makefile gren.json src/Main.gren + gren make --optimize Main --output=app + +.PHONY: test +test: app node_modules + npm test + +node_modules: package.json package-lock.json + npm ci + +.PHONY: clean +clean: + rm -rf .gren + rm -rf node_modules + rm -f app diff --git a/integration-tests/websocket/gren.json b/integration-tests/websocket/gren.json new file mode 100644 index 0000000..9bf7c12 --- /dev/null +++ b/integration-tests/websocket/gren.json @@ -0,0 +1,17 @@ +{ + "type": "application", + "platform": "node", + "source-directories": [ + "src" + ], + "gren-version": "0.6.5", + "dependencies": { + "direct": { + "gren-lang/core": "7.0.0", + "gren-lang/node": "local:../.." + }, + "indirect": { + "gren-lang/url": "6.0.0" + } + } +} 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 new file mode 100644 index 0000000..e3bb45b Binary files /dev/null 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 new file mode 100644 index 0000000..8f624b4 Binary files /dev/null and b/integration-tests/websocket/gren_packages/gren_lang_url__6_0_0.pkg.gz differ diff --git a/integration-tests/websocket/package-lock.json b/integration-tests/websocket/package-lock.json new file mode 100644 index 0000000..f5f371d --- /dev/null +++ b/integration-tests/websocket/package-lock.json @@ -0,0 +1,875 @@ +{ + "name": "websocket", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "mocha": "^10.2.0", + "ws": "^8.0.0" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browser-stdout": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz", + "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==", + "license": "ISC" + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", + "integrity": "sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mocha": { + "version": "10.8.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.8.2.tgz", + "integrity": "sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==", + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.3", + "browser-stdout": "^1.3.1", + "chokidar": "^3.5.3", + "debug": "^4.3.5", + "diff": "^5.2.0", + "escape-string-regexp": "^4.0.0", + "find-up": "^5.0.0", + "glob": "^8.1.0", + "he": "^1.2.0", + "js-yaml": "^4.1.0", + "log-symbols": "^4.1.0", + "minimatch": "^5.1.6", + "ms": "^2.1.3", + "serialize-javascript": "^6.0.2", + "strip-json-comments": "^3.1.1", + "supports-color": "^8.1.1", + "workerpool": "^6.5.1", + "yargs": "^16.2.0", + "yargs-parser": "^20.2.9", + "yargs-unparser": "^2.0.0" + }, + "bin": { + "_mocha": "bin/_mocha", + "mocha": "bin/mocha.js" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "license": "BSD-3-Clause", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/workerpool": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-6.5.1.tgz", + "integrity": "sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==", + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-unparser": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/yargs-unparser/-/yargs-unparser-2.0.0.tgz", + "integrity": "sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==", + "license": "MIT", + "dependencies": { + "camelcase": "^6.0.0", + "decamelize": "^4.0.0", + "flat": "^5.0.2", + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/integration-tests/websocket/package.json b/integration-tests/websocket/package.json new file mode 100644 index 0000000..ca4d679 --- /dev/null +++ b/integration-tests/websocket/package.json @@ -0,0 +1,9 @@ +{ + "scripts": { + "test": "mocha --require test/fixtures.mjs" + }, + "dependencies": { + "mocha": "^10.2.0", + "ws": "^8.0.0" + } +} diff --git a/integration-tests/websocket/src/Main.gren b/integration-tests/websocket/src/Main.gren new file mode 100644 index 0000000..6da1cfc --- /dev/null +++ b/integration-tests/websocket/src/Main.gren @@ -0,0 +1,212 @@ +module Main exposing (main) + +import Node exposing (Environment, Program) +import Init +import Bytes exposing (Bytes) +import Dict exposing (Dict) +import Stream +import Stream.Log +import WebSocketServer +import WebSocketServer.Connection as WsConn +import Task exposing (Task) + + +main : Program Model Msg +main = + Node.defineProgram + { init = init + , update = update + , subscriptions = subscriptions + } + + +type alias ConnState = + { connection : WebSocketServer.Connection + , textWritable : Stream.Writable String + , binaryWritable : Stream.Writable Bytes + } + + +type alias Model = + { stdout : Stream.Writable Bytes + , stderr : Stream.Writable Bytes + , server : Maybe WebSocketServer.Server + , connections : Dict Int ConnState + } + + +type Msg + = ServerCreated (Result WebSocketServer.ServerError WebSocketServer.Server) + | ClientConnected + { connection : WebSocketServer.Connection + , readable : Stream.Readable WebSocketServer.Message + , textWritable : Stream.Writable String + , binaryWritable : Stream.Writable Bytes + } + | ReadResult + { connId : Int + , readable : Stream.Readable WebSocketServer.Message + , result : Result Stream.Error WebSocketServer.Message + } + | ClientDisconnected { connection : WebSocketServer.Connection, reason : WebSocketServer.CloseReason } + | SendResult (Result WsConn.Error {}) + + +init : Environment -> Init.Task { model : Model, command : Cmd Msg } +init env = + Init.await WebSocketServer.initialize <| \wsPermission -> + Node.startProgram + { model = + { stdout = env.stdout + , stderr = env.stderr + , server = Nothing + , connections = Dict.empty + } + , command = + WebSocketServer.createServer wsPermission { host = "127.0.0.1", port_ = 8085 } + |> Task.attempt ServerCreated + } + + +update : Msg -> Model -> { model : Model, command : Cmd Msg } +update msg model = + when msg is + ServerCreated result -> + when result is + Ok server -> + { model = { model | server = Just server } + , command = + Stream.Log.line model.stdout "WebSocket server started on port 8085" + |> Task.execute + } + + Err (WebSocketServer.ServerError { code, message }) -> + { model = model + , command = + Stream.Log.line model.stderr ("Server error: " ++ code ++ " " ++ message) + |> Task.execute + } + + ClientConnected { connection, readable, textWritable, binaryWritable } -> + let + connId = + WebSocketServer.connectionId connection + in + { model = + { model + | connections = + Dict.set connId + { connection = connection + , textWritable = textWritable + , binaryWritable = binaryWritable + } + model.connections + } + , command = + Cmd.batch + [ WsConn.send connection "welcome" + |> Task.attempt SendResult + , readFromStream connId readable + ] + } + + ReadResult { connId, readable, result } -> + when result is + Ok message -> + when Dict.get connId model.connections is + Just connState -> + { model = model + , command = echoAndContinue connState connId readable message + } + + Nothing -> + { model = model + , command = Cmd.none + } + + Err Stream.Closed -> + { model = model + , command = Cmd.none + } + + Err (Stream.Cancelled reason) -> + { model = model + , command = + Stream.Log.line model.stdout ("stream error: " ++ reason) + |> Task.execute + } + + Err Stream.Locked -> + { model = model + , command = Cmd.none + } + + ClientDisconnected { connection } -> + let + connId = + WebSocketServer.connectionId connection + in + { model = + { model + | connections = Dict.remove connId model.connections + } + , command = Cmd.none + } + + SendResult _ -> + { model = model + , command = Cmd.none + } + + +subscriptions : Model -> Sub Msg +subscriptions model = + when model.server is + Just server -> + Sub.batch + [ WebSocketServer.onConnection server + (\{ connection, readable, textWritable, binaryWritable } -> + ClientConnected + { connection = connection + , readable = readable + , textWritable = textWritable + , binaryWritable = binaryWritable + } + ) + , WebSocketServer.onClose server (\conn reason -> ClientDisconnected { connection = conn, reason = reason }) + ] + + Nothing -> + Sub.none + + +readFromStream : Int -> Stream.Readable WebSocketServer.Message -> Cmd Msg +readFromStream connId readable = + Stream.read readable + |> Task.attempt + (\result -> ReadResult { connId = connId, readable = readable, result = result }) + + +{-| Writes the response for a message, then reads the next message. Writes are +serialized (one in flight at a time) because the underlying WritableStream only +accepts one writer at a time: issuing concurrent Stream.write calls would race +on the writer lock and drop writes with Stream.Locked. +-} +echoAndContinue : ConnState -> Int -> Stream.Readable WebSocketServer.Message -> WebSocketServer.Message -> Cmd Msg +echoAndContinue connState connId readable message = + when message is + WebSocketServer.TextMessage text -> + if text == "please-close" then + WsConn.close connState.connection 1000 "server-initiated-close" + |> Task.attempt SendResult + else + Stream.write ("echo:" ++ text) connState.textWritable + |> Task.andThen (\_ -> Stream.read readable) + |> Task.attempt + (\result -> ReadResult { connId = connId, readable = readable, result = result }) + + WebSocketServer.BinaryMessage bytes -> + Stream.write bytes connState.binaryWritable + |> Task.andThen (\_ -> Stream.read readable) + |> Task.attempt + (\result -> ReadResult { connId = connId, readable = readable, result = result }) diff --git a/integration-tests/websocket/test/fixtures.mjs b/integration-tests/websocket/test/fixtures.mjs new file mode 100644 index 0000000..fa08f0c --- /dev/null +++ b/integration-tests/websocket/test/fixtures.mjs @@ -0,0 +1,35 @@ +import * as path from "node:path"; +import * as childProc from "node:child_process"; + +let proc; + +export function mochaGlobalSetup() { + const appPath = path.resolve(import.meta.dirname, "../app"); + proc = childProc.fork(appPath, [], { silent: true }); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Server did not start within 5000ms")); + }, 5000); + + proc.stdout.on("data", (data) => { + if (data.toString().includes("WebSocket server started")) { + clearTimeout(timeout); + // Keep draining stdout/stderr + proc.stdout.resume(); + proc.stderr.resume(); + resolve(); + } + }); + + proc.stderr.resume(); + }); +} + +export function mochaGlobalTeardown() { + proc.kill(); +} + +export function getAppProcess() { + return proc; +} diff --git a/integration-tests/websocket/test/requests.mjs b/integration-tests/websocket/test/requests.mjs new file mode 100644 index 0000000..1da6753 --- /dev/null +++ b/integration-tests/websocket/test/requests.mjs @@ -0,0 +1,319 @@ +import WebSocket from "ws"; +import * as assert from "node:assert"; +import { getAppProcess } from "./fixtures.mjs"; + +const url = "ws://127.0.0.1:8085"; + +// Buffer messages from the moment the WebSocket is created. +// The WebSocket library can emit "message" in the same event-loop tick as "open" +// (when the server response and the first data frame arrive in one TCP read), +// so any listener added after `await connect()` may miss early messages. +function connect() { + return new Promise((resolve, reject) => { + const client = new WebSocket(url); + const messageQueue = []; + const waitingForMessageQueue = []; + + client.on("message", (data, isBinary) => { + const entry = { data, isBinary }; + if (waitingForMessageQueue.length > 0) { + const resolve = waitingForMessageQueue.shift(); + resolve(entry); + } else { + messageQueue.push(entry); + } + }); + + client._takeMessage = function () { + return new Promise((resolve) => { + if (messageQueue.length > 0) { + resolve(messageQueue.shift()); + } else { + waitingForMessageQueue.push(resolve); + } + }); + }; + + client.on("open", () => resolve(client)); + client.on("error", reject); + }); +} + +async function waitForMessage(client) { + const { data } = await client._takeMessage(); + return data.toString(); +} + +async function waitForRawMessage(client) { + return client._takeMessage(); +} + +function waitForClose(client) { + return new Promise((resolve) => { + client.once("close", (code, reason) => { + resolve({ code, reason: reason.toString() }); + }); + }); +} + +function closeConnection(client) { + return new Promise((resolve) => { + client.on("close", () => resolve()); + client.close(); + }); +} + +// Wait for a line on the app's stdout that matches `predicate`. +function waitForStdoutLine(predicate, timeoutMs = 5000) { + const proc = getAppProcess(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + proc.stdout.off("data", onChunk); + reject(new Error("Timed out waiting for stdout line")); + }, timeoutMs); + + let buffer = ""; + const onChunk = (data) => { + buffer += data.toString(); + const lines = buffer.split("\n"); + buffer = lines.pop(); + for (const line of lines) { + if (predicate(line)) { + clearTimeout(timer); + proc.stdout.off("data", onChunk); + resolve(line); + return; + } + } + }; + proc.stdout.on("data", onChunk); + }); +} + +describe("WebSocket Server", function () { + this.timeout(10000); + it("sends welcome message on connection", async () => { + const client = await connect(); + const msg = await waitForMessage(client); + + assert.equal(msg, "welcome"); + + await closeConnection(client); + }); + + it("echoes text messages", async () => { + const client = await connect(); + + // Consume the welcome message + await waitForMessage(client); + + client.send("hello"); + const echo = await waitForMessage(client); + + assert.equal(echo, "echo:hello"); + + await closeConnection(client); + }); + + it("echoes multiple messages", async () => { + const client = await connect(); + + // Consume the welcome message + await waitForMessage(client); + + client.send("first"); + const echo1 = await waitForMessage(client); + assert.equal(echo1, "echo:first"); + + client.send("second"); + const echo2 = await waitForMessage(client); + assert.equal(echo2, "echo:second"); + + await closeConnection(client); + }); + + it("echoes binary messages", async () => { + const client = await connect(); + + // Consume the welcome message + await waitForMessage(client); + + const binaryData = Buffer.from([1, 2, 3, 4, 5]); + client.send(binaryData); + + const echoed = await waitForRawMessage(client); + + assert.ok(echoed.isBinary, "Expected binary message"); + assert.deepEqual(Buffer.from(echoed.data), binaryData); + + await closeConnection(client); + }); + + it("handles unicode text messages", async () => { + const client = await connect(); + + // Consume the welcome message + await waitForMessage(client); + + client.send("snow ❄ flake"); + const echo = await waitForMessage(client); + + assert.equal(echo, "echo:snow ❄ flake"); + + await closeConnection(client); + }); + + it("supports multiple concurrent connections", async () => { + const client1 = await connect(); + // Consume welcome for client1 before opening client2 + await waitForMessage(client1); + + const client2 = await connect(); + // Consume welcome for client2 + await waitForMessage(client2); + + client1.send("from-client-1"); + const echo1 = await waitForMessage(client1); + assert.equal(echo1, "echo:from-client-1"); + + client2.send("from-client-2"); + const echo2 = await waitForMessage(client2); + assert.equal(echo2, "echo:from-client-2"); + + await closeConnection(client1); + await closeConnection(client2); + }); + + it("client can close connection", async () => { + // This test verifies that the server handles client disconnection gracefully + const client = await connect(); + + // Consume the welcome message + await waitForMessage(client); + + const closePromise = waitForClose(client); + client.close(1000, "client closing"); + + const { code } = await closePromise; + assert.equal(code, 1000); + }); + + it("server initiates close when receiving please-close", async () => { + const client = await connect(); + + // Consume the welcome message + await waitForMessage(client); + + // Set up close listener before sending the trigger message + const closePromise = waitForClose(client); + + client.send("please-close"); + + const { code, reason } = await closePromise; + assert.equal(code, 1000); + assert.equal(reason, "server-initiated-close"); + }); + + it("echoes a large text message", async () => { + const client = await connect(); + + // Consume the welcome message + await waitForMessage(client); + + // Create a 100KB message + const largeMessage = "A".repeat(100 * 1024); + client.send(largeMessage); + const echo = await waitForMessage(client); + + assert.equal(echo, "echo:" + largeMessage); + + await closeConnection(client); + }); + + it("echoes rapid messages in order", async () => { + const client = await connect(); + + // Consume the welcome message + await waitForMessage(client); + + const messageCount = 50; + + // Send all messages rapidly without waiting for echoes + for (let i = 0; i < messageCount; i++) { + client.send("msg-" + i); + } + + // Collect all echoes via the message queue + const received = []; + for (let i = 0; i < messageCount; i++) { + received.push(await waitForMessage(client)); + } + + // Verify all messages were echoed in order + assert.equal(received.length, messageCount); + for (let i = 0; i < messageCount; i++) { + assert.equal(received[i], "echo:msg-" + i); + } + + await closeConnection(client); + }); + + it("accepts a new connection after a previous one was closed", async () => { + // First connection + const client1 = await connect(); + const welcome1 = await waitForMessage(client1); + assert.equal(welcome1, "welcome"); + + client1.send("first-connection"); + const echo1 = await waitForMessage(client1); + assert.equal(echo1, "echo:first-connection"); + + await closeConnection(client1); + + // Second connection after close + const client2 = await connect(); + const welcome2 = await waitForMessage(client2); + assert.equal(welcome2, "welcome"); + + client2.send("second-connection"); + const echo2 = await waitForMessage(client2); + assert.equal(echo2, "echo:second-connection"); + + await closeConnection(client2); + }); + + it("echoes an empty string message", async () => { + const client = await connect(); + + // Consume the welcome message + await waitForMessage(client); + + client.send(""); + const echo = await waitForMessage(client); + + assert.equal(echo, "echo:"); + + await closeConnection(client); + }); + + it("surfaces a connection error through the message stream", async () => { + const client = await connect(); + + // Consume the welcome message + await waitForMessage(client); + + // Send a malformed frame (FIN bit set + reserved opcode 3) directly over the + // underlying socket. The server's ws receiver rejects this as a protocol + // error and emits 'error' on the connection, which the kernel surfaces to + // readers as Stream.Cancelled . The app logs the reason to stdout. + client._socket.write(Buffer.from([0x83, 0x00])); + + const line = await waitForStdoutLine((l) => l.startsWith("stream error:")); + const reason = line.slice("stream error:".length).trim(); + + assert.ok(reason.length > 0, `Expected a non-empty error reason, got: ${line}`); + + // Allow the connection to tear down after the server closes it. + await new Promise((resolve) => setTimeout(resolve, 50)); + }); +}); diff --git a/src/Gren/Kernel/WebSocketServer.js b/src/Gren/Kernel/WebSocketServer.js new file mode 100644 index 0000000..44bf7e2 --- /dev/null +++ b/src/Gren/Kernel/WebSocketServer.js @@ -0,0 +1,264 @@ +/* + +import Gren.Kernel.Scheduler exposing (binding, succeed, fail, rawSpawn) +import WebSocketServer exposing (ServerError, TextMessage, BinaryMessage) +import WebSocketServer.Connection as WsConn exposing (Error) +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, + }), + ), + ); + }); + + server.on("listening", function () { + callback(__Scheduler_succeed(server)); + }); + }); +}); + +var _WebSocketServer_nextConnectionId = 0; + +// Initialize the handler storage on a server object and attach the server-level +// "connection" listener exactly once. Per-client handlers delegate to the current +// handler references stored on the server object, so when onEffects updates the +// handlers, existing long-lived connections automatically use the new handlers. +function _WebSocketServer_ensureListenersAttached(server) { + if (server.__grenListenersAttached) { + return; + } + server.__grenListenersAttached = true; + server.__grenConnectionHandlers = []; + server.__grenCloseHandlers = []; + + server.on("connection", function (client) { + var connId = _WebSocketServer_nextConnectionId++; + var connection = { __$id: connId, __$client: client }; + + // Store the Connection object on the client instance so that close/error + // handlers can retrieve it without a separate lookup map. + client.__grenConnection = connection; + + // Create a ReadableStream that surfaces incoming messages for this + // connection. The controller is retained on the client so the per-event + // closures below can enqueue messages and close/error the stream when the + // connection ends. The stream is passed to the app via the connection + // handler; the app reads messages from it using the Stream module. + client.__grenStreamClosed = false; + var messageStream = new ReadableStream({ + start: function (controller) { + client.__grenStreamController = controller; + }, + }); + + // Create two WritableStreams for sending data back to the client: one for + // text frames (String) and one for binary frames (Bytes). Each write calls + // ws#send with the appropriate JS type, and the send callback drives + // backpressure by resolving the write once the data is flushed. The sink + // controllers are retained so they can be errored when the connection ends. + client.__grenWritableControllers = []; + var textWritable = _WebSocketServer_makeWritable(client, function (chunk) { + return chunk; + }); + var binaryWritable = _WebSocketServer_makeWritable(client, function (chunk) { + return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); + }); + + // Notify the app of the new connection, if any handlers are registered. + var connHandlers = server.__grenConnectionHandlers; + for (var i = 0; i < connHandlers.length; i++) { + __Scheduler_rawSpawn( + A2( + __Platform_sendToApp, + connHandlers[i].router, + connHandlers[i].handler({ + __$connection: connection, + __$readable: messageStream, + __$textWritable: textWritable, + __$binaryWritable: binaryWritable, + }), + ), + ); + } + + // Feed incoming messages into the connection's stream. + client.on("message", function (data, isBinary) { + if (client.__grenStreamClosed) return; + + var msg = isBinary + ? __WebSocketServer_BinaryMessage( + new DataView(data.buffer, data.byteOffset, data.byteLength), + ) + : __WebSocketServer_TextMessage(data.toString()); + + client.__grenStreamController.enqueue(msg); + }); + + client.on("close", function (code, reason) { + // Close the message stream so readers observe end-of-stream. + if (!client.__grenStreamClosed) { + client.__grenStreamClosed = true; + try { + client.__grenStreamController.close(); + } catch (e) { + // Controller may already be closed or errored; safe to ignore. + } + } + + // Fail any further writes on the writable streams. + _WebSocketServer_terminateWritables(client, "WebSocket connection closed"); + + var handlers = server.__grenCloseHandlers; + for (var i = 0; i < handlers.length; i++) { + __Scheduler_rawSpawn( + A2( + __Platform_sendToApp, + handlers[i].router, + A2(handlers[i].handler, client.__grenConnection, { + __$code: code, + __$reason: reason.toString(), + }), + ), + ); + } + }); + + client.on("error", function (err) { + // Error the message stream so active readers stop. There is no separate + // error subscription: callers observe this as Stream.Cancelled . + if (!client.__grenStreamClosed) { + client.__grenStreamClosed = true; + try { + client.__grenStreamController.error(err.message); + } catch (e) { + // Controller may already be closed or errored; safe to ignore. + } + } + + // Fail any further writes on the writable streams. + _WebSocketServer_terminateWritables(client, err.message); + }); + }); +} + +// Build a WritableStream whose writes forward to ws#send. `toPayload` converts +// the chunk (a String for text, a Uint8Array for binary) into the value passed +// to ws#send, which determines the frame type (string -> text, Buffer -> binary). +// The send callback resolves the write (backpressure) and errors it on failure. +function _WebSocketServer_makeWritable(client, toPayload) { + return new WritableStream({ + start: function (controller) { + client.__grenWritableControllers.push(controller); + }, + write: function (chunk, controller) { + return new Promise(function (resolve, reject) { + client.send(toPayload(chunk), function (err) { + if (err) { + controller.error(err.message); + reject(err); + } else { + resolve(); + } + }); + }); + }, + }); +} + +// Error every writable sink controller for a client. Called when the connection +// closes or errors so that pending and future writes fail promptly. +function _WebSocketServer_terminateWritables(client, reason) { + var controllers = client.__grenWritableControllers; + for (var i = 0; i < controllers.length; i++) { + try { + controllers[i].error(reason); + } catch (e) { + // Controller may already be closed or errored; safe to ignore. + } + } +} + +// Clear all stored handler references for a server. Called once per server +// at the start of each onEffects cycle, before re-adding current handlers. +var _WebSocketServer_clearHandlers = function (server) { + _WebSocketServer_ensureListenersAttached(server); + server.__grenConnectionHandlers = []; + server.__grenCloseHandlers = []; +}; + +var _WebSocketServer_setConnectionHandler = F3(function (server, router, handler) { + _WebSocketServer_ensureListenersAttached(server); + server.__grenConnectionHandlers.push({ router: router, handler: handler }); +}); + +var _WebSocketServer_setCloseHandler = F3(function (server, router, handler) { + _WebSocketServer_ensureListenersAttached(server); + server.__grenCloseHandlers.push({ router: router, handler: handler }); +}); + +var _WebSocketServer_getConnectionId = function (connection) { + return connection.__$id; +}; + +function _WebSocketServer_constructError(err) { + return __WsConn_Error({ + __$code: err.code || "", + __$message: err.message || "", + }); +} + +var _WebSocketServer_send = F2(function (connection, data) { + return __Scheduler_binding(function (callback) { + try { + connection.__$client.send(data, function (err) { + if (err) { + callback(__Scheduler_fail(_WebSocketServer_constructError(err))); + } else { + callback(__Scheduler_succeed({})); + } + }); + } catch (e) { + callback(__Scheduler_fail(_WebSocketServer_constructError(e))); + } + }); +}); + +var _WebSocketServer_sendBytes = F2(function (connection, bytes) { + return __Scheduler_binding(function (callback) { + try { + var buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); + connection.__$client.send(buffer, function (err) { + if (err) { + callback(__Scheduler_fail(_WebSocketServer_constructError(err))); + } else { + callback(__Scheduler_succeed({})); + } + }); + } catch (e) { + callback(__Scheduler_fail(_WebSocketServer_constructError(e))); + } + }); +}); + +var _WebSocketServer_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(_WebSocketServer_constructError(e))); + } + }); +}); diff --git a/src/WebSocketServer.gren b/src/WebSocketServer.gren new file mode 100644 index 0000000..9562376 --- /dev/null +++ b/src/WebSocketServer.gren @@ -0,0 +1,252 @@ +effect module WebSocketServer where { subscription = WebSocketSubscription } exposing (CloseReason, Connection, Message(..), Permission, Server, ServerError(..), connectionId, createServer, initialize, onClose, onConnection) + +{-| Create a WebSocket server that can accept connections and exchange messages. + +You write your server using The Gren Architecture by subscribing to connection +and message events and responding with commands via [WebSocketServer.Connection](WebSocketServer.Connection). + +## Initialization + +@docs Permission, Server, ServerError, initialize, createServer + +## Connections + +@docs Connection, connectionId + +## Events + +@docs onConnection, onClose + +## Message Types + +@docs Message, CloseReason +-} + +import Bytes exposing (Bytes) +import Gren.Kernel.WebSocketServer +import Init +import Internal.Init +import Stream exposing (Readable, Writable) +import Task exposing (Task) + + + +-- INITIALIZATION + + +{-| The permission to start a WebSocket [`Server`](WebSocketServer.Server). + +You get this from [`initialize`](WebSocketServer.initialize). +-} +type Permission + = Permission + + +{-| The WebSocket server. +-} +type Server + = -- Note: Actual implementation in Kernel code + Server + + +{-| Error code and message from node. +Most likely from a failed attempt to start the server (e.g. `EADDRINUSE`). +-} +type ServerError + = ServerError { code : String, message : String } + + +{-| Initialize the [`WebSocketServer`](WebSocketServer) module and get permission to create a server. +-} +initialize : Init.Task Permission +initialize = + Task.succeed Permission + |> Internal.Init.Task + + +{-| Task to create a WebSocket server. + + WebSocketServer.createServer permission { host = "0.0.0.0", port_ = 8080 } + |> Task.attempt ServerCreated + +-} +createServer : Permission -> { host : String, port_ : Int } -> Task ServerError Server +createServer _ options = + Gren.Kernel.WebSocketServer.createServer options.host options.port_ + + + +-- CONNECTIONS + + +{-| An opaque handle representing a single WebSocket client connection. + +Use [`connectionId`](WebSocketServer.connectionId) to get a comparable identifier for this connection. +-} +type Connection + = -- Note: Actual implementation in Kernel code. Backed by a JS object with __$id and __$client 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.WebSocketServer.getConnectionId conn + + + +-- MESSAGE TYPES + + +{-| A message received from a WebSocket client. +-} +type Message + = TextMessage String + | BinaryMessage Bytes + + +{-| The reason a WebSocket connection was closed. +-} +type alias CloseReason = + { code : Int + , reason : String + } + + + +-- SUBSCRIPTIONS + + +{-| Subscribe to new WebSocket client connections on a server. + +For each new connection the handler receives a record containing the +[`Connection`](WebSocketServer.Connection), a `Stream.Readable Message` to read +messages sent by the client, and two writable streams for sending data back: +`textWritable` (sends text frames) and `binaryWritable` (sends binary frames). +The readable stream closes when the connection closes. + + WebSocketServer.onConnection server + (\{ connection, readable } -> ClientConnected { connection = connection, readable = readable }) + +-} +onConnection : + Server + -> + ({ connection : Connection + , readable : Readable Message + , textWritable : Writable String + , binaryWritable : Writable Bytes + } + -> msg + ) + -> Sub msg +onConnection server handler = + subscription (OnConnectionSub { server = server, handler = handler }) + + +{-| Subscribe to connection close events on a server. + + WebSocketServer.onClose server ClientDisconnected + +-} +onClose : Server -> (Connection -> CloseReason -> msg) -> Sub msg +onClose server handler = + subscription (OnCloseSub { server = server, handler = handler }) + + + +-- EFFECT STUFF + + +type WebSocketSubscription msg + = OnConnectionSub + { server : Server + , handler : + { connection : Connection + , readable : Readable Message + , textWritable : Writable String + , binaryWritable : Writable Bytes + } + -> msg + } + | OnCloseSub { server : Server, handler : Connection -> CloseReason -> msg } + + +subMap : (a -> b) -> WebSocketSubscription a -> WebSocketSubscription b +subMap fn sub = + when sub is + OnConnectionSub { server, handler } -> + OnConnectionSub + { server = server + , handler = handler >> fn + } + + OnCloseSub { server, handler } -> + OnCloseSub + { server = server + , handler = \conn reason -> fn (handler conn reason) + } + + +type alias State msg = + Array (WebSocketSubscription msg) + + +init : Task Never (State msg) +init = + Task.succeed [] + + +onEffects : + Platform.Router msg SelfMsg + -> Array (WebSocketSubscription msg) + -> State msg + -> Task Never (State msg) +onEffects router subs state = + let + -- Clear all handler references for servers that had subscriptions in the + -- previous cycle. clearHandlers is idempotent, so calling it more than + -- once on the same server is safe (it just sets null on already-null fields). + -- This does not remove the server-level "connection" EventEmitter listener, + -- it only nulls the mutable handler references that per-client closures read on + -- each event fire. + _clearOldHandlers = + state + |> Array.map + (\sub -> + when sub is + OnConnectionSub { server } -> + Gren.Kernel.WebSocketServer.clearHandlers server + + OnCloseSub { server } -> + Gren.Kernel.WebSocketServer.clearHandlers server + ) + + -- Set the current handler references. Per-websocket event closures read these + -- on every event fire, so existing long-lived connections will use the + -- updated handlers without needing to be re-wired. + _setNewHandlers = + subs + |> Array.map + (\sub -> + when sub is + OnConnectionSub { server, handler } -> + Gren.Kernel.WebSocketServer.setConnectionHandler server router handler + + OnCloseSub { server, handler } -> + Gren.Kernel.WebSocketServer.setCloseHandler server 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/WebSocketServer/Connection.gren b/src/WebSocketServer/Connection.gren new file mode 100644 index 0000000..502a96b --- /dev/null +++ b/src/WebSocketServer/Connection.gren @@ -0,0 +1,153 @@ +module WebSocketServer.Connection exposing + ( Error + , errorCode + , errorToString + , errorIsConnectionNotOpen + , errorIsConnectionReset + , errorIsBrokenPipe + , errorIsInvalidCloseCode + , errorIsCloseReasonTooLong + , send + , sendBytes + , close + ) + +{-| Send messages and close WebSocket 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. + + WebSocketServer.Connection.send connection "Hello!" + |> Task.attempt MessageSent + +## Errors + +@docs Error, errorCode, errorToString, errorIsConnectionNotOpen, errorIsConnectionReset, errorIsBrokenPipe, errorIsInvalidCloseCode, errorIsCloseReasonTooLong + +## Sending + +@docs send, sendBytes + +## Closing + +@docs close + +-} + +import Bytes exposing (Bytes) +import Task exposing (Task) +import WebSocketServer exposing (Connection) +import Gren.Kernel.WebSocketServer + + +-- 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 + + +-- SENDING + + +{-| Send a text message to a specific client connection. + + WebSocketServer.Connection.send connection "Hello!" + |> Task.attempt MessageSent + +-} +send : Connection -> String -> Task Error {} +send connection data = + Gren.Kernel.WebSocketServer.send connection data + + +{-| Send binary data to a specific client connection. +-} +sendBytes : Connection -> Bytes -> Task Error {} +sendBytes connection data = + Gren.Kernel.WebSocketServer.sendBytes connection data + + +-- CLOSING + + +{-| Close a connection with a status code and reason string. + + WebSocketServer.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.WebSocketServer.close connection code reason diff --git a/tests/gren.json b/tests/gren.json index 825da3f..6ab00aa 100644 --- a/tests/gren.json +++ b/tests/gren.json @@ -1,19 +1,17 @@ { - "type": "application", - "platform": "node", - "source-directories": [ - "src" - ], - "gren-version": "0.6.2", - "dependencies": { - "direct": { - "gren-lang/core": "7.0.0", - "gren-lang/node": "local:..", - "gren-lang/test": "5.0.0", - "gren-lang/test-runner-node": "7.0.0" - }, - "indirect": { - "gren-lang/url": "6.0.0" - } + "type": "application", + "platform": "node", + "source-directories": ["src"], + "gren-version": "0.6.5", + "dependencies": { + "direct": { + "gren-lang/core": "7.0.0", + "gren-lang/node": "local:..", + "gren-lang/test": "5.0.0", + "gren-lang/test-runner-node": "7.0.0" + }, + "indirect": { + "gren-lang/url": "6.0.0" } + } }