rb-portless 0.4.0.dev.20260630.0e2b377 → 0.4.0.dev.20260704.9259a17

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0e0f20f5dbecd72651bb3e1d7509f41ae93d2a901336189d7776b58747008917
4
- data.tar.gz: 90d742ec41737333d3f55a6552505379a8b097373eb95031edd32973f6a42689
3
+ metadata.gz: b9239a4ef5be3c67695e2633bf59fb8a08cbf5624a9bd3d413439529c198884d
4
+ data.tar.gz: f173e9ac7a171ed118cbc7a9df7c6244338a183841f0dfe6a961d42014e6b929
5
5
  SHA512:
6
- metadata.gz: 11ff15a4154575a926025f57192d9f663325b62df8054ba2fe403ad88db33aceebc4ae683082f5e4a6bc70f0ace369658dac8a35cd99e626e8d844cf577252b4
7
- data.tar.gz: 99f4b875811a3f0a6a8c51a588d57d1877de0c722d232916e066654e738e12d47261adf2621b4581c7224932054cf7cbcabff75907e4cda4d421c409a238647d
6
+ metadata.gz: bf329bdf61a50dd27b782b8f2ecf5141d052e986d5f03150bb3587bb2e0eeae61c21c62cfb45a875660de62e46893ec8696f4a56e681b38c7030e61ac5345fab
7
+ data.tar.gz: 3533215489c1e577481fe42a517157a9afecaa84b1f23da6c6e632e8579312e3931636a3cb1c380360433297b1337de627102e1444c8e905fd9adda2d93e126e
data/CHANGELOG.md CHANGED
@@ -4,6 +4,59 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/), and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.4.0]
8
+
9
+ ### Added
10
+
11
+ - **Stale-proxy detection + `proxy restart`.** The proxy daemon outlives gem
12
+ updates, so a `bundle update` could leave last week's code holding :443
13
+ indefinitely. The proxy now stamps its version on every response (the health
14
+ header carries `VERSION` instead of a bare `1`), and `run` compares it with
15
+ the loaded gem: an older proxy prompts `restart the proxy? [Y/n]` (warn-only
16
+ without a TTY); a *newer* proxy warns that the project's gem is the stale
17
+ side. `rb-portless proxy restart` does the stop → wait → start dance manually,
18
+ and the startup banner always prints both sides (`v0.4.0 · proxy v0.4.0`) so a
19
+ drifting daemon is visible at a glance.
20
+
21
+ - **`default_url_options` under portless.** The Rails integration now points
22
+ `config.action_mailer.default_url_options` and the router's default URL
23
+ options at `https://<name>.localhost` whenever the app runs under rb-portless
24
+ (dev only). Mailers and jobs — which build links without a request — stop
25
+ emitting stale `localhost:<port>` URLs, so apps no longer need to hardcode a
26
+ dev host. Untouched when not running under rb-portless.
27
+ - **Action Cable origin allow-listing under portless.** The integration adds the
28
+ portless host and its subdomains to `config.action_cable.allowed_request_origins`
29
+ (dev only), so a WebSocket handshake from `https://<name>.localhost` isn't
30
+ rejected and Cable connects without extra config.
31
+
32
+ ### Fixed
33
+
34
+ - **HTTP/2 cookie splitting corrupted the session.** Browsers speaking HTTP/2
35
+ may send one `cookie` header field per cookie (RFC 9113 §8.2.3); the proxy
36
+ forwarded them as repeated HTTP/1.1 `cookie:` lines, which the backend
37
+ (e.g. Puma) joins with `", "`. Rack then parses that single mangled field and
38
+ every cookie but the first is lost — silently emptying the Rails session and
39
+ breaking CSRF on every form POST. The proxy now concatenates split cookie
40
+ fields into one `"; "`-joined header, as an h2→h1 intermediary must.
41
+ - **WebSockets from HTTP/2 browsers never reached the backend.** Firefox (and
42
+ any client using RFC 8441) opens `wss://` as an h2 *extended CONNECT* with
43
+ `:protocol: websocket`; the proxy forwarded the CONNECT verb raw, which the
44
+ HTTP/1.1 backend rejects as a parse error — Action Cable / Turbo Streams were
45
+ dead through the proxy. The proxy now translates: forwards it as `GET` +
46
+ `Upgrade` with a synthesized `Sec-WebSocket-Key` (extended CONNECT carries no
47
+ nonce, h1 backends demand one), and maps the backend's `101 Switching
48
+ Protocols` back to the `200` h2 requires. Verified end-to-end: an h2 extended
49
+ CONNECT through the old proxy → 400, through the fixed proxy → 200 with
50
+ Action Cable's welcome frame relayed.
51
+ - **A failed second `proxy start` left the live daemon unstoppable.** The
52
+ latecomer overwrote the running daemon's pid/port marker files, then deleted
53
+ them in its own crash cleanup — after which `proxy stop` claimed no proxy was
54
+ running while a (often root-owned) daemon still held the port. A foreground
55
+ start now refuses the port when a proxy already answers on it, cleanup only
56
+ reaps markers the exiting process owns, and `proxy stop` falls back to
57
+ port-owner discovery (re-trying under sudo for a root daemon) when the
58
+ markers are gone.
59
+
7
60
  ## [0.3.1]
8
61
 
9
62
  ### Changed (internal — no behaviour change)
data/README.md CHANGED
@@ -113,7 +113,7 @@ anything, same as portless), and each degrades gracefully if the tool is absent:
113
113
  | --- | --- |
114
114
  | `run <cmd>` | run a dev server through the proxy |
115
115
  | `<name> <cmd>` | shorthand for `run --name <name> <cmd>` |
116
- | `proxy start \| stop` | manage the proxy daemon |
116
+ | `proxy start \| stop \| restart` | manage the proxy daemon (`restart` picks up an updated gem) |
117
117
  | `trust` | install the local CA into the OS trust store |
118
118
  | `service install \| uninstall \| status` | bind the privileged port at boot (launchd/systemd) |
119
119
  | `alias <name> <port> [--force]` | a static route (Docker, Postgres, …); `--remove` to drop it |
@@ -10,8 +10,8 @@ module Portless
10
10
 
11
11
  # rows: ordered [label, value, color] for the reachable URLs (Local,
12
12
  # Network, Public, …); backend is the real 127.0.0.1:port behind the proxy.
13
- def app(rows:, backend_port:)
14
- out = [ "", " #{bold('rb-portless')} #{dim("v#{VERSION}")}", "" ]
13
+ def app(rows:, backend_port:, proxy_version: nil)
14
+ out = [ "", " #{bold('rb-portless')} #{dim(versions(proxy_version))}", "" ]
15
15
  rows.each { |label, value, paint| out << row(label, send(paint || :cyan, value)) }
16
16
  out << row("Backend", dim("127.0.0.1:#{backend_port}"))
17
17
  out << ""
@@ -21,8 +21,8 @@ module Portless
21
21
  end
22
22
 
23
23
  # Multi-app: one row per app (name → URL).
24
- def multi(apps:)
25
- out = [ "", " #{bold('rb-portless')} #{dim("v#{VERSION}")}", "" ]
24
+ def multi(apps:, proxy_version: nil)
25
+ out = [ "", " #{bold('rb-portless')} #{dim(versions(proxy_version))}", "" ]
26
26
  apps.each { |app| out << row(app.name, cyan(app.url)) }
27
27
  out << ""
28
28
  out << " #{dim('ready — press Ctrl-C to stop')}"
@@ -30,6 +30,14 @@ module Portless
30
30
  warn out.join("\n")
31
31
  end
32
32
 
33
+ # Always show both sides of the version handshake — a stale daemon is
34
+ # invisible otherwise (it keeps last week's code in memory across updates).
35
+ def versions(proxy_version)
36
+ return "v#{VERSION}" unless proxy_version
37
+
38
+ "v#{VERSION} · proxy v#{proxy_version}"
39
+ end
40
+
33
41
  def row(label, value) = " #{green('➜')} #{label.to_s.ljust(8)}#{value}"
34
42
 
35
43
  # ── colours (target stderr, where the banner is printed) ──
data/lib/portless/cli.rb CHANGED
@@ -92,9 +92,10 @@ module Portless
92
92
  case args.first
93
93
  when "start"
94
94
  Daemon.start(tls: tls_flag(args), port: int_flag(args, "--port"), foreground: flag?("--foreground"))
95
- when "stop" then Daemon.stop
96
- when nil then command_help("proxy")
97
- else invalid_action!("proxy start|stop")
95
+ when "stop" then Daemon.stop
96
+ when "restart" then Daemon.restart(tls: tls_flag(args), port: int_flag(args, "--port"))
97
+ when nil then command_help("proxy")
98
+ else invalid_action!("proxy start|stop|restart")
98
99
  end
99
100
  end
100
101
 
@@ -282,7 +283,7 @@ module Portless
282
283
  rb-portless <name> <command> run <command> at https://<name>.localhost
283
284
  rb-portless get <name> print a service's URL (--no-worktree)
284
285
  rb-portless alias <name> <port> static route for an unmanaged service
285
- rb-portless proxy start|stop manage the proxy daemon
286
+ rb-portless proxy start|stop|restart manage the proxy daemon
286
287
  rb-portless trust trust the local CA (HTTPS)
287
288
  rb-portless hosts sync|clean manage /etc/hosts (Safari fallback)
288
289
  rb-portless list show active routes
@@ -326,7 +327,8 @@ module Portless
326
327
  flags: [ [ "--force", "overwrite an existing route" ] ],
327
328
  example: "rb-portless alias postgres 5432 # -> https://postgres.localhost" },
328
329
  "proxy" => { summary: "Manage the proxy daemon.",
329
- usage: [ "proxy start [--no-tls] [--port <n>]", "proxy stop" ] },
330
+ usage: [ "proxy start [--no-tls] [--port <n>]", "proxy stop",
331
+ "proxy restart (pick up an updated rb-portless)" ] },
330
332
  "trust" => { summary: "Trust the local CA so HTTPS works without warnings.",
331
333
  usage: [ "trust" ] },
332
334
  "hosts" => { summary: "Manage the /etc/hosts block (Safari / non-.localhost TLDs).",
@@ -12,12 +12,60 @@ module Portless
12
12
 
13
13
  def ensure_running(tls:)
14
14
  port = Health.discover_port
15
- return port if port
15
+ return refresh_stale(port, tls: tls) if port
16
16
 
17
17
  start(tls: tls)
18
18
  Health.discover_port
19
19
  end
20
20
 
21
+ # The daemon outlives gem updates — after a `bundle update` the process on
22
+ # :443 may still be running last week's code. Compare the version it stamps
23
+ # on its responses with ours and offer to restart it; the reverse mismatch
24
+ # (a newer proxy) means this project's gem is the stale side.
25
+ def refresh_stale(port, tls:)
26
+ running = Health.proxy_version(port)
27
+ case version_action(running, VERSION)
28
+ when :restart
29
+ if restart_consented?(running)
30
+ restart(tls: tls, port: port)
31
+ port = Health.discover_port || port
32
+ else
33
+ warn "rb-portless: keeping the v#{running} proxy — run `rb-portless proxy restart` when ready"
34
+ end
35
+ when :update_gem
36
+ warn "rb-portless: the proxy is v#{running} but this project loads rb-portless v#{VERSION} — " \
37
+ "update the gem (`bundle update rb-portless`) so they match"
38
+ end
39
+ port
40
+ end
41
+
42
+ # nil → proxy unreadable or versions equal: leave it alone.
43
+ def version_action(running, current)
44
+ return nil unless running
45
+
46
+ case Gem::Version.new(running) <=> Gem::Version.new(current)
47
+ when -1 then :restart
48
+ when 1 then :update_gem
49
+ end
50
+ rescue ArgumentError
51
+ nil
52
+ end
53
+
54
+ def restart_consented?(running)
55
+ warn "rb-portless: the running proxy is v#{running}; this rb-portless is v#{VERSION}"
56
+ return false unless Privilege.interactive?
57
+
58
+ $stderr.print "rb-portless: restart the proxy to pick up the update? [Y/n] "
59
+ !$stdin.gets.to_s.strip.downcase.start_with?("n")
60
+ end
61
+
62
+ def restart(tls:, port: nil)
63
+ port ||= Health.discover_port
64
+ stop
65
+ wait_until_stopped(port) if port
66
+ start(tls: tls, port: port)
67
+ end
68
+
21
69
  # foreground: become the daemon (binds the port, blocks). Otherwise
22
70
  # orchestrate: elevate if needed, then spawn the detached foreground daemon.
23
71
  def start(tls:, port: nil, foreground: false)
@@ -34,8 +82,12 @@ module Portless
34
82
  end
35
83
 
36
84
  def stop
37
- pid = read_pid
85
+ pid = read_pid || discovered_pid
38
86
  unless pid
87
+ # A proxy answers but we can't see who owns the port (a root daemon
88
+ # whose marker files were lost) — retry the whole stop under sudo.
89
+ return Privilege.reexec_with_sudo([ "proxy", "stop" ]) if Health.discover_port && !Privilege.root?
90
+
39
91
  warn "rb-portless: no proxy is running"
40
92
  return
41
93
  end
@@ -48,6 +100,14 @@ module Portless
48
100
  Privilege.reexec_with_sudo([ "proxy", "stop" ]) unless Privilege.root?
49
101
  end
50
102
 
103
+ # Fallback when the pid marker is missing: whoever listens on the port the
104
+ # proxy answered from (lsof only sees our own processes unless root).
105
+ def discovered_pid
106
+ port = Health.discover_port or return nil
107
+
108
+ PortOwner.listeners(port).find { |pid| pid != Process.pid }
109
+ end
110
+
51
111
  def start_privileged(port:, tls:)
52
112
  unless Privilege.interactive?
53
113
  warn "rb-portless: can't bind :#{port} without a terminal — using :#{Constants::FALLBACK_PROXY_PORT}"
@@ -85,6 +145,17 @@ module Portless
85
145
  true
86
146
  end
87
147
 
148
+ # A restart can't rebind the port until the old daemon has let go of it.
149
+ def wait_until_stopped(port, timeout: 10)
150
+ deadline = monotonic + timeout
151
+ while Health.proxy_running?(port)
152
+ return false if monotonic > deadline
153
+
154
+ sleep 0.2
155
+ end
156
+ true
157
+ end
158
+
88
159
  def default_port(tls) = tls ? Constants::HTTPS_PORT : Constants::HTTP_PORT
89
160
 
90
161
  def read_pid
@@ -16,7 +16,28 @@ module Portless
16
16
  REQUEST = "HEAD / HTTP/1.0\r\nHost: rb-portless.localhost\r\n\r\n"
17
17
 
18
18
  def proxy_running?(port, timeout: 1.0)
19
- probe_tls(port, timeout) || probe_plain(port, timeout)
19
+ !probe(port, timeout).nil?
20
+ end
21
+
22
+ # The running proxy's version, read from the health header it stamps on
23
+ # every response. Nil when no proxy answers on the port.
24
+ def proxy_version(port, timeout: 1.0)
25
+ version_from(probe(port, timeout))
26
+ end
27
+
28
+ # Proxies since 0.4.0 stamp their version as the header value; older ones
29
+ # stamped a bare "1" — report those as "0.0.0" so any release compares newer.
30
+ def version_from(response)
31
+ value = response.to_s[/^#{Regexp.escape(Constants::HEALTH_HEADER)}:\s*(\S+)/i, 1]
32
+ return nil unless value
33
+
34
+ value.match?(/\A\d+\.\d+/) ? value : "0.0.0"
35
+ end
36
+
37
+ # The raw probe response when it's our proxy answering, else nil.
38
+ def probe(port, timeout)
39
+ response = probe_tls(port, timeout) || probe_plain(port, timeout)
40
+ marker?(response) ? response : nil
20
41
  end
21
42
 
22
43
  def probe_tls(port, timeout)
@@ -28,9 +49,9 @@ module Portless
28
49
  ssl.connect
29
50
  ssl.write(REQUEST)
30
51
  # Read timeout too — a port that accepts but never answers must not hang us.
31
- marker?(Timeout.timeout(timeout) { ssl.read(4096) })
52
+ Timeout.timeout(timeout) { ssl.read(4096) }
32
53
  rescue StandardError
33
- false
54
+ nil
34
55
  ensure
35
56
  ssl&.close
36
57
  socket&.close unless ssl
@@ -40,10 +61,10 @@ module Portless
40
61
  Socket.tcp("127.0.0.1", port, connect_timeout: timeout) do |sock|
41
62
  sock.write(REQUEST)
42
63
  sock.close_write
43
- marker?(Timeout.timeout(timeout) { sock.read(4096) })
64
+ Timeout.timeout(timeout) { sock.read(4096) }
44
65
  end
45
66
  rescue StandardError
46
- false
67
+ nil
47
68
  end
48
69
 
49
70
  def marker?(response) = response.to_s.downcase.include?(Constants::HEALTH_HEADER)
@@ -24,7 +24,7 @@ module Portless
24
24
  proxy_port = Daemon.ensure_running(tls: @config.tls)
25
25
  @apps = @config.apps.map { |name, command| start_app(name, command, proxy_port) }
26
26
 
27
- Banner.multi(apps: @apps)
27
+ Banner.multi(apps: @apps, proxy_version: Health.proxy_version(proxy_port))
28
28
  install_signal_handlers
29
29
  Process.waitall
30
30
  ensure
@@ -6,6 +6,7 @@ require "async/http/client"
6
6
  require "async/http/endpoint"
7
7
  require "protocol/http/headers"
8
8
  require "protocol/http/body/buffered"
9
+ require "securerandom"
9
10
 
10
11
  module Portless
11
12
  # The reverse proxy daemon (async-http: HTTP/1.1 + TLS + WebSockets; HTTP/2 in
@@ -30,6 +31,11 @@ module Portless
30
31
 
31
32
  def run
32
33
  State.ensure_dir!
34
+ # A second daemon on the same port would overwrite the live one's marker
35
+ # files, then wipe them on its own EADDRINUSE crash — leaving the survivor
36
+ # unstoppable by `proxy stop`. Refuse before touching any state.
37
+ raise Error, "a proxy is already running on :#{@port}" if Health.proxy_running?(@port)
38
+
33
39
  @certs.ensure_ca! if @tls
34
40
  write_markers
35
41
  install_signal_handlers
@@ -63,7 +69,10 @@ module Portless
63
69
  return error(508, "Proxy loop detected for #{host}.") if hops >= Constants::MAX_PROXY_HOPS
64
70
 
65
71
  response = client_for(route.port).call(build_forward(request, host, hops))
66
- response.headers.add(Constants::HEALTH_HEADER, "1")
72
+ # The h1 backend accepts a WebSocket with 101 Switching Protocols, but
73
+ # HTTP/2 forbids 1xx finals — an extended-CONNECT success is a plain 2xx.
74
+ response.status = 200 if response.status == 101 && request.version == "HTTP/2"
75
+ response.headers.add(Constants::HEALTH_HEADER, VERSION)
67
76
  response
68
77
  rescue StandardError => e
69
78
  error(502, "Backend for #{host} is not responding (#{e.class}).")
@@ -77,16 +86,36 @@ module Portless
77
86
 
78
87
  def build_forward(request, host, hops)
79
88
  headers = Protocol::HTTP::Headers.new
89
+ cookies = []
80
90
  request.headers.each do |key, value|
81
- headers.add(key, value) unless HOP_BY_HOP.include?(key.downcase)
91
+ next if HOP_BY_HOP.include?(key.downcase)
92
+
93
+ # HTTP/2 clients may split the cookie header into one field per cookie
94
+ # (RFC 9113 §8.2.3); an intermediary translating to HTTP/1.1 MUST
95
+ # concatenate them with "; " — otherwise the backend joins the repeated
96
+ # lines with "," and cookie parsing (split on ";") corrupts the values.
97
+ key.downcase == "cookie" ? cookies << value : headers.add(key, value)
82
98
  end
99
+ headers.add("cookie", cookies.join("; ")) unless cookies.empty?
83
100
  headers.set("x-forwarded-host", host.split(":").first)
84
101
  headers.set("x-forwarded-proto", @tls ? "https" : "http")
85
102
  headers.set("x-forwarded-port", @port.to_s)
86
103
  headers.add(HOP_HEADER, (hops + 1).to_s)
87
104
 
105
+ # HTTP/2 WebSockets arrive as extended CONNECT (RFC 8441, :protocol on the
106
+ # request); the HTTP/1.1 equivalent is GET + Upgrade, which the client
107
+ # layer emits from request.protocol. Forwarded raw, the CONNECT verb makes
108
+ # the backend's parser reject the stream. Extended CONNECT also drops
109
+ # Sec-WebSocket-Key (h2 needs no handshake nonce), but an h1 backend
110
+ # refuses an upgrade without one — synthesize it.
111
+ method = request.method
112
+ if method == "CONNECT" && request.protocol
113
+ method = "GET"
114
+ headers.set("sec-websocket-key", SecureRandom.base64(16)) if headers["sec-websocket-key"].nil?
115
+ end
116
+
88
117
  Protocol::HTTP::Request.new(
89
- "http", host, request.method, request.path, request.version,
118
+ "http", host, method, request.path, request.version,
90
119
  headers, request.body, request.protocol
91
120
  )
92
121
  end
@@ -128,7 +157,7 @@ module Portless
128
157
  endpoint = Async::HTTP::Endpoint.parse("http://0.0.0.0:#{Constants::HTTP_PORT}")
129
158
  Async::HTTP::Server.for(endpoint) do |request|
130
159
  host = request_host(request).split(":").first
131
- Protocol::HTTP::Response[302, { "location" => "https://#{host}#{request.path}", Constants::HEALTH_HEADER => "1" }, []]
160
+ Protocol::HTTP::Response[302, { "location" => "https://#{host}#{request.path}", Constants::HEALTH_HEADER => VERSION }, []]
132
161
  end.run
133
162
  rescue StandardError
134
163
  nil # port 80 taken / unavailable — non-fatal.
@@ -142,7 +171,7 @@ module Portless
142
171
  body = Protocol::HTTP::Body::Buffered.wrap("<!doctype html><meta charset=utf-8><title>rb-portless</title>" \
143
172
  "<body style='font:16px system-ui;padding:3rem;max-width:40rem;margin:auto'>" \
144
173
  "<h1>#{status}</h1><p>#{message}</p></body>")
145
- Protocol::HTTP::Response[status, { "content-type" => "text/html; charset=utf-8", Constants::HEALTH_HEADER => "1" }, body]
174
+ Protocol::HTTP::Response[status, { "content-type" => "text/html; charset=utf-8", Constants::HEALTH_HEADER => VERSION }, body]
146
175
  end
147
176
 
148
177
  def write_markers
@@ -155,10 +184,18 @@ module Portless
155
184
  %w[INT TERM].each { |sig| trap(sig) { cleanup; exit(0) } }
156
185
  end
157
186
 
187
+ # Only reap markers we own — a crashing latecomer must never delete the
188
+ # live daemon's pid/port files.
158
189
  def cleanup
190
+ return unless marker_pid == Process.pid
191
+
159
192
  [ State.proxy_pid_file, State.proxy_port_file ].each { |f| File.delete(f) if File.exist?(f) }
160
193
  rescue StandardError
161
194
  nil
162
195
  end
196
+
197
+ def marker_pid
198
+ Integer(File.read(State.proxy_pid_file).strip, exception: false) if File.exist?(State.proxy_pid_file)
199
+ end
163
200
  end
164
201
  end
@@ -5,10 +5,16 @@
5
5
  # gem "rb-portless", require: "portless/rails"
6
6
  #
7
7
  # It auto-detects when the app is being run through `rb-portless run` (via the
8
- # PORTLESS_URL env the runner injects) and *only then* whitelists the matching
9
- # `*.localhost` hosts in development — so Action Dispatch host authorization
10
- # doesn't 403 your named subdomains. Run Rails normally (not under rb-portless)
11
- # and nothing is touched. Lightweight: does NOT load the proxy stack.
8
+ # PORTLESS_URL env the runner injects) and *only then*:
9
+ #
10
+ # * whitelists the matching `*.localhost` hosts in development so Action
11
+ # Dispatch host authorization doesn't 403 your named subdomains, and
12
+ # * points `default_url_options` at the portless URL — so mailers and jobs
13
+ # (which generate links without a request) link to https://<name>.localhost
14
+ # instead of a bare localhost:<random-port>.
15
+ #
16
+ # Run Rails normally (not under rb-portless) and nothing is touched. Lightweight:
17
+ # does NOT load the proxy stack.
12
18
  require "rails/railtie"
13
19
  require_relative "rails_hosts"
14
20
 
@@ -22,5 +28,39 @@ module Portless
22
28
  app.config.hosts << pattern unless app.config.hosts.include?(pattern)
23
29
  end
24
30
  end
31
+
32
+ # Under rb-portless the app is reached at https://<name>.localhost, not the
33
+ # random backend port — so request-less URL generation must target that. We
34
+ # own default_url_options here (authoritatively, when running under portless)
35
+ # rather than leave every app to hardcode a now-wrong localhost:port in
36
+ # development.rb. `config.action_mailer.default_url_options` is applied to the
37
+ # mailer lazily on load, so setting it after the env config still wins.
38
+ initializer "portless.default_url_options" do |app|
39
+ next unless defined?(Rails) && Rails.env.development?
40
+
41
+ options = RailsHosts.url_options or next
42
+
43
+ # The router's defaults cover jobs and any request-less url_for (mailers
44
+ # merge them in too). Set the mailer's own default_url_options via on_load
45
+ # so we win over Rails' earlier action_mailer.set_configs regardless of
46
+ # railtie order.
47
+ app.routes.default_url_options.merge!(options)
48
+ ActiveSupport.on_load(:action_mailer) do
49
+ self.default_url_options = default_url_options.merge(options)
50
+ end
51
+ end
52
+
53
+ # Allow the portless origin (and its subdomains) through Action Cable's
54
+ # WebSocket origin check — otherwise the handshake from https://<name>.
55
+ # localhost is rejected and Cable silently never connects.
56
+ initializer "portless.action_cable_origins" do |app|
57
+ next unless defined?(Rails) && Rails.env.development?
58
+
59
+ origins = RailsHosts.cable_origins
60
+ next if origins.empty?
61
+
62
+ app.config.action_cable.allowed_request_origins ||= []
63
+ app.config.action_cable.allowed_request_origins.concat(origins)
64
+ end
25
65
  end
26
66
  end
@@ -25,5 +25,36 @@ module Portless
25
25
  patterns.push(host, ".#{host}") if host && !host.end_with?(".localhost")
26
26
  patterns
27
27
  end
28
+
29
+ # The `default_url_options` (host + scheme) that mailers, jobs, and other
30
+ # request-less URL generation need so their links point at the portless URL
31
+ # instead of a bare `localhost:<random-port>`. Nil unless we're running
32
+ # under rb-portless. The standard 80/443 ports are dropped (portless serves
33
+ # without a port number); a custom one is carried through.
34
+ def url_options(portless_url = ENV["PORTLESS_URL"])
35
+ return if portless_url.to_s.empty?
36
+
37
+ uri = begin
38
+ URI(portless_url)
39
+ rescue StandardError
40
+ nil
41
+ end
42
+ return unless uri&.host
43
+
44
+ options = { host: uri.host, protocol: uri.scheme }
45
+ options[:port] = uri.port if uri.port && ![ 80, 443 ].include?(uri.port)
46
+ options
47
+ end
48
+
49
+ # Action Cable rejects a WebSocket whose `Origin` isn't in
50
+ # `allowed_request_origins`; under rb-portless that's the portless host and
51
+ # any of its subdomains (tenant hosts), over the portless scheme — not the
52
+ # `localhost` default. Empty unless we're running under rb-portless.
53
+ def cable_origins(portless_url = ENV["PORTLESS_URL"])
54
+ options = url_options(portless_url) or return []
55
+
56
+ host = Regexp.escape(options[:host])
57
+ [ %r{\A#{options[:protocol]}://([\w-]+\.)*#{host}(:\d+)?\z} ]
58
+ end
28
59
  end
29
60
  end
@@ -36,7 +36,7 @@ module Portless
36
36
  rows.concat(lan_rows(port, proxy_port))
37
37
  rows.concat(share_rows(hostname, port))
38
38
  record_share_urls(hostname, port) # so `rb-portless list` shows the public URLs
39
- Banner.app(rows: rows, backend_port: port)
39
+ Banner.app(rows: rows, backend_port: port, proxy_version: Health.proxy_version(proxy_port))
40
40
 
41
41
  status = supervise(command, port, url)
42
42
  exit(status)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Portless
4
- VERSION = "0.4.0.dev.20260630.0e2b377"
4
+ VERSION = "0.4.0.dev.20260704.9259a17"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rb-portless
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0.dev.20260630.0e2b377
4
+ version: 0.4.0.dev.20260704.9259a17
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Afonso
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-30 00:00:00.000000000 Z
11
+ date: 2026-07-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: async