@zhin.js/gateway 0.0.1-bootstrap.0 → 1.1.1
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/LICENSE +21 -0
- package/README.md +61 -0
- package/lib/config/ai-config-loader.d.ts +22 -0
- package/lib/config/ai-config-loader.js +96 -0
- package/lib/config/configuration-chat-policy.d.ts +57 -0
- package/lib/config/configuration-chat-policy.js +423 -0
- package/lib/config/configuration-patch.d.ts +16 -0
- package/lib/config/configuration-patch.js +105 -0
- package/lib/config/configuration-path.d.ts +6 -0
- package/lib/config/configuration-path.js +40 -0
- package/lib/config/configuration-projection.d.ts +6 -0
- package/lib/config/configuration-projection.js +28 -0
- package/lib/config/configuration-redaction.d.ts +24 -0
- package/lib/config/configuration-redaction.js +128 -0
- package/lib/config/configuration.d.ts +81 -0
- package/lib/config/configuration.js +686 -0
- package/lib/config/console-schema.d.ts +11 -0
- package/lib/config/console-schema.js +222 -0
- package/lib/config/environment-files.d.ts +2 -0
- package/lib/config/environment-files.js +21 -0
- package/lib/config/plugin-package-map.d.ts +2 -0
- package/lib/config/plugin-package-map.js +14 -0
- package/lib/config/plugin-schema-catalog.d.ts +22 -0
- package/lib/config/plugin-schema-catalog.js +262 -0
- package/lib/config.d.ts +12 -0
- package/lib/config.js +12 -0
- package/lib/database/config.d.ts +22 -0
- package/lib/database/config.js +159 -0
- package/lib/database/index.d.ts +2 -0
- package/lib/database/index.js +2 -0
- package/lib/database/project-database.d.ts +31 -0
- package/lib/database/project-database.js +130 -0
- package/lib/database.d.ts +2 -0
- package/lib/database.js +2 -0
- package/lib/http/console-rpc.d.ts +19 -0
- package/lib/http/console-rpc.js +51 -0
- package/lib/http/gateway-console.d.ts +63 -0
- package/lib/http/gateway-console.js +309 -0
- package/lib/http/gateway-events.d.ts +35 -0
- package/lib/http/gateway-events.js +172 -0
- package/lib/http/gateway-http-server.d.ts +95 -0
- package/lib/http/gateway-http-server.js +518 -0
- package/lib/http/local-principal.d.ts +2 -0
- package/lib/http/local-principal.js +16 -0
- package/lib/http/run-gateway.d.ts +43 -0
- package/lib/http/run-gateway.js +268 -0
- package/lib/http/start-gateway.d.ts +114 -0
- package/lib/http/start-gateway.js +220 -0
- package/lib/http-response.d.ts +6 -0
- package/lib/http-response.js +25 -0
- package/lib/http.d.ts +6 -0
- package/lib/http.js +6 -0
- package/lib/iam/crypto.d.ts +12 -0
- package/lib/iam/crypto.js +58 -0
- package/lib/iam/index.d.ts +8 -0
- package/lib/iam/index.js +8 -0
- package/lib/iam/install.d.ts +36 -0
- package/lib/iam/install.js +74 -0
- package/lib/iam/local-admin.d.ts +27 -0
- package/lib/iam/local-admin.js +102 -0
- package/lib/iam/rate-limit.d.ts +15 -0
- package/lib/iam/rate-limit.js +52 -0
- package/lib/iam/routes.d.ts +23 -0
- package/lib/iam/routes.js +157 -0
- package/lib/iam/rpc.d.ts +14 -0
- package/lib/iam/rpc.js +100 -0
- package/lib/iam/service.d.ts +203 -0
- package/lib/iam/service.js +784 -0
- package/lib/iam/setup-service.d.ts +83 -0
- package/lib/iam/setup-service.js +206 -0
- package/lib/iam/tables.d.ts +20 -0
- package/lib/iam/tables.js +82 -0
- package/lib/iam.d.ts +2 -0
- package/lib/iam.js +2 -0
- package/lib/index.d.ts +22 -0
- package/lib/index.js +22 -0
- package/lib/local/client.d.ts +71 -0
- package/lib/local/client.js +296 -0
- package/lib/local/handler.d.ts +30 -0
- package/lib/local/handler.js +86 -0
- package/lib/local/local-socket.d.ts +55 -0
- package/lib/local/local-socket.js +232 -0
- package/lib/local/paths.d.ts +34 -0
- package/lib/local/paths.js +58 -0
- package/lib/local/protocol.d.ts +140 -0
- package/lib/local/protocol.js +90 -0
- package/lib/local/state-file.d.ts +8 -0
- package/lib/local/state-file.js +45 -0
- package/lib/local.d.ts +10 -0
- package/lib/local.js +10 -0
- package/lib/onboarding/allowlist.d.ts +54 -0
- package/lib/onboarding/allowlist.js +106 -0
- package/lib/onboarding/errors.d.ts +7 -0
- package/lib/onboarding/errors.js +17 -0
- package/lib/onboarding/index.d.ts +6 -0
- package/lib/onboarding/index.js +6 -0
- package/lib/onboarding/install.d.ts +99 -0
- package/lib/onboarding/install.js +132 -0
- package/lib/onboarding/package-manager.d.ts +47 -0
- package/lib/onboarding/package-manager.js +179 -0
- package/lib/onboarding/service.d.ts +53 -0
- package/lib/onboarding/service.js +144 -0
- package/lib/onboarding/state-store.d.ts +26 -0
- package/lib/onboarding/state-store.js +97 -0
- package/lib/onboarding.d.ts +2 -0
- package/lib/onboarding.js +2 -0
- package/lib/packages/authorization.d.ts +23 -0
- package/lib/packages/authorization.js +109 -0
- package/lib/packages/errors.d.ts +11 -0
- package/lib/packages/errors.js +26 -0
- package/lib/packages/index.d.ts +5 -0
- package/lib/packages/index.js +5 -0
- package/lib/packages/rpc.d.ts +30 -0
- package/lib/packages/rpc.js +155 -0
- package/lib/packages/service.d.ts +121 -0
- package/lib/packages/service.js +441 -0
- package/lib/packages/snapshot.d.ts +14 -0
- package/lib/packages/snapshot.js +73 -0
- package/lib/packages.d.ts +2 -0
- package/lib/packages.js +2 -0
- package/lib/plugin-management/activity.d.ts +6 -0
- package/lib/plugin-management/activity.js +14 -0
- package/lib/plugin-management/config-explanation.d.ts +6 -0
- package/lib/plugin-management/config-explanation.js +113 -0
- package/lib/plugin-management/use-cases.d.ts +15 -0
- package/lib/plugin-management/use-cases.js +120 -0
- package/lib/plugin-management/workflow.d.ts +92 -0
- package/lib/plugin-management/workflow.js +772 -0
- package/lib/plugin-management.d.ts +5 -0
- package/lib/plugin-management.js +5 -0
- package/lib/project/config-file.d.ts +6 -0
- package/lib/project/config-file.js +58 -0
- package/lib/project/environment.d.ts +13 -0
- package/lib/project/environment.js +52 -0
- package/lib/project/instance-declarations.d.ts +55 -0
- package/lib/project/instance-declarations.js +542 -0
- package/lib/project/legacy-config.d.ts +25 -0
- package/lib/project/legacy-config.js +70 -0
- package/lib/project/node-requirements.d.ts +7 -0
- package/lib/project/node-requirements.js +20 -0
- package/lib/project/tsx-module-loader.d.ts +7 -0
- package/lib/project/tsx-module-loader.js +29 -0
- package/lib/project.d.ts +7 -0
- package/lib/project.js +7 -0
- package/lib/server.d.ts +11 -0
- package/lib/server.js +11 -0
- package/lib/supervisor/instance-logs.d.ts +39 -0
- package/lib/supervisor/instance-logs.js +152 -0
- package/lib/supervisor/instance-supervisor.d.ts +140 -0
- package/lib/supervisor/instance-supervisor.js +408 -0
- package/lib/supervisor/run-instance.d.ts +35 -0
- package/lib/supervisor/run-instance.js +161 -0
- package/lib/supervisor/terminal-state.d.ts +30 -0
- package/lib/supervisor/terminal-state.js +57 -0
- package/lib/supervisor.d.ts +4 -0
- package/lib/supervisor.js +4 -0
- package/lib/terminal/backends.d.ts +38 -0
- package/lib/terminal/backends.js +193 -0
- package/lib/terminal/config.d.ts +63 -0
- package/lib/terminal/config.js +237 -0
- package/lib/terminal/host.d.ts +26 -0
- package/lib/terminal/host.js +76 -0
- package/lib/terminal/index.d.ts +60 -0
- package/lib/terminal/index.js +135 -0
- package/lib/terminal/modules.d.ts +87 -0
- package/lib/terminal/modules.js +44 -0
- package/lib/terminal/service.d.ts +101 -0
- package/lib/terminal/service.js +455 -0
- package/lib/terminal/tickets.d.ts +46 -0
- package/lib/terminal/tickets.js +76 -0
- package/lib/terminal.d.ts +3 -0
- package/lib/terminal.js +3 -0
- package/lib/usage/aggregate.d.ts +87 -0
- package/lib/usage/aggregate.js +212 -0
- package/lib/usage.d.ts +2 -0
- package/lib/usage.js +2 -0
- package/package.json +117 -4
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/** CLI side of the local control socket (ADR 0054 §6). */
|
|
2
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { lstat } from 'node:fs/promises';
|
|
4
|
+
import { connect } from 'node:net';
|
|
5
|
+
import { gatewaySocketPath, isNamedPipe } from './paths.js';
|
|
6
|
+
import { handshakeProof, LineDecoder, LOCAL_GATEWAY_RPC, MAX_LOCAL_HTTP_BODY_BYTES, MAX_LOCAL_LINE_BYTES, } from './protocol.js';
|
|
7
|
+
import { readGatewayInfo } from './state-file.js';
|
|
8
|
+
export class LocalGatewayClientError extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message, options) {
|
|
11
|
+
super(message, options);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.name = 'LocalGatewayClientError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/** The gateway answered `{ ok: false }`. */
|
|
17
|
+
export class LocalGatewayRpcError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
status;
|
|
20
|
+
details;
|
|
21
|
+
constructor(code, message, status, details = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.status = status;
|
|
25
|
+
this.details = details;
|
|
26
|
+
this.name = 'LocalGatewayRpcError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const defaultUid = () => (typeof process.getuid === 'function' ? process.getuid() : undefined);
|
|
30
|
+
export class LocalGatewayClient {
|
|
31
|
+
path;
|
|
32
|
+
#socket;
|
|
33
|
+
#pending = new Map();
|
|
34
|
+
#nextId = 1;
|
|
35
|
+
#closedError;
|
|
36
|
+
constructor(path, socket) {
|
|
37
|
+
this.path = path;
|
|
38
|
+
this.#socket = socket;
|
|
39
|
+
const decoder = new LineDecoder();
|
|
40
|
+
socket.setEncoding('utf8');
|
|
41
|
+
socket.on('data', (chunk) => {
|
|
42
|
+
let lines;
|
|
43
|
+
try {
|
|
44
|
+
lines = decoder.push(chunk);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
this.#fail(new LocalGatewayClientError('protocol', '网关返回的数据过长', { cause: error }));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const line of lines)
|
|
51
|
+
this.#receive(line);
|
|
52
|
+
});
|
|
53
|
+
socket.on('error', (error) => { this.#fail(new LocalGatewayClientError('unreachable', `与网关的连接出错:${error.message}`, { cause: error })); });
|
|
54
|
+
socket.on('close', () => { this.#fail(new LocalGatewayClientError('unreachable', '网关关闭了连接')); });
|
|
55
|
+
}
|
|
56
|
+
/** Connects to the project's gateway; see {@link LocalGatewayClientErrorCode} for failures. */
|
|
57
|
+
static async connect(options) {
|
|
58
|
+
const recorded = await readGatewayInfo(options.root);
|
|
59
|
+
const path = options.path ?? recorded?.socket ?? gatewaySocketPath(options.root);
|
|
60
|
+
const handshakeKey = options.handshakeKey ?? (recorded?.socket === path ? recorded.handshakeKey : undefined);
|
|
61
|
+
if (!isNamedPipe(path)) {
|
|
62
|
+
let info;
|
|
63
|
+
try {
|
|
64
|
+
info = await lstat(path);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error.code === 'ENOENT')
|
|
68
|
+
throw new LocalGatewayClientError('not_running', '网关未运行');
|
|
69
|
+
throw new LocalGatewayClientError('unreachable', `无法读取 ${path}:${error.message}`, { cause: error });
|
|
70
|
+
}
|
|
71
|
+
if (!info.isSocket())
|
|
72
|
+
throw new LocalGatewayClientError('protocol', `${path} 不是网关控制套接字`);
|
|
73
|
+
const uid = (options.uid ?? defaultUid)();
|
|
74
|
+
if (uid !== undefined && info.uid !== uid) {
|
|
75
|
+
throw new LocalGatewayClientError('not_owner', `${path} 属于其他用户(uid ${info.uid}),当前用户 uid ${uid};请以启动网关的用户运行 zhin`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const socket = await new Promise((resolve, reject) => {
|
|
79
|
+
const candidate = connect(path);
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
candidate.destroy();
|
|
82
|
+
reject(new LocalGatewayClientError('unreachable', `连接网关超时(${path})`));
|
|
83
|
+
}, options.connectTimeoutMs ?? 5_000);
|
|
84
|
+
candidate.once('connect', () => { clearTimeout(timer); resolve(candidate); });
|
|
85
|
+
candidate.once('error', (error) => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
if (error.code === 'ECONNREFUSED' || error.code === 'ENOENT')
|
|
88
|
+
reject(new LocalGatewayClientError('not_running', '网关未运行(套接字已失效)', { cause: error }));
|
|
89
|
+
else if (error.code === 'EACCES' || error.code === 'EPERM')
|
|
90
|
+
reject(new LocalGatewayClientError('not_owner', `无权访问 ${path};请以启动网关的用户运行 zhin`, { cause: error }));
|
|
91
|
+
else
|
|
92
|
+
reject(new LocalGatewayClientError('unreachable', `无法连接网关:${error.message}`, { cause: error }));
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
const client = new LocalGatewayClient(path, socket);
|
|
96
|
+
if (handshakeKey) {
|
|
97
|
+
try {
|
|
98
|
+
await client.#verify(handshakeKey);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
client.close();
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return client;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Proves the peer holds this run's handshake key. Node cannot restrict who creates a Windows
|
|
109
|
+
* named pipe, so a pipe another account created first must not receive our requests.
|
|
110
|
+
*/
|
|
111
|
+
async #verify(key) {
|
|
112
|
+
const nonce = randomBytes(16).toString('hex');
|
|
113
|
+
let reply;
|
|
114
|
+
try {
|
|
115
|
+
reply = await this.request(LOCAL_GATEWAY_RPC.HELLO, { nonce }, { timeoutMs: 5_000 });
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
throw new LocalGatewayClientError('not_owner', `${this.path} 未通过网关握手校验;它可能不是本项目的网关`, { cause: error });
|
|
119
|
+
}
|
|
120
|
+
const expected = Buffer.from(handshakeProof(key, nonce), 'hex');
|
|
121
|
+
const actual = typeof reply?.proof === 'string' && /^[0-9a-f]+$/u.test(reply.proof) ? Buffer.from(reply.proof, 'hex') : Buffer.alloc(0);
|
|
122
|
+
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
|
|
123
|
+
throw new LocalGatewayClientError('not_owner', `${this.path} 未通过网关握手校验;它可能不是本项目的网关`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
request(type, data, options = {}) {
|
|
127
|
+
if (this.#closedError)
|
|
128
|
+
return Promise.reject(this.#closedError);
|
|
129
|
+
const id = this.#nextId++;
|
|
130
|
+
const line = `${JSON.stringify({ id, type, ...(data === undefined ? {} : { data }) })}\n`;
|
|
131
|
+
if (Buffer.byteLength(line) > MAX_LOCAL_LINE_BYTES) {
|
|
132
|
+
return Promise.reject(new LocalGatewayClientError('protocol', `请求超过本地套接字上限(1 MiB):${type}`));
|
|
133
|
+
}
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
const pending = { resolve: resolve, reject, ...(options.onEvent ? { onEvent: options.onEvent } : {}) };
|
|
136
|
+
if (options.timeoutMs !== undefined) {
|
|
137
|
+
pending.timer = setTimeout(() => {
|
|
138
|
+
this.#pending.delete(id);
|
|
139
|
+
reject(new LocalGatewayClientError('unreachable', `网关在 ${options.timeoutMs} ms 内没有响应 ${type}`));
|
|
140
|
+
}, options.timeoutMs);
|
|
141
|
+
}
|
|
142
|
+
this.#pending.set(id, pending);
|
|
143
|
+
this.#socket.write(line);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
info(timeoutMs = 10_000) {
|
|
147
|
+
return this.request(LOCAL_GATEWAY_RPC.INFO, undefined, { timeoutMs });
|
|
148
|
+
}
|
|
149
|
+
close() {
|
|
150
|
+
this.#closedError ??= new LocalGatewayClientError('unreachable', '连接已关闭');
|
|
151
|
+
this.#socket.destroy();
|
|
152
|
+
}
|
|
153
|
+
#receive(text) {
|
|
154
|
+
let line;
|
|
155
|
+
try {
|
|
156
|
+
line = JSON.parse(text);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
this.#fail(new LocalGatewayClientError('protocol', '网关返回了无效的 JSON'));
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const pending = typeof line?.id === 'number' ? this.#pending.get(line.id) : undefined;
|
|
163
|
+
if (!pending)
|
|
164
|
+
return;
|
|
165
|
+
if ('event' in line) {
|
|
166
|
+
try {
|
|
167
|
+
pending.onEvent?.(line.event);
|
|
168
|
+
}
|
|
169
|
+
catch { /* a faulty listener must not break the stream */ }
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
this.#pending.delete(line.id);
|
|
173
|
+
clearTimeout(pending.timer);
|
|
174
|
+
if (line.ok)
|
|
175
|
+
pending.resolve(line.data);
|
|
176
|
+
else {
|
|
177
|
+
const { errors, missingEnv } = line.error;
|
|
178
|
+
pending.reject(new LocalGatewayRpcError(line.error.code, line.error.message, line.error.status, {
|
|
179
|
+
...(Array.isArray(errors) && errors.length ? { errors } : {}),
|
|
180
|
+
...(Array.isArray(missingEnv) && missingEnv.length ? { missingEnv } : {}),
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
#fail(error) {
|
|
185
|
+
this.#closedError ??= error;
|
|
186
|
+
for (const [id, pending] of this.#pending) {
|
|
187
|
+
this.#pending.delete(id);
|
|
188
|
+
clearTimeout(pending.timer);
|
|
189
|
+
pending.reject(error);
|
|
190
|
+
}
|
|
191
|
+
this.#socket.destroy();
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The running gateway's info, or `undefined` when no gateway runs for `root`. Ownership and
|
|
196
|
+
* reachability problems are thrown (a caller must not fall back to working on the files then).
|
|
197
|
+
*/
|
|
198
|
+
export async function probeLocalGateway(options) {
|
|
199
|
+
let client;
|
|
200
|
+
try {
|
|
201
|
+
client = await LocalGatewayClient.connect(options);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (error instanceof LocalGatewayClientError && error.code === 'not_running')
|
|
205
|
+
return undefined;
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
return await client.info();
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
client.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** The Bearer credential of `Authorization` in `headers` (a `Headers`, record or entry list). */
|
|
216
|
+
export function bearerFromHeaders(headers) {
|
|
217
|
+
let value;
|
|
218
|
+
if (headers instanceof Headers)
|
|
219
|
+
value = headers.get('authorization');
|
|
220
|
+
else if (Array.isArray(headers)) {
|
|
221
|
+
value = headers.find((entry) => Array.isArray(entry) && String(entry[0]).toLowerCase() === 'authorization')?.[1];
|
|
222
|
+
}
|
|
223
|
+
else if (headers && typeof headers === 'object') {
|
|
224
|
+
value = Object.entries(headers).find(([name]) => name.toLowerCase() === 'authorization')?.[1];
|
|
225
|
+
}
|
|
226
|
+
if (typeof value !== 'string')
|
|
227
|
+
return undefined;
|
|
228
|
+
const match = /^Bearer\s+(\S+)\s*$/iu.exec(value.trim());
|
|
229
|
+
return match?.[1];
|
|
230
|
+
}
|
|
231
|
+
/** A JSON reply in the shape `requestPluginConsole` / `hostFetch` read. */
|
|
232
|
+
function localError(status, code, error) {
|
|
233
|
+
return new Response(JSON.stringify({ success: false, code, error }), { status, headers: { 'content-type': 'application/json' } });
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* A `fetch` that sends `{base}/…` requests over the local control socket
|
|
237
|
+
* (`gateway.http.request`) instead of HTTP (ADR 0054 §6). Only the path and query of the URL are
|
|
238
|
+
* used. Without an `Authorization: Bearer` header the gateway runs the request as the local
|
|
239
|
+
* owner, so no Console token is needed; with one (an explicit `ZHIN_CONSOLE_TOKEN`), the gateway
|
|
240
|
+
* authenticates that credential and runs — and audits — the request as its principal. A gateway
|
|
241
|
+
* that is not running rejects like a refused connection (`cause.code === 'ECONNREFUSED'`).
|
|
242
|
+
*/
|
|
243
|
+
export function createLocalGatewayFetch(options) {
|
|
244
|
+
return async (input, init = {}) => {
|
|
245
|
+
const url = typeof input === 'string' ? new URL(input) : input;
|
|
246
|
+
const method = (init.method ?? 'GET').toUpperCase();
|
|
247
|
+
const body = typeof init.body === 'string' ? init.body : undefined;
|
|
248
|
+
const token = bearerFromHeaders(init.headers);
|
|
249
|
+
const signal = init.signal ?? undefined;
|
|
250
|
+
signal?.throwIfAborted();
|
|
251
|
+
if (body !== undefined && (method === 'GET' || method === 'HEAD')) {
|
|
252
|
+
return localError(400, 'invalid_input', 'GET 请求不能带 body');
|
|
253
|
+
}
|
|
254
|
+
if (body !== undefined && Buffer.byteLength(JSON.stringify(body)) > MAX_LOCAL_HTTP_BODY_BYTES) {
|
|
255
|
+
return localError(413, 'invalid_input', '请求体超过本地套接字上限;改用 HTTP 请求(Console API 令牌 ZHIN_CONSOLE_TOKEN,--host 指向网关地址)');
|
|
256
|
+
}
|
|
257
|
+
let client;
|
|
258
|
+
try {
|
|
259
|
+
client = await LocalGatewayClient.connect(options);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (error instanceof LocalGatewayClientError && error.code === 'not_running') {
|
|
263
|
+
throw new TypeError('fetch failed', { cause: Object.assign(new Error(error.message), { code: 'ECONNREFUSED' }) });
|
|
264
|
+
}
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
const aborted = new Promise((_, reject) => {
|
|
268
|
+
if (!signal)
|
|
269
|
+
return;
|
|
270
|
+
signal.addEventListener('abort', () => { client.close(); reject(signal.reason); }, { once: true });
|
|
271
|
+
});
|
|
272
|
+
aborted.catch(() => undefined);
|
|
273
|
+
try {
|
|
274
|
+
const reply = await Promise.race([
|
|
275
|
+
client.request(LOCAL_GATEWAY_RPC.HTTP_REQUEST, {
|
|
276
|
+
method,
|
|
277
|
+
path: `${url.pathname}${url.search}`,
|
|
278
|
+
...(body === undefined ? {} : { body }),
|
|
279
|
+
...(token === undefined ? {} : { token }),
|
|
280
|
+
}),
|
|
281
|
+
aborted,
|
|
282
|
+
]);
|
|
283
|
+
return new Response(reply.body === '' ? null : reply.body, { status: reply.status, headers: reply.headers });
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (signal?.aborted)
|
|
287
|
+
throw signal.reason;
|
|
288
|
+
if (error instanceof LocalGatewayRpcError)
|
|
289
|
+
return localError(error.status, error.code, error.message);
|
|
290
|
+
throw error;
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
client.close();
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { GatewayRpcDispatcher } from '../packages/rpc.js';
|
|
2
|
+
import type { GatewayPackageService } from '../packages/service.js';
|
|
3
|
+
import type { LocalRequestHandler, LocalRequestResult } from './local-socket.js';
|
|
4
|
+
import { type LocalGatewayInfoResult } from './protocol.js';
|
|
5
|
+
export interface LocalRpcHandlerOptions {
|
|
6
|
+
readonly dispatch: GatewayRpcDispatcher;
|
|
7
|
+
/** Progress of package transactions is streamed to the requesting connection. */
|
|
8
|
+
readonly packages?: GatewayPackageService;
|
|
9
|
+
readonly info: () => LocalGatewayInfoResult;
|
|
10
|
+
/** Called after the `gateway.shutdown` answer was queued. */
|
|
11
|
+
readonly shutdown: () => void;
|
|
12
|
+
/** Socket-only `gateway.console.*` (Console setup and user rescue); absent without Console. */
|
|
13
|
+
readonly console?: {
|
|
14
|
+
owns(type: string): boolean;
|
|
15
|
+
handle(type: string, data: unknown): Promise<LocalRequestResult>;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* `gateway.http.request`: a `{base}/…` request as the local owner, or — with `token` — as the
|
|
19
|
+
* principal that credential authenticates to; absent → `unavailable`.
|
|
20
|
+
*/
|
|
21
|
+
readonly http?: (request: {
|
|
22
|
+
readonly method: string;
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly body?: string;
|
|
25
|
+
readonly token?: string;
|
|
26
|
+
}) => Promise<Response>;
|
|
27
|
+
}
|
|
28
|
+
/** UTF-8 bytes `value` takes inside a JSON line (escapes included). */
|
|
29
|
+
export declare function jsonEncodedBytes(value: string): number;
|
|
30
|
+
export declare function createLocalRpcHandler(options: LocalRpcHandlerOptions): LocalRequestHandler;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Maps local socket requests onto the gateway RPC with principal `'local'` (ADR 0054 §6). */
|
|
2
|
+
import { GATEWAY_RPC } from '@zhin.js/console-protocol';
|
|
3
|
+
import { LOCAL_GATEWAY_RPC, MAX_LOCAL_HTTP_BODY_BYTES } from './protocol.js';
|
|
4
|
+
const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
5
|
+
const MAX_TOKEN_CHARS = 4096;
|
|
6
|
+
const USE_HTTP = '改用 HTTP 请求(Console API 令牌 ZHIN_CONSOLE_TOKEN,--host 指向网关地址)';
|
|
7
|
+
/** UTF-8 bytes `value` takes inside a JSON line (escapes included). */
|
|
8
|
+
export function jsonEncodedBytes(value) {
|
|
9
|
+
return Buffer.byteLength(JSON.stringify(value));
|
|
10
|
+
}
|
|
11
|
+
async function handleHttp(options, data) {
|
|
12
|
+
const invalid = (message) => ({ ok: false, error: { code: 'invalid_input', message, status: 400 } });
|
|
13
|
+
if (!options.http)
|
|
14
|
+
return { ok: false, error: { code: 'unavailable', message: '网关未提供 HTTP 转发', status: 503 } };
|
|
15
|
+
const input = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
16
|
+
const { method, path, body, token } = input;
|
|
17
|
+
if (typeof method !== 'string' || !HTTP_METHODS.has(method))
|
|
18
|
+
return invalid('method 必须是 GET / POST / PUT / PATCH / DELETE');
|
|
19
|
+
// `\` is a path separator to the WHATWG URL parser (`/\evil.com/x` names a host).
|
|
20
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.includes('\\') || path.length > 4096) {
|
|
21
|
+
return invalid('path 必须是以 / 开头、不含 \\ 的路径');
|
|
22
|
+
}
|
|
23
|
+
if (body !== undefined && typeof body !== 'string')
|
|
24
|
+
return invalid('body 必须是字符串');
|
|
25
|
+
if (body !== undefined && method === 'GET')
|
|
26
|
+
return invalid('GET 请求不能带 body');
|
|
27
|
+
if (typeof body === 'string' && jsonEncodedBytes(body) > MAX_LOCAL_HTTP_BODY_BYTES) {
|
|
28
|
+
return { ok: false, error: { code: 'invalid_input', message: `请求体超过本地套接字上限;${USE_HTTP}`, status: 413 } };
|
|
29
|
+
}
|
|
30
|
+
if (token !== undefined && (typeof token !== 'string' || token.length === 0 || token.length > MAX_TOKEN_CHARS))
|
|
31
|
+
return invalid('token 必须是非空字符串');
|
|
32
|
+
const response = await options.http({
|
|
33
|
+
method,
|
|
34
|
+
path,
|
|
35
|
+
...(body === undefined ? {} : { body }),
|
|
36
|
+
...(token === undefined ? {} : { token }),
|
|
37
|
+
});
|
|
38
|
+
// A stream (SSE) never ends: one JSON line cannot carry it.
|
|
39
|
+
if (response.headers.get('content-type')?.toLowerCase().startsWith('text/event-stream')) {
|
|
40
|
+
await response.body?.cancel().catch(() => undefined);
|
|
41
|
+
return { ok: false, error: { code: 'unavailable', message: `事件流不能经本地套接字转发;${USE_HTTP}`, status: 502 } };
|
|
42
|
+
}
|
|
43
|
+
const text = await response.text();
|
|
44
|
+
if (jsonEncodedBytes(text) > MAX_LOCAL_HTTP_BODY_BYTES) {
|
|
45
|
+
return { ok: false, error: { code: 'unavailable', message: `响应超过本地套接字上限;${USE_HTTP}`, status: 502 } };
|
|
46
|
+
}
|
|
47
|
+
const headers = {};
|
|
48
|
+
for (const name of ['content-type', 'retry-after']) {
|
|
49
|
+
const value = response.headers.get(name);
|
|
50
|
+
if (value !== null)
|
|
51
|
+
headers[name] = value;
|
|
52
|
+
}
|
|
53
|
+
const result = { status: response.status, headers, body: text };
|
|
54
|
+
return { ok: true, data: result };
|
|
55
|
+
}
|
|
56
|
+
const STREAMED = new Set([GATEWAY_RPC.INSTALL, GATEWAY_RPC.UNINSTALL, GATEWAY_RPC.UPDATE, GATEWAY_RPC.DECLARE]);
|
|
57
|
+
export function createLocalRpcHandler(options) {
|
|
58
|
+
return async (request, emit) => {
|
|
59
|
+
if (request.type === LOCAL_GATEWAY_RPC.INFO)
|
|
60
|
+
return { ok: true, data: options.info() };
|
|
61
|
+
if (request.type === LOCAL_GATEWAY_RPC.SHUTDOWN) {
|
|
62
|
+
setImmediate(options.shutdown);
|
|
63
|
+
return { ok: true, data: { stopping: true } };
|
|
64
|
+
}
|
|
65
|
+
if (options.console?.owns(request.type))
|
|
66
|
+
return options.console.handle(request.type, request.data);
|
|
67
|
+
if (request.type === LOCAL_GATEWAY_RPC.HTTP_REQUEST)
|
|
68
|
+
return handleHttp(options, request.data);
|
|
69
|
+
// One transaction at a time: the first `start` seen after subscribing is this request's.
|
|
70
|
+
let transaction;
|
|
71
|
+
const unsubscribe = STREAMED.has(request.type)
|
|
72
|
+
? options.packages?.subscribe((event) => {
|
|
73
|
+
if (transaction === undefined && event.type === 'start')
|
|
74
|
+
transaction = event.id;
|
|
75
|
+
if (event.id === transaction)
|
|
76
|
+
emit(event);
|
|
77
|
+
})
|
|
78
|
+
: undefined;
|
|
79
|
+
try {
|
|
80
|
+
return await options.dispatch({ type: request.type, data: request.data }, 'local');
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
unsubscribe?.();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { GatewayPackageEvent } from '@zhin.js/console-protocol';
|
|
2
|
+
import { type LocalGatewayError, type LocalGatewayRequest } from './protocol.js';
|
|
3
|
+
export type LocalSocketErrorCode = 'in_use' | 'not_owner' | 'insecure_dir' | 'not_socket' | 'insecure_socket';
|
|
4
|
+
export declare class LocalSocketError extends Error {
|
|
5
|
+
readonly code: LocalSocketErrorCode;
|
|
6
|
+
constructor(code: LocalSocketErrorCode, message: string, options?: ErrorOptions);
|
|
7
|
+
}
|
|
8
|
+
export interface LocalSocketSecurityOptions {
|
|
9
|
+
/** Current user id; `undefined` skips owner checks (Windows). Default `process.getuid()`. */
|
|
10
|
+
readonly uid?: () => number | undefined;
|
|
11
|
+
/** Connect probe timeout for an existing socket. Default 1 s. */
|
|
12
|
+
readonly probeTimeoutMs?: number;
|
|
13
|
+
}
|
|
14
|
+
export type LocalRequestResult = Readonly<{
|
|
15
|
+
ok: true;
|
|
16
|
+
data: unknown;
|
|
17
|
+
}> | Readonly<{
|
|
18
|
+
ok: false;
|
|
19
|
+
error: LocalGatewayError;
|
|
20
|
+
}>;
|
|
21
|
+
/** Answers one request; `emit` streams events for it before the answer. */
|
|
22
|
+
export type LocalRequestHandler = (request: LocalGatewayRequest, emit: (event: GatewayPackageEvent) => void) => Promise<LocalRequestResult>;
|
|
23
|
+
export interface LocalControlServerOptions extends LocalSocketSecurityOptions {
|
|
24
|
+
readonly path: string;
|
|
25
|
+
/** Requests before {@link LocalControlServer.setHandler} get `unavailable`. */
|
|
26
|
+
readonly handler?: LocalRequestHandler;
|
|
27
|
+
readonly maxConnections?: number;
|
|
28
|
+
/** Key of the {@link LOCAL_GATEWAY_RPC.HELLO} handshake; without it `gateway.hello` proves nothing. */
|
|
29
|
+
readonly handshakeKey?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Runs once the path is known to be free (no live gateway) and before the bind: the gateway
|
|
32
|
+
* drops a stale `.zhin/gateway.json`, so no client checks this run against an old handshake key.
|
|
33
|
+
*/
|
|
34
|
+
readonly beforeListen?: () => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/** Probes an existing endpoint: `live` when something accepts a connection. */
|
|
37
|
+
export declare function probeLocalSocket(path: string, timeoutMs?: number): Promise<'live' | 'dead'>;
|
|
38
|
+
/**
|
|
39
|
+
* Checks the socket's directory and any existing socket, removing a stale one. Throws
|
|
40
|
+
* {@link LocalSocketError} when another live gateway owns the path or ownership is wrong.
|
|
41
|
+
*/
|
|
42
|
+
export declare function prepareLocalSocketPath(path: string, options?: LocalSocketSecurityOptions): Promise<{
|
|
43
|
+
readonly removedStale: boolean;
|
|
44
|
+
}>;
|
|
45
|
+
/** The listening control server. */
|
|
46
|
+
export declare class LocalControlServer {
|
|
47
|
+
#private;
|
|
48
|
+
readonly path: string;
|
|
49
|
+
private constructor();
|
|
50
|
+
/** Validates the path, removes a stale socket, then listens with mode 0600. */
|
|
51
|
+
static open(options: LocalControlServerOptions): Promise<LocalControlServer>;
|
|
52
|
+
setHandler(handler: LocalRequestHandler): void;
|
|
53
|
+
/** Stops accepting, drops connections and removes the socket file it created. */
|
|
54
|
+
close(): Promise<void>;
|
|
55
|
+
}
|