unplugin-https-reverse-proxy 4.1.0-beta.3 → 4.1.0-beta.5

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,6 +1,6 @@
1
1
  import {
2
2
  unpluginFactory
3
- } from "./chunk-HBXMM25C.js";
3
+ } from "./chunk-A3KSOGWY.js";
4
4
  import "./chunk-EY3HXBJ4.js";
5
5
  import "./chunk-C5A7XLKL.js";
6
6
  import "./chunk-WOMR3ZLM.js";
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.3",
4
+ "version": "4.1.0-beta.5",
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'
@@ -133,6 +131,8 @@ function splitHostPort(addr: string): { host: string, port: string } {
133
131
  /** Injection point for tests: opens the privileged helper without sudo. */
134
132
  export interface CaddyDependencies {
135
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 = {
@@ -144,6 +144,7 @@ const defaultDependencies: CaddyDependencies = {
144
144
  caddyConfigPath: caddyFilePath,
145
145
  reason,
146
146
  }),
147
+ config: () => getCaddyConfig(),
147
148
  }
148
149
 
149
150
  export class CaddyInstant {
@@ -151,8 +152,6 @@ export class CaddyInstant {
151
152
  private stoped = false
152
153
  private readonly deps: CaddyDependencies
153
154
 
154
- /** has locked when start */
155
- private locked = false
156
155
  private caddyfile: string | undefined
157
156
 
158
157
  private _caddyChild: HelperCaddyProcess | undefined
@@ -161,21 +160,11 @@ export class CaddyInstant {
161
160
  private _source: string | undefined
162
161
  private _target: string | undefined
163
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)}`
164
165
 
165
166
  constructor(deps: Partial<CaddyDependencies> = {}) {
166
167
  this.deps = { ...defaultDependencies, ...deps }
167
- this.locked = this.checkLock()
168
- }
169
-
170
- checkLock(): boolean {
171
- if (!existsSync(caddyLockFilePath))
172
- writeFileSync(caddyLockFilePath, '')
173
- try {
174
- return lockfile.checkSync(caddyLockFilePath)
175
- }
176
- catch {
177
- return false
178
- }
179
168
  }
180
169
 
181
170
  /**
@@ -260,9 +249,15 @@ export class CaddyInstant {
260
249
  throw new Error(formatTlsHealthFailure(targetHost, health))
261
250
  }
262
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
+ */
263
258
  private async updateCaddyfile(): Promise<void> {
264
- const { configJsonRef } = getCaddyConfig()
265
- const proxies = Object.values(configJsonRef() || {})
259
+ const { readProxies } = this.deps.config()
260
+ const proxies = Object.values(readProxies())
266
261
  const ctx = validateTemplateContext({
267
262
  caddy_root: this.caddyRoot,
268
263
  proxies,
@@ -342,32 +337,17 @@ export class CaddyInstant {
342
337
  },
343
338
  ]
344
339
 
345
- const { writeCaddyConfig } = getCaddyConfig()
346
- writeCaddyConfig(proxies)
340
+ const { writeCaddyConfig } = this.deps.config()
341
+ await writeCaddyConfig(proxies, this.proxyKey)
347
342
 
348
343
  await this.updateCaddyfile()
349
344
 
350
- let skipInitialCaddyfileEffect = true
351
- this._stopEffects.push(
352
- effect(() => {
353
- getCaddyConfig().configJsonRef()
354
- if (skipInitialCaddyfileEffect) {
355
- skipInitialCaddyfileEffect = false
356
- return
357
- }
358
- void this.updateCaddyfile()
359
- }),
360
- )
361
-
362
- if (!existsSync(caddyLockFilePath))
363
- writeFileSync(caddyLockFilePath, '')
364
-
365
- if (!this.locked) {
366
- // Ports 80/443 may still be held by a previous, uncleanly stopped run.
367
- await session.client.call('port.free', { port: 443 })
368
- if (process.platform === 'win32' || !https)
369
- await session.client.call('port.free', { port: 80 })
370
- }
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 })
371
351
 
372
352
  return new Promise<void>((resolve, reject) => {
373
353
  let startupCheckStarted = false
@@ -388,8 +368,7 @@ export class CaddyInstant {
388
368
  resolve()
389
369
  }
390
370
  catch (error) {
391
- if (!this.locked)
392
- await this.baseCleanup(restore)
371
+ await this.baseCleanup(restore)
393
372
  reject(error)
394
373
  }
395
374
  }
@@ -434,84 +413,65 @@ export class CaddyInstant {
434
413
  }
435
414
  }
436
415
 
437
- if (!this.locked) {
438
- lockfile.lockSync(caddyLockFilePath)
439
- setupCleanup()
440
-
441
- void (async () => {
442
- try {
443
- const child = new HelperCaddyProcess(session.client)
444
- this._caddyChild = child
445
-
446
- await session.client.call('caddy.start')
416
+ setupCleanup()
447
417
 
448
- child.on('exit', (code: number | null) => {
449
- if (code !== null && code !== 0)
450
- reject(new Error(`Caddy exited with code ${code}`))
451
- })
452
-
453
- child.stderr.on('data', async (data: Buffer) => {
454
- const lines = data.toString().split('\n').map((line: string) => line.trim())
455
- for (const line of lines) {
456
- // caddy log
457
- // eslint-disable-next-line no-console
458
- showCaddyLog && line && console.info(line)
459
- if (isCaddyReloadCancellation(line)) {
460
- consola.info('Caddy restarted before TLS issuance completed; retrying.\n')
461
- continue
462
- }
463
- try {
464
- if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) {
465
- if (line.includes('parsing caddyfile tokens')) {
466
- consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`)
467
- await this.baseCleanup()
468
- process.exit()
469
- }
470
- else {
471
- consola.error(`${line}\n`)
472
- return reject(line)
473
- }
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)
474
450
  }
475
451
  }
476
- catch {}
477
-
478
- if (line.includes('config file changed'))
479
- consola.success('Detect added a new reverse proxy config, restarted.\n')
480
-
481
- if (line.includes('caddy proxying') || line.includes('serving initial'))
482
- void resolveWhenReady()
483
452
  }
484
- })
453
+ catch {}
485
454
 
486
- child.stdout.on('data', (data: Buffer) => {
487
- consola.info(data.toString())
488
- void resolveWhenReady()
489
- })
490
- }
491
- catch (error) {
492
- // The hosts entry was written before Caddy started; a failed start
493
- // must not leave it behind while the dev server keeps running.
494
- if (!this.locked)
495
- await this.baseCleanup(restore)
496
- reject(error)
497
- }
498
- })()
499
- }
500
- else {
501
- 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')
502
457
 
503
- void (async () => {
504
- try {
505
- if (https)
506
- await this.ensureTlsReady(targetHost)
507
- setupCleanup()
508
- resolve()
509
- }
510
- catch (error) {
511
- reject(error)
512
- }
513
- })()
514
- }
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
+ })()
515
475
  })
516
476
  }
517
477
 
@@ -541,15 +501,12 @@ export class CaddyInstant {
541
501
  }
542
502
  }
543
503
 
544
- if (!this.locked && !this.stoped) {
545
- 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) {
546
508
  try {
547
- await lockfile.unlock(caddyLockFilePath).catch(() => {})
548
- await unlink(caddyFilePath).catch(() => {})
549
- await unlink(caddyLockFilePath).catch(() => {})
550
- if (caddyConfig) {
551
- await caddyConfig.cleanUp()
552
- }
509
+ await this.deps.config().removeProxy(this.proxyKey)
553
510
  }
554
511
  catch (e) {
555
512
  consola.error(e)