unplugin-https-reverse-proxy 1.0.0-beta.9 → 2.0.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.
@@ -1,17 +1,19 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { readFile, unlink, writeFile } from 'node:fs/promises'
1
4
  import { platform } from 'node:os'
5
+ import { join } from 'node:path'
2
6
  import process from 'node:process'
3
- import { chmodSync, createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs'
4
- import { spawn } from 'node:child_process'
5
7
  import { got } from 'got-cjs'
6
8
  import { HttpProxyAgent } from 'http-proxy-agent'
7
9
  import { HttpsProxyAgent } from 'https-proxy-agent'
8
- import kill from 'kill-port'
9
- import { chmodRecursive, consola, once } from '../utils'
10
+ import * as lockfile from 'proper-lockfile'
10
11
  import { addHost, removeHost } from '../host'
11
- import { TEMP_DIR, caddyPath, supportList } from './constants'
12
- import { logProgress, logProgressOver, tryPort } from './utils'
12
+ import { chmodRecursive, consola, once } from '../utils'
13
+ import { caddyFilePath, caddyLockFilePath, caddyPath, supportList, TEMP_DIR } from './constants'
14
+ import { kill, logProgress, logProgressOver, tryPort } from './utils'
13
15
 
14
- export async function download() {
16
+ export async function download(): Promise<string> {
15
17
  if (await testCaddy())
16
18
  return caddyPath
17
19
 
@@ -37,7 +39,7 @@ export async function download() {
37
39
  if (!support)
38
40
  return reject(new Error('not support'))
39
41
 
40
- const dowmloadLink = `https://caddyserver.com/api/download?os=${support.platform}&arch=${support.arch}`
42
+ const dowmloadLink = `https://caddyserver.com/api/download?os=${support.platform}&arch=${support.arch}&p=github.com%2Fcaddyserver%2Fforwardproxy`
41
43
 
42
44
  existsSync(caddyPath) && unlinkSync(caddyPath)
43
45
 
@@ -74,7 +76,7 @@ export async function download() {
74
76
  })
75
77
  }
76
78
 
77
- function testCaddy() {
79
+ function testCaddy(): Promise<boolean> {
78
80
  return new Promise((resolve, reject) => {
79
81
  if (!existsSync(caddyPath))
80
82
  return resolve(false)
@@ -103,13 +105,82 @@ export class CaddyInstant {
103
105
  private inited = false
104
106
  private stoped = false
105
107
 
108
+ /** has locked */
109
+ private locked = false
110
+ private caddyfile: string | undefined
111
+
112
+ private _caddyChild: ReturnType<typeof spawn> | undefined
113
+
114
+ private _source: string | undefined
115
+ private _target: string | undefined
116
+
106
117
  constructor() {
107
- testCaddy().then(() => {
118
+ testCaddy().then(async () => {
119
+ await this.getCaddyfile()
120
+
108
121
  this.inited = true
109
122
  })
123
+
124
+ this.locked = this.checkLock()
125
+ }
126
+
127
+ checkLock(): boolean {
128
+ if (!existsSync(caddyLockFilePath))
129
+ writeFileSync(caddyLockFilePath, '')
130
+ return lockfile.checkSync(caddyLockFilePath)
131
+ }
132
+
133
+ async getCaddyfile(): Promise<string> {
134
+ if (!existsSync(caddyFilePath))
135
+ await this.initCaddyfile()
136
+
137
+ this.caddyfile = await readFile(caddyFilePath, 'utf-8')
138
+ return this.caddyfile
139
+ }
140
+
141
+ async initCaddyfile(): Promise<void> {
142
+ const contet = `
143
+ {
144
+ debug
145
+ auto_https disable_redirects
146
+ }
147
+
148
+ :7601 {
149
+ root * "${join(
150
+ process.platform === 'win32'
151
+ ? `${process.env.AppData!}/Caddy`
152
+ : `${process.env.HOME!}/Library/Application Support/Caddy`,
153
+ './pki/authorities/local',
154
+ )}"
155
+ file_server browse
156
+ }
157
+
158
+ # 正向代理配置
159
+ :7600 {
160
+ bind 0.0.0.0
161
+
162
+ forward_proxy {
163
+ hide_ip
164
+ hide_via
165
+ acl {
166
+ allow all
167
+ }
168
+ }
169
+ }
170
+ `
171
+ await writeFile(caddyFilePath, contet)
172
+ this.caddyfile = contet
173
+ }
174
+
175
+ async writeCaddyfile(): Promise<void> {
176
+ return await writeFile(caddyFilePath, this.caddyfile || '')
177
+ }
178
+
179
+ async deleteCaddyfile(): Promise<void> {
180
+ return await unlink(caddyFilePath)
110
181
  }
111
182
 
112
- async init() {
183
+ async init(): Promise<void> {
113
184
  await download()
114
185
  this.inited = true
115
186
  }
@@ -117,9 +188,8 @@ export class CaddyInstant {
117
188
  /**
118
189
  * @param source like `127.0.0.1:8080`
119
190
  * @param target like `test.abc.com`. And if you provide a port ,it will be ignore.
120
- * @returns
121
191
  */
122
- async run(source: string, target: string, options?: Partial<RunOptions>) {
192
+ async run(source: string, target: string, options?: Partial<RunOptions>): Promise<void> {
123
193
  source = source.replace('0.0.0.0', '127.0.0.1')
124
194
  const {
125
195
  restore = true,
@@ -128,111 +198,193 @@ export class CaddyInstant {
128
198
  https = false,
129
199
  } = options || {}
130
200
 
201
+ this._source = source
202
+ this._target = target
203
+
131
204
  if (!this.inited)
132
205
  await this.init()
133
206
 
134
- if (https && await tryPort(443))
135
- await kill(443, 'tcp')
136
- if ((process.platform === 'win32' || !https) && await tryPort(80))
137
- await kill(80, 'tcp')
138
-
139
207
  if (!await addHost(source.split(':')[0], target.split(':')[0]))
140
208
  throw new Error('update host failed')
141
209
  consola.success('update host success\n')
142
210
 
143
- return new Promise<void>((resolve, reject) => {
144
- // caddy reverse-proxy --from target --to source --internal-certs
145
- const child = process.platform !== 'win32'
146
- ? spawn('sudo', ['-E', caddyPath, 'reverse-proxy', '--from', `${target.split(':')[0]}${https ? '' : ':80'}`, '--to', `${source}`, '--internal-certs', '--insecure', '--disable-redirects'])
147
- : spawn(caddyPath, ['reverse-proxy', '--from', `${target.split(':')[0]}`, '--to', `${source}`, '--internal-certs'])
211
+ await this.getCaddyfile()
212
+ if (!(this.caddyfile || '').includes(`${target.split(':')[0]}${https ? '' : ':80'}`)) {
213
+ this.caddyfile = `${this.caddyfile}
214
+ ${target.split(':')[0]}${https ? '' : ':80'} {
215
+ ${https ? 'tls internal' : ''}
216
+ reverse_proxy http://${source} {
217
+ health_uri ${base}
218
+ health_interval 2s
219
+ health_timeout 5s
220
+ fail_duration 2s
221
+ unhealthy_status 502 503 504 404
222
+ lb_try_duration 3s
223
+ lb_try_interval 300ms
224
+ health_headers {
225
+ Accept text/html
226
+ Accept-Encoding gzip, deflate, br
227
+ User-Agent Caddy-Health-Check
228
+ }
229
+
230
+ @error status 502 503 504 404
231
+ handle_response @error {
232
+ respond * 503 {
233
+ body "Service Unavailable"
234
+ close
235
+ }
236
+ }
237
+ }
238
+ }
239
+ `
148
240
 
149
- child.on('error', (err) => {
150
- return reject(err)
151
- })
241
+ await this.writeCaddyfile()
242
+ }
243
+ if (!existsSync(caddyLockFilePath))
244
+ writeFileSync(caddyLockFilePath, '')
245
+
246
+ if (!this.locked) {
247
+ if (await tryPort(443))
248
+ await kill(443)
249
+ if ((process.platform === 'win32' || !https) && await tryPort(80))
250
+ await kill(80)
251
+ }
152
252
 
153
- process.on('SIGINT', async () => {
154
- if (!restore || this.stoped)
155
- return
253
+ return new Promise<void>((resolve, reject) => {
254
+ const setupCleanup = (): void => {
255
+ process.on('SIGINT', async () => {
256
+ if (this.stoped)
257
+ return
156
258
 
157
- try {
158
- if (await removeHost(source.split(':')[0], target.split(':')[0]))
159
- consola.success('restore host success\n')
259
+ process.stdin.resume()
160
260
 
161
- else
162
- consola.fail('restore host failed\n')
163
- }
164
- catch (e) {
165
- consola.error(e)
166
- consola.fail('restore host failed\n')
167
- }
168
- finally {
169
- this.stoped = true
170
- process.nextTick(() => {
171
- process.exit()
172
- })
173
- }
174
- })
175
-
176
- const originalExit = process.exit.bind(process)
177
-
178
- // @ts-expect-error override
179
- process.exit = async (code?: number) => {
180
- const port = Number(source.split(':')[1])
181
- if (!Number.isNaN(port) && await tryPort(port))
182
- await kill(port, 'tcp')
183
-
184
- // fix `Error: EPERM: operation not permitted`
185
- const pwd = process.cwd()
186
- const viteCacheDir = `${pwd}/node_modules/.vite`
187
- if (existsSync(viteCacheDir))
188
- chmodRecursive(viteCacheDir, 0o777)
189
- const nuxtCacheDir = `${pwd}/.nuxt`
190
- if (existsSync(nuxtCacheDir))
191
- await chmodRecursive(nuxtCacheDir, 0o777)
192
- const webpackCacheDir = `${pwd}/node_modules/.cache`
193
- if (existsSync(webpackCacheDir))
194
- chmodRecursive(webpackCacheDir, 0o777)
195
-
196
- if (!restore || this.stoped)
197
- return originalExit(code)
198
-
199
- try {
200
- if (await removeHost(source.split(':')[0], target.split(':')[0]))
201
- consola.success('restore host success\n')
202
-
203
- else
204
- consola.fail('restore host failed\n')
205
- }
206
- catch (e) {
207
- consola.error(e)
208
- consola.fail('restore host failed\n')
209
- }
210
- finally {
211
- this.stoped = true
212
- process.nextTick(() => {
213
- originalExit(code)
214
- })
261
+ try {
262
+ await this.baseCleanup(restore)
263
+ }
264
+ finally {
265
+ this.stoped = true
266
+ process.nextTick(() => {
267
+ process.exit()
268
+ })
269
+ }
270
+ })
271
+
272
+ const originalExit = process.exit.bind(process)
273
+
274
+ // @ts-expect-error override
275
+ process.exit = async (code?: number) => {
276
+ const port = Number(source.split(':')[1])
277
+ if (!Number.isNaN(port) && await tryPort(port))
278
+ await kill(port)
279
+
280
+ // fix `Error: EPERM: operation not permitted`
281
+ const pwd = process.cwd()
282
+ const viteCacheDir = `${pwd}/node_modules/.vite`
283
+ if (existsSync(viteCacheDir))
284
+ await chmodRecursive(viteCacheDir, 0o777).catch(() => { })
285
+ const nuxtCacheDir = `${pwd}/.nuxt`
286
+ if (existsSync(nuxtCacheDir))
287
+ await chmodRecursive(nuxtCacheDir, 0o777).catch(() => { })
288
+ const webpackCacheDir = `${pwd}/node_modules/.cache`
289
+ if (existsSync(webpackCacheDir))
290
+ await chmodRecursive(webpackCacheDir, 0o777).catch(() => { })
291
+
292
+ if (this.stoped)
293
+ return originalExit(code)
294
+
295
+ process.stdin.resume()
296
+
297
+ try {
298
+ await this.baseCleanup()
299
+ }
300
+
301
+ finally {
302
+ this.stoped = true
303
+ process.nextTick(() => {
304
+ originalExit(code)
305
+ })
306
+ }
215
307
  }
216
308
  }
217
309
 
218
- child.stderr.on('data', (data) => {
219
- const lines = (data.toString() as string).split('\n').map(line => line.trim())
220
- for (const line of lines) {
221
- // caddy log
222
- // eslint-disable-next-line no-console
223
- showCaddyLog && line && console.info(line)
224
- if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) {
225
- consola.error(line)
226
- // child.kill()
227
- return reject(line)
310
+ if (!this.locked) {
311
+ this._caddyChild = process.platform !== 'win32'
312
+ ? spawn('sudo', ['-E', caddyPath, 'run', '--config', caddyFilePath, '--watch'])
313
+ : spawn(caddyPath, ['run', '--config', caddyFilePath, '--watch'])
314
+
315
+ lockfile.lockSync(caddyLockFilePath)
316
+
317
+ this._caddyChild!.on('error', (err) => {
318
+ return reject(err)
319
+ })
320
+
321
+ this._caddyChild!.stderr?.on('data', async (data) => {
322
+ const lines = (data.toString() as string).split('\n').map(line => line.trim())
323
+ for (const line of lines) {
324
+ // caddy log
325
+ // eslint-disable-next-line no-console
326
+ showCaddyLog && line && console.info(line)
327
+ if (line.includes('Error:') || (line && JSON.parse(line).level === 'error')) {
328
+ if (line.includes('parsing caddyfile tokens')) {
329
+ consola.error('Caddyfile syntax error, please contact the author:\n', `${this.caddyfile}\n\n${line}\n`)
330
+ await this.baseCleanup()
331
+ process.exit()
332
+ }
333
+ else {
334
+ consola.error(`${line}\n`)
335
+ // child.kill()
336
+ return reject(line)
337
+ }
338
+ }
339
+ if (line.includes('config file changed'))
340
+ consola.success('Detect added a new reverse proxy config, restarted.\n')
341
+
342
+ if (line.includes('caddy proxying') || line.includes('serving initial'))
343
+ return resolve()
228
344
  }
229
- }
230
- })
345
+ })
346
+
347
+ this._caddyChild!.stdout?.on('data', (_data) => {
348
+ consola.info(_data.toString())
349
+ resolve()
350
+ })
351
+
352
+ setupCleanup()
353
+ }
354
+ else {
355
+ consola.info('Caddy is running in another process.')
356
+
357
+ setupCleanup()
231
358
 
232
- child.stdout.on('data', (_data) => {
233
- consola.info(_data.toString())
234
- resolve()
235
- })
359
+ return resolve()
360
+ }
236
361
  })
237
362
  }
363
+
364
+ async baseCleanup(restore: boolean = true): Promise<void> {
365
+ try {
366
+ if (restore && this._source && this._target) {
367
+ if (await removeHost(this._source.split(':')[0], this._target.split(':')[0]))
368
+ consola.success('Restore host success.\n')
369
+
370
+ else
371
+ consola.fail('Restore host failed.\n')
372
+ }
373
+ }
374
+ catch (e) {
375
+ consola.error(e)
376
+ consola.fail('Restore host failed.\n')
377
+ }
378
+ if (!this.locked && !this.stoped) {
379
+ this._caddyChild?.kill()
380
+ try {
381
+ await lockfile.unlock(caddyLockFilePath).catch(() => { })
382
+ await unlink(caddyFilePath).catch(() => { })
383
+ await unlink(caddyLockFilePath).catch(() => { })
384
+ }
385
+ catch (e) {
386
+ consola.error(e)
387
+ }
388
+ }
389
+ }
238
390
  }
@@ -1,3 +1,4 @@
1
+ import { exec } from 'node:child_process'
1
2
  import { createServer } from 'node:net'
2
3
  import ora from 'ora'
3
4
  import { consola } from '../utils'
@@ -7,8 +8,8 @@ let spinner: ora.Ora
7
8
  /**
8
9
  * @returns whether the port is in use
9
10
  */
10
- export function tryPort(port: number) {
11
- return new Promise<boolean>((resolve, reject) => {
11
+ export function tryPort(port: number): Promise<boolean> {
12
+ return new Promise<boolean>((resolve, _reject) => {
12
13
  try {
13
14
  const server = createServer().listen(port)
14
15
  server.on('listening', () => {
@@ -22,13 +23,30 @@ export function tryPort(port: number) {
22
23
  server.close()
23
24
  })
24
25
  }
25
- catch (e) {
26
+ catch {
26
27
  resolve(true)
27
28
  }
28
29
  })
29
30
  }
30
31
 
31
- function getNumber(number: number) {
32
+ /**
33
+ * npx free-port :port
34
+ */
35
+ export async function kill(port: number): Promise<void> {
36
+ return new Promise<void>((resolve, reject) => {
37
+ exec(`npx @maxbo/free-port ${port} -s`, (err, stdout, _stderr) => {
38
+ if (err) {
39
+ reject(err)
40
+ }
41
+ else {
42
+ consola.log(stdout)
43
+ resolve()
44
+ }
45
+ })
46
+ })
47
+ }
48
+
49
+ function getNumber(number: number): string {
32
50
  if (number <= 0)
33
51
  return '0%'
34
52
  else if (number < 100)
@@ -37,7 +55,7 @@ function getNumber(number: number) {
37
55
  return '100%'
38
56
  }
39
57
 
40
- export function logProgress(percent: number | string) {
58
+ export function logProgress(percent: number | string): void {
41
59
  if (!spinner) {
42
60
  // empty a line before start
43
61
  consola.log('')
@@ -50,7 +68,7 @@ export function logProgress(percent: number | string) {
50
68
  else { spinner.text = `Download Caddy: ${getNumber(Number(percent) * 100)}` }
51
69
  }
52
70
 
53
- export function logProgressOver() {
71
+ export function logProgressOver(): void {
54
72
  if (!spinner) {
55
73
  spinner = ora({
56
74
  text: 'Start Download Caddy: 100%',
package/src/host/index.ts CHANGED
@@ -1,57 +1,52 @@
1
- import process from 'node:process'
2
1
  import { exec } from 'node:child_process'
3
2
  import { chmodSync } from 'node:fs'
3
+ import process from 'node:process'
4
+ import { promisify } from 'node:util'
4
5
 
5
- // @ts-expect-error no type
6
- import _Hosts from 'hosts-so-easy'
6
+ import hostile from 'hostile'
7
7
  import { consola } from '../utils'
8
8
 
9
- const Hosts = _Hosts.default || _Hosts
10
-
11
- const hostInstance = new Hosts({
12
- debounceTime: 50,
13
- hostsFile: getPathOfSystemHostsPath(),
14
- })
9
+ const _setHost = promisify(hostile.set)
10
+ const _removeHost = promisify(hostile.remove)
15
11
 
16
- function getPathOfSystemHostsPath() {
12
+ function getPathOfSystemHostsPath(): string {
17
13
  return process.platform === 'win32'
18
14
  ? `${process.env.windir || 'C:\\WINDOWS'}\\system32\\drivers\\etc\\hosts`
19
15
  : '/etc/hosts'
20
16
  }
21
17
 
22
- async function syncHost() {
23
- return new Promise<boolean>((resolve, reject) => {
18
+ async function syncHost(): Promise<boolean> {
19
+ return new Promise<boolean>((resolve, _reject) => {
24
20
  exec(
25
21
  process.platform === 'win32'
26
22
  ? 'ipconfig /flushdns'
27
23
  : 'sudo -E killall -HUP mDNSResponder',
28
- (error, stdout, stderr) => {
24
+ (error, _stdout, _stderr) => {
29
25
  if (error) {
30
26
  consola.error(`exec error: ${error}`)
31
27
  return resolve(false)
32
28
  }
33
29
  return resolve(true)
34
- })
30
+ },
31
+ )
35
32
  })
36
33
  }
37
34
 
38
- export async function addHost(ip: string, host: string) {
35
+ export async function addHost(ip: string, host: string): Promise<boolean> {
39
36
  if (process.platform !== 'win32')
40
37
  chmodSync(getPathOfSystemHostsPath(), 0o777)
41
- hostInstance.add('127.0.0.1', 'localhost')
42
- hostInstance.add(ip, host)
38
+ await _setHost('127.0.0.1', 'localhost')
39
+ await _setHost(ip, host)
43
40
  if (ip === 'localhost')
44
- hostInstance.add('127.0.0.1', host)
45
- await hostInstance.updateFinish()
41
+ await _setHost('127.0.0.1', host)
46
42
  return await syncHost()
47
43
  }
48
44
 
49
- export async function removeHost(ip: string, host: string) {
45
+ export async function removeHost(ip: string, host: string): Promise<boolean> {
50
46
  if (process.platform !== 'win32')
51
47
  chmodSync(getPathOfSystemHostsPath(), 0o777)
52
- hostInstance.remove(ip, host)
48
+ await _removeHost(ip, host)
53
49
  if (ip === 'localhost')
54
- hostInstance.remove('127.0.0.1', host)
55
- await hostInstance.updateFinish()
50
+ await _removeHost('127.0.0.1', host)
56
51
  return await syncHost()
57
52
  }