dsh-zen-remote 2.3.11 → 2.4.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.
- package/CHANGELOG.md +39 -78
- package/README.md +94 -84
- package/README.zh-CN.md +90 -81
- package/bin/zen-remote.mjs +5 -0
- package/docs/interface.md +6 -6
- package/docs/remote-access.en.md +6 -4
- package/docs/remote-access.md +6 -4
- package/dsh-push.mjs +96 -10
- package/lib/adapters/dsh-better-sidebar/index.js +7 -3
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/admin-routes.js +10 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/cli/args.js +251 -0
- package/lib/cli/args.js.map +1 -0
- package/lib/cli/bootstrap.js +449 -0
- package/lib/cli/bootstrap.js.map +1 -0
- package/lib/cli/command-setup.js +484 -0
- package/lib/cli/command-setup.js.map +1 -0
- package/lib/cli/commands-client.js +359 -0
- package/lib/cli/commands-client.js.map +1 -0
- package/lib/cli/commands-host.js +1108 -0
- package/lib/cli/commands-host.js.map +1 -0
- package/lib/cli/commands-status.js +279 -0
- package/lib/cli/commands-status.js.map +1 -0
- package/lib/cli/config-writer.js +334 -0
- package/lib/cli/config-writer.js.map +1 -0
- package/lib/cli/context.js +10 -0
- package/lib/cli/context.js.map +1 -0
- package/lib/cli/i18n.js +620 -0
- package/lib/cli/i18n.js.map +1 -0
- package/lib/cli/ipc-client.js +160 -0
- package/lib/cli/ipc-client.js.map +1 -0
- package/lib/cli/ipc-server.js +464 -0
- package/lib/cli/ipc-server.js.map +1 -0
- package/lib/cli/main.js +245 -0
- package/lib/cli/main.js.map +1 -0
- package/lib/cli/qr-terminal.js +48 -0
- package/lib/cli/qr-terminal.js.map +1 -0
- package/lib/client-data/qr.js +286 -0
- package/lib/client-data/qr.js.map +1 -0
- package/lib/client-routes.js +308 -25
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +1013 -229
- package/lib/config.js +50 -12
- package/lib/config.js.map +1 -1
- package/lib/debug-report.js +28 -1
- package/lib/debug-report.js.map +1 -1
- package/lib/fetch-route-intercept.js +334 -26
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +564 -363
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +1833 -280
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +36 -10
- package/lib/merge-streams.js +103 -11
- package/lib/merge-streams.js.map +1 -1
- package/lib/plugin-route-intercept.js +93 -63
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/relay-access.js +8 -1
- package/lib/relay-access.js.map +1 -1
- package/lib/relay-client.js +133 -4
- package/lib/relay-client.js.map +1 -1
- package/lib/relay-pool.js +275 -0
- package/lib/relay-pool.js.map +1 -0
- package/lib/relay-server.js +269 -17
- package/lib/relay-server.js.map +1 -1
- package/lib/restart-fields.js +6 -4
- package/lib/restart-fields.js.map +1 -1
- package/lib/share-store.js +65 -27
- package/lib/share-store.js.map +1 -1
- package/lib/state-store.js +400 -0
- package/lib/state-store.js.map +1 -0
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +3 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/cli/args.d.ts +78 -0
- package/lib/types/cli/args.d.ts.map +1 -0
- package/lib/types/cli/bootstrap.d.ts +170 -0
- package/lib/types/cli/bootstrap.d.ts.map +1 -0
- package/lib/types/cli/command-setup.d.ts +72 -0
- package/lib/types/cli/command-setup.d.ts.map +1 -0
- package/lib/types/cli/commands-client.d.ts +36 -0
- package/lib/types/cli/commands-client.d.ts.map +1 -0
- package/lib/types/cli/commands-host.d.ts +82 -0
- package/lib/types/cli/commands-host.d.ts.map +1 -0
- package/lib/types/cli/commands-status.d.ts +46 -0
- package/lib/types/cli/commands-status.d.ts.map +1 -0
- package/lib/types/cli/config-writer.d.ts +92 -0
- package/lib/types/cli/config-writer.d.ts.map +1 -0
- package/lib/types/cli/context.d.ts +39 -0
- package/lib/types/cli/context.d.ts.map +1 -0
- package/lib/types/cli/i18n.d.ts +32 -0
- package/lib/types/cli/i18n.d.ts.map +1 -0
- package/lib/types/cli/ipc-client.d.ts +83 -0
- package/lib/types/cli/ipc-client.d.ts.map +1 -0
- package/lib/types/cli/ipc-server.d.ts +113 -0
- package/lib/types/cli/ipc-server.d.ts.map +1 -0
- package/lib/types/cli/main.d.ts +57 -0
- package/lib/types/cli/main.d.ts.map +1 -0
- package/lib/types/cli/qr-terminal.d.ts +17 -0
- package/lib/types/cli/qr-terminal.d.ts.map +1 -0
- package/lib/types/client/RemoteStatusIcon.d.ts.map +1 -1
- package/lib/types/client/current-session-report.d.ts +4 -0
- package/lib/types/client/current-session-report.d.ts.map +1 -1
- package/lib/types/client/effects/model-group-side.d.ts.map +1 -1
- package/lib/types/client/effects/remote-session.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +47 -3
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/settings/settings-css.d.ts +1 -1
- package/lib/types/client/settings/settings-css.d.ts.map +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts +1 -1
- package/lib/types/client/styles/remote-session.css.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -1
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts +1 -1
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -1
- package/lib/types/client-data/model-group-side.d.ts +23 -2
- package/lib/types/client-data/model-group-side.d.ts.map +1 -1
- package/lib/types/client-data/remote-status.d.ts +47 -1
- package/lib/types/client-data/remote-status.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +85 -1
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +56 -1
- package/lib/types/client-data/wizard.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +25 -1
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/config.d.ts +40 -14
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/debug-report.d.ts.map +1 -1
- package/lib/types/fetch-route-intercept.d.ts +66 -8
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +9 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +72 -10
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts +79 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +25 -9
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/relay-access.d.ts +3 -1
- package/lib/types/relay-access.d.ts.map +1 -1
- package/lib/types/relay-client.d.ts +23 -2
- package/lib/types/relay-client.d.ts.map +1 -1
- package/lib/types/relay-pool.d.ts +148 -0
- package/lib/types/relay-pool.d.ts.map +1 -0
- package/lib/types/relay-server.d.ts +30 -3
- package/lib/types/relay-server.d.ts.map +1 -1
- package/lib/types/restart-fields.d.ts +5 -5
- package/lib/types/restart-fields.d.ts.map +1 -1
- package/lib/types/share-store.d.ts.map +1 -1
- package/lib/types/state-store.d.ts +179 -0
- package/lib/types/state-store.d.ts.map +1 -0
- package/lib/types/upload-limits.d.ts +15 -0
- package/lib/types/upload-limits.d.ts.map +1 -1
- package/lib/types/virtual-id.d.ts +21 -0
- package/lib/types/virtual-id.d.ts.map +1 -1
- package/lib/types/wizard/probe.d.ts.map +1 -1
- package/lib/types/wizard/proxy-snippets.d.ts +3 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -1
- package/lib/types/wizard/routes.d.ts +1 -0
- package/lib/types/wizard/routes.d.ts.map +1 -1
- package/lib/types/wizard/status.d.ts +9 -0
- package/lib/types/wizard/status.d.ts.map +1 -1
- package/lib/types/wizard/verify.d.ts +3 -0
- package/lib/types/wizard/verify.d.ts.map +1 -1
- package/lib/upload-limits.js +15 -0
- package/lib/upload-limits.js.map +1 -1
- package/lib/virtual-id.js +60 -0
- package/lib/virtual-id.js.map +1 -1
- package/lib/wizard/probe.js +46 -1
- package/lib/wizard/probe.js.map +1 -1
- package/lib/wizard/proxy-snippets.js +14 -1
- package/lib/wizard/proxy-snippets.js.map +1 -1
- package/lib/wizard/routes.js +23 -4
- package/lib/wizard/routes.js.map +1 -1
- package/lib/wizard/status.js +21 -0
- package/lib/wizard/status.js.map +1 -1
- package/lib/wizard/verify.js +31 -8
- package/lib/wizard/verify.js.map +1 -1
- package/package.json +8 -4
- package/pwa/icons/badge-96.png +0 -0
- package/pwa/icons/badge.svg +1 -0
- package/pwa/manifest.json +2 -1
- package/pwa/sw.js +3 -1
- package/scripts/check-debug-report.mjs +41 -1
- package/scripts/check-settings-form.mjs +14 -3
- package/scripts/check-settings-view.mjs +247 -0
- package/scripts/check-wizard-ui.mjs +37 -0
- package/src/adapters/dsh-better-sidebar/index.ts +7 -3
- package/src/admin-routes.ts +10 -2
- package/src/cli/args.ts +289 -0
- package/src/cli/bootstrap.ts +569 -0
- package/src/cli/command-setup.ts +540 -0
- package/src/cli/commands-client.ts +369 -0
- package/src/cli/commands-host.ts +1127 -0
- package/src/cli/commands-status.ts +296 -0
- package/src/cli/config-writer.ts +354 -0
- package/src/cli/context.ts +38 -0
- package/src/cli/i18n.ts +645 -0
- package/src/cli/ipc-client.ts +216 -0
- package/src/cli/ipc-server.ts +531 -0
- package/src/cli/main.ts +285 -0
- package/src/cli/qr-terminal.ts +49 -0
- package/src/client/RemoteStatusIcon.tsx +4 -1
- package/src/client/current-session-report.ts +28 -4
- package/src/client/effects/model-group-side.ts +19 -5
- package/src/client/effects/remote-session.ts +26 -6
- package/src/client/locales.ts +96 -6
- package/src/client/settings/SettingsSection.tsx +287 -92
- package/src/client/settings/settings-css.ts +18 -0
- package/src/client/styles/remote-session.css.ts +15 -1
- package/src/client/wizard/GatewayWizard.tsx +470 -109
- package/src/client/wizard/cards/ProxyCard.tsx +2 -1
- package/src/client/wizard/wizard-css.ts +23 -0
- package/src/client-data/model-group-side.ts +37 -10
- package/src/client-data/remote-status.ts +112 -17
- package/src/client-data/settings-form.ts +164 -8
- package/src/client-data/wizard.ts +112 -2
- package/src/client-routes.ts +334 -26
- package/src/config.ts +62 -16
- package/src/debug-report.ts +27 -1
- package/src/fetch-route-intercept.ts +381 -32
- package/src/index.ts +566 -367
- package/src/intercept.ts +1866 -286
- package/src/merge-streams.ts +155 -10
- package/src/plugin-route-intercept.ts +120 -71
- package/src/relay-access.ts +14 -3
- package/src/relay-client.ts +163 -5
- package/src/relay-pool.ts +387 -0
- package/src/relay-server.ts +312 -16
- package/src/restart-fields.ts +6 -4
- package/src/share-store.ts +59 -26
- package/src/state-store.ts +428 -0
- package/src/upload-limits.ts +16 -0
- package/src/virtual-id.ts +57 -0
- package/src/wizard/probe.ts +54 -2
- package/src/wizard/proxy-snippets.ts +17 -2
- package/src/wizard/routes.ts +24 -5
- package/src/wizard/status.ts +28 -0
- package/src/wizard/verify.ts +33 -11
- package/AGENTS.md +0 -185
- package/docs/adr/0001-plugin-bridge-forwards-plugin-routes.md +0 -15
- package/docs/adr/0002-relay-emulates-websocket.md +0 -8
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's local IPC control server (CLI-1): a small HTTP server over a
|
|
3
|
+
* 0600 Unix domain socket (`<dshHome>/zen-remote-cli.sock`, Windows: a named
|
|
4
|
+
* pipe), owned by the DSH plugin process. Every DSH `webServer` route sits
|
|
5
|
+
* behind signed-cookie auth, and the pieces the CLI must drive (the relay
|
|
6
|
+
* pool, the managed Caddy / cloudflared children, the wizard state) follow
|
|
7
|
+
* the plugin's single-in-process-owner discipline — so the CLI talks to THIS
|
|
8
|
+
* process over THIS socket instead of a second entry point. Same user, same
|
|
9
|
+
* machine: the 0600 socket IS the authentication.
|
|
10
|
+
*
|
|
11
|
+
* Lifecycle: the server is module-level (one per process, per dshHome). A
|
|
12
|
+
* plugin row reload — including the role flips `restart-watcher` triggers —
|
|
13
|
+
* re-acquires it and swaps the request DELEGATE in place; the underlying
|
|
14
|
+
* socket never unbinds, so a CLI session survives every reload. When the
|
|
15
|
+
* last owning row released it and a short grace period passes without a new
|
|
16
|
+
* acquire, the server closes and the socket file is removed (the grace
|
|
17
|
+
* covers the cordis dispose order, where the OLD row's disposer runs after
|
|
18
|
+
* the NEW row's apply — a plain refcount would tear down between them).
|
|
19
|
+
*
|
|
20
|
+
* The wiring side (src/index.ts) folds the acquire/release into the existing
|
|
21
|
+
* `'dsh-zen-remote: restart watcher'` effect on purpose: test/
|
|
22
|
+
* role-wiring.test.cjs pins the exact number of top-level `ctx.effect`
|
|
23
|
+
* registrations per role, so the IPC lifecycle may not add one.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { createServer, get } from 'node:http'
|
|
27
|
+
import type { IncomingMessage, RequestListener, Server, ServerResponse } from 'node:http'
|
|
28
|
+
import { chmodSync, existsSync, unlinkSync } from 'node:fs'
|
|
29
|
+
import { join } from 'node:path'
|
|
30
|
+
import { connectsRole, servesRole } from '../config.js'
|
|
31
|
+
import type { ZenRemoteConfig } from '../config.js'
|
|
32
|
+
import { responseJson } from '../http.js'
|
|
33
|
+
import { ADMIN_ROUTE_PREFIX } from '../admin-routes.js'
|
|
34
|
+
import { CLIENT_ROUTE_PREFIX } from '../client-routes.js'
|
|
35
|
+
import { WIZARD_ROUTE_PREFIX } from '../wizard/routes.js'
|
|
36
|
+
|
|
37
|
+
/** The CLI protocol's major version. A client asking for a different one is
|
|
38
|
+
* talking to a build it cannot drive (SPEC: refuse and tell the user to
|
|
39
|
+
* upgrade the plugin). */
|
|
40
|
+
export const CLI_PROTOCOL_VERSION = 1
|
|
41
|
+
|
|
42
|
+
/** The protocol handshake route. */
|
|
43
|
+
export const CLI_PING_ROUTE = '/_dsh/zen-remote/cli/ping'
|
|
44
|
+
|
|
45
|
+
/** The config-write route (POST `{set, unset}` → the anti-shadowing writer). */
|
|
46
|
+
export const CLI_CONFIG_ROUTE = '/_dsh/zen-remote/cli/config'
|
|
47
|
+
|
|
48
|
+
/** Socket file / pipe basename: `zen-remote-cli.sock`, `-2.sock`, … */
|
|
49
|
+
export const CLI_SOCKET_BASENAME = 'zen-remote-cli'
|
|
50
|
+
|
|
51
|
+
/** How long a fully-released IPC server waits for a re-acquire (the hot
|
|
52
|
+
* reload window: cordis runs the OLD row's disposer AFTER the new apply, so
|
|
53
|
+
* refcount 0 is momentary during every reload). Exported for tests. */
|
|
54
|
+
export const CLI_CLOSE_GRACE_MS = 250
|
|
55
|
+
|
|
56
|
+
/** Inbound config body cap — the CLI writes a few fields at a time. */
|
|
57
|
+
const MAX_BODY_BYTES = 16 * 1024
|
|
58
|
+
|
|
59
|
+
/** Socket paths the process still holds, for the exit-time cleanup. */
|
|
60
|
+
const boundSocketFiles = new Set<string>()
|
|
61
|
+
let exitHookInstalled = false
|
|
62
|
+
|
|
63
|
+
function ensureExitHook(): void {
|
|
64
|
+
if (exitHookInstalled) return
|
|
65
|
+
exitHookInstalled = true
|
|
66
|
+
process.on('exit', () => {
|
|
67
|
+
for (const path of boundSocketFiles) {
|
|
68
|
+
try {
|
|
69
|
+
unlinkSync(path)
|
|
70
|
+
} catch { /* already gone */ }
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The bind candidates, in backoff order: `<dshHome>/zen-remote-cli.sock`,
|
|
77
|
+
* then `-2.sock` … `-8.sock` (two DSH instances on one machine — a web
|
|
78
|
+
* profile and a headless one — each get their own channel). Windows has no
|
|
79
|
+
* filesystem sockets: named pipes under the package's pipe name
|
|
80
|
+
* `dsh-zen-remote-cli` with the same numbering.
|
|
81
|
+
*/
|
|
82
|
+
export function candidateSocketPaths(dshHome: string, platform: NodeJS.Platform = process.platform): string[] {
|
|
83
|
+
const out: string[] = []
|
|
84
|
+
for (let n = 1; n <= 8; n += 1) {
|
|
85
|
+
if (platform === 'win32') {
|
|
86
|
+
out.push(n === 1 ? `\\\\.\\pipe\\dsh-${CLI_SOCKET_BASENAME}` : `\\\\.\\pipe\\dsh-${CLI_SOCKET_BASENAME}-${n}`)
|
|
87
|
+
} else {
|
|
88
|
+
out.push(join(dshHome, n === 1 ? `${CLI_SOCKET_BASENAME}.sock` : `${CLI_SOCKET_BASENAME}-${n}.sock`))
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** One role-gated handler set the IPC server forwards to — the row-scoped
|
|
95
|
+
* closures src/index.ts builds per apply (per role branch). */
|
|
96
|
+
export interface CliIpcDelegate {
|
|
97
|
+
/** The CURRENT effective role, re-read per request (a volatile row field). */
|
|
98
|
+
role(): 'host' | 'client' | 'peer'
|
|
99
|
+
/** The row's display name (this device's name on a client). */
|
|
100
|
+
serverName(): string
|
|
101
|
+
/** The profile context's name, when the composition has one. */
|
|
102
|
+
profileName(): string | undefined
|
|
103
|
+
/** This package's version, for the handshake header and ping body. */
|
|
104
|
+
pluginVersion(): string
|
|
105
|
+
/** The anti-shadowing config write (src/cli/config-writer.ts, wired with
|
|
106
|
+
* the row object and the restart watcher's checkNow). */
|
|
107
|
+
writeConfig(input: { set?: Record<string, unknown>; unset?: readonly string[] }):
|
|
108
|
+
| { ok: true; values: ZenRemoteConfig }
|
|
109
|
+
| { ok: false; code: 'env-shadowed'; field: string; envVar: string }
|
|
110
|
+
/** Host-role handler sets; absent means the composition never built them
|
|
111
|
+
* (the wrong-role gate answers before any consumer reaches here). */
|
|
112
|
+
wizardHandler?: CliRouteHandler | undefined
|
|
113
|
+
adminHandler?: CliRouteHandler | undefined
|
|
114
|
+
/** Both roles mount a client handler (remote-status / debug-report are
|
|
115
|
+
* role-agnostic); the wrong-role gate covers the pairing routes. */
|
|
116
|
+
clientHandler?: CliRouteHandler | undefined
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type CliRouteHandler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
|
|
120
|
+
|
|
121
|
+
export interface CliIpcServerOptions {
|
|
122
|
+
dshHome: string
|
|
123
|
+
delegate: CliIpcDelegate
|
|
124
|
+
/** Bind-shape override (tests pin both platforms' candidate lists). */
|
|
125
|
+
platform?: NodeJS.Platform
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface CliIpcServer {
|
|
129
|
+
/** The candidate path this instance actually bound. */
|
|
130
|
+
readonly socketPath: string
|
|
131
|
+
/** Hot reload: replace the delegate without touching the socket. */
|
|
132
|
+
swapDelegate(delegate: CliIpcDelegate): void
|
|
133
|
+
/** Close the server, clean the socket file, and answer once drained. */
|
|
134
|
+
close(): Promise<void>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Is something ANSWERING on this socket path? A stale file (a dead
|
|
138
|
+
* instance's leftover, or a plain file that merely squats the name) answers
|
|
139
|
+
* nothing and may be cleaned; a live DSH instance answers its ping and must
|
|
140
|
+
* be left alone. */
|
|
141
|
+
function probeAlive(socketPath: string): Promise<boolean> {
|
|
142
|
+
return new Promise((resolve) => {
|
|
143
|
+
let settled = false
|
|
144
|
+
const done = (value: boolean): void => {
|
|
145
|
+
if (settled) return
|
|
146
|
+
settled = true
|
|
147
|
+
resolve(value)
|
|
148
|
+
}
|
|
149
|
+
const req = get({ socketPath, path: CLI_PING_ROUTE, timeout: 150 }, (res) => {
|
|
150
|
+
res.resume()
|
|
151
|
+
done(true)
|
|
152
|
+
})
|
|
153
|
+
req.on('timeout', () => {
|
|
154
|
+
req.destroy()
|
|
155
|
+
done(false)
|
|
156
|
+
})
|
|
157
|
+
req.on('error', () => done(false))
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Read the whole body under the cap and demand one JSON object. Same
|
|
162
|
+
* contract as every route module's reader: empty body → `{}`, malformed or
|
|
163
|
+
* oversized → null. */
|
|
164
|
+
async function readJsonObject(req: IncomingMessage): Promise<Record<string, unknown> | null> {
|
|
165
|
+
const declared = req.headers['content-length']
|
|
166
|
+
const expected = declared === undefined ? undefined : Number(declared)
|
|
167
|
+
if (expected !== undefined && Number.isSafeInteger(expected) && expected > MAX_BODY_BYTES) return null
|
|
168
|
+
const chunks: Buffer[] = []
|
|
169
|
+
let size = 0
|
|
170
|
+
for await (const chunk of req) {
|
|
171
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
|
|
172
|
+
size += bytes.length
|
|
173
|
+
if (size > MAX_BODY_BYTES) return null
|
|
174
|
+
chunks.push(bytes)
|
|
175
|
+
}
|
|
176
|
+
const text = Buffer.concat(chunks).toString('utf8')
|
|
177
|
+
if (text.trim() === '') return {}
|
|
178
|
+
try {
|
|
179
|
+
const parsed: unknown = JSON.parse(text)
|
|
180
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
181
|
+
return parsed as Record<string, unknown>
|
|
182
|
+
} catch {
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function plainObject(value: unknown): Record<string, unknown> | undefined {
|
|
188
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
189
|
+
? (value as Record<string, unknown>)
|
|
190
|
+
: undefined
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isErrnoCode(error: unknown, code: string): boolean {
|
|
194
|
+
return error instanceof Error && 'code' in error && (error as { code?: unknown }).code === code
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Bind one CLI IPC server: walk the candidate paths, probing occupied files
|
|
199
|
+
* for a live owner (skip those), cleaning stale ones, and answering with the
|
|
200
|
+
* bound handle. Throws when every candidate is taken by a live instance.
|
|
201
|
+
*/
|
|
202
|
+
export async function createCliIpcServer(options: CliIpcServerOptions): Promise<CliIpcServer> {
|
|
203
|
+
const platform = options.platform ?? process.platform
|
|
204
|
+
const isWin = platform === 'win32'
|
|
205
|
+
let delegate = options.delegate
|
|
206
|
+
let current: Server | undefined
|
|
207
|
+
let socketPath = ''
|
|
208
|
+
|
|
209
|
+
const routeListener: RequestListener = (req, res) => {
|
|
210
|
+
void handleCliRequest(req, res, () => delegate)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
for (const candidate of candidateSocketPaths(options.dshHome, platform)) {
|
|
214
|
+
if (!isWin && existsSync(candidate)) {
|
|
215
|
+
if (await probeAlive(candidate)) continue
|
|
216
|
+
try {
|
|
217
|
+
unlinkSync(candidate)
|
|
218
|
+
} catch {
|
|
219
|
+
continue // unwritable squatter — try the next name
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const server = createServer(routeListener)
|
|
223
|
+
try {
|
|
224
|
+
await new Promise<void>((resolve, reject) => {
|
|
225
|
+
server.once('error', reject)
|
|
226
|
+
server.listen(candidate, () => resolve())
|
|
227
|
+
})
|
|
228
|
+
current = server
|
|
229
|
+
socketPath = candidate
|
|
230
|
+
break
|
|
231
|
+
} catch (error) {
|
|
232
|
+
// EADDRINUSE (another process won the race) or any other bind failure
|
|
233
|
+
// — the remaining candidates are the fallback, never a crash.
|
|
234
|
+
server.close()
|
|
235
|
+
if (!isErrnoCode(error, 'EADDRINUSE')) void error
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (current === undefined) {
|
|
240
|
+
throw new Error(`every ${CLI_SOCKET_BASENAME} candidate is occupied by a live instance (${options.dshHome})`)
|
|
241
|
+
}
|
|
242
|
+
if (!isWin) {
|
|
243
|
+
try {
|
|
244
|
+
chmodSync(socketPath, 0o600)
|
|
245
|
+
} catch { /* exotic filesystems — the 0700 home is the outer wall */ }
|
|
246
|
+
} else {
|
|
247
|
+
// Named pipes carry no unlinkable file; nothing to register for exit.
|
|
248
|
+
}
|
|
249
|
+
if (!isWin) {
|
|
250
|
+
boundSocketFiles.add(socketPath)
|
|
251
|
+
ensureExitHook()
|
|
252
|
+
}
|
|
253
|
+
// The listen-phase `once('error')` is spent; later server-level errors
|
|
254
|
+
// (accept failures on a dying socket) must not crash the host process.
|
|
255
|
+
current.on('error', () => {})
|
|
256
|
+
// The listening handle must not hold the event loop open (the same
|
|
257
|
+
// discipline as the restart watcher's poll and the fetch-route fallback
|
|
258
|
+
// timer): the DSH host process stays alive on its own services anyway,
|
|
259
|
+
// and a composition whose everything-else has shut down should still be
|
|
260
|
+
// able to exit. The exit hook above still cleans the socket file.
|
|
261
|
+
if (typeof current.unref === 'function') current.unref()
|
|
262
|
+
|
|
263
|
+
let closed = false
|
|
264
|
+
return {
|
|
265
|
+
socketPath,
|
|
266
|
+
swapDelegate(next: CliIpcDelegate): void {
|
|
267
|
+
delegate = next
|
|
268
|
+
},
|
|
269
|
+
close(): Promise<void> {
|
|
270
|
+
if (closed) return Promise.resolve()
|
|
271
|
+
closed = true
|
|
272
|
+
boundSocketFiles.delete(socketPath)
|
|
273
|
+
return new Promise((resolve) => {
|
|
274
|
+
const finish = (): void => {
|
|
275
|
+
if (!isWin) {
|
|
276
|
+
try {
|
|
277
|
+
unlinkSync(socketPath)
|
|
278
|
+
} catch { /* the server's own close unlink usually got here first */ }
|
|
279
|
+
}
|
|
280
|
+
resolve()
|
|
281
|
+
}
|
|
282
|
+
current!.close(() => finish())
|
|
283
|
+
if (typeof current!.closeAllConnections === 'function') current!.closeAllConnections()
|
|
284
|
+
})
|
|
285
|
+
},
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** The per-request dispatch: version headers on EVERY answer first, then the
|
|
290
|
+
* two CLI routes, then the role-gated forwards into the row's handlers. */
|
|
291
|
+
async function handleCliRequest(
|
|
292
|
+
req: IncomingMessage,
|
|
293
|
+
res: ServerResponse,
|
|
294
|
+
getDelegate: () => CliIpcDelegate,
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
try {
|
|
297
|
+
const delegate = getDelegate()
|
|
298
|
+
res.setHeader('x-zr-cli-version', String(CLI_PROTOCOL_VERSION))
|
|
299
|
+
res.setHeader('x-zr-plugin-version', delegate.pluginVersion())
|
|
300
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
301
|
+
const route = url.pathname
|
|
302
|
+
const method = req.method ?? 'GET'
|
|
303
|
+
|
|
304
|
+
if (route === CLI_PING_ROUTE) {
|
|
305
|
+
if (method !== 'GET') {
|
|
306
|
+
res.setHeader('Allow', 'GET')
|
|
307
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } })
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
const role = delegate.role()
|
|
311
|
+
const profileName = delegate.profileName()
|
|
312
|
+
responseJson(res, 200, {
|
|
313
|
+
ok: true,
|
|
314
|
+
cliProtocolVersion: CLI_PROTOCOL_VERSION,
|
|
315
|
+
pluginVersion: delegate.pluginVersion(),
|
|
316
|
+
pid: process.pid,
|
|
317
|
+
role,
|
|
318
|
+
serves: servesRole(role),
|
|
319
|
+
connects: connectsRole(role),
|
|
320
|
+
serverName: delegate.serverName(),
|
|
321
|
+
...(profileName === undefined ? {} : { profileName }),
|
|
322
|
+
})
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (route === CLI_CONFIG_ROUTE) {
|
|
327
|
+
if (method !== 'POST') {
|
|
328
|
+
res.setHeader('Allow', 'POST')
|
|
329
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST' } })
|
|
330
|
+
return
|
|
331
|
+
}
|
|
332
|
+
const body = await readJsonObject(req)
|
|
333
|
+
if (body === null) {
|
|
334
|
+
responseJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'The request body must be one JSON object' } })
|
|
335
|
+
return
|
|
336
|
+
}
|
|
337
|
+
const set = body.set === undefined ? undefined : plainObject(body.set)
|
|
338
|
+
const rawUnset = body.unset === undefined ? undefined : body.unset
|
|
339
|
+
const unset = Array.isArray(rawUnset) && rawUnset.every((item) => typeof item === 'string')
|
|
340
|
+
? (rawUnset as string[])
|
|
341
|
+
: undefined
|
|
342
|
+
if ((body.set !== undefined && set === undefined) || (body.unset !== undefined && unset === undefined)) {
|
|
343
|
+
responseJson(res, 400, { ok: false, error: { code: 'bad-request', message: '`set` must be an object and `unset` a string array' } })
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
let result: { ok: true; values: ZenRemoteConfig } | { ok: false; code: 'env-shadowed'; field: string; envVar: string }
|
|
347
|
+
try {
|
|
348
|
+
result = delegate.writeConfig({
|
|
349
|
+
...(set === undefined ? {} : { set }),
|
|
350
|
+
...(unset === undefined ? {} : { unset }),
|
|
351
|
+
})
|
|
352
|
+
} catch (error) {
|
|
353
|
+
responseJson(res, 500, { ok: false, error: { code: 'internal', message: error instanceof Error ? error.message : String(error) } })
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
if (!result.ok) {
|
|
357
|
+
responseJson(res, 409, { ok: false, error: { code: result.code, field: result.field, envVar: result.envVar } })
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
responseJson(res, 200, { ok: true, values: result.values })
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (route === WIZARD_ROUTE_PREFIX || route.startsWith(`${WIZARD_ROUTE_PREFIX}/`)) {
|
|
365
|
+
await forwardIfRole(req, res, delegate, delegate.wizardHandler)
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
if (route === ADMIN_ROUTE_PREFIX || route.startsWith(`${ADMIN_ROUTE_PREFIX}/`)) {
|
|
369
|
+
await forwardIfRole(req, res, delegate, delegate.adminHandler)
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
if (route === CLIENT_ROUTE_PREFIX || route.startsWith(`${CLIENT_ROUTE_PREFIX}/`)) {
|
|
373
|
+
// The pairing surfaces belong to a dialing role; remote-status and the
|
|
374
|
+
// debug report are every role's own (the host answers its empty
|
|
375
|
+
// conclusion). SPEC pins exactly this split.
|
|
376
|
+
const sub = route.slice(CLIENT_ROUTE_PREFIX.length) || '/'
|
|
377
|
+
const pairingOnly = new Set(['/status', '/claim', '/unpair', '/reconnect'])
|
|
378
|
+
const required: 'client' | undefined = pairingOnly.has(sub) ? 'client' : undefined
|
|
379
|
+
if (required !== undefined && !connectsRole(delegate.role())) {
|
|
380
|
+
wrongRole(res, delegate.role(), required)
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
if (delegate.clientHandler === undefined) {
|
|
384
|
+
responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'no client handler in this row generation' } })
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
await delegate.clientHandler(req, res)
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown CLI route' } })
|
|
392
|
+
} catch (error) {
|
|
393
|
+
// A malformed req.url (`new URL` throwing) or a broken delegate must
|
|
394
|
+
// never escape as an unhandled rejection — the handler owns the whole
|
|
395
|
+
// response lifecycle.
|
|
396
|
+
if (!res.headersSent) {
|
|
397
|
+
responseJson(res, 500, { ok: false, error: { code: 'internal', message: error instanceof Error ? error.message : String(error) } })
|
|
398
|
+
} else {
|
|
399
|
+
res.destroy()
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** The host-side role gate: answer 409 wrong-role (or 503 when the row never
|
|
405
|
+
* built the handler) and stop there, or forward into the row's handler. */
|
|
406
|
+
async function forwardIfRole(
|
|
407
|
+
req: IncomingMessage,
|
|
408
|
+
res: ServerResponse,
|
|
409
|
+
delegate: CliIpcDelegate,
|
|
410
|
+
handler: CliRouteHandler | undefined,
|
|
411
|
+
): Promise<void> {
|
|
412
|
+
if (!servesRole(delegate.role())) {
|
|
413
|
+
wrongRole(res, delegate.role(), 'host')
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
if (handler === undefined) {
|
|
417
|
+
responseJson(res, 503, { ok: false, error: { code: 'unavailable', message: 'no host handler in this row generation' } })
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
await handler(req, res)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function wrongRole(res: ServerResponse, role: 'host' | 'client' | 'peer', required: 'host' | 'client'): void {
|
|
424
|
+
responseJson(res, 409, { ok: false, error: { code: 'wrong-role', role, required } })
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// --- module-level reuse coordinator (hot reload) -----------------------------
|
|
428
|
+
//
|
|
429
|
+
// One server per process per dshHome. apply() acquires with ITS delegate;
|
|
430
|
+
// the next apply swaps; the refcount only reaches zero across a reload
|
|
431
|
+
// momentarily, and the grace period eats that moment instead of tearing the
|
|
432
|
+
// socket down between the two rows.
|
|
433
|
+
|
|
434
|
+
interface AcquireEntry {
|
|
435
|
+
starting: Promise<CliIpcServer>
|
|
436
|
+
server: CliIpcServer | undefined
|
|
437
|
+
pendingDelegate: CliIpcDelegate | undefined
|
|
438
|
+
refCount: number
|
|
439
|
+
closed: boolean
|
|
440
|
+
graceTimer: ReturnType<typeof setTimeout> | undefined
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const acquisitions = new Map<string, AcquireEntry>()
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* A stand-in delegate for the bind window: between `listen` succeeding and
|
|
447
|
+
* the acquiring row's swap landing (one microtask later), a request would
|
|
448
|
+
* otherwise meet no delegate at all. It answers EVERYTHING through the
|
|
449
|
+
* entry's pending delegate — which `acquireCliIpcServer` sets synchronously
|
|
450
|
+
* — so the stub is only visible when literally no row ever acquired, and
|
|
451
|
+
* its write refuses rather than pretending.
|
|
452
|
+
*/
|
|
453
|
+
function bootstrapDelegateFor(getEntry: () => AcquireEntry | undefined): CliIpcDelegate {
|
|
454
|
+
return {
|
|
455
|
+
role: () => getEntry()?.pendingDelegate?.role() ?? 'host',
|
|
456
|
+
serverName: () => getEntry()?.pendingDelegate?.serverName() ?? '',
|
|
457
|
+
profileName: () => getEntry()?.pendingDelegate?.profileName(),
|
|
458
|
+
pluginVersion: () => getEntry()?.pendingDelegate?.pluginVersion() ?? 'unknown',
|
|
459
|
+
writeConfig: (input) => {
|
|
460
|
+
const current = getEntry()?.pendingDelegate
|
|
461
|
+
if (current === undefined) throw new Error('no plugin row has acquired the CLI IPC server yet')
|
|
462
|
+
return current.writeConfig(input)
|
|
463
|
+
},
|
|
464
|
+
wizardHandler: undefined,
|
|
465
|
+
adminHandler: undefined,
|
|
466
|
+
clientHandler: undefined,
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Acquire (or hot-swap into) this process's IPC server for `dshHome`.
|
|
472
|
+
* Synchronous on purpose: apply() runs inside cordis and cannot await. The
|
|
473
|
+
* returned handle's `release()` is the row disposer; a release that hits
|
|
474
|
+
* refcount 0 closes the server after {@link CLI_CLOSE_GRACE_MS} unless a new
|
|
475
|
+
* row acquires first.
|
|
476
|
+
*/
|
|
477
|
+
export function acquireCliIpcServer(dshHome: string, delegate: CliIpcDelegate): { release(): void } {
|
|
478
|
+
let entry = acquisitions.get(dshHome)
|
|
479
|
+
if (entry === undefined) {
|
|
480
|
+
// The entry the bind closure reads through `getEntry` — assigned right
|
|
481
|
+
// after the (synchronous) object literal completes, long before any
|
|
482
|
+
// request callback can run.
|
|
483
|
+
let creating: AcquireEntry | undefined
|
|
484
|
+
const created: AcquireEntry = {
|
|
485
|
+
starting: createCliIpcServer({ dshHome, delegate: bootstrapDelegateFor(() => creating) }),
|
|
486
|
+
server: undefined,
|
|
487
|
+
pendingDelegate: undefined,
|
|
488
|
+
refCount: 0,
|
|
489
|
+
closed: false,
|
|
490
|
+
graceTimer: undefined,
|
|
491
|
+
}
|
|
492
|
+
creating = created
|
|
493
|
+
entry = created
|
|
494
|
+
acquisitions.set(dshHome, created)
|
|
495
|
+
void created.starting
|
|
496
|
+
.then((server) => {
|
|
497
|
+
created.server = server
|
|
498
|
+
if (created.closed) {
|
|
499
|
+
void server.close()
|
|
500
|
+
return
|
|
501
|
+
}
|
|
502
|
+
if (created.pendingDelegate !== undefined) server.swapDelegate(created.pendingDelegate)
|
|
503
|
+
})
|
|
504
|
+
.catch(() => {
|
|
505
|
+
// Every candidate was taken by live instances — drop the slot so a
|
|
506
|
+
// later acquire can retry from scratch.
|
|
507
|
+
if (acquisitions.get(dshHome) === created) acquisitions.delete(dshHome)
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
if (entry.graceTimer !== undefined) {
|
|
511
|
+
clearTimeout(entry.graceTimer)
|
|
512
|
+
entry.graceTimer = undefined
|
|
513
|
+
}
|
|
514
|
+
entry.refCount += 1
|
|
515
|
+
entry.pendingDelegate = delegate
|
|
516
|
+
if (entry.server !== undefined && !entry.closed) entry.server.swapDelegate(delegate)
|
|
517
|
+
return {
|
|
518
|
+
release() {
|
|
519
|
+
if (entry!.refCount > 0) entry!.refCount -= 1
|
|
520
|
+
if (entry!.refCount > 0 || entry!.closed || entry!.graceTimer !== undefined) return
|
|
521
|
+
entry!.graceTimer = setTimeout(() => {
|
|
522
|
+
entry!.graceTimer = undefined
|
|
523
|
+
if (entry!.refCount > 0 || entry!.closed) return
|
|
524
|
+
acquisitions.delete(dshHome)
|
|
525
|
+
entry!.closed = true
|
|
526
|
+
void entry!.starting.then((server) => server.close()).catch(() => {})
|
|
527
|
+
}, CLI_CLOSE_GRACE_MS)
|
|
528
|
+
if (typeof entry!.graceTimer.unref === 'function') entry!.graceTimer.unref()
|
|
529
|
+
},
|
|
530
|
+
}
|
|
531
|
+
}
|