copse 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08369533669364d4fdb1d9f8f3b634392c4ac690dceeecd0b236965bccbffa26'
4
- data.tar.gz: b19a227b24020e7564a1789840ae0e0401ad0cef97f9c0f2a5f5d95067de62f6
3
+ metadata.gz: a8272bcb50acd3e7cdd810ec98b4be8d2af79c461b83f001dda7dd406e36dd7f
4
+ data.tar.gz: ab24056f37f69404a7f2182ef1ef2b3045b34ed3cb0b6a33511286b6e5d490c6
5
5
  SHA512:
6
- metadata.gz: 3543263f409eda9b9dd5960250ab2d3a851af4868bad5d5b493a91b91886bacf6b8ebafbe9c5c4d470b19bce7eeab0423c0ee1710971e6a40a023a1299f14088
7
- data.tar.gz: 38792c0ba1a9e9155ba9f11acf9ab7b48823953d11a8e60a1101faef7513735bb547aac492f533d42396a353614a33333f601645dca4398cc910fa4a50786921
6
+ metadata.gz: 41ddd256af9c44ea14c6edd638167d0c1e4540f3c0fe52a2b21b45800ebb272ba81271a03d11329410b6090e981b4e490af4693d67e02f49602a679c7c48dbac
7
+ data.tar.gz: a5e6859a15cba6b1476eea240b92636f79b2744e292688b96f624012f3082b10935357fbbbe7d119a04204a4a389f071628e1e3f334d8122d489e46f4d695adf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 (2026-09-10)
4
+
5
+ - Adds an optional second naming mode: `<branch>.<project>.<machine>.local`,
6
+ published over multicast DNS, instead of `<branch>.<project>.localhost`
7
+ published to nobody. `.localhost` is a *SHOULD* that Safari on macOS ≤ 15 and
8
+ bare-glibc Linux do not honour; `.local` is answered by the mDNS responder every
9
+ desktop and phone already runs, and is answered for every device on the network
10
+ — so the app can be opened from a phone. Opt in per machine with
11
+ `COPSE_ZEROCONF=1`, or for a team with `Copse.start(zeroconf: true)`.
12
+ One trade to know about: `.local` is not a secure context and `.localhost` is,
13
+ so service workers, `getUserMedia`, geolocation, WebAuthn, `crypto.subtle` and
14
+ the rest of the secure-context features stop working under it. See the README.
15
+ - The derived port is now a function of the `.localhost` hostname whatever mode is
16
+ in use, so turning zeroconf naming on moves no port — not for the developer
17
+ turning it on, and not against teammates who have not. Nothing changes for an
18
+ app on `.localhost`.
19
+ - Exports `BINDING=0.0.0.0` and appends the advertised name to
20
+ `RAILS_DEVELOPMENT_HOSTS`, on the zeroconf path only. A `.local` name resolves
21
+ to this machine's network address rather than loopback, and Rails' development
22
+ host allowlist covers `.localhost` and `.test` but not `.local`. An inherited
23
+ `BINDING` wins, as does an explicit `-b` or a `bind` in `config/puma.rb`.
24
+ - `COPSE_SUBDOMAINS=jane,peter` (or `subdomains:`) publishes extra names under the
25
+ app's own, for apps that serve one subdomain per tenant. Under `.localhost`
26
+ every label resolves for free; mDNS answers only for names something announced.
27
+ - The names are withdrawn when the session ends. Under Overmind, which replaces
28
+ Copse's process, the advertiser is forked into a process that watches Overmind's
29
+ pid rather than being kept in a thread that the `exec` would destroy.
30
+ - The `zeroconf` gem (>= 1.2.0) stays an optional dependency and is required only
31
+ when the mode is asked for. A machine without it says so in one line and boots
32
+ on `.localhost`, the same fallback as a teammate without Overmind — and since
33
+ the port does not move, the fallback costs the name and nothing else.
34
+
3
35
  ## 0.1.0 (2026-07-24)
4
36
 
5
37
  First release.
data/README.md CHANGED
@@ -53,12 +53,16 @@ Copse runs `web` in the foreground, holding your terminal's stdin and process gr
53
53
 
54
54
  `<project>` is always the **main** worktree's directory name, so `~/code/cora-fix-billing` on `fix-billing` is `fix-billing.cora.localhost`, not `cora-fix-billing.cora.localhost`.
55
55
 
56
+ The suffix is the one thing here you can change: `.localhost` resolves on the machine and nowhere else, and [not on every client](#where-localhost-resolves). [Zeroconf names](#zeroconf-local-names) swap it for `<machine>.local` and put the app on the network.
57
+
56
58
  Names reduce to one DNS label: lowercased, anything outside `[a-z0-9]` collapsed to `-`, capped at 63 characters. `feat/billing-v2` → `feat-billing-v2`. It's a whitelist, not a substitution — git permits `;`, `$(`, and quotes in branch names, and the result reaches your environment and a generated Procfile.
57
59
 
58
60
  ## Ports
59
61
 
60
62
  `CRC32(hostname)` indexed into `3000..9999` minus 25 well-known service ports. A pure function: same hostname, same port, on every machine and across reboots and Ruby versions, with no stored state and no coordination.
61
63
 
64
+ Always the `.localhost` hostname, even when the app is published under another suffix. Switching a machine to [zeroconf names](#zeroconf-local-names) is about names, so it must not move a port your bookmarks, your OAuth callback registrations, and your teammates still on `.localhost` all point at.
65
+
62
66
  Uncoordinated means collisions are possible. Measured over 20,000 seeded trials against 6,975 available ports:
63
67
 
64
68
  | Worktrees at once | Chance two share a port |
@@ -81,6 +85,8 @@ Changing the range or the reserved list would move nearly every derived port, so
81
85
  | `PORT` | the derived port — **`web` process only** |
82
86
  | `VITE_RUBY_PORT` | the derived companion port |
83
87
  | `COPSE_DATABASE_SUFFIX` | `fix_billing` — **linked worktrees only**, actively unset in a main one |
88
+ | `BINDING` | `0.0.0.0` — **[zeroconf names](#zeroconf-local-names) only**, and never over one you set |
89
+ | `RAILS_DEVELOPMENT_HOSTS` | `.cora.thicc.local` — **zeroconf names only**, appended to any you set |
84
90
 
85
91
  `COPSE_DATABASE_SUFFIX` is exported for your processes to read; Copse never reads it back. In a main worktree it's *removed* from the child environment rather than merely left unset, so a copy inherited from another worktree's session can't be believed. And the rename below is derived from the checkout on disk, so no stale copy of this variable — a shell opened from a linked worktree, a leftover line in `.env` — can rename a main worktree's database.
86
92
 
@@ -176,15 +182,96 @@ For a ❌ row, add a hosts entry — with two caveats:
176
182
 
177
183
  Neither glibc nor the macOS resolver supports wildcards, so that's **one line per hostname, meaning one per branch**. And it does nothing for Chrome, which hardcodes `.localhost` and [ignores your hosts file](https://issues.chromium.org/issues/41175806) for these names.
178
184
 
179
- On macOS ≤ 15 or bare-glibc Linux and need Safari or `curl`? puma-dev's resolver approach doesn't have this gap. That's a real reason to prefer it.
185
+ On macOS ≤ 15 or bare-glibc Linux and need Safari or `curl`? Either use zeroconf names below, or puma-dev, whose resolver approach doesn't have this gap.
186
+
187
+ ## Zeroconf (`.local`) names
188
+
189
+ Two reasons to want this:
190
+
191
+ - **Other devices reach the app by name.** Your phone, a tablet, a real iOS Safari, a VM running end-to-end tests — all of them resolve it, with no `/etc/hosts` entry on any of them. This is the only way to check a mobile layout, or a JS-heavy page on a slow phone, on the actual device.
192
+ - **Clients that don't resolve `.localhost` at all.** Safari and `curl` on macOS before 26 (Tahoe), and bare-glibc Linux — see [the table above](#where-localhost-resolves). On those, `.localhost` isn't a preference, it's a dead end.
193
+
194
+ `.localhost` is a *SHOULD* that each client decides to honour or not. `.local` is the opposite: [RFC 6762](https://www.rfc-editor.org/rfc/rfc6762) reserves it for multicast DNS, and every desktop and phone already runs a responder for it. Nothing has to special-case the name, because the name gets *answered*.
195
+
196
+ So Copse can publish the derived hostname over mDNS instead — the technique from [Julik Tarkhanov's write-up](https://blog.julik.nl/2025/05/dev-subdomains-with-zeroconf), using [tenderlove's `zeroconf`](https://github.com/tenderlove/zeroconf) gem:
197
+
198
+ ```ruby
199
+ group :development do
200
+ gem "copse"
201
+ gem "zeroconf" # only this mode needs it; Copse itself has no dependencies
202
+ end
203
+ ```
204
+
205
+ ```sh
206
+ COPSE_ZEROCONF=1 bin/dev
207
+ ```
208
+
209
+ ```
210
+ ~/code/cora on main → http://cora.thicc.local:5368
211
+ ~/code/cora-fix-billing on fix-billing → http://fix-billing.cora.thicc.local:4783
212
+ ```
213
+
214
+ **The ports are the same ones.** Only the suffix moved: `.localhost` became `<machine>.local`, where `thicc` is this machine's own Bonjour name.
215
+
216
+ That machine label is not decoration. mDNS is a shared namespace, so two people on one network working on one repository would otherwise announce the same `fix-billing.cora.local` and whoever spoke last would win — your browser would land on your colleague's laptop.
217
+
218
+ ### Opting in
219
+
220
+ `COPSE_ZEROCONF=1` in your shell profile, because which naming mode you need is a property of your machine, not of the repository: same branch, Chrome on Linux is fine with `.localhost` and Safari on macOS 15 is not. `bin/dev` is committed and shared; this doesn't touch it.
221
+
222
+ For a whole team, put it in `bin/dev` instead:
223
+
224
+ ```ruby
225
+ exit Copse.start(zeroconf: true)
226
+ ```
227
+
228
+ Either way, a machine without the `zeroconf` gem installed says so in one line and boots on `.localhost` — same fallback as a teammate without Overmind. The port doesn't move, so the fallback costs you the name and nothing else.
229
+
230
+ ### What changes
231
+
232
+ - **The app binds `0.0.0.0`**, via `BINDING`. A `.local` name resolves to this machine's address *on the network*, so a server on loopback would answer at an address the name never points at — unreachable from your phone, and unreachable from your own browser under that name. An inherited `BINDING`, an explicit `-b` on the `web` line, or a `bind` in `config/puma.rb` all still win.
233
+ - **`RAILS_DEVELOPMENT_HOSTS` gets `.<host>`.** Rails' development allowlist is `.localhost`, `.test`, and the two IP ranges — which match address literals, not names — so without this every request under the advertised name gets a 403. Anything you already set is kept. Two limits, both Rails': the leading dot covers the host and **one** subdomain level, so `jane.cora.thicc.local` passes and `deep.jane.cora.thicc.local` does not; and an app that *assigns* `config.hosts` in `config/environments/development.rb` replaces the list this was appended to. Appending (`config.hosts <<`) is fine.
234
+ - **Everything else is unchanged**: the port, the database suffix, `default_url_options`, foreman, Overmind.
235
+
236
+ ### Being on the network
237
+
238
+ Reachable by every device is the feature, and it is also the cost: someone browsing Bonjour services on the same café Wi-Fi can see the app and connect to it. Fine on a network you trust; think twice on one you don't.
239
+
240
+ Advertising stops when the session does, including the goodbye packets that withdraw the names. Under Overmind — which replaces Copse's process, so threads can't do it — the advertiser is forked into a process that watches Overmind's pid and withdraws the names when it exits.
241
+
242
+ ### Several subdomains at once
243
+
244
+ Under `.localhost` every label resolves for free. mDNS answers only for names something announced, so an app serving one subdomain per tenant has to name them:
245
+
246
+ ```sh
247
+ COPSE_SUBDOMAINS=jane,peter,tom COPSE_ZEROCONF=1 bin/dev
248
+ ```
249
+
250
+ ```
251
+ jane.cora.thicc.local peter.cora.thicc.local tom.cora.thicc.local
252
+ ```
253
+
254
+ All on the same port, all covered by the `RAILS_DEVELOPMENT_HOSTS` entry above. `Copse.start(zeroconf: true, subdomains: %w[jane peter tom])` is the committed form.
255
+
256
+ ### When it doesn't work
257
+
258
+ - **`.local` is not a secure context, and `.localhost` is.** An origin is [potentially trustworthy](https://w3c.github.io/webappsec-secure-contexts/#is-origin-trustworthy) if it is HTTPS, a loopback address, or a host named `localhost` or ending in `.localhost`. `http://cora.thicc.local` is none of those, so everything [gated on a secure context](https://developer.mozilla.org/en-US/docs/Web/Security/Secure_Contexts/features_restricted_to_secure_contexts) turns off: service workers, `getUserMedia`, geolocation, WebAuthn and passkeys, `crypto.subtle`, the async clipboard API, Web Bluetooth/USB/Serial. That bites hardest exactly where this mode is most useful — camera and passkeys are half the reason you wanted the phone. Nothing short of HTTPS fixes it, which is [out of scope](#notes) here; on desktop Chrome you can list the origin in `chrome://flags/#unsafely-treat-insecure-origin-as-secure`, and mobile Safari has no such escape hatch. **If a feature works on `.localhost` and dies on `.local`, this is why** — and switching back is one environment variable.
259
+ - **Multicast has to cross your network.** Most do; some routers bridging Wi-Fi and Ethernet drop it.
260
+ - **Two interfaces, one machine.** The gem announces on the first interface the system returns — on a Mac, the top of Service Order in System Settings. Wired and wireless on different networks means the name may be announced to the one you're not browsing from. Copse prints the address it announced on; if that isn't the one you expect, that's the reason.
261
+ - **On Linux, announcing and resolving are separate installs.** Copse announces fine anywhere a multicast packet can leave the machine — **verified here** in a bare Debian container, which has no responder of its own. Whether the *host* then resolves `.local` is up to it: macOS always does, and Linux needs `avahi` with `nss-mdns` in `/etc/nsswitch.conf`, or `systemd-resolved` with `MulticastDNS=yes`. Without one, the name is answered on the wire and `getaddrinfo` still says no — **verified here**, same container. Other devices on the network are unaffected; this is only about the machine running the app.
262
+ - **`.local` as a corporate search domain.** Windows-era networks sometimes configure it, and those DNS servers then compete with mDNS for the same names.
263
+ - **Nothing to announce on.** With no multicast-capable interface up, Copse says so and boots anyway — the app is still on its port.
264
+
265
+ `dns-sd -B _http._tcp` lists what's being advertised, and the app appears there under a dotless instance name (`fix-billing-cora-thicc-local`) because mDNS instance names can't contain dots. The hostname keeps them.
180
266
 
181
267
  ## Requirements
182
268
 
183
269
  - Ruby >= 3.2.0 (Rails 8.1's own floor; note 3.2 reached EOL 2026-04-01)
184
270
  - Rails 7.1+ for the generator and URL options — the derivation itself needs neither Rails nor git
185
271
  - `foreman` >= 0.90.0, only for apps with non-`web` Procfile entries — or Overmind instead, on the `--process-manager=overmind` path
272
+ - `zeroconf` >= 1.2.0, only for [zeroconf names](#zeroconf-local-names) — 1.2.0 is where `instance_name:` arrived, without which a hostname containing dots cannot be advertised at all
186
273
 
187
- Rails 8's development host allowlist already includes `.localhost`, so no `config.hosts` change is needed.
274
+ Rails 8's development host allowlist already includes `.localhost`, so no `config.hosts` change is needed. Zeroconf names are not in it; Copse passes those through `RAILS_DEVELOPMENT_HOSTS` instead.
188
275
 
189
276
  ## Notes
190
277
 
data/lib/copse/session.rb CHANGED
@@ -26,10 +26,11 @@ module Copse
26
26
 
27
27
  attr_reader :worktree, :root
28
28
 
29
- def initialize(worktree, root: nil, out: $stdout)
29
+ def initialize(worktree, root: nil, out: $stdout, advertiser: nil)
30
30
  @worktree = worktree
31
31
  @root = File.expand_path(root || worktree.root)
32
32
  @out = out
33
+ @advertiser = advertiser
33
34
  end
34
35
 
35
36
  # Boots the app and returns the foreground process's exit status.
@@ -46,6 +47,11 @@ module Copse
46
47
  # used to escape the handler entirely -- which surfaced as a bogus "cannot run
47
48
  # foreman" error rather than a clean interrupt.
48
49
  guarded do
50
+ # Inside the guard, not before it: the announcement opens sockets and
51
+ # starts threads, and a signal arriving during that has to reach the
52
+ # teardown that withdraws the names again.
53
+ @advertiser&.start
54
+
49
55
  # No secondaries means no foreman -- not even a preflight. The Procfile the
50
56
  # install generator writes has only a `web` line, so preflighting a tool
51
57
  # this run will never use would refuse to boot the most common app. Running
@@ -148,6 +154,11 @@ module Copse
148
154
  # foreman up front means its own 5s SIGTERM-to-SIGKILL escalation runs in
149
155
  # parallel with the web process shutting down.
150
156
  def teardown
157
+ # First, and before anything is waited on: withdrawing a name is a packet,
158
+ # not a wait, and the reaping below can take seconds. A name left announced
159
+ # after the port stops answering is a name that resolves to a closed door,
160
+ # cached for its TTL on every device that heard it.
161
+ stop_advertising
151
162
  signal_foreman
152
163
  terminate_web
153
164
  reap_foreman
@@ -155,6 +166,12 @@ module Copse
155
166
  restore_term_trap
156
167
  end
157
168
 
169
+ def stop_advertising
170
+ @advertiser&.stop
171
+ rescue StandardError => e
172
+ @out.puts "copse: could not stop advertising cleanly: #{e.message} (#{e.class})"
173
+ end
174
+
158
175
  def terminate_web
159
176
  return if @web_pid.nil?
160
177
 
@@ -272,6 +289,10 @@ module Copse
272
289
  @out.puts overmind_web_position_warning if web_out_of_position?
273
290
  @out.puts overmind_port_flag_warning if web_port_flag?
274
291
 
292
+ # Threads do not survive the exec below, so the advertiser is forked into a
293
+ # process that watches this pid instead. See Advertiser#fork_watching_parent.
294
+ @advertiser&.fork_watching_parent
295
+
275
296
  # Matches the `chdir: root` both foreman and the foreground web spawn use, but
276
297
  # for a different reason. The processes themselves are fine either way --
277
298
  # Overmind takes their working directory from the Procfile's own directory, so
@@ -351,6 +372,32 @@ module Copse
351
372
  "COPSE_URL" => worktree.url,
352
373
  "VITE_RUBY_PORT" => worktree.companion_port.to_s,
353
374
  "COPSE_DATABASE_SUFFIX" => worktree.database_suffix
375
+ }.merge(reachability_env)
376
+ end
377
+
378
+ # What an advertised name needs on top of a derived one, and nothing when
379
+ # there is no advertised name.
380
+ #
381
+ # A `.local` name resolves to this machine's address on the network, not to
382
+ # loopback. `rails server` binds `localhost` in development, so without
383
+ # BINDING the app would answer only on an address the advertised name does not
384
+ # point at -- unreachable from a phone, and unreachable from this machine's own
385
+ # browser under that name. An inherited BINDING wins, as does an explicit `-b`
386
+ # on the `web` line or a `bind` in config/puma.rb: Copse sets the default here,
387
+ # it does not take the decision away.
388
+ #
389
+ # Rails' development host allowlist covers `.localhost` and `.test`, not
390
+ # `.local`, so every request under the advertised name would be blocked. The
391
+ # leading dot also covers one level of subdomain, which is exactly the shape
392
+ # `subdomains` publishes.
393
+ def reachability_env
394
+ return {} if @advertiser.nil?
395
+
396
+ allowed = [ENV["RAILS_DEVELOPMENT_HOSTS"], ".#{worktree.host}"]
397
+
398
+ {
399
+ "BINDING" => ENV["BINDING"] || "0.0.0.0",
400
+ "RAILS_DEVELOPMENT_HOSTS" => allowed.reject { |value| value.to_s.strip.empty? }.join(",")
354
401
  }
355
402
  end
356
403
 
data/lib/copse/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Copse
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -11,24 +11,44 @@ module Copse
11
11
  class Worktree
12
12
  LABEL_LIMIT = 63
13
13
 
14
- attr_reader :root
14
+ # RFC 6761's own loopback TLD, and the default: a name under it needs no
15
+ # advertising, no listener, and no /etc/hosts entry on a client that honours
16
+ # it. Copse::Zeroconf swaps in `<machine>.local` for the clients that do not.
17
+ LOCALHOST_DOMAIN = "localhost"
15
18
 
16
- def initialize(root = Dir.pwd)
19
+ attr_reader :root, :domain
20
+
21
+ def initialize(root = Dir.pwd, domain: LOCALHOST_DOMAIN)
17
22
  @root = File.expand_path(root)
23
+ @domain = domain
18
24
  end
19
25
 
20
- # `<project>.localhost` for a main worktree, `<branch>.<project>.localhost`
21
- # for a linked one.
26
+ # `<project>.<domain>` for a main worktree, `<branch>.<project>.<domain>` for
27
+ # a linked one.
22
28
  def host
23
- @host ||= [slug, project, "localhost"].compact.join(".")
29
+ @host ||= [slug, project, domain].compact.join(".")
30
+ end
31
+
32
+ # The name the port is derived from, which is the `.localhost` one whatever
33
+ # domain the app is published under.
34
+ #
35
+ # Deriving from `host` instead would make the port a function of the naming
36
+ # mode, and both directions of that are wrong. A developer switching their own
37
+ # machine to zeroconf naming would find every port moved -- bookmarks, OAuth
38
+ # callback registrations, a `-p` typed into a script -- for a change that is
39
+ # supposed to be about names. And the zeroconf name carries the *machine's*
40
+ # name, so every teammate would derive a different port for the same branch,
41
+ # which is the coordination-free promise (R4) going the wrong way.
42
+ def canonical_host
43
+ @canonical_host ||= [slug, project, LOCALHOST_DOMAIN].compact.join(".")
24
44
  end
25
45
 
26
46
  def port
27
- @port ||= Copse.port_for(host)
47
+ @port ||= Copse.port_for(canonical_host)
28
48
  end
29
49
 
30
50
  def companion_port
31
- @companion_port ||= Copse.companion_port_for(host)
51
+ @companion_port ||= Copse.companion_port_for(canonical_host)
32
52
  end
33
53
 
34
54
  def url
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Copse
6
+ # An optional second naming mode, for machines where `.localhost` does not
7
+ # resolve.
8
+ #
9
+ # `.localhost` is a *SHOULD* (RFC 6761 §6.3), so whether a name under it
10
+ # resolves is up to each client -- Safari and `curl` on macOS 15 and earlier do
11
+ # not, and neither does bare glibc. `.local` is the opposite: RFC 6762 reserves
12
+ # it for multicast DNS, and every desktop and phone OS already runs a responder
13
+ # for it. Nothing has to special-case the name, because the name is *answered*.
14
+ #
15
+ # The trade is that an answer has to come from somewhere. Under `.localhost`
16
+ # Copse advertises nothing and binds nothing; here it publishes the derived
17
+ # hostname over mDNS for as long as the session runs, and the server has to bind
18
+ # a real interface rather than loopback -- so the app is reachable by every
19
+ # device on the network, phones included. That is the feature (it is how you
20
+ # open the app on a phone) and the cost (it is on the network).
21
+ #
22
+ # See https://blog.julik.nl/2025/05/dev-subdomains-with-zeroconf for the
23
+ # technique this implements.
24
+ module Zeroconf
25
+ # RFC 6762's TLD. Not configurable: a name outside `.local` is not a name
26
+ # multicast DNS will answer for.
27
+ TLD = "local"
28
+
29
+ # The DNS-SD service type a web UI advertises -- the same one a printer's
30
+ # admin page uses, which is what makes the app show up in `dns-sd -B _http._tcp`
31
+ # and in Bonjour browsers.
32
+ SERVICE = "_http._tcp.#{TLD}.".freeze
33
+
34
+ # Accepted as "yes" in COPSE_ZEROCONF. Anything else, including `0` and an
35
+ # empty value, is no: the variable is likely to end up in a shell profile, and
36
+ # `COPSE_ZEROCONF=0` must be a way to turn it off rather than a way to spell
37
+ # "any value is truthy".
38
+ TRUTHY = %w[1 true yes on].freeze
39
+
40
+ # Whether zeroconf naming was asked for. An explicit argument -- `bin/dev`
41
+ # passing `zeroconf: true` -- always wins; otherwise the environment decides.
42
+ #
43
+ # The environment matters more than it looks. `bin/dev` is committed and
44
+ # shared, but which naming mode a developer needs is a property of *their
45
+ # machine*: same repo, same branch, Chrome on Linux is fine with `.localhost`
46
+ # and Safari on macOS 15 is not. COPSE_ZEROCONF is how one teammate opts in
47
+ # from their shell profile without changing the file everyone else runs.
48
+ def self.requested?(flag = nil, env: ENV)
49
+ return !!flag unless flag.nil?
50
+
51
+ TRUTHY.include?(env["COPSE_ZEROCONF"].to_s.strip.downcase)
52
+ end
53
+
54
+ # Whether the optional dependency is installed. Copse declares no runtime
55
+ # dependencies, so this is a require rather than a constant check.
56
+ def self.available?
57
+ require "zeroconf"
58
+ true
59
+ rescue LoadError
60
+ false
61
+ end
62
+
63
+ # `<machine>.local` -- the apex the derived name is published under.
64
+ #
65
+ # The machine's own name is in there deliberately. Two developers on one
66
+ # network working on one repository derive the same `<branch>.<project>`, and
67
+ # mDNS is a shared namespace: without this, whoever announced last would own
68
+ # the name and the other's browser would land on a colleague's laptop.
69
+ def self.domain(hostname = Socket.gethostname)
70
+ "#{machine(hostname)}.#{TLD}"
71
+ end
72
+
73
+ # The machine's name as one DNS label. `Socket.gethostname` already returns
74
+ # `thicc.local` on a Mac, so only the first label is kept -- appending `.local`
75
+ # to a name that ends in it would publish `thicc.local.local`.
76
+ def self.machine(hostname = Socket.gethostname)
77
+ Worktree.slug(hostname.to_s.split(".").first) || "workstation"
78
+ end
79
+
80
+ # Extra names to publish alongside the app's own, for apps that serve several
81
+ # subdomains (one per tenant, per site, per brand). Under `.localhost` these
82
+ # cost nothing -- the resolver answers for every label -- but mDNS answers only
83
+ # for names something announced, so each one has to be named here.
84
+ #
85
+ # Slugged through the same whitelist as a branch name: these arrive from an
86
+ # environment variable and end up in a DNS record.
87
+ def self.subdomains(list = nil, env: ENV)
88
+ values = list || env["COPSE_SUBDOMAINS"].to_s.split(",")
89
+ Array(values).filter_map { |value| Worktree.slug(value) }.uniq
90
+ end
91
+
92
+ # The full set of names to advertise: the app's own, plus one per subdomain.
93
+ def self.hostnames(host, subdomains = [])
94
+ [host, *subdomains.map { |subdomain| "#{subdomain}.#{host}" }]
95
+ end
96
+
97
+ # Publishes hostnames over multicast DNS for as long as the session runs, and
98
+ # withdraws them when it ends.
99
+ #
100
+ # Each name is a separate announcement on its own thread, because that is the
101
+ # shape the zeroconf gem offers: `Service#start` binds a socket and loops
102
+ # answering queries until told to stop. The threads are pure IO -- they hold no
103
+ # lock the web process cares about and spend their lives in `IO.select` -- so
104
+ # they sit alongside the foreground `Process.waitpid` without contending with
105
+ # it.
106
+ class Advertiser
107
+ # How long `start` waits for the announcements to actually go out. Only a
108
+ # bound on a callback the gem fires once its socket is open; the normal case
109
+ # is milliseconds. Waiting at all is what makes `stop` reliable -- a service
110
+ # that has not started yet ignores it.
111
+ START_TIMEOUT = 2
112
+
113
+ # How long `stop` waits for a thread to leave its `IO.select` before killing
114
+ # it. Either way the thread's `ensure` sends the goodbye packet that
115
+ # withdraws the name; this only decides whether it does so on request.
116
+ STOP_TIMEOUT = 2
117
+
118
+ # How often the forked advertiser checks whether the process it was forked
119
+ # from is still alive. See `fork_watching_parent`.
120
+ PARENT_POLL = 1
121
+
122
+ attr_reader :hostnames, :port
123
+
124
+ def initialize(hostnames, port:, out: $stdout)
125
+ @hostnames = Array(hostnames).uniq
126
+ @port = port
127
+ @out = out
128
+ @services = []
129
+ @threads = []
130
+ end
131
+
132
+ # Returns whether anything is being advertised. False is not fatal anywhere:
133
+ # the app still boots and still answers on its port, it just cannot be
134
+ # reached by name.
135
+ def start
136
+ return false unless Zeroconf.available?
137
+
138
+ interfaces = ::ZeroConf.service_interfaces
139
+ if interfaces.empty?
140
+ @out.puts "copse: no multicast-capable network interface is up, so #{@hostnames.first} " \
141
+ "is not being advertised. The app is still on port #{port}."
142
+ return false
143
+ end
144
+
145
+ announced = Queue.new
146
+ @hostnames.each { |hostname| advertise(hostname, interfaces, announced) }
147
+ await(announced)
148
+ # Every announcement died on the spot -- each one has already said why.
149
+ return false unless advertising?
150
+
151
+ @out.puts "=> Copse: advertising #{@hostnames.join(', ')} over mDNS on " \
152
+ "#{interfaces.map { |iface| iface.addr.ip_address }.join(', ')}"
153
+ true
154
+ end
155
+
156
+ def stop
157
+ @services.each do |service|
158
+ service.stop
159
+ rescue StandardError
160
+ # Never started, or already stopped. Either way there is nothing to
161
+ # withdraw.
162
+ end
163
+
164
+ @threads.each { |thread| thread.kill unless thread.join(STOP_TIMEOUT) }
165
+ @services.clear
166
+ @threads.clear
167
+ end
168
+
169
+ def advertising? = @threads.any?(&:alive?)
170
+
171
+ # Advertises from a forked child that outlives this process, for the Overmind
172
+ # path -- which `exec`s, and threads do not survive an `exec`.
173
+ #
174
+ # The child watches its parent rather than being signalled by it, because
175
+ # after the `exec` there is no Copse left to do the signalling. `exec`
176
+ # preserves the pid, so the pid the child remembers is Overmind's; when
177
+ # Overmind exits the child is reparented and `Process.ppid` changes, which is
178
+ # the cue to withdraw the names and go. Ctrl-C gets there first in the usual
179
+ # case: the child shares the terminal's foreground process group, so it takes
180
+ # the same SIGINT, unwinds, and sends its goodbye packets.
181
+ #
182
+ # Returns the child's pid, or nil if this platform cannot fork.
183
+ def fork_watching_parent
184
+ return nil unless Process.respond_to?(:fork)
185
+
186
+ parent = Process.pid
187
+ fork do
188
+ Signal.trap("TERM") { raise Interrupt }
189
+ begin
190
+ sleep PARENT_POLL while Process.ppid == parent if start
191
+ rescue Interrupt
192
+ # Ctrl-C, or the SIGTERM converted above.
193
+ ensure
194
+ stop
195
+ end
196
+ end
197
+ end
198
+
199
+ # mDNS service instance names may not contain dots -- the gem raises on one --
200
+ # so the hostname is flattened into a single label. This is the name that
201
+ # shows up in `dns-sd -B _http._tcp` and in Bonjour browsers; the hostname
202
+ # itself keeps its dots and is what a browser is pointed at.
203
+ def self.instance_name(hostname)
204
+ hostname.tr(".", "-")[0, Worktree::LABEL_LIMIT]
205
+ end
206
+
207
+ private
208
+
209
+ def advertise(hostname, interfaces, announced)
210
+ service = ::ZeroConf::Service.new(
211
+ SERVICE, port, hostname,
212
+ instance_name: self.class.instance_name(hostname),
213
+ service_interfaces: interfaces,
214
+ started_callback: -> { announced << hostname }
215
+ )
216
+ @services << service
217
+
218
+ @threads << Thread.new do
219
+ # One clear line, never a backtrace. This is background work the
220
+ # developer did not ask about by name, and a multicast socket giving up
221
+ # must not read like an application error.
222
+ Thread.current.report_on_exception = false
223
+ service.start
224
+ rescue StandardError => e
225
+ @out.puts "copse: stopped advertising #{hostname}: #{e.message} (#{e.class})"
226
+ end
227
+ end
228
+
229
+ # Waits for every announcement to report itself, or for the deadline, or for
230
+ # every thread to have died -- whichever comes first. A thread that died
231
+ # already printed its own line.
232
+ def await(announced)
233
+ deadline = Time.now + START_TIMEOUT
234
+ @hostnames.size.times do
235
+ remaining = deadline - Time.now
236
+ break if remaining <= 0 || !advertising?
237
+
238
+ announced.pop(timeout: remaining)
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
data/lib/copse.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "copse/worktree"
8
8
  require_relative "copse/database"
9
9
  require_relative "copse/procfile"
10
10
  require_relative "copse/session"
11
+ require_relative "copse/zeroconf"
11
12
 
12
13
  # Copse gives every Rails app and every git worktree its own hostname and its
13
14
  # own port, derived rather than assigned, so nothing collides and nothing has to
@@ -67,8 +68,19 @@ module Copse
67
68
  #
68
69
  # `process_manager: :overmind` hands the whole Procfile to Overmind instead,
69
70
  # replacing this process, and `args` is forwarded to it (`bin/dev -l web`).
70
- def self.start(root: Dir.pwd, process_manager: :foreman, args: [])
71
- session = Session.new(Worktree.new(root))
71
+ #
72
+ # `zeroconf: true` publishes the derived hostname under `.local` over multicast
73
+ # DNS instead of `.localhost`, for machines whose clients do not resolve
74
+ # `.localhost` -- and for reaching the app from a phone. Left nil it follows
75
+ # COPSE_ZEROCONF, so a single developer can opt in without editing the committed
76
+ # `bin/dev`. `subdomains` names extra labels to publish under the app's own
77
+ # hostname (`jane.cora.thicc.local`), for apps that serve several; it follows
78
+ # COPSE_SUBDOMAINS the same way, and does nothing without zeroconf, where the
79
+ # resolver answers for every label already.
80
+ def self.start(root: Dir.pwd, process_manager: :foreman, args: [], zeroconf: nil, subdomains: nil,
81
+ out: $stdout)
82
+ worktree, advertiser = derive(root: root, zeroconf: zeroconf, subdomains: subdomains, out: out)
83
+ session = Session.new(worktree, out: out, advertiser: advertiser)
72
84
 
73
85
  # `exec_overmind` never returns, so falling through to the foreman session
74
86
  # means overmind is not installed *on this machine*. That is a fallback rather
@@ -79,6 +91,30 @@ module Copse
79
91
  session.start
80
92
  end
81
93
 
94
+ # The naming decision, split out of `start` so it can be tested without booting
95
+ # anything. Returns the worktree and, when zeroconf naming is on, the advertiser
96
+ # that will publish its hostname.
97
+ #
98
+ # A missing `zeroconf` gem is a fallback rather than an error, for the same
99
+ # reason a missing overmind is: the request can come from a committed `bin/dev`,
100
+ # and a machine that cannot honour it should still boot. The port is unchanged
101
+ # by the fallback -- it is derived from the `.localhost` name either way -- so
102
+ # what is lost is the name, not the session.
103
+ def self.derive(root: Dir.pwd, zeroconf: nil, subdomains: nil, out: $stdout, env: ENV)
104
+ return [Worktree.new(root), nil] unless Zeroconf.requested?(zeroconf, env: env)
105
+
106
+ unless Zeroconf.available?
107
+ out.puts "copse: zeroconf naming was asked for, but the `zeroconf` gem is not installed " \
108
+ "for this Ruby. Add `gem \"zeroconf\"` to the development group. " \
109
+ "Booting under .localhost instead."
110
+ return [Worktree.new(root), nil]
111
+ end
112
+
113
+ worktree = Worktree.new(root, domain: Zeroconf.domain)
114
+ hostnames = Zeroconf.hostnames(worktree.host, Zeroconf.subdomains(subdomains, env: env))
115
+ [worktree, Zeroconf::Advertiser.new(hostnames, port: worktree.port, out: out)]
116
+ end
117
+
82
118
  # The derived port for a hostname. A pure function: same hostname, same port,
83
119
  # on every machine, in every terminal, across reboots and Ruby versions.
84
120
  def self.port_for(hostname)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: copse
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kieran Klaassen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-24 00:00:00.000000000 Z
11
+ date: 2026-09-10 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Copse derives a hostname and a port for each Rails app and each git worktree,
14
14
  so several can run at once without colliding and without anyone choosing numbers.
@@ -31,6 +31,7 @@ files:
31
31
  - lib/copse/url_options.rb
32
32
  - lib/copse/version.rb
33
33
  - lib/copse/worktree.rb
34
+ - lib/copse/zeroconf.rb
34
35
  - lib/generators/copse/install_generator.rb
35
36
  - lib/generators/copse/templates/Procfile.dev.tt
36
37
  - lib/generators/copse/templates/dev.tt