websocket-text-relay 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.eslintrc +29 -0
  2. package/LICENSE +21 -0
  3. package/README.md +40 -0
  4. package/docs/code-structure.md +77 -0
  5. package/docs/creating-text-editor-plugin.md +10 -0
  6. package/docs/dev-getting-started.md +8 -0
  7. package/index.js +87 -0
  8. package/package.json +45 -0
  9. package/src/language-server/JsonRpcInterface.js +126 -0
  10. package/src/language-server/JsonRpcInterface.test.js +496 -0
  11. package/src/language-server/LspReader.js +119 -0
  12. package/src/language-server/LspReader.test.js +508 -0
  13. package/src/language-server/LspWriter.js +44 -0
  14. package/src/language-server/LspWriter.test.js +179 -0
  15. package/src/language-server/constants.js +23 -0
  16. package/src/ui/css/fonts/Montserrat-Black.ttf +0 -0
  17. package/src/ui/css/fonts/Montserrat-Bold.ttf +0 -0
  18. package/src/ui/css/fonts/Montserrat-ExtraBold.ttf +0 -0
  19. package/src/ui/css/fonts/Montserrat-ExtraLight.ttf +0 -0
  20. package/src/ui/css/fonts/Montserrat-Light.ttf +0 -0
  21. package/src/ui/css/fonts/Montserrat-Medium.ttf +0 -0
  22. package/src/ui/css/fonts/Montserrat-Regular.ttf +0 -0
  23. package/src/ui/css/fonts/Montserrat-SemiBold.ttf +0 -0
  24. package/src/ui/css/fonts/Montserrat-Thin.ttf +0 -0
  25. package/src/ui/css/fonts.css +54 -0
  26. package/src/ui/css/main.css +198 -0
  27. package/src/ui/index.html +36 -0
  28. package/src/ui/js/components/ActivityTimeseriesGraph.js +149 -0
  29. package/src/ui/js/components/HeaderSummary.js +23 -0
  30. package/src/ui/js/components/ServerStatus.js +31 -0
  31. package/src/ui/js/components/SessionLabels.js +171 -0
  32. package/src/ui/js/components/SessionWedges.js +107 -0
  33. package/src/ui/js/components/StatusRing.js +42 -0
  34. package/src/ui/js/components/grids.js +29 -0
  35. package/src/ui/js/index.js +117 -0
  36. package/src/ui/js/main.js +97 -0
  37. package/src/ui/js/util/DependencyManager.js +31 -0
  38. package/src/ui/js/util/EventEmitter.js +40 -0
  39. package/src/ui/js/util/WebsocketClient.js +76 -0
  40. package/src/ui/js/util/constants.js +9 -0
  41. package/src/ui/js/util/drawing.js +128 -0
  42. package/src/websocket-interface/WebsocketClient.js +56 -0
  43. package/src/websocket-interface/WebsocketInterface.js +71 -0
  44. package/src/websocket-interface/WtrSession.js +122 -0
  45. package/src/websocket-interface/httpServer.js +58 -0
  46. package/src/websocket-interface/sessionManager.js +107 -0
  47. package/src/websocket-interface/util.js +12 -0
  48. package/src/websocket-interface/websocketApi.js +116 -0
  49. package/src/websocket-interface/websocketServer.js +18 -0
  50. package/start.js +4 -0
package/.eslintrc ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "root": true,
3
+ "extends": [
4
+ "eslint:recommended"
5
+ ],
6
+ "rules": {
7
+ "semi": ["error", "never"],
8
+ "indent": ["error", 2],
9
+ "no-multiple-empty-lines": ["error", { "max": 1 }],
10
+ "no-unused-vars": 1,
11
+ "no-return-assign": 0,
12
+ "multiline-ternary": 0,
13
+ "object-curly-spacing": 0,
14
+ "object-property-newline": 0,
15
+ "object-curly-newline": 0,
16
+ "quotes": 0,
17
+ "quote-props": 0,
18
+ "import/no-absolute-path": 0
19
+ },
20
+ "env": {
21
+ "node": true,
22
+ "browser": true,
23
+ "es2024": true
24
+ },
25
+ "parserOptions": {
26
+ "sourceType": "module",
27
+ "ecmaVersion": "latest"
28
+ }
29
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Niels Nielsen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # websocket-text-relay (WTR)
2
+
3
+ ## Alpha
4
+
5
+ Note: This tool is still in alpha state. Some features like the VsCode extension and vite plugin have not been published yet. Documentation is still a work in progress
6
+
7
+ This application connects to your text editor using the Language Server Protocol (LSP) and then starts a websocket
8
+ server that front end clients can connect to and subscribe to file change events. This allows users to see their
9
+ front end changes evaluated immediately as they type, without having to first save the file to disk or reload the browser.
10
+ It's a similar concept to sites like CodePen and JsFiddle, except you can develop locally using your own text editor with all
11
+ of your personalized plugins instead of having to use an in browser code editor.
12
+
13
+ ## Usage
14
+
15
+ ### 1. Install the extension for your text editor
16
+
17
+ WTR currently has plugins for Neovim and VSCode.
18
+ * Installation instructions for Neovim
19
+ * Installation instructions for VSCode
20
+
21
+ If you wish to create a plugin for a new editor, the process is fairly straight forward if the editor has
22
+ LSP support. See the [developer guide to creating a WTR text editor plugin](./docs/creating-text-editor-plugin.md)
23
+
24
+ ### 2. Connect to the websocket server from the front end application
25
+
26
+ To use this on a professional level project that gives you the option to use modules, typescript, and react, I recommend using vite along with
27
+ the plugin **vite-plugin-websocket-text-relay**. This plugin gives you all the power of vite when developing while also hooking
28
+ the live text updates into Vite's hot module reload system.
29
+
30
+ If you want to use this as a learning tool to play around with UI concepts using simple projects involving 1 html, css, and javascript file,
31
+ then check out this **Simple WTR reference project**. This is a great setup for following along with any short and focused web development tutorials.
32
+
33
+ And finally, the [status UI for this project](http://localhost:38378) was also created using live updates from websocket-text-relay.
34
+ In addition to giving you live feedback on the status and activity of the application, it is also meant to serve as a
35
+ reference UI project that is more complicated than a single javascript file, but still doesn't use any external dependencies.
36
+
37
+
38
+ ## Developing
39
+
40
+ If you are a developer looking to run websocket-text-relay from source and make modifications, follow the [Developer Getting Started Guide](./docs/dev-getting-started.md)
@@ -0,0 +1,77 @@
1
+ # Developer Guide: Code Structure
2
+
3
+ ## Core Logic: the start function
4
+ _websocket-text-relay_ merges two asynchronous interfaces. The the LSP server <-> text editor interface and
5
+ the websocket server <-> front end client interface.
6
+
7
+ Everything starts with the `startLanguageServer` function exported by the index.js file in the root of the project. The start function
8
+ is exported this way so it can be used as either a standalone command line application (the way the Neovim plugin interacts with the LSP server)
9
+ or as an imported library (the way the VSCode extension uses websocket-text-relay).
10
+
11
+ The function takes as parameters the input and output streams for the LSP server (they are optional and default to stdin and stdout if not included), and the port
12
+ for the http/websocket server (optional and defaults to 38378).
13
+
14
+ This function will connect the LSP server to the IO streams and spin up the websocket interface on the resolved port. Then it will wire up all
15
+ of the LSP lifecycle events and websocket API events to their handlers.
16
+
17
+ Every text editor instance will start up an instance of the websocket-text-relay app. Because it needs to host
18
+ an http server on a specific port for front end clients to connect to, only one instance of the app may act as the
19
+ websocket server. Every subsuquent instance must connect to that websocket server as a client to relay its text changes.
20
+ This is all abstracted away behind the websocket-interface, which is explained in greater detail below.
21
+
22
+ ## Core Logic: The lsp and websocket events
23
+
24
+ LSP: `initailze` / WS: `init`
25
+
26
+ Whenever a new editor instance or front end client connects to the websocket, a websocket session is created for that client. These
27
+ can be seen as the labeled blue wedges around the ring in the status UI. When the lsp client from the editor sends an
28
+ initialize request to the language-server, it contains the editor name and editor processId. This data is then sent to the
29
+ main websocket server in an `init` message, where it can be viewed in the status UI (used to determine which sessions are editors and label each wedge with the editor name)
30
+
31
+ WS: `watch-file`
32
+
33
+ The front end client will send `watch-file` events to the websocket server to let it know what file changes it wants to subscribe to.
34
+
35
+ LSP: `wtr/update-open-files`
36
+
37
+ This event has to be manually sent by the editor plugin. Any time a file is opened or closed in the editor, the editor
38
+ will send a list of open files to the language-server.
39
+
40
+ LSP: `wtr/update-active-files`
41
+
42
+ The sessionManager will reconcile the files that the front end clients are watching with the files that the text
43
+ editor has open and determine which files the LSP client should attach to. Once attached the client will start sending `textDocument/didChange` events.
44
+
45
+ LSP: `textDocument/didChange`
46
+
47
+ Whenever an active text document is updated, send the latest changes to all watching clients via the weboscket-interface
48
+
49
+
50
+ ## Language-Server
51
+
52
+ The language-server directory contains the code that handles communicating with the text editor over stdio. The main export for
53
+ interfacing with the LSP events is the JsonRpcInterface object in the JsonRpcInterface.js file. JsonRpc has 2
54
+ message types, requests and notifications. Therefore the JsonRpcInterface has 4 functions to send and receive these message types.
55
+
56
+ * onRequest - handle incoming requests from editor. Can be asynchronous, return a promise, or return a response synchronously.
57
+ * onNotification - handle incoming notifications from editor. No return value
58
+ * sendRequest - send outgoing request to editor. Returns a promise that resolves with the editor's response
59
+ * sendNotification - send outgoing notification. No response or return value
60
+
61
+ The code in LspReader and LspWriter handles all the lower level details of reading and writing JSON RPC messages. Test
62
+ driven development (TDD) was used to develop the data stream handlers and verify that the resulting interface behaves
63
+ as expected.
64
+
65
+ ## Websocket-Interface
66
+
67
+ The websocket-interface directory contains the code for hosting and interacting with the websocket API. When the
68
+ application first starts up, the websocket-interface will attempt to spin up the http server on the specified port. If
69
+ successful, the application runs in server mode and all function calls made to the websocket-interface will be directed to itself.
70
+ If there is already a server running on the specified port, then the interface will connect to that server as a client. All
71
+ calls made through the websocket-interface will now be sent to the main websocket instance through the websocket client.
72
+
73
+ If the main server ever goes offline, each instance will attempt to start up an http server on the port. The first instance able
74
+ to start the http server will be the new main server, the rest of the instances will receive a port in use error from the OS and then
75
+ proceed to connect to the new server in client mode again.
76
+
77
+ All init messages are resent when a websocket client reconnects.
@@ -0,0 +1,10 @@
1
+ # Developers Guide: Creating Text Editor Plugin
2
+
3
+ Placeholder guide for creating text editor plugin:
4
+ * Connecting the LSP client
5
+ * Seeing the session in the status UI
6
+ * Sending list of open files
7
+ * receiving list of active files (verify in status UI)
8
+ * Connecting the LSP client to the active text documents
9
+ * Subscribing to changes from a test UI
10
+ * Seeing updates in test UI and Status UI
@@ -0,0 +1,8 @@
1
+ # Developers Guide: Getting Started
2
+
3
+ Placeholder for instructions on how to:
4
+ * check out repo
5
+ * run tests
6
+ * point your text editor plugin to use your checked out code
7
+ * run the chrome debugger and set breakpoints
8
+ * understanding the code structure
package/index.js ADDED
@@ -0,0 +1,87 @@
1
+ import { JsonRpcInterface } from "./src/language-server/JsonRpcInterface.js"
2
+ import { WebsocketInterface } from "./src/websocket-interface/WebsocketInterface.js"
3
+
4
+ const DEFAULT_WS_PORT = 38378
5
+
6
+ const resolvePort = (portParam) => {
7
+ if (portParam != null) { return portParam }
8
+ if (process.env["websocket_text_relay_port"] != null) { return process.env["websocket_text_relay_port"] }
9
+ return DEFAULT_WS_PORT
10
+ }
11
+
12
+ const resolveIoStreams = ({inputStreamParam, outputStreamParam}) => {
13
+ const inputStream = inputStreamParam || process.stdin
14
+ const outputStream = outputStreamParam || process.stdout
15
+ return {inputStream, outputStream}
16
+ }
17
+
18
+ const fileProtocolPrefix = "file://"
19
+
20
+ const getNormalizedFileName = (uri) => {
21
+ if (uri.startsWith(fileProtocolPrefix)) { return uri.substring(fileProtocolPrefix.length) }
22
+ return uri
23
+ }
24
+
25
+ const lspInitializeResponse = {
26
+ serverInfo: {
27
+ name: "Websocket Text Relay LSP Server",
28
+ version: "1.0.0"
29
+ },
30
+ capabilities: {
31
+ textDocumentSync: 1 // full text sync
32
+ }
33
+ }
34
+
35
+ export const startLanguageServer = (options = {}) => {
36
+ const {inputStream: inputStreamParam, outputStream: outputStreamParam, port: portParam} = options
37
+ const jsonRpc = new JsonRpcInterface(resolveIoStreams({inputStreamParam, outputStreamParam}))
38
+
39
+ const wsInterface = new WebsocketInterface({port: resolvePort(portParam)})
40
+
41
+ wsInterface.emitter.on('message', (message) => {
42
+ if (message.method === "watch-editor-active-files") {
43
+ jsonRpc.sendNotification("wtr/update-active-files", {files: message.files})
44
+ }
45
+ })
46
+
47
+ jsonRpc.onRequest("initialize", (params) => {
48
+ const wsInitMessage = {
49
+ method: "init",
50
+ name: params.clientInfo?.name || "Unnamed Editor",
51
+ editorPid: params.processId,
52
+ lsPid: process.pid
53
+ }
54
+
55
+ wsInterface.sendInitMessage(wsInitMessage)
56
+
57
+ return lspInitializeResponse
58
+ })
59
+
60
+ // use this function to hook into initalized event
61
+ // jsonRpc.onNotification("initialized", () => {
62
+ // })
63
+
64
+ jsonRpc.onRequest("shutdown", () => {
65
+ return null
66
+ })
67
+
68
+ jsonRpc.onNotification("exit", () => {
69
+ process.exit(0)
70
+ })
71
+
72
+ jsonRpc.onNotification('wtr/update-open-files', ({files}) => {
73
+ wsInterface.sendOpenFileList(files)
74
+ })
75
+
76
+ jsonRpc.onNotification("textDocument/didOpen", (params) => {
77
+ const file = getNormalizedFileName(params.textDocument.uri)
78
+ const contents = params.textDocument.text
79
+ wsInterface.sendText({file, contents})
80
+ })
81
+
82
+ jsonRpc.onNotification("textDocument/didChange", (params) => {
83
+ const file = getNormalizedFileName(params.textDocument.uri)
84
+ const contents = params.contentChanges[0]?.text
85
+ wsInterface.sendText({file, contents})
86
+ })
87
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "websocket-text-relay",
3
+ "version": "1.0.0",
4
+ "description": "An LSP server for sending live file updates from your text editor to the front end via websockets.",
5
+ "bin": {
6
+ "websocket-text-relay": "./start.js"
7
+ },
8
+ "scripts": {
9
+ "lint": "eslint .",
10
+ "test": "vitest run --coverage",
11
+ "test-ui": "vitest --ui"
12
+ },
13
+ "type": "module",
14
+ "main": "index.js",
15
+ "directories": {
16
+ "doc": "docs"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+ssh://git@github.com/niels4/websocket-text-relay.git"
21
+ },
22
+ "keywords": [
23
+ "websocket",
24
+ "text",
25
+ "relay",
26
+ "live",
27
+ "coding",
28
+ "LSP"
29
+ ],
30
+ "author": "Niels Nielsen",
31
+ "license": "MIT",
32
+ "bugs": {
33
+ "url": "https://github.com/niels4/websocket-text-relay/issues"
34
+ },
35
+ "homepage": "https://github.com/niels4/websocket-text-relay#readme",
36
+ "devDependencies": {
37
+ "@vitest/coverage-v8": "^1.4.0",
38
+ "@vitest/ui": "^1.4.0",
39
+ "eslint": "^8.57.0",
40
+ "vitest": "^1.4.0"
41
+ },
42
+ "dependencies": {
43
+ "ws": "^8.16.0"
44
+ }
45
+ }
@@ -0,0 +1,126 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { LspReader } from "./LspReader.js"
3
+ import { writeNotification, writeRequest, writeResponse } from './LspWriter.js'
4
+ import { invalidRequestErrorCode, invalidRequestErrorMessage, methodNotFoundErrorCode, methodNotFoundErrorMessage, unexpectedNotificationErrorCode,
5
+ unexpectedNotificationErrorMessage, unexpectedRequestErrorCode, unexpectedRequestErrorMessage } from './constants.js'
6
+
7
+ export class JsonRpcInterface {
8
+ constructor ({inputStream, outputStream}) {
9
+ this.inputStream = inputStream
10
+ this.outputStream = outputStream
11
+
12
+ this.outstandingRequests = new Map()
13
+ this.events = new EventEmitter()
14
+ this.notificationHandlers = new Map()
15
+ this.requestHandlers = new Map()
16
+ this.lspInputStream = new LspReader()
17
+ this.inputStream.pipe(this.lspInputStream)
18
+
19
+ this.lspInputStream.on('data', this._handleIncomingLspMessage.bind(this))
20
+
21
+ this.lspInputStream.on('parse-error', (error) => {
22
+ writeResponse(this.outputStream, null, error)
23
+ })
24
+ }
25
+
26
+ onNotification (method, handler) {
27
+ if (this.notificationHandlers.has(method)) {
28
+ throw new Error("Can only register one notification handler at a time. Duplicate method handlers for " + method)
29
+ }
30
+ this.notificationHandlers.set(method, handler)
31
+ }
32
+
33
+ removeNotificationHandler (method) {
34
+ return this.notificationHandlers.delete(method)
35
+ }
36
+
37
+ onRequest (method, handler) {
38
+ if (this.requestHandlers.has(method)) {
39
+ throw new Error("Can only register one request handler at a time. Duplicate method handlers for " + method)
40
+ }
41
+ this.requestHandlers.set(method, handler)
42
+ }
43
+
44
+ removeRequestHandler (method) {
45
+ return this.requestHandlers.delete(method)
46
+ }
47
+
48
+ sendNotification (method, params) {
49
+ writeNotification(this.outputStream, method, params)
50
+ }
51
+
52
+ sendRequest (method, params) {
53
+ const responseHandler = {}
54
+ const promise = new Promise((resolve, reject) => {
55
+ responseHandler.resolve = resolve
56
+ responseHandler.reject = reject
57
+ })
58
+ const id = writeRequest(this.outputStream, method, params)
59
+ this.outstandingRequests.set(id, responseHandler)
60
+ return promise
61
+ }
62
+
63
+ _handleIncomingLspMessage (message) {
64
+ const {method, params, id, error, result} = message
65
+ if (id != null) {
66
+ if (method != null) {
67
+ this._handleIncomingRequest(method, params, id)
68
+ } else {
69
+ this._handleRequestResponse(id, error, result)
70
+ }
71
+ return
72
+ }
73
+
74
+ if (method != null) {
75
+ this._handleIncomingNotification(method, params)
76
+ return
77
+ }
78
+ const rpcError = {code: invalidRequestErrorCode, message: invalidRequestErrorMessage}
79
+ this.events.emit('rpc-error', rpcError)
80
+ writeResponse(this.outputStream, null, rpcError)
81
+ }
82
+
83
+ _handleIncomingNotification (method, params) {
84
+ const handler = this.notificationHandlers.get(method)
85
+ if (!handler) {
86
+ const errorObject = {code: methodNotFoundErrorCode, message: methodNotFoundErrorMessage, data: {method}}
87
+ this.events.emit("notification-error", errorObject)
88
+ return
89
+ }
90
+ try {
91
+ handler(params)
92
+ } catch (error) {
93
+ const errorObject = {code: unexpectedNotificationErrorCode, message: unexpectedNotificationErrorMessage, data: {method, params, error: error.message}}
94
+ this.events.emit("notification-error", errorObject)
95
+ }
96
+ }
97
+
98
+ async _handleIncomingRequest (method, params, id) {
99
+ const handler = this.requestHandlers.get(method)
100
+ if (!handler) {
101
+ const errorObject = {code: methodNotFoundErrorCode, message: methodNotFoundErrorMessage, data: {method}}
102
+ writeResponse(this.outputStream, id, errorObject)
103
+ this.events.emit("request-error", {id, error: errorObject})
104
+ return
105
+ }
106
+ try {
107
+ const result = await handler(params)
108
+ writeResponse(this.outputStream, id, null, result)
109
+ } catch (error) {
110
+ const errorObject = {code: unexpectedRequestErrorCode, message: unexpectedRequestErrorMessage, data: {method, params, error: error.message}}
111
+ this.events.emit("request-error", {id, error: errorObject})
112
+ writeResponse(this.outputStream, id, errorObject)
113
+ }
114
+ }
115
+
116
+ _handleRequestResponse (id, error, result) {
117
+ const responseHandler = this.outstandingRequests.get(id)
118
+ if (!responseHandler) { return }
119
+ this.outstandingRequests.delete(id)
120
+ if (error != null) {
121
+ responseHandler.reject(error)
122
+ } else {
123
+ responseHandler.resolve(result)
124
+ }
125
+ }
126
+ }