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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +89 -2
- data/lib/copse/session.rb +48 -1
- data/lib/copse/version.rb +1 -1
- data/lib/copse/worktree.rb +27 -7
- data/lib/copse/zeroconf.rb +243 -0
- data/lib/copse.rb +38 -2
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a8272bcb50acd3e7cdd810ec98b4be8d2af79c461b83f001dda7dd406e36dd7f
|
|
4
|
+
data.tar.gz: ab24056f37f69404a7f2182ef1ef2b3045b34ed3cb0b6a33511286b6e5d490c6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
data/lib/copse/worktree.rb
CHANGED
|
@@ -11,24 +11,44 @@ module Copse
|
|
|
11
11
|
class Worktree
|
|
12
12
|
LABEL_LIMIT = 63
|
|
13
13
|
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
21
|
-
#
|
|
26
|
+
# `<project>.<domain>` for a main worktree, `<branch>.<project>.<domain>` for
|
|
27
|
+
# a linked one.
|
|
22
28
|
def host
|
|
23
|
-
@host ||= [slug, project,
|
|
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(
|
|
47
|
+
@port ||= Copse.port_for(canonical_host)
|
|
28
48
|
end
|
|
29
49
|
|
|
30
50
|
def companion_port
|
|
31
|
-
@companion_port ||= Copse.companion_port_for(
|
|
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
|
-
|
|
71
|
-
|
|
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.
|
|
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-
|
|
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
|