@stacksjs/rpx 0.11.9 → 0.11.11

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.
package/src/dns.ts CHANGED
@@ -1,12 +1,49 @@
1
1
  /**
2
- * Minimal DNS server for local development
3
- * Handles DNS queries for configured domains and responds with localhost IPs
2
+ * Local development DNS for macOS.
3
+ *
4
+ * Uses domain-scoped `/etc/resolver/<base-domain>` files (never whole-TLD hijacks like
5
+ * `/etc/resolver/com`) so real sites keep working when the rpx DNS server is down.
4
6
  */
5
7
  import dgram from 'node:dgram'
8
+ import * as fsp from 'node:fs/promises'
9
+ import * as path from 'node:path'
10
+ import * as process from 'node:process'
11
+ import type { RegistryEntry } from './registry'
12
+ import { getDaemonRpxDir } from './daemon'
13
+ import {
14
+ type DnsState,
15
+ DNS_STATE_VERSION,
16
+ devDomainsFromHosts,
17
+ LEGACY_TLD_RESOLVER_LABELS,
18
+ loadDnsState,
19
+ resolverBasenamesForDomains,
20
+ saveDnsState,
21
+ clearDnsState,
22
+ } from './dns-state'
23
+ import { isPidAlive } from './registry'
6
24
  import { debugLog } from './utils'
7
25
 
8
- // Use a high port that doesn't require root
9
- const DNS_PORT = 15353
26
+ /** High port does not require root. */
27
+ export const DNS_PORT = 15353
28
+
29
+ export const RPX_RESOLVER_MARKER = '# managed-by: rpx'
30
+
31
+ const MACOS_RESOLVER_DIR = '/etc/resolver'
32
+
33
+ export interface DevelopmentDnsOptions {
34
+ domains: string[]
35
+ rpxDir?: string
36
+ verbose?: boolean
37
+ /** Defaults to `process.pid` — stored so stale state can be reconciled after crashes. */
38
+ ownerPid?: number
39
+ }
40
+
41
+ let dnsServer: dgram.Socket | null = null
42
+ let configuredDomains: Set<string> = new Set()
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // DNS UDP server
46
+ // ---------------------------------------------------------------------------
10
47
 
11
48
  interface DnsHeader {
12
49
  id: number
@@ -23,9 +60,6 @@ interface DnsQuestion {
23
60
  class: number
24
61
  }
25
62
 
26
- /**
27
- * Parse DNS header from buffer
28
- */
29
63
  function parseHeader(buffer: Buffer): DnsHeader {
30
64
  return {
31
65
  id: buffer.readUInt16BE(0),
@@ -37,9 +71,6 @@ function parseHeader(buffer: Buffer): DnsHeader {
37
71
  }
38
72
  }
39
73
 
40
- /**
41
- * Parse domain name from DNS message
42
- */
43
74
  function parseName(buffer: Buffer, offset: number): { name: string, newOffset: number } {
44
75
  const labels: string[] = []
45
76
  let currentOffset = offset
@@ -52,7 +83,6 @@ function parseName(buffer: Buffer, offset: number): { name: string, newOffset: n
52
83
  break
53
84
  }
54
85
 
55
- // Check for pointer (compression)
56
86
  if ((length & 0xC0) === 0xC0) {
57
87
  const pointer = buffer.readUInt16BE(currentOffset) & 0x3FFF
58
88
  const { name } = parseName(buffer, pointer)
@@ -69,9 +99,6 @@ function parseName(buffer: Buffer, offset: number): { name: string, newOffset: n
69
99
  return { name: labels.join('.'), newOffset: currentOffset }
70
100
  }
71
101
 
72
- /**
73
- * Parse DNS question section
74
- */
75
102
  function parseQuestion(buffer: Buffer, offset: number): { question: DnsQuestion, newOffset: number } {
76
103
  const { name, newOffset } = parseName(buffer, offset)
77
104
  const type = buffer.readUInt16BE(newOffset)
@@ -83,9 +110,6 @@ function parseQuestion(buffer: Buffer, offset: number): { question: DnsQuestion,
83
110
  }
84
111
  }
85
112
 
86
- /**
87
- * Encode domain name for DNS response
88
- */
89
113
  function encodeName(name: string): Buffer {
90
114
  const labels = name.split('.')
91
115
  const parts: Buffer[] = []
@@ -94,87 +118,68 @@ function encodeName(name: string): Buffer {
94
118
  parts.push(Buffer.from([label.length]))
95
119
  parts.push(Buffer.from(label, 'ascii'))
96
120
  }
97
- parts.push(Buffer.from([0])) // Null terminator
121
+ parts.push(Buffer.from([0]))
98
122
 
99
123
  return Buffer.concat(parts)
100
124
  }
101
125
 
102
- /**
103
- * Build DNS response
104
- */
105
- function buildResponse(
106
- queryId: number,
107
- question: DnsQuestion,
108
- ip: string,
109
- ): Buffer {
126
+ function buildResponse(queryId: number, question: DnsQuestion, ip: string): Buffer {
110
127
  const parts: Buffer[] = []
111
128
 
112
- // Header
113
129
  const header = Buffer.alloc(12)
114
- header.writeUInt16BE(queryId, 0) // ID
115
- header.writeUInt16BE(0x8180, 2) // Flags: Response, Authoritative, No error
116
- header.writeUInt16BE(1, 4) // Questions: 1
117
- header.writeUInt16BE(1, 6) // Answers: 1
118
- header.writeUInt16BE(0, 8) // Authority: 0
119
- header.writeUInt16BE(0, 10) // Additional: 0
130
+ header.writeUInt16BE(queryId, 0)
131
+ header.writeUInt16BE(0x8180, 2)
132
+ header.writeUInt16BE(1, 4)
133
+ header.writeUInt16BE(1, 6)
134
+ header.writeUInt16BE(0, 8)
135
+ header.writeUInt16BE(0, 10)
120
136
  parts.push(header)
121
137
 
122
- // Question section (echo back)
123
138
  parts.push(encodeName(question.name))
124
139
  const qtype = Buffer.alloc(4)
125
140
  qtype.writeUInt16BE(question.type, 0)
126
141
  qtype.writeUInt16BE(question.class, 2)
127
142
  parts.push(qtype)
128
143
 
129
- // Answer section
130
144
  parts.push(encodeName(question.name))
131
145
 
132
146
  const answer = Buffer.alloc(10)
133
- answer.writeUInt16BE(question.type, 0) // Type
134
- answer.writeUInt16BE(1, 2) // Class: IN
135
- answer.writeUInt32BE(300, 4) // TTL: 5 minutes
147
+ answer.writeUInt16BE(question.type, 0)
148
+ answer.writeUInt16BE(1, 2)
149
+ answer.writeUInt32BE(300, 4)
136
150
 
137
151
  if (question.type === 1) {
138
- // A record (IPv4)
139
- answer.writeUInt16BE(4, 8) // Data length
152
+ answer.writeUInt16BE(4, 8)
140
153
  parts.push(answer)
141
154
  const ipParts = ip.split('.').map(p => Number.parseInt(p, 10))
142
155
  parts.push(Buffer.from(ipParts))
143
156
  }
144
157
  else if (question.type === 28) {
145
- // AAAA record (IPv6)
146
- answer.writeUInt16BE(16, 8) // Data length
158
+ answer.writeUInt16BE(16, 8)
147
159
  parts.push(answer)
148
- // ::1 as bytes
149
160
  parts.push(Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]))
150
161
  }
151
162
  else {
152
- // Unsupported type - return NXDOMAIN
153
- header.writeUInt16BE(0x8183, 2) // Flags with NXDOMAIN
154
- header.writeUInt16BE(0, 6) // No answers
163
+ header.writeUInt16BE(0x8183, 2)
164
+ header.writeUInt16BE(0, 6)
155
165
  return Buffer.concat([header, encodeName(question.name), qtype])
156
166
  }
157
167
 
158
168
  return Buffer.concat(parts)
159
169
  }
160
170
 
161
- /**
162
- * Build NXDOMAIN response for unknown domains
163
- */
164
171
  function buildNxdomainResponse(queryId: number, question: DnsQuestion): Buffer {
165
172
  const parts: Buffer[] = []
166
173
 
167
- // Header with NXDOMAIN
168
174
  const header = Buffer.alloc(12)
169
- header.writeUInt16BE(queryId, 0) // ID
170
- header.writeUInt16BE(0x8183, 2) // Flags: Response, Authoritative, NXDOMAIN
171
- header.writeUInt16BE(1, 4) // Questions: 1
172
- header.writeUInt16BE(0, 6) // Answers: 0
173
- header.writeUInt16BE(0, 8) // Authority: 0
174
- header.writeUInt16BE(0, 10) // Additional: 0
175
+ header.writeUInt16BE(queryId, 0)
176
+ header.writeUInt16BE(0x8183, 2)
177
+ header.writeUInt16BE(1, 4)
178
+ header.writeUInt16BE(0, 6)
179
+ header.writeUInt16BE(0, 8)
180
+ header.writeUInt16BE(0, 10)
175
181
  parts.push(header)
176
182
 
177
- // Question section (echo back)
178
183
  parts.push(encodeName(question.name))
179
184
  const qtype = Buffer.alloc(4)
180
185
  qtype.writeUInt16BE(question.type, 0)
@@ -184,28 +189,28 @@ function buildNxdomainResponse(queryId: number, question: DnsQuestion): Buffer {
184
189
  return Buffer.concat(parts)
185
190
  }
186
191
 
187
- let dnsServer: dgram.Socket | null = null
188
- let configuredDomains: Set<string> = new Set()
189
-
190
- /**
191
- * Start the DNS server
192
- */
193
192
  export async function startDnsServer(domains: string[], verbose?: boolean): Promise<boolean> {
193
+ if (process.platform !== 'darwin')
194
+ return false
195
+
196
+ const devDomains = devDomainsFromHosts(domains)
197
+ if (devDomains.length === 0)
198
+ return false
199
+
194
200
  if (dnsServer) {
195
- debugLog('dns', 'DNS server already running', verbose)
201
+ for (const d of devDomains)
202
+ configuredDomains.add(d)
203
+ debugLog('dns', 'DNS server already running — merged domains', verbose)
196
204
  return true
197
205
  }
198
206
 
199
- configuredDomains = new Set(domains.map(d => d.toLowerCase()))
207
+ configuredDomains = new Set(devDomains)
200
208
 
201
209
  return new Promise((resolve) => {
202
210
  dnsServer = dgram.createSocket('udp4')
203
211
 
204
212
  dnsServer.on('error', (err) => {
205
213
  debugLog('dns', `DNS server error: ${err.message}`, verbose)
206
- if (err.message.includes('EACCES') || err.message.includes('permission')) {
207
- debugLog('dns', 'DNS server requires root privileges to bind to port 53', verbose)
208
- }
209
214
  dnsServer?.close()
210
215
  dnsServer = null
211
216
  resolve(false)
@@ -218,7 +223,6 @@ export async function startDnsServer(domains: string[], verbose?: boolean): Prom
218
223
 
219
224
  debugLog('dns', `Query for ${question.name} type ${question.type} from ${rinfo.address}`, verbose)
220
225
 
221
- // Check if this domain should be handled
222
226
  const domainLower = question.name.toLowerCase()
223
227
  let shouldHandle = false
224
228
 
@@ -229,8 +233,6 @@ export async function startDnsServer(domains: string[], verbose?: boolean): Prom
229
233
  }
230
234
  }
231
235
 
232
- // Note: Only configured domains are handled, no hardcoded TLDs
233
-
234
236
  let response: Buffer
235
237
  if (shouldHandle && (question.type === 1 || question.type === 28)) {
236
238
  response = buildResponse(header.id, question, '127.0.0.1')
@@ -254,7 +256,6 @@ export async function startDnsServer(domains: string[], verbose?: boolean): Prom
254
256
  resolve(true)
255
257
  })
256
258
 
257
- // Try to bind to port 53 with sudo
258
259
  try {
259
260
  dnsServer.bind(DNS_PORT, '127.0.0.1')
260
261
  }
@@ -265,106 +266,103 @@ export async function startDnsServer(domains: string[], verbose?: boolean): Prom
265
266
  })
266
267
  }
267
268
 
268
- /**
269
- * Stop the DNS server
270
- */
271
269
  export function stopDnsServer(verbose?: boolean): void {
272
270
  if (dnsServer) {
273
271
  debugLog('dns', 'Stopping DNS server', verbose)
274
272
  dnsServer.close()
275
273
  dnsServer = null
274
+ configuredDomains = new Set()
276
275
  }
277
276
  }
278
277
 
279
- /**
280
- * Check if DNS server is running
281
- */
282
278
  export function isDnsServerRunning(): boolean {
283
279
  return dnsServer !== null
284
280
  }
285
281
 
286
- /**
287
- * Extract unique TLDs from domain list
288
- */
289
- function extractTLDs(domains: string[]): string[] {
290
- const tlds = new Set<string>()
291
- for (const domain of domains) {
292
- const parts = domain.split('.')
293
- if (parts.length >= 2) {
294
- tlds.add(parts[parts.length - 1])
295
- }
296
- }
297
- return Array.from(tlds)
282
+ // ---------------------------------------------------------------------------
283
+ // macOS resolver files
284
+ // ---------------------------------------------------------------------------
285
+
286
+ function resolverFileContent(): string {
287
+ return `${RPX_RESOLVER_MARKER}\nnameserver 127.0.0.1\nport ${DNS_PORT}\n`
298
288
  }
299
289
 
300
- // Track which TLDs we've created resolvers for
301
- const createdResolvers = new Set<string>()
290
+ export function resolverFilePath(basename: string): string {
291
+ return path.join(MACOS_RESOLVER_DIR, basename)
292
+ }
293
+
294
+ /** True when a resolver file points at the rpx local DNS port. */
295
+ export function contentLooksLikeRpxResolver(content: string): boolean {
296
+ return content.includes('127.0.0.1') && content.includes(String(DNS_PORT))
297
+ }
298
+
299
+ async function readResolverFile(basename: string): Promise<string | null> {
300
+ try {
301
+ return await fsp.readFile(resolverFilePath(basename), 'utf8')
302
+ }
303
+ catch (err) {
304
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT')
305
+ return null
306
+ throw err
307
+ }
308
+ }
302
309
 
303
- /**
304
- * Flush macOS DNS cache to ensure resolver changes take effect
305
- */
306
310
  async function flushDnsCache(verbose?: boolean): Promise<void> {
307
- if (process.platform !== 'darwin') {
311
+ if (process.platform !== 'darwin')
308
312
  return
309
- }
310
313
 
311
314
  const { execSudoSync, getSudoPassword } = await import('./utils')
312
- const sudoPassword = getSudoPassword()
313
315
 
314
- if (!sudoPassword) {
316
+ if (!getSudoPassword()) {
315
317
  debugLog('dns', 'Cannot flush DNS cache without SUDO_PASSWORD', verbose)
316
318
  return
317
319
  }
318
320
 
319
321
  try {
320
- // Flush DNS cache and restart mDNSResponder
321
322
  execSudoSync('dscacheutil -flushcache')
322
323
  execSudoSync('killall -HUP mDNSResponder 2>/dev/null || true')
323
324
  debugLog('dns', 'DNS cache flushed', verbose)
324
325
  }
325
326
  catch (err) {
326
- // Non-fatal - DNS cache flush failure shouldn't block startup
327
327
  debugLog('dns', `Could not flush DNS cache: ${err}`, verbose)
328
328
  }
329
329
  }
330
330
 
331
+ async function writeResolverFile(basename: string, verbose?: boolean): Promise<void> {
332
+ const { execSudoSync } = await import('./utils')
333
+ const content = resolverFileContent().replace(/\n/g, '\\n')
334
+ const cmd = `bash -c 'mkdir -p ${MACOS_RESOLVER_DIR} && printf "%b" "${content}" > ${resolverFilePath(basename)}'`
335
+ execSudoSync(cmd)
336
+ debugLog('dns', `Created ${resolverFilePath(basename)}`, verbose)
337
+ }
338
+
339
+ async function removeResolverFile(basename: string, verbose?: boolean): Promise<void> {
340
+ const { execSudoSync } = await import('./utils')
341
+ execSudoSync(`rm -f ${resolverFilePath(basename)}`)
342
+ debugLog('dns', `Removed ${resolverFilePath(basename)}`, verbose)
343
+ }
344
+
331
345
  /**
332
- * Set up the macOS resolver for configured domains
333
- * Creates /etc/resolver/<tld> files pointing to our local DNS server
346
+ * @deprecated Use {@link setupDevelopmentDns}. Domain-scoped resolver files only.
334
347
  */
335
348
  export async function setupResolver(verbose?: boolean, domains?: string[]): Promise<boolean> {
336
- if (process.platform !== 'darwin') {
337
- debugLog('dns', 'Resolver setup only needed on macOS', verbose)
338
- return true
339
- }
349
+ return setupDevelopmentDns({ domains: domains ?? [], verbose })
350
+ }
340
351
 
341
- const { execSudoSync, getSudoPassword } = await import('./utils')
342
- const sudoPassword = getSudoPassword()
352
+ async function installResolvers(basenames: string[], verbose?: boolean): Promise<boolean> {
353
+ if (process.platform !== 'darwin')
354
+ return true
343
355
 
344
- if (!sudoPassword) {
356
+ const { getSudoPassword } = await import('./utils')
357
+ if (!getSudoPassword()) {
345
358
  debugLog('dns', 'SUDO_PASSWORD not set, cannot create resolver files', verbose)
346
359
  return false
347
360
  }
348
361
 
349
- // Get TLDs from configured domains
350
- const tlds = domains ? extractTLDs(domains) : ['test']
351
-
352
362
  try {
353
- for (const tld of tlds) {
354
- if (createdResolvers.has(tld)) {
355
- continue
356
- }
357
-
358
- // Use bash -c to properly handle the echo with newlines
359
- const cmd = `bash -c 'mkdir -p /etc/resolver && echo -e "nameserver 127.0.0.1\\nport ${DNS_PORT}" > /etc/resolver/${tld}'`
360
- execSudoSync(cmd)
361
- createdResolvers.add(tld)
362
- debugLog('dns', `Created /etc/resolver/${tld} for .${tld} TLD`, verbose)
363
- }
364
-
365
- // Flush DNS cache to ensure new resolver files take effect immediately
363
+ for (const basename of basenames)
364
+ await writeResolverFile(basename, verbose)
366
365
  await flushDnsCache(verbose)
367
-
368
366
  return true
369
367
  }
370
368
  catch (err) {
@@ -373,27 +371,139 @@ export async function setupResolver(verbose?: boolean, domains?: string[]): Prom
373
371
  }
374
372
  }
375
373
 
376
- /**
377
- * Remove the macOS resolver files we created
378
- */
379
- export async function removeResolver(verbose?: boolean): Promise<void> {
380
- if (process.platform !== 'darwin') {
374
+ async function uninstallResolvers(basenames: string[], verbose?: boolean): Promise<void> {
375
+ if (process.platform !== 'darwin')
381
376
  return
382
- }
383
377
 
384
- const { execSudoSync, getSudoPassword } = await import('./utils')
378
+ const { getSudoPassword } = await import('./utils')
379
+ if (!getSudoPassword())
380
+ return
385
381
 
386
382
  try {
387
- const sudoPassword = getSudoPassword()
388
- if (sudoPassword) {
389
- for (const tld of createdResolvers) {
390
- execSudoSync(`rm -f /etc/resolver/${tld}`)
391
- debugLog('dns', `Removed /etc/resolver/${tld}`, verbose)
392
- }
393
- createdResolvers.clear()
394
- }
383
+ for (const basename of basenames)
384
+ await removeResolverFile(basename, verbose)
385
+ await flushDnsCache(verbose)
395
386
  }
396
387
  catch (err) {
397
388
  debugLog('dns', `Failed to remove resolver files: ${err}`, verbose)
398
389
  }
399
390
  }
391
+
392
+ /** Remove legacy whole-TLD resolver files (e.g. `/etc/resolver/com`). */
393
+ export async function removeLegacyTldResolvers(verbose?: boolean): Promise<string[]> {
394
+ if (process.platform !== 'darwin')
395
+ return []
396
+
397
+ const removed: string[] = []
398
+ for (const label of LEGACY_TLD_RESOLVER_LABELS) {
399
+ const content = await readResolverFile(label)
400
+ if (content && contentLooksLikeRpxResolver(content)) {
401
+ await uninstallResolvers([label], verbose)
402
+ removed.push(label)
403
+ }
404
+ }
405
+ return removed
406
+ }
407
+
408
+ /**
409
+ * Start the local DNS server and install domain-scoped macOS resolver files.
410
+ */
411
+ export async function setupDevelopmentDns(opts: DevelopmentDnsOptions): Promise<boolean> {
412
+ const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
413
+ const domains = devDomainsFromHosts(opts.domains)
414
+ if (domains.length === 0)
415
+ return false
416
+
417
+ const basenames = resolverBasenamesForDomains(domains)
418
+ const started = await startDnsServer(domains, opts.verbose)
419
+ if (!started)
420
+ return false
421
+
422
+ const installed = await installResolvers(basenames, opts.verbose)
423
+ if (!installed)
424
+ return false
425
+
426
+ const state: DnsState = {
427
+ version: DNS_STATE_VERSION,
428
+ resolvers: basenames,
429
+ domains,
430
+ ownerPid: opts.ownerPid ?? process.pid,
431
+ updatedAt: new Date().toISOString(),
432
+ }
433
+ await saveDnsState(rpxDir, state)
434
+ return true
435
+ }
436
+
437
+ /**
438
+ * Sync resolver + DNS state to the current set of registry hosts (daemon mode).
439
+ */
440
+ export async function syncDevelopmentDnsFromRegistry(
441
+ entries: RegistryEntry[],
442
+ opts: { rpxDir?: string, verbose?: boolean, ownerPid?: number } = {},
443
+ ): Promise<void> {
444
+ const domains = entries.map(e => e.to).filter(Boolean)
445
+ const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
446
+ const wanted = resolverBasenamesForDomains(domains)
447
+ const state = await loadDnsState(rpxDir)
448
+ const previous = state?.resolvers ?? []
449
+ const toRemove = previous.filter(b => !wanted.includes(b))
450
+
451
+ if (toRemove.length > 0)
452
+ await uninstallResolvers(toRemove, opts.verbose)
453
+
454
+ if (wanted.length === 0) {
455
+ stopDnsServer(opts.verbose)
456
+ await clearDnsState(rpxDir)
457
+ return
458
+ }
459
+
460
+ await setupDevelopmentDns({
461
+ domains,
462
+ rpxDir,
463
+ verbose: opts.verbose,
464
+ ownerPid: opts.ownerPid ?? process.pid,
465
+ })
466
+ }
467
+
468
+ /**
469
+ * Stop DNS and remove all resolver files recorded in state (plus legacy TLD files).
470
+ */
471
+ export async function tearDownDevelopmentDns(opts: { rpxDir?: string, verbose?: boolean } = {}): Promise<void> {
472
+ const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
473
+ stopDnsServer(opts.verbose)
474
+
475
+ const state = await loadDnsState(rpxDir)
476
+ const fromState = state?.resolvers ?? []
477
+ await uninstallResolvers(fromState, opts.verbose)
478
+ await removeLegacyTldResolvers(opts.verbose)
479
+ await clearDnsState(rpxDir)
480
+ }
481
+
482
+ /**
483
+ * @deprecated Use {@link tearDownDevelopmentDns}.
484
+ */
485
+ export async function removeResolver(verbose?: boolean): Promise<void> {
486
+ await tearDownDevelopmentDns({ verbose })
487
+ }
488
+
489
+ /**
490
+ * Remove stale DNS overrides left after a crashed dev session or legacy TLD hijacks.
491
+ * Safe to call before starting the daemon or `./buddy dev`.
492
+ */
493
+ export async function reconcileStaleDevelopmentDns(opts: { rpxDir?: string, verbose?: boolean } = {}): Promise<void> {
494
+ const rpxDir = opts.rpxDir ?? getDaemonRpxDir()
495
+ const state = await loadDnsState(rpxDir)
496
+ const ownerAlive = state?.ownerPid != null && isPidAlive(state.ownerPid)
497
+
498
+ if (state && !ownerAlive) {
499
+ debugLog('dns', `reconcile: owner pid ${state.ownerPid} is gone — tearing down DNS`, opts.verbose)
500
+ await tearDownDevelopmentDns(opts)
501
+ return
502
+ }
503
+
504
+ const legacyRemoved = await removeLegacyTldResolvers(opts.verbose)
505
+ if (legacyRemoved.length > 0)
506
+ debugLog('dns', `reconcile: removed legacy TLD resolvers: ${legacyRemoved.join(', ')}`, opts.verbose)
507
+
508
+ await flushDnsCache(opts.verbose)
509
+ }
package/src/hosts.ts CHANGED
@@ -8,6 +8,14 @@ import { debugLog, getSudoPassword } from './utils'
8
8
 
9
9
  const execAsync = promisify(exec)
10
10
 
11
+ /** `.localhost` names resolve to loopback per RFC 6761 — no /etc/hosts entry needed. */
12
+ export function isLoopbackDevelopmentHost(host: string): boolean {
13
+ const normalized = host.trim().toLowerCase()
14
+ return normalized === 'localhost'
15
+ || normalized.endsWith('.localhost')
16
+ || normalized.endsWith('.localhost.')
17
+ }
18
+
11
19
  export const hostsFilePath: string = process.platform === 'win32'
12
20
  ? path.join(process.env.windir || 'C:\\Windows', 'System32', 'drivers', 'etc', 'hosts')
13
21
  : '/etc/hosts'
@@ -42,9 +50,14 @@ async function execSudo(command: string): Promise<string> {
42
50
  }
43
51
  }
44
52
 
45
- const { stdout } = await execAsync(`sudo sh -c '${escaped}'`)
46
- sudoPrivilegesAcquired = true
47
- return stdout
53
+ try {
54
+ const { stdout } = await execAsync(`sudo -n sh -c '${escaped}'`)
55
+ sudoPrivilegesAcquired = true
56
+ return stdout
57
+ }
58
+ catch {
59
+ throw new Error('sudo required but no cached credentials (set SUDO_PASSWORD in .env or run sudo -v)')
60
+ }
48
61
  }
49
62
  catch (error) {
50
63
  throw new Error(`Failed to execute sudo command: ${(error as Error).message}`)
@@ -52,7 +65,15 @@ async function execSudo(command: string): Promise<string> {
52
65
  }
53
66
 
54
67
  export async function addHosts(hosts: string[], verbose?: boolean): Promise<void> {
55
- debugLog('hosts', `Adding hosts: ${hosts.join(', ')}`, verbose)
68
+ const needsHostsFile = hosts.filter(h => !isLoopbackDevelopmentHost(h))
69
+ const skipped = hosts.filter(h => isLoopbackDevelopmentHost(h))
70
+ if (skipped.length > 0) {
71
+ debugLog('hosts', `Skipping /etc/hosts for loopback dev names: ${skipped.join(', ')}`, verbose)
72
+ }
73
+ if (needsHostsFile.length === 0)
74
+ return
75
+
76
+ debugLog('hosts', `Adding hosts: ${needsHostsFile.join(', ')}`, verbose)
56
77
  debugLog('hosts', `Using hosts file at: ${hostsFilePath}`, verbose)
57
78
 
58
79
  try {
@@ -76,7 +97,7 @@ export async function addHosts(hosts: string[], verbose?: boolean): Promise<void
76
97
  }
77
98
 
78
99
  // Prepare new entries, only including those that don't exist
79
- const newEntries = hosts.filter((host) => {
100
+ const newEntries = needsHostsFile.filter((host) => {
80
101
  const ipv4Entry = `127.0.0.1 ${host}`
81
102
  const ipv6Entry = `::1 ${host}`
82
103
  return !existingContent.includes(ipv4Entry) && !existingContent.includes(ipv6Entry)
package/src/index.ts CHANGED
@@ -61,6 +61,34 @@ export {
61
61
 
62
62
  export type { RegistryEntry, WatchHandle, WatchOptions } from './registry'
63
63
 
64
+ export {
65
+ DNS_PORT,
66
+ RPX_RESOLVER_MARKER,
67
+ contentLooksLikeRpxResolver,
68
+ isDnsServerRunning,
69
+ reconcileStaleDevelopmentDns,
70
+ removeLegacyTldResolvers,
71
+ removeResolver,
72
+ resolverFilePath,
73
+ setupDevelopmentDns,
74
+ setupResolver,
75
+ startDnsServer,
76
+ stopDnsServer,
77
+ syncDevelopmentDnsFromRegistry,
78
+ tearDownDevelopmentDns,
79
+ } from './dns'
80
+
81
+ export type { DevelopmentDnsOptions } from './dns'
82
+
83
+ export {
84
+ DNS_STATE_VERSION,
85
+ LEGACY_TLD_RESOLVER_LABELS,
86
+ devDomainsFromHosts,
87
+ normalizeDevDomain,
88
+ resolverBasenameForDomain,
89
+ resolverBasenamesForDomains,
90
+ } from './dns-state'
91
+
64
92
  export {
65
93
  acquireDaemonLock,
66
94
  defaultDaemonSpawnCommand,
@@ -69,6 +97,7 @@ export {
69
97
  getDaemonRpxDir,
70
98
  isDaemonRunning,
71
99
  readDaemonPid,
100
+ reconcileDevelopmentDnsOnIdle,
72
101
  releaseDaemonLock,
73
102
  runDaemon,
74
103
  stopDaemon,