diff --git a/gren.json b/gren.json index 27d9ff9..71fd1fb 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/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..f2edaf1 --- /dev/null +++ b/integration-tests/websocket/gren.json @@ -0,0 +1,17 @@ +{ + "type": "application", + "platform": "node", + "source-directories": [ + "src" + ], + "gren-version": "0.6.3", + "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..ae2262b --- /dev/null +++ b/integration-tests/websocket/src/Main.gren @@ -0,0 +1,150 @@ +module Main exposing (main) + +import Node exposing (Environment, Program) +import Init +import Bytes exposing (Bytes) +import Dict exposing (Dict) +import Stream +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 Model = + { stdout : Stream.Writable Bytes + , stderr : Stream.Writable Bytes + , server : Maybe WebSocketServer.Server + , connections : Dict Int WebSocketServer.Connection + } + + +type Msg + = ServerCreated (Result WebSocketServer.ServerError WebSocketServer.Server) + | ClientConnected WebSocketServer.Connection + | MessageReceived { connection : WebSocketServer.Connection, message : WebSocketServer.Message } + | ClientDisconnected { connection : WebSocketServer.Connection, reason : WebSocketServer.CloseReason } + | ClientError { connection : WebSocketServer.Connection, error : String } + | 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.writeLineAsBytes "WebSocket server started on port 8085" model.stdout + |> Task.map (\_ -> {}) + |> Task.onError (\_ -> Task.succeed {}) + |> Task.execute + } + + Err (WebSocketServer.ServerError { code, message }) -> + { model = model + , command = + Stream.writeLineAsBytes ("Server error: " ++ code ++ " " ++ message) model.stderr + |> Task.map (\_ -> {}) + |> Task.onError (\_ -> Task.succeed {}) + |> Task.execute + } + + ClientConnected connection -> + let + connId = + WebSocketServer.connectionIdToInt (WebSocketServer.connectionId connection) + in + { model = + { model + | connections = Dict.set connId connection model.connections + } + , command = + WsConn.send connection "welcome" + |> Task.attempt SendResult + } + + MessageReceived { connection, message } -> + when message is + WebSocketServer.TextMessage text -> + if text == "please-close" then + { model = model + , command = + WsConn.close connection 1000 "server-initiated-close" + |> Task.attempt SendResult + } + else + { model = model + , command = + WsConn.send connection ("echo:" ++ text) + |> Task.attempt SendResult + } + + WebSocketServer.BinaryMessage bytes -> + { model = model + , command = + WsConn.sendBytes connection bytes + |> Task.attempt SendResult + } + + ClientDisconnected { connection } -> + let + connId = + WebSocketServer.connectionIdToInt (WebSocketServer.connectionId connection) + in + { model = + { model + | connections = Dict.remove connId model.connections + } + , command = Cmd.none + } + + ClientError _ -> + { model = model + , 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 ClientConnected + , WebSocketServer.onMessage server (\conn msg -> MessageReceived { connection = conn, message = msg }) + , WebSocketServer.onClose server (\conn reason -> ClientDisconnected { connection = conn, reason = reason }) + , WebSocketServer.onError server (\conn err -> ClientError { connection = conn, error = err }) + ] + + Nothing -> + Sub.none diff --git a/integration-tests/websocket/test/fixtures.mjs b/integration-tests/websocket/test/fixtures.mjs new file mode 100644 index 0000000..92a0cba --- /dev/null +++ b/integration-tests/websocket/test/fixtures.mjs @@ -0,0 +1,31 @@ +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(); +} diff --git a/integration-tests/websocket/test/requests.mjs b/integration-tests/websocket/test/requests.mjs new file mode 100644 index 0000000..230df0e --- /dev/null +++ b/integration-tests/websocket/test/requests.mjs @@ -0,0 +1,269 @@ +import WebSocket from "ws"; +import * as assert from "node:assert"; + +const url = "ws://127.0.0.1:8085"; + +// Buffer messages from the moment the WebSocket is created. +// The ws 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 ws = new WebSocket(url); + const messageQueue = []; + const waitingForMessageQueue = []; + + ws.on("message", (data, isBinary) => { + const entry = { data, isBinary }; + if (waitingForMessageQueue.length > 0) { + waitingForMessageQueue.shift()(entry); + } else { + messageQueue.push(entry); + } + }); + + ws._takeMessage = function () { + return new Promise((resolve) => { + if (messageQueue.length > 0) { + resolve(messageQueue.shift()); + } else { + waitingForMessageQueue.push(resolve); + } + }); + }; + + ws.on("open", () => resolve(ws)); + ws.on("error", reject); + }); +} + +async function waitForMessage(ws) { + const { data } = await ws._takeMessage(); + return data.toString(); +} + +async function waitForRawMessage(ws) { + return ws._takeMessage(); +} + +function waitForClose(ws) { + return new Promise((resolve) => { + ws.once("close", (code, reason) => { + resolve({ code, reason: reason.toString() }); + }); + }); +} + +function closeConnection(ws) { + return new Promise((resolve) => { + ws.on("close", () => resolve()); + ws.close(); + }); +} + +describe("WebSocket Server", function () { + this.timeout(10000); + it("sends welcome message on connection", async () => { + const ws = await connect(); + const msg = await waitForMessage(ws); + + assert.equal(msg, "welcome"); + + await closeConnection(ws); + }); + + it("echoes text messages", async () => { + const ws = await connect(); + + // Consume the welcome message + await waitForMessage(ws); + + ws.send("hello"); + const echo = await waitForMessage(ws); + + assert.equal(echo, "echo:hello"); + + await closeConnection(ws); + }); + + it("echoes multiple messages", async () => { + const ws = await connect(); + + // Consume the welcome message + await waitForMessage(ws); + + ws.send("first"); + const echo1 = await waitForMessage(ws); + assert.equal(echo1, "echo:first"); + + ws.send("second"); + const echo2 = await waitForMessage(ws); + assert.equal(echo2, "echo:second"); + + await closeConnection(ws); + }); + + it("echoes binary messages", async () => { + const ws = await connect(); + + // Consume the welcome message + await waitForMessage(ws); + + const binaryData = Buffer.from([1, 2, 3, 4, 5]); + ws.send(binaryData); + + const echoed = await waitForRawMessage(ws); + + assert.ok(echoed.isBinary, "Expected binary message"); + assert.deepEqual(Buffer.from(echoed.data), binaryData); + + await closeConnection(ws); + }); + + it("handles unicode text messages", async () => { + const ws = await connect(); + + // Consume the welcome message + await waitForMessage(ws); + + ws.send("snow ❄ flake"); + const echo = await waitForMessage(ws); + + assert.equal(echo, "echo:snow ❄ flake"); + + await closeConnection(ws); + }); + + it("supports multiple concurrent connections", async () => { + const ws1 = await connect(); + // Consume welcome for ws1 before opening ws2 + await waitForMessage(ws1); + + const ws2 = await connect(); + // Consume welcome for ws2 + await waitForMessage(ws2); + + ws1.send("from-client-1"); + const echo1 = await waitForMessage(ws1); + assert.equal(echo1, "echo:from-client-1"); + + ws2.send("from-client-2"); + const echo2 = await waitForMessage(ws2); + assert.equal(echo2, "echo:from-client-2"); + + await closeConnection(ws1); + await closeConnection(ws2); + }); + + it("client can close connection", async () => { + // This test verifies that the server handles client disconnection gracefully + const ws = await connect(); + + // Consume the welcome message + await waitForMessage(ws); + + const closePromise = waitForClose(ws); + ws.close(1000, "client closing"); + + const { code } = await closePromise; + assert.equal(code, 1000); + }); + + it("server initiates close when receiving please-close", async () => { + const ws = await connect(); + + // Consume the welcome message + await waitForMessage(ws); + + // Set up close listener before sending the trigger message + const closePromise = waitForClose(ws); + + ws.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 ws = await connect(); + + // Consume the welcome message + await waitForMessage(ws); + + // Create a 100KB message + const largeMessage = "A".repeat(100 * 1024); + ws.send(largeMessage); + const echo = await waitForMessage(ws); + + assert.equal(echo, "echo:" + largeMessage); + + await closeConnection(ws); + }); + + it("echoes rapid messages in order", async () => { + const ws = await connect(); + + // Consume the welcome message + await waitForMessage(ws); + + const messageCount = 50; + + // Send all messages rapidly without waiting for echoes + for (let i = 0; i < messageCount; i++) { + ws.send("msg-" + i); + } + + // Collect all echoes via the message queue + const received = []; + for (let i = 0; i < messageCount; i++) { + received.push(await waitForMessage(ws)); + } + + // 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(ws); + }); + + it("accepts a new connection after a previous one was closed", async () => { + // First connection + const ws1 = await connect(); + const welcome1 = await waitForMessage(ws1); + assert.equal(welcome1, "welcome"); + + ws1.send("first-connection"); + const echo1 = await waitForMessage(ws1); + assert.equal(echo1, "echo:first-connection"); + + await closeConnection(ws1); + + // Second connection after close + const ws2 = await connect(); + const welcome2 = await waitForMessage(ws2); + assert.equal(welcome2, "welcome"); + + ws2.send("second-connection"); + const echo2 = await waitForMessage(ws2); + assert.equal(echo2, "echo:second-connection"); + + await closeConnection(ws2); + }); + + it("echoes an empty string message", async () => { + const ws = await connect(); + + // Consume the welcome message + await waitForMessage(ws); + + ws.send(""); + const echo = await waitForMessage(ws); + + assert.equal(echo, "echo:"); + + await closeConnection(ws); + }); +}); diff --git a/src/Gren/Kernel/WebSocketServer.js b/src/Gren/Kernel/WebSocketServer.js new file mode 100644 index 0000000..8a8db5d --- /dev/null +++ b/src/Gren/Kernel/WebSocketServer.js @@ -0,0 +1,206 @@ +/* + +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 wss = new WebSocket.Server({ host: host, port: port }); + + wss.on("error", function (e) { + callback( + __Scheduler_fail( + __WebSocketServer_ServerError({ + __$code: e.code || "UNKNOWN", + __$message: e.message, + }), + ), + ); + }); + + wss.on("listening", function () { + callback(__Scheduler_succeed(wss)); + }); + }); +}); + +var _WebSocketServer_nextConnectionId = 0; + +// Initialize the handler storage on a wss object and attach the wss-level +// "connection" listener exactly once. Per-ws handlers delegate to the current +// handler references stored on the wss object, so when onEffects updates the +// handlers, existing long-lived connections automatically use the new handlers. +function _WebSocketServer_ensureListenersAttached(wss) { + if (wss.__grenListenersAttached) { + return; + } + wss.__grenListenersAttached = true; + wss.__grenConnectionHandlers = []; + wss.__grenMessageHandlers = []; + wss.__grenCloseHandlers = []; + wss.__grenErrorHandlers = []; + + wss.on("connection", function (ws) { + var connId = _WebSocketServer_nextConnectionId++; + var connection = { __$id: connId, __$ws: ws }; + + // Store the Connection object on the ws instance so that message/close/error + // handlers can retrieve it without a separate lookup map. + ws.__grenConnection = connection; + + // Notify the app of the new connection, if any handlers are registered. + var connHandlers = wss.__grenConnectionHandlers; + for (var i = 0; i < connHandlers.length; i++) { + __Scheduler_rawSpawn( + A2( + __Platform_sendToApp, + connHandlers[i].router, + connHandlers[i].handler(connection), + ), + ); + } + + // Attach per-ws handlers that delegate to the current stored handlers. + // Each handler reads the current reference from wss on every event fire, + // so re-evaluating subscriptions updates behavior for existing connections. + ws.on("message", function (data, isBinary) { + var handlers = wss.__grenMessageHandlers; + if (handlers.length === 0) return; + + var msg = isBinary + ? __WebSocketServer_BinaryMessage( + new DataView(data.buffer, data.byteOffset, data.byteLength), + ) + : __WebSocketServer_TextMessage(data.toString()); + + for (var i = 0; i < handlers.length; i++) { + __Scheduler_rawSpawn( + A2( + __Platform_sendToApp, + handlers[i].router, + A2(handlers[i].handler, ws.__grenConnection, msg), + ), + ); + } + }); + + ws.on("close", function (code, reason) { + var handlers = wss.__grenCloseHandlers; + for (var i = 0; i < handlers.length; i++) { + __Scheduler_rawSpawn( + A2( + __Platform_sendToApp, + handlers[i].router, + A2(handlers[i].handler, ws.__grenConnection, { + __$code: code, + __$reason: reason.toString(), + }), + ), + ); + } + }); + + ws.on("error", function (err) { + var handlers = wss.__grenErrorHandlers; + for (var i = 0; i < handlers.length; i++) { + __Scheduler_rawSpawn( + A2( + __Platform_sendToApp, + handlers[i].router, + A2(handlers[i].handler, ws.__grenConnection, err.message), + ), + ); + } + }); + }); +} + +// 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 (wss) { + _WebSocketServer_ensureListenersAttached(wss); + wss.__grenConnectionHandlers = []; + wss.__grenMessageHandlers = []; + wss.__grenCloseHandlers = []; + wss.__grenErrorHandlers = []; +}; + +var _WebSocketServer_setConnectionHandler = F3(function (wss, router, handler) { + _WebSocketServer_ensureListenersAttached(wss); + wss.__grenConnectionHandlers.push({ router: router, handler: handler }); +}); + +var _WebSocketServer_setMessageHandler = F3(function (wss, router, handler) { + _WebSocketServer_ensureListenersAttached(wss); + wss.__grenMessageHandlers.push({ router: router, handler: handler }); +}); + +var _WebSocketServer_setCloseHandler = F3(function (wss, router, handler) { + _WebSocketServer_ensureListenersAttached(wss); + wss.__grenCloseHandlers.push({ router: router, handler: handler }); +}); + +var _WebSocketServer_setErrorHandler = F3(function (wss, router, handler) { + _WebSocketServer_ensureListenersAttached(wss); + wss.__grenErrorHandlers.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.__$ws.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.__$ws.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.__$ws.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..e24dd23 --- /dev/null +++ b/src/WebSocketServer.gren @@ -0,0 +1,296 @@ +effect module WebSocketServer where { subscription = WebSocketSubscription } exposing + -- Init + ( Permission + , initialize + + -- Server + , Server + , ServerError(..) + , createServer + + -- Connections + , Connection + , ConnectionId + , connectionId + , connectionIdToInt + + -- Events + , onConnection + , onMessage + , onClose + , onError + + -- Message types + , Message(..) + , CloseReason + ) + +{-| 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, connectionId, connectionIdToInt + +## Events + +@docs onConnection, onMessage, onClose, onError + +## Message Types + +@docs Message, CloseReason + +-} + +import Bytes exposing (Bytes) +import Init +import Internal.Init +import Task exposing (Task) +import Gren.Kernel.WebSocketServer + + +-- 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 __$ws fields. + = Connection + + +{-| A comparable identifier for a [`Connection`](WebSocketServer.Connection). + +Use [`connectionIdToInt`](WebSocketServer.connectionIdToInt) to convert this to an `Int` +for use as a `Dict` key. +-} +type ConnectionId + = ConnectionId Int + + +{-| Get the [`ConnectionId`](WebSocketServer.ConnectionId) for a connection. +-} +connectionId : Connection -> ConnectionId +connectionId conn = + ConnectionId (Gren.Kernel.WebSocketServer.getConnectionId conn) + + +{-| Convert a [`ConnectionId`](WebSocketServer.ConnectionId) to an `Int`. + +Useful for storing connections in a `Dict Int Connection`. +-} +connectionIdToInt : ConnectionId -> Int +connectionIdToInt (ConnectionId id) = + id + + +-- 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. + + WebSocketServer.onConnection server ClientConnected + +-} +onConnection : Server -> (Connection -> msg) -> Sub msg +onConnection server handler = + subscription (OnConnectionSub { server = server, handler = handler }) + + +{-| Subscribe to messages received on any connection managed by a server. + + WebSocketServer.onMessage server MessageReceived + +-} +onMessage : Server -> (Connection -> Message -> msg) -> Sub msg +onMessage server handler = + subscription (OnMessageSub { 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 }) + + +{-| Subscribe to connection error events on a server. + + WebSocketServer.onError server ConnectionError + +-} +onError : Server -> (Connection -> String -> msg) -> Sub msg +onError server handler = + subscription (OnErrorSub { server = server, handler = handler }) + + +-- EFFECT STUFF + + +type WebSocketSubscription msg + = OnConnectionSub { server : Server, handler : Connection -> msg } + | OnMessageSub { server : Server, handler : Connection -> Message -> msg } + | OnCloseSub { server : Server, handler : Connection -> CloseReason -> msg } + | OnErrorSub { server : Server, handler : Connection -> String -> msg } + + +subMap : (a -> b) -> WebSocketSubscription a -> WebSocketSubscription b +subMap f sub = + when sub is + OnConnectionSub { server, handler } -> + OnConnectionSub { server = server, handler = (\conn -> f (handler conn)) } + + OnMessageSub { server, handler } -> + OnMessageSub { server = server, handler = (\conn msg -> f (handler conn msg)) } + + OnCloseSub { server, handler } -> + OnCloseSub { server = server, handler = (\conn reason -> f (handler conn reason)) } + + OnErrorSub { server, handler } -> + OnErrorSub { server = server, handler = (\conn err -> f (handler conn err)) } + + +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 wss-level "connection" EventEmitter listener, + -- it only nulls the mutable handler references that per-ws closures read on + -- each event fire. + _clearOldHandlers = + state + |> Array.map + (\sub -> + when sub is + OnConnectionSub { server } -> + Gren.Kernel.WebSocketServer.clearHandlers server + + OnMessageSub { server } -> + Gren.Kernel.WebSocketServer.clearHandlers server + + OnCloseSub { server } -> + Gren.Kernel.WebSocketServer.clearHandlers server + + OnErrorSub { 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 + + OnMessageSub { server, handler } -> + Gren.Kernel.WebSocketServer.setMessageHandler server router handler + + OnCloseSub { server, handler } -> + Gren.Kernel.WebSocketServer.setCloseHandler server router handler + + OnErrorSub { server, handler } -> + Gren.Kernel.WebSocketServer.setErrorHandler 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