open-in-editor-bridge 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: b59090b703a55c7194b9898ffc6753cda0df2a3e3b1d1d311c1cd0dcd0fa57d9
4
+ data.tar.gz: d57211d78ebeb9048b963a853621ba575825115c45fdd2a651d75961b2062ee6
5
+ SHA512:
6
+ metadata.gz: 86eccd454213ccc64074b85d2eb4a91ddfaec7bb879c0e7e7da0dc53a924ab9d926658d6ece2600ce699e2f5f46b97565d4bd17f1b9dd6358f15fc9bfca78224
7
+ data.tar.gz: 4be766b7b327ee39aa94f763979ab93b46a9a6c226774a80e3773c303f7f24d58870c6700e2685fc1399afcd8ee8424bfc47f976fffc99f25fb121f40ffb280b
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Marlen Brunner
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.
data/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # OpenInEditorBridge
2
+
3
+ A small Ruby gem that runs a local HTTP bridge for opening files from a container in the host editor.
4
+
5
+ ## Install
6
+
7
+ ```ruby
8
+ gem "open-in-editor-bridge"
9
+ ```
10
+
11
+ ## Ruby API
12
+
13
+ Wrap the local development command that needs the bridge:
14
+
15
+ ```ruby
16
+ require "open_in_editor_bridge"
17
+
18
+ OpenInEditorBridge.with_running do
19
+ # Run the command that emits open-in-editor links.
20
+ end
21
+ ```
22
+
23
+ Use `ensure_running: false` when the wrapped command is responsible for stopping the bridge:
24
+
25
+ ```ruby
26
+ OpenInEditorBridge.with_running(ensure_running: false) { run_down_command }
27
+ ```
28
+
29
+ The bridge is stopped in the wrapper's `ensure` path. A block exception remains the raised exception.
30
+
31
+ ## CLI
32
+
33
+ ```sh
34
+ open-in-editor-bridge --ensure-running
35
+ open-in-editor-bridge --shutdown
36
+ open-in-editor-bridge --serve
37
+ ```
38
+
39
+ The server provides `GET /health` and `GET /__open-in-editor?file=...`. Container paths beginning with `/usr/src/web` are mapped to the host `web` directory, preserving optional `:line:column` locations.
40
+
41
+ ## Configuration
42
+
43
+ - `OPEN_IN_EDITOR_BRIDGE_PORT` (default `3333`)
44
+ - `OPEN_IN_EDITOR_PROJECT_ROOT` (default current directory)
45
+ - `OPEN_IN_EDITOR_CONTAINER_WEB_ROOT` (default `/usr/src/web`)
46
+ - `OPEN_IN_EDITOR_HOST_WEB_ROOT` (default `<project-root>/web`)
47
+ - `OPEN_IN_EDITOR_COMMAND` (or `EDITOR`)
48
+
49
+ The editor command is required and is parsed with `Shellwords`.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/open_in_editor_bridge"
4
+
5
+ OpenInEditorBridge.call(*ARGV)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class OpenInEditorBridge
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "rbconfig"
6
+ require "shellwords"
7
+ require "socket"
8
+ require "timeout"
9
+ require "uri"
10
+ require_relative "open_in_editor_bridge/version"
11
+
12
+ class OpenInEditorBridge
13
+ DEFAULT_PORT = 3333
14
+ HEALTH_PATH = "/health"
15
+ OPEN_IN_EDITOR_PATH = "/__open-in-editor"
16
+ RESPONSE_PHRASES = {
17
+ 200 => "OK",
18
+ 400 => "Bad Request",
19
+ 404 => "Not Found",
20
+ 405 => "Method Not Allowed",
21
+ 500 => "Internal Server Error",
22
+ }.freeze
23
+
24
+ class StartupError < StandardError
25
+ end
26
+
27
+ def self.call(*args)
28
+ new.call(*args)
29
+ end
30
+
31
+ def self.with_running(ensure_running: true)
32
+ owns_server = !ensure_running
33
+ owns_server = call("--ensure-running") == :started if ensure_running
34
+
35
+ begin
36
+ yield
37
+ ensure
38
+ if owns_server
39
+ block_error = $!
40
+ begin
41
+ call("--shutdown")
42
+ rescue StandardError => cleanup_error
43
+ raise cleanup_error unless block_error
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ def initialize(env: ENV, project_root: nil)
50
+ @env = env
51
+ @project_root = project_root
52
+ end
53
+
54
+ def call(*args)
55
+ case args.first || "--serve"
56
+ when "--ensure-running"
57
+ ensure_running
58
+ when "--shutdown"
59
+ shutdown
60
+ when "--serve"
61
+ serve
62
+ else
63
+ serve
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def ensure_running
70
+ pid = running_pid(validate_health: true)
71
+ if pid
72
+ puts "Editor bridge already running on port #{config[:port]} (pid #{pid})."
73
+ return :reused
74
+ end
75
+
76
+ ensure_runtime_directory
77
+ pid = Process.spawn(
78
+ *server_process_command,
79
+ out: config[:log_file],
80
+ err: config[:log_file],
81
+ pgroup: true
82
+ )
83
+ Process.detach(pid)
84
+
85
+ wait_until_ready(pid)
86
+ write_pid_file(pid)
87
+ puts "Started editor bridge on port #{config[:port]}."
88
+ :started
89
+ rescue StandardError
90
+ if pid && process_running?(pid)
91
+ begin
92
+ Process.kill("TERM", -pid)
93
+ rescue Errno::ESRCH
94
+ end
95
+ end
96
+ delete_pid_file
97
+ raise
98
+ end
99
+
100
+ def shutdown
101
+ pid = running_pid
102
+ return unless pid
103
+
104
+ Process.kill("TERM", -pid)
105
+ wait_until_stopped(pid)
106
+ delete_pid_file
107
+ puts "Stopped editor bridge."
108
+ rescue Errno::ESRCH
109
+ delete_pid_file
110
+ end
111
+
112
+ def serve
113
+ abort("No editor configured. Set OPEN_IN_EDITOR_COMMAND or EDITOR environment variable.") if editor_command.empty?
114
+
115
+ ensure_runtime_directory
116
+ write_pid_file(Process.pid)
117
+ @server = TCPServer.new("127.0.0.1", config[:port])
118
+ @running = true
119
+ install_signal_handlers
120
+
121
+ while @running
122
+ begin
123
+ socket = @server.accept
124
+ handle_request(socket)
125
+ rescue IOError, Errno::EBADF
126
+ break
127
+ end
128
+ end
129
+ ensure
130
+ @server&.close
131
+ delete_pid_file
132
+ end
133
+
134
+ def handle_request(socket)
135
+ request = read_request(socket)
136
+ return if request.nil?
137
+
138
+ method = request.fetch(:method)
139
+ consume_headers(socket)
140
+ return write_json_response(socket, status: 405, payload: { error: "Method not allowed" }) unless method == "GET"
141
+
142
+ respond_to_get_request(socket, request.fetch(:path), request.fetch(:params))
143
+ ensure
144
+ socket.close unless socket.closed?
145
+ end
146
+
147
+ def open_in_editor(socket, params)
148
+ file = params["file"]
149
+ if file.nil? || file.empty?
150
+ return write_json_response(
151
+ socket,
152
+ status: 400,
153
+ payload: { error: "Missing required query parameter: file" }
154
+ )
155
+ end
156
+
157
+ translated_target = translate_target(file)
158
+ pid = Process.spawn(*editor_command_args, "--goto", translated_target)
159
+ Process.detach(pid)
160
+
161
+ write_json_response(
162
+ socket,
163
+ status: 200,
164
+ payload: {
165
+ ok: true,
166
+ requestedFile: file,
167
+ translatedTarget: translated_target,
168
+ editorCommand: editor_command,
169
+ }
170
+ )
171
+ rescue StandardError => error
172
+ write_json_response(
173
+ socket,
174
+ status: 500,
175
+ payload: {
176
+ ok: false,
177
+ error: "Failed to open editor: #{error.message}",
178
+ requestedFile: file,
179
+ translatedTarget: translated_target,
180
+ editorCommand: editor_command,
181
+ }
182
+ )
183
+ end
184
+
185
+ def write_json_response(socket, status: 200, payload:)
186
+ body = JSON.generate(payload)
187
+ socket.write("HTTP/1.1 #{status} #{RESPONSE_PHRASES.fetch(status)}\r\n")
188
+ socket.write("Content-Type: application/json\r\n")
189
+ socket.write("Access-Control-Allow-Origin: *\r\n")
190
+ socket.write("X-Open-In-Editor-Bridge: 1\r\n")
191
+ socket.write("Content-Length: #{body.bytesize}\r\n")
192
+ socket.write("Connection: close\r\n")
193
+ socket.write("\r\n")
194
+ socket.write(body)
195
+ end
196
+
197
+ def stop_server
198
+ @running = false
199
+ @server&.close
200
+ end
201
+
202
+ def ensure_runtime_directory
203
+ FileUtils.mkdir_p(File.dirname(config[:pid_file]))
204
+ end
205
+
206
+ def write_pid_file(pid)
207
+ File.write(config[:pid_file], "#{pid}\n")
208
+ end
209
+
210
+ def delete_pid_file
211
+ File.delete(config[:pid_file]) if File.exist?(config[:pid_file])
212
+ end
213
+
214
+ def server_process_command
215
+ [RbConfig.ruby, File.expand_path("../exe/open-in-editor-bridge", __dir__), "--serve"]
216
+ end
217
+
218
+ def editor_command
219
+ @env.fetch("OPEN_IN_EDITOR_COMMAND", @env.fetch("EDITOR", "")).to_s
220
+ end
221
+
222
+ def editor_command_args
223
+ Shellwords.split(editor_command)
224
+ end
225
+
226
+ def install_signal_handlers
227
+ trap("INT") { stop_server }
228
+ trap("TERM") { stop_server }
229
+ end
230
+
231
+ def read_request(socket)
232
+ request_line = socket.gets
233
+ return nil if request_line.nil?
234
+
235
+ method, raw_target, = request_line.split(" ")
236
+ return nil if method.nil? || raw_target.nil?
237
+
238
+ path, query = raw_target.split("?", 2)
239
+ params = URI.decode_www_form(query.to_s).to_h
240
+ { method: method, path: path, params: params }
241
+ rescue ArgumentError
242
+ write_json_response(socket, status: 400, payload: { error: "Bad request" })
243
+ nil
244
+ end
245
+
246
+ def consume_headers(socket)
247
+ loop do
248
+ line = socket.gets
249
+ break if line.nil? || line == "\r\n"
250
+ end
251
+ end
252
+
253
+ def respond_to_get_request(socket, path, params)
254
+ case path
255
+ when HEALTH_PATH
256
+ write_json_response(socket, payload: { ok: true })
257
+ when OPEN_IN_EDITOR_PATH
258
+ open_in_editor(socket, params)
259
+ else
260
+ write_json_response(socket, status: 404, payload: { error: "Not found" })
261
+ end
262
+ end
263
+
264
+ def translate_target(target)
265
+ path, location = split_target(target)
266
+ translated_path = path.sub(/\A#{Regexp.escape(config[:container_web_root])}/, config[:host_web_root])
267
+ return translated_path if location.nil?
268
+
269
+ "#{translated_path}:#{location}"
270
+ end
271
+
272
+ def split_target(target)
273
+ decoded_target = URI.decode_www_form_component(target)
274
+ match = decoded_target.match(/\A(.+?):(\d+)(?::(\d+))?\z/)
275
+ return [decoded_target, nil] if match.nil?
276
+
277
+ [match[1], [match[2], match[3]].compact.join(":")]
278
+ end
279
+
280
+ def running_pid(validate_health: false)
281
+ return nil unless File.exist?(config[:pid_file])
282
+
283
+ pid = Integer(File.read(config[:pid_file]).strip)
284
+ return pid if process_running?(pid) && (!validate_health || bridge_healthy?)
285
+
286
+ delete_pid_file
287
+ nil
288
+ rescue ArgumentError
289
+ delete_pid_file
290
+ nil
291
+ end
292
+
293
+ def process_running?(pid)
294
+ Process.kill(0, pid)
295
+ true
296
+ rescue Errno::ESRCH, Errno::EPERM
297
+ false
298
+ end
299
+
300
+ def wait_until_ready(pid)
301
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config[:startup_timeout]
302
+ loop do
303
+ raise StartupError, "Editor bridge failed to start. See #{config[:log_file]}." unless process_running?(pid)
304
+ return if bridge_healthy?
305
+
306
+ raise StartupError, "Editor bridge did not become ready." if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
307
+ sleep 0.05
308
+ end
309
+ end
310
+
311
+ def wait_until_stopped(pid)
312
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + config[:startup_timeout]
313
+ until !process_running?(pid) && !bridge_healthy?
314
+ break if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
315
+
316
+ sleep 0.05
317
+ end
318
+ end
319
+
320
+ def bridge_healthy?
321
+ socket = TCPSocket.new("127.0.0.1", config[:port])
322
+ response = Timeout.timeout(0.25) do
323
+ socket.write("GET /health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
324
+ socket.read
325
+ end
326
+ headers, body = response.split("\r\n\r\n", 2)
327
+ headers&.start_with?("HTTP/1.1 200 ") &&
328
+ headers.lines.any? { |line| line.casecmp("X-Open-In-Editor-Bridge: 1\r\n").zero? } &&
329
+ JSON.parse(body.to_s).fetch("ok") == true
330
+ rescue Errno::ECONNREFUSED, JSON::ParserError, KeyError, Timeout::Error
331
+ false
332
+ ensure
333
+ socket&.close unless socket&.closed?
334
+ end
335
+
336
+ def config
337
+ root = @project_root || @env.fetch("OPEN_IN_EDITOR_PROJECT_ROOT", Dir.pwd)
338
+ {
339
+ port: Integer(@env.fetch("OPEN_IN_EDITOR_BRIDGE_PORT", DEFAULT_PORT)),
340
+ pid_file: File.join(root, "tmp", "open-in-editor-bridge.pid"),
341
+ log_file: File.join(root, "tmp", "open-in-editor-bridge.log"),
342
+ container_web_root: @env.fetch("OPEN_IN_EDITOR_CONTAINER_WEB_ROOT", "/usr/src/web"),
343
+ host_web_root: @env.fetch("OPEN_IN_EDITOR_HOST_WEB_ROOT", File.join(root, "web")),
344
+ startup_timeout: Float(@env.fetch("OPEN_IN_EDITOR_BRIDGE_STARTUP_TIMEOUT", "5")),
345
+ }
346
+ end
347
+ end
348
+
349
+ OpenInEditorBridge.call(*ARGV) if $PROGRAM_NAME == __FILE__
metadata ADDED
@@ -0,0 +1,51 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: open-in-editor-bridge
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Marlen Brunner
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-07-28 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Ruby API and CLI for serving local open-in-editor requests during development.
14
+ email:
15
+ - klondikemarlen@gmail.com
16
+ executables:
17
+ - open-in-editor-bridge
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - LICENSE.txt
22
+ - README.md
23
+ - exe/open-in-editor-bridge
24
+ - lib/open_in_editor_bridge.rb
25
+ - lib/open_in_editor_bridge/version.rb
26
+ homepage: https://github.com/klondikemarlen/open-in-editor-bridge
27
+ licenses:
28
+ - MIT
29
+ metadata:
30
+ bug_tracker_uri: https://github.com/klondikemarlen/open-in-editor-bridge/issues
31
+ source_code_uri: https://github.com/klondikemarlen/open-in-editor-bridge
32
+ post_install_message:
33
+ rdoc_options: []
34
+ require_paths:
35
+ - lib
36
+ required_ruby_version: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '3.2'
41
+ required_rubygems_version: !ruby/object:Gem::Requirement
42
+ requirements:
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ requirements: []
47
+ rubygems_version: 3.5.22
48
+ signing_key:
49
+ specification_version: 4
50
+ summary: Local HTTP bridge for opening container paths in an editor
51
+ test_files: []