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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +116 -19
- data/lib/open_in_editor_bridge/authentication.rb +29 -0
- data/lib/open_in_editor_bridge/client.rb +172 -0
- data/lib/open_in_editor_bridge/configuration.rb +96 -0
- data/lib/open_in_editor_bridge/docker.rb +151 -0
- data/lib/open_in_editor_bridge/server.rb +209 -0
- data/lib/open_in_editor_bridge/version.rb +1 -1
- data/lib/open_in_editor_bridge/vite.mjs +79 -0
- data/lib/open_in_editor_bridge.rb +40 -313
- metadata +10 -2
|
@@ -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
|
|
@@ -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
|
+
}
|