@zhin.js/host-http 1.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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/lib/console-rest-pages.d.ts +70 -0
- package/lib/console-rest-pages.js +680 -0
- package/lib/console-rpc-extended.d.ts +36 -0
- package/lib/console-rpc-extended.js +579 -0
- package/lib/console-rpc.d.ts +98 -0
- package/lib/console-rpc.js +687 -0
- package/lib/http-host.d.ts +50 -0
- package/lib/http-host.js +410 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +8 -0
- package/lib/json-body.d.ts +12 -0
- package/lib/json-body.js +37 -0
- package/lib/openapi.d.ts +31 -0
- package/lib/openapi.js +136 -0
- package/lib/project-files.d.ts +20 -0
- package/lib/project-files.js +145 -0
- package/lib/timing-safe-equal.d.ts +2 -0
- package/lib/timing-safe-equal.js +10 -0
- package/lib/token-registry.d.ts +20 -0
- package/lib/token-registry.js +41 -0
- package/package.json +48 -0
- package/src/console-rest-pages.ts +880 -0
- package/src/console-rpc-extended.ts +696 -0
- package/src/console-rpc.ts +759 -0
- package/src/http-host.ts +533 -0
- package/src/index.ts +63 -0
- package/src/json-body.ts +41 -0
- package/src/openapi.ts +172 -0
- package/src/project-files.ts +161 -0
- package/src/timing-safe-equal.ts +9 -0
- package/src/token-registry.ts +58 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
+
import { type WebSocket } from 'ws';
|
|
3
|
+
import { TokenRegistry, type AuthScope, type ScopedTokenConfig } from './token-registry.js';
|
|
4
|
+
import { type ListedRoute, type RouteMeta } from './openapi.js';
|
|
5
|
+
export declare const REMOTE_CONSOLE_ORIGIN = "https://console.zhin.dev";
|
|
6
|
+
export interface WsConnection {
|
|
7
|
+
readonly socket: WebSocket;
|
|
8
|
+
readonly request: IncomingMessage;
|
|
9
|
+
readonly authScope: AuthScope;
|
|
10
|
+
}
|
|
11
|
+
export interface WsHandle {
|
|
12
|
+
onConnection(listener: (connection: WsConnection) => void): () => void;
|
|
13
|
+
close(): void;
|
|
14
|
+
}
|
|
15
|
+
export interface HttpHostAddress {
|
|
16
|
+
readonly host: string;
|
|
17
|
+
readonly port: number;
|
|
18
|
+
}
|
|
19
|
+
export interface HttpHostOptions {
|
|
20
|
+
readonly host?: string;
|
|
21
|
+
readonly port?: number;
|
|
22
|
+
/** Primary full-scope Bearer token (`http.token`). */
|
|
23
|
+
readonly token?: string;
|
|
24
|
+
/** Additional scoped tokens (`http.tokens`). */
|
|
25
|
+
readonly tokens?: readonly ScopedTokenConfig[];
|
|
26
|
+
/** CORS allowlist; always merged with Remote Console origin. */
|
|
27
|
+
readonly corsOrigins?: readonly string[];
|
|
28
|
+
/**
|
|
29
|
+
* Paths under this prefix require Bearer auth when a token registry is configured.
|
|
30
|
+
* Defaults to `/api`. `/pub/*`, Console shell, and page routes stay public.
|
|
31
|
+
*/
|
|
32
|
+
readonly apiBase?: string;
|
|
33
|
+
/** Extra HTTP path prefixes that skip auth even under `apiBase`. */
|
|
34
|
+
readonly authExemptPaths?: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
export type HttpHandler = (request: IncomingMessage, response: ServerResponse, url: URL, authScope: AuthScope) => void | Promise<void>;
|
|
37
|
+
export interface HttpRouteRegistration {
|
|
38
|
+
(): void;
|
|
39
|
+
}
|
|
40
|
+
export interface HttpHost {
|
|
41
|
+
ws(path: string): WsHandle;
|
|
42
|
+
route(method: string, path: string, handler: HttpHandler, meta?: RouteMeta): HttpRouteRegistration;
|
|
43
|
+
listRoutes(): readonly ListedRoute[];
|
|
44
|
+
listen(): Promise<HttpHostAddress>;
|
|
45
|
+
close(): Promise<void>;
|
|
46
|
+
get address(): HttpHostAddress | undefined;
|
|
47
|
+
get tokenRegistry(): TokenRegistry;
|
|
48
|
+
}
|
|
49
|
+
export declare const httpHostToken: import("@zhin.js/plugin-runtime").Token<HttpHost>;
|
|
50
|
+
export declare function createHttpHost(options?: HttpHostOptions): HttpHost;
|
package/lib/http-host.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
import { createServer, } from 'node:http';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
4
|
+
import { createToken } from '@zhin.js/plugin-runtime';
|
|
5
|
+
import { TokenRegistry, extractBearerToken, isDemoWebSocketPath, } from './token-registry.js';
|
|
6
|
+
import { buildOpenApiDocument, } from './openapi.js';
|
|
7
|
+
import { isDemoHttpAllowed } from './console-rpc.js';
|
|
8
|
+
const logger = getLogger('HttpHost');
|
|
9
|
+
export const REMOTE_CONSOLE_ORIGIN = 'https://console.zhin.dev';
|
|
10
|
+
const DEFAULT_CORS_ORIGINS = Object.freeze([REMOTE_CONSOLE_ORIGIN]);
|
|
11
|
+
export const httpHostToken = createToken('zhin.host.http');
|
|
12
|
+
export function createHttpHost(options = {}) {
|
|
13
|
+
const host = options.host ?? '127.0.0.1';
|
|
14
|
+
const port = options.port ?? 8086;
|
|
15
|
+
const apiBase = normalizePath(options.apiBase ?? '/api');
|
|
16
|
+
const corsOrigins = Object.freeze([
|
|
17
|
+
...new Set([...DEFAULT_CORS_ORIGINS, ...(options.corsOrigins ?? [])]),
|
|
18
|
+
]);
|
|
19
|
+
const authExempt = Object.freeze((options.authExemptPaths ?? []).map((path) => normalizePath(path)));
|
|
20
|
+
const tokenRegistry = new TokenRegistry({
|
|
21
|
+
primaryToken: options.token,
|
|
22
|
+
scopedTokens: options.tokens,
|
|
23
|
+
});
|
|
24
|
+
const wsRoutes = new Map();
|
|
25
|
+
const httpRoutes = [];
|
|
26
|
+
let address;
|
|
27
|
+
let closed = false;
|
|
28
|
+
const server = createServer((request, response) => {
|
|
29
|
+
void dispatchHttp(request, response);
|
|
30
|
+
});
|
|
31
|
+
// Track live TCP sockets so close() can destroy long-lived connections
|
|
32
|
+
// (SSE /api/events, keep-alive) that would otherwise block server.close().
|
|
33
|
+
const sockets = new Set();
|
|
34
|
+
server.on('connection', (socket) => {
|
|
35
|
+
sockets.add(socket);
|
|
36
|
+
socket.once('close', () => {
|
|
37
|
+
sockets.delete(socket);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
41
|
+
// Built-in public health probe (parity with host-router `/pub/health`).
|
|
42
|
+
httpRoutes.push(Object.freeze({
|
|
43
|
+
method: 'GET',
|
|
44
|
+
path: '/pub/health',
|
|
45
|
+
pattern: '/pub/health',
|
|
46
|
+
prefix: false,
|
|
47
|
+
meta: Object.freeze({ summary: 'Health probe', tags: ['pub'] }),
|
|
48
|
+
handler: (_request, response) => {
|
|
49
|
+
writeJson(response, 200, {
|
|
50
|
+
success: true,
|
|
51
|
+
status: 'ok',
|
|
52
|
+
timestamp: new Date().toISOString(),
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
}));
|
|
56
|
+
// OpenAPI catalog (parity with host-router `/pub/openapi.json`).
|
|
57
|
+
httpRoutes.push(Object.freeze({
|
|
58
|
+
method: 'GET',
|
|
59
|
+
path: '/pub/openapi.json',
|
|
60
|
+
pattern: '/pub/openapi.json',
|
|
61
|
+
prefix: false,
|
|
62
|
+
meta: Object.freeze({ summary: 'OpenAPI 3.1 document', tags: ['pub'] }),
|
|
63
|
+
handler: (request, response) => {
|
|
64
|
+
const hostHeader = headerValue(request.headers.host) ?? 'localhost';
|
|
65
|
+
const proto = headerValue(request.headers['x-forwarded-proto']) ?? 'http';
|
|
66
|
+
writeJson(response, 200, buildOpenApiDocument(listListedRoutes(), {
|
|
67
|
+
apiBase,
|
|
68
|
+
serverUrl: `${proto}://${hostHeader}`,
|
|
69
|
+
version: process.env.npm_package_version ?? '0.0.0',
|
|
70
|
+
}));
|
|
71
|
+
},
|
|
72
|
+
}));
|
|
73
|
+
server.on('upgrade', (request, socket, head) => {
|
|
74
|
+
if (closed) {
|
|
75
|
+
socket.destroy();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const pathname = upgradePath(request.url);
|
|
79
|
+
const listeners = wsRoutes.get(pathname);
|
|
80
|
+
if (!listeners || listeners.size === 0) {
|
|
81
|
+
logger.debug(formatCompact({ op: 'ws_upgrade_unmatched', path: pathname }));
|
|
82
|
+
socket.destroy();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const auth = authenticateUpgrade(request, pathname);
|
|
86
|
+
if (!auth.ok) {
|
|
87
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
88
|
+
socket.destroy();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
93
|
+
const connection = Object.freeze({
|
|
94
|
+
socket: ws,
|
|
95
|
+
request,
|
|
96
|
+
authScope: auth.scope,
|
|
97
|
+
});
|
|
98
|
+
for (const listener of listeners)
|
|
99
|
+
listener(connection);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
logger.warn(formatCompact({
|
|
104
|
+
op: 'ws_upgrade_failed',
|
|
105
|
+
path: pathname,
|
|
106
|
+
error: err instanceof Error ? err.message : String(err),
|
|
107
|
+
}));
|
|
108
|
+
try {
|
|
109
|
+
socket.destroy();
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
/* already destroyed */
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
function authenticateUpgrade(request, pathname) {
|
|
117
|
+
if (!tokenRegistry.hasAnyToken())
|
|
118
|
+
return { ok: true, scope: 'full' };
|
|
119
|
+
let url;
|
|
120
|
+
try {
|
|
121
|
+
url = new URL(request.url ?? '/', 'http://localhost');
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return { ok: false };
|
|
125
|
+
}
|
|
126
|
+
const token = extractBearerToken(headerValue(request.headers.authorization), url.searchParams.get('token'));
|
|
127
|
+
const scope = tokenRegistry.resolve(token);
|
|
128
|
+
if (!scope)
|
|
129
|
+
return { ok: false };
|
|
130
|
+
if (scope === 'demo' && !isDemoWebSocketPath(pathname))
|
|
131
|
+
return { ok: false };
|
|
132
|
+
return { ok: true, scope };
|
|
133
|
+
}
|
|
134
|
+
async function dispatchHttp(request, response) {
|
|
135
|
+
if (closed) {
|
|
136
|
+
response.writeHead(503);
|
|
137
|
+
response.end();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
141
|
+
let url;
|
|
142
|
+
try {
|
|
143
|
+
url = new URL(request.url ?? '/', 'http://localhost');
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
response.writeHead(400);
|
|
147
|
+
response.end();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const pathname = normalizePath(url.pathname);
|
|
151
|
+
const origin = headerValue(request.headers.origin);
|
|
152
|
+
const corsOk = corsMatch(origin, corsOrigins);
|
|
153
|
+
if (method === 'OPTIONS') {
|
|
154
|
+
if (corsOk && origin)
|
|
155
|
+
applyCors(response, origin, corsOrigins);
|
|
156
|
+
response.writeHead(204);
|
|
157
|
+
response.end();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const auth = authenticateHttp(request, url, pathname);
|
|
161
|
+
if (!auth.ok) {
|
|
162
|
+
if (corsOk && origin)
|
|
163
|
+
applyCors(response, origin, corsOrigins);
|
|
164
|
+
writeJson(response, 401, { success: false, error: 'Invalid or missing token' });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const route = matchHttpRoute(httpRoutes, method, pathname);
|
|
168
|
+
if (!route) {
|
|
169
|
+
if (corsOk && origin)
|
|
170
|
+
applyCors(response, origin, corsOrigins);
|
|
171
|
+
response.writeHead(404);
|
|
172
|
+
response.end();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
if (corsOk && origin)
|
|
177
|
+
applyCors(response, origin, corsOrigins);
|
|
178
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
179
|
+
response.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
180
|
+
await route.handler(request, response, url, auth.scope);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
logger.warn(formatCompact({
|
|
184
|
+
op: 'http_handler_failed',
|
|
185
|
+
path: pathname,
|
|
186
|
+
error: err instanceof Error ? err.message : String(err),
|
|
187
|
+
}));
|
|
188
|
+
if (!response.headersSent) {
|
|
189
|
+
response.writeHead(500);
|
|
190
|
+
response.end();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function authenticateHttp(request, url, pathname) {
|
|
195
|
+
if (!requiresHttpAuth(pathname, apiBase, authExempt)) {
|
|
196
|
+
return { ok: true, scope: 'full' };
|
|
197
|
+
}
|
|
198
|
+
if (!tokenRegistry.hasAnyToken()) {
|
|
199
|
+
return { ok: true, scope: 'full' };
|
|
200
|
+
}
|
|
201
|
+
const token = extractBearerToken(headerValue(request.headers.authorization), url.searchParams.get('token'));
|
|
202
|
+
const scope = tokenRegistry.resolve(token);
|
|
203
|
+
if (!scope)
|
|
204
|
+
return { ok: false };
|
|
205
|
+
if (scope === 'demo') {
|
|
206
|
+
const method = (request.method ?? 'GET').toUpperCase();
|
|
207
|
+
if (!isDemoHttpAllowed(method, pathname, apiBase))
|
|
208
|
+
return { ok: false };
|
|
209
|
+
}
|
|
210
|
+
return { ok: true, scope };
|
|
211
|
+
}
|
|
212
|
+
function listListedRoutes() {
|
|
213
|
+
return Object.freeze(httpRoutes.map((route) => Object.freeze({
|
|
214
|
+
method: route.method,
|
|
215
|
+
pattern: route.pattern,
|
|
216
|
+
meta: route.meta,
|
|
217
|
+
})));
|
|
218
|
+
}
|
|
219
|
+
return {
|
|
220
|
+
ws(pathname) {
|
|
221
|
+
const normalized = normalizePath(pathname);
|
|
222
|
+
let listeners = wsRoutes.get(normalized);
|
|
223
|
+
if (!listeners) {
|
|
224
|
+
listeners = new Set();
|
|
225
|
+
wsRoutes.set(normalized, listeners);
|
|
226
|
+
}
|
|
227
|
+
const owned = new Set();
|
|
228
|
+
return {
|
|
229
|
+
onConnection(listener) {
|
|
230
|
+
listeners.add(listener);
|
|
231
|
+
owned.add(listener);
|
|
232
|
+
return () => {
|
|
233
|
+
listeners.delete(listener);
|
|
234
|
+
owned.delete(listener);
|
|
235
|
+
};
|
|
236
|
+
},
|
|
237
|
+
close() {
|
|
238
|
+
for (const listener of owned)
|
|
239
|
+
listeners.delete(listener);
|
|
240
|
+
owned.clear();
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
},
|
|
244
|
+
route(method, path, handler, meta) {
|
|
245
|
+
const normalizedMethod = method.toUpperCase();
|
|
246
|
+
const prefix = path.endsWith('/*');
|
|
247
|
+
const normalizedPath = normalizePath(prefix ? path.slice(0, -2) : path);
|
|
248
|
+
const pattern = prefix ? `${normalizedPath}/*` : normalizedPath;
|
|
249
|
+
const entry = Object.freeze({
|
|
250
|
+
method: normalizedMethod,
|
|
251
|
+
path: normalizedPath,
|
|
252
|
+
pattern,
|
|
253
|
+
prefix,
|
|
254
|
+
handler,
|
|
255
|
+
meta,
|
|
256
|
+
});
|
|
257
|
+
httpRoutes.push(entry);
|
|
258
|
+
return () => {
|
|
259
|
+
const index = httpRoutes.indexOf(entry);
|
|
260
|
+
if (index >= 0)
|
|
261
|
+
httpRoutes.splice(index, 1);
|
|
262
|
+
};
|
|
263
|
+
},
|
|
264
|
+
listRoutes() {
|
|
265
|
+
return listListedRoutes();
|
|
266
|
+
},
|
|
267
|
+
get address() {
|
|
268
|
+
return address;
|
|
269
|
+
},
|
|
270
|
+
get tokenRegistry() {
|
|
271
|
+
return tokenRegistry;
|
|
272
|
+
},
|
|
273
|
+
listen() {
|
|
274
|
+
if (address)
|
|
275
|
+
return Promise.resolve(address);
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
server.once('error', reject);
|
|
278
|
+
server.listen({ host, port }, () => {
|
|
279
|
+
server.off('error', reject);
|
|
280
|
+
const bound = server.address();
|
|
281
|
+
const listenPort = typeof bound === 'object' && bound ? bound.port : port;
|
|
282
|
+
const publicHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
|
|
283
|
+
address = Object.freeze({ host: publicHost, port: listenPort });
|
|
284
|
+
logger.info(formatCompact({
|
|
285
|
+
http: `http://${publicHost}:${listenPort}`,
|
|
286
|
+
token: tokenRegistry.hasAnyToken()
|
|
287
|
+
? `${tokenRegistry.primaryTokenPrefixForLog()}…`
|
|
288
|
+
: '(none)',
|
|
289
|
+
cors: corsOrigins.join(','),
|
|
290
|
+
ws_routes: [...wsRoutes.keys()].join(',') || '(none)',
|
|
291
|
+
http_routes: httpRoutes.length,
|
|
292
|
+
}));
|
|
293
|
+
resolve(address);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
close() {
|
|
298
|
+
if (closed)
|
|
299
|
+
return Promise.resolve();
|
|
300
|
+
closed = true;
|
|
301
|
+
wsRoutes.clear();
|
|
302
|
+
httpRoutes.length = 0;
|
|
303
|
+
// Rollback paths dispose a host whose generation never reached listen().
|
|
304
|
+
if (!server.listening) {
|
|
305
|
+
address = undefined;
|
|
306
|
+
return Promise.resolve();
|
|
307
|
+
}
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
// Long-lived connections (SSE, keep-alive, WS) never end on their own:
|
|
310
|
+
// destroy tracked sockets and terminate WS clients before close(),
|
|
311
|
+
// otherwise the close callbacks would never fire.
|
|
312
|
+
for (const socket of sockets)
|
|
313
|
+
socket.destroy();
|
|
314
|
+
sockets.clear();
|
|
315
|
+
for (const client of wss.clients)
|
|
316
|
+
client.terminate();
|
|
317
|
+
wss.close((wssError) => {
|
|
318
|
+
if (wssError) {
|
|
319
|
+
reject(wssError);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
server.close((serverError) => {
|
|
323
|
+
if (serverError) {
|
|
324
|
+
reject(serverError);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
logger.debug(formatCompact({ op: 'http_host_closed' }));
|
|
328
|
+
address = undefined;
|
|
329
|
+
resolve();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
function requiresHttpAuth(pathname, apiBase, authExempt) {
|
|
337
|
+
if (pathname === '/pub' || pathname.startsWith('/pub/'))
|
|
338
|
+
return false;
|
|
339
|
+
if (authExempt.some((path) => pathname === path || pathname.startsWith(`${path}/`))) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
return pathname === apiBase || pathname.startsWith(`${apiBase}/`);
|
|
343
|
+
}
|
|
344
|
+
function corsMatch(origin, allowed) {
|
|
345
|
+
if (!origin || allowed.length === 0)
|
|
346
|
+
return false;
|
|
347
|
+
return allowed.includes('*')
|
|
348
|
+
|| allowed.some((entry) => (entry === origin
|
|
349
|
+
|| (entry.endsWith('*') && origin.startsWith(entry.slice(0, -1)))));
|
|
350
|
+
}
|
|
351
|
+
function applyCors(response, origin, allowed) {
|
|
352
|
+
response.setHeader('Access-Control-Allow-Origin', allowed.includes('*') ? '*' : origin);
|
|
353
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
354
|
+
response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept');
|
|
355
|
+
response.setHeader('Access-Control-Max-Age', '86400');
|
|
356
|
+
}
|
|
357
|
+
function writeJson(response, status, body) {
|
|
358
|
+
const payload = JSON.stringify(body);
|
|
359
|
+
response.writeHead(status, {
|
|
360
|
+
'content-type': 'application/json; charset=utf-8',
|
|
361
|
+
'content-length': Buffer.byteLength(payload),
|
|
362
|
+
});
|
|
363
|
+
response.end(payload);
|
|
364
|
+
}
|
|
365
|
+
function headerValue(value) {
|
|
366
|
+
if (Array.isArray(value))
|
|
367
|
+
return value[0];
|
|
368
|
+
return value;
|
|
369
|
+
}
|
|
370
|
+
function matchHttpRoute(routes, method, pathname) {
|
|
371
|
+
let best;
|
|
372
|
+
let bestLength = -1;
|
|
373
|
+
for (const route of routes) {
|
|
374
|
+
if (route.method !== method && route.method !== 'ALL')
|
|
375
|
+
continue;
|
|
376
|
+
if (route.prefix) {
|
|
377
|
+
const matches = route.path === '/'
|
|
378
|
+
? pathname.startsWith('/')
|
|
379
|
+
: pathname === route.path || pathname.startsWith(`${route.path}/`);
|
|
380
|
+
if (matches && route.path.length > bestLength) {
|
|
381
|
+
best = route;
|
|
382
|
+
bestLength = route.path.length;
|
|
383
|
+
}
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
if (pathname === route.path && route.path.length >= bestLength) {
|
|
387
|
+
best = route;
|
|
388
|
+
bestLength = route.path.length;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
return best;
|
|
392
|
+
}
|
|
393
|
+
function normalizePath(pathname) {
|
|
394
|
+
if (!pathname.startsWith('/'))
|
|
395
|
+
return `/${pathname}`;
|
|
396
|
+
// 线性裁剪尾部斜杠(等价于 /\/+$/u,但避免长串 `/` 无匹配时的
|
|
397
|
+
// 二次方回溯 — js/polynomial-redos)。
|
|
398
|
+
let end = pathname.length;
|
|
399
|
+
while (end > 0 && pathname[end - 1] === '/')
|
|
400
|
+
end -= 1;
|
|
401
|
+
return pathname.slice(0, end) || '/';
|
|
402
|
+
}
|
|
403
|
+
function upgradePath(url) {
|
|
404
|
+
try {
|
|
405
|
+
return normalizePath(new URL(url ?? '/', 'http://localhost').pathname);
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
return '/';
|
|
409
|
+
}
|
|
410
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type HttpHostAddress, type HttpHostOptions, type HttpRouteRegistration, type WsConnection, type WsHandle, } from './http-host.js';
|
|
2
|
+
export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, type AuthScope, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
|
|
3
|
+
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
4
|
+
export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, type BuildOpenApiOptions, type ListedRoute, type OpenApiParameter, type RouteMeta, } from './openapi.js';
|
|
5
|
+
export { HttpBodyError, readJsonBody, } from './json-body.js';
|
|
6
|
+
export { dispatchRuntimeConsoleRpc, isDemoHttpAllowed, pickRpcReply, type RuntimeConsolePage, type RuntimeConsoleRpcContext, type RuntimeConsoleRpcMessage, type RuntimeConsoleRpcReply, type RuntimeEndpointSendInput, type RuntimeEndpointSummary, } from './console-rpc.js';
|
|
7
|
+
export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, type FileTreeNode, } from './project-files.js';
|
|
8
|
+
export { registerConsoleRestPages, type ConsoleAgentIntrospection, type ConsoleAgentRuntime, type ConsoleAgentSessionTree, type ConsoleRestCtx, type ConsoleRestPagesOptions, } from './console-rest-pages.js';
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, } from './http-host.js';
|
|
2
|
+
export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, } from './token-registry.js';
|
|
3
|
+
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
4
|
+
export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, } from './openapi.js';
|
|
5
|
+
export { HttpBodyError, readJsonBody, } from './json-body.js';
|
|
6
|
+
export { dispatchRuntimeConsoleRpc, isDemoHttpAllowed, pickRpcReply, } from './console-rpc.js';
|
|
7
|
+
export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, } from './project-files.js';
|
|
8
|
+
export { registerConsoleRestPages, } from './console-rest-pages.js';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
export declare class HttpBodyError extends Error {
|
|
3
|
+
readonly statusCode: number;
|
|
4
|
+
constructor(message: string, statusCode?: number);
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Read and parse a JSON request body with a size limit (default 1 MiB).
|
|
8
|
+
* Empty bodies become `undefined`.
|
|
9
|
+
*/
|
|
10
|
+
export declare function readJsonBody<T = unknown>(request: IncomingMessage, options?: {
|
|
11
|
+
readonly limit?: number;
|
|
12
|
+
}): Promise<T | undefined>;
|
package/lib/json-body.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export class HttpBodyError extends Error {
|
|
2
|
+
statusCode;
|
|
3
|
+
constructor(message, statusCode = 400) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.statusCode = statusCode;
|
|
6
|
+
this.name = 'HttpBodyError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Read and parse a JSON request body with a size limit (default 1 MiB).
|
|
11
|
+
* Empty bodies become `undefined`.
|
|
12
|
+
*/
|
|
13
|
+
export async function readJsonBody(request, options = {}) {
|
|
14
|
+
const limit = options.limit ?? 1_048_576;
|
|
15
|
+
const chunks = [];
|
|
16
|
+
let size = 0;
|
|
17
|
+
for await (const chunk of request) {
|
|
18
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
19
|
+
size += buffer.length;
|
|
20
|
+
if (size > limit) {
|
|
21
|
+
request.destroy();
|
|
22
|
+
throw new HttpBodyError(`Request body exceeds ${limit} bytes`, 413);
|
|
23
|
+
}
|
|
24
|
+
chunks.push(buffer);
|
|
25
|
+
}
|
|
26
|
+
if (chunks.length === 0)
|
|
27
|
+
return undefined;
|
|
28
|
+
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
29
|
+
if (!raw)
|
|
30
|
+
return undefined;
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new HttpBodyError('Invalid JSON body', 400);
|
|
36
|
+
}
|
|
37
|
+
}
|
package/lib/openapi.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type OpenApiParameter = {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly in: 'query' | 'path' | 'header';
|
|
4
|
+
readonly required?: boolean;
|
|
5
|
+
readonly description?: string;
|
|
6
|
+
readonly schema?: Record<string, unknown>;
|
|
7
|
+
};
|
|
8
|
+
export type RouteMeta = {
|
|
9
|
+
readonly summary?: string;
|
|
10
|
+
readonly description?: string;
|
|
11
|
+
readonly tags?: readonly string[];
|
|
12
|
+
readonly operationId?: string;
|
|
13
|
+
readonly deprecated?: boolean;
|
|
14
|
+
readonly parameters?: readonly OpenApiParameter[];
|
|
15
|
+
readonly responses?: Record<string, unknown>;
|
|
16
|
+
readonly requestBody?: Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
export type ListedRoute = {
|
|
19
|
+
readonly method: string;
|
|
20
|
+
readonly pattern: string;
|
|
21
|
+
readonly meta?: RouteMeta;
|
|
22
|
+
};
|
|
23
|
+
export type BuildOpenApiOptions = {
|
|
24
|
+
readonly title?: string;
|
|
25
|
+
readonly version?: string;
|
|
26
|
+
readonly apiBase?: string;
|
|
27
|
+
readonly serverUrl?: string;
|
|
28
|
+
};
|
|
29
|
+
export declare function patternToOpenApiPath(pattern: string): string;
|
|
30
|
+
export declare function routeRequiresBearerAuth(pathname: string, apiBase: string): boolean;
|
|
31
|
+
export declare function buildOpenApiDocument(routes: readonly ListedRoute[], options?: BuildOpenApiOptions): Record<string, unknown>;
|