unplugin-https-reverse-proxy 4.0.3 → 4.1.0-beta.10

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.
Files changed (62) hide show
  1. package/README.md +30 -6
  2. package/dist/{chunk-RSBXFJ3Z.js → chunk-C5A7XLKL.js} +9 -2
  3. package/dist/{chunk-2P7SUCQ3.js → chunk-EY3HXBJ4.js} +0 -26
  4. package/dist/chunk-RKHJN6KY.js +53 -0
  5. package/dist/{chunk-MD5CVZP7.js → chunk-U7KAIPZC.js} +982 -797
  6. package/dist/chunk-WOMR3ZLM.js +17 -0
  7. package/dist/esbuild.cjs +1086 -867
  8. package/dist/esbuild.js +5 -3
  9. package/dist/hmr-sse-bridge.cjs +12 -4
  10. package/dist/hmr-sse-bridge.d.cts +4 -3
  11. package/dist/hmr-sse-bridge.d.ts +4 -3
  12. package/dist/hmr-sse-bridge.js +5 -3
  13. package/dist/index.cjs +1086 -867
  14. package/dist/index.js +5 -3
  15. package/dist/nuxt.cjs +1057 -852
  16. package/dist/nuxt.js +6 -9
  17. package/dist/privilege-state.cjs +44 -0
  18. package/dist/privilege-state.d.cts +7 -0
  19. package/dist/privilege-state.d.ts +7 -0
  20. package/dist/privilege-state.js +10 -0
  21. package/dist/privileged-helper.cjs +824 -0
  22. package/dist/privileged-helper.d.cts +2 -0
  23. package/dist/privileged-helper.d.ts +2 -0
  24. package/dist/privileged-helper.js +797 -0
  25. package/dist/rollup.cjs +1086 -867
  26. package/dist/rollup.js +5 -3
  27. package/dist/rspack.cjs +1086 -867
  28. package/dist/rspack.js +5 -3
  29. package/dist/utils.cjs +0 -28
  30. package/dist/utils.d.cts +1 -3
  31. package/dist/utils.d.ts +1 -3
  32. package/dist/utils.js +1 -5
  33. package/dist/vite.cjs +1086 -867
  34. package/dist/vite.js +5 -3
  35. package/dist/webpack.cjs +1086 -867
  36. package/dist/webpack.js +5 -3
  37. package/package.json +1 -4
  38. package/src/caddy/caddyfile.ts +3 -0
  39. package/src/caddy/config.ts +195 -53
  40. package/src/caddy/constants.ts +30 -1
  41. package/src/caddy/index.ts +239 -206
  42. package/src/caddy/privileged.ts +124 -0
  43. package/src/caddy/utils.ts +0 -43
  44. package/src/hmr-sse-bridge.ts +14 -3
  45. package/src/index.ts +52 -25
  46. package/src/nuxt.ts +1 -6
  47. package/src/privilege-state.ts +23 -0
  48. package/src/privileged/bootstrap.ts +289 -0
  49. package/src/privileged/client.ts +288 -0
  50. package/src/privileged/entry.ts +73 -0
  51. package/src/privileged/hosts-file.ts +142 -0
  52. package/src/privileged/launch.ts +332 -0
  53. package/src/privileged/path-guard.ts +74 -0
  54. package/src/privileged/ports.ts +45 -0
  55. package/src/privileged/protocol.ts +111 -0
  56. package/src/privileged/server.ts +578 -0
  57. package/src/privileged/session.ts +169 -0
  58. package/src/privileged/validation.ts +38 -0
  59. package/src/privileged/windows-elevation.ts +80 -0
  60. package/src/privileged-helper.ts +123 -0
  61. package/src/utils.ts +0 -28
  62. package/src/host/index.ts +0 -52
package/dist/webpack.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import {
2
2
  unpluginFactory
3
- } from "./chunk-MD5CVZP7.js";
4
- import "./chunk-RSBXFJ3Z.js";
5
- import "./chunk-2P7SUCQ3.js";
3
+ } from "./chunk-U7KAIPZC.js";
4
+ import "./chunk-EY3HXBJ4.js";
5
+ import "./chunk-C5A7XLKL.js";
6
+ import "./chunk-WOMR3ZLM.js";
7
+ import "./chunk-RKHJN6KY.js";
6
8
 
7
9
  // src/webpack.ts
8
10
  import { createWebpackPlugin } from "unplugin";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "unplugin-https-reverse-proxy",
3
3
  "type": "module",
4
- "version": "4.0.3",
4
+ "version": "4.1.0-beta.10",
5
5
  "description": "",
6
6
  "license": "MIT",
7
7
  "homepage": "https://github.com/zcf0508/unplugin-https-reverse-proxy#readme",
@@ -119,7 +119,6 @@
119
119
  "console-table-printer": "^2.12.1",
120
120
  "dotenv-flow": "^4.1.0",
121
121
  "got-cjs": "^12.5.4",
122
- "hostile": "^1.4.0",
123
122
  "http-proxy-agent": "^7.0.2",
124
123
  "https-proxy-agent": "^7.0.6",
125
124
  "ora": "5",
@@ -133,8 +132,6 @@
133
132
  "@nuxt/schema": "^3.16.1",
134
133
  "@rsbuild/core": "^0.4.15",
135
134
  "@rspack/core": "^0.5.9",
136
- "@types/hostile": "^1.3.5",
137
- "@types/kill-port": "^2.0.3",
138
135
  "@types/node": "^20.11.19",
139
136
  "@types/proper-lockfile": "^4.1.4",
140
137
  "@types/qrcode-terminal": "^0.12.2",
@@ -12,6 +12,7 @@ export const ProxyType = type({
12
12
 
13
13
  const CaddyContext = type({
14
14
  caddy_root: 'string',
15
+ admin_socket: 'string',
15
16
  proxies: ProxyType.array(),
16
17
  })
17
18
 
@@ -26,6 +27,8 @@ export function validateTemplateContext(ctx: unknown): typeof CaddyContext.infer
26
27
  export const caddyTemplate = `{
27
28
  debug
28
29
  auto_https disable_redirects ignore_loaded_certs
30
+ skip_install_trust
31
+ admin unix/{{ admin_socket }}
29
32
  }
30
33
 
31
34
  :7601 {
@@ -1,72 +1,214 @@
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('{}')
33
+ }
13
34
 
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
- })
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
45
+ }
46
+ }
22
47
 
23
- function setConfigContent(): void {
24
- if (!existsSync(caddyConfigPath)) {
25
- writeFileSync(caddyConfigPath, '{}')
26
- }
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(path: string = caddyConfigPath): ProxyMap {
55
+ if (!existsSync(path))
56
+ return {}
57
+
58
+ let parsed: ProxyMap
59
+ try {
60
+ parsed = JSON.parse(readFileSync(path, 'utf-8')) as ProxyMap
61
+ }
62
+ catch {
63
+ // A half-written file is not worth preserving: it has no usable routes.
64
+ return {}
65
+ }
66
+
67
+ const alive: ProxyMap = {}
68
+ for (const [key, entry] of Object.entries(parsed)) {
69
+ if (isAlive(entry?.ownerPid))
70
+ alive[key] = entry
71
+ }
72
+ return alive
73
+ }
74
+
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, path: string = caddyConfigPath): void {
81
+ const content = JSON.stringify(proxies, null, 2)
82
+ const temp = `${path}.${process.pid}.tmp`
83
+ writeFileSync(temp, content)
84
+ renameSync(temp, path)
85
+ }
86
+
87
+ export interface CaddyConfigOptions {
88
+ /** Injected in tests so they do not need the real lock. */
89
+ lock?: ConfigLock
90
+ /**
91
+ * Config file to operate on; defaults to the shared runtime path.
92
+ *
93
+ * Injectable so a test can own a file without racing every other test that
94
+ * happens to run against the same module-level constant.
95
+ */
96
+ configPath?: string
97
+ /**
98
+ * Renders the Caddyfile from the routes, called inside the config lock.
99
+ *
100
+ * Rendering here rather than in the caller is what keeps the file in step
101
+ * with the JSON: two projects writing concurrently would otherwise render
102
+ * from their own stale view and the later write would win.
103
+ */
104
+ renderCaddyfile?: (proxies: ProxyMap) => Promise<void>
105
+ }
106
+
107
+ export function useCaddyConfig(options: CaddyConfigOptions = {}): CaddyConfigApi {
108
+ const lock = options.lock ?? createFileLock()
109
+ const configPath = options.configPath ?? caddyConfigPath
110
+
111
+ /**
112
+ * Applies the change, then re-renders while still holding the lock.
113
+ *
114
+ * The Caddyfile has to follow every change: leaving a stopped project's site
115
+ * in it would keep proxying to an upstream that is gone. Doing it inside the
116
+ * lock also picks up routes whose owner process died, since `readConfig`
117
+ * drops those.
118
+ */
119
+ async function mutate(
120
+ apply: (proxies: ProxyMap) => void,
121
+ ): Promise<ProxyMap> {
122
+ const release = await lock.acquire()
27
123
  try {
28
- configContentRef(readFileSync(caddyConfigPath, 'utf-8'))
124
+ const proxies = readConfig(configPath)
125
+ apply(proxies)
126
+ writeConfig(proxies, configPath)
127
+ await options.renderCaddyfile?.(proxies)
128
+ return proxies
29
129
  }
30
- catch {
31
- configContentRef('')
130
+ finally {
131
+ release()
32
132
  }
33
133
  }
34
134
 
35
- if (!existsSync(caddyConfigPath)) {
36
- writeFileSync(caddyConfigPath, '{}')
37
- }
135
+ return {
136
+ configJsonRef: () => (existsSync(configPath) ? readConfig(configPath) : undefined),
137
+
138
+ async writeCaddyConfig(_proxies, key) {
139
+ await mutate((proxies) => {
140
+ for (const p of _proxies)
141
+ proxies[routeKey(p, key)] = { ...p, base: normalizeBase(p.base), ownerPid: process.pid }
142
+ })
143
+ },
38
144
 
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)
145
+ async removeProxy(key) {
146
+ // The empty config is written inside the lock rather than unlinked after
147
+ // it: between the release and the unlink another project can add its
148
+ // route, and the unlink would then drop a route that is in use.
149
+ await mutate((current) => {
150
+ for (const route of Object.keys(current)) {
151
+ if (route.endsWith(`#${key}`))
152
+ delete current[route]
153
+ }
154
+ })
155
+ },
156
+
157
+ readProxies: () => readConfig(configPath),
158
+
159
+ async cleanUp() {
160
+ removeConfigFile(configPath)
161
+ },
59
162
  }
163
+ }
60
164
 
61
- async function cleanUp(): Promise<void> {
62
- await unlink(caddyConfigPath).catch(() => { })
63
- configContentRef('{}')
64
- watchCaddyConfig.close()
165
+ function removeConfigFile(path: string = caddyConfigPath): void {
166
+ try {
167
+ if (existsSync(path))
168
+ unlinkSync(path)
65
169
  }
170
+ catch {}
171
+ }
66
172
 
173
+ function normalizeBase(base: string | undefined): string {
174
+ let out = base || '/'
175
+ if (!out.startsWith('/'))
176
+ out = `/${out}`
177
+ if (out !== '/' && !out.endsWith('/'))
178
+ out = `${out}/`
179
+ return out
180
+ }
181
+
182
+ /**
183
+ * Route key: the domain it answers on, plus the owning project.
184
+ *
185
+ * Two projects can legitimately serve the same domain on different bases, so
186
+ * the key has to carry the base as well as the owner.
187
+ */
188
+ export function routeKey(proxy: typeof ProxyType.infer, ownerKey: string): string {
189
+ return `${proxy.target}${proxy.portSuffix}${normalizeBase(proxy.base)}#${ownerKey}`
190
+ }
191
+
192
+ /**
193
+ * Builds the on-disk lock.
194
+ *
195
+ * `proper-lockfile` retries because the winner may hold the lock for a moment;
196
+ * `realpath: false` keeps it working when the temp directory is a symlink.
197
+ */
198
+ function createFileLock(): ConfigLock {
67
199
  return {
68
- configJsonRef,
69
- writeCaddyConfig,
70
- cleanUp,
200
+ async acquire() {
201
+ if (!existsSync(caddyConfigPath))
202
+ writeFileSync(caddyConfigPath, '{}')
203
+
204
+ const release = await lockfile.lock(caddyConfigPath, {
205
+ realpath: false,
206
+ stale: 10_000,
207
+ retries: { retries: 20, minTimeout: 10, maxTimeout: 100 },
208
+ })
209
+ return () => {
210
+ void release().catch(() => {})
211
+ }
212
+ },
71
213
  }
72
214
  }
@@ -13,13 +13,42 @@ function resolvePath(path: string): string {
13
13
  return path
14
14
  }
15
15
 
16
- export const TEMP_DIR = resolvePath(process.env.UHRP_CADDY_PATH || process.env.TEMP || process.env.TMPDIR || '/tmp')
16
+ /**
17
+ * Directory for the Caddy binary, Caddyfile and the admin socket.
18
+ *
19
+ * The Caddy binary is executed as root, so this must not be a directory other
20
+ * users can write to. macOS gives every user a private `TMPDIR`, and `TEMP` on
21
+ * Windows is per-user too. On Linux `TMPDIR` is usually unset and `/tmp` is
22
+ * world-writable (sticky bit only prevents deleting other users' files, not
23
+ * creating `/tmp/caddy` first), so the fallback goes under the user's own cache
24
+ * directory instead.
25
+ */
26
+ function defaultTempDir(): string {
27
+ const fromEnv = process.env.TEMP || process.env.TMPDIR
28
+ if (fromEnv)
29
+ return resolvePath(fromEnv)
30
+ if (process.platform === 'linux')
31
+ return join(process.env.XDG_CACHE_HOME || join(os.homedir(), '.cache'), 'uhrp')
32
+ return '/tmp'
33
+ }
34
+
35
+ export const TEMP_DIR = resolvePath(process.env.UHRP_CADDY_PATH || defaultTempDir())
17
36
  export const caddyPath = join(TEMP_DIR, `caddy${process.platform === 'win32' ? '.exe' : ''}`)
18
37
  export const caddyFilePath = join(TEMP_DIR, 'CADDYFILE')
19
38
 
20
39
  export const caddyConfigPath = join(TEMP_DIR, '.CADDYFILE.config.json')
21
40
  export const caddyLockFilePath = join(TEMP_DIR, 'caddylock')
22
41
 
42
+ /**
43
+ * Admin API socket, shared by every project using this helper.
44
+ *
45
+ * Caddy runs as root, so its admin API must not listen on a TCP port any local
46
+ * user can reach: `POST /load` there reconfigures the proxy with no further
47
+ * check. A unix socket inside the runtime directory is reachable only by whoever
48
+ * can open that directory, and the directory is private on macOS.
49
+ */
50
+ export const caddyAdminSocketPath = join(TEMP_DIR, 'caddy-admin.sock')
51
+
23
52
  export const supportList = [
24
53
  {
25
54
  platform: 'dragonfly',