unplugin-https-reverse-proxy 4.1.0-beta.2 → 4.1.0-beta.4

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/dist/webpack.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import {
2
2
  unpluginFactory
3
- } from "./chunk-7CA3EIIX.js";
3
+ } from "./chunk-H7LIW3S4.js";
4
4
  import "./chunk-EY3HXBJ4.js";
5
5
  import "./chunk-C5A7XLKL.js";
6
+ import "./chunk-WOMR3ZLM.js";
6
7
  import "./chunk-RKHJN6KY.js";
7
8
 
8
9
  // src/webpack.ts
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unplugin-https-reverse-proxy",
3
3
  "type": "module",
4
- "version": "4.1.0-beta.2",
4
+ "version": "4.1.0-beta.4",
5
5
  "description": "",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme",
@@ -1,72 +1,191 @@
1
1
  import type { ProxyType } from './caddyfile'
2
- import { existsSync, readFileSync, watch, writeFileSync } from 'node:fs'
3
- import { unlink } from 'node:fs/promises'
4
- import { computed, signal } from 'alien-signals'
2
+ import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import process from 'node:process'
4
+ import * as lockfile from 'proper-lockfile'
5
5
  import { caddyConfigPath } from './constants'
6
6
 
7
- export function useCaddyConfig(): {
8
- configJsonRef: () => (Record<string, typeof ProxyType.infer> | undefined)
9
- writeCaddyConfig: (proxies: Array<typeof ProxyType.infer>) => void
7
+ /** One project's route, plus the pid that owns it. */
8
+ type ProxyEntry = typeof ProxyType.infer & { ownerPid?: number }
9
+
10
+ type ProxyMap = Record<string, ProxyEntry>
11
+
12
+ /**
13
+ * A file lock held around every read-modify-write of the shared config.
14
+ *
15
+ * Several dev servers run as independent processes, so a read-then-write from
16
+ * two of them can interleave and drop an entry. Synchronous calls are only
17
+ * atomic within one event loop, which is why the lock has to be a real file
18
+ * lock rather than an in-process queue.
19
+ */
20
+ interface ConfigLock {
21
+ acquire: () => Promise<() => void>
22
+ }
23
+
24
+ export interface CaddyConfigApi {
25
+ configJsonRef: () => ProxyMap | undefined
26
+ /** Adds or replaces this project's routes, keeping everyone else's. */
27
+ writeCaddyConfig: (proxies: Array<typeof ProxyType.infer>, key: string) => Promise<void>
28
+ /** Drops one project's routes; the file goes away when nothing is left. */
29
+ removeProxy: (key: string) => Promise<void>
30
+ /** Reads the routes currently on disk, for regenerating the Caddyfile. */
31
+ readProxies: () => ProxyMap
10
32
  cleanUp: () => Promise<void>
11
- } {
12
- const configContentRef = signal('{}')
13
-
14
- const configJsonRef = computed(() => {
15
- try {
16
- return JSON.parse(configContentRef()) as Record<string, typeof ProxyType.infer>
17
- }
18
- catch {
19
- return undefined
20
- }
21
- })
22
-
23
- function setConfigContent(): void {
24
- if (!existsSync(caddyConfigPath)) {
25
- writeFileSync(caddyConfigPath, '{}')
26
- }
27
- try {
28
- configContentRef(readFileSync(caddyConfigPath, 'utf-8'))
29
- }
30
- catch {
31
- configContentRef('')
32
- }
33
+ }
34
+
35
+ /** Owner pids that have exited can never clean up after themselves. */
36
+ function isAlive(pid: number | undefined): boolean {
37
+ if (pid === undefined)
38
+ return true
39
+ try {
40
+ process.kill(pid, 0)
41
+ return true
42
+ }
43
+ catch {
44
+ return false
33
45
  }
46
+ }
47
+
48
+ /**
49
+ * Reads the shared config, dropping entries whose owner process is gone.
50
+ *
51
+ * A SIGKILLed dev server cannot remove its own route, so without this sweep its
52
+ * domain would keep resolving to a proxy with no upstream behind it.
53
+ */
54
+ function readConfig(): ProxyMap {
55
+ if (!existsSync(caddyConfigPath))
56
+ return {}
34
57
 
35
- if (!existsSync(caddyConfigPath)) {
36
- writeFileSync(caddyConfigPath, '{}')
58
+ let parsed: ProxyMap
59
+ try {
60
+ parsed = JSON.parse(readFileSync(caddyConfigPath, 'utf-8')) as ProxyMap
61
+ }
62
+ catch {
63
+ // A half-written file is not worth preserving: it has no usable routes.
64
+ return {}
37
65
  }
38
66
 
39
- const watchCaddyConfig = watch(caddyConfigPath, () => {
40
- setConfigContent()
41
- })
42
-
43
- function writeCaddyConfig(_proxies: Array<typeof ProxyType.infer>): void {
44
- const proxies = configJsonRef() || {}
45
-
46
- _proxies.forEach((p) => {
47
- let base = p.base || '/'
48
- if (!base.startsWith('/'))
49
- base = `/${base}`
50
- if (base !== '/' && !base.endsWith('/'))
51
- base = `${base}/`
52
- const index = `${p.target}${p.portSuffix}${base}`
53
- proxies[index] = { ...p, base }
54
- })
55
-
56
- const content = JSON.stringify(proxies, null, 2)
57
- configContentRef(content)
58
- return writeFileSync(caddyConfigPath, content)
67
+ const alive: ProxyMap = {}
68
+ for (const [key, entry] of Object.entries(parsed)) {
69
+ if (isAlive(entry?.ownerPid))
70
+ alive[key] = entry
59
71
  }
72
+ return alive
73
+ }
60
74
 
61
- async function cleanUp(): Promise<void> {
62
- await unlink(caddyConfigPath).catch(() => { })
63
- configContentRef('{}')
64
- watchCaddyConfig.close()
75
+ /**
76
+ * Writes through a temporary file so a concurrent reader never sees a partial
77
+ * document. `rename` is atomic within a filesystem, which keeps the shared
78
+ * config readable at all times.
79
+ */
80
+ function writeConfig(proxies: ProxyMap): void {
81
+ const content = JSON.stringify(proxies, null, 2)
82
+ const temp = `${caddyConfigPath}.${process.pid}.tmp`
83
+ writeFileSync(temp, content)
84
+ renameSync(temp, caddyConfigPath)
85
+ }
86
+
87
+ /** Applies `mutate` under the file lock, re-reading the latest state first. */
88
+ async function mutateConfig(
89
+ lock: ConfigLock,
90
+ mutate: (proxies: ProxyMap) => void,
91
+ ): Promise<ProxyMap> {
92
+ const release = await lock.acquire()
93
+ try {
94
+ const proxies = readConfig()
95
+ mutate(proxies)
96
+ writeConfig(proxies)
97
+ return proxies
98
+ }
99
+ finally {
100
+ release()
65
101
  }
102
+ }
103
+
104
+ export interface CaddyConfigOptions {
105
+ /** Injected in tests so they do not need the real lock. */
106
+ lock?: ConfigLock
107
+ }
108
+
109
+ export function useCaddyConfig(options: CaddyConfigOptions = {}): CaddyConfigApi {
110
+ const lock = options.lock ?? createFileLock()
66
111
 
67
112
  return {
68
- configJsonRef,
69
- writeCaddyConfig,
70
- cleanUp,
113
+ configJsonRef: () => (existsSync(caddyConfigPath) ? readConfig() : undefined),
114
+
115
+ async writeCaddyConfig(_proxies, key) {
116
+ await mutateConfig(lock, (proxies) => {
117
+ for (const p of _proxies)
118
+ proxies[routeKey(p, key)] = { ...p, base: normalizeBase(p.base), ownerPid: process.pid }
119
+ })
120
+ },
121
+
122
+ async removeProxy(key) {
123
+ const proxies = await mutateConfig(lock, (current) => {
124
+ for (const route of Object.keys(current)) {
125
+ if (route.endsWith(`#${key}`))
126
+ delete current[route]
127
+ }
128
+ })
129
+
130
+ if (Object.keys(proxies).length === 0)
131
+ removeConfigFile()
132
+ },
133
+
134
+ readProxies: () => readConfig(),
135
+
136
+ async cleanUp() {
137
+ removeConfigFile()
138
+ },
139
+ }
140
+ }
141
+
142
+ function removeConfigFile(): void {
143
+ try {
144
+ if (existsSync(caddyConfigPath))
145
+ unlinkSync(caddyConfigPath)
146
+ }
147
+ catch {}
148
+ }
149
+
150
+ function normalizeBase(base: string | undefined): string {
151
+ let out = base || '/'
152
+ if (!out.startsWith('/'))
153
+ out = `/${out}`
154
+ if (out !== '/' && !out.endsWith('/'))
155
+ out = `${out}/`
156
+ return out
157
+ }
158
+
159
+ /**
160
+ * Route key: the domain it answers on, plus the owning project.
161
+ *
162
+ * Two projects can legitimately serve the same domain on different bases, so
163
+ * the key has to carry the base as well as the owner.
164
+ */
165
+ export function routeKey(proxy: typeof ProxyType.infer, ownerKey: string): string {
166
+ return `${proxy.target}${proxy.portSuffix}${normalizeBase(proxy.base)}#${ownerKey}`
167
+ }
168
+
169
+ /**
170
+ * Builds the on-disk lock.
171
+ *
172
+ * `proper-lockfile` retries because the winner may hold the lock for a moment;
173
+ * `realpath: false` keeps it working when the temp directory is a symlink.
174
+ */
175
+ function createFileLock(): ConfigLock {
176
+ return {
177
+ async acquire() {
178
+ if (!existsSync(caddyConfigPath))
179
+ writeFileSync(caddyConfigPath, '{}')
180
+
181
+ const release = await lockfile.lock(caddyConfigPath, {
182
+ realpath: false,
183
+ stale: 10_000,
184
+ retries: { retries: 20, minTimeout: 10, maxTimeout: 100 },
185
+ })
186
+ return () => {
187
+ void release().catch(() => {})
188
+ }
189
+ },
71
190
  }
72
191
  }
@@ -1,20 +1,18 @@
1
1
  import type { Buffer } from 'node:buffer'
2
2
  import type { OpenedPrivilegedSession } from './privileged'
3
3
  import { execFile } from 'node:child_process'
4
- import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
4
+ import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs'
5
5
  import { readFile, unlink, writeFile } from 'node:fs/promises'
6
6
  import { platform } from 'node:os'
7
7
  import { join } from 'node:path'
8
8
  import process from 'node:process'
9
- import { effect } from 'alien-signals'
10
9
  import { got } from 'got-cjs'
11
10
  import { HttpProxyAgent } from 'http-proxy-agent'
12
11
  import { HttpsProxyAgent } from 'https-proxy-agent'
13
- import * as lockfile from 'proper-lockfile'
14
12
  import { consola, once } from '../utils'
15
13
  import { genCaddyfile, validateTemplateContext } from './caddyfile'
16
14
  import { useCaddyConfig } from './config'
17
- import { caddyFilePath, caddyLockFilePath, caddyPath, supportList, TEMP_DIR } from './constants'
15
+ import { caddyFilePath, caddyPath, supportList, TEMP_DIR } from './constants'
18
16
  import { isCaddyReloadCancellation } from './log'
19
17
  import { HelperCaddyProcess, openPrivilegedSession } from './privileged'
20
18
  import { formatTlsHealthFailure, waitForLocalTls } from './tls-health'
@@ -132,17 +130,21 @@ function splitHostPort(addr: string): { host: string, port: string } {
132
130
 
133
131
  /** Injection point for tests: opens the privileged helper without sudo. */
134
132
  export interface CaddyDependencies {
135
- openSession: (runtimeDir: string) => Promise<OpenedPrivilegedSession>
133
+ openSession: (runtimeDir: string, reason?: { target: string, https: boolean }) => Promise<OpenedPrivilegedSession>
134
+ /** Shared route config; tests inject an in-memory one. */
135
+ config: () => ReturnType<typeof useCaddyConfig>
136
136
  }
137
137
 
138
138
  const defaultDependencies: CaddyDependencies = {
139
- openSession: runtimeDir => openPrivilegedSession(runtimeDir, {
139
+ openSession: (runtimeDir, reason) => openPrivilegedSession(runtimeDir, {
140
140
  log: message => consola.debug(message),
141
141
  // Both paths are fixed here, before any socket traffic: the privileged
142
142
  // helper must never execute something named in a request.
143
143
  caddyBin: caddyPath,
144
144
  caddyConfigPath: caddyFilePath,
145
+ reason,
145
146
  }),
147
+ config: () => getCaddyConfig(),
146
148
  }
147
149
 
148
150
  export class CaddyInstant {
@@ -150,8 +152,6 @@ export class CaddyInstant {
150
152
  private stoped = false
151
153
  private readonly deps: CaddyDependencies
152
154
 
153
- /** has locked when start */
154
- private locked = false
155
155
  private caddyfile: string | undefined
156
156
 
157
157
  private _caddyChild: HelperCaddyProcess | undefined
@@ -160,21 +160,11 @@ export class CaddyInstant {
160
160
  private _source: string | undefined
161
161
  private _target: string | undefined
162
162
  private _stopEffects: Array<(() => void)> = []
163
+ /** Identifies this instance's routes in the shared config. */
164
+ private readonly proxyKey = `${process.pid}-${Math.random().toString(36).slice(2, 8)}`
163
165
 
164
166
  constructor(deps: Partial<CaddyDependencies> = {}) {
165
167
  this.deps = { ...defaultDependencies, ...deps }
166
- this.locked = this.checkLock()
167
- }
168
-
169
- checkLock(): boolean {
170
- if (!existsSync(caddyLockFilePath))
171
- writeFileSync(caddyLockFilePath, '')
172
- try {
173
- return lockfile.checkSync(caddyLockFilePath)
174
- }
175
- catch {
176
- return false
177
- }
178
168
  }
179
169
 
180
170
  /**
@@ -182,14 +172,14 @@ export class CaddyInstant {
182
172
  * reused across instances so a second dev server does not ask for a
183
173
  * password again.
184
174
  */
185
- private async session(): Promise<OpenedPrivilegedSession> {
175
+ private async session(reason?: { target: string, https: boolean }): Promise<OpenedPrivilegedSession> {
186
176
  // A crashed helper leaves the cached client permanently disconnected; drop
187
177
  // it so the next call starts a fresh helper instead of failing forever.
188
178
  if (this._session && !this._session.client.connected)
189
179
  this._session = undefined
190
180
 
191
181
  if (!this._session)
192
- this._session = await this.deps.openSession(TEMP_DIR)
182
+ this._session = await this.deps.openSession(TEMP_DIR, reason)
193
183
  return this._session
194
184
  }
195
185
 
@@ -259,9 +249,15 @@ export class CaddyInstant {
259
249
  throw new Error(formatTlsHealthFailure(targetHost, health))
260
250
  }
261
251
 
252
+ /**
253
+ * Regenerates the Caddyfile from the config on disk.
254
+ *
255
+ * Reading the file rather than a cached view matters here: another project
256
+ * may have added its route since this instance last looked.
257
+ */
262
258
  private async updateCaddyfile(): Promise<void> {
263
- const { configJsonRef } = getCaddyConfig()
264
- const proxies = Object.values(configJsonRef() || {})
259
+ const { readProxies } = this.deps.config()
260
+ const proxies = Object.values(readProxies())
265
261
  const ctx = validateTemplateContext({
266
262
  caddy_root: this.caddyRoot,
267
263
  proxies,
@@ -313,10 +309,10 @@ export class CaddyInstant {
313
309
  if (!this.inited)
314
310
  await this.init()
315
311
 
316
- const session = await this.session()
317
-
318
312
  const sourceHost = splitHostPort(source).host
319
313
  const targetHost = splitHostPort(target).host
314
+
315
+ const session = await this.session({ target: targetHost, https })
320
316
  await session.client.call('hosts.add', { ip: sourceHost, host: targetHost })
321
317
  consola.success('update host success\n')
322
318
 
@@ -341,32 +337,17 @@ export class CaddyInstant {
341
337
  },
342
338
  ]
343
339
 
344
- const { writeCaddyConfig } = getCaddyConfig()
345
- writeCaddyConfig(proxies)
340
+ const { writeCaddyConfig } = this.deps.config()
341
+ await writeCaddyConfig(proxies, this.proxyKey)
346
342
 
347
343
  await this.updateCaddyfile()
348
344
 
349
- let skipInitialCaddyfileEffect = true
350
- this._stopEffects.push(
351
- effect(() => {
352
- getCaddyConfig().configJsonRef()
353
- if (skipInitialCaddyfileEffect) {
354
- skipInitialCaddyfileEffect = false
355
- return
356
- }
357
- void this.updateCaddyfile()
358
- }),
359
- )
360
-
361
- if (!existsSync(caddyLockFilePath))
362
- writeFileSync(caddyLockFilePath, '')
363
-
364
- if (!this.locked) {
365
- // Ports 80/443 may still be held by a previous, uncleanly stopped run.
366
- await session.client.call('port.free', { port: 443 })
367
- if (process.platform === 'win32' || !https)
368
- await session.client.call('port.free', { port: 80 })
369
- }
345
+ // Ports 80/443 may still be held by a previous, uncleanly stopped run.
346
+ // The helper skips its own Caddy, so this never kills a proxy another
347
+ // project is already using.
348
+ await session.client.call('port.free', { port: 443 })
349
+ if (process.platform === 'win32' || !https)
350
+ await session.client.call('port.free', { port: 80 })
370
351
 
371
352
  return new Promise<void>((resolve, reject) => {
372
353
  let startupCheckStarted = false
@@ -387,8 +368,7 @@ export class CaddyInstant {
387
368
  resolve()
388
369
  }
389
370
  catch (error) {
390
- if (!this.locked)
391
- await this.baseCleanup(restore)
371
+ await this.baseCleanup(restore)
392
372
  reject(error)
393
373
  }
394
374
  }
@@ -433,84 +413,65 @@ export class CaddyInstant {
433
413
  }
434
414
  }
435
415
 
436
- if (!this.locked) {
437
- lockfile.lockSync(caddyLockFilePath)
438
- setupCleanup()
439
-
440
- void (async () => {
441
- try {
442
- const child = new HelperCaddyProcess(session.client)
443
- this._caddyChild = child
444
-
445
- await session.client.call('caddy.start')
446
-
447
- child.on('exit', (code: number | null) => {
448
- if (code !== null && code !== 0)
449
- reject(new Error(`Caddy exited with code ${code}`))
450
- })
416
+ setupCleanup()
451
417
 
452
- child.stderr.on('data', async (data: Buffer) => {
453
- const lines = data.toString().split('\n').map((line: string) => line.trim())
454
- for (const line of lines) {
455
- // caddy log
456
- // eslint-disable-next-line no-console
457
- showCaddyLog && line && console.info(line)
458
- if (isCaddyReloadCancellation(line)) {
459
- consola.info('Caddy restarted before TLS issuance completed; retrying.\n')
460
- continue
461
- }
462
- try {
463
- if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) {
464
- if (line.includes('parsing caddyfile tokens')) {
465
- consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`)
466
- await this.baseCleanup()
467
- process.exit()
468
- }
469
- else {
470
- consola.error(`${line}\n`)
471
- return reject(line)
472
- }
418
+ void (async () => {
419
+ try {
420
+ const child = new HelperCaddyProcess(session.client)
421
+ this._caddyChild = child
422
+
423
+ await session.client.call('caddy.start')
424
+
425
+ child.on('exit', (code: number | null) => {
426
+ if (code !== null && code !== 0)
427
+ reject(new Error(`Caddy exited with code ${code}`))
428
+ })
429
+
430
+ child.stderr.on('data', async (data: Buffer) => {
431
+ const lines = data.toString().split('\n').map((line: string) => line.trim())
432
+ for (const line of lines) {
433
+ // caddy log
434
+ // eslint-disable-next-line no-console
435
+ showCaddyLog && line && console.info(line)
436
+ if (isCaddyReloadCancellation(line)) {
437
+ consola.info('Caddy restarted before TLS issuance completed; retrying.\n')
438
+ continue
439
+ }
440
+ try {
441
+ if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) {
442
+ if (line.includes('parsing caddyfile tokens')) {
443
+ consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`)
444
+ await this.baseCleanup()
445
+ process.exit()
446
+ }
447
+ else {
448
+ consola.error(`${line}\n`)
449
+ return reject(line)
473
450
  }
474
451
  }
475
- catch {}
476
-
477
- if (line.includes('config file changed'))
478
- consola.success('Detect added a new reverse proxy config, restarted.\n')
479
-
480
- if (line.includes('caddy proxying') || line.includes('serving initial'))
481
- void resolveWhenReady()
482
452
  }
483
- })
453
+ catch {}
484
454
 
485
- child.stdout.on('data', (data: Buffer) => {
486
- consola.info(data.toString())
487
- void resolveWhenReady()
488
- })
489
- }
490
- catch (error) {
491
- // The hosts entry was written before Caddy started; a failed start
492
- // must not leave it behind while the dev server keeps running.
493
- if (!this.locked)
494
- await this.baseCleanup(restore)
495
- reject(error)
496
- }
497
- })()
498
- }
499
- else {
500
- consola.info('Caddy is running in another process.')
455
+ if (line.includes('config file changed'))
456
+ consola.success('Detect added a new reverse proxy config, restarted.\n')
501
457
 
502
- void (async () => {
503
- try {
504
- if (https)
505
- await this.ensureTlsReady(targetHost)
506
- setupCleanup()
507
- resolve()
508
- }
509
- catch (error) {
510
- reject(error)
511
- }
512
- })()
513
- }
458
+ if (line.includes('caddy proxying') || line.includes('serving initial'))
459
+ void resolveWhenReady()
460
+ }
461
+ })
462
+
463
+ child.stdout.on('data', (data: Buffer) => {
464
+ consola.info(data.toString())
465
+ void resolveWhenReady()
466
+ })
467
+ }
468
+ catch (error) {
469
+ // The hosts entry was written before Caddy started; a failed start
470
+ // must not leave it behind while the dev server keeps running.
471
+ await this.baseCleanup(restore)
472
+ reject(error)
473
+ }
474
+ })()
514
475
  })
515
476
  }
516
477
 
@@ -540,15 +501,12 @@ export class CaddyInstant {
540
501
  }
541
502
  }
542
503
 
543
- if (!this.locked && !this.stoped) {
544
- await this.stopCaddy()
504
+ // Caddy and the shared Caddyfile belong to every project using this
505
+ // helper. Only this project's route is removed here; the helper stops
506
+ // Caddy when its last client disconnects.
507
+ if (!this.stoped) {
545
508
  try {
546
- await lockfile.unlock(caddyLockFilePath).catch(() => {})
547
- await unlink(caddyFilePath).catch(() => {})
548
- await unlink(caddyLockFilePath).catch(() => {})
549
- if (caddyConfig) {
550
- await caddyConfig.cleanUp()
551
- }
509
+ await this.deps.config().removeProxy(this.proxyKey)
552
510
  }
553
511
  catch (e) {
554
512
  consola.error(e)
@@ -82,6 +82,8 @@ export interface PrivilegedSessionOptions {
82
82
  caddyBin: string
83
83
  /** Caddyfile the helper may load. */
84
84
  caddyConfigPath: string
85
+ /** Shown before the password prompt so the request is not a surprise. */
86
+ reason?: { target: string, https: boolean }
85
87
  }
86
88
 
87
89
  /** Where Caddy keeps its PKI, per platform. */
@@ -113,6 +115,7 @@ export function openPrivilegedSession(
113
115
  // execute a path supplied over the socket.
114
116
  caddyBin: options.caddyBin,
115
117
  caddyConfigPath: options.caddyConfigPath,
118
+ reason: options.reason,
116
119
  log: options.log,
117
120
  })
118
121
  }