open-in-editor-bridge 0.1.1 → 0.3.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.
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ class OpenInEditorBridge
6
+ class Docker
7
+ CONTAINER_DIRECTORY = "/open-in-editor-bridge"
8
+ COMPOSE_FILES = %w[compose.yaml compose.yml docker-compose.yaml docker-compose.yml].freeze
9
+ OVERRIDE_FILES = %w[compose.override.yaml compose.override.yml docker-compose.override.yaml docker-compose.override.yml].freeze
10
+
11
+ def initialize(env:, project_root:, service:, compose_options:)
12
+ @env = env.to_h
13
+ @project_root = File.expand_path(project_root || @env.fetch("OPEN_IN_EDITOR_PROJECT_ROOT", Dir.pwd))
14
+ @service = service
15
+ @compose_options = compose_options
16
+ end
17
+
18
+ def call(*args)
19
+ return up(args) if args.first == "up"
20
+
21
+ result = run(args)
22
+ bridge.call("--shutdown") if args.first == "down"
23
+ result
24
+ end
25
+
26
+ private
27
+
28
+ def configuration
29
+ @configuration ||= Configuration.new(env: @env, project_root: @project_root)
30
+ end
31
+
32
+ def bridge
33
+ @bridge ||= OpenInEditorBridge.new(env: @env, project_root: @project_root)
34
+ end
35
+
36
+ def up(args)
37
+ if %w[127.0.0.1 ::1 localhost].include?(configuration.bind_address)
38
+ raise ArgumentError, "Docker editor access requires explicit OPEN_IN_EDITOR_BRIDGE_BIND_ADDRESS (for example 0.0.0.0 on a trusted network)."
39
+ end
40
+ raise ArgumentError, "Select a Compose service for the Vite integration" if @service.to_s.empty?
41
+
42
+ # Resolve Compose inputs before acquiring a lease; invalid setup must not leave a broker behind.
43
+ files = compose_file_options
44
+ with_override do |override|
45
+ options = [*files, "-f", override]
46
+ detached?(args) ? detached_up(args, options) : foreground_up(args, options)
47
+ end
48
+ end
49
+
50
+ def foreground_up(args, options)
51
+ lease = SecureRandom.hex(16)
52
+ bridge.start_session(lease)
53
+ begin
54
+ run(args, options)
55
+ ensure
56
+ release_preserving_error(lease)
57
+ end
58
+ end
59
+
60
+ def detached_up(args, options)
61
+ registration = bridge.start_session("detached")
62
+ run(args, options)
63
+ rescue StandardError
64
+ release_preserving_error("detached") if registration == :started
65
+ raise
66
+ end
67
+
68
+ def release_preserving_error(lease)
69
+ original_error = $!
70
+ bridge.stop_session(lease)
71
+ rescue StandardError
72
+ raise unless original_error
73
+ end
74
+
75
+ def detached?(args)
76
+ flags = args.select { |arg| %w[-d --detach --detach=true --detach=false -d=true -d=false].include?(arg) }
77
+ !flags.empty? && !%w[--detach=false -d=false].include?(flags.last)
78
+ end
79
+
80
+ def run(args, extra_options = [])
81
+ system(@env, "docker", "compose", *@compose_options, *extra_options, *args,
82
+ chdir: @project_root, exception: true)
83
+ end
84
+
85
+ def with_override
86
+ configuration.ensure_runtime_directory
87
+ manifest = write_manifest
88
+ mounts = [
89
+ file_mount(File.join(__dir__, "vite.mjs"), "vite.mjs"),
90
+ file_mount(manifest, "session.json"),
91
+ ]
92
+ override = { "services" => { @service => {
93
+ "extra_hosts" => { "host.docker.internal" => "host-gateway" },
94
+ "volumes" => mounts,
95
+ } } }
96
+ Tempfile.create(["compose-", ".json"], configuration.runtime_directory) do |file|
97
+ file.write(JSON.generate(override))
98
+ file.flush
99
+ yield file.path
100
+ end
101
+ end
102
+
103
+ def file_mount(source, name)
104
+ { "type" => "bind", "source" => File.expand_path(source),
105
+ "target" => "#{CONTAINER_DIRECTORY}/#{name}", "read_only" => true,
106
+ "bind" => { "create_host_path" => false } }
107
+ end
108
+
109
+ def write_manifest
110
+ path = File.join(configuration.runtime_directory, "docker-#{configuration.port}-#{configuration.session_id}.json")
111
+ payload = { "session" => configuration.session_id, "target" => "http://host.docker.internal:#{configuration.port}" }
112
+ # Atomic replacement keeps simultaneous starts from exposing a partial identity file.
113
+ Tempfile.create(["session-", ".json"], configuration.runtime_directory) do |file|
114
+ file.write(JSON.generate(payload))
115
+ file.flush
116
+ file.chmod(0o644)
117
+ File.rename(file.path, path)
118
+ end
119
+ path
120
+ end
121
+
122
+ def compose_file_options
123
+ return [] if @compose_options.any? { |arg| arg == "-f" || arg == "--file" || arg.start_with?("--file=") || arg.match?(/\A-f.+/) }
124
+
125
+ if @env["COMPOSE_FILE"] && !@env["COMPOSE_FILE"].empty?
126
+ separator = @env.fetch("COMPOSE_PATH_SEPARATOR", File::PATH_SEPARATOR)
127
+ return @env.fetch("COMPOSE_FILE").split(separator).flat_map { |path| ["-f", File.expand_path(path, @project_root)] }
128
+ end
129
+
130
+ directory = discovery_directory
131
+ loop do
132
+ base = COMPOSE_FILES.find { |name| File.file?(File.join(directory, name)) }
133
+ if base
134
+ override = OVERRIDE_FILES.find { |name| File.file?(File.join(directory, name)) }
135
+ return [base, override].compact.flat_map { |name| ["-f", File.join(directory, name)] }
136
+ end
137
+ parent = File.dirname(directory)
138
+ raise ArgumentError, "No Compose file found; select it with compose_options: [\"-f\", path]" if parent == directory
139
+ directory = parent
140
+ end
141
+ end
142
+
143
+ def discovery_directory
144
+ @compose_options.each_with_index do |option, index|
145
+ return File.expand_path(@compose_options.fetch(index + 1), @project_root) if option == "--project-directory"
146
+ return File.expand_path(option.delete_prefix("--project-directory="), @project_root) if option.start_with?("--project-directory=")
147
+ end
148
+ @project_root
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "timeout"
5
+ require "uri"
6
+
7
+ class OpenInEditorBridge
8
+ class Server
9
+ RESPONSE_PHRASES = {
10
+ 200 => "OK", 400 => "Bad Request", 403 => "Forbidden", 404 => "Not Found",
11
+ 405 => "Method Not Allowed", 409 => "Conflict", 500 => "Internal Server Error",
12
+ }.freeze
13
+
14
+ def initialize(configuration, token:)
15
+ @configuration = configuration
16
+ @token = token
17
+ # Only this broker mutates sessions; each lease represents one live client.
18
+ @sessions = {}
19
+ # Accepted control nonces cannot be replayed during this broker's lifetime.
20
+ @used_nonces = {}
21
+ end
22
+
23
+ def start(initial_session: nil)
24
+ @listener = TCPServer.new(@configuration.bind_address, @configuration.port)
25
+ @sessions[initial_session.fetch("id")] = initial_session.merge("leases" => ["detached"]) if initial_session
26
+ @configuration.write_state(@token)
27
+ @running = true
28
+ rescue StandardError
29
+ @listener&.close
30
+ raise
31
+ end
32
+
33
+ def serve
34
+ trap("INT") { stop }
35
+ trap("TERM") { stop }
36
+ while @running
37
+ begin
38
+ socket = @listener.accept
39
+ handle_request(socket)
40
+ rescue IOError, Errno::EBADF
41
+ break unless @running
42
+ raise
43
+ end
44
+ end
45
+ ensure
46
+ @listener&.close unless @listener&.closed?
47
+ @configuration.delete_state(expected_pid: Process.pid)
48
+ end
49
+
50
+ private
51
+
52
+ def stop
53
+ @running = false
54
+ @listener.close unless @listener.closed?
55
+ end
56
+
57
+ def handle_request(socket)
58
+ Timeout.timeout(2) do
59
+ method, target, headers, body = read_request(socket)
60
+ path, query = target.split("?", 2)
61
+ params = URI.decode_www_form(query.to_s).to_h
62
+ case path
63
+ when "/health"
64
+ if method == "GET"
65
+ nonce = headers["x-open-in-editor-nonce"]
66
+ if nonce && !Authentication.valid_nonce?(nonce)
67
+ return respond(socket, 400, { "error" => "Invalid health nonce" })
68
+ end
69
+ respond(socket, 200, { "ok" => true, "pid" => Process.pid, "protocol" => Configuration::PROTOCOL,
70
+ "bind_address" => @configuration.bind_address, "sessions" => @sessions.keys },
71
+ nonce: nonce, path: path)
72
+ else
73
+ respond(socket, 405, { "error" => "Method not allowed" })
74
+ end
75
+ when "/__open-in-editor"
76
+ method == "GET" ? open_in_editor(socket, params) : respond(socket, 405, { "error" => "Method not allowed" })
77
+ when "/sessions", "/release"
78
+ control_request(socket, method, path, headers, body)
79
+ else
80
+ respond(socket, 404, { "error" => "Not found" })
81
+ end
82
+ end
83
+ rescue ArgumentError, JSON::ParserError, EOFError => error
84
+ respond(socket, 400, { "error" => "Bad request: #{error.message}" })
85
+ rescue Timeout::Error, Errno::EPIPE, Errno::ECONNRESET
86
+ nil
87
+ ensure
88
+ socket.close unless socket.closed?
89
+ end
90
+
91
+ def read_request(socket)
92
+ line = socket.gets("\n", 8192)
93
+ raise EOFError, "Missing request line" unless line
94
+ method, target, = line.split(" ")
95
+ raise ArgumentError, "Missing request target" unless target
96
+
97
+ headers = {}
98
+ header_bytes = 0
99
+ loop do
100
+ line = socket.gets("\n", 8192)
101
+ raise EOFError, "Incomplete headers" unless line
102
+ header_bytes += line.bytesize
103
+ raise ArgumentError, "Headers too large" if header_bytes > 65_536
104
+ break if line == "\r\n"
105
+
106
+ name, value = line.split(":", 2)
107
+ raise ArgumentError, "Invalid header" unless value
108
+ headers[name.downcase] = value.strip
109
+ end
110
+ length = Integer(headers.fetch("content-length", "0"))
111
+ raise ArgumentError, "Invalid content length" unless (0..65_536).cover?(length)
112
+ body = length.zero? ? "" : socket.read(length)
113
+ raise EOFError, "Incomplete body" unless body && body.bytesize == length
114
+ [method, target, headers, body]
115
+ end
116
+
117
+ def control_request(socket, method, path, headers, body)
118
+ return respond(socket, 405, { "error" => "Method not allowed" }) unless method == "POST"
119
+ nonce = headers["x-open-in-editor-nonce"]
120
+ valid_nonce = Authentication.valid_nonce?(nonce) && !@used_nonces.key?(nonce)
121
+ return respond(socket, 403, { "error" => "Forbidden" }) unless valid_nonce
122
+
123
+ valid_signature = Authentication.valid?(headers["x-open-in-editor-signature"], @token,
124
+ "request", nonce, method, path, body)
125
+ return respond(socket, 403, { "error" => "Forbidden" }) unless valid_signature
126
+
127
+ @used_nonces[nonce] = true
128
+ payload = JSON.parse(body)
129
+ path == "/sessions" ? register(socket, payload, nonce) : release(socket, payload, nonce)
130
+ end
131
+
132
+ def register(socket, payload, nonce)
133
+ session = payload.fetch("session")
134
+ id = session.fetch("id")
135
+ lease = payload.fetch("lease")
136
+ existing = @sessions[id]
137
+ if existing && existing.reject { |key, _| key == "leases" } != session
138
+ return respond(socket, 409, { "error" => "Checkout already registered with different editor configuration" },
139
+ nonce: nonce, path: "/sessions")
140
+ end
141
+ existing ||= @sessions[id] = session.merge("leases" => [])
142
+ added = !existing.fetch("leases").include?(lease)
143
+ existing.fetch("leases") << lease if added
144
+ respond(socket, 200, { "ok" => true, "added" => added }, nonce: nonce, path: "/sessions")
145
+ end
146
+
147
+ def release(socket, payload, nonce)
148
+ id = payload.fetch("session_id")
149
+ session = @sessions[id]
150
+ removed = session && session.fetch("leases").delete(payload.fetch("lease"))
151
+ @sessions.delete(id) if session && session.fetch("leases").empty?
152
+ stopping = !!removed && @sessions.empty?
153
+ respond(socket, 200, { "ok" => true, "stopping" => stopping }, nonce: nonce, path: "/release")
154
+ stop if stopping
155
+ end
156
+
157
+ def open_in_editor(socket, params)
158
+ file = params["file"]
159
+ return respond(socket, 400, { "error" => "Missing required query parameter: file" }) if file.nil? || file.empty?
160
+
161
+ id = params["session"]
162
+ if id.nil?
163
+ return respond(socket, 400, { "error" => "Specify session when multiple checkouts are registered" }) unless @sessions.size == 1
164
+ id = @sessions.keys.first
165
+ end
166
+ session = @sessions[id]
167
+ return respond(socket, 404, { "error" => "Unknown editor session" }) unless session
168
+
169
+ translated_target = translate_target(file, session)
170
+ editor_command = session.fetch("editor_command")
171
+ pid = Process.spawn(*Shellwords.split(editor_command), "--goto", translated_target,
172
+ chdir: session.fetch("working_directory"))
173
+ Process.detach(pid)
174
+ respond(socket, 200, { "ok" => true, "requestedFile" => file, "translatedTarget" => translated_target,
175
+ "editorCommand" => editor_command, "session" => id })
176
+ rescue StandardError => error
177
+ respond(socket, 500, { "ok" => false, "error" => "Failed to open editor: #{error.message}",
178
+ "requestedFile" => file, "translatedTarget" => translated_target, "editorCommand" => editor_command })
179
+ end
180
+
181
+ def translate_target(target, session)
182
+ # Query parameters have already been decoded; literal percent signs are filenames.
183
+ match = target.match(/\A(.+?):(\d+)(?::(\d+))?\z/)
184
+ path = match ? match[1] : target
185
+ location = match ? ":#{[match[2], match[3]].compact.join(":")}" : ""
186
+ root = session.fetch("container_root")
187
+ if path == root || path.start_with?("#{root}/")
188
+ path = "#{session.fetch("host_root")}#{path.delete_prefix(root)}"
189
+ end
190
+ "#{path}#{location}"
191
+ end
192
+
193
+ def respond(socket, status, payload, nonce: nil, path: nil)
194
+ body = JSON.generate(payload)
195
+ signature_header = ""
196
+ if nonce
197
+ signature = Authentication.signature(@token, "response", nonce, status, path, body)
198
+ signature_header = "X-Open-In-Editor-Signature: #{signature}\r\n"
199
+ end
200
+ socket.write("HTTP/1.1 #{status} #{RESPONSE_PHRASES.fetch(status)}\r\n" \
201
+ "Content-Type: application/json\r\nAccess-Control-Allow-Origin: *\r\n" \
202
+ "X-Open-In-Editor-Bridge: #{Configuration::PROTOCOL}\r\n#{signature_header}" \
203
+ "Content-Length: #{body.bytesize}\r\nConnection: close\r\n\r\n#{body}")
204
+ rescue Errno::EPIPE, Errno::ECONNRESET
205
+ # A disconnected HTTP client must not take down other checkout sessions.
206
+ nil
207
+ end
208
+ end
209
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class OpenInEditorBridge
4
- VERSION = "0.1.1"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,79 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const sessionManifestPath = new URL("./session.json", import.meta.url);
4
+ const editorRouteKey = "^/__open-in-editor(?:\\?|$)";
5
+
6
+ function readSession() {
7
+ try {
8
+ const manifest = JSON.parse(readFileSync(sessionManifestPath, "utf8"));
9
+ if (
10
+ !manifest ||
11
+ typeof manifest.session !== "string" ||
12
+ !/^[a-f0-9]{64}$/.test(manifest.session) ||
13
+ typeof manifest.target !== "string"
14
+ ) {
15
+ throw new Error("Invalid checkout identity");
16
+ }
17
+
18
+ const target = new URL(manifest.target);
19
+ if (
20
+ target.protocol !== "http:" ||
21
+ !target.hostname ||
22
+ target.username ||
23
+ target.password ||
24
+ target.pathname !== "/" ||
25
+ target.search ||
26
+ target.hash
27
+ ) {
28
+ throw new Error("Invalid bridge target");
29
+ }
30
+
31
+ return { session: manifest.session, target: target.origin };
32
+ } catch (cause) {
33
+ throw new Error("OpenInEditorBridge requires a valid Docker session manifest; start Vite through OpenInEditorBridge.compose.", { cause });
34
+ }
35
+ }
36
+
37
+ function withSession(requestUrl, session) {
38
+ const queryStart = requestUrl.indexOf("?");
39
+ if (queryStart === -1) return `${requestUrl}?session=${encodeURIComponent(session)}`;
40
+
41
+ const path = requestUrl.slice(0, queryStart);
42
+ const query = requestUrl.slice(queryStart + 1);
43
+ const retained = query.split("&").filter((part) => {
44
+ const separator = part.indexOf("=");
45
+ const rawName = separator === -1 ? part : part.slice(0, separator);
46
+ try {
47
+ return decodeURIComponent(rawName.replace(/\+/g, " ")) !== "session";
48
+ } catch {
49
+ return true;
50
+ }
51
+ });
52
+ retained.push(`session=${encodeURIComponent(session)}`);
53
+ return `${path}?${retained.join("&")}`;
54
+ }
55
+
56
+ export default function openInEditorBridge() {
57
+ const identity = readSession();
58
+
59
+ return {
60
+ name: "open-in-editor-bridge",
61
+ enforce: "post",
62
+ config(config) {
63
+ const existing = { ...config.server?.proxy };
64
+ delete existing["/__open-in-editor"];
65
+ delete existing["^/__open-in-editor$"];
66
+ delete existing[editorRouteKey];
67
+
68
+ config.server ??= {};
69
+ config.server.proxy = {
70
+ [editorRouteKey]: {
71
+ target: identity.target,
72
+ changeOrigin: true,
73
+ rewrite: (requestUrl) => withSession(requestUrl, identity.session),
74
+ },
75
+ ...existing,
76
+ };
77
+ },
78
+ };
79
+ }