@zhin.js/host-http 1.0.8 → 1.0.9
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/README.md +5 -0
- package/lib/console-rpc-extended.d.ts +4 -6
- package/lib/console-rpc-extended.js +54 -105
- package/lib/http-host.d.ts +8 -5
- package/lib/http-host.js +134 -55
- package/lib/index.d.ts +1 -1
- package/package.json +2 -2
- package/src/console-rpc-extended.ts +57 -104
- package/src/http-host.ts +163 -63
- package/src/index.ts +1 -0
package/README.md
CHANGED
|
@@ -7,6 +7,11 @@ Console can register path-scoped WebSocket / HTTP routes without importing Koa o
|
|
|
7
7
|
Root installs the host via `installResources` (see `basic/cli/src/plugin-runtime/http-host-installer.ts`
|
|
8
8
|
and `console-host-installer.ts`).
|
|
9
9
|
|
|
10
|
+
The composition root owns the process listener through `ProcessHttpHost`. Generation scopes receive
|
|
11
|
+
only the `HttpHost` routing port: they may register HTTP/WS routes but cannot listen or close the
|
|
12
|
+
shared server. Registrations are bound to generation admission, so candidate routes stay invisible
|
|
13
|
+
until snapshot commit and retired routes stop matching immediately while their leases drain.
|
|
14
|
+
|
|
10
15
|
## Capabilities (this slice)
|
|
11
16
|
|
|
12
17
|
- Path-scoped WebSocket upgrades (`ws(path)`)
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* - inbox:`ctx.databaseHost.models`(unified_inbox_message/request/notice 三张表,
|
|
12
12
|
* 表不存在或查询失败 → 空数组 + inboxEnabled:false;requestConsumed/noticeConsumed
|
|
13
13
|
* 按行 id update consumed=1,表/model 缺失时报"未接线")。
|
|
14
|
-
* - 社交/群管:`ctx.
|
|
15
|
-
* `management` 语义端口;平台 SDK
|
|
14
|
+
* - 社交/群管:`ctx.withEndpointManagement` 在固定 generation lease 内
|
|
15
|
+
* 消费 `management` 语义端口;平台 SDK 字段归一化由 Adapter 负责。
|
|
16
16
|
*/
|
|
17
17
|
export interface ConsoleRpcExtendedCtx {
|
|
18
18
|
/** full scope 才允许写操作;demo scope 只放行只读 RPC。 */
|
|
@@ -20,10 +20,8 @@ export interface ConsoleRpcExtendedCtx {
|
|
|
20
20
|
projectRoot: string;
|
|
21
21
|
/** Plugin Runtime ScheduleHost(basic/cli schedule-host-installer 提供)。 */
|
|
22
22
|
scheduleHost?: unknown;
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
/** @deprecated Use `resolveEndpointManagement`; accepted for one compatibility cycle. */
|
|
26
|
-
resolveEndpoint?: (adapter: string, endpointKey: string) => unknown;
|
|
23
|
+
/** 在固定 generation lease 内执行一次 Endpoint 管理操作。 */
|
|
24
|
+
withEndpointManagement?: <T>(adapter: string, endpointKey: string, run: (management: EndpointManagementPort) => T | Promise<T>) => Promise<T | null>;
|
|
27
25
|
/** Plugin Runtime DatabaseHost 的 models 视图。 */
|
|
28
26
|
databaseHost?: {
|
|
29
27
|
models: {
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* - inbox:`ctx.databaseHost.models`(unified_inbox_message/request/notice 三张表,
|
|
12
12
|
* 表不存在或查询失败 → 空数组 + inboxEnabled:false;requestConsumed/noticeConsumed
|
|
13
13
|
* 按行 id update consumed=1,表/model 缺失时报"未接线")。
|
|
14
|
-
* - 社交/群管:`ctx.
|
|
15
|
-
* `management` 语义端口;平台 SDK
|
|
14
|
+
* - 社交/群管:`ctx.withEndpointManagement` 在固定 generation lease 内
|
|
15
|
+
* 消费 `management` 语义端口;平台 SDK 字段归一化由 Adapter 负责。
|
|
16
16
|
*/
|
|
17
17
|
/** 新 Runtime 统一收件箱表名(SSOT:@zhin.js/plugin-runtime inbox.js)。 */
|
|
18
18
|
import { assertDemoConsoleRpcAllowed, normalizeConsoleRpcType, } from '@zhin.js/console-protocol';
|
|
@@ -398,77 +398,58 @@ async function actOnRequest(type, d, ctx) {
|
|
|
398
398
|
if (!adapter || !endpointKey || !requestId) {
|
|
399
399
|
return { error: '$adapter, $endpoint, $id required' };
|
|
400
400
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
'且新 Runtime 尚未挂载 pending request 注册表',
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
try {
|
|
401
|
+
return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
|
|
402
|
+
const approve = type === 'request.approve';
|
|
403
|
+
const method = approve ? management.approveRequest : management.rejectRequest;
|
|
404
|
+
if (!method) {
|
|
405
|
+
return {
|
|
406
|
+
error: `请求审批未接线:当前平台(${adapter})endpoint 不支持 ${approve ? 'approveRequest' : 'rejectRequest'},` +
|
|
407
|
+
'且新 Runtime 尚未挂载 pending request 注册表',
|
|
408
|
+
};
|
|
409
|
+
}
|
|
414
410
|
const extra = approve
|
|
415
411
|
? strField(d, '$remark', 'remark')
|
|
416
412
|
: strField(d, '$reason', 'reason');
|
|
417
413
|
await method.call(management, requestId, extra || undefined);
|
|
418
414
|
return { data: { success: true } };
|
|
419
|
-
}
|
|
420
|
-
catch (error) {
|
|
421
|
-
return { error: errorMessage(error) };
|
|
422
|
-
}
|
|
415
|
+
});
|
|
423
416
|
}
|
|
424
417
|
// ---------------------------------------------------------------- 社交读取
|
|
425
418
|
async function listFriends(d, ctx) {
|
|
426
|
-
const
|
|
427
|
-
if ('error' in
|
|
428
|
-
return
|
|
429
|
-
|
|
430
|
-
try {
|
|
419
|
+
const endpoint = requireEndpointAddress(d);
|
|
420
|
+
if ('error' in endpoint)
|
|
421
|
+
return endpoint;
|
|
422
|
+
return withLiveEndpoint(ctx, endpoint.adapter, endpoint.endpointKey, async (management) => {
|
|
431
423
|
if (management.listFriends) {
|
|
432
424
|
const friends = [...await management.listFriends()];
|
|
433
425
|
return { data: { friends, count: friends.length } };
|
|
434
426
|
}
|
|
435
|
-
return { error: `当前适配器(${adapter})不支持好友列表` };
|
|
436
|
-
}
|
|
437
|
-
catch (error) {
|
|
438
|
-
return { error: errorMessage(error) };
|
|
439
|
-
}
|
|
427
|
+
return { error: `当前适配器(${endpoint.adapter})不支持好友列表` };
|
|
428
|
+
});
|
|
440
429
|
}
|
|
441
430
|
async function listGroups(d, ctx) {
|
|
442
|
-
const
|
|
443
|
-
if ('error' in
|
|
444
|
-
return
|
|
445
|
-
|
|
446
|
-
try {
|
|
431
|
+
const endpoint = requireEndpointAddress(d);
|
|
432
|
+
if ('error' in endpoint)
|
|
433
|
+
return endpoint;
|
|
434
|
+
return withLiveEndpoint(ctx, endpoint.adapter, endpoint.endpointKey, async (management) => {
|
|
447
435
|
if (management.listGroups) {
|
|
448
436
|
const groups = [...await management.listGroups()];
|
|
449
437
|
return { data: { groups, count: groups.length } };
|
|
450
438
|
}
|
|
451
|
-
return { error: `当前适配器(${adapter})不支持群列表` };
|
|
452
|
-
}
|
|
453
|
-
catch (error) {
|
|
454
|
-
return { error: errorMessage(error) };
|
|
455
|
-
}
|
|
439
|
+
return { error: `当前适配器(${endpoint.adapter})不支持群列表` };
|
|
440
|
+
});
|
|
456
441
|
}
|
|
457
442
|
async function listChannels(d, ctx) {
|
|
458
|
-
const
|
|
459
|
-
if ('error' in
|
|
460
|
-
return
|
|
461
|
-
|
|
462
|
-
try {
|
|
443
|
+
const endpoint = requireEndpointAddress(d);
|
|
444
|
+
if ('error' in endpoint)
|
|
445
|
+
return endpoint;
|
|
446
|
+
return withLiveEndpoint(ctx, endpoint.adapter, endpoint.endpointKey, async (management) => {
|
|
463
447
|
if (management.listChannels) {
|
|
464
448
|
const channels = [...await management.listChannels()];
|
|
465
449
|
return { data: { channels, count: channels.length } };
|
|
466
450
|
}
|
|
467
|
-
return { error: `当前适配器(${adapter})不支持频道列表` };
|
|
468
|
-
}
|
|
469
|
-
catch (error) {
|
|
470
|
-
return { error: errorMessage(error) };
|
|
471
|
-
}
|
|
451
|
+
return { error: `当前适配器(${endpoint.adapter})不支持频道列表` };
|
|
452
|
+
});
|
|
472
453
|
}
|
|
473
454
|
async function listGroupMembers(d, ctx) {
|
|
474
455
|
const adapter = strField(d, '$adapter', 'adapter');
|
|
@@ -477,21 +458,14 @@ async function listGroupMembers(d, ctx) {
|
|
|
477
458
|
if (!adapter || !endpointKey || !groupId) {
|
|
478
459
|
return { error: '$adapter, $endpoint, $group_id required' };
|
|
479
460
|
}
|
|
480
|
-
|
|
481
|
-
if ('error' in resolved)
|
|
482
|
-
return resolved;
|
|
483
|
-
const { management } = resolved;
|
|
484
|
-
try {
|
|
461
|
+
return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
|
|
485
462
|
const method = management.listGroupMembers;
|
|
486
463
|
if (!method) {
|
|
487
464
|
return { error: `当前适配器(${adapter})不支持群成员列表` };
|
|
488
465
|
}
|
|
489
466
|
const members = [...await method(groupId)];
|
|
490
467
|
return { data: { members, count: members.length } };
|
|
491
|
-
}
|
|
492
|
-
catch (error) {
|
|
493
|
-
return { error: errorMessage(error) };
|
|
494
|
-
}
|
|
468
|
+
});
|
|
495
469
|
}
|
|
496
470
|
async function groupWriteOp(d, ctx, spec) {
|
|
497
471
|
const adapter = strField(d, '$adapter', 'adapter');
|
|
@@ -504,15 +478,11 @@ async function groupWriteOp(d, ctx, spec) {
|
|
|
504
478
|
if (spec.requireUser && !userId) {
|
|
505
479
|
return { error: '$user_id required' };
|
|
506
480
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
if (!method) {
|
|
513
|
-
return { error: `当前适配器(${adapter})不支持${spec.unsupported}` };
|
|
514
|
-
}
|
|
515
|
-
try {
|
|
481
|
+
return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
|
|
482
|
+
const method = management[spec.method];
|
|
483
|
+
if (!method) {
|
|
484
|
+
return { error: `当前适配器(${adapter})不支持${spec.unsupported}` };
|
|
485
|
+
}
|
|
516
486
|
const durationRaw = d.$duration ?? d.duration;
|
|
517
487
|
const enableRaw = d.$enable ?? d.enable;
|
|
518
488
|
const args = spec.buildArgs(groupId, userId, {
|
|
@@ -523,10 +493,7 @@ async function groupWriteOp(d, ctx, spec) {
|
|
|
523
493
|
});
|
|
524
494
|
await method.call(management, ...args);
|
|
525
495
|
return { data: { success: true } };
|
|
526
|
-
}
|
|
527
|
-
catch (error) {
|
|
528
|
-
return { error: errorMessage(error) };
|
|
529
|
-
}
|
|
496
|
+
});
|
|
530
497
|
}
|
|
531
498
|
async function deleteFriend(d, ctx) {
|
|
532
499
|
const adapter = strField(d, '$adapter', 'adapter');
|
|
@@ -535,21 +502,14 @@ async function deleteFriend(d, ctx) {
|
|
|
535
502
|
if (!adapter || !endpointKey || !userId) {
|
|
536
503
|
return { error: '$adapter, $endpoint, $user_id required' };
|
|
537
504
|
}
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
if (!method) {
|
|
544
|
-
return { error: '当前适配器暂不支持删除好友' };
|
|
545
|
-
}
|
|
546
|
-
try {
|
|
505
|
+
return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
|
|
506
|
+
const method = management.deleteFriend;
|
|
507
|
+
if (!method) {
|
|
508
|
+
return { error: '当前适配器暂不支持删除好友' };
|
|
509
|
+
}
|
|
547
510
|
await method(userId);
|
|
548
511
|
return { data: { success: true } };
|
|
549
|
-
}
|
|
550
|
-
catch (error) {
|
|
551
|
-
return { error: errorMessage(error) };
|
|
552
|
-
}
|
|
512
|
+
});
|
|
553
513
|
}
|
|
554
514
|
// ---------------------------------------------------------------- helpers
|
|
555
515
|
function strField(d, ...keys) {
|
|
@@ -592,35 +552,24 @@ function numArrayField(d, ...keys) {
|
|
|
592
552
|
}
|
|
593
553
|
return [];
|
|
594
554
|
}
|
|
595
|
-
function
|
|
596
|
-
if (ctx.
|
|
597
|
-
const management = ctx.resolveEndpointManagement(adapter, endpointKey);
|
|
598
|
-
if (!management)
|
|
599
|
-
return { error: 'endpoint not found' };
|
|
600
|
-
return { management, adapter, endpointKey };
|
|
601
|
-
}
|
|
602
|
-
if (!ctx.resolveEndpoint) {
|
|
555
|
+
async function withLiveEndpoint(ctx, adapter, endpointKey, run) {
|
|
556
|
+
if (!ctx.withEndpointManagement) {
|
|
603
557
|
return { error: 'Endpoint registry is not configured' };
|
|
604
558
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
559
|
+
try {
|
|
560
|
+
return await ctx.withEndpointManagement(adapter, endpointKey, run)
|
|
561
|
+
?? { error: 'endpoint not found' };
|
|
562
|
+
}
|
|
563
|
+
catch (error) {
|
|
564
|
+
return { error: errorMessage(error) };
|
|
608
565
|
}
|
|
609
|
-
const management = endpoint.management;
|
|
610
|
-
return {
|
|
611
|
-
management: management && typeof management === 'object'
|
|
612
|
-
? management
|
|
613
|
-
: {},
|
|
614
|
-
adapter,
|
|
615
|
-
endpointKey,
|
|
616
|
-
};
|
|
617
566
|
}
|
|
618
|
-
function
|
|
567
|
+
function requireEndpointAddress(d) {
|
|
619
568
|
const adapter = strField(d, '$adapter', 'adapter');
|
|
620
569
|
const endpointKey = strField(d, '$endpoint', 'endpointKey', 'endpoint');
|
|
621
570
|
if (!adapter || !endpointKey)
|
|
622
571
|
return { error: '$adapter and $endpoint required' };
|
|
623
|
-
return
|
|
572
|
+
return { adapter, endpointKey };
|
|
624
573
|
}
|
|
625
574
|
function normalizeParent(raw) {
|
|
626
575
|
if (!raw || typeof raw !== 'object')
|
package/lib/http-host.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type IncomingMessage, type ServerResponse } from 'node:http';
|
|
2
|
-
import {
|
|
2
|
+
import { WebSocket } from 'ws';
|
|
3
|
+
import { type GenerationAdmissionBindable } from '@zhin.js/plugin-runtime';
|
|
3
4
|
import { TokenRegistry, type AuthScope, type ScopedTokenConfig } from './token-registry.js';
|
|
4
5
|
import { type ListedRoute, type RouteMeta } from './openapi.js';
|
|
5
6
|
export declare const REMOTE_CONSOLE_ORIGIN = "https://console.zhin.dev";
|
|
@@ -37,14 +38,16 @@ export type HttpHandler = (request: IncomingMessage, response: ServerResponse, u
|
|
|
37
38
|
export interface HttpRouteRegistration {
|
|
38
39
|
(): void;
|
|
39
40
|
}
|
|
40
|
-
export interface HttpHost {
|
|
41
|
+
export interface HttpHost extends GenerationAdmissionBindable<HttpHost> {
|
|
41
42
|
ws(path: string): WsHandle;
|
|
42
43
|
route(method: string, path: string, handler: HttpHandler, meta?: RouteMeta): HttpRouteRegistration;
|
|
43
44
|
listRoutes(): readonly ListedRoute[];
|
|
44
|
-
listen(): Promise<HttpHostAddress>;
|
|
45
|
-
close(): Promise<void>;
|
|
46
45
|
get address(): HttpHostAddress | undefined;
|
|
47
46
|
get tokenRegistry(): TokenRegistry;
|
|
48
47
|
}
|
|
48
|
+
export interface ProcessHttpHost extends HttpHost {
|
|
49
|
+
listen(): Promise<HttpHostAddress>;
|
|
50
|
+
close(): Promise<void>;
|
|
51
|
+
}
|
|
49
52
|
export declare const httpHostToken: import("@zhin.js/plugin-runtime").Token<HttpHost>;
|
|
50
|
-
export declare function createHttpHost(options?: HttpHostOptions):
|
|
53
|
+
export declare function createHttpHost(options?: HttpHostOptions): ProcessHttpHost;
|
package/lib/http-host.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createServer, } from 'node:http';
|
|
2
|
-
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
3
3
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
4
|
-
import { createToken } from '@zhin.js/plugin-runtime';
|
|
4
|
+
import { createToken, generationAdmissionBinder, } from '@zhin.js/plugin-runtime';
|
|
5
5
|
import { TokenRegistry, extractBearerToken, isDemoWebSocketPath, } from './token-registry.js';
|
|
6
6
|
import { buildOpenApiDocument, } from './openapi.js';
|
|
7
7
|
import { isDemoHttpAllowed } from './console-rpc.js';
|
|
@@ -26,6 +26,7 @@ export function createHttpHost(options = {}) {
|
|
|
26
26
|
const httpRoutes = [];
|
|
27
27
|
let address;
|
|
28
28
|
let closed = false;
|
|
29
|
+
let closeResult;
|
|
29
30
|
const server = createServer((request, response) => {
|
|
30
31
|
void dispatchHttp(request, response);
|
|
31
32
|
});
|
|
@@ -78,7 +79,13 @@ export function createHttpHost(options = {}) {
|
|
|
78
79
|
}
|
|
79
80
|
const pathname = upgradePath(request.url);
|
|
80
81
|
const listeners = wsRoutes.get(pathname);
|
|
81
|
-
|
|
82
|
+
const admitted = listeners ? [...listeners].flatMap((entry) => {
|
|
83
|
+
const release = entry.admission?.acquire();
|
|
84
|
+
if (entry.admission && !release)
|
|
85
|
+
return [];
|
|
86
|
+
return [{ entry, release }];
|
|
87
|
+
}) : [];
|
|
88
|
+
if (admitted.length === 0) {
|
|
82
89
|
logger.debug(formatCompact({ op: 'ws_upgrade_unmatched', path: pathname }));
|
|
83
90
|
socket.destroy();
|
|
84
91
|
return;
|
|
@@ -87,20 +94,46 @@ export function createHttpHost(options = {}) {
|
|
|
87
94
|
if (!auth.ok) {
|
|
88
95
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
89
96
|
socket.destroy();
|
|
97
|
+
for (const item of admitted)
|
|
98
|
+
item.release?.();
|
|
90
99
|
return;
|
|
91
100
|
}
|
|
92
101
|
try {
|
|
93
102
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
103
|
+
let settled = false;
|
|
104
|
+
const retireSubscriptions = [];
|
|
105
|
+
const releaseAdmissions = () => {
|
|
106
|
+
if (settled)
|
|
107
|
+
return;
|
|
108
|
+
settled = true;
|
|
109
|
+
for (const dispose of retireSubscriptions)
|
|
110
|
+
dispose();
|
|
111
|
+
for (const item of admitted)
|
|
112
|
+
item.release?.();
|
|
113
|
+
};
|
|
114
|
+
ws.once('close', releaseAdmissions);
|
|
115
|
+
ws.once('error', releaseAdmissions);
|
|
116
|
+
for (const item of admitted) {
|
|
117
|
+
if (item.entry.admission) {
|
|
118
|
+
retireSubscriptions.push(item.entry.admission.onDeactivate(() => {
|
|
119
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
120
|
+
ws.close(1012, 'Generation retired');
|
|
121
|
+
}
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
94
125
|
const connection = Object.freeze({
|
|
95
126
|
socket: ws,
|
|
96
127
|
request,
|
|
97
128
|
authScope: auth.scope,
|
|
98
129
|
});
|
|
99
|
-
for (const
|
|
100
|
-
listener(connection);
|
|
130
|
+
for (const item of admitted)
|
|
131
|
+
item.entry.listener(connection);
|
|
101
132
|
});
|
|
102
133
|
}
|
|
103
134
|
catch (err) {
|
|
135
|
+
for (const item of admitted)
|
|
136
|
+
item.release?.();
|
|
104
137
|
logger.warn(formatCompact({
|
|
105
138
|
op: 'ws_upgrade_failed',
|
|
106
139
|
path: pathname,
|
|
@@ -178,7 +211,30 @@ export function createHttpHost(options = {}) {
|
|
|
178
211
|
applyCors(response, origin, corsOrigins);
|
|
179
212
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
180
213
|
response.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
214
|
+
const release = route.admission?.acquire();
|
|
215
|
+
if (route.admission && !release) {
|
|
216
|
+
response.writeHead(503);
|
|
217
|
+
response.end();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (!release) {
|
|
221
|
+
await route.handler(request, response, url, auth.scope);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
let released = false;
|
|
225
|
+
const releaseResponse = () => {
|
|
226
|
+
if (released)
|
|
227
|
+
return;
|
|
228
|
+
released = true;
|
|
229
|
+
response.off('finish', releaseResponse);
|
|
230
|
+
response.off('close', releaseResponse);
|
|
231
|
+
release();
|
|
232
|
+
};
|
|
233
|
+
response.once('finish', releaseResponse);
|
|
234
|
+
response.once('close', releaseResponse);
|
|
181
235
|
await route.handler(request, response, url, auth.scope);
|
|
236
|
+
if (response.writableFinished || response.destroyed)
|
|
237
|
+
releaseResponse();
|
|
182
238
|
}
|
|
183
239
|
catch (err) {
|
|
184
240
|
// 统一把请求体错误(400/413)映射回对应状态码,
|
|
@@ -219,57 +275,75 @@ export function createHttpHost(options = {}) {
|
|
|
219
275
|
return { ok: true, scope };
|
|
220
276
|
}
|
|
221
277
|
function listListedRoutes() {
|
|
222
|
-
return Object.freeze(httpRoutes
|
|
278
|
+
return Object.freeze(httpRoutes
|
|
279
|
+
.filter((route) => !route.admission || route.admission.active)
|
|
280
|
+
.map((route) => Object.freeze({
|
|
223
281
|
method: route.method,
|
|
224
282
|
pattern: route.pattern,
|
|
225
283
|
meta: route.meta,
|
|
226
284
|
})));
|
|
227
285
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
286
|
+
function registerWs(pathname, admission) {
|
|
287
|
+
const normalized = normalizePath(pathname);
|
|
288
|
+
let listeners = wsRoutes.get(normalized);
|
|
289
|
+
if (!listeners) {
|
|
290
|
+
listeners = new Set();
|
|
291
|
+
wsRoutes.set(normalized, listeners);
|
|
292
|
+
}
|
|
293
|
+
const owned = new Set();
|
|
294
|
+
return {
|
|
295
|
+
onConnection(listener) {
|
|
296
|
+
const entry = Object.freeze({ listener, admission });
|
|
297
|
+
listeners.add(entry);
|
|
298
|
+
owned.add(entry);
|
|
299
|
+
return () => {
|
|
300
|
+
listeners.delete(entry);
|
|
301
|
+
owned.delete(entry);
|
|
302
|
+
};
|
|
303
|
+
},
|
|
304
|
+
close() {
|
|
305
|
+
for (const entry of owned)
|
|
306
|
+
listeners.delete(entry);
|
|
307
|
+
owned.clear();
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function registerRoute(method, path, handler, meta, admission) {
|
|
312
|
+
const normalizedMethod = method.toUpperCase();
|
|
313
|
+
const prefix = path.endsWith('/*');
|
|
314
|
+
const normalizedPath = normalizePath(prefix ? path.slice(0, -2) : path);
|
|
315
|
+
const pattern = prefix ? `${normalizedPath}/*` : normalizedPath;
|
|
316
|
+
const entry = Object.freeze({
|
|
317
|
+
method: normalizedMethod,
|
|
318
|
+
path: normalizedPath,
|
|
319
|
+
pattern,
|
|
320
|
+
prefix,
|
|
321
|
+
handler,
|
|
322
|
+
meta,
|
|
323
|
+
admission,
|
|
324
|
+
});
|
|
325
|
+
httpRoutes.push(entry);
|
|
326
|
+
return () => {
|
|
327
|
+
const index = httpRoutes.indexOf(entry);
|
|
328
|
+
if (index >= 0)
|
|
329
|
+
httpRoutes.splice(index, 1);
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
function createBoundHost(admission) {
|
|
333
|
+
const bound = {
|
|
334
|
+
[generationAdmissionBinder]: (next) => createBoundHost(next),
|
|
335
|
+
ws: (path) => registerWs(path, admission),
|
|
336
|
+
route: (method, path, handler, meta) => registerRoute(method, path, handler, meta, admission),
|
|
337
|
+
listRoutes: () => listListedRoutes(),
|
|
338
|
+
get address() { return runtime.address; },
|
|
339
|
+
get tokenRegistry() { return tokenRegistry; },
|
|
340
|
+
};
|
|
341
|
+
return Object.freeze(bound);
|
|
342
|
+
}
|
|
343
|
+
const runtime = {
|
|
344
|
+
[generationAdmissionBinder]: (admission) => createBoundHost(admission),
|
|
345
|
+
ws: (pathname) => registerWs(pathname),
|
|
346
|
+
route: (method, path, handler, meta) => registerRoute(method, path, handler, meta),
|
|
273
347
|
listRoutes() {
|
|
274
348
|
return listListedRoutes();
|
|
275
349
|
},
|
|
@@ -302,17 +376,18 @@ export function createHttpHost(options = {}) {
|
|
|
302
376
|
});
|
|
303
377
|
},
|
|
304
378
|
close() {
|
|
305
|
-
if (
|
|
306
|
-
return
|
|
379
|
+
if (closeResult)
|
|
380
|
+
return closeResult;
|
|
307
381
|
closed = true;
|
|
308
382
|
wsRoutes.clear();
|
|
309
383
|
httpRoutes.length = 0;
|
|
310
384
|
// Rollback paths dispose a host whose generation never reached listen().
|
|
311
385
|
if (!server.listening) {
|
|
312
386
|
address = undefined;
|
|
313
|
-
|
|
387
|
+
closeResult = Promise.resolve();
|
|
388
|
+
return closeResult;
|
|
314
389
|
}
|
|
315
|
-
|
|
390
|
+
closeResult = new Promise((resolve, reject) => {
|
|
316
391
|
// Long-lived connections (SSE, keep-alive, WS) never end on their own:
|
|
317
392
|
// destroy tracked sockets and terminate WS clients before close(),
|
|
318
393
|
// otherwise the close callbacks would never fire.
|
|
@@ -337,8 +412,10 @@ export function createHttpHost(options = {}) {
|
|
|
337
412
|
});
|
|
338
413
|
});
|
|
339
414
|
});
|
|
415
|
+
return closeResult;
|
|
340
416
|
},
|
|
341
417
|
};
|
|
418
|
+
return runtime;
|
|
342
419
|
}
|
|
343
420
|
function requiresHttpAuth(pathname, apiBase, authExempt) {
|
|
344
421
|
if (pathname === '/pub' || pathname.startsWith('/pub/'))
|
|
@@ -378,6 +455,8 @@ function matchHttpRoute(routes, method, pathname) {
|
|
|
378
455
|
let best;
|
|
379
456
|
let bestLength = -1;
|
|
380
457
|
for (const route of routes) {
|
|
458
|
+
if (route.admission && !route.admission.active)
|
|
459
|
+
continue;
|
|
381
460
|
if (route.method !== method && route.method !== 'ALL')
|
|
382
461
|
continue;
|
|
383
462
|
if (route.prefix) {
|
package/lib/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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';
|
|
1
|
+
export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type ProcessHttpHost, type HttpHostAddress, type HttpHostOptions, type HttpRouteRegistration, type WsConnection, type WsHandle, } from './http-host.js';
|
|
2
2
|
export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, type AuthScope, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
|
|
3
3
|
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
4
4
|
export { createConsoleEventHub, type ConsoleEventHub, } from './console-events.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/host-http",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
4
4
|
"description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"@zhin.js/console-protocol": "1.1.1",
|
|
22
22
|
"@zhin.js/host-http-contract": "1.0.1",
|
|
23
23
|
"@zhin.js/logger": "1.0.76",
|
|
24
|
-
"@zhin.js/plugin-runtime": "1.1.
|
|
24
|
+
"@zhin.js/plugin-runtime": "1.1.6"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/node": "^26.1.2",
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* - inbox:`ctx.databaseHost.models`(unified_inbox_message/request/notice 三张表,
|
|
12
12
|
* 表不存在或查询失败 → 空数组 + inboxEnabled:false;requestConsumed/noticeConsumed
|
|
13
13
|
* 按行 id update consumed=1,表/model 缺失时报"未接线")。
|
|
14
|
-
* - 社交/群管:`ctx.
|
|
15
|
-
* `management` 语义端口;平台 SDK
|
|
14
|
+
* - 社交/群管:`ctx.withEndpointManagement` 在固定 generation lease 内
|
|
15
|
+
* 消费 `management` 语义端口;平台 SDK 字段归一化由 Adapter 负责。
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
/** 新 Runtime 统一收件箱表名(SSOT:@zhin.js/plugin-runtime inbox.js)。 */
|
|
@@ -31,13 +31,12 @@ export interface ConsoleRpcExtendedCtx {
|
|
|
31
31
|
projectRoot: string;
|
|
32
32
|
/** Plugin Runtime ScheduleHost(basic/cli schedule-host-installer 提供)。 */
|
|
33
33
|
scheduleHost?: unknown;
|
|
34
|
-
/**
|
|
35
|
-
|
|
34
|
+
/** 在固定 generation lease 内执行一次 Endpoint 管理操作。 */
|
|
35
|
+
withEndpointManagement?: <T>(
|
|
36
36
|
adapter: string,
|
|
37
37
|
endpointKey: string,
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
resolveEndpoint?: (adapter: string, endpointKey: string) => unknown;
|
|
38
|
+
run: (management: EndpointManagementPort) => T | Promise<T>,
|
|
39
|
+
) => Promise<T | null>;
|
|
41
40
|
/** Plugin Runtime DatabaseHost 的 models 视图。 */
|
|
42
41
|
databaseHost?: { models: { get(name: string): unknown } };
|
|
43
42
|
/** Agent Host 持久化调度引擎(@zhin.js/agent getAssistantRuntime().engine),未 init 时返回 null。 */
|
|
@@ -530,26 +529,21 @@ async function actOnRequest(
|
|
|
530
529
|
if (!adapter || !endpointKey || !requestId) {
|
|
531
530
|
return { error: '$adapter, $endpoint, $id required' };
|
|
532
531
|
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
};
|
|
543
|
-
}
|
|
544
|
-
try {
|
|
532
|
+
return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
|
|
533
|
+
const approve = type === 'request.approve';
|
|
534
|
+
const method = approve ? management.approveRequest : management.rejectRequest;
|
|
535
|
+
if (!method) {
|
|
536
|
+
return {
|
|
537
|
+
error: `请求审批未接线:当前平台(${adapter})endpoint 不支持 ${approve ? 'approveRequest' : 'rejectRequest'},` +
|
|
538
|
+
'且新 Runtime 尚未挂载 pending request 注册表',
|
|
539
|
+
};
|
|
540
|
+
}
|
|
545
541
|
const extra = approve
|
|
546
542
|
? strField(d, '$remark', 'remark')
|
|
547
543
|
: strField(d, '$reason', 'reason');
|
|
548
544
|
await method.call(management, requestId, extra || undefined);
|
|
549
545
|
return { data: { success: true } };
|
|
550
|
-
}
|
|
551
|
-
return { error: errorMessage(error) };
|
|
552
|
-
}
|
|
546
|
+
});
|
|
553
547
|
}
|
|
554
548
|
|
|
555
549
|
// ---------------------------------------------------------------- 社交读取
|
|
@@ -558,54 +552,45 @@ async function listFriends(
|
|
|
558
552
|
d: Record<string, unknown>,
|
|
559
553
|
ctx: ConsoleRpcExtendedCtx,
|
|
560
554
|
): Promise<ExtendedRpcResult> {
|
|
561
|
-
const
|
|
562
|
-
if ('error' in
|
|
563
|
-
|
|
564
|
-
try {
|
|
555
|
+
const endpoint = requireEndpointAddress(d);
|
|
556
|
+
if ('error' in endpoint) return endpoint;
|
|
557
|
+
return withLiveEndpoint(ctx, endpoint.adapter, endpoint.endpointKey, async (management) => {
|
|
565
558
|
if (management.listFriends) {
|
|
566
559
|
const friends = [...await management.listFriends()];
|
|
567
560
|
return { data: { friends, count: friends.length } };
|
|
568
561
|
}
|
|
569
|
-
return { error: `当前适配器(${adapter})不支持好友列表` };
|
|
570
|
-
}
|
|
571
|
-
return { error: errorMessage(error) };
|
|
572
|
-
}
|
|
562
|
+
return { error: `当前适配器(${endpoint.adapter})不支持好友列表` };
|
|
563
|
+
});
|
|
573
564
|
}
|
|
574
565
|
|
|
575
566
|
async function listGroups(
|
|
576
567
|
d: Record<string, unknown>,
|
|
577
568
|
ctx: ConsoleRpcExtendedCtx,
|
|
578
569
|
): Promise<ExtendedRpcResult> {
|
|
579
|
-
const
|
|
580
|
-
if ('error' in
|
|
581
|
-
|
|
582
|
-
try {
|
|
570
|
+
const endpoint = requireEndpointAddress(d);
|
|
571
|
+
if ('error' in endpoint) return endpoint;
|
|
572
|
+
return withLiveEndpoint(ctx, endpoint.adapter, endpoint.endpointKey, async (management) => {
|
|
583
573
|
if (management.listGroups) {
|
|
584
574
|
const groups = [...await management.listGroups()];
|
|
585
575
|
return { data: { groups, count: groups.length } };
|
|
586
576
|
}
|
|
587
|
-
return { error: `当前适配器(${adapter})不支持群列表` };
|
|
588
|
-
}
|
|
589
|
-
return { error: errorMessage(error) };
|
|
590
|
-
}
|
|
577
|
+
return { error: `当前适配器(${endpoint.adapter})不支持群列表` };
|
|
578
|
+
});
|
|
591
579
|
}
|
|
592
580
|
|
|
593
581
|
async function listChannels(
|
|
594
582
|
d: Record<string, unknown>,
|
|
595
583
|
ctx: ConsoleRpcExtendedCtx,
|
|
596
584
|
): Promise<ExtendedRpcResult> {
|
|
597
|
-
const
|
|
598
|
-
if ('error' in
|
|
599
|
-
|
|
600
|
-
try {
|
|
585
|
+
const endpoint = requireEndpointAddress(d);
|
|
586
|
+
if ('error' in endpoint) return endpoint;
|
|
587
|
+
return withLiveEndpoint(ctx, endpoint.adapter, endpoint.endpointKey, async (management) => {
|
|
601
588
|
if (management.listChannels) {
|
|
602
589
|
const channels = [...await management.listChannels()];
|
|
603
590
|
return { data: { channels, count: channels.length } };
|
|
604
591
|
}
|
|
605
|
-
return { error: `当前适配器(${adapter})不支持频道列表` };
|
|
606
|
-
}
|
|
607
|
-
return { error: errorMessage(error) };
|
|
608
|
-
}
|
|
592
|
+
return { error: `当前适配器(${endpoint.adapter})不支持频道列表` };
|
|
593
|
+
});
|
|
609
594
|
}
|
|
610
595
|
|
|
611
596
|
async function listGroupMembers(
|
|
@@ -618,19 +603,14 @@ async function listGroupMembers(
|
|
|
618
603
|
if (!adapter || !endpointKey || !groupId) {
|
|
619
604
|
return { error: '$adapter, $endpoint, $group_id required' };
|
|
620
605
|
}
|
|
621
|
-
|
|
622
|
-
if ('error' in resolved) return resolved;
|
|
623
|
-
const { management } = resolved;
|
|
624
|
-
try {
|
|
606
|
+
return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
|
|
625
607
|
const method = management.listGroupMembers;
|
|
626
608
|
if (!method) {
|
|
627
609
|
return { error: `当前适配器(${adapter})不支持群成员列表` };
|
|
628
610
|
}
|
|
629
611
|
const members = [...await method(groupId)];
|
|
630
612
|
return { data: { members, count: members.length } };
|
|
631
|
-
}
|
|
632
|
-
return { error: errorMessage(error) };
|
|
633
|
-
}
|
|
613
|
+
});
|
|
634
614
|
}
|
|
635
615
|
|
|
636
616
|
// ---------------------------------------------------------------- 群管/好友写操作
|
|
@@ -658,14 +638,11 @@ async function groupWriteOp(
|
|
|
658
638
|
if (spec.requireUser && !userId) {
|
|
659
639
|
return { error: '$user_id required' };
|
|
660
640
|
}
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
return { error: `当前适配器(${adapter})不支持${spec.unsupported}` };
|
|
667
|
-
}
|
|
668
|
-
try {
|
|
641
|
+
return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
|
|
642
|
+
const method = management[spec.method];
|
|
643
|
+
if (!method) {
|
|
644
|
+
return { error: `当前适配器(${adapter})不支持${spec.unsupported}` };
|
|
645
|
+
}
|
|
669
646
|
const durationRaw = d.$duration ?? d.duration;
|
|
670
647
|
const enableRaw = d.$enable ?? d.enable;
|
|
671
648
|
const args = spec.buildArgs(groupId, userId, {
|
|
@@ -676,9 +653,7 @@ async function groupWriteOp(
|
|
|
676
653
|
});
|
|
677
654
|
await (method as (...args: unknown[]) => Promise<void>).call(management, ...args);
|
|
678
655
|
return { data: { success: true } };
|
|
679
|
-
}
|
|
680
|
-
return { error: errorMessage(error) };
|
|
681
|
-
}
|
|
656
|
+
});
|
|
682
657
|
}
|
|
683
658
|
|
|
684
659
|
async function deleteFriend(
|
|
@@ -691,19 +666,14 @@ async function deleteFriend(
|
|
|
691
666
|
if (!adapter || !endpointKey || !userId) {
|
|
692
667
|
return { error: '$adapter, $endpoint, $user_id required' };
|
|
693
668
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
return { error: '当前适配器暂不支持删除好友' };
|
|
700
|
-
}
|
|
701
|
-
try {
|
|
669
|
+
return withLiveEndpoint(ctx, adapter, endpointKey, async (management) => {
|
|
670
|
+
const method = management.deleteFriend;
|
|
671
|
+
if (!method) {
|
|
672
|
+
return { error: '当前适配器暂不支持删除好友' };
|
|
673
|
+
}
|
|
702
674
|
await method(userId);
|
|
703
675
|
return { data: { success: true } };
|
|
704
|
-
}
|
|
705
|
-
return { error: errorMessage(error) };
|
|
706
|
-
}
|
|
676
|
+
});
|
|
707
677
|
}
|
|
708
678
|
|
|
709
679
|
// ---------------------------------------------------------------- helpers
|
|
@@ -746,47 +716,30 @@ function numArrayField(d: Record<string, unknown>, ...keys: string[]): number[]
|
|
|
746
716
|
return [];
|
|
747
717
|
}
|
|
748
718
|
|
|
749
|
-
|
|
750
|
-
management: EndpointManagementPort;
|
|
751
|
-
adapter: string;
|
|
752
|
-
endpointKey: string;
|
|
753
|
-
};
|
|
754
|
-
|
|
755
|
-
function resolveLiveEndpoint(
|
|
719
|
+
async function withLiveEndpoint(
|
|
756
720
|
ctx: ConsoleRpcExtendedCtx,
|
|
757
721
|
adapter: string,
|
|
758
722
|
endpointKey: string,
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
if (!management) return { error: 'endpoint not found' };
|
|
763
|
-
return { management, adapter, endpointKey };
|
|
764
|
-
}
|
|
765
|
-
if (!ctx.resolveEndpoint) {
|
|
723
|
+
run: (management: EndpointManagementPort) => ExtendedRpcResult | Promise<ExtendedRpcResult>,
|
|
724
|
+
): Promise<ExtendedRpcResult> {
|
|
725
|
+
if (!ctx.withEndpointManagement) {
|
|
766
726
|
return { error: 'Endpoint registry is not configured' };
|
|
767
727
|
}
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
728
|
+
try {
|
|
729
|
+
return await ctx.withEndpointManagement(adapter, endpointKey, run)
|
|
730
|
+
?? { error: 'endpoint not found' };
|
|
731
|
+
} catch (error) {
|
|
732
|
+
return { error: errorMessage(error) };
|
|
771
733
|
}
|
|
772
|
-
const management = (endpoint as { management?: unknown }).management;
|
|
773
|
-
return {
|
|
774
|
-
management: management && typeof management === 'object'
|
|
775
|
-
? management as EndpointManagementPort
|
|
776
|
-
: {},
|
|
777
|
-
adapter,
|
|
778
|
-
endpointKey,
|
|
779
|
-
};
|
|
780
734
|
}
|
|
781
735
|
|
|
782
|
-
function
|
|
736
|
+
function requireEndpointAddress(
|
|
783
737
|
d: Record<string, unknown>,
|
|
784
|
-
|
|
785
|
-
): ResolveOk | { error: string } {
|
|
738
|
+
): { adapter: string; endpointKey: string } | { error: string } {
|
|
786
739
|
const adapter = strField(d, '$adapter', 'adapter');
|
|
787
740
|
const endpointKey = strField(d, '$endpoint', 'endpointKey', 'endpoint');
|
|
788
741
|
if (!adapter || !endpointKey) return { error: '$adapter and $endpoint required' };
|
|
789
|
-
return
|
|
742
|
+
return { adapter, endpointKey };
|
|
790
743
|
}
|
|
791
744
|
|
|
792
745
|
function normalizeParent(raw: unknown): { type: 'group' | 'guild'; id: string; name?: string } | undefined {
|
package/src/http-host.ts
CHANGED
|
@@ -5,9 +5,14 @@ import {
|
|
|
5
5
|
type ServerResponse,
|
|
6
6
|
} from 'node:http';
|
|
7
7
|
import type { Socket } from 'node:net';
|
|
8
|
-
import {
|
|
8
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
9
9
|
import { formatCompact, getLogger } from '@zhin.js/logger';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
createToken,
|
|
12
|
+
generationAdmissionBinder,
|
|
13
|
+
type GenerationAdmissionBindable,
|
|
14
|
+
type GenerationAdmissionGate,
|
|
15
|
+
} from '@zhin.js/plugin-runtime';
|
|
11
16
|
import {
|
|
12
17
|
TokenRegistry,
|
|
13
18
|
extractBearerToken,
|
|
@@ -74,7 +79,7 @@ export interface HttpRouteRegistration {
|
|
|
74
79
|
(): void;
|
|
75
80
|
}
|
|
76
81
|
|
|
77
|
-
export interface HttpHost {
|
|
82
|
+
export interface HttpHost extends GenerationAdmissionBindable<HttpHost> {
|
|
78
83
|
ws(path: string): WsHandle;
|
|
79
84
|
route(
|
|
80
85
|
method: string,
|
|
@@ -83,12 +88,15 @@ export interface HttpHost {
|
|
|
83
88
|
meta?: RouteMeta,
|
|
84
89
|
): HttpRouteRegistration;
|
|
85
90
|
listRoutes(): readonly ListedRoute[];
|
|
86
|
-
listen(): Promise<HttpHostAddress>;
|
|
87
|
-
close(): Promise<void>;
|
|
88
91
|
get address(): HttpHostAddress | undefined;
|
|
89
92
|
get tokenRegistry(): TokenRegistry;
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
export interface ProcessHttpHost extends HttpHost {
|
|
96
|
+
listen(): Promise<HttpHostAddress>;
|
|
97
|
+
close(): Promise<void>;
|
|
98
|
+
}
|
|
99
|
+
|
|
92
100
|
interface HttpRoute {
|
|
93
101
|
readonly method: string;
|
|
94
102
|
readonly path: string;
|
|
@@ -96,11 +104,17 @@ interface HttpRoute {
|
|
|
96
104
|
readonly prefix: boolean;
|
|
97
105
|
readonly handler: HttpHandler;
|
|
98
106
|
readonly meta?: RouteMeta;
|
|
107
|
+
readonly admission?: GenerationAdmissionGate;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface WsRoute {
|
|
111
|
+
readonly listener: (connection: WsConnection) => void;
|
|
112
|
+
readonly admission?: GenerationAdmissionGate;
|
|
99
113
|
}
|
|
100
114
|
|
|
101
115
|
export const httpHostToken = createToken<HttpHost>('zhin.host.http');
|
|
102
116
|
|
|
103
|
-
export function createHttpHost(options: HttpHostOptions = {}):
|
|
117
|
+
export function createHttpHost(options: HttpHostOptions = {}): ProcessHttpHost {
|
|
104
118
|
const host = options.host ?? '127.0.0.1';
|
|
105
119
|
const port = options.port ?? 8086;
|
|
106
120
|
const apiBase = normalizePath(options.apiBase ?? '/api');
|
|
@@ -114,10 +128,11 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
114
128
|
primaryToken: options.token,
|
|
115
129
|
scopedTokens: options.tokens,
|
|
116
130
|
} satisfies TokenRegistryConfig);
|
|
117
|
-
const wsRoutes = new Map<string, Set<
|
|
131
|
+
const wsRoutes = new Map<string, Set<WsRoute>>();
|
|
118
132
|
const httpRoutes: HttpRoute[] = [];
|
|
119
133
|
let address: HttpHostAddress | undefined;
|
|
120
134
|
let closed = false;
|
|
135
|
+
let closeResult: Promise<void> | undefined;
|
|
121
136
|
|
|
122
137
|
const server: Server = createServer((request, response) => {
|
|
123
138
|
void dispatchHttp(request, response);
|
|
@@ -176,7 +191,12 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
176
191
|
}
|
|
177
192
|
const pathname = upgradePath(request.url);
|
|
178
193
|
const listeners = wsRoutes.get(pathname);
|
|
179
|
-
|
|
194
|
+
const admitted = listeners ? [...listeners].flatMap((entry) => {
|
|
195
|
+
const release = entry.admission?.acquire();
|
|
196
|
+
if (entry.admission && !release) return [];
|
|
197
|
+
return [{ entry, release }];
|
|
198
|
+
}) : [];
|
|
199
|
+
if (admitted.length === 0) {
|
|
180
200
|
logger.debug(formatCompact({ op: 'ws_upgrade_unmatched', path: pathname }));
|
|
181
201
|
socket.destroy();
|
|
182
202
|
return;
|
|
@@ -185,18 +205,39 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
185
205
|
if (!auth.ok) {
|
|
186
206
|
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
187
207
|
socket.destroy();
|
|
208
|
+
for (const item of admitted) item.release?.();
|
|
188
209
|
return;
|
|
189
210
|
}
|
|
190
211
|
try {
|
|
191
212
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
213
|
+
let settled = false;
|
|
214
|
+
const retireSubscriptions: Array<() => void> = [];
|
|
215
|
+
const releaseAdmissions = () => {
|
|
216
|
+
if (settled) return;
|
|
217
|
+
settled = true;
|
|
218
|
+
for (const dispose of retireSubscriptions) dispose();
|
|
219
|
+
for (const item of admitted) item.release?.();
|
|
220
|
+
};
|
|
221
|
+
ws.once('close', releaseAdmissions);
|
|
222
|
+
ws.once('error', releaseAdmissions);
|
|
223
|
+
for (const item of admitted) {
|
|
224
|
+
if (item.entry.admission) {
|
|
225
|
+
retireSubscriptions.push(item.entry.admission.onDeactivate(() => {
|
|
226
|
+
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
|
|
227
|
+
ws.close(1012, 'Generation retired');
|
|
228
|
+
}
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
}
|
|
192
232
|
const connection = Object.freeze({
|
|
193
233
|
socket: ws,
|
|
194
234
|
request,
|
|
195
235
|
authScope: auth.scope,
|
|
196
236
|
});
|
|
197
|
-
for (const
|
|
237
|
+
for (const item of admitted) item.entry.listener(connection);
|
|
198
238
|
});
|
|
199
239
|
} catch (err) {
|
|
240
|
+
for (const item of admitted) item.release?.();
|
|
200
241
|
logger.warn(formatCompact({
|
|
201
242
|
op: 'ws_upgrade_failed',
|
|
202
243
|
path: pathname,
|
|
@@ -275,7 +316,28 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
275
316
|
if (corsOk && origin) applyCors(response, origin, corsOrigins);
|
|
276
317
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
277
318
|
response.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
319
|
+
const release = route.admission?.acquire();
|
|
320
|
+
if (route.admission && !release) {
|
|
321
|
+
response.writeHead(503);
|
|
322
|
+
response.end();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (!release) {
|
|
326
|
+
await route.handler(request, response, url, auth.scope);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
let released = false;
|
|
330
|
+
const releaseResponse = () => {
|
|
331
|
+
if (released) return;
|
|
332
|
+
released = true;
|
|
333
|
+
response.off('finish', releaseResponse);
|
|
334
|
+
response.off('close', releaseResponse);
|
|
335
|
+
release();
|
|
336
|
+
};
|
|
337
|
+
response.once('finish', releaseResponse);
|
|
338
|
+
response.once('close', releaseResponse);
|
|
278
339
|
await route.handler(request, response, url, auth.scope);
|
|
340
|
+
if (response.writableFinished || response.destroyed) releaseResponse();
|
|
279
341
|
} catch (err) {
|
|
280
342
|
// 统一把请求体错误(400/413)映射回对应状态码,
|
|
281
343
|
// 避免个别端点未捕获时退化成 500 空响应。
|
|
@@ -322,62 +384,96 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
322
384
|
}
|
|
323
385
|
|
|
324
386
|
function listListedRoutes(): readonly ListedRoute[] {
|
|
325
|
-
return Object.freeze(httpRoutes
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
387
|
+
return Object.freeze(httpRoutes
|
|
388
|
+
.filter((route) => !route.admission || route.admission.active)
|
|
389
|
+
.map((route) => Object.freeze({
|
|
390
|
+
method: route.method,
|
|
391
|
+
pattern: route.pattern,
|
|
392
|
+
meta: route.meta,
|
|
393
|
+
})));
|
|
330
394
|
}
|
|
331
395
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
396
|
+
function registerWs(
|
|
397
|
+
pathname: string,
|
|
398
|
+
admission?: GenerationAdmissionGate,
|
|
399
|
+
): WsHandle {
|
|
400
|
+
const normalized = normalizePath(pathname);
|
|
401
|
+
let listeners = wsRoutes.get(normalized);
|
|
402
|
+
if (!listeners) {
|
|
403
|
+
listeners = new Set();
|
|
404
|
+
wsRoutes.set(normalized, listeners);
|
|
405
|
+
}
|
|
406
|
+
const owned = new Set<WsRoute>();
|
|
407
|
+
return {
|
|
408
|
+
onConnection(listener) {
|
|
409
|
+
const entry = Object.freeze({ listener, admission });
|
|
410
|
+
listeners!.add(entry);
|
|
411
|
+
owned.add(entry);
|
|
412
|
+
return () => {
|
|
413
|
+
listeners!.delete(entry);
|
|
414
|
+
owned.delete(entry);
|
|
415
|
+
};
|
|
416
|
+
},
|
|
417
|
+
close() {
|
|
418
|
+
for (const entry of owned) listeners!.delete(entry);
|
|
419
|
+
owned.clear();
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
}
|
|
356
423
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
424
|
+
function registerRoute(
|
|
425
|
+
method: string,
|
|
426
|
+
path: string,
|
|
427
|
+
handler: HttpHandler,
|
|
428
|
+
meta?: RouteMeta,
|
|
429
|
+
admission?: GenerationAdmissionGate,
|
|
430
|
+
): HttpRouteRegistration {
|
|
431
|
+
const normalizedMethod = method.toUpperCase();
|
|
432
|
+
const prefix = path.endsWith('/*');
|
|
433
|
+
const normalizedPath = normalizePath(prefix ? path.slice(0, -2) : path);
|
|
434
|
+
const pattern = prefix ? `${normalizedPath}/*` : normalizedPath;
|
|
435
|
+
const entry: HttpRoute = Object.freeze({
|
|
436
|
+
method: normalizedMethod,
|
|
437
|
+
path: normalizedPath,
|
|
438
|
+
pattern,
|
|
439
|
+
prefix,
|
|
440
|
+
handler,
|
|
441
|
+
meta,
|
|
442
|
+
admission,
|
|
443
|
+
});
|
|
444
|
+
httpRoutes.push(entry);
|
|
445
|
+
return () => {
|
|
446
|
+
const index = httpRoutes.indexOf(entry);
|
|
447
|
+
if (index >= 0) httpRoutes.splice(index, 1);
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function createBoundHost(
|
|
452
|
+
admission: GenerationAdmissionGate,
|
|
453
|
+
): HttpHost {
|
|
454
|
+
const bound: HttpHost = {
|
|
455
|
+
[generationAdmissionBinder]: (next) => createBoundHost(next),
|
|
456
|
+
ws: (path) => registerWs(path, admission),
|
|
457
|
+
route: (method, path, handler, meta) => registerRoute(
|
|
458
|
+
method,
|
|
459
|
+
path,
|
|
372
460
|
handler,
|
|
373
461
|
meta,
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
462
|
+
admission,
|
|
463
|
+
),
|
|
464
|
+
listRoutes: () => listListedRoutes(),
|
|
465
|
+
get address() { return runtime.address; },
|
|
466
|
+
get tokenRegistry() { return tokenRegistry; },
|
|
467
|
+
};
|
|
468
|
+
return Object.freeze(bound);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const runtime: ProcessHttpHost = {
|
|
472
|
+
[generationAdmissionBinder]: (admission) => createBoundHost(admission),
|
|
473
|
+
|
|
474
|
+
ws: (pathname) => registerWs(pathname),
|
|
475
|
+
|
|
476
|
+
route: (method, path, handler, meta) => registerRoute(method, path, handler, meta),
|
|
381
477
|
|
|
382
478
|
listRoutes(): readonly ListedRoute[] {
|
|
383
479
|
return listListedRoutes();
|
|
@@ -418,16 +514,17 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
418
514
|
},
|
|
419
515
|
|
|
420
516
|
close(): Promise<void> {
|
|
421
|
-
if (
|
|
517
|
+
if (closeResult) return closeResult;
|
|
422
518
|
closed = true;
|
|
423
519
|
wsRoutes.clear();
|
|
424
520
|
httpRoutes.length = 0;
|
|
425
521
|
// Rollback paths dispose a host whose generation never reached listen().
|
|
426
522
|
if (!server.listening) {
|
|
427
523
|
address = undefined;
|
|
428
|
-
|
|
524
|
+
closeResult = Promise.resolve();
|
|
525
|
+
return closeResult;
|
|
429
526
|
}
|
|
430
|
-
|
|
527
|
+
closeResult = new Promise((resolve, reject) => {
|
|
431
528
|
// Long-lived connections (SSE, keep-alive, WS) never end on their own:
|
|
432
529
|
// destroy tracked sockets and terminate WS clients before close(),
|
|
433
530
|
// otherwise the close callbacks would never fire.
|
|
@@ -450,8 +547,10 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
450
547
|
});
|
|
451
548
|
});
|
|
452
549
|
});
|
|
550
|
+
return closeResult;
|
|
453
551
|
},
|
|
454
552
|
};
|
|
553
|
+
return runtime;
|
|
455
554
|
}
|
|
456
555
|
|
|
457
556
|
function requiresHttpAuth(
|
|
@@ -507,6 +606,7 @@ function matchHttpRoute(routes: readonly HttpRoute[], method: string, pathname:
|
|
|
507
606
|
let best: HttpRoute | undefined;
|
|
508
607
|
let bestLength = -1;
|
|
509
608
|
for (const route of routes) {
|
|
609
|
+
if (route.admission && !route.admission.active) continue;
|
|
510
610
|
if (route.method !== method && route.method !== 'ALL') continue;
|
|
511
611
|
if (route.prefix) {
|
|
512
612
|
const matches = route.path === '/'
|